From 26606abccd62be7b97515adbf21019d89e033719 Mon Sep 17 00:00:00 2001 From: voj-tech-j Date: Thu, 16 Apr 2026 10:36:44 +0200 Subject: [PATCH 1/4] feat: sync with Lettr API spec --- CHANGELOG.md | 101 ++++ README.md | 104 +++- RELEASING.md | 92 ++++ pyproject.toml | 7 +- src/lettr/__init__.py | 58 +- .../__pycache__/__init__.cpython-313.pyc | Bin 4383 -> 5673 bytes src/lettr/__pycache__/_client.cpython-313.pyc | Bin 4260 -> 5385 bytes .../__pycache__/_exceptions.cpython-313.pyc | Bin 4971 -> 5673 bytes src/lettr/__pycache__/_types.cpython-313.pyc | Bin 9144 -> 15217 bytes src/lettr/_client.py | 49 +- src/lettr/_exceptions.py | 30 +- src/lettr/_types.py | 280 +++++++--- .../__pycache__/__init__.cpython-313.pyc | Bin 474 -> 480 bytes .../__pycache__/domains.cpython-313.pyc | Bin 4941 -> 5892 bytes .../__pycache__/emails.cpython-313.pyc | Bin 7107 -> 20772 bytes .../__pycache__/projects.cpython-313.pyc | Bin 2190 -> 2156 bytes .../__pycache__/templates.cpython-313.pyc | Bin 9272 -> 9861 bytes .../__pycache__/webhooks.cpython-313.pyc | Bin 2659 -> 6775 bytes src/lettr/resources/domains.py | 37 +- src/lettr/resources/emails.py | 509 +++++++++++++++--- src/lettr/resources/projects.py | 8 +- src/lettr/resources/templates.py | 69 ++- src/lettr/resources/webhooks.py | 191 +++++-- tests/__init__.py | 0 tests/__pycache__/__init__.cpython-313.pyc | Bin 0 -> 164 bytes .../conftest.cpython-313-pytest-9.0.3.pyc | Bin 0 -> 873 bytes .../test_client.cpython-313-pytest-9.0.3.pyc | Bin 0 -> 9715 bytes .../test_domains.cpython-313-pytest-9.0.3.pyc | Bin 0 -> 23638 bytes .../test_emails.cpython-313-pytest-9.0.3.pyc | Bin 0 -> 54322 bytes ...st_exceptions.cpython-313-pytest-9.0.3.pyc | Bin 0 -> 14861 bytes .../test_lettr.cpython-313-pytest-9.0.3.pyc | Bin 0 -> 13087 bytes ...test_projects.cpython-313-pytest-9.0.3.pyc | Bin 0 -> 6817 bytes ...est_templates.cpython-313-pytest-9.0.3.pyc | Bin 0 -> 23315 bytes .../test_types.cpython-313-pytest-9.0.3.pyc | Bin 0 -> 14917 bytes ...test_webhooks.cpython-313-pytest-9.0.3.pyc | Bin 0 -> 17509 bytes tests/conftest.py | 17 + tests/test_client.py | 105 ++++ tests/test_domains.py | 177 ++++++ tests/test_emails.py | 415 ++++++++++++++ tests/test_exceptions.py | 95 ++++ tests/test_lettr.py | 65 +++ tests/test_projects.py | 64 +++ tests/test_templates.py | 171 ++++++ tests/test_types.py | 72 +++ tests/test_webhooks.py | 140 +++++ 45 files changed, 2627 insertions(+), 229 deletions(-) create mode 100644 CHANGELOG.md create mode 100644 RELEASING.md create mode 100644 tests/__init__.py create mode 100644 tests/__pycache__/__init__.cpython-313.pyc create mode 100644 tests/__pycache__/conftest.cpython-313-pytest-9.0.3.pyc create mode 100644 tests/__pycache__/test_client.cpython-313-pytest-9.0.3.pyc create mode 100644 tests/__pycache__/test_domains.cpython-313-pytest-9.0.3.pyc create mode 100644 tests/__pycache__/test_emails.cpython-313-pytest-9.0.3.pyc create mode 100644 tests/__pycache__/test_exceptions.cpython-313-pytest-9.0.3.pyc create mode 100644 tests/__pycache__/test_lettr.cpython-313-pytest-9.0.3.pyc create mode 100644 tests/__pycache__/test_projects.cpython-313-pytest-9.0.3.pyc create mode 100644 tests/__pycache__/test_templates.cpython-313-pytest-9.0.3.pyc create mode 100644 tests/__pycache__/test_types.cpython-313-pytest-9.0.3.pyc create mode 100644 tests/__pycache__/test_webhooks.cpython-313-pytest-9.0.3.pyc create mode 100644 tests/conftest.py create mode 100644 tests/test_client.py create mode 100644 tests/test_domains.py create mode 100644 tests/test_emails.py create mode 100644 tests/test_exceptions.py create mode 100644 tests/test_lettr.py create mode 100644 tests/test_projects.py create mode 100644 tests/test_templates.py create mode 100644 tests/test_types.py create mode 100644 tests/test_webhooks.py diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..37ac4fc --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,101 @@ +# Changelog + +All notable changes to this project will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [Unreleased] + +### Fixed +- `emails.list()`, `emails.get()`, and `emails.list_events()` were reading + responses at the wrong nesting level and silently returned empty results + when the API actually had data. Now correctly read from + `data.events.data` (list endpoints) and `data.events` (detail endpoint), + matching the OpenAPI spec. +- `emails.schedule()` crashed with `KeyError: 'transmission_id'` because + it parsed the wrong response shape. Per the spec, the endpoint returns + the same envelope as `POST /emails` (`request_id`, `accepted`, + `rejected`), not a full transmission detail. +- `emails.get_scheduled()` was using the wrong `ScheduledEmail` shape + (missing required `recipients`/`events`; `events` was typed as + `list[str]` instead of `list[EmailEvent]`). +- `Lettr.auth_check()` crashed with `KeyError: 'team_name'` — the spec + returns `team_id` + `timestamp`, not `team_name`. +- `Lettr.health()` now also surfaces the `timestamp` field returned by + the API. +- `domains.create()` (and any future endpoint returning a DKIM object) + no longer crashes when the API returns unknown DKIM fields. All nested + dataclass parsers (`DkimInfo`, `DmarcValidationResult`, + `SpfValidationResult`, `GeoIp`, `UserAgentParsed`) now silently ignore + unknown keys instead of raising `TypeError`. +- Added `signing_domain` field to `DkimInfo` (was missing from the SDK + but present in the spec). + +### Changed (breaking) +- `EmailDetail` dataclass reshaped to match the spec. Was + `{ results: list[EmailEvent], total_count: int }`; now has + `transmission_id`, `state`, `from_email`, `subject`, `recipients`, + `num_recipients`, `events`, plus optional `scheduled_at` and `from_name`. + Code that did `detail.results` should use `detail.events`. +- `EmailList` and `EmailEventList` gained `date_from` / `date_to` fields + exposing the query window returned by the API. +- `ScheduledEmail` dataclass reshaped to match the spec (same shape as + `EmailDetail`): required fields are now `transmission_id`, `state`, + `from_email`, `subject`, `recipients`, `num_recipients`, `events`; + removed the spurious `created_at`; `events` is now + `list[EmailEvent]` (was `list[str]`). +- `emails.schedule()` now returns `SendEmailResponse` (not + `ScheduledEmail`). Use `emails.get_scheduled(request_id)` to retrieve + the full transmission detail afterwards. +- `AuthCheck` dataclass: `team_name` removed, `timestamp` added (matches + the spec). +- `HealthCheck` dataclass: added required `timestamp` field. + +## [0.2.0] + +### Added +- `client.health()` — check API health (no auth required) +- `client.auth_check()` — validate API key and get team info +- `client.emails.list_events()` — list email events with filtering +- `client.emails.schedule()` — schedule email for future delivery +- `client.emails.get_scheduled()` / `cancel_scheduled()` — manage scheduled emails +- `client.webhooks.create()` / `update()` / `delete()` — full webhook CRUD +- `client.templates.get_html()` — retrieve rendered template HTML +- `tag` and `headers` parameters to `emails.send()` +- `from_date` / `to_date` query params to `emails.get()` +- `ForbiddenError` (403) and `RateLimitError` (429) exceptions +- New types: `HealthCheck`, `AuthCheck`, `GeoIp`, `UserAgentParsed`, + `EmailEventList`, `ScheduledEmail`, `DmarcValidationResult`, + `SpfValidationResult` +- `dmarc_status`, `spf_status`, `is_primary_domain`, `dns_provider` fields + on `Domain`; `dmarc` / `spf` validation objects on `DomainVerification` +- `perform_substitutions` option on `EmailOptions` +- Additional event fields (`campaign_id`, `template_id`, geo/user-agent, etc.) + on `EmailEvent` +- Comprehensive test suite (72 tests) + +### Changed +- `emails.send()` — `subject` is now optional (required only when not using + a template) +- `ScheduledEmail.id` → `transmission_id` to match spec +- Bumped minimum SDK version to 0.2.0 + +### Removed +- `campaign_id` kwarg from `emails.send()` and `emails.schedule()` — not + part of the spec's send request schema + +## [0.1.0] + +### Added +- Initial release +- `Lettr` client with resource-based API (`emails`, `domains`, `templates`, + `webhooks`, `projects`) +- Bearer token authentication, context manager support +- Typed exception hierarchy (`LettrError`, `AuthenticationError`, + `ValidationError`, `NotFoundError`, `ConflictError`, `BadRequestError`, + `ServerError`) + +[Unreleased]: https://github.com/lettr/lettr-python/compare/v0.2.0...HEAD +[0.2.0]: https://github.com/lettr/lettr-python/compare/v0.1.0...v0.2.0 +[0.1.0]: https://github.com/lettr/lettr-python/releases/tag/v0.1.0 diff --git a/README.md b/README.md index 1adf97c..742a887 100644 --- a/README.md +++ b/README.md @@ -25,6 +25,18 @@ client.emails.send( ## Usage +### Health & Authentication + +```python +# Check API health (no authentication required) +health = client.health() +print(health.status) # "ok" + +# Validate your API key +auth = client.auth_check() +print(f"Team: {auth.team_name} (ID: {auth.team_id})") +``` + ### Sending Emails ```python @@ -58,6 +70,8 @@ response = client.emails.send( subject="Your Order Confirmation", html="

Order Confirmed

", text="Order Confirmed", + tag="order-confirmation", + headers={"X-Order-Id": "12345"}, options=lettr.EmailOptions( click_tracking=True, open_tracking=True, @@ -96,7 +110,6 @@ response = client.emails.send( response = client.emails.send( from_email="hello@example.com", to=["user@example.com"], - subject="Welcome, {{first_name}}!", template_slug="welcome-email", substitution_data={ "first_name": "John", @@ -105,6 +118,27 @@ response = client.emails.send( ) ``` +### Scheduling Emails + +```python +# Schedule an email for future delivery +response = client.emails.schedule( + from_email="sender@example.com", + to=["recipient@example.com"], + subject="Scheduled Message", + html="

This was scheduled!

