import Foundation
/// The mailbox HTTP API (`/mailbox/v1`): deposit opaque bytes for a room and
/// replay them later. Payloads are whatever you give it — encrypt first; the
/// node stores exactly the bytes you send.
public actor MailboxClient {
public static let maxLimit = 500
public let endpoint: URL
private let tokens: any MailboxTokenProvider
private let http: any HTTPTransport
private let origin: String?
private let timeout: TimeInterval
public init(endpoint: URL, tokens: any MailboxTokenProvider, http: any HTTPTransport, origin: String? = nil, timeout: TimeInterval = 20) {
self.endpoint = endpoint
self.tokens = tokens
self.http = http
self.origin = origin
self.timeout = timeout
}
/// `POST /rooms/<room>/messages` with the raw payload. `deduped` is true when
/// the node already held this exact payload for the room.
public func deposit(room: String, _ payload: Data) async throws -> DepositResult {
try MailboxWire.validateRoom(room)
guard !payload.isEmpty else { throw P2PError.config("mailbox payload is empty") }
let response = try await authorized(room: room) { token in
var r = URLRequest.p2p(MailboxWire.messagesURL(endpoint: self.endpoint, room: room), method: "POST", origin: self.origin, timeout: self.timeout)
r.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
r.setValue("application/octet-stream", forHTTPHeaderField: "Content-Type")
r.httpBody = payload
return r
}
let d: DepositResponse
do { d = try JSONDecoder().decode(DepositResponse.self, from: response.body) } catch { throw P2PError.transport("deposit response not decodable: \(error)") }
return DepositResult(seq: d.seq, id: d.id, expiresAt: Date(timeIntervalSince1970: d.expires_at), deduped: d.deduped ?? (response.status == 200))
}
/// `GET /rooms/<room>/messages?after=|before=&limit=`. `before` wins when both are given.
public func replay(room: String, after: MailboxSeq? = nil, before: MailboxSeq? = nil, limit: Int = 100) async throws -> ReplayPage {
try MailboxWire.validateRoom(room)
guard limit > 0 else { throw P2PError.config("limit must be positive") }
var comps = URLComponents(url: MailboxWire.messagesURL(endpoint: endpoint, room: room), resolvingAgainstBaseURL: false)!
var items: [URLQueryItem] = []
if let before { items.append(URLQueryItem(name: "before", value: before.raw)) } else if let after { items.append(URLQueryItem(name: "after", value: after.raw)) }
items.append(URLQueryItem(name: "limit", value: String(min(limit, MailboxClient.maxLimit))))
comps.queryItems = items
let url = comps.url!
let response = try await authorized(room: room) { token in
var r = URLRequest.p2p(url, origin: self.origin, timeout: self.timeout)
r.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
return r
}
let rr: ReplayResponse
do { rr = try JSONDecoder().decode(ReplayResponse.self, from: response.body) } catch { throw P2PError.transport("replay response not decodable: \(error)") }
let messages = try rr.messages.map { item -> MailboxMessage in
guard let payload = Data(base64Encoded: item.payload) else { throw P2PError.transport("replay payload is not base64") }
return MailboxMessage(seq: item.seq, id: item.id, timestampMs: item.ts, payload: payload)
}
return ReplayPage(messages: messages, hasMore: rr.has_more ?? false, oldestSeq: rr.oldest_seq, latestSeq: rr.latest_seq)
}
/// Pages forward from `after` until the end. `gap` is true when the cursor is
/// older than what the node still holds — history was evicted in between.
public func replayAll(room: String, after: MailboxSeq?, limit: Int = MailboxClient.maxLimit) async throws -> (messages: [MailboxMessage], cursor: MailboxSeq?, gap: Bool) {
var cursor = after
var all: [MailboxMessage] = []
var gap = false
var first = true
while true {
let page = try await replay(room: room, after: cursor, limit: limit)
if first { gap = MailboxClient.isHistoryGap(cursor: after, page: page); first = false }
all.append(contentsOf: page.messages)
if let last = page.messages.last { cursor = last.seq }
if !page.hasMore || page.messages.isEmpty { break }
}
return (all, cursor, gap)
}
/// True when `cursor` is older than the page's `oldest_seq`: messages between
/// them were evicted, so the caller should show "history truncated".
public static func isHistoryGap(cursor: MailboxSeq?, page: ReplayPage) -> Bool {
guard let cursor, let oldest = page.oldestSeq else { return false }
return cursor < oldest
}
/// Runs a request with a bearer token; on 401/403 invalidates it and retries exactly once.
private func authorized(room: String, build: @Sendable (String) -> URLRequest) async throws -> HTTPResponse {
var token = try await tokens.token(for: room)
var response = try await http.send(build(token.token))
if response.status == 401 || response.status == 403 {
await tokens.invalidate(for: room)
token = try await tokens.token(for: room)
response = try await http.send(build(token.token))
}
guard response.isSuccess else { throw MailboxWire.error(response) }
return response
}
}