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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
321 changes: 321 additions & 0 deletions CHANGELOG.md

Large diffs are not rendered by default.

760 changes: 715 additions & 45 deletions README.md

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ build-backend = "hatchling.build"
# the PyPI trusted-publisher configuration). The import package is
# unchanged (`import sendly`) — see [tool.hatch.build.targets.wheel] below.
name = "sendly-python"
version = "1.0.0"
version = "1.1.0"
description = "Official Sendly Python SDK"
readme = "README.md"
requires-python = ">=3.10"
Expand Down
8 changes: 8 additions & 0 deletions src/sendly/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,16 +33,20 @@
from sendly.resources.analytics import AnalyticsResource
from sendly.resources.campaigns import CampaignsResource
from sendly.resources.contacts import ContactsResource
from sendly.resources.deliverability import DeliverabilityResource
from sendly.resources.domains import DomainsResource
from sendly.resources.emails import EmailsResource
from sendly.resources.events import EventsResource
from sendly.resources.lists import ListsResource
from sendly.resources.mailboxes import MailboxesResource
from sendly.resources.projects import ProjectsResource
from sendly.resources.segments import SegmentsResource
from sendly.resources.snippets import SnippetsResource
from sendly.resources.suppression import SuppressionResource
from sendly.resources.templates import TemplatesResource
from sendly.resources.topics import TopicsResource
from sendly.resources.usage import UsageResource
from sendly.resources.validation import ValidationResource
from sendly.resources.verify import VerifyResource
from sendly.resources.webhooks import WebhooksResource
from sendly.resources.workflows import WorkflowsResource
Expand All @@ -57,6 +61,7 @@
"AnalyticsResource",
"CampaignsResource",
"ContactsResource",
"DeliverabilityResource",
"DomainsResource",
"EmailsResource",
"EventsResource",
Expand All @@ -74,9 +79,12 @@
"SendlyRateLimitError",
"SendlyServerError",
"SendlyValidationError",
"SnippetsResource",
"SuppressionResource",
"TemplatesResource",
"TopicsResource",
"UsageResource",
"ValidationResource",
"VerifyResource",
"WebhooksResource",
"WorkflowsResource",
Expand Down
10 changes: 9 additions & 1 deletion src/sendly/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -36,16 +36,20 @@
from sendly.resources.analytics import AnalyticsResource
from sendly.resources.campaigns import CampaignsResource
from sendly.resources.contacts import ContactsResource
from sendly.resources.deliverability import DeliverabilityResource
from sendly.resources.domains import DomainsResource
from sendly.resources.emails import EmailsResource
from sendly.resources.events import EventsResource
from sendly.resources.lists import ListsResource
from sendly.resources.mailboxes import MailboxesResource
from sendly.resources.projects import ProjectsResource
from sendly.resources.segments import SegmentsResource
from sendly.resources.snippets import SnippetsResource
from sendly.resources.suppression import SuppressionResource
from sendly.resources.templates import TemplatesResource
from sendly.resources.topics import TopicsResource
from sendly.resources.usage import UsageResource
from sendly.resources.validation import ValidationResource
from sendly.resources.verify import VerifyResource
from sendly.resources.webhooks import WebhooksResource
from sendly.resources.workflows import WorkflowsResource
Expand All @@ -59,7 +63,7 @@
__all__ = ["DEFAULT_BASE_URL", "SDK_VERSION", "Sendly"]

#: Package version. Kept in sync with ``pyproject.toml``.
SDK_VERSION = "1.0.0"
SDK_VERSION = "1.1.0"

