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 17c17fe..253199e 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 + (raises ValueError before the request is sent). + """ + 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 + (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 + (raises ValueError before the request is sent). + """ + + +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,36 @@ 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]]: + """ + 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() + if not (isinstance(value, list) and not value) + } + + class Emails: Attachments = Attachments Receiving = Receiving @@ -200,6 +346,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 +617,29 @@ 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. + see more: https://resend.com/docs/api-reference/emails/get-metrics + + Args: + params (Optional[MetricsParams]): The metrics query parameters + + 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) + 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 +706,31 @@ 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). + see more: https://resend.com/docs/api-reference/emails/get-metrics + + Args: + params (Optional[MetricsParams]): The metrics query parameters + + 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) + 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_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 a565072..6974734 100644 --- a/tests/emails_test.py +++ b/tests/emails_test.py @@ -677,6 +677,384 @@ 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_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_metrics_raises_exception_when_no_content(self) -> None: + self.set_mock_json(None) + with self.assertRaises(NoContentError): + _ = resend.Emails.metrics() + import unittest as _unittest