From d0421e8164fdf590b680e50416927bcb6e9ce8ec Mon Sep 17 00:00:00 2001 From: bobbyonmagic <237182227+bobbyonmagic@users.noreply.github.com> Date: Sun, 11 Oct 2026 04:21:30 +0000 Subject: [PATCH] Sync with the API spec --- spec/openapi.json | 1429 ++++++++++++++++++++++++++++++++---- src/smtpfast/_resources.py | 36 +- src/smtpfast/types.py | 452 +++++++++++- 3 files changed, 1730 insertions(+), 187 deletions(-) diff --git a/spec/openapi.json b/spec/openapi.json index 9f3f855..585b1c3 100644 --- a/spec/openapi.json +++ b/spec/openapi.json @@ -368,11 +368,11 @@ }, "responses": { "200": { - "description": "Email queued successfully", + "description": "Email accepted. A repeat with the same Idempotency-Key and body returns the same id.", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/Email" + "$ref": "#/components/schemas/SendEmailResponse" } } } @@ -1086,6 +1086,16 @@ "application/json": { "schema": { "type": "object", + "required": [ + "object", + "start_date", + "end_date", + "timezone", + "metrics", + "dimensions", + "granularity", + "totals" + ], "properties": { "object": { "type": "string", @@ -1100,25 +1110,44 @@ "format": "date-time" }, "timezone": { - "type": "string" + "type": "string", + "example": "UTC" }, "metrics": { "type": "array", "items": { "type": "string" - } + }, + "example": [ + "sent", + "delivered", + "open_rate" + ] }, "dimensions": { "type": "array", "items": { - "type": "string" + "type": "string", + "enum": [ + "period", + "domain", + "email", + "broadcast" + ] } }, "granularity": { - "type": "string" + "type": "string", + "enum": [ + "hourly", + "daily", + "weekly", + "monthly" + ] }, "totals": { "type": "object", + "description": "One value per requested metric: counts are integers, rates percentages with one decimal.", "additionalProperties": { "type": "number" }, @@ -1130,10 +1159,56 @@ }, "data": { "type": "array", - "description": "One row per combination of the requested dimensions. Absent when no dimensions are requested.", + "description": "One row per combination of the requested dimensions, with the requested metrics. Absent when no dimensions are requested.", "items": { "type": "object", - "additionalProperties": true, + "properties": { + "period": { + "type": [ + "string", + "null" + ], + "description": "With the period dimension" + }, + "domain_id": { + "type": [ + "string", + "null" + ], + "description": "With the domain dimension" + }, + "domain_name": { + "type": [ + "string", + "null" + ], + "description": "With the domain dimension" + }, + "email_id": { + "type": [ + "string", + "null" + ], + "description": "With the email dimension" + }, + "broadcast_id": { + "type": [ + "string", + "null" + ], + "description": "With the broadcast dimension" + }, + "broadcast_name": { + "type": [ + "string", + "null" + ], + "description": "With the broadcast dimension" + } + }, + "additionalProperties": { + "type": "number" + }, "example": { "period": "2026-07-01", "domain_id": "dom_1", @@ -5961,7 +6036,7 @@ "post": { "operationId": "createContact", "summary": "Create a contact", - "description": "Create a global contact for the current account. Supports firstName/lastName and first_name/last_name aliases for Resend compatibility.", + "description": "Create one contact, or send { contacts: [...] } to create or update up to 500 at once. The single form returns 409 when the email already exists; the bulk form matches existing contacts by email and updates them. Supports firstName/lastName and first_name/last_name aliases for Resend compatibility.", "tags": [ "Contacts" ], @@ -5970,18 +6045,32 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/CreateContactRequest" + "oneOf": [ + { + "$ref": "#/components/schemas/CreateContactRequest" + }, + { + "$ref": "#/components/schemas/CreateContactBatchRequest" + } + ] } } } }, "responses": { "201": { - "description": "Contact created", + "description": "Contact created. The bulk form returns the created and updated contacts as a list.", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ContactCreated" + "oneOf": [ + { + "$ref": "#/components/schemas/ContactCreated" + }, + { + "$ref": "#/components/schemas/ContactBatchCreated" + } + ] } } } @@ -6119,6 +6208,16 @@ "maximum": 100, "default": 50 } + }, + { + "name": "page", + "in": "query", + "description": "Page number, starting at 1", + "schema": { + "type": "integer", + "minimum": 1, + "default": 1 + } } ], "responses": { @@ -6235,7 +6334,7 @@ }, "application/json": { "schema": { - "$ref": "#/components/schemas/ContactList" + "$ref": "#/components/schemas/ContactExport" } } } @@ -6790,7 +6889,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ContactList" + "$ref": "#/components/schemas/SegmentContactList" } } } @@ -8295,7 +8394,7 @@ "post": { "operationId": "addSuppressionsBatch", "summary": "Suppress addresses in bulk", - "description": "Resend-compatible: add 1 to 100 addresses as manual suppressions. An address already suppressed keeps its record. Requires the contact:write scope.", + "description": "Resend-compatible: add 1 to 100 addresses as manual suppressions of every send (scope all). An address already suppressed keeps its record, except that a marketing-only one becomes a manual suppression of every send. Requires the contact:write scope.", "tags": [ "Suppressions" ], @@ -8502,7 +8601,7 @@ "get": { "operationId": "listSuppressions", "summary": "List suppressions", - "description": "Return suppressed addresses for the current account. Suppressed recipients are blocked before sending.", + "description": "Return suppressed addresses for the current account. Suppressed recipients are blocked before sending. A suppression with scope marketing blocks marketing email only; scope all blocks every send.", "tags": [ "Suppressions" ], @@ -8603,7 +8702,7 @@ "post": { "operationId": "createSuppression", "summary": "Create a manual suppression", - "description": "Manually suppress an address so future sends to it are blocked.", + "description": "Manually suppress an address so future sends to it are blocked: every send by default, or marketing email only with scope marketing.", "tags": [ "Suppressions" ], @@ -9060,7 +9159,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/Broadcast" + "$ref": "#/components/schemas/BroadcastDetail" } } } @@ -9703,7 +9802,7 @@ "post": { "operationId": "sendBroadcast", "summary": "Send or schedule a broadcast", - "description": "Snapshot eligible subscribed contacts, create one queued email per recipient, and send now or schedule within 30 days.", + "description": "Snapshot eligible subscribed contacts, create one queued email per recipient, and send now or schedule within 30 days. With send_over_minutes the recipients go out evenly spread over that window instead of all at once; a canceled broadcast stops the sends that have not gone out yet.", "tags": [ "Broadcasts" ], @@ -9728,6 +9827,13 @@ "scheduled_at": { "type": "string", "format": "date-time" + }, + "send_over_minutes": { + "type": "integer", + "minimum": 0, + "maximum": 4320, + "description": "Not in Resend. Spread the send evenly over this many minutes from the send time (or scheduled_at): recipient i of N goes out at start + i * window / N. 0 sends to everyone at once. 15 to 4320 (72 hours). Useful on a new sending domain or during the first week's warm-up, where each hour of the window must fit the hourly cap; the daily cap still counts the whole send. Left out, the broadcast's saved send_over_minutes applies; given, it is saved on the broadcast. The window must end within 30 days.", + "example": 240 } } } @@ -9740,7 +9846,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/Broadcast" + "$ref": "#/components/schemas/BroadcastSendResult" } } } @@ -9954,7 +10060,27 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/Email" + "type": "object", + "required": [ + "object", + "id", + "status" + ], + "properties": { + "object": { + "type": "string", + "example": "email" + }, + "id": { + "type": "string", + "example": "email_abc123", + "description": "The test email, in GET /v1/emails/{id}" + }, + "status": { + "type": "string", + "example": "queued" + } + } } } } @@ -12019,10 +12145,19 @@ "application/json": { "schema": { "type": "object", + "required": [ + "success", + "message" + ], "properties": { "success": { "type": "boolean", "example": true + }, + "message": { + "type": "string", + "example": "API key revoked", + "description": "\"Connected app disconnected\" for a connected app's key" } } } @@ -15116,6 +15251,28 @@ "items": { "type": "string" } + }, + "read_at": { + "type": [ + "string", + "null" + ], + "format": "date-time", + "description": "When the message was marked read. Not in Resend." + }, + "inbox_id": { + "type": [ + "string", + "null" + ], + "description": "The inbox this copy was filed into, when its address has one. Not in Resend." + }, + "thread_id": { + "type": [ + "string", + "null" + ], + "description": "The inbox thread this copy was filed into. Not in Resend." } } }, @@ -16066,9 +16223,19 @@ "description": "Send later, up to 30 days ahead (ISO 8601)", "example": "2026-04-10T14:00:00Z" }, + "category": { + "type": "string", + "enum": [ + "transactional", + "marketing" + ], + "default": "marketing", + "description": "Defaults to marketing. Label receipts, password resets and other transactional mail transactional so a marketing opt-out does not block them; unlabelled mail is treated as marketing. Over SMTP, set the X-SMTPfast-Category header instead; it is removed before sending. An email whose html or text (for a template, the rendered body) contains an unsubscribe placeholder is always marketing. A marketing email carries the one-click List-Unsubscribe header and is not sent to anyone who unsubscribed. A transactional email has no List-Unsubscribe header and still goes to addresses whose suppression has scope marketing (a recipient's own unsubscribe, or a manual suppression created with scope marketing), but not to addresses with scope all: hard bounces, complaints, and manual suppressions, which block everything by default. Transactional means mail the recipient needs because of an action or account; labelling promotions transactional is not allowed. Any other value is refused with 400.", + "example": "transactional" + }, "html": { "type": "string", - "description": "HTML body of the email. Include `{{unsubscribe_url}}` (or the Resend-compatible `{{{RESEND_UNSUBSCRIBE_URL}}}`) anywhere in the body and SMTPfast substitutes the per-recipient unsubscribe link at send time. The same URL is set on the RFC 8058 `List-Unsubscribe` header automatically.", + "description": "HTML body of the email. Include `{{unsubscribe_url}}` (or the Resend-compatible `{{{RESEND_UNSUBSCRIBE_URL}}}`) anywhere in the body and SMTPfast substitutes the per-recipient unsubscribe link at send time. An email with the placeholder is always marketing, and a marketing email gets the same URL on the RFC 8058 `List-Unsubscribe` header.", "example": "

Hello!

Unsubscribe

" }, "text": { @@ -16078,16 +16245,48 @@ } } }, + "SendEmailResponse": { + "type": "object", + "required": [ + "id" + ], + "description": "The id of the accepted email, as Resend returns it. Fetch the email with GET /v1/emails/{id}.", + "properties": { + "id": { + "type": "string", + "example": "email_abc123" + } + } + }, "Email": { "type": "object", "required": [ "id", "from", "to", + "cc", + "bcc", "subject", - "status", + "html", + "text", + "created_at", + "last_event", + "body_cleared_at", "source", - "created_at" + "category", + "status", + "reply_to", + "tags", + "message_id", + "ses_message_id", + "sent_at", + "delivered_at", + "opened_at", + "clicked_at", + "bounced_at", + "unsubscribed_at", + "error_message", + "events" ], "properties": { "id": { @@ -16102,13 +16301,26 @@ "to": { "type": "array", "items": { - "type": "string", - "format": "email" + "type": "string" }, "example": [ "user@example.com" ] }, + "cc": { + "type": "array", + "items": { + "type": "string" + }, + "example": [] + }, + "bcc": { + "type": "array", + "items": { + "type": "string" + }, + "example": [] + }, "subject": { "type": "string", "example": "Welcome!" @@ -16117,13 +16329,47 @@ "type": [ "string", "null" - ] + ], + "description": "The stored body. For an API send the {{unsubscribe_url}} placeholder is filled in when the email goes out, so it appears here unreplaced; broadcast and form emails are stored with the real link. Null when there was no HTML body or the retention window removed it." }, "text": { "type": [ "string", "null" - ] + ], + "description": "The stored plain-text body, null when there was none or the retention window removed it." + }, + "created_at": { + "type": "string", + "format": "date-time", + "example": "2026-04-05T12:00:00Z" + }, + "last_event": { + "type": "string", + "example": "delivered", + "description": "The type of the newest event, or the status when the email has no events yet. Resend-compatible." + }, + "body_cleared_at": { + "type": [ + "string", + "null" + ], + "format": "date-time", + "description": "When the retention window removed html and text. Null while the body is kept, so null html and text with a timestamp here means the content is no longer stored." + }, + "source": { + "type": "string", + "example": "api", + "description": "api, smtp, dashboard, broadcast or broadcast_test" + }, + "category": { + "type": "string", + "enum": [ + "transactional", + "marketing" + ], + "example": "transactional", + "description": "Decides which suppressions apply and whether the List-Unsubscribe header went out. marketing unless the email was labelled transactional; broadcasts, signup form emails and emails sent before categories existed are marketing." }, "status": { "type": "string", @@ -16142,58 +16388,175 @@ ], "example": "queued" }, - "source": { - "type": "string", - "example": "api", - "description": "api, smtp, dashboard, broadcast or broadcast_test" - }, - "created_at": { - "type": "string", - "format": "date-time", - "example": "2026-04-05T12:00:00Z" - } - } - }, - "EmailSummary": { - "type": "object", - "required": [ - "id", - "from", - "to", - "subject", - "status", - "source", - "created_at", - "sent_at", - "delivered_at", - "opened_at", - "clicked_at", - "bounced_at", - "error_message" - ], - "properties": { - "id": { - "type": "string", - "example": "email_abc123" - }, - "from": { - "type": "string", - "example": "hello@yourapp.com" + "reply_to": { + "type": [ + "string", + "null" + ], + "description": "The Reply-To address the email was sent with, or null." }, - "to": { + "tags": { "type": "array", + "description": "The tags given at send time, as name/value pairs. Empty when none were given.", "items": { - "type": "string" + "type": "object", + "required": [ + "name", + "value" + ], + "properties": { + "name": { + "type": "string" + }, + "value": { + "type": "string" + } + } }, "example": [ - "user@example.com" + { + "name": "campaign", + "value": "welcome" + } ] }, - "subject": { - "type": "string", - "example": "Welcome!" + "message_id": { + "type": [ + "string", + "null" + ], + "description": "The RFC 5322 Message-ID on your sending domain, the one a recipient's server and bounce reports quote. Null until the email is sent." }, - "status": { + "ses_message_id": { + "type": [ + "string", + "null" + ], + "description": "The Amazon SES message id. Kept for older integrations; correlate with message_id." + }, + "sent_at": { + "type": [ + "string", + "null" + ], + "format": "date-time" + }, + "delivered_at": { + "type": [ + "string", + "null" + ], + "format": "date-time" + }, + "opened_at": { + "type": [ + "string", + "null" + ], + "format": "date-time" + }, + "clicked_at": { + "type": [ + "string", + "null" + ], + "format": "date-time" + }, + "bounced_at": { + "type": [ + "string", + "null" + ], + "format": "date-time" + }, + "unsubscribed_at": { + "type": [ + "string", + "null" + ], + "format": "date-time", + "description": "When the recipient unsubscribed through this email. status keeps its delivery value." + }, + "error_message": { + "type": [ + "string", + "null" + ] + }, + "events": { + "type": "array", + "description": "Every event of the email, oldest first.", + "items": { + "type": "object", + "required": [ + "type", + "metadata", + "timestamp" + ], + "properties": { + "type": { + "type": "string", + "example": "delivered", + "description": "queued, scheduled, sending, retrying, sent, delivered, delivery_delayed, bounced, complained, opened, clicked, failed, suppressed or unsubscribed" + }, + "metadata": { + "type": [ + "object", + "null" + ], + "additionalProperties": true, + "description": "Event details such as the bounce type or the clicked URL" + }, + "timestamp": { + "type": "string", + "format": "date-time" + } + } + } + } + } + }, + "EmailSummary": { + "type": "object", + "required": [ + "id", + "from", + "to", + "subject", + "status", + "source", + "category", + "created_at", + "sent_at", + "delivered_at", + "opened_at", + "clicked_at", + "bounced_at", + "error_message" + ], + "properties": { + "id": { + "type": "string", + "example": "email_abc123" + }, + "from": { + "type": "string", + "example": "hello@yourapp.com" + }, + "to": { + "type": "array", + "items": { + "type": "string" + }, + "example": [ + "user@example.com" + ] + }, + "subject": { + "type": "string", + "example": "Welcome!" + }, + "status": { "type": "string", "example": "delivered", "description": "queued, scheduled, sending, sent, delivered, opened, clicked, bounced, complained, failed or canceled" @@ -16203,6 +16566,14 @@ "example": "api", "description": "api, smtp, dashboard, broadcast or broadcast_test" }, + "category": { + "type": "string", + "enum": [ + "transactional", + "marketing" + ], + "example": "marketing" + }, "created_at": { "type": "string", "format": "date-time" @@ -16573,13 +16944,240 @@ "type": "string", "example": "clx_contact123" }, - "deleted": { - "type": "boolean", - "example": true + "deleted": { + "type": "boolean", + "example": true + } + } + }, + "ContactList": { + "type": "object", + "required": [ + "object", + "has_more", + "data" + ], + "properties": { + "object": { + "type": "string", + "example": "list" + }, + "has_more": { + "type": "boolean", + "example": false + }, + "data": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ContactListItem" + } + }, + "page": { + "type": "integer", + "example": 1 + }, + "page_size": { + "type": "integer", + "example": 50 + }, + "filtered_total": { + "type": "integer", + "example": 1240, + "description": "Contacts matching the filters" + }, + "total": { + "type": "integer", + "example": 1240, + "description": "Every contact of the team" + }, + "subscribed": { + "type": "integer", + "example": 1198 + }, + "unsubscribed": { + "type": "integer", + "example": 42 + }, + "disposable": { + "type": "integer", + "example": 3, + "description": "Subscribed contacts on throwaway-mailbox domains" + }, + "new_7d": { + "type": "integer", + "example": 18, + "description": "Subscribed contacts added in the last 7 days" + }, + "new_30d": { + "type": "integer", + "example": 77, + "description": "Subscribed contacts added in the last 30 days" + }, + "contact_limit": { + "type": "integer", + "example": 25000, + "description": "The plan's contact limit, with any override for the account" + }, + "contact_limit_base": { + "type": "integer", + "example": 25000, + "description": "The plan's contact limit without overrides" + }, + "tier": { + "type": "string", + "example": "pro" + } + } + }, + "ContactListItem": { + "description": "A contact as lists return it: the summary fields plus properties, segments and the suppression.", + "allOf": [ + { + "$ref": "#/components/schemas/ContactSummary" + }, + { + "type": "object", + "required": [ + "properties", + "segments", + "suppression" + ], + "properties": { + "properties": { + "type": "object", + "additionalProperties": true, + "example": { + "company": "Acme" + } + }, + "suppression": { + "anyOf": [ + { + "$ref": "#/components/schemas/ContactSuppression" + }, + { + "type": "null" + } + ] + } + } + } + ] + }, + "ContactExport": { + "type": "object", + "required": [ + "object", + "has_more", + "data", + "exported_at" + ], + "properties": { + "object": { + "type": "string", + "example": "list" + }, + "has_more": { + "type": "boolean", + "example": false, + "description": "True when more than 10,000 contacts matched and the export stopped there" + }, + "data": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ContactListItem" + } + }, + "exported_at": { + "type": "string", + "format": "date-time" + } + } + }, + "SegmentContactList": { + "type": "object", + "required": [ + "object", + "has_more", + "data", + "segment", + "page", + "page_size", + "total" + ], + "properties": { + "object": { + "type": "string", + "example": "list" + }, + "has_more": { + "type": "boolean", + "example": false + }, + "data": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ContactListItem" + } + }, + "segment": { + "type": "object", + "required": [ + "id", + "name" + ], + "properties": { + "id": { + "type": "string", + "example": "seg_product" + }, + "name": { + "type": "string", + "example": "Product updates" + } + } + }, + "page": { + "type": "integer", + "example": 1 + }, + "page_size": { + "type": "integer", + "example": 50 + }, + "total": { + "type": "integer", + "example": 128, + "description": "Contacts in the segment" + } + } + }, + "CreateContactBatchRequest": { + "type": "object", + "required": [ + "contacts" + ], + "description": "Create or update up to 500 contacts, matched by email. Fields sent replace the stored ones; fields left out stay. segment_ids only adds memberships.", + "properties": { + "contacts": { + "type": "array", + "minItems": 1, + "maxItems": 500, + "items": { + "$ref": "#/components/schemas/CreateContactRequest" + } + }, + "segment_ids": { + "type": "array", + "maxItems": 50, + "description": "Manual segments to add every contact to, for contacts that do not name their own", + "items": { + "type": "string" + } } } }, - "ContactList": { + "ContactBatchCreated": { "type": "object", "required": [ "object", @@ -16600,11 +17198,6 @@ "items": { "$ref": "#/components/schemas/ContactSummary" } - }, - "disposable": { - "type": "integer", - "example": 3, - "description": "Subscribed contacts on throwaway-mailbox domains" } } }, @@ -16657,12 +17250,36 @@ }, "suppressed": { "type": "boolean", - "example": false + "example": false, + "description": "The address is on the suppression list" }, "segments": { "type": "array", + "description": "The manual segments the contact is in. Lists and GET /v1/contacts/{id} return it.", "items": { - "$ref": "#/components/schemas/SegmentSummary" + "type": "object", + "required": [ + "id", + "name", + "color" + ], + "properties": { + "id": { + "type": "string", + "example": "seg_product" + }, + "name": { + "type": "string", + "example": "Product updates" + }, + "color": { + "type": [ + "string", + "null" + ], + "example": "#10b981" + } + } } } } @@ -16852,6 +17469,7 @@ "id", "reason", "source", + "scope", "created_at" ], "properties": { @@ -16867,6 +17485,15 @@ "type": "string", "example": "ses" }, + "scope": { + "type": "string", + "enum": [ + "all", + "marketing" + ], + "example": "all", + "description": "all: every send to the address is blocked. marketing: only marketing email is blocked, and email labelled transactional still goes out. A recipient's own unsubscribe is marketing. Hard bounces, complaints and unsubscribes recorded before marketing-only unsubscribes existed are all. A manual suppression is all unless it was created with scope marketing." + }, "created_at": { "type": "string", "format": "date-time" @@ -16891,6 +17518,15 @@ ], "default": "manual" }, + "scope": { + "type": "string", + "enum": [ + "all", + "marketing" + ], + "default": "all", + "description": "all blocks every send to the address. marketing blocks marketing email only, like a recipient's own unsubscribe. An address already on the list keeps its suppression, except that scope all turns a marketing-only one into a manual suppression of every send." + }, "diagnostic_code": { "type": "string", "description": "Optional internal note for manual suppressions", @@ -16919,6 +17555,36 @@ "items": { "$ref": "#/components/schemas/Suppression" } + }, + "page": { + "type": "integer", + "example": 1 + }, + "page_size": { + "type": "integer", + "example": 50 + }, + "filtered_total": { + "type": "integer", + "example": 412, + "description": "Suppressions matching q and reason" + }, + "total": { + "type": "integer", + "example": 412, + "description": "Every suppression of the team" + }, + "hard_bounces": { + "type": "integer", + "example": 388 + }, + "complaints": { + "type": "integer", + "example": 19 + }, + "manual": { + "type": "integer", + "example": 5 } } }, @@ -16929,6 +17595,7 @@ "email", "reason", "source", + "scope", "created_at" ], "properties": { @@ -16959,6 +17626,15 @@ "type": "string", "example": "ses" }, + "scope": { + "type": "string", + "enum": [ + "all", + "marketing" + ], + "example": "all", + "description": "all: every send to the address is blocked. marketing: only marketing email is blocked, and email labelled transactional still goes out. A recipient's own unsubscribe is marketing. Hard bounces, complaints and unsubscribes recorded before marketing-only unsubscribes existed are all. A manual suppression is all unless it was created with scope marketing." + }, "bounce_type": { "type": [ "string", @@ -17132,6 +17808,17 @@ "track_clicks": { "type": "boolean", "default": true + }, + "send_over_minutes": { + "type": [ + "integer", + "null" + ], + "minimum": 0, + "maximum": 4320, + "default": 0, + "description": "Not in Resend. Spread the send evenly over this many minutes from the send time (or scheduled_at): recipient i of N goes out at start + i * window / N. 0 sends to everyone at once. 15 to 4320 (72 hours). Useful on a new sending domain or during the first week's warm-up, where each hour of the window must fit the hourly cap; the daily cap still counts the whole send. null clears it.", + "example": 240 } } }, @@ -17164,6 +17851,24 @@ "type": "boolean", "example": false }, + "page": { + "type": "integer", + "example": 1 + }, + "page_size": { + "type": "integer", + "example": 50 + }, + "filtered_total": { + "type": "integer", + "example": 1, + "description": "Broadcasts matching the status filter" + }, + "total": { + "type": "integer", + "example": 1, + "description": "Every broadcast of the team" + }, "status_counts": { "type": "object", "additionalProperties": { @@ -17258,89 +17963,314 @@ ], "example": "draft" }, - "scheduled_at": { + "scheduled_at": { + "type": [ + "string", + "null" + ], + "format": "date-time" + }, + "queued_at": { + "type": [ + "string", + "null" + ], + "format": "date-time" + }, + "sent_at": { + "type": [ + "string", + "null" + ], + "format": "date-time" + }, + "canceled_at": { + "type": [ + "string", + "null" + ], + "format": "date-time" + }, + "paused_at": { + "type": [ + "string", + "null" + ], + "format": "date-time", + "description": "Set when the campaign was stopped mid-send by the deliverability guard" + }, + "pause_reason": { + "type": [ + "string", + "null" + ], + "description": "Why the campaign was paused, for example the complaint rate that tripped the guard" + }, + "fanout_completed_at": { + "type": [ + "string", + "null" + ], + "format": "date-time", + "description": "When every recipient of the send had been created" + }, + "expected_recipients": { + "type": [ + "integer", + "null" + ], + "description": "Eligible recipients counted when the send was accepted" + }, + "send_over_minutes": { + "type": "integer", + "example": 0, + "description": "Minutes the send is spread over from its start (scheduled_at, else queued_at); 0 means all at once" + }, + "track_opens": { + "type": "boolean", + "example": true + }, + "track_clicks": { + "type": "boolean", + "example": true + }, + "recipient_count": { + "type": "integer", + "example": 1250 + }, + "metrics": { + "$ref": "#/components/schemas/BroadcastMetrics" + }, + "created_at": { + "type": "string", + "format": "date-time" + }, + "updated_at": { + "type": "string", + "format": "date-time" + } + } + }, + "BroadcastDetail": { + "description": "A broadcast with its latest 250 recipients and the links clicked in it.", + "allOf": [ + { + "$ref": "#/components/schemas/Broadcast" + }, + { + "type": "object", + "required": [ + "recipients", + "links" + ], + "properties": { + "recipients": { + "type": "array", + "items": { + "$ref": "#/components/schemas/BroadcastRecipientDetail" + } + }, + "links": { + "type": "array", + "items": { + "type": "object", + "required": [ + "url", + "clicks" + ], + "properties": { + "url": { + "type": "string", + "example": "https://example.com/pricing" + }, + "clicks": { + "type": "integer", + "example": 42 + } + } + } + } + } + } + ] + }, + "BroadcastRecipientDetail": { + "type": "object", + "required": [ + "id", + "email", + "status", + "events" + ], + "properties": { + "id": { + "type": "string", + "example": "clx_recipient123" + }, + "email": { + "type": "string", + "example": "ada@example.com" + }, + "first_name": { + "type": [ + "string", + "null" + ] + }, + "last_name": { + "type": [ + "string", + "null" + ] + }, + "status": { + "type": "string", + "example": "delivered" + }, + "skip_reason": { + "type": [ + "string", + "null" + ], + "description": "Why the contact was left out: unsubscribed, suppressed or invalid" + }, + "email_id": { + "type": [ + "string", + "null" + ] + }, + "queued_at": { + "type": [ + "string", + "null" + ], + "format": "date-time" + }, + "sent_at": { + "type": [ + "string", + "null" + ], + "format": "date-time" + }, + "delivered_at": { "type": [ "string", "null" ], "format": "date-time" }, - "queued_at": { + "opened_at": { "type": [ "string", "null" ], "format": "date-time" }, - "sent_at": { + "clicked_at": { "type": [ "string", "null" ], "format": "date-time" }, - "canceled_at": { + "bounced_at": { "type": [ "string", "null" ], "format": "date-time" }, - "paused_at": { + "complained_at": { "type": [ "string", "null" ], - "format": "date-time", - "description": "Set when the campaign was stopped mid-send by the deliverability guard" + "format": "date-time" }, - "pause_reason": { + "unsubscribed_at": { "type": [ "string", "null" ], - "description": "Why the campaign was paused, for example the complaint rate that tripped the guard" + "format": "date-time" }, - "fanout_completed_at": { + "failed_at": { "type": [ "string", "null" ], - "format": "date-time", - "description": "When every recipient of the send had been created" + "format": "date-time" }, - "expected_recipients": { + "error_message": { "type": [ - "integer", + "string", "null" - ], - "description": "Eligible recipients counted when the send was accepted" - }, - "track_opens": { - "type": "boolean", - "example": true - }, - "track_clicks": { - "type": "boolean", - "example": true - }, - "recipient_count": { - "type": "integer", - "example": 1250 - }, - "metrics": { - "$ref": "#/components/schemas/BroadcastMetrics" - }, - "created_at": { - "type": "string", - "format": "date-time" + ] }, - "updated_at": { - "type": "string", - "format": "date-time" + "events": { + "type": "array", + "items": { + "type": "object", + "required": [ + "id", + "type", + "created_at" + ], + "properties": { + "id": { + "type": "string" + }, + "type": { + "type": "string", + "example": "delivered" + }, + "metadata": { + "type": "object", + "additionalProperties": true + }, + "created_at": { + "type": "string", + "format": "date-time" + } + } + } } } }, + "BroadcastSendResult": { + "allOf": [ + { + "$ref": "#/components/schemas/Broadcast" + }, + { + "type": "object", + "required": [ + "object", + "recipients", + "skipped", + "batch_id" + ], + "properties": { + "recipients": { + "type": "integer", + "example": 1240, + "description": "Contacts the send will reach" + }, + "skipped": { + "type": "integer", + "example": 18, + "description": "Contacts left out: unsubscribed, suppressed or invalid" + }, + "batch_id": { + "type": "string", + "example": "batch_abc123" + } + } + } + ] + }, "BroadcastMetrics": { "type": "object", "properties": { @@ -17360,6 +18290,11 @@ "type": "integer", "example": 0 }, + "handed_off": { + "type": "integer", + "example": 1238, + "description": "Recipients whose email the provider accepted, whatever happened next. Shows how far a send spread over send_over_minutes has got." + }, "sent": { "type": "integer", "example": 1238 @@ -18687,8 +19622,10 @@ "id", "domain", "status", - "dns_records" + "dns_records", + "created_at" ], + "description": "POST /v1/domains returns id, domain, status, dns_records and created_at. GET /v1/domains/{id} adds the verification detail, a live DNS check of each record and the receiving settings.", "properties": { "id": { "type": "string", @@ -18702,35 +19639,193 @@ "type": "string", "enum": [ "pending", + "verifying", "verified", "failed" ], - "example": "pending" + "example": "pending", + "description": "GET /v1/domains/{id} answers pending for a verified domain whose required records are no longer live." }, - "receivingEnabled": { - "type": "boolean", - "description": "Whether inbound email is turned on for the domain (list responses)." + "receiving": { + "type": "object", + "description": "Inbound email for the domain", + "properties": { + "enabled": { + "type": "boolean" + }, + "status": { + "type": "string", + "enum": [ + "disabled", + "pending", + "active", + "failed" + ] + }, + "available": { + "type": "boolean", + "description": "The team's plan includes inbound email" + }, + "eligible": { + "type": "boolean", + "description": "The domain can turn receiving on" + }, + "mx_record": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "type": { + "type": "string", + "example": "MX" + }, + "priority": { + "type": "integer", + "example": 10 + }, + "value": { + "type": "string" + } + } + }, + "checked_at": { + "type": [ + "string", + "null" + ], + "format": "date-time" + } + } }, - "receivingStatus": { + "verification_phase": { "type": "string", "enum": [ - "disabled", - "pending", - "active", + "verified", + "dns_missing", + "waiting_on_ses", + "mail_from_pending", "failed" + ] + }, + "verification_message": { + "type": "string" + }, + "next_auto_check_at": { + "type": [ + "string", + "null" + ], + "format": "date-time", + "description": "Null once the domain is verified" + }, + "dkim_status": { + "type": "string", + "example": "verified" + }, + "spf_status": { + "type": "string", + "example": "verified" + }, + "dmarc_status": { + "type": "string", + "example": "pending" + }, + "mail_from_status": { + "type": "string", + "example": "verified" + }, + "mail_from_domain": { + "type": [ + "string", + "null" + ], + "example": "bounce.yourapp.com" + }, + "verified_at": { + "type": [ + "string", + "null" ], - "description": "Inbound status (list responses)." + "format": "date-time" + }, + "region": { + "type": "string", + "example": "us-east-1" + }, + "provider": { + "type": "object", + "description": "The DNS provider found from the NS records", + "properties": { + "id": { + "type": "string", + "example": "cloudflare" + }, + "name": { + "type": "string", + "example": "Cloudflare" + }, + "badge": { + "type": "string" + }, + "zone": { + "type": "string" + } + } + }, + "events": { + "type": "array", + "description": "The verification steps and when each was done", + "items": { + "type": "object", + "properties": { + "key": { + "type": "string", + "example": "dns_live" + }, + "label": { + "type": "string", + "example": "DNS records live" + }, + "at": { + "type": [ + "string", + "null" + ], + "format": "date-time" + }, + "done": { + "type": "boolean" + } + } + } }, "dns_records": { "type": "array", "items": { "$ref": "#/components/schemas/DnsRecord" } + }, + "created_at": { + "type": "string", + "format": "date-time" + }, + "last_checked_at": { + "type": [ + "string", + "null" + ], + "format": "date-time" } } }, "DnsRecord": { "type": "object", + "required": [ + "type", + "name", + "value" + ], "properties": { "type": { "type": "string", @@ -18743,11 +19838,79 @@ }, "name": { "type": "string", - "example": "smtp1._domainkey.yourapp.com" + "example": "abc123._domainkey.yourapp.com" }, "value": { "type": "string", - "example": "smtp1.dkim.smtpfa.st" + "example": "abc123.dkim.amazonses.com" + }, + "ttl": { + "type": "string", + "example": "Auto" + }, + "priority": { + "type": [ + "integer", + "null" + ], + "description": "Set for MX records" + }, + "status": { + "type": "string", + "example": "pending", + "description": "The verification status that covers this record" + }, + "purpose": { + "type": "string", + "example": "DKIM authentication", + "description": "DKIM authentication, SPF authorization, DMARC policy, MAIL FROM bounce delivery, MAIL FROM SPF or Inbound mail" + }, + "category": { + "type": "string", + "enum": [ + "verification", + "sending", + "receiving" + ] + }, + "actual_value": { + "type": [ + "string", + "null" + ], + "description": "GET /v1/domains/{id} only: what DNS answers now, several values joined with \" | \"" + }, + "live_check": { + "type": [ + "string", + "null" + ], + "enum": [ + "match", + "mismatch", + "missing", + "needs_include", + "multiple_spf_records", + "invalid_spf", + null + ], + "description": "GET /v1/domains/{id} only: how the live value compares with the expected one. Null for the inbound MX record." + }, + "actualValue": { + "type": [ + "string", + "null" + ], + "deprecated": true, + "description": "Same as actual_value, for older clients" + }, + "liveCheck": { + "type": [ + "string", + "null" + ], + "deprecated": true, + "description": "Same as live_check, for older clients" } } }, diff --git a/src/smtpfast/_resources.py b/src/smtpfast/_resources.py index c7b1826..34dbfae 100644 --- a/src/smtpfast/_resources.py +++ b/src/smtpfast/_resources.py @@ -83,7 +83,7 @@ def list_all(self, *, limit: int | None = None, status: str | None = None, to: s """Every item of list, fetching the following pages as you iterate.""" return self._core.paginate("GET", "/v1/emails", path_params=None, query={"limit": limit, "status": status, "to": to, "after": after}, body=None, options=options) - def send(self, params: SendEmailRequest, *, idempotency_key: str | None = None, options: RequestOptions | None = None) -> Email: + def send(self, params: SendEmailRequest, *, idempotency_key: str | None = None, options: RequestOptions | None = None) -> SendEmailResponse: """Send an email. `POST /v1/emails`""" return self._core.request("POST", "/v1/emails", path_params=None, query=None, body=params, idempotent=True, idempotency_key=idempotency_key, options=options) # type: ignore[no-any-return] @@ -305,11 +305,11 @@ def __init__(self, core: Any) -> None: super().__init__(core) self.segments = ContactsSegmentsResource(core) - def list(self, *, q: str | None = None, status: Literal["all", "subscribed", "unsubscribed"] | None = None, disposable: bool | None = None, segment_id: str | None = None, limit: int | None = None, options: RequestOptions | None = None) -> ContactList: + def list(self, *, q: str | None = None, status: Literal["all", "subscribed", "unsubscribed"] | None = None, disposable: bool | None = None, segment_id: str | None = None, limit: int | None = None, page: int | None = None, options: RequestOptions | None = None) -> ContactList: """List contacts. `GET /v1/contacts`""" - return self._core.request("GET", "/v1/contacts", path_params=None, query={"q": q, "status": status, "disposable": disposable, "segment_id": segment_id, "limit": limit}, body=None, options=options) # type: ignore[no-any-return] + return self._core.request("GET", "/v1/contacts", path_params=None, query={"q": q, "status": status, "disposable": disposable, "segment_id": segment_id, "limit": limit, "page": page}, body=None, options=options) # type: ignore[no-any-return] - def create(self, params: CreateContactRequest, *, options: RequestOptions | None = None) -> ContactCreated: + def create(self, params: CreateContactParams, *, options: RequestOptions | None = None) -> CreateContactResponse: """Create a contact. `POST /v1/contacts`""" return self._core.request("POST", "/v1/contacts", path_params=None, query=None, body=params, options=options) # type: ignore[no-any-return] @@ -317,7 +317,7 @@ def delete_many(self, params: DeleteContactsParams, *, options: RequestOptions | """Delete contacts in bulk. `DELETE /v1/contacts`""" return self._core.request("DELETE", "/v1/contacts", path_params=None, query=None, body=params, options=options) # type: ignore[no-any-return] - def export(self, *, format: Literal["csv", "json"] | None = None, status: Literal["all", "subscribed", "unsubscribed"] | None = None, disposable: bool | None = None, options: RequestOptions | None = None) -> ContactList: + def export(self, *, format: Literal["csv", "json"] | None = None, status: Literal["all", "subscribed", "unsubscribed"] | None = None, disposable: bool | None = None, options: RequestOptions | None = None) -> ContactExport: """Export contacts. `GET /v1/contacts/export`""" return self._core.request("GET", "/v1/contacts/export", path_params=None, query={"format": format if format is not None else "json", "status": status, "disposable": disposable}, body=None, options=options) # type: ignore[no-any-return] @@ -337,7 +337,7 @@ def delete(self, id: str, *, options: RequestOptions | None = None) -> ContactDe class SegmentsContactsResource(_SyncResource): """`client.segments.contacts`""" - def list(self, id: str, *, limit: int | None = None, page: int | None = None, options: RequestOptions | None = None) -> ContactList: + def list(self, id: str, *, limit: int | None = None, page: int | None = None, options: RequestOptions | None = None) -> SegmentContactList: """List contacts in a segment. `GET /v1/segments/{id}/contacts`""" return self._core.request("GET", "/v1/segments/{id}/contacts", path_params={"id": id}, query={"limit": limit, "page": page}, body=None, options=options) # type: ignore[no-any-return] @@ -478,7 +478,7 @@ def create(self, params: CreateBroadcastRequest, *, options: RequestOptions | No """Create a broadcast draft. `POST /v1/broadcasts`""" return self._core.request("POST", "/v1/broadcasts", path_params=None, query=None, body=params, options=options) # type: ignore[no-any-return] - def get(self, id: str, *, options: RequestOptions | None = None) -> Broadcast: + def get(self, id: str, *, options: RequestOptions | None = None) -> BroadcastDetail: """Get broadcast details. `GET /v1/broadcasts/{id}`""" return self._core.request("GET", "/v1/broadcasts/{id}", path_params={"id": id}, query=None, body=None, options=options) # type: ignore[no-any-return] @@ -502,7 +502,7 @@ def duplicate(self, id: str, *, options: RequestOptions | None = None) -> Duplic """Duplicate broadcast. `POST /v1/broadcasts/{id}/duplicate`""" return self._core.request("POST", "/v1/broadcasts/{id}/duplicate", path_params={"id": id}, query=None, body=None, options=options) # type: ignore[no-any-return] - def send(self, id: str, params: SendBroadcastParams | None = None, *, options: RequestOptions | None = None) -> Broadcast: + def send(self, id: str, params: SendBroadcastParams | None = None, *, options: RequestOptions | None = None) -> BroadcastSendResult: """Send or schedule a broadcast. `POST /v1/broadcasts/{id}/send`""" return self._core.request("POST", "/v1/broadcasts/{id}/send", path_params={"id": id}, query=None, body=params, options=options) # type: ignore[no-any-return] @@ -510,7 +510,7 @@ 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 send_test(self, id: str, params: SendBroadcastTestParams, *, options: RequestOptions | None = None) -> Email: + 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] @@ -865,7 +865,7 @@ def list_all(self, *, limit: int | None = None, status: str | None = None, to: s """Every item of list, fetching the following pages as you iterate.""" return self._core.paginate("GET", "/v1/emails", path_params=None, query={"limit": limit, "status": status, "to": to, "after": after}, body=None, options=options) - async def send(self, params: SendEmailRequest, *, idempotency_key: str | None = None, options: RequestOptions | None = None) -> Email: + async def send(self, params: SendEmailRequest, *, idempotency_key: str | None = None, options: RequestOptions | None = None) -> SendEmailResponse: """Send an email. `POST /v1/emails`""" return await self._core.request("POST", "/v1/emails", path_params=None, query=None, body=params, idempotent=True, idempotency_key=idempotency_key, options=options) # type: ignore[no-any-return] @@ -1087,11 +1087,11 @@ def __init__(self, core: Any) -> None: super().__init__(core) self.segments = AsyncContactsSegmentsResource(core) - async def list(self, *, q: str | None = None, status: Literal["all", "subscribed", "unsubscribed"] | None = None, disposable: bool | None = None, segment_id: str | None = None, limit: int | None = None, options: RequestOptions | None = None) -> ContactList: + async def list(self, *, q: str | None = None, status: Literal["all", "subscribed", "unsubscribed"] | None = None, disposable: bool | None = None, segment_id: str | None = None, limit: int | None = None, page: int | None = None, options: RequestOptions | None = None) -> ContactList: """List contacts. `GET /v1/contacts`""" - return await self._core.request("GET", "/v1/contacts", path_params=None, query={"q": q, "status": status, "disposable": disposable, "segment_id": segment_id, "limit": limit}, body=None, options=options) # type: ignore[no-any-return] + return await self._core.request("GET", "/v1/contacts", path_params=None, query={"q": q, "status": status, "disposable": disposable, "segment_id": segment_id, "limit": limit, "page": page}, body=None, options=options) # type: ignore[no-any-return] - async def create(self, params: CreateContactRequest, *, options: RequestOptions | None = None) -> ContactCreated: + async def create(self, params: CreateContactParams, *, options: RequestOptions | None = None) -> CreateContactResponse: """Create a contact. `POST /v1/contacts`""" return await self._core.request("POST", "/v1/contacts", path_params=None, query=None, body=params, options=options) # type: ignore[no-any-return] @@ -1099,7 +1099,7 @@ async def delete_many(self, params: DeleteContactsParams, *, options: RequestOpt """Delete contacts in bulk. `DELETE /v1/contacts`""" return await self._core.request("DELETE", "/v1/contacts", path_params=None, query=None, body=params, options=options) # type: ignore[no-any-return] - async def export(self, *, format: Literal["csv", "json"] | None = None, status: Literal["all", "subscribed", "unsubscribed"] | None = None, disposable: bool | None = None, options: RequestOptions | None = None) -> ContactList: + async def export(self, *, format: Literal["csv", "json"] | None = None, status: Literal["all", "subscribed", "unsubscribed"] | None = None, disposable: bool | None = None, options: RequestOptions | None = None) -> ContactExport: """Export contacts. `GET /v1/contacts/export`""" return await self._core.request("GET", "/v1/contacts/export", path_params=None, query={"format": format if format is not None else "json", "status": status, "disposable": disposable}, body=None, options=options) # type: ignore[no-any-return] @@ -1119,7 +1119,7 @@ async def delete(self, id: str, *, options: RequestOptions | None = None) -> Con class AsyncSegmentsContactsResource(_AsyncResource): """`client.segments.contacts`""" - async def list(self, id: str, *, limit: int | None = None, page: int | None = None, options: RequestOptions | None = None) -> ContactList: + async def list(self, id: str, *, limit: int | None = None, page: int | None = None, options: RequestOptions | None = None) -> SegmentContactList: """List contacts in a segment. `GET /v1/segments/{id}/contacts`""" return await self._core.request("GET", "/v1/segments/{id}/contacts", path_params={"id": id}, query={"limit": limit, "page": page}, body=None, options=options) # type: ignore[no-any-return] @@ -1260,7 +1260,7 @@ async def create(self, params: CreateBroadcastRequest, *, options: RequestOption """Create a broadcast draft. `POST /v1/broadcasts`""" return await self._core.request("POST", "/v1/broadcasts", path_params=None, query=None, body=params, options=options) # type: ignore[no-any-return] - async def get(self, id: str, *, options: RequestOptions | None = None) -> Broadcast: + async def get(self, id: str, *, options: RequestOptions | None = None) -> BroadcastDetail: """Get broadcast details. `GET /v1/broadcasts/{id}`""" return await self._core.request("GET", "/v1/broadcasts/{id}", path_params={"id": id}, query=None, body=None, options=options) # type: ignore[no-any-return] @@ -1284,7 +1284,7 @@ async def duplicate(self, id: str, *, options: RequestOptions | None = None) -> """Duplicate broadcast. `POST /v1/broadcasts/{id}/duplicate`""" return await self._core.request("POST", "/v1/broadcasts/{id}/duplicate", path_params={"id": id}, query=None, body=None, options=options) # type: ignore[no-any-return] - async def send(self, id: str, params: SendBroadcastParams | None = None, *, options: RequestOptions | None = None) -> Broadcast: + async def send(self, id: str, params: SendBroadcastParams | None = None, *, options: RequestOptions | None = None) -> BroadcastSendResult: """Send or schedule a broadcast. `POST /v1/broadcasts/{id}/send`""" return await self._core.request("POST", "/v1/broadcasts/{id}/send", path_params={"id": id}, query=None, body=params, options=options) # type: ignore[no-any-return] @@ -1292,7 +1292,7 @@ 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 send_test(self, id: str, params: SendBroadcastTestParams, *, options: RequestOptions | None = None) -> Email: + 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] diff --git a/src/smtpfast/types.py b/src/smtpfast/types.py index c175060..daa444d 100644 --- a/src/smtpfast/types.py +++ b/src/smtpfast/types.py @@ -84,6 +84,9 @@ "verdicts": NotRequired[ReceivedEmailSummaryVerdicts], "in_reply_to": NotRequired[Union[str, None]], "references": NotRequired[list[str]], + "read_at": NotRequired[Union[str, None]], + "inbox_id": NotRequired[Union[str, None]], + "thread_id": NotRequired[Union[str, None]], }, ) @@ -372,6 +375,9 @@ "verdicts": NotRequired[ReceivedEmailVerdicts], "in_reply_to": NotRequired[Union[str, None]], "references": NotRequired[list[str]], + "read_at": NotRequired[Union[str, None]], + "inbox_id": NotRequired[Union[str, None]], + "thread_id": NotRequired[Union[str, None]], "html": NotRequired[Union[str, None]], "text": NotRequired[Union[str, None]], "headers": NotRequired[dict[str, str]], @@ -457,24 +463,70 @@ "tags": NotRequired[list[SendEmailRequestTagsItem]], "headers": NotRequired[dict[str, str]], "scheduled_at": NotRequired[str], + "category": NotRequired[Literal["transactional", "marketing"]], "html": NotRequired[str], "text": NotRequired[str], }, ) +# The id of the accepted email, as Resend returns it. Fetch the email with GET /v1/emails/{id}. +SendEmailResponse = TypedDict( + "SendEmailResponse", + { + "id": str, + }, +) + + +EmailEventsItem = TypedDict( + "EmailEventsItem", + { + "type": str, + "metadata": Union[dict[str, Any], None], + "timestamp": str, + }, +) + + +EmailTagsItem = TypedDict( + "EmailTagsItem", + { + "name": str, + "value": str, + }, +) + + Email = TypedDict( "Email", { "id": str, "from": str, "to": list[str], + "cc": list[str], + "bcc": list[str], "subject": str, - "html": NotRequired[Union[str, None]], - "text": NotRequired[Union[str, None]], - "status": Literal["queued", "scheduled", "sending", "sent", "delivered", "opened", "clicked", "bounced", "complained", "failed", "canceled"], - "source": str, + "html": Union[str, None], + "text": Union[str, None], "created_at": str, + "last_event": str, + "body_cleared_at": Union[str, None], + "source": str, + "category": Literal["transactional", "marketing"], + "status": Literal["queued", "scheduled", "sending", "sent", "delivered", "opened", "clicked", "bounced", "complained", "failed", "canceled"], + "reply_to": Union[str, None], + "tags": list[EmailTagsItem], + "message_id": Union[str, None], + "ses_message_id": Union[str, None], + "sent_at": Union[str, None], + "delivered_at": Union[str, None], + "opened_at": Union[str, None], + "clicked_at": Union[str, None], + "bounced_at": Union[str, None], + "unsubscribed_at": Union[str, None], + "error_message": Union[str, None], + "events": list[EmailEventsItem], }, ) @@ -488,6 +540,7 @@ "subject": str, "status": str, "source": str, + "category": Literal["transactional", "marketing"], "created_at": str, "sent_at": Union[str, None], "delivered_at": Union[str, None], @@ -611,22 +664,30 @@ ) -SegmentSummary = TypedDict( - "SegmentSummary", +ContactListItemSegmentsItem = TypedDict( + "ContactListItemSegmentsItem", { "id": str, "name": str, - "description": NotRequired[Union[str, None]], - "color": NotRequired[Union[str, None]], - "contact_count": NotRequired[int], - "created_at": NotRequired[str], - "updated_at": NotRequired[str], + "color": Union[str, None], }, ) -ContactSummary = TypedDict( - "ContactSummary", +ContactSuppression = TypedDict( + "ContactSuppression", + { + "id": str, + "reason": str, + "source": str, + "scope": Literal["all", "marketing"], + "created_at": str, + }, +) + + +ContactListItem = TypedDict( + "ContactListItem", { "id": str, "email": str, @@ -636,7 +697,9 @@ "unsubscribed": bool, "disposable": bool, "suppressed": bool, - "segments": NotRequired[list[SegmentSummary]], + "segments": list[ContactListItemSegmentsItem], + "properties": dict[str, Any], + "suppression": Union[ContactSuppression, None], }, ) @@ -646,8 +709,113 @@ { "object": str, "has_more": bool, - "data": list[ContactSummary], + "data": list[ContactListItem], + "page": NotRequired[int], + "page_size": NotRequired[int], + "filtered_total": NotRequired[int], + "total": NotRequired[int], + "subscribed": NotRequired[int], + "unsubscribed": NotRequired[int], "disposable": NotRequired[int], + "new_7d": NotRequired[int], + "new_30d": NotRequired[int], + "contact_limit": NotRequired[int], + "contact_limit_base": NotRequired[int], + "tier": NotRequired[str], + }, +) + + +ContactExport = TypedDict( + "ContactExport", + { + "object": str, + "has_more": bool, + "data": list[ContactListItem], + "exported_at": str, + }, +) + + +SegmentContactListSegment = TypedDict( + "SegmentContactListSegment", + { + "id": str, + "name": str, + }, +) + + +SegmentContactList = TypedDict( + "SegmentContactList", + { + "object": str, + "has_more": bool, + "data": list[ContactListItem], + "segment": SegmentContactListSegment, + "page": int, + "page_size": int, + "total": int, + }, +) + + +# Create or update up to 500 contacts, matched by email. Fields sent replace the stored ones; fields left out stay. segment_ids only adds memberships. +CreateContactBatchRequest = TypedDict( + "CreateContactBatchRequest", + { + "contacts": list[CreateContactRequest], + "segment_ids": NotRequired[list[str]], + }, +) + + +ContactSummarySegmentsItem = TypedDict( + "ContactSummarySegmentsItem", + { + "id": str, + "name": str, + "color": Union[str, None], + }, +) + + +ContactSummary = TypedDict( + "ContactSummary", + { + "id": str, + "email": str, + "first_name": NotRequired[Union[str, None]], + "last_name": NotRequired[Union[str, None]], + "created_at": str, + "unsubscribed": bool, + "disposable": bool, + "suppressed": bool, + "segments": NotRequired[list[ContactSummarySegmentsItem]], + }, +) + + +ContactBatchCreated = TypedDict( + "ContactBatchCreated", + { + "object": str, + "has_more": bool, + "data": list[ContactSummary], + }, +) + + +SegmentSummary = TypedDict( + "SegmentSummary", + { + "id": str, + "name": str, + "description": NotRequired[Union[str, None]], + "color": NotRequired[Union[str, None]], + "contact_count": NotRequired[int], + "created_at": NotRequired[str], + "updated_at": NotRequired[str], }, ) @@ -700,13 +868,12 @@ ) -ContactSuppression = TypedDict( - "ContactSuppression", +ContactSegmentsItem = TypedDict( + "ContactSegmentsItem", { "id": str, - "reason": str, - "source": str, - "created_at": str, + "name": str, + "color": Union[str, None], }, ) @@ -722,7 +889,7 @@ "unsubscribed": bool, "disposable": bool, "suppressed": bool, - "segments": NotRequired[list[SegmentSummary]], + "segments": NotRequired[list[ContactSegmentsItem]], "object": NotRequired[str], "properties": NotRequired[dict[str, Any]], "suppression": NotRequired[Union[ContactSuppression, None]], @@ -735,6 +902,7 @@ { "email": str, "reason": NotRequired[Literal["manual"]], + "scope": NotRequired[Literal["all", "marketing"]], "diagnostic_code": NotRequired[str], }, ) @@ -748,6 +916,7 @@ "email": str, "reason": Literal["hard_bounce", "complaint", "manual", "unsubscribe"], "source": str, + "scope": Literal["all", "marketing"], "bounce_type": NotRequired[Union[str, None]], "diagnostic_code": NotRequired[Union[str, None]], "feedback_id": NotRequired[Union[str, None]], @@ -768,6 +937,13 @@ "object": str, "has_more": bool, "data": list[Suppression], + "page": NotRequired[int], + "page_size": NotRequired[int], + "filtered_total": NotRequired[int], + "total": NotRequired[int], + "hard_bounces": NotRequired[int], + "complaints": NotRequired[int], + "manual": NotRequired[int], }, ) @@ -805,6 +981,7 @@ "text": NotRequired[Union[str, None]], "track_opens": NotRequired[bool], "track_clicks": NotRequired[bool], + "send_over_minutes": NotRequired[Union[int, None]], }, ) @@ -819,6 +996,7 @@ "skipped": NotRequired[int], "queued": NotRequired[int], "sending": NotRequired[int], + "handed_off": NotRequired[int], "sent": NotRequired[int], "delivered": NotRequired[int], "opened": NotRequired[int], @@ -861,6 +1039,7 @@ "pause_reason": NotRequired[Union[str, None]], "fanout_completed_at": NotRequired[Union[str, None]], "expected_recipients": NotRequired[Union[int, None]], + "send_over_minutes": NotRequired[int], "track_opens": NotRequired[bool], "track_clicks": NotRequired[bool], "recipient_count": NotRequired[int], @@ -877,11 +1056,135 @@ "object": str, "data": list[Broadcast], "has_more": bool, + "page": NotRequired[int], + "page_size": NotRequired[int], + "filtered_total": NotRequired[int], + "total": NotRequired[int], "status_counts": NotRequired[dict[str, int]], }, ) +BroadcastDetailLinksItem = TypedDict( + "BroadcastDetailLinksItem", + { + "url": str, + "clicks": int, + }, +) + + +BroadcastRecipientDetailEventsItem = TypedDict( + "BroadcastRecipientDetailEventsItem", + { + "id": str, + "type": str, + "metadata": NotRequired[dict[str, Any]], + "created_at": str, + }, +) + + +BroadcastRecipientDetail = TypedDict( + "BroadcastRecipientDetail", + { + "id": str, + "email": str, + "first_name": NotRequired[Union[str, None]], + "last_name": NotRequired[Union[str, None]], + "status": str, + "skip_reason": NotRequired[Union[str, None]], + "email_id": NotRequired[Union[str, None]], + "queued_at": NotRequired[Union[str, None]], + "sent_at": NotRequired[Union[str, None]], + "delivered_at": NotRequired[Union[str, None]], + "opened_at": NotRequired[Union[str, None]], + "clicked_at": NotRequired[Union[str, None]], + "bounced_at": NotRequired[Union[str, None]], + "complained_at": NotRequired[Union[str, None]], + "unsubscribed_at": NotRequired[Union[str, None]], + "failed_at": NotRequired[Union[str, None]], + "error_message": NotRequired[Union[str, None]], + "events": list[BroadcastRecipientDetailEventsItem], + }, +) + + +BroadcastDetail = TypedDict( + "BroadcastDetail", + { + "object": NotRequired[str], + "id": str, + "name": str, + "audience": NotRequired[str], + "audience_meta": NotRequired[AudienceMeta], + "from": NotRequired[Union[str, None]], + "reply_to": NotRequired[Union[str, None]], + "subject": NotRequired[Union[str, None]], + "preview_text": NotRequired[Union[str, None]], + "html": NotRequired[Union[str, None]], + "text": NotRequired[Union[str, None]], + "markdown": NotRequired[Union[str, None]], + "status": Literal["draft", "scheduled", "queued", "sending", "sent", "paused", "canceled", "failed"], + "scheduled_at": NotRequired[Union[str, None]], + "queued_at": NotRequired[Union[str, None]], + "sent_at": NotRequired[Union[str, None]], + "canceled_at": NotRequired[Union[str, None]], + "paused_at": NotRequired[Union[str, None]], + "pause_reason": NotRequired[Union[str, None]], + "fanout_completed_at": NotRequired[Union[str, None]], + "expected_recipients": NotRequired[Union[int, None]], + "send_over_minutes": NotRequired[int], + "track_opens": NotRequired[bool], + "track_clicks": NotRequired[bool], + "recipient_count": NotRequired[int], + "metrics": NotRequired[BroadcastMetrics], + "created_at": str, + "updated_at": str, + "recipients": list[BroadcastRecipientDetail], + "links": list[BroadcastDetailLinksItem], + }, +) + + +BroadcastSendResult = TypedDict( + "BroadcastSendResult", + { + "object": str, + "id": str, + "name": str, + "audience": NotRequired[str], + "audience_meta": NotRequired[AudienceMeta], + "from": NotRequired[Union[str, None]], + "reply_to": NotRequired[Union[str, None]], + "subject": NotRequired[Union[str, None]], + "preview_text": NotRequired[Union[str, None]], + "html": NotRequired[Union[str, None]], + "text": NotRequired[Union[str, None]], + "markdown": NotRequired[Union[str, None]], + "status": Literal["draft", "scheduled", "queued", "sending", "sent", "paused", "canceled", "failed"], + "scheduled_at": NotRequired[Union[str, None]], + "queued_at": NotRequired[Union[str, None]], + "sent_at": NotRequired[Union[str, None]], + "canceled_at": NotRequired[Union[str, None]], + "paused_at": NotRequired[Union[str, None]], + "pause_reason": NotRequired[Union[str, None]], + "fanout_completed_at": NotRequired[Union[str, None]], + "expected_recipients": NotRequired[Union[int, None]], + "send_over_minutes": NotRequired[int], + "track_opens": NotRequired[bool], + "track_clicks": NotRequired[bool], + "recipient_count": NotRequired[int], + "metrics": NotRequired[BroadcastMetrics], + "created_at": str, + "updated_at": str, + "recipients": int, + "skipped": int, + "batch_id": str, + }, +) + + TemplateVariable = TypedDict( "TemplateVariable", { @@ -1234,22 +1537,68 @@ DnsRecord = TypedDict( "DnsRecord", { - "type": NotRequired[Literal["CNAME", "TXT", "MX"]], + "type": Literal["CNAME", "TXT", "MX"], + "name": str, + "value": str, + "ttl": NotRequired[str], + "priority": NotRequired[Union[int, None]], + "status": NotRequired[str], + "purpose": NotRequired[str], + "category": NotRequired[Literal["verification", "sending", "receiving"]], + "actual_value": NotRequired[Union[str, None]], + "live_check": NotRequired[Union[Literal["match", "mismatch", "missing", "needs_include", "multiple_spf_records", "invalid_spf"], None]], + "actualValue": NotRequired[Union[str, None]], + "liveCheck": NotRequired[Union[str, None]], + }, +) + + +DomainEventsItem = TypedDict( + "DomainEventsItem", + { + "key": NotRequired[str], + "label": NotRequired[str], + "at": NotRequired[Union[str, None]], + "done": NotRequired[bool], + }, +) + + +# The DNS provider found from the NS records +DomainProvider = TypedDict( + "DomainProvider", + { + "id": NotRequired[str], "name": NotRequired[str], - "value": NotRequired[str], + "badge": NotRequired[str], + "zone": NotRequired[str], }, ) +# POST /v1/domains returns id, domain, status, dns_records and created_at. GET /v1/domains/{id} adds the verification detail, a live DNS check of each r Domain = TypedDict( "Domain", { "id": str, "domain": str, - "status": Literal["pending", "verified", "failed"], - "receivingEnabled": NotRequired[bool], - "receivingStatus": NotRequired[Literal["disabled", "pending", "active", "failed"]], + "status": Literal["pending", "verifying", "verified", "failed"], + "receiving": NotRequired[DomainReceiving], + "verification_phase": NotRequired[Literal["verified", "dns_missing", "waiting_on_ses", "mail_from_pending", "failed"]], + "verification_message": NotRequired[str], + "next_auto_check_at": NotRequired[Union[str, None]], + "dkim_status": NotRequired[str], + "spf_status": NotRequired[str], + "dmarc_status": NotRequired[str], + "mail_from_status": NotRequired[str], + "mail_from_domain": NotRequired[Union[str, None]], + "verified_at": NotRequired[Union[str, None]], + "region": NotRequired[str], + "provider": NotRequired[DomainProvider], + "events": NotRequired[list[DomainEventsItem]], "dns_records": list[DnsRecord], + "created_at": str, + "last_checked_at": NotRequired[Union[str, None]], }, ) @@ -1711,18 +2060,31 @@ ) +GetEmailMetricsResponseDataItem = TypedDict( + "GetEmailMetricsResponseDataItem", + { + "period": NotRequired[Union[str, None]], + "domain_id": NotRequired[Union[str, None]], + "domain_name": NotRequired[Union[str, None]], + "email_id": NotRequired[Union[str, None]], + "broadcast_id": NotRequired[Union[str, None]], + "broadcast_name": NotRequired[Union[str, None]], + }, +) + + GetEmailMetricsResponse = TypedDict( "GetEmailMetricsResponse", { - "object": NotRequired[str], - "start_date": NotRequired[str], - "end_date": NotRequired[str], - "timezone": NotRequired[str], - "metrics": NotRequired[list[str]], - "dimensions": NotRequired[list[str]], - "granularity": NotRequired[str], - "totals": NotRequired[dict[str, float]], - "data": NotRequired[list[dict[str, Any]]], + "object": str, + "start_date": str, + "end_date": str, + "timezone": str, + "metrics": list[str], + "dimensions": list[Literal["period", "domain", "email", "broadcast"]], + "granularity": Literal["hourly", "daily", "weekly", "monthly"], + "totals": dict[str, float], + "data": NotRequired[list[GetEmailMetricsResponseDataItem]], }, ) @@ -2102,6 +2464,12 @@ ) +CreateContactParams: TypeAlias = Union[CreateContactRequest, CreateContactBatchRequest] + + +CreateContactResponse: TypeAlias = Union[ContactCreated, ContactBatchCreated] + + DeleteContactsParams = TypedDict( "DeleteContactsParams", { @@ -2276,6 +2644,7 @@ "SendBroadcastParams", { "scheduled_at": NotRequired[str], + "send_over_minutes": NotRequired[int], }, ) @@ -2288,6 +2657,16 @@ ) +SendBroadcastTestResponse = TypedDict( + "SendBroadcastTestResponse", + { + "object": str, + "id": str, + "status": str, + }, +) + + PublishTemplateParams = TypedDict( "PublishTemplateParams", { @@ -2425,7 +2804,8 @@ RevokeApiKeyResponse = TypedDict( "RevokeApiKeyResponse", { - "success": NotRequired[bool], + "success": bool, + "message": str, }, )