Skip to content
Draft
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 context/shared/email-node-client.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
title: Configure the posthog-node client for email
---

- Reuse the app's existing `posthog-node` client when it has one, and add `secretKey`. Keep `enableLocalEvaluation: false` unless the key also has `feature_flag:read` and the app uses local evaluation.
- When the project token or the host is not configured yet, get the token from the PostHog MCP `projects-get` tool (`api_token`). The host is `https://us.i.posthog.com` for US Cloud and `https://eu.i.posthog.com` for EU Cloud. Ask the person which one when you can't tell.
- When `host` is a reverse proxy or a self-hosted instance, also set `appHost` to the PostHog app URL, such as `https://us.posthog.com`. posthog-node never sends the secret key to a proxy, and without `appHost` the send fails with `missing_app_host`.
2 changes: 1 addition & 1 deletion context/skills/migrate-email/config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
type: skill
template: description.md
category: migrate
description: Migrate transactional email from {display_name} to PostHog. Use when asked to migrate from {display_name}, replace {display_name} with PostHog, or switch email providers. It finds every {display_name} send in the code, sets up the sender and DNS next to {display_name}'s records, imports {display_name}'s suppression list, hands over the PostHog secret key, and writes a migration report. It changes no send call site yet.
description: Migrate transactional email from {display_name} to PostHog. Use when asked to migrate from {display_name}, replace {display_name} with PostHog, or switch email providers. It finds every {display_name} send in the code, sets up the sender and DNS next to {display_name}'s records, imports {display_name}'s suppression list, moves each send PostHog can take to posthog-node, and writes a migration report that lists the sends that stay.
tags: [migration, workflows]
cli:
role: command
Expand Down
12 changes: 6 additions & 6 deletions context/skills/migrate-email/description.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

Use this skill to move an app's transactional email, such as welcome emails, password resets, and receipts, from {display_name} to PostHog.

This version prepares the move and changes no send call site. It finds every {display_name} send, sets up the PostHog sender and its DNS records next to {display_name}'s, imports {display_name}'s suppression list, hands over the PostHog secret key, and writes a report. Both providers can send during the cutover.
It finds every {display_name} send, sets up the PostHog sender and its DNS records next to {display_name}'s, imports {display_name}'s suppression list, and hands over the PostHog secret key. Then it moves every send PostHog can take to `posthog-node`, checks one email on one person, and writes a report. Sends PostHog can't take stay on {display_name} with a reason, so both providers send during the cutover.

The transactional email API is in beta. PostHog enables it per project.

Expand All @@ -17,8 +17,8 @@ Read the step files in order, one at a time. Each step names the next one. Do no
The folder `references/<provider>/` holds everything specific to {display_name}. The steps tell you when to read each file:

- `sdk-reference.md`: how {display_name} looks in code, so you can find every send.
- `mapping.md`: how each send field maps to PostHog, and what keeps a call site on {display_name}.
- `account.md`: {display_name}'s DNS records and how to export its suppression list.
- `mapping.md`: how each send field maps to PostHog, what keeps a call site on {display_name}, and how to rewrite a site that moves.
- `account.md`: {display_name}'s DNS records, and how to get its templates and its suppression list.

Everything you need is on disk. Do not fetch {display_name}'s docs.

Expand All @@ -28,10 +28,10 @@ The plan file `.posthog-email-migration-plan.md` lives at the project root. Step

## Rules

- Change no send call site. This version only prepares the move.
- Change send code only in Step 7, and fix it only in Step 8: the sites that move, and the helpers, callers, and tests they need. Leave every site that stays as it is.
- Never touch DNS or the {display_name} account. Give the person the records and the links, and let them make the change.
- Never remove the {display_name} package, its client, or its API key.
- Never create, upload, or edit a template in PostHog.
- Never remove the {display_name} package or its API key. The report tells the person when to remove them.
- Never create, upload, or edit a template in PostHog. A moved local template uploads on its first send.
- Never write a recipient's email address into a file in the repository, the plan, or the report. Count addresses instead.
- Keep keys in environment variables. Never hardcode a key, and never ask the person to paste one into the chat.
- Do not commit. The person reviews the diff and commits.
Expand Down
4 changes: 4 additions & 0 deletions context/skills/migrate-email/references/3-inventory.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,8 @@ Go through `mapping.md` field by field. A site **moves** when PostHog can send e

When the code passes a value you can't read, such as a list of recipients built at run time, follow it to its callers. When you still can't tell, the site stays, and the reason says what you could not check.

A `template` site needs the template's content to be classified. Read the provider's `account.md`, and ask the person once for every template the sites send: copy from the dashboard, or read through the API. Keep the content for Step 7, and never write it into the plan. When the person can't give the content, the site stays.

## Write the plan file

Write `.posthog-email-migration-plan.md` at the project root with three sections. Never write an email address of a recipient into it. A `from` address is fine.
Expand All @@ -51,6 +53,8 @@ A phase checklist, in this order. Tick `preflight` with what Step 2 recorded, an
- [ ] sender
- [ ] suppressions
- [ ] key
- [ ] rewrite
- [ ] verify
```

A sites table, one row per site, in source tree order:
Expand Down
2 changes: 1 addition & 1 deletion context/skills/migrate-email/references/6-key.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
---
title: Hand over the PostHog secret key
next_step: 7-report.md
next_step: 7-rewrite.md
---

# Step 6, hand over the PostHog secret key
Expand Down
69 changes: 69 additions & 0 deletions context/skills/migrate-email/references/7-rewrite.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
---
title: Move every site that can move
next_step: 8-verify.md
---

# Step 7, move every site that can move

Rewrite each site whose `moves` is `yes` in the plan to send with `posthog-node`. The provider's `mapping.md` has the rewrite of each field, call, and content form under "Rewrite".

## Status

```
[STATUS] Setting up posthog-node for email
[STATUS] Moving the send call sites
```

## Before you change code

- Change only the sites whose `moves` is `yes`. Never change a site that stays, and never change a line that a site that stays still needs.
- When no site moves, tick the phase in the plan with `none moved` and go to the next step.

## Set up the client

Install or upgrade `posthog-node` with the project's package manager, to a version that has `posthog.emails`.

Configure one server-side client for email. Set `migratedFrom` to the provider folder name, such as `resend`, once on the client, so PostHog knows which sends a migration moved:

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

export const posthog = new PostHog(process.env.POSTHOG_PROJECT_TOKEN, {
host: process.env.POSTHOG_HOST,
secretKey: process.env.POSTHOG_PROJECT_SECRET_API_KEY,
migratedFrom: '<provider folder name>',
enableLocalEvaluation: false,
})
```

{{> email-node-client}}
- A reused client gets `migratedFrom` too.
- Put a new client in its own server-only module, next to the provider's client, and import it where the sites send.
- Use the env variable names the app already uses for the PostHog token and host. Add each new name, without a value, to the env example file.

## Find the user ID

PostHog shows each email on the person the send names with `distinctId`. Use the ID the app already identifies people with in PostHog:

- Find the app's identify call, such as `posthog.identify(user.id)` in posthog-js, or the `distinctId` of its `posthog-node` captures.
- At each site, pass the same ID for the recipient, such as `distinctId: user.id`. Follow the recipient back through the callers when the site only has the address.
- When the recipient is not a user of the app, such as a team inbox, or the site has no way to know the user, leave `distinctId` out and note it in the plan row.
- When the app identifies no one in PostHog, use the app's own user ID, and note it for the report.

## Rewrite each site

For each site that moves, in plan order:

- Rewrite the call as the provider's `mapping.md` says, for the site's content form: `html` and `text` content, a component the app renders, or a template stored at the provider.
- Give each send a `templateKey` that names the kind of email, such as `welcome` or `password-reset`, with letters, digits, `-`, and `_` only. Two sites that send the same email share a key. A template stored at the provider keeps the key `mapping.md` gives it.
- A template stored at the provider becomes a module that exports `subject` and `html`, and `text` when it has one, in an `emails/` folder next to the server code. The site imports it and passes `templateData`.
- Install `@react-email/render` with the project's package manager when a site renders a component and the app doesn't have it.
- Keep the site's idempotency key, error branches, logging, and return value working as before. Keep the code around the send unchanged where you can.
- `posthog.emails.send` never throws on an API error: it returns `{ data, error }`. When the old call threw on an error, or the code checked an HTTP status, check `error` so the site fails the same way.
- Never add a field that the old call didn't set, apart from `templateKey`, `distinctId`, and the template defaults `mapping.md` names.

When a site turns out to need something `mapping.md` keeps on the provider, undo its change, set `moves` to `no` with the reason, and go on.

## Write down

Tick the phase in the plan: `- [x] rewrite — <n> sites moved`. Add a `key` column to the sites table with each moved site's `templateKey`. Under the table, note each site without a `distinctId`, each fallback that an empty string now replaces, each site that got a branch for a suppressed recipient, each idempotency key a retry across the deploy could send twice, each new dependency, and each new env variable name.
48 changes: 48 additions & 0 deletions context/skills/migrate-email/references/8-verify.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
---
title: Check the moved sites
next_step: 9-report.md
---

# Step 8, check the moved sites

Check that the app still builds, its tests still pass, and one moved email reaches one person in PostHog. When no site moved in Step 7, tick the phase with `nothing to check` and go to the next step.

## Status

```
[STATUS] Checking the moved sites
[STATUS] Sending a test email
```

## Build and tests

- Run the project's typecheck, such as its `typecheck` script or `tsc --noEmit`, or its build when it has no typecheck. Fix each error in a line you changed. An error in a file you didn't change was there before: note it, and leave it.
- Run the tests that cover the files you changed. A test that mocks the provider's client or intercepts its HTTP requests for a moved site now mocks `posthog.emails.send` and asserts on the fields it gets, with the same expected values. Never turn a PostHog send back into the provider's request shape to keep an old assertion. Never delete or skip a test.
- Search the changed files for the provider's calls: a moved site has none left, and each moved site has one `posthog.emails.send`.

## One email on one person

The chain to prove: PostHog sends the email, records its engagement on the person, and that person's product events show next to it.

Skip this part and record `not checked` with the reason when the API is not confirmed on, no sender is verified, or `POSTHOG_PROJECT_SECRET_API_KEY` is not set.

1. Ask the person whether to send one test email. Say that it sends a real email, and that the first send of a local template uploads it to the PostHog project. When they say no, record `not checked: declined`.
2. Ask them to trigger one moved email in their local app while they are signed in as themselves, such as a signup or a password reset, so the email goes to an address they own. Ask them to tell you when it is sent and to open the email.
3. When the send fails, find its code in `transactional-email-api.md`, tell the person the fix, and record it. Never retry a send yourself.
4. Find the person's distinct ID: the value the moved site passed as `distinctId`. Run a SQL query through the PostHog MCP `execute-sql` tool for that distinct ID over the last hour:

```sql
SELECT event, timestamp FROM events
WHERE distinct_id = '<distinct id>' AND timestamp > now() - INTERVAL 1 HOUR
ORDER BY timestamp
```

5. The chain holds when the result has `$workflows_email_sent`, an engagement event such as `$workflows_email_delivered` or `$workflows_email_opened`, and at least one product event of the app. Delivery events arrive after the provider reports back: when only the send shows, wait a minute and run the query once more.

When the MCP has no SQL tool, ask the person to open their person in PostHog and tell you which of the three they see.

Never write the address or the distinct ID into a file.

## Write down

Tick the phase in the plan: `- [x] verify — typecheck: <passed|failed|none>, tests: <passed|failed|none>, chain: <proven|partly: what showed|not checked: why>`.
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ title: Write the migration report
next_step: null
---

# Step 7, write the migration report
# Step 9, write the migration report

The report comes from the plan file. Invent nothing.

Expand All @@ -20,14 +20,16 @@ Read `.posthog-email-migration-plan.md`. Write `posthog-migrate-email-<provider>
```markdown
# Email migration report: <provider name> to PostHog

<One or two sentences: how many sites were found, how many can move, how many stay, and whether the sender and the suppression list are ready.>
<One or two sentences: how many sites were found, how many moved, how many stay, and whether the sender and the suppression list are ready.>

## Send call sites

| File | Line | Content | Status |
|------|------|---------|--------|
| File | Line | Content | Key | Status |
|------|------|---------|-----|--------|

<One row per site. Status is "Ready to move" or "Stays on <provider name>: <reason>".>
<One row per site. Key is the `templateKey` of a moved site. Status is "Moved", "Moved without distinctId: <why>", or "Stays on <provider name>: <reason>".>

<Each template that moved into the code, with its module path, and each fallback that an empty string now replaces.>

## Other <provider name> calls

Expand All @@ -45,13 +47,23 @@ Read `.posthog-email-migration-plan.md`. Write `posthog-migrate-email-<provider>

<Where POSTHOG_PROJECT_SECRET_API_KEY is set, and each other place the provider key lives that needs the PostHog key too.>

## Checks

<The typecheck, the tests, and the test email: what passed, what failed, and what was not checked and why. Say which of the send, the engagement event, and the product event showed on one person.>

## Sending tier

<What the person found, or that it was not checked.>

## Before cutover

<A numbered list of everything the person still has to do. Include each unverified domain, a missing suppression import, each place that needs the PostHog key, a tier that is too low or not checked, an API that is not confirmed, and each site that stays with what would let it move. When nothing is left, write "Nothing left before cutover.">
The moved sites send through PostHog as soon as this code is deployed. Everything in this list has to be done before that deploy.

<A numbered list of everything the person still has to do. Include each unverified domain, a missing suppression import, each place that needs the PostHog key and each new env variable, a tier that is too low or not checked, an API that is not confirmed, a failed check, and each site that stays with what would let it move. When nothing is left, write "Nothing left before cutover.">

## After cutover

<What the person removes once the moved emails arrive from PostHog: when no site stays and no other provider call is left, the provider's package and its key in every place listed above. Otherwise, write that the provider stays for the sites and calls listed above.>

## Summary
```
Expand All @@ -61,21 +73,19 @@ End the report with the summary block, as the last thing in the file. The Wizard
```yaml
from: resend
sites_found: 3
sites_rewritten: 0
sites_rewritten: 2
sites_left: 1
dns_ready: false
suppressions_ready: true
```

- `from`: the provider folder name.
- `sites_found`: every row of the sites table.
- `sites_rewritten`: the sites this run changed to send with PostHog. This version changes none, so it is `0`.
- `sites_rewritten`: the sites this run moved to send with PostHog.
- `sites_left`: the sites that stay on the provider.
- `dns_ready`: `true` when every sending domain has a verified sender.
- `suppressions_ready`: `true` when the suppression list was imported or was empty.

Sites that are ready to move count in `sites_found` only.

After the report is written, print its path:

```
Expand Down
18 changes: 16 additions & 2 deletions context/skills/migrate-email/references/resend/account.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,18 +18,32 @@ A domain verified at Resend carries these records. Keep all of them until the cu
- PostHog's DKIM records have their own names, so they sit next to `resend._domainkey`.
- Keep the one `_dmarc` record. Never add a second one.

## Full-access key

The app's own Resend key is usually a sending-only key, and it can't read templates or the suppression list. When the person chooses the API over the dashboard, ask them to create a full-access key in Resend, store it in `RESEND_FULL_ACCESS_API_KEY` in the git-ignored env file, and delete the key in Resend after the migration. Never print the key, and never write it into another file.

## Template content

A site that sends a template stored at Resend needs the template's content.

**Copy from the dashboard.** In Resend: Templates, then the template. Ask the person for its HTML, its plain text when it has one, its subject, its `from` and reply-to when they are set, and each variable's fallback.

**Read from the API.** `GET https://api.resend.com/templates/{id or alias}` with `Authorization: Bearer $RESEND_FULL_ACCESS_API_KEY`. The answer holds `alias`, `name`, `subject`, `from`, `reply_to`, `html`, `text`, and `variables`, a list of `{ key, type, fallback_value }`.

The content goes only into the template module the rewrite writes. Never write it into the plan or the report.

## Suppression list

Resend suppresses an address after a hard bounce or a spam complaint, and keeps addresses the team added by hand.

**Export from the dashboard (preferred).** In Resend: Emails, then Suppressions, then **Download CSV**. Read the header row to find the address column and the origin column.

**Read from the API.** `GET https://api.resend.com/suppressions?limit=100` with `Authorization: Bearer $RESEND_SUPPRESSIONS_API_KEY`.
**Read from the API.** `GET https://api.resend.com/suppressions?limit=100` with `Authorization: Bearer $RESEND_FULL_ACCESS_API_KEY`.

- The answer is `{ "object": "list", "has_more": <bool>, "data": [{ "id", "email", "origin", "source_id", "created_at" }] }`.
- Page with `after=<id of the last entry>` until `has_more` is `false`.
- Resend allows 10 requests per second per team. Wait and retry on `429`.
- A sending-only key can't read the list. Ask the person to create a full-access key, store it in `RESEND_SUPPRESSIONS_API_KEY` for the import, and delete the key in Resend afterwards.
- A sending-only key can't read the list. Use the full-access key below.

**Map each entry.**

Expand Down
Loading