From 941ceba6a59fb0948eda931dfd72d7e626bf23d8 Mon Sep 17 00:00:00 2001 From: Leo Orel Date: Sat, 11 Jul 2026 23:58:56 -0400 Subject: [PATCH] feat(seo): fix GSC indexing issues, add provider comparisons and DNS checker Search Console reports ~450 pages stuck in "Discovered/Crawled - currently not indexed" and duplicate-canonical states. Root cause: 450 old-version doc snapshots under /docs/v/ are prerendered, sitemap-listed, self-canonical, and fully crawlable, so Google sees ~10x near-duplicate content. Indexing fixes: - Old-version docs now emit - Sitemap lists only current-version docs (~500 -> ~82 URLs) - Real per-page lastmod from git history via a committed manifest (scripts/generate-docs-lastmod.ts), replacing a hardcoded date - robots.txt comment documents why /docs/v/ stays crawlable (noindex must be seen to work) - Frontmatter guard test: every docs page needs a unique 40-170 char description New keyword surfaces: - /compare + 14 curated provider comparison pages (resend-vs-postmark, sendgrid-vs-mailgun, ...) generated from the SDK's own SUPPORTED_MESSAGE_FIELDS capability matrix, with hand-written intros, fallback-compatibility analysis, code for both adapters, and FAQPage JSON-LD; reversed slugs 301 to the canonical order - /tools/email-dns-checker: free SPF/DKIM/DMARC/MX checker using DNS-over-HTTPS server-side, with provider hints linking back to adapter docs and WebApplication JSON-LD - Nav links, sitemap entries, and cross-links from adapter/auth docs SUPPORTED_MESSAGE_FIELDS is now exported from @opencoredev/email-sdk and snapshotted to a client-safe JSON (guarded by a sync test). Generated-By: PostHog Code Task-Id: d350c3df-f1aa-4b85-9ba9-1b16318cbde9 --- apps/fumadocs/content/docs/adapters/brevo.mdx | 7 + apps/fumadocs/content/docs/adapters/loops.mdx | 13 +- .../content/docs/adapters/mailersend.mdx | 15 +- .../content/docs/adapters/mailgun.mdx | 13 +- .../content/docs/adapters/postmark.mdx | 13 +- .../fumadocs/content/docs/adapters/resend.mdx | 15 +- .../content/docs/adapters/sendgrid.mdx | 10 + apps/fumadocs/content/docs/adapters/ses.mdx | 13 +- apps/fumadocs/content/docs/authentication.mdx | 74 +++-- apps/fumadocs/package.json | 2 + apps/fumadocs/public/robots.txt | 3 + .../fumadocs/scripts/generate-docs-lastmod.ts | 52 +++ .../scripts/generate-field-support.ts | 17 + .../fumadocs/src/components/compare-table.tsx | 84 +++++ apps/fumadocs/src/lib/compare.test.ts | 40 +++ apps/fumadocs/src/lib/compare.ts | 202 ++++++++++++ apps/fumadocs/src/lib/dns-checker-runtime.ts | 153 +++++++++ apps/fumadocs/src/lib/dns-checker.test.ts | 116 +++++++ apps/fumadocs/src/lib/dns-checker.ts | 258 +++++++++++++++ .../fumadocs/src/lib/docs-frontmatter.test.ts | 57 ++++ .../src/lib/docs-lastmod.generated.json | 57 ++++ .../src/lib/field-support.generated.json | 169 ++++++++++ apps/fumadocs/src/lib/layout.shared.tsx | 12 + apps/fumadocs/src/routeTree.gen.ts | 84 +++++ apps/fumadocs/src/routes/compare/$pair.tsx | 275 ++++++++++++++++ apps/fumadocs/src/routes/compare/index.tsx | 82 +++++ apps/fumadocs/src/routes/docs/$.tsx | 4 + apps/fumadocs/src/routes/sitemap[.]xml.ts | 49 ++- .../src/routes/tools/email-dns-checker.tsx | 296 ++++++++++++++++++ apps/fumadocs/src/routes/tools/index.tsx | 65 ++++ apps/fumadocs/vite.config.ts | 11 + packages/email-sdk/src/index.ts | 2 + vercel.json | 78 ++++- 33 files changed, 2280 insertions(+), 61 deletions(-) create mode 100644 apps/fumadocs/scripts/generate-docs-lastmod.ts create mode 100644 apps/fumadocs/scripts/generate-field-support.ts create mode 100644 apps/fumadocs/src/components/compare-table.tsx create mode 100644 apps/fumadocs/src/lib/compare.test.ts create mode 100644 apps/fumadocs/src/lib/compare.ts create mode 100644 apps/fumadocs/src/lib/dns-checker-runtime.ts create mode 100644 apps/fumadocs/src/lib/dns-checker.test.ts create mode 100644 apps/fumadocs/src/lib/dns-checker.ts create mode 100644 apps/fumadocs/src/lib/docs-frontmatter.test.ts create mode 100644 apps/fumadocs/src/lib/docs-lastmod.generated.json create mode 100644 apps/fumadocs/src/lib/field-support.generated.json create mode 100644 apps/fumadocs/src/routes/compare/$pair.tsx create mode 100644 apps/fumadocs/src/routes/compare/index.tsx create mode 100644 apps/fumadocs/src/routes/tools/email-dns-checker.tsx create mode 100644 apps/fumadocs/src/routes/tools/index.tsx 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 ( +
+ + + + + {columns.map((column) => ( + + ))} + + + + {messageFields.map((field) => ( + + + {columns.map((column) => ( + + ))} + + ))} + +
Message field + {column.name} +
{messageFieldLabels[field]} + +
+
+ ); +} + +export function FullCompareTable({ providers: allProviders }: { providers: readonly Provider[] }) { + return ( +
+ + + + + {messageFields.map((field) => ( + + ))} + + + + {allProviders.map((provider) => { + const support = getFieldSupport(provider.key as ProviderKey); + return ( + + + {messageFields.map((field) => ( + + ))} + + ); + })} + +
Provider + {field} +
{provider.name} + +
+
+ ); +} diff --git a/apps/fumadocs/src/lib/compare.test.ts b/apps/fumadocs/src/lib/compare.test.ts new file mode 100644 index 0000000..46aa4af --- /dev/null +++ b/apps/fumadocs/src/lib/compare.test.ts @@ -0,0 +1,40 @@ +import { describe, expect, test } from "bun:test"; + +import { SUPPORTED_MESSAGE_FIELDS } from "../../../../packages/email-sdk/src/utils"; +import { comparePairs, getProvider } from "./compare"; +import fieldSupport from "./field-support.generated.json"; + +describe("field-support snapshot", () => { + test("matches the SDK's SUPPORTED_MESSAGE_FIELDS (run `bun run field-support:generate` if this fails)", () => { + expect(fieldSupport).toEqual(SUPPORTED_MESSAGE_FIELDS); + }); +}); + +describe("compare pairs", () => { + test("slugs are unique and match their provider keys", () => { + const slugs = comparePairs.map((pair) => pair.slug); + expect(new Set(slugs).size).toBe(slugs.length); + for (const pair of comparePairs) { + expect(pair.slug).toBe(`${pair.a}-vs-${pair.b}`); + } + }); + + test("every pair references known providers with field-support data", () => { + for (const pair of comparePairs) { + expect(getProvider(pair.a)).toBeDefined(); + expect(getProvider(pair.b)).toBeDefined(); + expect(fieldSupport[pair.a]).toBeDefined(); + expect(fieldSupport[pair.b]).toBeDefined(); + } + }); + + test("every intro is pair-specific prose of at least 120 words", () => { + const thin = comparePairs.filter((pair) => pair.intro.split(/\s+/).length < 120); + expect(thin.map((pair) => pair.slug)).toEqual([]); + }); + + test("intros are not duplicated across pairs", () => { + const intros = comparePairs.map((pair) => pair.intro); + expect(new Set(intros).size).toBe(intros.length); + }); +}); diff --git a/apps/fumadocs/src/lib/compare.ts b/apps/fumadocs/src/lib/compare.ts new file mode 100644 index 0000000..9fba39b --- /dev/null +++ b/apps/fumadocs/src/lib/compare.ts @@ -0,0 +1,202 @@ +// Relative imports (not "@/..."): vite.config.ts imports this module at config +// time to prerender the compare routes, where tsconfig path aliases don't resolve. +import fieldSupport from "./field-support.generated.json"; +import { type Provider, providers } from "./providers"; + +export const messageFields = [ + "cc", + "bcc", + "replyTo", + "headers", + "attachments", + "tags", + "metadata", + "sendAt", +] as const; + +export type MessageField = (typeof messageFields)[number]; + +export const messageFieldLabels: Record = { + cc: "CC recipients", + bcc: "BCC recipients", + replyTo: "Reply-To address", + headers: "Custom headers", + attachments: "Attachments", + tags: "Tags", + metadata: "Metadata", + sendAt: "Scheduled sending (sendAt)", +}; + +export type ProviderKey = keyof typeof fieldSupport; + +export function getFieldSupport(key: ProviderKey): Partial> { + return fieldSupport[key]; +} + +export function getProvider(key: ProviderKey): Provider { + const provider = providers.find((entry) => entry.key === key); + if (!provider) throw new Error(`Unknown provider key: ${key}`); + return provider; +} + +/** + * Fields that are lost when failing over from `from` to `to` — exactly what + * assertSupportedMessageFields rejects on the fallback adapter. This is the + * SDK's own compatibility data, surfaced for comparison pages. + */ +export function getFallbackGaps(from: ProviderKey, to: ProviderKey): MessageField[] { + const fromSupport = getFieldSupport(from); + const toSupport = getFieldSupport(to); + return messageFields.filter((field) => fromSupport[field] && !toSupport[field]); +} + +export type ComparePair = { + slug: string; + a: ProviderKey; + b: ProviderKey; + /** + * Hand-written, pair-specific intro (>=120 words). Never templated — this is + * what keeps the pages from being thin programmatic content. + */ + intro: string; +}; + +export const comparePairs: ComparePair[] = [ + { + slug: "resend-vs-postmark", + a: "resend", + b: "postmark", + intro: + "Resend and Postmark are both developer-first transactional email APIs, but they grew up in different eras. Postmark has spent over a decade earning a reputation for fast, reliable inbox delivery and strict separation of transactional and broadcast streams, which makes it a favorite for password resets, receipts, and other must-arrive mail. Resend is the newer entrant, built by the team behind React Email, and wins on developer experience: a modern dashboard, first-class React templates, and quick domain setup. In the message model the practical differences show up at the edges — Resend supports scheduled sending natively while Postmark does not, and Postmark exposes structured metadata on each message while Resend leans on tags. Both handle CC, BCC, reply-to, custom headers, and attachments without fuss.", + }, + { + slug: "resend-vs-sendgrid", + a: "resend", + b: "sendgrid", + intro: + "SendGrid (now part of Twilio) is the incumbent of transactional email: it has been around since 2009, handles enormous volume, and its v3 Mail Send API covers nearly every feature a sender could ask for — including per-message metadata and scheduled sends. That maturity comes with a sprawling dashboard, IP-reputation management you may need to do yourself on shared pools, and a pricing ladder that can surprise you as volume grows. Resend is the opposite trade: a small, sharp API with excellent defaults, React Email templates, and a fast path from signup to first send, at the cost of fewer enterprise controls. On the message shape itself the two are nearly equivalent; SendGrid's extra metadata field support is the only structural difference the SDK has to reconcile.", + }, + { + slug: "resend-vs-ses", + a: "resend", + b: "ses", + intro: + "Amazon SES is the cheapest way to send email at scale — fractions of a cent per message — and it sits inside the AWS ecosystem you may already run on, with IAM auth, CloudWatch metrics, and regional endpoints. The trade-off is that SES is infrastructure, not a product: you manage your own sending reputation, configuration sets, and bounce handling, and the console is unmistakably AWS. Resend is a managed product with opinionated defaults, a clean dashboard, and React Email support, priced per-message at a premium over raw SES. In the unified message model the gap is scheduling and structured data: Resend supports sendAt natively while SES has no scheduled-send API, and neither exposes per-message metadata, so both lean on tags for categorization.", + }, + { + slug: "resend-vs-mailgun", + a: "resend", + b: "mailgun", + intro: + "Mailgun was one of the original developer email APIs and it still shows: flexible routing, inbound parsing, EU and US regions, and a message API that supports everything in the unified shape — CC, BCC, headers, attachments, tags, metadata, and scheduled delivery. It is a strong pick when you need inbound mail or regional data residency alongside outbound sends. Resend is younger and more focused: outbound transactional email with a polished developer experience, React Email templates, and a simpler pricing page, but no per-message metadata field and no inbound handling. If your application round-trips structured data on every message, Mailgun's metadata support matters; if your priority is shipping the first send this afternoon, Resend's onboarding is hard to beat, and the SDK keeps a later migration to a single-line change.", + }, + { + slug: "resend-vs-loops", + a: "resend", + b: "loops", + intro: + "Resend and Loops both target modern SaaS teams but solve different problems. Resend is a transactional email API in the classic sense: you construct a message — recipients, subject, HTML, attachments — and it delivers it, with scheduling, tags, and full header control. Loops is an email platform for product companies where transactional sends are one feature next to marketing loops and audience management; its transactional API is intentionally minimal and template-driven, so the unified message shape maps onto a much smaller surface — no CC or BCC, no custom headers, no tags, and no scheduled sending, though it does carry per-message metadata (data variables) and attachments. Pick Loops when the same tool should own lifecycle and marketing email; pick Resend when you want a full-fidelity send API.", + }, + { + slug: "resend-vs-mailersend", + a: "resend", + b: "mailersend", + intro: + "MailerSend is the transactional arm of the MailerLite family, and it inherits that lineage: a generous free tier, a template gallery with a drag-and-drop editor, and team-friendly features like domain-level permissions that appeal to mixed marketing-and-engineering orgs. Its API covers the full common surface — CC, BCC, reply-to, headers, attachments, tags, and scheduled sending — making it structurally one of the closest matches to Resend in the SDK's capability matrix; neither exposes per-message metadata. Resend differentiates on developer experience instead: React Email integration, a minimal API, and fast domain verification. Costs diverge with volume and features, so the practical decision is usually whether you want MailerSend's built-in template tooling or prefer owning templates in code with Resend and React Email.", + }, + { + slug: "postmark-vs-sendgrid", + a: "postmark", + b: "sendgrid", + intro: + "Postmark and SendGrid are the classic head-to-head of transactional email. Postmark's pitch is focus: transactional mail only (broadcasts live on a separate stream), consistently fast inbox delivery, 45 days of full-content message history, and pricing that is simple if never the cheapest. SendGrid's pitch is breadth: one platform for transactional and marketing email, dedicated IP options, subusers for multi-tenant setups, and an API that has accumulated every feature over fifteen years — including native scheduled sending, which Postmark lacks. Both support CC, BCC, reply-to, custom headers, attachments, tags, and per-message metadata in the unified message shape, so switching between them with the SDK is nearly lossless; only scheduled sends need a migration plan when moving from SendGrid to Postmark, and the SDK's field checks will flag exactly that.", + }, + { + slug: "postmark-vs-mailgun", + a: "postmark", + b: "mailgun", + intro: + "Postmark and Mailgun both come from the developer-tools generation of email providers, but they optimize for different things. Postmark optimizes for deliverability and clarity: separate transactional and broadcast message streams, aggressive list hygiene, detailed delivery events, and a UI your support team can actually use. Mailgun optimizes for flexibility: powerful routing and inbound parsing, EU region hosting for data residency, send-time optimization, and native scheduled delivery — the one field in the unified message shape Postmark doesn't support. Both carry CC, BCC, reply-to, headers, attachments, tags, and metadata, so the SDK can fail over between them for most messages. Teams typically choose Postmark for critical low-volume transactional mail and Mailgun when inbound processing or EU residency is a requirement — or run both, with one as the fallback route.", + }, + { + slug: "postmark-vs-ses", + a: "postmark", + b: "ses", + intro: + "Postmark and Amazon SES sit at opposite ends of the managed-vs-infrastructure spectrum. Postmark is a premium managed service: you pay per message for excellent deliverability out of the box, human support, and a dashboard with full message history — a strong default for teams that want transactional email to be someone else's operational problem. SES is a raw sending engine priced near cost; deliverability is largely yours to manage through dedicated IPs, configuration sets, and bounce processing, which rewards teams with the volume to justify the effort. In the unified message model Postmark's structured metadata has no SES equivalent, while neither offers native scheduled sending. The SDK's fail-fast checks flag metadata before a Postmark-to-SES fallback silently drops it, so the cost gap can be exploited without losing data integrity.", + }, + { + slug: "sendgrid-vs-mailgun", + a: "sendgrid", + b: "mailgun", + intro: + "SendGrid and Mailgun are the two heavyweight generalists of email infrastructure, and on the unified message shape they are actually identical: both support every field the SDK models — CC, BCC, reply-to, custom headers, attachments, tags, metadata, and scheduled sending — which makes them fully interchangeable as fallbacks for each other. The decision therefore comes down to the platforms around the API. SendGrid brings Twilio-scale infrastructure, marketing campaigns in the same account, subusers, and granular IP management. Mailgun counters with stronger inbound mail processing, straightforward EU data residency, message retention options, and a reputation for developer-friendly logs and webhooks. Pricing at low volume is comparable; at high volume both reward negotiation, so benchmark real traffic — ideally through the SDK, where swapping the adapter is one line — before committing either way.", + }, + { + slug: "sendgrid-vs-ses", + a: "sendgrid", + b: "ses", + intro: + "SendGrid versus Amazon SES is usually a cost-versus-convenience decision. SES wins on raw price by an order of magnitude and integrates natively with IAM, CloudWatch, and the rest of AWS — if your infrastructure already lives there, sending through SES keeps billing and auth in one place. What SES does not give you is a product: no template marketing tools, minimal UI, and deliverability management is your job. SendGrid charges more per message but bundles IP management, marketing email, template editing, and analytics into one platform. In the message model SendGrid supports per-message metadata and native scheduled sending; SES supports neither, so the SDK's field-support checks matter when SES is your fallback — tags are the only categorization that survives the hop.", + }, + { + slug: "sendgrid-vs-brevo", + a: "sendgrid", + b: "brevo", + intro: + "SendGrid and Brevo (formerly Sendinblue) both bundle transactional and marketing email into one platform, and both support the entire unified message shape — CC, BCC, reply-to, headers, attachments, tags, metadata, and scheduled sending — so with the SDK they are drop-in replacements for each other at the API level. The differences are commercial and regional. Brevo is European, GDPR-native, and prices by messages sent rather than contacts stored, with a workable free tier that makes it popular with early-stage teams; it also folds in SMS, WhatsApp, and a CRM. SendGrid is the larger sender with deeper deliverability tooling, dedicated IP options, and Twilio's enterprise support behind it. For EU-heavy audiences Brevo's regional footing is a genuine advantage; for very high volume SendGrid's infrastructure usually wins.", + }, + { + slug: "mailgun-vs-ses", + a: "mailgun", + b: "ses", + intro: + "Mailgun and Amazon SES both court high-volume senders, but with different bargains. SES offers the lowest unit price in the industry and rock-solid infrastructure, in exchange for doing your own deliverability work: warming IPs, processing bounces via SNS, and living without much of a UI. Mailgun costs more per message but includes the operational layer — send-time optimization, inbound routing and parsing, EU region hosting, searchable logs, and human support tiers. The unified message shape exposes a real functional gap too: Mailgun supports per-message metadata and native scheduled delivery while SES supports neither, so a Mailgun-to-SES fallback drops both fields and the SDK will fail fast rather than lose them silently. Teams often start on Mailgun and graduate to SES when volume justifies the operational investment.", + }, + { + slug: "brevo-vs-mailersend", + a: "brevo", + b: "mailersend", + intro: + "Brevo and MailerSend are both European-rooted platforms that pair transactional APIs with approachable marketing tooling, which makes them a common shortlist for teams that want one vendor for both jobs. Brevo is the broader suite: campaigns, automation, SMS, WhatsApp, and a CRM alongside its SMTP and API sending, priced by send volume with a usable free tier. MailerSend is narrower and more developer-focused within the MailerLite family, with a clean REST API, template editor, and inbound routing. In the unified message shape they differ on exactly one field: Brevo supports per-message metadata while MailerSend does not — both handle CC, BCC, reply-to, headers, attachments, tags, and scheduled sending — so failing over from Brevo to MailerSend only needs care when messages carry metadata.", + }, +]; + +/** + * Real constructor arguments per adapter (they differ: Postmark takes a server + * token, SES takes IAM credentials, Loops needs a template id), so the code + * snippets on comparison pages compile as written. + */ +export const adapterConfigSnippets: Partial> = { + resend: `resend({ apiKey: process.env.RESEND_API_KEY! })`, + postmark: `postmark({ serverToken: process.env.POSTMARK_SERVER_TOKEN! })`, + sendgrid: `sendgrid({ apiKey: process.env.SENDGRID_API_KEY! })`, + ses: `ses({ + accessKeyId: process.env.AWS_ACCESS_KEY_ID!, + secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY!, + region: "us-east-1", + })`, + mailgun: `mailgun({ apiKey: process.env.MAILGUN_API_KEY!, domain: "mg.yourdomain.com" })`, + brevo: `brevo({ apiKey: process.env.BREVO_API_KEY! })`, + mailersend: `mailersend({ apiKey: process.env.MAILERSEND_API_KEY! })`, + loops: `loops({ + apiKey: process.env.LOOPS_API_KEY!, + transactionalId: "your-template-id", + })`, +}; + +export function getAdapterConfigSnippet(key: ProviderKey): string { + return adapterConfigSnippets[key] ?? `${key}({ apiKey: process.env.EMAIL_API_KEY! })`; +} + +export function getComparePair(slug: string): ComparePair | undefined { + return comparePairs.find((pair) => pair.slug === slug); +} + +export function getComparePairsForProvider(key: ProviderKey): ComparePair[] { + return comparePairs.filter((pair) => pair.a === key || pair.b === key); +} + +export function getComparePairTitle(pair: ComparePair): string { + return `${getProvider(pair.a).name} vs ${getProvider(pair.b).name}`; +} diff --git a/apps/fumadocs/src/lib/dns-checker-runtime.ts b/apps/fumadocs/src/lib/dns-checker-runtime.ts new file mode 100644 index 0000000..dbca61b --- /dev/null +++ b/apps/fumadocs/src/lib/dns-checker-runtime.ts @@ -0,0 +1,153 @@ +import { createServerFn } from "@tanstack/react-start"; + +import { + type CheckFinding, + COMMON_DKIM_SELECTORS, + type DkimSelectorResult, + type DnsCheckResult, + findProviderHints, + normalizeDomainInput, + parseDmarc, + parseMx, + parseSpf, + SELECTOR_RE, +} from "./dns-checker"; + +// --- DNS-over-HTTPS resolution --- + +type DohAnswer = { data: string; type: number }; + +async function fetchDohJson(url: string): Promise<{ Status: number; Answer?: DohAnswer[] } | null> { + try { + const response = await fetch(url, { + headers: { accept: "application/dns-json" }, + signal: AbortSignal.timeout(5000), + }); + if (!response.ok) return null; + return (await response.json()) as { Status: number; Answer?: DohAnswer[] }; + } catch { + return null; + } +} + +async function resolveDns(name: string, type: "TXT" | "MX"): Promise { + const endpoints = [ + `https://cloudflare-dns.com/dns-query?name=${encodeURIComponent(name)}&type=${type}`, + `https://dns.google/resolve?name=${encodeURIComponent(name)}&type=${type}`, + ]; + for (const endpoint of endpoints) { + const result = await fetchDohJson(endpoint); + if (!result) continue; + if (result.Answer) { + const wantedType = type === "TXT" ? 16 : 15; + return result.Answer.filter((answer) => answer.type === wantedType).map((answer) => + // TXT strings come back quoted and possibly split into chunks. + answer.data.replace(/^"|"$/g, "").replaceAll('" "', ""), + ); + } + // NOERROR/NXDOMAIN without answers is an authoritative empty result. + if (result.Status === 0 || result.Status === 3) return []; + } + throw new Error("DNS resolution is temporarily unavailable. Try again in a minute."); +} + +async function probeDkimSelectors(domain: string, userSelector?: string) { + const selectors = [ + ...new Set([...(userSelector ? [userSelector] : []), ...COMMON_DKIM_SELECTORS]), + ]; + const results = await Promise.all( + selectors.map(async (selector): Promise => { + try { + const records = await resolveDns(`${selector}._domainkey.${domain}`, "TXT"); + const dkim = records.find((record) => /v=DKIM1|k=rsa|p=[A-Za-z0-9+/]/.test(record)); + return { selector, record: dkim ?? null }; + } catch { + return { selector, record: null }; + } + }), + ); + return results.filter((result) => result.record !== null); +} + +// --- Rate limiting: per-instance sliding window. Good enough for a free tool; +// the DoH endpoints we proxy are public and unauthenticated anyway. --- + +const RATE_LIMIT = 30; +const RATE_WINDOW_MS = 60_000; +let recentChecks: number[] = []; + +function assertRateLimit() { + const now = Date.now(); + recentChecks = recentChecks.filter((time) => now - time < RATE_WINDOW_MS); + if (recentChecks.length >= RATE_LIMIT) { + throw new Error("Too many checks right now — please try again in a minute."); + } + recentChecks.push(now); +} + +// Short-lived per-domain cache so repeat checks of the same domain (or shared +// result links) don't re-query DNS. +const resultCache = new Map(); +const CACHE_TTL_MS = 60_000; + +export const checkEmailDnsServerFn = createServerFn({ method: "GET" }) + .inputValidator((input: { domain: string; dkimSelector?: string }) => { + const domain = normalizeDomainInput(input.domain); + if (!domain) throw new Error("Enter a valid domain, e.g. yourdomain.com"); + const dkimSelector = + input.dkimSelector && SELECTOR_RE.test(input.dkimSelector.trim()) + ? input.dkimSelector.trim() + : undefined; + return { domain, dkimSelector }; + }) + .handler(async ({ data }): Promise => { + const cacheKey = `${data.domain}:${data.dkimSelector ?? ""}`; + const cached = resultCache.get(cacheKey); + if (cached && cached.expires > Date.now()) return cached.result; + + assertRateLimit(); + + const [rootTxt, dmarcTxt, mxRecords, dkimSelectors] = await Promise.all([ + resolveDns(data.domain, "TXT"), + resolveDns(`_dmarc.${data.domain}`, "TXT"), + resolveDns(data.domain, "MX"), + probeDkimSelectors(data.domain, data.dkimSelector), + ]); + + const spf = parseSpf(rootTxt); + const dkimFindings: CheckFinding[] = + dkimSelectors.length > 0 + ? [ + { + status: "pass", + message: `DKIM key${dkimSelectors.length === 1 ? "" : "s"} found for selector${ + dkimSelectors.length === 1 ? "" : "s" + }: ${dkimSelectors.map((entry) => entry.selector).join(", ")}.`, + }, + ] + : [ + { + status: "warn", + message: + "No DKIM key found under the common selectors. Your provider may use a custom selector — check its dashboard and re-run with that selector.", + fix: "Every provider issues DKIM records during domain verification; publish the CNAME/TXT records it gives you.", + }, + ]; + + const result: DnsCheckResult = { + domain: data.domain, + spf, + dmarc: parseDmarc(dmarcTxt), + mx: parseMx(mxRecords), + dkim: { selectors: dkimSelectors, findings: dkimFindings }, + providerHints: findProviderHints(spf.record), + checkedAt: new Date().toISOString(), + }; + + resultCache.set(cacheKey, { result, expires: Date.now() + CACHE_TTL_MS }); + if (resultCache.size > 500) { + const oldest = resultCache.keys().next().value; + if (oldest) resultCache.delete(oldest); + } + return result; + }); diff --git a/apps/fumadocs/src/lib/dns-checker.test.ts b/apps/fumadocs/src/lib/dns-checker.test.ts new file mode 100644 index 0000000..5642d3c --- /dev/null +++ b/apps/fumadocs/src/lib/dns-checker.test.ts @@ -0,0 +1,116 @@ +import { describe, expect, test } from "bun:test"; + +import { + findProviderHints, + normalizeDomainInput, + parseDmarc, + parseMx, + parseSpf, +} from "./dns-checker"; + +describe("normalizeDomainInput", () => { + test("accepts a plain domain", () => { + expect(normalizeDomainInput("example.com")).toBe("example.com"); + }); + + test("strips protocol, path, port, email local part, and trailing dot", () => { + expect(normalizeDomainInput("https://Example.com/path")).toBe("example.com"); + expect(normalizeDomainInput("user@example.com")).toBe("example.com"); + expect(normalizeDomainInput("example.com:443")).toBe("example.com"); + expect(normalizeDomainInput("example.com.")).toBe("example.com"); + }); + + test("rejects garbage", () => { + expect(normalizeDomainInput("not a domain")).toBeNull(); + expect(normalizeDomainInput("localhost")).toBeNull(); + expect(normalizeDomainInput("")).toBeNull(); + expect(normalizeDomainInput("-bad.example.com")).toBeNull(); + }); +}); + +describe("parseSpf", () => { + test("fails when no SPF record exists", () => { + const result = parseSpf(["some-verification=abc"]); + expect(result.record).toBeNull(); + expect(result.findings[0].status).toBe("fail"); + }); + + test("passes a healthy record with -all", () => { + const result = parseSpf(["v=spf1 include:amazonses.com -all"]); + expect(result.record).toBe("v=spf1 include:amazonses.com -all"); + expect(result.findings.every((finding) => finding.status === "pass")).toBe(true); + }); + + test("fails on multiple SPF records", () => { + const result = parseSpf(["v=spf1 include:a.com ~all", "v=spf1 include:b.com ~all"]); + expect(result.findings.some((finding) => finding.status === "fail")).toBe(true); + }); + + test("fails on +all", () => { + const result = parseSpf(["v=spf1 +all"]); + expect(result.findings.some((finding) => finding.status === "fail")).toBe(true); + }); + + test("warns on missing all mechanism", () => { + const result = parseSpf(["v=spf1 include:amazonses.com"]); + expect(result.findings.some((finding) => finding.status === "warn")).toBe(true); + }); + + test("fails when over 10 DNS lookups", () => { + const includes = Array.from({ length: 11 }, (_, i) => `include:x${i}.com`).join(" "); + const result = parseSpf([`v=spf1 ${includes} -all`]); + expect( + result.findings.some( + (finding) => finding.status === "fail" && /lookups/.test(finding.message), + ), + ).toBe(true); + }); +}); + +describe("parseDmarc", () => { + test("fails when missing", () => { + const result = parseDmarc([]); + expect(result.record).toBeNull(); + expect(result.findings[0].status).toBe("fail"); + }); + + test("passes p=reject with rua", () => { + const result = parseDmarc(["v=DMARC1; p=reject; rua=mailto:d@example.com"]); + expect(result.findings.every((finding) => finding.status === "pass")).toBe(true); + }); + + test("warns on p=none and missing rua", () => { + const result = parseDmarc(["v=DMARC1; p=none"]); + expect(result.findings.filter((finding) => finding.status === "warn").length).toBe(2); + }); + + test("warns on partial pct", () => { + const result = parseDmarc(["v=DMARC1; p=reject; rua=mailto:d@e.com; pct=50"]); + expect(result.findings.some((finding) => /50%/.test(finding.message))).toBe(true); + }); +}); + +describe("parseMx", () => { + test("sorts by priority and passes", () => { + const result = parseMx(["20 backup.example.com.", "10 primary.example.com."]); + expect(result.hosts).toEqual(["10 primary.example.com", "20 backup.example.com"]); + expect(result.findings[0].status).toBe("pass"); + }); + + test("warns when empty", () => { + const result = parseMx([]); + expect(result.findings[0].status).toBe("warn"); + }); +}); + +describe("findProviderHints", () => { + test("maps SPF includes to adapter docs", () => { + const hints = findProviderHints("v=spf1 include:amazonses.com include:_spf.resend.com -all"); + expect(hints.map((hint) => hint.name).sort()).toEqual(["AWS SES", "Resend"]); + expect(hints.find((hint) => hint.name === "AWS SES")?.docsPath).toBe("/docs/adapters/ses"); + }); + + test("returns nothing without a record", () => { + expect(findProviderHints(null)).toEqual([]); + }); +}); diff --git a/apps/fumadocs/src/lib/dns-checker.ts b/apps/fumadocs/src/lib/dns-checker.ts new file mode 100644 index 0000000..6d5585a --- /dev/null +++ b/apps/fumadocs/src/lib/dns-checker.ts @@ -0,0 +1,258 @@ +export type CheckStatus = "pass" | "warn" | "fail"; + +export type CheckFinding = { + status: CheckStatus; + message: string; + fix?: string; +}; + +export type RecordCheck = { + record: string | null; + findings: CheckFinding[]; +}; + +export type DkimSelectorResult = { + selector: string; + record: string | null; +}; + +export type DnsCheckResult = { + domain: string; + spf: RecordCheck; + dmarc: RecordCheck; + mx: { hosts: string[]; findings: CheckFinding[] }; + dkim: { selectors: DkimSelectorResult[]; findings: CheckFinding[] }; + providerHints: ProviderHint[]; + checkedAt: string; +}; + +export type ProviderHint = { + name: string; + docsPath: string; +}; + +const DOMAIN_RE = /^(?!-)[a-z0-9-]{1,63}(? 253 || !DOMAIN_RE.test(domain)) return null; + return domain; +} + +// --- Pure parsers (unit tested) --- + +export function parseSpf(txtRecords: string[]): RecordCheck { + const spfRecords = txtRecords.filter((record) => /^v=spf1(\s|$)/i.test(record.trim())); + const findings: CheckFinding[] = []; + + if (spfRecords.length === 0) { + findings.push({ + status: "fail", + message: "No SPF record found.", + fix: 'Publish a TXT record starting with v=spf1 that lists your email provider, e.g. "v=spf1 include:amazonses.com -all".', + }); + return { record: null, findings }; + } + + if (spfRecords.length > 1) { + findings.push({ + status: "fail", + message: `Found ${spfRecords.length} SPF records — receivers treat multiple SPF records as a permanent error (permerror).`, + fix: "Merge all include: mechanisms into a single v=spf1 record.", + }); + } + + const record = spfRecords[0].trim(); + const allMatch = record.match(/([-~+?])?all\b/i); + if (!allMatch) { + findings.push({ + status: "warn", + message: "SPF record has no `all` mechanism, so it never gives receivers a final verdict.", + fix: "End the record with ~all (soft fail) or -all (hard fail).", + }); + } else { + const qualifier = allMatch[1] ?? "+"; + if (qualifier === "+") { + findings.push({ + status: "fail", + message: + "SPF ends with +all, which authorizes every server on the internet to send as your domain.", + fix: "Replace +all with -all (or ~all while testing).", + }); + } else if (qualifier === "?") { + findings.push({ + status: "warn", + message: "SPF ends with ?all (neutral), which gives receivers no signal.", + fix: "Use ~all or -all once you are confident every legitimate sender is listed.", + }); + } else { + findings.push({ + status: "pass", + message: `SPF record found, ending with ${qualifier}all.`, + }); + } + } + + // RFC 7208 caps DNS-querying mechanisms at 10; beyond that receivers permerror. + const lookupCount = ( + record.match(/\b(include:|a\b|a:|mx\b|mx:|ptr\b|ptr:|exists:|redirect=)/gi) ?? [] + ).length; + if (lookupCount > 10) { + findings.push({ + status: "fail", + message: `SPF requires ${lookupCount} DNS lookups; the limit is 10, so receivers will permerror.`, + fix: "Flatten or remove include: entries for providers you no longer use.", + }); + } else if (lookupCount > 7) { + findings.push({ + status: "warn", + message: `SPF uses ${lookupCount} of the 10 allowed DNS lookups — close to the limit.`, + }); + } + + return { record, findings }; +} + +export function parseDmarc(txtRecords: string[]): RecordCheck { + const dmarcRecords = txtRecords.filter((record) => /^v=DMARC1(\s*;|$)/i.test(record.trim())); + const findings: CheckFinding[] = []; + + if (dmarcRecords.length === 0) { + findings.push({ + status: "fail", + message: "No DMARC record found at _dmarc..", + fix: 'Publish a TXT record at _dmarc., e.g. "v=DMARC1; p=none; rua=mailto:dmarc@yourdomain.com" to start monitoring.', + }); + return { record: null, findings }; + } + + const record = dmarcRecords[0].trim(); + const tags = new Map(); + for (const part of record.split(";")) { + const [key, ...rest] = part.split("="); + if (key && rest.length > 0) tags.set(key.trim().toLowerCase(), rest.join("=").trim()); + } + + const policy = tags.get("p")?.toLowerCase(); + if (!policy) { + findings.push({ + status: "fail", + message: "DMARC record has no p= policy tag, which makes it invalid.", + fix: "Add p=none, p=quarantine, or p=reject.", + }); + } else if (policy === "none") { + findings.push({ + status: "warn", + message: "DMARC policy is p=none — you get reports, but spoofed mail is still delivered.", + fix: "Move to p=quarantine and then p=reject once reports show only legitimate senders.", + }); + } else { + findings.push({ status: "pass", message: `DMARC policy is p=${policy}.` }); + } + + if (!tags.get("rua")) { + findings.push({ + status: "warn", + message: "No rua= reporting address — you will not receive aggregate reports.", + fix: "Add rua=mailto:dmarc@yourdomain.com to see who sends as your domain.", + }); + } + + const pct = tags.get("pct"); + if (pct && pct !== "100") { + findings.push({ + status: "warn", + message: `DMARC applies to only ${pct}% of messages (pct=${pct}).`, + fix: "Remove pct= (defaults to 100) once you trust the policy.", + }); + } + + return { record, findings }; +} + +export function parseMx(mxRecords: string[]): { hosts: string[]; findings: CheckFinding[] } { + const hosts = mxRecords + .map((record) => { + const [priority, host] = record.trim().split(/\s+/); + return { priority: Number(priority), host: (host ?? "").replace(/\.$/, "") }; + }) + .filter((entry) => entry.host) + .sort((a, b) => a.priority - b.priority) + .map((entry) => `${entry.priority} ${entry.host}`); + + const findings: CheckFinding[] = + hosts.length > 0 + ? [ + { + status: "pass", + message: `${hosts.length} MX record${hosts.length === 1 ? "" : "s"} found.`, + }, + ] + : [ + { + status: "warn", + message: + "No MX records — this domain cannot receive mail (replies and bounces will be lost).", + fix: "Add MX records if you expect replies; send-only subdomains sometimes skip this deliberately.", + }, + ]; + + return { hosts, findings }; +} + +export function findProviderHints(spfRecord: string | null): ProviderHint[] { + if (!spfRecord) return []; + const hints = new Map(); + for (const provider of SPF_INCLUDE_PROVIDERS) { + if (spfRecord.toLowerCase().includes(provider.match)) { + hints.set(provider.name, { name: provider.name, docsPath: provider.docsPath }); + } + } + return [...hints.values()]; +} diff --git a/apps/fumadocs/src/lib/docs-frontmatter.test.ts b/apps/fumadocs/src/lib/docs-frontmatter.test.ts new file mode 100644 index 0000000..2661925 --- /dev/null +++ b/apps/fumadocs/src/lib/docs-frontmatter.test.ts @@ -0,0 +1,57 @@ +import { describe, expect, test } from "bun:test"; +import { readdirSync, readFileSync } from "node:fs"; +import { join, relative, resolve } from "node:path"; + +const docsDir = resolve(import.meta.dir, "../../content/docs"); + +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 parseFrontmatter(file: string): Record { + const content = readFileSync(file, "utf8"); + const match = content.match(/^---\n([\s\S]*?)\n---/); + if (!match) return {}; + const fields: Record = {}; + for (const line of match[1].split("\n")) { + const keyValue = line.match(/^(\w+):\s*(.*)$/); + if (keyValue) fields[keyValue[1]] = keyValue[2].replace(/^["']|["']$/g, "").trim(); + } + return fields; +} + +const pages = listMdxFiles(docsDir).map((file) => ({ + path: relative(docsDir, file), + frontmatter: parseFrontmatter(file), +})); + +describe("current docs frontmatter", () => { + test("every page has a title", () => { + const missing = pages.filter((page) => !page.frontmatter.title); + expect(missing.map((page) => page.path)).toEqual([]); + }); + + test("every page has a SERP-friendly description (40-170 chars)", () => { + const bad = pages.filter((page) => { + const description = page.frontmatter.description ?? ""; + return description.length < 40 || description.length > 170; + }); + expect( + bad.map((page) => `${page.path} (${page.frontmatter.description?.length ?? 0})`), + ).toEqual([]); + }); + + test("descriptions are unique across pages", () => { + const byDescription = new Map(); + for (const page of pages) { + const description = page.frontmatter.description ?? ""; + byDescription.set(description, [...(byDescription.get(description) ?? []), page.path]); + } + const duplicates = [...byDescription.entries()].filter(([, paths]) => paths.length > 1); + expect(duplicates).toEqual([]); + }); +}); diff --git a/apps/fumadocs/src/lib/docs-lastmod.generated.json b/apps/fumadocs/src/lib/docs-lastmod.generated.json new file mode 100644 index 0000000..8bd76e7 --- /dev/null +++ b/apps/fumadocs/src/lib/docs-lastmod.generated.json @@ -0,0 +1,57 @@ +{ + "adapters/brevo.mdx": "2026-07-11", + "adapters/cloudflare.mdx": "2026-07-06", + "adapters/field-support.mdx": "2026-07-06", + "adapters/index.mdx": "2026-07-06", + "adapters/iterable.mdx": "2026-07-06", + "adapters/jetemail.mdx": "2026-07-06", + "adapters/lettermint.mdx": "2026-07-06", + "adapters/loops.mdx": "2026-07-11", + "adapters/mailchimp.mdx": "2026-07-06", + "adapters/mailersend.mdx": "2026-07-11", + "adapters/mailgun.mdx": "2026-07-11", + "adapters/mailpace.mdx": "2026-07-06", + "adapters/mailtrap.mdx": "2026-07-06", + "adapters/plunk.mdx": "2026-07-06", + "adapters/postmark.mdx": "2026-07-11", + "adapters/primitive.mdx": "2026-07-06", + "adapters/resend.mdx": "2026-07-11", + "adapters/scaleway.mdx": "2026-07-06", + "adapters/sendgrid.mdx": "2026-07-11", + "adapters/sequenzy.mdx": "2026-07-06", + "adapters/ses.mdx": "2026-07-11", + "adapters/smtp.mdx": "2026-07-06", + "adapters/sparkpost.mdx": "2026-07-06", + "adapters/unosend.mdx": "2026-07-06", + "adapters/zeptomail.mdx": "2026-07-06", + "agents/skill.mdx": "2026-07-06", + "authentication.mdx": "2026-07-11", + "components/convex-email.mdx": "2026-07-06", + "concepts/adapter-model.mdx": "2026-07-06", + "concepts/fallbacks-and-retries.mdx": "2026-07-06", + "concepts/hooks.mdx": "2026-07-06", + "getting-started/install.mdx": "2026-07-06", + "getting-started/quickstart.mdx": "2026-07-06", + "guides/authoring/create-adapter.mdx": "2026-07-06", + "guides/authoring/create-first-plugin.mdx": "2026-07-06", + "guides/authoring/publish-community-adapter.mdx": "2026-07-06", + "guides/authoring/publish-community-plugin.mdx": "2026-07-06", + "guides/index.mdx": "2026-07-06", + "guides/production-send-pipeline.mdx": "2026-07-06", + "guides/test-email-behavior.mdx": "2026-07-06", + "index.mdx": "2026-07-06", + "plugins/api.mdx": "2026-07-06", + "plugins/built-in/capture.mdx": "2026-07-06", + "plugins/built-in/defaults.mdx": "2026-07-06", + "plugins/built-in/observability.mdx": "2026-07-06", + "plugins/community.mdx": "2026-07-06", + "plugins/index.mdx": "2026-07-06", + "plugins/writing-plugins.mdx": "2026-07-06", + "reference/adapter-contract.mdx": "2026-07-06", + "reference/cli.mdx": "2026-07-06", + "reference/client.mdx": "2026-07-06", + "reference/community-registry.mdx": "2026-07-06", + "reference/errors.mdx": "2026-07-06", + "reference/message.mdx": "2026-07-06", + "telemetry.mdx": "2026-07-06" +} diff --git a/apps/fumadocs/src/lib/field-support.generated.json b/apps/fumadocs/src/lib/field-support.generated.json new file mode 100644 index 0000000..bd41a0f --- /dev/null +++ b/apps/fumadocs/src/lib/field-support.generated.json @@ -0,0 +1,169 @@ +{ + "resend": { + "cc": true, + "bcc": true, + "replyTo": true, + "headers": true, + "attachments": true, + "tags": true, + "sendAt": true + }, + "postmark": { + "cc": true, + "bcc": true, + "replyTo": true, + "headers": true, + "attachments": true, + "tags": true, + "metadata": true + }, + "sendgrid": { + "cc": true, + "bcc": true, + "replyTo": true, + "headers": true, + "attachments": true, + "tags": true, + "metadata": true, + "sendAt": true + }, + "cloudflare": { + "cc": true, + "bcc": true, + "replyTo": true, + "headers": true, + "attachments": true + }, + "unosend": { + "cc": true, + "bcc": true, + "replyTo": true, + "headers": true, + "attachments": true, + "tags": true + }, + "ses": { + "cc": true, + "bcc": true, + "replyTo": true, + "headers": true, + "attachments": true, + "tags": true + }, + "mailgun": { + "cc": true, + "bcc": true, + "replyTo": true, + "headers": true, + "attachments": true, + "tags": true, + "metadata": true, + "sendAt": true + }, + "mailersend": { + "cc": true, + "bcc": true, + "replyTo": true, + "headers": true, + "attachments": true, + "tags": true, + "sendAt": true + }, + "brevo": { + "cc": true, + "bcc": true, + "replyTo": true, + "headers": true, + "attachments": true, + "tags": true, + "metadata": true, + "sendAt": true + }, + "mailchimp": { + "cc": true, + "bcc": true, + "headers": true, + "attachments": true, + "tags": true, + "metadata": true, + "sendAt": true + }, + "sparkpost": { + "replyTo": true, + "headers": true, + "attachments": true, + "tags": true, + "metadata": true, + "sendAt": true + }, + "iterable": { + "metadata": true + }, + "loops": { + "attachments": true, + "metadata": true + }, + "sequenzy": { + "replyTo": true, + "attachments": true, + "metadata": true + }, + "jetemail": { + "cc": true, + "bcc": true, + "replyTo": true, + "headers": true, + "attachments": true + }, + "lettermint": { + "cc": true, + "bcc": true, + "replyTo": true, + "headers": true, + "attachments": true, + "tags": true, + "metadata": true + }, + "primitive": { + "attachments": true + }, + "plunk": { + "replyTo": true, + "headers": true, + "attachments": true, + "metadata": true + }, + "mailtrap": { + "cc": true, + "bcc": true, + "replyTo": true, + "headers": true, + "attachments": true, + "tags": true, + "metadata": true + }, + "scaleway": { + "cc": true, + "bcc": true, + "replyTo": true, + "headers": true, + "attachments": true + }, + "zeptomail": { + "cc": true, + "bcc": true, + "replyTo": true, + "attachments": true + }, + "mailpace": { + "cc": true, + "bcc": true, + "replyTo": true + }, + "smtp": { + "cc": true, + "bcc": true, + "replyTo": true, + "headers": true + } +} diff --git a/apps/fumadocs/src/lib/layout.shared.tsx b/apps/fumadocs/src/lib/layout.shared.tsx index db17a85..7efd319 100644 --- a/apps/fumadocs/src/lib/layout.shared.tsx +++ b/apps/fumadocs/src/lib/layout.shared.tsx @@ -41,6 +41,18 @@ export function baseOptions({ url: "/blog", active: "nested-url" as const, }, + { + type: "main" as const, + text: "Compare", + url: "/compare", + active: "nested-url" as const, + }, + { + type: "main" as const, + text: "Tools", + url: "/tools", + active: "nested-url" as const, + }, ] : []), ...(versionPicker diff --git a/apps/fumadocs/src/routeTree.gen.ts b/apps/fumadocs/src/routeTree.gen.ts index b579fdd..8a4a766 100644 --- a/apps/fumadocs/src/routeTree.gen.ts +++ b/apps/fumadocs/src/routeTree.gen.ts @@ -21,11 +21,15 @@ import { Route as FeedDotjsonRouteImport } from './routes/feed[.]json' import { Route as ContactRouteImport } from './routes/contact' import { Route as AboutRouteImport } from './routes/about' import { Route as IndexRouteImport } from './routes/index' +import { Route as ToolsIndexRouteImport } from './routes/tools/index' +import { Route as CompareIndexRouteImport } from './routes/compare/index' import { Route as BlogIndexRouteImport } from './routes/blog/index' +import { Route as ToolsEmailDnsCheckerRouteImport } from './routes/tools/email-dns-checker' import { Route as FeedsDocsDotjsonlRouteImport } from './routes/feeds/docs[.]jsonl' import { Route as DocsChar123Char125DotmdRouteImport } from './routes/docs/{$}[.]md' import { Route as DocsLlmsDottxtRouteImport } from './routes/docs/llms[.]txt' import { Route as DocsSplatRouteImport } from './routes/docs/$' +import { Route as ComparePairRouteImport } from './routes/compare/$pair' import { Route as BlogSlugRouteImport } from './routes/blog/$slug' import { Route as ApiSearchRouteImport } from './routes/api/search' import { Route as ApiBuildInfoRouteImport } from './routes/api/build-info' @@ -91,11 +95,26 @@ const IndexRoute = IndexRouteImport.update({ path: '/', getParentRoute: () => rootRouteImport, } as any) +const ToolsIndexRoute = ToolsIndexRouteImport.update({ + id: '/tools/', + path: '/tools/', + getParentRoute: () => rootRouteImport, +} as any) +const CompareIndexRoute = CompareIndexRouteImport.update({ + id: '/compare/', + path: '/compare/', + getParentRoute: () => rootRouteImport, +} as any) const BlogIndexRoute = BlogIndexRouteImport.update({ id: '/blog/', path: '/blog/', getParentRoute: () => rootRouteImport, } as any) +const ToolsEmailDnsCheckerRoute = ToolsEmailDnsCheckerRouteImport.update({ + id: '/tools/email-dns-checker', + path: '/tools/email-dns-checker', + getParentRoute: () => rootRouteImport, +} as any) const FeedsDocsDotjsonlRoute = FeedsDocsDotjsonlRouteImport.update({ id: '/feeds/docs.jsonl', path: '/feeds/docs.jsonl', @@ -116,6 +135,11 @@ const DocsSplatRoute = DocsSplatRouteImport.update({ path: '/docs/$', getParentRoute: () => rootRouteImport, } as any) +const ComparePairRoute = ComparePairRouteImport.update({ + id: '/compare/$pair', + path: '/compare/$pair', + getParentRoute: () => rootRouteImport, +} as any) const BlogSlugRoute = BlogSlugRouteImport.update({ id: '/blog/$slug', path: '/blog/$slug', @@ -153,11 +177,15 @@ export interface FileRoutesByFullPath { '/api/build-info': typeof ApiBuildInfoRoute '/api/search': typeof ApiSearchRoute '/blog/$slug': typeof BlogSlugRoute + '/compare/$pair': typeof ComparePairRoute '/docs/$': typeof DocsSplatRoute '/docs/llms.txt': typeof DocsLlmsDottxtRoute '/docs/{$}.md': typeof DocsChar123Char125DotmdRoute '/feeds/docs.jsonl': typeof FeedsDocsDotjsonlRoute + '/tools/email-dns-checker': typeof ToolsEmailDnsCheckerRoute '/blog/': typeof BlogIndexRoute + '/compare/': typeof CompareIndexRoute + '/tools/': typeof ToolsIndexRoute '/og/blog/$': typeof OgBlogSplatRoute } export interface FileRoutesByTo { @@ -176,11 +204,15 @@ export interface FileRoutesByTo { '/api/build-info': typeof ApiBuildInfoRoute '/api/search': typeof ApiSearchRoute '/blog/$slug': typeof BlogSlugRoute + '/compare/$pair': typeof ComparePairRoute '/docs/$': typeof DocsSplatRoute '/docs/llms.txt': typeof DocsLlmsDottxtRoute '/docs/{$}.md': typeof DocsChar123Char125DotmdRoute '/feeds/docs.jsonl': typeof FeedsDocsDotjsonlRoute + '/tools/email-dns-checker': typeof ToolsEmailDnsCheckerRoute '/blog': typeof BlogIndexRoute + '/compare': typeof CompareIndexRoute + '/tools': typeof ToolsIndexRoute '/og/blog/$': typeof OgBlogSplatRoute } export interface FileRoutesById { @@ -200,11 +232,15 @@ export interface FileRoutesById { '/api/build-info': typeof ApiBuildInfoRoute '/api/search': typeof ApiSearchRoute '/blog/$slug': typeof BlogSlugRoute + '/compare/$pair': typeof ComparePairRoute '/docs/$': typeof DocsSplatRoute '/docs/llms.txt': typeof DocsLlmsDottxtRoute '/docs/{$}.md': typeof DocsChar123Char125DotmdRoute '/feeds/docs.jsonl': typeof FeedsDocsDotjsonlRoute + '/tools/email-dns-checker': typeof ToolsEmailDnsCheckerRoute '/blog/': typeof BlogIndexRoute + '/compare/': typeof CompareIndexRoute + '/tools/': typeof ToolsIndexRoute '/og/blog/$': typeof OgBlogSplatRoute } export interface FileRouteTypes { @@ -225,11 +261,15 @@ export interface FileRouteTypes { | '/api/build-info' | '/api/search' | '/blog/$slug' + | '/compare/$pair' | '/docs/$' | '/docs/llms.txt' | '/docs/{$}.md' | '/feeds/docs.jsonl' + | '/tools/email-dns-checker' | '/blog/' + | '/compare/' + | '/tools/' | '/og/blog/$' fileRoutesByTo: FileRoutesByTo to: @@ -248,11 +288,15 @@ export interface FileRouteTypes { | '/api/build-info' | '/api/search' | '/blog/$slug' + | '/compare/$pair' | '/docs/$' | '/docs/llms.txt' | '/docs/{$}.md' | '/feeds/docs.jsonl' + | '/tools/email-dns-checker' | '/blog' + | '/compare' + | '/tools' | '/og/blog/$' id: | '__root__' @@ -271,11 +315,15 @@ export interface FileRouteTypes { | '/api/build-info' | '/api/search' | '/blog/$slug' + | '/compare/$pair' | '/docs/$' | '/docs/llms.txt' | '/docs/{$}.md' | '/feeds/docs.jsonl' + | '/tools/email-dns-checker' | '/blog/' + | '/compare/' + | '/tools/' | '/og/blog/$' fileRoutesById: FileRoutesById } @@ -295,11 +343,15 @@ export interface RootRouteChildren { ApiBuildInfoRoute: typeof ApiBuildInfoRoute ApiSearchRoute: typeof ApiSearchRoute BlogSlugRoute: typeof BlogSlugRoute + ComparePairRoute: typeof ComparePairRoute DocsSplatRoute: typeof DocsSplatRoute DocsLlmsDottxtRoute: typeof DocsLlmsDottxtRoute DocsChar123Char125DotmdRoute: typeof DocsChar123Char125DotmdRoute FeedsDocsDotjsonlRoute: typeof FeedsDocsDotjsonlRoute + ToolsEmailDnsCheckerRoute: typeof ToolsEmailDnsCheckerRoute BlogIndexRoute: typeof BlogIndexRoute + CompareIndexRoute: typeof CompareIndexRoute + ToolsIndexRoute: typeof ToolsIndexRoute OgBlogSplatRoute: typeof OgBlogSplatRoute } @@ -389,6 +441,20 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof IndexRouteImport parentRoute: typeof rootRouteImport } + '/tools/': { + id: '/tools/' + path: '/tools' + fullPath: '/tools/' + preLoaderRoute: typeof ToolsIndexRouteImport + parentRoute: typeof rootRouteImport + } + '/compare/': { + id: '/compare/' + path: '/compare' + fullPath: '/compare/' + preLoaderRoute: typeof CompareIndexRouteImport + parentRoute: typeof rootRouteImport + } '/blog/': { id: '/blog/' path: '/blog' @@ -396,6 +462,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof BlogIndexRouteImport parentRoute: typeof rootRouteImport } + '/tools/email-dns-checker': { + id: '/tools/email-dns-checker' + path: '/tools/email-dns-checker' + fullPath: '/tools/email-dns-checker' + preLoaderRoute: typeof ToolsEmailDnsCheckerRouteImport + parentRoute: typeof rootRouteImport + } '/feeds/docs.jsonl': { id: '/feeds/docs.jsonl' path: '/feeds/docs.jsonl' @@ -424,6 +497,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof DocsSplatRouteImport parentRoute: typeof rootRouteImport } + '/compare/$pair': { + id: '/compare/$pair' + path: '/compare/$pair' + fullPath: '/compare/$pair' + preLoaderRoute: typeof ComparePairRouteImport + parentRoute: typeof rootRouteImport + } '/blog/$slug': { id: '/blog/$slug' path: '/blog/$slug' @@ -471,11 +551,15 @@ const rootRouteChildren: RootRouteChildren = { ApiBuildInfoRoute: ApiBuildInfoRoute, ApiSearchRoute: ApiSearchRoute, BlogSlugRoute: BlogSlugRoute, + ComparePairRoute: ComparePairRoute, DocsSplatRoute: DocsSplatRoute, DocsLlmsDottxtRoute: DocsLlmsDottxtRoute, DocsChar123Char125DotmdRoute: DocsChar123Char125DotmdRoute, FeedsDocsDotjsonlRoute: FeedsDocsDotjsonlRoute, + ToolsEmailDnsCheckerRoute: ToolsEmailDnsCheckerRoute, BlogIndexRoute: BlogIndexRoute, + CompareIndexRoute: CompareIndexRoute, + ToolsIndexRoute: ToolsIndexRoute, OgBlogSplatRoute: OgBlogSplatRoute, } export const routeTree = rootRouteImport diff --git a/apps/fumadocs/src/routes/compare/$pair.tsx b/apps/fumadocs/src/routes/compare/$pair.tsx new file mode 100644 index 0000000..f02a183 --- /dev/null +++ b/apps/fumadocs/src/routes/compare/$pair.tsx @@ -0,0 +1,275 @@ +import { createFileRoute, Link, notFound } from "@tanstack/react-router"; +import { HomeLayout } from "fumadocs-ui/layouts/home"; +import type { ReactNode } from "react"; + +import { CompareTable } from "@/components/compare-table"; +import { DocsVersionLink } from "@/components/docs-version-link"; +import { + type ComparePair, + getAdapterConfigSnippet, + getComparePair, + getComparePairTitle, + getFallbackGaps, + getProvider, + messageFieldLabels, + type ProviderKey, +} from "@/lib/compare"; +import { baseOptions } from "@/lib/layout.shared"; +import { appName, siteUrl } from "@/lib/shared"; + +export const Route = createFileRoute("/compare/$pair")({ + head: ({ params }) => { + const pair = getComparePair(params.pair); + if (!pair) return {}; + + const title = getComparePairTitle(pair); + const pageTitle = `${title}: transactional email API comparison - ${appName}`; + const description = buildDescription(pair); + const canonicalUrl = `${siteUrl}/compare/${pair.slug}`; + + return { + meta: [ + { title: pageTitle }, + { name: "description", content: description }, + { property: "og:type", content: "article" }, + { property: "og:title", content: pageTitle }, + { property: "og:description", content: description }, + { property: "og:url", content: canonicalUrl }, + { + "script:ld+json": { + "@context": "https://schema.org", + "@type": "FAQPage", + mainEntity: buildFaq(pair).map((entry) => ({ + "@type": "Question", + name: entry.question, + acceptedAnswer: { "@type": "Answer", text: entry.answer }, + })), + }, + }, + ], + links: [{ rel: "canonical", href: canonicalUrl }], + }; + }, + loader: ({ params }) => { + // Only curated pairs render; arbitrary slugs must 404, not produce thin pages. + if (!getComparePair(params.pair)) throw notFound(); + }, + component: ComparePage, +}); + +function buildDescription(pair: ComparePair) { + const a = getProvider(pair.a); + const b = getProvider(pair.b); + return `${a.name} vs ${b.name} for transactional email: message-field support compared side by side (attachments, scheduling, metadata, and more), with code for both via one TypeScript SDK.`; +} + +function buildFaq(pair: ComparePair) { + const a = getProvider(pair.a); + const b = getProvider(pair.b); + const gapsAtoB = getFallbackGaps(pair.a, pair.b); + const gapsBtoA = getFallbackGaps(pair.b, pair.a); + + const gapAnswer = + gapsAtoB.length > 0 + ? `${a.name} supports ${listFields(gapsAtoB)} in the unified message shape, which ${b.name} does not.` + : `${b.name} supports every message field that ${a.name} supports, so nothing is lost moving a message from ${a.name} to ${b.name}.`; + + const fallbackAnswer = + gapsBtoA.length === 0 && gapsAtoB.length === 0 + ? `Yes — ${a.name} and ${b.name} support the same message fields, so Email SDK can fail over between them in either direction without dropping data.` + : `Partially. Email SDK checks field support before every send: a fallback from ${a.name} to ${b.name} is rejected for messages using ${ + gapsAtoB.length > 0 ? listFields(gapsAtoB) : "no fields" + }${ + gapsBtoA.length > 0 + ? `, and from ${b.name} to ${a.name} for messages using ${listFields(gapsBtoA)}` + : "" + }. Messages that avoid those fields fail over cleanly.`; + + return [ + { + question: `Can I switch from ${a.name} to ${b.name} without rewriting my email code?`, + answer: `Yes. With Email SDK both providers share one typed send() call and one message shape, so switching from ${a.name} to ${b.name} is a one-line adapter change plus an API key. The SDK fails fast if a message uses a field ${b.name} does not support, so nothing is silently dropped.`, + }, + { + question: `Which message fields does ${a.name} support that ${b.name} doesn't?`, + answer: gapAnswer, + }, + { + question: `Can I use ${b.name} as a fallback for ${a.name}?`, + answer: fallbackAnswer, + }, + ]; +} + +function listFields(fields: ReturnType) { + return fields.map((field) => messageFieldLabels[field].toLowerCase()).join(", "); +} + +function ComparePage() { + const { pair: slug } = Route.useParams(); + const pair = getComparePair(slug); + if (!pair) return null; + + const a = getProvider(pair.a); + const b = getProvider(pair.b); + const title = getComparePairTitle(pair); + const gapsAtoB = getFallbackGaps(pair.a, pair.b); + const gapsBtoA = getFallbackGaps(pair.b, pair.a); + const faq = buildFaq(pair); + + return ( + +
+
+

