From 4e95c5a4e4532a8ec07254a8e570b1e5e2d4c0ec Mon Sep 17 00:00:00 2001 From: Dries Augustyns Date: Wed, 13 May 2026 17:37:02 +0200 Subject: [PATCH] seo: implement OpenAPI operation rendering and add llms documentation --- apps/landing/public/llms.txt | 78 ++++++ apps/landing/src/content/markdown-slugs.ts | 14 ++ apps/landing/src/content/pages.ts | 2 + apps/landing/src/middleware.ts | 5 + apps/landing/src/pages/_app.tsx | 7 + apps/wiki/app/[[...slug]]/page.tsx | 5 + apps/wiki/app/openapi.json/route.ts | 16 ++ apps/wiki/lib/get-llm-text.ts | 10 +- apps/wiki/lib/render-openapi-operation.ts | 279 +++++++++++++++++++++ apps/wiki/middleware.ts | 3 +- apps/wiki/public/llms.txt | 83 ++++++ 11 files changed, 500 insertions(+), 2 deletions(-) create mode 100644 apps/landing/public/llms.txt create mode 100644 apps/landing/src/content/markdown-slugs.ts create mode 100644 apps/wiki/app/openapi.json/route.ts create mode 100644 apps/wiki/lib/render-openapi-operation.ts create mode 100644 apps/wiki/public/llms.txt diff --git a/apps/landing/public/llms.txt b/apps/landing/public/llms.txt new file mode 100644 index 0000000..e990d2e --- /dev/null +++ b/apps/landing/public/llms.txt @@ -0,0 +1,78 @@ +# Plunk + +> Plunk is an open-source, all-in-one email platform for developers. It unifies marketing, transactional, and broadcast email behind a single API — built to handle millions of contacts with workflows, segments, templates, and deliverability tooling out of the box. + +Plunk can be used as a hosted service at useplunk.com or self-hosted via Docker. Source code lives at https://github.com/useplunk/plunk. + +Pages listed below with a `.md` suffix serve a Markdown version (also available by requesting the same URL with `Accept: text/markdown`). Other pages are HTML only. + +For full product documentation, see the docs site: https://docs.useplunk.com/llms.txt + +## Product + +- [Plunk home](https://www.useplunk.com/index.md): Product overview, features, and positioning +- [Pricing](https://www.useplunk.com/pricing.md): Plans, included volume, and overage pricing +- [Made by humans](https://www.useplunk.com/made-by-humans): The team and story behind Plunk + +## Features + +- [Email editor](https://www.useplunk.com/features/email-editor.md): Drag-and-drop and code-based template editor +- [Workflows](https://www.useplunk.com/features/workflows.md): Event- and segment-triggered automation flows +- [Segments](https://www.useplunk.com/features/segments.md): Dynamic and static contact segments +- [SMTP](https://www.useplunk.com/features/smtp.md): Send through Plunk over standard SMTP +- [Inbound email](https://www.useplunk.com/features/inbound-email.md): Receive email at your verified domain and turn it into events + +## Comparisons + +- [Plunk vs Resend](https://www.useplunk.com/vs/resend) +- [Plunk vs SendGrid](https://www.useplunk.com/vs/sendgrid) +- [Plunk vs Mailchimp](https://www.useplunk.com/vs/mailchimp) +- [Plunk vs Mailgun](https://www.useplunk.com/vs/mailgun) +- [Plunk vs Postmark](https://www.useplunk.com/vs/postmark) +- [Plunk vs Customer.io](https://www.useplunk.com/vs/customerio) +- [Plunk vs Loops](https://www.useplunk.com/vs/loops) +- [Plunk vs Brevo](https://www.useplunk.com/vs/brevo) +- [Plunk vs ActiveCampaign](https://www.useplunk.com/vs/activecampaign) +- [Plunk vs Klaviyo](https://www.useplunk.com/vs/klaviyo) +- [Plunk vs ConvertKit](https://www.useplunk.com/vs/convertkit) +- [Plunk vs Bento](https://www.useplunk.com/vs/bento) +- [Plunk vs MailerLite](https://www.useplunk.com/vs/mailerlite) +- [Plunk vs Amazon SES](https://www.useplunk.com/vs/amazon-ses) +- [Plunk vs Mailjet](https://www.useplunk.com/vs/mailjet) +- [Plunk vs Buttondown](https://www.useplunk.com/vs/buttondown) +- [All comparisons](https://www.useplunk.com/vs) + +## Guides + +- [Email API guide](https://www.useplunk.com/guides/email-api-guide): Choosing and integrating with an email API +- [Email deliverability](https://www.useplunk.com/guides/email-deliverability): Reaching the inbox reliably +- [Email bounce rate](https://www.useplunk.com/guides/email-bounce-rate): What it is and how to reduce it +- [Email open rate](https://www.useplunk.com/guides/email-open-rate): Benchmarks and improvement tactics +- [Email click-through rate](https://www.useplunk.com/guides/email-click-through-rate): Measuring and improving CTR +- [Email sender reputation](https://www.useplunk.com/guides/email-sender-reputation): Protecting your sending reputation +- [Email marketing best practices](https://www.useplunk.com/guides/email-marketing-best-practices) +- [Transactional vs marketing email](https://www.useplunk.com/guides/transactional-vs-marketing-email) +- [What is SPF](https://www.useplunk.com/guides/what-is-spf) +- [What is DKIM](https://www.useplunk.com/guides/what-is-dkim) +- [What is DMARC](https://www.useplunk.com/guides/what-is-dmarc) +- [All guides](https://www.useplunk.com/guides) + +## Tools + +- [Verify email](https://www.useplunk.com/tools/verify-email): Check a single email address for validity +- [Spam checker](https://www.useplunk.com/tools/spam-checker): Score an email for spam triggers +- [Markdown to email](https://www.useplunk.com/tools/markdown-to-email): Convert Markdown into HTML email +- [SPF checker](https://www.useplunk.com/tools/spf-checker) +- [DKIM checker](https://www.useplunk.com/tools/dkim-checker) +- [DMARC checker](https://www.useplunk.com/tools/dmarc-checker) +- [MX checker](https://www.useplunk.com/tools/mx-checker) +- [Email headers analyzer](https://www.useplunk.com/tools/email-headers) +- [All tools](https://www.useplunk.com/tools) + +## Optional + +- [Documentation](https://docs.useplunk.com): Product, API, and self-hosting docs +- [Discord community](https://www.useplunk.com/discord) +- [Privacy policy](https://www.useplunk.com/privacy) +- [Terms of service](https://www.useplunk.com/terms) +- [Data processing agreement](https://www.useplunk.com/dpa) diff --git a/apps/landing/src/content/markdown-slugs.ts b/apps/landing/src/content/markdown-slugs.ts new file mode 100644 index 0000000..90f09bf --- /dev/null +++ b/apps/landing/src/content/markdown-slugs.ts @@ -0,0 +1,14 @@ +export const MARKDOWN_SLUGS: ReadonlySet = new Set([ + 'index', + 'pricing', + 'features/workflows', + 'features/segments', + 'features/inbound-email', + 'features/email-editor', + 'features/smtp', +]); + +export function hasMarkdownVariant(pathname: string): boolean { + const slug = pathname.replace(/^\/+|\/+$/g, '') || 'index'; + return MARKDOWN_SLUGS.has(slug); +} diff --git a/apps/landing/src/content/pages.ts b/apps/landing/src/content/pages.ts index 41a51f8..3012919 100644 --- a/apps/landing/src/content/pages.ts +++ b/apps/landing/src/content/pages.ts @@ -341,3 +341,5 @@ Plunk provides SMTP credentials so you can send emails from any application or f [Back to features](/features) | [Pricing](/pricing) | [Documentation](https://docs.useplunk.com) `, }; + +export {MARKDOWN_SLUGS, hasMarkdownVariant} from './markdown-slugs'; diff --git a/apps/landing/src/middleware.ts b/apps/landing/src/middleware.ts index 992d6d6..88f8326 100644 --- a/apps/landing/src/middleware.ts +++ b/apps/landing/src/middleware.ts @@ -1,6 +1,8 @@ import { NextResponse } from 'next/server'; import type { NextRequest } from 'next/server'; +import { hasMarkdownVariant } from './content/markdown-slugs'; + type Negotiated = 'markdown' | 'html' | 'none'; function parseAccept(accept: string): Array<{ type: string; q: number }> { @@ -59,6 +61,9 @@ export function middleware(request: NextRequest) { const response = NextResponse.next(); response.headers.set('Vary', 'Accept'); + if (hasMarkdownVariant(pathname)) { + response.headers.append('Link', `<${pathname}.md>; rel="alternate"; type="text/markdown"`); + } return response; } diff --git a/apps/landing/src/pages/_app.tsx b/apps/landing/src/pages/_app.tsx index 2665c0e..402af71 100644 --- a/apps/landing/src/pages/_app.tsx +++ b/apps/landing/src/pages/_app.tsx @@ -2,9 +2,11 @@ import '../styles/globals.css'; import React, {useEffect} from 'react'; import Head from 'next/head'; import {AppProps} from 'next/app'; +import {useRouter} from 'next/router'; import {toast, Toaster} from 'sonner'; import {SWRConfig} from 'swr'; import {network} from '../lib/network'; +import {hasMarkdownVariant} from '../content/markdown-slugs'; import {DefaultSeo} from 'next-seo'; import Script from 'next/script'; import {Bricolage_Grotesque, Hanken_Grotesk, JetBrains_Mono} from 'next/font/google'; @@ -37,6 +39,10 @@ const mono = JetBrains_Mono({ * @param props.pageProps */ function App({Component, pageProps}: AppProps) { + const router = useRouter(); + const pathname = (router.asPath.split('?')[0] ?? '').split('#')[0] ?? '/'; + const markdownHref = hasMarkdownVariant(pathname) ? `${pathname === '/' ? '/index' : pathname}.md` : null; + useEffect(() => { const searchParams = new URLSearchParams(window.location.search); const message = searchParams.get('message'); @@ -51,6 +57,7 @@ function App({Component, pageProps}: AppProps) { Plunk | The Open-Source Email Platform + {markdownHref && } diff --git a/apps/wiki/app/[[...slug]]/page.tsx b/apps/wiki/app/[[...slug]]/page.tsx index 761c566..bc050b8 100644 --- a/apps/wiki/app/[[...slug]]/page.tsx +++ b/apps/wiki/app/[[...slug]]/page.tsx @@ -64,6 +64,11 @@ export async function generateMetadata(props: {params: Promise<{slug?: string[]} return { title: page.data.title, description: page.data.description, + alternates: { + types: { + 'text/markdown': `${page.url}.md`, + }, + }, openGraph: { images: [{url: ogUrl.toString(), width: 1200, height: 630}], }, diff --git a/apps/wiki/app/openapi.json/route.ts b/apps/wiki/app/openapi.json/route.ts new file mode 100644 index 0000000..762ed19 --- /dev/null +++ b/apps/wiki/app/openapi.json/route.ts @@ -0,0 +1,16 @@ +import {openapi} from '@/lib/openapi'; + +export const revalidate = false; + +export async function GET() { + const schemas = await openapi.getSchemas(); + const first = Object.values(schemas)[0]; + if (!first) return new Response('Not found', {status: 404}); + + return new Response(JSON.stringify(first.bundled), { + headers: { + 'Content-Type': 'application/json; charset=utf-8', + 'Cache-Control': 'public, max-age=300, s-maxage=3600', + }, + }); +} diff --git a/apps/wiki/lib/get-llm-text.ts b/apps/wiki/lib/get-llm-text.ts index d573d70..1046996 100644 --- a/apps/wiki/lib/get-llm-text.ts +++ b/apps/wiki/lib/get-llm-text.ts @@ -1,9 +1,17 @@ +import {renderOpenAPIOperationFromMDX} from '@/lib/render-openapi-operation'; import {source} from '@/lib/source'; import type {InferPageType} from 'fumadocs-core/source'; export async function getLLMText(page: InferPageType) { - const processed = await page.data.getText('processed'); + const isOpenAPI = Boolean((page.data as {_openapi?: unknown})._openapi); + if (isOpenAPI) { + const raw = await page.data.getText('raw'); + const rendered = await renderOpenAPIOperationFromMDX(raw, page.data.title, page.url); + if (rendered) return rendered; + } + + const processed = await page.data.getText('processed'); return `# ${page.data.title} (${page.url}) ${processed}`; diff --git a/apps/wiki/lib/render-openapi-operation.ts b/apps/wiki/lib/render-openapi-operation.ts new file mode 100644 index 0000000..0071223 --- /dev/null +++ b/apps/wiki/lib/render-openapi-operation.ts @@ -0,0 +1,279 @@ +import {openapi} from '@/lib/openapi'; + +interface SchemaLike { + type?: string | string[]; + format?: string; + enum?: unknown[]; + description?: string; + default?: unknown; + example?: unknown; + required?: string[]; + properties?: Record; + items?: SchemaLike; + oneOf?: SchemaLike[]; + anyOf?: SchemaLike[]; + allOf?: SchemaLike[]; + nullable?: boolean; + [key: string]: unknown; +} + +interface Parameter { + name: string; + in: string; + required?: boolean; + description?: string; + schema?: SchemaLike; +} + +interface Operation { + operationId?: string; + summary?: string; + description?: string; + deprecated?: boolean; + tags?: string[]; + parameters?: Parameter[]; + requestBody?: { + required?: boolean; + description?: string; + content?: Record; + }; + responses?: Record< + string, + { + description?: string; + content?: Record; + } + >; + security?: Array>; +} + +function typeLabel(schema?: SchemaLike): string { + if (!schema) return 'unknown'; + if (schema.enum) return `enum (${schema.enum.map(v => JSON.stringify(v)).join(' | ')})`; + if (schema.oneOf) return schema.oneOf.map(typeLabel).join(' | '); + if (schema.anyOf) return schema.anyOf.map(typeLabel).join(' | '); + if (schema.allOf) return schema.allOf.map(typeLabel).join(' & '); + if (Array.isArray(schema.type)) return schema.type.join(' | '); + const base = schema.type ?? 'object'; + if (base === 'array' && schema.items) return `array<${typeLabel(schema.items)}>`; + if (schema.format) return `${base} (${schema.format})`; + return base; +} + +function renderSchemaTree(schema: SchemaLike | undefined, depth = 0, lines: string[] = []): string[] { + if (!schema) return lines; + const indent = ' '.repeat(depth); + + if (schema.allOf) { + for (const sub of schema.allOf) renderSchemaTree(sub, depth, lines); + return lines; + } + + if (schema.type === 'object' || schema.properties) { + const required = new Set(schema.required ?? []); + const props = schema.properties ?? {}; + for (const [name, prop] of Object.entries(props)) { + const tag = required.has(name) ? ' (required)' : ''; + const desc = prop.description ? ` — ${prop.description.replace(/\n+/g, ' ')}` : ''; + lines.push(`${indent}- \`${name}\`: ${typeLabel(prop)}${tag}${desc}`); + if (prop.type === 'object' || prop.properties) { + renderSchemaTree(prop, depth + 1, lines); + } else if (prop.type === 'array' && prop.items && (prop.items.type === 'object' || prop.items.properties)) { + lines.push(`${indent} items:`); + renderSchemaTree(prop.items, depth + 2, lines); + } + } + return lines; + } + + if (schema.type === 'array' && schema.items) { + lines.push(`${indent}- items: ${typeLabel(schema.items)}`); + renderSchemaTree(schema.items, depth + 1, lines); + } + + return lines; +} + +function exampleFromSchema(schema?: SchemaLike): unknown { + if (!schema) return undefined; + if (schema.example !== undefined) return schema.example; + if (schema.default !== undefined) return schema.default; + if (schema.enum && schema.enum.length > 0) return schema.enum[0]; + if (schema.allOf) { + const merged: Record = {}; + for (const sub of schema.allOf) Object.assign(merged, exampleFromSchema(sub) as object ?? {}); + return merged; + } + if (schema.oneOf?.[0]) return exampleFromSchema(schema.oneOf[0]); + if (schema.anyOf?.[0]) return exampleFromSchema(schema.anyOf[0]); + + if (schema.properties) { + const out: Record = {}; + const required = new Set(schema.required ?? Object.keys(schema.properties)); + for (const [name, prop] of Object.entries(schema.properties)) { + if (!required.has(name) && schema.required) continue; + out[name] = exampleFromSchema(prop); + } + return out; + } + + if (schema.type === 'array') { + const item = exampleFromSchema(schema.items); + return item === undefined ? [] : [item]; + } + + switch (schema.type) { + case 'string': + if (schema.format === 'email') return 'user@example.com'; + if (schema.format === 'date-time') return new Date().toISOString(); + if (schema.format === 'uri' || schema.format === 'url') return 'https://example.com'; + return 'string'; + case 'integer': + case 'number': + return 0; + case 'boolean': + return false; + default: + return null; + } +} + +function findOperationByMethodPath( + doc: {paths?: Record>}, + method: string, + path: string, +): Operation | undefined { + const pathItem = doc.paths?.[path]; + return pathItem?.[method.toLowerCase()]; +} + +function extractOperationRef(mdxBody: string): {method: string; path: string} | undefined { + const block = mdxBody.match(/operations=\{(\[[\s\S]*?\])\}/); + if (!block) return undefined; + const pathMatch = block[1].match(/['"]?path['"]?\s*:\s*['"]([^'"]+)['"]/); + const methodMatch = block[1].match(/['"]?method['"]?\s*:\s*['"]([^'"]+)['"]/); + if (!pathMatch || !methodMatch) return undefined; + return {path: pathMatch[1], method: methodMatch[1]}; +} + +export async function renderOpenAPIOperationFromMDX( + mdxBody: string, + fallbackTitle: string, + pageUrl: string, +): Promise { + const ref = extractOperationRef(mdxBody); + if (!ref) return undefined; + + const schemas = await openapi.getSchemas(); + const first = Object.values(schemas)[0]; + if (!first) return undefined; + + const doc = first.dereferenced as { + servers?: Array<{url: string}>; + paths?: Record>; + }; + const op = findOperationByMethodPath(doc, ref.method, ref.path); + if (!op) return undefined; + + const lines: string[] = []; + const method = ref.method.toUpperCase(); + const baseUrl = doc.servers?.[0]?.url ?? ''; + + lines.push(`# ${op.summary ?? fallbackTitle} (${pageUrl})`); + lines.push(''); + lines.push(`\`${method} ${ref.path}\``); + lines.push(''); + if (baseUrl) { + lines.push(`Base URL: \`${baseUrl}\``); + lines.push(''); + } + if (op.description) { + lines.push(op.description); + lines.push(''); + } + if (op.deprecated) { + lines.push('> **Deprecated.** This endpoint should not be used in new integrations.'); + lines.push(''); + } + + if (op.parameters && op.parameters.length > 0) { + const grouped: Record = {}; + for (const p of op.parameters) (grouped[p.in] ??= []).push(p); + for (const [location, params] of Object.entries(grouped)) { + lines.push(`## ${location[0].toUpperCase()}${location.slice(1)} parameters`); + lines.push(''); + for (const p of params) { + const req = p.required ? ' (required)' : ''; + const desc = p.description ? ` — ${p.description.replace(/\n+/g, ' ')}` : ''; + lines.push(`- \`${p.name}\`: ${typeLabel(p.schema)}${req}${desc}`); + } + lines.push(''); + } + } + + const body = op.requestBody?.content?.['application/json']?.schema; + if (body) { + lines.push('## Request body'); + lines.push(''); + if (op.requestBody?.description) { + lines.push(op.requestBody.description); + lines.push(''); + } + const tree = renderSchemaTree(body); + if (tree.length > 0) { + lines.push(...tree); + lines.push(''); + } + const example = exampleFromSchema(body); + if (example !== undefined) { + lines.push('Example:'); + lines.push(''); + lines.push('```json'); + lines.push(JSON.stringify(example, null, 2)); + lines.push('```'); + lines.push(''); + } + } + + if (op.responses) { + lines.push('## Responses'); + lines.push(''); + for (const [status, resp] of Object.entries(op.responses)) { + const desc = resp.description ? ` — ${resp.description.replace(/\n+/g, ' ')}` : ''; + lines.push(`### \`${status}\`${desc}`); + lines.push(''); + const schema = resp.content?.['application/json']?.schema; + if (schema) { + const tree = renderSchemaTree(schema); + if (tree.length > 0) { + lines.push(...tree); + lines.push(''); + } + const example = exampleFromSchema(schema); + if (example !== undefined) { + lines.push('```json'); + lines.push(JSON.stringify(example, null, 2)); + lines.push('```'); + lines.push(''); + } + } + } + } + + if (baseUrl) { + lines.push('## Example request'); + lines.push(''); + lines.push('```bash'); + const curlParts = [`curl -X ${method} '${baseUrl}${ref.path}'`, " -H 'Authorization: Bearer YOUR_API_KEY'"]; + if (body) { + curlParts.push(" -H 'Content-Type: application/json'"); + const example = exampleFromSchema(body); + curlParts.push(` -d '${JSON.stringify(example)}'`); + } + lines.push(curlParts.join(' \\\n')); + lines.push('```'); + lines.push(''); + } + + return lines.join('\n'); +} diff --git a/apps/wiki/middleware.ts b/apps/wiki/middleware.ts index 02e0371..39309a0 100644 --- a/apps/wiki/middleware.ts +++ b/apps/wiki/middleware.ts @@ -57,9 +57,10 @@ export function middleware(request: NextRequest) { const response = NextResponse.next(); response.headers.set('Vary', 'Accept'); + response.headers.append('Link', `<${pathname}.md>; rel="alternate"; type="text/markdown"`); return response; } export const config = { - matcher: ['/((?!llms\\.mdx|api/search|_next|.*\\.(?:png|jpg|jpeg|gif|svg|ico|webp|woff|woff2|ttf|css|js|xml|txt|webmanifest)).*)',], + matcher: ['/((?!llms\\.mdx|api/search|openapi\\.json|_next|.*\\.(?:png|jpg|jpeg|gif|svg|ico|webp|woff|woff2|ttf|css|js|json|xml|txt|webmanifest)).*)',], }; diff --git a/apps/wiki/public/llms.txt b/apps/wiki/public/llms.txt new file mode 100644 index 0000000..2bba6b8 --- /dev/null +++ b/apps/wiki/public/llms.txt @@ -0,0 +1,83 @@ +# Plunk Documentation + +> Documentation for Plunk, an open-source email platform for developers. Covers core concepts, integration guides, the REST API, and self-hosting. + +Every documentation page is available as Markdown by appending `.md` to the path (for example, `https://docs.useplunk.com/concepts/contacts.md`), or by requesting the page URL with `Accept: text/markdown`. API reference endpoints render the OpenAPI operation — including parameters, request/response schemas, and a sample curl — directly into the Markdown response. + +The full OpenAPI specification is available at [https://docs.useplunk.com/openapi.json](https://docs.useplunk.com/openapi.json). + +## Getting Started + +- [Welcome to Plunk](https://docs.useplunk.com/index.md): What Plunk is and how to get started + +## Concepts + +- [Contacts](https://docs.useplunk.com/concepts/contacts.md): Manage and organize your contacts +- [Segments](https://docs.useplunk.com/concepts/segments.md): Group and target contacts with dynamic or static segments +- [Templates](https://docs.useplunk.com/concepts/templates.md): Reusable email templates for campaigns, workflows, and transactional emails +- [Campaigns](https://docs.useplunk.com/concepts/campaigns.md): One-off broadcast emails sent to a defined audience +- [Workflows](https://docs.useplunk.com/concepts/workflows.md): Automated, multi-step journeys triggered by events, segments, schedules, or manual entry +- [Transactional emails](https://docs.useplunk.com/concepts/transactional-emails.md): Send emails via API +- [Billing](https://docs.useplunk.com/concepts/billing.md): How Plunk's pricing, limits, and consumption work + +## Guides + +- [API keys](https://docs.useplunk.com/guides/api-keys.md): Plunk's two-key model and how to rotate or revoke keys safely +- [Verifying domains](https://docs.useplunk.com/guides/verifying-domains.md): Verify sending domains so emails reach the inbox +- [Tracking](https://docs.useplunk.com/guides/tracking.md): Track opens and clicks on emails sent through Plunk +- [Webhooks](https://docs.useplunk.com/guides/webhooks.md): Send real-time event data from Plunk to your own application +- [Receiving emails](https://docs.useplunk.com/guides/receiving-emails.md): Receive inbound email at your verified domain and turn it into events +- [Custom fields](https://docs.useplunk.com/guides/custom-fields.md): Store arbitrary data on contacts and use it for personalization and segmentation +- [Segment filter reference](https://docs.useplunk.com/guides/segment-filters.md): How to write filters for dynamic segments, campaign audiences, and workflow conditions +- [Importing contacts from CSV](https://docs.useplunk.com/guides/importing-contacts.md): Bulk-load contacts and their custom fields +- [Unsubscribe & preferences pages](https://docs.useplunk.com/guides/unsubscribe-pages.md): Hosted pages for unsubscribe, resubscribe, and preference management +- [Localization](https://docs.useplunk.com/guides/localization.md): Translate the unsubscribe footer and contact-facing pages +- [List hygiene](https://docs.useplunk.com/guides/list-hygiene.md): Maintain a healthy email list + +## API Reference + +- [API overview](https://docs.useplunk.com/api-reference/overview.md): Complete Plunk API documentation +- [Error codes](https://docs.useplunk.com/api-reference/errors.md): API error codes and troubleshooting + +### Public API + +- [Send transactional email](https://docs.useplunk.com/api-reference/public-api/sendEmail.md): POST — send a transactional email; auto-creates/updates contacts +- [Track event](https://docs.useplunk.com/api-reference/public-api/trackEvent.md): POST — track an event for a contact +- [Verify email address](https://docs.useplunk.com/api-reference/public-api/verifyEmail.md): POST — validate an address, check disposable/MX/typos + +### Contacts + +- [Create or update contact](https://docs.useplunk.com/api-reference/contacts/createContact.md): POST — upsert by email +- [Get contact](https://docs.useplunk.com/api-reference/contacts/getContact.md): GET — single contact by ID +- [List contacts](https://docs.useplunk.com/api-reference/contacts/listContacts.md): GET — paginated, cursor-based +- [Update contact](https://docs.useplunk.com/api-reference/contacts/updateContact.md): PATCH +- [Delete contact](https://docs.useplunk.com/api-reference/contacts/deleteContact.md): DELETE + +### Campaigns + +- [Create campaign](https://docs.useplunk.com/api-reference/campaigns/createCampaign.md): POST +- [List campaigns](https://docs.useplunk.com/api-reference/campaigns/listCampaigns.md): GET — paginated +- [Send or schedule campaign](https://docs.useplunk.com/api-reference/campaigns/sendCampaign.md): POST — send immediately or schedule + +### Templates + +- [Create template](https://docs.useplunk.com/api-reference/templates/createTemplate.md): POST +- [List templates](https://docs.useplunk.com/api-reference/templates/listTemplates.md): GET — paginated + +### Segments + +- [Create segment](https://docs.useplunk.com/api-reference/segments/createSegment.md): POST +- [List segments](https://docs.useplunk.com/api-reference/segments/listSegments.md): GET + +## Self-Hosting + +- [Self-hosting introduction](https://docs.useplunk.com/self-hosting/introduction.md): Deploy Plunk on your own infrastructure +- [Docker deployment](https://docs.useplunk.com/self-hosting/docker.md): Deploy with Docker Compose +- [Environment variables](https://docs.useplunk.com/self-hosting/environment-variables.md): Configuration reference +- [AWS SES setup](https://docs.useplunk.com/self-hosting/email-setup.md): Configure email delivery + +## Optional + +- [Marketing site](https://www.useplunk.com) +- [GitHub repository](https://github.com/useplunk/plunk) +- [Discord community](https://www.useplunk.com/discord)