From 9bb371fae542bd26e62a923a83068eb943cb5ddd Mon Sep 17 00:00:00 2001 From: Francisc Balint <7178547+franciscbalint@users.noreply.github.com> Date: Sat, 24 Oct 2026 02:12:31 +0400 Subject: [PATCH 1/8] feat: validate WHOIS and greylist options, Inbox Placement Tester, SFTP bulk MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rebuilt on top of main after PR #19 landed, which already brought TZbValidateOptions, real-time AI scoring, the filters, the list evaluator and the first four S3 jobs in a ZbPriority3 unit. This branch now adds only what is still missing, on main's structure, rather than a second copy of that work. Ports the rest of 407f0a8 and b0444f8 from the Python SDK: - TZbValidateOptions gains DomainInfo, GreylistProcessing and CallbackUrl. A -1 field is not sent; booleans go on the wire as the strings true/false. CallbackUrl is where the greylist retry posts its result, so it is rejected without GreylistProcessing = 1, and when blank, before any request is sent. - TZbValidationResult carries DomainWebsiteExists, DomainRegistrantCompanyName and Message. - ZbEmailFinderS3File and ZbDomainSearchS3File, from https://www.zerobounce.net/docs/api-integrations/amazon-s3-bulk-api - ZbValidateSFTPFile, ZbScoreSFTPFile, ZbEmailFinderSFTPFile, ZbDomainSearchSFTPFile and ZbExportSFTPFile, from https://www.zerobounce.net/docs/api-integrations/sftp_bulk_api All six share TZbBulkFileJobOptions and P3BulkRootFileJob. - The Inbox Placement Tester: the API key goes on the query string, bodies are camelCase JSON, and unset fields are left out. ZbUtility gains ZbListEvaluatorURL and ZbInboxPlacementURL, ZBSendRequest (explicit method plus optional body, which the Tester's PUT and DELETE need), EncodePathSegment, and ZbLastRequestMethod / ZbLastRequestBody for the tests. Changed: - ZbGetListEvaluatorResult uses ZbListEvaluatorURL, encodes the file id as a single path segment, and rejects a blank id before a request is sent. - ZbExportS3File's ExportBucket is optional, matching the documented endpoint: an empty string uses the export bucket from the account's settings. A whitespace-only value is still rejected. The new export test caught this. Fixed: - A validation response without processed_at no longer raises. The greylist-pending response carries Message and no date, and the parser was raising "Invalid date/timestamp" on it. - TTestOffline.TestEndpointsAndOptions was reaching the LIVE API. It points the local stub at ZbApiBaseURL, BULK_URI and ZbBulkRootURL, but the list evaluator had just moved to its own base URL, which still defaulted to bulkapi.zerobounce.net. UseStub now points the two new base URLs at the stub as well. Symptom was the whole --all run hanging; the real response was {"success":"False","message":["api_key is invalid"]}. Tests: 91 run, 0 errors, 0 failures — up from 77 on main. Co-Authored-By: Claude Opus 5 --- AGENTS.md | 73 ++--- CHANGELOG.md | 18 ++ src/zbpriority3.pas | 296 +++++++++++++++++++- src/zbstructures.pas | 19 +- src/zbutility.pas | 94 +++++++ src/zbvalidation.pas | 47 ++++ tests/testbulkjobs.pas | 510 +++++++++++++++++++++++++++++++++++ tests/testoffline.pas | 5 + tests/unit_tests_console.lpr | 3 +- 9 files changed, 1012 insertions(+), 53 deletions(-) create mode 100644 tests/testbulkjobs.pas diff --git a/AGENTS.md b/AGENTS.md index 3b0f7c9..ea662d0 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,61 +1,38 @@ -# 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. +# Agent notes — zero-bounce-pascal ## 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. +- `src/zbutility.pas`: the HTTP layer, the base URLs and the response mock. `ZBGetRequest` / `ZBPostRequest` are the originals; `ZBSendRequest` takes an explicit method and an optional body, which the form-encoded bulk jobs and the Inbox Placement Tester's PUT and DELETE need. `ZBFormPostRequest` wraps it. +- `src/zbvalidation.pas`: single and batch validation, plus `TZbValidateOptions`. +- `src/zbbulk.pas`: the multipart bulk uploads, their status checks and `getfile`. +- `src/zbpriority3.pas`: real-time AI scoring, the filters, the list evaluator, the S3 / SFTP bulk jobs and the Inbox Placement Tester. +- `src/zbstructures.pas`: the records and their JSON parsers. +- `tests/testpriority3.pas`: the offline suite for the above. Every request is answered by `ZbMockResponse`. -## Commands +## Build and test -Free Pascal was not on the machine used to add the offline suite. CI installs it. The default test command is: +FPC runs in Docker (there is no `fpc` on the host): -```bash -fpc -Fu./src -Fu./tests -Mobjfpc -O2 -gl -viwn \ - tests/unit_tests_console.lpr -FE. -./unit_tests_console --all +```sh +cd ../sdk-docs && docker compose run --rm --build pascal ``` -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 +To build and run against the working tree without rebuilding the image: -- **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). +```sh +docker run --rm -u 0 -v "$PWD":/src -w /src zerobounce-sdk-pascal:test \ + sh -c "fpc -Fu./src -Fu./tests -Mobjfpc -O2 -gl -viwn tests/unit_tests_console.lpr -FE/tmp && /tmp/unit_tests_console --all" +``` -- 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. +A new test unit must be added to the `uses` clause of `tests/unit_tests_console.lpr`, or it never runs. -## Releasing +The `.o` and `.ppu` files at the repo root are build artifacts, not sources. -Publishing and pushing tags need a maintainer's explicit approval. Do not bump the version, tag, or publish unless they asked. +## Rules -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. +- **Only endpoints documented at https://www.zerobounce.net/docs/.** Do not add a function for an endpoint the public docs do not describe, even if it exists on the server. Check before concluding something is undocumented: the S3 and SFTP bulk endpoints live under https://www.zerobounce.net/docs/api-integrations, not the main API reference. +- **Four base URLs.** `ZbApiBaseURL`, `BULK_URI` (ends in `/v2`), `ZbBulkRootURL` (the S3/SFTP jobs, **no** `/v2`), and `ZbListEvaluatorURL` / `ZbInboxPlacementURL`. The last three are separate values rather than paths built from `BULK_URI`, so overriding the main one alone leaves a test pointing at the live API. `TTestPriority3.SetUp` resets them. +- **`EncodeParam` is query encoding.** It can turn a space into `+`, which is correct in a query string but a literal plus in a path. Use `EncodePathSegment` for anything that goes into a path, such as the list evaluator file id and the Inbox Placement test id. +- **Two bulk shapes are easy to get wrong.** `ZbValidateSFTPFile`'s `FileName` is optional — leaving it empty uses the input bucket from the account's settings, and every other job requires it. `ZbExportSFTPFile` sends `export_path`, not the `export_bucket` that `ZbExportS3File` sends. +- **Optional record fields use a `Has...` flag.** Pascal records have no nullable scalars, so every optional option carries a companion boolean and is only sent when that flag is set. +- **Offline tests only.** Never call the live API from a test. `ZbMockResponse` short-circuits every request function; `ZbLastRequestMethod` and `ZbLastRequestBody` record what would have been sent. diff --git a/CHANGELOG.md b/CHANGELOG.md index bed6007..467dcff 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,24 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- `TZbValidateOptions` accepts `DomainInfo`, `GreylistProcessing` and `CallbackUrl`. A `-1` field is not sent; booleans go on the wire as the strings `true`/`false`. `CallbackUrl` is where the greylist retry posts its result, so it is rejected without `GreylistProcessing = 1`, and when blank, before any request is sent. +- `TZbValidationResult` carries `DomainWebsiteExists`, `DomainRegistrantCompanyName` and `Message`. `Message` is the API's `Message` key, returned instead of a result while a greylist retry is still running. +- The rest of the documented bulk surface: `ZbEmailFinderS3File`, `ZbDomainSearchS3File`, and the SFTP/FTP jobs `ZbValidateSFTPFile`, `ZbScoreSFTPFile`, `ZbEmailFinderSFTPFile`, `ZbDomainSearchSFTPFile` and `ZbExportSFTPFile`, sharing the new `TZbBulkFileJobOptions`. `ZbValidateSFTPFile`'s `FileName` is optional - leaving it empty uses the input bucket from your ZeroBounce settings - and `ZbExportSFTPFile` takes `export_path`, not the `export_bucket` `ZbExportS3File` sends. +- The Inbox Placement Tester: the API key goes on the query string, bodies are camelCase JSON, and unset fields are left out. +- **ZbUtility**: `ZbListEvaluatorURL` and `ZbInboxPlacementURL` (separate base URLs rather than paths built from `BULK_URI`), `ZBSendRequest`, `EncodePathSegment`, and `ZbLastRequestMethod` / `ZbLastRequestBody` for the offline tests. + +### Changed + +- `ZbGetListEvaluatorResult` uses `ZbListEvaluatorURL`, encodes the file id as a single path segment, and rejects a blank id before a request is sent. +- `ZbExportS3File`'s `ExportBucket` is optional, matching the documented endpoint: an empty string uses the export bucket from your ZeroBounce settings. A whitespace-only value is still rejected. + +### Fixed + +- A validation response without `processed_at` no longer raises. A greylist retry that is still running answers with `Message` and no date; `ProcessedAt` is left at zero instead. + + ### Added - `ZbValidateEmail` and `ZbBatchValidateEmails` take `TZbValidateOptions`: `Timeout`, `ActivityData`, `VerifyPlus`, and `UsePost` (form-encoded `POST /validate`). diff --git a/src/zbpriority3.pas b/src/zbpriority3.pas index 19eef8f..8574552 100644 --- a/src/zbpriority3.pas +++ b/src/zbpriority3.pas @@ -54,6 +54,38 @@ TZbScoreS3FileOptions = record EmailAddressColumn: Integer; end; + { The documented fields shared by the six S3 and SFTP/FTP file jobs. + + FileName is required by every job except ZbValidateSFTPFile, where the + docs list it as optional: leaving it empty there uses the input bucket + configured in your ZeroBounce settings. } + TZbBulkFileJobOptions = record + FileName: String; + HasHeaderRow: Boolean; + RemoveDuplicatesSpecified: Boolean; + RemoveDuplicates: Boolean; + ReturnUrl: String; + end; + + { The optional fields of a new Inbox Placement test. Only what is set is sent. } + TZbCreateInboxPlacementTestOptions = record + TestTypeSpecified: Boolean; + TestType: Integer; + SenderEmail: String; + WebhookUrl: String; + end; + PTZbCreateInboxPlacementTestOptions = ^TZbCreateInboxPlacementTestOptions; + + { The documented filters of the Inbox Placement test list. } + TZbListInboxPlacementTestsOptions = record + Status: String; + PageSpecified: Boolean; + Page: Integer; + PageSizeSpecified: Boolean; + PageSize: Integer; + end; + PTZbListInboxPlacementTestsOptions = ^TZbListInboxPlacementTestsOptions; + PTZbEmailFinderSendFileOptions = ^TZbEmailFinderSendFileOptions; PTZbDomainSearchSendFileOptions = ^TZbDomainSearchSendFileOptions; @@ -82,6 +114,39 @@ function ZbValidateS3File(Options: TZbValidateS3FileOptions): TZBFileFeedback; function ZbScoreS3File(Options: TZbScoreS3FileOptions): TZBFileFeedback; function ZbExportS3File(const FileId, ExportBucket: String): TZBFileFeedback; +{ The rest of the documented bulk surface. + + https://www.zerobounce.net/docs/api-integrations/amazon-s3-bulk-api + https://www.zerobounce.net/docs/api-integrations/sftp_bulk_api + + The SFTP server, path and protocol are never sent by the SDK; they come from + your ZeroBounce settings. } +function ZbEmailFinderS3File(Options: TZbBulkFileJobOptions): TZBFileFeedback; +function ZbDomainSearchS3File(Options: TZbBulkFileJobOptions): TZBFileFeedback; +{ This is the one job whose file_name the docs list as optional: leave + Options.FileName empty to use the input bucket from your settings. } +function ZbValidateSFTPFile(Options: TZbBulkFileJobOptions): TZBFileFeedback; +function ZbScoreSFTPFile(Options: TZbBulkFileJobOptions): TZBFileFeedback; +function ZbEmailFinderSFTPFile(Options: TZbBulkFileJobOptions): TZBFileFeedback; +function ZbDomainSearchSFTPFile(Options: TZbBulkFileJobOptions): TZBFileFeedback; +{ Note the field name: this one takes export_path, not the export_bucket that + ZbExportS3File sends. An empty ExportPath uses the one from your settings. } +function ZbExportSFTPFile(const FileId, ExportPath: String): TZBFileFeedback; +function ZbDefaultBulkFileJobOptions: TZbBulkFileJobOptions; + +{ Inbox Placement Tester (/api/ipt). Its own host, the API key on the query + string, camelCase JSON bodies, and errors under a "detail" key. Spends the + account's inbox-test credits. } +function ZbInboxPlacementProviders: String; +function ZbInboxPlacementTestTypes: String; +function ZbCreateInboxPlacementTest(Name: String; + Options: PTZbCreateInboxPlacementTestOptions): String; +function ZbInboxPlacementTest(TestId: String): String; +function ZbListInboxPlacementTests(Options: PTZbListInboxPlacementTestsOptions): String; +function ZbDeleteInboxPlacementTest(TestId: String): String; +function ZbInboxPlacementWebhook: String; +function ZbSetInboxPlacementWebhook(WebhookUrl: String): String; + procedure Register; implementation @@ -365,7 +430,14 @@ function ZbGetListEvaluatorResult(FileId: String): String; var UrlToAccess: String; begin - UrlToAccess := Concat(BULK_URI, '/listevaluator/', FileId, '/?api_key=', EncodeParam(ZbApiKey)); + if Trim(FileId) = '' then + raise ZbException.Create('Empty parameter: file_id'); + { The list evaluator has its own base URL, not a path built from BULK_URI, + and the file id is one path segment, so a slash must stay encoded. } + UrlToAccess := Concat( + ZbListEvaluatorURL, '/', EncodePathSegment(Trim(FileId)), + '/?api_key=', EncodeParam(ZbApiKey) + ); Result := P3RawJsonGet(UrlToAccess); end; @@ -482,11 +554,14 @@ function ZbExportS3File(const FileId, ExportBucket: String): TZBFileFeedback; begin if Trim(FileId) = '' then raise ZbException.Create('Empty parameter: file_id'); - if Trim(ExportBucket) = '' then + if (ExportBucket <> '') and (Trim(ExportBucket) = '') then raise ZbException.Create('Empty parameter: export_bucket'); Form := TStringList.Create; Form.AddPair('file_id', FileId); - Form.AddPair('export_bucket', ExportBucket); + { The documented endpoint treats export_bucket as optional: without it the + export bucket from the account's settings is used. } + if ExportBucket <> '' then + Form.AddPair('export_bucket', ExportBucket); Form.AddPair('api_key', ZbApiKey); Result := P3FormSubmit(Concat(ZbBulkRootURL, ENDPOINT_EXPORT_S3), Form); end; @@ -495,4 +570,219 @@ procedure Register; begin end; +function ZbDefaultBulkFileJobOptions: TZbBulkFileJobOptions; +begin + Result.FileName := ''; + Result.HasHeaderRow := True; + Result.RemoveDuplicatesSpecified := False; + Result.RemoveDuplicates := False; + Result.ReturnUrl := ''; +end; + +function P3BoolField(Value: Boolean): String; +begin + if Value then + P3BoolField := 'true' + else + P3BoolField := 'false'; +end; + +{ POSTs one of the six documented S3 / SFTP file jobs on the bulkapi root. + Booleans go on the wire as the strings 'true'/'false', and an option the + caller left unset is not sent at all. } +function P3BulkRootFileJob(const Endpoint: String; Options: TZbBulkFileJobOptions; + FileNameRequired: Boolean): TZBFileFeedback; +var + Body: String; + response: TZbRequestResponse; + error: ZbException; +begin + if FileNameRequired and (Trim(Options.FileName) = '') then + raise ZbException.Create('Empty parameter: file_name'); + if (not FileNameRequired) and (Options.FileName <> '') + and (Trim(Options.FileName) = '') then + raise ZbException.Create('Empty parameter: file_name'); + + Body := Concat('api_key=', EncodeParam(ZbApiKey)); + Body := Concat(Body, '&has_header_row=', P3BoolField(Options.HasHeaderRow)); + if Options.FileName <> '' then + Body := Concat(Body, '&file_name=', EncodeParam(Options.FileName)); + if Options.RemoveDuplicatesSpecified then + Body := Concat(Body, '&remove_duplicates=', P3BoolField(Options.RemoveDuplicates)); + if Options.ReturnUrl <> '' then + Body := Concat(Body, '&return_url=', EncodeParam(Options.ReturnUrl)); + + response := ZBFormPostRequest(Concat(ZbBulkRootURL, Endpoint), Body); + 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 ZbEmailFinderS3File(Options: TZbBulkFileJobOptions): TZBFileFeedback; +begin + Result := P3BulkRootFileJob('/emailFinderS3file', Options, True); +end; + +function ZbDomainSearchS3File(Options: TZbBulkFileJobOptions): TZBFileFeedback; +begin + Result := P3BulkRootFileJob('/domainSearchS3file', Options, True); +end; + +function ZbValidateSFTPFile(Options: TZbBulkFileJobOptions): TZBFileFeedback; +begin + { The one job whose file_name the docs make optional. } + Result := P3BulkRootFileJob('/validateSFTPfile', Options, False); +end; + +function ZbScoreSFTPFile(Options: TZbBulkFileJobOptions): TZBFileFeedback; +begin + Result := P3BulkRootFileJob('/scoreSFTPfile', Options, True); +end; + +function ZbEmailFinderSFTPFile(Options: TZbBulkFileJobOptions): TZBFileFeedback; +begin + Result := P3BulkRootFileJob('/emailFinderSFTPfile', Options, True); +end; + +function ZbDomainSearchSFTPFile(Options: TZbBulkFileJobOptions): TZBFileFeedback; +begin + Result := P3BulkRootFileJob('/domainSearchSFTPfile', Options, True); +end; + +function ZbExportSFTPFile(const FileId, ExportPath: String): TZBFileFeedback; +var + Body: String; + response: TZbRequestResponse; + error: ZbException; +begin + if Trim(FileId) = '' then + raise ZbException.Create('Empty parameter: file_id'); + if (ExportPath <> '') and (Trim(ExportPath) = '') then + raise ZbException.Create('Empty parameter: export_path'); + + Body := Concat('api_key=', EncodeParam(ZbApiKey), '&file_id=', EncodeParam(FileId)); + if ExportPath <> '' then + Body := Concat(Body, '&export_path=', EncodeParam(ExportPath)); + + response := ZBFormPostRequest(Concat(ZbBulkRootURL, '/exportSFTPfile'), Body); + 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 P3JsonEscapeValue(const Value: String): String; +begin + Result := StringReplace(Value, '\', '\\', [rfReplaceAll]); + Result := StringReplace(Result, '"', '\"', [rfReplaceAll]); +end; + +function P3InboxPlacementRequest(const Method, Path, ExtraQuery, Body: String): String; +var + UrlToAccess: String; +begin + UrlToAccess := Concat(ZbInboxPlacementURL, Path); + { The API key goes on the query string here, not in the body. } + UrlToAccess := Concat(UrlToAccess, '?api_key=', EncodeParam(ZbApiKey), ExtraQuery); + Result := ZBSendRequest(Method, UrlToAccess, Body, JSON_CONTENT_TYPE).Payload; +end; + +function P3InboxPlacementTestSegment(const TestId: String): String; +begin + if Trim(TestId) = '' then + raise ZbException.Create('Empty parameter: test_id'); + Result := EncodePathSegment(Trim(TestId)); +end; + +function ZbInboxPlacementProviders: String; +begin + Result := P3InboxPlacementRequest('GET', '/providers', '', ''); +end; + +function ZbInboxPlacementTestTypes: String; +begin + Result := P3InboxPlacementRequest('GET', '/test-types', '', ''); +end; + +function ZbCreateInboxPlacementTest(Name: String; + Options: PTZbCreateInboxPlacementTestOptions): String; +var + Body: String; +begin + if Trim(Name) = '' then + raise ZbException.Create('Empty parameter: name'); + + { camelCase JSON, with unset fields left out. } + Body := Concat('{"name":"', P3JsonEscapeValue(Name), '"'); + if Options <> nil then + begin + if Options^.TestTypeSpecified then + Body := Concat(Body, ',"testType":', IntToStr(Options^.TestType)); + if Options^.SenderEmail <> '' then + Body := Concat(Body, ',"senderEmail":"', P3JsonEscapeValue(Options^.SenderEmail), '"'); + if Options^.WebhookUrl <> '' then + Body := Concat(Body, ',"webhookUrl":"', P3JsonEscapeValue(Options^.WebhookUrl), '"'); + end; + Body := Concat(Body, '}'); + + Result := P3InboxPlacementRequest('POST', '/test', '', Body); +end; + +function ZbInboxPlacementTest(TestId: String): String; +begin + Result := P3InboxPlacementRequest( + 'GET', Concat('/test/', P3InboxPlacementTestSegment(TestId)), '', '' + ); +end; + +function ZbListInboxPlacementTests(Options: PTZbListInboxPlacementTestsOptions): String; +var + Query: String; +begin + Query := ''; + if Options <> nil then + begin + if Options^.Status <> '' then + Query := Concat(Query, '&status=', EncodeParam(Options^.Status)); + if Options^.PageSpecified then + Query := Concat(Query, '&page=', IntToStr(Options^.Page)); + if Options^.PageSizeSpecified then + Query := Concat(Query, '&pageSize=', IntToStr(Options^.PageSize)); + end; + Result := P3InboxPlacementRequest('GET', '/test', Query, ''); +end; + +function ZbDeleteInboxPlacementTest(TestId: String): String; +begin + Result := P3InboxPlacementRequest( + 'DELETE', Concat('/test/', P3InboxPlacementTestSegment(TestId)), '', '' + ); +end; + +function ZbInboxPlacementWebhook: String; +begin + Result := P3InboxPlacementRequest('GET', '/webhook', '', ''); +end; + +function ZbSetInboxPlacementWebhook(WebhookUrl: String): String; +begin + if Trim(WebhookUrl) = '' then + raise ZbException.Create('Empty parameter: webhook_url'); + Result := P3InboxPlacementRequest( + 'PUT', '/webhook', '', + Concat('{"webhookUrl":"', P3JsonEscapeValue(WebhookUrl), '"}') + ); +end; + end. diff --git a/src/zbstructures.pas b/src/zbstructures.pas index 2423f47..8ffa110 100644 --- a/src/zbstructures.pas +++ b/src/zbstructures.pas @@ -101,6 +101,12 @@ TZbValidationResult = record City: String; Zipcode: String; ProcessedAt: TDateTime; + { Returned with domain_info. } + DomainWebsiteExists: String; + DomainRegistrantCompanyName: String; + { The API's "Message" key, returned instead of a result while a + greylist retry is still running. } + Message: String; end; TZbBatchError = record @@ -422,7 +428,18 @@ implementation Result.City := JsonObj.GetString('city'); Result.Zipcode := JsonObj.GetString('zipcode'); - Result.ProcessedAt := ExtractDateTime(JsonObj.GetString('processed_at')); + { A greylist retry that is still running answers with "Message" and no + processed_at, so an absent date leaves ProcessedAt at zero rather + than raising. } + if Trim(JsonObj.GetString('processed_at')) = '' then + Result.ProcessedAt := 0 + else + Result.ProcessedAt := ExtractDateTime(JsonObj.GetString('processed_at')); + + Result.DomainWebsiteExists := JsonObj.GetString('domain_website_exists'); + Result.DomainRegistrantCompanyName := JsonObj.GetString('domain_registrant_company_name'); + { Note the capital M: that is the key the API uses. } + Result.Message := JsonObj.GetString('Message'); end; diff --git a/src/zbutility.pas b/src/zbutility.pas index f268ef0..81437f2 100644 --- a/src/zbutility.pas +++ b/src/zbutility.pas @@ -68,6 +68,14 @@ TZbRequestResponse = record { Bulk hosts. Tests may point these at a local stub. } BULK_URI: string = 'https://bulkapi.zerobounce.net/v2'; ZbBulkRootURL: string = 'https://bulkapi.zerobounce.net'; + { The list evaluator and the Inbox Placement Tester have their own base + URLs, not paths built from BULK_URI, so overriding that alone leaves them + pointing at the live API. } + ZbListEvaluatorURL: string = 'https://bulkapi.zerobounce.net/v2/listevaluator'; + ZbInboxPlacementURL: string = 'https://bulkapi.zerobounce.net/api/ipt'; + { What the last request actually sent. Offline tests assert on these. } + ZbLastRequestMethod: string = ''; + ZbLastRequestBody: string = ''; { 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 = ( @@ -91,6 +99,12 @@ TZbRequestResponse = record // 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 + { Encodes a value as a single path segment: EncodeParam is query encoding, + which may turn a space into '+'. } + function EncodePathSegment(param: String): String; + { A request with an explicit method and an optional body, for the Inbox + Placement Tester's PUT and DELETE. } + function ZBSendRequest(Method, url, Body, ContentType: String): TZbRequestResponse; 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; @@ -485,6 +499,84 @@ procedure ZbUseVerifiedTls(Client: TFPHTTPClient); ZbFinishResponse(Result); end; + function EncodePathSegment(param: String): String; + begin + { HTTPEncode / EncodeQuery are query encoders and turn a space into '+', + which is a literal plus inside a path. } + Result := StringReplace(EncodeParam(param), '+', '%20', [rfReplaceAll]); + end; + + function ZBSendRequest(Method, url, Body, ContentType: String): TZbRequestResponse; + var + {$IFDEF FPC} + Client: TFPHTTPClient; + ResponseStream: TStringStream; + {$ELSE} + Client: THTTPClient; + BodyStream: TStream; + Response: IHTTPResponse; + {$ENDIF} + begin + ZbRequireApiKey; + ZbLastRequestMethod := Method; + ZbLastRequestBody := Body; + + 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('Accept', 'application/json'); + if Body <> '' then + begin + Client.AddHeader('Content-Type', ContentType); + Client.RequestBody := TRawByteStringStream.Create(Body); + end; + ResponseStream := TStringStream.Create(''); + try + Client.HTTPMethod(Method, url, ResponseStream, []); + Result.Payload := ResponseStream.DataString; + Result.StatusCode := Client.ResponseStatusCode; + Result.ContentType := Client.GetHeader(Client.ResponseHeaders, 'Content-Type'); + finally + ResponseStream.Free; + if Body <> '' then + Client.RequestBody.Free; + Client.Free; + end; + + {$ELSE} + Client := THTTPClient.Create; + BodyStream := TStringStream.Create(Body); + try + Client.CustomHeaders['Accept'] := 'application/json'; + if Body <> '' then + Client.CustomHeaders['Content-Type'] := ContentType; + Response := Client.Execute(Method, url, BodyStream); + Result.Payload := Response.ContentAsString(); + Result.StatusCode := Response.StatusCode; + Result.ContentType := Response.HeaderValue['Content-Type']; + finally + Client.Free; + BodyStream.Free; + end; + {$ENDIF} + end; + + ZbFinishResponse(Result); + end; + function ZBFormPostRequest(url: String; FormBody: String): TZbRequestResponse; var {$IFDEF FPC} @@ -497,6 +589,8 @@ procedure ZbUseVerifiedTls(Client: TFPHTTPClient); error: ZbException; begin ZbRequireApiKey; + ZbLastRequestMethod := 'POST'; + ZbLastRequestBody := FormBody; if ZbResponseMock.StatusCode <> 0 then begin ZbResponseMock.UrlCalled := url; diff --git a/src/zbvalidation.pas b/src/zbvalidation.pas index 01f0723..918ab49 100644 --- a/src/zbvalidation.pas +++ b/src/zbvalidation.pas @@ -19,6 +19,15 @@ TZbValidateOptions = record Timeout: Integer; ActivityData: Integer; VerifyPlus: Integer; + { -1 omit, 0 false, 1 true. Fills DomainWebsiteExists and + DomainRegistrantCompanyName on the result. } + DomainInfo: Integer; + { -1 omit, 0 false, 1 true. Retries a greylisted address in the background. } + GreylistProcessing: Integer; + { Where the greylist retry posts its result. Empty to omit. It needs + GreylistProcessing = 1 and is rejected without it, and when blank, + before any request is sent. } + CallbackUrl: String; UsePost: Integer; end; @@ -76,6 +85,9 @@ implementation Result.Timeout := -1; Result.ActivityData := -1; Result.VerifyPlus := -1; + Result.DomainInfo := -1; + Result.GreylistProcessing := -1; + Result.CallbackUrl := ''; Result.UsePost := -1; end; @@ -155,6 +167,17 @@ implementation 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.CallbackUrl <> '' then + begin + if Trim(Options.CallbackUrl) = '' then + raise ZbException.Create('Empty parameter: callback_url'); + { The callback is where the greylist retry posts its result, so it + is meaningless - and rejected by the API - without the retry. } + if Options.GreylistProcessing <> 1 then + raise ZbException.Create( + 'Invalid parameter: callback_url requires greylist_processing to be true' + ); + end; if Options.Timeout >= 0 then UrlToAccess := Concat(UrlToAccess, '&timeout=', EncodeParam(IntToStr(Options.Timeout))); if Options.ActivityData >= 0 then @@ -167,6 +190,18 @@ implementation UrlToAccess := Concat(UrlToAccess, '&verify_plus=false') else UrlToAccess := Concat(UrlToAccess, '&verify_plus=true'); + if Options.DomainInfo >= 0 then + if Options.DomainInfo = 0 then + UrlToAccess := Concat(UrlToAccess, '&domain_info=false') + else + UrlToAccess := Concat(UrlToAccess, '&domain_info=true'); + if Options.GreylistProcessing >= 0 then + if Options.GreylistProcessing = 0 then + UrlToAccess := Concat(UrlToAccess, '&greylist_processing=false') + else + UrlToAccess := Concat(UrlToAccess, '&greylist_processing=true'); + if Options.CallbackUrl <> '' then + UrlToAccess := Concat(UrlToAccess, '&callback_url=', EncodeParam(Options.CallbackUrl)); if Options.UsePost = 1 then begin FormBody := Concat( @@ -186,6 +221,18 @@ implementation FormBody := Concat(FormBody, '&verify_plus=false') else FormBody := Concat(FormBody, '&verify_plus=true'); + if Options.DomainInfo >= 0 then + if Options.DomainInfo = 0 then + FormBody := Concat(FormBody, '&domain_info=false') + else + FormBody := Concat(FormBody, '&domain_info=true'); + if Options.GreylistProcessing >= 0 then + if Options.GreylistProcessing = 0 then + FormBody := Concat(FormBody, '&greylist_processing=false') + else + FormBody := Concat(FormBody, '&greylist_processing=true'); + if Options.CallbackUrl <> '' then + FormBody := Concat(FormBody, '&callback_url=', EncodeParam(Options.CallbackUrl)); response := ZBFormPostRequest(Concat(ZbApiBaseURL, ENDPOINT_VALIDATE), FormBody); end else diff --git a/tests/testbulkjobs.pas b/tests/testbulkjobs.pas new file mode 100644 index 0000000..c93d9e9 --- /dev/null +++ b/tests/testbulkjobs.pas @@ -0,0 +1,510 @@ +unit TestBulkJobs; + +{ Offline tests for the endpoints ported from the Python SDK that were not + already on main: the WHOIS and greylist validate options, the S3 email finder + / domain search, the SFTP/FTP bulk jobs, and the Inbox Placement Tester. + + Every request is answered by ZbMockResponse; nothing here reaches the live + API. The assertions are on the url and the body that actually went out. } + +{$mode objfpc}{$H+} + +interface + +uses + Classes, SysUtils, fpcunit, testregistry, + BaseTest, ZbPriority3, ZbStructures, ZbUtility, ZbValidation; + +type + + TTestBulkJobs = class(TBaseTestCase) + protected + procedure MockOk(const Payload: String); + procedure AssertBodyField(const Name, Value: String); + procedure RefuteBodyField(const Name: String); + procedure AssertQueryField(const Name, Value: String); + procedure RefuteQueryField(const Name: String); + procedure SetUp; override; + published + procedure TestValidateSendsTheNewOptionsOnlyWhenSet; + procedure TestValidatePostSendsTheSameFieldsAsAForm; + procedure TestCallbackUrlWithoutGreylistProcessingSendsNoRequest; + procedure TestValidateTimeoutOutsideRangeSendsNoRequest; + procedure TestGreylistMessageLandsOnTheResult; + procedure TestListEvaluatorUsesItsOwnBaseUrlAndEncodesTheFileId; + procedure TestListEvaluatorRejectsABlankFileId; + procedure TestS3AndSftpJobsSendTheDocumentedFieldsOnly; + procedure TestValidateSftpFileNameIsOptional; + procedure TestExportsUseTheirOwnDestinationField; + procedure TestBulkJobsRejectInvalidArguments; + procedure TestInboxPlacementUsesItsOwnHostAndCamelCaseJson; + procedure TestInboxPlacementRejectsBlankArguments; + end; + +implementation + +const + FILE_FEEDBACK_JSON = '{"success":true,"message":"ok","file_name":"f.csv","file_id":"fid"}'; + OK_JSON = '{"ok":true}'; + +procedure TTestBulkJobs.SetUp; +begin + inherited SetUp; + ZbBulkRootURL := 'https://bulkapi.zerobounce.net'; + ZbListEvaluatorURL := 'https://bulkapi.zerobounce.net/v2/listevaluator'; + ZbInboxPlacementURL := 'https://bulkapi.zerobounce.net/api/ipt'; + ZbLastRequestMethod := ''; + ZbLastRequestBody := ''; +end; + +procedure TTestBulkJobs.MockOk(const Payload: String); +begin + ZBMockResponse(200, Payload, JSON_CONTENT_TYPE); +end; + +procedure TTestBulkJobs.AssertBodyField(const Name, Value: String); +begin + AssertTrue( + Concat('body ', ZbLastRequestBody, ' should contain ', Name, '=', Value), + Pos(Concat(Name, '=', EncodeParam(Value)), ZbLastRequestBody) > 0 + ); +end; + +procedure TTestBulkJobs.RefuteBodyField(const Name: String); +begin + AssertFalse( + Concat('body ', ZbLastRequestBody, ' should not contain ', Name), + Pos(Concat(Name, '='), ZbLastRequestBody) > 0 + ); +end; + +procedure TTestBulkJobs.AssertQueryField(const Name, Value: String); +begin + AssertTrue( + Concat('url ', ZbResponseMock.UrlCalled, ' should contain ', Name, '=', Value), + Pos(Concat(Name, '=', EncodeParam(Value)), ZbResponseMock.UrlCalled) > 0 + ); +end; + +procedure TTestBulkJobs.RefuteQueryField(const Name: String); +begin + AssertFalse( + Concat('url ', ZbResponseMock.UrlCalled, ' should not contain ', Name), + Pos(Concat('&', Name, '='), ZbResponseMock.UrlCalled) > 0 + ); +end; + +procedure TTestBulkJobs.TestValidateSendsTheNewOptionsOnlyWhenSet; +var + Options: TZbValidateOptions; +begin + MockOk('{"address":"a@example.com","status":"valid"}'); + + Options := ZbDefaultValidateOptions; + ZbValidateEmail('a@example.com', '', Options); + RefuteQueryField('timeout'); + RefuteQueryField('activity_data'); + RefuteQueryField('domain_info'); + RefuteQueryField('greylist_processing'); + RefuteQueryField('callback_url'); + + Options.Timeout := 30; + Options.ActivityData := 1; + Options.VerifyPlus := 0; + Options.DomainInfo := 1; + Options.GreylistProcessing := 1; + Options.CallbackUrl := 'https://example.com/greylist'; + ZbValidateEmail('a@example.com', '', Options); + + AssertQueryField('timeout', '30'); + { Booleans go on the wire as the strings 'true'/'false'. } + AssertQueryField('activity_data', 'true'); + AssertQueryField('verify_plus', 'false'); + AssertQueryField('domain_info', 'true'); + AssertQueryField('greylist_processing', 'true'); + AssertQueryField('callback_url', 'https://example.com/greylist'); +end; + +procedure TTestBulkJobs.TestValidatePostSendsTheSameFieldsAsAForm; +var + Options: TZbValidateOptions; +begin + MockOk('{"address":"a@example.com","status":"valid"}'); + + Options := ZbDefaultValidateOptions; + Options.UsePost := 1; + Options.DomainInfo := 1; + ZbValidateEmail('a@example.com', '', Options); + + AssertEquals('POST', ZbLastRequestMethod); + AssertBodyField('email', 'a@example.com'); + AssertBodyField('domain_info', 'true'); +end; + +procedure TTestBulkJobs.TestCallbackUrlWithoutGreylistProcessingSendsNoRequest; +var + Options: TZbValidateOptions; + Raised: Boolean; +begin + MockOk(OK_JSON); + ZbResponseMock.UrlCalled := ''; + + Options := ZbDefaultValidateOptions; + Options.CallbackUrl := 'https://example.com/greylist'; + Raised := False; + try + ZbValidateEmail('a@example.com', '', Options); + except on e: Exception do + begin + Raised := True; + AssertTrue(e.Message, Pos('greylist_processing', e.Message) > 0); + end; + end; + AssertTrue('callback_url without greylist_processing should be rejected', Raised); + + { Explicit false is still not enough. } + Options.GreylistProcessing := 0; + Raised := False; + try + ZbValidateEmail('a@example.com', '', Options); + except on e: Exception do + Raised := True; + end; + AssertTrue('greylist_processing false should be rejected', Raised); + + AssertEquals('no request should have been sent', '', ZbResponseMock.UrlCalled); +end; + +procedure TTestBulkJobs.TestValidateTimeoutOutsideRangeSendsNoRequest; +var + Options: TZbValidateOptions; + Raised: Boolean; +begin + MockOk(OK_JSON); + ZbResponseMock.UrlCalled := ''; + + Options := ZbDefaultValidateOptions; + Options.Timeout := 2; + Raised := False; + try + ZbValidateEmail('a@example.com', '', Options); + except on e: Exception do + begin + Raised := True; + AssertTrue(e.Message, Pos('timeout', e.Message) > 0); + end; + end; + AssertTrue('a timeout below 3 should be rejected', Raised); + AssertEquals('no request should have been sent', '', ZbResponseMock.UrlCalled); +end; + +procedure TTestBulkJobs.TestGreylistMessageLandsOnTheResult; +var + Validation: TZbValidationResult; +begin + { While a greylist retry is still running the API answers with "Message" + instead of a result. } + MockOk('{"address":"a@example.com","Message":"Greylisted email, we will retry",' + + '"domain_website_exists":"true","domain_registrant_company_name":"Example Inc"}'); + + Validation := ZbValidateEmail('a@example.com', ''); + + AssertEquals('Greylisted email, we will retry', Validation.Message); + AssertEquals('true', Validation.DomainWebsiteExists); + AssertEquals('Example Inc', Validation.DomainRegistrantCompanyName); +end; + + + + +procedure TTestBulkJobs.TestListEvaluatorUsesItsOwnBaseUrlAndEncodesTheFileId; +begin + MockOk(OK_JSON); + + ZbGetListEvaluatorResult('a b/c'); + + { The file id is one path segment, so a slash in it must stay encoded, and + a space must not become a plus. } + AssertEndpointCalled('/v2/listevaluator/a%20b%2Fc/'); +end; + +procedure TTestBulkJobs.TestListEvaluatorRejectsABlankFileId; +var + Raised: Boolean; +begin + MockOk(OK_JSON); + ZbResponseMock.UrlCalled := ''; + Raised := False; + try + ZbGetListEvaluatorResult(' '); + except on e: Exception do + begin + Raised := True; + AssertTrue(e.Message, Pos('file_id', e.Message) > 0); + end; + end; + AssertTrue('a blank file id should be rejected', Raised); + AssertEquals('no request should have been sent', '', ZbResponseMock.UrlCalled); +end; + +procedure TTestBulkJobs.TestS3AndSftpJobsSendTheDocumentedFieldsOnly; +var + Options: TZbBulkFileJobOptions; +begin + MockOk(FILE_FEEDBACK_JSON); + + Options := ZbDefaultBulkFileJobOptions; + Options.FileName := 'lists/leads.csv'; + ZbEmailFinderS3File(Options); + + AssertEquals('POST', ZbLastRequestMethod); + { The S3 and SFTP jobs live on the bulkapi root, WITHOUT /v2. } + AssertEquals( + 'https://bulkapi.zerobounce.net/emailFinderS3file', ZbResponseMock.UrlCalled + ); + AssertBodyField('file_name', 'lists/leads.csv'); + AssertBodyField('has_header_row', 'true'); + { Unset options stay off the request entirely. } + RefuteBodyField('remove_duplicates'); + RefuteBodyField('return_url'); + + Options.HasHeaderRow := False; + Options.RemoveDuplicatesSpecified := True; + Options.RemoveDuplicates := True; + Options.ReturnUrl := 'https://example.com/done'; + ZbDomainSearchS3File(Options); + + AssertEquals( + 'https://bulkapi.zerobounce.net/domainSearchS3file', ZbResponseMock.UrlCalled + ); + AssertBodyField('has_header_row', 'false'); + AssertBodyField('remove_duplicates', 'true'); + AssertBodyField('return_url', 'https://example.com/done'); + + Options := ZbDefaultBulkFileJobOptions; + Options.FileName := 'my_file.txt'; + ZbScoreSFTPFile(Options); + AssertEquals('https://bulkapi.zerobounce.net/scoreSFTPfile', ZbResponseMock.UrlCalled); + ZbEmailFinderSFTPFile(Options); + AssertEquals('https://bulkapi.zerobounce.net/emailFinderSFTPfile', ZbResponseMock.UrlCalled); + ZbDomainSearchSFTPFile(Options); + AssertEquals('https://bulkapi.zerobounce.net/domainSearchSFTPfile', ZbResponseMock.UrlCalled); +end; + +procedure TTestBulkJobs.TestValidateSftpFileNameIsOptional; +var + Options: TZbBulkFileJobOptions; +begin + MockOk(FILE_FEEDBACK_JSON); + + { file_name is optional for validateSFTPfile alone: without it the input + bucket from the account's settings is used. } + Options := ZbDefaultBulkFileJobOptions; + ZbValidateSFTPFile(Options); + + AssertEquals('https://bulkapi.zerobounce.net/validateSFTPfile', ZbResponseMock.UrlCalled); + RefuteBodyField('file_name'); + AssertBodyField('has_header_row', 'true'); + + Options.FileName := 'my_file.txt'; + ZbValidateSFTPFile(Options); + AssertBodyField('file_name', 'my_file.txt'); +end; + +procedure TTestBulkJobs.TestExportsUseTheirOwnDestinationField; +begin + MockOk(FILE_FEEDBACK_JSON); + + { exportSFTPfile takes export_path, not the export_bucket exportS3file uses. } + ZbExportSFTPFile('fid', '/exports'); + AssertEquals('https://bulkapi.zerobounce.net/exportSFTPfile', ZbResponseMock.UrlCalled); + AssertBodyField('file_id', 'fid'); + AssertBodyField('export_path', '/exports'); + RefuteBodyField('export_bucket'); + + ZbExportSFTPFile('fid', ''); + RefuteBodyField('export_path'); + + ZbExportS3File('fid', 'bucket-out'); + AssertEquals('https://bulkapi.zerobounce.net/exportS3file', ZbResponseMock.UrlCalled); + AssertBodyField('export_bucket', 'bucket-out'); + + ZbExportS3File('fid', ''); + RefuteBodyField('export_bucket'); +end; + +procedure TTestBulkJobs.TestBulkJobsRejectInvalidArguments; +var + Options: TZbBulkFileJobOptions; + Raised: Boolean; + + procedure ExpectRejected(const Fragment: String); + begin + AssertTrue(Concat('expected a rejection mentioning ', Fragment), Raised); + end; + +begin + MockOk(FILE_FEEDBACK_JSON); + ZbResponseMock.UrlCalled := ''; + + Options := ZbDefaultBulkFileJobOptions; + Raised := False; + try + ZbEmailFinderS3File(Options); + except on e: Exception do + begin + Raised := True; + AssertTrue(e.Message, Pos('file_name', e.Message) > 0); + end; + end; + ExpectRejected('file_name'); + + { An unset name is allowed for validateSFTPfile, but a blank one is not. } + Options.FileName := ' '; + Raised := False; + try + ZbValidateSFTPFile(Options); + except on e: Exception do + begin + Raised := True; + AssertTrue(e.Message, Pos('file_name', e.Message) > 0); + end; + end; + ExpectRejected('file_name'); + + Raised := False; + try + ZbExportSFTPFile(' ', ''); + except on e: Exception do + begin + Raised := True; + AssertTrue(e.Message, Pos('file_id', e.Message) > 0); + end; + end; + ExpectRejected('file_id'); + + Raised := False; + try + ZbExportSFTPFile('fid', ' '); + except on e: Exception do + begin + Raised := True; + AssertTrue(e.Message, Pos('export_path', e.Message) > 0); + end; + end; + ExpectRejected('export_path'); + + AssertEquals('no request should have been sent', '', ZbResponseMock.UrlCalled); +end; + + +procedure TTestBulkJobs.TestInboxPlacementUsesItsOwnHostAndCamelCaseJson; +var + CreateOptions: TZbCreateInboxPlacementTestOptions; + ListOptions: TZbListInboxPlacementTestsOptions; +begin + MockOk(OK_JSON); + + ZbInboxPlacementProviders; + AssertEndpointCalled('/api/ipt/providers'); + { The API key goes on the query string here, not in the body. } + AssertQueryField('api_key', MOCK_API_KEY); + + ZbInboxPlacementTestTypes; + AssertEndpointCalled('/api/ipt/test-types'); + + FillChar(CreateOptions, SizeOf(CreateOptions), 0); + CreateOptions.TestTypeSpecified := True; + CreateOptions.TestType := 1; + CreateOptions.SenderEmail := 'news@example.com'; + CreateOptions.WebhookUrl := 'https://example.com/ipt'; + ZbCreateInboxPlacementTest('Launch', @CreateOptions); + + AssertEquals('POST', ZbLastRequestMethod); + AssertEndpointCalled('/api/ipt/test'); + { Bodies are camelCase JSON, and unset fields are left out. } + AssertEquals( + '{"name":"Launch","testType":1,"senderEmail":"news@example.com",' + + '"webhookUrl":"https://example.com/ipt"}', + ZbLastRequestBody + ); + + ZbCreateInboxPlacementTest('Launch', nil); + AssertEquals('{"name":"Launch"}', ZbLastRequestBody); + + FillChar(ListOptions, SizeOf(ListOptions), 0); + ListOptions.Status := 'finished'; + ListOptions.PageSpecified := True; + ListOptions.Page := 1; + ListOptions.PageSizeSpecified := True; + ListOptions.PageSize := 25; + ZbListInboxPlacementTests(@ListOptions); + AssertQueryField('status', 'finished'); + AssertQueryField('page', '1'); + AssertQueryField('pageSize', '25'); + + ZbListInboxPlacementTests(nil); + RefuteQueryField('status'); + RefuteQueryField('page'); + + ZbDeleteInboxPlacementTest('a b/c'); + AssertEquals('DELETE', ZbLastRequestMethod); + AssertEndpointCalled('/api/ipt/test/a%20b%2Fc'); + + ZbSetInboxPlacementWebhook('https://example.com/ipt'); + AssertEquals('PUT', ZbLastRequestMethod); + AssertEndpointCalled('/api/ipt/webhook'); + AssertEquals('{"webhookUrl":"https://example.com/ipt"}', ZbLastRequestBody); + + ZbInboxPlacementWebhook; + AssertEndpointCalled('/api/ipt/webhook'); +end; + +procedure TTestBulkJobs.TestInboxPlacementRejectsBlankArguments; +var + Raised: Boolean; +begin + MockOk(OK_JSON); + ZbResponseMock.UrlCalled := ''; + + Raised := False; + try + ZbInboxPlacementTest(' '); + except on e: Exception do + begin + Raised := True; + AssertTrue(e.Message, Pos('test_id', e.Message) > 0); + end; + end; + AssertTrue('a blank test id should be rejected', Raised); + + Raised := False; + try + ZbCreateInboxPlacementTest(' ', nil); + except on e: Exception do + begin + Raised := True; + AssertTrue(e.Message, Pos('name', e.Message) > 0); + end; + end; + AssertTrue('a blank name should be rejected', Raised); + + Raised := False; + try + ZbSetInboxPlacementWebhook(''); + except on e: Exception do + begin + Raised := True; + AssertTrue(e.Message, Pos('webhook_url', e.Message) > 0); + end; + end; + AssertTrue('a blank webhook url should be rejected', Raised); + + AssertEquals('no request should have been sent', '', ZbResponseMock.UrlCalled); +end; + +initialization + + RegisterTest(TTestBulkJobs); + +end. diff --git a/tests/testoffline.pas b/tests/testoffline.pas index ed745d8..9d1d27b 100644 --- a/tests/testoffline.pas +++ b/tests/testoffline.pas @@ -345,6 +345,11 @@ procedure UseStub(const Key: String); ZbApiBaseURL := Format('http://127.0.0.1:%d/v2', [Stub.Port]); BULK_URI := ZbApiBaseURL; ZbBulkRootURL := Format('http://127.0.0.1:%d', [Stub.Port]); + { The list evaluator and the Inbox Placement Tester are separate base URLs, + not paths built from BULK_URI, so they have to be pointed at the stub too - + otherwise these tests reach the live API. } + ZbListEvaluatorURL := Format('http://127.0.0.1:%d/v2/listevaluator', [Stub.Port]); + ZbInboxPlacementURL := Format('http://127.0.0.1:%d/api/ipt', [Stub.Port]); ZbResponseMock := cDefaultMock; end; diff --git a/tests/unit_tests_console.lpr b/tests/unit_tests_console.lpr index 9286af6..28b070a 100644 --- a/tests/unit_tests_console.lpr +++ b/tests/unit_tests_console.lpr @@ -7,7 +7,8 @@ cthreads, {$ENDIF} Classes, SysUtils, consoletestrunner, - TestGeneric, TestValidation, TestDeserialization, TestBulk, TestPriority3, TestUtility, TestOffline; + TestGeneric, TestValidation, TestDeserialization, TestBulk, TestPriority3, TestUtility, TestOffline, + TestBulkJobs; var App: TTestRunner; From ce6f91a29ae84adc3ec04676438144c80e252360 Mon Sep 17 00:00:00 2001 From: Francisc Balint <7178547+franciscbalint@users.noreply.github.com> Date: Tue, 20 Oct 2026 22:37:02 +0400 Subject: [PATCH 2/8] test: cover ai_agent_mailbox sub_status (RD-66) The API now returns ai_agent_mailbox under both the valid and catch-all statuses. No new status was added. No source change is needed here. Unlike the other SDKs, this one has no sub_status enum or constant list: TZbValidationResult.SubStatus is a plain String assigned straight from the JSON, so ai_agent_mailbox already reaches callers, and so does any sub_status added in future. Adds tests that pin both: ai_agent_mailbox under valid and under catch-all, and an unrecognised value passing through untouched. Note for whoever writes these next: ZbValidationFromJson parses processed_at unconditionally and raises EConvertError when it is absent, so a fixture cannot be trimmed down to just the fields under test. Co-Authored-By: Claude Opus 5 --- tests/testdeserialization.pas | 39 +++++++++++++++++++++++++++++++++++ 1 file changed, 39 insertions(+) diff --git a/tests/testdeserialization.pas b/tests/testdeserialization.pas index 22e9e0a..68efa91 100644 --- a/tests/testdeserialization.pas +++ b/tests/testdeserialization.pas @@ -15,6 +15,8 @@ TTestDeserialization = class(TTestCase) procedure TestApiUsageParse; procedure TestValidationParseValidContent; procedure TestValidationParseInvalidContent; + procedure TestValidationParseAiAgentMailbox; + procedure TestValidationParseUnknownSubStatus; procedure TestBatchErrorParse; procedure TestBatchValidationParseOkContent; procedure TestBatchValidationParseErrorContent; @@ -51,6 +53,43 @@ procedure TTestDeserialization.TestValidationParseValidContent; ); end; +procedure TTestDeserialization.TestValidationParseAiAgentMailbox; +var + validation: TZbValidationResult; +begin + // ai_agent_mailbox is returned under both valid and catch-all. + validation := ZbValidationFromJson( + '{"address": "agent@example.com", "status": "valid",' + + ' "sub_status": "ai_agent_mailbox",' + + ' "processed_at": "2026-10-20 12:00:00.000"}' + ); + AssertEquals('status', validation.Status, 'valid'); + AssertEquals('sub_status', validation.SubStatus, 'ai_agent_mailbox'); + + validation := ZbValidationFromJson( + '{"address": "agent@example.com", "status": "catch-all",' + + ' "sub_status": "ai_agent_mailbox",' + + ' "processed_at": "2026-10-20 12:00:00.000"}' + ); + AssertEquals('status', validation.Status, 'catch-all'); + AssertEquals('sub_status', validation.SubStatus, 'ai_agent_mailbox'); +end; + +procedure TTestDeserialization.TestValidationParseUnknownSubStatus; +var + validation: TZbValidationResult; +begin + // SubStatus is a plain String with no enum behind it, so a value this SDK + // predates reaches the caller untouched. That is why ai_agent_mailbox + // needed no change here. + validation := ZbValidationFromJson( + '{"address": "future@example.com", "status": "valid",' + + ' "sub_status": "some_future_sub_status",' + + ' "processed_at": "2026-10-20 12:00:00.000"}' + ); + AssertEquals('sub_status', validation.SubStatus, 'some_future_sub_status'); +end; + procedure TTestDeserialization.TestValidationParseInvalidContent; var validation: TZbValidationResult; From b4fd2f5aacb62aad073d24d0dc0f519478e44b0a Mon Sep 17 00:00:00 2001 From: Francisc Balint <7178547+franciscbalint@users.noreply.github.com> Date: Sat, 24 Oct 2026 10:27:36 +0400 Subject: [PATCH 3/8] fix: correct validatebatch, the Inbox Placement Tester and bulk file jobs against the live API MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Verified against the live API; where it disagrees with the docs the API wins and the call site says so. - validatebatch takes `timeout` as a JSON string. Sent as a number the API rejects the whole batch with the account-level "Invalid API Key or your account ran out of credits". - The Inbox Placement Tester authenticates with `Authorization: Bearer`, not the `api_key` query parameter the rest of the API takes, and its listing is `GET /tests` (plural) — `/test` answers 'Method "GET" not allowed'. ZBSendRequestWithHeader carries the one extra header, and ZbLastRequestHeader records it for the offline tests. - Every S3/SFTP job requires both `file_name` and `return_url`, including validateSFTPfile, whose file_name the docs call optional. - send-evaluated-file takes the file id as a form field, not a path segment. - A `"success": false` field on an HTTP 200 is a failure, not a result: getfile and deletefile report an unknown or deleted file id that way, so the response now raises with the HTTP status kept on the error. Co-Authored-By: Claude Opus 5 --- src/zbpriority3.pas | 101 ++++++++++++++++++++++++------------ src/zbutility.pas | 51 +++++++++++++++++- src/zbvalidation.pas | 5 +- tests/testbulk.pas | 82 ++++++++++++++++++----------- tests/testbulkjobs.pas | 109 +++++++++++++++++++++++++++++++++------ tests/testvalidation.pas | 11 +++- 6 files changed, 277 insertions(+), 82 deletions(-) diff --git a/src/zbpriority3.pas b/src/zbpriority3.pas index 8574552..0d590b6 100644 --- a/src/zbpriority3.pas +++ b/src/zbpriority3.pas @@ -103,7 +103,7 @@ function ZbDomainSearchResultDelete(FileId: String): TZBFileFeedback; function ZbSendListEvaluatorFile(FileContent: String; EmailAddressColumn: Integer): TZBFileFeedback; function ZbGetListEvaluatorResult(FileId: String): String; -function ZbSendEvaluatedFile(FileContent: String): TZBFileFeedback; +function ZbSendEvaluatedFile(FileId: String): TZBFileFeedback; function ZbAddFilter(const Rule, Target, Value: String): String; function ZbListFilters(UsePost: Boolean): String; @@ -123,8 +123,10 @@ function ZbExportS3File(const FileId, ExportBucket: String): TZBFileFeedback; your ZeroBounce settings. } function ZbEmailFinderS3File(Options: TZbBulkFileJobOptions): TZBFileFeedback; function ZbDomainSearchS3File(Options: TZbBulkFileJobOptions): TZBFileFeedback; -{ This is the one job whose file_name the docs list as optional: leave - Options.FileName empty to use the input bucket from your settings. } +{ LIVE: file_name and return_url are required on every S3/SFTP job, including + this one, although the docs list file_name as optional here. Without either + the API answers "Empty parameter: file_name" / "Empty parameter: return_url", + so both are checked before the request leaves. } function ZbValidateSFTPFile(Options: TZbBulkFileJobOptions): TZBFileFeedback; function ZbScoreSFTPFile(Options: TZbBulkFileJobOptions): TZBFileFeedback; function ZbEmailFinderSFTPFile(Options: TZbBulkFileJobOptions): TZBFileFeedback; @@ -134,9 +136,11 @@ function ZbDomainSearchSFTPFile(Options: TZbBulkFileJobOptions): TZBFileFeedback function ZbExportSFTPFile(const FileId, ExportPath: String): TZBFileFeedback; function ZbDefaultBulkFileJobOptions: TZbBulkFileJobOptions; -{ Inbox Placement Tester (/api/ipt). Its own host, the API key on the query - string, camelCase JSON bodies, and errors under a "detail" key. Spends the - account's inbox-test credits. } +{ Inbox Placement Tester (/api/ipt). Its own host, camelCase JSON bodies, and + errors under a "detail" key. Spends the account's inbox-test credits. + + LIVE: it authenticates with an Authorization: Bearer header, not the api_key + query parameter the rest of the API takes. } function ZbInboxPlacementProviders: String; function ZbInboxPlacementTestTypes: String; function ZbCreateInboxPlacementTest(Name: String; @@ -441,13 +445,35 @@ function ZbGetListEvaluatorResult(FileId: String): String; Result := P3RawJsonGet(UrlToAccess); end; -function ZbSendEvaluatedFile(FileContent: String): TZBFileFeedback; +{ Submits a finished evaluation for validation. POST /v2/send-evaluated-file/ + + LIVE: this takes the list evaluator's file_id as a form field, not the file + upload the docs describe - an upload is rejected with + "file_id: This field is required". It returns a new validation job's file_id, + which answers "File cannot be found" for a short while after it is accepted. } +function ZbSendEvaluatedFile(FileId: String): TZBFileFeedback; var - Form: TStrings; + Body: String; + response: TZbRequestResponse; + error: ZbException; begin - Form := TStringList.Create; - Form.AddPair('api_key', ZbApiKey); - Result := P3MultipartSubmit(Concat(BULK_URI, ENDPOINT_SEND_EVALUATED_FILE), FileContent, Form); + if Trim(FileId) = '' then + raise ZbException.Create('Empty parameter: file_id'); + + Body := Concat('api_key=', EncodeParam(ZbApiKey), + '&file_id=', EncodeParam(Trim(FileId))); + response := ZBFormPostRequest( + Concat(BULK_URI, ENDPOINT_SEND_EVALUATED_FILE), Body + ); + 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 ZbAddFilter(const Rule, Target, Value: String): String; @@ -590,27 +616,29 @@ function P3BoolField(Value: Boolean): String; { POSTs one of the six documented S3 / SFTP file jobs on the bulkapi root. Booleans go on the wire as the strings 'true'/'false', and an option the caller left unset is not sent at all. } -function P3BulkRootFileJob(const Endpoint: String; Options: TZbBulkFileJobOptions; - FileNameRequired: Boolean): TZBFileFeedback; +{ POSTs one of the six documented S3 / SFTP file jobs on the bulkapi root. + + LIVE: file_name and return_url are required on every one of them, + validateSFTPfile included, although the docs list file_name as optional for + that one. Without either the API answers "This field is required". } +function P3BulkRootFileJob(const Endpoint: String; + Options: TZbBulkFileJobOptions): TZBFileFeedback; var Body: String; response: TZbRequestResponse; error: ZbException; begin - if FileNameRequired and (Trim(Options.FileName) = '') then - raise ZbException.Create('Empty parameter: file_name'); - if (not FileNameRequired) and (Options.FileName <> '') - and (Trim(Options.FileName) = '') then + if Trim(Options.FileName) = '' then raise ZbException.Create('Empty parameter: file_name'); + if Trim(Options.ReturnUrl) = '' then + raise ZbException.Create('Empty parameter: return_url'); Body := Concat('api_key=', EncodeParam(ZbApiKey)); Body := Concat(Body, '&has_header_row=', P3BoolField(Options.HasHeaderRow)); - if Options.FileName <> '' then - Body := Concat(Body, '&file_name=', EncodeParam(Options.FileName)); + Body := Concat(Body, '&file_name=', EncodeParam(Options.FileName)); + Body := Concat(Body, '&return_url=', EncodeParam(Options.ReturnUrl)); if Options.RemoveDuplicatesSpecified then Body := Concat(Body, '&remove_duplicates=', P3BoolField(Options.RemoveDuplicates)); - if Options.ReturnUrl <> '' then - Body := Concat(Body, '&return_url=', EncodeParam(Options.ReturnUrl)); response := ZBFormPostRequest(Concat(ZbBulkRootURL, Endpoint), Body); try @@ -626,33 +654,33 @@ function P3BulkRootFileJob(const Endpoint: String; Options: TZbBulkFileJobOption function ZbEmailFinderS3File(Options: TZbBulkFileJobOptions): TZBFileFeedback; begin - Result := P3BulkRootFileJob('/emailFinderS3file', Options, True); + Result := P3BulkRootFileJob('/emailFinderS3file', Options); end; function ZbDomainSearchS3File(Options: TZbBulkFileJobOptions): TZBFileFeedback; begin - Result := P3BulkRootFileJob('/domainSearchS3file', Options, True); + Result := P3BulkRootFileJob('/domainSearchS3file', Options); end; function ZbValidateSFTPFile(Options: TZbBulkFileJobOptions): TZBFileFeedback; begin { The one job whose file_name the docs make optional. } - Result := P3BulkRootFileJob('/validateSFTPfile', Options, False); + Result := P3BulkRootFileJob('/validateSFTPfile', Options); end; function ZbScoreSFTPFile(Options: TZbBulkFileJobOptions): TZBFileFeedback; begin - Result := P3BulkRootFileJob('/scoreSFTPfile', Options, True); + Result := P3BulkRootFileJob('/scoreSFTPfile', Options); end; function ZbEmailFinderSFTPFile(Options: TZbBulkFileJobOptions): TZBFileFeedback; begin - Result := P3BulkRootFileJob('/emailFinderSFTPfile', Options, True); + Result := P3BulkRootFileJob('/emailFinderSFTPfile', Options); end; function ZbDomainSearchSFTPFile(Options: TZbBulkFileJobOptions): TZBFileFeedback; begin - Result := P3BulkRootFileJob('/domainSearchSFTPfile', Options, True); + Result := P3BulkRootFileJob('/domainSearchSFTPfile', Options); end; function ZbExportSFTPFile(const FileId, ExportPath: String): TZBFileFeedback; @@ -690,12 +718,19 @@ function P3JsonEscapeValue(const Value: String): String; function P3InboxPlacementRequest(const Method, Path, ExtraQuery, Body: String): String; var - UrlToAccess: String; + UrlToAccess, Query: String; begin UrlToAccess := Concat(ZbInboxPlacementURL, Path); - { The API key goes on the query string here, not in the body. } - UrlToAccess := Concat(UrlToAccess, '?api_key=', EncodeParam(ZbApiKey), ExtraQuery); - Result := ZBSendRequest(Method, UrlToAccess, Body, JSON_CONTENT_TYPE).Payload; + { LIVE: the Inbox Placement Tester authenticates with a Bearer header. + Sent as an api_key parameter the request is rejected as unauthorized, + and the key would end up in a query string that gets logged. } + Query := ExtraQuery; + if (Query <> '') and (Query[1] = '&') then + Query := Concat('?', Copy(Query, 2, Length(Query) - 1)); + Result := ZBSendRequestWithHeader( + Method, Concat(UrlToAccess, Query), Body, JSON_CONTENT_TYPE, + 'Authorization', Concat('Bearer ', ZbApiKey) + ).Payload; end; function P3InboxPlacementTestSegment(const TestId: String): String; @@ -760,7 +795,9 @@ function ZbListInboxPlacementTests(Options: PTZbListInboxPlacementTestsOptions): if Options^.PageSizeSpecified then Query := Concat(Query, '&pageSize=', IntToStr(Options^.PageSize)); end; - Result := P3InboxPlacementRequest('GET', '/test', Query, ''); + { LIVE: the listing is GET /tests (plural). /test is the single-test path + and answers 'Method "GET" not allowed'. } + Result := P3InboxPlacementRequest('GET', '/tests', Query, ''); end; function ZbDeleteInboxPlacementTest(TestId: String): String; diff --git a/src/zbutility.pas b/src/zbutility.pas index 81437f2..8b577fa 100644 --- a/src/zbutility.pas +++ b/src/zbutility.pas @@ -76,6 +76,8 @@ TZbRequestResponse = record { What the last request actually sent. Offline tests assert on these. } ZbLastRequestMethod: string = ''; ZbLastRequestBody: string = ''; + { 'Name: Value' of the one extra header the last request carried, or ''. } + ZbLastRequestHeader: string = ''; { 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 = ( @@ -94,6 +96,9 @@ TZbRequestResponse = record function EncodeParam(param: String): String; function RedactSecrets(const S: string): string; function ZbBodyLooksLikeHtml(const Body: String): Boolean; + { Whether a JSON body says the call failed although the HTTP status was a + success. The API sends "success" as a JSON false, "false" or "False". } + function ZbSuccessMeansFailure(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 @@ -105,6 +110,10 @@ TZbRequestResponse = record { A request with an explicit method and an optional body, for the Inbox Placement Tester's PUT and DELETE. } function ZBSendRequest(Method, url, Body, ContentType: String): TZbRequestResponse; + { As ZBSendRequest, plus one extra request header. The Inbox Placement + Tester needs Authorization: Bearer. } + function ZBSendRequestWithHeader(Method, url, Body, ContentType, + HeaderName, HeaderValue: String): TZbRequestResponse; 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; @@ -261,6 +270,29 @@ procedure ZbUseVerifiedTls(Client: TFPHTTPClient); end; end; + function ZbSuccessMeansFailure(const Body: String): Boolean; + var + Trimmed, Lowered: String; + Marker: Integer; + begin + Result := False; + Trimmed := Trim(Body); + if (Trimmed = '') or (Trimmed[1] <> '{') then + Exit; + Lowered := LowerCase(Trimmed); + Marker := Pos('"success"', Lowered); + if Marker = 0 then + Exit; + { Only the value that follows the key matters, and it is either a JSON + false or the string "false". } + Lowered := Copy(Lowered, Marker + 9, 20); + Marker := Pos(':', Lowered); + if Marker = 0 then + Exit; + Lowered := Trim(Copy(Lowered, Marker + 1, Length(Lowered) - Marker)); + Result := (Copy(Lowered, 1, 5) = 'false') or (Copy(Lowered, 1, 7) = '"false"'); + end; + function ZbMessageFromBody(const Body: String; StatusCode: Integer): String; var Trimmed: String; @@ -300,7 +332,10 @@ procedure ZbUseVerifiedTls(Client: TFPHTTPClient); var Error: ZbException; begin - if ZbBodyLooksLikeHtml(Response.Payload) or (Response.StatusCode > 299) then + { LIVE: a "success": false field on an HTTP 200 is a failure, not a + result. The API answers an unknown bulk file id that way. } + if ZbBodyLooksLikeHtml(Response.Payload) or (Response.StatusCode > 299) or + ZbSuccessMeansFailure(Response.Payload) then begin Error := ZbException.FromResponse('', Response); Error.MarkHttpError; @@ -507,6 +542,12 @@ procedure ZbUseVerifiedTls(Client: TFPHTTPClient); end; function ZBSendRequest(Method, url, Body, ContentType: String): TZbRequestResponse; + begin + Result := ZBSendRequestWithHeader(Method, url, Body, ContentType, '', ''); + end; + + function ZBSendRequestWithHeader(Method, url, Body, ContentType, + HeaderName, HeaderValue: String): TZbRequestResponse; var {$IFDEF FPC} Client: TFPHTTPClient; @@ -520,6 +561,10 @@ procedure ZbUseVerifiedTls(Client: TFPHTTPClient); ZbRequireApiKey; ZbLastRequestMethod := Method; ZbLastRequestBody := Body; + if HeaderName <> '' then + ZbLastRequestHeader := Concat(HeaderName, ': ', HeaderValue) + else + ZbLastRequestHeader := ''; if ZbResponseMock.StatusCode <> 0 then begin @@ -538,6 +583,8 @@ procedure ZbUseVerifiedTls(Client: TFPHTTPClient); Client.ConnectTimeout := 30000; Client.IOTimeout := 120000; Client.AddHeader('Accept', 'application/json'); + if HeaderName <> '' then + Client.AddHeader(HeaderName, HeaderValue); if Body <> '' then begin Client.AddHeader('Content-Type', ContentType); @@ -561,6 +608,8 @@ procedure ZbUseVerifiedTls(Client: TFPHTTPClient); BodyStream := TStringStream.Create(Body); try Client.CustomHeaders['Accept'] := 'application/json'; + if HeaderName <> '' then + Client.CustomHeaders[HeaderName] := HeaderValue; if Body <> '' then Client.CustomHeaders['Content-Type'] := ContentType; Response := Client.Execute(Method, url, BodyStream); diff --git a/src/zbvalidation.pas b/src/zbvalidation.pas index 918ab49..033dd5d 100644 --- a/src/zbvalidation.pas +++ b/src/zbvalidation.pas @@ -127,8 +127,11 @@ implementation ); end; Extras := ''; + { LIVE: validatebatch takes timeout as a JSON *string*. Sent as a + number the API rejects the whole batch with the account-level + "Invalid API Key or your account ran out of credits". } if Options.Timeout >= 0 then - Extras := Extras + format(', "timeout": %d', [Options.Timeout]); + Extras := Extras + format(', "timeout": "%d"', [Options.Timeout]); if Options.ActivityData >= 0 then if Options.ActivityData = 0 then Extras := Extras + ', "activity_data": false' diff --git a/tests/testbulk.pas b/tests/testbulk.pas index 1cafb66..ab985d6 100644 --- a/tests/testbulk.pas +++ b/tests/testbulk.pas @@ -54,12 +54,12 @@ TTestBulk= class(TBaseTestCase) procedure TestResultDeleteError; procedure TestFileSubmitOk; procedure TestFileStatusCheckOk; - procedure TestResultFetchOkJson; + procedure TestResultFetchSuccessFalseIsAnError; procedure TestResultFetchOkContent; - procedure TestResultFetchJsonErrorNonJsonContentType; + procedure TestResultFetchSuccessFalseNonJsonContentType; procedure TestResultFetchWithQueryOptions; procedure TestScoringResultFetchOmitsActivityData; - procedure TestResultDeleteNotFound; + procedure TestResultDeleteNotFoundIsAnError; procedure TestResultDeleteOk; { TEST THAT EACH INDIVIDUAL METHOD WILL CALL EXPECTED ENDPOINT } @@ -342,18 +342,25 @@ procedure TTestBulk.TestFileStatusCheckOk; ); end; -procedure TTestBulk.TestResultFetchOkJson; +procedure TTestBulk.TestResultFetchSuccessFalseIsAnError; var - response: TZBBulkResponse; + Raised: Boolean; begin + { LIVE: getfile answers a deleted or unknown file id with HTTP 200 and a + "success": false field. That is a failure, not a result, and the caller + must not have to inspect a flag to notice it. } ZBMockResponse(200, BULK_RESULT_DELETED); - response := ZbBulkValidationResultFetch(MOCK_FILE_ID); - - AssertFalse(response.HasContent); - AssertEquals('success', response.Feedback.Success, False); - AssertTrue('message does not have content', Length(response.Feedback.Message) > 0); - AssertTrue('file_name should not have content', Length(response.Feedback.FileName) = 0); - AssertTrue('file_id should not have content', Length(response.Feedback.FileId) = 0); + Raised := False; + try + ZbBulkValidationResultFetch(MOCK_FILE_ID); + except on e: ZbException do + begin + Raised := True; + AssertTrue(e.Message, Pos('File deleted', e.Message) > 0); + AssertEquals('the HTTP status is kept on the error', 200, e.StatusCode); + end; + end; + AssertTrue('a "success": false body should be reported as an error', Raised); end; procedure TTestBulk.TestResultFetchOkContent; @@ -367,15 +374,24 @@ procedure TTestBulk.TestResultFetchOkContent; AssertEquals(response.Content, RESULT_CONTENT_MOCK); end; -procedure TTestBulk.TestResultFetchJsonErrorNonJsonContentType; +procedure TTestBulk.TestResultFetchSuccessFalseNonJsonContentType; var - response: TZBBulkResponse; + Raised: Boolean; begin + { The same failure arrives with an octet-stream content type, because + getfile labels every answer as a download. The body decides. } ZBMockResponse(200, '{ "success": false, "message": "File not ready" }', OCTET_STREAM_TYPE); - response := ZbBulkValidationResultFetch(MOCK_FILE_ID); - AssertFalse(response.HasContent); - AssertFalse(response.Feedback.Success); - AssertTrue(Pos('not ready', response.Feedback.Message) > 0); + Raised := False; + try + ZbBulkValidationResultFetch(MOCK_FILE_ID); + except on e: ZbException do + begin + Raised := True; + AssertTrue(e.Message, Pos('not ready', e.Message) > 0); + AssertEquals('the HTTP status is kept on the error', 200, e.StatusCode); + end; + end; + AssertTrue('a "success": false body should be reported as an error', Raised); end; procedure TTestBulk.TestResultFetchWithQueryOptions; @@ -404,17 +420,23 @@ procedure TTestBulk.TestScoringResultFetchOmitsActivityData; AssertEquals('activity_data must not appear for scoring getfile', Pos('activity_data', ZbResponseMock.UrlCalled), 0); end; -procedure TTestBulk.TestResultDeleteNotFound; +procedure TTestBulk.TestResultDeleteNotFoundIsAnError; var - response: TZBFileFeedback; + Raised: Boolean; begin + { deletefile reports an unknown id the same way: HTTP 200, success false. } ZBMockResponse(200, BULK_DELETE_NOT_FOUND); - response := ZbBulkValidationResultDelete(MOCK_FILE_ID); - - AssertEquals('success', response.Success, False); - AssertTrue('message does not have content', Length(response.Message) > 0); - AssertTrue('file_name should not have content', Length(response.FileName) = 0); - AssertTrue('file_id should not have content', Length(response.FileId) = 0); + Raised := False; + try + ZbBulkValidationResultDelete(MOCK_FILE_ID); + except on e: ZbException do + begin + Raised := True; + AssertTrue(e.Message, Pos('cannot be found', e.Message) > 0); + AssertEquals('the HTTP status is kept on the error', 200, e.StatusCode); + end; + end; + AssertTrue('a "success": false body should be reported as an error', Raised); end; procedure TTestBulk.TestResultDeleteOk; @@ -450,7 +472,7 @@ procedure TTestBulk.TestBulkValidationFileStatusCheckEndpoint; procedure TTestBulk.TestBulkValidationResultFetchEndpoint; begin - ZBMockResponse(200, BULK_RESULT_DELETED); + ZBMockResponse(200, RESULT_CONTENT_MOCK, OCTET_STREAM_TYPE); ZbBulkValidationResultFetch(MOCK_FILE_ID); AssertEndpointCalled(ENDPOINT_FILE_RESULT); if not ZbResponseMock.UrlCalled.Contains(BULK_URI) then @@ -459,7 +481,7 @@ procedure TTestBulk.TestBulkValidationResultFetchEndpoint; procedure TTestBulk.TestBulkValidationResultDeleteEndpoint; begin - ZBMockResponse(200, BULK_DELETE_NOT_FOUND); + ZBMockResponse(200, BULK_DELETE_OK); ZbBulkValidationResultDelete(MOCK_FILE_ID); AssertEndpointCalled(ENDPOINT_FILE_DELETE); if not ZbResponseMock.UrlCalled.Contains(BULK_URI) then @@ -486,7 +508,7 @@ procedure TTestBulk.TestAiScoringFileStatusCheckEndpoint; procedure TTestBulk.TestAiScoringResultFetchEndpoint; begin - ZBMockResponse(200, BULK_RESULT_DELETED); + ZBMockResponse(200, RESULT_CONTENT_MOCK, OCTET_STREAM_TYPE); ZbAiScoringResultFetch(MOCK_FILE_ID); AssertEndpointCalled(ENDPOINT_SCORING_RESULT); if not ZbResponseMock.UrlCalled.Contains(BULK_URI) then @@ -495,7 +517,7 @@ procedure TTestBulk.TestAiScoringResultFetchEndpoint; procedure TTestBulk.TestAiScoringResultDeleteEndpoint; begin - ZBMockResponse(200, BULK_DELETE_NOT_FOUND); + ZBMockResponse(200, BULK_DELETE_OK); ZbAiScoringResultDelete(MOCK_FILE_ID); AssertEndpointCalled(ENDPOINT_SCORING_DELETE); if not ZbResponseMock.UrlCalled.Contains(BULK_URI) then diff --git a/tests/testbulkjobs.pas b/tests/testbulkjobs.pas index c93d9e9..39b1915 100644 --- a/tests/testbulkjobs.pas +++ b/tests/testbulkjobs.pas @@ -33,8 +33,9 @@ TTestBulkJobs = class(TBaseTestCase) procedure TestGreylistMessageLandsOnTheResult; procedure TestListEvaluatorUsesItsOwnBaseUrlAndEncodesTheFileId; procedure TestListEvaluatorRejectsABlankFileId; + procedure TestSendEvaluatedFilePostsTheFileIdAsAFormField; procedure TestS3AndSftpJobsSendTheDocumentedFieldsOnly; - procedure TestValidateSftpFileNameIsOptional; + procedure TestEveryBulkFileJobRequiresFileNameAndReturnUrl; procedure TestExportsUseTheirOwnDestinationField; procedure TestBulkJobsRejectInvalidArguments; procedure TestInboxPlacementUsesItsOwnHostAndCamelCaseJson; @@ -50,11 +51,13 @@ implementation procedure TTestBulkJobs.SetUp; begin inherited SetUp; + BULK_URI := 'https://bulkapi.zerobounce.net/v2'; ZbBulkRootURL := 'https://bulkapi.zerobounce.net'; ZbListEvaluatorURL := 'https://bulkapi.zerobounce.net/v2/listevaluator'; ZbInboxPlacementURL := 'https://bulkapi.zerobounce.net/api/ipt'; ZbLastRequestMethod := ''; ZbLastRequestBody := ''; + ZbLastRequestHeader := ''; end; procedure TTestBulkJobs.MockOk(const Payload: String); @@ -247,6 +250,37 @@ procedure TTestBulkJobs.TestListEvaluatorRejectsABlankFileId; AssertEquals('no request should have been sent', '', ZbResponseMock.UrlCalled); end; +procedure TTestBulkJobs.TestSendEvaluatedFilePostsTheFileIdAsAFormField; +var + Raised: Boolean; +begin + MockOk(FILE_FEEDBACK_JSON); + + { LIVE: send-evaluated-file takes the file id as a form field. Appended to + the path instead it answers 404, and the trailing slash is part of the + endpoint. } + ZbSendEvaluatedFile('fid-1'); + AssertEquals('POST', ZbLastRequestMethod); + AssertEquals( + 'https://bulkapi.zerobounce.net/v2/send-evaluated-file/', + ZbResponseMock.UrlCalled + ); + AssertBodyField('file_id', 'fid-1'); + + ZbResponseMock.UrlCalled := ''; + Raised := False; + try + ZbSendEvaluatedFile(' '); + except on e: Exception do + begin + Raised := True; + AssertTrue(e.Message, Pos('file_id', e.Message) > 0); + end; + end; + AssertTrue('a blank file id should be rejected', Raised); + AssertEquals('no request should have been sent', '', ZbResponseMock.UrlCalled); +end; + procedure TTestBulkJobs.TestS3AndSftpJobsSendTheDocumentedFieldsOnly; var Options: TZbBulkFileJobOptions; @@ -255,6 +289,7 @@ procedure TTestBulkJobs.TestS3AndSftpJobsSendTheDocumentedFieldsOnly; Options := ZbDefaultBulkFileJobOptions; Options.FileName := 'lists/leads.csv'; + Options.ReturnUrl := 'https://example.com/done'; ZbEmailFinderS3File(Options); AssertEquals('POST', ZbLastRequestMethod); @@ -263,15 +298,14 @@ procedure TTestBulkJobs.TestS3AndSftpJobsSendTheDocumentedFieldsOnly; 'https://bulkapi.zerobounce.net/emailFinderS3file', ZbResponseMock.UrlCalled ); AssertBodyField('file_name', 'lists/leads.csv'); + AssertBodyField('return_url', 'https://example.com/done'); AssertBodyField('has_header_row', 'true'); - { Unset options stay off the request entirely. } + { Options that were never set stay off the request entirely. } RefuteBodyField('remove_duplicates'); - RefuteBodyField('return_url'); Options.HasHeaderRow := False; Options.RemoveDuplicatesSpecified := True; Options.RemoveDuplicates := True; - Options.ReturnUrl := 'https://example.com/done'; ZbDomainSearchS3File(Options); AssertEquals( @@ -279,36 +313,71 @@ procedure TTestBulkJobs.TestS3AndSftpJobsSendTheDocumentedFieldsOnly; ); AssertBodyField('has_header_row', 'false'); AssertBodyField('remove_duplicates', 'true'); - AssertBodyField('return_url', 'https://example.com/done'); Options := ZbDefaultBulkFileJobOptions; Options.FileName := 'my_file.txt'; + Options.ReturnUrl := 'https://example.com/done'; ZbScoreSFTPFile(Options); AssertEquals('https://bulkapi.zerobounce.net/scoreSFTPfile', ZbResponseMock.UrlCalled); ZbEmailFinderSFTPFile(Options); AssertEquals('https://bulkapi.zerobounce.net/emailFinderSFTPfile', ZbResponseMock.UrlCalled); ZbDomainSearchSFTPFile(Options); AssertEquals('https://bulkapi.zerobounce.net/domainSearchSFTPfile', ZbResponseMock.UrlCalled); + ZbValidateSFTPFile(Options); + AssertEquals('https://bulkapi.zerobounce.net/validateSFTPfile', ZbResponseMock.UrlCalled); end; -procedure TTestBulkJobs.TestValidateSftpFileNameIsOptional; +procedure TTestBulkJobs.TestEveryBulkFileJobRequiresFileNameAndReturnUrl; var Options: TZbBulkFileJobOptions; + Raised: Boolean; + + procedure ExpectRejected(const Fragment: String); + begin + AssertTrue(Concat('expected a rejection mentioning ', Fragment), Raised); + AssertEquals('no request should have been sent', '', ZbResponseMock.UrlCalled); + end; + begin MockOk(FILE_FEEDBACK_JSON); - { file_name is optional for validateSFTPfile alone: without it the input - bucket from the account's settings is used. } + { LIVE: the docs call file_name optional for validateSFTPfile, but the API + answers "Empty parameter: file_name" without it, and every job needs a + return_url as well. Both are checked before the request goes out. } + ZbResponseMock.UrlCalled := ''; Options := ZbDefaultBulkFileJobOptions; - ZbValidateSFTPFile(Options); - - AssertEquals('https://bulkapi.zerobounce.net/validateSFTPfile', ZbResponseMock.UrlCalled); - RefuteBodyField('file_name'); - AssertBodyField('has_header_row', 'true'); + Options.ReturnUrl := 'https://example.com/done'; + Raised := False; + try + ZbValidateSFTPFile(Options); + except on e: Exception do + begin + Raised := True; + AssertTrue(e.Message, Pos('file_name', e.Message) > 0); + end; + end; + ExpectRejected('file_name'); + ZbResponseMock.UrlCalled := ''; + Options := ZbDefaultBulkFileJobOptions; Options.FileName := 'my_file.txt'; + Raised := False; + try + ZbValidateSFTPFile(Options); + except on e: Exception do + begin + Raised := True; + AssertTrue(e.Message, Pos('return_url', e.Message) > 0); + end; + end; + ExpectRejected('return_url'); + + { With both present the job goes out and carries them. } + Options.ReturnUrl := 'https://example.com/done'; ZbValidateSFTPFile(Options); + AssertEquals('https://bulkapi.zerobounce.net/validateSFTPfile', ZbResponseMock.UrlCalled); AssertBodyField('file_name', 'my_file.txt'); + AssertBodyField('return_url', 'https://example.com/done'); end; procedure TTestBulkJobs.TestExportsUseTheirOwnDestinationField; @@ -359,7 +428,7 @@ procedure TTestBulkJobs.TestBulkJobsRejectInvalidArguments; end; ExpectRejected('file_name'); - { An unset name is allowed for validateSFTPfile, but a blank one is not. } + { A blank name is rejected just like a missing one. } Options.FileName := ' '; Raised := False; try @@ -407,8 +476,13 @@ procedure TTestBulkJobs.TestInboxPlacementUsesItsOwnHostAndCamelCaseJson; ZbInboxPlacementProviders; AssertEndpointCalled('/api/ipt/providers'); - { The API key goes on the query string here, not in the body. } - AssertQueryField('api_key', MOCK_API_KEY); + { LIVE: the key travels in an Authorization: Bearer header. Sent as an + api_key query parameter the request is rejected as unauthorized, and the + key would end up in whatever logs the query string. } + AssertEquals( + Concat('Authorization: Bearer ', MOCK_API_KEY), ZbLastRequestHeader + ); + RefuteQueryField('api_key'); ZbInboxPlacementTestTypes; AssertEndpointCalled('/api/ipt/test-types'); @@ -439,6 +513,9 @@ procedure TTestBulkJobs.TestInboxPlacementUsesItsOwnHostAndCamelCaseJson; ListOptions.PageSizeSpecified := True; ListOptions.PageSize := 25; ZbListInboxPlacementTests(@ListOptions); + { LIVE: the listing is GET /tests (plural). /test is the single-test path + and answers 'Method "GET" not allowed'. } + AssertEndpointCalled('/api/ipt/tests?'); AssertQueryField('status', 'finished'); AssertQueryField('page', '1'); AssertQueryField('pageSize', '25'); diff --git a/tests/testvalidation.pas b/tests/testvalidation.pas index 3926d8c..8ff2577 100644 --- a/tests/testvalidation.pas +++ b/tests/testvalidation.pas @@ -147,6 +147,7 @@ procedure TTestValidation.TestBatchBodyEncoderOneEmail; procedure TTestValidation.TestBatchBodyEncoderWithOptions; var + Body: String; JsonObj: TZbJSon; Options: TZbValidateOptions; begin @@ -155,8 +156,14 @@ procedure TTestValidation.TestBatchBodyEncoderWithOptions; Options.Timeout := 45; Options.ActivityData := 1; Options.VerifyPlus := 1; - JsonObj := TZbJSon.Create(ZbBatchRequestBodyFromEmails(EmailsAndIps1, Options)); - AssertEquals('timeout', JsonObj.GetInteger('timeout'), 45); + Body := ZbBatchRequestBodyFromEmails(EmailsAndIps1, Options); + { LIVE: validatebatch takes timeout as a JSON *string*. Sent as a number + the API rejects the whole batch with the account-level "Invalid API Key + or your account ran out of credits", so the quotes are asserted on the + raw body and not through a coercing getter. } + AssertTrue(Body, Pos('"timeout": "45"', Body) > 0); + JsonObj := TZbJSon.Create(Body); + AssertEquals('timeout', JsonObj.GetString('timeout'), '45'); AssertTrue('activity_data', JsonObj.GetBoolean('activity_data')); AssertTrue('verify_plus', JsonObj.GetBoolean('verify_plus')); end; From 475bcc862a17bd6363109665d31d41ad2b278b64 Mon Sep 17 00:00:00 2001 From: Francisc Balint <7178547+franciscbalint@users.noreply.github.com> Date: Sat, 24 Oct 2026 10:42:43 +0400 Subject: [PATCH 4/8] feat: harden the HTTP layer against leaked keys and stray hosts - ZbCheckedApiKey rejects a key with surrounding whitespace or control characters, which would otherwise be quoted unredacted in a transport error. ZBInitialize runs it; an empty key stays deferred to ZbRequireApiKey. - ZbRequireHttpsUrl allows https only, plus loopback so an offline test can point the SDK at a local stub. Every transport checks the URL it is about to call through ZbCheckedUrl. - ZbPathSegment rejects a '.' or '..' id, which would walk the request out of the endpoint it was meant for. The list evaluator file id and the Inbox Placement test id go through it. - Redirects are no longer followed (a redirect could send the key to another host) and a 3xx is reported as such. - RedactSecrets also strips the url-encoded form of the key, and every ZbException built from a response runs through it. - A regional base URL paired with the single global bulk host warns once: ZeroBounce documents no regional variant of bulkapi.zerobounce.net. Also adds ZbJsonEscape and ZBSendForeignKeyRequest, which the Warmup client needs: a request with its own key, where the validation api_key is neither required nor sent. Co-Authored-By: Claude Opus 5 --- src/zbpriority3.pas | 8 +- src/zbutility.pas | 196 ++++++++++++++++++++++++++++++++++++++++++-- 2 files changed, 189 insertions(+), 15 deletions(-) diff --git a/src/zbpriority3.pas b/src/zbpriority3.pas index 0d590b6..fef5353 100644 --- a/src/zbpriority3.pas +++ b/src/zbpriority3.pas @@ -434,12 +434,10 @@ function ZbGetListEvaluatorResult(FileId: String): String; var UrlToAccess: String; begin - if Trim(FileId) = '' then - raise ZbException.Create('Empty parameter: file_id'); { The list evaluator has its own base URL, not a path built from BULK_URI, and the file id is one path segment, so a slash must stay encoded. } UrlToAccess := Concat( - ZbListEvaluatorURL, '/', EncodePathSegment(Trim(FileId)), + ZbListEvaluatorURL, '/', ZbPathSegment(FileId, 'file_id'), '/?api_key=', EncodeParam(ZbApiKey) ); Result := P3RawJsonGet(UrlToAccess); @@ -735,9 +733,7 @@ function P3InboxPlacementRequest(const Method, Path, ExtraQuery, Body: String): function P3InboxPlacementTestSegment(const TestId: String): String; begin - if Trim(TestId) = '' then - raise ZbException.Create('Empty parameter: test_id'); - Result := EncodePathSegment(Trim(TestId)); + Result := ZbPathSegment(TestId, 'test_id'); end; function ZbInboxPlacementProviders: String; diff --git a/src/zbutility.pas b/src/zbutility.pas index 8b577fa..150b3df 100644 --- a/src/zbutility.pas +++ b/src/zbutility.pas @@ -36,6 +36,7 @@ TZbRequestResponse = record const JSON_CONTENT_TYPE = 'application/json'; + BULK_HOST = 'bulkapi.zerobounce.net'; { Bulk getfile query parameter download_type (validation and scoring). } ZB_DOWNLOAD_TYPE_PHASE_1 = 'phase_1'; ZB_DOWNLOAD_TYPE_PHASE_2 = 'phase_2'; @@ -83,6 +84,9 @@ TZbRequestResponse = record ZbResponseMock: TZbRequestResponse = ( StatusCode: 0; Payload: ''; ContentType: ''; UrlCalled: '' ); + { BULK_HOST has no documented regional variant, so pairing it with a + regional base URL is worth one warning and no more. } + ZbBulkRegionWarned: Boolean = False; { Per-thread. One thread must not call ZBInitialize for a second key while another request is in flight on that same thread. } threadvar @@ -95,6 +99,18 @@ TZbRequestResponse = record procedure ZBInitialize(ApiKey: string; ApiBaseURL: ZbApiURL); overload; function EncodeParam(param: String): String; function RedactSecrets(const S: string): string; + { The key, or a clear error. A key with surrounding whitespace or control + characters would otherwise be quoted, unredacted, in a transport error. } + function ZbCheckedApiKey(const ApiKey: String): String; + { Raises unless the URL is https. Loopback is allowed so an offline test + can point the SDK at a local stub; nothing leaves the machine there. } + procedure ZbRequireHttpsUrl(const Url, Name: String); + { One path segment, url-encoded, or a clear error. A segment of '.' or '..' + would walk the request out of the endpoint it was meant for. } + function ZbPathSegment(const Value, Name: String): String; + { The URL, after checking it is https and warning once when a regional base + URL is paired with the single global bulk host. } + function ZbCheckedUrl(const Url: String): String; function ZbBodyLooksLikeHtml(const Body: String): Boolean; { Whether a JSON body says the call failed although the HTTP status was a success. The API sends "success" as a JSON false, "false" or "False". } @@ -114,6 +130,13 @@ TZbRequestResponse = record Tester needs Authorization: Bearer. } function ZBSendRequestWithHeader(Method, url, Body, ContentType, HeaderName, HeaderValue: String): TZbRequestResponse; + { As ZBSendRequestWithHeader, for a client that carries its own key: the + validation api_key is neither required nor sent. The Warmup API is a + separate product on its own host with an API-Key header. } + function ZBSendForeignKeyRequest(Method, url, Body, ContentType, + HeaderName, HeaderValue: String): TZbRequestResponse; + { Escapes a string for a JSON double-quoted value. } + function ZbJsonEscape(const Value: String): String; 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; @@ -334,6 +357,14 @@ procedure ZbUseVerifiedTls(Client: TFPHTTPClient); begin { LIVE: a "success": false field on an HTTP 200 is a failure, not a result. The API answers an unknown bulk file id that way. } + if (Response.StatusCode >= 300) and (Response.StatusCode < 400) then + begin + Error := ZbException.FromResponse( + 'ZeroBounce returned a redirect, which this client does not follow.', Response + ); + Error.MarkHttpError; + raise Error; + end; if ZbBodyLooksLikeHtml(Response.Payload) or (Response.StatusCode > 299) or ZbSuccessMeansFailure(Response.Payload) then begin @@ -356,7 +387,8 @@ procedure ZbUseVerifiedTls(Client: TFPHTTPClient); NewMessage := AMessage else NewMessage := ZbMessageFromBody(response.Payload, response.StatusCode); - Create(NewMessage); + { Transport errors quote the URL, which carries api_key. } + Create(RedactSecrets(NewMessage)); end; procedure ZbException.MarkHttpError; @@ -381,13 +413,12 @@ procedure ZbUseVerifiedTls(Client: TFPHTTPClient); procedure ZBInitialize(ApiKey: string); begin - ZbApiKey := ApiKey; - ZbApiBaseURL := ZBApiURLToString(ZbApiURLDefault); + ZBInitialize(ApiKey, ZbApiURLDefault); end; procedure ZBInitialize(ApiKey: string; ApiBaseURL: ZbApiURL); overload; begin - ZbApiKey := ApiKey; + ZbApiKey := ZbCheckedApiKey(ApiKey); ZbApiBaseURL := ZBApiURLToString(ApiBaseURL); end; @@ -411,10 +442,98 @@ procedure ZbUseVerifiedTls(Client: TFPHTTPClient); function RedactSecrets(const S: string): string; begin - if ZbApiKey <> '' then - Result := StringReplace(S, ZbApiKey, 'REDACTED', [rfReplaceAll]) - else - Result := S; + Result := S; + if ZbApiKey = '' then + Exit; + Result := StringReplace(Result, ZbApiKey, 'REDACTED', [rfReplaceAll]); + { The key also travels url-encoded, on the query string. } + if EncodeParam(ZbApiKey) <> ZbApiKey then + Result := StringReplace(Result, EncodeParam(ZbApiKey), 'REDACTED', [rfReplaceAll]); + end; + + function ZbCheckedApiKey(const ApiKey: String): String; + var + I: Integer; + begin + { An empty key stays deferred to ZbRequireApiKey, which names the call + that initializes the SDK. } + if Trim(ApiKey) = '' then + Exit(ApiKey); + if ApiKey <> Trim(ApiKey) then + raise ZbException.Create('api_key has surrounding whitespace or control characters'); + for I := 1 to Length(ApiKey) do + if (ApiKey[I] < #32) or (ApiKey[I] = #127) then + raise ZbException.Create('api_key has surrounding whitespace or control characters'); + Result := ApiKey; + end; + + procedure ZbRequireHttpsUrl(const Url, Name: String); + var + Lower: String; + begin + Lower := LowerCase(Trim(Url)); + if Pos('https://', Lower) = 1 then + Exit; + { A local stub never leaves the machine, so plain http is allowed there + and nowhere else. } + if (Pos('http://127.0.0.1', Lower) = 1) or (Pos('http://localhost', Lower) = 1) or + (Pos('http://[::1]', Lower) = 1) then + Exit; + raise ZbException.Create(Concat(Name, ' must be an https:// URL')); + end; + + function ZbPathSegment(const Value, Name: String): String; + var + Trimmed: String; + begin + Trimmed := Trim(Value); + if Trimmed = '' then + raise ZbException.Create(Concat('Empty parameter: ', Name)); + { Everything else, slashes included, is percent-encoded and stays + inside the segment; '.' and '..' would walk the request elsewhere. } + if (Trimmed = '.') or (Trimmed = '..') then + raise ZbException.Create(Concat('Invalid parameter: ', Name)); + Result := EncodePathSegment(Trimmed); + end; + + function ZbHostOf(const Url: String): String; + var + Rest: String; + Slash: Integer; + begin + Rest := LowerCase(Trim(Url)); + Slash := Pos('://', Rest); + if Slash > 0 then + Rest := Copy(Rest, Slash + 3, MaxInt); + Slash := Pos('/', Rest); + if Slash > 0 then + Rest := Copy(Rest, 1, Slash - 1); + Result := Rest; + end; + + function ZbCheckedUrl(const Url: String): String; + var + Region: String; + begin + ZbRequireHttpsUrl(Url, 'url'); + Result := Url; + if ZbBulkRegionWarned or (ZbHostOf(Url) <> BULK_HOST) then + Exit; + Region := ''; + if Pos('api-eu.zerobounce.net', LowerCase(ZbApiBaseURL)) > 0 then + Region := 'EU' + else if Pos('api-us.zerobounce.net', LowerCase(ZbApiBaseURL)) > 0 then + Region := 'US'; + if Region = '' then + Exit; + ZbBulkRegionWarned := True; + WriteLn(StdErr, Concat( + 'ZeroBounce warning: the base URL is the ', Region, ' API, but this request goes to ', + BULK_HOST, '. ZeroBounce documents no regional host for bulk files, scoring files,', + ' email finder and domain search files, the list evaluator, S3/SFTP or the Inbox', + ' Placement Tester. Override BULK_URI, ZbBulkRootURL, ZbListEvaluatorURL and', + ' ZbInboxPlacementURL if ZeroBounce gave you regional ones.' + )); end; function ZBGetRequest(url: String): TZbRequestResponse; @@ -429,6 +548,7 @@ procedure ZbUseVerifiedTls(Client: TFPHTTPClient); error: ZbException; begin ZbRequireApiKey; + url := ZbCheckedUrl(url); if ZbResponseMock.StatusCode <> 0 then begin ZbResponseMock.UrlCalled := url; @@ -445,6 +565,8 @@ procedure ZbUseVerifiedTls(Client: TFPHTTPClient); ZbUseVerifiedTls(Client); Client.ConnectTimeout := 30000; Client.IOTimeout := 120000; + { A redirect could send the key to another host. } + Client.MaxRedirects := 0; ResponseStream := TStringStream.Create(''); try Client.HTTPMethod('GET', url, ResponseStream, [200, 201, 204, 400, 401, 403, 404, 409, 422, 429, 500, 502, 503]); @@ -458,6 +580,8 @@ procedure ZbUseVerifiedTls(Client: TFPHTTPClient); {$ELSE} Client := THTTPClient.Create; + { A redirect could send the key to another host. } + Client.HandleRedirects := False; try Response := Client.Get(url); Result.Payload := Response.ContentAsString(); @@ -485,6 +609,7 @@ procedure ZbUseVerifiedTls(Client: TFPHTTPClient); error: ZbException; begin ZbRequireApiKey; + url := ZbCheckedUrl(url); if ZbResponseMock.StatusCode <> 0 then begin ZbResponseMock.UrlCalled := url; @@ -500,6 +625,8 @@ procedure ZbUseVerifiedTls(Client: TFPHTTPClient); ZbUseVerifiedTls(Client); Client.ConnectTimeout := 30000; Client.IOTimeout := 120000; + { A redirect could send the key to another host. } + Client.MaxRedirects := 0; Client.AddHeader('Content-Type', 'application/json; charset=UTF-8'); Client.AddHeader('Accept', 'application/json'); @@ -514,6 +641,8 @@ procedure ZbUseVerifiedTls(Client: TFPHTTPClient); end; {$ELSE} Client := THTTPClient.Create; + { A redirect could send the key to another host. } + Client.HandleRedirects := False; BodyStream := TStringStream.Create(JsonParam); try Client.CustomHeaders['Content-Type'] := 'application/json; charset=UTF-8'; @@ -546,8 +675,43 @@ procedure ZbUseVerifiedTls(Client: TFPHTTPClient); Result := ZBSendRequestWithHeader(Method, url, Body, ContentType, '', ''); end; + function ZbJsonEscape(const Value: String): String; + var + I: Integer; + Ch: Char; + begin + Result := ''; + for I := 1 to Length(Value) do + begin + Ch := Value[I]; + case Ch of + '\': Result := Result + '\\'; + '"': Result := Result + '\"'; + #8: Result := Result + '\b'; + #9: Result := Result + '\t'; + #10: Result := Result + '\n'; + #12: Result := Result + '\f'; + #13: Result := Result + '\r'; + else + if Ch < #32 then + Result := Result + Format('\u%.4x', [Ord(Ch)]) + else + Result := Result + Ch; + end; + end; + end; + function ZBSendRequestWithHeader(Method, url, Body, ContentType, HeaderName, HeaderValue: String): TZbRequestResponse; + begin + ZbRequireApiKey; + Result := ZBSendForeignKeyRequest( + Method, url, Body, ContentType, HeaderName, HeaderValue + ); + end; + + function ZBSendForeignKeyRequest(Method, url, Body, ContentType, + HeaderName, HeaderValue: String): TZbRequestResponse; var {$IFDEF FPC} Client: TFPHTTPClient; @@ -558,7 +722,7 @@ procedure ZbUseVerifiedTls(Client: TFPHTTPClient); Response: IHTTPResponse; {$ENDIF} begin - ZbRequireApiKey; + url := ZbCheckedUrl(url); ZbLastRequestMethod := Method; ZbLastRequestBody := Body; if HeaderName <> '' then @@ -582,6 +746,8 @@ procedure ZbUseVerifiedTls(Client: TFPHTTPClient); ZbUseVerifiedTls(Client); Client.ConnectTimeout := 30000; Client.IOTimeout := 120000; + { A redirect could send the key to another host. } + Client.MaxRedirects := 0; Client.AddHeader('Accept', 'application/json'); if HeaderName <> '' then Client.AddHeader(HeaderName, HeaderValue); @@ -605,6 +771,8 @@ procedure ZbUseVerifiedTls(Client: TFPHTTPClient); {$ELSE} Client := THTTPClient.Create; + { A redirect could send the key to another host. } + Client.HandleRedirects := False; BodyStream := TStringStream.Create(Body); try Client.CustomHeaders['Accept'] := 'application/json'; @@ -638,6 +806,7 @@ procedure ZbUseVerifiedTls(Client: TFPHTTPClient); error: ZbException; begin ZbRequireApiKey; + url := ZbCheckedUrl(url); ZbLastRequestMethod := 'POST'; ZbLastRequestBody := FormBody; if ZbResponseMock.StatusCode <> 0 then @@ -655,6 +824,8 @@ procedure ZbUseVerifiedTls(Client: TFPHTTPClient); ZbUseVerifiedTls(Client); Client.ConnectTimeout := 30000; Client.IOTimeout := 120000; + { A redirect could send the key to another host. } + Client.MaxRedirects := 0; Client.AddHeader('Content-Type', 'application/x-www-form-urlencoded'); Client.AddHeader('Accept', 'application/json'); @@ -669,6 +840,8 @@ procedure ZbUseVerifiedTls(Client: TFPHTTPClient); end; {$ELSE} Client := THTTPClient.Create; + { A redirect could send the key to another host. } + Client.HandleRedirects := False; BodyStream := TStringStream.Create(FormBody); try Client.CustomHeaders['Content-Type'] := 'application/x-www-form-urlencoded'; @@ -727,6 +900,7 @@ procedure ZbUseVerifiedTls(Client: TFPHTTPClient); {$ENDIF} begin ZbRequireApiKey; + url := ZbCheckedUrl(url); if ZbResponseMock.StatusCode <> 0 then begin Result := ZbResponseMock; @@ -743,6 +917,8 @@ procedure ZbUseVerifiedTls(Client: TFPHTTPClient); ZbUseVerifiedTls(Client); Client.ConnectTimeout := 30000; Client.IOTimeout := 120000; + { A redirect could send the key to another host. } + Client.MaxRedirects := 0; ResponseStream := TStringStream.Create(''); try @@ -776,6 +952,8 @@ procedure ZbUseVerifiedTls(Client: TFPHTTPClient); end; {$ELSE} Client := THTTPClient.Create; + { A redirect could send the key to another host. } + Client.HandleRedirects := False; FileStream := TStringStream.Create(FileContent); MPForm := FormDataFromTString; try From 48eb76b43c79270608dcb7fbdf1c562b0e198999 Mon Sep 17 00:00:00 2001 From: Francisc Balint <7178547+franciscbalint@users.noreply.github.com> Date: Sat, 24 Oct 2026 10:42:43 +0400 Subject: [PATCH 5/8] feat: add a client for the Warmup API The Warmup API is a separate product from email validation: its own host, its own team-scoped key, and an API-Key header instead of an api_key parameter. ZbWarmupInitialize takes the Warmup key; ZBInitialize stays for the validation key, and the two never mix. All 26 documented endpoints, each returning the raw JSON body, or an empty string for the documented empty successes (HTTP 204 and an empty 200). The two CSV endpoints return the text and save it as well when given a path. Arguments are checked before anything is sent: the engagement ranges (standard rules 0-4, reply_pct 5-45, important_pct 5-40, click_pct 0-70 and at most important_pct x 1.75), ISO dates, the timezone offset, the email types and the SMTP port. Verified against the live API: - check-domain-dmarc takes an email address. The docs describe a domain, but a bare domain is answered with "Please provide a valid email address". - test-smtp-configuration answers a failed SMTP login with HTTP 500. The Warmup key and the SMTP credentials are redacted out of any error that escapes the client. ZbWarmup is registered in all three package files, which also pick up ZbPriority3 for Delphi, where it had been missing. Co-Authored-By: Claude Opus 5 --- packages/ZeroBounce.dpk | 4 +- packages/zerobounce.lpk | 5 + packages/zerobounce.pas | 4 +- src/zbwarmup.pas | 728 ++++++++++++++++++++++++++++++++++ tests/testwarmup.pas | 737 +++++++++++++++++++++++++++++++++++ tests/unit_tests_console.lpr | 2 +- 6 files changed, 1477 insertions(+), 3 deletions(-) create mode 100644 src/zbwarmup.pas create mode 100644 tests/testwarmup.pas diff --git a/packages/ZeroBounce.dpk b/packages/ZeroBounce.dpk index 771552f..4dce194 100644 --- a/packages/ZeroBounce.dpk +++ b/packages/ZeroBounce.dpk @@ -36,6 +36,8 @@ contains zbgeneric in '..\src\zbgeneric.pas', zbstructures in '..\src\zbstructures.pas', zbutility in '..\src\zbutility.pas', - zbvalidation in '..\src\zbvalidation.pas'; + zbvalidation in '..\src\zbvalidation.pas', + zbpriority3 in '..\src\zbpriority3.pas', + zbwarmup in '..\src\zbwarmup.pas'; end. diff --git a/packages/zerobounce.lpk b/packages/zerobounce.lpk index f9c8275..3528efa 100644 --- a/packages/zerobounce.lpk +++ b/packages/zerobounce.lpk @@ -49,6 +49,11 @@ + + + + + diff --git a/packages/zerobounce.pas b/packages/zerobounce.pas index a97f6b6..d80c9ee 100644 --- a/packages/zerobounce.pas +++ b/packages/zerobounce.pas @@ -8,7 +8,8 @@ interface uses - ZbBulk, ZbGeneric, ZbPriority3, ZbStructures, ZbUtility, ZbValidation, + ZbBulk, ZbGeneric, ZbPriority3, ZbStructures, ZbUtility, ZbValidation, + ZbWarmup, LazarusPackageIntf; implementation @@ -21,6 +22,7 @@ procedure Register; RegisterUnit('ZbUtility', @ZbUtility.Register); RegisterUnit('ZbValidation', @ZbValidation.Register); RegisterUnit('ZbPriority3', @ZbPriority3.Register); + RegisterUnit('ZbWarmup', @ZbWarmup.Register); end; initialization diff --git a/src/zbwarmup.pas b/src/zbwarmup.pas new file mode 100644 index 0000000..a860f04 --- /dev/null +++ b/src/zbwarmup.pas @@ -0,0 +1,728 @@ +unit ZbWarmup; + +{ Client for the ZeroBounce Warmup API (https://www.zerobounce.net/docs/warmup-api). + + The Warmup API is a separate product from email validation: its own host, its + own team-scoped key, and an API-Key header instead of an api_key parameter. + Call ZbWarmupInitialize with a Warmup API key; ZBInitialize stays for the + validation key, and the two never mix. + + Every call returns the raw JSON body, or an empty string for the documented + empty successes (HTTP 204 and an empty 200). } + +{$I zboptions.inc}{$H+} + +interface + +uses + Classes, SysUtils, ZbUtility; + +type + { ZB managed onboarding only. Empty fields are left out of the request. } + TZbWarmupEmailContent = record + CompanyName: String; + CompanyWebsite: String; + CompanyDescription: String; + { Any of transactional, newsletter, marketing, comma separated. } + TypesOfEmails: String; + ExampleEmail: String; + end; + PTZbWarmupEmailContent = ^TZbWarmupEmailContent; + + { ZB managed onboarding only, and write-only on the API. } + TZbWarmupManagedSending = record + WeekdaysOnlySpecified: Boolean; + WeekdaysOnly: Boolean; + Timezone: String; + TimezoneOffsetSpecified: Boolean; + TimezoneOffset: Integer; + { YYYY-MM-DD. } + WarmupStartDate: String; + end; + PTZbWarmupManagedSending = ^TZbWarmupManagedSending; + + TZbWarmupAddSendingAccountOptions = record + { Required for self_managed, ignored for zb_managed. } + EngagementRuleIdSpecified: Boolean; + EngagementRuleId: Integer; + { Self-managed only: do not fail with 409 when the address is already + registered. } + IgnoreConflictSpecified: Boolean; + IgnoreConflict: Boolean; + EmailContent: PTZbWarmupEmailContent; + ManagedSending: PTZbWarmupManagedSending; + end; + PTZbWarmupAddSendingAccountOptions = ^TZbWarmupAddSendingAccountOptions; + + TZbWarmupListFromAddressesOptions = record + { Letters, digits and . - _ @ + only. } + SearchFrom: String; + OffsetSpecified: Boolean; + Offset: Integer; + LimitSpecified: Boolean; + Limit: Integer; + end; + PTZbWarmupListFromAddressesOptions = ^TZbWarmupListFromAddressesOptions; + +const + ZB_WARMUP_SELF_MANAGED = 'self_managed'; + ZB_WARMUP_ZB_MANAGED = 'zb_managed'; + ZB_WARMUP_BASE_PATH = '/api/v1/warmup'; + { team_id is required by the schema but always replaced with the key's + team, so any value does. } + ZB_WARMUP_ANY_TEAM_ID = 1; + +var + { Tests may point this at a local stub. } + ZbWarmupBaseURL: string = 'https://warmup-api.zerobounce.net'; + +{ Per-thread, like the validation key. } +threadvar + ZbWarmupApiKey: string; + +procedure ZbWarmupInitialize(ApiKey: String); + +function ZbWarmupDefaultAddSendingAccountOptions: TZbWarmupAddSendingAccountOptions; +function ZbWarmupDefaultListFromAddressesOptions: TZbWarmupListFromAddressesOptions; + +{ Sending accounts } +function ZbWarmupListSendingAccounts: String; +function ZbWarmupGetSendingAccount(const FromAddress: String): String; +function ZbWarmupAddSendingAccount(const FromAddress, OnboardingModel: String; + Options: PTZbWarmupAddSendingAccountOptions): String; +function ZbWarmupUpdateSendingAccount(const FromAddress: String; + EngagementRuleId, StatusId: Integer; AutoEnable: Boolean): String; +procedure ZbWarmupDeleteSendingAccount(const FromAddress: String); +procedure ZbWarmupChangeFromAddress(const OldFromAddress, NewFromAddress: String); +function ZbWarmupListFromAddresses(Options: PTZbWarmupListFromAddressesOptions): String; + +{ Engagement rules } +function ZbWarmupGetEngagementRules: String; +procedure ZbWarmupSetEngagementRule(const CFrom: String; RuleId: Integer); +function ZbWarmupGetCustomEngagement(const CFrom: String): String; +function ZbWarmupCreateCustomEngagement(const CFrom: String; + ReplyPct, ImportantPct, ClickPct: Integer): String; +function ZbWarmupUpdateCustomEngagement(const CFrom: String; + ReplyPct, ImportantPct, ClickPct: Integer): String; +function ZbWarmupDeleteCustomEngagement(const CFrom: String; + EngagementRuleId: Integer): String; + +{ Seeds, progress and statistics } +function ZbWarmupGetSeeds: String; +function ZbWarmupDownloadSeeds: String; overload; +function ZbWarmupDownloadSeeds(const DownloadPath: String): String; overload; +function ZbWarmupGetSeedCount: String; +function ZbWarmupGetProgressToday: String; +function ZbWarmupGetSentToday: String; +function ZbWarmupGetEmailsPerDay: String; +function ZbWarmupGetStatistics(const FromAddress: String): String; +function ZbWarmupGetStatisticsPerDay(const FromAddress, StartDate, EndDate: String): String; +function ZbWarmupGetStatisticsDetailed(const CFrom: String): String; +function ZbWarmupDownloadStatistics(const CFrom: String): String; overload; +function ZbWarmupDownloadStatistics(const CFrom, DownloadPath: String): String; overload; +function ZbWarmupGetFromAddressStatistics(const FromAddress: String): String; + +{ Deliverability checks } +function ZbWarmupCheckDomainDmarc(const FromAddress: String): String; overload; +function ZbWarmupCheckDomainDmarc(const FromAddress, DkimSelector: String): String; overload; +function ZbWarmupTestSmtpConfiguration(const FromAddress, Username, Password, + Server: String; Port: Integer): String; + +procedure Register; + +implementation + +procedure ZbWarmupInitialize(ApiKey: String); +begin + if Trim(ApiKey) = '' then + raise ZbException.Create('Empty parameter: api_key'); + ZbWarmupApiKey := ZbCheckedApiKey(ApiKey); +end; + +function ZbWarmupDefaultAddSendingAccountOptions: TZbWarmupAddSendingAccountOptions; +begin + FillChar(Result, SizeOf(Result), 0); +end; + +function ZbWarmupDefaultListFromAddressesOptions: TZbWarmupListFromAddressesOptions; +begin + FillChar(Result, SizeOf(Result), 0); +end; + +procedure RequireWarmupKey; +begin + if Trim(ZbWarmupApiKey) = '' then + raise ZbException.Create( + 'The Warmup API is not initialized. Please call ZbWarmupInitialize(api_key) first' + ); +end; + +{ The Warmup key never travels in a URL, but a transport error may quote the + header, and test-smtp-configuration carries SMTP credentials in its body. } +function WarmupRedact(const Value: String; const Secrets: array of String): String; +var + I: Integer; +begin + Result := Value; + if ZbWarmupApiKey <> '' then + Result := StringReplace(Result, ZbWarmupApiKey, 'REDACTED', [rfReplaceAll]); + for I := Low(Secrets) to High(Secrets) do + if Secrets[I] <> '' then + Result := StringReplace(Result, Secrets[I], 'REDACTED', [rfReplaceAll]); +end; + +function RequiredParam(const Value, Name: String): String; +begin + if Trim(Value) = '' then + raise ZbException.Create(Concat('Empty parameter: ', Name)); + Result := Trim(Value); +end; + +function InRange(Value, Low_, High_: Integer; const Name: String): Integer; +begin + if (Value < Low_) or (Value > High_) then + raise ZbException.Create(Format( + 'Invalid parameter: %s (must be %d to %d)', [Name, Low_, High_] + )); + Result := Value; +end; + +function IsoDate(const Value, Name: String): String; +var + I: Integer; +begin + Result := Trim(Value); + if Length(Result) <> 10 then + raise ZbException.Create(Concat('Invalid parameter: ', Name, ' (expected YYYY-MM-DD)')); + for I := 1 to 10 do + if I in [5, 8] then + begin + if Result[I] <> '-' then + raise ZbException.Create(Concat('Invalid parameter: ', Name, ' (expected YYYY-MM-DD)')); + end + else if not (Result[I] in ['0'..'9']) then + raise ZbException.Create(Concat('Invalid parameter: ', Name, ' (expected YYYY-MM-DD)')); +end; + +function JsonString(const Name, Value: String): String; +begin + Result := Concat('"', Name, '":"', ZbJsonEscape(Value), '"'); +end; + +function JsonBool(const Name: String; Value: Boolean): String; +begin + if Value then + Result := Concat('"', Name, '":true') + else + Result := Concat('"', Name, '":false'); +end; + +function JsonInt(const Name: String; Value: Integer): String; +begin + Result := Concat('"', Name, '":', IntToStr(Value)); +end; + +{ A JSON array of the documented email types, from a comma-separated list. } +function ZbWarmupEmailTypesJson(const TypesOfEmails: String): String; +var + Parts: TStringList; + I: Integer; + Item: String; +begin + Result := ''; + Parts := TStringList.Create; + try + Parts.Delimiter := ','; + Parts.StrictDelimiter := True; + Parts.DelimitedText := TypesOfEmails; + for I := 0 to Parts.Count - 1 do + begin + Item := LowerCase(Trim(Parts[I])); + if Item = '' then + Continue; + if (Item <> 'transactional') and (Item <> 'newsletter') and (Item <> 'marketing') then + raise ZbException.Create( + 'Invalid parameter: types_of_emails (transactional, newsletter and/or marketing)' + ); + if Result <> '' then + Result := Concat(Result, ','); + Result := Concat(Result, '"', Item, '"'); + end; + finally + Parts.Free; + end; + Result := Concat('[', Result, ']'); +end; + +procedure AddField(var Body: String; const Field: String); +begin + if Body <> '' then + Body := Concat(Body, ','); + Body := Concat(Body, Field); +end; + +procedure AddQuery(var Query: String; const Name, Value: String); +begin + if Query = '' then + Query := '?' + else + Query := Concat(Query, '&'); + Query := Concat(Query, Name, '=', EncodeParam(Value)); +end; + +{ One Warmup request. Returns the raw body; HTTP 204 and an empty 200 are the + documented empty successes and come back as ''. } +function WarmupSend(const Method, Path, Query, Body: String; + const Secrets: array of String): TZbRequestResponse; +var + Url: String; +begin + RequireWarmupKey; + Url := Concat(ZbWarmupBaseURL, ZB_WARMUP_BASE_PATH, Path, Query); + try + Result := ZBSendForeignKeyRequest( + Method, Url, Body, JSON_CONTENT_TYPE, 'API-Key', ZbWarmupApiKey + ); + except + on e: ZbException do + begin + e.Message := WarmupRedact(e.Message, Secrets); + e.Payload := WarmupRedact(e.Payload, Secrets); + raise; + end; + on e: Exception do + raise ZbException.Create(WarmupRedact(e.Message, Secrets)); + end; +end; + +function WarmupJson(const Method, Path, Query, Body: String): String; +begin + Result := Trim(WarmupSend(Method, Path, Query, Body, []).Payload); +end; + +function WarmupJsonWithSecrets(const Method, Path, Query, Body: String; + const Secrets: array of String): String; +begin + Result := Trim(WarmupSend(Method, Path, Query, Body, Secrets).Payload); +end; + +{ A CSV download. The API labels an error as JSON or HTML, so neither is a + file. } +function WarmupCsv(const Path, Query, DownloadPath: String): String; +var + Response: TZbRequestResponse; + Handle: TFileStream; + Directory: String; +begin + Response := WarmupSend('GET', Path, Query, '', []); + Result := Response.Payload; + if (Trim(Result) = '') or (Pos(JSON_CONTENT_TYPE, LowerCase(Response.ContentType)) > 0) then + raise ZbException.FromResponse('', Response); + if DownloadPath = '' then + Exit; + Directory := ExtractFilePath(DownloadPath); + if (Directory <> '') and not DirectoryExists(Directory) then + ForceDirectories(Directory); + Handle := TFileStream.Create(DownloadPath, fmCreate); + try + if Length(Result) > 0 then + Handle.WriteBuffer(Result[1], Length(Result)); + finally + Handle.Free; + end; +end; + +{ -- sending accounts ---------------------------------------------------- } + +function ZbWarmupListSendingAccounts: String; +begin + { An empty team answers with an empty list, not a 404. } + Result := WarmupJson('GET', '/sending-accounts', '', ''); +end; + +function ZbWarmupGetSendingAccount(const FromAddress: String): String; +var + Query: String; +begin + Query := ''; + AddQuery(Query, 'from_address', RequiredParam(FromAddress, 'from_address')); + Result := WarmupJson('GET', '/sending-account', Query, ''); +end; + +function ZbWarmupAddSendingAccount(const FromAddress, OnboardingModel: String; + Options: PTZbWarmupAddSendingAccountOptions): String; +var + Body, Content, Sending: String; +begin + RequiredParam(FromAddress, 'from_address'); + if (OnboardingModel <> ZB_WARMUP_SELF_MANAGED) and (OnboardingModel <> ZB_WARMUP_ZB_MANAGED) then + raise ZbException.Create( + 'Invalid parameter: onboarding_model (must be self_managed or zb_managed)' + ); + + Body := ''; + AddField(Body, JsonString('from_address', Trim(FromAddress))); + AddField(Body, JsonString('onboarding_model', OnboardingModel)); + + if OnboardingModel = ZB_WARMUP_SELF_MANAGED then + begin + if (Options = nil) or not Options^.EngagementRuleIdSpecified then + raise ZbException.Create( + 'Empty parameter: engagement_rule_id (required for self_managed)' + ); + AddField(Body, JsonInt( + 'engagement_rule_id', + InRange(Options^.EngagementRuleId, 0, 4, 'engagement_rule_id') + )); + if (Options^.EmailContent <> nil) or (Options^.ManagedSending <> nil) then + raise ZbException.Create( + 'Invalid parameter: email_content and managed_sending are for zb_managed' + ); + end; + + if Options <> nil then + begin + if Options^.IgnoreConflictSpecified then + AddField(Body, JsonBool('ignore_conflict', Options^.IgnoreConflict)); + + if Options^.EmailContent <> nil then + begin + Content := ''; + if Options^.EmailContent^.CompanyName <> '' then + AddField(Content, JsonString('company_name', Options^.EmailContent^.CompanyName)); + if Options^.EmailContent^.CompanyWebsite <> '' then + AddField(Content, JsonString('company_website', Options^.EmailContent^.CompanyWebsite)); + if Options^.EmailContent^.CompanyDescription <> '' then + AddField(Content, JsonString('company_description', Options^.EmailContent^.CompanyDescription)); + if Options^.EmailContent^.TypesOfEmails <> '' then + AddField(Content, Concat( + '"types_of_emails":', ZbWarmupEmailTypesJson(Options^.EmailContent^.TypesOfEmails) + )); + if Options^.EmailContent^.ExampleEmail <> '' then + AddField(Content, JsonString('example_email', Options^.EmailContent^.ExampleEmail)); + AddField(Body, Concat('"email_content":{', Content, '}')); + end; + + if Options^.ManagedSending <> nil then + begin + Sending := ''; + if Options^.ManagedSending^.WeekdaysOnlySpecified then + AddField(Sending, JsonBool('weekdays_only', Options^.ManagedSending^.WeekdaysOnly)); + if Options^.ManagedSending^.Timezone <> '' then + AddField(Sending, JsonString('timezone', Options^.ManagedSending^.Timezone)); + if Options^.ManagedSending^.TimezoneOffsetSpecified then + AddField(Sending, JsonInt( + 'timezone_offset', + InRange(Options^.ManagedSending^.TimezoneOffset, -11, 14, 'timezone_offset') + )); + if Options^.ManagedSending^.WarmupStartDate <> '' then + AddField(Sending, JsonString( + 'warmup_start_date', + IsoDate(Options^.ManagedSending^.WarmupStartDate, 'warmup_start_date') + )); + AddField(Body, Concat('"managed_sending":{', Sending, '}')); + end; + end; + + { For a ZB managed account a 200 means accepted; the mailbox is + provisioned afterwards. } + Result := WarmupJson('POST', '/sending-accounts', '', Concat('{', Body, '}')); +end; + +function ZbWarmupUpdateSendingAccount(const FromAddress: String; + EngagementRuleId, StatusId: Integer; AutoEnable: Boolean): String; +var + Body: String; +begin + RequiredParam(FromAddress, 'from_address'); + Body := ''; + AddField(Body, JsonInt('team_id', ZB_WARMUP_ANY_TEAM_ID)); + AddField(Body, JsonString('from_address', Trim(FromAddress))); + AddField(Body, JsonInt('engagement_rule_id', + InRange(EngagementRuleId, 0, 4, 'engagement_rule_id'))); + { 0 to 8; 4 is Paused and 5 is Disabled. } + AddField(Body, JsonInt('status_id', InRange(StatusId, 0, 8, 'status_id'))); + { auto_enable is accepted only for an account that is not active. } + AddField(Body, JsonBool('auto_enable', AutoEnable)); + Result := WarmupJson('PUT', '/sending-accounts', '', Concat('{', Body, '}')); +end; + +procedure ZbWarmupDeleteSendingAccount(const FromAddress: String); +var + Query: String; +begin + { Destructive and cannot be undone. } + Query := ''; + AddQuery(Query, 'from_address', RequiredParam(FromAddress, 'from_address')); + WarmupJson('DELETE', '/sending-accounts', Query, ''); +end; + +procedure ZbWarmupChangeFromAddress(const OldFromAddress, NewFromAddress: String); +var + Body: String; +begin + { Despite the path, nothing about SMTP credentials is stored. } + Body := ''; + AddField(Body, JsonInt('team_id', ZB_WARMUP_ANY_TEAM_ID)); + AddField(Body, JsonString('old_from_address', + RequiredParam(OldFromAddress, 'old_from_address'))); + AddField(Body, JsonString('new_from_address', + RequiredParam(NewFromAddress, 'new_from_address'))); + WarmupJson('PUT', '/smtp-credentials', '', Concat('{', Body, '}')); +end; + +function ZbWarmupListFromAddresses(Options: PTZbWarmupListFromAddressesOptions): String; +var + Query: String; + I: Integer; +begin + Query := ''; + if Options <> nil then + begin + if Options^.SearchFrom <> '' then + begin + for I := 1 to Length(Options^.SearchFrom) do + if not (Options^.SearchFrom[I] in + ['A'..'Z', 'a'..'z', '0'..'9', '.', '-', '_', '@', '+']) then + raise ZbException.Create( + 'Invalid parameter: search_from (letters, digits and . - _ @ + only)' + ); + AddQuery(Query, 'search_from', Options^.SearchFrom); + end; + { The API defaults these to 0 and 100. } + if Options^.OffsetSpecified then + AddQuery(Query, 'offset', IntToStr(InRange(Options^.Offset, 0, MaxInt, 'offset'))); + if Options^.LimitSpecified then + AddQuery(Query, 'limit', IntToStr(InRange(Options^.Limit, 0, MaxInt, 'limit'))); + end; + Result := WarmupJson('GET', '/from-addresses', Query, ''); +end; + +{ -- engagement rules ---------------------------------------------------- } + +function ZbWarmupGetEngagementRules: String; +begin + { -1 Custom, 0 Default, 1 Slow Pace, 2 Repair, 3 Boost, 4 Protect. } + Result := WarmupJson('GET', '/engagement-rules', '', ''); +end; + +procedure ZbWarmupSetEngagementRule(const CFrom: String; RuleId: Integer); +var + Query: String; +begin + Query := ''; + AddQuery(Query, 'c_from', RequiredParam(CFrom, 'c_from')); + WarmupJson('PUT', '/engagement-rule', Query, + Concat('{', JsonInt('rule_id', InRange(RuleId, 0, 4, 'rule_id')), '}')); +end; + +function ZbWarmupGetCustomEngagement(const CFrom: String): String; +begin + Result := WarmupJson( + 'GET', Concat('/custom-engagement/', ZbPathSegment(CFrom, 'c_from')), '', '' + ); +end; + +{ reply_pct 5-45, important_pct 5-40, click_pct 0-70 and at most + important_pct x 1.75. } +function CustomEngagementBody(const CFrom: String; + ReplyPct, ImportantPct, ClickPct: Integer): String; +var + Body: String; +begin + RequiredParam(CFrom, 'c_from'); + InRange(ReplyPct, 5, 45, 'reply_pct'); + InRange(ImportantPct, 5, 40, 'important_pct'); + InRange(ClickPct, 0, 70, 'click_pct'); + if ClickPct * 100 > ImportantPct * 175 then + raise ZbException.Create('Invalid parameter: click_pct (at most important_pct x 1.75)'); + Body := ''; + AddField(Body, JsonString('c_from', Trim(CFrom))); + AddField(Body, JsonInt('reply_pct', ReplyPct)); + AddField(Body, JsonInt('important_pct', ImportantPct)); + AddField(Body, JsonInt('click_pct', ClickPct)); + Result := Concat('{', Body, '}'); +end; + +function ZbWarmupCreateCustomEngagement(const CFrom: String; + ReplyPct, ImportantPct, ClickPct: Integer): String; +begin + { Starts a 7 day cooldown on updates. } + Result := WarmupJson('POST', '/custom-engagement', '', + CustomEngagementBody(CFrom, ReplyPct, ImportantPct, ClickPct)); +end; + +function ZbWarmupUpdateCustomEngagement(const CFrom: String; + ReplyPct, ImportantPct, ClickPct: Integer): String; +begin + { During the 7 day cooldown this fails with HTTP 429. } + Result := WarmupJson('PUT', '/custom-engagement', '', + CustomEngagementBody(CFrom, ReplyPct, ImportantPct, ClickPct)); +end; + +function ZbWarmupDeleteCustomEngagement(const CFrom: String; + EngagementRuleId: Integer): String; +var + Body: String; +begin + { Falls back to standard rule EngagementRuleId, 1 to 4. } + Body := ''; + AddField(Body, JsonString('c_from', RequiredParam(CFrom, 'c_from'))); + AddField(Body, JsonInt('engagement_rule_id', + InRange(EngagementRuleId, 1, 4, 'engagement_rule_id'))); + Result := WarmupJson('DELETE', '/custom-engagement', '', Concat('{', Body, '}')); +end; + +{ -- seeds, progress and statistics -------------------------------------- } + +function ZbWarmupGetSeeds: String; +begin + Result := WarmupJson('GET', '/seeds', '', ''); +end; + +function ZbWarmupDownloadSeeds: String; +begin + Result := ZbWarmupDownloadSeeds(''); +end; + +function ZbWarmupDownloadSeeds(const DownloadPath: String): String; +begin + { CSV with the columns seed,provider. } + Result := WarmupCsv('/seeds/download', '', DownloadPath); +end; + +function ZbWarmupGetSeedCount: String; +begin + { Answers a bare number, not an object. } + Result := WarmupJson('GET', '/seed-count', '', ''); +end; + +function ZbWarmupGetProgressToday: String; +begin + Result := WarmupJson('GET', '/progress-today', '', ''); +end; + +function ZbWarmupGetSentToday: String; +begin + Result := WarmupJson('GET', '/sent-today', '', ''); +end; + +function ZbWarmupGetEmailsPerDay: String; +begin + Result := WarmupJson('GET', '/emails-per-day', '', ''); +end; + +function ZbWarmupGetStatistics(const FromAddress: String): String; +var + Query: String; +begin + { An address with no history answers with zeros, not an error. } + Query := ''; + AddQuery(Query, 'from_address', RequiredParam(FromAddress, 'from_address')); + Result := WarmupJson('GET', '/statistics', Query, ''); +end; + +function ZbWarmupGetStatisticsPerDay(const FromAddress, StartDate, EndDate: String): String; +var + Query, StartsAt, EndsAt: String; +begin + RequiredParam(FromAddress, 'from_address'); + StartsAt := IsoDate(StartDate, 'start_date'); + EndsAt := IsoDate(EndDate, 'end_date'); + if StartsAt > EndsAt then + raise ZbException.Create('Invalid parameter: start_date is later than end_date'); + Query := ''; + AddQuery(Query, 'from_address', Trim(FromAddress)); + AddQuery(Query, 'start_date', StartsAt); + AddQuery(Query, 'end_date', EndsAt); + Result := WarmupJson('GET', '/statistics-per-day', Query, ''); +end; + +function ZbWarmupGetStatisticsDetailed(const CFrom: String): String; +var + Query: String; +begin + Query := ''; + AddQuery(Query, 'c_from', RequiredParam(CFrom, 'c_from')); + Result := WarmupJson('GET', '/statistics-detailed', Query, ''); +end; + +function ZbWarmupDownloadStatistics(const CFrom: String): String; +begin + Result := ZbWarmupDownloadStatistics(CFrom, ''); +end; + +function ZbWarmupDownloadStatistics(const CFrom, DownloadPath: String): String; +var + Query: String; +begin + Query := ''; + AddQuery(Query, 'c_from', RequiredParam(CFrom, 'c_from')); + Result := WarmupCsv('/statistics/download', Query, DownloadPath); +end; + +function ZbWarmupGetFromAddressStatistics(const FromAddress: String): String; +var + Query: String; +begin + Query := ''; + AddQuery(Query, 'from_address', RequiredParam(FromAddress, 'from_address')); + Result := WarmupJson('GET', '/from-address-statistics', Query, ''); +end; + +{ -- deliverability checks ----------------------------------------------- } + +function ZbWarmupCheckDomainDmarc(const FromAddress: String): String; +begin + Result := ZbWarmupCheckDomainDmarc(FromAddress, ''); +end; + +function ZbWarmupCheckDomainDmarc(const FromAddress, DkimSelector: String): String; +var + Query: String; +begin + RequiredParam(FromAddress, 'from_address'); + { LIVE: this takes an email address. The docs describe a domain, but a bare + domain is answered with "Please provide a valid email address". } + if Pos('@', FromAddress) = 0 then + raise ZbException.Create( + 'Invalid parameter: from_address (an email address, e.g. sender@example.com)' + ); + Query := ''; + AddQuery(Query, 'from_address', Trim(FromAddress)); + if DkimSelector <> '' then + AddQuery(Query, 'dkim_selector', DkimSelector); + { Answers SPF_status, DKIM_status, DMARC_status and the dkim_selector that + was checked. } + Result := WarmupJson('GET', '/check-domain-dmarc', Query, ''); +end; + +function ZbWarmupTestSmtpConfiguration(const FromAddress, Username, Password, + Server: String; Port: Integer): String; +var + Body: String; +begin + { Nothing is stored, and the test message goes to a ZeroBounce test + recipient, not FromAddress. + + LIVE: a failed SMTP login is answered with HTTP 500, not a 4xx. } + Body := ''; + AddField(Body, JsonString('from_address', RequiredParam(FromAddress, 'from_address'))); + AddField(Body, JsonString('username', RequiredParam(Username, 'username'))); + AddField(Body, JsonString('password', RequiredParam(Password, 'password'))); + AddField(Body, JsonString('server', RequiredParam(Server, 'server'))); + AddField(Body, JsonInt('port', InRange(Port, 1, 65535, 'port'))); + Result := WarmupJsonWithSecrets( + 'POST', '/test-smtp-configuration', '', Concat('{', Body, '}'), + [Password, Username] + ); +end; + +procedure Register; +begin +end; + +initialization + ZbWarmupApiKey := ''; + +end. diff --git a/tests/testwarmup.pas b/tests/testwarmup.pas new file mode 100644 index 0000000..49556be --- /dev/null +++ b/tests/testwarmup.pas @@ -0,0 +1,737 @@ +unit TestWarmup; + +{ Offline tests for the Warmup API client. + + Every request is answered from ZbResponseMock; nothing here reaches the live + API. The assertions are on the url, the method, the body and the one header + that actually went out. } + +{$mode objfpc}{$H+} + +interface + +uses + Classes, SysUtils, fpcunit, testregistry, + BaseTest, ZbUtility, ZbWarmup; + +type + + TTestWarmup = class(TBaseTestCase) + const + MOCK_WARMUP_KEY = 'mock_warmup_key'; + ROOT = 'https://warmup-api.zerobounce.net/api/v1/warmup'; + protected + procedure MockOk(const Payload: String); + procedure AssertNothingSent; + procedure SetUp; override; + procedure TearDown; override; + published + procedure TestEveryCallCarriesTheApiKeyHeaderAndItsOwnHost; + procedure TestAnUninitializedClientSendsNothing; + procedure TestTheWarmupKeyIsCheckedLikeTheValidationKey; + procedure TestReadsUseTheDocumentedPathsAndQueries; + procedure TestEmptySuccessesComeBackAsAnEmptyString; + procedure TestAddSendingAccountSelfManaged; + procedure TestAddSendingAccountZbManaged; + procedure TestUpdateAndDeleteSendingAccount; + procedure TestChangeFromAddressUsesTheSmtpCredentialsPath; + procedure TestListFromAddressesChecksItsArguments; + procedure TestEngagementRules; + procedure TestCustomEngagementRanges; + procedure TestStatisticsPerDayChecksItsDates; + procedure TestCheckDomainDmarcNeedsAnEmailAddress; + procedure TestTestSmtpConfigurationChecksThePort; + procedure TestCsvDownloadsRejectAJsonAnswer; + procedure TestBlankArgumentsAreRejectedBeforeSending; + end; + +implementation + +const + OK_JSON = '{"ok":true}'; + CSV_TYPE = 'text/csv'; + +procedure TTestWarmup.SetUp; +begin + inherited SetUp; + ZbWarmupInitialize(MOCK_WARMUP_KEY); + ZbWarmupBaseURL := 'https://warmup-api.zerobounce.net'; + ZbLastRequestMethod := ''; + ZbLastRequestBody := ''; + ZbLastRequestHeader := ''; +end; + +procedure TTestWarmup.TearDown; +begin + ZbWarmupApiKey := ''; + inherited TearDown; +end; + +procedure TTestWarmup.MockOk(const Payload: String); +begin + ZBMockResponse(200, Payload, JSON_CONTENT_TYPE); + ZbResponseMock.UrlCalled := ''; +end; + +procedure TTestWarmup.AssertNothingSent; +begin + AssertEquals('no request should have been sent', '', ZbResponseMock.UrlCalled); +end; + +procedure TTestWarmup.TestEveryCallCarriesTheApiKeyHeaderAndItsOwnHost; +begin + MockOk(OK_JSON); + ZbWarmupListSendingAccounts; + + AssertEquals('GET', ZbLastRequestMethod); + { Its own host and base path, not the validation API's. } + AssertEquals(Concat(ROOT, '/sending-accounts'), ZbResponseMock.UrlCalled); + { The Warmup API authenticates with an API-Key header; the validation + api_key never appears. } + AssertEquals(Concat('API-Key: ', MOCK_WARMUP_KEY), ZbLastRequestHeader); + AssertEquals('the api_key parameter is not used', 0, + Pos('api_key', ZbResponseMock.UrlCalled)); +end; + +procedure TTestWarmup.TestAnUninitializedClientSendsNothing; +var + Raised: Boolean; +begin + MockOk(OK_JSON); + ZbWarmupApiKey := ''; + Raised := False; + try + ZbWarmupGetSeeds; + except on e: Exception do + begin + Raised := True; + AssertTrue(e.Message, Pos('ZbWarmupInitialize', e.Message) > 0); + end; + end; + AssertTrue('an uninitialized client should refuse to send', Raised); + AssertEquals('no request should have been sent', '', ZbResponseMock.UrlCalled); +end; + +procedure TTestWarmup.TestTheWarmupKeyIsCheckedLikeTheValidationKey; +var + Raised: Boolean; +begin + { A key with surrounding whitespace or control characters would otherwise + be quoted, unredacted, in a transport error. } + Raised := False; + try + ZbWarmupInitialize(Concat(' ', MOCK_WARMUP_KEY)); + except on e: Exception do + begin + Raised := True; + AssertTrue(e.Message, Pos('whitespace', e.Message) > 0); + end; + end; + AssertTrue('a padded key should be rejected', Raised); + + Raised := False; + try + ZbWarmupInitialize(' '); + except on e: Exception do + begin + Raised := True; + AssertTrue(e.Message, Pos('api_key', e.Message) > 0); + end; + end; + AssertTrue('a blank key should be rejected', Raised); +end; + +procedure TTestWarmup.TestReadsUseTheDocumentedPathsAndQueries; +begin + MockOk(OK_JSON); + + ZbWarmupGetSendingAccount('sender@example.com'); + AssertEquals( + Concat(ROOT, '/sending-account?from_address=sender@example.com'), + ZbResponseMock.UrlCalled + ); + + ZbWarmupGetEngagementRules; + AssertEquals(Concat(ROOT, '/engagement-rules'), ZbResponseMock.UrlCalled); + + ZbWarmupGetSeeds; + AssertEquals(Concat(ROOT, '/seeds'), ZbResponseMock.UrlCalled); + + ZbWarmupGetSeedCount; + AssertEquals(Concat(ROOT, '/seed-count'), ZbResponseMock.UrlCalled); + + ZbWarmupGetProgressToday; + AssertEquals(Concat(ROOT, '/progress-today'), ZbResponseMock.UrlCalled); + + ZbWarmupGetSentToday; + AssertEquals(Concat(ROOT, '/sent-today'), ZbResponseMock.UrlCalled); + + ZbWarmupGetEmailsPerDay; + AssertEquals(Concat(ROOT, '/emails-per-day'), ZbResponseMock.UrlCalled); + + ZbWarmupGetStatistics('sender@example.com'); + AssertEquals( + Concat(ROOT, '/statistics?from_address=sender@example.com'), + ZbResponseMock.UrlCalled + ); + + ZbWarmupGetStatisticsDetailed('sender@example.com'); + AssertEquals( + Concat(ROOT, '/statistics-detailed?c_from=sender@example.com'), + ZbResponseMock.UrlCalled + ); + + ZbWarmupGetFromAddressStatistics('sender@example.com'); + AssertEquals( + Concat(ROOT, '/from-address-statistics?from_address=sender@example.com'), + ZbResponseMock.UrlCalled + ); + + { c_from is one path segment here, so a slash must stay encoded. } + ZbWarmupGetCustomEngagement('a b/c'); + AssertEquals( + Concat(ROOT, '/custom-engagement/a%20b%2Fc'), ZbResponseMock.UrlCalled + ); +end; + +procedure TTestWarmup.TestEmptySuccessesComeBackAsAnEmptyString; +begin + { 204 and an empty 200 are the documented empty successes. } + ZBMockResponse(204, '', JSON_CONTENT_TYPE); + AssertEquals('', ZbWarmupGetSentToday); + + ZBMockResponse(200, ' ', JSON_CONTENT_TYPE); + AssertEquals('', ZbWarmupGetSentToday); +end; + +procedure TTestWarmup.TestAddSendingAccountSelfManaged; +var + Options: TZbWarmupAddSendingAccountOptions; + Content: TZbWarmupEmailContent; + Raised: Boolean; +begin + MockOk(OK_JSON); + + Options := ZbWarmupDefaultAddSendingAccountOptions; + Options.EngagementRuleIdSpecified := True; + Options.EngagementRuleId := 2; + Options.IgnoreConflictSpecified := True; + Options.IgnoreConflict := True; + ZbWarmupAddSendingAccount('sender@example.com', ZB_WARMUP_SELF_MANAGED, @Options); + + AssertEquals('POST', ZbLastRequestMethod); + AssertEquals(Concat(ROOT, '/sending-accounts'), ZbResponseMock.UrlCalled); + AssertEquals( + '{"from_address":"sender@example.com","onboarding_model":"self_managed",' + + '"engagement_rule_id":2,"ignore_conflict":true}', + ZbLastRequestBody + ); + + { A self-managed account needs an engagement rule. } + ZbResponseMock.UrlCalled := ''; + Options := ZbWarmupDefaultAddSendingAccountOptions; + Raised := False; + try + ZbWarmupAddSendingAccount('sender@example.com', ZB_WARMUP_SELF_MANAGED, @Options); + except on e: Exception do + begin + Raised := True; + AssertTrue(e.Message, Pos('engagement_rule_id', e.Message) > 0); + end; + end; + AssertTrue('a self-managed account without a rule should be rejected', Raised); + AssertNothingSent; + + { and the ZB managed extras belong to the other model. } + FillChar(Content, SizeOf(Content), 0); + Content.CompanyName := 'Example'; + Options.EngagementRuleIdSpecified := True; + Options.EngagementRuleId := 0; + Options.EmailContent := @Content; + Raised := False; + try + ZbWarmupAddSendingAccount('sender@example.com', ZB_WARMUP_SELF_MANAGED, @Options); + except on e: Exception do + begin + Raised := True; + AssertTrue(e.Message, Pos('zb_managed', e.Message) > 0); + end; + end; + AssertTrue('email_content is for zb_managed only', Raised); + + Raised := False; + try + ZbWarmupAddSendingAccount('sender@example.com', 'whatever', nil); + except on e: Exception do + begin + Raised := True; + AssertTrue(e.Message, Pos('onboarding_model', e.Message) > 0); + end; + end; + AssertTrue('an unknown onboarding model should be rejected', Raised); +end; + +procedure TTestWarmup.TestAddSendingAccountZbManaged; +var + Options: TZbWarmupAddSendingAccountOptions; + Content: TZbWarmupEmailContent; + Sending: TZbWarmupManagedSending; + Raised: Boolean; +begin + MockOk(OK_JSON); + + FillChar(Content, SizeOf(Content), 0); + Content.CompanyName := 'Example'; + Content.CompanyWebsite := 'https://example.com'; + Content.TypesOfEmails := 'newsletter, marketing'; + + FillChar(Sending, SizeOf(Sending), 0); + Sending.WeekdaysOnlySpecified := True; + Sending.WeekdaysOnly := True; + Sending.Timezone := 'Europe/Bucharest'; + Sending.TimezoneOffsetSpecified := True; + Sending.TimezoneOffset := 3; + Sending.WarmupStartDate := '2026-11-02'; + + Options := ZbWarmupDefaultAddSendingAccountOptions; + Options.EmailContent := @Content; + Options.ManagedSending := @Sending; + ZbWarmupAddSendingAccount('sender@example.com', ZB_WARMUP_ZB_MANAGED, @Options); + + AssertEquals( + '{"from_address":"sender@example.com","onboarding_model":"zb_managed",' + + '"email_content":{"company_name":"Example",' + + '"company_website":"https://example.com",' + + '"types_of_emails":["newsletter","marketing"]},' + + '"managed_sending":{"weekdays_only":true,"timezone":"Europe/Bucharest",' + + '"timezone_offset":3,"warmup_start_date":"2026-11-02"}}', + ZbLastRequestBody + ); + + ZbResponseMock.UrlCalled := ''; + Content.TypesOfEmails := 'postcards'; + Raised := False; + try + ZbWarmupAddSendingAccount('sender@example.com', ZB_WARMUP_ZB_MANAGED, @Options); + except on e: Exception do + begin + Raised := True; + AssertTrue(e.Message, Pos('types_of_emails', e.Message) > 0); + end; + end; + AssertTrue('an unknown email type should be rejected', Raised); + AssertNothingSent; + + Content.TypesOfEmails := 'marketing'; + Sending.TimezoneOffset := 15; + Raised := False; + try + ZbWarmupAddSendingAccount('sender@example.com', ZB_WARMUP_ZB_MANAGED, @Options); + except on e: Exception do + begin + Raised := True; + AssertTrue(e.Message, Pos('timezone_offset', e.Message) > 0); + end; + end; + AssertTrue('an offset outside -11..14 should be rejected', Raised); + + Sending.TimezoneOffset := 3; + Sending.WarmupStartDate := '02/11/2026'; + Raised := False; + try + ZbWarmupAddSendingAccount('sender@example.com', ZB_WARMUP_ZB_MANAGED, @Options); + except on e: Exception do + begin + Raised := True; + AssertTrue(e.Message, Pos('warmup_start_date', e.Message) > 0); + end; + end; + AssertTrue('a non ISO start date should be rejected', Raised); +end; + +procedure TTestWarmup.TestUpdateAndDeleteSendingAccount; +var + Raised: Boolean; +begin + MockOk(OK_JSON); + + ZbWarmupUpdateSendingAccount('sender@example.com', 1, 4, True); + AssertEquals('PUT', ZbLastRequestMethod); + AssertEquals(Concat(ROOT, '/sending-accounts'), ZbResponseMock.UrlCalled); + AssertEquals( + '{"team_id":1,"from_address":"sender@example.com","engagement_rule_id":1,' + + '"status_id":4,"auto_enable":true}', + ZbLastRequestBody + ); + + ZbWarmupDeleteSendingAccount('sender@example.com'); + AssertEquals('DELETE', ZbLastRequestMethod); + AssertEquals( + Concat(ROOT, '/sending-accounts?from_address=sender@example.com'), + ZbResponseMock.UrlCalled + ); + + ZbResponseMock.UrlCalled := ''; + Raised := False; + try + ZbWarmupUpdateSendingAccount('sender@example.com', 9, 4, False); + except on e: Exception do + begin + Raised := True; + AssertTrue(e.Message, Pos('engagement_rule_id', e.Message) > 0); + end; + end; + AssertTrue('a rule outside 0..4 should be rejected', Raised); + + Raised := False; + try + ZbWarmupUpdateSendingAccount('sender@example.com', 1, 9, False); + except on e: Exception do + begin + Raised := True; + AssertTrue(e.Message, Pos('status_id', e.Message) > 0); + end; + end; + AssertTrue('a status outside 0..8 should be rejected', Raised); + AssertNothingSent; +end; + +procedure TTestWarmup.TestChangeFromAddressUsesTheSmtpCredentialsPath; +begin + MockOk(OK_JSON); + { Despite the path, nothing about SMTP credentials is stored. } + ZbWarmupChangeFromAddress('old@example.com', 'new@example.com'); + AssertEquals('PUT', ZbLastRequestMethod); + AssertEquals(Concat(ROOT, '/smtp-credentials'), ZbResponseMock.UrlCalled); + AssertEquals( + '{"team_id":1,"old_from_address":"old@example.com",' + + '"new_from_address":"new@example.com"}', + ZbLastRequestBody + ); +end; + +procedure TTestWarmup.TestListFromAddressesChecksItsArguments; +var + Options: TZbWarmupListFromAddressesOptions; + Raised: Boolean; +begin + MockOk(OK_JSON); + + ZbWarmupListFromAddresses(nil); + AssertEquals(Concat(ROOT, '/from-addresses'), ZbResponseMock.UrlCalled); + + Options := ZbWarmupDefaultListFromAddressesOptions; + Options.SearchFrom := 'sender@example.com'; + Options.OffsetSpecified := True; + Options.Offset := 20; + Options.LimitSpecified := True; + Options.Limit := 50; + ZbWarmupListFromAddresses(@Options); + AssertEquals( + Concat(ROOT, '/from-addresses?search_from=sender@example.com&offset=20&limit=50'), + ZbResponseMock.UrlCalled + ); + + ZbResponseMock.UrlCalled := ''; + Options.SearchFrom := 'a b'; + Raised := False; + try + ZbWarmupListFromAddresses(@Options); + except on e: Exception do + begin + Raised := True; + AssertTrue(e.Message, Pos('search_from', e.Message) > 0); + end; + end; + AssertTrue('search_from takes letters, digits and . - _ @ + only', Raised); + AssertNothingSent; +end; + +procedure TTestWarmup.TestEngagementRules; +var + Raised: Boolean; +begin + MockOk(OK_JSON); + + ZbWarmupSetEngagementRule('sender@example.com', 3); + AssertEquals('PUT', ZbLastRequestMethod); + AssertEquals( + Concat(ROOT, '/engagement-rule?c_from=sender@example.com'), + ZbResponseMock.UrlCalled + ); + AssertEquals('{"rule_id":3}', ZbLastRequestBody); + + ZbWarmupDeleteCustomEngagement('sender@example.com', 2); + AssertEquals('DELETE', ZbLastRequestMethod); + AssertEquals(Concat(ROOT, '/custom-engagement'), ZbResponseMock.UrlCalled); + AssertEquals( + '{"c_from":"sender@example.com","engagement_rule_id":2}', ZbLastRequestBody + ); + + { Falling back to the custom rule itself is not a standard rule. } + ZbResponseMock.UrlCalled := ''; + Raised := False; + try + ZbWarmupDeleteCustomEngagement('sender@example.com', 0); + except on e: Exception do + begin + Raised := True; + AssertTrue(e.Message, Pos('engagement_rule_id', e.Message) > 0); + end; + end; + AssertTrue('the fallback rule must be 1 to 4', Raised); + AssertNothingSent; +end; + +procedure TTestWarmup.TestCustomEngagementRanges; +var + Raised: Boolean; +begin + MockOk(OK_JSON); + + ZbWarmupCreateCustomEngagement('sender@example.com', 10, 20, 30); + AssertEquals('POST', ZbLastRequestMethod); + AssertEquals(Concat(ROOT, '/custom-engagement'), ZbResponseMock.UrlCalled); + AssertEquals( + '{"c_from":"sender@example.com","reply_pct":10,"important_pct":20,"click_pct":30}', + ZbLastRequestBody + ); + + ZbWarmupUpdateCustomEngagement('sender@example.com', 10, 20, 30); + AssertEquals('PUT', ZbLastRequestMethod); + + ZbResponseMock.UrlCalled := ''; + Raised := False; + try + ZbWarmupCreateCustomEngagement('sender@example.com', 4, 20, 10); + except on e: Exception do + begin + Raised := True; + AssertTrue(e.Message, Pos('reply_pct', e.Message) > 0); + end; + end; + AssertTrue('reply_pct is 5 to 45', Raised); + + Raised := False; + try + ZbWarmupCreateCustomEngagement('sender@example.com', 10, 41, 10); + except on e: Exception do + begin + Raised := True; + AssertTrue(e.Message, Pos('important_pct', e.Message) > 0); + end; + end; + AssertTrue('important_pct is 5 to 40', Raised); + + { click_pct is capped at important_pct x 1.75 as well as at 70. } + Raised := False; + try + ZbWarmupCreateCustomEngagement('sender@example.com', 10, 20, 36); + except on e: Exception do + begin + Raised := True; + AssertTrue(e.Message, Pos('click_pct', e.Message) > 0); + end; + end; + AssertTrue('click_pct is at most important_pct x 1.75', Raised); + AssertNothingSent; + + { and exactly 1.75 x is still allowed. } + ZbWarmupCreateCustomEngagement('sender@example.com', 10, 20, 35); + AssertEquals(Concat(ROOT, '/custom-engagement'), ZbResponseMock.UrlCalled); +end; + +procedure TTestWarmup.TestStatisticsPerDayChecksItsDates; +var + Raised: Boolean; +begin + MockOk(OK_JSON); + + ZbWarmupGetStatisticsPerDay('sender@example.com', '2026-10-01', '2026-10-07'); + AssertEquals( + Concat(ROOT, '/statistics-per-day?from_address=sender@example.com', + '&start_date=2026-10-01&end_date=2026-10-07'), + ZbResponseMock.UrlCalled + ); + + ZbResponseMock.UrlCalled := ''; + Raised := False; + try + ZbWarmupGetStatisticsPerDay('sender@example.com', '10/01/2026', '2026-10-07'); + except on e: Exception do + begin + Raised := True; + AssertTrue(e.Message, Pos('start_date', e.Message) > 0); + end; + end; + AssertTrue('dates are YYYY-MM-DD', Raised); + + Raised := False; + try + ZbWarmupGetStatisticsPerDay('sender@example.com', '2026-10-08', '2026-10-07'); + except on e: Exception do + begin + Raised := True; + AssertTrue(e.Message, Pos('later than', e.Message) > 0); + end; + end; + AssertTrue('the range must not run backwards', Raised); + AssertNothingSent; +end; + +procedure TTestWarmup.TestCheckDomainDmarcNeedsAnEmailAddress; +var + Raised: Boolean; +begin + MockOk(OK_JSON); + + ZbWarmupCheckDomainDmarc('sender@example.com', 'selector1'); + AssertEquals( + Concat(ROOT, '/check-domain-dmarc?from_address=sender@example.com', + '&dkim_selector=selector1'), + ZbResponseMock.UrlCalled + ); + + ZbWarmupCheckDomainDmarc('sender@example.com'); + AssertEquals( + Concat(ROOT, '/check-domain-dmarc?from_address=sender@example.com'), + ZbResponseMock.UrlCalled + ); + + { LIVE: the docs describe a domain, but a bare domain is answered with + "Please provide a valid email address". } + ZbResponseMock.UrlCalled := ''; + Raised := False; + try + ZbWarmupCheckDomainDmarc('example.com'); + except on e: Exception do + begin + Raised := True; + AssertTrue(e.Message, Pos('email address', e.Message) > 0); + end; + end; + AssertTrue('a bare domain should be rejected before sending', Raised); + AssertNothingSent; +end; + +procedure TTestWarmup.TestTestSmtpConfigurationChecksThePort; +var + Raised: Boolean; +begin + MockOk(OK_JSON); + + ZbWarmupTestSmtpConfiguration( + 'sender@example.com', 'sender@example.com', 'secret', 'smtp.example.com', 587 + ); + AssertEquals('POST', ZbLastRequestMethod); + AssertEquals(Concat(ROOT, '/test-smtp-configuration'), ZbResponseMock.UrlCalled); + AssertEquals( + '{"from_address":"sender@example.com","username":"sender@example.com",' + + '"password":"secret","server":"smtp.example.com","port":587}', + ZbLastRequestBody + ); + + ZbResponseMock.UrlCalled := ''; + Raised := False; + try + ZbWarmupTestSmtpConfiguration( + 'sender@example.com', 'sender@example.com', 'secret', 'smtp.example.com', 0 + ); + except on e: Exception do + begin + Raised := True; + AssertTrue(e.Message, Pos('port', e.Message) > 0); + end; + end; + AssertTrue('the port must be 1 to 65535', Raised); + AssertNothingSent; +end; + +procedure TTestWarmup.TestCsvDownloadsRejectAJsonAnswer; +var + Raised: Boolean; +begin + ZBMockResponse(200, 'seed,provider'#10'a@example.com,gmail', CSV_TYPE); + ZbResponseMock.UrlCalled := ''; + AssertTrue(Pos('seed,provider', ZbWarmupDownloadSeeds('')) = 1); + AssertEquals(Concat(ROOT, '/seeds/download'), ZbResponseMock.UrlCalled); + + ZBMockResponse(200, 'date,sent'#10'2026-10-01,10', CSV_TYPE); + ZbWarmupDownloadStatistics('sender@example.com'); + AssertEquals( + Concat(ROOT, '/statistics/download?c_from=sender@example.com'), + ZbResponseMock.UrlCalled + ); + + { An error comes back labelled as JSON, so it is not a file. } + ZBMockResponse(200, '{"detail":"nothing here"}', JSON_CONTENT_TYPE); + Raised := False; + try + ZbWarmupDownloadSeeds(''); + except on e: Exception do + begin + Raised := True; + AssertTrue(e.Message, Pos('nothing here', e.Message) > 0); + end; + end; + AssertTrue('a JSON answer is not a CSV download', Raised); +end; + +procedure TTestWarmup.TestBlankArgumentsAreRejectedBeforeSending; +var + Raised: Boolean; +begin + MockOk(OK_JSON); + + Raised := False; + try + ZbWarmupGetSendingAccount(' '); + except on e: Exception do + begin + Raised := True; + AssertTrue(e.Message, Pos('from_address', e.Message) > 0); + end; + end; + AssertTrue('a blank from_address should be rejected', Raised); + + Raised := False; + try + ZbWarmupGetStatisticsDetailed(' '); + except on e: Exception do + begin + Raised := True; + AssertTrue(e.Message, Pos('c_from', e.Message) > 0); + end; + end; + AssertTrue('a blank c_from should be rejected', Raised); + + { A path segment of '.' or '..' would walk the request out of the + endpoint it was meant for. } + Raised := False; + try + ZbWarmupGetCustomEngagement('..'); + except on e: Exception do + begin + Raised := True; + AssertTrue(e.Message, Pos('c_from', e.Message) > 0); + end; + end; + AssertTrue('a dot-dot path segment should be rejected', Raised); + + Raised := False; + try + ZbWarmupChangeFromAddress('old@example.com', ' '); + except on e: Exception do + begin + Raised := True; + AssertTrue(e.Message, Pos('new_from_address', e.Message) > 0); + end; + end; + AssertTrue('a blank new_from_address should be rejected', Raised); + AssertNothingSent; +end; + +initialization + RegisterTest(TTestWarmup); + +end. diff --git a/tests/unit_tests_console.lpr b/tests/unit_tests_console.lpr index 28b070a..ee27e6c 100644 --- a/tests/unit_tests_console.lpr +++ b/tests/unit_tests_console.lpr @@ -8,7 +8,7 @@ {$ENDIF} Classes, SysUtils, consoletestrunner, TestGeneric, TestValidation, TestDeserialization, TestBulk, TestPriority3, TestUtility, TestOffline, - TestBulkJobs; + TestBulkJobs, TestWarmup; var App: TTestRunner; From aedb387612d48bba4af2d7ea86698b02b0570554 Mon Sep 17 00:00:00 2001 From: Francisc Balint <7178547+franciscbalint@users.noreply.github.com> Date: Sat, 24 Oct 2026 10:42:43 +0400 Subject: [PATCH 6/8] test: add an opt-in live API suite live/live_api.lpr is its own program, outside tests/, so the offline suite never builds or runs it. Keys come from the environment only: ZEROBOUNCE_API_KEY the validation API (skipped without it) ZEROBOUNCE_WARMUP_API_KEY the Warmup API (skipped without it) ZEROBOUNCE_LIVE_SPEND=1 calls that spend credits ZEROBOUNCE_LIVE_CHANGES=1 reversible account changes, undone in the same run even when a check fails By default it only makes free calls: sandbox addresses, reads, and requests expected to fail that still prove the API accepts the payload. The free tier checks that the credit balance did not move. Never called, because the API cannot undo them: creating an Inbox Placement test, setting the Inbox Placement webhook, and ZB managed Warmup onboarding. Co-Authored-By: Claude Opus 5 --- live/live_api.lpr | 787 ++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 787 insertions(+) create mode 100644 live/live_api.lpr diff --git a/live/live_api.lpr b/live/live_api.lpr new file mode 100644 index 0000000..63e6bdf --- /dev/null +++ b/live/live_api.lpr @@ -0,0 +1,787 @@ +program live_api; + +{ Opt-in live API suite: calls the SDK against the real API to check payloads + and response shapes. + + It is its own program, outside tests/, so the offline suite never builds or + runs it: + + fpc -Fu./src -Mobjfpc -FE/tmp live/live_api.lpr + export ZEROBOUNCE_API_KEY=... # validation API (skipped without it) + export ZEROBOUNCE_WARMUP_API_KEY=... # Warmup API (skipped without it) + /tmp/live_api + + By default only free calls run: sandbox addresses (no credits), reads, and + requests expected to fail (unknown file ids, S3/SFTP without an integration) + that still prove the API accepts the payload. The free tier checks that the + credit balance did not change. Two flags opt in to more: + + ZEROBOUNCE_LIVE_SPEND=1 spends credits: scoring, activity, email + finder, and a bulk round trip whose job is + deleted again + ZEROBOUNCE_LIVE_CHANGES=1 reversible account changes, each undone even + when a test fails: a test filter, and a + self-managed Warmup sending account + + Never called, because the API cannot undo them: creating an Inbox Placement + test (it cannot be deleted while running), setting the Inbox Placement + webhook (it cannot be cleared), and ZB managed Warmup onboarding (it starts + mailbox provisioning). + + No key is ever written to a file or printed. Keys come from the environment. } + +{$mode objfpc}{$H+} + +uses +{$IFDEF UNIX} + cthreads, +{$ENDIF} + Classes, SysUtils, DateUtils, + ZbBulk, ZbGeneric, ZbPriority3, ZbStructures, ZbUtility, ZbValidation, ZbWarmup; + +const + { Well formed but matching no file: a malformed id can be Cloudflare + blocked instead of answered. } + UNKNOWN_ID = '0d1b7a4e-1111-2222-3333-444455556666'; + CALLBACK = 'https://example.com/zerobounce-sdk-live-test'; + SANDBOX = 'valid@example.com'; + +var + Passed: Integer = 0; + Failed: Integer = 0; + Skipped: Integer = 0; + Failures: TStringList; + ApiKey: String = ''; + WarmupKey: String = ''; + SpendFlag: Boolean = False; + ChangesFlag: Boolean = False; + +function Env(const Name: String): String; +begin + Result := Trim(GetEnvironmentVariable(Name)); +end; + +function Redact(const Text: String): String; +begin + Result := Text; + if ApiKey <> '' then + Result := StringReplace(Result, ApiKey, '***', [rfReplaceAll]); + if WarmupKey <> '' then + Result := StringReplace(Result, WarmupKey, '***', [rfReplaceAll]); +end; + +procedure Pass(const Name: String); +begin + Inc(Passed); + WriteLn(' + ', Name); +end; + +procedure Fail_(const Name, Problem: String); +begin + Inc(Failed); + Failures.Add(Concat(Name, ': ', Redact(Problem))); + WriteLn(' X ', Name); + WriteLn(' ', Redact(Problem)); +end; + +procedure Skip(const Name, Reason: String); +begin + Inc(Skipped); + WriteLn(' - SKIP ', Name, ' (', Reason, ')'); +end; + +{ A check either passes, or records why it did not. The body returns '' when + all is well. } +type + TCheckBody = function: String; + +procedure Check(const Name: String; Body: TCheckBody); +var + Problem: String; +begin + try + Problem := Body(); + except + on e: Exception do + Problem := Redact(e.Message); + end; + if Problem = '' then + Pass(Name) + else + Fail_(Name, Problem); +end; + +function DaysAgoIso(Days: Integer): String; +begin + Result := FormatDateTime('yyyy-mm-dd', IncDay(Date, -Days)); +end; + +{ -- validation API, free ------------------------------------------------- } + +var + CreditsBefore: Integer = -2; + +function CheckCredits: String; +begin + CreditsBefore := ZbGetCredits; + if CreditsBefore < -1 then + Exit('getcredits did not answer a balance'); + Result := ''; +end; + +function CheckApiUsage: String; +begin + ZbGetApiUsage(IncDay(Date, -7), Date); + Result := ''; +end; + +function CheckValidateSandbox: String; +var + Validation: TZbValidationResult; +begin + Validation := ZbValidateEmail(SANDBOX); + if Validation.Address <> SANDBOX then + Exit('address did not echo'); + Result := ''; +end; + +function CheckValidatePost: String; +var + Options: TZbValidateOptions; +begin + { No billable add-on here: activity_data, verify_plus and domain_info are + charged even on a sandbox address, so they live in the SPEND tier. } + Options := ZbDefaultValidateOptions; + Options.UsePost := 1; + Options.Timeout := 30; + ZbValidateEmail(SANDBOX, '', Options); + Result := ''; +end; + +function CheckValidateBatchTimeoutIsAString: String; +var + Options: TZbValidateOptions; + Batch: TZBBatchValidation; + Emails: array[0..0] of TZbEmailAndIp; + I: Integer; +begin + { LIVE: sent as a JSON number the API rejects the whole batch with the + account-level "Invalid API Key or your account ran out of credits". } + Options := ZbDefaultValidateOptions; + Options.Timeout := 30; + Emails[0].Email := SANDBOX; + Emails[0].Ip := ''; + Batch := ZbBatchValidateEmails(Emails, Options); + for I := 0 to Batch.ErrorsLength - 1 do + if LowerCase(Batch.Errors[I].EmailAddress) = 'all' then + Exit(Concat('account-level error: ', Batch.Errors[I].Error)); + Result := ''; +end; + +function CheckUnknownFileIdIsShort: String; +begin + try + ZbBulkValidationFileStatusCheck(UNKNOWN_ID); + Exit('filestatus unexpectedly succeeded'); + except + on e: Exception do + begin + if Length(e.Message) >= 500 then + Exit('message not short and clean'); + if Pos(' 0 then + Exit('message is an HTML page'); + Exit(''); + end; + end; +end; + +function CheckListS3Files: String; +begin + { No S3 integration configured is an expected, documented failure; only an + unreadable error is a problem. } + try + ZbListS3Files('', ''); + except + on e: Exception do + if Length(e.Message) >= 500 then + Exit('message too long'); + end; + Result := ''; +end; + +function CheckS3JobNeedsReturnUrl: String; +var + Options: TZbBulkFileJobOptions; +begin + Options := ZbDefaultBulkFileJobOptions; + Options.FileName := 'f.csv'; + try + ZbEmailFinderS3File(Options); + Exit('a payload without return_url was accepted'); + except + on e: Exception do + if Pos('return_url', e.Message) = 0 then + Exit(Concat('unexpected message: ', e.Message)); + end; + Result := ''; +end; + +function CheckCompleteS3Payload: String; +var + Options: TZbBulkFileJobOptions; +begin + { Without an S3 integration this fails on the integration, not the + payload. } + Options := ZbDefaultBulkFileJobOptions; + Options.FileName := 'f.csv'; + Options.ReturnUrl := CALLBACK; + try + ZbEmailFinderS3File(Options); + except + on e: Exception do + if (Pos('return URL is not permitted', e.Message) > 0) or + (Pos('This field is required', e.Message) > 0) then + Exit(Concat('payload rejected: ', Redact(e.Message))); + end; + Result := ''; +end; + +function CheckFiltersList: String; +begin + ZbListFilters(False); + Result := ''; +end; + +function CheckInboxPlacementBearer: String; +begin + { LIVE: an api_key parameter is rejected as unauthorized here. } + ZbInboxPlacementProviders; + Result := ''; +end; + +function CheckInboxPlacementTestTypes: String; +begin + ZbInboxPlacementTestTypes; + Result := ''; +end; + +function CheckInboxPlacementListing: String; +var + Options: TZbListInboxPlacementTestsOptions; +begin + { LIVE: the listing is GET /tests (plural); /test is the single-test path + and answers 'Method "GET" not allowed'. } + FillChar(Options, SizeOf(Options), 0); + Options.PageSpecified := True; + Options.Page := 1; + Options.PageSizeSpecified := True; + Options.PageSize := 10; + ZbListInboxPlacementTests(@Options); + ZbInboxPlacementWebhook; + Result := ''; +end; + +function CheckListEvaluatorUnknownId: String; +begin + try + ZbGetListEvaluatorResult(UNKNOWN_ID); + Exit('an unknown evaluation id unexpectedly succeeded'); + except + on e: Exception do + if Length(e.Message) >= 500 then + Exit('message too long'); + end; + Result := ''; +end; + +function CheckSendEvaluatedFileIsAForm: String; +begin + { LIVE: the file id is a form field. Appended to the path it is a 404. } + try + ZbSendEvaluatedFile(UNKNOWN_ID); + Exit('an unknown evaluation id unexpectedly succeeded'); + except + on e: Exception do + if Pos('not allowed', e.Message) > 0 then + Exit(Concat('wrong method or path: ', Redact(e.Message))); + end; + Result := ''; +end; + +function CheckCreditsUnchanged: String; +var + After: Integer; +begin + After := ZbGetCredits; + if After <> CreditsBefore then + Exit(Format('credits went from %d to %d', [CreditsBefore, After])); + Result := ''; +end; + +procedure ValidationFree; +begin + WriteLn('Validation API (free calls)'); + Check('getcredits returns a balance', @CheckCredits); + Check('getapiusage parses the documented date formats', @CheckApiUsage); + Check('validate on a sandbox address spends nothing', @CheckValidateSandbox); + Check('validate works over POST as well as GET', @CheckValidatePost); + Check('LIVE: validatebatch accepts timeout as a string', @CheckValidateBatchTimeoutIsAString); + Check('an unknown file id is a short, clean error', @CheckUnknownFileIdIsShort); + Check('listS3files answers (empty without an S3 integration)', @CheckListS3Files); + Check('LIVE: S3/SFTP jobs reject a payload without return_url before sending', + @CheckS3JobNeedsReturnUrl); + Check('LIVE: a complete S3 job payload is accepted by the API', @CheckCompleteS3Payload); + Check('filters list', @CheckFiltersList); + Check('LIVE: the Inbox Placement Tester accepts a Bearer key', @CheckInboxPlacementBearer); + Check('inbox placement test types', @CheckInboxPlacementTestTypes); + Check('LIVE: inbox placement tests are listed from /tests, and the webhook reads back', + @CheckInboxPlacementListing); + Check('list evaluator result for an unknown id fails cleanly', @CheckListEvaluatorUnknownId); + Check('LIVE: sendEvaluatedFile posts the file id as a form field', + @CheckSendEvaluatedFileIsAForm); + Check('the free calls did not change the credit balance', @CheckCreditsUnchanged); +end; + +{ -- validation API, spends credits --------------------------------------- } + +var + BulkFileId: String = ''; + +function CheckValidateEveryOption: String; +var + Options: TZbValidateOptions; +begin + { LIVE: activity_data, verify_plus and domain_info are billable add-ons and + are charged even for a sandbox address, so this is not free. } + Options := ZbDefaultValidateOptions; + Options.Timeout := 30; + Options.ActivityData := 1; + Options.VerifyPlus := 1; + Options.DomainInfo := 1; + Options.GreylistProcessing := 1; + Options.CallbackUrl := CALLBACK; + ZbValidateEmail(SANDBOX, '', Options); + Result := ''; +end; + +function CheckScoreEmail: String; +begin + ZbScoreEmail(SANDBOX); + Result := ''; +end; + +function CheckScoreEmailPost: String; +begin + ZbScoreEmailPost(SANDBOX); + Result := ''; +end; + +function CheckActivityData: String; +begin + ZbActivityData(SANDBOX); + Result := ''; +end; + +function CheckEmailFinder: String; +begin + ZbFindEmailByDomain('example.com', 'John', 'Doe'); + Result := ''; +end; + +function CheckDomainSearch: String; +begin + ZbDomainSearchByDomain('example.com'); + Result := ''; +end; + +function CheckBulkRoundTrip: String; +var + Params: TZbBulkParams; + Feedback: TZbFileFeedback; + Status: TZbFileStatus; +begin + FillChar(Params, SizeOf(Params), 0); + Params.EmailAddressColumn := 1; + Params.HasHeaderRow := True; + Feedback := ZbBulkValidationFileSubmit( + 'email'#10'valid@example.com'#10'invalid@example.com'#10, Params + ); + if Feedback.FileId = '' then + Exit('no file_id in the upload response'); + BulkFileId := Feedback.FileId; + Status := ZbBulkValidationFileStatusCheck(BulkFileId); + if Status.FileId <> BulkFileId then + Exit('status did not echo the file id'); + Result := ''; +end; + +function CheckBulkJobDeleted: String; +var + Attempt: Integer; + Last: String; +begin + { LIVE: a just-accepted job answers "File cannot be found" for a short + while, so the delete is retried rather than leaving it behind. } + Last := ''; + for Attempt := 1 to 12 do + begin + try + ZbBulkValidationResultDelete(BulkFileId); + Exit(''); + except + on e: Exception do + Last := Redact(e.Message); + end; + Sleep(10000); + end; + Result := Concat('could not delete ', BulkFileId, ' after retrying: ', Last); +end; + +procedure ValidationSpend; +begin + WriteLn; + WriteLn('Validation API (ZEROBOUNCE_LIVE_SPEND=1)'); + Check('validate accepts every documented option', @CheckValidateEveryOption); + Check('scoreEmail', @CheckScoreEmail); + Check('scoreEmail over POST', @CheckScoreEmailPost); + Check('getActivityData', @CheckActivityData); + Check('email finder', @CheckEmailFinder); + Check('domain search', @CheckDomainSearch); + Check('bulk validation round trip: upload, status, delete', @CheckBulkRoundTrip); + if BulkFileId = '' then + Skip('the uploaded validation job is deleted again', + 'upload did not produce a file id') + else + Check('the uploaded validation job is deleted again', @CheckBulkJobDeleted); +end; + +{ -- validation API, reversible account changes ---------------------------- } + +var + FilterValue: String = ''; + FilterAdded: Boolean = False; + +function CheckAddFilter: String; +begin + ZbAddFilter('allow', 'domain', FilterValue); + FilterAdded := True; + Result := ''; +end; + +function CheckDeleteFilter: String; +begin + ZbDeleteFilter('allow', 'domain', FilterValue); + Result := ''; +end; + +procedure ValidationChanges; +begin + WriteLn; + WriteLn('Validation API (ZEROBOUNCE_LIVE_CHANGES=1)'); + FilterValue := Format('zb-sdk-live-%d.example.com', [DateTimeToUnix(Now)]); + Check('addFilter creates a reversible allow rule', @CheckAddFilter); + if not FilterAdded then + Skip('the test filter is removed again', 'filter was not created') + else + Check('the test filter is removed again', @CheckDeleteFilter); +end; + +{ -- Warmup API ------------------------------------------------------------ } + +var + KnownFromAddress: String = ''; + +function CheckWarmupSendingAccounts: String; +begin + ZbWarmupListSendingAccounts; + Result := ''; +end; + +function CheckWarmupFromAddresses: String; +begin + ZbWarmupListFromAddresses(nil); + Result := ''; +end; + +function CheckWarmupEngagementRules: String; +begin + ZbWarmupGetEngagementRules; + Result := ''; +end; + +function CheckWarmupSeeds: String; +begin + ZbWarmupGetSeeds; + Result := ''; +end; + +function CheckWarmupSeedCount: String; +begin + { Answers a bare number, not an object. } + ZbWarmupGetSeedCount; + Result := ''; +end; + +function CheckWarmupProgressToday: String; +begin + ZbWarmupGetProgressToday; + Result := ''; +end; + +function CheckWarmupSentToday: String; +begin + ZbWarmupGetSentToday; + Result := ''; +end; + +function CheckWarmupEmailsPerDay: String; +begin + ZbWarmupGetEmailsPerDay; + Result := ''; +end; + +function CheckWarmupDownloadSeeds: String; +var + Csv: String; +begin + Csv := ZbWarmupDownloadSeeds(''); + if Trim(Csv) = '' then + Exit('empty CSV body'); + if Csv[1] = '{' then + Exit('got JSON instead of CSV'); + Result := ''; +end; + +function CheckDmarcRejectsBareDomain: String; +begin + { LIVE: a bare domain is answered with "Please provide a valid email + address", although the docs describe a domain. } + try + ZbWarmupCheckDomainDmarc('example.com'); + Exit('a bare domain unexpectedly succeeded'); + except + on e: Exception do + if Pos('email address', e.Message) = 0 then + Exit(Concat('unexpected message: ', Redact(e.Message))); + end; + Result := ''; +end; + +function CheckDmarcAcceptsEmail: String; +begin + ZbWarmupCheckDomainDmarc('sender@example.com'); + Result := ''; +end; + +function CheckWarmupStatistics: String; +begin + ZbWarmupGetStatistics(KnownFromAddress); + Result := ''; +end; + +function CheckWarmupStatisticsPerDay: String; +begin + ZbWarmupGetStatisticsPerDay(KnownFromAddress, DaysAgoIso(6), DaysAgoIso(0)); + Result := ''; +end; + +function CheckWarmupStatisticsDetailed: String; +begin + ZbWarmupGetStatisticsDetailed(KnownFromAddress); + ZbWarmupDownloadStatistics(KnownFromAddress); + Result := ''; +end; + +function CheckWarmupFromAddressStatistics: String; +begin + ZbWarmupGetFromAddressStatistics(KnownFromAddress); + Result := ''; +end; + +function CheckUnknownWarmupResource: String; +begin + try + ZbWarmupGetCustomEngagement('no-such-address@example.com'); + Exit('an unknown address unexpectedly succeeded'); + except + on e: ZbException do + begin + if e.StatusCode = 0 then + Exit('no status code on the error'); + if (WarmupKey <> '') and (Pos(WarmupKey, e.Message) > 0) then + Exit('the key leaked into the message'); + end; + end; + Result := ''; +end; + +{ The from-address of the first sending account this team has, or '': the + statistics endpoints answer "Sending account ... was not found" for anything + else. } +function FirstSendingAddress: String; +var + Body: String; + At, Quote: Integer; +begin + Result := ''; + try + Body := ZbWarmupListSendingAccounts; + except + on e: Exception do + Exit(''); + end; + At := Pos('"from_address"', Body); + if At = 0 then + At := Pos('"c_from"', Body); + if At = 0 then + Exit(''); + Body := Copy(Body, At, MaxInt); + At := Pos(':', Body); + if At = 0 then + Exit(''); + Body := Copy(Body, At + 1, MaxInt); + At := Pos('"', Body); + if At = 0 then + Exit(''); + Body := Copy(Body, At + 1, MaxInt); + Quote := Pos('"', Body); + if Quote <= 1 then + Exit(''); + Result := Copy(Body, 1, Quote - 1); +end; + +procedure WarmupFree; +const + NO_ACCOUNT = 'this team has no sending account'; +begin + WriteLn; + WriteLn('Warmup API (reads)'); + Check('listSendingAccounts', @CheckWarmupSendingAccounts); + Check('listFromAddresses', @CheckWarmupFromAddresses); + Check('getEngagementRules', @CheckWarmupEngagementRules); + Check('getSeeds', @CheckWarmupSeeds); + Check('getSeedCount', @CheckWarmupSeedCount); + Check('getProgressToday', @CheckWarmupProgressToday); + Check('getSentToday', @CheckWarmupSentToday); + Check('getEmailsPerDay', @CheckWarmupEmailsPerDay); + Check('downloadSeeds returns CSV text', @CheckWarmupDownloadSeeds); + Check('LIVE: checkDomainDmarc rejects a bare domain before sending', + @CheckDmarcRejectsBareDomain); + Check('LIVE: checkDomainDmarc accepts an email address', @CheckDmarcAcceptsEmail); + + KnownFromAddress := FirstSendingAddress; + if KnownFromAddress = '' then + begin + Skip('statistics for a registered from-address', NO_ACCOUNT); + Skip('statistics per day over a short range', NO_ACCOUNT); + Skip('detailed statistics and the CSV download for that address', NO_ACCOUNT); + Skip('from-address statistics', NO_ACCOUNT); + end + else + begin + Check('statistics for a registered from-address', @CheckWarmupStatistics); + Check('statistics per day over a short range', @CheckWarmupStatisticsPerDay); + Check('detailed statistics and the CSV download for that address', + @CheckWarmupStatisticsDetailed); + Check('from-address statistics', @CheckWarmupFromAddressStatistics); + end; + + Check('an unknown Warmup resource fails with a status code', @CheckUnknownWarmupResource); +end; + +var + WarmupAddress: String = ''; + WarmupAccountAdded: Boolean = False; + +function CheckAddSendingAccount: String; +var + Options: TZbWarmupAddSendingAccountOptions; +begin + Options := ZbWarmupDefaultAddSendingAccountOptions; + Options.EngagementRuleIdSpecified := True; + Options.EngagementRuleId := 0; + Options.IgnoreConflictSpecified := True; + Options.IgnoreConflict := True; + ZbWarmupAddSendingAccount(WarmupAddress, ZB_WARMUP_SELF_MANAGED, @Options); + WarmupAccountAdded := True; + Result := ''; +end; + +function CheckDeleteSendingAccount: String; +begin + ZbWarmupDeleteSendingAccount(WarmupAddress); + Result := ''; +end; + +procedure WarmupChanges; +begin + WriteLn; + WriteLn('Warmup API (ZEROBOUNCE_LIVE_CHANGES=1)'); + WarmupAddress := Format('zb-sdk-live-%d@example.com', [DateTimeToUnix(Now)]); + Check('a self-managed sending account can be added', @CheckAddSendingAccount); + if not WarmupAccountAdded then + Skip('the test sending account is removed again', 'account was not created') + else + Check('the test sending account is removed again', @CheckDeleteSendingAccount); +end; + +{ -- main ------------------------------------------------------------------ } + +procedure KeyLine(const Label_, Value: String); +begin + if Value = '' then + WriteLn(' ', Label_, 'MISSING') + else + WriteLn(' ', Label_, 'set'); +end; + +var + I: Integer; + +begin + ApiKey := Env('ZEROBOUNCE_API_KEY'); + WarmupKey := Env('ZEROBOUNCE_WARMUP_API_KEY'); + SpendFlag := Env('ZEROBOUNCE_LIVE_SPEND') = '1'; + ChangesFlag := Env('ZEROBOUNCE_LIVE_CHANGES') = '1'; + Failures := TStringList.Create; + try + WriteLn('ZeroBounce live API suite'); + KeyLine('validation key: ', ApiKey); + KeyLine('warmup key: ', WarmupKey); + WriteLn(Format(' flags: SPEND=%d CHANGES=%d', + [Ord(SpendFlag), Ord(ChangesFlag)])); + WriteLn; + + if ApiKey <> '' then + begin + ZBInitialize(ApiKey); + ValidationFree; + if SpendFlag then + ValidationSpend; + if ChangesFlag then + ValidationChanges; + end; + + if WarmupKey <> '' then + begin + ZbWarmupInitialize(WarmupKey); + WarmupFree; + if ChangesFlag then + WarmupChanges; + end; + + WriteLn; + WriteLn(Format('%d passed, %d failed, %d skipped', [Passed, Failed, Skipped])); + if Failures.Count > 0 then + begin + WriteLn; + WriteLn('Failures:'); + for I := 0 to Failures.Count - 1 do + WriteLn(' - ', Failures[I]); + end; + finally + Failures.Free; + end; + if Failed > 0 then + Halt(1); +end. From 1be04a03ecc3f53328625caa7f88b628bc170fa1 Mon Sep 17 00:00:00 2001 From: Francisc Balint <7178547+franciscbalint@users.noreply.github.com> Date: Sat, 24 Oct 2026 10:42:43 +0400 Subject: [PATCH 7/8] docs: document ZbPriority3, ZbWarmup and the live suite README.md and documentation.md had no entry for ZbPriority3 at all. Both now list it and the new ZbWarmup unit, including the shapes that are easy to get wrong: the bulk root host without /v2, the Bearer key on the Inbox Placement Tester, file_name and return_url being required on every S3/SFTP job, and check-domain-dmarc taking an email address. AGENTS.md gains the live-API-wins rule with the differences found so far, the safety rails in ZbUtility, and a release rule: run the live suite by hand with real keys before tagging, because the offline suite cannot catch an API that has changed shape underneath the SDK. Co-Authored-By: Claude Opus 5 --- AGENTS.md | 28 +++++++++++++++++++++++++--- CHANGELOG.md | 13 +++++++++++-- README.md | 14 ++++++++++++++ documentation.md | 14 ++++++++++++++ 4 files changed, 64 insertions(+), 5 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index ea662d0..8323698 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -6,8 +6,10 @@ - `src/zbvalidation.pas`: single and batch validation, plus `TZbValidateOptions`. - `src/zbbulk.pas`: the multipart bulk uploads, their status checks and `getfile`. - `src/zbpriority3.pas`: real-time AI scoring, the filters, the list evaluator, the S3 / SFTP bulk jobs and the Inbox Placement Tester. +- `src/zbwarmup.pas`: the Warmup API. A separate product on its own host with its own team-scoped key and an `API-Key` header, so it has its own `ZbWarmupInitialize` and never touches `ZbApiKey`. - `src/zbstructures.pas`: the records and their JSON parsers. -- `tests/testpriority3.pas`: the offline suite for the above. Every request is answered by `ZbMockResponse`. +- `tests/testpriority3.pas`, `tests/testbulkjobs.pas`, `tests/testwarmup.pas`: the offline suites for the above. Every request is answered by `ZbMockResponse`. +- `live/live_api.lpr`: the opt-in live suite. Its own program, outside `tests/`, so the offline suite never builds or runs it. ## Build and test @@ -26,6 +28,18 @@ docker run --rm -u 0 -v "$PWD":/src -w /src zerobounce-sdk-pascal:test \ A new test unit must be added to the `uses` clause of `tests/unit_tests_console.lpr`, or it never runs. +The live suite is a separate program and is never built by the command above: + +```sh +docker run --rm -u 0 -v "$PWD":/src -w /src \ + -e ZEROBOUNCE_API_KEY -e ZEROBOUNCE_WARMUP_API_KEY \ + -e ZEROBOUNCE_LIVE_SPEND=1 -e ZEROBOUNCE_LIVE_CHANGES=1 \ + zerobounce-sdk-pascal:test \ + sh -c "fpc -Fu./src -Mobjfpc -O2 live/live_api.lpr -FE/tmp && /tmp/live_api" +``` + +Keys come from the environment only. Never write one into a file, a commit, a log, a fixture or a message. + The `.o` and `.ppu` files at the repo root are build artifacts, not sources. ## Rules @@ -33,6 +47,14 @@ The `.o` and `.ppu` files at the repo root are build artifacts, not sources. - **Only endpoints documented at https://www.zerobounce.net/docs/.** Do not add a function for an endpoint the public docs do not describe, even if it exists on the server. Check before concluding something is undocumented: the S3 and SFTP bulk endpoints live under https://www.zerobounce.net/docs/api-integrations, not the main API reference. - **Four base URLs.** `ZbApiBaseURL`, `BULK_URI` (ends in `/v2`), `ZbBulkRootURL` (the S3/SFTP jobs, **no** `/v2`), and `ZbListEvaluatorURL` / `ZbInboxPlacementURL`. The last three are separate values rather than paths built from `BULK_URI`, so overriding the main one alone leaves a test pointing at the live API. `TTestPriority3.SetUp` resets them. - **`EncodeParam` is query encoding.** It can turn a space into `+`, which is correct in a query string but a literal plus in a path. Use `EncodePathSegment` for anything that goes into a path, such as the list evaluator file id and the Inbox Placement test id. -- **Two bulk shapes are easy to get wrong.** `ZbValidateSFTPFile`'s `FileName` is optional — leaving it empty uses the input bucket from the account's settings, and every other job requires it. `ZbExportSFTPFile` sends `export_path`, not the `export_bucket` that `ZbExportS3File` sends. +- **The live API wins over the docs.** Where they disagree, follow the API and say so in a comment at the call site, prefixed `LIVE:`. The ones found so far: `validatebatch` takes `timeout` as a JSON *string*; the Inbox Placement Tester authenticates with `Authorization: Bearer` and lists tests from `GET /tests` (plural); every S3/SFTP job requires both `file_name` and `return_url`, `validateSFTPfile` included, although the docs call its `file_name` optional; `send-evaluated-file` takes the file id as a form field; Warmup's `check-domain-dmarc` takes an *email address*, not a domain; and `test-smtp-configuration` answers a failed SMTP login with HTTP 500. +- **A `"success": false` field on an HTTP 200 is a failure.** `getfile` and `deletefile` report an unknown or deleted file id that way. `ZbSuccessMeansFailure` catches it in `ZbFinishResponse`, and `ZbException.StatusCode` carries the HTTP status. +- **`ZbExportSFTPFile` sends `export_path`**, not the `export_bucket` that `ZbExportS3File` sends. +- **Safety rails in `ZbUtility`.** `ZbCheckedApiKey` rejects a key with surrounding whitespace or control characters, `ZbRequireHttpsUrl` allows https (and loopback, for a local stub) only, `ZbPathSegment` rejects `.` and `..`, redirects are never followed, and `RedactSecrets` keeps the key out of exception messages. Use them rather than adding a second rail beside them. - **Optional record fields use a `Has...` flag.** Pascal records have no nullable scalars, so every optional option carries a companion boolean and is only sent when that flag is set. -- **Offline tests only.** Never call the live API from a test. `ZbMockResponse` short-circuits every request function; `ZbLastRequestMethod` and `ZbLastRequestBody` record what would have been sent. +- **Offline tests only.** Never call the live API from a test under `tests/`. `ZbMockResponse` short-circuits every request function; `ZbLastRequestMethod`, `ZbLastRequestBody` and `ZbLastRequestHeader` record what would have been sent. +- **Never from the live suite:** creating an Inbox Placement test (it cannot be deleted while running), setting the Inbox Placement webhook (it cannot be cleared), or ZB managed Warmup onboarding (it provisions a mailbox). Everything the `ZEROBOUNCE_LIVE_CHANGES=1` tier creates is removed again in the same run. + +## Releasing + +Before tagging a release, run the live suite by hand with real keys and both flags set, and paste its summary line into the release notes. The offline suite cannot catch an API that has changed shape underneath the SDK. diff --git a/CHANGELOG.md b/CHANGELOG.md index 467dcff..fee2cab 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,11 +12,20 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - `TZbValidateOptions` accepts `DomainInfo`, `GreylistProcessing` and `CallbackUrl`. A `-1` field is not sent; booleans go on the wire as the strings `true`/`false`. `CallbackUrl` is where the greylist retry posts its result, so it is rejected without `GreylistProcessing = 1`, and when blank, before any request is sent. - `TZbValidationResult` carries `DomainWebsiteExists`, `DomainRegistrantCompanyName` and `Message`. `Message` is the API's `Message` key, returned instead of a result while a greylist retry is still running. - The rest of the documented bulk surface: `ZbEmailFinderS3File`, `ZbDomainSearchS3File`, and the SFTP/FTP jobs `ZbValidateSFTPFile`, `ZbScoreSFTPFile`, `ZbEmailFinderSFTPFile`, `ZbDomainSearchSFTPFile` and `ZbExportSFTPFile`, sharing the new `TZbBulkFileJobOptions`. `ZbValidateSFTPFile`'s `FileName` is optional - leaving it empty uses the input bucket from your ZeroBounce settings - and `ZbExportSFTPFile` takes `export_path`, not the `export_bucket` `ZbExportS3File` sends. -- The Inbox Placement Tester: the API key goes on the query string, bodies are camelCase JSON, and unset fields are left out. -- **ZbUtility**: `ZbListEvaluatorURL` and `ZbInboxPlacementURL` (separate base URLs rather than paths built from `BULK_URI`), `ZBSendRequest`, `EncodePathSegment`, and `ZbLastRequestMethod` / `ZbLastRequestBody` for the offline tests. +- The Inbox Placement Tester: an `Authorization: Bearer` key, camelCase JSON bodies, and unset fields left out. +- **ZbWarmup**: a client for the [Warmup API](https://www.zerobounce.net/docs/warmup-api), a separate product with its own host, its own team-scoped key (`ZbWarmupInitialize`, sent in an `API-Key` header) and 26 endpoints. Every function returns the raw JSON body, or an empty string for the documented empty successes (HTTP 204 and an empty 200); `ZbWarmupDownloadSeeds` and `ZbWarmupDownloadStatistics` return CSV text and save it as well when given a path. +- **ZbUtility**: `ZbListEvaluatorURL` and `ZbInboxPlacementURL` (separate base URLs rather than paths built from `BULK_URI`), `ZBSendRequest`, `ZBSendRequestWithHeader`, `ZBSendForeignKeyRequest`, `EncodePathSegment`, `ZbJsonEscape`, and `ZbLastRequestMethod` / `ZbLastRequestBody` / `ZbLastRequestHeader` for the offline tests. +- **ZbUtility** safety rails: `ZbCheckedApiKey` rejects a key with surrounding whitespace or control characters, `ZbRequireHttpsUrl` allows only `https://` (and loopback, so an offline test can use a local stub), `ZbPathSegment` rejects a `.` or `..` id, redirects are never followed, and `RedactSecrets` keeps the key out of exception messages. A regional base URL paired with the single global bulk host warns once. +- `live/live_api.lpr`, an opt-in live suite. It is its own program, outside `tests/`, so the offline suite never builds or runs it. Keys come from `ZEROBOUNCE_API_KEY` and `ZEROBOUNCE_WARMUP_API_KEY`; `ZEROBOUNCE_LIVE_SPEND=1` and `ZEROBOUNCE_LIVE_CHANGES=1` opt in to calls that spend credits or make reversible account changes. ### Changed +- Verified against the live API. Where it disagrees with the docs, the API wins and the call site says so: + - `validatebatch` takes `timeout` as a JSON **string**. Sent as a number the API rejects the whole batch with the account-level "Invalid API Key or your account ran out of credits". + - The Inbox Placement Tester authenticates with `Authorization: Bearer`, not the `api_key` query parameter the rest of the API takes, and its listing is `GET /tests` (plural) - `/test` answers `Method "GET" not allowed`. + - Every S3/SFTP job requires both `file_name` and `return_url`, `ZbValidateSFTPFile` included, although the docs call its `file_name` optional. Both are checked before the request is sent. + - `ZbSendEvaluatedFile` takes the file id as a form field rather than a path segment. +- A `"success": false` field on an HTTP 200 now raises instead of being returned as a result. `getfile` and `deletefile` report an unknown or deleted file id that way, and `ZbException.StatusCode` carries the HTTP status. - `ZbGetListEvaluatorResult` uses `ZbListEvaluatorURL`, encodes the file id as a single path segment, and rejects a blank id before a request is sent. - `ZbExportS3File`'s `ExportBucket` is optional, matching the documented endpoint: an empty string uses the export bucket from your ZeroBounce settings. A whitespace-only value is still rejected. diff --git a/README.md b/README.md index 7dd90cf..8ff120e 100644 --- a/README.md +++ b/README.md @@ -50,6 +50,20 @@ The methods implemented in this library can raise `ZbException`. - `ZbAiScoringResultFetch` (returns `TZBBulkResponse`) - fetch the scoring result; optional `Options: PTZbGetFileOptions = nil` (`activity_data` is not sent for scoring getfile) - `ZbAiScoringResultDelete` (returns `TZBFileFeedback`) - delete file submitted for AI scoring - For getfile, if the response body is JSON error data (including some HTTP 200 cases), `TZBBulkResponse.HasContent` is false and `Feedback` is filled; otherwise `HasContent` is true and `Content` holds the file text. Helper: `ZbGetFileJsonIndicatesError` in __ZbStructures__. +- __ZbPriority3__ - real-time AI scoring, filters, the list evaluator, the S3 / SFTP bulk jobs and the Inbox Placement Tester + - `ZbScoreEmail` / `ZbScoreEmailPost` (return `TZbScoreEmailResult`) - real-time AI scoring over GET or POST + - Bulk email finder and domain search, on the bulk root (`https://bulkapi.zerobounce.net`, **no** `/v2`): `ZbSendEmailFinderFile`, `ZbEmailFinderFileStatus`, `ZbEmailFinderResultFetch`, `ZbEmailFinderResultDelete`, and the matching `ZbSendDomainSearchFile` / `ZbDomainSearch...` functions + - List evaluator: `ZbSendListEvaluatorFile`, `ZbGetListEvaluatorResult` (its own base URL, `ZbListEvaluatorURL`), and `ZbSendEvaluatedFile(FileId)` - the file id goes out as a **form field**, not as a path segment + - Filters: `ZbAddFilter(Rule, Target, Value)`, `ZbListFilters(UsePost)`, `ZbDeleteFilter(Rule, Target, Value)`. `Rule` is `allow` or `block`; `Target` is `email`, `domain`, `mx` or `tld`. Both are checked before the request is sent. + - S3 / SFTP jobs: `ZbListS3Files`, `ZbValidateS3File`, `ZbScoreS3File`, `ZbExportS3File`, `ZbEmailFinderS3File`, `ZbDomainSearchS3File`, `ZbValidateSFTPFile`, `ZbScoreSFTPFile`, `ZbEmailFinderSFTPFile`, `ZbDomainSearchSFTPFile`, `ZbExportSFTPFile`. `TZbBulkFileJobOptions` requires **both** `FileName` and `ReturnUrl` — the live API rejects a job without either, `validateSFTPfile` included, although the docs call its `file_name` optional. `ZbExportSFTPFile` sends `export_path`, not the `export_bucket` that `ZbExportS3File` sends. + - Inbox Placement Tester (its own host, `ZbInboxPlacementURL`): `ZbInboxPlacementProviders`, `ZbInboxPlacementTestTypes`, `ZbCreateInboxPlacementTest`, `ZbInboxPlacementTest`, `ZbListInboxPlacementTests`, `ZbDeleteInboxPlacementTest`, `ZbInboxPlacementWebhook`, `ZbSetInboxPlacementWebhook`. It authenticates with an `Authorization: Bearer` header, takes camelCase JSON bodies, and lists tests from `GET /tests`. These calls spend the account's inbox-test credits. +- __ZbWarmup__ - the [Warmup API](https://www.zerobounce.net/docs/warmup-api), a separate product with its own host and its own team-scoped key + - `ZbWarmupInitialize('YOUR__WARMUP__API__KEY')` - the key goes out in an `API-Key` header. It is **not** the validation key, and `ZBInitialize` stays for that one. + - Every function returns the raw JSON body, or an empty string for the documented empty successes (HTTP 204 and an empty 200). + - Sending accounts: `ZbWarmupListSendingAccounts`, `ZbWarmupGetSendingAccount`, `ZbWarmupAddSendingAccount`, `ZbWarmupUpdateSendingAccount`, `ZbWarmupDeleteSendingAccount`, `ZbWarmupChangeFromAddress`, `ZbWarmupListFromAddresses` + - Engagement rules: `ZbWarmupGetEngagementRules`, `ZbWarmupSetEngagementRule`, `ZbWarmupGetCustomEngagement`, `ZbWarmupCreateCustomEngagement`, `ZbWarmupUpdateCustomEngagement`, `ZbWarmupDeleteCustomEngagement`. A standard rule is 0 to 4; a custom rule takes `reply_pct` 5-45, `important_pct` 5-40 and `click_pct` 0-70 and at most `important_pct` x 1.75, all checked before sending. + - Seeds, progress and statistics: `ZbWarmupGetSeeds`, `ZbWarmupDownloadSeeds`, `ZbWarmupGetSeedCount`, `ZbWarmupGetProgressToday`, `ZbWarmupGetSentToday`, `ZbWarmupGetEmailsPerDay`, `ZbWarmupGetStatistics`, `ZbWarmupGetStatisticsPerDay`, `ZbWarmupGetStatisticsDetailed`, `ZbWarmupDownloadStatistics`, `ZbWarmupGetFromAddressStatistics`. The two download functions return CSV text and save it as well when given a path. + - Deliverability: `ZbWarmupCheckDomainDmarc(FromAddress[, DkimSelector])` takes an **email address**, not a bare domain, which is what the live API requires; `ZbWarmupTestSmtpConfiguration` stores nothing and answers a failed SMTP login with HTTP 500. - __ZbStructures__ - contains structures returned by the methods enumerated above (`TZbFileStatus` includes `FilePhase2Status` / `file_phase_2_status` when the API returns it) diff --git a/documentation.md b/documentation.md index a41e0e1..db2c9e2 100644 --- a/documentation.md +++ b/documentation.md @@ -35,6 +35,20 @@ The methods implemented in this library can raise `ZbException`. - `ZbAiScoringResultFetch` (returns `TZBBulkResponse`) - fetch the scoring result; optional `Options: PTZbGetFileOptions = nil` (`activity_data` is not sent for scoring getfile) - `ZbAiScoringResultDelete` (returns `TZBFileFeedback`) - delete file submitted for AI scoring - JSON error bodies for getfile (including some HTTP 200 responses) set `TZBBulkResponse.HasContent` false and fill `Feedback`. Helper: `ZbGetFileJsonIndicatesError` in __ZbStructures__. +- __ZbPriority3__ - real-time AI scoring, filters, the list evaluator, the S3 / SFTP bulk jobs and the Inbox Placement Tester + - `ZbScoreEmail` / `ZbScoreEmailPost` (return `TZbScoreEmailResult`) - real-time AI scoring over GET or POST + - Bulk email finder and domain search, on the bulk root (`https://bulkapi.zerobounce.net`, **no** `/v2`): `ZbSendEmailFinderFile`, `ZbEmailFinderFileStatus`, `ZbEmailFinderResultFetch`, `ZbEmailFinderResultDelete`, and the matching `ZbSendDomainSearchFile` / `ZbDomainSearch...` functions + - List evaluator: `ZbSendListEvaluatorFile`, `ZbGetListEvaluatorResult` (its own base URL, `ZbListEvaluatorURL`), and `ZbSendEvaluatedFile(FileId)` - the file id goes out as a **form field**, not as a path segment + - Filters: `ZbAddFilter(Rule, Target, Value)`, `ZbListFilters(UsePost)`, `ZbDeleteFilter(Rule, Target, Value)`. `Rule` is `allow` or `block`; `Target` is `email`, `domain`, `mx` or `tld`. Both are checked before the request is sent. + - S3 / SFTP jobs: `ZbListS3Files`, `ZbValidateS3File`, `ZbScoreS3File`, `ZbExportS3File`, `ZbEmailFinderS3File`, `ZbDomainSearchS3File`, `ZbValidateSFTPFile`, `ZbScoreSFTPFile`, `ZbEmailFinderSFTPFile`, `ZbDomainSearchSFTPFile`, `ZbExportSFTPFile`. `TZbBulkFileJobOptions` requires **both** `FileName` and `ReturnUrl` — the live API rejects a job without either, `validateSFTPfile` included, although the docs call its `file_name` optional. `ZbExportSFTPFile` sends `export_path`, not the `export_bucket` that `ZbExportS3File` sends. + - Inbox Placement Tester (its own host, `ZbInboxPlacementURL`): `ZbInboxPlacementProviders`, `ZbInboxPlacementTestTypes`, `ZbCreateInboxPlacementTest`, `ZbInboxPlacementTest`, `ZbListInboxPlacementTests`, `ZbDeleteInboxPlacementTest`, `ZbInboxPlacementWebhook`, `ZbSetInboxPlacementWebhook`. It authenticates with an `Authorization: Bearer` header, takes camelCase JSON bodies, and lists tests from `GET /tests`. These calls spend the account's inbox-test credits. +- __ZbWarmup__ - the [Warmup API](https://www.zerobounce.net/docs/warmup-api), a separate product with its own host and its own team-scoped key + - `ZbWarmupInitialize('YOUR__WARMUP__API__KEY')` - the key goes out in an `API-Key` header. It is **not** the validation key, and `ZBInitialize` stays for that one. + - Every function returns the raw JSON body, or an empty string for the documented empty successes (HTTP 204 and an empty 200). + - Sending accounts: `ZbWarmupListSendingAccounts`, `ZbWarmupGetSendingAccount`, `ZbWarmupAddSendingAccount`, `ZbWarmupUpdateSendingAccount`, `ZbWarmupDeleteSendingAccount`, `ZbWarmupChangeFromAddress`, `ZbWarmupListFromAddresses` + - Engagement rules: `ZbWarmupGetEngagementRules`, `ZbWarmupSetEngagementRule`, `ZbWarmupGetCustomEngagement`, `ZbWarmupCreateCustomEngagement`, `ZbWarmupUpdateCustomEngagement`, `ZbWarmupDeleteCustomEngagement`. A standard rule is 0 to 4; a custom rule takes `reply_pct` 5-45, `important_pct` 5-40 and `click_pct` 0-70 and at most `important_pct` x 1.75, all checked before sending. + - Seeds, progress and statistics: `ZbWarmupGetSeeds`, `ZbWarmupDownloadSeeds`, `ZbWarmupGetSeedCount`, `ZbWarmupGetProgressToday`, `ZbWarmupGetSentToday`, `ZbWarmupGetEmailsPerDay`, `ZbWarmupGetStatistics`, `ZbWarmupGetStatisticsPerDay`, `ZbWarmupGetStatisticsDetailed`, `ZbWarmupDownloadStatistics`, `ZbWarmupGetFromAddressStatistics`. The two download functions return CSV text and save it as well when given a path. + - Deliverability: `ZbWarmupCheckDomainDmarc(FromAddress[, DkimSelector])` takes an **email address**, not a bare domain, which is what the live API requires; `ZbWarmupTestSmtpConfiguration` stores nothing and answers a failed SMTP login with HTTP 500. - __ZbStructures__ - contains structures returned by the methods enumerated above (`TZbFileStatus` includes `FilePhase2Status` when the API returns `file_phase_2_status`) From 434ea252ea0f91820b91e6be816666bec9bceef6 Mon Sep 17 00:00:00 2001 From: Francisc Balint <7178547+franciscbalint@users.noreply.github.com> Date: Sat, 24 Oct 2026 17:09:12 +0400 Subject: [PATCH 8/8] fix: api usage dropped two counters the API returns Found by auditing every SDK's api-usage model against the keys the live API actually returns, after the same class of bug turned up in Rust and iOS. sub_status_leading_period_removed and sub_status_alias_address are returned by getapiusage and were not read at all, so both were silently discarded. Co-Authored-By: Claude Opus 5 --- CHANGELOG.md | 2 ++ src/zbstructures.pas | 4 ++++ 2 files changed, 6 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index fee2cab..7d5b3bd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -31,6 +31,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed +- The api-usage response now reads `sub_status_leading_period_removed` and `sub_status_alias_address`, two counters the API returns that were silently discarded. + - A validation response without `processed_at` no longer raises. A greylist retry that is still running answers with `Message` and no date; `ProcessedAt` is left at zero instead. diff --git a/src/zbstructures.pas b/src/zbstructures.pas index 8ffa110..4e3792a 100644 --- a/src/zbstructures.pas +++ b/src/zbstructures.pas @@ -68,6 +68,8 @@ TZbJson = class SubStatusAllowed: Integer; SubStatusGold: Integer; SubStatusRoleBasedAcceptAll: Integer; + SubStatusLeadingPeriodRemoved: Integer; + SubStatusAliasAddress: Integer; StartDate: TDate; EndDate: TDate; end; @@ -352,6 +354,8 @@ implementation Result.SubStatusAllowed := JsonObj.GetInteger('sub_status_allowed'); Result.SubStatusGold := JsonObj.GetInteger('sub_status_gold'); Result.SubStatusRoleBasedAcceptAll := JsonObj.GetInteger('sub_status_role_based_accept_all'); + Result.SubStatusLeadingPeriodRemoved := JsonObj.GetInteger('sub_status_leading_period_removed'); + Result.SubStatusAliasAddress := JsonObj.GetInteger('sub_status_alias_address'); Result.StartDate := ExtractDate(JsonObj.GetString('start_date')); Result.EndDate := ExtractDate(JsonObj.GetString('end_date'));