Recipes

migrate-email-resend 스킬: Resend 전송을 posthog-node emails.send로 재작성

README 링크에서 발견 · today

Software & DevOpsWork & Operations

Resend 전송 지점을 PostHog로 다시 작성하고 실제 이메일 한 통을 검증하는 스킬입니다.

What they did

작성자는 PostHog의 context-mill 저장소에 있는 기존 `migrate-email-resend` 스킬을 확장하여, Resend 전송을 찾고 계정을 준비하는 단계에서 멈추지 않도록 했다. 기존의 보고 단계는 세 단계로 대체되었다. 7단계(재작성)는 posthog-node를 설치하거나 업그레이드하고, 이동 대상 사이트에 필요한 사항을 설치된 타입에서 확인하며, `secretKey`와 `migratedFrom: 'resend'`로 서버 측 클라이언트 하나를 설정한다. 계획 행에서 이동한다고 표시된 각 사이트는 두 가지 형태 중 하나로 재작성된다. 코드에서 온 콘텐츠(React 사이트는 `@react-email/render`로 렌더링하여 결과를 `html`로 전송하고, `html` 또는 `text` 사이트는 콘텐츠를 그대로 전송하며, 각 전송에는 이메일 종류에 따라 이름 붙인 `templateKey`가 부여된다)이거나, PostHog에서 만든 템플릿이다(사용자가 템플릿을 만들고, 트랜잭션으로 설정하고, 3단계에서 템플릿 ID를 제공하면, 사이트는 Resend의 `variables`를 대체하는 `templateData`와 함께 `templateId`를 전송한다). `from`, `replyTo`, 멱등성 키, `error.name` 분기는 계속 작동한다. `distinctId`는 앱이 이미 사용자를 식별하는 데 쓰는 ID이다. 억제된 주소로의 전송은 `recipient_suppressed`를 전송된 이메일처럼 처리하는 분기를 갖는다. `cc`, `bcc`, `headers`, `tags`, `attachments`, 예약 전송, 일괄 전송은 Resend에 남는다. 8단계(검증)는 7단계의 기준선에 대해 타입체크를 실행하고 변경된 파일의 테스트를 실행한 뒤, 동의를 받아 실제 이메일 한 통을 보내고 MCP의 `execute-sql`을 통해 `$workflows_email_sent`, 같은 워크플로의 참여 이벤트, 같은 distinct ID의 제품 이벤트를 읽는다. 9단계(보고)는 이동한 사이트, 이동할 수 있게 해줄 것이 무엇인지와 함께 남는 사이트, 검사 항목, "전환 전" 목록(각 PostHog 템플릿 테스트 포함), 그리고 Resend를 언제 제거할지에 대한 "전환 후" 참고 사항을 나열한다. 3단계는 Resend 템플릿마다 한 번씩 이동할지 유지할지 묻고, 콘텐츠를 읽어 분류하며, PostHog 템플릿 ID와 발신자 주소를 요청한다. 4단계는 해당 주소에 대한 발신자를 설정한다. `mapping.md`에는 재작성 규칙이 추가되었고 `account.md`는 템플릿의 콘텐츠를 읽는 방법을 다룬다. 이후 개정에서는 7단계에 "이메일을 사용자에게 연결"을 추가하여, 앱이 이미 `identify()`에 전달하는 `distinctId`를 전달함으로써 전송과 그 전송/열람/클릭 이벤트가 사용자의 person에 귀속되도록 했다.

How it went

번들 테스트(vitest)는 `1-detect`부터 `6-key`까지의 단계, 이어서 `7-rewrite`, `8-verify`, `9-report`를 기대한다. `d157859`에서 `pnpm test`는 29개 파일에서 225개 테스트를 통과하고 `pnpm build`는 `migrate-email-resend.zip`을 빌드한다. Sonnet 5.5의 Claude Code로 읽기 전용 도구를 사용해 픽스처 앱에서 두 차례 헤드리스 드라이 런을 수행했으며, 픽스처 앱에는 네 건의 Resend 전송(React email, replyTo가 있는 html 및 text, Resend 템플릿 `invoice`, 그리고 첨부 파일이 있는 한 건)이 있었고 픽스처 파일은 변경되지 않았다. keep 실행에서는 4개 사이트 중 2개가 이동하도록 계획되었다. React email은 `render(<WelcomeEmail … />)`로 렌더링되고 `templateKey: 'welcome'`과 함께 `html`을 전송한다. html/text email은 `replyTo`를 유지한다. `invoice`는 유지되고(`kept on Resend for now`), 첨부 파일 email도 유지된다(`attachments`). 클라이언트는 `migratedFrom: 'resend'`를 설정한다. move 실행에서는 `invoice`가 `templateId`와 `templateData: { INVOICE_URL: invoiceUrl }`와 함께 이동하며, 해당 `from` 컬럼에는 템플릿의 발신자가 들어 있어 Step 4가 그 도메인을 커버하고, "Before cutover"에는 해당 템플릿의 테스트가 나열된다. 세 차례의 리뷰 라운드와 GPT-6.1 Sol 및 Opus 5.5의 최종 읽기에서 미해결 발견 사항은 없었다.

Worth knowing

