chore(server): temporary diagnostic logging for empty verification email body (#21628)

## What

Adds **temporary** diagnostic logging to
`EmailVerificationService.sendVerificationEmail` so we can capture the
real error behind the empty verification email body in deployed
environments.

## Why

Verification emails are delivered with an **empty body** (subject is
fine). The body is `<!DOCTYPE html
…><!--$!--><template></template><!--/$-->` — an **errored React Suspense
boundary**.

`@react-email/render`'s `render()` wraps the email in `<Suspense>` and
streams via `renderToReadableStream` **without an `onError` handler**,
so any throw during SSR is swallowed into the errored boundary and the
body ships empty. In production React also strips the error text from
the markup, so the cause is invisible.

This could **not** be reproduced locally on `main` (renders fine in dev,
in the production-focused `yarn workspaces focus --production` install
layout, and on the React 18 + react-email 6 dep set), so we need the
error from a deployed environment.

## What it logs

When the rendered html is empty or contains `<!--$!-->`, it logs (prefix
`EMAIL_VERIFICATION_RENDER_DEBUG`):
- locale, trigger, html length, and the first 400 chars of the html;
- the **real error + stack**, obtained by re-rendering synchronously
with `renderToStaticMarkup` (which re-throws instead of swallowing).

No behavior change on the happy path — the block only runs when
rendering already failed.

## How to use

Deploy, trigger a verification email (sign up / resend), then:

```
grep -i "EMAIL_VERIFICATION_RENDER_DEBUG" <twenty-server logs>
```

## Revert

Remove this block once the root cause is identified.
This commit is contained in:
Charles Bochet
2026-06-15 17:51:08 +02:00
committed by GitHub
parent 206120677b
commit 2de60d7ea1
@@ -1,8 +1,9 @@
import { Injectable } from '@nestjs/common';
import { Injectable, Logger } 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';
@@ -31,6 +32,8 @@ 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>,
@@ -95,6 +98,35 @@ export class EmailVerificationService {
const emailTemplate = SendEmailVerificationLinkEmail(emailData);
const html = await render(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, {
plainText: true,
});