AtlatestRepositorysigil-postmark
sigil-postmark / tree / src / postmarkapi.sgl
1
;;; (postmark api) - Postmark HTTP API client library.2
;;;3
;;; Provides functions for sending transactional and broadcast email4
;;; through Postmark's REST API. Authentication is a single server token5
;;; passed as the `X-Postmark-Server-Token` header — no request signing.7
(define-library (postmark api)8
(import (sigil core)9
(sigil dict)10
(sigil string)11
(sigil struct)12
(sigil json)13
(only (sigil array) array-map)14
(only (sigil http) url-encode-value)15
(sigil http client))17
(export ;; Client18
postmark-client19
postmark-client?20
postmark-client-server-token21
postmark-client-api-base-url23
;; Records24
postmark-email-result25
postmark-email-result?26
postmark-email-result-message-id27
postmark-email-result-submitted-at28
postmark-email-result-error-code29
postmark-email-result-message30
postmark-email-result-to32
postmark-bounce33
postmark-bounce?34
postmark-bounce-id35
postmark-bounce-type36
postmark-bounce-email37
postmark-bounce-description38
postmark-bounce-inactive?39
postmark-bounce-can-activate?40
postmark-bounce-bounced-at42
;; API functions43
postmark-send44
postmark-send-batch45
postmark-list-bounces46
postmark-get-bounce47
postmark-activate-bounce49
;; Request/response helpers (exported for testing)50
build-send-payload51
parse-email-result52
parse-bounce)54
(begin56
;; ---------------------------------------------------------------57
;; Client record58
;; ---------------------------------------------------------------60
(define-struct postmark-client61
(server-token)62
(api-base-url default: "https://api.postmarkapp.com"))64
;; ---------------------------------------------------------------65
;; Response records66
;; ---------------------------------------------------------------68
(define-struct postmark-email-result69
(message-id)70
(submitted-at)71
(error-code)72
(message)73
(to))75
(define-struct postmark-bounce76
(id)77
(type)78
(email)79
(description)80
(inactive?)81
(can-activate?)82
(bounced-at))84
;; ---------------------------------------------------------------85
;; Internal helpers86
;; ---------------------------------------------------------------88
;;; Build auth + content-type headers for a Postmark request.89
(define (postmark-headers client)90
#{ x-postmark-server-token: (postmark-client-server-token client)91
accept: "application/json"92
content-type: "application/json" })94
;;; Ensure a value is a comma-separated string for Postmark's95
;;; `To`, `Cc`, `Bcc`, `ReplyTo` fields.96
(define (join-addresses v)97
(cond98
((string? v) v)99
((list? v) (string-join v ", "))100
((not v) "")101
(else "")))103
;;; Response checker for Postmark API errors. Postmark returns a104
;;; JSON body with `ErrorCode` + `Message` on most failures; the105
;;; checker will surface that via sigil-http's standard error form.106
(define check-postmark-response107
(make-response-checker108
name: "Postmark API"109
handlers: (list110
(cons 401 "Unauthorized. Check server token.")111
(cons 422 "Invalid request. Check payload fields.")112
(cons 429 "Rate limit exceeded. Retry after a delay.")113
(cons 500 "Postmark server error. Retry after a delay."))))115
;;; Make a request to the Postmark API.116
;;; Returns parsed JSON response, or #t for empty responses.117
(define (postmark-request client method path payload-dict)118
(let* ((url (string-append (postmark-client-api-base-url client) path))119
(headers (postmark-headers client))120
(body (if payload-dict (json-encode payload-dict) "")))121
(check-postmark-response122
(cond123
((string=? method "POST")124
(http-post url body headers: headers))125
((string=? method "GET")126
(http-get url headers: headers))127
((string=? method "PUT")128
(http-put url body headers: headers))129
(else (error "postmark-request: unsupported method" method))))))131
;; ---------------------------------------------------------------132
;; Request payload building133
;; ---------------------------------------------------------------135
;;; Build the JSON payload dict for a single send request.136
;;;137
;;; Required: from, to, subject. At least one of text or html.138
;;; Optional keyword args: cc, bcc, reply-to, tag, metadata,139
;;; message-stream (default "outbound").140
(define (build-send-payload from to subject141
(keys: (text #f)142
(html #f)143
(cc #f)144
(bcc #f)145
(reply-to #f)146
(tag #f)147
(metadata #f)148
(message-stream "outbound")))149
(let* ((base #{ From: from150
To: (join-addresses to)151
Subject: subject152
MessageStream: message-stream })153
(with-text (if text (dict-set base TextBody: text) base))154
(with-html (if html (dict-set with-text HtmlBody: html) with-text))155
(with-cc (if cc (dict-set with-html Cc: (join-addresses cc))156
with-html))157
(with-bcc (if bcc (dict-set with-cc Bcc: (join-addresses bcc))158
with-cc))159
(with-reply (if reply-to160
(dict-set with-bcc ReplyTo: (join-addresses reply-to))161
with-bcc))162
(with-tag (if tag (dict-set with-reply Tag: tag) with-reply))163
(with-meta (if metadata164
(dict-set with-tag Metadata: metadata)165
with-tag)))166
(if (and (not text) (not html))167
(error "postmark-send: must provide text or html body")168
with-meta)))170
;; ---------------------------------------------------------------171
;; Response parsing172
;; ---------------------------------------------------------------174
;;; Parse a single-send response dict into a postmark-email-result.175
(define (parse-email-result data)176
(postmark-email-result177
message-id: (dict-ref data MessageID: "")178
submitted-at: (dict-ref data SubmittedAt: "")179
error-code: (dict-ref data ErrorCode: 0)180
message: (dict-ref data Message: "")181
to: (dict-ref data To: "")))183
;;; Parse a bounce entry into a postmark-bounce record.184
(define (parse-bounce data)185
(postmark-bounce186
id: (dict-ref data ID: 0)187
type: (dict-ref data Type: "")188
email: (dict-ref data Email: "")189
description: (dict-ref data Description: "")190
inactive?: (dict-ref data Inactive: #f)191
can-activate?: (dict-ref data CanActivate: #f)192
bounced-at: (dict-ref data BouncedAt: "")))194
;; ---------------------------------------------------------------195
;; Email sending196
;; ---------------------------------------------------------------198
;;; Send a single email via Postmark's `POST /email` endpoint.199
;;;200
;;; Parameters:201
;;; client: postmark-client record202
;;; from: sender address (string, may include display name)203
;;; to: recipient — string or list of strings204
;;; subject: subject line (string)205
;;;206
;;; Keyword arguments:207
;;; text: plain text body (string)208
;;; html: HTML body (string)209
;;; cc, bcc: string or list of strings210
;;; reply-to: string or list of strings211
;;; tag: tag for categorizing messages (string)212
;;; metadata: dict of custom metadata213
;;; message-stream: stream ID (default "outbound")214
;;;215
;;; Returns a postmark-email-result record.216
(define (postmark-send client from to subject . args)217
(let* ((kw-args (cdr (split-keyword-args args)))218
(text (keyword-ref kw-args text: #f))219
(html (keyword-ref kw-args html: #f))220
(cc (keyword-ref kw-args cc: #f))221
(bcc (keyword-ref kw-args bcc: #f))222
(reply-to (keyword-ref kw-args reply-to: #f))223
(tag (keyword-ref kw-args tag: #f))224
(metadata (keyword-ref kw-args metadata: #f))225
(stream (keyword-ref kw-args message-stream: "outbound"))226
(payload (build-send-payload from to subject227
text: text html: html228
cc: cc bcc: bcc reply-to: reply-to229
tag: tag metadata: metadata230
message-stream: stream))231
(result (postmark-request client "POST" "/email" payload)))232
(parse-email-result result)))234
;;; Send a batch of messages via `POST /email/batch`.235
;;;236
;;; Parameters:237
;;; client: postmark-client record238
;;; messages: list of dicts already shaped as Postmark payloads239
;;; (use `build-send-payload` to construct them)240
;;;241
;;; Returns a list of postmark-email-result records, one per242
;;; message, in the same order as the input.243
(define (postmark-send-batch client messages)244
(let ((result (postmark-request client "POST" "/email/batch"245
(list->array messages))))246
(if (array? result)247
(array->list (array-map parse-email-result result))248
'())))250
;; ---------------------------------------------------------------251
;; Bounce management252
;; ---------------------------------------------------------------254
;;; List bounces. Optional keyword args map to Postmark query string:255
;;; count (default 50), offset (default 0), type, inactive, email.256
(define (postmark-list-bounces client . args)257
(let* ((kw-args (cdr (split-keyword-args args)))258
(count (keyword-ref kw-args count: 50))259
(offset (keyword-ref kw-args offset: 0))260
(type (keyword-ref kw-args type: #f))261
(inactive (keyword-ref kw-args inactive: #f))262
(email (keyword-ref kw-args email: #f))263
(parts (list (string-append "count=" (number->string count))264
(string-append "offset=" (number->string offset))))265
(parts (if type266
(append parts267
(list (string-append "type="268
(url-encode-value type))))269
parts))270
(parts (if (not (eq? inactive #f))271
(append parts272
(list (string-append "inactive="273
(if inactive "true" "false"))))274
parts))275
(parts (if email276
(append parts277
(list (string-append "email="278
(url-encode-value email))))279
parts))280
(query (string-join parts "&"))281
(data (postmark-request client "GET"282
(string-append "/bounces?" query) #f)))283
(let ((items (dict-ref data Bounces: #[])))284
(if (array? items)285
(array->list (array-map parse-bounce items))286
'()))))288
;;; Get a single bounce by ID.289
(define (postmark-get-bounce client id)290
(let ((data (postmark-request client "GET"291
(string-append "/bounces/" (number->string id))292
#f)))293
(parse-bounce data)))295
;;; Re-activate a bounced address. Returns the raw response dict.296
(define (postmark-activate-bounce client id)297
(postmark-request client "PUT"298
(string-append "/bounces/" (number->string id) "/activate")299
#{}))))