#: Default production API base. Override via ``base_url`` for staging/self-hosted.
DEFAULT_BASE_URL = "https://api.sendly.now"
Expand Down Expand Up @@ -137,6 +141,7 @@ def __init__(
self.webhooks = WebhooksResource(self)
self.suppression = SuppressionResource(self)
self.lists = ListsResource(self)
self.snippets = SnippetsResource(self)
# Reads only -- the mailbox writes need a user, which an API key is not.
self.mailboxes = MailboxesResource(self)
# /api/v1 surface. Same client, same auth; bare resource bodies instead
Expand All @@ -147,6 +152,9 @@ def __init__(
self.analytics = AnalyticsResource(self)
self.usage = UsageResource(self)
self.projects = ProjectsResource(self)
self.topics = TopicsResource(self)
self.validation = ValidationResource(self)
self.deliverability = DeliverabilityResource(self)

def request(
self,
Expand Down
9 changes: 6 additions & 3 deletions src/sendly/resources/_pagination.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,8 +25,11 @@ def iterate_cursor(
``fetch`` receives the query for one page — the caller's own filters, plus
the ``after`` cursor from page two onward — and returns the raw cursor
envelope. Iteration stops when ``has_more`` is false or ``next_cursor`` is
``None``, and also when a page carries no ``data`` list, so a malformed
response ends the walk instead of looping forever.
``None``, when a page carries no ``data`` list, and when a page hands back
the very cursor it was given -- so a malformed or stuck response ends the
walk instead of looping forever. The last of those came from the two
hand-rolled walkers this helper absorbed in 1.1; consolidating them must not
drop a stop condition the resources that had it were relying on.

The caller's filters are held fixed for the whole walk on purpose: changing
them mid-pagination invalidates the cursor and the API answers ``422
Expand All @@ -44,6 +47,6 @@ def iterate_cursor(
if not page.get("has_more"):
return
cursor = page.get("next_cursor")
if not cursor:
if not cursor or cursor == params.get("after"):
return
params = {**params, "after": cursor}
43 changes: 43 additions & 0 deletions src/sendly/resources/campaigns.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,10 @@
from sendly.types import (
Body,
CampaignDeleted,
CampaignFailureListV1,
CampaignList,
CampaignRecord,
CampaignRetryFailedV1,
CampaignStats,
JSONDict,
Query,
Expand Down Expand Up @@ -133,3 +135,44 @@ def stats(self, id: str) -> CampaignStats:
method="GET", path=f"/api/v1/campaigns/{encode_path_segment(id)}/stats"
)
return response

def list_failures(self, id: str, query: Query | None = None) -> CampaignFailureListV1:
"""The recipients this campaign did not reach, and why.

:meth:`stats` says how many sends failed; only this says who. ``reason``
comes from a fixed vocabulary rather than the underlying error text, so
it is stable enough to branch on, and is ``None`` on rows recorded
before reasons were captured.

Cursor-paginated (``limit`` / ``after``) like every other v1 list, but
uniquely it also carries ``total``: :meth:`retry_failed` acts on that
number, and ``has_more`` alone cannot tell you whether 3 or 30,000 sends
failed.
"""
response: CampaignFailureListV1 = self._client.request(
method="GET",
path=f"/api/v1/campaigns/{encode_path_segment(id)}/failures",
query=query,
)
return response

def iter_list_failures(self, id: str, query: Query | None = None) -> Iterator[JSONDict]:
"""Iterate every failed send across pages, one recipient at a time."""
return iterate_cursor(lambda params: self.list_failures(id, params), query)

def retry_failed(self, id: str) -> CampaignRetryFailedV1:
"""Re-drive only the recipients whose send failed.

Nobody who already received the campaign is mailed a second time: each
ledger row is claimed before it is touched, and a row whose email exists
already is re-queued rather than re-sent.

The walk runs in the background, so this returns as soon as it is
queued, reporting ``queued`` -- how many failed rows it was started for.
Only a ``SENT`` campaign qualifies (400 ``validation_error`` otherwise),
and a retry already running answers 409 ``conflict``. Takes no body.
"""
response: CampaignRetryFailedV1 = self._client.request(
method="POST", path=f"/api/v1/campaigns/{encode_path_segment(id)}/retry-failed"
)
return response
100 changes: 99 additions & 1 deletion src/sendly/resources/contacts.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,20 +5,35 @@
from typing import TYPE_CHECKING

from sendly.resources._helpers import encode_path_segment, idempotency_headers
from sendly.resources._pagination import iterate_cursor

if TYPE_CHECKING:
from collections.abc import Iterator

from sendly.client import Sendly
from sendly.types import (
Body,
ContactDeletedV1,
ContactListResponse,
ContactListV1,
ContactRecord,
ContactTopicPreferencesV1,
ContactV1,
JSONDict,
Query,
)


class ContactsResource:
"""Create, query, and manage contacts."""
"""Create, query, and manage contacts, on both surfaces.

The unsuffixed methods speak the legacy ``/api/*`` dialect — camelCase
bodies inside a ``{success, data}`` envelope the SDK unwraps. The
``_v1``-suffixed methods speak ``/api/v1``: bare snake_case bodies, cursor
pagination, and RFC 9457 problem documents on error. Both answer the same
questions, so the suffix is there to keep a call site from confusing one for
the other.
"""

def __init__(self, client: Sendly) -> None:
self._client = client
Expand Down Expand Up @@ -91,3 +106,86 @@ def delete(self, id: str) -> None:
self._client.request(
method="DELETE", path=f"/api/contacts/{encode_path_segment(id)}", no_content=True
)

def list_v1(self, query: Query | None = None) -> ContactListV1:
"""List contacts on the ``/api/v1`` surface.

Accepts ``limit`` (1-100, default 20), ``after`` (opaque cursor),
``search`` (case-insensitive substring on the address) and
``subscribed`` (the string ``"true"`` or ``"false"``), and answers
``{data, has_more, next_cursor}``. Hold the filters steady across one
walk — the cursor encodes them, and changing one mid-pagination is
answered with 422 ``validation_error`` telling you to restart from the
first page.
"""
response: ContactListV1 = self._client.request(
method="GET", path="/api/v1/contacts", query=query
)
return response

def iter_list_v1(self, query: Query | None = None) -> Iterator[JSONDict]:
"""Iterate every v1 contact across pages, following the cursor for you."""
return iterate_cursor(self.list_v1, query)

def create_v1(self, body: Body) -> ContactV1:
"""Create a contact. Requires ``email``.

``subscribed`` defaults to true server-side, and ``custom_fields`` is
arbitrary JSON that templates read back as ``{{ variables }}``.
"""
response: ContactV1 = self._client.request(
method="POST", path="/api/v1/contacts", body=body
)
return response

def get_v1(self, id: str) -> ContactV1:
"""Fetch a single contact by id.

v1 has no lookup-by-address route — reach a contact you only know the
email of through :meth:`list_v1`'s ``search`` filter.
"""
response: ContactV1 = self._client.request(
method="GET", path=f"/api/v1/contacts/{encode_path_segment(id)}"
)
return response

def update_v1(self, id: str, body: Body) -> ContactV1:
"""Patch a contact's ``subscribed`` flag or ``custom_fields``.

``email`` is deliberately not patchable: an address is the contact's
identity on this surface, and rewriting it in place would change who
every earlier send was addressed to. Create the new address instead.

``custom_fields`` is **replaced, not merged** — the object you send
becomes the whole of it, so read the contact and send back every key you
mean to keep. A partial object silently drops the rest.
"""
response: ContactV1 = self._client.request(
method="PATCH", path=f"/api/v1/contacts/{encode_path_segment(id)}", body=body
)
return response

def delete_v1(self, id: str) -> ContactDeletedV1:
"""Delete a contact, returning the ``{id, deleted}`` confirmation body.

Unlike the legacy :meth:`delete`, the acknowledgement is handed back
rather than discarded.
"""
response: ContactDeletedV1 = self._client.request(
method="DELETE", path=f"/api/v1/contacts/{encode_path_segment(id)}"
)
return response

def topic_preferences(self, id: str) -> ContactTopicPreferencesV1:
"""Read everything this contact has said about what they want.

The top-level ``subscribed`` is the global marketing opt-out and
outranks every topic: false means no marketing reaches them whatever the
topic rows say. Each topic's own ``subscribed`` is the effective answer
the send path reaches today, with the topic's ``default_opt_in`` already
folded in, so a contact who has never answered still reads correctly.
"""
response: ContactTopicPreferencesV1 = self._client.request(
method="GET", path=f"/api/v1/contacts/{encode_path_segment(id)}/topics"
)
return response
Loading
Loading