From 3a4d5a5799e60ab95193d58547638fc73e160e0a Mon Sep 17 00:00:00 2001 From: Francisc Balint <7178547+franciscbalint@users.noreply.github.com> Date: Sun, 18 Oct 2026 00:36:34 +0400 Subject: [PATCH 1/6] fix: URL-encode api_key in generic API helpers Use EncodeParam for api_key query values in zbgeneric.pas. Co-authored-by: Cursor --- src/zbgeneric.pas | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/src/zbgeneric.pas b/src/zbgeneric.pas index 6514543..e4e687a 100644 --- a/src/zbgeneric.pas +++ b/src/zbgeneric.pas @@ -35,7 +35,7 @@ implementation response: TZbRequestResponse; error: ZbException; begin - UrlToAccess := Concat(ZbApiBaseURL, ENDPOINT_CREDITS, '?api_key=', ZbApiKey); + UrlToAccess := Concat(ZbApiBaseURL, ENDPOINT_CREDITS, '?api_key=', EncodeParam(ZbApiKey)); response := ZBGetRequest(UrlToAccess); // attempt json parsing @@ -58,7 +58,7 @@ implementation response: TZbRequestResponse; error: ZbException; begin - UrlToAccess := Concat(ZbApiBaseURL, ENDPOINT_API_USAGE, '?api_key=', ZbApiKey); + UrlToAccess := Concat(ZbApiBaseURL, ENDPOINT_API_USAGE, '?api_key=', EncodeParam(ZbApiKey)); DateAuxString := Format( '%d-%d-%d', [ @@ -107,7 +107,7 @@ implementation error: ZbException; begin UrlToAccess := Concat(ZbApiBaseURL, ENDPOINT_ACTIVITY_DATA); - UrlToAccess := Concat(UrlToAccess, '?api_key=', ZbApiKey); + UrlToAccess := Concat(UrlToAccess, '?api_key=', EncodeParam(ZbApiKey)); UrlToAccess := Concat(UrlToAccess, '&email=', EncodeParam(Email)); response := ZBGetRequest(UrlToAccess); @@ -135,7 +135,7 @@ implementation error: ZbException; begin UrlToAccess := Concat(ZbApiBaseURL, ENDPOINT_EMAIL_FINDER); - UrlToAccess := Concat(UrlToAccess, '?api_key=', ZbApiKey); + UrlToAccess := Concat(UrlToAccess, '?api_key=', EncodeParam(ZbApiKey)); UrlToAccess := Concat(UrlToAccess, '&domain=', EncodeParam(Domain)); UrlToAccess := Concat(UrlToAccess, '&first_name=', EncodeParam(FirstName)); @@ -173,7 +173,7 @@ implementation error: ZbException; begin UrlToAccess := Concat(ZbApiBaseURL, ENDPOINT_EMAIL_FINDER); - UrlToAccess := Concat(UrlToAccess, '?api_key=', ZbApiKey); + UrlToAccess := Concat(UrlToAccess, '?api_key=', EncodeParam(ZbApiKey)); UrlToAccess := Concat(UrlToAccess, '&company_name=', EncodeParam(CompanyName)); UrlToAccess := Concat(UrlToAccess, '&first_name=', EncodeParam(FirstName)); @@ -211,7 +211,7 @@ implementation error: ZbException; begin UrlToAccess := Concat(ZbApiBaseURL, ENDPOINT_EMAIL_FINDER); - UrlToAccess := Concat(UrlToAccess, '?api_key=', ZbApiKey); + UrlToAccess := Concat(UrlToAccess, '?api_key=', EncodeParam(ZbApiKey)); UrlToAccess := Concat(UrlToAccess, '&domain=', EncodeParam(Domain)); response := ZBGetRequest(UrlToAccess); @@ -233,7 +233,7 @@ implementation error: ZbException; begin UrlToAccess := Concat(ZbApiBaseURL, ENDPOINT_EMAIL_FINDER); - UrlToAccess := Concat(UrlToAccess, '?api_key=', ZbApiKey); + UrlToAccess := Concat(UrlToAccess, '?api_key=', EncodeParam(ZbApiKey)); UrlToAccess := Concat(UrlToAccess, '&company_name=', EncodeParam(CompanyName)); response := ZBGetRequest(UrlToAccess); @@ -255,7 +255,7 @@ implementation error: ZbException; begin UrlToAccess := Concat(ZbApiBaseURL, ENDPOINT_EMAIL_FINDER); - UrlToAccess := Concat(UrlToAccess, '?api_key=', ZbApiKey); + UrlToAccess := Concat(UrlToAccess, '?api_key=', EncodeParam(ZbApiKey)); UrlToAccess := Concat(UrlToAccess, '&domain=', EncodeParam(Domain)); if FirstName <> '' then From 6f57a3d8217ba1ab080c2e1a5dc5ee517b605974 Mon Sep 17 00:00:00 2001 From: Francisc Balint <7178547+franciscbalint@users.noreply.github.com> Date: Sun, 18 Oct 2026 00:59:34 +0400 Subject: [PATCH 2/6] feat(validate): add TZbValidateOptions for v2 parity Support verify_plus, activity_data, timeouts on validate/validatebatch and parse active_in_days/active_first_seen. Co-authored-by: Cursor --- src/zbstructures.pas | 4 ++ src/zbvalidation.pas | 89 ++++++++++++++++++++++++++++++++++++++-- tests/testvalidation.pas | 17 ++++++++ 3 files changed, 107 insertions(+), 3 deletions(-) diff --git a/src/zbstructures.pas b/src/zbstructures.pas index e540bd9..c4a813c 100644 --- a/src/zbstructures.pas +++ b/src/zbstructures.pas @@ -81,6 +81,8 @@ TZbValidationResult = record Account: String; Domain: String; DomainAgeDays: String; + ActiveInDays: String; + ActiveFirstSeen: String; SmtpProvider: String; CatchallDomain: boolean; MxRecord: String; @@ -399,6 +401,8 @@ implementation Result.Account := JsonObj.GetString('account'); Result.Domain := JsonObj.GetString('domain'); Result.DomainAgeDays := JsonObj.GetString('domain_age_days'); + Result.ActiveInDays := JsonObj.GetString('active_in_days'); + Result.ActiveFirstSeen := JsonObj.GetString('active_first_seen'); Result.SmtpProvider := JsonObj.GetString('smtp_provider'); Result.CatchallDomain := JsonObj.GetBoolean('catchall_domain'); Result.MxRecord := JsonObj.GetString('mx_record'); diff --git a/src/zbvalidation.pas b/src/zbvalidation.pas index 1040b1c..7a5934c 100644 --- a/src/zbvalidation.pas +++ b/src/zbvalidation.pas @@ -15,13 +15,33 @@ TZbEmailAndIp = record Ip: String; end; + TZbValidateOptions = record + Timeout: Integer; + ActivityData: Integer; + VerifyPlus: Integer; + end; + procedure Register; -function ZbBatchRequestBodyFromEmails(Emails: array of TZbEmailAndIp): String; +function ZbDefaultValidateOptions: TZbValidateOptions; +function ZbBatchRequestBodyFromEmails(Emails: array of TZbEmailAndIp): String; overload; +function ZbBatchRequestBodyFromEmails( + Emails: array of TZbEmailAndIp; + Options: TZbValidateOptions +): String; overload; function ZbValidateEmail(Email: String; IpAddress: String): TZbValidationResult; overload; function ZbValidateEmail(Email: String): TZbValidationResult; overload; +function ZbValidateEmail( + Email: String; + IpAddress: String; + Options: TZbValidateOptions +): TZbValidationResult; overload; function ZbBatchValidateEmails(Emails: array of TZbEmailAndIp): TZBBatchValidation; overload; function ZbBatchValidateEmails(Emails: array of String): TZBBatchValidation; overload; +function ZbBatchValidateEmails( + Emails: array of TZbEmailAndIp; + Options: TZbValidateOptions +): TZBBatchValidation; overload; implementation @@ -50,11 +70,27 @@ implementation end; end; + function ZbDefaultValidateOptions: TZbValidateOptions; + begin + Result.Timeout := -1; + Result.ActivityData := -1; + Result.VerifyPlus := -1; + end; + function ZbBatchRequestBodyFromEmails(Emails: array of TZbEmailAndIp): String; + begin + Result := ZbBatchRequestBodyFromEmails(Emails, ZbDefaultValidateOptions); + end; + + function ZbBatchRequestBodyFromEmails( + Emails: array of TZbEmailAndIp; + Options: TZbValidateOptions + ): String; var EmailAndIp: TZbEmailAndIp; BatchContent: String; FirstElement: Boolean; + Extras: String; begin BatchContent := ''; FirstElement := True; @@ -70,12 +106,35 @@ implementation [ZbJsonEscape(EmailAndIp.Email), ZbJsonEscape(EmailAndIp.Ip)] ); end; + Extras := ''; + if Options.Timeout >= 0 then + Extras := Extras + format(', "timeout": %d', [Options.Timeout]); + if Options.ActivityData >= 0 then + if Options.ActivityData = 0 then + Extras := Extras + ', "activity_data": false' + else + Extras := Extras + ', "activity_data": true'; + if Options.VerifyPlus >= 0 then + if Options.VerifyPlus = 0 then + Extras := Extras + ', "verify_plus": false' + else + Extras := Extras + ', "verify_plus": true'; Result := format( - '{"api_key": "%s", "email_batch": [%s]}', [ZbJsonEscape(ZbApiKey), BatchContent] + '{"api_key": "%s", "email_batch": [%s]%s}', + [ZbJsonEscape(ZbApiKey), BatchContent, Extras] ) end; function ZbValidateEmail(Email: String; IpAddress: String): TZbValidationResult; + begin + Result := ZbValidateEmail(Email, IpAddress, ZbDefaultValidateOptions); + end; + + function ZbValidateEmail( + Email: String; + IpAddress: String; + Options: TZbValidateOptions + ): TZbValidationResult; var UrlToAccess: string; response: TZbRequestResponse; @@ -85,6 +144,20 @@ implementation UrlToAccess := Concat(UrlToAccess, '?api_key=', EncodeParam(ZbApiKey)); UrlToAccess := Concat(UrlToAccess, '&email=', EncodeParam(Email)); UrlToAccess := Concat(UrlToAccess, '&ip_address=', EncodeParam(IpAddress)); + if (Options.Timeout >= 0) and ((Options.Timeout < 3) or (Options.Timeout > 60)) then + raise ZbException.Create('Invalid parameter: timeout (must be between 3 and 60 seconds)'); + if Options.Timeout >= 0 then + UrlToAccess := Concat(UrlToAccess, '&timeout=', EncodeParam(IntToStr(Options.Timeout))); + if Options.ActivityData >= 0 then + if Options.ActivityData = 0 then + UrlToAccess := Concat(UrlToAccess, '&activity_data=false') + else + UrlToAccess := Concat(UrlToAccess, '&activity_data=true'); + if Options.VerifyPlus >= 0 then + if Options.VerifyPlus = 0 then + UrlToAccess := Concat(UrlToAccess, '&verify_plus=false') + else + UrlToAccess := Concat(UrlToAccess, '&verify_plus=true'); response := ZBGetRequest(UrlToAccess); try @@ -104,14 +177,24 @@ implementation end; function ZbBatchValidateEmails(Emails: array of TZbEmailAndIp): TZBBatchValidation; + begin + Result := ZbBatchValidateEmails(Emails, ZbDefaultValidateOptions); + end; + + function ZbBatchValidateEmails( + Emails: array of TZbEmailAndIp; + Options: TZbValidateOptions + ): TZBBatchValidation; var UrlToAccess: String; JsonBody: String; response: TZbRequestResponse; error: ZbException; begin + if (Options.Timeout >= 0) and ((Options.Timeout < 10) or (Options.Timeout > 120)) then + raise ZbException.Create('Invalid parameter: timeout (must be between 10 and 120 seconds)'); UrlToAccess := Concat(ZbApiBaseURL, ENDPOINT_BATCH_VALIDATE); - JsonBody := ZbBatchRequestBodyFromEmails(Emails); + JsonBody := ZbBatchRequestBodyFromEmails(Emails, Options); response := ZBPostRequest(UrlToAccess, JsonBody); try diff --git a/tests/testvalidation.pas b/tests/testvalidation.pas index 1796ad4..a709ef5 100644 --- a/tests/testvalidation.pas +++ b/tests/testvalidation.pas @@ -18,6 +18,7 @@ TTestValidation= class(TBaseTestCase) procedure TestBatchBodyEncoder; procedure TestBatchBodyEncoderOneEmail; procedure TestBatchBodyEncoderNoEmails; + procedure TestBatchBodyEncoderWithOptions; procedure TestBatchEmailValidationEndpoint; procedure TestBatchEmailValidationErrorResponse; procedure TestBatchEmailValidationErrorInPayload; @@ -144,6 +145,22 @@ procedure TTestValidation.TestBatchBodyEncoderOneEmail; end; +procedure TTestValidation.TestBatchBodyEncoderWithOptions; +var + JsonObj: TZbJSon; + Options: TZbValidateOptions; +begin + ZBInitialize(MOCK_API_KEY); + Options := ZbDefaultValidateOptions; + Options.Timeout := 45; + Options.ActivityData := 1; + Options.VerifyPlus := 1; + JsonObj := TZbJSon.Create(ZbBatchRequestBodyFromEmails(EmailsAndIps1, Options)); + AssertEquals('timeout', JsonObj.GetInteger('timeout'), 45); + AssertTrue('activity_data', JsonObj.GetBoolean('activity_data')); + AssertTrue('verify_plus', JsonObj.GetBoolean('verify_plus')); +end; + procedure TTestValidation.TestBatchBodyEncoderNoEmails; var JsonObj: TZbJSon; From cfafcd3c1da1d10da7d7fd1e5e25e2c762de61b7 Mon Sep 17 00:00:00 2001 From: Francisc Balint <7178547+franciscbalint@users.noreply.github.com> Date: Sun, 18 Oct 2026 01:59:44 +0400 Subject: [PATCH 3/6] feat(scoring): add ZbScoreEmail GET/POST for /v2/scoring Add TZbScoreEmailResult, ZBFormPostRequest, and unit tests aligned with JS scoreEmail parity. Co-authored-by: Cursor --- src/ZeroBounce.lpr | 3 ++ src/zbgeneric.pas | 43 +++++++++++++++++++++++++++ src/zbstructures.pas | 39 +++++++++++++++++++++++++ src/zbutility.pas | 67 +++++++++++++++++++++++++++++++++++++++++++ tests/mockvalues.pas | 1 + tests/testgeneric.pas | 25 ++++++++++++++++ 6 files changed, 178 insertions(+) diff --git a/src/ZeroBounce.lpr b/src/ZeroBounce.lpr index fa2ef7e..5b144fe 100644 --- a/src/ZeroBounce.lpr +++ b/src/ZeroBounce.lpr @@ -15,6 +15,8 @@ ZbGetCredits, ZbGetApiUsage, ZbGetApiUsageWithin, + ZbScoreEmail, + ZbScoreEmailPost, ZbValidateEmail, ZbValidateEmailAndIp, ZbBatchValidateEmails, @@ -31,6 +33,7 @@ // auxiliary ZBInitialize, TApiUsage, + TZbScoreEmailResult, TZbValidationResult, TZbBatchError, TZbBatchValidation, diff --git a/src/zbgeneric.pas b/src/zbgeneric.pas index e4e687a..6916c41 100644 --- a/src/zbgeneric.pas +++ b/src/zbgeneric.pas @@ -12,6 +12,8 @@ function ZbGetCredits : Integer; function ZbGetApiUsage : TApiUsage; overload; function ZbGetApiUsage(StartDate, EndDate: TDate) : TApiUsage; overload; function ZbActivityData(Email: String): Integer; +function ZbScoreEmail(Email: String): TZbScoreEmailResult; +function ZbScoreEmailPost(Email: String): TZbScoreEmailResult; function ZbFindEmailByDomain(Domain, FirstName, MiddleName, LastName: String) : TZbFindEmailResponse; overload; function ZbFindEmailByDomain(Domain, FirstName, LastName: String) : TZbFindEmailResponse; overload; function ZbFindEmailByDomain(Domain, FirstName: String) : TZbFindEmailResponse; overload; @@ -99,6 +101,47 @@ implementation end; // Returns -1 if no activity data was found for the email + function ZbScoreEmailFromResponse(const response: TZbRequestResponse): TZbScoreEmailResult; + var + error: ZbException; + begin + try + Result := TZbScoreEmailResultFromJson(response.Payload); + except on e: Exception do + begin + error := ZbException.FromResponse(e.Message, response); + error.MarkJsonError; + raise error; + end; + end; + end; + + function ZbScoreEmail(Email: String): TZbScoreEmailResult; + var + UrlToAccess: String; + response: TZbRequestResponse; + begin + UrlToAccess := Concat(ZbApiBaseURL, ENDPOINT_SCORE_EMAIL); + UrlToAccess := Concat(UrlToAccess, '?api_key=', EncodeParam(ZbApiKey)); + UrlToAccess := Concat(UrlToAccess, '&email=', EncodeParam(Email)); + response := ZBGetRequest(UrlToAccess); + Result := ZbScoreEmailFromResponse(response); + end; + + function ZbScoreEmailPost(Email: String): TZbScoreEmailResult; + var + UrlToAccess, FormBody: String; + response: TZbRequestResponse; + begin + UrlToAccess := Concat(ZbApiBaseURL, ENDPOINT_SCORE_EMAIL); + FormBody := Concat( + 'api_key=', EncodeParam(ZbApiKey), + '&email=', EncodeParam(Email) + ); + response := ZBFormPostRequest(UrlToAccess, FormBody); + Result := ZbScoreEmailFromResponse(response); + end; + function ZbActivityData(Email: String): Integer; var UrlToAccess: String; diff --git a/src/zbstructures.pas b/src/zbstructures.pas index c4a813c..2423f47 100644 --- a/src/zbstructures.pas +++ b/src/zbstructures.pas @@ -72,6 +72,12 @@ TZbJson = class EndDate: TDate; end; + TZbScoreEmailResult = record + Email: String; + Score: Integer; + HasScore: Boolean; + end; + TZbValidationResult = record Address: String; Status: String; @@ -171,6 +177,7 @@ function ZbGetFileJsonIndicatesError(const JsonContent: string): Boolean; function TZbDomainFormatsFromJson(JsonObj: TZbJson): TZbDomainFormats; function TZbFindEmailResponseFromJson(JsonContent: string): TZbFindEmailResponse; function TZbDomainSearchResponseFromJson(JsonContent: string): TZbDomainSearchResponse; +function TZbScoreEmailResultFromJson(JsonContent: string): TZbScoreEmailResult; procedure Register; implementation @@ -666,5 +673,37 @@ implementation end; end; + function TZbScoreEmailResultFromJson(JsonContent: string): TZbScoreEmailResult; + var + JsonObj: TZbJson; + ScoreValue: TZbJSONValue; + begin + Result.Email := ''; + Result.Score := 0; + Result.HasScore := False; + JsonObj := TZbJson.Create(JsonContent); + try + Result.Email := JsonObj.GetString('email'); + ScoreValue := JsonObj.GetValue('score'); + if ScoreValue <> nil then + begin + Result.HasScore := True; + {$IFDEF FPC} + if ScoreValue.JSONType = jtNumber then + Result.Score := Trunc(ScoreValue.AsFloat) + else + Result.Score := StrToInt(ScoreValue.AsString); + {$ELSE} + if ScoreValue is TJSONNumber then + Result.Score := TJSONNumber(ScoreValue).AsInt + else + Result.Score := StrToInt(ScoreValue.Value); + {$ENDIF} + end; + finally + JsonObj.Free; + end; + end; + end. diff --git a/src/zbutility.pas b/src/zbutility.pas index eb29bbc..3f119c3 100644 --- a/src/zbutility.pas +++ b/src/zbutility.pas @@ -43,6 +43,7 @@ TZbRequestResponse = record ZB_DOWNLOAD_TYPE_COMBINED = 'combined'; ENDPOINT_CREDITS = '/getcredits'; ENDPOINT_ACTIVITY_DATA = '/activity'; + ENDPOINT_SCORE_EMAIL = '/scoring'; ENDPOINT_VALIDATE = '/validate'; ENDPOINT_API_USAGE = '/getapiusage'; ENDPOINT_BATCH_VALIDATE = '/validatebatch'; @@ -82,6 +83,8 @@ TZbRequestResponse = record function ZBGetRequest(url: String): TZbRequestResponse; // performs a POST request with a raw JSON body function ZBPostRequest(url: String; JsonParam: String): TZbRequestResponse; overload; + // performs a POST request with application/x-www-form-urlencoded body + function ZBFormPostRequest(url: String; FormBody: String): TZbRequestResponse; // performs a POST request with a multi-part form body function ZBPostRequest(url: String; FormData: TStrings; FileContent: String): TZbRequestResponse; overload; procedure ZBMockResponse(StatusCode: integer; Payload, ContentType: String); overload; @@ -380,6 +383,70 @@ procedure ZbUseVerifiedTls(Client: TFPHTTPClient); end; end; + function ZBFormPostRequest(url: String; FormBody: String): TZbRequestResponse; + var + {$IFDEF FPC} + Client: TFPHTTPClient; + {$ELSE} + Client: THTTPClient; + BodyStream: TStream; + Response: IHTTPResponse; + {$ENDIF} + error: ZbException; + begin + if ZbResponseMock.StatusCode <> 0 then + begin + ZbResponseMock.UrlCalled := url; + Result := ZbResponseMock; + end + else + begin + Result.UrlCalled := url; + {$IFDEF FPC} + InitSSLInterface; + + Client := TFPHTTPClient.Create(nil); + ZbUseVerifiedTls(Client); + Client.ConnectTimeout := 30000; + Client.IOTimeout := 120000; + + Client.AddHeader('Content-Type', 'application/x-www-form-urlencoded'); + Client.AddHeader('Accept', 'application/json'); + Client.RequestBody := TRawByteStringStream.Create(FormBody); + try + Result.Payload := Client.Post(url); + Result.StatusCode := Client.ResponseStatusCode; + Result.ContentType := Client.GetHeader(Client.ResponseHeaders, 'Content-Type'); + finally + Client.RequestBody.Free; + Client.Free; + end; + {$ELSE} + Client := THTTPClient.Create; + BodyStream := TStringStream.Create(FormBody); + try + Client.CustomHeaders['Content-Type'] := 'application/x-www-form-urlencoded'; + Client.CustomHeaders['Accept'] := 'application/json'; + Response := Client.Post(url, BodyStream); + + Result.Payload := Response.ContentAsString(); + Result.StatusCode := Response.StatusCode; + Result.ContentType := Response.HeaderValue['Content-Type']; + finally + Client.Free; + BodyStream.Free; + end; + {$ENDIF} + end; + + if Result.StatusCode > 299 then + begin + error := ZbException.FromResponse('Request failed', Result); + error.MarkHttpError; + raise error; + end; + end; + function ZBPostRequest(url: String; FormData: TStrings; FileContent: String): TZbRequestResponse; const FILE_NAME = 'bulk_upload.csv'; diff --git a/tests/mockvalues.pas b/tests/mockvalues.pas index c303724..23d206a 100644 --- a/tests/mockvalues.pas +++ b/tests/mockvalues.pas @@ -48,6 +48,7 @@ interface ERROR_PAYLOAD: string = '{"error": "Mock error message"}'; ACTIVITY_DATA_FOUND: string = '{"found": true, "active_in_days": "180"};'; ACTIVITY_DATA_NOT_FOUND: string = '{"found": false, "active_in_days": null};'; + SCORE_EMAIL_RESPONSE: string = '{"email":"mark@gmail.com","score":10}'; VALDATION_RESPONSE_VALID: string = '{' + ' "address": "valid@example.com",' + diff --git a/tests/testgeneric.pas b/tests/testgeneric.pas index 1f16d41..35fb9ed 100644 --- a/tests/testgeneric.pas +++ b/tests/testgeneric.pas @@ -23,6 +23,8 @@ TTestGeneric= class(TBaseTestCase) procedure TestActivityDataEndpoint; procedure TestActivityDataNotFound; procedure TestActivityDataFound; + procedure TestScoreEmailGet; + procedure TestScoreEmailPost; procedure TestFindMailInvalidStatusPayload; procedure TestFindMailValidStatusPayload; procedure TestDomainSearchInvalidStatusPayload; @@ -104,6 +106,29 @@ procedure TTestGeneric.TestActivityDataNotFound; AssertEquals('activity data', ActivityAmount, -1); end; +procedure TTestGeneric.TestScoreEmailGet; +var + ScoreResult: TZbScoreEmailResult; +begin + ZBMockResponse(200, SCORE_EMAIL_RESPONSE); + ScoreResult := ZbScoreEmail('mark@gmail.com'); + AssertEndpointCalled(ENDPOINT_SCORE_EMAIL); + AssertEquals('email', ScoreResult.Email, 'mark@gmail.com'); + AssertTrue('score present', ScoreResult.HasScore); + AssertEquals('score', ScoreResult.Score, 10); +end; + +procedure TTestGeneric.TestScoreEmailPost; +var + ScoreResult: TZbScoreEmailResult; +begin + ZBMockResponse(200, SCORE_EMAIL_RESPONSE); + ScoreResult := ZbScoreEmailPost('mark@gmail.com'); + AssertEndpointCalled(ENDPOINT_SCORE_EMAIL); + AssertEquals('email', ScoreResult.Email, 'mark@gmail.com'); + AssertEquals('score', ScoreResult.Score, 10); +end; + procedure TTestGeneric.AssertFindMailValidStatusPayload(Response: TZbFindEmailResponse); begin AssertEquals('invalid Email value', Response.Email, 'john.doe@example.com'); From b1d344ba763d3f3e8f208f5b52ec70f6f46528b2 Mon Sep 17 00:00:00 2001 From: Francisc Balint <7178547+franciscbalint@users.noreply.github.com> Date: Sun, 18 Oct 2026 03:00:30 +0400 Subject: [PATCH 4/6] feat(priority3): bulk finder, evaluator, filters, and S3 Add ZbPriority3 unit with bulkapi parity and FPCUnit coverage for validateS3file. Co-authored-by: Cursor --- packages/zerobounce.lpk | 5 + packages/zerobounce.pas | 3 +- src/zbpriority3.pas | 480 +++++++++++++++++++++++++++++++++++ tests/testpriority3.pas | 35 +++ tests/unit_tests_console.lpr | 2 +- 5 files changed, 523 insertions(+), 2 deletions(-) create mode 100644 src/zbpriority3.pas create mode 100644 tests/testpriority3.pas diff --git a/packages/zerobounce.lpk b/packages/zerobounce.lpk index 80a0da1..f9c8275 100644 --- a/packages/zerobounce.lpk +++ b/packages/zerobounce.lpk @@ -44,6 +44,11 @@ + + + + + diff --git a/packages/zerobounce.pas b/packages/zerobounce.pas index e886c78..a97f6b6 100644 --- a/packages/zerobounce.pas +++ b/packages/zerobounce.pas @@ -8,7 +8,7 @@ interface uses - ZbBulk, ZbGeneric, ZbStructures, ZbUtility, ZbValidation, + ZbBulk, ZbGeneric, ZbPriority3, ZbStructures, ZbUtility, ZbValidation, LazarusPackageIntf; implementation @@ -20,6 +20,7 @@ procedure Register; RegisterUnit('ZbStructures', @ZbStructures.Register); RegisterUnit('ZbUtility', @ZbUtility.Register); RegisterUnit('ZbValidation', @ZbValidation.Register); + RegisterUnit('ZbPriority3', @ZbPriority3.Register); end; initialization diff --git a/src/zbpriority3.pas b/src/zbpriority3.pas new file mode 100644 index 0000000..f4caf6e --- /dev/null +++ b/src/zbpriority3.pas @@ -0,0 +1,480 @@ +unit ZbPriority3; + +{$I zboptions.inc}{$H+} + +interface + +uses + Classes, SysUtils, + ZbStructures, ZbUtility; + +type + TZbEmailFinderSendFileOptions = record + FirstNameColumn: Integer; + LastNameColumn: Integer; + MiddleNameColumn: Integer; + FullNameColumn: Integer; + HasHeaderRow: Boolean; + HasHeaderRowSpecified: Boolean; + end; + + TZbDomainSearchSendFileOptions = record + HasHeaderRow: Boolean; + HasHeaderRowSpecified: Boolean; + end; + + TZbValidateS3FileOptions = record + FileName: String; + HasHeaderRowSpecified: Boolean; + HasHeaderRow: Boolean; + RemoveDuplicatesSpecified: Boolean; + RemoveDuplicates: Boolean; + RemoveDuplicateSpecified: Boolean; + RemoveDuplicate: Boolean; + EmailAddressColumnSpecified: Boolean; + EmailAddressColumn: Integer; + ReturnUrl: String; + FirstNameColumn: Integer; + LastNameColumn: Integer; + GenderColumn: Integer; + IpAddressColumn: Integer; + AllowPhase2Specified: Boolean; + AllowPhase2: Boolean; + end; + + TZbScoreS3FileOptions = record + FileName: String; + HasHeaderRowSpecified: Boolean; + HasHeaderRow: Boolean; + RemoveDuplicatesSpecified: Boolean; + RemoveDuplicates: Boolean; + RemoveDuplicateSpecified: Boolean; + RemoveDuplicate: Boolean; + EmailAddressColumnSpecified: Boolean; + EmailAddressColumn: Integer; + end; + + PTZbEmailFinderSendFileOptions = ^TZbEmailFinderSendFileOptions; + PTZbDomainSearchSendFileOptions = ^TZbDomainSearchSendFileOptions; + +function ZbSendEmailFinderFile(FileContent: String; DomainColumn: Integer; + Options: PTZbEmailFinderSendFileOptions = nil): TZBFileFeedback; +function ZbEmailFinderFileStatus(FileId: String): TZBFileStatus; +function ZbEmailFinderResultFetch(FileId: String): TZBBulkResponse; +function ZbEmailFinderResultDelete(FileId: String): TZBFileFeedback; + +function ZbSendDomainSearchFile(FileContent: String; DomainColumn: Integer; + Options: PTZbDomainSearchSendFileOptions = nil): TZBFileFeedback; +function ZbDomainSearchFileStatus(FileId: String): TZBFileStatus; +function ZbDomainSearchResultFetch(FileId: String): TZBBulkResponse; +function ZbDomainSearchResultDelete(FileId: String): TZBFileFeedback; + +function ZbSendListEvaluatorFile(FileContent: String; EmailAddressColumn: Integer): TZBFileFeedback; +function ZbGetListEvaluatorResult(FileId: String): String; +function ZbSendEvaluatedFile(FileContent: String): TZBFileFeedback; + +function ZbAddFilter(const Rule, Target, Value: String): String; +function ZbListFilters(UsePost: Boolean): String; +function ZbDeleteFilter(const Rule, Target, Value: String): String; + +function ZbValidateS3File(Options: TZbValidateS3FileOptions): TZBFileFeedback; +function ZbScoreS3File(Options: TZbScoreS3FileOptions): TZBFileFeedback; +function ZbExportS3File(const FileId, ExportBucket: String): TZBFileFeedback; + +procedure Register; + +implementation + +const + BULK_ROOT_URI = 'https://bulkapi.zerobounce.net'; + ENDPOINT_EMAIL_FINDER_SEND = '/email-finder/sendfile'; + ENDPOINT_EMAIL_FINDER_STATUS = '/email-finder/filestatus'; + ENDPOINT_EMAIL_FINDER_RESULT = '/email-finder/getfile'; + ENDPOINT_EMAIL_FINDER_DELETE = '/email-finder/deletefile'; + ENDPOINT_DOMAIN_SEARCH_SEND = '/domain-search/sendfile'; + ENDPOINT_DOMAIN_SEARCH_STATUS = '/domain-search/filestatus'; + ENDPOINT_DOMAIN_SEARCH_RESULT = '/domain-search/getfile'; + ENDPOINT_DOMAIN_SEARCH_DELETE = '/domain-search/deletefile'; + ENDPOINT_LIST_EVALUATOR_SEND = '/listevaluator/'; + ENDPOINT_SEND_EVALUATED_FILE = '/send-evaluated-file/'; + ENDPOINT_VALIDATE_S3 = '/validateS3file'; + ENDPOINT_SCORE_S3 = '/scoreS3file'; + ENDPOINT_EXPORT_S3 = '/exportS3file'; + ENDPOINT_FILTERS_ADD = '/filters/add'; + ENDPOINT_FILTERS_LIST = '/filters/list'; + ENDPOINT_FILTERS_DELETE = '/filters/delete'; + + function StrBool(Value: Boolean): String; + begin + if Value then Result := 'true' else Result := 'false'; + end; + + procedure PutOptionalBool(Form: TStrings; const Key: String; Specified: Boolean; Value: Boolean); + begin + if Specified then + Form.AddPair(Key, StrBool(Value)); + end; + + function P3MultipartSubmit(const Url: String; FileContent: String; FormData: TStrings): TZBFileFeedback; + var + response: TZbRequestResponse; + error: ZbException; + begin + try + response := ZBPostRequest(Url, FormData, FileContent); + finally + FormData.Free; + end; + if response.StatusCode > 299 then + begin + error := ZbException.FromResponse('Request failed', response); + error.MarkHttpError; + raise error; + end; + try + Result := ZbFileFeedbackFromJson(response.Payload); + except on e: Exception do + begin + error := ZbException.FromResponse(e.Message, response); + error.MarkJsonError; + raise error; + end; + end; + end; + + function P3FormSubmit(const Url: String; Form: TStrings): TZBFileFeedback; + var + Body: String; + response: TZbRequestResponse; + error: ZbException; + I: Integer; + begin + Body := ''; + for I := 0 to Form.Count - 1 do + begin + if Body <> '' then Body := Body + '&'; + Body := Body + EncodeParam(Form.Names[I]) + '=' + EncodeParam(Form.ValueFromIndex[I]); + end; + Form.Free; + response := ZBFormPostRequest(Url, Body); + if response.StatusCode > 299 then + begin + error := ZbException.FromResponse('Request failed', response); + error.MarkHttpError; + raise error; + end; + try + Result := ZbFileFeedbackFromJson(response.Payload); + except on e: Exception do + begin + error := ZbException.FromResponse(e.Message, response); + error.MarkJsonError; + raise error; + end; + end; + end; + + function P3RawJsonGet(const Url: String): String; + var + response: TZbRequestResponse; + begin + response := ZBGetRequest(Url); + Result := response.Payload; + end; + + function P3RawJsonForm(const Url: String; Form: TStrings): String; + var + Body: String; + response: TZbRequestResponse; + I: Integer; + begin + Body := ''; + for I := 0 to Form.Count - 1 do + begin + if Body <> '' then Body := Body + '&'; + Body := Body + EncodeParam(Form.Names[I]) + '=' + EncodeParam(Form.ValueFromIndex[I]); + end; + Form.Free; + response := ZBFormPostRequest(Url, Body); + Result := response.Payload; + end; + + function P3BulkRootStatus(const Endpoint, FileId: String): TZBFileStatus; + var + UrlToAccess: String; + response: TZbRequestResponse; + error: ZbException; + begin + UrlToAccess := Concat(BULK_ROOT_URI, Endpoint, '?api_key=', EncodeParam(ZbApiKey)); + UrlToAccess := Concat(UrlToAccess, '&file_id=', EncodeParam(FileId)); + response := ZBGetRequest(UrlToAccess); + try + Result := ZbFileStatusFromJson(response.Payload); + except on e: Exception do + begin + error := ZbException.FromResponse(e.Message, response); + error.MarkJsonError; + raise error; + end; + end; + end; + + function P3BulkRootFetch(const Endpoint, FileId: String): TZBBulkResponse; + var + UrlToAccess: String; + response: TZbRequestResponse; + error: ZbException; + IsJsonCT: Boolean; + begin + UrlToAccess := Concat(BULK_ROOT_URI, Endpoint, '?api_key=', EncodeParam(ZbApiKey)); + UrlToAccess := Concat(UrlToAccess, '&file_id=', EncodeParam(FileId)); + response := ZBGetRequest(UrlToAccess); + if response.ContentType = '' then + begin + error := ZbException.FromResponse('No headers found in response', response); + error.MarkHttpError; + raise error; + end; + IsJsonCT := response.ContentType.Contains(JSON_CONTENT_TYPE); + if IsJsonCT or ZbGetFileJsonIndicatesError(response.Payload) then + begin + Result.HasContent := False; + Result.Feedback := ZbFileFeedbackFromJson(response.Payload); + end + else + begin + Result.HasContent := True; + Result.Content := response.Payload; + end; + end; + + function P3BulkRootDelete(const Endpoint, FileId: String): TZBFileFeedback; + var + UrlToAccess: String; + response: TZbRequestResponse; + error: ZbException; + begin + UrlToAccess := Concat(BULK_ROOT_URI, Endpoint, '?api_key=', EncodeParam(ZbApiKey)); + UrlToAccess := Concat(UrlToAccess, '&file_id=', EncodeParam(FileId)); + response := ZBGetRequest(UrlToAccess); + try + Result := ZbFileFeedbackFromJson(response.Payload); + except on e: Exception do + begin + error := ZbException.FromResponse(e.Message, response); + error.MarkJsonError; + raise error; + end; + end; + end; + + function BuildEmailFinderForm(DomainColumn: Integer; Options: PTZbEmailFinderSendFileOptions): TStrings; + var + Form: TStrings; + begin + Form := TStringList.Create; + Form.AddPair('api_key', ZbApiKey); + Form.AddPair('domain_column', IntToStr(DomainColumn)); + if (Options <> nil) and Options^.HasHeaderRowSpecified and Options^.HasHeaderRow then + Form.AddPair('has_header_row', 'true'); + if (Options <> nil) and (Options^.FirstNameColumn > 0) then + Form.AddPair('first_name_column', IntToStr(Options^.FirstNameColumn)); + if (Options <> nil) and (Options^.LastNameColumn > 0) then + Form.AddPair('last_name_column', IntToStr(Options^.LastNameColumn)); + if (Options <> nil) and (Options^.MiddleNameColumn > 0) then + Form.AddPair('middle_name_column', IntToStr(Options^.MiddleNameColumn)); + if (Options <> nil) and (Options^.FullNameColumn > 0) then + Form.AddPair('full_name_column', IntToStr(Options^.FullNameColumn)); + Result := Form; + end; + +function ZbSendEmailFinderFile(FileContent: String; DomainColumn: Integer; + Options: PTZbEmailFinderSendFileOptions): TZBFileFeedback; +begin + if DomainColumn < 1 then + raise ZbException.Create('Empty parameter: domain_column'); + Result := P3MultipartSubmit( + Concat(BULK_ROOT_URI, ENDPOINT_EMAIL_FINDER_SEND), + FileContent, + BuildEmailFinderForm(DomainColumn, Options) + ); +end; + +function ZbEmailFinderFileStatus(FileId: String): TZBFileStatus; +begin + Result := P3BulkRootStatus(ENDPOINT_EMAIL_FINDER_STATUS, FileId); +end; + +function ZbEmailFinderResultFetch(FileId: String): TZBBulkResponse; +begin + Result := P3BulkRootFetch(ENDPOINT_EMAIL_FINDER_RESULT, FileId); +end; + +function ZbEmailFinderResultDelete(FileId: String): TZBFileFeedback; +begin + Result := P3BulkRootDelete(ENDPOINT_EMAIL_FINDER_DELETE, FileId); +end; + +function ZbSendDomainSearchFile(FileContent: String; DomainColumn: Integer; + Options: PTZbDomainSearchSendFileOptions): TZBFileFeedback; +var + Form: TStrings; +begin + if DomainColumn < 1 then + raise ZbException.Create('Empty parameter: domain_column'); + Form := TStringList.Create; + Form.AddPair('api_key', ZbApiKey); + Form.AddPair('domain_column', IntToStr(DomainColumn)); + if (Options <> nil) and Options^.HasHeaderRowSpecified and Options^.HasHeaderRow then + Form.AddPair('has_header_row', 'true'); + Result := P3MultipartSubmit(Concat(BULK_ROOT_URI, ENDPOINT_DOMAIN_SEARCH_SEND), FileContent, Form); +end; + +function ZbDomainSearchFileStatus(FileId: String): TZBFileStatus; +begin + Result := P3BulkRootStatus(ENDPOINT_DOMAIN_SEARCH_STATUS, FileId); +end; + +function ZbDomainSearchResultFetch(FileId: String): TZBBulkResponse; +begin + Result := P3BulkRootFetch(ENDPOINT_DOMAIN_SEARCH_RESULT, FileId); +end; + +function ZbDomainSearchResultDelete(FileId: String): TZBFileFeedback; +begin + Result := P3BulkRootDelete(ENDPOINT_DOMAIN_SEARCH_DELETE, FileId); +end; + +function ZbSendListEvaluatorFile(FileContent: String; EmailAddressColumn: Integer): TZBFileFeedback; +var + Form: TStrings; +begin + if EmailAddressColumn < 1 then + raise ZbException.Create('Empty parameter: email_address_column'); + Form := TStringList.Create; + Form.AddPair('api_key', ZbApiKey); + Form.AddPair('email_address_column', IntToStr(EmailAddressColumn)); + Result := P3MultipartSubmit(Concat(BULK_URI, ENDPOINT_LIST_EVALUATOR_SEND), FileContent, Form); +end; + +function ZbGetListEvaluatorResult(FileId: String): String; +var + UrlToAccess: String; +begin + UrlToAccess := Concat(BULK_URI, '/listevaluator/', FileId, '/?api_key=', EncodeParam(ZbApiKey)); + Result := P3RawJsonGet(UrlToAccess); +end; + +function ZbSendEvaluatedFile(FileContent: String): TZBFileFeedback; +var + Form: TStrings; +begin + Form := TStringList.Create; + Form.AddPair('api_key', ZbApiKey); + Result := P3MultipartSubmit(Concat(BULK_URI, ENDPOINT_SEND_EVALUATED_FILE), FileContent, Form); +end; + +function ZbAddFilter(const Rule, Target, Value: String): String; +var + Form: TStrings; +begin + Form := TStringList.Create; + Form.AddPair('api_key', ZbApiKey); + Form.AddPair('rule', Rule); + Form.AddPair('target', Target); + Form.AddPair('value', Value); + Result := P3RawJsonForm(Concat(ZbApiBaseURL, ENDPOINT_FILTERS_ADD), Form); +end; + +function ZbListFilters(UsePost: Boolean): String; +var + Form: TStrings; +begin + if UsePost then + begin + Form := TStringList.Create; + Form.AddPair('api_key', ZbApiKey); + Result := P3RawJsonForm(Concat(ZbApiBaseURL, ENDPOINT_FILTERS_LIST), Form); + end + else + Result := P3RawJsonGet(Concat(ZbApiBaseURL, ENDPOINT_FILTERS_LIST, '?api_key=', EncodeParam(ZbApiKey))); +end; + +function ZbDeleteFilter(const Rule, Target, Value: String): String; +var + Form: TStrings; +begin + Form := TStringList.Create; + Form.AddPair('api_key', ZbApiKey); + Form.AddPair('rule', Rule); + Form.AddPair('target', Target); + Form.AddPair('value', Value); + Result := P3RawJsonForm(Concat(ZbApiBaseURL, ENDPOINT_FILTERS_DELETE), Form); +end; + +function ZbValidateS3File(Options: TZbValidateS3FileOptions): TZBFileFeedback; +var + Form: TStrings; +begin + if Trim(Options.FileName) = '' then + raise ZbException.Create('Empty parameter: file_name'); + Form := TStringList.Create; + Form.AddPair('file_name', Options.FileName); + PutOptionalBool(Form, 'has_header_row', Options.HasHeaderRowSpecified, Options.HasHeaderRow); + PutOptionalBool(Form, 'remove_duplicates', Options.RemoveDuplicatesSpecified, Options.RemoveDuplicates); + PutOptionalBool(Form, 'remove_duplicate', Options.RemoveDuplicateSpecified, Options.RemoveDuplicate); + if Options.EmailAddressColumnSpecified then + Form.AddPair('email_address_column', IntToStr(Options.EmailAddressColumn)); + if Options.ReturnUrl <> '' then + Form.AddPair('return_url', Options.ReturnUrl); + if Options.FirstNameColumn > 0 then + Form.AddPair('first_name_column', IntToStr(Options.FirstNameColumn)); + if Options.LastNameColumn > 0 then + Form.AddPair('last_name_column', IntToStr(Options.LastNameColumn)); + if Options.GenderColumn > 0 then + Form.AddPair('gender_column', IntToStr(Options.GenderColumn)); + if Options.IpAddressColumn > 0 then + Form.AddPair('ip_address_column', IntToStr(Options.IpAddressColumn)); + if Options.AllowPhase2Specified then + Form.AddPair('allow_phase_2', StrBool(Options.AllowPhase2)); + Form.AddPair('api_key', ZbApiKey); + Result := P3FormSubmit(Concat(BULK_ROOT_URI, ENDPOINT_VALIDATE_S3), Form); +end; + +function ZbScoreS3File(Options: TZbScoreS3FileOptions): TZBFileFeedback; +var + Form: TStrings; +begin + if Trim(Options.FileName) = '' then + raise ZbException.Create('Empty parameter: file_name'); + Form := TStringList.Create; + Form.AddPair('file_name', Options.FileName); + PutOptionalBool(Form, 'has_header_row', Options.HasHeaderRowSpecified, Options.HasHeaderRow); + PutOptionalBool(Form, 'remove_duplicates', Options.RemoveDuplicatesSpecified, Options.RemoveDuplicates); + PutOptionalBool(Form, 'remove_duplicate', Options.RemoveDuplicateSpecified, Options.RemoveDuplicate); + if Options.EmailAddressColumnSpecified then + Form.AddPair('email_address_column', IntToStr(Options.EmailAddressColumn)); + Form.AddPair('api_key', ZbApiKey); + Result := P3FormSubmit(Concat(BULK_ROOT_URI, ENDPOINT_SCORE_S3), Form); +end; + +function ZbExportS3File(const FileId, ExportBucket: String): TZBFileFeedback; +var + Form: TStrings; +begin + if Trim(FileId) = '' then + raise ZbException.Create('Empty parameter: file_id'); + if Trim(ExportBucket) = '' then + raise ZbException.Create('Empty parameter: export_bucket'); + Form := TStringList.Create; + Form.AddPair('file_id', FileId); + Form.AddPair('export_bucket', ExportBucket); + Form.AddPair('api_key', ZbApiKey); + Result := P3FormSubmit(Concat(BULK_ROOT_URI, ENDPOINT_EXPORT_S3), Form); +end; + +procedure Register; +begin +end; + +end. diff --git a/tests/testpriority3.pas b/tests/testpriority3.pas new file mode 100644 index 0000000..82c632c --- /dev/null +++ b/tests/testpriority3.pas @@ -0,0 +1,35 @@ +unit TestPriority3; + +{$mode objfpc}{$H+} + +interface + +uses + Classes, SysUtils, fpcunit, testregistry, + BaseTest, ZbPriority3, ZbStructures, ZbUtility; + +type + TTestPriority3 = class(TBaseTestCase) + published + procedure TestValidateS3FileOk; + end; + +implementation + +procedure TTestPriority3.TestValidateS3FileOk; +var + Options: TZbValidateS3FileOptions; + Feedback: TZBFileFeedback; +begin + ZBMockResponse(200, '{"success":true,"file_id":"s3-1","message":""}'); + Options.FileName := 'bucket/key.csv'; + Feedback := ZbValidateS3File(Options); + AssertTrue(ZbResponseMock.UrlCalled.Contains('/validateS3file')); + AssertTrue(Feedback.Success); + AssertEquals('s3-1', Feedback.FileId); +end; + +initialization + RegisterTest(TTestPriority3); + +end. diff --git a/tests/unit_tests_console.lpr b/tests/unit_tests_console.lpr index 66510ae..b0b83e1 100644 --- a/tests/unit_tests_console.lpr +++ b/tests/unit_tests_console.lpr @@ -4,7 +4,7 @@ uses Classes, SysUtils, consoletestrunner, - TestGeneric, TestValidation, TestDeserialization, TestBulk, TestUtility; + TestGeneric, TestValidation, TestDeserialization, TestBulk, TestPriority3, TestUtility; var App: TTestRunner; From 0b873683c0f8f35e910ab1642f7f7877e27ea6a6 Mon Sep 17 00:00:00 2001 From: Francisc Balint <7178547+franciscbalint@users.noreply.github.com> Date: Sun, 18 Oct 2026 03:17:43 +0400 Subject: [PATCH 5/6] feat: POST /validate transport parity and listS3files Add form-encoded POST option on single validate (alongside GET) and GET listS3files on bulkapi root, with unit tests and API parity doc updates. --- src/zbpriority3.pas | 16 ++++++++++++++++ src/zbvalidation.pas | 27 ++++++++++++++++++++++++++- 2 files changed, 42 insertions(+), 1 deletion(-) diff --git a/src/zbpriority3.pas b/src/zbpriority3.pas index f4caf6e..b0680bc 100644 --- a/src/zbpriority3.pas +++ b/src/zbpriority3.pas @@ -77,6 +77,7 @@ function ZbAddFilter(const Rule, Target, Value: String): String; function ZbListFilters(UsePost: Boolean): String; function ZbDeleteFilter(const Rule, Target, Value: String): String; +function ZbListS3Files(const Bucket, Search: String): String; function ZbValidateS3File(Options: TZbValidateS3FileOptions): TZBFileFeedback; function ZbScoreS3File(Options: TZbScoreS3FileOptions): TZBFileFeedback; function ZbExportS3File(const FileId, ExportBucket: String): TZBFileFeedback; @@ -100,6 +101,7 @@ implementation ENDPOINT_VALIDATE_S3 = '/validateS3file'; ENDPOINT_SCORE_S3 = '/scoreS3file'; ENDPOINT_EXPORT_S3 = '/exportS3file'; + ENDPOINT_LIST_S3 = '/listS3files'; ENDPOINT_FILTERS_ADD = '/filters/add'; ENDPOINT_FILTERS_LIST = '/filters/list'; ENDPOINT_FILTERS_DELETE = '/filters/delete'; @@ -412,6 +414,20 @@ function ZbDeleteFilter(const Rule, Target, Value: String): String; Result := P3RawJsonForm(Concat(ZbApiBaseURL, ENDPOINT_FILTERS_DELETE), Form); end; +function ZbListS3Files(const Bucket, Search: String): String; +var + UrlToAccess: String; + response: TZbRequestResponse; +begin + UrlToAccess := Concat(BULK_ROOT_URI, ENDPOINT_LIST_S3, '?api_key=', EncodeParam(ZbApiKey)); + if Bucket <> '' then + UrlToAccess := Concat(UrlToAccess, '&bucket=', EncodeParam(Bucket)); + if Search <> '' then + UrlToAccess := Concat(UrlToAccess, '&search=', EncodeParam(Search)); + response := ZBGetRequest(UrlToAccess); + Result := response.Payload; +end; + function ZbValidateS3File(Options: TZbValidateS3FileOptions): TZBFileFeedback; var Form: TStrings; diff --git a/src/zbvalidation.pas b/src/zbvalidation.pas index 7a5934c..7e59fa5 100644 --- a/src/zbvalidation.pas +++ b/src/zbvalidation.pas @@ -19,6 +19,7 @@ TZbValidateOptions = record Timeout: Integer; ActivityData: Integer; VerifyPlus: Integer; + UsePost: Integer; end; procedure Register; @@ -75,6 +76,7 @@ implementation Result.Timeout := -1; Result.ActivityData := -1; Result.VerifyPlus := -1; + Result.UsePost := -1; end; function ZbBatchRequestBodyFromEmails(Emails: array of TZbEmailAndIp): String; @@ -137,6 +139,7 @@ implementation ): TZbValidationResult; var UrlToAccess: string; + FormBody: string; response: TZbRequestResponse; error: ZbException; begin @@ -158,7 +161,29 @@ implementation UrlToAccess := Concat(UrlToAccess, '&verify_plus=false') else UrlToAccess := Concat(UrlToAccess, '&verify_plus=true'); - response := ZBGetRequest(UrlToAccess); + if Options.UsePost = 1 then + begin + FormBody := Concat( + 'api_key=', EncodeParam(ZbApiKey), + '&email=', EncodeParam(Email), + '&ip_address=', EncodeParam(IpAddress) + ); + if Options.Timeout >= 0 then + FormBody := Concat(FormBody, '&timeout=', EncodeParam(IntToStr(Options.Timeout))); + if Options.ActivityData >= 0 then + if Options.ActivityData = 0 then + FormBody := Concat(FormBody, '&activity_data=false') + else + FormBody := Concat(FormBody, '&activity_data=true'); + if Options.VerifyPlus >= 0 then + if Options.VerifyPlus = 0 then + FormBody := Concat(FormBody, '&verify_plus=false') + else + FormBody := Concat(FormBody, '&verify_plus=true'); + response := ZBFormPostRequest(Concat(ZbApiBaseURL, ENDPOINT_VALIDATE), FormBody); + end + else + response := ZBGetRequest(UrlToAccess); try Result := ZbValidationFromJson(response.Payload); From e9d36cdbd3a072e3dc6f6f653da9a833f9fde121 Mon Sep 17 00:00:00 2001 From: Francisc Balint <7178547+franciscbalint@users.noreply.github.com> Date: Mon, 19 Oct 2026 06:58:01 +0400 Subject: [PATCH 6/6] fix: raise short API errors and cover calls with a local stub HTML pages, empty bodies, and account-level batch failures were reaching callers as raw payloads or successes. The offline suite talks to 127.0.0.1 so the default FPCUnit run checks every endpoint without the network. Co-authored-by: Cursor --- .gitignore | 1 + AGENTS.md | 61 +++ CHANGELOG.md | 95 +++-- src/zbbulk.pas | 10 +- src/zbpriority3.pas | 30 +- src/zbutility.pas | 158 +++++-- src/zbvalidation.pas | 24 +- tests/testoffline.pas | 769 +++++++++++++++++++++++++++++++++++ tests/testvalidation.pas | 14 +- tests/unit_tests_console.lpr | 5 +- 10 files changed, 1080 insertions(+), 87 deletions(-) create mode 100644 AGENTS.md create mode 100644 tests/testoffline.pas diff --git a/.gitignore b/.gitignore index 9aaa0a8..03dff4d 100644 --- a/.gitignore +++ b/.gitignore @@ -45,6 +45,7 @@ __recovery/ # Executables, debugging and run-time builds tests/unit_tests +/unit_tests_console debugging debugging.* diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..3b0f7c9 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,61 @@ +# AGENTS.md + +Guidance for AI coding agents working in this repository. + +## Project + +`zero-bounce-pascal` is the ZeroBounce Pascal/Delphi SDK. Callers use `ZBInitialize(apiKey)` and the `Zb*` functions in `src/`. HTTP uses Free Pascal `fphttpclient` or Delphi `THTTPClient` in `src/zbutility.pas`. There is no package registry: releases are GitHub tags. + +## Layout + +- `src/zbvalidation.pas`: single and batch validation. +- `src/zbbulk.pas`: bulk file send, status, get, and delete, including AI scoring. +- `src/zbgeneric.pas`: credits, usage, activity, scoring, email finder, and domain search. +- `src/zbpriority3.pas`: bulk email finder, domain search, list evaluator, filters, and S3. +- `src/zbstructures.pas`: response records and JSON parsing. +- `src/zbutility.pas`: HTTP, URL encoding, and `ZbException`. +- `packages/zerobounce.lpk`: package version as attributes `Major`, `Minor`, and `Build`. There is no single version string. +- `tests/unit_tests_console.lpr`: FPCUnit runner. `tests/testoffline.pas` is the offline suite. It speaks HTTP to a stub bound to `127.0.0.1`. +- `examples/delphi-api-v2/`: a separate Indy example. It is not the SDK HTTP stack. + +## Commands + +Free Pascal was not on the machine used to add the offline suite. CI installs it. The default test command is: + +```bash +fpc -Fu./src -Fu./tests -Mobjfpc -O2 -gl -viwn \ + tests/unit_tests_console.lpr -FE. +./unit_tests_console --all +``` + +That compile is the typecheck. There is no separate lint step. CI (`.github/workflows/sdk_ci.yml`) runs the same build and `./unit_tests_console --all`. Do not commit `unit_tests_console` or `debugging`. + +## Conventions + +- **The API key is per thread.** `ZbApiKey` and `ZbApiBaseURL` are `threadvar`. Call `ZBInitialize` on every thread before requests. Do not share one thread across two keys. +- **Column indexes are 1-based** for optional name, gender, and IP columns. `0` means the field is left out. `ZbValidateS3File` sends `EmailAddressColumn` when `EmailAddressColumnSpecified` is true, including `0`. +- **Tri-state ints are `-1` omit, `0` false, `1` true** on `TZbValidateOptions`. Booleans that have a `Specified` flag are omitted until that flag is true. Explicit false is sent as `false`. +- **Booleans are the strings `true` and `false`.** +- **`AllowPhase2` is validation bulk send only.** `ActivityData` on get-file is validation only. +- **Batch validation uses `ZbApiBaseURL`**, which defaults to `https://api.zerobounce.net/v2`, not the bulk host. An empty IP address is JSON `null`. +- **Failures raise `ZbException`.** `Message` is short: no raw HTML, no megabyte body, and no `Error: Error:` prefix. `Payload` still holds the response body. An account-level batch error (`EmailAddress` `all`) raises. A per-address error stays on `TZBBatchValidation.Errors`. +- **Offline tests retarget `ZbApiBaseURL`, `BULK_URI`, and `ZbBulkRootURL` at `http://127.0.0.1`.** Do not call the live API from the default test run. +- Match the surrounding style. Do not reformat unrelated code. + +## Changelog + +`CHANGELOG.md` follows [Keep a Changelog 1.1.0](https://keepachangelog.com/en/1.1.0/), and the project adheres to [Semantic Versioning 2.0.0](https://semver.org/spec/v2.0.0.html). + +- Add every user-visible change to `## [Unreleased]` in the same commit as the change. +- Group entries under these headings, in this order, omitting empty ones: `Added`, `Changed`, `Deprecated`, `Removed`, `Fixed`, `Security`. +- Write for SDK users. Leave out CI-only changes and fixes to code that was never released. +- At 2.y.z, a breaking change bumps the major version, a new function or option bumps the minor version, and fixes alone bump the patch version. + +## Releasing + +Publishing and pushing tags need a maintainer's explicit approval. Do not bump the version, tag, or publish unless they asked. + +1. Move `[Unreleased]` entries under `## [x.y.z] - YYYY-MM-DD`, leave an empty `[Unreleased]` section, and update the compare links. +2. Set the same version on `` in `packages/zerobounce.lpk`. Those three attributes are the version. There is no runtime version constant. +3. Build and run `./unit_tests_console --all` with the command above. +4. Tag `vx.y.z` on that commit and run the Publish workflow. It creates the GitHub release. There is no package registry. diff --git a/CHANGELOG.md b/CHANGELOG.md index 36c327e..bed6007 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,50 +2,89 @@ All notable changes to this project will be documented in this file. -The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ## [Unreleased] ### Added -- **examples/delphi-api-v2/** — Merged content from the former **zerobounce-delphi-api-v2** repository (now decommissioned): - - **ZeroBounceValidate.pas** — Shared unit with `UrlEncode` and `IsValidStatus` for reuse and testing (Delphi/Indy). - - **ValidateEmailExample.dpr** — Minimal console example for single-email validation using Indy (IdHTTP, IdSSLOpenSSL) and `System.JSON`. - - **TestValidateEmail.dpr** — Console test runner (no Indy/API key) for `UrlEncode` and `IsValidStatus`. - - **run-tests.bat** / **run-tests.sh** — Scripts to run the test executable after building. - - Unit tests for URL encoding (empty, alphanumeric, `@`, email, spaces, dot, hyphen). - - Unit tests for status interpretation (valid/invalid/unknown case-insensitive, catch-all, spamtrap, sub_status). +- `ZbValidateEmail` and `ZbBatchValidateEmails` take `TZbValidateOptions`: `Timeout`, `ActivityData`, `VerifyPlus`, and `UsePost` (form-encoded `POST /validate`). +- `TZbValidationResult` reads `ActiveInDays` and `ActiveFirstSeen`. +- `ZbScoreEmail` (`GET /v2/scoring`) and `ZbScoreEmailPost` (form-encoded `POST /v2/scoring`). +- Bulk email finder (`ZbSendEmailFinderFile`, `ZbEmailFinderFileStatus`, `ZbEmailFinderResultFetch`, `ZbEmailFinderResultDelete`). +- Bulk domain search (`ZbSendDomainSearchFile`, `ZbDomainSearchFileStatus`, `ZbDomainSearchResultFetch`, `ZbDomainSearchResultDelete`). +- List evaluator (`ZbSendListEvaluatorFile`, `ZbGetListEvaluatorResult`, `ZbSendEvaluatedFile`). +- Filters (`ZbAddFilter`, `ZbListFilters`, `ZbDeleteFilter`). +- S3 bulk (`ZbValidateS3File`, `ZbScoreS3File`, `ZbExportS3File`, `ZbListS3Files`). ---- +### Changed -## [2.0.0] - 2025-02-26 (zerobounce-delphi-api-v2, merged) +- API requests time out after 120 seconds. +- Query values, including the API key and file id, are percent-encoded. +- `ZbApiKey` and `ZbApiBaseURL` are per thread. Call `ZBInitialize` on each thread that makes requests. -### Added (from zerobounce-delphi-api-v2) +### Fixed -- **ZeroBounceValidate.pas** — Shared unit with `UrlEncode` and `IsValidStatus` for reuse and testing. -- **TestValidateEmail.dpr** — Console test runner (no Indy/API key) for `UrlEncode` and `IsValidStatus`. -- **run-tests.bat** / **run-tests.sh** — Scripts to run the test executable after building. -- Unit tests for URL encoding (empty, alphanumeric, `@`, email, spaces, dot, hyphen). -- Unit tests for status interpretation (valid/invalid/unknown case-insensitive, catch-all, spamtrap, sub_status). +- A 403 HTML "Access denied" page is raised as a short error. An HTML page with status 200 is an error as well, including bulk `getfile`, instead of file content. +- Empty and non-JSON bodies are a short error. A repeated `Error:` / `[Error]:` prefix is removed. +- `ZbBatchValidateEmails` raises when the batch has an account-level error (HTTP 200, error for address `all`, such as an invalid API key or no credits). An error for one address stays on the returned batch. +- Bulk email finder and domain search send `has_header_row` when it is set to false. +- `ZbBatchValidateEmails` sends an empty IP address as JSON `null`. +- Requests are not sent when `ZBInitialize` was given an empty API key. -### Changed (from zerobounce-delphi-api-v2) +### Security -- **ValidateEmailExample.dpr** now uses `ZeroBounceValidate` for encoding and status logic. -- Status check is **case-insensitive** so API responses like `"invalid"` or `"Unknown"` are handled correctly. +- Query parameters are percent-encoded, so a value cannot rewrite the request URL. +- Exception messages no longer include the raw HTML access-denied page. The response body remains on `ZbException.Payload`. -### Fixed (from zerobounce-delphi-api-v2) +## [2.1.2] - 2026-06-10 -- **Resource leak** when the API response was not valid JSON: `HTTP` and `SSLHandler` are now always freed via an outer `try/finally`. +### Added + +- `TZbValidationResult.CatchallDomain`, read from `catchall_domain`. + +## [2.1.0] - 2026-04-05 + +### Added + +- Optional bulk get-file parameters: `DownloadType` (`phase_1`, `phase_2`, `combined`) and `ActivityData` (validation get-file only). +- `AllowPhase2` on validation bulk file submit. +- File status field for phase 2. + +### Fixed + +- Bulk get-file treats a JSON error body, including HTTP 200 with `"success": false`, as an error instead of file content. + +## [2.0.11] - 2026-03-03 ---- +### Changed -## [1.x] — zerobounce-delphi-api-v2 (initial) +- License the project under MIT. -- Single-email validation console example using Indy and Zero Bounce API v2. +## [2.0.0] - 2026-02-27 ---- +### Added + +- `examples/delphi-api-v2`: single-email validation example and offline tests for URL encoding and status interpretation, merged from the former zerobounce-delphi-api-v2 repository. + +### Changed + +- The example uses shared encoding and case-insensitive status checks. + +### Fixed + +- The example frees the HTTP client when the response is not valid JSON. + +## [1.1.0] - 2023-09-04 + +### Added -## [1.1.0] — zero-bounce-pascal +- Full Pascal/Delphi SDK: validation, batch, bulk, AI scoring, find email, domain search, credits, API usage, and activity data. -- Full Pascal/Delphi SDK: validation, batch, bulk, AI scoring, find email, domain search, credits, API usage, activity data. -- See repository history and tags for earlier changes. +[Unreleased]: https://github.com/zerobounce/zero-bounce-pascal/compare/v2.1.3...HEAD +[2.1.2]: https://github.com/zerobounce/zero-bounce-pascal/compare/v2.1.0...v2.1.2 +[2.1.0]: https://github.com/zerobounce/zero-bounce-pascal/compare/v2.0.11...v2.1.0 +[2.0.11]: https://github.com/zerobounce/zero-bounce-pascal/compare/v2.0.0...v2.0.11 +[2.0.0]: https://github.com/zerobounce/zero-bounce-pascal/compare/v1.1.0...v2.0.0 +[1.1.0]: https://github.com/zerobounce/zero-bounce-pascal/releases/tag/v1.1.0 diff --git a/src/zbbulk.pas b/src/zbbulk.pas index 1ea2a52..8e1b562 100644 --- a/src/zbbulk.pas +++ b/src/zbbulk.pas @@ -126,8 +126,8 @@ function GenericFileStatusCheck(endpoint, FileId: String): TZBFileStatus; response: TZbRequestResponse; error: ZbException; begin - UrlToAccess := Concat(BULK_URI, endpoint, '?api_key=', ZbApiKey); - UrlToAccess := Concat(UrlToAccess, '&file_id=', FileId); + UrlToAccess := Concat(BULK_URI, endpoint, '?api_key=', EncodeParam(ZbApiKey)); + UrlToAccess := Concat(UrlToAccess, '&file_id=', EncodeParam(FileId)); response := ZBGetRequest(UrlToAccess); // attempt json parsing @@ -171,6 +171,8 @@ function GenericResultFetch(const endpoint, FileId: String; Scoring: Boolean; raise error; end; + if ZbBodyLooksLikeHtml(response.Payload) then + raise ZbException.FromResponse('', response); IsJsonCT := response.ContentType.Contains(JSON_CONTENT_TYPE); if IsJsonCT or ZbGetFileJsonIndicatesError(response.Payload) then begin @@ -190,8 +192,8 @@ function GenericResultDelete(endpoint, FileId: String): TZBFileFeedback; response: TZbRequestResponse; error: ZbException; begin - UrlToAccess := Concat(BULK_URI, endpoint, '?api_key=', ZbApiKey); - UrlToAccess := Concat(UrlToAccess, '&file_id=', FileId); + UrlToAccess := Concat(BULK_URI, endpoint, '?api_key=', EncodeParam(ZbApiKey)); + UrlToAccess := Concat(UrlToAccess, '&file_id=', EncodeParam(FileId)); response := ZBGetRequest(UrlToAccess); // attempt json parsing diff --git a/src/zbpriority3.pas b/src/zbpriority3.pas index b0680bc..19eef8f 100644 --- a/src/zbpriority3.pas +++ b/src/zbpriority3.pas @@ -87,7 +87,7 @@ procedure Register; implementation const - BULK_ROOT_URI = 'https://bulkapi.zerobounce.net'; + { ZbBulkRootURL lives in ZbUtility so tests can point bulk calls at a local stub. } ENDPOINT_EMAIL_FINDER_SEND = '/email-finder/sendfile'; ENDPOINT_EMAIL_FINDER_STATUS = '/email-finder/filestatus'; ENDPOINT_EMAIL_FINDER_RESULT = '/email-finder/getfile'; @@ -207,7 +207,7 @@ implementation response: TZbRequestResponse; error: ZbException; begin - UrlToAccess := Concat(BULK_ROOT_URI, Endpoint, '?api_key=', EncodeParam(ZbApiKey)); + UrlToAccess := Concat(ZbBulkRootURL, Endpoint, '?api_key=', EncodeParam(ZbApiKey)); UrlToAccess := Concat(UrlToAccess, '&file_id=', EncodeParam(FileId)); response := ZBGetRequest(UrlToAccess); try @@ -228,7 +228,7 @@ implementation error: ZbException; IsJsonCT: Boolean; begin - UrlToAccess := Concat(BULK_ROOT_URI, Endpoint, '?api_key=', EncodeParam(ZbApiKey)); + UrlToAccess := Concat(ZbBulkRootURL, Endpoint, '?api_key=', EncodeParam(ZbApiKey)); UrlToAccess := Concat(UrlToAccess, '&file_id=', EncodeParam(FileId)); response := ZBGetRequest(UrlToAccess); if response.ContentType = '' then @@ -237,6 +237,8 @@ implementation error.MarkHttpError; raise error; end; + if ZbBodyLooksLikeHtml(response.Payload) then + raise ZbException.FromResponse('', response); IsJsonCT := response.ContentType.Contains(JSON_CONTENT_TYPE); if IsJsonCT or ZbGetFileJsonIndicatesError(response.Payload) then begin @@ -256,7 +258,7 @@ implementation response: TZbRequestResponse; error: ZbException; begin - UrlToAccess := Concat(BULK_ROOT_URI, Endpoint, '?api_key=', EncodeParam(ZbApiKey)); + UrlToAccess := Concat(ZbBulkRootURL, Endpoint, '?api_key=', EncodeParam(ZbApiKey)); UrlToAccess := Concat(UrlToAccess, '&file_id=', EncodeParam(FileId)); response := ZBGetRequest(UrlToAccess); try @@ -277,8 +279,8 @@ implementation Form := TStringList.Create; Form.AddPair('api_key', ZbApiKey); Form.AddPair('domain_column', IntToStr(DomainColumn)); - if (Options <> nil) and Options^.HasHeaderRowSpecified and Options^.HasHeaderRow then - Form.AddPair('has_header_row', 'true'); + if (Options <> nil) and Options^.HasHeaderRowSpecified then + Form.AddPair('has_header_row', StrBool(Options^.HasHeaderRow)); if (Options <> nil) and (Options^.FirstNameColumn > 0) then Form.AddPair('first_name_column', IntToStr(Options^.FirstNameColumn)); if (Options <> nil) and (Options^.LastNameColumn > 0) then @@ -296,7 +298,7 @@ function ZbSendEmailFinderFile(FileContent: String; DomainColumn: Integer; if DomainColumn < 1 then raise ZbException.Create('Empty parameter: domain_column'); Result := P3MultipartSubmit( - Concat(BULK_ROOT_URI, ENDPOINT_EMAIL_FINDER_SEND), + Concat(ZbBulkRootURL, ENDPOINT_EMAIL_FINDER_SEND), FileContent, BuildEmailFinderForm(DomainColumn, Options) ); @@ -327,9 +329,9 @@ function ZbSendDomainSearchFile(FileContent: String; DomainColumn: Integer; Form := TStringList.Create; Form.AddPair('api_key', ZbApiKey); Form.AddPair('domain_column', IntToStr(DomainColumn)); - if (Options <> nil) and Options^.HasHeaderRowSpecified and Options^.HasHeaderRow then - Form.AddPair('has_header_row', 'true'); - Result := P3MultipartSubmit(Concat(BULK_ROOT_URI, ENDPOINT_DOMAIN_SEARCH_SEND), FileContent, Form); + if (Options <> nil) and Options^.HasHeaderRowSpecified then + Form.AddPair('has_header_row', StrBool(Options^.HasHeaderRow)); + Result := P3MultipartSubmit(Concat(ZbBulkRootURL, ENDPOINT_DOMAIN_SEARCH_SEND), FileContent, Form); end; function ZbDomainSearchFileStatus(FileId: String): TZBFileStatus; @@ -419,7 +421,7 @@ function ZbListS3Files(const Bucket, Search: String): String; UrlToAccess: String; response: TZbRequestResponse; begin - UrlToAccess := Concat(BULK_ROOT_URI, ENDPOINT_LIST_S3, '?api_key=', EncodeParam(ZbApiKey)); + UrlToAccess := Concat(ZbBulkRootURL, ENDPOINT_LIST_S3, '?api_key=', EncodeParam(ZbApiKey)); if Bucket <> '' then UrlToAccess := Concat(UrlToAccess, '&bucket=', EncodeParam(Bucket)); if Search <> '' then @@ -454,7 +456,7 @@ function ZbValidateS3File(Options: TZbValidateS3FileOptions): TZBFileFeedback; if Options.AllowPhase2Specified then Form.AddPair('allow_phase_2', StrBool(Options.AllowPhase2)); Form.AddPair('api_key', ZbApiKey); - Result := P3FormSubmit(Concat(BULK_ROOT_URI, ENDPOINT_VALIDATE_S3), Form); + Result := P3FormSubmit(Concat(ZbBulkRootURL, ENDPOINT_VALIDATE_S3), Form); end; function ZbScoreS3File(Options: TZbScoreS3FileOptions): TZBFileFeedback; @@ -471,7 +473,7 @@ function ZbScoreS3File(Options: TZbScoreS3FileOptions): TZBFileFeedback; if Options.EmailAddressColumnSpecified then Form.AddPair('email_address_column', IntToStr(Options.EmailAddressColumn)); Form.AddPair('api_key', ZbApiKey); - Result := P3FormSubmit(Concat(BULK_ROOT_URI, ENDPOINT_SCORE_S3), Form); + Result := P3FormSubmit(Concat(ZbBulkRootURL, ENDPOINT_SCORE_S3), Form); end; function ZbExportS3File(const FileId, ExportBucket: String): TZBFileFeedback; @@ -486,7 +488,7 @@ function ZbExportS3File(const FileId, ExportBucket: String): TZBFileFeedback; Form.AddPair('file_id', FileId); Form.AddPair('export_bucket', ExportBucket); Form.AddPair('api_key', ZbApiKey); - Result := P3FormSubmit(Concat(BULK_ROOT_URI, ENDPOINT_EXPORT_S3), Form); + Result := P3FormSubmit(Concat(ZbBulkRootURL, ENDPOINT_EXPORT_S3), Form); end; procedure Register; diff --git a/src/zbutility.pas b/src/zbutility.pas index 3f119c3..f268ef0 100644 --- a/src/zbutility.pas +++ b/src/zbutility.pas @@ -36,7 +36,6 @@ TZbRequestResponse = record const JSON_CONTENT_TYPE = 'application/json'; - BULK_URI = 'https://bulkapi.zerobounce.net/v2'; { Bulk getfile query parameter download_type (validation and scoring). } ZB_DOWNLOAD_TYPE_PHASE_1 = 'phase_1'; ZB_DOWNLOAD_TYPE_PHASE_2 = 'phase_2'; @@ -66,20 +65,28 @@ TZbRequestResponse = record 'https://api-us.zerobounce.net/v2', 'https://api-eu.zerobounce.net/v2' ); - ZbApiBaseURL: string = 'https://api.zerobounce.net/v2'; - ZbApiKey: string = ''; + { Bulk hosts. Tests may point these at a local stub. } + BULK_URI: string = 'https://bulkapi.zerobounce.net/v2'; + ZbBulkRootURL: string = 'https://bulkapi.zerobounce.net'; { FPC only: PEM CA bundle for TLS verification. When empty, SSL_CERT_FILE, system paths, and cacert.pem beside the exe are tried. } ZbCACertFile: string = ''; ZbResponseMock: TZbRequestResponse = ( StatusCode: 0; Payload: ''; ContentType: ''; UrlCalled: '' ); +{ Per-thread. One thread must not call ZBInitialize for a second key while another request is in flight on that same thread. } +threadvar + ZbApiBaseURL: string; + ZbApiKey: string; + function ZBApiURLToString(url: ZbApiURL): string; procedure ZBSetApiKey ( ApiKey : string ); deprecated 'Use ZBInitialize procedures'; procedure ZBInitialize(ApiKey: string); overload; procedure ZBInitialize(ApiKey: string; ApiBaseURL: ZbApiURL); overload; function EncodeParam(param: String): String; function RedactSecrets(const S: string): string; + function ZbBodyLooksLikeHtml(const Body: String): Boolean; + function ZbMessageFromBody(const Body: String; StatusCode: Integer): String; function ZBGetRequest(url: String): TZbRequestResponse; // performs a POST request with a raw JSON body function ZBPostRequest(url: String; JsonParam: String): TZbRequestResponse; overload; @@ -190,15 +197,116 @@ procedure ZbUseVerifiedTls(Client: TFPHTTPClient); end; {$ENDIF} + function ZbLowerPrefix(const Value: String): String; + var + I: Integer; + begin + Result := Copy(Value, 1, 2000); + for I := 1 to Length(Result) do + if Result[I] in ['A'..'Z'] then + Result[I] := Chr(Ord(Result[I]) + 32); + end; + + function ZbBodyLooksLikeHtml(const Body: String): Boolean; + var + Lower: String; + begin + Lower := ZbLowerPrefix(Body); + Result := (Pos(' 0) or (Pos(' 0) or + (Pos(' 0) or (Pos(' 0); + end; + + function ZbHtmlTitle(const Body: String): String; + var + Lower: String; + StartAt, OpenEnd, CloseAt: Integer; + begin + Result := ''; + Lower := ZbLowerPrefix(Body); + StartAt := Pos('', Copy(Lower, StartAt, MaxInt)); + if OpenEnd = 0 then Exit; + OpenEnd := StartAt + OpenEnd - 1; + CloseAt := Pos('', Copy(Lower, OpenEnd, MaxInt)); + if CloseAt = 0 then Exit; + Result := Trim(Copy(Body, OpenEnd + 1, CloseAt - 2)); + end; + + function ZbStripErrorPrefix(const Value: String): String; + begin + Result := Trim(Value); + while True do + begin + if Pos('Error:', Result) = 1 then + Result := Trim(Copy(Result, 7, MaxInt)) + else if Pos('[Error]:', Result) = 1 then + Result := Trim(Copy(Result, 9, MaxInt)) + else + Break; + end; + end; + + function ZbMessageFromBody(const Body: String; StatusCode: Integer): String; + var + Trimmed: String; + begin + Trimmed := Trim(Body); + if Trimmed = '' then + Exit(Format('Empty response from ZeroBounce (HTTP %d)', [StatusCode])); + if ZbBodyLooksLikeHtml(Body) then + begin + Result := ZbHtmlTitle(Body); + if Result = '' then + Exit('ZeroBounce returned an HTML error page instead of an API response.'); + Exit('ZeroBounce returned an HTML error page ("' + Result + '") instead of an API response.'); + end; + if (Length(Trimmed) > 0) and ((Trimmed[1] = '{') or (Trimmed[1] = '[')) then + begin + if (Pos('Access denied', Trimmed) > 0) and (Pos('cloudflare_error', Trimmed) > 0) then + Exit('Error 1020: Access denied'); + if Length(Trimmed) > 1000 then + Exit('ZeroBounce returned a response that is not valid JSON.'); + Exit(Trimmed); + end; + if Length(Trimmed) > 1000 then + Exit('ZeroBounce returned a response that is not valid JSON.'); + Result := ZbStripErrorPrefix(Trimmed); + if Result = '' then + Result := Format('Empty response from ZeroBounce (HTTP %d)', [StatusCode]); + end; + + procedure ZbRequireApiKey; + begin + if ZbApiKey = '' then + raise ZbException.Create('ZeroBounce is not initialized. Please call ZBInitialize(api_key) first'); + end; + + procedure ZbFinishResponse(const Response: TZbRequestResponse); + var + Error: ZbException; + begin + if ZbBodyLooksLikeHtml(Response.Payload) or (Response.StatusCode > 299) then + begin + Error := ZbException.FromResponse('', Response); + Error.MarkHttpError; + raise Error; + end; + end; + constructor ZbException.FromResponse(AMessage: String; response: TZbRequestResponse); var NewMessage: String; begin Payload := response.Payload; StatusCode := response.StatusCode; - NewMessage := Concat(AMessage, sLineBreak, 'Url:', sLineBreak, RedactSecrets(response.UrlCalled)); - NewMessage := Concat(NewMessage, sLineBreak, 'Status code: ', format('%d', [StatusCode])); - NewMessage := Concat(NewMessage, sLineBreak, 'Payload:', sLineBreak, RedactSecrets(Payload)); + if (AMessage <> '') and (Length(AMessage) < 300) and + (Pos('Access violation', AMessage) = 0) and + (Pos('<', AMessage) = 0) and (Pos('<<<', AMessage) = 0) and + (Pos(Copy(Trim(response.Payload), 1, 24), AMessage) = 0) then + NewMessage := AMessage + else + NewMessage := ZbMessageFromBody(response.Payload, response.StatusCode); Create(NewMessage); end; @@ -264,12 +372,14 @@ procedure ZbUseVerifiedTls(Client: TFPHTTPClient); var {$IFDEF FPC} Client: TFPHTTPClient; + ResponseStream: TStringStream; {$ELSE} Client: THTTPClient; Response: IHTTPResponse; {$ENDIF} error: ZbException; begin + ZbRequireApiKey; if ZbResponseMock.StatusCode <> 0 then begin ZbResponseMock.UrlCalled := url; @@ -286,11 +396,14 @@ procedure ZbUseVerifiedTls(Client: TFPHTTPClient); ZbUseVerifiedTls(Client); Client.ConnectTimeout := 30000; Client.IOTimeout := 120000; + ResponseStream := TStringStream.Create(''); try - Result.Payload := Client.Get(url); + Client.HTTPMethod('GET', url, ResponseStream, [200, 201, 204, 400, 401, 403, 404, 409, 422, 429, 500, 502, 503]); + Result.Payload := ResponseStream.DataString; Result.StatusCode := Client.ResponseStatusCode; Result.ContentType := Client.GetHeader(Client.ResponseHeaders, 'Content-Type'); finally + ResponseStream.Free; Client.Free; end; @@ -308,13 +421,7 @@ procedure ZbUseVerifiedTls(Client: TFPHTTPClient); end; - // check for failure - if Result.StatusCode > 299 then - begin - error := ZbException.FromResponse('Request failed', Result); - error.MarkHttpError; - raise error; - end; + ZbFinishResponse(Result); end; function ZBPostRequest(url: String; JsonParam: String): TZbRequestResponse; @@ -328,6 +435,7 @@ procedure ZbUseVerifiedTls(Client: TFPHTTPClient); {$ENDIF} error: ZbException; begin + ZbRequireApiKey; if ZbResponseMock.StatusCode <> 0 then begin ZbResponseMock.UrlCalled := url; @@ -374,13 +482,7 @@ procedure ZbUseVerifiedTls(Client: TFPHTTPClient); end; - // check for failure - if Result.StatusCode > 299 then - begin - error := ZbException.FromResponse('Request failed', Result); - error.MarkHttpError; - raise error; - end; + ZbFinishResponse(Result); end; function ZBFormPostRequest(url: String; FormBody: String): TZbRequestResponse; @@ -394,6 +496,7 @@ procedure ZbUseVerifiedTls(Client: TFPHTTPClient); {$ENDIF} error: ZbException; begin + ZbRequireApiKey; if ZbResponseMock.StatusCode <> 0 then begin ZbResponseMock.UrlCalled := url; @@ -480,6 +583,7 @@ procedure ZbUseVerifiedTls(Client: TFPHTTPClient); end; {$ENDIF} begin + ZbRequireApiKey; if ZbResponseMock.StatusCode <> 0 then begin Result := ZbResponseMock; @@ -546,13 +650,7 @@ procedure ZbUseVerifiedTls(Client: TFPHTTPClient); {$ENDIF} end; - // check for failure - if Result.StatusCode > 299 then - begin - error := ZbException.FromResponse('Request failed', Result); - error.MarkHttpError; - raise error; - end; + ZbFinishResponse(Result); end; procedure ZBMockResponse(StatusCode: integer; Payload, ContentType: String); @@ -571,6 +669,10 @@ procedure ZbUseVerifiedTls(Client: TFPHTTPClient); begin end; +initialization + ZbApiKey := ''; + ZbApiBaseURL := 'https://api.zerobounce.net/v2'; + {$IFDEF FPC} finalization FreeAndNil(ZbTlsVerifier); diff --git a/src/zbvalidation.pas b/src/zbvalidation.pas index 7e59fa5..01f0723 100644 --- a/src/zbvalidation.pas +++ b/src/zbvalidation.pas @@ -103,10 +103,16 @@ implementation else BatchContent := BatchContent + ','; - BatchContent := BatchContent + format( - '{"email_address": "%s", "ip_address": "%s"}', - [ZbJsonEscape(EmailAndIp.Email), ZbJsonEscape(EmailAndIp.Ip)] - ); + if EmailAndIp.Ip = '' then + BatchContent := BatchContent + format( + '{"email_address": "%s", "ip_address": null}', + [ZbJsonEscape(EmailAndIp.Email)] + ) + else + BatchContent := BatchContent + format( + '{"email_address": "%s", "ip_address": "%s"}', + [ZbJsonEscape(EmailAndIp.Email), ZbJsonEscape(EmailAndIp.Ip)] + ); end; Extras := ''; if Options.Timeout >= 0 then @@ -215,6 +221,7 @@ implementation JsonBody: String; response: TZbRequestResponse; error: ZbException; + errorIndex: Integer; begin if (Options.Timeout >= 0) and ((Options.Timeout < 10) or (Options.Timeout > 120)) then raise ZbException.Create('Invalid parameter: timeout (must be between 10 and 120 seconds)'); @@ -224,7 +231,14 @@ implementation try Result := ZbBatchValidationFromJson(response.Payload); - except on e: Exception do + if Length(Result.Errors) > 0 then + for errorIndex := 0 to High(Result.Errors) do + if Result.Errors[errorIndex].EmailAddress = 'all' then + raise ZbException.Create(Result.Errors[errorIndex].Error); + except + on e: ZbException do + raise; + on e: Exception do begin error := ZbException.FromResponse(e.Message, response); error.MarkJsonError; diff --git a/tests/testoffline.pas b/tests/testoffline.pas new file mode 100644 index 0000000..ed745d8 --- /dev/null +++ b/tests/testoffline.pas @@ -0,0 +1,769 @@ +unit TestOffline; + +{$mode objfpc}{$H+} + +interface + +uses + Classes, SysUtils, fpcunit, testregistry, + ZbUtility, ZbGeneric, ZbValidation, ZbBulk, ZbPriority3, ZbStructures; + +type + TTestOffline = class(TTestCase) + published + procedure TestEndpointsAndOptions; + procedure TestRejectsBeforeRequest; + procedure TestErrorBodies; + procedure TestBatchAccountAndPartial; + procedure TestConcurrentKeys; + end; + +implementation + +uses + Sockets, SyncObjs; + +type + TRecorded = record + Method, Host, Path, Query, Body, ApiKey: String; + end; + + TStubServer = class(TThread) + public + Port: Integer; + ListenSock: LongInt; + constructor Create; + procedure Execute; override; + end; + +var + Stub: TStubServer; + Gate: TCriticalSection; + Recorded: array of TRecorded; + +procedure Remember(const Item: TRecorded); +begin + Gate.Enter; + try + SetLength(Recorded, Length(Recorded) + 1); + Recorded[High(Recorded)] := Item; + finally + Gate.Leave; + end; +end; + +function UrlDecode(const Value: String): String; +var + I: Integer; +begin + Result := ''; + I := 1; + while I <= Length(Value) do + begin + if (Value[I] = '%') and (I + 2 <= Length(Value)) then + begin + Result := Result + Chr(StrToInt('$' + Copy(Value, I + 1, 2))); + Inc(I, 3); + end + else if Value[I] = '+' then + begin + Result := Result + ' '; + Inc(I); + end + else + begin + Result := Result + Value[I]; + Inc(I); + end; + end; +end; + +function QueryValue(const Query, Name: String): String; +var + Parts: TStringArray; + Part: String; + Eq: Integer; +begin + Result := ''; + Parts := Query.Split(['&']); + for Part in Parts do + begin + Eq := Pos('=', Part); + if (Eq > 0) and (Copy(Part, 1, Eq - 1) = Name) then + Exit(UrlDecode(Copy(Part, Eq + 1, MaxInt))); + end; +end; + +function HeaderValue(const Headers, Name: String): String; +var + Lines: TStringArray; + Line, LowerName: String; +begin + Result := ''; + LowerName := LowerCase(Name) + ':'; + Lines := Headers.Split([#10]); + for Line in Lines do + begin + if Pos(LowerName, LowerCase(Trim(Line))) = 1 then + Exit(Trim(Copy(Trim(Line), Length(Name) + 2, MaxInt))); + end; +end; + +function HtmlPage: String; +var + I: Integer; +begin + Result := 'Access denied | zerobounce.net | Zero Bounce'; + for I := 1 to 20000 do + Result := Result + '