+ + Compare providers + +

+

{title}

+

+ {pair.intro} +

+ +
+ +

+ Field support as encoded in Email SDK's own adapter capability matrix — the + same data the SDK uses to reject sends that would silently drop fields. +

+ +
+ + + {gapsAtoB.length === 0 && gapsBtoA.length === 0 ? ( +

+ {a.name} and {b.name} support identical message fields, so Email SDK can fail over + between them in either direction without losing data. +

+ ) : ( + <> + {gapsAtoB.length > 0 && ( +

+ Failing over from {a.name} to {b.name}{" "} + loses: {listFields(gapsAtoB)}. Email SDK rejects the fallback send for + messages that use these fields instead of dropping them silently. +

+ )} + {gapsBtoA.length > 0 && ( +

0 ? "mt-3" : undefined}> + Failing over from {b.name} to {a.name}{" "} + loses: {listFields(gapsBtoA)}. +

+ )} + + )} +
+ + +

+ With Email SDK the send call is identical for both providers — only the adapter + import changes: +

+ {sendSnippet(pair.a, a.importPath)} +

Or run both, with automatic fallback:

+ {fallbackSnippet(pair, a.importPath, b.importPath)} +
+ + +
+ {faq.map((entry) => ( +
+
{entry.question}
+
{entry.answer}
+
+ ))} +
+
+ + +

