From c8a396f5ecfdd7e19e036a19eeef82b730ed188e Mon Sep 17 00:00:00 2001 From: Lucas Fernandes da Costa Date: Thu, 12 Nov 2026 09:02:02 -0300 Subject: [PATCH 1/2] feat(domains): add forge activation methods Co-Authored-By: Claude Fable 5 --- examples/domain_forge.py | 35 ++++++ resend/__init__.py | 3 + resend/domains/_domains.py | 4 + resend/domains/forge/__init__.py | 3 + resend/domains/forge/_domain_forge.py | 165 ++++++++++++++++++++++++ resend/version.py | 2 +- tests/domain_forge_async_test.py | 88 +++++++++++++ tests/domain_forge_test.py | 174 ++++++++++++++++++++++++++ 8 files changed, 473 insertions(+), 1 deletion(-) create mode 100644 examples/domain_forge.py create mode 100644 resend/domains/forge/__init__.py create mode 100644 resend/domains/forge/_domain_forge.py create mode 100644 tests/domain_forge_async_test.py create mode 100644 tests/domain_forge_test.py diff --git a/examples/domain_forge.py b/examples/domain_forge.py new file mode 100644 index 0000000..9a33333 --- /dev/null +++ b/examples/domain_forge.py @@ -0,0 +1,35 @@ +import os + +import resend + +if not os.environ["RESEND_API_KEY"]: + raise EnvironmentError("RESEND_API_KEY is missing") + +domain_id = "d91cd9bd-1176-453e-8fc1-35364d380206" + +# Enable: activate Forge on an existing domain. Returns the full domain. +# Enabling an already-migrated domain is idempotent and returns its +# current state. +enable_params: resend.Domains.Forge.EnableParams = { + "id": domain_id, + "return_path": "send", +} +domain: resend.Domain = resend.Domains.Forge.enable(enable_params) +print(f"Forge enabled on domain: {domain['id']}") +print(f"Domain status: {domain['status']}") + +# Update: move the return path and re-verify. After the domain verifies, +# the API refuses this with a 422 return_path_unavailable. +update_params: resend.Domains.Forge.UpdateParams = { + "id": domain_id, + "return_path": "outbound", +} +domain = resend.Domains.Forge.update(update_params) +print(f"Domain status after return path move: {domain['status']}") + +# Verify: trigger asynchronous Forge verification, then poll Domains.get +# for the domain's state. +verified: resend.ForgeVerifyResponse = resend.Domains.Forge.verify( + domain_id=domain_id +) +print(f"Verification triggered for domain: {verified['id']}") diff --git a/resend/__init__.py b/resend/__init__.py index 6e5aa88..8cb365e 100644 --- a/resend/__init__.py +++ b/resend/__init__.py @@ -36,6 +36,7 @@ from .domains._domains import Domains from .domains.claims._domain_claim import DomainClaim, DomainClaimRecord from .domains.claims._domain_claims import DomainClaims +from .domains.forge._domain_forge import DomainForge, ForgeVerifyResponse from .emails._attachment import Attachment, RemoteAttachment from .emails._attachments import Attachments as EmailAttachments from .emails._batch import Batch, BatchValidationError @@ -167,6 +168,7 @@ "ApiKeys", "Domains", "DomainClaims", + "DomainForge", "Batch", "Audiences", "Automations", @@ -220,6 +222,7 @@ "Domain", "DomainClaim", "DomainClaimRecord", + "ForgeVerifyResponse", "ApiKey", "Log", "Email", diff --git a/resend/domains/_domains.py b/resend/domains/_domains.py index aa5f2ca..c8c1612 100644 --- a/resend/domains/_domains.py +++ b/resend/domains/_domains.py @@ -7,6 +7,7 @@ from resend.domains._domain import Domain from resend.domains._record import Record from resend.domains.claims._domain_claims import DomainClaims +from resend.domains.forge._domain_forge import DomainForge from resend.pagination_helper import PaginationHelper # Async imports (optional - only available with pip install resend[async]) @@ -23,6 +24,9 @@ class Domains: class Claims(DomainClaims): pass + class Forge(DomainForge): + pass + class ListParams(TypedDict): limit: NotRequired[int] """ diff --git a/resend/domains/forge/__init__.py b/resend/domains/forge/__init__.py new file mode 100644 index 0000000..1900038 --- /dev/null +++ b/resend/domains/forge/__init__.py @@ -0,0 +1,3 @@ +from ._domain_forge import DomainForge, ForgeVerifyResponse + +__all__ = ["DomainForge", "ForgeVerifyResponse"] diff --git a/resend/domains/forge/_domain_forge.py b/resend/domains/forge/_domain_forge.py new file mode 100644 index 0000000..a2a64f0 --- /dev/null +++ b/resend/domains/forge/_domain_forge.py @@ -0,0 +1,165 @@ +from typing import Any, Dict, Mapping + +from typing_extensions import NotRequired, TypedDict + +from resend import request +from resend._base_response import BaseResponse +from resend.domains._domain import Domain + +# Async imports (optional - only available with pip install resend[async]) +try: + from resend.async_request import AsyncRequest +except ImportError: + pass + + +class ForgeVerifyResponse(BaseResponse): + """ + ForgeVerifyResponse is the slim acknowledgment returned when a Forge + verification is triggered. Verification runs asynchronously; poll + `Domains.get` for the domain's state. + + Attributes: + object (str): The object type, always "domain" + id (str): The domain ID + """ + + object: str + id: str + + +class DomainForge: + class EnableParams(TypedDict): + id: str + """ + The domain ID. + """ + return_path: NotRequired[str] + """ + The subdomain for the Return-Path address. When omitted, Resend picks + a suggested label based on the domain's current return path. + """ + + class UpdateParams(TypedDict): + id: str + """ + The domain ID. + """ + return_path: str + """ + The new subdomain for the Return-Path address. + """ + + @classmethod + def enable(cls, params: EnableParams) -> Domain: + """ + Activate Forge on an existing domain. Idempotent on the API side: + enabling an already-migrated domain returns its current state. + + Args: + params (EnableParams): The Forge enable parameters + + Returns: + Domain: The full domain object + """ + path = f"/domains/{params['id']}/forge" + resp = request.Request[Domain]( + path=path, params=cls._body(params), verb="post" + ).perform_with_content() + return resp + + @classmethod + def update(cls, params: UpdateParams) -> Domain: + """ + Move the return path of a Forge domain and re-verify. After the domain + verifies, the API refuses this with a 422 `return_path_unavailable`. + + Args: + params (UpdateParams): The Forge update parameters + + Returns: + Domain: The full domain object + """ + path = f"/domains/{params['id']}/forge" + resp = request.Request[Domain]( + path=path, params=cls._body(params), verb="patch" + ).perform_with_content() + return resp + + @classmethod + def verify(cls, domain_id: str) -> ForgeVerifyResponse: + """ + Trigger asynchronous Forge verification for a domain. Poll + `Domains.get` for the domain's state. + + Args: + domain_id (str): The domain ID + + Returns: + ForgeVerifyResponse: The object and ID of the domain being verified + """ + path = f"/domains/{domain_id}/forge/verify" + resp = request.Request[ForgeVerifyResponse]( + path=path, params={}, verb="post" + ).perform_with_content() + return resp + + @classmethod + async def enable_async(cls, params: EnableParams) -> Domain: + """ + Activate Forge on an existing domain (async). Idempotent on the API + side: enabling an already-migrated domain returns its current state. + + Args: + params (EnableParams): The Forge enable parameters + + Returns: + Domain: The full domain object + """ + path = f"/domains/{params['id']}/forge" + resp = await AsyncRequest[Domain]( + path=path, params=cls._body(params), verb="post" + ).perform_with_content() + return resp + + @classmethod + async def update_async(cls, params: UpdateParams) -> Domain: + """ + Move the return path of a Forge domain and re-verify (async). After + the domain verifies, the API refuses this with a 422 + `return_path_unavailable`. + + Args: + params (UpdateParams): The Forge update parameters + + Returns: + Domain: The full domain object + """ + path = f"/domains/{params['id']}/forge" + resp = await AsyncRequest[Domain]( + path=path, params=cls._body(params), verb="patch" + ).perform_with_content() + return resp + + @classmethod + async def verify_async(cls, domain_id: str) -> ForgeVerifyResponse: + """ + Trigger asynchronous Forge verification for a domain (async). Poll + `Domains.get_async` for the domain's state. + + Args: + domain_id (str): The domain ID + + Returns: + ForgeVerifyResponse: The object and ID of the domain being verified + """ + path = f"/domains/{domain_id}/forge/verify" + resp = await AsyncRequest[ForgeVerifyResponse]( + path=path, params={}, verb="post" + ).perform_with_content() + return resp + + @staticmethod + def _body(params: Mapping[str, Any]) -> Dict[str, Any]: + """The id addresses the path, not the request body.""" + return {k: v for k, v in params.items() if k != "id"} diff --git a/resend/version.py b/resend/version.py index 2c8a584..5cd4c2a 100644 --- a/resend/version.py +++ b/resend/version.py @@ -1,4 +1,4 @@ -__version__ = "2.53.2" +__version__ = "2.54.0" def get_version() -> str: diff --git a/tests/domain_forge_async_test.py b/tests/domain_forge_async_test.py new file mode 100644 index 0000000..2a50a14 --- /dev/null +++ b/tests/domain_forge_async_test.py @@ -0,0 +1,88 @@ +import pytest + +import resend +from resend.exceptions import NoContentError +from tests.conftest import AsyncResendBaseTest +from tests.domain_forge_test import forge_domain_json + +# flake8: noqa + +pytestmark = pytest.mark.asyncio + + +class TestDomainForgeAsync(AsyncResendBaseTest): + async def test_domain_forge_enable_async(self) -> None: + self.set_mock_json(forge_domain_json()) + + params: resend.Domains.Forge.EnableParams = { + "id": "d91cd9bd-1176-453e-8fc1-35364d380206", + "return_path": "send", + } + domain = await resend.Domains.Forge.enable_async(params) + assert domain["id"] == "d91cd9bd-1176-453e-8fc1-35364d380206" + assert domain["name"] == "example.com" + + async def test_domain_forge_enable_async_without_return_path(self) -> None: + self.set_mock_json(forge_domain_json()) + + params: resend.Domains.Forge.EnableParams = { + "id": "d91cd9bd-1176-453e-8fc1-35364d380206", + } + domain = await resend.Domains.Forge.enable_async(params) + assert domain["id"] == "d91cd9bd-1176-453e-8fc1-35364d380206" + assert domain["status"] == "pending" + + async def test_should_enable_forge_async_raise_exception_when_no_content( + self, + ) -> None: + self.set_mock_json(None) + params: resend.Domains.Forge.EnableParams = { + "id": "d91cd9bd-1176-453e-8fc1-35364d380206", + } + with pytest.raises(NoContentError): + _ = await resend.Domains.Forge.enable_async(params) + + async def test_domain_forge_update_async(self) -> None: + self.set_mock_json(forge_domain_json()) + + params: resend.Domains.Forge.UpdateParams = { + "id": "d91cd9bd-1176-453e-8fc1-35364d380206", + "return_path": "outbound", + } + domain = await resend.Domains.Forge.update_async(params) + assert domain["id"] == "d91cd9bd-1176-453e-8fc1-35364d380206" + assert domain["name"] == "example.com" + + async def test_should_update_forge_async_raise_exception_when_no_content( + self, + ) -> None: + self.set_mock_json(None) + params: resend.Domains.Forge.UpdateParams = { + "id": "d91cd9bd-1176-453e-8fc1-35364d380206", + "return_path": "outbound", + } + with pytest.raises(NoContentError): + _ = await resend.Domains.Forge.update_async(params) + + async def test_domain_forge_verify_async(self) -> None: + self.set_mock_json( + { + "object": "domain", + "id": "d91cd9bd-1176-453e-8fc1-35364d380206", + } + ) + + verified = await resend.Domains.Forge.verify_async( + domain_id="d91cd9bd-1176-453e-8fc1-35364d380206" + ) + assert verified["object"] == "domain" + assert verified["id"] == "d91cd9bd-1176-453e-8fc1-35364d380206" + + async def test_should_verify_forge_async_raise_exception_when_no_content( + self, + ) -> None: + self.set_mock_json(None) + with pytest.raises(NoContentError): + _ = await resend.Domains.Forge.verify_async( + domain_id="d91cd9bd-1176-453e-8fc1-35364d380206" + ) diff --git a/tests/domain_forge_test.py b/tests/domain_forge_test.py new file mode 100644 index 0000000..05467e4 --- /dev/null +++ b/tests/domain_forge_test.py @@ -0,0 +1,174 @@ +from typing import Any, Dict +from unittest import TestCase +from unittest.mock import create_autospec + +import resend +from resend.exceptions import NoContentError +from resend.http_client import HTTPClient +from tests.conftest import ResendBaseTest + +# flake8: noqa + + +def forge_domain_json() -> Dict[str, Any]: + return { + "object": "domain", + "id": "d91cd9bd-1176-453e-8fc1-35364d380206", + "name": "example.com", + "status": "pending", + "created_at": "2026-11-11 10:00:00.000000+00", + "region": "us-east-1", + "records": [ + { + "record": "SPF", + "name": "send", + "value": "v=spf1 include:spf.resend.com ~all", + "type": "TXT", + "ttl": "Auto", + "status": "pending", + }, + { + "record": "DKIM", + "name": "resend._domainkey", + "value": "resend._domainkey.example.com.dkim.resend.com.", + "type": "CNAME", + "ttl": "Auto", + "status": "pending", + }, + ], + "open_tracking": False, + "click_tracking": False, + } + + +class TestDomainForge(ResendBaseTest): + def test_domain_forge_enable(self) -> None: + self.set_mock_json(forge_domain_json()) + + params: resend.Domains.Forge.EnableParams = { + "id": "d91cd9bd-1176-453e-8fc1-35364d380206", + "return_path": "send", + } + domain: resend.Domain = resend.Domains.Forge.enable(params) + assert domain["id"] == "d91cd9bd-1176-453e-8fc1-35364d380206" + assert domain["name"] == "example.com" + assert domain["status"] == "pending" + assert ( + self.mock.call_args.kwargs["url"] + == "https://api.resend.com/domains/d91cd9bd-1176-453e-8fc1-35364d380206/forge" + ) + + def test_domain_forge_enable_without_return_path(self) -> None: + self.set_mock_json(forge_domain_json()) + + params: resend.Domains.Forge.EnableParams = { + "id": "d91cd9bd-1176-453e-8fc1-35364d380206", + } + domain = resend.Domains.Forge.enable(params) + assert domain["id"] == "d91cd9bd-1176-453e-8fc1-35364d380206" + assert domain["status"] == "pending" + + def test_should_enable_forge_raise_exception_when_no_content(self) -> None: + self.set_mock_json(None) + params: resend.Domains.Forge.EnableParams = { + "id": "d91cd9bd-1176-453e-8fc1-35364d380206", + } + with self.assertRaises(NoContentError): + _ = resend.Domains.Forge.enable(params) + + def test_domain_forge_update(self) -> None: + self.set_mock_json(forge_domain_json()) + + params: resend.Domains.Forge.UpdateParams = { + "id": "d91cd9bd-1176-453e-8fc1-35364d380206", + "return_path": "outbound", + } + domain: resend.Domain = resend.Domains.Forge.update(params) + assert domain["id"] == "d91cd9bd-1176-453e-8fc1-35364d380206" + assert domain["name"] == "example.com" + assert ( + self.mock.call_args.kwargs["url"] + == "https://api.resend.com/domains/d91cd9bd-1176-453e-8fc1-35364d380206/forge" + ) + + def test_should_update_forge_raise_exception_when_no_content(self) -> None: + self.set_mock_json(None) + params: resend.Domains.Forge.UpdateParams = { + "id": "d91cd9bd-1176-453e-8fc1-35364d380206", + "return_path": "outbound", + } + with self.assertRaises(NoContentError): + _ = resend.Domains.Forge.update(params) + + def test_domain_forge_verify(self) -> None: + self.set_mock_json( + { + "object": "domain", + "id": "d91cd9bd-1176-453e-8fc1-35364d380206", + } + ) + + verified: resend.ForgeVerifyResponse = resend.Domains.Forge.verify( + domain_id="d91cd9bd-1176-453e-8fc1-35364d380206" + ) + assert verified["object"] == "domain" + assert verified["id"] == "d91cd9bd-1176-453e-8fc1-35364d380206" + assert ( + self.mock.call_args.kwargs["url"] + == "https://api.resend.com/domains/d91cd9bd-1176-453e-8fc1-35364d380206/forge/verify" + ) + + def test_should_verify_forge_raise_exception_when_no_content(self) -> None: + self.set_mock_json(None) + with self.assertRaises(NoContentError): + _ = resend.Domains.Forge.verify( + domain_id="d91cd9bd-1176-453e-8fc1-35364d380206" + ) + + +class TestDomainForgeRequestBody(TestCase): + def setUp(self) -> None: + resend.api_key = "re_123" + self.mock_client = create_autospec(HTTPClient, instance=True) + self.mock_client.request.return_value = ( + b'{"id": "d91cd9bd-1176-453e-8fc1-35364d380206"}', + 200, + {"Content-Type": "application/json"}, + ) + self.previous_http_client = resend.default_http_client + resend.default_http_client = self.mock_client + + def tearDown(self) -> None: + resend.default_http_client = self.previous_http_client + + def test_enable_sends_snake_case_return_path_and_no_id(self) -> None: + params: resend.Domains.Forge.EnableParams = { + "id": "d91cd9bd-1176-453e-8fc1-35364d380206", + "return_path": "send", + } + resend.Domains.Forge.enable(params) + + _, kwargs = self.mock_client.request.call_args + assert kwargs["method"] == "post" + assert kwargs["json"] == {"return_path": "send"} + + def test_enable_sends_empty_body_without_return_path(self) -> None: + params: resend.Domains.Forge.EnableParams = { + "id": "d91cd9bd-1176-453e-8fc1-35364d380206", + } + resend.Domains.Forge.enable(params) + + _, kwargs = self.mock_client.request.call_args + assert kwargs["method"] == "post" + assert kwargs["json"] == {} + + def test_update_sends_return_path_with_patch(self) -> None: + params: resend.Domains.Forge.UpdateParams = { + "id": "d91cd9bd-1176-453e-8fc1-35364d380206", + "return_path": "outbound", + } + resend.Domains.Forge.update(params) + + _, kwargs = self.mock_client.request.call_args + assert kwargs["method"] == "patch" + assert kwargs["json"] == {"return_path": "outbound"} From 242e609a0d636f7149fce8daa0520f5a88100698 Mon Sep 17 00:00:00 2001 From: Lucas Fernandes da Costa Date: Thu, 12 Nov 2026 13:29:41 -0300 Subject: [PATCH 2/2] feat(domains): fold forge verify and update into the classic endpoints Matches the reshaped API: Domains.Forge keeps only enable (return_path optional), Domains.verify gains an optional scope of ["forge"] that re-checks only the Forge record, and Domains.update gains forge_return_path to move the Forge return path without starting verification. Co-Authored-By: Claude Fable 5 --- examples/domain_forge.py | 25 +++--- resend/__init__.py | 3 +- resend/domains/_domains.py | 35 ++++++++- resend/domains/forge/__init__.py | 4 +- resend/domains/forge/_domain_forge.py | 109 +++----------------------- tests/domain_forge_async_test.py | 45 ----------- tests/domain_forge_test.py | 60 -------------- tests/domains_async_test.py | 12 +++ tests/domains_test.py | 64 +++++++++++++++ 9 files changed, 132 insertions(+), 225 deletions(-) diff --git a/examples/domain_forge.py b/examples/domain_forge.py index 9a33333..a2441ea 100644 --- a/examples/domain_forge.py +++ b/examples/domain_forge.py @@ -8,6 +8,7 @@ domain_id = "d91cd9bd-1176-453e-8fc1-35364d380206" # Enable: activate Forge on an existing domain. Returns the full domain. +# return_path is optional: when omitted, Resend picks a free DNS label. # Enabling an already-migrated domain is idempotent and returns its # current state. enable_params: resend.Domains.Forge.EnableParams = { @@ -18,18 +19,18 @@ print(f"Forge enabled on domain: {domain['id']}") print(f"Domain status: {domain['status']}") -# Update: move the return path and re-verify. After the domain verifies, -# the API refuses this with a 422 return_path_unavailable. -update_params: resend.Domains.Forge.UpdateParams = { +# Move the Forge return path with the classic domain update. This does not +# start verification: fetch the new record with Domains.get, add it to your +# DNS, then verify with scope=["forge"]. +update_params: resend.Domains.UpdateParams = { "id": domain_id, - "return_path": "outbound", + "forge_return_path": "outbound", } -domain = resend.Domains.Forge.update(update_params) -print(f"Domain status after return path move: {domain['status']}") +updated = resend.Domains.update(update_params) +print(f"Forge return path moved on domain: {updated['id']}") -# Verify: trigger asynchronous Forge verification, then poll Domains.get -# for the domain's state. -verified: resend.ForgeVerifyResponse = resend.Domains.Forge.verify( - domain_id=domain_id -) -print(f"Verification triggered for domain: {verified['id']}") +# Verify only the Forge record with the classic domain verify, scoped to +# Forge. Verification runs asynchronously; poll Domains.get for the state. +verify_params: resend.Domains.VerifyParams = {"scope": ["forge"]} +verified = resend.Domains.verify(domain_id=domain_id, params=verify_params) +print(f"Forge verification triggered for domain: {verified['id']}") diff --git a/resend/__init__.py b/resend/__init__.py index 8cb365e..9653ee8 100644 --- a/resend/__init__.py +++ b/resend/__init__.py @@ -36,7 +36,7 @@ from .domains._domains import Domains from .domains.claims._domain_claim import DomainClaim, DomainClaimRecord from .domains.claims._domain_claims import DomainClaims -from .domains.forge._domain_forge import DomainForge, ForgeVerifyResponse +from .domains.forge._domain_forge import DomainForge from .emails._attachment import Attachment, RemoteAttachment from .emails._attachments import Attachments as EmailAttachments from .emails._batch import Batch, BatchValidationError @@ -222,7 +222,6 @@ "Domain", "DomainClaim", "DomainClaimRecord", - "ForgeVerifyResponse", "ApiKey", "Log", "Email", diff --git a/resend/domains/_domains.py b/resend/domains/_domains.py index c8c1612..b3c34b0 100644 --- a/resend/domains/_domains.py +++ b/resend/domains/_domains.py @@ -18,6 +18,8 @@ TlsOptions = Literal["enforced", "opportunistic"] +VerifyDomainScope = Literal["forge"] + class Domains: @@ -149,6 +151,21 @@ class UpdateParams(TypedDict): """ The custom subdomain used for click and open tracking links (e.g., "links"). """ + forge_return_path: NotRequired[str] + """ + Move the domain's Forge return path to another host. Only possible + while the Forge record is still verifying. Moving the path does not + start verification: fetch the new record with `Domains.get`, add it to + your DNS, then call `Domains.verify` with `scope=["forge"]`. + """ + + class VerifyParams(TypedDict): + scope: NotRequired[List[VerifyDomainScope]] + """ + Limit the check to specific records. Pass `["forge"]` to verify only + the Forge record and leave the other records untouched. Requires Forge + to be enabled on the domain. When omitted, all records are checked. + """ class CreateParams(TypedDict): name: str @@ -276,20 +293,24 @@ def remove(cls, domain_id: str) -> Domain: return resp @classmethod - def verify(cls, domain_id: str) -> Domain: + def verify(cls, domain_id: str, params: Optional[VerifyParams] = None) -> Domain: """ Verify an existing domain. see more: https://resend.com/docs/api-reference/domains/verify-domain Args: domain_id (str): The domain ID + params (Optional[VerifyParams]): Optional verification parameters + - scope: Pass ["forge"] to re-check only the Forge record Returns: Domain: The verified domain object """ path = f"/domains/{domain_id}/verify" resp = request.Request[Domain]( - path=path, params={}, verb="post" + path=path, + params=cast(Dict[Any, Any], params) if params else {}, + verb="post", ).perform_with_content() return resp @@ -386,19 +407,25 @@ async def remove_async(cls, domain_id: str) -> Domain: return resp @classmethod - async def verify_async(cls, domain_id: str) -> Domain: + async def verify_async( + cls, domain_id: str, params: Optional[VerifyParams] = None + ) -> Domain: """ Verify an existing domain (async). see more: https://resend.com/docs/api-reference/domains/verify-domain Args: domain_id (str): The domain ID + params (Optional[VerifyParams]): Optional verification parameters + - scope: Pass ["forge"] to re-check only the Forge record Returns: Domain: The verified domain object """ path = f"/domains/{domain_id}/verify" resp = await AsyncRequest[Domain]( - path=path, params={}, verb="post" + path=path, + params=cast(Dict[Any, Any], params) if params else {}, + verb="post", ).perform_with_content() return resp diff --git a/resend/domains/forge/__init__.py b/resend/domains/forge/__init__.py index 1900038..20672a3 100644 --- a/resend/domains/forge/__init__.py +++ b/resend/domains/forge/__init__.py @@ -1,3 +1,3 @@ -from ._domain_forge import DomainForge, ForgeVerifyResponse +from ._domain_forge import DomainForge -__all__ = ["DomainForge", "ForgeVerifyResponse"] +__all__ = ["DomainForge"] diff --git a/resend/domains/forge/_domain_forge.py b/resend/domains/forge/_domain_forge.py index a2a64f0..33bfe6f 100644 --- a/resend/domains/forge/_domain_forge.py +++ b/resend/domains/forge/_domain_forge.py @@ -3,7 +3,6 @@ from typing_extensions import NotRequired, TypedDict from resend import request -from resend._base_response import BaseResponse from resend.domains._domain import Domain # Async imports (optional - only available with pip install resend[async]) @@ -13,21 +12,6 @@ pass -class ForgeVerifyResponse(BaseResponse): - """ - ForgeVerifyResponse is the slim acknowledgment returned when a Forge - verification is triggered. Verification runs asynchronously; poll - `Domains.get` for the domain's state. - - Attributes: - object (str): The object type, always "domain" - id (str): The domain ID - """ - - object: str - id: str - - class DomainForge: class EnableParams(TypedDict): id: str @@ -37,17 +21,7 @@ class EnableParams(TypedDict): return_path: NotRequired[str] """ The subdomain for the Return-Path address. When omitted, Resend picks - a suggested label based on the domain's current return path. - """ - - class UpdateParams(TypedDict): - id: str - """ - The domain ID. - """ - return_path: str - """ - The new subdomain for the Return-Path address. + a free DNS label for the domain. """ @classmethod @@ -56,6 +30,10 @@ def enable(cls, params: EnableParams) -> Domain: Activate Forge on an existing domain. Idempotent on the API side: enabling an already-migrated domain returns its current state. + To move the Forge return path afterwards, use `Domains.update` with + `forge_return_path`. To re-check only the Forge record, use + `Domains.verify` with `scope=["forge"]`. + Args: params (EnableParams): The Forge enable parameters @@ -68,48 +46,16 @@ def enable(cls, params: EnableParams) -> Domain: ).perform_with_content() return resp - @classmethod - def update(cls, params: UpdateParams) -> Domain: - """ - Move the return path of a Forge domain and re-verify. After the domain - verifies, the API refuses this with a 422 `return_path_unavailable`. - - Args: - params (UpdateParams): The Forge update parameters - - Returns: - Domain: The full domain object - """ - path = f"/domains/{params['id']}/forge" - resp = request.Request[Domain]( - path=path, params=cls._body(params), verb="patch" - ).perform_with_content() - return resp - - @classmethod - def verify(cls, domain_id: str) -> ForgeVerifyResponse: - """ - Trigger asynchronous Forge verification for a domain. Poll - `Domains.get` for the domain's state. - - Args: - domain_id (str): The domain ID - - Returns: - ForgeVerifyResponse: The object and ID of the domain being verified - """ - path = f"/domains/{domain_id}/forge/verify" - resp = request.Request[ForgeVerifyResponse]( - path=path, params={}, verb="post" - ).perform_with_content() - return resp - @classmethod async def enable_async(cls, params: EnableParams) -> Domain: """ Activate Forge on an existing domain (async). Idempotent on the API side: enabling an already-migrated domain returns its current state. + To move the Forge return path afterwards, use `Domains.update_async` + with `forge_return_path`. To re-check only the Forge record, use + `Domains.verify_async` with `scope=["forge"]`. + Args: params (EnableParams): The Forge enable parameters @@ -122,43 +68,6 @@ async def enable_async(cls, params: EnableParams) -> Domain: ).perform_with_content() return resp - @classmethod - async def update_async(cls, params: UpdateParams) -> Domain: - """ - Move the return path of a Forge domain and re-verify (async). After - the domain verifies, the API refuses this with a 422 - `return_path_unavailable`. - - Args: - params (UpdateParams): The Forge update parameters - - Returns: - Domain: The full domain object - """ - path = f"/domains/{params['id']}/forge" - resp = await AsyncRequest[Domain]( - path=path, params=cls._body(params), verb="patch" - ).perform_with_content() - return resp - - @classmethod - async def verify_async(cls, domain_id: str) -> ForgeVerifyResponse: - """ - Trigger asynchronous Forge verification for a domain (async). Poll - `Domains.get_async` for the domain's state. - - Args: - domain_id (str): The domain ID - - Returns: - ForgeVerifyResponse: The object and ID of the domain being verified - """ - path = f"/domains/{domain_id}/forge/verify" - resp = await AsyncRequest[ForgeVerifyResponse]( - path=path, params={}, verb="post" - ).perform_with_content() - return resp - @staticmethod def _body(params: Mapping[str, Any]) -> Dict[str, Any]: """The id addresses the path, not the request body.""" diff --git a/tests/domain_forge_async_test.py b/tests/domain_forge_async_test.py index 2a50a14..845b29d 100644 --- a/tests/domain_forge_async_test.py +++ b/tests/domain_forge_async_test.py @@ -41,48 +41,3 @@ async def test_should_enable_forge_async_raise_exception_when_no_content( } with pytest.raises(NoContentError): _ = await resend.Domains.Forge.enable_async(params) - - async def test_domain_forge_update_async(self) -> None: - self.set_mock_json(forge_domain_json()) - - params: resend.Domains.Forge.UpdateParams = { - "id": "d91cd9bd-1176-453e-8fc1-35364d380206", - "return_path": "outbound", - } - domain = await resend.Domains.Forge.update_async(params) - assert domain["id"] == "d91cd9bd-1176-453e-8fc1-35364d380206" - assert domain["name"] == "example.com" - - async def test_should_update_forge_async_raise_exception_when_no_content( - self, - ) -> None: - self.set_mock_json(None) - params: resend.Domains.Forge.UpdateParams = { - "id": "d91cd9bd-1176-453e-8fc1-35364d380206", - "return_path": "outbound", - } - with pytest.raises(NoContentError): - _ = await resend.Domains.Forge.update_async(params) - - async def test_domain_forge_verify_async(self) -> None: - self.set_mock_json( - { - "object": "domain", - "id": "d91cd9bd-1176-453e-8fc1-35364d380206", - } - ) - - verified = await resend.Domains.Forge.verify_async( - domain_id="d91cd9bd-1176-453e-8fc1-35364d380206" - ) - assert verified["object"] == "domain" - assert verified["id"] == "d91cd9bd-1176-453e-8fc1-35364d380206" - - async def test_should_verify_forge_async_raise_exception_when_no_content( - self, - ) -> None: - self.set_mock_json(None) - with pytest.raises(NoContentError): - _ = await resend.Domains.Forge.verify_async( - domain_id="d91cd9bd-1176-453e-8fc1-35364d380206" - ) diff --git a/tests/domain_forge_test.py b/tests/domain_forge_test.py index 05467e4..a8b68b2 100644 --- a/tests/domain_forge_test.py +++ b/tests/domain_forge_test.py @@ -76,55 +76,6 @@ def test_should_enable_forge_raise_exception_when_no_content(self) -> None: with self.assertRaises(NoContentError): _ = resend.Domains.Forge.enable(params) - def test_domain_forge_update(self) -> None: - self.set_mock_json(forge_domain_json()) - - params: resend.Domains.Forge.UpdateParams = { - "id": "d91cd9bd-1176-453e-8fc1-35364d380206", - "return_path": "outbound", - } - domain: resend.Domain = resend.Domains.Forge.update(params) - assert domain["id"] == "d91cd9bd-1176-453e-8fc1-35364d380206" - assert domain["name"] == "example.com" - assert ( - self.mock.call_args.kwargs["url"] - == "https://api.resend.com/domains/d91cd9bd-1176-453e-8fc1-35364d380206/forge" - ) - - def test_should_update_forge_raise_exception_when_no_content(self) -> None: - self.set_mock_json(None) - params: resend.Domains.Forge.UpdateParams = { - "id": "d91cd9bd-1176-453e-8fc1-35364d380206", - "return_path": "outbound", - } - with self.assertRaises(NoContentError): - _ = resend.Domains.Forge.update(params) - - def test_domain_forge_verify(self) -> None: - self.set_mock_json( - { - "object": "domain", - "id": "d91cd9bd-1176-453e-8fc1-35364d380206", - } - ) - - verified: resend.ForgeVerifyResponse = resend.Domains.Forge.verify( - domain_id="d91cd9bd-1176-453e-8fc1-35364d380206" - ) - assert verified["object"] == "domain" - assert verified["id"] == "d91cd9bd-1176-453e-8fc1-35364d380206" - assert ( - self.mock.call_args.kwargs["url"] - == "https://api.resend.com/domains/d91cd9bd-1176-453e-8fc1-35364d380206/forge/verify" - ) - - def test_should_verify_forge_raise_exception_when_no_content(self) -> None: - self.set_mock_json(None) - with self.assertRaises(NoContentError): - _ = resend.Domains.Forge.verify( - domain_id="d91cd9bd-1176-453e-8fc1-35364d380206" - ) - class TestDomainForgeRequestBody(TestCase): def setUp(self) -> None: @@ -161,14 +112,3 @@ def test_enable_sends_empty_body_without_return_path(self) -> None: _, kwargs = self.mock_client.request.call_args assert kwargs["method"] == "post" assert kwargs["json"] == {} - - def test_update_sends_return_path_with_patch(self) -> None: - params: resend.Domains.Forge.UpdateParams = { - "id": "d91cd9bd-1176-453e-8fc1-35364d380206", - "return_path": "outbound", - } - resend.Domains.Forge.update(params) - - _, kwargs = self.mock_client.request.call_args - assert kwargs["method"] == "patch" - assert kwargs["json"] == {"return_path": "outbound"} diff --git a/tests/domains_async_test.py b/tests/domains_async_test.py index c9f900b..1949cbc 100644 --- a/tests/domains_async_test.py +++ b/tests/domains_async_test.py @@ -370,3 +370,15 @@ async def test_should_verify_domains_async_raise_exception_when_no_content( _ = await resend.Domains.verify_async( domain_id="d91cd9bd-1176-453e-8fc1-35364d380206" ) + + async def test_domains_verify_async_with_forge_scope(self) -> None: + self.set_mock_json( + {"object": "domain", "id": "d91cd9bd-1176-453e-8fc1-35364d380206"} + ) + + params: resend.Domains.VerifyParams = {"scope": ["forge"]} + domain = await resend.Domains.verify_async( + domain_id="d91cd9bd-1176-453e-8fc1-35364d380206", + params=params, + ) + assert domain["id"] == "d91cd9bd-1176-453e-8fc1-35364d380206" diff --git a/tests/domains_test.py b/tests/domains_test.py index 53ae913..f8d0ae8 100644 --- a/tests/domains_test.py +++ b/tests/domains_test.py @@ -1,5 +1,9 @@ +from unittest import TestCase +from unittest.mock import create_autospec + import resend from resend.exceptions import NoContentError +from resend.http_client import HTTPClient from tests.conftest import ResendBaseTest # flake8: noqa @@ -180,6 +184,22 @@ def test_should_verify_domains_raise_exception_when_no_content(self) -> None: domain_id="d91cd9bd-1176-453e-8fc1-35364d380206", ) + def test_domains_verify_with_forge_scope(self) -> None: + self.set_mock_json( + {"object": "domain", "id": "d91cd9bd-1176-453e-8fc1-35364d380206"} + ) + + params: resend.Domains.VerifyParams = {"scope": ["forge"]} + domain = resend.Domains.verify( + domain_id="d91cd9bd-1176-453e-8fc1-35364d380206", + params=params, + ) + assert domain["id"] == "d91cd9bd-1176-453e-8fc1-35364d380206" + assert ( + self.mock.call_args.kwargs["url"] + == "https://api.resend.com/domains/d91cd9bd-1176-453e-8fc1-35364d380206/verify" + ) + def test_domains_create_with_tracking_subdomain(self) -> None: self.set_mock_json( { @@ -393,3 +413,47 @@ def test_domains_list_with_before_param(self) -> None: assert domains["has_more"] is False assert len(domains["data"]) == 1 assert domains["data"][0]["id"] == "domain-3" + + +class TestDomainsRequestBody(TestCase): + def setUp(self) -> None: + resend.api_key = "re_123" + self.mock_client = create_autospec(HTTPClient, instance=True) + self.mock_client.request.return_value = ( + b'{"id": "d91cd9bd-1176-453e-8fc1-35364d380206"}', + 200, + {"Content-Type": "application/json"}, + ) + self.previous_http_client = resend.default_http_client + resend.default_http_client = self.mock_client + + def tearDown(self) -> None: + resend.default_http_client = self.previous_http_client + + def test_verify_sends_forge_scope(self) -> None: + params: resend.Domains.VerifyParams = {"scope": ["forge"]} + resend.Domains.verify( + domain_id="d91cd9bd-1176-453e-8fc1-35364d380206", params=params + ) + + _, kwargs = self.mock_client.request.call_args + assert kwargs["method"] == "post" + assert kwargs["json"] == {"scope": ["forge"]} + + def test_verify_sends_empty_body_without_scope(self) -> None: + resend.Domains.verify(domain_id="d91cd9bd-1176-453e-8fc1-35364d380206") + + _, kwargs = self.mock_client.request.call_args + assert kwargs["method"] == "post" + assert kwargs["json"] == {} + + def test_update_sends_forge_return_path(self) -> None: + params: resend.Domains.UpdateParams = { + "id": "d91cd9bd-1176-453e-8fc1-35364d380206", + "forge_return_path": "outbound", + } + resend.Domains.update(params) + + _, kwargs = self.mock_client.request.call_args + assert kwargs["method"] == "patch" + assert kwargs["json"]["forge_return_path"] == "outbound"