Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions context/skills/sending-transactional-emails/config.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
# PostHog transactional emails - PostHog/posthog's sending-transactional-emails skill, fetched at build time, plus how to run it inside the Wizard
type: skill
template: description.md
category: workflows
description: Set up PostHog transactional emails, one-off emails that code sends to one person, such as password resets, sign-in codes, magic links, receipts, and invites. Use when asked to send one of those emails with PostHog, or to set up transactional email. It checks for a verified sender, writes the email in the app's code by default or as a PostHog template when the person picks that, hands over the project secret API key, and writes the send with PostHog's SDK for the backend's language (Node.js, Python, Go, Ruby, PHP, .NET, Rust or Elixir) or the HTTP API.
tags: [workflows]
shared_docs:
- https://posthog.com/docs/workflows/transactional-emails.md
- https://posthog.com/docs/workflows/transactional-emails/api.md
variants:
- id: all
display_name: Transactional Emails
docs_urls:
- url: https://raw.githubusercontent.com/PostHog/posthog/master/products/messaging/skills/sending-transactional-emails/SKILL.md
filename: sending-transactional-emails.md
title: The sending-transactional-emails skill. Read it first
- url: https://raw.githubusercontent.com/PostHog/posthog/master/products/messaging/skills/sending-transactional-emails/references/moving-from-other-providers.md
title: Moving a send from another provider to PostHog
- url: https://raw.githubusercontent.com/PostHog/posthog/master/products/messaging/skills/sending-transactional-emails/references/sdks.md
title: Each PostHog SDK's client setup, sends and status read
24 changes: 24 additions & 0 deletions context/skills/sending-transactional-emails/description.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
# Sending transactional emails

This skill publishes PostHog's `sending-transactional-emails` skill, which PostHog keeps next to its product code. Read `references/sending-transactional-emails.md` first, and follow it. Where it links to `references/<file>`, the file is in this skill's `references/` folder. The transactional emails docs are in the same folder: `references/transactional-emails.md` has every error code, and `references/api.md` has the HTTP send.

## 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. This section names the PostHog skill's steps by their bold titles, such as **API key.**, so find them by title, not by number.

