Skip to content

Commit 38f8e87

Browse files
authored
fix(growth): suppress abusive forms and remove recipient BCC (#1094)
1 parent 71789ff commit 38f8e87

38 files changed

Lines changed: 1861 additions & 42 deletions

apps/lifecycle/src/campaign/send.spec.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -504,6 +504,7 @@ describe('dispatchLifecycleAppOwnedJob', () => {
504504
'campaign_disabled',
505505
'delivery_disabled',
506506
'outside_send_window',
507+
'reply_binding_pending',
507508
] as const)(
508509
'keeps an install-runtime hello deferred while %s',
509510
async (reason) => {

apps/lifecycle/src/campaign/send.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -404,7 +404,8 @@ async function dispatchRecipient(
404404
if (
405405
result.reason === 'campaign_disabled' ||
406406
result.reason === 'delivery_disabled' ||
407-
result.reason === 'outside_send_window'
407+
result.reason === 'outside_send_window' ||
408+
result.reason === 'reply_binding_pending'
408409
) {
409410
const now = dependencies.now();
410411
await dependencies.deferJob(executor, {

apps/lifecycle/src/dispatcher.spec.ts

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import {
22
createUnsubscribeActionUrl,
33
dispatchGrowthLeasedJob,
4+
reconcilePendingResendMessageIds,
45
type GrowthJob,
56
type SqlExecutor,
67
} from '@threadplane-internal/growth';
@@ -63,6 +64,7 @@ function dependencies(
6364
createDatabase: vi.fn(() => executor),
6465
dispatchLeasedJob: vi.fn().mockResolvedValue('completed'),
6566
isRecoveryPaused: vi.fn().mockResolvedValue(false),
67+
reconcileMessageIds: vi.fn().mockResolvedValue({ attempted: 0, bound: 0 }),
6668
leaseDueJobs: vi.fn().mockResolvedValue([]),
6769
loadEmailKeyring: vi.fn(() => EMAIL_KEYRING),
6870
processInstallRuntimeActivations: vi.fn().mockResolvedValue({
@@ -772,3 +774,50 @@ describe('dispatchLifecycleJobs', () => {
772774
expect(dispatchLeasedJob).toHaveBeenCalledTimes(2);
773775
});
774776
});
777+
778+
it('reconciles accepted send identities before leasing contact follow-ups', async () => {
779+
const deps = dependencies();
780+
await dispatchLifecycleJobs(
781+
{
782+
batchSize: 5,
783+
campaignEnabled: true,
784+
signal: new AbortController().signal,
785+
},
786+
deps
787+
);
788+
expect(deps.reconcileMessageIds).toHaveBeenCalledWith(expect.anything(), {
789+
now: NOW,
790+
signal: expect.any(AbortSignal),
791+
});
792+
expect(
793+
deps.reconcileMessageIds &&
794+
vi.mocked(deps.reconcileMessageIds).mock.invocationCallOrder[0]
795+
).toBeLessThan(vi.mocked(deps.leaseDueJobs).mock.invocationCallOrder[0] ?? 0);
796+
});
797+
798+
it('continues enrichment-only dispatch when delivery configuration is absent', async () => {
799+
vi.stubEnv('RESEND_API_KEY', undefined);
800+
vi.stubEnv('DELIVERY_ENVIRONMENT', undefined);
801+
vi.stubEnv('GROWTH_DATABASE_ENVIRONMENT', undefined);
802+
try {
803+
const deps = dependencies({
804+
reconcileMessageIds: reconcilePendingResendMessageIds,
805+
leaseDueJobs: vi
806+
.fn()
807+
.mockResolvedValue([leasedJob('enrich-1', 'enrich')]),
808+
});
809+
await expect(
810+
dispatchLifecycleJobs(
811+
{
812+
batchSize: 5,
813+
campaignEnabled: true,
814+
signal: new AbortController().signal,
815+
},
816+
deps
817+
)
818+
).resolves.toMatchObject({ dispatched: 1 });
819+
expect(deps.dispatchLeasedJob).toHaveBeenCalledOnce();
820+
} finally {
821+
vi.unstubAllEnvs();
822+
}
823+
});

apps/lifecycle/src/dispatcher.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import {
66
leaseDueJobs,
77
materializeCampaignEnrollment,
88
processInstallRuntimeActivations,
9+
reconcilePendingResendMessageIds,
910
renewJobLease,
1011
type GrowthAppJobHandlers,
1112
type GrowthDispatchDependencies,
@@ -60,6 +61,7 @@ export interface LifecycleDispatcherDependencies {
6061
leaseDueJobs: typeof leaseDueJobs;
6162
materializeCampaignEnrollment: typeof materializeCampaignEnrollment;
6263
processInstallRuntimeActivations: typeof processInstallRuntimeActivations;
64+
reconcileMessageIds?: typeof reconcilePendingResendMessageIds;
6365
loadEmailKeyring: typeof loadEmailHmacKeyring;
6466
now: () => Date;
6567
renewJobLease: typeof renewJobLease;
@@ -76,6 +78,7 @@ const defaultDependencies: LifecycleDispatcherDependencies = {
7678
leaseDueJobs,
7779
materializeCampaignEnrollment,
7880
processInstallRuntimeActivations,
81+
reconcileMessageIds: reconcilePendingResendMessageIds,
7982
loadEmailKeyring: loadEmailHmacKeyring,
8083
now: () => new Date(),
8184
renewJobLease,
@@ -157,6 +160,11 @@ export async function dispatchLifecycleJobs(
157160
input.signal.throwIfAborted();
158161
const executor = dependencies.createDatabase();
159162
try {
163+
await dependencies.reconcileMessageIds?.(executor, {
164+
now: dependencies.now(),
165+
signal: input.signal,
166+
});
167+
input.signal.throwIfAborted();
160168
if (input.campaignEnrollmentEnabled) {
161169
if (
162170
!(input.campaignEnrollmentStartAt instanceof Date) ||

apps/website/src/app/api/leads/route.ts

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,8 @@ import {
88
import { matchesSubmittedFormPolicy } from '../../../lib/growth/form-policy';
99
import {
1010
defaultGrowthFormRouteDependencies,
11+
formAdmissionError,
12+
trustedFormClientIp,
1113
jsonResponse,
1214
readBoundedJsonObject,
1315
stalePolicyResponse,
@@ -57,6 +59,7 @@ export function createLeadRoute(
5759
return jsonResponse({ error: 'Invalid form' }, 400);
5860
}
5961

62+
let honeypot;
6063
let submissionId;
6164
let acquisitionSessionId;
6265
let email;
@@ -67,6 +70,7 @@ export function createLeadRoute(
6770
let timeline;
6871
let pilotInterest;
6972
try {
73+
honeypot = strictText(body, 'website_url', 200);
7074
submissionId = strictText(body, 'submission_id', 36);
7175
acquisitionSessionId = strictText(body, 'acquisition_session_id', 36);
7276
email = strictText(body, 'email', 254);
@@ -115,8 +119,13 @@ export function createLeadRoute(
115119
}
116120

117121
let accepted = false;
122+
const trustedClientIp = trustedFormClientIp(request);
123+
let deliverySuppressed = false;
124+
let admissionError: Response | undefined;
118125
try {
119-
await dependencies.accept(database, {
126+
const result = await dependencies.accept(database, {
127+
...(honeypot ? { honeypot } : {}),
128+
...(trustedClientIp ? { trustedClientIp } : {}),
120129
submissionId,
121130
email: normalizedEmail,
122131
displayName: name || undefined,
@@ -132,7 +141,9 @@ export function createLeadRoute(
132141
keyring,
133142
});
134143
accepted = true;
135-
} catch {
144+
deliverySuppressed = result.deliverySuppressed === true;
145+
} catch (error) {
146+
admissionError = formAdmissionError(error);
136147
// The response below reports the failure without echoing provider detail.
137148
}
138149

@@ -141,7 +152,9 @@ export function createLeadRoute(
141152
} catch {
142153
return unableToAccept();
143154
}
155+
if (admissionError) return admissionError;
144156
if (!accepted) return unableToAccept();
157+
if (deliverySuppressed) return jsonResponse({ ok: true });
145158

146159
// The durable jobs remain available to the scheduled dispatcher.
147160
await dependencies.nudge({ submissionId }).catch(() => undefined);

apps/website/src/app/api/newsletter/route.ts

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,8 @@ import { normalizeRecipientEmail } from '@threadplane-internal/growth';
55
import { matchesSubmittedFormPolicy } from '../../../lib/growth/form-policy';
66
import {
77
defaultGrowthFormRouteDependencies,
8+
formAdmissionError,
9+
trustedFormClientIp,
810
jsonResponse,
911
readBoundedJsonObject,
1012
stalePolicyResponse,
@@ -30,6 +32,7 @@ export function createNewsletterRoute(
3032
return jsonResponse({ error: 'Unable to accept request' }, 503);
3133
}
3234

35+
let honeypot;
3336
let submissionId;
3437
let acquisitionSessionId;
3538
let email;
@@ -38,6 +41,7 @@ export function createNewsletterRoute(
3841
if (!matchesSubmittedFormPolicy(policy, policyVersion || undefined)) {
3942
return stalePolicyResponse(policy);
4043
}
44+
honeypot = strictText(body, 'website_url', 200);
4145
submissionId = strictText(body, 'submission_id', 36);
4246
acquisitionSessionId = strictText(body, 'acquisition_session_id', 36);
4347
email = strictText(body, 'email', 254);
@@ -65,8 +69,13 @@ export function createNewsletterRoute(
6569
}
6670

6771
let accepted = false;
72+
const trustedClientIp = trustedFormClientIp(request);
73+
let deliverySuppressed = false;
74+
let admissionError: Response | undefined;
6875
try {
69-
await dependencies.accept(database, {
76+
const result = await dependencies.accept(database, {
77+
...(honeypot ? { honeypot } : {}),
78+
...(trustedClientIp ? { trustedClientIp } : {}),
7079
submissionId,
7180
email: normalizedEmail,
7281
form: { kind: 'newsletter' },
@@ -80,7 +89,9 @@ export function createNewsletterRoute(
8089
keyring,
8190
});
8291
accepted = true;
83-
} catch {
92+
deliverySuppressed = result.deliverySuppressed === true;
93+
} catch (error) {
94+
admissionError = formAdmissionError(error);
8495
// The response below reports the failure without echoing provider detail.
8596
}
8697

@@ -89,7 +100,9 @@ export function createNewsletterRoute(
89100
} catch {
90101
return unableToAccept();
91102
}
103+
if (admissionError) return admissionError;
92104
if (!accepted) return unableToAccept();
105+
if (deliverySuppressed) return jsonResponse({ ok: true });
93106

94107
// The durable jobs remain available to the scheduled dispatcher.
95108
await dependencies.nudge({ submissionId }).catch(() => undefined);

apps/website/src/app/api/webhooks/resend/route.spec.ts

Lines changed: 14 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -69,7 +69,10 @@ describe('/api/webhooks/resend', () => {
6969
const body = rawPayload.replace('email.delivered', 'email.sent');
7070
test.verify.mockImplementationOnce(() => {
7171
test.order.push('verify');
72-
return { type: 'email.sent', data: { email_id: 'resend-email-1' } };
72+
return {
73+
type: 'email.sent',
74+
data: { email_id: 'resend-email-1', message_id: '<actual@resend.dev>' },
75+
};
7376
});
7477

7578
const response = await test.POST(request(body) as never);
@@ -90,7 +93,13 @@ describe('/api/webhooks/resend', () => {
9093
test.database,
9194
{
9295
providerEventId: 'msg_123',
93-
payload: { type: 'email.sent', data: { email_id: 'resend-email-1' } },
96+
payload: {
97+
type: 'email.sent',
98+
data: {
99+
email_id: 'resend-email-1',
100+
message_id: '<actual@resend.dev>',
101+
},
102+
},
94103
}
95104
);
96105
expect(test.database.close).toHaveBeenCalledTimes(1);
@@ -215,7 +224,9 @@ describe('/api/webhooks/resend', () => {
215224
const second = await test.POST(request() as never);
216225
const third = await test.POST(request() as never);
217226

218-
expect([first.status, second.status, third.status]).toEqual([503, 200, 200]);
227+
expect([first.status, second.status, third.status]).toEqual([
228+
503, 200, 200,
229+
]);
219230
expect(test.processVerifiedResendWebhook).toHaveBeenCalledTimes(3);
220231
expect(test.createDatabase).toHaveBeenCalledTimes(3);
221232
expect(test.database.close).toHaveBeenCalledTimes(3);

apps/website/src/app/api/whitepaper-signup/route.ts

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,8 @@ import { normalizeRecipientEmail } from '@threadplane-internal/growth';
55
import { matchesSubmittedFormPolicy } from '../../../lib/growth/form-policy';
66
import {
77
defaultGrowthFormRouteDependencies,
8+
formAdmissionError,
9+
trustedFormClientIp,
810
jsonResponse,
911
readBoundedJsonObject,
1012
stalePolicyResponse,
@@ -39,6 +41,7 @@ export function createWhitepaperSignupRoute(
3941
return jsonResponse({ error: 'Unable to accept request' }, 503);
4042
}
4143

44+
let honeypot;
4245
let submissionId;
4346
let acquisitionSessionId;
4447
let name;
@@ -49,6 +52,7 @@ export function createWhitepaperSignupRoute(
4952
if (!matchesSubmittedFormPolicy(policy, policyVersion || undefined)) {
5053
return stalePolicyResponse(policy);
5154
}
55+
honeypot = strictText(body, 'website_url', 200);
5256
submissionId = strictText(body, 'submission_id', 36);
5357
acquisitionSessionId = strictText(body, 'acquisition_session_id', 36);
5458
name = strictText(body, 'name', 200);
@@ -81,8 +85,13 @@ export function createWhitepaperSignupRoute(
8185
}
8286

8387
let accepted = false;
88+
const trustedClientIp = trustedFormClientIp(request);
89+
let deliverySuppressed = false;
90+
let admissionError: Response | undefined;
8491
try {
85-
await dependencies.accept(database, {
92+
const result = await dependencies.accept(database, {
93+
...(honeypot ? { honeypot } : {}),
94+
...(trustedClientIp ? { trustedClientIp } : {}),
8695
submissionId,
8796
email: normalizedEmail,
8897
displayName: name || undefined,
@@ -97,7 +106,9 @@ export function createWhitepaperSignupRoute(
97106
keyring,
98107
});
99108
accepted = true;
100-
} catch {
109+
deliverySuppressed = result.deliverySuppressed === true;
110+
} catch (error) {
111+
admissionError = formAdmissionError(error);
101112
// The response below reports the failure without echoing provider detail.
102113
}
103114

@@ -106,7 +117,9 @@ export function createWhitepaperSignupRoute(
106117
} catch {
107118
return unableToAccept();
108119
}
120+
if (admissionError) return admissionError;
109121
if (!accepted) return unableToAccept();
122+
if (deliverySuppressed) return jsonResponse({ ok: true });
110123

111124
// The durable jobs remain available to the scheduled dispatcher.
112125
await dependencies.nudge({ submissionId }).catch(() => undefined);

apps/website/src/components/contact/ContactForm.tsx

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
'use client';
2+
import { Honeypot, readHoneypot } from '../form/Honeypot';
23

34
import React, { useState } from 'react';
45
import { Button } from '../ui/Button';
@@ -88,6 +89,7 @@ export function ContactForm({ formPolicy, intent = 'contact', entryPoint }: Cont
8889
return;
8990
}
9091
void form.submit({
92+
website_url: readHoneypot(e.currentTarget),
9193
form_kind: enterprise ? 'pricing' : 'contact',
9294
email: email.trim(),
9395
...(name.trim() ? { name: name.trim() } : {}),
@@ -113,6 +115,7 @@ export function ContactForm({ formPolicy, intent = 'contact', entryPoint }: Cont
113115

114116
return (
115117
<form onSubmit={handleSubmit} data-ui="form" noValidate>
118+
<Honeypot />
116119
<Field id="contact-email" label="Work email" error={emailMessage}>
117120
<TextInput
118121
type="email"
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
import { fireEvent, render } from '@testing-library/react';
2+
import { expect, it } from 'vitest';
3+
import { Honeypot, readHoneypot } from './Honeypot';
4+
5+
it('keeps the trap out of normal navigation but includes bot input in form facts', () => {
6+
const { container } = render(
7+
<form>
8+
<Honeypot />
9+
</form>
10+
);
11+
const input = container.querySelector('input');
12+
const form = container.querySelector('form');
13+
if (!input || !form) throw new Error('Expected form trap');
14+
expect(input.tabIndex).toBe(-1);
15+
expect(input.getAttribute('autocomplete')).toBe('off');
16+
expect(input.closest('[aria-hidden="true"]')).not.toBeNull();
17+
expect(readHoneypot(form)).toBe('');
18+
fireEvent.change(input, { target: { value: 'https://spam.invalid' } });
19+
expect(readHoneypot(form)).toBe(
20+
'https://spam.invalid'
21+
);
22+
});

0 commit comments

Comments
 (0)