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 f89d479..585586f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,9 +9,34 @@ 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). +- `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. +- 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 - 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. +- `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. +- `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 + +- **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/__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/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/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/manager.py b/postmark/models/outbound/manager.py index 0fb55cb..aa26dc4 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 @@ -8,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 @@ -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 @@ -156,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 # ------------------------------------------------------------------------- @@ -322,7 +380,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: @@ -335,17 +399,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: @@ -356,10 +420,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 +473,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: @@ -424,17 +492,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: @@ -445,10 +513,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/postmark/models/outbound/schemas.py b/postmark/models/outbound/schemas.py index bd35b25..fc8e44c 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, @@ -94,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") @@ -259,11 +268,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 +296,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/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/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/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/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/postmark/models/webhooks/__init__.py b/postmark/models/webhooks/__init__.py index 1846a15..f01c6a5 100644 --- a/postmark/models/webhooks/__init__.py +++ b/postmark/models/webhooks/__init__.py @@ -10,8 +10,13 @@ SpamComplaintTrigger, SubscriptionChangeTrigger, Webhook, + WebhookMetrics, WebhooksListResponse, + WebhookStatistics, + WebhookTimeRange, WebhookTriggers, + WebhookTriggerVerification, + WebhookVerificationResult, ) __all__ = [ @@ -26,6 +31,11 @@ "SubscriptionChangeTrigger", "WebhookTriggers", "Webhook", + "WebhookMetrics", + "WebhookTimeRange", + "WebhookStatistics", + "WebhookTriggerVerification", + "WebhookVerificationResult", "WebhooksListResponse", "DeleteWebhookResponse", # Manager diff --git a/postmark/models/webhooks/manager.py b/postmark/models/webhooks/manager.py index 46489b8..85256b4 100644 --- a/postmark/models/webhooks/manager.py +++ b/postmark/models/webhooks/manager.py @@ -2,7 +2,13 @@ from postmark.utils.types import HTTPClient -from .schemas import DeleteWebhookResponse, Webhook, WebhooksListResponse +from .schemas import ( + DeleteWebhookResponse, + Webhook, + WebhooksListResponse, + WebhookStatistics, + WebhookVerificationResult, +) class WebhookManager: @@ -30,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. @@ -43,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} @@ -55,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( @@ -65,32 +77,85 @@ 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. - 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. + verify: ``False`` saves the changes without first sending test events + (see :meth:`create`). Triggers then report ``"unverified"``. """ - body: dict = {} + 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 + + suffix = "" if verify else "?verify=false" + response = await self.client.put(f"/webhooks/{webhook_id}{suffix}", json=body) + return Webhook(**response.json()) - 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 + async def get_statistics(self, webhook_id: int) -> WebhookStatistics: + """ + Return delivery statistics for a webhook over the last 24 hours. - response = await self.client.put(f"/webhooks/{webhook_id}", json=body) - return Webhook(**response.json()) + 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 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.""" diff --git a/postmark/models/webhooks/schemas.py b/postmark/models/webhooks/schemas.py index 04dc624..df36b18 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 @@ -18,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) @@ -37,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) @@ -44,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) @@ -93,3 +107,83 @@ 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) + + +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/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/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_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 diff --git a/tests/test_bulk_messages.py b/tests/test_bulk_messages.py index 041f911..90d9683 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,277 @@ 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", + } + + +# --------------------------------------------------------------------------- +# 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] 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..3a3f804 --- /dev/null +++ b/tests/test_pagination.py @@ -0,0 +1,105 @@ +"""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)] + + +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 diff --git a/tests/test_retrieve_messages.py b/tests/test_retrieve_messages.py index 01aec8d..dd00d3a 100644 --- a/tests/test_retrieve_messages.py +++ b/tests/test_retrieve_messages.py @@ -386,3 +386,184 @@ 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() + + +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")], +) +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") diff --git a/tests/test_send_messages.py b/tests/test_send_messages.py index ec07895..e98ade2 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,39 @@ 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_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_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 == [] 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 b3bcb66..c049390 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 @@ -441,3 +497,97 @@ 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() + + +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() diff --git a/tests/test_webhooks.py b/tests/test_webhooks.py index e785e3d..88459d2 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_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_edit_calls_correct_endpoint(self, webhooks): + 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" # --------------------------------------------------------------------------- @@ -293,3 +451,494 @@ 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 + + +# --------------------------------------------------------------------------- +# 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 + + +# --------------------------------------------------------------------------- +# 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 + + +# --------------------------------------------------------------------------- +# 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"