AtlatestRepositorysigil-jmap
sigil-jmap / tree / src / sigil / jmapclient.sgl
1
;;; (sigil jmap client) - JMAP Client Core2
;;;3
;;; Handles JMAP session discovery, request building, and response4
;;; parsing per RFC 8620. Provides the transport layer for all5
;;; JMAP method calls.7
(define-library (sigil jmap client)8
(import (sigil core)9
(sigil string)10
(sigil struct)11
(sigil http client))13
(export14
;; Client struct15
jmap-client16
jmap-client?17
jmap-client-session-url18
jmap-client-auth-header19
jmap-client-api-url20
jmap-client-download-url21
jmap-client-upload-url22
jmap-client-account-id23
jmap-client-accounts24
jmap-client-capabilities25
jmap-client-session-state27
;; Connection28
jmap-connect30
;; Request building31
jmap-call32
jmap-request33
jmap-result-ref35
;; Response helpers36
jmap-response-get37
jmap-response-error?38
jmap-error-type40
;; Blob download41
jmap-download-blob)43
(begin45
;; ============================================================46
;; Client Record47
;; ============================================================49
(define-struct jmap-client50
(session-url string?)51
(auth-header string?)52
(api-url default: #f mutable: #t)53
(download-url default: #f mutable: #t)54
(upload-url default: #f mutable: #t)55
(account-id default: #f mutable: #t)56
(accounts default: #{} mutable: #t)57
(capabilities default: #{} mutable: #t)58
(session-state default: #f mutable: #t))61
;; ============================================================62
;; Session Discovery63
;; ============================================================65
;;; Connect to a JMAP server and discover session capabilities.66
;;;67
;;; Fetches the JMAP session resource, populates the client with68
;;; API URLs, account IDs, and capabilities. Authenticates using69
;;; either a Bearer token or a pre-built Authorization header.70
;;;71
;;; ```scheme72
;;; (jmap-connect73
;;; url: "https://api.fastmail.com/.well-known/jmap"74
;;; token: "fmu1-...")75
;;;76
;;; (jmap-connect77
;;; url: "https://jmap.example.com/.well-known/jmap"78
;;; auth: "Basic dXNlcjpwYXNz")79
;;; ```80
(define (jmap-connect (keys: (url #f) (token #f) (auth #f)))81
(: (url: string?) (token: any?) (auth: any?) -> jmap-client?)82
(unless url83
(error "jmap-connect: url: is required"))84
(unless (or token auth)85
(error "jmap-connect: token: or auth: is required"))87
(let* ((auth-header (if token88
(string-append "Bearer " token)89
auth))90
(client (jmap-client91
session-url: url92
auth-header: auth-header))93
(session (http-get/json url94
headers: (dict authorization: auth-header))))95
(unless session96
(error "jmap-connect: failed to fetch session resource" url))98
;; Populate client from session99
(set-jmap-client-api-url! client (dict-ref session apiUrl: #f))100
(set-jmap-client-download-url! client (dict-ref session downloadUrl: #f))101
(set-jmap-client-upload-url! client (dict-ref session uploadUrl: #f))102
(set-jmap-client-session-state! client (dict-ref session state: #f))103
(set-jmap-client-capabilities! client (or (dict-ref session capabilities: #f) #{}))104
(set-jmap-client-accounts! client (or (dict-ref session accounts: #f) #{}))106
;; Find primary mail account107
(let ((primary-accounts (dict-ref session primaryAccounts: #{})))108
(let ((mail-account-id (dict-ref primary-accounts109
(string->keyword "urn:ietf:params:jmap:mail")110
#f)))111
(when mail-account-id112
(set-jmap-client-account-id! client mail-account-id))))114
client))117
;; ============================================================118
;; Request Building119
;; ============================================================121
;;; Build a JMAP method call triple.122
;;;123
;;; Returns an array of `[method-name, arguments, call-id]` suitable124
;;; for inclusion in the `methodCalls` array of a JMAP request.125
;;;126
;;; ```scheme127
;;; (jmap-call "Mailbox/get"128
;;; #{ accountId: "abc123" ids: 'null })129
;;; ; => #["Mailbox/get" #{ accountId: "abc123" ids: null } "0"]130
;;;131
;;; (jmap-call "Email/query"132
;;; #{ accountId: "abc123" }133
;;; "q0")134
;;; ; => #["Email/query" #{ accountId: "abc123" } "q0"]135
;;; ```136
(define (jmap-call method args . call-id)137
(: string? dict? string? ... -> array?)138
(list->array (list method args (if (pair? call-id) (car call-id) "0"))))140
;;; Send JMAP method calls to the server.141
;;;142
;;; Takes the client and one or more method call triples (from `jmap-call`).143
;;; Returns the parsed JSON response body, or raises an error on144
;;; HTTP failure.145
;;;146
;;; The `using:` keyword specifies JMAP capability URNs to include.147
;;; Defaults to core and mail capabilities.148
;;;149
;;; ```scheme150
;;; (jmap-request client151
;;; (jmap-call "Mailbox/get" #{ accountId: id ids: 'null }))152
;;; ```153
(define (jmap-request client (rest: calls) (keys: (using #f)))154
(: jmap-client? array? ... (using: any?) -> dict?)155
(let* ((capability-urns (or using156
(list "urn:ietf:params:jmap:core"157
"urn:ietf:params:jmap:mail")))158
(body (dict159
using: (list->array capability-urns)160
methodCalls: (list->array calls)))161
(result (http-post/json162
(jmap-client-api-url client)163
body164
headers: (dict165
authorization: (jmap-client-auth-header client)))))166
(unless result167
(error "jmap-request: HTTP request failed"))169
;; Update session state if changed170
(let ((new-state (dict-ref result sessionState: #f)))171
(when (and new-state172
(not (equal? new-state (jmap-client-session-state client))))173
(set-jmap-client-session-state! client new-state)))175
result))178
;; ============================================================179
;; Response Helpers180
;; ============================================================182
;;; Extract a method response by call ID from a JMAP response.183
;;;184
;;; Searches the `methodResponses` array for a triple matching the185
;;; given call ID and returns its arguments dict. Returns `#f` if186
;;; no match is found.187
;;;188
;;; ```scheme189
;;; (jmap-response-get response "0")190
;;; ; => #{ accountId: "abc" state: "..." list: #[...] }191
;;; ```192
(define (jmap-response-get response call-id)193
(: dict? string? -> any?)194
(let ((responses (dict-ref response methodResponses: #f)))195
(and responses196
(let loop ((i 0))197
(if (>= i (array-length responses))198
#f199
(let ((triple (array-ref responses i)))200
(if (equal? (array-ref triple 2) call-id)201
(array-ref triple 1)202
(loop (+ i 1)))))))))204
;;; Check if a method response is a JMAP error.205
;;;206
;;; Returns `#t` if the response triple for the given call ID has207
;;; `"error"` as its method name.208
;;;209
;;; ```scheme210
;;; (jmap-response-error? response "0")211
;;; ; => #f212
;;; ```213
(define (jmap-response-error? response call-id)214
(: dict? string? -> boolean?)215
(let ((responses (dict-ref response methodResponses: #f)))216
(and responses217
(let loop ((i 0))218
(if (>= i (array-length responses))219
#f220
(let ((triple (array-ref responses i)))221
(if (equal? (array-ref triple 2) call-id)222
(equal? (array-ref triple 0) "error")223
(loop (+ i 1)))))))))225
;;; Get the error type from a JMAP error response.226
;;;227
;;; Returns the `type` field from the error arguments, or `#f` if228
;;; the response is not an error.229
;;;230
;;; ```scheme231
;;; (jmap-error-type response "0")232
;;; ; => "unknownMethod"233
;;; ```234
(define (jmap-error-type response call-id)235
(: dict? string? -> any?)236
(let ((responses (dict-ref response methodResponses: #f)))237
(and responses238
(let loop ((i 0))239
(if (>= i (array-length responses))240
#f241
(let ((triple (array-ref responses i)))242
(if (and (equal? (array-ref triple 2) call-id)243
(equal? (array-ref triple 0) "error"))244
(dict-ref (array-ref triple 1) type: #f)245
(loop (+ i 1)))))))))247
;;; Send a single JMAP method call and return the result directly.248
;;;249
;;; Convenience wrapper around `jmap-request` + `jmap-response-get`.250
;;; Raises an error if the response is a JMAP-level error.251
;;;252
;;; ```scheme253
;;; (jmap-result-ref client254
;;; (jmap-call "Mailbox/get" #{ accountId: id ids: 'null }))255
;;; ; => #{ accountId: "abc" state: "..." list: #[...] }256
;;; ```257
(define (jmap-result-ref client call (keys: (using #f)))258
(: jmap-client? array? (using: any?) -> dict?)259
(let* ((call-id (array-ref call 2))260
(response (if using261
(jmap-request client call using: using)262
(jmap-request client call))))263
(when (jmap-response-error? response call-id)264
(let ((err-type (jmap-error-type response call-id)))265
(error (string-append "JMAP error: " (or err-type "unknown"))266
(jmap-response-get response call-id))))267
(jmap-response-get response call-id)))270
;; ============================================================271
;; Blob Download272
;; ============================================================274
;;; Download a JMAP blob to a file.275
;;;276
;;; Expands the server's download URL template (RFC 8620 section 6.2)277
;;; and streams the blob to `dest-path` using the client's auth.278
;;;279
;;; Returns a dict with download info on success:280
;;; `#{ status: 200 size: 12345 path: "/tmp/file.pdf" }`281
;;;282
;;; ```scheme283
;;; (jmap-download-blob client "Bf12abc" "report.pdf"284
;;; "application/pdf" "/tmp/report.pdf")285
;;; ```286
(define (jmap-download-blob client blob-id name type dest-path)287
(: jmap-client? string? string? string? string? -> dict?)288
(let* ((template (or (jmap-client-download-url client)289
(error "jmap-download-blob: no download URL in session")))290
(account-id (or (jmap-client-account-id client)291
(error "jmap-download-blob: no account ID")))292
(url (string-replace293
(string-replace294
(string-replace295
(string-replace template296
"{accountId}" account-id)297
"{blobId}" blob-id)298
"{name}" name)299
"{type}" type))300
(result (http-download url dest-path301
headers: (dict302
authorization: (jmap-client-auth-header client)))))303
(unless result304
(error "jmap-download-blob: download failed" url))305
result))307
))