diff --git a/CHANGELOG.md b/CHANGELOG.md index d3496a1..a429c54 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,13 @@ These import changes require `@brew.new/sdk` 13.3.0 or newer, which adds ### Added +- Add `--lang ` (ISO 639-1, or `zh-Hans` / `zh-Hant`) to + `emails generate` and `emails edit`, sent as the body `lang`, shipped in + GetBrew/brew-v2#2225. On `emails edit`, `--lang` alone is a free envelope + patch: no AI run, no new version, and Brew stops re-detecting the language. + `emails edit --clear-lang` sends `lang: null` to clear it, so Brew reads the + language again. + `emails get` shows the design's `lang` once Brew has read it. - Synchronize email-category and template browse-group contracts with app revision `9bf2e01f14d8260bdde0825e901a05497b66c70c`. Template reads accept `--group`, and `--count --group-by group` labels browse groups correctly in diff --git a/README.md b/README.md index 6e9c7ca..9235a52 100644 --- a/README.md +++ b/README.md @@ -133,6 +133,12 @@ for the complete SDK and host authorization flow. Imports accept HTML, MJML, JSX, and saved EML messages; JSON output includes the import's `assetReport` with asset counts and warnings. +`emails generate --lang ` names the language of the copy (ISO 639-1, or +`zh-Hans` / `zh-Hant`); omit it and Brew detects it. `emails edit +--lang ` sets it without an AI run, and Brew keeps that value from then +on; `--clear-lang` clears it so Brew reads the language again. `emails get` +returns it as `lang`. + Every collection with ids to look up has a real detail read — ` get ` returns the bare row, and an unknown id surfaces the API's own typed `404`. `notifications list` and `emails comments list` are list-only (one diff --git a/docs/commands/README.md b/docs/commands/README.md index f2b3193..374e2b7 100644 --- a/docs/commands/README.md +++ b/docs/commands/README.md @@ -57,7 +57,7 @@ a `confirmCommand` otherwise, `--yes` to proceed). | `brew-cli emails generate` | write ($) | `POST /v1/emails` | Generate a new on-brand email design from a prompt | | `brew-cli emails import` | write | `POST /v1/emails/import` | Import existing HTML, MJML, JSX, or EML as a new editable design | | `brew-cli emails import-figma` | write | `POST /v1/emails/figma` | Convert one Figma frame into an editable design (deterministic, free) | -| `brew-cli emails edit` | write ($) | `PATCH /v1/emails/{emailId}` | AI-edit an email design, and/or set its subject line, title or group (free without a prompt) | +| `brew-cli emails edit` | write ($) | `PATCH /v1/emails/{emailId}` | AI-edit an email design, and/or set its subject line, title, language or group (free without a prompt) | | `brew-cli emails clone` | write | `POST /v1/emails/{emailId}/clone` | Clone a design into a new one (exact snapshot copy, no AI) | | `brew-cli emails restore` | write | `POST /v1/emails/{emailId}/restore` | Restore a previous version as the new latest (non-destructive) | | `brew-cli emails delete` | destructive | `DELETE /v1/emails/{emailId}` | Hard-delete an email design and all its versions (idempotent) | @@ -722,6 +722,7 @@ Generate a new on-brand email design from a prompt - `--content-urls ` — Page URL(s) to pull copy and imagery from, repeatable - `--group-id ` — Destination group id; omit or use ungrouped for Ungrouped - `--subject-line ` — The design's default inbox subject line +- `--lang ` — Language of the copy (ISO 639-1, or zh-Hans / zh-Hant); omit and Brew detects it - `--input ` — Full JSON request body, or - to read stdin (flags override it) - `--idempotency-key ` — Idempotency-Key for safe retries (auto-generated otherwise) @@ -729,6 +730,7 @@ Generate a new on-brand email design from a prompt brew-cli emails generate --prompt "Product-launch email for the fall sale" brew-cli emails generate --prompt "Welcome email" --group-id grp_welcome brew-cli emails generate --prompt "Welcome email" --subject-line "Welcome to Brew" +brew-cli emails generate --prompt "Willkommens-E-Mail" --lang de brew-cli emails generate --prompt "Welcome email" --content-urls https://example.com/pricing ``` @@ -777,7 +779,7 @@ brew-cli emails import-figma --url "https://www.figma.com/design/abc123/Launch?n ### brew-cli emails edit -AI-edit an email design, and/or set its subject line, title or group (free without a prompt) +AI-edit an email design, and/or set its subject line, title, language or group (free without a prompt) - Route: `PATCH /v1/emails/{emailId}` - Class: write @@ -788,6 +790,8 @@ AI-edit an email design, and/or set its subject line, title or group (free witho - `--email-version-id ` — Edit from a specific version (default: latest); needs --prompt - `--content-urls ` — Page URL(s) to pull copy and imagery from, repeatable - `--subject-line ` — The design's default inbox subject line; alone it skips the AI run +- `--lang ` — Language of the copy (ISO 639-1, or zh-Hans / zh-Hant), kept from then on; alone it skips the AI run +- `--clear-lang` — Clear a language you set (lang: null) so Brew reads it from the email again - `--title ` — Rename the design (its canvas name); alone it skips the AI run - `--group-id ` — Move the design into this existing group; alone it skips the AI run - `--ungroup` — Move the design to Ungrouped (groupId: null) @@ -796,6 +800,8 @@ AI-edit an email design, and/or set its subject line, title or group (free witho ```bash brew-cli emails edit eml_2SmZOWV3ZQ7W5x6g3m4p --prompt "Tighten the hero copy" brew-cli emails edit eml_2SmZOWV3ZQ7W5x6g3m4p --subject-line "Your September roundup" +brew-cli emails edit eml_2SmZOWV3ZQ7W5x6g3m4p --lang fr +brew-cli emails edit eml_2SmZOWV3ZQ7W5x6g3m4p --clear-lang brew-cli emails edit eml_2SmZOWV3ZQ7W5x6g3m4p --title "Fall sale v2" --group-id grp_7Hq2 brew-cli emails edit eml_2SmZOWV3ZQ7W5x6g3m4p --ungroup ``` diff --git a/src/commands/emails/edit.ts b/src/commands/emails/edit.ts index cdf2232..f2410e5 100644 --- a/src/commands/emails/edit.ts +++ b/src/commands/emails/edit.ts @@ -16,7 +16,7 @@ import { progress } from '../../lib/output' * What a PATCH without a prompt changes in place: no AI run, no new version, * no credits. The API needs a prompt or at least one of these. */ -const ENVELOPE_FIELDS = ['title', 'subjectLine', 'groupId'] as const +const ENVELOPE_FIELDS = ['title', 'subjectLine', 'lang', 'groupId'] as const function isText(value: unknown): boolean { return typeof value === 'string' && value !== '' @@ -25,7 +25,7 @@ function isText(value: unknown): boolean { export const emailsEditCommand = defineCommand({ path: ['emails', 'edit'], summary: - 'AI-edit an email design, and/or set its subject line, title or group (free without a prompt)', + 'AI-edit an email design, and/or set its subject line, title, language or group (free without a prompt)', sdkMethod: 'emails.edit', route: { method: 'PATCH', path: '/v1/emails/{emailId}' }, commandClass: 'write', @@ -47,6 +47,16 @@ export const emailsEditCommand = defineCommand({ summary: "The design's default inbox subject line; alone it skips the AI run", }, + { + flag: '--lang ', + summary: + 'Language of the copy (ISO 639-1, or zh-Hans / zh-Hant), kept from then on; alone it skips the AI run', + }, + { + flag: '--clear-lang', + summary: + 'Clear a language you set (lang: null) so Brew reads it from the email again', + }, { flag: '--title ', summary: 'Rename the design (its canvas name); alone it skips the AI run', @@ -65,6 +75,8 @@ export const emailsEditCommand = defineCommand({ examples: [ 'brew-cli emails edit eml_2SmZOWV3ZQ7W5x6g3m4p --prompt "Tighten the hero copy"', 'brew-cli emails edit eml_2SmZOWV3ZQ7W5x6g3m4p --subject-line "Your September roundup"', + 'brew-cli emails edit eml_2SmZOWV3ZQ7W5x6g3m4p --lang fr', + 'brew-cli emails edit eml_2SmZOWV3ZQ7W5x6g3m4p --clear-lang', 'brew-cli emails edit eml_2SmZOWV3ZQ7W5x6g3m4p --title "Fall sale v2" --group-id grp_7Hq2', 'brew-cli emails edit eml_2SmZOWV3ZQ7W5x6g3m4p --ungroup', ], @@ -75,24 +87,33 @@ export const emailsEditCommand = defineCommand({ '--group-id and --ungroup conflict: pick one group move.' ) } + const lang = flagString(flags.lang) + if (lang !== undefined && flags.clearLang === true) { + throw new CliUsageError( + '--lang and --clear-lang conflict: set a language or clear it.' + ) + } const base = await readJsonFlag(ctx, flags.input, '--input') const input = mergeInput(base, { prompt: flagString(flags.prompt), emailVersionId: flagString(flags.emailVersionId), contentUrls: toStringArray(flags.contentUrls), subjectLine: flagString(flags.subjectLine), + lang: flags.clearLang === true ? null : lang, title: flagString(flags.title), groupId: flags.ungroup === true ? null : groupId, }) const isPromptEdit = isText(input.prompt) - // `groupId: null` is a real value: it moves the design to Ungrouped. + // `null` is a real value for `groupId` (moves the design to Ungrouped) + // and `lang` (clears a named language so Brew reads it again). const envelope = ENVELOPE_FIELDS.filter( (field) => - isText(input[field]) || (field === 'groupId' && input[field] === null) + isText(input[field]) || + ((field === 'groupId' || field === 'lang') && input[field] === null) ) if (!isPromptEdit && envelope.length === 0) { throw new CliUsageError( - 'Pass --prompt, or at least one of --subject-line, --title, --group-id or --ungroup (or those fields via --input).' + 'Pass --prompt, or at least one of --subject-line, --lang, --clear-lang, --title, --group-id or --ungroup (or those fields via --input).' ) } // Without a prompt the patch is deterministic server-side: no AI run, no @@ -103,7 +124,9 @@ export const emailsEditCommand = defineCommand({ ? 'Editing email… (typically 30-90s)' : envelope.length === 1 && envelope[0] === 'subjectLine' ? 'Setting the subject line…' - : 'Updating the design (no AI run)…' + : envelope.length === 1 && envelope[0] === 'lang' + ? 'Setting the language…' + : 'Updating the design (no AI run)…' ) const result = await ctx .client() diff --git a/src/commands/emails/generate.ts b/src/commands/emails/generate.ts index 7843928..9b3cb03 100644 --- a/src/commands/emails/generate.ts +++ b/src/commands/emails/generate.ts @@ -40,6 +40,11 @@ export const emailsGenerateCommand = defineCommand({ flag: '--subject-line ', summary: "The design's default inbox subject line", }, + { + flag: '--lang ', + summary: + 'Language of the copy (ISO 639-1, or zh-Hans / zh-Hant); omit and Brew detects it', + }, INPUT_FLAG, IDEMPOTENCY_FLAG, ], @@ -47,6 +52,7 @@ export const emailsGenerateCommand = defineCommand({ 'brew-cli emails generate --prompt "Product-launch email for the fall sale"', 'brew-cli emails generate --prompt "Welcome email" --group-id grp_welcome', 'brew-cli emails generate --prompt "Welcome email" --subject-line "Welcome to Brew"', + 'brew-cli emails generate --prompt "Willkommens-E-Mail" --lang de', 'brew-cli emails generate --prompt "Welcome email" --content-urls https://example.com/pricing', ], run: async ({ ctx, flags }) => { @@ -57,6 +63,7 @@ export const emailsGenerateCommand = defineCommand({ contentUrls: toStringArray(flags.contentUrls), groupId: flagString(flags.groupId), subjectLine: flagString(flags.subjectLine), + lang: flagString(flags.lang), }) if (typeof input.prompt !== 'string' || input.prompt === '') { throw new CliUsageError( diff --git a/tests/commands/emails.test.ts b/tests/commands/emails.test.ts index 09519e4..0eae709 100644 --- a/tests/commands/emails.test.ts +++ b/tests/commands/emails.test.ts @@ -186,6 +186,25 @@ describe('emails get', () => { expect((result.json as { emailId: string }).emailId).toBe('eml_1') }) + it('shows the lang the API returns on the detail row', async () => { + server.use( + http.get(`${EMAILS_URL}/eml_1`, () => + HttpResponse.json({ + emailId: 'eml_1', + title: 'Herbstverkauf', + status: 'ready', + lang: 'de', + }) + ) + ) + const result = await runCli(['emails', 'get', 'eml_1'], { + env: env(), + extraCommands: EXTRA, + }) + expect(result.code).toBe(0) + expect((result.json as { lang: string }).lang).toBe('de') + }) + it("surfaces the API's own 404 instead of a hand-built one", async () => { server.use( http.get(`${EMAILS_URL}/eml_ghost`, () => @@ -273,6 +292,25 @@ describe('emails generate', () => { }) }) + it('sends --lang as lang', async () => { + let body: unknown + server.use( + http.post(EMAILS_URL, async ({ request }) => { + body = await request.json() + return HttpResponse.json( + { emailId: 'eml_new', status: 'ready' }, + { status: 201 } + ) + }) + ) + const result = await runCli( + ['emails', 'generate', '--prompt', 'Willkommens-E-Mail', '--lang', 'de'], + { env: env(), extraCommands: EXTRA } + ) + expect(result.code).toBe(0) + expect(body).toEqual({ prompt: 'Willkommens-E-Mail', lang: 'de' }) + }) + it('requires a prompt', async () => { const result = await runCli(['emails', 'generate'], { env: env(), @@ -382,6 +420,55 @@ describe('emails edit', () => { expect(result.stderr).not.toContain('Editing email') }) + it('patches lang-only without an AI prompt (no generation heartbeat)', async () => { + let body: unknown + server.use( + http.patch(`${EMAILS_URL}/eml_1`, async ({ request }) => { + body = await request.json() + return HttpResponse.json({ emailId: 'eml_1', status: 'ready' }) + }) + ) + const result = await runCli( + ['emails', 'edit', 'eml_1', '--lang', 'zh-Hant'], + { + env: env(), + extraCommands: EXTRA, + } + ) + expect(result.code).toBe(0) + expect(body).toEqual({ lang: 'zh-Hant' }) + expect(result.stderr).toContain('Setting the language') + expect(result.stderr).not.toContain('Editing email') + }) + + it('sends both when a prompt and a lang are given', async () => { + let body: unknown + server.use( + http.patch(`${EMAILS_URL}/eml_1`, async ({ request }) => { + body = await request.json() + return HttpResponse.json({ emailId: 'eml_1', status: 'ready' }) + }) + ) + const result = await runCli( + [ + 'emails', + 'edit', + 'eml_1', + '--prompt', + 'Translate the copy into French', + '--lang', + 'fr', + ], + { env: env(), extraCommands: EXTRA } + ) + expect(result.code).toBe(0) + expect(body).toEqual({ + prompt: 'Translate the copy into French', + lang: 'fr', + }) + expect(result.stderr).toContain('Editing email') + }) + it('sends both when a prompt and a subject line are given', async () => { let body: unknown server.use( @@ -410,7 +497,7 @@ describe('emails edit', () => { expect(result.stderr).toContain('Editing email') }) - // The API takes any of prompt, title, subjectLine and groupId; without a + // The API takes any of prompt, title, subjectLine, lang and groupId; without a // prompt it is one free in-place patch of the design's envelope. it('renames through --input without a prompt (no AI run)', async () => { let body: unknown @@ -493,6 +580,33 @@ describe('emails edit', () => { expect(result.stderr).toContain('conflict') }) + it('clears a named language with --clear-lang (lang: null, no AI run)', async () => { + let body: unknown + server.use( + http.patch(`${EMAILS_URL}/eml_1`, async ({ request }) => { + body = await request.json() + return HttpResponse.json({ emailId: 'eml_1', status: 'ready' }) + }) + ) + const result = await runCli(['emails', 'edit', 'eml_1', '--clear-lang'], { + env: env(), + extraCommands: EXTRA, + }) + expect(result.code).toBe(0) + expect(body).toEqual({ lang: null }) + expect(result.stderr).toContain('Setting the language') + expect(result.stderr).not.toContain('Editing email') + }) + + it('refuses --lang with --clear-lang before any request', async () => { + const result = await runCli( + ['emails', 'edit', 'eml_1', '--lang', 'fr', '--clear-lang'], + { env: env(), extraCommands: EXTRA } + ) + expect(result.code).toBe(2) + expect(result.stderr).toContain('conflict') + }) + it('requires a prompt or one envelope field (or --input)', async () => { const result = await runCli(['emails', 'edit', 'eml_1'], { env: env(),