Skip to content

Commit 84338cb

Browse files
authored
(fix/monitoring) Email Confirmation (firecrawl#3645)
* Nick: * Nick: * Nick: * Nick: * Nick:
1 parent 40b5e73 commit 84338cb

12 files changed

Lines changed: 2063 additions & 115 deletions

File tree

apps/api/src/__tests__/snips/v2/lib.ts

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -179,6 +179,26 @@ export async function monitorCheckRaw(
179179
return query ? req.query(query) : req;
180180
}
181181

182+
export async function monitorEmailConfirmRaw(token: string) {
183+
return await request(TEST_API_URL)
184+
.post(`/v2/monitor/email/confirm`)
185+
.set("Content-Type", "application/json")
186+
.send({ token });
187+
}
188+
189+
export async function monitorEmailUnsubscribeRaw(token: string) {
190+
return await request(TEST_API_URL)
191+
.post(`/v2/monitor/email/unsubscribe`)
192+
.set("Content-Type", "application/json")
193+
.send({ token });
194+
}
195+
196+
export async function monitorEmailConfirmRawViaQuery(token: string) {
197+
return await request(TEST_API_URL)
198+
.post(`/v2/monitor/email/confirm`)
199+
.query({ token });
200+
}
201+
182202
export async function parseRaw(
183203
body: {
184204
options?: Omit<ParseRequestInput, "file">;

apps/api/src/__tests__/snips/v2/monitor.test.ts

Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,9 @@ import {
1010
monitorCheckRaw,
1111
monitorCreateRaw,
1212
monitorDeleteRaw,
13+
monitorEmailConfirmRaw,
14+
monitorEmailConfirmRawViaQuery,
15+
monitorEmailUnsubscribeRaw,
1316
monitorGetRaw,
1417
monitorListRaw,
1518
monitorPatchRaw,
@@ -308,6 +311,124 @@ describeIf(ALLOW_TEST_SUITE_WEBSITE && !TEST_SELF_HOST)("/v2/monitor", () => {
308311
2 * scrapeTimeout,
309312
);
310313

314+
describe("email recipient opt-in", () => {
315+
function externalRecipient(): string {
316+
const id = Math.random().toString(36).slice(2, 10);
317+
return `optin-${id}@external-test.example`;
318+
}
319+
320+
it("starts external recipients as pending without sending notifications", async () => {
321+
const recipient = externalRecipient();
322+
const create = await monitorCreateRaw(
323+
{
324+
name: "opt-in monitor",
325+
schedule: { cron: "*/30 * * * *", timezone: "UTC" },
326+
targets: [
327+
{
328+
type: "scrape",
329+
urls: [createTestIdUrl()],
330+
scrapeOptions: { formats: ["markdown"] },
331+
},
332+
],
333+
notification: {
334+
email: { enabled: true, recipients: [recipient] },
335+
},
336+
},
337+
identity,
338+
);
339+
expect(create.statusCode).toBe(200);
340+
expect(create.body.data.emailRecipientSubscriptions).toEqual(
341+
expect.arrayContaining([
342+
expect.objectContaining({
343+
email: recipient.toLowerCase(),
344+
status: "pending",
345+
source: "opt_in",
346+
}),
347+
]),
348+
);
349+
350+
const get = await monitorGetRaw(create.body.data.id, identity);
351+
expect(get.body.data.emailRecipientSubscriptions).toEqual(
352+
expect.arrayContaining([
353+
expect.objectContaining({
354+
email: recipient.toLowerCase(),
355+
status: "pending",
356+
}),
357+
]),
358+
);
359+
360+
await monitorDeleteRaw(create.body.data.id, identity);
361+
});
362+
363+
it("rejects malformed confirm/unsubscribe tokens with a 400 JSON error", async () => {
364+
const badConfirm = await monitorEmailConfirmRaw("x");
365+
expect(badConfirm.statusCode).toBe(400);
366+
expect(badConfirm.body).toEqual({
367+
success: false,
368+
error: "invalid_token",
369+
});
370+
371+
const badUnsub = await monitorEmailUnsubscribeRaw("x");
372+
expect(badUnsub.statusCode).toBe(400);
373+
expect(badUnsub.body).toEqual({
374+
success: false,
375+
error: "invalid_token",
376+
});
377+
});
378+
379+
it("rejects tokens sent in the query string (body-only)", async () => {
380+
// Tokens in URLs leak into access/proxy logs and APM URL tags, so
381+
// the controller deliberately ignores query params.
382+
const unknownToken = "a".repeat(43);
383+
const response = await monitorEmailConfirmRawViaQuery(unknownToken);
384+
expect(response.statusCode).toBe(400);
385+
expect(response.body).toEqual({
386+
success: false,
387+
error: "invalid_token",
388+
});
389+
});
390+
391+
it("returns 404 JSON for unknown but well-formed tokens", async () => {
392+
const unknownToken = "a".repeat(43);
393+
394+
const confirm = await monitorEmailConfirmRaw(unknownToken);
395+
expect(confirm.statusCode).toBe(404);
396+
expect(confirm.body).toEqual({
397+
success: false,
398+
error: "not_found",
399+
});
400+
401+
const unsub = await monitorEmailUnsubscribeRaw(unknownToken);
402+
expect(unsub.statusCode).toBe(404);
403+
expect(unsub.body).toEqual({
404+
success: false,
405+
error: "not_found",
406+
});
407+
});
408+
409+
it("does not require opt-in when recipients are unset (team-default path)", async () => {
410+
const create = await monitorCreateRaw(
411+
{
412+
name: "team default monitor",
413+
schedule: { cron: "*/30 * * * *", timezone: "UTC" },
414+
targets: [
415+
{
416+
type: "scrape",
417+
urls: [createTestIdUrl()],
418+
scrapeOptions: { formats: ["markdown"] },
419+
},
420+
],
421+
notification: { email: { enabled: true } },
422+
},
423+
identity,
424+
);
425+
expect(create.statusCode).toBe(200);
426+
expect(create.body.data.emailRecipientSubscriptions).toEqual([]);
427+
428+
await monitorDeleteRaw(create.body.data.id, identity);
429+
});
430+
});
431+
311432
it(
312433
"accepts mixed json + git-diff changeTracking modes",
313434
async () => {

0 commit comments

Comments
 (0)