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
1 change: 0 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -206,4 +206,3 @@ marimo/_static/
marimo/_lsp/
__marimo__/
.DS_Store
poetry.lock
38 changes: 37 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,10 +7,46 @@ and this project adheres to [Semantic Versioning](https://semver.org/).

---

## [0.2.3] - 2026-04-14

### Fixed

- `__version__` resolves from the **`postmark-python`** distribution metadata so `X-Postmark-Client-Version` matches after `pip install postmark-python` (falls back to `0.0.0` when not installed as a package).

### Changed

- **`poetry.lock`** is tracked in version control again (removed from `.gitignore`) for reproducible installs and CI cache keys.
- README: removed the misleading note about a future PyPI distribution under the name `postmark`.

---

## [0.2.2] - 2026-04-14

### Fixed

- Timeout error message now uses the client’s configured timeout with clearer numeric formatting (`:g`), for both `ServerClient` and `AccountClient`.
- Postmark API `ErrorCode` values from JSON are coerced to `int` when sent as numeric strings; invalid values and booleans map to `None` so exception mapping stays reliable.
- README quick start no longer imports `python-dotenv` (a dev-only dependency); optional `.env` loading is described in a comment instead.

---

## [0.2.1] - 2026-04-14

### Changed

- PyPI distribution renamed from `postmark` to **`postmark-python`** to avoid clashing with the unrelated [`postmark`](https://pypi.org/project/postmark/) package on PyPI. The import name remains `postmark`.
- Trove classifier updated from **Alpha** to **Beta** (`Development Status :: 4 - Beta`).

### Added

- Project URLs for PyPI metadata: repository, homepage ([official libraries](https://postmarkapp.com/developer/integration/official-libraries)), documentation (wiki), and Issues link.

---

## [0.2.0] - 2026-03-06

### Added
- `User-Agent` header sent on every request in the format `Postmark.PY - {version} (Python/{major}.{minor}.{micro})`.
- Client identification on every request: `User-Agent` as `Python/{major}.{minor}.{micro}`, `X-Postmark-Client` as `postmark-python`, `X-Postmark-Client-Version` as the installed SDK version, and a fresh `X-Postmark-Correlation-Id` (UUID) per HTTP request.
- `X-Request-Id` from Postmark responses is now stored as `request_id` on all `PostmarkAPIException` subclasses and included in the exception `__str__` output when present — enabling direct support escalations.
- `request_id` included in structured log records for both successful requests and API errors.
- Structured `extra={}` fields on all log calls (`method`, `endpoint`, `status_code`, `duration_ms`, `error_code`, `postmark_message`, `request_id`) for compatibility with Datadog, Splunk, and other log aggregators.
Expand Down
13 changes: 8 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@
</tr>
</table>

The official Python SDK for [Postmark](https://postmarkapp.com) — send emails, manage bounces, templates, webhooks, and more.
The official (Beta) Python SDK for [Postmark](https://postmarkapp.com) — send emails, manage bounces, templates, webhooks, and more.

For tutorials and detailed usage, check out the **[wiki](https://github.com/ActiveCampaign/postmark-python/wiki)**.

Expand All @@ -24,9 +24,11 @@ For details about the Postmark API in general, see the **[Postmark developer doc

## Installation

~~pip install postmark-python~~
Install from PyPI as **`postmark-python`** (the Python package you import is still **`postmark`**):

(PyPI Coming Soon)
```bash
pip install postmark-python
```

## Quick Start

Expand All @@ -35,10 +37,11 @@ The SDK is fully async. All API calls must be awaited.
```python
import asyncio
import os

import postmark
from dotenv import load_dotenv

load_dotenv()
# Tokens are read from the environment here. Optionally: pip install python-dotenv,
# then use load_dotenv() to populate os.environ from a .env file.

async def main():
async with postmark.ServerClient(os.environ["POSTMARK_SERVER_TOKEN"]) as client:
Expand Down
1,142 changes: 1,142 additions & 0 deletions poetry.lock

Large diffs are not rendered by default.

4 changes: 2 additions & 2 deletions postmark/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,8 @@
from importlib.metadata import PackageNotFoundError, version

try:
__version__ = version("postmark")
except PackageNotFoundError: # running from source without install
__version__ = version("postmark-python")
except PackageNotFoundError: # running from checkout / editable without metadata
__version__ = "0.0.0"

from .clients.account_client import AccountClient
Expand Down
2 changes: 1 addition & 1 deletion postmark/clients/account_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -147,7 +147,7 @@ async def request(self, method: str, endpoint: str, **kwargs) -> httpx.Response:
},
)
raise TimeoutException(
f"Request timed out after {self.timeout} seconds"
f"Request timed out after {self.timeout:g} seconds"
) from e

except httpx.HTTPStatusError as e:
Expand Down
2 changes: 1 addition & 1 deletion postmark/clients/server_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -158,7 +158,7 @@ async def request(self, method: str, endpoint: str, **kwargs) -> httpx.Response:
},
)
raise TimeoutException(
f"Request timed out after {self.timeout} seconds"
f"Request timed out after {self.timeout:g} seconds"
) from e

except httpx.HTTPStatusError as e:
Expand Down
25 changes: 23 additions & 2 deletions postmark/utils/server_utils.py
Original file line number Diff line number Diff line change
@@ -1,15 +1,36 @@
import json
from typing import Optional
from typing import Any, Optional

import httpx


def _coerce_postmark_error_code(raw: Any) -> Optional[int]:
"""Normalize Postmark ``ErrorCode`` from JSON (int or numeric string) to ``int`` or ``None``."""
if raw is None:
return None
if isinstance(
raw, bool
): # must be before ``int`` (``bool`` is a subclass of ``int``)
return None
if isinstance(raw, int):
return raw
if isinstance(raw, str):
stripped = raw.strip()
if not stripped:
return None
try:
return int(stripped, 10)
except ValueError:
return None
return None


def parse_error_response(response: httpx.Response) -> tuple[str, Optional[int]]:
"""Parse error details from Postmark API response."""
try:
error_data = response.json()
message = error_data.get("Message", "Unknown error")
error_code = error_data.get("ErrorCode")
error_code = _coerce_postmark_error_code(error_data.get("ErrorCode"))
return message, error_code
except (json.JSONDecodeError, AttributeError):
# If response isn't JSON, use status text
Expand Down
13 changes: 10 additions & 3 deletions pyproject.toml
Original file line number Diff line number Diff line change
@@ -1,12 +1,16 @@
[tool.poetry]
name = "postmark"
version = "0.2.0"
name = "postmark-python"
version = "0.2.3"
description = "The Official Postmark Python SDK."
authors = ["Greg Svoboda <gsvoboda@activecampaign.com>"]
license = "MIT"
readme = "README.md"
packages = [{include = "postmark"}]
repository = "https://github.com/ActiveCampaign/postmark-python"
homepage = "https://postmarkapp.com/developer/integration/official-libraries"
documentation = "https://github.com/ActiveCampaign/postmark-python/wiki"
classifiers = [
"Development Status :: 3 - Alpha",
"Development Status :: 4 - Beta",
"Intended Audience :: Developers",
"License :: OSI Approved :: MIT License",
"Programming Language :: Python :: 3",
Expand All @@ -17,6 +21,9 @@ classifiers = [
"Topic :: Software Development :: Libraries :: Python Modules",
]

[tool.poetry.urls]
"Issues" = "https://github.com/ActiveCampaign/postmark-python/issues"

[tool.poetry.dependencies]
python = "^3.10"
httpx = "^0.27.0" # Concurrency (async) and HTTP requests
Expand Down
10 changes: 10 additions & 0 deletions tests/test_account_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -204,6 +204,16 @@ async def test_timeout_raises_timeout_exception(self, client):
with pytest.raises(TimeoutException, match="timed out"):
await client.request("GET", "/servers")

@pytest.mark.asyncio
async def test_timeout_message_reflects_client_timeout(self):
client = AccountClient("test-token", timeout=7.25)
with patch.object(AsyncClient, "request", new_callable=AsyncMock) as mock_req:
mock_req.side_effect = httpx.TimeoutException("timed out")
with pytest.raises(TimeoutException) as exc_info:
await client.request("GET", "/servers")
assert "7.25" in str(exc_info.value)
await client.close()

@pytest.mark.asyncio
async def test_correlation_ids_unique_across_requests(self, client):
ok_resp = Mock(spec=Response)
Expand Down
10 changes: 10 additions & 0 deletions tests/test_server_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -187,6 +187,16 @@ async def test_timeout_raises_timeout_exception(self, client):
with pytest.raises(TimeoutException, match="timed out"):
await client.request("GET", "/test")

@pytest.mark.asyncio
async def test_timeout_message_reflects_client_timeout(self):
client = ServerClient("test-token", timeout=12.5)
with patch.object(AsyncClient, "request", new_callable=AsyncMock) as mock_req:
mock_req.side_effect = httpx.TimeoutException("timed out")
with pytest.raises(TimeoutException) as exc_info:
await client.request("GET", "/test")
assert "12.5" in str(exc_info.value)
await client.close()

@pytest.mark.asyncio
async def test_request_error_raises_postmark_exception(self, client):
with patch.object(AsyncClient, "request", new_callable=AsyncMock) as mock_req:
Expand Down
18 changes: 18 additions & 0 deletions tests/test_server_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,24 @@ def test_missing_error_code_returns_none(self):
assert message == "Oops"
assert code is None

def test_error_code_numeric_string_coerced_to_int(self):
response = self._mock_response({"Message": "Bad", "ErrorCode": "300"})
message, code = parse_error_response(response)
assert message == "Bad"
assert code == 300

def test_error_code_invalid_string_returns_none(self):
response = self._mock_response({"Message": "Bad", "ErrorCode": "not-a-code"})
message, code = parse_error_response(response)
assert message == "Bad"
assert code is None

def test_error_code_bool_returns_none(self):
response = self._mock_response({"Message": "Bad", "ErrorCode": True})
message, code = parse_error_response(response)
assert message == "Bad"
assert code is None

def test_missing_message_returns_unknown(self):
response = self._mock_response({"ErrorCode": 422})
message, code = parse_error_response(response)
Expand Down
Loading