Skip to content

Commit 623826c

Browse files
authored
fix(web): Spam Assassin Parsing issue with multiline descriptions (#1933)
1 parent f27e08f commit 623826c

File tree

5 files changed

+160
-26
lines changed

5 files changed

+160
-26
lines changed

apps/web/src/app/api/check-spam/__snapshots__/check-spam.spec.tsx.snap

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
22

3-
exports[`checkSpam() 1`] = `
3+
exports[`checkSpam() > with most spammy email 1`] = `
44
{
55
"checks": [
66
{
@@ -23,3 +23,11 @@ exports[`checkSpam() 1`] = `
2323
"points": 5.4,
2424
}
2525
`;
26+
27+
exports[`checkSpam() > with real email template 1`] = `
28+
{
29+
"checks": [],
30+
"isSpam": false,
31+
"points": 0,
32+
}
33+
`;
Lines changed: 25 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,30 @@
11
import { render } from '@react-email/components';
2+
import { StripeWelcomeEmail } from '../../../../../demo/emails/welcome/stripe-welcome';
23
import { checkSpam } from './check-spam';
34

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';
5+
describe('checkSpam()', () => {
6+
test('with most spammy email', async () => {
7+
const template = (
8+
<html lang="en">
9+
<body>
10+
This email is spam. We sell rolexss for cheap. Get your viagra. We
11+
also sell weight loss pills today. Money back guaranteed. sEnd me $500
12+
and I'll send you back $700. don't tell anyone. Send this email to ten
13+
friends
14+
</body>
15+
</html>
16+
);
17+
const html = await render(template);
18+
// const plainText = await render(template, { plainText: true });
19+
const plainText = 'Completely different content from the original';
1820

19-
expect(await checkSpam(html, plainText)).toMatchSnapshot();
21+
expect(await checkSpam(html, plainText)).toMatchSnapshot();
22+
});
23+
24+
test('with real email template', async () => {
25+
const html = await render(<StripeWelcomeEmail />);
26+
const plainText = await render(<StripeWelcomeEmail />, { plainText: true });
27+
28+
expect(await checkSpam(html, plainText)).toMatchSnapshot();
29+
});
2030
});

apps/web/src/utils/spam-assassin/__snapshots__/parse-pointing-table-rows.spec.ts.snap

Lines changed: 61 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,66 @@
11
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
22

3-
exports[`parsePointingTableRows() 1`] = `
3+
exports[`parsePointingTableRows() > works with a multiline description 1`] = `
4+
[
5+
{
6+
"description": "Informational: message has no Received headers",
7+
"pts": -0,
8+
"ruleName": "NO_RECEIVED",
9+
},
10+
{
11+
"description": "Missing Message-Id: header",
12+
"pts": 0.1,
13+
"ruleName": "MISSING_MID",
14+
},
15+
{
16+
"description": "Missing Date: header",
17+
"pts": 1.4,
18+
"ruleName": "MISSING_DATE",
19+
},
20+
{
21+
"description": "Missing From: header",
22+
"pts": 1,
23+
"ruleName": "MISSING_FROM",
24+
},
25+
{
26+
"description": "Missing Subject: header",
27+
"pts": 1.8,
28+
"ruleName": "MISSING_SUBJECT",
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": "ADMINISTRATOR NOTICE: The query to URIBL was blocked. See http://wiki.apache.org/spamassassin/DnsBlocklists#dnsbl-block for more information. [URI: stripe.com]",
42+
"pts": 0,
43+
"ruleName": "URIBL_BLOCKED",
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": "High bit body and no message ID header",
57+
"pts": 3.9,
58+
"ruleName": "DOS_BODY_HIGH_NO_MID",
59+
},
60+
]
61+
`;
62+
63+
exports[`parsePointingTableRows() > works with spammy emails 1`] = `
464
[
565
{
666
"description": "Missing Subject: header",

apps/web/src/utils/spam-assassin/parse-pointing-table-rows.spec.ts

Lines changed: 39 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
import { parsePointingTableRows } from './parse-pointing-table-rows';
22

3-
const spamdResponse = `Received: from localhost by gabriels-computer
3+
describe('parsePointingTableRows()', () => {
4+
test('works with spammy emails', () => {
5+
const spamdResponse = `Received: from localhost by gabriels-computer
46
with SpamAssassin (version 4.0.1);
57
Mon, 10 Feb 2025 09:21:23 -0300
68
X-Spam-Checker-Version: SpamAssassin 4.0.1 (2024-03-26) on gabriels-computer
@@ -83,7 +85,41 @@ Content-Type: text/html; charset="UTF-8"
8385
8486
------------=_67A9EF43.EC247F5D--
8587
`;
88+
expect(parsePointingTableRows(spamdResponse)).toMatchSnapshot();
89+
});
8690

87-
test('parsePointingTableRows()', () => {
88-
expect(parsePointingTableRows(spamdResponse)).toMatchSnapshot();
91+
test('works with a multiline description', () => {
92+
const partialSpamResponse = `‌â [...]
93+
94+
Content analysis details: (9.4 points, 5.0 required)
95+
96+
pts rule name description
97+
---- ---------------------- --------------------------------------------------
98+
-0.0 NO_RECEIVED Informational: message has no Received headers
99+
0.1 MISSING_MID Missing Message-Id: header
100+
1.4 MISSING_DATE Missing Date: header
101+
1.0 MISSING_FROM Missing From: header
102+
1.8 MISSING_SUBJECT Missing Subject: header
103+
1.2 MISSING_HEADERS Missing To: header
104+
-0.0 NO_RELAYS Informational: message was not relayed via SMTP
105+
0.0 URIBL_BLOCKED ADMINISTRATOR NOTICE: The query to URIBL was blocked.
106+
See
107+
http://wiki.apache.org/spamassassin/DnsBlocklists#dnsbl-block
108+
for more information.
109+
[URI: stripe.com]
110+
0.0 HTML_MESSAGE BODY: HTML included in message
111+
0.0 NO_HEADERS_MESSAGE Message appears to be missing most RFC-822 headers
112+
3.9 DOS_BODY_HIGH_NO_MID High bit body and no message ID header
113+
114+
The original message was not completely plain text, and may be unsafe to
115+
open with some email clients; in particular, it may contain a virus,
116+
or confirm that your address can receive spam. If you wish to view
117+
it, it may be safer to save it to a file and open it with an editor.
118+
119+
120+
------------=_67BF2F0C.CEF6CDE8
121+
Content-Type: message/rfc822; x-spam-type=original`;
122+
123+
expect(parsePointingTableRows(partialSpamResponse)).toMatchSnapshot();
124+
});
89125
});

apps/web/src/utils/spam-assassin/parse-pointing-table-rows.ts

Lines changed: 26 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
import { rule } from 'postcss';
2+
13
export const parsePointingTableRows = (response: string) => {
24
const tableHeader =
35
/pts\s+rule\s+name\s+description\s+(?<ptsWidth>-+) (?<ruleNameWidth>-+) (?<descriptionWidth>-+) *(?:\r\n|\n|\r)*/;
@@ -13,24 +15,36 @@ export const parsePointingTableRows = (response: string) => {
1315

1416
const ptsWidth = tableStartMatch.groups.ptsWidth!.length;
1517
const ruleNameWidth = tableStartMatch.groups.ruleNameWidth!.length;
16-
const descriptionWidth = tableStartMatch.groups.descriptionWidth!.length;
1718
const columnsRegex = new RegExp(
18-
`^(?<pts>.{${ptsWidth}}) (?<ruleName>.{${ruleNameWidth}}) (?<description>.{${descriptionWidth}}|.+$)`,
19+
`^(?<pts>.{${ptsWidth}}) (?<ruleName>.{${ruleNameWidth}}) (?<description>.+}|.+$)`,
1920
);
2021

21-
const rows: {
22+
interface Row {
2223
pts: number;
2324
ruleName: string;
2425
description: string;
25-
}[] = [];
26+
}
27+
28+
const rows: Row[] = [];
2629

2730
const responseFromTableStart = response.slice(
2831
tableStartMatch.index + tableStartMatch[0].length,
2932
);
33+
let currentRow: Row | undefined = undefined;
3034
for (const line of responseFromTableStart.split(/\r\n|\n|\r/)) {
3135
if (line.trim().length === 0) break;
3236

3337
const match = line.match(columnsRegex);
38+
39+
// This means the description column was done with multi columns.
40+
if (
41+
currentRow &&
42+
line.startsWith(' '.repeat(ptsWidth + ruleNameWidth + 2))
43+
) {
44+
currentRow.description += ` ${line.trimStart()}`;
45+
continue;
46+
}
47+
3448
if (match?.groups === undefined) {
3549
throw new Error('Could not match the columns in the row', {
3650
cause: {
@@ -44,11 +58,14 @@ export const parsePointingTableRows = (response: string) => {
4458
const description = match.groups.description!;
4559

4660
try {
47-
rows.push({
61+
if (currentRow) {
62+
rows.push(currentRow);
63+
}
64+
currentRow = {
4865
pts: Number.parseFloat(pts),
4966
ruleName: ruleName.trim(),
5067
description: description.trim(),
51-
});
68+
};
5269
} catch (exception) {
5370
throw new Error('could not parse points to insert into rows array', {
5471
cause: {
@@ -59,6 +76,9 @@ export const parsePointingTableRows = (response: string) => {
5976
});
6077
}
6178
}
79+
if (currentRow) {
80+
rows.push(currentRow);
81+
}
6282

6383
return rows;
6484
};

0 commit comments

Comments
 (0)