Skip to content

Commit 555e4b0

Browse files
committed
Revert "Remove client-side reCAPTCHA from Brevo form submissions"
This reverts commit 4198d63.
1 parent a543fbc commit 555e4b0

4 files changed

Lines changed: 119 additions & 104 deletions

File tree

README.md

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -20,18 +20,18 @@ The public lead-capture forms are the inbound channel for the highest-value deal
2020

2121
Every form found on the site is listed here. Both forms submit to Brevo (`sibforms.com`) via `src/lib/brevo.ts` — there is no server-side form handling in this repo.
2222

23-
| Form | Page tested | Spec | Real submission? |
24-
| --------------------------------------------------------- | ------------- | --------------------------- | ---------------- |
25-
| Contact modal — "Request a Quote" (the June 2026 failure) | `/commercial` | `e2e/contact-modal.spec.ts` | yes |
26-
| Contact modal — Enterprise "Request Information" | `/pricing` | `e2e/contact-modal.spec.ts` | yes |
27-
| Contact modal — Self-Hosted "Contact Us" | `/` (home) | `e2e/contact-modal.spec.ts` | yes |
28-
| Newsletter signup (footer, sitewide) | `/` (home) | `e2e/newsletter.spec.ts` | yes |
23+
| Form | Page tested | Spec | Real submission? |
24+
| --------------------------------------------------------- | ------------- | --------------------------- | ---------------------- |
25+
| Contact modal — "Request a Quote" (the June 2026 failure) | `/commercial` | `e2e/contact-modal.spec.ts` | yes |
26+
| Contact modal — Enterprise "Request Information" | `/pricing` | `e2e/contact-modal.spec.ts` | yes |
27+
| Contact modal — Self-Hosted "Contact Us" | `/` (home) | `e2e/contact-modal.spec.ts` | yes |
28+
| Newsletter signup (footer, sitewide) | `/` (home) | `e2e/newsletter.spec.ts` | attempted — see caveat |
2929

3030
The contact modal is one component with three separate page-level trigger wirings; a page-specific JS error can break one page while the others keep working, so each path submits for real.
3131

32-
Each weekly run creates **3 real Brevo contact inquiries and 1 newsletter subscription** (double that in the worst case, since CI retries a failed test once).
32+
Each weekly run creates **3 real Brevo contact inquiries and 1 newsletter attempt** (double that in the worst case, since CI retries a failed test once).
3333

