From 3b5af2ff36906720569a9d4edef819a70ff5257d Mon Sep 17 00:00:00 2001 From: Felipe Freitag Date: Wed, 27 May 2026 11:33:38 -0300 Subject: [PATCH] feat(react-email): filter compatibility warnings by email client Adds --clients to email dev and reads COMPATIBILITY_EMAIL_CLIENTS from the environment so teams that only target a subset of clients can quiet warnings for the rest. The CLI flag wins over the env var; an empty or fully-invalid list falls back to the defaults so warnings can't be silently switched off. The empty-state copy on the Compatibility tab now names the active client list ("Template should render properly in Gmail") instead of claiming "everywhere," so the filter scope isn't misrepresented when there are no issues. Builds on #2797 by @ReemX with the cubic-flagged regressions fixed: the port-retry path now preserves the flag, undefined no longer clobbers a user-set env var, and invalid env values fall back to the defaults instead of suppressing all checks. Co-authored-by: ReemX Co-authored-by: Gabriel Miranda --- .changeset/clients-compat-flag.md | 6 +++ packages/react-email/src/cli/commands/dev.ts | 8 +++- packages/react-email/src/cli/index.ts | 31 +++++++++++- .../src/cli/utils/email-clients.ts | 28 +++++++++++ .../get-env-variables-for-preview-app.ts | 5 ++ .../src/cli/utils/preview/start-dev-server.ts | 3 ++ .../react-email/src/cli/utils/tree.spec.ts | 1 + .../email-validation/check-compatibility.ts | 36 ++------------ .../ui/src/app/preview/[...slug]/page.tsx | 2 + packages/ui/src/components/toolbar.tsx | 12 ++++- .../ui/src/utils/caniemail/email-clients.ts | 48 +++++++++++++++++++ .../get-compatibility-stats-for-entry.ts | 4 +- 12 files changed, 148 insertions(+), 36 deletions(-) create mode 100644 .changeset/clients-compat-flag.md create mode 100644 packages/react-email/src/cli/utils/email-clients.ts create mode 100644 packages/ui/src/utils/caniemail/email-clients.ts diff --git a/.changeset/clients-compat-flag.md b/.changeset/clients-compat-flag.md new file mode 100644 index 0000000000..bcd6abf16e --- /dev/null +++ b/.changeset/clients-compat-flag.md @@ -0,0 +1,6 @@ +--- +"@react-email/ui": minor +"react-email": minor +--- + +add a `--clients` option to `email dev` and a `COMPATIBILITY_EMAIL_CLIENTS` environment variable to narrow which email clients trigger compatibility warnings. By default the preview still warns for `gmail`, `apple-mail`, `outlook`, and `yahoo`. Teams that only target one or two clients can now skip the noise: `email dev --clients outlook,apple-mail`. The CLI flag wins over the env var; an empty or fully-invalid list falls back to the defaults so warnings can't be silently switched off. Builds on #2797 by @ReemX. diff --git a/packages/react-email/src/cli/commands/dev.ts b/packages/react-email/src/cli/commands/dev.ts index dac1a871cb..ab7a29a5d1 100644 --- a/packages/react-email/src/cli/commands/dev.ts +++ b/packages/react-email/src/cli/commands/dev.ts @@ -4,9 +4,14 @@ import { setupHotreloading, startDevServer } from '../utils/index.js'; interface Args { dir: string; port: string; + clients?: string; } -export const dev = async ({ dir: emailsDirRelativePath, port }: Args) => { +export const dev = async ({ + dir: emailsDirRelativePath, + port, + clients, +}: Args) => { try { if (!fs.existsSync(emailsDirRelativePath)) { console.error(`Missing ${emailsDirRelativePath} folder`); @@ -17,6 +22,7 @@ export const dev = async ({ dir: emailsDirRelativePath, port }: Args) => { emailsDirRelativePath, emailsDirRelativePath, // defaults to ./emails/static for the static files that are served to the preview Number.parseInt(port, 10), + clients, ); await setupHotreloading(devServer, emailsDirRelativePath); diff --git a/packages/react-email/src/cli/index.ts b/packages/react-email/src/cli/index.ts index c0b34787e3..8ae5dd6a41 100644 --- a/packages/react-email/src/cli/index.ts +++ b/packages/react-email/src/cli/index.ts @@ -1,14 +1,38 @@ #!/usr/bin/env node import { spawn } from 'node:child_process'; -import { Option, program } from 'commander'; +import { InvalidArgumentError, Option, program } from 'commander'; import { build } from './commands/build.js'; import { dev } from './commands/dev.js'; import { exportTemplates } from './commands/export.js'; import { resendReset } from './commands/resend/reset.js'; import { resendSetup } from './commands/resend/setup.js'; import { start } from './commands/start.js'; +import { ALL_EMAIL_CLIENTS } from './utils/email-clients.js'; import { packageJson } from './utils/packageJson.js'; +const parseClientsOption = (value: string): string => { + const requested = value + .split(',') + .map((entry) => entry.trim().toLowerCase()) + .filter(Boolean); + + if (requested.length === 0) { + throw new InvalidArgumentError( + '--clients requires at least one email client.', + ); + } + + const known = new Set(ALL_EMAIL_CLIENTS); + const invalid = requested.filter((entry) => !known.has(entry)); + if (invalid.length > 0) { + throw new InvalidArgumentError( + `Unknown email client(s): ${invalid.join(', ')}. Supported: ${ALL_EMAIL_CLIENTS.join(', ')}.`, + ); + } + + return requested.join(','); +}; + const requiredFlags = [ '--experimental-vm-modules', '--disable-warning=ExperimentalWarning', @@ -50,6 +74,11 @@ if (!hasRequiredFlags) { './emails', ) .option('-p --port ', 'Port to run dev server on', '3000') + .option( + '-c, --clients ', + 'Comma-separated list of email clients to show compatibility warnings for (overrides COMPATIBILITY_EMAIL_CLIENTS)', + parseClientsOption, + ) .action(dev); program diff --git a/packages/react-email/src/cli/utils/email-clients.ts b/packages/react-email/src/cli/utils/email-clients.ts new file mode 100644 index 0000000000..9539defff7 --- /dev/null +++ b/packages/react-email/src/cli/utils/email-clients.ts @@ -0,0 +1,28 @@ +// Kept in sync with ALL_EMAIL_CLIENTS in +// packages/ui/src/actions/email-validation/check-compatibility.ts. +// Duplicated here so the CLI can validate --clients without depending on +// @react-email/ui at runtime. +export const ALL_EMAIL_CLIENTS = [ + 'gmail', + 'outlook', + 'yahoo', + 'apple-mail', + 'aol', + 'thunderbird', + 'microsoft', + 'samsung-email', + 'sfr', + 'orange', + 'protonmail', + 'hey', + 'mail-ru', + 'fastmail', + 'laposte', + 't-online-de', + 'free-fr', + 'gmx', + 'web-de', + 'ionos-1and1', + 'rainloop', + 'wp-pl', +] as const; diff --git a/packages/react-email/src/cli/utils/preview/get-env-variables-for-preview-app.ts b/packages/react-email/src/cli/utils/preview/get-env-variables-for-preview-app.ts index 6b60cb279e..4f007ded86 100644 --- a/packages/react-email/src/cli/utils/preview/get-env-variables-for-preview-app.ts +++ b/packages/react-email/src/cli/utils/preview/get-env-variables-for-preview-app.ts @@ -5,6 +5,7 @@ export const getEnvVariablesForPreviewApp = ( previewServerLocation: string, cwd: string, resendApiKey?: string, + compatibilityClients?: string, ) => { return { REACT_EMAIL_INTERNAL_EMAILS_DIR_RELATIVE_PATH: @@ -16,5 +17,9 @@ export const getEnvVariablesForPreviewApp = ( REACT_EMAIL_INTERNAL_PREVIEW_SERVER_LOCATION: previewServerLocation, REACT_EMAIL_INTERNAL_USER_PROJECT_LOCATION: cwd, REACT_EMAIL_INTERNAL_RESEND_API_KEY: resendApiKey, + // Only spread the key when set so a user-provided env var isn't clobbered. + ...(compatibilityClients !== undefined && { + COMPATIBILITY_EMAIL_CLIENTS: compatibilityClients, + }), } as const; }; diff --git a/packages/react-email/src/cli/utils/preview/start-dev-server.ts b/packages/react-email/src/cli/utils/preview/start-dev-server.ts index 6f56fa6926..29492f1c6d 100644 --- a/packages/react-email/src/cli/utils/preview/start-dev-server.ts +++ b/packages/react-email/src/cli/utils/preview/start-dev-server.ts @@ -33,6 +33,7 @@ export const startDevServer = async ( emailsDirRelativePath: string, staticBaseDirRelativePath: string, port: number, + compatibilityClients?: string, ): Promise => { const [majorNodeVersion] = process.versions.node.split('.'); if (majorNodeVersion && Number.parseInt(majorNodeVersion, 10) < 20) { @@ -100,6 +101,7 @@ export const startDevServer = async ( emailsDirRelativePath, staticBaseDirRelativePath, nextPortToTry, + compatibilityClients, ); } @@ -137,6 +139,7 @@ export const startDevServer = async ( previewServerLocation, process.cwd(), conf.get('resendApiKey'), + compatibilityClients, ), }; if (!process.env.ESBUILD_BINARY_PATH) { diff --git a/packages/react-email/src/cli/utils/tree.spec.ts b/packages/react-email/src/cli/utils/tree.spec.ts index b924718617..11aae75be7 100644 --- a/packages/react-email/src/cli/utils/tree.spec.ts +++ b/packages/react-email/src/cli/utils/tree.spec.ts @@ -17,6 +17,7 @@ test('tree(__dirname, 2)', async () => { │ ├── hot-reload-change.ts │ └── hot-reload-event.ts ├── conf.ts + ├── email-clients.ts ├── get-emails-directory-metadata.spec.ts ├── get-emails-directory-metadata.ts ├── get-ui-location.ts diff --git a/packages/ui/src/actions/email-validation/check-compatibility.ts b/packages/ui/src/actions/email-validation/check-compatibility.ts index cd26a5b21b..07f25690c3 100644 --- a/packages/ui/src/actions/email-validation/check-compatibility.ts +++ b/packages/ui/src/actions/email-validation/check-compatibility.ts @@ -11,6 +11,10 @@ import { doesPropertyHaveLocation, getUsedStyleProperties, } from '../../utils/caniemail/ast/get-used-style-properties'; +import { + type EmailClient, + getRelevantEmailClients, +} from '../../utils/caniemail/email-clients'; import type { CompatibilityStats, SupportStatus, @@ -34,30 +38,6 @@ export interface CompatibilityCheckingResult { statsPerEmailClient: CompatibilityStats['perEmailClient']; } -export type EmailClient = - | 'gmail' - | 'outlook' - | 'yahoo' - | 'apple-mail' - | 'aol' - | 'thunderbird' - | 'microsoft' - | 'samsung-email' - | 'sfr' - | 'orange' - | 'protonmail' - | 'hey' - | 'mail-ru' - | 'fastmail' - | 'laposte' - | 't-online-de' - | 'free-fr' - | 'gmx' - | 'web-de' - | 'ionos-1and1' - | 'rainloop' - | 'wp-pl'; - export type Platform = | 'desktop-app' | 'desktop-webmail' @@ -123,17 +103,11 @@ export type SupportEntry = source: 'react-email'; }); -const relevantEmailClients: EmailClient[] = [ - 'gmail', - 'apple-mail', - 'outlook', - 'yahoo', -]; - export const checkCompatibility = async ( reactCode: string, emailPath: string, ) => { + const relevantEmailClients = getRelevantEmailClients(); const ast = parse(reactCode, { strictMode: false, errorRecovery: true, diff --git a/packages/ui/src/app/preview/[...slug]/page.tsx b/packages/ui/src/app/preview/[...slug]/page.tsx index 86073f080a..348a151025 100644 --- a/packages/ui/src/app/preview/[...slug]/page.tsx +++ b/packages/ui/src/app/preview/[...slug]/page.tsx @@ -13,6 +13,7 @@ import type { LintingRow } from '../../../components/toolbar/linter'; import type { SpamCheckingResult } from '../../../components/toolbar/spam-assassin'; import { PreviewProvider } from '../../../contexts/preview'; import { ToolbarProvider } from '../../../contexts/toolbar'; +import { getRelevantEmailClients } from '../../../utils/caniemail/email-clients'; import { getEmailsDirectoryMetadata } from '../../../utils/get-emails-directory-metadata'; import { getLintingSources, loadLintingRowsFrom } from '../../../utils/linting'; import { loadStream } from '../../../utils/load-stream'; @@ -139,6 +140,7 @@ This is most likely not an issue with the preview server. Maybe there was a typo serverLintingRows={lintingRows} serverSpamCheckingResult={spamCheckingResult} serverCompatibilityResults={compatibilityCheckingResults} + serverCompatibilityClients={getRelevantEmailClients()} /> diff --git a/packages/ui/src/components/toolbar.tsx b/packages/ui/src/components/toolbar.tsx index fbf840cfd8..0c4f4a7f7b 100644 --- a/packages/ui/src/components/toolbar.tsx +++ b/packages/ui/src/components/toolbar.tsx @@ -5,6 +5,7 @@ import { LayoutGroup } from 'framer-motion'; import { usePathname, useRouter, useSearchParams } from 'next/navigation'; import type { ComponentProps } from 'react'; import * as React from 'react'; +import { nicenames } from '../actions/email-validation/caniemail-data'; import type { CompatibilityCheckingResult } from '../actions/email-validation/check-compatibility'; import { isBuilding } from '../app/env'; import { usePreviewContext } from '../contexts/preview'; @@ -51,6 +52,7 @@ const ToolbarInner = ({ serverLintingRows, serverSpamCheckingResult, serverCompatibilityResults, + serverCompatibilityClients, prettyMarkup, reactMarkup, @@ -72,6 +74,10 @@ const ToolbarInner = ({ const { hasSetupResendIntegration } = useToolbarContext(); + const compatibilityClientsLabel = serverCompatibilityClients + .map((client) => nicenames.family[client] ?? client) + .join(', '); + const { activeTab, toggled } = useToolbarState(); const setActivePanelValue = (newValue: ToolbarTabValue | undefined) => { @@ -283,7 +289,8 @@ const ToolbarInner = ({ Great compatibility - Template should render properly everywhere. + Template should render properly in{' '} + {compatibilityClientsLabel}. ) : ( @@ -406,12 +413,14 @@ interface ToolbarProps { serverSpamCheckingResult: SpamCheckingResult | undefined; serverLintingRows: LintingRow[] | undefined; serverCompatibilityResults: CompatibilityCheckingResult[] | undefined; + serverCompatibilityClients: readonly string[]; } export function Toolbar({ serverLintingRows, serverSpamCheckingResult, serverCompatibilityResults, + serverCompatibilityClients, }: ToolbarProps) { const { emailPath, emailSlug, renderedEmailMetadata } = usePreviewContext(); @@ -430,6 +439,7 @@ export function Toolbar({ serverLintingRows={serverLintingRows} serverSpamCheckingResult={serverSpamCheckingResult} serverCompatibilityResults={serverCompatibilityResults} + serverCompatibilityClients={serverCompatibilityClients} /> ); } diff --git a/packages/ui/src/utils/caniemail/email-clients.ts b/packages/ui/src/utils/caniemail/email-clients.ts new file mode 100644 index 0000000000..5d9aa4f5ef --- /dev/null +++ b/packages/ui/src/utils/caniemail/email-clients.ts @@ -0,0 +1,48 @@ +export const ALL_EMAIL_CLIENTS = [ + 'gmail', + 'outlook', + 'yahoo', + 'apple-mail', + 'aol', + 'thunderbird', + 'microsoft', + 'samsung-email', + 'sfr', + 'orange', + 'protonmail', + 'hey', + 'mail-ru', + 'fastmail', + 'laposte', + 't-online-de', + 'free-fr', + 'gmx', + 'web-de', + 'ionos-1and1', + 'rainloop', + 'wp-pl', +] as const; + +export type EmailClient = (typeof ALL_EMAIL_CLIENTS)[number]; + +export const DEFAULT_RELEVANT_EMAIL_CLIENTS = [ + 'gmail', + 'apple-mail', + 'outlook', + 'yahoo', +] as const satisfies readonly EmailClient[]; + +const isEmailClient = (value: string): value is EmailClient => + (ALL_EMAIL_CLIENTS as readonly string[]).includes(value); + +export const getRelevantEmailClients = (): readonly EmailClient[] => { + const raw = process.env.COMPATIBILITY_EMAIL_CLIENTS; + if (!raw) return DEFAULT_RELEVANT_EMAIL_CLIENTS; + + const requested = raw + .split(',') + .map((entry) => entry.trim().toLowerCase()) + .filter(isEmailClient); + + return requested.length > 0 ? requested : DEFAULT_RELEVANT_EMAIL_CLIENTS; +}; diff --git a/packages/ui/src/utils/caniemail/get-compatibility-stats-for-entry.ts b/packages/ui/src/utils/caniemail/get-compatibility-stats-for-entry.ts index e4e6a403b1..370ff005a8 100644 --- a/packages/ui/src/utils/caniemail/get-compatibility-stats-for-entry.ts +++ b/packages/ui/src/utils/caniemail/get-compatibility-stats-for-entry.ts @@ -1,8 +1,8 @@ import type { - EmailClient, Platform, SupportEntry, } from '../../actions/email-validation/check-compatibility'; +import type { EmailClient } from './email-clients'; export type SupportStatus = DetailedSupportStatus['status']; @@ -32,7 +32,7 @@ const noteNumbersRegex = /#(?\d+)/g; export const getCompatibilityStatsForEntry = ( entry: SupportEntry, - emailClients: EmailClient[], + emailClients: readonly EmailClient[], ) => { const stats: CompatibilityStats = { status: 'success',