- **First, look for server code.** Before any question or PostHog step, check that the project has server code that can send: a backend route, a server action, a worker or a job. When it has none, emit `[ABORT] no server code found` and stop.
- The person ran the Wizard to set up a transactional email, so skip the skill's advice on when to offer it.
- The Wizard has no chat. Ask every question of the skill with `wizard_ask`, never in plain text, and batch the questions of one step into one call. In the first call, ask which email to send when the person did not say, and offer a welcome email after signup as the first choice. In the same call, ask the skill's **Code or template** question as "Keep the email in your code (recommended) or design it in PostHog?", with code as the first choice. Ask it once.
- **When `wizard_ask` fails.** When it errors or isn't available, don't ask in plain text, and don't send anything. Keep the email in code. Write the email and its send code only when you already know which email to send. List every step you skipped in the report as one the person still has to do, and stop.
- The PostHog tools of the skill run through `posthog_exec`. Check that a tool exists with `search <name>`.
- The Wizard's login may not carry the scopes of the `workflows-*` and `integrations-*` tools, so a missing tool does not mean transactional emails are off. When a step needs a tool that is missing, ask the person to do that step in PostHog, and wait until they confirm it is done:
- **Verified sender.** They set one up as [configure a channel](https://posthog.com/docs/workflows/configure-channels) says.
- Only when the email is a PostHog template: **No marketing category.** and **Make it transactional.**, from the skill's **Design it in PostHog** section. They open **Transactional** in PostHog, click **New transactional email**, choose the verified sender, and write the subject and body. Agree on the variable names first, and give them to the person as `{{ variables.<name> }}`. The email can't be in a marketing message category. For sign-in codes, reset links and other secrets, tell them to turn on **Protect sensitive data** on the email's **API** tab. Then ask for the template ID, which that tab shows, in the same `wizard_ask` call as the key.
- **Engagement events.**, for code and template sends alike. Tell them PostHog records no send, delivery, open, click or bounce events on the person until they turn on **Engagement events** in project settings, under **Workflows**, and that these events count toward the project's event usage.
- **API key.** Tell the person how to create the key, as the skill's **API key.** step says. Then ask for it with `wizard_ask` and `sensitive: true`, and pass the answer to `set_env_values` as `POSTHOG_PROJECT_SECRET_API_KEY`. The answer is a reference that only `set_env_values` can read, so the key never shows in the run. The code reads the key from that variable.
- **Test.** Ask before the skill's test send, and send only to an address the person owns. Send it through the app's new send code, for example with a one-off script that calls it and takes the address as an argument. Delete the script after the test. Never read, print or pass `POSTHOG_PROJECT_SECRET_API_KEY` yourself. When you can't run that code, ask the person to send it and tell you what came back. When they say no, skip it, and write in the report that no send has confirmed the setup.
- When the person says **Transactional** isn't in their PostHog project, or a test of a template send answers `404` with the code `not_found` for a template ID the person checked on its **API** tab, emit `[ABORT] transactional email api not enabled` and stop.

## Reference files

{references}
20 changes: 18 additions & 2 deletions scripts/lib/skill-generator.js
Original file line number Diff line number Diff line change
Expand Up @@ -727,16 +727,32 @@ async function generateSkill({
}
}

// Helper to process a doc entry (string URL or {url, title} object).
const chosenFilenames = new Set();
function checkDocFilename(filename, url, chosen) {
const taken = references.some((ref) => ref.filename === filename);
const clash = chosen ? taken || filename === 'COMMANDMENTS.md' : chosenFilenames.has(filename);
const malformed = chosen && !/^[^/\\]+\.md$/.test(filename);
if (clash || malformed) {
throw new Error(
`${skill.id}: doc ${url} uses filename "${filename}"; a filename must be a plain .md name no other reference uses`
);
}
if (chosen) chosenFilenames.add(filename);
}

// Helper to process a doc entry (string URL or {url, title, filename} object).
// fetchDoc logs `Fetching doc:` only on a real network fetch — cache hits
// are silent.
async function processDoc(docEntry) {
const url = typeof docEntry === 'string' ? docEntry : docEntry.url;
const titleOverride = typeof docEntry === 'object' ? docEntry.title : null;
const filenameOverride = typeof docEntry === 'object' ? docEntry.filename : null;

const filename = filenameOverride || urlToFilename(url);
checkDocFilename(filename, url, Boolean(filenameOverride));

const result = await fetchDoc(url);
if (result) {
const filename = urlToFilename(url);
fs.writeFileSync(
path.join(referencesDir, filename),
result.content,
Expand Down
75 changes: 75 additions & 0 deletions scripts/lib/tests/sending-transactional-emails-skill.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { existsSync, mkdtempSync, readFileSync, rmSync } from 'fs';
import { join } from 'path';
import { tmpdir } from 'os';

vi.hoisted(() => {
process.env.DOCS_CACHE_TTL_MS = '0';
});

import { expandSkillGroups, generateSkill, loadSkillsConfig } from '../skill-generator.js';

const CONFIG_DIR = join(process.cwd(), 'context');
const UPSTREAM_SKILL = '---\nname: sending-transactional-emails\n---\n\n# Upstream skill\n';

describe('the built sending-transactional-emails skill', () => {
let outputDir;

beforeEach(() => {
outputDir = mkdtempSync(join(tmpdir(), 'sending-transactional-emails-'));
vi.stubGlobal(
'fetch',
vi.fn(async (url) =>
new Response(String(url).endsWith('/sending-transactional-emails/SKILL.md') ? UPSTREAM_SKILL : `# ${url}\n`),
),
);
});

afterEach(() => {
vi.unstubAllGlobals();
rmSync(outputDir, { recursive: true, force: true });
});

async function build() {
const config = loadSkillsConfig(CONFIG_DIR);
const [skill] = expandSkillGroups(config, CONFIG_DIR).filter((s) => s._group === 'sending-transactional-emails');
await generateSkill({
skill,
version: 'test',
repoRoot: process.cwd(),
configDir: CONFIG_DIR,
outputDir,
skipPatterns: { global: [], examples: {} },
commandmentsConfig: { commandments: {} },
skillTemplate: skill._template,
sharedDocs: skill._sharedDocs || [],
});
return join(outputDir, 'sending-transactional-emails');
}

it('packages the PostHog skill as a reference, not as a second SKILL.md', async () => {
const skillDir = await build();

expect(readFileSync(join(skillDir, 'references', 'sending-transactional-emails.md'), 'utf8')).toBe(UPSTREAM_SKILL);
expect(existsSync(join(skillDir, 'references', 'SKILL.md'))).toBe(false);
});

it('names only reference files the package holds', async () => {
const skillDir = await build();
const named = readFileSync(join(skillDir, 'SKILL.md'), 'utf8').match(/references\/[\w.-]+\.md/g);

expect(named).toContain('references/sending-transactional-emails.md');
for (const reference of new Set(named)) {
expect(existsSync(join(skillDir, reference)), reference).toBe(true);
}
});

it('emits only the [ABORT] reasons the Wizard program matches', async () => {
const skillDir = await build();
const reasons = [...readFileSync(join(skillDir, 'SKILL.md'), 'utf8').matchAll(/\[ABORT\] ([a-z][a-z ]*[a-z])/g)].map(
(match) => match[1],
);

expect(new Set(reasons)).toEqual(new Set(['no server code found', 'transactional email api not enabled']));
});
});
93 changes: 93 additions & 0 deletions scripts/lib/tests/skill-generator-doc-filename.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import { mkdirSync, writeFileSync, readFileSync, existsSync, mkdtempSync, rmSync } from 'fs';
import { join } from 'path';
import { tmpdir } from 'os';

vi.hoisted(() => {
process.env.DOCS_CACHE_TTL_MS = '0';
});

import { expandSkillGroups, generateSkill } from '../skill-generator.js';

const UPSTREAM_SKILL_URL = 'https://docs.example.test/skills/sending-email/SKILL.md';

describe('generateSkill doc filename', () => {
let tmpDir;

beforeEach(() => {
tmpDir = mkdtempSync(join(tmpdir(), 'skills-test-'));
mkdirSync(join(tmpDir, 'skills', 'sending-email'), { recursive: true });
writeFileSync(
join(tmpDir, 'skills', 'sending-email', 'description.md'),
'# Sending email\n\n## Reference files\n\n{references}\n',
);
vi.stubGlobal('fetch', vi.fn(async () => new Response('# Upstream skill\n\nSteps')));
});

afterEach(() => {
vi.unstubAllGlobals();
rmSync(tmpDir, { recursive: true, force: true });
});

async function generate(docsUrls) {
const config = {
'sending-email': {
type: 'skill',
template: 'description.md',
variants: [{ id: 'all', display_name: 'Sending email', docs_urls: docsUrls }],
},
};
const skill = expandSkillGroups(config, tmpDir)[0];
const outputDir = join(tmpDir, 'out');
await generateSkill({
skill,
version: 'test',
repoRoot: tmpDir,
configDir: tmpDir,
outputDir,
skipPatterns: { global: [], examples: {} },
commandmentsConfig: { commandments: {} },
skillTemplate: skill._template,
sharedDocs: skill._sharedDocs || [],
});
return join(outputDir, 'sending-email');
}

it('writes a fetched doc under the filename its entry names', async () => {
const skillDir = await generate([
{ url: UPSTREAM_SKILL_URL, filename: 'upstream-skill.md', title: 'The upstream skill' },
]);

const references = join(skillDir, 'references');
expect(readFileSync(join(references, 'upstream-skill.md'), 'utf8')).toBe('# Upstream skill\n\nSteps');
expect(existsSync(join(references, 'SKILL.md'))).toBe(false);
expect(readFileSync(join(skillDir, 'SKILL.md'), 'utf8')).toContain(
'- `references/upstream-skill.md` - The upstream skill',
);
});

it.each(['nested/upstream-skill.md', '../upstream-skill.md', 'upstream-skill.txt', 'COMMANDMENTS.md', '.md'])(
'refuses the filename %s',
async (filename) => {
await expect(generate([{ url: UPSTREAM_SKILL_URL, filename }])).rejects.toThrow(filename);
},
);

it('refuses a filename another reference already uses', async () => {
await expect(
generate([
{ url: UPSTREAM_SKILL_URL, filename: 'upstream-skill.md' },
{ url: 'https://docs.example.test/other.md', filename: 'upstream-skill.md' },
]),
).rejects.toThrow('upstream-skill.md');
});

it('refuses a doc whose name from its URL another reference already uses', async () => {
await expect(
generate([
{ url: UPSTREAM_SKILL_URL, filename: 'upstream.md' },
'https://docs.example.test/other/upstream.md',
]),
).rejects.toThrow('upstream.md');
});
});
Loading