Skip to content
Draft
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
5 changes: 5 additions & 0 deletions .changeset/body-inherits-html-lang-dir.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"react-email": patch
---

Make `<Body>` inherit `lang` and `dir` from `<Html>` instead of always defaulting to `lang="en" dir="ltr"`, so non-English emails no longer end up with contradictory language metadata
6 changes: 4 additions & 2 deletions apps/docs/components/html.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -49,10 +49,12 @@ const Email = () => {
## Props

<ResponseField name="lang" type="string" default="en">
Identify the language of text content on the email
Identify the language of text content on the email. Inherited by `<Body />`,
which repeats it for email clients that strip the `<html>` tag.
</ResponseField>
<ResponseField name="dir" type="string" default="ltr">
Identify the direction of text content on the email
Identify the direction of text content on the email. Inherited by `<Body />`,
which repeats it for email clients that strip the `<html>` tag.
</ResponseField>

<Support/>
1 change: 1 addition & 0 deletions packages/react-email/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,7 @@
"next": "catalog:",
"react": "catalog:",
"react-dom": "catalog:",
"react-server-dom-webpack": "19.2.4",

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: This adds a version with a published high-severity RSC denial-of-service vulnerability (CVE-2026-23869); CI and developer installs will resolve the vulnerable package. Consider upgrading the React/RSC package set to the patched 19.2.5 releases together, since react-server-dom-webpack has exact React and React DOM peer-version requirements.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At packages/react-email/package.json, line 83:

<comment>This adds a version with a published high-severity RSC denial-of-service vulnerability (CVE-2026-23869); CI and developer installs will resolve the vulnerable package. Consider upgrading the React/RSC package set to the patched 19.2.5 releases together, since `react-server-dom-webpack` has exact React and React DOM peer-version requirements.</comment>

<file context>
@@ -80,6 +80,7 @@
     "next": "catalog:",
     "react": "catalog:",
     "react-dom": "catalog:",
+    "react-server-dom-webpack": "19.2.4",
     "shelljs": "0.10.0",
     "shlex": "3.0.0",
</file context>

"shelljs": "0.10.0",
"shlex": "3.0.0",
"tsx": "catalog:",
Expand Down
99 changes: 99 additions & 0 deletions packages/react-email/src/components/body/body.spec.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
import { render } from '@react-email/render';
import { Html } from '../html/index.js';
import { Tailwind } from '../tailwind/index.js';
import { Body } from './index.js';
import { marginProperties, paddingProperties } from './margin-properties.js';

Expand Down Expand Up @@ -55,6 +57,103 @@ describe('<Body> component', () => {
expect(tdStyle).toContain('padding:20px');
});

describe('lang and dir inheritance from <Html>', () => {
const getAttributes = (html: string, tag: 'html' | 'body' | 'td') => {
const attributes = html.match(new RegExp(`<${tag}([^>]*)>`))?.[1] ?? '';
return {
lang: attributes.match(/lang="([^"]*)"/)?.[1],
dir: attributes.match(/dir="([^"]*)"/)?.[1],
};
};

it('defaults to lang="en" dir="ltr" without an <Html> parent', async () => {
const html = await render(<Body>Test</Body>);
expect(getAttributes(html, 'body')).toEqual({ lang: 'en', dir: 'ltr' });
expect(getAttributes(html, 'td')).toEqual({ lang: 'en', dir: 'ltr' });
});

it('inherits lang and dir set on <Html>', async () => {
const html = await render(
<Html lang="ar" dir="rtl">
<Body>Test</Body>
</Html>,
);
expect(getAttributes(html, 'body')).toEqual({ lang: 'ar', dir: 'rtl' });
expect(getAttributes(html, 'td')).toEqual({ lang: 'ar', dir: 'rtl' });
});

it("inherits <Html>'s defaults when it has no explicit lang/dir", async () => {
const html = await render(
<Html>
<Body>Test</Body>
</Html>,
);
expect(getAttributes(html, 'body')).toEqual({ lang: 'en', dir: 'ltr' });
});

it('lets explicit lang and dir on <Body> win over <Html>', async () => {
const html = await render(
<Html lang="pl">
<Body lang="en" dir="rtl">
Test
</Body>
</Html>,
);
expect(getAttributes(html, 'html')).toEqual({ lang: 'pl', dir: 'ltr' });
expect(getAttributes(html, 'body')).toEqual({ lang: 'en', dir: 'rtl' });
});

it('inherits lang and dir through custom components', async () => {
const Content = ({ children }: { children: React.ReactNode }) => {
return <Body>{children}</Body>;
};
const Layout = ({ children }: { children: React.ReactNode }) => {
return <Content>{children}</Content>;
};

const html = await render(
<Html lang="pl">
<Layout>Cześć</Layout>
</Html>,
);
expect(getAttributes(html, 'body')).toEqual({ lang: 'pl', dir: 'ltr' });
expect(getAttributes(html, 'td')).toEqual({ lang: 'pl', dir: 'ltr' });
});

it('inherits lang and dir with <Tailwind> in between', async () => {
const html = await render(
<Html lang="pl">
<Tailwind>
<Body className="bg-red-500">Cześć</Body>
</Tailwind>
</Html>,
);
expect(getAttributes(html, 'body')).toEqual({ lang: 'pl', dir: 'ltr' });
expect(html).toContain('background-color');
});

it('inherits lang and dir with <Tailwind> around <Html>', async () => {
const html = await render(
<Tailwind>
<Html lang="pl">
<Body className="bg-red-500">Cześć</Body>
</Html>
</Tailwind>,
);
expect(getAttributes(html, 'body')).toEqual({ lang: 'pl', dir: 'ltr' });
expect(html).toContain('background-color');
});

it('does not leak the private context prop into the markup', async () => {
const html = await render(
<Html lang="pl">
<Body>Test</Body>
</Html>,
);
expect(html.toLowerCase()).not.toContain('reactemailcontexts');
});
});

