diff --git a/apps/fumadocs/content/docs/adapters/brevo.mdx b/apps/fumadocs/content/docs/adapters/brevo.mdx
index 6b97972..cfad931 100644
--- a/apps/fumadocs/content/docs/adapters/brevo.mdx
+++ b/apps/fumadocs/content/docs/adapters/brevo.mdx
@@ -83,3 +83,10 @@ BREVO_API_KEY="xkeysib-..." npx email-sdk send \
```
Drop `--dry-run` for one real send to confirm the key, sender verification, and recipient policy end to end.
+
+## Compare Brevo with other providers
+
+Side-by-side message-field support from the SDK capability matrix, with working code for both adapters:
+
+- [SendGrid vs Brevo](/compare/sendgrid-vs-brevo)
+- [Brevo vs MailerSend](/compare/brevo-vs-mailersend)
diff --git a/apps/fumadocs/content/docs/adapters/loops.mdx b/apps/fumadocs/content/docs/adapters/loops.mdx
index e2d3d0d..1e9dc1f 100644
--- a/apps/fumadocs/content/docs/adapters/loops.mdx
+++ b/apps/fumadocs/content/docs/adapters/loops.mdx
@@ -34,7 +34,8 @@ export const email = createEmailClient({
required: true,
},
transactionalId: {
- description: "Published transactional email to render. Must be non-empty; the adapter throws at construction otherwise.",
+ description:
+ "Published transactional email to render. Must be non-empty; the adapter throws at construction otherwise.",
type: "string",
required: true,
},
@@ -74,8 +75,8 @@ Attachments are supported and sent as `{ filename, contentType, data }` with bas
Loops only accepts transactional attachments on accounts where the capability has been enabled.
Without it, sends with attachments fail at the API. Also note `cc`, `bcc`, `replyTo`, `headers`,
- and `tags` throw an `EmailValidationError` before any request; see
- [field support](/docs/adapters/field-support).
+ and `tags` throw an `EmailValidationError` before any request; see [field
+ support](/docs/adapters/field-support).
## Verify from the CLI
@@ -95,3 +96,9 @@ LOOPS_API_KEY="..." LOOPS_TRANSACTIONAL_ID="cm..." npx email-sdk send \
```
Drop `--dry-run` to trigger one real template send and confirm the API key and published transactional email are live.
+
+## Compare Loops with other providers
+
+Side-by-side message-field support from the SDK capability matrix, with working code for both adapters:
+
+- [Resend vs Loops](/compare/resend-vs-loops)
diff --git a/apps/fumadocs/content/docs/adapters/mailersend.mdx b/apps/fumadocs/content/docs/adapters/mailersend.mdx
index 653c1f2..671e553 100644
--- a/apps/fumadocs/content/docs/adapters/mailersend.mdx
+++ b/apps/fumadocs/content/docs/adapters/mailersend.mdx
@@ -60,10 +60,10 @@ console.log(result.id); // MailerSend message id from the x-message-id header
The response `id` comes from the `x-message-id` response header, falling back to `message_id` or `id` in the body. Use it to look the send up in MailerSend's activity log.
- MailerSend has no metadata concept, so `metadata` on a message throws an
- `EmailValidationError` before any request is made. Use `tags` instead, or pick a
- [metadata-capable adapter](/docs/adapters/field-support). MailerSend also gates custom
- `headers` behind its Professional and Enterprise plans.
+ MailerSend has no metadata concept, so `metadata` on a message throws an `EmailValidationError`
+ before any request is made. Use `tags` instead, or pick a [metadata-capable
+ adapter](/docs/adapters/field-support). MailerSend also gates custom `headers` behind its
+ Professional and Enterprise plans.
## Verify from the CLI
@@ -83,3 +83,10 @@ MAILERSEND_API_KEY="mlsn..." npx email-sdk send \
```
Drop `--dry-run` for one real send. The token, domain verification, and plan limits only prove themselves against the live API.
+
+## Compare MailerSend with other providers
+
+Side-by-side message-field support from the SDK capability matrix, with working code for both adapters:
+
+- [Resend vs MailerSend](/compare/resend-vs-mailersend)
+- [Brevo vs MailerSend](/compare/brevo-vs-mailersend)
diff --git a/apps/fumadocs/content/docs/adapters/mailgun.mdx b/apps/fumadocs/content/docs/adapters/mailgun.mdx
index 860cf8c..583a429 100644
--- a/apps/fumadocs/content/docs/adapters/mailgun.mdx
+++ b/apps/fumadocs/content/docs/adapters/mailgun.mdx
@@ -51,8 +51,8 @@ export const email = createEmailClient({
/>
- Domains created in Mailgun's EU region only respond on
- `baseUrl: "https://api.eu.mailgun.net"`. Against the default US origin they fail with a 404.
+ Domains created in Mailgun's EU region only respond on `baseUrl: "https://api.eu.mailgun.net"`.
+ Against the default US origin they fail with a 404.
## Send
@@ -111,3 +111,12 @@ MAILGUN_API_KEY="key-..." npx email-sdk send \
```
Credentials also work as flags: `--api-key`, `--domain`, and `--base-url` (or `MAILGUN_BASE_URL`) override the environment. Drop `--dry-run` for one real send to prove the domain's DNS records and the account are actually ready.
+
+## Compare Mailgun with other providers
+
+Side-by-side message-field support from the SDK capability matrix, with working code for both adapters:
+
+- [Resend vs Mailgun](/compare/resend-vs-mailgun)
+- [Postmark vs Mailgun](/compare/postmark-vs-mailgun)
+- [SendGrid vs Mailgun](/compare/sendgrid-vs-mailgun)
+- [Mailgun vs AWS SES](/compare/mailgun-vs-ses)
diff --git a/apps/fumadocs/content/docs/adapters/postmark.mdx b/apps/fumadocs/content/docs/adapters/postmark.mdx
index 4a96a02..f6f9692 100644
--- a/apps/fumadocs/content/docs/adapters/postmark.mdx
+++ b/apps/fumadocs/content/docs/adapters/postmark.mdx
@@ -75,8 +75,8 @@ console.log(result.id); // Postmark MessageID, also exposed as result.messageId
Postmark has a single `Tag` field. The adapter serializes the first tag as `name:value` and a
- second tag throws an `EmailValidationError` before any request is made. Need multiple tags?
- Check the [field support matrix](/docs/adapters/field-support).
+ second tag throws an `EmailValidationError` before any request is made. Need multiple tags? Check
+ the [field support matrix](/docs/adapters/field-support).
## Verify from the CLI
@@ -97,3 +97,12 @@ POSTMARK_SERVER_TOKEN="..." npx email-sdk send \
```
The token also accepts the `--server-token` flag. Drop `--dry-run` for one real send to confirm the server token, sender signature, and message stream actually work together.
+
+## Compare Postmark with other providers
+
+Side-by-side message-field support from the SDK capability matrix, with working code for both adapters:
+
+- [Resend vs Postmark](/compare/resend-vs-postmark)
+- [Postmark vs SendGrid](/compare/postmark-vs-sendgrid)
+- [Postmark vs Mailgun](/compare/postmark-vs-mailgun)
+- [Postmark vs AWS SES](/compare/postmark-vs-ses)
diff --git a/apps/fumadocs/content/docs/adapters/resend.mdx b/apps/fumadocs/content/docs/adapters/resend.mdx
index 9605891..a69d1b3 100644
--- a/apps/fumadocs/content/docs/adapters/resend.mdx
+++ b/apps/fumadocs/content/docs/adapters/resend.mdx
@@ -70,8 +70,8 @@ console.log(result.id); // Resend email id, also exposed as result.messageId
The `idempotencyKey` is sent as Resend's `Idempotency-Key` header, so retried requests cannot double-send.
- Resend has no metadata concept, so `metadata` on a message throws an `EmailValidationError`
- before any request is made. Use `tags` instead, or pick a [metadata-capable
+ Resend has no metadata concept, so `metadata` on a message throws an `EmailValidationError` before
+ any request is made. Use `tags` instead, or pick a [metadata-capable
adapter](/docs/adapters/field-support).
@@ -92,3 +92,14 @@ RESEND_API_KEY="re_..." npx email-sdk send \
```
Drop `--dry-run` to perform one real send from the environment that will send production email. Local validation cannot tell you whether the account, sender domain, and recipient policy are actually ready; one live send can.
+
+## Compare Resend with other providers
+
+Side-by-side message-field support from the SDK capability matrix, with working code for both adapters:
+
+- [Resend vs Postmark](/compare/resend-vs-postmark)
+- [Resend vs SendGrid](/compare/resend-vs-sendgrid)
+- [Resend vs AWS SES](/compare/resend-vs-ses)
+- [Resend vs Mailgun](/compare/resend-vs-mailgun)
+- [Resend vs Loops](/compare/resend-vs-loops)
+- [Resend vs MailerSend](/compare/resend-vs-mailersend)
diff --git a/apps/fumadocs/content/docs/adapters/sendgrid.mdx b/apps/fumadocs/content/docs/adapters/sendgrid.mdx
index 70ed2b0..5191780 100644
--- a/apps/fumadocs/content/docs/adapters/sendgrid.mdx
+++ b/apps/fumadocs/content/docs/adapters/sendgrid.mdx
@@ -94,3 +94,13 @@ SENDGRID_API_KEY="SG...." npx email-sdk send \
```
Drop `--dry-run` for one real send. SendGrid account review and sender verification can block delivery even when the payload validates, and only a live send proves they are done.
+
+## Compare SendGrid with other providers
+
+Side-by-side message-field support from the SDK capability matrix, with working code for both adapters:
+
+- [Resend vs SendGrid](/compare/resend-vs-sendgrid)
+- [Postmark vs SendGrid](/compare/postmark-vs-sendgrid)
+- [SendGrid vs Mailgun](/compare/sendgrid-vs-mailgun)
+- [SendGrid vs AWS SES](/compare/sendgrid-vs-ses)
+- [SendGrid vs Brevo](/compare/sendgrid-vs-brevo)
diff --git a/apps/fumadocs/content/docs/adapters/ses.mdx b/apps/fumadocs/content/docs/adapters/ses.mdx
index e961131..cc1a767 100644
--- a/apps/fumadocs/content/docs/adapters/ses.mdx
+++ b/apps/fumadocs/content/docs/adapters/ses.mdx
@@ -88,8 +88,8 @@ console.log(result.id); // SES MessageId, also exposed as result.messageId
SES v2 `SendEmail` has no metadata concept, so `metadata` on a message throws an
- `EmailValidationError` before any request is made. Use `tags` instead, or pick a
- [metadata-capable adapter](/docs/adapters/field-support).
+ `EmailValidationError` before any request is made. Use `tags` instead, or pick a [metadata-capable
+ adapter](/docs/adapters/field-support).
## Verify from the CLI
@@ -113,3 +113,12 @@ npx email-sdk send \
```
`--session-token` and `--configuration-set` flags are also available. Drop `--dry-run` for one real send. It is the only way to prove the identity is verified and the account is out of the SES sandbox.
+
+## Compare AWS SES with other providers
+
+Side-by-side message-field support from the SDK capability matrix, with working code for both adapters:
+
+- [Resend vs AWS SES](/compare/resend-vs-ses)
+- [Postmark vs AWS SES](/compare/postmark-vs-ses)
+- [SendGrid vs AWS SES](/compare/sendgrid-vs-ses)
+- [Mailgun vs AWS SES](/compare/mailgun-vs-ses)
diff --git a/apps/fumadocs/content/docs/authentication.mdx b/apps/fumadocs/content/docs/authentication.mdx
index 1c1ca9c..ad7625e 100644
--- a/apps/fumadocs/content/docs/authentication.mdx
+++ b/apps/fumadocs/content/docs/authentication.mdx
@@ -5,15 +5,15 @@ icon: KeyRound
---
Email SDK is a library, not a hosted service. **There is no Email SDK account, API
-key, or OAuth flow.** You authenticate to each email provider with *that provider's*
+key, or OAuth flow.** You authenticate to each email provider with _that provider's_
own credential, which you read from an environment variable and pass to the
adapter. The adapter sets the provider's required auth header (or signs the request)
for you.
- One typed client, many providers: the only thing that changes between providers
- is the factory you import and the credential you give it. For an agent-facing
- summary of this model see [/auth.md](https://email-sdk.dev/auth.md).
+ One typed client, many providers: the only thing that changes between providers is the factory you
+ import and the credential you give it. For an agent-facing summary of this model see
+ [/auth.md](https://email-sdk.dev/auth.md).
## How credentials flow
@@ -34,31 +34,31 @@ few add a routing id, use a key pair, or sign requests.
## Credential by provider
-| Provider | Import | Credential model | Required config → environment variable |
-| --- | --- | --- | --- |
-| Resend | `@opencoredev/email-sdk/resend` | API key | `apiKey` → `RESEND_API_KEY` |
-| Postmark | `@opencoredev/email-sdk/postmark` | Server token | `serverToken` → `POSTMARK_SERVER_TOKEN` |
-| SendGrid | `@opencoredev/email-sdk/sendgrid` | API key | `apiKey` → `SENDGRID_API_KEY` |
-| Cloudflare | `@opencoredev/email-sdk/cloudflare` | API token + account | `apiToken` → `CLOUDFLARE_API_TOKEN`, `accountId` → `CLOUDFLARE_ACCOUNT_ID` |
-| Unosend | `@opencoredev/email-sdk/unosend` | API key | `apiKey` → `UNOSEND_API_KEY` |
-| AWS SES | `@opencoredev/email-sdk/ses` | Access key pair (SigV4) | `accessKeyId` → `AWS_ACCESS_KEY_ID`, `secretAccessKey` → `AWS_SECRET_ACCESS_KEY`, `region` → `AWS_REGION` (optional `sessionToken` → `AWS_SESSION_TOKEN`) |
-| Mailgun | `@opencoredev/email-sdk/mailgun` | API key + domain | `apiKey` → `MAILGUN_API_KEY`, `domain` → `MAILGUN_DOMAIN` |
-| MailerSend | `@opencoredev/email-sdk/mailersend` | API key | `apiKey` → `MAILERSEND_API_KEY` |
-| Brevo | `@opencoredev/email-sdk/brevo` | API key | `apiKey` → `BREVO_API_KEY` |
-| Mailchimp Transactional | `@opencoredev/email-sdk/mailchimp` | API key | `apiKey` → `MAILCHIMP_API_KEY` |
-| SparkPost | `@opencoredev/email-sdk/sparkpost` | API key | `apiKey` → `SPARKPOST_API_KEY` |
-| Iterable | `@opencoredev/email-sdk/iterable` | API key + campaign | `apiKey` → `ITERABLE_API_KEY`, `campaignId` → `ITERABLE_CAMPAIGN_ID` |
-| Loops | `@opencoredev/email-sdk/loops` | API key + transactional id | `apiKey` → `LOOPS_API_KEY`, `transactionalId` → `LOOPS_TRANSACTIONAL_ID` |
-| Sequenzy | `@opencoredev/email-sdk/sequenzy` | API key | `apiKey` → `SEQUENZY_API_KEY` |
-| JetEmail | `@opencoredev/email-sdk/jetemail` | API key | `apiKey` → `JETEMAIL_API_KEY` |
-| Lettermint | `@opencoredev/email-sdk/lettermint` | API token | `apiToken` → `LETTERMINT_API_TOKEN` |
-| Primitive | `@opencoredev/email-sdk/primitive` | API key | `apiKey` → `PRIMITIVE_API_KEY` |
-| Plunk | `@opencoredev/email-sdk/plunk` | API key | `apiKey` → `PLUNK_API_KEY` |
-| Mailtrap | `@opencoredev/email-sdk/mailtrap` | API key | `apiKey` → `MAILTRAP_API_KEY` |
-| Scaleway | `@opencoredev/email-sdk/scaleway` | Project id + secret key | `projectId` → `SCALEWAY_PROJECT_ID`, `secretKey` → `SCALEWAY_SECRET_KEY` (optional `region`, defaults to `fr-par`) |
-| ZeptoMail | `@opencoredev/email-sdk/zeptomail` | Send-mail token | `token` → `ZEPTOMAIL_TOKEN` |
-| MailPace | `@opencoredev/email-sdk/mailpace` | API key | `apiKey` → `MAILPACE_API_KEY` |
-| SMTP | `@opencoredev/email-sdk/smtp` | SMTP username + password | `host`, `port`, `secure`, `auth: { user, pass }` → `SMTP_USER`, `SMTP_PASS` |
+| Provider | Import | Credential model | Required config → environment variable |
+| ----------------------- | ----------------------------------- | -------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------- |
+| Resend | `@opencoredev/email-sdk/resend` | API key | `apiKey` → `RESEND_API_KEY` |
+| Postmark | `@opencoredev/email-sdk/postmark` | Server token | `serverToken` → `POSTMARK_SERVER_TOKEN` |
+| SendGrid | `@opencoredev/email-sdk/sendgrid` | API key | `apiKey` → `SENDGRID_API_KEY` |
+| Cloudflare | `@opencoredev/email-sdk/cloudflare` | API token + account | `apiToken` → `CLOUDFLARE_API_TOKEN`, `accountId` → `CLOUDFLARE_ACCOUNT_ID` |
+| Unosend | `@opencoredev/email-sdk/unosend` | API key | `apiKey` → `UNOSEND_API_KEY` |
+| AWS SES | `@opencoredev/email-sdk/ses` | Access key pair (SigV4) | `accessKeyId` → `AWS_ACCESS_KEY_ID`, `secretAccessKey` → `AWS_SECRET_ACCESS_KEY`, `region` → `AWS_REGION` (optional `sessionToken` → `AWS_SESSION_TOKEN`) |
+| Mailgun | `@opencoredev/email-sdk/mailgun` | API key + domain | `apiKey` → `MAILGUN_API_KEY`, `domain` → `MAILGUN_DOMAIN` |
+| MailerSend | `@opencoredev/email-sdk/mailersend` | API key | `apiKey` → `MAILERSEND_API_KEY` |
+| Brevo | `@opencoredev/email-sdk/brevo` | API key | `apiKey` → `BREVO_API_KEY` |
+| Mailchimp Transactional | `@opencoredev/email-sdk/mailchimp` | API key | `apiKey` → `MAILCHIMP_API_KEY` |
+| SparkPost | `@opencoredev/email-sdk/sparkpost` | API key | `apiKey` → `SPARKPOST_API_KEY` |
+| Iterable | `@opencoredev/email-sdk/iterable` | API key + campaign | `apiKey` → `ITERABLE_API_KEY`, `campaignId` → `ITERABLE_CAMPAIGN_ID` |
+| Loops | `@opencoredev/email-sdk/loops` | API key + transactional id | `apiKey` → `LOOPS_API_KEY`, `transactionalId` → `LOOPS_TRANSACTIONAL_ID` |
+| Sequenzy | `@opencoredev/email-sdk/sequenzy` | API key | `apiKey` → `SEQUENZY_API_KEY` |
+| JetEmail | `@opencoredev/email-sdk/jetemail` | API key | `apiKey` → `JETEMAIL_API_KEY` |
+| Lettermint | `@opencoredev/email-sdk/lettermint` | API token | `apiToken` → `LETTERMINT_API_TOKEN` |
+| Primitive | `@opencoredev/email-sdk/primitive` | API key | `apiKey` → `PRIMITIVE_API_KEY` |
+| Plunk | `@opencoredev/email-sdk/plunk` | API key | `apiKey` → `PLUNK_API_KEY` |
+| Mailtrap | `@opencoredev/email-sdk/mailtrap` | API key | `apiKey` → `MAILTRAP_API_KEY` |
+| Scaleway | `@opencoredev/email-sdk/scaleway` | Project id + secret key | `projectId` → `SCALEWAY_PROJECT_ID`, `secretKey` → `SCALEWAY_SECRET_KEY` (optional `region`, defaults to `fr-par`) |
+| ZeptoMail | `@opencoredev/email-sdk/zeptomail` | Send-mail token | `token` → `ZEPTOMAIL_TOKEN` |
+| MailPace | `@opencoredev/email-sdk/mailpace` | API key | `apiKey` → `MAILPACE_API_KEY` |
+| SMTP | `@opencoredev/email-sdk/smtp` | SMTP username + password | `host`, `port`, `secure`, `auth: { user, pass }` → `SMTP_USER`, `SMTP_PASS` |
Each provider page under [Adapters](/docs/adapters) links to where that credential
is issued and lists every configuration field.
@@ -122,10 +122,10 @@ export const email = createEmailClient({
## Keeping credentials safe
- Keep every credential in environment variables or a secret manager. Never
- hardcode or log API keys, SMTP passwords, raw tokens, full message bodies, or
- recipient lists. When an agent drives sends, gate them behind explicit human
- approval and run `email-sdk doctor` and `send --dry-run` first.
+ Keep every credential in environment variables or a secret manager. Never hardcode or log API
+ keys, SMTP passwords, raw tokens, full message bodies, or recipient lists. When an agent drives
+ sends, gate them behind explicit human approval and run `email-sdk doctor` and `send --dry-run`
+ first.
- Scope each provider key to sending only, where the provider supports it.
@@ -137,3 +137,11 @@ export const email = createEmailClient({
## Per-provider setup
+
+## Verify your sending domain's DNS
+
+API credentials get the request accepted; SPF, DKIM, and DMARC records get the
+mail delivered. After verifying a domain with your provider, confirm the records
+actually resolve with the free [email DNS checker](/tools/email-dns-checker) —
+it validates SPF syntax and lookup limits, probes common DKIM selectors, and
+checks your DMARC policy.
diff --git a/apps/fumadocs/package.json b/apps/fumadocs/package.json
index 7024130..d903180 100644
--- a/apps/fumadocs/package.json
+++ b/apps/fumadocs/package.json
@@ -8,6 +8,8 @@
"build": "bun scripts/check-module-identity.ts && bun scripts/fetch-notra-posts.ts && bun scripts/og/generate-og-image.ts && vite build && bun scripts/check-client-bundle.ts && bun scripts/ensure-root-index.ts",
"posts:fetch": "bun scripts/fetch-notra-posts.ts",
"og:generate": "bun scripts/og/generate-og-image.ts",
+ "lastmod:generate": "bun scripts/generate-docs-lastmod.ts",
+ "field-support:generate": "bun scripts/generate-field-support.ts",
"start": "serve .output/public --config ../../serve.json",
"preview": "vite preview",
"test": "bun test",
diff --git a/apps/fumadocs/public/robots.txt b/apps/fumadocs/public/robots.txt
index dcdcf72..71d1f56 100644
--- a/apps/fumadocs/public/robots.txt
+++ b/apps/fumadocs/public/robots.txt
@@ -1,5 +1,8 @@
# Email SDK — crawler policy
# Default: every well-behaved crawler may index the whole site.
+# Note: /docs/v/ (old doc versions) is intentionally NOT disallowed — those
+# pages carry a noindex meta tag, and crawlers must be able to fetch them to
+# see it. Blocking them here would leave stale URLs stuck in search indexes.
User-agent: *
Allow: /
# Content Signals (Cloudflare spec): allow search indexing and AI answer-engine
diff --git a/apps/fumadocs/scripts/generate-docs-lastmod.ts b/apps/fumadocs/scripts/generate-docs-lastmod.ts
new file mode 100644
index 0000000..30675a9
--- /dev/null
+++ b/apps/fumadocs/scripts/generate-docs-lastmod.ts
@@ -0,0 +1,52 @@
+/**
+ * Regenerates src/lib/docs-lastmod.generated.json with the last git commit date
+ * (YYYY-MM-DD) of every current-version docs page, keyed by path relative to
+ * content/docs. The manifest is committed because Vercel builds from a shallow
+ * clone where per-file git history is unavailable.
+ *
+ * Run with: bun run lastmod:generate (from apps/fumadocs) — do this whenever
+ * docs content changes, ideally as part of the release flow.
+ */
+import { spawnSync } from "node:child_process";
+import { readdirSync, writeFileSync } from "node:fs";
+import { join, relative, resolve } from "node:path";
+
+const appDir = resolve(import.meta.dir, "..");
+const docsDir = join(appDir, "content/docs");
+const outputPath = join(appDir, "src/lib/docs-lastmod.generated.json");
+
+function listMdxFiles(dir: string): string[] {
+ return readdirSync(dir, { withFileTypes: true }).flatMap((entry) => {
+ const fullPath = join(dir, entry.name);
+ if (entry.isDirectory()) return listMdxFiles(fullPath);
+ return entry.name.endsWith(".mdx") ? [fullPath] : [];
+ });
+}
+
+function gitLastCommitDate(filePath: string): string | undefined {
+ const result = spawnSync("git", ["log", "-1", "--format=%cs", "--", filePath], {
+ cwd: appDir,
+ encoding: "utf8",
+ });
+ const date = result.stdout.trim();
+ return /^\d{4}-\d{2}-\d{2}$/.test(date) ? date : undefined;
+}
+
+const shallowCheck = spawnSync("git", ["rev-parse", "--is-shallow-repository"], {
+ cwd: appDir,
+ encoding: "utf8",
+});
+if (shallowCheck.stdout.trim() === "true") {
+ console.error("Repository is a shallow clone; git dates would be wrong. Aborting.");
+ process.exit(1);
+}
+
+const today = new Date().toISOString().slice(0, 10);
+const manifest: Record = {};
+for (const file of listMdxFiles(docsDir).sort()) {
+ // Uncommitted pages fall back to today so new docs still get a real date.
+ manifest[relative(docsDir, file)] = gitLastCommitDate(file) ?? today;
+}
+
+writeFileSync(outputPath, `${JSON.stringify(manifest, null, 2)}\n`);
+console.log(`Wrote ${Object.keys(manifest).length} entries to ${relative(appDir, outputPath)}`);
diff --git a/apps/fumadocs/scripts/generate-field-support.ts b/apps/fumadocs/scripts/generate-field-support.ts
new file mode 100644
index 0000000..40956a6
--- /dev/null
+++ b/apps/fumadocs/scripts/generate-field-support.ts
@@ -0,0 +1,17 @@
+/**
+ * Regenerates src/lib/field-support.generated.json from the SDK's
+ * SUPPORTED_MESSAGE_FIELDS matrix. The compare pages hydrate on the client, so
+ * they can't import the SDK source directly (it pulls in node:fs); this
+ * snapshot keeps the pages on a client-safe copy of the same data. A test in
+ * src/lib/compare.test.ts fails if the snapshot drifts from the SDK.
+ *
+ * Run with: bun run field-support:generate (from apps/fumadocs).
+ */
+import { writeFileSync } from "node:fs";
+import { join, resolve } from "node:path";
+
+import { SUPPORTED_MESSAGE_FIELDS } from "../../../packages/email-sdk/src/utils";
+
+const outputPath = join(resolve(import.meta.dir, ".."), "src/lib/field-support.generated.json");
+writeFileSync(outputPath, `${JSON.stringify(SUPPORTED_MESSAGE_FIELDS, null, 2)}\n`);
+console.log(`Wrote ${Object.keys(SUPPORTED_MESSAGE_FIELDS).length} adapters to ${outputPath}`);
diff --git a/apps/fumadocs/src/components/compare-table.tsx b/apps/fumadocs/src/components/compare-table.tsx
new file mode 100644
index 0000000..1c10e7c
--- /dev/null
+++ b/apps/fumadocs/src/components/compare-table.tsx
@@ -0,0 +1,84 @@
+import {
+ getFieldSupport,
+ messageFieldLabels,
+ messageFields,
+ type ProviderKey,
+} from "@/lib/compare";
+import { type Provider } from "@/lib/providers";
+
+function SupportMark({ supported }: { supported: boolean }) {
+ return supported ? (
+
+ ✓
+
+ ) : (
+
+ —
+
+ );
+}
+
+export function CompareTable({ columns }: { columns: { key: ProviderKey; name: string }[] }) {
+ return (
+