Skip to content

Commit f27e08f

Browse files
authored
feat(web): Check Spam API (#1914)
1 parent d206c46 commit f27e08f

File tree

14 files changed

+479
-21
lines changed

14 files changed

+479
-21
lines changed

.github/workflows/tests.yml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,9 @@ jobs:
7070

7171
- name: Run Tests
7272
run: pnpm test
73+
env:
74+
SPAM_ASSASSIN_HOST: ${{ secrets.SPAM_ASSASSIN_HOST }}
75+
SPAM_ASSASSIN_PORT: ${{ secrets.SPAM_ASSASSIN_PORT }}
7376

7477
build:
7578
runs-on: buildjet-4vcpu-ubuntu-2204

apps/web/package.json

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,8 +6,8 @@
66
"dev": "next dev",
77
"build": "next build",
88
"start": "next start",
9-
"test:watch": "vitest --config ../../vitest.config.ts --bail 1",
10-
"test": "vitest run --config ../../vitest.config.ts --bail 1",
9+
"test:watch": "vitest --bail 1",
10+
"test": "vitest run --bail 1",
1111
"pattern:dev": "email dev -d ./components",
1212
"pattern:build": "email build -d ./components"
1313
},
@@ -30,6 +30,7 @@
3030
"vaul": "1.1.1"
3131
},
3232
"devDependencies": {
33+
"@next/env": "15.1.7",
3334
"@radix-ui/colors": "1.0.1",
3435
"@radix-ui/react-select": "2.1.2",
3536
"@radix-ui/react-slot": "1.1.0",
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
2+
3+
exports[`checkSpam() 1`] = `
4+
{
5+
"checks": [
6+
{
7+
"description": "BODY: Money back guarantee",
8+
"name": "MONEY_BACK",
9+
"points": 2.5,
10+
},
11+
{
12+
"description": "BODY: HTML and text parts are different",
13+
"name": "MPART_ALT_DIFF",
14+
"points": 0.7,
15+
},
16+
{
17+
"description": "Refers to an erectile drug",
18+
"name": "DRUGS_ERECTILE",
19+
"points": 2.2,
20+
},
21+
],
22+
"isSpam": true,
23+
"points": 5.4,
24+
}
25+
`;
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
import { render } from '@react-email/components';
2+
import { checkSpam } from './check-spam';
3+
4+
test('checkSpam()', async () => {
5+
const template = (
6+
<html lang="en">
7+
<body>
8+
This email is spam. We sell rolexss for cheap. Get your viagra. We also
9+
sell weight loss pills today. Money back guaranteed. sEnd me $500 and
10+
I'll send you back $700. don't tell anyone. Send this email to ten
11+
friends
12+
</body>
13+
</html>
14+
);
15+
const html = await render(template);
16+
// const plainText = await render(template, { plainText: true });
17+
const plainText = 'Completely different content from the original';
18+
19+
expect(await checkSpam(html, plainText)).toMatchSnapshot();
20+
});
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
import { parsePointingTableRows } from '@/utils/spam-assassin/parse-pointing-table-rows';
2+
import { sendToSpamd } from '@/utils/spam-assassin/send-to-spamd';
3+
4+
export async function checkSpam(html: string, plainText: string) {
5+
const response = await sendToSpamd(html, plainText);
6+
7+
const tableRows = parsePointingTableRows(response);
8+
9+
const filteredRows = tableRows.filter(
10+
(row) =>
11+
!row.description.toLowerCase().includes('header') &&
12+
!row.ruleName.includes('HEADER') &&
13+
row.pts !== 0,
14+
);
15+
16+
const checks = filteredRows.map((row) => ({
17+
name: row.ruleName,
18+
description: row.description,
19+
points: row.pts,
20+
}));
21+
22+
const points = checks.reduce((acc, check) => acc + check.points, 0);
23+
24+
return {
25+
checks,
26+
isSpam: points >= 5.0,
27+
points,
28+
};
29+
}
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
import { type NextRequest, NextResponse } from 'next/server';
2+
import { ZodError, z } from 'zod';
3+
import { checkSpam } from './check-spam';
4+
5+
export const dynamic = 'force-dynamic';
6+
7+
export function OPTIONS() {
8+
return Promise.resolve(NextResponse.json({}));
9+
}
10+
11+
const bodySchema = z.object({
12+
html: z.string(),
13+
plainText: z.string(),
14+
});
15+
16+
export async function POST(req: NextRequest) {
17+
try {
18+
const { html, plainText } = bodySchema.parse(await req.json());
19+
20+
return NextResponse.json(await checkSpam(html, plainText));
21+
} catch (exception) {
22+
if (exception instanceof Error) {
23+
return NextResponse.json(
24+
{ error: exception.message },
25+
{ status: exception instanceof ZodError ? 400 : 500 },
26+
);
27+
}
28+
29+
return NextResponse.json(
30+
{ error: 'Something went wrong' },
31+
{ status: 500 },
32+
);
33+
}
34+
}
Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
2+
3+
exports[`parsePointingTableRows() 1`] = `
4+
[
5+
{
6+
"description": "Missing Subject: header",
7+
"pts": 1.8,
8+
"ruleName": "MISSING_SUBJECT",
9+
},
10+
{
11+
"description": "Missing Date: header",
12+
"pts": 1.4,
13+
"ruleName": "MISSING_DATE",
14+
},
15+
{
16+
"description": "Missing Message-Id: header",
17+
"pts": 0.1,
18+
"ruleName": "MISSING_MID",
19+
},
20+
{
21+
"description": "Missing From: header",
22+
"pts": 1,
23+
"ruleName": "MISSING_FROM",
24+
},
25+
{
26+
"description": "Informational: message has no Received headers",
27+
"pts": -0,
28+
"ruleName": "NO_RECEIVED",
29+
},
30+
{
31+
"description": "Missing To: header",
32+
"pts": 1.2,
33+
"ruleName": "MISSING_HEADERS",
34+
},
35+
{
36+
"description": "Informational: message was not relayed via SMTP",
37+
"pts": -0,
38+
"ruleName": "NO_RELAYS",
39+
},
40+
{
41+
"description": "BODY: Money back guarantee",
42+
"pts": 2.5,
43+
"ruleName": "MONEY_BACK",
44+
},
45+
{
46+
"description": "BODY: HTML included in message",
47+
"pts": 0,
48+
"ruleName": "HTML_MESSAGE",
49+
},
50+
{
51+
"description": "Message appears to be missing most RFC-822 headers",
52+
"pts": 0,
53+
"ruleName": "NO_HEADERS_MESSAGE",
54+
},
55+
{
56+
"description": "Refers to an erectile drug",
57+
"pts": 2.2,
58+
"ruleName": "DRUGS_ERECTILE",
59+
},
60+
]
61+
`;
Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
import { parsePointingTableRows } from './parse-pointing-table-rows';
2+
3+
const spamdResponse = `Received: from localhost by gabriels-computer
4+
with SpamAssassin (version 4.0.1);
5+
Mon, 10 Feb 2025 09:21:23 -0300
6+
X-Spam-Checker-Version: SpamAssassin 4.0.1 (2024-03-26) on gabriels-computer
7+
X-Spam-Flag: YES
8+
X-Spam-Level: **********
9+
X-Spam-Status: Yes, score=10.2 required=5.0 tests=DRUGS_ERECTILE,HTML_MESSAGE,
10+
MISSING_DATE,MISSING_FROM,MISSING_HEADERS,MISSING_MID,MISSING_SUBJECT,
11+
MONEY_BACK,NO_HEADERS_MESSAGE,NO_RECEIVED,NO_RELAYS autolearn=no
12+
autolearn_force=no version=4.0.1
13+
MIME-Version: 1.0
14+
Content-Type: multipart/mixed; boundary="----------=_67A9EF43.EC247F5D"
15+
16+
This is a multi-part message in MIME format.
17+
18+
------------=_67A9EF43.EC247F5D
19+
Content-Type: text/plain; charset=UTF-8
20+
Content-Disposition: inline
21+
Content-Transfer-Encoding: 8bit
22+
23+
Spam detection software, running on the system "gabriels-computer",
24+
has identified this incoming email as possible spam. The original
25+
message has been attached to this so you can view it or label
26+
similar future email. If you have any questions, see
27+
root@localhost for details.
28+
29+
Content preview: This email is spam. We sell rolexss for cheap. Get your viagra.
30+
We also sell weight loss pills today. Money back guaranteed. sEnd me $500
31+
and I'll send you back $700. don't tell anyone. Send this emai [...]
32+
33+
Content analysis details: (10.2 points, 5.0 required)
34+
35+
pts rule name description
36+
---- ---------------------- --------------------------------------------------
37+
1.8 MISSING_SUBJECT Missing Subject: header
38+
1.4 MISSING_DATE Missing Date: header
39+
0.1 MISSING_MID Missing Message-Id: header
40+
1.0 MISSING_FROM Missing From: header
41+
-0.0 NO_RECEIVED Informational: message has no Received headers
42+
1.2 MISSING_HEADERS Missing To: header
43+
-0.0 NO_RELAYS Informational: message was not relayed via SMTP
44+
2.5 MONEY_BACK BODY: Money back guarantee
45+
0.0 HTML_MESSAGE BODY: HTML included in message
46+
0.0 NO_HEADERS_MESSAGE Message appears to be missing most RFC-822 headers
47+
2.2 DRUGS_ERECTILE Refers to an erectile drug
48+
49+
The original message was not completely plain text, and may be unsafe to
50+
open with some email clients; in particular, it may contain a virus,
51+
or confirm that your address can receive spam. If you wish to view
52+
it, it may be safer to save it to a file and open it with an editor.
53+
54+
55+
------------=_67A9EF43.EC247F5D
56+
Content-Type: message/rfc822; x-spam-type=original
57+
Content-Description: original message before SpamAssassin
58+
Content-Disposition: attachment
59+
Content-Transfer-Encoding: 8bit
60+
61+
MIME-Version: 1.0
62+
Content-Type: multipart/mixed; boundary="Part_af7eace217e10ccb689dd03f0d264877"
63+
64+
--Part_af7eace217e10ccb689dd03f0d264877
65+
Content-Type: text/plain; charset="UTF-8"
66+
67+
This email is spam. We sell rolexss for cheap. Get your viagra. We also
68+
sell weight loss pills today. Money back guaranteed. sEnd me $500 and
69+
I'll send you back $700. don't tell anyone. Send this email to ten
70+
friends
71+
72+
--Part_af7eace217e10ccb689dd03f0d264877
73+
Content-Type: text/html; charset="UTF-8"
74+
75+
<html>
76+
<body>
77+
What
78+
</body>
79+
</html>
80+
81+
--Part_af7eace217e10ccb689dd03f0d264877--
82+
83+
84+
------------=_67A9EF43.EC247F5D--
85+
`;
86+
87+
test('parsePointingTableRows()', () => {
88+
expect(parsePointingTableRows(spamdResponse)).toMatchSnapshot();
89+
});
Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
export const parsePointingTableRows = (response: string) => {
2+
const tableHeader =
3+
/pts\s+rule\s+name\s+description\s+(?<ptsWidth>-+) (?<ruleNameWidth>-+) (?<descriptionWidth>-+) *(?:\r\n|\n|\r)*/;
4+
const tableStartMatch = response.match(tableHeader);
5+
6+
if (
7+
tableStartMatch === null ||
8+
tableStartMatch.index === undefined ||
9+
tableStartMatch.groups === undefined
10+
) {
11+
throw new Error('Could not find spam checking points table');
12+
}
13+
14+
const ptsWidth = tableStartMatch.groups.ptsWidth!.length;
15+
const ruleNameWidth = tableStartMatch.groups.ruleNameWidth!.length;
16+
const descriptionWidth = tableStartMatch.groups.descriptionWidth!.length;
17+
const columnsRegex = new RegExp(
18+
`^(?<pts>.{${ptsWidth}}) (?<ruleName>.{${ruleNameWidth}}) (?<description>.{${descriptionWidth}}|.+$)`,
19+
);
20+
21+
const rows: {
22+
pts: number;
23+
ruleName: string;
24+
description: string;
25+
}[] = [];
26+
27+
const responseFromTableStart = response.slice(
28+
tableStartMatch.index + tableStartMatch[0].length,
29+
);
30+
for (const line of responseFromTableStart.split(/\r\n|\n|\r/)) {
31+
if (line.trim().length === 0) break;
32+
33+
const match = line.match(columnsRegex);
34+
if (match?.groups === undefined) {
35+
throw new Error('Could not match the columns in the row', {
36+
cause: {
37+
line,
38+
match,
39+
},
40+
});
41+
}
42+
const pts = match.groups.pts!;
43+
const ruleName = match.groups.ruleName!;
44+
const description = match.groups.description!;
45+
46+
try {
47+
rows.push({
48+
pts: Number.parseFloat(pts),
49+
ruleName: ruleName.trim(),
50+
description: description.trim(),
51+
});
52+
} catch (exception) {
53+
throw new Error('could not parse points to insert into rows array', {
54+
cause: {
55+
exception,
56+
line,
57+
match,
58+
},
59+
});
60+
}
61+
}
62+
63+
return rows;
64+
};

0 commit comments

Comments
 (0)