Skip to content

Commit 8fe3d1d

Browse files
gabrielmfernbukinoshitadependabot[bot]luxonauta
committed
feat(react-email): Spam Assassin checker for email templates (#1913)
Signed-off-by: dependabot[bot] <[email protected]> Co-authored-by: Bu Kinoshita <[email protected]> Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: Lucas de França <[email protected]>
1 parent 9525279 commit 8fe3d1d

File tree

13 files changed

+283
-253
lines changed

13 files changed

+283
-253
lines changed

biome.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@
2222
"noAutofocus": "off"
2323
},
2424
"nursery": {
25-
"useSortedClasses": "warn"
25+
"useSortedClasses": "off"
2626
},
2727
"suspicious": {
2828
"noArrayIndexKey": "off",

packages/react-email/package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@
3939
"socket.io": "4.8.1"
4040
},
4141
"devDependencies": {
42+
"@lottiefiles/dotlottie-react": "0.12.3",
4243
"@radix-ui/colors": "1.0.1",
4344
"@radix-ui/react-collapsible": "1.1.0",
4445
"@radix-ui/react-dropdown-menu": "2.1.4",
@@ -62,7 +63,6 @@
6263
"autoprefixer": "10.4.20",
6364
"clsx": "2.1.0",
6465
"framer-motion": "12.0.0-alpha.2",
65-
"@lottiefiles/dotlottie-react": "0.12.3",
6666
"node-html-parser": "6.1.13",
6767
"postcss": "8.4.40",
6868
"prettier-plugin-tailwindcss": "0.6.6",
@@ -75,6 +75,7 @@
7575
"socket.io-client": "4.8.0",
7676
"sonner": "1.7.1",
7777
"source-map-js": "1.0.2",
78+
"spamc": "0.0.5",
7879
"stacktrace-parser": "0.1.10",
7980
"tailwind-merge": "2.2.0",
8081
"tailwindcss": "3.4.0",

packages/react-email/src/app/preview/[...slug]/preview.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -111,6 +111,7 @@ const Preview = ({
111111
activeView={activeView}
112112
currentEmailOpenSlug={slug}
113113
markup={renderedEmailMetadata?.markup}
114+
plainText={renderedEmailMetadata?.plainText}
114115
pathSeparator={pathSeparator}
115116
setActiveView={handleViewChange}
116117
setViewHeight={(height) => {
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
import { forwardRef } from 'react';
2+
import type { IconElement, IconProps } from './icon-base';
3+
import { IconBase } from './icon-base';
4+
5+
export const IconBug = forwardRef<IconElement, IconProps>((props, ref) => (
6+
<IconBase {...props} ref={ref}>
7+
<g
8+
fill="none"
9+
stroke="currentColor"
10+
strokeLinecap="round"
11+
strokeLinejoin="round"
12+
strokeWidth="2"
13+
>
14+
<path d="m8 2l1.88 1.88m4.24 0L16 2M9 7.13v-1a3.003 3.003 0 1 1 6 0v1" />
15+
<path d="M12 20c-3.3 0-6-2.7-6-6v-3a4 4 0 0 1 4-4h4a4 4 0 0 1 4 4v3c0 3.3-2.7 6-6 6m0 0v-9" />
16+
<path d="M6.53 9C4.6 8.8 3 7.1 3 5m3 8H2m1 8c0-2.1 1.7-3.9 3.8-4M20.97 5c0 2.1-1.6 3.8-3.5 4M22 13h-4m-.8 4c2.1.1 3.8 1.9 3.8 4" />
17+
</g>
18+
</IconBase>
19+
));

packages/react-email/src/components/shell.tsx

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ type RootProps = React.ComponentPropsWithoutRef<'div'>;
1010

1111
interface ShellProps extends RootProps {
1212
markup?: string;
13+
plainText?: string;
1314
currentEmailOpenSlug?: string;
1415
pathSeparator?: string;
1516

@@ -27,6 +28,7 @@ export const Shell = ({
2728
children,
2829
pathSeparator,
2930
markup,
31+
plainText,
3032
activeView,
3133
setActiveView,
3234
viewHeight,
@@ -76,6 +78,7 @@ export const Shell = ({
7678
})}
7779
currentEmailOpenSlug={currentEmailOpenSlug}
7880
markup={markup}
81+
plainText={plainText}
7982
style={{
8083
transition: triggerTransition ? 'transform 0.2s ease-in-out' : '',
8184
}}

packages/react-email/src/components/sidebar/file-tree-directory-children.tsx

Lines changed: 15 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -76,7 +76,8 @@ export const FileTreeDirectoryChildren = (props: {
7676
<motion.span
7777
animate={{ x: 0, opacity: 1 }}
7878
className={cn(
79-
'relative flex h-8 max-w-full items-center rounded-md pl-3 align-middle text-slate-11 text-sm transition-colors duration-100 ease-[cubic-bezier(.6,.12,.34,.96)]',
79+
'relative flex h-8 max-w-full items-center gap-2 rounded-md align-middle text-slate-11 text-sm transition-colors duration-100 ease-[cubic-bezier(.6,.12,.34,.96)]',
80+
props.isRoot ? undefined : 'pl-3',
8081
{
8182
'text-cyan-11': isCurrentPage,
8283
'hover:text-slate-12':
@@ -96,23 +97,25 @@ export const FileTreeDirectoryChildren = (props: {
9697
exit={{ opacity: 0 }}
9798
initial={{ opacity: 0 }}
9899
>
99-
<motion.div
100-
className="absolute top-1 left-[.625rem] h-6 w-px rounded-sm bg-cyan-11"
101-
layoutId="active-file"
102-
transition={{
103-
type: 'spring',
104-
bounce: 0.2,
105-
duration: 0.6,
106-
}}
107-
/>
100+
{props.isRoot ? null : (
101+
<motion.div
102+
className="absolute top-1 left-[.625rem] h-6 w-px rounded-sm bg-cyan-11"
103+
layoutId="active-file"
104+
transition={{
105+
type: 'spring',
106+
bounce: 0.2,
107+
duration: 0.6,
108+
}}
109+
/>
110+
)}
108111
</motion.span>
109112
) : null}
110113
<IconFile
111-
className="absolute left-4 h-5 w-5"
114+
className="h-5 w-5"
112115
height="20"
113116
width="20"
114117
/>
115-
<span className="truncate pl-8">{emailFilename}</span>
118+
<span className="truncate">{emailFilename}</span>
116119
</motion.span>
117120
</Link>
118121
);

packages/react-email/src/components/sidebar/image-checker.tsx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -150,7 +150,8 @@ export const ImageChecker = ({ emailSlug, emailMarkup }: ImageCheckerProps) => {
150150
</>
151151
) : (
152152
<span className="text-xs leading-relaxed">
153-
Check if all links are valid and redirect to the correct pages.
153+
Check if all images exist, have proper size, are accessible, and are
154+
secure.
154155
</span>
155156
)}
156157
<Button loading={loading} onClick={handleRun}>

packages/react-email/src/components/sidebar/sidebar.tsx

Lines changed: 36 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,4 @@
11
'use client';
2-
32
import { DotLottieReact } from '@lottiefiles/dotlottie-react';
43
import * as Tabs from '@radix-ui/react-tabs';
54
import { clsx } from 'clsx';
@@ -15,18 +14,25 @@ import { useIconAnimation } from '../../hooks/use-icon-animation';
1514
import { cn } from '../../utils';
1615
import { Button } from '../button';
1716
import { Heading } from '../heading';
17+
import { IconBug } from '../icons/icon-bug';
1818
import { IconImage } from '../icons/icon-image';
1919
import { Tooltip } from '../tooltip';
2020
import { FileTree } from './file-tree';
2121
import { ImageChecker } from './image-checker';
2222
import { LinkChecker } from './link-checker';
23+
import { SpamAssassin } from './spam-assassin';
2324

24-
type SidebarPanelValue = 'file-tree' | 'link-checker' | 'image-checker';
25+
type SidebarPanelValue =
26+
| 'file-tree'
27+
| 'link-checker'
28+
| 'image-checker'
29+
| 'spam-assassin';
2530

2631
interface SidebarProps {
2732
className?: string;
2833
currentEmailOpenSlug?: string;
2934
markup?: string;
35+
plainText?: string;
3036
style?: React.CSSProperties;
3137
}
3238

@@ -179,6 +185,7 @@ export const Sidebar = ({
179185
className,
180186
currentEmailOpenSlug,
181187
markup: emailMarkup,
188+
plainText: emailPlainText,
182189
style,
183190
}: SidebarProps) => {
184191
const pathname = usePathname();
@@ -256,6 +263,14 @@ export const Sidebar = ({
256263
>
257264
<IconImage className="h-6 w-6" />
258265
</TabTrigger>
266+
<TabTrigger
267+
activeTabValue={activePanelValue}
268+
className="relative"
269+
tabValue="spam-assassin"
270+
tooltipText="Spam Assassin"
271+
>
272+
<IconBug className="h-6 w-6" />
273+
</TabTrigger>
259274
<div className="mt-auto flex flex-col">
260275
<NavigationButton
261276
className="flex items-center justify-center"
@@ -325,6 +340,25 @@ export const Sidebar = ({
325340
)}
326341
</Panel>
327342
)}
343+
{activePanelValue === 'spam-assassin' && (
344+
<Panel
345+
title="Image Checker"
346+
active={activePanelValue === 'spam-assassin'}
347+
>
348+
{currentEmailOpenSlug && emailMarkup && emailPlainText ? (
349+
<SpamAssassin
350+
emailMarkup={emailMarkup}
351+
emailPlainText={emailPlainText}
352+
emailSlug={currentEmailOpenSlug}
353+
/>
354+
) : (
355+
<EmptyState
356+
title="Spam Assassin"
357+
onSelectTemplate={() => setActivePanelValue('file-tree')}
358+
/>
359+
)}
360+
</Panel>
361+
)}
328362
{activePanelValue === 'file-tree' && (
329363
<Panel
330364
title="File Explorer"
Lines changed: 158 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,158 @@
1+
import * as React from 'react';
2+
import { toast } from 'sonner';
3+
import { cn } from '../../utils';
4+
import { Button } from '../button';
5+
6+
interface SpamAssassinProps {
7+
emailSlug: string;
8+
emailMarkup: string;
9+
emailPlainText: string;
10+
}
11+
12+
interface SpamCheckingResult {
13+
checks: {
14+
name: string;
15+
description: string;
16+
points: number;
17+
}[];
18+
isSpam: boolean;
19+
points: number;
20+
}
21+
22+
export const SpamAssassin = ({
23+
emailSlug,
24+
emailMarkup,
25+
emailPlainText,
26+
}: SpamAssassinProps) => {
27+
const cacheKey = `spam-checking-results-${emailSlug.replaceAll('/', '-')}`;
28+
29+
const [result, setResult] = React.useState<SpamCheckingResult | undefined>();
30+
31+
React.useEffect(() => {
32+
const cachedValue =
33+
'localStorage' in global ? global.localStorage.getItem(cacheKey) : null;
34+
if (cachedValue) {
35+
try {
36+
setResult(JSON.parse(cachedValue));
37+
} catch (exception) {
38+
setResult(undefined);
39+
}
40+
}
41+
}, [cacheKey]);
42+
43+
const [loading, setLoading] = React.useState(false);
44+
45+
const handleRun = async () => {
46+
setLoading(true);
47+
48+
try {
49+
const response = await fetch('https://react.email/api/check-spam', {
50+
method: 'POST',
51+
headers: { 'Content-Type': 'application/json' },
52+
body: JSON.stringify({
53+
html: emailMarkup,
54+
plainText: emailPlainText,
55+
}),
56+
});
57+
58+
if (response.ok) {
59+
const responseBody = (await response.json()) as
60+
| { error: string }
61+
| SpamCheckingResult;
62+
if ('error' in responseBody) {
63+
toast.error(responseBody.error);
64+
} else {
65+
setResult(responseBody);
66+
localStorage.setItem(cacheKey, JSON.stringify(responseBody));
67+
}
68+
} else {
69+
console.error(await response.text());
70+
toast.error('Something went wrong');
71+
}
72+
} catch (exception) {
73+
console.error(exception);
74+
toast.error(JSON.stringify(exception));
75+
} finally {
76+
setLoading(false);
77+
}
78+
};
79+
80+
return (
81+
<div className="mt-4 flex w-full flex-col gap-2 text-pretty">
82+
{result ? (
83+
<div
84+
className="group flex flex-col gap-2"
85+
aria-label={result.isSpam ? 'spam' : 'ham'}
86+
>
87+
<table className="w-full border-collapse text-left text-slate-10 text-sm">
88+
<thead className="mb-4 h-8 border border-t-0 border-slate-6 bg-slate-3 text-xs">
89+
<tr>
90+
<th scope="col" className="px-3 py-1">
91+
Rule
92+
</th>
93+
<th scope="col" className="px-3 py-1" align="right">
94+
Score
95+
</th>
96+
</tr>
97+
</thead>
98+
<tbody>
99+
{result.checks.length > 0 ? (
100+
result.checks.map((check) => (
101+
<tr
102+
key={check.name}
103+
className="border-collapse border-slate-6 border-b"
104+
>
105+
<td className="px-3 py-2">
106+
<div className="font-medium text-slate-12">
107+
{check.name}
108+
</div>
109+
<div className="mt-1 text-slate-9 text-xs">
110+
{check.description}
111+
</div>
112+
</td>
113+
<td
114+
align="right"
115+
className={cn(
116+
'px-3 py-2 font-medium',
117+
check.points > 0 ? 'text-yellow-200' : null,
118+
check.points > 1 ? 'text-yellow-300' : null,
119+
check.points > 2 ? 'text-orange-400' : null,
120+
check.points > 3 ? 'text-red-400' : null,
121+
)}
122+
>
123+
{check.points.toFixed(1)}
124+
</td>
125+
</tr>
126+
))
127+
) : (
128+
<tr>
129+
<td colSpan={2} className="py-10 font-medium text-center">
130+
Nothing from Spam Assassin
131+
</td>
132+
</tr>
133+
)}
134+
</tbody>
135+
<tfoot className="mb-4 h-8 border border-slate-6 bg-slate-3">
136+
<tr className="border-collapse border-slate-6 border-b">
137+
<td className="px-3 py-2">Considered </td>
138+
<td
139+
className="text-green-300 py-2 px-3 group-aria-[label=spam]:text-red-300 bg-transparent"
140+
align="right"
141+
>
142+
{result.isSpam ? 'spam' : 'safe'}
143+
</td>
144+
</tr>
145+
</tfoot>
146+
</table>
147+
</div>
148+
) : (
149+
<span className="text-xs leading-relaxed">
150+
Check how well your email goes on a batch of spam testing.
151+
</span>
152+
)}
153+
<Button loading={loading} onClick={handleRun}>
154+
{result ? 'Re-run' : 'Run'}
155+
</Button>
156+
</div>
157+
);
158+
};

packages/react-email/tsconfig.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,5 +35,5 @@
3535
"outDir": "dist"
3636
},
3737
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
38-
"exclude": [".next", "dist", "node_modules", "**/*.spec.ts", "**/*.spec.tsx"]
38+
"exclude": [".next", "dist", "node_modules"]
3939
}

0 commit comments

Comments
 (0)