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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 36 additions & 0 deletions examples/domain_forge.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
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"

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: This example changes a hardcoded live domain's Return-Path and triggers re-verification, with no cleanup. Running it can alter production sending config. Once the domain verifies, the update call raises the documented 422 return_path_unavailable, so the script cannot be rerun. Create a throwaway domain as examples/domains.py does, or read the ID from an environment variable.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. When an issue isn't valid or won't be fixed in this PR, reply in its thread with the reason and then resolve the thread. At examples/domain_forge.py, line 8:

<comment>This example changes a hardcoded live domain's Return-Path and triggers re-verification, with no cleanup. Running it can alter production sending config. Once the domain verifies, the `update` call raises the documented 422 `return_path_unavailable`, so the script cannot be rerun. Create a throwaway domain as `examples/domains.py` does, or read the ID from an environment variable.</comment>

<file context>
@@ -0,0 +1,35 @@
+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,
</file context>


# 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 = {
"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']}")

# 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,
"forge_return_path": "outbound",
}
updated = resend.Domains.update(update_params)
print(f"Forge return path moved on domain: {updated['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']}")
2 changes: 2 additions & 0 deletions resend/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
from .emails._attachment import Attachment, RemoteAttachment
from .emails._attachments import Attachments as EmailAttachments
from .emails._batch import Batch, BatchValidationError
Expand Down Expand Up @@ -167,6 +168,7 @@
"ApiKeys",
"Domains",
"DomainClaims",
"DomainForge",
"Batch",
"Audiences",
"Automations",
Expand Down
39 changes: 35 additions & 4 deletions resend/domains/_domains.py
Original file line number Diff line number Diff line change
Expand Up @@ -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])
Expand All @@ -17,12 +18,17 @@

TlsOptions = Literal["enforced", "opportunistic"]

VerifyDomainScope = Literal["forge"]

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: Custom agent: API Key Permission Check SDK Methods

Please confirm that production callers use full-access API keys for these new domain-management operations; sending-only keys cannot access non-email endpoints.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. When an issue isn't valid or won't be fixed in this PR, reply in its thread with the reason and then resolve the thread. At resend/domains/_domains.py, line 21:

<comment>Please confirm that production callers use full-access API keys for these new domain-management operations; sending-only keys cannot access non-email endpoints.</comment>

<file context>
@@ -18,6 +18,8 @@
 
 TlsOptions = Literal["enforced", "opportunistic"]
 
+VerifyDomainScope = Literal["forge"]
+
 
</file context>



class Domains:

class Claims(DomainClaims):
pass

class Forge(DomainForge):
pass

class ListParams(TypedDict):
limit: NotRequired[int]
"""
Expand Down Expand Up @@ -145,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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3: This docstring says the Forge return path moves "to another host", but the value is a subdomain label, as Domains.Forge.enable documents for return_path. Say "subdomain" so callers don't pass a full hostname.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. When an issue isn't valid or won't be fixed in this PR, reply in its thread with the reason and then resolve the thread. At resend/domains/_domains.py, line 156:

<comment>This docstring says the Forge return path moves "to another host", but the value is a subdomain label, as `Domains.Forge.enable` documents for `return_path`. Say "subdomain" so callers don't pass a full hostname.</comment>

<file context>
@@ -149,6 +151,21 @@ class UpdateParams(TypedDict):
         """
+        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
</file context>
Suggested change
Move the domain's Forge return path to another host. Only possible
Move the domain's Forge return path to another subdomain. 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
Expand Down Expand Up @@ -272,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

Expand Down Expand Up @@ -382,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
3 changes: 3 additions & 0 deletions resend/domains/forge/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
from ._domain_forge import DomainForge

__all__ = ["DomainForge"]
74 changes: 74 additions & 0 deletions resend/domains/forge/_domain_forge.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
from typing import Any, Dict, Mapping

from typing_extensions import NotRequired, TypedDict

from resend import request
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 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 free DNS label for the domain.
"""

@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.

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

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
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

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

@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"}
2 changes: 1 addition & 1 deletion resend/version.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
__version__ = "2.53.2"
__version__ = "2.54.0"


def get_version() -> str:
Expand Down
43 changes: 43 additions & 0 deletions tests/domain_forge_async_test.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
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)
114 changes: 114 additions & 0 deletions tests/domain_forge_test.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
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)


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"] == {}
Loading
Loading