`templateKey`와 `migratedFrom`가 포함된 posthog-node 릴리스(PostHog/posthog-js#5509)가 아직 나오지 않아서, 머지는 보류되었고 코드를 작성하고 타입체크한 뒤 전송까지 완료한 run은 없습니다. PostHog MCP가 연결된 run도 없어서 Step 8의 이벤트 확인은 검증되지 않았고, Wizard 내부에서 진행된 run도 없습니다. 재작성은 사용자의 코드와 Resend 템플릿을 읽는데, PostHog는 이를 저장하지 않으므로 프로덕션 형태는 검증되지 않은 상태입니다. 이제 스킬이 전송 코드를 수정하므로, 잘못된 재작성은 배포 후 메일 발송 방식을 바꿀 수 있습니다(중간 수준의 머지 리스크, 양방향 문). 테스트 이메일 매칭에는 전송별 ID가 없어서, 동일한 사람에게 동일한 이메일을 이전에 전송했을 때 발생한 지연 이벤트가 카운트될 수 있습니다. 템플릿 콘텐츠는 저장소 외부에 저장된 대시보드 사본이나, 사용자가 Step 5 마지막에 삭제하는 전체 액세스 키를 사용하는 Resend API에서 가져옵니다.

Wiring

The skill lives in the context-mill repo as a bundled skill (`migrate-email-resend.zip` built by `pnpm build`, tested with vitest). It runs as ordered steps: `1-detect` through `6-key` (from the stacked PR #450) find sends and prepare the account, then `7-rewrite`, `8-verify`, `9-report`. Step 3 asks the person once per Resend template whether to move or keep it, reads its content to classify it, and collects the PostHog template ID and sender address; Step 4 sets up a sender for that address. Step 7 installs or upgrades posthog-node, checks installed types for `templateKey` and `migratedFrom`, creates one server-side client with `secretKey` and `migratedFrom: 'resend'`, and rewrites each moving site to `posthog.emails.send` with either `templateKey` plus rendered `html`/`text` or `templateId` plus `templateData`. Step 8 typechecks against Step 7's baseline, runs tests of changed files, then with consent sends one real email and reads events via the PostHog MCP's `execute-sql`. Step 9 writes the report with real `sites_rewritten`. `mapping.md` holds the rewrite rules and `account.md` covers reading template content. The skill depends on a posthog-node release containing PostHog/posthog-js#5509 and is stacked on PR #450, which must merge first.

Try it yourself

I can't run the `migrate-email-resend` skill or execute anything against your repo — I don't have tool access here, so I can't scan for Resend sends, typecheck, run tests, or send a real email. What I can do is give you the exact plan and the decision rules so you (or an agent with repo access) can execute it. Here's the dry-run design.

## Step 1 — Inventory every Resend send

Search for all call sites, e.g.:

- `resend.emails.send`, `resend.emails.create`, `resend.batch.send`
- `new Resend(`, `from 'resend'`, `from '@react-email/...'`
- `@react-email/render`, `render(`
- `scheduledAt`, `attachments`, `cc:`, `bcc:`, `headers:`, `tags:`
- `idempotencyKey`, `error.name`

For each site record: file:line, trigger, recipient source, `from`, `replyTo`, subject, body source (React / html / text / Resend template ID), and any of the "keep on Resend" features above.

## Step 2 — Classify each site

**Move to `posthog.emails.send` (content-from-code):**
- React component rendered via `@react-email/render` → `html` → assign a `templateKey`.
- Existing `html` or `text` string → assign a `templateKey`.
- Preserve `from`, `replyTo`, idempotency key, and any `error.name` branching.

**Move (Resend template) — only if you provide:**
- The PostHog transactional template ID, and
- The sender address.
Otherwise it stays on Resend.

**Stay on Resend (with written reason):**
- `cc` / `bcc` — PostHog `emails.send` doesn't take them.
- Custom `headers` — not supported.
- `tags` — not supported.
- `attachments` — not supported.
- `scheduledAt` — no scheduling.
- Batch sends — no batch API.

## Step 3 — Client setup

One server-side module:

```ts
import { PostHog } from 'posthog-node'

export const posthog = new PostHog(process.env.POSTHOG_API_KEY!, {
  host: process.env.POSTHOG_HOST,
})

// per send:
await posthog.emails.send({
  templateKey: 'welcome-email',
  from: '[email protected]',
  replyTo: '[email protected]',
  distinctId,                 // the same ID the app passes to identify()
  idempotencyKey,
  properties: { /* template vars */ },
  migratedFrom: 'resend',
})
```

Rules to enforce:
- `secretKey` from env, never inlined.
- `migratedFrom: 'resend'` on every migrated send.
- `distinctId` = the app's existing `identify()` ID.
- Wrap in try/catch; keep the existing `error.name` branches. Treat `recipient_suppressed` as success (email considered sent).

## Step 4 — Verification (needs your consent + repo access)

1. Typecheck against the pre-rewrite baseline (stash or branch compare).
2. Run tests for changed files only.
3. With your explicit consent, send one real email, then confirm on the same `distinctId`:
   - `$workflows_email_sent`
   - an engagement event from the same workflow
   - a product event

## Report template

**Moved sites**
| File:line | Trigger | templateKey | Notes |
|---|---|---|---|

**Stays on Resend**
| File:line | Reason |
|---|---|

**Before cutover checklist**
- [ ] Each PostHog template created and test-sent to yourself
- [ ] `from` / `replyTo` verified per template
- [ ] `distinctId` source confirmed matches `identify()`
- [ ] `error.name` branches preserved (incl. `recipient_suppressed` → success)
- [ ] Idempotency keys carried over
- [ ] Typecheck clean vs baseline
- [ ] Tests for changed files pass
- [ ] One real send verified: `$workflows_email_sent` + engagement + product event on same `distinctId`

**After cutover**
- Remove `resend` dependency and `RESEND_API_KEY` from env/secrets.
- Delete dead Resend call sites and unused React email templates (or keep if still used by remaining Resend sites).
- Update docs/runbooks referencing Resend.

If you paste the repo (or the relevant files) here, I'll fill in the actual inventory and per-site decisions.

Source

English

Tools in this stack