", + scheduled_at="2025-12-01T10:00:00Z", +) +print(f"Scheduled: {response.request_id}") + +# Get scheduled email details +detail = client.emails.get_scheduled(response.request_id) +print(f"{detail.subject} ({detail.state}) -> {detail.recipients}") + +# Cancel a scheduled email +client.emails.cancel_scheduled("transmission_id") +``` + ### Listing & Retrieving Emails ```python @@ -124,10 +158,31 @@ filtered = client.emails.list(from_date="2026-01-01", to_date="2026-01-31") # Get email details (all events for a transmission) detail = client.emails.get("request_id_here") -for event in detail.results: +print(f"{detail.subject} -> {detail.recipients} ({detail.state})") +for event in detail.events: print(f"{event.type}: {event.timestamp}") ``` +### Email Events + +```python +# List email events with filtering +events = client.emails.list_events( + events=["delivery", "bounce"], + recipients=["user@example.com"], + from_date="2025-01-01", + to_date="2025-12-31", + per_page=50, +) + +for event in events.results: + print(f"{event.type}: {event.rcpt_to} at {event.timestamp}") + if event.geo_ip: + print(f" Location: {event.geo_ip.city}, {event.geo_ip.country}") + if event.user_agent_parsed: + print(f" Client: {event.user_agent_parsed.agent_family}") +``` + ### Domains ```python @@ -140,13 +195,16 @@ for domain in domains: domain = client.domains.create("example.com") print(domain.dkim) # DKIM config for DNS setup -# Get domain details +# Get domain details (includes DMARC, SPF, DNS provider info) domain = client.domains.get("example.com") +print(f"DMARC: {domain.dmarc_status}, SPF: {domain.spf_status}") # Verify DNS records verification = client.domains.verify("example.com") print(f"DKIM: {verification.dkim_status}") print(f"CNAME: {verification.cname_status}") +print(f"DMARC: {verification.dmarc_status}") +print(f"SPF: {verification.spf_status}") # Delete a domain client.domains.delete("example.com") @@ -170,6 +228,9 @@ template = client.templates.create( template = client.templates.get("welcome-email") print(template.html) +# Get template HTML +html = client.templates.get_html(project_id=1, slug="welcome-email") + # Update a template (creates a new version) template = client.templates.update( "welcome-email", @@ -193,8 +254,37 @@ webhooks = client.webhooks.list() for webhook in webhooks: print(f"{webhook.name}: {webhook.url} (enabled: {webhook.enabled})") +# Create a webhook +webhook = client.webhooks.create( + name="My Webhook", + url="https://example.com/webhook", + auth_type="none", + events_mode="selected", + events=["delivery", "bounce", "spam_complaint"], +) + +# Create with authentication +webhook = client.webhooks.create( + name="Secure Webhook", + url="https://example.com/webhook", + auth_type="basic", + events_mode="all", + auth_username="user", + auth_password="secret", +) + +# Update a webhook +webhook = client.webhooks.update( + "webhook-abc123", + name="Renamed Webhook", + active=False, +) + # Get webhook details webhook = client.webhooks.get("webhook-abc123") + +# Delete a webhook +client.webhooks.delete("webhook-abc123") ``` ### Projects @@ -227,8 +317,12 @@ except lettr.ValidationError as e: print(f"Field errors: {e.errors}") except lettr.AuthenticationError: print("Invalid API key") +except lettr.ForbiddenError: + print("Access forbidden") except lettr.NotFoundError as e: print(f"Not found: {e.message}") +except lettr.RateLimitError as e: + print(f"Rate limited: {e.message} (code: {e.error_code})") except lettr.BadRequestError as e: print(f"Bad request: {e.message} (code: {e.error_code})") except lettr.ServerError as e: @@ -241,12 +335,14 @@ except lettr.LettrError as e: | Exception | HTTP Status | Description | |---|---|---| -| `LettrError` | — | Base exception for all errors | +| `LettrError` | -- | Base exception for all errors | | `AuthenticationError` | 401 | Missing or invalid API key | +| `ForbiddenError` | 403 | Access forbidden | | `ValidationError` | 422 | Request validation failed | | `NotFoundError` | 404 | Resource not found | | `ConflictError` | 409 | Resource already exists | | `BadRequestError` | 400 | Invalid domain or request | +| `RateLimitError` | 429 | Quota or rate limit exceeded | | `ServerError` | 500, 502 | Server-side error | ## Context Manager diff --git a/RELEASING.md b/RELEASING.md new file mode 100644 index 0000000..54a38d2 --- /dev/null +++ b/RELEASING.md @@ -0,0 +1,92 @@ +# Releasing + +This project uses [Semantic Versioning](https://semver.org/). Releases are +published to PyPI automatically by GitHub Actions when a GitHub Release is +published. + +## Versioning policy + +- **MAJOR** (`1.0.0` → `2.0.0`): breaking API changes +- **MINOR** (`0.2.0` → `0.3.0`): new features, backwards-compatible +- **PATCH** (`0.2.0` → `0.2.1`): bug fixes only + +Pre-1.0 minor bumps may contain breaking changes (noted in the changelog). + +## Version is stored in three places + +Keep all three in sync: + +1. `pyproject.toml` → `version = "X.Y.Z"` +2. `src/lettr/__init__.py` → `__version__ = "X.Y.Z"` +3. `src/lettr/_client.py` → `User-Agent` header (two occurrences: + `request()` and `get_no_auth()`) + +## Release checklist + +1. **Update the changelog.** Move items from `[Unreleased]` into a new + `[X.Y.Z]` section in `CHANGELOG.md`. Add a new empty `[Unreleased]` + section at the top. Update the compare links at the bottom. + +2. **Bump the version** in the three files above. + +3. **Run the checks locally:** + ```bash + source .venv/bin/activate + python -m pytest tests/ -v + ruff check src/ tests/ + ``` + +4. **Commit and push on `main`:** + ```bash + git add CHANGELOG.md pyproject.toml src/lettr/ + git commit -m "Release vX.Y.Z" + git push origin main + ``` + +5. **Tag the release:** + ```bash + git tag vX.Y.Z + git push origin vX.Y.Z + ``` + +6. **Create a GitHub Release** from the tag. The body should mirror the + changelog entry for this version. + ```bash + gh release create vX.Y.Z --title "vX.Y.Z" --notes-from-tag + ``` + (or use the GitHub web UI) + +7. **Watch the workflow.** The `.github/workflows/publish.yml` workflow + fires on release publish: + - Builds the sdist and wheel + - Publishes to TestPyPI + - Publishes to PyPI + + Both PyPI targets use [trusted publishing](https://docs.pypi.org/trusted-publishers/) + via OIDC, so no API tokens are needed — but the `testpypi` and `pypi` + environments must be configured in the GitHub repo settings with + matching trusted publisher entries on PyPI. + +8. **Verify** the new version appears at + and installs cleanly: + ```bash + pip install --upgrade lettr + python -c "import lettr; print(lettr.__version__)" + ``` + +## What if the workflow fails? + +- **TestPyPI step fails:** TestPyPI rejects re-uploads of an existing + version. Delete the release, bump to the next patch, and retry. +- **PyPI step fails:** same constraint — a published version cannot be + overwritten. You must bump to the next version. +- **Never yank/delete a PyPI release** unless it has a critical issue; + prefer publishing a fix release instead. + +## Cutting a v1.0.0 + +When the API surface is considered stable: + +1. Ensure the changelog's `[Unreleased]` section is clean. +2. Add a `## [1.0.0]` section documenting the stability commitment. +3. Follow the release checklist above with `X.Y.Z = 1.0.0`. diff --git a/pyproject.toml b/pyproject.toml index b43dd9b..85fbd57 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "lettr" -version = "0.1.0" +version = "0.2.0" description = "Official Python SDK for the Lettr Email API" readme = "README.md" license = "MIT" @@ -32,6 +32,11 @@ dependencies = [ "httpx>=0.24.0", ] +[project.optional-dependencies] +dev = [ + "pytest>=7.0", +] + [project.urls] Homepage = "https://lettr.com" Documentation = "https://lettr.com/docs" diff --git a/src/lettr/__init__.py b/src/lettr/__init__.py index f2507fb..e3bf523 100644 --- a/src/lettr/__init__.py +++ b/src/lettr/__init__.py @@ -16,39 +16,49 @@ from __future__ import annotations -from typing import Optional - -from ._client import ApiClient, DEFAULT_BASE_URL, DEFAULT_TIMEOUT +from ._client import DEFAULT_BASE_URL, DEFAULT_TIMEOUT, ApiClient from ._exceptions import ( AuthenticationError, BadRequestError, ConflictError, + ForbiddenError, LettrError, NotFoundError, + RateLimitError, ServerError, ValidationError, ) from ._types import ( Attachment, + AuthCheck, + DkimInfo, + DmarcValidationResult, Domain, DomainVerification, Email, EmailDetail, EmailEvent, + EmailEventList, EmailList, EmailOptions, + GeoIp, + HealthCheck, MergeTag, + MergeTagChild, Project, ProjectList, + ScheduledEmail, SendEmailResponse, + SpfValidationResult, Template, TemplateList, TemplateMergeTags, + UserAgentParsed, Webhook, ) from .resources import Domains, Emails, Projects, Templates, Webhooks -__version__ = "0.1.0" +__version__ = "0.2.0" __all__ = [ # Client @@ -58,25 +68,37 @@ "AuthenticationError", "BadRequestError", "ConflictError", + "ForbiddenError", "NotFoundError", + "RateLimitError", "ServerError", "ValidationError", # Types "Attachment", + "AuthCheck", + "DkimInfo", + "DmarcValidationResult", "Domain", "DomainVerification", "Email", "EmailDetail", "EmailEvent", + "EmailEventList", "EmailList", "EmailOptions", + "GeoIp", + "HealthCheck", "MergeTag", + "MergeTagChild", "Project", "ProjectList", + "ScheduledEmail", "SendEmailResponse", + "SpfValidationResult", "Template", "TemplateList", "TemplateMergeTags", + "UserAgentParsed", "Webhook", ] @@ -145,11 +167,37 @@ def __init__( self.projects = Projects(self._client) """Project management operations.""" + def health(self) -> HealthCheck: + """Check API health. No authentication required. + + Returns: + A :class:`HealthCheck` with the API status. + """ + body = self._client.get_no_auth("/health") + data = body["data"] + return HealthCheck(status=data["status"], timestamp=data["timestamp"]) + + def auth_check(self) -> AuthCheck: + """Validate the API key and return team information. + + Returns: + An :class:`AuthCheck` with team details. + + Raises: + AuthenticationError: If the API key is invalid. + """ + body = self._client.get("/auth/check") + data = body["data"] + return AuthCheck( + team_id=data["team_id"], + timestamp=data["timestamp"], + ) + def close(self) -> None: """Close the underlying HTTP connection pool.""" self._client.close() - def __enter__(self) -> "Lettr": + def __enter__(self) -> Lettr: return self def __exit__(self, *args: object) -> None: diff --git a/src/lettr/__pycache__/__init__.cpython-313.pyc b/src/lettr/__pycache__/__init__.cpython-313.pyc index 1a6673ace691fe21ff91580b1279552c9986ee9c..71c4a7dabf3e10f951b241d7a3e5ca1c71ecbead 100644 GIT binary patch delta 2601 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 A-2eap diff --git a/src/lettr/__pycache__/_client.cpython-313.pyc b/src/lettr/__pycache__/_client.cpython-313.pyc index c762c1572e71ffe217e0311437f3714c2c528022..78226e2304f4733bd74dd47871a6be7750058ba8 100644 GIT binary patch delta 2523 zcmaJ?O>7(25q@v~x%`v(BT+w=RW_I4Znc0VZ_s>Q?4~KOE&-#BpD^??WkrZ9o8|s}jh)GT9ip*qFHkB*NWtFLy zHKqZtvVf^EJw^J78R#UYJ|Un3LRfI`?OBD^#A$nb?{9C?zwjw~md1FIp4o|;N+&5v zC4p(CWU3izCQPl#1b_(ulQi`vqd#YYO(yu zbdnH|EoLigU6;}wTop?67n})SPhA_`5*xu2vZ@`SWu5<4n5PP-V#mN?OTJv*Di-oy z(XmHLu45m@wk*$M7s}h-22i7!F+L@x=^@_`&kn0KEtfABbDP$7O<}Ibisf1$cO&mw zxeD8YO|N*%aw;DGwK#k+hFOb)a3v51Jhj(GvaZG4k?%Vt&nj$`oXVa2$eiVFdQN#{ zi8&=})7$2{G*6fKlGH6%Q@!tmKiZNW3UWXAIOIw7%$r?+YAUnds#p*~2d)AOD=SO$ zj5&-cxDL8r-mAE|g0p6^)4s<1?SfUtQ!*9SX`OW=t=zKUm}@nuocA_-O*X(kR5MBs z?6NffquNG?KYFZoQ98hBU}10q_+L?$#y`>b!7`{i0K&y=!!cP8cLMG7GXF&H;12?_ z)`!}ZodyrHydF@lUqX|M$e#3pfgbyS1%&0eUM(_RF3xThE!)fBdJ)xIcbQ{E|6e44$GU-wgJQ;=u~d1|bv{SY?m}`uQJV^%Ig6hJnB*4;jk}{6OlxB)%cJ z;wW*2I9Ui0QUUw&cveC`i_opt#&g1AgpeRHh%MNnE%lJqqj&IqA$hu=WXUq#^H-h- z$(YOKOdR3k|Ezk{_Xf$T z0}!kMS7S@Ic$4%UheMYA@p5d3NF{mjXe+DXBi>Z93I;ZTDZ-Ow6-zLwtxPL?Oi7Cn z0^biLI8)kp)H}PTWv>}|;~VoUMzhL|9~7ZXE1uzPSjKb(Fe~2id)C;n@=)U$U5CzE zGj2M}0PC_-v^^KJ^PaKh*h8N0sK_iM55`bP&5k#Qt;~7`eQbFfMq$G$>};kp`c;OZ zGq?nj7FY}7R>Y!T=6+8zOd1DWPdsF+d{s-d^2KQUi)%H`>RQJ!rAA}EjMyvgkPh_zp_9URC zBKH>RDgL@Pq+K9w3-sSlQ~b7ej_UlOmL3f&zf@+z%5TPI;^gBqEi+T(Q9Lp8viN8y zIx{Ignv@Yw8MA%j?+pr)|2X(|uRp=$!j|J&FcxqSxmX!6I6$(id^D6e%}*Og6rZv8%j<8O&rrZn1$Rkfrzwe%kvMQRWd z9I7NpsUkE|)STKw4&~5GtNa0x+C!zPO4SS56H?UX>NY`0Jw%;1HV`h|!*71`oA=(# zym|Z9dhd0Go(6+D;osvEHwsVn^-v3)UQc$O9+g2JRX|A)lbX_)3aTla%9u9l1K+3) zI@eY3n;HaKNH;Nkhlr{7doU0o5L{m#Zt}X`!O}HX$`ocCSDUCfg>orhT>tqp{fYI^ zUK(ZR>EV@_sT?8;(t-yxQ!>>QHRGmMSA1OYaizi3>x#ao`0I*)&nr+@0%oHbL=Hi- z$qWe=GMmk?U|}-?kvYj|aeaf8!Z1@kv-A!tdQJtVl-lVDxuCHJp6qFPcRoi9^{h+N zOX`r72MZEQk$k06EX?HbPU!`^Tw011EysbQm21vCmyV@gV;QeOmsrW$OBHt4+rb`q zlgFc&ofrpO;*j8<`%e0zZGoL$$d}S17R;vWf$}q-<>xG$C8TWo7&l%e)c|Qt+zM{C zc74-%xAVLBAFW4kO1DBk-jMEje=Rj@u^v}^~OapoJDoIIBWCQW>q-WS=pLFFoH`}1Xdc!N?!Vz@u=3sj{@H7AhbiH$y;|BKw z^67hM)W~6#Y=+~vm)DlR&VJi>bNOL-;Gs4EoqT13vZ9{NNarTZNqCbB(2weiTj3oK zZY5R9w&RBO_rm!&o6tLGCqMZ}%U&QjdBB|G_E+6-IH6>+j}e|tB8rxxr(3|8Yy{^VXVlZFK6 z(G53y!7|Ezja@O?QN*%_0fbWsg9up!d>1&2aDh$7+g9F3if<~a7chka!kpOmIb0`? zr17dwT8+)tSE|0KtZzjdsxlv|3W+yWRb(28HH$TFsE4-wsP&QF)aH@&-@%03uoa0{ zWj=D3aIC5#(@8j4^{*fekf>2bvucRw!R;`z3?&&lLU#z2PjIhcdPeYm&WGr`g7pT)-%N;w>{IS+_0lixv_R7yp0ipOMGzkT<+6FXA!Ji4)BL{73W%|z*ZZy6>O4# zowL?y{5fm4k=UZwh;{;>pg|5nB diff --git a/src/lettr/__pycache__/_exceptions.cpython-313.pyc b/src/lettr/__pycache__/_exceptions.cpython-313.pyc index 56cb55005cf11b541d8edd9b972672edb4785884..12d74f80828a562ed99eb363dfbdcc33c1b12a80 100644 GIT binary patch delta 2379 zcmb7F&2JM&6yNdp=h|xr5+`x9354Lt!XY1gq>$3qP+C$HVcbAMx;6F^HUvAJ-2kyF zxxH|z)I_67Tq<$Rp@*tH=0E5qRZeVC#U+QThgvF#R_dYeJvZr+g4B+*zn%HboA=(# zoA>6IL+e@jw`epZL7V^MvGHAaP3~aLwSk_7Ds@PGQo++H6};EHV{R0YB<>^D2dw{^ zZNn0iIMdsDBfrE*y}P1P{f8#|)v z;nAUy%n>qok@vxpy_OFmhk*KaEf2LUBVUFOny@1=@ww+Z>kz+szL*FCm2ulX9epu* z7=}E8oQFrTk4MM{5$NPR&{H84t;36m4R4xt3EO*!Dc>?*gRz+S%s1>oP(9%E00N_;CaZVZIx?X#nTN7(fyMqi((q@cVt~k!P$K zl5!(Wzt7wEL}_b!;otO0N>mLg=JRXGM4_#L-wUl+Z$~Hyj=Ra`f(Pk?hCWCSW&4JU9wbYD@uUg5zmeV zon`jbC+Nbc)=gb4EiG}8j?WBXDXK?{)mzot-Kt9IKo{9q1>c}<;Czc%1HuquE#9dS ziS@_$8p)>sb-#M}{N>A2Y9?Zb4AZEZR;gOnZ4VYGdQ5Ehyl&CWrgwl!GG#j@Emah{ zDtt;rl+@U+;hih0Qfam093KP?9smtYSj#4#1xF^~GZReAD7t7%B)fYb4s6MTn}NY6 z{={l|%b$8IcR!fklKVFU{o+R@Ew+_hu>0Zk4|0ApkQeDhmWu7gjE`RhRkquxT6_k2 z923M*N#pdy*&$84v1~1KUDG%|!nV&^Su(2gByI=KLfS1^My=XEaoIyQX< zhm<5!RK9R4d)_ZQaO!rgvb?BI@Q+{?j#@Gs08KYz?2**9E%j|n`$aU_8Di|{>daFK bexg5ltQ)#s*7r0HusTTnXYm8<1SQ;GSm4u? delta 1988 zcmaJ?O>7%Q6rQnn*T3uaZ{qwou}#y&m8qkK{E*T#pp=Fbr3fb_!i_9rFOfsA%j`xX z;oxv7Ac5LwRJAua5|sl|uc#6i&Qz#^u6n4DDh@=*BB{~~67S8D#0rIx=9@R)jNiPS z@6Gd8_V?p~9aZ%bXg`d7Qhn^%48-Y=oBh3w8JWp53R7lW%#|c@k|uenljP;g?qMfx zCxm$fs{re|>^We)f^`GyX<>bW^#bc_Vf}*j1FN>MDzKv@9{^fwp#x%b5ZF))tL3Bl za3@)oSa2zliScfl?UpR(%e6I2nyQvfOM1O(n3ntE6|-8el~(vHeKM*-_?D+wEU%Od zqgXWXi;~F=YbrNy=*-A1muk6bomFxxx@ofKuawH~m6mjaZ#ep?#=mw2nmE9gkuO|w{VfhDMAps zAp=|`52XovN16}HAJHiPM!q)Thoy|uQgp`Z%wPeSG7UMn7PK*3b^<{}DS?%Q54Ho< z_?nWS?fj;4oJQ~5RT`9r`Gk9%rudrsQbK&HL-?^=72PaVR}8};Jg_j|cK6X<9`-CY zFzgJqPZ9Ab_OUTM7SCSq*%zUJ$`lNfWs|55T8oQ|tPOXzqZ@~9<4-dl_Mdl#pU6fL zdPoDX7tJI;)CM`%kdC?W;#`W#|VDWaoFCef*kNqXYa)@8B3JIm`M0kBT~u9Krzf15sbFJ&*I- z&M|)0r_n6G=sWd4ZqA?^glwyu5!=lf$c+yEm3o@1{tzAHy?(QSnrt~OugH;Nxn9u^ z^JJ?vPo~iGv+@LZEB;EU@}~a5s&1I)FxgHPi`81yEEb1gbq57|x05ENChnxk2)Dp70yNJF<_Ef`{J=wNo!Ig;GA>_yQp% zWL0Eieldqw3Yh((Eu?sFC{|a8vHC#E2VQjO;4iOD5q!(&*g*vtcJ$F1JAe` zIl5ZFS|i%LBS7-uc>*X8N&6@0G!x+;hnK0!x5II|$-5$*je{any$*rE8x{mgTipd> z-IW}eo|}6knekfQs!^>OW~o-zEvW*oH0HMzTMV?5-ly^!1Ki0>f^Q(zCZt>=GFG&#{pp Om_R=XCg_+*(!T*yHF-4v diff --git a/src/lettr/__pycache__/_types.cpython-313.pyc b/src/lettr/__pycache__/_types.cpython-313.pyc index 401d4e086a0ae2bf234b8c0ff100a288a1777aca..763223f00e36e9d1b44e81ef928c52df1c4620b5 100644 GIT binary patch literal 15217 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 DFs38v diff --git a/src/lettr/_client.py b/src/lettr/_client.py index 6fb861c..4839a64 100644 --- a/src/lettr/_client.py +++ b/src/lettr/_client.py @@ -2,7 +2,7 @@ from __future__ import annotations -from typing import Any, Dict, Optional +from typing import Any import httpx @@ -23,6 +23,7 @@ def __init__( ) -> None: self._api_key = api_key self._base_url = base_url.rstrip("/") + self._timeout = timeout self._http = httpx.Client( base_url=self._base_url, timeout=timeout, @@ -30,7 +31,7 @@ def __init__( "Authorization": f"Bearer {api_key}", "Content-Type": "application/json", "Accept": "application/json", - "User-Agent": "lettr-python/0.1.0", + "User-Agent": "lettr-python/0.2.0", }, ) @@ -41,8 +42,8 @@ def request( method: str, path: str, *, - json: Optional[Dict[str, Any]] = None, - params: Optional[Dict[str, Any]] = None, + json: dict[str, Any] | None = None, + params: dict[str, Any] | None = None, ) -> Any: """Send an HTTP request and return the decoded JSON body. @@ -70,23 +71,53 @@ def request( raise_for_status(response.status_code, body) return body - def get(self, path: str, *, params: Optional[Dict[str, Any]] = None) -> Any: + def get(self, path: str, *, params: dict[str, Any] | None = None) -> Any: return self.request("GET", path, params=params) - def post(self, path: str, *, json: Optional[Dict[str, Any]] = None) -> Any: + def post(self, path: str, *, json: dict[str, Any] | None = None) -> Any: return self.request("POST", path, json=json) - def put(self, path: str, *, json: Optional[Dict[str, Any]] = None) -> Any: + def put(self, path: str, *, json: dict[str, Any] | None = None) -> Any: return self.request("PUT", path, json=json) - def delete(self, path: str, *, params: Optional[Dict[str, Any]] = None) -> Any: + def delete(self, path: str, *, params: dict[str, Any] | None = None) -> Any: return self.request("DELETE", path, params=params) + def get_no_auth(self, path: str, *, params: dict[str, Any] | None = None) -> Any: + """Send a GET request without the Authorization header. + + Used for endpoints that don't require authentication (e.g. health check). + """ + if params: + params = {k: v for k, v in params.items() if v is not None} + + try: + response = httpx.get( + f"{self._base_url}{path}", + params=params, + timeout=self._timeout, + headers={ + "Accept": "application/json", + "User-Agent": "lettr-python/0.2.0", + }, + ) + except httpx.HTTPError as exc: + raise LettrError(f"HTTP request failed: {exc}") from exc + + try: + body = response.json() + except Exception: + raise_for_status(response.status_code, None) + return None + + raise_for_status(response.status_code, body) + return body + def close(self) -> None: """Close the underlying HTTP connection pool.""" self._http.close() - def __enter__(self) -> "ApiClient": + def __enter__(self) -> ApiClient: return self def __exit__(self, *args: Any) -> None: diff --git a/src/lettr/_exceptions.py b/src/lettr/_exceptions.py index 0659c3f..b8e30f1 100644 --- a/src/lettr/_exceptions.py +++ b/src/lettr/_exceptions.py @@ -2,7 +2,7 @@ from __future__ import annotations -from typing import Any, Dict, List, Optional +from typing import Any class LettrError(Exception): @@ -17,13 +17,17 @@ class AuthenticationError(LettrError): """Raised when the API key is missing or invalid (401).""" +class ForbiddenError(LettrError): + """Raised when access is forbidden (403).""" + + class ValidationError(LettrError): """Raised when request validation fails (422).""" def __init__( self, message: str, - errors: Optional[Dict[str, List[str]]] = None, + errors: dict[str, list[str]] | None = None, ) -> None: self.errors = errors or {} super().__init__(message) @@ -48,7 +52,15 @@ class ConflictError(LettrError): class BadRequestError(LettrError): """Raised for client-side errors (400).""" - def __init__(self, message: str, error_code: Optional[str] = None) -> None: + def __init__(self, message: str, error_code: str | None = None) -> None: + self.error_code = error_code + super().__init__(message) + + +class RateLimitError(LettrError): + """Raised when rate limit or quota is exceeded (429).""" + + def __init__(self, message: str, error_code: str | None = None) -> None: self.error_code = error_code super().__init__(message) @@ -56,7 +68,7 @@ def __init__(self, message: str, error_code: Optional[str] = None) -> None: class ServerError(LettrError): """Raised for server-side errors (500, 502).""" - def __init__(self, message: str, error_code: Optional[str] = None) -> None: + def __init__(self, message: str, error_code: str | None = None) -> None: self.error_code = error_code super().__init__(message) @@ -72,9 +84,15 @@ def raise_for_status(status_code: int, body: Any) -> None: message = body.get("message", "Unknown error") error_code = body.get("error_code") + if status_code == 400: + raise BadRequestError(message=message, error_code=error_code) + if status_code == 401: raise AuthenticationError(message) + if status_code == 403: + raise ForbiddenError(message) + if status_code == 404: raise NotFoundError(message) @@ -84,8 +102,8 @@ def raise_for_status(status_code: int, body: Any) -> None: if status_code == 422: raise ValidationError(message=message, errors=body.get("errors")) - if status_code == 400: - raise BadRequestError(message=message, error_code=error_code) + if status_code == 429: + raise RateLimitError(message=message, error_code=error_code) if status_code >= 500: raise ServerError(message=message, error_code=error_code) diff --git a/src/lettr/_types.py b/src/lettr/_types.py index 22a2067..529f8e2 100644 --- a/src/lettr/_types.py +++ b/src/lettr/_types.py @@ -2,8 +2,68 @@ from __future__ import annotations -from dataclasses import dataclass, field -from typing import Any, Dict, List, Optional +from dataclasses import dataclass, fields +from typing import Any, TypeVar + +T = TypeVar("T") + + +def _from_dict(cls: type[T], data: dict[str, Any]) -> T: + """Build a dataclass instance from a dict, ignoring unknown keys. + + Server payloads may add new fields over time; using bare ``cls(**data)`` + crashes on the first unknown key. This helper filters ``data`` to only + the fields declared on ``cls`` so that forward-compatible parsing works. + """ + known = {f.name for f in fields(cls)} # type: ignore[arg-type] + return cls(**{k: v for k, v in data.items() if k in known}) + +# --------------------------------------------------------------------------- +# Common / shared types +# --------------------------------------------------------------------------- + + +@dataclass +class HealthCheck: + """Response from the health check endpoint.""" + + status: str + timestamp: str + + +@dataclass +class AuthCheck: + """Response from the auth check endpoint.""" + + team_id: int + timestamp: str + + +@dataclass +class GeoIp: + """Geolocation data associated with an email event.""" + + country: str | None = None + region: str | None = None + city: str | None = None + latitude: float | None = None + longitude: float | None = None + zip: str | None = None + postal_code: str | None = None + + +@dataclass +class UserAgentParsed: + """Parsed user-agent data associated with an email event.""" + + agent_family: str | None = None + device_brand: str | None = None + device_family: str | None = None + os_family: str | None = None + os_version: str | None = None + is_mobile: bool | None = None + is_proxy: bool | None = None + is_prefetched: bool | None = None # --------------------------------------------------------------------------- @@ -29,13 +89,13 @@ class Attachment: class EmailOptions: """Delivery options for an email.""" - click_tracking: Optional[bool] = None - open_tracking: Optional[bool] = None - transactional: Optional[bool] = None - inline_css: Optional[bool] = None - perform_substitutions: Optional[bool] = None + click_tracking: bool | None = None + open_tracking: bool | None = None + transactional: bool | None = None + inline_css: bool | None = None + perform_substitutions: bool | None = None - def to_dict(self) -> Dict[str, Any]: + def to_dict(self) -> dict[str, Any]: return {k: v for k, v in self.__dict__.items() if v is not None} @@ -52,53 +112,105 @@ class SendEmailResponse: class Email: """A sent email event.""" - event_id: Optional[str] = None - timestamp: Optional[str] = None - request_id: Optional[str] = None - message_id: Optional[str] = None - subject: Optional[str] = None - friendly_from: Optional[str] = None - sending_domain: Optional[str] = None - rcpt_to: Optional[str] = None - raw_rcpt_to: Optional[str] = None - recipient_domain: Optional[str] = None - mailbox_provider: Optional[str] = None - mailbox_provider_region: Optional[str] = None - sending_ip: Optional[str] = None - click_tracking: Optional[bool] = None - open_tracking: Optional[bool] = None - transactional: Optional[bool] = None - msg_size: Optional[int] = None - injection_time: Optional[str] = None - rcpt_meta: Optional[Dict[str, Any]] = None + event_id: str | None = None + type: str | None = None + timestamp: str | None = None + request_id: str | None = None + message_id: str | None = None + subject: str | None = None + friendly_from: str | None = None + sending_domain: str | None = None + rcpt_to: str | None = None + raw_rcpt_to: str | None = None + recipient_domain: str | None = None + mailbox_provider: str | None = None + mailbox_provider_region: str | None = None + sending_ip: str | None = None + click_tracking: bool | None = None + open_tracking: bool | None = None + transactional: bool | None = None + msg_size: int | None = None + injection_time: str | None = None + rcpt_meta: dict[str, Any] | None = None @dataclass class EmailEvent(Email): """A detailed email event including type and error info.""" - type: Optional[str] = None - reason: Optional[str] = None - raw_reason: Optional[str] = None - error_code: Optional[str] = None + reason: str | None = None + raw_reason: str | None = None + error_code: str | None = None + campaign_id: str | None = None + template_id: str | None = None + template_version: str | None = None + ip_pool: str | None = None + msg_from: str | None = None + rcpt_type: str | None = None + rcpt_tags: list[str] | None = None + amp_enabled: bool | None = None + delv_method: str | None = None + recv_method: str | None = None + routing_domain: str | None = None + scheduled_time: str | None = None + ab_test_id: str | None = None + ab_test_version: str | None = None + geo_ip: GeoIp | None = None + user_agent_parsed: UserAgentParsed | None = None @dataclass class EmailList: """Paginated list of sent emails.""" - results: List[Email] + results: list[Email] total_count: int - next_cursor: Optional[str] = None + next_cursor: str | None = None per_page: int = 25 + date_from: str | None = None + date_to: str | None = None @dataclass class EmailDetail: - """Detailed email with all events.""" + """Detailed email with its delivery events.""" + + transmission_id: str + state: str + from_email: str + subject: str + recipients: list[str] + num_recipients: int + events: list[EmailEvent] + scheduled_at: str | None = None + from_name: str | None = None + - results: List[EmailEvent] +@dataclass +class EmailEventList: + """Paginated list of email events.""" + + results: list[EmailEvent] total_count: int + next_cursor: str | None = None + per_page: int = 25 + date_from: str | None = None + date_to: str | None = None + + +@dataclass +class ScheduledEmail: + """A scheduled email transmission (same shape as :class:`EmailDetail`).""" + + transmission_id: str + state: str + from_email: str + subject: str + recipients: list[str] + num_recipients: int + events: list[EmailEvent] + scheduled_at: str | None = None + from_name: str | None = None # --------------------------------------------------------------------------- @@ -110,9 +222,35 @@ class EmailDetail: class DkimInfo: """DKIM configuration for a domain.""" - public: Optional[str] = None - selector: Optional[str] = None - headers: Optional[str] = None + public: str | None = None + selector: str | None = None + headers: str | None = None + signing_domain: str | None = None + + +@dataclass +class DmarcValidationResult: + """DMARC DNS validation result.""" + + is_valid: bool | None = None + status: str | None = None + found_at_domain: str | None = None + record: str | None = None + policy: str | None = None + subdomain_policy: str | None = None + error: str | None = None + covered_by_parent_policy: bool | None = None + + +@dataclass +class SpfValidationResult: + """SPF DNS validation result.""" + + is_valid: bool | None = None + status: str | None = None + record: str | None = None + error: str | None = None + includes_sparkpost: bool | None = None @dataclass @@ -122,14 +260,18 @@ class Domain: domain: str status: str status_label: str - can_send: Optional[bool] = None - cname_status: Optional[str] = None - dkim_status: Optional[str] = None - tracking_domain: Optional[str] = None - dns: Optional[Dict[str, Any]] = None - dkim: Optional[DkimInfo] = None - created_at: Optional[str] = None - updated_at: Optional[str] = None + can_send: bool | None = None + cname_status: str | None = None + dkim_status: str | None = None + dmarc_status: str | None = None + spf_status: str | None = None + is_primary_domain: bool | None = None + tracking_domain: str | None = None + dns: dict[str, Any] | None = None + dns_provider: str | None = None + dkim: DkimInfo | None = None + created_at: str | None = None + updated_at: str | None = None @dataclass @@ -139,8 +281,13 @@ class DomainVerification: domain: str dkim_status: str cname_status: str - ownership_verified: Optional[str] = None - dns: Optional[Dict[str, Any]] = None + dmarc_status: str | None = None + spf_status: str | None = None + is_primary_domain: bool | None = None + ownership_verified: str | None = None + dns: dict[str, Any] | None = None + dmarc: DmarcValidationResult | None = None + spf: SpfValidationResult | None = None # --------------------------------------------------------------------------- @@ -158,10 +305,10 @@ class Webhook: enabled: bool auth_type: str has_auth_credentials: bool - event_types: Optional[List[str]] = None - last_successful_at: Optional[str] = None - last_failure_at: Optional[str] = None - last_status: Optional[str] = None + event_types: list[str] | None = None + last_successful_at: str | None = None + last_failure_at: str | None = None + last_status: str | None = None # --------------------------------------------------------------------------- @@ -174,7 +321,7 @@ class MergeTagChild: """A child merge tag within a loop block.""" key: str - type: Optional[str] = None + type: str | None = None @dataclass @@ -183,8 +330,8 @@ class MergeTag: key: str required: bool = False - type: Optional[str] = None - children: Optional[List[MergeTagChild]] = None + type: str | None = None + children: list[MergeTagChild] | None = None @dataclass @@ -197,19 +344,19 @@ class Template: project_id: int folder_id: int created_at: str - updated_at: Optional[str] = None - active_version: Optional[int] = None - versions_count: Optional[int] = None - html: Optional[str] = None - json: Optional[str] = None - merge_tags: Optional[List[MergeTag]] = None + updated_at: str | None = None + active_version: int | None = None + versions_count: int | None = None + html: str | None = None + json: str | None = None + merge_tags: list[MergeTag] | None = None @dataclass class TemplateList: """Paginated list of templates.""" - templates: List[Template] + templates: list[Template] total: int per_page: int current_page: int @@ -222,7 +369,8 @@ class TemplateMergeTags: template_slug: str version: int - merge_tags: List[MergeTag] + merge_tags: list[MergeTag] + project_id: int | None = None # --------------------------------------------------------------------------- @@ -238,15 +386,15 @@ class Project: name: str team_id: int created_at: str - emoji: Optional[str] = None - updated_at: Optional[str] = None + emoji: str | None = None + updated_at: str | None = None @dataclass class ProjectList: """Paginated list of projects.""" - projects: List[Project] + projects: list[Project] total: int per_page: int current_page: int diff --git a/src/lettr/resources/__pycache__/__init__.cpython-313.pyc b/src/lettr/resources/__pycache__/__init__.cpython-313.pyc index 8ba3db93066a3a91cc5bb95392ed1dd0f94169a5..9dc38ec7b39aa7c5800bce8831ed53752ea4c11e 100644 GIT binary patch delta 62 zcmcb`{D7JJGcPX}0}vc|duJoJ7o%#RerR!OQL%nmepX3paz<8uX;q@WOKNd;Nq&KT QKv8~HYIaHGJpIv diff --git a/src/lettr/resources/__pycache__/domains.cpython-313.pyc b/src/lettr/resources/__pycache__/domains.cpython-313.pyc index ba1dd462df8aed56faff8f035dde90ee860d91f8..30e6c2a5e241614c0200fb26b154f1f085c39034 100644 GIT binary patch delta 2479 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 diff --git a/src/lettr/resources/__pycache__/emails.cpython-313.pyc b/src/lettr/resources/__pycache__/emails.cpython-313.pyc index 1bf6a26aced1a56e7ca97845497d40d3346f8c4a..bc301bcd360e26145eab0df22354617f53979b7c 100644 GIT binary patch literal 20772 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 LeJt<~&!qnis17*l delta 3552 zcmai0S!^5E6@5bv$>9tsQrtu>7Kd6aQX6f@l5E9^EibXH#d17g>Qoj~S{f^KD3X3N z3Sz+k^VP~o+S(nbb>WXHZqdqVffn#jx}i<`QS_sL38(0E;E!|(iu7w_H!y6VK<|B& zSQ!n_0p`v*_q})Da+f#XyUqP}B=yMe7YRK7-uIRK-c~~XhGgsEYk+&Clk2ID#JHQf z$34_DE>J-x3a_{(yyGGj8Fo)x&+llFNt|>L#nVC*VaQP%PQ#1dv}jCn&yBh)=UDNw z#UIVj>lQzm*K|vmF6;SHF;}nzMZL7F7U$JZabTLWykq73kwRWA>S>ShU2eemm}@@N zsHnw-V@tVw;f$)4OGQnc-&!ty8*7AnWC3`WoFkM|2z4kNbt(?ZE0VFlE$ws-28~N? zRsg10n5!M)IJU0mD-=@Wb0%mtEp7W zT+9_SCsewSDX6+m2g=iX8g$AZ%igyGBPYe^MH7Zm`yvr>U#u`oJt2R{xyB6 zVDY;8rfzvJ(9+UeF}I`|`PlPAR!~=$%7vV+&bfFnMaD(o z0^<_n5^yi$KE{2(MaKP%`+-Z0H!$9?7Zg6G2rxwexS#PL<3ZpJj5jjg2s~h%iuZ9r zVX?FEu<$yIsd8}eW8eluC+2DbTjR6erlXgR+C zDUiRASE+oVL}i^qnB|L$#yg#R?*6m$LoS+jT091k<g)Rd?X6X*$Lt+lJ$^5;v+C_U+hu2}Ztt|Z# zUTZc+Q~P#5dFY2Onj6miH<`hsF+T0GgtAKK%1|vW_xv)|N)+R4K3~RiuNl`;Nzn`4 zSqVFB{5g<%T)qN_sV^+HM3I*D_LqN~M+PSFe)Q5Vk25BD)ZPsVod@o`9aTF&}974fzjiWW|6QI%1ioETi z5X;Skj)J-#%a~|i!_FTW;=sitfiOhsl~w-Zp1$>yw=4HUBeyGS$s0r0M{n-FG5TI* z)pIX2Vn&<4b@uw%n}zkm_oJCr@$Z4S8EG}+Z8vAD@jl~c11&o?ysn^V6PH(f^3X%V z`k)4Ga-h3XSE=>&E(UyP@xzsjLW_ zX{kwkB{QmU)(1G`-*Dl={ zy?$_wyK(T&G`i`)gFwsL$Op64eG}Edh5P%d}|mr%FXNouV&5ljVdm%Yu8wzx9VT1FBiN8wY*?MLUS6{;fZ;T5AFu&Z3a( zC<*9kQ@!6a<|?za13SKoqO(4vtB$(sXlEVarMqq7d{LjJDQx{B3U+z1CEyLV60c8; z|BtxJei=71%kH+y=eEP|CbZ+Vw-T!lXD+KTZI)dnEA92h`liNoJvaZ_5(FDq$l`j~ zW#y|SGg~05AZ&rh2%L58xL9&CCChMz*l<=5_}XpBj-6>)=!wSfP96-x|2LsyC@{zA zX`^?%wSoalcc5TFhd*}|1-{w}++bfO8xGMW{4376+U*cYbi$2gcJ`P%d(ECcvt#Ff zW$&Qbl`^~2=D==qc+>t_Q@d(KJVK;$mm zK7eHRMxdW<8dl*!B#_2-+lxrVi6uln+?sy?VFL*zY&IlF#PDa0pnH6d3%I7ZnrF}# zr_vERXYkWKGTw6(OBxkW;I#nxiRn3qe_Wa|G2NA8etg!qmT+LHw6I)IU!)7r9b8K^ u%u(CPaonFt$0wxYA7uECWcU-3c6`&;7Nz2;V| zIBq_Tx>{{N>PCZS51MN#-qTUPxyeyi?djpcLhf}=&Y`}Tyt6vx(~wnQ0jL2Xa3)!? z&%dZZ)`6_%EYzYw!{QUhr1@Q?*@4%E*W+pEXtyZA3lQklDOney69y=OJ8NMCv8r z0^lY~&YC<4xtu(4s)bXK7|8eu_LDbGWi?#7O386O!1ZEhg0w3MgCmyz8>~f{QqT+=UhX=zuBp~`Z(Gd?5o>+(*%UsF+O3E b7eaib(wORFDvqi4nHop(hVZ|UL0118E@OMN delta 618 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 MQsUa543QZB2FYyNQUCw| diff --git a/src/lettr/resources/__pycache__/webhooks.cpython-313.pyc b/src/lettr/resources/__pycache__/webhooks.cpython-313.pyc index 1d58cddf48ea01ee34c8f9b0529485129a38f365..5242d06796399be9bc7f8609b3b553e90779abdb 100644 GIT binary patch literal 6775 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 U&rr|bsQYE`0wcUYK-~5J0WgGUj{pDw diff --git a/src/lettr/resources/domains.py b/src/lettr/resources/domains.py index fbff04b..5374a91 100644 --- a/src/lettr/resources/domains.py +++ b/src/lettr/resources/domains.py @@ -2,10 +2,17 @@ from __future__ import annotations -from typing import List +import builtins from .._client import ApiClient -from .._types import DkimInfo, Domain, DomainVerification +from .._types import ( + DkimInfo, + DmarcValidationResult, + Domain, + DomainVerification, + SpfValidationResult, + _from_dict, +) class Domains: @@ -21,7 +28,7 @@ class Domains: def __init__(self, client: ApiClient) -> None: self._client = client - def list(self) -> List[Domain]: + def list(self) -> builtins.list[Domain]: """List all sending domains. Returns: @@ -36,6 +43,8 @@ def list(self) -> List[Domain]: can_send=d.get("can_send"), cname_status=d.get("cname_status"), dkim_status=d.get("dkim_status"), + dmarc_status=d.get("dmarc_status"), + spf_status=d.get("spf_status"), created_at=d.get("created_at"), updated_at=d.get("updated_at"), ) @@ -63,8 +72,12 @@ def get(self, domain: str) -> Domain: can_send=d.get("can_send"), cname_status=d.get("cname_status"), dkim_status=d.get("dkim_status"), + dmarc_status=d.get("dmarc_status"), + spf_status=d.get("spf_status"), + is_primary_domain=d.get("is_primary_domain"), tracking_domain=d.get("tracking_domain"), dns=d.get("dns"), + dns_provider=d.get("dns_provider"), created_at=d.get("created_at"), updated_at=d.get("updated_at"), ) @@ -89,7 +102,7 @@ def create(self, domain: str) -> Domain: d = body["data"] dkim = None if d.get("dkim"): - dkim = DkimInfo(**d["dkim"]) + dkim = _from_dict(DkimInfo, d["dkim"]) return Domain( domain=d["domain"], status=d["status"], @@ -109,7 +122,7 @@ def delete(self, domain: str) -> None: self._client.delete(f"/domains/{domain}") def verify(self, domain: str) -> DomainVerification: - """Verify a domain's DNS records (DKIM and CNAME). + """Verify a domain's DNS records (DKIM, CNAME, SPF, DMARC). Args: domain: The domain name to verify. @@ -122,10 +135,24 @@ def verify(self, domain: str) -> DomainVerification: """ body = self._client.post(f"/domains/{domain}/verify") d = body["data"] + + dmarc = None + if d.get("dmarc"): + dmarc = _from_dict(DmarcValidationResult, d["dmarc"]) + + spf = None + if d.get("spf"): + spf = _from_dict(SpfValidationResult, d["spf"]) + return DomainVerification( domain=d["domain"], dkim_status=d["dkim_status"], cname_status=d["cname_status"], + dmarc_status=d.get("dmarc_status"), + spf_status=d.get("spf_status"), + is_primary_domain=d.get("is_primary_domain"), ownership_verified=d.get("ownership_verified"), dns=d.get("dns"), + dmarc=dmarc, + spf=spf, ) diff --git a/src/lettr/resources/emails.py b/src/lettr/resources/emails.py index 41167bb..74559b7 100644 --- a/src/lettr/resources/emails.py +++ b/src/lettr/resources/emails.py @@ -2,7 +2,7 @@ from __future__ import annotations -from typing import Any, Dict, List, Optional, Sequence +from typing import Any, Sequence from .._client import ApiClient from .._types import ( @@ -10,12 +10,181 @@ Email, EmailDetail, EmailEvent, + EmailEventList, EmailList, EmailOptions, + GeoIp, + ScheduledEmail, SendEmailResponse, + UserAgentParsed, + _from_dict, ) +def _parse_email(r: dict[str, Any]) -> Email: + """Parse a raw dict into an Email.""" + return Email( + event_id=r.get("event_id"), + type=r.get("type"), + timestamp=r.get("timestamp"), + request_id=r.get("request_id"), + message_id=r.get("message_id"), + subject=r.get("subject"), + friendly_from=r.get("friendly_from"), + sending_domain=r.get("sending_domain"), + rcpt_to=r.get("rcpt_to"), + raw_rcpt_to=r.get("raw_rcpt_to"), + recipient_domain=r.get("recipient_domain"), + mailbox_provider=r.get("mailbox_provider"), + mailbox_provider_region=r.get("mailbox_provider_region"), + sending_ip=r.get("sending_ip"), + click_tracking=r.get("click_tracking"), + open_tracking=r.get("open_tracking"), + transactional=r.get("transactional"), + msg_size=r.get("msg_size"), + injection_time=r.get("injection_time"), + rcpt_meta=r.get("rcpt_meta"), + ) + + +def _parse_email_event(r: dict[str, Any]) -> EmailEvent: + """Parse a raw dict into an EmailEvent, handling nested objects.""" + geo_ip = None + if r.get("geo_ip"): + geo_ip = _from_dict(GeoIp, r["geo_ip"]) + user_agent_parsed = None + if r.get("user_agent_parsed"): + user_agent_parsed = _from_dict(UserAgentParsed, r["user_agent_parsed"]) + + return EmailEvent( + event_id=r.get("event_id"), + type=r.get("type"), + timestamp=r.get("timestamp"), + request_id=r.get("request_id"), + message_id=r.get("message_id"), + subject=r.get("subject"), + friendly_from=r.get("friendly_from"), + sending_domain=r.get("sending_domain"), + rcpt_to=r.get("rcpt_to"), + raw_rcpt_to=r.get("raw_rcpt_to"), + recipient_domain=r.get("recipient_domain"), + mailbox_provider=r.get("mailbox_provider"), + mailbox_provider_region=r.get("mailbox_provider_region"), + sending_ip=r.get("sending_ip"), + click_tracking=r.get("click_tracking"), + open_tracking=r.get("open_tracking"), + transactional=r.get("transactional"), + msg_size=r.get("msg_size"), + injection_time=r.get("injection_time"), + rcpt_meta=r.get("rcpt_meta"), + reason=r.get("reason"), + raw_reason=r.get("raw_reason"), + error_code=r.get("error_code"), + campaign_id=r.get("campaign_id"), + template_id=r.get("template_id"), + template_version=r.get("template_version"), + ip_pool=r.get("ip_pool"), + msg_from=r.get("msg_from"), + rcpt_type=r.get("rcpt_type"), + rcpt_tags=r.get("rcpt_tags"), + amp_enabled=r.get("amp_enabled"), + delv_method=r.get("delv_method"), + recv_method=r.get("recv_method"), + routing_domain=r.get("routing_domain"), + scheduled_time=r.get("scheduled_time"), + ab_test_id=r.get("ab_test_id"), + ab_test_version=r.get("ab_test_version"), + geo_ip=geo_ip, + user_agent_parsed=user_agent_parsed, + ) + + +def _parse_scheduled_email(data: dict[str, Any]) -> ScheduledEmail: + """Parse a raw dict into a ScheduledEmail.""" + return ScheduledEmail( + transmission_id=data["transmission_id"], + state=data["state"], + from_email=data["from"], + subject=data["subject"], + recipients=data["recipients"], + num_recipients=data["num_recipients"], + events=[_parse_email_event(e) for e in data["events"]], + scheduled_at=data.get("scheduled_at"), + from_name=data.get("from_name"), + ) + + +def _build_email_payload( + *, + from_email: str, + to: Sequence[str], + subject: str | None = None, + html: str | None = None, + text: str | None = None, + from_name: str | None = None, + cc: Sequence[str] | None = None, + bcc: Sequence[str] | None = None, + reply_to: str | None = None, + reply_to_name: str | None = None, + amp_html: str | None = None, + template_slug: str | None = None, + template_version: int | None = None, + project_id: int | None = None, + tag: str | None = None, + metadata: dict[str, str] | None = None, + headers: dict[str, str] | None = None, + substitution_data: dict[str, Any] | None = None, + options: EmailOptions | None = None, + attachments: Sequence[Attachment] | None = None, +) -> dict[str, Any]: + """Build the JSON payload for sending or scheduling an email.""" + payload: dict[str, Any] = { + "from": from_email, + "to": list(to), + } + + if subject is not None: + payload["subject"] = subject + if html is not None: + payload["html"] = html + if text is not None: + payload["text"] = text + if from_name is not None: + payload["from_name"] = from_name + if cc is not None: + payload["cc"] = list(cc) + if bcc is not None: + payload["bcc"] = list(bcc) + if reply_to is not None: + payload["reply_to"] = reply_to + if reply_to_name is not None: + payload["reply_to_name"] = reply_to_name + if amp_html is not None: + payload["amp_html"] = amp_html + if template_slug is not None: + payload["template_slug"] = template_slug + if template_version is not None: + payload["template_version"] = template_version + if project_id is not None: + payload["project_id"] = project_id + if tag is not None: + payload["tag"] = tag + if metadata is not None: + payload["metadata"] = metadata + if headers is not None: + payload["headers"] = headers + if substitution_data is not None: + payload["substitution_data"] = substitution_data + if options is not None: + payload["options"] = options.to_dict() + if attachments is not None: + payload["attachments"] = [ + {"name": a.name, "type": a.type, "data": a.data} for a in attachments + ] + + return payload + + class Emails: """Operations for sending and retrieving emails. @@ -37,23 +206,24 @@ def send( *, from_email: str, to: Sequence[str], - subject: str, - html: Optional[str] = None, - text: Optional[str] = None, - from_name: Optional[str] = None, - cc: Optional[Sequence[str]] = None, - bcc: Optional[Sequence[str]] = None, - reply_to: Optional[str] = None, - reply_to_name: Optional[str] = None, - amp_html: Optional[str] = None, - template_slug: Optional[str] = None, - template_version: Optional[int] = None, - project_id: Optional[int] = None, - campaign_id: Optional[str] = None, - metadata: Optional[Dict[str, str]] = None, - substitution_data: Optional[Dict[str, Any]] = None, - options: Optional[EmailOptions] = None, - attachments: Optional[Sequence[Attachment]] = None, + subject: str | None = None, + html: str | None = None, + text: str | None = None, + from_name: str | None = None, + cc: Sequence[str] | None = None, + bcc: Sequence[str] | None = None, + reply_to: str | None = None, + reply_to_name: str | None = None, + amp_html: str | None = None, + template_slug: str | None = None, + template_version: int | None = None, + project_id: int | None = None, + tag: str | None = None, + metadata: dict[str, str] | None = None, + headers: dict[str, str] | None = None, + substitution_data: dict[str, Any] | None = None, + options: EmailOptions | None = None, + attachments: Sequence[Attachment] | None = None, ) -> SendEmailResponse: """Send a transactional email. @@ -63,7 +233,7 @@ def send( Args: from_email: Sender email address. to: List of recipient email addresses. - subject: Email subject line. + subject: Email subject line. Required unless using a template. html: HTML content of the email. text: Plain text content of the email. from_name: Sender display name. @@ -75,8 +245,9 @@ def send( template_slug: Template slug to use for content. template_version: Specific template version number. project_id: Project ID containing the template. - campaign_id: Campaign identifier for tracking. + tag: Tracking tag (max 64 characters). metadata: Custom metadata for tracking. + headers: Custom email headers (max 10). substitution_data: Variables for template substitution. options: Delivery options (tracking, etc.). attachments: File attachments. @@ -89,47 +260,30 @@ def send( ValidationError: If required fields are missing or invalid. BadRequestError: If the sender domain is invalid or unconfigured. NotFoundError: If the template or project is not found. + RateLimitError: If sending quota is exceeded. """ - payload: Dict[str, Any] = { - "from": from_email, - "to": list(to), - "subject": subject, - } - - if html is not None: - payload["html"] = html - if text is not None: - payload["text"] = text - if from_name is not None: - payload["from_name"] = from_name - if cc is not None: - payload["cc"] = list(cc) - if bcc is not None: - payload["bcc"] = list(bcc) - if reply_to is not None: - payload["reply_to"] = reply_to - if reply_to_name is not None: - payload["reply_to_name"] = reply_to_name - if amp_html is not None: - payload["amp_html"] = amp_html - if template_slug is not None: - payload["template_slug"] = template_slug - if template_version is not None: - payload["template_version"] = template_version - if project_id is not None: - payload["project_id"] = project_id - if campaign_id is not None: - payload["campaign_id"] = campaign_id - if metadata is not None: - payload["metadata"] = metadata - if substitution_data is not None: - payload["substitution_data"] = substitution_data - if options is not None: - payload["options"] = options.to_dict() - if attachments is not None: - payload["attachments"] = [ - {"name": a.name, "type": a.type, "data": a.data} for a in attachments - ] + payload = _build_email_payload( + from_email=from_email, + to=to, + subject=subject, + html=html, + text=text, + from_name=from_name, + cc=cc, + bcc=bcc, + reply_to=reply_to, + reply_to_name=reply_to_name, + amp_html=amp_html, + template_slug=template_slug, + template_version=template_version, + project_id=project_id, + tag=tag, + metadata=metadata, + headers=headers, + substitution_data=substitution_data, + options=options, + attachments=attachments, + ) body = self._client.post("/emails", json=payload) data = body["data"] @@ -142,11 +296,11 @@ def send( def list( self, *, - per_page: Optional[int] = None, - cursor: Optional[str] = None, - recipients: Optional[str] = None, - from_date: Optional[str] = None, - to_date: Optional[str] = None, + per_page: int | None = None, + cursor: str | None = None, + recipients: str | None = None, + from_date: str | None = None, + to_date: str | None = None, ) -> EmailList: """List sent emails with cursor-based pagination. @@ -160,7 +314,7 @@ def list( Returns: An :class:`EmailList` with results and pagination info. """ - params: Dict[str, Any] = {} + params: dict[str, Any] = {} if per_page is not None: params["per_page"] = per_page if cursor is not None: @@ -173,23 +327,33 @@ def list( params["to"] = to_date body = self._client.get("/emails", params=params) - data = body["data"] + events = body["data"]["events"] - results = [Email(**r) for r in data["results"]] - pagination = data.get("pagination", {}) + results = [_parse_email(r) for r in events["data"]] + pagination = events.get("pagination", {}) return EmailList( results=results, - total_count=data["total_count"], + total_count=events["total_count"], next_cursor=pagination.get("next_cursor"), per_page=pagination.get("per_page", 25), + date_from=events.get("from"), + date_to=events.get("to"), ) - def get(self, request_id: str) -> EmailDetail: + def get( + self, + request_id: str, + *, + from_date: str | None = None, + to_date: str | None = None, + ) -> EmailDetail: """Get all events for a specific email transmission. Args: request_id: The ``request_id`` returned when the email was sent. + from_date: Start date for event search range (ISO 8601). + to_date: End date for event search range (ISO 8601). Returns: An :class:`EmailDetail` with all associated events. @@ -197,7 +361,204 @@ def get(self, request_id: str) -> EmailDetail: Raises: NotFoundError: If the email is not found. """ - body = self._client.get(f"/emails/{request_id}") + params: dict[str, Any] = {} + if from_date is not None: + params["from"] = from_date + if to_date is not None: + params["to"] = to_date + + body = self._client.get(f"/emails/{request_id}", params=params) + data = body["data"] + events = [_parse_email_event(r) for r in data["events"]] + return EmailDetail( + transmission_id=data["transmission_id"], + state=data["state"], + from_email=data["from"], + subject=data["subject"], + recipients=data["recipients"], + num_recipients=data["num_recipients"], + events=events, + scheduled_at=data.get("scheduled_at"), + from_name=data.get("from_name"), + ) + + def list_events( + self, + *, + events: Sequence[str] | None = None, + recipients: Sequence[str] | None = None, + from_date: str | None = None, + to_date: str | None = None, + per_page: int | None = None, + cursor: str | None = None, + transmissions: str | None = None, + bounce_classes: str | None = None, + ) -> EmailEventList: + """List email events with filtering. + + Args: + events: Event types to filter by (e.g. ``["delivery", "bounce"]``). + recipients: Recipient email addresses to filter by. + from_date: Start date (ISO 8601). Defaults to 10 days ago. + to_date: End date (ISO 8601). Defaults to now. + per_page: Number of events per page. + cursor: Pagination cursor from a previous response. + transmissions: Filter by transmission ID. + bounce_classes: Comma-separated bounce classification codes. + + Returns: + An :class:`EmailEventList` with events and pagination info. + """ + params: dict[str, Any] = {} + if events is not None: + params["events"] = ",".join(events) + if recipients is not None: + params["recipients"] = ",".join(recipients) + if from_date is not None: + params["from"] = from_date + if to_date is not None: + params["to"] = to_date + if per_page is not None: + params["per_page"] = per_page + if cursor is not None: + params["cursor"] = cursor + if transmissions is not None: + params["transmissions"] = transmissions + if bounce_classes is not None: + params["bounce_classes"] = bounce_classes + + body = self._client.get("/emails/events", params=params) + events = body["data"]["events"] + + results = [_parse_email_event(r) for r in events["data"]] + pagination = events.get("pagination", {}) + + return EmailEventList( + results=results, + total_count=events["total_count"], + next_cursor=pagination.get("next_cursor"), + per_page=pagination.get("per_page", 25), + date_from=events.get("from"), + date_to=events.get("to"), + ) + + def schedule( + self, + *, + from_email: str, + to: Sequence[str], + scheduled_at: str, + subject: str | None = None, + html: str | None = None, + text: str | None = None, + from_name: str | None = None, + cc: Sequence[str] | None = None, + bcc: Sequence[str] | None = None, + reply_to: str | None = None, + reply_to_name: str | None = None, + amp_html: str | None = None, + template_slug: str | None = None, + template_version: int | None = None, + project_id: int | None = None, + tag: str | None = None, + metadata: dict[str, str] | None = None, + headers: dict[str, str] | None = None, + substitution_data: dict[str, Any] | None = None, + options: EmailOptions | None = None, + attachments: Sequence[Attachment] | None = None, + ) -> SendEmailResponse: + """Schedule a transactional email for future delivery. + + Args: + from_email: Sender email address. + to: List of recipient email addresses. + scheduled_at: Scheduled delivery time (ISO 8601). + subject: Email subject line. Required unless using a template. + html: HTML content of the email. + text: Plain text content of the email. + from_name: Sender display name. + cc: Carbon copy recipients. + bcc: Blind carbon copy recipients. + reply_to: Reply-To email address. + reply_to_name: Reply-To display name. + amp_html: AMP HTML content. + template_slug: Template slug to use for content. + template_version: Specific template version number. + project_id: Project ID containing the template. + tag: Tracking tag (max 64 characters). + metadata: Custom metadata for tracking. + headers: Custom email headers (max 10). + substitution_data: Variables for template substitution. + options: Delivery options (tracking, etc.). + attachments: File attachments. + + Returns: + A :class:`SendEmailResponse` with ``request_id``, ``accepted``, + and ``rejected`` counts. Use :meth:`get_scheduled` with the + ``request_id`` to retrieve the full scheduled transmission. + + Raises: + ValidationError: If required fields are missing or invalid. + ForbiddenError: If scheduling is not permitted. + RateLimitError: If sending quota is exceeded. + """ + payload = _build_email_payload( + from_email=from_email, + to=to, + subject=subject, + html=html, + text=text, + from_name=from_name, + cc=cc, + bcc=bcc, + reply_to=reply_to, + reply_to_name=reply_to_name, + amp_html=amp_html, + template_slug=template_slug, + template_version=template_version, + project_id=project_id, + tag=tag, + metadata=metadata, + headers=headers, + substitution_data=substitution_data, + options=options, + attachments=attachments, + ) + payload["scheduled_at"] = scheduled_at + + body = self._client.post("/emails/scheduled", json=payload) data = body["data"] - results = [EmailEvent(**r) for r in data["results"]] - return EmailDetail(results=results, total_count=data["total_count"]) + return SendEmailResponse( + request_id=data["request_id"], + accepted=data["accepted"], + rejected=data["rejected"], + ) + + def get_scheduled(self, transmission_id: str) -> ScheduledEmail: + """Get details of a scheduled email transmission. + + Args: + transmission_id: The transmission ID. + + Returns: + A :class:`ScheduledEmail` with the transmission details. + + Raises: + NotFoundError: If the transmission is not found. + ForbiddenError: If access is not permitted. + """ + body = self._client.get(f"/emails/scheduled/{transmission_id}") + return _parse_scheduled_email(body["data"]) + + def cancel_scheduled(self, transmission_id: str) -> None: + """Cancel a scheduled email transmission before it is sent. + + Args: + transmission_id: The transmission ID to cancel. + + Raises: + NotFoundError: If the transmission is not found. + ConflictError: If the transmission has already been sent. + ForbiddenError: If cancellation is not permitted. + """ + self._client.delete(f"/emails/scheduled/{transmission_id}") diff --git a/src/lettr/resources/projects.py b/src/lettr/resources/projects.py index 5f67146..f5034ab 100644 --- a/src/lettr/resources/projects.py +++ b/src/lettr/resources/projects.py @@ -2,7 +2,7 @@ from __future__ import annotations -from typing import Any, Dict, Optional +from typing import Any from .._client import ApiClient from .._types import Project, ProjectList @@ -22,8 +22,8 @@ def __init__(self, client: ApiClient) -> None: def list( self, *, - per_page: Optional[int] = None, - page: Optional[int] = None, + per_page: int | None = None, + page: int | None = None, ) -> ProjectList: """List projects with pagination. @@ -34,7 +34,7 @@ def list( Returns: A :class:`ProjectList` with projects and pagination info. """ - params: Dict[str, Any] = {} + params: dict[str, Any] = {} if per_page is not None: params["per_page"] = per_page if page is not None: diff --git a/src/lettr/resources/templates.py b/src/lettr/resources/templates.py index 9271deb..7046918 100644 --- a/src/lettr/resources/templates.py +++ b/src/lettr/resources/templates.py @@ -2,7 +2,7 @@ from __future__ import annotations -from typing import Any, Dict, List, Optional +from typing import Any from .._client import ApiClient from .._types import ( @@ -14,8 +14,8 @@ ) -def _parse_merge_tags(raw: List[Dict[str, Any]]) -> List[MergeTag]: - tags: List[MergeTag] = [] +def _parse_merge_tags(raw: list[dict[str, Any]]) -> list[MergeTag]: + tags: list[MergeTag] = [] for t in raw: children = None if t.get("children"): @@ -49,9 +49,9 @@ def __init__(self, client: ApiClient) -> None: def list( self, *, - project_id: Optional[int] = None, - per_page: Optional[int] = None, - page: Optional[int] = None, + project_id: int | None = None, + per_page: int | None = None, + page: int | None = None, ) -> TemplateList: """List email templates with pagination. @@ -64,7 +64,7 @@ def list( Returns: A :class:`TemplateList` with templates and pagination info. """ - params: Dict[str, Any] = {} + params: dict[str, Any] = {} if project_id is not None: params["project_id"] = project_id if per_page is not None: @@ -97,7 +97,7 @@ def list( last_page=pagination["last_page"], ) - def get(self, slug: str, *, project_id: Optional[int] = None) -> Template: + def get(self, slug: str, *, project_id: int | None = None) -> Template: """Get a single template by slug. Args: @@ -110,7 +110,7 @@ def get(self, slug: str, *, project_id: Optional[int] = None) -> Template: Raises: NotFoundError: If the template or project is not found. """ - params: Dict[str, Any] = {} + params: dict[str, Any] = {} if project_id is not None: params["project_id"] = project_id @@ -134,10 +134,10 @@ def create( self, *, name: str, - html: Optional[str] = None, - json: Optional[str] = None, - project_id: Optional[int] = None, - folder_id: Optional[int] = None, + html: str | None = None, + json: str | None = None, + project_id: int | None = None, + folder_id: int | None = None, ) -> Template: """Create a new email template. @@ -158,7 +158,7 @@ def create( ValidationError: If validation fails. NotFoundError: If the project or folder is not found. """ - payload: Dict[str, Any] = {"name": name} + payload: dict[str, Any] = {"name": name} if html is not None: payload["html"] = html if json is not None: @@ -190,10 +190,10 @@ def update( self, slug: str, *, - name: Optional[str] = None, - html: Optional[str] = None, - json: Optional[str] = None, - project_id: Optional[int] = None, + name: str | None = None, + html: str | None = None, + json: str | None = None, + project_id: int | None = None, ) -> Template: """Update an existing email template. @@ -214,7 +214,7 @@ def update( NotFoundError: If the template or project is not found. ValidationError: If validation fails. """ - payload: Dict[str, Any] = {} + payload: dict[str, Any] = {} if name is not None: payload["name"] = name if html is not None: @@ -243,7 +243,7 @@ def update( updated_at=d.get("updated_at"), ) - def delete(self, slug: str, *, project_id: Optional[int] = None) -> None: + def delete(self, slug: str, *, project_id: int | None = None) -> None: """Permanently delete a template and all its versions. Args: @@ -253,7 +253,7 @@ def delete(self, slug: str, *, project_id: Optional[int] = None) -> None: Raises: NotFoundError: If the template or project is not found. """ - params: Dict[str, Any] = {} + params: dict[str, Any] = {} if project_id is not None: params["project_id"] = project_id self._client.delete(f"/templates/{slug}", params=params) @@ -262,8 +262,8 @@ def get_merge_tags( self, slug: str, *, - project_id: Optional[int] = None, - version: Optional[int] = None, + project_id: int | None = None, + version: int | None = None, ) -> TemplateMergeTags: """Get merge tags for a template version. @@ -278,7 +278,7 @@ def get_merge_tags( Raises: NotFoundError: If the template or project is not found. """ - params: Dict[str, Any] = {} + params: dict[str, Any] = {} if project_id is not None: params["project_id"] = project_id if version is not None: @@ -290,4 +290,25 @@ def get_merge_tags( template_slug=d["template_slug"], version=d["version"], merge_tags=_parse_merge_tags(d["merge_tags"]), + project_id=d.get("project_id"), ) + + def get_html(self, *, project_id: int, slug: str) -> str: + """Get the rendered HTML of a template. + + Args: + project_id: Project ID containing the template. + slug: The template slug. + + Returns: + The rendered HTML string. + + Raises: + NotFoundError: If the template or project is not found. + """ + params: dict[str, Any] = { + "project_id": project_id, + "slug": slug, + } + body = self._client.get("/templates/html", params=params) + return body["data"]["html"] diff --git a/src/lettr/resources/webhooks.py b/src/lettr/resources/webhooks.py index a0d0393..b038ca5 100644 --- a/src/lettr/resources/webhooks.py +++ b/src/lettr/resources/webhooks.py @@ -2,46 +2,55 @@ from __future__ import annotations -from typing import List +import builtins +from typing import Any from .._client import ApiClient from .._types import Webhook +def _parse_webhook(w: dict[str, Any]) -> Webhook: + """Parse a raw dict into a Webhook.""" + return Webhook( + id=w["id"], + name=w["name"], + url=w["url"], + enabled=w["enabled"], + auth_type=w["auth_type"], + has_auth_credentials=w["has_auth_credentials"], + event_types=w.get("event_types"), + last_successful_at=w.get("last_successful_at"), + last_failure_at=w.get("last_failure_at"), + last_status=w.get("last_status"), + ) + + class Webhooks: - """Operations for retrieving webhooks. + """Operations for managing webhooks. Usage:: webhooks = client.webhooks.list() webhook = client.webhooks.get("webhook-abc123") + webhook = client.webhooks.create( + name="My Webhook", + url="https://example.com/webhook", + auth_type="none", + events_mode="all", + ) """ def __init__(self, client: ApiClient) -> None: self._client = client - def list(self) -> List[Webhook]: + def list(self) -> builtins.list[Webhook]: """List all webhooks. Returns: A list of :class:`Webhook` objects. """ body = self._client.get("/webhooks") - return [ - Webhook( - id=w["id"], - name=w["name"], - url=w["url"], - enabled=w["enabled"], - auth_type=w["auth_type"], - has_auth_credentials=w["has_auth_credentials"], - event_types=w.get("event_types"), - last_successful_at=w.get("last_successful_at"), - last_failure_at=w.get("last_failure_at"), - last_status=w.get("last_status"), - ) - for w in body["data"]["webhooks"] - ] + return [_parse_webhook(w) for w in body["data"]["webhooks"]] def get(self, webhook_id: str) -> Webhook: """Get details of a single webhook. @@ -56,16 +65,136 @@ def get(self, webhook_id: str) -> Webhook: NotFoundError: If the webhook is not found. """ body = self._client.get(f"/webhooks/{webhook_id}") - w = body["data"] - return Webhook( - id=w["id"], - name=w["name"], - url=w["url"], - enabled=w["enabled"], - auth_type=w["auth_type"], - has_auth_credentials=w["has_auth_credentials"], - event_types=w.get("event_types"), - last_successful_at=w.get("last_successful_at"), - last_failure_at=w.get("last_failure_at"), - last_status=w.get("last_status"), - ) + return _parse_webhook(body["data"]) + + def create( + self, + *, + name: str, + url: str, + auth_type: str, + events_mode: str, + auth_username: str | None = None, + auth_password: str | None = None, + oauth_client_id: str | None = None, + oauth_client_secret: str | None = None, + oauth_token_url: str | None = None, + events: builtins.list[str] | None = None, + ) -> Webhook: + """Create a new webhook. + + Args: + name: Webhook name. + url: Webhook URL to receive events. + auth_type: Authentication type (``"none"``, ``"basic"``, ``"oauth2"``). + events_mode: Event mode (``"all"`` or ``"selected"``). + auth_username: Basic auth username (when ``auth_type="basic"``). + auth_password: Basic auth password (when ``auth_type="basic"``). + 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"``). + + Returns: + A :class:`Webhook` with the created webhook details. + + Raises: + ValidationError: If validation fails. + ConflictError: If a webhook with the same URL already exists. + """ + payload: dict[str, Any] = { + "name": name, + "url": url, + "auth_type": auth_type, + "events_mode": events_mode, + } + if auth_username is not None: + payload["auth_username"] = auth_username + if auth_password is not None: + payload["auth_password"] = auth_password + if oauth_client_id is not None: + payload["oauth_client_id"] = oauth_client_id + if oauth_client_secret is not None: + payload["oauth_client_secret"] = oauth_client_secret + if oauth_token_url is not None: + payload["oauth_token_url"] = oauth_token_url + if events is not None: + payload["events"] = events + + body = self._client.post("/webhooks", json=payload) + return _parse_webhook(body["data"]) + + def update( + self, + webhook_id: str, + *, + name: str | None = None, + target: str | None = None, + auth_type: str | None = None, + auth_username: str | None = None, + auth_password: str | None = None, + oauth_token_url: str | None = None, + oauth_client_id: str | None = None, + oauth_client_secret: str | None = None, + events: builtins.list[str] | None = None, + active: bool | None = None, + ) -> Webhook: + """Update an existing webhook. + + Only provided fields will be updated. + + Args: + webhook_id: The webhook ID to update. + name: New webhook name. + target: New webhook URL. + 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. + active: Enable or disable the webhook. + + Returns: + A :class:`Webhook` with the updated webhook details. + + Raises: + NotFoundError: If the webhook is not found. + ValidationError: If validation fails. + """ + payload: dict[str, Any] = {} + if name is not None: + payload["name"] = name + if target is not None: + payload["target"] = target + if auth_type is not None: + payload["auth_type"] = auth_type + if auth_username is not None: + payload["auth_username"] = auth_username + if auth_password is not None: + payload["auth_password"] = auth_password + if oauth_token_url is not None: + payload["oauth_token_url"] = oauth_token_url + if oauth_client_id is not None: + payload["oauth_client_id"] = oauth_client_id + if oauth_client_secret is not None: + payload["oauth_client_secret"] = oauth_client_secret + if events is not None: + payload["events"] = events + if active is not None: + payload["active"] = active + + body = self._client.put(f"/webhooks/{webhook_id}", json=payload) + return _parse_webhook(body["data"]) + + def delete(self, webhook_id: str) -> None: + """Delete a webhook. + + Args: + webhook_id: The webhook ID to delete. + + Raises: + NotFoundError: If the webhook is not found. + """ + self._client.delete(f"/webhooks/{webhook_id}") diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/__pycache__/__init__.cpython-313.pyc b/tests/__pycache__/__init__.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..01b8422e89c23190420b88d584e5d3c07419dde0 GIT binary patch literal 164 zcmey&%ge<81fJjSWP<3&AOZ#$p^VQgK*m&tbOudEzm*I{OhDdekkl<_{m|mnqGJ8B z{H&7H9{gp;-QA(|pp^C5v6A(QpB1)yyT`Hw%PZfqVJMCt>n}nH(b@#N0 z;Kgfi{saCwqCy?Kl_K6ME1rFm?QRD$dEd;u_rC9&WaPRvz|s8oGyZ4*oN6!`);KUm zLkx}~01kl>m`$T$a+B!TYFgYb-ZPDoz_mu1SBf3GS#8vK?c$Wf9XvJDoNc(=CGgtW zx;1HX`CfsC7a%AHrJK;PCzxM7uPC1FM;mE$sKN6rUhT##kbCE|ZdbQQAWk*U@OC#Ak3)Rk{}nMswc9%u-b75}G?vtNxB) zT=;L&j$~20ljeiaf5F6|Of!FzryX`E`+iaSUe-ruiLdPxzUHMPFY7Bu>!IVi+`+oD z_=qK(hVZupR~A0K{NmmnuAMC0AFh3~ub((`!;KMcL)}y*Ubs@wtzT1RN?U0}sj5-B zpX)25l%waMDtmd{m2o1}?6a&-r)ZHPG0h|tC`I3=$Jr5&B~#Vuk}6NuYM88#(~v7S zPhwf*Y6S%aRT^(AckJgxR$*lM;wvgdeg>{}a{WW7%9CE&%De0-*Rr*@;sH+QmSGq_ sVCf82egV9P?{NDR+%vd+UIMf7Vfo$iS7UBu+Q$6I0(1V4j*6=P0r?{A9RL6T literal 0 HcmV?d00001 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 new file mode 100644 index 0000000000000000000000000000000000000000..0b3eef44fda5f5653c320fb797845ddc7f41621c GIT binary patch literal 9715 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 literal 0 HcmV?d00001 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 new file mode 100644 index 0000000000000000000000000000000000000000..2067c1593f572d58d82fd80b7af6706bfc5f43e0 GIT binary patch literal 14861 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 literal 0 HcmV?d00001 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 new file mode 100644 index 0000000000000000000000000000000000000000..6bd69bc3ef855c9daaf6c1af07f0edd4103346d4 GIT binary patch literal 13087 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 C-sOk@ literal 0 HcmV?d00001 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 new file mode 100644 index 0000000000000000000000000000000000000000..8b9ac1009844ae5b47d65663aef3983399825974 GIT binary patch literal 23315 zcmeHPTWlQHd7iy-XP1}JE=h@$L~?o2Wl4%RQL^M%a%@YsL&>tk(K>3GV!I|+66MwI zW@c%bQtezCByyUL3~{fBxq`*ZI$xGym<({l>Rg!*53G4E6#B)F9 zk)-z}RnjEYqk2zxCcK(AM!4^UPxG_?fr&aDw27c5vmF14&_q}Zo2BZtdXx&BXqaf! z8Yh~xrU^w;SYF+UEfWzf5|h4KKiOw>Z2#|(4wwmbs~X%cUGQ6Jt+`F=kyLrRq=pW7 zc_FQZ(ZZnB3tB6qHGtMAXj>Vr2{c8}wlUfk&?17?#%NK{ngy+$(ON)j6}0W=eDQ6U zyHBU}f*w1Y(_)45>DcM?#e61JNb4~zt>@;n>GaSljWg~s>QmWlu8=Cs=Cb-Jq(d*I z&dpB0l$(A%?lWYoqS0VsCujA75wTJ)rL}YE)2Va%P1LlA`ZOjp`qX~(Zcxis`W#DwPDCR;z@WXtri^r zGd3?hlL1GDiK5lU!=9{fDrn^;d{e|`c3Dd%cWkdodBHO?AW3SIs!Xv*k;sz`W~Sqx z{V03^)R`_xnrgDLC%0Ib{l7<=sI}vi^i`k~V>=D_b{(x#3r$&Z?0Os@~jp6Y`TUN>c8J)*WMo z_EgNsPOGNW?mb|>I@H!reO|C~`DKmS8s&!8^qSh3m)6hcns&^$ z`x78$t!Bxf#jBbnWvisRbtu4AU|8Lzwk5(N9+s-##Coi^+Oq!BdOYIE*5NOB#FM}7 zzB1Kz+EjBtpQK&C>dMxs{@k5JBU_)BteKzr_J&qx5UX?hnTz)7tjUcAYveUcZhS5p ztv1yiLq5jSCUvLUk!UhG9Nq+n!&Y0?f0M%voXh#Q+}~i6u`0GAIf*B{`cgY-CbCLG zQM(w{paya$6Mn}1vx57%4ROB?+>f34$IpcO_P*IY+$egMtD}A&FY0zb~0aDe?ek%)znT4K*g!CAG3!TS~@Ub0=>xM6r z&fW}YgXmh@2SDBJCvbqkp^p{E?TzAQ3_k`oqLE;U;SADn*6EYx#qR51T)*jKJ!s=* zHwQm%ax{*?Xa^~$E}zm;7j?$no1V*2lY7>n!Q-1=-q0|LSG0jnv?pm~QNwpGT`(H7 zbYV`*Cf`V9=CD#S5N)+M@)@U%hA-ylldsLqW(t@Iqb`%1PGxi>3@SU zxy(#bOXs!ZxlHc0R3^z*T_~xi&ZdcMG(E!>02GnqnwHay<|NdYi>X2~{U($bzF64m z(Z=ZZB1v?UVJ%|W<6hGm2?IumkyF|^4CVDp^gq4@i#we;%PZH@xcjMOJ&l`lZa+`( z4i7kU#+-?9XW|KG;vi4(1`czU@Wkk-v)qU?VRppJJ)u2?LFpuz#;PujWN@RIag%s& zt2D+=OVz;AL`G$9Gc~AViG4%*s3Cf{PU?!S#NzbZy?15T?v>qR_}x?F)~$Da;m+vY zHmS2~CDyajw`--l=Uyn#6}{IUZEd>iYuOvVTK}%{j&fV>yE`aFcl@C9z0QTPYn{c& z(ABzMDDBsGe=xYD3>W3$JIZibuK1PY;qPA-w8|7G-;sx}?{=1Oq(q4i2Aw6W)UrHW zbn9`YxxL)nTWant%JJ{(#pd26r4Q(Whk=q3FU#g{NsbqlK9S_eGSw2aQbmfB@5u3M z2c0FX6sn>-p5tsA_<~1N5D1E|D6M6stE6<@@RXFEqC8$!_LP)8Ko$-&P*TRrpt9eh zJYH1xaQrsmJse+h(19-;7BvL2ERTN_+4+O6_qxiF{!*mB92qS|MsMvcMZR#g4uiOs zDJjIS8yW*nCM^<^%$vIHz;RPIG;uEiK@p8K!kf~mn|1uLBgI!^&mIZnv{loNV)#$x zvT0J8lWadoCXH}1c`-LLmq`O}NG88Fm&(vifs~R=o}Jb7LS{Cb&gPOy?RTi9)?^af ze_?hS@1}w_``R2H1msOoe>4X6@M!c{F+c*WPWte7qzB%PKtC@-*^i*`2k9w%AzfJA zPwhDMeY#_&?>~!|l8e3%jb9B9g*K$&3vC~5nisPi)F1Xj$EO!PyP)IS+BUskaIMkt z8`MVI5-=&WeO{WNi;gcW0h2=8cb1*(xS7!}z^Wb4j9CEnRH=BDG<7@cFj&Dt2HQuCf{S#^uh@#&Sp zE=TPai)R0SsE+Sq6M(iP=iW#JnY=FSO*22O;Jk}%0J?l6;fK5)J#)^M*FT@D!oe~3 zHDqgMHRdxRv}Co})XZeHMQu&UrWO&J+W0($EbYPiZ|lDK7ohhFJA^&En61zw&DPe( zv-Mfb7IeOCr!<;+jh;!3400MhZQ3>h^!#X~k*(4j=yLs2=SPpeIQ!Hv{>4KY&9g>^ zHYO%Btw1BYh6WR)WHhCw3$t&elW)Mz4hh_7G7&vFotw)R4FCDU#f-Lx%G18drGJv5 zHG1!8FdIwS2mo}MgQRFcJL4K05L>kxs_ezqYM_8Ys~MzLh6s>rr9vZlf>f;h{1cY0 zv`+gp%8FLwP1+Z!!Dk3Cm4%J>IZ7TQaGb#N1YRKUB7rXvm?bbl;B|m_SbK?Z@)d9~ zhTZ%rO1=!>6zfN|FAEBn?JKl{Lpr@~Z4sYK_Yo$)L)j9-z0qz;6S!JD+o6er)22S{eOOE^-Z#0PtwC9Kr4JWzD&kx9jUrS-&i zCXu>`?N{FrQYgfBD@8)NCFHLoh=YtS-@WqA74YEtMFvWafi21dSFea0;0VS}J=nKx zr6|KJyhN>uG6K0R4{*_w1l3d8R{`U7S`z=X-zShF@EQS)z%+p>nKn%{0wi?`nKl|2 z-VkU`8NNpMN z4#V*<vZe*@@ zvbpMbJXgQQxe7U4bwaSr!ULZBFcD_Y+u!dsA4JntXRjD~>s4J4@3$uE@x*nW`CZ2o z*R`o9j$EjAFRoAAln`>Qb!m-~sit;q*Jo*MZ9IWpcm{1T{x@e|5VPjsnd@EKPTZO$ zh4V*^nXbA9wOj2;G?+U`<0f_x;gqsw%2ZcEa3lq=i~Q7m7g2iySX;UOnsjjpXG2gv z|2Ltq^_j3{p1JDmnO|>p2CzE&&ivS3oi({3+%`2!ZhS5jiyP`LbC2Gl?pF6CwwN4l z*aU}#E75Ax3@+!BH8||%3=KhH1cy&bxy|u+Z-V$6f6QNm-2}}Q9IM(Sk;Dp? z7=JyR6zVEo9Pg zAiss0+SdW%evZFw5e#|y{A^|hj%Q{DiOcI7JxIGZ!WSugFAj*!XZ$`$fA zy!940ocOrSglquZW@1`7n8$V#zg{IfVk)5x>-Gj;L92|6MK-b zZ15V)0Zuj(&*fJ>nIqfTXr(8cc~>=3DYYjE93nt-iD3>r#7&JEv@I{>Kt4~`^G`ob-dX3JY2wV4F}||=-sH)HE^#xLdK-#IGn*{xveC(-IjOV z?T0z39O*7ax^K)C_dj1$6U9h(G4d*NDROg4@AY(127r69EStYYxtA;?HUaoDXfUUk zw33~o472bOH6+Ri1Y~9~ij|aDQ660IbD+R!Z2lJI!J-lqNscVbgVdv0q-3X%AxbY% zd!mp)KxSU%zEU$>%l+hK?psoJ0bPhNP*VEKviVz*`-{phk>toS)e^K)MT(Q}$o=G3 zHXA_+D}}1)j^{Yr2EGsx6$FCff;~4P%*{J$d6`ES4i;sAk}_JB&EKLt%Dl{!yiGXf zgzzN?9ryyqW>@0)vOHSd6&%`wC1nu4>|2-@0@V&}OaS?}nL`_05zZXiCLQ=K%z*6$ z=QteN)m@>+#&b0Lj+f zNcae5K!n??%=5jj5zzMujerfzu+&&#SZewlD*H~~xVsAfNsNGsFajc${nuy&3_a3p zMIX=BXEIx^=T6*w#%_iewV6~}D6cK5Z^VP;g~a1%xx5g|}%#K})U z0o(5I5gS3iJI_@4iC6vLG}DsofZ)*swKOKCC228KjeEFWv;l31v?*INSXUdmJVrwP zIvSN7a$M1p;lH5gvTP(!g;<0uLTjxESH#mA!F*~ylS|DY)RlRc$#YuaSf=qDpc_)) zU!Jp+1>PUO#f>Tp#1`!Yfs@o;!{$_kKc$MKCpZI1$8|C>J2e8QGQbB}r3~=t=E^`- z0n%ah57+Y=!(R-?acBR%NQk8TdZ){HyDj6#Z^fB>|CB?%-*bJoC<7p9u`HXvMR^a2 z>^1@TGH8&tOuLf6kbl*5!+`g|U?*l9; z`^vKUTa@=*g(znefG=0jN_Gkvs9seXNi?!S!S6@3ZrlXFKc)-XB` z75t8+app3;`b}yIf}Xf2ypjoi|BS^3!B5(NrxzuwFMc@kus60&eYYcz^eEG{ZXFkY z@uctq@VW$5KYMk-!>k4v=7*1KtxuT_oQT)fBt5KE7hYg>Oq>il1r$deSI<$x7ToOC z-AmfuTiJZ61SzD{A-IuIfnAPX-NnU+dT;S!sXyg-i?CQA3R$3;$64QZu+i0mHQOY-*H*p1`}w}D)5UqHMx ztRZ77ax>4y1B_?fij3bnQ;Hn@i~_Ei_V>_elD{M# z(Su*DDE>Lo2z(QNV!sGj5z>@H)x@hY7cc zi4_R!{UYY1LM#?L(WLu6B5FY_rV#GUjEcPZcKfcQvIT8R_!XmrpWjj2b#!ZMW0Xy` zYS}xos)%6eR*O}R+U{{NVwbi?Og`834@Bc#;~p$+b4EloSYx1&eRT^{mLe75x1Y;Z z;oz9-EZGOaYnBwM5RR(zW2~~;thOZVVCjv|lTZVl^JE@jrC@2d=z==c_eitV`gpcJ zquFwcRG?$qkMe9)hPFR`wm!XQD`xLx7M2Z9^;&pBm{0I&h;6yQP1Lh^o}XKL|C&!j zthYlpAmXI$%oT?@sr^%bL-kmrw)PQglpFdq#G2Z8L$!ZC-|jVcXEAFvONxEGW=Zim z2X(s{Z`P>pP zBdllv4)=%I;_NR3RMg@}QochsF-D*=f*>B%zDU$(0E{L&%V0+!a2~MRe~!u=BfzX~ zZa(=1%AFvV-uA=a^?7mZIus-uT*zsI+sbTk@lqtd5dWF? zpPng3V2C?qp53_Jvwuk$hi~ z0dShSEStYYx##NJBE^wq&|n=jOO)&sWtfGRs3B2CARs&Jk-Kj^Ta*Dx3ixRL7UkW{ z!e~>FwJh(Z9!*-wP9Z~-UZSQ%A%TGW8xwDUxP=YcFaL@jLozH>+ao?C8iDWOPuL?O z0rS`lVT6uTe`)9wc6Nf+hZ?Tch; zBc@%-$_ZI*Vps@D!&>hp-X)F^%2mTdlFb{PURIpmOGh1d4hbc+W=SDj(eWz&RV+24 zMukVQ!EsL5o<(k9?gyqW({FZaV-6QU{vRo7pwZ&Xdt-sw|giyWJ6spN9 z$pdfA4_I}xz*{b26I(5rnQSlYjIs77gfYSHeZu~XzySixDZnJcBb2Q4MDWm0dQDtb z@MTc*i>SkNWY~F1*pNxCW5=D3QzJ~&wB=Gak@N(WI|*R;+4qJTU4_tBs1%bwe@IxK z02}bDgxMl0iv%S{M}_Fg1y3j#_{xtpKygBt-eR`2Nn^i(`Unp;<;-iZ7CT0YEu#=F zog$^oL`w2cENDLo6(haH$g^-vv?Ee)u-Hx^dm`8o(UT;&*aYAY4W4YTD9URhzVg@f z0UCao_u5&K2K#TcGvExzT$KAsz@xaz+ct{e$s6q=W>O#^uPEE!{rWp!FDrXX%HFa< z4&Q|o9^Nu#A!5EB|1>{=-y=YdviJ@om`u*(ruo-*&d%Y>Jow&@1_?+qWb4CT zEL7TPPx7yN4RJRfJ{2&eA-Err`bJ2QjG#$lTAa~1mz^!p!X2Vd`?AjQaWeLCFr$He zwRb2<0slJxmH@q|G?KuKcAjZ{v)J+ZH8yS|cy{(p8Y_1T(#w<`hi2i9%=OLuSxksz zoT(Bq4f!elrJk#dDrLhc>Yz|^Pf`BuO#W)(z4Y1OR4c!(z7M$*?*IU z@f|I%{N2uPbuM`#_q`rZ&wZcdjou~ff!yiozb64ac-GVG8N4R}JQ(csJWa%>L2U61 h-ID+w4AoIdfCtg2XW*U$@SwlJ^StN3C4y|u{tvD$gslJo literal 0 HcmV?d00001 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 new file mode 100644 index 0000000000000000000000000000000000000000..2d3d888e9b59948312821828d08d3d7896817b0d GIT binary patch literal 14917 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 IgzlsMA4h$u)c^nh literal 0 HcmV?d00001 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 new file mode 100644 index 0000000000000000000000000000000000000000..88377fcb6d7e7ef6781dc8aebeea96d9bce073e5 GIT binary patch literal 17509 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 a(udbw5!a3{B_I#Gy{;3k|B{fhS@D08wNMxU literal 0 HcmV?d00001 diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..75179ba --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,17 @@ +"""Shared test fixtures.""" + +from __future__ import annotations + +from unittest.mock import MagicMock + +import pytest + +from lettr._client import ApiClient + + +@pytest.fixture() +def mock_client() -> MagicMock: + """Return a mocked ApiClient whose HTTP methods can be configured per test.""" + client = MagicMock(spec=ApiClient) + client._base_url = "https://app.lettr.com/api" + return client diff --git a/tests/test_client.py b/tests/test_client.py new file mode 100644 index 0000000..cd989cf --- /dev/null +++ b/tests/test_client.py @@ -0,0 +1,105 @@ +"""Tests for the ApiClient.""" + +from __future__ import annotations + +from unittest.mock import MagicMock, patch + +import pytest + +from lettr._client import ApiClient +from lettr._exceptions import LettrError + + +class TestApiClientRequest: + def test_strips_none_params(self) -> None: + client = ApiClient(api_key="lttr_test") + mock_response = MagicMock() + mock_response.status_code = 200 + mock_response.json.return_value = {"data": "ok"} + + with patch.object(client._http, "request", return_value=mock_response) as mock_req: + client.get("/test", params={"a": 1, "b": None, "c": "x"}) + _, kwargs = mock_req.call_args + assert kwargs["params"] == {"a": 1, "c": "x"} + + client.close() + + def test_204_returns_none(self) -> None: + client = ApiClient(api_key="lttr_test") + mock_response = MagicMock() + mock_response.status_code = 204 + + with patch.object(client._http, "request", return_value=mock_response): + result = client.delete("/test") + assert result is None + + client.close() + + def test_http_error_raises_lettr_error(self) -> None: + import httpx + + client = ApiClient(api_key="lttr_test") + + with patch.object( + client._http, "request", side_effect=httpx.ConnectError("connection failed") + ), pytest.raises(LettrError, match="HTTP request failed"): + client.get("/test") + + client.close() + + def test_json_parse_failure_on_error_response(self) -> None: + client = ApiClient(api_key="lttr_test") + mock_response = MagicMock() + mock_response.status_code = 500 + mock_response.json.side_effect = ValueError("bad json") + + with patch.object( # noqa: SIM117 + client._http, "request", return_value=mock_response + ): + with pytest.raises(LettrError): + client.get("/test") + + client.close() + + +class TestApiClientGetNoAuth: + @patch("lettr._client.httpx.get") + def test_get_no_auth_succeeds(self, mock_get: MagicMock) -> None: + mock_response = MagicMock() + mock_response.status_code = 200 + mock_response.json.return_value = {"data": {"status": "ok"}} + mock_get.return_value = mock_response + + client = ApiClient(api_key="lttr_test") + result = client.get_no_auth("/health") + assert result == {"data": {"status": "ok"}} + + mock_get.assert_called_once() + call_kwargs = mock_get.call_args + assert "Authorization" not in call_kwargs.kwargs.get("headers", {}) + + client.close() + + @patch("lettr._client.httpx.get") + def test_get_no_auth_strips_none_params(self, mock_get: MagicMock) -> None: + mock_response = MagicMock() + mock_response.status_code = 200 + mock_response.json.return_value = {"data": {}} + mock_get.return_value = mock_response + + client = ApiClient(api_key="lttr_test") + client.get_no_auth("/health", params={"a": 1, "b": None}) + + call_kwargs = mock_get.call_args + assert call_kwargs.kwargs["params"] == {"a": 1} + + client.close() + + +class TestApiClientContextManager: + def test_context_manager_closes(self) -> None: + client = ApiClient(api_key="lttr_test") + with patch.object(client, "close") as mock_close: + with client: + pass + mock_close.assert_called_once() diff --git a/tests/test_domains.py b/tests/test_domains.py new file mode 100644 index 0000000..58c7eef --- /dev/null +++ b/tests/test_domains.py @@ -0,0 +1,177 @@ +"""Tests for the Domains resource.""" + +from __future__ import annotations + +from unittest.mock import MagicMock + +import pytest + +from lettr._types import Domain, DomainVerification +from lettr.resources.domains import Domains + + +@pytest.fixture() +def domains(mock_client: MagicMock) -> Domains: + return Domains(mock_client) + + +class TestList: + def test_list_domains(self, domains: Domains, mock_client: MagicMock) -> None: + mock_client.get.return_value = { + "data": { + "domains": [ + { + "domain": "example.com", + "status": "verified", + "status_label": "Verified", + "can_send": True, + "cname_status": "valid", + "dkim_status": "valid", + "created_at": "2025-01-01", + } + ] + } + } + + result = domains.list() + assert len(result) == 1 + assert isinstance(result[0], Domain) + assert result[0].domain == "example.com" + assert result[0].can_send is True + + +class TestGet: + def test_get_domain(self, domains: Domains, mock_client: MagicMock) -> None: + mock_client.get.return_value = { + "data": { + "domain": "example.com", + "status": "verified", + "status_label": "Verified", + "can_send": True, + "cname_status": "valid", + "dkim_status": "valid", + "dmarc_status": "valid", + "spf_status": "valid", + "is_primary_domain": True, + "tracking_domain": "track.example.com", + "dns": {"cname": "cname.lettr.com"}, + "dns_provider": "cloudflare", + "created_at": "2025-01-01", + "updated_at": "2025-06-01", + } + } + + result = domains.get("example.com") + assert isinstance(result, Domain) + assert result.dmarc_status == "valid" + assert result.spf_status == "valid" + assert result.is_primary_domain is True + assert result.dns_provider == "cloudflare" + + +class TestCreate: + def test_create_domain(self, domains: Domains, mock_client: MagicMock) -> None: + mock_client.post.return_value = { + "data": { + "domain": "new.com", + "status": "pending", + "status_label": "Pending", + "dkim": { + "public": "MIGfMA0...", + "selector": "lettr", + "headers": "from:to:subject", + "signing_domain": "new.com", + }, + } + } + + result = domains.create("new.com") + assert result.domain == "new.com" + assert result.status == "pending" + assert result.dkim is not None + assert result.dkim.selector == "lettr" + assert result.dkim.signing_domain == "new.com" + + payload = mock_client.post.call_args.kwargs["json"] + assert payload == {"domain": "new.com"} + + def test_create_domain_tolerates_unknown_dkim_fields( + self, domains: Domains, mock_client: MagicMock + ) -> None: + """Regression: unknown server fields must not crash dataclass parsing.""" + mock_client.post.return_value = { + "data": { + "domain": "new.com", + "status": "pending", + "status_label": "Pending", + "dkim": { + "selector": "lettr", + "headers": "from:to:subject", + "signing_domain": "new.com", + "future_field_we_dont_know_about": "some_value", + "another_one": 42, + }, + } + } + + result = domains.create("new.com") + assert result.dkim is not None + assert result.dkim.selector == "lettr" + + +class TestDelete: + def test_delete_domain(self, domains: Domains, mock_client: MagicMock) -> None: + mock_client.delete.return_value = None + domains.delete("example.com") + mock_client.delete.assert_called_once_with("/domains/example.com") + + +class TestVerify: + def test_verify_domain(self, domains: Domains, mock_client: MagicMock) -> None: + mock_client.post.return_value = { + "data": { + "domain": "example.com", + "dkim_status": "valid", + "cname_status": "valid", + "dmarc_status": "valid", + "spf_status": "valid", + "is_primary_domain": True, + "ownership_verified": "2025-01-01", + "dns": {"cname": "ok"}, + "dmarc": { + "is_valid": True, + "status": "valid", + "record": "v=DMARC1; p=reject", + "policy": "reject", + }, + "spf": { + "is_valid": True, + "status": "valid", + "record": "v=spf1 include:lettr.com ~all", + "includes_sparkpost": True, + }, + } + } + + result = domains.verify("example.com") + assert isinstance(result, DomainVerification) + assert result.dmarc_status == "valid" + assert result.spf_status == "valid" + assert result.is_primary_domain is True + assert result.dmarc is not None + assert result.dmarc.policy == "reject" + assert result.spf is not None + assert result.spf.includes_sparkpost is True + + def test_verify_without_dmarc_spf(self, domains: Domains, mock_client: MagicMock) -> None: + mock_client.post.return_value = { + "data": { + "domain": "example.com", + "dkim_status": "pending", + "cname_status": "pending", + } + } + + result = domains.verify("example.com") + assert result.dmarc is None + assert result.spf is None diff --git a/tests/test_emails.py b/tests/test_emails.py new file mode 100644 index 0000000..eea38e2 --- /dev/null +++ b/tests/test_emails.py @@ -0,0 +1,415 @@ +"""Tests for the Emails resource.""" + +from __future__ import annotations + +from unittest.mock import MagicMock + +import pytest + +from lettr._types import ( + Attachment, + EmailDetail, + EmailEventList, + EmailList, + EmailOptions, + ScheduledEmail, + SendEmailResponse, +) +from lettr.resources.emails import Emails + + +@pytest.fixture() +def emails(mock_client: MagicMock) -> Emails: + return Emails(mock_client) + + +class TestSend: + def test_send_minimal(self, emails: Emails, mock_client: MagicMock) -> None: + mock_client.post.return_value = { + "data": {"request_id": "req_123", "accepted": 1, "rejected": 0} + } + + result = emails.send( + from_email="a@b.com", + to=["c@d.com"], + subject="Hi", + html="

