AtlatestRepositorysigil-postmark
sigil-postmark / tree / src / postmarkwebhook.sgl
1
;;; (postmark webhook) - Postmark webhook event parsing.2
;;;3
;;; Postmark POSTs JSON payloads to a configured webhook URL when4
;;; bounce, spam-complaint, delivery, open, or click events occur.5
;;; This module provides a stateless parser that decodes those6
;;; payloads into a typed record regardless of event type.8
(define-library (postmark webhook)9
(import (sigil core)10
(sigil dict)11
(sigil string)12
(sigil struct)13
(sigil json))15
(export ;; Records16
postmark-webhook-event17
postmark-webhook-event?18
postmark-webhook-event-type19
postmark-webhook-event-message-id20
postmark-webhook-event-recipient21
postmark-webhook-event-details22
postmark-webhook-event-raw24
;; Parsing25
postmark-parse-webhook27
;; Convenience predicates28
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?)35
(begin37
;; ---------------------------------------------------------------38
;; Event record39
;; ---------------------------------------------------------------41
(define-struct postmark-webhook-event42
(type) ; 'bounce 'spam-complaint 'delivery 'open 'click43
(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 dict48
;; ---------------------------------------------------------------49
;; Parsing50
;; ---------------------------------------------------------------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 to56
;;; detecting characteristic fields.57
(define (classify-event data)58
(let ((record-type (dict-ref data RecordType: #f)))59
(cond60
((not record-type)61
(cond62
((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))))76
;;; Build an event-type-specific details dict.77
(define (extract-details type data)78
(cond79
((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 #{})))108
;;; Parse a Postmark webhook JSON payload into a109
;;; `postmark-webhook-event` record. Accepts either a parsed dict110
;;; 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
#f116
(postmark-webhook-event117
type: type118
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))))125
;; ---------------------------------------------------------------126
;; Convenience predicates127
;; ---------------------------------------------------------------129
(define (postmark-bounce-event? ev)130
(and (postmark-webhook-event? ev)131
(eq? (postmark-webhook-event-type ev) 'bounce)))133
;;; A hard bounce is one Postmark marks as "HardBounce". Soft134
;;; 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")))141
(define (postmark-spam-complaint-event? ev)142
(and (postmark-webhook-event? ev)143
(eq? (postmark-webhook-event-type ev) 'spam-complaint)))145
(define (postmark-delivery-event? ev)146
(and (postmark-webhook-event? ev)147
(eq? (postmark-webhook-event-type ev) 'delivery)))149
(define (postmark-open-event? ev)150
(and (postmark-webhook-event? ev)151
(eq? (postmark-webhook-event-type ev) 'open)))153
(define (postmark-click-event? ev)154
(and (postmark-webhook-event? ev)155
(eq? (postmark-webhook-event-type ev) 'click)))))