From 87a1147726ce52f0a5e64a817f27abedf5257d88 Mon Sep 17 00:00:00 2001 From: thedogwiththedataonit Date: Tue, 20 Oct 2026 12:51:52 +0900 Subject: [PATCH 01/16] Add contacts import and Import History names for CSV imports `contacts import` calls the new `POST /v1/contacts/import` with JSON rows from a file or stdin, recorded as one Import History batch. `contacts import-csv` sends the file's name (or `--file-name`) as `fileName`, so the batch is named in Import History. Both print the returned `importId`. `upsert-many`'s summary now says up to 1000 contacts and no history entry. Pins the SDK source commit that adds `contacts.import`; re-vendors the spec. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 16 +- bun.lock | 4 +- docs/commands/README.md | 31 +- openapi/public-api-v1.yaml | 2124 +++++++++++++++++++------- package.json | 2 +- src/commands/contacts/import-csv.ts | 28 +- src/commands/contacts/import.ts | 101 ++ src/commands/contacts/upsert-many.ts | 3 +- src/generated/openapi-types.ts | 468 +++++- src/registry.ts | 2 + tests/commands/contacts.test.ts | 114 ++ 11 files changed, 2286 insertions(+), 607 deletions(-) create mode 100644 src/commands/contacts/import.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index da72570..49e8488 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,12 +2,26 @@ ## Unreleased +### Added + +- **`contacts import`** (`POST /v1/contacts/import`): bulk-import up to 1000 + contacts from a JSON file (an array, or `{ "contacts": [...] }`) or stdin as + one batch in Brew's Import History. Takes `--file-name`, `--validate` and + `--consent-source`, and prints the batch's `importId`. +- `contacts import-csv` names its batch in Import History: the `--file` name + by default, or `--file-name`. Both imports return `importId`. + ### Changed +- `contacts upsert-many` says what it is for: up to 1000 contacts per call + (its summary said 100) with no Import History entry, for recurring syncs. + - Canvas session create, refresh, and revoke use the SDK methods. JSON output, revocation confirmation, and create/refresh no-retry behavior are preserved. -Requires the SDK revocation response fix, planned for 13.0.0. Follow the canvas +Requires the SDK revocation response fix, planned for 13.0.0, and +`contacts.import` (GetBrew/typescript-sdk `claude/contact-imports`); the +dependency pins that SDK source commit until it is published. Follow the canvas migration gate in `RELEASING.md` before updating the dependency and releasing. ## 0.16.0 diff --git a/bun.lock b/bun.lock index 1dfb032..72d4ff3 100644 --- a/bun.lock +++ b/bun.lock @@ -5,7 +5,7 @@ "": { "name": "@brew.new/cli", "dependencies": { - "@brew.new/sdk": "git+https://github.com/GetBrew/typescript-sdk.git#a5ce43d47c51793dc3fb368d744d56deb91c3368", + "@brew.new/sdk": "git+https://github.com/GetBrew/typescript-sdk.git#fa924c46bba610ab51cd78eff2d72c2191aaf234", "commander": "^14.0.0", }, "devDependencies": { @@ -46,7 +46,7 @@ "@biomejs/cli-win32-x64": ["@biomejs/cli-win32-x64@2.5.7", "", { "os": "win32", "cpu": "x64" }, "sha512-V+0wu/nrj2S+MhP4EQ0uHNolP0IALEsz45pg0WoKkHfDeh0+ItHwP/p7bX5RPoMOl9NkpHYWdYPhIcy2mACHvQ=="], - "@brew.new/sdk": ["@brew.new/sdk@github:GetBrew/typescript-sdk#a5ce43d", { "peerDependencies": { "react": ">=18.2.0 <20" }, "optionalPeers": ["react"] }, "GetBrew-typescript-sdk-a5ce43d", "sha512-ygdGGCqHa/LLn4AJGtQsOCcVVQ4nTcVg+hf5+/CBOeEQD2JZ2fONv7rDO50V4oJGyJ5wNinHkjza+xmmMlPCrg=="], + "@brew.new/sdk": ["@brew.new/sdk@github:GetBrew/typescript-sdk#fa924c4", { "peerDependencies": { "react": ">=18.2.0 <20" }, "optionalPeers": ["react"] }, "GetBrew-typescript-sdk-fa924c4", "sha512-IpWN6jMmjRp0TXkz8ux5dM+DGeXRtyqHKSQWt0MSAgcaKo02CnIfcXXBtVOJJITvGZSh835AkQ4njPxuQrdO/w=="], "@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.27.7", "", { "os": "aix", "cpu": "ppc64" }, "sha512-EKX3Qwmhz1eMdEJokhALr0YiD0lhQNwDqkPYyPhiSwKrh7/4KRjQc04sZ8db+5DVVnZ1LmbNDI1uAMPEUBnQPg=="], diff --git a/docs/commands/README.md b/docs/commands/README.md index 00edb91..d262903 100644 --- a/docs/commands/README.md +++ b/docs/commands/README.md @@ -2,7 +2,7 @@ -148 commands. Classes: read (always safe), write +149 commands. Classes: read (always safe), write (mutating, retry-safe), destructive (irreversible — the confirmation protocol applies: interactive y/N on a TTY, exit 4 + JSON envelope with a `confirmCommand` otherwise, `--yes` to proceed). @@ -35,12 +35,13 @@ a `confirmCommand` otherwise, `--yes` to proceed). | `brew-cli contacts count` | read | `POST /v1/contacts/search` | Count contacts matching a filter | | `brew-cli contacts count-by` | read | `POST /v1/contacts/search` | Count contacts per field value, email domain, or signup period (largest group first) | | `brew-cli contacts upsert` | write | `POST /v1/contacts` | Create or update one contact by email | -| `brew-cli contacts upsert-many` | write | `POST /v1/contacts` | Create or update a batch of contacts (up to 100 per call) | +| `brew-cli contacts upsert-many` | write | `POST /v1/contacts` | Create or update up to 1000 contacts per call, with no Import History entry (for syncs; use contacts import for a batch you want listed) | | `brew-cli contacts update` | write | `PATCH /v1/contacts/{email}` | Partially update one contact (PATCH; never retried) | | `brew-cli contacts delete` | destructive | `DELETE /v1/contacts/{email}` | Delete one contact by email (idempotent) | | `brew-cli contacts delete-many` | destructive | `POST /v1/contacts/batch-delete` | Delete up to 1000 contacts by email | | `brew-cli contacts validate` | write ($) | `POST /v1/contacts/validate` | Batch-validate email deliverability (no contacts created) | -| `brew-cli contacts import-csv` | write | `POST /v1/contacts/import-csv` | Bulk-import contacts from a CSV file or stdin (free; --validate costs 2 credits per address) | +| `brew-cli contacts import` | write | `POST /v1/contacts/import` | Bulk-import up to 1000 contacts from a JSON file or stdin as one Import History batch (free; --validate costs 2 credits per address) | +| `brew-cli contacts import-csv` | write | `POST /v1/contacts/import-csv` | Bulk-import contacts from a CSV file or stdin as one Import History batch (free; --validate costs 2 credits per address) | | `brew-cli fields list` | read | `GET /v1/fields` | List custom contact fields | | `brew-cli fields get` | read | `GET /v1/fields/{fieldName}` | Fetch one contact field definition by name — the bare row | | `brew-cli fields create` | write | `POST /v1/fields` | Create a custom contact field | @@ -374,7 +375,7 @@ brew-cli contacts upsert --email j@x.com --custom plan=pro --custom seats=4 ### brew-cli contacts upsert-many -Create or update a batch of contacts (up to 100 per call) +Create or update up to 1000 contacts per call, with no Import History entry (for syncs; use contacts import for a batch you want listed) - Route: `POST /v1/contacts` - Class: write @@ -451,14 +452,34 @@ brew-cli contacts validate --emails jane@example.com bob@example.com brew-cli contacts validate --input '{"emails":["jane@example.com"]}' ``` +### brew-cli contacts import + +Bulk-import up to 1000 contacts from a JSON file or stdin as one Import History batch (free; --validate costs 2 credits per address) + +- Route: `POST /v1/contacts/import` +- Class: write +- SDK: `brew.contacts.import(...)` +- `--file ` — JSON file of contacts (an array, or { "contacts": [...] }), or - for stdin +- `--file-name ` — Name the batch in Brew's Import History (default: the --file name) +- `--validate` — Deliverability-check every imported address (2 credits per address) +- `--consent-source ` — Default marketing consent record for rows without their own: api, form or import +- `--input ` — Full JSON request body, or - to read stdin (flags override it) +- `--idempotency-key ` — Idempotency-Key for safe retries (auto-generated otherwise) + +```bash +brew-cli contacts import --file contacts.json --consent-source import +cat contacts.json | brew-cli contacts import --file - --file-name crm-export.json +``` + ### brew-cli contacts import-csv -Bulk-import contacts from a CSV file or stdin (free; --validate costs 2 credits per address) +Bulk-import contacts from a CSV file or stdin as one Import History batch (free; --validate costs 2 credits per address) - Route: `POST /v1/contacts/import-csv` - Class: write - SDK: `brew.contacts.importCsv(...)` - `--file ` — CSV file to import, or - for stdin (or csv in --input) +- `--file-name ` — Name the batch in Brew's Import History (default: the --file name) - `--mapping ` — Column mapping csvColumn=fieldName, repeatable - `--date-order ` — month_first or day_first: how to read dates like 03/04/2026 (a day over 12 in the column wins; default month-first, with a DATE_ORDER_ASSUMED warning) - `--validate` — Deliverability-check every imported address (2 credits per address) diff --git a/openapi/public-api-v1.yaml b/openapi/public-api-v1.yaml index 9760f28..5f7f03a 100644 --- a/openapi/public-api-v1.yaml +++ b/openapi/public-api-v1.yaml @@ -12795,6 +12795,23 @@ components: unscored: type: integer minimum: 0 + persistence: + type: object + properties: + matched: + type: integer + minimum: 0 + modified: + type: integer + minimum: 0 + failed: + type: integer + minimum: 0 + required: + - matched + - modified + - failed + additionalProperties: false required: - valid - risky @@ -12969,6 +12986,23 @@ components: unscored: type: integer minimum: 0 + persistence: + type: object + properties: + matched: + type: integer + minimum: 0 + modified: + type: integer + minimum: 0 + failed: + type: integer + minimum: 0 + required: + - matched + - modified + - failed + additionalProperties: false required: - valid - risky @@ -13839,35 +13873,86 @@ components: data: type: array items: - type: object - properties: - email: - type: string - valid: - type: boolean - status: - type: string - enum: + oneOf: + - type: object + properties: + email: + type: string + valid: + type: boolean + status: + type: string + enum: + - valid + - risky + - invalid + checkOutcome: + type: string + enum: + - completed + reason: + type: string + didYouMean: + type: string + risk: + type: string + isDisposable: + type: boolean + isRole: + type: boolean + required: + - email - valid - - risky - - invalid - reason: - type: string - didYouMean: - type: string - risk: - type: string - isDisposable: - type: boolean - isRole: - type: boolean - required: - - email - - valid - - status - additionalProperties: false + - status + - checkOutcome + additionalProperties: false + - type: object + properties: + email: + type: string + valid: + type: boolean + enum: + - false + status: + type: string + enum: + - risky + checkOutcome: + type: string + enum: + - unavailable + reason: + type: string + enum: + - validation_unavailable + required: + - email + - valid + - status + - checkOutcome + - reason + additionalProperties: false + persistence: + type: object + properties: + matched: + type: integer + minimum: 0 + modified: + type: integer + minimum: 0 + failed: + type: integer + minimum: 0 + required: + - matched + - modified + - failed + additionalProperties: false required: - data + - persistence additionalProperties: false ContactsValidateRequest: type: object @@ -13976,6 +14061,23 @@ components: unscored: type: integer minimum: 0 + persistence: + type: object + properties: + matched: + type: integer + minimum: 0 + modified: + type: integer + minimum: 0 + failed: + type: integer + minimum: 0 + required: + - matched + - modified + - failed + additionalProperties: false required: - valid - risky @@ -13984,11 +14086,15 @@ components: additionalProperties: false validationJobId: type: string + importId: + type: string + description: This batch's Import History id. Every contact it wrote carries it as `importId`, so `search_contacts` with an `importId` filter lists them. required: - summary - fieldsCreated - errors - warnings + - importId additionalProperties: false ContactsImportCsvRequest: type: object @@ -14036,9 +14142,228 @@ components: - month_first - day_first description: "Order for a date column whose dates read either way (`03/04/2026`): `day_first` is 3 April, `month_first` 4 March. A day over 12 in the column overrides it. Default month-first, with a DATE_ORDER_ASSUMED warning." + fileName: + type: string + minLength: 1 + maxLength: 255 + description: Name this batch is listed under in Import History (usually the source file name, e.g. `customers.csv`), also stamped on every contact it writes as `csvFileName`. A path keeps only its last segment. required: - csv additionalProperties: false + ContactsImportResponse: + type: object + properties: + summary: + type: object + properties: + inserted: + type: integer + minimum: 0 + updated: + type: integer + minimum: 0 + failed: + type: integer + minimum: 0 + skipped: + type: integer + minimum: 0 + required: + - inserted + - updated + - failed + - skipped + additionalProperties: false + fieldsCreated: + type: array + items: + type: string + errors: + type: array + items: + type: object + properties: + email: + type: string + code: + type: string + field: + type: string + message: + type: string + required: + - email + - code + - message + additionalProperties: false + warnings: + type: array + items: + type: object + properties: + code: + type: string + minLength: 1 + email: + type: string + minLength: 1 + field: + type: string + minLength: 1 + message: + type: string + minLength: 1 + from: + type: string + minLength: 1 + to: + type: string + minLength: 1 + original: + type: string + minLength: 1 + normalized: + type: string + minLength: 1 + required: + - code + - message + additionalProperties: false + validation: + type: object + properties: + valid: + type: integer + minimum: 0 + risky: + type: integer + minimum: 0 + invalid: + type: integer + minimum: 0 + unscored: + type: integer + minimum: 0 + persistence: + type: object + properties: + matched: + type: integer + minimum: 0 + modified: + type: integer + minimum: 0 + failed: + type: integer + minimum: 0 + required: + - matched + - modified + - failed + additionalProperties: false + required: + - valid + - risky + - invalid + - unscored + additionalProperties: false + validationJobId: + type: string + importId: + type: string + description: This batch's Import History id. Every contact it wrote carries it as `importId`, so `search_contacts` with an `importId` filter lists them. + required: + - summary + - fieldsCreated + - errors + - warnings + - importId + additionalProperties: false + ContactsImportRequest: + type: object + properties: + contacts: + type: array + items: + type: object + properties: + email: + type: string + firstName: + type: string + lastName: + type: string + subscribed: + type: boolean + customFields: + type: object + additionalProperties: {} + consent: + type: object + properties: + source: + type: string + enum: + - api + - form + - import + description: "Who captured it: `api`, `form`, or `import`. (`app` is reserved for the in-app CSV wizard.)" + capturedAt: + type: string + format: date-time + description: ISO 8601; defaults to now. + policyVersion: + type: string + minLength: 1 + maxLength: 64 + evidence: + type: string + minLength: 1 + maxLength: 500 + required: + - source + additionalProperties: false + description: Marketing consent provenance to record on this contact. Optional at creation; an inline marketing send to this address later needs the contact to be subscribed and warns when no record exists. + additionalProperties: false + minItems: 1 + maxItems: 1000 + validate: + type: boolean + description: Optional deliverability check on ingestion. When true, each address is validated with the provider (2 credits per address, charged on success) and the verdict is saved to the contact’s `validationStatus`. Submissions above the inline cap (100 addresses) upsert first and validate as a background job, returning a `validationJobId`. + consent: + type: object + properties: + source: + type: string + enum: + - api + - form + - import + description: "Who captured it: `api`, `form`, or `import`. (`app` is reserved for the in-app CSV wizard.)" + capturedAt: + type: string + format: date-time + description: ISO 8601; defaults to now. + policyVersion: + type: string + minLength: 1 + maxLength: 64 + evidence: + type: string + minLength: 1 + maxLength: 500 + required: + - source + additionalProperties: false + description: 'Default consent record for every row that does not carry its own `consent` (typically `{ "source": "import" }`).' + fileName: + type: string + minLength: 1 + maxLength: 255 + description: Name this batch is listed under in Import History (usually the source file name, e.g. `customers.csv`), also stamped on every contact it writes as `csvFileName`. A path keeps only its last segment. + required: + - contacts + additionalProperties: false ContactsBatchDeleteResponse: type: object properties: @@ -29915,9 +30240,9 @@ paths: Test (`test: true`): `emailId`, `subject`, one `to` address, optional `emailVersionId`, `previewText`, `replyTo`, `domainId` (a verified org-owned domain; otherwise the Brew default sender, and an unverified or foreign domain is rejected, never downgraded), `from` (`{ email, name? }`, an address on that domain), `variables` (example values for `{{ var | fallback }}` merge tags; a value wins over the fallback) and `payload` (template data; nested JSON renders via Liquid as `trigger.*`); the subject carries a `[TEST]` prefix. The same design can be sent unlimited times; every campaign call mints a new send. - **Returns** campaign `202 { status: 'queued' | 'scheduled', sendId, scheduledAt?, warnings? }` (poll `getSend`; `warnings[]` carries one `CONSENT_RECORD_MISSING` per inline recipient that is a subscribed contact with no consent record, and one `RECIPIENTS_EXCLUDED` with the counts when unsubscribed, suppressed or undeliverable contacts will be skipped); test `200 { status: 'completed', recipient }`. + **Returns** campaign `202 { status: 'queued' | 'scheduled', sendId, scheduledAt?, warnings? }` (poll `getSend`; `warnings[]` carries one `CONSENT_RECORD_MISSING` per inline recipient that is a subscribed contact with no consent record, and one `RECIPIENTS_EXCLUDED` with the counts when unsubscribed, suppressed, undeliverable or permission-less recipients will be skipped); test `200 { status: 'completed', recipient }`. - **Errors** `400 INVALID_REQUEST` (`param: subject`) when `subject` has a line break or runs over 1000 characters (993 on a test send, for the `[TEST]` prefix), judged on the text every recipient receives; `404 EMAIL_NOT_FOUND`, `DOMAIN_NOT_FOUND`, `AUDIENCE_NOT_FOUND` (a resource in another brand also surfaces as `404`); `422 EMAIL_NOT_READY`, `DOMAIN_NOT_READY`, `DOMAIN_PURPOSE_NOT_ALLOWED`, `LIQUID_RENDER_ERROR`; `422 CONSENT_REQUIRED` when an inline marketing recipient has no contact record with marketing consent and no `consent` was supplied (`details.recipients`); `422 RECIPIENT_UNSUBSCRIBED` when an inline recipient is an existing contact who opted out (`details.recipients`, never re-subscribed); `422 NO_ELIGIBLE_RECIPIENTS` when an immediate send would reach nobody because every contact it targets is unsubscribed, suppressed or undeliverable (`details.eligibility` = `{ total, eligible, excluded: { unsubscribed, suppressed, invalidEmail } }`; no send is created, while a scheduled send is accepted instead); `422 EMAIL_IMAGES_MISSING` when the email shows a cdn.brew.new image that does not exist (`details.missingImages[]`; a dead icon whose real file sits elsewhere in its pack is moved there instead when the key has `emails` access); `402 SEND_QUOTA_EXCEEDED` when the plan's monthly volume would be exceeded (no `Retry-After`). + **Errors** `400 INVALID_REQUEST` (`param: subject`) when `subject` has a line break or runs over 1000 characters (993 on a test send, for the `[TEST]` prefix), judged on the text every recipient receives; `404 EMAIL_NOT_FOUND`, `DOMAIN_NOT_FOUND`, `AUDIENCE_NOT_FOUND` (a resource in another brand also surfaces as `404`); `422 EMAIL_NOT_READY`, `DOMAIN_NOT_READY`, `DOMAIN_PURPOSE_NOT_ALLOWED`, `LIQUID_RENDER_ERROR`; `422 CONSENT_REQUIRED` when an inline marketing recipient has no contact record with marketing consent and no `consent` was supplied (`details.recipients`); `422 RECIPIENT_UNSUBSCRIBED` when an inline recipient is an existing contact who opted out (`details.recipients`, never re-subscribed); `422 NO_ELIGIBLE_RECIPIENTS` when an immediate send would reach nobody because every contact it targets is excluded (`details.eligibility` = `{ total, eligible, excluded: { unsubscribed, suppressed, invalidEmail, consentRequired? } }`, each address counted once; no send is created, while a scheduled send is accepted instead); `422 EMAIL_IMAGES_MISSING` when the email shows a cdn.brew.new image that does not exist (`details.missingImages[]`; a dead icon whose real file sits elsewhere in its pack is moved there instead when the key has `emails` access); `402 SEND_QUOTA_EXCEEDED` when the plan's monthly volume would be exceeded (no `Retry-After`). **See also** `listSends`, `cancelSend`, `fireTrigger`. parameters: @@ -38996,7 +39321,7 @@ paths: **Use when** debugging one contact’s journey or confirming a test run finished. - **Input** `automationRunId` in the path; `include=logs` inlines the newest 100 per-node execution logs and sets `logsTruncated` when older logs exist. + **Input** `automationRunId` in the path; `include=logs` inlines the newest 100 execution log rows in `orderIndex` order and sets `logsTruncated` when older rows exist. Each node status change is its own row: a node that runs has a `running` row (no `completedAt`), then its terminal `completed` or `failed` row, or `skipped` when the run is canceled while it runs; a node a branch rules out has only a `skipped` row. A node’s outcome is its last row. **Returns** `200` with the row; `logs` is present only when requested. @@ -39013,11 +39338,11 @@ paths: in: path - schema: type: string - description: "Expansions: `logs` inlines the newest 100 per-node execution logs and reports `logsTruncated`." + description: "Expansions: `logs` inlines the newest 100 execution log rows, one per node status change (a node's outcome is its last row by `orderIndex`), and reports `logsTruncated`." x-brew-include-tokens: - logs required: false - description: "Expansions: `logs` inlines the newest 100 per-node execution logs and reports `logsTruncated`." + description: "Expansions: `logs` inlines the newest 100 execution log rows, one per node status change (a node's outcome is its last row by `orderIndex`), and reports `logsTruncated`." name: include in: query - name: X-Brand-Id @@ -45911,7 +46236,445 @@ paths: retryAfter: 42 details: scope: credential - limit: 10 + limit: 10 + remaining: 0 + reset: 1712592360 + consumed: false + "500": + description: "`INTERNAL_ERROR`: An unexpected failure; the x-request-id header identifies it." + headers: + x-request-id: + schema: + type: string + description: Unique request identifier. Share this with support when debugging a request. + example: req_8cac13fd94e6420cacdd75a1aa403a28 + required: true + description: Unique request identifier. Share this with support when debugging a request. + content: + application/json: + schema: + $ref: "#/components/schemas/ApiErrorEnvelope" + examples: + internalError: + summary: INTERNAL_ERROR + value: + error: + code: INTERNAL_ERROR + type: internal_error + message: An unexpected failure; the x-request-id header identifies it. + suggestion: Retry the request. If it keeps failing, contact support with the x-request-id header. + docs: https://docs.brew.new/api-reference/api/errors + "503": + description: "`SERVICE_UNAVAILABLE`: A dependency the operation must consult (billing, the idempotency store) is temporarily unavailable, so the request was refused rather than run unmetered." + headers: + x-request-id: + schema: + type: string + description: Unique request identifier. Share this with support when debugging a request. + example: req_8cac13fd94e6420cacdd75a1aa403a28 + required: true + description: Unique request identifier. Share this with support when debugging a request. + Retry-After: + schema: + type: integer + description: Seconds to wait before retrying the request. + example: 42 + required: true + description: Seconds to wait before retrying the request. + content: + application/json: + schema: + $ref: "#/components/schemas/ApiErrorEnvelope" + examples: + serviceUnavailable: + summary: SERVICE_UNAVAILABLE + value: + error: + code: SERVICE_UNAVAILABLE + type: service_unavailable + message: A dependency the operation must consult (billing, the idempotency store) is temporarily unavailable, so the request was refused rather than run unmetered. + suggestion: Retry the request after a short delay. + docs: https://docs.brew.new/api-reference/api/errors + x-brew-scope: contacts + x-brew-brand-scope: brand + x-brew-idempotency: replay + x-brew-credited: true + get: + operationId: listContacts + tags: + - Contacts + summary: List contacts + description: |- + Lists the brand’s contacts under `{ data, pagination }`, newest first by default. + + **Use when** paging through contacts, running a free-text search, or reading one saved audience’s members. Typed filter clauses and counts are `searchContacts`. + + **Input** `search` (a whole email address matches only that contact, with its `+` encoded as `%2B`; other text matches any of its words in email, first and last name), `audienceId` (only that saved audience’s members), `sort` (any core column or custom field, default `createdAt`) and `order`, `limit` and `cursor`. + + **Returns** `200` with a page of `Contact` rows. + + **Errors** `400 INVALID_REQUEST` for an unknown `audienceId`, an unsupported `sort` field or an invalid cursor. + + **See also** `getContact`, `searchContacts`, `upsertContacts`, `listContactFields`. + parameters: + - schema: + type: string + minLength: 1 + description: A whole email address matches only that contact. Other text matches the contacts whose email, first or last name contains any of its words, in `sort` order. Encode an address's `+` as `%2B`. + required: false + description: A whole email address matches only that contact. Other text matches the contacts whose email, first or last name contains any of its words, in `sort` order. Encode an address's `+` as `%2B`. + name: search + in: query + - schema: + type: string + minLength: 1 + maxLength: 64 + description: Only members of this saved audience. + required: false + description: Only members of this saved audience. + name: audienceId + in: query + - schema: + type: string + minLength: 1 + default: createdAt + required: false + name: sort + in: query + - schema: + type: string + enum: + - asc + - desc + default: desc + required: false + name: order + in: query + - schema: + type: integer + minimum: 1 + maximum: 100 + description: Page size (1-100). Defaults to 100. + example: 50 + required: false + description: Page size (1-100). Defaults to 100. + name: limit + in: query + example: 50 + - schema: + type: string + minLength: 1 + maxLength: 8192 + required: false + name: cursor + in: query + - name: X-Brand-Id + in: header + required: false + description: The brand this request acts on. REQUIRED for organization-scoped credentials (otherwise `400 BRAND_ID_REQUIRED` — there is no default brand); list ids with `GET /v1/brands`. Brand-scoped credentials may omit it, and sending a different brand returns `403 BRAND_SCOPE_MISMATCH`. A brand outside your organization returns `404 BRAND_NOT_FOUND`. + schema: + type: string + minLength: 1 + maxLength: 64 + example: kx7b3s7fapqz8mjm12ekz1kxdx87yceg + responses: + "200": + description: A page of contacts. + headers: + x-request-id: + schema: + type: string + description: Unique request identifier. Share this with support when debugging a request. + example: req_8cac13fd94e6420cacdd75a1aa403a28 + required: true + description: Unique request identifier. Share this with support when debugging a request. + X-RateLimit-Limit: + schema: + type: integer + description: Requests allowed in the current rolling rate limit window. + example: 100 + required: true + description: Requests allowed in the current rolling rate limit window. + X-RateLimit-Remaining: + schema: + type: integer + description: Requests remaining in the current rolling rate limit window. + example: 99 + required: true + description: Requests remaining in the current rolling rate limit window. + X-RateLimit-Reset: + schema: + type: integer + description: Unix timestamp in seconds when the oldest recorded request leaves the rolling window. + example: 1712592360 + required: true + description: Unix timestamp in seconds when the oldest recorded request leaves the rolling window. + content: + application/json: + schema: + $ref: "#/components/schemas/ContactsListResponse" + example: + data: + - email: jane@example.com + firstName: Jane + lastName: Doe + subscribed: true + validationStatus: valid + lastValidatedAt: '2026-04-08T12:01:00.000Z' + suppressed: false + suppressedReason: null + consent: + source: form + capturedAt: '2026-04-08T12:00:00.000Z' + policyVersion: 2026-03 + createdAt: '2026-04-08T12:00:00.000Z' + updatedAt: '2026-04-08T12:05:00.000Z' + importId: null + customFields: + plan: enterprise + revenue: 4200 + - email: john@example.com + firstName: John + lastName: Smith + subscribed: true + validationStatus: valid + lastValidatedAt: '2026-04-08T12:01:00.000Z' + suppressed: false + suppressedReason: null + consent: + source: form + capturedAt: '2026-04-08T12:00:00.000Z' + policyVersion: 2026-03 + createdAt: '2026-04-08T12:00:00.000Z' + updatedAt: '2026-04-08T12:05:00.000Z' + importId: null + customFields: + plan: starter + pagination: + limit: 50 + cursor: eyJsYXN0SWQiOiIxMjMiLCJsYXN0U29ydCI6MTcxMjU5MjAwMDAwMH0= + hasMore: true + "400": + description: |- + `BRAND_ID_REQUIRED`: An organization-scoped credential called a brand-scoped operation without naming the brand. + + `INVALID_REQUEST`: The body or query failed validation: an unknown key, a wrong type, or a missing required field. `param` names the offender. + headers: + x-request-id: + schema: + type: string + description: Unique request identifier. Share this with support when debugging a request. + example: req_8cac13fd94e6420cacdd75a1aa403a28 + required: true + description: Unique request identifier. Share this with support when debugging a request. + content: + application/json: + schema: + $ref: "#/components/schemas/ApiErrorEnvelope" + examples: + brandIdRequired: + summary: BRAND_ID_REQUIRED + value: + error: + code: BRAND_ID_REQUIRED + type: invalid_request + message: An organization-scoped credential called a brand-scoped operation without naming the brand. + suggestion: List brands with GET /v1/brands, then name one with the X-Brand-Id header. There is no default brand. + docs: https://docs.brew.new/api-reference/api/authentication + invalidRequest: + summary: INVALID_REQUEST + value: + error: + code: INVALID_REQUEST + type: invalid_request + message: "The body or query failed validation: an unknown key, a wrong type, or a missing required field. `param` names the offender." + suggestion: Fix the field reported in `param` and retry. + docs: https://docs.brew.new/api-reference/api/errors + "401": + description: |- + `API_KEY_REVOKED`: The API key was revoked. + + `AUTHENTICATION_REQUIRED`: No API key or session accompanied the request. + + `INVALID_API_KEY`: The API key is malformed or unknown. + headers: + x-request-id: + schema: + type: string + description: Unique request identifier. Share this with support when debugging a request. + example: req_8cac13fd94e6420cacdd75a1aa403a28 + required: true + description: Unique request identifier. Share this with support when debugging a request. + content: + application/json: + schema: + $ref: "#/components/schemas/ApiErrorEnvelope" + examples: + apiKeyRevoked: + summary: API_KEY_REVOKED + value: + error: + code: API_KEY_REVOKED + type: authentication_error + message: The API key was revoked. + suggestion: Create a new active API key and retry. + docs: https://docs.brew.new/api-reference/api/authentication + authenticationRequired: + summary: AUTHENTICATION_REQUIRED + value: + error: + code: AUTHENTICATION_REQUIRED + type: authentication_error + message: No API key or session accompanied the request. + suggestion: Provide a valid API key or sign in with an organization session. + docs: https://docs.brew.new/api-reference/api/authentication + invalidApiKey: + summary: INVALID_API_KEY + value: + error: + code: INVALID_API_KEY + type: authentication_error + message: The API key is malformed or unknown. + suggestion: Check the API key format and retry with a valid active key. + docs: https://docs.brew.new/api-reference/api/authentication + "403": + description: |- + `ACCOUNT_SUSPENDED`: The organization behind the credential is suspended. + + `BRAND_SCOPE_MISMATCH`: A brand-scoped credential named a brand other than the one it is bound to. + + `INSUFFICIENT_PERMISSIONS`: The credential lacks the permission scope the operation needs. + + `INSUFFICIENT_ROLE`: The caller lacks the access the operation needs: `param` names `member` (access to the brand) or `org_admin` (the organization role). + headers: + x-request-id: + schema: + type: string + description: Unique request identifier. Share this with support when debugging a request. + example: req_8cac13fd94e6420cacdd75a1aa403a28 + required: true + description: Unique request identifier. Share this with support when debugging a request. + content: + application/json: + schema: + $ref: "#/components/schemas/ApiErrorEnvelope" + examples: + accountSuspended: + summary: ACCOUNT_SUSPENDED + value: + error: + code: ACCOUNT_SUSPENDED + type: authorization_error + message: The organization behind the credential is suspended. + suggestion: The organization behind this credential is suspended. Contact support@brew.new; do not retry. + docs: https://docs.brew.new/api-reference/api/authentication + brandScopeMismatch: + summary: BRAND_SCOPE_MISMATCH + value: + error: + code: BRAND_SCOPE_MISMATCH + type: authorization_error + message: A brand-scoped credential named a brand other than the one it is bound to. + suggestion: Omit the brand to use the one this credential is bound to, or use an organization-scoped credential to reach other brands. + docs: https://docs.brew.new/api-reference/api/authentication + insufficientPermissions: + summary: INSUFFICIENT_PERMISSIONS + value: + error: + code: INSUFFICIENT_PERMISSIONS + type: authorization_error + message: The credential lacks the permission scope the operation needs. + suggestion: Use an API key or session with the required permission. + docs: https://docs.brew.new/api-reference/api/authentication + insufficientRole: + summary: INSUFFICIENT_ROLE + value: + error: + code: INSUFFICIENT_ROLE + type: authorization_error + message: "The caller lacks the access the operation needs: `param` names `member` (access to the brand) or `org_admin` (the organization role)." + suggestion: Ask an organization admin to run this, to add you to the brand, or to make you an admin. + docs: https://docs.brew.new/api-reference/api/authentication + "404": + description: "`BRAND_NOT_FOUND`: The named or bound brand does not exist in this organization (unknown, deleting, or another organization)." + headers: + x-request-id: + schema: + type: string + description: Unique request identifier. Share this with support when debugging a request. + example: req_8cac13fd94e6420cacdd75a1aa403a28 + required: true + description: Unique request identifier. Share this with support when debugging a request. + content: + application/json: + schema: + $ref: "#/components/schemas/ApiErrorEnvelope" + examples: + brandNotFound: + summary: BRAND_NOT_FOUND + value: + error: + code: BRAND_NOT_FOUND + type: not_found + message: The named or bound brand does not exist in this organization (unknown, deleting, or another organization). + suggestion: List the brands this credential can reach with GET /v1/brands. + docs: https://docs.brew.new/api-reference/api/errors + "429": + description: "`RATE_LIMITED`: The credential exhausted the rolling window for this route policy; Retry-After is when the oldest recorded request leaves, and the refused request does not take a slot." + headers: + x-request-id: + schema: + type: string + description: Unique request identifier. Share this with support when debugging a request. + example: req_8cac13fd94e6420cacdd75a1aa403a28 + required: true + description: Unique request identifier. Share this with support when debugging a request. + X-RateLimit-Limit: + schema: + type: integer + description: Requests allowed in the current rolling rate limit window. + example: 100 + required: true + description: Requests allowed in the current rolling rate limit window. + X-RateLimit-Remaining: + schema: + type: integer + description: Requests remaining in the current rolling rate limit window. + example: 0 + required: true + description: Requests remaining in the current rolling rate limit window. + X-RateLimit-Reset: + schema: + type: integer + description: Unix timestamp in seconds when the oldest recorded request leaves the rolling window. + example: 1712592360 + required: true + description: Unix timestamp in seconds when the oldest recorded request leaves the rolling window. + Retry-After: + schema: + type: integer + description: Seconds to wait before retrying the request. + example: 42 + required: true + description: Seconds to wait before retrying the request. + content: + application/json: + schema: + $ref: "#/components/schemas/ApiErrorEnvelope" + examples: + rateLimited: + summary: RATE_LIMITED + value: + error: + code: RATE_LIMITED + type: rate_limit + message: The credential exhausted the rolling window for this route policy; Retry-After is when the oldest recorded request leaves, and the refused request does not take a slot. + suggestion: Wait the Retry-After time, then retry the same request. + docs: https://docs.brew.new/api-reference/api/rate-limits + retryAfter: 42 + details: + scope: credential + limit: 100 remaining: 0 reset: 1712592360 consumed: false @@ -45939,110 +46702,29 @@ paths: message: An unexpected failure; the x-request-id header identifies it. suggestion: Retry the request. If it keeps failing, contact support with the x-request-id header. docs: https://docs.brew.new/api-reference/api/errors - "503": - description: "`SERVICE_UNAVAILABLE`: A dependency the operation must consult (billing, the idempotency store) is temporarily unavailable, so the request was refused rather than run unmetered." - headers: - x-request-id: - schema: - type: string - description: Unique request identifier. Share this with support when debugging a request. - example: req_8cac13fd94e6420cacdd75a1aa403a28 - required: true - description: Unique request identifier. Share this with support when debugging a request. - Retry-After: - schema: - type: integer - description: Seconds to wait before retrying the request. - example: 42 - required: true - description: Seconds to wait before retrying the request. - content: - application/json: - schema: - $ref: "#/components/schemas/ApiErrorEnvelope" - examples: - serviceUnavailable: - summary: SERVICE_UNAVAILABLE - value: - error: - code: SERVICE_UNAVAILABLE - type: service_unavailable - message: A dependency the operation must consult (billing, the idempotency store) is temporarily unavailable, so the request was refused rather than run unmetered. - suggestion: Retry the request after a short delay. - docs: https://docs.brew.new/api-reference/api/errors x-brew-scope: contacts x-brew-brand-scope: brand - x-brew-idempotency: replay - x-brew-credited: true - get: - operationId: listContacts + x-brew-idempotency: none + x-brew-credited: false + x-brew-rate-policy: contacts.read + /v1/contacts/{email}: + patch: + operationId: updateContact tags: - Contacts - summary: List contacts - description: |- - Lists the brand’s contacts under `{ data, pagination }`, newest first by default. - - **Use when** paging through contacts, running a free-text search, or reading one saved audience’s members. Typed filter clauses and counts are `searchContacts`. - - **Input** `search` (a whole email address matches only that contact, with its `+` encoded as `%2B`; other text matches any of its words in email, first and last name), `audienceId` (only that saved audience’s members), `sort` (any core column or custom field, default `createdAt`) and `order`, `limit` and `cursor`. - - **Returns** `200` with a page of `Contact` rows. - - **Errors** `400 INVALID_REQUEST` for an unknown `audienceId`, an unsupported `sort` field or an invalid cursor. - - **See also** `getContact`, `searchContacts`, `upsertContacts`, `listContactFields`. + summary: Update a contact + description: "Patches fields on the contact (`{ fields: { : } }` — core columns or custom fields) and/or records marketing consent provenance (`{ consent: { source, capturedAt?, policyVersion?, evidence? } }`, never re-subscribing an opt-out). Send one or both. Returns `{ contact, updated }` where `updated` lists the field names that changed (`consent` included). `fields.subscribed: false` unsubscribes; `true` on a contact who opted out is a `422 RESUBSCRIBE_NOT_ALLOWED` (they re-subscribe through their own action, or from their contact page in the Brew app)." parameters: - schema: type: string minLength: 1 - description: A whole email address matches only that contact. Other text matches the contacts whose email, first or last name contains any of its words, in `sort` order. Encode an address's `+` as `%2B`. - required: false - description: A whole email address matches only that contact. Other text matches the contacts whose email, first or last name contains any of its words, in `sort` order. Encode an address's `+` as `%2B`. - name: search - in: query - - schema: - type: string - minLength: 1 - maxLength: 64 - description: Only members of this saved audience. - required: false - description: Only members of this saved audience. - name: audienceId - in: query - - schema: - type: string - minLength: 1 - default: createdAt - required: false - name: sort - in: query - - schema: - type: string - enum: - - asc - - desc - default: desc - required: false - name: order - in: query - - schema: - type: integer - minimum: 1 - maximum: 100 - description: Page size (1-100). Defaults to 100. - example: 50 - required: false - description: Page size (1-100). Defaults to 100. - name: limit - in: query - example: 50 - - schema: - type: string - minLength: 1 - maxLength: 8192 - required: false - name: cursor - in: query + maxLength: 320 + description: The contact’s email address (URL-encoded). Email is the contact primary key. + example: jane%40example.com + required: true + description: The contact’s email address (URL-encoded). Email is the contact primary key. + name: email + in: path - name: X-Brand-Id in: header required: false @@ -46052,9 +46734,29 @@ paths: minLength: 1 maxLength: 64 example: kx7b3s7fapqz8mjm12ekz1kxdx87yceg + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/ContactsPatchRequest" + examples: + fields: + summary: Change fields + value: + fields: + firstName: Janet + plan: scale + consent: + summary: Record consent + value: + consent: + source: api + policyVersion: 2026-03 + evidence: imported from the CRM opt-in list responses: "200": - description: A page of contacts. + description: Patched. headers: x-request-id: schema: @@ -46087,48 +46789,31 @@ paths: content: application/json: schema: - $ref: "#/components/schemas/ContactsListResponse" + $ref: "#/components/schemas/ContactsPatchResponse" example: - data: - - email: jane@example.com - firstName: Jane - lastName: Doe - subscribed: true - validationStatus: valid - lastValidatedAt: '2026-04-08T12:01:00.000Z' - suppressed: false - suppressedReason: null - consent: - source: form - capturedAt: '2026-04-08T12:00:00.000Z' - policyVersion: 2026-03 - createdAt: '2026-04-08T12:00:00.000Z' - updatedAt: '2026-04-08T12:05:00.000Z' - importId: null - customFields: - plan: enterprise - revenue: 4200 - - email: john@example.com - firstName: John - lastName: Smith - subscribed: true - validationStatus: valid - lastValidatedAt: '2026-04-08T12:01:00.000Z' - suppressed: false - suppressedReason: null - consent: - source: form - capturedAt: '2026-04-08T12:00:00.000Z' - policyVersion: 2026-03 - createdAt: '2026-04-08T12:00:00.000Z' - updatedAt: '2026-04-08T12:05:00.000Z' - importId: null - customFields: - plan: starter - pagination: - limit: 50 - cursor: eyJsYXN0SWQiOiIxMjMiLCJsYXN0U29ydCI6MTcxMjU5MjAwMDAwMH0= - hasMore: true + contact: + email: jane@example.com + firstName: Janet + lastName: Doe + subscribed: true + validationStatus: valid + lastValidatedAt: '2026-04-08T12:01:00.000Z' + suppressed: false + suppressedReason: null + consent: + source: form + capturedAt: '2026-04-08T12:00:00.000Z' + policyVersion: 2026-03 + createdAt: '2026-04-08T12:00:00.000Z' + updatedAt: '2026-04-08T12:05:00.000Z' + importId: null + customFields: + plan: enterprise + revenue: 4200 + updated: + - firstName + - plan + - consent "400": description: |- `BRAND_ID_REQUIRED`: An organization-scoped credential called a brand-scoped operation without naming the brand. @@ -46271,7 +46956,12 @@ paths: suggestion: Ask an organization admin to run this, to add you to the brand, or to make you an admin. docs: https://docs.brew.new/api-reference/api/authentication "404": - description: "`BRAND_NOT_FOUND`: The named or bound brand does not exist in this organization (unknown, deleting, or another organization)." + description: |- + `BRAND_NOT_FOUND`: The named or bound brand does not exist in this organization (unknown, deleting, or another organization). + + `CONTACT_NOT_FOUND`: No contact with that email exists in the brand. + + `FIELD_NOT_FOUND`: No custom field definition with that name exists in the brand. headers: x-request-id: schema: @@ -46294,6 +46984,95 @@ paths: message: The named or bound brand does not exist in this organization (unknown, deleting, or another organization). suggestion: List the brands this credential can reach with GET /v1/brands. docs: https://docs.brew.new/api-reference/api/errors + contactNotFound: + summary: CONTACT_NOT_FOUND + value: + error: + code: CONTACT_NOT_FOUND + type: not_found + message: No contact with that email exists in the brand. + suggestion: Create the contact first with POST /v1/contacts. + docs: https://docs.brew.new/api-reference/api/errors + fieldNotFound: + summary: FIELD_NOT_FOUND + value: + error: + code: FIELD_NOT_FOUND + type: not_found + message: No custom field definition with that name exists in the brand. + suggestion: Create the field first with POST /v1/fields or use a field that already exists. + docs: https://docs.brew.new/api-reference/api/errors + "409": + description: "`FIELD_TYPE_MISMATCH`: A value does not match the declared type of its custom field." + headers: + x-request-id: + schema: + type: string + description: Unique request identifier. Share this with support when debugging a request. + example: req_8cac13fd94e6420cacdd75a1aa403a28 + required: true + description: Unique request identifier. Share this with support when debugging a request. + content: + application/json: + schema: + $ref: "#/components/schemas/ApiErrorEnvelope" + examples: + fieldTypeMismatch: + summary: FIELD_TYPE_MISMATCH + value: + error: + code: FIELD_TYPE_MISMATCH + type: conflict + message: A value does not match the declared type of its custom field. + suggestion: Send a value that matches the field's type. To change the type, delete the field (this clears its value on every contact) and create it again. + docs: https://docs.brew.new/api-reference/api/errors + "422": + description: |- + `BATCH_TOO_LARGE`: A batch write exceeds the per-request row cap. + + `CORE_FIELD_IMMUTABLE`: The field is a core contact column and cannot be created, changed, or deleted as a custom field. + + `RESUBSCRIBE_NOT_ALLOWED`: The write set `subscribed` to true on a contact who unsubscribed, and Brew never re-subscribes an opt-out through the API, MCP or an import; `details.email` names the contact. + headers: + x-request-id: + schema: + type: string + description: Unique request identifier. Share this with support when debugging a request. + example: req_8cac13fd94e6420cacdd75a1aa403a28 + required: true + description: Unique request identifier. Share this with support when debugging a request. + content: + application/json: + schema: + $ref: "#/components/schemas/ApiErrorEnvelope" + examples: + batchTooLarge: + summary: BATCH_TOO_LARGE + value: + error: + code: BATCH_TOO_LARGE + type: invalid_request + message: A batch write exceeds the per-request row cap. + suggestion: Split the request into smaller batches and retry. + docs: https://docs.brew.new/api-reference/api/errors + coreFieldImmutable: + summary: CORE_FIELD_IMMUTABLE + value: + error: + code: CORE_FIELD_IMMUTABLE + type: invalid_request + message: The field is a core contact column and cannot be created, changed, or deleted as a custom field. + suggestion: Use a custom field name instead. + docs: https://docs.brew.new/api-reference/api/errors + resubscribeNotAllowed: + summary: RESUBSCRIBE_NOT_ALLOWED + value: + error: + code: RESUBSCRIBE_NOT_ALLOWED + type: invalid_request + message: The write set `subscribed` to true on a contact who unsubscribed, and Brew never re-subscribes an opt-out through the API, MCP or an import; `details.email` names the contact. + suggestion: Drop `subscribed` from the write; a contact who opted out re-subscribes through their own action, or from their contact page in the Brew app once they ask. + docs: https://docs.brew.new/api-reference/api/errors "429": description: "`RATE_LIMITED`: The credential exhausted the rolling window for this route policy; Retry-After is when the oldest recorded request leaves, and the refused request does not take a slot." headers: @@ -46381,14 +47160,13 @@ paths: x-brew-brand-scope: brand x-brew-idempotency: none x-brew-credited: false - x-brew-rate-policy: contacts.read - /v1/contacts/{email}: - patch: - operationId: updateContact + x-brew-rate-policy: contacts.write_single + delete: + operationId: deleteContact tags: - Contacts - summary: Update a contact - description: "Patches fields on the contact (`{ fields: { : } }` — core columns or custom fields) and/or records marketing consent provenance (`{ consent: { source, capturedAt?, policyVersion?, evidence? } }`, never re-subscribing an opt-out). Send one or both. Returns `{ contact, updated }` where `updated` lists the field names that changed (`consent` included). `fields.subscribed: false` unsubscribes; `true` on a contact who opted out is a `422 RESUBSCRIBE_NOT_ALLOWED` (they re-subscribe through their own action, or from their contact page in the Brew app)." + summary: Delete a contact + description: "Deletes the contact. Idempotent — an unknown email resolves with `deleted: false`. For bulk deletion use `POST /v1/contacts/batch-delete`." parameters: - schema: type: string @@ -46409,29 +47187,9 @@ paths: minLength: 1 maxLength: 64 example: kx7b3s7fapqz8mjm12ekz1kxdx87yceg - requestBody: - required: true - content: - application/json: - schema: - $ref: "#/components/schemas/ContactsPatchRequest" - examples: - fields: - summary: Change fields - value: - fields: - firstName: Janet - plan: scale - consent: - summary: Record consent - value: - consent: - source: api - policyVersion: 2026-03 - evidence: imported from the CRM opt-in list responses: "200": - description: Patched. + description: Deleted (or already gone). headers: x-request-id: schema: @@ -46464,31 +47222,10 @@ paths: content: application/json: schema: - $ref: "#/components/schemas/ContactsPatchResponse" + $ref: "#/components/schemas/ContactDeleteResponse" example: - contact: - email: jane@example.com - firstName: Janet - lastName: Doe - subscribed: true - validationStatus: valid - lastValidatedAt: '2026-04-08T12:01:00.000Z' - suppressed: false - suppressedReason: null - consent: - source: form - capturedAt: '2026-04-08T12:00:00.000Z' - policyVersion: 2026-03 - createdAt: '2026-04-08T12:00:00.000Z' - updatedAt: '2026-04-08T12:05:00.000Z' - importId: null - customFields: - plan: enterprise - revenue: 4200 - updated: - - firstName - - plan - - consent + email: jane@example.com + deleted: true "400": description: |- `BRAND_ID_REQUIRED`: An organization-scoped credential called a brand-scoped operation without naming the brand. @@ -46631,12 +47368,7 @@ paths: suggestion: Ask an organization admin to run this, to add you to the brand, or to make you an admin. docs: https://docs.brew.new/api-reference/api/authentication "404": - description: |- - `BRAND_NOT_FOUND`: The named or bound brand does not exist in this organization (unknown, deleting, or another organization). - - `CONTACT_NOT_FOUND`: No contact with that email exists in the brand. - - `FIELD_NOT_FOUND`: No custom field definition with that name exists in the brand. + description: "`BRAND_NOT_FOUND`: The named or bound brand does not exist in this organization (unknown, deleting, or another organization)." headers: x-request-id: schema: @@ -46659,95 +47391,6 @@ paths: message: The named or bound brand does not exist in this organization (unknown, deleting, or another organization). suggestion: List the brands this credential can reach with GET /v1/brands. docs: https://docs.brew.new/api-reference/api/errors - contactNotFound: - summary: CONTACT_NOT_FOUND - value: - error: - code: CONTACT_NOT_FOUND - type: not_found - message: No contact with that email exists in the brand. - suggestion: Create the contact first with POST /v1/contacts. - docs: https://docs.brew.new/api-reference/api/errors - fieldNotFound: - summary: FIELD_NOT_FOUND - value: - error: - code: FIELD_NOT_FOUND - type: not_found - message: No custom field definition with that name exists in the brand. - suggestion: Create the field first with POST /v1/fields or use a field that already exists. - docs: https://docs.brew.new/api-reference/api/errors - "409": - description: "`FIELD_TYPE_MISMATCH`: A value does not match the declared type of its custom field." - headers: - x-request-id: - schema: - type: string - description: Unique request identifier. Share this with support when debugging a request. - example: req_8cac13fd94e6420cacdd75a1aa403a28 - required: true - description: Unique request identifier. Share this with support when debugging a request. - content: - application/json: - schema: - $ref: "#/components/schemas/ApiErrorEnvelope" - examples: - fieldTypeMismatch: - summary: FIELD_TYPE_MISMATCH - value: - error: - code: FIELD_TYPE_MISMATCH - type: conflict - message: A value does not match the declared type of its custom field. - suggestion: Send a value that matches the field's type. To change the type, delete the field (this clears its value on every contact) and create it again. - docs: https://docs.brew.new/api-reference/api/errors - "422": - description: |- - `BATCH_TOO_LARGE`: A batch write exceeds the per-request row cap. - - `CORE_FIELD_IMMUTABLE`: The field is a core contact column and cannot be created, changed, or deleted as a custom field. - - `RESUBSCRIBE_NOT_ALLOWED`: The write set `subscribed` to true on a contact who unsubscribed, and Brew never re-subscribes an opt-out through the API, MCP or an import; `details.email` names the contact. - headers: - x-request-id: - schema: - type: string - description: Unique request identifier. Share this with support when debugging a request. - example: req_8cac13fd94e6420cacdd75a1aa403a28 - required: true - description: Unique request identifier. Share this with support when debugging a request. - content: - application/json: - schema: - $ref: "#/components/schemas/ApiErrorEnvelope" - examples: - batchTooLarge: - summary: BATCH_TOO_LARGE - value: - error: - code: BATCH_TOO_LARGE - type: invalid_request - message: A batch write exceeds the per-request row cap. - suggestion: Split the request into smaller batches and retry. - docs: https://docs.brew.new/api-reference/api/errors - coreFieldImmutable: - summary: CORE_FIELD_IMMUTABLE - value: - error: - code: CORE_FIELD_IMMUTABLE - type: invalid_request - message: The field is a core contact column and cannot be created, changed, or deleted as a custom field. - suggestion: Use a custom field name instead. - docs: https://docs.brew.new/api-reference/api/errors - resubscribeNotAllowed: - summary: RESUBSCRIBE_NOT_ALLOWED - value: - error: - code: RESUBSCRIBE_NOT_ALLOWED - type: invalid_request - message: The write set `subscribed` to true on a contact who unsubscribed, and Brew never re-subscribes an opt-out through the API, MCP or an import; `details.email` names the contact. - suggestion: Drop `subscribed` from the write; a contact who opted out re-subscribes through their own action, or from their contact page in the Brew app once they ask. - docs: https://docs.brew.new/api-reference/api/errors "429": description: "`RATE_LIMITED`: The credential exhausted the rolling window for this route policy; Retry-After is when the oldest recorded request leaves, and the refused request does not take a slot." headers: @@ -46836,12 +47479,23 @@ paths: x-brew-idempotency: none x-brew-credited: false x-brew-rate-policy: contacts.write_single - delete: - operationId: deleteContact + get: + operationId: getContact tags: - Contacts - summary: Delete a contact - description: "Deletes the contact. Idempotent — an unknown email resolves with `deleted: false`. For bulk deletion use `POST /v1/contacts/batch-delete`." + summary: Get a contact + description: |- + Reads one contact as the bare `Contact` row: core columns, `subscribed`, `consent`, validation state and `customFields`. + + **Use when** checking whether an address exists, reading its consent record, or inspecting its custom fields before a send. + + **Input** `email` in the path (URL-encoded; matched case-insensitively). `include=openProfile` attaches `openProfile`: the smart-send open-time profile, 48 UTC half-hour open counts (`histogram`), `totalOpens`, `lastOpenedAt`, and once there is enough history `bestOpenMinuteUtc`, `bestSendMinuteUtc` and `confidence`; `null` when the contact has no opens folded yet. It is not bot detection: machine opens cannot be told apart in it (per-event classification is on `getEventsAnalytics`). It needs the `emails` scope as well as `contacts`. + + **Returns** `200` with the row. + + **Errors** `404 CONTACT_NOT_FOUND` when no contact carries that address; `403 INSUFFICIENT_PERMISSIONS` when `include=openProfile` is asked for without the `emails` scope; `400 INVALID_REQUEST` for an unknown `include` token. + + **See also** `listContacts`, `updateContact`, `deleteContact`, `upsertContacts`. parameters: - schema: type: string @@ -46853,6 +47507,15 @@ paths: description: The contact’s email address (URL-encoded). Email is the contact primary key. name: email in: path + - schema: + type: string + description: "Expansions: `openProfile` attaches the contact's smart-send open-time profile (needs the `emails` permission as well)." + x-brew-include-tokens: + - openProfile + required: false + description: "Expansions: `openProfile` attaches the contact's smart-send open-time profile (needs the `emails` permission as well)." + name: include + in: query - name: X-Brand-Id in: header required: false @@ -46864,7 +47527,7 @@ paths: example: kx7b3s7fapqz8mjm12ekz1kxdx87yceg responses: "200": - description: Deleted (or already gone). + description: The contact row, with `openProfile` when `include=openProfile`. headers: x-request-id: schema: @@ -46897,10 +47560,26 @@ paths: content: application/json: schema: - $ref: "#/components/schemas/ContactDeleteResponse" + $ref: "#/components/schemas/ContactWithOpenProfile" example: email: jane@example.com - deleted: true + firstName: Jane + lastName: Doe + subscribed: true + validationStatus: valid + lastValidatedAt: '2026-04-08T12:01:00.000Z' + suppressed: false + suppressedReason: null + consent: + source: form + capturedAt: '2026-04-08T12:00:00.000Z' + policyVersion: 2026-03 + createdAt: '2026-04-08T12:00:00.000Z' + updatedAt: '2026-04-08T12:05:00.000Z' + importId: null + customFields: + plan: enterprise + revenue: 4200 "400": description: |- `BRAND_ID_REQUIRED`: An organization-scoped credential called a brand-scoped operation without naming the brand. @@ -47043,7 +47722,10 @@ paths: suggestion: Ask an organization admin to run this, to add you to the brand, or to make you an admin. docs: https://docs.brew.new/api-reference/api/authentication "404": - description: "`BRAND_NOT_FOUND`: The named or bound brand does not exist in this organization (unknown, deleting, or another organization)." + description: |- + `BRAND_NOT_FOUND`: The named or bound brand does not exist in this organization (unknown, deleting, or another organization). + + `CONTACT_NOT_FOUND`: No contact with that email exists in the brand. headers: x-request-id: schema: @@ -47066,6 +47748,15 @@ paths: message: The named or bound brand does not exist in this organization (unknown, deleting, or another organization). suggestion: List the brands this credential can reach with GET /v1/brands. docs: https://docs.brew.new/api-reference/api/errors + contactNotFound: + summary: CONTACT_NOT_FOUND + value: + error: + code: CONTACT_NOT_FOUND + type: not_found + message: No contact with that email exists in the brand. + suggestion: Create the contact first with POST /v1/contacts. + docs: https://docs.brew.new/api-reference/api/errors "429": description: "`RATE_LIMITED`: The credential exhausted the rolling window for this route policy; Retry-After is when the oldest recorded request leaves, and the refused request does not take a slot." headers: @@ -47153,56 +47844,51 @@ paths: x-brew-brand-scope: brand x-brew-idempotency: none x-brew-credited: false - x-brew-rate-policy: contacts.write_single - get: - operationId: getContact + x-brew-rate-policy: contacts.read + /v1/contacts/search: + post: + operationId: searchContacts tags: - Contacts - summary: Get a contact + summary: Get contacts description: |- - Reads one contact as the bare `Contact` row: core columns, `subscribed`, `consent`, validation state and `customFields`. - - **Use when** checking whether an address exists, reading its consent record, or inspecting its custom fields before a send. + The single "Get Contacts" read. Structured search over the brand’s contacts: free-text `search`, `filters` (`{ field, operator, value }` combined with `logic: "and" | "or" | "none"`; the allowed operators depend on the field’s type — see the `operator` schema — and an unsupported pairing is a `400`, never a dropped clause), `sort` + `order`, and cursor pagination. Returns `{ data, pagination }`. - **Input** `email` in the path (URL-encoded; matched case-insensitively). `include=openProfile` attaches `openProfile`: the smart-send open-time profile, 48 UTC half-hour open counts (`histogram`), `totalOpens`, `lastOpenedAt`, and once there is enough history `bestOpenMinuteUtc`, `bestSendMinuteUtc` and `confidence`; `null` when the contact has no opens folded yet. It is not bot detection: machine opens cannot be told apart in it (per-event classification is on `getEventsAnalytics`). It needs the `emails` scope as well as `contacts`. + `search` with a whole email address matches only that contact, like `filters: [{ field: "email", operator: "equals", value: "…" }]` or `GET /v1/contacts/{email}`. Any other `search` text matches the contacts whose email, first or last name contains any of its words, which can include other contacts, in `sort` order, so act on a contact found by its address. Omit every filter to list everything. - **Returns** `200` with the row. + Pass an optional `audienceId` to scope the search to a saved audience’s members — its stored filter set is evaluated as one unit with its own `logicalOperator` (an OR audience stays an OR) and then ANDed with `filters`, so the page is exactly who a send to that audience reaches (an unknown / cross-brand id, or an audience a send would refuse → `400`). - **Errors** `404 CONTACT_NOT_FOUND` when no contact carries that address; `403 INSUFFICIENT_PERMISSIONS` when `include=openProfile` is asked for without the `emails` scope; `400 INVALID_REQUEST` for an unknown `include` token. + Set `count: true` to get `{ count }` instead of a page. - **See also** `listContacts`, `updateContact`, `deleteContact`, `upsertContacts`. - parameters: - - schema: - type: string - minLength: 1 - maxLength: 320 - description: The contact’s email address (URL-encoded). Email is the contact primary key. - example: jane%40example.com - required: true - description: The contact’s email address (URL-encoded). Email is the contact primary key. - name: email - in: path - - schema: - type: string - description: "Expansions: `openProfile` attaches the contact's smart-send open-time profile (needs the `emails` permission as well)." - x-brew-include-tokens: - - openProfile - required: false - description: "Expansions: `openProfile` attaches the contact's smart-send open-time profile (needs the `emails` permission as well)." - name: include - in: query - - name: X-Brand-Id - in: header - required: false - description: The brand this request acts on. REQUIRED for organization-scoped credentials (otherwise `400 BRAND_ID_REQUIRED` — there is no default brand); list ids with `GET /v1/brands`. Brand-scoped credentials may omit it, and sending a different brand returns `403 BRAND_SCOPE_MISMATCH`. A brand outside your organization returns `404 BRAND_NOT_FOUND`. - schema: - type: string - minLength: 1 - maxLength: 64 - example: kx7b3s7fapqz8mjm12ekz1kxdx87yceg + `include: ["openProfile"]` attaches each returned contact’s smart-send open-time profile (`openProfile`, as on `getContact`; `null` when they have no opens yet). Each row costs one profile read, so a page then holds at most 10 contacts (`pagination.limit` reports the size read); it needs the `emails` scope as well (`403 INSUFFICIENT_PERMISSIONS` without it) and is refused with `count: true`. + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/ContactsSearchRequest" + examples: + filtered: + summary: Filter + sort + value: + filters: + - field: plan + operator: equals + value: enterprise + sort: createdAt + order: desc + limit: 50 + count: + summary: Count only + value: + filters: + - field: subscribed + operator: equals + value: 'true' + count: true responses: "200": - description: The contact row, with `openProfile` when `include=openProfile`. + description: "A page of matching contacts — or `{ count }` when `count: true`." headers: x-request-id: schema: @@ -47235,26 +47921,55 @@ paths: content: application/json: schema: - $ref: "#/components/schemas/ContactWithOpenProfile" - example: - email: jane@example.com - firstName: Jane - lastName: Doe - subscribed: true - validationStatus: valid - lastValidatedAt: '2026-04-08T12:01:00.000Z' - suppressed: false - suppressedReason: null - consent: - source: form - capturedAt: '2026-04-08T12:00:00.000Z' - policyVersion: 2026-03 - createdAt: '2026-04-08T12:00:00.000Z' - updatedAt: '2026-04-08T12:05:00.000Z' - importId: null - customFields: - plan: enterprise - revenue: 4200 + $ref: "#/components/schemas/ContactsSearchSuccessResponse" + examples: + page: + summary: Result page + value: + data: + - email: jane@example.com + firstName: Jane + lastName: Doe + subscribed: true + validationStatus: valid + lastValidatedAt: '2026-04-08T12:01:00.000Z' + suppressed: false + suppressedReason: null + consent: + source: form + capturedAt: '2026-04-08T12:00:00.000Z' + policyVersion: 2026-03 + createdAt: '2026-04-08T12:00:00.000Z' + updatedAt: '2026-04-08T12:05:00.000Z' + importId: null + customFields: + plan: enterprise + revenue: 4200 + - email: john@example.com + firstName: John + lastName: Smith + subscribed: true + validationStatus: valid + lastValidatedAt: '2026-04-08T12:01:00.000Z' + suppressed: false + suppressedReason: null + consent: + source: form + capturedAt: '2026-04-08T12:00:00.000Z' + policyVersion: 2026-03 + createdAt: '2026-04-08T12:00:00.000Z' + updatedAt: '2026-04-08T12:05:00.000Z' + importId: null + customFields: + plan: starter + pagination: + limit: 50 + cursor: eyJsYXN0SWQiOiIxMjMiLCJsYXN0U29ydCI6MTcxMjU5MjAwMDAwMH0= + hasMore: true + count: + summary: Count mode + value: + count: 1284 "400": description: |- `BRAND_ID_REQUIRED`: An organization-scoped credential called a brand-scoped operation without naming the brand. @@ -47397,10 +48112,7 @@ paths: suggestion: Ask an organization admin to run this, to add you to the brand, or to make you an admin. docs: https://docs.brew.new/api-reference/api/authentication "404": - description: |- - `BRAND_NOT_FOUND`: The named or bound brand does not exist in this organization (unknown, deleting, or another organization). - - `CONTACT_NOT_FOUND`: No contact with that email exists in the brand. + description: "`BRAND_NOT_FOUND`: The named or bound brand does not exist in this organization (unknown, deleting, or another organization)." headers: x-request-id: schema: @@ -47423,14 +48135,65 @@ paths: message: The named or bound brand does not exist in this organization (unknown, deleting, or another organization). suggestion: List the brands this credential can reach with GET /v1/brands. docs: https://docs.brew.new/api-reference/api/errors - contactNotFound: - summary: CONTACT_NOT_FOUND + "409": + description: "`FIELD_TYPE_MISMATCH`: A value does not match the declared type of its custom field." + headers: + x-request-id: + schema: + type: string + description: Unique request identifier. Share this with support when debugging a request. + example: req_8cac13fd94e6420cacdd75a1aa403a28 + required: true + description: Unique request identifier. Share this with support when debugging a request. + content: + application/json: + schema: + $ref: "#/components/schemas/ApiErrorEnvelope" + examples: + fieldTypeMismatch: + summary: FIELD_TYPE_MISMATCH value: error: - code: CONTACT_NOT_FOUND - type: not_found - message: No contact with that email exists in the brand. - suggestion: Create the contact first with POST /v1/contacts. + code: FIELD_TYPE_MISMATCH + type: conflict + message: A value does not match the declared type of its custom field. + suggestion: Send a value that matches the field's type. To change the type, delete the field (this clears its value on every contact) and create it again. + docs: https://docs.brew.new/api-reference/api/errors + "422": + description: |- + `BATCH_TOO_LARGE`: A batch write exceeds the per-request row cap. + + `CORE_FIELD_IMMUTABLE`: The field is a core contact column and cannot be created, changed, or deleted as a custom field. + headers: + x-request-id: + schema: + type: string + description: Unique request identifier. Share this with support when debugging a request. + example: req_8cac13fd94e6420cacdd75a1aa403a28 + required: true + description: Unique request identifier. Share this with support when debugging a request. + content: + application/json: + schema: + $ref: "#/components/schemas/ApiErrorEnvelope" + examples: + batchTooLarge: + summary: BATCH_TOO_LARGE + value: + error: + code: BATCH_TOO_LARGE + type: invalid_request + message: A batch write exceeds the per-request row cap. + suggestion: Split the request into smaller batches and retry. + docs: https://docs.brew.new/api-reference/api/errors + coreFieldImmutable: + summary: CORE_FIELD_IMMUTABLE + value: + error: + code: CORE_FIELD_IMMUTABLE + type: invalid_request + message: The field is a core contact column and cannot be created, changed, or deleted as a custom field. + suggestion: Use a custom field name instead. docs: https://docs.brew.new/api-reference/api/errors "429": description: "`RATE_LIMITED`: The credential exhausted the rolling window for this route policy; Retry-After is when the oldest recorded request leaves, and the refused request does not take a slot." @@ -47515,55 +48278,42 @@ paths: message: An unexpected failure; the x-request-id header identifies it. suggestion: Retry the request. If it keeps failing, contact support with the x-request-id header. docs: https://docs.brew.new/api-reference/api/errors + parameters: + - name: X-Brand-Id + in: header + required: false + description: The brand this request acts on. REQUIRED for organization-scoped credentials (otherwise `400 BRAND_ID_REQUIRED` — there is no default brand); list ids with `GET /v1/brands`. Brand-scoped credentials may omit it, and sending a different brand returns `403 BRAND_SCOPE_MISMATCH`. A brand outside your organization returns `404 BRAND_NOT_FOUND`. + schema: + type: string + minLength: 1 + maxLength: 64 + example: kx7b3s7fapqz8mjm12ekz1kxdx87yceg x-brew-scope: contacts x-brew-brand-scope: brand x-brew-idempotency: none x-brew-credited: false x-brew-rate-policy: contacts.read - /v1/contacts/search: + /v1/contacts/validate: post: - operationId: searchContacts + operationId: validateContacts tags: - Contacts - summary: Get contacts - description: |- - The single "Get Contacts" read. Structured search over the brand’s contacts: free-text `search`, `filters` (`{ field, operator, value }` combined with `logic: "and" | "or" | "none"`; the allowed operators depend on the field’s type — see the `operator` schema — and an unsupported pairing is a `400`, never a dropped clause), `sort` + `order`, and cursor pagination. Returns `{ data, pagination }`. - - `search` with a whole email address matches only that contact, like `filters: [{ field: "email", operator: "equals", value: "…" }]` or `GET /v1/contacts/{email}`. Any other `search` text matches the contacts whose email, first or last name contains any of its words, which can include other contacts, in `sort` order, so act on a contact found by its address. Omit every filter to list everything. - - Pass an optional `audienceId` to scope the search to a saved audience’s members — its stored filter set is evaluated as one unit with its own `logicalOperator` (an OR audience stays an OR) and then ANDed with `filters`, so the page is exactly who a send to that audience reaches (an unknown / cross-brand id, or an audience a send would refuse → `400`). - - Set `count: true` to get `{ count }` instead of a page. - - `include: ["openProfile"]` attaches each returned contact’s smart-send open-time profile (`openProfile`, as on `getContact`; `null` when they have no opens yet). Each row costs one profile read, so a page then holds at most 10 contacts (`pagination.limit` reports the size read); it needs the `emails` scope as well (`403 INSUFFICIENT_PERMISSIONS` without it) and is refused with `count: true`. + summary: Validate email deliverability + description: "Batch deliverability check (up to 100 addresses) — run BEFORE importing or sending. Each address is classified `valid` (safe), `risky` (deliverable but flagged — e.g. role account, disposable, catch-all), or `invalid` (undeliverable / do-not-send), with a machine-readable `reason` and a `didYouMean` typo correction when applicable. Partial outages retain the legacy `status: risky` with `checkOutcome: unavailable` (not a completed risky verdict) and preserve the stored verdict. `persistence` reports matched, modified and failed update-only writes; validation never grants subscription or clears suppression. Cost: 2 credits PER ADDRESS (`X-Credit-Cost` = 2 × the number of addresses), charged ONLY on success — if the check cannot complete for the whole batch, the call returns `503` (transient outages are retryable; configuration failures need support) and is NOT billed." requestBody: required: true content: application/json: schema: - $ref: "#/components/schemas/ContactsSearchRequest" - examples: - filtered: - summary: Filter + sort - value: - filters: - - field: plan - operator: equals - value: enterprise - sort: createdAt - order: desc - limit: 50 - count: - summary: Count only - value: - filters: - - field: subscribed - operator: equals - value: 'true' - count: true + $ref: "#/components/schemas/ContactsValidateRequest" + example: + emails: + - ada@gmail.com + - temp@mailinator.com + - admin@stripe.com responses: "200": - description: "A page of matching contacts — or `{ count }` when `count: true`." + description: One classification row per input address. headers: x-request-id: schema: @@ -47596,55 +48346,33 @@ paths: content: application/json: schema: - $ref: "#/components/schemas/ContactsSearchSuccessResponse" - examples: - page: - summary: Result page - value: - data: - - email: jane@example.com - firstName: Jane - lastName: Doe - subscribed: true - validationStatus: valid - lastValidatedAt: '2026-04-08T12:01:00.000Z' - suppressed: false - suppressedReason: null - consent: - source: form - capturedAt: '2026-04-08T12:00:00.000Z' - policyVersion: 2026-03 - createdAt: '2026-04-08T12:00:00.000Z' - updatedAt: '2026-04-08T12:05:00.000Z' - importId: null - customFields: - plan: enterprise - revenue: 4200 - - email: john@example.com - firstName: John - lastName: Smith - subscribed: true - validationStatus: valid - lastValidatedAt: '2026-04-08T12:01:00.000Z' - suppressed: false - suppressedReason: null - consent: - source: form - capturedAt: '2026-04-08T12:00:00.000Z' - policyVersion: 2026-03 - createdAt: '2026-04-08T12:00:00.000Z' - updatedAt: '2026-04-08T12:05:00.000Z' - importId: null - customFields: - plan: starter - pagination: - limit: 50 - cursor: eyJsYXN0SWQiOiIxMjMiLCJsYXN0U29ydCI6MTcxMjU5MjAwMDAwMH0= - hasMore: true - count: - summary: Count mode - value: - count: 1284 + $ref: "#/components/schemas/ContactsValidateResponse" + example: + data: + - email: ada@gmail.com + valid: true + status: valid + checkOutcome: completed + - email: temp@mailinator.com + valid: false + status: invalid + checkOutcome: completed + reason: undeliverable + - email: ada@gmial.com + valid: false + status: risky + checkOutcome: completed + reason: unknown + didYouMean: ada@gmail.com + - email: admin@stripe.com + valid: false + status: risky + checkOutcome: completed + reason: role_address + persistence: + matched: 4 + modified: 4 + failed: 0 "400": description: |- `BRAND_ID_REQUIRED`: An organization-scoped credential called a brand-scoped operation without naming the brand. @@ -47728,6 +48456,34 @@ paths: message: The API key is malformed or unknown. suggestion: Check the API key format and retry with a valid active key. docs: https://docs.brew.new/api-reference/api/authentication + "402": + description: "`INSUFFICIENT_CREDITS`: The credit balance is below what this operation requires; `details.cost` is the amount needed." + headers: + x-request-id: + schema: + type: string + description: Unique request identifier. Share this with support when debugging a request. + example: req_8cac13fd94e6420cacdd75a1aa403a28 + required: true + description: Unique request identifier. Share this with support when debugging a request. + content: + application/json: + schema: + $ref: "#/components/schemas/ApiErrorEnvelope" + examples: + insufficientCredits: + summary: INSUFFICIENT_CREDITS + value: + error: + code: INSUFFICIENT_CREDITS + type: payment_required + message: The credit balance is below what this operation requires; `details.cost` is the amount needed. + suggestion: Upgrade your plan or wait for the next billing period to reset. Check your balance up front with GET /v1/usage. + docs: https://docs.brew.new/api-reference/api/credits + details: + cost: 5 + remaining: 0 + planKey: free "403": description: |- `ACCOUNT_SUSPENDED`: The organization behind the credential is suspended. @@ -47811,34 +48567,10 @@ paths: suggestion: List the brands this credential can reach with GET /v1/brands. docs: https://docs.brew.new/api-reference/api/errors "409": - description: "`FIELD_TYPE_MISMATCH`: A value does not match the declared type of its custom field." - headers: - x-request-id: - schema: - type: string - description: Unique request identifier. Share this with support when debugging a request. - example: req_8cac13fd94e6420cacdd75a1aa403a28 - required: true - description: Unique request identifier. Share this with support when debugging a request. - content: - application/json: - schema: - $ref: "#/components/schemas/ApiErrorEnvelope" - examples: - fieldTypeMismatch: - summary: FIELD_TYPE_MISMATCH - value: - error: - code: FIELD_TYPE_MISMATCH - type: conflict - message: A value does not match the declared type of its custom field. - suggestion: Send a value that matches the field's type. To change the type, delete the field (this clears its value on every contact) and create it again. - docs: https://docs.brew.new/api-reference/api/errors - "422": description: |- - `BATCH_TOO_LARGE`: A batch write exceeds the per-request row cap. + `IDEMPOTENCY_CONFLICT`: The same Idempotency-Key was reused with a different request body. - `CORE_FIELD_IMMUTABLE`: The field is a core contact column and cannot be created, changed, or deleted as a custom field. + `IDEMPOTENCY_IN_PROGRESS`: A request with this Idempotency-Key is still executing. headers: x-request-id: schema: @@ -47852,24 +48584,24 @@ paths: schema: $ref: "#/components/schemas/ApiErrorEnvelope" examples: - batchTooLarge: - summary: BATCH_TOO_LARGE + idempotencyConflict: + summary: IDEMPOTENCY_CONFLICT value: error: - code: BATCH_TOO_LARGE - type: invalid_request - message: A batch write exceeds the per-request row cap. - suggestion: Split the request into smaller batches and retry. - docs: https://docs.brew.new/api-reference/api/errors - coreFieldImmutable: - summary: CORE_FIELD_IMMUTABLE + code: IDEMPOTENCY_CONFLICT + type: conflict + message: The same Idempotency-Key was reused with a different request body. + suggestion: Reuse the original payload or send a new idempotency key. + docs: https://docs.brew.new/api-reference/api/idempotency + idempotencyInProgress: + summary: IDEMPOTENCY_IN_PROGRESS value: error: - code: CORE_FIELD_IMMUTABLE - type: invalid_request - message: The field is a core contact column and cannot be created, changed, or deleted as a custom field. - suggestion: Use a custom field name instead. - docs: https://docs.brew.new/api-reference/api/errors + code: IDEMPOTENCY_IN_PROGRESS + type: conflict + message: A request with this Idempotency-Key is still executing. + suggestion: Wait for the original request to finish, then retry the same key to replay its response. + docs: https://docs.brew.new/api-reference/api/idempotency "429": description: "`RATE_LIMITED`: The credential exhausted the rolling window for this route policy; Retry-After is when the oldest recorded request leaves, and the refused request does not take a slot." headers: @@ -47884,7 +48616,7 @@ paths: schema: type: integer description: Requests allowed in the current rolling rate limit window. - example: 100 + example: 60 required: true description: Requests allowed in the current rolling rate limit window. X-RateLimit-Remaining: @@ -47925,7 +48657,7 @@ paths: retryAfter: 42 details: scope: credential - limit: 100 + limit: 60 remaining: 0 reset: 1712592360 consumed: false @@ -47953,6 +48685,37 @@ paths: message: An unexpected failure; the x-request-id header identifies it. suggestion: Retry the request. If it keeps failing, contact support with the x-request-id header. docs: https://docs.brew.new/api-reference/api/errors + "503": + description: "`SERVICE_UNAVAILABLE`: A dependency the operation must consult (billing, the idempotency store) is temporarily unavailable, so the request was refused rather than run unmetered." + headers: + x-request-id: + schema: + type: string + description: Unique request identifier. Share this with support when debugging a request. + example: req_8cac13fd94e6420cacdd75a1aa403a28 + required: true + description: Unique request identifier. Share this with support when debugging a request. + Retry-After: + schema: + type: integer + description: Seconds to wait before retrying the request. + example: 42 + required: true + description: Seconds to wait before retrying the request. + content: + application/json: + schema: + $ref: "#/components/schemas/ApiErrorEnvelope" + examples: + serviceUnavailable: + summary: SERVICE_UNAVAILABLE + value: + error: + code: SERVICE_UNAVAILABLE + type: service_unavailable + message: A dependency the operation must consult (billing, the idempotency store) is temporarily unavailable, so the request was refused rather than run unmetered. + suggestion: Retry the request after a short delay. + docs: https://docs.brew.new/api-reference/api/errors parameters: - name: X-Brand-Id in: header @@ -47965,30 +48728,42 @@ paths: example: kx7b3s7fapqz8mjm12ekz1kxdx87yceg x-brew-scope: contacts x-brew-brand-scope: brand - x-brew-idempotency: none - x-brew-credited: false - x-brew-rate-policy: contacts.read - /v1/contacts/validate: + x-brew-idempotency: replay + x-brew-credited: true + x-brew-rate-policy: content.transform + /v1/contacts/import-csv: post: - operationId: validateContacts + operationId: importContactsCsv tags: - Contacts - summary: Validate email deliverability - description: "Batch deliverability check (up to 100 addresses) — run BEFORE importing or sending. Each address is classified `valid` (safe), `risky` (deliverable but flagged — e.g. role account, disposable, catch-all), or `invalid` (undeliverable / do-not-send), with a machine-readable `reason` and a `didYouMean` typo correction when applicable. Cost: 2 credits PER ADDRESS (`X-Credit-Cost` = 2 × the number of addresses), charged ONLY on success — if the check cannot complete for the whole batch, the call returns a retryable `503` and is NOT billed." + summary: Bulk-import contacts from CSV + description: |- + Parse a raw CSV string and upsert the rows as contacts (≤ 1000). By default each column header maps to a field of the same name (`email` is required). A header that spells a core field, ignoring case, spaces, `_` and `-`, or whose field key equals it (`First.Name`), maps to it: `Email`, `First Name`, `Last Name` and `Subscribed` fill `email`, `firstName`, `lastName` and `subscribed`. Pass `mapping` to remap single columns; the columns it leaves out keep their default mapping, `""` leaves a column out, and a key that names no column is a `400`. A core field that `mapping` fills is filled from that column alone: another column whose default would fill it is left out with a `CSV_COLUMN_IGNORED` warning. Rows with a missing/invalid email are SKIPPED (counted in `summary.skipped`), not errored; disposable-domain rows are imported with `validationStatus: "risky"` (deliverable but flagged). Returns the same `{ summary, fieldsCreated, errors, warnings }` as batch-create plus `importId` — `207` when some rows fail. The batch is listed in Import History under `fileName` (optional), and every contact it writes carries `importId` (filter on it to list them; deleting the import in the app removes the contacts it created). For rows you already hold as JSON, use `POST /v1/contacts/import`. + + CSV cells are text: a column mapped onto an EXISTING field is coerced to that field's type (dates → epoch ms, `1,234` → 1234, `yes`/`no` → booleans), and a cell that cannot be coerced (`$49` in a number column) fails ITS row with a per-row `errors[]` entry (`code: FIELD_TYPE_MISMATCH`, `field`) while the other rows still land. An undeclared column is created as a `string` field unless every value is an ISO date (→ `date`); predeclare `number` / `bool` columns with `POST /v1/fields`. A column whose field Brew manages (`Created At`, `Validation Status`) is not imported and adds a `warnings[]` entry (`code: CSV_COLUMN_IGNORED`, `field` names the column). + + A date column is read in ONE day/month order: the order its own dates prove (a day over 12 anywhere in the column, `13/04/2026`), else `dateOrder` (`day_first` reads `03/04/2026` as 3 April, `month_first` as 4 March), else month/day with a `warnings[]` entry (`code: DATE_ORDER_ASSUMED`, `field`). Dotted dates (`03.04.2026`) are day-first. ISO (`2026-04-03`), Unix seconds or milliseconds, month names (`3 Apr 2026`), year-first (`2026/04/03`) and compact (`20260403`) dates need no order. Convert to `YYYY-MM-DD` to be unambiguous. + + A `subscribed` column reads true/false and ESP status words (`unsubscribed`, `cleaned`, `blocklisted`, `transactional`, `active`, …). An opt-out unsubscribes the contact, new or existing. A word that is not a subscription status (`maybe`) fails its row (`code: FIELD_TYPE_MISMATCH`, `field: subscribed`) instead of importing the contact subscribed. A subscribed value only applies to new contacts: a row that asks to re-subscribe a contact who opted out keeps its other fields and adds a `warnings[]` entry (`code: RESUBSCRIBE_SKIPPED`, `email`). requestBody: required: true content: application/json: schema: - $ref: "#/components/schemas/ContactsValidateRequest" + $ref: "#/components/schemas/ContactsImportCsvRequest" example: - emails: - - ada@gmail.com - - temp@mailinator.com - - admin@stripe.com + csv: |- + email,firstName,lastName + ada@example.com,Ada,Lovelace + grace@example.com,Grace,Hopper + mapping: + email: email + firstName: firstName + lastName: lastName + fileName: customers.csv responses: "200": - description: One classification row per input address. + description: Import summary (all rows valid). headers: x-request-id: schema: @@ -48021,25 +48796,193 @@ paths: content: application/json: schema: - $ref: "#/components/schemas/ContactsValidateResponse" + $ref: "#/components/schemas/ContactsImportCsvResponse" example: - data: - - email: ada@gmail.com - valid: true - status: valid - - email: temp@mailinator.com - valid: false - status: invalid - reason: undeliverable - - email: ada@gmial.com - valid: false - status: risky - reason: unknown - didYouMean: ada@gmail.com - - email: admin@stripe.com - valid: false - status: risky - reason: role_address + summary: + inserted: 2 + updated: 0 + failed: 0 + skipped: 0 + fieldsCreated: [] + errors: [] + warnings: [] + importId: imp_01JABCDEF0123456789XYZ + "207": + description: Partial success — some rows failed validation. + headers: + x-request-id: + schema: + type: string + description: Unique request identifier. Share this with support when debugging a request. + example: req_8cac13fd94e6420cacdd75a1aa403a28 + required: true + description: Unique request identifier. Share this with support when debugging a request. + X-RateLimit-Limit: + schema: + type: integer + description: Requests allowed in the current rolling rate limit window. + example: 100 + required: true + description: Requests allowed in the current rolling rate limit window. + X-RateLimit-Remaining: + schema: + type: integer + description: Requests remaining in the current rolling rate limit window. + example: 99 + required: true + description: Requests remaining in the current rolling rate limit window. + X-RateLimit-Reset: + schema: + type: integer + description: Unix timestamp in seconds when the oldest recorded request leaves the rolling window. + example: 1712592360 + required: true + description: Unix timestamp in seconds when the oldest recorded request leaves the rolling window. + content: + application/json: + schema: + type: object + properties: + summary: + type: object + properties: + inserted: + type: integer + minimum: 0 + updated: + type: integer + minimum: 0 + failed: + type: integer + minimum: 0 + skipped: + type: integer + minimum: 0 + required: + - inserted + - updated + - failed + - skipped + additionalProperties: false + fieldsCreated: + type: array + items: + type: string + errors: + type: array + items: + type: object + properties: + email: + type: string + code: + type: string + field: + type: string + message: + type: string + required: + - email + - code + - message + additionalProperties: false + warnings: + type: array + items: + type: object + properties: + code: + type: string + minLength: 1 + email: + type: string + minLength: 1 + field: + type: string + minLength: 1 + message: + type: string + minLength: 1 + from: + type: string + minLength: 1 + to: + type: string + minLength: 1 + original: + type: string + minLength: 1 + normalized: + type: string + minLength: 1 + required: + - code + - message + additionalProperties: false + validation: + type: object + properties: + valid: + type: integer + minimum: 0 + risky: + type: integer + minimum: 0 + invalid: + type: integer + minimum: 0 + unscored: + type: integer + minimum: 0 + persistence: + type: object + properties: + matched: + type: integer + minimum: 0 + modified: + type: integer + minimum: 0 + failed: + type: integer + minimum: 0 + required: + - matched + - modified + - failed + additionalProperties: false + required: + - valid + - risky + - invalid + - unscored + additionalProperties: false + validationJobId: + type: string + importId: + type: string + description: This batch's Import History id. Every contact it wrote carries it as `importId`, so `search_contacts` with an `importId` filter lists them. + required: + - summary + - fieldsCreated + - errors + - warnings + - importId + additionalProperties: false + example: + summary: + inserted: 1 + updated: 0 + failed: 1 + skipped: 2 + fieldsCreated: [] + errors: + - email: john@example.com + code: FIELD_TYPE_MISMATCH + field: customFields.plan + message: Field 'customFields.plan' is type 'number' but received string '$49'. + warnings: [] + importId: imp_01JABCDEF0123456789XYZ "400": description: |- `BRAND_ID_REQUIRED`: An organization-scoped credential called a brand-scoped operation without naming the brand. @@ -48283,7 +49226,7 @@ paths: schema: type: integer description: Requests allowed in the current rolling rate limit window. - example: 60 + example: 10 required: true description: Requests allowed in the current rolling rate limit window. X-RateLimit-Remaining: @@ -48324,7 +49267,7 @@ paths: retryAfter: 42 details: scope: credential - limit: 60 + limit: 10 remaining: 0 reset: 1712592360 consumed: false @@ -48397,36 +49340,34 @@ paths: x-brew-brand-scope: brand x-brew-idempotency: replay x-brew-credited: true - x-brew-rate-policy: content.transform - /v1/contacts/import-csv: + x-brew-rate-policy: contacts.write_batch + /v1/contacts/import: post: - operationId: importContactsCsv + operationId: importContacts tags: - Contacts - summary: Bulk-import contacts from CSV + summary: Bulk-import contacts from JSON description: |- - Parse a raw CSV string and upsert the rows as contacts (≤ 1000). By default each column header maps to a field of the same name (`email` is required). A header that spells a core field, ignoring case, spaces, `_` and `-`, or whose field key equals it (`First.Name`), maps to it: `Email`, `First Name`, `Last Name` and `Subscribed` fill `email`, `firstName`, `lastName` and `subscribed`. Pass `mapping` to remap single columns; the columns it leaves out keep their default mapping, `""` leaves a column out, and a key that names no column is a `400`. A core field that `mapping` fills is filled from that column alone: another column whose default would fill it is left out with a `CSV_COLUMN_IGNORED` warning. Rows with a missing/invalid email are SKIPPED (counted in `summary.skipped`), not errored; disposable-domain rows are imported with `validationStatus: "risky"` (deliverable but flagged). Returns the same `{ summary, fieldsCreated, errors, warnings }` as batch-create — `207` when some rows fail. - - CSV cells are text: a column mapped onto an EXISTING field is coerced to that field's type (dates → epoch ms, `1,234` → 1234, `yes`/`no` → booleans), and a cell that cannot be coerced (`$49` in a number column) fails ITS row with a per-row `errors[]` entry (`code: FIELD_TYPE_MISMATCH`, `field`) while the other rows still land. An undeclared column is created as a `string` field unless every value is an ISO date (→ `date`); predeclare `number` / `bool` columns with `POST /v1/fields`. A column whose field Brew manages (`Created At`, `Validation Status`) is not imported and adds a `warnings[]` entry (`code: CSV_COLUMN_IGNORED`, `field` names the column). - - A date column is read in ONE day/month order: the order its own dates prove (a day over 12 anywhere in the column, `13/04/2026`), else `dateOrder` (`day_first` reads `03/04/2026` as 3 April, `month_first` as 4 March), else month/day with a `warnings[]` entry (`code: DATE_ORDER_ASSUMED`, `field`). Dotted dates (`03.04.2026`) are day-first. ISO (`2026-04-03`), Unix seconds or milliseconds, month names (`3 Apr 2026`), year-first (`2026/04/03`) and compact (`20260403`) dates need no order. Convert to `YYYY-MM-DD` to be unambiguous. + Create or update up to 1000 contacts from JSON rows and record them as one Import History batch: the JSON twin of `POST /v1/contacts/import-csv`. Rows take the batch-create shape (`email`, `firstName`, `lastName`, `subscribed`, `customFields`, `consent`) and follow its rules: a missing or invalid email, or an address repeated in the batch, fails its row (`errors[]`, `207`) while the others land. New contacts are subscribed unless the row says `subscribed: false`; re-subscribing a contact who opted out keeps its other fields and adds a `RESUBSCRIBE_SKIPPED` warning. - A `subscribed` column reads true/false and ESP status words (`unsubscribed`, `cleaned`, `blocklisted`, `transactional`, `active`, …). An opt-out unsubscribes the contact, new or existing. A word that is not a subscription status (`maybe`) fails its row (`code: FIELD_TYPE_MISMATCH`, `field: subscribed`) instead of importing the contact subscribed. A subscribed value only applies to new contacts: a row that asks to re-subscribe a contact who opted out keeps its other fields and adds a `warnings[]` entry (`code: RESUBSCRIBE_SKIPPED`, `email`). + The batch is listed in Import History under `fileName` (optional), and every contact it writes carries the returned `importId` (filter on it to list them; deleting the import in the app removes the contacts it created). `POST /v1/contacts` with `contacts[]` writes the same rows without an Import History entry, for recurring syncs. requestBody: required: true content: application/json: schema: - $ref: "#/components/schemas/ContactsImportCsvRequest" + $ref: "#/components/schemas/ContactsImportRequest" example: - csv: |- - email,firstName,lastName - ada@example.com,Ada,Lovelace - grace@example.com,Grace,Hopper - mapping: - email: email - firstName: firstName - lastName: lastName + contacts: + - email: ada@example.com + firstName: Ada + - email: grace@example.com + firstName: Grace + customFields: + plan: pro + consent: + source: import + fileName: crm-export.json responses: "200": description: Import summary (all rows valid). @@ -48462,16 +49403,18 @@ paths: content: application/json: schema: - $ref: "#/components/schemas/ContactsImportCsvResponse" + $ref: "#/components/schemas/ContactsImportResponse" example: summary: inserted: 2 updated: 0 failed: 0 skipped: 0 - fieldsCreated: [] + fieldsCreated: + - plan errors: [] warnings: [] + importId: imp_01JABCDEF0123456789XYZ "207": description: Partial success — some rows failed validation. headers: @@ -48599,6 +49542,23 @@ paths: unscored: type: integer minimum: 0 + persistence: + type: object + properties: + matched: + type: integer + minimum: 0 + modified: + type: integer + minimum: 0 + failed: + type: integer + minimum: 0 + required: + - matched + - modified + - failed + additionalProperties: false required: - valid - risky @@ -48607,25 +49567,29 @@ paths: additionalProperties: false validationJobId: type: string + importId: + type: string + description: This batch's Import History id. Every contact it wrote carries it as `importId`, so `search_contacts` with an `importId` filter lists them. required: - summary - fieldsCreated - errors - warnings + - importId additionalProperties: false example: summary: inserted: 1 updated: 0 failed: 1 - skipped: 2 + skipped: 0 fieldsCreated: [] errors: - - email: john@example.com - code: FIELD_TYPE_MISMATCH - field: customFields.plan - message: Field 'customFields.plan' is type 'number' but received string '$49'. + - email: not-an-email + code: INVALID_EMAIL + message: "'not-an-email' is not a valid email address." warnings: [] + importId: imp_01JABCDEF0123456789XYZ "400": description: |- `BRAND_ID_REQUIRED`: An organization-scoped credential called a brand-scoped operation without naming the brand. diff --git a/package.json b/package.json index c8c13b7..f429937 100644 --- a/package.json +++ b/package.json @@ -53,7 +53,7 @@ "smoke:signals": "bun run scripts/signal-smoke.ts" }, "dependencies": { - "@brew.new/sdk": "git+https://github.com/GetBrew/typescript-sdk.git#a5ce43d47c51793dc3fb368d744d56deb91c3368", + "@brew.new/sdk": "git+https://github.com/GetBrew/typescript-sdk.git#fa924c46bba610ab51cd78eff2d72c2191aaf234", "commander": "^14.0.0" }, "devDependencies": { diff --git a/src/commands/contacts/import-csv.ts b/src/commands/contacts/import-csv.ts index 5990b15..dd278b4 100644 --- a/src/commands/contacts/import-csv.ts +++ b/src/commands/contacts/import-csv.ts @@ -1,3 +1,4 @@ +import { basename } from 'node:path' import type { ContactsResource } from '@brew.new/sdk' import { defineCommand } from '../../lib/define-command' import { CliUsageError } from '../../lib/errors' @@ -13,15 +14,12 @@ import { requestOptions, } from '../../lib/input' -// SDK 10's `importCsv` types only `{ csv, mapping, validate }` but sends -// its input as the body, so `dateOrder` and `consent` reach the API through -// the boundary cast until the CLI adopts SDK 11.2. type ImportCsvContactsInput = Parameters[0] export const contactsImportCsvCommand = defineCommand({ path: ['contacts', 'import-csv'], summary: - 'Bulk-import contacts from a CSV file or stdin (free; --validate costs 2 credits per address)', + 'Bulk-import contacts from a CSV file or stdin as one Import History batch (free; --validate costs 2 credits per address)', sdkMethod: 'contacts.importCsv', route: { method: 'POST', path: '/v1/contacts/import-csv' }, commandClass: 'write', @@ -30,6 +28,11 @@ export const contactsImportCsvCommand = defineCommand({ flag: '--file ', summary: 'CSV file to import, or - for stdin (or csv in --input)', }, + { + flag: '--file-name ', + summary: + "Name the batch in Brew's Import History (default: the --file name)", + }, { flag: '--mapping ', summary: 'Column mapping csvColumn=fieldName, repeatable', @@ -68,6 +71,7 @@ export const contactsImportCsvCommand = defineCommand({ mapping: parseKeyValues(flags.mapping, '--mapping'), dateOrder: flagString(flags.dateOrder), validate: flags.validate === true ? true : undefined, + fileName: importFileName(flags), }) if (typeof input.csv !== 'string' || input.csv.trim() === '') { throw new CliUsageError( @@ -92,3 +96,19 @@ export const contactsImportCsvCommand = defineCommand({ return { data: result } }, }) + +/** + * The Import History name: `--file-name`, else the file's own name (stdin + * has none, so the batch is named by its source in Brew). + */ +export function importFileName(flags: { + readonly fileName?: unknown + readonly file?: unknown +}): string | undefined { + const explicit = flagString(flags.fileName) + if (explicit !== undefined) { + return explicit + } + const file = flagString(flags.file) + return file === undefined || file === '-' ? undefined : basename(file) +} diff --git a/src/commands/contacts/import.ts b/src/commands/contacts/import.ts new file mode 100644 index 0000000..53e9dde --- /dev/null +++ b/src/commands/contacts/import.ts @@ -0,0 +1,101 @@ +import type { ContactsResource } from '@brew.new/sdk' +import { defineCommand } from '../../lib/define-command' +import { CliUsageError } from '../../lib/errors' +import { + asSdkInput, + flagString, + IDEMPOTENCY_FLAG, + INPUT_FLAG, + mergeInput, + readJsonFlag, + readTextFlag, + requestOptions, +} from '../../lib/input' +import { importFileName } from './import-csv' + +type ImportContactsInput = Parameters[0] + +export const contactsImportCommand = defineCommand({ + path: ['contacts', 'import'], + summary: + 'Bulk-import up to 1000 contacts from a JSON file or stdin as one Import History batch (free; --validate costs 2 credits per address)', + sdkMethod: 'contacts.import', + route: { method: 'POST', path: '/v1/contacts/import' }, + commandClass: 'write', + flags: [ + { + flag: '--file ', + summary: + 'JSON file of contacts (an array, or { "contacts": [...] }), or - for stdin', + }, + { + flag: '--file-name ', + summary: + "Name the batch in Brew's Import History (default: the --file name)", + }, + { + flag: '--validate', + summary: + 'Deliverability-check every imported address (2 credits per address)', + }, + { + flag: '--consent-source ', + summary: + 'Default marketing consent record for rows without their own: api, form or import', + }, + INPUT_FLAG, + IDEMPOTENCY_FLAG, + ], + examples: [ + 'brew-cli contacts import --file contacts.json --consent-source import', + 'cat contacts.json | brew-cli contacts import --file - --file-name crm-export.json', + ], + run: async ({ ctx, flags }) => { + if (flags.input === '-' && flags.file === '-') { + throw new CliUsageError('Only one of --input and --file can read stdin.') + } + const fromFile = parseContactsFile( + await readTextFlag(ctx, flags.file, '--file') + ) + const base = await readJsonFlag(ctx, flags.input, '--input') + const rows = Array.isArray(fromFile) ? { contacts: fromFile } : fromFile + const input = mergeInput(Array.isArray(base) ? { contacts: base } : base, { + ...(rows && typeof rows === 'object' ? rows : {}), + fileName: importFileName(flags), + validate: flags.validate === true ? true : undefined, + }) + if (!Array.isArray(input.contacts) || input.contacts.length === 0) { + throw new CliUsageError( + 'Pass the contacts with --file (a JSON array or { "contacts": [...] }, or - for stdin) or as contacts in --input.' + ) + } + const consentSource = flagString(flags.consentSource) + if (consentSource !== undefined) { + const record = input.consent + input.consent = { + ...(typeof record === 'object' && record !== null ? record : {}), + source: consentSource, + } + } + const result = await ctx + .client() + .contacts.import( + asSdkInput(input), + requestOptions(flags) + ) + return { data: result } + }, +}) + +function parseContactsFile(text: string | undefined): unknown { + if (text === undefined) { + return + } + try { + return JSON.parse(text) as unknown + } catch { + throw new CliUsageError( + '--file must hold JSON: an array of contacts or { "contacts": [...] }' + ) + } +} diff --git a/src/commands/contacts/upsert-many.ts b/src/commands/contacts/upsert-many.ts index c27816c..46e8416 100644 --- a/src/commands/contacts/upsert-many.ts +++ b/src/commands/contacts/upsert-many.ts @@ -11,7 +11,8 @@ import { export const contactsUpsertManyCommand = defineCommand({ path: ['contacts', 'upsert-many'], - summary: 'Create or update a batch of contacts (up to 100 per call)', + summary: + 'Create or update up to 1000 contacts per call, with no Import History entry (for syncs; use contacts import for a batch you want listed)', sdkMethod: 'contacts.upsertMany', route: { method: 'POST', path: '/v1/contacts' }, commandClass: 'write', diff --git a/src/generated/openapi-types.ts b/src/generated/openapi-types.ts index 1b4487c..46de895 100644 --- a/src/generated/openapi-types.ts +++ b/src/generated/openapi-types.ts @@ -539,9 +539,9 @@ export interface paths { * * Test (`test: true`): `emailId`, `subject`, one `to` address, optional `emailVersionId`, `previewText`, `replyTo`, `domainId` (a verified org-owned domain; otherwise the Brew default sender, and an unverified or foreign domain is rejected, never downgraded), `from` (`{ email, name? }`, an address on that domain), `variables` (example values for `{{ var | fallback }}` merge tags; a value wins over the fallback) and `payload` (template data; nested JSON renders via Liquid as `trigger.*`); the subject carries a `[TEST]` prefix. The same design can be sent unlimited times; every campaign call mints a new send. * - * **Returns** campaign `202 { status: 'queued' | 'scheduled', sendId, scheduledAt?, warnings? }` (poll `getSend`; `warnings[]` carries one `CONSENT_RECORD_MISSING` per inline recipient that is a subscribed contact with no consent record, and one `RECIPIENTS_EXCLUDED` with the counts when unsubscribed, suppressed or undeliverable contacts will be skipped); test `200 { status: 'completed', recipient }`. + * **Returns** campaign `202 { status: 'queued' | 'scheduled', sendId, scheduledAt?, warnings? }` (poll `getSend`; `warnings[]` carries one `CONSENT_RECORD_MISSING` per inline recipient that is a subscribed contact with no consent record, and one `RECIPIENTS_EXCLUDED` with the counts when unsubscribed, suppressed, undeliverable or permission-less recipients will be skipped); test `200 { status: 'completed', recipient }`. * - * **Errors** `400 INVALID_REQUEST` (`param: subject`) when `subject` has a line break or runs over 1000 characters (993 on a test send, for the `[TEST]` prefix), judged on the text every recipient receives; `404 EMAIL_NOT_FOUND`, `DOMAIN_NOT_FOUND`, `AUDIENCE_NOT_FOUND` (a resource in another brand also surfaces as `404`); `422 EMAIL_NOT_READY`, `DOMAIN_NOT_READY`, `DOMAIN_PURPOSE_NOT_ALLOWED`, `LIQUID_RENDER_ERROR`; `422 CONSENT_REQUIRED` when an inline marketing recipient has no contact record with marketing consent and no `consent` was supplied (`details.recipients`); `422 RECIPIENT_UNSUBSCRIBED` when an inline recipient is an existing contact who opted out (`details.recipients`, never re-subscribed); `422 NO_ELIGIBLE_RECIPIENTS` when an immediate send would reach nobody because every contact it targets is unsubscribed, suppressed or undeliverable (`details.eligibility` = `{ total, eligible, excluded: { unsubscribed, suppressed, invalidEmail } }`; no send is created, while a scheduled send is accepted instead); `422 EMAIL_IMAGES_MISSING` when the email shows a cdn.brew.new image that does not exist (`details.missingImages[]`; a dead icon whose real file sits elsewhere in its pack is moved there instead when the key has `emails` access); `402 SEND_QUOTA_EXCEEDED` when the plan's monthly volume would be exceeded (no `Retry-After`). + * **Errors** `400 INVALID_REQUEST` (`param: subject`) when `subject` has a line break or runs over 1000 characters (993 on a test send, for the `[TEST]` prefix), judged on the text every recipient receives; `404 EMAIL_NOT_FOUND`, `DOMAIN_NOT_FOUND`, `AUDIENCE_NOT_FOUND` (a resource in another brand also surfaces as `404`); `422 EMAIL_NOT_READY`, `DOMAIN_NOT_READY`, `DOMAIN_PURPOSE_NOT_ALLOWED`, `LIQUID_RENDER_ERROR`; `422 CONSENT_REQUIRED` when an inline marketing recipient has no contact record with marketing consent and no `consent` was supplied (`details.recipients`); `422 RECIPIENT_UNSUBSCRIBED` when an inline recipient is an existing contact who opted out (`details.recipients`, never re-subscribed); `422 NO_ELIGIBLE_RECIPIENTS` when an immediate send would reach nobody because every contact it targets is excluded (`details.eligibility` = `{ total, eligible, excluded: { unsubscribed, suppressed, invalidEmail, consentRequired? } }`, each address counted once; no send is created, while a scheduled send is accepted instead); `422 EMAIL_IMAGES_MISSING` when the email shows a cdn.brew.new image that does not exist (`details.missingImages[]`; a dead icon whose real file sits elsewhere in its pack is moved there instead when the key has `emails` access); `402 SEND_QUOTA_EXCEEDED` when the plan's monthly volume would be exceeded (no `Retry-After`). * * **See also** `listSends`, `cancelSend`, `fireTrigger`. */ @@ -1049,7 +1049,7 @@ export interface paths { * * **Use when** debugging one contact’s journey or confirming a test run finished. * - * **Input** `automationRunId` in the path; `include=logs` inlines the newest 100 per-node execution logs and sets `logsTruncated` when older logs exist. + * **Input** `automationRunId` in the path; `include=logs` inlines the newest 100 execution log rows in `orderIndex` order and sets `logsTruncated` when older rows exist. Each node status change is its own row: a node that runs has a `running` row (no `completedAt`), then its terminal `completed` or `failed` row, or `skipped` when the run is canceled while it runs; a node a branch rules out has only a `skipped` row. A node’s outcome is its last row. * * **Returns** `200` with the row; `logs` is present only when requested. * @@ -1525,7 +1525,7 @@ export interface paths { put?: never; /** * Validate email deliverability - * @description Batch deliverability check (up to 100 addresses) — run BEFORE importing or sending. Each address is classified `valid` (safe), `risky` (deliverable but flagged — e.g. role account, disposable, catch-all), or `invalid` (undeliverable / do-not-send), with a machine-readable `reason` and a `didYouMean` typo correction when applicable. Cost: 2 credits PER ADDRESS (`X-Credit-Cost` = 2 × the number of addresses), charged ONLY on success — if the check cannot complete for the whole batch, the call returns a retryable `503` and is NOT billed. + * @description Batch deliverability check (up to 100 addresses) — run BEFORE importing or sending. Each address is classified `valid` (safe), `risky` (deliverable but flagged — e.g. role account, disposable, catch-all), or `invalid` (undeliverable / do-not-send), with a machine-readable `reason` and a `didYouMean` typo correction when applicable. Partial outages retain the legacy `status: risky` with `checkOutcome: unavailable` (not a completed risky verdict) and preserve the stored verdict. `persistence` reports matched, modified and failed update-only writes; validation never grants subscription or clears suppression. Cost: 2 credits PER ADDRESS (`X-Credit-Cost` = 2 × the number of addresses), charged ONLY on success — if the check cannot complete for the whole batch, the call returns `503` (transient outages are retryable; configuration failures need support) and is NOT billed. */ post: operations["validateContacts"]; delete?: never; @@ -1545,7 +1545,7 @@ export interface paths { put?: never; /** * Bulk-import contacts from CSV - * @description Parse a raw CSV string and upsert the rows as contacts (≤ 1000). By default each column header maps to a field of the same name (`email` is required). A header that spells a core field, ignoring case, spaces, `_` and `-`, or whose field key equals it (`First.Name`), maps to it: `Email`, `First Name`, `Last Name` and `Subscribed` fill `email`, `firstName`, `lastName` and `subscribed`. Pass `mapping` to remap single columns; the columns it leaves out keep their default mapping, `""` leaves a column out, and a key that names no column is a `400`. A core field that `mapping` fills is filled from that column alone: another column whose default would fill it is left out with a `CSV_COLUMN_IGNORED` warning. Rows with a missing/invalid email are SKIPPED (counted in `summary.skipped`), not errored; disposable-domain rows are imported with `validationStatus: "risky"` (deliverable but flagged). Returns the same `{ summary, fieldsCreated, errors, warnings }` as batch-create — `207` when some rows fail. + * @description Parse a raw CSV string and upsert the rows as contacts (≤ 1000). By default each column header maps to a field of the same name (`email` is required). A header that spells a core field, ignoring case, spaces, `_` and `-`, or whose field key equals it (`First.Name`), maps to it: `Email`, `First Name`, `Last Name` and `Subscribed` fill `email`, `firstName`, `lastName` and `subscribed`. Pass `mapping` to remap single columns; the columns it leaves out keep their default mapping, `""` leaves a column out, and a key that names no column is a `400`. A core field that `mapping` fills is filled from that column alone: another column whose default would fill it is left out with a `CSV_COLUMN_IGNORED` warning. Rows with a missing/invalid email are SKIPPED (counted in `summary.skipped`), not errored; disposable-domain rows are imported with `validationStatus: "risky"` (deliverable but flagged). Returns the same `{ summary, fieldsCreated, errors, warnings }` as batch-create plus `importId` — `207` when some rows fail. The batch is listed in Import History under `fileName` (optional), and every contact it writes carries `importId` (filter on it to list them; deleting the import in the app removes the contacts it created). For rows you already hold as JSON, use `POST /v1/contacts/import`. * * CSV cells are text: a column mapped onto an EXISTING field is coerced to that field's type (dates → epoch ms, `1,234` → 1234, `yes`/`no` → booleans), and a cell that cannot be coerced (`$49` in a number column) fails ITS row with a per-row `errors[]` entry (`code: FIELD_TYPE_MISMATCH`, `field`) while the other rows still land. An undeclared column is created as a `string` field unless every value is an ISO date (→ `date`); predeclare `number` / `bool` columns with `POST /v1/fields`. A column whose field Brew manages (`Created At`, `Validation Status`) is not imported and adds a `warnings[]` entry (`code: CSV_COLUMN_IGNORED`, `field` names the column). * @@ -1560,6 +1560,28 @@ export interface paths { patch?: never; trace?: never; }; + "/v1/contacts/import": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Bulk-import contacts from JSON + * @description Create or update up to 1000 contacts from JSON rows and record them as one Import History batch: the JSON twin of `POST /v1/contacts/import-csv`. Rows take the batch-create shape (`email`, `firstName`, `lastName`, `subscribed`, `customFields`, `consent`) and follow its rules: a missing or invalid email, or an address repeated in the batch, fails its row (`errors[]`, `207`) while the others land. New contacts are subscribed unless the row says `subscribed: false`; re-subscribing a contact who opted out keeps its other fields and adds a `RESUBSCRIBE_SKIPPED` warning. + * + * The batch is listed in Import History under `fileName` (optional), and every contact it writes carries the returned `importId` (filter on it to list them; deleting the import in the app removes the contacts it created). `POST /v1/contacts` with `contacts[]` writes the same rows without an Import History entry, for recurring syncs. + */ + post: operations["importContacts"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/v1/contacts/batch-delete": { parameters: { query?: never; @@ -7118,6 +7140,11 @@ export interface components { risky: number; invalid: number; unscored: number; + persistence?: { + matched: number; + modified: number; + failed: number; + }; }; validationJobId?: string; }; @@ -7193,6 +7220,11 @@ export interface components { risky: number; invalid: number; unscored: number; + persistence?: { + matched: number; + modified: number; + failed: number; + }; }; }; ContactsPostRequest: components["schemas"]["ContactsPostSingleRequest"] | components["schemas"]["ContactsPostBatchRequest"]; @@ -7598,17 +7630,34 @@ export interface components { include?: "openProfile"[]; }; ContactsValidateResponse: { - data: { + data: ({ email: string; valid: boolean; /** @enum {string} */ status: "valid" | "risky" | "invalid"; + /** @enum {string} */ + checkOutcome: "completed"; reason?: string; didYouMean?: string; risk?: string; isDisposable?: boolean; isRole?: boolean; - }[]; + } | { + email: string; + /** @enum {boolean} */ + valid: false; + /** @enum {string} */ + status: "risky"; + /** @enum {string} */ + checkOutcome: "unavailable"; + /** @enum {string} */ + reason: "validation_unavailable"; + })[]; + persistence: { + matched: number; + modified: number; + failed: number; + }; }; ContactsValidateRequest: { emails: string[]; @@ -7642,8 +7691,15 @@ export interface components { risky: number; invalid: number; unscored: number; + persistence?: { + matched: number; + modified: number; + failed: number; + }; }; validationJobId?: string; + /** @description This batch's Import History id. Every contact it wrote carries it as `importId`, so `search_contacts` with an `importId` filter lists them. */ + importId: string; }; ContactsImportCsvRequest: { csv: string; @@ -7672,6 +7728,92 @@ export interface components { * @enum {string} */ dateOrder?: "month_first" | "day_first"; + /** @description Name this batch is listed under in Import History (usually the source file name, e.g. `customers.csv`), also stamped on every contact it writes as `csvFileName`. A path keeps only its last segment. */ + fileName?: string; + }; + ContactsImportResponse: { + summary: { + inserted: number; + updated: number; + failed: number; + skipped: number; + }; + fieldsCreated: string[]; + errors: { + email: string; + code: string; + field?: string; + message: string; + }[]; + warnings: { + code: string; + email?: string; + field?: string; + message: string; + from?: string; + to?: string; + original?: string; + normalized?: string; + }[]; + validation?: { + valid: number; + risky: number; + invalid: number; + unscored: number; + persistence?: { + matched: number; + modified: number; + failed: number; + }; + }; + validationJobId?: string; + /** @description This batch's Import History id. Every contact it wrote carries it as `importId`, so `search_contacts` with an `importId` filter lists them. */ + importId: string; + }; + ContactsImportRequest: { + contacts: { + email?: string; + firstName?: string; + lastName?: string; + subscribed?: boolean; + customFields?: { + [key: string]: unknown; + }; + /** @description Marketing consent provenance to record on this contact. Optional at creation; an inline marketing send to this address later needs the contact to be subscribed and warns when no record exists. */ + consent?: { + /** + * @description Who captured it: `api`, `form`, or `import`. (`app` is reserved for the in-app CSV wizard.) + * @enum {string} + */ + source: "api" | "form" | "import"; + /** + * Format: date-time + * @description ISO 8601; defaults to now. + */ + capturedAt?: string; + policyVersion?: string; + evidence?: string; + }; + }[]; + /** @description Optional deliverability check on ingestion. When true, each address is validated with the provider (2 credits per address, charged on success) and the verdict is saved to the contact’s `validationStatus`. Submissions above the inline cap (100 addresses) upsert first and validate as a background job, returning a `validationJobId`. */ + validate?: boolean; + /** @description Default consent record for every row that does not carry its own `consent` (typically `{ "source": "import" }`). */ + consent?: { + /** + * @description Who captured it: `api`, `form`, or `import`. (`app` is reserved for the in-app CSV wizard.) + * @enum {string} + */ + source: "api" | "form" | "import"; + /** + * Format: date-time + * @description ISO 8601; defaults to now. + */ + capturedAt?: string; + policyVersion?: string; + evidence?: string; + }; + /** @description Name this batch is listed under in Import History (usually the source file name, e.g. `customers.csv`), also stamped on every contact it writes as `csvFileName`. A path keeps only its last segment. */ + fileName?: string; }; ContactsBatchDeleteResponse: { deletedCount: number; @@ -17542,7 +17684,7 @@ export interface operations { getAutomationRun: { parameters: { query?: { - /** @description Expansions: `logs` inlines the newest 100 per-node execution logs and reports `logsTruncated`. */ + /** @description Expansions: `logs` inlines the newest 100 execution log rows, one per node status change (a node's outcome is its last row by `orderIndex`), and reports `logsTruncated`. */ include?: string; }; header?: { @@ -21775,18 +21917,21 @@ export interface operations { * { * "email": "ada@gmail.com", * "valid": true, - * "status": "valid" + * "status": "valid", + * "checkOutcome": "completed" * }, * { * "email": "temp@mailinator.com", * "valid": false, * "status": "invalid", + * "checkOutcome": "completed", * "reason": "undeliverable" * }, * { * "email": "ada@gmial.com", * "valid": false, * "status": "risky", + * "checkOutcome": "completed", * "reason": "unknown", * "didYouMean": "ada@gmail.com" * }, @@ -21794,9 +21939,15 @@ export interface operations { * "email": "admin@stripe.com", * "valid": false, * "status": "risky", + * "checkOutcome": "completed", * "reason": "role_address" * } - * ] + * ], + * "persistence": { + * "matched": 4, + * "modified": 4, + * "failed": 0 + * } * } */ "application/json": components["schemas"]["ContactsValidateResponse"]; @@ -21957,7 +22108,8 @@ export interface operations { * "email": "email", * "firstName": "firstName", * "lastName": "lastName" - * } + * }, + * "fileName": "customers.csv" * } */ "application/json": components["schemas"]["ContactsImportCsvRequest"]; @@ -21988,7 +22140,8 @@ export interface operations { * }, * "fieldsCreated": [], * "errors": [], - * "warnings": [] + * "warnings": [], + * "importId": "imp_01JABCDEF0123456789XYZ" * } */ "application/json": components["schemas"]["ContactsImportCsvResponse"]; @@ -22025,7 +22178,8 @@ export interface operations { * "message": "Field 'customFields.plan' is type 'number' but received string '$49'." * } * ], - * "warnings": [] + * "warnings": [], + * "importId": "imp_01JABCDEF0123456789XYZ" * } */ "application/json": { @@ -22057,8 +22211,296 @@ export interface operations { risky: number; invalid: number; unscored: number; + persistence?: { + matched: number; + modified: number; + failed: number; + }; + }; + validationJobId?: string; + /** @description This batch's Import History id. Every contact it wrote carries it as `importId`, so `search_contacts` with an `importId` filter lists them. */ + importId: string; + }; + }; + }; + /** + * @description `BRAND_ID_REQUIRED`: An organization-scoped credential called a brand-scoped operation without naming the brand. + * + * `INVALID_REQUEST`: The body or query failed validation: an unknown key, a wrong type, or a missing required field. `param` names the offender. + */ + 400: { + headers: { + /** @description Unique request identifier. Share this with support when debugging a request. */ + "x-request-id": string; + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ApiErrorEnvelope"]; + }; + }; + /** + * @description `API_KEY_REVOKED`: The API key was revoked. + * + * `AUTHENTICATION_REQUIRED`: No API key or session accompanied the request. + * + * `INVALID_API_KEY`: The API key is malformed or unknown. + */ + 401: { + headers: { + /** @description Unique request identifier. Share this with support when debugging a request. */ + "x-request-id": string; + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ApiErrorEnvelope"]; + }; + }; + /** @description `INSUFFICIENT_CREDITS`: The credit balance is below what this operation requires; `details.cost` is the amount needed. */ + 402: { + headers: { + /** @description Unique request identifier. Share this with support when debugging a request. */ + "x-request-id": string; + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ApiErrorEnvelope"]; + }; + }; + /** + * @description `ACCOUNT_SUSPENDED`: The organization behind the credential is suspended. + * + * `BRAND_SCOPE_MISMATCH`: A brand-scoped credential named a brand other than the one it is bound to. + * + * `INSUFFICIENT_PERMISSIONS`: The credential lacks the permission scope the operation needs. + * + * `INSUFFICIENT_ROLE`: The caller lacks the access the operation needs: `param` names `member` (access to the brand) or `org_admin` (the organization role). + */ + 403: { + headers: { + /** @description Unique request identifier. Share this with support when debugging a request. */ + "x-request-id": string; + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ApiErrorEnvelope"]; + }; + }; + /** @description `BRAND_NOT_FOUND`: The named or bound brand does not exist in this organization (unknown, deleting, or another organization). */ + 404: { + headers: { + /** @description Unique request identifier. Share this with support when debugging a request. */ + "x-request-id": string; + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ApiErrorEnvelope"]; + }; + }; + /** + * @description `IDEMPOTENCY_CONFLICT`: The same Idempotency-Key was reused with a different request body. + * + * `IDEMPOTENCY_IN_PROGRESS`: A request with this Idempotency-Key is still executing. + */ + 409: { + headers: { + /** @description Unique request identifier. Share this with support when debugging a request. */ + "x-request-id": string; + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ApiErrorEnvelope"]; + }; + }; + /** @description `RATE_LIMITED`: The credential exhausted the rolling window for this route policy; Retry-After is when the oldest recorded request leaves, and the refused request does not take a slot. */ + 429: { + headers: { + /** @description Unique request identifier. Share this with support when debugging a request. */ + "x-request-id": string; + /** @description Requests allowed in the current rolling rate limit window. */ + "X-RateLimit-Limit": number; + /** @description Requests remaining in the current rolling rate limit window. */ + "X-RateLimit-Remaining": number; + /** @description Unix timestamp in seconds when the oldest recorded request leaves the rolling window. */ + "X-RateLimit-Reset": number; + /** @description Seconds to wait before retrying the request. */ + "Retry-After": number; + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ApiErrorEnvelope"]; + }; + }; + /** @description `INTERNAL_ERROR`: An unexpected failure; the x-request-id header identifies it. */ + 500: { + headers: { + /** @description Unique request identifier. Share this with support when debugging a request. */ + "x-request-id": string; + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ApiErrorEnvelope"]; + }; + }; + /** @description `SERVICE_UNAVAILABLE`: A dependency the operation must consult (billing, the idempotency store) is temporarily unavailable, so the request was refused rather than run unmetered. */ + 503: { + headers: { + /** @description Unique request identifier. Share this with support when debugging a request. */ + "x-request-id": string; + /** @description Seconds to wait before retrying the request. */ + "Retry-After": number; + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ApiErrorEnvelope"]; + }; + }; + }; + }; + importContacts: { + parameters: { + query?: never; + header?: { + /** + * @description The brand this request acts on. REQUIRED for organization-scoped credentials (otherwise `400 BRAND_ID_REQUIRED` — there is no default brand); list ids with `GET /v1/brands`. Brand-scoped credentials may omit it, and sending a different brand returns `403 BRAND_SCOPE_MISMATCH`. A brand outside your organization returns `404 BRAND_NOT_FOUND`. + * @example kx7b3s7fapqz8mjm12ekz1kxdx87yceg + */ + "X-Brand-Id"?: string; + }; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + /** + * @example { + * "contacts": [ + * { + * "email": "ada@example.com", + * "firstName": "Ada" + * }, + * { + * "email": "grace@example.com", + * "firstName": "Grace", + * "customFields": { + * "plan": "pro" + * } + * } + * ], + * "consent": { + * "source": "import" + * }, + * "fileName": "crm-export.json" + * } + */ + "application/json": components["schemas"]["ContactsImportRequest"]; + }; + }; + responses: { + /** @description Import summary (all rows valid). */ + 200: { + headers: { + /** @description Unique request identifier. Share this with support when debugging a request. */ + "x-request-id": string; + /** @description Requests allowed in the current rolling rate limit window. */ + "X-RateLimit-Limit": number; + /** @description Requests remaining in the current rolling rate limit window. */ + "X-RateLimit-Remaining": number; + /** @description Unix timestamp in seconds when the oldest recorded request leaves the rolling window. */ + "X-RateLimit-Reset": number; + [name: string]: unknown; + }; + content: { + /** + * @example { + * "summary": { + * "inserted": 2, + * "updated": 0, + * "failed": 0, + * "skipped": 0 + * }, + * "fieldsCreated": [ + * "plan" + * ], + * "errors": [], + * "warnings": [], + * "importId": "imp_01JABCDEF0123456789XYZ" + * } + */ + "application/json": components["schemas"]["ContactsImportResponse"]; + }; + }; + /** @description Partial success — some rows failed validation. */ + 207: { + headers: { + /** @description Unique request identifier. Share this with support when debugging a request. */ + "x-request-id": string; + /** @description Requests allowed in the current rolling rate limit window. */ + "X-RateLimit-Limit": number; + /** @description Requests remaining in the current rolling rate limit window. */ + "X-RateLimit-Remaining": number; + /** @description Unix timestamp in seconds when the oldest recorded request leaves the rolling window. */ + "X-RateLimit-Reset": number; + [name: string]: unknown; + }; + content: { + /** + * @example { + * "summary": { + * "inserted": 1, + * "updated": 0, + * "failed": 1, + * "skipped": 0 + * }, + * "fieldsCreated": [], + * "errors": [ + * { + * "email": "not-an-email", + * "code": "INVALID_EMAIL", + * "message": "'not-an-email' is not a valid email address." + * } + * ], + * "warnings": [], + * "importId": "imp_01JABCDEF0123456789XYZ" + * } + */ + "application/json": { + summary: { + inserted: number; + updated: number; + failed: number; + skipped: number; + }; + fieldsCreated: string[]; + errors: { + email: string; + code: string; + field?: string; + message: string; + }[]; + warnings: { + code: string; + email?: string; + field?: string; + message: string; + from?: string; + to?: string; + original?: string; + normalized?: string; + }[]; + validation?: { + valid: number; + risky: number; + invalid: number; + unscored: number; + persistence?: { + matched: number; + modified: number; + failed: number; + }; }; validationJobId?: string; + /** @description This batch's Import History id. Every contact it wrote carries it as `importId`, so `search_contacts` with an `importId` filter lists them. */ + importId: string; }; }; }; diff --git a/src/registry.ts b/src/registry.ts index 995dd48..04fd866 100644 --- a/src/registry.ts +++ b/src/registry.ts @@ -73,6 +73,7 @@ import { contactsCountByCommand } from './commands/contacts/count-by' import { contactsDeleteCommand } from './commands/contacts/delete' import { contactsDeleteManyCommand } from './commands/contacts/delete-many' import { contactsGetCommand } from './commands/contacts/get' +import { contactsImportCommand } from './commands/contacts/import' import { contactsImportCsvCommand } from './commands/contacts/import-csv' import { contactsListCommand } from './commands/contacts/list' import { contactsSearchCommand } from './commands/contacts/search' @@ -175,6 +176,7 @@ export const ALL_COMMANDS: readonly CommandSpec[] = [ contactsDeleteCommand, contactsDeleteManyCommand, contactsValidateCommand, + contactsImportCommand, contactsImportCsvCommand, fieldsListCommand, fieldsGetCommand, diff --git a/tests/commands/contacts.test.ts b/tests/commands/contacts.test.ts index 41244f0..d5dcdfe 100644 --- a/tests/commands/contacts.test.ts +++ b/tests/commands/contacts.test.ts @@ -303,6 +303,7 @@ describe('contacts import-csv', () => { expect(body).toEqual({ csv: 'Email\njane@example.com\n', mapping: { Email: 'email' }, + fileName: 'contacts.csv', }) }) @@ -340,6 +341,7 @@ describe('contacts import-csv', () => { dateOrder: 'day_first', validate: true, consent: { source: 'import' }, + fileName: 'contacts.csv', }) }) @@ -375,6 +377,7 @@ describe('contacts import-csv', () => { csv: 'Email\njane@example.com\n', dateOrder: 'day_first', consent: { source: 'form', evidence: 'Footer form' }, + fileName: 'contacts.csv', }) }) @@ -413,6 +416,7 @@ describe('contacts import-csv', () => { evidence: 'Footer form', policyVersion: 'v3', }, + fileName: 'contacts.csv', }) }) @@ -459,6 +463,116 @@ describe('contacts import-csv', () => { ) expect(result.code).toBe(2) }) + it('names the batch with --file-name, and sends no name for stdin', async () => { + const bodies: Array = [] + server.use( + http.post( + 'https://brew.new/api/v1/contacts/import-csv', + async ({ request }) => { + bodies.push(await request.json()) + return HttpResponse.json({ imported: 1 }) + } + ) + ) + const dir = mkdtempSync(join(tmpdir(), 'brew-cli-csv-')) + const csvPath = join(dir, 'export-2026.csv') + writeFileSync(csvPath, 'Email\njane@example.com\n') + const named = await runCli( + [ + 'contacts', + 'import-csv', + '--file', + csvPath, + '--file-name', + 'October webinar', + ], + { env: env() } + ) + const piped = await runCli(['contacts', 'import-csv', '--file', '-'], { + env: env(), + stdin: 'Email\njane@example.com\n', + }) + expect(named.code).toBe(0) + expect(piped.code).toBe(0) + expect(bodies).toEqual([ + { csv: 'Email\njane@example.com\n', fileName: 'October webinar' }, + { csv: 'Email\njane@example.com\n' }, + ]) + }) +}) + +describe('contacts import', () => { + const IMPORT_URL = 'https://brew.new/api/v1/contacts/import' + + it('reads a JSON array file and sends it as one named batch', async () => { + let body: unknown + server.use( + http.post(IMPORT_URL, async ({ request }) => { + body = await request.json() + return HttpResponse.json({ + summary: { inserted: 1, updated: 0, failed: 0, skipped: 0 }, + fieldsCreated: [], + errors: [], + warnings: [], + importId: 'imp_1', + }) + }) + ) + const dir = mkdtempSync(join(tmpdir(), 'brew-cli-json-')) + const jsonPath = join(dir, 'crm-export.json') + writeFileSync( + jsonPath, + JSON.stringify([{ email: 'jane@example.com', firstName: 'Jane' }]) + ) + const result = await runCli( + [ + 'contacts', + 'import', + '--file', + jsonPath, + '--consent-source', + 'import', + '--validate', + ], + { env: env() } + ) + expect(result.code).toBe(0) + expect(body).toEqual({ + contacts: [{ email: 'jane@example.com', firstName: 'Jane' }], + fileName: 'crm-export.json', + validate: true, + consent: { source: 'import' }, + }) + expect(result.stdout).toContain('imp_1') + }) + + it('takes { contacts } from stdin and refuses an empty batch', async () => { + let body: unknown + server.use( + http.post(IMPORT_URL, async ({ request }) => { + body = await request.json() + return HttpResponse.json({ importId: 'imp_2' }) + }) + ) + const result = await runCli( + ['contacts', 'import', '--file', '-', '--file-name', 'sync.json'], + { + env: env(), + stdin: JSON.stringify({ contacts: [{ email: 'a@example.com' }] }), + } + ) + expect(result.code).toBe(0) + expect(body).toEqual({ + contacts: [{ email: 'a@example.com' }], + fileName: 'sync.json', + }) + + const empty = await runCli(['contacts', 'import', '--input', '[]'], { + env: env(), + }) + expect(empty.code).toBe(2) + expect(empty.stderr).toContain('--file') + }) }) describe('fields', () => { From 6bc6526574fa2000724d04b462e84e2ac5fba9cc Mon Sep 17 00:00:00 2001 From: thedogwiththedataonit Date: Tue, 20 Oct 2026 21:33:38 +0900 Subject: [PATCH 02/16] Keep JSON-body import options; CSV credits per row; pin SDK 07b8a6f - `contacts import`: a JSON object body read from --file keeps its own `validate`, `consent` and `fileName`; flags override only when given, and a non-array, non-object --file is a usage error (Greptile P1). - `contacts import-csv`: --file-name, else an --input `fileName`, else the --file name. - CSV validation is metered per row (help text). - Pins the SDK commit with the shared import response type; re-vendors the spec and regenerates types and command docs. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 9 +- bun.lock | 4 +- docs/commands/README.md | 6 +- openapi/public-api-v1.yaml | 398 +--------------------------- package.json | 2 +- src/commands/contacts/import-csv.ts | 25 +- src/commands/contacts/import.ts | 28 +- src/generated/openapi-types.ts | 130 +-------- tests/commands/contacts.test.ts | 81 ++++++ 9 files changed, 149 insertions(+), 534 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 49e8488..781c903 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,9 +7,12 @@ - **`contacts import`** (`POST /v1/contacts/import`): bulk-import up to 1000 contacts from a JSON file (an array, or `{ "contacts": [...] }`) or stdin as one batch in Brew's Import History. Takes `--file-name`, `--validate` and - `--consent-source`, and prints the batch's `importId`. -- `contacts import-csv` names its batch in Import History: the `--file` name - by default, or `--file-name`. Both imports return `importId`. + `--consent-source`, and prints the batch's `importId`. A JSON object body + keeps its own `validate`, `consent` and `fileName`; a flag overrides them + only when given. +- `contacts import-csv` names its batch in Import History: `--file-name`, else + a `fileName` in `--input`, else the `--file` name. Both imports return + `importId`. ### Changed diff --git a/bun.lock b/bun.lock index 72d4ff3..2712202 100644 --- a/bun.lock +++ b/bun.lock @@ -5,7 +5,7 @@ "": { "name": "@brew.new/cli", "dependencies": { - "@brew.new/sdk": "git+https://github.com/GetBrew/typescript-sdk.git#fa924c46bba610ab51cd78eff2d72c2191aaf234", + "@brew.new/sdk": "git+https://github.com/GetBrew/typescript-sdk.git#07b8a6f132426233be6df9de2718d74a4b6bc0c2", "commander": "^14.0.0", }, "devDependencies": { @@ -46,7 +46,7 @@ "@biomejs/cli-win32-x64": ["@biomejs/cli-win32-x64@2.5.7", "", { "os": "win32", "cpu": "x64" }, "sha512-V+0wu/nrj2S+MhP4EQ0uHNolP0IALEsz45pg0WoKkHfDeh0+ItHwP/p7bX5RPoMOl9NkpHYWdYPhIcy2mACHvQ=="], - "@brew.new/sdk": ["@brew.new/sdk@github:GetBrew/typescript-sdk#fa924c4", { "peerDependencies": { "react": ">=18.2.0 <20" }, "optionalPeers": ["react"] }, "GetBrew-typescript-sdk-fa924c4", "sha512-IpWN6jMmjRp0TXkz8ux5dM+DGeXRtyqHKSQWt0MSAgcaKo02CnIfcXXBtVOJJITvGZSh835AkQ4njPxuQrdO/w=="], + "@brew.new/sdk": ["@brew.new/sdk@github:GetBrew/typescript-sdk#07b8a6f", { "peerDependencies": { "react": ">=18.2.0 <20" }, "optionalPeers": ["react"] }, "GetBrew-typescript-sdk-07b8a6f", "sha512-1wwQ2LkToq1/8hnv6LkSZndMqO0zW3e6ThcWEDuwS+CaGJHLdtlgxvtgiZWj0hHrWLw27XYAw7p9sjPpkIlDog=="], "@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.27.7", "", { "os": "aix", "cpu": "ppc64" }, "sha512-EKX3Qwmhz1eMdEJokhALr0YiD0lhQNwDqkPYyPhiSwKrh7/4KRjQc04sZ8db+5DVVnZ1LmbNDI1uAMPEUBnQPg=="], diff --git a/docs/commands/README.md b/docs/commands/README.md index d262903..11d5f85 100644 --- a/docs/commands/README.md +++ b/docs/commands/README.md @@ -41,7 +41,7 @@ a `confirmCommand` otherwise, `--yes` to proceed). | `brew-cli contacts delete-many` | destructive | `POST /v1/contacts/batch-delete` | Delete up to 1000 contacts by email | | `brew-cli contacts validate` | write ($) | `POST /v1/contacts/validate` | Batch-validate email deliverability (no contacts created) | | `brew-cli contacts import` | write | `POST /v1/contacts/import` | Bulk-import up to 1000 contacts from a JSON file or stdin as one Import History batch (free; --validate costs 2 credits per address) | -| `brew-cli contacts import-csv` | write | `POST /v1/contacts/import-csv` | Bulk-import contacts from a CSV file or stdin as one Import History batch (free; --validate costs 2 credits per address) | +| `brew-cli contacts import-csv` | write | `POST /v1/contacts/import-csv` | Bulk-import contacts from a CSV file or stdin as one Import History batch (free; --validate costs 2 credits per row) | | `brew-cli fields list` | read | `GET /v1/fields` | List custom contact fields | | `brew-cli fields get` | read | `GET /v1/fields/{fieldName}` | Fetch one contact field definition by name — the bare row | | `brew-cli fields create` | write | `POST /v1/fields` | Create a custom contact field | @@ -473,7 +473,7 @@ cat contacts.json | brew-cli contacts import --file - --file-name crm-export.jso ### brew-cli contacts import-csv -Bulk-import contacts from a CSV file or stdin as one Import History batch (free; --validate costs 2 credits per address) +Bulk-import contacts from a CSV file or stdin as one Import History batch (free; --validate costs 2 credits per row) - Route: `POST /v1/contacts/import-csv` - Class: write @@ -482,7 +482,7 @@ Bulk-import contacts from a CSV file or stdin as one Import History batch (free; - `--file-name ` — Name the batch in Brew's Import History (default: the --file name) - `--mapping ` — Column mapping csvColumn=fieldName, repeatable - `--date-order ` — month_first or day_first: how to read dates like 03/04/2026 (a day over 12 in the column wins; default month-first, with a DATE_ORDER_ASSUMED warning) -- `--validate` — Deliverability-check every imported address (2 credits per address) +- `--validate` — Deliverability-check every imported address (2 credits per CSV row) - `--consent-source ` — Stamp a marketing consent record on every row: api, form or import (sets the source of a record given in --input) - `--input ` — Full JSON request body, or - to read stdin (flags override it) - `--idempotency-key ` — Idempotency-Key for safe retries (auto-generated otherwise) diff --git a/openapi/public-api-v1.yaml b/openapi/public-api-v1.yaml index 5f7f03a..ae5f0d3 100644 --- a/openapi/public-api-v1.yaml +++ b/openapi/public-api-v1.yaml @@ -13967,7 +13967,7 @@ components: required: - emails additionalProperties: false - ContactsImportCsvResponse: + ContactsImportResponse: type: object properties: summary: @@ -13985,6 +13985,7 @@ components: skipped: type: integer minimum: 0 + description: CSV rows left out for a missing or invalid email, or merged into a later row with the same address. Always 0 for a JSON import. required: - inserted - updated @@ -14088,7 +14089,7 @@ components: type: string importId: type: string - description: This batch's Import History id. Every contact it wrote carries it as `importId`, so `search_contacts` with an `importId` filter lists them. + description: This batch's Import History id. Each contact it wrote carries it as `importId` until a later import touches that contact, so filtering contacts on `importId` (`POST /v1/contacts/search`) lists them. required: - summary - fieldsCreated @@ -14150,135 +14151,6 @@ components: required: - csv additionalProperties: false - ContactsImportResponse: - type: object - properties: - summary: - type: object - properties: - inserted: - type: integer - minimum: 0 - updated: - type: integer - minimum: 0 - failed: - type: integer - minimum: 0 - skipped: - type: integer - minimum: 0 - required: - - inserted - - updated - - failed - - skipped - additionalProperties: false - fieldsCreated: - type: array - items: - type: string - errors: - type: array - items: - type: object - properties: - email: - type: string - code: - type: string - field: - type: string - message: - type: string - required: - - email - - code - - message - additionalProperties: false - warnings: - type: array - items: - type: object - properties: - code: - type: string - minLength: 1 - email: - type: string - minLength: 1 - field: - type: string - minLength: 1 - message: - type: string - minLength: 1 - from: - type: string - minLength: 1 - to: - type: string - minLength: 1 - original: - type: string - minLength: 1 - normalized: - type: string - minLength: 1 - required: - - code - - message - additionalProperties: false - validation: - type: object - properties: - valid: - type: integer - minimum: 0 - risky: - type: integer - minimum: 0 - invalid: - type: integer - minimum: 0 - unscored: - type: integer - minimum: 0 - persistence: - type: object - properties: - matched: - type: integer - minimum: 0 - modified: - type: integer - minimum: 0 - failed: - type: integer - minimum: 0 - required: - - matched - - modified - - failed - additionalProperties: false - required: - - valid - - risky - - invalid - - unscored - additionalProperties: false - validationJobId: - type: string - importId: - type: string - description: This batch's Import History id. Every contact it wrote carries it as `importId`, so `search_contacts` with an `importId` filter lists them. - required: - - summary - - fieldsCreated - - errors - - warnings - - importId - additionalProperties: false ContactsImportRequest: type: object properties: @@ -48738,7 +48610,7 @@ paths: - Contacts summary: Bulk-import contacts from CSV description: |- - Parse a raw CSV string and upsert the rows as contacts (≤ 1000). By default each column header maps to a field of the same name (`email` is required). A header that spells a core field, ignoring case, spaces, `_` and `-`, or whose field key equals it (`First.Name`), maps to it: `Email`, `First Name`, `Last Name` and `Subscribed` fill `email`, `firstName`, `lastName` and `subscribed`. Pass `mapping` to remap single columns; the columns it leaves out keep their default mapping, `""` leaves a column out, and a key that names no column is a `400`. A core field that `mapping` fills is filled from that column alone: another column whose default would fill it is left out with a `CSV_COLUMN_IGNORED` warning. Rows with a missing/invalid email are SKIPPED (counted in `summary.skipped`), not errored; disposable-domain rows are imported with `validationStatus: "risky"` (deliverable but flagged). Returns the same `{ summary, fieldsCreated, errors, warnings }` as batch-create plus `importId` — `207` when some rows fail. The batch is listed in Import History under `fileName` (optional), and every contact it writes carries `importId` (filter on it to list them; deleting the import in the app removes the contacts it created). For rows you already hold as JSON, use `POST /v1/contacts/import`. + Parse a raw CSV string and upsert the rows as contacts (≤ 1000). By default each column header maps to a field of the same name (`email` is required). A header that spells a core field, ignoring case, spaces, `_` and `-`, or whose field key equals it (`First.Name`), maps to it: `Email`, `First Name`, `Last Name` and `Subscribed` fill `email`, `firstName`, `lastName` and `subscribed`. Pass `mapping` to remap single columns; the columns it leaves out keep their default mapping, `""` leaves a column out, and a key that names no column is a `400`. A core field that `mapping` fills is filled from that column alone: another column whose default would fill it is left out with a `CSV_COLUMN_IGNORED` warning. Rows with a missing/invalid email are SKIPPED (counted in `summary.skipped`), not errored; disposable-domain rows are imported with `validationStatus: "risky"` (deliverable but flagged). Returns the same `{ summary, fieldsCreated, errors, warnings }` as batch-create plus `importId` — `207` when some rows fail. The batch is listed in Import History under `fileName` (optional), and each contact it writes carries `importId` until a later import touches it (filter on it to list them; deleting the import in the app removes the contacts it created). For rows you already hold as JSON, use `POST /v1/contacts/import`. CSV cells are text: a column mapped onto an EXISTING field is coerced to that field's type (dates → epoch ms, `1,234` → 1234, `yes`/`no` → booleans), and a cell that cannot be coerced (`$49` in a number column) fails ITS row with a per-row `errors[]` entry (`code: FIELD_TYPE_MISMATCH`, `field`) while the other rows still land. An undeclared column is created as a `string` field unless every value is an ISO date (→ `date`); predeclare `number` / `bool` columns with `POST /v1/fields`. A column whose field Brew manages (`Created At`, `Validation Status`) is not imported and adds a `warnings[]` entry (`code: CSV_COLUMN_IGNORED`, `field` names the column). @@ -48796,7 +48668,7 @@ paths: content: application/json: schema: - $ref: "#/components/schemas/ContactsImportCsvResponse" + $ref: "#/components/schemas/ContactsImportResponse" example: summary: inserted: 2 @@ -48841,134 +48713,7 @@ paths: content: application/json: schema: - type: object - properties: - summary: - type: object - properties: - inserted: - type: integer - minimum: 0 - updated: - type: integer - minimum: 0 - failed: - type: integer - minimum: 0 - skipped: - type: integer - minimum: 0 - required: - - inserted - - updated - - failed - - skipped - additionalProperties: false - fieldsCreated: - type: array - items: - type: string - errors: - type: array - items: - type: object - properties: - email: - type: string - code: - type: string - field: - type: string - message: - type: string - required: - - email - - code - - message - additionalProperties: false - warnings: - type: array - items: - type: object - properties: - code: - type: string - minLength: 1 - email: - type: string - minLength: 1 - field: - type: string - minLength: 1 - message: - type: string - minLength: 1 - from: - type: string - minLength: 1 - to: - type: string - minLength: 1 - original: - type: string - minLength: 1 - normalized: - type: string - minLength: 1 - required: - - code - - message - additionalProperties: false - validation: - type: object - properties: - valid: - type: integer - minimum: 0 - risky: - type: integer - minimum: 0 - invalid: - type: integer - minimum: 0 - unscored: - type: integer - minimum: 0 - persistence: - type: object - properties: - matched: - type: integer - minimum: 0 - modified: - type: integer - minimum: 0 - failed: - type: integer - minimum: 0 - required: - - matched - - modified - - failed - additionalProperties: false - required: - - valid - - risky - - invalid - - unscored - additionalProperties: false - validationJobId: - type: string - importId: - type: string - description: This batch's Import History id. Every contact it wrote carries it as `importId`, so `search_contacts` with an `importId` filter lists them. - required: - - summary - - fieldsCreated - - errors - - warnings - - importId - additionalProperties: false + $ref: "#/components/schemas/ContactsImportResponse" example: summary: inserted: 1 @@ -49350,7 +49095,7 @@ paths: description: |- Create or update up to 1000 contacts from JSON rows and record them as one Import History batch: the JSON twin of `POST /v1/contacts/import-csv`. Rows take the batch-create shape (`email`, `firstName`, `lastName`, `subscribed`, `customFields`, `consent`) and follow its rules: a missing or invalid email, or an address repeated in the batch, fails its row (`errors[]`, `207`) while the others land. New contacts are subscribed unless the row says `subscribed: false`; re-subscribing a contact who opted out keeps its other fields and adds a `RESUBSCRIBE_SKIPPED` warning. - The batch is listed in Import History under `fileName` (optional), and every contact it writes carries the returned `importId` (filter on it to list them; deleting the import in the app removes the contacts it created). `POST /v1/contacts` with `contacts[]` writes the same rows without an Import History entry, for recurring syncs. + The batch is listed in Import History under `fileName` (optional), and each contact it writes carries the returned `importId` until a later import touches it (filter on it to list them; deleting the import in the app removes the contacts it created). `POST /v1/contacts` with `contacts[]` writes the same rows without an Import History entry, for recurring syncs. requestBody: required: true content: @@ -49449,134 +49194,7 @@ paths: content: application/json: schema: - type: object - properties: - summary: - type: object - properties: - inserted: - type: integer - minimum: 0 - updated: - type: integer - minimum: 0 - failed: - type: integer - minimum: 0 - skipped: - type: integer - minimum: 0 - required: - - inserted - - updated - - failed - - skipped - additionalProperties: false - fieldsCreated: - type: array - items: - type: string - errors: - type: array - items: - type: object - properties: - email: - type: string - code: - type: string - field: - type: string - message: - type: string - required: - - email - - code - - message - additionalProperties: false - warnings: - type: array - items: - type: object - properties: - code: - type: string - minLength: 1 - email: - type: string - minLength: 1 - field: - type: string - minLength: 1 - message: - type: string - minLength: 1 - from: - type: string - minLength: 1 - to: - type: string - minLength: 1 - original: - type: string - minLength: 1 - normalized: - type: string - minLength: 1 - required: - - code - - message - additionalProperties: false - validation: - type: object - properties: - valid: - type: integer - minimum: 0 - risky: - type: integer - minimum: 0 - invalid: - type: integer - minimum: 0 - unscored: - type: integer - minimum: 0 - persistence: - type: object - properties: - matched: - type: integer - minimum: 0 - modified: - type: integer - minimum: 0 - failed: - type: integer - minimum: 0 - required: - - matched - - modified - - failed - additionalProperties: false - required: - - valid - - risky - - invalid - - unscored - additionalProperties: false - validationJobId: - type: string - importId: - type: string - description: This batch's Import History id. Every contact it wrote carries it as `importId`, so `search_contacts` with an `importId` filter lists them. - required: - - summary - - fieldsCreated - - errors - - warnings - - importId - additionalProperties: false + $ref: "#/components/schemas/ContactsImportResponse" example: summary: inserted: 1 diff --git a/package.json b/package.json index f429937..88216fc 100644 --- a/package.json +++ b/package.json @@ -53,7 +53,7 @@ "smoke:signals": "bun run scripts/signal-smoke.ts" }, "dependencies": { - "@brew.new/sdk": "git+https://github.com/GetBrew/typescript-sdk.git#fa924c46bba610ab51cd78eff2d72c2191aaf234", + "@brew.new/sdk": "git+https://github.com/GetBrew/typescript-sdk.git#07b8a6f132426233be6df9de2718d74a4b6bc0c2", "commander": "^14.0.0" }, "devDependencies": { diff --git a/src/commands/contacts/import-csv.ts b/src/commands/contacts/import-csv.ts index dd278b4..51241b4 100644 --- a/src/commands/contacts/import-csv.ts +++ b/src/commands/contacts/import-csv.ts @@ -19,7 +19,7 @@ type ImportCsvContactsInput = Parameters[0] export const contactsImportCsvCommand = defineCommand({ path: ['contacts', 'import-csv'], summary: - 'Bulk-import contacts from a CSV file or stdin as one Import History batch (free; --validate costs 2 credits per address)', + 'Bulk-import contacts from a CSV file or stdin as one Import History batch (free; --validate costs 2 credits per row)', sdkMethod: 'contacts.importCsv', route: { method: 'POST', path: '/v1/contacts/import-csv' }, commandClass: 'write', @@ -45,7 +45,7 @@ export const contactsImportCsvCommand = defineCommand({ { flag: '--validate', summary: - 'Deliverability-check every imported address (2 credits per address)', + 'Deliverability-check every imported address (2 credits per CSV row)', }, { flag: '--consent-source ', @@ -71,8 +71,11 @@ export const contactsImportCsvCommand = defineCommand({ mapping: parseKeyValues(flags.mapping, '--mapping'), dateOrder: flagString(flags.dateOrder), validate: flags.validate === true ? true : undefined, - fileName: importFileName(flags), }) + const fileName = importFileName(flags, input) + if (fileName !== undefined) { + input.fileName = fileName + } if (typeof input.csv !== 'string' || input.csv.trim() === '') { throw new CliUsageError( 'Pass the CSV with --file (a path, or - for stdin) or as csv in --input.' @@ -98,17 +101,21 @@ export const contactsImportCsvCommand = defineCommand({ }) /** - * The Import History name: `--file-name`, else the file's own name (stdin - * has none, so the batch is named by its source in Brew). + * The Import History name: `--file-name`, else a `fileName` the JSON body + * already carries, else the file's own name (stdin has none, so the batch is + * named by its source in Brew). `undefined` leaves the body's value alone. */ -export function importFileName(flags: { - readonly fileName?: unknown - readonly file?: unknown -}): string | undefined { +export function importFileName( + flags: { readonly fileName?: unknown; readonly file?: unknown }, + body: Readonly> = {} +): string | undefined { const explicit = flagString(flags.fileName) if (explicit !== undefined) { return explicit } + if (typeof body.fileName === 'string') { + return + } const file = flagString(flags.file) return file === undefined || file === '-' ? undefined : basename(file) } diff --git a/src/commands/contacts/import.ts b/src/commands/contacts/import.ts index 53e9dde..7464394 100644 --- a/src/commands/contacts/import.ts +++ b/src/commands/contacts/import.ts @@ -58,10 +58,15 @@ export const contactsImportCommand = defineCommand({ await readTextFlag(ctx, flags.file, '--file') ) const base = await readJsonFlag(ctx, flags.input, '--input') - const rows = Array.isArray(fromFile) ? { contacts: fromFile } : fromFile - const input = mergeInput(Array.isArray(base) ? { contacts: base } : base, { - ...(rows && typeof rows === 'object' ? rows : {}), - fileName: importFileName(flags), + // The file's body (an array, or an object that may carry `validate`, + // `consent` or `fileName`) layers over --input; flags apply only when + // given, so a value the body set is never replaced by an absent flag. + const body = mergeInput( + Array.isArray(base) ? { contacts: base } : base, + asBody(fromFile) + ) + const input = mergeInput(body, { + fileName: importFileName(flags, body), validate: flags.validate === true ? true : undefined, }) if (!Array.isArray(input.contacts) || input.contacts.length === 0) { @@ -87,6 +92,21 @@ export const contactsImportCommand = defineCommand({ }, }) +function asBody(fromFile: unknown): Record { + if (fromFile === undefined) { + return {} + } + if (Array.isArray(fromFile)) { + return { contacts: fromFile } + } + if (fromFile === null || typeof fromFile !== 'object') { + throw new CliUsageError( + '--file must hold JSON: an array of contacts or { "contacts": [...] }' + ) + } + return fromFile as Record +} + function parseContactsFile(text: string | undefined): unknown { if (text === undefined) { return diff --git a/src/generated/openapi-types.ts b/src/generated/openapi-types.ts index 46de895..902e669 100644 --- a/src/generated/openapi-types.ts +++ b/src/generated/openapi-types.ts @@ -1545,7 +1545,7 @@ export interface paths { put?: never; /** * Bulk-import contacts from CSV - * @description Parse a raw CSV string and upsert the rows as contacts (≤ 1000). By default each column header maps to a field of the same name (`email` is required). A header that spells a core field, ignoring case, spaces, `_` and `-`, or whose field key equals it (`First.Name`), maps to it: `Email`, `First Name`, `Last Name` and `Subscribed` fill `email`, `firstName`, `lastName` and `subscribed`. Pass `mapping` to remap single columns; the columns it leaves out keep their default mapping, `""` leaves a column out, and a key that names no column is a `400`. A core field that `mapping` fills is filled from that column alone: another column whose default would fill it is left out with a `CSV_COLUMN_IGNORED` warning. Rows with a missing/invalid email are SKIPPED (counted in `summary.skipped`), not errored; disposable-domain rows are imported with `validationStatus: "risky"` (deliverable but flagged). Returns the same `{ summary, fieldsCreated, errors, warnings }` as batch-create plus `importId` — `207` when some rows fail. The batch is listed in Import History under `fileName` (optional), and every contact it writes carries `importId` (filter on it to list them; deleting the import in the app removes the contacts it created). For rows you already hold as JSON, use `POST /v1/contacts/import`. + * @description Parse a raw CSV string and upsert the rows as contacts (≤ 1000). By default each column header maps to a field of the same name (`email` is required). A header that spells a core field, ignoring case, spaces, `_` and `-`, or whose field key equals it (`First.Name`), maps to it: `Email`, `First Name`, `Last Name` and `Subscribed` fill `email`, `firstName`, `lastName` and `subscribed`. Pass `mapping` to remap single columns; the columns it leaves out keep their default mapping, `""` leaves a column out, and a key that names no column is a `400`. A core field that `mapping` fills is filled from that column alone: another column whose default would fill it is left out with a `CSV_COLUMN_IGNORED` warning. Rows with a missing/invalid email are SKIPPED (counted in `summary.skipped`), not errored; disposable-domain rows are imported with `validationStatus: "risky"` (deliverable but flagged). Returns the same `{ summary, fieldsCreated, errors, warnings }` as batch-create plus `importId` — `207` when some rows fail. The batch is listed in Import History under `fileName` (optional), and each contact it writes carries `importId` until a later import touches it (filter on it to list them; deleting the import in the app removes the contacts it created). For rows you already hold as JSON, use `POST /v1/contacts/import`. * * CSV cells are text: a column mapped onto an EXISTING field is coerced to that field's type (dates → epoch ms, `1,234` → 1234, `yes`/`no` → booleans), and a cell that cannot be coerced (`$49` in a number column) fails ITS row with a per-row `errors[]` entry (`code: FIELD_TYPE_MISMATCH`, `field`) while the other rows still land. An undeclared column is created as a `string` field unless every value is an ISO date (→ `date`); predeclare `number` / `bool` columns with `POST /v1/fields`. A column whose field Brew manages (`Created At`, `Validation Status`) is not imported and adds a `warnings[]` entry (`code: CSV_COLUMN_IGNORED`, `field` names the column). * @@ -1573,7 +1573,7 @@ export interface paths { * Bulk-import contacts from JSON * @description Create or update up to 1000 contacts from JSON rows and record them as one Import History batch: the JSON twin of `POST /v1/contacts/import-csv`. Rows take the batch-create shape (`email`, `firstName`, `lastName`, `subscribed`, `customFields`, `consent`) and follow its rules: a missing or invalid email, or an address repeated in the batch, fails its row (`errors[]`, `207`) while the others land. New contacts are subscribed unless the row says `subscribed: false`; re-subscribing a contact who opted out keeps its other fields and adds a `RESUBSCRIBE_SKIPPED` warning. * - * The batch is listed in Import History under `fileName` (optional), and every contact it writes carries the returned `importId` (filter on it to list them; deleting the import in the app removes the contacts it created). `POST /v1/contacts` with `contacts[]` writes the same rows without an Import History entry, for recurring syncs. + * The batch is listed in Import History under `fileName` (optional), and each contact it writes carries the returned `importId` until a later import touches it (filter on it to list them; deleting the import in the app removes the contacts it created). `POST /v1/contacts` with `contacts[]` writes the same rows without an Import History entry, for recurring syncs. */ post: operations["importContacts"]; delete?: never; @@ -7662,11 +7662,12 @@ export interface components { ContactsValidateRequest: { emails: string[]; }; - ContactsImportCsvResponse: { + ContactsImportResponse: { summary: { inserted: number; updated: number; failed: number; + /** @description CSV rows left out for a missing or invalid email, or merged into a later row with the same address. Always 0 for a JSON import. */ skipped: number; }; fieldsCreated: string[]; @@ -7698,7 +7699,7 @@ export interface components { }; }; validationJobId?: string; - /** @description This batch's Import History id. Every contact it wrote carries it as `importId`, so `search_contacts` with an `importId` filter lists them. */ + /** @description This batch's Import History id. Each contact it wrote carries it as `importId` until a later import touches that contact, so filtering contacts on `importId` (`POST /v1/contacts/search`) lists them. */ importId: string; }; ContactsImportCsvRequest: { @@ -7731,45 +7732,6 @@ export interface components { /** @description Name this batch is listed under in Import History (usually the source file name, e.g. `customers.csv`), also stamped on every contact it writes as `csvFileName`. A path keeps only its last segment. */ fileName?: string; }; - ContactsImportResponse: { - summary: { - inserted: number; - updated: number; - failed: number; - skipped: number; - }; - fieldsCreated: string[]; - errors: { - email: string; - code: string; - field?: string; - message: string; - }[]; - warnings: { - code: string; - email?: string; - field?: string; - message: string; - from?: string; - to?: string; - original?: string; - normalized?: string; - }[]; - validation?: { - valid: number; - risky: number; - invalid: number; - unscored: number; - persistence?: { - matched: number; - modified: number; - failed: number; - }; - }; - validationJobId?: string; - /** @description This batch's Import History id. Every contact it wrote carries it as `importId`, so `search_contacts` with an `importId` filter lists them. */ - importId: string; - }; ContactsImportRequest: { contacts: { email?: string; @@ -22144,7 +22106,7 @@ export interface operations { * "importId": "imp_01JABCDEF0123456789XYZ" * } */ - "application/json": components["schemas"]["ContactsImportCsvResponse"]; + "application/json": components["schemas"]["ContactsImportResponse"]; }; }; /** @description Partial success — some rows failed validation. */ @@ -22182,45 +22144,7 @@ export interface operations { * "importId": "imp_01JABCDEF0123456789XYZ" * } */ - "application/json": { - summary: { - inserted: number; - updated: number; - failed: number; - skipped: number; - }; - fieldsCreated: string[]; - errors: { - email: string; - code: string; - field?: string; - message: string; - }[]; - warnings: { - code: string; - email?: string; - field?: string; - message: string; - from?: string; - to?: string; - original?: string; - normalized?: string; - }[]; - validation?: { - valid: number; - risky: number; - invalid: number; - unscored: number; - persistence?: { - matched: number; - modified: number; - failed: number; - }; - }; - validationJobId?: string; - /** @description This batch's Import History id. Every contact it wrote carries it as `importId`, so `search_contacts` with an `importId` filter lists them. */ - importId: string; - }; + "application/json": components["schemas"]["ContactsImportResponse"]; }; }; /** @@ -22463,45 +22387,7 @@ export interface operations { * "importId": "imp_01JABCDEF0123456789XYZ" * } */ - "application/json": { - summary: { - inserted: number; - updated: number; - failed: number; - skipped: number; - }; - fieldsCreated: string[]; - errors: { - email: string; - code: string; - field?: string; - message: string; - }[]; - warnings: { - code: string; - email?: string; - field?: string; - message: string; - from?: string; - to?: string; - original?: string; - normalized?: string; - }[]; - validation?: { - valid: number; - risky: number; - invalid: number; - unscored: number; - persistence?: { - matched: number; - modified: number; - failed: number; - }; - }; - validationJobId?: string; - /** @description This batch's Import History id. Every contact it wrote carries it as `importId`, so `search_contacts` with an `importId` filter lists them. */ - importId: string; - }; + "application/json": components["schemas"]["ContactsImportResponse"]; }; }; /** diff --git a/tests/commands/contacts.test.ts b/tests/commands/contacts.test.ts index d5dcdfe..83ca7f2 100644 --- a/tests/commands/contacts.test.ts +++ b/tests/commands/contacts.test.ts @@ -463,6 +463,38 @@ describe('contacts import-csv', () => { ) expect(result.code).toBe(2) }) + it('keeps a fileName from --input over the --file name', async () => { + let body: unknown + server.use( + http.post( + 'https://brew.new/api/v1/contacts/import-csv', + async ({ request }) => { + body = await request.json() + return HttpResponse.json({ imported: 1 }) + } + ) + ) + const dir = mkdtempSync(join(tmpdir(), 'brew-cli-csv-')) + const csvPath = join(dir, 'export.csv') + writeFileSync(csvPath, 'Email\njane@example.com\n') + const result = await runCli( + [ + 'contacts', + 'import-csv', + '--file', + csvPath, + '--input', + '{"fileName":"Webinar sign-ups"}', + ], + { env: env() } + ) + expect(result.code).toBe(0) + expect(body).toEqual({ + csv: 'Email\njane@example.com\n', + fileName: 'Webinar sign-ups', + }) + }) + it('names the batch with --file-name, and sends no name for stdin', async () => { const bodies: Array = [] server.use( @@ -504,6 +536,55 @@ describe('contacts import-csv', () => { describe('contacts import', () => { const IMPORT_URL = 'https://brew.new/api/v1/contacts/import' + it('keeps validate, consent and fileName a JSON body sets; flags win only when given', async () => { + const bodies: Array = [] + server.use( + http.post(IMPORT_URL, async ({ request }) => { + bodies.push(await request.json()) + return HttpResponse.json({ importId: 'imp_3' }) + }) + ) + const stdin = JSON.stringify({ + contacts: [{ email: 'a@example.com' }], + validate: true, + consent: { source: 'form' }, + fileName: 'crm.json', + }) + const kept = await runCli(['contacts', 'import', '--file', '-'], { + env: env(), + stdin, + }) + const overridden = await runCli( + ['contacts', 'import', '--file', '-', '--file-name', 'October list'], + { env: env(), stdin } + ) + expect(kept.code).toBe(0) + expect(overridden.code).toBe(0) + expect(bodies).toEqual([ + { + contacts: [{ email: 'a@example.com' }], + validate: true, + consent: { source: 'form' }, + fileName: 'crm.json', + }, + { + contacts: [{ email: 'a@example.com' }], + validate: true, + consent: { source: 'form' }, + fileName: 'October list', + }, + ]) + }) + + it('refuses a --file that is not an array or an object', async () => { + const result = await runCli(['contacts', 'import', '--file', '-'], { + env: env(), + stdin: '"nope"', + }) + expect(result.code).toBe(2) + expect(result.stderr).toContain('--file must hold JSON') + }) + it('reads a JSON array file and sends it as one named batch', async () => { let body: unknown server.use( From 5985af2f6aceaf9cbcc3cb7df6f08cceea9389d6 Mon Sep 17 00:00:00 2001 From: thedogwiththedataonit Date: Tue, 20 Oct 2026 21:41:23 +0900 Subject: [PATCH 03/16] Resync the OpenAPI spec (route-neutral importId description) Co-Authored-By: Claude Opus 5.5 --- openapi/public-api-v1.yaml | 2 +- src/generated/openapi-types.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/openapi/public-api-v1.yaml b/openapi/public-api-v1.yaml index ae5f0d3..525ebe8 100644 --- a/openapi/public-api-v1.yaml +++ b/openapi/public-api-v1.yaml @@ -14089,7 +14089,7 @@ components: type: string importId: type: string - description: This batch's Import History id. Each contact it wrote carries it as `importId` until a later import touches that contact, so filtering contacts on `importId` (`POST /v1/contacts/search`) lists them. + description: This batch's Import History id. Each contact it wrote carries it as `importId` until a later import touches that contact, so a contact filter on `importId` lists them. required: - summary - fieldsCreated diff --git a/src/generated/openapi-types.ts b/src/generated/openapi-types.ts index 902e669..c810497 100644 --- a/src/generated/openapi-types.ts +++ b/src/generated/openapi-types.ts @@ -7699,7 +7699,7 @@ export interface components { }; }; validationJobId?: string; - /** @description This batch's Import History id. Each contact it wrote carries it as `importId` until a later import touches that contact, so filtering contacts on `importId` (`POST /v1/contacts/search`) lists them. */ + /** @description This batch's Import History id. Each contact it wrote carries it as `importId` until a later import touches that contact, so a contact filter on `importId` lists them. */ importId: string; }; ContactsImportCsvRequest: { From 04790762660ed2037d806ede48197072bf10c260 Mon Sep 17 00:00:00 2001 From: thedogwiththedataonit Date: Tue, 20 Oct 2026 21:51:42 +0900 Subject: [PATCH 04/16] CSV validation is metered per data row; resync the spec Co-Authored-By: Claude Opus 5.5 --- openapi/public-api-v1.yaml | 2 +- src/generated/openapi-types.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/openapi/public-api-v1.yaml b/openapi/public-api-v1.yaml index 525ebe8..2a4d6ee 100644 --- a/openapi/public-api-v1.yaml +++ b/openapi/public-api-v1.yaml @@ -14110,7 +14110,7 @@ components: type: string validate: type: boolean - description: Optional deliverability check on ingestion. When true, each address is validated with the provider (2 credits per address, charged on success) and the verdict is saved to the contact’s `validationStatus`. Submissions above the inline cap (100 addresses) upsert first and validate as a background job, returning a `validationJobId`. + description: Optional deliverability check on ingestion. When true, each imported address is validated with the provider and the verdict is saved to the contact’s `validationStatus`. Metered at 2 credits per CSV data row (rows skipped for a missing or invalid email, and repeats of an address, count), charged on success. Imports above the inline cap (100 rows) write first and validate as a background job, returning a `validationJobId`. consent: type: object properties: diff --git a/src/generated/openapi-types.ts b/src/generated/openapi-types.ts index c810497..9eec17a 100644 --- a/src/generated/openapi-types.ts +++ b/src/generated/openapi-types.ts @@ -7707,7 +7707,7 @@ export interface components { mapping?: { [key: string]: string; }; - /** @description Optional deliverability check on ingestion. When true, each address is validated with the provider (2 credits per address, charged on success) and the verdict is saved to the contact’s `validationStatus`. Submissions above the inline cap (100 addresses) upsert first and validate as a background job, returning a `validationJobId`. */ + /** @description Optional deliverability check on ingestion. When true, each imported address is validated with the provider and the verdict is saved to the contact’s `validationStatus`. Metered at 2 credits per CSV data row (rows skipped for a missing or invalid email, and repeats of an address, count), charged on success. Imports above the inline cap (100 rows) write first and validate as a background job, returning a `validationJobId`. */ validate?: boolean; /** @description Consent record stamped on every imported row (typically `{ "source": "import" }`). */ consent?: { From 5d3b1022cc0d883c4491669b7269bdc6bc15745f Mon Sep 17 00:00:00 2001 From: thedogwiththedataonit Date: Tue, 20 Oct 2026 21:52:11 +0900 Subject: [PATCH 05/16] Pin the SDK head of typescript-sdk#76 Co-Authored-By: Claude Opus 5.5 --- bun.lock | 4 ++-- package.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/bun.lock b/bun.lock index 2712202..19f81c6 100644 --- a/bun.lock +++ b/bun.lock @@ -5,7 +5,7 @@ "": { "name": "@brew.new/cli", "dependencies": { - "@brew.new/sdk": "git+https://github.com/GetBrew/typescript-sdk.git#07b8a6f132426233be6df9de2718d74a4b6bc0c2", + "@brew.new/sdk": "git+https://github.com/GetBrew/typescript-sdk.git#45cabb67dc0d964f1c1b9619d755ac7a22a6b347", "commander": "^14.0.0", }, "devDependencies": { @@ -46,7 +46,7 @@ "@biomejs/cli-win32-x64": ["@biomejs/cli-win32-x64@2.5.7", "", { "os": "win32", "cpu": "x64" }, "sha512-V+0wu/nrj2S+MhP4EQ0uHNolP0IALEsz45pg0WoKkHfDeh0+ItHwP/p7bX5RPoMOl9NkpHYWdYPhIcy2mACHvQ=="], - "@brew.new/sdk": ["@brew.new/sdk@github:GetBrew/typescript-sdk#07b8a6f", { "peerDependencies": { "react": ">=18.2.0 <20" }, "optionalPeers": ["react"] }, "GetBrew-typescript-sdk-07b8a6f", "sha512-1wwQ2LkToq1/8hnv6LkSZndMqO0zW3e6ThcWEDuwS+CaGJHLdtlgxvtgiZWj0hHrWLw27XYAw7p9sjPpkIlDog=="], + "@brew.new/sdk": ["@brew.new/sdk@github:GetBrew/typescript-sdk#45cabb6", { "peerDependencies": { "react": ">=18.2.0 <20" }, "optionalPeers": ["react"] }, "GetBrew-typescript-sdk-45cabb6", "sha512-UeulQ+f49hc6oLu2cv12H2Hr7NuBIe9007jJAUta7y1dmySRly4e21kEM586h7vrz75MvmIYRK48mYTstNfKug=="], "@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.27.7", "", { "os": "aix", "cpu": "ppc64" }, "sha512-EKX3Qwmhz1eMdEJokhALr0YiD0lhQNwDqkPYyPhiSwKrh7/4KRjQc04sZ8db+5DVVnZ1LmbNDI1uAMPEUBnQPg=="], diff --git a/package.json b/package.json index 88216fc..dab17f3 100644 --- a/package.json +++ b/package.json @@ -53,7 +53,7 @@ "smoke:signals": "bun run scripts/signal-smoke.ts" }, "dependencies": { - "@brew.new/sdk": "git+https://github.com/GetBrew/typescript-sdk.git#07b8a6f132426233be6df9de2718d74a4b6bc0c2", + "@brew.new/sdk": "git+https://github.com/GetBrew/typescript-sdk.git#45cabb67dc0d964f1c1b9619d755ac7a22a6b347", "commander": "^14.0.0" }, "devDependencies": { From 34f22d1080a8d2381a43af2939cc3b80d4773eb5 Mon Sep 17 00:00:00 2001 From: thedogwiththedataonit Date: Wed, 21 Oct 2026 00:35:28 +0900 Subject: [PATCH 06/16] chore(openapi): re-vendor the spec from brew-v2#2194 after main's EMAIL_RESULT_UNAVAILABLE Co-Authored-By: Claude Opus 5.5 --- openapi/public-api-v1.yaml | 40 +++++++++++++++++++++++++++++++--- src/generated/openapi-types.ts | 16 +++++++++----- 2 files changed, 48 insertions(+), 8 deletions(-) diff --git a/openapi/public-api-v1.yaml b/openapi/public-api-v1.yaml index 777a362..d068126 100644 --- a/openapi/public-api-v1.yaml +++ b/openapi/public-api-v1.yaml @@ -5070,6 +5070,7 @@ components: - EMAIL_IN_USE_BY_AUTOMATION - EMAIL_NOT_FOUND - EMAIL_NOT_READY + - EMAIL_RESULT_UNAVAILABLE - EMAIL_RUN_AMBIGUOUS - EMAIL_TEMPLATE_INVALID - EMAIL_VERSION_NOT_FOUND @@ -18747,7 +18748,7 @@ paths: **Returns** `201 { emailId, emailVersionId, html, previewImage?, group, subjectLine? }` when a design was persisted, or `200 { response }` when the agent answered in prose without writing a design. - **Errors** `404 REFERENCE_EMAIL_NOT_FOUND` for an unknown `referenceEmailId`; `404 EMAIL_GROUP_NOT_FOUND` for an unknown `groupId`; `422 BRAND_NOT_READY` while the brand is still extracting; `502 EMAIL_GENERATION_FAILED` when the agent could not produce a design; `402 INSUFFICIENT_CREDITS` on an empty balance. + **Errors** `404 REFERENCE_EMAIL_NOT_FOUND` for an unknown `referenceEmailId`; `404 EMAIL_GROUP_NOT_FOUND` for an unknown `groupId`; `422 BRAND_NOT_READY` while the brand is still extracting; `502 EMAIL_GENERATION_FAILED` when the agent could not produce a design; `409 EMAIL_RESULT_UNAVAILABLE` when the design was saved but its completed result could not be returned (read the saved `emailVersionId` from `error.details`; do not repeat generation); `402 INSUFFICIENT_CREDITS` on an empty balance. **See also** `editEmail`, `importEmail`, `createSend`. parameters: @@ -19084,6 +19085,8 @@ paths: docs: https://docs.brew.new/api-reference/api/errors "409": description: |- + `EMAIL_RESULT_UNAVAILABLE`: The email was saved, but its completed result could not be read for this response. + `IDEMPOTENCY_CONFLICT`: The same Idempotency-Key was reused with a different request body. `IDEMPOTENCY_IN_PROGRESS`: A request with this Idempotency-Key is still executing. @@ -19100,6 +19103,20 @@ paths: schema: $ref: "#/components/schemas/ApiErrorEnvelope" examples: + emailResultUnavailable: + summary: EMAIL_RESULT_UNAVAILABLE + value: + error: + code: EMAIL_RESULT_UNAVAILABLE + type: conflict + message: The email was saved, but its completed result could not be read for this response. + suggestion: "Read GET /v1/emails/{emailId}?include=html with emailVersionId set to the saved value from error.details. Do not repeat the generation or edit: it already committed." + docs: https://docs.brew.new/api-reference/api/errors + details: + committed: true + emailId: K7mV9aL2qW4xP6tR8yU0n + emailVersionId: N8cX2mV5pL7qR9tW1yZ3a + runId: R6bN8kM2pV4xZ7qW9tY1s idempotencyConflict: summary: IDEMPOTENCY_CONFLICT value: @@ -19754,7 +19771,7 @@ paths: **Automations** a prompt edit moves every automation `sendEmail` step pinned to the previous latest version onto the new one, in a new unpublished automation version (while a Brew agent is still building that automation, its in-progress draft is updated in place instead); the published version keeps its pin until the next publish. Steps pinned to an older version do not move. An envelope patch writes no version and moves nothing. - **Errors** `404 EMAIL_NOT_FOUND`, `EMAIL_VERSION_NOT_FOUND` (a pin that is not one of this design's versions), `EMAIL_GROUP_NOT_FOUND` (a `groupId` that is not one of this brand's groups); `409 EMAIL_IN_PROGRESS` while the design is still generating (or a concurrent edit moved the head between read and patch); `422 BRAND_NOT_READY` until the brand has finished extracting; `502 EMAIL_GENERATION_FAILED` when the agent run fails (retry later); `400 INVALID_REQUEST` for a body with none of the four fields, or `emailVersionId` without `prompt`. + **Errors** `404 EMAIL_NOT_FOUND`, `EMAIL_VERSION_NOT_FOUND` (a pin that is not one of this design's versions), `EMAIL_GROUP_NOT_FOUND` (a `groupId` that is not one of this brand's groups); `409 EMAIL_IN_PROGRESS` while the design is still generating (or a concurrent edit moved the head between read and patch); `409 EMAIL_RESULT_UNAVAILABLE` when the edit was saved but its completed result could not be returned (read the saved `emailVersionId` from `error.details`; do not repeat the edit); `422 BRAND_NOT_READY` until the brand has finished extracting; `502 EMAIL_GENERATION_FAILED` when the agent run fails (retry later); `400 INVALID_REQUEST` for a body with none of the four fields, or `emailVersionId` without `prompt`. **See also** `cloneEmail` (name the copy with `title`), `restoreEmailVersion`, `listEmails`. parameters: @@ -20083,7 +20100,10 @@ paths: suggestion: List versions with GET /v1/emails/{emailId}?include=versions and retry with a valid emailVersionId. docs: https://docs.brew.new/api-reference/api/errors "409": - description: "`EMAIL_IN_PROGRESS`: The design is being generated and cannot be edited or cloned yet." + description: |- + `EMAIL_IN_PROGRESS`: The design is being generated and cannot be edited or cloned yet. + + `EMAIL_RESULT_UNAVAILABLE`: The email was saved, but its completed result could not be read for this response. headers: x-request-id: schema: @@ -20106,6 +20126,20 @@ paths: message: The design is being generated and cannot be edited or cloned yet. suggestion: Poll GET /v1/emails/{emailId} until `status` is `ready`, then retry. docs: https://docs.brew.new/api-reference/api/errors + emailResultUnavailable: + summary: EMAIL_RESULT_UNAVAILABLE + value: + error: + code: EMAIL_RESULT_UNAVAILABLE + type: conflict + message: The email was saved, but its completed result could not be read for this response. + suggestion: "Read GET /v1/emails/{emailId}?include=html with emailVersionId set to the saved value from error.details. Do not repeat the generation or edit: it already committed." + docs: https://docs.brew.new/api-reference/api/errors + details: + committed: true + emailId: K7mV9aL2qW4xP6tR8yU0n + emailVersionId: N8cX2mV5pL7qR9tW1yZ3a + runId: R6bN8kM2pV4xZ7qW9tY1s "422": description: "`BRAND_NOT_READY`: The brand is still extracting and cannot generate email yet." headers: diff --git a/src/generated/openapi-types.ts b/src/generated/openapi-types.ts index 7fe35d8..ab62533 100644 --- a/src/generated/openapi-types.ts +++ b/src/generated/openapi-types.ts @@ -35,7 +35,7 @@ export interface paths { * * **Returns** `201 { emailId, emailVersionId, html, previewImage?, group, subjectLine? }` when a design was persisted, or `200 { response }` when the agent answered in prose without writing a design. * - * **Errors** `404 REFERENCE_EMAIL_NOT_FOUND` for an unknown `referenceEmailId`; `404 EMAIL_GROUP_NOT_FOUND` for an unknown `groupId`; `422 BRAND_NOT_READY` while the brand is still extracting; `502 EMAIL_GENERATION_FAILED` when the agent could not produce a design; `402 INSUFFICIENT_CREDITS` on an empty balance. + * **Errors** `404 REFERENCE_EMAIL_NOT_FOUND` for an unknown `referenceEmailId`; `404 EMAIL_GROUP_NOT_FOUND` for an unknown `groupId`; `422 BRAND_NOT_READY` while the brand is still extracting; `502 EMAIL_GENERATION_FAILED` when the agent could not produce a design; `409 EMAIL_RESULT_UNAVAILABLE` when the design was saved but its completed result could not be returned (read the saved `emailVersionId` from `error.details`; do not repeat generation); `402 INSUFFICIENT_CREDITS` on an empty balance. * * **See also** `editEmail`, `importEmail`, `createSend`. */ @@ -89,7 +89,7 @@ export interface paths { * * **Automations** a prompt edit moves every automation `sendEmail` step pinned to the previous latest version onto the new one, in a new unpublished automation version (while a Brew agent is still building that automation, its in-progress draft is updated in place instead); the published version keeps its pin until the next publish. Steps pinned to an older version do not move. An envelope patch writes no version and moves nothing. * - * **Errors** `404 EMAIL_NOT_FOUND`, `EMAIL_VERSION_NOT_FOUND` (a pin that is not one of this design's versions), `EMAIL_GROUP_NOT_FOUND` (a `groupId` that is not one of this brand's groups); `409 EMAIL_IN_PROGRESS` while the design is still generating (or a concurrent edit moved the head between read and patch); `422 BRAND_NOT_READY` until the brand has finished extracting; `502 EMAIL_GENERATION_FAILED` when the agent run fails (retry later); `400 INVALID_REQUEST` for a body with none of the four fields, or `emailVersionId` without `prompt`. + * **Errors** `404 EMAIL_NOT_FOUND`, `EMAIL_VERSION_NOT_FOUND` (a pin that is not one of this design's versions), `EMAIL_GROUP_NOT_FOUND` (a `groupId` that is not one of this brand's groups); `409 EMAIL_IN_PROGRESS` while the design is still generating (or a concurrent edit moved the head between read and patch); `409 EMAIL_RESULT_UNAVAILABLE` when the edit was saved but its completed result could not be returned (read the saved `emailVersionId` from `error.details`; do not repeat the edit); `422 BRAND_NOT_READY` until the brand has finished extracting; `502 EMAIL_GENERATION_FAILED` when the agent run fails (retry later); `400 INVALID_REQUEST` for a body with none of the four fields, or `emailVersionId` without `prompt`. * * **See also** `cloneEmail` (name the copy with `title`), `restoreEmailVersion`, `listEmails`. */ @@ -4443,7 +4443,7 @@ export interface components { }; }; /** @enum {string} */ - ApiErrorCode: "ACCOUNT_SUSPENDED" | "API_KEY_REVOKED" | "AUDIENCE_BUILD_ACTIVE" | "AUDIENCE_BUILD_ALREADY_ACTIVE" | "AUDIENCE_EDIT_CONFLICT" | "AUDIENCE_MEMBERSHIP_NOT_EXPRESSIBLE" | "AUDIENCE_NOT_FOUND" | "AUDIENCE_RUN_NOT_FOUND" | "AUDIT_NOT_FOUND" | "AUTHENTICATION_REQUIRED" | "AUTOMATION_GRAPH_INVALID" | "AUTOMATION_NOT_FOUND" | "AUTOMATION_NOT_PAUSABLE" | "AUTOMATION_NOT_PUBLISHED" | "AUTOMATION_RUN_NOT_FOUND" | "AUTOMATION_VERSION_CONFLICT" | "AUTOMATION_VERSION_NOT_FOUND" | "BATCH_TOO_LARGE" | "BRAND_DOMAIN_CONFLICT" | "BRAND_ID_REQUIRED" | "BRAND_LIMIT_REACHED" | "BRAND_NOT_FOUND" | "BRAND_NOT_READY" | "BRAND_SCOPE_MISMATCH" | "CHAT_NOT_FOUND" | "COMMENT_NOT_FOUND" | "CONSENT_REQUIRED" | "CONTACT_NOT_FOUND" | "CONTENT_OPERATION_FAILED" | "CONTRACT_LOCKED_BY_PUBLISHED_AUTOMATIONS" | "CORE_FIELD_IMMUTABLE" | "DOMAIN_ALREADY_EXISTS" | "DOMAIN_CLAIMED_ELSEWHERE" | "DOMAIN_NOT_FOUND" | "DOMAIN_NOT_READY" | "DOMAIN_OTHER_BRAND" | "DOMAIN_PROVIDER_ERROR" | "DOMAIN_PURPOSE_NOT_ALLOWED" | "DOMAIN_VERIFICATION_FAILED" | "DOMAIN_VERIFIED_ELSEWHERE" | "EMAIL_GENERATION_FAILED" | "EMAIL_GROUP_NAME_CONFLICT" | "EMAIL_GROUP_NOT_FOUND" | "EMAIL_IMAGES_MISSING" | "EMAIL_IMPORT_FAILED" | "EMAIL_IN_PROGRESS" | "EMAIL_IN_USE_BY_AUTOMATION" | "EMAIL_NOT_FOUND" | "EMAIL_NOT_READY" | "EMAIL_RUN_AMBIGUOUS" | "EMAIL_TEMPLATE_INVALID" | "EMAIL_VERSION_NOT_FOUND" | "EXPORT_PROVIDER_ERROR" | "EXPORT_UNSUPPORTED" | "FIELD_NOT_FOUND" | "FIELD_TYPE_MISMATCH" | "FIGMA_ACCESS_DENIED" | "FIGMA_CONVERSION_FAILED" | "FIGMA_FRAME_NOT_FOUND" | "FIGMA_NOT_CONNECTED" | "FIGMA_UNAVAILABLE" | "FIGMA_URL_INVALID" | "FLOW_NOT_FOUND" | "IDEMPOTENCY_CONFLICT" | "IDEMPOTENCY_IN_PROGRESS" | "INSIGHT_NOT_FOUND" | "INSUFFICIENT_CREDITS" | "INSUFFICIENT_PERMISSIONS" | "INSUFFICIENT_ROLE" | "INTEGRATION_NOT_CONNECTED" | "INTERNAL_ERROR" | "INVALID_API_KEY" | "INVALID_EMAIL" | "INVALID_PAYLOAD" | "INVALID_REQUEST" | "LIQUID_RENDER_ERROR" | "METHOD_NOT_ALLOWED" | "MISSING_EMAIL" | "NO_ELIGIBLE_RECIPIENTS" | "NO_PUBLISHED_AUTOMATION" | "NOT_FOUND" | "NOT_IMPLEMENTED" | "ORG_SCOPE_REQUIRED" | "PAYLOAD_SCHEMA_EMAIL_REQUIRED" | "PAYLOAD_TOO_LARGE" | "PREVIEW_NOT_FOUND" | "PUBLISH_VALIDATION_FAILED" | "RATE_LIMITED" | "RECIPIENT_UNSUBSCRIBED" | "REFERENCE_EMAIL_NOT_FOUND" | "RESUBSCRIBE_NOT_ALLOWED" | "RUN_IN_PROGRESS" | "RUN_NOT_CANCELLABLE" | "RUN_NOT_PAUSABLE" | "RUN_NOT_PAUSED" | "RUN_NOT_RESUMABLE" | "RUN_START_FAILED" | "RUN_STOP_FAILED" | "SEND_NOT_CANCELLABLE" | "SEND_NOT_FOUND" | "SEND_NOT_PAUSABLE" | "SEND_NOT_RESUMABLE" | "SEND_QUOTA_EXCEEDED" | "SERVICE_UNAVAILABLE" | "TEMPLATE_NOT_FOUND" | "TRIGGER_ALREADY_EXISTS" | "TRIGGER_EVENT_NOT_FOUND" | "TRIGGER_HAS_DEPENDENT_AUTOMATIONS" | "TRIGGER_IMMUTABLE" | "TRIGGER_INSTANCE_NOT_FOUND" | "TRIGGER_LIMIT_REACHED" | "TRIGGER_NOT_FIREABLE" | "UPLOAD_IN_PROGRESS" | "UPLOAD_NOT_FOUND" | "UPLOAD_NOT_RECEIVED"; + ApiErrorCode: "ACCOUNT_SUSPENDED" | "API_KEY_REVOKED" | "AUDIENCE_BUILD_ACTIVE" | "AUDIENCE_BUILD_ALREADY_ACTIVE" | "AUDIENCE_EDIT_CONFLICT" | "AUDIENCE_MEMBERSHIP_NOT_EXPRESSIBLE" | "AUDIENCE_NOT_FOUND" | "AUDIENCE_RUN_NOT_FOUND" | "AUDIT_NOT_FOUND" | "AUTHENTICATION_REQUIRED" | "AUTOMATION_GRAPH_INVALID" | "AUTOMATION_NOT_FOUND" | "AUTOMATION_NOT_PAUSABLE" | "AUTOMATION_NOT_PUBLISHED" | "AUTOMATION_RUN_NOT_FOUND" | "AUTOMATION_VERSION_CONFLICT" | "AUTOMATION_VERSION_NOT_FOUND" | "BATCH_TOO_LARGE" | "BRAND_DOMAIN_CONFLICT" | "BRAND_ID_REQUIRED" | "BRAND_LIMIT_REACHED" | "BRAND_NOT_FOUND" | "BRAND_NOT_READY" | "BRAND_SCOPE_MISMATCH" | "CHAT_NOT_FOUND" | "COMMENT_NOT_FOUND" | "CONSENT_REQUIRED" | "CONTACT_NOT_FOUND" | "CONTENT_OPERATION_FAILED" | "CONTRACT_LOCKED_BY_PUBLISHED_AUTOMATIONS" | "CORE_FIELD_IMMUTABLE" | "DOMAIN_ALREADY_EXISTS" | "DOMAIN_CLAIMED_ELSEWHERE" | "DOMAIN_NOT_FOUND" | "DOMAIN_NOT_READY" | "DOMAIN_OTHER_BRAND" | "DOMAIN_PROVIDER_ERROR" | "DOMAIN_PURPOSE_NOT_ALLOWED" | "DOMAIN_VERIFICATION_FAILED" | "DOMAIN_VERIFIED_ELSEWHERE" | "EMAIL_GENERATION_FAILED" | "EMAIL_GROUP_NAME_CONFLICT" | "EMAIL_GROUP_NOT_FOUND" | "EMAIL_IMAGES_MISSING" | "EMAIL_IMPORT_FAILED" | "EMAIL_IN_PROGRESS" | "EMAIL_IN_USE_BY_AUTOMATION" | "EMAIL_NOT_FOUND" | "EMAIL_NOT_READY" | "EMAIL_RESULT_UNAVAILABLE" | "EMAIL_RUN_AMBIGUOUS" | "EMAIL_TEMPLATE_INVALID" | "EMAIL_VERSION_NOT_FOUND" | "EXPORT_PROVIDER_ERROR" | "EXPORT_UNSUPPORTED" | "FIELD_NOT_FOUND" | "FIELD_TYPE_MISMATCH" | "FIGMA_ACCESS_DENIED" | "FIGMA_CONVERSION_FAILED" | "FIGMA_FRAME_NOT_FOUND" | "FIGMA_NOT_CONNECTED" | "FIGMA_UNAVAILABLE" | "FIGMA_URL_INVALID" | "FLOW_NOT_FOUND" | "IDEMPOTENCY_CONFLICT" | "IDEMPOTENCY_IN_PROGRESS" | "INSIGHT_NOT_FOUND" | "INSUFFICIENT_CREDITS" | "INSUFFICIENT_PERMISSIONS" | "INSUFFICIENT_ROLE" | "INTEGRATION_NOT_CONNECTED" | "INTERNAL_ERROR" | "INVALID_API_KEY" | "INVALID_EMAIL" | "INVALID_PAYLOAD" | "INVALID_REQUEST" | "LIQUID_RENDER_ERROR" | "METHOD_NOT_ALLOWED" | "MISSING_EMAIL" | "NO_ELIGIBLE_RECIPIENTS" | "NO_PUBLISHED_AUTOMATION" | "NOT_FOUND" | "NOT_IMPLEMENTED" | "ORG_SCOPE_REQUIRED" | "PAYLOAD_SCHEMA_EMAIL_REQUIRED" | "PAYLOAD_TOO_LARGE" | "PREVIEW_NOT_FOUND" | "PUBLISH_VALIDATION_FAILED" | "RATE_LIMITED" | "RECIPIENT_UNSUBSCRIBED" | "REFERENCE_EMAIL_NOT_FOUND" | "RESUBSCRIBE_NOT_ALLOWED" | "RUN_IN_PROGRESS" | "RUN_NOT_CANCELLABLE" | "RUN_NOT_PAUSABLE" | "RUN_NOT_PAUSED" | "RUN_NOT_RESUMABLE" | "RUN_START_FAILED" | "RUN_STOP_FAILED" | "SEND_NOT_CANCELLABLE" | "SEND_NOT_FOUND" | "SEND_NOT_PAUSABLE" | "SEND_NOT_RESUMABLE" | "SEND_QUOTA_EXCEEDED" | "SERVICE_UNAVAILABLE" | "TEMPLATE_NOT_FOUND" | "TRIGGER_ALREADY_EXISTS" | "TRIGGER_EVENT_NOT_FOUND" | "TRIGGER_HAS_DEPENDENT_AUTOMATIONS" | "TRIGGER_IMMUTABLE" | "TRIGGER_INSTANCE_NOT_FOUND" | "TRIGGER_LIMIT_REACHED" | "TRIGGER_NOT_FIREABLE" | "UPLOAD_IN_PROGRESS" | "UPLOAD_NOT_FOUND" | "UPLOAD_NOT_RECEIVED"; EmailGenerateTextResponse: { response: string; }; @@ -9380,7 +9380,9 @@ export interface operations { }; }; /** - * @description `IDEMPOTENCY_CONFLICT`: The same Idempotency-Key was reused with a different request body. + * @description `EMAIL_RESULT_UNAVAILABLE`: The email was saved, but its completed result could not be read for this response. + * + * `IDEMPOTENCY_CONFLICT`: The same Idempotency-Key was reused with a different request body. * * `IDEMPOTENCY_IN_PROGRESS`: A request with this Idempotency-Key is still executing. */ @@ -9922,7 +9924,11 @@ export interface operations { "application/json": components["schemas"]["ApiErrorEnvelope"]; }; }; - /** @description `EMAIL_IN_PROGRESS`: The design is being generated and cannot be edited or cloned yet. */ + /** + * @description `EMAIL_IN_PROGRESS`: The design is being generated and cannot be edited or cloned yet. + * + * `EMAIL_RESULT_UNAVAILABLE`: The email was saved, but its completed result could not be read for this response. + */ 409: { headers: { /** @description Unique request identifier. Share this with support when debugging a request. */ From 16f44ff14759d1eb2914433569541e0fbfc37ef2 Mon Sep 17 00:00:00 2001 From: thedogwiththedataonit Date: Wed, 21 Oct 2026 11:06:43 +0900 Subject: [PATCH 07/16] feat(contacts): import commands print a per-row summary on a terminal Counts that add up to the rows submitted, then the failed rows and notes by row number; --json keeps the envelope. Pins the SDK at 09a89b8 (row, total, CSV_ROW_SKIPPED) and re-vendors the spec from brew-v2#2194. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 4 + bun.lock | 4 +- openapi/public-api-v1.yaml | 94 +++++++++++++++---- package.json | 2 +- src/commands/contacts/import-csv.ts | 10 +- src/commands/contacts/import-summary.ts | 75 +++++++++++++++ src/commands/contacts/import.ts | 10 +- src/generated/openapi-types.ts | 36 ++++--- .../commands/contacts-import-summary.test.ts | 78 +++++++++++++++ 9 files changed, 277 insertions(+), 36 deletions(-) create mode 100644 src/commands/contacts/import-summary.ts create mode 100644 tests/commands/contacts-import-summary.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 781c903..588bf6d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,10 @@ - `contacts import-csv` names its batch in Import History: `--file-name`, else a `fileName` in `--input`, else the `--file` name. Both imports return `importId`. +- On a terminal, `contacts import` and `contacts import-csv` print one line of + counts that add up to the rows submitted, then each failed row and each + note (a skipped CSV row, an ignored column) with its row number. `--json` + still prints the API envelope verbatim. ### Changed diff --git a/bun.lock b/bun.lock index b59193e..ec0653f 100644 --- a/bun.lock +++ b/bun.lock @@ -5,7 +5,7 @@ "": { "name": "@brew.new/cli", "dependencies": { - "@brew.new/sdk": "git+https://github.com/GetBrew/typescript-sdk.git#b73634f3f91d751d979cbe8139326330be51290b", + "@brew.new/sdk": "git+https://github.com/GetBrew/typescript-sdk.git#09a89b8001dff0f7ce9640bf5d7eacbf0fca4e16", "commander": "^14.0.0", }, "devDependencies": { @@ -46,7 +46,7 @@ "@biomejs/cli-win32-x64": ["@biomejs/cli-win32-x64@2.5.7", "", { "os": "win32", "cpu": "x64" }, "sha512-V+0wu/nrj2S+MhP4EQ0uHNolP0IALEsz45pg0WoKkHfDeh0+ItHwP/p7bX5RPoMOl9NkpHYWdYPhIcy2mACHvQ=="], - "@brew.new/sdk": ["@brew.new/sdk@github:GetBrew/typescript-sdk#b73634f", { "peerDependencies": { "react": ">=18.2.0 <20" }, "optionalPeers": ["react"] }, "GetBrew-typescript-sdk-b73634f", "sha512-NjWr/Vpj1qwr3KnGfdMdY5ImqzF3BjjVdqhFXMDHLy8jWOAhvvMhMtux1gvuNxzr3rORQBbwDCVaUk3gObpC2g=="], + "@brew.new/sdk": ["@brew.new/sdk@github:GetBrew/typescript-sdk#09a89b8", { "peerDependencies": { "react": ">=18.2.0 <20" }, "optionalPeers": ["react"] }, "GetBrew-typescript-sdk-09a89b8", "sha512-qnWG0AKTyECetXIKSTxRUoirFxYh+Nw1kBBAnQ6s/PFqQZLicz1XJBvA+Ehgd09SYkq2BxeoZF5t7AoFfBxDZw=="], "@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.27.7", "", { "os": "aix", "cpu": "ppc64" }, "sha512-EKX3Qwmhz1eMdEJokhALr0YiD0lhQNwDqkPYyPhiSwKrh7/4KRjQc04sZ8db+5DVVnZ1LmbNDI1uAMPEUBnQPg=="], diff --git a/openapi/public-api-v1.yaml b/openapi/public-api-v1.yaml index d068126..69ded41 100644 --- a/openapi/public-api-v1.yaml +++ b/openapi/public-api-v1.yaml @@ -2247,6 +2247,7 @@ components: - RESUBSCRIBE_SKIPPED - CORE_FIELD_IGNORED - DATE_ORDER_ASSUMED + - CSV_ROW_SKIPPED - CSV_COLUMN_IGNORED - RECIPIENTS_EXCLUDED - CONSENT_RECORD_MISSING @@ -5208,30 +5209,36 @@ components: type: string enum: - welcome + - onboarding - newsletter - - promotional + - content + - event-invitation + - event-reminder + - event-recap - product-launch - product-update + - product-pitch + - promotional + - cart-abandonment + - re-engagement + - subscription + - feedback-request + - referral + - company-news + - fundraising - order-confirmation - shipping-update - receipt - - cart-abandonment - - subscription - password-reset - verification - security-alert - account-update - - event-invitation - - event-reminder - - feedback-request - - re-engagement - - referral + - notification - support - - business - internal - - notification + - business - general - description: "Email category that steers the design treatment (exemplars, hero recipe, personalization) — mirrors what the in-app agent infers per request. One of: welcome, newsletter, promotional, product-launch, product-update, order-confirmation, shipping-update, receipt, cart-abandonment, subscription, password-reset, verification, security-alert, account-update, event-invitation, event-reminder, feedback-request, re-engagement, referral, support, business, internal, notification, general. Omit for a general treatment. Transactional categories (receipt, password-reset, order-confirmation, …) steer receipt/reset design conventions; to DELIVER those emails, wire the design into an automation with a trigger and a transactional-purpose sending domain, then fire the trigger." + description: "Email category that steers the design treatment (exemplars, hero recipe, personalization) — mirrors what the in-app agent infers per request. One of: welcome, onboarding, newsletter, content, event-invitation, event-reminder, event-recap, product-launch, product-update, product-pitch, promotional, cart-abandonment, re-engagement, subscription, feedback-request, referral, company-news, fundraising, order-confirmation, shipping-update, receipt, password-reset, verification, security-alert, account-update, notification, support, internal, business, general. Omit for a general treatment. Transactional categories (receipt, password-reset, order-confirmation, …) steer receipt/reset design conventions; to DELIVER those emails, wire the design into an automation with a trigger and a transactional-purpose sending domain, then fire the trigger." subjectLine: type: string minLength: 1 @@ -12737,6 +12744,10 @@ components: properties: email: type: string + row: + type: integer + exclusiveMinimum: 0 + description: "The 1-based row that failed: `contacts[row - 1]`, or the CSV data row (header excluded)." code: type: string field: @@ -12759,6 +12770,10 @@ components: email: type: string minLength: 1 + row: + type: integer + exclusiveMinimum: 0 + description: "The 1-based row it is about (CSV: data row)." field: type: string minLength: 1 @@ -12950,6 +12965,10 @@ components: email: type: string minLength: 1 + row: + type: integer + exclusiveMinimum: 0 + description: "The 1-based row it is about (CSV: data row)." field: type: string minLength: 1 @@ -13986,12 +14005,17 @@ components: skipped: type: integer minimum: 0 - description: CSV rows left out for a missing or invalid email, or merged into a later row with the same address. Always 0 for a JSON import. + description: CSV rows left out for a missing or invalid email, or merged into a later row with the same address; each has a `CSV_ROW_SKIPPED` warning naming its `row`. Always 0 for a JSON import. + total: + type: integer + minimum: 0 + description: Rows submitted (JSON contacts, or CSV data rows). Always `inserted + updated + failed + skipped`. required: - inserted - updated - failed - skipped + - total additionalProperties: false fieldsCreated: type: array @@ -14004,6 +14028,10 @@ components: properties: email: type: string + row: + type: integer + exclusiveMinimum: 0 + description: "The 1-based row that failed: `contacts[row - 1]`, or the CSV data row (header excluded)." code: type: string field: @@ -14026,6 +14054,10 @@ components: email: type: string minLength: 1 + row: + type: integer + exclusiveMinimum: 0 + description: "The 1-based row it is about (CSV: data row)." field: type: string minLength: 1 @@ -59168,7 +59200,7 @@ paths: tags: - Templates summary: List templates - description: Lists public templates under { data, pagination }. The default full representation includes HTML. Set representation=summary for metadata, previewImage, viewUrl, and referenceEmailId without HTML. Supports exact brand and category filters; query matches title text or an exact template identifier. semantic ranks up to 200 matches using vector search; query additionally filters those matches when both are supplied. Browse defaults to 50 full rows or 20 summary rows. Brand and category match case-insensitively. Continue with pagination.cursor, including after an empty filtered page with hasMore=true. Templates are organization-wide references; pass referenceEmailId to POST /v1/emails to remix one. Set count=true for { count }, the number of templates matching brand and category (refreshed every 15 minutes), instead of rows; add groupBy=brand or groupBy=category for per-value counts, largest first, 100 groups a page with the same pagination.cursor (count does not combine with semantic or query). + description: "Lists public templates under { data, pagination }. The default full representation includes HTML. Set representation=summary for metadata, previewImage, viewUrl, and referenceEmailId without HTML. Supports exact brand and category filters and a group filter (a browse group: every category in it); query matches title text or an exact template identifier. semantic ranks up to 200 matches using vector search; query additionally filters those matches when both are supplied. Browse defaults to 50 full rows or 20 summary rows. Brand and category match case-insensitively. Continue with pagination.cursor, including after an empty filtered page with hasMore=true. Templates are organization-wide references; pass referenceEmailId to POST /v1/emails to remix one. Set count=true for { count }, the number of templates matching brand, category and group (refreshed every 15 minutes), instead of rows; add groupBy=brand, groupBy=category or groupBy=group for per-value counts, largest first, 100 groups a page with the same pagination.cursor (count does not combine with semantic or query)." parameters: - schema: type: string @@ -59181,11 +59213,30 @@ paths: - schema: type: string minLength: 1 - description: "Email category, matched case-insensitively (unknown → empty page): welcome, newsletter, promotional, product-launch, product-update, order-confirmation, shipping-update, receipt, cart-abandonment, subscription, password-reset, verification, security-alert, account-update, event-invitation, event-reminder, feedback-request, re-engagement, referral, support, business, internal, notification, general." + description: "Email category, matched case-insensitively (unknown → empty page): welcome, onboarding, newsletter, content, event-invitation, event-reminder, event-recap, product-launch, product-update, product-pitch, promotional, cart-abandonment, re-engagement, subscription, feedback-request, referral, company-news, fundraising, order-confirmation, shipping-update, receipt, password-reset, verification, security-alert, account-update, notification, support, internal, business, general." required: false - description: "Email category, matched case-insensitively (unknown → empty page): welcome, newsletter, promotional, product-launch, product-update, order-confirmation, shipping-update, receipt, cart-abandonment, subscription, password-reset, verification, security-alert, account-update, event-invitation, event-reminder, feedback-request, re-engagement, referral, support, business, internal, notification, general." + description: "Email category, matched case-insensitively (unknown → empty page): welcome, onboarding, newsletter, content, event-invitation, event-reminder, event-recap, product-launch, product-update, product-pitch, promotional, cart-abandonment, re-engagement, subscription, feedback-request, referral, company-news, fundraising, order-confirmation, shipping-update, receipt, password-reset, verification, security-alert, account-update, notification, support, internal, business, general." name: category in: query + - schema: + type: string + enum: + - welcome-onboarding + - newsletter + - content + - events + - product-news + - sales + - retention + - company + - transactional + - internal + - other + description: "A browse group: every category in it (e.g. `events` = invitations, reminders and recaps)." + required: false + description: "A browse group: every category in it (e.g. `events` = invitations, reminders and recaps)." + name: group + in: query - schema: type: string minLength: 1 @@ -59221,9 +59272,9 @@ paths: enum: - 'true' - 'false' - description: Return `{ count }`, the number of templates matching `brand`/`category`, instead of rows. Not with `semantic` or `query`. + description: Return `{ count }`, the number of templates matching `brand`/`category`/`group`, instead of rows. Not with `semantic` or `query`. required: false - description: Return `{ count }`, the number of templates matching `brand`/`category`, instead of rows. Not with `semantic` or `query`. + description: Return `{ count }`, the number of templates matching `brand`/`category`/`group`, instead of rows. Not with `semantic` or `query`. name: count in: query - schema: @@ -59231,12 +59282,14 @@ paths: enum: - brand - category - description: "With `count: true`: also count per brand or per category, largest first, 100 groups a page; continue with `cursor`." + - group + description: "With `count: true`: also count per brand, category or group, largest first, 100 groups a page; continue with `cursor`." x-brew-group-by-tokens: - brand - category + - group required: false - description: "With `count: true`: also count per brand or per category, largest first, 100 groups a page; continue with `cursor`." + description: "With `count: true`: also count per brand, category or group, largest first, 100 groups a page; continue with `cursor`." name: groupBy in: query - schema: @@ -59367,6 +59420,7 @@ paths: enum: - brand - category + - group groups: type: array items: @@ -59375,7 +59429,7 @@ paths: value: type: string minLength: 1 - description: The brand domain or category; pass it back as the `brand`/`category` filter. + description: The brand domain, category or group; pass it back as the filter of the same name (`brand`, `category` or `group`). name: type: string minLength: 1 diff --git a/package.json b/package.json index 9cd7078..eaf416b 100644 --- a/package.json +++ b/package.json @@ -53,7 +53,7 @@ "smoke:signals": "bun run scripts/signal-smoke.ts" }, "dependencies": { - "@brew.new/sdk": "git+https://github.com/GetBrew/typescript-sdk.git#b73634f3f91d751d979cbe8139326330be51290b", + "@brew.new/sdk": "git+https://github.com/GetBrew/typescript-sdk.git#09a89b8001dff0f7ce9640bf5d7eacbf0fca4e16", "commander": "^14.0.0" }, "devDependencies": { diff --git a/src/commands/contacts/import-csv.ts b/src/commands/contacts/import-csv.ts index 51241b4..28d417b 100644 --- a/src/commands/contacts/import-csv.ts +++ b/src/commands/contacts/import-csv.ts @@ -13,6 +13,7 @@ import { readTextFlag, requestOptions, } from '../../lib/input' +import { renderImportResult } from './import-summary' type ImportCsvContactsInput = Parameters[0] @@ -96,7 +97,14 @@ export const contactsImportCsvCommand = defineCommand({ asSdkInput(input), requestOptions(flags) ) - return { data: result } + return { + data: result, + human: () => + renderImportResult( + result, + typeof input.fileName === 'string' ? input.fileName : undefined + ), + } }, }) diff --git a/src/commands/contacts/import-summary.ts b/src/commands/contacts/import-summary.ts new file mode 100644 index 0000000..32a31c4 --- /dev/null +++ b/src/commands/contacts/import-summary.ts @@ -0,0 +1,75 @@ +import type { ImportContactsResponse } from '@brew.new/sdk' +import { renderTable } from '../../lib/output' + +/** Rows of each table a terminal shows before pointing at `--json`. */ +export const MAX_TERMINAL_ROWS = 20 + +/** + * The terminal rendering of `contacts import` and `contacts import-csv`: one + * line of counts that add up to the rows submitted, then the rows that + * failed and the notes (skipped CSV rows, ignored columns), each by row, so + * a fix starts from the row number. `--json` prints the full envelope. + */ +export function renderImportResult( + result: ImportContactsResponse, + fileName: string | undefined +): string { + const { summary } = result + const counts = [ + `${summary.inserted} added`, + `${summary.updated} updated`, + `${summary.failed} failed`, + ...(summary.skipped > 0 ? [`${summary.skipped} skipped`] : []), + ].join(', ') + const name = fileName ? `${fileName} ` : '' + const lines = [ + `Imported ${name}as ${result.importId}: ${counts} (${summary.total} row${summary.total === 1 ? '' : 's'}).`, + ] + if (result.errors.length > 0) { + lines.push( + '', + 'FAILED ROWS', + limitedTable(result.errors, [ + { key: 'row', header: 'ROW' }, + { key: 'email', header: 'EMAIL' }, + { key: 'code', header: 'CODE' }, + { key: 'message', header: 'MESSAGE' }, + ]) + ) + } + if (result.warnings.length > 0) { + lines.push( + '', + 'WARNINGS', + limitedTable(result.warnings, [ + { key: 'row', header: 'ROW' }, + { key: 'code', header: 'CODE' }, + { key: 'message', header: 'MESSAGE' }, + ]) + ) + } + if (result.fieldsCreated.length > 0) { + lines.push('', `New custom fields: ${result.fieldsCreated.join(', ')}.`) + } + if (result.validationJobId) { + lines.push( + '', + `Deliverability check running as validation job ${result.validationJobId}.` + ) + } + return lines.join('\n') +} + +function limitedTable( + rows: ReadonlyArray, + columns: Parameters[1] +): string { + const shown = rows.slice(0, MAX_TERMINAL_ROWS) as ReadonlyArray< + Record + > + const table = renderTable(shown, columns) + const hidden = rows.length - shown.length + return hidden > 0 + ? `${table}\n… ${hidden} more; --json prints every row.` + : table +} diff --git a/src/commands/contacts/import.ts b/src/commands/contacts/import.ts index 7464394..7c77d22 100644 --- a/src/commands/contacts/import.ts +++ b/src/commands/contacts/import.ts @@ -12,6 +12,7 @@ import { requestOptions, } from '../../lib/input' import { importFileName } from './import-csv' +import { renderImportResult } from './import-summary' type ImportContactsInput = Parameters[0] @@ -88,7 +89,14 @@ export const contactsImportCommand = defineCommand({ asSdkInput(input), requestOptions(flags) ) - return { data: result } + return { + data: result, + human: () => + renderImportResult( + result, + typeof input.fileName === 'string' ? input.fileName : undefined + ), + } }, }) diff --git a/src/generated/openapi-types.ts b/src/generated/openapi-types.ts index ab62533..f568380 100644 --- a/src/generated/openapi-types.ts +++ b/src/generated/openapi-types.ts @@ -1987,7 +1987,7 @@ export interface paths { }; /** * List templates - * @description Lists public templates under { data, pagination }. The default full representation includes HTML. Set representation=summary for metadata, previewImage, viewUrl, and referenceEmailId without HTML. Supports exact brand and category filters; query matches title text or an exact template identifier. semantic ranks up to 200 matches using vector search; query additionally filters those matches when both are supplied. Browse defaults to 50 full rows or 20 summary rows. Brand and category match case-insensitively. Continue with pagination.cursor, including after an empty filtered page with hasMore=true. Templates are organization-wide references; pass referenceEmailId to POST /v1/emails to remix one. Set count=true for { count }, the number of templates matching brand and category (refreshed every 15 minutes), instead of rows; add groupBy=brand or groupBy=category for per-value counts, largest first, 100 groups a page with the same pagination.cursor (count does not combine with semantic or query). + * @description Lists public templates under { data, pagination }. The default full representation includes HTML. Set representation=summary for metadata, previewImage, viewUrl, and referenceEmailId without HTML. Supports exact brand and category filters and a group filter (a browse group: every category in it); query matches title text or an exact template identifier. semantic ranks up to 200 matches using vector search; query additionally filters those matches when both are supplied. Browse defaults to 50 full rows or 20 summary rows. Brand and category match case-insensitively. Continue with pagination.cursor, including after an empty filtered page with hasMore=true. Templates are organization-wide references; pass referenceEmailId to POST /v1/emails to remix one. Set count=true for { count }, the number of templates matching brand, category and group (refreshed every 15 minutes), instead of rows; add groupBy=brand, groupBy=category or groupBy=group for per-value counts, largest first, 100 groups a page with the same pagination.cursor (count does not combine with semantic or query). */ get: operations["listTemplates"]; put?: never; @@ -3448,7 +3448,7 @@ export interface components { }; ApiWarning: { /** @enum {string} */ - code: "DRAFT_SAVED_NOT_LIVE" | "TRIGGER_EVENT_NOT_RECEIVED" | "ENFORCEMENT_LOOSENED_WHILE_PUBLISHED" | "REQUIRED_FIELD_SATISFIED_BY_FALLBACK" | "RESUBSCRIBE_SKIPPED" | "CORE_FIELD_IGNORED" | "DATE_ORDER_ASSUMED" | "CSV_COLUMN_IGNORED" | "RECIPIENTS_EXCLUDED" | "CONSENT_RECORD_MISSING"; + code: "DRAFT_SAVED_NOT_LIVE" | "TRIGGER_EVENT_NOT_RECEIVED" | "ENFORCEMENT_LOOSENED_WHILE_PUBLISHED" | "REQUIRED_FIELD_SATISFIED_BY_FALLBACK" | "RESUBSCRIBE_SKIPPED" | "CORE_FIELD_IGNORED" | "DATE_ORDER_ASSUMED" | "CSV_ROW_SKIPPED" | "CSV_COLUMN_IGNORED" | "RECIPIENTS_EXCLUDED" | "CONSENT_RECORD_MISSING"; message: string; field?: string; }; @@ -4468,10 +4468,10 @@ export interface components { /** @description The `emailId` of an existing design to redraw in this brand's voice — one of this brand's own designs (GET /v1/emails) or a public gallery template (GET /v1/templates). Its layout and structure are the reference; the copy, imagery and styling become this brand's. */ referenceEmailId?: string; /** - * @description Email category that steers the design treatment (exemplars, hero recipe, personalization) — mirrors what the in-app agent infers per request. One of: welcome, newsletter, promotional, product-launch, product-update, order-confirmation, shipping-update, receipt, cart-abandonment, subscription, password-reset, verification, security-alert, account-update, event-invitation, event-reminder, feedback-request, re-engagement, referral, support, business, internal, notification, general. Omit for a general treatment. Transactional categories (receipt, password-reset, order-confirmation, …) steer receipt/reset design conventions; to DELIVER those emails, wire the design into an automation with a trigger and a transactional-purpose sending domain, then fire the trigger. + * @description Email category that steers the design treatment (exemplars, hero recipe, personalization) — mirrors what the in-app agent infers per request. One of: welcome, onboarding, newsletter, content, event-invitation, event-reminder, event-recap, product-launch, product-update, product-pitch, promotional, cart-abandonment, re-engagement, subscription, feedback-request, referral, company-news, fundraising, order-confirmation, shipping-update, receipt, password-reset, verification, security-alert, account-update, notification, support, internal, business, general. Omit for a general treatment. Transactional categories (receipt, password-reset, order-confirmation, …) steer receipt/reset design conventions; to DELIVER those emails, wire the design into an automation with a trigger and a transactional-purpose sending domain, then fire the trigger. * @enum {string} */ - category?: "welcome" | "newsletter" | "promotional" | "product-launch" | "product-update" | "order-confirmation" | "shipping-update" | "receipt" | "cart-abandonment" | "subscription" | "password-reset" | "verification" | "security-alert" | "account-update" | "event-invitation" | "event-reminder" | "feedback-request" | "re-engagement" | "referral" | "support" | "business" | "internal" | "notification" | "general"; + category?: "welcome" | "onboarding" | "newsletter" | "content" | "event-invitation" | "event-reminder" | "event-recap" | "product-launch" | "product-update" | "product-pitch" | "promotional" | "cart-abandonment" | "re-engagement" | "subscription" | "feedback-request" | "referral" | "company-news" | "fundraising" | "order-confirmation" | "shipping-update" | "receipt" | "password-reset" | "verification" | "security-alert" | "account-update" | "notification" | "support" | "internal" | "business" | "general"; /** @description Inbox subject line to set on the design (`subjectLine` — distinct from `title`, the canvas name). Sends still take an explicit per-send `subject`; this is the design's default, seeded into the send dialog and returned by `GET /v1/emails/{emailId}`. */ subjectLine?: string; /** @description File the design under an existing group (`grp_…`), or `ungrouped`. Mutually exclusive with `groupName`. */ @@ -7121,6 +7121,8 @@ export interface components { fieldsCreated: string[]; errors: { email: string; + /** @description The 1-based row that failed: `contacts[row - 1]`, or the CSV data row (header excluded). */ + row?: number; code: string; field?: string; message: string; @@ -7128,6 +7130,8 @@ export interface components { warnings: { code: string; email?: string; + /** @description The 1-based row it is about (CSV: data row). */ + row?: number; field?: string; message: string; from?: string; @@ -7208,6 +7212,8 @@ export interface components { warnings: { code: string; email?: string; + /** @description The 1-based row it is about (CSV: data row). */ + row?: number; field?: string; message: string; from?: string; @@ -7667,12 +7673,16 @@ export interface components { inserted: number; updated: number; failed: number; - /** @description CSV rows left out for a missing or invalid email, or merged into a later row with the same address. Always 0 for a JSON import. */ + /** @description CSV rows left out for a missing or invalid email, or merged into a later row with the same address; each has a `CSV_ROW_SKIPPED` warning naming its `row`. Always 0 for a JSON import. */ skipped: number; + /** @description Rows submitted (JSON contacts, or CSV data rows). Always `inserted + updated + failed + skipped`. */ + total: number; }; fieldsCreated: string[]; errors: { email: string; + /** @description The 1-based row that failed: `contacts[row - 1]`, or the CSV data row (header excluded). */ + row?: number; code: string; field?: string; message: string; @@ -7680,6 +7690,8 @@ export interface components { warnings: { code: string; email?: string; + /** @description The 1-based row it is about (CSV: data row). */ + row?: number; field?: string; message: string; from?: string; @@ -26837,18 +26849,20 @@ export interface operations { query?: { /** @description Only this brand's templates, by domain (e.g. `brew.new`); case-insensitive. */ brand?: string; - /** @description Email category, matched case-insensitively (unknown → empty page): welcome, newsletter, promotional, product-launch, product-update, order-confirmation, shipping-update, receipt, cart-abandonment, subscription, password-reset, verification, security-alert, account-update, event-invitation, event-reminder, feedback-request, re-engagement, referral, support, business, internal, notification, general. */ + /** @description Email category, matched case-insensitively (unknown → empty page): welcome, onboarding, newsletter, content, event-invitation, event-reminder, event-recap, product-launch, product-update, product-pitch, promotional, cart-abandonment, re-engagement, subscription, feedback-request, referral, company-news, fundraising, order-confirmation, shipping-update, receipt, password-reset, verification, security-alert, account-update, notification, support, internal, business, general. */ category?: string; + /** @description A browse group: every category in it (e.g. `events` = invitations, reminders and recaps). */ + group?: "welcome-onboarding" | "newsletter" | "content" | "events" | "product-news" | "sales" | "retention" | "company" | "transactional" | "internal" | "other"; /** @description Rank by relevance to this text instead of newest first (top 200; `brand`/`category` still narrow). */ semantic?: string; /** @description Case-insensitive title substring or exact public template identifier. Applied within semantic results when semantic is supplied. */ query?: string; /** @description Full rows include HTML. Summary rows include selection metadata and links only. */ representation?: "full" | "summary"; - /** @description Return `{ count }`, the number of templates matching `brand`/`category`, instead of rows. Not with `semantic` or `query`. */ + /** @description Return `{ count }`, the number of templates matching `brand`/`category`/`group`, instead of rows. Not with `semantic` or `query`. */ count?: boolean | ("true" | "false"); - /** @description With `count: true`: also count per brand or per category, largest first, 100 groups a page; continue with `cursor`. */ - groupBy?: "brand" | "category"; + /** @description With `count: true`: also count per brand, category or group, largest first, 100 groups a page; continue with `cursor`. */ + groupBy?: "brand" | "category" | "group"; /** * @description Page size, 1–100 (default 50: each row carries its full HTML). * @example 50 @@ -26919,10 +26933,10 @@ export interface operations { /** @description Templates matching `brand`/`category`. */ count: number; /** @enum {string} */ - groupBy?: "brand" | "category"; + groupBy?: "brand" | "category" | "group"; /** @description With `groupBy`: up to 100 groups, largest first. */ groups?: { - /** @description The brand domain or category; pass it back as the `brand`/`category` filter. */ + /** @description The brand domain, category or group; pass it back as the filter of the same name (`brand`, `category` or `group`). */ value: string; /** @description The brand's curated display name, when it has one. */ name?: string; diff --git a/tests/commands/contacts-import-summary.test.ts b/tests/commands/contacts-import-summary.test.ts new file mode 100644 index 0000000..ee8e161 --- /dev/null +++ b/tests/commands/contacts-import-summary.test.ts @@ -0,0 +1,78 @@ +import type { ImportContactsResponse } from '@brew.new/sdk' +import { describe, expect, it } from 'vitest' +import { + MAX_TERMINAL_ROWS, + renderImportResult, +} from '../../src/commands/contacts/import-summary' + +function result( + overrides: Partial = {} +): ImportContactsResponse { + return { + summary: { inserted: 2, updated: 0, failed: 1, skipped: 3, total: 6 }, + fieldsCreated: [], + errors: [ + { + email: 'grace@brewmail.dev', + row: 5, + code: 'FIELD_TYPE_MISMATCH', + message: "Field 'customFields.plan' is type 'number'.", + }, + ], + warnings: [ + { + code: 'CSV_ROW_SKIPPED', + row: 2, + message: 'Row 2 has no email address, so it was skipped.', + }, + ], + importId: 'imp_123', + ...overrides, + } +} + +describe('renderImportResult (contacts import / import-csv on a terminal)', () => { + it('leads with counts that add up to the rows, then names each row', () => { + const text = renderImportResult(result(), 'customers.csv') + expect(text.split('\n')[0]).toBe( + 'Imported customers.csv as imp_123: 2 added, 0 updated, 1 failed, 3 skipped (6 rows).' + ) + expect(text).toContain('FAILED ROWS') + expect(text).toMatch(/5\s+grace@brewmail\.dev\s+FIELD_TYPE_MISMATCH/) + expect(text).toMatch(/2\s+CSV_ROW_SKIPPED\s+Row 2 has no email address/) + }) + + it('omits empty sections and a zero skipped count', () => { + const text = renderImportResult( + result({ + summary: { inserted: 1, updated: 0, failed: 0, skipped: 0, total: 1 }, + errors: [], + warnings: [], + fieldsCreated: ['plan'], + validationJobId: 'val_1', + }), + undefined + ) + expect(text).toBe( + [ + 'Imported as imp_123: 1 added, 0 updated, 0 failed (1 row).', + '', + 'New custom fields: plan.', + '', + 'Deliverability check running as validation job val_1.', + ].join('\n') + ) + }) + + it('caps a long table and points at --json for the rest', () => { + const errors = Array.from({ length: MAX_TERMINAL_ROWS + 5 }, (_, i) => ({ + email: `x${i}@brewmail.dev`, + row: i + 1, + code: 'INVALID_EMAIL', + message: 'Invalid.', + })) + const text = renderImportResult(result({ errors, warnings: [] }), 'a.json') + expect(text).toContain('… 5 more; --json prints every row.') + expect(text).not.toContain(`x${MAX_TERMINAL_ROWS}@brewmail.dev`) + }) +}) From 7e443f22d0557dd9933daa2171261a2f62dfc4c3 Mon Sep 17 00:00:00 2001 From: Thomas Park Date: Wed, 21 Oct 2026 02:21:16 +0000 Subject: [PATCH 08/16] Render supported import outcomes from the canonical API --- CHANGELOG.md | 8 +++--- src/commands/contacts/import-summary.ts | 14 +++++----- src/generated/openapi-types.ts | 16 ++--------- .../commands/contacts-import-summary.test.ts | 27 ++++++++++--------- 4 files changed, 27 insertions(+), 38 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 195a667..4c930af 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -22,10 +22,10 @@ but does not provide that method. Follow the contact import SDK gate in - `contacts import-csv` names its batch in Import History: `--file-name`, else a `fileName` in `--input`, else the `--file` name. Both imports return `importId`. -- On a terminal, `contacts import` and `contacts import-csv` print one line of - counts that add up to the rows submitted, then each failed row and each - note (a skipped CSV row, an ignored column) with its row number. `--json` - still prints the API envelope verbatim. +- On a terminal, `contacts import` and `contacts import-csv` print the API's + counts, failed addresses, and warnings with their available field details. + `--json` still prints the API envelope verbatim. Row indexes and a submitted + total are not part of the current API contract. ### Changed diff --git a/src/commands/contacts/import-summary.ts b/src/commands/contacts/import-summary.ts index 32a31c4..1b5e203 100644 --- a/src/commands/contacts/import-summary.ts +++ b/src/commands/contacts/import-summary.ts @@ -6,9 +6,8 @@ export const MAX_TERMINAL_ROWS = 20 /** * The terminal rendering of `contacts import` and `contacts import-csv`: one - * line of counts that add up to the rows submitted, then the rows that - * failed and the notes (skipped CSV rows, ignored columns), each by row, so - * a fix starts from the row number. `--json` prints the full envelope. + * line of counts, then the failures and warnings returned by the API. + * `--json` prints the full envelope. */ export function renderImportResult( result: ImportContactsResponse, @@ -22,17 +21,15 @@ export function renderImportResult( ...(summary.skipped > 0 ? [`${summary.skipped} skipped`] : []), ].join(', ') const name = fileName ? `${fileName} ` : '' - const lines = [ - `Imported ${name}as ${result.importId}: ${counts} (${summary.total} row${summary.total === 1 ? '' : 's'}).`, - ] + const lines = [`Imported ${name}as ${result.importId}: ${counts}.`] if (result.errors.length > 0) { lines.push( '', 'FAILED ROWS', limitedTable(result.errors, [ - { key: 'row', header: 'ROW' }, { key: 'email', header: 'EMAIL' }, { key: 'code', header: 'CODE' }, + { key: 'field', header: 'FIELD' }, { key: 'message', header: 'MESSAGE' }, ]) ) @@ -42,8 +39,9 @@ export function renderImportResult( '', 'WARNINGS', limitedTable(result.warnings, [ - { key: 'row', header: 'ROW' }, + { key: 'email', header: 'EMAIL' }, { key: 'code', header: 'CODE' }, + { key: 'field', header: 'FIELD' }, { key: 'message', header: 'MESSAGE' }, ]) ) diff --git a/src/generated/openapi-types.ts b/src/generated/openapi-types.ts index f568380..c964bd4 100644 --- a/src/generated/openapi-types.ts +++ b/src/generated/openapi-types.ts @@ -3448,7 +3448,7 @@ export interface components { }; ApiWarning: { /** @enum {string} */ - code: "DRAFT_SAVED_NOT_LIVE" | "TRIGGER_EVENT_NOT_RECEIVED" | "ENFORCEMENT_LOOSENED_WHILE_PUBLISHED" | "REQUIRED_FIELD_SATISFIED_BY_FALLBACK" | "RESUBSCRIBE_SKIPPED" | "CORE_FIELD_IGNORED" | "DATE_ORDER_ASSUMED" | "CSV_ROW_SKIPPED" | "CSV_COLUMN_IGNORED" | "RECIPIENTS_EXCLUDED" | "CONSENT_RECORD_MISSING"; + code: "DRAFT_SAVED_NOT_LIVE" | "TRIGGER_EVENT_NOT_RECEIVED" | "ENFORCEMENT_LOOSENED_WHILE_PUBLISHED" | "REQUIRED_FIELD_SATISFIED_BY_FALLBACK" | "RESUBSCRIBE_SKIPPED" | "CORE_FIELD_IGNORED" | "DATE_ORDER_ASSUMED" | "CSV_COLUMN_IGNORED" | "RECIPIENTS_EXCLUDED" | "CONSENT_RECORD_MISSING"; message: string; field?: string; }; @@ -7121,8 +7121,6 @@ export interface components { fieldsCreated: string[]; errors: { email: string; - /** @description The 1-based row that failed: `contacts[row - 1]`, or the CSV data row (header excluded). */ - row?: number; code: string; field?: string; message: string; @@ -7130,8 +7128,6 @@ export interface components { warnings: { code: string; email?: string; - /** @description The 1-based row it is about (CSV: data row). */ - row?: number; field?: string; message: string; from?: string; @@ -7212,8 +7208,6 @@ export interface components { warnings: { code: string; email?: string; - /** @description The 1-based row it is about (CSV: data row). */ - row?: number; field?: string; message: string; from?: string; @@ -7673,16 +7667,12 @@ export interface components { inserted: number; updated: number; failed: number; - /** @description CSV rows left out for a missing or invalid email, or merged into a later row with the same address; each has a `CSV_ROW_SKIPPED` warning naming its `row`. Always 0 for a JSON import. */ + /** @description CSV rows left out for a missing or invalid email, or merged into a later row with the same address. Always 0 for a JSON import. */ skipped: number; - /** @description Rows submitted (JSON contacts, or CSV data rows). Always `inserted + updated + failed + skipped`. */ - total: number; }; fieldsCreated: string[]; errors: { email: string; - /** @description The 1-based row that failed: `contacts[row - 1]`, or the CSV data row (header excluded). */ - row?: number; code: string; field?: string; message: string; @@ -7690,8 +7680,6 @@ export interface components { warnings: { code: string; email?: string; - /** @description The 1-based row it is about (CSV: data row). */ - row?: number; field?: string; message: string; from?: string; diff --git a/tests/commands/contacts-import-summary.test.ts b/tests/commands/contacts-import-summary.test.ts index ee8e161..2cadf66 100644 --- a/tests/commands/contacts-import-summary.test.ts +++ b/tests/commands/contacts-import-summary.test.ts @@ -9,21 +9,21 @@ function result( overrides: Partial = {} ): ImportContactsResponse { return { - summary: { inserted: 2, updated: 0, failed: 1, skipped: 3, total: 6 }, + summary: { inserted: 2, updated: 0, failed: 1, skipped: 3 }, fieldsCreated: [], errors: [ { email: 'grace@brewmail.dev', - row: 5, code: 'FIELD_TYPE_MISMATCH', + field: 'customFields.plan', message: "Field 'customFields.plan' is type 'number'.", }, ], warnings: [ { - code: 'CSV_ROW_SKIPPED', - row: 2, - message: 'Row 2 has no email address, so it was skipped.', + code: 'CSV_COLUMN_IGNORED', + field: 'Created At', + message: 'Created At is managed by Brew and was ignored.', }, ], importId: 'imp_123', @@ -32,20 +32,24 @@ function result( } describe('renderImportResult (contacts import / import-csv on a terminal)', () => { - it('leads with counts that add up to the rows, then names each row', () => { + it('renders API counts, failed addresses and warning fields without requiring row indexes', () => { const text = renderImportResult(result(), 'customers.csv') expect(text.split('\n')[0]).toBe( - 'Imported customers.csv as imp_123: 2 added, 0 updated, 1 failed, 3 skipped (6 rows).' + 'Imported customers.csv as imp_123: 2 added, 0 updated, 1 failed, 3 skipped.' ) expect(text).toContain('FAILED ROWS') - expect(text).toMatch(/5\s+grace@brewmail\.dev\s+FIELD_TYPE_MISMATCH/) - expect(text).toMatch(/2\s+CSV_ROW_SKIPPED\s+Row 2 has no email address/) + expect(text).toMatch( + /grace@brewmail\.dev\s+FIELD_TYPE_MISMATCH\s+customFields.plan/ + ) + expect(text).toMatch( + /CSV_COLUMN_IGNORED\s+Created At\s+Created At is managed/ + ) }) it('omits empty sections and a zero skipped count', () => { const text = renderImportResult( result({ - summary: { inserted: 1, updated: 0, failed: 0, skipped: 0, total: 1 }, + summary: { inserted: 1, updated: 0, failed: 0, skipped: 0 }, errors: [], warnings: [], fieldsCreated: ['plan'], @@ -55,7 +59,7 @@ describe('renderImportResult (contacts import / import-csv on a terminal)', () = ) expect(text).toBe( [ - 'Imported as imp_123: 1 added, 0 updated, 0 failed (1 row).', + 'Imported as imp_123: 1 added, 0 updated, 0 failed.', '', 'New custom fields: plan.', '', @@ -67,7 +71,6 @@ describe('renderImportResult (contacts import / import-csv on a terminal)', () = it('caps a long table and points at --json for the rest', () => { const errors = Array.from({ length: MAX_TERMINAL_ROWS + 5 }, (_, i) => ({ email: `x${i}@brewmail.dev`, - row: i + 1, code: 'INVALID_EMAIL', message: 'Invalid.', })) From 66e1d767b65a79510f3b2c1a6c7dd02c9d57b418 Mon Sep 17 00:00:00 2001 From: Thomas Park Date: Wed, 21 Oct 2026 02:24:14 +0000 Subject: [PATCH 09/16] Validate imports against the reviewed SDK 13 source baseline --- CHANGELOG.md | 5 +++-- RELEASING.md | 22 +++++++++++----------- bun.lock | 7 +++++-- package.json | 7 +++++-- 4 files changed, 24 insertions(+), 17 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4c930af..166f694 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,8 +4,9 @@ These import changes require an SDK release exposing `contacts.import`. The published SDK 13.0.0 baseline supports the existing released commands, -but does not provide that method. Follow the contact import SDK gate in -`RELEASING.md` before dependency validation and release. +but does not provide that method. This branch uses a reviewed immutable SDK +source commit for coordinated validation. Follow the contact import SDK gate +in `RELEASING.md` before release. ### Added diff --git a/RELEASING.md b/RELEASING.md index 9cfcf4b..ac351b6 100644 --- a/RELEASING.md +++ b/RELEASING.md @@ -13,8 +13,8 @@ JSON response. Do not release these commands with an SDK version that discards that response. SDK 13.0.0 preserves the revocation JSON response and is the minimum supported -SDK release for these commands. The dependency and Bun lockfile consume the -published registry artifact; the temporary Git source pin has been removed. +SDK release for these commands. CLI 0.17.0 consumes that published registry +artifact. Contact-import coordination uses the separate gate below. For future upgrades, release the SDK first, then update the dependency and regenerate `bun.lock` from its actual published artifact. Run frozen @@ -26,15 +26,15 @@ lockfile checksums must never be invented. The canvas tests must preserve ### Contact Import SDK Gate The new `contacts import` command requires `contacts.import`, which the -published SDK 13.0.0 does not expose. Keeping its registry dependency while -integrating the branch does not validate or enable that command. - -After the app contract is finalized, coordinated PR validation may use an -immutable reviewed SDK source commit containing the import method and the -current published SDK features. A fresh frozen install must build that source -dependency without relying on a locally packed SDK. Release the API and SDK -first, then replace any temporary source pin with the actual published SDK -version and regenerate `bun.lock` before releasing the CLI. +published SDK 13.0.0 does not expose. This branch pins reviewed SDK source +commit `9bedcdc8810314ee8de62273e510b8da6131574c` for coordinated PR validation. +It preserves SDK 13.0.0's features and version while adding the import method. + +A fresh frozen install must build that source dependency without relying on +a locally packed SDK. Do not release the CLI with this temporary source pin. +Deploy the API contract and release the SDK first, then replace the pin with +the actual published SDK version and regenerate `bun.lock` before releasing +the CLI. ```bash # on a clean main with CI green diff --git a/bun.lock b/bun.lock index 8904a83..5bc0c1f 100644 --- a/bun.lock +++ b/bun.lock @@ -5,7 +5,7 @@ "": { "name": "@brew.new/cli", "dependencies": { - "@brew.new/sdk": "^13.0.0", + "@brew.new/sdk": "git+https://github.com/GetBrew/typescript-sdk.git#9bedcdc8810314ee8de62273e510b8da6131574c", "commander": "^14.0.0", }, "devDependencies": { @@ -20,6 +20,9 @@ }, }, }, + "trustedDependencies": [ + "@brew.new/sdk", + ], "packages": { "@babel/code-frame": ["@babel/code-frame@7.29.7", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.29.7", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" } }, "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw=="], @@ -43,7 +46,7 @@ "@biomejs/cli-win32-x64": ["@biomejs/cli-win32-x64@2.5.7", "", { "os": "win32", "cpu": "x64" }, "sha512-V+0wu/nrj2S+MhP4EQ0uHNolP0IALEsz45pg0WoKkHfDeh0+ItHwP/p7bX5RPoMOl9NkpHYWdYPhIcy2mACHvQ=="], - "@brew.new/sdk": ["@brew.new/sdk@13.0.0", "", { "peerDependencies": { "react": ">=18.2.0 <20" }, "optionalPeers": ["react"] }, "sha512-oqQnXRJY/duMsL7Sn+i18+omB+oDkp7fzK6f7/27YWkB5nxIY5X+aQ7sRjRBBnb/JPeHvfVmHb7DuZ2ABJ3HFw=="], + "@brew.new/sdk": ["@brew.new/sdk@github:GetBrew/typescript-sdk#9bedcdc", { "peerDependencies": { "react": ">=18.2.0 <20" }, "optionalPeers": ["react"] }, "GetBrew-typescript-sdk-9bedcdc", "sha512-xlegIQtdOP1P2Rx/wZYBcEvzGNxsoEVPM4FOz9Yo47qipvehJIM3iDxr16PLpUDuKna1d/WsLduyoJSz+nIaZw=="], "@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.27.7", "", { "os": "aix", "cpu": "ppc64" }, "sha512-EKX3Qwmhz1eMdEJokhALr0YiD0lhQNwDqkPYyPhiSwKrh7/4KRjQc04sZ8db+5DVVnZ1LmbNDI1uAMPEUBnQPg=="], diff --git a/package.json b/package.json index 7453d83..df3a561 100644 --- a/package.json +++ b/package.json @@ -53,7 +53,7 @@ "smoke:signals": "bun run scripts/signal-smoke.ts" }, "dependencies": { - "@brew.new/sdk": "^13.0.0", + "@brew.new/sdk": "git+https://github.com/GetBrew/typescript-sdk.git#9bedcdc8810314ee8de62273e510b8da6131574c", "commander": "^14.0.0" }, "devDependencies": { @@ -66,5 +66,8 @@ "vitest": "^4.1.2", "yaml": "^2.8.0" }, - "packageManager": "bun@1.3.10" + "packageManager": "bun@1.3.10", + "trustedDependencies": [ + "@brew.new/sdk" + ] } From 0d6b11d56bc0f8b6e357a8a375d81d558d45c1c2 Mon Sep 17 00:00:00 2001 From: Thomas Park Date: Wed, 21 Oct 2026 02:52:17 +0000 Subject: [PATCH 10/16] Show completed contact import validation results on terminals --- CHANGELOG.md | 2 + RELEASING.md | 2 +- bun.lock | 4 +- package.json | 2 +- src/commands/contacts/import-summary.ts | 13 +++++ .../commands/contacts-import-summary.test.ts | 29 ++++++++++ .../contacts-import-validation-output.test.ts | 54 +++++++++++++++++++ 7 files changed, 102 insertions(+), 4 deletions(-) create mode 100644 tests/commands/contacts-import-validation-output.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 166f694..34b286d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -27,6 +27,8 @@ in `RELEASING.md` before release. counts, failed addresses, and warnings with their available field details. `--json` still prints the API envelope verbatim. Row indexes and a submitted total are not part of the current API contract. +- Completed import deliverability checks show valid, risky, invalid and + unscored counts, plus available persistence counts, in terminal output. ### Changed diff --git a/RELEASING.md b/RELEASING.md index ac351b6..65e7219 100644 --- a/RELEASING.md +++ b/RELEASING.md @@ -27,7 +27,7 @@ lockfile checksums must never be invented. The canvas tests must preserve The new `contacts import` command requires `contacts.import`, which the published SDK 13.0.0 does not expose. This branch pins reviewed SDK source -commit `9bedcdc8810314ee8de62273e510b8da6131574c` for coordinated PR validation. +commit `1f9f0af51c81b23541c9cee303e027ad5a263bc3` for coordinated PR validation. It preserves SDK 13.0.0's features and version while adding the import method. A fresh frozen install must build that source dependency without relying on diff --git a/bun.lock b/bun.lock index 5bc0c1f..6e5586c 100644 --- a/bun.lock +++ b/bun.lock @@ -5,7 +5,7 @@ "": { "name": "@brew.new/cli", "dependencies": { - "@brew.new/sdk": "git+https://github.com/GetBrew/typescript-sdk.git#9bedcdc8810314ee8de62273e510b8da6131574c", + "@brew.new/sdk": "git+https://github.com/GetBrew/typescript-sdk.git#1f9f0af51c81b23541c9cee303e027ad5a263bc3", "commander": "^14.0.0", }, "devDependencies": { @@ -46,7 +46,7 @@ "@biomejs/cli-win32-x64": ["@biomejs/cli-win32-x64@2.5.7", "", { "os": "win32", "cpu": "x64" }, "sha512-V+0wu/nrj2S+MhP4EQ0uHNolP0IALEsz45pg0WoKkHfDeh0+ItHwP/p7bX5RPoMOl9NkpHYWdYPhIcy2mACHvQ=="], - "@brew.new/sdk": ["@brew.new/sdk@github:GetBrew/typescript-sdk#9bedcdc", { "peerDependencies": { "react": ">=18.2.0 <20" }, "optionalPeers": ["react"] }, "GetBrew-typescript-sdk-9bedcdc", "sha512-xlegIQtdOP1P2Rx/wZYBcEvzGNxsoEVPM4FOz9Yo47qipvehJIM3iDxr16PLpUDuKna1d/WsLduyoJSz+nIaZw=="], + "@brew.new/sdk": ["@brew.new/sdk@github:GetBrew/typescript-sdk#1f9f0af", { "peerDependencies": { "react": ">=18.2.0 <20" }, "optionalPeers": ["react"] }, "GetBrew-typescript-sdk-1f9f0af", "sha512-gZRtsBwgmpKpON5Muq57Y/4KxmvMnIw7UzQScNwJ4HAfGqY8OlzeCHUchN/lRKfk+1a4qkkT5YGlyqawvwMO+g=="], "@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.27.7", "", { "os": "aix", "cpu": "ppc64" }, "sha512-EKX3Qwmhz1eMdEJokhALr0YiD0lhQNwDqkPYyPhiSwKrh7/4KRjQc04sZ8db+5DVVnZ1LmbNDI1uAMPEUBnQPg=="], diff --git a/package.json b/package.json index df3a561..405700f 100644 --- a/package.json +++ b/package.json @@ -53,7 +53,7 @@ "smoke:signals": "bun run scripts/signal-smoke.ts" }, "dependencies": { - "@brew.new/sdk": "git+https://github.com/GetBrew/typescript-sdk.git#9bedcdc8810314ee8de62273e510b8da6131574c", + "@brew.new/sdk": "git+https://github.com/GetBrew/typescript-sdk.git#1f9f0af51c81b23541c9cee303e027ad5a263bc3", "commander": "^14.0.0" }, "devDependencies": { diff --git a/src/commands/contacts/import-summary.ts b/src/commands/contacts/import-summary.ts index 1b5e203..2112d5f 100644 --- a/src/commands/contacts/import-summary.ts +++ b/src/commands/contacts/import-summary.ts @@ -49,6 +49,19 @@ export function renderImportResult( if (result.fieldsCreated.length > 0) { lines.push('', `New custom fields: ${result.fieldsCreated.join(', ')}.`) } + if (result.validation) { + const { valid, risky, invalid, unscored, persistence } = result.validation + lines.push( + '', + 'DELIVERABILITY CHECK', + `${valid} valid, ${risky} risky, ${invalid} invalid, ${unscored} unscored.` + ) + if (persistence) { + lines.push( + `Saved results: ${persistence.matched} matched, ${persistence.modified} updated, ${persistence.failed} failed to save.` + ) + } + } if (result.validationJobId) { lines.push( '', diff --git a/tests/commands/contacts-import-summary.test.ts b/tests/commands/contacts-import-summary.test.ts index 2cadf66..bd81b22 100644 --- a/tests/commands/contacts-import-summary.test.ts +++ b/tests/commands/contacts-import-summary.test.ts @@ -78,4 +78,33 @@ describe('renderImportResult (contacts import / import-csv on a terminal)', () = expect(text).toContain('… 5 more; --json prints every row.') expect(text).not.toContain(`x${MAX_TERMINAL_ROWS}@brewmail.dev`) }) + + it('shows completed deliverability outcomes and failed saves on a terminal', () => { + const text = renderImportResult( + result({ + validation: { + valid: 2, + risky: 1, + invalid: 3, + unscored: 4, + persistence: { matched: 6, modified: 5, failed: 1 }, + }, + }), + undefined + ) + expect(text).toContain('DELIVERABILITY CHECK') + expect(text).toContain('2 valid, 1 risky, 3 invalid, 4 unscored.') + expect(text).toContain( + 'Saved results: 6 matched, 5 updated, 1 failed to save.' + ) + }) + + it('shows completed outcomes when persistence details are absent', () => { + const text = renderImportResult( + result({ validation: { valid: 1, risky: 0, invalid: 0, unscored: 0 } }), + undefined + ) + expect(text).toContain('1 valid, 0 risky, 0 invalid, 0 unscored.') + expect(text).not.toContain('Saved results:') + }) }) diff --git a/tests/commands/contacts-import-validation-output.test.ts b/tests/commands/contacts-import-validation-output.test.ts new file mode 100644 index 0000000..f4e3fcb --- /dev/null +++ b/tests/commands/contacts-import-validation-output.test.ts @@ -0,0 +1,54 @@ +import { HttpResponse, http } from 'msw' +import { describe, expect, it } from 'vitest' +import { server } from '../helpers/msw-server' +import { runCli } from '../helpers/run-cli' + +describe('completed import validation output', () => { + it.each(['import', 'import-csv'])( + '%s shows paid check outcomes on a terminal and preserves JSON', + async (command) => { + const response = { + summary: { inserted: 1, updated: 0, failed: 0, skipped: 0 }, + fieldsCreated: [], + errors: [], + warnings: [], + importId: 'imp_checked', + validation: { + valid: 0, + risky: 0, + invalid: 0, + unscored: 1, + persistence: { matched: 1, modified: 0, failed: 1 }, + }, + } + server.use( + http.post( + `https://brew.new/api/v1/contacts/${command}`, + async ({ request }) => { + expect(await request.json()).toMatchObject({ validate: true }) + return HttpResponse.json(response) + } + ) + ) + const options = { + env: { + BREW_API_KEY: 'brew_validation_output_abcdefghijklmnopqrstuvwxyz', + }, + stdin: + command === 'import' + ? '[{"email":"jane@example.com"}]' + : 'Email\njane@example.com\n', + ttyOut: true, + } + const args = ['contacts', command, '--file', '-', '--validate'] + const human = await runCli(args, options) + expect(human.code).toBe(0) + expect(human.stdout).toContain('0 valid, 0 risky, 0 invalid, 1 unscored.') + expect(human.stdout).toContain('1 failed to save.') + + const json = await runCli([...args, '--json'], options) + expect(json.code).toBe(0) + expect(json.json).toEqual(response) + } + ) +}) From bfdbd641f0f052f42bbca44ee1d971b48035fbda Mon Sep 17 00:00:00 2001 From: Thomas Park Date: Wed, 21 Oct 2026 03:18:37 +0000 Subject: [PATCH 11/16] Synchronize final import totals and row diagnostics --- CHANGELOG.md | 7 ++- RELEASING.md | 2 +- bun.lock | 4 +- openapi/public-api-v1.yaml | 43 +++++++++++++- package.json | 2 +- src/commands/contacts/import-summary.ts | 9 ++- src/generated/openapi-types.ts | 44 +++++++++++--- .../commands/contacts-import-summary.test.ts | 58 ++++++++++++++++--- .../contacts-import-validation-output.test.ts | 2 +- tests/commands/contacts.test.ts | 2 +- 10 files changed, 145 insertions(+), 28 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 34b286d..f5108f2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -24,9 +24,10 @@ in `RELEASING.md` before release. a `fileName` in `--input`, else the `--file` name. Both imports return `importId`. - On a terminal, `contacts import` and `contacts import-csv` print the API's - counts, failed addresses, and warnings with their available field details. - `--json` still prints the API envelope verbatim. Row indexes and a submitted - total are not part of the current API contract. + submitted total and outcome counts, plus failed rows and warnings with + available row, email and field details. `--json` preserves the API envelope. + CSV skip warnings list the first 100 skipped rows and summarize the rest; + column-wide and aggregate warnings have no row index. - Completed import deliverability checks show valid, risky, invalid and unscored counts, plus available persistence counts, in terminal output. diff --git a/RELEASING.md b/RELEASING.md index 65e7219..5c36275 100644 --- a/RELEASING.md +++ b/RELEASING.md @@ -27,7 +27,7 @@ lockfile checksums must never be invented. The canvas tests must preserve The new `contacts import` command requires `contacts.import`, which the published SDK 13.0.0 does not expose. This branch pins reviewed SDK source -commit `1f9f0af51c81b23541c9cee303e027ad5a263bc3` for coordinated PR validation. +commit `f7608fa1d7771b9d023dea5bfc123b983d925408` for coordinated PR validation. It preserves SDK 13.0.0's features and version while adding the import method. A fresh frozen install must build that source dependency without relying on diff --git a/bun.lock b/bun.lock index 6e5586c..c4a1ef3 100644 --- a/bun.lock +++ b/bun.lock @@ -5,7 +5,7 @@ "": { "name": "@brew.new/cli", "dependencies": { - "@brew.new/sdk": "git+https://github.com/GetBrew/typescript-sdk.git#1f9f0af51c81b23541c9cee303e027ad5a263bc3", + "@brew.new/sdk": "git+https://github.com/GetBrew/typescript-sdk.git#f7608fa1d7771b9d023dea5bfc123b983d925408", "commander": "^14.0.0", }, "devDependencies": { @@ -46,7 +46,7 @@ "@biomejs/cli-win32-x64": ["@biomejs/cli-win32-x64@2.5.7", "", { "os": "win32", "cpu": "x64" }, "sha512-V+0wu/nrj2S+MhP4EQ0uHNolP0IALEsz45pg0WoKkHfDeh0+ItHwP/p7bX5RPoMOl9NkpHYWdYPhIcy2mACHvQ=="], - "@brew.new/sdk": ["@brew.new/sdk@github:GetBrew/typescript-sdk#1f9f0af", { "peerDependencies": { "react": ">=18.2.0 <20" }, "optionalPeers": ["react"] }, "GetBrew-typescript-sdk-1f9f0af", "sha512-gZRtsBwgmpKpON5Muq57Y/4KxmvMnIw7UzQScNwJ4HAfGqY8OlzeCHUchN/lRKfk+1a4qkkT5YGlyqawvwMO+g=="], + "@brew.new/sdk": ["@brew.new/sdk@github:GetBrew/typescript-sdk#f7608fa", { "peerDependencies": { "react": ">=18.2.0 <20" }, "optionalPeers": ["react"] }, "GetBrew-typescript-sdk-f7608fa", "sha512-cfc+iV7tYD+j3hoxf9ZXf4caSuhWO7D3VSPN/SvXaadhZIrIfPwECaMap/h/80N0WhavvDymp0OgV58UrAtrDg=="], "@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.27.7", "", { "os": "aix", "cpu": "ppc64" }, "sha512-EKX3Qwmhz1eMdEJokhALr0YiD0lhQNwDqkPYyPhiSwKrh7/4KRjQc04sZ8db+5DVVnZ1LmbNDI1uAMPEUBnQPg=="], diff --git a/openapi/public-api-v1.yaml b/openapi/public-api-v1.yaml index fcea97d..6f2ce8d 100644 --- a/openapi/public-api-v1.yaml +++ b/openapi/public-api-v1.yaml @@ -2247,6 +2247,7 @@ components: - RESUBSCRIBE_SKIPPED - CORE_FIELD_IGNORED - DATE_ORDER_ASSUMED + - CSV_ROW_SKIPPED - CSV_COLUMN_IGNORED - RECIPIENTS_EXCLUDED - CONSENT_RECORD_MISSING @@ -12743,6 +12744,10 @@ components: properties: email: type: string + row: + type: integer + exclusiveMinimum: 0 + description: "The 1-based row that failed: `contacts[row - 1]`, or the CSV data row (header excluded)." code: type: string field: @@ -12765,6 +12770,10 @@ components: email: type: string minLength: 1 + row: + type: integer + exclusiveMinimum: 0 + description: "The 1-based row it is about (CSV: data row)." field: type: string minLength: 1 @@ -12956,6 +12965,10 @@ components: email: type: string minLength: 1 + row: + type: integer + exclusiveMinimum: 0 + description: "The 1-based row it is about (CSV: data row)." field: type: string minLength: 1 @@ -13992,12 +14005,17 @@ components: skipped: type: integer minimum: 0 - description: CSV rows left out for a missing or invalid email, or merged into a later row with the same address. Always 0 for a JSON import. + description: CSV rows omitted for a missing/invalid email or merged into a later duplicate. Up to 100 `CSV_ROW_SKIPPED` warnings name each `row`; a final warning counts further skipped rows. Always 0 for JSON. + total: + type: integer + minimum: 0 + description: Rows submitted (JSON contacts, or CSV data rows). Always `inserted + updated + failed + skipped`. required: - inserted - updated - failed - skipped + - total additionalProperties: false fieldsCreated: type: array @@ -14010,6 +14028,10 @@ components: properties: email: type: string + row: + type: integer + exclusiveMinimum: 0 + description: "The 1-based row that failed: `contacts[row - 1]`, or the CSV data row (header excluded)." code: type: string field: @@ -14032,6 +14054,10 @@ components: email: type: string minLength: 1 + row: + type: integer + exclusiveMinimum: 0 + description: "The 1-based row it is about (CSV: data row)." field: type: string minLength: 1 @@ -48715,6 +48741,7 @@ paths: updated: 0 failed: 0 skipped: 0 + total: 2 fieldsCreated: [] errors: [] warnings: [] @@ -48760,13 +48787,22 @@ paths: updated: 0 failed: 1 skipped: 2 + total: 4 fieldsCreated: [] errors: - email: john@example.com + row: 2 code: FIELD_TYPE_MISMATCH field: customFields.plan message: Field 'customFields.plan' is type 'number' but received string '$49'. - warnings: [] + warnings: + - code: CSV_ROW_SKIPPED + row: 3 + message: Row 3 has no email address, so it was skipped. + - code: CSV_ROW_SKIPPED + row: 4 + email: not-an-email + message: Row 4 holds 'not-an-email', which is not a valid email address, so it was skipped. importId: imp_01JABCDEF0123456789XYZ "400": description: |- @@ -49195,6 +49231,7 @@ paths: updated: 0 failed: 0 skipped: 0 + total: 2 fieldsCreated: - plan errors: [] @@ -49241,9 +49278,11 @@ paths: updated: 0 failed: 1 skipped: 0 + total: 2 fieldsCreated: [] errors: - email: not-an-email + row: 2 code: INVALID_EMAIL message: "'not-an-email' is not a valid email address." warnings: [] diff --git a/package.json b/package.json index 405700f..7bb5ad3 100644 --- a/package.json +++ b/package.json @@ -53,7 +53,7 @@ "smoke:signals": "bun run scripts/signal-smoke.ts" }, "dependencies": { - "@brew.new/sdk": "git+https://github.com/GetBrew/typescript-sdk.git#1f9f0af51c81b23541c9cee303e027ad5a263bc3", + "@brew.new/sdk": "git+https://github.com/GetBrew/typescript-sdk.git#f7608fa1d7771b9d023dea5bfc123b983d925408", "commander": "^14.0.0" }, "devDependencies": { diff --git a/src/commands/contacts/import-summary.ts b/src/commands/contacts/import-summary.ts index 2112d5f..6351b47 100644 --- a/src/commands/contacts/import-summary.ts +++ b/src/commands/contacts/import-summary.ts @@ -21,12 +21,16 @@ export function renderImportResult( ...(summary.skipped > 0 ? [`${summary.skipped} skipped`] : []), ].join(', ') const name = fileName ? `${fileName} ` : '' - const lines = [`Imported ${name}as ${result.importId}: ${counts}.`] + const rowNoun = summary.total === 1 ? 'row' : 'rows' + const lines = [ + `Imported ${name}as ${result.importId}: ${counts} (${summary.total} ${rowNoun} submitted).`, + ] if (result.errors.length > 0) { lines.push( '', 'FAILED ROWS', limitedTable(result.errors, [ + { key: 'row', header: 'ROW' }, { key: 'email', header: 'EMAIL' }, { key: 'code', header: 'CODE' }, { key: 'field', header: 'FIELD' }, @@ -39,6 +43,7 @@ export function renderImportResult( '', 'WARNINGS', limitedTable(result.warnings, [ + { key: 'row', header: 'ROW' }, { key: 'email', header: 'EMAIL' }, { key: 'code', header: 'CODE' }, { key: 'field', header: 'FIELD' }, @@ -81,6 +86,6 @@ function limitedTable( const table = renderTable(shown, columns) const hidden = rows.length - shown.length return hidden > 0 - ? `${table}\n… ${hidden} more; --json prints every row.` + ? `${table}\n… ${hidden} more; --json prints the full response.` : table } diff --git a/src/generated/openapi-types.ts b/src/generated/openapi-types.ts index c964bd4..57b2455 100644 --- a/src/generated/openapi-types.ts +++ b/src/generated/openapi-types.ts @@ -3448,7 +3448,7 @@ export interface components { }; ApiWarning: { /** @enum {string} */ - code: "DRAFT_SAVED_NOT_LIVE" | "TRIGGER_EVENT_NOT_RECEIVED" | "ENFORCEMENT_LOOSENED_WHILE_PUBLISHED" | "REQUIRED_FIELD_SATISFIED_BY_FALLBACK" | "RESUBSCRIBE_SKIPPED" | "CORE_FIELD_IGNORED" | "DATE_ORDER_ASSUMED" | "CSV_COLUMN_IGNORED" | "RECIPIENTS_EXCLUDED" | "CONSENT_RECORD_MISSING"; + code: "DRAFT_SAVED_NOT_LIVE" | "TRIGGER_EVENT_NOT_RECEIVED" | "ENFORCEMENT_LOOSENED_WHILE_PUBLISHED" | "REQUIRED_FIELD_SATISFIED_BY_FALLBACK" | "RESUBSCRIBE_SKIPPED" | "CORE_FIELD_IGNORED" | "DATE_ORDER_ASSUMED" | "CSV_ROW_SKIPPED" | "CSV_COLUMN_IGNORED" | "RECIPIENTS_EXCLUDED" | "CONSENT_RECORD_MISSING"; message: string; field?: string; }; @@ -7121,6 +7121,8 @@ export interface components { fieldsCreated: string[]; errors: { email: string; + /** @description The 1-based row that failed: `contacts[row - 1]`, or the CSV data row (header excluded). */ + row?: number; code: string; field?: string; message: string; @@ -7128,6 +7130,8 @@ export interface components { warnings: { code: string; email?: string; + /** @description The 1-based row it is about (CSV: data row). */ + row?: number; field?: string; message: string; from?: string; @@ -7208,6 +7212,8 @@ export interface components { warnings: { code: string; email?: string; + /** @description The 1-based row it is about (CSV: data row). */ + row?: number; field?: string; message: string; from?: string; @@ -7667,12 +7673,16 @@ export interface components { inserted: number; updated: number; failed: number; - /** @description CSV rows left out for a missing or invalid email, or merged into a later row with the same address. Always 0 for a JSON import. */ + /** @description CSV rows omitted for a missing/invalid email or merged into a later duplicate. Up to 100 `CSV_ROW_SKIPPED` warnings name each `row`; a final warning counts further skipped rows. Always 0 for JSON. */ skipped: number; + /** @description Rows submitted (JSON contacts, or CSV data rows). Always `inserted + updated + failed + skipped`. */ + total: number; }; fieldsCreated: string[]; errors: { email: string; + /** @description The 1-based row that failed: `contacts[row - 1]`, or the CSV data row (header excluded). */ + row?: number; code: string; field?: string; message: string; @@ -7680,6 +7690,8 @@ export interface components { warnings: { code: string; email?: string; + /** @description The 1-based row it is about (CSV: data row). */ + row?: number; field?: string; message: string; from?: string; @@ -22104,7 +22116,8 @@ export interface operations { * "inserted": 2, * "updated": 0, * "failed": 0, - * "skipped": 0 + * "skipped": 0, + * "total": 2 * }, * "fieldsCreated": [], * "errors": [], @@ -22135,18 +22148,32 @@ export interface operations { * "inserted": 1, * "updated": 0, * "failed": 1, - * "skipped": 2 + * "skipped": 2, + * "total": 4 * }, * "fieldsCreated": [], * "errors": [ * { * "email": "john@example.com", + * "row": 2, * "code": "FIELD_TYPE_MISMATCH", * "field": "customFields.plan", * "message": "Field 'customFields.plan' is type 'number' but received string '$49'." * } * ], - * "warnings": [], + * "warnings": [ + * { + * "code": "CSV_ROW_SKIPPED", + * "row": 3, + * "message": "Row 3 has no email address, so it was skipped." + * }, + * { + * "code": "CSV_ROW_SKIPPED", + * "row": 4, + * "email": "not-an-email", + * "message": "Row 4 holds 'not-an-email', which is not a valid email address, so it was skipped." + * } + * ], * "importId": "imp_01JABCDEF0123456789XYZ" * } */ @@ -22346,7 +22373,8 @@ export interface operations { * "inserted": 2, * "updated": 0, * "failed": 0, - * "skipped": 0 + * "skipped": 0, + * "total": 2 * }, * "fieldsCreated": [ * "plan" @@ -22379,12 +22407,14 @@ export interface operations { * "inserted": 1, * "updated": 0, * "failed": 1, - * "skipped": 0 + * "skipped": 0, + * "total": 2 * }, * "fieldsCreated": [], * "errors": [ * { * "email": "not-an-email", + * "row": 2, * "code": "INVALID_EMAIL", * "message": "'not-an-email' is not a valid email address." * } diff --git a/tests/commands/contacts-import-summary.test.ts b/tests/commands/contacts-import-summary.test.ts index bd81b22..58c54e3 100644 --- a/tests/commands/contacts-import-summary.test.ts +++ b/tests/commands/contacts-import-summary.test.ts @@ -9,11 +9,12 @@ function result( overrides: Partial = {} ): ImportContactsResponse { return { - summary: { inserted: 2, updated: 0, failed: 1, skipped: 3 }, + summary: { inserted: 2, updated: 0, failed: 1, skipped: 3, total: 6 }, fieldsCreated: [], errors: [ { email: 'grace@brewmail.dev', + row: 5, code: 'FIELD_TYPE_MISMATCH', field: 'customFields.plan', message: "Field 'customFields.plan' is type 'number'.", @@ -32,24 +33,25 @@ function result( } describe('renderImportResult (contacts import / import-csv on a terminal)', () => { - it('renders API counts, failed addresses and warning fields without requiring row indexes', () => { + it('renders submitted totals, failed row positions and warning fields', () => { const text = renderImportResult(result(), 'customers.csv') expect(text.split('\n')[0]).toBe( - 'Imported customers.csv as imp_123: 2 added, 0 updated, 1 failed, 3 skipped.' + 'Imported customers.csv as imp_123: 2 added, 0 updated, 1 failed, 3 skipped (6 rows submitted).' ) expect(text).toContain('FAILED ROWS') expect(text).toMatch( - /grace@brewmail\.dev\s+FIELD_TYPE_MISMATCH\s+customFields.plan/ + /5\s+grace@brewmail\.dev\s+FIELD_TYPE_MISMATCH\s+customFields.plan/ ) expect(text).toMatch( /CSV_COLUMN_IGNORED\s+Created At\s+Created At is managed/ ) + expect(text).not.toContain('undefined') }) it('omits empty sections and a zero skipped count', () => { const text = renderImportResult( result({ - summary: { inserted: 1, updated: 0, failed: 0, skipped: 0 }, + summary: { inserted: 1, updated: 0, failed: 0, skipped: 0, total: 1 }, errors: [], warnings: [], fieldsCreated: ['plan'], @@ -59,7 +61,7 @@ describe('renderImportResult (contacts import / import-csv on a terminal)', () = ) expect(text).toBe( [ - 'Imported as imp_123: 1 added, 0 updated, 0 failed.', + 'Imported as imp_123: 1 added, 0 updated, 0 failed (1 row submitted).', '', 'New custom fields: plan.', '', @@ -74,11 +76,51 @@ describe('renderImportResult (contacts import / import-csv on a terminal)', () = code: 'INVALID_EMAIL', message: 'Invalid.', })) - const text = renderImportResult(result({ errors, warnings: [] }), 'a.json') - expect(text).toContain('… 5 more; --json prints every row.') + const text = renderImportResult( + result({ + summary: { inserted: 0, updated: 0, failed: 25, skipped: 0, total: 25 }, + errors, + warnings: [], + }), + 'a.json' + ) + expect(text).toContain('… 5 more; --json prints the full response.') expect(text).not.toContain(`x${MAX_TERMINAL_ROWS}@brewmail.dev`) }) + it('caps skipped-row details without promising every skipped row in JSON', () => { + const text = renderImportResult( + result({ + summary: { + inserted: 1, + updated: 0, + failed: 0, + skipped: 150, + total: 151, + }, + errors: [], + warnings: [ + ...Array.from({ length: 100 }, (_, index) => ({ + row: index + 1, + code: 'CSV_ROW_SKIPPED', + message: `Row ${index + 1} has no email address, so it was skipped.`, + })), + { + code: 'CSV_ROW_SKIPPED', + message: + '50 more rows were skipped; only the first 100 are listed.', + }, + ], + }), + undefined + ) + expect(text).toMatch(/2\s+CSV_ROW_SKIPPED\s+Row 2 has no email address/) + expect(text).toContain('150 skipped (151 rows submitted).') + expect(text).toContain('… 81 more; --json prints the full response.') + expect(text).not.toContain('every row') + expect(text).not.toContain('undefined') + }) + it('shows completed deliverability outcomes and failed saves on a terminal', () => { const text = renderImportResult( result({ diff --git a/tests/commands/contacts-import-validation-output.test.ts b/tests/commands/contacts-import-validation-output.test.ts index f4e3fcb..9817ed5 100644 --- a/tests/commands/contacts-import-validation-output.test.ts +++ b/tests/commands/contacts-import-validation-output.test.ts @@ -8,7 +8,7 @@ describe('completed import validation output', () => { '%s shows paid check outcomes on a terminal and preserves JSON', async (command) => { const response = { - summary: { inserted: 1, updated: 0, failed: 0, skipped: 0 }, + summary: { inserted: 1, updated: 0, failed: 0, skipped: 0, total: 1 }, fieldsCreated: [], errors: [], warnings: [], diff --git a/tests/commands/contacts.test.ts b/tests/commands/contacts.test.ts index 83ca7f2..0af98a4 100644 --- a/tests/commands/contacts.test.ts +++ b/tests/commands/contacts.test.ts @@ -591,7 +591,7 @@ describe('contacts import', () => { http.post(IMPORT_URL, async ({ request }) => { body = await request.json() return HttpResponse.json({ - summary: { inserted: 1, updated: 0, failed: 0, skipped: 0 }, + summary: { inserted: 1, updated: 0, failed: 0, skipped: 0, total: 1 }, fieldsCreated: [], errors: [], warnings: [], From 5f611379b6d0e84d2e52aa23d50585c8ed7703a9 Mon Sep 17 00:00:00 2001 From: Thomas Park Date: Wed, 21 Oct 2026 06:12:51 +0000 Subject: [PATCH 12/16] Refresh the reviewed SDK import pin after main integration --- RELEASING.md | 2 +- bun.lock | 4 ++-- package.json | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/RELEASING.md b/RELEASING.md index 5c36275..9a1ce7d 100644 --- a/RELEASING.md +++ b/RELEASING.md @@ -27,7 +27,7 @@ lockfile checksums must never be invented. The canvas tests must preserve The new `contacts import` command requires `contacts.import`, which the published SDK 13.0.0 does not expose. This branch pins reviewed SDK source -commit `f7608fa1d7771b9d023dea5bfc123b983d925408` for coordinated PR validation. +commit `5c1b2f7aa56e14d458d1e6c7c4ad2bd3a4d53b72` for coordinated PR validation. It preserves SDK 13.0.0's features and version while adding the import method. A fresh frozen install must build that source dependency without relying on diff --git a/bun.lock b/bun.lock index c4a1ef3..8e3babc 100644 --- a/bun.lock +++ b/bun.lock @@ -5,7 +5,7 @@ "": { "name": "@brew.new/cli", "dependencies": { - "@brew.new/sdk": "git+https://github.com/GetBrew/typescript-sdk.git#f7608fa1d7771b9d023dea5bfc123b983d925408", + "@brew.new/sdk": "git+https://github.com/GetBrew/typescript-sdk.git#5c1b2f7aa56e14d458d1e6c7c4ad2bd3a4d53b72", "commander": "^14.0.0", }, "devDependencies": { @@ -46,7 +46,7 @@ "@biomejs/cli-win32-x64": ["@biomejs/cli-win32-x64@2.5.7", "", { "os": "win32", "cpu": "x64" }, "sha512-V+0wu/nrj2S+MhP4EQ0uHNolP0IALEsz45pg0WoKkHfDeh0+ItHwP/p7bX5RPoMOl9NkpHYWdYPhIcy2mACHvQ=="], - "@brew.new/sdk": ["@brew.new/sdk@github:GetBrew/typescript-sdk#f7608fa", { "peerDependencies": { "react": ">=18.2.0 <20" }, "optionalPeers": ["react"] }, "GetBrew-typescript-sdk-f7608fa", "sha512-cfc+iV7tYD+j3hoxf9ZXf4caSuhWO7D3VSPN/SvXaadhZIrIfPwECaMap/h/80N0WhavvDymp0OgV58UrAtrDg=="], + "@brew.new/sdk": ["@brew.new/sdk@github:GetBrew/typescript-sdk#5c1b2f7", { "peerDependencies": { "react": ">=18.2.0 <20" }, "optionalPeers": ["react"] }, "GetBrew-typescript-sdk-5c1b2f7", "sha512-O51R/9Ts1poo+wjXHEDvRWHdoh/Ra5o8bR3yoW1xhrEUvRSCG4fvGMGmMUKc2FSOJRQA+dZiXhEb0xZkA5DJTQ=="], "@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.27.7", "", { "os": "aix", "cpu": "ppc64" }, "sha512-EKX3Qwmhz1eMdEJokhALr0YiD0lhQNwDqkPYyPhiSwKrh7/4KRjQc04sZ8db+5DVVnZ1LmbNDI1uAMPEUBnQPg=="], diff --git a/package.json b/package.json index 7bb5ad3..56341fa 100644 --- a/package.json +++ b/package.json @@ -53,7 +53,7 @@ "smoke:signals": "bun run scripts/signal-smoke.ts" }, "dependencies": { - "@brew.new/sdk": "git+https://github.com/GetBrew/typescript-sdk.git#f7608fa1d7771b9d023dea5bfc123b983d925408", + "@brew.new/sdk": "git+https://github.com/GetBrew/typescript-sdk.git#5c1b2f7aa56e14d458d1e6c7c4ad2bd3a4d53b72", "commander": "^14.0.0" }, "devDependencies": { From 8ce11ee2124aa7f981bfcadcabbe5f1d196ca145 Mon Sep 17 00:00:00 2001 From: Thomas Park Date: Wed, 21 Oct 2026 06:28:08 +0000 Subject: [PATCH 13/16] Clarify CSV validation billing across CLI help and contracts --- RELEASING.md | 2 +- bun.lock | 4 ++-- docs/commands/README.md | 6 +++--- openapi/public-api-v1.yaml | 2 +- package.json | 2 +- src/commands/contacts/import-csv.ts | 4 ++-- src/generated/openapi-types.ts | 2 +- 7 files changed, 11 insertions(+), 11 deletions(-) diff --git a/RELEASING.md b/RELEASING.md index 9a1ce7d..6b2b22f 100644 --- a/RELEASING.md +++ b/RELEASING.md @@ -27,7 +27,7 @@ lockfile checksums must never be invented. The canvas tests must preserve The new `contacts import` command requires `contacts.import`, which the published SDK 13.0.0 does not expose. This branch pins reviewed SDK source -commit `5c1b2f7aa56e14d458d1e6c7c4ad2bd3a4d53b72` for coordinated PR validation. +commit `7ca8cac2d70dd53244a343ee123b75ed9520b988` for coordinated PR validation. It preserves SDK 13.0.0's features and version while adding the import method. A fresh frozen install must build that source dependency without relying on diff --git a/bun.lock b/bun.lock index 8e3babc..880c13f 100644 --- a/bun.lock +++ b/bun.lock @@ -5,7 +5,7 @@ "": { "name": "@brew.new/cli", "dependencies": { - "@brew.new/sdk": "git+https://github.com/GetBrew/typescript-sdk.git#5c1b2f7aa56e14d458d1e6c7c4ad2bd3a4d53b72", + "@brew.new/sdk": "git+https://github.com/GetBrew/typescript-sdk.git#7ca8cac2d70dd53244a343ee123b75ed9520b988", "commander": "^14.0.0", }, "devDependencies": { @@ -46,7 +46,7 @@ "@biomejs/cli-win32-x64": ["@biomejs/cli-win32-x64@2.5.7", "", { "os": "win32", "cpu": "x64" }, "sha512-V+0wu/nrj2S+MhP4EQ0uHNolP0IALEsz45pg0WoKkHfDeh0+ItHwP/p7bX5RPoMOl9NkpHYWdYPhIcy2mACHvQ=="], - "@brew.new/sdk": ["@brew.new/sdk@github:GetBrew/typescript-sdk#5c1b2f7", { "peerDependencies": { "react": ">=18.2.0 <20" }, "optionalPeers": ["react"] }, "GetBrew-typescript-sdk-5c1b2f7", "sha512-O51R/9Ts1poo+wjXHEDvRWHdoh/Ra5o8bR3yoW1xhrEUvRSCG4fvGMGmMUKc2FSOJRQA+dZiXhEb0xZkA5DJTQ=="], + "@brew.new/sdk": ["@brew.new/sdk@github:GetBrew/typescript-sdk#7ca8cac", { "peerDependencies": { "react": ">=18.2.0 <20" }, "optionalPeers": ["react"] }, "GetBrew-typescript-sdk-7ca8cac", "sha512-/RjBYoZIbQgf3E9m0iG77mqssFhxFBTinpa/OO8AmY5ZtyUlic2tYDUfNCNT/seg1GRgGoTzOnxQ//6SEThr1A=="], "@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.27.7", "", { "os": "aix", "cpu": "ppc64" }, "sha512-EKX3Qwmhz1eMdEJokhALr0YiD0lhQNwDqkPYyPhiSwKrh7/4KRjQc04sZ8db+5DVVnZ1LmbNDI1uAMPEUBnQPg=="], diff --git a/docs/commands/README.md b/docs/commands/README.md index e1a0cc3..14d0297 100644 --- a/docs/commands/README.md +++ b/docs/commands/README.md @@ -41,7 +41,7 @@ a `confirmCommand` otherwise, `--yes` to proceed). | `brew-cli contacts delete-many` | destructive | `POST /v1/contacts/batch-delete` | Delete up to 1000 contacts by email | | `brew-cli contacts validate` | write ($) | `POST /v1/contacts/validate` | Batch-validate email deliverability (no contacts created) | | `brew-cli contacts import` | write | `POST /v1/contacts/import` | Bulk-import up to 1000 contacts from a JSON file or stdin as one Import History batch (free; --validate costs 2 credits per address) | -| `brew-cli contacts import-csv` | write | `POST /v1/contacts/import-csv` | Bulk-import contacts from a CSV file or stdin as one Import History batch (free; --validate costs 2 credits per row) | +| `brew-cli contacts import-csv` | write | `POST /v1/contacts/import-csv` | Bulk-import contacts from a CSV file or stdin as one Import History batch (free; validation charged separately) | | `brew-cli fields list` | read | `GET /v1/fields` | List custom contact fields | | `brew-cli fields get` | read | `GET /v1/fields/{fieldName}` | Fetch one contact field definition by name — the bare row | | `brew-cli fields create` | write | `POST /v1/fields` | Create a custom contact field | @@ -473,7 +473,7 @@ cat contacts.json | brew-cli contacts import --file - --file-name crm-export.jso ### brew-cli contacts import-csv -Bulk-import contacts from a CSV file or stdin as one Import History batch (free; --validate costs 2 credits per row) +Bulk-import contacts from a CSV file or stdin as one Import History batch (free; validation charged separately) - Route: `POST /v1/contacts/import-csv` - Class: write @@ -482,7 +482,7 @@ Bulk-import contacts from a CSV file or stdin as one Import History batch (free; - `--file-name ` — Name the batch in Brew's Import History (default: the --file name) - `--mapping ` — Column mapping csvColumn=fieldName, repeatable - `--date-order ` — month_first or day_first: how to read dates like 03/04/2026 (a day over 12 in the column wins; default month-first, with a DATE_ORDER_ASSUMED warning) -- `--validate` — Deliverability-check every imported address (2 credits per CSV row) +- `--validate` — Deliverability checks: up to 100 submitted CSV data rows cost 2 credits/row inline; larger files use background checks when needed, charged per address - `--consent-source ` — Stamp a marketing consent record on every row: api, form or import (sets the source of a record given in --input) - `--input ` — Full JSON request body, or - to read stdin (flags override it) - `--idempotency-key ` — Idempotency-Key for safe retries (auto-generated otherwise) diff --git a/openapi/public-api-v1.yaml b/openapi/public-api-v1.yaml index 6f2ce8d..8c4f6b4 100644 --- a/openapi/public-api-v1.yaml +++ b/openapi/public-api-v1.yaml @@ -14143,7 +14143,7 @@ components: type: string validate: type: boolean - description: Optional deliverability check on ingestion. When true, each imported address is validated with the provider and the verdict is saved to the contact’s `validationStatus`. Metered at 2 credits per CSV data row (rows skipped for a missing or invalid email, and repeats of an address, count), charged on success. Imports above the inline cap (100 rows) write first and validate as a background job, returning a `validationJobId`. + description: Optional deliverability check on ingestion. When true, each imported address is validated with the provider and the verdict is saved to the contact’s `validationStatus`. Imports of at most 100 submitted CSV data rows validate inline, metered at 2 credits per submitted row on a successful response, including skipped rows and repeated addresses. Larger imports write first and use a background validation workflow billed per address as it runs; `validationJobId` is returned when a job is queued. consent: type: object properties: diff --git a/package.json b/package.json index 56341fa..4450f5f 100644 --- a/package.json +++ b/package.json @@ -53,7 +53,7 @@ "smoke:signals": "bun run scripts/signal-smoke.ts" }, "dependencies": { - "@brew.new/sdk": "git+https://github.com/GetBrew/typescript-sdk.git#5c1b2f7aa56e14d458d1e6c7c4ad2bd3a4d53b72", + "@brew.new/sdk": "git+https://github.com/GetBrew/typescript-sdk.git#7ca8cac2d70dd53244a343ee123b75ed9520b988", "commander": "^14.0.0" }, "devDependencies": { diff --git a/src/commands/contacts/import-csv.ts b/src/commands/contacts/import-csv.ts index 28d417b..3d03343 100644 --- a/src/commands/contacts/import-csv.ts +++ b/src/commands/contacts/import-csv.ts @@ -20,7 +20,7 @@ type ImportCsvContactsInput = Parameters[0] export const contactsImportCsvCommand = defineCommand({ path: ['contacts', 'import-csv'], summary: - 'Bulk-import contacts from a CSV file or stdin as one Import History batch (free; --validate costs 2 credits per row)', + 'Bulk-import contacts from a CSV file or stdin as one Import History batch (free; validation charged separately)', sdkMethod: 'contacts.importCsv', route: { method: 'POST', path: '/v1/contacts/import-csv' }, commandClass: 'write', @@ -46,7 +46,7 @@ export const contactsImportCsvCommand = defineCommand({ { flag: '--validate', summary: - 'Deliverability-check every imported address (2 credits per CSV row)', + 'Deliverability checks: up to 100 submitted CSV data rows cost 2 credits/row inline; larger files use background checks when needed, charged per address', }, { flag: '--consent-source ', diff --git a/src/generated/openapi-types.ts b/src/generated/openapi-types.ts index 57b2455..ea9aebd 100644 --- a/src/generated/openapi-types.ts +++ b/src/generated/openapi-types.ts @@ -7719,7 +7719,7 @@ export interface components { mapping?: { [key: string]: string; }; - /** @description Optional deliverability check on ingestion. When true, each imported address is validated with the provider and the verdict is saved to the contact’s `validationStatus`. Metered at 2 credits per CSV data row (rows skipped for a missing or invalid email, and repeats of an address, count), charged on success. Imports above the inline cap (100 rows) write first and validate as a background job, returning a `validationJobId`. */ + /** @description Optional deliverability check on ingestion. When true, each imported address is validated with the provider and the verdict is saved to the contact’s `validationStatus`. Imports of at most 100 submitted CSV data rows validate inline, metered at 2 credits per submitted row on a successful response, including skipped rows and repeated addresses. Larger imports write first and use a background validation workflow billed per address as it runs; `validationJobId` is returned when a job is queued. */ validate?: boolean; /** @description Consent record stamped on every imported row (typically `{ "source": "import" }`). */ consent?: { From db747ef51aba705efc47d44cba2b959ca6a55a68 Mon Sep 17 00:00:00 2001 From: thedogwiththedataonit Date: Wed, 21 Oct 2026 19:42:46 +0900 Subject: [PATCH 14/16] chore(openapi): JSON import validate states its submitted-contact metering Re-vendored from brew-v2#2194: imports of at most 100 submitted contacts validate inline at 2 credits per submitted contact (failed rows count); larger imports bill per address in the background workflow, and validationJobId comes back only when a job is queued. Co-Authored-By: Claude Opus 5.5 --- openapi/public-api-v1.yaml | 2 +- src/generated/openapi-types.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/openapi/public-api-v1.yaml b/openapi/public-api-v1.yaml index 8c4f6b4..0f092c7 100644 --- a/openapi/public-api-v1.yaml +++ b/openapi/public-api-v1.yaml @@ -14234,7 +14234,7 @@ components: maxItems: 1000 validate: type: boolean - description: Optional deliverability check on ingestion. When true, each address is validated with the provider (2 credits per address, charged on success) and the verdict is saved to the contact’s `validationStatus`. Submissions above the inline cap (100 addresses) upsert first and validate as a background job, returning a `validationJobId`. + description: Optional deliverability check on ingestion. When true, each imported address is validated with the provider and the verdict is saved to the contact’s `validationStatus`. Imports of at most 100 submitted contacts validate inline, metered at 2 credits per submitted contact on a successful response, including rows that fail. Larger imports write first and use a background validation workflow billed per address as it runs; `validationJobId` is returned when a job is queued. consent: type: object properties: diff --git a/src/generated/openapi-types.ts b/src/generated/openapi-types.ts index ea9aebd..f229d7a 100644 --- a/src/generated/openapi-types.ts +++ b/src/generated/openapi-types.ts @@ -7769,7 +7769,7 @@ export interface components { evidence?: string; }; }[]; - /** @description Optional deliverability check on ingestion. When true, each address is validated with the provider (2 credits per address, charged on success) and the verdict is saved to the contact’s `validationStatus`. Submissions above the inline cap (100 addresses) upsert first and validate as a background job, returning a `validationJobId`. */ + /** @description Optional deliverability check on ingestion. When true, each imported address is validated with the provider and the verdict is saved to the contact’s `validationStatus`. Imports of at most 100 submitted contacts validate inline, metered at 2 credits per submitted contact on a successful response, including rows that fail. Larger imports write first and use a background validation workflow billed per address as it runs; `validationJobId` is returned when a job is queued. */ validate?: boolean; /** @description Default consent record for every row that does not carry its own `consent` (typically `{ "source": "import" }`). */ consent?: { From a4d0b9f78a532ac07b4bced3d1a95a1ee2e14bf9 Mon Sep 17 00:00:00 2001 From: thedogwiththedataonit Date: Wed, 21 Oct 2026 19:52:47 +0900 Subject: [PATCH 15/16] chore(openapi): CSV row numbers skip the header and blank lines Re-vendored from brew-v2#2194: a CSV row is the parsed data row, so the header and blank lines are not counted. FIELD_TYPE_COERCED is reported once per field, and a validation failure after an import names the saved importId. Co-Authored-By: Claude Opus 5.5 --- openapi/public-api-v1.yaml | 10 +++++----- src/generated/openapi-types.ts | 10 +++++----- 2 files changed, 10 insertions(+), 10 deletions(-) diff --git a/openapi/public-api-v1.yaml b/openapi/public-api-v1.yaml index 0f092c7..0a815de 100644 --- a/openapi/public-api-v1.yaml +++ b/openapi/public-api-v1.yaml @@ -12747,7 +12747,7 @@ components: row: type: integer exclusiveMinimum: 0 - description: "The 1-based row that failed: `contacts[row - 1]`, or the CSV data row (header excluded)." + description: "The 1-based row that failed: `contacts[row - 1]`, or the CSV data row (header and blank lines not counted)." code: type: string field: @@ -12773,7 +12773,7 @@ components: row: type: integer exclusiveMinimum: 0 - description: "The 1-based row it is about (CSV: data row)." + description: "The 1-based row it is about (CSV: data row; header and blank lines not counted)." field: type: string minLength: 1 @@ -12968,7 +12968,7 @@ components: row: type: integer exclusiveMinimum: 0 - description: "The 1-based row it is about (CSV: data row)." + description: "The 1-based row it is about (CSV: data row; header and blank lines not counted)." field: type: string minLength: 1 @@ -14031,7 +14031,7 @@ components: row: type: integer exclusiveMinimum: 0 - description: "The 1-based row that failed: `contacts[row - 1]`, or the CSV data row (header excluded)." + description: "The 1-based row that failed: `contacts[row - 1]`, or the CSV data row (header and blank lines not counted)." code: type: string field: @@ -14057,7 +14057,7 @@ components: row: type: integer exclusiveMinimum: 0 - description: "The 1-based row it is about (CSV: data row)." + description: "The 1-based row it is about (CSV: data row; header and blank lines not counted)." field: type: string minLength: 1 diff --git a/src/generated/openapi-types.ts b/src/generated/openapi-types.ts index f229d7a..8403a8b 100644 --- a/src/generated/openapi-types.ts +++ b/src/generated/openapi-types.ts @@ -7121,7 +7121,7 @@ export interface components { fieldsCreated: string[]; errors: { email: string; - /** @description The 1-based row that failed: `contacts[row - 1]`, or the CSV data row (header excluded). */ + /** @description The 1-based row that failed: `contacts[row - 1]`, or the CSV data row (header and blank lines not counted). */ row?: number; code: string; field?: string; @@ -7130,7 +7130,7 @@ export interface components { warnings: { code: string; email?: string; - /** @description The 1-based row it is about (CSV: data row). */ + /** @description The 1-based row it is about (CSV: data row; header and blank lines not counted). */ row?: number; field?: string; message: string; @@ -7212,7 +7212,7 @@ export interface components { warnings: { code: string; email?: string; - /** @description The 1-based row it is about (CSV: data row). */ + /** @description The 1-based row it is about (CSV: data row; header and blank lines not counted). */ row?: number; field?: string; message: string; @@ -7681,7 +7681,7 @@ export interface components { fieldsCreated: string[]; errors: { email: string; - /** @description The 1-based row that failed: `contacts[row - 1]`, or the CSV data row (header excluded). */ + /** @description The 1-based row that failed: `contacts[row - 1]`, or the CSV data row (header and blank lines not counted). */ row?: number; code: string; field?: string; @@ -7690,7 +7690,7 @@ export interface components { warnings: { code: string; email?: string; - /** @description The 1-based row it is about (CSV: data row). */ + /** @description The 1-based row it is about (CSV: data row; header and blank lines not counted). */ row?: number; field?: string; message: string; From 613cc4cce67f82f03273bb3e0f5bab430218b6fd Mon Sep 17 00:00:00 2001 From: thedogwiththedataonit Date: Sun, 25 Oct 2026 03:30:10 +0900 Subject: [PATCH 16/16] build(deps): depend on the published @brew.new/sdk ^13.3.0 The SDK release adds contacts.import and the shared import response, so the temporary source pin and its release gate are gone; bun.lock resolves the registry artifact. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 7 ++----- RELEASING.md | 17 +++++------------ bun.lock | 4 ++-- package.json | 2 +- 4 files changed, 10 insertions(+), 20 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f5108f2..641b404 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,11 +2,8 @@ ## Unreleased -These import changes require an SDK release exposing `contacts.import`. -The published SDK 13.0.0 baseline supports the existing released commands, -but does not provide that method. This branch uses a reviewed immutable SDK -source commit for coordinated validation. Follow the contact import SDK gate -in `RELEASING.md` before release. +These import changes require `@brew.new/sdk` 13.3.0 or newer, which adds +`contacts.import` and the shared import response. ### Added diff --git a/RELEASING.md b/RELEASING.md index 6b2b22f..15f8170 100644 --- a/RELEASING.md +++ b/RELEASING.md @@ -14,7 +14,7 @@ that response. SDK 13.0.0 preserves the revocation JSON response and is the minimum supported SDK release for these commands. CLI 0.17.0 consumes that published registry -artifact. Contact-import coordination uses the separate gate below. +artifact. For future upgrades, release the SDK first, then update the dependency and regenerate `bun.lock` from its actual published artifact. Run frozen @@ -23,18 +23,11 @@ CLI. A locally packed SDK must not hide dependency failures, and future lockfile checksums must never be invented. The canvas tests must preserve `{ revoked: true }`, the `--yes` gate, and one-attempt create/refresh requests. -### Contact Import SDK Gate +### Contact Import SDK Minimum -The new `contacts import` command requires `contacts.import`, which the -published SDK 13.0.0 does not expose. This branch pins reviewed SDK source -commit `7ca8cac2d70dd53244a343ee123b75ed9520b988` for coordinated PR validation. -It preserves SDK 13.0.0's features and version while adding the import method. - -A fresh frozen install must build that source dependency without relying on -a locally packed SDK. Do not release the CLI with this temporary source pin. -Deploy the API contract and release the SDK first, then replace the pin with -the actual published SDK version and regenerate `bun.lock` before releasing -the CLI. +`contacts import` and the `contacts import-csv` naming and row results use +`contacts.import` and the import response from SDK 13.3.0, the minimum this +CLI depends on (`^13.3.0`). ```bash # on a clean main with CI green diff --git a/bun.lock b/bun.lock index 880c13f..cf75c69 100644 --- a/bun.lock +++ b/bun.lock @@ -5,7 +5,7 @@ "": { "name": "@brew.new/cli", "dependencies": { - "@brew.new/sdk": "git+https://github.com/GetBrew/typescript-sdk.git#7ca8cac2d70dd53244a343ee123b75ed9520b988", + "@brew.new/sdk": "^13.3.0", "commander": "^14.0.0", }, "devDependencies": { @@ -46,7 +46,7 @@ "@biomejs/cli-win32-x64": ["@biomejs/cli-win32-x64@2.5.7", "", { "os": "win32", "cpu": "x64" }, "sha512-V+0wu/nrj2S+MhP4EQ0uHNolP0IALEsz45pg0WoKkHfDeh0+ItHwP/p7bX5RPoMOl9NkpHYWdYPhIcy2mACHvQ=="], - "@brew.new/sdk": ["@brew.new/sdk@github:GetBrew/typescript-sdk#7ca8cac", { "peerDependencies": { "react": ">=18.2.0 <20" }, "optionalPeers": ["react"] }, "GetBrew-typescript-sdk-7ca8cac", "sha512-/RjBYoZIbQgf3E9m0iG77mqssFhxFBTinpa/OO8AmY5ZtyUlic2tYDUfNCNT/seg1GRgGoTzOnxQ//6SEThr1A=="], + "@brew.new/sdk": ["@brew.new/sdk@13.3.0", "", { "peerDependencies": { "react": ">=18.2.0 <20" }, "optionalPeers": ["react"] }, "sha512-GpdjjF148y9vN4I9mKhA9ndfvVW2Wse1AOfdwfG2XMNMpUKXTdd/3Lb3p769xZz+Fy5bdbMMqklwE/Iu/LwyWg=="], "@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.27.7", "", { "os": "aix", "cpu": "ppc64" }, "sha512-EKX3Qwmhz1eMdEJokhALr0YiD0lhQNwDqkPYyPhiSwKrh7/4KRjQc04sZ8db+5DVVnZ1LmbNDI1uAMPEUBnQPg=="], diff --git a/package.json b/package.json index 4450f5f..702815b 100644 --- a/package.json +++ b/package.json @@ -53,7 +53,7 @@ "smoke:signals": "bun run scripts/signal-smoke.ts" }, "dependencies": { - "@brew.new/sdk": "git+https://github.com/GetBrew/typescript-sdk.git#7ca8cac2d70dd53244a343ee123b75ed9520b988", + "@brew.new/sdk": "^13.3.0", "commander": "^14.0.0" }, "devDependencies": {