diff --git a/CHANGELOG.md b/CHANGELOG.md index 2d516f2..3ef241f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,24 @@ All notable changes to `sendly-python` are documented here. This project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [Unreleased] + +### Added + +- **`emails.send` takes `listUnsubscribe`**, the https URL of your own one-click + unsubscribe endpoint. A transactional send carries no unsubscribe of Sendly's, + because Sendly cannot know what the reader would be opting out of; with this + field Sendly writes `List-Unsubscribe: ` and + `List-Unsubscribe-Post: List-Unsubscribe=One-Click` (RFC 8058), so the mail + client shows its own unsubscribe button. The two headers stay reserved in + `headers`. The server answers `422` for a value that is not https, is over + 2048 characters, or contains whitespace, a comma or angle brackets. Campaign + and workflow sends ignore it and keep Sendly's own link. +- **The vendored spec is re-synced** from the platform contract. The new mailbox + conversation and domain-logo operations are listed as not yet implemented, and + the provider compat dialects under `/api/compat/` are excluded by path: those + are for the other providers' own SDKs. + ## [1.1.0] - 2026-09-05 Four new resources, the `/api/v1` half of six that only had a legacy one, and a diff --git a/README.md b/README.md index f761b2f..8585243 100644 --- a/README.md +++ b/README.md @@ -233,6 +233,13 @@ receipt = sendly.emails.send({"from": "a@you.com", "to": "b@them.com", "subject" sendly.emails.send_legacy({"from": "a@you.com", "to": ["b@them.com", "c@them.com"], "subject": "Hi", "body": "

Hi

"}) +# Your own one-click unsubscribe (https only). Sendly writes the RFC 8058 +# List-Unsubscribe headers, so the mail client shows its unsubscribe button; the +# endpoint gets a cookie-less POST and must unsubscribe the reader on it alone. +sendly.emails.send({"from": "notify@you.com", "to": "b@them.com", "subject": "New reply", + "body": "

Someone replied.

", + "listUnsubscribe": "https://you.com/unsubscribe?token=abc123"}) + # Batch send (up to 100) sendly.emails.batch({"emails": [{"from": "a@you.com", "to": "b@them.com", "subject": "Hi", "body": "

Hi

