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/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/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/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/zbgeneric.pas b/src/zbgeneric.pas
index 6514543..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;
@@ -35,7 +37,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 +60,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',
[
@@ -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;
@@ -107,7 +150,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 +178,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 +216,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 +254,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 +276,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 +298,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
diff --git a/src/zbpriority3.pas b/src/zbpriority3.pas
new file mode 100644
index 0000000..19eef8f
--- /dev/null
+++ b/src/zbpriority3.pas
@@ -0,0 +1,498 @@
+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 ZbListS3Files(const Bucket, Search: String): String;
+function ZbValidateS3File(Options: TZbValidateS3FileOptions): TZBFileFeedback;
+function ZbScoreS3File(Options: TZbScoreS3FileOptions): TZBFileFeedback;
+function ZbExportS3File(const FileId, ExportBucket: String): TZBFileFeedback;
+
+procedure Register;
+
+implementation
+
+const
+ { 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';
+ 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_LIST_S3 = '/listS3files';
+ 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(ZbBulkRootURL, 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(ZbBulkRootURL, 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;
+ if ZbBodyLooksLikeHtml(response.Payload) then
+ raise ZbException.FromResponse('', response);
+ 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(ZbBulkRootURL, 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 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
+ 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(ZbBulkRootURL, 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 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;
+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 ZbListS3Files(const Bucket, Search: String): String;
+var
+ UrlToAccess: String;
+ response: TZbRequestResponse;
+begin
+ UrlToAccess := Concat(ZbBulkRootURL, 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;
+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(ZbBulkRootURL, 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(ZbBulkRootURL, 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(ZbBulkRootURL, ENDPOINT_EXPORT_S3), Form);
+end;
+
+procedure Register;
+begin
+end;
+
+end.
diff --git a/src/zbstructures.pas b/src/zbstructures.pas
index e540bd9..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;
@@ -81,6 +87,8 @@ TZbValidationResult = record
Account: String;
Domain: String;
DomainAgeDays: String;
+ ActiveInDays: String;
+ ActiveFirstSeen: String;
SmtpProvider: String;
CatchallDomain: boolean;
MxRecord: String;
@@ -169,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
@@ -399,6 +408,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');
@@ -662,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..f268ef0 100644
--- a/src/zbutility.pas
+++ b/src/zbutility.pas
@@ -36,13 +36,13 @@ 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';
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';
@@ -65,23 +65,33 @@ 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;
+ // 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;
@@ -187,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;
@@ -261,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;
@@ -283,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;
@@ -305,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;
@@ -325,6 +435,7 @@ procedure ZbUseVerifiedTls(Client: TFPHTTPClient);
{$ENDIF}
error: ZbException;
begin
+ ZbRequireApiKey;
if ZbResponseMock.StatusCode <> 0 then
begin
ZbResponseMock.UrlCalled := url;
@@ -371,7 +482,66 @@ procedure ZbUseVerifiedTls(Client: TFPHTTPClient);
end;
- // check for failure
+ ZbFinishResponse(Result);
+ end;
+
+ function ZBFormPostRequest(url: String; FormBody: String): TZbRequestResponse;
+ var
+ {$IFDEF FPC}
+ Client: TFPHTTPClient;
+ {$ELSE}
+ Client: THTTPClient;
+ BodyStream: TStream;
+ Response: IHTTPResponse;
+ {$ENDIF}
+ error: ZbException;
+ begin
+ ZbRequireApiKey;
+ 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);
@@ -413,6 +583,7 @@ procedure ZbUseVerifiedTls(Client: TFPHTTPClient);
end;
{$ENDIF}
begin
+ ZbRequireApiKey;
if ZbResponseMock.StatusCode <> 0 then
begin
Result := ZbResponseMock;
@@ -479,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);
@@ -504,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 1040b1c..01f0723 100644
--- a/src/zbvalidation.pas
+++ b/src/zbvalidation.pas
@@ -15,13 +15,34 @@ TZbEmailAndIp = record
Ip: String;
end;
+ TZbValidateOptions = record
+ Timeout: Integer;
+ ActivityData: Integer;
+ VerifyPlus: Integer;
+ UsePost: 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 +71,28 @@ implementation
end;
end;
+ function ZbDefaultValidateOptions: TZbValidateOptions;
+ begin
+ Result.Timeout := -1;
+ Result.ActivityData := -1;
+ Result.VerifyPlus := -1;
+ Result.UsePost := -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;
@@ -65,19 +103,49 @@ 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
+ 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;
+ FormBody: string;
response: TZbRequestResponse;
error: ZbException;
begin
@@ -85,7 +153,43 @@ implementation
UrlToAccess := Concat(UrlToAccess, '?api_key=', EncodeParam(ZbApiKey));
UrlToAccess := Concat(UrlToAccess, '&email=', EncodeParam(Email));
UrlToAccess := Concat(UrlToAccess, '&ip_address=', EncodeParam(IpAddress));
- response := ZBGetRequest(UrlToAccess);
+ 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');
+ 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);
@@ -104,19 +208,37 @@ 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;
+ 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)');
UrlToAccess := Concat(ZbApiBaseURL, ENDPOINT_BATCH_VALIDATE);
- JsonBody := ZbBatchRequestBodyFromEmails(Emails);
+ JsonBody := ZbBatchRequestBodyFromEmails(Emails, Options);
response := ZBPostRequest(UrlToAccess, JsonBody);
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/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');
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/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/testvalidation.pas b/tests/testvalidation.pas
index 1796ad4..3926d8c 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;
@@ -185,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 66510ae..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, TestUtility;
+ TestGeneric, TestValidation, TestDeserialization, TestBulk, TestPriority3, TestUtility, TestOffline;
var
App: TTestRunner;