diff --git a/CHANGELOG.md b/CHANGELOG.md index 641b404..d3496a1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,12 +14,20 @@ These import changes require `@brew.new/sdk` 13.3.0 or newer, which adds - **`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`. A JSON object body + `--consent-source`, and prints the batch's `importId` (none on a `--dry-run`). 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`. + `importId`, except on a `--dry-run`. +- `contacts import` and `contacts import-csv` take `--dry-run` (print what + the import would do and write nothing: no contacts, fields or Import History + batch, no `importId`) and `--on-conflict upsert|skip` (`skip` creates + missing contacts and leaves existing ones unchanged, counted as skipped + with a `CONTACT_EXISTS_SKIPPED` warning). The terminal summary of a dry run + reads "Dry run of — nothing was written: N to add, …" (or "Dry run + — nothing was written: …" without a file name). These flags require the + matching API deployment. - On a terminal, `contacts import` and `contacts import-csv` print the API's submitted total and outcome counts, plus failed rows and warnings with available row, email and field details. `--json` preserves the API envelope. @@ -33,6 +41,12 @@ These import changes require `@brew.new/sdk` 13.3.0 or newer, which adds - `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. + +### Fixed + +- `flows get` takes a `/` slug from `flows list` (one send + stream's flow) and a `--kind` flag for a bare-domain slug. + ## 0.17.0 Requires `@brew.new/sdk` `^13.0.0`. This is a minor release because it adds diff --git a/docs/commands/README.md b/docs/commands/README.md index 14d0297..f2b3193 100644 --- a/docs/commands/README.md +++ b/docs/commands/README.md @@ -463,12 +463,15 @@ Bulk-import up to 1000 contacts from a JSON file or stdin as one Import History - `--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 +- `--dry-run` — Show what the import would do and write nothing (no contacts, fields or Import History batch; --validate is skipped and nothing is charged) +- `--on-conflict ` — upsert (default) updates existing contacts; skip only creates missing ones and leaves existing contacts unchanged (counted as skipped) - `--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 --file contacts.json --dry-run --on-conflict skip ``` ### brew-cli contacts import-csv @@ -484,6 +487,8 @@ Bulk-import contacts from a CSV file or stdin as one Import History batch (free; - `--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 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) +- `--dry-run` — Show what the import would do and write nothing (no contacts, fields or Import History batch; --validate is skipped and nothing is charged) +- `--on-conflict ` — upsert (default) updates existing contacts; skip only creates missing ones and leaves existing contacts unchanged (counted as skipped) - `--input ` — Full JSON request body, or - to read stdin (flags override it) - `--idempotency-key ` — Idempotency-Key for safe retries (auto-generated otherwise) @@ -491,6 +496,8 @@ Bulk-import contacts from a CSV file or stdin as one Import History batch (free; brew-cli contacts import-csv --file contacts.csv cat contacts.csv | brew-cli contacts import-csv --file - --mapping Email=email brew-cli contacts import-csv --file eu-signups.csv --date-order day_first --consent-source import +brew-cli contacts import-csv --file contacts.csv --dry-run +brew-cli contacts import-csv --file contacts.csv --on-conflict skip ``` ### brew-cli fields list @@ -2493,12 +2500,14 @@ Fetch one public email flow by brand domain, with every step (day offset, wait, - Route: `GET /v1/flows/{slug}` - Class: read - SDK: `brew.flows.get(...)` -- Argument `slug` — The brand domain a `flows list` card carries (e.g. brew.new) +- Argument `slug` — The slug a `flows list` card carries: a brand domain (brew.new) or / (brew.new/welcome) - `--include ` — Comma-separated expansions: html (each step’s rendered HTML, best-effort per step) +- `--kind ` — One send stream’s flow for a bare-domain slug (e.g. welcome); a / slug already names it ```bash brew-cli flows get brew.new brew-cli flows get brew.new --include html --json +brew-cli flows get brew.new/welcome ``` ### brew-cli integrations list diff --git a/openapi/public-api-v1.yaml b/openapi/public-api-v1.yaml index 0a815de..70a50a3 100644 --- a/openapi/public-api-v1.yaml +++ b/openapi/public-api-v1.yaml @@ -114,6 +114,55 @@ components: previewText: type: string description: The design's inbox preview line, read directly from the latest version's JSX (its single source of truth) — what a send delivers when no explicit `previewText` override is passed to POST /v1/sends. Detail-only; absent when the design has no . + lang: + type: string + enum: + - en + - de + - fr + - es + - pt + - it + - nl + - pl + - sv + - da + - 'no' + - fi + - cs + - sk + - hu + - ro + - bg + - el + - tr + - ru + - uk + - hr + - sr + - sl + - lt + - lv + - et + - ca + - ar + - he + - fa + - ur + - hi + - bn + - th + - vi + - id + - ms + - tl + - ja + - ko + - zh-Hans + - zh-Hant + - sw + - af + description: The language the email's copy is written in (ISO 639-1, or zh-Hans / zh-Hant), read by Brew's language model after each content change unless a caller set it. Rendered as the HTML document's lang. Detail-only; absent until it has been read. html: type: string versions: @@ -196,6 +245,55 @@ components: previewText: type: string description: The design's inbox preview line, read directly from the latest version's JSX (its single source of truth) — what a send delivers when no explicit `previewText` override is passed to POST /v1/sends. Detail-only; absent when the design has no . + lang: + type: string + enum: + - en + - de + - fr + - es + - pt + - it + - nl + - pl + - sv + - da + - 'no' + - fi + - cs + - sk + - hu + - ro + - bg + - el + - tr + - ru + - uk + - hr + - sr + - sl + - lt + - lv + - et + - ca + - ar + - he + - fa + - ur + - hi + - bn + - th + - vi + - id + - ms + - tl + - ja + - ko + - zh-Hans + - zh-Hant + - sw + - af + description: The language the email's copy is written in (ISO 639-1, or zh-Hans / zh-Hant), read by Brew's language model after each content change unless a caller set it. Rendered as the HTML document's lang. Detail-only; absent until it has been read. html: type: string versions: @@ -2248,6 +2346,7 @@ components: - CORE_FIELD_IGNORED - DATE_ORDER_ASSUMED - CSV_ROW_SKIPPED + - CONTACT_EXISTS_SKIPPED - CSV_COLUMN_IGNORED - RECIPIENTS_EXCLUDED - CONSENT_RECORD_MISSING @@ -3563,6 +3662,14 @@ components: enum: - newsletter - signup + kind: + type: string + enum: + - onboarding + - trial + - events + - product_updates + - promotions category: type: string minLength: 1 @@ -4672,6 +4779,7 @@ components: - send_review_rejected - comment_mention - comment_reply + - integration_attention status: type: string enum: @@ -4899,6 +5007,55 @@ components: previewText: type: string description: The design's inbox preview line, read directly from the latest version's JSX (its single source of truth) — what a send delivers when no explicit `previewText` override is passed to POST /v1/sends. Detail-only; absent when the design has no . + lang: + type: string + enum: + - en + - de + - fr + - es + - pt + - it + - nl + - pl + - sv + - da + - 'no' + - fi + - cs + - sk + - hu + - ro + - bg + - el + - tr + - ru + - uk + - hr + - sr + - sl + - lt + - lv + - et + - ca + - ar + - he + - fa + - ur + - hi + - bn + - th + - vi + - id + - ms + - tl + - ja + - ko + - zh-Hans + - zh-Hant + - sw + - af + description: The language the email's copy is written in (ISO 639-1, or zh-Hans / zh-Hant), read by Brew's language model after each content change unless a caller set it. Rendered as the HTML document's lang. Detail-only; absent until it has been read. html: type: string versions: @@ -5244,6 +5401,55 @@ components: minLength: 1 maxLength: 250 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}`. + lang: + type: string + enum: + - en + - de + - fr + - es + - pt + - it + - nl + - pl + - sv + - da + - 'no' + - fi + - cs + - sk + - hu + - ro + - bg + - el + - tr + - ru + - uk + - hr + - sr + - sl + - lt + - lv + - et + - ca + - ar + - he + - fa + - ur + - hi + - bn + - th + - vi + - id + - ms + - tl + - ja + - ko + - zh-Hans + - zh-Hant + - sw + - af + description: "ISO 639-1 language code of the email's copy (or zh-Hans / zh-Hant). Omit and Brew's language model reads it from the finished body; a value you set is kept and never re-detected. Becomes the HTML document's lang (and dir for right-to-left languages) on every send and export. One of: en, de, fr, es, pt, it, nl, pl, sv, da, no, fi, cs, sk, hu, ro, bg, el, tr, ru, uk, hr, sr, sl, lt, lv, et, ca, ar, he, fa, ur, hi, bn, th, vi, id, ms, tl, ja, ko, zh-Hans, zh-Hant, sw, af." groupId: type: string minLength: 1 @@ -5566,7 +5772,7 @@ components: prompt: type: string minLength: 1 - description: The edit to make, in plain language — e.g. "swap the hero for the spring campaign image and tighten the CTA copy". Scoped edits beat full rewrites. Omit to only patch `title`, `subjectLine` or `groupId` (at least one of the four is required). + description: The edit to make, in plain language — e.g. "swap the hero for the spring campaign image and tighten the CTA copy". Scoped edits beat full rewrites. Omit to only patch `title`, `subjectLine`, `lang` or `groupId` (at least one of the five is required). emailVersionId: type: string minLength: 1 @@ -5589,6 +5795,58 @@ components: minLength: 1 maxLength: 250 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}`. + lang: + type: + - string + - "null" + enum: + - en + - de + - fr + - es + - pt + - it + - nl + - pl + - sv + - da + - 'no' + - fi + - cs + - sk + - hu + - ro + - bg + - el + - tr + - ru + - uk + - hr + - sr + - sl + - lt + - lv + - et + - ca + - ar + - he + - fa + - ur + - hi + - bn + - th + - vi + - id + - ms + - tl + - ja + - ko + - zh-Hans + - zh-Hant + - sw + - af + - null + description: "Set the language of the email (ISO 639-1, or zh-Hans / zh-Hant). Part of the free envelope patch: no AI run, no new version. Once set it is kept: Brew no longer re-detects it after edits. null clears a language you set, and Brew reads it again from the email." groupId: type: - string @@ -14005,7 +14263,7 @@ components: skipped: type: integer minimum: 0 - 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. + description: 'Rows not written and not failed: existing contacts left alone by `onConflict: "skip"` (one `CONTACT_EXISTS_SKIPPED` warning each), and 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).' total: type: integer minimum: 0 @@ -14122,13 +14380,17 @@ 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 a contact filter on `importId` 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. Absent on a dry run. + dryRun: + type: boolean + enum: + - true + description: "Present on a dry run: the counts say what the import would do, and nothing was written." required: - summary - fieldsCreated - errors - warnings - - importId additionalProperties: false ContactsImportCsvRequest: type: object @@ -14181,6 +14443,15 @@ components: 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. + dryRun: + type: boolean + description: "When true, returns what this import would return and writes nothing: no contacts, fields, Import History entry or `importId`; `validate` is skipped and nothing is charged." + onConflict: + type: string + enum: + - upsert + - skip + description: "`upsert` (default) updates existing contacts; `skip` only creates missing ones, leaving each existing contact unchanged (counted in `summary.skipped`, with a `CONTACT_EXISTS_SKIPPED` warning)." required: - csv additionalProperties: false @@ -14266,6 +14537,15 @@ components: 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. + dryRun: + type: boolean + description: "When true, returns what this import would return and writes nothing: no contacts, fields, Import History entry or `importId`; `validate` is skipped and nothing is charged." + onConflict: + type: string + enum: + - upsert + - skip + description: "`upsert` (default) updates existing contacts; `skip` only creates missing ones, leaving each existing contact unchanged (counted in `summary.skipped`, with a `CONTACT_EXISTS_SKIPPED` warning)." required: - contacts additionalProperties: false @@ -16684,6 +16964,14 @@ components: enum: - newsletter - signup + kind: + type: string + enum: + - onboarding + - trial + - events + - product_updates + - promotions category: type: string minLength: 1 @@ -18112,6 +18400,7 @@ components: - send_review_rejected - comment_mention - comment_reply + - integration_attention status: type: string enum: @@ -18776,7 +19065,7 @@ paths: **Use when** you have a brief and want Brew to author on brand. For existing markup use `importEmail`; for a Figma frame use `importFigmaDesign`. - **Input** `prompt`; optional `category` (steers the design treatment only: transactional categories get receipt or reset conventions, while delivery for those flows is a triggered automation on a transactional-purpose domain), `contentUrls`, `referenceEmailId`, `subjectLine` (the design-default inbox subject; `createSend` still takes a per-send `subject`), and `groupId` (`grp_…` or `ungrouped`) or `groupName` (resolve or create), not both; omit to land Ungrouped. Supports `Idempotency-Key`. + **Input** `prompt`; optional `category` (steers the design treatment only: transactional categories get receipt or reset conventions, while delivery for those flows is a triggered automation on a transactional-purpose domain), `contentUrls`, `referenceEmailId`, `subjectLine` (the design-default inbox subject; `createSend` still takes a per-send `subject`), `lang` (the language of the copy, ISO 639-1; omit and Brew detects it from the finished body), and `groupId` (`grp_…` or `ungrouped`) or `groupName` (resolve or create), not both; omit to land Ungrouped. Supports `Idempotency-Key`. **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. @@ -19340,7 +19629,7 @@ paths: - Emails summary: Get an email design description: |- - Reads one design as the bare `EmailDetail` row: `emailId`, the `emailVersionId` (the pin for `POST /v1/sends` and sendEmail nodes), `title`, `status`, `previewStatus`, `subjectLine`, `previewText`, `previewImage`, `updatedAt`, `group` and `createdBy` / `createdByUserId` (the design’s creator, shared by every version), plus `errorMessage` when a generation failed. + Reads one design as the bare `EmailDetail` row: `emailId`, the `emailVersionId` (the pin for `POST /v1/sends` and sendEmail nodes), `title`, `status`, `previewStatus`, `subjectLine`, `previewText`, `lang`, `previewImage`, `updatedAt`, `group` and `createdBy` / `createdByUserId` (the design’s creator, shared by every version), plus `errorMessage` when a generation failed. **Use when** inspecting a design before sending it, pinning a version, polling a generation you started, or pulling rendered HTML or version history. @@ -19793,11 +20082,11 @@ paths: - Emails summary: Edit an email design description: |- - Updates a design: an AI edit (`prompt`), its envelope (`title`, `subjectLine`, `groupId`), or both in one call. At least one of the four is required. A design renames and moves between groups here exactly like an audience, trigger, automation or group renames through its own PATCH. + Updates a design: an AI edit (`prompt`), its envelope (`title`, `subjectLine`, `lang`, `groupId`), or both in one call. At least one of the five is required. A design renames and moves between groups here exactly like an audience, trigger, automation or group renames through its own PATCH. **Use when** changing an existing design: rewording or restyling it with a prompt, renaming it (a clone starts as `Copy of …`), setting the inbox subject the design carries by default, or filing it in another group. - **Input** `prompt` runs the Brew email agent against the current latest version (or the `emailVersionId` pin, optionally grounded by `contentUrls`) and writes a new `version: "latest"` row on the same `emailId` (the previous head becomes a numeric historical version); usage-metered by actual token spend. `title` renames the design (its canvas name, 1 to 200 characters), `subjectLine` sets the default inbox subject and `groupId` moves it into an existing group (`null` = Ungrouped); any of them without `prompt` is ONE atomic in-place patch (no AI run, no new version, free). With `prompt`, the rename and move land first so the new version inherits them and the subject is applied to the new head. `emailVersionId` requires `prompt`. + **Input** `prompt` runs the Brew email agent against the current latest version (or the `emailVersionId` pin, optionally grounded by `contentUrls`) and writes a new `version: "latest"` row on the same `emailId` (the previous head becomes a numeric historical version); usage-metered by actual token spend. `title` renames the design (its canvas name, 1 to 200 characters), `subjectLine` sets the default inbox subject, `lang` sets the language of the copy (kept from then on; Brew stops re-detecting it) and `groupId` moves it into an existing group (`null` = Ungrouped); any of them without `prompt` is ONE atomic in-place patch (no AI run, no new version, free). With `prompt`, the rename and move land first so the new version inherits them and the subject is applied to the new head. `emailVersionId` requires `prompt`. **Returns** `200` with the generated-email shape: the NEW head after a prompt edit, or the CURRENT head after an envelope patch (with its `title`, `subjectLine` and `group` after the patch); a text response when the agent answered in prose. @@ -19828,7 +20117,7 @@ paths: example: kx7b3s7fapqz8mjm12ekz1kxdx87yceg requestBody: required: true - description: A natural-language `prompt` edit (optional `emailVersionId` source pin, optional `contentUrls`) and/or the envelope fields `title`, `subjectLine` and `groupId` — at least one of the four. Identity lives on the path. + description: A natural-language `prompt` edit (optional `emailVersionId` source pin, optional `contentUrls`) and/or the envelope fields `title`, `subjectLine`, `lang` and `groupId` — at least one of the five. Identity lives on the path. content: application/json: schema: @@ -48682,7 +48971,9 @@ paths: 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`). + A `subscribed` column reads true/false and ESP status words (`unsubscribed`, `cleaned`, `blocklisted`, `transactional`, `active`, …). An opt-out unsubscribes the contact, new or existing (with `onConflict: "skip"` an existing contact is left unchanged, and its `CONTACT_EXISTS_SKIPPED` warning says so). 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`). + + Two opt-in modes. `dryRun: true` returns the response this import would return (`summary`, `errors`, row `warnings`, `fieldsCreated` as fields it would create) with `dryRun: true` and no `importId`, and writes nothing: no contacts, field definitions or Import History entry; `validate` is not run and nothing is charged. Insert vs update is decided by the same address match the write uses, so a real run right after returns the same counts. `onConflict: "skip"` only creates missing contacts: each existing contact is left unchanged (an opt-out row included), counted in `summary.skipped` with a `CONTACT_EXISTS_SKIPPED` warning naming its `row`. Either way `inserted + updated + failed + skipped = total`. A dry run cannot foresee failures that only happen while writing (a concurrent change, a database refusal), so the real run can still fail where the preview succeeded. With `validate: true`, inline checks are still metered per submitted row, rows `skip` left alone included; a request that leaves no address to check is not charged. requestBody: required: true content: @@ -49172,6 +49463,8 @@ paths: 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 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. + + Two opt-in modes. `dryRun: true` returns the response this import would return (`summary`, `errors`, row `warnings`, `fieldsCreated` as fields it would create) with `dryRun: true` and no `importId`, and writes nothing: no contacts, field definitions or Import History entry; `validate` is not run and nothing is charged. Insert vs update is decided by the same address match the write uses, so a real run right after returns the same counts. `onConflict: "skip"` only creates missing contacts: each existing contact is left unchanged (an opt-out row included), counted in `summary.skipped` with a `CONTACT_EXISTS_SKIPPED` warning naming its `row`. Either way `inserted + updated + failed + skipped = total`. A dry run cannot foresee failures that only happen while writing (a concurrent change, a database refusal), so the real run can still fail where the preview succeeded. With `validate: true`, inline checks are still metered per submitted row, rows `skip` left alone included; a request that leaves no address to check is not charged. requestBody: required: true content: @@ -60062,6 +60355,19 @@ paths: description: "Filter LIST by how the sequence starts: `signup` (after creating an account) or `newsletter` (after subscribing)." name: type in: query + - schema: + type: string + enum: + - onboarding + - trial + - events + - product_updates + - promotions + description: Filter LIST by send-stream kind. + required: false + description: Filter LIST by send-stream kind. + name: kind + in: query - schema: type: string minLength: 1 @@ -60399,12 +60705,20 @@ paths: type: string minLength: 1 maxLength: 253 - description: The flow’s brand domain, as returned in every `GET /v1/flows` row. Case-insensitive. + description: The flow’s brand domain. A bare domain returns the brand’s primary flow; one send stream’s flow is `?kind=` on it (or the path `/v1/flows/{domain}/{kind}`), as each `GET /v1/flows` row’s `slug` (`/`) names it. Case-insensitive. example: brew.new required: true - description: The flow’s brand domain, as returned in every `GET /v1/flows` row. Case-insensitive. + description: The flow’s brand domain. A bare domain returns the brand’s primary flow; one send stream’s flow is `?kind=` on it (or the path `/v1/flows/{domain}/{kind}`), as each `GET /v1/flows` row’s `slug` (`/`) names it. Case-insensitive. name: slug in: path + - schema: + type: string + pattern: ^[a-z][a-z-]*[a-z](?:-[0-9]{1,3})?$ + description: Stream segment for a bare-domain `slug`. + required: false + description: Stream segment for a bare-domain `slug`. + name: kind + in: query - schema: type: string description: "Detail-only expansions, comma-separated. `html` adds each step’s rendered HTML. Rendered email is bulky — over MCP only the first step or two fit one response; the rest arrive without `html` and the summary says how many were kept. A step’s `emailId` is already a valid `referenceEmailId`, so reuse needs no body; for the bodies themselves call `GET /v1/flows/{slug}?include=html` over REST, which has no cap. Best-effort per step: a step whose body is no longer servable comes back without `html` rather than failing the flow." @@ -68820,7 +69134,7 @@ paths: - `domain_status`, `domain_score_run`: `domains` (`emails` implies it). - `import_job`, `validation_job`: `contacts`. - `automation_pause_window_closed`: `automations`. - - `brand_extracted`, `brand_image_import`: every credential on the brand. + - `brand_extracted`, `brand_image_import`, `integration_attention` (a connected integration such as Stripe needs a reconnect, moved to another brand, or lost a permission): every credential on the brand. - `api_key_created`: the `all` scope (a signed-in member, or a key or connection holding `all`). - `send_limit_reached`: organization admins only (a signed-in admin, an admin’s organization-wide connection, or an organization-wide key holding `all`); never a brand key. - `comment_mention`, `comment_reply` (`isPersonal: true`): only the person addressed, signed in or on a personal connection; never an API key. @@ -68857,6 +69171,7 @@ paths: - send_review_rejected - comment_mention - comment_reply + - integration_attention description: Only notifications of this type. A type this credential cannot see (its scopes, or a person-only or admin-only type) is an empty page. required: false description: Only notifications of this type. A type this credential cannot see (its scopes, or a person-only or admin-only type) is an empty page. diff --git a/src/commands/contacts/import-csv.ts b/src/commands/contacts/import-csv.ts index 3d03343..6dcf191 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 { DRY_RUN_FLAG, importModeFlags, ON_CONFLICT_FLAG } from './import-modes' import { renderImportResult } from './import-summary' type ImportCsvContactsInput = Parameters[0] @@ -53,6 +54,8 @@ export const contactsImportCsvCommand = defineCommand({ summary: 'Stamp a marketing consent record on every row: api, form or import (sets the source of a record given in --input)', }, + DRY_RUN_FLAG, + ON_CONFLICT_FLAG, INPUT_FLAG, IDEMPOTENCY_FLAG, ], @@ -60,6 +63,8 @@ export const contactsImportCsvCommand = defineCommand({ 'brew-cli contacts import-csv --file contacts.csv', 'cat contacts.csv | brew-cli contacts import-csv --file - --mapping Email=email', 'brew-cli contacts import-csv --file eu-signups.csv --date-order day_first --consent-source import', + 'brew-cli contacts import-csv --file contacts.csv --dry-run', + 'brew-cli contacts import-csv --file contacts.csv --on-conflict skip', ], run: async ({ ctx, flags }) => { if (flags.input === '-' && flags.file === '-') { @@ -72,6 +77,7 @@ export const contactsImportCsvCommand = defineCommand({ mapping: parseKeyValues(flags.mapping, '--mapping'), dateOrder: flagString(flags.dateOrder), validate: flags.validate === true ? true : undefined, + ...importModeFlags(flags), }) const fileName = importFileName(flags, input) if (fileName !== undefined) { diff --git a/src/commands/contacts/import-modes.ts b/src/commands/contacts/import-modes.ts new file mode 100644 index 0000000..24ccdf8 --- /dev/null +++ b/src/commands/contacts/import-modes.ts @@ -0,0 +1,48 @@ +import { CliUsageError } from '../../lib/errors' + +/** `--dry-run` on both imports. */ +export const DRY_RUN_FLAG = { + flag: '--dry-run', + summary: + 'Show what the import would do and write nothing (no contacts, fields or Import History batch; --validate is skipped and nothing is charged)', +} as const + +/** `--on-conflict` on both imports. */ +export const ON_CONFLICT_FLAG = { + flag: '--on-conflict ', + summary: + 'upsert (default) updates existing contacts; skip only creates missing ones and leaves existing contacts unchanged (counted as skipped)', +} as const + +const ON_CONFLICT_MODES = ['upsert', 'skip'] as const + +/** + * The body keys `--dry-run` and `--on-conflict` set. An absent flag sets + * nothing, so a value from --input or the file stays. + */ +export function importModeFlags(flags: { + readonly dryRun?: unknown + readonly onConflict?: unknown +}): { dryRun?: true; onConflict?: (typeof ON_CONFLICT_MODES)[number] } { + // Any supplied value is checked before it is normalized: an empty one + // (an unset shell variable) must stop the import, not run an upsert. + const raw = flags.onConflict + const onConflict = typeof raw === 'string' ? raw.trim() : raw + if ( + onConflict !== undefined && + !( + typeof onConflict === 'string' && + (ON_CONFLICT_MODES as ReadonlyArray).includes(onConflict) + ) + ) { + throw new CliUsageError( + `--on-conflict takes upsert or skip, not "${String(raw)}".` + ) + } + return { + ...(flags.dryRun === true ? { dryRun: true } : {}), + ...(onConflict !== undefined + ? { onConflict: onConflict as (typeof ON_CONFLICT_MODES)[number] } + : {}), + } +} diff --git a/src/commands/contacts/import-summary.ts b/src/commands/contacts/import-summary.ts index 6351b47..00583b3 100644 --- a/src/commands/contacts/import-summary.ts +++ b/src/commands/contacts/import-summary.ts @@ -7,24 +7,34 @@ export const MAX_TERMINAL_ROWS = 20 /** * The terminal rendering of `contacts import` and `contacts import-csv`: one * line of counts, then the failures and warnings returned by the API. - * `--json` prints the full envelope. + * `--json` prints the full envelope. A dry run (`--dry-run`) says so first + * and counts what the import would do. */ export function renderImportResult( result: ImportContactsResponse, fileName: string | undefined ): string { const { summary } = result + const isDryRun = result.dryRun === true const counts = [ - `${summary.inserted} added`, - `${summary.updated} updated`, - `${summary.failed} failed`, - ...(summary.skipped > 0 ? [`${summary.skipped} skipped`] : []), + `${summary.inserted} ${isDryRun ? 'to add' : 'added'}`, + `${summary.updated} ${isDryRun ? 'to update' : 'updated'}`, + `${summary.failed} ${isDryRun ? 'to fail' : 'failed'}`, + ...(summary.skipped > 0 + ? [`${summary.skipped} ${isDryRun ? 'to skip' : 'skipped'}`] + : []), ].join(', ') - const name = fileName ? `${fileName} ` : '' const rowNoun = summary.total === 1 ? 'row' : 'rows' - const lines = [ - `Imported ${name}as ${result.importId}: ${counts} (${summary.total} ${rowNoun} submitted).`, - ] + const head = isDryRun + ? `Dry run${fileName ? ` of ${fileName}` : ''} — nothing was written` + : [ + 'Imported', + fileName, + result.importId ? `as ${result.importId}` : undefined, + ] + .filter(Boolean) + .join(' ') + const lines = [`${head}: ${counts} (${summary.total} ${rowNoun} submitted).`] if (result.errors.length > 0) { lines.push( '', @@ -52,7 +62,10 @@ export function renderImportResult( ) } if (result.fieldsCreated.length > 0) { - lines.push('', `New custom fields: ${result.fieldsCreated.join(', ')}.`) + lines.push( + '', + `${isDryRun ? 'Custom fields it would create' : 'New custom fields'}: ${result.fieldsCreated.join(', ')}.` + ) } if (result.validation) { const { valid, risky, invalid, unscored, persistence } = result.validation diff --git a/src/commands/contacts/import.ts b/src/commands/contacts/import.ts index 7c77d22..a0ea920 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 { DRY_RUN_FLAG, importModeFlags, ON_CONFLICT_FLAG } from './import-modes' import { renderImportResult } from './import-summary' type ImportContactsInput = Parameters[0] @@ -44,12 +45,15 @@ export const contactsImportCommand = defineCommand({ summary: 'Default marketing consent record for rows without their own: api, form or import', }, + DRY_RUN_FLAG, + ON_CONFLICT_FLAG, 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', + 'brew-cli contacts import --file contacts.json --dry-run --on-conflict skip', ], run: async ({ ctx, flags }) => { if (flags.input === '-' && flags.file === '-') { @@ -69,6 +73,7 @@ export const contactsImportCommand = defineCommand({ const input = mergeInput(body, { fileName: importFileName(flags, body), validate: flags.validate === true ? true : undefined, + ...importModeFlags(flags), }) if (!Array.isArray(input.contacts) || input.contacts.length === 0) { throw new CliUsageError( diff --git a/src/commands/flows/get.ts b/src/commands/flows/get.ts index ebfd53b..1414c82 100644 --- a/src/commands/flows/get.ts +++ b/src/commands/flows/get.ts @@ -18,7 +18,8 @@ export const flowsGetCommand = defineCommand({ args: [ { name: 'slug', - summary: 'The brand domain a `flows list` card carries (e.g. brew.new)', + summary: + 'The slug a `flows list` card carries: a brand domain (brew.new) or / (brew.new/welcome)', isRequired: true, }, ], @@ -28,18 +29,29 @@ export const flowsGetCommand = defineCommand({ summary: 'Comma-separated expansions: html (each step’s rendered HTML, best-effort per step)', }, + { + flag: '--kind ', + summary: + 'One send stream’s flow for a bare-domain slug (e.g. welcome); a / slug already names it', + }, ], examples: [ 'brew-cli flows get brew.new', 'brew-cli flows get brew.new --include html --json', + 'brew-cli flows get brew.new/welcome', ], run: async ({ ctx, args, flags }) => { const include = flagString(flags.include) + const kind = flagString(flags.kind) + const options = { + ...(include === undefined ? {} : { include }), + ...(kind === undefined ? {} : { kind }), + } const flow = await ctx .client() .flows.get( args.slug ?? '', - include === undefined ? undefined : { include } + Object.keys(options).length > 0 ? options : undefined ) return { data: flow, human: renderSteps(flow) } }, diff --git a/src/generated/openapi-types.ts b/src/generated/openapi-types.ts index 8403a8b..7d8dc5d 100644 --- a/src/generated/openapi-types.ts +++ b/src/generated/openapi-types.ts @@ -31,7 +31,7 @@ export interface paths { * * **Use when** you have a brief and want Brew to author on brand. For existing markup use `importEmail`; for a Figma frame use `importFigmaDesign`. * - * **Input** `prompt`; optional `category` (steers the design treatment only: transactional categories get receipt or reset conventions, while delivery for those flows is a triggered automation on a transactional-purpose domain), `contentUrls`, `referenceEmailId`, `subjectLine` (the design-default inbox subject; `createSend` still takes a per-send `subject`), and `groupId` (`grp_…` or `ungrouped`) or `groupName` (resolve or create), not both; omit to land Ungrouped. Supports `Idempotency-Key`. + * **Input** `prompt`; optional `category` (steers the design treatment only: transactional categories get receipt or reset conventions, while delivery for those flows is a triggered automation on a transactional-purpose domain), `contentUrls`, `referenceEmailId`, `subjectLine` (the design-default inbox subject; `createSend` still takes a per-send `subject`), `lang` (the language of the copy, ISO 639-1; omit and Brew detects it from the finished body), and `groupId` (`grp_…` or `ungrouped`) or `groupName` (resolve or create), not both; omit to land Ungrouped. Supports `Idempotency-Key`. * * **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. * @@ -55,7 +55,7 @@ export interface paths { }; /** * Get an email design - * @description Reads one design as the bare `EmailDetail` row: `emailId`, the `emailVersionId` (the pin for `POST /v1/sends` and sendEmail nodes), `title`, `status`, `previewStatus`, `subjectLine`, `previewText`, `previewImage`, `updatedAt`, `group` and `createdBy` / `createdByUserId` (the design’s creator, shared by every version), plus `errorMessage` when a generation failed. + * @description Reads one design as the bare `EmailDetail` row: `emailId`, the `emailVersionId` (the pin for `POST /v1/sends` and sendEmail nodes), `title`, `status`, `previewStatus`, `subjectLine`, `previewText`, `lang`, `previewImage`, `updatedAt`, `group` and `createdBy` / `createdByUserId` (the design’s creator, shared by every version), plus `errorMessage` when a generation failed. * * **Use when** inspecting a design before sending it, pinning a version, polling a generation you started, or pulling rendered HTML or version history. * @@ -79,11 +79,11 @@ export interface paths { head?: never; /** * Edit an email design - * @description Updates a design: an AI edit (`prompt`), its envelope (`title`, `subjectLine`, `groupId`), or both in one call. At least one of the four is required. A design renames and moves between groups here exactly like an audience, trigger, automation or group renames through its own PATCH. + * @description Updates a design: an AI edit (`prompt`), its envelope (`title`, `subjectLine`, `lang`, `groupId`), or both in one call. At least one of the five is required. A design renames and moves between groups here exactly like an audience, trigger, automation or group renames through its own PATCH. * * **Use when** changing an existing design: rewording or restyling it with a prompt, renaming it (a clone starts as `Copy of …`), setting the inbox subject the design carries by default, or filing it in another group. * - * **Input** `prompt` runs the Brew email agent against the current latest version (or the `emailVersionId` pin, optionally grounded by `contentUrls`) and writes a new `version: "latest"` row on the same `emailId` (the previous head becomes a numeric historical version); usage-metered by actual token spend. `title` renames the design (its canvas name, 1 to 200 characters), `subjectLine` sets the default inbox subject and `groupId` moves it into an existing group (`null` = Ungrouped); any of them without `prompt` is ONE atomic in-place patch (no AI run, no new version, free). With `prompt`, the rename and move land first so the new version inherits them and the subject is applied to the new head. `emailVersionId` requires `prompt`. + * **Input** `prompt` runs the Brew email agent against the current latest version (or the `emailVersionId` pin, optionally grounded by `contentUrls`) and writes a new `version: "latest"` row on the same `emailId` (the previous head becomes a numeric historical version); usage-metered by actual token spend. `title` renames the design (its canvas name, 1 to 200 characters), `subjectLine` sets the default inbox subject, `lang` sets the language of the copy (kept from then on; Brew stops re-detecting it) and `groupId` moves it into an existing group (`null` = Ungrouped); any of them without `prompt` is ONE atomic in-place patch (no AI run, no new version, free). With `prompt`, the rename and move land first so the new version inherits them and the subject is applied to the new head. `emailVersionId` requires `prompt`. * * **Returns** `200` with the generated-email shape: the NEW head after a prompt edit, or the CURRENT head after an envelope patch (with its `title`, `subjectLine` and `group` after the patch); a text response when the agent answered in prose. * @@ -1551,7 +1551,9 @@ export interface paths { * * 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`). + * A `subscribed` column reads true/false and ESP status words (`unsubscribed`, `cleaned`, `blocklisted`, `transactional`, `active`, …). An opt-out unsubscribes the contact, new or existing (with `onConflict: "skip"` an existing contact is left unchanged, and its `CONTACT_EXISTS_SKIPPED` warning says so). 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`). + * + * Two opt-in modes. `dryRun: true` returns the response this import would return (`summary`, `errors`, row `warnings`, `fieldsCreated` as fields it would create) with `dryRun: true` and no `importId`, and writes nothing: no contacts, field definitions or Import History entry; `validate` is not run and nothing is charged. Insert vs update is decided by the same address match the write uses, so a real run right after returns the same counts. `onConflict: "skip"` only creates missing contacts: each existing contact is left unchanged (an opt-out row included), counted in `summary.skipped` with a `CONTACT_EXISTS_SKIPPED` warning naming its `row`. Either way `inserted + updated + failed + skipped = total`. A dry run cannot foresee failures that only happen while writing (a concurrent change, a database refusal), so the real run can still fail where the preview succeeded. With `validate: true`, inline checks are still metered per submitted row, rows `skip` left alone included; a request that leaves no address to check is not charged. */ post: operations["importContactsCsv"]; delete?: never; @@ -1574,6 +1576,8 @@ export interface 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 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. + * + * Two opt-in modes. `dryRun: true` returns the response this import would return (`summary`, `errors`, row `warnings`, `fieldsCreated` as fields it would create) with `dryRun: true` and no `importId`, and writes nothing: no contacts, field definitions or Import History entry; `validate` is not run and nothing is charged. Insert vs update is decided by the same address match the write uses, so a real run right after returns the same counts. `onConflict: "skip"` only creates missing contacts: each existing contact is left unchanged (an opt-out row included), counted in `summary.skipped` with a `CONTACT_EXISTS_SKIPPED` warning naming its `row`. Either way `inserted + updated + failed + skipped = total`. A dry run cannot foresee failures that only happen while writing (a concurrent change, a database refusal), so the real run can still fail where the preview succeeded. With `validate: true`, inline checks are still metered per submitted row, rows `skip` left alone included; a request that leaves no address to check is not charged. */ post: operations["importContacts"]; delete?: never; @@ -2598,7 +2602,7 @@ export interface paths { * - `domain_status`, `domain_score_run`: `domains` (`emails` implies it). * - `import_job`, `validation_job`: `contacts`. * - `automation_pause_window_closed`: `automations`. - * - `brand_extracted`, `brand_image_import`: every credential on the brand. + * - `brand_extracted`, `brand_image_import`, `integration_attention` (a connected integration such as Stripe needs a reconnect, moved to another brand, or lost a permission): every credential on the brand. * - `api_key_created`: the `all` scope (a signed-in member, or a key or connection holding `all`). * - `send_limit_reached`: organization admins only (a signed-in admin, an admin’s organization-wide connection, or an organization-wide key holding `all`); never a brand key. * - `comment_mention`, `comment_reply` (`isPersonal: true`): only the person addressed, signed in or on a personal connection; never an API key. @@ -2673,6 +2677,11 @@ export interface components { subjectLine?: string; /** @description The design's inbox preview line, read directly from the latest version's JSX (its single source of truth) — what a send delivers when no explicit `previewText` override is passed to POST /v1/sends. Detail-only; absent when the design has no . */ previewText?: string; + /** + * @description The language the email's copy is written in (ISO 639-1, or zh-Hans / zh-Hant), read by Brew's language model after each content change unless a caller set it. Rendered as the HTML document's lang. Detail-only; absent until it has been read. + * @enum {string} + */ + lang?: "en" | "de" | "fr" | "es" | "pt" | "it" | "nl" | "pl" | "sv" | "da" | "no" | "fi" | "cs" | "sk" | "hu" | "ro" | "bg" | "el" | "tr" | "ru" | "uk" | "hr" | "sr" | "sl" | "lt" | "lv" | "et" | "ca" | "ar" | "he" | "fa" | "ur" | "hi" | "bn" | "th" | "vi" | "id" | "ms" | "tl" | "ja" | "ko" | "zh-Hans" | "zh-Hant" | "sw" | "af"; html?: string; versions?: { version: number | "latest"; @@ -2704,6 +2713,11 @@ export interface components { subjectLine?: string; /** @description The design's inbox preview line, read directly from the latest version's JSX (its single source of truth) — what a send delivers when no explicit `previewText` override is passed to POST /v1/sends. Detail-only; absent when the design has no . */ previewText?: string; + /** + * @description The language the email's copy is written in (ISO 639-1, or zh-Hans / zh-Hant), read by Brew's language model after each content change unless a caller set it. Rendered as the HTML document's lang. Detail-only; absent until it has been read. + * @enum {string} + */ + lang?: "en" | "de" | "fr" | "es" | "pt" | "it" | "nl" | "pl" | "sv" | "da" | "no" | "fi" | "cs" | "sk" | "hu" | "ro" | "bg" | "el" | "tr" | "ru" | "uk" | "hr" | "sr" | "sl" | "lt" | "lv" | "et" | "ca" | "ar" | "he" | "fa" | "ur" | "hi" | "bn" | "th" | "vi" | "id" | "ms" | "tl" | "ja" | "ko" | "zh-Hans" | "zh-Hant" | "sw" | "af"; html?: string; versions?: { version: number | "latest"; @@ -3448,7 +3462,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_ROW_SKIPPED" | "CONTACT_EXISTS_SKIPPED" | "CSV_COLUMN_IGNORED" | "RECIPIENTS_EXCLUDED" | "CONSENT_RECORD_MISSING"; message: string; field?: string; }; @@ -3930,6 +3944,8 @@ export interface components { title: string; /** @enum {string} */ type: "newsletter" | "signup"; + /** @enum {string} */ + kind?: "onboarding" | "trial" | "events" | "product_updates" | "promotions"; category: string; categoryLabel: string; emailCount: number; @@ -4312,7 +4328,7 @@ export interface components { /** @description `ntf_` + 20 hex characters: a one-way hash, stable for the row’s life. */ notificationId: string; /** @enum {string} */ - type: "chat_stream" | "import_job" | "brand_image_import" | "validation_job" | "email_sent" | "email_scheduled" | "preview_email" | "brand_extracted" | "domain_status" | "domain_score_run" | "api_key_created" | "email_send_failed" | "gradual_send_paused" | "gradual_send_completed" | "send_limit_reached" | "automation_pause_window_closed" | "send_review_rejected" | "comment_mention" | "comment_reply"; + type: "chat_stream" | "import_job" | "brand_image_import" | "validation_job" | "email_sent" | "email_scheduled" | "preview_email" | "brand_extracted" | "domain_status" | "domain_score_run" | "api_key_created" | "email_send_failed" | "gradual_send_paused" | "gradual_send_completed" | "send_limit_reached" | "automation_pause_window_closed" | "send_review_rejected" | "comment_mention" | "comment_reply" | "integration_attention"; /** @enum {string} */ status: "uploading" | "preparing" | "processing" | "finalizing" | "streaming" | "background_finalizing" | "completed" | "partial" | "failed" | "interrupted" | "stopped" | "persistence_failed"; title: string; @@ -4406,6 +4422,11 @@ export interface components { subjectLine?: string; /** @description The design's inbox preview line, read directly from the latest version's JSX (its single source of truth) — what a send delivers when no explicit `previewText` override is passed to POST /v1/sends. Detail-only; absent when the design has no . */ previewText?: string; + /** + * @description The language the email's copy is written in (ISO 639-1, or zh-Hans / zh-Hant), read by Brew's language model after each content change unless a caller set it. Rendered as the HTML document's lang. Detail-only; absent until it has been read. + * @enum {string} + */ + lang?: "en" | "de" | "fr" | "es" | "pt" | "it" | "nl" | "pl" | "sv" | "da" | "no" | "fi" | "cs" | "sk" | "hu" | "ro" | "bg" | "el" | "tr" | "ru" | "uk" | "hr" | "sr" | "sl" | "lt" | "lv" | "et" | "ca" | "ar" | "he" | "fa" | "ur" | "hi" | "bn" | "th" | "vi" | "id" | "ms" | "tl" | "ja" | "ko" | "zh-Hans" | "zh-Hant" | "sw" | "af"; html?: string; versions?: { version: number | "latest"; @@ -4474,6 +4495,11 @@ export interface components { 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 ISO 639-1 language code of the email's copy (or zh-Hans / zh-Hant). Omit and Brew's language model reads it from the finished body; a value you set is kept and never re-detected. Becomes the HTML document's lang (and dir for right-to-left languages) on every send and export. One of: en, de, fr, es, pt, it, nl, pl, sv, da, no, fi, cs, sk, hu, ro, bg, el, tr, ru, uk, hr, sr, sl, lt, lv, et, ca, ar, he, fa, ur, hi, bn, th, vi, id, ms, tl, ja, ko, zh-Hans, zh-Hant, sw, af. + * @enum {string} + */ + lang?: "en" | "de" | "fr" | "es" | "pt" | "it" | "nl" | "pl" | "sv" | "da" | "no" | "fi" | "cs" | "sk" | "hu" | "ro" | "bg" | "el" | "tr" | "ru" | "uk" | "hr" | "sr" | "sl" | "lt" | "lv" | "et" | "ca" | "ar" | "he" | "fa" | "ur" | "hi" | "bn" | "th" | "vi" | "id" | "ms" | "tl" | "ja" | "ko" | "zh-Hans" | "zh-Hant" | "sw" | "af"; /** @description File the design under an existing group (`grp_…`), or `ungrouped`. Mutually exclusive with `groupName`. */ groupId?: string; /** @description File the design under a group resolved (or created) by this name. Mutually exclusive with `groupId`. */ @@ -4582,7 +4608,7 @@ export interface components { response: string; }; EmailEditRequest: { - /** @description The edit to make, in plain language — e.g. "swap the hero for the spring campaign image and tighten the CTA copy". Scoped edits beat full rewrites. Omit to only patch `title`, `subjectLine` or `groupId` (at least one of the four is required). */ + /** @description The edit to make, in plain language — e.g. "swap the hero for the spring campaign image and tighten the CTA copy". Scoped edits beat full rewrites. Omit to only patch `title`, `subjectLine`, `lang` or `groupId` (at least one of the five is required). */ prompt?: string; /** @description Pin the edit to a specific source version (from `GET /v1/emails/{emailId}?include=versions`). Requires `prompt`. Omit to edit the current latest. */ emailVersionId?: string; @@ -4592,6 +4618,11 @@ export interface components { title?: string; /** @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 Set the language of the email (ISO 639-1, or zh-Hans / zh-Hant). Part of the free envelope patch: no AI run, no new version. Once set it is kept: Brew no longer re-detects it after edits. null clears a language you set, and Brew reads it again from the email. + * @enum {string|null} + */ + lang?: "en" | "de" | "fr" | "es" | "pt" | "it" | "nl" | "pl" | "sv" | "da" | "no" | "fi" | "cs" | "sk" | "hu" | "ro" | "bg" | "el" | "tr" | "ru" | "uk" | "hr" | "sr" | "sl" | "lt" | "lv" | "et" | "ca" | "ar" | "he" | "fa" | "ur" | "hi" | "bn" | "th" | "vi" | "id" | "ms" | "tl" | "ja" | "ko" | "zh-Hans" | "zh-Hant" | "sw" | "af" | null; /** @description Move the design into this existing group (`grp_…`); `null` moves it to Ungrouped; omit to leave its group alone. Part of the free envelope patch: no AI run, no new version. */ groupId?: string | null; }; @@ -7673,7 +7704,7 @@ export interface components { inserted: number; updated: number; failed: number; - /** @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. */ + /** @description Rows not written and not failed: existing contacts left alone by `onConflict: "skip"` (one `CONTACT_EXISTS_SKIPPED` warning each), and 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). */ skipped: number; /** @description Rows submitted (JSON contacts, or CSV data rows). Always `inserted + updated + failed + skipped`. */ total: number; @@ -7711,8 +7742,13 @@ 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 a contact filter on `importId` lists them. */ - importId: string; + /** @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. Absent on a dry run. */ + importId?: string; + /** + * @description Present on a dry run: the counts say what the import would do, and nothing was written. + * @enum {boolean} + */ + dryRun?: true; }; ContactsImportCsvRequest: { csv: string; @@ -7743,6 +7779,13 @@ export interface components { 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; + /** @description When true, returns what this import would return and writes nothing: no contacts, fields, Import History entry or `importId`; `validate` is skipped and nothing is charged. */ + dryRun?: boolean; + /** + * @description `upsert` (default) updates existing contacts; `skip` only creates missing ones, leaving each existing contact unchanged (counted in `summary.skipped`, with a `CONTACT_EXISTS_SKIPPED` warning). + * @enum {string} + */ + onConflict?: "upsert" | "skip"; }; ContactsImportRequest: { contacts: { @@ -7788,6 +7831,13 @@ 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; + /** @description When true, returns what this import would return and writes nothing: no contacts, fields, Import History entry or `importId`; `validate` is skipped and nothing is charged. */ + dryRun?: boolean; + /** + * @description `upsert` (default) updates existing contacts; `skip` only creates missing ones, leaving each existing contact unchanged (counted in `summary.skipped`, with a `CONTACT_EXISTS_SKIPPED` warning). + * @enum {string} + */ + onConflict?: "upsert" | "skip"; }; ContactsBatchDeleteResponse: { deletedCount: number; @@ -8501,6 +8551,8 @@ export interface components { title: string; /** @enum {string} */ type: "newsletter" | "signup"; + /** @enum {string} */ + kind?: "onboarding" | "trial" | "events" | "product_updates" | "promotions"; category: string; categoryLabel: string; emailCount: number; @@ -8965,7 +9017,7 @@ export interface components { /** @description `ntf_` + 20 hex characters: a one-way hash, stable for the row’s life. */ notificationId: string; /** @enum {string} */ - type: "chat_stream" | "import_job" | "brand_image_import" | "validation_job" | "email_sent" | "email_scheduled" | "preview_email" | "brand_extracted" | "domain_status" | "domain_score_run" | "api_key_created" | "email_send_failed" | "gradual_send_paused" | "gradual_send_completed" | "send_limit_reached" | "automation_pause_window_closed" | "send_review_rejected" | "comment_mention" | "comment_reply"; + type: "chat_stream" | "import_job" | "brand_image_import" | "validation_job" | "email_sent" | "email_scheduled" | "preview_email" | "brand_extracted" | "domain_status" | "domain_score_run" | "api_key_created" | "email_send_failed" | "gradual_send_paused" | "gradual_send_completed" | "send_limit_reached" | "automation_pause_window_closed" | "send_review_rejected" | "comment_mention" | "comment_reply" | "integration_attention"; /** @enum {string} */ status: "uploading" | "preparing" | "processing" | "finalizing" | "streaming" | "background_finalizing" | "completed" | "partial" | "failed" | "interrupted" | "stopped" | "persistence_failed"; title: string; @@ -9831,7 +9883,7 @@ export interface operations { }; cookie?: never; }; - /** @description A natural-language `prompt` edit (optional `emailVersionId` source pin, optional `contentUrls`) and/or the envelope fields `title`, `subjectLine` and `groupId` — at least one of the four. Identity lives on the path. */ + /** @description A natural-language `prompt` edit (optional `emailVersionId` source pin, optional `contentUrls`) and/or the envelope fields `title`, `subjectLine`, `lang` and `groupId` — at least one of the five. Identity lives on the path. */ requestBody: { content: { "application/json": components["schemas"]["EmailEditRequest"]; @@ -27186,6 +27238,8 @@ export interface operations { category?: "verification" | "transactional" | "direct_sales" | "welcome" | "promotion" | "newsletter" | "education" | "abandoned_cart" | "winback" | "other"; /** @description Filter LIST by how the sequence starts: `signup` (after creating an account) or `newsletter` (after subscribing). */ type?: "newsletter" | "signup"; + /** @description Filter LIST by send-stream kind. */ + kind?: "onboarding" | "trial" | "events" | "product_updates" | "promotions"; /** @description Semantic search over what the sequences are about — “developer onboarding drip”, “trial expiry win-back”. Results come back in relevance order and `sort` is ignored. */ semantic?: string; /** @description LIST order: `newest` (default), `emails` (longest sequences first), `span` (sequences that run the longest first), `remixes` (most remixed first). */ @@ -27341,12 +27395,14 @@ export interface operations { getFlow: { parameters: { query?: { + /** @description Stream segment for a bare-domain `slug`. */ + kind?: string; /** @description Detail-only expansions, comma-separated. `html` adds each step’s rendered HTML. Rendered email is bulky — over MCP only the first step or two fit one response; the rest arrive without `html` and the summary says how many were kept. A step’s `emailId` is already a valid `referenceEmailId`, so reuse needs no body; for the bodies themselves call `GET /v1/flows/{slug}?include=html` over REST, which has no cap. Best-effort per step: a step whose body is no longer servable comes back without `html` rather than failing the flow. */ include?: string; }; header?: never; path: { - /** @description The flow’s brand domain, as returned in every `GET /v1/flows` row. Case-insensitive. */ + /** @description The flow’s brand domain. A bare domain returns the brand’s primary flow; one send stream’s flow is `?kind=` on it (or the path `/v1/flows/{domain}/{kind}`), as each `GET /v1/flows` row’s `slug` (`/`) names it. Case-insensitive. */ slug: string; }; cookie?: never; @@ -31140,7 +31196,7 @@ export interface operations { parameters: { query?: { /** @description Only notifications of this type. A type this credential cannot see (its scopes, or a person-only or admin-only type) is an empty page. */ - type?: "chat_stream" | "import_job" | "brand_image_import" | "validation_job" | "email_sent" | "email_scheduled" | "preview_email" | "brand_extracted" | "domain_status" | "domain_score_run" | "api_key_created" | "email_send_failed" | "gradual_send_paused" | "gradual_send_completed" | "send_limit_reached" | "automation_pause_window_closed" | "send_review_rejected" | "comment_mention" | "comment_reply"; + type?: "chat_stream" | "import_job" | "brand_image_import" | "validation_job" | "email_sent" | "email_scheduled" | "preview_email" | "brand_extracted" | "domain_status" | "domain_score_run" | "api_key_created" | "email_send_failed" | "gradual_send_paused" | "gradual_send_completed" | "send_limit_reached" | "automation_pause_window_closed" | "send_review_rejected" | "comment_mention" | "comment_reply" | "integration_attention"; /** * @description Page size (1-100). Defaults to 100. * @example 50 diff --git a/tests/commands/contacts-import-summary.test.ts b/tests/commands/contacts-import-summary.test.ts index 58c54e3..1325afd 100644 --- a/tests/commands/contacts-import-summary.test.ts +++ b/tests/commands/contacts-import-summary.test.ts @@ -70,6 +70,40 @@ describe('renderImportResult (contacts import / import-csv on a terminal)', () = ) }) + it('renders a dry run as what the import would do, with no importId', () => { + const { importId: _none, ...dry } = result({ + summary: { inserted: 2, updated: 1, failed: 0, skipped: 1, total: 4 }, + errors: [], + warnings: [ + { + code: 'CONTACT_EXISTS_SKIPPED', + email: 'ada@brewmail.dev', + row: 3, + message: + 'ada@brewmail.dev already exists, so onConflict: "skip" left it unchanged.', + }, + ], + fieldsCreated: ['plan'], + }) + const text = renderImportResult({ ...dry, dryRun: true }, undefined) + expect(text.split('\n')[0]).toBe( + 'Dry run — nothing was written: 2 to add, 1 to update, 0 to fail, 1 to skip (4 rows submitted).' + ) + expect(text).toMatch(/3\s+ada@brewmail\.dev\s+CONTACT_EXISTS_SKIPPED/) + expect(text).toContain('Custom fields it would create: plan.') + expect(text).not.toContain('undefined') + }) + + it('names no batch when a real run returns no importId', () => { + const { importId: _none, ...noId } = result({ errors: [], warnings: [] }) + expect(renderImportResult(noId, 'customers.csv').split('\n')[0]).toBe( + 'Imported customers.csv: 2 added, 0 updated, 1 failed, 3 skipped (6 rows submitted).' + ) + expect(renderImportResult(noId, undefined).split('\n')[0]).toBe( + 'Imported: 2 added, 0 updated, 1 failed, 3 skipped (6 rows submitted).' + ) + }) + 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`, diff --git a/tests/commands/contacts.test.ts b/tests/commands/contacts.test.ts index 0af98a4..d9599de 100644 --- a/tests/commands/contacts.test.ts +++ b/tests/commands/contacts.test.ts @@ -447,6 +447,89 @@ describe('contacts import-csv', () => { }) }) + it('sends --dry-run and --on-conflict, and renders the dry run as written nowhere', 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({ + summary: { + inserted: 1, + updated: 0, + failed: 0, + skipped: 1, + total: 2, + }, + fieldsCreated: [], + errors: [], + warnings: [ + { + code: 'CONTACT_EXISTS_SKIPPED', + email: 'jane@example.com', + row: 1, + message: + 'jane@example.com already exists, so onConflict: "skip" left it unchanged.', + }, + ], + dryRun: true, + }) + } + ) + ) + const dir = mkdtempSync(join(tmpdir(), 'brew-cli-csv-')) + const csvPath = join(dir, 'contacts.csv') + writeFileSync(csvPath, 'Email\njane@example.com\nnew@example.com\n') + const result = await runCli( + [ + 'contacts', + 'import-csv', + '--file', + csvPath, + '--dry-run', + '--on-conflict', + 'skip', + ], + { env: env(), ttyOut: true } + ) + expect(result.code).toBe(0) + expect(body).toEqual({ + csv: 'Email\njane@example.com\nnew@example.com\n', + dryRun: true, + onConflict: 'skip', + fileName: 'contacts.csv', + }) + expect(result.stdout).toContain( + 'Dry run of contacts.csv — nothing was written: 1 to add, 0 to update, 0 to fail, 1 to skip (2 rows submitted).' + ) + expect(result.stdout).toContain('CONTACT_EXISTS_SKIPPED') + }) + + it('refuses an --on-conflict mode the API does not take', async () => { + const dir = mkdtempSync(join(tmpdir(), 'brew-cli-csv-')) + const csvPath = join(dir, 'contacts.csv') + writeFileSync(csvPath, 'Email\njane@example.com\n') + const result = await runCli( + ['contacts', 'import-csv', '--file', csvPath, '--on-conflict', 'merge'], + { env: env() } + ) + expect(result.code).toBe(2) + expect(result.stderr).toContain('--on-conflict takes upsert or skip') + }) + + it('refuses an empty --on-conflict instead of running an upsert', async () => { + const dir = mkdtempSync(join(tmpdir(), 'brew-cli-csv-')) + const csvPath = join(dir, 'contacts.csv') + writeFileSync(csvPath, 'Email\njane@example.com\n') + const result = await runCli( + ['contacts', 'import-csv', '--file', csvPath, '--on-conflict', ''], + { env: env() } + ) + expect(result.code).toBe(2) + expect(result.stderr).toContain('--on-conflict takes upsert or skip, not') + }) + it('refuses a call with no CSV at all', async () => { const result = await runCli( ['contacts', 'import-csv', '--date-order', 'day_first'], @@ -536,6 +619,120 @@ describe('contacts import-csv', () => { describe('contacts import', () => { const IMPORT_URL = 'https://brew.new/api/v1/contacts/import' + const DRY_RUN_ENVELOPE = { + summary: { inserted: 1, updated: 0, failed: 0, skipped: 0, total: 1 }, + fieldsCreated: [], + errors: [], + warnings: [], + dryRun: true, + } + + it('sends --dry-run and --on-conflict over a body that sets neither', async () => { + let body: unknown + server.use( + http.post(IMPORT_URL, async ({ request }) => { + body = await request.json() + return HttpResponse.json(DRY_RUN_ENVELOPE) + }) + ) + const result = await runCli( + [ + 'contacts', + 'import', + '--file', + '-', + '--dry-run', + '--on-conflict', + 'skip', + ], + { env: env(), stdin: JSON.stringify([{ email: 'a@example.com' }]) } + ) + expect(result.code).toBe(0) + expect(body).toEqual({ + contacts: [{ email: 'a@example.com' }], + dryRun: true, + onConflict: 'skip', + }) + }) + + it('renders a dry run that --input asked for, with no flag', async () => { + let body: unknown + server.use( + http.post(IMPORT_URL, async ({ request }) => { + body = await request.json() + return HttpResponse.json(DRY_RUN_ENVELOPE) + }) + ) + const result = await runCli( + [ + 'contacts', + 'import', + '--input', + JSON.stringify({ + contacts: [{ email: 'a@example.com' }], + dryRun: true, + }), + ], + { env: env(), ttyOut: true } + ) + expect(result.code).toBe(0) + // The preview the body asked for is still a preview on the wire. + expect(body).toEqual({ + contacts: [{ email: 'a@example.com' }], + dryRun: true, + }) + expect(result.stdout).toContain( + 'Dry run — nothing was written: 1 to add, 0 to update, 0 to fail (1 row submitted).' + ) + }) + + it.each([ + ['import', IMPORT_URL, { contacts: [{ email: 'a@example.com' }] }], + [ + 'import-csv', + 'https://brew.new/api/v1/contacts/import-csv', + { csv: 'Email\na@example.com\n' }, + ], + ] as const)( + 'contacts %s keeps dryRun and onConflict from the body, and a flag wins over it', + async (command, url, rows) => { + const bodies: Array = [] + server.use( + http.post(url, async ({ request }) => { + bodies.push(await request.json()) + return HttpResponse.json(DRY_RUN_ENVELOPE) + }) + ) + const input = JSON.stringify({ + ...rows, + dryRun: true, + onConflict: 'upsert', + }) + const kept = await runCli(['contacts', command, '--input', input], { + env: env(), + }) + const overridden = await runCli( + ['contacts', command, '--input', input, '--on-conflict', 'skip'], + { env: env() } + ) + expect(kept.code).toBe(0) + expect(overridden.code).toBe(0) + expect(bodies).toEqual([ + { ...rows, dryRun: true, onConflict: 'upsert' }, + { ...rows, dryRun: true, onConflict: 'skip' }, + ]) + } + ) + + it('refuses an --on-conflict mode the API does not take', async () => { + const result = await runCli( + ['contacts', 'import', '--file', '-', '--on-conflict', 'merge'], + { env: env(), stdin: JSON.stringify([{ email: 'a@example.com' }]) } + ) + expect(result.code).toBe(2) + expect(result.stderr).toContain('--on-conflict takes upsert or skip') + }) + it('keeps validate, consent and fileName a JSON body sets; flags win only when given', async () => { const bodies: Array = [] server.use(