diff --git a/AGENTS.md b/AGENTS.md index 9bbb4e2c..2d5c0779 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -72,11 +72,12 @@ longer exist — only some keep an alias. | `wizard integrate` | `wizard` (default flow) | command removed | | `wizard events-audit` | `wizard audit events` | moved into `audit` family | | `wizard audit` (single) | `wizard audit ` | now a family — see audit subcommands below | +| `wizard migrate` (single) | `wizard migrate ` | becomes a family with the wizard's family command — see migrate subcommands below | | `wizard audit-3000` | *removed* | retired | | `wizard revenue` | `wizard revenue-analytics` | renamed (old `revenue` removed) | | `wizard upload-sourcemaps` | `wizard upload-source-maps` | renamed; `upload-sourcemaps` kept as alias | -**Audit subcommands** — the one family today backed by skills from this repo: +**Audit subcommands**, backed by skills from this repo: | Subcommand | Backing skill | |---|---| @@ -88,6 +89,13 @@ longer exist — only some keep an alias. | `wizard audit session-replay` | `audit-session-replay` | | `wizard audit web-analytics` | *(wizard-native, not a skill here)* | +**Migrate subcommands**, published as `cliEntries` with `parentCommand: migrate`. Until the wizard runs `migrate` as a family, bare `wizard migrate` keeps running `migrate-statsig`: + +| Subcommand | Backing skill | +|---|---| +| `wizard migrate statsig` | `migrate-statsig` | +| `wizard migrate resend` | `migrate-email-resend` | + **Commands vs. skills:** those audit subcommands **are** skills, promoted to commands via `cli: role: command`. A `role: skill` skill is reachable only via `wizard skill `. Same machinery, two surfaces — so `wizard audit ` 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/shared/email-sender-setup.md b/context/shared/email-sender-setup.md new file mode 100644 index 00000000..bdf95e3c --- /dev/null +++ b/context/shared/email-sender-setup.md @@ -0,0 +1,15 @@ +--- +title: Set up an email sender +--- + +- First check that the PostHog MCP has the sender tools: `integrations-email-create`, `integrations-email-verify-create`, `integrations-domain-connect-check-retrieve`, and `integrations-domain-connect-apply-url-create`. When they are missing, go to the last bullet of this step. +- When the `setting-up-an-email-domain` skill is available, follow it. It covers the details. +- Ask which domain to send from. Recommend a subdomain such as `mail.example.com`. Use the person's own domain, never a free mailbox domain such as gmail.com. +- Never create a sender for an address that already has one: creating it again marks it unverified. For an existing unverified sender, skip to its records. +- Create the sender with `integrations-email-create`, then get its DNS records with `integrations-email-verify-create`. +- PostHog never writes DNS. Call `integrations-domain-connect-check-retrieve` with the domain. When `supported` is true, give the person the URL from `integrations-domain-connect-apply-url-create`: they approve the records at their DNS host. Otherwise, give them the records that are not `success` as a table with type, name, value, and priority. +- Always include both SPF records, merged into an existing `v=spf1` record when there is one. Never add a second `_dmarc` record when the domain already has one. +- Call `integrations-email-verify-create` again after the records are published, at least a minute apart. `success` means done. `failed` means fix the records, then verify again. After about 15 minutes of `pending` with every record published, stop and tell the person that DNS is still propagating and they can ask you to check again later. +- A 403 that says the person lacks permissions in the project means a project admin must verify the sender. Do not retry. +- **When the sender tools are missing or refused**, the agent setup for sender domains is not available to this person yet. Do not try other integration tools. Ask the person to verify a sender in PostHog: Workflows, then Channels, then add an email sender on their domain and follow the DNS steps (https://posthog.com/docs/workflows/configure-channels). Wait until they confirm that the sender shows as verified, then continue. + diff --git a/context/skills/migrate-email/config.yaml b/context/skills/migrate-email/config.yaml new file mode 100644 index 00000000..bf496738 --- /dev/null +++ b/context/skills/migrate-email/config.yaml @@ -0,0 +1,17 @@ +# Email migration skill group - move transactional email from another provider to PostHog, one variant per provider +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, 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 + parentCommand: migrate +references: + preamble: "**Read ONLY this file.** Do not read any other reference file until this one tells you to." +shared_docs: + - https://posthog.com/docs/workflows/transactional-email-api.md +variants: + - id: resend + display_name: Resend + tags: [resend] diff --git a/context/skills/migrate-email/description.md b/context/skills/migrate-email/description.md new file mode 100644 index 00000000..f7cf35f2 --- /dev/null +++ b/context/skills/migrate-email/description.md @@ -0,0 +1,55 @@ +# Migrate transactional email from {display_name} to PostHog + +Use this skill to move an app's transactional email, such as welcome emails, password resets, and receipts, from {display_name} to PostHog. + +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. + +## Workflow + +{workflow} + +Read the step files in order, one at a time. Each step names the next one. Do not read a step before the one before it tells you to. + +## Provider files + +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, 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. + +## Plan file + +The plan file `.posthog-email-migration-plan.md` lives at the project root. Step 3 writes it, the later steps update it, and the last step writes the report from it and deletes it. + +## Rules + +- 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. +- 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. + +## Inside the PostHog Wizard + +Read this section only when the Wizard's `wizard_ask` tool exists. Some runtimes name its tools `mcp__wizard-tools__wizard_ask` and `mcp__wizard-tools__set_env_values`: use the names the runtime shows. The Wizard has no chat, so the person answers through its tools. + +- The PostHog tools of this skill run through `posthog_exec`. Check that a tool exists with `search `. +- The Wizard's login may not carry the scopes of the `workflows-*` and `integrations-*` tools, so a missing tool does not mean the API is off. Step 2 says how to tell. +- Ask every question with `wizard_ask`, never in plain text. Batch the questions of one step into one call. +- Emit the `[STATUS]` line each step names when you start it, and the `[ABORT]` line a step names when it stops the run. +- Ask for a key with `wizard_ask` and `sensitive: true`, then pass the answer to `set_env_values` with the variable name the step gives. The answer is a reference that only `set_env_values` can read, so the key never shows in the run. + +Outside the Wizard, skip the `[STATUS]` lines, and tell the person why you stopped instead of an `[ABORT]` line. + +## Reference files + +{references} + +`transactional-email-api.md` is the transactional email API page. It covers senders, the send forms, `posthog-node`, idempotency, and every error code. diff --git a/context/skills/migrate-email/references/1-detect.md b/context/skills/migrate-email/references/1-detect.md new file mode 100644 index 00000000..94881bc9 --- /dev/null +++ b/context/skills/migrate-email/references/1-detect.md @@ -0,0 +1,38 @@ +--- +title: Find the email provider in the code +next_step: 2-preflight.md +--- + +# Step 1, find the email provider in the code + +Discovery only. Do not change any file. + +## Status + +``` +[STATUS] Looking for the email provider +``` + +## Procedure + +Read the provider's `sdk-reference.md`. It lists the provider's packages, imports, clients, and HTTP host, and the packages that look related but are not the provider. + +The provider is in this project when at least one of these is true: + +- Its package is in a dependency manifest, such as `package.json`, `requirements.txt`, `pyproject.toml`, `Gemfile`, `composer.json`, or `go.mod`. +- Source code imports it or creates its client. +- Source code calls its HTTP host. + +Search the source code, not only the manifest: a package without a call site is not a migration. Skip `node_modules`, build output, and lockfiles. + +A rendering package alone, such as `@react-email/components`, is not the provider. It only tells you how some emails are rendered. + +When you find nothing, stop before any change: + +``` +[ABORT] no email provider sends found +``` + +Do not widen the search to other email providers. That outcome is correct, not a failure to work around. + +When you find the provider, note each file that uses it for Step 3, and continue. diff --git a/context/skills/migrate-email/references/2-preflight.md b/context/skills/migrate-email/references/2-preflight.md new file mode 100644 index 00000000..ffd88264 --- /dev/null +++ b/context/skills/migrate-email/references/2-preflight.md @@ -0,0 +1,41 @@ +--- +title: Check that the project can send +next_step: 3-inventory.md +--- + +# Step 2, check that the project can send + +Do not change any file. + +## Status + +``` +[STATUS] Checking the PostHog project +``` + +## The transactional email API + +Check which PostHog MCP tools exist. + +- When there are no PostHog tools at all, the PostHog MCP is not connected or not signed in. Ask the person to connect it, and wait. +- When other PostHog tools exist but `workflows-upload-email-template` and `workflows-send-email` don't, the transactional email API is not enabled for this project. Stop and tell the person: the API is in beta, and they can ask PostHog in-app to enable it for their project. Then emit: + +``` +[ABORT] transactional email api not enabled +``` + +- When both tools exist, the API is on. Record `api: on`. + +Inside the Wizard, a missing tool can mean that the login lacks its scope, so missing tools do not stop the run. Record `api: not confirmed` and continue. The report asks the person to confirm it. + +## The sending tier + +PostHog gives each project a sending allowance: how many emails it sends per hour and per day. A new project starts on a low tier, and the tier grows with low bounce and complaint rates. Emails above the allowance are not dropped, they are sent later. So a cutover with more mail than the tier allows delays email, such as password resets. + +- Ask the person how many emails the app sends in its busiest hour and on its busiest day. +- Ask them to compare these numbers with the "Sending allowance" in PostHog: Workflows, then Messaging, then Reputation. +- Record the answer as `tier: fits`, `tier: too low`, or `tier: not checked` when they don't know. Do not stop on `too low`: the report tells the person to move the sends in parts, or to ask PostHog about a higher tier, before cutover. + +## Write down + +Keep `api` and `tier` for Step 3, which writes them into the plan file. diff --git a/context/skills/migrate-email/references/3-inventory.md b/context/skills/migrate-email/references/3-inventory.md new file mode 100644 index 00000000..452109d7 --- /dev/null +++ b/context/skills/migrate-email/references/3-inventory.md @@ -0,0 +1,68 @@ +--- +title: List and classify every send +next_step: 4-sender.md +--- + +# Step 3, list and classify every send + +List every place the code sends email through the provider, and decide for each one whether PostHog can send it. Change no source file. The only file you write is the plan file. + +## Status + +``` +[STATUS] Listing every send +[STATUS] Writing the migration plan +``` + +## Find the sends + +Read the provider's `sdk-reference.md` for the call shapes, and its `mapping.md` for the fields. + +A **site** is one place in the code that decides what a send contains: its content and its fields. Usually that is the `emails.send(...)` call or the HTTP request itself. + +- When a helper builds the send from a few parameters, such as a subject and a template name, the helper is one site, however many places call it. Read its callers anyway: they decide values such as how many recipients a send has. +- When a helper only forwards a payload that its callers build, each caller is a site, and the helper is not. + +For each site, read the surrounding code and record: + +- The file and the line of the call. +- How it sends: `sdk`, `http`, or `smtp`. +- The content: `template` for a template stored at the provider, `react` for a component the provider renders, `html`, or `text`. A site that sets both `html` and `text` is `html`. +- Every field the site sets, including fields set on the payload object after it is created and fields set through options, such as an idempotency key. +- Where `from` comes from: the literal address, or the name of the environment variable or config value that holds it. + +Also list every other call to the provider that is not a send, such as reading an email's status, contacts, or domains. These calls stay on the provider. Note what each one does, because a site whose code reads the status back stays on the provider too. + +## Classify each site + +Go through `mapping.md` field by field. A site **moves** when PostHog can send every field it sets. A site **stays** on the provider when any one field or feature keeps it there: record that field or feature as the reason. Never plan to drop a field. + +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. + +A phase checklist, in this order. Tick `preflight` with what Step 2 recorded, and `inventory` once the file is written. Later steps tick the rest. + +``` +- [x] preflight — api: — tier: +- [x] inventory — sites, stay on the provider +- [ ] sender +- [ ] suppressions +- [ ] key +- [ ] rewrite +- [ ] verify +``` + +A sites table, one row per site, in source tree order: + +``` +| site | file | line | sends | content | from | moves | reason | +``` + +`moves` is `yes` or `no`. `reason` is empty when the site moves. + +A list of the other provider calls, each with its file, line, and what it does. diff --git a/context/skills/migrate-email/references/4-sender.md b/context/skills/migrate-email/references/4-sender.md new file mode 100644 index 00000000..87a968c0 --- /dev/null +++ b/context/skills/migrate-email/references/4-sender.md @@ -0,0 +1,38 @@ +--- +title: Set up the senders and their DNS records +next_step: 5-suppressions.md +--- + +# Step 4, set up the senders and their DNS records + +PostHog sends from a verified sender: an address on a domain whose DNS records PostHog checked. Set up a sender for each domain the app sends from, next to the provider's records, so both providers can send during the cutover. Never change DNS yourself, and never change the provider account. + +## Status + +``` +[STATUS] Setting up the PostHog sender +``` + +## List the addresses + +Collect every `from` in the plan's sites table. When a `from` comes from an environment variable or a config value, read it from the example env file or the config, or ask the person for the address. A `from` can carry a name, such as `Example `: the sender is the address in it. + +Group the addresses by domain. Each domain needs one verified sender. + +## Check for verified senders + +Call `integrations-list` with `kind=email`, and read every page. A sender with `config.verified: true` can send. When the list shows senders without `config`, call `integrations-email-verify-create` once on the sender for the address: `success` means it is verified, and `pending` or `failed` means you continue from its records below. When the sender tools are missing, or the verify answers 403, ask the person whether a sender is verified for each domain. + +The first sender that verifies becomes the project's default sender. A send without `from` uses it. + +## Set up each domain without a verified sender + +Read the provider's `account.md` first. It lists the records the provider keeps on the domain and the label PostHog must not take. Keep every one of the provider's records: removing one stops the provider's sends before the cutover is done. + +Send from the domain of the existing `from` address, so the emails keep their sender. Ask for another domain only when the person wants one. + +{{> email-sender-setup}} + +## Write down + +Tick the `sender` phase in the plan file: `- [x] sender — / verified — dns_ready: `. `dns_ready` is `true` only when every domain has a verified sender. List each domain that is not verified, with what the person still has to do. diff --git a/context/skills/migrate-email/references/5-suppressions.md b/context/skills/migrate-email/references/5-suppressions.md new file mode 100644 index 00000000..02471b58 --- /dev/null +++ b/context/skills/migrate-email/references/5-suppressions.md @@ -0,0 +1,39 @@ +--- +title: Import the suppression list +next_step: 6-key.md +--- + +# Step 5, import the suppression list + +The provider keeps a suppression list: addresses that hard-bounced or marked an email as spam. It skips them on every send. PostHog keeps its own list and starts empty, so without an import the first PostHog sends go to these addresses again. That hurts the sending reputation of the project and the domain. Import the list before cutover. + +## Status + +``` +[STATUS] Importing the suppression list +``` + +## Check the import tool + +Look for the PostHog MCP tool `email-suppressions-add`, and read its schema with `info`. + +When it is missing, the import is not available to this person yet. Do not try other tools, and do not use the opt-out tools: opt-outs are marketing preferences, and transactional email ignores them. Tick the phase as `- [ ] suppressions — not imported: no import tool`, and continue to the next step. The report tells the person to add the addresses in PostHog (Workflows, then Messaging, then Suppression list) before cutover. + +## Get the list + +Read the provider's `account.md`. It says how to export the list, which entries to take, and how each entry maps to PostHog. + +- Prefer the export the person downloads from the provider's dashboard. Ask them where they saved the file. +- Read the list from the provider's API only when the person gives you a key that can read it, through an environment variable that `account.md` names. The app's own sending key usually can't. +- Keep the list out of the repository. Read the file where the person saved it, or write the API's answer to a file in the system's temporary folder, and delete that file when the import is done. + +## Import it + +- Send the entries to `email-suppressions-add` in batches of the size its schema allows. Give each entry the source and the reason `account.md` names. +- Repeating an import is safe: an address already on the list stays on it. +- Never print the addresses, and never write them into the plan or the report. Count them. +- When a call fails, read its error and fix the call. When it fails again, stop the import and record how many entries you imported. + +## Write down + +Tick the phase in the plan file: `- [x] suppressions — / imported`. When the list is empty, write `- [x] suppressions — empty list`. When the person has no export and no key, write `- [ ] suppressions — not imported: `. diff --git a/context/skills/migrate-email/references/6-key.md b/context/skills/migrate-email/references/6-key.md new file mode 100644 index 00000000..0c39a80d --- /dev/null +++ b/context/skills/migrate-email/references/6-key.md @@ -0,0 +1,32 @@ +--- +title: Hand over the PostHog secret key +next_step: 7-rewrite.md +--- + +# Step 6, hand over the PostHog secret key + +PostHog's send needs a project secret API key on the server. The provider's key stays where it is until the cutover is done. + +## Status + +``` +[STATUS] Setting up the PostHog secret key +``` + +## The PostHog key + +- Ask the person to create a project secret API key (it starts with `phs_`) with the `hog_flow:write` scope in their PostHog project settings. +- The key goes into `POSTHOG_PROJECT_SECRET_API_KEY`, in the env file the app loads its provider key from, such as `.env` or `.env.local`. Check that git ignores that file. When it doesn't, ask the person where the key goes. +- Ask the person to add the key to that file themselves. Inside the Wizard, ask for it and store it as the "Inside the PostHog Wizard" section of `SKILL.md` says. +- Add the variable name, without a value, to the project's env example file when it has one. +- Never hardcode the key, never commit it, and never ask the person to paste it into the chat. + +## Every other place the provider key lives + +The provider's `sdk-reference.md` names its key's environment variable. Search the whole repository for that name, including CI workflows, deploy and hosting config, Docker and compose files, env example files, and docs. Skip `node_modules` and build output. + +List each place with its file and line. Each one needs `POSTHOG_PROJECT_SECRET_API_KEY` too before the cutover, for example as a CI secret or a hosting environment variable. Do not change these files: the person adds the secret where the platform stores it. + +## Write down + +Tick the phase in the plan file: `- [x] key — POSTHOG_PROJECT_SECRET_API_KEY set in `, or `- [ ] key — not set: `. Under the phase list, add the places of the provider 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..1670e95c --- /dev/null +++ b/context/skills/migrate-email/references/7-rewrite.md @@ -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: '', + 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 — 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/8-verify.md b/context/skills/migrate-email/references/8-verify.md new file mode 100644 index 00000000..2bd615b8 --- /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 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 = '' 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/9-report.md b/context/skills/migrate-email/references/9-report.md new file mode 100644 index 00000000..08441c30 --- /dev/null +++ b/context/skills/migrate-email/references/9-report.md @@ -0,0 +1,93 @@ +--- +title: Write the migration report +next_step: null +--- + +# Step 9, write the migration report + +The report comes from the plan file. Invent nothing. + +## Status + +``` +[STATUS] Writing the migration report +``` + +## Write the report + +Read `.posthog-email-migration-plan.md`. Write `posthog-migrate-email--report.md` at the project root, where `` is the name of the provider folder under `references/`. Use the structure below, then delete the plan file. Never write a recipient's email address into the report. + +```markdown +# Email migration report: to PostHog + + + +## Send call sites + +| File | Line | Content | Key | Status | +|------|------|---------|-----|--------| + +", or "Stays on : ".> + + + +## Other calls + +.> + +## Sender and DNS + + + +## Suppression list + + + +## PostHog secret key + + + +## 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 +``` + +End the report with the summary block, as the last thing in the file. The Wizard parses it, so keep the keys, their order, and bare values: a whole number, `true`, `false`, or the provider folder name. Never add a comment or a reason to a value. The reasons are in the tables above. For example: + +```yaml +from: resend +sites_found: 3 +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 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. + +After the report is written, print its path: + +``` +Created migration report: +``` diff --git a/context/skills/migrate-email/references/resend/account.md b/context/skills/migrate-email/references/resend/account.md new file mode 100644 index 00000000..f4c38602 --- /dev/null +++ b/context/skills/migrate-email/references/resend/account.md @@ -0,0 +1,56 @@ +# Resend account + +What the migration needs from the person's Resend account. The agent never changes the account: it reads what the person exports or allows. + +## DNS records + +A domain verified at Resend carries these records. Keep all of them until the cutover is done. + +| Record | Name | What it does | +|---|---|---| +| `TXT` (DKIM) | `resend._domainkey` | Signs Resend's mail | +| `MX` | `send` | Return path for Resend's bounces | +| `TXT` (SPF) | `send` | Allows Resend to send for the return path | +| `TXT` (DMARC) | `_dmarc` | Optional, shared by every provider on the domain | + +- `send` is Resend's default return-path label. A domain can use a custom one: the Resend dashboard shows it under Domains. +- PostHog's sender uses its own label for its MAIL FROM, `feedback` by default. Never give PostHog the label Resend uses. +- 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_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. Use the full-access key below. + +**Map each entry.** + +| Resend `origin` | PostHog source | Reason | +|---|---|---| +| `bounce` | bounce | `Imported from Resend: hard bounce` | +| `complaint` | complaint | `Imported from Resend: spam complaint` | +| `manual` | manual | `Imported from Resend: added by hand` | + +**Leave out unsubscribed contacts.** A Resend contact with `unsubscribed: true` still gets transactional email, and PostHog's opt-outs apply to marketing messages only. They are not suppressions. diff --git a/context/skills/migrate-email/references/resend/mapping.md b/context/skills/migrate-email/references/resend/mapping.md new file mode 100644 index 00000000..4ceacdcc --- /dev/null +++ b/context/skills/migrate-email/references/resend/mapping.md @@ -0,0 +1,85 @@ +# Resend to PostHog mapping + +This file decides, field by field, whether PostHog can send a Resend call site. A site moves only when every field it sets moves. Otherwise it stays on Resend, and the reason names the first field or feature that keeps it there. `transactional-email-api.md` has the PostHog side in full. + +## Fields + +| Resend | PostHog | A site stays on Resend when | +|---|---|---| +| `from` | `from` | Never. It needs a verified sender (Step 4) | +| `to` | `to` | `to` can hold more than one address. A list with exactly one address moves as that address | +| `subject` | `subject` | Never | +| `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 | 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` | 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 | + +## Calls + +| Resend call | A site stays on Resend when | +|---|---| +| `emails.send`, `emails.create`, `Emails.send`, `POST /emails` | A field above keeps it | +| `batch.send`, `Batch.send`, `POST /emails/batch` | Always | +| SMTP through `smtp.resend.com` | Always. PostHog has no SMTP relay | +| Laravel's or Rails' `resend` mailer | Always. PostHog has no mailer driver | +| A send in a language other than JavaScript or TypeScript | Always, until the skill moves that language | + +## Code around the send + +| Code | A site stays on Resend when | +|---|---| +| `if (error.name === '...')` | The name is not one of PostHog's: `validation_error`, `missing_api_key`, `invalid_permission`, `not_found`, `concurrent_idempotent_requests`, `invalid_idempotent_request`, `rate_limit_exceeded`, `monthly_quota_exceeded`, `service_unavailable`, `application_error` | +| `emails.get(id)` and a branch on `last_event` | Always: the send and its status read stay together. PostHog's status is not `last_event` | +| `data.id` stored for a webhook or a status read | Always, when Resend webhooks or status reads use the id | + +## 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, 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. So does a fallback that holds both `'` and `"`. + +## 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. +- 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. + +**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`. 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, 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. + +**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`). 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, 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. diff --git a/context/skills/migrate-email/references/resend/sdk-reference.md b/context/skills/migrate-email/references/resend/sdk-reference.md new file mode 100644 index 00000000..f88d47b2 --- /dev/null +++ b/context/skills/migrate-email/references/resend/sdk-reference.md @@ -0,0 +1,95 @@ +# Resend SDK reference + +This file says what Resend looks like in a codebase. It does not describe PostHog. `mapping.md` maps each field to PostHog. + +## Key + +The API key lives in `RESEND_API_KEY` by convention. Apps also use their own names, such as `PRIVATE_RESEND_API_KEY`: take the name the client reads. + +## Packages + +- Node.js: `resend`. +- Python: `resend` (PyPI). +- Ruby: `resend` (RubyGems), and `resend-rails`. +- PHP: `resend/resend-php`, and `resend/resend-laravel` for Laravel. +- Go: `github.com/resend/resend-go`. +- Elixir: `resend` (Hex). +- Java: `com.resend:resend-java`. +- .NET: `Resend`. +- Rust: `resend-rs`. + +These are not Resend, and alone they are not a migration: `@react-email/components`, `@react-email/render`, `react-email`, and `jsx-email` render emails, and `nodemailer` sends through any SMTP server. + +## Clients + +```js +import { Resend } from 'resend' +const resend = new Resend(process.env.RESEND_API_KEY) +``` + +```python +import resend +resend.api_key = os.environ["RESEND_API_KEY"] +``` + +Look for `new Resend(`, `from 'resend'`, `require('resend')`, `import resend`, `Resend::`, and `resend.NewClient(`. + +## Sends + +**Node.js.** `resend.emails.send(payload, options?)`, and its alias `resend.emails.create(...)`. The answer is `{ data, error }`: `data.id` is the email's id, and `error` has `name`, `message`, and `statusCode`. The SDK does not throw on API errors. + +```js +const { data, error } = await resend.emails.send({ + from: 'Example ', + to: ['jane@example.com'], + subject: 'Welcome', + html: '

Welcome

', +}, { idempotencyKey: `welcome:${user.id}` }) +``` + +**Python.** `resend.Emails.send(params)`, with the same fields in snake_case, and `options={"idempotency_key": ...}`. It raises on API errors. + +**Batch.** `resend.batch.send([...])` in Node.js and `resend.Batch.send([...])` in Python send up to 100 emails in one call. + +**HTTP.** `POST https://api.resend.com/emails` with `Authorization: Bearer `, a JSON body with the snake_case fields, and an optional `Idempotency-Key` header. `POST https://api.resend.com/emails/batch` is the batch send. Look for `api.resend.com` in `fetch`, `axios`, `got`, `requests`, `httpx`, and `curl` calls. + +**SMTP.** Resend also takes mail over SMTP at `smtp.resend.com`, with the user `resend` and the API key as the password. Look for that host in Nodemailer transports, `MAIL_HOST` or `SMTP_HOST` settings, and framework mail config. + +**Frameworks.** Laravel's `resend` mailer (`MAIL_MAILER=resend`) and Rails' `resend` delivery method send every framework mail through Resend: each mailable or mailer that sends is a site. + +## Send fields + +| Node.js field | HTTP field | Value | +|---|---|---| +| `from` | `from` | One address, with an optional name: `Name ` | +| `to` | `to` | One address, or a list of up to 50 | +| `subject` | `subject` | String | +| `html` | `html` | String | +| `text` | `text` | String. Resend derives one from `html` when it is absent | +| `react` | (none) | A React element that the Node.js SDK renders to HTML | +| `cc`, `bcc` | `cc`, `bcc` | One address or a list | +| `replyTo` | `reply_to` | One address or a list | +| `headers` | `headers` | Object of custom headers | +| `tags` | `tags` | List of `{ name, value }` | +| `attachments` | `attachments` | List of `{ filename, content, path, contentType, contentId }`. `content` is a Buffer or base64, `path` is a URL Resend fetches | +| `scheduledAt` | `scheduled_at` | A time to send later | +| `topicId` | `topic_id` | A topic the recipient can unsubscribe from | +| `template` | `template` | `{ id, variables }`: a template stored at Resend | +| `idempotencyKey` option | `Idempotency-Key` header | Up to 256 characters, kept for 24 hours | + +## Templates stored at Resend + +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 + +`resend.emails.get(id)` and `GET https://api.resend.com/emails/{id}` return the email with `last_event`, such as `delivered`, `bounced`, or `complained`. Code that branches on `last_event` reads Resend's delivery state. + +## Other APIs + +`resend.contacts`, `resend.audiences`, `resend.segments`, `resend.broadcasts`, `resend.domains`, `resend.apiKeys`, `resend.webhooks`, and their HTTP routes are not transactional sends. diff --git a/context/skills/migrate/config.yaml b/context/skills/migrate/config.yaml index c56409d5..d4e2cb5a 100644 --- a/context/skills/migrate/config.yaml +++ b/context/skills/migrate/config.yaml @@ -5,7 +5,7 @@ description: Migrate an existing analytics or feature-flag vendor to PostHog. Re tags: [migration] cli: role: command - command: migrate + parentCommand: migrate references: preamble: "**Read ONLY this file.** Do not read any other reference file until this one tells you to." shared_docs: [] diff --git a/context/skills/omnibus/send-transactional-email/config.yaml b/context/skills/omnibus/send-transactional-email/config.yaml new file mode 100644 index 00000000..e99c4a9d --- /dev/null +++ b/context/skills/omnibus/send-transactional-email/config.yaml @@ -0,0 +1,12 @@ +# Transactional email skill - one email to one person, from a template in the app's code +type: skill +template: description.md +category: workflows +description: Send transactional email from code with PostHog, such as a welcome email, a password reset, a signup confirmation, or a receipt. Use when asked to send an email from a Node.js app or other server code. It verifies a sender through the PostHog MCP, writes the email as a local template in the app's code, and writes the send with posthog-node's emails.send or the HTTP API. The first send uploads the template. +tags: [workflows] +shared_docs: + - https://posthog.com/docs/workflows/transactional-email-api.md +variants: + - id: all + display_name: all supported platforms + tags: [] diff --git a/context/skills/omnibus/send-transactional-email/description.md b/context/skills/omnibus/send-transactional-email/description.md new file mode 100644 index 00000000..0f3aa6a1 --- /dev/null +++ b/context/skills/omnibus/send-transactional-email/description.md @@ -0,0 +1,154 @@ +# Send transactional email with PostHog + +Use this skill to send one email to one person from server code, such as a welcome email, a password reset, or a signup confirmation. The email lives in the app's code as a local template: a subject and an HTML string, named by a key such as `welcome`. The first send uploads it to PostHog, and PostHog sends it through Workflows. + +The transactional email API is in beta. PostHog enables it per project. + +## Instructions + +Follow these steps IN ORDER. Do not send before a sender is verified: the first send of a local template uploads it, and that upload needs a verified default sender. + +STEP 1: Detect the platform and check the PostHog MCP. + - Find the server code that should send the email, such as the signup handler for a welcome email. + - Node.js (including Next.js route handlers and server actions) uses `posthog-node`. Any other server language uses the HTTP API. + - Never send from browser or mobile code. The send needs a secret key, and the key stays on the server. + - Look for lockfiles (pnpm-lock.yaml, package-lock.json, yarn.lock, bun.lockb) to pick the package manager. + - Check which PostHog MCP tools exist. When there are no PostHog tools at all, the PostHog MCP is not connected or not signed in: ask the person to connect it, and wait. + - When other PostHog tools exist but `workflows-upload-email-template` and `workflows-send-email` don't, the transactional email API is not enabled for this project. Go to STEP 6 before the person does any sender work. + +STEP 2: Check for a verified sender through the PostHog MCP. + - Call `integrations-list` with `kind=email`, and read every page. A sender with `config.verified: true` can send. When the list shows senders without `config`, call `integrations-email-verify-create` once on the sender for the address the person wants: `success` means it is verified (go to STEP 4), and `pending` or `failed` means STEP 3 from its records. When the sender tools are missing, or the verify answers 403, ask the person whether a sender is verified, and let the first send in STEP 8 confirm it. + - The first sender that verifies becomes the project's default sender. A send without `from` uses the default sender. + - When a verified sender exists, go to STEP 4. + +STEP 3: Set up a sender when none is verified. + {{> email-sender-setup}} + +STEP 4: Pick the form of the send. + - **Local template**, the default for a new email: the code holds the subject and the HTML with `{{ variables.* }}` placeholders, and each send passes the values in `templateData`. Write it as STEP 5 says. + - **Rendered content**: the app already renders the whole email for one person, for example a React Email component rendered with `render()` from `@react-email/render`. Send the rendered `html` without `templateData`. PostHog sends it as it is and never stores it. Skip STEP 5. + - **A template made in PostHog**: only when the person names one. Send it by its `templateId` with `templateData`. Skip STEP 5. + +STEP 5: Write the local template in the code. + - Put it in a module next to the server code, for example `emails/welcome.ts`, that exports a `subject` and an `html` string. Add a `text` string only when the person wants their own plain-text part: without it, PostHog derives one from the HTML. + - Pick the key that names the email, such as `welcome` or `password-reset`. Use letters, digits, `-`, and `_` only. PostHog groups the email's logs, metrics, and status under it. + - Use Liquid with `{{ variables. }}` for every value the email shows, in the subject too, such as `{{ variables.first_name }}` or `{{ variables.reset_url }}`. + - Never use `{{ person.* }}`. A send doesn't load person properties, so those placeholders render empty. + - Never upload the template, and never create it in PostHog. The first send uploads it, and a send after a change uploads the new content. The template is then managed by code: changes go through the code (see "Change an email the app already sends"). + +STEP 6: Stop when the API is not enabled for the project. + - The transactional email API is not enabled for this project when other PostHog tools exist but the upload and send tools don't, or when a send by `templateKey` answers `404` with the code `not_found` or without a code. A send by `templateId` answers the same `404` when no template has that id: check the id with the person first. + - Stop and tell the person: the API is in beta, and they can ask PostHog in-app to enable it for their project. Do not retry, and do not work around it with other tools. + +STEP 7: Have the person create a project secret API key. + - Ask the person to create a project secret API key (it starts with `phs_`) with the `hog_flow:write` scope in their PostHog project settings. + - Read it from the `POSTHOG_PROJECT_SECRET_API_KEY` environment variable. Add the variable name to the project's env example file. Never hardcode the key, never commit it, and never ask the person to paste it into the chat. + +STEP 8: Write the send. + **Node.js:** install or upgrade `posthog-node` to a version that has `posthog.emails`. Send the local template from STEP 5: + + ```js + import { PostHog } from 'posthog-node' + import * as welcomeEmail from './emails/welcome' + + const posthog = new PostHog(process.env.POSTHOG_PROJECT_TOKEN, { + host: process.env.POSTHOG_HOST, + secretKey: process.env.POSTHOG_PROJECT_SECRET_API_KEY, + enableLocalEvaluation: false, + }) + + const { data, error } = await posthog.emails.send({ + to: user.email, + distinctId: user.id, + templateKey: 'welcome', + subject: welcomeEmail.subject, + html: welcomeEmail.html, + templateData: { first_name: user.firstName }, + }) + ``` + + - 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`. + {{> 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. + + **Other languages:** send over HTTP to the app host, `https://us.posthog.com` or `https://eu.posthog.com`, with `Authorization: Bearer $POSTHOG_PROJECT_SECRET_API_KEY` and an `Idempotency-Key` header: + + ``` + POST /api/projects/@current/messaging/send/email/ + Idempotency-Key: welcome:user_42 + {"to": "jane@example.com", "identifiers": {"distinct_id": "user_42"}, "template_key": "welcome", "subject": "Welcome to Example", "html": "

Hi Jane

"} + ``` + + - That body sends rendered content. A local template sends `"template": {"key": "welcome", "hash": ""}` and `message_data` instead, with the hash computed as the reference says. + - A local template answers `409 template_outdated` when PostHog doesn't hold its content yet, on its first send too. Upload it with `PUT /api/projects/@current/messaging_templates/by_key//` and `if_code_managed: true`, then send again with the same `Idempotency-Key`. + - A template made in PostHog sends `"template": {"id": "