Skip to content

Commit 6a2256c

Browse files
committed
feat(react-email): Image validation checking (#1884)
1 parent 6d4ac23 commit 6a2256c

14 files changed

+872
-291
lines changed

.changeset/angry-bugs-sing.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"react-email": minor
3+
---
4+
5+
Add image validation checking

package.json

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,6 @@
2020
"@types/node": "22.10.2",
2121
"@types/react": "19.0.1",
2222
"@types/react-dom": "19.0.1",
23-
"eslint": "8.50.0",
2423
"happy-dom": "15.10.2",
2524
"prettier": "3.4.2",
2625
"prettier-plugin-tailwindcss": "0.6.6",

packages/react-email/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,7 @@
6969
"node-html-parser": "6.1.13",
7070
"postcss": "8.4.40",
7171
"prettier-plugin-tailwindcss": "0.6.6",
72+
"pretty-bytes": "6.1.1",
7273
"prism-react-renderer": "2.1.0",
7374
"react": "^19",
7475
"react-dom": "^19",
Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
import { render } from '@react-email/render';
2+
import * as React from 'react';
3+
import { type ImageCheckingResult, checkImages } from './check-images';
4+
5+
test('checkImages()', async () => {
6+
expect(
7+
await checkImages(
8+
await render(
9+
<div>
10+
{/* biome-ignore lint/a11y/useAltText: This is intentional to test the checking for accessibility */}
11+
<img src="https://resend.com/static/brand/resend-icon-white.png" />,
12+
<img src="/static/codepen-challengers.png" alt="codepen challenges" />
13+
,
14+
</div>,
15+
),
16+
'https://demo.react.email',
17+
),
18+
).toEqual([
19+
{
20+
source: 'https://resend.com/static/brand/resend-icon-white.png',
21+
checks: [
22+
{
23+
passed: false,
24+
type: 'accessibility',
25+
metadata: {
26+
alt: undefined,
27+
},
28+
},
29+
{
30+
passed: true,
31+
type: 'syntax',
32+
},
33+
{
34+
passed: true,
35+
type: 'security',
36+
},
37+
{
38+
passed: true,
39+
type: 'fetch_attempt',
40+
metadata: {
41+
fetchStatusCode: 200,
42+
},
43+
},
44+
{
45+
passed: true,
46+
type: 'image_size',
47+
metadata: {
48+
byteCount: 23_138,
49+
},
50+
},
51+
],
52+
status: 'warning',
53+
},
54+
{
55+
checks: [
56+
{
57+
metadata: {
58+
alt: 'codepen challenges',
59+
},
60+
passed: true,
61+
type: 'accessibility',
62+
},
63+
{
64+
passed: true,
65+
type: 'syntax',
66+
},
67+
{
68+
passed: true,
69+
type: 'security',
70+
},
71+
{
72+
metadata: {
73+
fetchStatusCode: 200,
74+
},
75+
passed: true,
76+
type: 'fetch_attempt',
77+
},
78+
{
79+
metadata: {
80+
byteCount: 111_922,
81+
},
82+
passed: true,
83+
type: 'image_size',
84+
},
85+
],
86+
source: '/static/codepen-challengers.png',
87+
status: 'success',
88+
},
89+
] satisfies ImageCheckingResult[]);
90+
});
Lines changed: 142 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,142 @@
1+
'use server';
2+
3+
import type { IncomingMessage } from 'node:http';
4+
import { headers } from 'next/headers';
5+
import { parse } from 'node-html-parser';
6+
import { quickFetch } from './quick-fetch';
7+
8+
type Check = { passed: boolean } & (
9+
| {
10+
type: 'accessibility';
11+
metadata: {
12+
alt: string | undefined;
13+
};
14+
}
15+
| {
16+
type: 'fetch_attempt';
17+
metadata: {
18+
fetchStatusCode: number | undefined;
19+
};
20+
}
21+
| {
22+
type: 'image_size';
23+
metadata: {
24+
byteCount: number | undefined;
25+
};
26+
}
27+
| {
28+
type: 'syntax';
29+
}
30+
| {
31+
type: 'security';
32+
}
33+
);
34+
35+
export interface ImageCheckingResult {
36+
status: 'success' | 'warning' | 'error';
37+
source: string;
38+
checks: Check[];
39+
}
40+
41+
const getResponseSizeInBytes = async (res: IncomingMessage) => {
42+
let totalBytes = 0;
43+
for await (const chunk of res) {
44+
totalBytes += chunk.byteLength;
45+
}
46+
return totalBytes;
47+
};
48+
49+
export const checkImages = async (code: string, base: string) => {
50+
const ast = parse(code);
51+
52+
const imageCheckingResults: ImageCheckingResult[] = [];
53+
54+
const images = ast.querySelectorAll('img');
55+
for await (const image of images) {
56+
const rawSource = image.attributes.src;
57+
if (!rawSource) continue;
58+
if (imageCheckingResults.some((result) => result.source === rawSource))
59+
continue;
60+
61+
const source = rawSource?.startsWith('/')
62+
? `${base}${rawSource}`
63+
: rawSource;
64+
65+
const result: ImageCheckingResult = {
66+
source: rawSource,
67+
status: 'success',
68+
checks: [],
69+
};
70+
71+
const alt = image.attributes.alt;
72+
result.checks.push({
73+
passed: alt !== undefined,
74+
type: 'accessibility',
75+
metadata: {
76+
alt,
77+
},
78+
});
79+
if (alt === undefined) {
80+
result.status = 'warning';
81+
}
82+
83+
try {
84+
const url = new URL(source);
85+
result.checks.push({
86+
passed: true,
87+
type: 'syntax',
88+
});
89+
90+
if (source.startsWith('https://')) {
91+
result.checks.push({
92+
passed: true,
93+
type: 'security',
94+
});
95+
} else {
96+
result.checks.push({
97+
passed: false,
98+
type: 'security',
99+
});
100+
result.status = 'warning';
101+
}
102+
103+
const res = await quickFetch(url);
104+
const hasSucceeded = res.statusCode?.toString().startsWith('2') ?? false;
105+
106+
result.checks.push({
107+
type: 'fetch_attempt',
108+
passed: hasSucceeded,
109+
metadata: {
110+
fetchStatusCode: res.statusCode,
111+
},
112+
});
113+
if (!hasSucceeded) {
114+
result.status = res.statusCode?.toString().startsWith('3')
115+
? 'warning'
116+
: 'error';
117+
}
118+
119+
const responseSizeBytes = await getResponseSizeInBytes(res);
120+
result.checks.push({
121+
type: 'image_size',
122+
passed: responseSizeBytes < 1_048_576, // 1024 x 1024 bytes
123+
metadata: {
124+
byteCount: responseSizeBytes,
125+
},
126+
});
127+
if (responseSizeBytes > 1_048_576) {
128+
result.status = 'warning';
129+
}
130+
} catch (exception) {
131+
result.checks.push({
132+
passed: false,
133+
type: 'syntax',
134+
});
135+
result.status = 'error';
136+
}
137+
138+
imageCheckingResults.push(result);
139+
}
140+
141+
return imageCheckingResults;
142+
};
Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
import { render } from '@react-email/render';
2+
import * as React from 'react';
3+
import { type LinkCheckingResult, checkLinks } from './check-links';
4+
5+
test('checkLinks()', async () => {
6+
expect(
7+
await checkLinks(
8+
await render(
9+
<div>
10+
<a href="/">Root</a>
11+
<a href="https://resend.com">Resend</a>
12+
<a href="https://notion.so">Notion</a>
13+
<a href="http://example.com">Example unsafe</a>
14+
</div>,
15+
),
16+
),
17+
).toEqual([
18+
{
19+
status: 'error',
20+
checks: [
21+
{
22+
type: 'syntax',
23+
passed: false,
24+
},
25+
],
26+
link: '/',
27+
},
28+
{
29+
status: 'success',
30+
checks: [
31+
{
32+
type: 'syntax',
33+
passed: true,
34+
},
35+
{
36+
type: 'security',
37+
passed: true,
38+
},
39+
{
40+
type: 'fetch_attempt',
41+
passed: true,
42+
metadata: {
43+
fetchStatusCode: 200,
44+
},
45+
},
46+
],
47+
link: 'https://resend.com',
48+
},
49+
{
50+
status: 'warning',
51+
checks: [
52+
{
53+
type: 'syntax',
54+
passed: true,
55+
},
56+
{
57+
type: 'security',
58+
passed: true,
59+
},
60+
{
61+
type: 'fetch_attempt',
62+
metadata: {
63+
fetchStatusCode: 301,
64+
},
65+
passed: false,
66+
},
67+
],
68+
link: 'https://notion.so',
69+
},
70+
{
71+
status: 'warning',
72+
checks: [
73+
{
74+
type: 'syntax',
75+
passed: true,
76+
},
77+
{
78+
type: 'security',
79+
passed: false,
80+
},
81+
{
82+
type: 'fetch_attempt',
83+
metadata: {
84+
fetchStatusCode: 200,
85+
},
86+
passed: true,
87+
},
88+
],
89+
link: 'http://example.com',
90+
},
91+
] satisfies LinkCheckingResult[]);
92+
});

0 commit comments

Comments
 (0)