diff --git a/AGENTS.md b/AGENTS.md
index 3b0f7c9..8323698 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -1,61 +1,60 @@
-# AGENTS.md
+# Agent notes — zero-bounce-pascal
-Guidance for AI coding agents working in this repository.
-
-## Project
+## Layout
-`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.
+- `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/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`, `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.
-## Layout
+## Build and test
-- `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.
+FPC runs in Docker (there is no `fpc` on the host):
-## Commands
+```sh
+cd ../sdk-docs && docker compose run --rm --build pascal
+```
-Free Pascal was not on the machine used to add the offline suite. CI installs it. The default test command is:
+To build and run against the working tree without rebuilding the image:
-```bash
-fpc -Fu./src -Fu./tests -Mobjfpc -O2 -gl -viwn \
- tests/unit_tests_console.lpr -FE.
-./unit_tests_console --all
+```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"
```
-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`.
+A new test unit must be added to the `uses` clause of `tests/unit_tests_console.lpr`, or it never runs.
-## Conventions
+The live suite is a separate program and is never built by the command above:
-- **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.
+```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"
+```
-## Changelog
+Keys come from the environment only. Never write one into a file, a commit, a log, a fixture or a message.
-`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).
+The `.o` and `.ppu` files at the repo root are build artifacts, not sources.
-- 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.
+## Rules
-## Releasing
+- **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.
+- **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 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.
-Publishing and pushing tags need a maintainer's explicit approval. Do not bump the version, tag, or publish unless they asked.
+## Releasing
-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.
+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 bed6007..7d5b3bd 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -7,6 +7,35 @@ 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: 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.
+
+### 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.
+
+
### Added
- `ZbValidateEmail` and `ZbBatchValidateEmails` take `TZbValidateOptions`: `Timeout`, `ActivityData`, `VerifyPlus`, and `UsePost` (form-encoded `POST /validate`).
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`)
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.
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/zbpriority3.pas b/src/zbpriority3.pas
index 19eef8f..fef5353 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;
@@ -71,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;
@@ -82,6 +114,43 @@ 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;
+{ 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;
+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, 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;
+ 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,17 +434,44 @@ function ZbGetListEvaluatorResult(FileId: String): String;
var
UrlToAccess: String;
begin
- UrlToAccess := Concat(BULK_URI, '/listevaluator/', FileId, '/?api_key=', EncodeParam(ZbApiKey));
+ { 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, '/', ZbPathSegment(FileId, 'file_id'),
+ '/?api_key=', EncodeParam(ZbApiKey)
+ );
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;
@@ -482,11 +578,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 +594,228 @@ 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. }
+{ 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 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));
+ 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));
+
+ 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);
+end;
+
+function ZbDomainSearchS3File(Options: TZbBulkFileJobOptions): TZBFileFeedback;
+begin
+ 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);
+end;
+
+function ZbScoreSFTPFile(Options: TZbBulkFileJobOptions): TZBFileFeedback;
+begin
+ Result := P3BulkRootFileJob('/scoreSFTPfile', Options);
+end;
+
+function ZbEmailFinderSFTPFile(Options: TZbBulkFileJobOptions): TZBFileFeedback;
+begin
+ Result := P3BulkRootFileJob('/emailFinderSFTPfile', Options);
+end;
+
+function ZbDomainSearchSFTPFile(Options: TZbBulkFileJobOptions): TZBFileFeedback;
+begin
+ Result := P3BulkRootFileJob('/domainSearchSFTPfile', Options);
+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, Query: String;
+begin
+ UrlToAccess := Concat(ZbInboxPlacementURL, Path);
+ { 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;
+begin
+ Result := ZbPathSegment(TestId, 'test_id');
+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;
+ { 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;
+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..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;
@@ -101,6 +103,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
@@ -346,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'));
@@ -422,7 +432,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..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';
@@ -68,11 +69,24 @@ 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 = '';
+ { '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 = (
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
@@ -85,12 +99,44 @@ 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". }
+ 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
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;
+ { As ZBSendRequest, plus one extra request header. The Inbox Placement
+ 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;
@@ -247,6 +293,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;
@@ -286,7 +355,18 @@ 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 (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
Error := ZbException.FromResponse('', Response);
Error.MarkHttpError;
@@ -307,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;
@@ -332,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;
@@ -362,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;
@@ -380,6 +548,7 @@ procedure ZbUseVerifiedTls(Client: TFPHTTPClient);
error: ZbException;
begin
ZbRequireApiKey;
+ url := ZbCheckedUrl(url);
if ZbResponseMock.StatusCode <> 0 then
begin
ZbResponseMock.UrlCalled := url;
@@ -396,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]);
@@ -409,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();
@@ -436,6 +609,7 @@ procedure ZbUseVerifiedTls(Client: TFPHTTPClient);
error: ZbException;
begin
ZbRequireApiKey;
+ url := ZbCheckedUrl(url);
if ZbResponseMock.StatusCode <> 0 then
begin
ZbResponseMock.UrlCalled := url;
@@ -451,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');
@@ -465,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';
@@ -485,6 +663,137 @@ 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;
+ begin
+ 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;
+ ResponseStream: TStringStream;
+ {$ELSE}
+ Client: THTTPClient;
+ BodyStream: TStream;
+ Response: IHTTPResponse;
+ {$ENDIF}
+ begin
+ url := ZbCheckedUrl(url);
+ ZbLastRequestMethod := Method;
+ ZbLastRequestBody := Body;
+ if HeaderName <> '' then
+ ZbLastRequestHeader := Concat(HeaderName, ': ', HeaderValue)
+ else
+ ZbLastRequestHeader := '';
+
+ 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;
+ { A redirect could send the key to another host. }
+ Client.MaxRedirects := 0;
+ Client.AddHeader('Accept', 'application/json');
+ if HeaderName <> '' then
+ Client.AddHeader(HeaderName, HeaderValue);
+ 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;
+ { A redirect could send the key to another host. }
+ Client.HandleRedirects := False;
+ 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);
+ 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 +806,9 @@ procedure ZbUseVerifiedTls(Client: TFPHTTPClient);
error: ZbException;
begin
ZbRequireApiKey;
+ url := ZbCheckedUrl(url);
+ ZbLastRequestMethod := 'POST';
+ ZbLastRequestBody := FormBody;
if ZbResponseMock.StatusCode <> 0 then
begin
ZbResponseMock.UrlCalled := url;
@@ -512,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');
@@ -526,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';
@@ -584,6 +900,7 @@ procedure ZbUseVerifiedTls(Client: TFPHTTPClient);
{$ENDIF}
begin
ZbRequireApiKey;
+ url := ZbCheckedUrl(url);
if ZbResponseMock.StatusCode <> 0 then
begin
Result := ZbResponseMock;
@@ -600,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
@@ -633,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
diff --git a/src/zbvalidation.pas b/src/zbvalidation.pas
index 01f0723..033dd5d 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;
@@ -115,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'
@@ -155,6 +170,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 +193,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 +224,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/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/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
new file mode 100644
index 0000000..39b1915
--- /dev/null
+++ b/tests/testbulkjobs.pas
@@ -0,0 +1,587 @@
+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 TestSendEvaluatedFilePostsTheFileIdAsAFormField;
+ procedure TestS3AndSftpJobsSendTheDocumentedFieldsOnly;
+ procedure TestEveryBulkFileJobRequiresFileNameAndReturnUrl;
+ 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;
+ 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);
+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.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;
+begin
+ MockOk(FILE_FEEDBACK_JSON);
+
+ Options := ZbDefaultBulkFileJobOptions;
+ Options.FileName := 'lists/leads.csv';
+ Options.ReturnUrl := 'https://example.com/done';
+ 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('return_url', 'https://example.com/done');
+ AssertBodyField('has_header_row', 'true');
+ { Options that were never set stay off the request entirely. }
+ RefuteBodyField('remove_duplicates');
+
+ Options.HasHeaderRow := False;
+ Options.RemoveDuplicatesSpecified := True;
+ Options.RemoveDuplicates := True;
+ ZbDomainSearchS3File(Options);
+
+ AssertEquals(
+ 'https://bulkapi.zerobounce.net/domainSearchS3file', ZbResponseMock.UrlCalled
+ );
+ AssertBodyField('has_header_row', 'false');
+ AssertBodyField('remove_duplicates', 'true');
+
+ 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.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);
+
+ { 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;
+ 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;
+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');
+
+ { A blank name is rejected just like a missing one. }
+ 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');
+ { 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');
+
+ 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);
+ { 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');
+
+ 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/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;
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/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;
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 9286af6..ee27e6c 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, TestWarmup;
var
App: TTestRunner;