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',