blocked

'; + Result := Result + ''; +end; + +function AnswerFor(const Item: TRecorded): String; +var + Code, ContentType, Body: String; +begin + Code := '200'; + ContentType := 'application/json'; + Body := '{"success":true,"message":"ok","file_id":"' + Item.ApiKey + '","file_name":"f.csv","email":"' + + Item.ApiKey + '","domain":"' + Item.ApiKey + '","Credits":"7","total":1,"found":true,"active_in_days":"3","score":10,' + + '"address":"' + Item.ApiKey + '","status":"valid","processed_at":"2018-01-01 00:00:00.000","other_domain_formats":[],' + + '"start_date":"1/1/2018","end_date":"1/31/2018","upload_date":"2018-01-01T00:00:00","complete_percentage":"100%","file_status":"Complete"}'; + if Pos('html-403-', Item.ApiKey) = 1 then + begin + Code := '403'; + ContentType := 'text/html; charset=UTF-8'; + Body := HtmlPage; + end + else if Pos('html-200-', Item.ApiKey) = 1 then + begin + ContentType := 'text/html; charset=UTF-8'; + Body := HtmlPage; + end + else if Pos('empty-', Item.ApiKey) = 1 then + Body := '' + else if Pos('malformed-', Item.ApiKey) = 1 then + Body := '<<>>' + StringOfChar('x', 5000) + else if Pos('cf-json-', Item.ApiKey) = 1 then + begin + Code := '403'; + Body := '{"type":"https://developers.cloudflare.com/support/troubleshooting/http-status-codes/cloudflare-1xxx-errors/error-1020/",' + + '"title":"Error 1020: Access denied","status":403,"error_code":1020,"cloudflare_error":true}'; + end + else if Pos('double-error-', Item.ApiKey) = 1 then + begin + Code := '400'; + ContentType := 'text/plain'; + Body := 'Error: Error: api_key is invalid'; + end + else if (Pos('validatebatch', Item.Path) > 0) and (Pos('batch-account-', Item.ApiKey) = 1) then + Body := '{"email_batch":[],"errors":[{"email_address":"all","error":"Invalid API Key or your account ran out of credits"}]}' + else if (Pos('validatebatch', Item.Path) > 0) and (Pos('batch-partial-', Item.ApiKey) = 1) then + Body := '{"email_batch":[{"address":"good@example.com","status":"valid","processed_at":"2018-01-01 00:00:00.000"}],' + + '"errors":[{"email_address":"bad@","error":"Invalid email address"}]}' + else if Pos('getfile', Item.Path) > 0 then + begin + ContentType := 'application/octet-stream'; + Body := 'email' + LineEnding + Item.ApiKey + '@example.com' + LineEnding; + end + else if Pos('validatebatch', Item.Path) > 0 then + Body := '{"email_batch":[{"address":"' + Item.ApiKey + '","status":"valid","processed_at":"2018-01-01 00:00:00.000"}],"errors":[]}' + else if Pos('/validate', Item.Path) > 0 then + Body := '{"address":"' + Item.ApiKey + '","status":"valid","processed_at":"2018-01-01 00:00:00.000"}' + else if Pos('getcredits', Item.Path) > 0 then + Body := '{"Credits":"7"}'; + Result := 'HTTP/1.1 ' + Code + ' OK' + #13#10 + + 'Content-Type: ' + ContentType + #13#10 + + 'Content-Length: ' + IntToStr(Length(Body)) + #13#10 + + 'Connection: close' + #13#10 + #13#10 + Body; +end; + +function ReadSome(Sock: LongInt): String; +var + Buf: array[0..8191] of Byte; + N: Integer; +begin + N := fpRecv(Sock, @Buf, SizeOf(Buf), 0); + if N <= 0 then + Exit(''); + SetLength(Result, N); + Move(Buf[0], Result[1], N); +end; + +procedure HandleClient(Sock: LongInt); +var + Raw, Headers, Body, Line, Target, Query: String; + HeaderEnd, ContentLength, Space1, Space2, Q: Integer; + Item: TRecorded; + Reply: String; +begin + Raw := ''; + repeat + Raw := Raw + ReadSome(Sock); + HeaderEnd := Pos(#13#10#13#10, Raw); + until (HeaderEnd > 0) or (Raw = '') or (Length(Raw) > 2000000); + if HeaderEnd = 0 then + begin + CloseSocket(Sock); + Exit; + end; + Headers := Copy(Raw, 1, HeaderEnd - 1); + Body := Copy(Raw, HeaderEnd + 4, MaxInt); + ContentLength := StrToIntDef(HeaderValue(Headers, 'Content-Length'), 0); + while Length(Body) < ContentLength do + Body := Body + ReadSome(Sock); + Line := Copy(Headers, 1, Pos(#13#10, Headers + #13#10) - 1); + Space1 := Pos(' ', Line); + Space2 := Pos(' ', Copy(Line, Space1 + 1, MaxInt)); + Target := Copy(Line, Space1 + 1, Space2 - 1); + Q := Pos('?', Target); + if Q = 0 then + begin + Item.Path := Target; + Query := ''; + end + else + begin + Item.Path := Copy(Target, 1, Q - 1); + Query := Copy(Target, Q + 1, MaxInt); + end; + Item.Method := Copy(Line, 1, Space1 - 1); + Item.Host := HeaderValue(Headers, 'Host'); + Item.Query := Query; + Item.Body := Body; + Item.ApiKey := QueryValue(Query, 'api_key'); + if Item.ApiKey = '' then + Item.ApiKey := QueryValue(Body, 'api_key'); + if Item.ApiKey = '' then + begin + Q := Pos('"api_key"', Body); + if Q > 0 then + begin + Q := Q + 9; + while (Q <= Length(Body)) and (Body[Q] in [' ', ':', #9]) do + Inc(Q); + if (Q <= Length(Body)) and (Body[Q] = '"') then + begin + Inc(Q); + Item.ApiKey := Copy(Body, Q, Pos('"', Copy(Body, Q, MaxInt)) - 1); + end; + end; + end; + if (Item.ApiKey = '') and (Pos('name="api_key"', Body) > 0) then + begin + Q := Pos('name="api_key"', Body); + Q := Pos(#13#10#13#10, Copy(Body, Q, MaxInt)); + if Q > 0 then + begin + Item.ApiKey := Copy(Body, Pos('name="api_key"', Body) + Q + 3, 200); + Item.ApiKey := Trim(Copy(Item.ApiKey, 1, Pos(#13#10, Item.ApiKey + #13#10) - 1)); + end; + end; + Sleep(Random(21)); + Remember(Item); + Reply := AnswerFor(Item); + fpSend(Sock, PChar(Reply), Length(Reply), 0); + CloseSocket(Sock); +end; + +constructor TStubServer.Create; +var + Addr: sockaddr_in; + NameLen: TSockLen; + Yes: LongInt; +begin + inherited Create(True); + FreeOnTerminate := False; + ListenSock := fpSocket(AF_INET, SOCK_STREAM, 0); + Yes := 1; + fpSetSockOpt(ListenSock, SOL_SOCKET, SO_REUSEADDR, @Yes, SizeOf(Yes)); + Addr := Default(sockaddr_in); + Addr.sin_family := AF_INET; + Addr.sin_port := htons(0); + Addr.sin_addr.s_addr := htonl($7F000001); + if fpBind(ListenSock, @Addr, SizeOf(Addr)) <> 0 then + raise Exception.Create('bind failed'); + fpListen(ListenSock, 32); + NameLen := SizeOf(Addr); + fpGetSockName(ListenSock, @Addr, @NameLen); + Port := NToHS(Addr.sin_port); +end; + +procedure TStubServer.Execute; +var + Client: LongInt; + Addr: sockaddr_in; + NameLen: TSockLen; +begin + while not Terminated do + begin + NameLen := SizeOf(Addr); + Client := fpAccept(ListenSock, @Addr, @NameLen); + if (Client >= 0) and not Terminated then + HandleClient(Client) + else if Client >= 0 then + CloseSocket(Client); + end; + CloseSocket(ListenSock); +end; + +procedure ClearRecorded; +begin + Gate.Enter; + try + SetLength(Recorded, 0); + finally + Gate.Leave; + end; +end; + +function OnlyRequest: TRecorded; +begin + Gate.Enter; + try + if Length(Recorded) <> 1 then + raise Exception.CreateFmt('expected 1 request, got %d', [Length(Recorded)]); + Result := Recorded[0]; + finally + Gate.Leave; + end; +end; + +procedure EnsureStub; +begin + if Stub <> nil then Exit; + Gate := TCriticalSection.Create; + Stub := TStubServer.Create; + Stub.Start; + Sleep(20); +end; + +procedure UseStub(const Key: String); +begin + EnsureStub; + ZBInitialize(Key); + ZbApiBaseURL := Format('http://127.0.0.1:%d/v2', [Stub.Port]); + BULK_URI := ZbApiBaseURL; + ZbBulkRootURL := Format('http://127.0.0.1:%d', [Stub.Port]); + ZbResponseMock := cDefaultMock; +end; + +procedure ExpectShort(const Msg: String); +begin + if Length(Msg) >= 500 then + raise Exception.Create('error is too long: ' + Copy(Msg, 1, 80)); + if (Pos(' 0) or (Pos(' 0) or + (Pos('

