Commit7152898aRecorded21 Apr 2026Repositorysigil-postmark
Initial sigil-postmark v0.1.0
Message
Postmark HTTP API client library for Sigil. Mirrors the surface of sigil-ses so consumers can swap providers by changing imports + env vars. Authentication is a single X-Postmark-Server-Token header — no request signing, no sigil-crypto dependency.
Includes: - Single send and batch send (up to 500 messages) - Bounce list/get/activate endpoints - Webhook parser for bounce, spam-complaint, delivery, open, click - 24 unit tests, no network - examples/live-send.sgl integration harness (reads POSTMARKSERVERTOKEN)
Changed
.gitignore | 2 ++
README.md | 112 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
examples/live-send.sgl | 62 ++++++++++++++++++++++++++++++++++
package.sgl | 37 +++++++++++++++++++++
sigil.lock | 18 ++++++++++
src/postmark/api.sgl | 299 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
src/postmark/webhook.sgl | 155 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
test/postmark-test.sgl | 304 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
8 files changed, 989 insertions(+)Diff
.gitignoreadded
@@ -0,0 +1,2 @@
+1
build/+2
.sigil/README.mdadded
@@ -0,0 +1,112 @@
+1
# sigil-postmark+2
+3
A Sigil library for the [Postmark](https://postmarkapp.com) email API. Send transactional and broadcast email, query bounces, and parse delivery / bounce / complaint / open / click webhooks — all with a single server-token header, no request signing.+4
+5
Designed to drop in as a replacement for `sigil-ses` when you'd rather not fight AWS sandbox approval.+6
+7
## Features+8
+9
- **Single send** — plain text + HTML with To / Cc / Bcc / ReplyTo, tagging, metadata, and message streams.+10
- **Batch send** — up to 500 messages per request, one HTTP round trip.+11
- **Bounce queries** — list bounces with filters, fetch by ID, re-activate inactive addresses.+12
- **Webhook parsing** — typed records for bounce, spam-complaint, delivery, open, and click events. Includes both JSON-string and pre-parsed dict input.+13
+14
## Quickstart+15
+16
```scheme+17
(import (postmark api))+18
+19
(define client+20
(postmark-client server-token: (getenv "POSTMARK_SERVER_TOKEN")))+21
+22
;; Single send+23
(let ((result (postmark-send client+24
"[email protected]"+25
"[email protected]"+26
"Hello from Sigil"+27
text: "Plain-text body."+28
html: "<h1>Hello</h1><p>HTML body.</p>"+29
tag: "newsletter"+30
reply-to: "[email protected]")))+31
(display (postmark-email-result-message-id result)))+32
+33
;; Send to multiple recipients+34
(postmark-send client+35
"[email protected]"+36
'("[email protected]" "[email protected]")+37
"Issue #42"+38
text: "Content…"+39
bcc: "[email protected]")+40
+41
;; Batch send — hand-build each payload+42
(postmark-send-batch client+43
(list+44
(build-send-payload "[email protected]" "[email protected]"+45
"Subject A" text: "Body A")+46
(build-send-payload "[email protected]" "[email protected]"+47
"Subject B" text: "Body B")))+48
+49
;; Query bounces+50
(postmark-list-bounces client count: 100 type: "HardBounce")+51
+52
;; Reactivate a bounced address+53
(postmark-activate-bounce client 12345)+54
```+55
+56
## Webhook parsing+57
+58
Configure a Postmark webhook to POST to your HTTP handler, then:+59
+60
```scheme+61
(import (postmark webhook))+62
+63
(let ((ev (postmark-parse-webhook json-body)))+64
(when ev+65
(cond+66
((postmark-hard-bounce-event? ev)+67
;; deactivate the address+68
(remove-subscriber (postmark-webhook-event-recipient ev)))+69
((postmark-spam-complaint-event? ev)+70
(unsubscribe (postmark-webhook-event-recipient ev)))+71
((postmark-delivery-event? ev)+72
(log-delivered (postmark-webhook-event-message-id ev))))))+73
```+74
+75
`postmark-webhook-event` carries:+76
+77
- `type` — `'bounce`, `'spam-complaint`, `'delivery`, `'open`, `'click`+78
- `message-id` — Postmark message ID+79
- `recipient` — affected address+80
- `details` — event-specific dict (bounce type code, user agent, click target, …)+81
- `raw` — the original parsed dict, for any field not exposed above+82
+83
## Environment variables+84
+85
Consumers typically read a single variable:+86
+87
- `POSTMARK_SERVER_TOKEN` — per-server API token from the Postmark console.+88
+89
The bundled `examples/live-send.sgl` script also honours:+90
+91
- `POSTMARK_FROM` — verified sender (default `[email protected]`)+92
- `POSTMARK_TO` — recipient for the integration test+93
+94
## Dependencies+95
+96
- `sigil-stdlib` ^0.13.0+97
- `sigil-http` ^0.13.0+98
- `sigil-json` ^0.13.0+99
+100
(No `sigil-crypto` — Postmark does not sign requests.)+101
+102
## Building+103
+104
```bash+105
sigil deps install+106
sigil build+107
sigil test+108
```+109
+110
## License+111
+112
BSD-3-Clauseexamples/live-send.sgladded
@@ -0,0 +1,62 @@
+1
;;; Live integration test — sends real email via Postmark.+2
;;;+3
;;; Requires environment:+4
;;; POSTMARK_SERVER_TOKEN — the server API token for David's Postmark+5
;;; account (David runs this, don't commit it)+6
;;; POSTMARK_FROM — verified sender address+7
;;; (default [email protected])+8
;;; POSTMARK_TO — recipient address+9
;;; (default [email protected])+10
;;;+11
;;; Run:+12
;;; sigil run examples/live-send.sgl+13
+14
(import (sigil core)+15
(only (sigil process) getenv)+16
(postmark api))+17
+18
(define token+19
(or (getenv "POSTMARK_SERVER_TOKEN")+20
(error "POSTMARK_SERVER_TOKEN must be set")))+21
+22
(define from+23
(or (getenv "POSTMARK_FROM") "[email protected]"))+24
+25
(define to+26
(or (getenv "POSTMARK_TO") "[email protected]"))+27
+28
(define client (postmark-client server-token: token))+29
+30
;; Test 1 — single send+31
(display "=== Single send ===\n")+32
(let ((result (postmark-send client from to+33
"sigil-postmark integration test"+34
text: "This is a plain-text integration test from sigil-postmark."+35
html: (string-append+36
"<h2>sigil-postmark integration test</h2>"+37
"<p>Sent by the <code>sigil-postmark</code> library.</p>"))))+38
(display "Message ID: ") (display (postmark-email-result-message-id result)) (newline)+39
(display "Submitted: ") (display (postmark-email-result-submitted-at result)) (newline)+40
(display "Error code: ") (display (postmark-email-result-error-code result)) (newline)+41
(display "Message: ") (display (postmark-email-result-message result)) (newline))+42
+43
;; Test 2 — batch of 2+44
(display "\n=== Batch send (2 messages) ===\n")+45
(let ((results (postmark-send-batch client+46
(list+47
(build-send-payload from to+48
"sigil-postmark batch 1"+49
text: "Batch message 1")+50
(build-send-payload from to+51
"sigil-postmark batch 2"+52
text: "Batch message 2")))))+53
(for-each+54
(lambda (r)+55
(display "Message ID: ")+56
(display (postmark-email-result-message-id r))+57
(display " Error: ")+58
(display (postmark-email-result-error-code r))+59
(newline))+60
results))+61
+62
(display "\nDone!\n")package.sgladded
@@ -0,0 +1,37 @@
+1
;;; sigil-postmark - Postmark HTTP API client library for Sigil+2
;;;+3
;;; Provides functions for sending email (single + batch) and parsing+4
;;; Postmark webhook events (bounce, spam-complaint, delivery, open, click).+5
+6
(package+7
name: "sigil-postmark"+8
version: "0.1.0"+9
description: "Postmark API client library for Sigil"+10
url: "https://codeberg.org/sigil/sigil-postmark"+11
license: "BSD-3-Clause"+12
authors: (list "David Wilson <[email protected]>")+13
+14
configs: (list+15
(config+16
name: 'dev+17
output-dir: "build/dev"+18
debug?: #t+19
optimize: 0)+20
(config+21
name: 'release+22
output-dir: "build/release"+23
debug?: #f+24
optimize: 2))+25
+26
dependencies: (list+27
(from-git url: "codeberg:sigil/sigil" package: "sigil-stdlib" version: "^0.13.0")+28
(from-git url: "codeberg:sigil/sigil" package: "sigil-http" version: "^0.13.0")+29
(from-git url: "codeberg:sigil/sigil" package: "sigil-json" version: "^0.13.0"))+30
+31
tasks: (list+32
(task+33
name: 'build+34
description: "Compile sigil-postmark modules"+35
steps: (list+36
(compile-sigil-modules sources: "src/**/*.sgl"+37
output-dir: (config-output-subdir "lib"))))))sigil.lockadded
@@ -0,0 +1,18 @@
+1
;; Auto-generated by sigil deps install. Do not edit.+2
(lock+3
(package name: "sigil-stdlib"+4
url: "codeberg:sigil/sigil"+5
ref: "^0.13.0"+6
sha: "4d2b385e81d15ca447106e22fbf3db6de17b2a7e"+7
package-selector: "sigil-stdlib")+8
(package name: "sigil-http"+9
url: "codeberg:sigil/sigil"+10
ref: "^0.13.0"+11
sha: "4d2b385e81d15ca447106e22fbf3db6de17b2a7e"+12
package-selector: "sigil-http")+13
(package name: "sigil-json"+14
url: "codeberg:sigil/sigil"+15
ref: "^0.13.0"+16
sha: "4d2b385e81d15ca447106e22fbf3db6de17b2a7e"+17
package-selector: "sigil-json")+18
)src/postmark/api.sgladded
@@ -0,0 +1,299 @@
+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))+16
+17
(export ;; Client+18
postmark-client+19
postmark-client?+20
postmark-client-server-token+21
postmark-client-api-base-url+22
+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+31
+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+41
+42
;; API functions+43
postmark-send+44
postmark-send-batch+45
postmark-list-bounces+46
postmark-get-bounce+47
postmark-activate-bounce+48
+49
;; Request/response helpers (exported for testing)+50
build-send-payload+51
parse-email-result+52
parse-bounce)+53
+54
(begin+55
+56
;; ---------------------------------------------------------------+57
;; Client record+58
;; ---------------------------------------------------------------+59
+60
(define-struct postmark-client+61
(server-token)+62
(api-base-url default: "https://api.postmarkapp.com"))+63
+64
;; ---------------------------------------------------------------+65
;; Response records+66
;; ---------------------------------------------------------------+67
+68
(define-struct postmark-email-result+69
(message-id)+70
(submitted-at)+71
(error-code)+72
(message)+73
(to))+74
+75
(define-struct postmark-bounce+76
(id)+77
(type)+78
(email)+79
(description)+80
(inactive?)+81
(can-activate?)+82
(bounced-at))+83
+84
;; ---------------------------------------------------------------+85
;; Internal helpers+86
;; ---------------------------------------------------------------+87
+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" })+93
+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 "")))+102
+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."))))+114
+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))))))+130
+131
;; ---------------------------------------------------------------+132
;; Request payload building+133
;; ---------------------------------------------------------------+134
+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)))+169
+170
;; ---------------------------------------------------------------+171
;; Response parsing+172
;; ---------------------------------------------------------------+173
+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: "")))+182
+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: "")))+193
+194
;; ---------------------------------------------------------------+195
;; Email sending+196
;; ---------------------------------------------------------------+197
+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)))+233
+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
'())))+249
+250
;; ---------------------------------------------------------------+251
;; Bounce management+252
;; ---------------------------------------------------------------+253
+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
'()))))+287
+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)))+294
+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
#{}))))src/postmark/webhook.sgladded
@@ -0,0 +1,155 @@
+1
;;; (postmark webhook) - Postmark webhook event parsing.+2
;;;+3
;;; Postmark POSTs JSON payloads to a configured webhook URL when+4
;;; bounce, spam-complaint, delivery, open, or click events occur.+5
;;; This module provides a stateless parser that decodes those+6
;;; payloads into a typed record regardless of event type.+7
+8
(define-library (postmark webhook)+9
(import (sigil core)+10
(sigil dict)+11
(sigil string)+12
(sigil struct)+13
(sigil json))+14
+15
(export ;; Records+16
postmark-webhook-event+17
postmark-webhook-event?+18
postmark-webhook-event-type+19
postmark-webhook-event-message-id+20
postmark-webhook-event-recipient+21
postmark-webhook-event-details+22
postmark-webhook-event-raw+23
+24
;; Parsing+25
postmark-parse-webhook+26
+27
;; Convenience predicates+28
postmark-bounce-event?+29
postmark-hard-bounce-event?+30
postmark-spam-complaint-event?+31
postmark-delivery-event?+32
postmark-open-event?+33
postmark-click-event?)+34
+35
(begin+36
+37
;; ---------------------------------------------------------------+38
;; Event record+39
;; ---------------------------------------------------------------+40
+41
(define-struct postmark-webhook-event+42
(type) ; 'bounce 'spam-complaint 'delivery 'open 'click+43
(message-id) ; Postmark message ID (string)+44
(recipient) ; affected email address (string)+45
(details) ; event-specific dict — bounce type, user-agent, etc.+46
(raw)) ; original parsed dict+47
+48
;; ---------------------------------------------------------------+49
;; Parsing+50
;; ---------------------------------------------------------------+51
+52
;;; Classify a Postmark webhook payload by inspecting its fields.+53
;;;+54
;;; Postmark uses a `RecordType` field on newer webhook versions,+55
;;; but older bounce webhooks don't carry one, so we fall back to+56
;;; detecting characteristic fields.+57
(define (classify-event data)+58
(let ((record-type (dict-ref data RecordType: #f)))+59
(cond+60
((not record-type)+61
(cond+62
((dict-ref data BounceID: #f) 'bounce)+63
((dict-ref data DeliveredAt: #f) 'delivery)+64
((dict-ref data OriginalLink: #f) 'click)+65
((dict-ref data FirstOpen: #f) 'open)+66
((dict-ref data Client: #f) 'open)+67
(else 'unknown)))+68
((string-ci=? record-type "Bounce") 'bounce)+69
((string-ci=? record-type "SpamComplaint") 'spam-complaint)+70
((string-ci=? record-type "Delivery") 'delivery)+71
((string-ci=? record-type "Open") 'open)+72
((string-ci=? record-type "Click") 'click)+73
((string-ci=? record-type "SubscriptionChange") 'subscription-change)+74
(else 'unknown))))+75
+76
;;; Build an event-type-specific details dict.+77
(define (extract-details type data)+78
(cond+79
((eq? type 'bounce)+80
#{ bounce-type: (dict-ref data Type: "")+81
type-code: (dict-ref data TypeCode: 0)+82
description: (dict-ref data Description: "")+83
details: (dict-ref data Details: "")+84
inactive?: (dict-ref data Inactive: #f)+85
can-activate?: (dict-ref data CanActivate: #f)+86
bounced-at: (dict-ref data BouncedAt: "") })+87
((eq? type 'spam-complaint)+88
#{ bounce-type: (dict-ref data Type: "SpamComplaint")+89
description: (dict-ref data Description: "")+90
bounced-at: (dict-ref data BouncedAt: "") })+91
((eq? type 'delivery)+92
#{ delivered-at: (dict-ref data DeliveredAt: "")+93
details: (dict-ref data Details: "") })+94
((eq? type 'open)+95
#{ opened-at: (dict-ref data ReceivedAt: "")+96
first-open?: (dict-ref data FirstOpen: #f)+97
client: (dict-ref data Client: #{})+98
os: (dict-ref data OS: #{})+99
platform: (dict-ref data Platform: "")+100
user-agent: (dict-ref data UserAgent: "") })+101
((eq? type 'click)+102
#{ clicked-at: (dict-ref data ReceivedAt: "")+103
original-link: (dict-ref data OriginalLink: "")+104
click-location: (dict-ref data ClickLocation: "")+105
user-agent: (dict-ref data UserAgent: "") })+106
(else #{})))+107
+108
;;; Parse a Postmark webhook JSON payload into a+109
;;; `postmark-webhook-event` record. Accepts either a parsed dict+110
;;; or a JSON string. Returns #f for unknown event types.+111
(define (postmark-parse-webhook data)+112
(let* ((parsed (if (string? data) (json-decode data) data))+113
(type (classify-event parsed)))+114
(if (eq? type 'unknown)+115
#f+116
(postmark-webhook-event+117
type: type+118
message-id: (dict-ref parsed MessageID: "")+119
recipient: (or (dict-ref parsed Email: #f)+120
(dict-ref parsed Recipient: #f)+121
"")+122
details: (extract-details type parsed)+123
raw: parsed))))+124
+125
;; ---------------------------------------------------------------+126
;; Convenience predicates+127
;; ---------------------------------------------------------------+128
+129
(define (postmark-bounce-event? ev)+130
(and (postmark-webhook-event? ev)+131
(eq? (postmark-webhook-event-type ev) 'bounce)))+132
+133
;;; A hard bounce is one Postmark marks as "HardBounce". Soft+134
;;; bounces and transient issues use other Type values.+135
(define (postmark-hard-bounce-event? ev)+136
(and (postmark-bounce-event? ev)+137
(string=? (dict-ref (postmark-webhook-event-details ev)+138
bounce-type: "")+139
"HardBounce")))+140
+141
(define (postmark-spam-complaint-event? ev)+142
(and (postmark-webhook-event? ev)+143
(eq? (postmark-webhook-event-type ev) 'spam-complaint)))+144
+145
(define (postmark-delivery-event? ev)+146
(and (postmark-webhook-event? ev)+147
(eq? (postmark-webhook-event-type ev) 'delivery)))+148
+149
(define (postmark-open-event? ev)+150
(and (postmark-webhook-event? ev)+151
(eq? (postmark-webhook-event-type ev) 'open)))+152
+153
(define (postmark-click-event? ev)+154
(and (postmark-webhook-event? ev)+155
(eq? (postmark-webhook-event-type ev) 'click)))))test/postmark-test.sgladded
@@ -0,0 +1,304 @@
+1
;;; Tests for sigil-postmark+2
+3
(import (sigil core)+4
(sigil dict)+5
(sigil string)+6
(sigil struct)+7
(sigil json)+8
(sigil test)+9
(postmark api)+10
(postmark webhook))+11
+12
;; ---------------------------------------------------------------+13
;; Client construction+14
;; ---------------------------------------------------------------+15
+16
(test-group "client construction"+17
+18
(test "default api-base-url"+19
(let ((c (postmark-client server-token: "tok-1")))+20
(assert-true (postmark-client? c))+21
(assert-equal "tok-1" (postmark-client-server-token c))+22
(assert-equal "https://api.postmarkapp.com"+23
(postmark-client-api-base-url c))))+24
+25
(test "custom api-base-url"+26
(let ((c (postmark-client server-token: "tok-2"+27
api-base-url: "https://mock.test")))+28
(assert-equal "https://mock.test"+29
(postmark-client-api-base-url c)))))+30
+31
;; ---------------------------------------------------------------+32
;; Request payload building+33
;; ---------------------------------------------------------------+34
+35
(test-group "build-send-payload — required fields"+36
+37
(test "text body only"+38
(let ((p (build-send-payload "[email protected]" "[email protected]" "Hi" text: "hello")))+39
(assert-equal "[email protected]" (dict-ref p From:))+40
(assert-equal "[email protected]" (dict-ref p To:))+41
(assert-equal "Hi" (dict-ref p Subject:))+42
(assert-equal "hello" (dict-ref p TextBody:))+43
(assert-equal "outbound" (dict-ref p MessageStream:))))+44
+45
(test "html body only"+46
(let ((p (build-send-payload "[email protected]" "[email protected]" "Hi" html: "<p>hi</p>")))+47
(assert-equal "<p>hi</p>" (dict-ref p HtmlBody:))))+48
+49
(test "both text and html"+50
(let ((p (build-send-payload "[email protected]" "[email protected]" "Hi"+51
text: "plain" html: "<p>rich</p>")))+52
(assert-equal "plain" (dict-ref p TextBody:))+53
(assert-equal "<p>rich</p>" (dict-ref p HtmlBody:))))+54
+55
(test "to list joined with commas"+56
(let ((p (build-send-payload "[email protected]"+57
'("[email protected]" "[email protected]")+58
"Hi" text: "hello")))+59
(assert-equal "[email protected], [email protected]" (dict-ref p To:)))))+60
+61
(test-group "build-send-payload — optional fields"+62
+63
(test "cc, bcc, reply-to as strings"+64
(let ((p (build-send-payload "[email protected]" "[email protected]" "Hi"+65
text: "hi"+66
cc: "[email protected]"+67
bcc: "[email protected]"+68
reply-to: "[email protected]")))+69
(assert-equal "[email protected]" (dict-ref p Cc:))+70
(assert-equal "[email protected]" (dict-ref p Bcc:))+71
(assert-equal "[email protected]" (dict-ref p ReplyTo:))))+72
+73
(test "cc as list"+74
(let ((p (build-send-payload "[email protected]" "[email protected]" "Hi"+75
text: "hi"+76
cc: '("[email protected]" "[email protected]"))))+77
(assert-equal "[email protected], [email protected]" (dict-ref p Cc:))))+78
+79
(test "tag + metadata + stream"+80
(let ((p (build-send-payload "[email protected]" "[email protected]" "Hi"+81
text: "hi"+82
tag: "newsletter"+83
metadata: #{ campaign: "2026-04" }+84
message-stream: "broadcast")))+85
(assert-equal "newsletter" (dict-ref p Tag:))+86
(assert-equal "2026-04" (dict-ref (dict-ref p Metadata:) campaign:))+87
(assert-equal "broadcast" (dict-ref p MessageStream:))))+88
+89
(test "omitted optional fields are absent"+90
(let ((p (build-send-payload "[email protected]" "[email protected]" "Hi" text: "hi")))+91
(assert-equal #f (dict-ref p Cc: #f))+92
(assert-equal #f (dict-ref p Bcc: #f))+93
(assert-equal #f (dict-ref p ReplyTo: #f))+94
(assert-equal #f (dict-ref p Tag: #f))+95
(assert-equal #f (dict-ref p Metadata: #f))+96
(assert-equal #f (dict-ref p HtmlBody: #f)))))+97
+98
(test-group "build-send-payload encodes to JSON"+99
+100
(test "round-trips through json-encode/json-decode"+101
(let* ((p (build-send-payload "[email protected]" "[email protected]" "Hi"+102
text: "hello"+103
tag: "tag1"))+104
(round (json-decode (json-encode p))))+105
(assert-equal "[email protected]" (dict-ref round From:))+106
(assert-equal "hello" (dict-ref round TextBody:))+107
(assert-equal "tag1" (dict-ref round Tag:)))))+108
+109
;; ---------------------------------------------------------------+110
;; Response parsing+111
;; ---------------------------------------------------------------+112
+113
(test-group "parse-email-result — success"+114
+115
(test "success response"+116
(let ((r (parse-email-result+117
#{ To: "[email protected]"+118
SubmittedAt: "2026-04-21T10:00:00.000Z"+119
MessageID: "abc-123"+120
ErrorCode: 0+121
Message: "OK" })))+122
(assert-true (postmark-email-result? r))+123
(assert-equal "abc-123" (postmark-email-result-message-id r))+124
(assert-equal "2026-04-21T10:00:00.000Z"+125
(postmark-email-result-submitted-at r))+126
(assert-equal 0 (postmark-email-result-error-code r))+127
(assert-equal "OK" (postmark-email-result-message r))+128
(assert-equal "[email protected]" (postmark-email-result-to r)))))+129
+130
(test-group "parse-email-result — error"+131
+132
(test "error response still parses"+133
(let ((r (parse-email-result+134
#{ ErrorCode: 406+135
Message: "Inactive recipient"+136
To: "[email protected]" })))+137
(assert-equal 406 (postmark-email-result-error-code r))+138
(assert-equal "Inactive recipient"+139
(postmark-email-result-message r))+140
(assert-equal "" (postmark-email-result-message-id r)))))+141
+142
(test-group "parse-bounce"+143
+144
(test "parse bounce entry"+145
(let ((b (parse-bounce+146
#{ ID: 12345+147
Type: "HardBounce"+148
Email: "[email protected]"+149
Description: "The server could not deliver your message"+150
Inactive: #t+151
CanActivate: #t+152
BouncedAt: "2026-04-20T12:00:00Z" })))+153
(assert-true (postmark-bounce? b))+154
(assert-equal 12345 (postmark-bounce-id b))+155
(assert-equal "HardBounce" (postmark-bounce-type b))+156
(assert-equal "[email protected]" (postmark-bounce-email b))+157
(assert-equal #t (postmark-bounce-inactive? b))+158
(assert-equal #t (postmark-bounce-can-activate? b))+159
(assert-equal "2026-04-20T12:00:00Z"+160
(postmark-bounce-bounced-at b)))))+161
+162
;; ---------------------------------------------------------------+163
;; Webhook parsing+164
;; ---------------------------------------------------------------+165
+166
(define bounce-json+167
(string-append+168
"{\"RecordType\": \"Bounce\","+169
" \"MessageID\": \"msg-1\","+170
" \"Email\": \"[email protected]\","+171
" \"Type\": \"HardBounce\","+172
" \"TypeCode\": 1,"+173
" \"Description\": \"gone\","+174
" \"Inactive\": true,"+175
" \"CanActivate\": true,"+176
" \"BouncedAt\": \"2026-04-20T12:00:00Z\"}"))+177
+178
(define spam-json+179
(string-append+180
"{\"RecordType\": \"SpamComplaint\","+181
" \"MessageID\": \"msg-2\","+182
" \"Email\": \"[email protected]\","+183
" \"BouncedAt\": \"2026-04-20T12:00:00Z\"}"))+184
+185
(define delivery-json+186
(string-append+187
"{\"RecordType\": \"Delivery\","+188
" \"MessageID\": \"msg-3\","+189
" \"Recipient\": \"[email protected]\","+190
" \"DeliveredAt\": \"2026-04-20T12:00:00Z\"}"))+191
+192
(define open-json+193
(string-append+194
"{\"RecordType\": \"Open\","+195
" \"MessageID\": \"msg-4\","+196
" \"Recipient\": \"[email protected]\","+197
" \"FirstOpen\": true,"+198
" \"Platform\": \"WebMail\","+199
" \"UserAgent\": \"Mozilla/5.0\","+200
" \"ReceivedAt\": \"2026-04-20T12:00:00Z\"}"))+201
+202
(define click-json+203
(string-append+204
"{\"RecordType\": \"Click\","+205
" \"MessageID\": \"msg-5\","+206
" \"Recipient\": \"[email protected]\","+207
" \"OriginalLink\": \"https://example.com\","+208
" \"ClickLocation\": \"HTML\","+209
" \"ReceivedAt\": \"2026-04-20T12:00:00Z\"}"))+210
+211
(test-group "webhook parsing — bounce"+212
+213
(test "parses bounce webhook"+214
(let ((ev (postmark-parse-webhook bounce-json)))+215
(assert-true (postmark-webhook-event? ev))+216
(assert-equal 'bounce (postmark-webhook-event-type ev))+217
(assert-equal "msg-1" (postmark-webhook-event-message-id ev))+218
(assert-equal "[email protected]" (postmark-webhook-event-recipient ev))+219
(let ((d (postmark-webhook-event-details ev)))+220
(assert-equal "HardBounce" (dict-ref d bounce-type:))+221
(assert-equal 1 (dict-ref d type-code:))+222
(assert-equal #t (dict-ref d inactive?:))+223
(assert-equal "2026-04-20T12:00:00Z" (dict-ref d bounced-at:)))))+224
+225
(test "bounce predicates"+226
(let ((ev (postmark-parse-webhook bounce-json)))+227
(assert-true (postmark-bounce-event? ev))+228
(assert-true (postmark-hard-bounce-event? ev))+229
(assert-false (postmark-spam-complaint-event? ev))+230
(assert-false (postmark-delivery-event? ev)))))+231
+232
(test-group "webhook parsing — spam complaint"+233
+234
(test "parses spam complaint webhook"+235
(let ((ev (postmark-parse-webhook spam-json)))+236
(assert-true (postmark-spam-complaint-event? ev))+237
(assert-equal 'spam-complaint (postmark-webhook-event-type ev))+238
(assert-equal "[email protected]" (postmark-webhook-event-recipient ev)))))+239
+240
(test-group "webhook parsing — delivery"+241
+242
(test "parses delivery webhook"+243
(let ((ev (postmark-parse-webhook delivery-json)))+244
(assert-true (postmark-delivery-event? ev))+245
(assert-equal 'delivery (postmark-webhook-event-type ev))+246
(assert-equal "[email protected]" (postmark-webhook-event-recipient ev))+247
(assert-equal "2026-04-20T12:00:00Z"+248
(dict-ref (postmark-webhook-event-details ev)+249
delivered-at:)))))+250
+251
(test-group "webhook parsing — open"+252
+253
(test "parses open webhook"+254
(let ((ev (postmark-parse-webhook open-json)))+255
(assert-true (postmark-open-event? ev))+256
(let ((d (postmark-webhook-event-details ev)))+257
(assert-equal #t (dict-ref d first-open?:))+258
(assert-equal "WebMail" (dict-ref d platform:))+259
(assert-equal "Mozilla/5.0" (dict-ref d user-agent:))))))+260
+261
(test-group "webhook parsing — click"+262
+263
(test "parses click webhook"+264
(let ((ev (postmark-parse-webhook click-json)))+265
(assert-true (postmark-click-event? ev))+266
(let ((d (postmark-webhook-event-details ev)))+267
(assert-equal "https://example.com" (dict-ref d original-link:))+268
(assert-equal "HTML" (dict-ref d click-location:))))))+269
+270
(test-group "webhook parsing — pre-parsed dicts"+271
+272
(test "accepts already-parsed dict"+273
(let* ((dict (json-decode bounce-json))+274
(ev (postmark-parse-webhook dict)))+275
(assert-true (postmark-bounce-event? ev))+276
(assert-equal "[email protected]" (postmark-webhook-event-recipient ev)))))+277
+278
(test-group "webhook parsing — unknown"+279
+280
(test "unknown record type returns #f"+281
(assert-false+282
(postmark-parse-webhook "{\"RecordType\": \"Nonsense\"}"))))+283
+284
(test-group "record construction"+285
+286
(test "postmark-email-result"+287
(let ((r (postmark-email-result message-id: "id"+288
submitted-at: "t"+289
error-code: 0+290
message: "ok"+291
to: "[email protected]")))+292
(assert-true (postmark-email-result? r))+293
(assert-equal "id" (postmark-email-result-message-id r))))+294
+295
(test "postmark-webhook-event"+296
(let ((ev (postmark-webhook-event type: 'bounce+297
message-id: "m"+298
recipient: "r"+299
details: #{}+300
raw: #{})))+301
(assert-true (postmark-webhook-event? ev))+302
(assert-equal 'bounce (postmark-webhook-event-type ev)))))+303
+304
(run-tests)