34-
**reCAPTCHA history:** the forms originally attached reCAPTCHA v3 tokens, and the newsletter form enforced them in Brevo — which blocked this monitor, since automated browsers always score too low to pass v3. Enforcement was turned off in Brevo in July 2026 and the client-side integration was commented out in `src/lib/brevo.ts` (spam protection is now the honeypot field plus newsletter double opt-in). If the newsletter test ever fails with `BREVO REJECTED SUBMISSION` mentioning a captcha error, someone re-enabled enforcement in Brevo — either turn it back off, or accept that the newsletter form can only be monitored up to Brevo's bot-protection boundary (the pre-July-2026 version of `e2e/newsletter.spec.ts` in git history did exactly that).
34+
**Newsletter caveat:** the newsletter form has reCAPTCHA v3 enforcement enabled in Brevo, and automated browsers always score too low to pass — verified July 2026 that this is score-based bot protection, not a config mismatch (Brevo's own hosted form uses the same site key and the same `submit` action as `src/lib/brevo.ts`). The newsletter spec therefore verifies everything up to that boundary: the form renders, clicking Subscribe fires the POST (catching the "click does nothing" failure mode), Brevo receives and parses the submission, and the UI gives the user feedback. It cannot confirm that a real human's subscription is accepted — worth a quick manual subscribe check if the form is ever suspect. If Brevo ever accepts the automated submission, the spec asserts the full success UI as well.
3535

3636
### Sentinel convention — keeping test data out of the lead list
3737

e2e/helpers.ts

Lines changed: 21 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -66,12 +66,18 @@ export async function gotoHydrated(page: Page, path: string): Promise<void> {
6666
* missed, and matches ANY status so a 4xx/5xx produces a status assertion
6767
* instead of an opaque timeout. Each failure mode gets a distinct message so
6868
* a red CI run is diagnosable from the assertion text alone.
69+
*
70+
* Timeout covers brevo.ts's best-effort reCAPTCHA budget (~10s worst case
71+
* before the fetch even starts).
6972
*/
7073
export async function submitAndExpectBrevoSuccess(
7174
page: Page,
7275
submit: () => Promise<void>,
73-
{ timeoutMs = 25_000 }: { timeoutMs?: number } = {}
74-
): Promise<void> {
76+
{
77+
timeoutMs = 25_000,
78+
allowCaptchaRejection = false
79+
}: { timeoutMs?: number; allowCaptchaRejection?: boolean } = {}
80+
): Promise<'accepted' | 'captcha-rejected'> {
7581
const responsePromise = page.waitForResponse(
7682
(r) => r.url().includes('sibforms.com/serve') && r.request().method() === 'POST',
7783
{ timeout: timeoutMs }
@@ -100,17 +106,27 @@ export async function submitAndExpectBrevoSuccess(
100106
throw new Error(`BREVO RESPONSE NOT JSON (status ${response.status()}): ${slice}`);
101107
}
102108

109+
// Brevo's captcha rejection comes back as HTTP 400 {"errors":{"captcha":...}}.
110+
// For forms with reCAPTCHA enforcement enabled in Brevo (newsletter), automated
111+
// browsers ALWAYS score too low to pass — that rejection proves the whole
112+
// pipeline works up to Brevo's bot-protection boundary and is tolerated only
113+
// when the caller opts in. See README "Form Monitoring" > newsletter caveat.
114+
if (allowCaptchaRejection && json.success !== true && json.errors?.captcha) {
115+
return 'captcha-rejected';
116+
}
117+
103118
expect(
104119
response.ok(),
105120
`BREVO HTTP ERROR ${response.status()} from ${response.url()}. Body: ${slice}`
106121
).toBe(true);
107122

108123
expect(
109124
json.success,
110-
`BREVO REJECTED SUBMISSION: ${slice} — a captcha error here means reCAPTCHA enforcement ` +
111-
`was re-enabled in Brevo, which automated browsers cannot pass; see the README ` +
112-
`"Form Monitoring" section.`
125+
`BREVO REJECTED SUBMISSION: ${slice} — see the README "Form Monitoring" section for ` +
126+
`how to interpret Brevo rejection errors.`
113127
).toBe(true);
128+
129+
return 'accepted';
114130
}
115131

116132
/**

e2e/newsletter.spec.ts

Lines changed: 26 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -11,17 +11,17 @@ import {
1111
* of every page via Footer.svelte. Tested once on the home page since the
1212
* footer is identical sitewide.
1313
*
14-
* Fully end-to-end since July 2026, when reCAPTCHA enforcement was turned off
15-
* in Brevo (automated browsers can't pass reCAPTCHA v3, so it blocked this
16-
* monitor). If Brevo ever rejects with a captcha error again, enforcement was
17-
* re-enabled — see README "Form Monitoring".
18-
*
19-
* NOTE: submits a real (sentinel) subscription to production Brevo.
14+
* CAVEAT: the newsletter form has reCAPTCHA v3 enforcement enabled in Brevo,
15+
* which (verified July 2026) always rejects automated browsers regardless of
16+
* headed/headless mode — Brevo's own hosted form uses the same site key and
17+
* action, so this is score-based bot protection, not a config mismatch. This
18+
* test therefore verifies everything up to that boundary: the form renders,
19+
* clicking Subscribe fires the POST (the "click does nothing" failure mode),
20+
* Brevo receives and parses the submission, and the UI gives the user
21+
* feedback. If Brevo ever accepts (captcha relaxed), the success UI is
22+
* asserted too. See README "Form Monitoring".
2023
*/
21-
test('footer newsletter signup submits to Brevo and shows success state', async ({
22-
page,
23-
context
24-
}) => {
24+
test('footer newsletter signup reaches Brevo and shows UI feedback', async ({ page, context }) => {
2525
await seedCookieConsent(context);
2626
await gotoHydrated(page, '/');
2727

@@ -31,10 +31,22 @@ test('footer newsletter signup submits to Brevo and shows success state', async
3131
await expect(emailInput, 'newsletter form missing from footer').toBeVisible();
3232
await emailInput.fill(sentinelEmail());
3333

34-
await submitAndExpectBrevoSuccess(page, () =>
35-
footer.getByRole('button', { name: 'Subscribe' }).click()
34+
const outcome = await submitAndExpectBrevoSuccess(
35+
page,
36+
() => footer.getByRole('button', { name: 'Subscribe' }).click(),
37+
{ allowCaptchaRejection: true }
3638
);
3739

38-
// Success state replaces the form (typographic apostrophe in prod markup).
39-
await expect(footer.getByText(/You.re subscribed!/)).toBeVisible();
40+
if (outcome === 'accepted') {
41+
// Success state replaces the form (typographic apostrophe in prod markup).
42+
await expect(footer.getByText(/You.re subscribed!/)).toBeVisible();
43+
} else {
44+
// Brevo's bot protection rejected our automated submission (expected).
45+
// The user-visible contract still holds: the UI must respond, not sit
46+
// silent — the component surfaces this as its generic error message.
47+
await expect(
48+
footer.getByText('Something went wrong. Please try again.'),
49+
'no UI feedback after submit — user would see nothing happen'
50+
).toBeVisible();
51+
}
4052
});

src/lib/brevo.ts

Lines changed: 64 additions & 77 deletions
Original file line numberDiff line numberDiff line change
@@ -1,68 +1,58 @@
1-
// reCAPTCHA v3 integration — disabled July 2026, kept for possible re-enable.
2-
// Captcha enforcement was turned off in Brevo (the honeypot field and
3-
// newsletter double opt-in remain the spam protection), so generating tokens
4-
// only added latency and a Google script load. To re-enable: turn captcha back
5-
// on in the Brevo form settings, then uncomment this block and the
6-
// `getRecaptchaToken`/`g-recaptcha-response` lines in both submit functions
7-
// below (PUBLIC_RECAPTCHA_SITE_KEY is still set in .env). Note: with captcha
8-
// enforced, the weekly form monitor's newsletter spec will fail — automated
9-
// browsers can't pass reCAPTCHA v3; see README "Form Monitoring".
10-
//
11-
// import { PUBLIC_RECAPTCHA_SITE_KEY } from '$env/static/public';
12-
//
13-
// declare const grecaptcha: {
14-
// ready: (cb: () => void) => void;
15-
// execute: (siteKey: string, options: { action: string }) => Promise<string>;
16-
// };
17-
//
18-
// let recaptchaLoaded = false;
19-
//
20-
// function loadRecaptcha(): Promise<void> {
21-
// if (recaptchaLoaded) return Promise.resolve();
22-
// return new Promise((resolve, reject) => {
23-
// const script = document.createElement('script');
24-
// script.src = `https://www.google.com/recaptcha/api.js?render=${PUBLIC_RECAPTCHA_SITE_KEY}`;
25-
// script.async = true;
26-
// script.onload = () => {
27-
// recaptchaLoaded = true;
28-
// resolve();
29-
// };
30-
// script.onerror = () => {
31-
// reject(new Error('Failed to load reCAPTCHA'));
32-
// };
33-
// const timeout = setTimeout(() => {
34-
// reject(new Error('reCAPTCHA load timeout'));
35-
// }, 5000);
36-
// script.onload = () => {
37-
// clearTimeout(timeout);
38-
// recaptchaLoaded = true;
39-
// resolve();
40-
// };
41-
// document.head.appendChild(script);
42-
// });
43-
// }
44-
//
45-
// async function getRecaptchaToken(): Promise<string | null> {
46-
// try {
47-
// await loadRecaptcha();
48-
// return await new Promise((resolve) => {
49-
// const timeout = setTimeout(() => resolve(null), 5000);
50-
// grecaptcha.ready(() => {
51-
// grecaptcha.execute(PUBLIC_RECAPTCHA_SITE_KEY, { action: 'submit' })
52-
// .then((token) => {
53-
// clearTimeout(timeout);
54-
// resolve(token);
55-
// })
56-
// .catch(() => {
57-
// clearTimeout(timeout);
58-
// resolve(null);
59-
// });
60-
// });
61-
// });
62-
// } catch {
63-
// return null;
64-
// }
65-
// }
1+
import { PUBLIC_RECAPTCHA_SITE_KEY } from '$env/static/public';
2+
3+
declare const grecaptcha: {
4+
ready: (cb: () => void) => void;
5+
execute: (siteKey: string, options: { action: string }) => Promise<string>;
6+
};
7+
8+
let recaptchaLoaded = false;
9+
10+
function loadRecaptcha(): Promise<void> {
11+
if (recaptchaLoaded) return Promise.resolve();
12+
return new Promise((resolve, reject) => {
13+
const script = document.createElement('script');
14+
script.src = `https://www.google.com/recaptcha/api.js?render=${PUBLIC_RECAPTCHA_SITE_KEY}`;
15+
script.async = true;
16+
script.onload = () => {
17+
recaptchaLoaded = true;
18+
resolve();
19+
};
20+
script.onerror = () => {
21+
reject(new Error('Failed to load reCAPTCHA'));
22+
};
23+
const timeout = setTimeout(() => {
24+
reject(new Error('reCAPTCHA load timeout'));
25+
}, 5000);
26+
script.onload = () => {
27+
clearTimeout(timeout);
28+
recaptchaLoaded = true;
29+
resolve();
30+
};
31+
document.head.appendChild(script);
32+
});
33+
}
34+
35+
async function getRecaptchaToken(): Promise<string | null> {
36+
try {
37+
await loadRecaptcha();
38+
return await new Promise((resolve) => {
39+
const timeout = setTimeout(() => resolve(null), 5000);
40+
grecaptcha.ready(() => {
41+
grecaptcha.execute(PUBLIC_RECAPTCHA_SITE_KEY, { action: 'submit' })
42+
.then((token) => {
43+
clearTimeout(timeout);
44+
resolve(token);
45+
})
46+
.catch(() => {
47+
clearTimeout(timeout);
48+
resolve(null);
49+
});
50+
});
51+
});
52+
} catch {
53+
return null;
54+
}
55+
}
6656

6757
interface ContactInquiryData {
6858
email: string;
@@ -77,13 +67,13 @@ interface ContactInquiryData {
7767
}
7868

7969
export async function submitNewsletter(formUrl: string, email: string): Promise<boolean> {
80-
// const token = await getRecaptchaToken();
70+
const token = await getRecaptchaToken();
8171

8272
const formData = new FormData();
8373
formData.append('EMAIL', email);
84-
// if (token) {
85-
// formData.append('g-recaptcha-response', token);
86-
// }
74+
if (token) {
75+
formData.append('g-recaptcha-response', token);
76+
}
8777
formData.append('email_address_check', '');
8878
formData.append('locale', 'en');
8979

@@ -117,7 +107,7 @@ export async function submitContactInquiry(
117107
formUrl: string,
118108
data: ContactInquiryData
119109
): Promise<ContactSubmitResult> {
120-
// const token = await getRecaptchaToken();
110+
const token = await getRecaptchaToken();
121111

122112
const formData = new FormData();
123113
formData.append('EMAIL', data.email);
@@ -126,15 +116,12 @@ export async function submitContactInquiry(
126116
formData.append('INQUIRY_COMPANY', data.company);
127117
formData.append('INQUIRY_NUM_EMPLOYEES', data.numemployees);
128118
formData.append('INQUIRY_URGENCY', data.urgency || '');
129-
formData.append(
130-
'INQUIRY_NETWORK_COUNT',
131-
data.networkCount != null ? String(data.networkCount) : ''
132-
);
119+
formData.append('INQUIRY_NETWORK_COUNT', data.networkCount != null ? String(data.networkCount) : '');
133120
formData.append('INQUIRY_MESSAGE', data.message || '');
134121
formData.append('INQUIRY_PLAN_TYPE', data.planType);
135-
// if (token) {
136-
// formData.append('g-recaptcha-response', token);
137-
// }
122+
if (token) {
123+
formData.append('g-recaptcha-response', token);
124+
}
138125
formData.append('email_address_check', '');
139126
formData.append('locale', 'en');
140127

0 commit comments

Comments
 (0)