From 01c8a76d27c6d536a0e397446dc630a23d690715 Mon Sep 17 00:00:00 2001 From: dielduarte Date: Wed, 19 Aug 2026 19:54:50 -0300 Subject: [PATCH 1/4] feat(emails): add beta Emails.metrics() endpoint Adds GET /emails/metrics support ahead of its GA merge: start/end date, timezone, granularity, metrics, dimensions (period/domain/email/broadcast), and domain_id/email_id/broadcast_id filters, all as comma-joined query params. Sync and async variants included. --- resend/emails/_emails.py | 216 ++++++++++++++++++++++++- tests/emails_test.py | 331 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 546 insertions(+), 1 deletion(-) diff --git a/resend/emails/_emails.py b/resend/emails/_emails.py index 17c17fe..47af77f 100644 --- a/resend/emails/_emails.py +++ b/resend/emails/_emails.py @@ -1,6 +1,6 @@ from typing import Any, Dict, List, Optional, Union, cast -from typing_extensions import NotRequired, TypedDict +from typing_extensions import Literal, NotRequired, TypedDict from resend import request from resend._base_response import BaseResponse @@ -17,6 +17,35 @@ except ImportError: pass +MetricsGranularity = Literal["hourly", "daily", "weekly", "monthly"] + +MetricsMetric = Literal[ + "received", + "delivered", + "complained", + "suppressed", + "bounced", + "bounced_transient", + "bounced_permanent", + "bounced_undetermined", + "opened", + "clicked", + "unsubscribed", + "delivery_delayed", + "failed", + "sent", + "unique_opened", + "unique_clicked", + "delivery_rate", + "open_rate", + "click_rate", + "bounce_rate", + "complaint_rate", + "unsubscribe_rate", +] + +MetricsDimension = Literal["period", "domain", "email", "broadcast"] + class EmailTemplate(TypedDict): """ @@ -95,6 +124,93 @@ class _ShareEmailResponse(BaseResponse): """ +class _MetricsParams(TypedDict): + start_date: NotRequired[str] + """ + Start of the date range, as an ISO 8601 date or datetime. + Defaults to 6 days before end_date. + """ + end_date: NotRequired[str] + """ + End of the date range, as an ISO 8601 date or datetime. + Defaults to now. + """ + timezone: NotRequired[str] + """ + IANA timezone (e.g. "America/New_York") used to bucket results. + Defaults to "UTC". + """ + granularity: NotRequired[MetricsGranularity] + """ + The bucket size used for the "period" dimension. Defaults to "daily". + """ + metrics: NotRequired[List[MetricsMetric]] + """ + The metrics to compute. Defaults to all available metrics. + """ + dimensions: NotRequired[List[MetricsDimension]] + """ + The dimensions to break results down by. Defaults to no dimensions, in + which case only `totals` is returned and `data` is omitted. + Note: the "email" and "broadcast" dimensions cannot be combined + (validated server-side). + """ + domain_id: NotRequired[List[str]] + """ + Restrict results to these sending domain IDs. Maximum 100. + """ + email_id: NotRequired[List[str]] + """ + Restrict results to these email IDs. Maximum 100. + Cannot be combined with the "broadcast" dimension or broadcast_id filter + (validated server-side). + """ + broadcast_id: NotRequired[List[str]] + """ + Restrict results to these broadcast IDs. Maximum 100. + Cannot be combined with the "email" dimension or email_id filter + (validated server-side). + """ + + +class _MetricsResponse(BaseResponse): + object: str + """ + The object type: "metrics" + """ + start_date: str + """ + Start of the date range that was queried. + """ + end_date: str + """ + End of the date range that was queried. + """ + metrics: List[str] + """ + The metrics that were computed. + """ + dimensions: List[str] + """ + The dimensions results are broken down by. + """ + granularity: str + """ + The bucket size used for the "period" dimension. + """ + totals: Dict[str, Any] + """ + The requested metrics, totaled across the whole date range. + """ + data: NotRequired[List[Dict[str, Any]]] + """ + One row per combination of requested dimensions, each containing the + dimension key fields (e.g. `period`, `domain_id`/`domain_name`, + `email_id`, `broadcast_id`/`broadcast_name`) plus the requested metrics. + Omitted when `dimensions` is empty. + """ + + # SendParamsFrom is declared with functional TypedDict syntax here because # "from" is a reserved keyword in Python, and this is the best way to # support type-checking for it. @@ -158,6 +274,22 @@ class _SendParamsDefault(_SendParamsFrom): """ +def _build_metrics_query_params( + params: Optional["Emails.MetricsParams"], +) -> Optional[Dict[str, Any]]: + """ + Comma-join list-valued params (metrics, dimensions, domain_id, email_id, + broadcast_id) the way the metrics endpoint expects them in the query + string; PaginationHelper.build_paginated_path does not join lists itself. + """ + if not params: + return None + return { + key: ",".join(value) if isinstance(value, list) else value + for key, value in params.items() + } + + class Emails: Attachments = Attachments Receiving = Receiving @@ -200,6 +332,42 @@ class ShareEmailResponse(_ShareEmailResponse): url (str): The shareable link URL. """ + class MetricsParams(_MetricsParams): + """ + MetricsParams is the class that wraps the parameters for the metrics method. + + Attributes: + start_date (NotRequired[str]): Start of the date range (ISO 8601). \ + Defaults to 6 days before end_date. + end_date (NotRequired[str]): End of the date range (ISO 8601). Defaults to now. + timezone (NotRequired[str]): IANA timezone, e.g. "America/New_York". Defaults to "UTC". + granularity (NotRequired[MetricsGranularity]): Bucket size for the "period" \ + dimension. Defaults to "daily". + metrics (NotRequired[List[MetricsMetric]]): The metrics to compute. \ + Defaults to all available metrics. + dimensions (NotRequired[List[MetricsDimension]]): The dimensions to break \ + results down by. Defaults to none, in which case only `totals` is returned. + domain_id (NotRequired[List[str]]): Restrict results to these sending domain IDs. + email_id (NotRequired[List[str]]): Restrict results to these email IDs. + broadcast_id (NotRequired[List[str]]): Restrict results to these broadcast IDs. + """ + + class MetricsResponse(_MetricsResponse): + """ + MetricsResponse is the type that wraps the response of the metrics method. + + Attributes: + object (str): The object type: "metrics" + start_date (str): Start of the date range that was queried. + end_date (str): End of the date range that was queried. + metrics (List[str]): The metrics that were computed. + dimensions (List[str]): The dimensions results are broken down by. + granularity (str): The bucket size used for the "period" dimension. + totals (Dict[str, Any]): The requested metrics, totaled across the whole date range. + data (NotRequired[List[Dict[str, Any]]]): One row per combination of requested \ + dimensions. Omitted when `dimensions` is empty. + """ + class UpdateParams(_UpdateParams): """ UpdateParams is the class that wraps the parameters for the update method. @@ -435,6 +603,28 @@ def list(cls, params: Optional[ListParams] = None) -> ListResponse: ).perform_with_content() return resp + @classmethod + def metrics(cls, params: Optional[MetricsParams] = None) -> MetricsResponse: + """ + Retrieve email metrics. + This is a beta endpoint and its shape may change ahead of GA. + + Args: + params (Optional[MetricsParams]): The metrics query parameters + + Returns: + MetricsResponse: The requested metrics, totaled and (optionally) broken down by dimension + """ + base_path = "/emails/metrics" + query_params = _build_metrics_query_params(params) + path = PaginationHelper.build_paginated_path(base_path, query_params) + resp = request.Request[Emails.MetricsResponse]( + path=path, + params={}, + verb="get", + ).perform_with_content() + return resp + @classmethod async def send_async( cls, params: SendParams, options: Optional[SendOptions] = None @@ -501,6 +691,30 @@ async def list_async(cls, params: Optional[ListParams] = None) -> ListResponse: ).perform_with_content() return resp + @classmethod + async def metrics_async( + cls, params: Optional[MetricsParams] = None + ) -> MetricsResponse: + """ + Retrieve email metrics (async version). + This is a beta endpoint and its shape may change ahead of GA. + + Args: + params (Optional[MetricsParams]): The metrics query parameters + + Returns: + MetricsResponse: The requested metrics, totaled and (optionally) broken down by dimension + """ + base_path = "/emails/metrics" + query_params = _build_metrics_query_params(params) + path = PaginationHelper.build_paginated_path(base_path, query_params) + resp = await AsyncRequest[Emails.MetricsResponse]( + path=path, + params={}, + verb="get", + ).perform_with_content() + return resp + @classmethod async def cancel_async(cls, email_id: str) -> CancelScheduledEmailResponse: """ diff --git a/tests/emails_test.py b/tests/emails_test.py index a565072..ca5517c 100644 --- a/tests/emails_test.py +++ b/tests/emails_test.py @@ -677,6 +677,337 @@ def test_email_send_with_custom_headers(self) -> None: email: resend.Emails.SendResponse = resend.Emails.send(params) assert email["id"] == "49a3999c-0ce1-4ea6-ab68-afcd6dc2e794" + def test_metrics_with_no_params(self) -> None: + self.set_mock_json( + { + "object": "metrics", + "start_date": "2026-07-02T00:00:00.000Z", + "end_date": "2026-07-08T00:00:00.000Z", + "metrics": ["delivered", "opened"], + "dimensions": [], + "granularity": "daily", + "totals": {"delivered": 100, "opened": 40}, + } + ) + metrics: resend.Emails.MetricsResponse = resend.Emails.metrics() + assert metrics["object"] == "metrics" + assert metrics["totals"]["delivered"] == 100 + assert "data" not in metrics + self.mock.assert_called_with(url="https://api.resend.com/emails/metrics") + + def test_metrics_with_period_dimension(self) -> None: + self.set_mock_json( + { + "object": "metrics", + "start_date": "2026-07-01T00:00:00.000Z", + "end_date": "2026-07-08T00:00:00.000Z", + "metrics": ["delivered"], + "dimensions": ["period"], + "granularity": "daily", + "totals": {"delivered": 100}, + "data": [ + {"period": "2026-07-01", "delivered": 10}, + {"period": "2026-07-02", "delivered": 20}, + ], + } + ) + params: resend.Emails.MetricsParams = {"dimensions": ["period"]} + metrics: resend.Emails.MetricsResponse = resend.Emails.metrics(params=params) + assert metrics["dimensions"] == ["period"] + assert metrics["data"][0]["period"] == "2026-07-01" + self.mock.assert_called_with( + url="https://api.resend.com/emails/metrics?dimensions=period" + ) + + def test_metrics_with_domain_dimension(self) -> None: + self.set_mock_json( + { + "object": "metrics", + "start_date": "2026-07-01T00:00:00.000Z", + "end_date": "2026-07-08T00:00:00.000Z", + "metrics": ["delivered"], + "dimensions": ["domain"], + "granularity": "daily", + "totals": {"delivered": 100}, + "data": [ + { + "domain_id": "d68a4265-d33b-4658-b9e6-c9d0c5b0e4a3", + "domain_name": "example.com", + "delivered": 100, + }, + ], + } + ) + params: resend.Emails.MetricsParams = {"dimensions": ["domain"]} + metrics: resend.Emails.MetricsResponse = resend.Emails.metrics(params=params) + assert metrics["dimensions"] == ["domain"] + assert metrics["data"][0]["domain_name"] == "example.com" + self.mock.assert_called_with( + url="https://api.resend.com/emails/metrics?dimensions=domain" + ) + + def test_metrics_with_email_dimension(self) -> None: + self.set_mock_json( + { + "object": "metrics", + "start_date": "2026-07-01T00:00:00.000Z", + "end_date": "2026-07-08T00:00:00.000Z", + "metrics": ["delivered"], + "dimensions": ["email"], + "granularity": "daily", + "totals": {"delivered": 1}, + "data": [ + { + "email_id": "4ef9a417-02e9-4d39-ad75-9611e0fcc33c", + "delivered": 1, + }, + ], + } + ) + params: resend.Emails.MetricsParams = {"dimensions": ["email"]} + metrics: resend.Emails.MetricsResponse = resend.Emails.metrics(params=params) + assert metrics["dimensions"] == ["email"] + assert metrics["data"][0]["email_id"] == "4ef9a417-02e9-4d39-ad75-9611e0fcc33c" + self.mock.assert_called_with( + url="https://api.resend.com/emails/metrics?dimensions=email" + ) + + def test_metrics_with_broadcast_dimension(self) -> None: + self.set_mock_json( + { + "object": "metrics", + "start_date": "2026-07-01T00:00:00.000Z", + "end_date": "2026-07-08T00:00:00.000Z", + "metrics": ["delivered", "opened"], + "dimensions": ["broadcast"], + "granularity": "daily", + "totals": {"delivered": 100, "opened": 40}, + "data": [ + { + "broadcast_id": "b3a6e6e2-9f2b-4e2a-9b1b-1a2b3c4d5e6f", + "broadcast_name": "July Newsletter", + "delivered": 100, + "opened": 40, + }, + ], + } + ) + params: resend.Emails.MetricsParams = {"dimensions": ["broadcast"]} + metrics: resend.Emails.MetricsResponse = resend.Emails.metrics(params=params) + assert metrics["dimensions"] == ["broadcast"] + assert metrics["data"][0]["broadcast_name"] == "July Newsletter" + self.mock.assert_called_with( + url="https://api.resend.com/emails/metrics?dimensions=broadcast" + ) + + def test_metrics_with_multiple_dimensions(self) -> None: + self.set_mock_json( + { + "object": "metrics", + "start_date": "2026-07-01T00:00:00.000Z", + "end_date": "2026-07-08T00:00:00.000Z", + "metrics": ["delivered", "opened"], + "dimensions": ["period", "broadcast"], + "granularity": "daily", + "totals": {"delivered": 100, "opened": 40}, + "data": [ + { + "period": "2026-07-01", + "broadcast_id": "b3a6e6e2-9f2b-4e2a-9b1b-1a2b3c4d5e6f", + "broadcast_name": "July Newsletter", + "delivered": 10, + "opened": 4, + }, + ], + } + ) + params: resend.Emails.MetricsParams = {"dimensions": ["period", "broadcast"]} + metrics: resend.Emails.MetricsResponse = resend.Emails.metrics(params=params) + assert metrics["dimensions"] == ["period", "broadcast"] + self.mock.assert_called_with( + url="https://api.resend.com/emails/metrics?dimensions=period%2Cbroadcast" + ) + + def test_metrics_with_single_domain_id_filter(self) -> None: + self.set_mock_json( + { + "object": "metrics", + "start_date": "2026-07-01T00:00:00.000Z", + "end_date": "2026-07-08T00:00:00.000Z", + "metrics": ["delivered"], + "dimensions": [], + "granularity": "daily", + "totals": {"delivered": 50}, + } + ) + params: resend.Emails.MetricsParams = { + "domain_id": ["d68a4265-d33b-4658-b9e6-c9d0c5b0e4a3"], + } + metrics: resend.Emails.MetricsResponse = resend.Emails.metrics(params=params) + assert metrics["totals"]["delivered"] == 50 + self.mock.assert_called_with( + url="https://api.resend.com/emails/metrics?domain_id=d68a4265-d33b-4658-b9e6-c9d0c5b0e4a3" + ) + + def test_metrics_with_multiple_domain_id_filter(self) -> None: + self.set_mock_json( + { + "object": "metrics", + "start_date": "2026-07-01T00:00:00.000Z", + "end_date": "2026-07-08T00:00:00.000Z", + "metrics": ["delivered"], + "dimensions": [], + "granularity": "daily", + "totals": {"delivered": 90}, + } + ) + params: resend.Emails.MetricsParams = { + "domain_id": [ + "d68a4265-d33b-4658-b9e6-c9d0c5b0e4a3", + "e79b5376-e44c-5769-c0f7-dae1d6c1f5b4", + ], + } + metrics: resend.Emails.MetricsResponse = resend.Emails.metrics(params=params) + assert metrics["totals"]["delivered"] == 90 + self.mock.assert_called_with( + url="https://api.resend.com/emails/metrics?domain_id=d68a4265-d33b-4658-b9e6-c9d0c5b0e4a3%2Ce79b5376-e44c-5769-c0f7-dae1d6c1f5b4" + ) + + def test_metrics_with_single_email_id_filter(self) -> None: + self.set_mock_json( + { + "object": "metrics", + "start_date": "2026-07-01T00:00:00.000Z", + "end_date": "2026-07-08T00:00:00.000Z", + "metrics": ["delivered"], + "dimensions": [], + "granularity": "daily", + "totals": {"delivered": 1}, + } + ) + params: resend.Emails.MetricsParams = { + "email_id": ["4ef9a417-02e9-4d39-ad75-9611e0fcc33c"], + } + metrics: resend.Emails.MetricsResponse = resend.Emails.metrics(params=params) + assert metrics["totals"]["delivered"] == 1 + self.mock.assert_called_with( + url="https://api.resend.com/emails/metrics?email_id=4ef9a417-02e9-4d39-ad75-9611e0fcc33c" + ) + + def test_metrics_with_multiple_email_id_filter(self) -> None: + self.set_mock_json( + { + "object": "metrics", + "start_date": "2026-07-01T00:00:00.000Z", + "end_date": "2026-07-08T00:00:00.000Z", + "metrics": ["delivered"], + "dimensions": [], + "granularity": "daily", + "totals": {"delivered": 2}, + } + ) + params: resend.Emails.MetricsParams = { + "email_id": [ + "4ef9a417-02e9-4d39-ad75-9611e0fcc33c", + "5ef9a417-02e9-4d39-ad75-9611e0fcc33d", + ], + } + metrics: resend.Emails.MetricsResponse = resend.Emails.metrics(params=params) + assert metrics["totals"]["delivered"] == 2 + self.mock.assert_called_with( + url="https://api.resend.com/emails/metrics?email_id=4ef9a417-02e9-4d39-ad75-9611e0fcc33c%2C5ef9a417-02e9-4d39-ad75-9611e0fcc33d" + ) + + def test_metrics_with_single_broadcast_id_filter(self) -> None: + self.set_mock_json( + { + "object": "metrics", + "start_date": "2026-07-01T00:00:00.000Z", + "end_date": "2026-07-08T00:00:00.000Z", + "metrics": ["delivered"], + "dimensions": [], + "granularity": "daily", + "totals": {"delivered": 100}, + } + ) + params: resend.Emails.MetricsParams = { + "broadcast_id": ["b3a6e6e2-9f2b-4e2a-9b1b-1a2b3c4d5e6f"], + } + metrics: resend.Emails.MetricsResponse = resend.Emails.metrics(params=params) + assert metrics["totals"]["delivered"] == 100 + self.mock.assert_called_with( + url="https://api.resend.com/emails/metrics?broadcast_id=b3a6e6e2-9f2b-4e2a-9b1b-1a2b3c4d5e6f" + ) + + def test_metrics_with_multiple_broadcast_id_filter(self) -> None: + self.set_mock_json( + { + "object": "metrics", + "start_date": "2026-07-01T00:00:00.000Z", + "end_date": "2026-07-08T00:00:00.000Z", + "metrics": ["delivered"], + "dimensions": [], + "granularity": "daily", + "totals": {"delivered": 150}, + } + ) + params: resend.Emails.MetricsParams = { + "broadcast_id": [ + "b3a6e6e2-9f2b-4e2a-9b1b-1a2b3c4d5e6f", + "c4b7f7f3-a03c-5f3b-ac2c-2b3c4d5e6f70", + ], + } + metrics: resend.Emails.MetricsResponse = resend.Emails.metrics(params=params) + assert metrics["totals"]["delivered"] == 150 + self.mock.assert_called_with( + url="https://api.resend.com/emails/metrics?broadcast_id=b3a6e6e2-9f2b-4e2a-9b1b-1a2b3c4d5e6f%2Cc4b7f7f3-a03c-5f3b-ac2c-2b3c4d5e6f70" + ) + + def test_metrics_with_metrics_granularity_and_timezone(self) -> None: + self.set_mock_json( + { + "object": "metrics", + "start_date": "2026-07-01T00:00:00.000Z", + "end_date": "2026-07-08T00:00:00.000Z", + "metrics": ["delivered", "opened", "clicked"], + "dimensions": ["period"], + "granularity": "hourly", + "totals": {"delivered": 100, "opened": 40, "clicked": 10}, + "data": [ + { + "period": "2026-07-01T00:00:00.000Z", + "delivered": 5, + "opened": 2, + "clicked": 1, + }, + ], + } + ) + params: resend.Emails.MetricsParams = { + "start_date": "2026-07-01", + "end_date": "2026-07-08", + "timezone": "America/New_York", + "granularity": "hourly", + "metrics": ["delivered", "opened", "clicked"], + "dimensions": ["period"], + } + metrics: resend.Emails.MetricsResponse = resend.Emails.metrics(params=params) + assert metrics["granularity"] == "hourly" + assert metrics["metrics"] == ["delivered", "opened", "clicked"] + self.mock.assert_called_with( + url=( + "https://api.resend.com/emails/metrics?" + "start_date=2026-07-01&end_date=2026-07-08" + "&timezone=America%2FNew_York&granularity=hourly" + "&metrics=delivered%2Copened%2Cclicked&dimensions=period" + ) + ) + + def test_should_metrics_raise_exception_when_no_content(self) -> None: + self.set_mock_json(None) + with self.assertRaises(NoContentError): + _ = resend.Emails.metrics() + import unittest as _unittest From 67e8ac247e3d5b1302c6649214e49cbf11e7fd34 Mon Sep 17 00:00:00 2001 From: dielduarte Date: Fri, 21 Aug 2026 19:39:34 -0300 Subject: [PATCH 2/4] fix(emails): validate email/broadcast mutual exclusivity in metrics() The email and broadcast dimensions/filters were only rejected server-side (a 400 from the API). Raise ValueError locally instead, matching how this SDK already reports invalid input elsewhere (e.g. Contacts.update). --- resend/emails/_emails.py | 15 +++++++++++++ tests/emails_test.py | 47 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 62 insertions(+) diff --git a/resend/emails/_emails.py b/resend/emails/_emails.py index 47af77f..76d705b 100644 --- a/resend/emails/_emails.py +++ b/resend/emails/_emails.py @@ -274,6 +274,19 @@ class _SendParamsDefault(_SendParamsFrom): """ +def _validate_metrics_params(params: Optional["Emails.MetricsParams"]) -> None: + if not params: + return + dimensions = params.get("dimensions") or [] + has_broadcast = "broadcast" in dimensions or bool(params.get("broadcast_id")) + has_email = "email" in dimensions or bool(params.get("email_id")) + if has_broadcast and has_email: + raise ValueError( + "the broadcast dimension/broadcast_id filter cannot be combined " + "with the email dimension/email_id filter" + ) + + def _build_metrics_query_params( params: Optional["Emails.MetricsParams"], ) -> Optional[Dict[str, Any]]: @@ -615,6 +628,7 @@ def metrics(cls, params: Optional[MetricsParams] = None) -> MetricsResponse: Returns: MetricsResponse: The requested metrics, totaled and (optionally) broken down by dimension """ + _validate_metrics_params(params) base_path = "/emails/metrics" query_params = _build_metrics_query_params(params) path = PaginationHelper.build_paginated_path(base_path, query_params) @@ -705,6 +719,7 @@ async def metrics_async( Returns: MetricsResponse: The requested metrics, totaled and (optionally) broken down by dimension """ + _validate_metrics_params(params) base_path = "/emails/metrics" query_params = _build_metrics_query_params(params) path = PaginationHelper.build_paginated_path(base_path, query_params) diff --git a/tests/emails_test.py b/tests/emails_test.py index ca5517c..87f457e 100644 --- a/tests/emails_test.py +++ b/tests/emails_test.py @@ -1003,6 +1003,53 @@ def test_metrics_with_metrics_granularity_and_timezone(self) -> None: ) ) + def test_metrics_raises_when_email_and_broadcast_dimensions_combined( + self, + ) -> None: + params: resend.Emails.MetricsParams = { + "dimensions": ["email", "broadcast"], + } + try: + resend.Emails.metrics(params=params) + self.fail("expected ValueError") + except ValueError as e: + assert str(e) == ( + "the broadcast dimension/broadcast_id filter cannot be " + "combined with the email dimension/email_id filter" + ) + self.mock.assert_not_called() + + def test_metrics_raises_when_broadcast_dimension_combined_with_email_id( + self, + ) -> None: + params: resend.Emails.MetricsParams = { + "dimensions": ["broadcast"], + "email_id": ["4dd369bc-aa82-4ff3-97de-514ae3000ee0"], + } + with self.assertRaises(ValueError): + resend.Emails.metrics(params=params) + self.mock.assert_not_called() + + def test_metrics_raises_when_email_dimension_combined_with_broadcast_id( + self, + ) -> None: + params: resend.Emails.MetricsParams = { + "dimensions": ["email"], + "broadcast_id": ["b3a6e6e2-9f2b-4e2a-9b1b-1a2b3c4d5e6f"], + } + with self.assertRaises(ValueError): + resend.Emails.metrics(params=params) + self.mock.assert_not_called() + + def test_metrics_raises_when_email_id_and_broadcast_id_combined(self) -> None: + params: resend.Emails.MetricsParams = { + "email_id": ["4dd369bc-aa82-4ff3-97de-514ae3000ee0"], + "broadcast_id": ["b3a6e6e2-9f2b-4e2a-9b1b-1a2b3c4d5e6f"], + } + with self.assertRaises(ValueError): + resend.Emails.metrics(params=params) + self.mock.assert_not_called() + def test_should_metrics_raise_exception_when_no_content(self) -> None: self.set_mock_json(None) with self.assertRaises(NoContentError): From 084c4347cc37aca7001a28b202e9253892bded6c Mon Sep 17 00:00:00 2001 From: dielduarte Date: Fri, 21 Aug 2026 21:55:38 -0300 Subject: [PATCH 3/4] fix(emails): address cubic review feedback on metrics() - remove stale beta-endpoint docstring lines, replace with the see-more doc link matching sibling methods - skip empty lists when building the metrics query string, so an explicit dimensions: [] or empty id filter is omitted instead of sent as a bare query param - rename test_should_metrics_raise_exception_when_no_content to drop the disallowed 'should' wording - add async test coverage for metrics_async(), mirroring the sync suite (success, broadcast dimension, all four mutual-exclusivity violations, no-content) --- resend/emails/_emails.py | 5 +- tests/emails_metrics_async_test.py | 107 +++++++++++++++++++++++++++++ tests/emails_test.py | 2 +- 3 files changed, 111 insertions(+), 3 deletions(-) create mode 100644 tests/emails_metrics_async_test.py diff --git a/resend/emails/_emails.py b/resend/emails/_emails.py index 76d705b..2d2ec5b 100644 --- a/resend/emails/_emails.py +++ b/resend/emails/_emails.py @@ -300,6 +300,7 @@ def _build_metrics_query_params( return { key: ",".join(value) if isinstance(value, list) else value for key, value in params.items() + if not (isinstance(value, list) and not value) } @@ -620,7 +621,7 @@ def list(cls, params: Optional[ListParams] = None) -> ListResponse: def metrics(cls, params: Optional[MetricsParams] = None) -> MetricsResponse: """ Retrieve email metrics. - This is a beta endpoint and its shape may change ahead of GA. + see more: https://resend.com/docs/api-reference/emails/get-metrics Args: params (Optional[MetricsParams]): The metrics query parameters @@ -711,7 +712,7 @@ async def metrics_async( ) -> MetricsResponse: """ Retrieve email metrics (async version). - This is a beta endpoint and its shape may change ahead of GA. + see more: https://resend.com/docs/api-reference/emails/get-metrics Args: params (Optional[MetricsParams]): The metrics query parameters diff --git a/tests/emails_metrics_async_test.py b/tests/emails_metrics_async_test.py new file mode 100644 index 0000000..529dd7d --- /dev/null +++ b/tests/emails_metrics_async_test.py @@ -0,0 +1,107 @@ +import pytest + +import resend +from resend.exceptions import NoContentError +from tests.conftest import AsyncResendBaseTest + +# flake8: noqa + +pytestmark = pytest.mark.asyncio + + +class TestResendEmailsMetricsAsync(AsyncResendBaseTest): + async def test_metrics_async_with_no_params(self) -> None: + self.set_mock_json( + { + "object": "metrics", + "start_date": "2026-07-02T00:00:00.000Z", + "end_date": "2026-07-08T00:00:00.000Z", + "metrics": ["delivered", "opened"], + "dimensions": [], + "granularity": "daily", + "totals": {"delivered": 100, "opened": 40}, + } + ) + metrics: resend.Emails.MetricsResponse = await resend.Emails.metrics_async() + assert metrics["object"] == "metrics" + assert metrics["totals"]["delivered"] == 100 + assert "data" not in metrics + self.mock.assert_called_with(url="https://api.resend.com/emails/metrics") + + async def test_metrics_async_with_broadcast_dimension(self) -> None: + self.set_mock_json( + { + "object": "metrics", + "start_date": "2026-07-01T00:00:00.000Z", + "end_date": "2026-07-08T00:00:00.000Z", + "metrics": ["delivered", "opened"], + "dimensions": ["broadcast"], + "granularity": "daily", + "totals": {"delivered": 100, "opened": 40}, + "data": [ + { + "broadcast_id": "b3a6e6e2-9f2b-4e2a-9b1b-1a2b3c4d5e6f", + "broadcast_name": "July Newsletter", + "delivered": 100, + "opened": 40, + }, + ], + } + ) + params: resend.Emails.MetricsParams = {"dimensions": ["broadcast"]} + metrics: resend.Emails.MetricsResponse = await resend.Emails.metrics_async( + params=params + ) + assert metrics["dimensions"] == ["broadcast"] + assert metrics["data"][0]["broadcast_name"] == "July Newsletter" + self.mock.assert_called_with( + url="https://api.resend.com/emails/metrics?dimensions=broadcast" + ) + + async def test_metrics_async_raises_when_email_and_broadcast_dimensions_combined( + self, + ) -> None: + params: resend.Emails.MetricsParams = { + "dimensions": ["email", "broadcast"], + } + with pytest.raises(ValueError): + await resend.Emails.metrics_async(params=params) + self.mock.assert_not_called() + + async def test_metrics_async_raises_when_broadcast_dimension_combined_with_email_id( + self, + ) -> None: + params: resend.Emails.MetricsParams = { + "dimensions": ["broadcast"], + "email_id": ["4dd369bc-aa82-4ff3-97de-514ae3000ee0"], + } + with pytest.raises(ValueError): + await resend.Emails.metrics_async(params=params) + self.mock.assert_not_called() + + async def test_metrics_async_raises_when_email_dimension_combined_with_broadcast_id( + self, + ) -> None: + params: resend.Emails.MetricsParams = { + "dimensions": ["email"], + "broadcast_id": ["b3a6e6e2-9f2b-4e2a-9b1b-1a2b3c4d5e6f"], + } + with pytest.raises(ValueError): + await resend.Emails.metrics_async(params=params) + self.mock.assert_not_called() + + async def test_metrics_async_raises_when_email_id_and_broadcast_id_combined( + self, + ) -> None: + params: resend.Emails.MetricsParams = { + "email_id": ["4dd369bc-aa82-4ff3-97de-514ae3000ee0"], + "broadcast_id": ["b3a6e6e2-9f2b-4e2a-9b1b-1a2b3c4d5e6f"], + } + with pytest.raises(ValueError): + await resend.Emails.metrics_async(params=params) + self.mock.assert_not_called() + + async def test_metrics_async_raises_exception_when_no_content(self) -> None: + self.set_mock_json(None) + with pytest.raises(NoContentError): + _ = await resend.Emails.metrics_async() diff --git a/tests/emails_test.py b/tests/emails_test.py index 87f457e..6974734 100644 --- a/tests/emails_test.py +++ b/tests/emails_test.py @@ -1050,7 +1050,7 @@ def test_metrics_raises_when_email_id_and_broadcast_id_combined(self) -> None: resend.Emails.metrics(params=params) self.mock.assert_not_called() - def test_should_metrics_raise_exception_when_no_content(self) -> None: + def test_metrics_raises_exception_when_no_content(self) -> None: self.set_mock_json(None) with self.assertRaises(NoContentError): _ = resend.Emails.metrics() From 16de4277fadb39b4922aae433567c3607d3b668c Mon Sep 17 00:00:00 2001 From: Felipe Freitag Date: Mon, 24 Aug 2026 12:02:14 -0300 Subject: [PATCH 4/4] docs: add a metrics example; docstrings say the exclusion raises client-side Feature PRs ship an examples/ entry. The exclusion docstrings said validated server-side, but _validate_metrics_params raises ValueError before the request. --- examples/emails_metrics.py | 21 +++++++++++++++++++++ resend/emails/_emails.py | 6 +++--- 2 files changed, 24 insertions(+), 3 deletions(-) create mode 100644 examples/emails_metrics.py diff --git a/examples/emails_metrics.py b/examples/emails_metrics.py new file mode 100644 index 0000000..bd8c7f2 --- /dev/null +++ b/examples/emails_metrics.py @@ -0,0 +1,21 @@ +import os + +import resend + +if not os.environ["RESEND_API_KEY"]: + raise EnvironmentError("RESEND_API_KEY is missing") + +metrics: resend.Emails.MetricsResponse = resend.Emails.metrics() +print(f"Metrics for {metrics['start_date']} to {metrics['end_date']}") +for metric, value in metrics["totals"].items(): + print(f"{metric}: {value}") + +print("\n--- Broken down by period and domain ---") +params: resend.Emails.MetricsParams = { + "dimensions": ["period", "domain"], + "metrics": ["sent", "delivered", "opened"], + "granularity": "daily", +} +breakdown: resend.Emails.MetricsResponse = resend.Emails.metrics(params=params) +for row in breakdown.get("data", []): + print(row) diff --git a/resend/emails/_emails.py b/resend/emails/_emails.py index 2d2ec5b..253199e 100644 --- a/resend/emails/_emails.py +++ b/resend/emails/_emails.py @@ -153,7 +153,7 @@ class _MetricsParams(TypedDict): The dimensions to break results down by. Defaults to no dimensions, in which case only `totals` is returned and `data` is omitted. Note: the "email" and "broadcast" dimensions cannot be combined - (validated server-side). + (raises ValueError before the request is sent). """ domain_id: NotRequired[List[str]] """ @@ -163,13 +163,13 @@ class _MetricsParams(TypedDict): """ Restrict results to these email IDs. Maximum 100. Cannot be combined with the "broadcast" dimension or broadcast_id filter - (validated server-side). + (raises ValueError before the request is sent). """ broadcast_id: NotRequired[List[str]] """ Restrict results to these broadcast IDs. Maximum 100. Cannot be combined with the "email" dimension or email_id filter - (validated server-side). + (raises ValueError before the request is sent). """