Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions apps/lifecycle/src/campaign/send.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -504,6 +504,7 @@ describe('dispatchLifecycleAppOwnedJob', () => {
'campaign_disabled',
'delivery_disabled',
'outside_send_window',
'reply_binding_pending',
] as const)(
'keeps an install-runtime hello deferred while %s',
async (reason) => {
Expand Down
3 changes: 2 additions & 1 deletion apps/lifecycle/src/campaign/send.ts
Original file line number Diff line number Diff line change
Expand Up @@ -404,7 +404,8 @@ async function dispatchRecipient(
if (
result.reason === 'campaign_disabled' ||
result.reason === 'delivery_disabled' ||
result.reason === 'outside_send_window'
result.reason === 'outside_send_window' ||
result.reason === 'reply_binding_pending'
) {
const now = dependencies.now();
await dependencies.deferJob(executor, {
Expand Down
49 changes: 49 additions & 0 deletions apps/lifecycle/src/dispatcher.spec.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import {
createUnsubscribeActionUrl,
dispatchGrowthLeasedJob,
reconcilePendingResendMessageIds,
type GrowthJob,
type SqlExecutor,
} from '@threadplane-internal/growth';
Expand Down Expand Up @@ -63,6 +64,7 @@ function dependencies(
createDatabase: vi.fn(() => executor),
dispatchLeasedJob: vi.fn().mockResolvedValue('completed'),
isRecoveryPaused: vi.fn().mockResolvedValue(false),
reconcileMessageIds: vi.fn().mockResolvedValue({ attempted: 0, bound: 0 }),
leaseDueJobs: vi.fn().mockResolvedValue([]),
loadEmailKeyring: vi.fn(() => EMAIL_KEYRING),
processInstallRuntimeActivations: vi.fn().mockResolvedValue({
Expand Down Expand Up @@ -772,3 +774,50 @@ describe('dispatchLifecycleJobs', () => {
expect(dispatchLeasedJob).toHaveBeenCalledTimes(2);
});
});

it('reconciles accepted send identities before leasing contact follow-ups', async () => {
const deps = dependencies();
await dispatchLifecycleJobs(
{
batchSize: 5,
campaignEnabled: true,
signal: new AbortController().signal,
},
deps
);
expect(deps.reconcileMessageIds).toHaveBeenCalledWith(expect.anything(), {
now: NOW,
signal: expect.any(AbortSignal),
});
expect(
deps.reconcileMessageIds &&
vi.mocked(deps.reconcileMessageIds).mock.invocationCallOrder[0]
).toBeLessThan(vi.mocked(deps.leaseDueJobs).mock.invocationCallOrder[0] ?? 0);
});

it('continues enrichment-only dispatch when delivery configuration is absent', async () => {
vi.stubEnv('RESEND_API_KEY', undefined);
vi.stubEnv('DELIVERY_ENVIRONMENT', undefined);
vi.stubEnv('GROWTH_DATABASE_ENVIRONMENT', undefined);
try {
const deps = dependencies({
reconcileMessageIds: reconcilePendingResendMessageIds,
leaseDueJobs: vi
.fn()
.mockResolvedValue([leasedJob('enrich-1', 'enrich')]),
});
await expect(
dispatchLifecycleJobs(
{
batchSize: 5,
campaignEnabled: true,
signal: new AbortController().signal,
},
deps
)
).resolves.toMatchObject({ dispatched: 1 });
expect(deps.dispatchLeasedJob).toHaveBeenCalledOnce();
} finally {
vi.unstubAllEnvs();
}
});
8 changes: 8 additions & 0 deletions apps/lifecycle/src/dispatcher.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import {
leaseDueJobs,
materializeCampaignEnrollment,
processInstallRuntimeActivations,
reconcilePendingResendMessageIds,
renewJobLease,
type GrowthAppJobHandlers,
type GrowthDispatchDependencies,
Expand Down Expand Up @@ -60,6 +61,7 @@ export interface LifecycleDispatcherDependencies {
leaseDueJobs: typeof leaseDueJobs;
materializeCampaignEnrollment: typeof materializeCampaignEnrollment;
processInstallRuntimeActivations: typeof processInstallRuntimeActivations;
reconcileMessageIds?: typeof reconcilePendingResendMessageIds;
loadEmailKeyring: typeof loadEmailHmacKeyring;
now: () => Date;
renewJobLease: typeof renewJobLease;
Expand All @@ -76,6 +78,7 @@ const defaultDependencies: LifecycleDispatcherDependencies = {
leaseDueJobs,
materializeCampaignEnrollment,
processInstallRuntimeActivations,
reconcileMessageIds: reconcilePendingResendMessageIds,
loadEmailKeyring: loadEmailHmacKeyring,
now: () => new Date(),
renewJobLease,
Expand Down Expand Up @@ -157,6 +160,11 @@ export async function dispatchLifecycleJobs(
input.signal.throwIfAborted();
const executor = dependencies.createDatabase();
try {
await dependencies.reconcileMessageIds?.(executor, {
now: dependencies.now(),
signal: input.signal,
});
input.signal.throwIfAborted();
if (input.campaignEnrollmentEnabled) {
if (
!(input.campaignEnrollmentStartAt instanceof Date) ||
Expand Down
17 changes: 15 additions & 2 deletions apps/website/src/app/api/leads/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ import {
import { matchesSubmittedFormPolicy } from '../../../lib/growth/form-policy';
import {
defaultGrowthFormRouteDependencies,
formAdmissionError,
trustedFormClientIp,
jsonResponse,
readBoundedJsonObject,
stalePolicyResponse,
Expand Down Expand Up @@ -57,6 +59,7 @@ export function createLeadRoute(
return jsonResponse({ error: 'Invalid form' }, 400);
}

let honeypot;
let submissionId;
let acquisitionSessionId;
let email;
Expand All @@ -67,6 +70,7 @@ export function createLeadRoute(
let timeline;
let pilotInterest;
try {
honeypot = strictText(body, 'website_url', 200);
submissionId = strictText(body, 'submission_id', 36);
acquisitionSessionId = strictText(body, 'acquisition_session_id', 36);
email = strictText(body, 'email', 254);
Expand Down Expand Up @@ -115,8 +119,13 @@ export function createLeadRoute(
}

let accepted = false;
const trustedClientIp = trustedFormClientIp(request);
let deliverySuppressed = false;
let admissionError: Response | undefined;
try {
await dependencies.accept(database, {
const result = await dependencies.accept(database, {
...(honeypot ? { honeypot } : {}),
...(trustedClientIp ? { trustedClientIp } : {}),
submissionId,
email: normalizedEmail,
displayName: name || undefined,
Expand All @@ -132,7 +141,9 @@ export function createLeadRoute(
keyring,
});
accepted = true;
} catch {
deliverySuppressed = result.deliverySuppressed === true;
} catch (error) {
admissionError = formAdmissionError(error);
// The response below reports the failure without echoing provider detail.
}

Expand All @@ -141,7 +152,9 @@ export function createLeadRoute(
} catch {
return unableToAccept();
}
if (admissionError) return admissionError;
if (!accepted) return unableToAccept();
if (deliverySuppressed) return jsonResponse({ ok: true });

// The durable jobs remain available to the scheduled dispatcher.
await dependencies.nudge({ submissionId }).catch(() => undefined);
Expand Down
17 changes: 15 additions & 2 deletions apps/website/src/app/api/newsletter/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ import { normalizeRecipientEmail } from '@threadplane-internal/growth';
import { matchesSubmittedFormPolicy } from '../../../lib/growth/form-policy';
import {
defaultGrowthFormRouteDependencies,
formAdmissionError,
trustedFormClientIp,
jsonResponse,
readBoundedJsonObject,
stalePolicyResponse,
Expand All @@ -30,6 +32,7 @@ export function createNewsletterRoute(
return jsonResponse({ error: 'Unable to accept request' }, 503);
}

let honeypot;
let submissionId;
let acquisitionSessionId;
let email;
Expand All @@ -38,6 +41,7 @@ export function createNewsletterRoute(
if (!matchesSubmittedFormPolicy(policy, policyVersion || undefined)) {
return stalePolicyResponse(policy);
}
honeypot = strictText(body, 'website_url', 200);
submissionId = strictText(body, 'submission_id', 36);
acquisitionSessionId = strictText(body, 'acquisition_session_id', 36);
email = strictText(body, 'email', 254);
Expand Down Expand Up @@ -65,8 +69,13 @@ export function createNewsletterRoute(
}

let accepted = false;
const trustedClientIp = trustedFormClientIp(request);
let deliverySuppressed = false;
let admissionError: Response | undefined;
try {
await dependencies.accept(database, {
const result = await dependencies.accept(database, {
...(honeypot ? { honeypot } : {}),
...(trustedClientIp ? { trustedClientIp } : {}),
submissionId,
email: normalizedEmail,
form: { kind: 'newsletter' },
Expand All @@ -80,7 +89,9 @@ export function createNewsletterRoute(
keyring,
});
accepted = true;
} catch {
deliverySuppressed = result.deliverySuppressed === true;
} catch (error) {
admissionError = formAdmissionError(error);
// The response below reports the failure without echoing provider detail.
}

Expand All @@ -89,7 +100,9 @@ export function createNewsletterRoute(
} catch {
return unableToAccept();
}
if (admissionError) return admissionError;
if (!accepted) return unableToAccept();
if (deliverySuppressed) return jsonResponse({ ok: true });

// The durable jobs remain available to the scheduled dispatcher.
await dependencies.nudge({ submissionId }).catch(() => undefined);
Expand Down
17 changes: 14 additions & 3 deletions apps/website/src/app/api/webhooks/resend/route.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -69,7 +69,10 @@ describe('/api/webhooks/resend', () => {
const body = rawPayload.replace('email.delivered', 'email.sent');
test.verify.mockImplementationOnce(() => {
test.order.push('verify');
return { type: 'email.sent', data: { email_id: 'resend-email-1' } };
return {
type: 'email.sent',
data: { email_id: 'resend-email-1', message_id: '<actual@resend.dev>' },
};
});

const response = await test.POST(request(body) as never);
Expand All @@ -90,7 +93,13 @@ describe('/api/webhooks/resend', () => {
test.database,
{
providerEventId: 'msg_123',
payload: { type: 'email.sent', data: { email_id: 'resend-email-1' } },
payload: {
type: 'email.sent',
data: {
email_id: 'resend-email-1',
message_id: '<actual@resend.dev>',
},
},
}
);
expect(test.database.close).toHaveBeenCalledTimes(1);
Expand Down Expand Up @@ -215,7 +224,9 @@ describe('/api/webhooks/resend', () => {
const second = await test.POST(request() as never);
const third = await test.POST(request() as never);

expect([first.status, second.status, third.status]).toEqual([503, 200, 200]);
expect([first.status, second.status, third.status]).toEqual([
503, 200, 200,
]);
expect(test.processVerifiedResendWebhook).toHaveBeenCalledTimes(3);
expect(test.createDatabase).toHaveBeenCalledTimes(3);
expect(test.database.close).toHaveBeenCalledTimes(3);
Expand Down
17 changes: 15 additions & 2 deletions apps/website/src/app/api/whitepaper-signup/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ import { normalizeRecipientEmail } from '@threadplane-internal/growth';
import { matchesSubmittedFormPolicy } from '../../../lib/growth/form-policy';
import {
defaultGrowthFormRouteDependencies,
formAdmissionError,
trustedFormClientIp,
jsonResponse,
readBoundedJsonObject,
stalePolicyResponse,
Expand Down Expand Up @@ -39,6 +41,7 @@ export function createWhitepaperSignupRoute(
return jsonResponse({ error: 'Unable to accept request' }, 503);
}

let honeypot;
let submissionId;
let acquisitionSessionId;
let name;
Expand All @@ -49,6 +52,7 @@ export function createWhitepaperSignupRoute(
if (!matchesSubmittedFormPolicy(policy, policyVersion || undefined)) {
return stalePolicyResponse(policy);
}
honeypot = strictText(body, 'website_url', 200);
submissionId = strictText(body, 'submission_id', 36);
acquisitionSessionId = strictText(body, 'acquisition_session_id', 36);
name = strictText(body, 'name', 200);
Expand Down Expand Up @@ -81,8 +85,13 @@ export function createWhitepaperSignupRoute(
}

let accepted = false;
const trustedClientIp = trustedFormClientIp(request);
let deliverySuppressed = false;
let admissionError: Response | undefined;
try {
await dependencies.accept(database, {
const result = await dependencies.accept(database, {
...(honeypot ? { honeypot } : {}),
...(trustedClientIp ? { trustedClientIp } : {}),
submissionId,
email: normalizedEmail,
displayName: name || undefined,
Expand All @@ -97,7 +106,9 @@ export function createWhitepaperSignupRoute(
keyring,
});
accepted = true;
} catch {
deliverySuppressed = result.deliverySuppressed === true;
} catch (error) {
admissionError = formAdmissionError(error);
// The response below reports the failure without echoing provider detail.
}

Expand All @@ -106,7 +117,9 @@ export function createWhitepaperSignupRoute(
} catch {
return unableToAccept();
}
if (admissionError) return admissionError;
if (!accepted) return unableToAccept();
if (deliverySuppressed) return jsonResponse({ ok: true });

// The durable jobs remain available to the scheduled dispatcher.
await dependencies.nudge({ submissionId }).catch(() => undefined);
Expand Down
3 changes: 3 additions & 0 deletions apps/website/src/components/contact/ContactForm.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
'use client';
import { Honeypot, readHoneypot } from '../form/Honeypot';

import React, { useState } from 'react';
import { Button } from '../ui/Button';
Expand Down Expand Up @@ -88,6 +89,7 @@ export function ContactForm({ formPolicy, intent = 'contact', entryPoint }: Cont
return;
}
void form.submit({
website_url: readHoneypot(e.currentTarget),
form_kind: enterprise ? 'pricing' : 'contact',
email: email.trim(),
...(name.trim() ? { name: name.trim() } : {}),
Expand All @@ -113,6 +115,7 @@ export function ContactForm({ formPolicy, intent = 'contact', entryPoint }: Cont

return (
<form onSubmit={handleSubmit} data-ui="form" noValidate>
<Honeypot />
<Field id="contact-email" label="Work email" error={emailMessage}>
<TextInput
type="email"
Expand Down
22 changes: 22 additions & 0 deletions apps/website/src/components/form/Honeypot.spec.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import { fireEvent, render } from '@testing-library/react';
import { expect, it } from 'vitest';
import { Honeypot, readHoneypot } from './Honeypot';

it('keeps the trap out of normal navigation but includes bot input in form facts', () => {
const { container } = render(
<form>
<Honeypot />
</form>
);
const input = container.querySelector('input');
const form = container.querySelector('form');
if (!input || !form) throw new Error('Expected form trap');
expect(input.tabIndex).toBe(-1);
expect(input.getAttribute('autocomplete')).toBe('off');
expect(input.closest('[aria-hidden="true"]')).not.toBeNull();
expect(readHoneypot(form)).toBe('');
fireEvent.change(input, { target: { value: 'https://spam.invalid' } });
expect(readHoneypot(form)).toBe(
'https://spam.invalid'
);
});
Loading
Loading