describe('padding resetting behavior', () => {
for (const property of paddingProperties) {
it(`resets the ${property} property on body when it comes from props`, async () => {
Expand Down
32 changes: 20 additions & 12 deletions packages/react-email/src/components/body/body.tsx
Original file line number Diff line number Diff line change
@@ -1,11 +1,28 @@
import * as React from 'react';
import { markAsElement } from '../element-marker.js';
import {
markAsEmailContextConsumer,
readEmailContext,
stripEmailContexts,
} from '../email-context/index.js';
import { htmlContext } from '../html/html-context.js';
import { marginProperties, paddingProperties } from './margin-properties.js';

export type BodyProps = Readonly<React.HtmlHTMLAttributes<HTMLBodyElement>>;

export const Body = React.forwardRef<HTMLBodyElement, BodyProps>(
({ children, style, ...props }, ref) => {
// Email clients like Gmail may strip the html tag, so the language
// metadata is repeated here. It is inherited from <Html> to avoid
// conflicting values in the same document.
// See https://github.com/resend/react-email/issues/3652.
const inherited = readEmailContext(props, htmlContext);
const {
dir = inherited.dir ?? 'ltr',
lang = inherited.lang ?? 'en',
...restProps
} = stripEmailContexts(props);
Comment on lines +20 to +24

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3: A runtime null for dir or lang now bypasses both the inherited value and the ltr/en fallback, because destructuring defaults only handle undefined. This causes the body and its inner cell to omit the metadata for values commonly supplied through a JavaScript/spread props object. Keeping the nullish fallback after reading the explicit values preserves the prior behavior.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At packages/react-email/src/components/body/body.tsx, line 20:

<comment>A runtime `null` for `dir` or `lang` now bypasses both the inherited value and the `ltr`/`en` fallback, because destructuring defaults only handle `undefined`. This causes the body and its inner cell to omit the metadata for values commonly supplied through a JavaScript/spread props object. Keeping the nullish fallback after reading the explicit values preserves the prior behavior.</comment>

<file context>
@@ -1,11 +1,28 @@
+    // conflicting values in the same document.
+    // See https://github.com/resend/react-email/issues/3652.
+    const inherited = readEmailContext(props, htmlContext);
+    const {
+      dir = inherited.dir ?? 'ltr',
+      lang = inherited.lang ?? 'en',
</file context>
Suggested change
const {
dir = inherited.dir ?? 'ltr',
lang = inherited.lang ?? 'en',
...restProps
} = stripEmailContexts(props);
const {
dir: explicitDir,
lang: explicitLang,
...restProps
} = stripEmailContexts(props);
const dir = explicitDir ?? inherited.dir ?? 'ltr';
const lang = explicitLang ?? inherited.lang ?? 'en';


const bodyStyle: Record<string, string | number | undefined> = {
background: style?.background,
backgroundColor: style?.backgroundColor,
Expand All @@ -20,13 +37,7 @@ export const Body = React.forwardRef<HTMLBodyElement, BodyProps>(
}
}
return (
<body
{...props}
dir={props.dir ?? 'ltr'}
lang={props.lang ?? 'en'}
style={bodyStyle}
ref={ref}
>
<body {...restProps} dir={dir} lang={lang} style={bodyStyle} ref={ref}>
<table
border={0}
width="100%"
Expand All @@ -43,11 +54,7 @@ export const Body = React.forwardRef<HTMLBodyElement, BodyProps>(

See https://github.com/resend/react-email/issues/662.
*/}
<td
dir={props.dir ?? 'ltr'}
lang={props.lang ?? 'en'}
style={style}
>
<td dir={dir} lang={lang} style={style}>
{children}
</td>
</tr>
Expand All @@ -60,3 +67,4 @@ export const Body = React.forwardRef<HTMLBodyElement, BodyProps>(

Body.displayName = 'Body';
markAsElement(Body);
markAsEmailContextConsumer(Body);
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
import type * as React from 'react';
import {
contextsPropName,
type EmailContextMap,
markAsEmailContextConsumer,
markAsEmailContextProvider,
} from './markers.js';
import { propagateEmailContexts } from './propagate-email-contexts.js';

/**
* A context meant to be used by the email components, mimicking React
* context without relying on it.
*
* React context cannot be used for the email components because they may be
* rendered inside React Server Components, where `createContext` and hooks
* are not available, while also being rendered with `react-dom/server` or in
* the browser.
*
* Instead of the value living in the renderer, it is carried through the
* element tree itself: a provider component walks its children at render
* time and clones every registered consumer element with the value injected
* into a private prop (see `propagateEmailContexts` for how component
* boundaries are crossed). Consumers then read it with `readEmailContext`,
* which is a plain function and not a hook.
*/
export interface EmailContext<Value> {
id: symbol;
defaultValue: Value;
}

export const createEmailContext = <Value>(
name: string,
defaultValue: Value,
): EmailContext<Value> => ({
// Registered globally so that duplicated copies of a context's module
// (e.g. the CJS and ESM builds loaded in the same process) interoperate.
id: Symbol.for(`react-email.email-context.${name}`),
defaultValue,
});

/**
* Reads the value provided for `context` from a consumer component's props.
*
* The component must have been marked with `markAsEmailContextConsumer` for
* providers to inject values into it. Falls back to the context's default
* value when the component is rendered outside of a provider or when the
* propagation could not reach it (see the caveats on
* `propagateEmailContexts`).
*/
export const readEmailContext = <Value>(
props: object,
context: EmailContext<Value>,
): Value => {
const contexts = (props as Record<string, unknown>)[contextsPropName] as
| EmailContextMap
| undefined;
if (contexts && context.id in contexts) {
return contexts[context.id] as Value;
}
return context.defaultValue;
};

/**
* Removes the private prop used to carry email context values so that it
* doesn't leak into the DOM. Consumers and providers must call this before
* spreading their remaining props into a host element.
*/
export const stripEmailContexts = <Props extends object>(
props: Props,
): Props => {
if (!(contextsPropName in props)) {
return props;
}
const { [contextsPropName]: _contexts, ...rest } = props as Props & {
[contextsPropName]?: EmailContextMap;
};
return rest as unknown as Props;
};

/**
* Provides a value for `context` to all consumer elements found inside
* `children`, mimicking what rendering a React context provider would do.
*
* The component calling this must be marked with
* `markAsEmailContextProvider` and pass its own props as `providerProps`:
* outer providers stop their propagation at nested provider elements and
* inject the values they carry into them, so the nested provider is
* responsible for merging its own value over the inherited ones and
* continuing the propagation, which is what this function does.
*/
export const provideEmailContext = <Value>(
context: EmailContext<Value>,
value: Value,
children: React.ReactNode,
providerProps: object,
): React.ReactNode => {
const inherited = (providerProps as Record<string, unknown>)[
contextsPropName
] as EmailContextMap | undefined;
return propagateEmailContexts(children, {
...inherited,
[context.id]: value,
});
};

export { markAsEmailContextConsumer, markAsEmailContextProvider };
Loading
Loading