diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..3bdc508 --- /dev/null +++ b/.gitignore @@ -0,0 +1,23 @@ +# Python +__pycache__/ +*.py[cod] +*$py.class +*.egg-info/ +dist/ +build/ + +# Virtual environments +.venv/ +venv/ +env/ + +# Tooling caches +.pytest_cache/ +.ruff_cache/ +.mypy_cache/ + +# Claude local files +.claude/ + +# OS +.DS_Store diff --git a/CHANGELOG.md b/CHANGELOG.md index ae81817..3cdaa20 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,21 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [1.1.0] - 2026-04-22 + +### Changed +- Webhook event types are now namespaced (e.g. `message.delivery`, + `engagement.click`, `unsubscribe.list_unsubscribe`). The SDK continues to + pass `events` strings through unchanged, so callers just need to switch to + the new names. Docs and tests updated accordingly. +- `webhooks.update()` now sends the webhook URL as `url` instead of `target` + to match the updated API. + +### Deprecated +- `webhooks.update(..., target=...)` — use `url=` instead. `target` still + works (mapped to `url` in the request body) but emits a + `DeprecationWarning`. Passing both raises `TypeError`. + ## [1.0.0] - 2026-04-20 ### Changed @@ -114,7 +129,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 `ValidationError`, `NotFoundError`, `ConflictError`, `BadRequestError`, `ServerError`) -[Unreleased]: https://github.com/lettr/lettr-python/compare/v1.0.0...HEAD +[Unreleased]: https://github.com/lettr/lettr-python/compare/v1.1.0...HEAD +[1.1.0]: https://github.com/lettr/lettr-python/compare/v1.0.0...v1.1.0 [1.0.0]: https://github.com/lettr/lettr-python/compare/v0.3.0...v1.0.0 [0.3.0]: https://github.com/lettr/lettr-python/compare/v0.2.0...v0.3.0 [0.2.0]: https://github.com/lettr/lettr-python/compare/v0.1.0...v0.2.0 diff --git a/README.md b/README.md index 742a887..56d7d90 100644 --- a/README.md +++ b/README.md @@ -260,7 +260,7 @@ webhook = client.webhooks.create( url="https://example.com/webhook", auth_type="none", events_mode="selected", - events=["delivery", "bounce", "spam_complaint"], + events=["message.delivery", "message.bounce", "message.spam_complaint"], ) # Create with authentication diff --git a/pyproject.toml b/pyproject.toml index 9b4e1cf..4cdb4d4 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "lettr" -version = "1.0.0" +version = "1.1.0" description = "Official Python SDK for the Lettr Email API" readme = "README.md" license = "MIT" diff --git a/src/lettr/__init__.py b/src/lettr/__init__.py index 94cd32c..b3c8c0a 100644 --- a/src/lettr/__init__.py +++ b/src/lettr/__init__.py @@ -61,7 +61,7 @@ ) from .resources import Domains, Emails, Projects, Templates, Webhooks -__version__ = "1.0.0" +__version__ = "1.1.0" __all__ = [ # Client diff --git a/src/lettr/__pycache__/__init__.cpython-313.pyc b/src/lettr/__pycache__/__init__.cpython-313.pyc deleted file mode 100644 index ffef533..0000000 Binary files a/src/lettr/__pycache__/__init__.cpython-313.pyc and /dev/null differ diff --git a/src/lettr/__pycache__/_client.cpython-313.pyc b/src/lettr/__pycache__/_client.cpython-313.pyc deleted file mode 100644 index 71b88e9..0000000 Binary files a/src/lettr/__pycache__/_client.cpython-313.pyc and /dev/null differ diff --git a/src/lettr/__pycache__/_exceptions.cpython-313.pyc b/src/lettr/__pycache__/_exceptions.cpython-313.pyc deleted file mode 100644 index fee3318..0000000 Binary files a/src/lettr/__pycache__/_exceptions.cpython-313.pyc and /dev/null differ diff --git a/src/lettr/__pycache__/_types.cpython-313.pyc b/src/lettr/__pycache__/_types.cpython-313.pyc deleted file mode 100644 index 4b72358..0000000 Binary files a/src/lettr/__pycache__/_types.cpython-313.pyc and /dev/null differ diff --git a/src/lettr/_client.py b/src/lettr/_client.py index dc84307..8017ce3 100644 --- a/src/lettr/_client.py +++ b/src/lettr/_client.py @@ -31,7 +31,7 @@ def __init__( "Authorization": f"Bearer {api_key}", "Content-Type": "application/json", "Accept": "application/json", - "User-Agent": "lettr-python/1.0.0", + "User-Agent": "lettr-python/1.1.0", }, ) @@ -98,7 +98,7 @@ def get_no_auth(self, path: str, *, params: dict[str, Any] | None = None) -> Any timeout=self._timeout, headers={ "Accept": "application/json", - "User-Agent": "lettr-python/1.0.0", + "User-Agent": "lettr-python/1.1.0", }, ) except httpx.HTTPError as exc: diff --git a/src/lettr/resources/__pycache__/__init__.cpython-313.pyc b/src/lettr/resources/__pycache__/__init__.cpython-313.pyc deleted file mode 100644 index 9dc38ec..0000000 Binary files a/src/lettr/resources/__pycache__/__init__.cpython-313.pyc and /dev/null differ diff --git a/src/lettr/resources/__pycache__/domains.cpython-313.pyc b/src/lettr/resources/__pycache__/domains.cpython-313.pyc deleted file mode 100644 index af8eca5..0000000 Binary files a/src/lettr/resources/__pycache__/domains.cpython-313.pyc and /dev/null differ diff --git a/src/lettr/resources/__pycache__/emails.cpython-313.pyc b/src/lettr/resources/__pycache__/emails.cpython-313.pyc deleted file mode 100644 index d5cf899..0000000 Binary files a/src/lettr/resources/__pycache__/emails.cpython-313.pyc and /dev/null differ diff --git a/src/lettr/resources/__pycache__/projects.cpython-313.pyc b/src/lettr/resources/__pycache__/projects.cpython-313.pyc deleted file mode 100644 index 2f7166b..0000000 Binary files a/src/lettr/resources/__pycache__/projects.cpython-313.pyc and /dev/null differ diff --git a/src/lettr/resources/__pycache__/templates.cpython-313.pyc b/src/lettr/resources/__pycache__/templates.cpython-313.pyc deleted file mode 100644 index 993d0fe..0000000 Binary files a/src/lettr/resources/__pycache__/templates.cpython-313.pyc and /dev/null differ diff --git a/src/lettr/resources/__pycache__/webhooks.cpython-313.pyc b/src/lettr/resources/__pycache__/webhooks.cpython-313.pyc deleted file mode 100644 index 62a2c65..0000000 Binary files a/src/lettr/resources/__pycache__/webhooks.cpython-313.pyc and /dev/null differ diff --git a/src/lettr/resources/webhooks.py b/src/lettr/resources/webhooks.py index b038ca5..6be82ca 100644 --- a/src/lettr/resources/webhooks.py +++ b/src/lettr/resources/webhooks.py @@ -3,6 +3,7 @@ from __future__ import annotations import builtins +import warnings from typing import Any from .._client import ApiClient @@ -93,7 +94,9 @@ def create( oauth_client_id: OAuth2 client ID (when ``auth_type="oauth2"``). oauth_client_secret: OAuth2 client secret (when ``auth_type="oauth2"``). oauth_token_url: OAuth2 token URL (when ``auth_type="oauth2"``). - events: Event types to receive (when ``events_mode="selected"``). + events: Namespaced event types to receive (when + ``events_mode="selected"``), e.g. ``["message.delivery", + "engagement.click"]``. Returns: A :class:`Webhook` with the created webhook details. @@ -129,6 +132,7 @@ def update( webhook_id: str, *, name: str | None = None, + url: str | None = None, target: str | None = None, auth_type: str | None = None, auth_username: str | None = None, @@ -146,14 +150,18 @@ def update( Args: webhook_id: The webhook ID to update. name: New webhook name. - target: New webhook URL. + url: New webhook URL. + target: Deprecated alias for ``url``. Still accepted but emits a + :class:`DeprecationWarning`; passing both ``url`` and + ``target`` raises :class:`TypeError`. auth_type: New authentication type. auth_username: New basic auth username. auth_password: New basic auth password. oauth_token_url: New OAuth2 token URL. oauth_client_id: New OAuth2 client ID. oauth_client_secret: New OAuth2 client secret. - events: New event types to receive. + events: New namespaced event types to receive, e.g. + ``["message.delivery", "engagement.click"]``. active: Enable or disable the webhook. Returns: @@ -163,11 +171,21 @@ def update( NotFoundError: If the webhook is not found. ValidationError: If validation fails. """ + if target is not None: + if url is not None: + raise TypeError("Pass either `url` or `target` (deprecated), not both.") + warnings.warn( + "The `target` parameter is deprecated; use `url` instead.", + DeprecationWarning, + stacklevel=2, + ) + url = target + payload: dict[str, Any] = {} if name is not None: payload["name"] = name - if target is not None: - payload["target"] = target + if url is not None: + payload["url"] = url if auth_type is not None: payload["auth_type"] = auth_type if auth_username is not None: diff --git a/tests/__pycache__/__init__.cpython-313.pyc b/tests/__pycache__/__init__.cpython-313.pyc deleted file mode 100644 index 6ae4105..0000000 Binary files a/tests/__pycache__/__init__.cpython-313.pyc and /dev/null differ diff --git a/tests/__pycache__/conftest.cpython-313-pytest-9.0.3.pyc b/tests/__pycache__/conftest.cpython-313-pytest-9.0.3.pyc deleted file mode 100644 index c06036f..0000000 Binary files a/tests/__pycache__/conftest.cpython-313-pytest-9.0.3.pyc and /dev/null differ diff --git a/tests/__pycache__/test_client.cpython-313-pytest-9.0.3.pyc b/tests/__pycache__/test_client.cpython-313-pytest-9.0.3.pyc deleted file mode 100644 index 6d88fdb..0000000 Binary files a/tests/__pycache__/test_client.cpython-313-pytest-9.0.3.pyc and /dev/null differ diff --git a/tests/__pycache__/test_domains.cpython-313-pytest-9.0.3.pyc b/tests/__pycache__/test_domains.cpython-313-pytest-9.0.3.pyc deleted file mode 100644 index e17a1d4..0000000 Binary files a/tests/__pycache__/test_domains.cpython-313-pytest-9.0.3.pyc and /dev/null differ diff --git a/tests/__pycache__/test_emails.cpython-313-pytest-9.0.3.pyc b/tests/__pycache__/test_emails.cpython-313-pytest-9.0.3.pyc deleted file mode 100644 index a831e5b..0000000 Binary files a/tests/__pycache__/test_emails.cpython-313-pytest-9.0.3.pyc and /dev/null differ diff --git a/tests/__pycache__/test_exceptions.cpython-313-pytest-9.0.3.pyc b/tests/__pycache__/test_exceptions.cpython-313-pytest-9.0.3.pyc deleted file mode 100644 index 8510914..0000000 Binary files a/tests/__pycache__/test_exceptions.cpython-313-pytest-9.0.3.pyc and /dev/null differ diff --git a/tests/__pycache__/test_lettr.cpython-313-pytest-9.0.3.pyc b/tests/__pycache__/test_lettr.cpython-313-pytest-9.0.3.pyc deleted file mode 100644 index 4a82d51..0000000 Binary files a/tests/__pycache__/test_lettr.cpython-313-pytest-9.0.3.pyc and /dev/null differ diff --git a/tests/__pycache__/test_projects.cpython-313-pytest-9.0.3.pyc b/tests/__pycache__/test_projects.cpython-313-pytest-9.0.3.pyc deleted file mode 100644 index e2ac8bd..0000000 Binary files a/tests/__pycache__/test_projects.cpython-313-pytest-9.0.3.pyc and /dev/null differ diff --git a/tests/__pycache__/test_templates.cpython-313-pytest-9.0.3.pyc b/tests/__pycache__/test_templates.cpython-313-pytest-9.0.3.pyc deleted file mode 100644 index 14c43a7..0000000 Binary files a/tests/__pycache__/test_templates.cpython-313-pytest-9.0.3.pyc and /dev/null differ diff --git a/tests/__pycache__/test_types.cpython-313-pytest-9.0.3.pyc b/tests/__pycache__/test_types.cpython-313-pytest-9.0.3.pyc deleted file mode 100644 index 46027cf..0000000 Binary files a/tests/__pycache__/test_types.cpython-313-pytest-9.0.3.pyc and /dev/null differ diff --git a/tests/__pycache__/test_webhooks.cpython-313-pytest-9.0.3.pyc b/tests/__pycache__/test_webhooks.cpython-313-pytest-9.0.3.pyc deleted file mode 100644 index 3eba311..0000000 Binary files a/tests/__pycache__/test_webhooks.cpython-313-pytest-9.0.3.pyc and /dev/null differ diff --git a/tests/test_webhooks.py b/tests/test_webhooks.py index dbcef51..e01b353 100644 --- a/tests/test_webhooks.py +++ b/tests/test_webhooks.py @@ -22,7 +22,7 @@ def webhooks(mock_client: MagicMock) -> Webhooks: "enabled": True, "auth_type": "none", "has_auth_credentials": False, - "event_types": ["delivery", "bounce"], + "event_types": ["message.delivery", "message.bounce"], "last_successful_at": "2025-06-01", "last_failure_at": None, "last_status": "200", @@ -37,7 +37,7 @@ def test_list_webhooks(self, webhooks: Webhooks, mock_client: MagicMock) -> None assert len(result) == 1 assert isinstance(result[0], Webhook) assert result[0].id == "wh_123" - assert result[0].event_types == ["delivery", "bounce"] + assert result[0].event_types == ["message.delivery", "message.bounce"] class TestGet: @@ -59,7 +59,7 @@ def test_create_webhook(self, webhooks: Webhooks, mock_client: MagicMock) -> Non url="https://example.com/hook", auth_type="none", events_mode="selected", - events=["delivery", "bounce"], + events=["message.delivery", "message.bounce"], ) assert isinstance(result, Webhook) @@ -69,7 +69,7 @@ def test_create_webhook(self, webhooks: Webhooks, mock_client: MagicMock) -> Non assert payload["name"] == "My Hook" assert payload["auth_type"] == "none" assert payload["events_mode"] == "selected" - assert payload["events"] == ["delivery", "bounce"] + assert payload["events"] == ["message.delivery", "message.bounce"] def test_create_with_basic_auth(self, webhooks: Webhooks, mock_client: MagicMock) -> None: mock_client.post.return_value = { @@ -123,11 +123,38 @@ def test_update_webhook(self, webhooks: Webhooks, mock_client: MagicMock) -> Non def test_update_partial(self, webhooks: Webhooks, mock_client: MagicMock) -> None: mock_client.put.return_value = {"data": WEBHOOK_DATA} - webhooks.update("wh_123", events=["delivery"]) + webhooks.update("wh_123", events=["message.delivery"]) payload = mock_client.put.call_args.kwargs["json"] - assert payload == {"events": ["delivery"]} + assert payload == {"events": ["message.delivery"]} assert "name" not in payload + def test_update_with_url(self, webhooks: Webhooks, mock_client: MagicMock) -> None: + mock_client.put.return_value = {"data": WEBHOOK_DATA} + + webhooks.update("wh_123", url="https://new.example.com/hook") + payload = mock_client.put.call_args.kwargs["json"] + assert payload == {"url": "https://new.example.com/hook"} + + def test_update_target_deprecated(self, webhooks: Webhooks, mock_client: MagicMock) -> None: + mock_client.put.return_value = {"data": WEBHOOK_DATA} + + with pytest.warns(DeprecationWarning, match="target"): + webhooks.update("wh_123", target="https://legacy.example.com/hook") + + payload = mock_client.put.call_args.kwargs["json"] + assert payload == {"url": "https://legacy.example.com/hook"} + + def test_update_url_and_target_conflict( + self, webhooks: Webhooks, mock_client: MagicMock + ) -> None: + with pytest.raises(TypeError, match="both"): + webhooks.update( + "wh_123", + url="https://a.example.com/hook", + target="https://b.example.com/hook", + ) + mock_client.put.assert_not_called() + class TestDelete: def test_delete_webhook(self, webhooks: Webhooks, mock_client: MagicMock) -> None: