From 8ba31c315c6cf9a4ade1e8baad41cf0eb14fe19b Mon Sep 17 00:00:00 2001 From: "Reichenbach, Michael" <755327+Silthus@users.noreply.github.com> Date: Thu, 22 Oct 2026 07:00:11 +0000 Subject: [PATCH 1/6] refactor(skills): share the posthog-node client setup as a partial Co-Authored-By: Claude Opus 5.5 --- context/shared/email-node-client.md | 7 +++++++ .../skills/omnibus/send-transactional-email/description.md | 4 +--- 2 files changed, 8 insertions(+), 3 deletions(-) create mode 100644 context/shared/email-node-client.md diff --git a/context/shared/email-node-client.md b/context/shared/email-node-client.md new file mode 100644 index 00000000..3f26ccb4 --- /dev/null +++ b/context/shared/email-node-client.md @@ -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`. diff --git a/context/skills/omnibus/send-transactional-email/description.md b/context/skills/omnibus/send-transactional-email/description.md index 07c45138..0f3aa6a1 100644 --- a/context/skills/omnibus/send-transactional-email/description.md +++ b/context/skills/omnibus/send-transactional-email/description.md @@ -69,9 +69,7 @@ STEP 8: Write the send. - Rendered content sends `templateKey`, `subject`, and the rendered `html` without `templateData`: `html: await render()`. - A template made in PostHog sends `templateId` and `templateData`, and no `templateKey`, `subject`, or `html`. - - 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`. + {{> email-node-client}} - Leave out `from` to send from the default sender. Pass `from` only with an address on a verified sender, such as `'Example '`. - Pass `replyTo` when replies should go to another address, such as a support mailbox. It takes one address or a list, and needs no verified sender. From a4916b9bfaf1ee4013a68e6511fed3492e7168af Mon Sep 17 00:00:00 2001 From: "Reichenbach, Michael" <755327+Silthus@users.noreply.github.com> Date: Thu, 22 Oct 2026 07:00:11 +0000 Subject: [PATCH 2/6] feat(skills): move Resend sends to posthog-node in migrate-email The migrate-email skill now rewrites every call site PostHog can send: hosted Resend templates become local templates, React and html/text sites send rendered content, and the client carries migratedFrom. It checks the build and one email on one person before it writes the report. Co-Authored-By: Claude Opus 5.5 --- context/skills/migrate-email/config.yaml | 2 +- context/skills/migrate-email/description.md | 12 ++-- .../migrate-email/references/3-inventory.md | 4 ++ .../skills/migrate-email/references/6-key.md | 2 +- .../migrate-email/references/7-rewrite.md | 68 +++++++++++++++++++ .../migrate-email/references/8-verify.md | 48 +++++++++++++ .../references/{7-report.md => 9-report.md} | 30 +++++--- .../references/resend/account.md | 18 ++++- .../references/resend/mapping.md | 41 ++++++++++- .../references/resend/sdk-reference.md | 7 +- scripts/lib/tests/migrate-email-skill.test.js | 19 ++++++ 11 files changed, 227 insertions(+), 24 deletions(-) create mode 100644 context/skills/migrate-email/references/7-rewrite.md create mode 100644 context/skills/migrate-email/references/8-verify.md rename context/skills/migrate-email/references/{7-report.md => 9-report.md} (58%) diff --git a/context/skills/migrate-email/config.yaml b/context/skills/migrate-email/config.yaml index 6b0b83c3..bf496738 100644 --- a/context/skills/migrate-email/config.yaml +++ b/context/skills/migrate-email/config.yaml @@ -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 diff --git a/context/skills/migrate-email/description.md b/context/skills/migrate-email/description.md index 174a82b6..a5fcb352 100644 --- a/context/skills/migrate-email/description.md +++ b/context/skills/migrate-email/description.md @@ -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. @@ -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//` 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. @@ -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 only the call sites that move, and only in Step 7. 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. diff --git a/context/skills/migrate-email/references/3-inventory.md b/context/skills/migrate-email/references/3-inventory.md index 884e9005..452109d7 100644 --- a/context/skills/migrate-email/references/3-inventory.md +++ b/context/skills/migrate-email/references/3-inventory.md @@ -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. @@ -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: diff --git a/context/skills/migrate-email/references/6-key.md b/context/skills/migrate-email/references/6-key.md index 0b4a0820..0c39a80d 100644 --- a/context/skills/migrate-email/references/6-key.md +++ b/context/skills/migrate-email/references/6-key.md @@ -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 diff --git a/context/skills/migrate-email/references/7-rewrite.md b/context/skills/migrate-email/references/7-rewrite.md new file mode 100644 index 00000000..f107996d --- /dev/null +++ b/context/skills/migrate-email/references/7-rewrite.md @@ -0,0 +1,68 @@ +--- +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, 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: 'resend', + 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. +- Never add a field that the old call didn't set, apart from `templateKey` and `distinctId`. + +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 — 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 new dependency, and each new env variable name. diff --git a/context/skills/migrate-email/references/8-verify.md b/context/skills/migrate-email/references/8-verify.md new file mode 100644 index 00000000..61ee2c3a --- /dev/null +++ b/context/skills/migrate-email/references/8-verify.md @@ -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 for a moved site now mocks `posthog.emails.send` with the same assertions. 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 = '' 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: , tests: , chain: `. diff --git a/context/skills/migrate-email/references/7-report.md b/context/skills/migrate-email/references/9-report.md similarity index 58% rename from context/skills/migrate-email/references/7-report.md rename to context/skills/migrate-email/references/9-report.md index 6869e754..08441c30 100644 --- a/context/skills/migrate-email/references/7-report.md +++ b/context/skills/migrate-email/references/9-report.md @@ -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. @@ -20,14 +20,16 @@ Read `.posthog-email-migration-plan.md`. Write `posthog-migrate-email- ```markdown # Email migration report: to PostHog - + ## Send call sites -| File | Line | Content | Status | -|------|------|---------|--------| +| File | Line | Content | Key | Status | +|------|------|---------|-----|--------| -: ".> +", or "Stays on : ".> + + ## Other calls @@ -45,13 +47,23 @@ Read `.posthog-email-migration-plan.md`. Write `posthog-migrate-email- +## Checks + + + ## Sending tier ## 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. + + + +## After cutover + + ## Summary ``` @@ -61,7 +73,7 @@ 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 @@ -69,13 +81,11 @@ 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: ``` diff --git a/context/skills/migrate-email/references/resend/account.md b/context/skills/migrate-email/references/resend/account.md index dfd42d4b..f4c38602 100644 --- a/context/skills/migrate-email/references/resend/account.md +++ b/context/skills/migrate-email/references/resend/account.md @@ -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": , "data": [{ "id", "email", "origin", "source_id", "created_at" }] }`. - Page with `after=` 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.** diff --git a/context/skills/migrate-email/references/resend/mapping.md b/context/skills/migrate-email/references/resend/mapping.md index eecb90fb..649ef3f1 100644 --- a/context/skills/migrate-email/references/resend/mapping.md +++ b/context/skills/migrate-email/references/resend/mapping.md @@ -12,13 +12,13 @@ This file decides, field by field, whether PostHog can send a Resend call site. | `html` | `html` | Never | | `text` | `text` | Never | | `react` | `html`, rendered in the app | Never. The app renders the component | -| `template` | a local template in the app's code | The template uses syntax beyond variables and fallbacks | +| `template` | a local template in the app's code | Its content can't be read, it uses syntax beyond variables and fallbacks, or the app fills a variable with HTML | | `cc`, `bcc` | `cc`, `bcc` | An entry carries a name, such as `Jane `, or there are more than 10 | | `replyTo` / `reply_to` | `replyTo` / `reply_to` | Never | | `headers` | `headers` | A header is one PostHog sets itself (`From`, `To`, `Cc`, `Bcc`, `Reply-To`, `Subject`, `Date`, `Message-ID`, `MIME-Version`, `Sender`, `Return-Path`, `Auto-Submitted`, any `Content-*`, `X-PostHog-*`, `X-SES-*`), a value has a line break or a non-ASCII character, or there are more than 10 | | `tags` | `tags` | A name or value has characters other than ASCII letters, digits, `_`, and `-`, a name starts with `ph_`, or there are more than 10 | | `attachments` | `attachments` | An attachment uses `path` (a URL Resend fetches), the files can be more than 10 or more than 512 KB in total, or a file type is one mailbox providers block, such as `.exe` | -| `idempotencyKey` / `Idempotency-Key` | `idempotencyKey` / `Idempotency-Key` | Never. The key keeps its value | +| `idempotencyKey` / `Idempotency-Key` | `idempotencyKey` / `Idempotency-Key` | The key can hold a space or a non-ASCII character, or be longer than 255 characters. Otherwise the key keeps its value and its meaning: both providers keep a key for 24 hours | | `scheduledAt` / `scheduled_at` | none | Always | | `topicId` / `topic_id` | none | Always | @@ -43,4 +43,39 @@ This file decides, field by field, whether PostHog can send a Resend call site. ## Content - `html`, `text`, and `react` content is rendered for one recipient before the send. PostHog sends it as it is. -- A `template` site needs the template's content from the Resend dashboard, because the code has only its id. PostHog's Liquid escapes HTML in a variable's value. A variable the app fills with HTML keeps the site on Resend. +- A `template` site needs the template's content, because the code has only its id. `account.md` says how to get it. +- Resend inserts a variable's value as it is. PostHog's Liquid escapes HTML in a value, so `` shows as text. A variable is HTML when the code fills it with markup or with a renderer's output. A variable the app fills with plain values, such as names, numbers, and URLs, renders the same. +- A template that holds anything other than `{{{VAR}}}` and `{{{VAR|fallback}}}` placeholders, such as a section, a loop, or a condition, stays on Resend. + +## Rewrite + +How a site that moves is written with `posthog-node`. Step 7 says when to use each part. + +**The call.** `resend.emails.send(payload, { idempotencyKey })` and `resend.emails.create(...)` become one `posthog.emails.send({ ... })`. The options object goes away: `idempotencyKey` moves into the payload. + +**The fields.** + +- `to: ['jane@example.com']` becomes `to: 'jane@example.com'`. A value that is a list with one entry becomes that entry. +- `from`, `subject`, `html`, `text`, `replyTo`, `cc`, `bcc`, `headers`, and `tags` keep their names and values. +- `attachments` keep `filename`, `content`, `contentType`, and `contentId`. +- Each send gets a `templateKey`, as Step 7 says. + +**React.** `react: ` becomes `html: await render()`, with `import { render } from '@react-email/render'`. Keep a `text` the site already passes. + +**HTTP.** A `fetch` or `axios` call to `https://api.resend.com/emails` becomes `posthog.emails.send` with the camelCase fields: `reply_to` becomes `replyTo`, and the `Idempotency-Key` header becomes `idempotencyKey`. Code that read the response status reads `error` instead. + +**Templates stored at Resend.** The template becomes a module in the app's code, in the format of the send skill's local template: a `subject` and an `html` string, and a `text` string when the template has one. + +- `{{{NAME}}}` becomes `{{ variables.NAME }}`. The name keeps its case. +- `{{{NAME|there}}}` becomes `{{ variables.NAME | default: "there" }}`. A variable with a `fallback_value` in the template's definition gets the same `default`. Escape a `"` in the fallback as `\"`. +- Liquid's `default` also replaces an empty string, which Resend inserts as it is. When the code can pass an empty string for a variable with a fallback, the email shows the fallback instead. Note it in the report. +- The site sends `templateKey`, `subject`, `html`, `text` when there is one, and `templateData` with the site's `variables` object unchanged. +- The site's own `subject`, `from`, and `replyTo` win over the template's. When the site has none, use the template's: put its `from` and `replyTo` on the send, and its subject in the module. +- The template's alias, or its name in lowercase with `-` between words, becomes the `templateKey` and the module's file name. + +**The answer.** posthog-node answers `{ data, error }` like Resend and never throws on an API error. + +- `data.id` is PostHog's message id. Code that logs it or stores it for the app's own use keeps working. +- `error.name` uses Resend's names, so `if (error.name === 'rate_limit_exceeded')` keeps working. `error.message` and `error.statusCode` keep their names. + +**The Resend client.** Keep `new Resend(...)` and its import while any site or other call still uses it. When nothing uses it, remove the import and the client, and keep the `resend` package and its key: the person removes them after the cutover. diff --git a/context/skills/migrate-email/references/resend/sdk-reference.md b/context/skills/migrate-email/references/resend/sdk-reference.md index fa3cf5f0..f88d47b2 100644 --- a/context/skills/migrate-email/references/resend/sdk-reference.md +++ b/context/skills/migrate-email/references/resend/sdk-reference.md @@ -79,7 +79,12 @@ const { data, error } = await resend.emails.send({ ## Templates stored at Resend -A send with `template: { id: 'tmpl_...', variables: { FIRST_NAME: 'Jane' } }` renders a template from the Resend dashboard. Its content is not in the code. The template reads variables as `{{{FIRST_NAME}}}`, with an optional fallback: `{{{FIRST_NAME|there}}}`. +A send with `template: { id: '...', variables: { PRODUCT: 'Laptop' } }` renders a template from the Resend dashboard. `id` is the template's id or its alias. Its content is not in the code. + +- The template reads variables as `{{{PRODUCT}}}`, with an optional fallback: `{{{PRODUCT|a laptop}}}`. A variable's definition in the template can also carry a `fallback_value`. +- A variable's type is `string` or `number`. +- The template can hold a default `from`, `subject`, and `reply_to`. A value the send passes wins over the default. +- A send with `template` passes no `html`, `text`, or `react`. ## Status reads diff --git a/scripts/lib/tests/migrate-email-skill.test.js b/scripts/lib/tests/migrate-email-skill.test.js index 29ca4ea6..3470410d 100644 --- a/scripts/lib/tests/migrate-email-skill.test.js +++ b/scripts/lib/tests/migrate-email-skill.test.js @@ -86,6 +86,25 @@ describe('migrate-email skill', () => { expect(markdownFiles(skillDir).map((file) => readFileSync(file, 'utf8')).join('\n')).not.toMatch(/\{\{>|\{display_name\}|\{references\}/); }); + it('moves the supported sites and checks them before it writes the report', async () => { + outputDir = mkdtempSync(join(tmpdir(), 'migrate-skill-')); + + const skillDir = await buildSkill('migrate-email-resend', outputDir); + const workflow = readFileSync(join(skillDir, 'SKILL.md'), 'utf8').match(/^\d+\. `references\/[^`]+`/gm); + + expect(workflow.map((step) => step.replace(/^\d+\. /, ''))).toEqual([ + '`references/1-detect.md`', + '`references/2-preflight.md`', + '`references/3-inventory.md`', + '`references/4-sender.md`', + '`references/5-suppressions.md`', + '`references/6-key.md`', + '`references/7-rewrite.md`', + '`references/8-verify.md`', + '`references/9-report.md`', + ]); + }); + it('publishes Resend and Statsig as wizard migrate subcommands', async () => { const { skills } = await loadSkills(); const cliOf = (id) => skills.find((skill) => skill.id === id)?._cli; From c3274f5f13ea0de404138d6c6cadb611fe3ac894 Mon Sep 17 00:00:00 2001 From: "Reichenbach, Michael" <755327+Silthus@users.noreply.github.com> Date: Thu, 22 Oct 2026 07:07:57 +0000 Subject: [PATCH 3/6] fix(skills): keep migrate-email from splitting sends or faking the old provider in tests Fixture runs looped over or truncated a one-recipient list, and one run mocked posthog-node to rebuild a Resend request. The rewrite now narrows the helper's parameter to one address, and the verify step asserts on the fields posthog.emails.send gets. Co-Authored-By: Claude Opus 5.5 --- context/skills/migrate-email/references/8-verify.md | 2 +- context/skills/migrate-email/references/resend/mapping.md | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/context/skills/migrate-email/references/8-verify.md b/context/skills/migrate-email/references/8-verify.md index 61ee2c3a..2bd615b8 100644 --- a/context/skills/migrate-email/references/8-verify.md +++ b/context/skills/migrate-email/references/8-verify.md @@ -17,7 +17,7 @@ Check that the app still builds, its tests still pass, and one moved email reach ## 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 for a moved site now mocks `posthog.emails.send` with the same assertions. Never delete or skip a test. +- 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 diff --git a/context/skills/migrate-email/references/resend/mapping.md b/context/skills/migrate-email/references/resend/mapping.md index 649ef3f1..9d34d55e 100644 --- a/context/skills/migrate-email/references/resend/mapping.md +++ b/context/skills/migrate-email/references/resend/mapping.md @@ -56,6 +56,7 @@ How a site that moves is written with `posthog-node`. Step 7 says when to use ea **The fields.** - `to: ['jane@example.com']` becomes `to: 'jane@example.com'`. A value that is a list with one entry becomes that entry. +- When a helper takes a list of recipients and every caller passes one address, change the parameter to one address and update the callers, so the type keeps a second address out. Never send one email per address in a loop, and never drop the addresses after the first. - `from`, `subject`, `html`, `text`, `replyTo`, `cc`, `bcc`, `headers`, and `tags` keep their names and values. - `attachments` keep `filename`, `content`, `contentType`, and `contentId`. - Each send gets a `templateKey`, as Step 7 says. From c1383c145a9d6ae636f31b220b3042be6c0e42c6 Mon Sep 17 00:00:00 2001 From: "Reichenbach, Michael" <755327+Silthus@users.noreply.github.com> Date: Thu, 22 Oct 2026 07:14:55 +0000 Subject: [PATCH 4/6] fix(skills): handle suppressed recipients and dynamic subjects in migrate-email A moved site now treats recipient_suppressed like a send that went out, a site-built subject becomes a module placeholder so the template hash stays stable, fallbacks use single quotes, and the report warns that idempotency keys don't cross providers. The rules and Step 7 no longer contradict the mapping. Co-Authored-By: Claude Opus 5.5 --- context/skills/migrate-email/description.md | 2 +- context/skills/migrate-email/references/7-rewrite.md | 9 +++++---- .../skills/migrate-email/references/resend/mapping.md | 8 +++++--- scripts/lib/tests/migrate-email-skill.test.js | 2 +- 4 files changed, 12 insertions(+), 9 deletions(-) diff --git a/context/skills/migrate-email/description.md b/context/skills/migrate-email/description.md index a5fcb352..f7cf35f2 100644 --- a/context/skills/migrate-email/description.md +++ b/context/skills/migrate-email/description.md @@ -28,7 +28,7 @@ The plan file `.posthog-email-migration-plan.md` lives at the project root. Step ## Rules -- Change only the call sites that move, and only in Step 7. Leave every site that stays as it is. +- 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 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. diff --git a/context/skills/migrate-email/references/7-rewrite.md b/context/skills/migrate-email/references/7-rewrite.md index f107996d..1670e95c 100644 --- a/context/skills/migrate-email/references/7-rewrite.md +++ b/context/skills/migrate-email/references/7-rewrite.md @@ -23,7 +23,7 @@ Rewrite each site whose `moves` is `yes` in the plan to send with `posthog-node` 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, once on the client, so PostHog knows which sends a migration moved: +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' @@ -31,7 +31,7 @@ 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: 'resend', + migratedFrom: '', enableLocalEvaluation: false, }) ``` @@ -59,10 +59,11 @@ For each site that moves, in plan order: - 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. -- Never add a field that the old call didn't set, apart from `templateKey` and `distinctId`. +- `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 — 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 new dependency, and each new env variable name. +Tick the phase in the plan: `- [x] rewrite — 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. diff --git a/context/skills/migrate-email/references/resend/mapping.md b/context/skills/migrate-email/references/resend/mapping.md index 9d34d55e..a44ffbf9 100644 --- a/context/skills/migrate-email/references/resend/mapping.md +++ b/context/skills/migrate-email/references/resend/mapping.md @@ -18,7 +18,7 @@ This file decides, field by field, whether PostHog can send a Resend call site. | `headers` | `headers` | A header is one PostHog sets itself (`From`, `To`, `Cc`, `Bcc`, `Reply-To`, `Subject`, `Date`, `Message-ID`, `MIME-Version`, `Sender`, `Return-Path`, `Auto-Submitted`, any `Content-*`, `X-PostHog-*`, `X-SES-*`), a value has a line break or a non-ASCII character, or there are more than 10 | | `tags` | `tags` | A name or value has characters other than ASCII letters, digits, `_`, and `-`, a name starts with `ph_`, or there are more than 10 | | `attachments` | `attachments` | An attachment uses `path` (a URL Resend fetches), the files can be more than 10 or more than 512 KB in total, or a file type is one mailbox providers block, such as `.exe` | -| `idempotencyKey` / `Idempotency-Key` | `idempotencyKey` / `Idempotency-Key` | The key can hold a space or a non-ASCII character, or be longer than 255 characters. Otherwise the key keeps its value and its meaning: both providers keep a key for 24 hours | +| `idempotencyKey` / `Idempotency-Key` | `idempotencyKey` / `Idempotency-Key` | The key can hold a space or a non-ASCII character, or be longer than 255 characters. Otherwise the key keeps its value, and PostHog keeps it for 24 hours as Resend does. The two providers don't share keys: a retry that runs after the deploy, of a send Resend already took, sends a second email. Note it in the report | | `scheduledAt` / `scheduled_at` | none | Always | | `topicId` / `topic_id` | none | Always | @@ -45,7 +45,7 @@ This file decides, field by field, whether PostHog can send a Resend call site. - `html`, `text`, and `react` content is rendered for one recipient before the send. PostHog sends it as it is. - A `template` site needs the template's content, because the code has only its id. `account.md` says how to get it. - Resend inserts a variable's value as it is. PostHog's Liquid escapes HTML in a value, so `` shows as text. A variable is HTML when the code fills it with markup or with a renderer's output. A variable the app fills with plain values, such as names, numbers, and URLs, renders the same. -- A template that holds anything other than `{{{VAR}}}` and `{{{VAR|fallback}}}` placeholders, such as a section, a loop, or a condition, stays on Resend. +- A template that holds anything other than `{{{VAR}}}` and `{{{VAR|fallback}}}` placeholders, such as a section, a loop, or a condition, stays on Resend. So does a fallback that holds both `'` and `"`. ## Rewrite @@ -68,15 +68,17 @@ How a site that moves is written with `posthog-node`. Step 7 says when to use ea **Templates stored at Resend.** The template becomes a module in the app's code, in the format of the send skill's local template: a `subject` and an `html` string, and a `text` string when the template has one. - `{{{NAME}}}` becomes `{{ variables.NAME }}`. The name keeps its case. -- `{{{NAME|there}}}` becomes `{{ variables.NAME | default: "there" }}`. A variable with a `fallback_value` in the template's definition gets the same `default`. Escape a `"` in the fallback as `\"`. +- `{{{NAME|there}}}` becomes `{{ variables.NAME | default: 'there' }}`. A variable with a `fallback_value` in the template's definition gets the same `default`. Quote a fallback that holds a `'` with `"` instead. Liquid has no escape character in a quoted value. - Liquid's `default` also replaces an empty string, which Resend inserts as it is. When the code can pass an empty string for a variable with a fallback, the email shows the fallback instead. Note it in the report. - The site sends `templateKey`, `subject`, `html`, `text` when there is one, and `templateData` with the site's `variables` object unchanged. - The site's own `subject`, `from`, and `replyTo` win over the template's. When the site has none, use the template's: put its `from` and `replyTo` on the send, and its subject in the module. +- The module's subject is fixed text with placeholders. When the site builds its subject from values, such as `` `Order #${order.id}` ``, write the subject in the module as `Order #{{ variables.ORDER_ID }}` and add each value to `templateData`. Never send a subject that changes per email with `templateData`: PostHog hashes the subject with the template, so each email would upload a new version of the template. - The template's alias, or its name in lowercase with `-` between words, becomes the `templateKey` and the module's file name. **The answer.** posthog-node answers `{ data, error }` like Resend and never throws on an API error. - `data.id` is PostHog's message id. Code that logs it or stores it for the app's own use keeps working. - `error.name` uses Resend's names, so `if (error.name === 'rate_limit_exceeded')` keeps working. `error.message` and `error.statusCode` keep their names. +- Resend takes a send to a suppressed address and drops it later. PostHog refuses it with `error.code` `recipient_suppressed` (name `validation_error`), and refuses the whole send when a `cc` or `bcc` address is suppressed. When the site throws, retries, or reports a failure on any error, add `if (error?.code === 'recipient_suppressed')` before that branch and handle it the way the site handled a send that went out: log it and go on. Note each site that got this branch in the report. **The Resend client.** Keep `new Resend(...)` and its import while any site or other call still uses it. When nothing uses it, remove the import and the client, and keep the `resend` package and its key: the person removes them after the cutover. diff --git a/scripts/lib/tests/migrate-email-skill.test.js b/scripts/lib/tests/migrate-email-skill.test.js index 3470410d..22000cdd 100644 --- a/scripts/lib/tests/migrate-email-skill.test.js +++ b/scripts/lib/tests/migrate-email-skill.test.js @@ -86,7 +86,7 @@ describe('migrate-email skill', () => { expect(markdownFiles(skillDir).map((file) => readFileSync(file, 'utf8')).join('\n')).not.toMatch(/\{\{>|\{display_name\}|\{references\}/); }); - it('moves the supported sites and checks them before it writes the report', async () => { + it('runs the rewrite and verify steps between the key and the report', async () => { outputDir = mkdtempSync(join(tmpdir(), 'migrate-skill-')); const skillDir = await buildSkill('migrate-email-resend', outputDir); From 3d51135257b45545073fe636e0a291196fdbe52f Mon Sep 17 00:00:00 2001 From: "Reichenbach, Michael" <755327+Silthus@users.noreply.github.com> Date: Thu, 22 Oct 2026 07:17:02 +0000 Subject: [PATCH 5/6] fix(skills): keep a suppressed copy address from passing as a sent email Co-Authored-By: Claude Opus 5.5 --- context/skills/migrate-email/references/resend/mapping.md | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/context/skills/migrate-email/references/resend/mapping.md b/context/skills/migrate-email/references/resend/mapping.md index a44ffbf9..ab634fcf 100644 --- a/context/skills/migrate-email/references/resend/mapping.md +++ b/context/skills/migrate-email/references/resend/mapping.md @@ -70,7 +70,7 @@ How a site that moves is written with `posthog-node`. Step 7 says when to use ea - `{{{NAME}}}` becomes `{{ variables.NAME }}`. The name keeps its case. - `{{{NAME|there}}}` becomes `{{ variables.NAME | default: 'there' }}`. A variable with a `fallback_value` in the template's definition gets the same `default`. Quote a fallback that holds a `'` with `"` instead. Liquid has no escape character in a quoted value. - Liquid's `default` also replaces an empty string, which Resend inserts as it is. When the code can pass an empty string for a variable with a fallback, the email shows the fallback instead. Note it in the report. -- The site sends `templateKey`, `subject`, `html`, `text` when there is one, and `templateData` with the site's `variables` object unchanged. +- The site sends `templateKey`, `subject`, `html`, `text` when there is one, and `templateData` with the site's `variables` object, plus the subject values below. - The site's own `subject`, `from`, and `replyTo` win over the template's. When the site has none, use the template's: put its `from` and `replyTo` on the send, and its subject in the module. - The module's subject is fixed text with placeholders. When the site builds its subject from values, such as `` `Order #${order.id}` ``, write the subject in the module as `Order #{{ variables.ORDER_ID }}` and add each value to `templateData`. Never send a subject that changes per email with `templateData`: PostHog hashes the subject with the template, so each email would upload a new version of the template. - The template's alias, or its name in lowercase with `-` between words, becomes the `templateKey` and the module's file name. @@ -79,6 +79,7 @@ How a site that moves is written with `posthog-node`. Step 7 says when to use ea - `data.id` is PostHog's message id. Code that logs it or stores it for the app's own use keeps working. - `error.name` uses Resend's names, so `if (error.name === 'rate_limit_exceeded')` keeps working. `error.message` and `error.statusCode` keep their names. -- Resend takes a send to a suppressed address and drops it later. PostHog refuses it with `error.code` `recipient_suppressed` (name `validation_error`), and refuses the whole send when a `cc` or `bcc` address is suppressed. When the site throws, retries, or reports a failure on any error, add `if (error?.code === 'recipient_suppressed')` before that branch and handle it the way the site handled a send that went out: log it and go on. Note each site that got this branch in the report. +- Resend takes a send to a suppressed address and drops it later. PostHog refuses it with `error.code` `recipient_suppressed` (name `validation_error`). When the site sets no `cc` or `bcc`, and it throws, retries, or reports a failure on any error, add `if (error?.code === 'recipient_suppressed')` before that branch and handle it the way the site handled a send that went out: log it and go on. Note each site that got this branch in the report. +- PostHog also refuses the whole send when a `cc` or `bcc` address is suppressed, where Resend still delivered to `to`. Give a site with `cc` or `bcc` no such branch: the error keeps failing the site, and the report says that a suppressed copy address now stops the email. **The Resend client.** Keep `new Resend(...)` and its import while any site or other call still uses it. When nothing uses it, remove the import and the client, and keep the `resend` package and its key: the person removes them after the cutover. From 4499957d7266d8f2c2d0e4db0b65b9a35ecb6f15 Mon Sep 17 00:00:00 2001 From: "Reichenbach, Michael" <755327+Silthus@users.noreply.github.com> Date: Thu, 22 Oct 2026 07:18:15 +0000 Subject: [PATCH 6/6] fix(skills): warn that retrying a send with a suppressed copy address uses sending cap Co-Authored-By: Claude Opus 5.5 --- context/skills/migrate-email/references/resend/mapping.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/context/skills/migrate-email/references/resend/mapping.md b/context/skills/migrate-email/references/resend/mapping.md index ab634fcf..4ceacdcc 100644 --- a/context/skills/migrate-email/references/resend/mapping.md +++ b/context/skills/migrate-email/references/resend/mapping.md @@ -80,6 +80,6 @@ How a site that moves is written with `posthog-node`. Step 7 says when to use ea - `data.id` is PostHog's message id. Code that logs it or stores it for the app's own use keeps working. - `error.name` uses Resend's names, so `if (error.name === 'rate_limit_exceeded')` keeps working. `error.message` and `error.statusCode` keep their names. - Resend takes a send to a suppressed address and drops it later. PostHog refuses it with `error.code` `recipient_suppressed` (name `validation_error`). When the site sets no `cc` or `bcc`, and it throws, retries, or reports a failure on any error, add `if (error?.code === 'recipient_suppressed')` before that branch and handle it the way the site handled a send that went out: log it and go on. Note each site that got this branch in the report. -- PostHog also refuses the whole send when a `cc` or `bcc` address is suppressed, where Resend still delivered to `to`. Give a site with `cc` or `bcc` no such branch: the error keeps failing the site, and the report says that a suppressed copy address now stops the email. +- PostHog also refuses the whole send when a `cc` or `bcc` address is suppressed, where Resend still delivered to `to`. Give a site with `cc` or `bcc` no such branch: the error keeps failing the site, and the report says that a suppressed copy address now stops the email, and that a job runner that retries the site repeats the refusal, each attempt using the project's sending cap. **The Resend client.** Keep `new Resend(...)` and its import while any site or other call still uses it. When nothing uses it, remove the import and the client, and keep the `resend` package and its key: the person removes them after the cutover.