Skip to content

migrate resend (C5, 0.21.0): move a Resend account and codebase to Brew - #61

Open
thedogwiththedataonit wants to merge 6 commits into
claude/initfrom
claude/migrate-resend
Open

thedogwiththedataonit wants to merge 6 commits into
claude/initfrom
claude/migrate-resend

Conversation

@thedogwiththedataonit

@thedogwiththedataonit thedogwiththedataonit commented Oct 16, 2026 •

Copy link
Copy Markdown
Contributor

Update 2026-10-25: restacked on #55. Conflicts: CHANGELOG.md, RELEASING.md (API gate text kept, spec provenance updated). install-sdk follows SDK_RANGE ^13.6.0 (^13.3.0/^13.5.0 declared → next step). Checks against the local typescript-sdk#64 + SDK main build: tsc, lint, test 1082, generated types fresh, docs:commands:check, generate:skills:check, sync:launchers:check, build, bin smoke, launcher pack smoke, signal smoke ✓.

Renumbered 10-25: main published 0.18.0 (--lang/--clear-lang, contact imports, SDK ^13.3.0), and SDK main published 13.1.0, 13.2.0 and 13.3.0 without the DX features (13.4.0 is reserved for its pending email lang release). So this chain moves up one: #54 0.19.0, #55 0.20.0, #61 0.21.0, #63 0.22.0, and the DX SDK releases are send-by-key 13.5.0, adapters/appEmails 13.6.0 (SDK_RANGE ^13.6.0, so installs can no longer resolve to the published 13.3.0), integrations.connect 13.7.0. This PR is 0.21.0; older numbers below are pre-rebase.

Spec sync 2026-10-21: restacked on #55; the #2064 and #2145 spec commits folded into #54's vendored brew-v2 #2169 @ 6f8ef50544 spec. Adapted migrate resend: a key whose trigger already starts a published automation no setup made (APP_EMAIL_TRIGGER_IN_USE) is left manual with the API's message instead of failing the run (the next run sets it up once that automation is unpublished). RELEASING/CHANGELOG: brew-v2 #2169 deployed. Checks: tsc, lint, test 1049, docs/skills/launchers freshness, build, launcher + signal smokes ✓.

Renumbered 10-21: main published 0.17.0 (canvas commands, SDK ^13.0.0), so this chain moved up one: #54 0.18.0, #55 0.19.0, #61 0.20.0, #63 0.21.0. This PR is 0.20.0; older numbers below are pre-rebase.

Stacked on #55 (→ #54 → #53 → #44 → main 2c1deff). CLI 0.19.0 (C5 of the DX push): brew-cli migrate resend.

Depends on unpublished work: the same SDK release as #54/#55, and brew-v2#2169 (import modes props: "merge_tags", placeholders: "resend", variables, and the same modes on an app email's source) deployed. Bundled spec = brew-v2 #2145 b2912ad9f7's file: #2066 0fed90fbf3 merged with #2064 9f97aea3f8 (merge c612d5fdc8), plus #2145's own 28-line AppEmailPutRequest.source delta; byte-identical to typescript-sdk#64's. install-sdk now also appears when the declared SDK is older than ^13.2.0. Local checks run against the typescript-sdk#64 worktree build.