+ Setup guides:{" "} + + {a.name} adapter + {" "} + and{" "} + + {b.name} adapter + + . Sending from your own domain? Verify SPF, DKIM, and DMARC with the free{" "} + + email DNS checker + + . +

+
+
+
+
+
+ ); +} + +function sendSnippet(key: ProviderKey, importPath: string) { + return `import { createEmailClient } from "@opencoredev/email-sdk"; +import { ${key} } from "${importPath}"; + +const client = createEmailClient({ + adapters: [${getAdapterConfigSnippet(key)}], +}); + +await client.send({ + from: "hello@yourdomain.com", + to: "user@example.com", + subject: "Welcome!", + html: "

It works.

", +});`; +} + +function fallbackSnippet(pair: ComparePair, importPathA: string, importPathB: string) { + return `import { createEmailClient } from "@opencoredev/email-sdk"; +import { ${pair.a} } from "${importPathA}"; +import { ${pair.b} } from "${importPathB}"; + +const client = createEmailClient({ + adapters: [ + ${getAdapterConfigSnippet(pair.a)}, + ${getAdapterConfigSnippet(pair.b)}, + ], + defaultAdapter: "${pair.a}", + fallback: ["${pair.b}"], +});`; +} + +function CodeBlock({ children }: { children: string }) { + return ( +
+      {children}
+    
+ ); +} + +function CompareSection({ children, title }: { children: ReactNode; title: string }) { + return ( +
+

{title}

+
{children}
+
+ ); +} diff --git a/apps/fumadocs/src/routes/compare/index.tsx b/apps/fumadocs/src/routes/compare/index.tsx new file mode 100644 index 0000000..29ee1f4 --- /dev/null +++ b/apps/fumadocs/src/routes/compare/index.tsx @@ -0,0 +1,82 @@ +import { createFileRoute, Link } from "@tanstack/react-router"; +import { HomeLayout } from "fumadocs-ui/layouts/home"; + +import { FullCompareTable } from "@/components/compare-table"; +import { DocsVersionLink } from "@/components/docs-version-link"; +import { comparePairs, getComparePairTitle } from "@/lib/compare"; +import { baseOptions } from "@/lib/layout.shared"; +import { appName, siteUrl } from "@/lib/shared"; +import { providers } from "@/lib/providers"; + +const pageTitle = `Compare transactional email providers - ${appName}`; +const pageDescription = + "Side-by-side comparisons of transactional email providers — Resend, Postmark, SendGrid, Mailgun, AWS SES, Brevo, and more — based on the message fields each API actually supports."; + +export const Route = createFileRoute("/compare/")({ + head: () => ({ + meta: [ + { title: pageTitle }, + { name: "description", content: pageDescription }, + { property: "og:title", content: pageTitle }, + { property: "og:description", content: pageDescription }, + { property: "og:url", content: `${siteUrl}/compare` }, + ], + links: [{ rel: "canonical", href: `${siteUrl}/compare` }], + }), + component: CompareIndex, +}); + +function CompareIndex() { + return ( + +
+
+

Compare

+

+ Compare transactional email providers +

+

+ These comparisons are generated from Email SDK's own adapter capability matrix — + the field-support data the SDK uses to fail fast instead of silently dropping CC + recipients, attachments, or metadata. Every provider below works behind the same typed{" "} + send() call. +

+ +
+

Head-to-head comparisons

+
    + {comparePairs.map((pair) => ( +
  • + + {getComparePairTitle(pair)} + +
  • + ))} +
+
+ +
+

Full capability matrix

+

+ Message-field support across all {providers.length} adapters, straight from the SDK + source. See the{" "} + + field support docs + {" "} + for what each field means. +

+ +
+
+
+
+ ); +} diff --git a/apps/fumadocs/src/routes/docs/$.tsx b/apps/fumadocs/src/routes/docs/$.tsx index b5ac5d3..ab9a4cf 100644 --- a/apps/fumadocs/src/routes/docs/$.tsx +++ b/apps/fumadocs/src/routes/docs/$.tsx @@ -54,11 +54,15 @@ export const Route = createFileRoute("/docs/$")({ : data.docsBasePath : "/docs"; const canonicalUrl = `${siteUrl}${canonicalPath}`; + // Old-version docs stay reachable (version picker, inbound links) but must + // not compete with current docs in search; "follow" preserves link equity. + const isCurrentVersion = !data || data.docsBasePath === "/docs"; return { meta: [ { title }, { name: "description", content: description }, + ...(isCurrentVersion ? [] : [{ name: "robots", content: "noindex, follow" }]), { property: "og:type", content: "article" }, { property: "og:title", content: title }, { property: "og:description", content: description }, diff --git a/apps/fumadocs/src/routes/sitemap[.]xml.ts b/apps/fumadocs/src/routes/sitemap[.]xml.ts index ee50fac..1c6ce78 100644 --- a/apps/fumadocs/src/routes/sitemap[.]xml.ts +++ b/apps/fumadocs/src/routes/sitemap[.]xml.ts @@ -1,9 +1,11 @@ import { createFileRoute } from "@tanstack/react-router"; import { getBlogPostUrl, getPublishedBlogPosts } from "@/lib/blog"; +import { comparePairs } from "@/lib/compare"; +import docsLastmod from "@/lib/docs-lastmod.generated.json"; import { siteUrl } from "@/lib/shared"; import { getDocsSource } from "@/lib/source"; -import { docsVersions } from "@/lib/versions"; +import { latestDocsVersion } from "@/lib/versions"; type SitemapEntry = { loc: string; @@ -70,6 +72,30 @@ function getSitemapEntries() { changefreq: "monthly", priority: "0.3", }, + { + loc: `${siteUrl}/compare`, + lastmod: "2026-07-11", + changefreq: "monthly", + priority: "0.7", + }, + ...comparePairs.map((pair) => ({ + loc: `${siteUrl}/compare/${pair.slug}`, + lastmod: "2026-07-11", + changefreq: "monthly" as const, + priority: "0.7", + })), + { + loc: `${siteUrl}/tools`, + lastmod: "2026-07-11", + changefreq: "monthly", + priority: "0.6", + }, + { + loc: `${siteUrl}/tools/email-dns-checker`, + lastmod: "2026-07-11", + changefreq: "monthly", + priority: "0.7", + }, ...publishedBlogPosts.map((post) => ({ loc: `${siteUrl}${getBlogPostUrl(post.slug)}`, lastmod: post.updatedAt, @@ -102,16 +128,17 @@ function getSitemapEntries() { }, ]; - for (const version of docsVersions) { - const docsSource = getDocsSource(version); - for (const page of docsSource.getPages()) { - entries.push({ - loc: `${siteUrl}${page.url}`, - lastmod: "2026-06-01", - changefreq: version.current ? "weekly" : "monthly", - priority: version.current ? "0.8" : "0.4", - }); - } + // Only the current docs version is listed: old /docs/v/ snapshots are + // near-duplicates that carry noindex and would dilute crawl priority. + const docsSource = getDocsSource(latestDocsVersion); + const lastmodByPath: Record = docsLastmod; + for (const page of docsSource.getPages()) { + entries.push({ + loc: `${siteUrl}${page.url}`, + lastmod: lastmodByPath[page.path] ?? "2026-06-01", + changefreq: "weekly", + priority: "0.8", + }); } return dedupeEntries(entries); diff --git a/apps/fumadocs/src/routes/tools/email-dns-checker.tsx b/apps/fumadocs/src/routes/tools/email-dns-checker.tsx new file mode 100644 index 0000000..970c06c --- /dev/null +++ b/apps/fumadocs/src/routes/tools/email-dns-checker.tsx @@ -0,0 +1,296 @@ +import { createFileRoute, Link } from "@tanstack/react-router"; +import { HomeLayout } from "fumadocs-ui/layouts/home"; +import { type FormEvent, type ReactNode, useEffect, useRef, useState } from "react"; + +import { DocsVersionLink } from "@/components/docs-version-link"; +import type { CheckFinding, DnsCheckResult } from "@/lib/dns-checker"; +import { checkEmailDnsServerFn } from "@/lib/dns-checker-runtime"; +import { baseOptions } from "@/lib/layout.shared"; +import { appName, siteUrl } from "@/lib/shared"; + +const pageTitle = `Free Email DNS Checker — SPF, DKIM & DMARC Lookup - ${appName}`; +const pageDescription = + "Check any domain's email DNS setup in seconds: SPF record validation, DKIM selector lookup, DMARC policy check, and MX records — free, no signup, with fixes explained."; +const canonicalUrl = `${siteUrl}/tools/email-dns-checker`; + +type CheckerSearch = { domain?: string }; + +export const Route = createFileRoute("/tools/email-dns-checker")({ + validateSearch: (search: Record): CheckerSearch => ({ + domain: typeof search.domain === "string" ? search.domain.slice(0, 253) : undefined, + }), + head: () => ({ + meta: [ + { title: pageTitle }, + { name: "description", content: pageDescription }, + { property: "og:title", content: pageTitle }, + { property: "og:description", content: pageDescription }, + { property: "og:url", content: canonicalUrl }, + { + "script:ld+json": { + "@context": "https://schema.org", + "@type": "WebApplication", + name: "Email DNS Checker", + applicationCategory: "DeveloperApplication", + operatingSystem: "Any", + url: canonicalUrl, + description: pageDescription, + offers: { "@type": "Offer", price: "0", priceCurrency: "USD" }, + publisher: { "@id": `${siteUrl}/#organization` }, + }, + }, + ], + // Always canonicalize to the bare tool URL: ?domain= result views are + // shareable but must not be indexed as separate pages. + links: [{ rel: "canonical", href: canonicalUrl }], + }), + component: DnsCheckerPage, +}); + +function DnsCheckerPage() { + const search = Route.useSearch(); + const navigate = Route.useNavigate(); + const [domain, setDomain] = useState(search.domain ?? ""); + const [result, setResult] = useState(null); + const [error, setError] = useState(null); + const [loading, setLoading] = useState(false); + const autoRan = useRef(false); + + async function runCheck(target: string) { + setLoading(true); + setError(null); + try { + const checked = await checkEmailDnsServerFn({ data: { domain: target } }); + setResult(checked); + void navigate({ search: { domain: checked.domain }, replace: true }); + } catch (checkError) { + setResult(null); + setError(checkError instanceof Error ? checkError.message : "Check failed. Try again."); + } finally { + setLoading(false); + } + } + + useEffect(() => { + if (search.domain && !autoRan.current) { + autoRan.current = true; + void runCheck(search.domain); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + function onSubmit(event: FormEvent) { + event.preventDefault(); + if (domain.trim()) void runCheck(domain); + } + + return ( + +
+
+

+ + Free tools + +

+

Email DNS checker

+

+ Check a domain's SPF, DKIM, DMARC, and MX records in one pass. Free, no signup — + results explain what each record means and how to fix what's missing. +

+ +
+ setDomain(event.target.value)} + placeholder="yourdomain.com" + spellCheck={false} + value={domain} + /> + +
+ + {error && ( +

+ {error} +

+ )} + + {result && } + +
+ + Receiving mail servers decide whether to trust your email based on three DNS records.{" "} + SPF (a TXT record starting with{" "} + v=spf1) lists the servers allowed to send mail as your domain.{" "} + DKIM (a TXT record under{" "} + <selector>._domainkey.yourdomain.com) publishes a public key that + lets receivers verify each message was signed by you and not altered in transit.{" "} + DMARC (a TXT record at{" "} + _dmarc.yourdomain.com) ties the two together: it tells receivers what to + do with mail that fails SPF and DKIM, and where to send reports. MX records are + checked too, since a domain that cannot receive replies or bounces loses signal with + mailbox providers. + + + + A pass means the record exists and + follows current best practice. A warn{" "} + usually means mail still flows but you are leaving deliverability or security on the + table — a DMARC policy of p=none, for example, monitors spoofing without + stopping it. A fail is worth fixing + before your next campaign: multiple SPF records or a record ending in{" "} + +all can cause receivers to reject or junk otherwise legitimate mail. + DKIM selectors are provider-specific, so if the probe finds nothing under the common + selectors, check your provider's dashboard for the exact selector name and DNS + records it issued during domain verification. + + + + Every transactional email provider — Resend, Postmark, SendGrid, AWS SES, Mailgun, + Brevo, and the rest — walks you through publishing SPF and DKIM records when you + verify a sending domain. The{" "} + + Email SDK adapter guides + {" "} + cover setup and verification for each provider, and the{" "} + + authentication docs + {" "} + cover credentials. If you send through more than one provider (for example a{" "} + + primary plus a fallback + + ), each provider needs its own DKIM records, and your SPF record must include all of + them — while staying under SPF's 10-DNS-lookup limit, which this tool checks. + +
+
+
+
+ ); +} + +function CheckResults({ result }: { result: DnsCheckResult }) { + return ( +
+ + + 0 + ? result.dkim.selectors.map((entry) => entry.selector).join(", ") + : null + } + recordLabel="Selectors" + title="DKIM" + /> + + + {result.providerHints.length > 0 && ( +
+

Providers detected in your SPF record

+
    + {result.providerHints.map((hint) => ( +
  • + + {hint.name} adapter guide + +
  • + ))} +
+
+ )} +
+ ); +} + +function ResultSection({ + findings, + record, + recordLabel = "Record", + title, +}: { + findings: CheckFinding[]; + record: string | null; + recordLabel?: string; + title: string; +}) { + return ( +
+

{title}

+ {record && ( +

+ {recordLabel}: + {record} +

+ )} +
    + {findings.map((finding) => ( +
  • + + + {finding.message} + {finding.fix && {finding.fix}} + +
  • + ))} +
+
+ ); +} + +function StatusBadge({ status }: { status: CheckFinding["status"] }) { + const styles = { + pass: "bg-emerald-500/15 text-emerald-600 dark:text-emerald-400", + warn: "bg-amber-500/15 text-amber-600 dark:text-amber-400", + fail: "bg-red-500/15 text-red-600 dark:text-red-400", + } as const; + return ( + + {status} + + ); +} + +function ExplainerSection({ children, title }: { children: ReactNode; title: string }) { + return ( +
+

{title}

+

{children}

+
+ ); +} diff --git a/apps/fumadocs/src/routes/tools/index.tsx b/apps/fumadocs/src/routes/tools/index.tsx new file mode 100644 index 0000000..f02bd09 --- /dev/null +++ b/apps/fumadocs/src/routes/tools/index.tsx @@ -0,0 +1,65 @@ +import { createFileRoute, Link } from "@tanstack/react-router"; +import { HomeLayout } from "fumadocs-ui/layouts/home"; + +import { baseOptions } from "@/lib/layout.shared"; +import { appName, siteUrl } from "@/lib/shared"; + +const pageTitle = `Free email tools - ${appName}`; +const pageDescription = + "Free tools for developers who send email: check SPF, DKIM, DMARC, and MX records for any domain, and compare transactional email providers side by side."; + +export const Route = createFileRoute("/tools/")({ + head: () => ({ + meta: [ + { title: pageTitle }, + { name: "description", content: pageDescription }, + { property: "og:title", content: pageTitle }, + { property: "og:description", content: pageDescription }, + { property: "og:url", content: `${siteUrl}/tools` }, + ], + links: [{ rel: "canonical", href: `${siteUrl}/tools` }], + }), + component: ToolsIndex, +}); + +function ToolsIndex() { + return ( + +
+
+

Free tools

+

Free email tools

+

+ Utilities from the Email SDK project — free, no signup, built on the same data and + checks the SDK uses. +

+ +
+ + +
+
+
+
+ ); +} + +function ToolCard({ description, title, to }: { description: string; title: string; to: string }) { + return ( + +

{title}

+

{description}

+ + ); +} diff --git a/apps/fumadocs/vite.config.ts b/apps/fumadocs/vite.config.ts index d2221d8..4386b58 100644 --- a/apps/fumadocs/vite.config.ts +++ b/apps/fumadocs/vite.config.ts @@ -8,6 +8,7 @@ import mdx from "fumadocs-mdx/vite"; import { nitro } from "nitro/vite"; import { defineConfig, loadEnv } from "vite"; +import { comparePairs } from "./src/lib/compare"; import { docsVersions, getDocsVersionHref } from "./src/lib/versions"; function collectContentPages(dir: string) { @@ -106,6 +107,16 @@ export default defineConfig(({ mode }) => { path: "/terms", }, ...versionedDocsPages, + { + path: "/compare", + }, + ...comparePairs.map((pair) => ({ path: `/compare/${pair.slug}` })), + { + path: "/tools", + }, + { + path: "/tools/email-dns-checker", + }, { path: "/api/search", }, diff --git a/packages/email-sdk/src/index.ts b/packages/email-sdk/src/index.ts index afc3aa8..ef3aaab 100644 --- a/packages/email-sdk/src/index.ts +++ b/packages/email-sdk/src/index.ts @@ -1,4 +1,6 @@ export { createEmailClient } from "./core.js"; +export { SUPPORTED_MESSAGE_FIELDS } from "./utils.js"; +export type { MessageFieldSupport } from "./utils.js"; export { EmailProviderError, EmailProviderNotFoundError, diff --git a/vercel.json b/vercel.json index 9e76743..93d0f22 100644 --- a/vercel.json +++ b/vercel.json @@ -14,7 +14,11 @@ "destination": "/docs/getting-started/quickstart", "permanent": true }, - { "source": "/docs/install", "destination": "/docs/getting-started/install", "permanent": true }, + { + "source": "/docs/install", + "destination": "/docs/getting-started/install", + "permanent": true + }, { "source": "/docs/installation", "destination": "/docs/getting-started/install", @@ -24,7 +28,77 @@ { "source": "/docs/cli", "destination": "/docs/reference/cli", "permanent": true }, { "source": "/docs/api", "destination": "/docs/reference", "permanent": true }, { "source": "/docs/providers", "destination": "/docs/adapters", "permanent": true }, - { "source": "/docs/provider", "destination": "/docs/adapters", "permanent": true } + { "source": "/docs/provider", "destination": "/docs/adapters", "permanent": true }, + { + "source": "/compare/postmark-vs-resend", + "destination": "/compare/resend-vs-postmark", + "permanent": true + }, + { + "source": "/compare/sendgrid-vs-resend", + "destination": "/compare/resend-vs-sendgrid", + "permanent": true + }, + { + "source": "/compare/ses-vs-resend", + "destination": "/compare/resend-vs-ses", + "permanent": true + }, + { + "source": "/compare/mailgun-vs-resend", + "destination": "/compare/resend-vs-mailgun", + "permanent": true + }, + { + "source": "/compare/loops-vs-resend", + "destination": "/compare/resend-vs-loops", + "permanent": true + }, + { + "source": "/compare/mailersend-vs-resend", + "destination": "/compare/resend-vs-mailersend", + "permanent": true + }, + { + "source": "/compare/sendgrid-vs-postmark", + "destination": "/compare/postmark-vs-sendgrid", + "permanent": true + }, + { + "source": "/compare/mailgun-vs-postmark", + "destination": "/compare/postmark-vs-mailgun", + "permanent": true + }, + { + "source": "/compare/ses-vs-postmark", + "destination": "/compare/postmark-vs-ses", + "permanent": true + }, + { + "source": "/compare/mailgun-vs-sendgrid", + "destination": "/compare/sendgrid-vs-mailgun", + "permanent": true + }, + { + "source": "/compare/ses-vs-sendgrid", + "destination": "/compare/sendgrid-vs-ses", + "permanent": true + }, + { + "source": "/compare/brevo-vs-sendgrid", + "destination": "/compare/sendgrid-vs-brevo", + "permanent": true + }, + { + "source": "/compare/ses-vs-mailgun", + "destination": "/compare/mailgun-vs-ses", + "permanent": true + }, + { + "source": "/compare/mailersend-vs-brevo", + "destination": "/compare/brevo-vs-mailersend", + "permanent": true + } ], "headers": [ {