From ad32702f8278d6b223f1f1af7fe328c58c33a5ef Mon Sep 17 00:00:00 2001 From: Greg Svoboda <13701780+gregsvo@users.noreply.github.com> Date: Thu, 29 Oct 2026 16:34:28 -0400 Subject: [PATCH 01/16] fix(outbound): read Id from the send_bulk response and add the Cancelled status send_bulk() raised a ValidationError after Postmark had accepted the request: the response model read the request ID as "ID", but the API returns "Id", so the caller never got the bulk request ID back. Read "Id" (still accepting "ID"), and expose the other fields the API returns (subject, total_messages, percentage_completed, released_count, failed_count) on both BulkSendResponse and BulkSendStatus. Add BulkJobStatus.CANCELLED, which Postmark documents. FAILED is kept so existing code that references it keeps working. --- CHANGELOG.md | 2 + postmark/models/outbound/enums.py | 3 +- postmark/models/outbound/schemas.py | 13 +++- tests/test_bulk_messages.py | 98 ++++++++++++++++++++++++++++- 4 files changed, 112 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f89d479..df40bfc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/). ### Fixed - Django backend closes clients it automatically opens when a batch send fails, while preserving caller-owned connections for reuse. +- `send_bulk()` no longer raises `ValidationError` after Postmark accepts the request. The response key is `Id` (`ID` is still accepted); `BulkSendResponse` and `BulkSendStatus` now also expose `subject`, `total_messages`, `percentage_completed`, `released_count` and `failed_count`. +- `BulkJobStatus` gains `CANCELLED`. `FAILED` is kept for backward compatibility. --- diff --git a/postmark/models/outbound/enums.py b/postmark/models/outbound/enums.py index 8dafd3e..07886fd 100644 --- a/postmark/models/outbound/enums.py +++ b/postmark/models/outbound/enums.py @@ -37,4 +37,5 @@ class BulkJobStatus(str, Enum): ACCEPTED = "Accepted" PROCESSING = "Processing" COMPLETED = "Completed" - FAILED = "Failed" + CANCELLED = "Cancelled" + FAILED = "Failed" # kept for backward compatibility; Postmark documents Cancelled diff --git a/postmark/models/outbound/schemas.py b/postmark/models/outbound/schemas.py index bd35b25..5a59381 100644 --- a/postmark/models/outbound/schemas.py +++ b/postmark/models/outbound/schemas.py @@ -3,6 +3,7 @@ from typing import Annotated, Any from pydantic import ( + AliasChoices, BaseModel, BeforeValidator, ConfigDict, @@ -259,11 +260,19 @@ class BulkEmail(BaseModel): class BulkSendResponse(BaseModel): """ Response from POST /email/bulk. + + Postmark returns the request ID as ``Id``. ``ID`` is still accepted so that + payloads recorded before this was corrected keep parsing. """ - id: str = Field(alias="ID") + id: str = Field(validation_alias=AliasChoices("Id", "ID"), serialization_alias="Id") status: BulkJobStatus = Field(alias="Status") submitted_at: datetime = Field(alias="SubmittedAt") + subject: str | None = Field(None, alias="Subject") + total_messages: int | None = Field(None, alias="TotalMessages") + percentage_completed: float | None = Field(None, alias="PercentageCompleted") + released_count: int | None = Field(None, alias="ReleasedCount") + failed_count: int | None = Field(None, alias="FailedCount") model_config = ConfigDict(populate_by_name=True) @@ -279,5 +288,7 @@ class BulkSendStatus(BaseModel): percentage_completed: float = Field(alias="PercentageCompleted") status: BulkJobStatus = Field(alias="Status") subject: str | None = Field(None, alias="Subject") + released_count: int | None = Field(None, alias="ReleasedCount") + failed_count: int | None = Field(None, alias="FailedCount") model_config = ConfigDict(populate_by_name=True) diff --git a/tests/test_bulk_messages.py b/tests/test_bulk_messages.py index 041f911..ac4db24 100644 --- a/tests/test_bulk_messages.py +++ b/tests/test_bulk_messages.py @@ -4,6 +4,7 @@ from postmark.exceptions import InvalidEmailException from postmark.models.outbound import BulkEmail, BulkRecipient +from postmark.models.outbound.enums import BulkJobStatus class TestBulkSend: @@ -11,10 +12,16 @@ class TestBulkSend: @pytest.fixture def bulk_accepted_response(self): + """Shape captured from the live API (POST /email/bulk).""" return { - "ID": "f24af63c-533d-4b7a-ad65-4a7b3202d3a7", - "Status": "Accepted", + "Id": "f24af63c-533d-4b7a-ad65-4a7b3202d3a7", "SubmittedAt": "2024-03-17T07:25:01.4178645-05:00", + "TotalMessages": 2, + "PercentageCompleted": 0, + "Status": "Accepted", + "Subject": "Hello {{FirstName}}", + "ReleasedCount": 0, + "FailedCount": 0, } @pytest.fixture @@ -26,6 +33,8 @@ def bulk_status_response(self): "PercentageCompleted": 100.0, "Status": "Completed", "Subject": "Hello {{FirstName}}", + "ReleasedCount": 3, + "FailedCount": 0, } @pytest.fixture @@ -281,3 +290,88 @@ async def test_get_bulk_status_processing(self, outbound): assert status.status == "Processing" assert status.percentage_completed == 45.5 assert status.total_messages == 1000 + + # ------------------------------------------------------------------------- + # Response shape: live API uses "Id"; "ID" kept for backward compatibility + # ------------------------------------------------------------------------- + + @pytest.mark.asyncio + async def test_send_bulk_response_exposes_all_live_fields( + self, outbound, bulk_accepted_response, minimal_bulk_dict + ): + manager, fake = outbound + fake.mock_post_response(bulk_accepted_response) + + response = await manager.send_bulk(minimal_bulk_dict) + + assert response.id == "f24af63c-533d-4b7a-ad65-4a7b3202d3a7" + assert response.subject == "Hello {{FirstName}}" + assert response.total_messages == 2 + assert response.percentage_completed == 0 + assert response.released_count == 0 + assert response.failed_count == 0 + + @pytest.mark.asyncio + async def test_send_bulk_response_still_accepts_legacy_uppercase_id( + self, outbound, minimal_bulk_dict + ): + """Payloads recorded with the old "ID" key keep parsing.""" + manager, fake = outbound + fake.mock_post_response( + { + "ID": "legacy-1", + "Status": "Accepted", + "SubmittedAt": "2024-03-17T07:25:01Z", + } + ) + + response = await manager.send_bulk(minimal_bulk_dict) + + assert response.id == "legacy-1" + assert response.total_messages is None + + @pytest.mark.asyncio + async def test_send_bulk_response_without_optional_counters( + self, outbound, minimal_bulk_dict + ): + manager, fake = outbound + fake.mock_post_response( + {"Id": "x", "Status": "Accepted", "SubmittedAt": "2024-03-17T07:25:01Z"} + ) + + response = await manager.send_bulk(minimal_bulk_dict) + + assert response.released_count is None + assert response.failed_count is None + + @pytest.mark.asyncio + async def test_get_bulk_status_cancelled(self, outbound): + """Postmark documents a Cancelled status; it must parse.""" + manager, fake = outbound + fake.mock_get_response( + { + "Id": "abc-123", + "SubmittedAt": "2024-03-17T07:25:01Z", + "TotalMessages": 10, + "PercentageCompleted": 30.0, + "Status": "Cancelled", + "ReleasedCount": 3, + "FailedCount": 0, + } + ) + + status = await manager.get_bulk_status("abc-123") + + assert status.status == BulkJobStatus.CANCELLED + assert status.released_count == 3 + assert status.failed_count == 0 + + def test_bulk_job_status_keeps_failed_for_backward_compatibility(self): + assert BulkJobStatus.FAILED.value == "Failed" + assert {s.value for s in BulkJobStatus} >= { + "Accepted", + "Processing", + "Completed", + "Cancelled", + "Failed", + } From 14120a1fca16b1a9aff3a2c6bcb05b62ac1e6017 Mon Sep 17 00:00:00 2001 From: Greg Svoboda <13701780+gregsvo@users.noreply.github.com> Date: Thu, 29 Oct 2026 16:34:55 -0400 Subject: [PATCH 02/16] fix(outbound): return failed batch entries instead of raising The batch endpoints answer HTTP 200 even when individual entries fail, and a failed entry carries only ErrorCode and Message. SendResponse required To, SubmittedAt and MessageID, so a single failed entry raised a ValidationError and discarded the results of every entry that was sent. Make the three fields optional so every entry comes back, in order, and the caller checks success. This applies to send_batch() and send_batch_with_template(). Typing only: SendResponse.to, .submitted_at and .message_id are now optional. Successful sends still populate them. --- CHANGELOG.md | 5 +++ postmark/models/outbound/schemas.py | 14 +++++-- tests/test_send_messages.py | 65 +++++++++++++++++++++++++++++ 3 files changed, 81 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index df40bfc..029f303 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/). - Django backend closes clients it automatically opens when a batch send fails, while preserving caller-owned connections for reuse. - `send_bulk()` no longer raises `ValidationError` after Postmark accepts the request. The response key is `Id` (`ID` is still accepted); `BulkSendResponse` and `BulkSendStatus` now also expose `subject`, `total_messages`, `percentage_completed`, `released_count` and `failed_count`. - `BulkJobStatus` gains `CANCELLED`. `FAILED` is kept for backward compatibility. +- `send_batch()` and `send_batch_with_template()` no longer raise when an entry fails. Failed entries come back as results (check `success` / `error_code`) instead of discarding the whole batch. + +### Changed + +- **Typing only:** `SendResponse.to`, `.submitted_at` and `.message_id` are now `X | None` (they are `None` for failed batch entries; successful sends still populate them). Type checkers may flag code that assumes they are always set. --- diff --git a/postmark/models/outbound/schemas.py b/postmark/models/outbound/schemas.py index 5a59381..fc8e44c 100644 --- a/postmark/models/outbound/schemas.py +++ b/postmark/models/outbound/schemas.py @@ -95,9 +95,17 @@ class MessageEvent(BaseModel): class SendResponse(BaseModel): - to: str = Field(alias="To") - submitted_at: datetime = Field(alias="SubmittedAt") - message_id: str = Field(alias="MessageID") + """ + Result of sending one message. + + Batch endpoints return HTTP 200 even when individual entries fail. A failed + entry carries only ``error_code`` and ``message``, so ``to``, + ``submitted_at`` and ``message_id`` are ``None`` for it. Check ``success``. + """ + + to: str | None = Field(None, alias="To") + submitted_at: datetime | None = Field(None, alias="SubmittedAt") + message_id: str | None = Field(None, alias="MessageID") error_code: int = Field(alias="ErrorCode") message: str = Field(alias="Message") diff --git a/tests/test_send_messages.py b/tests/test_send_messages.py index ec07895..759a216 100644 --- a/tests/test_send_messages.py +++ b/tests/test_send_messages.py @@ -174,6 +174,50 @@ async def test_send_batch(self, outbound): assert fake.post.call_args[0] == ("/email/batch",) assert len(fake.post.call_args[1]["json"]) == 2 + @pytest.mark.asyncio + async def test_send_batch_failed_entry_is_returned_not_raised(self, outbound): + """A failed entry has no To/SubmittedAt/MessageID (captured live) and must + come back as a result so the successful entries are not lost.""" + manager, fake = outbound + fake.mock_post_response( + [ + { + "To": "user1@example.com", + "SubmittedAt": "2024-01-01T00:00:00", + "MessageID": "id-1", + "ErrorCode": 0, + "Message": "OK", + }, + { + "ErrorCode": 300, + "Message": "Error parsing 'To': Illegal email address " + "'not-an-address'. It must contain the '@' symbol.", + }, + ] + ) + + responses = await manager.send_batch( + [ + {"sender": "s@example.com", "to": "user1@example.com", "subject": "1"}, + {"sender": "s@example.com", "to": "not-an-address", "subject": "2"}, + ] + ) + + assert [r.success for r in responses] == [True, False] + assert responses[0].message_id == "id-1" + assert responses[1].error_code == 300 + assert "Illegal email address" in responses[1].message + assert responses[1].to is None + assert responses[1].message_id is None + assert responses[1].submitted_at is None + + def test_send_response_success_is_false_for_error_code(self): + from postmark.models.outbound import SendResponse + + failed = SendResponse(ErrorCode=406, Message="Inactive recipient") + assert failed.success is False + assert SendResponse(ErrorCode=0, Message="OK").success is True + @pytest.mark.asyncio async def test_send_batch_limit(self, outbound): """Test that batches over 500 are rejected before any API call.""" @@ -311,6 +355,27 @@ async def test_multiple_messages(self, outbound, two_messages): endpoint = fake.post.call_args[0][0] assert endpoint == "/email/batchWithTemplates" + @pytest.mark.asyncio + async def test_failed_entry_is_returned_not_raised(self, outbound, two_messages): + """Unknown template alias: the failed entry has only ErrorCode/Message.""" + manager, fake = outbound + fake.mock_post_response( + [ + SEND_RESPONSE, + { + "ErrorCode": 1101, + "Message": "The 'TemplateAlias' associated with this request " + "is not valid or was not found.", + }, + ] + ) + + results = await manager.send_batch_with_template(two_messages) + + assert [r.success for r in results] == [True, False] + assert results[1].error_code == 1101 + assert results[1].message_id is None + @pytest.mark.asyncio async def test_wraps_in_messages_key(self, outbound, two_messages): manager, fake = outbound From ebd646c2d5678ebba743d838dd9d466f0ecab08e Mon Sep 17 00:00:00 2001 From: Greg Svoboda <13701780+gregsvo@users.noreply.github.com> Date: Thu, 29 Oct 2026 16:35:22 -0400 Subject: [PATCH 03/16] fix(bounces): accept the All entry in delivery stats get_delivery_stats() raised a ValidationError whenever Postmark returned its "All" total entry, which has only a Name and a Count. Make type and type_code optional. Typing only: BounceTypeCount.type and .type_code are now optional. --- CHANGELOG.md | 2 ++ postmark/models/bounces/schemas.py | 5 +++-- tests/test_bounces.py | 33 ++++++++++++++++++++++++++++++ 3 files changed, 38 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 029f303..84ce992 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,10 +15,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/). - `send_bulk()` no longer raises `ValidationError` after Postmark accepts the request. The response key is `Id` (`ID` is still accepted); `BulkSendResponse` and `BulkSendStatus` now also expose `subject`, `total_messages`, `percentage_completed`, `released_count` and `failed_count`. - `BulkJobStatus` gains `CANCELLED`. `FAILED` is kept for backward compatibility. - `send_batch()` and `send_batch_with_template()` no longer raise when an entry fails. Failed entries come back as results (check `success` / `error_code`) instead of discarding the whole batch. +- `get_delivery_stats()` no longer raises: the `All` total entry has no `Type`/`TypeCode`. ### Changed - **Typing only:** `SendResponse.to`, `.submitted_at` and `.message_id` are now `X | None` (they are `None` for failed batch entries; successful sends still populate them). Type checkers may flag code that assumes they are always set. +- **Typing only:** `BounceTypeCount.type` and `.type_code` are now `X | None`. --- diff --git a/postmark/models/bounces/schemas.py b/postmark/models/bounces/schemas.py index bdd876a..7a40197 100644 --- a/postmark/models/bounces/schemas.py +++ b/postmark/models/bounces/schemas.py @@ -34,9 +34,10 @@ class BounceTypeCount(BaseModel): """Aggregate count for a single bounce type, returned within DeliveryStats""" name: str = Field(alias="Name") - type: str = Field(alias="Type") + # The "All" total entry returned by Postmark carries only Name and Count. + type: str | None = Field(None, alias="Type") count: int = Field(alias="Count") - type_code: int = Field(alias="TypeCode") + type_code: int | None = Field(None, alias="TypeCode") model_config = ConfigDict(populate_by_name=True) diff --git a/tests/test_bounces.py b/tests/test_bounces.py index edce504..49c7586 100644 --- a/tests/test_bounces.py +++ b/tests/test_bounces.py @@ -86,6 +86,39 @@ async def test_get_delivery_stats_empty_bounces(self, bounces): assert stats.inactive_mails == 0 assert stats.bounces == [] + @pytest.mark.asyncio + async def test_get_delivery_stats_all_entry_without_type(self, bounces): + """Live API returns the 'All' total with only Name and Count.""" + manager, fake = bounces + fake.mock_get_response( + {"InactiveMails": 0, "Bounces": [{"Name": "All", "Count": 0}]} + ) + + stats = await manager.get_delivery_stats() + + assert stats.bounces[0].name == "All" + assert stats.bounces[0].count == 0 + assert stats.bounces[0].type is None + assert stats.bounces[0].type_code is None + + @pytest.mark.asyncio + async def test_get_delivery_stats_all_alongside_typed_entries(self, bounces): + manager, fake = bounces + fake.mock_get_response( + { + "InactiveMails": 1, + "Bounces": [ + {"Name": "All", "Count": 3}, + {"Name": "Hard bounce", "Type": "HardBounce", "Count": 3}, + ], + } + ) + + stats = await manager.get_delivery_stats() + + assert [b.type for b in stats.bounces] == [None, "HardBounce"] + assert stats.bounces[1].type_code is None + # --------------------------------------------------------------------------- # Bounce List From c8723a685e0114e7a740c9f131a7eecb37ec89d8 Mon Sep 17 00:00:00 2001 From: Greg Svoboda <13701780+gregsvo@users.noreply.github.com> Date: Thu, 29 Oct 2026 16:50:27 -0400 Subject: [PATCH 04/16] fix(templates): accept integer server IDs when pushing templates Server IDs are integers, but PushTemplatesRequest typed them as str, so a real server ID raised InvalidEmailException before any request was made. Postmark accepts either form, so accept int or str and send the value as given. Typing only: the two ID fields are now int | str. --- .secrets.baseline | 8 +++- CHANGELOG.md | 2 + postmark/models/templates/schemas.py | 6 ++- tests/test_templates.py | 56 ++++++++++++++++++++++++++++ 4 files changed, 68 insertions(+), 4 deletions(-) diff --git a/.secrets.baseline b/.secrets.baseline index a0789f7..71a47fd 100644 --- a/.secrets.baseline +++ b/.secrets.baseline @@ -90,6 +90,10 @@ { "path": "detect_secrets.filters.allowlist.is_line_allowlisted" }, + { + "path": "detect_secrets.filters.common.is_baseline_file", + "filename": ".secrets.baseline" + }, { "path": "detect_secrets.filters.common.is_ignored_due_to_verification_policies", "min_level": 2 @@ -129,7 +133,7 @@ "filename": "postmark/models/webhooks/manager.py", "hashed_secret": "9d4e1e23bd5b727046a9e3b4b7db57bd8d6ee684", "is_verified": false, - "line_number": 41 + "line_number": 48 } ], "tests/test_webhooks.py": [ @@ -142,5 +146,5 @@ } ] }, - "generated_at": "2026-07-10T18:03:37Z" + "generated_at": "2026-10-29T20:40:13Z" } diff --git a/CHANGELOG.md b/CHANGELOG.md index 84ce992..4c74127 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,11 +16,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/). - `BulkJobStatus` gains `CANCELLED`. `FAILED` is kept for backward compatibility. - `send_batch()` and `send_batch_with_template()` no longer raise when an entry fails. Failed entries come back as results (check `success` / `error_code`) instead of discarding the whole batch. - `get_delivery_stats()` no longer raises: the `All` total entry has no `Type`/`TypeCode`. +- `templates.push()` accepts integer `SourceServerID` / `DestinationServerID` (strings still work and are sent unchanged). ### Changed - **Typing only:** `SendResponse.to`, `.submitted_at` and `.message_id` are now `X | None` (they are `None` for failed batch entries; successful sends still populate them). Type checkers may flag code that assumes they are always set. - **Typing only:** `BounceTypeCount.type` and `.type_code` are now `X | None`. +- **Typing only:** `PushTemplatesRequest.source_server_id` / `.destination_server_id` are now `int | str`. --- diff --git a/postmark/models/templates/schemas.py b/postmark/models/templates/schemas.py index 4f44dbe..8ee1804 100644 --- a/postmark/models/templates/schemas.py +++ b/postmark/models/templates/schemas.py @@ -167,8 +167,10 @@ class ValidateTemplateResponse(BaseModel): class PushTemplatesRequest(BaseModel): - source_server_id: str = Field(alias="SourceServerID") - destination_server_id: str = Field(alias="DestinationServerID") + # Server IDs are integers in the API, which also accepts them as strings. + # The value is sent exactly as the caller passed it. + source_server_id: int | str = Field(alias="SourceServerID") + destination_server_id: int | str = Field(alias="DestinationServerID") perform_changes: bool = Field(alias="PerformChanges") model_config = ConfigDict(populate_by_name=True) diff --git a/tests/test_templates.py b/tests/test_templates.py index b3bcb66..5c48347 100644 --- a/tests/test_templates.py +++ b/tests/test_templates.py @@ -441,3 +441,59 @@ async def test_push_response_parsed(self, account_templates): pushed = result.templates[0] assert pushed.alias == "welcome" assert pushed.template_type == TemplateType.STANDARD + + @pytest.mark.asyncio + async def test_push_accepts_integer_server_ids(self, account_templates): + """Server IDs are integers in the API; ints must be accepted and sent as ints.""" + manager, fake = account_templates + fake.mock_put_response(PUSH_RESPONSE) + + await manager.push( + { + "SourceServerID": 11111, + "DestinationServerID": 22222, + "PerformChanges": False, + } + ) + + body = fake.put.call_args[1]["json"] + assert body == { + "SourceServerID": 11111, + "DestinationServerID": 22222, + "PerformChanges": False, + } + assert isinstance(body["SourceServerID"], int) + + @pytest.mark.asyncio + async def test_push_string_server_ids_are_sent_unchanged(self, account_templates): + """Existing callers passing strings keep the same wire format.""" + manager, fake = account_templates + fake.mock_put_response(PUSH_RESPONSE) + + await manager.push( + { + "SourceServerID": "11111", + "DestinationServerID": "22222", + "PerformChanges": True, + } + ) + + body = fake.put.call_args[1]["json"] + assert body["SourceServerID"] == "11111" + assert isinstance(body["DestinationServerID"], str) + + def test_push_request_model_accepts_int_and_str(self): + req = PushTemplatesRequest( + SourceServerID=1, DestinationServerID="2", PerformChanges=False + ) + assert req.source_server_id == 1 + assert req.destination_server_id == "2" + + @pytest.mark.asyncio + async def test_push_missing_server_id_still_rejected(self, account_templates): + from postmark.exceptions import InvalidEmailException + + manager, fake = account_templates + with pytest.raises(InvalidEmailException): + await manager.push({"DestinationServerID": 2, "PerformChanges": False}) + fake.put.assert_not_called() From 14cd20e1394f82c4e957ac0492fe77833ca7f552 Mon Sep 17 00:00:00 2001 From: Greg Svoboda <13701780+gregsvo@users.noreply.github.com> Date: Thu, 29 Oct 2026 16:50:53 -0400 Subject: [PATCH 05/16] fix(stats): treat omitted zero counts as 0 Postmark leaves out any category whose count is zero, for example a day with only desktop clicks has no Mobile or Unknown entry. The per-day models required every field, so platform_usage(), browser_platform_usage() and click_location() raised on real data, and the other day models were exposed to the same problem. Default every per-day count to 0, and every days list to empty. --- CHANGELOG.md | 1 + postmark/models/stats/schemas.py | 64 +++++++++--------- tests/test_stats.py | 110 +++++++++++++++++++++++++++++++ 3 files changed, 143 insertions(+), 32 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4c74127..5e374a3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/). - `send_batch()` and `send_batch_with_template()` no longer raise when an entry fails. Failed entries come back as results (check `success` / `error_code`) instead of discarding the whole batch. - `get_delivery_stats()` no longer raises: the `All` total entry has no `Type`/`TypeCode`. - `templates.push()` accepts integer `SourceServerID` / `DestinationServerID` (strings still work and are sent unchanged). +- Stats: `platform_usage()`, `browser_platform_usage()`, `click_location()` and the other per-day stats models no longer raise when Postmark omits a category whose count is zero (for example a day with no mobile activity). Missing counts read as `0` and a missing `days` as `[]`. ### Changed diff --git a/postmark/models/stats/schemas.py b/postmark/models/stats/schemas.py index 7d07458..9b221ef 100644 --- a/postmark/models/stats/schemas.py +++ b/postmark/models/stats/schemas.py @@ -28,7 +28,7 @@ class OutboundOverview(BaseModel): class SentDay(BaseModel): date: str = Field(alias="Date") - sent: int = Field(alias="Sent") + sent: int = Field(0, alias="Sent") model_config = ConfigDict(populate_by_name=True) @@ -36,7 +36,7 @@ class SentDay(BaseModel): class SentCounts(BaseModel): """Response from ``GET /stats/outbound/sends``.""" - days: list[SentDay] = Field(alias="Days") + days: list[SentDay] = Field(default_factory=list, alias="Days") sent: int = Field(0, alias="Sent") model_config = ConfigDict(populate_by_name=True) @@ -44,10 +44,10 @@ class SentCounts(BaseModel): class BounceDay(BaseModel): date: str = Field(alias="Date") - hard_bounce: int = Field(alias="HardBounce") - smtp_api_error: int = Field(alias="SMTPApiError") - soft_bounce: int = Field(alias="SoftBounce") - transient: int = Field(alias="Transient") + hard_bounce: int = Field(0, alias="HardBounce") + smtp_api_error: int = Field(0, alias="SMTPApiError") + soft_bounce: int = Field(0, alias="SoftBounce") + transient: int = Field(0, alias="Transient") model_config = ConfigDict(populate_by_name=True) @@ -55,7 +55,7 @@ class BounceDay(BaseModel): class BounceCounts(BaseModel): """Response from ``GET /stats/outbound/bounces``.""" - days: list[BounceDay] = Field(alias="Days") + days: list[BounceDay] = Field(default_factory=list, alias="Days") hard_bounce: int = Field(0, alias="HardBounce") smtp_api_error: int = Field(0, alias="SMTPApiError") soft_bounce: int = Field(0, alias="SoftBounce") @@ -66,7 +66,7 @@ class BounceCounts(BaseModel): class SpamComplaintDay(BaseModel): date: str = Field(alias="Date") - spam_complaint: int = Field(alias="SpamComplaint") + spam_complaint: int = Field(0, alias="SpamComplaint") model_config = ConfigDict(populate_by_name=True) @@ -74,7 +74,7 @@ class SpamComplaintDay(BaseModel): class SpamComplaints(BaseModel): """Response from ``GET /stats/outbound/spam``.""" - days: list[SpamComplaintDay] = Field(alias="Days") + days: list[SpamComplaintDay] = Field(default_factory=list, alias="Days") spam_complaint: int = Field(0, alias="SpamComplaint") model_config = ConfigDict(populate_by_name=True) @@ -82,7 +82,7 @@ class SpamComplaints(BaseModel): class TrackedDay(BaseModel): date: str = Field(alias="Date") - tracked: int = Field(alias="Tracked") + tracked: int = Field(0, alias="Tracked") model_config = ConfigDict(populate_by_name=True) @@ -90,7 +90,7 @@ class TrackedDay(BaseModel): class TrackedCounts(BaseModel): """Response from ``GET /stats/outbound/tracked``.""" - days: list[TrackedDay] = Field(alias="Days") + days: list[TrackedDay] = Field(default_factory=list, alias="Days") tracked: int = Field(0, alias="Tracked") model_config = ConfigDict(populate_by_name=True) @@ -98,8 +98,8 @@ class TrackedCounts(BaseModel): class OpenDay(BaseModel): date: str = Field(alias="Date") - opens: int = Field(alias="Opens") - unique: int = Field(alias="Unique") + opens: int = Field(0, alias="Opens") + unique: int = Field(0, alias="Unique") model_config = ConfigDict(populate_by_name=True) @@ -107,7 +107,7 @@ class OpenDay(BaseModel): class OpenCounts(BaseModel): """Response from ``GET /stats/outbound/opens``.""" - days: list[OpenDay] = Field(alias="Days") + days: list[OpenDay] = Field(default_factory=list, alias="Days") opens: int = Field(0, alias="Opens") unique: int = Field(0, alias="Unique") @@ -116,10 +116,10 @@ class OpenCounts(BaseModel): class PlatformDay(BaseModel): date: str = Field(alias="Date") - desktop: int = Field(alias="Desktop") - mobile: int = Field(alias="Mobile") - unknown: int = Field(alias="Unknown") - web_mail: int = Field(alias="WebMail") + desktop: int = Field(0, alias="Desktop") + mobile: int = Field(0, alias="Mobile") + unknown: int = Field(0, alias="Unknown") + web_mail: int = Field(0, alias="WebMail") model_config = ConfigDict(populate_by_name=True) @@ -127,7 +127,7 @@ class PlatformDay(BaseModel): class PlatformUsage(BaseModel): """Response from ``GET /stats/outbound/opens/platforms``.""" - days: list[PlatformDay] = Field(alias="Days") + days: list[PlatformDay] = Field(default_factory=list, alias="Days") desktop: int = Field(0, alias="Desktop") mobile: int = Field(0, alias="Mobile") unknown: int = Field(0, alias="Unknown") @@ -144,15 +144,15 @@ class EmailClientUsage(BaseModel): ``model_extra`` and individual day dicts via ``days``. """ - days: list[dict[str, Any]] = Field(alias="Days") + days: list[dict[str, Any]] = Field(default_factory=list, alias="Days") model_config = ConfigDict(populate_by_name=True, extra="allow") class ClickDay(BaseModel): date: str = Field(alias="Date") - clicks: int = Field(alias="Clicks") - unique: int = Field(alias="Unique") + clicks: int = Field(0, alias="Clicks") + unique: int = Field(0, alias="Unique") model_config = ConfigDict(populate_by_name=True) @@ -160,7 +160,7 @@ class ClickDay(BaseModel): class ClickCounts(BaseModel): """Response from ``GET /stats/outbound/clicks``.""" - days: list[ClickDay] = Field(alias="Days") + days: list[ClickDay] = Field(default_factory=list, alias="Days") clicks: int = Field(0, alias="Clicks") unique: int = Field(0, alias="Unique") @@ -174,16 +174,16 @@ class BrowserUsage(BaseModel): and within each day object. Access totals via ``model_extra``. """ - days: list[dict[str, Any]] = Field(alias="Days") + days: list[dict[str, Any]] = Field(default_factory=list, alias="Days") model_config = ConfigDict(populate_by_name=True, extra="allow") class BrowserPlatformDay(BaseModel): date: str = Field(alias="Date") - desktop: int = Field(alias="Desktop") - mobile: int = Field(alias="Mobile") - unknown: int = Field(alias="Unknown") + desktop: int = Field(0, alias="Desktop") + mobile: int = Field(0, alias="Mobile") + unknown: int = Field(0, alias="Unknown") model_config = ConfigDict(populate_by_name=True) @@ -191,7 +191,7 @@ class BrowserPlatformDay(BaseModel): class BrowserPlatformUsage(BaseModel): """Response from ``GET /stats/outbound/clicks/platforms``.""" - days: list[BrowserPlatformDay] = Field(alias="Days") + days: list[BrowserPlatformDay] = Field(default_factory=list, alias="Days") desktop: int = Field(0, alias="Desktop") mobile: int = Field(0, alias="Mobile") unknown: int = Field(0, alias="Unknown") @@ -201,8 +201,8 @@ class BrowserPlatformUsage(BaseModel): class ClickLocationDay(BaseModel): date: str = Field(alias="Date") - html: int = Field(alias="HTML") - text: int = Field(alias="Text") + html: int = Field(0, alias="HTML") + text: int = Field(0, alias="Text") model_config = ConfigDict(populate_by_name=True) @@ -210,7 +210,7 @@ class ClickLocationDay(BaseModel): class ClickLocation(BaseModel): """Response from ``GET /stats/outbound/clicks/location``.""" - days: list[ClickLocationDay] = Field(alias="Days") + days: list[ClickLocationDay] = Field(default_factory=list, alias="Days") html: int = Field(0, alias="HTML") text: int = Field(0, alias="Text") @@ -224,6 +224,6 @@ class ReadTimes(BaseModel): day object alongside ``Date``. Access totals via ``model_extra``. """ - days: list[dict[str, Any]] = Field(alias="Days") + days: list[dict[str, Any]] = Field(default_factory=list, alias="Days") model_config = ConfigDict(populate_by_name=True, extra="allow") diff --git a/tests/test_stats.py b/tests/test_stats.py index 39b00fa..31a8f8b 100644 --- a/tests/test_stats.py +++ b/tests/test_stats.py @@ -342,3 +342,113 @@ async def test_click_location_fields(self, stats): assert result.html == 14 assert result.text == 6 assert result.days[0].html == 14 + + +# --------------------------------------------------------------------------- +# Zero-valued categories are omitted by the API (captured live) +# --------------------------------------------------------------------------- + + +class TestOmittedZeroValues: + """Postmark leaves out categories whose count is zero. Those must parse as 0.""" + + @pytest.mark.asyncio + async def test_platform_usage_missing_categories(self, stats): + manager, fake = stats + fake.mock_get_response( + {"Days": [{"Date": "2026-08-25", "WebMail": 3}], "WebMail": 3} + ) + + result = await manager.platform_usage() + + day = result.days[0] + assert (day.desktop, day.mobile, day.unknown, day.web_mail) == (0, 0, 0, 3) + assert result.web_mail == 3 + assert result.desktop == 0 + + @pytest.mark.asyncio + async def test_browser_platform_usage_missing_categories(self, stats): + manager, fake = stats + fake.mock_get_response( + {"Days": [{"Date": "2026-08-25", "Desktop": 2}], "Desktop": 2} + ) + + result = await manager.browser_platform_usage() + + day = result.days[0] + assert (day.desktop, day.mobile, day.unknown) == (2, 0, 0) + + @pytest.mark.asyncio + async def test_click_location_missing_categories(self, stats): + manager, fake = stats + fake.mock_get_response({"Days": [{"Date": "2026-08-25", "HTML": 2}], "HTML": 2}) + + result = await manager.click_location() + + assert result.days[0].html == 2 + assert result.days[0].text == 0 + assert result.text == 0 + + @pytest.mark.asyncio + async def test_bounce_counts_missing_categories(self, stats): + manager, fake = stats + fake.mock_get_response({"Days": [{"Date": "2026-08-25", "HardBounce": 1}]}) + + result = await manager.bounce_counts() + + day = result.days[0] + assert ( + day.hard_bounce, + day.soft_bounce, + day.transient, + day.smtp_api_error, + ) == (1, 0, 0, 0) + + @pytest.mark.asyncio + @pytest.mark.parametrize( + "method,attrs", + [ + ("sent_counts", ["sent"]), + ("spam_counts", ["spam_complaint"]), + ("tracked_counts", ["tracked"]), + ("open_counts", ["opens", "unique"]), + ("click_counts", ["clicks", "unique"]), + ], + ) + async def test_day_with_only_date_defaults_counts_to_zero( + self, stats, method, attrs + ): + manager, fake = stats + fake.mock_get_response({"Days": [{"Date": "2026-08-25"}]}) + + result = await getattr(manager, method)() + + assert result.days[0].date == "2026-08-25" + for attr in attrs: + assert getattr(result.days[0], attr) == 0 + + @pytest.mark.asyncio + @pytest.mark.parametrize( + "method", + [ + "sent_counts", + "bounce_counts", + "spam_counts", + "tracked_counts", + "open_counts", + "platform_usage", + "email_client_usage", + "click_counts", + "browser_usage", + "browser_platform_usage", + "click_location", + "read_times", + ], + ) + async def test_missing_days_defaults_to_empty_list(self, stats, method): + manager, fake = stats + fake.mock_get_response({}) + + result = await getattr(manager, method)() + + assert result.days == [] From f96121af824da5f52a9f571514b8dca5f6b03d55 Mon Sep 17 00:00:00 2001 From: Greg Svoboda <13701780+gregsvo@users.noreply.github.com> Date: Thu, 29 Oct 2026 16:51:20 -0400 Subject: [PATCH 06/16] fix(outbound): send opens and clicks client and OS filters in snake_case list_opens() and list_clicks() sent client_name, client_company, client_family, os_name, os_family and os_company in camelCase. Postmark ignores unknown query parameters, so these filters silently did nothing and always returned unfiltered results. Send the names the API understands. Behaviour change: calls that pass these filters now return filtered results. The Python argument names are unchanged. --- CHANGELOG.md | 1 + postmark/models/outbound/manager.py | 24 ++++----- tests/test_retrieve_messages.py | 81 +++++++++++++++++++++++++++++ 3 files changed, 94 insertions(+), 12 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5e374a3..6531d2d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/). - `get_delivery_stats()` no longer raises: the `All` total entry has no `Type`/`TypeCode`. - `templates.push()` accepts integer `SourceServerID` / `DestinationServerID` (strings still work and are sent unchanged). - Stats: `platform_usage()`, `browser_platform_usage()`, `click_location()` and the other per-day stats models no longer raise when Postmark omits a category whose count is zero (for example a day with no mobile activity). Missing counts read as `0` and a missing `days` as `[]`. +- **Behaviour change:** `list_opens()` / `list_clicks()` client and OS filters (`client_name`, `client_company`, `client_family`, `os_name`, `os_family`, `os_company`) are now sent with the parameter names Postmark understands. They were sent in camelCase, which the API silently ignored, so these filters previously had no effect and returned unfiltered results. Calls that pass them will now return filtered results. ### Changed diff --git a/postmark/models/outbound/manager.py b/postmark/models/outbound/manager.py index 0fb55cb..26394a3 100644 --- a/postmark/models/outbound/manager.py +++ b/postmark/models/outbound/manager.py @@ -335,17 +335,17 @@ async def list_opens( if tag is not None: params["tag"] = tag if client_name is not None: - params["clientName"] = client_name + params["client_name"] = client_name if client_company is not None: - params["clientCompany"] = client_company + params["client_company"] = client_company if client_family is not None: - params["clientFamily"] = client_family + params["client_family"] = client_family if os_name is not None: - params["osName"] = os_name + params["os_name"] = os_name if os_family is not None: - params["osFamily"] = os_family + params["os_family"] = os_family if os_company is not None: - params["osCompany"] = os_company + params["os_company"] = os_company if platform is not None: params["platform"] = platform if country is not None: @@ -424,17 +424,17 @@ async def list_clicks( if tag is not None: params["tag"] = tag if client_name is not None: - params["clientName"] = client_name + params["client_name"] = client_name if client_company is not None: - params["clientCompany"] = client_company + params["client_company"] = client_company if client_family is not None: - params["clientFamily"] = client_family + params["client_family"] = client_family if os_name is not None: - params["osName"] = os_name + params["os_name"] = os_name if os_family is not None: - params["osFamily"] = os_family + params["os_family"] = os_family if os_company is not None: - params["osCompany"] = os_company + params["os_company"] = os_company if platform is not None: params["platform"] = platform if country is not None: diff --git a/tests/test_retrieve_messages.py b/tests/test_retrieve_messages.py index 01aec8d..887e20e 100644 --- a/tests/test_retrieve_messages.py +++ b/tests/test_retrieve_messages.py @@ -386,3 +386,84 @@ async def test_list_message_success( assert result.total == 1 assert result.items[0].message_id == "msg-123" + + +# --------------------------------------------------------------------------- +# Opens / clicks: wire names of the filters (verified against the live API) +# --------------------------------------------------------------------------- + +TRACKING_FILTERS = [ + # (python argument, wire query parameter) + ("recipient", "recipient"), + ("tag", "tag"), + ("client_name", "client_name"), + ("client_company", "client_company"), + ("client_family", "client_family"), + ("os_name", "os_name"), + ("os_family", "os_family"), + ("os_company", "os_company"), + ("platform", "platform"), + ("country", "country"), + ("region", "region"), + ("city", "city"), + ("message_stream", "messagestream"), +] + + +@pytest.mark.parametrize( + "list_method,response_key", + [("list_opens", "Opens"), ("list_clicks", "Clicks")], +) +class TestTrackingFilterWireNames: + """The API silently ignores unknown query parameters, so the exact wire + spelling matters: ``client_family`` filters, ``clientFamily`` does nothing.""" + + @pytest.mark.asyncio + @pytest.mark.parametrize("argument,wire", TRACKING_FILTERS) + async def test_each_filter_uses_its_wire_name( + self, outbound, list_method, response_key, argument, wire + ): + manager, fake = outbound + fake.mock_get_response({"TotalCount": 0, response_key: []}) + + await getattr(manager, list_method)(**{argument: "value"}) + + params = fake.get.call_args[1]["params"] + assert params[wire] == "value" + assert set(params) == {"count", "offset", wire} + + @pytest.mark.asyncio + async def test_all_filters_together_send_only_snake_case_names( + self, outbound, list_method, response_key + ): + manager, fake = outbound + fake.mock_get_response({"TotalCount": 0, response_key: []}) + + await getattr(manager, list_method)( + **{argument: f"v-{argument}" for argument, _ in TRACKING_FILTERS} + ) + + params = fake.get.call_args[1]["params"] + assert {wire: params[wire] for _, wire in TRACKING_FILTERS} == { + wire: f"v-{argument}" for argument, wire in TRACKING_FILTERS + } + camel = { + "clientName", + "clientCompany", + "clientFamily", + "osName", + "osFamily", + "osCompany", + } + assert camel.isdisjoint(params) + + @pytest.mark.asyncio + async def test_count_plus_offset_validation( + self, outbound, list_method, response_key + ): + manager, fake = outbound + + with pytest.raises(ValueError, match="Count \\+ Offset cannot exceed 10,000"): + await getattr(manager, list_method)(count=500, offset=9501) + + fake.get.assert_not_called() From bdf977f2df3f39f572f8251de41dfb25c8847b35 Mon Sep 17 00:00:00 2001 From: Greg Svoboda <13701780+gregsvo@users.noreply.github.com> Date: Thu, 29 Oct 2026 16:51:46 -0400 Subject: [PATCH 07/16] fix(outbound): warn that opens and clicks date filters have no effect Postmark has no date filter on the opens and clicks endpoints, so the from_date and to_date arguments of list_opens() and list_clicks() never had any effect. Keep accepting them so existing calls do not break, but stop sending them and emit a FutureWarning that points at the caller. Behaviour change: a call that passes either argument now warns. Results are unchanged. --- CHANGELOG.md | 1 + postmark/models/outbound/manager.py | 40 ++++++++++++++++++------ tests/test_retrieve_messages.py | 48 +++++++++++++++++++++++++++++ 3 files changed, 79 insertions(+), 10 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6531d2d..d5daab5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,6 +19,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/). - `templates.push()` accepts integer `SourceServerID` / `DestinationServerID` (strings still work and are sent unchanged). - Stats: `platform_usage()`, `browser_platform_usage()`, `click_location()` and the other per-day stats models no longer raise when Postmark omits a category whose count is zero (for example a day with no mobile activity). Missing counts read as `0` and a missing `days` as `[]`. - **Behaviour change:** `list_opens()` / `list_clicks()` client and OS filters (`client_name`, `client_company`, `client_family`, `os_name`, `os_family`, `os_company`) are now sent with the parameter names Postmark understands. They were sent in camelCase, which the API silently ignored, so these filters previously had no effect and returned unfiltered results. Calls that pass them will now return filtered results. +- `list_opens()` / `list_clicks()`: `from_date` / `to_date` never had any effect (Postmark does not support date filtering on these endpoints). They are still accepted so existing calls keep working, but now emit a `FutureWarning` and are no longer sent. ### Changed diff --git a/postmark/models/outbound/manager.py b/postmark/models/outbound/manager.py index 26394a3..4d2adbb 100644 --- a/postmark/models/outbound/manager.py +++ b/postmark/models/outbound/manager.py @@ -1,6 +1,7 @@ from __future__ import annotations import logging +import warnings from collections.abc import AsyncGenerator from datetime import datetime from typing import TYPE_CHECKING, Any @@ -68,6 +69,17 @@ def _parse_bulk_email(message: BulkEmail | dict[str, Any]) -> BulkEmail: raise InvalidEmailException(e.errors()) from e +def _warn_date_filters_unsupported(method: str) -> None: + """Postmark's opens/clicks endpoints have no date filter; the arguments are no-ops.""" + warnings.warn( + f"{method}(): from_date/to_date have no effect. Postmark's opens and clicks " + "endpoints do not support date filtering, so the arguments are ignored " + "and not sent. Filter the returned events by received_at instead.", + FutureWarning, + stacklevel=3, + ) + + class OutboundManager: def __init__(self, client: HTTPClient): self.client = client @@ -322,7 +334,13 @@ async def list_opens( from_date: datetime | None = None, to_date: datetime | None = None, ) -> Page[OpenEvent]: - """List open tracking events across all messages.""" + """ + List open tracking events across all messages. + + ``from_date`` / ``to_date`` have no effect: Postmark does not support date + filtering on this endpoint. Passing them emits a ``FutureWarning`` and they + are not sent. + """ if count > 500: raise ValueError("Count cannot exceed 500 per request") if count + offset > 10000: @@ -356,10 +374,8 @@ async def list_opens( params["city"] = city if message_stream is not None: params["messagestream"] = message_stream - if from_date is not None: - params["fromdate"] = from_date.strftime("%Y-%m-%dT%H:%M:%S") - if to_date is not None: - params["todate"] = to_date.strftime("%Y-%m-%dT%H:%M:%S") + if from_date is not None or to_date is not None: + _warn_date_filters_unsupported("list_opens") response = await self.client.get("/messages/outbound/opens", params=params) data = response.json() @@ -411,7 +427,13 @@ async def list_clicks( from_date: datetime | None = None, to_date: datetime | None = None, ) -> Page[ClickEvent]: - """List click tracking events across all messages.""" + """ + List click tracking events across all messages. + + ``from_date`` / ``to_date`` have no effect: Postmark does not support date + filtering on this endpoint. Passing them emits a ``FutureWarning`` and they + are not sent. + """ if count > 500: raise ValueError("Count cannot exceed 500 per request") if count + offset > 10000: @@ -445,10 +467,8 @@ async def list_clicks( params["city"] = city if message_stream is not None: params["messagestream"] = message_stream - if from_date is not None: - params["fromdate"] = from_date.strftime("%Y-%m-%dT%H:%M:%S") - if to_date is not None: - params["todate"] = to_date.strftime("%Y-%m-%dT%H:%M:%S") + if from_date is not None or to_date is not None: + _warn_date_filters_unsupported("list_clicks") response = await self.client.get("/messages/outbound/clicks", params=params) data = response.json() diff --git a/tests/test_retrieve_messages.py b/tests/test_retrieve_messages.py index 887e20e..1cc274a 100644 --- a/tests/test_retrieve_messages.py +++ b/tests/test_retrieve_messages.py @@ -467,3 +467,51 @@ async def test_count_plus_offset_validation( await getattr(manager, list_method)(count=500, offset=9501) fake.get.assert_not_called() + + +@pytest.mark.parametrize( + "list_method,response_key", + [("list_opens", "Opens"), ("list_clicks", "Clicks")], +) +class TestTrackingDateFiltersUnsupported: + """Postmark's opens/clicks endpoints ignore date filters, so the arguments warn + and are not sent (the signature is kept so existing calls do not break).""" + + @pytest.mark.asyncio + @pytest.mark.parametrize( + "kwargs", + [ + {"from_date": datetime(2024, 1, 1)}, + {"to_date": datetime(2024, 1, 31)}, + {"from_date": datetime(2024, 1, 1), "to_date": datetime(2024, 1, 31)}, + ], + ) + async def test_date_arguments_warn_and_are_not_sent( + self, outbound, list_method, response_key, kwargs + ): + manager, fake = outbound + fake.mock_get_response({"TotalCount": 0, response_key: []}) + + with pytest.warns(FutureWarning, match="have no effect") as record: + result = await getattr(manager, list_method)(**kwargs) + + params = fake.get.call_args[1]["params"] + assert "fromdate" not in params + assert "todate" not in params + assert result.total == 0 + # The warning points at the caller, not at the SDK internals + assert record[0].filename == __file__ + assert list_method in str(record[0].message) + + @pytest.mark.asyncio + async def test_no_warning_without_date_arguments( + self, outbound, list_method, response_key + ): + import warnings + + manager, fake = outbound + fake.mock_get_response({"TotalCount": 0, response_key: []}) + + with warnings.catch_warnings(): + warnings.simplefilter("error") + await getattr(manager, list_method)(tag="promo") From 04371aed2e371a0ba71fd94c3287487e1ada6035 Mon Sep 17 00:00:00 2001 From: Greg Svoboda <13701780+gregsvo@users.noreply.github.com> Date: Thu, 29 Oct 2026 16:52:14 -0400 Subject: [PATCH 08/16] fix(webhooks): make edit() work for partial updates The docstring promised that only the fields you pass are changed, but PUT /webhooks/{id} requires Url and verifies the endpoint using only the request body, so an edit without the URL failed, and an edit of a webhook that uses Basic auth failed verification. Read the current webhook first, merge the requested changes into it (triggers field by field, dropping the read-only per-trigger Status) and send the complete configuration. Behaviour change: edit() now makes one extra GET before the PUT. --- .secrets.baseline | 4 +- CHANGELOG.md | 1 + postmark/models/webhooks/manager.py | 49 +++++-- tests/test_webhooks.py | 208 ++++++++++++++++++++++++---- 4 files changed, 221 insertions(+), 41 deletions(-) diff --git a/.secrets.baseline b/.secrets.baseline index 71a47fd..438d100 100644 --- a/.secrets.baseline +++ b/.secrets.baseline @@ -133,7 +133,7 @@ "filename": "postmark/models/webhooks/manager.py", "hashed_secret": "9d4e1e23bd5b727046a9e3b4b7db57bd8d6ee684", "is_verified": false, - "line_number": 48 + "line_number": 41 } ], "tests/test_webhooks.py": [ @@ -146,5 +146,5 @@ } ] }, - "generated_at": "2026-10-29T20:40:13Z" + "generated_at": "2026-10-29T20:52:14Z" } diff --git a/CHANGELOG.md b/CHANGELOG.md index d5daab5..c9d0712 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,6 +20,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/). - Stats: `platform_usage()`, `browser_platform_usage()`, `click_location()` and the other per-day stats models no longer raise when Postmark omits a category whose count is zero (for example a day with no mobile activity). Missing counts read as `0` and a missing `days` as `[]`. - **Behaviour change:** `list_opens()` / `list_clicks()` client and OS filters (`client_name`, `client_company`, `client_family`, `os_name`, `os_family`, `os_company`) are now sent with the parameter names Postmark understands. They were sent in camelCase, which the API silently ignored, so these filters previously had no effect and returned unfiltered results. Calls that pass them will now return filtered results. - `list_opens()` / `list_clicks()`: `from_date` / `to_date` never had any effect (Postmark does not support date filtering on these endpoints). They are still accepted so existing calls keep working, but now emit a `FutureWarning` and are no longer sent. +- `webhooks.edit()` now works with partial updates. Postmark requires `Url` on every `PUT /webhooks/{id}` and verifies the endpoint using only the request body, so `edit()` reads the current webhook, merges your changes (triggers field by field) and sends the complete configuration. This costs one extra `GET` per edit; calls that already passed everything keep working. ### Changed diff --git a/postmark/models/webhooks/manager.py b/postmark/models/webhooks/manager.py index 46489b8..0ba4ca7 100644 --- a/postmark/models/webhooks/manager.py +++ b/postmark/models/webhooks/manager.py @@ -69,25 +69,46 @@ async def edit( """ Update a webhook. - Only fields you provide are changed; omitted fields are left unchanged. + Only fields you provide are changed; omitted fields keep their current value. + + Postmark's ``PUT /webhooks/{id}`` always requires ``Url`` and verifies the + endpoint using only what the request contains, so this method first reads + the current webhook, merges your changes into it and sends the complete + configuration. That costs one extra ``GET`` and is not atomic if the + webhook is changed elsewhere at the same time. Args: webhook_id: ID of the webhook to update. url: New destination URL. - http_auth: Basic auth credentials dict. - http_headers: Custom headers list. - triggers: Trigger configuration dict. + http_auth: Basic auth credentials dict (replaces the stored credentials). + http_headers: Custom headers list (replaces the stored list; ``[]`` clears). + triggers: Trigger configuration. Merged field by field into the existing + triggers, so ``{"Bounce": {"Enabled": False}}`` leaves every other + trigger and every other Bounce setting as it is. """ - body: dict = {} - - if url is not None: - body["Url"] = url - if http_auth is not None: - body["HttpAuth"] = http_auth - if http_headers is not None: - body["HttpHeaders"] = http_headers - if triggers is not None: - body["Triggers"] = triggers + current = (await self.client.get(f"/webhooks/{webhook_id}")).json() + + body: dict = {"Url": url if url is not None else current["Url"]} + + auth = http_auth if http_auth is not None else current.get("HttpAuth") + if auth is not None: + body["HttpAuth"] = auth + + headers = ( + http_headers if http_headers is not None else current.get("HttpHeaders") + ) + if headers is not None: + body["HttpHeaders"] = headers + + # Responses carry a read-only per-trigger "Status"; it must not be sent back. + merged: dict = { + name: {k: v for k, v in config.items() if k != "Status"} + for name, config in (current.get("Triggers") or {}).items() + } + for name, config in (triggers or {}).items(): + merged[name] = {**merged.get(name, {}), **config} + if merged: + body["Triggers"] = merged response = await self.client.put(f"/webhooks/{webhook_id}", json=body) return Webhook(**response.json()) diff --git a/tests/test_webhooks.py b/tests/test_webhooks.py index e785e3d..3a4debe 100644 --- a/tests/test_webhooks.py +++ b/tests/test_webhooks.py @@ -203,59 +203,217 @@ async def test_create_omits_none_fields(self, webhooks): # --------------------------------------------------------------------------- +# Shape captured from the live API: per-trigger "Status" on enabled triggers, +# stored Basic auth and a custom header. +LIVE_WEBHOOK = { + "ID": 1, + "Url": "https://old.example.com/hook", + "MessageStream": "outbound", + "HttpAuth": {"Username": "user", "Password": "pass"}, + "HttpHeaders": [{"Name": "X-Custom", "Value": "sdk"}], + "Triggers": { + "Open": {"Enabled": False, "PostFirstOpenOnly": False}, + "Click": {"Enabled": False}, + "Delivery": {"Enabled": True, "Status": "verified"}, + "Bounce": {"Enabled": True, "IncludeContent": True, "Status": "verified"}, + "SpamComplaint": {"Enabled": False, "IncludeContent": False}, + "SubscriptionChange": {"Enabled": False}, + }, +} + +EXPECTED_TRIGGERS_NO_STATUS = { + "Open": {"Enabled": False, "PostFirstOpenOnly": False}, + "Click": {"Enabled": False}, + "Delivery": {"Enabled": True}, + "Bounce": {"Enabled": True, "IncludeContent": True}, + "SpamComplaint": {"Enabled": False, "IncludeContent": False}, + "SubscriptionChange": {"Enabled": False}, +} + + class TestEditWebhook: - @pytest.mark.asyncio - async def test_edit_url(self, webhooks): + """Postmark's PUT requires Url and verifies using only the request body, so + edit() reads the current webhook and sends the complete merged configuration.""" + + @pytest.fixture + def live(self, webhooks): manager, fake = webhooks - fake.mock_put_response(_make_webhook(Url="https://new.com/hook")) + fake.mock_get_response(LIVE_WEBHOOK) + fake.mock_put_response(LIVE_WEBHOOK) + return manager, fake + + @pytest.mark.asyncio + async def test_edit_url_keeps_everything_else(self, live): + manager, fake = live + fake.mock_put_response({**LIVE_WEBHOOK, "Url": "https://new.com/hook"}) wh = await manager.edit(1, url="https://new.com/hook") assert wh.url == "https://new.com/hook" fake.put.assert_called_once_with( - "/webhooks/1", json={"Url": "https://new.com/hook"} + "/webhooks/1", + json={ + "Url": "https://new.com/hook", + "HttpAuth": {"Username": "user", "Password": "pass"}, + "HttpHeaders": [{"Name": "X-Custom", "Value": "sdk"}], + "Triggers": EXPECTED_TRIGGERS_NO_STATUS, + }, ) @pytest.mark.asyncio - async def test_edit_no_args_sends_empty_body(self, webhooks): - manager, fake = webhooks - fake.mock_put_response(WEBHOOK) + async def test_edit_without_url_uses_current_url(self, live): + """The live API rejects a PUT without Url (error 1354).""" + manager, fake = live + + await manager.edit(1, triggers={"Bounce": {"Enabled": False}}) + + assert fake.put.call_args[1]["json"]["Url"] == "https://old.example.com/hook" + + @pytest.mark.asyncio + async def test_reads_current_webhook_first(self, live): + manager, fake = live + order = [] + fake.get.side_effect = lambda *a, **k: ( + order.append("get") or fake.get.return_value + ) + fake.put.side_effect = lambda *a, **k: ( + order.append("put") or fake.put.return_value + ) + + await manager.edit(7, url="https://x.com/h") + + assert order == ["get", "put"] + fake.get.assert_called_once_with("/webhooks/7") + assert fake.put.call_args[0][0] == "/webhooks/7" + + @pytest.mark.asyncio + async def test_trigger_status_is_not_sent_back(self, live): + manager, fake = live + + await manager.edit(1, url="https://x.com/h") + + sent = fake.put.call_args[1]["json"]["Triggers"] + assert all("Status" not in cfg for cfg in sent.values()) + + @pytest.mark.asyncio + async def test_triggers_merge_field_by_field(self, live): + manager, fake = live + + await manager.edit(1, triggers={"Bounce": {"IncludeContent": False}}) + + sent = fake.put.call_args[1]["json"]["Triggers"] + assert sent["Bounce"] == {"Enabled": True, "IncludeContent": False} + assert sent["Delivery"] == {"Enabled": True} + assert sent["Open"] == {"Enabled": False, "PostFirstOpenOnly": False} + + @pytest.mark.asyncio + async def test_triggers_can_enable_a_new_trigger(self, live): + manager, fake = live + + await manager.edit(1, triggers={"Click": {"Enabled": True}}) + + sent = fake.put.call_args[1]["json"]["Triggers"] + assert sent["Click"] == {"Enabled": True} + assert sent["Delivery"] == {"Enabled": True} + + @pytest.mark.asyncio + async def test_unknown_trigger_name_is_passed_through(self, live): + manager, fake = live + + await manager.edit(1, triggers={"Brand New": {"Enabled": True}}) + + assert fake.put.call_args[1]["json"]["Triggers"]["Brand New"] == { + "Enabled": True + } + + @pytest.mark.asyncio + async def test_edit_with_http_auth_replaces_stored_auth(self, live): + manager, fake = live + + await manager.edit(1, http_auth={"Username": "u", "Password": "p"}) + + body = fake.put.call_args[1]["json"] + assert body["HttpAuth"] == {"Username": "u", "Password": "p"} + assert body["HttpHeaders"] == [{"Name": "X-Custom", "Value": "sdk"}] + + @pytest.mark.asyncio + async def test_edit_http_headers_replace_and_empty_list_clears(self, live): + manager, fake = live + + await manager.edit(1, http_headers=[{"Name": "X-A", "Value": "1"}]) + assert fake.put.call_args[1]["json"]["HttpHeaders"] == [ + {"Name": "X-A", "Value": "1"} + ] + + await manager.edit(1, http_headers=[]) + assert fake.put.call_args[1]["json"]["HttpHeaders"] == [] + + @pytest.mark.asyncio + async def test_edit_no_args_resaves_current_configuration(self, live): + manager, fake = live await manager.edit(1) - fake.put.assert_called_once_with("/webhooks/1", json={}) + assert fake.put.call_args[1]["json"] == { + "Url": "https://old.example.com/hook", + "HttpAuth": {"Username": "user", "Password": "pass"}, + "HttpHeaders": [{"Name": "X-Custom", "Value": "sdk"}], + "Triggers": EXPECTED_TRIGGERS_NO_STATUS, + } @pytest.mark.asyncio - async def test_edit_calls_correct_endpoint(self, webhooks): + async def test_webhook_without_auth_or_headers_sends_neither(self, webhooks): + """Live responses omit HttpAuth/HttpHeaders entirely when unset.""" manager, fake = webhooks + bare = { + "ID": 2, + "Url": "https://bare.example.com", + "MessageStream": "outbound", + "Triggers": { + **EXPECTED_TRIGGERS_NO_STATUS, + "Delivery": {"Enabled": True, "Status": "verified"}, + }, + } + fake.mock_get_response(bare) + fake.mock_put_response(bare) + + await manager.edit(2, url="https://bare2.example.com") + + assert fake.put.call_args[1]["json"] == { + "Url": "https://bare2.example.com", + "Triggers": EXPECTED_TRIGGERS_NO_STATUS, + } + + @pytest.mark.asyncio + async def test_webhook_without_triggers_sends_none(self, webhooks): + manager, fake = webhooks + bare = {"ID": 3, "Url": "https://t.example.com", "MessageStream": "outbound"} + fake.mock_get_response(bare) fake.mock_put_response(WEBHOOK) - await manager.edit(99, url="https://example.com/hook") + await manager.edit(3, url="https://t2.example.com") - fake.put.assert_called_once_with( - "/webhooks/99", json={"Url": "https://example.com/hook"} - ) + assert fake.put.call_args[1]["json"] == {"Url": "https://t2.example.com"} @pytest.mark.asyncio - async def test_edit_with_triggers(self, webhooks): + async def test_failed_read_prevents_the_write(self, webhooks): manager, fake = webhooks - fake.mock_put_response(WEBHOOK) + fake.get.side_effect = RuntimeError("not found") - triggers = {"Bounce": {"Enabled": True, "IncludeContent": True}} - await manager.edit(1, triggers=triggers) + with pytest.raises(RuntimeError): + await manager.edit(99, url="https://x.com/h") - body = fake.put.call_args[1]["json"] - assert body["Triggers"] == triggers + fake.put.assert_not_called() @pytest.mark.asyncio - async def test_edit_with_http_auth(self, webhooks): - manager, fake = webhooks - fake.mock_put_response(WEBHOOK) + async def test_edit_returns_updated_webhook(self, live): + manager, fake = live + fake.mock_put_response({**LIVE_WEBHOOK, "Url": "https://new.com/hook"}) - await manager.edit(1, http_auth={"Username": "u", "Password": "p"}) + wh = await manager.edit(1, url="https://new.com/hook") - body = fake.put.call_args[1]["json"] - assert body["HttpAuth"] == {"Username": "u", "Password": "p"} + assert wh.id == 1 + assert wh.url == "https://new.com/hook" # --------------------------------------------------------------------------- From e074f6df1e38abed30f5d04bee6fc1c974edc318 Mon Sep 17 00:00:00 2001 From: Greg Svoboda <13701780+gregsvo@users.noreply.github.com> Date: Thu, 29 Oct 2026 16:52:37 -0400 Subject: [PATCH 09/16] feat(outbound): add list_bulk_requests and stream_bulk_requests Add GET /email/bulk. Unlike the other lists it paginates with an opaque cursor rather than offsets, so add a generic CursorPage (items and next_key) and have list_bulk_requests() return it. stream_bulk_requests() follows the cursor. count is validated to 1-500 before any request: Postmark answers 0 or a negative count with an HTTP 500, which the client would retry. --- .secrets.baseline | 4 +- CHANGELOG.md | 4 + postmark/__init__.py | 3 +- postmark/models/outbound/manager.py | 48 ++++++- postmark/models/page.py | 17 +++ tests/test_bulk_messages.py | 189 ++++++++++++++++++++++++++++ 6 files changed, 261 insertions(+), 4 deletions(-) diff --git a/.secrets.baseline b/.secrets.baseline index 438d100..71a47fd 100644 --- a/.secrets.baseline +++ b/.secrets.baseline @@ -133,7 +133,7 @@ "filename": "postmark/models/webhooks/manager.py", "hashed_secret": "9d4e1e23bd5b727046a9e3b4b7db57bd8d6ee684", "is_verified": false, - "line_number": 41 + "line_number": 48 } ], "tests/test_webhooks.py": [ @@ -146,5 +146,5 @@ } ] }, - "generated_at": "2026-10-29T20:52:14Z" + "generated_at": "2026-10-29T20:40:13Z" } diff --git a/CHANGELOG.md b/CHANGELOG.md index c9d0712..8c77562 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/). ## [Unreleased] +### Added + +- `outbound.list_bulk_requests(count, pagination_key)` for `GET /email/bulk`, returning the new `CursorPage` (`items`, `next_key`; `next_key` is `None` on the last page), and `outbound.stream_bulk_requests()` which follows the cursor automatically. `count` must be 1-500 (Postmark answers 0 with an HTTP 500). + ### Fixed - Django backend closes clients it automatically opens when a batch send fails, while preserving caller-owned connections for reuse. diff --git a/postmark/__init__.py b/postmark/__init__.py index 7e56373..01c4f80 100644 --- a/postmark/__init__.py +++ b/postmark/__init__.py @@ -22,7 +22,7 @@ ) from .models import outbound from .models.outbound import Attachment, Email, Header, Message, SendResponse -from .models.page import Page +from .models.page import CursorPage, Page from .utils import server_utils # Base logger @@ -49,6 +49,7 @@ "ServerException", "TimeoutException", "server_utils", + "CursorPage", "Page", "__version__", ] diff --git a/postmark/models/outbound/manager.py b/postmark/models/outbound/manager.py index 4d2adbb..aa26dc4 100644 --- a/postmark/models/outbound/manager.py +++ b/postmark/models/outbound/manager.py @@ -9,7 +9,7 @@ from pydantic import ValidationError from postmark.exceptions import InvalidEmailException -from postmark.models.page import Page +from postmark.models.page import CursorPage, Page from postmark.utils.pagination import paginate from postmark.utils.types import HTTPClient @@ -168,6 +168,52 @@ async def get_bulk_status(self, bulk_id: str) -> BulkSendStatus: response = await self.client.get(f"/email/bulk/{bulk_id}") return BulkSendStatus(**response.json()) + async def list_bulk_requests( + self, count: int = 100, pagination_key: str | None = None + ) -> CursorPage[BulkSendStatus]: + """ + List the bulk requests submitted on this server, newest first. + + Pagination uses an opaque cursor instead of offsets: pass the previous + page's ``next_key`` as ``pagination_key`` to get the following page. + ``next_key`` is ``None`` on the last page. + + Args: + count: Requests per page, 1 to 500. + pagination_key: Cursor from the previous page. Do not build or modify it. + """ + if not 1 <= count <= 500: + raise ValueError("Count must be between 1 and 500") + + params: dict[str, Any] = {"count": count} + if pagination_key is not None: + params["paginationKey"] = pagination_key + + response = await self.client.get("/email/bulk", params=params) + data = response.json() + return CursorPage( + items=[BulkSendStatus(**item) for item in data.get("Requests", [])], + next_key=data.get("PaginationKey"), + ) + + async def stream_bulk_requests( + self, batch_size: int = 100, max_requests: int = 1000 + ) -> AsyncGenerator[BulkSendStatus, None]: + """Stream bulk requests, following the pagination cursor automatically.""" + yielded = 0 + key: str | None = None + while yielded < max_requests: + page = await self.list_bulk_requests( + count=min(batch_size, 500, max_requests - yielded), + pagination_key=key, + ) + for item in page.items: + yield item + yielded += 1 + key = page.next_key + if key is None or not page.items: + break + # ------------------------------------------------------------------------- # Template sends # ------------------------------------------------------------------------- diff --git a/postmark/models/page.py b/postmark/models/page.py index e1242e9..9bef073 100644 --- a/postmark/models/page.py +++ b/postmark/models/page.py @@ -16,3 +16,20 @@ class Page(Generic[T]): items: list[T] total: int + + +@dataclass(frozen=True) +class CursorPage(Generic[T]): + """ + A single page of results from a cursor-paginated list endpoint. + + Unlike :class:`Page`, these endpoints report no total, only an opaque cursor. + + Attributes: + items: The list of results for this page. + next_key: Opaque cursor for the next page, or ``None`` on the last page. + Pass it back unchanged; Postmark rejects altered values. + """ + + items: list[T] + next_key: str | None = None diff --git a/tests/test_bulk_messages.py b/tests/test_bulk_messages.py index ac4db24..90d9683 100644 --- a/tests/test_bulk_messages.py +++ b/tests/test_bulk_messages.py @@ -375,3 +375,192 @@ def test_bulk_job_status_keeps_failed_for_backward_compatibility(self): "Cancelled", "Failed", } + + +# --------------------------------------------------------------------------- +# GET /email/bulk — cursor-paginated listing +# --------------------------------------------------------------------------- + + +def _bulk_request(n: int, **overrides) -> dict: + """Shape captured from the live API (GET /email/bulk).""" + return { + "Id": f"bulk-{n}", + "SubmittedAt": "2026-10-21T20:18:16.3590414Z", + "TotalMessages": 2, + "PercentageCompleted": 100, + "Status": "Completed", + "Subject": f"Bulk {n}", + "ReleasedCount": 2, + "FailedCount": 0, + **overrides, + } + + +class TestListBulkRequests: + @pytest.mark.asyncio + async def test_list_parses_requests(self, outbound): + manager, fake = outbound + fake.mock_get_response({"Requests": [_bulk_request(1), _bulk_request(2)]}) + + page = await manager.list_bulk_requests() + + assert [r.id for r in page.items] == ["bulk-1", "bulk-2"] + assert page.items[0].status == BulkJobStatus.COMPLETED + assert page.items[0].total_messages == 2 + assert page.items[0].released_count == 2 + assert page.items[0].subject == "Bulk 1" + + @pytest.mark.asyncio + async def test_default_request(self, outbound): + manager, fake = outbound + fake.mock_get_response({"Requests": []}) + + await manager.list_bulk_requests() + + fake.get.assert_called_once_with("/email/bulk", params={"count": 100}) + + @pytest.mark.asyncio + async def test_last_page_has_no_next_key(self, outbound): + """PaginationKey is absent on the last page (observed live).""" + manager, fake = outbound + fake.mock_get_response({"Requests": [_bulk_request(1)]}) + + page = await manager.list_bulk_requests() + + assert page.next_key is None + + @pytest.mark.asyncio + async def test_empty_server(self, outbound): + manager, fake = outbound + fake.mock_get_response({"Requests": []}) + + page = await manager.list_bulk_requests() + + assert page.items == [] + assert page.next_key is None + + @pytest.mark.asyncio + async def test_missing_requests_key_is_empty(self, outbound): + manager, fake = outbound + fake.mock_get_response({}) + + page = await manager.list_bulk_requests() + + assert page.items == [] + + @pytest.mark.asyncio + async def test_next_key_is_returned_and_passed_back_unchanged(self, outbound): + manager, fake = outbound + fake.mock_get_response( + {"Requests": [_bulk_request(1)], "PaginationKey": "opaque+key=="} + ) + + first = await manager.list_bulk_requests(count=1) + assert first.next_key == "opaque+key==" + + await manager.list_bulk_requests(count=1, pagination_key=first.next_key) + assert fake.get.call_args[1]["params"] == { + "count": 1, + "paginationKey": "opaque+key==", + } + + @pytest.mark.asyncio + @pytest.mark.parametrize("count", [0, -1, 501]) + async def test_count_outside_1_to_500_is_rejected_before_any_request( + self, outbound, count + ): + """Postmark answers count=0/-1 with HTTP 500, which the client would retry.""" + manager, fake = outbound + + with pytest.raises(ValueError, match="between 1 and 500"): + await manager.list_bulk_requests(count=count) + + fake.get.assert_not_called() + + @pytest.mark.asyncio + @pytest.mark.parametrize("count", [1, 500]) + async def test_count_boundaries_accepted(self, outbound, count): + manager, fake = outbound + fake.mock_get_response({"Requests": []}) + + await manager.list_bulk_requests(count=count) + + assert fake.get.call_args[1]["params"]["count"] == count + + +class TestStreamBulkRequests: + @pytest.mark.asyncio + async def test_follows_the_cursor_until_it_runs_out(self, outbound): + manager, fake = outbound + fake.mock_get_responses( + {"Requests": [_bulk_request(1), _bulk_request(2)], "PaginationKey": "k1"}, + {"Requests": [_bulk_request(3), _bulk_request(4)], "PaginationKey": "k2"}, + {"Requests": [_bulk_request(5)]}, + ) + + got = [r.id async for r in manager.stream_bulk_requests(batch_size=2)] + + assert got == ["bulk-1", "bulk-2", "bulk-3", "bulk-4", "bulk-5"] + sent = [c[1]["params"] for c in fake.get.call_args_list] + assert sent == [ + {"count": 2}, + {"count": 2, "paginationKey": "k1"}, + {"count": 2, "paginationKey": "k2"}, + ] + + @pytest.mark.asyncio + async def test_stops_at_max_requests(self, outbound): + manager, fake = outbound + fake.mock_get_responses( + {"Requests": [_bulk_request(1), _bulk_request(2)], "PaginationKey": "k1"}, + {"Requests": [_bulk_request(3)], "PaginationKey": "k2"}, + ) + + got = [ + r.id + async for r in manager.stream_bulk_requests(batch_size=2, max_requests=3) + ] + + assert got == ["bulk-1", "bulk-2", "bulk-3"] + assert fake.get.call_count == 2 + # the second page only asks for what is still needed + assert fake.get.call_args_list[1][1]["params"]["count"] == 1 + + @pytest.mark.asyncio + async def test_batch_size_is_capped_at_500(self, outbound): + manager, fake = outbound + fake.mock_get_response({"Requests": []}) + + _ = [r async for r in manager.stream_bulk_requests(batch_size=9999)] + + assert fake.get.call_args[1]["params"]["count"] == 500 + + @pytest.mark.asyncio + async def test_empty_page_with_a_key_does_not_loop_forever(self, outbound): + manager, fake = outbound + fake.mock_get_response({"Requests": [], "PaginationKey": "stuck"}) + + got = [r async for r in manager.stream_bulk_requests()] + + assert got == [] + assert fake.get.call_count == 1 + + @pytest.mark.asyncio + async def test_empty_server_yields_nothing(self, outbound): + manager, fake = outbound + fake.mock_get_response({"Requests": []}) + + assert [r async for r in manager.stream_bulk_requests()] == [] + + +class TestCursorPage: + def test_defaults_and_immutability(self): + from dataclasses import FrozenInstanceError + + from postmark import CursorPage + + page = CursorPage(items=[1, 2]) + assert page.next_key is None + with pytest.raises(FrozenInstanceError): + page.items = [] # type: ignore[misc] From fd962dfed65ef913e5b17352e8aac92b0cbe1309 Mon Sep 17 00:00:00 2001 From: Greg Svoboda <13701780+gregsvo@users.noreply.github.com> Date: Thu, 29 Oct 2026 16:53:06 -0400 Subject: [PATCH 10/16] feat(webhooks): add get_statistics Add GET /webhooks/{id}/statistics: delivery metrics over the last 24 hours, overall and per trigger, plus the verification status of each enabled trigger. Averages are optional because Postmark omits them when there was no traffic or no retries. --- .secrets.baseline | 4 +- CHANGELOG.md | 1 + postmark/models/webhooks/__init__.py | 6 + postmark/models/webhooks/manager.py | 18 ++- postmark/models/webhooks/schemas.py | 54 +++++++++ tests/test_webhooks.py | 160 +++++++++++++++++++++++++++ 6 files changed, 240 insertions(+), 3 deletions(-) diff --git a/.secrets.baseline b/.secrets.baseline index 71a47fd..dafddf3 100644 --- a/.secrets.baseline +++ b/.secrets.baseline @@ -133,7 +133,7 @@ "filename": "postmark/models/webhooks/manager.py", "hashed_secret": "9d4e1e23bd5b727046a9e3b4b7db57bd8d6ee684", "is_verified": false, - "line_number": 48 + "line_number": 46 } ], "tests/test_webhooks.py": [ @@ -146,5 +146,5 @@ } ] }, - "generated_at": "2026-10-29T20:40:13Z" + "generated_at": "2026-10-29T20:53:06Z" } diff --git a/CHANGELOG.md b/CHANGELOG.md index 8c77562..3ad3b25 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/). ### Added - `outbound.list_bulk_requests(count, pagination_key)` for `GET /email/bulk`, returning the new `CursorPage` (`items`, `next_key`; `next_key` is `None` on the last page), and `outbound.stream_bulk_requests()` which follows the cursor automatically. `count` must be 1-500 (Postmark answers 0 with an HTTP 500). +- `webhooks.get_statistics(webhook_id)` for `GET /webhooks/{id}/statistics` (24-hour window, overall and per-trigger metrics, per-trigger verification status), with `WebhookStatistics`, `WebhookMetrics` and `WebhookTimeRange` models. ### Fixed diff --git a/postmark/models/webhooks/__init__.py b/postmark/models/webhooks/__init__.py index 1846a15..2e3e642 100644 --- a/postmark/models/webhooks/__init__.py +++ b/postmark/models/webhooks/__init__.py @@ -10,7 +10,10 @@ SpamComplaintTrigger, SubscriptionChangeTrigger, Webhook, + WebhookMetrics, WebhooksListResponse, + WebhookStatistics, + WebhookTimeRange, WebhookTriggers, ) @@ -26,6 +29,9 @@ "SubscriptionChangeTrigger", "WebhookTriggers", "Webhook", + "WebhookMetrics", + "WebhookTimeRange", + "WebhookStatistics", "WebhooksListResponse", "DeleteWebhookResponse", # Manager diff --git a/postmark/models/webhooks/manager.py b/postmark/models/webhooks/manager.py index 0ba4ca7..873f59e 100644 --- a/postmark/models/webhooks/manager.py +++ b/postmark/models/webhooks/manager.py @@ -2,7 +2,12 @@ from postmark.utils.types import HTTPClient -from .schemas import DeleteWebhookResponse, Webhook, WebhooksListResponse +from .schemas import ( + DeleteWebhookResponse, + Webhook, + WebhooksListResponse, + WebhookStatistics, +) class WebhookManager: @@ -113,6 +118,17 @@ async def edit( response = await self.client.put(f"/webhooks/{webhook_id}", json=body) return Webhook(**response.json()) + async def get_statistics(self, webhook_id: int) -> WebhookStatistics: + """ + Return delivery statistics for a webhook over the last 24 hours. + + Includes overall and per-trigger request, success, failure and retry counts, + the success rate and average response times, plus each enabled trigger's + verification status. The window is fixed by Postmark; there are no filters. + """ + response = await self.client.get(f"/webhooks/{webhook_id}/statistics") + return WebhookStatistics(**response.json()) + async def delete(self, webhook_id: int) -> DeleteWebhookResponse: """Delete a webhook by ID.""" response = await self.client.delete(f"/webhooks/{webhook_id}") diff --git a/postmark/models/webhooks/schemas.py b/postmark/models/webhooks/schemas.py index 04dc624..8f4f444 100644 --- a/postmark/models/webhooks/schemas.py +++ b/postmark/models/webhooks/schemas.py @@ -1,3 +1,5 @@ +from datetime import datetime + from pydantic import BaseModel, ConfigDict, Field @@ -93,3 +95,55 @@ class DeleteWebhookResponse(BaseModel): message: str = Field(alias="Message") model_config = ConfigDict(populate_by_name=True) + + +class WebhookMetrics(BaseModel): + """Delivery metrics for a webhook (or for one of its triggers) over the window.""" + + total_requests: int = Field(0, alias="TotalRequests") + success_count: int = Field(0, alias="SuccessCount") + failure_count: int = Field(0, alias="FailureCount") + retry_count: int = Field(0, alias="RetryCount") + success_rate: float = Field(0.0, alias="SuccessRate") + slow_count: int = Field(0, alias="SlowCount") + very_slow_count: int = Field(0, alias="VerySlowCount") + # Absent when there was no traffic / no retries in the window. + average_terminal_response_time_ms: float | None = Field( + None, alias="AverageTerminalResponseTimeMs" + ) + average_retry_response_time_ms: float | None = Field( + None, alias="AverageRetryResponseTimeMs" + ) + + model_config = ConfigDict(populate_by_name=True) + + +class WebhookTimeRange(BaseModel): + """The window the statistics cover (currently the last 24 hours).""" + + start_time: datetime = Field(alias="StartTime") + end_time: datetime = Field(alias="EndTime") + hours: int = Field(alias="Hours") + + model_config = ConfigDict(populate_by_name=True) + + +class WebhookStatistics(BaseModel): + """Response from ``GET /webhooks/{id}/statistics``.""" + + webhook_id: int = Field(alias="WebhookId") + server_id: int | None = Field(None, alias="ServerId") + message_stream_id: str | None = Field(None, alias="MessageStreamId") + url: str | None = Field(None, alias="Url") + # Enabled triggers only, e.g. {"Delivery": "verified"}. + statuses: dict[str, str] = Field(default_factory=dict, alias="Statuses") + time_range: WebhookTimeRange | None = Field(None, alias="TimeRange") + metrics: WebhookMetrics = Field( + default_factory=lambda: WebhookMetrics.model_validate({}), alias="Metrics" + ) + # Keyed by trigger name (Open, Click, Delivery, Bounce, ...). + metrics_by_trigger: dict[str, WebhookMetrics] = Field( + default_factory=dict, alias="MetricsByTrigger" + ) + + model_config = ConfigDict(populate_by_name=True) diff --git a/tests/test_webhooks.py b/tests/test_webhooks.py index 3a4debe..0b4e11e 100644 --- a/tests/test_webhooks.py +++ b/tests/test_webhooks.py @@ -451,3 +451,163 @@ async def test_delete_does_not_call_other_methods(self, webhooks): fake.get.assert_not_called() fake.post.assert_not_called() fake.put.assert_not_called() + + +# --------------------------------------------------------------------------- +# Webhook statistics (shape captured from the live API) +# --------------------------------------------------------------------------- + +_ZERO_METRICS = { + "TotalRequests": 0, + "SuccessCount": 0, + "FailureCount": 0, + "RetryCount": 0, + "SuccessRate": 0, + "SlowCount": 0, + "VerySlowCount": 0, +} + +STATISTICS = { + "WebhookId": 1234, + "ServerId": 11111, + "MessageStreamId": "outbound", + "Url": "https://example.com/hook", + "Statuses": {"Open": "verified", "Delivery": "verified"}, + "TimeRange": { + "StartTime": "2026-10-20T20:00:00Z", + "EndTime": "2026-10-21T20:28:07.9440221Z", + "Hours": 24, + }, + "Metrics": { + "TotalRequests": 1, + "SuccessCount": 1, + "FailureCount": 0, + "RetryCount": 0, + "SuccessRate": 100, + "SlowCount": 0, + "VerySlowCount": 0, + "AverageTerminalResponseTimeMs": 98, + }, + "MetricsByTrigger": { + "Open": _ZERO_METRICS, + "Delivery": { + "TotalRequests": 1, + "SuccessCount": 1, + "FailureCount": 0, + "RetryCount": 0, + "SuccessRate": 100, + "SlowCount": 0, + "VerySlowCount": 0, + "AverageTerminalResponseTimeMs": 98, + }, + }, +} + + +class TestWebhookStatistics: + @pytest.mark.asyncio + async def test_calls_the_statistics_endpoint(self, webhooks): + manager, fake = webhooks + fake.mock_get_response(STATISTICS) + + await manager.get_statistics(1234) + + fake.get.assert_called_once_with("/webhooks/1234/statistics") + + @pytest.mark.asyncio + async def test_parses_identity_window_and_statuses(self, webhooks): + manager, fake = webhooks + fake.mock_get_response(STATISTICS) + + stats = await manager.get_statistics(1234) + + assert stats.webhook_id == 1234 + assert stats.server_id == 11111 + assert stats.message_stream_id == "outbound" + assert stats.url == "https://example.com/hook" + assert stats.statuses == {"Open": "verified", "Delivery": "verified"} + assert stats.time_range.hours == 24 + assert stats.time_range.start_time.isoformat() == "2026-10-20T20:00:00+00:00" + assert stats.time_range.end_time.year == 2026 + + @pytest.mark.asyncio + async def test_parses_overall_metrics(self, webhooks): + manager, fake = webhooks + fake.mock_get_response(STATISTICS) + + m = (await manager.get_statistics(1)).metrics + + assert (m.total_requests, m.success_count, m.failure_count) == (1, 1, 0) + assert m.retry_count == 0 + assert m.success_rate == 100.0 + assert (m.slow_count, m.very_slow_count) == (0, 0) + assert m.average_terminal_response_time_ms == 98 + + @pytest.mark.asyncio + async def test_parses_metrics_by_trigger(self, webhooks): + manager, fake = webhooks + fake.mock_get_response(STATISTICS) + + by_trigger = (await manager.get_statistics(1)).metrics_by_trigger + + assert set(by_trigger) == {"Open", "Delivery"} + assert by_trigger["Delivery"].total_requests == 1 + assert by_trigger["Open"].total_requests == 0 + + @pytest.mark.asyncio + async def test_averages_are_none_without_traffic_or_retries(self, webhooks): + """The averages are absent from the response when nothing was sent.""" + manager, fake = webhooks + fake.mock_get_response({**STATISTICS, "Metrics": _ZERO_METRICS}) + + m = (await manager.get_statistics(1)).metrics + + assert m.average_terminal_response_time_ms is None + assert m.average_retry_response_time_ms is None + + @pytest.mark.asyncio + async def test_retry_average_when_present(self, webhooks): + manager, fake = webhooks + metrics = { + **_ZERO_METRICS, + "RetryCount": 2, + "AverageRetryResponseTimeMs": 310.5, + } + fake.mock_get_response({**STATISTICS, "Metrics": metrics}) + + m = (await manager.get_statistics(1)).metrics + + assert m.retry_count == 2 + assert m.average_retry_response_time_ms == 310.5 + + @pytest.mark.asyncio + async def test_minimal_response_uses_defaults(self, webhooks): + manager, fake = webhooks + fake.mock_get_response({"WebhookId": 5}) + + stats = await manager.get_statistics(5) + + assert stats.webhook_id == 5 + assert stats.statuses == {} + assert stats.time_range is None + assert stats.metrics_by_trigger == {} + assert stats.metrics.total_requests == 0 + assert stats.url is None + + @pytest.mark.asyncio + async def test_unknown_trigger_and_status_values_do_not_break_parsing( + self, webhooks + ): + manager, fake = webhooks + fake.mock_get_response( + { + **STATISTICS, + "Statuses": {"BrandNew": "pending"}, + "MetricsByTrigger": {"BrandNew": _ZERO_METRICS}, + } + ) + + stats = await manager.get_statistics(1) + + assert stats.statuses == {"BrandNew": "pending"} + assert "BrandNew" in stats.metrics_by_trigger From 385a2d6c216178077b60eace28276fab48ed0da2 Mon Sep 17 00:00:00 2001 From: Greg Svoboda <13701780+gregsvo@users.noreply.github.com> Date: Thu, 29 Oct 2026 16:53:33 -0400 Subject: [PATCH 11/16] feat(webhooks): add verify Add POST /webhooks/{id}/verify, which sends a test event for every enabled trigger and reports how the endpoint answered. A failed verification is returned with success=False and per-trigger details, as Postmark answers HTTP 200, and is not raised. The docstring notes that each call sends real requests and that timeouts are retried by the client, which would send them again. --- .secrets.baseline | 4 +- CHANGELOG.md | 1 + postmark/models/webhooks/__init__.py | 4 + postmark/models/webhooks/manager.py | 18 +++ postmark/models/webhooks/schemas.py | 28 +++++ tests/test_webhooks.py | 161 +++++++++++++++++++++++++++ 6 files changed, 214 insertions(+), 2 deletions(-) diff --git a/.secrets.baseline b/.secrets.baseline index dafddf3..ec91c5d 100644 --- a/.secrets.baseline +++ b/.secrets.baseline @@ -133,7 +133,7 @@ "filename": "postmark/models/webhooks/manager.py", "hashed_secret": "9d4e1e23bd5b727046a9e3b4b7db57bd8d6ee684", "is_verified": false, - "line_number": 46 + "line_number": 47 } ], "tests/test_webhooks.py": [ @@ -146,5 +146,5 @@ } ] }, - "generated_at": "2026-10-29T20:53:06Z" + "generated_at": "2026-10-29T20:53:33Z" } diff --git a/CHANGELOG.md b/CHANGELOG.md index 3ad3b25..fd7edbe 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/). - `outbound.list_bulk_requests(count, pagination_key)` for `GET /email/bulk`, returning the new `CursorPage` (`items`, `next_key`; `next_key` is `None` on the last page), and `outbound.stream_bulk_requests()` which follows the cursor automatically. `count` must be 1-500 (Postmark answers 0 with an HTTP 500). - `webhooks.get_statistics(webhook_id)` for `GET /webhooks/{id}/statistics` (24-hour window, overall and per-trigger metrics, per-trigger verification status), with `WebhookStatistics`, `WebhookMetrics` and `WebhookTimeRange` models. +- `webhooks.verify(webhook_id)` for `POST /webhooks/{id}/verify`. A failed verification is returned with `success=False` and per-trigger details (status code, message), not raised. Each call sends real test requests to the endpoint, and the client's automatic retry on timeouts would resend them, so use a generous `timeout` for slow endpoints. ### Fixed diff --git a/postmark/models/webhooks/__init__.py b/postmark/models/webhooks/__init__.py index 2e3e642..f01c6a5 100644 --- a/postmark/models/webhooks/__init__.py +++ b/postmark/models/webhooks/__init__.py @@ -15,6 +15,8 @@ WebhookStatistics, WebhookTimeRange, WebhookTriggers, + WebhookTriggerVerification, + WebhookVerificationResult, ) __all__ = [ @@ -32,6 +34,8 @@ "WebhookMetrics", "WebhookTimeRange", "WebhookStatistics", + "WebhookTriggerVerification", + "WebhookVerificationResult", "WebhooksListResponse", "DeleteWebhookResponse", # Manager diff --git a/postmark/models/webhooks/manager.py b/postmark/models/webhooks/manager.py index 873f59e..f98bfeb 100644 --- a/postmark/models/webhooks/manager.py +++ b/postmark/models/webhooks/manager.py @@ -7,6 +7,7 @@ Webhook, WebhooksListResponse, WebhookStatistics, + WebhookVerificationResult, ) @@ -129,6 +130,23 @@ async def get_statistics(self, webhook_id: int) -> WebhookStatistics: response = await self.client.get(f"/webhooks/{webhook_id}/statistics") return WebhookStatistics(**response.json()) + async def verify(self, webhook_id: int) -> WebhookVerificationResult: + """ + Send a test event for every enabled trigger to the webhook's URL. + + A failed verification is **not** an error: Postmark answers HTTP 200 with + ``success=False`` and a per-trigger breakdown (``results``), including the + status code your endpoint returned (``0`` if it could not be reached). Check + ``result.success``. + + Each call sends real test requests to your endpoint. Like every call, this + one is retried automatically on timeouts and 5xx responses, which would send + the test events again, so give a slow endpoint a generous ``timeout`` on the + client (the default is 5 seconds). + """ + response = await self.client.post(f"/webhooks/{webhook_id}/verify") + return WebhookVerificationResult(**response.json()) + async def delete(self, webhook_id: int) -> DeleteWebhookResponse: """Delete a webhook by ID.""" response = await self.client.delete(f"/webhooks/{webhook_id}") diff --git a/postmark/models/webhooks/schemas.py b/postmark/models/webhooks/schemas.py index 8f4f444..25234ce 100644 --- a/postmark/models/webhooks/schemas.py +++ b/postmark/models/webhooks/schemas.py @@ -147,3 +147,31 @@ class WebhookStatistics(BaseModel): ) model_config = ConfigDict(populate_by_name=True) + + +class WebhookTriggerVerification(BaseModel): + """Outcome of the test event sent for one trigger.""" + + # "Open", "Click", "Delivery", "Bounce", "SpamComplaint" or "SubscriptionChange". + trigger_type: str = Field(alias="TriggerType") + success: bool = Field(alias="Success") + # HTTP status your endpoint returned; 0 when it could not be reached at all. + status_code: int | None = Field(None, alias="StatusCode") + message: str | None = Field(None, alias="Message") + + model_config = ConfigDict(populate_by_name=True) + + +class WebhookVerificationResult(BaseModel): + """Response from ``POST /webhooks/{id}/verify``.""" + + id: int = Field(alias="Id") + url: str | None = Field(None, alias="Url") + # True only if every enabled trigger's test event got HTTP 200. + success: bool = Field(alias="Success") + results: list[WebhookTriggerVerification] = Field( + default_factory=list, alias="Results" + ) + message: str | None = Field(None, alias="Message") + + model_config = ConfigDict(populate_by_name=True) diff --git a/tests/test_webhooks.py b/tests/test_webhooks.py index 0b4e11e..ad1d9d8 100644 --- a/tests/test_webhooks.py +++ b/tests/test_webhooks.py @@ -611,3 +611,164 @@ async def test_unknown_trigger_and_status_values_do_not_break_parsing( assert stats.statuses == {"BrandNew": "pending"} assert "BrandNew" in stats.metrics_by_trigger + + +# --------------------------------------------------------------------------- +# Webhook verification (responses captured from the live API) +# --------------------------------------------------------------------------- + +VERIFY_OK = { + "Id": 101, + "Url": "https://example.com/hook", + "Success": True, + "Results": [ + { + "TriggerType": "Open", + "Success": True, + "StatusCode": 200, + "Message": "Success!", + }, + { + "TriggerType": "Delivery", + "Success": True, + "StatusCode": 200, + "Message": "Success!", + }, + ], + "Message": "All 2 triggers verified successfully", +} + +VERIFY_REJECTED = { + "Id": 102, + "Url": "https://example.com/hook", + "Success": False, + "Results": [ + { + "TriggerType": "Delivery", + "Success": False, + "StatusCode": 401, + "Message": "ok", + }, + { + "TriggerType": "Bounce", + "Success": False, + "StatusCode": 401, + "Message": "ok", + }, + ], + "Message": "0/2 triggers verified successfully", +} + +VERIFY_UNREACHABLE = { + "Id": 103, + "Url": "https://no-such-host.invalid/hook", + "Success": False, + "Results": [ + { + "TriggerType": "Delivery", + "Success": False, + "StatusCode": 0, + "Message": "The request failed due to a SocketException, which means " + "the webhook url could not be reached.", + } + ], + "Message": "0/1 triggers verified successfully", +} + +VERIFY_NO_TRIGGERS = { + "Id": 104, + "Url": "https://example.com/hook", + "Success": False, + "Results": [], + "Message": "No triggers configured for this webhook", +} + + +class TestVerifyWebhook: + @pytest.mark.asyncio + async def test_posts_to_the_verify_endpoint_without_a_body(self, webhooks): + manager, fake = webhooks + fake.mock_post_response(VERIFY_OK) + + await manager.verify(101) + + fake.post.assert_called_once_with("/webhooks/101/verify") + + @pytest.mark.asyncio + async def test_successful_verification(self, webhooks): + manager, fake = webhooks + fake.mock_post_response(VERIFY_OK) + + result = await manager.verify(101) + + assert result.success is True + assert result.id == 101 + assert result.url == "https://example.com/hook" + assert result.message == "All 2 triggers verified successfully" + assert [(r.trigger_type, r.success, r.status_code) for r in result.results] == [ + ("Open", True, 200), + ("Delivery", True, 200), + ] + assert result.results[0].message == "Success!" + + @pytest.mark.asyncio + async def test_rejecting_endpoint_is_returned_not_raised(self, webhooks): + """Postmark answers HTTP 200 with Success=false; the caller inspects it.""" + manager, fake = webhooks + fake.mock_post_response(VERIFY_REJECTED) + + result = await manager.verify(102) + + assert result.success is False + assert all(r.status_code == 401 for r in result.results) + assert result.message == "0/2 triggers verified successfully" + + @pytest.mark.asyncio + async def test_unreachable_endpoint_reports_status_code_zero(self, webhooks): + manager, fake = webhooks + fake.mock_post_response(VERIFY_UNREACHABLE) + + result = await manager.verify(103) + + assert result.success is False + assert result.results[0].status_code == 0 + assert "could not be reached" in result.results[0].message + + @pytest.mark.asyncio + async def test_no_triggers_enabled(self, webhooks): + manager, fake = webhooks + fake.mock_post_response(VERIFY_NO_TRIGGERS) + + result = await manager.verify(104) + + assert result.success is False + assert result.results == [] + assert result.message == "No triggers configured for this webhook" + + @pytest.mark.asyncio + async def test_minimal_response(self, webhooks): + manager, fake = webhooks + fake.mock_post_response({"Id": 7, "Success": True}) + + result = await manager.verify(7) + + assert result.success is True + assert result.results == [] + assert result.url is None + assert result.message is None + + @pytest.mark.asyncio + async def test_unknown_trigger_type_does_not_break_parsing(self, webhooks): + manager, fake = webhooks + fake.mock_post_response( + { + "Id": 7, + "Success": True, + "Results": [{"TriggerType": "BrandNew", "Success": True}], + } + ) + + result = await manager.verify(7) + + assert result.results[0].trigger_type == "BrandNew" + assert result.results[0].status_code is None From 5cd3eee9f715ad372c5f6c50366e007deb8eb88b Mon Sep 17 00:00:00 2001 From: Greg Svoboda <13701780+gregsvo@users.noreply.github.com> Date: Thu, 29 Oct 2026 16:53:59 -0400 Subject: [PATCH 12/16] feat(templates): add a layout_template filter to list Add the layoutTemplate query parameter, which restricts the list to templates that use the Layout template with a given alias. It is the last parameter, so existing positional calls are unaffected. --- .secrets.baseline | 4 +- CHANGELOG.md | 1 + postmark/models/templates/manager.py | 14 ++++++- tests/test_templates.py | 56 ++++++++++++++++++++++++++++ 4 files changed, 72 insertions(+), 3 deletions(-) diff --git a/.secrets.baseline b/.secrets.baseline index ec91c5d..71a47fd 100644 --- a/.secrets.baseline +++ b/.secrets.baseline @@ -133,7 +133,7 @@ "filename": "postmark/models/webhooks/manager.py", "hashed_secret": "9d4e1e23bd5b727046a9e3b4b7db57bd8d6ee684", "is_verified": false, - "line_number": 47 + "line_number": 48 } ], "tests/test_webhooks.py": [ @@ -146,5 +146,5 @@ } ] }, - "generated_at": "2026-10-29T20:53:33Z" + "generated_at": "2026-10-29T20:40:13Z" } diff --git a/CHANGELOG.md b/CHANGELOG.md index fd7edbe..6809a96 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/). - `outbound.list_bulk_requests(count, pagination_key)` for `GET /email/bulk`, returning the new `CursorPage` (`items`, `next_key`; `next_key` is `None` on the last page), and `outbound.stream_bulk_requests()` which follows the cursor automatically. `count` must be 1-500 (Postmark answers 0 with an HTTP 500). - `webhooks.get_statistics(webhook_id)` for `GET /webhooks/{id}/statistics` (24-hour window, overall and per-trigger metrics, per-trigger verification status), with `WebhookStatistics`, `WebhookMetrics` and `WebhookTimeRange` models. - `webhooks.verify(webhook_id)` for `POST /webhooks/{id}/verify`. A failed verification is returned with `success=False` and per-trigger details (status code, message), not raised. Each call sends real test requests to the endpoint, and the client's automatic retry on timeouts would resend them, so use a generous `timeout` for slow endpoints. +- `templates.list(layout_template=...)` filters to templates that use the Layout template with the given alias (`layoutTemplate`). ### Fixed diff --git a/postmark/models/templates/manager.py b/postmark/models/templates/manager.py index 95de104..95bb46b 100644 --- a/postmark/models/templates/manager.py +++ b/postmark/models/templates/manager.py @@ -70,8 +70,18 @@ async def list( count: int = 100, offset: int = 0, template_type: TemplateTypeFilter | None = None, + layout_template: str | None = None, ) -> Page[TemplateSummary]: - """List templates with optional type filter.""" + """ + List templates with optional filters. + + Args: + count: Templates per page (max 500). + offset: Records to skip (count + offset <= 10,000). + template_type: Only templates of this type. + layout_template: Only templates that use the Layout template with this + **alias**. An unknown alias is an API error (422). + """ if count > 500: raise ValueError("count cannot exceed 500 templates per request") if count + offset > 10000: @@ -80,6 +90,8 @@ async def list( params: dict[str, Any] = {"count": count, "offset": offset} if template_type is not None: params["templateType"] = template_type.value + if layout_template is not None: + params["layoutTemplate"] = layout_template response = await self.client.get("/templates", params=params) data = TemplateListResponse(**response.json()) diff --git a/tests/test_templates.py b/tests/test_templates.py index 5c48347..82f1cc6 100644 --- a/tests/test_templates.py +++ b/tests/test_templates.py @@ -267,6 +267,62 @@ async def test_with_layout_filter(self, templates): assert params["offset"] == 10 assert params["templateType"] == "Layout" + @pytest.mark.asyncio + async def test_layout_template_filter_is_sent_as_layouttemplate(self, templates): + """Filters to templates that use the Layout with this alias (verified live).""" + manager, fake = templates + fake.mock_get_response({"TotalCount": 0, "Templates": []}) + + await manager.list(layout_template="my-layout") + + fake.get.assert_called_once_with( + "/templates", + params={"count": 100, "offset": 0, "layoutTemplate": "my-layout"}, + ) + + @pytest.mark.asyncio + async def test_layout_template_combines_with_template_type(self, templates): + manager, fake = templates + fake.mock_get_response({"TotalCount": 0, "Templates": []}) + + await manager.list( + template_type=TemplateTypeFilter.STANDARD, layout_template="my-layout" + ) + + params = fake.get.call_args[1]["params"] + assert params["templateType"] == "Standard" + assert params["layoutTemplate"] == "my-layout" + + @pytest.mark.asyncio + async def test_layout_template_not_sent_when_omitted(self, templates): + manager, fake = templates + fake.mock_get_response({"TotalCount": 0, "Templates": []}) + + await manager.list(template_type=TemplateTypeFilter.STANDARD) + + assert "layoutTemplate" not in fake.get.call_args[1]["params"] + + @pytest.mark.asyncio + async def test_layout_template_is_the_last_positional_parameter(self, templates): + """Added at the end so existing positional calls keep their meaning.""" + manager, fake = templates + fake.mock_get_response({"TotalCount": 0, "Templates": []}) + + await manager.list(10, 5, TemplateTypeFilter.STANDARD) + + params = fake.get.call_args[1]["params"] + assert params == {"count": 10, "offset": 5, "templateType": "Standard"} + + @pytest.mark.asyncio + async def test_all_type_is_still_sent(self, templates): + """The live API accepts templateType=All, so the existing member keeps working.""" + manager, fake = templates + fake.mock_get_response({"TotalCount": 0, "Templates": []}) + + await manager.list(template_type=TemplateTypeFilter.ALL) + + assert fake.get.call_args[1]["params"]["templateType"] == "All" + @pytest.mark.asyncio async def test_count_too_large_raises(self, templates): manager, fake = templates From 11d907aeff774713f616c143c9c90cb82b2861c1 Mon Sep 17 00:00:00 2001 From: Greg Svoboda <13701780+gregsvo@users.noreply.github.com> Date: Thu, 29 Oct 2026 16:54:27 -0400 Subject: [PATCH 13/16] feat(webhooks): add verify=False to create and edit By default Postmark verifies the endpoint before saving a webhook and refuses to save it if the test events fail. The way to skip that is the query parameter ?verify=false; a Verify field in the body is silently ignored. Add verify=True to create() and edit(), and send ?verify=false when it is False. The webhook is then saved with its triggers unverified and receives no events until verify() succeeds. --- CHANGELOG.md | 1 + postmark/models/webhooks/manager.py | 14 ++++- tests/test_webhooks.py | 95 +++++++++++++++++++++++++++++ 3 files changed, 108 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6809a96..8e27dd9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/). - `webhooks.get_statistics(webhook_id)` for `GET /webhooks/{id}/statistics` (24-hour window, overall and per-trigger metrics, per-trigger verification status), with `WebhookStatistics`, `WebhookMetrics` and `WebhookTimeRange` models. - `webhooks.verify(webhook_id)` for `POST /webhooks/{id}/verify`. A failed verification is returned with `success=False` and per-trigger details (status code, message), not raised. Each call sends real test requests to the endpoint, and the client's automatic retry on timeouts would resend them, so use a generous `timeout` for slow endpoints. - `templates.list(layout_template=...)` filters to templates that use the Layout template with the given alias (`layoutTemplate`). +- `webhooks.create(..., verify=False)` and `webhooks.edit(..., verify=False)` save a webhook without first sending test events (sent as the `?verify=false` query parameter; a body field is ignored by the API). Its triggers then report status `unverified` and receive no events until `webhooks.verify()` succeeds. ### Fixed diff --git a/postmark/models/webhooks/manager.py b/postmark/models/webhooks/manager.py index f98bfeb..85256b4 100644 --- a/postmark/models/webhooks/manager.py +++ b/postmark/models/webhooks/manager.py @@ -36,6 +36,7 @@ async def create( http_auth: dict | None = None, http_headers: builtins.list[dict] | None = None, triggers: dict | None = None, + verify: bool = True, ) -> Webhook: """ Create a webhook. @@ -49,6 +50,10 @@ async def create( ``[{"Name": "X-Custom", "Value": "val"}]``. triggers: Trigger configuration using Postmark's PascalCase keys, e.g. ``{"Open": {"Enabled": True, "PostFirstOpenOnly": False}}``. + verify: By default Postmark sends a test event for every enabled trigger + and refuses to save the webhook if they fail. ``False`` saves it + anyway, with each trigger's status ``"unverified"``; use + :meth:`verify` to verify it later. """ body: dict = {"Url": url} @@ -61,7 +66,8 @@ async def create( if triggers is not None: body["Triggers"] = triggers - response = await self.client.post("/webhooks", json=body) + endpoint = "/webhooks" if verify else "/webhooks?verify=false" + response = await self.client.post(endpoint, json=body) return Webhook(**response.json()) async def edit( @@ -71,6 +77,7 @@ async def edit( http_auth: dict | None = None, http_headers: builtins.list[dict] | None = None, triggers: dict | None = None, + verify: bool = True, ) -> Webhook: """ Update a webhook. @@ -91,6 +98,8 @@ async def edit( triggers: Trigger configuration. Merged field by field into the existing triggers, so ``{"Bounce": {"Enabled": False}}`` leaves every other trigger and every other Bounce setting as it is. + verify: ``False`` saves the changes without first sending test events + (see :meth:`create`). Triggers then report ``"unverified"``. """ current = (await self.client.get(f"/webhooks/{webhook_id}")).json() @@ -116,7 +125,8 @@ async def edit( if merged: body["Triggers"] = merged - response = await self.client.put(f"/webhooks/{webhook_id}", json=body) + suffix = "" if verify else "?verify=false" + response = await self.client.put(f"/webhooks/{webhook_id}{suffix}", json=body) return Webhook(**response.json()) async def get_statistics(self, webhook_id: int) -> WebhookStatistics: diff --git a/tests/test_webhooks.py b/tests/test_webhooks.py index ad1d9d8..6360804 100644 --- a/tests/test_webhooks.py +++ b/tests/test_webhooks.py @@ -772,3 +772,98 @@ async def test_unknown_trigger_type_does_not_break_parsing(self, webhooks): assert result.results[0].trigger_type == "BrandNew" assert result.results[0].status_code is None + + +# --------------------------------------------------------------------------- +# verify=False: Postmark takes it as the ?verify=false QUERY parameter. A body +# field named "Verify" is silently ignored by the API (verified live). +# --------------------------------------------------------------------------- + + +class TestSkipVerification: + @pytest.mark.asyncio + async def test_create_verifies_by_default(self, webhooks): + manager, fake = webhooks + fake.mock_post_response(WEBHOOK) + + await manager.create("https://example.com/hook") + + assert fake.post.call_args[0][0] == "/webhooks" + + @pytest.mark.asyncio + async def test_create_explicit_true_is_the_same_as_the_default(self, webhooks): + manager, fake = webhooks + fake.mock_post_response(WEBHOOK) + + await manager.create("https://example.com/hook", verify=True) + + assert fake.post.call_args[0][0] == "/webhooks" + + @pytest.mark.asyncio + async def test_create_without_verification_uses_the_query_parameter(self, webhooks): + manager, fake = webhooks + fake.mock_post_response(WEBHOOK) + + await manager.create("https://example.com/hook", verify=False) + + assert fake.post.call_args[0][0] == "/webhooks?verify=false" + + @pytest.mark.asyncio + async def test_create_never_puts_verify_in_the_body(self, webhooks): + manager, fake = webhooks + fake.mock_post_response(WEBHOOK) + + await manager.create("https://example.com/hook", verify=False) + + body = fake.post.call_args[1]["json"] + assert "Verify" not in body + assert "verify" not in body + assert body == {"Url": "https://example.com/hook"} + + @pytest.mark.asyncio + async def test_edit_verifies_by_default(self, webhooks): + manager, fake = webhooks + fake.mock_get_response(LIVE_WEBHOOK) + fake.mock_put_response(LIVE_WEBHOOK) + + await manager.edit(1, url="https://x.com/h") + + assert fake.put.call_args[0][0] == "/webhooks/1" + + @pytest.mark.asyncio + async def test_edit_without_verification_uses_the_query_parameter(self, webhooks): + manager, fake = webhooks + fake.mock_get_response(LIVE_WEBHOOK) + fake.mock_put_response(LIVE_WEBHOOK) + + await manager.edit(1, url="https://x.com/h", verify=False) + + assert fake.put.call_args[0][0] == "/webhooks/1?verify=false" + assert "Verify" not in fake.put.call_args[1]["json"] + + @pytest.mark.asyncio + async def test_edit_reads_the_webhook_without_the_query_parameter(self, webhooks): + """Only the write is affected; the read-before-write stays a plain GET.""" + manager, fake = webhooks + fake.mock_get_response(LIVE_WEBHOOK) + fake.mock_put_response(LIVE_WEBHOOK) + + await manager.edit(5, url="https://x.com/h", verify=False) + + fake.get.assert_called_once_with("/webhooks/5") + + @pytest.mark.asyncio + async def test_unverified_trigger_status_is_parsed(self, webhooks): + manager, fake = webhooks + unverified = { + **WEBHOOK, + "Triggers": { + **TRIGGERS, + "Delivery": {"Enabled": True, "Status": "unverified"}, + }, + } + fake.mock_post_response(unverified) + + wh = await manager.create("https://example.com/hook", verify=False) + + assert wh.triggers.delivery.enabled is True From e400be9e2bb062f6cf791bc751ee74c3082556c2 Mon Sep 17 00:00:00 2001 From: Greg Svoboda <13701780+gregsvo@users.noreply.github.com> Date: Thu, 29 Oct 2026 16:54:54 -0400 Subject: [PATCH 14/16] feat(webhooks): expose per-trigger verification status Postmark reports "verified" or "unverified" on each enabled trigger, but the trigger models dropped it. Add an optional status to all six. Disabled triggers have none. --- CHANGELOG.md | 1 + postmark/models/webhooks/schemas.py | 12 +++++ tests/test_webhooks.py | 75 +++++++++++++++++++++++++++++ 3 files changed, 88 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8e27dd9..18ed7f1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/). - `webhooks.verify(webhook_id)` for `POST /webhooks/{id}/verify`. A failed verification is returned with `success=False` and per-trigger details (status code, message), not raised. Each call sends real test requests to the endpoint, and the client's automatic retry on timeouts would resend them, so use a generous `timeout` for slow endpoints. - `templates.list(layout_template=...)` filters to templates that use the Layout template with the given alias (`layoutTemplate`). - `webhooks.create(..., verify=False)` and `webhooks.edit(..., verify=False)` save a webhook without first sending test events (sent as the `?verify=false` query parameter; a body field is ignored by the API). Its triggers then report status `unverified` and receive no events until `webhooks.verify()` succeeds. +- Webhook trigger models (`OpenTrigger`, `ClickTrigger`, ...) expose `status` (`"verified"` / `"unverified"`, `None` on disabled triggers), which the API returns per enabled trigger and which was previously dropped. ### Fixed diff --git a/postmark/models/webhooks/schemas.py b/postmark/models/webhooks/schemas.py index 25234ce..df36b18 100644 --- a/postmark/models/webhooks/schemas.py +++ b/postmark/models/webhooks/schemas.py @@ -20,18 +20,24 @@ class HttpHeader(BaseModel): class OpenTrigger(BaseModel): enabled: bool = Field(alias="Enabled") post_first_open_only: bool = Field(alias="PostFirstOpenOnly") + # "verified" or "unverified"; only present on enabled triggers. + status: str | None = Field(None, alias="Status") model_config = ConfigDict(populate_by_name=True) class ClickTrigger(BaseModel): enabled: bool = Field(alias="Enabled") + # "verified" or "unverified"; only present on enabled triggers. + status: str | None = Field(None, alias="Status") model_config = ConfigDict(populate_by_name=True) class DeliveryTrigger(BaseModel): enabled: bool = Field(alias="Enabled") + # "verified" or "unverified"; only present on enabled triggers. + status: str | None = Field(None, alias="Status") model_config = ConfigDict(populate_by_name=True) @@ -39,6 +45,8 @@ class DeliveryTrigger(BaseModel): class BounceTrigger(BaseModel): enabled: bool = Field(alias="Enabled") include_content: bool = Field(alias="IncludeContent") + # "verified" or "unverified"; only present on enabled triggers. + status: str | None = Field(None, alias="Status") model_config = ConfigDict(populate_by_name=True) @@ -46,12 +54,16 @@ class BounceTrigger(BaseModel): class SpamComplaintTrigger(BaseModel): enabled: bool = Field(alias="Enabled") include_content: bool = Field(alias="IncludeContent") + # "verified" or "unverified"; only present on enabled triggers. + status: str | None = Field(None, alias="Status") model_config = ConfigDict(populate_by_name=True) class SubscriptionChangeTrigger(BaseModel): enabled: bool = Field(alias="Enabled") + # "verified" or "unverified"; only present on enabled triggers. + status: str | None = Field(None, alias="Status") model_config = ConfigDict(populate_by_name=True) diff --git a/tests/test_webhooks.py b/tests/test_webhooks.py index 6360804..88459d2 100644 --- a/tests/test_webhooks.py +++ b/tests/test_webhooks.py @@ -867,3 +867,78 @@ async def test_unverified_trigger_status_is_parsed(self, webhooks): wh = await manager.create("https://example.com/hook", verify=False) assert wh.triggers.delivery.enabled is True + + +# --------------------------------------------------------------------------- +# Per-trigger verification status (live responses carry it on enabled triggers) +# --------------------------------------------------------------------------- + +_STATUS_TRIGGERS = { + "Open": {"Enabled": True, "PostFirstOpenOnly": False, "Status": "verified"}, + "Click": {"Enabled": True, "Status": "unverified"}, + "Delivery": {"Enabled": True, "Status": "verified"}, + "Bounce": {"Enabled": True, "IncludeContent": False, "Status": "unverified"}, + "SpamComplaint": {"Enabled": False, "IncludeContent": False}, + "SubscriptionChange": {"Enabled": True, "Status": "verified"}, +} + + +class TestTriggerStatus: + @pytest.mark.asyncio + async def test_status_is_exposed_on_every_trigger_type(self, webhooks): + manager, fake = webhooks + fake.mock_get_response(_make_webhook(Triggers=_STATUS_TRIGGERS)) + + t = (await manager.get(1)).triggers + + assert t.open.status == "verified" + assert t.click.status == "unverified" + assert t.delivery.status == "verified" + assert t.bounce.status == "unverified" + assert t.subscription_change.status == "verified" + + @pytest.mark.asyncio + async def test_disabled_trigger_has_no_status(self, webhooks): + manager, fake = webhooks + fake.mock_get_response(_make_webhook(Triggers=_STATUS_TRIGGERS)) + + t = (await manager.get(1)).triggers + + assert t.spam_complaint.enabled is False + assert t.spam_complaint.status is None + + @pytest.mark.asyncio + async def test_status_comes_through_list_and_create(self, webhooks): + manager, fake = webhooks + wh = _make_webhook(Triggers=_STATUS_TRIGGERS) + fake.mock_get_response({"Webhooks": [wh]}) + fake.mock_post_response(wh) + + listed = await manager.list() + created = await manager.create("https://example.com/hook", verify=False) + + assert listed[0].triggers.delivery.status == "verified" + assert created.triggers.click.status == "unverified" + + @pytest.mark.asyncio + async def test_responses_without_status_still_parse(self, webhooks): + """Webhooks created before verification existed carry no Status at all.""" + manager, fake = webhooks + fake.mock_get_response(WEBHOOK) + + t = (await manager.get(1)).triggers + + assert t.open.enabled is True + assert t.open.status is None + assert t.bounce.status is None + + @pytest.mark.asyncio + async def test_unknown_status_value_does_not_break_parsing(self, webhooks): + manager, fake = webhooks + triggers = { + **_STATUS_TRIGGERS, + "Delivery": {"Enabled": True, "Status": "pending"}, + } + fake.mock_get_response(_make_webhook(Triggers=triggers)) + + assert (await manager.get(1)).triggers.delivery.status == "pending" From 68f9bb9bf5242602e8bfddfd37764a9c00d145dc Mon Sep 17 00:00:00 2001 From: Greg Svoboda <13701780+gregsvo@users.noreply.github.com> Date: Thu, 29 Oct 2026 16:55:21 -0400 Subject: [PATCH 15/16] test: cover previously untested code Add tests for the remaining untested code paths: inbound and server edit filters, the suppression batch limit, exception fallbacks by HTTP status, error-code parsing, pagination, PATCH and DELETE delegation on both clients, the retry-loop guard, closing the sync clients, the Django backend's no-op paths and the package version fallback. --- tests/django_backend/test_backend.py | 15 +++++ tests/test_account_client.py | 32 ++++++++++ tests/test_exceptions.py | 37 ++++++++++- tests/test_inbound.py | 34 ++++++++++ tests/test_package_metadata.py | 24 ++++++++ tests/test_pagination.py | 92 ++++++++++++++++++++++++++++ tests/test_retrieve_messages.py | 52 ++++++++++++++++ tests/test_send_messages.py | 12 ++++ tests/test_server_client.py | 39 ++++++++++++ tests/test_server_utils.py | 23 +++++++ tests/test_servers.py | 59 ++++++++++++++++++ tests/test_suppressions.py | 26 ++++++++ tests/test_sync_client.py | 22 +++++++ tests/test_templates.py | 38 ++++++++++++ 14 files changed, 504 insertions(+), 1 deletion(-) create mode 100644 tests/test_package_metadata.py create mode 100644 tests/test_pagination.py diff --git a/tests/django_backend/test_backend.py b/tests/django_backend/test_backend.py index b716e30..63b006e 100644 --- a/tests/django_backend/test_backend.py +++ b/tests/django_backend/test_backend.py @@ -516,3 +516,18 @@ def fail_close(): with pytest.raises(OSError, match="close failed"): EmailBackend().send_messages([message]) + + +def test_close_without_open_is_a_noop(sync_client_factory): + backend = EmailBackend() + + backend.close() # nothing was opened; must not raise + + assert backend.client is None + + +def test_send_messages_with_nothing_to_send_returns_zero( + sync_client_factory, fake_sync_client +): + assert EmailBackend().send_messages([]) == 0 + assert fake_sync_client.outbound.calls == [] diff --git a/tests/test_account_client.py b/tests/test_account_client.py index 94854db..2f4a9c1 100644 --- a/tests/test_account_client.py +++ b/tests/test_account_client.py @@ -335,3 +335,35 @@ async def test_retries_disabled_with_zero(self): await client.request("GET", "/servers") assert mock_req.call_count == 1 + + +class TestAccountClientRemainingVerbs: + @pytest.fixture + def client(self): + return AccountClient(account_token="test-account-token", retries=0) + + @pytest.mark.asyncio + async def test_patch_delegates_to_request(self, client): + with patch.object(client, "request", new_callable=AsyncMock) as mock_req: + mock_req.return_value = Mock(spec=Response) + await client.patch("/servers/42", json={"Name": "Renamed"}) + mock_req.assert_called_once_with( + "PATCH", "/servers/42", json={"Name": "Renamed"} + ) + + @pytest.mark.asyncio + async def test_request_guard_when_the_retry_loop_yields_no_attempt(self, client): + """Defensive: if the retry loop never runs, fail loudly rather than return None.""" + + class NoAttempts: + def __aiter__(self): + return self + + async def __anext__(self): + raise StopAsyncIteration + + with patch( + "postmark.clients.account_client.AsyncRetrying", return_value=NoAttempts() + ): + with pytest.raises(AssertionError, match="unreachable"): + await client.request("GET", "/x") diff --git a/tests/test_exceptions.py b/tests/test_exceptions.py index d759830..193153c 100644 --- a/tests/test_exceptions.py +++ b/tests/test_exceptions.py @@ -2,7 +2,14 @@ import pytest -from postmark.exceptions import InactiveRecipientException +from postmark.exceptions import ( + InactiveRecipientException, + InvalidAPIKeyException, + PostmarkAPIException, + RateLimitException, + ServerException, + ValidationException, +) @pytest.mark.parametrize( @@ -38,3 +45,31 @@ def test_inactive_recipient_exception_parses_inactive_recipients( exc = InactiveRecipientException(message, error_code=406, http_status=406) assert exc.inactive_recipients == expected + + +class TestGetExceptionClassHttpFallbacks: + """With no (or an unknown) Postmark error code, the HTTP status decides.""" + + @pytest.mark.parametrize( + "http_status,expected", + [ + (401, InvalidAPIKeyException), + (422, ValidationException), + (429, RateLimitException), + (500, ServerException), + (503, ServerException), + (404, PostmarkAPIException), + (418, PostmarkAPIException), + ], + ) + @pytest.mark.parametrize("error_code", [0, 99999]) + def test_status_fallback(self, http_status, expected, error_code): + from postmark.exceptions import get_exception_class + + assert get_exception_class(error_code, http_status) is expected + + def test_known_error_code_wins_over_http_status(self): + from postmark.exceptions import get_exception_class + + # 406 is an inactive-recipient error even when the HTTP status says 422 + assert get_exception_class(406, 422) is not ValidationException diff --git a/tests/test_inbound.py b/tests/test_inbound.py index b447d66..667591e 100644 --- a/tests/test_inbound.py +++ b/tests/test_inbound.py @@ -262,3 +262,37 @@ async def test_retry_does_not_call_other_methods(self, inbound): fake.get.assert_not_called() fake.post.assert_not_called() + + +class TestListInboundFilters: + @pytest.mark.asyncio + async def test_every_filter_uses_its_wire_name(self, inbound): + from datetime import datetime + + manager, fake = inbound + fake.mock_get_response({"TotalCount": 0, "InboundMessages": []}) + + await manager.list( + recipient="inbox@example.com", + from_email="sender@example.com", + tag="support", + subject="Hello", + mailbox_hash="abc123", + status="blocked", + from_date=datetime(2024, 1, 1, 10, 0, 0), + to_date=datetime(2024, 1, 31, 23, 59, 59), + ) + + assert fake.get.call_args[0][0] == "/messages/inbound" + assert fake.get.call_args[1]["params"] == { + "count": 100, + "offset": 0, + "recipient": "inbox@example.com", + "fromemail": "sender@example.com", + "tag": "support", + "subject": "Hello", + "mailboxhash": "abc123", + "status": "blocked", + "fromdate": "2024-01-01T10:00:00", + "todate": "2024-01-31T23:59:59", + } diff --git a/tests/test_package_metadata.py b/tests/test_package_metadata.py new file mode 100644 index 0000000..669cbfe --- /dev/null +++ b/tests/test_package_metadata.py @@ -0,0 +1,24 @@ +"""Tests for postmark/__init__.py version handling.""" + +import importlib +from importlib.metadata import PackageNotFoundError +from unittest.mock import patch + +import postmark + + +def test_version_comes_from_the_installed_metadata(): + assert postmark.__version__ != "" + assert postmark.__version__ == importlib.metadata.version("postmark-python") + + +def test_version_falls_back_when_running_without_package_metadata(): + """Running from a bare checkout (no dist-info) must still import.""" + try: + with patch("importlib.metadata.version", side_effect=PackageNotFoundError): + importlib.reload(postmark) + assert postmark.__version__ == "0.0.0" + finally: + importlib.reload(postmark) # restore the real value for later tests + + assert postmark.__version__ != "0.0.0" diff --git a/tests/test_pagination.py b/tests/test_pagination.py new file mode 100644 index 0000000..64b63a8 --- /dev/null +++ b/tests/test_pagination.py @@ -0,0 +1,92 @@ +"""Tests for postmark.utils.pagination.paginate.""" + +import pytest + +from postmark.models.page import Page +from postmark.utils.pagination import paginate + + +def _list_fn(total, calls): + """A fake list endpoint over `total` integer records, recording its calls.""" + + async def list_fn(count, offset, **filters): + calls.append({"count": count, "offset": offset, **filters}) + items = list(range(offset, min(offset + count, total))) + return Page(items=items, total=total) + + return list_fn + + +class TestPaginate: + @pytest.mark.asyncio + async def test_yields_everything_across_pages(self): + calls = [] + + got = [x async for x in paginate(_list_fn(5, calls), 100, 2)] + + assert got == [0, 1, 2, 3, 4] + assert [c["offset"] for c in calls] == [0, 2, 4] + + @pytest.mark.asyncio + async def test_stops_at_max_records(self): + calls = [] + + got = [x async for x in paginate(_list_fn(100, calls), 3, 2)] + + assert got == [0, 1, 2] + # the last request only asks for what is still needed + assert calls[-1]["count"] == 1 + + @pytest.mark.asyncio + async def test_batch_size_is_capped_at_500(self): + calls = [] + + _ = [x async for x in paginate(_list_fn(10, calls), 10, 9999)] + + assert calls[0]["count"] == 10 # min(500, remaining) + big = [] + _ = [x async for x in paginate(_list_fn(2000, big), 1000, 9999)] + assert big[0]["count"] == 500 + + @pytest.mark.asyncio + async def test_empty_page_stops_the_loop(self): + calls = [] + + got = [x async for x in paginate(_list_fn(0, calls), 100, 10)] + + assert got == [] + assert len(calls) == 1 + + @pytest.mark.asyncio + async def test_page_that_comes_back_empty_early_stops_the_loop(self): + """The reported total can overstate; an empty page must end the stream.""" + responses = [Page(items=[1, 2], total=10), Page(items=[], total=10)] + + async def list_fn(count, offset, **filters): + return responses.pop(0) + + got = [x async for x in paginate(list_fn, 10, 2)] + + assert got == [1, 2] + + @pytest.mark.asyncio + async def test_stops_once_offset_reaches_the_reported_total(self): + calls = [] + + got = [x async for x in paginate(_list_fn(4, calls), 100, 4)] + + assert got == [0, 1, 2, 3] + assert len(calls) == 1 + + @pytest.mark.asyncio + async def test_filters_are_forwarded(self): + calls = [] + + _ = [x async for x in paginate(_list_fn(1, calls), 10, 5, tag="promo")] + + assert calls[0]["tag"] == "promo" + + @pytest.mark.asyncio + async def test_max_records_over_10000_is_rejected(self): + with pytest.raises(ValueError, match="cannot exceed 10000"): + _ = [x async for x in paginate(_list_fn(1, []), 10_001, 100)] diff --git a/tests/test_retrieve_messages.py b/tests/test_retrieve_messages.py index 1cc274a..dd00d3a 100644 --- a/tests/test_retrieve_messages.py +++ b/tests/test_retrieve_messages.py @@ -469,6 +469,58 @@ async def test_count_plus_offset_validation( fake.get.assert_not_called() +class TestListMessageFilters: + """Remaining GET /messages/outbound filters and their wire names.""" + + @pytest.mark.asyncio + async def test_from_email_subject_and_stream_filters(self, outbound): + manager, fake = outbound + fake.mock_get_response({"TotalCount": 0, "Messages": []}) + + await manager.list( + from_email="sender@example.com", + subject="Welcome", + message_stream="broadcast", + ) + + params = fake.get.call_args[1]["params"] + assert params["fromemail"] == "sender@example.com" + assert params["subject"] == "Welcome" + assert params["messagestream"] == "broadcast" + + @pytest.mark.asyncio + async def test_single_metadata_filter(self, outbound): + manager, fake = outbound + fake.mock_get_response({"TotalCount": 0, "Messages": []}) + + await manager.list(metadata={"customer": "42"}) + + assert fake.get.call_args[1]["params"]["metadata_customer"] == "42" + + @pytest.mark.asyncio + async def test_multiple_metadata_filters_rejected(self, outbound): + manager, fake = outbound + + with pytest.raises(ValueError, match="one metadata field at a time"): + await manager.list(metadata={"a": "1", "b": "2"}) + + fake.get.assert_not_called() + + +@pytest.mark.parametrize("msg_method", ["list_message_opens", "list_message_clicks"]) +class TestPerMessageTrackingValidation: + @pytest.mark.asyncio + async def test_count_and_offset_limits(self, outbound, msg_method): + manager, fake = outbound + + with pytest.raises(ValueError, match="Count cannot exceed 500"): + await getattr(manager, msg_method)("msg-1", count=501) + with pytest.raises(ValueError, match="Count \\+ Offset cannot exceed 10,000"): + await getattr(manager, msg_method)("msg-1", count=500, offset=9501) + + fake.get.assert_not_called() + + @pytest.mark.parametrize( "list_method,response_key", [("list_opens", "Opens"), ("list_clicks", "Clicks")], diff --git a/tests/test_send_messages.py b/tests/test_send_messages.py index 759a216..e98ade2 100644 --- a/tests/test_send_messages.py +++ b/tests/test_send_messages.py @@ -376,6 +376,18 @@ async def test_failed_entry_is_returned_not_raised(self, outbound, two_messages) assert results[1].error_code == 1101 assert results[1].message_id is None + @pytest.mark.asyncio + async def test_invalid_message_includes_index(self, outbound, two_messages): + """A bad entry is reported with its index and nothing is sent.""" + manager, fake = outbound + bad = [two_messages[0], {"To": "b@example.com", "TemplateId": 1}] # no sender + + with pytest.raises(InvalidEmailException) as exc_info: + await manager.send_batch_with_template(bad) + + assert "messages[1]" in str(exc_info.value) + fake.post.assert_not_called() + @pytest.mark.asyncio async def test_wraps_in_messages_key(self, outbound, two_messages): manager, fake = outbound diff --git a/tests/test_server_client.py b/tests/test_server_client.py index 53e6c22..ca95e14 100644 --- a/tests/test_server_client.py +++ b/tests/test_server_client.py @@ -349,3 +349,42 @@ async def test_retries_disabled_with_zero(self): await client.request("GET", "/test") assert mock_req.call_count == 1 + + +class TestServerClientRemainingVerbs: + @pytest.fixture + def client(self): + return ServerClient(server_token="test-token", retries=0) + + @pytest.mark.asyncio + async def test_patch_delegates_to_request(self, client): + with patch.object(client, "request", new_callable=AsyncMock) as mock_req: + mock_req.return_value = Mock(spec=Response) + await client.patch("/message-streams/s1", json={"Name": "New"}) + mock_req.assert_called_once_with( + "PATCH", "/message-streams/s1", json={"Name": "New"} + ) + + @pytest.mark.asyncio + async def test_delete_delegates_to_request(self, client): + with patch.object(client, "request", new_callable=AsyncMock) as mock_req: + mock_req.return_value = Mock(spec=Response) + await client.delete("/webhooks/1", params={"a": "b"}) + mock_req.assert_called_once_with("DELETE", "/webhooks/1", params={"a": "b"}) + + @pytest.mark.asyncio + async def test_request_guard_when_the_retry_loop_yields_no_attempt(self, client): + """Defensive: if the retry loop never runs, fail loudly rather than return None.""" + + class NoAttempts: + def __aiter__(self): + return self + + async def __anext__(self): + raise StopAsyncIteration + + with patch( + "postmark.clients.server_client.AsyncRetrying", return_value=NoAttempts() + ): + with pytest.raises(AssertionError, match="unreachable"): + await client.request("GET", "/x") diff --git a/tests/test_server_utils.py b/tests/test_server_utils.py index d111180..777ec0e 100644 --- a/tests/test_server_utils.py +++ b/tests/test_server_utils.py @@ -91,3 +91,26 @@ def test_attribute_error_empty_text_falls_back_to_status(self): message, code = parse_error_response(response) assert message == "HTTP 500 error" assert code is None + + +class TestErrorCodeEdgeCases: + def _response(self, error_code): + response = MagicMock() + response.status_code = 422 + response.text = "" + response.json.return_value = {"Message": "Bad", "ErrorCode": error_code} + return response + + def test_blank_string_error_code_returns_none(self): + for blank in ("", " "): + _, code = parse_error_response(self._response(blank)) + assert code is None + + def test_non_integer_types_return_none(self): + for odd in (12.5, [1], {"code": 1}): + _, code = parse_error_response(self._response(odd)) + assert code is None + + def test_padded_numeric_string_is_stripped_and_parsed(self): + _, code = parse_error_response(self._response(" 406 ")) + assert code == 406 diff --git a/tests/test_servers.py b/tests/test_servers.py index 0c88e9f..907d504 100644 --- a/tests/test_servers.py +++ b/tests/test_servers.py @@ -256,3 +256,62 @@ async def test_edit_boolean_fields(self, servers): assert body["PostFirstOpenOnly"] is True assert body["IncludeBounceContentInHook"] is True assert body["EnableSmtpApiErrorHooks"] is True + + +class TestEditServerAllFields: + @pytest.mark.asyncio + async def test_every_field_is_sent_with_its_wire_name(self, servers): + manager, fake = servers + fake.mock_put_response(SERVER) + + await manager.edit( + name="Renamed", + color=ServerColor.GREEN, + raw_email_enabled=True, + smtp_api_activated=False, + inbound_hook_url="https://example.com/in", + bounce_hook_url="https://example.com/bounce", + open_hook_url="https://example.com/open", + delivery_hook_url="https://example.com/delivery", + click_hook_url="https://example.com/click", + post_first_open_only=True, + track_opens=True, + track_links=TrackLinks.HTML_AND_TEXT, + inbound_domain="inbound.example.com", + inbound_spam_threshold=10, + include_bounce_content_in_hook=True, + enable_smtp_api_error_hooks=True, + ) + + assert fake.put.call_args[0][0] == "/server" + assert fake.put.call_args[1]["json"] == { + "Name": "Renamed", + "Color": ServerColor.GREEN.value, + "RawEmailEnabled": True, + "SmtpApiActivated": False, + "InboundHookUrl": "https://example.com/in", + "BounceHookUrl": "https://example.com/bounce", + "OpenHookUrl": "https://example.com/open", + "DeliveryHookUrl": "https://example.com/delivery", + "ClickHookUrl": "https://example.com/click", + "PostFirstOpenOnly": True, + "TrackOpens": True, + "TrackLinks": TrackLinks.HTML_AND_TEXT.value, + "InboundDomain": "inbound.example.com", + "InboundSpamThreshold": 10, + "IncludeBounceContentInHook": True, + "EnableSmtpApiErrorHooks": True, + } + + @pytest.mark.asyncio + async def test_false_and_zero_are_sent_not_dropped(self, servers): + """Only None means 'leave unchanged'; False and 0 are real values.""" + manager, fake = servers + fake.mock_put_response(SERVER) + + await manager.edit(track_opens=False, inbound_spam_threshold=0) + + assert fake.put.call_args[1]["json"] == { + "TrackOpens": False, + "InboundSpamThreshold": 0, + } diff --git a/tests/test_suppressions.py b/tests/test_suppressions.py index 529b29e..f917efe 100644 --- a/tests/test_suppressions.py +++ b/tests/test_suppressions.py @@ -257,3 +257,29 @@ async def test_delete_different_stream(self, suppressions): args, kwargs = fake.post.call_args assert args[0] == "/message-streams/broadcasts/suppressions/delete" + + +class TestSuppressionBatchLimit: + @pytest.mark.asyncio + @pytest.mark.parametrize("method", ["create", "delete"]) + async def test_more_than_50_addresses_is_rejected_before_any_request( + self, suppressions, method + ): + manager, fake = suppressions + addresses = [f"user{i}@example.com" for i in range(51)] + + with pytest.raises(ValueError, match="cannot exceed 50"): + await getattr(manager, method)("outbound", addresses) + + fake.post.assert_not_called() + + @pytest.mark.asyncio + @pytest.mark.parametrize("method", ["create", "delete"]) + async def test_exactly_50_addresses_is_accepted(self, suppressions, method): + manager, fake = suppressions + fake.mock_post_response({"Suppressions": []}) + addresses = [f"user{i}@example.com" for i in range(50)] + + await getattr(manager, method)("outbound", addresses) + + assert len(fake.post.call_args[1]["json"]["Suppressions"]) == 50 diff --git a/tests/test_sync_client.py b/tests/test_sync_client.py index 5100b0e..8852c57 100644 --- a/tests/test_sync_client.py +++ b/tests/test_sync_client.py @@ -306,3 +306,25 @@ async def echo(value): assert result == "child" assert int(stored_pid_str) == int(child_pid_str) assert int(child_pid_str) == pid + + +class TestSyncClientClose: + def test_server_client_close_closes_the_async_client(self): + client = SyncServerClient("test-token") + + client.close() + + assert client._async._http_client.is_closed + + def test_account_client_close_closes_the_async_client(self): + client = SyncAccountClient("test-token") + + client.close() + + assert client._async._http_client.is_closed + + def test_closing_twice_does_not_raise(self): + client = SyncServerClient("test-token") + + client.close() + client.close() diff --git a/tests/test_templates.py b/tests/test_templates.py index 82f1cc6..c049390 100644 --- a/tests/test_templates.py +++ b/tests/test_templates.py @@ -553,3 +553,41 @@ async def test_push_missing_server_id_still_rejected(self, account_templates): with pytest.raises(InvalidEmailException): await manager.push({"DestinationServerID": 2, "PerformChanges": False}) fake.put.assert_not_called() + + +class TestInvalidTemplateRequests: + """Bad input is rejected locally with InvalidEmailException; nothing is sent.""" + + @pytest.mark.asyncio + async def test_create_without_a_name_is_rejected(self, templates): + from postmark.exceptions import InvalidEmailException + + manager, fake = templates + + with pytest.raises(InvalidEmailException) as exc_info: + await manager.create({"subject": "no name"}) + + assert "Name" in str(exc_info.value) + fake.post.assert_not_called() + + @pytest.mark.asyncio + async def test_edit_with_a_wrongly_typed_field_is_rejected(self, templates): + from postmark.exceptions import InvalidEmailException + + manager, fake = templates + + with pytest.raises(InvalidEmailException): + await manager.edit("welcome", {"name": ["not", "a", "string"]}) + + fake.put.assert_not_called() + + @pytest.mark.asyncio + async def test_validate_with_a_non_dict_model_is_rejected(self, templates): + from postmark.exceptions import InvalidEmailException + + manager, fake = templates + + with pytest.raises(InvalidEmailException): + await manager.validate({"test_render_model": "not-a-dict"}) + + fake.post.assert_not_called() From a8483ec1bbc5e4cc43fdc1f6da26a9194738b9ca Mon Sep 17 00:00:00 2001 From: Greg Svoboda <13701780+gregsvo@users.noreply.github.com> Date: Thu, 29 Oct 2026 16:55:47 -0400 Subject: [PATCH 16/16] refactor(pagination): remove an unreachable offset guard paginate() yields every item it fetches and then advances the offset by that many, so the offset always equals the number of items yielded, and max_records is capped at 10,000. offset + current_limit can therefore never pass the 10,000 limit and the guard could never run. Remove it and add a test that the final request still ends at exactly 10,000. --- CHANGELOG.md | 1 + postmark/utils/pagination.py | 7 ++----- tests/test_pagination.py | 13 +++++++++++++ 3 files changed, 16 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 18ed7f1..585586f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -36,6 +36,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/). - **Typing only:** `SendResponse.to`, `.submitted_at` and `.message_id` are now `X | None` (they are `None` for failed batch entries; successful sends still populate them). Type checkers may flag code that assumes they are always set. - **Typing only:** `BounceTypeCount.type` and `.type_code` are now `X | None`. - **Typing only:** `PushTemplatesRequest.source_server_id` / `.destination_server_id` are now `int | str`. +- Internal: removed an unreachable offset guard in `utils.pagination.paginate` (no behaviour change). --- diff --git a/postmark/utils/pagination.py b/postmark/utils/pagination.py index 05ee390..10a8317 100644 --- a/postmark/utils/pagination.py +++ b/postmark/utils/pagination.py @@ -32,13 +32,10 @@ async def paginate( while yielded < max_records: remaining = max_records - yielded + # offset always equals yielded, and max_records <= _MAX_OFFSET, so + # offset + current_limit can never pass Postmark's 10,000 offset limit. current_limit = min(batch_size, remaining) - if offset + current_limit > _MAX_OFFSET: - current_limit = _MAX_OFFSET - offset - if current_limit <= 0: - break - page = await list_fn(count=current_limit, offset=offset, **filters) if not page.items: break diff --git a/tests/test_pagination.py b/tests/test_pagination.py index 64b63a8..3a3f804 100644 --- a/tests/test_pagination.py +++ b/tests/test_pagination.py @@ -90,3 +90,16 @@ async def test_filters_are_forwarded(self): async def test_max_records_over_10000_is_rejected(self): with pytest.raises(ValueError, match="cannot exceed 10000"): _ = [x async for x in paginate(_list_fn(1, []), 10_001, 100)] + + +class TestOffsetLimit: + @pytest.mark.asyncio + async def test_never_requests_past_postmarks_10000_offset(self): + """With the maximum allowed max_records, the last request still ends at 10,000.""" + calls = [] + + got = [x async for x in paginate(_list_fn(20_000, calls), 10_000, 500)] + + assert len(got) == 10_000 + assert all(c["offset"] + c["count"] <= 10_000 for c in calls) + assert calls[-1]["offset"] + calls[-1]["count"] == 10_000