Hello

", + ) + + assert isinstance(result, SendEmailResponse) + assert result.request_id == "req_123" + assert result.accepted == 1 + + payload = mock_client.post.call_args.kwargs["json"] + assert payload["from"] == "a@b.com" + assert payload["to"] == ["c@d.com"] + assert payload["subject"] == "Hi" + assert payload["html"] == "

Hello

" + + def test_send_with_all_options(self, emails: Emails, mock_client: MagicMock) -> None: + mock_client.post.return_value = { + "data": {"request_id": "req_456", "accepted": 2, "rejected": 0} + } + + result = emails.send( + from_email="a@b.com", + to=["c@d.com", "e@f.com"], + subject="Test", + html="

Hi

", + text="Hi", + from_name="Sender", + cc=["cc@test.com"], + bcc=["bcc@test.com"], + reply_to="reply@test.com", + reply_to_name="Reply", + amp_html="

AMP

", + template_slug="welcome", + template_version=2, + project_id=10, + tag="onboarding", + metadata={"user_id": "123"}, + headers={"X-Custom": "value"}, + substitution_data={"name": "World"}, + options=EmailOptions(click_tracking=True, open_tracking=False), + attachments=[Attachment(name="f.pdf", type="application/pdf", data="base64data")], + ) + + assert result.accepted == 2 + payload = mock_client.post.call_args.kwargs["json"] + assert payload["tag"] == "onboarding" + assert payload["headers"] == {"X-Custom": "value"} + assert payload["cc"] == ["cc@test.com"] + assert payload["options"] == {"click_tracking": True, "open_tracking": False} + assert payload["attachments"] == [ + {"name": "f.pdf", "type": "application/pdf", "data": "base64data"} + ] + + def test_send_without_subject(self, emails: Emails, mock_client: MagicMock) -> None: + mock_client.post.return_value = { + "data": {"request_id": "req_789", "accepted": 1, "rejected": 0} + } + + emails.send( + from_email="a@b.com", + to=["c@d.com"], + template_slug="welcome", + ) + + payload = mock_client.post.call_args.kwargs["json"] + assert "subject" not in payload + + +class TestList: + def test_list_default(self, emails: Emails, mock_client: MagicMock) -> None: + mock_client.get.return_value = { + "data": { + "events": { + "data": [ + { + "event_id": "ev1", + "type": "injection", + "timestamp": "2025-01-01T00:00:00Z", + "request_id": "req_1", + "subject": "Hello", + "rcpt_to": "a@b.com", + } + ], + "total_count": 1, + "from": "2025-01-01T00:00:00Z", + "to": "2025-01-15T00:00:00Z", + "pagination": {"next_cursor": "abc", "per_page": 25}, + } + } + } + + result = emails.list() + assert isinstance(result, EmailList) + assert len(result.results) == 1 + assert result.total_count == 1 + assert result.next_cursor == "abc" + assert result.date_from == "2025-01-01T00:00:00Z" + assert result.date_to == "2025-01-15T00:00:00Z" + assert result.results[0].type == "injection" + + def test_list_with_filters(self, emails: Emails, mock_client: MagicMock) -> None: + mock_client.get.return_value = { + "data": { + "events": { + "data": [], + "total_count": 0, + "from": None, + "to": None, + "pagination": {"per_page": 10}, + } + } + } + + emails.list( + per_page=10, + cursor="xyz", + recipients="a@b.com", + from_date="2025-01-01", + to_date="2025-12-31", + ) + + params = mock_client.get.call_args.kwargs["params"] + assert params["per_page"] == 10 + assert params["cursor"] == "xyz" + assert params["recipients"] == "a@b.com" + assert params["from"] == "2025-01-01" + assert params["to"] == "2025-12-31" + + def test_list_regression_nested_events_wrapper( + self, emails: Emails, mock_client: MagicMock + ) -> None: + """Regression: response uses data.events.data nesting per OpenAPI spec. + + Previously the SDK read from data.results and silently returned an + empty list when the API actually had emails. + """ + mock_client.get.return_value = { + "success": True, + "message": "Emails retrieved successfully.", + "data": { + "events": { + "data": [ + {"event_id": "e1", "type": "delivery", "timestamp": "t1"}, + {"event_id": "e2", "type": "delivery", "timestamp": "t2"}, + ], + "total_count": 2, + "from": "2024-01-05T00:00:00Z", + "to": "2024-01-15T23:59:59Z", + "pagination": {"next_cursor": None, "per_page": 25}, + } + }, + } + + result = emails.list() + assert result.total_count == 2 + assert len(result.results) == 2 + + +def _detail_envelope(**overrides: object) -> dict: + """Build a spec-shaped detail response (`GET /emails/{id}`).""" + base = { + "transmission_id": "req_123", + "state": "delivered", + "from": "sender@example.com", + "from_name": None, + "subject": "Hello", + "recipients": ["a@b.com"], + "num_recipients": 1, + "events": [], + } + base.update(overrides) + return {"data": base} + + +class TestGet: + def test_get_basic(self, emails: Emails, mock_client: MagicMock) -> None: + mock_client.get.return_value = _detail_envelope( + events=[{"event_id": "ev1", "type": "delivery", "rcpt_to": "a@b.com"}] + ) + + result = emails.get("req_123") + assert isinstance(result, EmailDetail) + assert result.transmission_id == "req_123" + assert result.state == "delivered" + assert result.from_email == "sender@example.com" + assert result.recipients == ["a@b.com"] + assert result.num_recipients == 1 + assert len(result.events) == 1 + assert result.events[0].type == "delivery" + + def test_get_with_date_filters(self, emails: Emails, mock_client: MagicMock) -> None: + mock_client.get.return_value = _detail_envelope() + + emails.get("req_123", from_date="2025-01-01", to_date="2025-06-01") + params = mock_client.get.call_args.kwargs["params"] + assert params["from"] == "2025-01-01" + assert params["to"] == "2025-06-01" + + def test_get_parses_nested_geo_ip(self, emails: Emails, mock_client: MagicMock) -> None: + mock_client.get.return_value = _detail_envelope( + events=[ + { + "event_id": "ev1", + "type": "open", + "geo_ip": { + "country": "US", + "city": "San Francisco", + "latitude": 37.7749, + "longitude": -122.4194, + }, + "user_agent_parsed": { + "agent_family": "Chrome", + "is_mobile": False, + }, + } + ] + ) + + result = emails.get("req_123") + event = result.events[0] + assert event.geo_ip is not None + assert event.geo_ip.country == "US" + assert event.geo_ip.latitude == 37.7749 + assert event.user_agent_parsed is not None + assert event.user_agent_parsed.agent_family == "Chrome" + assert event.user_agent_parsed.is_mobile is False + + +class TestListEvents: + def test_list_events_basic(self, emails: Emails, mock_client: MagicMock) -> None: + mock_client.get.return_value = { + "data": { + "events": { + "data": [ + {"event_id": "ev1", "type": "delivery"}, + {"event_id": "ev2", "type": "bounce"}, + ], + "total_count": 2, + "from": "2025-01-01T00:00:00Z", + "to": "2025-01-15T00:00:00Z", + "pagination": {"next_cursor": None, "per_page": 25}, + } + } + } + + result = emails.list_events() + assert isinstance(result, EmailEventList) + assert len(result.results) == 2 + assert result.results[0].type == "delivery" + assert result.date_from == "2025-01-01T00:00:00Z" + + def test_list_events_with_filters(self, emails: Emails, mock_client: MagicMock) -> None: + mock_client.get.return_value = { + "data": { + "events": { + "data": [], + "total_count": 0, + "from": None, + "to": None, + "pagination": {"per_page": 10}, + } + } + } + + emails.list_events( + events=["delivery", "bounce"], + recipients=["a@b.com"], + from_date="2025-01-01", + to_date="2025-12-31", + per_page=10, + cursor="abc", + transmissions="tr_123", + bounce_classes="10,30", + ) + + params = mock_client.get.call_args.kwargs["params"] + assert params["events"] == "delivery,bounce" + assert params["recipients"] == "a@b.com" + assert params["from"] == "2025-01-01" + assert params["transmissions"] == "tr_123" + assert params["bounce_classes"] == "10,30" + + +class TestSchedule: + def test_schedule_email(self, emails: Emails, mock_client: MagicMock) -> None: + mock_client.post.return_value = { + "message": "Email scheduled for delivery.", + "data": { + "request_id": "12345678901234567890", + "accepted": 1, + "rejected": 0, + }, + } + + result = emails.schedule( + from_email="a@b.com", + to=["c@d.com"], + subject="Later", + html="

Hi

", + scheduled_at="2025-12-01T10:00:00Z", + tag="scheduled-test", + ) + + assert isinstance(result, SendEmailResponse) + assert result.request_id == "12345678901234567890" + assert result.accepted == 1 + assert result.rejected == 0 + + payload = mock_client.post.call_args.kwargs["json"] + assert payload["scheduled_at"] == "2025-12-01T10:00:00Z" + assert payload["tag"] == "scheduled-test" + mock_client.post.assert_called_once() + assert mock_client.post.call_args.args[0] == "/emails/scheduled" + + +class TestGetScheduled: + def test_get_scheduled(self, emails: Emails, mock_client: MagicMock) -> None: + mock_client.get.return_value = { + "message": "Scheduled transmission retrieved successfully.", + "data": { + "transmission_id": "tr_123", + "state": "submitted", + "scheduled_at": "2025-12-01T10:00:00Z", + "from": "sender@example.com", + "from_name": "Sender", + "subject": "Scheduled Newsletter", + "recipients": ["recipient@example.com"], + "num_recipients": 1, + "events": [], + }, + } + + result = emails.get_scheduled("tr_123") + assert isinstance(result, ScheduledEmail) + assert result.transmission_id == "tr_123" + assert result.state == "submitted" + assert result.from_email == "sender@example.com" + assert result.from_name == "Sender" + assert result.subject == "Scheduled Newsletter" + assert result.recipients == ["recipient@example.com"] + assert result.num_recipients == 1 + assert result.events == [] + mock_client.get.assert_called_once_with("/emails/scheduled/tr_123") + + def test_get_scheduled_with_events( + self, emails: Emails, mock_client: MagicMock + ) -> None: + """Regression: events array contains EmailEvent objects, not strings.""" + mock_client.get.return_value = { + "data": { + "transmission_id": "tr_123", + "state": "delivered", + "scheduled_at": None, + "from": "sender@example.com", + "from_name": None, + "subject": "Scheduled Newsletter", + "recipients": ["recipient@example.com"], + "num_recipients": 1, + "events": [ + { + "event_id": "evt-1", + "type": "delivery", + "timestamp": "2024-01-16T10:00:02.000Z", + "rcpt_to": "recipient@example.com", + } + ], + } + } + + result = emails.get_scheduled("tr_123") + assert len(result.events) == 1 + assert result.events[0].type == "delivery" + assert result.events[0].rcpt_to == "recipient@example.com" + + +class TestCancelScheduled: + def test_cancel_scheduled(self, emails: Emails, mock_client: MagicMock) -> None: + mock_client.delete.return_value = None + + result = emails.cancel_scheduled("tr_123") + assert result is None + mock_client.delete.assert_called_once_with("/emails/scheduled/tr_123") diff --git a/tests/test_exceptions.py b/tests/test_exceptions.py new file mode 100644 index 0000000..a4af366 --- /dev/null +++ b/tests/test_exceptions.py @@ -0,0 +1,95 @@ +"""Tests for exception handling and raise_for_status.""" + +from __future__ import annotations + +import pytest + +from lettr._exceptions import ( + AuthenticationError, + BadRequestError, + ConflictError, + ForbiddenError, + LettrError, + NotFoundError, + RateLimitError, + ServerError, + ValidationError, + raise_for_status, +) + + +class TestRaiseForStatus: + def test_2xx_does_not_raise(self) -> None: + raise_for_status(200, {"message": "ok"}) + raise_for_status(201, {"message": "created"}) + raise_for_status(204, None) + + def test_400_raises_bad_request(self) -> None: + with pytest.raises(BadRequestError) as exc_info: + raise_for_status(400, {"message": "bad", "error_code": "invalid_domain"}) + assert exc_info.value.message == "bad" + assert exc_info.value.error_code == "invalid_domain" + + def test_401_raises_authentication_error(self) -> None: + with pytest.raises(AuthenticationError) as exc_info: + raise_for_status(401, {"message": "Unauthenticated"}) + assert exc_info.value.message == "Unauthenticated" + + def test_403_raises_forbidden_error(self) -> None: + with pytest.raises(ForbiddenError) as exc_info: + raise_for_status(403, {"message": "Forbidden"}) + assert exc_info.value.message == "Forbidden" + + def test_404_raises_not_found(self) -> None: + with pytest.raises(NotFoundError): + raise_for_status(404, {"message": "Not found"}) + + def test_409_raises_conflict(self) -> None: + with pytest.raises(ConflictError): + raise_for_status(409, {"message": "Already exists"}) + + def test_422_raises_validation_error(self) -> None: + body = { + "message": "Validation failed", + "errors": {"email": ["required"]}, + } + with pytest.raises(ValidationError) as exc_info: + raise_for_status(422, body) + assert exc_info.value.errors == {"email": ["required"]} + + def test_422_without_errors_field(self) -> None: + with pytest.raises(ValidationError) as exc_info: + raise_for_status(422, {"message": "Invalid"}) + assert exc_info.value.errors == {} + + def test_429_raises_rate_limit_error(self) -> None: + with pytest.raises(RateLimitError) as exc_info: + raise_for_status(429, {"message": "Quota exceeded", "error_code": "quota_exceeded"}) + assert exc_info.value.error_code == "quota_exceeded" + + def test_500_raises_server_error(self) -> None: + with pytest.raises(ServerError) as exc_info: + raise_for_status(500, {"message": "Internal error", "error_code": "send_error"}) + assert exc_info.value.error_code == "send_error" + + def test_502_raises_server_error(self) -> None: + with pytest.raises(ServerError): + raise_for_status(502, {"message": "Bad gateway", "error_code": "transmission_failed"}) + + def test_non_dict_body_raises_lettr_error(self) -> None: + with pytest.raises(LettrError, match="Unexpected error"): + raise_for_status(500, "not a dict") + + def test_unknown_status_raises_lettr_error(self) -> None: + with pytest.raises(LettrError, match="HTTP 418"): + raise_for_status(418, {"message": "I'm a teapot"}) + + +class TestValidationErrorStr: + def test_str_with_errors(self) -> None: + err = ValidationError("Failed", errors={"name": ["required", "too short"]}) + assert "name: required, too short" in str(err) + + def test_str_without_errors(self) -> None: + err = ValidationError("Failed") + assert str(err) == "Failed" diff --git a/tests/test_lettr.py b/tests/test_lettr.py new file mode 100644 index 0000000..c946a45 --- /dev/null +++ b/tests/test_lettr.py @@ -0,0 +1,65 @@ +"""Tests for the Lettr main client class.""" + +from __future__ import annotations + +from unittest.mock import MagicMock + +import pytest + +from lettr import AuthCheck, HealthCheck, Lettr + + +class TestLettrConstructor: + def test_empty_api_key_raises(self) -> None: + with pytest.raises(ValueError, match="api_key parameter is required"): + Lettr("") + + def test_creates_resources(self) -> None: + client = Lettr("lttr_test") + assert hasattr(client, "emails") + assert hasattr(client, "domains") + assert hasattr(client, "templates") + assert hasattr(client, "webhooks") + assert hasattr(client, "projects") + client.close() + + def test_repr(self) -> None: + client = Lettr("lttr_test") + assert "app.lettr.com" in repr(client) + client.close() + + +class TestLettrContextManager: + def test_context_manager(self) -> None: + with Lettr("lttr_test") as client: + assert client is not None + + +class TestLettrHealth: + def test_health(self) -> None: + client = Lettr("lttr_test") + client._client.get_no_auth = MagicMock( + return_value={"data": {"status": "ok", "timestamp": "2024-01-15T10:30:00Z"}} + ) + + result = client.health() + assert isinstance(result, HealthCheck) + assert result.status == "ok" + assert result.timestamp == "2024-01-15T10:30:00Z" + client._client.get_no_auth.assert_called_once_with("/health") + client.close() + + +class TestLettrAuthCheck: + def test_auth_check(self) -> None: + client = Lettr("lttr_test") + client._client.get = MagicMock( + return_value={"data": {"team_id": 1, "timestamp": "2024-01-15T10:30:00Z"}} + ) + + result = client.auth_check() + assert isinstance(result, AuthCheck) + assert result.team_id == 1 + assert result.timestamp == "2024-01-15T10:30:00Z" + client._client.get.assert_called_once_with("/auth/check") + client.close() diff --git a/tests/test_projects.py b/tests/test_projects.py new file mode 100644 index 0000000..0a05586 --- /dev/null +++ b/tests/test_projects.py @@ -0,0 +1,64 @@ +"""Tests for the Projects resource.""" + +from __future__ import annotations + +from unittest.mock import MagicMock + +import pytest + +from lettr._types import ProjectList +from lettr.resources.projects import Projects + + +@pytest.fixture() +def projects(mock_client: MagicMock) -> Projects: + return Projects(mock_client) + + +class TestList: + def test_list_projects(self, projects: Projects, mock_client: MagicMock) -> None: + mock_client.get.return_value = { + "data": { + "projects": [ + { + "id": 1, + "name": "Main", + "team_id": 10, + "emoji": "🚀", + "created_at": "2025-01-01", + "updated_at": "2025-06-01", + } + ], + "pagination": { + "total": 1, + "per_page": 25, + "current_page": 1, + "last_page": 1, + }, + } + } + + result = projects.list() + assert isinstance(result, ProjectList) + assert len(result.projects) == 1 + assert result.projects[0].name == "Main" + assert result.projects[0].emoji == "🚀" + assert result.total == 1 + + def test_list_with_pagination(self, projects: Projects, mock_client: MagicMock) -> None: + mock_client.get.return_value = { + "data": { + "projects": [], + "pagination": { + "total": 0, + "per_page": 10, + "current_page": 2, + "last_page": 2, + }, + } + } + + projects.list(per_page=10, page=2) + params = mock_client.get.call_args.kwargs["params"] + assert params["per_page"] == 10 + assert params["page"] == 2 diff --git a/tests/test_templates.py b/tests/test_templates.py new file mode 100644 index 0000000..6a016fc --- /dev/null +++ b/tests/test_templates.py @@ -0,0 +1,171 @@ +"""Tests for the Templates resource.""" + +from __future__ import annotations + +from unittest.mock import MagicMock + +import pytest + +from lettr._types import Template, TemplateList, TemplateMergeTags +from lettr.resources.templates import Templates + + +@pytest.fixture() +def templates(mock_client: MagicMock) -> Templates: + return Templates(mock_client) + + +class TestList: + def test_list_templates(self, templates: Templates, mock_client: MagicMock) -> None: + mock_client.get.return_value = { + "data": { + "templates": [ + { + "id": 1, + "name": "Welcome", + "slug": "welcome", + "project_id": 10, + "folder_id": 5, + "created_at": "2025-01-01", + "updated_at": "2025-06-01", + } + ], + "pagination": { + "total": 1, + "per_page": 25, + "current_page": 1, + "last_page": 1, + }, + } + } + + result = templates.list(project_id=10) + assert isinstance(result, TemplateList) + assert len(result.templates) == 1 + assert result.templates[0].slug == "welcome" + + params = mock_client.get.call_args.kwargs["params"] + assert params["project_id"] == 10 + + +class TestGet: + def test_get_template(self, templates: Templates, mock_client: MagicMock) -> None: + mock_client.get.return_value = { + "data": { + "id": 1, + "name": "Welcome", + "slug": "welcome", + "project_id": 10, + "folder_id": 5, + "created_at": "2025-01-01", + "active_version": 3, + "versions_count": 3, + "html": "

Hi

", + } + } + + result = templates.get("welcome", project_id=10) + assert isinstance(result, Template) + assert result.active_version == 3 + assert result.html == "

Hi

" + + +class TestCreate: + def test_create_template(self, templates: Templates, mock_client: MagicMock) -> None: + mock_client.post.return_value = { + "data": { + "id": 2, + "name": "New", + "slug": "new", + "project_id": 10, + "folder_id": 5, + "active_version": 1, + "merge_tags": [ + {"key": "name", "required": True, "type": "text"}, + { + "key": "items", + "required": False, + "type": "loop", + "children": [{"key": "title", "type": "text"}], + }, + ], + "created_at": "2025-01-01", + } + } + + result = templates.create(name="New", html="

{{name}}

", project_id=10) + assert result.slug == "new" + assert result.merge_tags is not None + assert len(result.merge_tags) == 2 + assert result.merge_tags[0].key == "name" + assert result.merge_tags[0].required is True + assert result.merge_tags[1].children is not None + assert result.merge_tags[1].children[0].key == "title" + + +class TestUpdate: + def test_update_template(self, templates: Templates, mock_client: MagicMock) -> None: + mock_client.put.return_value = { + "data": { + "id": 1, + "name": "Updated", + "slug": "welcome", + "project_id": 10, + "folder_id": 5, + "active_version": 4, + "created_at": "2025-01-01", + "updated_at": "2025-06-15", + } + } + + result = templates.update("welcome", name="Updated") + assert result.name == "Updated" + assert result.updated_at == "2025-06-15" + + payload = mock_client.put.call_args.kwargs["json"] + assert payload == {"name": "Updated"} + + +class TestDelete: + def test_delete_template(self, templates: Templates, mock_client: MagicMock) -> None: + mock_client.delete.return_value = None + templates.delete("welcome", project_id=10) + mock_client.delete.assert_called_once() + params = mock_client.delete.call_args.kwargs["params"] + assert params["project_id"] == 10 + + +class TestGetMergeTags: + def test_get_merge_tags(self, templates: Templates, mock_client: MagicMock) -> None: + mock_client.get.return_value = { + "data": { + "template_slug": "welcome", + "version": 3, + "project_id": 10, + "merge_tags": [ + {"key": "name", "required": True, "type": "text"}, + ], + } + } + + result = templates.get_merge_tags("welcome", project_id=10, version=3) + assert isinstance(result, TemplateMergeTags) + assert result.template_slug == "welcome" + assert result.version == 3 + assert result.project_id == 10 + assert len(result.merge_tags) == 1 + + +class TestGetHtml: + def test_get_html(self, templates: Templates, mock_client: MagicMock) -> None: + mock_client.get.return_value = { + "data": {"html": "

Welcome!

"} + } + + result = templates.get_html(project_id=10, slug="welcome") + assert result == "

Welcome!

" + + params = mock_client.get.call_args.kwargs["params"] + assert params["project_id"] == 10 + assert params["slug"] == "welcome" + assert mock_client.get.call_args.args[0] == "/templates/html" diff --git a/tests/test_types.py b/tests/test_types.py new file mode 100644 index 0000000..2da38ef --- /dev/null +++ b/tests/test_types.py @@ -0,0 +1,72 @@ +"""Tests for type definitions.""" + +from __future__ import annotations + +from lettr._types import EmailOptions, GeoIp, ScheduledEmail, UserAgentParsed + + +class TestEmailOptions: + def test_to_dict_filters_none(self) -> None: + opts = EmailOptions(click_tracking=True, open_tracking=None, transactional=False) + result = opts.to_dict() + assert result == {"click_tracking": True, "transactional": False} + + def test_to_dict_empty(self) -> None: + opts = EmailOptions() + assert opts.to_dict() == {} + + def test_to_dict_all_set(self) -> None: + opts = EmailOptions( + click_tracking=True, + open_tracking=False, + transactional=True, + inline_css=False, + perform_substitutions=True, + ) + result = opts.to_dict() + assert len(result) == 5 + assert result["perform_substitutions"] is True + + +class TestGeoIp: + def test_all_optional(self) -> None: + geo = GeoIp() + assert geo.country is None + assert geo.latitude is None + + def test_with_values(self) -> None: + geo = GeoIp(country="US", latitude=37.77, longitude=-122.42) + assert geo.country == "US" + assert geo.latitude == 37.77 + + +class TestUserAgentParsed: + def test_all_optional(self) -> None: + ua = UserAgentParsed() + assert ua.agent_family is None + assert ua.is_mobile is None + + def test_with_values(self) -> None: + ua = UserAgentParsed(agent_family="Chrome", is_mobile=True) + assert ua.agent_family == "Chrome" + assert ua.is_mobile is True + + +class TestScheduledEmail: + def test_creation(self) -> None: + se = ScheduledEmail( + transmission_id="tr_123", + state="submitted", + from_email="sender@example.com", + subject="Hello", + recipients=["a@b.com"], + num_recipients=1, + events=[], + scheduled_at="2025-12-01T10:00:00Z", + ) + assert se.transmission_id == "tr_123" + assert se.state == "submitted" + assert se.from_email == "sender@example.com" + assert se.num_recipients == 1 + assert se.events == [] + assert se.from_name is None diff --git a/tests/test_webhooks.py b/tests/test_webhooks.py new file mode 100644 index 0000000..97bdc67 --- /dev/null +++ b/tests/test_webhooks.py @@ -0,0 +1,140 @@ +"""Tests for the Webhooks resource.""" + +from __future__ import annotations + +from unittest.mock import MagicMock + +import pytest + +from lettr._types import Webhook +from lettr.resources.webhooks import Webhooks + + +@pytest.fixture() +def webhooks(mock_client: MagicMock) -> Webhooks: + return Webhooks(mock_client) + + +WEBHOOK_DATA = { + "id": "wh_123", + "name": "My Hook", + "url": "https://example.com/hook", + "enabled": True, + "auth_type": "none", + "has_auth_credentials": False, + "event_types": ["delivery", "bounce"], + "last_successful_at": "2025-06-01", + "last_failure_at": None, + "last_status": "200", +} + + +class TestList: + def test_list_webhooks(self, webhooks: Webhooks, mock_client: MagicMock) -> None: + mock_client.get.return_value = { + "data": {"webhooks": [WEBHOOK_DATA]} + } + + result = webhooks.list() + assert len(result) == 1 + assert isinstance(result[0], Webhook) + assert result[0].id == "wh_123" + assert result[0].event_types == ["delivery", "bounce"] + + +class TestGet: + def test_get_webhook(self, webhooks: Webhooks, mock_client: MagicMock) -> None: + mock_client.get.return_value = {"data": WEBHOOK_DATA} + + result = webhooks.get("wh_123") + assert isinstance(result, Webhook) + assert result.name == "My Hook" + mock_client.get.assert_called_once_with("/webhooks/wh_123") + + +class TestCreate: + def test_create_webhook(self, webhooks: Webhooks, mock_client: MagicMock) -> None: + mock_client.post.return_value = {"data": WEBHOOK_DATA} + + result = webhooks.create( + name="My Hook", + url="https://example.com/hook", + auth_type="none", + events_mode="selected", + events=["delivery", "bounce"], + ) + + assert isinstance(result, Webhook) + assert result.id == "wh_123" + + payload = mock_client.post.call_args.kwargs["json"] + assert payload["name"] == "My Hook" + assert payload["auth_type"] == "none" + assert payload["events_mode"] == "selected" + assert payload["events"] == ["delivery", "bounce"] + + def test_create_with_basic_auth(self, webhooks: Webhooks, mock_client: MagicMock) -> None: + mock_client.post.return_value = { + "data": {**WEBHOOK_DATA, "auth_type": "basic", "has_auth_credentials": True} + } + + webhooks.create( + name="Secure Hook", + url="https://example.com/hook", + auth_type="basic", + events_mode="all", + auth_username="user", + auth_password="pass", + ) + + payload = mock_client.post.call_args.kwargs["json"] + assert payload["auth_username"] == "user" + assert payload["auth_password"] == "pass" + + def test_create_with_oauth2(self, webhooks: Webhooks, mock_client: MagicMock) -> None: + mock_client.post.return_value = { + "data": {**WEBHOOK_DATA, "auth_type": "oauth2", "has_auth_credentials": True} + } + + webhooks.create( + name="OAuth Hook", + url="https://example.com/hook", + auth_type="oauth2", + events_mode="all", + oauth_client_id="cid", + oauth_client_secret="csecret", + oauth_token_url="https://auth.example.com/token", + ) + + payload = mock_client.post.call_args.kwargs["json"] + assert payload["oauth_client_id"] == "cid" + assert payload["oauth_client_secret"] == "csecret" + assert payload["oauth_token_url"] == "https://auth.example.com/token" + + +class TestUpdate: + def test_update_webhook(self, webhooks: Webhooks, mock_client: MagicMock) -> None: + mock_client.put.return_value = { + "data": {**WEBHOOK_DATA, "name": "Renamed Hook"} + } + + result = webhooks.update("wh_123", name="Renamed Hook", active=False) + assert result.name == "Renamed Hook" + + payload = mock_client.put.call_args.kwargs["json"] + assert payload == {"name": "Renamed Hook", "active": False} + + 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"]) + payload = mock_client.put.call_args.kwargs["json"] + assert payload == {"events": ["delivery"]} + assert "name" not in payload + + +class TestDelete: + def test_delete_webhook(self, webhooks: Webhooks, mock_client: MagicMock) -> None: + mock_client.delete.return_value = None + webhooks.delete("wh_123") + mock_client.delete.assert_called_once_with("/webhooks/wh_123") From 130579b9c5c5d371da8c7d872ab2f284c4ed4473 Mon Sep 17 00:00:00 2001 From: voj-tech-j Date: Thu, 16 Apr 2026 12:07:03 +0200 Subject: [PATCH 2/4] more fixes --- src/lettr/__init__.py | 6 ++ .../__pycache__/__init__.cpython-313.pyc | Bin 5673 -> 5770 bytes .../__pycache__/_exceptions.cpython-313.pyc | Bin 5673 -> 6247 bytes src/lettr/__pycache__/_types.cpython-313.pyc | Bin 15217 -> 17283 bytes src/lettr/_exceptions.py | 18 +++++- src/lettr/_types.py | 58 ++++++++++++++++-- .../__pycache__/domains.cpython-313.pyc | Bin 5892 -> 5879 bytes .../__pycache__/emails.cpython-313.pyc | Bin 20772 -> 21755 bytes .../__pycache__/projects.cpython-313.pyc | Bin 2156 -> 2126 bytes .../__pycache__/templates.cpython-313.pyc | Bin 9861 -> 10157 bytes src/lettr/resources/domains.py | 27 +++++--- src/lettr/resources/emails.py | 14 +++++ src/lettr/resources/projects.py | 2 +- src/lettr/resources/templates.py | 21 +++++-- .../test_domains.cpython-313-pytest-9.0.3.pyc | Bin 23638 -> 29632 bytes ...est_templates.cpython-313-pytest-9.0.3.pyc | Bin 23315 -> 30452 bytes tests/test_domains.py | 32 ++++++++-- tests/test_templates.py | 30 ++++++++- 18 files changed, 175 insertions(+), 33 deletions(-) diff --git a/src/lettr/__init__.py b/src/lettr/__init__.py index e3bf523..8ca5e40 100644 --- a/src/lettr/__init__.py +++ b/src/lettr/__init__.py @@ -33,7 +33,9 @@ AuthCheck, DkimInfo, DmarcValidationResult, + DnsProvider, Domain, + DomainDnsVerification, DomainVerification, Email, EmailDetail, @@ -51,6 +53,7 @@ SendEmailResponse, SpfValidationResult, Template, + TemplateHtml, TemplateList, TemplateMergeTags, UserAgentParsed, @@ -78,7 +81,9 @@ "AuthCheck", "DkimInfo", "DmarcValidationResult", + "DnsProvider", "Domain", + "DomainDnsVerification", "DomainVerification", "Email", "EmailDetail", @@ -96,6 +101,7 @@ "SendEmailResponse", "SpfValidationResult", "Template", + "TemplateHtml", "TemplateList", "TemplateMergeTags", "UserAgentParsed", diff --git a/src/lettr/__pycache__/__init__.cpython-313.pyc b/src/lettr/__pycache__/__init__.cpython-313.pyc index 71c4a7dabf3e10f951b241d7a3e5ca1c71ecbead..922396d743eff052c6787ff8aff5bf351a4117ed 100644 GIT binary patch delta 1192 zcmaJ=OK%fb6rSrDY{z5g;WVfa0;VdWhP=a5kU%NnF+fVOfR>_OjqPhZW$cO09fR0F z#D+(Us&FNKKzFGsffNB!x2PLhRjLH2nicAzQWsQ|QcdnsPRr-Rq_6_#lraTJ4Ij<@p zO70CBuGfru;uBpb1b8*C;)Y(Jzyxa?MP48`B4 z=T|2Gu##IenQFHSr-x9Ck^+nS5(Y_0RW+4_5z^X1(U|t#P)HahJ$+u3>p^fhV;McBTmF}^Pmt84%3w&5Y+{pK_N9v3G zy75WkN$R_Yag<>v!ES;Hf?Wtx#k79E$dn<(k60t$R`o33`(d5L0s{7_WxBzCWp9y8 zqgJ%wGd!YXP^S5a6BY;6)XDCt(gzp=M+lA)(2c^U1e~CX5cO~_Lmcx8ck$i5atmjX zb2vo-^iz5|fH*ZwtADyH?`j9xri$Ajz%$BlUTcLd5SUAI1v}KGx1L;DyeAaa20{ic zsNlTQHURC19(BDA@ebIWpA#UxpvifZ;@tIpEUG(L+EtX^*$ApFUrfMxtV7cvn+VTM z#@L^Uc4n#XdsZD;%@VJS|AW7^cfw%%*kfbzX?xF%{leO4CuUNwPBX#S_zQzjU&k9@ C*C)gP delta 1125 zcmaJ=O-vI}5Pq+_Y=5?;w4jkcE&fCsp+!N12nYnwRD@^~B++DD%7Ydc*351-o+Mrr zBQfNiy$~;$ctSmR)q{zK#Dju+(VOw0QHDT`otZcDeUmry?ldhmMt(#h zAtJuj`45HYLZq6Kh5gUF5+ti+{ihoxgT9sf8o%s7xjFX*!AE0*ap2h!*VuCcMaZC`evNxE0S zo?XdNJslIJRl&cKpR%-lgu?{Y{M+NdDtl;OA2)1 zfjVZ7Uxz+nI*NcqA^|5buH~6&xp^-<$XpjvCGR2cDetDe{C)T)SEEn5u*~zj%$zm~ z<0U;GoDsn-+KSMII7Uwi_$$LVq7C#hudWynl$R=Q4a!c-a+d9|B`Vu7{mL&I@Ei-dXZ75WW|l3$F%h-UMpi2I`=fx5Sd+EM{y{@O*4n{3QnbvSckP`wfMfyUewX#RG?kha15an;W$EvfT3hSPS#T#7fR#J;aMwhudE$r zXyOb-pVG!V_+wA}W8)GgRk$F2o@S5X=9CKlP0H~Hl|#F_MGj;Th7s_tVHDvag2`i5 zwbkd*#P;Qe0J2M%;{8>*z48EKL&+mxiN6;zjESOK1ZEh3y9I6hb=A8YUJ#gL7a=E> z9G?K-qmcV8-#}^cb;G#Iz$w@>hT-`Q!?Y~HA|dyqBdp?n#H>7977oxm*d*oCt^=**rd4A{IduAi>O_ug3 zH3@9Zzue7k(N~gBBz_ncufmw#2O=&fI0oOIYaQ+IPX7) z#%%~5gmwlw!eC6Z;CFuoJU~qsNI%Q$W_5?HbXv_v)nz5ki~KIbb5oEtVs`ozdh{~L zi4446@BMNlGy5u@sPn3}W&nLoBk=nAaEE-g0LWIie;l_6GJ6jDGI+Ev}bsUMNy3~QpaxiD7$Ed9Z<84j6sL_PNP=t7Q8jft&wBX zz_w&$PS@#4&{<-zO|4`Se$t1Ag9s~9u=Ub$5jriCb^JylX?4P&-igNvaSJ80uw=Pc ziTA@2Xq`JQo;wmlHNtFN;DMusqe-fPL1h`(Mkfel?<+jA{+?kceT7zZ@vb@3nT8CB z33K#pTo96|#jD6yjaI5^@y^s}H^10)q>mpO4%l59Q*J8iZN;0xanx`Cvjuj={SD;- z^nuOMPH{#mk`RO)LlNH14kqR!$?qRO=JHHn&AfQOSZvPallkRbBBiD?MR8G0uT&y$ zI=b}>%rXu;jwkT0#qMcaKmS3BY&Au88|{VE$42*#R29LqmQ?dKW?s}~mAsY!lXI$@ z{}lKb@-c{FUQVsbKwE%SXFJKjQ|F+pC!K}MC4s$4RvYmYZj=P}D)plL;(@$&4}YA2 EzhCAK@Bjb+ delta 1056 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 Rk2vRa3^X%J8?5N=_#Zk@zm@<1 delta 1808 zcmZ9MdrZ?;6vum^Q2HwrC@u6!p-|dFbp_i3BCC%nx;TdWEn8;DGDcWa9PRScOs69= z)98E!;h-~1wi$JgsKaL#vk9Bq98otP@Xu1?d_^5|4-CQV56yPJmu@Ee{qe~;_ndp~ zIp=roUAK|ldtE<~lOw74cY&_P_PKlYX4cy4S-@r;i5I4;qYb{sV>i-A3~A+zUUU2C zqCQBvf>~Ai)=W9q!?(%lN}im@wfvNpFRQs`NNEOHO{+lH zbf1IvnBz2JiDoFXcucCd9$;2lQfH;Vtu++3)zB@!kp^x0v|$zfXse(RyYX1R-NV=( zI_dZ%?;XBL*$iv}w$gRKSy~Sf*h4#to7j5l&9~6=u66W=Glvb(M(1tnDlV3K;06rP zj*@29BitUAbhANPQ|c~!7v>EB@>Mo+G%J2uWpiYrZOS&P))vr~5-s*-)GT>1b4UIhAq9e{|q6OuS(7nczGbg4($jaA?beOT7ac8jbWzIvKnUdgV| zvhtO>kKj@Yd`z(tm$VlmaD^=H2DVphiMu~#cc`f1uK~okuevZhx;}8KJXvjOC z9T%bdD-X<ySQjn9(Id?L}+7K@LmXnoZH zE%3L~m8sP}a0%T4&kX#oH@K)n(K@t(;bR ztn58vjV{Uw80lQ4AvcR>Bc-y*l6BPUC~DFulC0BMhl7^|ApX*R!uf*EhR>TtVn2GuW749#P(-1Jo!Dj;ESHRZ*?uc@Vt_6y+l}^a0 zZneFy!bH{8A=)R|s3n}?e6&II;q1Sj$~j0si0aQk1Wc&DjRvc&6q)8UxqTPag}bCt zweP1=_u6?EwZ)@qH$AMj^TEFKSPOO5L|G+`)C4k<>X#FVS4lI&&sI@zMi*W3d!_RT z3slnRj8=AD{NqKzg)BmS!MCI&%s_-}As0)+%$j+N%cv`uM_WR<4jpe-wrFvVqK#(x z^vSHJI+SR~vu)8>6P*r~XfE^p3zSRrTgXh>u%Ai%!Eow+M(BCKRZ-v-h(Hqx^wHL^ zi%x{|_#MJ?a3lOBTS6byI@kp|SzDEjxk$Bpm?mn2Y&Ci77Fvf9mqcpzKylh&iX}cGhM5%mF~g`Xs6Pd)7f3&6r1@Hi__VeOS8wgrCEu> z=_UmleC(zOeGdyjoZf7h&hBAXUt^qgQ=-x2xd5vf&;efHQ3=V6j;=&!jL-RjMqlQA z7{$m-lMsP!deGRwCR6#!dR>c>=FMvIw(3M3Kj_} zJowVtcIhDx!T+CX`5{_6d$Q2PLfcIfd}c!v>8do{cp8_+12i None: + self.error_code = error_code + super().__init__(message) + class ValidationError(LettrError): """Raised when request validation fails (422).""" @@ -44,10 +48,18 @@ def __str__(self) -> str: class NotFoundError(LettrError): """Raised when a resource is not found (404).""" + def __init__(self, message: str, error_code: str | None = None) -> None: + self.error_code = error_code + super().__init__(message) + class ConflictError(LettrError): """Raised when a resource already exists (409).""" + def __init__(self, message: str, error_code: str | None = None) -> None: + self.error_code = error_code + super().__init__(message) + class BadRequestError(LettrError): """Raised for client-side errors (400).""" @@ -91,13 +103,13 @@ def raise_for_status(status_code: int, body: Any) -> None: raise AuthenticationError(message) if status_code == 403: - raise ForbiddenError(message) + raise ForbiddenError(message=message, error_code=error_code) if status_code == 404: - raise NotFoundError(message) + raise NotFoundError(message=message, error_code=error_code) if status_code == 409: - raise ConflictError(message) + raise ConflictError(message=message, error_code=error_code) if status_code == 422: raise ValidationError(message=message, errors=body.get("errors")) diff --git a/src/lettr/_types.py b/src/lettr/_types.py index 529f8e2..a4412e4 100644 --- a/src/lettr/_types.py +++ b/src/lettr/_types.py @@ -141,6 +141,20 @@ class EmailEvent(Email): reason: str | None = None raw_reason: str | None = None error_code: str | None = None + bounce_class: int | None = None + queue_time: int | None = None + outbound_tls: str | None = None + num_retries: int | None = None + device_token: str | None = None + target_link_url: str | None = None + target_link_name: str | None = None + user_agent: str | None = None + ip_address: str | None = None + initial_pixel: bool | None = None + fbtype: str | None = None + report_by: str | None = None + report_to: str | None = None + remote_addr: str | None = None campaign_id: str | None = None template_id: str | None = None template_version: str | None = None @@ -253,6 +267,16 @@ class SpfValidationResult: includes_sparkpost: bool | None = None +@dataclass +class DnsProvider: + """DNS provider information for a domain.""" + + provider: str | None = None + provider_label: str | None = None + nameservers: list[str] | None = None + error: str | None = None + + @dataclass class Domain: """A sending domain.""" @@ -268,12 +292,26 @@ class Domain: is_primary_domain: bool | None = None tracking_domain: str | None = None dns: dict[str, Any] | None = None - dns_provider: str | None = None + dns_provider: DnsProvider | None = None dkim: DkimInfo | None = None created_at: str | None = None updated_at: str | None = None +@dataclass +class DomainDnsVerification: + """DNS verification error details for a domain.""" + + dkim_record: str | None = None + cname_record: str | None = None + dkim_error: str | None = None + cname_error: str | None = None + dmarc_record: str | None = None + dmarc_error: str | None = None + spf_record: str | None = None + spf_error: str | None = None + + @dataclass class DomainVerification: """Domain verification result.""" @@ -281,11 +319,11 @@ class DomainVerification: domain: str dkim_status: str cname_status: str - dmarc_status: str | None = None - spf_status: str | None = None - is_primary_domain: bool | None = None + dmarc_status: str + spf_status: str + is_primary_domain: bool ownership_verified: str | None = None - dns: dict[str, Any] | None = None + dns: DomainDnsVerification | None = None dmarc: DmarcValidationResult | None = None spf: SpfValidationResult | None = None @@ -331,6 +369,7 @@ class MergeTag: key: str required: bool = False type: str | None = None + name: str | None = None children: list[MergeTagChild] | None = None @@ -373,6 +412,15 @@ class TemplateMergeTags: project_id: int | None = None +@dataclass +class TemplateHtml: + """Response from the get template HTML endpoint.""" + + html: str + merge_tags: list[MergeTag] | None = None + subject: str | None = None + + # --------------------------------------------------------------------------- # Project types # --------------------------------------------------------------------------- diff --git a/src/lettr/resources/__pycache__/domains.cpython-313.pyc b/src/lettr/resources/__pycache__/domains.cpython-313.pyc index 30e6c2a5e241614c0200fb26b154f1f085c39034..9c03391a23943c9db9a5998e0b5e1e8b67004c2e 100644 GIT binary patch delta 1893 zcmaJ>Uu;uV7(b`If7*N7uIu`D?b>dvgG*ribIPANHYstLNH7hCI6||nomN;o&+RZ6 zq$Cmpi3-iZWW1;k#E2$3+`CWu;)_g-)Egc!h8LsB#ugVJO#HrcTgHo?q`!0e{X6IT zzTf>W^{;RDu6n&1f%U@TgVOJHZ;Te|o1agPd6{?2$9!oLB|Rjkc9NWHz=3vzu(tZ8 zVd|=X*Rxg6md#_Va<)_`GE2!;W+qByD`fAWdb!9-)1|42YN=BHbAbjIKcer@ZApk> zssI;BBhvN0XBC4^QN-r*!R{o4t}gSh^j-XdGeZyYo6aq+oGOFrxPvdKDXvT1{W;AR zLOi(jY`r4s+rmByRe+gmr~0{eWu#OUBZyVg1q z;C9sV6v}3Pwn+=9bg~mfGZSnoZ&oL&bEc)4v(wEp|I2N7STFdo41nPS`xKwlg0%5oWI9^&=Q$OB!1b`&^=cNt_)mD-qX`ddis7mbMy4Vu7y)ed$M=qhc6%D|M)t& ztWVMme?#9yV~Zc^%u$Qu_4pctTGH!grOfQ&NQKj-8J0qA8nu%B16+2)4o-a;M@XvhbX^dyuetm8mL^x_V?C?sVc&1n_!e6Alc!-Hy zYgOt1oGo9gQnqoGkNW!{Sj8WsUvsJs@NfLzN;#>l@SW`us`AnHRF4N7#R3kAPxdj- zl<_v9;Pv)Uy!rJy?XMSXJx#uOW!@d=r3%jm)@%6Wx6~bHahRNIR#6swvYbF9CWs>X zz6mT@4?;h}4uqWmR-p1`xya1dO0#(prc^8#nqBz+@?pd14Y6y44dL-1fZ?(U_zVg* zSct=nVa^OkDboQAb)%+F@bPgZ@oF&zu~D$>8Kc-)ZWR4&QNy%r5Yl|e_Xf@G2fn~% zXN!}etAket?*|e;b?sglSt#Bfy&HIz-wLL(^FvFqt&K=@K2fhVlBqlCJxj@_8maWh zb2sMhWcJ-n?Qi%y8i5GlN=V1aqq{X0!{_6KISRZC@$OIz2JeAT*C3V`s$FI55SoZD zuF2fPC=k*toLYR|km~$;C|ir5m4P72wii!GFE#>Th4Nw?l&10%)ha7Z&Q*(MK7WhY z55nookTwF4swM_`m#jEc*|ip+a&k@SkhQg~X@D&41l;SEcdv*0ppeFQHM1;G0(*WgU0Fn6Xn#E!vV9IGZ0aoI^J{e?sylAhm4+e4CC XcetrzodB#NtVdsRQ0);x6w&?#b&`_+ delta 1803 zcmZ`)O>7%Q6rS~Z*WTTD<2YG6aqQS}lQi3sG*k^GRS8u{1r!LCq6nn4T9SBE77{zm zu3K6~L;?gt0;$#y$b*m8S3#X#n=5H2A_@k>YWE@tWj^dQajzlmB+|cLyPFUov_C)?)Hf2 ziu&gs0nwG=Sa&A$iL=3ldgVwfm zI4aHRaqF4T?<^b6gs0oa^gd$7*tKv~Pr{lbS?*zX6tmB7$roH}g%+=4*}0Ksld6pKbP@dXoL1uCj=h zV6SU&A#uN`(ZE_4%<_Py;0NDwiv^13p7MkFx1D9q5849;*t$s=ZlI%Rm20dCt`uF5 z?@Tp}he@xoNcz7M^R+JKG@EN4+?N-qJ?GiJ-Z*AJ4@SHxh9U}hOp^^DlL-7c8NNX>oMG$HZ*#L~KBmbOa{MdY3dcos+qJB_q;^Pr8!7UJ1Jcs%2l$BW20~J}8WW_Rs%%JVbvO+$ zo&f)`=%Rm)l|!5wYOqCijLhv!SqlIIj$+Q#PNr(WY*y#zCd z+xy}3%f)S_VsW(*)NzeT0Vhs^N^CH`5LuwbULzzpyNU~A zXFcPjjT^^CbJ~D2ZQ>-od76i&+k5iR%VaW>PG|g}nWQ^;@N*whb<&5%eQ3}3Ek?Vl zJ54)jnDO7Ae&_aG&Sk&z8u|MzQu%R3g-76jpS*rY{y6$(<<&CB-%8oH%HDGIbqPnD z>{59h`^e$fFPHmVM1-FeVlROaf)FD|+K{wa$ZjONEhLO2Y$5GP+AX95Nr#1WBI&e{ zE+kzR(v76sLVA$&SjZkEdn{xxlD!tvi=@{=B0!oV7UCEuJl>LVM~p;Biy-#1o0WGo z-*h^Ynvr5N$+)5zp2dumkz%SmFB!gcMuo;iOie0=HQAwq;q!_-0^pwm8sp&Z> zWo%O8^o*p&l5%P;mZ3?*Zw0A1pUaa`BpQpKky5JRkr!g|M1o3uy^4N0C985g8C#IQ zA|(y??6kVHAQ|OUT1ZnhHodfAs%gVZrTMff;b>jkXv*dpfaI0u-cpL zUE7?i*_kf~+MR3Y$=3m^x3dOdAz|D0 z@4H@g-QGEnYd)B71h$#`d%O@s6R<6IPqqSUwt2h_&~_Vo08ooiT~kQojtCnotAXt1 zciECB0%{ZdRo63DGAol;wft@%VO#Fn0d?3=C!j7NShqT!9bY?;3$*3C0rl8=?E$oR zqn8HVdVxi3sp|vOztIhPeF4xup&|5&>xS$0md;##SN=g@`|a8MBCrFtMF#*KBsMlk z0BR2rTcW;%bck5PA4WDpY*{*l@L@ZA1mR&~3*}LSkJ;hl2uF!#>l#J+WnvF#4CxcZ zw%v{(JW6ae9Y;7}cj_3zNjp4_@I=x2Cy}1AyE=vNNjv2C4bR?bV&9B$q|-%o25G{c zehJ}hC$Sgw4B)?<<@pchWwg%`p)s7F!|p;vf4}BDsXM~22YQr?;hClB`Is~xmyGzer0Bn{YQIFquX8l|e1jwR$7l~QQi z=p_c}DFBNg{}0E|4Wo1+&IUsRuJe4E%lfmSXG_R?x_8S-QvW_DN-391!UuItFZgH& zw@2w|{pqcz-P-5i+uhPmETwXb7|wG_I%Q_Bp1WbNshy45SGh&M1|o8~<)jb>b|8XWb|>W|L-c?df!434K+-SGKLSmYoiH*n_Rr#L!1u4-hh; zzp!huq~jVmb{%8_1ooj>@5|icQM(u7G(NAGaZj>vc%S!0=(7rPjV*>l8bnLmK-xhn zxQIS8;-cACpgjxn4Up$SCP8+Cyae)1E{cy!v*nM5p-JIlc!%L=DIKtJBm4_X`Sa_Z zcH%Z?*8Ls7HqR2quw1rYy-vzt0^Iqy-o|+~w>5@-47wZ#!9#bFTcQ(7X$Ch2P4E>c zmYcyrX6e6%RI$Dg#x%)Jb+?jP_DuJOAzT@kDhx6cq%ZeeaB7A2LKO-Lya}tl%4NCo zUR2Uk{8LA<#0JTuh4QSy&l_Ceq@PR6sp4oL&tjz+&E$UQeP8#W;U)+yZz-LnYV6Iv z{p7m-QQteIpU1hxIxSb-ON26WR!U@&Qi47s2-C7A~qBsNv!;5 zamk6r*KgrmnmZo0q0+zwk7ZY@mwj?DM1HT=4*t>c05tL!qj09+!QcQd5Zt!UGyhO8 zY1K!D-XTptxSL7|1C@jfe~ zxSf6i-T!S*yd0{{8x7Mkf%!)BeuM7k(9I2p@URu!z_S`Q-wx7S4VO6U}mfvui&wD6G}*b1p)=5Z-Kxk2z?u*P$VbCEsn7u z_49VVZ;PnTe*4H!%Ox0l6+{8S7wEfS{Q%?*kRP$O!)=}=FhSnapFDiAgeycUnf^oC3tQvAIXs^-@1G*=o7Q86_{0$X s4Rc0Y$wd~9KHn+a delta 3494 zcmcguT~L%&82%3X!#^!Q3+$qZ0?ID`8j64*@goAlD3TfyY_Qsz?CRO2(xk;4Q;Qj; za{)G~lUZgz&NSaNQP$^HUewe&bww9-p7+CDKcb20B4&7azvsN? zyuar?{BeU_yUNmjOHEBuBu1N0riN6qAWL;+Hs!dMYu+ zB+ghY2IuiDqS=%$KC|3bt1^;Va*N!(UjF)<4Wd1Bcd#VI{B3&1jCI<2;dm&0O~fFH zA5zoK_n+&(uyJZIVv^qxvyz!LQ#@Tfn;gn0io}CTP_nEuHPbb-D?*uTBZ;7rbW}2^ z6h)qGnl@eVge(P-R8VP3PVS6*+CAGF$}Wop9|LDma&0r?)8lh1%R@O8k#ukwx~-X@ zvJ`9fOxtwZYmxbfa`l`IoLyPH=DO)^)7;voP{Gzn z9=H|K-+i$dR)Wjdd$I~#fzIRApw{T9LQq9YMrJIH#U)~ALME~sDbXd*0m`XZ($9~d z8^5r3Di|pRQ>M#ZIj9O9RSBv}mw;+e>vhxyP>)CB4^mQUKyK8fa}%gqoyVI&)#-e# z2i2gXwt#BX*=z!}mFc)<2J(rZo^2tvmFaWbMr`{cb{nxBOqYc1M0PU8X1`*(XqwBf z4CPiuy1+i@W{M>{vV-IvrsS1HdYKtsC?)F6oEKO_o9uetE+L$BfcwUV++!}!VQvT8 z0oV@c1atwqB{&S+0=Y%FY_+DHQam6o+b*+ivBloRHmMW#OL6(zBpr?m__U+?puZb{ z19-n^u&oI4O^^ow`vHT1rvXENVF?aH*yweQxp_92UVsa*2JG}&y$%kY7_M0$R^$s@OntNZg$8`v%N$JGOjeJvUbv)L}S zzi`;R>R;?3;QD15lV;}QE2#|r8!T)*{S7l;m?J)Iz z_3gM|G?wED3q`5qr*RxLBbkU^p~crGdSmzwxkDEZ9vq$oHc^Ww7sTJANL?^QVRVWO zHS5@3F;H{QPL?U#u}zaWmf zw&e6Z56e_9%5v-)9mfLh5`Rg8!?yV5Pyv^mAGpt(m+i(un4q477k-;~;LH=3>f^;+ ztDSwR{?z(iTp?EEWc5MS3BX{L1YKrou?ca1TMe_RD?9G6m6zmdk0VaI{pL~kVRyhS zKQ(Rf=k_}05Otm930|CHdP3~l64UOIx%CvmAwlUQBk5Zw|o zF0dMNgDzT`HqptA5v*SaP=^vnZAyEzs*-(ra*5QJRdejWRZ(-Bp6w;7$EiZHR(ljp zq__Md_&N;u7=SmN-vq>pWaQN36brHVA47KTBKBQzaYt(r$KIC#Zh#Xo19%f~8E{pw z9%s^X5CON;nw|+m5TZP^RQ{Pyi}hKv_t>E3OJYoce=22-P1abNAu*ObbS_z!#nr$` zvKYaIvi|SYSSUv6GOB_29CogXySvsgpD^_8s-SU?d8GZvfh1~UvjS3?gm6N`m~V9a Yu)C3eE>88;nyZ=l_h*^n@J4|5GcPX}0}!PDypvh9k++VCix~)# FMgX988&d!P diff --git a/src/lettr/resources/__pycache__/templates.cpython-313.pyc b/src/lettr/resources/__pycache__/templates.cpython-313.pyc index b13d24a841a2d57ad3b59074c38b20456bd576fb..b35f63e23ae2349e6adaa9d27121796da5defecf 100644 GIT binary patch delta 2660 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 builtins.list[Domain]: domain=d["domain"], status=d["status"], status_label=d["status_label"], - can_send=d.get("can_send"), + can_send=d["can_send"], cname_status=d.get("cname_status"), dkim_status=d.get("dkim_status"), - dmarc_status=d.get("dmarc_status"), - spf_status=d.get("spf_status"), - created_at=d.get("created_at"), - updated_at=d.get("updated_at"), + created_at=d["created_at"], + updated_at=d["updated_at"], ) for d in body["data"]["domains"] ] @@ -65,6 +65,9 @@ def get(self, domain: str) -> Domain: """ body = self._client.get(f"/domains/{domain}") d = body["data"] + dns_provider = None + if d.get("dns_provider"): + dns_provider = _from_dict(DnsProvider, d["dns_provider"]) return Domain( domain=d["domain"], status=d["status"], @@ -77,7 +80,7 @@ def get(self, domain: str) -> Domain: is_primary_domain=d.get("is_primary_domain"), tracking_domain=d.get("tracking_domain"), dns=d.get("dns"), - dns_provider=d.get("dns_provider"), + dns_provider=dns_provider, created_at=d.get("created_at"), updated_at=d.get("updated_at"), ) @@ -144,15 +147,19 @@ def verify(self, domain: str) -> DomainVerification: if d.get("spf"): spf = _from_dict(SpfValidationResult, d["spf"]) + dns = None + if d.get("dns"): + dns = _from_dict(DomainDnsVerification, d["dns"]) + return DomainVerification( domain=d["domain"], dkim_status=d["dkim_status"], cname_status=d["cname_status"], - dmarc_status=d.get("dmarc_status"), - spf_status=d.get("spf_status"), - is_primary_domain=d.get("is_primary_domain"), + dmarc_status=d["dmarc_status"], + spf_status=d["spf_status"], + is_primary_domain=d["is_primary_domain"], ownership_verified=d.get("ownership_verified"), - dns=d.get("dns"), + dns=dns, dmarc=dmarc, spf=spf, ) diff --git a/src/lettr/resources/emails.py b/src/lettr/resources/emails.py index 74559b7..4fa69dd 100644 --- a/src/lettr/resources/emails.py +++ b/src/lettr/resources/emails.py @@ -80,6 +80,20 @@ def _parse_email_event(r: dict[str, Any]) -> EmailEvent: reason=r.get("reason"), raw_reason=r.get("raw_reason"), error_code=r.get("error_code"), + bounce_class=r.get("bounce_class"), + queue_time=r.get("queue_time"), + outbound_tls=r.get("outbound_tls"), + num_retries=r.get("num_retries"), + device_token=r.get("device_token"), + target_link_url=r.get("target_link_url"), + target_link_name=r.get("target_link_name"), + user_agent=r.get("user_agent"), + ip_address=r.get("ip_address"), + initial_pixel=r.get("initial_pixel"), + fbtype=r.get("fbtype"), + report_by=r.get("report_by"), + report_to=r.get("report_to"), + remote_addr=r.get("remote_addr"), campaign_id=r.get("campaign_id"), template_id=r.get("template_id"), template_version=r.get("template_version"), diff --git a/src/lettr/resources/projects.py b/src/lettr/resources/projects.py index f5034ab..8d6eb2b 100644 --- a/src/lettr/resources/projects.py +++ b/src/lettr/resources/projects.py @@ -51,7 +51,7 @@ def list( team_id=p["team_id"], emoji=p.get("emoji"), created_at=p["created_at"], - updated_at=p.get("updated_at"), + updated_at=p["updated_at"], ) for p in data["projects"] ] diff --git a/src/lettr/resources/templates.py b/src/lettr/resources/templates.py index 7046918..2aa6c72 100644 --- a/src/lettr/resources/templates.py +++ b/src/lettr/resources/templates.py @@ -9,6 +9,7 @@ MergeTag, MergeTagChild, Template, + TemplateHtml, TemplateList, TemplateMergeTags, ) @@ -25,6 +26,7 @@ def _parse_merge_tags(raw: list[dict[str, Any]]) -> list[MergeTag]: key=t["key"], required=t.get("required", False), type=t.get("type"), + name=t.get("name"), children=children, ) ) @@ -84,7 +86,7 @@ def list( project_id=t["project_id"], folder_id=t["folder_id"], created_at=t["created_at"], - updated_at=t.get("updated_at"), + updated_at=t["updated_at"], ) for t in data["templates"] ] @@ -125,7 +127,7 @@ def get(self, slug: str, *, project_id: int | None = None) -> Template: created_at=d["created_at"], updated_at=d.get("updated_at"), active_version=d.get("active_version"), - versions_count=d.get("versions_count"), + versions_count=d["versions_count"], html=d.get("html"), json=d.get("json"), ) @@ -293,15 +295,16 @@ def get_merge_tags( project_id=d.get("project_id"), ) - def get_html(self, *, project_id: int, slug: str) -> str: - """Get the rendered HTML of a template. + def get_html(self, *, project_id: int, slug: str) -> TemplateHtml: + """Get the rendered HTML and merge tags of a template. Args: project_id: Project ID containing the template. slug: The template slug. Returns: - The rendered HTML string. + A :class:`TemplateHtml` with ``html``, ``merge_tags``, and + ``subject``. Raises: NotFoundError: If the template or project is not found. @@ -311,4 +314,10 @@ def get_html(self, *, project_id: int, slug: str) -> str: "slug": slug, } body = self._client.get("/templates/html", params=params) - return body["data"]["html"] + d = body["data"] + merge_tags = _parse_merge_tags(d.get("merge_tags", [])) + return TemplateHtml( + html=d["html"], + merge_tags=merge_tags, + subject=d.get("subject"), + ) 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 index c377bc29d0f8500a7c0cbb82bda9d7956a1c5909..d6c6e1a6f8ef4b4c2a83c5c123300b5f87225246 100644 GIT binary patch delta 6729 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 XZ*u^50`zwWz1A9j?WY_7d%XVuGnhkK delta 4208 zcmc&%eQZeKrht zEVQdSC_`O(+D+?J6*eI?o7PV9R8^DKt@~@M_7SRTTdir`w7=Li3D$K;{4wpEYvVwc zZS=3c@P3?k?mhRNchB#hbNr9LF?fTyK6E-|39ncG`TO)l-J+|Wg%>v_ykSXYs%4~h z*uyskUwdSxOT33!RKQdsP<@ItS{URb8FXxlre> z>ViU7i#ktLS1)v4)cLBqkkI+bI8aqKzyUUFHACGn z>AJ>q;RiNl&umw8-a{jnY>KC5bzCCjY#J06ov6QOat}`4fl9?&GEQr`l7nlxJfBOR zN@em|$&^X!Ij-P1x4_SwSqnMHx4?ia$eQ4&tJN+PcG5=Roa=?gJ{0v@gz&11JBpp| zq9NeIu_bAA$-;%HjZE!u%QHIi85B!_WKz%Jw@)Thxg1X)&*wBfndB2RjZC?NfLj&dKq2>L=To!9Y0rGmY0X7j+lbYCa_r`UJ{>UbkT6G1aU3qdPD zDAKx-#7zVu$Ptpv1nmTI_)}=mE?Cz=&)pDhsE1!hT4A!m-!Mq32ML6&FdL%BJ#cWc zxj#+fFu@4~Mdk-dzKbvPI7)B`p=2M-o=KKjyqO<{zc$2}144~G>_;%xxFK-UCkI?N zjE+t6dHZwj3-0%6WRyRGs91QCR{b17ir_c_CzvEy#U+h&1Y7a0 za>;9smbs)MmxioET*G5~eSh(-=1xZ&ZTWS|DkbBncDiJo(@ygsKJt13KS3jSBE6qJ zOX328V&X#n;vGfFIysZhOmS_FhiKgT-y=mES(0ZpKCLBlsc9WUcO??46+xw~EeACY z??k%Ri2!CuIZH5yps?GPv>u+D&nc|X1J@#r@cKz7>}{WH+Cp>av-viH?F95$II$gS zosHoW+8Oz5xW8ZI6-pJoG-W3NWf%omw6LBiP_j&l9V)ZzA(|jUU*W@78tT=jNh#jA zvJ+pTMLcUVW1glTCJ-FSl5~<_jNk}?3jc~WL0c?{OzDg5V8g}Jv7Z|6lMm0*3TyEp zPXh$|@vicr*}SXFhrHlJB0EKVc)a-M%@@rJ#0L@fQcZbair0~<%8Bcg5JaF0@|O6P z?pVAhwz>QN1)aB&RTx2GtSGfiQU#Mc+E^e*1-*?x(ZSu2*8Xn^2xTK7(!@R>WxvAl$_9A-~K zEFCI-wDVoIuo5+q7^#?5P8Ui21Oo)S2t?VGOO`1uqvf>nhIz{wEIFCVWVET|{M@9r zvO9cb2lvtf`v{0-n5Petyv<2yClIebN|Klr#@Mq(@8I|3uwcE|u^F-wWGAAH^C#%}3-G~k zOT3vxI{J#Cypv0$SXFT-Fn20WY^2}2I)N2BMcVrM$jGoruFuf?HIkR&<%2XNGLj41 zndDtrQ=t*@rXi9WX_!Hj99WSghozEUmLTJ&|slAOYV$P>&4% z`GEm;v1mQ`7sGwV>APgTR-CTV00E`3YMeag_A;@rQUm0&k865vua?`Fo6TH*g;?%7 zYF=rB;tV`3z3^Px)}|WghZ3e8NXYpF#fGjaTz&#OmGX7O3Rl_!wwi<)n;h%0E+}OB zu`69y-;KzEt#G-pbq_|df$7wxJ?^(O2U7ajlMV%%nfUn}mdwtki!Mu&VAhs>X_LuTPnb=!W??T@Pd zif@5W*f)>x4Lj7ja)-)Kz>ibG1(75uXWU5#qAR7c#(_Xouop@C8UYogaw-%jv!5RC zO~B=bgPN9tyE9k$d8K#GAmtU;UD?1&*btUVB@tkE@>sQml}sr*}oay;SCS) zH*L$F4a=V0=PkbrMVGD7y0V)eX65WcL)g%YIwcyL&!U1;O;Q?5HOVAAnqS9; z;9PzydkB7z?^`H^utMf|2iNrZJfGC`j%uWYS*hl+^yl$sr&j)XP)o~c1k{F!`Ymdb zNO;1rRXu#k+BZ8tmCtAc{3mFETPx{Qb8Z+JWA96lJLNI$z9#L43#Zy;AB*0U5N<`` Rty7BI!5(IRmk7nj{Tl!kP|pAW diff --git a/tests/test_domains.py b/tests/test_domains.py index 58c7eef..fbd4a5d 100644 --- a/tests/test_domains.py +++ b/tests/test_domains.py @@ -6,7 +6,7 @@ import pytest -from lettr._types import Domain, DomainVerification +from lettr._types import Domain, DomainDnsVerification, DomainVerification from lettr.resources.domains import Domains @@ -28,6 +28,7 @@ def test_list_domains(self, domains: Domains, mock_client: MagicMock) -> None: "cname_status": "valid", "dkim_status": "valid", "created_at": "2025-01-01", + "updated_at": "2025-06-01", } ] } @@ -55,7 +56,12 @@ def test_get_domain(self, domains: Domains, mock_client: MagicMock) -> None: "is_primary_domain": True, "tracking_domain": "track.example.com", "dns": {"cname": "cname.lettr.com"}, - "dns_provider": "cloudflare", + "dns_provider": { + "provider": "cloudflare", + "provider_label": "Cloudflare", + "nameservers": ["ns1.cloudflare.com", "ns2.cloudflare.com"], + "error": None, + }, "created_at": "2025-01-01", "updated_at": "2025-06-01", } @@ -66,7 +72,9 @@ def test_get_domain(self, domains: Domains, mock_client: MagicMock) -> None: assert result.dmarc_status == "valid" assert result.spf_status == "valid" assert result.is_primary_domain is True - assert result.dns_provider == "cloudflare" + assert result.dns_provider is not None + assert result.dns_provider.provider == "cloudflare" + assert result.dns_provider.provider_label == "Cloudflare" class TestCreate: @@ -137,7 +145,12 @@ def test_verify_domain(self, domains: Domains, mock_client: MagicMock) -> None: "spf_status": "valid", "is_primary_domain": True, "ownership_verified": "2025-01-01", - "dns": {"cname": "ok"}, + "dns": { + "dkim_record": "v=DKIM1; k=rsa; p=MIGfMA0...", + "cname_record": "example.com.d.]sparkpostmail.com", + "dkim_error": None, + "cname_error": None, + }, "dmarc": { "is_valid": True, "status": "valid", @@ -162,16 +175,25 @@ def test_verify_domain(self, domains: Domains, mock_client: MagicMock) -> None: assert result.dmarc.policy == "reject" assert result.spf is not None assert result.spf.includes_sparkpost is True + assert isinstance(result.dns, DomainDnsVerification) + assert result.dns.dkim_record == "v=DKIM1; k=rsa; p=MIGfMA0..." + assert result.dns.cname_error is None - def test_verify_without_dmarc_spf(self, domains: Domains, mock_client: MagicMock) -> None: + def test_verify_without_dmarc_spf_objects(self, domains: Domains, mock_client: MagicMock) -> None: mock_client.post.return_value = { "data": { "domain": "example.com", "dkim_status": "pending", "cname_status": "pending", + "dmarc_status": "unverified", + "spf_status": "unverified", + "is_primary_domain": False, } } result = domains.verify("example.com") assert result.dmarc is None assert result.spf is None + assert result.dmarc_status == "unverified" + assert result.spf_status == "unverified" + assert result.is_primary_domain is False diff --git a/tests/test_templates.py b/tests/test_templates.py index 6a016fc..6832a4a 100644 --- a/tests/test_templates.py +++ b/tests/test_templates.py @@ -6,7 +6,7 @@ import pytest -from lettr._types import Template, TemplateList, TemplateMergeTags +from lettr._types import Template, TemplateHtml, TemplateList, TemplateMergeTags from lettr.resources.templates import Templates @@ -61,6 +61,7 @@ def test_get_template(self, templates: Templates, mock_client: MagicMock) -> Non "active_version": 3, "versions_count": 3, "html": "

Hi

", + "updated_at": "2025-06-01", } } @@ -159,13 +160,36 @@ def test_get_merge_tags(self, templates: Templates, mock_client: MagicMock) -> N class TestGetHtml: def test_get_html(self, templates: Templates, mock_client: MagicMock) -> None: mock_client.get.return_value = { - "data": {"html": "

Welcome!

"} + "data": { + "html": "

Welcome!

", + "merge_tags": [ + {"key": "name", "name": "name", "required": True}, + ], + "subject": "Welcome Email", + } } result = templates.get_html(project_id=10, slug="welcome") - assert result == "

Welcome!

" + assert isinstance(result, TemplateHtml) + assert result.html == "

Welcome!

" + assert result.subject == "Welcome Email" + assert result.merge_tags is not None + assert len(result.merge_tags) == 1 + assert result.merge_tags[0].key == "name" + assert result.merge_tags[0].name == "name" params = mock_client.get.call_args.kwargs["params"] assert params["project_id"] == 10 assert params["slug"] == "welcome" assert mock_client.get.call_args.args[0] == "/templates/html" + + def test_get_html_empty_merge_tags(self, templates: Templates, mock_client: MagicMock) -> None: + mock_client.get.return_value = { + "data": {"html": "

Hello

", "merge_tags": []} + } + + result = templates.get_html(project_id=5, slug="simple") + assert isinstance(result, TemplateHtml) + assert result.html == "

Hello

" + assert result.merge_tags == [] + assert result.subject is None From f8f1bc0d87f2e2d00f3b8ec5a5306459acd2793d Mon Sep 17 00:00:00 2001 From: voj-tech-j Date: Fri, 17 Apr 2026 15:01:25 +0200 Subject: [PATCH 3/4] ci --- .github/workflows/ci.yml | 52 ++++++++++++++++++ pyproject.toml | 2 + src/lettr/__init__.py | 3 +- .../__pycache__/__init__.cpython-313.pyc | Bin 5770 -> 5768 bytes src/lettr/__pycache__/_types.cpython-313.pyc | Bin 17283 -> 17283 bytes src/lettr/_types.py | 1 + .../__pycache__/emails.cpython-313.pyc | Bin 21755 -> 21772 bytes src/lettr/resources/emails.py | 12 ++-- .../test_domains.cpython-313-pytest-9.0.3.pyc | Bin 29632 -> 29672 bytes .../test_emails.cpython-313-pytest-9.0.3.pyc | Bin 54322 -> 54283 bytes ...est_templates.cpython-313-pytest-9.0.3.pyc | Bin 30452 -> 30449 bytes ...test_webhooks.cpython-313-pytest-9.0.3.pyc | Bin 17509 -> 17501 bytes tests/test_domains.py | 4 +- tests/test_emails.py | 4 +- tests/test_templates.py | 4 +- tests/test_webhooks.py | 8 +-- 16 files changed, 69 insertions(+), 21 deletions(-) create mode 100644 .github/workflows/ci.yml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..e85440d --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,52 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + +jobs: + lint: + name: Lint & Type Check + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-python@v5 + with: + python-version: "3.12" + cache: "pip" + + - name: Install dependencies + run: pip install -e ".[dev]" + + - name: Ruff check + run: ruff check src/lettr tests + + - name: Ruff format check + run: ruff format --check src/lettr tests + + - name: Mypy + run: mypy src/lettr + + test: + name: Test (Python ${{ matrix.python-version }}) + needs: lint + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + python-version: ["3.8", "3.9", "3.10", "3.11", "3.12", "3.13"] + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python-version }} + cache: "pip" + + - name: Install dependencies + run: pip install -e ".[dev]" + + - name: Run tests + run: pytest diff --git a/pyproject.toml b/pyproject.toml index 85fbd57..7c0544e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -35,6 +35,8 @@ dependencies = [ [project.optional-dependencies] dev = [ "pytest>=7.0", + "ruff>=0.4.0", + "mypy>=1.10.0", ] [project.urls] diff --git a/src/lettr/__init__.py b/src/lettr/__init__.py index 8ca5e40..64926ee 100644 --- a/src/lettr/__init__.py +++ b/src/lettr/__init__.py @@ -152,8 +152,7 @@ def __init__( ) -> None: if not api_key: raise ValueError( - "The api_key parameter is required. " - "Get your API key at https://app.lettr.com" + "The api_key parameter is required. Get your API key at https://app.lettr.com" ) self._client = ApiClient(api_key=api_key, base_url=base_url, timeout=timeout) diff --git a/src/lettr/__pycache__/__init__.cpython-313.pyc b/src/lettr/__pycache__/__init__.cpython-313.pyc index 922396d743eff052c6787ff8aff5bf351a4117ed..88da3f670644565853e36f1296489dbaef14a9c7 100644 GIT binary patch delta 160 zcmeCu?a<}@%*)Hg00c^}A7s{Tr91q&F@PVNw#$arD0l+a$r%bRZqwKHlp3B=doZQPAcyGqW(?2L@(d zzAs#xGkC5sGH%#x&v%`Xao1#VfxnEWCW{IdFrJ&-CODDt;$#V-y^L2jUlVF))Rtk@ zeIO|EffY!9;9w9I`@jieaWM#qf8YkOco?|&zVZTDUu-sahmA-AYK1yJlS3xShZd5_+u1JpB|1c|q7&$*sRS=Ns44LfR{BdE zhE)tDO#nmUBl$j*laxR>oMJys8NsA}%vQP#}@&L0~EY*c_Sf&EnHXq7adaO7@FD+Egvg)dB*yyUt3!89MD*?M$t>Ke) z+NpiT94*&5S*>vk%XDjWu@j~htW&yql|3@Mu}6cJdG^=h#xWh#kKkD78^(bwI&0L3 Ii^lWte^Sel!vFvP delta 408 zcmWm8IV^(#7{+mrM%3|rlsFruZWZ^fu7SlMHU@);BO*l%2x34|8k1K|j);W>!J8%; zB^p=aSQr>BBq9b;21nxUGyMMRGik-770QRA1p3IT`LTO(S0R7I(yITp*sC2vB0X!n z#D)MjOcWaEfrv}%*ss-B{mQ$kvRHkt@^qnT=T`{<-U-2ghd zKWq%>i=tzy^Lhk`~$QAb>+70h;u{B-+Tx3sp!WE4gN5n6q2lw=v z$l^-U6<(<)c}t8*`NJngrGATHX+O}An!YU_rk_KlsSGDnt}@jD(L9wo?u{m^70BZD zY^^tzoK(Qf^SMdh?B~S*jcd30BHa}np@ZfMr$v3y1}t<| T: known = {f.name for f in fields(cls)} # type: ignore[arg-type] return cls(**{k: v for k, v in data.items() if k in known}) + # --------------------------------------------------------------------------- # Common / shared types # --------------------------------------------------------------------------- diff --git a/src/lettr/resources/__pycache__/emails.cpython-313.pyc b/src/lettr/resources/__pycache__/emails.cpython-313.pyc index 9ebfe9b447857b441b57e2a04a3f0648bea0051b..c6c2eb8ec8b373961fce70a7cefc2455a1800224 100644 GIT binary patch delta 1222 zcmeyplCftMBj0CUUM>b8c%b(vQ!rp7Uxm3ncPMu-4=Yeim?4a(m`R_Zm`RwygO7nB zk0+WPq!t81c}y80tN;TTQZtYK4rbeTTE((*~FX$78k{II=CMg+3rZYWaN5T z$PGxY2)rV!x*+kIu*O9r*ADgzLT)!i6{g2ej9(D5!RWH6SqIk>e%Tu$(l;cO7x-S3 zFq%BkP)1F0dj7=x1!~rPxqhbzrf|Ph~`ILb^)#r4D6g-KR>ZE2)l2xeZ<4K zX7Xdto1)u6PD=t2U{(~EHTj~KislXwGZikA24|(iSs7r~WI1ms?wufJ7KHEYt;)D- za;A4o{brDWAdt}HDe42U!OHr<%0NUIoHYT?nh0lsb@KwbnjA$EAknQL0;ft);?NW- ziU27BJ3g!)&cvn(n}ZgBbbv!*A)Ezv1`m*{sZ$gK5(PV>5X9OBA|l~pQXm#scOsYt zwjVukK{Cl80_?^VIE$E|1}Sz05#VslgtH(458`Hnh+QBehk+p+%FG4vcY}yL5U~eD zKG80f6 zIPD(+NrDaIhKR<31Q4QNE3~i%EP8N)G!h6+M2yoQIEe^NeyCqT{?n8yIyCuq&~nBT Jo123x7yb8___E&=4bznd==*QjG>IdJgh)5VTLg7VkUitVkTh*4?YHl zJnm?AkXjH3umD}aFnOM} zC8NpY8`f)V1w|*MO_Z6DaZymcgYAKl#SXDedY6S9H|ebiydtc+An}^8#zwsk_6tIe zH$0><4q~RlWzyiRbT}&m%$lt4EycY9#LR;5L%dZPcTTSIj;Y@S5)cFunmk2)AU0T8 zKUf)v2!pdGz*!UFEU<1~AXk&4NCG6f1w`Of35rlnp`r+oBCzAbCV%(ls|WiFVJJ3d zEC6W%t6K79_Yq+-wlB6GY^|S-BwAE(T8^nFnI-1`+unVh@NwL=8((Fh~IG zKd>k;T{CE6h;E*|$~Qt_DM;xG5U~bCY}hR1ca|9v&!A)v)TRv*KMW$khH*ngV^KxH zR%l@jSp30>H8kr%h7+A$L5j%^PM}`Q(By~u736D8siK3E-vljZJid8Ka0Md(K->G( diff --git a/src/lettr/resources/emails.py b/src/lettr/resources/emails.py index 4fa69dd..11ce946 100644 --- a/src/lettr/resources/emails.py +++ b/src/lettr/resources/emails.py @@ -442,18 +442,18 @@ def list_events( params["bounce_classes"] = bounce_classes body = self._client.get("/emails/events", params=params) - events = body["data"]["events"] + events_data = body["data"]["events"] - results = [_parse_email_event(r) for r in events["data"]] - pagination = events.get("pagination", {}) + results = [_parse_email_event(r) for r in events_data["data"]] + pagination = events_data.get("pagination", {}) return EmailEventList( results=results, - total_count=events["total_count"], + total_count=events_data["total_count"], next_cursor=pagination.get("next_cursor"), per_page=pagination.get("per_page", 25), - date_from=events.get("from"), - date_to=events.get("to"), + date_from=events_data.get("from"), + date_to=events_data.get("to"), ) def schedule( 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 index d6c6e1a6f8ef4b4c2a83c5c123300b5f87225246..485042e31a86ccfba8fdddf176fcf2102a391def 100644 GIT binary patch delta 118 zcmX@`obknTM!wIyyj%=GAo}`2CacUwzAWd-bJQI+uW_EI&0WgGz|g?(frEipcyn<6 zK_+!opu#qWFHBmjdLJSfg;@=6h|7Io2GP1^VEO?k?{`+=&B{eejEp6d-HLPh9T*u6 MKQVyFB0ivQ0DL(iLjV8( delta 92 zcmaFyobkYOM!wIyyj%=GuygZ+%V1!36x{4tq{PTrG&!d@m)DNb@Dl@&DB=UE0RX7F8@&Jk 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 index 8991f34128177982dcb19835ff6d4379f06e7c65..593885198a384a3552023562f12f4eb42789ed87 100644 GIT binary patch delta 109 zcmdnAg1LJIGw)|!UM>b8P2aMb85Sny9Q(+^o;A%#b$?~hEdE^)naI@8FLj^{z%{iyFnY1*4 zirW~zFiElcFn+L*VRdG_Atv*I8O+c&fiNC$@_uKPnY`?5EaS}0EawE68D~uvz4!pg h`F8Oqql_h^Gvh}F5c$1=k&V%x@e>1xED{154FC$xDX{AhNo0V>43uu7#{El&4}A!bWz0a12dBbR|j`L oe<%MH4wVla3_{YIZxux_F>-D;F5Sq;tIVkOi2+Cy@dJ$k00O-l9{>OV 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 index 88377fcb6d7e7ef6781dc8aebeea96d9bce073e5..332e386964975b469ea254bbb67e3f3d54793e32 100644 GIT binary patch delta 300 zcmaFb!FacWk@qt%FBbz4D7}7=$-9yFC9fhY69Yp7!wn6+4N`k@cEnvU@VTJw+riz> z-^qW4L*;>>leX z#7@XvG>EvM9*JUki{$}U#_G+k_92YIg&>W*K%$M|3llr53*&d8$!i=!7+W?oI`T6z zwoVpwY65bmI89}=;b64?$N(h1i?K7hFn(kJk>5ou8U2845c$1=QGikPBLk55uExUX O&iIJ|L>BP?jRycB4_4p+ delta 284 zcmccH!T7X;k@qt%FBbz4Z2fj8({dy4OI}q@CI*HEh6g-C6QY*rERee(V(@{PNrS6{ zyPv<4{|bl72Mz`ysm6r#0Hz?HI@fh8S6LO+J`U-7lSnO0*N+;FHD@QE{xyBCNFUaVeHua(}ACn zv2!w~QxlNW<203#i;L0zBLk55E;U)(MN(3TQS~DOkoc~}!syQUi2+0w@c@ki0M_kL AwEzGB diff --git a/tests/test_domains.py b/tests/test_domains.py index fbd4a5d..695384e 100644 --- a/tests/test_domains.py +++ b/tests/test_domains.py @@ -179,7 +179,9 @@ def test_verify_domain(self, domains: Domains, mock_client: MagicMock) -> None: assert result.dns.dkim_record == "v=DKIM1; k=rsa; p=MIGfMA0..." assert result.dns.cname_error is None - def test_verify_without_dmarc_spf_objects(self, domains: Domains, mock_client: MagicMock) -> None: + def test_verify_without_dmarc_spf_objects( + self, domains: Domains, mock_client: MagicMock + ) -> None: mock_client.post.return_value = { "data": { "domain": "example.com", diff --git a/tests/test_emails.py b/tests/test_emails.py index eea38e2..9fe35c6 100644 --- a/tests/test_emails.py +++ b/tests/test_emails.py @@ -375,9 +375,7 @@ def test_get_scheduled(self, emails: Emails, mock_client: MagicMock) -> None: assert result.events == [] mock_client.get.assert_called_once_with("/emails/scheduled/tr_123") - def test_get_scheduled_with_events( - self, emails: Emails, mock_client: MagicMock - ) -> None: + def test_get_scheduled_with_events(self, emails: Emails, mock_client: MagicMock) -> None: """Regression: events array contains EmailEvent objects, not strings.""" mock_client.get.return_value = { "data": { diff --git a/tests/test_templates.py b/tests/test_templates.py index 6832a4a..5e63cd8 100644 --- a/tests/test_templates.py +++ b/tests/test_templates.py @@ -184,9 +184,7 @@ def test_get_html(self, templates: Templates, mock_client: MagicMock) -> None: assert mock_client.get.call_args.args[0] == "/templates/html" def test_get_html_empty_merge_tags(self, templates: Templates, mock_client: MagicMock) -> None: - mock_client.get.return_value = { - "data": {"html": "

Hello

", "merge_tags": []} - } + mock_client.get.return_value = {"data": {"html": "

Hello

", "merge_tags": []}} result = templates.get_html(project_id=5, slug="simple") assert isinstance(result, TemplateHtml) diff --git a/tests/test_webhooks.py b/tests/test_webhooks.py index 97bdc67..dbcef51 100644 --- a/tests/test_webhooks.py +++ b/tests/test_webhooks.py @@ -31,9 +31,7 @@ def webhooks(mock_client: MagicMock) -> Webhooks: class TestList: def test_list_webhooks(self, webhooks: Webhooks, mock_client: MagicMock) -> None: - mock_client.get.return_value = { - "data": {"webhooks": [WEBHOOK_DATA]} - } + mock_client.get.return_value = {"data": {"webhooks": [WEBHOOK_DATA]}} result = webhooks.list() assert len(result) == 1 @@ -114,9 +112,7 @@ def test_create_with_oauth2(self, webhooks: Webhooks, mock_client: MagicMock) -> class TestUpdate: def test_update_webhook(self, webhooks: Webhooks, mock_client: MagicMock) -> None: - mock_client.put.return_value = { - "data": {**WEBHOOK_DATA, "name": "Renamed Hook"} - } + mock_client.put.return_value = {"data": {**WEBHOOK_DATA, "name": "Renamed Hook"}} result = webhooks.update("wh_123", name="Renamed Hook", active=False) assert result.name == "Renamed Hook" From 5a6394d755a33bf13b7bc8742aaf7d0a5daf994f Mon Sep 17 00:00:00 2001 From: voj-tech-j Date: Fri, 17 Apr 2026 15:21:25 +0200 Subject: [PATCH 4/4] ci rm test pypi --- .github/workflows/publish.yml | 21 +-------------------- 1 file changed, 1 insertion(+), 20 deletions(-) diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 6509c79..646c2fe 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -27,28 +27,9 @@ jobs: name: dist path: dist/ - publish-testpypi: - name: Publish to TestPyPI - needs: build - runs-on: ubuntu-latest - environment: testpypi - permissions: - id-token: write - steps: - - name: Download build artifacts - uses: actions/download-artifact@v4 - with: - name: dist - path: dist/ - - - name: Publish to TestPyPI - uses: pypa/gh-action-pypi-publish@release/v1 - with: - repository-url: https://test.pypi.org/legacy/ - publish-pypi: name: Publish to PyPI - needs: [build, publish-testpypi] + needs: build runs-on: ubuntu-latest environment: pypi permissions: