Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 6 additions & 2 deletions .secrets.baseline

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

25 changes: 25 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).

---

Expand Down
3 changes: 2 additions & 1 deletion postmark/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -49,6 +49,7 @@
"ServerException",
"TimeoutException",
"server_utils",
"CursorPage",
"Page",
"__version__",
]
5 changes: 3 additions & 2 deletions postmark/models/bounces/schemas.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
3 changes: 2 additions & 1 deletion postmark/models/outbound/enums.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
112 changes: 89 additions & 23 deletions postmark/models/outbound/manager.py
Original file line number Diff line number Diff line change
@@ -1,14 +1,15 @@
from __future__ import annotations

import logging
import warnings
from collections.abc import AsyncGenerator
from datetime import datetime
from typing import TYPE_CHECKING, Any

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

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
# -------------------------------------------------------------------------
Expand Down Expand Up @@ -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:
Expand All @@ -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:
Expand All @@ -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()
Expand Down Expand Up @@ -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:
Expand All @@ -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:
Expand All @@ -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()
Expand Down
27 changes: 23 additions & 4 deletions postmark/models/outbound/schemas.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
from typing import Annotated, Any

from pydantic import (
AliasChoices,
BaseModel,
BeforeValidator,
ConfigDict,
Expand Down Expand Up @@ -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")

Expand Down Expand Up @@ -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)

Expand All @@ -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)
17 changes: 17 additions & 0 deletions postmark/models/page.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Loading
Loading