From 6b9cc3076ae3b1018a67a760d6e1ee3e9afa8489 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 8 Jul 2026 21:15:45 +0000 Subject: [PATCH 1/2] feat(messages): add openedAt/clickedAt engagement fields to Message Adds the new read-only engagement timestamps returned by the messages API: - opened_at: first provider-reported email open (ISO 8601), or None. - clicked_at: first provider-reported link click (ISO 8601), or None. Both are set once on the first occurrence and hydrated on the Message model. README documents the two new subscribable webhook events, message.opened and message.clicked (the latter carries the clicked link). --- README.md | 5 +++++ src/senderkit/models.py | 8 ++++++++ 2 files changed, 13 insertions(+) diff --git a/README.md b/README.md index 2c22c9a..b66389b 100644 --- a/README.md +++ b/README.md @@ -274,6 +274,11 @@ except SignatureVerificationError: print(event.type, event.payload) # e.g. "message.delivered", {...} ``` +Subscribable event types: `message.sent`, `message.delivered`, `message.failed`, +`message.opted_out`, `message.opened`, and `message.clicked`. The engagement +events (`message.opened` / `message.clicked`) never change a message's status, +and the `message.clicked` payload also carries the clicked `link`. + The framework integrations below wire this up for you. ## Framework integrations diff --git a/src/senderkit/models.py b/src/senderkit/models.py index ec14623..3384c46 100644 --- a/src/senderkit/models.py +++ b/src/senderkit/models.py @@ -179,6 +179,12 @@ class Message: template_slug: Optional[str] recipient: str created_at: str + #: First provider-reported email open (ISO 8601), or ``None`` until opened. + #: Set once on the first open; later opens don't update it. + opened_at: Optional[str] = None + #: First provider-reported link click (ISO 8601), or ``None`` until a link + #: is clicked. Set once on the first click; later clicks don't update it. + clicked_at: Optional[str] = None raw: Dict[str, Any] = field(default_factory=dict) @classmethod @@ -191,6 +197,8 @@ def from_dict(cls, d: Dict[str, Any]) -> Message: template_slug=d.get("templateSlug"), recipient=str(d.get("recipient", "")), created_at=str(d.get("createdAt", "")), + opened_at=d.get("openedAt"), + clicked_at=d.get("clickedAt"), raw=d, ) From 9c0b3d18893cc967dbe5a9ee61df6caa388971b1 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 8 Jul 2026 21:25:13 +0000 Subject: [PATCH 2/2] test(messages): cover openedAt/clickedAt hydration on Message Asserts the new engagement timestamps hydrate from the messages API and default to None when absent from the payload. --- tests/test_messages.py | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/tests/test_messages.py b/tests/test_messages.py index 8c0dc32..0007553 100644 --- a/tests/test_messages.py +++ b/tests/test_messages.py @@ -62,6 +62,21 @@ def test_get_message(client): msg = client.messages.get("msg_1") assert msg.public_id == "msg_1" assert msg.raw["createdAt"] == "2026-01-01T00:00:00Z" # forward-compat access + # Engagement timestamps default to None when the payload omits them. + assert msg.opened_at is None + assert msg.clicked_at is None + + +@respx.mock +def test_get_message_hydrates_engagement(client): + payload = _msg("msg_1") | { + "openedAt": "2026-01-01T00:05:00Z", + "clickedAt": "2026-01-01T00:06:00Z", + } + respx.get(f"{BASE_URL}/v1/messages/msg_1").mock(return_value=httpx.Response(200, json=payload)) + msg = client.messages.get("msg_1") + assert msg.opened_at == "2026-01-01T00:05:00Z" + assert msg.clicked_at == "2026-01-01T00:06:00Z" @respx.mock