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 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))
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
24 ;; Parsing
25 postmark-parse-webhook
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?)
35 (begin
37 ;; ---------------------------------------------------------------
38 ;; Event record
39 ;; ---------------------------------------------------------------
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
48 ;; ---------------------------------------------------------------
49 ;; Parsing
50 ;; ---------------------------------------------------------------
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))))
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 #{})))
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))))
125 ;; ---------------------------------------------------------------
126 ;; Convenience predicates
127 ;; ---------------------------------------------------------------
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". 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")))
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)))))