Skip to content

Commit f6a1bc2

Browse files
d-bytebaseclaude
andauthored
feat: improve contact form spam detection and webhook reliability (#908)
* fix: adjust spam detection threshold to reduce false positives Increased spam score threshold from 3 to 5 to avoid flagging legitimate submissions from users with free email addresses and short company names (e.g., IBM, HP). Now requires stronger combination of spam indicators before flagging. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <[email protected]> * feat: add webhook retry logic and improve reliability - Add fetchWithRetry() with 3 attempts and exponential backoff (500ms, 1s, 2s) - Treat Feishu webhooks as fire-and-forget to prevent blocking submissions - Require Slack webhook to succeed before showing success to user - Prevent duplicate submissions by only allowing retry when all webhooks fail 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <[email protected]> * fix: improve error handling and email validation - Add fallback for malformed email addresses without @ symbol - Treat invalid email format as spam indicator (2 points) - Add explicit try-catch for Slack webhook errors - Use void keyword for fire-and-forget Feishu webhooks 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <[email protected]> --------- Co-authored-by: Claude <[email protected]>
1 parent cdb6356 commit f6a1bc2

File tree

2 files changed

+121
-29
lines changed

2 files changed

+121
-29
lines changed

src/app/api/slack/route.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,9 @@ const slackWebhookList = [process.env.SLACK_WEBHOOK_URL as string];
55
export async function POST(request: Request) {
66
try {
77
const body = await request.json();
8-
const { formId, firstname, lastname, email, company, message } = body;
8+
const { formId, firstname, lastname, email, company, message, isSpam } = body;
9+
10+
const spamPrefix = isSpam ? '[POTENTIAL SPAM] ' : '';
911

1012
const responses = await Promise.all(
1113
slackWebhookList.map((webhookUrl) =>
@@ -15,7 +17,7 @@ export async function POST(request: Request) {
1517
'Content-Type': 'application/json',
1618
},
1719
body: JSON.stringify({
18-
text: `${formId} by ${firstname} ${lastname} (${email}) from ${company}\n\n${message}`,
20+
text: `${spamPrefix}${formId} by ${firstname} ${lastname} (${email}) from ${company}\n\n${message}`,
1921
}),
2022
}),
2123
),

src/components/shared/contact-form/contact-form.tsx

Lines changed: 117 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,81 @@ const getButtonTitle = (formId: string) => {
5555
}
5656
};
5757

58+
// Detects spam-like patterns in text (random mixed-case strings)
59+
const isLikelySpam = (text: string): boolean => {
60+
if (!text) return false;
61+
62+
// Count uppercase letters that appear after lowercase letters (not at word boundaries)
63+
const mixedCaseTransitions = (text.match(/[a-z][A-Z]/g) || []).length;
64+
65+
// Spam pattern: 3+ mixed case transitions in a single field
66+
// Examples: kLfvrCxSDewcS, xKyCpzFjUCOipFFB, TsPoonGZcPwAv
67+
// Legitimate: Christopher, Gambino, McDonald (0-1 transitions)
68+
return mixedCaseTransitions >= 3;
69+
};
70+
71+
// Retry a fetch request up to 3 times with exponential backoff
72+
const fetchWithRetry = async (url: string, options: RequestInit): Promise<Response> => {
73+
const maxRetries = 3;
74+
let lastError: Error | null = null;
75+
76+
for (let attempt = 0; attempt < maxRetries; attempt++) {
77+
try {
78+
const response = await fetch(url, options);
79+
if (response.ok) {
80+
return response;
81+
}
82+
// If not ok, treat as retriable error
83+
lastError = new Error(`HTTP ${response.status}: ${response.statusText}`);
84+
} catch (error) {
85+
lastError = error as Error;
86+
}
87+
88+
// Wait before retrying (exponential backoff: 500ms, 1000ms, 2000ms)
89+
if (attempt < maxRetries - 1) {
90+
await new Promise((resolve) => setTimeout(resolve, 500 * Math.pow(2, attempt)));
91+
}
92+
}
93+
94+
// All retries failed, throw the last error
95+
throw lastError || new Error('Request failed');
96+
};
97+
98+
const detectSpamSubmission = (values: ValueType): boolean => {
99+
const { firstname, lastname, company, email, message } = values;
100+
101+
let spamScore = 0;
102+
103+
// High confidence spam indicators (3 points each)
104+
if (isLikelySpam(firstname)) spamScore += 3;
105+
if (isLikelySpam(lastname)) spamScore += 3;
106+
if (isLikelySpam(company)) spamScore += 3;
107+
if (company.trim().length <= 2) spamScore += 3;
108+
109+
// Medium confidence indicators (2 points each)
110+
const freeEmailDomains = ['gmail.com', 'yahoo.com', 'hotmail.com', 'outlook.com', 'aol.com', 'icloud.com', 't-online.de'];
111+
const emailDomain = email.toLowerCase().split('@')[1] || '';
112+
if (!emailDomain) {
113+
// Invalid email format (missing domain) - likely spam
114+
spamScore += 2;
115+
} else if (freeEmailDomains.includes(emailDomain)) {
116+
spamScore += 2;
117+
}
118+
119+
// Low confidence indicators (1 point each)
120+
const messageWords = (message || '').trim().split(/\s+/).filter(word => word.length > 0);
121+
if (messageWords.length < 5) spamScore += 1;
122+
123+
// Flag as spam if score >= 5
124+
// Examples:
125+
// - Random case name (3) + free email (2) = spam
126+
// - Random case name (3) + short company (3) = spam
127+
// - Free email (2) + short message (1) + short company (3) = spam
128+
// - Free email (2) + short company (3) = not spam (legitimate small companies)
129+
// - Free email (2) + short message (1) = not spam
130+
return spamScore >= 5;
131+
};
132+
58133
const ContactForm = ({
59134
className,
60135
formId,
@@ -82,6 +157,9 @@ const ContactForm = ({
82157
setButtonState(STATES.LOADING);
83158
setFormError('');
84159

160+
const isSpam = detectSpamSubmission(values);
161+
const spamPrefix = isSpam ? '[POTENTIAL SPAM] ' : '';
162+
85163
try {
86164
if (
87165
formId == VIEW_LIVE_DEMO ||
@@ -102,39 +180,50 @@ const ContactForm = ({
102180
});
103181
}
104182

105-
const responses = await Promise.all([
106-
...feishuWebhookList.map((url) =>
107-
fetch(url, {
108-
method: 'POST',
109-
headers: {
110-
'Content-Type': 'application/json',
111-
Accept: 'application/json, text/plain, */*',
112-
},
113-
body: JSON.stringify({
114-
msg_type: 'text',
115-
content: {
116-
text: `${formId} by ${firstname} ${lastname} (${email}) from ${company}\n\n${message}`,
117-
},
118-
}),
119-
}),
120-
),
121-
fetch('/api/slack', {
183+
// Send to Feishu (fire-and-forget) and Slack (must succeed) in parallel with retries
184+
const slackPromise = fetchWithRetry('/api/slack', {
185+
method: 'POST',
186+
headers: {
187+
'Content-Type': 'application/json',
188+
},
189+
body: JSON.stringify({
190+
formId,
191+
firstname,
192+
lastname,
193+
email,
194+
company,
195+
message,
196+
isSpam,
197+
}),
198+
});
199+
200+
// Feishu: fire-and-forget, ignore failures
201+
feishuWebhookList.forEach((url) => {
202+
void fetchWithRetry(url, {
122203
method: 'POST',
123204
headers: {
124205
'Content-Type': 'application/json',
206+
Accept: 'application/json, text/plain, */*',
125207
},
126208
body: JSON.stringify({
127-
formId,
128-
firstname,
129-
lastname,
130-
email,
131-
company,
132-
message,
209+
msg_type: 'text',
210+
content: {
211+
text: `${spamPrefix}${formId} by ${firstname} ${lastname} (${email}) from ${company}\n\n${message}`,
212+
},
133213
}),
134-
}),
135-
]);
214+
}).catch(() => {
215+
// Ignore Feishu failures
216+
});
217+
});
218+
219+
// Wait for Slack to complete (throws on failure after retries)
220+
try {
221+
const slackResult = await slackPromise;
222+
if (!slackResult.ok) {
223+
throw new Error('Slack webhook failed');
224+
}
136225

137-
if (responses.every((response) => response.ok)) {
226+
// Success - redirect user
138227
if (formId == VIEW_LIVE_DEMO) {
139228
window.open(Route.LIVE_DEMO, '_blank');
140229
}
@@ -145,7 +234,8 @@ const ContactForm = ({
145234
setButtonState(STATES.DEFAULT);
146235
reset();
147236
}, BUTTON_SUCCESS_TIMEOUT_MS);
148-
} else {
237+
} catch (slackError) {
238+
// Slack failed after retries - show error
149239
setButtonState(STATES.ERROR);
150240
setTimeout(() => {
151241
setButtonState(STATES.DEFAULT);

0 commit comments

Comments
 (0)