AtlatestRepositorysigil-postmark
1;;; (postmark api) - Postmark HTTP API client library.
2;;;
3;;; Provides functions for sending transactional and broadcast email
4;;; through Postmark's REST API. Authentication is a single server token
5;;; passed as the `X-Postmark-Server-Token` header — no request signing.
6
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 ;; Client
18 postmark-client
19 postmark-client?
20 postmark-client-server-token
21 postmark-client-api-base-url
23 ;; Records
24 postmark-email-result
25 postmark-email-result?
26 postmark-email-result-message-id
27 postmark-email-result-submitted-at
28 postmark-email-result-error-code
29 postmark-email-result-message
30 postmark-email-result-to
32 postmark-bounce
33 postmark-bounce?
34 postmark-bounce-id
35 postmark-bounce-type
36 postmark-bounce-email
37 postmark-bounce-description
38 postmark-bounce-inactive?
39 postmark-bounce-can-activate?
40 postmark-bounce-bounced-at
42 ;; API functions
43 postmark-send
44 postmark-send-batch
45 postmark-list-bounces
46 postmark-get-bounce
47 postmark-activate-bounce
49 ;; Request/response helpers (exported for testing)
50 build-send-payload
51 parse-email-result
52 parse-bounce)
54 (begin
56 ;; ---------------------------------------------------------------
57 ;; Client record
58 ;; ---------------------------------------------------------------
60 (define-struct postmark-client
61 (server-token)
62 (api-base-url default: "https://api.postmarkapp.com"))
64 ;; ---------------------------------------------------------------
65 ;; Response records
66 ;; ---------------------------------------------------------------
68 (define-struct postmark-email-result
69 (message-id)
70 (submitted-at)
71 (error-code)
72 (message)
73 (to))
75 (define-struct postmark-bounce
76 (id)
77 (type)
78 (email)
79 (description)
80 (inactive?)
81 (can-activate?)
82 (bounced-at))
84 ;; ---------------------------------------------------------------
85 ;; Internal helpers
86 ;; ---------------------------------------------------------------
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's
95 ;;; `To`, `Cc`, `Bcc`, `ReplyTo` fields.
96 (define (join-addresses v)
97 (cond
98 ((string? v) v)
99 ((list? v) (string-join v ", "))
100 ((not v) "")
101 (else "")))
103 ;;; Response checker for Postmark API errors. Postmark returns a
104 ;;; JSON body with `ErrorCode` + `Message` on most failures; the
105 ;;; checker will surface that via sigil-http's standard error form.
106 (define check-postmark-response
107 (make-response-checker
108 name: "Postmark API"
109 handlers: (list
110 (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-response
122 (cond
123 ((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 building
133 ;; ---------------------------------------------------------------
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 subject
141 (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: from
150 To: (join-addresses to)
151 Subject: subject
152 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-to
160 (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 metadata
164 (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 parsing
172 ;; ---------------------------------------------------------------
174 ;;; Parse a single-send response dict into a postmark-email-result.
175 (define (parse-email-result data)
176 (postmark-email-result
177 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-bounce
186 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 sending
196 ;; ---------------------------------------------------------------
198 ;;; Send a single email via Postmark's `POST /email` endpoint.
199 ;;;
200 ;;; Parameters:
201 ;;; client: postmark-client record
202 ;;; from: sender address (string, may include display name)
203 ;;; to: recipient — string or list of strings
204 ;;; 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 strings
210 ;;; reply-to: string or list of strings
211 ;;; tag: tag for categorizing messages (string)
212 ;;; metadata: dict of custom metadata
213 ;;; 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 subject
227 text: text html: html
228 cc: cc bcc: bcc reply-to: reply-to
229 tag: tag metadata: metadata
230 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 record
238 ;;; messages: list of dicts already shaped as Postmark payloads
239 ;;; (use `build-send-payload` to construct them)
240 ;;;
241 ;;; Returns a list of postmark-email-result records, one per
242 ;;; 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 management
252 ;; ---------------------------------------------------------------
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 type
266 (append parts
267 (list (string-append "type="
268 (url-encode-value type))))
269 parts))
270 (parts (if (not (eq? inactive #f))
271 (append parts
272 (list (string-append "inactive="
273 (if inactive "true" "false"))))
274 parts))
275 (parts (if email
276 (append parts
277 (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 #{}))))