fix(emails): bump @react-email/render to ^2.0.6 to fix empty transactional email bodies (#23323)

## Problem

Fixes #23307. Every transactional email (workspace invite, password
reset,
email verification, etc.) is delivered with an **empty body** — no
title, text,
or CTA.

## Root cause

`twenty-server` pins `@react-email/render` directly at `^1.2.3`:

```jsonc
// packages/twenty-server/package.json
"@react-email/render": "^1.2.3",
```

In 1.2.3, `render()` reads `renderToReadableStream` **before** the email
template's async Suspense boundary (i18n/locale load) has resolved. The
result
is the Suspense fallback marker instead of the real markup:

```html
<!DOCTYPE html ...><!--$!--><template></template><!--/$-->
```

This was fixed upstream in `@react-email/render@2.0.6`
(*"await stream.allReady before reading renderToReadableStream
output"*).
`twenty-emails` already resolves a 2.x render via `react-email@6.5.0`,
so the
server's direct pin was simply stale — the two were out of sync.

## Fix

Bump the direct pin to `^2.0.6` (resolves to `2.1.0`) and regenerate the
lockfile. The server's `render()` imports now use the fixed 2.x.

> Note: a `1.2.3` entry remains in `yarn.lock` — it is an internal
transitive
> pin of `@react-email/components@0.5.3`, not the server render path, so
it is
> expected and harmless.

## Verification

Rendering `SendInviteLinkEmail` through the real `render()` (Node 24)
now
returns full markup (5.7 kB) with no Suspense marker and the resolved
invite
link + workspace content, instead of the empty fallback.

A jest unit test was intentionally not added: `@react-email/render` 2.x
uses a
dynamic import that jest's CJS runtime rejects ("A dynamic import
callback was
invoked without --experimental-vm-modules") — which is exactly why the
existing
email specs mock `render`. The fix was verified with a standalone Node
script.


<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/23323?utm_source=github"
target="_blank" rel="noopener noreferrer"
data-no-image-dialog="true"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source
media="(prefers-color-scheme: light)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img
alt="Review in cubic"
src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a>
<!-- End of auto-generated description by cubic. -->

---------

Co-authored-by: Félix Malfait <felix.malfait@gmail.com>
This commit is contained in:
Guillaume Flambard
2026-07-27 11:37:09 +02:00
committed by GitHub
parent 44ed0d5498
commit 710d4da4b1
42 changed files with 409 additions and 438 deletions
+2 -3
View File
@@ -11,7 +11,6 @@
"dependencies": {
"@lingui/core": "^5.9.5",
"@lingui/react": "^5.9.5",
"@react-email/components": "^0.5.3",
"twenty-shared": "workspace:*"
},
"peerDependencies": {
@@ -22,13 +21,13 @@
"@lingui/cli": "^5.9.5",
"@lingui/swc-plugin": "^5.11.0",
"@lingui/vite-plugin": "^5.9.5",
"@react-email/ui": "6.5.0",
"@react-email/ui": "^6.9.0",
"@tiptap/core": "^3.4.2",
"@types/react": "^19.2.0",
"@types/react-dom": "^19.2.0",
"@typescript/native-preview": "^7.0.0-dev.20260116.1",
"@vitejs/plugin-react-swc": "^4.3.1",
"react-email": "6.5.0",
"react-email": "^6.9.0",
"tsc-alias": "^1.8.16",
"vite-plugin-dts": "^4.5.4"
},
@@ -1,6 +1,6 @@
import { type JSX } from 'react';
import { I18nProvider } from '@lingui/react';
import { Container, Html } from '@react-email/components';
import { Container, Html } from 'react-email';
import { BaseHead } from 'src/components/BaseHead';
import { Footer } from 'src/components/Footer';
@@ -1,4 +1,4 @@
import { Font, Head } from '@react-email/components';
import { Font, Head } from 'react-email';
import { emailTheme } from 'src/common-style';
@@ -1,5 +1,5 @@
import { type JSX } from 'react';
import { Button } from '@react-email/components';
import { Button } from 'react-email';
import { emailTheme } from 'src/common-style';
@@ -1,5 +1,5 @@
import { type I18n } from '@lingui/core';
import { Column, Container, Row } from '@react-email/components';
import { Column, Container, Row } from 'react-email';
import { Link } from 'src/components/Link';
import { ShadowText } from 'src/components/ShadowText';
@@ -1,4 +1,4 @@
import { Column, Container, Row } from '@react-email/components';
import { Column, Container, Row } from 'react-email';
import React, { type JSX } from 'react';
import { emailTheme } from 'src/common-style';
@@ -1,4 +1,4 @@
import { Text } from '@react-email/components';
import { Text } from 'react-email';
import { type JSX } from 'react';
import { emailTheme } from 'src/common-style';
@@ -1,5 +1,5 @@
import { type JSX } from 'react';
import { Link as EmailLink } from '@react-email/components';
import { Link as EmailLink } from 'react-email';
import { emailTheme } from 'src/common-style';
@@ -1,4 +1,4 @@
import { Img } from '@react-email/components';
import { Img } from 'react-email';
const logoStyle = {
marginBottom: '40px',
@@ -1,5 +1,5 @@
import { type JSX } from 'react';
import { Text } from '@react-email/components';
import { Text } from 'react-email';
import { emailTheme } from 'src/common-style';
@@ -1,5 +1,5 @@
import { type JSX } from 'react';
import { Text } from '@react-email/components';
import { Text } from 'react-email';
import { emailTheme } from 'src/common-style';
@@ -1,5 +1,5 @@
import { type JSX } from 'react';
import { Heading } from '@react-email/components';
import { Heading } from 'react-email';
import { emailTheme } from 'src/common-style';
@@ -1,5 +1,5 @@
import { type JSX } from 'react';
import { Heading } from '@react-email/components';
import { Heading } from 'react-email';
import { emailTheme } from 'src/common-style';
@@ -1,5 +1,5 @@
import { Trans } from '@lingui/react';
import { Img } from '@react-email/components';
import { Img } from 'react-email';
import { emailTheme } from 'src/common-style';
import { BaseEmail } from 'src/components/BaseEmail';
@@ -1,5 +1,5 @@
import { Trans } from '@lingui/react';
import { Img } from '@react-email/components';
import { Img } from 'react-email';
import { emailTheme } from 'src/common-style';
import { BaseEmail } from 'src/components/BaseEmail';
+5
View File
@@ -1,4 +1,9 @@
export type { JSONContent } from '@tiptap/core';
// Consumers must render through this package so there is a single resolved
// react-email version: a second, independently pinned render can silently drift
// onto an incompatible major and ship empty email bodies.
export { render, toPlainText } from 'react-email';
export * from './utils/render-email';
export * from './emails/billing-subscription-renewing.email';
export * from './emails/billing-trial-converting.email';
export * from './emails/billing-trial-ending.email';
@@ -1,4 +1,4 @@
import { Body, Head, Html } from '@react-email/components';
import { Body, Head, Html } from 'react-email';
import { type JSONContent } from '@tiptap/core';
import { mappedNodeContent } from 'src/utils/email-renderer/renderers/render-node';
@@ -1,4 +1,4 @@
import { Heading } from '@react-email/components';
import { Heading } from 'react-email';
import { type JSONContent } from '@tiptap/core';
import { type ReactNode } from 'react';
import { mappedNodeContent } from 'src/utils/email-renderer/renderers/render-node';
@@ -1,4 +1,4 @@
import { Column, Row } from '@react-email/components';
import { Column, Row } from 'react-email';
import { type JSONContent } from '@tiptap/core';
import { type ReactNode } from 'react';
import { isDefined } from 'twenty-shared/utils';
@@ -1,4 +1,4 @@
import { Text } from '@react-email/components';
import { Text } from 'react-email';
import { type JSONContent } from '@tiptap/core';
import { type ReactNode } from 'react';
import { mappedNodeContent } from 'src/utils/email-renderer/renderers/render-node';
@@ -0,0 +1,34 @@
import { render } from 'react-email';
// React Email renders the tree inside its own Suspense boundary without an
// onError, so anything that throws while rendering a template is swallowed and
// the boundary ships as `<!--$!--><template></template><!--/$-->`. The email is
// then delivered with a blank body and nothing is logged (#23307). Failing the
// send is the lesser evil: a blank email is useless to the recipient and the
// thrown error surfaces in the job logs.
const ERRORED_SUSPENSE_BOUNDARY_MARKER = '<!--$!-->';
export class EmailRenderError extends Error {
constructor(message: string) {
super(message);
this.name = 'EmailRenderError';
}
}
export const renderEmail = async (
...renderArgs: Parameters<typeof render>
): Promise<string> => {
const output = await render(...renderArgs);
if (output.trim().length === 0) {
throw new EmailRenderError('Email template rendered an empty body');
}
if (output.includes(ERRORED_SUSPENSE_BOUNDARY_MARKER)) {
throw new EmailRenderError(
'Email template threw while rendering, its body would have been empty',
);
}
return output;
};
-1
View File
@@ -58,7 +58,6 @@
"@nivo/line": "^0.99.0",
"@nivo/pie": "^0.99.0",
"@nivo/radial-bar": "^0.99.0",
"@react-email/components": "^0.5.3",
"@react-pdf/renderer": "^4.1.6",
"@scalar/api-reference-react": "^0.9.42",
"@sentry/react": "^10.51.0",
@@ -5,7 +5,7 @@ import { SettingsAccountsEventVisibilitySettingsCard } from '@/settings/accounts
import { SettingsOptionCardContentToggle } from '@/settings/components/SettingsOptions/SettingsOptionCardContentToggle';
import { styled } from '@linaria/react';
import { t } from '@lingui/core/macro';
import { Section } from '@react-email/components';
import { Section } from 'twenty-ui/layout';
import { IconUserPlus } from 'twenty-ui/icon';
import { H2Title } from 'twenty-ui/typography';
import { Card } from 'twenty-ui/surfaces';
@@ -5,7 +5,7 @@ import { DATE_TIME_SETTINGS_PREVIEW_DATE } from '@/localization/constants/DateTi
import { SettingsAccountsCalendarDisplaySettings } from '@/settings/accounts/components/SettingsAccountsCalendarDisplaySettings';
import { styled } from '@linaria/react';
import { t } from '@lingui/core/macro';
import { Section } from '@react-email/components';
import { Section } from 'twenty-ui/layout';
import { addMinutes, endOfDay, min, startOfDay } from 'date-fns';
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
import { H2Title } from 'twenty-ui/typography';
@@ -12,7 +12,7 @@ import { type SettingsFieldType } from '@/settings/data-model/types/SettingsFiel
import { SettingsTextInput } from '@/ui/input/components/SettingsTextInput';
import { styled } from '@linaria/react';
import { t } from '@lingui/core/macro';
import { Section } from '@react-email/components';
import { Section } from 'twenty-ui/layout';
import { useContext, useState } from 'react';
import { Controller, useFormContext } from 'react-hook-form';
import { SettingsPath } from 'twenty-shared/types';
-1
View File
@@ -71,7 +71,6 @@
"@ptc-org/nestjs-query-core": "^9.4.0",
"@ptc-org/nestjs-query-graphql": "patch:@ptc-org/nestjs-query-graphql@npm%3A9.4.0#~/.yarn/patches/@ptc-org-nestjs-query-graphql-npm-9.4.0-8e6f7894e1.patch",
"@ptc-org/nestjs-query-typeorm": "patch:@ptc-org/nestjs-query-typeorm@npm%3A9.4.0#~/.yarn/patches/@ptc-org-nestjs-query-typeorm-npm-9.4.0-ca3414967e.patch",
"@react-email/render": "^1.2.3",
"@react-pdf/renderer": "^4.1.6",
"@sentry/nestjs": "^10.59.0",
"@sentry/node": "^10.59.0",
@@ -2,9 +2,8 @@ import { Injectable, Logger } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { msg } from '@lingui/core/macro';
import { render } from '@react-email/render';
import { isNonEmptyString } from '@sniptt/guards';
import { ServerAdminAccessChangedEmail } from 'twenty-emails';
import { ServerAdminAccessChangedEmail, renderEmail } from 'twenty-emails';
import { SOURCE_LOCALE } from 'twenty-shared/translations';
import { isDefined } from 'twenty-shared/utils';
import { Repository } from 'typeorm';
@@ -249,8 +248,8 @@ export class AdminPanelServerAdminService {
canImpersonate: targetUser.canImpersonate,
locale,
});
const html = await render(emailTemplate, { pretty: true });
const text = await render(emailTemplate, { plainText: true });
const html = await renderEmail(emailTemplate, { pretty: true });
const text = await renderEmail(emailTemplate, { plainText: true });
const i18n = this.i18nService.getI18nInstance(locale);
const subject = i18n._(msg`Server administrator access changed`);
@@ -2,8 +2,7 @@ import { Injectable, Logger } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { msg } from '@lingui/core/macro';
import { render } from '@react-email/render';
import { SendApprovedAccessDomainValidation } from 'twenty-emails';
import { SendApprovedAccessDomainValidation, renderEmail } from 'twenty-emails';
import { FileFolder, SettingsPath } from 'twenty-shared/types';
import { getSettingsPath, isDefined } from 'twenty-shared/utils';
import { Repository } from 'typeorm';
@@ -116,8 +115,8 @@ export class ApprovedAccessDomainService {
serverUrl: this.twentyConfigService.get('SERVER_URL'),
locale: sender.locale,
});
const html = await render(emailTemplate);
const text = await render(emailTemplate, {
const html = await renderEmail(emailTemplate);
const text = await renderEmail(emailTemplate, {
plainText: true,
});
@@ -23,9 +23,12 @@ import { type WorkspaceMemberWorkspaceEntity } from 'src/modules/workspace-membe
import { FileUrlService } from 'src/engine/core-modules/file/file-url/file-url.service';
import { ApprovedAccessDomainService } from './approved-access-domain.service';
// To avoid dynamic import issues in Jest
jest.mock('@react-email/render', () => ({
render: jest.fn().mockImplementation(async (template, options) => {
// render() resolves through a streaming scheduler that never advances under the
// globally enabled fake timers; the real render path is covered by
// email-templates-rendering.spec.ts.
jest.mock('twenty-emails', () => ({
...jest.requireActual('twenty-emails'),
renderEmail: jest.fn().mockImplementation(async (_template, options) => {
if (options?.plainText) {
return 'Plain Text Email';
}
@@ -4,10 +4,9 @@ import { InjectRepository } from '@nestjs/typeorm';
import crypto, { randomUUID } from 'node:crypto';
import { msg } from '@lingui/core/macro';
import { render } from '@react-email/render';
import { addMilliseconds } from 'date-fns';
import ms from 'ms';
import { PasswordUpdateNotifyEmail } from 'twenty-emails';
import { PasswordUpdateNotifyEmail, renderEmail } from 'twenty-emails';
import { PermissionFlagType } from 'twenty-shared/constants';
import { AppPath, ConnectedAccountProvider } from 'twenty-shared/types';
import { isNonEmptyString } from '@sniptt/guards';
@@ -721,8 +720,8 @@ export class AuthService {
locale: firstUserWorkspace.locale,
});
const html = await render(emailTemplate, { pretty: true });
const text = await render(emailTemplate, { plainText: true });
const html = await renderEmail(emailTemplate, { pretty: true });
const text = await renderEmail(emailTemplate, { plainText: true });
const passwordChangedMsg = msg`Your Password Has Been Successfully Changed`;
const i18n = this.i18nService.getI18nInstance(firstUserWorkspace.locale);
@@ -22,8 +22,12 @@ import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.ent
import { ResetPasswordService } from './reset-password.service';
jest.mock('@react-email/render', () => ({
render: jest.fn().mockImplementation(async (_, options) => {
// render() resolves through a streaming scheduler that never advances under the
// globally enabled fake timers; the real render path is covered by
// email-templates-rendering.spec.ts.
jest.mock('twenty-emails', () => ({
...jest.requireActual('twenty-emails'),
renderEmail: jest.fn().mockImplementation(async (_template, options) => {
if (options?.plainText) {
return 'Plain Text Email';
}
@@ -4,10 +4,9 @@ import { InjectRepository } from '@nestjs/typeorm';
import crypto from 'crypto';
import { msg } from '@lingui/core/macro';
import { render } from '@react-email/render';
import { addMilliseconds, differenceInMilliseconds } from 'date-fns';
import ms from 'ms';
import { PasswordResetLinkEmail } from 'twenty-emails';
import { PasswordResetLinkEmail, renderEmail } from 'twenty-emails';
import { type APP_LOCALES } from 'twenty-shared/translations';
import { AppPath } from 'twenty-shared/types';
import {
@@ -197,8 +196,8 @@ export class ResetPasswordService {
const emailTemplate = PasswordResetLinkEmail(emailData);
const html = await render(emailTemplate, { pretty: true });
const text = await render(emailTemplate, { plainText: true });
const html = await renderEmail(emailTemplate, { pretty: true });
const text = await renderEmail(emailTemplate, { plainText: true });
const i18n = this.i18nService.getI18nInstance(locale);
const subjectTemplate = hasPassword
@@ -13,13 +13,14 @@ import {
} from 'src/engine/core-modules/billing/reminders/constants/billing-reminder-sent-keys.constant';
import { BillingReminderService } from 'src/engine/core-modules/billing/reminders/services/billing-reminder.service';
jest.mock('@react-email/render', () => ({
render: jest.fn().mockResolvedValue('<html></html>'),
}));
// render() resolves through a streaming scheduler that never advances under the
// globally enabled fake timers; the real render path is covered by
// email-templates-rendering.spec.ts.
jest.mock('twenty-emails', () => ({
BillingTrialEndingEmail: jest.fn(),
BillingTrialConvertingEmail: jest.fn(),
BillingSubscriptionRenewingEmail: jest.fn(),
renderEmail: jest.fn().mockResolvedValue('<html></html>'),
}));
const CONFIG: Record<string, unknown> = {
@@ -4,12 +4,12 @@ import { Injectable, Logger } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { msg } from '@lingui/core/macro';
import { render } from '@react-email/render';
import { addDays, differenceInCalendarDays } from 'date-fns';
import {
BillingSubscriptionRenewingEmail,
BillingTrialConvertingEmail,
BillingTrialEndingEmail,
renderEmail,
} from 'twenty-emails';
import { SettingsPath } from 'twenty-shared/types';
import { getSettingsPath, isDefined } from 'twenty-shared/utils';
@@ -293,8 +293,8 @@ export class BillingReminderService {
locale,
});
const html = await render(emailTemplate, { pretty: true });
const text = await render(emailTemplate, { plainText: true });
const html = await renderEmail(emailTemplate, { pretty: true });
const text = await renderEmail(emailTemplate, { plainText: true });
await this.emailService.send({
to: workspaceMember.userEmail,
@@ -1,12 +1,10 @@
import { Injectable, Logger } from '@nestjs/common';
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { msg } from '@lingui/core/macro';
import { render } from '@react-email/render';
import { renderToStaticMarkup } from 'react-dom/server';
import { addMilliseconds, differenceInMilliseconds } from 'date-fns';
import ms from 'ms';
import { SendEmailVerificationLinkEmail } from 'twenty-emails';
import { SendEmailVerificationLinkEmail, renderEmail } from 'twenty-emails';
import { type APP_LOCALES } from 'twenty-shared/translations';
import { AppPath } from 'twenty-shared/types';
import { assertIsDefinedOrThrow, isDefined } from 'twenty-shared/utils';
@@ -32,8 +30,6 @@ import { UserEntity } from 'src/engine/core-modules/user/user.entity';
@Injectable()
export class EmailVerificationService {
private readonly logger = new Logger(EmailVerificationService.name);
constructor(
@InjectRepository(AppTokenEntity)
private readonly appTokenRepository: Repository<AppTokenEntity>,
@@ -97,37 +93,9 @@ export class EmailVerificationService {
const emailTemplate = SendEmailVerificationLinkEmail(emailData);
const html = await render(emailTemplate);
const html = await renderEmail(emailTemplate);
// TEMPORARY DIAGNOSTIC (remove once the empty verification email body is root-caused):
// render() wraps the tree in <Suspense> and uses renderToReadableStream WITHOUT an
// onError, so any SSR throw is swallowed into an errored boundary and the body ships
// as `<!DOCTYPE ...><!--$!--><template></template><!--/$-->`. Detect that and re-render
// synchronously to surface the real error + stack (production strips it from the markup).
if (html.length === 0 || html.includes('<!--$!-->')) {
this.logger.error(
`[EMAIL_VERIFICATION_RENDER_DEBUG] verification email rendered empty/errored (locale=${locale}, trigger=${verificationTrigger}, htmlLength=${html.length})`,
);
this.logger.error(
`[EMAIL_VERIFICATION_RENDER_DEBUG] html start: ${html.slice(0, 400)}`,
);
try {
renderToStaticMarkup(emailTemplate);
this.logger.error(
'[EMAIL_VERIFICATION_RENDER_DEBUG] renderToStaticMarkup did not throw — likely an async/Suspense rejection rather than a synchronous throw',
);
} catch (renderError) {
this.logger.error(
`[EMAIL_VERIFICATION_RENDER_DEBUG] underlying render error: ${
renderError instanceof Error
? `${renderError.message}\n${renderError.stack}`
: String(renderError)
}`,
);
}
}
const text = await render(emailTemplate, {
const text = await renderEmail(emailTemplate, {
plainText: true,
});
@@ -0,0 +1,228 @@
import { createElement } from 'react';
import {
BillingSubscriptionRenewingEmail,
BillingTrialConvertingEmail,
BillingTrialEndingEmail,
CleanSuspendedWorkspaceEmail,
PasswordResetLinkEmail,
PasswordUpdateNotifyEmail,
SendApprovedAccessDomainValidation,
SendEmailVerificationLinkEmail,
SendInviteLinkEmail,
ServerAdminAccessChangedEmail,
WarnSuspendedWorkspaceEmail,
EmailRenderError,
renderEmail,
} from 'twenty-emails';
const WORKSPACE = { name: 'Acme Inc', logo: undefined };
const SENDER = {
email: 'tim@twenty.com',
firstName: 'Tim',
lastName: 'Apple',
};
const TEMPLATES = [
{
name: 'BillingSubscriptionRenewingEmail',
element: BillingSubscriptionRenewingEmail({
userName: 'Tim',
workspaceDisplayName: 'Acme Inc',
renewsAt: new Date('2026-01-01'),
link: 'https://app.twenty.com/settings/billing',
locale: 'en',
}),
expectedContent: 'https://app.twenty.com/settings/billing',
},
{
name: 'BillingTrialConvertingEmail',
element: BillingTrialConvertingEmail({
userName: 'Tim',
workspaceDisplayName: 'Acme Inc',
trialEndsAt: new Date('2026-01-01'),
interval: 'month',
link: 'https://app.twenty.com/settings/billing',
locale: 'en',
}),
expectedContent: 'https://app.twenty.com/settings/billing',
},
{
name: 'BillingTrialEndingEmail',
element: BillingTrialEndingEmail({
userName: 'Tim',
workspaceDisplayName: 'Acme Inc',
trialEndsAt: new Date('2026-01-01'),
dataRetentionDays: 30,
link: 'https://app.twenty.com/settings/billing',
locale: 'en',
}),
expectedContent: 'https://app.twenty.com/settings/billing',
},
{
name: 'CleanSuspendedWorkspaceEmail',
element: CleanSuspendedWorkspaceEmail({
daysSinceInactive: 30,
userName: 'Tim',
workspaceDisplayName: 'Acme Inc',
locale: 'en',
}),
expectedContent: 'Acme Inc',
},
{
name: 'PasswordResetLinkEmail',
element: PasswordResetLinkEmail({
duration: '5 minutes',
hasPassword: true,
link: 'https://app.twenty.com/reset-password',
locale: 'en',
}),
expectedContent: 'https://app.twenty.com/reset-password',
},
{
name: 'PasswordUpdateNotifyEmail',
element: PasswordUpdateNotifyEmail({
userName: 'Tim',
email: 'tim@twenty.com',
link: 'https://app.twenty.com',
locale: 'en',
}),
expectedContent: 'tim@twenty.com',
},
{
name: 'SendApprovedAccessDomainValidation',
element: SendApprovedAccessDomainValidation({
link: 'https://app.twenty.com/validate-approved-access-domain',
domain: 'acme.com',
workspace: WORKSPACE,
sender: SENDER,
serverUrl: 'https://app.twenty.com',
locale: 'en',
}),
expectedContent: 'https://app.twenty.com/validate-approved-access-domain',
},
{
name: 'SendEmailVerificationLinkEmail',
element: SendEmailVerificationLinkEmail({
link: 'https://app.twenty.com/verify-email',
locale: 'en',
}),
expectedContent: 'https://app.twenty.com/verify-email',
},
{
name: 'SendInviteLinkEmail',
element: SendInviteLinkEmail({
link: 'https://app.twenty.com/invite/token',
workspace: WORKSPACE,
sender: SENDER,
serverUrl: 'https://app.twenty.com',
locale: 'en',
}),
expectedContent: 'https://app.twenty.com/invite/token',
},
{
name: 'ServerAdminAccessChangedEmail',
element: ServerAdminAccessChangedEmail({
actorName: 'Tim',
targetName: 'Jony',
targetEmail: 'jony@twenty.com',
canAccessFullAdminPanel: true,
canImpersonate: false,
locale: 'en',
}),
expectedContent: 'jony@twenty.com',
},
{
name: 'WarnSuspendedWorkspaceEmail',
element: WarnSuspendedWorkspaceEmail({
daysSinceInactive: 30,
inactiveDaysBeforeDelete: 60,
userName: 'Tim',
workspaceDisplayName: 'Acme Inc',
link: 'https://app.twenty.com/settings/billing',
locale: 'en',
}),
expectedContent: 'https://app.twenty.com/settings/billing',
},
];
// Transactional emails went out with an empty body for a month without a single
// test noticing, because every email spec mocks the renderer (#23307). These
// specs run the real render path instead.
describe('email templates rendering', () => {
// render() resolves through a streaming scheduler that never advances under
// the globally enabled fake timers.
beforeAll(() => {
jest.useRealTimers();
});
afterAll(() => {
jest.useFakeTimers();
});
it.each(TEMPLATES)(
'should render $name to a complete HTML body',
async ({ element, expectedContent }) => {
const html = await renderEmail(element);
expect(html).not.toContain('<!--$!-->');
expect(html).not.toContain('<template></template>');
expect(html).toContain('</html>');
expect(html).toContain(expectedContent);
},
);
it.each(TEMPLATES)(
'should render $name to a non-empty plain text body',
async ({ element }) => {
const text = await renderEmail(element, { plainText: true });
expect(text.trim().length).toBeGreaterThan(0);
expect(text).not.toContain('<');
},
);
it('should render translated content for a non-english locale', async () => {
const html = await renderEmail(
PasswordResetLinkEmail({
duration: '5 minutes',
hasPassword: true,
link: 'https://app.twenty.com/reset-password',
locale: 'fr-FR',
}),
);
expect(html).toContain('mot de passe');
});
});
describe('renderEmail guard', () => {
beforeAll(() => {
jest.useRealTimers();
});
afterAll(() => {
jest.useFakeTimers();
});
// @react-email/render 1.x turned a throw into `<!--$!--><template></template>`
// and resolved, so the send went out blank. Whatever the renderer does, a
// failed render must never reach the recipient as an empty email.
it('should reject rather than resolve a blank body when a component throws', async () => {
const ThrowingComponent = () => {
throw new Error('boom');
};
await expect(renderEmail(createElement(ThrowingComponent))).rejects.toThrow(
/boom|Email template/,
);
});
it('should throw EmailRenderError when a template produces no text body', async () => {
const EmptyComponent = () => null;
await expect(
renderEmail(createElement(EmptyComponent), { plainText: true }),
).rejects.toThrow(EmailRenderError);
});
});
@@ -1,9 +1,9 @@
import { Injectable, Logger } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { toPlainText } from '@react-email/render';
import { isNonEmptyString } from '@sniptt/guards';
import DOMPurify from 'dompurify';
import { toPlainText } from 'twenty-emails';
import { MAX_EMAIL_RECIPIENTS } from 'twenty-shared/constants';
import {
ConnectedAccountProvider,
@@ -1,5 +1,4 @@
import { render } from '@react-email/render';
import { type JSONContent, reactMarkupFromJSON } from 'twenty-emails';
import { type JSONContent, reactMarkupFromJSON, render } from 'twenty-emails';
export const renderRichTextToHtml = async (
jsonContent: JSONContent,
@@ -26,9 +26,12 @@ import { WorkspaceInvitationService } from './workspace-invitation.service';
// To fix a circular dependency issue
jest.mock('src/engine/core-modules/workspace/services/workspace.service');
// To avoid dynamic import issues in Jest
jest.mock('@react-email/render', () => ({
render: jest.fn().mockImplementation(async (template, options) => {
// render() resolves through a streaming scheduler that never advances under the
// globally enabled fake timers; the real render path is covered by
// email-templates-rendering.spec.ts.
jest.mock('twenty-emails', () => ({
...jest.requireActual('twenty-emails'),
renderEmail: jest.fn().mockImplementation(async (_template, options) => {
if (options?.plainText) {
return 'Plain Text Email';
}
@@ -4,10 +4,9 @@ import { InjectRepository } from '@nestjs/typeorm';
import crypto from 'crypto';
import { msg } from '@lingui/core/macro';
import { render } from '@react-email/render';
import { addMilliseconds } from 'date-fns';
import ms from 'ms';
import { SendInviteLinkEmail } from 'twenty-emails';
import { SendInviteLinkEmail, renderEmail } from 'twenty-emails';
import { AppPath, FileFolder } from 'twenty-shared/types';
import { getAppPath, isDefined } from 'twenty-shared/utils';
import { In, IsNull, Repository } from 'typeorm';
@@ -361,8 +360,8 @@ export class WorkspaceInvitationService {
};
const emailTemplate = SendInviteLinkEmail(emailData);
const html = await render(emailTemplate);
const text = await render(emailTemplate, {
const html = await renderEmail(emailTemplate);
const text = await renderEmail(emailTemplate, {
plainText: true,
});
@@ -2,11 +2,11 @@ import { Injectable, Logger } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { msg } from '@lingui/core/macro';
import { render } from '@react-email/render';
import { differenceInDays } from 'date-fns';
import {
CleanSuspendedWorkspaceEmail,
WarnSuspendedWorkspaceEmail,
renderEmail,
} from 'twenty-emails';
import { SettingsPath } from 'twenty-shared/types';
import { getSettingsPath, isDefined } from 'twenty-shared/utils';
@@ -130,8 +130,8 @@ export class CleanerWorkspaceService {
locale: workspaceMember.locale,
};
const emailTemplate = WarnSuspendedWorkspaceEmail(emailData);
const html = await render(emailTemplate, { pretty: true });
const text = await render(emailTemplate, { plainText: true });
const html = await renderEmail(emailTemplate, { pretty: true });
const text = await renderEmail(emailTemplate, { plainText: true });
const workspaceDeletionMsg = msg`Your workspace is paused — reactivate to keep your data`;
const i18n = this.i18nService.getI18nInstance(workspaceMember.locale);
@@ -220,8 +220,8 @@ export class CleanerWorkspaceService {
locale: workspaceMember.locale,
};
const emailTemplate = CleanSuspendedWorkspaceEmail(emailData);
const html = await render(emailTemplate, { pretty: true });
const text = await render(emailTemplate, { plainText: true });
const html = await renderEmail(emailTemplate, { pretty: true });
const text = await renderEmail(emailTemplate, { plainText: true });
if (!isDefined(workspaceMember.userEmail)) {
throw new Error('Workspace member email is missing');
+68 -334
View File
@@ -2499,19 +2499,6 @@ __metadata:
languageName: node
linkType: hard
"@babel/generator@npm:^7.27.0, @babel/generator@npm:^7.29.7":
version: 7.29.7
resolution: "@babel/generator@npm:7.29.7"
dependencies:
"@babel/parser": "npm:^7.29.7"
"@babel/types": "npm:^7.29.7"
"@jridgewell/gen-mapping": "npm:^0.3.12"
"@jridgewell/trace-mapping": "npm:^0.3.28"
jsesc: "npm:^3.0.2"
checksum: 10c0/9bf72b01b5bd0ea5b1288a0e37dbd360bff2f2b1ce73342c0d40fb3db2ec3dc004ada5ffa925c5e12939a416eed59e600d562b8ecd938ce0d27dfd0eb6c6c2b7
languageName: node
linkType: hard
"@babel/generator@npm:^7.28.5":
version: 7.28.5
resolution: "@babel/generator@npm:7.28.5"
@@ -2538,6 +2525,19 @@ __metadata:
languageName: node
linkType: hard
"@babel/generator@npm:^7.29.7":
version: 7.29.7
resolution: "@babel/generator@npm:7.29.7"
dependencies:
"@babel/parser": "npm:^7.29.7"
"@babel/types": "npm:^7.29.7"
"@jridgewell/gen-mapping": "npm:^0.3.12"
"@jridgewell/trace-mapping": "npm:^0.3.28"
jsesc: "npm:^3.0.2"
checksum: 10c0/9bf72b01b5bd0ea5b1288a0e37dbd360bff2f2b1ce73342c0d40fb3db2ec3dc004ada5ffa925c5e12939a416eed59e600d562b8ecd938ce0d27dfd0eb6c6c2b7
languageName: node
linkType: hard
"@babel/helper-annotate-as-pure@npm:^7.25.9":
version: 7.25.9
resolution: "@babel/helper-annotate-as-pure@npm:7.25.9"
@@ -2983,14 +2983,14 @@ __metadata:
languageName: node
linkType: hard
"@babel/parser@npm:7.27.0":
version: 7.27.0
resolution: "@babel/parser@npm:7.27.0"
"@babel/parser@npm:7.29.2, @babel/parser@npm:^7.28.6, @babel/parser@npm:^7.29.0":
version: 7.29.2
resolution: "@babel/parser@npm:7.29.2"
dependencies:
"@babel/types": "npm:^7.27.0"
"@babel/types": "npm:^7.29.0"
bin:
parser: ./bin/babel-parser.js
checksum: 10c0/ba2ed3f41735826546a3ef2a7634a8d10351df221891906e59b29b0a0cd748f9b0e7a6f07576858a9de8e77785aad925c8389ddef146de04ea2842047c9d2859
checksum: 10c0/e5a4e69e3ac7acdde995f37cf299a68458cfe7009dff66bd0962fd04920bef287201169006af365af479c08ff216bfefbb595e331f87f6ae7283858aebbc3317
languageName: node
linkType: hard
@@ -3016,7 +3016,7 @@ __metadata:
languageName: node
linkType: hard
"@babel/parser@npm:^7.27.0, @babel/parser@npm:^7.28.5":
"@babel/parser@npm:^7.28.5":
version: 7.28.5
resolution: "@babel/parser@npm:7.28.5"
dependencies:
@@ -3027,17 +3027,6 @@ __metadata:
languageName: node
linkType: hard
"@babel/parser@npm:^7.28.6, @babel/parser@npm:^7.29.0":
version: 7.29.2
resolution: "@babel/parser@npm:7.29.2"
dependencies:
"@babel/types": "npm:^7.29.0"
bin:
parser: ./bin/babel-parser.js
checksum: 10c0/e5a4e69e3ac7acdde995f37cf299a68458cfe7009dff66bd0962fd04920bef287201169006af365af479c08ff216bfefbb595e331f87f6ae7283858aebbc3317
languageName: node
linkType: hard
"@babel/plugin-bugfix-firefox-class-in-computed-class-key@npm:^7.25.9":
version: 7.25.9
resolution: "@babel/plugin-bugfix-firefox-class-in-computed-class-key@npm:7.25.9"
@@ -4454,7 +4443,7 @@ __metadata:
languageName: node
linkType: hard
"@babel/template@npm:^7.20.7, @babel/template@npm:^7.27.0, @babel/template@npm:^7.29.7":
"@babel/template@npm:^7.20.7, @babel/template@npm:^7.29.7":
version: 7.29.7
resolution: "@babel/template@npm:7.29.7"
dependencies:
@@ -4476,18 +4465,18 @@ __metadata:
languageName: node
linkType: hard
"@babel/traverse@npm:7.27.0":
version: 7.27.0
resolution: "@babel/traverse@npm:7.27.0"
"@babel/traverse@npm:7.29.0, @babel/traverse@npm:^7.28.6":
version: 7.29.0
resolution: "@babel/traverse@npm:7.29.0"
dependencies:
"@babel/code-frame": "npm:^7.26.2"
"@babel/generator": "npm:^7.27.0"
"@babel/parser": "npm:^7.27.0"
"@babel/template": "npm:^7.27.0"
"@babel/types": "npm:^7.27.0"
"@babel/code-frame": "npm:^7.29.0"
"@babel/generator": "npm:^7.29.0"
"@babel/helper-globals": "npm:^7.28.0"
"@babel/parser": "npm:^7.29.0"
"@babel/template": "npm:^7.28.6"
"@babel/types": "npm:^7.29.0"
debug: "npm:^4.3.1"
globals: "npm:^11.1.0"
checksum: 10c0/c7af29781960dacaae51762e8bc6c4b13d6ab4b17312990fbca9fc38e19c4ad7fecaae24b1cf52fb844e8e6cdc76c70ad597f90e496bcb3cc0a1d66b41a0aa5b
checksum: 10c0/f63ef6e58d02a9fbf3c0e2e5f1c877da3e0bc57f91a19d2223d53e356a76859cbaf51171c9211c71816d94a0e69efa2732fd27ffc0e1bbc84b636e60932333eb
languageName: node
linkType: hard
@@ -4536,21 +4525,6 @@ __metadata:
languageName: node
linkType: hard
"@babel/traverse@npm:^7.28.6":
version: 7.29.0
resolution: "@babel/traverse@npm:7.29.0"
dependencies:
"@babel/code-frame": "npm:^7.29.0"
"@babel/generator": "npm:^7.29.0"
"@babel/helper-globals": "npm:^7.28.0"
"@babel/parser": "npm:^7.29.0"
"@babel/template": "npm:^7.28.6"
"@babel/types": "npm:^7.29.0"
debug: "npm:^4.3.1"
checksum: 10c0/f63ef6e58d02a9fbf3c0e2e5f1c877da3e0bc57f91a19d2223d53e356a76859cbaf51171c9211c71816d94a0e69efa2732fd27ffc0e1bbc84b636e60932333eb
languageName: node
linkType: hard
"@babel/types@npm:^7.0.0, @babel/types@npm:^7.18.13, @babel/types@npm:^7.20.7, @babel/types@npm:^7.21.2, @babel/types@npm:^7.21.3, @babel/types@npm:^7.23.5, @babel/types@npm:^7.25.9, @babel/types@npm:^7.27.1, @babel/types@npm:^7.27.3, @babel/types@npm:^7.28.0, @babel/types@npm:^7.28.2, @babel/types@npm:^7.3.3, @babel/types@npm:^7.4.4":
version: 7.28.2
resolution: "@babel/types@npm:7.28.2"
@@ -4561,7 +4535,7 @@ __metadata:
languageName: node
linkType: hard
"@babel/types@npm:^7.26.0, @babel/types@npm:^7.26.10, @babel/types@npm:^7.27.0, @babel/types@npm:^7.29.7":
"@babel/types@npm:^7.26.0, @babel/types@npm:^7.26.10, @babel/types@npm:^7.29.7":
version: 7.29.7
resolution: "@babel/types@npm:7.29.7"
dependencies:
@@ -15208,245 +15182,28 @@ __metadata:
languageName: node
linkType: hard
"@react-email/body@npm:0.1.0":
version: 0.1.0
resolution: "@react-email/body@npm:0.1.0"
peerDependencies:
react: ^18.0 || ^19.0 || ^19.0.0-rc
checksum: 10c0/deeee52e0326eed20b3267dfe970649705298efae9ff580009ee7f48c201cbca6cd200be163865178ccdab04fc8e44384c6dd7ab130557b93f421d3b263b33c8
languageName: node
linkType: hard
"@react-email/button@npm:0.2.0":
version: 0.2.0
resolution: "@react-email/button@npm:0.2.0"
peerDependencies:
react: ^18.0 || ^19.0 || ^19.0.0-rc
checksum: 10c0/1145d096ace8a459271f550faa941668122bd816adc6547390ede5becab5b0dc99a6f19c838e539fe53a73953bd4a4de790b1a7ad3d5981c44f5d34149045bfc
languageName: node
linkType: hard
"@react-email/code-block@npm:0.1.0":
version: 0.1.0
resolution: "@react-email/code-block@npm:0.1.0"
dependencies:
prismjs: "npm:^1.30.0"
peerDependencies:
react: ^18.0 || ^19.0 || ^19.0.0-rc
checksum: 10c0/55d84654cbf593e49102a11d1615e3beefb6943a82842ab1e51f435c6130a62f4ea2ebc034b127ebcc96822789b58cf352df340823278c9759bb3f031d8a796e
languageName: node
linkType: hard
"@react-email/code-inline@npm:0.0.5":
version: 0.0.5
resolution: "@react-email/code-inline@npm:0.0.5"
peerDependencies:
react: ^18.0 || ^19.0 || ^19.0.0-rc
checksum: 10c0/4081233686dd09575c580303f2822c415e1acc3c21847e64536afee60d37108810f5325e50a417ca39ab796fb21aae488108b4dcb55b0bb5e5b750f1019f32d7
languageName: node
linkType: hard
"@react-email/column@npm:0.0.13":
version: 0.0.13
resolution: "@react-email/column@npm:0.0.13"
peerDependencies:
react: ^18.0 || ^19.0 || ^19.0.0-rc
checksum: 10c0/56ebc6af1c546daca2c32b0700538242ab7d90ffff4be37bf9dcf929311b6996c66b39f506dfd37063bacd4382abdeab93baf8c1e3266aa62fe3b282b16a023f
languageName: node
linkType: hard
"@react-email/components@npm:^0.5.3":
version: 0.5.3
resolution: "@react-email/components@npm:0.5.3"
dependencies:
"@react-email/body": "npm:0.1.0"
"@react-email/button": "npm:0.2.0"
"@react-email/code-block": "npm:0.1.0"
"@react-email/code-inline": "npm:0.0.5"
"@react-email/column": "npm:0.0.13"
"@react-email/container": "npm:0.0.15"
"@react-email/font": "npm:0.0.9"
"@react-email/head": "npm:0.0.12"
"@react-email/heading": "npm:0.0.15"
"@react-email/hr": "npm:0.0.11"
"@react-email/html": "npm:0.0.11"
"@react-email/img": "npm:0.0.11"
"@react-email/link": "npm:0.0.12"
"@react-email/markdown": "npm:0.0.15"
"@react-email/preview": "npm:0.0.13"
"@react-email/render": "npm:1.2.3"
"@react-email/row": "npm:0.0.12"
"@react-email/section": "npm:0.0.16"
"@react-email/tailwind": "npm:1.2.2"
"@react-email/text": "npm:0.1.5"
peerDependencies:
react: ^18.0 || ^19.0 || ^19.0.0-rc
checksum: 10c0/a0839c635cde48d45d49d3ea19aa17a8e5616225e516d76de1c4d0915d88b903241033a55f4aa047d212672e48ff809840e0d05afd80bbaacb330cef7804971a
languageName: node
linkType: hard
"@react-email/container@npm:0.0.15":
version: 0.0.15
resolution: "@react-email/container@npm:0.0.15"
peerDependencies:
react: ^18.0 || ^19.0 || ^19.0.0-rc
checksum: 10c0/5098468b5336682f0f833a57cea1182bfe4a46bb03d5aa9fc85f96ab1e79845e30a9487c4c56094a9482465e94aa4395f10978c99b1772ca4bd0823d2d9071f6
languageName: node
linkType: hard
"@react-email/font@npm:0.0.9":
version: 0.0.9
resolution: "@react-email/font@npm:0.0.9"
peerDependencies:
react: ^18.0 || ^19.0 || ^19.0.0-rc
checksum: 10c0/b51c9bc22f3ea6e26c34fd5e4186be0b42a0414558f318b5784079145b267f7316729d54c8ba8c955ee5100e4b9ca9f5048c123a66ad2e277d49dd07c718287c
languageName: node
linkType: hard
"@react-email/head@npm:0.0.12":
version: 0.0.12
resolution: "@react-email/head@npm:0.0.12"
peerDependencies:
react: ^18.0 || ^19.0 || ^19.0.0-rc
checksum: 10c0/511147ea52330f5752e2d226a3417192adb5c0f301e35077a07fd66b99504eda6c3bfa49713a10400cf8542d75dfb317e5050b5383b285129db9854d5760d97d
languageName: node
linkType: hard
"@react-email/heading@npm:0.0.15":
version: 0.0.15
resolution: "@react-email/heading@npm:0.0.15"
peerDependencies:
react: ^18.0 || ^19.0 || ^19.0.0-rc
checksum: 10c0/74e58d2933eb6d37f4f3a6f82c63af6c9ffe2ce8cd14c7f6e27f8b76d39800cbf1a215d86e17a55d69eca4c73d5e9ea747ae1cd0a7df4a08a861a13afb9989dc
languageName: node
linkType: hard
"@react-email/hr@npm:0.0.11":
version: 0.0.11
resolution: "@react-email/hr@npm:0.0.11"
peerDependencies:
react: ^18.0 || ^19.0 || ^19.0.0-rc
checksum: 10c0/9d8199bdcfdc7e180636cdc11b7af5c44d185598fedd97dd0734103d662074ed8b7204e362c31f0dfd3925c37181e2fe22dbf1f76908c862d489f34e7efea7a9
languageName: node
linkType: hard
"@react-email/html@npm:0.0.11":
version: 0.0.11
resolution: "@react-email/html@npm:0.0.11"
peerDependencies:
react: ^18.0 || ^19.0 || ^19.0.0-rc
checksum: 10c0/04be96e135677cd507636877c2f9bf1a507d40f6d4442ff3fac58bc70d478e7f23dd03d94ecc1605d3e368d50129281e71f4b7afb242f1b32d285bfc37236a09
languageName: node
linkType: hard
"@react-email/img@npm:0.0.11":
version: 0.0.11
resolution: "@react-email/img@npm:0.0.11"
peerDependencies:
react: ^18.0 || ^19.0 || ^19.0.0-rc
checksum: 10c0/082e9f7d7290de4d340b06bc0ab0e4ffec08604885fdf06d93594e3684726279a3a65c0be2c83c9397bb832289078d533f0499b423ed3558f7736ad7d333d457
languageName: node
linkType: hard
"@react-email/link@npm:0.0.12":
version: 0.0.12
resolution: "@react-email/link@npm:0.0.12"
peerDependencies:
react: ^18.0 || ^19.0 || ^19.0.0-rc
checksum: 10c0/d831666bd52af9ba3a50bd4853e2b70387a041264e3781d3dab4708b755e8b2cb6df339f71eecd889b35221c205bd9997d47e950d1e3b226b4be50a22405ca87
languageName: node
linkType: hard
"@react-email/markdown@npm:0.0.15":
version: 0.0.15
resolution: "@react-email/markdown@npm:0.0.15"
dependencies:
md-to-react-email: "npm:^5.0.5"
peerDependencies:
react: ^18.0 || ^19.0 || ^19.0.0-rc
checksum: 10c0/68dc46d929ad0daf86f7d25e159455bdfbf4c0f0ca8ad152caf407f2eb154f4edb88912728cd5bba0c01f4806217e533e66baa02138c8ddcb5f2049719da7137
languageName: node
linkType: hard
"@react-email/preview@npm:0.0.13":
version: 0.0.13
resolution: "@react-email/preview@npm:0.0.13"
peerDependencies:
react: ^18.0 || ^19.0 || ^19.0.0-rc
checksum: 10c0/223cff5238f0bfc4f1c3cdd1ae4f8b92cde16a60594d4d5073607b7af0b3cfaf5ae10234ca531d9403c7143afcba2e71bc12c1aba75e51f0ae58fd9c0b8bf542
languageName: node
linkType: hard
"@react-email/render@npm:1.2.3, @react-email/render@npm:^1.2.3":
version: 1.2.3
resolution: "@react-email/render@npm:1.2.3"
dependencies:
html-to-text: "npm:^9.0.5"
prettier: "npm:^3.5.3"
react-promise-suspense: "npm:^0.3.4"
peerDependencies:
react: ^18.0 || ^19.0 || ^19.0.0-rc
react-dom: ^18.0 || ^19.0 || ^19.0.0-rc
checksum: 10c0/7ddd786e808fae6dc9aa456965ec5a67dff5d143b027c70538158e182a99a59b784a092c44f61ace20368ab5e31c31875ae1513593efe153d029c9870928d07b
languageName: node
linkType: hard
"@react-email/render@npm:>=2.0.8":
version: 2.0.8
resolution: "@react-email/render@npm:2.0.8"
"@react-email/render@npm:>=2.1.0":
version: 2.1.0
resolution: "@react-email/render@npm:2.1.0"
dependencies:
entities: "npm:^4.5.0"
html-to-text: "npm:^9.0.5"
html5parser: "npm:^3.0.0"
prettier: "npm:^3.5.3"
peerDependencies:
react: ^18.0 || ^19.0 || ^19.0.0-rc
react-dom: ^18.0 || ^19.0 || ^19.0.0-rc
checksum: 10c0/183793beaab413523e1ffbf3e07b352e33c0b7b0912e6bee0da2aceb495f327694f98b55957255419cbcaf771c538f2b084580d4d3a78c92ccf7a5cad527f73c
checksum: 10c0/d117fb48711fb491f8f958806278c23f5d816e5baf999faef9d93922581d5c9128c7b320418317c486f9a68760deb32a1aa97efde946f4027a7aadda18aab7fc
languageName: node
linkType: hard
"@react-email/row@npm:0.0.12":
version: 0.0.12
resolution: "@react-email/row@npm:0.0.12"
peerDependencies:
react: ^18.0 || ^19.0 || ^19.0.0-rc
checksum: 10c0/440c54071543700ce7a0db4749c93c56fa0cfae1057464231b25d9d7598f81a2f395816b922376acd5445c29163ae2858e4fc840d8cf30f3d4520104f7f5e3ed
languageName: node
linkType: hard
"@react-email/section@npm:0.0.16":
version: 0.0.16
resolution: "@react-email/section@npm:0.0.16"
peerDependencies:
react: ^18.0 || ^19.0 || ^19.0.0-rc
checksum: 10c0/48bfacdd78d403b50c4c1098d100a96f3afe6159d910374a0306d6186daa1bf3803b4a2b3163fb38d9137835041b9f70145c7bb2f652ba347a82a0d396fe2ef2
languageName: node
linkType: hard
"@react-email/tailwind@npm:1.2.2":
version: 1.2.2
resolution: "@react-email/tailwind@npm:1.2.2"
peerDependencies:
react: ^18.0 || ^19.0 || ^19.0.0-rc
checksum: 10c0/fa7d0a4d518a77cf0ec26c57aeac8a24509344225a19d6f3c1320a4357d88b2d48e592f312286ec59ef17333d875e3f3437cd09102d793896229e3f5502d079e
languageName: node
linkType: hard
"@react-email/text@npm:0.1.5":
version: 0.1.5
resolution: "@react-email/text@npm:0.1.5"
peerDependencies:
react: ^18.0 || ^19.0 || ^19.0.0-rc
checksum: 10c0/ad825332903917d1185ce7c5e4d19846496565996f82b71a873648dffc6342d382606a96a5a00a15a16abef5322540d56b481ad3ca21c8b34de9b598715ec149
languageName: node
linkType: hard
"@react-email/ui@npm:6.5.0":
version: 6.5.0
resolution: "@react-email/ui@npm:6.5.0"
"@react-email/ui@npm:^6.9.0":
version: 6.9.0
resolution: "@react-email/ui@npm:6.9.0"
dependencies:
esbuild: "npm:0.28.0"
esbuild: "npm:0.28.1"
next: "npm:16.2.6"
checksum: 10c0/af9b7449f3b66a461e296ccc93a0b779898a262973f698729a67243fec4eef49d6e395d393c01399217e4c0f87186ebf90bb173b2b0802e2bfac04d02d9692d9
checksum: 10c0/e031a5e96e19adbb51cb2c6c2425d4738ee034fcccc3741b4683e829abb94caa62d4f18b8d571f2d7b6e98eba64c46be6e2d5f17aa895f545e1e7f4a4e7870f2
languageName: node
linkType: hard
@@ -30761,13 +30518,6 @@ __metadata:
languageName: node
linkType: hard
"fast-deep-equal@npm:^2.0.1":
version: 2.0.1
resolution: "fast-deep-equal@npm:2.0.1"
checksum: 10c0/1602e0d6ed63493c865cc6b03f9070d6d3926e8cd086a123060b58f80a295f3f08b1ecfb479ae7c45b7fd45535202aea7cf5b49bc31bffb81c20b1502300be84
languageName: node
linkType: hard
"fast-deep-equal@npm:^3.1.1, fast-deep-equal@npm:^3.1.3":
version: 3.1.3
resolution: "fast-deep-equal@npm:3.1.3"
@@ -33328,6 +33078,13 @@ __metadata:
languageName: node
linkType: hard
"html5parser@npm:^3.0.0":
version: 3.0.0
resolution: "html5parser@npm:3.0.0"
checksum: 10c0/1f64db31ce882504ec73c36bc87e5225ad02e472f4953215cbda7a602ad5f5f85ea12d1b490bcf5713ae9ecbdc1b2b0dc8643075fd4cc3816624746762b5c0b3
languageName: node
linkType: hard
"htmlparser2@npm:^8.0.2":
version: 8.0.2
resolution: "htmlparser2@npm:8.0.2"
@@ -36075,6 +35832,15 @@ __metadata:
languageName: node
linkType: hard
"jiti@npm:2.6.1":
version: 2.6.1
resolution: "jiti@npm:2.6.1"
bin:
jiti: lib/jiti-cli.mjs
checksum: 10c0/79b2e96a8e623f66c1b703b98ec1b8be4500e1d217e09b09e343471bbb9c105381b83edbb979d01cef18318cc45ce6e153571b6c83122170eefa531c64b6789b
languageName: node
linkType: hard
"jiti@npm:^1.17.1":
version: 1.21.6
resolution: "jiti@npm:1.21.6"
@@ -37856,15 +37622,6 @@ __metadata:
languageName: node
linkType: hard
"marked@npm:7.0.4":
version: 7.0.4
resolution: "marked@npm:7.0.4"
bin:
marked: bin/marked.js
checksum: 10c0/7f5993bfb2d260806ffada81051c45952857117cba0fd82790779dc696c7ebd35a96be47409b4bdabd75e7ede286f5f5142a75a47200e3fa54eaf8b0cd6f74f6
languageName: node
linkType: hard
"matchmediaquery@npm:^0.3.0":
version: 0.3.1
resolution: "matchmediaquery@npm:0.3.1"
@@ -37881,17 +37638,6 @@ __metadata:
languageName: node
linkType: hard
"md-to-react-email@npm:^5.0.5":
version: 5.0.5
resolution: "md-to-react-email@npm:5.0.5"
dependencies:
marked: "npm:7.0.4"
peerDependencies:
react: ^18.0 || ^19.0
checksum: 10c0/0d6eedb905562d88025fbf45c79117d9d668c86427eac9a3ecc2f5082a7fac9cf55c271f8892083e994d794322e8f0ede928ee2380465c9f4f878cbd2a392999
languageName: node
linkType: hard
"md5.js@npm:^1.3.4":
version: 1.3.5
resolution: "md5.js@npm:1.3.5"
@@ -43739,13 +43485,13 @@ __metadata:
languageName: node
linkType: hard
"react-email@npm:6.5.0":
version: 6.5.0
resolution: "react-email@npm:6.5.0"
"react-email@npm:^6.9.0":
version: 6.9.0
resolution: "react-email@npm:6.9.0"
dependencies:
"@babel/parser": "npm:7.27.0"
"@babel/traverse": "npm:7.27.0"
"@react-email/render": "npm:>=2.0.8"
"@babel/parser": "npm:7.29.2"
"@babel/traverse": "npm:7.29.0"
"@react-email/render": "npm:>=2.1.0"
chokidar: "npm:^4.0.3"
commander: "npm:^13.0.0"
conf: "npm:^15.0.2"
@@ -43753,7 +43499,7 @@ __metadata:
debounce: "npm:^2.0.0"
esbuild: "npm:^0.28.0"
glob: "npm:^13.0.6"
jiti: "npm:2.4.2"
jiti: "npm:2.6.1"
log-symbols: "npm:^7.0.0"
marked: "npm:^15.0.12"
mime-types: "npm:^3.0.0"
@@ -43770,7 +43516,7 @@ __metadata:
react-dom: ^18.0 || ^19.0 || ^19.0.0-rc
bin:
email: ./dist/cli/index.mjs
checksum: 10c0/5d49c7771db71f57358ca33f260db709b55ff6844de06a468a44aa2c36c8b5ab9b023289a9607a6a56b87f6b01bd9992cfa1ce2da9b82f76ab99ea3f79bf759c
checksum: 10c0/7c2c5fbd2b1fe1d5a42e02aa9f9e9cbe1b08b6d2b9d2c4e74aeeccc15f192dcc3e2c4b7a9fb9995cd3e6bddaa864730864fdf6d671d53df3fb6e093821794536
languageName: node
linkType: hard
@@ -43988,15 +43734,6 @@ __metadata:
languageName: node
linkType: hard
"react-promise-suspense@npm:^0.3.4":
version: 0.3.4
resolution: "react-promise-suspense@npm:0.3.4"
dependencies:
fast-deep-equal: "npm:^2.0.1"
checksum: 10c0/ab7a22f5400f9e9933995537bf6430a4c79e33a121aedb51864968e7604e5c40421fd539ead62554f32300b7d49755c79636de06caa36fe52973b626b4ddfebf
languageName: node
linkType: hard
"react-qr-code@npm:^2.0.18":
version: 2.0.18
resolution: "react-qr-code@npm:2.0.18"
@@ -49166,14 +48903,13 @@ __metadata:
"@lingui/react": "npm:^5.9.5"
"@lingui/swc-plugin": "npm:^5.11.0"
"@lingui/vite-plugin": "npm:^5.9.5"
"@react-email/components": "npm:^0.5.3"
"@react-email/ui": "npm:6.5.0"
"@react-email/ui": "npm:^6.9.0"
"@tiptap/core": "npm:^3.4.2"
"@types/react": "npm:^19.2.0"
"@types/react-dom": "npm:^19.2.0"
"@typescript/native-preview": "npm:^7.0.0-dev.20260116.1"
"@vitejs/plugin-react-swc": "npm:^4.3.1"
react-email: "npm:6.5.0"
react-email: "npm:^6.9.0"
tsc-alias: "npm:^1.8.16"
twenty-shared: "workspace:*"
vite-plugin-dts: "npm:^4.5.4"
@@ -49264,7 +49000,6 @@ __metadata:
"@nivo/pie": "npm:^0.99.0"
"@nivo/radial-bar": "npm:^0.99.0"
"@playwright/test": "npm:^1.60.0"
"@react-email/components": "npm:^0.5.3"
"@react-pdf/renderer": "npm:^4.1.6"
"@scalar/api-reference-react": "npm:^0.9.42"
"@sentry/react": "npm:^10.51.0"
@@ -49520,7 +49255,6 @@ __metadata:
"@ptc-org/nestjs-query-core": "npm:^9.4.0"
"@ptc-org/nestjs-query-graphql": "patch:@ptc-org/nestjs-query-graphql@npm%3A9.4.0#~/.yarn/patches/@ptc-org-nestjs-query-graphql-npm-9.4.0-8e6f7894e1.patch"
"@ptc-org/nestjs-query-typeorm": "patch:@ptc-org/nestjs-query-typeorm@npm%3A9.4.0#~/.yarn/patches/@ptc-org-nestjs-query-typeorm-npm-9.4.0-ca3414967e.patch"
"@react-email/render": "npm:^1.2.3"
"@react-pdf/renderer": "npm:^4.1.6"
"@sentry/nestjs": "npm:^10.59.0"
"@sentry/node": "npm:^10.59.0"