Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,13 @@ These import changes require `@brew.new/sdk` 13.3.0 or newer, which adds

### Added

- Add `--lang <code>` (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
Expand Down
6 changes: 6 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <code>` names the language of the copy (ISO 639-1, or
`zh-Hans` / `zh-Hant`); omit it and Brew detects it. `emails edit <emailId>
--lang <code>` 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 — `<group> get
<id>` 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
Expand Down
10 changes: 8 additions & 2 deletions docs/commands/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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) |
Expand Down Expand Up @@ -722,13 +722,15 @@ Generate a new on-brand email design from a prompt
- `--content-urls <urls...>` — Page URL(s) to pull copy and imagery from, repeatable
- `--group-id <groupId>` — Destination group id; omit or use ungrouped for Ungrouped
- `--subject-line <text>` — The design's default inbox subject line
- `--lang <code>` — Language of the copy (ISO 639-1, or zh-Hans / zh-Hant); omit and Brew detects it
- `--input <json>` — Full JSON request body, or - to read stdin (flags override it)
- `--idempotency-key <key>` — Idempotency-Key for safe retries (auto-generated otherwise)

```bash
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
```

Expand Down Expand Up @@ -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
Expand All @@ -788,6 +790,8 @@ AI-edit an email design, and/or set its subject line, title or group (free witho
- `--email-version-id <id>` — Edit from a specific version (default: latest); needs --prompt
- `--content-urls <urls...>` — Page URL(s) to pull copy and imagery from, repeatable
- `--subject-line <text>` — The design's default inbox subject line; alone it skips the AI run
- `--lang <code>` — 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 <text>` — Rename the design (its canvas name); alone it skips the AI run
- `--group-id <groupId>` — Move the design into this existing group; alone it skips the AI run
- `--ungroup` — Move the design to Ungrouped (groupId: null)
Expand All @@ -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
```
Expand Down
35 changes: 29 additions & 6 deletions src/commands/emails/edit.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 !== ''
Expand All @@ -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',
Expand All @@ -47,6 +47,16 @@ export const emailsEditCommand = defineCommand({
summary:
"The design's default inbox subject line; alone it skips the AI run",
},
{
flag: '--lang <code>',
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 <text>',
summary: 'Rename the design (its canvas name); alone it skips the AI run',
Expand All @@ -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',
],
Expand All @@ -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
Expand All @@ -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()
Expand Down
7 changes: 7 additions & 0 deletions src/commands/emails/generate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,13 +40,19 @@ export const emailsGenerateCommand = defineCommand({
flag: '--subject-line <text>',
summary: "The design's default inbox subject line",
},
{
flag: '--lang <code>',
summary:
'Language of the copy (ISO 639-1, or zh-Hans / zh-Hant); omit and Brew detects it',
},
INPUT_FLAG,
IDEMPOTENCY_FLAG,
],
examples: [
'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 }) => {
Expand All @@ -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(
Expand Down
116 changes: 115 additions & 1 deletion tests/commands/emails.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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`, () =>
Expand Down Expand Up @@ -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(),
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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(),
Expand Down
Loading