11import { getEnvVariable , getNodeEnvironment } from "@stackframe/stack-shared/dist/utils/env" ;
22import { captureError , StackAssertionError } from "@stackframe/stack-shared/dist/utils/errors" ;
3+ import { wait } from "@stackframe/stack-shared/dist/utils/promises" ;
34import { traceSpan } from "@stackframe/stack-shared/dist/utils/telemetry" ;
45import createEmailableClient from "emailable" ;
56
@@ -12,9 +13,8 @@ const VERIFY_STATES = ["deliverable", "undeliverable", "risky", "unknown"] as co
1213type EmailableVerifyResponse = ReturnType < typeof validateVerifyResponse > ;
1314
1415export type EmailableCheckResult =
15- | { status : "ok " , emailableScore : number | null }
16+ | { status : "deliverable " , emailableScore : number | null }
1617 | { status : "not-deliverable" , emailableResponse : EmailableVerifyResponse , emailableScore : number | null }
17- | { status : "error" , error : unknown , emailableScore : null } ;
1818
1919
2020// ── Helpers ────────────────────────────────────────────────────────────
@@ -41,17 +41,15 @@ function validateVerifyResponse(value: unknown) {
4141
4242async function verifyWithRetries ( verifyFn : ( ) => Promise < unknown > , maxAttempts : number , delayBaseMs : number ) {
4343 for ( let i = 0 ; i < maxAttempts ; i ++ ) {
44- try {
45- return await verifyFn ( ) ;
46- } catch ( error ) {
47- const code = ( error != null && typeof error === "object" && ! Array . isArray ( error ) )
48- ? Reflect . get ( error , "code" )
49- : null ;
50- if ( code !== 249 ) throw error ; // only retry rate-limit errors
51- if ( i < maxAttempts - 1 ) {
52- await new Promise ( r => setTimeout ( r , ( Math . random ( ) + 0.5 ) * delayBaseMs * ( 2 ** i ) ) ) ;
44+ const res : any = await verifyFn ( ) ;
45+ if ( ! ( "state" in res ) ) {
46+ if ( "message" in res && res . message . includes ( "Your request is taking longer than normal" ) ) {
47+ await wait ( ( Math . random ( ) + 0.5 ) * delayBaseMs * ( 2 ** i ) ) ;
48+ continue ;
5349 }
50+ throw new StackAssertionError ( "Emailable returned an unexpected response body" , { response : res } ) ;
5451 }
52+ return res ;
5553 }
5654 throw new StackAssertionError ( "Timed out while verifying email address with Emailable" ) ;
5755}
@@ -81,46 +79,47 @@ export async function checkEmailWithEmailable(
8179 _clientFactory ?: ( apiKey : string ) => { verify : ( email : string ) => Promise < unknown > } ,
8280 } ,
8381) : Promise < EmailableCheckResult > {
84- const rawApiKey = getEnvVariable ( "STACK_EMAILABLE_API_KEY" , "" ) ;
85- const emailDomain = email . split ( "@" ) [ 1 ] ?. toLowerCase ( ) ?? "" ;
82+ try {
83+ const rawApiKey = getEnvVariable ( "STACK_EMAILABLE_API_KEY" , "" ) ;
84+ const emailDomain = email . split ( "@" ) [ 1 ] ?. toLowerCase ( ) ?? "" ;
85+
86+ // Always reject the explicit test domain, regardless of API key
87+ if ( emailDomain === EMAILABLE_NOT_DELIVERABLE_TEST_DOMAIN ) {
88+ const testResponse = buildTestUndeliverableResponse ( email ) ;
89+ return { status : "not-deliverable" , emailableResponse : testResponse , emailableScore : testResponse . score } ;
90+ }
8691
87- // Always reject the explicit test domain, regardless of API key
88- if ( emailDomain === EMAILABLE_NOT_DELIVERABLE_TEST_DOMAIN ) {
89- const testResponse = buildTestUndeliverableResponse ( email ) ;
90- return { status : "not-deliverable" , emailableResponse : testResponse , emailableScore : testResponse . score } ;
91- }
92+ if ( ! rawApiKey ) {
93+ if ( [ "development" , "test" ] . includes ( getNodeEnvironment ( ) ) ) {
94+ return { status : "deliverable" , emailableScore : null } ;
95+ }
96+ throw new StackAssertionError ( "STACK_EMAILABLE_API_KEY must not be empty; set it to 'disable_email_validation' to disable email validation" ) ;
97+ }
9298
93- if ( ! rawApiKey ) {
94- if ( [ "development" , "test" ] . includes ( getNodeEnvironment ( ) ) ) {
95- return { status : "ok " , emailableScore : null } ;
99+ const apiKey = rawApiKey === "disable_email_validation" ? "" : rawApiKey ;
100+ if ( ! apiKey || isReservedTestDomain ( emailDomain ) ) {
101+ return { status : "deliverable " , emailableScore : null } ;
96102 }
97- throw new StackAssertionError ( "STACK_EMAILABLE_API_KEY must not be empty; set it to 'disable_email_validation' to disable email validation" ) ;
98- }
99103
100- const apiKey = rawApiKey === "disable_email_validation" ? "" : rawApiKey ;
101- if ( ! apiKey || isReservedTestDomain ( emailDomain ) ) {
102- return { status : "ok" , emailableScore : null } ;
103- }
104+ const clientFactory = options ?. _clientFactory ?? createEmailableClient ;
105+ const retryDelayBase = options ?. retryExponentialDelayBaseMs ?? RETRY_BACKOFF_BASE_MS ;
104106
105- const clientFactory = options ?. _clientFactory ?? createEmailableClient ;
106- const retryDelayBase = options ?. retryExponentialDelayBaseMs ?? RETRY_BACKOFF_BASE_MS ;
107-
108- return await traceSpan ( "checking email address with Emailable" , async ( ) => {
109- const client = clientFactory ( apiKey ) ;
110- let raw : unknown ;
111- try {
112- raw = await verifyWithRetries ( ( ) => client . verify ( email ) , 4 , retryDelayBase ) ;
113- } catch ( error ) {
114- captureError ( "emailable-api-error" , error ) ;
115- return { status : "error" , error, emailableScore : null } ;
116- }
117- const response = validateVerifyResponse ( raw ) ;
107+ return await traceSpan ( "checking email address with Emailable" , async ( ) => {
108+ const client = clientFactory ( apiKey ) ;
109+ const raw = await verifyWithRetries ( ( ) => client . verify ( email ) , 4 , retryDelayBase ) ;
110+ console . log ( "Received emailable response" , { email, raw } ) ;
111+ const response = validateVerifyResponse ( raw ) ;
118112
119- if ( response . state === "undeliverable" || response . disposable ) {
120- return { status : "not-deliverable" , emailableResponse : response , emailableScore : response . score } ;
121- }
122- return { status : "ok" , emailableScore : response . score } ;
123- } ) ;
113+ if ( response . state === "undeliverable" ) {
114+ return { status : "not-deliverable" , emailableResponse : response , emailableScore : response . score } ;
115+ }
116+ return { status : "deliverable" , emailableScore : response . score } ;
117+ } ) ;
118+ } catch ( error ) {
119+ captureError ( "emailable-api-error" , new StackAssertionError ( "Error while checking email address with Emailable" , { cause : error , email, options } ) ) ;
120+ // If there's an error, let's pretend the email is deliverable, albeit with the score unavailable
121+ return { status : "deliverable" , emailableScore : null } ;
122+ }
124123}
125124
126125
@@ -157,17 +156,29 @@ import.meta.vitest?.describe("checkEmailWithEmailable(...)", () => {
157156
158157 test ( "returns ok for deliverable email" , async ( { expect } ) => {
159158 const result = await checkEmailWithEmailable ( "test@gmail.com" , { _clientFactory : deliverableClient } ) ;
160- expect ( result . status ) . toBe ( "ok" ) ;
159+ expect ( result ) . toMatchObject ( { status : "deliverable" , emailableScore : 95 } ) ;
160+ } ) ;
161+
162+ test ( "successfully retries and verifies deliverable email if Emailable asks for a retry the first time" , async ( { expect } ) => {
163+ let retryCount = 0 ;
164+ const retryClient = fakeClient ( async ( ) => retryCount ++ === 0 ? {
165+ message : "Your request is taking longer than normal. Please send your request again."
166+ } : {
167+ state : "deliverable" , disposable : false , score : 95 , domain : "gmail.com" , email : "test@gmail.com" , user : "test" ,
168+ } ) ;
169+ const result = await checkEmailWithEmailable ( "test@gmail.com" , { _clientFactory : retryClient } ) ;
170+ expect ( retryCount ) . toBe ( 2 ) ;
171+ expect ( result ) . toMatchObject ( { status : "deliverable" , emailableScore : 95 } ) ;
161172 } ) ;
162173
163- test ( "returns error on API error" , async ( { expect } ) => {
174+ test ( "returns deliverable on API error" , async ( { expect } ) => {
164175 const result = await checkEmailWithEmailable ( "test@gmail.com" , { _clientFactory : errorClient } ) ;
165- expect ( result . status ) . toBe ( "error" ) ;
176+ expect ( result ) . toMatchObject ( { status : "deliverable" , emailableScore : null } ) ;
166177 } ) ;
167178
168- test ( "throws on malformed Emailable response bodies" , async ( { expect } ) => {
179+ test ( "returns deliverable on malformed Emailable response bodies" , async ( { expect } ) => {
169180 const malformedClient = fakeClient ( async ( ) => "definitely not an object" ) ;
170- await expect ( checkEmailWithEmailable ( "test@gmail.com" , { _clientFactory : malformedClient } ) )
171- . rejects . toThrowError ( "Emailable returned a non-object response body" ) ;
181+ const result = await checkEmailWithEmailable ( "test@gmail.com" , { _clientFactory : malformedClient } ) ;
182+ expect ( result ) . toMatchObject ( { status : "deliverable" , emailableScore : null } ) ;
172183 } ) ;
173184} ) ;
0 commit comments