Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .changeset/clients-compat-flag.md
Original file line number Diff line number Diff line change
@@ -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.
8 changes: 7 additions & 1 deletion packages/react-email/src/cli/commands/dev.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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`);
Expand All @@ -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);
Expand Down
31 changes: 30 additions & 1 deletion packages/react-email/src/cli/index.ts
Original file line number Diff line number Diff line change
@@ -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<string>(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',
Expand Down Expand Up @@ -50,6 +74,11 @@ if (!hasRequiredFlags) {
'./emails',
)
.option('-p --port <port>', 'Port to run dev server on', '3000')
.option(
'-c, --clients <clients>',
'Comma-separated list of email clients to show compatibility warnings for (overrides COMPATIBILITY_EMAIL_CLIENTS)',
parseClientsOption,
)
.action(dev);

program
Expand Down
28 changes: 28 additions & 0 deletions packages/react-email/src/cli/utils/email-clients.ts
Original file line number Diff line number Diff line change
@@ -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;
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ export const getEnvVariablesForPreviewApp = (
previewServerLocation: string,
cwd: string,
resendApiKey?: string,
compatibilityClients?: string,
) => {
return {
REACT_EMAIL_INTERNAL_EMAILS_DIR_RELATIVE_PATH:
Expand All @@ -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;
};
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ export const startDevServer = async (
emailsDirRelativePath: string,
staticBaseDirRelativePath: string,
port: number,
compatibilityClients?: string,
): Promise<http.Server> => {
const [majorNodeVersion] = process.versions.node.split('.');
if (majorNodeVersion && Number.parseInt(majorNodeVersion, 10) < 20) {
Expand Down Expand Up @@ -100,6 +101,7 @@ export const startDevServer = async (
emailsDirRelativePath,
staticBaseDirRelativePath,
nextPortToTry,
compatibilityClients,
);
}

Expand Down Expand Up @@ -137,6 +139,7 @@ export const startDevServer = async (
previewServerLocation,
process.cwd(),
conf.get('resendApiKey'),
compatibilityClients,
),
};
if (!process.env.ESBUILD_BINARY_PATH) {
Expand Down
1 change: 1 addition & 0 deletions packages/react-email/src/cli/utils/tree.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
36 changes: 5 additions & 31 deletions packages/ui/src/actions/email-validation/check-compatibility.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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'
Expand Down Expand Up @@ -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,
Expand Down
2 changes: 2 additions & 0 deletions packages/ui/src/app/preview/[...slug]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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()}
/>
</ToolbarProvider>
</Suspense>
Expand Down
12 changes: 11 additions & 1 deletion packages/ui/src/components/toolbar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -51,6 +52,7 @@ const ToolbarInner = ({
serverLintingRows,
serverSpamCheckingResult,
serverCompatibilityResults,
serverCompatibilityClients,

prettyMarkup,
reactMarkup,
Expand All @@ -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) => {
Expand Down Expand Up @@ -283,7 +289,8 @@ const ToolbarInner = ({
<SuccessIcon />
<SuccessTitle>Great compatibility</SuccessTitle>
<SuccessDescription>
Template should render properly everywhere.
Template should render properly in{' '}
{compatibilityClientsLabel}.
</SuccessDescription>
</SuccessWrapper>
) : (
Expand Down Expand Up @@ -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();

Expand All @@ -430,6 +439,7 @@ export function Toolbar({
serverLintingRows={serverLintingRows}
serverSpamCheckingResult={serverSpamCheckingResult}
serverCompatibilityResults={serverCompatibilityResults}
serverCompatibilityClients={serverCompatibilityClients}
/>
);
}
48 changes: 48 additions & 0 deletions packages/ui/src/utils/caniemail/email-clients.ts
Original file line number Diff line number Diff line change
@@ -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;
};
Original file line number Diff line number Diff line change
@@ -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'];

Expand Down Expand Up @@ -32,7 +32,7 @@ const noteNumbersRegex = /#(?<noteNumber>\d+)/g;

export const getCompatibilityStatsForEntry = (
entry: SupportEntry,
emailClients: EmailClient[],
emailClients: readonly EmailClient[],
) => {
const stats: CompatibilityStats = {
status: 'success',
Expand Down
Loading