diff --git a/README.md b/README.md index 1227128..d1a68e3 100644 --- a/README.md +++ b/README.md @@ -224,9 +224,10 @@ sit alongside it. Transient failures are retried before they ever reach you (see ## Messages ```python -# One page (newest first). Filter by status, channel, template, or metadata. +# One page (newest first). Filter by status, channel, template, or metadata, +# or search (substring over id, recipient, template slug, and metadata). page = sk.messages.list(status="delivered", channel="email", limit=50, - metadata={"user_id": "usr_123"}) + metadata={"user_id": "usr_123"}, search="user@example.com") for m in page.data: print(m.public_id, m.status) print(page.next_cursor) # pass as cursor= for the next page, or None when done diff --git a/src/senderkit/_serialize.py b/src/senderkit/_serialize.py index cfd421b..75e05b4 100644 --- a/src/senderkit/_serialize.py +++ b/src/senderkit/_serialize.py @@ -169,6 +169,7 @@ def list_messages_query( template: Optional[str], metadata: Optional[Dict[str, Any]], tail: Optional[str], + search: Optional[str] = None, ) -> Dict[str, Any]: """Build the query dict for ``GET /v1/messages``, including ``metadata[key]``.""" query: Dict[str, Any] = _prune( @@ -178,6 +179,7 @@ def list_messages_query( "status": status.value if isinstance(status, Channel) else status, "channel": _channel_value(channel), "template": template, + "search": search, "tail": tail, } ) diff --git a/src/senderkit/resources/messages.py b/src/senderkit/resources/messages.py index e545631..2885812 100644 --- a/src/senderkit/resources/messages.py +++ b/src/senderkit/resources/messages.py @@ -24,9 +24,15 @@ def list( channel: Optional[ChannelLike] = None, template: Optional[str] = None, metadata: Optional[Dict[str, Any]] = None, + search: Optional[str] = None, tail: Optional[str] = None, ) -> MessageList: - """Return one page of messages, newest first, with a cursor for the next.""" + """Return one page of messages, newest first, with a cursor for the next. + + ``search`` is a case-insensitive substring match over a message's public + id, recipient, template slug, and metadata keys/values; use ``metadata`` + for an exact match. Capped at 512 characters by the API. + """ query = list_messages_query( limit=limit, cursor=cursor, @@ -34,6 +40,7 @@ def list( channel=channel, template=template, metadata=metadata, + search=search, tail=tail, ) return MessageList.from_dict(self._t.request_json("GET", "/v1/messages", query=query)) @@ -46,6 +53,7 @@ def iter( channel: Optional[ChannelLike] = None, template: Optional[str] = None, metadata: Optional[Dict[str, Any]] = None, + search: Optional[str] = None, ) -> Iterator[Message]: """Yield every matching message, following ``next_cursor`` across pages.""" cursor: Optional[str] = None @@ -57,6 +65,7 @@ def iter( channel=channel, template=template, metadata=metadata, + search=search, ) yield from page.data if not page.next_cursor: @@ -87,6 +96,7 @@ async def list( channel: Optional[ChannelLike] = None, template: Optional[str] = None, metadata: Optional[Dict[str, Any]] = None, + search: Optional[str] = None, tail: Optional[str] = None, ) -> MessageList: query = list_messages_query( @@ -96,6 +106,7 @@ async def list( channel=channel, template=template, metadata=metadata, + search=search, tail=tail, ) return MessageList.from_dict(await self._t.request_json("GET", "/v1/messages", query=query)) @@ -108,6 +119,7 @@ async def aiter( channel: Optional[ChannelLike] = None, template: Optional[str] = None, metadata: Optional[Dict[str, Any]] = None, + search: Optional[str] = None, ) -> AsyncIterator[Message]: cursor: Optional[str] = None while True: @@ -118,6 +130,7 @@ async def aiter( channel=channel, template=template, metadata=metadata, + search=search, ) for message in page.data: yield message diff --git a/tests/test_messages.py b/tests/test_messages.py index 0007553..a8af5fb 100644 --- a/tests/test_messages.py +++ b/tests/test_messages.py @@ -42,6 +42,15 @@ def test_list_with_filters_builds_query(client): assert params["metadata[userId]"] == "usr_123" +@respx.mock +def test_list_forwards_search(client): + route = respx.get(f"{BASE_URL}/v1/messages").mock( + return_value=httpx.Response(200, json={"data": [], "nextCursor": None}) + ) + client.messages.list(search="user@example.com") + assert route.calls.last.request.url.params["search"] == "user@example.com" + + @respx.mock def test_iter_paginates(client): respx.get(f"{BASE_URL}/v1/messages").mock(