diff --git a/apps/docs/api-reference/emails/send-email.mdx b/apps/docs/api-reference/emails/send-email.mdx index 8875ee3..c806ae2 100644 --- a/apps/docs/api-reference/emails/send-email.mdx +++ b/apps/docs/api-reference/emails/send-email.mdx @@ -1,3 +1,5 @@ --- openapi: post /v1/emails --- + +Send a transactional email via the public API. diff --git a/apps/docs/api-reference/openapi.json b/apps/docs/api-reference/openapi.json index 5f88ed4..3e6610d 100644 --- a/apps/docs/api-reference/openapi.json +++ b/apps/docs/api-reference/openapi.json @@ -1,21 +1,9 @@ { "openapi": "3.0.0", - "info": { - "version": "1.0.0", - "title": "useSend API" - }, - "servers": [ - { - "url": "https://app.usesend.com/api" - } - ], + "info": { "version": "1.0.0", "title": "useSend API" }, + "servers": [{ "url": "https://app.usesend.com/api" }], "components": { - "securitySchemes": { - "Bearer": { - "type": "http", - "scheme": "bearer" - } - }, + "securitySchemes": { "Bearer": { "type": "http", "scheme": "bearer" } }, "schemas": {}, "parameters": {} }, @@ -57,59 +45,23 @@ "TEMPORARY_FAILURE" ] }, - "region": { - "type": "string", - "default": "us-east-1" - }, - "clickTracking": { - "type": "boolean", - "default": false - }, - "openTracking": { - "type": "boolean", - "default": false - }, - "publicKey": { - "type": "string" - }, - "dkimStatus": { - "type": "string", - "nullable": true - }, - "spfDetails": { - "type": "string", - "nullable": true - }, - "createdAt": { - "type": "string" - }, - "updatedAt": { - "type": "string" - }, - "dmarcAdded": { - "type": "boolean", - "default": false - }, - "isVerifying": { - "type": "boolean", - "default": false - }, - "errorMessage": { - "type": "string", - "nullable": true - }, - "subdomain": { - "type": "string", - "nullable": true - }, + "region": { "type": "string", "default": "us-east-1" }, + "clickTracking": { "type": "boolean", "default": false }, + "openTracking": { "type": "boolean", "default": false }, + "publicKey": { "type": "string" }, + "dkimStatus": { "type": "string", "nullable": true }, + "spfDetails": { "type": "string", "nullable": true }, + "createdAt": { "type": "string" }, + "updatedAt": { "type": "string" }, + "dmarcAdded": { "type": "boolean", "default": false }, + "isVerifying": { "type": "boolean", "default": false }, + "errorMessage": { "type": "string", "nullable": true }, + "subdomain": { "type": "string", "nullable": true }, "verificationError": { "type": "string", "nullable": true }, - "lastCheckedTime": { - "type": "string", - "nullable": true - }, + "lastCheckedTime": { "type": "string", "nullable": true }, "dnsRecords": { "type": "array", "items": { @@ -117,10 +69,7 @@ "properties": { "type": { "type": "string", - "enum": [ - "MX", - "TXT" - ], + "enum": ["MX", "TXT"], "description": "DNS record type", "example": "TXT" }, @@ -160,13 +109,7 @@ "description": "Whether the record is recommended" } }, - "required": [ - "type", - "name", - "value", - "ttl", - "status" - ] + "required": ["type", "name", "value", "ttl", "status"] } } }, @@ -195,17 +138,10 @@ "schema": { "type": "object", "properties": { - "name": { - "type": "string" - }, - "region": { - "type": "string" - } + "name": { "type": "string" }, + "region": { "type": "string" } }, - "required": [ - "name", - "region" - ] + "required": ["name", "region"] } } } @@ -243,59 +179,20 @@ "TEMPORARY_FAILURE" ] }, - "region": { - "type": "string", - "default": "us-east-1" - }, - "clickTracking": { - "type": "boolean", - "default": false - }, - "openTracking": { - "type": "boolean", - "default": false - }, - "publicKey": { - "type": "string" - }, - "dkimStatus": { - "type": "string", - "nullable": true - }, - "spfDetails": { - "type": "string", - "nullable": true - }, - "createdAt": { - "type": "string" - }, - "updatedAt": { - "type": "string" - }, - "dmarcAdded": { - "type": "boolean", - "default": false - }, - "isVerifying": { - "type": "boolean", - "default": false - }, - "errorMessage": { - "type": "string", - "nullable": true - }, - "subdomain": { - "type": "string", - "nullable": true - }, - "verificationError": { - "type": "string", - "nullable": true - }, - "lastCheckedTime": { - "type": "string", - "nullable": true - }, + "region": { "type": "string", "default": "us-east-1" }, + "clickTracking": { "type": "boolean", "default": false }, + "openTracking": { "type": "boolean", "default": false }, + "publicKey": { "type": "string" }, + "dkimStatus": { "type": "string", "nullable": true }, + "spfDetails": { "type": "string", "nullable": true }, + "createdAt": { "type": "string" }, + "updatedAt": { "type": "string" }, + "dmarcAdded": { "type": "boolean", "default": false }, + "isVerifying": { "type": "boolean", "default": false }, + "errorMessage": { "type": "string", "nullable": true }, + "subdomain": { "type": "string", "nullable": true }, + "verificationError": { "type": "string", "nullable": true }, + "lastCheckedTime": { "type": "string", "nullable": true }, "dnsRecords": { "type": "array", "items": { @@ -303,10 +200,7 @@ "properties": { "type": { "type": "string", - "enum": [ - "MX", - "TXT" - ], + "enum": ["MX", "TXT"], "description": "DNS record type", "example": "TXT" }, @@ -346,13 +240,7 @@ "description": "Whether the record is recommended" } }, - "required": [ - "type", - "name", - "value", - "ttl", - "status" - ] + "required": ["type", "name", "value", "ttl", "status"] } } }, @@ -377,11 +265,7 @@ "put": { "parameters": [ { - "schema": { - "type": "number", - "nullable": true, - "example": 1 - }, + "schema": { "type": "number", "nullable": true, "example": 1 }, "required": false, "name": "id", "in": "path" @@ -394,14 +278,8 @@ "application/json": { "schema": { "type": "object", - "properties": { - "message": { - "type": "string" - } - }, - "required": [ - "message" - ] + "properties": { "message": { "type": "string" } }, + "required": ["message"] } } } @@ -412,14 +290,8 @@ "application/json": { "schema": { "type": "object", - "properties": { - "error": { - "type": "string" - } - }, - "required": [ - "error" - ] + "properties": { "error": { "type": "string" } }, + "required": ["error"] } } } @@ -430,14 +302,8 @@ "application/json": { "schema": { "type": "object", - "properties": { - "error": { - "type": "string" - } - }, - "required": [ - "error" - ] + "properties": { "error": { "type": "string" } }, + "required": ["error"] } } } @@ -449,11 +315,7 @@ "get": { "parameters": [ { - "schema": { - "type": "number", - "nullable": true, - "example": 1 - }, + "schema": { "type": "number", "nullable": true, "example": 1 }, "required": false, "name": "id", "in": "path" @@ -492,59 +354,20 @@ "TEMPORARY_FAILURE" ] }, - "region": { - "type": "string", - "default": "us-east-1" - }, - "clickTracking": { - "type": "boolean", - "default": false - }, - "openTracking": { - "type": "boolean", - "default": false - }, - "publicKey": { - "type": "string" - }, - "dkimStatus": { - "type": "string", - "nullable": true - }, - "spfDetails": { - "type": "string", - "nullable": true - }, - "createdAt": { - "type": "string" - }, - "updatedAt": { - "type": "string" - }, - "dmarcAdded": { - "type": "boolean", - "default": false - }, - "isVerifying": { - "type": "boolean", - "default": false - }, - "errorMessage": { - "type": "string", - "nullable": true - }, - "subdomain": { - "type": "string", - "nullable": true - }, - "verificationError": { - "type": "string", - "nullable": true - }, - "lastCheckedTime": { - "type": "string", - "nullable": true - }, + "region": { "type": "string", "default": "us-east-1" }, + "clickTracking": { "type": "boolean", "default": false }, + "openTracking": { "type": "boolean", "default": false }, + "publicKey": { "type": "string" }, + "dkimStatus": { "type": "string", "nullable": true }, + "spfDetails": { "type": "string", "nullable": true }, + "createdAt": { "type": "string" }, + "updatedAt": { "type": "string" }, + "dmarcAdded": { "type": "boolean", "default": false }, + "isVerifying": { "type": "boolean", "default": false }, + "errorMessage": { "type": "string", "nullable": true }, + "subdomain": { "type": "string", "nullable": true }, + "verificationError": { "type": "string", "nullable": true }, + "lastCheckedTime": { "type": "string", "nullable": true }, "dnsRecords": { "type": "array", "items": { @@ -552,10 +375,7 @@ "properties": { "type": { "type": "string", - "enum": [ - "MX", - "TXT" - ], + "enum": ["MX", "TXT"], "description": "DNS record type", "example": "TXT" }, @@ -595,13 +415,7 @@ "description": "Whether the record is recommended" } }, - "required": [ - "type", - "name", - "value", - "ttl", - "status" - ] + "required": ["type", "name", "value", "ttl", "status"] } } }, @@ -624,11 +438,7 @@ "delete": { "parameters": [ { - "schema": { - "type": "number", - "nullable": true, - "example": 1 - }, + "schema": { "type": "number", "nullable": true, "example": 1 }, "required": false, "name": "id", "in": "path" @@ -642,21 +452,11 @@ "schema": { "type": "object", "properties": { - "id": { - "type": "number" - }, - "success": { - "type": "boolean" - }, - "message": { - "type": "string" - } + "id": { "type": "number" }, + "success": { "type": "boolean" }, + "message": { "type": "string" } }, - "required": [ - "id", - "success", - "message" - ] + "required": ["id", "success", "message"] } } } @@ -667,14 +467,8 @@ "application/json": { "schema": { "type": "object", - "properties": { - "error": { - "type": "string" - } - }, - "required": [ - "error" - ] + "properties": { "error": { "type": "string" } }, + "required": ["error"] } } } @@ -685,14 +479,8 @@ "application/json": { "schema": { "type": "object", - "properties": { - "error": { - "type": "string" - } - }, - "required": [ - "error" - ] + "properties": { "error": { "type": "string" } }, + "required": ["error"] } } } @@ -722,92 +510,44 @@ "schema": { "type": "object", "properties": { - "id": { - "type": "string" - }, - "teamId": { - "type": "number" - }, + "id": { "type": "string" }, + "teamId": { "type": "number" }, "to": { "anyOf": [ - { - "type": "string" - }, - { - "type": "array", - "items": { - "type": "string" - } - } + { "type": "string" }, + { "type": "array", "items": { "type": "string" } } ] }, "replyTo": { "anyOf": [ - { - "type": "string" - }, - { - "type": "array", - "items": { - "type": "string" - } - } + { "type": "string" }, + { "type": "array", "items": { "type": "string" } } ] }, "cc": { "anyOf": [ - { - "type": "string" - }, - { - "type": "array", - "items": { - "type": "string" - } - } + { "type": "string" }, + { "type": "array", "items": { "type": "string" } } ] }, "bcc": { "anyOf": [ - { - "type": "string" - }, - { - "type": "array", - "items": { - "type": "string" - } - } + { "type": "string" }, + { "type": "array", "items": { "type": "string" } } ] }, - "from": { - "type": "string" - }, - "subject": { - "type": "string" - }, - "html": { - "type": "string", - "nullable": true - }, - "text": { - "type": "string", - "nullable": true - }, - "createdAt": { - "type": "string" - }, - "updatedAt": { - "type": "string" - }, + "from": { "type": "string" }, + "subject": { "type": "string" }, + "html": { "type": "string", "nullable": true }, + "text": { "type": "string", "nullable": true }, + "createdAt": { "type": "string" }, + "updatedAt": { "type": "string" }, "emailEvents": { "type": "array", "items": { "type": "object", "properties": { - "emailId": { - "type": "string" - }, + "emailId": { "type": "string" }, "status": { "type": "string", "enum": [ @@ -827,18 +567,10 @@ "SUPPRESSED" ] }, - "createdAt": { - "type": "string" - }, - "data": { - "nullable": true - } + "createdAt": { "type": "string" }, + "data": { "nullable": true } }, - "required": [ - "emailId", - "status", - "createdAt" - ] + "required": ["emailId", "status", "createdAt"] } } }, @@ -880,14 +612,9 @@ "schema": { "type": "object", "properties": { - "scheduledAt": { - "type": "string", - "format": "date-time" - } + "scheduledAt": { "type": "string", "format": "date-time" } }, - "required": [ - "scheduledAt" - ] + "required": ["scheduledAt"] } } } @@ -899,11 +626,7 @@ "application/json": { "schema": { "type": "object", - "properties": { - "emailId": { - "type": "string" - } - } + "properties": { "emailId": { "type": "string" } } } } } @@ -915,21 +638,13 @@ "get": { "parameters": [ { - "schema": { - "type": "string", - "default": "1", - "example": "1" - }, + "schema": { "type": "string", "default": "1", "example": "1" }, "required": false, "name": "page", "in": "query" }, { - "schema": { - "type": "string", - "default": "50", - "example": "50" - }, + "schema": { "type": "string", "default": "50", "example": "50" }, "required": false, "name": "limit", "in": "query" @@ -957,15 +672,8 @@ { "schema": { "anyOf": [ - { - "type": "string" - }, - { - "type": "array", - "items": { - "type": "string" - } - } + { "type": "string" }, + { "type": "array", "items": { "type": "string" } } ], "example": "123" }, @@ -987,90 +695,49 @@ "items": { "type": "object", "properties": { - "id": { - "type": "string" - }, + "id": { "type": "string" }, "to": { "anyOf": [ - { - "type": "string" - }, - { - "type": "array", - "items": { - "type": "string" - } - } + { "type": "string" }, + { "type": "array", "items": { "type": "string" } } ] }, "replyTo": { "anyOf": [ - { - "type": "string" - }, + { "type": "string" }, { "type": "array", - "items": { - "type": "string" - } + "items": { "type": "string" } }, - { - "nullable": true - } + { "nullable": true } ] }, "cc": { "anyOf": [ - { - "type": "string" - }, + { "type": "string" }, { "type": "array", - "items": { - "type": "string" - } + "items": { "type": "string" } }, - { - "nullable": true - } + { "nullable": true } ] }, "bcc": { "anyOf": [ - { - "type": "string" - }, + { "type": "string" }, { "type": "array", - "items": { - "type": "string" - } + "items": { "type": "string" } }, - { - "nullable": true - } + { "nullable": true } ] }, - "from": { - "type": "string" - }, - "subject": { - "type": "string" - }, - "html": { - "type": "string", - "nullable": true - }, - "text": { - "type": "string", - "nullable": true - }, - "createdAt": { - "type": "string" - }, - "updatedAt": { - "type": "string" - }, + "from": { "type": "string" }, + "subject": { "type": "string" }, + "html": { "type": "string", "nullable": true }, + "text": { "type": "string", "nullable": true }, + "createdAt": { "type": "string" }, + "updatedAt": { "type": "string" }, "latestStatus": { "type": "string", "nullable": true, @@ -1096,10 +763,7 @@ "nullable": true, "format": "date-time" }, - "domainId": { - "type": "number", - "nullable": true - } + "domainId": { "type": "number", "nullable": true } }, "required": [ "id", @@ -1116,14 +780,9 @@ ] } }, - "count": { - "type": "number" - } + "count": { "type": "number" } }, - "required": [ - "data", - "count" - ] + "required": ["data", "count"] } } } @@ -1131,6 +790,19 @@ } }, "post": { + "parameters": [ + { + "schema": { + "type": "string", + "minLength": 1, + "maxLength": 256, + "description": "Pass the optional Idempotency-Key header to make the request safe to retry. The key can be up to 256 characters. The server stores the canonical request body and behaves as follows:\n\n- Same key + same request body → returns the original emailId with 200 OK without re-sending.\n- Same key + different request body → returns 409 Conflict with code: NOT_UNIQUE so you can detect the mismatch.\n- Same key while another request is still being processed → returns 409 Conflict; retry after a short delay or once the first request completes.\n\nEntries expire after 24 hours. Use a unique key per logical send (for example, an order or signup ID)." + }, + "required": false, + "name": "Idempotency-Key", + "in": "header" + } + ], "requestBody": { "required": true, "content": { @@ -1140,20 +812,11 @@ "properties": { "to": { "anyOf": [ - { - "type": "string" - }, - { - "type": "array", - "items": { - "type": "string" - } - } + { "type": "string" }, + { "type": "array", "items": { "type": "string" } } ] }, - "from": { - "type": "string" - }, + "from": { "type": "string" }, "subject": { "type": "string", "minLength": 1, @@ -1165,47 +828,24 @@ }, "variables": { "type": "object", - "additionalProperties": { - "type": "string" - } + "additionalProperties": { "type": "string" } }, "replyTo": { "anyOf": [ - { - "type": "string" - }, - { - "type": "array", - "items": { - "type": "string" - } - } + { "type": "string" }, + { "type": "array", "items": { "type": "string" } } ] }, "cc": { "anyOf": [ - { - "type": "string" - }, - { - "type": "array", - "items": { - "type": "string" - } - } + { "type": "string" }, + { "type": "array", "items": { "type": "string" } } ] }, "bcc": { "anyOf": [ - { - "type": "string" - }, - { - "type": "array", - "items": { - "type": "string" - } - } + { "type": "string" }, + { "type": "array", "items": { "type": "string" } } ] }, "text": { @@ -1231,35 +871,17 @@ "items": { "type": "object", "properties": { - "filename": { - "type": "string", - "minLength": 1 - }, - "content": { - "type": "string", - "minLength": 1 - } + "filename": { "type": "string", "minLength": 1 }, + "content": { "type": "string", "minLength": 1 } }, - "required": [ - "filename", - "content" - ] + "required": ["filename", "content"] }, "maxItems": 10 }, - "scheduledAt": { - "type": "string", - "format": "date-time" - }, - "inReplyToId": { - "type": "string", - "nullable": true - } + "scheduledAt": { "type": "string", "format": "date-time" }, + "inReplyToId": { "type": "string", "nullable": true } }, - "required": [ - "to", - "from" - ] + "required": ["to", "from"] } } } @@ -1271,11 +893,7 @@ "application/json": { "schema": { "type": "object", - "properties": { - "emailId": { - "type": "string" - } - } + "properties": { "emailId": { "type": "string" } } } } } @@ -1285,6 +903,19 @@ }, "/v1/emails/batch": { "post": { + "parameters": [ + { + "schema": { + "type": "string", + "minLength": 1, + "maxLength": 256, + "description": "Pass the optional Idempotency-Key header to make the request safe to retry. The key can be up to 256 characters. The server stores the canonical request body and behaves as follows:\n\n- Same key + same request body → returns the original emailId with 200 OK without re-sending.\n- Same key + different request body → returns 409 Conflict with code: NOT_UNIQUE so you can detect the mismatch.\n- Same key while another request is still being processed → returns 409 Conflict; retry after a short delay or once the first request completes.\n\nEntries expire after 24 hours. Use a unique key per logical send (for example, an order or signup ID)." + }, + "required": false, + "name": "Idempotency-Key", + "in": "header" + } + ], "requestBody": { "required": true, "content": { @@ -1296,20 +927,11 @@ "properties": { "to": { "anyOf": [ - { - "type": "string" - }, - { - "type": "array", - "items": { - "type": "string" - } - } + { "type": "string" }, + { "type": "array", "items": { "type": "string" } } ] }, - "from": { - "type": "string" - }, + "from": { "type": "string" }, "subject": { "type": "string", "minLength": 1, @@ -1321,47 +943,24 @@ }, "variables": { "type": "object", - "additionalProperties": { - "type": "string" - } + "additionalProperties": { "type": "string" } }, "replyTo": { "anyOf": [ - { - "type": "string" - }, - { - "type": "array", - "items": { - "type": "string" - } - } + { "type": "string" }, + { "type": "array", "items": { "type": "string" } } ] }, "cc": { "anyOf": [ - { - "type": "string" - }, - { - "type": "array", - "items": { - "type": "string" - } - } + { "type": "string" }, + { "type": "array", "items": { "type": "string" } } ] }, "bcc": { "anyOf": [ - { - "type": "string" - }, - { - "type": "array", - "items": { - "type": "string" - } - } + { "type": "string" }, + { "type": "array", "items": { "type": "string" } } ] }, "text": { @@ -1387,35 +986,17 @@ "items": { "type": "object", "properties": { - "filename": { - "type": "string", - "minLength": 1 - }, - "content": { - "type": "string", - "minLength": 1 - } + "filename": { "type": "string", "minLength": 1 }, + "content": { "type": "string", "minLength": 1 } }, - "required": [ - "filename", - "content" - ] + "required": ["filename", "content"] }, "maxItems": 10 }, - "scheduledAt": { - "type": "string", - "format": "date-time" - }, - "inReplyToId": { - "type": "string", - "nullable": true - } + "scheduledAt": { "type": "string", "format": "date-time" }, + "inReplyToId": { "type": "string", "nullable": true } }, - "required": [ - "to", - "from" - ] + "required": ["to", "from"] }, "maxItems": 100 } @@ -1434,20 +1015,12 @@ "type": "array", "items": { "type": "object", - "properties": { - "emailId": { - "type": "string" - } - }, - "required": [ - "emailId" - ] + "properties": { "emailId": { "type": "string" } }, + "required": ["emailId"] } } }, - "required": [ - "data" - ] + "required": ["data"] } } } @@ -1476,11 +1049,7 @@ "application/json": { "schema": { "type": "object", - "properties": { - "emailId": { - "type": "string" - } - } + "properties": { "emailId": { "type": "string" } } } } } @@ -1509,28 +1078,16 @@ "schema": { "type": "object", "properties": { - "email": { - "type": "string" - }, - "firstName": { - "type": "string" - }, - "lastName": { - "type": "string" - }, + "email": { "type": "string" }, + "firstName": { "type": "string" }, + "lastName": { "type": "string" }, "properties": { "type": "object", - "additionalProperties": { - "type": "string" - } + "additionalProperties": { "type": "string" } }, - "subscribed": { - "type": "boolean" - } + "subscribed": { "type": "boolean" } }, - "required": [ - "email" - ] + "required": ["email"] } } } @@ -1542,11 +1099,7 @@ "application/json": { "schema": { "type": "object", - "properties": { - "contactId": { - "type": "string" - } - } + "properties": { "contactId": { "type": "string" } } } } } @@ -1556,42 +1109,31 @@ "get": { "parameters": [ { - "schema": { - "type": "string", - "example": "cuiwqdj74rygf74" - }, + "schema": { "type": "string", "example": "cuiwqdj74rygf74" }, "required": true, "name": "contactBookId", "in": "path" }, { - "schema": { - "type": "string" - }, + "schema": { "type": "string" }, "required": false, "name": "emails", "in": "query" }, { - "schema": { - "type": "number" - }, + "schema": { "type": "number" }, "required": false, "name": "page", "in": "query" }, { - "schema": { - "type": "number" - }, + "schema": { "type": "number" }, "required": false, "name": "limit", "in": "query" }, { - "schema": { - "type": "string" - }, + "schema": { "type": "string" }, "required": false, "name": "ids", "in": "query" @@ -1607,39 +1149,18 @@ "items": { "type": "object", "properties": { - "id": { - "type": "string" - }, - "firstName": { - "type": "string", - "nullable": true - }, - "lastName": { - "type": "string", - "nullable": true - }, - "email": { - "type": "string" - }, - "subscribed": { - "type": "boolean", - "default": true - }, + "id": { "type": "string" }, + "firstName": { "type": "string", "nullable": true }, + "lastName": { "type": "string", "nullable": true }, + "email": { "type": "string" }, + "subscribed": { "type": "boolean", "default": true }, "properties": { "type": "object", - "additionalProperties": { - "type": "string" - } + "additionalProperties": { "type": "string" } }, - "contactBookId": { - "type": "string" - }, - "createdAt": { - "type": "string" - }, - "updatedAt": { - "type": "string" - } + "contactBookId": { "type": "string" }, + "createdAt": { "type": "string" }, + "updatedAt": { "type": "string" } }, "required": [ "id", @@ -1661,19 +1182,13 @@ "patch": { "parameters": [ { - "schema": { - "type": "string", - "example": "cuiwqdj74rygf74" - }, + "schema": { "type": "string", "example": "cuiwqdj74rygf74" }, "required": true, "name": "contactBookId", "in": "path" }, { - "schema": { - "type": "string", - "example": "cuiwqdj74rygf74" - }, + "schema": { "type": "string", "example": "cuiwqdj74rygf74" }, "required": true, "name": "contactId", "in": "path" @@ -1686,21 +1201,13 @@ "schema": { "type": "object", "properties": { - "firstName": { - "type": "string" - }, - "lastName": { - "type": "string" - }, + "firstName": { "type": "string" }, + "lastName": { "type": "string" }, "properties": { "type": "object", - "additionalProperties": { - "type": "string" - } + "additionalProperties": { "type": "string" } }, - "subscribed": { - "type": "boolean" - } + "subscribed": { "type": "boolean" } } } } @@ -1713,11 +1220,7 @@ "application/json": { "schema": { "type": "object", - "properties": { - "contactId": { - "type": "string" - } - } + "properties": { "contactId": { "type": "string" } } } } } @@ -1727,19 +1230,13 @@ "get": { "parameters": [ { - "schema": { - "type": "string", - "example": "cuiwqdj74rygf74" - }, + "schema": { "type": "string", "example": "cuiwqdj74rygf74" }, "required": true, "name": "contactBookId", "in": "path" }, { - "schema": { - "type": "string", - "example": "cuiwqdj74rygf74" - }, + "schema": { "type": "string", "example": "cuiwqdj74rygf74" }, "required": true, "name": "contactId", "in": "path" @@ -1753,39 +1250,18 @@ "schema": { "type": "object", "properties": { - "id": { - "type": "string" - }, - "firstName": { - "type": "string", - "nullable": true - }, - "lastName": { - "type": "string", - "nullable": true - }, - "email": { - "type": "string" - }, - "subscribed": { - "type": "boolean", - "default": true - }, + "id": { "type": "string" }, + "firstName": { "type": "string", "nullable": true }, + "lastName": { "type": "string", "nullable": true }, + "email": { "type": "string" }, + "subscribed": { "type": "boolean", "default": true }, "properties": { "type": "object", - "additionalProperties": { - "type": "string" - } + "additionalProperties": { "type": "string" } }, - "contactBookId": { - "type": "string" - }, - "createdAt": { - "type": "string" - }, - "updatedAt": { - "type": "string" - } + "contactBookId": { "type": "string" }, + "createdAt": { "type": "string" }, + "updatedAt": { "type": "string" } }, "required": [ "id", @@ -1821,28 +1297,16 @@ "schema": { "type": "object", "properties": { - "email": { - "type": "string" - }, - "firstName": { - "type": "string" - }, - "lastName": { - "type": "string" - }, + "email": { "type": "string" }, + "firstName": { "type": "string" }, + "lastName": { "type": "string" }, "properties": { "type": "object", - "additionalProperties": { - "type": "string" - } + "additionalProperties": { "type": "string" } }, - "subscribed": { - "type": "boolean" - } + "subscribed": { "type": "boolean" } }, - "required": [ - "email" - ] + "required": ["email"] } } } @@ -1854,14 +1318,8 @@ "application/json": { "schema": { "type": "object", - "properties": { - "contactId": { - "type": "string" - } - }, - "required": [ - "contactId" - ] + "properties": { "contactId": { "type": "string" } }, + "required": ["contactId"] } } } @@ -1871,19 +1329,13 @@ "delete": { "parameters": [ { - "schema": { - "type": "string", - "example": "cuiwqdj74rygf74" - }, + "schema": { "type": "string", "example": "cuiwqdj74rygf74" }, "required": true, "name": "contactBookId", "in": "path" }, { - "schema": { - "type": "string", - "example": "cuiwqdj74rygf74" - }, + "schema": { "type": "string", "example": "cuiwqdj74rygf74" }, "required": true, "name": "contactId", "in": "path" @@ -1896,14 +1348,8 @@ "application/json": { "schema": { "type": "object", - "properties": { - "success": { - "type": "boolean" - } - }, - "required": [ - "success" - ] + "properties": { "success": { "type": "boolean" } }, + "required": ["success"] } } } @@ -1920,81 +1366,41 @@ "schema": { "type": "object", "properties": { - "name": { - "type": "string", - "minLength": 1 - }, - "from": { - "type": "string", - "minLength": 1 - }, - "subject": { - "type": "string", - "minLength": 1 - }, - "previewText": { - "type": "string" - }, - "contactBookId": { - "type": "string", - "minLength": 1 - }, - "content": { - "type": "string", - "minLength": 1 - }, - "html": { - "type": "string", - "minLength": 1 - }, + "name": { "type": "string", "minLength": 1 }, + "from": { "type": "string", "minLength": 1 }, + "subject": { "type": "string", "minLength": 1 }, + "previewText": { "type": "string" }, + "contactBookId": { "type": "string", "minLength": 1 }, + "content": { "type": "string", "minLength": 1 }, + "html": { "type": "string", "minLength": 1 }, "replyTo": { "anyOf": [ - { - "type": "string", - "minLength": 1 - }, + { "type": "string", "minLength": 1 }, { "type": "array", - "items": { - "type": "string", - "minLength": 1 - } + "items": { "type": "string", "minLength": 1 } } ] }, "cc": { "anyOf": [ - { - "type": "string", - "minLength": 1 - }, + { "type": "string", "minLength": 1 }, { "type": "array", - "items": { - "type": "string", - "minLength": 1 - } + "items": { "type": "string", "minLength": 1 } } ] }, "bcc": { "anyOf": [ - { - "type": "string", - "minLength": 1 - }, + { "type": "string", "minLength": 1 }, { "type": "array", - "items": { - "type": "string", - "minLength": 1 - } + "items": { "type": "string", "minLength": 1 } } ] }, - "sendNow": { - "type": "boolean" - }, + "sendNow": { "type": "boolean" }, "scheduledAt": { "type": "string", "description": "Timestamp in ISO 8601 format or natural language (e.g., 'tomorrow 9am', 'next monday 10:30')" @@ -2005,12 +1411,7 @@ "maximum": 100000 } }, - "required": [ - "name", - "from", - "subject", - "contactBookId" - ] + "required": ["name", "from", "subject", "contactBookId"] } } } @@ -2023,101 +1424,39 @@ "schema": { "type": "object", "properties": { - "id": { - "type": "string" - }, - "name": { - "type": "string" - }, - "from": { - "type": "string" - }, - "subject": { - "type": "string" - }, - "previewText": { - "type": "string", - "nullable": true - }, - "contactBookId": { - "type": "string", - "nullable": true - }, - "html": { - "type": "string", - "nullable": true - }, - "content": { - "type": "string", - "nullable": true - }, - "status": { - "type": "string" - }, + "id": { "type": "string" }, + "name": { "type": "string" }, + "from": { "type": "string" }, + "subject": { "type": "string" }, + "previewText": { "type": "string", "nullable": true }, + "contactBookId": { "type": "string", "nullable": true }, + "html": { "type": "string", "nullable": true }, + "content": { "type": "string", "nullable": true }, + "status": { "type": "string" }, "scheduledAt": { "type": "string", "nullable": true, "format": "date-time" }, - "batchSize": { - "type": "integer" - }, - "batchWindowMinutes": { - "type": "integer" - }, - "total": { - "type": "integer" - }, - "sent": { - "type": "integer" - }, - "delivered": { - "type": "integer" - }, - "opened": { - "type": "integer" - }, - "clicked": { - "type": "integer" - }, - "unsubscribed": { - "type": "integer" - }, - "bounced": { - "type": "integer" - }, - "hardBounced": { - "type": "integer" - }, - "complained": { - "type": "integer" - }, + "batchSize": { "type": "integer" }, + "batchWindowMinutes": { "type": "integer" }, + "total": { "type": "integer" }, + "sent": { "type": "integer" }, + "delivered": { "type": "integer" }, + "opened": { "type": "integer" }, + "clicked": { "type": "integer" }, + "unsubscribed": { "type": "integer" }, + "bounced": { "type": "integer" }, + "hardBounced": { "type": "integer" }, + "complained": { "type": "integer" }, "replyTo": { "type": "array", - "items": { - "type": "string" - } + "items": { "type": "string" } }, - "cc": { - "type": "array", - "items": { - "type": "string" - } - }, - "bcc": { - "type": "array", - "items": { - "type": "string" - } - }, - "createdAt": { - "type": "string", - "format": "date-time" - }, - "updatedAt": { - "type": "string", - "format": "date-time" - } + "cc": { "type": "array", "items": { "type": "string" } }, + "bcc": { "type": "array", "items": { "type": "string" } }, + "createdAt": { "type": "string", "format": "date-time" }, + "updatedAt": { "type": "string", "format": "date-time" } }, "required": [ "id", @@ -2176,101 +1515,39 @@ "schema": { "type": "object", "properties": { - "id": { - "type": "string" - }, - "name": { - "type": "string" - }, - "from": { - "type": "string" - }, - "subject": { - "type": "string" - }, - "previewText": { - "type": "string", - "nullable": true - }, - "contactBookId": { - "type": "string", - "nullable": true - }, - "html": { - "type": "string", - "nullable": true - }, - "content": { - "type": "string", - "nullable": true - }, - "status": { - "type": "string" - }, + "id": { "type": "string" }, + "name": { "type": "string" }, + "from": { "type": "string" }, + "subject": { "type": "string" }, + "previewText": { "type": "string", "nullable": true }, + "contactBookId": { "type": "string", "nullable": true }, + "html": { "type": "string", "nullable": true }, + "content": { "type": "string", "nullable": true }, + "status": { "type": "string" }, "scheduledAt": { "type": "string", "nullable": true, "format": "date-time" }, - "batchSize": { - "type": "integer" - }, - "batchWindowMinutes": { - "type": "integer" - }, - "total": { - "type": "integer" - }, - "sent": { - "type": "integer" - }, - "delivered": { - "type": "integer" - }, - "opened": { - "type": "integer" - }, - "clicked": { - "type": "integer" - }, - "unsubscribed": { - "type": "integer" - }, - "bounced": { - "type": "integer" - }, - "hardBounced": { - "type": "integer" - }, - "complained": { - "type": "integer" - }, + "batchSize": { "type": "integer" }, + "batchWindowMinutes": { "type": "integer" }, + "total": { "type": "integer" }, + "sent": { "type": "integer" }, + "delivered": { "type": "integer" }, + "opened": { "type": "integer" }, + "clicked": { "type": "integer" }, + "unsubscribed": { "type": "integer" }, + "bounced": { "type": "integer" }, + "hardBounced": { "type": "integer" }, + "complained": { "type": "integer" }, "replyTo": { "type": "array", - "items": { - "type": "string" - } + "items": { "type": "string" } }, - "cc": { - "type": "array", - "items": { - "type": "string" - } - }, - "bcc": { - "type": "array", - "items": { - "type": "string" - } - }, - "createdAt": { - "type": "string", - "format": "date-time" - }, - "updatedAt": { - "type": "string", - "format": "date-time" - } + "cc": { "type": "array", "items": { "type": "string" } }, + "bcc": { "type": "array", "items": { "type": "string" } }, + "createdAt": { "type": "string", "format": "date-time" }, + "updatedAt": { "type": "string", "format": "date-time" } }, "required": [ "id", @@ -2349,14 +1626,8 @@ "application/json": { "schema": { "type": "object", - "properties": { - "success": { - "type": "boolean" - } - }, - "required": [ - "success" - ] + "properties": { "success": { "type": "boolean" } }, + "required": ["success"] } } } @@ -2385,14 +1656,8 @@ "application/json": { "schema": { "type": "object", - "properties": { - "success": { - "type": "boolean" - } - }, - "required": [ - "success" - ] + "properties": { "success": { "type": "boolean" } }, + "required": ["success"] } } } @@ -2421,14 +1686,8 @@ "application/json": { "schema": { "type": "object", - "properties": { - "success": { - "type": "boolean" - } - }, - "required": [ - "success" - ] + "properties": { "success": { "type": "boolean" } }, + "required": ["success"] } } } @@ -2437,4 +1696,4 @@ } } } -} \ No newline at end of file +} diff --git a/apps/web/src/server/public-api/api/emails/batch-email.ts b/apps/web/src/server/public-api/api/emails/batch-email.ts index 422c274..977e4bd 100644 --- a/apps/web/src/server/public-api/api/emails/batch-email.ts +++ b/apps/web/src/server/public-api/api/emails/batch-email.ts @@ -1,9 +1,9 @@ import { createRoute, z } from "@hono/zod-openapi"; import { PublicAPIApp } from "~/server/public-api/hono"; -import { getTeamFromToken } from "~/server/public-api/auth"; import { sendBulkEmails } from "~/server/service/email-service"; import { EmailContent } from "~/types"; -import { emailSchema } from "../../schemas/email-schema"; // Corrected import path +import { emailSchema } from "../../schemas/email-schema"; +import { IdempotencyService } from "~/server/service/idempotency-service"; // Define the schema for a single email within the bulk request // This is similar to the schema in send-email.ts but without the top-level 'required' @@ -13,6 +13,24 @@ const route = createRoute({ method: "post", path: "/v1/emails/batch", request: { + headers: z + .object({ + "Idempotency-Key": z + .string() + .min(1) + .max(256) + .optional() + .openapi({ + description: `Pass the optional Idempotency-Key header to make the request safe to retry. The key can be up to 256 characters. The server stores the canonical request body and behaves as follows: + +- Same key + same request body → returns the original emailId with 200 OK without re-sending. +- Same key + different request body → returns 409 Conflict with code: NOT_UNIQUE so you can detect the mismatch. +- Same key while another request is still being processed → returns 409 Conflict; retry after a short delay or once the first request completes. + +Entries expire after 24 hours. Use a unique key per logical send (for example, an order or signup ID).`, + }), + }) + .partial(), body: { required: true, content: { @@ -47,27 +65,41 @@ function sendBatch(app: PublicAPIApp) { const team = c.var.team; const emailPayloads = c.req.valid("json"); - // Add teamId and apiKeyId to each email payload - const emailsToSend: Array< - EmailContent & { teamId: number; apiKeyId?: number } - > = emailPayloads.map((payload) => ({ + const normalizedPayloads = emailPayloads.map((payload) => ({ ...payload, text: payload.text ?? undefined, html: payload.html && payload.html !== "true" && payload.html !== "false" ? payload.html : undefined, + })); + + const idemKey = c.req.header("Idempotency-Key") ?? undefined; + + const responseData = await IdempotencyService.withIdempotency({ teamId: team.id, - apiKeyId: team.apiKeyId, - })); + idemKey, + payload: normalizedPayloads, + operation: async () => { + const emailsToSend: Array< + EmailContent & { teamId: number; apiKeyId?: number } + > = normalizedPayloads.map((payload) => ({ + ...payload, + teamId: team.id, + apiKeyId: team.apiKeyId, + })); - // Call the service function to send emails in bulk - const createdEmails = await sendBulkEmails(emailsToSend); + const createdEmails = await sendBulkEmails(emailsToSend); - // Map the result to the response format - const responseData = createdEmails.map((email) => ({ - emailId: email.id, - })); + return createdEmails.map((email) => ({ + emailId: email.id, + })); + }, + extractEmailIds: (data) => data.map((item) => item.emailId), + formatCachedResponse: (emailIds) => + emailIds.map((id) => ({ emailId: id })), + logContext: "bulk email send", + }); return c.json({ data: responseData }); }); diff --git a/apps/web/src/server/public-api/api/emails/send-email.ts b/apps/web/src/server/public-api/api/emails/send-email.ts index 5a6164c..58df846 100644 --- a/apps/web/src/server/public-api/api/emails/send-email.ts +++ b/apps/web/src/server/public-api/api/emails/send-email.ts @@ -2,11 +2,30 @@ import { createRoute, z } from "@hono/zod-openapi"; import { PublicAPIApp } from "~/server/public-api/hono"; import { sendEmail } from "~/server/service/email-service"; import { emailSchema } from "../../schemas/email-schema"; +import { IdempotencyService } from "~/server/service/idempotency-service"; const route = createRoute({ method: "post", path: "/v1/emails", request: { + headers: z + .object({ + "Idempotency-Key": z + .string() + .min(1) + .max(256) + .optional() + .openapi({ + description: `Pass the optional Idempotency-Key header to make the request safe to retry. The key can be up to 256 characters. The server stores the canonical request body and behaves as follows: + +- Same key + same request body → returns the original emailId with 200 OK without re-sending. +- Same key + different request body → returns 409 Conflict with code: NOT_UNIQUE so you can detect the mismatch. +- Same key while another request is still being processed → returns 409 Conflict; retry after a short delay or once the first request completes. + +Entries expire after 24 hours. Use a unique key per logical send (for example, an order or signup ID).`, + }), + }) + .partial(), body: { required: true, content: { @@ -31,24 +50,43 @@ const route = createRoute({ function send(app: PublicAPIApp) { app.openapi(route, async (c) => { const team = c.var.team; + const requestBody = c.req.valid("json"); - let html = undefined; - - const _html = c.req.valid("json")?.html?.toString(); - - if (_html && _html !== "true" && _html !== "false") { - html = _html; + let html: string | undefined; + const rawHtml = requestBody?.html?.toString(); + if (rawHtml && rawHtml !== "true" && rawHtml !== "false") { + html = rawHtml; } - const email = await sendEmail({ - ...c.req.valid("json"), + const clientPayload = { + ...requestBody, + text: requestBody.text ?? undefined, + html, + }; + + const idemKey = c.req.header("Idempotency-Key") ?? undefined; + + const result = await IdempotencyService.withIdempotency< + typeof clientPayload, + { emailId?: string } + >({ teamId: team.id, - apiKeyId: team.apiKeyId, - text: c.req.valid("json").text ?? undefined, - html: html, + idemKey, + payload: clientPayload, + operation: async () => { + const email = await sendEmail({ + ...clientPayload, + teamId: team.id, + apiKeyId: team.apiKeyId, + }); + return { emailId: email?.id }; + }, + extractEmailIds: (result) => (result.emailId ? [result.emailId] : []), + formatCachedResponse: (emailIds) => ({ emailId: emailIds[0] }), + logContext: "email send", }); - return c.json({ emailId: email?.id }); + return c.json(result); }); } diff --git a/apps/web/src/server/service/idempotency-service.ts b/apps/web/src/server/service/idempotency-service.ts new file mode 100644 index 0000000..2434e2a --- /dev/null +++ b/apps/web/src/server/service/idempotency-service.ts @@ -0,0 +1,177 @@ +import { getRedis } from "~/server/redis"; +import { canonicalizePayload } from "~/server/utils/idempotency"; +import { UnsendApiError } from "~/server/public-api/api-error"; +import { logger } from "~/server/logger/log"; + +const IDEMPOTENCY_RESULT_TTL_SECONDS = 24 * 60 * 60; // 24h +const IDEMPOTENCY_LOCK_TTL_SECONDS = 60; // 60s + +export type IdempotencyRecord = { + bodyHash: string; + emailIds: string[]; +}; + +export type IdempotencyHandlerOptions = { + teamId: number; + idemKey: string | undefined; + payload: TPayload; + operation: () => Promise; + extractEmailIds: (result: TResult) => string[]; + formatCachedResponse: (emailIds: string[]) => TResult; + logContext: string; +}; + +function resultKey(teamId: number, key: string) { + return `idem:${teamId}:${key}`; +} + +function lockKey(teamId: number, key: string) { + return `idemlock:${teamId}:${key}`; +} + +export const IdempotencyService = { + async getResult( + teamId: number, + key: string, + ): Promise { + const redis = getRedis(); + const raw = await redis.get(resultKey(teamId, key)); + if (!raw) return null; + try { + const parsed = JSON.parse(raw); + if ( + parsed && + typeof parsed === "object" && + typeof (parsed as any).bodyHash === "string" && + Array.isArray((parsed as any).emailIds) + ) { + return parsed as IdempotencyRecord; + } + return null; + } catch { + return null; + } + }, + + async setResult( + teamId: number, + key: string, + record: IdempotencyRecord, + ): Promise { + const redis = getRedis(); + await redis.setex( + resultKey(teamId, key), + IDEMPOTENCY_RESULT_TTL_SECONDS, + JSON.stringify(record), + ); + }, + + async acquireLock(teamId: number, key: string): Promise { + const redis = getRedis(); + const ok = await redis.set( + lockKey(teamId, key), + "1", + "EX", + IDEMPOTENCY_LOCK_TTL_SECONDS, + "NX", + ); + return ok === "OK"; + }, + + async releaseLock(teamId: number, key: string): Promise { + const redis = getRedis(); + await redis.del(lockKey(teamId, key)); + }, + + async withIdempotency( + options: IdempotencyHandlerOptions, + ): Promise { + const { + teamId, + idemKey, + payload, + operation, + extractEmailIds, + formatCachedResponse, + logContext, + } = options; + + // Validate idempotency key length + if (idemKey !== undefined && (idemKey.length < 1 || idemKey.length > 256)) { + throw new UnsendApiError({ + code: "BAD_REQUEST", + message: "Invalid Idempotency-Key length", + }); + } + + // If no idempotency key, just execute the operation + if (!idemKey) { + return await operation(); + } + + // Calculate payload hash + const { bodyHash: payloadHash } = canonicalizePayload(payload); + + // Check for existing result + const existing = await this.getResult(teamId, idemKey); + if (existing) { + if (existing.bodyHash === payloadHash) { + logger.info({ teamId }, `Idempotency hit for ${logContext}`); + return formatCachedResponse(existing.emailIds); + } + + throw new UnsendApiError({ + code: "NOT_UNIQUE", + message: "Idempotency-Key already used with a different payload", + }); + } + + // Try to acquire lock + const lockAcquired = await this.acquireLock(teamId, idemKey); + if (!lockAcquired) { + // Check again in case another request completed + const again = await this.getResult(teamId, idemKey); + if (again) { + if (again.bodyHash === payloadHash) { + logger.info( + { teamId }, + `Idempotency hit after contention for ${logContext}`, + ); + return formatCachedResponse(again.emailIds); + } + + throw new UnsendApiError({ + code: "NOT_UNIQUE", + message: "Idempotency-Key already used with a different payload", + }); + } + + throw new UnsendApiError({ + code: "NOT_UNIQUE", + message: + "Request with same Idempotency-Key is in progress. Retry later.", + }); + } + + try { + // Execute the operation + const result = await operation(); + + // Store the result for future idempotency checks + await this.setResult(teamId, idemKey, { + bodyHash: payloadHash, + emailIds: extractEmailIds(result), + }); + + return result; + } finally { + // Always release the lock + await this.releaseLock(teamId, idemKey); + } + }, +}; + +export const IDEMPOTENCY_CONSTANTS = { + RESULT_TTL_SECONDS: IDEMPOTENCY_RESULT_TTL_SECONDS, + LOCK_TTL_SECONDS: IDEMPOTENCY_LOCK_TTL_SECONDS, +}; diff --git a/apps/web/src/server/utils/idempotency.ts b/apps/web/src/server/utils/idempotency.ts new file mode 100644 index 0000000..6fc2e51 --- /dev/null +++ b/apps/web/src/server/utils/idempotency.ts @@ -0,0 +1,69 @@ +import { createHash } from "crypto"; + +type CanonicalValue = + | string + | number + | boolean + | null + | CanonicalValue[] + | { [key: string]: CanonicalValue }; + +function normalize(value: unknown): CanonicalValue | undefined { + if (value === undefined) { + return undefined; + } + + if (value === null) { + return null; + } + + if (Array.isArray(value)) { + return value.map((item) => normalize(item) ?? null); + } + + if (value instanceof Date) { + return value.toISOString(); + } + + if (typeof value === "object") { + const entries = Object.entries(value as Record).sort( + ([keyA], [keyB]) => (keyA < keyB ? -1 : keyA > keyB ? 1 : 0) + ); + + const result: Record = {}; + for (const [key, val] of entries) { + const normalized = normalize(val); + if (normalized !== undefined) { + result[key] = normalized; + } + } + + return result; + } + + if (typeof value === "string") { + return value; + } + + if (typeof value === "number") { + return value; + } + + if (typeof value === "boolean") { + return value; + } + + if (typeof value === "bigint") { + return value.toString(); + } + + return String(value); +} + +export function canonicalizePayload(payload: unknown) { + const normalized = normalize(payload); + const canonical = JSON.stringify(normalized ?? null); + const bodyHash = createHash("sha256").update(canonical).digest("hex"); + return { canonical, bodyHash }; +} + diff --git a/packages/python-sdk/README.md b/packages/python-sdk/README.md index 647fc87..56a3917 100644 --- a/packages/python-sdk/README.md +++ b/packages/python-sdk/README.md @@ -37,6 +37,19 @@ resp, _ = client.emails.send(payload={ "html": "Hi!", }) +# Idempotent retries: same payload + same key returns the original response +resp, _ = client.emails.send( + payload=payload, + options={"idempotency_key": "signup-123"}, +) + +# Works for batch requests as well +resp, _ = client.emails.batch( + payload=[payload], + options={"idempotency_key": "bulk-welcome-1"}, +) +# If the same key is reused with a different payload, the API responds with HTTP 409. + # 3) Campaigns campaign_payload: types.CampaignCreate = { "name": "Welcome Series", diff --git a/packages/python-sdk/pyproject.toml b/packages/python-sdk/pyproject.toml index f9ee12c..f598be3 100644 --- a/packages/python-sdk/pyproject.toml +++ b/packages/python-sdk/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "usesend" -version = "0.2.7" +version = "0.2.8" description = "Python SDK for the UseSend API" authors = ["UseSend"] license = "MIT" diff --git a/packages/python-sdk/usesend/emails.py b/packages/python-sdk/usesend/emails.py index 3756fcb..753521c 100644 --- a/packages/python-sdk/usesend/emails.py +++ b/packages/python-sdk/usesend/emails.py @@ -3,6 +3,7 @@ from __future__ import annotations from datetime import datetime from typing import Any, Dict, List, Optional, Sequence, Tuple, Union +from typing_extensions import TypedDict from .types import ( APIError, @@ -18,6 +19,17 @@ from .types import ( ) +class EmailOptions(TypedDict, total=False): + """Options for email operations.""" + idempotency_key: Optional[str] + + +def _idem_headers(idempotency_key: Optional[str]) -> Optional[Dict[str, str]]: + if idempotency_key: + return {"Idempotency-Key": idempotency_key} + return None + + class Emails: """Client for `/emails` endpoints.""" @@ -25,11 +37,19 @@ class Emails: self.usesend = usesend # Basic operations ------------------------------------------------- - def send(self, payload: EmailCreate) -> Tuple[Optional[EmailCreateResponse], Optional[APIError]]: + def send( + self, + payload: EmailCreate, + options: Optional[EmailOptions] = None, + ) -> Tuple[Optional[EmailCreateResponse], Optional[APIError]]: """Alias for :meth:`create`.""" - return self.create(payload) + return self.create(payload, options) - def create(self, payload: Union[EmailCreate, Dict[str, Any]]) -> Tuple[Optional[EmailCreateResponse], Optional[APIError]]: + def create( + self, + payload: Union[EmailCreate, Dict[str, Any]], + options: Optional[EmailOptions] = None, + ) -> Tuple[Optional[EmailCreateResponse], Optional[APIError]]: if isinstance(payload, dict): payload = dict(payload) @@ -42,10 +62,17 @@ class Emails: if isinstance(body.get("scheduledAt"), datetime): body["scheduledAt"] = body["scheduledAt"].isoformat() - data, err = self.usesend.post("/emails", body) + idempotency_key = options.get("idempotency_key") if options else None + data, err = self.usesend.post( + "/emails", body, headers=_idem_headers(idempotency_key) + ) return (data, err) # type: ignore[return-value] - def batch(self, payload: Sequence[Union[EmailBatchItem, Dict[str, Any]]]) -> Tuple[Optional[EmailBatchResponse], Optional[APIError]]: + def batch( + self, + payload: Sequence[Union[EmailBatchItem, Dict[str, Any]]], + options: Optional[EmailOptions] = None, + ) -> Tuple[Optional[EmailBatchResponse], Optional[APIError]]: items: List[Dict[str, Any]] = [] for item in payload: d = dict(item) @@ -54,7 +81,10 @@ class Emails: if isinstance(d.get("scheduledAt"), datetime): d["scheduledAt"] = d["scheduledAt"].isoformat() items.append(d) - data, err = self.usesend.post("/emails/batch", items) + idempotency_key = options.get("idempotency_key") if options else None + data, err = self.usesend.post( + "/emails/batch", items, headers=_idem_headers(idempotency_key) + ) return (data, err) # type: ignore[return-value] def get(self, email_id: str) -> Tuple[Optional[Email], Optional[APIError]]: diff --git a/packages/python-sdk/usesend/usesend.py b/packages/python-sdk/usesend/usesend.py index ba31a01..f65c1f4 100644 --- a/packages/python-sdk/usesend/usesend.py +++ b/packages/python-sdk/usesend/usesend.py @@ -77,12 +77,25 @@ class UseSend: # ------------------------------------------------------------------ # Internal request helper # ------------------------------------------------------------------ + def _build_headers(self, extra: Optional[Dict[str, str]] = None) -> Dict[str, str]: + headers = dict(self.headers) + if extra: + headers.update({k: v for k, v in extra.items() if v is not None}) + return headers + def _request( - self, method: str, path: str, json: Optional[Any] = None + self, + method: str, + path: str, + json: Optional[Any] = None, + headers: Optional[Dict[str, str]] = None, ) -> Tuple[Optional[Dict[str, Any]], Optional[Dict[str, Any]]]: """Perform an HTTP request and return ``(data, error)``.""" resp = self._session.request( - method, f"{self.url}{path}", headers=self.headers, json=json + method, + f"{self.url}{path}", + headers=self._build_headers(headers), + json=json, ) default_error = {"code": "INTERNAL_SERVER_ERROR", "message": resp.reason} @@ -104,22 +117,42 @@ class UseSend: # ------------------------------------------------------------------ # HTTP verb helpers # ------------------------------------------------------------------ - def post(self, path: str, body: Any) -> Tuple[Optional[Dict[str, Any]], Optional[Dict[str, Any]]]: - return self._request("POST", path, json=body) + def post( + self, + path: str, + body: Any, + headers: Optional[Dict[str, str]] = None, + ) -> Tuple[Optional[Dict[str, Any]], Optional[Dict[str, Any]]]: + return self._request("POST", path, json=body, headers=headers) - def get(self, path: str) -> Tuple[Optional[Dict[str, Any]], Optional[Dict[str, Any]]]: - return self._request("GET", path) + def get( + self, path: str, headers: Optional[Dict[str, str]] = None + ) -> Tuple[Optional[Dict[str, Any]], Optional[Dict[str, Any]]]: + return self._request("GET", path, headers=headers) - def put(self, path: str, body: Any) -> Tuple[Optional[Dict[str, Any]], Optional[Dict[str, Any]]]: - return self._request("PUT", path, json=body) + def put( + self, + path: str, + body: Any, + headers: Optional[Dict[str, str]] = None, + ) -> Tuple[Optional[Dict[str, Any]], Optional[Dict[str, Any]]]: + return self._request("PUT", path, json=body, headers=headers) - def patch(self, path: str, body: Any) -> Tuple[Optional[Dict[str, Any]], Optional[Dict[str, Any]]]: - return self._request("PATCH", path, json=body) + def patch( + self, + path: str, + body: Any, + headers: Optional[Dict[str, str]] = None, + ) -> Tuple[Optional[Dict[str, Any]], Optional[Dict[str, Any]]]: + return self._request("PATCH", path, json=body, headers=headers) def delete( - self, path: str, body: Optional[Any] = None + self, + path: str, + body: Optional[Any] = None, + headers: Optional[Dict[str, str]] = None, ) -> Tuple[Optional[Dict[str, Any]], Optional[Dict[str, Any]]]: - return self._request("DELETE", path, json=body) + return self._request("DELETE", path, json=body, headers=headers) # Import here to avoid circular dependency during type checking diff --git a/packages/sdk/README.md b/packages/sdk/README.md index 8c5c73f..d42f817 100644 --- a/packages/sdk/README.md +++ b/packages/sdk/README.md @@ -48,6 +48,37 @@ usesend.emails.send({ html: "

useSend is the best open source product to send emails

", text: "useSend is the best open source product to send emails", }); + +// Safely retry sends with an idempotency key +await usesend.emails.send( + { + to: "hello@acme.com", + from: "hello@company.com", + subject: "useSend email", + html: "

useSend is the best open source product to send emails

", + }, + { idempotencyKey: "signup-123" }, +); + +// Works for bulk sends too +await usesend.emails.batch( + [ + { + to: "a@example.com", + from: "hello@company.com", + subject: "Welcome", + html: "

Hello A

", + }, + { + to: "b@example.com", + from: "hello@company.com", + subject: "Welcome", + html: "

Hello B

", + }, + ], + { idempotencyKey: "bulk-welcome-1" }, +); +// Reusing the same key with a different payload returns HTTP 409. ``` ## Campaigns diff --git a/packages/sdk/package.json b/packages/sdk/package.json index e8f21cf..25a6b43 100644 --- a/packages/sdk/package.json +++ b/packages/sdk/package.json @@ -1,6 +1,6 @@ { "name": "usesend-js", - "version": "1.5.6", + "version": "1.5.7", "description": "", "main": "./dist/index.js", "module": "./dist/index.mjs", @@ -28,4 +28,4 @@ "@react-email/render": "^1.0.6", "react": "^19.1.0" } -} \ No newline at end of file +} diff --git a/packages/sdk/src/email.ts b/packages/sdk/src/email.ts index 14ede27..99a814d 100644 --- a/packages/sdk/src/email.ts +++ b/packages/sdk/src/email.ts @@ -67,16 +67,23 @@ type BatchEmailResponse = { error: ErrorResponse | null; }; +type EmailRequestOptions = { + idempotencyKey?: string; +}; + export class Emails { constructor(private readonly usesend: UseSend) { this.usesend = usesend; } - async send(payload: SendEmailPayload) { - return this.create(payload); + async send(payload: SendEmailPayload, options?: EmailRequestOptions) { + return this.create(payload, options); } - async create(payload: SendEmailPayload): Promise { + async create( + payload: SendEmailPayload, + options?: EmailRequestOptions, + ): Promise { if (payload.react) { payload.html = await render(payload.react as React.ReactElement); delete payload.react; @@ -84,7 +91,10 @@ export class Emails { const data = await this.usesend.post( "/emails", - payload + payload, + options?.idempotencyKey + ? { headers: { "Idempotency-Key": options.idempotencyKey } } + : undefined, ); return data; @@ -96,11 +106,17 @@ export class Emails { * @param payload An array of email payloads. Max 100 emails. * @returns A promise that resolves to the list of created email IDs or an error. */ - async batch(payload: BatchEmailPayload): Promise { + async batch( + payload: BatchEmailPayload, + options?: EmailRequestOptions, + ): Promise { // Note: React element rendering is not supported in batch mode. const response = await this.usesend.post( "/emails/batch", - payload + payload, + options?.idempotencyKey + ? { headers: { "Idempotency-Key": options.idempotencyKey } } + : undefined, ); return { data: response.data ? response.data.data : null, diff --git a/packages/sdk/src/usesend.ts b/packages/sdk/src/usesend.ts index 3f4b016..8dfafe2 100644 --- a/packages/sdk/src/usesend.ts +++ b/packages/sdk/src/usesend.ts @@ -12,8 +12,12 @@ function isUseSendErrorResponse(error: { error: ErrorResponse }) { return error.error.code !== undefined; } +type RequestOptions = { + headers?: HeadersInit; +}; + export class UseSend { - private readonly headers: Headers; + private readonly baseHeaders: Headers; // readonly domains = new Domains(this); readonly emails = new Emails(this); @@ -42,17 +46,36 @@ export class UseSend { this.url = `${url}/api/v1`; } - this.headers = new Headers({ + this.baseHeaders = new Headers({ Authorization: `Bearer ${this.key}`, "Content-Type": "application/json", }); } + private mergeHeaders(extra?: HeadersInit) { + const headers = new Headers(this.baseHeaders); + if (!extra) { + return headers; + } + + const additional = new Headers(extra); + additional.forEach((value, key) => { + headers.set(key, value); + }); + + return headers; + } + async fetchRequest( path: string, - options = {}, + options: RequestInit = {}, ): Promise<{ data: T | null; error: ErrorResponse | null }> { - const response = await fetch(`${this.url}${path}`, options); + const requestOptions: RequestInit = { + ...options, + headers: this.mergeHeaders(options.headers), + }; + + const response = await fetch(`${this.url}${path}`, requestOptions); const defaultError = { code: "INTERNAL_SERVER_ERROR", message: response.statusText, @@ -82,52 +105,70 @@ export class UseSend { return { data, error: null }; } - async post(path: string, body: unknown) { - const requestOptions = { + async post(path: string, body: unknown, options?: RequestOptions) { + const requestOptions: RequestInit = { method: "POST", - headers: this.headers, body: JSON.stringify(body), }; + if (options?.headers) { + requestOptions.headers = options.headers; + } + return this.fetchRequest(path, requestOptions); } - async get(path: string) { - const requestOptions = { + async get(path: string, options?: RequestOptions) { + const requestOptions: RequestInit = { method: "GET", - headers: this.headers, }; + if (options?.headers) { + requestOptions.headers = options.headers; + } + return this.fetchRequest(path, requestOptions); } - async put(path: string, body: any) { - const requestOptions = { + async put(path: string, body: any, options?: RequestOptions) { + const requestOptions: RequestInit = { method: "PUT", - headers: this.headers, body: JSON.stringify(body), }; + if (options?.headers) { + requestOptions.headers = options.headers; + } + return this.fetchRequest(path, requestOptions); } - async patch(path: string, body: any) { - const requestOptions = { + async patch(path: string, body: any, options?: RequestOptions) { + const requestOptions: RequestInit = { method: "PATCH", - headers: this.headers, body: JSON.stringify(body), }; + if (options?.headers) { + requestOptions.headers = options.headers; + } + return this.fetchRequest(path, requestOptions); } - async delete(path: string, body?: unknown) { - const requestOptions = { + async delete(path: string, body?: unknown, options?: RequestOptions) { + const requestOptions: RequestInit = { method: "DELETE", - headers: this.headers, - body: JSON.stringify(body), }; + if (body !== undefined) { + requestOptions.body = JSON.stringify(body); + } + + if (options?.headers) { + requestOptions.headers = options.headers; + } + return this.fetchRequest(path, requestOptions); } }