"}]}) diff --git a/tests/fixtures/openapi.json b/tests/fixtures/openapi.json index 2b76977..64a88f1 100644 --- a/tests/fixtures/openapi.json +++ b/tests/fixtures/openapi.json @@ -314,7 +314,9 @@ "topics:read", "topics:write", "lists:read", - "lists:write" + "lists:write", + "mailbox-conversations:read", + "mailbox-conversations:reply" ], "type": "string" }, @@ -443,6 +445,14 @@ "null" ] }, + "defaultFromName": { + "description": "The name recipients see beside any address on this identity when a send names none, e.g. `Acme`. Independent of `stream`. An empty string or `null` clears it, and the project name is used instead.", + "maxLength": 100, + "type": [ + "string", + "null" + ] + }, "stream": { "description": "Which traffic this identity carries. `null` unassigns it, returning it to serving every stream and clearing its default flag and default address.", "enum": [ @@ -1007,3230 +1017,3820 @@ }, "type": "object" }, - "ComposeMailboxMessage": { - "description": "Body for POST /api/mailboxes/{id}/messages — a new outbound message from a hosted mailbox.", + "CompatMailgunError": { + "description": "Mailgun's error body: a bare `{ message }`. mailgun.js surfaces the status as `error.status`.", + "properties": { + "message": { + "type": "string" + } + }, + "required": [ + "message" + ], + "type": "object" + }, + "CompatMailgunMessageRequest": { + "description": "A Mailgun `messages.create()` form. Custom headers travel as `h:` fields (for example `h:X-Campaign: spring`); `h:Reply-To` sets the reply address instead of becoming a custom header. Any other `h:` field is passed through as a header; other `o:` and `v:` options are ignored.", "properties": { + "attachment": { + "description": "Repeatable file parts. Read and base64-encoded; a part with no content type is sent as `application/octet-stream`.", + "items": { + "format": "binary", + "type": "string" + }, + "type": "array" + }, "bcc": { + "description": "Repeat the field once per recipient.", "items": { - "format": "email", "type": "string" }, - "maxItems": 20, "type": "array" }, - "body": { - "maxLength": 50000, - "minLength": 1, + "cc": { + "description": "Repeat the field once per recipient.", + "items": { + "type": "string" + }, + "type": "array" + }, + "from": { + "description": "A bare address or `\"Display Name \"`. Its domain MUST equal the `{domain}` path segment (compared case-insensitively), else `400`.", "type": "string" }, - "cc": { + "html": { + "description": "Preferred over `text` when both are sent.", + "type": "string" + }, + "o:tag": { + "description": "Repeatable. Each becomes a tag, stripped to `[a-zA-Z0-9_-]` and cut to 64 characters; at most 10.", "items": { - "format": "email", "type": "string" }, - "maxItems": 20, "type": "array" }, "subject": { - "maxLength": 200, - "minLength": 1, + "description": "Required in practice: Sendly has no template mapping here, so a message without a subject is refused with `400`.", + "type": "string" + }, + "text": { + "description": "One of `html` or `text` is required (else `400`).", "type": "string" }, "to": { + "description": "Repeat the field once per recipient.", "items": { - "format": "email", "type": "string" }, - "maxItems": 20, "minItems": 1, "type": "array" } }, "required": [ + "from", "to", - "subject", - "body" + "subject" ], "type": "object" }, - "Contact": { - "description": "A subscriber/contact within a project.", + "CompatMailgunSendResult": { + "description": "Mailgun's send success body.", "properties": { - "createdAt": { - "description": "ISO 8601 datetime string", - "format": "date-time", - "type": "string" - }, - "customFields": { - "additionalProperties": {}, - "type": [ - "object", - "null" - ] - }, - "email": { - "format": "email", - "type": "string" - }, "id": { - "format": "uuid", - "type": "string" - }, - "projectId": { - "format": "uuid", + "description": "`<{email id}@{domain}>`, with the first recipient's queued email id.", + "example": "<3f0d7c1e-9a52-4b0e-8d57-0b8f3a0c2a11@mail.example.com>", "type": "string" }, - "subscribed": { - "type": "boolean" - }, - "updatedAt": { - "description": "ISO 8601 datetime string", - "format": "date-time", + "message": { + "enum": [ + "Queued. Thank you." + ], "type": "string" } }, "required": [ "id", - "projectId", - "email", - "subscribed", - "createdAt", - "updatedAt" + "message" ], "type": "object" }, - "ContactBulkCreateBody": { - "description": "Bulk create up to 1000 contacts.", + "CompatPlunkError": { + "description": "Plunk's error body, the NestJS default exception shape.", "properties": { - "contacts": { - "items": { - "$ref": "#/components/schemas/CreateContact" - }, - "maxItems": 1000, - "minItems": 1, - "type": "array" + "code": { + "description": "The HTTP status, repeated.", + "type": "integer" + }, + "error": { + "description": "The status text, e.g. `Unprocessable Entity`.", + "type": "string" + }, + "message": { + "type": "string" + }, + "time": { + "description": "Milliseconds since the Unix epoch.", + "type": "integer" } }, "required": [ - "contacts" + "code", + "error", + "message", + "time" ], "type": "object" }, - "ContactBulkDeleteBody": { - "description": "Bulk delete contacts. Provide either `ids` or `emails` (max 1000 each).", + "CompatPlunkSendResult": { + "description": "Plunk's `{ success, emails, timestamp }`: one `emails` entry per recipient (a single request with an array `to` fans out), plus the send `timestamp`.", "properties": { "emails": { "items": { - "format": "email", - "type": "string" - }, - "maxItems": 1000, - "type": "array" - }, - "ids": { - "items": { - "format": "uuid", - "type": "string" + "$ref": "#/components/schemas/SendEmailRecipientResult" }, - "maxItems": 1000, "type": "array" - } - }, - "type": "object" - }, - "ContactListResponse": { - "description": "Cursor-paginated list of contacts.", - "properties": { - "data": { - "properties": { - "data": { - "items": { - "$ref": "#/components/schemas/Contact" - }, - "type": "array" - }, - "hasMore": { - "type": "boolean" - }, - "nextCursor": { - "description": "Cursor for the next page, or null on the last page.", - "type": [ - "string", - "null" - ] - }, - "total": { - "type": "integer" - } - }, - "required": [ - "data", - "total", - "nextCursor", - "hasMore" - ], - "type": "object" }, "success": { "enum": [ true ], "type": "boolean" + }, + "timestamp": { + "description": "ISO 8601 datetime string", + "format": "date-time", + "type": "string" } }, "required": [ "success", - "data" + "emails", + "timestamp" ], "type": "object" }, - "ContactTopicPreferencesV1": { - "description": "Everything this contact has said about what they want. `subscribed` on a topic already folds in `default_opt_in`, so a contact who has never answered still reads correctly.", + "CompatPostmarkBatchResult": { + "description": "One entry per input message, in order: a send result, or — for an entry that failed on its own — a `{ ErrorCode, Message }` object, with the request still answering `200` and the other entries still sent.", + "items": { + "anyOf": [ + { + "$ref": "#/components/schemas/CompatPostmarkSendResult" + }, + { + "$ref": "#/components/schemas/CompatPostmarkError" + } + ] + }, + "type": "array" + }, + "CompatPostmarkEmailRequest": { + "description": "A Postmark `/email` body. `From` and `To` are required, and so is one of `HtmlBody` or `TextBody` (else `422`).", "properties": { - "contact_id": { - "type": "string" - }, - "subscribed": { - "description": "The global marketing opt-out, which OUTRANKS every topic. False means no marketing reaches this contact whatever the topics below say.", - "type": "boolean" - }, - "topics": { + "Attachments": { "items": { "properties": { - "key": { - "description": "Stable, URL-safe, unique within the project. This is what a preference form and an API caller name the topic by, so it must survive a rename of `name` — which is the whole reason it exists beside one.", - "maxLength": 64, - "minLength": 1, - "pattern": "^[a-z0-9][a-z0-9_-]*$", + "Content": { + "description": "Base64 file content.", "type": "string" }, - "name": { + "ContentID": { "type": "string" }, - "pending": { - "type": "boolean" + "ContentType": { + "type": "string" }, - "subscribed": { - "description": "The EFFECTIVE answer: what the send path concludes for this contact today.", - "type": "boolean" + "Name": { + "type": "string" + } + }, + "required": [ + "Name", + "Content", + "ContentType" + ], + "type": "object" + }, + "type": "array" + }, + "Bcc": { + "description": "Comma-separated.", + "type": "string" + }, + "Cc": { + "description": "Comma-separated.", + "type": "string" + }, + "From": { + "description": "A bare address or `\"Display Name \"`.", + "type": "string" + }, + "Headers": { + "items": { + "properties": { + "Name": { + "type": "string" }, - "topic_id": { + "Value": { "type": "string" } }, "required": [ - "topic_id", - "key", - "name", - "subscribed", - "pending" + "Name", + "Value" ], "type": "object" }, "type": "array" - } - }, - "required": [ - "contact_id", - "subscribed", - "topics" - ], - "type": "object" - }, - "ContactV1": { - "description": "A contact as exposed on the v1 API.", - "properties": { - "created_at": { - "format": "date-time", + }, + "HtmlBody": { + "description": "Preferred over `TextBody` when both are sent.", "type": "string" }, - "custom_fields": { - "additionalProperties": {}, - "description": "Arbitrary JSON value (string, number, boolean, null, array, or object).", - "type": [ - "string", - "number", - "boolean", - "object", - "array", - "null" - ] + "MessageStream": { + "description": "Accepted and ignored.", + "type": "string" }, - "email": { + "ReplyTo": { + "description": "Only the address is used.", "type": "string" }, - "id": { - "format": "uuid", + "Subject": { + "description": "Required in practice: Sendly has no template mapping here, so a message without a subject is refused with `422`.", "type": "string" }, - "subscribed": { - "type": "boolean" + "Tag": { + "description": "Becomes a single tag, stripped to `[a-zA-Z0-9_-]` and cut to 64 characters.", + "type": "string" }, - "updated_at": { - "format": "date-time", + "TextBody": { + "type": "string" + }, + "To": { + "description": "One or more recipients, COMMA-SEPARATED in a single string (Postmark's own convention).", "type": "string" + }, + "TrackLinks": { + "description": "Accepted and ignored.", + "type": "string" + }, + "TrackOpens": { + "description": "Accepted and ignored.", + "type": "boolean" } }, "required": [ - "id", - "email", - "subscribed", - "custom_fields", - "created_at", - "updated_at" + "From", + "To", + "Subject" ], "type": "object" }, - "ContactV1Create": { - "description": "Body for POST /api/v1/contacts.", + "CompatPostmarkError": { + "description": "Postmark's error body. The HTTP status is carried separately; the postmark SDK keys its thrown error class off it.", "properties": { - "custom_fields": { - "additionalProperties": {}, - "description": "Arbitrary JSON stored on the contact and available to templates as `{{ variables }}`.", - "type": [ - "string", - "number", - "boolean", - "object", - "array", - "null" - ] + "ErrorCode": { + "description": "Postmark's own vocabulary for the statuses Sendly emits: `10` (401, 403), `300` (400, 422), `701` (404), `429` (429), `100` (500); any other status is repeated here. The one exception is a body that is not JSON or whose root is not an object: it answers `402` with HTTP `422`, as Postmark does.", + "type": "integer" }, - "email": { - "format": "email", + "Message": { "type": "string" - }, - "subscribed": { - "default": true, - "type": "boolean" } }, "required": [ - "email" + "ErrorCode", + "Message" ], "type": "object" }, - "ContactV1Deleted": { - "description": "Acknowledgement that a contact was deleted.", + "CompatPostmarkSendResult": { + "description": "Postmark's send success body.", "properties": { - "deleted": { + "ErrorCode": { "enum": [ - true + 0 ], - "type": "boolean" + "type": "number" }, - "id": { + "Message": { + "enum": [ + "OK" + ], + "type": "string" + }, + "MessageID": { + "description": "The id of the queued email for the first recipient.", "format": "uuid", "type": "string" + }, + "SubmittedAt": { + "format": "date-time", + "type": "string" + }, + "To": { + "description": "The request's `To` string echoed back, or `\"\"` if it was not a string.", + "type": "string" } }, "required": [ - "id", - "deleted" + "To", + "SubmittedAt", + "MessageID", + "ErrorCode", + "Message" ], "type": "object" }, - "ContactV1List": { - "description": "Cursor-paginated list of contacts.", + "CompatResendBatchResult": { + "description": "Resend `batch.send()` success body.", "properties": { "data": { + "description": "One `{ id }` per input entry, in input order.", "items": { - "$ref": "#/components/schemas/ContactV1" + "properties": { + "id": { + "format": "uuid", + "type": "string" + } + }, + "required": [ + "id" + ], + "type": "object" }, "type": "array" - }, - "has_more": { - "type": "boolean" - }, - "next_cursor": { - "description": "Pass as `after` to fetch the next page. `null` on the last page.", - "type": [ - "string", - "null" - ] } }, "required": [ - "data", - "has_more", - "next_cursor" + "data" ], "type": "object" }, - "ContactV1Update": { - "description": "Body for PATCH /api/v1/contacts/{id}. `email` is deliberately absent: an address is the contact's identity on this API, and changing it in place would silently rewrite what every earlier send was addressed to. Create the new address instead.", + "CompatResendEmail": { + "description": "Resend `emails.get()` body.", "properties": { - "custom_fields": { - "additionalProperties": {}, - "description": "Arbitrary JSON value (string, number, boolean, null, array, or object).", - "type": [ - "string", - "number", - "boolean", - "object", - "array", - "null" - ] + "bcc": { + "description": "Always `null`.", + "type": "null" }, - "subscribed": { - "type": "boolean" - } - }, - "type": "object" - }, - "CreateApiKeyBody": { - "properties": { - "domainId": { + "cc": { + "description": "Always `null`.", + "type": "null" + }, + "created_at": { + "format": "date-time", + "type": "string" + }, + "from": { + "type": "string" + }, + "html": { + "description": "The stored message body, whether it was sent as HTML or text.", + "type": "string" + }, + "id": { "format": "uuid", - "type": [ - "string", - "null" - ] + "type": "string" }, - "legacyGrantPreset": { + "last_event": { + "description": "Best-effort. `sent` when the row's delivery status is `SENT`, `failed` when it is `FAILED`, and `queued` for EVERY other status — including `DELIVERED` and `BOUNCED`. Read `GET /api/emails/{id}` for the real delivery status.", "enum": [ - "FULL", - "SENDING_ONLY" + "queued", + "sent", + "failed" ], "type": "string" }, - "mode": { - "description": "`LIVE` (default) or `TEST`. A test key can only send from the project's sandbox address, which accepts only the project's own verified account addresses as recipients, so it cannot reach a customer. Its sends are real sends down the same pipeline and are marked `sentInTestMode`. Fixed at creation.", + "object": { "enum": [ - "LIVE", - "TEST" + "email" ], "type": "string" }, - "name": { - "maxLength": 120, - "minLength": 1, + "reply_to": { + "description": "Always `null`.", + "type": "null" + }, + "subject": { "type": "string" }, - "scopes": { - "description": "The explicit grant the new key will carry. Omitted ⇒ materialised from `legacyGrantPreset`. A `SENDING_ONLY` key may carry only `emails:send`.", + "text": { + "description": "Always `null`; the body is returned in `html`.", + "type": "null" + }, + "to": { + "description": "Always exactly one address: the recipient of this row.", "items": { - "enum": [ - "emails:send", - "emails:read", - "contacts:read", - "contacts:write", - "campaigns:read", - "campaigns:write", - "segments:read", - "segments:write", - "workflows:read", - "workflows:write", - "templates:read", - "templates:write", - "domains:read", - "domains:write", - "webhooks:read", - "webhooks:write", - "suppression:read", - "suppression:write", - "analytics:read", - "usage:read", - "events:read", - "events:write", - "projects:read", - "projects:write", - "api-keys:read", - "api-keys:write", - "campaigns:send", - "mailboxes:read", - "mailboxes:write", - "emails:test", - "deliverability:read", - "mailboxes:send", - "validation:read", - "validation:write", - "topics:read", - "topics:write", - "lists:read", - "lists:write" - ], "type": "string" }, "type": "array" } }, "required": [ - "name" + "object", + "id", + "to", + "from", + "created_at", + "subject", + "html", + "text", + "bcc", + "cc", + "reply_to", + "last_event" ], "type": "object" }, - "CreateAppPassword": { - "description": "Body for POST /api/mailboxes/:id/app-passwords.", + "CompatResendEmailRequest": { + "description": "A Resend `emails.send()` body. Either `html` or `text` is required (else `422`). Translated to the native send input and then validated by the same schema as `POST /api/emails`, so its limits apply.", "properties": { - "name": { - "maxLength": 60, - "minLength": 1, + "attachments": { + "items": { + "properties": { + "content": { + "description": "Base64 file content.", + "type": "string" + }, + "content_id": { + "description": "Content-ID, for inline images.", + "type": "string" + }, + "content_type": { + "type": "string" + }, + "filename": { + "type": "string" + }, + "path": { + "description": "A URL to fetch the file from. NOT supported: Sendly never fetches a URL on a sender's behalf, so a request carrying `path` is refused with `422`. Send the file inline as base64 `content`.", + "type": "string" + } + }, + "type": "object" + }, + "type": "array" + }, + "bcc": { + "anyOf": [ + { + "type": "string" + }, + { + "items": { + "type": "string" + }, + "type": "array" + } + ] + }, + "cc": { + "anyOf": [ + { + "type": "string" + }, + { + "items": { + "type": "string" + }, + "type": "array" + } + ] + }, + "from": { + "description": "Sender: a bare address or `\"Display Name \"`. The domain must be verified.", "type": "string" }, - "scopes": { - "default": [ - "imap", - "smtp" - ], - "items": { - "enum": [ - "imap", - "smtp" - ], + "headers": { + "additionalProperties": { "type": "string" }, - "minItems": 1, - "type": "array" - } - }, - "required": [ - "name" - ], - "type": "object" - }, - "CreateContact": { - "description": "Body for POST /api/contacts and /api/contacts/upsert.", - "properties": { - "customFields": { - "additionalProperties": {}, - "description": "Arbitrary JSON value (string, number, boolean, null, array, or object).", "type": "object" }, - "email": { - "format": "email", + "html": { + "description": "HTML body. Preferred over `text` when both are sent.", "type": "string" }, - "subscribed": { - "default": true, - "type": "boolean" - } - }, - "required": [ - "email" - ], - "type": "object" - }, - "CreateMailboxBody": { - "properties": { - "displayName": { - "maxLength": 120, - "type": "string" + "reply_to": { + "anyOf": [ + { + "type": "string" + }, + { + "items": { + "type": "string" + }, + "type": "array" + } + ], + "description": "Only the FIRST address is used; Sendly carries a single reply address." }, - "domainId": { - "description": "A VERIFIED domain belonging to this project.", - "format": "uuid", + "scheduled_at": { + "description": "NOT supported. Any value other than `null` is refused with `422` (`COMPAT_UNSUPPORTED`).", "type": "string" }, - "localPart": { - "description": "The part before the `@`, e.g. `support`. Lowercased server-side.", - "maxLength": 64, - "minLength": 1, + "subject": { + "description": "Required in practice: Sendly has no template mapping here, so a message without a subject is refused with `422`.", "type": "string" }, - "projectId": { - "description": "Defaults to the project the credential resolves to. Naming a different one is refused.", - "format": "uuid", + "tags": { + "description": "Each tag becomes the single string `_`, stripped to `[a-zA-Z0-9_-]` and cut to 64 characters. At most 10 are kept.", + "items": { + "properties": { + "name": { + "type": "string" + }, + "value": { + "type": "string" + } + }, + "type": "object" + }, + "type": "array" + }, + "template": { + "description": "NOT supported: a Resend template id cannot map to a Sendly template. Refused with `422` (`COMPAT_UNSUPPORTED`)." + }, + "text": { + "description": "Plain-text body. Used only when `html` is absent.", "type": "string" }, - "quotaBytes": { - "description": "NOT IMPLEMENTED — sending any value answers 400.", - "exclusiveMinimum": 0, - "type": "integer" + "to": { + "anyOf": [ + { + "type": "string" + }, + { + "items": { + "type": "string" + }, + "type": "array" + } + ], + "description": "One address or an array of addresses." + }, + "topic_id": { + "description": "NOT supported. Refused with `422` (`COMPAT_UNSUPPORTED`).", + "type": "string" } }, "required": [ - "domainId", - "localPart" + "from", + "to", + "subject" ], "type": "object" }, - "CreateSnippet": { - "description": "Body for POST /api/snippets.", + "CompatResendError": { + "description": "Resend's error body. The resend SDK returns it as `{ data: null, error }`.", "properties": { - "body": { - "maxLength": 20000, - "minLength": 1, + "message": { "type": "string" }, - "description": { - "maxLength": 500, - "type": [ - "string", - "null" - ] - }, "name": { - "pattern": "^[a-z][\\da-z_-]{0,63}$/i", + "description": "Keyed off the status: `validation_error` (400, 422), `restricted_api_key` (401, 403), `not_found` (404), `invalid_idempotent_request` (409), `rate_limit_exceeded` (429), otherwise `application_error`.", "type": "string" + }, + "statusCode": { + "type": "integer" } }, "required": [ - "name", - "body" + "statusCode", + "message", + "name" ], "type": "object" }, - "CreateTemplate": { - "description": "Body for POST /api/templates.", + "CompatResendSendResult": { + "description": "Resend `emails.send()` success body.", "properties": { - "body": { - "minLength": 1, - "type": "string" - }, - "description": { - "maxLength": 500, - "type": "string" - }, - "emailCategory": { - "default": "MARKETING", - "enum": [ - "TRANSACTIONAL", - "MARKETING", - "SELF_MANAGED_UNSUBSCRIBE" - ], - "type": "string" - }, - "from": { - "format": "email", - "type": "string" - }, - "fromName": { - "maxLength": 100, - "type": [ - "string", - "null" - ] - }, - "name": { - "maxLength": 100, - "minLength": 1, - "type": "string" - }, - "replyTo": { - "format": "email", - "type": [ - "string", - "null" - ] - }, - "subject": { - "minLength": 1, + "id": { + "description": "The id of the queued email for the first recipient. Poll `GET /api/compat/resend/emails/{id}`.", + "format": "uuid", "type": "string" } }, "required": [ - "name", - "subject", - "body", - "from" + "id" ], "type": "object" }, - "CreateWebhook": { - "description": "Body for POST /api/webhooks — register a user webhook for one or more events.", + "CompatSendGridError": { + "description": "SendGrid's error body, `{ errors: [{ message, field, help }] }`.", "properties": { - "eventTypes": { + "errors": { "items": { - "enum": [ - "email.sent", - "email.delivered", - "email.opened", - "email.clicked", - "email.bounced", - "email.complained", - "email.failed", - "contact.created", - "contact.unsubscribed", - "contacts.bulk_created" + "properties": { + "field": { + "description": "Always `null`: Sendly does not track which field failed.", + "type": "null" + }, + "help": { + "type": "null" + }, + "message": { + "type": "string" + } + }, + "required": [ + "message", + "field", + "help" ], - "type": "string" + "type": "object" }, - "minItems": 1, "type": "array" - }, - "url": { - "format": "uri", - "type": "string" } }, "required": [ - "url", - "eventTypes" + "errors" ], "type": "object" }, - "DeliverabilityDiagnosisV1": { - "description": "A composed answer to why mail from one domain may not be arriving: its DNS identity, the project's recent delivery outcomes, one address's suppression state, and the findings drawn from them.", + "CompatSendGridMailRequest": { + "description": "A SendGrid v3 `mail/send` body.", "properties": { - "address": { - "type": [ - "string", - "null" - ] - }, - "checked_at": { - "format": "date-time", - "type": "string" + "attachments": { + "items": { + "properties": { + "content": { + "description": "Base64 file content.", + "type": "string" + }, + "content_id": { + "type": "string" + }, + "disposition": { + "type": "string" + }, + "filename": { + "type": "string" + }, + "type": { + "type": "string" + } + }, + "required": [ + "content", + "filename" + ], + "type": "object" + }, + "type": "array" }, - "domain": { - "type": "string" + "categories": { + "description": "Become tags, stripped to `[a-zA-Z0-9_-]` and cut to 64 characters. At most 10 are kept.", + "items": { + "type": "string" + }, + "type": "array" }, - "findings": { - "description": "What is wrong, worst first. An empty array means nothing here explains a delivery problem.", + "content": { + "description": "The body is the first non-empty `text/html` entry, else the first `text/plain`, else the first entry with a value. At least one entry with a value is required.", "items": { - "$ref": "#/components/schemas/DeliverabilityFindingV1" + "properties": { + "type": { + "type": "string" + }, + "value": { + "type": "string" + } + }, + "required": [ + "type", + "value" + ], + "type": "object" }, "type": "array" }, - "identity": { - "$ref": "#/components/schemas/DeliverabilityIdentityV1" + "from": { + "properties": { + "email": { + "type": "string" + }, + "name": { + "type": "string" + } + }, + "required": [ + "email" + ], + "type": "object" }, - "recent_delivery": { - "$ref": "#/components/schemas/DeliverabilityRecentDeliveryV1" + "headers": { + "additionalProperties": { + "type": "string" + }, + "type": "object" }, - "suppression": { - "$ref": "#/components/schemas/DeliverabilitySuppressionV1" - } - }, - "required": [ - "domain", - "address", - "checked_at", - "identity", - "suppression", - "recent_delivery", - "findings" - ], - "type": "object" - }, - "DeliverabilityFindingSeverityV1": { - "description": "`blocking`: mail from this domain cannot be delivered as configured. `degraded`: it delivers, but inbox placement or sender reputation is at risk. `info`: worth knowing, nothing to fix.", - "enum": [ - "blocking", - "degraded", - "info" - ], - "type": "string" - }, - "DeliverabilityFindingV1": { - "description": "One diagnosed problem, with its fix.", - "properties": { - "code": { - "description": "Stable identifier for this finding, e.g. `domain_not_verified`. Branch on this, not on `summary`.", - "type": "string" - }, - "remedy": { - "description": "What to do about it.", - "type": "string" - }, - "severity": { - "$ref": "#/components/schemas/DeliverabilityFindingSeverityV1" - }, - "summary": { - "description": "What is wrong, in one sentence.", - "type": "string" - } - }, - "required": [ - "code", - "severity", - "summary", - "remedy" - ], - "type": "object" - }, - "DeliverabilityIdentityV1": { - "description": "The sending identity's DNS health, as last refreshed.", - "properties": { - "dkim_status": { - "description": "DKIM signing. This is the one that decides whether Sendly will send from the domain at all.", - "enum": [ - "NOT_CHECKED", - "PENDING", - "VERIFIED", - "FAILED", - null - ], - "type": [ - "string", - "null" - ] - }, - "dmarc_status": { - "description": "The DMARC policy published at `_dmarc.`.", - "enum": [ - "NOT_CHECKED", - "PENDING", - "VERIFIED", - "FAILED", - null - ], - "type": [ - "string", - "null" - ] - }, - "last_checked_at": { - "description": "When the DNS refresh job last looked. These statuses are a CACHE, not a live lookup.", - "format": "date-time", - "type": [ - "string", - "null" - ] - }, - "mail_from_domain": { - "type": [ - "string", - "null" - ] - }, - "mail_from_domain_status": { - "description": "Raw SES `CustomMailFromStatus` (`Pending`/`Success`/`Failed`/`TemporaryFailure`), or `NotConfigured`. `Failed` means SES silently fell back to `amazonses.com`, which is why the value is reported rather than folded into a boolean.", - "type": [ - "string", - "null" - ] + "personalizations": { + "description": "Each entry becomes its own send, with its own recipients. The whole fan-out is validated, and content review runs over it, before any send runs, so an invalid entry or a content refusal rejects the request with nothing sent. The sends then run one after another: a later send that fails (an unverified domain, a sandbox restriction, a rate or billing limit) answers an error and leaves the earlier entries queued.", + "items": { + "properties": { + "bcc": { + "items": { + "properties": { + "email": { + "type": "string" + }, + "name": { + "type": "string" + } + }, + "required": [ + "email" + ], + "type": "object" + }, + "type": "array" + }, + "cc": { + "items": { + "properties": { + "email": { + "type": "string" + }, + "name": { + "type": "string" + } + }, + "required": [ + "email" + ], + "type": "object" + }, + "type": "array" + }, + "subject": { + "description": "Overrides the top-level `subject` for this entry. Every entry needs a subject from one of the two places, or the request is refused with `400`.", + "type": "string" + }, + "to": { + "description": "At least one address is required.", + "items": { + "properties": { + "email": { + "type": "string" + }, + "name": { + "type": "string" + } + }, + "required": [ + "email" + ], + "type": "object" + }, + "type": "array" + } + }, + "required": [ + "to" + ], + "type": "object" + }, + "minItems": 1, + "type": "array" }, - "mx_status": { - "description": "Inbound receiving only. Null unless the domain has receiving enabled.", - "enum": [ - "NOT_CHECKED", - "PENDING", - "VERIFIED", - "FAILED", - null + "reply_to": { + "description": "Only `email` is used.", + "properties": { + "email": { + "type": "string" + }, + "name": { + "type": "string" + } + }, + "required": [ + "email" ], - "type": [ - "string", - "null" - ] + "type": "object" }, - "registered": { - "description": "Whether this project has a domain record at all. False makes every other field null.", - "type": "boolean" + "send_at": { + "description": "NOT supported. Refused with `400` (`COMPAT_UNSUPPORTED`).", + "type": "integer" }, - "spf_status": { - "description": "SPF alignment for the sending identity.", - "enum": [ - "NOT_CHECKED", - "PENDING", - "VERIFIED", - "FAILED", - null - ], - "type": [ - "string", - "null" - ] + "subject": { + "description": "Optional only if EVERY personalization carries its own `subject`; an entry with neither is refused with `400`. The schema cannot express that either-or, so it is shown as optional here.", + "type": "string" }, - "verified": { - "type": "boolean" + "template_id": { + "description": "NOT supported: a dynamic template cannot map to a Sendly template. Refused with `400`.", + "type": "string" } }, "required": [ - "registered", - "verified", - "dkim_status", - "spf_status", - "dmarc_status", - "mx_status", - "mail_from_domain", - "mail_from_domain_status", - "last_checked_at" + "personalizations", + "from", + "content" ], "type": "object" }, - "DeliverabilityRecentDeliveryV1": { - "description": "Delivery outcomes over the requested window.", + "ComposeMailboxMessage": { + "description": "Body for POST /api/mailboxes/{id}/messages — a new outbound message from a hosted mailbox.", "properties": { - "bounce_rate": { - "description": "Bounced ÷ sent (0–1), or null when nothing was sent in the window.", - "type": [ - "number", - "null" - ] - }, - "bounced": { - "type": "integer" - }, - "complained": { - "type": "integer" - }, - "complaint_rate": { - "type": [ - "number", - "null" - ] - }, - "delivered": { - "type": "integer" - }, - "failed": { - "type": "integer" + "bcc": { + "items": { + "format": "email", + "type": "string" + }, + "maxItems": 20, + "type": "array" }, - "scope": { - "description": "PROJECT-WIDE, not per-domain, and said so rather than implied. `Email` has no sending-domain column, so narrowing these counters to one domain would mean a scan over every row the project has ever sent. A project that sends from one domain — most of them — can read these as that domain's.", - "enum": [ - "project" - ], + "body": { + "default": "", + "maxLength": 50000, "type": "string" }, - "sent": { - "type": "integer" + "cc": { + "items": { + "format": "email", + "type": "string" + }, + "maxItems": 20, + "type": "array" }, - "window_days": { - "type": "integer" - } - }, - "required": [ - "window_days", - "scope", - "sent", - "delivered", - "bounced", - "complained", - "failed", - "bounce_rate", - "complaint_rate" - ], - "type": "object" - }, - "DeliverabilitySuppressionV1": { - "description": "Null unless the request named an `address`.", - "properties": { - "reason": { - "enum": [ - "HARD_BOUNCE", - "COMPLAINT", - "MANUAL", - "UNSUBSCRIBE", - null - ], - "type": [ - "string", - "null" - ] - }, - "source": { - "enum": [ - "SES_WEBHOOK", - "API", - "DASHBOARD", - null + "document": { + "description": "Brandable email rendered by React Email. Text is escaped; images/GIFs use public HTTPS URLs. Columns stack on phones. No HTML, scripts, or arbitrary CSS.", + "properties": { + "blocks": { + "items": { + "anyOf": [ + { + "properties": { + "align": { + "enum": [ + "left", + "center", + "right" + ], + "type": "string" + }, + "text": { + "maxLength": 300, + "minLength": 1, + "type": "string" + }, + "type": { + "enum": [ + "heading" + ], + "type": "string" + } + }, + "required": [ + "type", + "text" + ], + "type": "object" + }, + { + "properties": { + "align": { + "enum": [ + "left", + "center", + "right" + ], + "type": "string" + }, + "text": { + "maxLength": 10000, + "minLength": 1, + "type": "string" + }, + "type": { + "enum": [ + "text" + ], + "type": "string" + } + }, + "required": [ + "type", + "text" + ], + "type": "object" + }, + { + "properties": { + "alt": { + "maxLength": 300, + "minLength": 1, + "type": "string" + }, + "href": { + "description": "Public HTTPS URL", + "maxLength": 2000, + "pattern": "^https:\\/\\//i", + "type": "string" + }, + "src": { + "description": "Public HTTPS image or animated GIF URL; no upload or server fetch is performed", + "maxLength": 2000, + "pattern": "^https:\\/\\//i", + "type": "string" + }, + "type": { + "enum": [ + "image" + ], + "type": "string" + }, + "width": { + "maximum": 600, + "minimum": 24, + "type": "integer" + } + }, + "required": [ + "type", + "src", + "alt" + ], + "type": "object" + }, + { + "properties": { + "align": { + "enum": [ + "left", + "center", + "right" + ], + "type": "string" + }, + "href": { + "description": "Public HTTPS URL", + "maxLength": 2000, + "pattern": "^https:\\/\\//i", + "type": "string" + }, + "text": { + "maxLength": 100, + "minLength": 1, + "type": "string" + }, + "type": { + "enum": [ + "button" + ], + "type": "string" + } + }, + "required": [ + "type", + "text", + "href" + ], + "type": "object" + }, + { + "properties": { + "type": { + "enum": [ + "divider" + ], + "type": "string" + } + }, + "required": [ + "type" + ], + "type": "object" + }, + { + "properties": { + "columns": { + "items": { + "items": { + "oneOf": [ + { + "properties": { + "align": { + "enum": [ + "left", + "center", + "right" + ], + "type": "string" + }, + "text": { + "maxLength": 300, + "minLength": 1, + "type": "string" + }, + "type": { + "enum": [ + "heading" + ], + "type": "string" + } + }, + "required": [ + "type", + "text" + ], + "type": "object" + }, + { + "properties": { + "align": { + "enum": [ + "left", + "center", + "right" + ], + "type": "string" + }, + "text": { + "maxLength": 10000, + "minLength": 1, + "type": "string" + }, + "type": { + "enum": [ + "text" + ], + "type": "string" + } + }, + "required": [ + "type", + "text" + ], + "type": "object" + }, + { + "properties": { + "alt": { + "maxLength": 300, + "minLength": 1, + "type": "string" + }, + "href": { + "description": "Public HTTPS URL", + "maxLength": 2000, + "pattern": "^https:\\/\\//i", + "type": "string" + }, + "src": { + "description": "Public HTTPS image or animated GIF URL; no upload or server fetch is performed", + "maxLength": 2000, + "pattern": "^https:\\/\\//i", + "type": "string" + }, + "type": { + "enum": [ + "image" + ], + "type": "string" + }, + "width": { + "maximum": 600, + "minimum": 24, + "type": "integer" + } + }, + "required": [ + "type", + "src", + "alt" + ], + "type": "object" + }, + { + "properties": { + "align": { + "enum": [ + "left", + "center", + "right" + ], + "type": "string" + }, + "href": { + "description": "Public HTTPS URL", + "maxLength": 2000, + "pattern": "^https:\\/\\//i", + "type": "string" + }, + "text": { + "maxLength": 100, + "minLength": 1, + "type": "string" + }, + "type": { + "enum": [ + "button" + ], + "type": "string" + } + }, + "required": [ + "type", + "text", + "href" + ], + "type": "object" + }, + { + "properties": { + "type": { + "enum": [ + "divider" + ], + "type": "string" + } + }, + "required": [ + "type" + ], + "type": "object" + } + ] + }, + "maxItems": 8, + "minItems": 1, + "type": "array" + }, + "maxItems": 3, + "minItems": 2, + "type": "array" + }, + "type": { + "enum": [ + "columns" + ], + "type": "string" + } + }, + "required": [ + "type", + "columns" + ], + "type": "object" + } + ] + }, + "maxItems": 30, + "minItems": 1, + "type": "array" + }, + "brand": { + "properties": { + "accentColor": { + "description": "Six-digit hex color, e.g. #1454d4", + "pattern": "^#[\\da-f]{6}$/i", + "type": "string" + }, + "backgroundColor": { + "description": "Six-digit hex color, e.g. #1454d4", + "pattern": "^#[\\da-f]{6}$/i", + "type": "string" + }, + "buttonTextColor": { + "description": "Six-digit hex color, e.g. #1454d4", + "pattern": "^#[\\da-f]{6}$/i", + "type": "string" + }, + "font": { + "enum": [ + "sans", + "serif", + "mono" + ], + "type": "string" + }, + "logoUrl": { + "description": "Public HTTPS URL", + "maxLength": 2000, + "pattern": "^https:\\/\\//i", + "type": "string" + }, + "name": { + "maxLength": 100, + "minLength": 1, + "type": "string" + }, + "surfaceColor": { + "description": "Six-digit hex color, e.g. #1454d4", + "pattern": "^#[\\da-f]{6}$/i", + "type": "string" + }, + "textColor": { + "description": "Six-digit hex color, e.g. #1454d4", + "pattern": "^#[\\da-f]{6}$/i", + "type": "string" + }, + "websiteUrl": { + "description": "Public HTTPS URL", + "maxLength": 2000, + "pattern": "^https:\\/\\//i", + "type": "string" + } + }, + "required": [ + "name" + ], + "type": "object" + }, + "footer": { + "maxLength": 1000, + "type": "string" + }, + "preview": { + "description": "Inbox preview text", + "maxLength": 200, + "type": "string" + } + }, + "required": [ + "blocks" ], - "type": [ - "string", - "null" - ] + "type": "object" }, - "suppressed": { - "type": "boolean" + "subject": { + "maxLength": 200, + "minLength": 1, + "type": "string" }, - "suppressed_at": { - "format": "date-time", - "type": [ - "string", - "null" - ] + "to": { + "items": { + "format": "email", + "type": "string" + }, + "maxItems": 20, + "minItems": 1, + "type": "array" } }, "required": [ - "suppressed", - "reason", - "source", - "suppressed_at" + "to", + "subject" ], - "type": [ - "object", - "null" - ] + "type": "object" }, - "DmarcReportV1": { - "description": "One DMARC aggregate (RUA) report.", + "Contact": { + "description": "A subscriber/contact within a project.", "properties": { - "fail_count": { - "type": "integer" - }, - "id": { - "type": "string" - }, - "org_name": { - "description": "The reporting receiver, e.g. `google.com`.", + "createdAt": { + "description": "ISO 8601 datetime string", + "format": "date-time", "type": "string" }, - "pass_count": { - "description": "Messages DMARC-ALIGNED (SPF or DKIM aligned and passing), from `policy_evaluated`. Not the raw auth results: a message can pass SPF for a domain that is not the one in its From header, which is the case DMARC exists to catch.", - "type": "integer" + "customFields": { + "additionalProperties": {}, + "type": [ + "object", + "null" + ] }, - "policy_domain": { - "description": "The domain of yours the report is about.", + "email": { + "format": "email", "type": "string" }, - "range_begin": { - "format": "date-time", + "id": { + "format": "uuid", "type": "string" }, - "range_end": { - "format": "date-time", + "projectId": { + "format": "uuid", "type": "string" }, - "received_at": { - "format": "date-time", - "type": "string" + "subscribed": { + "type": "boolean" }, - "report_id": { - "description": "The receiver's own id for this report.", + "updatedAt": { + "description": "ISO 8601 datetime string", + "format": "date-time", "type": "string" + } + }, + "required": [ + "id", + "projectId", + "email", + "subscribed", + "createdAt", + "updatedAt" + ], + "type": "object" + }, + "ContactBulkCreateBody": { + "description": "Bulk create up to 1000 contacts.", + "properties": { + "contacts": { + "items": { + "$ref": "#/components/schemas/CreateContact" + }, + "maxItems": 1000, + "minItems": 1, + "type": "array" + } + }, + "required": [ + "contacts" + ], + "type": "object" + }, + "ContactBulkDeleteBody": { + "description": "Bulk delete contacts. Provide either `ids` or `emails` (max 1000 each).", + "properties": { + "emails": { + "items": { + "format": "email", + "type": "string" + }, + "maxItems": 1000, + "type": "array" }, - "sources": { - "description": "Per-sending-source rows, as the receiver reported them.", + "ids": { "items": { - "properties": { - "count": { - "type": "integer" + "format": "uuid", + "type": "string" + }, + "maxItems": 1000, + "type": "array" + } + }, + "type": "object" + }, + "ContactListResponse": { + "description": "Cursor-paginated list of contacts.", + "properties": { + "data": { + "properties": { + "data": { + "items": { + "$ref": "#/components/schemas/Contact" }, - "disposition": { + "type": "array" + }, + "hasMore": { + "type": "boolean" + }, + "nextCursor": { + "description": "Cursor for the next page, or null on the last page.", + "type": [ + "string", + "null" + ] + }, + "total": { + "type": "integer" + } + }, + "required": [ + "data", + "total", + "nextCursor", + "hasMore" + ], + "type": "object" + }, + "success": { + "enum": [ + true + ], + "type": "boolean" + } + }, + "required": [ + "success", + "data" + ], + "type": "object" + }, + "ContactTopicPreferencesV1": { + "description": "Everything this contact has said about what they want. `subscribed` on a topic already folds in `default_opt_in`, so a contact who has never answered still reads correctly.", + "properties": { + "contact_id": { + "type": "string" + }, + "subscribed": { + "description": "The global marketing opt-out, which OUTRANKS every topic. False means no marketing reaches this contact whatever the topics below say.", + "type": "boolean" + }, + "topics": { + "items": { + "properties": { + "key": { + "description": "Stable, URL-safe, unique within the project. This is what a preference form and an API caller name the topic by, so it must survive a rename of `name` — which is the whole reason it exists beside one.", + "maxLength": 64, + "minLength": 1, + "pattern": "^[a-z0-9][a-z0-9_-]*$", "type": "string" }, - "dkim": { + "name": { "type": "string" }, - "header_from": { - "type": "string" + "pending": { + "type": "boolean" }, - "source_ip": { - "type": "string" + "subscribed": { + "description": "The EFFECTIVE answer: what the send path concludes for this contact today.", + "type": "boolean" }, - "spf": { + "topic_id": { "type": "string" } }, "required": [ - "source_ip", - "count", - "disposition", - "dkim", - "spf", - "header_from" + "topic_id", + "key", + "name", + "subscribed", + "pending" ], "type": "object" }, "type": "array" - }, - "total_count": { - "type": "integer" } }, "required": [ - "id", - "report_id", - "org_name", - "policy_domain", - "range_begin", - "range_end", - "total_count", - "pass_count", - "fail_count", - "sources", - "received_at" + "contact_id", + "subscribed", + "topics" ], "type": "object" }, - "DmarcReportV1List": { - "description": "Cursor-paginated DMARC aggregate reports, newest window first.", + "ContactV1": { + "description": "A contact as exposed on the v1 API.", "properties": { - "data": { - "items": { - "$ref": "#/components/schemas/DmarcReportV1" - }, - "type": "array" - }, - "has_more": { - "type": "boolean" - }, - "intake_configured": { - "description": "Whether this deployment has a DMARC report intake mailbox configured. When `false` no report can ever arrive, so an empty `data` means the feature is off rather than that your domains are clean — the two are otherwise indistinguishable.", - "type": "boolean" + "created_at": { + "format": "date-time", + "type": "string" }, - "next_cursor": { - "description": "Pass as `after` to fetch the next page. `null` on the last page.", + "custom_fields": { + "additionalProperties": {}, + "description": "Arbitrary JSON value (string, number, boolean, null, array, or object).", "type": [ "string", + "number", + "boolean", + "object", + "array", "null" ] + }, + "email": { + "type": "string" + }, + "id": { + "format": "uuid", + "type": "string" + }, + "subscribed": { + "type": "boolean" + }, + "updated_at": { + "format": "date-time", + "type": "string" } }, "required": [ - "data", - "has_more", - "next_cursor", - "intake_configured" + "id", + "email", + "subscribed", + "custom_fields", + "created_at", + "updated_at" ], "type": "object" }, - "Domain": { - "description": "A sending identity: one domain registered with SES, with its own DKIM keys, its own MAIL FROM and its own reputation.", + "ContactV1Create": { + "description": "Body for POST /api/v1/contacts.", "properties": { - "createdAt": { - "description": "ISO 8601 datetime string", - "format": "date-time", - "type": "string" - }, - "defaultFromAddress": { - "description": "The address a send on this stream uses when it names none. Always on this identity's own host.", + "custom_fields": { + "additionalProperties": {}, + "description": "Arbitrary JSON stored on the contact and available to templates as `{{ variables }}`.", "type": [ "string", + "number", + "boolean", + "object", + "array", "null" ] }, - "dkimStatus": { - "description": "Result of the last DNS check for this record type.", + "email": { + "format": "email", + "type": "string" + }, + "subscribed": { + "default": true, + "type": "boolean" + } + }, + "required": [ + "email" + ], + "type": "object" + }, + "ContactV1Deleted": { + "description": "Acknowledgement that a contact was deleted.", + "properties": { + "deleted": { "enum": [ - "NOT_CHECKED", - "PENDING", - "VERIFIED", - "FAILED", - null + true ], - "type": [ - "string", - "null" - ] - }, - "dkimTokens": { - "description": "SES DKIM tokens to publish as CNAME records before the domain can verify.", - "items": { - "type": "string" - }, - "type": [ - "array", - "null" - ] - }, - "dmarcStatus": { - "description": "Result of the last DNS check for this record type.", - "enum": [ - "NOT_CHECKED", - "PENDING", - "VERIFIED", - "FAILED", - null - ], - "type": [ - "string", - "null" - ] - }, - "domain": { - "description": "The bare domain, e.g. `mail.acme.com`.", - "type": "string" + "type": "boolean" }, "id": { "format": "uuid", "type": "string" + } + }, + "required": [ + "id", + "deleted" + ], + "type": "object" + }, + "ContactV1List": { + "description": "Cursor-paginated list of contacts.", + "properties": { + "data": { + "items": { + "$ref": "#/components/schemas/ContactV1" + }, + "type": "array" }, - "lastHealthCheckAt": { - "description": "ISO 8601 datetime string", - "format": "date-time", - "type": [ - "string", - "null" - ] + "has_more": { + "type": "boolean" }, - "mailFromDomain": { - "description": "Custom MAIL FROM subdomain SES has on record (normally `sendly.`).", + "next_cursor": { + "description": "Pass as `after` to fetch the next page. `null` on the last page.", "type": [ "string", "null" ] - }, - "mailFromDomainStatus": { - "description": "SES custom MAIL FROM setup state. Only `Success` means SES is using it.", - "enum": [ - "Pending", - "Success", - "Failed", - "TemporaryFailure", - "NotConfigured", - null - ], + } + }, + "required": [ + "data", + "has_more", + "next_cursor" + ], + "type": "object" + }, + "ContactV1Update": { + "description": "Body for PATCH /api/v1/contacts/{id}. `email` is deliberately absent: an address is the contact's identity on this API, and changing it in place would silently rewrite what every earlier send was addressed to. Create the new address instead.", + "properties": { + "custom_fields": { + "additionalProperties": {}, + "description": "Arbitrary JSON value (string, number, boolean, null, array, or object).", "type": [ "string", + "number", + "boolean", + "object", + "array", "null" ] }, - "projectId": { - "format": "uuid", - "type": "string" - }, - "receivingEnabled": { - "description": "Whether inbound mail for this domain is routed to Sendly mailboxes.", + "subscribed": { "type": "boolean" - }, - "region": { + } + }, + "type": "object" + }, + "CreateApiKeyBody": { + "properties": { + "domainId": { + "format": "uuid", "type": [ "string", "null" ] }, - "spfStatus": { - "description": "Result of the last DNS check for this record type.", + "legacyGrantPreset": { "enum": [ - "NOT_CHECKED", - "PENDING", - "VERIFIED", - "FAILED", - null + "FULL", + "SENDING_ONLY" ], - "type": [ - "string", - "null" - ] + "type": "string" }, - "stream": { - "description": "Which traffic this identity carries. `null` means unassigned, and an unassigned identity carries every stream — the behaviour of every domain added before per-stream identities. A send whose stream does not match an ASSIGNED identity is refused.", + "mode": { + "description": "`LIVE` (default) or `TEST`. A test key can only send from the project's sandbox address, which accepts only the project's own verified account addresses as recipients, so it cannot reach a customer. Its sends are real sends down the same pipeline and are marked `sentInTestMode`. Fixed at creation.", "enum": [ - "TRANSACTIONAL", - "MARKETING", - null + "LIVE", + "TEST" ], - "type": [ - "string", - "null" - ] - }, - "streamDefault": { - "description": "Whether this is the project's default identity for its stream — the one a send picks when it names no from-address. At most one per (project, stream).", - "type": "boolean" + "type": "string" }, - "updatedAt": { - "description": "ISO 8601 datetime string", - "format": "date-time", + "name": { + "maxLength": 120, + "minLength": 1, "type": "string" }, - "verified": { - "type": "boolean" + "scopes": { + "description": "The explicit grant the new key will carry. Omitted ⇒ materialised from `legacyGrantPreset`. A `SENDING_ONLY` key may carry only `emails:send`.", + "items": { + "enum": [ + "emails:send", + "emails:read", + "contacts:read", + "contacts:write", + "campaigns:read", + "campaigns:write", + "segments:read", + "segments:write", + "workflows:read", + "workflows:write", + "templates:read", + "templates:write", + "domains:read", + "domains:write", + "webhooks:read", + "webhooks:write", + "suppression:read", + "suppression:write", + "analytics:read", + "usage:read", + "events:read", + "events:write", + "projects:read", + "projects:write", + "api-keys:read", + "api-keys:write", + "campaigns:send", + "mailboxes:read", + "mailboxes:write", + "emails:test", + "deliverability:read", + "mailboxes:send", + "validation:read", + "validation:write", + "topics:read", + "topics:write", + "lists:read", + "lists:write", + "mailbox-conversations:read", + "mailbox-conversations:reply" + ], + "type": "string" + }, + "type": "array" } }, "required": [ - "id", - "projectId", - "domain", - "verified", - "receivingEnabled", - "createdAt", - "updatedAt" + "name" ], "type": "object" }, - "DomainListResponse": { - "description": "List of all domains for the auth'd project.", + "CreateAppPassword": { + "description": "Body for POST /api/mailboxes/:id/app-passwords.", "properties": { - "data": { + "name": { + "maxLength": 60, + "minLength": 1, + "type": "string" + }, + "scopes": { + "default": [ + "imap", + "smtp" + ], "items": { - "$ref": "#/components/schemas/Domain" + "enum": [ + "imap", + "smtp" + ], + "type": "string" }, + "minItems": 1, "type": "array" - }, - "success": { - "enum": [ - true - ], - "type": "boolean" } }, "required": [ - "success", - "data" + "name" ], "type": "object" }, - "DomainV1": { - "description": "A sending domain as exposed on the v1 API.", + "CreateContact": { + "description": "Body for POST /api/contacts and /api/contacts/upsert.", "properties": { - "created_at": { - "format": "date-time", - "type": "string" - }, - "default_from_address": { - "type": [ - "string", - "null" - ] - }, - "dkim_verified": { - "type": "boolean" + "customFields": { + "additionalProperties": {}, + "description": "Arbitrary JSON value (string, number, boolean, null, array, or object).", + "type": "object" }, - "domain": { + "email": { + "format": "email", "type": "string" }, - "id": { - "format": "uuid", - "type": "string" - }, - "mail_from_domain": { - "type": [ - "string", - "null" - ] - }, - "mail_from_domain_status": { - "description": "SES's CustomMailFromStatus for `mail_from_domain` — the subdomain that carries the bounce path, NOT the status of any From address.", - "type": [ - "string", - "null" - ] - }, - "region": { - "type": [ - "string", - "null" - ] - }, - "stream": { - "allOf": [ - { - "$ref": "#/components/schemas/SendingStream" - }, - { - "description": "Which traffic a sending identity carries. An identity with no stream serves both, which is how every domain added before per-stream identities behaves.", - "type": [ - "string", - "null" - ] - } - ] - }, - "stream_default": { - "type": "boolean" - }, - "updated_at": { - "format": "date-time", - "type": "string" - }, - "verified": { + "subscribed": { + "default": true, "type": "boolean" } }, "required": [ - "id", - "domain", - "verified", - "region", - "stream", - "stream_default", - "default_from_address", - "mail_from_domain", - "mail_from_domain_status", - "dkim_verified", - "created_at", - "updated_at" + "email" ], "type": "object" }, - "DomainV1Create": { - "description": "Body for POST /api/v1/domains.", + "CreateMailboxBody": { "properties": { - "domain": { - "maxLength": 253, - "minLength": 3, + "displayName": { + "maxLength": 120, "type": "string" }, - "region": { - "description": "AWS SES region for the project. Once a domain is added the region is locked and cannot be changed.", - "enum": [ - "us-east-1", - "us-west-2", - "eu-west-1" - ], + "domainId": { + "description": "A VERIFIED domain belonging to this project.", + "format": "uuid", "type": "string" }, - "stream": { - "allOf": [ - { - "$ref": "#/components/schemas/SendingStream" - }, - { - "description": "Which traffic a sending identity carries. An identity with no stream serves both, which is how every domain added before per-stream identities behaves." - } - ] - }, - "stream_default": { - "description": "Make this the project's default identity for `stream`. Requires `stream`.", - "type": "boolean" - } - }, - "required": [ - "domain" - ], - "type": "object" - }, - "DomainV1Deleted": { - "description": "Acknowledgement that a sending domain was removed.", - "properties": { - "deleted": { - "enum": [ - true - ], - "type": "boolean" + "localPart": { + "description": "The part before the `@`, e.g. `support`. Lowercased server-side.", + "maxLength": 64, + "minLength": 1, + "type": "string" }, - "id": { + "projectId": { + "description": "Defaults to the project the credential resolves to. Naming a different one is refused.", "format": "uuid", "type": "string" + }, + "quotaBytes": { + "description": "NOT IMPLEMENTED — sending any value answers 400.", + "exclusiveMinimum": 0, + "type": "integer" } }, "required": [ - "id", - "deleted" + "domainId", + "localPart" ], "type": "object" }, - "DomainV1List": { - "description": "Cursor-paginated list of sending domains.", + "CreateSnippet": { + "description": "Body for POST /api/snippets.", "properties": { - "data": { - "items": { - "$ref": "#/components/schemas/DomainV1" - }, - "type": "array" - }, - "has_more": { - "type": "boolean" + "body": { + "maxLength": 20000, + "minLength": 1, + "type": "string" }, - "next_cursor": { - "description": "Pass as `after` to fetch the next page. `null` on the last page.", + "description": { + "maxLength": 500, "type": [ "string", "null" ] + }, + "name": { + "pattern": "^[a-z][\\da-z_-]{0,63}$/i", + "type": "string" } }, "required": [ - "data", - "has_more", - "next_cursor" + "name", + "body" ], "type": "object" }, - "DomainVerificationStatus": { - "description": "Outcome of a verification check against SES.", + "CreateTemplate": { + "description": "Body for POST /api/templates.", "properties": { - "dkimStatus": { - "enum": [ - "VERIFIED", - "PENDING", - "FAILED" - ], + "body": { + "minLength": 1, "type": "string" }, - "dmarcStatus": { + "description": { + "maxLength": 500, + "type": "string" + }, + "emailCategory": { + "default": "MARKETING", "enum": [ - "VERIFIED", - "FAILED", - "NOT_CHECKED" + "TRANSACTIONAL", + "MARKETING", + "SELF_MANAGED_UNSUBSCRIBE" ], "type": "string" }, - "domain": { + "from": { + "format": "email", "type": "string" }, - "mailFromDomain": { + "fromName": { + "maxLength": 100, "type": [ "string", "null" ] }, - "mailFromDomainStatus": { - "description": "SES custom MAIL FROM setup state. Only `Success` means SES is using it.", - "enum": [ - "Pending", - "Success", - "Failed", - "TemporaryFailure", - "NotConfigured", - null - ], + "name": { + "maxLength": 100, + "minLength": 1, + "type": "string" + }, + "replyTo": { + "format": "email", "type": [ "string", "null" ] }, - "spfStatus": { - "enum": [ - "VERIFIED", - "FAILED", - "NOT_CHECKED" - ], - "type": "string" - }, - "status": { - "description": "Raw SES DKIM verification status, e.g. `Success` or `Pending`.", + "subject": { + "minLength": 1, "type": "string" - }, - "tokens": { - "description": "DKIM tokens SES still has to report. Absent once verification has resolved.", + } + }, + "required": [ + "name", + "subject", + "body", + "from" + ], + "type": "object" + }, + "CreateWebhook": { + "description": "Body for POST /api/webhooks — register a user webhook for one or more events.", + "properties": { + "eventTypes": { "items": { + "enum": [ + "email.sent", + "email.delivered", + "email.opened", + "email.clicked", + "email.bounced", + "email.complained", + "email.failed", + "contact.created", + "contact.unsubscribed", + "contacts.bulk_created" + ], "type": "string" }, + "minItems": 1, "type": "array" }, - "verified": { - "type": "boolean" + "url": { + "format": "uri", + "type": "string" } }, "required": [ - "domain", - "status", - "verified", - "dkimStatus", - "spfStatus", - "dmarcStatus", - "mailFromDomain" + "url", + "eventTypes" ], "type": "object" }, - "DraftMailboxMessage": { - "description": "Body for POST /api/mailboxes/{id}/drafts — ask for help writing, never for sending.", + "DeliverabilityDiagnosisV1": { + "description": "A composed answer to why mail from one domain may not be arriving: its DNS identity, the project's recent delivery outcomes, one address's suppression state, and the findings drawn from them.", "properties": { - "brief": { - "maxLength": 4000, - "type": "string" + "address": { + "type": [ + "string", + "null" + ] }, - "draft": { - "maxLength": 20000, + "checked_at": { + "format": "date-time", "type": "string" }, - "instruction": { - "maxLength": 500, + "domain": { "type": "string" }, - "mode": { - "enum": [ - "draft", - "rewrite", - "subject" - ], - "type": "string" + "findings": { + "description": "What is wrong, worst first. An empty array means nothing here explains a delivery problem.", + "items": { + "$ref": "#/components/schemas/DeliverabilityFindingV1" + }, + "type": "array" }, - "recipientContext": { - "maxLength": 2000, + "identity": { + "$ref": "#/components/schemas/DeliverabilityIdentityV1" + }, + "recent_delivery": { + "$ref": "#/components/schemas/DeliverabilityRecentDeliveryV1" + }, + "suppression": { + "$ref": "#/components/schemas/DeliverabilitySuppressionV1" + } + }, + "required": [ + "domain", + "address", + "checked_at", + "identity", + "suppression", + "recent_delivery", + "findings" + ], + "type": "object" + }, + "DeliverabilityFindingSeverityV1": { + "description": "`blocking`: mail from this domain cannot be delivered as configured. `degraded`: it delivers, but inbox placement or sender reputation is at risk. `info`: worth knowing, nothing to fix.", + "enum": [ + "blocking", + "degraded", + "info" + ], + "type": "string" + }, + "DeliverabilityFindingV1": { + "description": "One diagnosed problem, with its fix.", + "properties": { + "code": { + "description": "Stable identifier for this finding, e.g. `domain_not_verified`. Branch on this, not on `summary`.", "type": "string" }, - "senderAddress": { - "maxLength": 320, + "remedy": { + "description": "What to do about it.", "type": "string" }, - "tone": { - "enum": [ - "friendly", - "neutral", - "formal", - "apologetic", - "direct" - ], + "severity": { + "$ref": "#/components/schemas/DeliverabilityFindingSeverityV1" + }, + "summary": { + "description": "What is wrong, in one sentence.", "type": "string" } }, "required": [ - "mode" + "code", + "severity", + "summary", + "remedy" ], "type": "object" }, - "Email": { - "description": "A sent (or queued) transactional email.", + "DeliverabilityIdentityV1": { + "description": "The sending identity's DNS health, as last refreshed.", "properties": { - "bouncedAt": { - "description": "Bounced, or null.", - "format": "date-time", + "dkim_status": { + "description": "DKIM signing. This is the one that decides whether Sendly will send from the domain at all.", + "enum": [ + "NOT_CHECKED", + "PENDING", + "VERIFIED", + "FAILED", + null + ], "type": [ "string", "null" ] }, - "clickedAt": { - "description": "First click, or null.", - "format": "date-time", + "dmarc_status": { + "description": "The DMARC policy published at `_dmarc.`.", + "enum": [ + "NOT_CHECKED", + "PENDING", + "VERIFIED", + "FAILED", + null + ], "type": [ "string", "null" ] }, - "clicks": { - "description": "Total clicks recorded.", - "type": "integer" - }, - "complainedAt": { - "description": "Spam complaint, or null.", + "last_checked_at": { + "description": "When the DNS refresh job last looked. These statuses are a CACHE, not a live lookup.", "format": "date-time", "type": [ "string", "null" ] }, - "createdAt": { - "description": "ISO 8601 datetime string", - "format": "date-time", - "type": "string" - }, - "deliveredAt": { - "description": "Accepted by the recipient's server, or null.", - "format": "date-time", + "mail_from_domain": { "type": [ "string", "null" ] }, - "error": { + "mail_from_domain_status": { + "description": "Raw SES `CustomMailFromStatus` (`Pending`/`Success`/`Failed`/`TemporaryFailure`), or `NotConfigured`. `Failed` means SES silently fell back to `amazonses.com`, which is why the value is reported rather than folded into a boolean.", "type": [ "string", "null" ] }, - "from": { - "type": "string" + "mx_status": { + "description": "Inbound receiving only. Null unless the domain has receiving enabled.", + "enum": [ + "NOT_CHECKED", + "PENDING", + "VERIFIED", + "FAILED", + null + ], + "type": [ + "string", + "null" + ] }, - "id": { - "format": "uuid", - "type": "string" + "registered": { + "description": "Whether this project has a domain record at all. False makes every other field null.", + "type": "boolean" }, - "openedAt": { - "description": "First open, or null.", - "format": "date-time", + "spf_status": { + "description": "SPF alignment for the sending identity.", + "enum": [ + "NOT_CHECKED", + "PENDING", + "VERIFIED", + "FAILED", + null + ], "type": [ "string", "null" ] }, - "opens": { - "description": "Total opens recorded.", + "verified": { + "type": "boolean" + } + }, + "required": [ + "registered", + "verified", + "dkim_status", + "spf_status", + "dmarc_status", + "mx_status", + "mail_from_domain", + "mail_from_domain_status", + "last_checked_at" + ], + "type": "object" + }, + "DeliverabilityRecentDeliveryV1": { + "description": "Delivery outcomes over the requested window.", + "properties": { + "bounce_rate": { + "description": "Bounced ÷ sent (0–1), or null when nothing was sent in the window.", + "type": [ + "number", + "null" + ] + }, + "bounced": { "type": "integer" }, - "projectId": { - "format": "uuid", - "type": "string" + "complained": { + "type": "integer" }, - "sentAt": { - "description": "Handed to the provider, or null.", - "format": "date-time", + "complaint_rate": { "type": [ - "string", + "number", "null" ] }, - "status": { - "$ref": "#/components/schemas/EmailDeliveryStatus" - }, - "subject": { - "type": "string" + "delivered": { + "type": "integer" }, - "tags": { - "items": { - "type": "string" - }, - "type": "array" + "failed": { + "type": "integer" }, - "to": { + "scope": { + "description": "PROJECT-WIDE, not per-domain, and said so rather than implied. `Email` has no sending-domain column, so narrowing these counters to one domain would mean a scan over every row the project has ever sent. A project that sends from one domain — most of them — can read these as that domain's.", + "enum": [ + "project" + ], "type": "string" }, - "updatedAt": { - "description": "ISO 8601 datetime string", - "format": "date-time", - "type": "string" + "sent": { + "type": "integer" + }, + "window_days": { + "type": "integer" } }, "required": [ - "id", - "projectId", - "from", - "to", - "subject", - "status", - "sentAt", - "deliveredAt", - "bouncedAt", - "openedAt", - "clickedAt", - "complainedAt", - "opens", - "clicks", - "tags", - "createdAt", - "updatedAt" + "window_days", + "scope", + "sent", + "delivered", + "bounced", + "complained", + "failed", + "bounce_rate", + "complaint_rate" ], "type": "object" }, - "EmailDeliveryStatus": { - "description": "Delivery lifecycle of the message. Engagement is reported separately.", - "enum": [ - "PENDING", - "SENDING", - "SENT", - "DELIVERED", - "RECEIVED", - "BOUNCED", - "FAILED", - "REJECTED", - "RENDERING_FAILURE", - "DELIVERY_DELAY", - "CANCELLED" - ], - "type": "string" - }, - "EmailDetailResponse": { - "description": "One email and its delivery history.", + "DeliverabilitySuppressionV1": { + "description": "Null unless the request named an `address`.", "properties": { - "data": { - "$ref": "#/components/schemas/EmailWithEvents" + "reason": { + "enum": [ + "HARD_BOUNCE", + "COMPLAINT", + "MANUAL", + "UNSUBSCRIBE", + null + ], + "type": [ + "string", + "null" + ] }, - "success": { + "source": { "enum": [ - true + "SES_WEBHOOK", + "API", + "DASHBOARD", + null ], + "type": [ + "string", + "null" + ] + }, + "suppressed": { "type": "boolean" + }, + "suppressed_at": { + "format": "date-time", + "type": [ + "string", + "null" + ] } }, "required": [ - "success", - "data" + "suppressed", + "reason", + "source", + "suppressed_at" ], - "type": "object" + "type": [ + "object", + "null" + ] }, - "EmailEvent": { - "description": "One transition in a message's delivery history.", + "DmarcReportV1": { + "description": "One DMARC aggregate (RUA) report.", "properties": { + "fail_count": { + "type": "integer" + }, "id": { - "format": "uuid", "type": "string" }, - "status": { - "$ref": "#/components/schemas/EmailDeliveryStatus" + "org_name": { + "description": "The reporting receiver, e.g. `google.com`.", + "type": "string" }, - "timestamp": { - "description": "ISO 8601 datetime string", + "pass_count": { + "description": "Messages DMARC-ALIGNED (SPF or DKIM aligned and passing), from `policy_evaluated`. Not the raw auth results: a message can pass SPF for a domain that is not the one in its From header, which is the case DMARC exists to catch.", + "type": "integer" + }, + "policy_domain": { + "description": "The domain of yours the report is about.", + "type": "string" + }, + "range_begin": { + "format": "date-time", + "type": "string" + }, + "range_end": { + "format": "date-time", + "type": "string" + }, + "received_at": { "format": "date-time", "type": "string" + }, + "report_id": { + "description": "The receiver's own id for this report.", + "type": "string" + }, + "sources": { + "description": "Per-sending-source rows, as the receiver reported them.", + "items": { + "properties": { + "count": { + "type": "integer" + }, + "disposition": { + "type": "string" + }, + "dkim": { + "type": "string" + }, + "header_from": { + "type": "string" + }, + "source_ip": { + "type": "string" + }, + "spf": { + "type": "string" + } + }, + "required": [ + "source_ip", + "count", + "disposition", + "dkim", + "spf", + "header_from" + ], + "type": "object" + }, + "type": "array" + }, + "total_count": { + "type": "integer" } }, "required": [ "id", - "status", - "timestamp" + "report_id", + "org_name", + "policy_domain", + "range_begin", + "range_end", + "total_count", + "pass_count", + "fail_count", + "sources", + "received_at" ], "type": "object" }, - "EmailListResponse": { - "description": "Cursor-paginated list of emails.", + "DmarcReportV1List": { + "description": "Cursor-paginated DMARC aggregate reports, newest window first.", "properties": { "data": { "items": { - "$ref": "#/components/schemas/Email" + "$ref": "#/components/schemas/DmarcReportV1" }, "type": "array" }, - "nextCursor": { + "has_more": { + "type": "boolean" + }, + "intake_configured": { + "description": "Whether this deployment has a DMARC report intake mailbox configured. When `false` no report can ever arrive, so an empty `data` means the feature is off rather than that your domains are clean — the two are otherwise indistinguishable.", + "type": "boolean" + }, + "next_cursor": { + "description": "Pass as `after` to fetch the next page. `null` on the last page.", "type": [ "string", "null" ] - }, - "success": { - "enum": [ - true - ], - "type": "boolean" - } - }, - "required": [ - "success", - "data" - ], - "type": "object" - }, - "EmailResponse": { - "description": "A single email.", - "properties": { - "data": { - "$ref": "#/components/schemas/Email" - }, - "success": { - "enum": [ - true - ], - "type": "boolean" } }, "required": [ - "success", - "data" + "data", + "has_more", + "next_cursor", + "intake_configured" ], "type": "object" }, - "EmailTestV1": { - "description": "Receipt for a sandbox test send.", + "Domain": { + "description": "A sending identity: one domain registered with SES, with its own DKIM keys, its own MAIL FROM and its own reputation.", "properties": { - "from": { - "description": "This project's sandbox sender — resolved server-side, never from the body.", - "format": "email", + "createdAt": { + "description": "ISO 8601 datetime string", + "format": "date-time", "type": "string" }, - "id": { - "description": "The Email row this send created.", - "format": "uuid", - "type": "string" + "defaultFromAddress": { + "description": "The address a send on this stream uses when it names none. Always on this identity's own host.", + "type": [ + "string", + "null" + ] }, - "sandbox": { - "description": "Always true. It is here so a model relaying this result cannot describe a test send as a real one: the message came from the shared sandbox domain and could only reach the project owner's own inbox.", - "enum": [ - true - ], - "type": "boolean" + "defaultFromName": { + "description": "The display name recipients see beside any address on this identity when a send names none, e.g. `Acme` in `Acme `. A send's own `name` and its template's from-name both win over it; `null` falls back to the project name.", + "type": [ + "string", + "null" + ] }, - "status": { - "description": "Delivery status at the moment of the response — `PENDING` for a send still queued.", + "dkimStatus": { + "description": "Result of the last DNS check for this record type.", "enum": [ + "NOT_CHECKED", "PENDING", - "SENDING", - "SENT", - "DELIVERED", - "RECEIVED", - "BOUNCED", + "VERIFIED", "FAILED", - "REJECTED", - "RENDERING_FAILURE", - "DELIVERY_DELAY", - "CANCELLED" + null ], - "type": "string" - }, - "to": { - "description": "The recipient the message was queued for.", - "format": "email", - "type": "string" - } - }, - "required": [ - "id", - "status", - "to", - "from", - "sandbox" - ], - "type": "object" - }, - "EmailV1": { - "description": "Receipt for a single transactional send.", - "properties": { - "from": { - "description": "The sender actually used. Worth reading rather than assuming: it is resolved server-side and may come from the template when the request named none.", - "format": "email", - "type": "string" + "type": [ + "string", + "null" + ] }, - "id": { - "description": "The Email row this send created. Quote it in support requests.", - "format": "uuid", - "type": "string" + "dkimTokens": { + "description": "SES DKIM tokens to publish as CNAME records before the domain can verify.", + "items": { + "type": "string" + }, + "type": [ + "array", + "null" + ] }, - "status": { - "description": "Delivery status at the moment of the response — `PENDING` for a send the worker has not picked up yet, which is the usual answer. Later states arrive via webhooks, not here.", + "dmarcStatus": { + "description": "Result of the last DNS check for this record type.", "enum": [ + "NOT_CHECKED", "PENDING", - "SENDING", - "SENT", - "DELIVERED", - "RECEIVED", - "BOUNCED", + "VERIFIED", "FAILED", - "REJECTED", - "RENDERING_FAILURE", - "DELIVERY_DELAY", - "CANCELLED" + null ], - "type": "string" + "type": [ + "string", + "null" + ] }, - "to": { - "description": "The recipient the message was queued for.", - "format": "email", + "domain": { + "description": "The bare domain, e.g. `mail.acme.com`.", "type": "string" - } - }, - "required": [ - "id", - "status", - "to", - "from" - ], - "type": "object" - }, - "EmailValidationBatchRequestV1": { - "properties": { - "emails": { - "description": "The addresses to check, at most 50. Every distinct DOMAIN costs a DNS round trip, so this endpoint is bounded by latency rather than payload size — validate a whole list with `POST /api/v1/lists/{id}/validation-runs`, which is a background job.", - "items": { - "format": "email", - "type": "string" - }, - "maxItems": 50, - "minItems": 1, - "type": "array" - } - }, - "required": [ - "emails" - ], - "type": "object" - }, - "EmailValidationBatchV1": { - "description": "One verdict per address, in the order they were given.", - "properties": { - "results": { - "items": { - "$ref": "#/components/schemas/EmailValidationV1" - }, - "type": "array" - } - }, - "required": [ - "results" - ], - "type": "object" - }, - "EmailValidationResultListV1": { - "description": "One page of a run's results. No total — a run over a large list holds millions of rows, and the run's own counters are the numbers worth reading.", - "properties": { - "data": { - "items": { - "allOf": [ - { - "$ref": "#/components/schemas/EmailValidationV1" - }, - { - "properties": { - "contact_id": { - "type": [ - "string", - "null" - ] - } - }, - "required": [ - "contact_id" - ], - "type": "object" - } - ], - "description": "One address's verdict, with the evidence behind it." - }, - "type": "array" }, - "has_more": { - "type": "boolean" + "id": { + "format": "uuid", + "type": "string" }, - "next_cursor": { - "description": "Pass as `after` to fetch the next page. `null` on the last page.", + "lastHealthCheckAt": { + "description": "ISO 8601 datetime string", + "format": "date-time", "type": [ "string", "null" ] - } - }, - "required": [ - "data", - "has_more", - "next_cursor" - ], - "type": "object" - }, - "EmailValidationRunV1": { - "description": "One bulk validation run over a list.", - "properties": { - "completed_at": { - "format": "date-time", + }, + "mailFromDomain": { + "description": "Custom MAIL FROM subdomain SES has on record (normally `sendly.`).", "type": [ "string", "null" ] }, - "created_at": { - "format": "date-time", - "type": "string" - }, - "deliverable_count": { - "type": "integer" - }, - "failure_reason": { - "description": "Set only on `failed`. Prose for an operator; never parse it.", + "mailFromDomainStatus": { + "description": "SES custom MAIL FROM setup state. Only `Success` means SES is using it.", + "enum": [ + "Pending", + "Success", + "Failed", + "TemporaryFailure", + "NotConfigured", + null + ], "type": [ "string", "null" ] }, - "id": { + "projectId": { + "format": "uuid", "type": "string" }, - "list_id": { + "receivingEnabled": { + "description": "Whether inbound mail for this domain is routed to Sendly mailboxes.", + "type": "boolean" + }, + "region": { "type": [ "string", "null" ] }, - "processed_count": { - "description": "Addresses checked so far. There is deliberately no total: a list changes size while a run walks it, so a denominator captured up front would be wrong by the time you read it.", - "type": "integer" - }, - "risky_count": { - "type": "integer" - }, - "started_at": { - "format": "date-time", + "spfStatus": { + "description": "Result of the last DNS check for this record type.", + "enum": [ + "NOT_CHECKED", + "PENDING", + "VERIFIED", + "FAILED", + null + ], "type": [ "string", "null" ] }, - "status": { + "stream": { + "description": "Which traffic this identity carries. `null` means unassigned, and an unassigned identity carries every stream — the behaviour of every domain added before per-stream identities. A send whose stream does not match an ASSIGNED identity is refused.", "enum": [ - "pending", - "running", - "completed", - "failed" + "TRANSACTIONAL", + "MARKETING", + null ], + "type": [ + "string", + "null" + ] + }, + "streamDefault": { + "description": "Whether this is the project's default identity for its stream — the one a send picks when it names no from-address. At most one per (project, stream).", + "type": "boolean" + }, + "updatedAt": { + "description": "ISO 8601 datetime string", + "format": "date-time", "type": "string" }, - "undeliverable_count": { - "type": "integer" + "verified": { + "type": "boolean" } }, "required": [ "id", - "list_id", - "status", - "processed_count", - "deliverable_count", - "undeliverable_count", - "risky_count", - "started_at", - "completed_at", - "failure_reason", - "created_at" + "projectId", + "domain", + "verified", + "receivingEnabled", + "createdAt", + "updatedAt" ], "type": "object" }, - "EmailValidationV1": { - "description": "One address's verdict, with the evidence behind it.", - "properties": { - "email": { - "type": "string" - }, - "has_mx_records": { - "description": "The domain publishes MX records.", - "type": "boolean" + "DomainListResponse": { + "description": "List of all domains for the auth'd project.", + "properties": { + "data": { + "items": { + "$ref": "#/components/schemas/Domain" + }, + "type": "array" }, - "is_disposable": { - "description": "A throwaway-inbox provider. The ONLY flag here that lowers the verdict.", + "success": { + "enum": [ + true + ], "type": "boolean" + } + }, + "required": [ + "success", + "data" + ], + "type": "object" + }, + "DomainLogoReadinessV1": { + "description": "How ready a sending domain is to show its logo as the inbox avatar, per step and per inbox. Backed by public DNS and the customer's own web hosts, so a reading is held for a minute.", + "properties": { + "checked_at": { + "description": "When the lookups ran. A reading is held for a minute, so this can be up to a minute old; a fix to DNS shows after that.", + "format": "date-time", + "type": "string" }, - "is_personal": { - "description": "A free/consumer provider (Gmail, Outlook). List-quality information, not a problem.", - "type": "boolean" + "domain": { + "type": "string" }, - "is_role_address": { - "description": "The local part addresses a role (`support@`, `info@`), not a person. List-quality information: role mailboxes are deliverable and companies answer them.", - "type": "boolean" + "domain_id": { + "format": "uuid", + "type": "string" }, - "reasons": { - "description": "Human-readable findings. Prose for a person to read — branch on `verdict`, never on these.", + "items": { + "description": "`dmarc_enforced`: DMARC at p=quarantine or p=reject, pct=100, on the sending host and on its registered domain. `bimi_record`: a `default._bimi` TXT record whose `l=` is an https URL. `logo_file`: the file at `l=` is SVG Tiny PS (the file Sendly serves is always checked as served). `mark_certificate`: the record's `a=` URL answers with a PEM certificate (VMC or CMC); only that is checked, not the certificate chain. `gmail_profile_photo` and `apple_branded_mail`: routes that need no BIMI record and are set up with the provider, so they are always `unknown` (or `not_applicable`) and never `met`.", "items": { - "type": "string" + "properties": { + "evidence": { + "description": "What was read (a record name and its value, a URL and what it answered), to compare with what you published.", + "type": [ + "string", + "null" + ] + }, + "key": { + "enum": [ + "dmarc_enforced", + "bimi_record", + "logo_file", + "mark_certificate", + "gmail_profile_photo", + "apple_branded_mail" + ], + "type": "string" + }, + "reason": { + "description": "Plain English. Null when `status` is `met`; otherwise what is wrong, or why it cannot be checked.", + "type": [ + "string", + "null" + ] + }, + "status": { + "description": "`met`: checked just now and satisfied. `missing`: checked just now and not satisfied; `reason` says what to change. `unknown`: could not be checked, either because a lookup failed (check again shortly) or because the step is self-attested with the mailbox provider and invisible to us; never a pass and never a failure. `not_applicable`: depends on a step that is not done yet, or is not needed given another.", + "enum": [ + "met", + "missing", + "unknown", + "not_applicable" + ], + "type": "string" + }, + "unlocks": { + "description": "The inboxes this step matters for.", + "items": { + "enum": [ + "gmail", + "yahoo", + "apple_mail", + "fastmail" + ], + "type": "string" + }, + "type": "array" + } + }, + "required": [ + "key", + "status", + "reason", + "unlocks", + "evidence" + ], + "type": "object" }, "type": "array" }, - "verdict": { - "$ref": "#/components/schemas/EmailValidationVerdictV1" + "providers": { + "description": "One checklist per inbox, in the order the steps should be done.", + "items": { + "properties": { + "checklist": { + "items": { + "properties": { + "item": { + "enum": [ + "dmarc_enforced", + "bimi_record", + "logo_file", + "mark_certificate", + "gmail_profile_photo", + "apple_branded_mail" + ], + "type": "string" + }, + "requirement": { + "description": "`required`: the inbox shows no logo without it. `optional`: helps or is accepted, and its absence does not block. `alternative`: a different route to a picture in that inbox that does not use the BIMI record.", + "enum": [ + "required", + "optional", + "alternative" + ], + "type": "string" + }, + "status": { + "enum": [ + "met", + "missing", + "unknown", + "not_applicable" + ], + "type": "string" + } + }, + "required": [ + "item", + "requirement", + "status" + ], + "type": "object" + }, + "type": "array" + }, + "inbox": { + "enum": [ + "gmail", + "yahoo", + "apple_mail", + "fastmail" + ], + "type": "string" + }, + "status": { + "description": "`ready`: every `required` item is `met`. `not_ready`: a required item is missing. `unknown`: none is missing but one could not be checked. Readiness is not a promise: providers decide whether to display a logo.", + "enum": [ + "ready", + "not_ready", + "unknown" + ], + "type": "string" + } + }, + "required": [ + "inbox", + "status", + "checklist" + ], + "type": "object" + }, + "type": "array" } }, "required": [ - "email", - "verdict", - "is_disposable", - "is_role_address", - "is_personal", - "has_mx_records", - "reasons" + "domain_id", + "domain", + "checked_at", + "items", + "providers" ], "type": "object" }, - "EmailValidationVerdictV1": { - "description": "`deliverable`: the domain resolves and accepts mail, with no badness signal. `undeliverable`: the domain does not exist or publishes no MX records. `risky`: deliverable, but a throwaway-inbox provider — mailing it costs sender reputation. `unknown`: DNS did not answer in time, so this address was NOT checked. Ask again; never delete a contact on `unknown`.", - "enum": [ - "deliverable", - "undeliverable", - "risky", - "unknown" - ], - "type": "string" - }, - "EmailWithEvents": { - "allOf": [ - { - "$ref": "#/components/schemas/Email" - }, - { + "DomainLogoV1": { + "description": "The logo mailboxes show beside mail from a sending identity, published through BIMI.", + "properties": { + "bimi_record": { + "description": "The DNS record that makes a mailbox fetch `url`. Publish it once.", "properties": { - "events": { - "description": "Delivery transitions for this message, oldest first. NOT the custom events recorded with `POST /api/v1/events` — those are a separate resource.", - "items": { - "$ref": "#/components/schemas/EmailEvent" - }, - "type": "array" + "name": { + "type": "string" + }, + "type": { + "enum": [ + "TXT" + ], + "type": "string" + }, + "value": { + "type": "string" } }, "required": [ - "events" + "type", + "name", + "value" ], "type": "object" - } - ], - "description": "A transactional email together with its delivery history." - }, - "Error": { - "description": "Standard error envelope returned by all 4xx/5xx responses. Migrated routes include `success: false`; 422 validation errors add `error.details.errors`.", - "properties": { - "error": { + }, + "domain_id": { + "format": "uuid", + "type": "string" + }, + "png_url": { + "description": "The same logo as a 1024x1024 PNG, for the places that take no SVG (a Google account's profile photo, Apple Branded Mail). Served under the same rules as `url`.", + "format": "uri", + "type": "string" + }, + "readiness": { + "description": "What public DNS says right now. A provider shows the logo only when DMARC is `enforced` (p=quarantine or p=reject at pct=100) and the record is `published`. `unknown` means the lookup failed, not that the record is absent. Providers decide whether to display a logo. Gmail additionally requires a VMC or CMC certificate, which this does not check. DNS readiness alone does not guarantee display.", "properties": { - "code": { - "type": "string" - }, - "details": { + "bimi_record": { "properties": { - "errors": { - "items": {}, - "type": "array" + "found_at": { + "type": [ + "string", + "null" + ] + }, + "location": { + "type": [ + "string", + "null" + ] + }, + "status": { + "enum": [ + "published", + "points_elsewhere", + "missing", + "unknown" + ], + "type": "string" } }, "required": [ - "errors" + "status", + "found_at", + "location" ], "type": "object" }, - "message": { - "type": "string" + "dmarc": { + "properties": { + "found_at": { + "type": [ + "string", + "null" + ] + }, + "pct": { + "type": [ + "integer", + "null" + ] + }, + "policy": { + "type": [ + "string", + "null" + ] + }, + "status": { + "enum": [ + "enforced", + "not_enforced", + "missing", + "unknown" + ], + "type": "string" + } + }, + "required": [ + "status", + "policy", + "pct", + "found_at" + ], + "type": "object" } }, "required": [ - "message", - "code" + "dmarc", + "bimi_record" ], "type": "object" }, - "success": { + "serving": { + "description": "Whether `url` answers today. It answers only once the identity has verified.", + "type": "boolean" + }, + "sha256": { + "type": "string" + }, + "size_bytes": { + "type": "integer" + }, + "source": { + "description": "`default` is a monogram of the domain's initial, served until a logo is uploaded and again after one is removed.", "enum": [ - false + "uploaded", + "default" ], - "type": "boolean" + "type": "string" + }, + "svg": { + "description": "The SVG Tiny PS file `url` serves.", + "type": "string" + }, + "updated_at": { + "description": "When the upload was stored; null for `default`.", + "format": "date-time", + "type": [ + "string", + "null" + ] + }, + "url": { + "description": "Where the logo is served. Stable for the life of the identity, so the BIMI record is published once and a new upload needs no DNS change.", + "format": "uri", + "type": "string" } }, "required": [ - "error" + "domain_id", + "source", + "url", + "serving", + "svg", + "sha256", + "size_bytes", + "updated_at", + "bimi_record", + "readiness" ], "type": "object" }, - "EventNamesV1": { - "description": "Every distinct event name in the project, most frequent first.", + "DomainLogoV1Upload": { + "description": "Body for PUT /api/v1/domains/{id}/logo.", "properties": { - "data": { - "items": { - "type": "string" - }, - "type": "array" + "svg": { + "description": "The logo as SVG source text, square, at most 64 KB. It is rebuilt into SVG Tiny PS — the profile BIMI requires — before it is stored; a file that cannot be (text not converted to outlines, a raster image, a