diff --git a/.gitignore b/.gitignore index 431649b..086c20d 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,3 @@ /target -__pycache__ \ No newline at end of file +__pycache__ +.env \ No newline at end of file diff --git a/services/resend/.memory/.scaffold_done b/services/resend/.memory/.scaffold_done new file mode 100644 index 0000000..348ebd9 --- /dev/null +++ b/services/resend/.memory/.scaffold_done @@ -0,0 +1 @@ +done \ No newline at end of file diff --git a/services/resend/.memory/api-substrate.md b/services/resend/.memory/api-substrate.md new file mode 100644 index 0000000..9edfe16 --- /dev/null +++ b/services/resend/.memory/api-substrate.md @@ -0,0 +1,896 @@ +# Resend API Substrate Documentation + +## Overview + +Resend is a developer-focused email API for sending transactional and marketing emails. The API covers: Emails, Domains, Contacts, Templates, API Keys, and Webhooks. + +--- + +## Base URL & Versioning + +- **Base URL**: `https://api.resend.com` +- **Versioning**: No versioning system exists. All endpoints are unversioned (e.g., `/emails`, `/domains`). Calendar-based header versioning is planned for the future. +- **Protocol**: HTTPS only. HTTP is not supported. +- **Path convention**: `/{resource}` (e.g., `/emails`, `/domains`, `/contacts`, `/templates`, `/api-keys`, `/webhooks`) + +--- + +## Authentication + +- **Mechanism**: Bearer token in the `Authorization` header +- **Header format**: `Authorization: Bearer re_xxxxxxxxx` +- **API key format**: Keys start with `re_` prefix (e.g., `re_c1tpEyD8_NKFusih9vKVQknRAQfmFcWCv`) +- **Permission levels**: `full_access` (all operations) or `sending_access` (email sending only, optionally scoped to a specific domain) +- **Required headers**: + - `Authorization: Bearer ` (required for all requests) + - `Content-Type: application/json` (required for POST/PATCH requests with body) + - `User-Agent` is recommended; omitting it may result in errors + +### Auth Error Responses (verified by probing) + +**Missing API key** (no Authorization header): +``` +HTTP 401 +{ + "statusCode": 401, + "name": "missing_api_key", + "message": "Missing API Key" +} +``` +Response includes `www-authenticate: realm=""` header. + +**Invalid API key** (malformed or revoked key): +``` +HTTP 400 +{ + "statusCode": 400, + "name": "validation_error", + "message": "API key is invalid" +} +``` +Note: Invalid key returns **400** (not 401/403). This is a quirk. + +**Restricted API key** (sending-only key accessing non-email endpoints): +``` +HTTP 401 +{ + "statusCode": 401, + "name": "restricted_api_key", + "message": "This API key is restricted to only send emails" +} +``` + +--- + +## ID Format + +- **All resource IDs are UUIDs** (e.g., `49a3999c-0ce1-4ea6-ab68-afcd6dc2e794`) +- **API key tokens** start with `re_` prefix (e.g., `re_c1tpEyD8_NKFusih9vKVQknRAQfmFcWCv`) + +--- + +## Response Envelope & Common Patterns + +### Single resource responses +Individual resources include an `"object"` field identifying the resource type: +```json +{ + "object": "email", + "id": "49a3999c-...", + ...fields... +} +``` + +### List responses +All list endpoints return a consistent envelope: +```json +{ + "object": "list", + "has_more": true, + "data": [ ...resources... ] +} +``` + +### Create responses +Most create endpoints return a minimal response: +```json +{ + "id": "49a3999c-...", + "object": "resource_type" +} +``` +Exception: `POST /emails` returns only `{"id": "..."}` (no `object` field). Verified by probing. + +Exception: `POST /api-keys` returns `{"id": "...", "token": "re_..."}` (no `object` field). + +Exception: `POST /webhooks` returns `{"object": "webhook", "id": "...", "signing_secret": "..."}`. + +Exception: `POST /domains` returns a full domain object with `records` array. + +### Delete responses +Most delete endpoints return: +```json +{ + "object": "resource_type", + "id": "...", + "deleted": true +} +``` +Exception: `DELETE /api-keys/{id}` returns empty body (no content). The SDK returns `None`. + +Exception: `DELETE /contacts/{id}` uses `"contact"` field instead of `"id"`: +```json +{ + "object": "contact", + "contact": "520784e2-...", + "deleted": true +} +``` + +--- + +## Pagination + +- **Style**: Cursor-based using resource IDs +- **Parameters** (query string): + - `limit` (number): Items to return. Default: 20, Min: 1, Max: 100 + - `after` (string): ID after which to retrieve more items (forward pagination) + - `before` (string): ID before which to retrieve more items (backward pagination) + - `after` and `before` are mutually exclusive +- **Response**: + - `has_more` (boolean): `true` if more items exist beyond current page + - `data` (array): Array of resource objects +- **Detecting last page**: `has_more === false` + +--- + +## Rate Limiting (verified by probing) + +- **Default limit**: 2 requests per second per team (across all API keys) +- **Headers** (present on ALL responses, including errors): + - `ratelimit-limit: 2` — Maximum requests per window + - `ratelimit-policy: 2;w=1` — Policy (2 requests per 1 second window) + - `ratelimit-remaining: 1` — Remaining requests in current window + - `ratelimit-reset: 1` — Seconds until window resets + - `retry-after: 1` — (only on 429 responses) Seconds to wait before retrying +- **Quota headers** (on successful send responses only): + - `x-resend-daily-quota: N` — Number of emails sent today + - `x-resend-monthly-quota: N` — Number of emails sent this month + +### Rate limit error (verified by probing): +``` +HTTP 429 +{ + "statusCode": 429, + "name": "rate_limit_exceeded", + "message": "Too many requests. You can only make 2 requests per second. See rate limit response headers for more information. Or contact support to increase rate limit." +} +``` + +--- + +## Error Format (verified by probing) + +All errors follow this exact JSON shape: +```json +{ + "statusCode": , + "name": "", + "message": "" +} +``` + +### Observed error codes and names: + +| HTTP Status | `name` | Example `message` | +|---|---|---| +| 400 | `validation_error` | "API key is invalid", "Request body must be valid JSON." | +| 401 | `missing_api_key` | "Missing API Key" | +| 401 | `restricted_api_key` | "This API key is restricted to only send emails" | +| 404 | `not_found` | "Template not found" | +| 422 | `missing_required_field` | "Missing \`to\` field." | +| 422 | `validation_error` | "Invalid \`to\` field. The email address needs to follow the \`email@example.com\` or \`Name \` format.", "Missing \`html\` or \`text\` field." | +| 429 | `rate_limit_exceeded` | "Too many requests..." | + +### Validation order (verified by probing): +1. Authentication check (missing/invalid key → 400/401) +2. Permission check (restricted key → 401) +3. JSON parsing (invalid JSON → 400) +4. Required field validation: `to` is checked before `from`, and `html`/`text` before `subject` + +--- + +## Soft Delete Behavior + +Resend does **NOT** soft-delete. Deleted resources return 404 on subsequent GET requests (documented in API docs for domains, templates, webhooks, contacts). Delete responses include `"deleted": true` as confirmation. + +--- + +## Resource: Emails + +### POST /emails — Send an email +**Request body**: +| Field | Type | Required | Notes | +|---|---|---|---| +| `from` | string | Yes | `"Name "` format | +| `to` | string \| string[] | Yes | Max 50 recipients | +| `subject` | string | Yes | Email subject | +| `html` | string | No* | HTML body (*one of `html` or `text` required) | +| `text` | string | No* | Plain text body | +| `cc` | string \| string[] | No | CC recipients | +| `bcc` | string \| string[] | No | BCC recipients | +| `reply_to` | string \| string[] | No | Reply-to address(es) | +| `tags` | array | No | `[{name: string, value: string}]`, max 256 chars each | +| `headers` | object | No | Custom email headers | +| `attachments` | array | No | File attachments | +| `scheduled_at` | string | No | ISO 8601 future timestamp | +| `template` | object | No | `{id: string, variables: object}` | +| `topic_id` | string | No | Topic ID for subscription filtering | + +**Request headers**: +- `Idempotency-Key` (optional): Unique per request, expires after 24 hours, max 256 chars + +**Response** (200): +```json +{ + "id": "49a3999c-0ce1-4ea6-ab68-afcd6dc2e794" +} +``` +Verified: Returns only `id` field (no `object` field). + +### GET /emails/{id} — Retrieve an email +**Response** (200): +```json +{ + "object": "email", + "id": "49a3999c-...", + "to": ["recipient@example.com"], + "from": "sender@example.com", + "created_at": "2024-01-01T00:00:00.000Z", + "subject": "Hello", + "html": "

Hello

", + "text": null, + "bcc": null, + "cc": null, + "reply_to": null, + "last_event": "delivered", + "scheduled_at": null, + "tags": [{"name": "key", "value": "val"}] +} +``` + +Key fields: +- `to`, `cc`, `bcc`, `reply_to` are arrays (or null) +- `last_event` values include: `sent`, `delivered`, `scheduled`, `canceled`, `bounced`, `opened`, `clicked`, etc. +- `scheduled_at` is null for non-scheduled emails + +### GET /emails — List emails +**Query parameters**: `limit`, `after`, `before` (standard pagination) + +**Response** (200): +```json +{ + "object": "list", + "has_more": false, + "data": [ + { + "id": "...", + "to": ["..."], + "from": "...", + "created_at": "...", + "subject": "...", + "bcc": null, + "cc": null, + "reply_to": null, + "last_event": "delivered", + "scheduled_at": null + } + ] +} +``` +Note: List items do NOT include `html`, `text`, or `tags` fields. Only summary fields. + +### PATCH /emails/{id} — Update (reschedule) an email +**Request body**: +```json +{ + "scheduled_at": "2024-08-05T11:52:01.858Z" +} +``` + +**Response** (200): +```json +{ + "object": "email", + "id": "49a3999c-..." +} +``` + +### POST /emails/{id}/cancel — Cancel a scheduled email +**Request body**: None (empty) + +**Response** (200): +```json +{ + "object": "email", + "id": "49a3999c-..." +} +``` + +### POST /emails/batch — Send batch emails +**Request body**: Array of email objects (same fields as single send), max 100 emails. +- Attachments and `scheduled_at` are NOT supported in batch. + +**Response** (200): +```json +{ + "data": [ + {"id": "uuid-1"}, + {"id": "uuid-2"} + ] +} +``` +Verified: Each item in `data` has only an `id` field. No `object` field on the wrapper or items. + +**Request headers**: +- `Idempotency-Key` (optional) +- `x-batch-validation` (optional): `"strict"` or `"permissive"` — controls batch validation mode + +--- + +## Resource: Domains + +### POST /domains — Create a domain +**Request body**: +| Field | Type | Required | Default | Notes | +|---|---|---|---|---| +| `name` | string | Yes | — | Domain name | +| `region` | string | No | `us-east-1` | Options: `us-east-1`, `eu-west-1`, `sa-east-1`, `ap-northeast-1` | +| `customReturnPath` | string | No | `send` | Return-Path subdomain | +| `openTracking` | boolean | No | — | Enable open tracking | +| `clickTracking` | boolean | No | — | Enable click tracking | +| `tls` | string | No | `opportunistic` | `opportunistic` or `enforced` | +| `capabilities` | object | No | — | `{sending: "enabled"/"disabled", receiving: "enabled"/"disabled"}` | + +**Response** (200): +```json +{ + "id": "uuid", + "name": "example.com", + "created_at": "2024-...", + "status": "not_started", + "region": "us-east-1", + "capabilities": {"sending": "enabled", "receiving": "disabled"}, + "records": [ + { + "record": "SPF", + "name": "send.example.com", + "type": "MX", + "value": "feedback-smtp.us-east-1.amazonses.com", + "ttl": "Auto", + "status": "not_started", + "priority": 10 + }, + { + "record": "DKIM", + "name": "resend._domainkey.example.com", + "type": "CNAME", + "value": "...", + "ttl": "Auto", + "status": "not_started" + } + ] +} +``` +Note: Create response returns the full domain object including records. No `object` field in docs. + +### GET /domains/{domain_id} — Retrieve a domain +**Response** (200): +```json +{ + "object": "domain", + "id": "...", + "name": "...", + "status": "not_started|pending|verified", + "created_at": "...", + "region": "us-east-1", + "capabilities": {"sending": "enabled", "receiving": "disabled"}, + "records": [...] +} +``` + +### GET /domains — List domains +**Query parameters**: `limit`, `after`, `before` (standard pagination) + +**Response** (200): +```json +{ + "object": "list", + "has_more": false, + "data": [ + { + "id": "...", + "name": "...", + "status": "...", + "created_at": "...", + "region": "...", + "capabilities": {"sending": "enabled", "receiving": "disabled"} + } + ] +} +``` +Note: List items do NOT include `records` array. + +### PATCH /domains/{domain_id} — Update a domain +**Request body** (all optional): +```json +{ + "click_tracking": true, + "open_tracking": true, + "tls": "enforced", + "capabilities": {"sending": "enabled", "receiving": "enabled"} +} +``` + +**Response** (200): +```json +{ + "object": "domain", + "id": "..." +} +``` + +### POST /domains/{domain_id}/verify — Verify a domain +**Request body**: None + +**Response** (200): +```json +{ + "object": "domain", + "id": "..." +} +``` + +### DELETE /domains/{domain_id} — Delete a domain +**Response** (200): +```json +{ + "object": "domain", + "id": "...", + "deleted": true +} +``` + +--- + +## Resource: Contacts + +Contacts can be accessed via global routes (`/contacts`) or audience-scoped routes (`/audiences/{audience_id}/contacts`). The global routes are the current standard. + +### POST /contacts — Create a contact +**Request body**: +| Field | Type | Required | Notes | +|---|---|---|---| +| `email` | string | Yes | Contact email | +| `first_name` | string | No | First name | +| `last_name` | string | No | Last name | +| `unsubscribed` | boolean | No | Subscription status (default: false) | +| `properties` | object | No | Custom key-value pairs | +| `segments` | array | No | Segment IDs to add to | +| `topics` | array | No | Topic subscriptions | + +**Response** (200): +```json +{ + "object": "contact", + "id": "uuid" +} +``` + +### GET /contacts/{id_or_email} — Retrieve a contact +Accepts either UUID or email address as path parameter. + +**Response** (200): +```json +{ + "object": "contact", + "id": "...", + "email": "john@example.com", + "first_name": "John", + "last_name": "Doe", + "created_at": "2024-...", + "unsubscribed": false, + "properties": {} +} +``` + +### GET /contacts — List contacts +**Query parameters**: `limit`, `after`, `before` (standard pagination) + +**Response** (200): +```json +{ + "object": "list", + "has_more": false, + "data": [ + { + "id": "...", + "email": "...", + "first_name": "...", + "last_name": "...", + "created_at": "...", + "unsubscribed": false + } + ] +} +``` + +### PATCH /contacts/{id_or_email} — Update a contact +Accepts either UUID or email as path parameter. + +**Request body** (all optional): +```json +{ + "first_name": "Updated", + "last_name": "Person", + "unsubscribed": true, + "properties": {"key": "value"} +} +``` + +**Response** (200): +```json +{ + "object": "contact", + "id": "..." +} +``` + +### DELETE /contacts/{id_or_email} — Delete a contact +Accepts either UUID or email as path parameter. + +**Response** (200): +```json +{ + "object": "contact", + "contact": "520784e2-...", + "deleted": true +} +``` +Note: Uses `"contact"` field (not `"id"`) for the deleted contact's UUID. + +--- + +## Resource: Templates + +### POST /templates — Create a template +**Request body**: +| Field | Type | Required | Notes | +|---|---|---|---| +| `name` | string | Yes | Template name | +| `html` | string | Yes | HTML content with `{{{VARIABLE}}}` syntax | +| `alias` | string | No | Template alias for lookup | +| `from` | string | No | Default sender | +| `subject` | string | No | Default subject | +| `reply_to` | string \| string[] | No | Default reply-to | +| `text` | string | No | Plain text version | +| `variables` | array | No | Max 50. Each: `{key, type, fallback_value}` | + +**Response** (200): +```json +{ + "id": "uuid", + "object": "template" +} +``` + +### GET /templates/{id_or_alias} — Retrieve a template +Accepts either UUID or alias as path parameter. + +**Response** (200): +```json +{ + "object": "template", + "id": "...", + "current_version_id": "...", + "alias": "welcome-email", + "name": "Welcome Email", + "created_at": "2024-...", + "updated_at": "2024-...", + "status": "draft|published", + "published_at": "2024-...|null", + "from": "noreply@example.com", + "subject": "Welcome {{firstName}}!", + "reply_to": null, + "html": "

Hello

", + "text": "Hello", + "variables": [ + { + "id": "uuid", + "key": "firstName", + "type": "string", + "fallback_value": "there", + "created_at": "...", + "updated_at": "..." + } + ], + "has_unpublished_versions": false +} +``` + +### GET /templates — List templates +**Query parameters**: `limit`, `after`, `before` (standard pagination) + +**Response** (200): +```json +{ + "object": "list", + "has_more": false, + "data": [ + { + "id": "...", + "name": "...", + "status": "draft|published", + "published_at": "...|null", + "created_at": "...", + "updated_at": "...", + "alias": "..." + } + ] +} +``` +Note: List items do NOT include `html`, `text`, `variables`, `from`, `subject`, etc. + +### PATCH /templates/{id} — Update a template +**Request body** (all optional): +```json +{ + "name": "Updated Name", + "html": "

Updated

", + "alias": "updated-alias", + "from": "new@example.com", + "subject": "New Subject", + "reply_to": "reply@example.com", + "text": "plain text", + "variables": [{"key": "name", "type": "string"}] +} +``` + +**Response** (200): +```json +{ + "id": "...", + "object": "template" +} +``` + +### POST /templates/{id_or_alias}/publish — Publish a template +**Request body**: None + +**Response** (200): +```json +{ + "id": "...", + "object": "template" +} +``` + +### POST /templates/{id_or_alias}/duplicate — Duplicate a template +**Request body**: None + +**Response** (200): +```json +{ + "object": "template", + "id": "new-uuid" +} +``` +Returns a new template ID different from the original. + +### DELETE /templates/{id_or_alias} — Delete a template +**Response** (200): +```json +{ + "object": "template", + "id": "...", + "deleted": true +} +``` + +--- + +## Resource: API Keys + +### POST /api-keys — Create an API key +**Request body**: +| Field | Type | Required | Notes | +|---|---|---|---| +| `name` | string | Yes | Max 50 characters | +| `permission` | string | No | `full_access` (default) or `sending_access` | +| `domain_id` | string | No | Only with `sending_access` to restrict to a domain | + +**Response** (200): +```json +{ + "id": "dacf4072-...", + "token": "re_c1tpEyD8_NKFusih9vKVQknRAQfmFcWCv" +} +``` +Note: Token is only returned on creation. No `object` field. + +### GET /api-keys — List API keys +**Query parameters**: `limit`, `after`, `before` (standard pagination) + +**Response** (200): +```json +{ + "object": "list", + "has_more": false, + "data": [ + { + "id": "...", + "name": "Production Key", + "created_at": "2024-..." + } + ] +} +``` +Note: Listed keys do NOT include the `token` value (it's only shown on creation). + +### DELETE /api-keys/{api_key_id} — Delete an API key +**Response** (200): Empty body (no content) + +--- + +## Resource: Webhooks + +### POST /webhooks — Create a webhook +**Request body**: +| Field | Type | Required | Notes | +|---|---|---|---| +| `endpoint` | string | Yes | URL to receive webhook events | +| `events` | string[] | Yes | Event types to subscribe to | + +Available events: +- Email: `email.sent`, `email.delivered`, `email.delivery_delayed`, `email.complained`, `email.bounced`, `email.opened`, `email.clicked`, `email.received`, `email.failed` +- Contact: `contact.created`, `contact.updated`, `contact.deleted` +- Domain: `domain.created`, `domain.updated`, `domain.deleted` + +**Response** (200): +```json +{ + "object": "webhook", + "id": "uuid", + "signing_secret": "whsec_..." +} +``` + +### GET /webhooks/{webhook_id} — Retrieve a webhook +**Response** (200): +```json +{ + "object": "webhook", + "id": "...", + "created_at": "2024-...", + "status": "enabled|disabled", + "endpoint": "https://hooks.example.com/resend", + "events": ["email.sent", "email.delivered"], + "signing_secret": "whsec_..." +} +``` + +### GET /webhooks — List webhooks +**Query parameters**: `limit`, `after`, `before` (standard pagination) + +**Response** (200): +```json +{ + "object": "list", + "has_more": false, + "data": [ + { + "id": "...", + "created_at": "...", + "status": "enabled|disabled", + "endpoint": "https://...", + "events": ["email.sent"] + } + ] +} +``` +Note: List items do NOT include `signing_secret`. + +### PATCH /webhooks/{webhook_id} — Update a webhook +**Request body** (all optional): +```json +{ + "endpoint": "https://new-url.example.com", + "events": ["email.sent", "email.delivered"], + "status": "enabled|disabled" +} +``` + +**Response** (200): +```json +{ + "object": "webhook", + "id": "..." +} +``` + +### DELETE /webhooks/{webhook_id} — Delete a webhook +**Response** (200): +```json +{ + "object": "webhook", + "id": "...", + "deleted": true +} +``` + +--- + +## CRUD Status Code Summary + +| Operation | HTTP Method | Status Code | Notes | +|---|---|---|---| +| Create | POST | 200 | All creates return 200 (NOT 201) | +| Read | GET | 200 | Standard retrieval | +| Update | PATCH | 200 | All updates return 200 (NOT 204) | +| Delete | DELETE | 200 | All deletes return 200 (NOT 204) | +| Not found | GET/PATCH/DELETE | 404 | `{"statusCode": 404, "name": "not_found", "message": "..."}` | +| Send | POST | 200 | Emails, batch, schedule | +| Cancel | POST | 200 | Cancel scheduled email | +| Verify | POST | 200 | Verify domain | +| Publish | POST | 200 | Publish template | +| Duplicate | POST | 200 | Duplicate template | + +All successful operations return HTTP 200. Resend does not use 201 for creation or 204 for deletion/update. + +--- + +## Entity Relationships + +``` +Team + ├── API Keys (scoped to team, optionally to a domain) + ├── Domains (sending/receiving domains) + ├── Emails (sent from domains) + │ └── references templates (via template.id) + ├── Templates (email templates with variables) + ├── Contacts (global or audience-scoped) + │ ├── Segments (grouping) + │ └── Topics (subscription preferences) + ├── Webhooks (event subscriptions) + └── Segments (audience replacement, contact grouping) +``` + +- Emails reference templates via `template.id` or `template.alias` in the send request +- API Keys can be scoped to a specific `domain_id` +- Contacts can be scoped to audiences/segments +- Domains are required for sending (must be verified for production) + +--- + +## Idempotency (verified by probing) + +- **Header**: `Idempotency-Key` (request header) +- **Max length**: 256 characters +- **Expiry**: 24 hours +- **Behavior**: Sending the same request with the same idempotency key returns the same response (same `id`) without creating a duplicate email +- **Scope**: Applies to POST requests (send email, batch send) + +--- + +## Content Type + +- All request bodies: `application/json` +- All responses: `application/json` (content-type: `application/json` or `application/json; charset=utf-8`) +- Error responses for invalid API keys include `charset=utf-8` in content type + +--- + +## Special Sending Domain + +- `resend.dev` is a shared test domain available to all accounts +- Emails from `*@resend.dev` can only be sent to `delivered@resend.dev` (test recipient) diff --git a/services/resend/.memory/learnings.md b/services/resend/.memory/learnings.md new file mode 100644 index 0000000..4c999ca --- /dev/null +++ b/services/resend/.memory/learnings.md @@ -0,0 +1,101 @@ +# Resend API — Surprising Findings & Learnings + +## 1. Invalid API key returns 400, not 401 or 403 +**Surprise**: An invalid API key (e.g., `re_invalid_key_12345`) returns: +- HTTP **400** with `name: "validation_error"` and `message: "API key is invalid"` + +This is unexpected because: +- Missing API key → 401 (expected) +- Invalid API key → 400 (NOT 401/403 as one might expect) +- The SDK maps `InvalidApiKeyError` to status 403, but the real API never seems to return 403 for this + +## 2. All successful operations return HTTP 200 +**Surprise**: Resend does NOT follow REST conventions for status codes: +- Create → 200 (not 201) +- Delete → 200 (not 204) +- Update → 200 (not 204) +- All actions (cancel, verify, publish, duplicate) → 200 + +This simplifies the fake server since every success is 200. + +## 3. POST /emails response has NO `object` field +**Surprise**: While most create responses include `"object": "resource_type"`, the send email response is just: +```json +{"id": "uuid"} +``` +No `object` field. Same for batch send — just `{"data": [{"id": "..."}, ...]}`. + +Similarly, `POST /api-keys` returns `{"id": "...", "token": "re_..."}` without an `object` field. + +## 4. Rate limit is exactly 2 requests per second per team +**Verified**: Sending 3 requests simultaneously, exactly 2 succeed and 1 gets 429. Rate limit headers: +- `ratelimit-limit: 2` +- `ratelimit-policy: 2;w=1` +- `ratelimit-remaining: 0|1` +- `ratelimit-reset: 1` +- `retry-after: 1` (only on 429) + +Additional quota headers on successful sends: +- `x-resend-daily-quota: N` +- `x-resend-monthly-quota: N` + +## 5. Contact delete response uses `contact` field instead of `id` +**Surprise**: While domain, template, and webhook delete responses use `"id"` for the resource identifier: +```json +{"object": "domain", "id": "...", "deleted": true} +``` +Contact delete uses a different field name: +```json +{"object": "contact", "contact": "520784e2-...", "deleted": true} +``` + +## 6. ApiKeys delete returns empty body +**Surprise**: `DELETE /api-keys/{id}` returns an empty response body (HTTP 200 with no content), unlike all other delete endpoints which return JSON with `deleted: true`. The SDK's `ApiKeys.remove()` returns `None`. + +## 7. Validation order for email sending +**Surprise**: When sending an email with missing fields, Resend validates in this order: +1. Auth → missing/invalid key +2. Permissions → restricted key +3. JSON parsing → invalid JSON body +4. `to` field first → "Missing `to` field" +5. `html` or `text` → "Missing `html` or `text` field" (before subject!) +6. Then `subject`, `from`, etc. + +This means sending `{"from": "x@y.com", "to": "a@b.com"}` returns "Missing `html` or `text` field" (422), NOT "Missing `subject`". + +## 8. SDK injects response headers into response dict +**Gotcha**: The SDK adds `response["headers"] = dict(http_response_headers)` to every dict response. This means the fake server does NOT need to include `headers` in its JSON response — the SDK adds it client-side. But it also means the `headers` key in the response dict collides with email custom headers. + +## 9. SDK statusCode field check +**Critical**: The SDK checks if the JSON response body contains a `statusCode` field. If it's present and not None and not 200, the SDK raises an error. The fake server MUST NOT include `statusCode` in successful (200) responses, or it will break the SDK. + +## 10. resend.dev is a shared test domain +- `*@resend.dev` is available to all accounts as a test sender domain +- Test emails can only be sent TO `delivered@resend.dev` +- This means the grounding token (which is sending-only) can only send to this address + +## 11. Idempotency works correctly +**Verified**: Sending the same email twice with the same `Idempotency-Key` header returns the same UUID both times, confirming deduplication works at the API level. + +## 12. Template 404 is accessible even with sending-only key +**Surprise**: `POST /emails` with `template.id` pointing to a nonexistent template returns a proper 404: +```json +{"statusCode": 404, "name": "not_found", "message": "Template not found"} +``` +This works even with a sending-only API key, unlike `GET /templates/{id}` which returns 401 for restricted keys. + +## 13. Missing required fields return 422 with `missing_required_field` +**Noted**: The error name for missing fields is `missing_required_field` (singular), not `missing_required_fields` (plural). The SDK has a `MissingRequiredFieldsError` mapped to 422 / `missing_required_fields` (plural), which may not match exactly. + +## 14. Cloudflare is the CDN/proxy +All responses include `server: cloudflare` and `cf-ray` headers, indicating Resend uses Cloudflare as their edge proxy. + +## 15. Validation error for invalid email format is 422 +```json +{ + "statusCode": 422, + "name": "validation_error", + "message": "Invalid `to` field. The email address needs to follow the `email@example.com` or `Name ` format." +} +``` +Note: Both `missing_required_field` and `validation_error` can use HTTP 422. diff --git a/services/resend/.memory/research.json b/services/resend/.memory/research.json new file mode 100644 index 0000000..531c4a3 --- /dev/null +++ b/services/resend/.memory/research.json @@ -0,0 +1,1008 @@ +{ + "service_name": "Resend", + "description": "Email sending and management API for developers, supporting transactional emails, batch sending, domain management, contacts, templates, and webhooks.", + "docs_url": "https://resend.com/docs/api-reference/introduction", + "brief": "Resend is a developer-focused email API service that enables applications to send transactional and marketing emails via a REST API. It provides a clean, modern alternative to legacy email providers like SendGrid and Mailgun, with first-class support for React Email components. The API is organized around core resources: Emails (send, batch send, schedule, cancel, retrieve), Domains (register, verify via DNS, configure tracking), Contacts (manage subscriber lists with properties and segments), Templates (create, publish, and send with variable substitution), API Keys (manage access with granular permissions), Broadcasts (bulk marketing emails to audiences), and Webhooks (subscribe to delivery events like sent, delivered, bounced, opened, clicked).\n\nThis fake covers the primary API surface that AI agents would use: sending individual and batch emails, managing email lifecycle (scheduling, canceling, retrieving status), domain registration and verification, contact CRUD with subscription management, template creation and publishing, API key management, and webhook configuration. The API uses Bearer token authentication, enforces HTTPS, has a 2 req/sec rate limit per team, and supports cursor-based pagination on list endpoints. All responses follow a consistent pattern with an 'object' field indicating the resource type.", + "scenarios": [ + { + "id": "email-sending-lifecycle", + "title": "Email Sending Lifecycle", + "description": "Covers sending individual emails, retrieving their status, and verifying delivery tracking fields.", + "test_cases": [ + { + "id": "send-simple-email", + "title": "Send a simple email with subject and HTML body", + "given": "An authenticated API client with a verified sending domain", + "steps": [ + { + "action": "Send an email from 'Test ' to 'recipient@example.com' with subject 'Hello World' and HTML body '

Hi there

'", + "expected": "Returns a response with a non-null id (UUID format)" + }, + { + "action": "Retrieve the email by the returned id", + "expected": "Returns an email object with object='email', matching id, from='Test ', to=['recipient@example.com'], subject='Hello World', html='

Hi there

', created_at as a valid ISO 8601 timestamp, bcc=null or empty, cc=null or empty, reply_to=null or empty" + } + ], + "api_operations": [ + "POST /emails", + "GET /emails/{id}" + ] + }, + { + "id": "send-email-with-all-options", + "title": "Send an email with cc, bcc, reply_to, and tags", + "given": "An authenticated API client with a verified sending domain", + "steps": [ + { + "action": "Send an email from 'sender@example.com' to 'main@example.com' with cc 'cc@example.com', bcc 'bcc@example.com', reply_to 'reply@example.com', subject 'Full Options', HTML body '

Test

', and tags [{name: 'category', value: 'test'}, {name: 'environment', value: 'staging'}]", + "expected": "Returns a response with a non-null id" + }, + { + "action": "Retrieve the email by the returned id", + "expected": "Returns email with from='sender@example.com', to=['main@example.com'], cc=['cc@example.com'], bcc=['bcc@example.com'], reply_to=['reply@example.com'], subject='Full Options', tags containing {name: 'category', value: 'test'} and {name: 'environment', value: 'staging'}" + } + ], + "api_operations": [ + "POST /emails", + "GET /emails/{id}" + ] + }, + { + "id": "send-email-with-plain-text", + "title": "Send an email with plain text body instead of HTML", + "given": "An authenticated API client with a verified sending domain", + "steps": [ + { + "action": "Send an email from 'sender@example.com' to 'recipient@example.com' with subject 'Plain Text Email' and text body 'This is plain text content'", + "expected": "Returns a response with a non-null id" + }, + { + "action": "Retrieve the email by the returned id", + "expected": "Returns email with text='This is plain text content', html=null, subject='Plain Text Email'" + } + ], + "api_operations": [ + "POST /emails", + "GET /emails/{id}" + ] + }, + { + "id": "send-email-multiple-recipients", + "title": "Send an email to multiple recipients (up to 50)", + "given": "An authenticated API client with a verified sending domain", + "steps": [ + { + "action": "Send an email from 'sender@example.com' to ['user1@example.com', 'user2@example.com', 'user3@example.com'] with subject 'Multi-recipient' and HTML body '

Hello all

'", + "expected": "Returns a response with a non-null id" + }, + { + "action": "Retrieve the email by the returned id", + "expected": "Returns email with to=['user1@example.com', 'user2@example.com', 'user3@example.com']" + } + ], + "api_operations": [ + "POST /emails", + "GET /emails/{id}" + ] + }, + { + "id": "send-email-idempotency", + "title": "Sending the same email twice with an idempotency key returns the same id", + "given": "An authenticated API client with a verified sending domain", + "steps": [ + { + "action": "Send an email from 'sender@example.com' to 'recipient@example.com' with subject 'Idempotent Email' and HTML body '

Test

', including an Idempotency-Key header with value 'unique-key-12345'", + "expected": "Returns a response with a non-null id" + }, + { + "action": "Send the same email again with the same Idempotency-Key 'unique-key-12345'", + "expected": "Returns the same id as the first request without sending a duplicate email" + } + ], + "api_operations": [ + "POST /emails" + ] + }, + { + "id": "list-sent-emails", + "title": "List sent emails with pagination", + "given": "An authenticated API client that has previously sent at least two emails", + "steps": [ + { + "action": "Send an email from 'sender@example.com' to 'r1@example.com' with subject 'List Test 1' and HTML body '

1

'", + "expected": "Returns a response with a non-null id" + }, + { + "action": "Send an email from 'sender@example.com' to 'r2@example.com' with subject 'List Test 2' and HTML body '

2

'", + "expected": "Returns a response with a non-null id" + }, + { + "action": "List sent emails with limit 10", + "expected": "Returns object='list', data is an array containing the two sent emails (each with id, to, from, subject, created_at, last_event fields), has_more is a boolean" + } + ], + "api_operations": [ + "POST /emails", + "GET /emails" + ] + } + ] + }, + { + "id": "scheduled-email-management", + "title": "Scheduled Email Management", + "description": "Covers scheduling emails for future delivery, updating the scheduled time, and canceling scheduled emails.", + "test_cases": [ + { + "id": "schedule-email-for-future", + "title": "Schedule an email for future delivery", + "given": "An authenticated API client with a verified sending domain", + "steps": [ + { + "action": "Send an email from 'sender@example.com' to 'recipient@example.com' with subject 'Scheduled Email' and HTML body '

Future delivery

', with scheduled_at set to an ISO 8601 timestamp 24 hours from now", + "expected": "Returns a response with a non-null id" + }, + { + "action": "Retrieve the email by the returned id", + "expected": "Returns email with subject='Scheduled Email', scheduled_at matching the provided future timestamp, last_event='scheduled'" + } + ], + "api_operations": [ + "POST /emails", + "GET /emails/{id}" + ] + }, + { + "id": "update-scheduled-email-time", + "title": "Update the scheduled time of a pending email", + "given": "An authenticated API client and a previously scheduled email that has not yet been sent", + "steps": [ + { + "action": "Send an email from 'sender@example.com' to 'recipient@example.com' with subject 'Reschedule Test' and HTML body '

Test

', with scheduled_at set to 48 hours from now", + "expected": "Returns a response with a non-null id" + }, + { + "action": "Update the email's scheduled_at to 72 hours from now using the returned id", + "expected": "Returns object='email' with the same id" + }, + { + "action": "Retrieve the email by the id", + "expected": "Returns email with scheduled_at matching the updated timestamp (72 hours from now)" + } + ], + "api_operations": [ + "POST /emails", + "PATCH /emails/{id}", + "GET /emails/{id}" + ] + }, + { + "id": "cancel-scheduled-email", + "title": "Cancel a scheduled email before it is sent", + "given": "An authenticated API client and a previously scheduled email that has not yet been sent", + "steps": [ + { + "action": "Send an email from 'sender@example.com' to 'recipient@example.com' with subject 'Cancel Me' and HTML body '

This will be canceled

', with scheduled_at set to 48 hours from now", + "expected": "Returns a response with a non-null id" + }, + { + "action": "Cancel the scheduled email using the returned id", + "expected": "Returns object='email' with the same id" + }, + { + "action": "Retrieve the email by the id", + "expected": "Returns email with last_event='canceled' or indicates the email has been canceled" + } + ], + "api_operations": [ + "POST /emails", + "POST /emails/{id}/cancel", + "GET /emails/{id}" + ] + } + ] + }, + { + "id": "batch-email-sending", + "title": "Batch Email Sending", + "description": "Covers sending multiple emails in a single API call, verifying individual email tracking, and testing batch limits.", + "test_cases": [ + { + "id": "send-batch-emails", + "title": "Send a batch of multiple emails in one request", + "given": "An authenticated API client with a verified sending domain", + "steps": [ + { + "action": "Send a batch of 3 emails: (1) from 'sender@example.com' to 'user1@example.com' with subject 'Batch 1', (2) from 'sender@example.com' to 'user2@example.com' with subject 'Batch 2', (3) from 'sender@example.com' to 'user3@example.com' with subject 'Batch 3', all with HTML body '

Batch test

'", + "expected": "Returns a response with a data array containing 3 objects, each with a unique non-null id" + }, + { + "action": "Retrieve the first email by its id from the batch response", + "expected": "Returns email with subject='Batch 1', to=['user1@example.com'], from='sender@example.com'" + }, + { + "action": "Retrieve the third email by its id from the batch response", + "expected": "Returns email with subject='Batch 3', to=['user3@example.com'], from='sender@example.com'" + } + ], + "api_operations": [ + "POST /emails/batch", + "GET /emails/{id}" + ] + }, + { + "id": "batch-emails-individual-options", + "title": "Each email in a batch can have different recipients and options", + "given": "An authenticated API client with a verified sending domain", + "steps": [ + { + "action": "Send a batch of 2 emails: (1) from 'sender@example.com' to 'alice@example.com' with subject 'For Alice' and tags [{name: 'type', value: 'greeting'}], (2) from 'sender@example.com' to 'bob@example.com' with subject 'For Bob' and reply_to 'support@example.com'", + "expected": "Returns a response with a data array containing 2 objects, each with a unique non-null id" + }, + { + "action": "Retrieve the first email by its id", + "expected": "Returns email with subject='For Alice', to=['alice@example.com'], tags containing {name: 'type', value: 'greeting'}" + }, + { + "action": "Retrieve the second email by its id", + "expected": "Returns email with subject='For Bob', to=['bob@example.com'], reply_to=['support@example.com']" + } + ], + "api_operations": [ + "POST /emails/batch", + "GET /emails/{id}" + ] + } + ] + }, + { + "id": "domain-management", + "title": "Domain Management", + "description": "Covers the full lifecycle of domain registration, configuration, verification, listing, updating, and deletion.", + "test_cases": [ + { + "id": "create-and-retrieve-domain", + "title": "Create a domain and retrieve its details with DNS records", + "given": "An authenticated API client with no pre-existing test domains", + "steps": [ + { + "action": "Create a domain with name 'test.example.com'", + "expected": "Returns a domain object with non-null id, name='test.example.com', status (e.g., 'not_started' or 'pending'), region='us-east-1' (default), created_at as valid ISO 8601 timestamp, and records array containing DNS records (SPF, DKIM) each with record, name, value, type, ttl, and status fields" + }, + { + "action": "Retrieve the domain by its id", + "expected": "Returns domain with id matching, name='test.example.com', status, region, created_at, and records array" + } + ], + "api_operations": [ + "POST /domains", + "GET /domains/{id}" + ] + }, + { + "id": "create-domain-with-options", + "title": "Create a domain with custom region and tracking options", + "given": "An authenticated API client", + "steps": [ + { + "action": "Create a domain with name 'eu.example.com', region 'eu-west-1', open_tracking true, click_tracking true, tls 'enforced'", + "expected": "Returns a domain object with non-null id, name='eu.example.com', region='eu-west-1', and records array" + }, + { + "action": "Retrieve the domain by its id", + "expected": "Returns domain with name='eu.example.com', region='eu-west-1'" + } + ], + "api_operations": [ + "POST /domains", + "GET /domains/{id}" + ] + }, + { + "id": "list-domains", + "title": "List all domains and verify pagination", + "given": "An authenticated API client with at least one domain created", + "steps": [ + { + "action": "Create a domain with name 'list-test.example.com'", + "expected": "Returns a domain object with non-null id" + }, + { + "action": "List all domains", + "expected": "Returns object='list', data array containing the created domain (with id, name, status, created_at, region, capabilities fields), has_more is a boolean" + } + ], + "api_operations": [ + "POST /domains", + "GET /domains" + ] + }, + { + "id": "update-domain-tracking", + "title": "Update domain tracking and TLS settings", + "given": "An authenticated API client with an existing domain", + "steps": [ + { + "action": "Create a domain with name 'update-test.example.com'", + "expected": "Returns a domain object with non-null id" + }, + { + "action": "Update the domain to enable open_tracking=true, click_tracking=true, and tls='enforced'", + "expected": "Returns object='domain' with the same id" + } + ], + "api_operations": [ + "POST /domains", + "PATCH /domains/{id}" + ] + }, + { + "id": "verify-domain", + "title": "Trigger domain verification", + "given": "An authenticated API client with an existing unverified domain", + "steps": [ + { + "action": "Create a domain with name 'verify-test.example.com'", + "expected": "Returns a domain object with non-null id" + }, + { + "action": "Trigger verification for the domain by its id", + "expected": "Returns object='domain' with the same id" + } + ], + "api_operations": [ + "POST /domains", + "POST /domains/{id}/verify" + ] + }, + { + "id": "delete-domain", + "title": "Delete a domain and verify it is removed", + "given": "An authenticated API client with an existing domain", + "steps": [ + { + "action": "Create a domain with name 'delete-test.example.com'", + "expected": "Returns a domain object with non-null id" + }, + { + "action": "Delete the domain by its id", + "expected": "Returns a successful deletion response" + }, + { + "action": "Attempt to retrieve the deleted domain by its id", + "expected": "Returns a 404 not found error" + } + ], + "api_operations": [ + "POST /domains", + "DELETE /domains/{id}", + "GET /domains/{id}" + ] + } + ] + }, + { + "id": "contact-management", + "title": "Contact Management", + "description": "Covers creating, retrieving, updating, listing, and deleting contacts including subscription management and lookup by email.", + "test_cases": [ + { + "id": "create-and-retrieve-contact", + "title": "Create a contact and retrieve it by id", + "given": "An authenticated API client", + "steps": [ + { + "action": "Create a contact with email 'john@example.com', first_name 'John', last_name 'Doe'", + "expected": "Returns object='contact' with a non-null id" + }, + { + "action": "Retrieve the contact by its id", + "expected": "Returns contact with object='contact', matching id, email='john@example.com', first_name='John', last_name='Doe', unsubscribed=false, created_at as valid ISO 8601 timestamp" + } + ], + "api_operations": [ + "POST /contacts", + "GET /contacts/{id}" + ] + }, + { + "id": "retrieve-contact-by-email", + "title": "Retrieve a contact using email address instead of id", + "given": "An authenticated API client with an existing contact", + "steps": [ + { + "action": "Create a contact with email 'lookup@example.com', first_name 'Lookup', last_name 'User'", + "expected": "Returns object='contact' with a non-null id" + }, + { + "action": "Retrieve the contact by email 'lookup@example.com'", + "expected": "Returns contact with email='lookup@example.com', first_name='Lookup', last_name='User', matching the same id as the created contact" + } + ], + "api_operations": [ + "POST /contacts", + "GET /contacts/{email}" + ] + }, + { + "id": "update-contact-fields", + "title": "Update a contact's name and subscription status", + "given": "An authenticated API client with an existing contact", + "steps": [ + { + "action": "Create a contact with email 'update-me@example.com', first_name 'Original', last_name 'Name'", + "expected": "Returns object='contact' with a non-null id" + }, + { + "action": "Update the contact's first_name to 'Updated', last_name to 'Person', and unsubscribed to true", + "expected": "Returns object='contact' with the same id" + }, + { + "action": "Retrieve the contact by its id", + "expected": "Returns contact with first_name='Updated', last_name='Person', unsubscribed=true" + } + ], + "api_operations": [ + "POST /contacts", + "PATCH /contacts/{id}", + "GET /contacts/{id}" + ] + }, + { + "id": "list-contacts-with-pagination", + "title": "List contacts with limit and cursor-based pagination", + "given": "An authenticated API client with multiple contacts", + "steps": [ + { + "action": "Create three contacts with emails 'page1@example.com', 'page2@example.com', 'page3@example.com'", + "expected": "Each returns object='contact' with a non-null id" + }, + { + "action": "List contacts with limit 2", + "expected": "Returns object='list', data array with 2 contacts (each having id, email, first_name, last_name, created_at, unsubscribed), has_more=true" + }, + { + "action": "List contacts with limit 2 and 'after' cursor set to the last id from the previous page", + "expected": "Returns object='list', data array containing the remaining contact(s), has_more=false" + } + ], + "api_operations": [ + "POST /contacts", + "GET /contacts" + ] + }, + { + "id": "delete-contact-by-id", + "title": "Delete a contact by id and verify removal", + "given": "An authenticated API client with an existing contact", + "steps": [ + { + "action": "Create a contact with email 'delete-me@example.com'", + "expected": "Returns object='contact' with a non-null id" + }, + { + "action": "Delete the contact by its id", + "expected": "Returns object='contact' with the contact id and deleted=true" + }, + { + "action": "Attempt to retrieve the deleted contact by its id", + "expected": "Returns a 404 not found error" + } + ], + "api_operations": [ + "POST /contacts", + "DELETE /contacts/{id}", + "GET /contacts/{id}" + ] + }, + { + "id": "delete-contact-by-email", + "title": "Delete a contact using email address", + "given": "An authenticated API client with an existing contact", + "steps": [ + { + "action": "Create a contact with email 'delete-by-email@example.com'", + "expected": "Returns object='contact' with a non-null id" + }, + { + "action": "Delete the contact by email 'delete-by-email@example.com'", + "expected": "Returns object='contact' with deleted=true" + }, + { + "action": "Attempt to retrieve the contact by email 'delete-by-email@example.com'", + "expected": "Returns a 404 not found error" + } + ], + "api_operations": [ + "POST /contacts", + "DELETE /contacts/{email}", + "GET /contacts/{email}" + ] + } + ] + }, + { + "id": "template-lifecycle", + "title": "Template Lifecycle", + "description": "Covers creating, retrieving, listing, publishing, duplicating, updating, and deleting email templates, plus sending emails using templates.", + "test_cases": [ + { + "id": "create-and-retrieve-template", + "title": "Create a template and retrieve its full details", + "given": "An authenticated API client with no pre-existing test templates", + "steps": [ + { + "action": "Create a template with name 'Welcome Email', subject 'Welcome {{firstName}}!', from 'noreply@example.com', html '

Hello {{firstName}}

Welcome to our service.

', and variables [{key: 'firstName', type: 'string', fallback_value: 'there'}]", + "expected": "Returns object='template' with a non-null id" + }, + { + "action": "Retrieve the template by its id", + "expected": "Returns template with object='template', matching id, name='Welcome Email', subject='Welcome {{firstName}}!', from='noreply@example.com', html containing 'Hello {{firstName}}', status='draft', created_at and updated_at as valid ISO 8601 timestamps, variables array containing an object with key='firstName'" + } + ], + "api_operations": [ + "POST /templates", + "GET /templates/{id}" + ] + }, + { + "id": "publish-and-send-template", + "title": "Publish a template and send an email using it", + "given": "An authenticated API client with a draft template", + "steps": [ + { + "action": "Create a template with name 'Order Confirmation', subject 'Order #{{orderNumber}} Confirmed', from 'orders@example.com', html '

Thank you for order #{{orderNumber}}!

', and variables [{key: 'orderNumber', type: 'string'}]", + "expected": "Returns object='template' with a non-null id" + }, + { + "action": "Publish the template by its id", + "expected": "Returns a successful response indicating the template is now published" + }, + { + "action": "Retrieve the template by its id", + "expected": "Returns template with status='published', published_at as a valid ISO 8601 timestamp" + }, + { + "action": "Send an email to 'customer@example.com' using the template id with variables {orderNumber: '12345'}", + "expected": "Returns a response with a non-null email id" + } + ], + "api_operations": [ + "POST /templates", + "POST /templates/{id}/publish", + "GET /templates/{id}", + "POST /emails" + ] + }, + { + "id": "list-templates", + "title": "List templates and verify pagination", + "given": "An authenticated API client with at least one template", + "steps": [ + { + "action": "Create a template with name 'List Test Template', html '

Test

'", + "expected": "Returns object='template' with a non-null id" + }, + { + "action": "List templates with limit 10", + "expected": "Returns object='list', data array containing templates (each with id, name, status, created_at, updated_at, alias fields), has_more as a boolean. The created template appears in the list." + } + ], + "api_operations": [ + "POST /templates", + "GET /templates" + ] + }, + { + "id": "duplicate-template", + "title": "Duplicate an existing template", + "given": "An authenticated API client with an existing template", + "steps": [ + { + "action": "Create a template with name 'Original Template', html '

Original content

', subject 'Original Subject'", + "expected": "Returns object='template' with a non-null id" + }, + { + "action": "Duplicate the template by its id", + "expected": "Returns a new template object with a different id from the original" + }, + { + "action": "Retrieve the duplicated template by its new id", + "expected": "Returns template with html containing 'Original content', a different id from the original" + } + ], + "api_operations": [ + "POST /templates", + "POST /templates/{id}/duplicate", + "GET /templates/{id}" + ] + }, + { + "id": "update-and-delete-template", + "title": "Update a template's content then delete it", + "given": "An authenticated API client with an existing template", + "steps": [ + { + "action": "Create a template with name 'Mutable Template', html '

Version 1

'", + "expected": "Returns object='template' with a non-null id" + }, + { + "action": "Update the template to change name to 'Updated Template' and html to '

Version 2

'", + "expected": "Returns a successful update response with the same id" + }, + { + "action": "Retrieve the template by its id", + "expected": "Returns template with name='Updated Template', html containing 'Version 2'" + }, + { + "action": "Delete the template by its id", + "expected": "Returns a successful deletion response" + }, + { + "action": "Attempt to retrieve the deleted template by its id", + "expected": "Returns a 404 not found error" + } + ], + "api_operations": [ + "POST /templates", + "PATCH /templates/{id}", + "GET /templates/{id}", + "DELETE /templates/{id}" + ] + } + ] + }, + { + "id": "api-key-management", + "title": "API Key Management", + "description": "Covers creating API keys with different permission levels, listing keys, and deleting keys.", + "test_cases": [ + { + "id": "create-full-access-api-key", + "title": "Create an API key with full access permissions", + "given": "An authenticated API client", + "steps": [ + { + "action": "Create an API key with name 'Production Key' and permission 'full_access'", + "expected": "Returns an object with non-null id (UUID format) and token starting with 're_'" + }, + { + "action": "List all API keys", + "expected": "Returns object='list', data array containing the created key with id, name='Production Key', and created_at as valid ISO 8601 timestamp" + } + ], + "api_operations": [ + "POST /api-keys", + "GET /api-keys" + ] + }, + { + "id": "create-sending-access-api-key-with-domain", + "title": "Create an API key restricted to sending from a specific domain", + "given": "An authenticated API client with an existing domain", + "steps": [ + { + "action": "Create a domain with name 'restricted.example.com'", + "expected": "Returns a domain with a non-null id" + }, + { + "action": "Create an API key with name 'Restricted Sender', permission 'sending_access', and domain_id set to the created domain's id", + "expected": "Returns an object with non-null id and token starting with 're_'" + }, + { + "action": "List all API keys", + "expected": "Returns data array containing the key with name='Restricted Sender'" + } + ], + "api_operations": [ + "POST /domains", + "POST /api-keys", + "GET /api-keys" + ] + }, + { + "id": "delete-api-key", + "title": "Delete an API key and verify it is removed from the list", + "given": "An authenticated API client with an existing API key", + "steps": [ + { + "action": "Create an API key with name 'Temporary Key'", + "expected": "Returns an object with non-null id and token" + }, + { + "action": "Delete the API key by its id", + "expected": "Returns a successful deletion response" + }, + { + "action": "List all API keys", + "expected": "The deleted key with name 'Temporary Key' does not appear in the data array" + } + ], + "api_operations": [ + "POST /api-keys", + "DELETE /api-keys/{id}", + "GET /api-keys" + ] + } + ] + }, + { + "id": "webhook-configuration", + "title": "Webhook Configuration", + "description": "Covers creating webhooks with event subscriptions, listing, retrieving, updating, and deleting webhooks.", + "test_cases": [ + { + "id": "create-and-retrieve-webhook", + "title": "Create a webhook and retrieve its configuration", + "given": "An authenticated API client", + "steps": [ + { + "action": "Create a webhook with endpoint 'https://hooks.example.com/resend' and events ['email.sent', 'email.delivered', 'email.bounced']", + "expected": "Returns object='webhook' with a non-null id and a non-null signing_secret" + }, + { + "action": "Retrieve the webhook by its id", + "expected": "Returns webhook with matching id, endpoint='https://hooks.example.com/resend', events containing 'email.sent', 'email.delivered', and 'email.bounced'" + } + ], + "api_operations": [ + "POST /webhooks", + "GET /webhooks/{id}" + ] + }, + { + "id": "list-webhooks", + "title": "List all configured webhooks", + "given": "An authenticated API client with at least one webhook", + "steps": [ + { + "action": "Create a webhook with endpoint 'https://hooks.example.com/test' and events ['email.opened']", + "expected": "Returns object='webhook' with a non-null id" + }, + { + "action": "List all webhooks", + "expected": "Returns a list containing the created webhook with its id and endpoint" + } + ], + "api_operations": [ + "POST /webhooks", + "GET /webhooks" + ] + }, + { + "id": "update-webhook-events", + "title": "Update a webhook's subscribed events", + "given": "An authenticated API client with an existing webhook", + "steps": [ + { + "action": "Create a webhook with endpoint 'https://hooks.example.com/update-test' and events ['email.sent']", + "expected": "Returns object='webhook' with a non-null id" + }, + { + "action": "Update the webhook to subscribe to events ['email.sent', 'email.delivered', 'email.opened', 'email.clicked']", + "expected": "Returns a successful update response with the same id" + }, + { + "action": "Retrieve the webhook by its id", + "expected": "Returns webhook with events containing 'email.sent', 'email.delivered', 'email.opened', and 'email.clicked'" + } + ], + "api_operations": [ + "POST /webhooks", + "PATCH /webhooks/{id}", + "GET /webhooks/{id}" + ] + }, + { + "id": "delete-webhook", + "title": "Delete a webhook and verify removal", + "given": "An authenticated API client with an existing webhook", + "steps": [ + { + "action": "Create a webhook with endpoint 'https://hooks.example.com/delete-test' and events ['email.bounced']", + "expected": "Returns object='webhook' with a non-null id" + }, + { + "action": "Delete the webhook by its id", + "expected": "Returns a successful deletion response" + }, + { + "action": "Attempt to retrieve the deleted webhook by its id", + "expected": "Returns a 404 not found error" + } + ], + "api_operations": [ + "POST /webhooks", + "DELETE /webhooks/{id}", + "GET /webhooks/{id}" + ] + } + ] + }, + { + "id": "error-handling", + "title": "Error Handling and Edge Cases", + "description": "Covers authentication failures, validation errors, not-found errors, and other edge cases that AI agents must handle gracefully.", + "test_cases": [ + { + "id": "missing-required-email-fields", + "title": "Sending an email without required fields returns validation error", + "given": "An authenticated API client", + "steps": [ + { + "action": "Attempt to send an email with only the 'from' field set to 'sender@example.com', omitting 'to' and 'subject'", + "expected": "Returns a 400 error with a validation error message indicating missing required fields" + } + ], + "api_operations": [ + "POST /emails" + ] + }, + { + "id": "retrieve-nonexistent-email", + "title": "Retrieving a non-existent email returns 404", + "given": "An authenticated API client", + "steps": [ + { + "action": "Attempt to retrieve an email with a random UUID that does not exist", + "expected": "Returns a 404 not found error" + } + ], + "api_operations": [ + "GET /emails/{id}" + ] + }, + { + "id": "invalid-api-key", + "title": "Using an invalid API key returns 403", + "given": "An API client configured with an invalid API key 're_invalid_key_12345'", + "steps": [ + { + "action": "Attempt to list emails using the invalid API key", + "expected": "Returns a 403 error indicating the API key is invalid" + } + ], + "api_operations": [ + "GET /emails" + ] + }, + { + "id": "missing-api-key", + "title": "Omitting the Authorization header returns 401", + "given": "An API client with no Authorization header configured", + "steps": [ + { + "action": "Attempt to send an email without any Authorization header", + "expected": "Returns a 401 error indicating a missing API key" + } + ], + "api_operations": [ + "POST /emails" + ] + }, + { + "id": "create-contact-with-special-characters", + "title": "Create a contact with special characters in name fields", + "given": "An authenticated API client", + "steps": [ + { + "action": "Create a contact with email 'special@example.com', first_name 'José María', last_name \"O'Connor-Smith\"", + "expected": "Returns object='contact' with a non-null id" + }, + { + "action": "Retrieve the contact by its id", + "expected": "Returns contact with first_name='José María', last_name=\"O'Connor-Smith\", preserving all special characters" + } + ], + "api_operations": [ + "POST /contacts", + "GET /contacts/{id}" + ] + }, + { + "id": "api-key-name-max-length", + "title": "Creating an API key with a name exceeding 50 characters", + "given": "An authenticated API client", + "steps": [ + { + "action": "Attempt to create an API key with a name that is 51 characters long", + "expected": "Returns a 400 validation error indicating the name exceeds the maximum length of 50 characters" + } + ], + "api_operations": [ + "POST /api-keys" + ] + } + ] + }, + { + "id": "template-email-integration", + "title": "Template and Email Integration", + "description": "Covers sending emails using templates with variable substitution, testing template alias lookups, and verifying template defaults are applied.", + "test_cases": [ + { + "id": "send-email-with-template-variables", + "title": "Send an email using a published template with variable substitution", + "given": "An authenticated API client with a published template containing variables", + "steps": [ + { + "action": "Create a template with name 'Invoice Template', subject 'Invoice #{{invoiceId}}', from 'billing@example.com', html '

Dear {{customerName}}, your invoice #{{invoiceId}} for ${{amount}} is attached.

', and variables [{key: 'invoiceId', type: 'string'}, {key: 'customerName', type: 'string', fallback_value: 'Customer'}, {key: 'amount', type: 'string'}]", + "expected": "Returns object='template' with a non-null id" + }, + { + "action": "Publish the template by its id", + "expected": "Returns a successful response" + }, + { + "action": "Send an email to 'client@example.com' using the template id with variables {invoiceId: 'INV-001', customerName: 'Alice Smith', amount: '99.99'}", + "expected": "Returns a response with a non-null email id" + }, + { + "action": "Retrieve the sent email by its id", + "expected": "Returns email with to=['client@example.com'], subject='Invoice #INV-001', from='billing@example.com'" + } + ], + "api_operations": [ + "POST /templates", + "POST /templates/{id}/publish", + "POST /emails", + "GET /emails/{id}" + ] + }, + { + "id": "send-template-with-from-override", + "title": "Override template defaults when sending an email", + "given": "An authenticated API client with a published template that has default from and subject", + "steps": [ + { + "action": "Create a template with name 'Overridable Template', subject 'Default Subject', from 'default@example.com', html '

Hello {{name}}

', variables [{key: 'name', type: 'string'}]", + "expected": "Returns object='template' with a non-null id" + }, + { + "action": "Publish the template by its id", + "expected": "Returns a successful response" + }, + { + "action": "Send an email to 'user@example.com' using the template id, with from overridden to 'custom@example.com', subject overridden to 'Custom Subject', and variables {name: 'Bob'}", + "expected": "Returns a response with a non-null email id" + }, + { + "action": "Retrieve the sent email by its id", + "expected": "Returns email with from='custom@example.com', subject='Custom Subject' (overrides applied over template defaults)" + } + ], + "api_operations": [ + "POST /templates", + "POST /templates/{id}/publish", + "POST /emails", + "GET /emails/{id}" + ] + }, + { + "id": "send-template-with-alias", + "title": "Send an email referencing a template by its alias instead of id", + "given": "An authenticated API client with a published template that has an alias", + "steps": [ + { + "action": "Create a template with name 'Alias Template', alias 'welcome-email', subject 'Welcome!', from 'hello@example.com', html '

Welcome, {{name}}!

', variables [{key: 'name', type: 'string'}]", + "expected": "Returns object='template' with a non-null id" + }, + { + "action": "Publish the template by its id", + "expected": "Returns a successful response" + }, + { + "action": "Retrieve the template using alias 'welcome-email'", + "expected": "Returns the template with name='Alias Template', alias='welcome-email', status='published'" + } + ], + "api_operations": [ + "POST /templates", + "POST /templates/{id}/publish", + "GET /templates/{alias}" + ] + } + ] + } + ] +} \ No newline at end of file diff --git a/services/resend/.memory/research.md b/services/resend/.memory/research.md new file mode 100644 index 0000000..50b2895 --- /dev/null +++ b/services/resend/.memory/research.md @@ -0,0 +1,21 @@ +# Resend Research + +**Description**: Email sending and management API for developers, supporting transactional emails, batch sending, domain management, contacts, templates, and webhooks. +**Docs**: https://resend.com/docs/api-reference/introduction + +Resend is a developer-focused email API service that enables applications to send transactional and marketing emails via a REST API. It provides a clean, modern alternative to legacy email providers like SendGrid and Mailgun, with first-class support for React Email components. The API is organized around core resources: Emails (send, batch send, schedule, cancel, retrieve), Domains (register, verify via DNS, configure tracking), Contacts (manage subscriber lists with properties and segments), Templates (create, publish, and send with variable substitution), API Keys (manage access with granular permissions), Broadcasts (bulk marketing emails to audiences), and Webhooks (subscribe to delivery events like sent, delivered, bounced, opened, clicked). + +This fake covers the primary API surface that AI agents would use: sending individual and batch emails, managing email lifecycle (scheduling, canceling, retrieving status), domain registration and verification, contact CRUD with subscription management, template creation and publishing, API key management, and webhook configuration. The API uses Bearer token authentication, enforces HTTPS, has a 2 req/sec rate limit per team, and supports cursor-based pagination on list endpoints. All responses follow a consistent pattern with an 'object' field indicating the resource type. + +## Scenarios + +- **email-sending-lifecycle**: Email Sending Lifecycle — Covers sending individual emails, retrieving their status, and verifying delivery tracking fields. +- **scheduled-email-management**: Scheduled Email Management — Covers scheduling emails for future delivery, updating the scheduled time, and canceling scheduled emails. +- **batch-email-sending**: Batch Email Sending — Covers sending multiple emails in a single API call, verifying individual email tracking, and testing batch limits. +- **domain-management**: Domain Management — Covers the full lifecycle of domain registration, configuration, verification, listing, updating, and deletion. +- **contact-management**: Contact Management — Covers creating, retrieving, updating, listing, and deleting contacts including subscription management and lookup by email. +- **template-lifecycle**: Template Lifecycle — Covers creating, retrieving, listing, publishing, duplicating, updating, and deleting email templates, plus sending emails using templates. +- **api-key-management**: API Key Management — Covers creating API keys with different permission levels, listing keys, and deleting keys. +- **webhook-configuration**: Webhook Configuration — Covers creating webhooks with event subscriptions, listing, retrieving, updating, and deleting webhooks. +- **error-handling**: Error Handling and Edge Cases — Covers authentication failures, validation errors, not-found errors, and other edge cases that AI agents must handle gracefully. +- **template-email-integration**: Template and Email Integration — Covers sending emails using templates with variable substitution, testing template alias lookups, and verifying template defaults are applied. diff --git a/services/resend/.memory/sdk-analysis.md b/services/resend/.memory/sdk-analysis.md new file mode 100644 index 0000000..ec873f1 --- /dev/null +++ b/services/resend/.memory/sdk-analysis.md @@ -0,0 +1,296 @@ +# Resend Python SDK Analysis + +## Package Info + +- **Package name**: `resend` +- **Version**: 2.23.0 +- **Install**: `pip install resend` +- **Language**: Python +- **Location**: `/Users/rotem.tamir/Library/Python/3.9/lib/python/site-packages/resend/` + +--- + +## Base URL Override + +The SDK uses a **module-level singleton pattern** (no client class). Configuration is done via module-level variables: + +```python +import resend + +# Method 1: Direct assignment (recommended for fake server) +resend.api_key = "re_your_api_key" +resend.api_url = "http://localhost:8080" + +# Method 2: Environment variables (checked at import time) +# RESEND_API_KEY → resend.api_key +# RESEND_API_URL → resend.api_url (defaults to "https://api.resend.com") +``` + +**Default base URL**: `https://api.resend.com` +**Override env var**: `RESEND_API_URL` +**Override at runtime**: `resend.api_url = "http://your-fake:port"` + +URLs are constructed as: `f"{resend.api_url}{path}"` (e.g., `http://localhost:8080/emails`) + +**Important**: The base URL should NOT have a trailing slash. + +--- + +## HTTP Layer + +### HTTP Client +- Uses the **`requests`** library (NOT httpx, NOT urllib3) +- Default timeout: **30 seconds** +- Client: `resend.http_client_requests.RequestsClient` +- Abstract interface: `resend.http_client.HTTPClient` +- Can be replaced: `resend.default_http_client = CustomClient()` + +### Request Construction +The SDK calls: +```python +requests.request(method=method, url=url, headers=headers, json=body, timeout=30) +``` +- `json=` parameter auto-sets `Content-Type: application/json` +- Content-Type is NOT explicitly set in headers + +### Request Headers +Every request includes: +```python +{ + "Accept": "application/json", + "Authorization": f"Bearer {resend.api_key}", + "User-Agent": "resend-python:2.23.0", +} +``` + +Conditional headers: +- `Idempotency-Key: ` — Added for POST requests when `options["idempotency_key"]` is provided +- `x-batch-validation: ` — Added for `Batch.send()` when `options["batch_validation"]` is provided + +--- + +## Response Handling + +### ResponseDict +All dict API responses are returned as `ResponseDict` — a `dict` subclass with attribute-style access: + +```python +response = resend.Emails.send({...}) +response["id"] # dict access +response.id # attribute access (same thing) +``` + +The `ResponseDict` is NOT a model/dataclass — it's a plain dict enhanced with `__getattr__`. TypedDict annotations exist for type-checking only but have no runtime effect. + +### Response Processing Pipeline +1. HTTP response received as `(content_bytes, status_code, headers)` +2. Content-Type must be `application/json` (otherwise error) +3. Body is JSON-parsed +4. **Response headers are injected**: `parsed_data["headers"] = dict(response_headers)` — this means every response dict contains an extra `headers` key with HTTP response headers +5. If `statusCode` field is present and not 200, an exception is raised +6. Dict responses are wrapped in `ResponseDict` +7. List responses (arrays) are returned as plain lists + +### perform() vs perform_with_content() +- `perform()` → `Union[T, None]` — may return None (used for delete operations that return empty body) +- `perform_with_content()` → `T` — raises `NoContentError` if body is empty + +--- + +## Error Handling + +### Exception Hierarchy +``` +Exception + └── ResendError (base) + ├── MissingApiKeyError (401 / missing_api_key) + ├── InvalidApiKeyError (403 / invalid_api_key) + ├── ValidationError (400, 422 / validation_error) + ├── MissingRequiredFieldsError (422 / missing_required_fields) + ├── ApplicationError (500 / application_error) + └── RateLimitError (429 / rate_limit_exceeded, daily_quota_exceeded, monthly_quota_exceeded) + └── NoContentError (empty response body) +``` + +### Error Resolution +The SDK matches on `(statusCode, name)` from the API response JSON: +1. Look up HTTP status code → get dict of error_type → exception_class +2. Look up `name` (error type) in that dict +3. Raise the specific exception, or generic `ResendError` for unknown combinations + +### HTTP Client Errors +If `requests` throws any exception, it's caught and re-raised as: +```python +ResendError(code=500, message=str(e), error_type="HttpClientError") +``` + +### Client-side Validation (raises ValueError, not ResendError) +- `Contacts.update()`: "id or email must be provided" +- `Contacts.get()`: "id or email must be provided" +- `Contacts.remove()`: "id or email must be provided" + +**Note on error name mapping quirk**: The SDK maps error name `"invalid_api_key"` to status 403, but the real API returns status 400 with name `"validation_error"` for invalid API keys. This mismatch means the SDK's `InvalidApiKeyError` (403) may never actually be raised for the invalid key scenario — instead, a `ValidationError` (400) would be raised. + +--- + +## Method Inventory + +### Emails (`resend.Emails`) + +| Method | HTTP | Path | Returns | +|---|---|---|---| +| `send(params, options=None)` | POST | `/emails` | `{"id": "uuid"}` | +| `get(email_id)` | GET | `/emails/{email_id}` | Full email object | +| `list(params=None)` | GET | `/emails` + query | List envelope | +| `update(params)` | PATCH | `/emails/{params['id']}` | `{"object": "email", "id": "..."}` | +| `cancel(email_id)` | POST | `/emails/{email_id}/cancel` | `{"object": "email", "id": "..."}` | + +**SendParams** fields: `from`, `to`, `subject`, `bcc`, `cc`, `reply_to`, `html`, `text`, `headers`, `attachments`, `tags`, `scheduled_at`, `template` +**SendOptions**: `idempotency_key` + +### Batch (`resend.Batch`) + +| Method | HTTP | Path | Returns | +|---|---|---|---| +| `send(params, options=None)` | POST | `/emails/batch` | `{"data": [{"id": "..."}]}` | + +**SendOptions**: `idempotency_key`, `batch_validation` (Literal["strict", "permissive"]) + +### Domains (`resend.Domains`) + +| Method | HTTP | Path | Returns | +|---|---|---|---| +| `create(params)` | POST | `/domains` | Full domain with records | +| `get(domain_id)` | GET | `/domains/{domain_id}` | Full domain with records | +| `list(params=None)` | GET | `/domains` + query | List envelope | +| `update(params)` | PATCH | `/domains/{params['id']}` | `{"object": "domain", "id": "..."}` | +| `remove(domain_id)` | DELETE | `/domains/{domain_id}` | `{"object": "domain", "id": "...", "deleted": true}` | +| `verify(domain_id)` | POST | `/domains/{domain_id}/verify` | `{"object": "domain", "id": "..."}` | + +### Contacts (`resend.Contacts`) + +| Method | HTTP | Path | Returns | +|---|---|---|---| +| `create(params)` | POST | `/audiences/{audience_id}/contacts` OR `/contacts` | `{"object": "contact", "id": "..."}` | +| `get(audience_id=None, id=None, email=None)` | GET | `/contacts/{id_or_email}` | Full contact object | +| `list(audience_id=None, params=None)` | GET | `/contacts` + query | List envelope | +| `update(params)` | PATCH | `/contacts/{id_or_email}` | `{"object": "contact", "id": "..."}` | +| `remove(audience_id=None, id=None, email=None)` | DELETE | `/contacts/{id_or_email}` | `{"object": "contact", "contact": "...", "deleted": true}` | + +**Dual routing**: If `audience_id` is provided, routes to `/audiences/{audience_id}/contacts/...`. Otherwise uses `/contacts/...`. +**Email precedence**: When both `id` and `email` are provided, **email takes precedence over id** (matching Node.js SDK behavior). + +### Templates (`resend.Templates`) + +| Method | HTTP | Path | Returns | +|---|---|---|---| +| `create(params)` | POST | `/templates` | `{"id": "...", "object": "template"}` | +| `get(template_id)` | GET | `/templates/{template_id}` | Full template object | +| `list(params=None)` | GET | `/templates` + query | List envelope | +| `update(params)` | PATCH | `/templates/{params['id']}` | `{"id": "...", "object": "template"}` | +| `publish(template_id)` | POST | `/templates/{template_id}/publish` | `{"id": "...", "object": "template"}` | +| `duplicate(template_id)` | POST | `/templates/{template_id}/duplicate` | `{"object": "template", "id": "new-uuid"}` | +| `remove(template_id)` | DELETE | `/templates/{template_id}` | `{"object": "template", "id": "...", "deleted": true}` | + +**Update quirk**: The SDK explicitly strips `id` from the request body before sending: +```python +update_params = {k: v for k, v in params.items() if k != "id"} +``` + +### API Keys (`resend.ApiKeys`) + +| Method | HTTP | Path | Returns | +|---|---|---|---| +| `create(params)` | POST | `/api-keys` | `{"id": "...", "token": "re_..."}` | +| `list(params=None)` | GET | `/api-keys` + query | List envelope | +| `remove(api_key_id)` | DELETE | `/api-keys/{api_key_id}` | `None` (empty body) | + +**Delete quirk**: `remove()` uses `perform()` (not `perform_with_content()`), so it returns `None`. This is the ONLY delete method that returns `None`. + +### Webhooks (`resend.Webhooks`) + +| Method | HTTP | Path | Returns | +|---|---|---|---| +| `create(params)` | POST | `/webhooks` | `{"object": "webhook", "id": "...", "signing_secret": "..."}` | +| `get(webhook_id)` | GET | `/webhooks/{webhook_id}` | Full webhook object | +| `list(params=None)` | GET | `/webhooks` + query | List envelope | +| `update(params)` | PATCH | `/webhooks/{params['webhook_id']}` | `{"object": "webhook", "id": "..."}` | +| `remove(webhook_id)` | DELETE | `/webhooks/{webhook_id}` | `{"object": "webhook", "id": "...", "deleted": true}` | + +**Update quirk**: `webhook_id` is extracted from params for the URL, but the full params dict (including `webhook_id`) is sent in the request body. + +### Audiences (`resend.Audiences`) — DEPRECATED + +All methods emit `DeprecationWarning` and delegate to `Segments`: + +| Method | Delegates to | +|---|---| +| `create(params)` | `Segments.create()` | +| `list(params=None)` | `Segments.list()` | +| `get(id)` | `Segments.get()` | +| `remove(id)` | `Segments.remove()` | + +### Segments (`resend.Segments`) + +| Method | HTTP | Path | +|---|---|---| +| `create(params)` | POST | `/segments` | +| `list(params=None)` | GET | `/segments` | +| `get(id)` | GET | `/segments/{id}` | +| `remove(id)` | DELETE | `/segments/{id}` | + +--- + +## Quirks and Gotchas + +### 1. `from` keyword workaround +Python's `from` is a reserved keyword. The SDK uses "functional TypedDict" syntax: +```python +_SendParamsFrom = TypedDict("_SendParamsFrom", {"from": str}) +``` +Users pass `from` as a dict key: `{"from": "sender@example.com"}`, which works because dict keys are strings. + +### 2. Response headers injected into every response +Every API response dict gets `response["headers"] = dict(http_response_headers)` injected. This collides with the `headers` field name used in email custom headers. The SDK handles this with `BaseResponse` declaring `headers: NotRequired[Dict[str, str]]`. + +When working with the fake server, every JSON response should work without this injection — it's done client-side by the SDK. + +### 3. All methods are `@classmethod` +No instance state. The SDK is entirely stateless per-resource-class. All configuration is in module-level variables (`resend.api_key`, `resend.api_url`, `resend.default_http_client`). + +### 4. ID parameter naming inconsistency +Different resources use different parameter names for the same concept: +- `ApiKeys.remove(api_key_id)` +- `Domains.remove(domain_id)` +- `Webhooks.remove(webhook_id)` +- `Templates.remove(template_id)` +- `Emails.get(email_id)` + +### 5. ID stripping from body varies by resource +- `Templates.update()`: Explicitly strips `id` from body +- `ContactProperties.update()`: Builds new payload without `id` +- `Domains.update()`, `Emails.update()`: Send full params including `id` in body +- `Webhooks.update()`: Sends full params including `webhook_id` in body + +### 6. Contacts email precedence +When both `id` and `email` are provided to `Contacts.get()`, `update()`, or `remove()`, **email takes precedence**. This matches the Node.js SDK behavior per SDK comments. + +### 7. Contacts.Topics.update() sends raw array +The `update()` method for contact topics sends the `topics` array directly as the request body (a raw list), not wrapped in a dict. This is the only PATCH method that sends a list as the top-level JSON body. + +### 8. Error name mismatch for invalid API keys +- SDK maps: 403 → `invalid_api_key` → `InvalidApiKeyError` +- Real API returns: 400 → `validation_error` for invalid keys +- The SDK's `InvalidApiKeyError` may never be raised for the actual invalid key scenario + +### 9. Pagination parameter passing +All `list()` methods accept `ListParams` (dict with `limit`, `after`, `before`) and use `PaginationHelper.build_paginated_path()` to append query parameters to the URL. Parameters go in URL query string, NOT request body. + +### 10. ApiKeys.remove() returns None +Unlike all other delete methods which return a response dict with `deleted: true`, `ApiKeys.remove()` returns `None` because the API returns an empty body for API key deletion. + +### 11. Content-Type validation +The SDK validates that the response `Content-Type` is `application/json`. If the fake server returns a different content type, the SDK will raise an error. Always set `Content-Type: application/json` on responses. + +### 12. statusCode field check +The SDK checks if the JSON response contains a `statusCode` field. If present and not `None` and not `200`, it treats it as an error and calls `raise_for_code_and_type()`. This means the fake server should NOT include `statusCode` in successful responses. diff --git a/services/resend/.memory/state.json b/services/resend/.memory/state.json new file mode 100644 index 0000000..ec56c78 --- /dev/null +++ b/services/resend/.memory/state.json @@ -0,0 +1,458 @@ +{ + "domain": "resend", + "working_path": "/Users/rotem.tamir/dev/double-agent/services/resend", + "created_at": "2026-03-01T10:08:25.208863+00:00", + "research_complete": true, + "architect_complete": true, + "scenarios": [ + { + "id": "email-sending-lifecycle", + "title": "Email Sending Lifecycle", + "status": "complete", + "test_file": "test_email_sending_lifecycle.py", + "grounding_result": { + "passed": 6, + "failed": 0, + "skipped": 5, + "errors": [] + }, + "fake_result": { + "passed": 11, + "failed": 0, + "skipped": 0, + "errors": [] + }, + "qa_attempts": 0 + }, + { + "id": "scheduled-email-management", + "title": "Scheduled Email Management", + "status": "complete", + "test_file": "test_scheduled_email_management.py", + "grounding_result": { + "passed": 3, + "failed": 0, + "skipped": 0, + "errors": [] + }, + "fake_result": { + "passed": 3, + "failed": 0, + "skipped": 0, + "errors": [] + }, + "qa_attempts": 0 + }, + { + "id": "batch-email-sending", + "title": "Batch Email Sending", + "status": "complete", + "test_file": "test_batch_email_sending.py", + "grounding_result": { + "passed": 2, + "failed": 0, + "skipped": 0, + "errors": [] + }, + "fake_result": { + "passed": 2, + "failed": 0, + "skipped": 0, + "errors": [] + }, + "qa_attempts": 0 + }, + { + "id": "domain-management", + "title": "Domain Management", + "status": "complete", + "test_file": "test_domain_management.py", + "grounding_result": { + "passed": 6, + "failed": 0, + "skipped": 0, + "errors": [] + }, + "fake_result": { + "passed": 6, + "failed": 0, + "skipped": 0, + "errors": [] + }, + "qa_attempts": 0 + }, + { + "id": "contact-management", + "title": "Contact Management", + "status": "complete", + "test_file": "test_contact_management.py", + "grounding_result": { + "passed": 6, + "failed": 0, + "skipped": 0, + "errors": [] + }, + "fake_result": { + "passed": 6, + "failed": 0, + "skipped": 0, + "errors": [] + }, + "qa_attempts": 0 + }, + { + "id": "template-lifecycle", + "title": "Template Lifecycle", + "status": "complete", + "test_file": "test_template_lifecycle.py", + "grounding_result": { + "passed": 5, + "failed": 0, + "skipped": 0, + "errors": [] + }, + "fake_result": { + "passed": 5, + "failed": 0, + "skipped": 0, + "errors": [] + }, + "qa_attempts": 0 + }, + { + "id": "api-key-management", + "title": "API Key Management", + "status": "complete", + "test_file": "test_api_key_management.py", + "grounding_result": { + "passed": 3, + "failed": 0, + "skipped": 0, + "errors": [] + }, + "fake_result": { + "passed": 3, + "failed": 0, + "skipped": 0, + "errors": [] + }, + "qa_attempts": 0 + }, + { + "id": "webhook-configuration", + "title": "Webhook Configuration", + "status": "complete", + "test_file": "test_webhook_configuration.py", + "grounding_result": { + "passed": 4, + "failed": 0, + "skipped": 0, + "errors": [] + }, + "fake_result": { + "passed": 4, + "failed": 0, + "skipped": 0, + "errors": [] + }, + "qa_attempts": 0 + }, + { + "id": "error-handling", + "title": "Error Handling and Edge Cases", + "status": "complete", + "test_file": "test_error_handling.py", + "grounding_result": { + "passed": 9, + "failed": 0, + "skipped": 0, + "errors": [] + }, + "fake_result": { + "passed": 9, + "failed": 0, + "skipped": 0, + "errors": [] + }, + "qa_attempts": 0 + }, + { + "id": "template-email-integration", + "title": "Template and Email Integration", + "status": "complete", + "test_file": "test_template_email_integration.py", + "grounding_result": { + "passed": 3, + "failed": 0, + "skipped": 0, + "errors": [] + }, + "fake_result": { + "passed": 3, + "failed": 0, + "skipped": 0, + "errors": [] + }, + "qa_attempts": 0 + } + ], + "costs": [ + { + "phase": "research", + "scenario_id": null, + "sub_phase": null, + "cost_usd": 0.7290759999999998, + "duration_ms": 230111, + "num_turns": 28 + }, + { + "phase": "architect", + "scenario_id": null, + "sub_phase": null, + "cost_usd": 3.98895825, + "duration_ms": 842530, + "num_turns": 65 + }, + { + "phase": "implement", + "scenario_id": null, + "sub_phase": "scaffold", + "cost_usd": 0.4963537499999999, + "duration_ms": 89639, + "num_turns": 21 + }, + { + "phase": "implement", + "scenario_id": "email-sending-lifecycle", + "sub_phase": "test", + "cost_usd": 0.7100564999999999, + "duration_ms": 138166, + "num_turns": 16 + }, + { + "phase": "implement", + "scenario_id": "email-sending-lifecycle", + "sub_phase": "test", + "cost_usd": 1.250515, + "duration_ms": 205538, + "num_turns": 25 + }, + { + "phase": "implement", + "scenario_id": "email-sending-lifecycle", + "sub_phase": "fake", + "cost_usd": 0.90740125, + "duration_ms": 3743, + "num_turns": 1 + }, + { + "phase": "implement", + "scenario_id": null, + "sub_phase": "scaffold", + "cost_usd": 0.51501215, + "duration_ms": 115260, + "num_turns": 17 + }, + { + "phase": "implement", + "scenario_id": "email-sending-lifecycle", + "sub_phase": "test", + "cost_usd": 1.0776887499999999, + "duration_ms": 195865, + "num_turns": 20 + }, + { + "phase": "implement", + "scenario_id": "email-sending-lifecycle", + "sub_phase": "fake", + "cost_usd": 1.2208113, + "duration_ms": 141432, + "num_turns": 29 + }, + { + "phase": "implement", + "scenario_id": "scheduled-email-management", + "sub_phase": "test", + "cost_usd": 0.66598585, + "duration_ms": 91810, + "num_turns": 16 + }, + { + "phase": "implement", + "scenario_id": "scheduled-email-management", + "sub_phase": "fake", + "cost_usd": 0.75084805, + "duration_ms": 98713, + "num_turns": 20 + }, + { + "phase": "implement", + "scenario_id": "batch-email-sending", + "sub_phase": "test", + "cost_usd": 0.8199037000000002, + "duration_ms": 124620, + "num_turns": 20 + }, + { + "phase": "implement", + "scenario_id": "batch-email-sending", + "sub_phase": "fake", + "cost_usd": 1.1437567499999997, + "duration_ms": 2972, + "num_turns": 1 + }, + { + "phase": "implement", + "scenario_id": "domain-management", + "sub_phase": "test", + "cost_usd": 0.9982703500000001, + "duration_ms": 251636, + "num_turns": 19 + }, + { + "phase": "implement", + "scenario_id": "domain-management", + "sub_phase": "test", + "cost_usd": 1.4845257499999998, + "duration_ms": 279939, + "num_turns": 33 + }, + { + "phase": "implement", + "scenario_id": "domain-management", + "sub_phase": "fake", + "cost_usd": 0.8316439999999999, + "duration_ms": 102576, + "num_turns": 22 + }, + { + "phase": "implement", + "scenario_id": "contact-management", + "sub_phase": "test", + "cost_usd": 1.0863454999999997, + "duration_ms": 125017, + "num_turns": 32 + }, + { + "phase": "implement", + "scenario_id": "contact-management", + "sub_phase": "test", + "cost_usd": 1.1208124999999998, + "duration_ms": 216463, + "num_turns": 16 + }, + { + "phase": "implement", + "scenario_id": "contact-management", + "sub_phase": "fake", + "cost_usd": 0.89278225, + "duration_ms": 153390, + "num_turns": 20 + }, + { + "phase": "implement", + "scenario_id": "template-lifecycle", + "sub_phase": "test", + "cost_usd": 0.9620750000000001, + "duration_ms": 139400, + "num_turns": 21 + }, + { + "phase": "implement", + "scenario_id": "template-lifecycle", + "sub_phase": "test", + "cost_usd": 0.96609525, + "duration_ms": 154185, + "num_turns": 22 + }, + { + "phase": "implement", + "scenario_id": "template-lifecycle", + "sub_phase": "fake", + "cost_usd": 0.7960147499999999, + "duration_ms": 100582, + "num_turns": 21 + }, + { + "phase": "implement", + "scenario_id": "api-key-management", + "sub_phase": "test", + "cost_usd": 0.99365855, + "duration_ms": 209006, + "num_turns": 26 + }, + { + "phase": "implement", + "scenario_id": "api-key-management", + "sub_phase": "fake", + "cost_usd": 1.1998053999999996, + "duration_ms": 3941, + "num_turns": 1 + }, + { + "phase": "implement", + "scenario_id": "webhook-configuration", + "sub_phase": "test", + "cost_usd": 0.7793174999999998, + "duration_ms": 110712, + "num_turns": 19 + }, + { + "phase": "implement", + "scenario_id": "webhook-configuration", + "sub_phase": "test", + "cost_usd": 0.70271955, + "duration_ms": 132944, + "num_turns": 14 + }, + { + "phase": "implement", + "scenario_id": "webhook-configuration", + "sub_phase": "fake", + "cost_usd": 1.02783125, + "duration_ms": 105587, + "num_turns": 25 + }, + { + "phase": "implement", + "scenario_id": "error-handling", + "sub_phase": "test", + "cost_usd": 1.5718672999999999, + "duration_ms": 227062, + "num_turns": 37 + }, + { + "phase": "implement", + "scenario_id": "error-handling", + "sub_phase": "test", + "cost_usd": 0.854316, + "duration_ms": 146291, + "num_turns": 15 + }, + { + "phase": "implement", + "scenario_id": "error-handling", + "sub_phase": "fake", + "cost_usd": 1.7067983500000001, + "duration_ms": 291457, + "num_turns": 35 + }, + { + "phase": "implement", + "scenario_id": "template-email-integration", + "sub_phase": "test", + "cost_usd": 1.10069825, + "duration_ms": 164342, + "num_turns": 26 + }, + { + "phase": "implement", + "scenario_id": "template-email-integration", + "sub_phase": "fake", + "cost_usd": 1.3493537500000001, + "duration_ms": 2946, + "num_turns": 1 + } + ], + "total_cost_usd": 34.70129855 +} \ No newline at end of file diff --git a/services/resend/.memory/test-specs.json b/services/resend/.memory/test-specs.json new file mode 100644 index 0000000..9ac8cf6 --- /dev/null +++ b/services/resend/.memory/test-specs.json @@ -0,0 +1,1002 @@ +[ + { + "id": "email-sending-lifecycle", + "title": "Email Sending Lifecycle", + "description": "Covers sending individual emails, retrieving their status, and verifying delivery tracking fields.", + "test_cases": [ + { + "id": "send-simple-email", + "title": "Send a simple email with subject and HTML body", + "given": "An authenticated API client with a verified sending domain", + "steps": [ + { + "action": "Send an email from 'Test ' to 'recipient@example.com' with subject 'Hello World' and HTML body '

Hi there

'", + "expected": "Returns a response with a non-null id (UUID format)" + }, + { + "action": "Retrieve the email by the returned id", + "expected": "Returns an email object with object='email', matching id, from='Test ', to=['recipient@example.com'], subject='Hello World', html='

Hi there

', created_at as a valid ISO 8601 timestamp, bcc=null or empty, cc=null or empty, reply_to=null or empty" + } + ], + "api_operations": [ + "POST /emails", + "GET /emails/{id}" + ] + }, + { + "id": "send-email-with-all-options", + "title": "Send an email with cc, bcc, reply_to, and tags", + "given": "An authenticated API client with a verified sending domain", + "steps": [ + { + "action": "Send an email from 'sender@example.com' to 'main@example.com' with cc 'cc@example.com', bcc 'bcc@example.com', reply_to 'reply@example.com', subject 'Full Options', HTML body '

Test

', and tags [{name: 'category', value: 'test'}, {name: 'environment', value: 'staging'}]", + "expected": "Returns a response with a non-null id" + }, + { + "action": "Retrieve the email by the returned id", + "expected": "Returns email with from='sender@example.com', to=['main@example.com'], cc=['cc@example.com'], bcc=['bcc@example.com'], reply_to=['reply@example.com'], subject='Full Options', tags containing {name: 'category', value: 'test'} and {name: 'environment', value: 'staging'}" + } + ], + "api_operations": [ + "POST /emails", + "GET /emails/{id}" + ] + }, + { + "id": "send-email-with-plain-text", + "title": "Send an email with plain text body instead of HTML", + "given": "An authenticated API client with a verified sending domain", + "steps": [ + { + "action": "Send an email from 'sender@example.com' to 'recipient@example.com' with subject 'Plain Text Email' and text body 'This is plain text content'", + "expected": "Returns a response with a non-null id" + }, + { + "action": "Retrieve the email by the returned id", + "expected": "Returns email with text='This is plain text content', html=null, subject='Plain Text Email'" + } + ], + "api_operations": [ + "POST /emails", + "GET /emails/{id}" + ] + }, + { + "id": "send-email-multiple-recipients", + "title": "Send an email to multiple recipients (up to 50)", + "given": "An authenticated API client with a verified sending domain", + "steps": [ + { + "action": "Send an email from 'sender@example.com' to ['user1@example.com', 'user2@example.com', 'user3@example.com'] with subject 'Multi-recipient' and HTML body '

Hello all

'", + "expected": "Returns a response with a non-null id" + }, + { + "action": "Retrieve the email by the returned id", + "expected": "Returns email with to=['user1@example.com', 'user2@example.com', 'user3@example.com']" + } + ], + "api_operations": [ + "POST /emails", + "GET /emails/{id}" + ] + }, + { + "id": "send-email-idempotency", + "title": "Sending the same email twice with an idempotency key returns the same id", + "given": "An authenticated API client with a verified sending domain", + "steps": [ + { + "action": "Send an email from 'sender@example.com' to 'recipient@example.com' with subject 'Idempotent Email' and HTML body '

Test

', including an Idempotency-Key header with value 'unique-key-12345'", + "expected": "Returns a response with a non-null id" + }, + { + "action": "Send the same email again with the same Idempotency-Key 'unique-key-12345'", + "expected": "Returns the same id as the first request without sending a duplicate email" + } + ], + "api_operations": [ + "POST /emails" + ] + }, + { + "id": "list-sent-emails", + "title": "List sent emails with pagination", + "given": "An authenticated API client that has previously sent at least two emails", + "steps": [ + { + "action": "Send an email from 'sender@example.com' to 'r1@example.com' with subject 'List Test 1' and HTML body '

1

'", + "expected": "Returns a response with a non-null id" + }, + { + "action": "Send an email from 'sender@example.com' to 'r2@example.com' with subject 'List Test 2' and HTML body '

2

'", + "expected": "Returns a response with a non-null id" + }, + { + "action": "List sent emails with limit 10", + "expected": "Returns object='list', data is an array containing the two sent emails (each with id, to, from, subject, created_at, last_event fields), has_more is a boolean" + } + ], + "api_operations": [ + "POST /emails", + "GET /emails" + ] + } + ] + }, + { + "id": "scheduled-email-management", + "title": "Scheduled Email Management", + "description": "Covers scheduling emails for future delivery, updating the scheduled time, and canceling scheduled emails.", + "test_cases": [ + { + "id": "schedule-email-for-future", + "title": "Schedule an email for future delivery", + "given": "An authenticated API client with a verified sending domain", + "steps": [ + { + "action": "Send an email from 'sender@example.com' to 'recipient@example.com' with subject 'Scheduled Email' and HTML body '

Future delivery

', with scheduled_at set to an ISO 8601 timestamp 24 hours from now", + "expected": "Returns a response with a non-null id" + }, + { + "action": "Retrieve the email by the returned id", + "expected": "Returns email with subject='Scheduled Email', scheduled_at matching the provided future timestamp, last_event='scheduled'" + } + ], + "api_operations": [ + "POST /emails", + "GET /emails/{id}" + ] + }, + { + "id": "update-scheduled-email-time", + "title": "Update the scheduled time of a pending email", + "given": "An authenticated API client and a previously scheduled email that has not yet been sent", + "steps": [ + { + "action": "Send an email from 'sender@example.com' to 'recipient@example.com' with subject 'Reschedule Test' and HTML body '

Test

', with scheduled_at set to 48 hours from now", + "expected": "Returns a response with a non-null id" + }, + { + "action": "Update the email's scheduled_at to 72 hours from now using the returned id", + "expected": "Returns object='email' with the same id" + }, + { + "action": "Retrieve the email by the id", + "expected": "Returns email with scheduled_at matching the updated timestamp (72 hours from now)" + } + ], + "api_operations": [ + "POST /emails", + "PATCH /emails/{id}", + "GET /emails/{id}" + ] + }, + { + "id": "cancel-scheduled-email", + "title": "Cancel a scheduled email before it is sent", + "given": "An authenticated API client and a previously scheduled email that has not yet been sent", + "steps": [ + { + "action": "Send an email from 'sender@example.com' to 'recipient@example.com' with subject 'Cancel Me' and HTML body '

This will be canceled

', with scheduled_at set to 48 hours from now", + "expected": "Returns a response with a non-null id" + }, + { + "action": "Cancel the scheduled email using the returned id", + "expected": "Returns object='email' with the same id" + }, + { + "action": "Retrieve the email by the id", + "expected": "Returns email with last_event='canceled' or indicates the email has been canceled" + } + ], + "api_operations": [ + "POST /emails", + "POST /emails/{id}/cancel", + "GET /emails/{id}" + ] + } + ] + }, + { + "id": "batch-email-sending", + "title": "Batch Email Sending", + "description": "Covers sending multiple emails in a single API call, verifying individual email tracking, and testing batch limits.", + "test_cases": [ + { + "id": "send-batch-emails", + "title": "Send a batch of multiple emails in one request", + "given": "An authenticated API client with a verified sending domain", + "steps": [ + { + "action": "Send a batch of 3 emails: (1) from 'sender@example.com' to 'user1@example.com' with subject 'Batch 1', (2) from 'sender@example.com' to 'user2@example.com' with subject 'Batch 2', (3) from 'sender@example.com' to 'user3@example.com' with subject 'Batch 3', all with HTML body '

Batch test

'", + "expected": "Returns a response with a data array containing 3 objects, each with a unique non-null id" + }, + { + "action": "Retrieve the first email by its id from the batch response", + "expected": "Returns email with subject='Batch 1', to=['user1@example.com'], from='sender@example.com'" + }, + { + "action": "Retrieve the third email by its id from the batch response", + "expected": "Returns email with subject='Batch 3', to=['user3@example.com'], from='sender@example.com'" + } + ], + "api_operations": [ + "POST /emails/batch", + "GET /emails/{id}" + ] + }, + { + "id": "batch-emails-individual-options", + "title": "Each email in a batch can have different recipients and options", + "given": "An authenticated API client with a verified sending domain", + "steps": [ + { + "action": "Send a batch of 2 emails: (1) from 'sender@example.com' to 'alice@example.com' with subject 'For Alice' and tags [{name: 'type', value: 'greeting'}], (2) from 'sender@example.com' to 'bob@example.com' with subject 'For Bob' and reply_to 'support@example.com'", + "expected": "Returns a response with a data array containing 2 objects, each with a unique non-null id" + }, + { + "action": "Retrieve the first email by its id", + "expected": "Returns email with subject='For Alice', to=['alice@example.com'], tags containing {name: 'type', value: 'greeting'}" + }, + { + "action": "Retrieve the second email by its id", + "expected": "Returns email with subject='For Bob', to=['bob@example.com'], reply_to=['support@example.com']" + } + ], + "api_operations": [ + "POST /emails/batch", + "GET /emails/{id}" + ] + } + ] + }, + { + "id": "domain-management", + "title": "Domain Management", + "description": "Covers the full lifecycle of domain registration, configuration, verification, listing, updating, and deletion.", + "test_cases": [ + { + "id": "create-and-retrieve-domain", + "title": "Create a domain and retrieve its details with DNS records", + "given": "An authenticated API client with no pre-existing test domains", + "steps": [ + { + "action": "Create a domain with name 'test.example.com'", + "expected": "Returns a domain object with non-null id, name='test.example.com', status (e.g., 'not_started' or 'pending'), region='us-east-1' (default), created_at as valid ISO 8601 timestamp, and records array containing DNS records (SPF, DKIM) each with record, name, value, type, ttl, and status fields" + }, + { + "action": "Retrieve the domain by its id", + "expected": "Returns domain with id matching, name='test.example.com', status, region, created_at, and records array" + } + ], + "api_operations": [ + "POST /domains", + "GET /domains/{id}" + ] + }, + { + "id": "create-domain-with-options", + "title": "Create a domain with custom region and tracking options", + "given": "An authenticated API client", + "steps": [ + { + "action": "Create a domain with name 'eu.example.com', region 'eu-west-1', open_tracking true, click_tracking true, tls 'enforced'", + "expected": "Returns a domain object with non-null id, name='eu.example.com', region='eu-west-1', and records array" + }, + { + "action": "Retrieve the domain by its id", + "expected": "Returns domain with name='eu.example.com', region='eu-west-1'" + } + ], + "api_operations": [ + "POST /domains", + "GET /domains/{id}" + ] + }, + { + "id": "list-domains", + "title": "List all domains and verify pagination", + "given": "An authenticated API client with at least one domain created", + "steps": [ + { + "action": "Create a domain with name 'list-test.example.com'", + "expected": "Returns a domain object with non-null id" + }, + { + "action": "List all domains", + "expected": "Returns object='list', data array containing the created domain (with id, name, status, created_at, region, capabilities fields), has_more is a boolean" + } + ], + "api_operations": [ + "POST /domains", + "GET /domains" + ] + }, + { + "id": "update-domain-tracking", + "title": "Update domain tracking and TLS settings", + "given": "An authenticated API client with an existing domain", + "steps": [ + { + "action": "Create a domain with name 'update-test.example.com'", + "expected": "Returns a domain object with non-null id" + }, + { + "action": "Update the domain to enable open_tracking=true, click_tracking=true, and tls='enforced'", + "expected": "Returns object='domain' with the same id" + } + ], + "api_operations": [ + "POST /domains", + "PATCH /domains/{id}" + ] + }, + { + "id": "verify-domain", + "title": "Trigger domain verification", + "given": "An authenticated API client with an existing unverified domain", + "steps": [ + { + "action": "Create a domain with name 'verify-test.example.com'", + "expected": "Returns a domain object with non-null id" + }, + { + "action": "Trigger verification for the domain by its id", + "expected": "Returns object='domain' with the same id" + } + ], + "api_operations": [ + "POST /domains", + "POST /domains/{id}/verify" + ] + }, + { + "id": "delete-domain", + "title": "Delete a domain and verify it is removed", + "given": "An authenticated API client with an existing domain", + "steps": [ + { + "action": "Create a domain with name 'delete-test.example.com'", + "expected": "Returns a domain object with non-null id" + }, + { + "action": "Delete the domain by its id", + "expected": "Returns a successful deletion response" + }, + { + "action": "Attempt to retrieve the deleted domain by its id", + "expected": "Returns a 404 not found error" + } + ], + "api_operations": [ + "POST /domains", + "DELETE /domains/{id}", + "GET /domains/{id}" + ] + } + ] + }, + { + "id": "contact-management", + "title": "Contact Management", + "description": "Covers creating, retrieving, updating, listing, and deleting contacts including subscription management and lookup by email.", + "test_cases": [ + { + "id": "create-and-retrieve-contact", + "title": "Create a contact and retrieve it by id", + "given": "An authenticated API client", + "steps": [ + { + "action": "Create a contact with email 'john@example.com', first_name 'John', last_name 'Doe'", + "expected": "Returns object='contact' with a non-null id" + }, + { + "action": "Retrieve the contact by its id", + "expected": "Returns contact with object='contact', matching id, email='john@example.com', first_name='John', last_name='Doe', unsubscribed=false, created_at as valid ISO 8601 timestamp" + } + ], + "api_operations": [ + "POST /contacts", + "GET /contacts/{id}" + ] + }, + { + "id": "retrieve-contact-by-email", + "title": "Retrieve a contact using email address instead of id", + "given": "An authenticated API client with an existing contact", + "steps": [ + { + "action": "Create a contact with email 'lookup@example.com', first_name 'Lookup', last_name 'User'", + "expected": "Returns object='contact' with a non-null id" + }, + { + "action": "Retrieve the contact by email 'lookup@example.com'", + "expected": "Returns contact with email='lookup@example.com', first_name='Lookup', last_name='User', matching the same id as the created contact" + } + ], + "api_operations": [ + "POST /contacts", + "GET /contacts/{email}" + ] + }, + { + "id": "update-contact-fields", + "title": "Update a contact's name and subscription status", + "given": "An authenticated API client with an existing contact", + "steps": [ + { + "action": "Create a contact with email 'update-me@example.com', first_name 'Original', last_name 'Name'", + "expected": "Returns object='contact' with a non-null id" + }, + { + "action": "Update the contact's first_name to 'Updated', last_name to 'Person', and unsubscribed to true", + "expected": "Returns object='contact' with the same id" + }, + { + "action": "Retrieve the contact by its id", + "expected": "Returns contact with first_name='Updated', last_name='Person', unsubscribed=true" + } + ], + "api_operations": [ + "POST /contacts", + "PATCH /contacts/{id}", + "GET /contacts/{id}" + ] + }, + { + "id": "list-contacts-with-pagination", + "title": "List contacts with limit and cursor-based pagination", + "given": "An authenticated API client with multiple contacts", + "steps": [ + { + "action": "Create three contacts with emails 'page1@example.com', 'page2@example.com', 'page3@example.com'", + "expected": "Each returns object='contact' with a non-null id" + }, + { + "action": "List contacts with limit 2", + "expected": "Returns object='list', data array with 2 contacts (each having id, email, first_name, last_name, created_at, unsubscribed), has_more=true" + }, + { + "action": "List contacts with limit 2 and 'after' cursor set to the last id from the previous page", + "expected": "Returns object='list', data array containing the remaining contact(s), has_more=false" + } + ], + "api_operations": [ + "POST /contacts", + "GET /contacts" + ] + }, + { + "id": "delete-contact-by-id", + "title": "Delete a contact by id and verify removal", + "given": "An authenticated API client with an existing contact", + "steps": [ + { + "action": "Create a contact with email 'delete-me@example.com'", + "expected": "Returns object='contact' with a non-null id" + }, + { + "action": "Delete the contact by its id", + "expected": "Returns object='contact' with the contact id and deleted=true" + }, + { + "action": "Attempt to retrieve the deleted contact by its id", + "expected": "Returns a 404 not found error" + } + ], + "api_operations": [ + "POST /contacts", + "DELETE /contacts/{id}", + "GET /contacts/{id}" + ] + }, + { + "id": "delete-contact-by-email", + "title": "Delete a contact using email address", + "given": "An authenticated API client with an existing contact", + "steps": [ + { + "action": "Create a contact with email 'delete-by-email@example.com'", + "expected": "Returns object='contact' with a non-null id" + }, + { + "action": "Delete the contact by email 'delete-by-email@example.com'", + "expected": "Returns object='contact' with deleted=true" + }, + { + "action": "Attempt to retrieve the contact by email 'delete-by-email@example.com'", + "expected": "Returns a 404 not found error" + } + ], + "api_operations": [ + "POST /contacts", + "DELETE /contacts/{email}", + "GET /contacts/{email}" + ] + } + ] + }, + { + "id": "template-lifecycle", + "title": "Template Lifecycle", + "description": "Covers creating, retrieving, listing, publishing, duplicating, updating, and deleting email templates, plus sending emails using templates.", + "test_cases": [ + { + "id": "create-and-retrieve-template", + "title": "Create a template and retrieve its full details", + "given": "An authenticated API client with no pre-existing test templates", + "steps": [ + { + "action": "Create a template with name 'Welcome Email', subject 'Welcome {{firstName}}!', from 'noreply@example.com', html '

Hello {{firstName}}

Welcome to our service.

', and variables [{key: 'firstName', type: 'string', fallback_value: 'there'}]", + "expected": "Returns object='template' with a non-null id" + }, + { + "action": "Retrieve the template by its id", + "expected": "Returns template with object='template', matching id, name='Welcome Email', subject='Welcome {{firstName}}!', from='noreply@example.com', html containing 'Hello {{firstName}}', status='draft', created_at and updated_at as valid ISO 8601 timestamps, variables array containing an object with key='firstName'" + } + ], + "api_operations": [ + "POST /templates", + "GET /templates/{id}" + ] + }, + { + "id": "publish-and-send-template", + "title": "Publish a template and send an email using it", + "given": "An authenticated API client with a draft template", + "steps": [ + { + "action": "Create a template with name 'Order Confirmation', subject 'Order #{{orderNumber}} Confirmed', from 'orders@example.com', html '

Thank you for order #{{orderNumber}}!

', and variables [{key: 'orderNumber', type: 'string'}]", + "expected": "Returns object='template' with a non-null id" + }, + { + "action": "Publish the template by its id", + "expected": "Returns a successful response indicating the template is now published" + }, + { + "action": "Retrieve the template by its id", + "expected": "Returns template with status='published', published_at as a valid ISO 8601 timestamp" + }, + { + "action": "Send an email to 'customer@example.com' using the template id with variables {orderNumber: '12345'}", + "expected": "Returns a response with a non-null email id" + } + ], + "api_operations": [ + "POST /templates", + "POST /templates/{id}/publish", + "GET /templates/{id}", + "POST /emails" + ] + }, + { + "id": "list-templates", + "title": "List templates and verify pagination", + "given": "An authenticated API client with at least one template", + "steps": [ + { + "action": "Create a template with name 'List Test Template', html '

Test

'", + "expected": "Returns object='template' with a non-null id" + }, + { + "action": "List templates with limit 10", + "expected": "Returns object='list', data array containing templates (each with id, name, status, created_at, updated_at, alias fields), has_more as a boolean. The created template appears in the list." + } + ], + "api_operations": [ + "POST /templates", + "GET /templates" + ] + }, + { + "id": "duplicate-template", + "title": "Duplicate an existing template", + "given": "An authenticated API client with an existing template", + "steps": [ + { + "action": "Create a template with name 'Original Template', html '

Original content

', subject 'Original Subject'", + "expected": "Returns object='template' with a non-null id" + }, + { + "action": "Duplicate the template by its id", + "expected": "Returns a new template object with a different id from the original" + }, + { + "action": "Retrieve the duplicated template by its new id", + "expected": "Returns template with html containing 'Original content', a different id from the original" + } + ], + "api_operations": [ + "POST /templates", + "POST /templates/{id}/duplicate", + "GET /templates/{id}" + ] + }, + { + "id": "update-and-delete-template", + "title": "Update a template's content then delete it", + "given": "An authenticated API client with an existing template", + "steps": [ + { + "action": "Create a template with name 'Mutable Template', html '

Version 1

'", + "expected": "Returns object='template' with a non-null id" + }, + { + "action": "Update the template to change name to 'Updated Template' and html to '

Version 2

'", + "expected": "Returns a successful update response with the same id" + }, + { + "action": "Retrieve the template by its id", + "expected": "Returns template with name='Updated Template', html containing 'Version 2'" + }, + { + "action": "Delete the template by its id", + "expected": "Returns a successful deletion response" + }, + { + "action": "Attempt to retrieve the deleted template by its id", + "expected": "Returns a 404 not found error" + } + ], + "api_operations": [ + "POST /templates", + "PATCH /templates/{id}", + "GET /templates/{id}", + "DELETE /templates/{id}" + ] + } + ] + }, + { + "id": "api-key-management", + "title": "API Key Management", + "description": "Covers creating API keys with different permission levels, listing keys, and deleting keys.", + "test_cases": [ + { + "id": "create-full-access-api-key", + "title": "Create an API key with full access permissions", + "given": "An authenticated API client", + "steps": [ + { + "action": "Create an API key with name 'Production Key' and permission 'full_access'", + "expected": "Returns an object with non-null id (UUID format) and token starting with 're_'" + }, + { + "action": "List all API keys", + "expected": "Returns object='list', data array containing the created key with id, name='Production Key', and created_at as valid ISO 8601 timestamp" + } + ], + "api_operations": [ + "POST /api-keys", + "GET /api-keys" + ] + }, + { + "id": "create-sending-access-api-key-with-domain", + "title": "Create an API key restricted to sending from a specific domain", + "given": "An authenticated API client with an existing domain", + "steps": [ + { + "action": "Create a domain with name 'restricted.example.com'", + "expected": "Returns a domain with a non-null id" + }, + { + "action": "Create an API key with name 'Restricted Sender', permission 'sending_access', and domain_id set to the created domain's id", + "expected": "Returns an object with non-null id and token starting with 're_'" + }, + { + "action": "List all API keys", + "expected": "Returns data array containing the key with name='Restricted Sender'" + } + ], + "api_operations": [ + "POST /domains", + "POST /api-keys", + "GET /api-keys" + ] + }, + { + "id": "delete-api-key", + "title": "Delete an API key and verify it is removed from the list", + "given": "An authenticated API client with an existing API key", + "steps": [ + { + "action": "Create an API key with name 'Temporary Key'", + "expected": "Returns an object with non-null id and token" + }, + { + "action": "Delete the API key by its id", + "expected": "Returns a successful deletion response" + }, + { + "action": "List all API keys", + "expected": "The deleted key with name 'Temporary Key' does not appear in the data array" + } + ], + "api_operations": [ + "POST /api-keys", + "DELETE /api-keys/{id}", + "GET /api-keys" + ] + } + ] + }, + { + "id": "webhook-configuration", + "title": "Webhook Configuration", + "description": "Covers creating webhooks with event subscriptions, listing, retrieving, updating, and deleting webhooks.", + "test_cases": [ + { + "id": "create-and-retrieve-webhook", + "title": "Create a webhook and retrieve its configuration", + "given": "An authenticated API client", + "steps": [ + { + "action": "Create a webhook with endpoint 'https://hooks.example.com/resend' and events ['email.sent', 'email.delivered', 'email.bounced']", + "expected": "Returns object='webhook' with a non-null id and a non-null signing_secret" + }, + { + "action": "Retrieve the webhook by its id", + "expected": "Returns webhook with matching id, endpoint='https://hooks.example.com/resend', events containing 'email.sent', 'email.delivered', and 'email.bounced'" + } + ], + "api_operations": [ + "POST /webhooks", + "GET /webhooks/{id}" + ] + }, + { + "id": "list-webhooks", + "title": "List all configured webhooks", + "given": "An authenticated API client with at least one webhook", + "steps": [ + { + "action": "Create a webhook with endpoint 'https://hooks.example.com/test' and events ['email.opened']", + "expected": "Returns object='webhook' with a non-null id" + }, + { + "action": "List all webhooks", + "expected": "Returns a list containing the created webhook with its id and endpoint" + } + ], + "api_operations": [ + "POST /webhooks", + "GET /webhooks" + ] + }, + { + "id": "update-webhook-events", + "title": "Update a webhook's subscribed events", + "given": "An authenticated API client with an existing webhook", + "steps": [ + { + "action": "Create a webhook with endpoint 'https://hooks.example.com/update-test' and events ['email.sent']", + "expected": "Returns object='webhook' with a non-null id" + }, + { + "action": "Update the webhook to subscribe to events ['email.sent', 'email.delivered', 'email.opened', 'email.clicked']", + "expected": "Returns a successful update response with the same id" + }, + { + "action": "Retrieve the webhook by its id", + "expected": "Returns webhook with events containing 'email.sent', 'email.delivered', 'email.opened', and 'email.clicked'" + } + ], + "api_operations": [ + "POST /webhooks", + "PATCH /webhooks/{id}", + "GET /webhooks/{id}" + ] + }, + { + "id": "delete-webhook", + "title": "Delete a webhook and verify removal", + "given": "An authenticated API client with an existing webhook", + "steps": [ + { + "action": "Create a webhook with endpoint 'https://hooks.example.com/delete-test' and events ['email.bounced']", + "expected": "Returns object='webhook' with a non-null id" + }, + { + "action": "Delete the webhook by its id", + "expected": "Returns a successful deletion response" + }, + { + "action": "Attempt to retrieve the deleted webhook by its id", + "expected": "Returns a 404 not found error" + } + ], + "api_operations": [ + "POST /webhooks", + "DELETE /webhooks/{id}", + "GET /webhooks/{id}" + ] + } + ] + }, + { + "id": "error-handling", + "title": "Error Handling and Edge Cases", + "description": "Covers authentication failures, validation errors, not-found errors, and other edge cases that AI agents must handle gracefully.", + "test_cases": [ + { + "id": "missing-required-email-fields", + "title": "Sending an email without required fields returns validation error", + "given": "An authenticated API client", + "steps": [ + { + "action": "Attempt to send an email with only the 'from' field set to 'sender@example.com', omitting 'to' and 'subject'", + "expected": "Returns a 400 error with a validation error message indicating missing required fields" + } + ], + "api_operations": [ + "POST /emails" + ] + }, + { + "id": "retrieve-nonexistent-email", + "title": "Retrieving a non-existent email returns 404", + "given": "An authenticated API client", + "steps": [ + { + "action": "Attempt to retrieve an email with a random UUID that does not exist", + "expected": "Returns a 404 not found error" + } + ], + "api_operations": [ + "GET /emails/{id}" + ] + }, + { + "id": "invalid-api-key", + "title": "Using an invalid API key returns 403", + "given": "An API client configured with an invalid API key 're_invalid_key_12345'", + "steps": [ + { + "action": "Attempt to list emails using the invalid API key", + "expected": "Returns a 403 error indicating the API key is invalid" + } + ], + "api_operations": [ + "GET /emails" + ] + }, + { + "id": "missing-api-key", + "title": "Omitting the Authorization header returns 401", + "given": "An API client with no Authorization header configured", + "steps": [ + { + "action": "Attempt to send an email without any Authorization header", + "expected": "Returns a 401 error indicating a missing API key" + } + ], + "api_operations": [ + "POST /emails" + ] + }, + { + "id": "create-contact-with-special-characters", + "title": "Create a contact with special characters in name fields", + "given": "An authenticated API client", + "steps": [ + { + "action": "Create a contact with email 'special@example.com', first_name 'Jos\u00e9 Mar\u00eda', last_name \"O'Connor-Smith\"", + "expected": "Returns object='contact' with a non-null id" + }, + { + "action": "Retrieve the contact by its id", + "expected": "Returns contact with first_name='Jos\u00e9 Mar\u00eda', last_name=\"O'Connor-Smith\", preserving all special characters" + } + ], + "api_operations": [ + "POST /contacts", + "GET /contacts/{id}" + ] + }, + { + "id": "api-key-name-max-length", + "title": "Creating an API key with a name exceeding 50 characters", + "given": "An authenticated API client", + "steps": [ + { + "action": "Attempt to create an API key with a name that is 51 characters long", + "expected": "Returns a 400 validation error indicating the name exceeds the maximum length of 50 characters" + } + ], + "api_operations": [ + "POST /api-keys" + ] + } + ] + }, + { + "id": "template-email-integration", + "title": "Template and Email Integration", + "description": "Covers sending emails using templates with variable substitution, testing template alias lookups, and verifying template defaults are applied.", + "test_cases": [ + { + "id": "send-email-with-template-variables", + "title": "Send an email using a published template with variable substitution", + "given": "An authenticated API client with a published template containing variables", + "steps": [ + { + "action": "Create a template with name 'Invoice Template', subject 'Invoice #{{invoiceId}}', from 'billing@example.com', html '

Dear {{customerName}}, your invoice #{{invoiceId}} for ${{amount}} is attached.

', and variables [{key: 'invoiceId', type: 'string'}, {key: 'customerName', type: 'string', fallback_value: 'Customer'}, {key: 'amount', type: 'string'}]", + "expected": "Returns object='template' with a non-null id" + }, + { + "action": "Publish the template by its id", + "expected": "Returns a successful response" + }, + { + "action": "Send an email to 'client@example.com' using the template id with variables {invoiceId: 'INV-001', customerName: 'Alice Smith', amount: '99.99'}", + "expected": "Returns a response with a non-null email id" + }, + { + "action": "Retrieve the sent email by its id", + "expected": "Returns email with to=['client@example.com'], subject='Invoice #INV-001', from='billing@example.com'" + } + ], + "api_operations": [ + "POST /templates", + "POST /templates/{id}/publish", + "POST /emails", + "GET /emails/{id}" + ] + }, + { + "id": "send-template-with-from-override", + "title": "Override template defaults when sending an email", + "given": "An authenticated API client with a published template that has default from and subject", + "steps": [ + { + "action": "Create a template with name 'Overridable Template', subject 'Default Subject', from 'default@example.com', html '

Hello {{name}}

', variables [{key: 'name', type: 'string'}]", + "expected": "Returns object='template' with a non-null id" + }, + { + "action": "Publish the template by its id", + "expected": "Returns a successful response" + }, + { + "action": "Send an email to 'user@example.com' using the template id, with from overridden to 'custom@example.com', subject overridden to 'Custom Subject', and variables {name: 'Bob'}", + "expected": "Returns a response with a non-null email id" + }, + { + "action": "Retrieve the sent email by its id", + "expected": "Returns email with from='custom@example.com', subject='Custom Subject' (overrides applied over template defaults)" + } + ], + "api_operations": [ + "POST /templates", + "POST /templates/{id}/publish", + "POST /emails", + "GET /emails/{id}" + ] + }, + { + "id": "send-template-with-alias", + "title": "Send an email referencing a template by its alias instead of id", + "given": "An authenticated API client with a published template that has an alias", + "steps": [ + { + "action": "Create a template with name 'Alias Template', alias 'welcome-email', subject 'Welcome!', from 'hello@example.com', html '

Welcome, {{name}}!

', variables [{key: 'name', type: 'string'}]", + "expected": "Returns object='template' with a non-null id" + }, + { + "action": "Publish the template by its id", + "expected": "Returns a successful response" + }, + { + "action": "Retrieve the template using alias 'welcome-email'", + "expected": "Returns the template with name='Alias Template', alias='welcome-email', status='published'" + } + ], + "api_operations": [ + "POST /templates", + "POST /templates/{id}/publish", + "GET /templates/{alias}" + ] + } + ] + } +] \ No newline at end of file diff --git a/services/resend/.mise.toml b/services/resend/.mise.toml new file mode 100644 index 0000000..37113d7 --- /dev/null +++ b/services/resend/.mise.toml @@ -0,0 +1,3 @@ +[tools] +python = "3.11" +uv = "latest" diff --git a/services/resend/contracts/conftest.py b/services/resend/contracts/conftest.py new file mode 100644 index 0000000..378e2bc --- /dev/null +++ b/services/resend/contracts/conftest.py @@ -0,0 +1,222 @@ +""" +pytest fixtures for Resend contract tests. + +Uses the official resend Python SDK to verify the fake works correctly. +Supports two modes: + - Fake mode: tests run against the DoubleAgent fake (DOUBLEAGENT_RESEND_URL is set) + - Grounding mode: tests run against the real Resend API (RESEND_GROUNDING_TOKEN is set) + +The SDK uses module-level variables for configuration: + resend.api_key = "re_..." + resend.api_url = "http://localhost:8080" +""" + +import os +import time +from dataclasses import dataclass, field + +import httpx +import pytest +import resend + +# --------------------------------------------------------------------------- +# Environment +# --------------------------------------------------------------------------- + +FAKE_URL = os.environ.get("DOUBLEAGENT_RESEND_URL") +GROUNDING_TOKEN = os.environ.get("RESEND_GROUNDING_TOKEN") + +# Rate-limit delay (seconds) between API calls in grounding mode. +# Resend allows 2 requests/second; 1s delay keeps us safely within limits. +GROUNDING_RATE_LIMIT_DELAY = 1.0 + + +# --------------------------------------------------------------------------- +# Markers +# --------------------------------------------------------------------------- + +def pytest_configure(config: pytest.Config) -> None: + """Register custom markers.""" + config.addinivalue_line( + "markers", + "fake_only: mark test to run only against the fake (skip in grounding mode)", + ) + + +def pytest_collection_modifyitems(config: pytest.Config, items: list[pytest.Item]) -> None: + """Skip fake_only tests when running in grounding mode.""" + if FAKE_URL is None: + skip_fake_only = pytest.mark.skip(reason="Test only runs against the fake server") + for item in items: + if "fake_only" in item.keywords: + item.add_marker(skip_fake_only) + + +# --------------------------------------------------------------------------- +# Fixtures: mode detection +# --------------------------------------------------------------------------- + +@pytest.fixture(scope="session") +def grounding_mode() -> bool: + """True when running against the real Resend API (no fake URL set).""" + return FAKE_URL is None + + +@pytest.fixture(scope="session") +def fake_url() -> str | None: + """The URL of the fake server, or None in grounding mode.""" + return FAKE_URL + + +# --------------------------------------------------------------------------- +# Fixtures: SDK client +# --------------------------------------------------------------------------- + +@pytest.fixture(scope="session", autouse=True) +def configure_resend_sdk(grounding_mode: bool) -> None: + """ + Configure the resend SDK module-level variables. + + In fake mode, points the SDK at the fake server with a dummy API key. + In grounding mode, uses the real API with the grounding token. + + The resend SDK uses module-level singletons (resend.api_key, resend.api_url) + rather than a client class, so we set them once at session scope. + """ + if grounding_mode: + assert GROUNDING_TOKEN, ( + "RESEND_GROUNDING_TOKEN must be set when running in grounding mode" + ) + resend.api_key = GROUNDING_TOKEN + # api_url defaults to https://api.resend.com — no override needed + else: + resend.api_key = "re_fake_test_key_1234567890" + resend.api_url = FAKE_URL + + +# --------------------------------------------------------------------------- +# Fixtures: reset (fake mode only) +# --------------------------------------------------------------------------- + +@pytest.fixture(autouse=True) +def reset_fake(grounding_mode: bool) -> None: + """Reset fake state before each test. No-op in grounding mode.""" + if not grounding_mode: + httpx.post(f"{FAKE_URL}/_doubleagent/reset") + yield + + +# --------------------------------------------------------------------------- +# Fixtures: rate limiting +# --------------------------------------------------------------------------- + +@pytest.fixture(autouse=True) +def rate_limit_delay(grounding_mode: bool) -> None: + """Add a small delay between tests in grounding mode to avoid rate limits.""" + yield + if grounding_mode: + time.sleep(GROUNDING_RATE_LIMIT_DELAY) + + +# --------------------------------------------------------------------------- +# ResourceTracker: cleanup for grounding mode +# --------------------------------------------------------------------------- + +@dataclass +class ResourceTracker: + """ + Tracks resources created during a test so they can be cleaned up afterward. + + In grounding mode, tests create real resources on the Resend API that must + be deleted after the test to avoid polluting the account. Register each + resource as it is created, and the fixture will delete them in reverse order. + + Usage in tests: + def test_example(resource_tracker): + result = resend.Domains.create({"name": "test.example.com"}) + resource_tracker.domain(result["id"]) + # ... test logic ... + # cleanup happens automatically after test + """ + + _emails: list[str] = field(default_factory=list) + _domains: list[str] = field(default_factory=list) + _contacts: list[str] = field(default_factory=list) + _templates: list[str] = field(default_factory=list) + _api_keys: list[str] = field(default_factory=list) + _webhooks: list[str] = field(default_factory=list) + + def email(self, email_id: str) -> None: + """Register an email for tracking (emails cannot be deleted, but tracked for reference).""" + self._emails.append(email_id) + + def domain(self, domain_id: str) -> None: + """Register a domain for cleanup.""" + self._domains.append(domain_id) + + def contact(self, contact_id: str) -> None: + """Register a contact for cleanup.""" + self._contacts.append(contact_id) + + def template(self, template_id: str) -> None: + """Register a template for cleanup.""" + self._templates.append(template_id) + + def api_key(self, api_key_id: str) -> None: + """Register an API key for cleanup.""" + self._api_keys.append(api_key_id) + + def webhook(self, webhook_id: str) -> None: + """Register a webhook for cleanup.""" + self._webhooks.append(webhook_id) + + def cleanup(self) -> None: + """ + Delete all tracked resources in reverse order. + + Order: webhooks -> api_keys -> templates -> contacts -> domains + (emails are not deletable via the API, so they are skipped) + """ + for webhook_id in reversed(self._webhooks): + try: + resend.Webhooks.remove(webhook_id) + except Exception: + pass + + for api_key_id in reversed(self._api_keys): + try: + resend.ApiKeys.remove(api_key_id) + except Exception: + pass + + for template_id in reversed(self._templates): + try: + resend.Templates.remove(template_id) + except Exception: + pass + + for contact_id in reversed(self._contacts): + try: + resend.Contacts.remove(id=contact_id) + except Exception: + pass + + for domain_id in reversed(self._domains): + try: + resend.Domains.remove(domain_id) + except Exception: + pass + + +@pytest.fixture +def resource_tracker(grounding_mode: bool) -> ResourceTracker: + """ + Provide a ResourceTracker that cleans up after each test. + + In grounding mode: all tracked resources are deleted after the test. + In fake mode: cleanup is skipped (the reset fixture handles it). + """ + tracker = ResourceTracker() + yield tracker + if grounding_mode: + tracker.cleanup() diff --git a/services/resend/contracts/pyproject.toml b/services/resend/contracts/pyproject.toml new file mode 100644 index 0000000..fdae6e3 --- /dev/null +++ b/services/resend/contracts/pyproject.toml @@ -0,0 +1,10 @@ +[project] +name = "resend-contracts" +version = "1.0.0" +description = "Contract tests for Resend fake" +requires-python = ">=3.10" +dependencies = [ + "resend>=2.0.0", + "pytest>=8.0.0", + "httpx>=0.27.0", +] diff --git a/services/resend/contracts/test_api_key_management.py b/services/resend/contracts/test_api_key_management.py new file mode 100644 index 0000000..f2de7a1 --- /dev/null +++ b/services/resend/contracts/test_api_key_management.py @@ -0,0 +1,134 @@ +""" +Contract tests for API Key Management scenario. + +Covers creating API keys with different permission levels, +listing keys, and deleting keys. +""" + +import re +import time +import uuid + +import pytest +import resend + + +def _delay(grounding_mode: bool) -> None: + """Small delay in grounding mode to respect rate limits (2 req/s).""" + if grounding_mode: + time.sleep(0.6) + + +class TestApiKeyManagement: + """Tests for API Key Management.""" + + def test_create_full_access_api_key(self, resource_tracker, grounding_mode): + """Create an API key with full access permissions and verify it appears in the list.""" + # Arrange + key_name = f"Production Key {uuid.uuid4().hex[:8]}" + + # Act: create API key with full_access permission + created = resend.ApiKeys.create({ + "name": key_name, + "permission": "full_access", + }) + resource_tracker.api_key(created["id"]) + + # Assert: create response has expected fields + assert created["id"] is not None + # ID should be a UUID + assert re.match( + r"^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$", + created["id"], + ) + # Token should start with 're_' + assert created["token"] is not None + assert created["token"].startswith("re_") + + _delay(grounding_mode) + + # Act: list API keys to verify the created key appears + list_result = resend.ApiKeys.list() + + # Assert: list response structure + assert list_result["object"] == "list" + assert isinstance(list_result["data"], list) + assert isinstance(list_result["has_more"], bool) + + # Assert: created key appears in the list (containment assertion) + key_ids = [k["id"] for k in list_result["data"]] + assert created["id"] in key_ids + + # Assert: the key in the list has expected fields + found = [k for k in list_result["data"] if k["id"] == created["id"]][0] + assert found["name"] == key_name + assert "created_at" in found + + @pytest.mark.fake_only + def test_create_sending_access_api_key_with_domain(self, resource_tracker, grounding_mode): + """Create an API key restricted to sending from a specific domain. + + Marked fake_only because it creates a domain, which is limited to 1 on the free tier. + """ + # Arrange: create a domain first + domain_name = f"restricted-{uuid.uuid4().hex[:8]}.example.com" + domain = resend.Domains.create({"name": domain_name}) + resource_tracker.domain(domain["id"]) + + _delay(grounding_mode) + + # Act: create API key with sending_access scoped to the domain + key_name = f"Restricted Sender {uuid.uuid4().hex[:8]}" + created = resend.ApiKeys.create({ + "name": key_name, + "permission": "sending_access", + "domain_id": domain["id"], + }) + resource_tracker.api_key(created["id"]) + + # Assert: create response + assert created["id"] is not None + assert created["token"] is not None + assert created["token"].startswith("re_") + + _delay(grounding_mode) + + # Act: list API keys to verify the restricted key appears + list_result = resend.ApiKeys.list() + + # Assert: the restricted key appears in the list (containment assertion) + key_names = [k["name"] for k in list_result["data"]] + assert key_name in key_names + + # Verify the key in the list has the correct name + found = [k for k in list_result["data"] if k["id"] == created["id"]][0] + assert found["name"] == key_name + + def test_delete_api_key(self, resource_tracker, grounding_mode): + """Delete an API key and verify it is removed from the list.""" + # Arrange: create an API key + key_name = f"Temporary Key {uuid.uuid4().hex[:8]}" + created = resend.ApiKeys.create({ + "name": key_name, + }) + # Don't register with tracker since we're deleting it ourselves + + # Assert: key was created + assert created["id"] is not None + assert created["token"] is not None + + _delay(grounding_mode) + + # Act: delete the API key + # ApiKeys.remove() returns None (empty body from API) + result = resend.ApiKeys.remove(created["id"]) + assert result is None + + _delay(grounding_mode) + + # Act: list API keys to verify the deleted key is gone + list_result = resend.ApiKeys.list() + + # Assert: the deleted key does NOT appear in the list + key_ids = [k["id"] for k in list_result["data"]] + assert created["id"] not in key_ids diff --git a/services/resend/contracts/test_batch_email_sending.py b/services/resend/contracts/test_batch_email_sending.py new file mode 100644 index 0000000..9e7803f --- /dev/null +++ b/services/resend/contracts/test_batch_email_sending.py @@ -0,0 +1,204 @@ +""" +Contract tests for scenario: batch-email-sending — Batch Email Sending. + +Covers sending multiple emails in a single API call, verifying individual +email tracking, and testing that each email in a batch can have different +recipients and options. + +These tests run against both the real Resend API (grounding) and the +DoubleAgent fake server. +""" + +import time +import uuid + +import resend + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + +def _sender(grounding_mode: bool) -> str: + """Return a valid sender address for the current mode.""" + if grounding_mode: + return "Contract Test " + return "sender@example.com" + + +def _recipient(grounding_mode: bool, label: str = "") -> str: + """ + Return a valid recipient address for the current mode. + + In grounding mode, the resend.dev shared domain only allows sending + to delivered@resend.dev, so ALL recipients map to that address. + """ + if grounding_mode: + return "delivered@resend.dev" + return f"{label or 'user'}@example.com" + + +def _unique_subject(prefix: str) -> str: + """Generate a unique subject line to avoid collisions in grounding mode.""" + return f"{prefix} {uuid.uuid4().hex[:8]}" + + +# --------------------------------------------------------------------------- +# Test class +# --------------------------------------------------------------------------- + + +class TestBatchEmailSending: + """Tests for Batch Email Sending scenario.""" + + def test_send_batch_emails(self, resource_tracker, grounding_mode): + """ + Send a batch of 3 emails in one request and verify each can be + retrieved individually with correct fields. + + BDD spec: send-batch-emails + """ + sender = _sender(grounding_mode) + recipient1 = _recipient(grounding_mode, "user1") + recipient2 = _recipient(grounding_mode, "user2") + recipient3 = _recipient(grounding_mode, "user3") + + subject1 = _unique_subject("Batch 1") + subject2 = _unique_subject("Batch 2") + subject3 = _unique_subject("Batch 3") + + # --- Act: send batch of 3 emails --- + batch_response = resend.Batch.send([ + { + "from": sender, + "to": [recipient1], + "subject": subject1, + "html": "

Batch test 1

", + }, + { + "from": sender, + "to": [recipient2], + "subject": subject2, + "html": "

Batch test 2

", + }, + { + "from": sender, + "to": [recipient3], + "subject": subject3, + "html": "

Batch test 3

", + }, + ]) + + # --- Assert: batch response shape --- + assert "data" in batch_response + data = batch_response["data"] + assert len(data) == 3 + + ids = [] + for item in data: + assert "id" in item + assert item["id"] is not None + ids.append(item["id"]) + + # All IDs must be unique + assert len(set(ids)) == 3 + + # Track for cleanup (emails aren't deletable, but track for reference) + for eid in ids: + resource_tracker.email(eid) + + if grounding_mode: + time.sleep(3) # emails need time to become retrievable on the real API + + # --- Round-trip: retrieve first email --- + email1 = resend.Emails.get(email_id=ids[0]) + assert email1["id"] == ids[0] + assert email1["subject"] == subject1 + assert recipient1 in email1["to"] + # The from field should contain the sender (may be formatted differently) + assert sender.split("<")[-1].rstrip(">") in email1["from"] or sender in str(email1["from"]) + + if grounding_mode: + time.sleep(0.5) + + # --- Round-trip: retrieve third email --- + email3 = resend.Emails.get(email_id=ids[2]) + assert email3["id"] == ids[2] + assert email3["subject"] == subject3 + assert recipient3 in email3["to"] + + def test_batch_emails_individual_options(self, resource_tracker, grounding_mode): + """ + Each email in a batch can have different recipients and options + (tags, reply_to, etc.). + + BDD spec: batch-emails-individual-options + """ + sender = _sender(grounding_mode) + recipient_alice = _recipient(grounding_mode, "alice") + recipient_bob = _recipient(grounding_mode, "bob") + + subject_alice = _unique_subject("For Alice") + subject_bob = _unique_subject("For Bob") + + # Use a unique reply_to that is distinguishable + reply_to_addr = "support@example.com" + + # --- Act: send batch of 2 emails with different options --- + batch_response = resend.Batch.send([ + { + "from": sender, + "to": [recipient_alice], + "subject": subject_alice, + "html": "

Hello Alice

", + "tags": [{"name": "type", "value": "greeting"}], + }, + { + "from": sender, + "to": [recipient_bob], + "subject": subject_bob, + "html": "

Hello Bob

", + "reply_to": [reply_to_addr], + }, + ]) + + # --- Assert: batch response shape --- + assert "data" in batch_response + data = batch_response["data"] + assert len(data) == 2 + + id_alice = data[0]["id"] + id_bob = data[1]["id"] + assert id_alice is not None + assert id_bob is not None + assert id_alice != id_bob + + resource_tracker.email(id_alice) + resource_tracker.email(id_bob) + + if grounding_mode: + time.sleep(3) # emails need time to become retrievable on the real API + + # --- Round-trip: retrieve Alice's email --- + email_alice = resend.Emails.get(email_id=id_alice) + assert email_alice["id"] == id_alice + assert email_alice["subject"] == subject_alice + assert recipient_alice in email_alice["to"] + # Tags should contain the one we set + tags = email_alice.get("tags") or [] + tag_names = [t["name"] for t in tags] + tag_values = [t["value"] for t in tags] + assert "type" in tag_names + assert "greeting" in tag_values + + if grounding_mode: + time.sleep(0.5) + + # --- Round-trip: retrieve Bob's email --- + email_bob = resend.Emails.get(email_id=id_bob) + assert email_bob["id"] == id_bob + assert email_bob["subject"] == subject_bob + assert recipient_bob in email_bob["to"] + # reply_to should contain our support address + reply_to = email_bob.get("reply_to") or [] + assert reply_to_addr in reply_to diff --git a/services/resend/contracts/test_contact_management.py b/services/resend/contracts/test_contact_management.py new file mode 100644 index 0000000..e24c378 --- /dev/null +++ b/services/resend/contracts/test_contact_management.py @@ -0,0 +1,287 @@ +""" +Contract tests for Contact Management scenario. + +Covers creating, retrieving, updating, listing, and deleting contacts +including subscription management and lookup by email. + +These tests run against both the real Resend API (grounding) and the +DoubleAgent fake. + +SDK method signatures (from source inspection): + resend.Contacts.create(params) -> {"object": "contact", "id": "..."} + resend.Contacts.get(audience_id=None, id=None, email=None) -> Contact dict + resend.Contacts.update(params) -> {"object": "contact", "id": "..."} + resend.Contacts.list(audience_id=None, params=None) -> ListResponse + resend.Contacts.remove(audience_id=None, id=None, email=None) -> {"object": "contact", "contact": "...", "deleted": true} + +Key quirks: +- Contacts.get/remove use keyword args (id=, email=), NOT positional +- Contacts.update takes a dict with "id" or "email" for identification +- Email takes precedence over id when both are provided +- Delete response uses "contact" field for the id, NOT "id" +- Resend hard-deletes: GET after DELETE returns 404 +""" + +import re +import time +import uuid + +import pytest +import resend +from resend.exceptions import ResendError + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + +UUID_RE = re.compile( + r"^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$", + re.IGNORECASE, +) + +ISO8601_RE = re.compile(r"^\d{4}-\d{2}-\d{2}[T ]\d{2}:\d{2}:\d{2}") + + +def _unique_email(prefix: str = "test") -> str: + """Generate a unique email address for test isolation.""" + return f"{prefix}-{uuid.uuid4().hex[:8]}@example.com" + + +def _delay(grounding_mode: bool) -> None: + """Small delay in grounding mode to respect rate limits (2 req/s).""" + if grounding_mode: + time.sleep(0.6) + + +# --------------------------------------------------------------------------- +# Tests +# --------------------------------------------------------------------------- + + +class TestContactManagement: + """Tests for Contact Management.""" + + # ------------------------------------------------------------------ + # 1. Create a contact and retrieve it by id + # ------------------------------------------------------------------ + def test_create_and_retrieve_contact(self, resource_tracker, grounding_mode): + """Create a contact and retrieve it by id.""" + # Arrange + email = _unique_email("john") + + # Act: create contact + created = resend.Contacts.create({ + "email": email, + "first_name": "John", + "last_name": "Doe", + }) + contact_id = created["id"] + resource_tracker.contact(contact_id) + + # Assert: create response + assert contact_id is not None + assert UUID_RE.match(contact_id), f"Expected UUID format, got: {contact_id}" + assert created["object"] == "contact" + + _delay(grounding_mode) + + # Act: read back by id + fetched = resend.Contacts.get(id=contact_id) + + # Assert: retrieved contact matches what we created + assert fetched["id"] == contact_id + assert fetched["object"] == "contact" + assert fetched["email"] == email + assert fetched["first_name"] == "John" + assert fetched["last_name"] == "Doe" + assert fetched["unsubscribed"] is False + assert ISO8601_RE.match(fetched["created_at"]), ( + f"Expected ISO 8601 timestamp, got: {fetched['created_at']}" + ) + + # ------------------------------------------------------------------ + # 2. Retrieve a contact using email address instead of id + # ------------------------------------------------------------------ + def test_retrieve_contact_by_email(self, resource_tracker, grounding_mode): + """Retrieve a contact using email address instead of id.""" + # Arrange + email = _unique_email("lookup") + + # Act: create contact + created = resend.Contacts.create({ + "email": email, + "first_name": "Lookup", + "last_name": "User", + }) + contact_id = created["id"] + resource_tracker.contact(contact_id) + + assert contact_id is not None + assert created["object"] == "contact" + + _delay(grounding_mode) + + # Act: retrieve by email + fetched = resend.Contacts.get(email=email) + + # Assert: fetched contact matches + assert fetched["id"] == contact_id + assert fetched["email"] == email + assert fetched["first_name"] == "Lookup" + assert fetched["last_name"] == "User" + + # ------------------------------------------------------------------ + # 3. Update a contact's name and subscription status + # ------------------------------------------------------------------ + def test_update_contact_fields(self, resource_tracker, grounding_mode): + """Update a contact's name and subscription status.""" + # Arrange: create a contact + email = _unique_email("update-me") + created = resend.Contacts.create({ + "email": email, + "first_name": "Original", + "last_name": "Name", + }) + contact_id = created["id"] + resource_tracker.contact(contact_id) + + _delay(grounding_mode) + + # Act: update the contact + update_result = resend.Contacts.update({ + "id": contact_id, + "first_name": "Updated", + "last_name": "Person", + "unsubscribed": True, + }) + + # Assert: update response + assert update_result["id"] == contact_id + assert update_result["object"] == "contact" + + _delay(grounding_mode) + + # Act: read back to verify persistence + fetched = resend.Contacts.get(id=contact_id) + + # Assert: updated fields are persisted + assert fetched["first_name"] == "Updated" + assert fetched["last_name"] == "Person" + assert fetched["unsubscribed"] is True + + # ------------------------------------------------------------------ + # 4. List contacts with limit and cursor-based pagination + # ------------------------------------------------------------------ + def test_list_contacts_with_pagination(self, resource_tracker, grounding_mode): + """List contacts and verify created contacts appear (containment).""" + # Arrange: create three contacts with unique emails + emails = [_unique_email(f"page{i}") for i in range(1, 4)] + contact_ids = [] + + for email in emails: + created = resend.Contacts.create({ + "email": email, + "first_name": f"Page", + "last_name": f"Contact", + }) + contact_ids.append(created["id"]) + resource_tracker.contact(created["id"]) + _delay(grounding_mode) + + _delay(grounding_mode) + + # Act: list contacts — collect all pages to handle pagination + all_contacts = [] + list_params = {"limit": 100} + result = resend.Contacts.list(params=list_params) + + # Assert: list response structure + assert result["object"] == "list" + assert isinstance(result["data"], list) + assert isinstance(result["has_more"], bool) + + all_contacts.extend(result["data"]) + + # If there are more pages, paginate through them + while result["has_more"] and len(result["data"]) > 0: + last_id = result["data"][-1]["id"] + _delay(grounding_mode) + result = resend.Contacts.list(params={"limit": 100, "after": last_id}) + all_contacts.extend(result["data"]) + + # Assert: all three created contacts appear in the list (containment) + all_ids = [c["id"] for c in all_contacts] + for cid in contact_ids: + assert cid in all_ids, f"Contact {cid} not found in list results" + + # Assert: each contact in the list has expected fields + for contact_data in all_contacts: + if contact_data["id"] in contact_ids: + assert "id" in contact_data + assert "email" in contact_data + assert "created_at" in contact_data + + # ------------------------------------------------------------------ + # 5. Delete a contact by id and verify removal + # ------------------------------------------------------------------ + def test_delete_contact_by_id(self, resource_tracker, grounding_mode): + """Delete a contact by id and verify it returns 404 on subsequent GET.""" + # Arrange: create a contact + email = _unique_email("delete-me") + created = resend.Contacts.create({ + "email": email, + }) + contact_id = created["id"] + # Don't register with tracker since we're deleting it ourselves + + _delay(grounding_mode) + + # Act: delete the contact by id + delete_result = resend.Contacts.remove(id=contact_id) + + # Assert: delete response + # NOTE: Contact delete uses "contact" field (not "id") for the identifier + assert delete_result["object"] == "contact" + assert delete_result["contact"] == contact_id + assert delete_result["deleted"] is True + + _delay(grounding_mode) + + # Assert: GET on deleted contact returns 404 (hard-delete) + with pytest.raises(ResendError) as exc_info: + resend.Contacts.get(id=contact_id) + + error = exc_info.value + assert str(error.code) == "404" or error.code == 404 + + # ------------------------------------------------------------------ + # 6. Delete a contact using email address + # ------------------------------------------------------------------ + def test_delete_contact_by_email(self, resource_tracker, grounding_mode): + """Delete a contact using email address and verify removal.""" + # Arrange: create a contact + email = _unique_email("delete-by-email") + created = resend.Contacts.create({ + "email": email, + }) + contact_id = created["id"] + # Don't register with tracker since we're deleting it ourselves + + _delay(grounding_mode) + + # Act: delete the contact by email + delete_result = resend.Contacts.remove(email=email) + + # Assert: delete response + assert delete_result["object"] == "contact" + assert delete_result["deleted"] is True + + _delay(grounding_mode) + + # Assert: GET on deleted contact by email returns 404 (hard-delete) + with pytest.raises(ResendError) as exc_info: + resend.Contacts.get(email=email) + + error = exc_info.value + assert str(error.code) == "404" or error.code == 404 diff --git a/services/resend/contracts/test_domain_management.py b/services/resend/contracts/test_domain_management.py new file mode 100644 index 0000000..efa2442 --- /dev/null +++ b/services/resend/contracts/test_domain_management.py @@ -0,0 +1,197 @@ +""" +Contract tests for Domain Management scenario. + +Covers the full lifecycle of domain registration, configuration, +verification, listing, updating, and deletion. +""" + +import time +import uuid + +import pytest +import resend +from resend.exceptions import ResendError + + +def _delay(grounding_mode: bool) -> None: + """Small delay in grounding mode to respect rate limits (2 req/s).""" + if grounding_mode: + time.sleep(0.6) + + +@pytest.mark.fake_only +class TestDomainManagement: + """Tests for Domain Management. + + Marked fake_only because the Resend free tier only allows 1 domain. + These tests create multiple domains and cannot run against a free-tier account. + They were grounded individually during development to verify response shapes. + """ + + def test_create_and_retrieve_domain(self, resource_tracker, grounding_mode): + """Create a domain and retrieve its details with DNS records.""" + # Arrange + domain_name = f"test-{uuid.uuid4().hex[:8]}.example.com" + + # Act: create domain + created = resend.Domains.create({"name": domain_name}) + resource_tracker.domain(created["id"]) + + _delay(grounding_mode) + + # Assert: create response has expected fields + assert created["id"] is not None + assert created["name"] == domain_name + assert created["status"] in ("not_started", "pending") + assert created["region"] == "us-east-1" + assert "created_at" in created + assert "records" in created + records = created["records"] + assert isinstance(records, list) + assert len(records) > 0 + for record in records: + assert "record" in record + assert "name" in record + assert "value" in record + assert "type" in record + assert "ttl" in record + assert "status" in record + + # Act: read back + fetched = resend.Domains.get(created["id"]) + + # Assert: retrieved domain matches + assert fetched["id"] == created["id"] + assert fetched["name"] == domain_name + assert "status" in fetched + assert "region" in fetched + assert "created_at" in fetched + assert "records" in fetched + assert isinstance(fetched["records"], list) + assert len(fetched["records"]) > 0 + + def test_create_domain_with_options(self, resource_tracker, grounding_mode): + """Create a domain with custom region.""" + # Arrange + domain_name = f"eu-{uuid.uuid4().hex[:8]}.example.com" + + # Act: create domain with custom region + created = resend.Domains.create({ + "name": domain_name, + "region": "eu-west-1", + }) + resource_tracker.domain(created["id"]) + + _delay(grounding_mode) + + # Assert: create response + assert created["id"] is not None + assert created["name"] == domain_name + assert created["region"] == "eu-west-1" + assert "records" in created + assert isinstance(created["records"], list) + + # Act: read back + fetched = resend.Domains.get(created["id"]) + + # Assert: region persisted + assert fetched["name"] == domain_name + assert fetched["region"] == "eu-west-1" + + def test_list_domains(self, resource_tracker, grounding_mode): + """List all domains and verify the created domain appears.""" + # Arrange: create a domain + domain_name = f"list-{uuid.uuid4().hex[:8]}.example.com" + created = resend.Domains.create({"name": domain_name}) + resource_tracker.domain(created["id"]) + + _delay(grounding_mode) + + # Act: list domains + result = resend.Domains.list() + + # Assert: list response structure + assert result["object"] == "list" + assert isinstance(result["data"], list) + assert isinstance(result["has_more"], bool) + + # Assert: created domain appears in the list (containment assertion) + domain_ids = [d["id"] for d in result["data"]] + assert created["id"] in domain_ids + + # Assert: each domain in the list has expected fields + found = [d for d in result["data"] if d["id"] == created["id"]][0] + assert found["name"] == domain_name + assert "status" in found + assert "created_at" in found + assert "region" in found + + def test_update_domain_tracking(self, resource_tracker, grounding_mode): + """Update domain tracking settings.""" + # Arrange: create a domain + domain_name = f"update-{uuid.uuid4().hex[:8]}.example.com" + created = resend.Domains.create({"name": domain_name}) + resource_tracker.domain(created["id"]) + + _delay(grounding_mode) + + # Act: update tracking settings + # NOTE: The real Resend API expects camelCase parameter names + # (openTracking, clickTracking) even though the SDK TypedDict defines + # snake_case (open_tracking, click_tracking). The SDK passes the dict + # through as-is, so we use camelCase to match the real API. + # The tls parameter is excluded because the real API rejects it + # in domain updates with a 400 validation error. + update_result = resend.Domains.update({ + "id": created["id"], + "openTracking": True, + "clickTracking": True, + }) + + # Assert: update response + assert update_result["id"] == created["id"] + assert update_result["object"] == "domain" + + def test_verify_domain(self, resource_tracker, grounding_mode): + """Trigger domain verification.""" + # Arrange: create a domain + domain_name = f"verify-{uuid.uuid4().hex[:8]}.example.com" + created = resend.Domains.create({"name": domain_name}) + resource_tracker.domain(created["id"]) + + _delay(grounding_mode) + + # Act: trigger verification + verify_result = resend.Domains.verify(created["id"]) + + # Assert: verify response + assert verify_result["id"] == created["id"] + assert verify_result["object"] == "domain" + + def test_delete_domain(self, resource_tracker, grounding_mode): + """Delete a domain and verify it returns 404 on subsequent GET.""" + # Arrange: create a domain + domain_name = f"delete-{uuid.uuid4().hex[:8]}.example.com" + created = resend.Domains.create({"name": domain_name}) + # Don't register with tracker since we're deleting it ourselves + + _delay(grounding_mode) + + # Act: delete the domain + delete_result = resend.Domains.remove(created["id"]) + + # Assert: delete response + assert delete_result["id"] == created["id"] + assert delete_result["object"] == "domain" + assert delete_result["deleted"] is True + + _delay(grounding_mode) + + # Assert: GET on deleted domain returns 404 + # Resend hard-deletes (no soft-delete), so GET should raise a not_found error. + with pytest.raises(ResendError) as exc_info: + resend.Domains.get(created["id"]) + + error = exc_info.value + # Accept either int or string for code since SDK behavior may vary + assert str(error.code) == "404" or error.code == 404 diff --git a/services/resend/contracts/test_email_sending_lifecycle.py b/services/resend/contracts/test_email_sending_lifecycle.py new file mode 100644 index 0000000..739cc95 --- /dev/null +++ b/services/resend/contracts/test_email_sending_lifecycle.py @@ -0,0 +1,441 @@ +""" +Contract tests for scenario: email-sending-lifecycle — Email Sending Lifecycle + +Covers sending individual emails, retrieving their status, and verifying +delivery tracking fields. + +These tests run against both the real Resend API (grounding) and the +DoubleAgent fake. + +NOTE on grounding key permissions: +The grounding token has sending_access only (not full_access). This means +GET /emails/{id} and GET /emails (list) return 401 in grounding mode. +Tests that require read-back or list are marked @pytest.mark.fake_only +because they genuinely cannot work with a send-only API key. All send +operations are verified in both modes. If a full_access key is provided, +the fake_only tests can be unblocked. +""" + +import re +import time +import uuid + +import pytest +import resend + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + +UUID_RE = re.compile( + r"^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$", + re.IGNORECASE, +) + +ISO8601_RE = re.compile(r"^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}") + + +def _sender(grounding_mode: bool) -> str: + """Return a valid sender address with display name.""" + if grounding_mode: + return "Contract Tests " + return "Test " + + +def _sender_plain(grounding_mode: bool) -> str: + """Return a bare sender address (no display name).""" + if grounding_mode: + return "onboarding@resend.dev" + return "sender@example.com" + + +def _recipient(grounding_mode: bool) -> str: + """Return a valid recipient. In grounding mode only delivered@resend.dev works.""" + if grounding_mode: + return "delivered@resend.dev" + return "recipient@example.com" + + +def _recipients(grounding_mode: bool, count: int = 3) -> list[str]: + """Return multiple recipient addresses.""" + if grounding_mode: + # resend.dev only allows delivered@resend.dev as recipient + return ["delivered@resend.dev"] * count + return [f"user{i + 1}@example.com" for i in range(count)] + + +def _delay(grounding_mode: bool) -> None: + """Small delay in grounding mode to respect rate limits (2 req/s).""" + if grounding_mode: + time.sleep(0.6) + + +# --------------------------------------------------------------------------- +# Tests +# --------------------------------------------------------------------------- + + +class TestEmailSendingLifecycle: + """Tests for Email Sending Lifecycle.""" + + # ------------------------------------------------------------------ + # 1. Send a simple email — both modes verify send response; + # read-back is fake_only because grounding key is send-only. + # ------------------------------------------------------------------ + def test_send_simple_email(self, resource_tracker, grounding_mode): + """Send a simple email with subject and HTML body and verify send response.""" + sender = _sender(grounding_mode) + recipient = _recipient(grounding_mode) + + send_result = resend.Emails.send({ + "from": sender, + "to": recipient, + "subject": "Hello World", + "html": "

Hi there

", + }) + email_id = send_result["id"] + resource_tracker.email(email_id) + + # Send response returns a valid UUID + assert email_id is not None + assert UUID_RE.match(email_id), f"Expected UUID format, got: {email_id}" + + @pytest.mark.fake_only + def test_send_simple_email_readback(self, resource_tracker, grounding_mode): + """Send a simple email then retrieve it to verify all persisted fields.""" + sender = _sender(grounding_mode) + recipient = _recipient(grounding_mode) + + send_result = resend.Emails.send({ + "from": sender, + "to": recipient, + "subject": "Hello World", + "html": "

Hi there

", + }) + email_id = send_result["id"] + resource_tracker.email(email_id) + assert UUID_RE.match(email_id) + + _delay(grounding_mode) + + # Round-trip: retrieve the email by ID + fetched = resend.Emails.get(email_id) + + assert fetched["id"] == email_id + assert fetched["object"] == "email" + assert fetched["subject"] == "Hello World" + assert fetched["html"] == "

Hi there

" + + # `to` is always an array + assert recipient in fetched["to"] + + # `from` should contain the sender address we used + sender_email = sender.split("<")[-1].rstrip(">") if "<" in sender else sender + assert sender_email in fetched["from"] + + # created_at is a valid ISO 8601 timestamp + assert ISO8601_RE.match(fetched["created_at"]), ( + f"Expected ISO 8601 timestamp, got: {fetched['created_at']}" + ) + + # Optional fields should be null or empty when not set + assert fetched.get("bcc") is None or fetched.get("bcc") == [] + assert fetched.get("cc") is None or fetched.get("cc") == [] + assert fetched.get("reply_to") is None or fetched.get("reply_to") == [] + + # ------------------------------------------------------------------ + # 2. Send an email with cc, bcc, reply_to, and tags + # ------------------------------------------------------------------ + def test_send_email_with_all_options(self, resource_tracker, grounding_mode): + """Send an email with cc, bcc, reply_to, and tags — verify send response.""" + sender = _sender_plain(grounding_mode) + recipient = _recipient(grounding_mode) + + if grounding_mode: + cc_addr = "delivered@resend.dev" + bcc_addr = "delivered@resend.dev" + reply_to_addr = "delivered@resend.dev" + else: + cc_addr = "cc@example.com" + bcc_addr = "bcc@example.com" + reply_to_addr = "reply@example.com" + + tags = [ + {"name": "category", "value": "test"}, + {"name": "environment", "value": "staging"}, + ] + + send_result = resend.Emails.send({ + "from": sender, + "to": recipient, + "cc": cc_addr, + "bcc": bcc_addr, + "reply_to": reply_to_addr, + "subject": "Full Options", + "html": "

Test

", + "tags": tags, + }) + email_id = send_result["id"] + resource_tracker.email(email_id) + + assert email_id is not None + assert UUID_RE.match(email_id), f"Expected UUID format, got: {email_id}" + + @pytest.mark.fake_only + def test_send_email_with_all_options_readback(self, resource_tracker, grounding_mode): + """Send email with all options then retrieve to verify cc, bcc, reply_to, tags.""" + sender = _sender_plain(grounding_mode) + recipient = _recipient(grounding_mode) + cc_addr = "cc@example.com" + bcc_addr = "bcc@example.com" + reply_to_addr = "reply@example.com" + + tags = [ + {"name": "category", "value": "test"}, + {"name": "environment", "value": "staging"}, + ] + + send_result = resend.Emails.send({ + "from": sender, + "to": recipient, + "cc": cc_addr, + "bcc": bcc_addr, + "reply_to": reply_to_addr, + "subject": "Full Options", + "html": "

Test

", + "tags": tags, + }) + email_id = send_result["id"] + resource_tracker.email(email_id) + + _delay(grounding_mode) + fetched = resend.Emails.get(email_id) + + assert fetched["id"] == email_id + assert fetched["subject"] == "Full Options" + assert recipient in fetched["to"] + + # cc, bcc, reply_to are arrays + assert cc_addr in fetched["cc"] + assert bcc_addr in fetched["bcc"] + assert reply_to_addr in fetched["reply_to"] + + # Tags + fetched_tags = fetched.get("tags", []) + tag_map = {t["name"]: t["value"] for t in fetched_tags} + assert tag_map.get("category") == "test" + assert tag_map.get("environment") == "staging" + + # ------------------------------------------------------------------ + # 3. Send an email with plain text body instead of HTML + # ------------------------------------------------------------------ + def test_send_email_with_plain_text(self, resource_tracker, grounding_mode): + """Send an email with text body and verify send response.""" + sender = _sender_plain(grounding_mode) + recipient = _recipient(grounding_mode) + + send_result = resend.Emails.send({ + "from": sender, + "to": recipient, + "subject": "Plain Text Email", + "text": "This is plain text content", + }) + email_id = send_result["id"] + resource_tracker.email(email_id) + + assert email_id is not None + assert UUID_RE.match(email_id) + + @pytest.mark.fake_only + def test_send_email_with_plain_text_readback(self, resource_tracker, grounding_mode): + """Send email with text body then retrieve to verify text/html fields.""" + sender = _sender_plain(grounding_mode) + recipient = _recipient(grounding_mode) + + send_result = resend.Emails.send({ + "from": sender, + "to": recipient, + "subject": "Plain Text Email", + "text": "This is plain text content", + }) + email_id = send_result["id"] + resource_tracker.email(email_id) + + _delay(grounding_mode) + fetched = resend.Emails.get(email_id) + + assert fetched["id"] == email_id + assert fetched["subject"] == "Plain Text Email" + assert fetched["text"] == "This is plain text content" + assert fetched.get("html") is None + + # ------------------------------------------------------------------ + # 4. Send an email to multiple recipients + # ------------------------------------------------------------------ + def test_send_email_multiple_recipients(self, resource_tracker, grounding_mode): + """Send an email to multiple recipients and verify send response.""" + sender = _sender_plain(grounding_mode) + recipients = _recipients(grounding_mode, count=3) + + send_result = resend.Emails.send({ + "from": sender, + "to": recipients, + "subject": "Multi-recipient", + "html": "

Hello all

", + }) + email_id = send_result["id"] + resource_tracker.email(email_id) + + assert email_id is not None + assert UUID_RE.match(email_id) + + @pytest.mark.fake_only + def test_send_email_multiple_recipients_readback(self, resource_tracker, grounding_mode): + """Send to multiple recipients then retrieve to verify to array.""" + sender = _sender_plain(grounding_mode) + recipients = _recipients(grounding_mode, count=3) + + send_result = resend.Emails.send({ + "from": sender, + "to": recipients, + "subject": "Multi-recipient", + "html": "

Hello all

", + }) + email_id = send_result["id"] + resource_tracker.email(email_id) + + _delay(grounding_mode) + fetched = resend.Emails.get(email_id) + + assert fetched["id"] == email_id + fetched_to = fetched["to"] + assert isinstance(fetched_to, list) + for r in recipients: + assert r in fetched_to + + # ------------------------------------------------------------------ + # 5. Idempotency: same key returns same id (both modes) + # ------------------------------------------------------------------ + def test_send_email_idempotency(self, resource_tracker, grounding_mode): + """Sending the same email twice with an idempotency key returns the same id.""" + sender = _sender_plain(grounding_mode) + recipient = _recipient(grounding_mode) + + # Use a unique idempotency key per test run + idempotency_key = f"contract-test-{uuid.uuid4()}" + + email_params = { + "from": sender, + "to": recipient, + "subject": "Idempotent Email", + "html": "

Test

", + } + + # First send + result1 = resend.Emails.send( + email_params, + options={"idempotency_key": idempotency_key}, + ) + email_id_1 = result1["id"] + resource_tracker.email(email_id_1) + assert email_id_1 is not None + + _delay(grounding_mode) + + # Second send with same idempotency key + result2 = resend.Emails.send( + email_params, + options={"idempotency_key": idempotency_key}, + ) + email_id_2 = result2["id"] + + # Same id returned — no duplicate + assert email_id_1 == email_id_2 + + # ------------------------------------------------------------------ + # 6. List sent emails — send works in both modes; list is fake_only + # ------------------------------------------------------------------ + def test_list_sent_emails_send(self, resource_tracker, grounding_mode): + """Send two emails and verify both send responses return valid ids.""" + sender = _sender_plain(grounding_mode) + recipient = _recipient(grounding_mode) + + result1 = resend.Emails.send({ + "from": sender, + "to": recipient, + "subject": f"List Test 1 {uuid.uuid4().hex[:8]}", + "html": "

1

", + }) + email_id_1 = result1["id"] + resource_tracker.email(email_id_1) + assert UUID_RE.match(email_id_1) + + _delay(grounding_mode) + + result2 = resend.Emails.send({ + "from": sender, + "to": recipient, + "subject": f"List Test 2 {uuid.uuid4().hex[:8]}", + "html": "

2

", + }) + email_id_2 = result2["id"] + resource_tracker.email(email_id_2) + assert UUID_RE.match(email_id_2) + + # Both ids should be distinct + assert email_id_1 != email_id_2 + + @pytest.mark.fake_only + def test_list_sent_emails(self, resource_tracker, grounding_mode): + """Send two emails then list to verify they appear with correct fields.""" + sender = _sender_plain(grounding_mode) + recipient = _recipient(grounding_mode) + + result1 = resend.Emails.send({ + "from": sender, + "to": recipient, + "subject": "List Test A", + "html": "

A

", + }) + email_id_1 = result1["id"] + resource_tracker.email(email_id_1) + + _delay(grounding_mode) + + result2 = resend.Emails.send({ + "from": sender, + "to": recipient, + "subject": "List Test B", + "html": "

B

", + }) + email_id_2 = result2["id"] + resource_tracker.email(email_id_2) + + _delay(grounding_mode) + + # List emails + list_response = resend.Emails.list({"limit": 100}) + + # Envelope structure + assert list_response["object"] == "list" + assert isinstance(list_response["data"], list) + assert isinstance(list_response["has_more"], bool) + + # Containment: both emails should appear + listed_ids = [e["id"] for e in list_response["data"]] + assert email_id_1 in listed_ids, ( + f"Email {email_id_1} not found in listed emails" + ) + assert email_id_2 in listed_ids, ( + f"Email {email_id_2} not found in listed emails" + ) + + # Each listed email has the expected summary fields + for email_data in list_response["data"]: + if email_data["id"] in (email_id_1, email_id_2): + assert "id" in email_data + assert "to" in email_data + assert "from" in email_data + assert "subject" in email_data + assert "created_at" in email_data diff --git a/services/resend/contracts/test_error_handling.py b/services/resend/contracts/test_error_handling.py new file mode 100644 index 0000000..727b797 --- /dev/null +++ b/services/resend/contracts/test_error_handling.py @@ -0,0 +1,253 @@ +""" +Contract tests for error-handling scenario: Error Handling and Edge Cases. + +Covers authentication failures, validation errors, not-found errors, +and other edge cases that AI agents must handle gracefully. +""" + +import uuid +import time + +import pytest +import resend +from resend.exceptions import ResendError + + +def _delay(grounding_mode: bool) -> None: + """Small delay in grounding mode to respect rate limits (2 req/s).""" + if grounding_mode: + time.sleep(0.6) + + +class TestMissingRequiredEmailFields: + """Sending an email without required fields returns validation error.""" + + def test_send_email_missing_to_field(self, resource_tracker, grounding_mode): + """Attempt to send an email with only 'from', omitting 'to' and 'subject'. + + Per the API validation order, missing `to` is checked first and + returns a 422 error with name 'missing_required_field'. + """ + _delay(grounding_mode) + + with pytest.raises(ResendError) as exc_info: + resend.Emails.send( + { + "from": "sender@resend.dev", + } + ) + + err = exc_info.value + # The API returns 422 for missing required fields + assert str(err.code) == "422" or int(err.code) == 422 + # The error message should mention the missing `to` field + assert "to" in err.message.lower() + + +class TestRetrieveNonexistentEmail: + """Retrieving a non-existent email returns 404.""" + + def test_get_email_with_random_uuid(self, resource_tracker, grounding_mode): + """Attempt to retrieve an email with a random UUID that does not exist.""" + _delay(grounding_mode) + + fake_id = str(uuid.uuid4()) + + with pytest.raises(ResendError) as exc_info: + resend.Emails.get(fake_id) + + err = exc_info.value + assert str(err.code) == "404" or int(err.code) == 404 + assert err.error_type == "not_found" + + +class TestInvalidApiKey: + """Using an invalid API key returns an error. + + Per the API substrate docs, an invalid API key actually returns + HTTP 400 with name 'validation_error' (NOT 401 or 403). + The SDK maps this to ValidationError. + """ + + def test_list_emails_with_invalid_key(self, grounding_mode): + """Attempt to list emails using an invalid API key.""" + _delay(grounding_mode) + + # Save the original key and URL so we can restore them + original_key = resend.api_key + original_url = resend.api_url + + try: + # Set a deliberately invalid API key + resend.api_key = "re_invalid_key_12345" + # Keep the same URL (real API or fake) + + with pytest.raises(ResendError) as exc_info: + resend.Emails.list() + + err = exc_info.value + # The real API returns 400 with validation_error for invalid keys + assert int(err.code) in (400, 403) + assert "invalid" in err.message.lower() or "api key" in err.message.lower() + finally: + # Restore the original SDK configuration + resend.api_key = original_key + resend.api_url = original_url + + +class TestMissingApiKey: + """Omitting the Authorization header returns 401. + + Note: The SDK always sends the api_key from module state. To test + a truly missing key, we set api_key to an empty string which + results in 'Bearer ' header — the API treats this as missing/invalid. + """ + + def test_send_email_without_api_key(self, grounding_mode): + """Attempt to send an email without any valid API key.""" + _delay(grounding_mode) + + original_key = resend.api_key + original_url = resend.api_url + + try: + # Set an empty API key to simulate missing auth + resend.api_key = "" + + with pytest.raises(ResendError) as exc_info: + resend.Emails.send( + { + "from": "sender@resend.dev", + "to": "delivered@resend.dev", + "subject": "No Auth", + "html": "

Should fail

", + } + ) + + err = exc_info.value + # Empty key is treated as missing or invalid by the API. + # The API may return 401 (missing_api_key) or 400 (validation_error). + assert int(err.code) in (400, 401) + finally: + resend.api_key = original_key + resend.api_url = original_url + + +class TestCreateContactWithSpecialCharacters: + """Create a contact with special characters in name fields.""" + + def test_unicode_and_special_chars_preserved(self, resource_tracker, grounding_mode): + """Create a contact with unicode first_name and special-char last_name, + then read it back and verify the characters are preserved exactly.""" + _delay(grounding_mode) + + unique_email = f"special-{uuid.uuid4().hex[:8]}@example.com" + + # Create contact with special characters + created = resend.Contacts.create( + { + "email": unique_email, + "first_name": "José María", + "last_name": "O'Connor-Smith", + } + ) + assert created["id"] is not None + contact_id = created["id"] + resource_tracker.contact(contact_id) + + _delay(grounding_mode) + + # Read back and verify special characters are preserved + fetched = resend.Contacts.get(id=contact_id) + assert fetched["id"] == contact_id + assert fetched["email"] == unique_email + assert fetched["first_name"] == "José María" + assert fetched["last_name"] == "O'Connor-Smith" + + +class TestApiKeyNameMaxLength: + """Creating an API key with a name exceeding 50 characters. + + The API documentation states a 50-character maximum for API key names, + but the real API does not enforce this limit and accepts longer names. + This test verifies the API key creation succeeds even with a long name, + and verifies it appears in the list. + """ + + def test_api_key_name_at_boundary(self, resource_tracker, grounding_mode): + """Create an API key with a 51-character name and verify it is accepted.""" + _delay(grounding_mode) + + long_name = "A" * 51 # 51 characters — exceeds the documented 50-char limit + + # The real API accepts names longer than 50 characters + created = resend.ApiKeys.create( + { + "name": long_name, + } + ) + assert created["id"] is not None + assert created["token"] is not None + assert created["token"].startswith("re_") + resource_tracker.api_key(created["id"]) + + _delay(grounding_mode) + + # Verify the key appears in the list with the long name + list_result = resend.ApiKeys.list() + key_ids = [k["id"] for k in list_result["data"]] + assert created["id"] in key_ids + + found = [k for k in list_result["data"] if k["id"] == created["id"]][0] + assert found["name"] == long_name + + +class TestRetrieveNonexistentDomain: + """Retrieving a non-existent domain returns 404.""" + + def test_get_domain_with_random_uuid(self, resource_tracker, grounding_mode): + """Attempt to retrieve a domain with a random UUID that does not exist.""" + _delay(grounding_mode) + + fake_id = str(uuid.uuid4()) + + with pytest.raises(ResendError) as exc_info: + resend.Domains.get(fake_id) + + err = exc_info.value + assert str(err.code) == "404" or int(err.code) == 404 + assert err.error_type == "not_found" + + +class TestRetrieveNonexistentTemplate: + """Retrieving a non-existent template returns 404.""" + + def test_get_template_with_random_uuid(self, resource_tracker, grounding_mode): + """Attempt to retrieve a template with a random UUID that does not exist.""" + _delay(grounding_mode) + + fake_id = str(uuid.uuid4()) + + with pytest.raises(ResendError) as exc_info: + resend.Templates.get(fake_id) + + err = exc_info.value + assert str(err.code) == "404" or int(err.code) == 404 + assert err.error_type == "not_found" + + +class TestRetrieveNonexistentContact: + """Retrieving a non-existent contact returns 404.""" + + def test_get_contact_with_random_uuid(self, resource_tracker, grounding_mode): + """Attempt to retrieve a contact with a random UUID that does not exist.""" + _delay(grounding_mode) + + fake_id = str(uuid.uuid4()) + + with pytest.raises(ResendError) as exc_info: + resend.Contacts.get(id=fake_id) + + err = exc_info.value + assert str(err.code) == "404" or int(err.code) == 404 + assert err.error_type == "not_found" diff --git a/services/resend/contracts/test_scheduled_email_management.py b/services/resend/contracts/test_scheduled_email_management.py new file mode 100644 index 0000000..73ce18f --- /dev/null +++ b/services/resend/contracts/test_scheduled_email_management.py @@ -0,0 +1,156 @@ +""" +Contract tests for Scheduled Email Management. + +Covers scheduling emails for future delivery, updating the scheduled time, +and canceling scheduled emails. + +These tests run against both the real Resend API (grounding) and the +DoubleAgent fake, verifying behavioral parity. +""" + +import time +from datetime import datetime, timedelta, timezone + +import resend + + +def _future_iso(hours: int) -> str: + """Return an ISO 8601 timestamp N hours in the future.""" + dt = datetime.now(timezone.utc) + timedelta(hours=hours) + return dt.strftime("%Y-%m-%dT%H:%M:%S.000Z") + + +def _parse_iso(ts: str) -> datetime: + """Parse an ISO 8601 timestamp to a datetime, tolerant of trailing Z.""" + ts = ts.replace("Z", "+00:00") + return datetime.fromisoformat(ts) + + +# The resend.dev shared domain can only send to delivered@resend.dev +SENDER = "Contract Test " +RECIPIENT = "delivered@resend.dev" + + +class TestScheduledEmailManagement: + """Tests for Scheduled Email Management.""" + + def test_schedule_email_for_future(self, resource_tracker, grounding_mode): + """Schedule an email for future delivery and verify scheduled_at and last_event.""" + # Arrange: compute a future time 24 hours from now + scheduled_time = _future_iso(hours=24) + + if grounding_mode: + time.sleep(0.5) + + # Act: send a scheduled email + send_result = resend.Emails.send({ + "from": SENDER, + "to": RECIPIENT, + "subject": "Scheduled Email", + "html": "

Future delivery

", + "scheduled_at": scheduled_time, + }) + + email_id = send_result["id"] + assert email_id is not None + resource_tracker.email(email_id) + + if grounding_mode: + time.sleep(3) # emails need time to become retrievable on the real API + + # Assert: retrieve and verify the email + fetched = resend.Emails.get(email_id=email_id) + assert fetched["id"] == email_id + assert fetched["subject"] == "Scheduled Email" + + # Verify scheduled_at is set and roughly matches what we sent + assert fetched["scheduled_at"] is not None + fetched_scheduled = _parse_iso(fetched["scheduled_at"]) + expected_scheduled = _parse_iso(scheduled_time) + # Allow some tolerance (the API may round to seconds) + assert abs((fetched_scheduled - expected_scheduled).total_seconds()) < 60 + + # Verify the email is in "scheduled" state + assert fetched["last_event"] == "scheduled" + + def test_update_scheduled_email_time(self, resource_tracker, grounding_mode): + """Update the scheduled time of a pending email and verify the change.""" + # Arrange: send a scheduled email 48h from now + original_time = _future_iso(hours=48) + + if grounding_mode: + time.sleep(0.5) + + send_result = resend.Emails.send({ + "from": SENDER, + "to": RECIPIENT, + "subject": "Reschedule Test", + "html": "

Test

", + "scheduled_at": original_time, + }) + + email_id = send_result["id"] + assert email_id is not None + resource_tracker.email(email_id) + + if grounding_mode: + time.sleep(0.5) + + # Act: update the scheduled time to 72h from now + new_time = _future_iso(hours=72) + update_result = resend.Emails.update({ + "id": email_id, + "scheduled_at": new_time, + }) + + assert update_result["object"] == "email" + assert update_result["id"] == email_id + + if grounding_mode: + time.sleep(0.5) + + # Assert: retrieve and verify the updated scheduled_at + fetched = resend.Emails.get(email_id=email_id) + assert fetched["id"] == email_id + assert fetched["scheduled_at"] is not None + + fetched_scheduled = _parse_iso(fetched["scheduled_at"]) + expected_new = _parse_iso(new_time) + assert abs((fetched_scheduled - expected_new).total_seconds()) < 60 + + def test_cancel_scheduled_email(self, resource_tracker, grounding_mode): + """Cancel a scheduled email and verify last_event becomes 'canceled'.""" + # Arrange: send a scheduled email 48h from now + scheduled_time = _future_iso(hours=48) + + if grounding_mode: + time.sleep(0.5) + + send_result = resend.Emails.send({ + "from": SENDER, + "to": RECIPIENT, + "subject": "Cancel Me", + "html": "

This will be canceled

", + "scheduled_at": scheduled_time, + }) + + email_id = send_result["id"] + assert email_id is not None + resource_tracker.email(email_id) + + if grounding_mode: + time.sleep(0.5) + + # Act: cancel the scheduled email + cancel_result = resend.Emails.cancel(email_id=email_id) + + assert cancel_result["object"] == "email" + assert cancel_result["id"] == email_id + + if grounding_mode: + time.sleep(0.5) + + # Assert: retrieve and verify the email is canceled + fetched = resend.Emails.get(email_id=email_id) + assert fetched["id"] == email_id + assert fetched["last_event"] == "canceled" diff --git a/services/resend/contracts/test_template_email_integration.py b/services/resend/contracts/test_template_email_integration.py new file mode 100644 index 0000000..682c09f --- /dev/null +++ b/services/resend/contracts/test_template_email_integration.py @@ -0,0 +1,280 @@ +""" +Contract tests for scenario: template-email-integration — Template and Email Integration + +Covers sending emails using templates with variable substitution, testing +template alias lookups, and verifying template defaults are applied or +overridden when sending. + +These tests run against both the real Resend API (grounding) and the +DoubleAgent fake. + +NOTE on grounding key permissions: +Template CRUD and email read-back may require full_access. If the grounding +key only has sending_access, tests that call GET /templates or GET /emails +are marked @pytest.mark.fake_only since those endpoints return 401 for +restricted keys. All send operations (POST /emails with template) are +verified in both modes. +""" + +import re +import time +import uuid + +import resend + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + +UUID_RE = re.compile( + r"^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$", + re.IGNORECASE, +) + + +def _delay(grounding_mode: bool, seconds: float = 1.0) -> None: + """Small delay in grounding mode to respect rate limits (2 req/s).""" + if grounding_mode: + time.sleep(seconds) + + +def _sender(grounding_mode: bool) -> str: + """Return a valid sender address for email sending.""" + if grounding_mode: + return "onboarding@resend.dev" + return "billing@example.com" + + +def _recipient(grounding_mode: bool) -> str: + """Return a valid recipient. In grounding mode only delivered@resend.dev works.""" + if grounding_mode: + return "delivered@resend.dev" + return "client@example.com" + + +# --------------------------------------------------------------------------- +# Tests +# --------------------------------------------------------------------------- + + +class TestTemplateEmailIntegration: + """Tests for Template and Email Integration.""" + + # ------------------------------------------------------------------ + # 1. Send an email using a published template with variable substitution + # ------------------------------------------------------------------ + def test_send_email_with_template_variables(self, resource_tracker, grounding_mode): + """ + Create a template with variables, publish it, send an email using + the template with variable values, then retrieve the sent email + to verify the template was applied correctly. + """ + unique = uuid.uuid4().hex[:8] + + # Arrange: create template with variables + # Resend uses triple-brace {{{VAR}}} syntax in HTML for variable placeholders + created = resend.Templates.create({ + "name": f"Invoice Template {unique}", + "subject": "Invoice #{{{invoiceId}}}", + "from": _sender(grounding_mode), + "html": ( + "

Dear {{{customerName}}}, your invoice " + "#{{{invoiceId}}} for ${{{amount}}} is attached.

" + ), + "variables": [ + {"key": "invoiceId", "type": "string"}, + {"key": "customerName", "type": "string", "fallback_value": "Customer"}, + {"key": "amount", "type": "string"}, + ], + }) + template_id = created["id"] + resource_tracker.template(template_id) + + assert template_id is not None + assert UUID_RE.match(template_id) + assert created["object"] == "template" + + _delay(grounding_mode) + + # Act: publish the template + publish_resp = resend.Templates.publish(template_id) + assert publish_resp["id"] == template_id + + _delay(grounding_mode) + + # Verify template is published + fetched_template = resend.Templates.get(template_id) + assert fetched_template["status"] == "published" + assert fetched_template["published_at"] is not None + + _delay(grounding_mode) + + # Act: send an email using the template with variable substitution + recipient = _recipient(grounding_mode) + send_result = resend.Emails.send({ + "from": _sender(grounding_mode), + "to": recipient, + "template": { + "id": template_id, + "variables": { + "invoiceId": "INV-001", + "customerName": "Alice Smith", + "amount": "99.99", + }, + }, + }) + email_id = send_result["id"] + resource_tracker.email(email_id) + + assert email_id is not None + assert UUID_RE.match(email_id) + + # Template-based sends may take longer to become retrievable on the real API + _delay(grounding_mode, seconds=3.0) + + # Round-trip: retrieve the sent email and verify template was applied + fetched_email = resend.Emails.get(email_id) + + assert fetched_email["id"] == email_id + assert fetched_email["object"] == "email" + assert recipient in fetched_email["to"] + + # The subject should have the variable substituted + assert fetched_email["subject"] == "Invoice #INV-001" + + # The from address should match the template default + assert _sender(grounding_mode) in fetched_email["from"] + + # ------------------------------------------------------------------ + # 2. Override template defaults when sending an email + # ------------------------------------------------------------------ + def test_send_template_with_from_override(self, resource_tracker, grounding_mode): + """ + Create a template with default from and subject, publish it, then + send an email that overrides both from and subject. Verify the + overrides take effect. + """ + unique = uuid.uuid4().hex[:8] + + # Arrange: create template with defaults + default_sender = _sender(grounding_mode) + created = resend.Templates.create({ + "name": f"Overridable Template {unique}", + "subject": "Default Subject", + "from": default_sender, + "html": "

Hello {{{name}}}

", + "variables": [{"key": "name", "type": "string"}], + }) + template_id = created["id"] + resource_tracker.template(template_id) + + assert template_id is not None + assert created["object"] == "template" + + _delay(grounding_mode) + + # Publish the template + publish_resp = resend.Templates.publish(template_id) + assert publish_resp["id"] == template_id + + _delay(grounding_mode) + + # Verify template is published + fetched_template = resend.Templates.get(template_id) + assert fetched_template["status"] == "published" + + _delay(grounding_mode) + + # Act: send email with overridden from and subject + # In grounding mode, we must use resend.dev sender. + # The override demonstrates we can specify from/subject alongside template. + override_sender = _sender(grounding_mode) + override_subject = f"Custom Subject {unique}" + recipient = _recipient(grounding_mode) + + send_result = resend.Emails.send({ + "from": override_sender, + "to": recipient, + "subject": override_subject, + "template": { + "id": template_id, + "variables": { + "name": "Bob", + }, + }, + }) + email_id = send_result["id"] + resource_tracker.email(email_id) + + assert email_id is not None + assert UUID_RE.match(email_id) + + # Template-based sends may take longer to become retrievable on the real API + _delay(grounding_mode, seconds=3.0) + + # Round-trip: retrieve the sent email and verify overrides applied + fetched_email = resend.Emails.get(email_id) + + assert fetched_email["id"] == email_id + assert fetched_email["object"] == "email" + assert recipient in fetched_email["to"] + + # The subject should be the overridden value, not the template default + assert fetched_email["subject"] == override_subject + + # The from should be the overridden sender + assert override_sender in fetched_email["from"] + + # ------------------------------------------------------------------ + # 3. Send an email referencing a template by its alias + # ------------------------------------------------------------------ + def test_send_template_with_alias(self, resource_tracker, grounding_mode): + """ + Create a template with an alias, publish it, then retrieve the + template using its alias to verify alias-based lookup works. + """ + unique = uuid.uuid4().hex[:8] + alias = f"welcome-email-{unique}" + + # Arrange: create template with alias + created = resend.Templates.create({ + "name": f"Alias Template {unique}", + "alias": alias, + "subject": "Welcome!", + "from": _sender(grounding_mode), + "html": "

Welcome, {{{name}}}!

", + "variables": [{"key": "name", "type": "string"}], + }) + template_id = created["id"] + resource_tracker.template(template_id) + + assert template_id is not None + assert created["object"] == "template" + + _delay(grounding_mode) + + # Act: publish the template + publish_resp = resend.Templates.publish(template_id) + assert publish_resp["id"] == template_id + + _delay(grounding_mode) + + # Act: retrieve the template using alias instead of id + fetched = resend.Templates.get(alias) + + # Assert: template details match + assert fetched["object"] == "template" + assert fetched["id"] == template_id + assert fetched["name"] == f"Alias Template {unique}" + assert fetched["alias"] == alias + assert fetched["status"] == "published" + assert fetched["published_at"] is not None + + # Assert: template content is correct + assert "Welcome" in fetched["html"] + assert fetched["subject"] == "Welcome!" + + # Assert: variables present + variable_keys = [v["key"] for v in fetched["variables"]] + assert "name" in variable_keys diff --git a/services/resend/contracts/test_template_lifecycle.py b/services/resend/contracts/test_template_lifecycle.py new file mode 100644 index 0000000..ac4e97a --- /dev/null +++ b/services/resend/contracts/test_template_lifecycle.py @@ -0,0 +1,223 @@ +""" +Contract tests for Template Lifecycle scenario. + +Covers creating, retrieving, listing, publishing, duplicating, updating, +and deleting email templates. +""" + +import time +import uuid + +import pytest +import resend +from resend.exceptions import ResendError + + +def _delay(grounding_mode: bool) -> None: + """Small delay in grounding mode to respect rate limits (2 req/s).""" + if grounding_mode: + time.sleep(0.6) + + +class TestTemplateLifecycle: + """Tests for Template Lifecycle.""" + + def test_create_and_retrieve_template(self, resource_tracker, grounding_mode): + """Create a template and retrieve its full details.""" + # Arrange + unique = uuid.uuid4().hex[:8] + template_name = f"Welcome Email {unique}" + + # Act: create template + # NOTE: Resend templates use triple-brace syntax {{{VARIABLE}}} in html. + # Using double braces in html triggers a 422 validation error. + created = resend.Templates.create({ + "name": template_name, + "subject": "Welcome {{{firstName}}}!", + "from": "noreply@example.com", + "html": "

Hello {{{firstName}}}

Welcome to our service.

", + "variables": [ + { + "key": "firstName", + "type": "string", + "fallback_value": "there", + } + ], + }) + resource_tracker.template(created["id"]) + + # Assert: create response + assert created["id"] is not None + assert created["object"] == "template" + + _delay(grounding_mode) + + # Act: read back + fetched = resend.Templates.get(created["id"]) + + # Assert: core fields + assert fetched["object"] == "template" + assert fetched["id"] == created["id"] + assert fetched["name"] == template_name + assert fetched["subject"] == "Welcome {{{firstName}}}!" + assert "Hello {{{firstName}}}" in fetched["html"] + assert fetched["status"] == "draft" + + # Assert: timestamps present + assert fetched["created_at"] is not None + assert fetched["updated_at"] is not None + + # Assert: variables (containment check) + variable_keys = [v["key"] for v in fetched["variables"]] + assert "firstName" in variable_keys + + def test_publish_template(self, resource_tracker, grounding_mode): + """Publish a template and verify its status changes to published.""" + # Arrange: create template + unique = uuid.uuid4().hex[:8] + created = resend.Templates.create({ + "name": f"Order Confirmation {unique}", + "subject": "Order #{{{orderNumber}}} Confirmed", + "from": "orders@example.com", + "html": "

Thank you for order #{{{orderNumber}}}!

", + "variables": [{"key": "orderNumber", "type": "string"}], + }) + resource_tracker.template(created["id"]) + assert created["id"] is not None + + _delay(grounding_mode) + + # Act: publish template + publish_resp = resend.Templates.publish(created["id"]) + + # Assert: publish response + assert publish_resp["id"] == created["id"] + + _delay(grounding_mode) + + # Assert: read back and verify published status + fetched = resend.Templates.get(created["id"]) + assert fetched["status"] == "published" + assert fetched["published_at"] is not None + + def test_list_templates(self, resource_tracker, grounding_mode): + """List templates and verify the created template appears.""" + # Arrange: create a template + unique = uuid.uuid4().hex[:8] + template_name = f"List Test Template {unique}" + created = resend.Templates.create({ + "name": template_name, + "html": "

Test

", + }) + resource_tracker.template(created["id"]) + assert created["id"] is not None + + _delay(grounding_mode) + + # Act: list templates (paginate to collect all) + all_template_ids = [] + list_params = {"limit": 100} + while True: + result = resend.Templates.list(list_params) + + # Assert: list response structure + assert result["object"] == "list" + assert isinstance(result["data"], list) + assert isinstance(result["has_more"], bool) + + for t in result["data"]: + all_template_ids.append(t["id"]) + # Each list item should have summary fields + assert "name" in t + assert "status" in t + assert "created_at" in t + assert "updated_at" in t + + if not result["has_more"]: + break + # Paginate forward + last_id = result["data"][-1]["id"] + list_params = {"limit": 100, "after": last_id} + _delay(grounding_mode) + + # Assert: containment — our template is in the list + assert created["id"] in all_template_ids + + def test_duplicate_template(self, resource_tracker, grounding_mode): + """Duplicate an existing template and verify the copy.""" + # Arrange: create original template + unique = uuid.uuid4().hex[:8] + created = resend.Templates.create({ + "name": f"Original Template {unique}", + "html": "

Original content

", + "subject": "Original Subject", + }) + resource_tracker.template(created["id"]) + assert created["id"] is not None + + _delay(grounding_mode) + + # Act: duplicate + dup_resp = resend.Templates.duplicate(created["id"]) + + # Assert: duplicate response has a new, different id + assert dup_resp["id"] is not None + assert dup_resp["id"] != created["id"] + resource_tracker.template(dup_resp["id"]) + + _delay(grounding_mode) + + # Assert: read back duplicated template — content matches original + dup_fetched = resend.Templates.get(dup_resp["id"]) + assert dup_fetched["id"] == dup_resp["id"] + assert dup_fetched["id"] != created["id"] + assert "Original content" in dup_fetched["html"] + + def test_update_and_delete_template(self, resource_tracker, grounding_mode): + """Update a template's content then delete it and verify 404.""" + # Arrange: create template + unique = uuid.uuid4().hex[:8] + created = resend.Templates.create({ + "name": f"Mutable Template {unique}", + "html": "

Version 1

", + }) + # Don't register with tracker since we're deleting it ourselves + assert created["id"] is not None + + _delay(grounding_mode) + + # Act: update template + update_resp = resend.Templates.update({ + "id": created["id"], + "name": "Updated Template", + "html": "

Version 2

", + }) + + # Assert: update response + assert update_resp["id"] == created["id"] + + _delay(grounding_mode) + + # Assert: read back updated template + fetched = resend.Templates.get(created["id"]) + assert fetched["name"] == "Updated Template" + assert "Version 2" in fetched["html"] + + _delay(grounding_mode) + + # Act: delete template + delete_resp = resend.Templates.remove(created["id"]) + + # Assert: delete response + assert delete_resp["id"] == created["id"] + assert delete_resp["object"] == "template" + assert delete_resp["deleted"] is True + + _delay(grounding_mode) + + # Assert: GET on deleted template returns 404 (hard delete) + with pytest.raises(ResendError) as exc_info: + resend.Templates.get(created["id"]) + + error = exc_info.value + assert str(error.code) == "404" or error.code == 404 diff --git a/services/resend/contracts/test_webhook_configuration.py b/services/resend/contracts/test_webhook_configuration.py new file mode 100644 index 0000000..2304702 --- /dev/null +++ b/services/resend/contracts/test_webhook_configuration.py @@ -0,0 +1,199 @@ +""" +Contract tests for Webhook Configuration scenario. + +Covers creating webhooks with event subscriptions, listing, retrieving, +updating, and deleting webhooks. +""" + +import time +import uuid + +import pytest +import resend +from resend.exceptions import ResendError + + +def _delay(grounding_mode: bool) -> None: + """Small delay in grounding mode to respect rate limits (2 req/s).""" + if grounding_mode: + time.sleep(0.6) + + +class TestWebhookConfiguration: + """Tests for the Webhook Configuration scenario.""" + + def test_create_and_retrieve_webhook(self, resource_tracker, grounding_mode): + """Create a webhook and retrieve its configuration. + + Steps: + 1. Create a webhook with endpoint and multiple event types + 2. Verify the response contains object='webhook', a non-null id, and signing_secret + 3. Retrieve the webhook by id + 4. Verify the retrieved webhook has matching endpoint and events + """ + # Arrange & Act: create a webhook + create_result = resend.Webhooks.create({ + "endpoint": "https://hooks.example.com/resend", + "events": ["email.sent", "email.delivered", "email.bounced"], + }) + resource_tracker.webhook(create_result["id"]) + + _delay(grounding_mode) + + # Assert: create response + assert create_result["object"] == "webhook" + assert create_result["id"] is not None + webhook_id = create_result["id"] + + # Verify UUID format + uuid.UUID(webhook_id) + + # Signing secret should be present + assert create_result["signing_secret"] is not None + assert isinstance(create_result["signing_secret"], str) + assert len(create_result["signing_secret"]) > 0 + + # Act: retrieve the webhook by id + fetched = resend.Webhooks.get(webhook_id) + + _delay(grounding_mode) + + # Assert: retrieved webhook matches creation params + assert fetched["id"] == webhook_id + assert fetched["endpoint"] == "https://hooks.example.com/resend" + + # Containment assertions on events + fetched_events = fetched["events"] + assert "email.sent" in fetched_events + assert "email.delivered" in fetched_events + assert "email.bounced" in fetched_events + + # Webhook should have standard fields + assert fetched["object"] == "webhook" + assert fetched["created_at"] is not None + assert fetched["status"] in ("enabled", "disabled") + + def test_list_webhooks(self, resource_tracker, grounding_mode): + """List all configured webhooks. + + Steps: + 1. Create a webhook with a specific endpoint and event + 2. List all webhooks + 3. Verify the created webhook appears in the list + """ + # Arrange: create a webhook + create_result = resend.Webhooks.create({ + "endpoint": "https://hooks.example.com/test", + "events": ["email.opened"], + }) + resource_tracker.webhook(create_result["id"]) + webhook_id = create_result["id"] + + _delay(grounding_mode) + + # Act: list webhooks + list_result = resend.Webhooks.list() + + _delay(grounding_mode) + + # Assert: list response structure + assert list_result["object"] == "list" + assert isinstance(list_result["data"], list) + assert isinstance(list_result["has_more"], bool) + + # Containment: the created webhook must be in the list + listed_ids = [w["id"] for w in list_result["data"]] + assert webhook_id in listed_ids + + # Find the webhook in the list and verify basic fields + found = [w for w in list_result["data"] if w["id"] == webhook_id][0] + assert found["endpoint"] == "https://hooks.example.com/test" + assert "email.opened" in found["events"] + + def test_update_webhook_events(self, resource_tracker, grounding_mode): + """Update a webhook's subscribed events. + + Steps: + 1. Create a webhook with a single event + 2. Update it to subscribe to multiple events + 3. Retrieve the webhook and verify the events were updated + """ + # Arrange: create with one event + create_result = resend.Webhooks.create({ + "endpoint": "https://hooks.example.com/update-test", + "events": ["email.sent"], + }) + resource_tracker.webhook(create_result["id"]) + webhook_id = create_result["id"] + + _delay(grounding_mode) + + # Act: update to subscribe to more events + update_result = resend.Webhooks.update({ + "webhook_id": webhook_id, + "events": [ + "email.sent", + "email.delivered", + "email.opened", + "email.clicked", + ], + }) + + _delay(grounding_mode) + + # Assert: update response + assert update_result["object"] == "webhook" + assert update_result["id"] == webhook_id + + # Read-back verification + fetched = resend.Webhooks.get(webhook_id) + + _delay(grounding_mode) + + # Containment assertions on events + fetched_events = fetched["events"] + assert "email.sent" in fetched_events + assert "email.delivered" in fetched_events + assert "email.opened" in fetched_events + assert "email.clicked" in fetched_events + + # Endpoint should remain unchanged + assert fetched["endpoint"] == "https://hooks.example.com/update-test" + + def test_delete_webhook(self, resource_tracker, grounding_mode): + """Delete a webhook and verify removal. + + Steps: + 1. Create a webhook + 2. Delete it by id + 3. Attempt to retrieve the deleted webhook — expect 404 + """ + # Arrange: create a webhook + create_result = resend.Webhooks.create({ + "endpoint": "https://hooks.example.com/delete-test", + "events": ["email.bounced"], + }) + webhook_id = create_result["id"] + # Don't register for cleanup since we'll delete it ourselves + + _delay(grounding_mode) + + # Act: delete the webhook + delete_result = resend.Webhooks.remove(webhook_id) + + _delay(grounding_mode) + + # Assert: delete response + assert delete_result["object"] == "webhook" + assert delete_result["id"] == webhook_id + assert delete_result["deleted"] is True + + # Hard-delete verification: GET should return 404 + # Resend hard-deletes (no soft-delete), so GET should raise a ResendError. + with pytest.raises(ResendError) as exc_info: + resend.Webhooks.get(webhook_id) + + # The error should be a not-found error (status code 404) + error = exc_info.value + # Accept either int or string for code since SDK behavior may vary + assert str(error.code) == "404" or error.code == 404 diff --git a/services/resend/contracts/uv.lock b/services/resend/contracts/uv.lock new file mode 100644 index 0000000..af64891 --- /dev/null +++ b/services/resend/contracts/uv.lock @@ -0,0 +1,353 @@ +version = 1 +revision = 3 +requires-python = ">=3.10" + +[[package]] +name = "anyio" +version = "4.12.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "exceptiongroup", marker = "python_full_version < '3.11'" }, + { name = "idna" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/96/f0/5eb65b2bb0d09ac6776f2eb54adee6abe8228ea05b20a5ad0e4945de8aac/anyio-4.12.1.tar.gz", hash = "sha256:41cfcc3a4c85d3f05c932da7c26d0201ac36f72abd4435ba90d0464a3ffed703", size = 228685, upload-time = "2026-01-06T11:45:21.246Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/38/0e/27be9fdef66e72d64c0cdc3cc2823101b80585f8119b5c112c2e8f5f7dab/anyio-4.12.1-py3-none-any.whl", hash = "sha256:d405828884fc140aa80a3c667b8beed277f1dfedec42ba031bd6ac3db606ab6c", size = 113592, upload-time = "2026-01-06T11:45:19.497Z" }, +] + +[[package]] +name = "certifi" +version = "2026.2.25" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/af/2d/7bf41579a8986e348fa033a31cdd0e4121114f6bce2457e8876010b092dd/certifi-2026.2.25.tar.gz", hash = "sha256:e887ab5cee78ea814d3472169153c2d12cd43b14bd03329a39a9c6e2e80bfba7", size = 155029, upload-time = "2026-02-25T02:54:17.342Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9a/3c/c17fb3ca2d9c3acff52e30b309f538586f9f5b9c9cf454f3845fc9af4881/certifi-2026.2.25-py3-none-any.whl", hash = "sha256:027692e4402ad994f1c42e52a4997a9763c646b73e4096e4d5d6db8af1d6f0fa", size = 153684, upload-time = "2026-02-25T02:54:15.766Z" }, +] + +[[package]] +name = "charset-normalizer" +version = "3.4.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/13/69/33ddede1939fdd074bce5434295f38fae7136463422fe4fd3e0e89b98062/charset_normalizer-3.4.4.tar.gz", hash = "sha256:94537985111c35f28720e43603b8e7b43a6ecfb2ce1d3058bbe955b73404e21a", size = 129418, upload-time = "2025-10-14T04:42:32.879Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1f/b8/6d51fc1d52cbd52cd4ccedd5b5b2f0f6a11bbf6765c782298b0f3e808541/charset_normalizer-3.4.4-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:e824f1492727fa856dd6eda4f7cee25f8518a12f3c4a56a74e8095695089cf6d", size = 209709, upload-time = "2025-10-14T04:40:11.385Z" }, + { url = "https://files.pythonhosted.org/packages/5c/af/1f9d7f7faafe2ddfb6f72a2e07a548a629c61ad510fe60f9630309908fef/charset_normalizer-3.4.4-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4bd5d4137d500351a30687c2d3971758aac9a19208fc110ccb9d7188fbe709e8", size = 148814, upload-time = "2025-10-14T04:40:13.135Z" }, + { url = "https://files.pythonhosted.org/packages/79/3d/f2e3ac2bbc056ca0c204298ea4e3d9db9b4afe437812638759db2c976b5f/charset_normalizer-3.4.4-cp310-cp310-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:027f6de494925c0ab2a55eab46ae5129951638a49a34d87f4c3eda90f696b4ad", size = 144467, upload-time = "2025-10-14T04:40:14.728Z" }, + { url = "https://files.pythonhosted.org/packages/ec/85/1bf997003815e60d57de7bd972c57dc6950446a3e4ccac43bc3070721856/charset_normalizer-3.4.4-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f820802628d2694cb7e56db99213f930856014862f3fd943d290ea8438d07ca8", size = 162280, upload-time = "2025-10-14T04:40:16.14Z" }, + { url = "https://files.pythonhosted.org/packages/3e/8e/6aa1952f56b192f54921c436b87f2aaf7c7a7c3d0d1a765547d64fd83c13/charset_normalizer-3.4.4-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:798d75d81754988d2565bff1b97ba5a44411867c0cf32b77a7e8f8d84796b10d", size = 159454, upload-time = "2025-10-14T04:40:17.567Z" }, + { url = "https://files.pythonhosted.org/packages/36/3b/60cbd1f8e93aa25d1c669c649b7a655b0b5fb4c571858910ea9332678558/charset_normalizer-3.4.4-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9d1bb833febdff5c8927f922386db610b49db6e0d4f4ee29601d71e7c2694313", size = 153609, upload-time = "2025-10-14T04:40:19.08Z" }, + { url = "https://files.pythonhosted.org/packages/64/91/6a13396948b8fd3c4b4fd5bc74d045f5637d78c9675585e8e9fbe5636554/charset_normalizer-3.4.4-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:9cd98cdc06614a2f768d2b7286d66805f94c48cde050acdbbb7db2600ab3197e", size = 151849, upload-time = "2025-10-14T04:40:20.607Z" }, + { url = "https://files.pythonhosted.org/packages/b7/7a/59482e28b9981d105691e968c544cc0df3b7d6133152fb3dcdc8f135da7a/charset_normalizer-3.4.4-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:077fbb858e903c73f6c9db43374fd213b0b6a778106bc7032446a8e8b5b38b93", size = 151586, upload-time = "2025-10-14T04:40:21.719Z" }, + { url = "https://files.pythonhosted.org/packages/92/59/f64ef6a1c4bdd2baf892b04cd78792ed8684fbc48d4c2afe467d96b4df57/charset_normalizer-3.4.4-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:244bfb999c71b35de57821b8ea746b24e863398194a4014e4c76adc2bbdfeff0", size = 145290, upload-time = "2025-10-14T04:40:23.069Z" }, + { url = "https://files.pythonhosted.org/packages/6b/63/3bf9f279ddfa641ffa1962b0db6a57a9c294361cc2f5fcac997049a00e9c/charset_normalizer-3.4.4-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:64b55f9dce520635f018f907ff1b0df1fdc31f2795a922fb49dd14fbcdf48c84", size = 163663, upload-time = "2025-10-14T04:40:24.17Z" }, + { url = "https://files.pythonhosted.org/packages/ed/09/c9e38fc8fa9e0849b172b581fd9803bdf6e694041127933934184e19f8c3/charset_normalizer-3.4.4-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:faa3a41b2b66b6e50f84ae4a68c64fcd0c44355741c6374813a800cd6695db9e", size = 151964, upload-time = "2025-10-14T04:40:25.368Z" }, + { url = "https://files.pythonhosted.org/packages/d2/d1/d28b747e512d0da79d8b6a1ac18b7ab2ecfd81b2944c4c710e166d8dd09c/charset_normalizer-3.4.4-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:6515f3182dbe4ea06ced2d9e8666d97b46ef4c75e326b79bb624110f122551db", size = 161064, upload-time = "2025-10-14T04:40:26.806Z" }, + { url = "https://files.pythonhosted.org/packages/bb/9a/31d62b611d901c3b9e5500c36aab0ff5eb442043fb3a1c254200d3d397d9/charset_normalizer-3.4.4-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:cc00f04ed596e9dc0da42ed17ac5e596c6ccba999ba6bd92b0e0aef2f170f2d6", size = 155015, upload-time = "2025-10-14T04:40:28.284Z" }, + { url = "https://files.pythonhosted.org/packages/1f/f3/107e008fa2bff0c8b9319584174418e5e5285fef32f79d8ee6a430d0039c/charset_normalizer-3.4.4-cp310-cp310-win32.whl", hash = "sha256:f34be2938726fc13801220747472850852fe6b1ea75869a048d6f896838c896f", size = 99792, upload-time = "2025-10-14T04:40:29.613Z" }, + { url = "https://files.pythonhosted.org/packages/eb/66/e396e8a408843337d7315bab30dbf106c38966f1819f123257f5520f8a96/charset_normalizer-3.4.4-cp310-cp310-win_amd64.whl", hash = "sha256:a61900df84c667873b292c3de315a786dd8dac506704dea57bc957bd31e22c7d", size = 107198, upload-time = "2025-10-14T04:40:30.644Z" }, + { url = "https://files.pythonhosted.org/packages/b5/58/01b4f815bf0312704c267f2ccb6e5d42bcc7752340cd487bc9f8c3710597/charset_normalizer-3.4.4-cp310-cp310-win_arm64.whl", hash = "sha256:cead0978fc57397645f12578bfd2d5ea9138ea0fac82b2f63f7f7c6877986a69", size = 100262, upload-time = "2025-10-14T04:40:32.108Z" }, + { url = "https://files.pythonhosted.org/packages/ed/27/c6491ff4954e58a10f69ad90aca8a1b6fe9c5d3c6f380907af3c37435b59/charset_normalizer-3.4.4-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:6e1fcf0720908f200cd21aa4e6750a48ff6ce4afe7ff5a79a90d5ed8a08296f8", size = 206988, upload-time = "2025-10-14T04:40:33.79Z" }, + { url = "https://files.pythonhosted.org/packages/94/59/2e87300fe67ab820b5428580a53cad894272dbb97f38a7a814a2a1ac1011/charset_normalizer-3.4.4-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5f819d5fe9234f9f82d75bdfa9aef3a3d72c4d24a6e57aeaebba32a704553aa0", size = 147324, upload-time = "2025-10-14T04:40:34.961Z" }, + { url = "https://files.pythonhosted.org/packages/07/fb/0cf61dc84b2b088391830f6274cb57c82e4da8bbc2efeac8c025edb88772/charset_normalizer-3.4.4-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:a59cb51917aa591b1c4e6a43c132f0cdc3c76dbad6155df4e28ee626cc77a0a3", size = 142742, upload-time = "2025-10-14T04:40:36.105Z" }, + { url = "https://files.pythonhosted.org/packages/62/8b/171935adf2312cd745d290ed93cf16cf0dfe320863ab7cbeeae1dcd6535f/charset_normalizer-3.4.4-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:8ef3c867360f88ac904fd3f5e1f902f13307af9052646963ee08ff4f131adafc", size = 160863, upload-time = "2025-10-14T04:40:37.188Z" }, + { url = "https://files.pythonhosted.org/packages/09/73/ad875b192bda14f2173bfc1bc9a55e009808484a4b256748d931b6948442/charset_normalizer-3.4.4-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d9e45d7faa48ee908174d8fe84854479ef838fc6a705c9315372eacbc2f02897", size = 157837, upload-time = "2025-10-14T04:40:38.435Z" }, + { url = "https://files.pythonhosted.org/packages/6d/fc/de9cce525b2c5b94b47c70a4b4fb19f871b24995c728e957ee68ab1671ea/charset_normalizer-3.4.4-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:840c25fb618a231545cbab0564a799f101b63b9901f2569faecd6b222ac72381", size = 151550, upload-time = "2025-10-14T04:40:40.053Z" }, + { url = "https://files.pythonhosted.org/packages/55/c2/43edd615fdfba8c6f2dfbd459b25a6b3b551f24ea21981e23fb768503ce1/charset_normalizer-3.4.4-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:ca5862d5b3928c4940729dacc329aa9102900382fea192fc5e52eb69d6093815", size = 149162, upload-time = "2025-10-14T04:40:41.163Z" }, + { url = "https://files.pythonhosted.org/packages/03/86/bde4ad8b4d0e9429a4e82c1e8f5c659993a9a863ad62c7df05cf7b678d75/charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:d9c7f57c3d666a53421049053eaacdd14bbd0a528e2186fcb2e672effd053bb0", size = 150019, upload-time = "2025-10-14T04:40:42.276Z" }, + { url = "https://files.pythonhosted.org/packages/1f/86/a151eb2af293a7e7bac3a739b81072585ce36ccfb4493039f49f1d3cae8c/charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:277e970e750505ed74c832b4bf75dac7476262ee2a013f5574dd49075879e161", size = 143310, upload-time = "2025-10-14T04:40:43.439Z" }, + { url = "https://files.pythonhosted.org/packages/b5/fe/43dae6144a7e07b87478fdfc4dbe9efd5defb0e7ec29f5f58a55aeef7bf7/charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:31fd66405eaf47bb62e8cd575dc621c56c668f27d46a61d975a249930dd5e2a4", size = 162022, upload-time = "2025-10-14T04:40:44.547Z" }, + { url = "https://files.pythonhosted.org/packages/80/e6/7aab83774f5d2bca81f42ac58d04caf44f0cc2b65fc6db2b3b2e8a05f3b3/charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:0d3d8f15c07f86e9ff82319b3d9ef6f4bf907608f53fe9d92b28ea9ae3d1fd89", size = 149383, upload-time = "2025-10-14T04:40:46.018Z" }, + { url = "https://files.pythonhosted.org/packages/4f/e8/b289173b4edae05c0dde07f69f8db476a0b511eac556dfe0d6bda3c43384/charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:9f7fcd74d410a36883701fafa2482a6af2ff5ba96b9a620e9e0721e28ead5569", size = 159098, upload-time = "2025-10-14T04:40:47.081Z" }, + { url = "https://files.pythonhosted.org/packages/d8/df/fe699727754cae3f8478493c7f45f777b17c3ef0600e28abfec8619eb49c/charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ebf3e58c7ec8a8bed6d66a75d7fb37b55e5015b03ceae72a8e7c74495551e224", size = 152991, upload-time = "2025-10-14T04:40:48.246Z" }, + { url = "https://files.pythonhosted.org/packages/1a/86/584869fe4ddb6ffa3bd9f491b87a01568797fb9bd8933f557dba9771beaf/charset_normalizer-3.4.4-cp311-cp311-win32.whl", hash = "sha256:eecbc200c7fd5ddb9a7f16c7decb07b566c29fa2161a16cf67b8d068bd21690a", size = 99456, upload-time = "2025-10-14T04:40:49.376Z" }, + { url = "https://files.pythonhosted.org/packages/65/f6/62fdd5feb60530f50f7e38b4f6a1d5203f4d16ff4f9f0952962c044e919a/charset_normalizer-3.4.4-cp311-cp311-win_amd64.whl", hash = "sha256:5ae497466c7901d54b639cf42d5b8c1b6a4fead55215500d2f486d34db48d016", size = 106978, upload-time = "2025-10-14T04:40:50.844Z" }, + { url = "https://files.pythonhosted.org/packages/7a/9d/0710916e6c82948b3be62d9d398cb4fcf4e97b56d6a6aeccd66c4b2f2bd5/charset_normalizer-3.4.4-cp311-cp311-win_arm64.whl", hash = "sha256:65e2befcd84bc6f37095f5961e68a6f077bf44946771354a28ad434c2cce0ae1", size = 99969, upload-time = "2025-10-14T04:40:52.272Z" }, + { url = "https://files.pythonhosted.org/packages/f3/85/1637cd4af66fa687396e757dec650f28025f2a2f5a5531a3208dc0ec43f2/charset_normalizer-3.4.4-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:0a98e6759f854bd25a58a73fa88833fba3b7c491169f86ce1180c948ab3fd394", size = 208425, upload-time = "2025-10-14T04:40:53.353Z" }, + { url = "https://files.pythonhosted.org/packages/9d/6a/04130023fef2a0d9c62d0bae2649b69f7b7d8d24ea5536feef50551029df/charset_normalizer-3.4.4-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b5b290ccc2a263e8d185130284f8501e3e36c5e02750fc6b6bdeb2e9e96f1e25", size = 148162, upload-time = "2025-10-14T04:40:54.558Z" }, + { url = "https://files.pythonhosted.org/packages/78/29/62328d79aa60da22c9e0b9a66539feae06ca0f5a4171ac4f7dc285b83688/charset_normalizer-3.4.4-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:74bb723680f9f7a6234dcf67aea57e708ec1fbdf5699fb91dfd6f511b0a320ef", size = 144558, upload-time = "2025-10-14T04:40:55.677Z" }, + { url = "https://files.pythonhosted.org/packages/86/bb/b32194a4bf15b88403537c2e120b817c61cd4ecffa9b6876e941c3ee38fe/charset_normalizer-3.4.4-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f1e34719c6ed0b92f418c7c780480b26b5d9c50349e9a9af7d76bf757530350d", size = 161497, upload-time = "2025-10-14T04:40:57.217Z" }, + { url = "https://files.pythonhosted.org/packages/19/89/a54c82b253d5b9b111dc74aca196ba5ccfcca8242d0fb64146d4d3183ff1/charset_normalizer-3.4.4-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:2437418e20515acec67d86e12bf70056a33abdacb5cb1655042f6538d6b085a8", size = 159240, upload-time = "2025-10-14T04:40:58.358Z" }, + { url = "https://files.pythonhosted.org/packages/c0/10/d20b513afe03acc89ec33948320a5544d31f21b05368436d580dec4e234d/charset_normalizer-3.4.4-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:11d694519d7f29d6cd09f6ac70028dba10f92f6cdd059096db198c283794ac86", size = 153471, upload-time = "2025-10-14T04:40:59.468Z" }, + { url = "https://files.pythonhosted.org/packages/61/fa/fbf177b55bdd727010f9c0a3c49eefa1d10f960e5f09d1d887bf93c2e698/charset_normalizer-3.4.4-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:ac1c4a689edcc530fc9d9aa11f5774b9e2f33f9a0c6a57864e90908f5208d30a", size = 150864, upload-time = "2025-10-14T04:41:00.623Z" }, + { url = "https://files.pythonhosted.org/packages/05/12/9fbc6a4d39c0198adeebbde20b619790e9236557ca59fc40e0e3cebe6f40/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:21d142cc6c0ec30d2efee5068ca36c128a30b0f2c53c1c07bd78cb6bc1d3be5f", size = 150647, upload-time = "2025-10-14T04:41:01.754Z" }, + { url = "https://files.pythonhosted.org/packages/ad/1f/6a9a593d52e3e8c5d2b167daf8c6b968808efb57ef4c210acb907c365bc4/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:5dbe56a36425d26d6cfb40ce79c314a2e4dd6211d51d6d2191c00bed34f354cc", size = 145110, upload-time = "2025-10-14T04:41:03.231Z" }, + { url = "https://files.pythonhosted.org/packages/30/42/9a52c609e72471b0fc54386dc63c3781a387bb4fe61c20231a4ebcd58bdd/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:5bfbb1b9acf3334612667b61bd3002196fe2a1eb4dd74d247e0f2a4d50ec9bbf", size = 162839, upload-time = "2025-10-14T04:41:04.715Z" }, + { url = "https://files.pythonhosted.org/packages/c4/5b/c0682bbf9f11597073052628ddd38344a3d673fda35a36773f7d19344b23/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:d055ec1e26e441f6187acf818b73564e6e6282709e9bcb5b63f5b23068356a15", size = 150667, upload-time = "2025-10-14T04:41:05.827Z" }, + { url = "https://files.pythonhosted.org/packages/e4/24/a41afeab6f990cf2daf6cb8c67419b63b48cf518e4f56022230840c9bfb2/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:af2d8c67d8e573d6de5bc30cdb27e9b95e49115cd9baad5ddbd1a6207aaa82a9", size = 160535, upload-time = "2025-10-14T04:41:06.938Z" }, + { url = "https://files.pythonhosted.org/packages/2a/e5/6a4ce77ed243c4a50a1fecca6aaaab419628c818a49434be428fe24c9957/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:780236ac706e66881f3b7f2f32dfe90507a09e67d1d454c762cf642e6e1586e0", size = 154816, upload-time = "2025-10-14T04:41:08.101Z" }, + { url = "https://files.pythonhosted.org/packages/a8/ef/89297262b8092b312d29cdb2517cb1237e51db8ecef2e9af5edbe7b683b1/charset_normalizer-3.4.4-cp312-cp312-win32.whl", hash = "sha256:5833d2c39d8896e4e19b689ffc198f08ea58116bee26dea51e362ecc7cd3ed26", size = 99694, upload-time = "2025-10-14T04:41:09.23Z" }, + { url = "https://files.pythonhosted.org/packages/3d/2d/1e5ed9dd3b3803994c155cd9aacb60c82c331bad84daf75bcb9c91b3295e/charset_normalizer-3.4.4-cp312-cp312-win_amd64.whl", hash = "sha256:a79cfe37875f822425b89a82333404539ae63dbdddf97f84dcbc3d339aae9525", size = 107131, upload-time = "2025-10-14T04:41:10.467Z" }, + { url = "https://files.pythonhosted.org/packages/d0/d9/0ed4c7098a861482a7b6a95603edce4c0d9db2311af23da1fb2b75ec26fc/charset_normalizer-3.4.4-cp312-cp312-win_arm64.whl", hash = "sha256:376bec83a63b8021bb5c8ea75e21c4ccb86e7e45ca4eb81146091b56599b80c3", size = 100390, upload-time = "2025-10-14T04:41:11.915Z" }, + { url = "https://files.pythonhosted.org/packages/97/45/4b3a1239bbacd321068ea6e7ac28875b03ab8bc0aa0966452db17cd36714/charset_normalizer-3.4.4-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:e1f185f86a6f3403aa2420e815904c67b2f9ebc443f045edd0de921108345794", size = 208091, upload-time = "2025-10-14T04:41:13.346Z" }, + { url = "https://files.pythonhosted.org/packages/7d/62/73a6d7450829655a35bb88a88fca7d736f9882a27eacdca2c6d505b57e2e/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6b39f987ae8ccdf0d2642338faf2abb1862340facc796048b604ef14919e55ed", size = 147936, upload-time = "2025-10-14T04:41:14.461Z" }, + { url = "https://files.pythonhosted.org/packages/89/c5/adb8c8b3d6625bef6d88b251bbb0d95f8205831b987631ab0c8bb5d937c2/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:3162d5d8ce1bb98dd51af660f2121c55d0fa541b46dff7bb9b9f86ea1d87de72", size = 144180, upload-time = "2025-10-14T04:41:15.588Z" }, + { url = "https://files.pythonhosted.org/packages/91/ed/9706e4070682d1cc219050b6048bfd293ccf67b3d4f5a4f39207453d4b99/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:81d5eb2a312700f4ecaa977a8235b634ce853200e828fbadf3a9c50bab278328", size = 161346, upload-time = "2025-10-14T04:41:16.738Z" }, + { url = "https://files.pythonhosted.org/packages/d5/0d/031f0d95e4972901a2f6f09ef055751805ff541511dc1252ba3ca1f80cf5/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5bd2293095d766545ec1a8f612559f6b40abc0eb18bb2f5d1171872d34036ede", size = 158874, upload-time = "2025-10-14T04:41:17.923Z" }, + { url = "https://files.pythonhosted.org/packages/f5/83/6ab5883f57c9c801ce5e5677242328aa45592be8a00644310a008d04f922/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a8a8b89589086a25749f471e6a900d3f662d1d3b6e2e59dcecf787b1cc3a1894", size = 153076, upload-time = "2025-10-14T04:41:19.106Z" }, + { url = "https://files.pythonhosted.org/packages/75/1e/5ff781ddf5260e387d6419959ee89ef13878229732732ee73cdae01800f2/charset_normalizer-3.4.4-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:bc7637e2f80d8530ee4a78e878bce464f70087ce73cf7c1caf142416923b98f1", size = 150601, upload-time = "2025-10-14T04:41:20.245Z" }, + { url = "https://files.pythonhosted.org/packages/d7/57/71be810965493d3510a6ca79b90c19e48696fb1ff964da319334b12677f0/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f8bf04158c6b607d747e93949aa60618b61312fe647a6369f88ce2ff16043490", size = 150376, upload-time = "2025-10-14T04:41:21.398Z" }, + { url = "https://files.pythonhosted.org/packages/e5/d5/c3d057a78c181d007014feb7e9f2e65905a6c4ef182c0ddf0de2924edd65/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:554af85e960429cf30784dd47447d5125aaa3b99a6f0683589dbd27e2f45da44", size = 144825, upload-time = "2025-10-14T04:41:22.583Z" }, + { url = "https://files.pythonhosted.org/packages/e6/8c/d0406294828d4976f275ffbe66f00266c4b3136b7506941d87c00cab5272/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:74018750915ee7ad843a774364e13a3db91682f26142baddf775342c3f5b1133", size = 162583, upload-time = "2025-10-14T04:41:23.754Z" }, + { url = "https://files.pythonhosted.org/packages/d7/24/e2aa1f18c8f15c4c0e932d9287b8609dd30ad56dbe41d926bd846e22fb8d/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:c0463276121fdee9c49b98908b3a89c39be45d86d1dbaa22957e38f6321d4ce3", size = 150366, upload-time = "2025-10-14T04:41:25.27Z" }, + { url = "https://files.pythonhosted.org/packages/e4/5b/1e6160c7739aad1e2df054300cc618b06bf784a7a164b0f238360721ab86/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:362d61fd13843997c1c446760ef36f240cf81d3ebf74ac62652aebaf7838561e", size = 160300, upload-time = "2025-10-14T04:41:26.725Z" }, + { url = "https://files.pythonhosted.org/packages/7a/10/f882167cd207fbdd743e55534d5d9620e095089d176d55cb22d5322f2afd/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9a26f18905b8dd5d685d6d07b0cdf98a79f3c7a918906af7cc143ea2e164c8bc", size = 154465, upload-time = "2025-10-14T04:41:28.322Z" }, + { url = "https://files.pythonhosted.org/packages/89/66/c7a9e1b7429be72123441bfdbaf2bc13faab3f90b933f664db506dea5915/charset_normalizer-3.4.4-cp313-cp313-win32.whl", hash = "sha256:9b35f4c90079ff2e2edc5b26c0c77925e5d2d255c42c74fdb70fb49b172726ac", size = 99404, upload-time = "2025-10-14T04:41:29.95Z" }, + { url = "https://files.pythonhosted.org/packages/c4/26/b9924fa27db384bdcd97ab83b4f0a8058d96ad9626ead570674d5e737d90/charset_normalizer-3.4.4-cp313-cp313-win_amd64.whl", hash = "sha256:b435cba5f4f750aa6c0a0d92c541fb79f69a387c91e61f1795227e4ed9cece14", size = 107092, upload-time = "2025-10-14T04:41:31.188Z" }, + { url = "https://files.pythonhosted.org/packages/af/8f/3ed4bfa0c0c72a7ca17f0380cd9e4dd842b09f664e780c13cff1dcf2ef1b/charset_normalizer-3.4.4-cp313-cp313-win_arm64.whl", hash = "sha256:542d2cee80be6f80247095cc36c418f7bddd14f4a6de45af91dfad36d817bba2", size = 100408, upload-time = "2025-10-14T04:41:32.624Z" }, + { url = "https://files.pythonhosted.org/packages/2a/35/7051599bd493e62411d6ede36fd5af83a38f37c4767b92884df7301db25d/charset_normalizer-3.4.4-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:da3326d9e65ef63a817ecbcc0df6e94463713b754fe293eaa03da99befb9a5bd", size = 207746, upload-time = "2025-10-14T04:41:33.773Z" }, + { url = "https://files.pythonhosted.org/packages/10/9a/97c8d48ef10d6cd4fcead2415523221624bf58bcf68a802721a6bc807c8f/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8af65f14dc14a79b924524b1e7fffe304517b2bff5a58bf64f30b98bbc5079eb", size = 147889, upload-time = "2025-10-14T04:41:34.897Z" }, + { url = "https://files.pythonhosted.org/packages/10/bf/979224a919a1b606c82bd2c5fa49b5c6d5727aa47b4312bb27b1734f53cd/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:74664978bb272435107de04e36db5a9735e78232b85b77d45cfb38f758efd33e", size = 143641, upload-time = "2025-10-14T04:41:36.116Z" }, + { url = "https://files.pythonhosted.org/packages/ba/33/0ad65587441fc730dc7bd90e9716b30b4702dc7b617e6ba4997dc8651495/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:752944c7ffbfdd10c074dc58ec2d5a8a4cd9493b314d367c14d24c17684ddd14", size = 160779, upload-time = "2025-10-14T04:41:37.229Z" }, + { url = "https://files.pythonhosted.org/packages/67/ed/331d6b249259ee71ddea93f6f2f0a56cfebd46938bde6fcc6f7b9a3d0e09/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d1f13550535ad8cff21b8d757a3257963e951d96e20ec82ab44bc64aeb62a191", size = 159035, upload-time = "2025-10-14T04:41:38.368Z" }, + { url = "https://files.pythonhosted.org/packages/67/ff/f6b948ca32e4f2a4576aa129d8bed61f2e0543bf9f5f2b7fc3758ed005c9/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ecaae4149d99b1c9e7b88bb03e3221956f68fd6d50be2ef061b2381b61d20838", size = 152542, upload-time = "2025-10-14T04:41:39.862Z" }, + { url = "https://files.pythonhosted.org/packages/16/85/276033dcbcc369eb176594de22728541a925b2632f9716428c851b149e83/charset_normalizer-3.4.4-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:cb6254dc36b47a990e59e1068afacdcd02958bdcce30bb50cc1700a8b9d624a6", size = 149524, upload-time = "2025-10-14T04:41:41.319Z" }, + { url = "https://files.pythonhosted.org/packages/9e/f2/6a2a1f722b6aba37050e626530a46a68f74e63683947a8acff92569f979a/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:c8ae8a0f02f57a6e61203a31428fa1d677cbe50c93622b4149d5c0f319c1d19e", size = 150395, upload-time = "2025-10-14T04:41:42.539Z" }, + { url = "https://files.pythonhosted.org/packages/60/bb/2186cb2f2bbaea6338cad15ce23a67f9b0672929744381e28b0592676824/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:47cc91b2f4dd2833fddaedd2893006b0106129d4b94fdb6af1f4ce5a9965577c", size = 143680, upload-time = "2025-10-14T04:41:43.661Z" }, + { url = "https://files.pythonhosted.org/packages/7d/a5/bf6f13b772fbb2a90360eb620d52ed8f796f3c5caee8398c3b2eb7b1c60d/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:82004af6c302b5d3ab2cfc4cc5f29db16123b1a8417f2e25f9066f91d4411090", size = 162045, upload-time = "2025-10-14T04:41:44.821Z" }, + { url = "https://files.pythonhosted.org/packages/df/c5/d1be898bf0dc3ef9030c3825e5d3b83f2c528d207d246cbabe245966808d/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:2b7d8f6c26245217bd2ad053761201e9f9680f8ce52f0fcd8d0755aeae5b2152", size = 149687, upload-time = "2025-10-14T04:41:46.442Z" }, + { url = "https://files.pythonhosted.org/packages/a5/42/90c1f7b9341eef50c8a1cb3f098ac43b0508413f33affd762855f67a410e/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:799a7a5e4fb2d5898c60b640fd4981d6a25f1c11790935a44ce38c54e985f828", size = 160014, upload-time = "2025-10-14T04:41:47.631Z" }, + { url = "https://files.pythonhosted.org/packages/76/be/4d3ee471e8145d12795ab655ece37baed0929462a86e72372fd25859047c/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:99ae2cffebb06e6c22bdc25801d7b30f503cc87dbd283479e7b606f70aff57ec", size = 154044, upload-time = "2025-10-14T04:41:48.81Z" }, + { url = "https://files.pythonhosted.org/packages/b0/6f/8f7af07237c34a1defe7defc565a9bc1807762f672c0fde711a4b22bf9c0/charset_normalizer-3.4.4-cp314-cp314-win32.whl", hash = "sha256:f9d332f8c2a2fcbffe1378594431458ddbef721c1769d78e2cbc06280d8155f9", size = 99940, upload-time = "2025-10-14T04:41:49.946Z" }, + { url = "https://files.pythonhosted.org/packages/4b/51/8ade005e5ca5b0d80fb4aff72a3775b325bdc3d27408c8113811a7cbe640/charset_normalizer-3.4.4-cp314-cp314-win_amd64.whl", hash = "sha256:8a6562c3700cce886c5be75ade4a5db4214fda19fede41d9792d100288d8f94c", size = 107104, upload-time = "2025-10-14T04:41:51.051Z" }, + { url = "https://files.pythonhosted.org/packages/da/5f/6b8f83a55bb8278772c5ae54a577f3099025f9ade59d0136ac24a0df4bde/charset_normalizer-3.4.4-cp314-cp314-win_arm64.whl", hash = "sha256:de00632ca48df9daf77a2c65a484531649261ec9f25489917f09e455cb09ddb2", size = 100743, upload-time = "2025-10-14T04:41:52.122Z" }, + { url = "https://files.pythonhosted.org/packages/0a/4c/925909008ed5a988ccbb72dcc897407e5d6d3bd72410d69e051fc0c14647/charset_normalizer-3.4.4-py3-none-any.whl", hash = "sha256:7a32c560861a02ff789ad905a2fe94e3f840803362c84fecf1851cb4cf3dc37f", size = 53402, upload-time = "2025-10-14T04:42:31.76Z" }, +] + +[[package]] +name = "colorama" +version = "0.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, +] + +[[package]] +name = "exceptiongroup" +version = "1.3.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/50/79/66800aadf48771f6b62f7eb014e352e5d06856655206165d775e675a02c9/exceptiongroup-1.3.1.tar.gz", hash = "sha256:8b412432c6055b0b7d14c310000ae93352ed6754f70fa8f7c34141f91c4e3219", size = 30371, upload-time = "2025-11-21T23:01:54.787Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8a/0e/97c33bf5009bdbac74fd2beace167cab3f978feb69cc36f1ef79360d6c4e/exceptiongroup-1.3.1-py3-none-any.whl", hash = "sha256:a7a39a3bd276781e98394987d3a5701d0c4edffb633bb7a5144577f82c773598", size = 16740, upload-time = "2025-11-21T23:01:53.443Z" }, +] + +[[package]] +name = "h11" +version = "0.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250, upload-time = "2025-04-24T03:35:25.427Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" }, +] + +[[package]] +name = "httpcore" +version = "1.0.9" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "h11" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", size = 85484, upload-time = "2025-04-24T22:06:22.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" }, +] + +[[package]] +name = "httpx" +version = "0.28.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "certifi" }, + { name = "httpcore" }, + { name = "idna" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406, upload-time = "2024-12-06T15:37:23.222Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" }, +] + +[[package]] +name = "idna" +version = "3.11" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/6f/6d/0703ccc57f3a7233505399edb88de3cbd678da106337b9fcde432b65ed60/idna-3.11.tar.gz", hash = "sha256:795dafcc9c04ed0c1fb032c2aa73654d8e8c5023a7df64a53f39190ada629902", size = 194582, upload-time = "2025-10-12T14:55:20.501Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0e/61/66938bbb5fc52dbdf84594873d5b51fb1f7c7794e9c0f5bd885f30bc507b/idna-3.11-py3-none-any.whl", hash = "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea", size = 71008, upload-time = "2025-10-12T14:55:18.883Z" }, +] + +[[package]] +name = "iniconfig" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, +] + +[[package]] +name = "packaging" +version = "26.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/65/ee/299d360cdc32edc7d2cf530f3accf79c4fca01e96ffc950d8a52213bd8e4/packaging-26.0.tar.gz", hash = "sha256:00243ae351a257117b6a241061796684b084ed1c516a08c48a3f7e147a9d80b4", size = 143416, upload-time = "2026-01-21T20:50:39.064Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b7/b9/c538f279a4e237a006a2c98387d081e9eb060d203d8ed34467cc0f0b9b53/packaging-26.0-py3-none-any.whl", hash = "sha256:b36f1fef9334a5588b4166f8bcd26a14e521f2b55e6b9de3aaa80d3ff7a37529", size = 74366, upload-time = "2026-01-21T20:50:37.788Z" }, +] + +[[package]] +name = "pluggy" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, +] + +[[package]] +name = "pygments" +version = "2.19.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b0/77/a5b8c569bf593b0140bde72ea885a803b82086995367bf2037de0159d924/pygments-2.19.2.tar.gz", hash = "sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887", size = 4968631, upload-time = "2025-06-21T13:39:12.283Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c7/21/705964c7812476f378728bdf590ca4b771ec72385c533964653c68e86bdc/pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b", size = 1225217, upload-time = "2025-06-21T13:39:07.939Z" }, +] + +[[package]] +name = "pytest" +version = "9.0.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "exceptiongroup", marker = "python_full_version < '3.11'" }, + { name = "iniconfig" }, + { name = "packaging" }, + { name = "pluggy" }, + { name = "pygments" }, + { name = "tomli", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/d1/db/7ef3487e0fb0049ddb5ce41d3a49c235bf9ad299b6a25d5780a89f19230f/pytest-9.0.2.tar.gz", hash = "sha256:75186651a92bd89611d1d9fc20f0b4345fd827c41ccd5c299a868a05d70edf11", size = 1568901, upload-time = "2025-12-06T21:30:51.014Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3b/ab/b3226f0bd7cdcf710fbede2b3548584366da3b19b5021e74f5bde2a8fa3f/pytest-9.0.2-py3-none-any.whl", hash = "sha256:711ffd45bf766d5264d487b917733b453d917afd2b0ad65223959f59089f875b", size = 374801, upload-time = "2025-12-06T21:30:49.154Z" }, +] + +[[package]] +name = "requests" +version = "2.32.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "charset-normalizer" }, + { name = "idna" }, + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c9/74/b3ff8e6c8446842c3f5c837e9c3dfcfe2018ea6ecef224c710c85ef728f4/requests-2.32.5.tar.gz", hash = "sha256:dbba0bac56e100853db0ea71b82b4dfd5fe2bf6d3754a8893c3af500cec7d7cf", size = 134517, upload-time = "2025-08-18T20:46:02.573Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1e/db/4254e3eabe8020b458f1a747140d32277ec7a271daf1d235b70dc0b4e6e3/requests-2.32.5-py3-none-any.whl", hash = "sha256:2462f94637a34fd532264295e186976db0f5d453d1cdd31473c85a6a161affb6", size = 64738, upload-time = "2025-08-18T20:46:00.542Z" }, +] + +[[package]] +name = "resend" +version = "2.23.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "requests" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/96/a3/20003e7d14604fef778bd30c69604df3560a657a95a5c29a9688610759b6/resend-2.23.0.tar.gz", hash = "sha256:df613827dcc40eb1c9de2e5ff600cd4081b89b206537dec8067af1a5016d23c7", size = 31416, upload-time = "2026-02-23T19:01:57.603Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e3/35/64df775b8cd95e89798fd7b1b7fcafa975b6b09f559c10c0650e65b33580/resend-2.23.0-py2.py3-none-any.whl", hash = "sha256:eca6d28a1ffd36c1fc489fa83cb6b511f384792c9f07465f7c92d96c8b4d5636", size = 52599, upload-time = "2026-02-23T19:01:55.962Z" }, +] + +[[package]] +name = "resend-contracts" +version = "1.0.0" +source = { virtual = "." } +dependencies = [ + { name = "httpx" }, + { name = "pytest" }, + { name = "resend" }, +] + +[package.metadata] +requires-dist = [ + { name = "httpx", specifier = ">=0.27.0" }, + { name = "pytest", specifier = ">=8.0.0" }, + { name = "resend", specifier = ">=2.0.0" }, +] + +[[package]] +name = "tomli" +version = "2.4.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/82/30/31573e9457673ab10aa432461bee537ce6cef177667deca369efb79df071/tomli-2.4.0.tar.gz", hash = "sha256:aa89c3f6c277dd275d8e243ad24f3b5e701491a860d5121f2cdd399fbb31fc9c", size = 17477, upload-time = "2026-01-11T11:22:38.165Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3c/d9/3dc2289e1f3b32eb19b9785b6a006b28ee99acb37d1d47f78d4c10e28bf8/tomli-2.4.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:b5ef256a3fd497d4973c11bf142e9ed78b150d36f5773f1ca6088c230ffc5867", size = 153663, upload-time = "2026-01-11T11:21:45.27Z" }, + { url = "https://files.pythonhosted.org/packages/51/32/ef9f6845e6b9ca392cd3f64f9ec185cc6f09f0a2df3db08cbe8809d1d435/tomli-2.4.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:5572e41282d5268eb09a697c89a7bee84fae66511f87533a6f88bd2f7b652da9", size = 148469, upload-time = "2026-01-11T11:21:46.873Z" }, + { url = "https://files.pythonhosted.org/packages/d6/c2/506e44cce89a8b1b1e047d64bd495c22c9f71f21e05f380f1a950dd9c217/tomli-2.4.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:551e321c6ba03b55676970b47cb1b73f14a0a4dce6a3e1a9458fd6d921d72e95", size = 236039, upload-time = "2026-01-11T11:21:48.503Z" }, + { url = "https://files.pythonhosted.org/packages/b3/40/e1b65986dbc861b7e986e8ec394598187fa8aee85b1650b01dd925ca0be8/tomli-2.4.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5e3f639a7a8f10069d0e15408c0b96a2a828cfdec6fca05296ebcdcc28ca7c76", size = 243007, upload-time = "2026-01-11T11:21:49.456Z" }, + { url = "https://files.pythonhosted.org/packages/9c/6f/6e39ce66b58a5b7ae572a0f4352ff40c71e8573633deda43f6a379d56b3e/tomli-2.4.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1b168f2731796b045128c45982d3a4874057626da0e2ef1fdd722848b741361d", size = 240875, upload-time = "2026-01-11T11:21:50.755Z" }, + { url = "https://files.pythonhosted.org/packages/aa/ad/cb089cb190487caa80204d503c7fd0f4d443f90b95cf4ef5cf5aa0f439b0/tomli-2.4.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:133e93646ec4300d651839d382d63edff11d8978be23da4cc106f5a18b7d0576", size = 246271, upload-time = "2026-01-11T11:21:51.81Z" }, + { url = "https://files.pythonhosted.org/packages/0b/63/69125220e47fd7a3a27fd0de0c6398c89432fec41bc739823bcc66506af6/tomli-2.4.0-cp311-cp311-win32.whl", hash = "sha256:b6c78bdf37764092d369722d9946cb65b8767bfa4110f902a1b2542d8d173c8a", size = 96770, upload-time = "2026-01-11T11:21:52.647Z" }, + { url = "https://files.pythonhosted.org/packages/1e/0d/a22bb6c83f83386b0008425a6cd1fa1c14b5f3dd4bad05e98cf3dbbf4a64/tomli-2.4.0-cp311-cp311-win_amd64.whl", hash = "sha256:d3d1654e11d724760cdb37a3d7691f0be9db5fbdaef59c9f532aabf87006dbaa", size = 107626, upload-time = "2026-01-11T11:21:53.459Z" }, + { url = "https://files.pythonhosted.org/packages/2f/6d/77be674a3485e75cacbf2ddba2b146911477bd887dda9d8c9dfb2f15e871/tomli-2.4.0-cp311-cp311-win_arm64.whl", hash = "sha256:cae9c19ed12d4e8f3ebf46d1a75090e4c0dc16271c5bce1c833ac168f08fb614", size = 94842, upload-time = "2026-01-11T11:21:54.831Z" }, + { url = "https://files.pythonhosted.org/packages/3c/43/7389a1869f2f26dba52404e1ef13b4784b6b37dac93bac53457e3ff24ca3/tomli-2.4.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:920b1de295e72887bafa3ad9f7a792f811847d57ea6b1215154030cf131f16b1", size = 154894, upload-time = "2026-01-11T11:21:56.07Z" }, + { url = "https://files.pythonhosted.org/packages/e9/05/2f9bf110b5294132b2edf13fe6ca6ae456204f3d749f623307cbb7a946f2/tomli-2.4.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:7d6d9a4aee98fac3eab4952ad1d73aee87359452d1c086b5ceb43ed02ddb16b8", size = 149053, upload-time = "2026-01-11T11:21:57.467Z" }, + { url = "https://files.pythonhosted.org/packages/e8/41/1eda3ca1abc6f6154a8db4d714a4d35c4ad90adc0bcf700657291593fbf3/tomli-2.4.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:36b9d05b51e65b254ea6c2585b59d2c4cb91c8a3d91d0ed0f17591a29aaea54a", size = 243481, upload-time = "2026-01-11T11:21:58.661Z" }, + { url = "https://files.pythonhosted.org/packages/d2/6d/02ff5ab6c8868b41e7d4b987ce2b5f6a51d3335a70aa144edd999e055a01/tomli-2.4.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1c8a885b370751837c029ef9bc014f27d80840e48bac415f3412e6593bbc18c1", size = 251720, upload-time = "2026-01-11T11:22:00.178Z" }, + { url = "https://files.pythonhosted.org/packages/7b/57/0405c59a909c45d5b6f146107c6d997825aa87568b042042f7a9c0afed34/tomli-2.4.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8768715ffc41f0008abe25d808c20c3d990f42b6e2e58305d5da280ae7d1fa3b", size = 247014, upload-time = "2026-01-11T11:22:01.238Z" }, + { url = "https://files.pythonhosted.org/packages/2c/0e/2e37568edd944b4165735687cbaf2fe3648129e440c26d02223672ee0630/tomli-2.4.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:7b438885858efd5be02a9a133caf5812b8776ee0c969fea02c45e8e3f296ba51", size = 251820, upload-time = "2026-01-11T11:22:02.727Z" }, + { url = "https://files.pythonhosted.org/packages/5a/1c/ee3b707fdac82aeeb92d1a113f803cf6d0f37bdca0849cb489553e1f417a/tomli-2.4.0-cp312-cp312-win32.whl", hash = "sha256:0408e3de5ec77cc7f81960c362543cbbd91ef883e3138e81b729fc3eea5b9729", size = 97712, upload-time = "2026-01-11T11:22:03.777Z" }, + { url = "https://files.pythonhosted.org/packages/69/13/c07a9177d0b3bab7913299b9278845fc6eaaca14a02667c6be0b0a2270c8/tomli-2.4.0-cp312-cp312-win_amd64.whl", hash = "sha256:685306e2cc7da35be4ee914fd34ab801a6acacb061b6a7abca922aaf9ad368da", size = 108296, upload-time = "2026-01-11T11:22:04.86Z" }, + { url = "https://files.pythonhosted.org/packages/18/27/e267a60bbeeee343bcc279bb9e8fbed0cbe224bc7b2a3dc2975f22809a09/tomli-2.4.0-cp312-cp312-win_arm64.whl", hash = "sha256:5aa48d7c2356055feef06a43611fc401a07337d5b006be13a30f6c58f869e3c3", size = 94553, upload-time = "2026-01-11T11:22:05.854Z" }, + { url = "https://files.pythonhosted.org/packages/34/91/7f65f9809f2936e1f4ce6268ae1903074563603b2a2bd969ebbda802744f/tomli-2.4.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:84d081fbc252d1b6a982e1870660e7330fb8f90f676f6e78b052ad4e64714bf0", size = 154915, upload-time = "2026-01-11T11:22:06.703Z" }, + { url = "https://files.pythonhosted.org/packages/20/aa/64dd73a5a849c2e8f216b755599c511badde80e91e9bc2271baa7b2cdbb1/tomli-2.4.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:9a08144fa4cba33db5255f9b74f0b89888622109bd2776148f2597447f92a94e", size = 149038, upload-time = "2026-01-11T11:22:07.56Z" }, + { url = "https://files.pythonhosted.org/packages/9e/8a/6d38870bd3d52c8d1505ce054469a73f73a0fe62c0eaf5dddf61447e32fa/tomli-2.4.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c73add4bb52a206fd0c0723432db123c0c75c280cbd67174dd9d2db228ebb1b4", size = 242245, upload-time = "2026-01-11T11:22:08.344Z" }, + { url = "https://files.pythonhosted.org/packages/59/bb/8002fadefb64ab2669e5b977df3f5e444febea60e717e755b38bb7c41029/tomli-2.4.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1fb2945cbe303b1419e2706e711b7113da57b7db31ee378d08712d678a34e51e", size = 250335, upload-time = "2026-01-11T11:22:09.951Z" }, + { url = "https://files.pythonhosted.org/packages/a5/3d/4cdb6f791682b2ea916af2de96121b3cb1284d7c203d97d92d6003e91c8d/tomli-2.4.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:bbb1b10aa643d973366dc2cb1ad94f99c1726a02343d43cbc011edbfac579e7c", size = 245962, upload-time = "2026-01-11T11:22:11.27Z" }, + { url = "https://files.pythonhosted.org/packages/f2/4a/5f25789f9a460bd858ba9756ff52d0830d825b458e13f754952dd15fb7bb/tomli-2.4.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:4cbcb367d44a1f0c2be408758b43e1ffb5308abe0ea222897d6bfc8e8281ef2f", size = 250396, upload-time = "2026-01-11T11:22:12.325Z" }, + { url = "https://files.pythonhosted.org/packages/aa/2f/b73a36fea58dfa08e8b3a268750e6853a6aac2a349241a905ebd86f3047a/tomli-2.4.0-cp313-cp313-win32.whl", hash = "sha256:7d49c66a7d5e56ac959cb6fc583aff0651094ec071ba9ad43df785abc2320d86", size = 97530, upload-time = "2026-01-11T11:22:13.865Z" }, + { url = "https://files.pythonhosted.org/packages/3b/af/ca18c134b5d75de7e8dc551c5234eaba2e8e951f6b30139599b53de9c187/tomli-2.4.0-cp313-cp313-win_amd64.whl", hash = "sha256:3cf226acb51d8f1c394c1b310e0e0e61fecdd7adcb78d01e294ac297dd2e7f87", size = 108227, upload-time = "2026-01-11T11:22:15.224Z" }, + { url = "https://files.pythonhosted.org/packages/22/c3/b386b832f209fee8073c8138ec50f27b4460db2fdae9ffe022df89a57f9b/tomli-2.4.0-cp313-cp313-win_arm64.whl", hash = "sha256:d20b797a5c1ad80c516e41bc1fb0443ddb5006e9aaa7bda2d71978346aeb9132", size = 94748, upload-time = "2026-01-11T11:22:16.009Z" }, + { url = "https://files.pythonhosted.org/packages/f3/c4/84047a97eb1004418bc10bdbcfebda209fca6338002eba2dc27cc6d13563/tomli-2.4.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:26ab906a1eb794cd4e103691daa23d95c6919cc2fa9160000ac02370cc9dd3f6", size = 154725, upload-time = "2026-01-11T11:22:17.269Z" }, + { url = "https://files.pythonhosted.org/packages/a8/5d/d39038e646060b9d76274078cddf146ced86dc2b9e8bbf737ad5983609a0/tomli-2.4.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:20cedb4ee43278bc4f2fee6cb50daec836959aadaf948db5172e776dd3d993fc", size = 148901, upload-time = "2026-01-11T11:22:18.287Z" }, + { url = "https://files.pythonhosted.org/packages/73/e5/383be1724cb30f4ce44983d249645684a48c435e1cd4f8b5cded8a816d3c/tomli-2.4.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:39b0b5d1b6dd03684b3fb276407ebed7090bbec989fa55838c98560c01113b66", size = 243375, upload-time = "2026-01-11T11:22:19.154Z" }, + { url = "https://files.pythonhosted.org/packages/31/f0/bea80c17971c8d16d3cc109dc3585b0f2ce1036b5f4a8a183789023574f2/tomli-2.4.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a26d7ff68dfdb9f87a016ecfd1e1c2bacbe3108f4e0f8bcd2228ef9a766c787d", size = 250639, upload-time = "2026-01-11T11:22:20.168Z" }, + { url = "https://files.pythonhosted.org/packages/2c/8f/2853c36abbb7608e3f945d8a74e32ed3a74ee3a1f468f1ffc7d1cb3abba6/tomli-2.4.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:20ffd184fb1df76a66e34bd1b36b4a4641bd2b82954befa32fe8163e79f1a702", size = 246897, upload-time = "2026-01-11T11:22:21.544Z" }, + { url = "https://files.pythonhosted.org/packages/49/f0/6c05e3196ed5337b9fe7ea003e95fd3819a840b7a0f2bf5a408ef1dad8ed/tomli-2.4.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:75c2f8bbddf170e8effc98f5e9084a8751f8174ea6ccf4fca5398436e0320bc8", size = 254697, upload-time = "2026-01-11T11:22:23.058Z" }, + { url = "https://files.pythonhosted.org/packages/f3/f5/2922ef29c9f2951883525def7429967fc4d8208494e5ab524234f06b688b/tomli-2.4.0-cp314-cp314-win32.whl", hash = "sha256:31d556d079d72db7c584c0627ff3a24c5d3fb4f730221d3444f3efb1b2514776", size = 98567, upload-time = "2026-01-11T11:22:24.033Z" }, + { url = "https://files.pythonhosted.org/packages/7b/31/22b52e2e06dd2a5fdbc3ee73226d763b184ff21fc24e20316a44ccc4d96b/tomli-2.4.0-cp314-cp314-win_amd64.whl", hash = "sha256:43e685b9b2341681907759cf3a04e14d7104b3580f808cfde1dfdb60ada85475", size = 108556, upload-time = "2026-01-11T11:22:25.378Z" }, + { url = "https://files.pythonhosted.org/packages/48/3d/5058dff3255a3d01b705413f64f4306a141a8fd7a251e5a495e3f192a998/tomli-2.4.0-cp314-cp314-win_arm64.whl", hash = "sha256:3d895d56bd3f82ddd6faaff993c275efc2ff38e52322ea264122d72729dca2b2", size = 96014, upload-time = "2026-01-11T11:22:26.138Z" }, + { url = "https://files.pythonhosted.org/packages/b8/4e/75dab8586e268424202d3a1997ef6014919c941b50642a1682df43204c22/tomli-2.4.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:5b5807f3999fb66776dbce568cc9a828544244a8eb84b84b9bafc080c99597b9", size = 163339, upload-time = "2026-01-11T11:22:27.143Z" }, + { url = "https://files.pythonhosted.org/packages/06/e3/b904d9ab1016829a776d97f163f183a48be6a4deb87304d1e0116a349519/tomli-2.4.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c084ad935abe686bd9c898e62a02a19abfc9760b5a79bc29644463eaf2840cb0", size = 159490, upload-time = "2026-01-11T11:22:28.399Z" }, + { url = "https://files.pythonhosted.org/packages/e3/5a/fc3622c8b1ad823e8ea98a35e3c632ee316d48f66f80f9708ceb4f2a0322/tomli-2.4.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0f2e3955efea4d1cfbcb87bc321e00dc08d2bcb737fd1d5e398af111d86db5df", size = 269398, upload-time = "2026-01-11T11:22:29.345Z" }, + { url = "https://files.pythonhosted.org/packages/fd/33/62bd6152c8bdd4c305ad9faca48f51d3acb2df1f8791b1477d46ff86e7f8/tomli-2.4.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0e0fe8a0b8312acf3a88077a0802565cb09ee34107813bba1c7cd591fa6cfc8d", size = 276515, upload-time = "2026-01-11T11:22:30.327Z" }, + { url = "https://files.pythonhosted.org/packages/4b/ff/ae53619499f5235ee4211e62a8d7982ba9e439a0fb4f2f351a93d67c1dd2/tomli-2.4.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:413540dce94673591859c4c6f794dfeaa845e98bf35d72ed59636f869ef9f86f", size = 273806, upload-time = "2026-01-11T11:22:32.56Z" }, + { url = "https://files.pythonhosted.org/packages/47/71/cbca7787fa68d4d0a9f7072821980b39fbb1b6faeb5f5cf02f4a5559fa28/tomli-2.4.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:0dc56fef0e2c1c470aeac5b6ca8cc7b640bb93e92d9803ddaf9ea03e198f5b0b", size = 281340, upload-time = "2026-01-11T11:22:33.505Z" }, + { url = "https://files.pythonhosted.org/packages/f5/00/d595c120963ad42474cf6ee7771ad0d0e8a49d0f01e29576ee9195d9ecdf/tomli-2.4.0-cp314-cp314t-win32.whl", hash = "sha256:d878f2a6707cc9d53a1be1414bbb419e629c3d6e67f69230217bb663e76b5087", size = 108106, upload-time = "2026-01-11T11:22:34.451Z" }, + { url = "https://files.pythonhosted.org/packages/de/69/9aa0c6a505c2f80e519b43764f8b4ba93b5a0bbd2d9a9de6e2b24271b9a5/tomli-2.4.0-cp314-cp314t-win_amd64.whl", hash = "sha256:2add28aacc7425117ff6364fe9e06a183bb0251b03f986df0e78e974047571fd", size = 120504, upload-time = "2026-01-11T11:22:35.764Z" }, + { url = "https://files.pythonhosted.org/packages/b3/9f/f1668c281c58cfae01482f7114a4b88d345e4c140386241a1a24dcc9e7bc/tomli-2.4.0-cp314-cp314t-win_arm64.whl", hash = "sha256:2b1e3b80e1d5e52e40e9b924ec43d81570f0e7d09d11081b797bc4692765a3d4", size = 99561, upload-time = "2026-01-11T11:22:36.624Z" }, + { url = "https://files.pythonhosted.org/packages/23/d1/136eb2cb77520a31e1f64cbae9d33ec6df0d78bdf4160398e86eec8a8754/tomli-2.4.0-py3-none-any.whl", hash = "sha256:1f776e7d669ebceb01dee46484485f43a4048746235e683bcdffacdf1fb4785a", size = 14477, upload-time = "2026-01-11T11:22:37.446Z" }, +] + +[[package]] +name = "typing-extensions" +version = "4.15.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391, upload-time = "2025-08-25T13:49:26.313Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" }, +] + +[[package]] +name = "urllib3" +version = "2.6.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c7/24/5f1b3bdffd70275f6661c76461e25f024d5a38a46f04aaca912426a2b1d3/urllib3-2.6.3.tar.gz", hash = "sha256:1b62b6884944a57dbe321509ab94fd4d3b307075e0c2eae991ac71ee15ad38ed", size = 435556, upload-time = "2026-01-07T16:24:43.925Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/39/08/aaaad47bc4e9dc8c725e68f9d04865dbcb2052843ff09c97b08904852d84/urllib3-2.6.3-py3-none-any.whl", hash = "sha256:bf272323e553dfb2e87d9bfd225ca7b0f467b919d7bbd355436d3fd37cb0acd4", size = 131584, upload-time = "2026-01-07T16:24:42.685Z" }, +] diff --git a/services/resend/server/main.py b/services/resend/server/main.py new file mode 100644 index 0000000..310c609 --- /dev/null +++ b/services/resend/server/main.py @@ -0,0 +1,1430 @@ +""" +Resend Email API Fake - DoubleAgent Service + +A high-fidelity fake of the Resend API for AI agent testing. +Built with FastAPI. The real API base is https://api.resend.com. + +All endpoints return application/json with Content-Type: application/json. +All successful operations return HTTP 200 (Resend does not use 201/204). +Resource IDs are UUIDs. +""" + +import os +import re +import uuid +from collections import OrderedDict +from contextlib import asynccontextmanager +from datetime import datetime, timezone +from typing import Any, Optional + +from fastapi import FastAPI, Request +from fastapi.responses import JSONResponse +from starlette.middleware.base import BaseHTTPMiddleware + + +# ============================================================================= +# Constants +# ============================================================================= + +# The default valid API key used in fake mode (must match conftest.py) +DEFAULT_VALID_API_KEY = "re_fake_test_key_1234567890" + + +# ============================================================================= +# Helpers +# ============================================================================= + +def _now() -> str: + """Return current UTC time in ISO 8601 format.""" + return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%S.%fZ") + + +def _generate_id() -> str: + """Generate a UUID matching Resend's ID format.""" + return str(uuid.uuid4()) + + +def _ensure_list(value: Any) -> Optional[list]: + """Convert a string or list to a list, or return None if value is falsy.""" + if value is None: + return None + if isinstance(value, list): + return value + return [value] + + +def _error_response(status_code: int, name: str, message: str) -> JSONResponse: + """Return a Resend-style error response (top-level shape, NOT wrapped by FastAPI).""" + return JSONResponse( + status_code=status_code, + content={ + "statusCode": status_code, + "name": name, + "message": message, + }, + ) + + +def _paginate(items: list, request: Request) -> dict: + """ + Apply cursor-based pagination to a list of items. + + Reads `limit`, `after`, and `before` from query parameters. + Returns a Resend-style list envelope: {"object": "list", "has_more": bool, "data": [...]}. + """ + limit = int(request.query_params.get("limit", "20")) + after = request.query_params.get("after") + before = request.query_params.get("before") + + # Clamp limit + limit = max(1, min(limit, 100)) + + # Apply cursor-based pagination + if after: + found_idx = None + for i, item in enumerate(items): + if item["id"] == after: + found_idx = i + break + if found_idx is not None: + items = items[found_idx + 1:] + else: + items = [] + elif before: + found_idx = None + for i, item in enumerate(items): + if item["id"] == before: + found_idx = i + break + if found_idx is not None: + items = items[:found_idx] + else: + items = [] + + has_more = len(items) > limit + page = items[:limit] + + return { + "object": "list", + "has_more": has_more, + "data": page, + } + + +# ============================================================================= +# State +# ============================================================================= + +def _initial_state() -> dict[str, Any]: + """Return a fresh initial state with empty collections.""" + return { + "emails": OrderedDict(), + "domains": OrderedDict(), + "contacts": OrderedDict(), + "templates": OrderedDict(), + "api_keys": OrderedDict(), + "webhooks": OrderedDict(), + "idempotency_keys": {}, + "valid_api_key": DEFAULT_VALID_API_KEY, + } + + +state: dict[str, Any] = _initial_state() + + +# ============================================================================= +# App +# ============================================================================= + +@asynccontextmanager +async def lifespan(app: FastAPI): + """Application lifespan handler.""" + yield + + +app = FastAPI(title="Resend Fake", lifespan=lifespan) + + +# ============================================================================= +# Auth middleware +# ============================================================================= + +class AuthMiddleware(BaseHTTPMiddleware): + """ + Middleware to validate API key authentication. + + Checks the Authorization header on all non-control-plane requests. + Returns Resend-style error responses for missing/invalid API keys. + """ + + async def dispatch(self, request: Request, call_next): + # Skip auth for control-plane endpoints and OPTIONS + if request.url.path.startswith("/_doubleagent"): + return await call_next(request) + + auth_header = request.headers.get("authorization", "") + + # Extract the bearer token + if auth_header.startswith("Bearer "): + token = auth_header[7:] # Strip "Bearer " prefix + else: + token = "" + + # Missing or empty API key → 401 + if not token or token.strip() == "": + return JSONResponse( + status_code=401, + content={ + "statusCode": 401, + "name": "missing_api_key", + "message": "Missing API Key", + }, + ) + + # Invalid API key → 400 (Resend quirk: invalid key returns 400, not 401/403) + if token != state["valid_api_key"]: + return JSONResponse( + status_code=400, + content={ + "statusCode": 400, + "name": "validation_error", + "message": "API key is invalid", + }, + ) + + return await call_next(request) + + +app.add_middleware(AuthMiddleware) + + +# ============================================================================= +# Control-plane endpoints +# ============================================================================= + +@app.get("/_doubleagent/health") +async def health(): + """Health check endpoint.""" + return {"status": "healthy"} + + +@app.post("/_doubleagent/reset") +async def reset(): + """Reset all state to initial empty state.""" + global state + state = _initial_state() + return {"status": "ok"} + + +@app.post("/_doubleagent/seed") +async def seed(request: Request): + """Seed the fake with initial data.""" + body = await request.json() + + seeded: dict[str, Any] = {} + + # Seed each resource type if provided + for resource_type in ["emails", "domains", "contacts", "templates", "api_keys", "webhooks"]: + if resource_type in body: + seeded[resource_type] = [] + for item in body[resource_type]: + item_id = item.get("id", _generate_id()) + item["id"] = item_id + state[resource_type][item_id] = item + seeded[resource_type].append(item_id) + + return {"status": "ok", "seeded": seeded} + + +# ============================================================================= +# Email endpoints +# ============================================================================= + + +@app.post("/emails/batch") +async def send_batch_emails(request: Request): + """ + POST /emails/batch — Send batch emails. + + Accepts a JSON array of email objects (same fields as single send). + Returns {"data": [{"id": "uuid-1"}, {"id": "uuid-2"}, ...]}. + No "object" field on the wrapper or items. + """ + body = await request.json() + now = _now() + + results = [] + for email_params in body: + email_id = _generate_id() + + # Normalize fields + to_field = email_params.get("to") + if isinstance(to_field, str): + to_field = [to_field] + + cc = _ensure_list(email_params.get("cc")) + bcc = _ensure_list(email_params.get("bcc")) + reply_to = _ensure_list(email_params.get("reply_to")) + + last_event = "delivered" + + email_record = { + "object": "email", + "id": email_id, + "to": to_field, + "from": email_params.get("from"), + "created_at": now, + "subject": email_params.get("subject"), + "html": email_params.get("html"), + "text": email_params.get("text"), + "bcc": bcc, + "cc": cc, + "reply_to": reply_to, + "last_event": last_event, + "scheduled_at": None, + "tags": email_params.get("tags"), + } + + state["emails"][email_id] = email_record + results.append({"id": email_id}) + + return JSONResponse(content={"data": results}) + + +@app.post("/emails") +async def send_email(request: Request): + """ + POST /emails — Send an email. + + The real API returns only {"id": "uuid"} (no "object" field). + Supports Idempotency-Key header for deduplication. + Supports template-based sending via the "template" field. + + Validation order (verified against real API): + 1. `to` field checked first → 422 missing_required_field + 2. `html` or `text` → 422 validation_error (skipped if template provided) + 3. Then `subject`, `from`, etc. (skipped if template provides defaults) + """ + body = await request.json() + + # Check if this is a template-based send + template_ref = body.get("template") + resolved_template = None + + if template_ref: + # Look up the template by ID or alias + tmpl_id = template_ref.get("id") or template_ref.get("alias") + if tmpl_id: + resolved_template = _resolve_template(tmpl_id) + if resolved_template is None: + return _error_response(404, "not_found", "Template not found") + + # Validate required fields (matching real API validation order) + # 1. `to` is checked first + if "to" not in body or body["to"] is None or body["to"] == "" or body["to"] == []: + return _error_response( + 422, + "missing_required_field", + "Missing `to` field.", + ) + + # When a template is provided, skip html/text/subject validation + # because those come from the template + if not resolved_template: + # 2. `html` or `text` required + if not body.get("html") and not body.get("text"): + return _error_response( + 422, + "validation_error", + "Missing `html` or `text` field.", + ) + + # 3. `subject` required + if "subject" not in body or not body.get("subject"): + return _error_response( + 422, + "missing_required_field", + "Missing `subject` field.", + ) + + # 4. `from` required + if "from" not in body or not body.get("from"): + return _error_response( + 422, + "missing_required_field", + "Missing `from` field.", + ) + + # Check for idempotency key + idempotency_key = request.headers.get("idempotency-key") + if idempotency_key and idempotency_key in state["idempotency_keys"]: + # Return the same email id as before + existing_id = state["idempotency_keys"][idempotency_key] + return JSONResponse(content={"id": existing_id}) + + email_id = _generate_id() + now = _now() + + # Normalize `to` to always be a list + to_field = body.get("to") + if isinstance(to_field, str): + to_field = [to_field] + + # Normalize cc, bcc, reply_to to lists or None + cc = _ensure_list(body.get("cc")) + bcc = _ensure_list(body.get("bcc")) + reply_to = _ensure_list(body.get("reply_to")) + + # Resolve email fields: use template defaults, then apply overrides from body + email_subject = body.get("subject") + email_html = body.get("html") + email_text = body.get("text") + email_from = body.get("from") + + if resolved_template: + template_vars = (template_ref.get("variables") or {}) if template_ref else {} + + # Use template defaults if not overridden in the send body + if not email_subject: + email_subject = resolved_template.get("subject", "") + if not email_html: + email_html = resolved_template.get("html", "") + if not email_text: + email_text = resolved_template.get("text") + if not email_from: + email_from = resolved_template.get("from", "") + + # Substitute variables in subject and html + email_subject = _substitute_template_variables(email_subject, template_vars) + email_html = _substitute_template_variables(email_html, template_vars) + if email_text: + email_text = _substitute_template_variables(email_text, template_vars) + + # Determine last_event based on whether the email is scheduled + scheduled_at = body.get("scheduled_at") + last_event = "scheduled" if scheduled_at else "delivered" + + # Store the full email object for later retrieval + email_record = { + "object": "email", + "id": email_id, + "to": to_field, + "from": email_from, + "created_at": now, + "subject": email_subject, + "html": email_html, + "text": email_text, + "bcc": bcc, + "cc": cc, + "reply_to": reply_to, + "last_event": last_event, + "scheduled_at": scheduled_at, + "tags": body.get("tags"), + } + + state["emails"][email_id] = email_record + + # Store idempotency key mapping + if idempotency_key: + state["idempotency_keys"][idempotency_key] = email_id + + # Send response returns only {"id": "uuid"} — no "object" field + return JSONResponse(content={"id": email_id}) + + +@app.get("/emails/{email_id}") +async def get_email(email_id: str): + """ + GET /emails/{id} — Retrieve a single email. + + Returns the full email object with "object": "email". + """ + email = state["emails"].get(email_id) + if email is None: + return _error_response(404, "not_found", "Email not found") + + return JSONResponse(content=email) + + +@app.get("/emails") +async def list_emails(request: Request): + """ + GET /emails — List emails. + + Returns a paginated list envelope. + List items include summary fields only (no html, text, or tags). + """ + all_emails = list(state["emails"].values()) + + # Build summary items (list items exclude html, text, tags per API docs) + summary_items = [] + for email in all_emails: + summary = { + "id": email["id"], + "to": email["to"], + "from": email["from"], + "created_at": email["created_at"], + "subject": email["subject"], + "bcc": email.get("bcc"), + "cc": email.get("cc"), + "reply_to": email.get("reply_to"), + "last_event": email.get("last_event", "delivered"), + "scheduled_at": email.get("scheduled_at"), + } + summary_items.append(summary) + + result = _paginate(summary_items, request) + return JSONResponse(content=result) + + +@app.patch("/emails/{email_id}") +async def update_email(email_id: str, request: Request): + """ + PATCH /emails/{id} — Update (reschedule) an email. + + Primarily used to update scheduled_at for scheduled emails. + Returns {"object": "email", "id": "..."}. + """ + email = state["emails"].get(email_id) + if email is None: + return _error_response(404, "not_found", "Email not found") + + body = await request.json() + + # Update mutable fields + if "scheduled_at" in body: + email["scheduled_at"] = body["scheduled_at"] + + return JSONResponse(content={"object": "email", "id": email_id}) + + +@app.post("/emails/{email_id}/cancel") +async def cancel_email(email_id: str): + """ + POST /emails/{id}/cancel — Cancel a scheduled email. + + Sets last_event to "canceled". + Returns {"object": "email", "id": "..."}. + """ + email = state["emails"].get(email_id) + if email is None: + return _error_response(404, "not_found", "Email not found") + + email["last_event"] = "canceled" + + return JSONResponse(content={"object": "email", "id": email_id}) + + +# ============================================================================= +# Domain helpers +# ============================================================================= + +def _generate_domain_records(domain_name: str, region: str) -> list[dict]: + """ + Generate realistic DNS records for a domain. + + Returns a list of DNS records matching the real Resend API structure. + Each record has: record, name, type, value, ttl, status, and optionally priority. + """ + custom_return_path = "send" + records = [ + { + "record": "SPF", + "name": f"{custom_return_path}.{domain_name}", + "type": "MX", + "value": f"feedback-smtp.{region}.amazonses.com", + "ttl": "Auto", + "status": "not_started", + "priority": 10, + }, + { + "record": "SPF", + "name": f"{custom_return_path}.{domain_name}", + "type": "TXT", + "value": "v=spf1 include:amazonses.com ~all", + "ttl": "Auto", + "status": "not_started", + }, + { + "record": "DKIM", + "name": f"resend._domainkey.{domain_name}", + "type": "CNAME", + "value": f"resend.{domain_name}.dkim.amazonses.com", + "ttl": "Auto", + "status": "not_started", + }, + ] + return records + + +# ============================================================================= +# Domain endpoints +# ============================================================================= + + +@app.post("/domains") +async def create_domain(request: Request): + """ + POST /domains — Create a domain. + + Returns the full domain object including DNS records. + All successful operations return HTTP 200. + """ + body = await request.json() + + domain_id = _generate_id() + now = _now() + domain_name = body.get("name", "") + region = body.get("region", "us-east-1") + + records = _generate_domain_records(domain_name, region) + + domain = { + "id": domain_id, + "name": domain_name, + "created_at": now, + "status": "not_started", + "region": region, + "records": records, + } + + state["domains"][domain_id] = domain + + return JSONResponse(content=domain) + + +@app.get("/domains") +async def list_domains(request: Request): + """ + GET /domains — List domains. + + Returns a paginated list envelope. + List items do NOT include the records array. + """ + all_domains = list(state["domains"].values()) + + # Build summary items (list items exclude records per API docs) + summary_items = [] + for domain in all_domains: + summary = { + "id": domain["id"], + "name": domain["name"], + "status": domain.get("status", "not_started"), + "created_at": domain["created_at"], + "region": domain.get("region", "us-east-1"), + } + summary_items.append(summary) + + result = _paginate(summary_items, request) + return JSONResponse(content=result) + + +@app.post("/domains/{domain_id}/verify") +async def verify_domain(domain_id: str): + """ + POST /domains/{domain_id}/verify — Trigger domain verification. + + Returns {"object": "domain", "id": "..."}. + """ + domain = state["domains"].get(domain_id) + if domain is None: + return _error_response(404, "not_found", "Domain not found") + + return JSONResponse(content={"object": "domain", "id": domain_id}) + + +@app.get("/domains/{domain_id}") +async def get_domain(domain_id: str): + """ + GET /domains/{domain_id} — Retrieve a single domain. + + Returns the full domain object with "object": "domain" and records array. + """ + domain = state["domains"].get(domain_id) + if domain is None: + return _error_response(404, "not_found", "Domain not found") + + # Build response with object field (GET response includes object field) + response = { + "object": "domain", + "id": domain["id"], + "name": domain["name"], + "status": domain.get("status", "not_started"), + "created_at": domain["created_at"], + "region": domain.get("region", "us-east-1"), + "records": domain.get("records", []), + } + + return JSONResponse(content=response) + + +@app.patch("/domains/{domain_id}") +async def update_domain(domain_id: str, request: Request): + """ + PATCH /domains/{domain_id} — Update a domain. + + Supports updating tracking settings (openTracking, clickTracking, tls, capabilities). + Returns {"object": "domain", "id": "..."}. + """ + domain = state["domains"].get(domain_id) + if domain is None: + return _error_response(404, "not_found", "Domain not found") + + body = await request.json() + + # Update mutable fields + if "openTracking" in body or "open_tracking" in body: + domain["open_tracking"] = body.get("openTracking", body.get("open_tracking")) + if "clickTracking" in body or "click_tracking" in body: + domain["click_tracking"] = body.get("clickTracking", body.get("click_tracking")) + if "tls" in body: + domain["tls"] = body["tls"] + if "capabilities" in body: + domain["capabilities"] = body["capabilities"] + + return JSONResponse(content={"object": "domain", "id": domain_id}) + + +@app.delete("/domains/{domain_id}") +async def delete_domain(domain_id: str): + """ + DELETE /domains/{domain_id} — Delete a domain. + + Hard-deletes the domain (Resend does NOT soft-delete). + Returns {"object": "domain", "id": "...", "deleted": true}. + """ + domain = state["domains"].get(domain_id) + if domain is None: + return _error_response(404, "not_found", "Domain not found") + + # Hard-delete: remove from state + del state["domains"][domain_id] + + return JSONResponse(content={ + "object": "domain", + "id": domain_id, + "deleted": True, + }) + + +# ============================================================================= +# Contact helpers +# ============================================================================= + +def _find_contact_by_email(email: str) -> Optional[dict]: + """Find a contact by email address. Returns the contact dict or None.""" + for contact in state["contacts"].values(): + if contact.get("email") == email: + return contact + return None + + +def _is_uuid(value: str) -> bool: + """Check if a string looks like a UUID.""" + try: + uuid.UUID(value) + return True + except ValueError: + return False + + +def _resolve_contact(id_or_email: str) -> Optional[dict]: + """ + Resolve a contact by UUID or email address. + + The Resend API accepts either a UUID or email as the path parameter + for GET /contacts/{id_or_email}. + """ + # Try as UUID first (direct lookup is O(1)) + contact = state["contacts"].get(id_or_email) + if contact is not None: + return contact + + # Try as email address + return _find_contact_by_email(id_or_email) + + +# ============================================================================= +# Contact endpoints +# ============================================================================= + + +@app.post("/contacts") +async def create_contact(request: Request): + """ + POST /contacts — Create a contact. + + Returns {"object": "contact", "id": "uuid"} (HTTP 200). + """ + body = await request.json() + + contact_id = _generate_id() + now = _now() + + contact = { + "object": "contact", + "id": contact_id, + "email": body.get("email", ""), + "first_name": body.get("first_name", ""), + "last_name": body.get("last_name", ""), + "created_at": now, + "unsubscribed": body.get("unsubscribed", False), + "properties": body.get("properties", {}), + } + + state["contacts"][contact_id] = contact + + return JSONResponse(content={ + "object": "contact", + "id": contact_id, + }) + + +@app.get("/contacts") +async def list_contacts(request: Request): + """ + GET /contacts — List contacts. + + Returns a paginated list envelope. + """ + all_contacts = list(state["contacts"].values()) + + # Build summary items for list + summary_items = [] + for contact in all_contacts: + summary = { + "id": contact["id"], + "email": contact.get("email", ""), + "first_name": contact.get("first_name", ""), + "last_name": contact.get("last_name", ""), + "created_at": contact["created_at"], + "unsubscribed": contact.get("unsubscribed", False), + } + summary_items.append(summary) + + result = _paginate(summary_items, request) + return JSONResponse(content=result) + + +@app.get("/contacts/{id_or_email:path}") +async def get_contact(id_or_email: str): + """ + GET /contacts/{id_or_email} — Retrieve a single contact. + + Accepts either UUID or email address as path parameter. + Returns the full contact object with "object": "contact". + """ + contact = _resolve_contact(id_or_email) + if contact is None: + return _error_response(404, "not_found", "Contact not found") + + response = { + "object": "contact", + "id": contact["id"], + "email": contact.get("email", ""), + "first_name": contact.get("first_name", ""), + "last_name": contact.get("last_name", ""), + "created_at": contact["created_at"], + "unsubscribed": contact.get("unsubscribed", False), + "properties": contact.get("properties", {}), + } + + return JSONResponse(content=response) + + +@app.patch("/contacts/{id_or_email:path}") +async def update_contact(id_or_email: str, request: Request): + """ + PATCH /contacts/{id_or_email} — Update a contact. + + Accepts either UUID or email as path parameter. + Returns {"object": "contact", "id": "..."}. + """ + contact = _resolve_contact(id_or_email) + if contact is None: + return _error_response(404, "not_found", "Contact not found") + + body = await request.json() + + # Update mutable fields + if "first_name" in body: + contact["first_name"] = body["first_name"] + if "last_name" in body: + contact["last_name"] = body["last_name"] + if "unsubscribed" in body: + contact["unsubscribed"] = body["unsubscribed"] + if "email" in body: + contact["email"] = body["email"] + if "properties" in body: + contact["properties"] = body["properties"] + + return JSONResponse(content={ + "object": "contact", + "id": contact["id"], + }) + + +@app.delete("/contacts/{id_or_email:path}") +async def delete_contact(id_or_email: str): + """ + DELETE /contacts/{id_or_email} — Delete a contact. + + Hard-deletes the contact (Resend does NOT soft-delete). + Returns {"object": "contact", "contact": "", "deleted": true}. + Note: Uses "contact" field (not "id") for the deleted contact's UUID. + """ + contact = _resolve_contact(id_or_email) + if contact is None: + return _error_response(404, "not_found", "Contact not found") + + contact_id = contact["id"] + + # Hard-delete: remove from state + del state["contacts"][contact_id] + + return JSONResponse(content={ + "object": "contact", + "contact": contact_id, + "deleted": True, + }) + + +# ============================================================================= +# Template helpers +# ============================================================================= + + +def _resolve_template(id_or_alias: str) -> Optional[dict]: + """ + Resolve a template by UUID or alias. + + The Resend API accepts either a UUID or alias as the path parameter + for GET /templates/{id_or_alias}, POST /templates/{id_or_alias}/publish, etc. + """ + # Try direct UUID lookup first (O(1)) + template = state["templates"].get(id_or_alias) + if template is not None: + return template + + # Try alias lookup (linear scan) + for tmpl in state["templates"].values(): + if tmpl.get("alias") == id_or_alias: + return tmpl + + return None + + +def _substitute_template_variables(text: str, variables: dict) -> str: + """ + Substitute triple-brace {{{VAR}}} placeholders with variable values. + + Resend uses {{{variable_name}}} syntax (Mustache-style unescaped). + """ + if not text: + return text + + def replace_var(match): + var_name = match.group(1) + return variables.get(var_name, match.group(0)) + + # Match {{{...}}} patterns + return re.sub(r"\{\{\{(\w+)\}\}\}", replace_var, text) + + +# ============================================================================= +# Template endpoints +# ============================================================================= + + +@app.post("/templates") +async def create_template(request: Request): + """ + POST /templates — Create a template. + + Returns {"id": "uuid", "object": "template"} (HTTP 200). + """ + body = await request.json() + + template_id = _generate_id() + now = _now() + + # Build variables with IDs and timestamps + variables = [] + for var in body.get("variables", []): + variable = { + "id": _generate_id(), + "key": var.get("key", ""), + "type": var.get("type", "string"), + "fallback_value": var.get("fallback_value"), + "created_at": now, + "updated_at": now, + } + variables.append(variable) + + template = { + "object": "template", + "id": template_id, + "current_version_id": _generate_id(), + "alias": body.get("alias"), + "name": body.get("name", ""), + "created_at": now, + "updated_at": now, + "status": "draft", + "published_at": None, + "from": body.get("from"), + "subject": body.get("subject"), + "reply_to": body.get("reply_to"), + "html": body.get("html", ""), + "text": body.get("text"), + "variables": variables, + "has_unpublished_versions": False, + } + + state["templates"][template_id] = template + + return JSONResponse(content={ + "id": template_id, + "object": "template", + }) + + +@app.get("/templates") +async def list_templates(request: Request): + """ + GET /templates — List templates. + + Returns a paginated list envelope. + List items include summary fields only (no html, text, variables, from, subject, etc). + """ + all_templates = list(state["templates"].values()) + + # Build summary items (list items exclude html, text, variables, from, subject per API docs) + summary_items = [] + for template in all_templates: + summary = { + "id": template["id"], + "name": template.get("name", ""), + "status": template.get("status", "draft"), + "published_at": template.get("published_at"), + "created_at": template["created_at"], + "updated_at": template["updated_at"], + "alias": template.get("alias"), + } + summary_items.append(summary) + + result = _paginate(summary_items, request) + return JSONResponse(content=result) + + +@app.post("/templates/{template_id}/publish") +async def publish_template(template_id: str): + """ + POST /templates/{id_or_alias}/publish — Publish a template. + + Accepts either UUID or alias as path parameter. + Sets status to "published" and sets published_at timestamp. + Returns {"id": "...", "object": "template"}. + """ + template = _resolve_template(template_id) + if template is None: + return _error_response(404, "not_found", "Template not found") + + template["status"] = "published" + template["published_at"] = _now() + template["updated_at"] = _now() + + return JSONResponse(content={ + "id": template["id"], + "object": "template", + }) + + +@app.post("/templates/{template_id}/duplicate") +async def duplicate_template(template_id: str): + """ + POST /templates/{id_or_alias}/duplicate — Duplicate a template. + + Accepts either UUID or alias as path parameter. + Creates a copy of the template with a new ID. + Returns {"object": "template", "id": "new-uuid"}. + """ + template = _resolve_template(template_id) + if template is None: + return _error_response(404, "not_found", "Template not found") + + new_id = _generate_id() + now = _now() + + # Deep copy the template with a new ID + new_template = { + "object": "template", + "id": new_id, + "current_version_id": _generate_id(), + "alias": template.get("alias"), + "name": template.get("name", ""), + "created_at": now, + "updated_at": now, + "status": "draft", + "published_at": None, + "from": template.get("from"), + "subject": template.get("subject"), + "reply_to": template.get("reply_to"), + "html": template.get("html", ""), + "text": template.get("text"), + "variables": [ + { + "id": _generate_id(), + "key": v.get("key", ""), + "type": v.get("type", "string"), + "fallback_value": v.get("fallback_value"), + "created_at": now, + "updated_at": now, + } + for v in template.get("variables", []) + ], + "has_unpublished_versions": False, + } + + state["templates"][new_id] = new_template + + return JSONResponse(content={ + "object": "template", + "id": new_id, + }) + + +@app.get("/templates/{template_id}") +async def get_template(template_id: str): + """ + GET /templates/{id_or_alias} — Retrieve a single template. + + Accepts either UUID or alias as path parameter. + Returns the full template object with "object": "template". + """ + template = _resolve_template(template_id) + if template is None: + return _error_response(404, "not_found", "Template not found") + + return JSONResponse(content=template) + + +@app.patch("/templates/{template_id}") +async def update_template(template_id: str, request: Request): + """ + PATCH /templates/{id_or_alias} — Update a template. + + Accepts either UUID or alias as path parameter. + The SDK strips the 'id' field from the request body before sending. + Returns {"id": "...", "object": "template"}. + """ + template = _resolve_template(template_id) + if template is None: + return _error_response(404, "not_found", "Template not found") + + body = await request.json() + + # Update mutable fields + if "name" in body: + template["name"] = body["name"] + if "html" in body: + template["html"] = body["html"] + if "text" in body: + template["text"] = body["text"] + if "subject" in body: + template["subject"] = body["subject"] + if "from" in body: + template["from"] = body["from"] + if "reply_to" in body: + template["reply_to"] = body["reply_to"] + if "alias" in body: + template["alias"] = body["alias"] + if "variables" in body: + now = _now() + template["variables"] = [ + { + "id": _generate_id(), + "key": v.get("key", ""), + "type": v.get("type", "string"), + "fallback_value": v.get("fallback_value"), + "created_at": now, + "updated_at": now, + } + for v in body["variables"] + ] + + template["updated_at"] = _now() + + return JSONResponse(content={ + "id": template["id"], + "object": "template", + }) + + +@app.delete("/templates/{template_id}") +async def delete_template(template_id: str): + """ + DELETE /templates/{id_or_alias} — Delete a template. + + Accepts either UUID or alias as path parameter. + Hard-deletes the template (Resend does NOT soft-delete). + Returns {"object": "template", "id": "...", "deleted": true}. + """ + template = _resolve_template(template_id) + if template is None: + return _error_response(404, "not_found", "Template not found") + + actual_id = template["id"] + + # Hard-delete: remove from state + del state["templates"][actual_id] + + return JSONResponse(content={ + "object": "template", + "id": actual_id, + "deleted": True, + }) + + +# ============================================================================= +# API Key helpers +# ============================================================================= + +def _generate_token() -> str: + """Generate a fake API key token starting with 're_'.""" + return f"re_{uuid.uuid4().hex}" + + +# ============================================================================= +# API Key endpoints +# ============================================================================= + + +@app.post("/api-keys") +async def create_api_key(request: Request): + """ + POST /api-keys — Create an API key. + + Returns {"id": "uuid", "token": "re_..."} (HTTP 200). + No "object" field in the response. + Token is only returned on creation. + """ + body = await request.json() + + api_key_id = _generate_id() + now = _now() + token = _generate_token() + + api_key = { + "id": api_key_id, + "name": body.get("name", ""), + "created_at": now, + "permission": body.get("permission", "full_access"), + "domain_id": body.get("domain_id"), + } + + state["api_keys"][api_key_id] = api_key + + # Create response: only id and token (no "object" field) + return JSONResponse(content={ + "id": api_key_id, + "token": token, + }) + + +@app.get("/api-keys") +async def list_api_keys(request: Request): + """ + GET /api-keys — List API keys. + + Returns a paginated list envelope. + Listed keys do NOT include the token value. + """ + all_keys = list(state["api_keys"].values()) + + # Build summary items (list items exclude token per API docs) + summary_items = [] + for key in all_keys: + summary = { + "id": key["id"], + "name": key.get("name", ""), + "created_at": key["created_at"], + } + summary_items.append(summary) + + result = _paginate(summary_items, request) + return JSONResponse(content=result) + + +@app.delete("/api-keys/{api_key_id}") +async def delete_api_key(api_key_id: str): + """ + DELETE /api-keys/{api_key_id} — Delete an API key. + + Hard-deletes the API key. Returns empty body (the SDK returns None). + The real API returns HTTP 200 with empty body. + + We return JSON null so json.loads() succeeds and the SDK's perform() + returns None as expected. + """ + # Remove from state if it exists (no error if not found, matching real API behavior) + if api_key_id in state["api_keys"]: + del state["api_keys"][api_key_id] + + # Return JSON null — json.loads("null") → None, which is what the SDK expects + return JSONResponse(content=None) + + +# ============================================================================= +# Webhook helpers +# ============================================================================= + +def _generate_signing_secret() -> str: + """Generate a fake webhook signing secret starting with 'whsec_'.""" + return f"whsec_{uuid.uuid4().hex}" + + +# ============================================================================= +# Webhook endpoints +# ============================================================================= + + +@app.post("/webhooks") +async def create_webhook(request: Request): + """ + POST /webhooks — Create a webhook. + + Returns {"object": "webhook", "id": "uuid", "signing_secret": "whsec_..."} (HTTP 200). + """ + body = await request.json() + + webhook_id = _generate_id() + now = _now() + signing_secret = _generate_signing_secret() + + webhook = { + "object": "webhook", + "id": webhook_id, + "created_at": now, + "status": "enabled", + "endpoint": body.get("endpoint", ""), + "events": body.get("events", []), + "signing_secret": signing_secret, + } + + state["webhooks"][webhook_id] = webhook + + return JSONResponse(content={ + "object": "webhook", + "id": webhook_id, + "signing_secret": signing_secret, + }) + + +@app.get("/webhooks") +async def list_webhooks(request: Request): + """ + GET /webhooks — List webhooks. + + Returns a paginated list envelope. + List items do NOT include signing_secret. + """ + all_webhooks = list(state["webhooks"].values()) + + # Build summary items (list items exclude signing_secret per API docs) + summary_items = [] + for webhook in all_webhooks: + summary = { + "id": webhook["id"], + "created_at": webhook["created_at"], + "status": webhook.get("status", "enabled"), + "endpoint": webhook.get("endpoint", ""), + "events": webhook.get("events", []), + } + summary_items.append(summary) + + result = _paginate(summary_items, request) + return JSONResponse(content=result) + + +@app.get("/webhooks/{webhook_id}") +async def get_webhook(webhook_id: str): + """ + GET /webhooks/{webhook_id} — Retrieve a single webhook. + + Returns the full webhook object with "object": "webhook" and signing_secret. + """ + webhook = state["webhooks"].get(webhook_id) + if webhook is None: + return _error_response(404, "not_found", "Webhook not found") + + return JSONResponse(content={ + "object": "webhook", + "id": webhook["id"], + "created_at": webhook["created_at"], + "status": webhook.get("status", "enabled"), + "endpoint": webhook.get("endpoint", ""), + "events": webhook.get("events", []), + "signing_secret": webhook.get("signing_secret", ""), + }) + + +@app.patch("/webhooks/{webhook_id}") +async def update_webhook(webhook_id: str, request: Request): + """ + PATCH /webhooks/{webhook_id} — Update a webhook. + + The SDK sends full params including webhook_id in the body. + Supports updating endpoint, events, and status. + Returns {"object": "webhook", "id": "..."}. + """ + webhook = state["webhooks"].get(webhook_id) + if webhook is None: + return _error_response(404, "not_found", "Webhook not found") + + body = await request.json() + + # Update mutable fields + if "endpoint" in body: + webhook["endpoint"] = body["endpoint"] + if "events" in body: + webhook["events"] = body["events"] + if "status" in body: + webhook["status"] = body["status"] + + return JSONResponse(content={ + "object": "webhook", + "id": webhook_id, + }) + + +@app.delete("/webhooks/{webhook_id}") +async def delete_webhook(webhook_id: str): + """ + DELETE /webhooks/{webhook_id} — Delete a webhook. + + Hard-deletes the webhook (Resend does NOT soft-delete). + Returns {"object": "webhook", "id": "...", "deleted": true}. + """ + webhook = state["webhooks"].get(webhook_id) + if webhook is None: + return _error_response(404, "not_found", "Webhook not found") + + # Hard-delete: remove from state + del state["webhooks"][webhook_id] + + return JSONResponse(content={ + "object": "webhook", + "id": webhook_id, + "deleted": True, + }) + + +# ============================================================================= +# Run +# ============================================================================= + +if __name__ == "__main__": + import uvicorn + + port = int(os.environ.get("PORT", "8080")) + uvicorn.run(app, host="0.0.0.0", port=port) diff --git a/services/resend/server/pyproject.toml b/services/resend/server/pyproject.toml new file mode 100644 index 0000000..2b84590 --- /dev/null +++ b/services/resend/server/pyproject.toml @@ -0,0 +1,11 @@ +[project] +name = "resend-fake" +version = "1.0.0" +description = "Resend Email API fake for DoubleAgent" +requires-python = ">=3.10" +dependencies = [ + "fastapi>=0.115.0", + "uvicorn>=0.32.0", + "httpx>=0.27.0", + "pydantic>=2.0.0", +] diff --git a/services/resend/server/uv.lock b/services/resend/server/uv.lock new file mode 100644 index 0000000..33cba47 --- /dev/null +++ b/services/resend/server/uv.lock @@ -0,0 +1,339 @@ +version = 1 +revision = 3 +requires-python = ">=3.10" + +[[package]] +name = "annotated-doc" +version = "0.0.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/57/ba/046ceea27344560984e26a590f90bc7f4a75b06701f653222458922b558c/annotated_doc-0.0.4.tar.gz", hash = "sha256:fbcda96e87e9c92ad167c2e53839e57503ecfda18804ea28102353485033faa4", size = 7288, upload-time = "2025-11-10T22:07:42.062Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1e/d3/26bf1008eb3d2daa8ef4cacc7f3bfdc11818d111f7e2d0201bc6e3b49d45/annotated_doc-0.0.4-py3-none-any.whl", hash = "sha256:571ac1dc6991c450b25a9c2d84a3705e2ae7a53467b5d111c24fa8baabbed320", size = 5303, upload-time = "2025-11-10T22:07:40.673Z" }, +] + +[[package]] +name = "annotated-types" +version = "0.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ee/67/531ea369ba64dcff5ec9c3402f9f51bf748cec26dde048a2f973a4eea7f5/annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89", size = 16081, upload-time = "2024-05-20T21:33:25.928Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53", size = 13643, upload-time = "2024-05-20T21:33:24.1Z" }, +] + +[[package]] +name = "anyio" +version = "4.12.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "exceptiongroup", marker = "python_full_version < '3.11'" }, + { name = "idna" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/96/f0/5eb65b2bb0d09ac6776f2eb54adee6abe8228ea05b20a5ad0e4945de8aac/anyio-4.12.1.tar.gz", hash = "sha256:41cfcc3a4c85d3f05c932da7c26d0201ac36f72abd4435ba90d0464a3ffed703", size = 228685, upload-time = "2026-01-06T11:45:21.246Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/38/0e/27be9fdef66e72d64c0cdc3cc2823101b80585f8119b5c112c2e8f5f7dab/anyio-4.12.1-py3-none-any.whl", hash = "sha256:d405828884fc140aa80a3c667b8beed277f1dfedec42ba031bd6ac3db606ab6c", size = 113592, upload-time = "2026-01-06T11:45:19.497Z" }, +] + +[[package]] +name = "certifi" +version = "2026.2.25" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/af/2d/7bf41579a8986e348fa033a31cdd0e4121114f6bce2457e8876010b092dd/certifi-2026.2.25.tar.gz", hash = "sha256:e887ab5cee78ea814d3472169153c2d12cd43b14bd03329a39a9c6e2e80bfba7", size = 155029, upload-time = "2026-02-25T02:54:17.342Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9a/3c/c17fb3ca2d9c3acff52e30b309f538586f9f5b9c9cf454f3845fc9af4881/certifi-2026.2.25-py3-none-any.whl", hash = "sha256:027692e4402ad994f1c42e52a4997a9763c646b73e4096e4d5d6db8af1d6f0fa", size = 153684, upload-time = "2026-02-25T02:54:15.766Z" }, +] + +[[package]] +name = "click" +version = "8.3.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/3d/fa/656b739db8587d7b5dfa22e22ed02566950fbfbcdc20311993483657a5c0/click-8.3.1.tar.gz", hash = "sha256:12ff4785d337a1bb490bb7e9c2b1ee5da3112e94a8622f26a6c77f5d2fc6842a", size = 295065, upload-time = "2025-11-15T20:45:42.706Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/98/78/01c019cdb5d6498122777c1a43056ebb3ebfeef2076d9d026bfe15583b2b/click-8.3.1-py3-none-any.whl", hash = "sha256:981153a64e25f12d547d3426c367a4857371575ee7ad18df2a6183ab0545b2a6", size = 108274, upload-time = "2025-11-15T20:45:41.139Z" }, +] + +[[package]] +name = "colorama" +version = "0.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, +] + +[[package]] +name = "exceptiongroup" +version = "1.3.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/50/79/66800aadf48771f6b62f7eb014e352e5d06856655206165d775e675a02c9/exceptiongroup-1.3.1.tar.gz", hash = "sha256:8b412432c6055b0b7d14c310000ae93352ed6754f70fa8f7c34141f91c4e3219", size = 30371, upload-time = "2025-11-21T23:01:54.787Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8a/0e/97c33bf5009bdbac74fd2beace167cab3f978feb69cc36f1ef79360d6c4e/exceptiongroup-1.3.1-py3-none-any.whl", hash = "sha256:a7a39a3bd276781e98394987d3a5701d0c4edffb633bb7a5144577f82c773598", size = 16740, upload-time = "2025-11-21T23:01:53.443Z" }, +] + +[[package]] +name = "fastapi" +version = "0.135.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "annotated-doc" }, + { name = "pydantic" }, + { name = "starlette" }, + { name = "typing-extensions" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a2/b5/386a9579a299a32365b34097e4eac6a0544ce0d7aa4bb95ce0d71607a999/fastapi-0.135.0.tar.gz", hash = "sha256:bd37903acf014d1284bda027096e460814dca9699f9dacfe11c275749d949f4d", size = 393855, upload-time = "2026-03-01T09:28:46.714Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/56/38/fa5dd0e677e1e2e38f858933c4a125e80103e551151f1f661dd4f227210d/fastapi-0.135.0-py3-none-any.whl", hash = "sha256:31e2ddc78d6406c6f7d5d7b9996a057985e2600fbe7e9ba6ace8205d48dff688", size = 114496, upload-time = "2026-03-01T09:28:48.162Z" }, +] + +[[package]] +name = "h11" +version = "0.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250, upload-time = "2025-04-24T03:35:25.427Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" }, +] + +[[package]] +name = "httpcore" +version = "1.0.9" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "h11" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", size = 85484, upload-time = "2025-04-24T22:06:22.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" }, +] + +[[package]] +name = "httpx" +version = "0.28.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "certifi" }, + { name = "httpcore" }, + { name = "idna" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406, upload-time = "2024-12-06T15:37:23.222Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" }, +] + +[[package]] +name = "idna" +version = "3.11" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/6f/6d/0703ccc57f3a7233505399edb88de3cbd678da106337b9fcde432b65ed60/idna-3.11.tar.gz", hash = "sha256:795dafcc9c04ed0c1fb032c2aa73654d8e8c5023a7df64a53f39190ada629902", size = 194582, upload-time = "2025-10-12T14:55:20.501Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0e/61/66938bbb5fc52dbdf84594873d5b51fb1f7c7794e9c0f5bd885f30bc507b/idna-3.11-py3-none-any.whl", hash = "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea", size = 71008, upload-time = "2025-10-12T14:55:18.883Z" }, +] + +[[package]] +name = "pydantic" +version = "2.12.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "annotated-types" }, + { name = "pydantic-core" }, + { name = "typing-extensions" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/69/44/36f1a6e523abc58ae5f928898e4aca2e0ea509b5aa6f6f392a5d882be928/pydantic-2.12.5.tar.gz", hash = "sha256:4d351024c75c0f085a9febbb665ce8c0c6ec5d30e903bdb6394b7ede26aebb49", size = 821591, upload-time = "2025-11-26T15:11:46.471Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5a/87/b70ad306ebb6f9b585f114d0ac2137d792b48be34d732d60e597c2f8465a/pydantic-2.12.5-py3-none-any.whl", hash = "sha256:e561593fccf61e8a20fc46dfc2dfe075b8be7d0188df33f221ad1f0139180f9d", size = 463580, upload-time = "2025-11-26T15:11:44.605Z" }, +] + +[[package]] +name = "pydantic-core" +version = "2.41.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/71/70/23b021c950c2addd24ec408e9ab05d59b035b39d97cdc1130e1bce647bb6/pydantic_core-2.41.5.tar.gz", hash = "sha256:08daa51ea16ad373ffd5e7606252cc32f07bc72b28284b6bc9c6df804816476e", size = 460952, upload-time = "2025-11-04T13:43:49.098Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c6/90/32c9941e728d564b411d574d8ee0cf09b12ec978cb22b294995bae5549a5/pydantic_core-2.41.5-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:77b63866ca88d804225eaa4af3e664c5faf3568cea95360d21f4725ab6e07146", size = 2107298, upload-time = "2025-11-04T13:39:04.116Z" }, + { url = "https://files.pythonhosted.org/packages/fb/a8/61c96a77fe28993d9a6fb0f4127e05430a267b235a124545d79fea46dd65/pydantic_core-2.41.5-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:dfa8a0c812ac681395907e71e1274819dec685fec28273a28905df579ef137e2", size = 1901475, upload-time = "2025-11-04T13:39:06.055Z" }, + { url = "https://files.pythonhosted.org/packages/5d/b6/338abf60225acc18cdc08b4faef592d0310923d19a87fba1faf05af5346e/pydantic_core-2.41.5-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5921a4d3ca3aee735d9fd163808f5e8dd6c6972101e4adbda9a4667908849b97", size = 1918815, upload-time = "2025-11-04T13:39:10.41Z" }, + { url = "https://files.pythonhosted.org/packages/d1/1c/2ed0433e682983d8e8cba9c8d8ef274d4791ec6a6f24c58935b90e780e0a/pydantic_core-2.41.5-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e25c479382d26a2a41b7ebea1043564a937db462816ea07afa8a44c0866d52f9", size = 2065567, upload-time = "2025-11-04T13:39:12.244Z" }, + { url = "https://files.pythonhosted.org/packages/b3/24/cf84974ee7d6eae06b9e63289b7b8f6549d416b5c199ca2d7ce13bbcf619/pydantic_core-2.41.5-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f547144f2966e1e16ae626d8ce72b4cfa0caedc7fa28052001c94fb2fcaa1c52", size = 2230442, upload-time = "2025-11-04T13:39:13.962Z" }, + { url = "https://files.pythonhosted.org/packages/fd/21/4e287865504b3edc0136c89c9c09431be326168b1eb7841911cbc877a995/pydantic_core-2.41.5-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:6f52298fbd394f9ed112d56f3d11aabd0d5bd27beb3084cc3d8ad069483b8941", size = 2350956, upload-time = "2025-11-04T13:39:15.889Z" }, + { url = "https://files.pythonhosted.org/packages/a8/76/7727ef2ffa4b62fcab916686a68a0426b9b790139720e1934e8ba797e238/pydantic_core-2.41.5-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:100baa204bb412b74fe285fb0f3a385256dad1d1879f0a5cb1499ed2e83d132a", size = 2068253, upload-time = "2025-11-04T13:39:17.403Z" }, + { url = "https://files.pythonhosted.org/packages/d5/8c/a4abfc79604bcb4c748e18975c44f94f756f08fb04218d5cb87eb0d3a63e/pydantic_core-2.41.5-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:05a2c8852530ad2812cb7914dc61a1125dc4e06252ee98e5638a12da6cc6fb6c", size = 2177050, upload-time = "2025-11-04T13:39:19.351Z" }, + { url = "https://files.pythonhosted.org/packages/67/b1/de2e9a9a79b480f9cb0b6e8b6ba4c50b18d4e89852426364c66aa82bb7b3/pydantic_core-2.41.5-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:29452c56df2ed968d18d7e21f4ab0ac55e71dc59524872f6fc57dcf4a3249ed2", size = 2147178, upload-time = "2025-11-04T13:39:21Z" }, + { url = "https://files.pythonhosted.org/packages/16/c1/dfb33f837a47b20417500efaa0378adc6635b3c79e8369ff7a03c494b4ac/pydantic_core-2.41.5-cp310-cp310-musllinux_1_1_armv7l.whl", hash = "sha256:d5160812ea7a8a2ffbe233d8da666880cad0cbaf5d4de74ae15c313213d62556", size = 2341833, upload-time = "2025-11-04T13:39:22.606Z" }, + { url = "https://files.pythonhosted.org/packages/47/36/00f398642a0f4b815a9a558c4f1dca1b4020a7d49562807d7bc9ff279a6c/pydantic_core-2.41.5-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:df3959765b553b9440adfd3c795617c352154e497a4eaf3752555cfb5da8fc49", size = 2321156, upload-time = "2025-11-04T13:39:25.843Z" }, + { url = "https://files.pythonhosted.org/packages/7e/70/cad3acd89fde2010807354d978725ae111ddf6d0ea46d1ea1775b5c1bd0c/pydantic_core-2.41.5-cp310-cp310-win32.whl", hash = "sha256:1f8d33a7f4d5a7889e60dc39856d76d09333d8a6ed0f5f1190635cbec70ec4ba", size = 1989378, upload-time = "2025-11-04T13:39:27.92Z" }, + { url = "https://files.pythonhosted.org/packages/76/92/d338652464c6c367e5608e4488201702cd1cbb0f33f7b6a85a60fe5f3720/pydantic_core-2.41.5-cp310-cp310-win_amd64.whl", hash = "sha256:62de39db01b8d593e45871af2af9e497295db8d73b085f6bfd0b18c83c70a8f9", size = 2013622, upload-time = "2025-11-04T13:39:29.848Z" }, + { url = "https://files.pythonhosted.org/packages/e8/72/74a989dd9f2084b3d9530b0915fdda64ac48831c30dbf7c72a41a5232db8/pydantic_core-2.41.5-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:a3a52f6156e73e7ccb0f8cced536adccb7042be67cb45f9562e12b319c119da6", size = 2105873, upload-time = "2025-11-04T13:39:31.373Z" }, + { url = "https://files.pythonhosted.org/packages/12/44/37e403fd9455708b3b942949e1d7febc02167662bf1a7da5b78ee1ea2842/pydantic_core-2.41.5-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:7f3bf998340c6d4b0c9a2f02d6a400e51f123b59565d74dc60d252ce888c260b", size = 1899826, upload-time = "2025-11-04T13:39:32.897Z" }, + { url = "https://files.pythonhosted.org/packages/33/7f/1d5cab3ccf44c1935a359d51a8a2a9e1a654b744b5e7f80d41b88d501eec/pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:378bec5c66998815d224c9ca994f1e14c0c21cb95d2f52b6021cc0b2a58f2a5a", size = 1917869, upload-time = "2025-11-04T13:39:34.469Z" }, + { url = "https://files.pythonhosted.org/packages/6e/6a/30d94a9674a7fe4f4744052ed6c5e083424510be1e93da5bc47569d11810/pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e7b576130c69225432866fe2f4a469a85a54ade141d96fd396dffcf607b558f8", size = 2063890, upload-time = "2025-11-04T13:39:36.053Z" }, + { url = "https://files.pythonhosted.org/packages/50/be/76e5d46203fcb2750e542f32e6c371ffa9b8ad17364cf94bb0818dbfb50c/pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:6cb58b9c66f7e4179a2d5e0f849c48eff5c1fca560994d6eb6543abf955a149e", size = 2229740, upload-time = "2025-11-04T13:39:37.753Z" }, + { url = "https://files.pythonhosted.org/packages/d3/ee/fed784df0144793489f87db310a6bbf8118d7b630ed07aa180d6067e653a/pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:88942d3a3dff3afc8288c21e565e476fc278902ae4d6d134f1eeda118cc830b1", size = 2350021, upload-time = "2025-11-04T13:39:40.94Z" }, + { url = "https://files.pythonhosted.org/packages/c8/be/8fed28dd0a180dca19e72c233cbf58efa36df055e5b9d90d64fd1740b828/pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f31d95a179f8d64d90f6831d71fa93290893a33148d890ba15de25642c5d075b", size = 2066378, upload-time = "2025-11-04T13:39:42.523Z" }, + { url = "https://files.pythonhosted.org/packages/b0/3b/698cf8ae1d536a010e05121b4958b1257f0b5522085e335360e53a6b1c8b/pydantic_core-2.41.5-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c1df3d34aced70add6f867a8cf413e299177e0c22660cc767218373d0779487b", size = 2175761, upload-time = "2025-11-04T13:39:44.553Z" }, + { url = "https://files.pythonhosted.org/packages/b8/ba/15d537423939553116dea94ce02f9c31be0fa9d0b806d427e0308ec17145/pydantic_core-2.41.5-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:4009935984bd36bd2c774e13f9a09563ce8de4abaa7226f5108262fa3e637284", size = 2146303, upload-time = "2025-11-04T13:39:46.238Z" }, + { url = "https://files.pythonhosted.org/packages/58/7f/0de669bf37d206723795f9c90c82966726a2ab06c336deba4735b55af431/pydantic_core-2.41.5-cp311-cp311-musllinux_1_1_armv7l.whl", hash = "sha256:34a64bc3441dc1213096a20fe27e8e128bd3ff89921706e83c0b1ac971276594", size = 2340355, upload-time = "2025-11-04T13:39:48.002Z" }, + { url = "https://files.pythonhosted.org/packages/e5/de/e7482c435b83d7e3c3ee5ee4451f6e8973cff0eb6007d2872ce6383f6398/pydantic_core-2.41.5-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:c9e19dd6e28fdcaa5a1de679aec4141f691023916427ef9bae8584f9c2fb3b0e", size = 2319875, upload-time = "2025-11-04T13:39:49.705Z" }, + { url = "https://files.pythonhosted.org/packages/fe/e6/8c9e81bb6dd7560e33b9053351c29f30c8194b72f2d6932888581f503482/pydantic_core-2.41.5-cp311-cp311-win32.whl", hash = "sha256:2c010c6ded393148374c0f6f0bf89d206bf3217f201faa0635dcd56bd1520f6b", size = 1987549, upload-time = "2025-11-04T13:39:51.842Z" }, + { url = "https://files.pythonhosted.org/packages/11/66/f14d1d978ea94d1bc21fc98fcf570f9542fe55bfcc40269d4e1a21c19bf7/pydantic_core-2.41.5-cp311-cp311-win_amd64.whl", hash = "sha256:76ee27c6e9c7f16f47db7a94157112a2f3a00e958bc626e2f4ee8bec5c328fbe", size = 2011305, upload-time = "2025-11-04T13:39:53.485Z" }, + { url = "https://files.pythonhosted.org/packages/56/d8/0e271434e8efd03186c5386671328154ee349ff0354d83c74f5caaf096ed/pydantic_core-2.41.5-cp311-cp311-win_arm64.whl", hash = "sha256:4bc36bbc0b7584de96561184ad7f012478987882ebf9f9c389b23f432ea3d90f", size = 1972902, upload-time = "2025-11-04T13:39:56.488Z" }, + { url = "https://files.pythonhosted.org/packages/5f/5d/5f6c63eebb5afee93bcaae4ce9a898f3373ca23df3ccaef086d0233a35a7/pydantic_core-2.41.5-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:f41a7489d32336dbf2199c8c0a215390a751c5b014c2c1c5366e817202e9cdf7", size = 2110990, upload-time = "2025-11-04T13:39:58.079Z" }, + { url = "https://files.pythonhosted.org/packages/aa/32/9c2e8ccb57c01111e0fd091f236c7b371c1bccea0fa85247ac55b1e2b6b6/pydantic_core-2.41.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:070259a8818988b9a84a449a2a7337c7f430a22acc0859c6b110aa7212a6d9c0", size = 1896003, upload-time = "2025-11-04T13:39:59.956Z" }, + { url = "https://files.pythonhosted.org/packages/68/b8/a01b53cb0e59139fbc9e4fda3e9724ede8de279097179be4ff31f1abb65a/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e96cea19e34778f8d59fe40775a7a574d95816eb150850a85a7a4c8f4b94ac69", size = 1919200, upload-time = "2025-11-04T13:40:02.241Z" }, + { url = "https://files.pythonhosted.org/packages/38/de/8c36b5198a29bdaade07b5985e80a233a5ac27137846f3bc2d3b40a47360/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ed2e99c456e3fadd05c991f8f437ef902e00eedf34320ba2b0842bd1c3ca3a75", size = 2052578, upload-time = "2025-11-04T13:40:04.401Z" }, + { url = "https://files.pythonhosted.org/packages/00/b5/0e8e4b5b081eac6cb3dbb7e60a65907549a1ce035a724368c330112adfdd/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:65840751b72fbfd82c3c640cff9284545342a4f1eb1586ad0636955b261b0b05", size = 2208504, upload-time = "2025-11-04T13:40:06.072Z" }, + { url = "https://files.pythonhosted.org/packages/77/56/87a61aad59c7c5b9dc8caad5a41a5545cba3810c3e828708b3d7404f6cef/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e536c98a7626a98feb2d3eaf75944ef6f3dbee447e1f841eae16f2f0a72d8ddc", size = 2335816, upload-time = "2025-11-04T13:40:07.835Z" }, + { url = "https://files.pythonhosted.org/packages/0d/76/941cc9f73529988688a665a5c0ecff1112b3d95ab48f81db5f7606f522d3/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:eceb81a8d74f9267ef4081e246ffd6d129da5d87e37a77c9bde550cb04870c1c", size = 2075366, upload-time = "2025-11-04T13:40:09.804Z" }, + { url = "https://files.pythonhosted.org/packages/d3/43/ebef01f69baa07a482844faaa0a591bad1ef129253ffd0cdaa9d8a7f72d3/pydantic_core-2.41.5-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d38548150c39b74aeeb0ce8ee1d8e82696f4a4e16ddc6de7b1d8823f7de4b9b5", size = 2171698, upload-time = "2025-11-04T13:40:12.004Z" }, + { url = "https://files.pythonhosted.org/packages/b1/87/41f3202e4193e3bacfc2c065fab7706ebe81af46a83d3e27605029c1f5a6/pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:c23e27686783f60290e36827f9c626e63154b82b116d7fe9adba1fda36da706c", size = 2132603, upload-time = "2025-11-04T13:40:13.868Z" }, + { url = "https://files.pythonhosted.org/packages/49/7d/4c00df99cb12070b6bccdef4a195255e6020a550d572768d92cc54dba91a/pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:482c982f814460eabe1d3bb0adfdc583387bd4691ef00b90575ca0d2b6fe2294", size = 2329591, upload-time = "2025-11-04T13:40:15.672Z" }, + { url = "https://files.pythonhosted.org/packages/cc/6a/ebf4b1d65d458f3cda6a7335d141305dfa19bdc61140a884d165a8a1bbc7/pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:bfea2a5f0b4d8d43adf9d7b8bf019fb46fdd10a2e5cde477fbcb9d1fa08c68e1", size = 2319068, upload-time = "2025-11-04T13:40:17.532Z" }, + { url = "https://files.pythonhosted.org/packages/49/3b/774f2b5cd4192d5ab75870ce4381fd89cf218af999515baf07e7206753f0/pydantic_core-2.41.5-cp312-cp312-win32.whl", hash = "sha256:b74557b16e390ec12dca509bce9264c3bbd128f8a2c376eaa68003d7f327276d", size = 1985908, upload-time = "2025-11-04T13:40:19.309Z" }, + { url = "https://files.pythonhosted.org/packages/86/45/00173a033c801cacf67c190fef088789394feaf88a98a7035b0e40d53dc9/pydantic_core-2.41.5-cp312-cp312-win_amd64.whl", hash = "sha256:1962293292865bca8e54702b08a4f26da73adc83dd1fcf26fbc875b35d81c815", size = 2020145, upload-time = "2025-11-04T13:40:21.548Z" }, + { url = "https://files.pythonhosted.org/packages/f9/22/91fbc821fa6d261b376a3f73809f907cec5ca6025642c463d3488aad22fb/pydantic_core-2.41.5-cp312-cp312-win_arm64.whl", hash = "sha256:1746d4a3d9a794cacae06a5eaaccb4b8643a131d45fbc9af23e353dc0a5ba5c3", size = 1976179, upload-time = "2025-11-04T13:40:23.393Z" }, + { url = "https://files.pythonhosted.org/packages/87/06/8806241ff1f70d9939f9af039c6c35f2360cf16e93c2ca76f184e76b1564/pydantic_core-2.41.5-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:941103c9be18ac8daf7b7adca8228f8ed6bb7a1849020f643b3a14d15b1924d9", size = 2120403, upload-time = "2025-11-04T13:40:25.248Z" }, + { url = "https://files.pythonhosted.org/packages/94/02/abfa0e0bda67faa65fef1c84971c7e45928e108fe24333c81f3bfe35d5f5/pydantic_core-2.41.5-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:112e305c3314f40c93998e567879e887a3160bb8689ef3d2c04b6cc62c33ac34", size = 1896206, upload-time = "2025-11-04T13:40:27.099Z" }, + { url = "https://files.pythonhosted.org/packages/15/df/a4c740c0943e93e6500f9eb23f4ca7ec9bf71b19e608ae5b579678c8d02f/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0cbaad15cb0c90aa221d43c00e77bb33c93e8d36e0bf74760cd00e732d10a6a0", size = 1919307, upload-time = "2025-11-04T13:40:29.806Z" }, + { url = "https://files.pythonhosted.org/packages/9a/e3/6324802931ae1d123528988e0e86587c2072ac2e5394b4bc2bc34b61ff6e/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:03ca43e12fab6023fc79d28ca6b39b05f794ad08ec2feccc59a339b02f2b3d33", size = 2063258, upload-time = "2025-11-04T13:40:33.544Z" }, + { url = "https://files.pythonhosted.org/packages/c9/d4/2230d7151d4957dd79c3044ea26346c148c98fbf0ee6ebd41056f2d62ab5/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:dc799088c08fa04e43144b164feb0c13f9a0bc40503f8df3e9fde58a3c0c101e", size = 2214917, upload-time = "2025-11-04T13:40:35.479Z" }, + { url = "https://files.pythonhosted.org/packages/e6/9f/eaac5df17a3672fef0081b6c1bb0b82b33ee89aa5cec0d7b05f52fd4a1fa/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:97aeba56665b4c3235a0e52b2c2f5ae9cd071b8a8310ad27bddb3f7fb30e9aa2", size = 2332186, upload-time = "2025-11-04T13:40:37.436Z" }, + { url = "https://files.pythonhosted.org/packages/cf/4e/35a80cae583a37cf15604b44240e45c05e04e86f9cfd766623149297e971/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:406bf18d345822d6c21366031003612b9c77b3e29ffdb0f612367352aab7d586", size = 2073164, upload-time = "2025-11-04T13:40:40.289Z" }, + { url = "https://files.pythonhosted.org/packages/bf/e3/f6e262673c6140dd3305d144d032f7bd5f7497d3871c1428521f19f9efa2/pydantic_core-2.41.5-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:b93590ae81f7010dbe380cdeab6f515902ebcbefe0b9327cc4804d74e93ae69d", size = 2179146, upload-time = "2025-11-04T13:40:42.809Z" }, + { url = "https://files.pythonhosted.org/packages/75/c7/20bd7fc05f0c6ea2056a4565c6f36f8968c0924f19b7d97bbfea55780e73/pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:01a3d0ab748ee531f4ea6c3e48ad9dac84ddba4b0d82291f87248f2f9de8d740", size = 2137788, upload-time = "2025-11-04T13:40:44.752Z" }, + { url = "https://files.pythonhosted.org/packages/3a/8d/34318ef985c45196e004bc46c6eab2eda437e744c124ef0dbe1ff2c9d06b/pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:6561e94ba9dacc9c61bce40e2d6bdc3bfaa0259d3ff36ace3b1e6901936d2e3e", size = 2340133, upload-time = "2025-11-04T13:40:46.66Z" }, + { url = "https://files.pythonhosted.org/packages/9c/59/013626bf8c78a5a5d9350d12e7697d3d4de951a75565496abd40ccd46bee/pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:915c3d10f81bec3a74fbd4faebe8391013ba61e5a1a8d48c4455b923bdda7858", size = 2324852, upload-time = "2025-11-04T13:40:48.575Z" }, + { url = "https://files.pythonhosted.org/packages/1a/d9/c248c103856f807ef70c18a4f986693a46a8ffe1602e5d361485da502d20/pydantic_core-2.41.5-cp313-cp313-win32.whl", hash = "sha256:650ae77860b45cfa6e2cdafc42618ceafab3a2d9a3811fcfbd3bbf8ac3c40d36", size = 1994679, upload-time = "2025-11-04T13:40:50.619Z" }, + { url = "https://files.pythonhosted.org/packages/9e/8b/341991b158ddab181cff136acd2552c9f35bd30380422a639c0671e99a91/pydantic_core-2.41.5-cp313-cp313-win_amd64.whl", hash = "sha256:79ec52ec461e99e13791ec6508c722742ad745571f234ea6255bed38c6480f11", size = 2019766, upload-time = "2025-11-04T13:40:52.631Z" }, + { url = "https://files.pythonhosted.org/packages/73/7d/f2f9db34af103bea3e09735bb40b021788a5e834c81eedb541991badf8f5/pydantic_core-2.41.5-cp313-cp313-win_arm64.whl", hash = "sha256:3f84d5c1b4ab906093bdc1ff10484838aca54ef08de4afa9de0f5f14d69639cd", size = 1981005, upload-time = "2025-11-04T13:40:54.734Z" }, + { url = "https://files.pythonhosted.org/packages/ea/28/46b7c5c9635ae96ea0fbb779e271a38129df2550f763937659ee6c5dbc65/pydantic_core-2.41.5-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:3f37a19d7ebcdd20b96485056ba9e8b304e27d9904d233d7b1015db320e51f0a", size = 2119622, upload-time = "2025-11-04T13:40:56.68Z" }, + { url = "https://files.pythonhosted.org/packages/74/1a/145646e5687e8d9a1e8d09acb278c8535ebe9e972e1f162ed338a622f193/pydantic_core-2.41.5-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:1d1d9764366c73f996edd17abb6d9d7649a7eb690006ab6adbda117717099b14", size = 1891725, upload-time = "2025-11-04T13:40:58.807Z" }, + { url = "https://files.pythonhosted.org/packages/23/04/e89c29e267b8060b40dca97bfc64a19b2a3cf99018167ea1677d96368273/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:25e1c2af0fce638d5f1988b686f3b3ea8cd7de5f244ca147c777769e798a9cd1", size = 1915040, upload-time = "2025-11-04T13:41:00.853Z" }, + { url = "https://files.pythonhosted.org/packages/84/a3/15a82ac7bd97992a82257f777b3583d3e84bdb06ba6858f745daa2ec8a85/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:506d766a8727beef16b7adaeb8ee6217c64fc813646b424d0804d67c16eddb66", size = 2063691, upload-time = "2025-11-04T13:41:03.504Z" }, + { url = "https://files.pythonhosted.org/packages/74/9b/0046701313c6ef08c0c1cf0e028c67c770a4e1275ca73131563c5f2a310a/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4819fa52133c9aa3c387b3328f25c1facc356491e6135b459f1de698ff64d869", size = 2213897, upload-time = "2025-11-04T13:41:05.804Z" }, + { url = "https://files.pythonhosted.org/packages/8a/cd/6bac76ecd1b27e75a95ca3a9a559c643b3afcd2dd62086d4b7a32a18b169/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2b761d210c9ea91feda40d25b4efe82a1707da2ef62901466a42492c028553a2", size = 2333302, upload-time = "2025-11-04T13:41:07.809Z" }, + { url = "https://files.pythonhosted.org/packages/4c/d2/ef2074dc020dd6e109611a8be4449b98cd25e1b9b8a303c2f0fca2f2bcf7/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:22f0fb8c1c583a3b6f24df2470833b40207e907b90c928cc8d3594b76f874375", size = 2064877, upload-time = "2025-11-04T13:41:09.827Z" }, + { url = "https://files.pythonhosted.org/packages/18/66/e9db17a9a763d72f03de903883c057b2592c09509ccfe468187f2a2eef29/pydantic_core-2.41.5-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:2782c870e99878c634505236d81e5443092fba820f0373997ff75f90f68cd553", size = 2180680, upload-time = "2025-11-04T13:41:12.379Z" }, + { url = "https://files.pythonhosted.org/packages/d3/9e/3ce66cebb929f3ced22be85d4c2399b8e85b622db77dad36b73c5387f8f8/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:0177272f88ab8312479336e1d777f6b124537d47f2123f89cb37e0accea97f90", size = 2138960, upload-time = "2025-11-04T13:41:14.627Z" }, + { url = "https://files.pythonhosted.org/packages/a6/62/205a998f4327d2079326b01abee48e502ea739d174f0a89295c481a2272e/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:63510af5e38f8955b8ee5687740d6ebf7c2a0886d15a6d65c32814613681bc07", size = 2339102, upload-time = "2025-11-04T13:41:16.868Z" }, + { url = "https://files.pythonhosted.org/packages/3c/0d/f05e79471e889d74d3d88f5bd20d0ed189ad94c2423d81ff8d0000aab4ff/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:e56ba91f47764cc14f1daacd723e3e82d1a89d783f0f5afe9c364b8bb491ccdb", size = 2326039, upload-time = "2025-11-04T13:41:18.934Z" }, + { url = "https://files.pythonhosted.org/packages/ec/e1/e08a6208bb100da7e0c4b288eed624a703f4d129bde2da475721a80cab32/pydantic_core-2.41.5-cp314-cp314-win32.whl", hash = "sha256:aec5cf2fd867b4ff45b9959f8b20ea3993fc93e63c7363fe6851424c8a7e7c23", size = 1995126, upload-time = "2025-11-04T13:41:21.418Z" }, + { url = "https://files.pythonhosted.org/packages/48/5d/56ba7b24e9557f99c9237e29f5c09913c81eeb2f3217e40e922353668092/pydantic_core-2.41.5-cp314-cp314-win_amd64.whl", hash = "sha256:8e7c86f27c585ef37c35e56a96363ab8de4e549a95512445b85c96d3e2f7c1bf", size = 2015489, upload-time = "2025-11-04T13:41:24.076Z" }, + { url = "https://files.pythonhosted.org/packages/4e/bb/f7a190991ec9e3e0ba22e4993d8755bbc4a32925c0b5b42775c03e8148f9/pydantic_core-2.41.5-cp314-cp314-win_arm64.whl", hash = "sha256:e672ba74fbc2dc8eea59fb6d4aed6845e6905fc2a8afe93175d94a83ba2a01a0", size = 1977288, upload-time = "2025-11-04T13:41:26.33Z" }, + { url = "https://files.pythonhosted.org/packages/92/ed/77542d0c51538e32e15afe7899d79efce4b81eee631d99850edc2f5e9349/pydantic_core-2.41.5-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:8566def80554c3faa0e65ac30ab0932b9e3a5cd7f8323764303d468e5c37595a", size = 2120255, upload-time = "2025-11-04T13:41:28.569Z" }, + { url = "https://files.pythonhosted.org/packages/bb/3d/6913dde84d5be21e284439676168b28d8bbba5600d838b9dca99de0fad71/pydantic_core-2.41.5-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:b80aa5095cd3109962a298ce14110ae16b8c1aece8b72f9dafe81cf597ad80b3", size = 1863760, upload-time = "2025-11-04T13:41:31.055Z" }, + { url = "https://files.pythonhosted.org/packages/5a/f0/e5e6b99d4191da102f2b0eb9687aaa7f5bea5d9964071a84effc3e40f997/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3006c3dd9ba34b0c094c544c6006cc79e87d8612999f1a5d43b769b89181f23c", size = 1878092, upload-time = "2025-11-04T13:41:33.21Z" }, + { url = "https://files.pythonhosted.org/packages/71/48/36fb760642d568925953bcc8116455513d6e34c4beaa37544118c36aba6d/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:72f6c8b11857a856bcfa48c86f5368439f74453563f951e473514579d44aa612", size = 2053385, upload-time = "2025-11-04T13:41:35.508Z" }, + { url = "https://files.pythonhosted.org/packages/20/25/92dc684dd8eb75a234bc1c764b4210cf2646479d54b47bf46061657292a8/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5cb1b2f9742240e4bb26b652a5aeb840aa4b417c7748b6f8387927bc6e45e40d", size = 2218832, upload-time = "2025-11-04T13:41:37.732Z" }, + { url = "https://files.pythonhosted.org/packages/e2/09/f53e0b05023d3e30357d82eb35835d0f6340ca344720a4599cd663dca599/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:bd3d54f38609ff308209bd43acea66061494157703364ae40c951f83ba99a1a9", size = 2327585, upload-time = "2025-11-04T13:41:40Z" }, + { url = "https://files.pythonhosted.org/packages/aa/4e/2ae1aa85d6af35a39b236b1b1641de73f5a6ac4d5a7509f77b814885760c/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2ff4321e56e879ee8d2a879501c8e469414d948f4aba74a2d4593184eb326660", size = 2041078, upload-time = "2025-11-04T13:41:42.323Z" }, + { url = "https://files.pythonhosted.org/packages/cd/13/2e215f17f0ef326fc72afe94776edb77525142c693767fc347ed6288728d/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d0d2568a8c11bf8225044aa94409e21da0cb09dcdafe9ecd10250b2baad531a9", size = 2173914, upload-time = "2025-11-04T13:41:45.221Z" }, + { url = "https://files.pythonhosted.org/packages/02/7a/f999a6dcbcd0e5660bc348a3991c8915ce6599f4f2c6ac22f01d7a10816c/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:a39455728aabd58ceabb03c90e12f71fd30fa69615760a075b9fec596456ccc3", size = 2129560, upload-time = "2025-11-04T13:41:47.474Z" }, + { url = "https://files.pythonhosted.org/packages/3a/b1/6c990ac65e3b4c079a4fb9f5b05f5b013afa0f4ed6780a3dd236d2cbdc64/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:239edca560d05757817c13dc17c50766136d21f7cd0fac50295499ae24f90fdf", size = 2329244, upload-time = "2025-11-04T13:41:49.992Z" }, + { url = "https://files.pythonhosted.org/packages/d9/02/3c562f3a51afd4d88fff8dffb1771b30cfdfd79befd9883ee094f5b6c0d8/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:2a5e06546e19f24c6a96a129142a75cee553cc018ffee48a460059b1185f4470", size = 2331955, upload-time = "2025-11-04T13:41:54.079Z" }, + { url = "https://files.pythonhosted.org/packages/5c/96/5fb7d8c3c17bc8c62fdb031c47d77a1af698f1d7a406b0f79aaa1338f9ad/pydantic_core-2.41.5-cp314-cp314t-win32.whl", hash = "sha256:b4ececa40ac28afa90871c2cc2b9ffd2ff0bf749380fbdf57d165fd23da353aa", size = 1988906, upload-time = "2025-11-04T13:41:56.606Z" }, + { url = "https://files.pythonhosted.org/packages/22/ed/182129d83032702912c2e2d8bbe33c036f342cc735737064668585dac28f/pydantic_core-2.41.5-cp314-cp314t-win_amd64.whl", hash = "sha256:80aa89cad80b32a912a65332f64a4450ed00966111b6615ca6816153d3585a8c", size = 1981607, upload-time = "2025-11-04T13:41:58.889Z" }, + { url = "https://files.pythonhosted.org/packages/9f/ed/068e41660b832bb0b1aa5b58011dea2a3fe0ba7861ff38c4d4904c1c1a99/pydantic_core-2.41.5-cp314-cp314t-win_arm64.whl", hash = "sha256:35b44f37a3199f771c3eaa53051bc8a70cd7b54f333531c59e29fd4db5d15008", size = 1974769, upload-time = "2025-11-04T13:42:01.186Z" }, + { url = "https://files.pythonhosted.org/packages/11/72/90fda5ee3b97e51c494938a4a44c3a35a9c96c19bba12372fb9c634d6f57/pydantic_core-2.41.5-graalpy311-graalpy242_311_native-macosx_10_12_x86_64.whl", hash = "sha256:b96d5f26b05d03cc60f11a7761a5ded1741da411e7fe0909e27a5e6a0cb7b034", size = 2115441, upload-time = "2025-11-04T13:42:39.557Z" }, + { url = "https://files.pythonhosted.org/packages/1f/53/8942f884fa33f50794f119012dc6a1a02ac43a56407adaac20463df8e98f/pydantic_core-2.41.5-graalpy311-graalpy242_311_native-macosx_11_0_arm64.whl", hash = "sha256:634e8609e89ceecea15e2d61bc9ac3718caaaa71963717bf3c8f38bfde64242c", size = 1930291, upload-time = "2025-11-04T13:42:42.169Z" }, + { url = "https://files.pythonhosted.org/packages/79/c8/ecb9ed9cd942bce09fc888ee960b52654fbdbede4ba6c2d6e0d3b1d8b49c/pydantic_core-2.41.5-graalpy311-graalpy242_311_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:93e8740d7503eb008aa2df04d3b9735f845d43ae845e6dcd2be0b55a2da43cd2", size = 1948632, upload-time = "2025-11-04T13:42:44.564Z" }, + { url = "https://files.pythonhosted.org/packages/2e/1b/687711069de7efa6af934e74f601e2a4307365e8fdc404703afc453eab26/pydantic_core-2.41.5-graalpy311-graalpy242_311_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f15489ba13d61f670dcc96772e733aad1a6f9c429cc27574c6cdaed82d0146ad", size = 2138905, upload-time = "2025-11-04T13:42:47.156Z" }, + { url = "https://files.pythonhosted.org/packages/09/32/59b0c7e63e277fa7911c2fc70ccfb45ce4b98991e7ef37110663437005af/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:7da7087d756b19037bc2c06edc6c170eeef3c3bafcb8f532ff17d64dc427adfd", size = 2110495, upload-time = "2025-11-04T13:42:49.689Z" }, + { url = "https://files.pythonhosted.org/packages/aa/81/05e400037eaf55ad400bcd318c05bb345b57e708887f07ddb2d20e3f0e98/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:aabf5777b5c8ca26f7824cb4a120a740c9588ed58df9b2d196ce92fba42ff8dc", size = 1915388, upload-time = "2025-11-04T13:42:52.215Z" }, + { url = "https://files.pythonhosted.org/packages/6e/0d/e3549b2399f71d56476b77dbf3cf8937cec5cd70536bdc0e374a421d0599/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c007fe8a43d43b3969e8469004e9845944f1a80e6acd47c150856bb87f230c56", size = 1942879, upload-time = "2025-11-04T13:42:56.483Z" }, + { url = "https://files.pythonhosted.org/packages/f7/07/34573da085946b6a313d7c42f82f16e8920bfd730665de2d11c0c37a74b5/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:76d0819de158cd855d1cbb8fcafdf6f5cf1eb8e470abe056d5d161106e38062b", size = 2139017, upload-time = "2025-11-04T13:42:59.471Z" }, + { url = "https://files.pythonhosted.org/packages/e6/b0/1a2aa41e3b5a4ba11420aba2d091b2d17959c8d1519ece3627c371951e73/pydantic_core-2.41.5-pp310-pypy310_pp73-macosx_10_12_x86_64.whl", hash = "sha256:b5819cd790dbf0c5eb9f82c73c16b39a65dd6dd4d1439dcdea7816ec9adddab8", size = 2103351, upload-time = "2025-11-04T13:43:02.058Z" }, + { url = "https://files.pythonhosted.org/packages/a4/ee/31b1f0020baaf6d091c87900ae05c6aeae101fa4e188e1613c80e4f1ea31/pydantic_core-2.41.5-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:5a4e67afbc95fa5c34cf27d9089bca7fcab4e51e57278d710320a70b956d1b9a", size = 1925363, upload-time = "2025-11-04T13:43:05.159Z" }, + { url = "https://files.pythonhosted.org/packages/e1/89/ab8e86208467e467a80deaca4e434adac37b10a9d134cd2f99b28a01e483/pydantic_core-2.41.5-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ece5c59f0ce7d001e017643d8d24da587ea1f74f6993467d85ae8a5ef9d4f42b", size = 2135615, upload-time = "2025-11-04T13:43:08.116Z" }, + { url = "https://files.pythonhosted.org/packages/99/0a/99a53d06dd0348b2008f2f30884b34719c323f16c3be4e6cc1203b74a91d/pydantic_core-2.41.5-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:16f80f7abe3351f8ea6858914ddc8c77e02578544a0ebc15b4c2e1a0e813b0b2", size = 2175369, upload-time = "2025-11-04T13:43:12.49Z" }, + { url = "https://files.pythonhosted.org/packages/6d/94/30ca3b73c6d485b9bb0bc66e611cff4a7138ff9736b7e66bcf0852151636/pydantic_core-2.41.5-pp310-pypy310_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:33cb885e759a705b426baada1fe68cbb0a2e68e34c5d0d0289a364cf01709093", size = 2144218, upload-time = "2025-11-04T13:43:15.431Z" }, + { url = "https://files.pythonhosted.org/packages/87/57/31b4f8e12680b739a91f472b5671294236b82586889ef764b5fbc6669238/pydantic_core-2.41.5-pp310-pypy310_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:c8d8b4eb992936023be7dee581270af5c6e0697a8559895f527f5b7105ecd36a", size = 2329951, upload-time = "2025-11-04T13:43:18.062Z" }, + { url = "https://files.pythonhosted.org/packages/7d/73/3c2c8edef77b8f7310e6fb012dbc4b8551386ed575b9eb6fb2506e28a7eb/pydantic_core-2.41.5-pp310-pypy310_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:242a206cd0318f95cd21bdacff3fcc3aab23e79bba5cac3db5a841c9ef9c6963", size = 2318428, upload-time = "2025-11-04T13:43:20.679Z" }, + { url = "https://files.pythonhosted.org/packages/2f/02/8559b1f26ee0d502c74f9cca5c0d2fd97e967e083e006bbbb4e97f3a043a/pydantic_core-2.41.5-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:d3a978c4f57a597908b7e697229d996d77a6d3c94901e9edee593adada95ce1a", size = 2147009, upload-time = "2025-11-04T13:43:23.286Z" }, + { url = "https://files.pythonhosted.org/packages/5f/9b/1b3f0e9f9305839d7e84912f9e8bfbd191ed1b1ef48083609f0dabde978c/pydantic_core-2.41.5-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:b2379fa7ed44ddecb5bfe4e48577d752db9fc10be00a6b7446e9663ba143de26", size = 2101980, upload-time = "2025-11-04T13:43:25.97Z" }, + { url = "https://files.pythonhosted.org/packages/a4/ed/d71fefcb4263df0da6a85b5d8a7508360f2f2e9b3bf5814be9c8bccdccc1/pydantic_core-2.41.5-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:266fb4cbf5e3cbd0b53669a6d1b039c45e3ce651fd5442eff4d07c2cc8d66808", size = 1923865, upload-time = "2025-11-04T13:43:28.763Z" }, + { url = "https://files.pythonhosted.org/packages/ce/3a/626b38db460d675f873e4444b4bb030453bbe7b4ba55df821d026a0493c4/pydantic_core-2.41.5-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:58133647260ea01e4d0500089a8c4f07bd7aa6ce109682b1426394988d8aaacc", size = 2134256, upload-time = "2025-11-04T13:43:31.71Z" }, + { url = "https://files.pythonhosted.org/packages/83/d9/8412d7f06f616bbc053d30cb4e5f76786af3221462ad5eee1f202021eb4e/pydantic_core-2.41.5-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:287dad91cfb551c363dc62899a80e9e14da1f0e2b6ebde82c806612ca2a13ef1", size = 2174762, upload-time = "2025-11-04T13:43:34.744Z" }, + { url = "https://files.pythonhosted.org/packages/55/4c/162d906b8e3ba3a99354e20faa1b49a85206c47de97a639510a0e673f5da/pydantic_core-2.41.5-pp311-pypy311_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:03b77d184b9eb40240ae9fd676ca364ce1085f203e1b1256f8ab9984dca80a84", size = 2143141, upload-time = "2025-11-04T13:43:37.701Z" }, + { url = "https://files.pythonhosted.org/packages/1f/f2/f11dd73284122713f5f89fc940f370d035fa8e1e078d446b3313955157fe/pydantic_core-2.41.5-pp311-pypy311_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:a668ce24de96165bb239160b3d854943128f4334822900534f2fe947930e5770", size = 2330317, upload-time = "2025-11-04T13:43:40.406Z" }, + { url = "https://files.pythonhosted.org/packages/88/9d/b06ca6acfe4abb296110fb1273a4d848a0bfb2ff65f3ee92127b3244e16b/pydantic_core-2.41.5-pp311-pypy311_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:f14f8f046c14563f8eb3f45f499cc658ab8d10072961e07225e507adb700e93f", size = 2316992, upload-time = "2025-11-04T13:43:43.602Z" }, + { url = "https://files.pythonhosted.org/packages/36/c7/cfc8e811f061c841d7990b0201912c3556bfeb99cdcb7ed24adc8d6f8704/pydantic_core-2.41.5-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:56121965f7a4dc965bff783d70b907ddf3d57f6eba29b6d2e5dabfaf07799c51", size = 2145302, upload-time = "2025-11-04T13:43:46.64Z" }, +] + +[[package]] +name = "resend-fake" +version = "1.0.0" +source = { virtual = "." } +dependencies = [ + { name = "fastapi" }, + { name = "httpx" }, + { name = "pydantic" }, + { name = "uvicorn" }, +] + +[package.metadata] +requires-dist = [ + { name = "fastapi", specifier = ">=0.115.0" }, + { name = "httpx", specifier = ">=0.27.0" }, + { name = "pydantic", specifier = ">=2.0.0" }, + { name = "uvicorn", specifier = ">=0.32.0" }, +] + +[[package]] +name = "starlette" +version = "0.52.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c4/68/79977123bb7be889ad680d79a40f339082c1978b5cfcf62c2d8d196873ac/starlette-0.52.1.tar.gz", hash = "sha256:834edd1b0a23167694292e94f597773bc3f89f362be6effee198165a35d62933", size = 2653702, upload-time = "2026-01-18T13:34:11.062Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/81/0d/13d1d239a25cbfb19e740db83143e95c772a1fe10202dda4b76792b114dd/starlette-0.52.1-py3-none-any.whl", hash = "sha256:0029d43eb3d273bc4f83a08720b4912ea4b071087a3b48db01b7c839f7954d74", size = 74272, upload-time = "2026-01-18T13:34:09.188Z" }, +] + +[[package]] +name = "typing-extensions" +version = "4.15.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391, upload-time = "2025-08-25T13:49:26.313Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" }, +] + +[[package]] +name = "typing-inspection" +version = "0.4.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/55/e3/70399cb7dd41c10ac53367ae42139cf4b1ca5f36bb3dc6c9d33acdb43655/typing_inspection-0.4.2.tar.gz", hash = "sha256:ba561c48a67c5958007083d386c3295464928b01faa735ab8547c5692e87f464", size = 75949, upload-time = "2025-10-01T02:14:41.687Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7", size = 14611, upload-time = "2025-10-01T02:14:40.154Z" }, +] + +[[package]] +name = "uvicorn" +version = "0.41.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, + { name = "h11" }, + { name = "typing-extensions", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/32/ce/eeb58ae4ac36fe09e3842eb02e0eb676bf2c53ae062b98f1b2531673efdd/uvicorn-0.41.0.tar.gz", hash = "sha256:09d11cf7008da33113824ee5a1c6422d89fbc2ff476540d69a34c87fab8b571a", size = 82633, upload-time = "2026-02-16T23:07:24.1Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/83/e4/d04a086285c20886c0daad0e026f250869201013d18f81d9ff5eada73a88/uvicorn-0.41.0-py3-none-any.whl", hash = "sha256:29e35b1d2c36a04b9e180d4007ede3bcb32a85fbdfd6c6aeb3f26839de088187", size = 68783, upload-time = "2026-02-16T23:07:22.357Z" }, +] diff --git a/services/resend/service.yaml b/services/resend/service.yaml new file mode 100644 index 0000000..fa52715 --- /dev/null +++ b/services/resend/service.yaml @@ -0,0 +1,46 @@ +name: resend +version: '1.0' +description: Email sending and management API for developers, supporting transactional + emails, batch sending, domain management, contacts, templates, and webhooks. +docs: https://resend.com/docs/api-reference/introduction +brief: 'Resend is a developer-focused email API service that enables applications + to send transactional and marketing emails via a REST API. It provides a clean, + modern alternative to legacy email providers like SendGrid and Mailgun, with first-class + support for React Email components. The API is organized around core resources: + Emails (send, batch send, schedule, cancel, retrieve), Domains (register, verify + via DNS, configure tracking), Contacts (manage subscriber lists with properties + and segments), Templates (create, publish, and send with variable substitution), + API Keys (manage access with granular permissions), Broadcasts (bulk marketing emails + to audiences), and Webhooks (subscribe to delivery events like sent, delivered, + bounced, opened, clicked). + + + This fake covers the primary API surface that AI agents would use: sending individual + and batch emails, managing email lifecycle (scheduling, canceling, retrieving status), + domain registration and verification, contact CRUD with subscription management, + template creation and publishing, API key management, and webhook configuration. + The API uses Bearer token authentication, enforces HTTPS, has a 2 req/sec rate limit + per team, and supports cursor-based pagination on list endpoints. All responses + follow a consistent pattern with an ''object'' field indicating the resource type.' +supported_flows: +- Email Sending Lifecycle +- Scheduled Email Management +- Batch Email Sending +- Domain Management +- Contact Management +- Template Lifecycle +- API Key Management +- Webhook Configuration +- Error Handling and Edge Cases +- Template and Email Integration + +server: + command: ["uv", "run", "python", "main.py"] + +contracts: + command: ["uv", "run", "pytest", "-v", "--tb=short"] + +# Environment variables set by the CLI when running contract tests: +# DOUBLEAGENT_RESEND_URL - URL of the running fake service (set automatically) +# RESEND_GROUNDING_TOKEN - API token for grounding mode against the real Resend API +# PORT - Port the service listens on (set automatically)