From abab417e2874fbb4408fa3628bb8c0fd50531b5d Mon Sep 17 00:00:00 2001 From: bobbyonmagic <237182227+bobbyonmagic@users.noreply.github.com> Date: Thu, 22 Oct 2026 16:35:14 +0000 Subject: [PATCH] Sync with the API spec --- spec/openapi.json | 467 ++++++++++++++++++++++++++++++++++++- src/smtpfast/_resources.py | 40 +++- src/smtpfast/types.py | 28 +++ 3 files changed, 526 insertions(+), 9 deletions(-) diff --git a/spec/openapi.json b/spec/openapi.json index 651014c..18bd9e7 100644 --- a/spec/openapi.json +++ b/spec/openapi.json @@ -6162,7 +6162,7 @@ { "name": "q", "in": "query", - "description": "Search by email, first name, or last name", + "description": "Search by email, first name, or last name. Several email addresses separated by commas, spaces or new lines are matched exactly, up to 100.", "schema": { "type": "string" } @@ -8990,6 +8990,20 @@ "schema": { "type": "string" } + }, + { + "name": "archived", + "in": "query", + "description": "false leaves archived broadcasts out, true lists only archived ones, all lists both", + "schema": { + "type": "string", + "enum": [ + "false", + "true", + "all" + ], + "default": "false" + } } ], "responses": { @@ -10015,6 +10029,190 @@ "x-sdk-method": "cancel" } }, + "/v1/broadcasts/{id}/archive": { + "post": { + "operationId": "archiveBroadcast", + "summary": "Archive a broadcast", + "description": "Hide a sent, canceled or failed broadcast from the default list. The broadcast, its recipients and its stats stay available, and the list shows it with archived=true. Archiving an archived broadcast changes nothing.", + "tags": [ + "Broadcasts" + ], + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "description": "The broadcast ID", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Broadcast archived", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Broadcast" + } + } + } + }, + "400": { + "description": "Bad request - check your payload", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "401": { + "description": "Invalid or missing API key", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "403": { + "description": "Forbidden - domain not verified or insufficient permissions", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "404": { + "description": "Resource not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "429": { + "description": "Rate limit exceeded - retry after the period indicated in the Retry-After header", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "500": { + "description": "Internal server error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + }, + "x-sdk-group": [ + "broadcasts" + ], + "x-sdk-method": "archive" + } + }, + "/v1/broadcasts/{id}/unarchive": { + "post": { + "operationId": "unarchiveBroadcast", + "summary": "Unarchive a broadcast", + "description": "Return an archived broadcast to the default list. Unarchiving a broadcast that is not archived changes nothing.", + "tags": [ + "Broadcasts" + ], + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "description": "The broadcast ID", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Broadcast unarchived", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Broadcast" + } + } + } + }, + "401": { + "description": "Invalid or missing API key", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "403": { + "description": "Forbidden - domain not verified or insufficient permissions", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "404": { + "description": "Resource not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "429": { + "description": "Rate limit exceeded - retry after the period indicated in the Retry-After header", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "500": { + "description": "Internal server error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + }, + "x-sdk-group": [ + "broadcasts" + ], + "x-sdk-method": "unarchive" + } + }, "/v1/broadcasts/{id}/test": { "post": { "operationId": "sendBroadcastTest", @@ -14388,6 +14586,188 @@ "x-sdk-method": "delete" } }, + "/v1/link-domains/{id}/domain-connect": { + "get": { + "operationId": "getLinkDomainDomainConnect", + "summary": "Check one-click DNS setup for a link domain", + "description": "Whether the link domain's DNS provider can add its CNAME record through Domain Connect, the open standard behind one-click DNS setup. Not in Resend. Requires the domain:read scope; 30 checks per 10 minutes per team.", + "tags": [ + "Domains" + ], + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "description": "The link domain ID", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Whether one-click setup is available", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/LinkDomainConnectCheck" + } + } + } + }, + "401": { + "description": "Invalid or missing API key", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "403": { + "description": "Forbidden - domain not verified or insufficient permissions", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "404": { + "description": "Resource not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "429": { + "description": "Rate limit exceeded - retry after the period indicated in the Retry-After header", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "500": { + "description": "Internal server error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + }, + "x-sdk-group": [ + "linkDomains" + ], + "x-sdk-method": "getDomainConnect" + }, + "post": { + "operationId": "startLinkDomainDomainConnect", + "summary": "Set up a link domain's DNS in one click", + "description": "Returns a signed Domain Connect link. The domain owner opens it, approves the CNAME record at the DNS provider, and the link domain turns active once Cloudflare sees the record. 409 with the reason when the provider cannot apply it, or the link domain is not pending (refresh a failed one first). Not in Resend. Requires the domain:write scope; 30 per 10 minutes per team.", + "tags": [ + "Domains" + ], + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "description": "The link domain ID", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "The link to open", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/LinkDomainConnectLink" + } + } + } + }, + "401": { + "description": "Invalid or missing API key", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "403": { + "description": "Forbidden - domain not verified or insufficient permissions", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "404": { + "description": "Resource not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "409": { + "description": "Conflict - the resource already exists or changed meanwhile", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "429": { + "description": "Rate limit exceeded - retry after the period indicated in the Retry-After header", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "500": { + "description": "Internal server error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + }, + "x-sdk-group": [ + "linkDomains" + ], + "x-sdk-method": "startDomainConnect" + } + }, "/v1/link-domains/{id}/refresh": { "post": { "operationId": "refreshLinkDomain", @@ -16704,7 +17084,7 @@ }, "track_clicks": { "type": "boolean", - "description": "Not in Resend, which sets tracking per domain. Rewrite this email's links through the click tracker (true) or send them exactly as written (false). Left out, the account's tracking settings apply, including the setting for transactional email. Over SMTP, set the X-SMTPfast-Track-Clicks header to true or false instead; it is removed before sending. To keep one link out of tracking, such as a password reset link with a token, add data-smtpfast-track=\"off\" to its tag; the attribute is removed before sending. null is the same as leaving it out; any other value that is not true or false is refused with 400.", + "description": "Not in Resend, which sets tracking per domain. Rewrite this email's links through the click tracker (true) or send them exactly as written (false). Left out, the account's tracking settings apply, including the setting for transactional email, except that a contact-form relay (every recipient on the From domain or a parent or subdomain of it, Reply-To on an unrelated domain, not sent from an inbox) gets no click tracking. Over SMTP, set the X-SMTPfast-Track-Clicks header to true or false instead; it is removed before sending. To keep one link out of tracking, such as a password reset link with a token, add data-smtpfast-track=\"off\" to its tag; the attribute is removed before sending. null is the same as leaving it out; any other value that is not true or false is refused with 400.", "example": false }, "track_opens": { @@ -18341,18 +18721,24 @@ "filtered_total": { "type": "integer", "example": 1, - "description": "Broadcasts matching the status filter" + "description": "Broadcasts matching the status, search and archived filters" }, "total": { "type": "integer", "example": 1, - "description": "Every broadcast of the team" + "description": "Every broadcast of the team that is not archived" + }, + "archived_total": { + "type": "integer", + "example": 0, + "description": "Archived broadcasts of the team" }, "status_counts": { "type": "object", "additionalProperties": { "type": "integer" - } + }, + "description": "Broadcasts that are not archived, per status" } } }, @@ -18385,6 +18771,13 @@ "audience_meta": { "$ref": "#/components/schemas/AudienceMeta" }, + "segment_id": { + "type": [ + "string", + "null" + ], + "description": "The segment the broadcast goes to when audience is segment, otherwise null" + }, "from": { "type": [ "string", @@ -18470,6 +18863,14 @@ ], "format": "date-time" }, + "archived_at": { + "type": [ + "string", + "null" + ], + "format": "date-time", + "description": "When the broadcast was archived. Archived broadcasts are left out of the list unless archived=true or archived=all." + }, "paused_at": { "type": [ "string", @@ -20057,6 +20458,11 @@ "type": "string", "example": "yourapp.com" }, + "name": { + "type": "string", + "example": "yourapp.com", + "description": "Same as domain. Resend calls this field name." + }, "status": { "type": "string", "example": "verified", @@ -20114,6 +20520,11 @@ "type": "string", "example": "yourapp.com" }, + "name": { + "type": "string", + "example": "yourapp.com", + "description": "Same as domain. Resend calls this field name." + }, "status": { "type": "string", "enum": [ @@ -21729,6 +22140,52 @@ } } }, + "LinkDomainConnectCheck": { + "type": "object", + "required": [ + "available", + "provider", + "reason" + ], + "properties": { + "available": { + "type": "boolean", + "description": "Whether the DNS provider can add the CNAME record in one click through Domain Connect." + }, + "provider": { + "type": [ + "string", + "null" + ], + "example": "Cloudflare", + "description": "The DNS provider, when discovery found one." + }, + "reason": { + "type": [ + "string", + "null" + ], + "description": "Why one-click setup is not available, when it is not." + } + } + }, + "LinkDomainConnectLink": { + "type": "object", + "required": [ + "url", + "provider" + ], + "properties": { + "url": { + "type": "string", + "description": "The signed link to open. The DNS provider shows the CNAME record and asks the domain owner to approve it." + }, + "provider": { + "type": "string", + "example": "Cloudflare" + } + } + }, "LinkDomainDeleted": { "type": "object", "required": [ diff --git a/src/smtpfast/_resources.py b/src/smtpfast/_resources.py index 99a5543..df470ab 100644 --- a/src/smtpfast/_resources.py +++ b/src/smtpfast/_resources.py @@ -470,9 +470,9 @@ def __init__(self, core: Any) -> None: super().__init__(core) self.recipients = BroadcastsRecipientsResource(core) - def list(self, *, status: Literal["all", "draft", "scheduled", "queued", "sent", "paused", "canceled"] | None = None, q: str | None = None, options: RequestOptions | None = None) -> BroadcastList: + def list(self, *, status: Literal["all", "draft", "scheduled", "queued", "sent", "paused", "canceled"] | None = None, q: str | None = None, archived: Literal["false", "true", "all"] | None = None, options: RequestOptions | None = None) -> BroadcastList: """List broadcasts. `GET /v1/broadcasts`""" - return self._core.request("GET", "/v1/broadcasts", path_params=None, query={"status": status, "q": q}, body=None, options=options) # type: ignore[no-any-return] + return self._core.request("GET", "/v1/broadcasts", path_params=None, query={"status": status, "q": q, "archived": archived}, body=None, options=options) # type: ignore[no-any-return] def create(self, params: CreateBroadcastRequest, *, options: RequestOptions | None = None) -> Broadcast: """Create a broadcast draft. `POST /v1/broadcasts`""" @@ -510,6 +510,14 @@ def cancel(self, id: str, *, options: RequestOptions | None = None) -> Broadcast """Cancel a scheduled broadcast. `POST /v1/broadcasts/{id}/cancel`""" return self._core.request("POST", "/v1/broadcasts/{id}/cancel", path_params={"id": id}, query=None, body=None, options=options) # type: ignore[no-any-return] + def archive(self, id: str, *, options: RequestOptions | None = None) -> Broadcast: + """Archive a broadcast. `POST /v1/broadcasts/{id}/archive`""" + return self._core.request("POST", "/v1/broadcasts/{id}/archive", path_params={"id": id}, query=None, body=None, options=options) # type: ignore[no-any-return] + + def unarchive(self, id: str, *, options: RequestOptions | None = None) -> Broadcast: + """Unarchive a broadcast. `POST /v1/broadcasts/{id}/unarchive`""" + return self._core.request("POST", "/v1/broadcasts/{id}/unarchive", path_params={"id": id}, query=None, body=None, options=options) # type: ignore[no-any-return] + def send_test(self, id: str, params: SendBroadcastTestParams, *, options: RequestOptions | None = None) -> SendBroadcastTestResponse: """Send a test broadcast email. `POST /v1/broadcasts/{id}/test`""" return self._core.request("POST", "/v1/broadcasts/{id}/test", path_params={"id": id}, query=None, body=params, options=options) # type: ignore[no-any-return] @@ -779,6 +787,14 @@ def delete(self, id: str, *, options: RequestOptions | None = None) -> LinkDomai """Delete a link domain. `DELETE /v1/link-domains/{id}`""" return self._core.request("DELETE", "/v1/link-domains/{id}", path_params={"id": id}, query=None, body=None, options=options) # type: ignore[no-any-return] + def get_domain_connect(self, id: str, *, options: RequestOptions | None = None) -> LinkDomainConnectCheck: + """Check one-click DNS setup for a link domain. `GET /v1/link-domains/{id}/domain-connect`""" + return self._core.request("GET", "/v1/link-domains/{id}/domain-connect", path_params={"id": id}, query=None, body=None, options=options) # type: ignore[no-any-return] + + def start_domain_connect(self, id: str, *, options: RequestOptions | None = None) -> LinkDomainConnectLink: + """Set up a link domain's DNS in one click. `POST /v1/link-domains/{id}/domain-connect`""" + return self._core.request("POST", "/v1/link-domains/{id}/domain-connect", path_params={"id": id}, query=None, body=None, options=options) # type: ignore[no-any-return] + def refresh(self, id: str, *, options: RequestOptions | None = None) -> LinkDomain: """Refresh a link domain's status. `POST /v1/link-domains/{id}/refresh`""" return self._core.request("POST", "/v1/link-domains/{id}/refresh", path_params={"id": id}, query=None, body=None, options=options) # type: ignore[no-any-return] @@ -1277,9 +1293,9 @@ def __init__(self, core: Any) -> None: super().__init__(core) self.recipients = AsyncBroadcastsRecipientsResource(core) - async def list(self, *, status: Literal["all", "draft", "scheduled", "queued", "sent", "paused", "canceled"] | None = None, q: str | None = None, options: RequestOptions | None = None) -> BroadcastList: + async def list(self, *, status: Literal["all", "draft", "scheduled", "queued", "sent", "paused", "canceled"] | None = None, q: str | None = None, archived: Literal["false", "true", "all"] | None = None, options: RequestOptions | None = None) -> BroadcastList: """List broadcasts. `GET /v1/broadcasts`""" - return await self._core.request("GET", "/v1/broadcasts", path_params=None, query={"status": status, "q": q}, body=None, options=options) # type: ignore[no-any-return] + return await self._core.request("GET", "/v1/broadcasts", path_params=None, query={"status": status, "q": q, "archived": archived}, body=None, options=options) # type: ignore[no-any-return] async def create(self, params: CreateBroadcastRequest, *, options: RequestOptions | None = None) -> Broadcast: """Create a broadcast draft. `POST /v1/broadcasts`""" @@ -1317,6 +1333,14 @@ async def cancel(self, id: str, *, options: RequestOptions | None = None) -> Bro """Cancel a scheduled broadcast. `POST /v1/broadcasts/{id}/cancel`""" return await self._core.request("POST", "/v1/broadcasts/{id}/cancel", path_params={"id": id}, query=None, body=None, options=options) # type: ignore[no-any-return] + async def archive(self, id: str, *, options: RequestOptions | None = None) -> Broadcast: + """Archive a broadcast. `POST /v1/broadcasts/{id}/archive`""" + return await self._core.request("POST", "/v1/broadcasts/{id}/archive", path_params={"id": id}, query=None, body=None, options=options) # type: ignore[no-any-return] + + async def unarchive(self, id: str, *, options: RequestOptions | None = None) -> Broadcast: + """Unarchive a broadcast. `POST /v1/broadcasts/{id}/unarchive`""" + return await self._core.request("POST", "/v1/broadcasts/{id}/unarchive", path_params={"id": id}, query=None, body=None, options=options) # type: ignore[no-any-return] + async def send_test(self, id: str, params: SendBroadcastTestParams, *, options: RequestOptions | None = None) -> SendBroadcastTestResponse: """Send a test broadcast email. `POST /v1/broadcasts/{id}/test`""" return await self._core.request("POST", "/v1/broadcasts/{id}/test", path_params={"id": id}, query=None, body=params, options=options) # type: ignore[no-any-return] @@ -1586,6 +1610,14 @@ async def delete(self, id: str, *, options: RequestOptions | None = None) -> Lin """Delete a link domain. `DELETE /v1/link-domains/{id}`""" return await self._core.request("DELETE", "/v1/link-domains/{id}", path_params={"id": id}, query=None, body=None, options=options) # type: ignore[no-any-return] + async def get_domain_connect(self, id: str, *, options: RequestOptions | None = None) -> LinkDomainConnectCheck: + """Check one-click DNS setup for a link domain. `GET /v1/link-domains/{id}/domain-connect`""" + return await self._core.request("GET", "/v1/link-domains/{id}/domain-connect", path_params={"id": id}, query=None, body=None, options=options) # type: ignore[no-any-return] + + async def start_domain_connect(self, id: str, *, options: RequestOptions | None = None) -> LinkDomainConnectLink: + """Set up a link domain's DNS in one click. `POST /v1/link-domains/{id}/domain-connect`""" + return await self._core.request("POST", "/v1/link-domains/{id}/domain-connect", path_params={"id": id}, query=None, body=None, options=options) # type: ignore[no-any-return] + async def refresh(self, id: str, *, options: RequestOptions | None = None) -> LinkDomain: """Refresh a link domain's status. `POST /v1/link-domains/{id}/refresh`""" return await self._core.request("POST", "/v1/link-domains/{id}/refresh", path_params={"id": id}, query=None, body=None, options=options) # type: ignore[no-any-return] diff --git a/src/smtpfast/types.py b/src/smtpfast/types.py index f33053f..50efe7c 100644 --- a/src/smtpfast/types.py +++ b/src/smtpfast/types.py @@ -1025,6 +1025,7 @@ "name": str, "audience": NotRequired[str], "audience_meta": NotRequired[AudienceMeta], + "segment_id": NotRequired[Union[str, None]], "from": NotRequired[Union[str, None]], "reply_to": NotRequired[Union[str, None]], "subject": NotRequired[Union[str, None]], @@ -1037,6 +1038,7 @@ "queued_at": NotRequired[Union[str, None]], "sent_at": NotRequired[Union[str, None]], "canceled_at": NotRequired[Union[str, None]], + "archived_at": NotRequired[Union[str, None]], "paused_at": NotRequired[Union[str, None]], "pause_reason": NotRequired[Union[str, None]], "fanout_completed_at": NotRequired[Union[str, None]], @@ -1062,6 +1064,7 @@ "page_size": NotRequired[int], "filtered_total": NotRequired[int], "total": NotRequired[int], + "archived_total": NotRequired[int], "status_counts": NotRequired[dict[str, int]], }, ) @@ -1120,6 +1123,7 @@ "name": str, "audience": NotRequired[str], "audience_meta": NotRequired[AudienceMeta], + "segment_id": NotRequired[Union[str, None]], "from": NotRequired[Union[str, None]], "reply_to": NotRequired[Union[str, None]], "subject": NotRequired[Union[str, None]], @@ -1132,6 +1136,7 @@ "queued_at": NotRequired[Union[str, None]], "sent_at": NotRequired[Union[str, None]], "canceled_at": NotRequired[Union[str, None]], + "archived_at": NotRequired[Union[str, None]], "paused_at": NotRequired[Union[str, None]], "pause_reason": NotRequired[Union[str, None]], "fanout_completed_at": NotRequired[Union[str, None]], @@ -1157,6 +1162,7 @@ "name": str, "audience": NotRequired[str], "audience_meta": NotRequired[AudienceMeta], + "segment_id": NotRequired[Union[str, None]], "from": NotRequired[Union[str, None]], "reply_to": NotRequired[Union[str, None]], "subject": NotRequired[Union[str, None]], @@ -1169,6 +1175,7 @@ "queued_at": NotRequired[Union[str, None]], "sent_at": NotRequired[Union[str, None]], "canceled_at": NotRequired[Union[str, None]], + "archived_at": NotRequired[Union[str, None]], "paused_at": NotRequired[Union[str, None]], "pause_reason": NotRequired[Union[str, None]], "fanout_completed_at": NotRequired[Union[str, None]], @@ -1524,6 +1531,7 @@ { "id": str, "domain": str, + "name": NotRequired[str], "status": str, "dkim_status": str, "spf_status": str, @@ -1584,6 +1592,7 @@ { "id": str, "domain": str, + "name": NotRequired[str], "status": Literal["pending", "verifying", "verified", "failed"], "receiving": NotRequired[DomainReceiving], "verification_phase": NotRequired[Literal["verified", "dns_missing", "waiting_on_ses", "mail_from_pending", "failed"]], @@ -2069,6 +2078,25 @@ ) +LinkDomainConnectCheck = TypedDict( + "LinkDomainConnectCheck", + { + "available": bool, + "provider": Union[str, None], + "reason": Union[str, None], + }, +) + + +LinkDomainConnectLink = TypedDict( + "LinkDomainConnectLink", + { + "url": str, + "provider": str, + }, +) + + LinkDomainDeleted = TypedDict( "LinkDomainDeleted", {