krz/hutch

an ios client for sourcehut

clone: git clone https://gitbay.org/krz/hutch.git

441b69afae30ee6b662be38004fd7b5de1e47302

verified · cmc

author: Christian Cleberg <hello@cleberg.net> · 2026-03-19T06:17:49Z

feat: add support for reading inbox messages (emails) and replying in-app
 Hutch/App/AppState.swift                 |  14 +
 Hutch/App/RootView.swift                 |  42 +-
 Hutch/Models/Inbox.swift                 | 187 ++++++++
 Hutch/Views/Home/HomeView.swift          |  25 +-
 Hutch/Views/Inbox/InboxView.swift        | 107 +++++
 Hutch/Views/Inbox/InboxViewModel.swift   | 335 +++++++++++++++
 Hutch/Views/Inbox/ThreadDetailView.swift | 324 ++++++++++++++
 Hutch/Views/Inbox/ThreadViewModel.swift  | 702 +++++++++++++++++++++++++++++++
 Hutch/Views/Repositories/DiffView.swift  |  22 +-
 HutchTests/InboxViewModelTests.swift     | 168 ++++++++
 10 files changed, 1897 insertions(+), 29 deletions(-)

diff --git a/Hutch/App/AppState.swift b/Hutch/App/AppState.swift
index 624c78d..7492fa6 100644
--- a/Hutch/App/AppState.swift
+++ b/Hutch/App/AppState.swift
@@ -7,6 +7,15 @@ import WebKit
 @MainActor
 final class AppState {
 
+    enum Tab: Hashable {
+        case home
+        case inbox
+        case repositories
+        case builds
+        case tickets
+        case settings
+    }
+
     enum AuthPhase {
         /// App just launched, checking for an existing token.
         case launching
@@ -25,6 +34,8 @@ final class AppState {
         authPhase == .authenticated && currentUser != nil
     }
 
+    var selectedTab: Tab = .home
+
     // MARK: - Current user (populated after successful validation)
 
     private(set) var currentUser: User?
@@ -94,6 +105,7 @@ final class AppState {
         await clearWebData()
         clearWebContentRenderCaches()
         authPhase = .unauthenticated
+        selectedTab = .home
     }
 
     func resetAppData() async {
@@ -108,6 +120,7 @@ final class AppState {
         clearWebContentRenderCaches()
 
         authPhase = .unauthenticated
+        selectedTab = .home
     }
 
     // MARK: - Deep link resolution
@@ -208,6 +221,7 @@ final class AppState {
         client.responseCache.clear()
         currentUser = nil
         pendingDeepLink = nil
+        selectedTab = .home
     }
 
     private func clearWebData() async {
diff --git a/Hutch/App/RootView.swift b/Hutch/App/RootView.swift
index 79411ed..36cbe5c 100644
--- a/Hutch/App/RootView.swift
+++ b/Hutch/App/RootView.swift
@@ -4,17 +4,8 @@ import SwiftUI
 /// full-screen sheet for token entry on first launch.
 struct RootView: View {
     @Environment(AppState.self) private var appState
-
-    enum Tab: Hashable {
-        case home
-        case repositories
-        case builds
-        case tickets
-        case settings
-    }
-
-    @State private var selectedTab: Tab = .home
     @State private var homePath = NavigationPath()
+    @State private var inboxPath = NavigationPath()
     @State private var repoPath = NavigationPath()
     @State private var buildsPath = NavigationPath()
     @State private var ticketsPath = NavigationPath()
@@ -48,19 +39,29 @@ struct RootView: View {
     // MARK: - Tab View
 
     private var tabContent: some View {
-        TabView(selection: $selectedTab) {
+        @Bindable var appState = appState
+
+        return TabView(selection: $appState.selectedTab) {
             NavigationStack(path: $homePath) {
                 HomeView()
             }
-            .tag(Tab.home)
+            .tag(AppState.Tab.home)
             .tabItem {
                 Label("Home", systemImage: "house")
             }
 
+            NavigationStack(path: $inboxPath) {
+                InboxView()
+            }
+            .tag(AppState.Tab.inbox)
+            .tabItem {
+                Label("Inbox", systemImage: "tray")
+            }
+
             NavigationStack(path: $repoPath) {
                 RepositoryListView()
             }
-            .tag(Tab.repositories)
+            .tag(AppState.Tab.repositories)
             .tabItem {
                 Label("Repositories", systemImage: "book.closed")
             }
@@ -73,7 +74,7 @@ struct RootView: View {
                         BuildDetailView(jobId: jobId)
                     }
             }
-            .tag(Tab.builds)
+            .tag(AppState.Tab.builds)
             .tabItem {
                 Label("Builds", systemImage: "hammer")
             }
@@ -85,13 +86,13 @@ struct RootView: View {
                         TicketDetailView(ownerUsername: target.ownerUsername, trackerName: target.trackerName, trackerId: target.trackerId, trackerRid: target.trackerRid, ticketId: target.ticketId)
                     }
             }
-            .tag(Tab.tickets)
+            .tag(AppState.Tab.tickets)
             .tabItem {
                 Label("Tickets", systemImage: "ticket")
             }
 
             SettingsView()
-                .tag(Tab.settings)
+                .tag(AppState.Tab.settings)
                 .tabItem {
                     Label("Settings", systemImage: "gear")
                 }
@@ -117,10 +118,11 @@ struct RootView: View {
             break
         case .unauthenticated:
             homePath = NavigationPath()
+            inboxPath = NavigationPath()
             repoPath = NavigationPath()
             buildsPath = NavigationPath()
             ticketsPath = NavigationPath()
-            selectedTab = .home
+            appState.selectedTab = .home
             isResolvingDeepLink = false
         case .authenticated:
             consumePendingDeepLinkIfPossible(appState.pendingDeepLink)
@@ -143,7 +145,7 @@ struct RootView: View {
         case .build(let jobId):
             // Reset the builds navigation and push the detail
             buildsPath = NavigationPath()
-            selectedTab = .builds
+            appState.selectedTab = .builds
             // Defer the push slightly so the tab switch takes effect
             Task { @MainActor in
                 try? await Task.sleep(for: .milliseconds(100))
@@ -162,7 +164,7 @@ struct RootView: View {
             do {
                 let summary = try await appState.resolveRepository(owner: owner, name: repo)
                 repoPath = NavigationPath()
-                selectedTab = .repositories
+                appState.selectedTab = .repositories
                 try? await Task.sleep(for: .milliseconds(100))
                 repoPath.append(summary)
             } catch {
@@ -178,7 +180,7 @@ struct RootView: View {
             do {
                 let trackerSummary = try await appState.resolveTracker(owner: owner, name: tracker)
                 ticketsPath = NavigationPath()
-                selectedTab = .tickets
+                appState.selectedTab = .tickets
                 try? await Task.sleep(for: .milliseconds(100))
                 ticketsPath.append(trackerSummary)
                 try? await Task.sleep(for: .milliseconds(100))
diff --git a/Hutch/Models/Inbox.swift b/Hutch/Models/Inbox.swift
new file mode 100644
index 0000000..720c71f
--- /dev/null
+++ b/Hutch/Models/Inbox.swift
@@ -0,0 +1,187 @@
+import Foundation
+
+struct InboxThreadSummary: Identifiable, Hashable, Sendable {
+    let rootEmailID: Int
+    let rootMessageID: String
+    let threadRootEmailIDs: [Int]
+    let threadRootMessageIDs: [String]
+    let listID: Int
+    let listRID: String
+    let listName: String
+    let listOwner: Entity
+    let subject: String
+    let latestSender: Entity
+    let lastActivityAt: Date
+    let messageCount: Int?
+    let repo: String?
+    let containsPatch: Bool
+    let isUnread: Bool
+
+    var id: String {
+        threadGroupingKey
+    }
+
+    var listDisplayName: String {
+        "\(listOwner.canonicalName)/\(listName)"
+    }
+
+    var displaySubject: String {
+        Self.normalizedSubject(from: subject)
+    }
+
+    var metadataLine: String {
+        var parts = [latestSenderDisplayName]
+        if let messageCount, messageCount > 1 {
+            let replyCount = max(messageCount - 1, 1)
+            parts.append("\(replyCount) repl\(replyCount == 1 ? "y" : "ies")")
+        }
+        parts.append(lastActivityAt.relativeDescription)
+        return parts.joined(separator: " • ")
+    }
+
+    var latestSenderDisplayName: String {
+        let canonicalName = latestSender.canonicalName.trimmingCharacters(in: .whitespacesAndNewlines)
+        if canonicalName.contains("@") {
+            return canonicalName
+        }
+        return canonicalName
+    }
+
+    var debugIdentifierSummary: String {
+        "subject=\(subject) listRID=\(listRID) listID=\(listID) rootEmailID=\(rootEmailID) rootMessageID=\(rootMessageID) groupingKey=\(threadGroupingKey)"
+    }
+
+    var threadGroupingKey: String {
+        "\(listRID)#\(displaySubject.lowercased())"
+    }
+
+    private static func normalizedSubject(from subject: String) -> String {
+        let collapsedWhitespace = subject
+            .replacingOccurrences(of: #"\s+"#, with: " ", options: .regularExpression)
+            .trimmingCharacters(in: .whitespacesAndNewlines)
+
+        let pattern = #"^(?:(?:re|fwd?)\s*:\s*)+"#
+        return collapsedWhitespace.replacingOccurrences(
+            of: pattern,
+            with: "",
+            options: [.regularExpression, .caseInsensitive]
+        )
+    }
+}
+
+struct InboxMessage: Identifiable, Hashable, Sendable {
+    let id: Int
+    let author: Entity
+    let date: Date
+    let subject: String
+    let body: String
+    let senderDisplayName: String
+    let senderEmailAddress: String?
+    let isPatch: Bool
+    let contentBlocks: [InboxMessageContentBlock]
+    let rawMessageURL: URL?
+}
+
+enum InboxMessageContentBlock: Hashable, Sendable {
+    case plainText(String)
+    case diff(String)
+}
+
+struct InboxThreadDetail: Sendable {
+    let id: String
+    let rootEmailID: Int
+    let rootMessageID: String
+    let subject: String
+    let author: Entity
+    let lastActivityAt: Date
+    let mailto: String?
+    let listID: Int
+    let listRID: String
+    let listName: String
+    let listOwner: Entity
+    let messageCount: Int?
+    let messages: [InboxMessage]
+
+    var listDisplayName: String {
+        "\(listOwner.canonicalName)/\(listName)"
+    }
+}
+
+extension InboxThreadDetail {
+    var replyRecipient: String {
+        "\(listOwner.canonicalName)/\(listName)@lists.sr.ht"
+    }
+
+    var replySubject: String {
+        subject.lowercased().hasPrefix("re:") ? subject : "Re: \(subject)"
+    }
+
+    var displaySubject: String {
+        InboxThreadSummary(
+            rootEmailID: rootEmailID,
+            rootMessageID: rootMessageID,
+            threadRootEmailIDs: [rootEmailID],
+            threadRootMessageIDs: [rootMessageID],
+            listID: listID,
+            listRID: listRID,
+            listName: listName,
+            listOwner: listOwner,
+            subject: subject,
+            latestSender: author,
+            lastActivityAt: lastActivityAt,
+            messageCount: messageCount,
+            repo: nil,
+            containsPatch: messages.contains(where: \.isPatch),
+            isUnread: false
+        ).displaySubject
+    }
+}
+
+struct MailComposeDraft: Sendable {
+    let recipients: [String]
+    let ccRecipients: [String]
+    let subject: String
+    let body: String
+
+    var id: String {
+        ([subject] + recipients + ccRecipients).joined(separator: "|")
+    }
+}
+
+extension MailComposeDraft: Identifiable {}
+
+struct InboxMailingListReference: Decodable, Sendable, Hashable {
+    let id: Int
+    let rid: String
+    let name: String
+    let owner: Entity
+}
+
+struct InboxPatchPreview: Decodable, Sendable, Hashable {
+    let subject: String?
+}
+
+enum InboxReadStateStore {
+    private static let key = "InboxThreadLastViewed"
+
+    static func lastViewedAt(for threadID: String, defaults: UserDefaults = .standard) -> Date? {
+        guard let dictionary = defaults.dictionary(forKey: key) as? [String: TimeInterval],
+              let timestamp = dictionary[threadID] else {
+            return nil
+        }
+        return Date(timeIntervalSince1970: timestamp)
+    }
+
+    static func markViewed(_ date: Date, for threadID: String, defaults: UserDefaults = .standard) {
+        var dictionary = defaults.dictionary(forKey: key) as? [String: TimeInterval] ?? [:]
+        dictionary[threadID] = date.timeIntervalSince1970
+        defaults.set(dictionary, forKey: key)
+    }
+
+    static func isUnread(threadID: String, lastActivityAt: Date, defaults: UserDefaults = .standard) -> Bool {
+        guard let lastViewedAt = lastViewedAt(for: threadID, defaults: defaults) else {
+            return true
+        }
+        return lastActivityAt > lastViewedAt
+    }
+}
diff --git a/Hutch/Views/Home/HomeView.swift b/Hutch/Views/Home/HomeView.swift
index a72853f..1e98039 100644
--- a/Hutch/Views/Home/HomeView.swift
+++ b/Hutch/Views/Home/HomeView.swift
@@ -115,8 +115,8 @@ struct HomeView: View {
                 }
             }
         } header: {
-            HomeSectionHeader("Recent Builds") {
-                BuildListView()
+            HomeSectionActionHeader("Recent Builds") {
+                appState.selectedTab = .builds
             }
         }
     }
@@ -243,6 +243,27 @@ private struct HomeSectionHeader<Destination: View>: View {
     }
 }
 
+private struct HomeSectionActionHeader: View {
+    let title: String
+    let action: () -> Void
+
+    init(_ title: String, action: @escaping () -> Void) {
+        self.title = title
+        self.action = action
+    }
+
+    var body: some View {
+        HStack {
+            Text(title)
+            Spacer()
+            Button("See All", action: action)
+                .font(.caption.weight(.medium))
+                .buttonStyle(.plain)
+        }
+        .textCase(nil)
+    }
+}
+
 private struct HomeAssignedTicketsListView: View {
     let viewModel: HomeViewModel
 
diff --git a/Hutch/Views/Inbox/InboxView.swift b/Hutch/Views/Inbox/InboxView.swift
new file mode 100644
index 0000000..e5f3619
--- /dev/null
+++ b/Hutch/Views/Inbox/InboxView.swift
@@ -0,0 +1,107 @@
+import SwiftUI
+
+struct InboxView: View {
+    @Environment(AppState.self) private var appState
+    @State private var viewModel: InboxViewModel?
+
+    var body: some View {
+        Group {
+            if let viewModel {
+                listContent(viewModel)
+            } else {
+                SRHTLoadingStateView(message: "Loading inbox…")
+            }
+        }
+        .navigationTitle("Inbox")
+        .task {
+            if viewModel == nil {
+                let vm = InboxViewModel(client: appState.client)
+                viewModel = vm
+                await vm.loadThreads()
+            }
+        }
+    }
+
+    @ViewBuilder
+    private func listContent(_ viewModel: InboxViewModel) -> some View {
+        @Bindable var vm = viewModel
+
+        List {
+            ForEach(viewModel.threads) { thread in
+                NavigationLink(value: thread) {
+                    InboxThreadRow(thread: thread)
+                }
+            }
+        }
+        .listStyle(.plain)
+        .overlay {
+            if viewModel.isLoading, viewModel.threads.isEmpty {
+                SRHTLoadingStateView(message: "Loading inbox…")
+            } else if let error = viewModel.error, viewModel.threads.isEmpty {
+                SRHTErrorStateView(
+                    title: "Couldn't Load Threads",
+                    message: error,
+                    retryAction: { await viewModel.loadThreads() }
+                )
+            } else if viewModel.threads.isEmpty, viewModel.error == nil {
+                ContentUnavailableView(
+                    "No Threads",
+                    systemImage: "tray",
+                    description: Text("Patch threads will appear here.")
+                )
+            }
+        }
+        .connectivityOverlay(hasContent: !viewModel.threads.isEmpty) {
+            await viewModel.loadThreads()
+        }
+        .srhtErrorBanner(error: $vm.error)
+        .refreshable {
+            await viewModel.loadThreads()
+        }
+        .navigationDestination(for: InboxThreadSummary.self) { thread in
+            ThreadDetailView(thread: thread) {
+                viewModel.markThreadRead(thread)
+            }
+        }
+    }
+}
+
+private struct InboxThreadRow: View {
+    let thread: InboxThreadSummary
+
+    var body: some View {
+        HStack(alignment: .top, spacing: 12) {
+            Circle()
+                .fill(thread.isUnread ? .blue : .clear)
+                .frame(width: 8, height: 8)
+                .padding(.top, 6)
+
+            VStack(alignment: .leading, spacing: 4) {
+                Text(thread.displaySubject)
+                    .font(.subheadline.weight(thread.isUnread ? .semibold : .medium))
+                    .lineLimit(2)
+
+                HStack(spacing: 8) {
+                    if thread.containsPatch {
+                        Image(systemName: "arrow.triangle.branch")
+                            .font(.caption)
+                            .foregroundStyle(.secondary)
+                    }
+
+                    Text(thread.metadataLine)
+                        .font(.caption)
+                        .foregroundStyle(.secondary)
+                        .lineLimit(1)
+                }
+            }
+
+            Spacer(minLength: 8)
+
+            Text(thread.lastActivityAt.relativeDescription)
+                .font(.caption)
+                .foregroundStyle(.tertiary.opacity(0.7))
+                .lineLimit(1)
+        }
+        .padding(.vertical, 2)
+    }
+}
diff --git a/Hutch/Views/Inbox/InboxViewModel.swift b/Hutch/Views/Inbox/InboxViewModel.swift
new file mode 100644
index 0000000..808a5ef
--- /dev/null
+++ b/Hutch/Views/Inbox/InboxViewModel.swift
@@ -0,0 +1,335 @@
+import Foundation
+import os
+
+private let inboxListLogger = Logger(subsystem: "net.cleberg.Hutch", category: "InboxList")
+
+private struct InboxSubscriptionsResponse: Decodable, Sendable {
+    let subscriptions: InboxSubscriptionPage
+}
+
+private struct InboxSubscriptionPage: Decodable, Sendable {
+    let results: [InboxActivitySubscription]
+    let cursor: String?
+}
+
+private struct InboxActivitySubscription: Decodable, Sendable {
+    let id: Int
+    let created: Date
+    let list: InboxMailingListReference?
+
+    enum CodingKeys: String, CodingKey {
+        case id
+        case created
+        case list
+    }
+}
+
+private struct InboxListThreadsResponse: Decodable, Sendable {
+    let list: InboxMailingListThreads
+}
+
+private struct InboxMailingListThreads: Decodable, Sendable {
+    let threads: InboxThreadPage
+}
+
+private struct InboxThreadPage: Decodable, Sendable {
+    let results: [InboxThreadPayload]
+    let cursor: String?
+}
+
+private struct InboxThreadPayload: Decodable, Sendable {
+    let created: Date
+    let updated: Date
+    let subject: String
+    let replies: Int
+    let sender: Entity
+    let root: InboxEmailPreview
+}
+
+private struct InboxEmailPreview: Decodable, Sendable {
+    let id: Int
+    let subject: String
+    let date: Date?
+    let received: Date
+    let messageID: String
+    let body: String
+    let patch: InboxPatchPreview?
+}
+
+@Observable
+@MainActor
+final class InboxViewModel {
+    private(set) var threads: [InboxThreadSummary] = []
+    private(set) var isLoading = false
+    var error: String?
+
+    private let client: SRHTClient
+    private let listThreadFetchLimit = 10
+    private let listFetchConcurrencyLimit = 4
+
+    private static let subscriptionsQuery = """
+    query inboxSubscriptions($cursor: Cursor) {
+        subscriptions(cursor: $cursor) {
+            results {
+                ... on MailingListSubscription {
+                    id
+                    created
+                    list {
+                        id
+                        rid
+                        name
+                        owner { canonicalName }
+                    }
+                }
+            }
+            cursor
+        }
+    }
+    """
+
+    private static let listThreadsQuery = """
+    query inboxListThreads($rid: ID!, $cursor: Cursor) {
+        list(rid: $rid) {
+            threads(cursor: $cursor) {
+                results {
+                    created
+                    updated
+                    subject
+                    replies
+                    sender { canonicalName }
+                    root {
+                        id
+                        subject
+                        date
+                        received
+                        messageID
+                        body
+                        patch { subject }
+                    }
+                }
+                cursor
+            }
+        }
+    }
+    """
+
+    init(client: SRHTClient) {
+        self.client = client
+    }
+
+    func loadThreads() async {
+        guard !isLoading else { return }
+        isLoading = true
+        error = nil
+        defer { isLoading = false }
+
+        do {
+            let subscriptions = try await fetchSubscriptions()
+            let mailingLists = deduplicateMailingLists(subscriptions.compactMap(\.list))
+            let fetchedThreads = try await fetchThreads(for: mailingLists)
+            threads = fetchedThreads.sorted { lhs, rhs in
+                if lhs.lastActivityAt == rhs.lastActivityAt {
+                    return lhs.subject.localizedCaseInsensitiveCompare(rhs.subject) == .orderedAscending
+                }
+                return lhs.lastActivityAt > rhs.lastActivityAt
+            }
+        } catch {
+            threads = []
+            self.error = error.localizedDescription
+        }
+    }
+
+    func markThreadRead(_ thread: InboxThreadSummary) {
+        let viewedAt = max(Date(), thread.lastActivityAt)
+        InboxReadStateStore.markViewed(viewedAt, for: thread.id)
+        guard let index = threads.firstIndex(where: { $0.id == thread.id }) else { return }
+        let current = threads[index]
+        threads[index] = InboxThreadSummary(
+            rootEmailID: current.rootEmailID,
+            rootMessageID: current.rootMessageID,
+            threadRootEmailIDs: current.threadRootEmailIDs,
+            threadRootMessageIDs: current.threadRootMessageIDs,
+            listID: current.listID,
+            listRID: current.listRID,
+            listName: current.listName,
+            listOwner: current.listOwner,
+            subject: current.subject,
+            latestSender: current.latestSender,
+            lastActivityAt: current.lastActivityAt,
+            messageCount: current.messageCount,
+            repo: current.repo,
+            containsPatch: current.containsPatch,
+            isUnread: false
+        )
+    }
+
+    private func fetchSubscriptions() async throws -> [InboxActivitySubscription] {
+        var subscriptions: [InboxActivitySubscription] = []
+        var cursor: String?
+
+        while true {
+            var variables: [String: any Sendable] = [:]
+            if let cursor {
+                variables["cursor"] = cursor
+            }
+
+            let response = try await client.execute(
+                service: .lists,
+                query: Self.subscriptionsQuery,
+                variables: variables.isEmpty ? nil : variables,
+                responseType: InboxSubscriptionsResponse.self
+            )
+
+            subscriptions.append(contentsOf: response.subscriptions.results)
+            guard let nextCursor = response.subscriptions.cursor else {
+                break
+            }
+            cursor = nextCursor
+        }
+
+        return subscriptions
+    }
+
+    private func fetchThreads(for mailingLists: [InboxMailingListReference]) async throws -> [InboxThreadSummary] {
+        guard !mailingLists.isEmpty else { return [] }
+
+        var summaries: [InboxThreadSummary] = []
+        var startIndex = mailingLists.startIndex
+        var failureMessages: [String] = []
+
+        while startIndex < mailingLists.endIndex {
+            let endIndex = mailingLists.index(
+                startIndex,
+                offsetBy: listFetchConcurrencyLimit,
+                limitedBy: mailingLists.endIndex
+            ) ?? mailingLists.endIndex
+            let batch = Array(mailingLists[startIndex..<endIndex])
+
+            let batchResult = await withTaskGroup(of: ([InboxThreadSummary], String?).self) { group in
+                for mailingList in batch {
+                    group.addTask {
+                        do {
+                            return (try await self.fetchThreads(for: mailingList), nil)
+                        } catch {
+                            return ([], error.localizedDescription)
+                        }
+                    }
+                }
+
+                var batchSummaries: [InboxThreadSummary] = []
+                var batchFailures: [String] = []
+                for await result in group {
+                    batchSummaries.append(contentsOf: result.0)
+                    if let failure = result.1 {
+                        batchFailures.append(failure)
+                    }
+                }
+                return (batchSummaries, batchFailures)
+            }
+
+            summaries.append(contentsOf: batchResult.0)
+            failureMessages.append(contentsOf: batchResult.1)
+            startIndex = endIndex
+        }
+
+        if summaries.isEmpty, let firstFailure = failureMessages.first {
+            throw SRHTError.graphQLErrors([GraphQLError(message: firstFailure, locations: nil)])
+        }
+
+        return deduplicateThreads(summaries)
+    }
+
+    private func fetchThreads(for mailingList: InboxMailingListReference) async throws -> [InboxThreadSummary] {
+        let response = try await client.execute(
+            service: .lists,
+            query: Self.listThreadsQuery,
+            variables: ["rid": mailingList.rid],
+            responseType: InboxListThreadsResponse.self
+        )
+
+        return response.list.threads.results.prefix(listThreadFetchLimit).map { thread in
+            let threadID = "\(mailingList.rid)#\(thread.root.messageID)"
+            let groupingKey = "\(mailingList.rid)#\(thread.subject.replacingOccurrences(of: #"\s+"#, with: " ", options: .regularExpression).trimmingCharacters(in: .whitespacesAndNewlines).replacingOccurrences(of: #"^(?:(?:re|fwd?)\s*:\s*)+"#, with: "", options: [.regularExpression, .caseInsensitive]).lowercased())"
+            inboxListLogger.debug(
+                "Inbox thread grouping candidate: listRID=\(mailingList.rid, privacy: .public) rootMessageID=\(thread.root.messageID, privacy: .public) rootEmailID=\(thread.root.id, privacy: .public) groupingKey=\(groupingKey, privacy: .public)"
+            )
+            return InboxThreadSummary(
+                rootEmailID: thread.root.id,
+                rootMessageID: thread.root.messageID,
+                threadRootEmailIDs: [thread.root.id],
+                threadRootMessageIDs: [thread.root.messageID],
+                listID: mailingList.id,
+                listRID: mailingList.rid,
+                listName: mailingList.name,
+                listOwner: mailingList.owner,
+                subject: thread.subject,
+                latestSender: thread.sender,
+                lastActivityAt: thread.updated,
+                messageCount: thread.replies + 1,
+                repo: Self.deriveRepositoryName(from: mailingList.name),
+                containsPatch: thread.root.patch != nil || thread.subject.localizedCaseInsensitiveContains("[patch"),
+                isUnread: InboxReadStateStore.isUnread(threadID: threadID, lastActivityAt: thread.updated)
+            )
+        }
+    }
+
+    private func deduplicateThreads(_ threads: [InboxThreadSummary]) -> [InboxThreadSummary] {
+        var grouped: [String: InboxThreadSummary] = [:]
+
+        for thread in threads {
+            guard let existing = grouped[thread.threadGroupingKey] else {
+                grouped[thread.threadGroupingKey] = thread
+                continue
+            }
+
+            let latest = thread.lastActivityAt >= existing.lastActivityAt ? thread : existing
+            let mergedRootEmailIDs = Array(Set(existing.threadRootEmailIDs + thread.threadRootEmailIDs)).sorted()
+            let mergedRootMessageIDs = Array(Set(existing.threadRootMessageIDs + thread.threadRootMessageIDs)).sorted()
+            let mergedMessageCount = max(
+                existing.messageCount ?? existing.threadRootMessageIDs.count,
+                thread.messageCount ?? thread.threadRootMessageIDs.count,
+                mergedRootMessageIDs.count
+            )
+
+            grouped[thread.threadGroupingKey] = InboxThreadSummary(
+                rootEmailID: latest.rootEmailID,
+                rootMessageID: latest.rootMessageID,
+                threadRootEmailIDs: mergedRootEmailIDs,
+                threadRootMessageIDs: mergedRootMessageIDs,
+                listID: latest.listID,
+                listRID: latest.listRID,
+                listName: latest.listName,
+                listOwner: latest.listOwner,
+                subject: latest.subject,
+                latestSender: latest.latestSender,
+                lastActivityAt: max(existing.lastActivityAt, thread.lastActivityAt),
+                messageCount: mergedMessageCount,
+                repo: latest.repo ?? existing.repo,
+                containsPatch: latest.containsPatch || existing.containsPatch,
+                isUnread: latest.isUnread || existing.isUnread
+            )
+        }
+
+        return grouped.values.sorted { lhs, rhs in
+            if lhs.lastActivityAt == rhs.lastActivityAt {
+                return lhs.displaySubject.localizedCaseInsensitiveCompare(rhs.displaySubject) == .orderedAscending
+            }
+            return lhs.lastActivityAt > rhs.lastActivityAt
+        }
+    }
+
+    private func deduplicateMailingLists(_ mailingLists: [InboxMailingListReference]) -> [InboxMailingListReference] {
+        var seen = Set<String>()
+        return mailingLists.filter { mailingList in
+            seen.insert(mailingList.rid).inserted
+        }
+    }
+
+    nonisolated static func deriveRepositoryName(from listName: String) -> String? {
+        let separators = ["-devel", "-patches", "-dev", ".patches"]
+        for separator in separators where listName.hasSuffix(separator) {
+            return String(listName.dropLast(separator.count))
+        }
+        return nil
+    }
+}
diff --git a/Hutch/Views/Inbox/ThreadDetailView.swift b/Hutch/Views/Inbox/ThreadDetailView.swift
new file mode 100644
index 0000000..3c42e7f
--- /dev/null
+++ b/Hutch/Views/Inbox/ThreadDetailView.swift
@@ -0,0 +1,324 @@
+import MessageUI
+import os
+import SwiftUI
+import UIKit
+
+private let inboxReplyLogger = Logger(subsystem: "net.cleberg.Hutch", category: "InboxReply")
+
+struct ThreadDetailView: View {
+    let thread: InboxThreadSummary
+    let onViewed: () -> Void
+
+    @Environment(AppState.self) private var appState
+    @State private var viewModel: ThreadViewModel?
+    @State private var replySuccessMessage: String?
+
+    var body: some View {
+        Group {
+            if let viewModel {
+                content(viewModel)
+            } else {
+                SRHTLoadingStateView(message: "Loading thread…")
+            }
+        }
+        .navigationTitle("Thread")
+        .navigationBarTitleDisplayMode(.inline)
+        .task {
+            if viewModel == nil {
+                onViewed()
+                let vm = ThreadViewModel(summary: thread, client: appState.client)
+                viewModel = vm
+                await vm.loadThread()
+            }
+        }
+        .sheet(item: Binding(
+            get: { viewModel?.composeDraft },
+            set: { _ in viewModel?.dismissReply() }
+        )) { draft in
+            MailComposeView(draft: draft) { result in
+                switch result {
+                case .failed(let message):
+                    inboxReplyLogger.error("Inbox reply failed for thread \(thread.debugIdentifierSummary, privacy: .public): \(message, privacy: .public)")
+                    viewModel?.error = message
+                case .cancelled:
+                    inboxReplyLogger.debug("Inbox reply cancelled for thread \(thread.debugIdentifierSummary, privacy: .public)")
+                case .saved:
+                    inboxReplyLogger.debug("Inbox reply draft saved for thread \(thread.debugIdentifierSummary, privacy: .public)")
+                case .sent:
+                    inboxReplyLogger.debug("Inbox reply handed off to Mail for thread \(thread.debugIdentifierSummary, privacy: .public)")
+                    replySuccessMessage = "Reply handed off to Mail."
+                    Task {
+                        await viewModel?.loadThread()
+                    }
+                }
+            }
+        }
+        .overlay(alignment: .top) {
+            if let replySuccessMessage {
+                Text(replySuccessMessage)
+                    .font(.caption.weight(.medium))
+                    .padding(.horizontal, 12)
+                    .padding(.vertical, 8)
+                    .background(.thinMaterial, in: Capsule())
+                    .padding(.top, 8)
+                    .transition(.move(edge: .top).combined(with: .opacity))
+            }
+        }
+        .animation(.easeInOut(duration: 0.2), value: replySuccessMessage)
+        .onChange(of: replySuccessMessage) { _, message in
+            guard message != nil else { return }
+            Task { @MainActor in
+                try? await Task.sleep(for: .seconds(2))
+                if self.replySuccessMessage == message {
+                    self.replySuccessMessage = nil
+                }
+            }
+        }
+    }
+
+    @ViewBuilder
+    private func content(_ viewModel: ThreadViewModel) -> some View {
+        @Bindable var vm = viewModel
+
+        List {
+            if let thread = viewModel.thread {
+                Section {
+                    VStack(alignment: .leading, spacing: 6) {
+                        Text(thread.displaySubject)
+                            .font(.headline)
+                        Text(headerMetadata(thread))
+                            .font(.caption)
+                            .foregroundStyle(.secondary)
+                    }
+                    .padding(.vertical, 4)
+                }
+
+                ForEach(thread.messages) { message in
+                    InboxMessageRow(message: message)
+                }
+            }
+        }
+        .listStyle(.plain)
+        .toolbar {
+            ToolbarItem(placement: .topBarTrailing) {
+                Button("Reply") {
+                    viewModel.prepareReply()
+                }
+            }
+        }
+        .overlay {
+            if viewModel.isLoading, viewModel.thread == nil {
+                SRHTLoadingStateView(message: "Loading thread…")
+            } else if let error = viewModel.error, viewModel.thread == nil {
+                SRHTErrorStateView(
+                    title: "Couldn't Load Thread",
+                    message: error,
+                    retryAction: { await viewModel.loadThread() }
+                )
+            }
+        }
+        .srhtErrorBanner(error: $vm.error)
+        .refreshable {
+            await viewModel.loadThread()
+        }
+    }
+
+    private func headerMetadata(_ thread: InboxThreadDetail) -> String {
+        var parts = [thread.listDisplayName]
+        if let messageCount = thread.messageCount, messageCount > 1 {
+            parts.append("\(messageCount) messages")
+        }
+        parts.append(thread.lastActivityAt.relativeDescription)
+        return parts.joined(separator: " • ")
+    }
+}
+
+private struct InboxMessageRow: View {
+    let message: InboxMessage
+
+    var body: some View {
+        VStack(alignment: .leading, spacing: 10) {
+            HStack(alignment: .top, spacing: 12) {
+                VStack(alignment: .leading, spacing: 2) {
+                    Text(senderLine)
+                        .font(.subheadline.weight(.medium))
+                        .lineLimit(2)
+                    Text(message.date.formatted(date: .abbreviated, time: .shortened))
+                        .font(.caption)
+                        .foregroundStyle(.secondary)
+                }
+
+                Spacer()
+
+                if message.isPatch {
+                    Text("Patch")
+                        .font(.caption2.weight(.medium))
+                        .foregroundStyle(.secondary)
+                }
+            }
+
+            ForEach(Array(message.contentBlocks.enumerated()), id: \.offset) { _, block in
+                switch block {
+                case .plainText(let text):
+                    Text(text)
+                        .font(.body)
+                        .textSelection(.enabled)
+                        .frame(maxWidth: .infinity, alignment: .leading)
+                        .fixedSize(horizontal: false, vertical: true)
+                case .diff(let diff):
+                    ScrollView(.horizontal) {
+                        DiffView(diff: diff)
+                            .textSelection(.enabled)
+                            .frame(maxWidth: .infinity, alignment: .leading)
+                    }
+                }
+            }
+        }
+        .padding(.vertical, 6)
+        .listRowSeparator(.visible)
+    }
+
+    private var senderLine: String {
+        if let email = message.senderEmailAddress,
+           email.caseInsensitiveCompare(message.senderDisplayName) != .orderedSame {
+            return "\(message.senderDisplayName) <\(email)>"
+        }
+        return message.senderDisplayName
+    }
+}
+
+private struct MailComposeView: UIViewControllerRepresentable {
+    let draft: MailComposeDraft
+    let onComplete: (Result) -> Void
+
+    enum Result {
+        case cancelled
+        case saved
+        case sent
+        case failed(String)
+    }
+
+    func makeCoordinator() -> Coordinator {
+        Coordinator(onComplete: onComplete)
+    }
+
+    func makeUIViewController(context: Context) -> UIViewController {
+        guard MFMailComposeViewController.canSendMail() else {
+            let controller = UINavigationController(rootViewController: MailUnavailableViewController(onDismiss: {
+                context.coordinator.onComplete(.failed("Mail is not configured on this device."))
+            }))
+            DispatchQueue.main.async {
+                UIImpactFeedbackGenerator(style: .light).impactOccurred()
+            }
+            return controller
+        }
+
+        let controller = MFMailComposeViewController()
+        controller.mailComposeDelegate = context.coordinator
+        controller.setToRecipients(draft.recipients)
+        if !draft.ccRecipients.isEmpty {
+            controller.setCcRecipients(draft.ccRecipients)
+        }
+        if !draft.subject.isEmpty {
+            controller.setSubject(draft.subject)
+        }
+        if !draft.body.isEmpty {
+            controller.setMessageBody(draft.body, isHTML: false)
+        }
+        return controller
+    }
+
+    func updateUIViewController(_ uiViewController: UIViewController, context: Context) {}
+
+    final class Coordinator: NSObject, MFMailComposeViewControllerDelegate {
+        let onComplete: (Result) -> Void
+
+        init(onComplete: @escaping (Result) -> Void) {
+            self.onComplete = onComplete
+        }
+
+        func mailComposeController(
+            _ controller: MFMailComposeViewController,
+            didFinishWith result: MFMailComposeResult,
+            error: Error?
+        ) {
+            if error != nil {
+                let message = error?.localizedDescription ?? "The reply could not be sent."
+                presentFailureAlert(on: controller, message: message)
+                onComplete(.failed(message))
+                return
+            }
+            switch result {
+            case .cancelled:
+                controller.dismiss(animated: true)
+                onComplete(.cancelled)
+            case .saved:
+                controller.dismiss(animated: true)
+                onComplete(.saved)
+            case .sent:
+                controller.dismiss(animated: true)
+                onComplete(.sent)
+            case .failed:
+                let message = "Mail could not send the reply from the configured iOS Mail account."
+                presentFailureAlert(on: controller, message: message)
+                onComplete(.failed(message))
+            @unknown default:
+                let message = "Mail returned an unknown result while sending the reply."
+                presentFailureAlert(on: controller, message: message)
+                onComplete(.failed(message))
+            }
+        }
+
+        private func presentFailureAlert(on controller: UIViewController, message: String) {
+            guard controller.presentedViewController == nil else { return }
+            let alert = UIAlertController(title: "Reply Failed", message: message, preferredStyle: .alert)
+            alert.addAction(UIAlertAction(title: "OK", style: .default))
+            controller.present(alert, animated: true)
+        }
+    }
+}
+
+private final class MailUnavailableViewController: UIViewController {
+    private let onDismiss: () -> Void
+
+    init(onDismiss: @escaping () -> Void) {
+        self.onDismiss = onDismiss
+        super.init(nibName: nil, bundle: nil)
+    }
+
+    @available(*, unavailable)
+    required init?(coder: NSCoder) {
+        fatalError("init(coder:) has not been implemented")
+    }
+
+    override func viewDidLoad() {
+        super.viewDidLoad()
+        view.backgroundColor = .systemBackground
+        navigationItem.title = "Reply"
+        navigationItem.rightBarButtonItem = UIBarButtonItem(
+            barButtonSystemItem: .done,
+            target: self,
+            action: #selector(dismissSelf)
+        )
+
+        let label = UILabel()
+        label.translatesAutoresizingMaskIntoConstraints = false
+        label.text = "Mail is not configured on this device."
+        label.textAlignment = .center
+        label.numberOfLines = 0
+        label.textColor = .secondaryLabel
+
+        view.addSubview(label)
+        NSLayoutConstraint.activate([
+            label.leadingAnchor.constraint(equalTo: view.layoutMarginsGuide.leadingAnchor),
+            label.trailingAnchor.constraint(equalTo: view.layoutMarginsGuide.trailingAnchor),
+            label.centerYAnchor.constraint(equalTo: view.centerYAnchor)
+        ])
+    }
+
+    @objc
+    private func dismissSelf() {
+        dismiss(animated: true)
+        onDismiss()
+    }
+}
diff --git a/Hutch/Views/Inbox/ThreadViewModel.swift b/Hutch/Views/Inbox/ThreadViewModel.swift
new file mode 100644
index 0000000..10a5b59
--- /dev/null
+++ b/Hutch/Views/Inbox/ThreadViewModel.swift
@@ -0,0 +1,702 @@
+import Foundation
+import os
+
+private let inboxLogger = Logger(subsystem: "net.cleberg.Hutch", category: "Inbox")
+
+private struct InboxThreadDetailResponse: Decodable, Sendable {
+    let list: InboxThreadDetailList?
+}
+
+private struct InboxThreadDetailList: Decodable, Sendable {
+    let threads: InboxThreadPayloadPage?
+}
+
+private struct InboxThreadLookupResponse: Decodable, Sendable {
+    let list: InboxThreadLookupList?
+}
+
+private struct InboxThreadLookupList: Decodable, Sendable {
+    let message: InboxThreadLookupMessage?
+}
+
+private struct InboxThreadLookupMessage: Decodable, Sendable {
+    let thread: InboxThreadPayloadDetail?
+}
+
+private struct InboxThreadPayloadDetail: Decodable, Sendable {
+    let subject: String?
+    let updated: Date?
+    let replies: Int?
+    let sender: Entity?
+    let list: InboxMailingListReference?
+    let root: InboxThreadMessagePayload?
+    let descendants: InboxThreadMessagesPage?
+}
+
+private struct InboxThreadPayloadPage: Decodable, Sendable {
+    let results: [InboxThreadPayloadDetail]
+    let cursor: String?
+}
+
+private struct InboxThreadMessagesPage: Decodable, Sendable {
+    let results: [InboxThreadMessagePayload]?
+    let cursor: String?
+}
+
+private struct InboxThreadMessagePayload: Decodable, Sendable {
+    let id: Int?
+    let sender: Entity?
+    let received: Date?
+    let date: Date?
+    let subject: String?
+    let messageID: String?
+    let body: String?
+    let rawMessage: URL?
+    let patch: InboxPatchPreview?
+}
+
+@Observable
+@MainActor
+final class ThreadViewModel {
+    private(set) var thread: InboxThreadDetail?
+    private(set) var isLoading = false
+    var error: String?
+    var composeDraft: MailComposeDraft?
+
+    private let summary: InboxThreadSummary
+    private let client: SRHTClient
+
+    private static let threadDetailQuery = """
+    query inboxThreadDetail($rid: ID!, $cursor: Cursor, $descCursor: Cursor) {
+        list(rid: $rid) {
+            threads(cursor: $cursor) {
+                results {
+                subject
+                updated
+                replies
+                sender { canonicalName }
+                list {
+                    id
+                    rid
+                    name
+                    owner { canonicalName }
+                }
+                root {
+                    id
+                    sender { canonicalName }
+                    received
+                    date
+                    subject
+                    messageID
+                    body
+                    rawMessage
+                    patch { subject }
+                }
+                descendants(cursor: $descCursor) {
+                    results {
+                        id
+                        sender { canonicalName }
+                        received
+                        date
+                        subject
+                        messageID
+                        body
+                        rawMessage
+                        patch { subject }
+                    }
+                    cursor
+                }
+                }
+                cursor
+                }
+        }
+    }
+    """
+
+    private static let threadByMessageIDQuery = """
+    query inboxThreadByMessageID($rid: ID!, $messageID: String!, $descCursor: Cursor) {
+        list(rid: $rid) {
+            message(messageID: $messageID) {
+                thread {
+                    subject
+                    updated
+                    replies
+                    sender { canonicalName }
+                    list {
+                        id
+                        rid
+                        name
+                        owner { canonicalName }
+                    }
+                    root {
+                        id
+                        sender { canonicalName }
+                        received
+                        date
+                        subject
+                        messageID
+                        body
+                        rawMessage
+                        patch { subject }
+                    }
+                    descendants(cursor: $descCursor) {
+                        results {
+                            id
+                            sender { canonicalName }
+                            received
+                            date
+                            subject
+                            messageID
+                            body
+                            rawMessage
+                            patch { subject }
+                        }
+                        cursor
+                    }
+                }
+            }
+        }
+    }
+    """
+
+    init(summary: InboxThreadSummary, client: SRHTClient) {
+        self.summary = summary
+        self.client = client
+    }
+
+    func loadThread() async {
+        guard !isLoading else { return }
+        isLoading = true
+        error = nil
+        defer { isLoading = false }
+
+        inboxLogger.debug("Opening inbox thread: \(self.summary.debugIdentifierSummary, privacy: .public)")
+
+        do {
+            let threadPayloads = try await fetchThreadPayloads()
+
+            guard !threadPayloads.isEmpty else {
+                throw SRHTError.graphQLErrors([GraphQLError(message: "Thread is no longer available.", locations: nil)])
+            }
+
+            let listReference = threadPayloads.lazy.compactMap(\.list).first ?? InboxMailingListReference(
+                id: summary.listID,
+                rid: summary.listRID,
+                name: summary.listName,
+                owner: summary.listOwner
+            )
+            var messagesByID: [Int: InboxMessage] = [:]
+
+            for payload in threadPayloads {
+                guard let rootMessage = Self.message(from: payload.root, fallbackID: summary.rootEmailID) else {
+                    continue
+                }
+                messagesByID[rootMessage.id] = rootMessage
+
+                let descendantMessages = try await fetchAllDescendantMessages(
+                    initialPayload: payload,
+                    candidateMessageIDs: Self.messageIDCandidates(from: payload.root?.messageID ?? summary.rootMessageID)
+                )
+                for message in descendantMessages {
+                    messagesByID[message.id] = message
+                }
+            }
+
+            let messages = messagesByID.values.sorted { $0.date < $1.date }
+            guard !messages.isEmpty else {
+                throw SRHTError.graphQLErrors([GraphQLError(message: "Thread root message is unavailable.", locations: nil)])
+            }
+
+            let latestPayload = threadPayloads.max(by: { ($0.updated ?? .distantPast) < ($1.updated ?? .distantPast) }) ?? threadPayloads[0]
+            thread = InboxThreadDetail(
+                id: summary.id,
+                rootEmailID: summary.rootEmailID,
+                rootMessageID: summary.rootMessageID,
+                subject: latestPayload.subject ?? summary.subject,
+                author: latestPayload.sender ?? summary.latestSender,
+                lastActivityAt: latestPayload.updated ?? summary.lastActivityAt,
+                mailto: nil,
+                listID: listReference.id,
+                listRID: listReference.rid,
+                listName: listReference.name,
+                listOwner: listReference.owner,
+                messageCount: max(messages.count, summary.messageCount ?? 0),
+                messages: messages
+            )
+        } catch {
+            thread = nil
+            self.error = error.localizedDescription
+            inboxLogger.error("Inbox thread detail failed for \(self.summary.debugIdentifierSummary, privacy: .public): \(error.localizedDescription, privacy: .public)")
+        }
+    }
+
+    private func fetchThreadPayloads() async throws -> [InboxThreadPayloadDetail] {
+        var payloads: [InboxThreadPayloadDetail] = []
+        var seenRoots = Set<String>()
+
+        for rootMessageID in summary.threadRootMessageIDs {
+            guard !seenRoots.contains(rootMessageID) else { continue }
+            seenRoots.insert(rootMessageID)
+            if let payload = try await fetchThreadPayload(rootMessageID: rootMessageID) {
+                payloads.append(payload)
+            }
+        }
+
+        if payloads.isEmpty, let fallback = try await fetchThreadPayload(rootMessageID: summary.rootMessageID) {
+            payloads.append(fallback)
+        }
+
+        return payloads
+    }
+
+    private func fetchThreadPayload(rootMessageID: String) async throws -> InboxThreadPayloadDetail? {
+        if let messageMatchedThread = try await fetchThreadByMessageID(rootMessageID: rootMessageID) {
+            return messageMatchedThread
+        }
+        return try await scanThreadPages(targetRootMessageID: rootMessageID)
+    }
+
+    private func fetchThreadByMessageID(rootMessageID: String) async throws -> InboxThreadPayloadDetail? {
+        let candidateMessageIDs = Self.messageIDCandidates(from: rootMessageID)
+        inboxLogger.debug(
+            "Inbox thread lookup IDs: subject=\(self.summary.subject, privacy: .public) rootEmailID=\(self.summary.rootEmailID, privacy: .public) rootMessageID=\(rootMessageID, privacy: .public) candidates=\(candidateMessageIDs.joined(separator: ", "), privacy: .public)"
+        )
+
+        var lastLookupError: Error?
+
+        for messageID in candidateMessageIDs {
+            inboxLogger.debug(
+                "Inbox thread detail lookup request: rid=\(self.summary.listRID, privacy: .public) messageID=\(messageID, privacy: .public)"
+            )
+
+            do {
+                let response: InboxThreadLookupResponse = try await Self.executeGraphQLRequest(
+                    client: client,
+                    query: Self.threadByMessageIDQuery,
+                    variables: [
+                        "rid": self.summary.listRID,
+                        "messageID": messageID,
+                        "descCursor": nil as String?
+                    ]
+                )
+
+                if let thread = response.list?.message?.thread {
+                    return thread
+                }
+            } catch let error as SRHTError {
+                switch error {
+                case .graphQLErrors(let errors):
+                    let combinedMessage = errors.map(\.message).joined(separator: " | ")
+                    inboxLogger.error(
+                        "Inbox thread message lookup failed: rid=\(self.summary.listRID, privacy: .public) messageID=\(messageID, privacy: .public) errors=\(combinedMessage, privacy: .public)"
+                    )
+                    if errors.allSatisfy({ $0.message.localizedCaseInsensitiveContains("no rows in result set") }) {
+                        lastLookupError = error
+                        continue
+                    }
+                    throw error
+                default:
+                    throw error
+                }
+            }
+        }
+
+        if let lastLookupError {
+            inboxLogger.debug(
+                "Inbox thread message lookup exhausted candidates for \(self.summary.debugIdentifierSummary, privacy: .public): \(lastLookupError.localizedDescription, privacy: .public)"
+            )
+        }
+        return nil
+    }
+
+    private func scanThreadPages(targetRootMessageID: String) async throws -> InboxThreadPayloadDetail? {
+        var threadCursor: String?
+
+        while true {
+            var variables: [String: any Sendable] = ["rid": summary.listRID]
+            if let threadCursor {
+                variables["cursor"] = threadCursor
+            }
+
+            let response: InboxThreadDetailResponse = try await Self.executeGraphQLRequest(
+                client: client,
+                query: Self.threadDetailQuery,
+                variables: {
+                    var variables = variables
+                    variables["descCursor"] = nil as String?
+                    return variables
+                }()
+            )
+
+            guard let threadPage = response.list?.threads else {
+                throw SRHTError.graphQLErrors([GraphQLError(message: "Thread is no longer available.", locations: nil)])
+            }
+
+            let candidates = threadPage.results.map { payload in
+                "subject=\(payload.subject ?? "<nil>") rootEmailID=\(payload.root?.id.map(String.init) ?? "<nil>") rootMessageID=\(payload.root?.messageID ?? "<nil>")"
+            }.joined(separator: " | ")
+            inboxLogger.debug("Inbox thread detail page candidates: \(candidates, privacy: .public)")
+
+            if let matchedThread = threadPage.results.first(where: {
+                $0.root?.messageID == targetRootMessageID ||
+                $0.root?.id == summary.rootEmailID ||
+                $0.root?.subject == summary.subject
+            }) {
+                return matchedThread
+            }
+
+            guard let nextCursor = threadPage.cursor else {
+                return nil
+            }
+            threadCursor = nextCursor
+        }
+    }
+
+    private func fetchAllDescendantMessages(
+        initialPayload: InboxThreadPayloadDetail,
+        candidateMessageIDs: [String]
+    ) async throws -> [InboxMessage] {
+        var messagesByID: [Int: InboxMessage] = [:]
+
+        for payload in initialPayload.descendants?.results ?? [] {
+            if let message = Self.message(from: payload, fallbackID: nil) {
+                messagesByID[message.id] = message
+            }
+        }
+
+        var descendantCursor = initialPayload.descendants?.cursor
+        while let currentCursor = descendantCursor {
+            guard let page = try await fetchDescendantPage(
+                cursor: currentCursor,
+                candidateMessageIDs: candidateMessageIDs
+            ) else {
+                break
+            }
+
+            for payload in page.results ?? [] {
+                if let message = Self.message(from: payload, fallbackID: nil) {
+                    messagesByID[message.id] = message
+                }
+            }
+            descendantCursor = page.cursor
+        }
+
+        return messagesByID.values.sorted { $0.date < $1.date }
+    }
+
+    private func fetchDescendantPage(
+        cursor: String,
+        candidateMessageIDs: [String]
+    ) async throws -> InboxThreadMessagesPage? {
+        for messageID in candidateMessageIDs {
+            let response: InboxThreadLookupResponse = try await Self.executeGraphQLRequest(
+                client: client,
+                query: Self.threadByMessageIDQuery,
+                variables: [
+                    "rid": summary.listRID,
+                    "messageID": messageID,
+                    "descCursor": cursor
+                ]
+            )
+
+            if let descendants = response.list?.message?.thread?.descendants {
+                return descendants
+            }
+        }
+
+        return nil
+    }
+
+    func prepareReply() {
+        guard let thread else {
+            error = "This thread is not ready to reply to yet."
+            return
+        }
+        inboxLogger.debug(
+            "Preparing inbox reply: subject=\(thread.subject, privacy: .public) listRID=\(thread.listRID, privacy: .public) rootMessageID=\(thread.rootMessageID, privacy: .public) recipient=\(thread.replyRecipient, privacy: .public) senderIdentity=system-mail-account"
+        )
+        composeDraft = MailComposeDraft(
+            recipients: [thread.replyRecipient],
+            ccRecipients: [],
+            subject: thread.replySubject,
+            body: ""
+        )
+    }
+
+    func dismissReply() {
+        composeDraft = nil
+    }
+
+    private static func message(from payload: InboxThreadMessagePayload?, fallbackID: Int?) -> InboxMessage? {
+        guard let payload else { return nil }
+        guard let id = payload.id ?? fallbackID,
+              let author = payload.sender,
+              let date = payload.date ?? payload.received,
+              let subject = payload.subject,
+              let body = payload.body else {
+            return nil
+        }
+
+        let normalizedIdentity = normalizedSenderIdentity(from: body, fallbackAuthor: author)
+        let displayBody = sanitizedDisplayBody(from: body)
+        let contentBlocks = segmentMessageBody(displayBody, isPatch: payload.patch != nil)
+
+        return InboxMessage(
+            id: id,
+            author: author,
+            date: date,
+            subject: subject,
+            body: body,
+            senderDisplayName: normalizedIdentity.displayName,
+            senderEmailAddress: normalizedIdentity.emailAddress,
+            isPatch: payload.patch != nil,
+            contentBlocks: contentBlocks,
+            rawMessageURL: payload.rawMessage
+        )
+    }
+
+    nonisolated static func mailComposeDraft(from mailto: String) -> MailComposeDraft? {
+        guard let components = URLComponents(string: mailto),
+              components.scheme?.lowercased() == "mailto" else {
+            return nil
+        }
+
+        let recipients = components.path
+            .split(separator: ",")
+            .map { String($0) }
+            .filter { !$0.isEmpty }
+        let queryItems = components.queryItems ?? []
+        let ccRecipients = queryItems
+            .first(where: { $0.name.caseInsensitiveCompare("cc") == .orderedSame })?
+            .value?
+            .split(separator: ",")
+            .map(String.init) ?? []
+        let subject = queryItems
+            .first(where: { $0.name.caseInsensitiveCompare("subject") == .orderedSame })?
+            .value ?? ""
+        let body = queryItems
+            .first(where: { $0.name.caseInsensitiveCompare("body") == .orderedSame })?
+            .value ?? ""
+
+        return MailComposeDraft(
+            recipients: recipients,
+            ccRecipients: ccRecipients,
+            subject: subject,
+            body: body
+        )
+    }
+
+    private static func messageIDCandidates(from messageID: String) -> [String] {
+        let trimmedMessageID = messageID.trimmingCharacters(in: .whitespacesAndNewlines)
+        guard !trimmedMessageID.isEmpty else { return [] }
+
+        if trimmedMessageID.hasPrefix("<"), trimmedMessageID.hasSuffix(">") {
+            return [trimmedMessageID, String(trimmedMessageID.dropFirst().dropLast())]
+        }
+
+        return [trimmedMessageID, "<\(trimmedMessageID)>"]
+    }
+
+    private static func normalizedSenderIdentity(from body: String, fallbackAuthor: Entity) -> (displayName: String, emailAddress: String?) {
+        guard let fromLine = leadingHeaderValue(named: "From", in: body) else {
+            return fallbackSenderIdentity(from: fallbackAuthor)
+        }
+
+        let trimmedFromLine = fromLine.trimmingCharacters(in: .whitespacesAndNewlines)
+        if let start = trimmedFromLine.lastIndex(of: "<"),
+           let end = trimmedFromLine.lastIndex(of: ">"),
+           start < end {
+            let email = String(trimmedFromLine[trimmedFromLine.index(after: start)..<end]).trimmingCharacters(in: .whitespaces)
+            let name = String(trimmedFromLine[..<start]).trimmingCharacters(in: .whitespacesAndNewlines)
+            if !name.isEmpty {
+                return (name, email.isEmpty ? nil : email)
+            }
+            return (email.isEmpty ? trimmedFromLine : email, email.isEmpty ? nil : email)
+        }
+
+        if trimmedFromLine.contains("@") {
+            return (trimmedFromLine, trimmedFromLine)
+        }
+
+        return (trimmedFromLine, nil)
+    }
+
+    private static func fallbackSenderIdentity(from author: Entity) -> (displayName: String, emailAddress: String?) {
+        let canonicalName = author.canonicalName.trimmingCharacters(in: .whitespacesAndNewlines)
+        if canonicalName.contains("@") {
+            return (canonicalName, canonicalName)
+        }
+        if canonicalName.hasPrefix("~") {
+            return (String(canonicalName.dropFirst()), nil)
+        }
+        return (canonicalName, nil)
+    }
+
+    private static func sanitizedDisplayBody(from body: String) -> String {
+        let lines = body.components(separatedBy: .newlines)
+        let headerPrefixes = ["From:", "Date:", "To:", "Cc:", "Subject:"]
+        var headerCount = 0
+        var blankLineIndex: Int?
+
+        for (index, line) in lines.prefix(12).enumerated() {
+            if line.isEmpty {
+                blankLineIndex = index
+                break
+            }
+            if headerPrefixes.contains(where: { line.hasPrefix($0) }) {
+                headerCount += 1
+            } else if headerCount > 0 {
+                break
+            }
+        }
+
+        guard headerCount >= 2, let blankLineIndex else {
+            return body
+        }
+
+        return lines.dropFirst(blankLineIndex + 1).joined(separator: "\n")
+    }
+
+    nonisolated static func segmentMessageBodyForTesting(_ body: String, isPatch: Bool) -> [InboxMessageContentBlock] {
+        segmentMessageBody(body, isPatch: isPatch)
+    }
+
+    private nonisolated static func segmentMessageBody(_ body: String, isPatch: Bool) -> [InboxMessageContentBlock] {
+        guard isPatch else {
+            let trimmedBody = body.trimmingCharacters(in: .whitespacesAndNewlines)
+            return trimmedBody.isEmpty ? [] : [.plainText(trimmedBody)]
+        }
+
+        let normalizedBody = normalizeLineEndings(in: body)
+        let lines = normalizedBody.components(separatedBy: "\n")
+        guard let diffStartIndex = actualDiffStartIndex(in: lines) else {
+            let trimmedBody = normalizedBody.trimmingCharacters(in: .whitespacesAndNewlines)
+            return trimmedBody.isEmpty ? [] : [.plainText(trimmedBody)]
+        }
+
+        var blocks: [InboxMessageContentBlock] = []
+        let leadingPlainText = lines[..<diffStartIndex]
+            .joined(separator: "\n")
+            .trimmingCharacters(in: .whitespacesAndNewlines)
+        if !leadingPlainText.isEmpty {
+            blocks.append(.plainText(leadingPlainText))
+        }
+
+        let remainingLines = Array(lines[diffStartIndex...])
+        let signatureIndex = remainingLines.firstIndex(where: isEmailSignatureSeparator)
+
+        let diffLines: ArraySlice<String>
+        let trailingPlainText: String
+        if let signatureIndex {
+            diffLines = remainingLines[..<signatureIndex]
+            trailingPlainText = remainingLines[signatureIndex...]
+                .joined(separator: "\n")
+                .trimmingCharacters(in: .whitespacesAndNewlines)
+        } else {
+            diffLines = remainingLines[...]
+            trailingPlainText = ""
+        }
+
+        let diff = diffLines.joined(separator: "\n").trimmingCharacters(in: .whitespacesAndNewlines)
+        if !diff.isEmpty {
+            blocks.append(.diff(diff))
+        }
+
+        if !trailingPlainText.isEmpty {
+            blocks.append(.plainText(trailingPlainText))
+        }
+        return blocks
+    }
+
+    private nonisolated static func actualDiffStartIndex(in lines: [String]) -> Int? {
+        if let explicitDiffIndex = lines.firstIndex(where: { $0.hasPrefix("diff --git ") }) {
+            return explicitDiffIndex
+        }
+
+        for index in lines.indices {
+            let line = lines[index]
+            guard line.hasPrefix("--- ") else { continue }
+            let nextIndex = lines.index(after: index)
+            guard nextIndex < lines.endIndex else { continue }
+            let nextLine = lines[nextIndex]
+            guard nextLine.hasPrefix("+++ ") else { continue }
+
+            let oldPath = String(line.dropFirst(4))
+            let newPath = String(nextLine.dropFirst(4))
+            let looksLikeUnifiedDiff = (oldPath.hasPrefix("a/") || oldPath == "/dev/null") &&
+                (newPath.hasPrefix("b/") || newPath == "/dev/null")
+
+            if looksLikeUnifiedDiff {
+                return index
+            }
+        }
+
+        return nil
+    }
+
+    private nonisolated static func isEmailSignatureSeparator(_ line: String) -> Bool {
+        line == "-- " || line == "--"
+    }
+
+    private nonisolated static func normalizeLineEndings(in text: String) -> String {
+        text
+            .replacingOccurrences(of: "\r\n", with: "\n")
+            .replacingOccurrences(of: "\r", with: "\n")
+    }
+
+    private static func leadingHeaderValue(named headerName: String, in body: String) -> String? {
+        let prefix = "\(headerName):"
+        let lines = body.components(separatedBy: .newlines)
+        for line in lines.prefix(12) {
+            if line.isEmpty {
+                break
+            }
+            if line.hasPrefix(prefix) {
+                return String(line.dropFirst(prefix.count)).trimmingCharacters(in: .whitespaces)
+            }
+        }
+        return nil
+    }
+
+    private static func executeGraphQLRequest<T: Decodable>(
+        client: SRHTClient,
+        query: String,
+        variables: [String: any Sendable]
+    ) async throws -> T {
+        guard let token = KeychainHelper.loadToken(), !token.isEmpty else {
+            throw SRHTError.unauthorized
+        }
+
+        var request = URLRequest(url: SRHTService.lists.url)
+        request.httpMethod = "POST"
+        request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
+        request.setValue("application/json", forHTTPHeaderField: "Content-Type")
+
+        let encoder = JSONEncoder()
+        request.httpBody = try encoder.encode(
+            GraphQLRequestBody(
+                query: query,
+                variables: variables.mapValues { AnyCodable($0) }
+            )
+        )
+
+        let (data, _) = try await URLSession.shared.data(for: request)
+        #if DEBUG
+        let responseBody = String(data: data, encoding: .utf8) ?? "<non-utf8 response>"
+        inboxLogger.debug("Inbox thread raw GraphQL response: \(responseBody, privacy: .public)")
+        #endif
+
+        let decoder = JSONDecoder()
+        decoder.dateDecodingStrategy = .srhtFlexible
+        let envelope = try decoder.decode(GraphQLResponse<T>.self, from: data)
+        if let errors = envelope.errors, !errors.isEmpty {
+            throw SRHTError.graphQLErrors(errors)
+        }
+        guard let payload = envelope.data else {
+            throw SRHTError.decodingError(
+                DecodingError.dataCorrupted(.init(codingPath: [], debugDescription: "No data in thread detail response"))
+            )
+        }
+        return payload
+    }
+}
diff --git a/Hutch/Views/Repositories/DiffView.swift b/Hutch/Views/Repositories/DiffView.swift
index b1db464..4380e22 100644
--- a/Hutch/Views/Repositories/DiffView.swift
+++ b/Hutch/Views/Repositories/DiffView.swift
@@ -9,14 +9,23 @@ struct DiffView: View {
     let diff: String
 
     var body: some View {
-        let lines = diff.components(separatedBy: "\n")
+        let lines = normalizedDiff.components(separatedBy: "\n")
 
-        LazyVStack(alignment: .leading, spacing: 0) {
+        VStack(alignment: .leading, spacing: 0) {
             ForEach(Array(lines.enumerated()), id: \.offset) { _, line in
                 DiffLineView(line: line)
             }
         }
-        .font(.caption.monospaced())
+        .font(.system(.caption, design: .monospaced))
+        .frame(maxWidth: .infinity, alignment: .leading)
+        .background(Color(.secondarySystemBackground))
+        .clipShape(RoundedRectangle(cornerRadius: 8, style: .continuous))
+    }
+
+    private var normalizedDiff: String {
+        diff
+            .replacingOccurrences(of: "\r\n", with: "\n")
+            .replacingOccurrences(of: "\r", with: "\n")
     }
 }
 
@@ -27,7 +36,6 @@ private struct DiffLineView: View {
         Text(line.isEmpty ? " " : line)
             .frame(maxWidth: .infinity, alignment: .leading)
             .padding(.horizontal, 8)
-            .padding(.vertical, 1)
             .background(backgroundColor)
             .foregroundStyle(foregroundColor)
             .fontWeight(isHeader ? .semibold : .regular)
@@ -47,9 +55,9 @@ private struct DiffLineView: View {
         switch kind {
         case .added:      .green.opacity(0.15)
         case .removed:    .red.opacity(0.15)
-        case .hunk:       .gray.opacity(0.12)
-        case .fileHeader: .gray.opacity(0.08)
-        case .meta:       .gray.opacity(0.05)
+        case .hunk:       .clear
+        case .fileHeader: .clear
+        case .meta:       .clear
         case .context:    .clear
         }
     }
diff --git a/HutchTests/InboxViewModelTests.swift b/HutchTests/InboxViewModelTests.swift
new file mode 100644
index 0000000..b202d03
--- /dev/null
+++ b/HutchTests/InboxViewModelTests.swift
@@ -0,0 +1,168 @@
+import Foundation
+import Testing
+@testable import Hutch
+
+struct InboxViewModelTests {
+
+    @Test
+    func derivesRepositoryNameFromCommonPatchListSuffixes() {
+        #expect(InboxViewModel.deriveRepositoryName(from: "hut-devel") == "hut")
+        #expect(InboxViewModel.deriveRepositoryName(from: "git.patches") == "git")
+        #expect(InboxViewModel.deriveRepositoryName(from: "discuss") == nil)
+    }
+
+    @Test
+    func parsesMailtoDraft() {
+        let draft = ThreadViewModel.mailComposeDraft(
+            from: "mailto:list@example.com?cc=author@example.com&subject=Re:%20PATCH&body=LGTM"
+        )
+
+        #expect(draft?.recipients == ["list@example.com"])
+        #expect(draft?.ccRecipients == ["author@example.com"])
+        #expect(draft?.subject == "Re: PATCH")
+        #expect(draft?.body == "LGTM")
+    }
+
+    @Test
+    func computesLocalUnreadStateFromLastViewedMarker() {
+        let suiteName = "InboxViewModelTests-\(UUID().uuidString)"
+        let defaults = UserDefaults(suiteName: suiteName)!
+        defer { defaults.removePersistentDomain(forName: suiteName) }
+
+        let threadID = "list#message"
+        let lastActivity = Date(timeIntervalSince1970: 2_000)
+
+        #expect(InboxReadStateStore.isUnread(threadID: threadID, lastActivityAt: lastActivity, defaults: defaults))
+
+        InboxReadStateStore.markViewed(Date(timeIntervalSince1970: 1_000), for: threadID, defaults: defaults)
+        #expect(InboxReadStateStore.isUnread(threadID: threadID, lastActivityAt: lastActivity, defaults: defaults))
+
+        InboxReadStateStore.markViewed(Date(timeIntervalSince1970: 2_500), for: threadID, defaults: defaults)
+        #expect(!InboxReadStateStore.isUnread(threadID: threadID, lastActivityAt: lastActivity, defaults: defaults))
+    }
+
+    @Test
+    func normalizesThreadSubjectsForDisplay() {
+        let summary = InboxThreadSummary(
+            rootEmailID: 1,
+            rootMessageID: "message",
+            threadRootEmailIDs: [1],
+            threadRootMessageIDs: ["message"],
+            listID: 2,
+            listRID: "list",
+            listName: "hut-devel",
+            listOwner: Entity(canonicalName: "~owner"),
+            subject: "Re: Fwd:   [PATCH] test: add parser  ",
+            latestSender: Entity(canonicalName: "~sender"),
+            lastActivityAt: Date(timeIntervalSince1970: 2_000),
+            messageCount: 3,
+            repo: "hut",
+            containsPatch: true,
+            isUnread: true
+        )
+
+        #expect(summary.displaySubject == "[PATCH] test: add parser")
+        #expect(summary.metadataLine.contains("~sender"))
+        #expect(summary.metadataLine.contains("2 replies"))
+    }
+
+    @Test
+    func keepsDistinctThreadsDistinctByRootMessageID() {
+        let baseList = InboxMailingListReference(
+            id: 1,
+            rid: "list",
+            name: "hut-devel",
+            owner: Entity(canonicalName: "~owner")
+        )
+
+        let first = InboxThreadSummary(
+            rootEmailID: 10,
+            rootMessageID: "message-1",
+            threadRootEmailIDs: [10],
+            threadRootMessageIDs: ["message-1"],
+            listID: baseList.id,
+            listRID: baseList.rid,
+            listName: baseList.name,
+            listOwner: baseList.owner,
+            subject: "[PATCH] test",
+            latestSender: Entity(canonicalName: "~a"),
+            lastActivityAt: Date(timeIntervalSince1970: 100),
+            messageCount: 1,
+            repo: "hut",
+            containsPatch: true,
+            isUnread: true
+        )
+        let second = InboxThreadSummary(
+            rootEmailID: 11,
+            rootMessageID: "message-2",
+            threadRootEmailIDs: [11],
+            threadRootMessageIDs: ["message-2"],
+            listID: baseList.id,
+            listRID: baseList.rid,
+            listName: baseList.name,
+            listOwner: baseList.owner,
+            subject: "[PATCH] test",
+            latestSender: Entity(canonicalName: "~b"),
+            lastActivityAt: Date(timeIntervalSince1970: 200),
+            messageCount: 2,
+            repo: "hut",
+            containsPatch: true,
+            isUnread: true
+        )
+
+        #expect(first.id != second.id)
+    }
+
+    @Test
+    func segmentsPatchBodyAndTreatsSignatureAsPlainText() {
+        let body = """
+        From: Christian Cleberg <hello@cleberg.net>
+
+        ---
+         test-patch.txt | 1 +
+         1 file changed, 1 insertion(+)
+         create mode 100644 test-patch.txt
+
+        diff --git a/test-patch.txt b/test-patch.txt
+        new file mode 100644
+        index 0000000..c7b6eed
+        --- /dev/null
+        +++ b/test-patch.txt
+        @@ -0,0 +1 @@
+        +test Wed Mar 18 23:19:03 CDT 2026
+        -- 
+        2.50.1 (Apple Git-155)
+        """
+
+        let segments = ThreadViewModel.segmentMessageBodyForTesting(body, isPatch: true)
+
+        #expect(segments.count == 3)
+
+        guard case let .plainText(leadingPlainText) = segments[0] else {
+            Issue.record("Expected first segment to be plain text")
+            return
+        }
+        #expect(leadingPlainText.contains("From: Christian Cleberg <hello@cleberg.net>"))
+        #expect(leadingPlainText.contains("---"))
+        #expect(leadingPlainText.contains(" test-patch.txt | 1 +"))
+        #expect(leadingPlainText.contains(" 1 file changed, 1 insertion(+)"))
+        #expect(leadingPlainText.contains(" create mode 100644 test-patch.txt"))
+
+        guard case let .diff(diff) = segments[1] else {
+            Issue.record("Expected second segment to be diff")
+            return
+        }
+        #expect(diff.contains("diff --git a/test-patch.txt b/test-patch.txt"))
+        #expect(diff.contains("--- /dev/null"))
+        #expect(diff.contains("+++ b/test-patch.txt"))
+        #expect(diff.contains("+test Wed Mar 18 23:19:03 CDT 2026"))
+        #expect(!diff.contains("-- \n2.50.1 (Apple Git-155)"))
+
+        guard case let .plainText(trailingPlainText) = segments[2] else {
+            Issue.record("Expected third segment to be plain text")
+            return
+        }
+        #expect(trailingPlainText.contains("--"))
+        #expect(trailingPlainText.contains("2.50.1 (Apple Git-155)"))
+    }
+}