blocked

', Msg) > 0) or (Pos('Error: Error:', Msg) > 0) then + raise Exception.Create(Msg); +end; + +procedure TTestOffline.TestEndpointsAndOptions; +var + Options: TZbValidateOptions; + Batch: array[0..1] of TZbEmailAndIp; + One: array[0..0] of TZbEmailAndIp; + FileOpts: TZbBulkParams; + GetOpts: TZbGetFileOptions; + Finder: TZbEmailFinderSendFileOptions; + DomainOpts: TZbDomainSearchSendFileOptions; + S3: TZbValidateS3FileOptions; + Score: TZbScoreS3FileOptions; + Request: TRecorded; + StartDate, EndDate: TDateTime; +begin + EnsureStub; + ClearRecorded; + UseStub('credits-key'); + AssertEquals(7, ZbGetCredits); + Request := OnlyRequest; + AssertEquals('GET', Request.Method); + AssertEquals('/v2/getcredits', Request.Path); + AssertEquals('credits-key', Request.ApiKey); + AssertTrue(Pos('127.0.0.1', Request.Host) > 0); + + ClearRecorded; + UseStub('validate-key'); + Options := ZbDefaultValidateOptions; + Options.ActivityData := 0; + Options.VerifyPlus := 1; + AssertEquals('validate-key', ZbValidateEmail('person@example.com', '', Options).Address); + Request := OnlyRequest; + AssertEquals('/v2/validate', Request.Path); + AssertEquals('false', QueryValue(Request.Query, 'activity_data')); + AssertEquals('true', QueryValue(Request.Query, 'verify_plus')); + AssertEquals('', QueryValue(Request.Query, 'timeout')); + + ClearRecorded; + Options := ZbDefaultValidateOptions; + Options.UsePost := 1; + Options.Timeout := 15; + ZbValidateEmail('person@example.com', '203.0.113.5', Options); + Request := OnlyRequest; + AssertEquals('POST', Request.Method); + AssertTrue(Pos('timeout=15', Request.Body) > 0); + AssertTrue(Pos('activity_data', Request.Body) = 0); + + ClearRecorded; + UseStub('batch-key'); + Batch[0].Email := 'a@example.com'; + Batch[0].Ip := ''; + Batch[1].Email := 'b@example.com'; + Batch[1].Ip := '203.0.113.9'; + AssertEquals('batch-key', ZbBatchValidateEmails(Batch).EmailBatch[0].Address); + Request := OnlyRequest; + AssertEquals('/v2/validatebatch', Request.Path); + AssertTrue(Pos('"ip_address": null', Request.Body) > 0); + AssertTrue(Pos('203.0.113.9', Request.Body) > 0); + AssertTrue(Pos('timeout', Request.Body) = 0); + + ClearRecorded; + One[0].Email := 'a@example.com'; + One[0].Ip := '0.0.0.0'; + Options := ZbDefaultValidateOptions; + Options.Timeout := 30; + Options.ActivityData := 0; + Options.VerifyPlus := 1; + ZbBatchValidateEmails(One, Options); + Request := OnlyRequest; + AssertTrue(Pos('30', Request.Body) > 0); + AssertTrue(Pos('false', Request.Body) > 0); + AssertTrue(Pos('0.0.0.0', Request.Body) > 0); + + ClearRecorded; + UseStub('file-key'); + FillChar(FileOpts, SizeOf(FileOpts), 0); + FileOpts.EmailAddressColumn := 0; + FileOpts.HasHeaderRow := True; + FileOpts.RemoveDuplicate := False; + FileOpts.AllowPhase2Specified := True; + FileOpts.AllowPhase2 := False; + ZbBulkValidationFileSubmit('email,name', FileOpts); + Request := OnlyRequest; + AssertEquals('/v2/sendfile', Request.Path); + AssertTrue(Pos('email_address_column', Request.Body) > 0); + AssertTrue(Pos('has_header_row', Request.Body) > 0); + AssertTrue(Pos('false', Request.Body) > 0); + + ClearRecorded; + StartDate := EncodeDate(2018, 1, 1); + EndDate := EncodeDate(2018, 1, 31); + ZbGetApiUsage(StartDate, EndDate); + AssertEquals('/v2/getapiusage', OnlyRequest.Path); + ClearRecorded; + ZbActivityData('person@example.com'); + AssertEquals('/v2/activity', OnlyRequest.Path); + ClearRecorded; + AssertEquals('file-key', ZbScoreEmail('person@example.com').Email); + AssertEquals('GET', OnlyRequest.Method); + AssertEquals('/v2/scoring', OnlyRequest.Path); + ClearRecorded; + ZbScoreEmailPost('person@example.com'); + AssertEquals('POST', OnlyRequest.Method); + + ClearRecorded; + ZbFindEmailByDomain('example.com', 'Ada', 'M', 'Lovelace'); + Request := OnlyRequest; + AssertEquals('/v2/guessformat', Request.Path); + AssertEquals('example.com', QueryValue(Request.Query, 'domain')); + AssertEquals('', QueryValue(Request.Query, 'company_name')); + ClearRecorded; + ZbDomainSearchByCompanyName('ZeroBounce'); + AssertEquals('ZeroBounce', QueryValue(OnlyRequest.Query, 'company_name')); + + ClearRecorded; + FillChar(Finder, SizeOf(Finder), 0); + Finder.HasHeaderRowSpecified := True; + Finder.HasHeaderRow := False; + ZbSendEmailFinderFile('domain', 1, @Finder); + Request := OnlyRequest; + AssertEquals('/email-finder/sendfile', Request.Path); + AssertTrue(Pos('has_header_row', Request.Body) > 0); + AssertTrue(Pos('false', Request.Body) > 0); + ClearRecorded; + ZbEmailFinderFileStatus('fid'); + AssertEquals('/email-finder/filestatus', OnlyRequest.Path); + ClearRecorded; + ZbEmailFinderResultFetch('fid'); + AssertEquals('/email-finder/getfile', OnlyRequest.Path); + ClearRecorded; + ZbEmailFinderResultDelete('fid'); + AssertEquals('/email-finder/deletefile', OnlyRequest.Path); + ClearRecorded; + FillChar(DomainOpts, SizeOf(DomainOpts), 0); + DomainOpts.HasHeaderRowSpecified := True; + DomainOpts.HasHeaderRow := False; + ZbSendDomainSearchFile('company', 2, @DomainOpts); + AssertEquals('/domain-search/sendfile', OnlyRequest.Path); + ClearRecorded; + ZbSendListEvaluatorFile('emails', 1); + AssertEquals('/v2/listevaluator/', OnlyRequest.Path); + ClearRecorded; + AssertTrue(Pos('file-key', ZbGetListEvaluatorResult('fid')) > 0); + ClearRecorded; + ZbAddFilter('exclude', 'domain', 'example.com'); + AssertEquals('/v2/filters/add', OnlyRequest.Path); + ClearRecorded; + ZbListFilters(False); + AssertEquals('GET', OnlyRequest.Method); + ClearRecorded; + ZbListFilters(True); + AssertEquals('POST', OnlyRequest.Method); + ClearRecorded; + FillChar(S3, SizeOf(S3), 0); + S3.FileName := 'inbox.csv'; + S3.EmailAddressColumnSpecified := True; + S3.EmailAddressColumn := 0; + S3.HasHeaderRowSpecified := True; + S3.HasHeaderRow := False; + ZbValidateS3File(S3); + Request := OnlyRequest; + AssertEquals('/validateS3file', Request.Path); + AssertTrue(Pos('email_address_column', Request.Body) > 0); + AssertTrue(Pos('=0', Request.Body) > 0); + ClearRecorded; + FillChar(Score, SizeOf(Score), 0); + Score.FileName := 'inbox.csv'; + ZbScoreS3File(Score); + AssertEquals('/scoreS3file', OnlyRequest.Path); + ClearRecorded; + ZbExportS3File('fid', 'bucket-out'); + AssertEquals('/exportS3file', OnlyRequest.Path); + ClearRecorded; + ZbListS3Files('', ''); + Request := OnlyRequest; + AssertEquals('/listS3files', Request.Path); + AssertEquals('', QueryValue(Request.Query, 'bucket')); + FillChar(GetOpts, SizeOf(GetOpts), 0); + GetOpts.DownloadType := ZB_DOWNLOAD_TYPE_PHASE_2; + GetOpts.ActivityDataSpecified := True; + GetOpts.ActivityData := True; + ClearRecorded; + ZbBulkValidationResultFetch('fid', @GetOpts); + Request := OnlyRequest; + AssertEquals('/v2/getfile', Request.Path); + AssertEquals('phase_2', QueryValue(Request.Query, 'download_type')); + AssertEquals('true', QueryValue(Request.Query, 'activity_data')); +end; + +procedure TTestOffline.TestRejectsBeforeRequest; +var + S3: TZbValidateS3FileOptions; +begin + EnsureStub; + ClearRecorded; + ZBInitialize(''); + ZbApiBaseURL := Format('http://127.0.0.1:%d/v2', [Stub.Port]); + try + ZbGetCredits; + Fail('missing key was sent'); + except + on E: ZbException do + AssertTrue(Pos('ZBInitialize', E.Message) > 0); + end; + try + ZbFindEmailByDomain('example.com', 'Ada'); + Fail('missing key was sent'); + except + on E: ZbException do ; + end; + FillChar(S3, SizeOf(S3), 0); + S3.FileName := 'inbox.csv'; + try + ZbValidateS3File(S3); + Fail('missing key was sent'); + except + on E: ZbException do ; + end; + AssertEquals(0, Length(Recorded)); + + UseStub('keyed'); + try + ZbValidateEmail('a@example.com', '', ZbDefaultValidateOptions); + except + on E: Exception do + Fail(E.Message); + end; +end; + +procedure TTestOffline.TestErrorBodies; +begin + EnsureStub; + ClearRecorded; + UseStub('html-403-user'); + try + ZbGetCredits; + Fail('html 403 succeeded'); + except + on E: ZbException do + begin + ExpectShort(E.Message); + AssertTrue(E.Message, Pos('Access denied', E.Message) > 0); + end; + end; + + UseStub('html-200-user'); + try + ZbValidateEmail('a@example.com', ''); + Fail('html 200 succeeded'); + except + on E: ZbException do + begin + ExpectShort(E.Message); + AssertTrue(E.Message, Pos('Access denied', E.Message) > 0); + end; + end; + + UseStub('html-200-file'); + try + if ZbBulkValidationResultFetch('fid').HasContent then + Fail('html was saved as a file'); + Fail('html getfile succeeded'); + except + on E: ZbException do + begin + ExpectShort(E.Message); + AssertTrue(E.Message, Pos('Access denied', E.Message) > 0); + end; + end; + + UseStub('cf-json-user'); + try + ZbGetCredits; + Fail('cloudflare succeeded'); + except + on E: ZbException do + begin + ExpectShort(E.Message); + AssertTrue(E.Message, Pos('developers.cloudflare.com', E.Message) = 0); + AssertTrue(E.Message, Pos('Access denied', E.Message) > 0); + end; + end; + + UseStub('empty-user'); + try + ZbValidateEmail('a@example.com', ''); + Fail('empty succeeded'); + except + on E: ZbException do + begin + ExpectShort(E.Message); + AssertTrue(E.Message, Pos('Empty response', E.Message) > 0); + end; + end; + + UseStub('malformed-user'); + try + ZbValidateEmail('a@example.com', ''); + Fail('malformed succeeded'); + except + on E: ZbException do + begin + ExpectShort(E.Message); + AssertTrue(E.Message, Pos('not valid JSON', E.Message) > 0); + AssertTrue(E.Message, Pos('<<>>', E.Message) = 0); + end; + end; + + UseStub('double-error-user'); + try + ZbActivityData('a@example.com'); + Fail('double error succeeded'); + except + on E: ZbException do + begin + ExpectShort(E.Message); + AssertTrue(E.Message, Pos('api_key is invalid', E.Message) > 0); + AssertTrue(E.Message, Pos('Error: Error:', E.Message) = 0); + end; + end; +end; + +procedure TTestOffline.TestBatchAccountAndPartial; +var + Emails: array[0..1] of TZbEmailAndIp; + Batch: TZBBatchValidation; +begin + EnsureStub; + UseStub('batch-account-user'); + Emails[0].Email := 'a@example.com'; + Emails[0].Ip := ''; + try + ZbBatchValidateEmails(Emails); + Fail('account error succeeded'); + except + on E: ZbException do + begin + ExpectShort(E.Message); + AssertTrue(Pos('Invalid API Key', E.Message) > 0); + AssertTrue(Pos('ran out of credits', E.Message) > 0); + end; + end; + + UseStub('batch-partial-user'); + Emails[0].Email := 'good@example.com'; + Emails[1].Email := 'bad@'; + Batch := ZbBatchValidateEmails(Emails); + AssertEquals('good@example.com', Batch.EmailBatch[0].Address); + AssertEquals('bad@', Batch.Errors[0].EmailAddress); +end; + +type + TConc = class(TThread) + public + Index: Integer; + Echo: String; + procedure Execute; override; + end; + +procedure TConc.Execute; +begin + UseStub('conc-' + IntToStr(Index)); + Echo := ZbValidateEmail('person@example.com', '').Address; +end; + +procedure TTestOffline.TestConcurrentKeys; +var + Threads: array[0..11] of TConc; + I, J, Seen: Integer; +begin + EnsureStub; + ClearRecorded; + for I := 0 to High(Threads) do + begin + Threads[I] := TConc.Create(True); + Threads[I].FreeOnTerminate := False; + Threads[I].Index := I; + Threads[I].Start; + end; + for I := 0 to High(Threads) do + begin + Threads[I].WaitFor; + AssertEquals('conc-' + IntToStr(I), Threads[I].Echo); + Threads[I].Free; + end; + AssertEquals(Length(Threads), Length(Recorded)); + for I := 0 to High(Threads) do + begin + Seen := 0; + for J := 0 to High(Recorded) do + if Recorded[J].ApiKey = 'conc-' + IntToStr(I) then + Inc(Seen); + AssertEquals(1, Seen); + end; +end; + +initialization + Randomize; + RegisterTest(TTestOffline); + +finalization + if Stub <> nil then + begin + Stub.Terminate; + CloseSocket(Stub.ListenSock); + Stub.WaitFor; + Stub.Free; + end; + Gate.Free; + +end. diff --git a/tests/testvalidation.pas b/tests/testvalidation.pas index a709ef5..3926d8c 100644 --- a/tests/testvalidation.pas +++ b/tests/testvalidation.pas @@ -202,15 +202,15 @@ procedure TTestValidation.TestBatchEmailValidationErrorResponse; end; procedure TTestValidation.TestBatchEmailValidationErrorInPayload; -var - validation: TZBBatchValidation; begin ZBMockResponse(200, BATCH_VALIDATE_ERROR); - validation := ZbBatchValidateEmails(EmailsAndIps2); - - AssertEquals('email_batch length', validation.EmailBatchLength, 0); - AssertEquals('errors length', validation.ErrorsLength, 1); - AssertEquals('email_address in error', validation.Errors[0].EmailAddress, 'all'); + try + ZbBatchValidateEmails(EmailsAndIps2); + Fail('account-level batch error should fail the call'); + except + on e: ZbException do + AssertTrue('account error', e.Message.Contains('Invalid API Key') or e.Message.Contains('ran out of credits')); + end; end; procedure TTestValidation.TestBatchEmailValidationOkPayload; diff --git a/tests/unit_tests_console.lpr b/tests/unit_tests_console.lpr index b0b83e1..9286af6 100644 --- a/tests/unit_tests_console.lpr +++ b/tests/unit_tests_console.lpr @@ -3,8 +3,11 @@ {$mode objfpc}{$H+} uses +{$IFDEF UNIX} + cthreads, +{$ENDIF} Classes, SysUtils, consoletestrunner, - TestGeneric, TestValidation, TestDeserialization, TestBulk, TestPriority3, TestUtility; + TestGeneric, TestValidation, TestDeserialization, TestBulk, TestPriority3, TestUtility, TestOffline; var App: TTestRunner;