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
+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
+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-Clause
examples/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]"
+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"
+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"
+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)