diff --git a/infra/console.ts b/infra/console.ts index 79d5e077cfac..8ddbc43f14a6 100644 --- a/infra/console.ts +++ b/infra/console.ts @@ -22,6 +22,8 @@ new sst.cloudflare.Bucket("ZenDataNew") const AWS_SES_ACCESS_KEY_ID = new sst.Secret("AWS_SES_ACCESS_KEY_ID") const AWS_SES_SECRET_ACCESS_KEY = new sst.Secret("AWS_SES_SECRET_ACCESS_KEY") const ENTERPRISE_SALES_INBOX_EMAIL = new sst.Secret("ENTERPRISE_SALES_INBOX_EMAIL") +// OpenCode API key for Jev spam scoring of enterprise form submissions. +const ENTERPRISE_SPAM_API_KEY = new sst.Secret("ENTERPRISE_SPAM_API_KEY") const SALESFORCE_CLIENT_ID = new sst.Secret("SALESFORCE_CLIENT_ID") const SALESFORCE_CLIENT_SECRET = new sst.Secret("SALESFORCE_CLIENT_SECRET") @@ -40,6 +42,7 @@ new sst.cloudflare.x.SolidStart("Console", { AWS_SES_ACCESS_KEY_ID, AWS_SES_SECRET_ACCESS_KEY, ENTERPRISE_SALES_INBOX_EMAIL, + ENTERPRISE_SPAM_API_KEY, SALESFORCE_CLIENT_ID, SALESFORCE_CLIENT_SECRET, SALESFORCE_INSTANCE_URL, diff --git a/packages/console/app/src/lib/aws.ts b/packages/console/app/src/lib/aws.ts index 5aea909e458c..3880e3c039a2 100644 --- a/packages/console/app/src/lib/aws.ts +++ b/packages/console/app/src/lib/aws.ts @@ -3,7 +3,7 @@ import { AwsClient } from "aws4fetch" let client: AwsClient | undefined -export async function sendEmail(input: { to: string; subject: string; body: string; replyTo?: string }) { +export async function sendEmail(input: { to: string; subject: string; body: string; text?: string; replyTo?: string }) { client ??= new AwsClient({ accessKeyId: Resource.AWS_SES_ACCESS_KEY_ID.value, secretAccessKey: Resource.AWS_SES_SECRET_ACCESS_KEY.value, @@ -30,7 +30,7 @@ export async function sendEmail(input: { to: string; subject: string; body: stri Body: { Text: { Charset: "UTF-8", - Data: input.body, + Data: input.text ?? input.body, }, Html: { Charset: "UTF-8", diff --git a/packages/console/app/src/lib/enterprise-spam.test.ts b/packages/console/app/src/lib/enterprise-spam.test.ts new file mode 100644 index 000000000000..75785b0da9bd --- /dev/null +++ b/packages/console/app/src/lib/enterprise-spam.test.ts @@ -0,0 +1,155 @@ +import { afterEach, expect, mock, spyOn, test } from "bun:test" +import { enterpriseSpamProbability, ENTERPRISE_SPAM_MODEL } from "./enterprise-spam" +import { enterpriseEmailContent, handleEnterpriseRequest } from "./enterprise" + +const submission = { + name: "Fake Visitor", + role: "Engineering lead", + company: "Example Test Company", + email: "fake-visitor@example.test", + message: "We need SSO for 200 developers.", +} + +afterEach(() => mock.restore()) + +const score = (probability: unknown) => + Response.json({ model: ENTERPRISE_SPAM_MODEL, answers: { spam: { type: "noul", noul: probability } } }) + +test("scores a submission through the System One gateway", async () => { + const requests: Request[] = [] + // The form handler passes the whole submission, phone included; the classifier must not receive it. + const withPhone = { ...submission, phone: "+1 555 0100" } + const probability = await enterpriseSpamProbability(withPhone, { + inferenceUrl: "https://opencode.test/inference", + apiKey: "test-key", + fetch: async (request) => { + requests.push(request) + return score(0.97) + }, + }) + + expect(probability).toBe(0.97) + expect(requests).toHaveLength(1) + expect(requests[0].url).toBe("https://opencode.test/inference/systemone/v1/systemone") + expect(requests[0].headers.get("authorization")).toBe("Bearer test-key") + const body = await requests[0].json() + expect(body.model).toBe(ENTERPRISE_SPAM_MODEL) + expect(body.state.submission).toMatchObject({ email: submission.email, message: submission.message }) + expect(body.state.submission).not.toHaveProperty("phone") + expect(body.questions.spam.type).toBe("noul") +}) + +test("returns no score when the gateway fails, times out, or answers without a probability", async () => { + const options = { inferenceUrl: "https://opencode.test/inference", apiKey: "test-key" } + const failures = [ + async () => new Response("nope", { status: 401 }), + async () => { + throw new Error("network") + }, + async () => score("high"), + async () => score(1.5), + async () => Response.json({ answers: {} }), + (request: Request) => + new Promise((_, reject) => + request.signal.addEventListener("abort", () => reject(request.signal.reason)), + ), + ] + for (const fetch of failures) { + expect(await enterpriseSpamProbability(submission, { ...options, timeoutMs: 20, fetch })).toBeUndefined() + } +}) + +test("returns no score without logging an error when the gateway is not configured", async () => { + const error = spyOn(console, "error").mockImplementation(() => {}) + expect(await enterpriseSpamProbability(submission, { inferenceUrl: "", apiKey: "test-key" })).toBeUndefined() + expect(error).not.toHaveBeenCalled() +}) + +const submit = async (fetch: (request: Request) => Promise) => { + const leads: Record[] = [] + const response = await handleEnterpriseRequest( + new Request("http://localhost/api/enterprise", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ ...submission, alias: "" }), + }), + { + spam: { inferenceUrl: "https://opencode.test/inference", apiKey: "test-key", fetch }, + salesforceSubmit: async (payload) => { + leads.push(payload) + return true + }, + notifications: false, + accountLookup: async () => null, + }, + ) + return { status: response.status, body: await response.json(), leads } +} + +test("confident spam gets the normal success response, never reaches Salesforce, and is logged", async () => { + const warn = spyOn(console, "warn").mockImplementation(() => {}) + const result = await submit(async () => score(0.95)) + expect(result.status).toBe(200) + expect(result.body).toMatchObject({ success: true }) + expect(result.leads).toHaveLength(0) + expect(warn).toHaveBeenCalledWith("Enterprise inquiry dropped as spam", { + email: submission.email, + probability: 0.95, + }) +}) + +test("uncertain, missing, or failed scores still create the lead", async () => { + spyOn(console, "error").mockImplementation(() => {}) + for (const fetch of [ + async () => score(0.6), + async () => Response.json({ answers: {} }), + async () => { + throw new Error("classifier down") + }, + ]) { + const result = await submit(fetch) + expect(result.status).toBe(200) + expect(result.leads).toHaveLength(1) + } +}) + +test("the sales inbox email escapes submitted HTML", () => { + const content = enterpriseEmailContent({ + name: "Name", + role: "Role", + email: "fake-visitor@example.test", + message: ` & `, + alias: "", + }) + expect(content.html).not.toContain("