@@ -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