Publish order (nothing here is published, merged or tagged)

  1. brew-v2: #2169 (the whole DX stack collapsed into one PR: browser sign-in, trigger keys, fire by key, team sandbox, app emails, import modes, hosted auth hooks) merged and deployed. RELEASING.md's app-email gate names #2066, #2064 and #2145.
  2. typescript-sdk: init + the brew.new / create-brew launchers (C4, 0.20.0) #55 (send, fireByKey) and Release CLI 0.17.0 with registry SDK 13 #64 (appEmails, adapter subpaths) rebased onto SDK main (which already has the canvas revocation fix this CLI's main needs) and published. Numbers are final: init + the brew.new / create-brew launchers (C4, 0.20.0) #55 ships as 13.1.0 and Release CLI 0.17.0 with registry SDK 13 #64 as 13.2.0, after the canvas fix's 13.0.0; this CLI takes ^13.2.0. Both SDK PRs are currently behind SDK main (init + the brew.new / create-brew launchers (C4, 0.20.0) #55 conflicts).
  3. This chain: bun add @brew.new/sdk@^<that version> (regenerates bun.lock from the registry, replacing main's git pin), re-vendor openapi/public-api-v1.yaml from brew-v2 main, frozen install + full checks, then CLI 0.17.0 → 0.18.0 → 0.19.0.
  4. Launchers: reserve brew.new and create-brew on npm (both still 404) for the token's account; until then the release step warns and skips them.

What

migrate resend plans by default and writes brew.migration.json; --dry-run writes nothing; --apply (destructive class, exit 4 without --yes) executes and resumes when re-run.

  • Resend key: RESEND_API_KEY or --resend-key-stdin; it rides only requests to api.resend.com through vendorFetch (never recorded in the Brew transport, redacted from errors), never reaches Brew, the terminal or the manifest. A sending-only key exits 3 (RESEND_KEY_SENDING_ONLY) with a full-access next step.
  • Throttled + resumable: requests paced to 2 per second (429s wait out retry-after); contacts, suppressions, segment members and topic choices page with saved cursors; every Brew write carries an idempotency key; done items are skipped on re-run.
Resend Brew
Templates ({{{VAR}}}) POST /v1/emails/import with placeholders: "resend" + variable fallbacks → PUT /v1/app-emails/{key} from the converted HTML (published where it was; from carried) → the intermediate import deleted
React Email files the same with props: "merge_tags" (a file importing another component is manual)
Contacts upsertMany: subscribed with an import consent record, unsubscribed with subscribed: false (+ --domain's unsubscribe list), no state left out
Segments boolean field resend_segment_* on members + an audience on it
Topics boolean field resend_topic_* (true = opted in) from each contact's choices (one request per contact; the plan estimates the time) + an audience
Suppressions --domain's unsubscribe list
Draft broadcasts Brew draft emails via import (nothing sent); a {{ trigger.* }} tag gets a warning
Domains report + DKIM_SELECTOR_IN_USE advice (Brew also signs with resend._domainkey) and a subdomain to verify in Brew
Webhooks (origin only), automations, sent broadcasts reported
  • Code: scans for emails.send / batch.send (TypeScript-aware when typescript is installed, tokenizer otherwise; comments, strings and regexes are not calls), records each call site with what does not carry over (attachments, cc, raw html, scheduledAt, …) and a suggested getBrew().send(key, payload) as a blocking edit-file next step. --rewrite-imports moves a file to @brew.new/sdk/resend and writes lib/brew-resend-map.ts only when every send through it is a migrated template.
  • brew.migration.json: ids, keys, counts, cursors, call sites; never an address, a name or a key (tested).
  • MigrationSource interface; Resend is the only source.
  • brew-migrate-from-resend skill (embedded; init installs it only in projects that use Resend). README, brew-cli skill, CHANGELOG ## 0.19.0, version 0.19.0 + launchers synced.
  • #2145 (10-17): app-emails upsert --input passes source.props / placeholders / variables through; with --source-file it used to replace --input's whole source and drop them, now the file fills format + content and keeps the rest (format falls back to --input's source.format for stdin). New example --source-file emails/order-shipped.tsx --input '{"source":{"props":"merge_tags"}}'; migrate resend's hand-move hint for a React Email file now includes it. Considered and kept: migrate resend still imports then sets up rather than one PUT with source.placeholders: "resend", because the setup result carries no import notices (escaping_changed, dynamic_prop_folded, placeholder_unmapped, Tailwind drops) and no merge-path list, which the migration report shows per template.

Checks (scratch SDK = SDK main + #64, head 68124a1)

On e5a5052: tsc ✓ · lint ✓ · test 66 files / 941 tests ✓ · generated types match the vendored spec ✓ · docs:commands:check, generate:skills:check, sync:launchers:check ✓ · build + bin smoke ✓ · smoke:launchers ✓ · smoke:signals ✓.
New MSW tests (tests/commands/migrate-resend.test.ts, tests/helpers/resend-api.ts, tests/lib/migrate/keys.test.ts): plan with zero Brew writes and ≥ 500 ms between Resend requests; Resend key only on Resend requests, Brew key only on Brew's; no PII or secret in the manifest; --dry-run; --only code without a key; missing key exit 2; --resend-key-stdin; sending-only key exit 3; --apply exit 4 without --yes; full apply (exact import / upsert / contacts / audiences / unsubscribe bodies, idempotency keys, broadcast draft kept, imports deleted); resume from a saved cursor after a refused page; --rewrite-imports on the fixture; key derivation. Every run checks stdout and stderr for the Resend key, the Brew key and a webhook token.

Needs live validation (after deploy + publish)

migrate resend plan then --apply against a real Resend test account and a Brew test brand (examples/migrate-from-resend/before), including a React Email file with props, a broadcast draft, a topic, and the rewritten client sending through @brew.new/sdk/resend.

🤖 Generated with Claude Code


View with [code]smith Autofix with [code]smith
Need help on this PR? Tag @codesmith-bot with what you need.

RetriggerView in GreptileConfidence Score: 0/5

The PR is not ready to merge because the previous migration, rewrite and personal-data findings remain unresolved.

Fix All in Claude CodeFindings

  1. P1 Email defaults disappear ▶
  2. P1 Resume uses another brand ▶
  3. P1 Refused rows never retry ▶
  4. P1 Segments bypass subscription checks ▶
  5. P1 Hidden send options pass ▶
  6. P1 Shared client users are missed ▶
  7. P1 Node cannot load the map ▶
  8. P1 New templates miss the map ▶
  9. P1 Clients use an unchecked map ▶
  10. P1 Security Manifest keeps personal data ▶
  11. P1 Audience uses the wrong filter ▶
  12. P1 Retries skip unsubscribe rows ▶
  13. P1 Generated map cannot load ▶
  14. P1 New topic choices are skipped ▶
  15. P1 Rewritten CommonJS senders cannot load ▶
  16. P1 Rewritten imports break file loading ▶
  17. P2 Empty batches stop scanning ▶
  18. P2 Key failures lose authentication instructions ▶
  19. P2 Selected phases omit consent history ▶
Fix with agent prompt
### Issue 1
src/lib/migrate/import-setup.ts:136-141
`importAndSetUp` passes only `imported.html` into the app email. The import returns bare merge tags and their defaults in `variables[].fallback`, but this code uses those variables only for the report. Resend variable fallbacks and React prop defaults therefore disappear when a send omits the value. Preserve the returned fallbacks in the new trigger contract before marking the item done.

### Issue 2
src/lib/migrate/pipeline.ts:undefined-137
`readBrew` replaces the saved `brandId` without comparing it with the current destination. Re-running the manifest with another `--brand` or `BREW_API_KEY` keeps the first brand's completed items and saved cursors. Completed items are skipped, while remaining writes go to the new brand. Reject a changed destination before replacing the saved ID or using its progress.

### Issue 3
src/lib/migrate/phases.ts:469-474
`contactsPhase` advances its cursor and marks the phase done even when `upsertMany` reports failed rows. Brew can return per-row errors without throwing, so these failures bypass the catch block. A rerun then skips the completed phase. The segment and topic passes do the same. Keep failed pages retryable and report failure until those rows are resolved.

### Issue 4
src/lib/migrate/phases.ts:594-598
`segmentsPhase` imports every member, including contacts whose `unsubscribed` value is `null`. The contacts phase deliberately leaves those people out, but this later pass creates them without `subscribed: false`, so they default to subscribed. Apply the same subscription-state filter here so segment migration does not undo that safeguard.

### Issue 5
src/lib/migrate/code/analyze.ts:271-273
`analyzeMessage` flags a spread only when the message kind is unknown. A call such as `send({ template: { id: 'receipt' }, ...options })` is cleared for rewriting even if `options` contains `cc`, `attachments`, or a different template. That can switch a send which still needs Resend to the Brew adapter. Treat unresolved spreads as a blocker even when an explicit template is present.

```suggestion
  if (spread) {
    unsupported.push('...spread')
  }
```

### Issue 6
src/lib/migrate/code/rewrite.ts:94-100
`blockingReason` can clear an exported client even when some sends through it were not followed. For example, `export { resend } from './resend'` is not linked back to the original client. A raw-HTML send through that re-export does not block rewriting the original client, so the caller unexpectedly switches to the template adapter. Follow re-exports or leave clients with unresolved users on Resend.

### Issue 7
src/lib/migrate/code/rewrite.ts:157-162
`relativeSpecifier` removes the map's extension unless the source already has a relative `.js` import. A Node ESM file that imports only `resend` therefore gains an extensionless map import, which Node cannot resolve. The rewritten email client fails to load. Emit the map's runtime extension for Node ESM rather than inferring it from existing imports.

### Issue 8
src/lib/migrate/phase-code.ts:406-407
The template map is refreshed only when another file needs rewriting. After the first migration rewrites a shared client, importing a newly added template leaves `rewritable` empty and never updates the map. Sends through that client then have no entry for the new template. Refresh the managed map whenever migrated templates change, even if no import needs rewriting.

### Issue 9
src/lib/migrate/phase-code.ts:484-490
`rewriteImports` continues changing clients after the map returns `kept-user-version`. The retained map may lack the newly required template entries, but those clients are still marked done and their manual steps disappear. This can replace a working Resend client with a Brew client that cannot find its templates. Block those rewrites until the retained map is checked or merged.

### Issue 10
src/lib/migrate/phase-code.ts:undefined-228
`callSites[].replacement` copies recipient and variable expressions into `brew.migration.json`. For example, `to: 'alice@example.com'` remains in the saved replacement because `clean` removes keys, not addresses or names. Users are told this file contains no personal data and is safe to commit. Replace literal values with placeholders before saving them.

**How this was verified:** Recipient source text is inserted into the replacement, the redactor leaves email literals unchanged, and the replacement is saved in the manifest.

### Issue 11
src/lib/migrate/phases.ts:513-517
`ensureAudience` checks ownership of the original name but blindly reuses an audience named `<name> (Resend)`. If both names already exist, the fallback audience may select entirely different contacts. The migration still marks the segment or topic done. Check the fallback audience's saved ID and filter too, or choose another unused name.

### Issue 12
src/lib/migrate/pipeline.ts:undefined-197
`resolveTargetDomain` replaces the saved domain without checking which domain owns the saved progress. If an apply stops partway through importing suppressions into domain A, rerunning with domain B starts from A's saved cursor. Earlier suppressions never reach B, yet the phase can finish successfully.

Bind progress to the resolved domain ID and reject a changed destination, or restart the domain-specific work.

### Issue 13
src/lib/migrate/code/rewrite.ts:261-264
If a JavaScript project has `"type": "module"` and a `.cjs` sender, `mapFileContent` writes `exports.brewResendTemplates` into `brew-resend-map.js`. Node treats that `.js` file as an ES module, so the rewritten sender cannot load its map.

Use a `.cjs` map for CommonJS content, or choose the map's format from its own package settings.

### Issue 14
src/lib/migrate/phases.ts:undefined-839
`topicsPhase` keeps `passComplete` after discovering new topics. After one apply completes the contact pass, a rerun can discover another topic but skip copying its choices. The later audience pass then marks that topic done even though no contacts received its field.

Reset the contact pass when the topic set changes, or track completion separately for each topic.

### Issue 15
src/lib/migrate/code/rewrite.ts:223-231
`rewriteFile` inserts the map's `require` after the whole line containing `require('resend')`. If that line also creates the client, as in `const { Resend } = require('resend'); const resend = new Resend(process.env.RESEND_API_KEY)`, the rewritten constructor reads `brewResendTemplates` before its declaration. Loading the file then throws, breaking all sends through it. Insert the map declaration before any rewritten constructor.

### Issue 16
src/lib/migrate/code/rewrite.ts:undefined-217
`rewriteFile` breaks valid one-line ESM files such as `import { Resend } from 'resend'; const resend = new Resend(process.env.RESEND_API_KEY);`. It inserts the map import after the original import but adds neither a semicolon nor a trailing newline. The result contains `from './brew-resend-map' const resend`, which cannot parse, so sends through that file stop working.

End the inserted import with a semicolon or newline before marking the file done.

```suggestion
        replacement: `${locations.importInsertAt === 0 ? '' : '\n'}import { ${MAP_EXPORT} } from ${quote}${specifier}${quote}\n`,
```

### Issue 17
src/lib/migrate/phase-code.ts:undefined-147
Scanning `resend.batch.send([])` produces an empty `messages` array. `callSiteRecord` takes its first entry anyway, then passes `undefined` to `sendLine`, which crashes on `message.to`. Handle an empty batch as an empty or manual call site so one such call does not abort the code scan.

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

(2 more issues omitted due to length limits.)

Summary

Adds migrate resend to plan and apply a resumable move from Resend to Brew.

  • The CLI moves selected Resend account data into Brew and saves progress between runs.
  • The migration finds Resend send calls and rewrites files that fit Brew’s adapter.
  • App-email source files keep import settings supplied through --input.

Diagram

%%{init: {'theme': 'neutral'}}%%
flowchart TD
  A[Read Resend account and project] --> B[Read Brew brand]
  B --> C[Plan and save progress]
  C --> D{Apply confirmed?}
  D -->|No| E[Return migration plan]
  D -->|Yes| F[Copy account data and email designs]
  F --> G[Optionally rewrite template clients]
  G --> H[Save progress and remaining steps]
Loading

Reviews (7) · Last reviewed commit: "chore(release): migrate is 0.21.0; its i..." · Reviewed by Greptile

Comment on lines +136 to +141
const paths = (imported.variables ?? []).map((variable) => variable.path)
const subject = imported.subjectLine ?? input.subject
const setup = await brew.appEmails.upsert(
asSdkInput<UpsertAppEmailInput>({
triggerKey: key,
source: { format: 'html', content: imported.html },

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Email defaults disappear

importAndSetUp passes only imported.html into the app email. The import returns bare merge tags and their defaults in variables[].fallback, but this code uses those variables only for the report. Resend variable fallbacks and React prop defaults therefore disappear when a send omits the value. Preserve the returned fallbacks in the new trigger contract before marking the item done.

Prompt To Fix With AI
This is a comment left during a code review.
Path: src/lib/migrate/import-setup.ts
Line: 136-141

Comment:
**Email defaults disappear**

`importAndSetUp` passes only `imported.html` into the app email. The import returns bare merge tags and their defaults in `variables[].fallback`, but this code uses those variables only for the report. Resend variable fallbacks and React prop defaults therefore disappear when a send omits the value. Preserve the returned fallbacks in the new trigger contract before marking the item done.

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

Fix in Claude Code Fix in Codex Fix in Cursor

brandId: row.brandId,
name: row.identity?.brandName ?? row.domain ?? null,
}
run.manifest.brandId = row.brandId

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Resume uses another brand

readBrew replaces the saved brandId without comparing it with the current destination. Re-running the manifest with another --brand or BREW_API_KEY keeps the first brand's completed items and saved cursors. Completed items are skipped, while remaining writes go to the new brand. Reject a changed destination before replacing the saved ID or using its progress.

Prompt To Fix With AI
This is a comment left during a code review.
Path: src/lib/migrate/pipeline.ts
Line: 135

Comment:
**Resume uses another brand**

`readBrew` replaces the saved `brandId` without comparing it with the current destination. Re-running the manifest with another `--brand` or `BREW_API_KEY` keeps the first brand's completed items and saved cursors. Completed items are skipped, while remaining writes go to the new brand. Reject a changed destination before replacing the saved ID or using its progress.

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

Fix in Claude Code Fix in Codex Fix in Cursor

Comment thread src/lib/migrate/phases.ts
Comment on lines +469 to +474
after = page.next
phase.cursor = after
run.persist()
} while (after !== null)
phase.status = 'done'
phase.cursor = null

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Refused rows never retry

contactsPhase advances its cursor and marks the phase done even when upsertMany reports failed rows. Brew can return per-row errors without throwing, so these failures bypass the catch block. A rerun then skips the completed phase. The segment and topic passes do the same. Keep failed pages retryable and report failure until those rows are resolved.

Prompt To Fix With AI
This is a comment left during a code review.
Path: src/lib/migrate/phases.ts
Line: 469-474

Comment:
**Refused rows never retry**

`contactsPhase` advances its cursor and marks the phase done even when `upsertMany` reports failed rows. Brew can return per-row errors without throwing, so these failures bypass the catch block. A rerun then skips the completed phase. The segment and topic passes do the same. Keep failed pages retryable and report failure until those rows are resolved.

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

Fix in Claude Code Fix in Codex Fix in Cursor

Comment thread src/lib/migrate/phases.ts
Comment on lines +594 to +598
contacts: page.rows.map((row) => ({
email: row.email,
customFields: { [fieldName]: true },
...(row.unsubscribed === true ? { subscribed: false } : {}),
})),

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Segments bypass subscription checks

segmentsPhase imports every member, including contacts whose unsubscribed value is null. The contacts phase deliberately leaves those people out, but this later pass creates them without subscribed: false, so they default to subscribed. Apply the same subscription-state filter here so segment migration does not undo that safeguard.

Prompt To Fix With AI
This is a comment left during a code review.
Path: src/lib/migrate/phases.ts
Line: 594-598

Comment:
**Segments bypass subscription checks**

`segmentsPhase` imports every member, including contacts whose `unsubscribed` value is `null`. The contacts phase deliberately leaves those people out, but this later pass creates them without `subscribed: false`, so they default to subscribed. Apply the same subscription-state filter here so segment migration does not undo that safeguard.

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

Fix in Claude Code Fix in Codex Fix in Cursor

Comment on lines +271 to +273
if (spread && kind === 'unknown') {
unsupported.push('...spread')
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Hidden send options pass

analyzeMessage flags a spread only when the message kind is unknown. A call such as send({ template: { id: 'receipt' }, ...options }) is cleared for rewriting even if options contains cc, attachments, or a different template. That can switch a send which still needs Resend to the Brew adapter. Treat unresolved spreads as a blocker even when an explicit template is present.

Suggested change
if (spread && kind === 'unknown') {
unsupported.push('...spread')
}
if (spread) {
unsupported.push('...spread')
}
Prompt To Fix With AI
This is a comment left during a code review.
Path: src/lib/migrate/code/analyze.ts
Line: 271-273

Comment:
**Hidden send options pass**

`analyzeMessage` flags a spread only when the message kind is unknown. A call such as `send({ template: { id: 'receipt' }, ...options })` is cleared for rewriting even if `options` contains `cc`, `attachments`, or a different template. That can switch a send which still needs Resend to the Brew adapter. Treat unresolved spreads as a blocker even when an explicit template is present.

```suggestion
  if (spread) {
    unsupported.push('...spread')
  }
```

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

Fix in Claude Code Fix in Codex Fix in Cursor

Comment on lines +406 to +407
if (run.apply && run.options.rewriteImports && rewritable.length > 0) {
rewriteImports(run, scan, rewritable, templateItems, rewritten)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 New templates miss the map

The template map is refreshed only when another file needs rewriting. After the first migration rewrites a shared client, importing a newly added template leaves rewritable empty and never updates the map. Sends through that client then have no entry for the new template. Refresh the managed map whenever migrated templates change, even if no import needs rewriting.

Prompt To Fix With AI
This is a comment left during a code review.
Path: src/lib/migrate/phase-code.ts
Line: 406-407

Comment:
**New templates miss the map**

The template map is refreshed only when another file needs rewriting. After the first migration rewrites a shared client, importing a newly added template leaves `rewritable` empty and never updates the map. Sends through that client then have no entry for the new template. Refresh the managed map whenever migrated templates change, even if no import needs rewriting.

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

Fix in Claude Code Fix in Codex Fix in Cursor

Comment on lines +484 to +490
if (mapChange.action === 'kept-user-version') {
run.findings.push({
code: 'MAP_FILE_KEPT',
severity: 'warning',
message: `${mapPath} was edited since the migration wrote it, so it was kept; check it maps every template id.`,
})
} else if (mapChange.sha256 !== undefined) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Clients use an unchecked map

rewriteImports continues changing clients after the map returns kept-user-version. The retained map may lack the newly required template entries, but those clients are still marked done and their manual steps disappear. This can replace a working Resend client with a Brew client that cannot find its templates. Block those rewrites until the retained map is checked or merged.

Prompt To Fix With AI
This is a comment left during a code review.
Path: src/lib/migrate/phase-code.ts
Line: 484-490

Comment:
**Clients use an unchecked map**

`rewriteImports` continues changing clients after the map returns `kept-user-version`. The retained map may lack the newly required template entries, but those clients are still marked done and their manual steps disappear. This can replace a working Resend client with a Brew client that cannot find its templates. Block those rewrites until the retained map is checked or merged.

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

Fix in Claude Code Fix in Codex Fix in Cursor

client: string
): CallSiteRecord {
const { analysis } = site
const first = analysis.messages[0] as MessageAnalysis

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Empty batches stop scanning

Scanning resend.batch.send([]) produces an empty messages array. callSiteRecord takes its first entry anyway, then passes undefined to sendLine, which crashes on message.to. Handle an empty batch as an empty or manual call site so one such call does not abort the code scan.

Prompt To Fix With AI
This is a comment left during a code review.
Path: src/lib/migrate/phase-code.ts
Line: 147

Comment:
**Empty batches stop scanning**

Scanning `resend.batch.send([])` produces an empty `messages` array. `callSiteRecord` takes its first entry anyway, then passes `undefined` to `sendLine`, which crashes on `message.to`. Handle an empty batch as an empty or manual call site so one such call does not abort the code scan.

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

Fix in Claude Code Fix in Codex Fix in Cursor

key,
component,
unsupported,
replacement: clean(run, replacement),

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 security Manifest keeps personal data

callSites[].replacement copies recipient and variable expressions into brew.migration.json. For example, to: 'alice@example.com' remains in the saved replacement because clean removes keys, not addresses or names. Users are told this file contains no personal data and is safe to commit. Replace literal values with placeholders before saving them.

How this was verified: Recipient source text is inserted into the replacement, the redactor leaves email literals unchanged, and the replacement is saved in the manifest.

Prompt To Fix With AI
This is a comment left during a code review.
Path: src/lib/migrate/phase-code.ts
Line: 228

Comment:
**Manifest keeps personal data**

`callSites[].replacement` copies recipient and variable expressions into `brew.migration.json`. For example, `to: 'alice@example.com'` remains in the saved replacement because `clean` removes keys, not addresses or names. Users are told this file contains no personal data and is safe to commit. Replace literal values with placeholders before saving them.

**How this was verified:** Recipient source text is inserted into the replacement, the redactor leaves email literals unchanged, and the replacement is saved in the manifest.

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

Fix in Claude Code Fix in Codex Fix in Cursor

Comment thread src/lib/migrate/phases.ts
Comment on lines +513 to +517
if (match !== undefined && item.brew?.audienceId !== match.audienceId) {
audienceName = `${name} (Resend)`
audienceId = rows.find(
(row) => row.audienceName === audienceName
)?.audienceId

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Audience uses the wrong filter

ensureAudience checks ownership of the original name but blindly reuses an audience named <name> (Resend). If both names already exist, the fallback audience may select entirely different contacts. The migration still marks the segment or topic done. Check the fallback audience's saved ID and filter too, or choose another unused name.

Prompt To Fix With AI
This is a comment left during a code review.
Path: src/lib/migrate/phases.ts
Line: 513-517

Comment:
**Audience uses the wrong filter**

`ensureAudience` checks ownership of the original name but blindly reuses an audience named `<name> (Resend)`. If both names already exist, the fallback audience may select entirely different contacts. The migration still marks the segment or topic done. Check the fallback audience's saved ID and filter too, or choose another unused name.

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

Fix in Claude Code Fix in Codex Fix in Cursor

@thedogwiththedataonit thedogwiththedataonit changed the title migrate resend (C5): move a Resend account and codebase to Brew migrate resend (C5, 0.19.0): move a Resend account and codebase to Brew Oct 16, 2026
if (domain === undefined) {
return
}
run.manifest.options.domain = domain

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Retries skip unsubscribe rows

resolveTargetDomain replaces the saved domain without checking which domain owns the saved progress. If an apply stops partway through importing suppressions into domain A, rerunning with domain B starts from A's saved cursor. Earlier suppressions never reach B, yet the phase can finish successfully.

Bind progress to the resolved domain ID and reject a changed destination, or restart the domain-specific work.

Prompt To Fix With AI
This is a comment left during a code review.
Path: src/lib/migrate/pipeline.ts
Line: 195

Comment:
**Retries skip unsubscribe rows**

`resolveTargetDomain` replaces the saved domain without checking which domain owns the saved progress. If an apply stops partway through importing suppressions into domain A, rerunning with domain B starts from A's saved cursor. Earlier suppressions never reach B, yet the phase can finish successfully.

Bind progress to the resolved domain ID and reject a changed destination, or restart the domain-specific work.

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

Fix in Claude Code Fix in Codex Fix in Cursor

Comment on lines +261 to +264
if (input.moduleSyntax === 'cjs' && !input.typescript) {
return [...header, `exports.${MAP_EXPORT} = {`, ...lines, '}', ''].join(
'\n'
)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Generated map cannot load

If a JavaScript project has "type": "module" and a .cjs sender, mapFileContent writes exports.brewResendTemplates into brew-resend-map.js. Node treats that .js file as an ES module, so the rewritten sender cannot load its map.

Use a .cjs map for CommonJS content, or choose the map's format from its own package settings.

Prompt To Fix With AI
This is a comment left during a code review.
Path: src/lib/migrate/code/rewrite.ts
Line: 261-264

Comment:
**Generated map cannot load**

If a JavaScript project has `"type": "module"` and a `.cjs` sender, `mapFileContent` writes `exports.brewResendTemplates` into `brew-resend-map.js`. Node treats that `.js` file as an ES module, so the rewritten sender cannot load its map.

Use a `.cjs` map for CommonJS content, or choose the map's format from its own package settings.

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

Fix in Claude Code Fix in Codex Fix in Cursor

Comment thread src/lib/migrate/phases.ts
)
)
// Pass 1: every contact's choices onto its record, page by page.
if (phase.counts?.passComplete !== 1) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 New topic choices are skipped

topicsPhase keeps passComplete after discovering new topics. After one apply completes the contact pass, a rerun can discover another topic but skip copying its choices. The later audience pass then marks that topic done even though no contacts received its field.

Reset the contact pass when the topic set changes, or track completion separately for each topic.

Prompt To Fix With AI
This is a comment left during a code review.
Path: src/lib/migrate/phases.ts
Line: 839

Comment:
**New topic choices are skipped**

`topicsPhase` keeps `passComplete` after discovering new topics. After one apply completes the contact pass, a rerun can discover another topic but skip copying its choices. The later audience pass then marks that topic done even though no contacts received its field.

Reset the contact pass when the topic set changes, or track completion separately for each topic.

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

Fix in Claude Code Fix in Codex Fix in Cursor

Comment on lines +223 to +231
const lineEnd =
requireAt === undefined
? 0
: text.indexOf('\n', requireAt.specifier.end)
const at = lineEnd === -1 ? text.length : lineEnd
edits.push({
start: at,
end: at,
replacement: `${at === 0 ? '' : '\n'}const { ${MAP_EXPORT} } = require(${quote}${specifier}${quote})${at === 0 ? '\n' : ''}`,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Rewritten CommonJS senders cannot load

rewriteFile inserts the map's require after the whole line containing require('resend'). If that line also creates the client, as in const { Resend } = require('resend'); const resend = new Resend(process.env.RESEND_API_KEY), the rewritten constructor reads brewResendTemplates before its declaration. Loading the file then throws, breaking all sends through it. Insert the map declaration before any rewritten constructor.

Prompt To Fix With AI
This is a comment left during a code review.
Path: src/lib/migrate/code/rewrite.ts
Line: 223-231

Comment:
**Rewritten CommonJS senders cannot load**

`rewriteFile` inserts the map's `require` after the whole line containing `require('resend')`. If that line also creates the client, as in `const { Resend } = require('resend'); const resend = new Resend(process.env.RESEND_API_KEY)`, the rewritten constructor reads `brewResendTemplates` before its declaration. Loading the file then throws, breaking all sends through it. Insert the map declaration before any rewritten constructor.

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

Fix in Claude Code Fix in Codex Fix in Cursor

Comment thread src/lib/migrate/run.ts
Comment on lines +188 to +195
export function rethrowIfFatal(ctx: CliContext, error: unknown): void {
ctx.signal.throwIfAborted()
if (
(error instanceof BrewApiError || error instanceof CliApiError) &&
(error.status === 401 || error.status === 403)
) {
throw error
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Key failures lose authentication instructions

If a Resend key is revoked during segment-member or topic-choice reads, the phase catches ResendAccessError and turns it into a generic failure. rethrowIfFatal only passes Brew authentication errors through, so the outer Resend handler never returns exit 3 or adds the replacement-key instructions. Users instead get exit 1 without that next step. Let ResendAccessError reach the existing authentication handler.

Prompt To Fix With AI
This is a comment left during a code review.
Path: src/lib/migrate/run.ts
Line: 188-195

Comment:
**Key failures lose authentication instructions**

If a Resend key is revoked during segment-member or topic-choice reads, the phase catches `ResendAccessError` and turns it into a generic failure. `rethrowIfFatal` only passes Brew authentication errors through, so the outer Resend handler never returns exit 3 or adds the replacement-key instructions. Users instead get exit 1 without that next step. Let `ResendAccessError` reach the existing authentication handler.

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

Fix in Claude Code Fix in Codex Fix in Cursor

edits.push({
start: locations.importInsertAt,
end: locations.importInsertAt,
replacement: `${locations.importInsertAt === 0 ? '' : '\n'}import { ${MAP_EXPORT} } from ${quote}${specifier}${quote}${locations.importInsertAt === 0 ? '\n' : ''}`,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Rewritten imports break file loading

rewriteFile breaks valid one-line ESM files such as import { Resend } from 'resend'; const resend = new Resend(process.env.RESEND_API_KEY);. It inserts the map import after the original import but adds neither a semicolon nor a trailing newline. The result contains from './brew-resend-map' const resend, which cannot parse, so sends through that file stop working.

End the inserted import with a semicolon or newline before marking the file done.

Suggested change
replacement: `${locations.importInsertAt === 0 ? '' : '\n'}import { ${MAP_EXPORT} } from ${quote}${specifier}${quote}${locations.importInsertAt === 0 ? '\n' : ''}`,
replacement: `${locations.importInsertAt === 0 ? '' : '\n'}import { ${MAP_EXPORT} } from ${quote}${specifier}${quote}\n`,
Prompt To Fix With AI
This is a comment left during a code review.
Path: src/lib/migrate/code/rewrite.ts
Line: 217

Comment:
**Rewritten imports break file loading**

`rewriteFile` breaks valid one-line ESM files such as `import { Resend } from 'resend'; const resend = new Resend(process.env.RESEND_API_KEY);`. It inserts the map import after the original import but adds neither a semicolon nor a trailing newline. The result contains `from './brew-resend-map' const resend`, which cannot parse, so sends through that file stop working.

End the inserted import with a semicolon or newline before marking the file done.

```suggestion
        replacement: `${locations.importInsertAt === 0 ? '' : '\n'}import { ${MAP_EXPORT} } from ${quote}${specifier}${quote}\n`,
```

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

Fix in Claude Code Fix in Codex Fix in Cursor

thedogwiththedataonit added a commit that referenced this pull request Oct 17, 2026
…ion)

The bundled spec is #61's plus exactly the spec deltas of brew-v2
claude/clerk-email-created 12a02af5e3 over 0fed90fbf3 (the kit's Clerk
description) and claude/supabase-send-email-hook c3da555e60 over
12a02af5e3: `GET|PUT /v1/integrations/{provider}/connection`
(`getIntegrationConnection`, `connectIntegrationHook`; providers
`supabase_auth` and `clerk`), the `IntegrationConnection*` schemas and
`supabase_auth` in the integration catalog. No other operation changes.
Generated types regenerated. Re-vendor from brew-v2 main once both merge.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
thedogwiththedataonit added a commit that referenced this pull request Oct 20, 2026
…ion)

The bundled spec is #61's plus exactly the spec deltas of brew-v2
claude/clerk-email-created 12a02af5e3 over 0fed90fbf3 (the kit's Clerk
description) and claude/supabase-send-email-hook c3da555e60 over
12a02af5e3: `GET|PUT /v1/integrations/{provider}/connection`
(`getIntegrationConnection`, `connectIntegrationHook`; providers
`supabase_auth` and `clerk`), the `IntegrationConnection*` schemas and
`supabase_auth` in the integration catalog. No other operation changes.
Generated types regenerated. Re-vendor from brew-v2 main once both merge.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
thedogwiththedataonit added a commit that referenced this pull request Oct 20, 2026
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@thedogwiththedataonit thedogwiththedataonit changed the title migrate resend (C5, 0.19.0): move a Resend account and codebase to Brew migrate resend (C5, 0.20.0): move a Resend account and codebase to Brew Oct 20, 2026
thedogwiththedataonit added a commit that referenced this pull request Oct 21, 2026
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Comment thread src/lib/migrate/phases.ts
Comment on lines +593 to +599
const body = {
contacts: page.rows.map((row) => ({
email: row.email,
customFields: { [fieldName]: true },
...(row.unsubscribed === true ? { subscribed: false } : {}),
})),
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Selected phases omit consent history

Running --only segments or --only topics can create subscribed contacts without recording where their consent came from. These phases omit the consent record that contactsPhase adds, even though they can run without it. Later inline marketing sends can warn that those contacts have no consent record.

Add import consent for subscribed rows in both phases so they keep the same consent history as a full migration.

Prompt To Fix With AI
This is a comment left during a code review.
Path: src/lib/migrate/phases.ts
Line: 593-599

Comment:
**Selected phases omit consent history**

Running `--only segments` or `--only topics` can create subscribed contacts without recording where their consent came from. These phases omit the `consent` record that `contactsPhase` adds, even though they can run without it. Later inline marketing sends can warn that those contacts have no consent record.

Add import consent for subscribed rows in both phases so they keep the same consent history as a full migration.

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

Fix in Claude Code Fix in Codex Fix in Cursor

thedogwiththedataonit and others added 4 commits October 25, 2026 22:33
…o Brew

Plan by default, `--apply` to execute (destructive class). The Resend key
comes from RESEND_API_KEY or --resend-key-stdin, rides only requests to
api.resend.com (paced to 2 a second, 429s waited out) and never reaches
Brew, the terminal or the manifest.

- Templates → app emails by key: imported with `placeholders: "resend"`
  and their variables' fallbacks, set up by key from the converted HTML,
  published where they were; the intermediate import is deleted.
- React Email files → app emails with `props: "merge_tags"`.
- Contacts → upsertMany (subscribed with an import consent record,
  unsubscribed stay unsubscribed, no state left out); unsubscribed ones
  and suppressions → `--domain`'s unsubscribe list.
- Segments and topics → a boolean contact field each plus an audience;
  topic choices are read per contact.
- Draft broadcasts → Brew draft emails (nothing is sent); sent ones,
  domains, webhooks (origin only) and automations are reported.
- A Resend domain also in Brew gets the DKIM_SELECTOR_IN_USE advice
  (both sign with resend._domainkey) and a subdomain to verify instead.
- The code scan finds every `emails.send` / `batch.send` (not those in
  comments, strings or regexes), says what does not carry over, and
  suggests `getBrew().send(key, payload)`; `--rewrite-imports` moves a file
  to `@brew.new/sdk/resend` only when every send through it is a migrated
  template.
- `brew.migration.json`: ids, keys, counts and cursors (never an address,
  a name or a key), so an interrupted apply resumes. Every Brew write
  carries an idempotency key.
- Sources implement `MigrationSource`; Resend is the only one.
- The `brew-migrate-from-resend` skill; init installs it only where the
  project uses Resend. `lib/trigger-key.ts` reuses the one grammar in
  `lib/project/state.ts`.

MSW tests cover the plan (no Brew writes, ≥500 ms between Resend
requests, no PII or secret in the manifest), --dry-run, --only code
without a key, the sending-only key (exit 3), --apply across every phase,
resume from a saved cursor, and --rewrite-imports; every run checks
stdout and stderr for the Resend key, the Brew key and a webhook token.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
package.json and both launchers go to 0.19.0 (`sync:launchers`).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…_RANGE

The rewritten files import `@brew.new/sdk/resend` (SDK 13.2.0), but the
install-sdk next step only appeared when no SDK was declared at all, so
an app on 12.3 to 13.1 got no step and a build that cannot resolve the
adapter. `hasSdk` now uses init's `reachesSdkRange`, the same threshold
the init install step uses.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
PUT /v1/app-emails/{triggerKey} `source` takes `props`, `placeholders`
and `variables` (GetBrew/brew-v2#2145 b2912ad9f7). The vendored spec is
28-line `AppEmailPutRequest.source` delta; types regenerated.

`app-emails upsert --input` already passed the modes through; with
`--source-file` it replaced `--input`'s whole `source`, dropping them.
The file now fills `format` and `content` and keeps the rest, and the
format falls back to `--input`'s `source.format` (stdin). New example:
`--source-file emails/order-shipped.tsx --input '{"source":{"props":"merge_tags"}}'`.
`migrate resend` names that command for a React Email file it leaves to
move by hand, so its props stay merge tags like the imported ones.

RELEASING: the API gate names #2066, #2064 and #2145.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
thedogwiththedataonit and others added 2 commits October 25, 2026 22:33
- A template or React Email file whose key's trigger already starts a
  published automation no setup made (409 APP_EMAIL_TRIGGER_IN_USE) is
  left `manual` with the API's message instead of failing the run: a
  re-run alone repeats the refusal, and the next run after the person
  unpublishes that automation sets it up (non-done items re-plan).
- The #2064 and #2145 spec commits fold into #54's vendored #2169 spec;
  CHANGELOG, RELEASING and comments name brew-v2 #2169 @ 6f8ef50544.
- Regenerated src/generated/skills.ts after the restack.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ANGE ^13.6.0

After main's published 0.18.0 the DX numbers move up one: #54 0.19.0, #55
0.20.0, this PR 0.21.0. A declared ^13.3.0 or ^13.5.0 still gets the
install-sdk next step; ^13.6.0 does not.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
thedogwiththedataonit added a commit that referenced this pull request Oct 25, 2026
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
thedogwiththedataonit added a commit that referenced this pull request Oct 25, 2026
After main's published 0.18.0 the DX numbers move up one (#54 0.19.0, #55
0.20.0, #61 0.21.0, this PR 0.22.0), and the SDK's DX releases follow its
published 13.3.0 and pending 13.4.0: adapters 13.6.0 (SDK_RANGE), connect
13.7.0. doctor --project's tests use those ranges.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@thedogwiththedataonit thedogwiththedataonit changed the title migrate resend (C5, 0.20.0): move a Resend account and codebase to Brew migrate resend (C5, 0.21.0): move a Resend account and codebase to Brew Oct 25, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant