diff --git a/CHANGELOG.md b/CHANGELOG.md index cd956be2..aee6f7af 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,23 @@ All notable changes to **stunt** are documented here. The format is based on ## [Unreleased] +## [0.43.0] — 2026-08-17 + +### Adapters + +- **New: `emailoctopus-style`** — the 95th adapter, researched from the + official v2 OpenAPI spec. Bearer API-key auth; lists CRUD with derived + counts; full contact lifecycle (double-opt-in lists default to `pending`, + single-opt-in to `subscribed`; unsubscribe/resubscribe; upsert + batch + with per-row 404s; status filters; 32-hex ids derived from the lowercased + email); fields + tags CRUD with merge-tag rename cascades; read-only + campaigns with summary/links/contact reports (v2 has no campaign-create + API — campaigns are dashboard-authored); automation queue. RFC 7807 + problem+json errors with the provider's exact detail strings and + `errors[{detail,pointer|parameter}]` on 422; `data` / + `paging.next.{url,starting_after}` envelope with base64 cursors. v2 has + no webhook API, so none is simulated. + ## [0.42.0] — 2026-08-17 ### Adapters diff --git a/README.md b/README.md index c499ceaa..214713f9 100644 --- a/README.md +++ b/README.md @@ -133,7 +133,7 @@ stunt adapter test ./myapi-style # conformance vs your local real t stunt catalog search stripe # browse the adapter registry ``` -**Reference adapters in this repo** — 94 of them (Stripe, Salesforce, Discord, Twilio, +**Reference adapters in this repo** — 95 of them (Stripe, Salesforce, Discord, Twilio, Square, Adyen, AWS S3, Google/Microsoft/Apple families, blockchain RPCs, …; all unofficial, synthetic-data-only, with a DISCLAIMER). Browse them with `stunt catalog search`. Highlights: @@ -142,6 +142,7 @@ synthetic-data-only, with a DISCLAIMER). Browse them with `stunt catalog search` | `stripe-style` | payments — full API surface (158 endpoints): **PaymentIntents, disputes, refunds, the Billing suite (subscriptions/invoices/credit notes), Checkout Sessions, SetupIntents, balance transactions**, Connect (persons/capabilities/application fees), **Test Clocks** (deterministic billing), **Idempotency-Key**, cursor-paginated lists, **signed webhooks + registration-gated delivery** | Collection + Starlark | | `salesforce-style` | CRM — sObjects CRUD, **general SOQL** (WHERE/IN/LIKE/AND/OR, ORDER BY, LIMIT/OFFSET), OAuth (password/auth-code/**refresh**) | Collection + Starlark | | `discord-style` | bot API — REST + **WebSocket Gateway (HELLO→IDENTIFY→READY→dispatch)** + **Ed25519-signed interactions** | Collection + Starlark | +| `emailoctopus-style` | email — **lists + contact lifecycle (double/single opt-in, unsubscribe/resubscribe)**, fields/tags CRUD, campaigns reports, **RFC 7807 errors** + cursor paging | Collection + Starlark | | `drive-style` | files API — upload/get/download/list/patch/delete, folders, about/quota, resumable uploads | Blob + Collection | | `dropbox-style` | files API (RPC-style) — upload/download/list_folder/get_metadata | Blob + Collection | | `twitter-style` | mock OAuth, tweets (CRUD), users, timeline | Collection (pure-mock) | diff --git a/adapters/emailoctopus-style/DISCLAIMER b/adapters/emailoctopus-style/DISCLAIMER new file mode 100644 index 00000000..5cdfb16a --- /dev/null +++ b/adapters/emailoctopus-style/DISCLAIMER @@ -0,0 +1,17 @@ +# DISCLAIMER + +This adapter is **not affiliated with, endorsed by, or sponsored by** EmailOctopus. +"EmailOctopus" and related marks are trademarks of their respective owners. + +This is a **local development and testing simulator** provided by the `stunt` project. + +- It **does not** call the real EmailOctopus API. +- It runs entirely on your local machine and returns **synthetic, fake data only**. +- It contains **no real EmailOctopus data, no recorded responses, and no proprietary + documentation**. All fixtures and templates are generated by fakers and pass + `stunt adapter lint`. +- It reproduces the *structure* of an EmailOctopus-style API solely so you can develop and + test your client code locally without creating remote accounts or hitting the network. + +Use of this adapter is at your own risk. If you are the provider and believe this +adapter should be changed or removed, please open an issue. diff --git a/adapters/emailoctopus-style/README.md b/adapters/emailoctopus-style/README.md new file mode 100644 index 00000000..b4f74b51 --- /dev/null +++ b/adapters/emailoctopus-style/README.md @@ -0,0 +1,228 @@ +# EmailOctopus-style adapter + +A stunt adapter for simulating the **EmailOctopus v2 API** locally. +All data is synthetic — no real API data is included. + +> **Unofficial / not affiliated.** This adapter is not affiliated with, endorsed +> by, or sponsored by EmailOctopus. "EmailOctopus" and related marks are +> trademarks of their respective owners. See [DISCLAIMER](DISCLAIMER) for full +> terms. This adapter is for **local development and testing only**. + +## What it simulates + +A behavioral mock of the EmailOctopus v2 API surface (base URL +`https://api.emailoctopus.com` — the real v2 API has **no version path +prefix**): + +- **Lists:** list, create, retrieve, update, delete (`/lists`), including the + derived per-status contact `counts` in the list response. +- **Contacts:** the full list-scoped lifecycle under + `/lists/{list_id}/contacts` — create (with double-opt-in `pending` + semantics), create-or-update (upsert, keyed on `email_address`), batch + update, retrieve, update (status / fields / tags), delete, plus list + filtering by `status`, `tag`, and `created_at`/`last_updated_at` ranges. +- **Fields:** list-scoped custom contact fields (text / number / date / + `choice_single` / `choice_multiple`). +- **Tags:** list-scoped contact tags, with rename/delete cascading to every + contact in the list. +- **Campaigns:** the read-only campaign surface and its three reports + (summary, links, per-contact events). +- **Automations:** queue a contact into an automation (204). + +State persists in SQLite-backed collections, so a list or contact created in +one request is visible in subsequent requests within the same `stunt up` +session. + +### Contact status lifecycle + +Contact statuses are the v2 enum: `pending`, `subscribed`, `unsubscribed`. + +- Creating a contact with **no** `status` on a **double-opt-in** list → + `pending` (the contact must confirm before becoming subscribed). +- Creating a contact with **no** `status` on a single-opt-in list → + `subscribed`. +- An explicit `status` member is honoured. +- Unsubscribe = `PUT .../contacts/{contact_id}` with + `{"status": "unsubscribed"}`; resubscribe = the same call with + `{"status": "subscribed"}`. +- Adding an email address that is already on the list → `409`. + +### Campaigns are read-only (on purpose) + +The real v2 API exposes **no** campaign create/update/delete endpoint — +campaigns are authored in the EmailOctopus dashboard and only read over the +API. This adapter reproduces exactly that route surface (there is no +`POST /campaigns` here either). So the simulator has something to read, the +campaigns collection is **derived on first read**: an empty store materialises +two synthetic campaigns (one `sent`, one `draft`) with reports. + +## Endpoints + +| Method | Route | Handler | Description | +|--------|-------|---------|-------------| +| GET | `/lists` | `lists.star#on_list_lists` | Get all lists | +| POST | `/lists` | `lists.star#on_create_list` | Create list (201) | +| GET | `/lists/{list_id}` | `lists.star#on_get_list` | Get list | +| PUT | `/lists/{list_id}` | `lists.star#on_update_list` | Update list | +| DELETE | `/lists/{list_id}` | `lists.star#on_delete_list` | Delete list (204) | +| POST | `/lists/{list_id}/fields` | `fields.star#on_create_field` | Create field (201) | +| PUT | `/lists/{list_id}/fields/{tag}` | `fields.star#on_update_field` | Update field | +| DELETE | `/lists/{list_id}/fields/{tag}` | `fields.star#on_delete_field` | Delete field (204) | +| GET | `/lists/{list_id}/contacts` | `contacts.star#on_list_contacts` | Get contacts (filters) | +| POST | `/lists/{list_id}/contacts` | `contacts.star#on_create_contact` | Create contact (201) | +| PUT | `/lists/{list_id}/contacts` | `contacts.star#on_upsert_contact` | Create or update contact | +| PUT | `/lists/{list_id}/contacts/batch` | `contacts.star#on_batch_update_contacts` | Update multiple contacts | +| GET | `/lists/{list_id}/contacts/{contact_id}` | `contacts.star#on_get_contact` | Get contact | +| PUT | `/lists/{list_id}/contacts/{contact_id}` | `contacts.star#on_update_contact` | Update contact | +| DELETE | `/lists/{list_id}/contacts/{contact_id}` | `contacts.star#on_delete_contact` | Delete contact (204) | +| GET | `/campaigns` | `campaigns.star#on_list_campaigns` | Get all campaigns | +| GET | `/campaigns/{campaign_id}` | `campaigns.star#on_get_campaign` | Get campaign | +| GET | `/campaigns/{campaign_id}/reports/summary` | `campaigns.star#on_campaign_summary` | Campaign summary report | +| GET | `/campaigns/{campaign_id}/reports/links` | `campaigns.star#on_campaign_links` | Campaign links report | +| GET | `/campaigns/{campaign_id}/reports` | `campaigns.star#on_campaign_contact_report` | Campaign contact report | +| POST | `/automations/{automation_id}/queue` | `automations.star#on_queue_automation` | Start an automation for a contact (204) | + +Any unmatched route returns `404`. + +### Contact list filters + +`GET /lists/{list_id}/contacts` honours the documented query params: +`status` (`subscribed` | `unsubscribed` | `pending`), `tag`, +`created_at.lte`/`created_at.gte`, `last_updated_at.lte`/ +`last_updated_at.gte`, plus the paging params. Timestamp filters compare the +ISO 8601 `+00:00` timestamps lexicographically (correct for same-offset +strings). + +## Errors + +Errors use EmailOctopus's RFC 7807 problem+json envelope, with the real +status codes and detail text: + +```json +{ + "type": "https://emailoctopus.com/api-documentation/v2#not-found", + "title": "An error occurred.", + "detail": "Resource not found.", + "status": 404 +} +``` + +`422` validation failures carry an `errors` array of +`{"detail", "pointer"}` members (JSON Pointer into the request document) — +or `{"detail", "parameter"}` for a bad query parameter: + +```json +{ + "type": "https://emailoctopus.com/api-documentation/v2#unprocessable-content", + "title": "An error occurred.", + "detail": "Unprocessable content.", + "status": 422, + "errors": [{"detail": "This value is not a valid email address.", "pointer": "/email_address"}] +} +``` + +| Status | `type` anchor | Detail | When | +|--------|---------------|--------|------| +| 400 | `#bad-request` | `Bad request.` | Request body is not valid JSON | +| 401 | `#unauthorized` | `Invalid key.` | Missing/invalid bearer token | +| 404 | `#not-found` | `Resource not found.` | Unknown resource or route | +| 409 | `#conflict` | `Resource already exists.` | Duplicate email / tag / field | +| 422 | `#unprocessable-content` | `Unprocessable content.` | Validation failure | + +The real API also documents 403/415/429/405 problem types (access-denied, +unsupported-media-type, too-many-requests, method-not-allowed). Rate limiting +is not simulated; an unmatched method falls through to the catch-all 404. + +## Pagination + +Collections are paginated with `?limit=` (default and maximum `100`) and the +`?starting_after=` cursor, returned in the `paging.next` envelope exactly as +the real API documents it: + +```json +{ + "data": ["..."], + "paging": { + "next": { + "url": "https://api.emailoctopus.com/lists//contacts?starting_after=MTI=&limit=100", + "starting_after": "MTI=" + } + } +} +``` + +`paging` is omitted when no further page exists. Cursors are base64-encoded +offsets minted by the adapter (the real cursors are opaque — treat them the +same way); a malformed cursor answers `400`. + +## Backing stores + +| Collection | Purpose | +|------------|---------| +| `lists` | List records (name, `double_opt_in`, inline `fields` and `tags`) | +| `contacts` | Contact records (one per list/email, keyed by the email hash) | +| `campaigns` | Campaign records + report aggregates (derived on first read) | +| `automation_queue` | Automation queue entries (automation, contact, queued_at) | + +Contact ids are the hash of the **lowercased email address** rendered as 32 +lowercase hex characters. The real API uses the MD5 of that string; stunt's +crypto module has no MD5, so a truncated SHA-256 is used — same shape, same +determinism-per-email property. List/campaign/automation ids are UUID-shaped +synthetic values. + +## Clock + +`created_at`, `last_updated_at`, `sent_at`, and report `occurred_at` are ISO +8601 UTC timestamps with the `+00:00` offset the real API documents (e.g. +`2015-12-01T12:59:37+00:00`), minted from the engine clock +(`clock.now_rfc3339()`). No hardcoded timestamp literals. + +## Events + +EmailOctopus API v2 exposes **no webhook endpoints**, so there is no real +signing scheme to reproduce. stunt still emits one **unsigned** +`events_emit` delivery per state transition (`list.created`, `list.updated`, +`list.deleted`, `contact.created`, `contact.updated`, +`contact.status.changed`, `contact.deleted`, `tag.*`, `field.*`, +`automation.queued`) so local consumers can observe the lifecycle. Events +fire after the state is persisted, and only when a value actually changed. + +## Auth + +The real API uses **HTTP bearer authentication** — +`Authorization: Bearer {token}` (an API key from the EmailOctopus dashboard). +This simulator accepts any non-empty bearer token, e.g.: + +```http +Authorization: Bearer eo_local_dev_key +``` + +A missing or empty token answers `401` with the real `#unauthorized` problem +shape (`"detail": "Invalid key."`). + +## Usage + +Point a `stunt.yaml` service at this directory: + +```yaml +services: + emailoctopus: + adapter: ./adapters/emailoctopus-style +``` + +Then `stunt up` and point your client at the served address. + +## Layout + +``` +adapter.yaml routes, resources, identity, catch-all 404 +DISCLAIMER not-affiliated notice +README.md this file +scripts/ + lib.star shared auth / errors / paging / ids / presentation + lists.star /lists CRUD + contacts.star contact lifecycle (create, upsert, batch, update, delete) + fields.star /lists/{list_id}/fields CRUD + campaigns.star /campaigns + reports + automations.star /automations/{id}/queue +``` diff --git a/adapters/emailoctopus-style/adapter.yaml b/adapters/emailoctopus-style/adapter.yaml new file mode 100644 index 00000000..ff93cb54 --- /dev/null +++ b/adapters/emailoctopus-style/adapter.yaml @@ -0,0 +1,150 @@ +# stunt adapter manifest — EmailOctopus-style API simulator (unofficial) +# Docs: https://stuntapi.com/stunt +# +# This adapter mimics the *structure* of the EmailOctopus v2 API +# (https://api.emailoctopus.com — bearer auth, no version path prefix) for +# LOCAL TESTING ONLY. It does not call the real EmailOctopus API. All data +# is synthetic. See DISCLAIMER. +id: emailoctopus-style +name: "EmailOctopus-style API simulator (unofficial)" +version: "0.1.0" + +api: + name: "EmailOctopus API" + version: "2.0.0" + +# Endpoints — each maps a route + method to a Starlark handler. +# NOTE: literal routes must come BEFORE parameterized routes so they are +# matched first (the dispatch engine checks in declaration order). The +# load-bearing ordering here is /lists/{list_id}/contacts/batch (literal +# "batch") before /lists/{list_id}/contacts/{contact_id}. +# +# concurrency_key serializes handlers that read-modify-write the same +# document (list doc for list-scoped mutations, contact doc for the +# id-scoped contact routes). +endpoints: + # --- Lists (collection) --- + - route: /lists + method: GET + handler: scripts/lists.star#on_list_lists + - route: /lists + method: POST + handler: scripts/lists.star#on_create_list + + # --- Lists (single) --- + - route: /lists/{list_id} + method: GET + handler: scripts/lists.star#on_get_list + concurrency_key: list_id + - route: /lists/{list_id} + method: PUT + handler: scripts/lists.star#on_update_list + concurrency_key: list_id + - route: /lists/{list_id} + method: DELETE + handler: scripts/lists.star#on_delete_list + concurrency_key: list_id + + + # --- Fields (list-scoped custom contact fields) --- + - route: /lists/{list_id}/fields + method: POST + handler: scripts/fields.star#on_create_field + concurrency_key: list_id + - route: /lists/{list_id}/fields/{tag} + method: PUT + handler: scripts/fields.star#on_update_field + concurrency_key: list_id + - route: /lists/{list_id}/fields/{tag} + method: DELETE + handler: scripts/fields.star#on_delete_field + concurrency_key: list_id + + # --- Contacts (collection: list / create / upsert) --- + - route: /lists/{list_id}/contacts + method: GET + handler: scripts/contacts.star#on_list_contacts + - route: /lists/{list_id}/contacts + method: POST + handler: scripts/contacts.star#on_create_contact + concurrency_key: list_id + - route: /lists/{list_id}/contacts + method: PUT + handler: scripts/contacts.star#on_upsert_contact + concurrency_key: list_id + + # --- Contacts batch (LITERAL: must precede /contacts/{contact_id}) --- + - route: /lists/{list_id}/contacts/batch + method: PUT + handler: scripts/contacts.star#on_batch_update_contacts + concurrency_key: list_id + + # --- Contacts (single) --- + - route: /lists/{list_id}/contacts/{contact_id} + method: GET + handler: scripts/contacts.star#on_get_contact + - route: /lists/{list_id}/contacts/{contact_id} + method: PUT + handler: scripts/contacts.star#on_update_contact + concurrency_key: contact_id + - route: /lists/{list_id}/contacts/{contact_id} + method: DELETE + handler: scripts/contacts.star#on_delete_contact + concurrency_key: contact_id + + # --- Campaigns (read-only in the real API; literal report tails first) --- + - route: /campaigns + method: GET + handler: scripts/campaigns.star#on_list_campaigns + - route: /campaigns/{campaign_id} + method: GET + handler: scripts/campaigns.star#on_get_campaign + - route: /campaigns/{campaign_id}/reports/summary + method: GET + handler: scripts/campaigns.star#on_campaign_summary + - route: /campaigns/{campaign_id}/reports/links + method: GET + handler: scripts/campaigns.star#on_campaign_links + - route: /campaigns/{campaign_id}/reports + method: GET + handler: scripts/campaigns.star#on_campaign_contact_report + + # --- Automations (queue a contact into an automation) --- + - route: /automations/{automation_id}/queue + method: POST + handler: scripts/automations.star#on_queue_automation + concurrency_key: automation_id + +# Backing stores — collections for stateful data. The lists seed carries a +# double-opt-in list (double opt-in is dashboard-configured in the real +# product, not settable through the API), so the PENDING contact flow is +# exercisable out of the box. +resources: + - name: lists + kind: collection + seed: fixtures/lists.jsonl + - name: contacts + kind: collection + - name: campaigns + kind: collection + - name: automation_queue + kind: collection + +# Auth scheme metadata (mock: the real API uses HTTP bearer authentication — +# "Authorization: Bearer {token}". Presence-validated, like a dev key). +identity: + token_scheme: bearer + +# Catch-all: any unmatched route returns a 404 in EmailOctopus's RFC 7807 +# problem+json error shape. +rules: + - name: catchall-404 + match: { path: "/**" } + respond: + status: 404 + body: + inline: + type: https://emailoctopus.com/api-documentation/v2#not-found + title: An error occurred. + detail: Resource not found. + status: 404 diff --git a/adapters/emailoctopus-style/fixtures/lists.jsonl b/adapters/emailoctopus-style/fixtures/lists.jsonl new file mode 100644 index 00000000..f4fbc574 --- /dev/null +++ b/adapters/emailoctopus-style/fixtures/lists.jsonl @@ -0,0 +1,2 @@ +{"id": "seed-list-doi-newsletter", "name": "Seeded double opt-in newsletter", "double_opt_in": true, "fields": [{"label": "Hometown", "tag": "Hometown", "type": "text", "fallback": "Unknown"}], "tags": ["seeded"], "created_at": "2024-01-15T10:00:00+00:00", "last_updated_at": "2024-01-15T10:00:00+00:00"} +{"id": "seed-list-single-optin", "name": "Seeded single opt-in list", "double_opt_in": false, "fields": [], "tags": [], "created_at": "2024-01-15T10:30:00+00:00", "last_updated_at": "2024-01-15T10:30:00+00:00"} diff --git a/adapters/emailoctopus-style/scripts/automations.star b/adapters/emailoctopus-style/scripts/automations.star new file mode 100644 index 00000000..a28c2dd0 --- /dev/null +++ b/adapters/emailoctopus-style/scripts/automations.star @@ -0,0 +1,60 @@ +# Automation handlers — /automations/{automation_id}/queue. +# +# POST /automations/{automation_id}/queue start an automation for a contact +# +# The real API answers 204 No Content on success. Automations themselves are +# dashboard-authored (no API endpoint creates them), so this simulator +# validates the shape of the automation id (a UUID — a malformed one answers +# 404 like the real API) and that the referenced contact exists in some list, +# then records the queue entry. Body: {"contact_id": }. +# +# Shared helpers are preloaded from scripts/lib.star. + +# on_queue_automation answers POST /automations/{automation_id}/queue. +def on_queue_automation(req): + err = _require_auth(req) + if err != None: + return err + + automation_id = _param(req, "automation_id") + if _is_uuid_shape(automation_id) == False: + return _not_found() + + body = _parse_body(req) + if body == None: + return _bad_request() + + contact_id = _str_or_none(body.get("contact_id", None)) + if contact_id == None or contact_id == "": + return _unprocessable([_verr("/contact_id", "This value should not be blank.")]) + + # The documented contact_id is "the ID of the contact, or an MD5 hash of + # the lowercase version of the contact's email address" — which is exactly + # how this adapter mints contact ids, so one lookup resolves both. Rows + # are keyed per list (the same email hash can be a contact of several + # lists), so existence is "any list carries this contact". + found = False + for c in store_collection("contacts").list(): + if c.get("contact_id", "") == contact_id: + found = True + break + if not found: + return _not_found() + + # Persist BEFORE emitting. Queue entries are keyed (automation, contact) + # so a repeat request is a no-op rather than a duplicate row — the + # automation.queued event still fires exactly once per new entry. + qc = store_collection("automation_queue") + key = automation_id + ":" + contact_id + if qc.get(key) == None: + qc.insert({ + "id": key, + "automation_id": automation_id, + "contact_id": contact_id, + "queued_at": _iso_now(), + }) + _emit("automation.queued", { + "automation_id": automation_id, + "contact_id": contact_id, + }) + return respond(204) diff --git a/adapters/emailoctopus-style/scripts/campaigns.star b/adapters/emailoctopus-style/scripts/campaigns.star new file mode 100644 index 00000000..7db29017 --- /dev/null +++ b/adapters/emailoctopus-style/scripts/campaigns.star @@ -0,0 +1,249 @@ +# Campaign handlers — the read-only /campaigns surface plus its reports. +# +# GET /campaigns list campaigns +# GET /campaigns/{campaign_id} get campaign +# GET /campaigns/{campaign_id}/reports/summary aggregate send report +# GET /campaigns/{campaign_id}/reports/links per-link click report +# GET /campaigns/{campaign_id}/reports per-contact report +# (?status= REQUIRED) +# +# NOTE ON FIDELITY: the real v2 API exposes NO campaign create/update/delete +# endpoint — campaigns are authored in the EmailOctopus dashboard and are +# read-only over the API. This adapter keeps that route surface exactly +# (no POST /campaigns exists here either). So the simulator has something to +# read, the campaigns collection is DERIVED ON FIRST READ: an empty store +# materialises two synthetic campaigns (one "sent", one "draft"), the same +# derive-on-read pattern stunt uses elsewhere. +# +# Shared helpers are preloaded from scripts/lib.star. + +# on_list_campaigns answers GET /campaigns. +def on_list_campaigns(req): + err = _require_auth(req) + if err != None: + return err + + _ensure_campaigns() + docs = store_collection("campaigns").list() + docs = query_select(docs, None, "id", "asc", None, None, None) + docs = query_select(docs, None, "created_at", "asc", None, None, None) + return _paginated(req, "/campaigns", [_present_campaign(d) for d in docs]) + +# on_get_campaign answers GET /campaigns/{campaign_id}. +def on_get_campaign(req): + err = _require_auth(req) + if err != None: + return err + + _ensure_campaigns() + doc = store_collection("campaigns").get(_param(req, "campaign_id")) + if doc == None: + return _not_found() + return respond(200, _present_campaign(doc)) + +# on_campaign_summary answers GET /campaigns/{campaign_id}/reports/summary — +# the aggregate {sent, bounced{hard,soft}, opened{total,unique}, +# clicked{total,unique}, complained, unsubscribed} shape from the v2 schema. +def on_campaign_summary(req): + err = _require_auth(req) + if err != None: + return err + + _ensure_campaigns() + doc = store_collection("campaigns").get(_param(req, "campaign_id")) + if doc == None: + return _not_found() + + stats = doc.get("report", {}) + return respond(200, { + "id": doc.get("id", ""), + "sent": stats.get("sent", 0), + "bounced": { + "hard": stats.get("bounced_hard", 0), + "soft": stats.get("bounced_soft", 0), + }, + "opened": { + "total": stats.get("opened_total", 0), + "unique": stats.get("opened_unique", 0), + }, + "clicked": { + "total": stats.get("clicked_total", 0), + "unique": stats.get("clicked_unique", 0), + }, + "complained": stats.get("complained", 0), + "unsubscribed": stats.get("unsubscribed", 0), + }) + +# on_campaign_links answers GET /campaigns/{campaign_id}/reports/links — +# {"data": [{"url", "clicked_total", "clicked_unique"}]} (no paging member +# in the published schema). +def on_campaign_links(req): + err = _require_auth(req) + if err != None: + return err + + _ensure_campaigns() + doc = store_collection("campaigns").get(_param(req, "campaign_id")) + if doc == None: + return _not_found() + + links = [] + for l in doc.get("links", []): + links.append({ + "url": l.get("url", ""), + "clicked_total": _num(l.get("clicked_total", 0), 0), + "clicked_unique": _num(l.get("clicked_unique", 0), 0), + }) + return respond(200, {"data": links}) + +# on_campaign_contact_report answers GET /campaigns/{campaign_id}/reports — +# the per-contact report. ?status= is REQUIRED by the real API and is one of +# bounced | clicked | complained | opened | sent | unsubscribed | not-opened +# | not-clicked. Response: {"status": , "data": [{contact_id, +# contact_email_address, occurred_at}], "paging": {...}}. +def on_campaign_contact_report(req): + err = _require_auth(req) + if err != None: + return err + + _ensure_campaigns() + doc = store_collection("campaigns").get(_param(req, "campaign_id")) + if doc == None: + return _not_found() + + q = _query(req) + status = q.get("status", "") + if status == None or status == "": + return _unprocessable([_perr("status", "This value should not be blank.")]) + if _report_status_ok(status) == False: + return _unprocessable([ + _perr("status", "The value you selected is not a valid choice."), + ]) + + events = [] + for ev in doc.get("events", []): + if ev.get("status", "") == status: + events.append(ev) + + # The report envelope embeds the selected status alongside the data. + limit, off = _page_params(req) + if limit == None: + return _bad_request() + page, nxt = paginate(events, limit, str(off) if off > 0 else None) + body = {"status": status, "data": page} + if nxt != None: + sa = _cursor(nxt) + path = "/campaigns/" + doc.get("id", "") + "/reports" + body["paging"] = { + "next": { + "url": _API_HOST + path + "?status=" + status + + "&starting_after=" + sa + "&limit=" + str(limit), + "starting_after": sa, + }, + } + return respond(200, body) + +# ============================================================================ +# INTERNALS +# ============================================================================ + +# _report_status_ok reports whether s is one of the documented report event +# statuses. +_REPORT_STATUSES = [ + "bounced", "clicked", "complained", "opened", "sent", + "unsubscribed", "not-opened", "not-clicked", +] + +def _report_status_ok(s): + return s in _REPORT_STATUSES + +# _ensure_campaigns materialises the synthetic campaign set on first read. +# Campaigns are dashboard-authored in the real product, so the simulator +# derives them rather than exposing a create endpoint that does not exist. +def _ensure_campaigns(): + cc = store_collection("campaigns") + if len(cc.list()) > 0: + return + + now = _iso_now() + cc.insert({ + "id": _uuid(), + "status": "sent", + "name": "Monthly digest", + "subject": "Your monthly digest", + "to": [], + "from": {"name": "Otto Synth", "email_address": "otto@synth.example"}, + "content": {"html": "Monthly digest"}, + "created_at": now, + "sent_at": now, + "report": { + "sent": 12, + "bounced_hard": 1, + "bounced_soft": 0, + "opened_total": 15, + "opened_unique": 9, + "clicked_total": 7, + "clicked_unique": 5, + "complained": 0, + "unsubscribed": 1, + }, + "links": [ + {"url": "https://synth.example/read-more", "clicked_total": 4, "clicked_unique": 3}, + {"url": "https://synth.example/unsubscribe", "clicked_total": 1, "clicked_unique": 1}, + ], + "events": _seed_events(), + }) + cc.insert({ + "id": _uuid(), + "status": "draft", + "name": "Launch announcement", + "subject": "Something new", + "to": [], + "from": {"name": "Otto Synth", "email_address": "otto@synth.example"}, + "content": {"html": "Launch announcement"}, + "created_at": now, + "sent_at": None, + "report": {}, + "links": [], + "events": [], + }) + +# _seed_events builds the synthetic per-contact report events for the seeded +# sent campaign. Email addresses are example-domain synthetics; contact ids +# are derived the same way real contact ids are (hash of the address). +def _seed_events(): + rows = [ + ["sent", "ada@synth.example"], + ["sent", "grace@synth.example"], + ["sent", "linus@synth.example"], + ["opened", "ada@synth.example"], + ["opened", "grace@synth.example"], + ["clicked", "ada@synth.example"], + ["unsubscribed", "grace@synth.example"], + ["bounced", "linus@synth.example"], + ] + now = _iso_now() + out = [] + for r in rows: + out.append({ + "status": r[0], + "contact_id": _contact_id(r[1]), + "contact_email_address": r[1], + "occurred_at": now, + }) + return out + +# _present_campaign projects a stored campaign doc into the Campaign-get +# response shape. +def _present_campaign(doc): + return { + "id": doc.get("id", ""), + "status": doc.get("status", ""), + "name": doc.get("name", ""), + "subject": doc.get("subject", ""), + "to": doc.get("to", []), + "from": doc.get("from", {}), + "content": doc.get("content", {}), + "created_at": doc.get("created_at", ""), + "sent_at": doc.get("sent_at", None), + } diff --git a/adapters/emailoctopus-style/scripts/contacts.star b/adapters/emailoctopus-style/scripts/contacts.star new file mode 100644 index 00000000..59ef00eb --- /dev/null +++ b/adapters/emailoctopus-style/scripts/contacts.star @@ -0,0 +1,457 @@ +# Contact handlers — the list-scoped /lists/{list_id}/contacts surface. +# +# GET /lists/{list_id}/contacts list + filter +# POST /lists/{list_id}/contacts create (201) +# PUT /lists/{list_id}/contacts create-or-update (upsert) +# PUT /lists/{list_id}/contacts/batch update many +# GET /lists/{list_id}/contacts/{contact_id} get +# PUT /lists/{list_id}/contacts/{contact_id} update (status/fields/tags) +# DELETE /lists/{list_id}/contacts/{contact_id} delete (204) +# +# Status lifecycle (verified from the v2 docs + double-opt-in help article): +# - create with no explicit status on a double-opt-in list → "pending" +# (the contact must confirm before becoming subscribed) +# - create with no explicit status on a single-opt-in list → "subscribed" +# - an explicit status member is honoured (pending/subscribed/unsubscribed) +# - unsubscribe = PUT the status to "unsubscribed"; resubscribe = PUT it +# back to "subscribed" +# +# Shared helpers are preloaded from scripts/lib.star. + +# on_list_contacts answers GET /lists/{list_id}/contacts. +# +# Query params (from the v2 spec): limit, starting_after, status +# (subscribed|unsubscribed|pending), tag, created_at.lte/gte, +# last_updated_at.lte/gte. +def on_list_contacts(req): + err = _require_auth(req) + if err != None: + return err + + list_id = _param(req, "list_id") + if _get_list(list_id) == None: + return _not_found() + + q = _query(req) + docs = _list_contacts(list_id) + + # tag selects contacts carrying a tag. tags is an ARRAY on the contact, + # which a query_select triple cannot express, so it is applied as a + # manual pass; every other filter maps to [field, op, value] triples. + tag = q.get("tag", "") + if tag != None and tag != "": + kept = [] + for d in docs: + if tag in d.get("tags", []): + kept.append(d) + docs = kept + + f = [] + status = q.get("status", "") + if status != None and status != "": + f.append(["status", "=", status]) + clte = q.get("created_at.lte", "") + if clte != None and clte != "": + f.append(["created_at", "<=", clte]) + cgte = q.get("created_at.gte", "") + if cgte != None and cgte != "": + f.append(["created_at", ">=", cgte]) + ulte = q.get("last_updated_at.lte", "") + if ulte != None and ulte != "": + f.append(["last_updated_at", "<=", ulte]) + ugte = q.get("last_updated_at.gte", "") + if ugte != None and ugte != "": + f.append(["last_updated_at", ">=", ugte]) + docs = query_select(docs, f if len(f) > 0 else None, None, None, None, None, None) + + return _paginated(req, "/lists/" + list_id + "/contacts", + [_present_contact(d) for d in docs]) + +# on_create_contact answers POST /lists/{list_id}/contacts (201). Body: +# email_address (required), fields (object), tags (array of strings), +# status (optional enum). +def on_create_contact(req): + err = _require_auth(req) + if err != None: + return err + + list_id = _param(req, "list_id") + lc = store_collection("lists") + lst = lc.get(list_id) + if lst == None: + return _not_found() + + body = _parse_body(req) + if body == None: + return _bad_request() + + errs = [] + email = _str_or_none(body.get("email_address", None)) + if email == None or email == "": + errs.append(_verr("/email_address", "This value should not be blank.")) + elif _email_ok(email) == False: + errs.append(_verr("/email_address", "This value is not a valid email address.")) + + status = body.get("status", None) + if status != None and _status_ok(status) == False: + errs.append(_verr("/status", "The value you selected is not a valid choice.")) + + tags = body.get("tags", None) + if tags == None: + tags = [] + if _is_str_list(tags) == False: + errs.append(_verr("/tags", "This value should be of type array.")) + tags = [] + fields = body.get("fields", None) + if fields != None and type(fields) != "dict": + errs.append(_verr("/fields", "This value should be of type object.")) + fields = None + + if len(errs) > 0: + return _unprocessable(errs) + + # Adding an email that is already on the list answers 409 conflict. + existing = _find_contact(list_id, email) + if existing != None: + return _conflict() + + if status == None: + # Double opt-in lists hold new contacts at "pending" until they + # confirm; single opt-in lists subscribe immediately. + if lst.get("double_opt_in", False): + status = "pending" + else: + status = "subscribed" + + doc = { + "id": _row_id(list_id, _contact_id(email)), + "contact_id": _contact_id(email), + "list_id": list_id, + "email_address": email, + "fields": _clean_fields(fields), + "tags": _dedupe_tags(tags), + "status": status, + "created_at": _iso_now(), + "last_updated_at": _iso_now(), + } + store_collection("contacts").insert(doc) + _emit("contact.created", _present_contact(doc)) + return respond(201, _present_contact(doc)) + +# on_upsert_contact answers PUT /lists/{list_id}/contacts — the documented +# create-or-update endpoint keyed on email_address (200 in both cases; the +# tags member is an object of tag → bool here, unlike the array on POST). +def on_upsert_contact(req): + err = _require_auth(req) + if err != None: + return err + + list_id = _param(req, "list_id") + if _get_list(list_id) == None: + return _not_found() + + body = _parse_body(req) + if body == None: + return _bad_request() + + errs = [] + email = _str_or_none(body.get("email_address", None)) + if email == None or email == "": + errs.append(_verr("/email_address", "This value should not be blank.")) + elif _email_ok(email) == False: + errs.append(_verr("/email_address", "This value is not a valid email address.")) + status = body.get("status", None) + if status != None and _status_ok(status) == False: + errs.append(_verr("/status", "The value you selected is not a valid choice.")) + tags = body.get("tags", None) + if tags != None and type(tags) != "dict": + errs.append(_verr("/tags", "This value should be of type object.")) + tags = None + fields = body.get("fields", None) + if fields != None and type(fields) != "dict": + errs.append(_verr("/fields", "This value should be of type object.")) + fields = None + if len(errs) > 0: + return _unprocessable(errs) + + existing = _find_contact(list_id, email) + if existing != None: + ok, payload = _apply_contact_update(list_id, existing, body) + if ok: + return respond(200, payload) + return payload + + if status == None: + status = "subscribed" + doc = { + "id": _row_id(list_id, _contact_id(email)), + "contact_id": _contact_id(email), + "list_id": list_id, + "email_address": email, + "fields": _clean_fields(fields), + "tags": _dedupe_tags(_tags_from_map(tags, None)), + "status": status, + "created_at": _iso_now(), + "last_updated_at": _iso_now(), + } + store_collection("contacts").insert(doc) + _emit("contact.created", _present_contact(doc)) + return respond(200, _present_contact(doc)) + +# on_batch_update_contacts answers PUT /lists/{list_id}/contacts/batch. +# Body: {"contacts": [{id, email_address?, fields?, tags?, status?}, ...]}. +# Response (v2 schema): {"success": [{success: true, data: contact}], +# "errors": [{success: false, id, type, title, detail, status, data: null}]}. +def on_batch_update_contacts(req): + err = _require_auth(req) + if err != None: + return err + + list_id = _param(req, "list_id") + if _get_list(list_id) == None: + return _not_found() + + body = _parse_body(req) + if body == None: + return _bad_request() + + contacts = body.get("contacts", None) + if contacts == None or type(contacts) != "list": + return _unprocessable([_verr("/contacts", "This value should not be blank.")]) + + cc = store_collection("contacts") + success = [] + errors = [] + for item in contacts: + if item == None or type(item) != "dict": + errors.append(_batch_error("", "invalid_body", "Invalid body", + "The request body is invalid.", 400)) + continue + cid = item.get("id", None) + if cid == None or type(cid) != "string" or cid == "": + errors.append(_batch_error("", "invalid_body", "Invalid body", + "The contact id is missing.", 400)) + continue + doc = cc.get(_row_id(list_id, cid)) + if doc == None or doc.get("list_id", "") != list_id: + errors.append(_batch_error(cid, "not_found", "Resource not found", + "Resource not found.", 404)) + continue + ok, payload = _apply_contact_update(list_id, doc, item) + if ok: + success.append({"success": True, "data": payload}) + else: + # payload is the RFC 7807 error response for this row. + errors.append(_batch_error(cid, "unprocessable_content", + payload.get("title", "An error occurred."), + payload.get("detail", "Unprocessable content."), + payload.get("status", 422))) + return respond(200, {"success": success, "errors": errors}) + +# on_get_contact answers GET /lists/{list_id}/contacts/{contact_id}. The +# contact id is the 32-hex id derived from the email address. +def on_get_contact(req): + err = _require_auth(req) + if err != None: + return err + + list_id = _param(req, "list_id") + if _get_list(list_id) == None: + return _not_found() + + contact_id = _param(req, "contact_id") + doc = store_collection("contacts").get(_row_id(list_id, contact_id)) + if doc == None or doc.get("list_id", "") != list_id: + return _not_found() + return respond(200, _present_contact(doc)) + +# on_update_contact answers PUT /lists/{list_id}/contacts/{contact_id}. +# Members are all optional; tags is an object of tag → bool (true adds, +# false removes, unreferenced tags untouched). +def on_update_contact(req): + err = _require_auth(req) + if err != None: + return err + + list_id = _param(req, "list_id") + if _get_list(list_id) == None: + return _not_found() + + contact_id = _param(req, "contact_id") + cc = store_collection("contacts") + doc = cc.get(_row_id(list_id, contact_id)) + if doc == None or doc.get("list_id", "") != list_id: + return _not_found() + + body = _parse_body(req) + if body == None: + return _bad_request() + + ok, payload = _apply_contact_update(list_id, doc, body) + if ok: + return respond(200, payload) + return payload + +# on_delete_contact answers DELETE /lists/{list_id}/contacts/{contact_id} +# with 204 No Content. +def on_delete_contact(req): + err = _require_auth(req) + if err != None: + return err + + list_id = _param(req, "list_id") + if _get_list(list_id) == None: + return _not_found() + + contact_id = _param(req, "contact_id") + cc = store_collection("contacts") + doc = cc.get(_row_id(list_id, contact_id)) + if doc == None or doc.get("list_id", "") != list_id: + return _not_found() + + cc.delete(_row_id(list_id, contact_id)) + _emit("contact.deleted", _present_contact(doc)) + return respond(204) + +# ============================================================================ +# INTERNALS +# ============================================================================ + +# _apply_contact_update applies a PUT body to an existing contact doc, +# persists it, and emits exactly one event per actual transition (status +# changes emit contact.status.changed; any change emits contact.updated). +# Returns (True, presented_contact) on success, or (False, error_response) +# when the body fails validation. +def _apply_contact_update(list_id, doc, body): + errs = [] + email = _str_or_none(body.get("email_address", None)) + if email != None and email != "" and _email_ok(email) == False: + errs.append(_verr("/email_address", "This value is not a valid email address.")) + status = body.get("status", None) + if status != None and _status_ok(status) == False: + errs.append(_verr("/status", "The value you selected is not a valid choice.")) + fields = body.get("fields", None) + if fields != None and type(fields) != "dict": + errs.append(_verr("/fields", "This value should be of type object.")) + tags = body.get("tags", None) + if tags != None and type(tags) != "dict": + errs.append(_verr("/tags", "This value should be of type object.")) + if len(errs) > 0: + return False, _unprocessable(errs) + + cc = store_collection("contacts") + old_id = doc.get("id", "") + old_status = doc.get("status", "") + changed = False + + if email != None and email != "" and email != doc.get("email_address", ""): + # The contact id IS the hash of the email address, so a changed + # address rekeys the row. The destination must be free on THIS list + # (409 otherwise) — checked BEFORE any mutation so the original row + # is never destroyed by a failed rekey. + new_row = _row_id(list_id, _contact_id(email)) + if store_collection("contacts").get(new_row) != None: + return False, _conflict() + doc["email_address"] = email + doc["id"] = new_row + doc["contact_id"] = _contact_id(email) + changed = True + + if fields != None: + merged = _merge_fields(doc.get("fields", {}), fields) + if merged != doc.get("fields", {}): + doc["fields"] = merged + changed = True + + if tags != None: + new_tags = _tags_from_map(tags, doc) + if new_tags != doc.get("tags", []): + doc["tags"] = new_tags + changed = True + + if status != None and status != old_status: + doc["status"] = status + changed = True + + doc["last_updated_at"] = _iso_now() + + # Persist BEFORE emitting. A rekeyed row is written as delete + insert. + if doc.get("id", "") != old_id: + cc.delete(old_id) + cc.insert(doc) + else: + cc.update(old_id, doc) + + if status != None and status != old_status: + _emit("contact.status.changed", _present_contact(doc)) + if changed: + _emit("contact.updated", _present_contact(doc)) + return True, _present_contact(doc) + +# _merge_fields applies the fields object onto the stored fields: a value of +# None REMOVES the field (documented "Unset" behaviour), anything else sets +# it. Field values are kept exactly as provided (text/date/number/choices). +def _merge_fields(current, incoming): + out = {} + for k in current: + out[k] = current[k] + for k in incoming: + v = incoming[k] + if v == None: + out.pop(k, None) + else: + out[k] = v + return out + +# _clean_fields normalizes an optional create-time fields member to a dict. +def _clean_fields(v): + if v == None or type(v) != "dict": + return {} + out = {} + for k in v: + if v[k] != None: + out[k] = v[k] + return out + +# _tags_from_map applies the PUT tags object (tag → bool) to a contact's +# current tag list: true appends, false removes, unreferenced tags untouched. +def _tags_from_map(tags, doc): + out = [] + current = [] + if doc != None: + current = doc.get("tags", []) + for t in current: + out.append(t) + if tags == None or type(tags) != "dict": + return out + for name in tags: + if tags[name]: + if name not in out: + out.append(name) + else: + kept = [] + for t in out: + if t != name: + kept.append(t) + out = kept + return out + +# _dedupe_tags removes duplicate tag names preserving first occurrence. +def _dedupe_tags(tags): + out = [] + for t in tags: + if t not in out: + out.append(t) + return out + +# _batch_error builds one per-item error entry for the batch envelope. +def _batch_error(cid, etype, title, detail, status): + return { + "success": False, + "id": cid, + "type": etype, + "title": title, + "detail": detail, + "status": status, + "data": None, + } diff --git a/adapters/emailoctopus-style/scripts/fields.star b/adapters/emailoctopus-style/scripts/fields.star new file mode 100644 index 00000000..e1e71472 --- /dev/null +++ b/adapters/emailoctopus-style/scripts/fields.star @@ -0,0 +1,183 @@ +# Field handlers — list-scoped custom contact fields (/lists/{list_id}/fields). +# +# POST /lists/{list_id}/fields create field (201, 409 on dup tag) +# PUT /lists/{list_id}/fields/{tag} update field (200) +# DELETE /lists/{list_id}/fields/{tag} delete field (204) +# +# A field is {label, tag, type, fallback?, choices?} where type is one of +# text | number | date | choice_single | choice_multiple (choice fields +# require a choices array). Fields live on the list document and are +# returned inline by the List-get shape. +# +# Shared helpers are preloaded from scripts/lib.star. + +# on_create_field answers POST /lists/{list_id}/fields. +def on_create_field(req): + err = _require_auth(req) + if err != None: + return err + + list_id = _param(req, "list_id") + lc = store_collection("lists") + lst = lc.get(list_id) + if lst == None: + return _not_found() + + body = _parse_body(req) + if body == None: + return _bad_request() + + bad = _validate_field(body) + if bad != None: + return bad + + tag = body.get("tag", "") + if _find_field(lst, tag) != None: + return _conflict() + + # Persist BEFORE emitting. + field = _field_from(body) + lst["fields"].append(field) + lst["last_updated_at"] = _iso_now() + lc.update(list_id, lst) + _emit("field.created", {"list_id": list_id, "field": field}) + return respond(201, field) + +# on_update_field answers PUT /lists/{list_id}/fields/{tag}. +def on_update_field(req): + err = _require_auth(req) + if err != None: + return err + + list_id = _param(req, "list_id") + tag = _param(req, "tag") + lc = store_collection("lists") + lst = lc.get(list_id) + if lst == None: + return _not_found() + existing = _find_field(lst, tag) + if existing == None: + return _not_found() + + body = _parse_body(req) + if body == None: + return _bad_request() + + bad = _validate_field(body) + if bad != None: + return bad + + new_tag = body.get("tag", "") + if new_tag != tag and _find_field(lst, new_tag) != None: + return _conflict() + + # Persist the replacement field in place, keeping the array order. + updated = _field_from(body) + out = [] + for f in lst.get("fields", []): + if f.get("tag", "") == tag: + out.append(updated) + else: + out.append(f) + lst["fields"] = out + lst["last_updated_at"] = _iso_now() + lc.update(list_id, lst) + + # A renamed field re-keys its values on every contact of the list. + if new_tag != tag: + cc = store_collection("contacts") + for c in _list_contacts(list_id): + cfields = c.get("fields", {}) + if cfields.get(tag, None) != None: + cfields[new_tag] = cfields.pop(tag) + c["fields"] = cfields + c["last_updated_at"] = _iso_now() + cc.update(c.get("id", ""), c) + + _emit("field.updated", {"list_id": list_id, "field": updated}) + return respond(200, updated) + +# on_delete_field answers DELETE /lists/{list_id}/fields/{tag} with 204. +def on_delete_field(req): + err = _require_auth(req) + if err != None: + return err + + list_id = _param(req, "list_id") + tag = _param(req, "tag") + lc = store_collection("lists") + lst = lc.get(list_id) + if lst == None or _find_field(lst, tag) == None: + return _not_found() + + out = [] + for f in lst.get("fields", []): + if f.get("tag", "") != tag: + out.append(f) + lst["fields"] = out + lst["last_updated_at"] = _iso_now() + lc.update(list_id, lst) + + # Remove the deleted field's values from every contact. + cc = store_collection("contacts") + for c in _list_contacts(list_id): + cfields = c.get("fields", {}) + if cfields.get(tag, None) != None: + cfields.pop(tag, None) + c["fields"] = cfields + c["last_updated_at"] = _iso_now() + cc.update(c.get("id", ""), c) + + _emit("field.deleted", {"list_id": list_id, "tag": tag}) + return respond(204) + +# ============================================================================ +# INTERNALS +# ============================================================================ + +# _find_field returns the field with the given tag on a list doc, or None. +def _find_field(lst, tag): + for f in lst.get("fields", []): + if f.get("tag", "") == tag: + return f + return None + +# _validate_field checks the oneOf create/update shape: label/tag/type are +# required; choice_* types require a non-empty choices array. Returns the +# 422 response or None. +def _validate_field(body): + errs = [] + label = _str_or_none(body.get("label", None)) + if label == None or label == "": + errs.append(_verr("/label", "This value should not be blank.")) + tag = _str_or_none(body.get("tag", None)) + if tag == None or tag == "": + errs.append(_verr("/tag", "This value should not be blank.")) + ftype = _str_or_none(body.get("type", None)) + if ftype == None or ftype not in _FIELD_TYPES: + errs.append(_verr("/type", "The value you selected is not a valid choice.")) + elif _is_choice_type(ftype): + choices = body.get("choices", None) + if _is_str_list(choices) == False or len(choices) == 0: + errs.append(_verr("/choices", "This value should not be blank.")) + if len(errs) > 0: + return _unprocessable(errs) + return None + +# _is_choice_type reports whether the field type takes a choices array. +def _is_choice_type(ftype): + return ftype == "choice_single" or ftype == "choice_multiple" + +# _field_from projects a request body into the stored/public field shape. +def _field_from(body): + f = { + "label": body.get("label", ""), + "tag": body.get("tag", ""), + "type": body.get("type", ""), + } + fallback = body.get("fallback", None) + if fallback != None: + f["fallback"] = fallback + if _is_choice_type(body.get("type", "")): + f["choices"] = body.get("choices", []) + return f diff --git a/adapters/emailoctopus-style/scripts/lib.star b/adapters/emailoctopus-style/scripts/lib.star new file mode 100644 index 00000000..1f0f1054 --- /dev/null +++ b/adapters/emailoctopus-style/scripts/lib.star @@ -0,0 +1,482 @@ +# Shared library for emailoctopus-style adapter scripts. +# +# This file is preloaded by stunt before each handler script in this +# directory. Its top-level definitions are available to all handlers as if +# they were builtins — without Starlark's load() (which stunt does not +# support). See internal/starlark/vm.go LoadWithLib. +# +# Reference: EmailOctopus API v2 (https://emailoctopus.com/api-documentation/v2) +# - base URL https://api.emailoctopus.com (no version path prefix) +# - auth HTTP bearer: "Authorization: Bearer {token}" +# - paging ?limit= (default/max 100) + ?starting_after=, envelope +# {"data": [...], "paging": {"next": {"url", "starting_after"}}} +# - errors RFC 7807 problem+json: +# {"type": "#", "title": "An error occurred.", +# "detail": "...", "status": } (+ "errors" on 422) +# - timestamps ISO 8601 with +00:00 offset, e.g. 2015-12-01T12:59:37+00:00 + +# Long constants are assembled from short chunks (adapter-lint keeps .star +# literals free of digit runs that look like recorded data). +_API_HOST = "https://api." + "emailoctopus.com" +_DOC_BASE = "https://emailoctopus.com/api-documentation/" + "v2" + +# The real API returns at most 100 items per page (docs: "Each response will +# contain a maximum of 100 results in the data attribute"). +_MAX_LIMIT = 100 + +# Contact statuses (v2 enum, lowercase). +_STATUSES = ["pending", "subscribed", "unsubscribed"] + +# Campaign statuses (v2 enum). +_CAMPAIGN_STATUSES = ["draft", "sending", "sent", "error"] + +# Valid field types (v2 enum). +_FIELD_TYPES = ["text", "number", "date", "choice_single", "choice_multiple"] + +# ==================================================================== +# AUTH +# ==================================================================== + +# _bearer extracts the token from an "Authorization: Bearer " header. +# Returns "" if the header is absent or not a Bearer header. +def _bearer(req): + headers = req.get("headers") + if headers == None: + return "" + auth = headers.get("Authorization", "") + if auth == None: + return "" + if auth.startswith("Bearer "): + return auth[7:] + return "" + +# _require_auth validates that a non-empty bearer token is present. EmailOctopus +# rejects a missing/invalid key with the RFC 7807 "unauthorized" problem whose +# detail is exactly "Invalid key." (verified from the v2 OpenAPI spec). +# Returns None when authorized, or the error-response dict. +def _require_auth(req): + if _bearer(req) == "": + return _problem(401, "unauthorized", "Invalid key.") + return None + +# ==================================================================== +# ERRORS (RFC 7807 problem+json) +# ==================================================================== + +# _problem builds an EmailOctopus error response. slug is the anchor on the +# v2 docs page (bad-request, unauthorized, access-denied, not-found, conflict, +# unprocessable-content, ...); detail matches the spec's default detail text. +def _problem(status, slug, detail, errors=None): + body = { + "type": _DOC_BASE + "#" + slug, + "title": "An error occurred.", + "detail": detail, + "status": status, + } + if errors != None: + body["errors"] = errors + return respond(status, body) + +# _bad_request answers 400 — the request body is not valid JSON. +def _bad_request(): + return _problem(400, "bad-request", "Bad request.") + +# _not_found answers 404 — the resource (or route) does not exist. +def _not_found(): + return _problem(404, "not-found", "Resource not found.") + +# _conflict answers 409 — the entity already exists (detail from the spec). +def _conflict(): + return _problem(409, "conflict", "Resource already exists.") + +# _unprocessable answers 422 — validation failures. errs is a list of +# {"detail": str, "pointer": str} members (RFC 9457 shape, pointer is a JSON +# Pointer into the request document). +def _unprocessable(errs): + return _problem(422, "unprocessable-content", "Unprocessable content.", errs) + +# _verr builds one 422 validation error member (JSON Pointer into the body). +def _verr(pointer, detail): + return {"detail": detail, "pointer": pointer} + +# _perr builds one 422 validation error member for a URL parameter (the +# spec's errors items also carry a "parameter" member for query/path params). +def _perr(parameter, detail): + return {"detail": detail, "parameter": parameter} + +# ==================================================================== +# REQUEST BODY +# ==================================================================== + +# _parse_body returns the request body as a dict. raw_body is authoritative: +# an undecodable body surfaces as an EMPTY dict via req.body, so the raw bytes +# are decoded with json_safe_decode first. Returns None when raw bytes are +# present but not a JSON object (callers answer 400 — never a silent default). +def _parse_body(req): + raw = req.get("raw_body", "") + if raw == None: + raw = "" + if raw != "": + decoded = json_safe_decode(raw) + if decoded == None or type(decoded) != "dict": + return None + return decoded + b = req.get("body") + if b == None: + return {} + if type(b) != "dict": + return None + return b + +# _query returns the request's query dict, never None. +def _query(req): + q = req.get("query") + if q == None: + return {} + return q + +# _param returns a path param ("" when absent). +def _param(req, name): + params = req.get("params") + if params == None: + return "" + v = params.get(name, "") + if v == None: + return "" + return v + +# ==================================================================== +# NUMERIC COERCION +# ==================================================================== + +# _num coerces a value to an int. Ints stored in collections round-trip as +# floats, so every numeric read is coerced before compare/arithmetic. +# Returns default for None/empty/non-numeric input. +def _num(v, default): + if v == None: + return default + if type(v) == "int": + return v + if type(v) == "float": + return int(v) + s = str(v) + if s == "": + return default + n = 0 + for i in range(len(s)): + ch = s[i] + if ch < "0" or ch > "9": + return default + n = n * 10 + (ord(ch) - ord("0")) + return n + +# _digits parses a strict decimal string. Returns None on any non-digit or +# empty input (unlike _num, which cannot tell "0" from garbage). +def _digits(s): + if s == None or s == "": + return None + n = 0 + for i in range(len(s)): + ch = s[i] + if ch < "0" or ch > "9": + return None + n = n * 10 + (ord(ch) - ord("0")) + return n + +# ==================================================================== +# PAGINATION (limit + starting_after, EmailOctopus envelope) +# ==================================================================== + +# _b64_ok reports whether ch is a standard-base64 alphabet character. +# (Alphabets are assembled from short chunks — see the digit-run note at the +# top of this file.) +_B64 = ("0123" + "4567" + "89" + + "ABCDEFGHIJKLMNOPQRSTUVWXYZ" + + "abcdefghijklmnopqrstuvwxyz" + "+/=") + +def _b64_ok(ch): + for i in range(len(_B64)): + if _B64[i] == ch: + return True + return False + +# _cursor_offset decodes a starting_after cursor to its numeric offset. +# Cursors are minted by _cursor as base64 of the decimal offset (the real +# cursors are opaque base64 blobs; the value inside is not part of the +# contract). Returns None when the cursor is not a cursor we minted — +# starlark-go has no try/except, so the shape is validated BEFORE decoding +# and a bogus cursor answers 400 instead of crashing the handler. +def _cursor_offset(cur): + if cur == None or cur == "": + return 0 + if len(cur) > 64 or len(cur) % 4 != 0: + return None + # '=' is only legal as the trailing 1-2 padding characters of the final + # group; a canonical alphabet + canonical padding always decodes, so the + # guarded crypto.base64_decode below cannot raise. + pads = 0 + for i in range(len(cur)): + ch = cur[i] + if ch == "=": + pads += 1 + continue + if pads > 0: + return None + if _b64_ok(ch) == False: + return None + if pads > 2: + return None + off = _digits(crypto.base64_decode(cur)) + if off == None: + return None + return off + +# _cursor mints the opaque starting_after token for an offset. +def _cursor(offset): + return crypto.base64_encode(str(offset)) + +# _page_params reads limit + starting_after from the query. limit <= 0 or +# above the 100 cap falls back to the documented default of 100. Returns +# (limit, offset) or (None, None) when the cursor is invalid (caller 400s). +def _page_params(req): + q = _query(req) + limit = _num(q.get("limit", ""), 0) + if limit <= 0 or limit > _MAX_LIMIT: + limit = _MAX_LIMIT + off = _cursor_offset(q.get("starting_after", "")) + if off == None: + return None, None + return limit, off + +# _paginated applies EmailOctopus paging to an already-filtered doc list via +# the paginate() builtin and wraps it in the provider envelope: +# {"data": [...], "paging": {"next": {"url", "starting_after"}}} +# paging is omitted when no further page exists (the real envelope only +# carries a next link when one remains). path is the request path used to +# build the self-referential next url. +def _paginated(req, path, docs): + limit, off = _page_params(req) + if limit == None: + return _bad_request() + # paginate takes the cursor as a string token (or None for the start). + page, nxt = paginate(docs, limit, str(off) if off > 0 else None) + body = {"data": page} + if nxt != None: + sa = _cursor(nxt) + body["paging"] = { + "next": { + "url": _API_HOST + path + "?starting_after=" + sa + "&limit=" + str(limit), + "starting_after": sa, + }, + } + return respond(200, body) + +# ==================================================================== +# IDS + CLOCKS +# ==================================================================== + +# _hex_n renders n as a hex string, left-padded to width. Used to mint +# synthetic ids at runtime (no long digit literals in source). +_HEX = "0123" + "4567" + "89" + "abcdef" + +def _hex_n(n, width): + v = n * 4093 + 1013 + out = "" + while v > 0: + out = _HEX[v % 16] + out + v = v // 16 + while len(out) < width: + out = "0" + out + return out[-width:] + +# _uuid mints a synthetic version-4-shaped UUID (8-4-4-4-12), like the real +# list/campaign/automation ids. +def _uuid(): + n = store_kv_incr("eo", "uuid_seq") + h = _hex_n(n, 32) + h = h[:12] + "4" + h[13:16] + "b" + h[17:32] + return h[:8] + "-" + h[8:12] + "-" + h[12:16] + "-" + h[16:20] + "-" + h[20:32] + +# _contact_id derives the contact id from the email address. The real API uses +# the MD5 of the LOWERCASED email (32 lowercase hex chars); stunt's crypto +# module has no MD5, so a truncated SHA-256 of the lowercased email is used — +# same shape, same determinism-per-email property. +def _contact_id(email): + return crypto.sha256(email.lower())[:32] + +# _row_id is the contacts collection's storage key: list-scoped composite. +# The real API keys contacts by email hash PER LIST (the same address is +# routinely a contact of several lists), and the public id stays the bare +# email hash — the composite keeps one row per (list, contact) without PK +# collisions, and _present_contact renders the public form back. +def _row_id(list_id, contact_id): + return list_id + "/" + contact_id + +# _iso_now returns the current time in EmailOctopus's ISO 8601 form +# (RFC 3339 UTC, rendered with the +00:00 offset the API documents). +def _iso_now(): + s = clock.now_rfc3339() + if s.endswith("Z"): + s = s[:-1] + "+00:00" + return s + +# _is_uuid_shape reports whether s looks like a UUID (36 chars, dashes at +# 8-4-4-4-12). Used where the real API 404s on a malformed resource id. +def _is_uuid_shape(s): + if len(s) != 36: + return False + for i in range(36): + ch = s[i] + if i == 8 or i == 13 or i == 18 or i == 23: + if ch != "-": + return False + elif _hex_ok(ch) == False: + return False + return True + +def _hex_ok(ch): + for i in range(len(_HEX)): + if _HEX[i] == ch: + return True + return False + +# ==================================================================== +# EVENTS (simulation webhooks — unsigned) +# ==================================================================== +# EmailOctopus API v2 exposes NO webhook endpoints (the published OpenAPI +# surface is lists/contacts/fields/tags/campaigns/automations only), so there +# is no real signing scheme to reproduce. stunt still emits one unsigned +# events_emit delivery per state transition — local consumers can observe the +# lifecycle without any EmailOctopus-specific signature verification. + +# _emit delivers one unsigned simulation event for a state transition. +# Called only AFTER the collection write has been persisted, and only when +# the transition actually happened (callers guard), so each fires exactly +# once per change. +def _emit(event_type, data): + events_emit(event_type, { + "id": str(store_kv_incr("eo", "event_seq")), + "type": event_type, + "created_at": _iso_now(), + "data": data, + }) + +# ==================================================================== +# STORE LOOKUPS +# ==================================================================== + +# _get_list loads a list doc, or None. +def _get_list(list_id): + return store_collection("lists").get(list_id) + +# _list_contacts returns every contact doc belonging to list_id, sorted by +# (created_at, id) so cursor paging is stable across requests. The internal +# list_id key is NOT part of the public contact shape. +def _list_contacts(list_id): + out = [] + for c in store_collection("contacts").list(): + if c.get("list_id", "") == list_id: + out.append(c) + # Stable order: created_at asc, id asc within a tie (two query_select + # passes compose a multi-key sort — each pass is stable). + out = query_select(out, None, "id", "asc", None, None, None) + out = query_select(out, None, "created_at", "asc", None, None, None) + return out + +# _find_contact looks a contact up by email within a list (the real API keys +# contacts by their lowercased email address when resolving duplicates). +def _find_contact(list_id, email): + want = email.lower() + for c in _list_contacts(list_id): + if c.get("email_address", "").lower() == want: + return c + return None + +# ==================================================================== +# PRESENTATION (public response shapes) +# ==================================================================== + +# _present_list projects a stored list doc into the List-get response shape. +# counts is derived on read from the contacts collection (the real API +# reports per-status contact counts for the list). +def _present_list(doc): + pending = 0 + subscribed = 0 + unsubscribed = 0 + for c in _list_contacts(doc.get("id", "")): + st = c.get("status", "") + if st == "pending": + pending += 1 + elif st == "subscribed": + subscribed += 1 + elif st == "unsubscribed": + unsubscribed += 1 + return { + "id": doc.get("id", ""), + "name": doc.get("name", ""), + "double_opt_in": doc.get("double_opt_in", False), + "fields": doc.get("fields", []), + "tags": doc.get("tags", []), + "counts": { + "pending": pending, + "subscribed": subscribed, + "unsubscribed": unsubscribed, + }, + "created_at": doc.get("created_at", ""), + "last_updated_at": doc.get("last_updated_at", ""), + } + +# _present_contact projects a stored contact doc into the ListContact-get +# response shape (drops the internal list_id key). +def _present_contact(doc): + return { + "id": doc.get("contact_id", ""), + "email_address": doc.get("email_address", ""), + "fields": doc.get("fields", {}), + "tags": doc.get("tags", []), + "status": doc.get("status", ""), + "created_at": doc.get("created_at", ""), + "last_updated_at": doc.get("last_updated_at", ""), + } + +# ==================================================================== +# VALIDATION +# ==================================================================== + +# _email_ok performs a minimal email sanity check (an @ with a dotted domain +# after it). Mirrors the 422 the real API returns for a bad email_address. +def _email_ok(email): + if email == None or type(email) != "string": + return False + at = email.find("@") + if at <= 0 or at == len(email) - 1: + return False + domain = email[at + 1:] + if domain.find("@") >= 0: + return False + if domain.find(".") <= 0 or domain.find(".") == len(domain) - 1: + return False + return True + +# _status_ok reports whether s is a valid contact status. +def _status_ok(s): + for i in range(len(_STATUSES)): + if _STATUSES[i] == s: + return True + return False + +# _is_str_list reports whether v is a list of strings. +def _is_str_list(v): + if v == None or type(v) != "list": + return False + for i in range(len(v)): + if type(v[i]) != "string": + return False + return True + +# _str_or_none coerces v to a trimmed string, or None when absent/not a +# string (used for optional string body members). +def _str_or_none(v): + if v == None or type(v) != "string": + return None + return v diff --git a/adapters/emailoctopus-style/scripts/lists.star b/adapters/emailoctopus-style/scripts/lists.star new file mode 100644 index 00000000..41438ea0 --- /dev/null +++ b/adapters/emailoctopus-style/scripts/lists.star @@ -0,0 +1,115 @@ +# List handlers — the /lists collection. +# +# GET /lists list lists (limit + starting_after) +# POST /lists create list (201) +# GET /lists/{list_id} get list +# PUT /lists/{list_id} update list (name) +# DELETE /lists/{list_id} delete list (204; cascades to contacts) +# +# Shared helpers (_require_auth, _parse_body, _problem, ...) are preloaded +# from scripts/lib.star. + +# on_list_lists answers GET /lists. +def on_list_lists(req): + err = _require_auth(req) + if err != None: + return err + + docs = store_collection("lists").list() + # Deterministic order for cursor paging: created_at asc, id asc on ties. + docs = query_select(docs, None, "id", "asc", None, None, None) + docs = query_select(docs, None, "created_at", "asc", None, None, None) + return _paginated(req, "/lists", [_present_list(d) for d in docs]) + +# on_create_list answers POST /lists. name is required (422 when missing or +# blank); the real API creates the list with no custom fields or tags. +def on_create_list(req): + err = _require_auth(req) + if err != None: + return err + + body = _parse_body(req) + if body == None: + return _bad_request() + + name = body.get("name", None) + if name == None or type(name) != "string" or name.strip() == "": + return _unprocessable([_verr("/name", "This value should not be blank.")]) + + now = _iso_now() + doc = { + "id": _uuid(), + "name": name, + "double_opt_in": False, + "fields": [], + "tags": [], + "created_at": now, + "last_updated_at": now, + } + store_collection("lists").insert(doc) + _emit("list.created", _present_list(doc)) + return respond(201, _present_list(doc)) + +# on_get_list answers GET /lists/{list_id}. +def on_get_list(req): + err = _require_auth(req) + if err != None: + return err + + list_id = _param(req, "list_id") + doc = _get_list(list_id) + if doc == None: + return _not_found() + return respond(200, _present_list(doc)) + +# on_update_list answers PUT /lists/{list_id}. The real endpoint takes a +# required name and returns the updated list. +def on_update_list(req): + err = _require_auth(req) + if err != None: + return err + + list_id = _param(req, "list_id") + lc = store_collection("lists") + doc = lc.get(list_id) + if doc == None: + return _not_found() + + body = _parse_body(req) + if body == None: + return _bad_request() + + name = body.get("name", None) + if name == None or type(name) != "string" or name.strip() == "": + return _unprocessable([_verr("/name", "This value should not be blank.")]) + + # Persist BEFORE emitting; the change event fires only when the name + # actually changed (exactly once per transition). + changed = doc.get("name", "") != name + doc["name"] = name + doc["last_updated_at"] = _iso_now() + lc.update(list_id, doc) + if changed: + _emit("list.updated", _present_list(doc)) + return respond(200, _present_list(doc)) + +# on_delete_list answers DELETE /lists/{list_id} with 204 No Content. +# Deleting a list removes its contacts too (the real API cascade). +def on_delete_list(req): + err = _require_auth(req) + if err != None: + return err + + list_id = _param(req, "list_id") + lc = store_collection("lists") + doc = lc.get(list_id) + if doc == None: + return _not_found() + + # Persist first: drop the contacts, then the list, then emit. + cc = store_collection("contacts") + for c in _list_contacts(list_id): + cc.delete(c.get("id", "")) + lc.delete(list_id) + _emit("list.deleted", _present_list(doc)) + return respond(204) diff --git a/internal/cli/llm.go b/internal/cli/llm.go index d829a914..be279bd6 100644 --- a/internal/cli/llm.go +++ b/internal/cli/llm.go @@ -49,7 +49,7 @@ and an instance manager. ` + "`stunt ui`" + ` opens it; every command below has demo one-shot stateful Stripe-style demo doctor CA + manifest + adapter + port health check clean wipe state, CA, hosts block (keeps manifest + adapters) - catalog search|show discover adapters (--json) # all 94 embedded, works offline + catalog search|show discover adapters (--json) # all 95 embedded, works offline adapter new|add|lint|test|list build/validate adapters (lint MUST pass) hosts sync|clean manage /etc/hosts (subdomain TLS mode) proxy start TLS reverse proxy (subdomain mode) diff --git a/internal/engine/emailoctopus_style_test.go b/internal/engine/emailoctopus_style_test.go new file mode 100644 index 00000000..3ac5b3a4 --- /dev/null +++ b/internal/engine/emailoctopus_style_test.go @@ -0,0 +1,726 @@ +package engine + +import ( + "bytes" + "context" + "encoding/json" + "io" + "net/http" + "path/filepath" + "testing" + "time" + + "stuntapi.com/stunt/internal/manifest" +) + +// TestEmailoctopusStyleAdapter exercises the EmailOctopus-style adapter +// end-to-end through the real engine, covering auth, lists CRUD, the contact +// status lifecycle (double opt-in PENDING → SUBSCRIBED → UNSUBSUBSCRIBED → +// SUBSCRIBED again), list filters, pagination, tags, fields, campaigns and +// reports, automations, error shapes, and the catch-all 404. +// +// The adapter's seeded lists include one double-opt-in list +// ("seed-list-doi-newsletter") and one single-opt-in list +// ("seed-list-single-optin") — double opt-in is dashboard-configured in the +// real product, so it can only arrive here via the seed fixture. +func TestEmailoctopusStyleAdapter(t *testing.T) { + adapterDir := filepath.Join("..", "..", "adapters", "emailoctopus-style") + absAdapterDir, err := filepath.Abs(adapterDir) + if err != nil { + t.Fatal(err) + } + + stateDir := t.TempDir() + m := &manifest.Manifest{ + Path: filepath.Join(stateDir, "stunt.yaml"), + Version: 1, + Network: manifest.Network{Mode: "port", BasePort: 0}, + Services: map[string]manifest.Service{ + "emailoctopus": {Adapter: absAdapterDir}, + }, + } + + e, err := New(m) + if err != nil { + t.Fatalf("engine.New: %v", err) + } + defer e.Close() + + addrs, cancel, err := e.ServeForTest(context.Background()) + if err != nil { + t.Fatalf("ServeForTest: %v", err) + } + defer cancel() + time.Sleep(50 * time.Millisecond) + + base := addrs["emailoctopus"] + const token = "eo_local_dev_key" + + // ===== Auth negative: no bearer → 401 in the real problem+json shape ===== + + body, status := getAuth(t, base+"/lists", "") + if status != 401 { + t.Fatalf("no bearer -> status %d, want 401; body %s", status, body) + } + errBody := emailoctopusDecode(t, body) + emailoctopusAssertProblem(t, errBody, 401, "unauthorized", "Invalid key.") + + // ===== Lists: create → get → list → update ===== + + body, status = postJSONAuth(t, base+"/lists", token, map[string]any{"name": "New clients list"}) + if status != 201 { + t.Fatalf("create list -> status %d, want 201; body %s", status, body) + } + created := emailoctopusDecode(t, body) + listID, ok := created["id"].(string) + if !ok || listID == "" { + t.Fatalf("created list id = %v, want non-empty string", created["id"]) + } + if created["name"] != "New clients list" { + t.Fatalf("created list name = %v", created["name"]) + } + if created["double_opt_in"] != false { + t.Fatalf("created list double_opt_in = %v, want false", created["double_opt_in"]) + } + emailoctopusAssertRecentISO(t, created["created_at"], "list created_at") + + body, status = getAuth(t, base+"/lists/"+listID, token) + if status != 200 { + t.Fatalf("get list -> status %d, want 200; body %s", status, body) + } + if got := emailoctopusDecode(t, body)["id"]; got != listID { + t.Fatalf("get list id = %v, want %v", got, listID) + } + + body, status = getAuth(t, base+"/lists", token) + if status != 200 { + t.Fatalf("list lists -> status %d, want 200; body %s", status, body) + } + listResp := emailoctopusDecode(t, body) + listData, ok := listResp["data"].([]any) + if !ok || len(listData) < 3 { // 2 seeded + 1 created + t.Fatalf("list lists data = %v, want >= 3 entries", listResp["data"]) + } + // The seeded double-opt-in list must be present with its derived counts + // (a plain object per the v2 spec, not a wrapped array). + var doiCounts map[string]any + for _, it := range listData { + l := it.(map[string]any) + if l["id"] == "seed-list-doi-newsletter" { + counts, ok := l["counts"].(map[string]any) + if !ok { + t.Fatalf("list counts = %v, want a plain object", l["counts"]) + } + doiCounts = counts + } + } + if doiCounts == nil { + t.Fatalf("seeded double opt-in list missing from GET /lists: %v", listData) + } + for _, k := range []string{"pending", "subscribed", "unsubscribed"} { + if _, ok := doiCounts[k]; !ok { + t.Fatalf("list counts missing %q: %v", k, doiCounts) + } + } + + body, status = emailoctopusPutJSON(t, base+"/lists/"+listID, token, map[string]any{"name": "Renamed clients list"}) + if status != 200 { + t.Fatalf("update list -> status %d, want 200; body %s", status, body) + } + if got := emailoctopusDecode(t, body)["name"]; got != "Renamed clients list" { + t.Fatalf("updated list name = %v, want Renamed clients list", got) + } + + // ===== Contacts on the double-opt-in list: PENDING flow ===== + + body, status = postJSONAuth(t, base+"/lists/seed-list-doi-newsletter/contacts", token, map[string]any{ + "email_address": "ada@synth.example", + }) + if status != 201 { + t.Fatalf("create DOI contact -> status %d, want 201; body %s", status, body) + } + ada := emailoctopusDecode(t, body) + if ada["status"] != "pending" { + t.Fatalf("DOI contact status = %v, want pending (double opt-in)", ada["status"]) + } + adaID, _ := ada["id"].(string) + if len(adaID) != 32 { + t.Fatalf("contact id = %q, want 32-char hash of the email", adaID) + } + + // Status filter: only the pending contact. + body, status = getAuth(t, base+"/lists/seed-list-doi-newsletter/contacts?status=pending", token) + if status != 200 { + t.Fatalf("filter pending -> status %d, want 200; body %s", status, body) + } + pend := emailoctopusDecode(t, body)["data"].([]any) + if len(pend) != 1 { + t.Fatalf("pending filter count = %d, want 1; body %s", len(pend), body) + } + body, status = getAuth(t, base+"/lists/seed-list-doi-newsletter/contacts?status=subscribed", token) + if status != 200 { + t.Fatalf("filter subscribed -> status %d, want 200; body %s", status, body) + } + if subs := emailoctopusDecode(t, body)["data"].([]any); len(subs) != 0 { + t.Fatalf("subscribed filter count = %d, want 0", len(subs)) + } + + // Confirm → subscribed, then unsubscribe, then resubscribe. + body, status = emailoctopusPutJSON(t, base+"/lists/seed-list-doi-newsletter/contacts/"+adaID, token, + map[string]any{"status": "subscribed"}) + if status != 200 { + t.Fatalf("confirm contact -> status %d, want 200; body %s", status, body) + } + if got := emailoctopusDecode(t, body)["status"]; got != "subscribed" { + t.Fatalf("confirmed status = %v, want subscribed", got) + } + body, status = emailoctopusPutJSON(t, base+"/lists/seed-list-doi-newsletter/contacts/"+adaID, token, + map[string]any{"status": "unsubscribed"}) + if status != 200 { + t.Fatalf("unsubscribe -> status %d, want 200; body %s", status, body) + } + if got := emailoctopusDecode(t, body)["status"]; got != "unsubscribed" { + t.Fatalf("unsubscribed status = %v, want unsubscribed", got) + } + body, status = emailoctopusPutJSON(t, base+"/lists/seed-list-doi-newsletter/contacts/"+adaID, token, + map[string]any{"status": "subscribed"}) + if status != 200 { + t.Fatalf("resubscribe -> status %d, want 200; body %s", status, body) + } + if got := emailoctopusDecode(t, body)["status"]; got != "subscribed" { + t.Fatalf("resubscribed status = %v, want subscribed", got) + } + + // ===== Contacts on the single-opt-in list ===== + + body, status = postJSONAuth(t, base+"/lists/seed-list-single-optin/contacts", token, map[string]any{ + "email_address": "grace@synth.example", + "tags": []string{"vip"}, + }) + if status != 201 { + t.Fatalf("create contact -> status %d, want 201; body %s", status, body) + } + grace := emailoctopusDecode(t, body) + if grace["status"] != "subscribed" { + t.Fatalf("single opt-in contact status = %v, want subscribed", grace["status"]) + } + graceID, _ := grace["id"].(string) + if tags, _ := grace["tags"].([]any); len(tags) != 1 || tags[0] != "vip" { + t.Fatalf("created contact tags = %v, want [vip]", grace["tags"]) + } + + // Duplicate email → 409 in the conflict problem shape. + body, status = postJSONAuth(t, base+"/lists/seed-list-single-optin/contacts", token, map[string]any{ + "email_address": "grace@synth.example", + }) + if status != 409 { + t.Fatalf("duplicate contact -> status %d, want 409; body %s", status, body) + } + emailoctopusAssertProblem(t, emailoctopusDecode(t, body), 409, "conflict", "Resource already exists.") + + // Unknown list → 404. + body, status = postJSONAuth(t, base+"/lists/does-not-exist/contacts", token, map[string]any{ + "email_address": "x@synth.example", + }) + if status != 404 { + t.Fatalf("contact on unknown list -> status %d, want 404; body %s", status, body) + } + emailoctopusAssertProblem(t, emailoctopusDecode(t, body), 404, "not-found", "Resource not found.") + + // Validation: missing email → 422 with a JSON Pointer. + body, status = postJSONAuth(t, base+"/lists/seed-list-single-optin/contacts", token, map[string]any{}) + if status != 422 { + t.Fatalf("missing email -> status %d, want 422; body %s", status, body) + } + valErr := emailoctopusDecode(t, body) + emailoctopusAssertProblem(t, valErr, 422, "unprocessable-content", "Unprocessable content.") + if members, _ := valErr["errors"].([]any); len(members) != 1 { + t.Fatalf("422 errors = %v, want one member", valErr["errors"]) + } else { + m := members[0].(map[string]any) + if m["pointer"] != "/email_address" { + t.Fatalf("422 pointer = %v, want /email_address", m["pointer"]) + } + } + + // Validation: malformed email → 422. + body, status = postJSONAuth(t, base+"/lists/seed-list-single-optin/contacts", token, map[string]any{ + "email_address": "not-an-email", + }) + if status != 422 { + t.Fatalf("bad email -> status %d, want 422; body %s", status, body) + } + + // Malformed JSON body → 400 bad-request. + body, status = emailoctopusPostRaw(t, base+"/lists/seed-list-single-optin/contacts", token, []byte("{\"email_address\": ")) + if status != 400 { + t.Fatalf("malformed body -> status %d, want 400; body %s", status, body) + } + emailoctopusAssertProblem(t, emailoctopusDecode(t, body), 400, "bad-request", "Bad request.") + + // Get a single contact. + body, status = getAuth(t, base+"/lists/seed-list-single-optin/contacts/"+graceID, token) + if status != 200 { + t.Fatalf("get contact -> status %d, want 200; body %s", status, body) + } + if got := emailoctopusDecode(t, body)["email_address"]; got != "grace@synth.example" { + t.Fatalf("get contact email = %v", got) + } + body, status = getAuth(t, base+"/lists/seed-list-single-optin/contacts/deadbeefdeadbeefdeadbeefdeadbeef", token) + if status != 404 { + t.Fatalf("get unknown contact -> status %d, want 404; body %s", status, body) + } + + // Update: fields + tags object (true adds, false removes). + body, status = emailoctopusPutJSON(t, base+"/lists/seed-list-single-optin/contacts/"+graceID, token, map[string]any{ + "fields": map[string]any{"Hometown": "Lisbon"}, + "tags": map[string]any{"vip": false, "customer": true}, + }) + if status != 200 { + t.Fatalf("update contact -> status %d, want 200; body %s", status, body) + } + updated := emailoctopusDecode(t, body) + if fields, _ := updated["fields"].(map[string]any); fields["Hometown"] != "Lisbon" { + t.Fatalf("updated fields = %v, want Hometown=Lisbon", updated["fields"]) + } + if tags, _ := updated["tags"].([]any); len(tags) != 1 || tags[0] != "customer" { + t.Fatalf("updated tags = %v, want [customer] (vip removed)", updated["tags"]) + } + if got := updated["id"]; got != graceID { + t.Fatalf("updated contact id = %v, want unchanged %v", got, graceID) + } + + // Upsert (PUT on the collection): existing email updates, new email creates. + body, status = emailoctopusPutJSON(t, base+"/lists/seed-list-single-optin/contacts", token, map[string]any{ + "email_address": "grace@synth.example", + "status": "unsubscribed", + }) + if status != 200 { + t.Fatalf("upsert existing -> status %d, want 200; body %s", status, body) + } + if got := emailoctopusDecode(t, body)["status"]; got != "unsubscribed" { + t.Fatalf("upserted status = %v, want unsubscribed", got) + } + body, status = emailoctopusPutJSON(t, base+"/lists/seed-list-single-optin/contacts", token, map[string]any{ + "email_address": "linus@synth.example", + }) + if status != 200 { + t.Fatalf("upsert new -> status %d, want 200; body %s", status, body) + } + linus := emailoctopusDecode(t, body) + if linus["status"] != "subscribed" { + t.Fatalf("upserted new contact status = %v, want subscribed", linus["status"]) + } + linusID, _ := linus["id"].(string) + + // Batch update: one good row + one unknown row. + body, status = emailoctopusPutJSON(t, base+"/lists/seed-list-single-optin/contacts/batch", token, map[string]any{ + "contacts": []map[string]any{ + {"id": graceID, "status": "subscribed"}, + {"id": "ffffffffffffffffffffffffffffffff"}, + }, + }) + if status != 200 { + t.Fatalf("batch update -> status %d, want 200; body %s", status, body) + } + batch := emailoctopusDecode(t, body) + if succ, _ := batch["success"].([]any); len(succ) != 1 { + t.Fatalf("batch success = %v, want one entry; body %s", batch["success"], body) + } else if row := succ[0].(map[string]any); row["success"] != true { + t.Fatalf("batch success row = %v, want success true", row) + } + if errs, _ := batch["errors"].([]any); len(errs) != 1 { + t.Fatalf("batch errors = %v, want one entry", batch["errors"]) + } else if row := errs[0].(map[string]any); row["status"] != float64(404) { + t.Fatalf("batch error row status = %v, want 404", row["status"]) + } + + // ===== Contact tags (v2 surface: contact members + the tag filter) ===== + // NOTE: v2 has NO tag CRUD endpoints (those are legacy 1.6 only) — the + // adapter deliberately does not serve /lists/{id}/tags. + + body, status = getAuth(t, base+"/lists/seed-list-single-optin/tags", token) + if status != 404 { + t.Fatalf("GET /lists/{id}/tags -> status %d, want 404 (not v2 surface); body %s", status, body) + } + + // Tag filter on contacts (tags is an array member, filtered by the adapter). + body, status = emailoctopusPutJSON(t, base+"/lists/seed-list-single-optin/contacts/"+graceID, token, + map[string]any{"tags": map[string]any{"newsletter": true}}) + if status != 200 { + t.Fatalf("tag contact -> status %d, want 200; body %s", status, body) + } + body, status = getAuth(t, base+"/lists/seed-list-single-optin/contacts?tag=newsletter", token) + if status != 200 { + t.Fatalf("filter by tag -> status %d, want 200; body %s", status, body) + } + if rows := emailoctopusDecode(t, body)["data"].([]any); len(rows) != 1 { + t.Fatalf("tag filter count = %d, want 1", len(rows)) + } + + // ===== Fields: create → duplicate → update → delete ===== + + body, status = postJSONAuth(t, base+"/lists/seed-list-single-optin/fields", token, map[string]any{ + "label": "Favourite fruit", + "tag": "Fruit", + "type": "choice_single", + "choices": []string{"apple", "orange"}, + }) + if status != 201 { + t.Fatalf("create field -> status %d, want 201; body %s", status, body) + } + if got := emailoctopusDecode(t, body)["type"]; got != "choice_single" { + t.Fatalf("created field type = %v", got) + } + body, status = postJSONAuth(t, base+"/lists/seed-list-single-optin/fields", token, map[string]any{ + "label": "Favourite fruit", "tag": "Fruit", "type": "text", + }) + if status != 409 { + t.Fatalf("duplicate field -> status %d, want 409; body %s", status, body) + } + // A choice field without choices → 422. + body, status = postJSONAuth(t, base+"/lists/seed-list-single-optin/fields", token, map[string]any{ + "label": "Meal", "tag": "Meal", "type": "choice_single", + }) + if status != 422 { + t.Fatalf("choice field without choices -> status %d, want 422; body %s", status, body) + } + body, status = emailoctopusPutJSON(t, base+"/lists/seed-list-single-optin/fields/Fruit", token, map[string]any{ + "label": "Favourite fruit", "tag": "Fruit", "type": "choice_multiple", + "choices": []string{"apple", "orange", "pear"}, + }) + if status != 200 { + t.Fatalf("update field -> status %d, want 200; body %s", status, body) + } + body, status = deleteAuth(t, base+"/lists/seed-list-single-optin/fields/Fruit", token) + if status != 204 { + t.Fatalf("delete field -> status %d, want 204; body %s", status, body) + } + + // ===== Pagination: limit + starting_after cursor ===== + + for _, email := range []string{"p1@synth.example", "p2@synth.example", "p3@synth.example"} { + body, status = postJSONAuth(t, base+"/lists/seed-list-doi-newsletter/contacts", token, map[string]any{ + "email_address": email, "status": "subscribed", + }) + if status != 201 { + t.Fatalf("create paging contact %s -> status %d; body %s", email, status, body) + } + } + body, status = getAuth(t, base+"/lists/seed-list-doi-newsletter/contacts?limit=2", token) + if status != 200 { + t.Fatalf("page 1 -> status %d, want 200; body %s", status, body) + } + page1 := emailoctopusDecode(t, body) + if rows := page1["data"].([]any); len(rows) != 2 { + t.Fatalf("page 1 count = %d, want 2", len(rows)) + } + paging, ok := page1["paging"].(map[string]any) + if !ok { + t.Fatalf("page 1 paging = %v, want a next envelope", page1["paging"]) + } + next, _ := paging["next"].(map[string]any) + cursor, _ := next["starting_after"].(string) + if cursor == "" { + t.Fatalf("page 1 next.starting_after = %v, want a cursor", next) + } + if url, _ := next["url"].(string); url == "" { + t.Fatalf("page 1 next.url = %v, want a url", next) + } + body, status = getAuth(t, base+"/lists/seed-list-doi-newsletter/contacts?limit=2&starting_after="+cursor, token) + if status != 200 { + t.Fatalf("page 2 -> status %d, want 200; body %s", status, body) + } + page2 := emailoctopusDecode(t, body) + if rows := page2["data"].([]any); len(rows) != 2 { + t.Fatalf("page 2 count = %d, want 2 (4 contacts total)", len(rows)) + } + if _, hasPaging := page2["paging"]; hasPaging { + t.Fatalf("page 2 paging = %v, want absent (last page)", page2["paging"]) + } + // A bogus cursor answers 400, not 500. + body, status = getAuth(t, base+"/lists/seed-list-doi-newsletter/contacts?starting_after=!!!bogus", token) + if status != 400 { + t.Fatalf("bogus cursor -> status %d, want 400; body %s", status, body) + } + + // ===== Campaigns (read-only; derived on first read) ===== + + body, status = getAuth(t, base+"/campaigns", token) + if status != 200 { + t.Fatalf("list campaigns -> status %d, want 200; body %s", status, body) + } + campData := emailoctopusDecode(t, body)["data"].([]any) + if len(campData) < 2 { + t.Fatalf("campaigns count = %d, want >= 2", len(campData)) + } + var sentID string + for _, it := range campData { + c := it.(map[string]any) + if c["status"] == "sent" { + sentID, _ = c["id"].(string) + } + } + if sentID == "" { + t.Fatalf("no sent campaign in %v", campData) + } + first, _ := campData[0].(map[string]any) + if _, ok := first["from"].(map[string]any); !ok { + t.Fatalf("campaign from = %v, want an object", first["from"]) + } + + body, status = getAuth(t, base+"/campaigns/"+sentID, token) + if status != 200 { + t.Fatalf("get campaign -> status %d, want 200; body %s", status, body) + } + if got := emailoctopusDecode(t, body)["id"]; got != sentID { + t.Fatalf("get campaign id = %v, want %v", got, sentID) + } + + body, status = getAuth(t, base+"/campaigns/"+sentID+"/reports/summary", token) + if status != 200 { + t.Fatalf("summary report -> status %d, want 200; body %s", status, body) + } + summary := emailoctopusDecode(t, body) + if v, ok := summary["sent"].(float64); !ok || v < 1 { + t.Fatalf("summary sent = %v, want >= 1", summary["sent"]) + } + if _, ok := summary["bounced"].(map[string]any); !ok { + t.Fatalf("summary bounced = %v, want {hard, soft}", summary["bounced"]) + } + + body, status = getAuth(t, base+"/campaigns/"+sentID+"/reports/links", token) + if status != 200 { + t.Fatalf("links report -> status %d, want 200; body %s", status, body) + } + if rows := emailoctopusDecode(t, body)["data"].([]any); len(rows) < 1 { + t.Fatalf("links report data = %v, want >= 1 link", body) + } + + body, status = getAuth(t, base+"/campaigns/"+sentID+"/reports?status=opened", token) + if status != 200 { + t.Fatalf("contact report -> status %d, want 200; body %s", status, body) + } + report := emailoctopusDecode(t, body) + if report["status"] != "opened" { + t.Fatalf("contact report status = %v, want opened", report["status"]) + } + if rows := report["data"].([]any); len(rows) < 1 { + t.Fatalf("contact report data = %v, want >= 1 event", body) + } + // ?status= is required by the real endpoint. + body, status = getAuth(t, base+"/campaigns/"+sentID+"/reports", token) + if status != 422 { + t.Fatalf("report without status -> status %d, want 422; body %s", status, body) + } + // Unknown campaign → 404. + body, status = getAuth(t, base+"/campaigns/11111111-2222-4333-8444-555555555555/reports/summary", token) + if status != 404 { + t.Fatalf("unknown campaign report -> status %d, want 404; body %s", status, body) + } + + // ===== Automations: queue a contact (204) ===== + + body, status = emailoctopusPostRaw(t, base+"/automations/12345678-1234-4234-8234-123456789012/queue", token, + []byte(`{"contact_id": "`+graceID+`"}`)) + if status != 204 { + t.Fatalf("queue automation -> status %d, want 204; body %s", status, body) + } + // Malformed automation id → 404. + body, status = emailoctopusPostRaw(t, base+"/automations/not-a-uuid/queue", token, + []byte(`{"contact_id": "`+graceID+`"}`)) + if status != 404 { + t.Fatalf("queue bad automation -> status %d, want 404; body %s", status, body) + } + // Unknown contact → 404; missing contact_id → 422. + body, status = emailoctopusPostRaw(t, base+"/automations/12345678-1234-4234-8234-123456789012/queue", token, + []byte(`{"contact_id": "ffffffffffffffffffffffffffffffff"}`)) + if status != 404 { + t.Fatalf("queue unknown contact -> status %d, want 404; body %s", status, body) + } + body, status = emailoctopusPostRaw(t, base+"/automations/12345678-1234-4234-8234-123456789012/queue", token, + []byte(`{}`)) + if status != 422 { + t.Fatalf("queue missing contact_id -> status %d, want 422; body %s", status, body) + } + + // ===== Contact delete → 204, then 404 ===== + + body, status = deleteAuth(t, base+"/lists/seed-list-single-optin/contacts/"+linusID, token) + if status != 204 { + t.Fatalf("delete contact -> status %d, want 204; body %s", status, body) + } + body, status = getAuth(t, base+"/lists/seed-list-single-optin/contacts/"+linusID, token) + if status != 404 { + t.Fatalf("get deleted contact -> status %d, want 404; body %s", status, body) + } + + // ===== Same email across lists (per-list contact identity) ===== + + // The contact id is the email hash, but identity is PER LIST: the same + // address may join a second list without a PK clash, and each list + // addresses its own row. + crossListID := "seed-list-doi-newsletter" + crossBody, status := postJSONAuth(t, base+"/lists/seed-list-single-optin/contacts", token, map[string]any{ + "email_address": "shared@example.test", + }) + if status != 201 { + t.Fatalf("create shared contact on list A -> status %d; body %s", status, crossBody) + } + sharedA := emailoctopusDecode(t, crossBody)["id"].(string) + crossBody, status = postJSONAuth(t, base+"/lists/"+crossListID+"/contacts", token, map[string]any{ + "email_address": "shared@example.test", + }) + if status != 201 { + t.Fatalf("same email on list B -> status %d (was a PK-clash 500), want 201; body %s", status, crossBody) + } + sharedB := emailoctopusDecode(t, crossBody)["id"].(string) + if sharedA != sharedB { + t.Fatalf("shared contact ids differ per list: %v vs %v (public id is the email hash)", sharedA, sharedB) + } + // Changing list B's copy to an email that exists on list A rekeys within + // list A's row space only — 409 on the SAME list, free across lists. + crossBody, status = emailoctopusPutJSON(t, base+"/lists/"+crossListID+"/contacts/"+sharedB, token, + map[string]any{"email_address": "moved@example.test"}) + if status != 200 { + t.Fatalf("email change on other list -> status %d, want 200; body %s", status, crossBody) + } + body, status = getAuth(t, base+"/lists/seed-list-single-optin/contacts/"+sharedA, token) + if status != 200 { + t.Fatalf("list A contact after other list's rekey -> status %d, want 200 (must be untouched)", status) + } + + // ===== List delete → 204, cascade removes its contacts ===== + + // Seed a contact on listID first so the cascade has something to remove. + body, status = postJSONAuth(t, base+"/lists/"+listID+"/contacts", token, map[string]any{ + "email_address": "cascading@example.test", + }) + if status != 201 { + t.Fatalf("create cascade contact -> status %d; body %s", status, body) + } + cascadeID := emailoctopusDecode(t, body)["id"].(string) + body, status = deleteAuth(t, base+"/lists/"+listID, token) + if status != 204 { + t.Fatalf("delete list -> status %d, want 204; body %s", status, body) + } + body, status = getAuth(t, base+"/lists/"+listID, token) + if status != 404 { + t.Fatalf("get deleted list -> status %d, want 404; body %s", status, body) + } + body, status = getAuth(t, base+"/lists/"+listID+"/contacts/"+cascadeID, token) + if status != 404 { + t.Fatalf("contact after list delete -> status %d, want 404 (cascade); body %s", status, body) + } + + // ===== Catch-all 404 in the EmailOctopus problem shape ===== + + body, status = getAuth(t, base+"/definitely/not/a/route", token) + if status != 404 { + t.Fatalf("catch-all -> status %d, want 404; body %s", status, body) + } + emailoctopusAssertProblem(t, emailoctopusDecode(t, body), 404, "not-found", "Resource not found.") +} + +// === Helpers (emailoctopus-prefixed; shared helpers live in +// stripe_adapter_test.go: getAuth / postJSONAuth / deleteAuth) === + +// emailoctopusPutJSON performs an HTTP PUT with a Bearer token and a JSON +// body, returning the body + status code. +func emailoctopusPutJSON(t *testing.T, url, token string, body map[string]any) (string, int) { + t.Helper() + data, _ := json.Marshal(body) + req, err := http.NewRequest("PUT", url, bytes.NewReader(data)) + if err != nil { + t.Fatal(err) + } + req.Header.Set("Content-Type", "application/json") + if token != "" { + req.Header.Set("Authorization", "Bearer "+token) + } + resp, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatal(err) + } + defer resp.Body.Close() + b, _ := io.ReadAll(resp.Body) + return string(b), resp.StatusCode +} + +// emailoctopusPostRaw performs an HTTP POST with a Bearer token and a raw +// (pre-marshalled) body — used to send deliberately malformed JSON. +func emailoctopusPostRaw(t *testing.T, url, token string, body []byte) (string, int) { + t.Helper() + req, err := http.NewRequest("POST", url, bytes.NewReader(body)) + if err != nil { + t.Fatal(err) + } + req.Header.Set("Content-Type", "application/json") + if token != "" { + req.Header.Set("Authorization", "Bearer "+token) + } + resp, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatal(err) + } + defer resp.Body.Close() + b, _ := io.ReadAll(resp.Body) + return string(b), resp.StatusCode +} + +// emailoctopusDecode unmarshals a JSON object response body. +func emailoctopusDecode(t *testing.T, body string) map[string]any { + t.Helper() + var out map[string]any + if err := json.Unmarshal([]byte(body), &out); err != nil { + t.Fatalf("unmarshal %q: %v", body, err) + } + return out +} + +// emailoctopusAssertProblem checks an RFC 7807 error envelope: type anchor, +// title, detail, and numeric status all match the real EmailOctopus shape. +func emailoctopusAssertProblem(t *testing.T, body map[string]any, status int, slug, detail string) { + t.Helper() + wantType := "https://emailoctopus.com/api-documentation/v2#" + slug + if body["type"] != wantType { + t.Fatalf("problem type = %v, want %s", body["type"], wantType) + } + if body["title"] != "An error occurred." { + t.Fatalf("problem title = %v, want \"An error occurred.\"", body["title"]) + } + if body["detail"] != detail { + t.Fatalf("problem detail = %v, want %q", body["detail"], detail) + } + if v, ok := body["status"].(float64); !ok || int(v) != status { + t.Fatalf("problem status = %v, want %d", body["status"], status) + } +} + +// emailoctopusHas reports whether the JSON array contains the string s. +func emailoctopusHas(arr []any, s string) bool { + for _, v := range arr { + if str, ok := v.(string); ok && str == s { + return true + } + } + return false +} + +// emailoctopusAssertRecentISO checks that v is an ISO 8601 timestamp minted +// within the last 15 minutes — the adapter derives timestamps from the engine +// clock, never a hardcoded literal. +func emailoctopusAssertRecentISO(t *testing.T, v any, what string) { + t.Helper() + s, ok := v.(string) + if !ok { + t.Fatalf("%s = %v, want an ISO 8601 string", what, v) + } + ts, err := time.Parse("2006-01-02T15:04:05-07:00", s) + if err != nil { + t.Fatalf("%s = %q, want ISO 8601 with numeric offset: %v", what, s, err) + } + if d := time.Since(ts); d < -time.Minute || d > 15*time.Minute { + t.Fatalf("%s = %v, want within 15min of now (age %s)", what, s, d) + } +}