diff --git a/.gitignore b/.gitignore index 416d8ff..6fed494 100644 --- a/.gitignore +++ b/.gitignore @@ -33,6 +33,7 @@ # CMake / build build/ +build-live/ cmake-build-*/ CMakeFiles/ CMakeCache.txt @@ -57,6 +58,8 @@ _deps/ # Test artifacts downloads/ test/downloads/ +# written and removed again by the live suite's bulk round trip +zb-sdk-live.csv # Misc .DS_Store diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..4e90313 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,80 @@ +# AGENTS.md + +Guidance for AI coding agents working in this repository. + +## Project + +`zerobounce-c-api-wrapper` is the ZeroBounce C SDK. Callers allocate a `ZeroBounce` with `new_zero_bounce_instance`, call `zero_bounce_initialize`, and receive results through success and error callbacks. HTTP goes through libcurl in `src/ZeroBounce.c` and `src/Priority3.c`. There is no package registry: releases are GitHub tags. This is not the C++ repository `zerobounce-cpp-api-wrapper`. + +## Layout + +- `include/ZeroBounce/`: public headers. `ZeroBounce.h` is the client. +- `src/ZeroBounce.c`: validation, bulk file, credits, activity, scoring, and the libcurl write callback. +- `src/Priority3.c`: bulk email finder, domain search, list evaluator, filters, the S3 / SFTP bulk jobs, and the Inbox Placement Tester. The six file jobs share `ZBBulkFileJobOptions` and `p3_bulk_root_file_job`. +- `src/ZeroBounceWarmup.c` / `include/ZeroBounce/ZeroBounceWarmup.h`: 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 `ZeroBounceWarmup` and never touches the validation key. +- `src/ZBErrorResponse.c`: turns HTTP failures, HTML pages, and empty or non-JSON bodies into a short `ZBErrorResponse`, with the HTTP status on `status_code`. +- `test/ZeroBounceTest.c`: existing Unity tests. +- `test/OfflineApiTest.c`, `test/OfflineWarmupTest.c`: offline suites. `zero_bounce_set_http_perform_for_test` replaces libcurl and records every request. +- `live/live_api.c`: the opt-in live suite. Built only with `-DZEROBOUNCE_BUILD_LIVE_TESTS=ON`, so the default build and CI never compile it. +- `CMakeLists.txt`: the only version field, `project(ZeroBounce VERSION x.y.z)`. + +## Commands + +```bash +cmake -S . -B build -G "Unix Makefiles" +cmake --build build # this is the typecheck; there is no separate lint step +./build/bin/ZeroBounceTests +``` + +CI (`.github/workflows/sdk_ci.yml`) configures, builds, and runs `./build/bin/ZeroBounceTests`. That build is the typecheck. `build/` is generated; do not commit it. + +The live suite is opt-in and is never part of that build: + +```bash +cmake -S . -B build-live -DZEROBOUNCE_BUILD_LIVE_TESTS=ON +cmake --build build-live --target ZeroBounceLiveApi +export ZEROBOUNCE_API_KEY=... ZEROBOUNCE_WARMUP_API_KEY=... +ZEROBOUNCE_LIVE_SPEND=1 ZEROBOUNCE_LIVE_CHANGES=1 ./build-live/bin/ZeroBounceLiveApi +``` + +Keys come from the environment only. Never write one into a file, a commit, a log, a fixture or a message. + +## Conventions + +- **One `ZeroBounce` per API key.** `zero_bounce_get_instance()` is a process-wide singleton. Construct a separate object with `new_zero_bounce_instance` per key. The offline concurrency test does this. +- **Only endpoints documented at https://www.zerobounce.net/docs/.** Do not add a function for an endpoint the public docs do not describe. 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 on `ZeroBounce`.** `api_base_url`, `bulk_api_base_url` (ends in `/v2`), `bulk_api_root_url` (the S3/SFTP jobs, **no** `/v2`), and `list_evaluator_base_url` / `inbox_placement_base_url`. The last two are separate hosts rather than paths built from the bulk base, so `zero_bounce_initialize_with_base_url` alone leaves them on the live API — use the two setters in tests. +- **`zb_url_escape` is a query encoder.** It turns a space into `+`, which is correct in a query string but a literal plus in a path. Use `p3_escape_path_segment` 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`, `validate_sftp_file` included, although the docs call its `file_name` optional; `send_evaluated_file` takes a file id as a form field, not an upload; 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. `zb_success_means_failure` catches it in `zb_read_success_json`, and `ZBErrorResponse.status_code` carries the HTTP status. +- **`export_sftp_file` sends `export_path`**, not the `export_bucket` that `export_s3_file` sends. +- **Safety rails.** `zb_api_key_is_clean` rejects a key with surrounding whitespace or control characters, `zb_check_request_url` allows https only, `p3_escape_path_segment` rejects `.` and `..`, `set_write_callback` turns redirects off and restricts the protocol to https, and `zb_redact_api_key` keeps the key out of messages built from a URL. Use them rather than adding a second rail beside them. +- **`p3_append_form_field` does not call `zb_note_http_field`.** The offline tests assert on the recorded request body (`OfflineRequest.body`), not on `has_field`, for anything built with it. +- **Column indexes are 1-based** on bulk send-file name, gender, and IP columns. `0` means the field is left out. `send_file`'s email column is required and is sent as given, including `0`. `ValidateS3FileOptions.email_address_column` uses `-1` for omit, and `0` is sent. +- **Tri-state ints are `-1` omit, `0` false, `1` true.** `timeout`, `activity_data`, `verify_plus`, `has_header_row`, and `allow_phase_2` follow that. Explicit `0` is sent as `false`. +- **Booleans on multipart and form bodies are the strings `true` and `false`.** +- **`allow_phase_2` is validation `send_file` only**, and only when `allow_phase_2_is_set` is true. Scoring `send_file` ignores it. `activity_data` on get-file is validation only. +- **`validate_email_batch` uses `api.zerobounce.net`**, not `bulkapi.zerobounce.net`. An empty batch IP is JSON `null`. +- **Failures use the error callback**, not a process exit. The message must be short: no raw HTML, no megabyte body, and no `Error: Error:` prefix. An account-level `validate_email_batch` error (`email_address` `all`) is an error callback. A per-address error stays on the success result. +- **Tests replace HTTP** with `zero_bounce_set_http_perform_for_test`. Read `zero_bounce_test_method`, `zero_bounce_test_url`, `zero_bounce_test_body`, `zero_bounce_test_extra_header`, and `zero_bounce_test_field_*` from that callback. They are thread-local. Do not call the live API from anything under `test/`. +- **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. +- Match the surrounding style. Do not reformat unrelated code. + +## Changelog + +`CHANGELOG.md` follows [Keep a Changelog 1.1.0](https://keepachangelog.com/en/1.1.0/), and the project adheres to [Semantic Versioning 2.0.0](https://semver.org/spec/v2.0.0.html). + +- Add every user-visible change to `## [Unreleased]` in the same commit as the change. +- Group entries under these headings, in this order, omitting empty ones: `Added`, `Changed`, `Deprecated`, `Removed`, `Fixed`, `Security`. +- Write for SDK users: name the function, option, or behaviour, and say what changed. Leave out CI-only changes, internal refactors, and fixes to code that was never released. +- At 2.y.z, a breaking change bumps the major version, a new function or option bumps the minor version, and fixes alone bump the patch version. + +## Releasing + +Publishing and pushing tags need a maintainer's explicit approval. Do not bump the version, tag, or publish unless they asked. + +1. Move `[Unreleased]` entries under `## [x.y.z] - YYYY-MM-DD`, leave an empty `[Unreleased]` section, and update the compare links at the bottom of `CHANGELOG.md`. +2. Set the same version in `project(ZeroBounce VERSION …)` in `CMakeLists.txt`. That is the only version field. There is no lockfile and no runtime version constant. +3. `cmake --build build` and `./build/bin/ZeroBounceTests`. +3a. 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. +4. Tag `vx.y.z` on that commit and run the Publish workflow with that tag. It creates the GitHub release. There is no package registry. diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..fbaf297 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,130 @@ +# Changelog + +All notable changes to this project will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [Unreleased] + +### Added + +- `validate_email_with_options` and `validate_email_batch_with_options` take `ZBValidateOptions`: `timeout`, `activity_data`, `verify_plus`, and `use_post` (form-encoded `POST /validate`). +- `ZBValidateResponse` reads `active_in_days` and `active_first_seen`. +- `score_email` (`GET /v2/scoring`) and `score_email_post` (form-encoded `POST /v2/scoring`). +- Bulk email finder (`send_email_finder_file`, `email_finder_file_status`, `email_finder_get_file`, `email_finder_delete_file`). +- Bulk domain search (`send_domain_search_file`, `domain_search_file_status`, `domain_search_get_file`, `domain_search_delete_file`). +- List evaluator (`send_list_evaluator_file`, `get_list_evaluator_result`, `send_evaluated_file`). +- Filters (`add_filter`, `list_filters`, `delete_filter`). +- S3 bulk (`validate_s3_file`, `score_s3_file`, `export_s3_file`, `list_s3_files`, `email_finder_s3_file`, `domain_search_s3_file`). +- SFTP/FTP bulk (`validate_sftp_file`, `score_sftp_file`, `email_finder_sftp_file`, `domain_search_sftp_file`, `export_sftp_file`), sharing the new `ZBBulkFileJobOptions`. The server, path and protocol come from your ZeroBounce settings. `validate_sftp_file`'s `file_name` is optional; `export_sftp_file` takes `export_path` rather than the `export_bucket` `export_s3_file` sends. +- `ZBValidateOptions` accepts `domain_info`, `greylist_processing` and `callback_url`. `callback_url` needs `greylist_processing` set to 1 and is rejected without it, and when blank, before a request is sent. +- `ZBValidateResponse` reads `domain_website_exists`, `domain_registrant_company_name` and `message`. `message` is the API's `Message` key, returned instead of a result while a greylist retry is still running. +- Inbox Placement Tester on `https://bulkapi.zerobounce.net/api/ipt`: `get_inbox_placement_providers`, `get_inbox_placement_test_types`, `create_inbox_placement_test`, `get_inbox_placement_test`, `list_inbox_placement_tests`, `delete_inbox_placement_test`, `get_inbox_placement_webhook`, `set_inbox_placement_webhook`. It authenticates with an `Authorization: Bearer` header, bodies are camelCase JSON, and unset fields are left out. +- `zero_bounce_set_list_evaluator_base_url` and `zero_bounce_set_inbox_placement_base_url`. +- `zb_make_request_with_method_and_body` and `zb_make_request_with_extra_header`, for the Tester's PUT and DELETE and its Bearer header. +- `ZeroBounceWarmup`, 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 (`zero_bounce_warmup_initialize`, sent in an `API-Key` header) and all 26 documented endpoints. Every call hands the raw JSON body to its callback, or an empty string for the documented empty successes (HTTP 204 and an empty 200); `warmup_download_seeds` and `warmup_download_statistics` hand over CSV text and write it to a path when one is given. +- `ZBErrorResponse.status_code` carries the HTTP status the request came back with, so a caller can tell 429 from 500 without parsing the message. It is 0 when the error was raised before a response arrived. +- Safety rails: `zb_api_key_is_clean` rejects a key with surrounding whitespace or control characters (both initializers run it), `zb_check_request_url` refuses anything that is not `https://`, `p3_escape_path_segment` rejects a `.` or `..` id, redirects are never followed and only the https protocol is enabled, and `zb_redact_api_key` strips the key out of any message built from a URL. A regional base URL paired with the single global bulk host warns once. +- `get_file_streaming` and `scoring_get_file_streaming` write a result file straight to disk as it arrives instead of buffering it in memory first, for a download too large to hold. They are opt-in because a streamed body cannot be inspected as a whole: the first 4 KB are held back and checked, so a JSON or HTML error still reaches the error callback and no partial file is left behind. +- `live/live_api.c`, an opt-in live suite built only with `-DZEROBOUNCE_BUILD_LIVE_TESTS=ON`, so the default build and CI never compile 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: + - `validate_email_batch_with_options` sends `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`, `validate_sftp_file` included, although the docs call its `file_name` optional. Both are checked before the request is sent. + - **Breaking:** `send_evaluated_file`'s second argument is the id of a finished evaluation, not a path to upload. The live endpoint takes the file id as a form field. +- A `"success": false` field on an HTTP 200 now reaches the error callback instead of being returned as a result. `get_file` and `delete_file` report an unknown or deleted file id that way. +- The list evaluator uses its own base URL (`.../v2/listevaluator`) instead of building the path from the bulk base, and the file id is encoded as a single path segment. `send_evaluated_file` stays on the bulk base. + +- API requests time out after 120 seconds. +- Query values, including the API key, email, and file id, are percent-encoded. +- JSON API responses are capped at 16 MB and bulk file downloads at 256 MB. + +### Fixed + +- `get_api_usage` read `sub_status_anti_spam_system`, but the API sends that counter as `sub_status_antispam_system`, so it was always 0. The struct field keeps its name; only the key it reads is corrected. +- `get_api_usage` now reads `sub_status_leading_period_removed` and `sub_status_alias_address`, two counters the API returns that were silently discarded. + +- Form-encoded request bodies no longer start with stray bytes. The builder grew its buffer with `realloc(NULL, n)`, which hands back uninitialized memory, and then appended to it without terminating it first, so whatever was in that block went out in front of the first field. Depending on what the allocator handed back, `add_filter`, `send_evaluated_file` and the S3/SFTP jobs could be answered with a Cloudflare block instead of a result. + +- A 403 HTML "Access denied" page is returned as a short error. An HTML page with status 200 is an error as well, including `get_file`, `email_finder_get_file`, and `domain_search_get_file`, instead of a saved file. +- Empty and non-JSON bodies are a short error. A repeated `Error:` / `[Error]:` prefix is removed. +- `validate_email_batch` reports an account-level failure (HTTP 200, error for address `all`, such as an invalid API key or no credits) through the error callback. An error for one address stays on the successful result. +- Bulk email finder and domain search send `has_header_row` when it is set to false. +- `validate_s3_file` sends `email_address_column` when it is set to 0. `validate_s3_file`, `score_s3_file`, and `export_s3_file` reject an empty API key before a request is sent. +- `validate_email_batch` sends an empty IP address as JSON `null`. + +### Security + +- Query parameters are percent-encoded, so a value cannot rewrite the request URL. +- Error callbacks no longer include the raw HTML access-denied page or a multi-megabyte body. + +## [2.1.3] - 2026-06-18 + +### Fixed + +- `validate_email_batch` posts to `api.zerobounce.net/v2/validatebatch` instead of `bulkapi.zerobounce.net`. + +## [2.1.2] - 2026-06-10 + +### Added + +- `ZBValidateResponse.catchall_domain`, read from `catchall_domain`. + +## [2.1.0] - 2026-04-05 + +### Added + +- `get_file_with_options` and `scoring_get_file_with_options`, with `GetFileOptions.download_type` (`phase_1`, `phase_2`, `combined`) and `activity_data` (validation `get_file` only). +- `SendFileOptions.allow_phase_2` / `allow_phase_2_is_set`, sent as `allow_phase_2` on validation `send_file` only. +- `ZBFileStatusResponse.file_phase_2_status`. + +### Fixed + +- `get_file` calls the error callback when the body is a JSON error, including HTTP 200 with `"success": false`, instead of saving that body as the results file. + +## [2.0.10] - 2026-03-03 + +### Changed + +- License the project under MIT. + +## [2.0.0] - 2026-03-03 + +### Added + +- `find_email_by_domain_*`, `find_email_by_company_name_*`, `search_domain_by_domain`, and `search_domain_by_company_name`. +- `zero_bounce_initialize_with_base_url` to select the API base URL. +- `ZBValidateSubStatus` value `AcceptAll`. + +### Changed + +- The library builds as C17 and uses libcurl for HTTP. + +### Deprecated + +- `find_email`. Use the email-finder or domain-search functions instead. + +## [1.1.0] - 2023-09-07 + +### Added + +- Email finder response types and `find_email`. + +## [1.0.0] - 2023-08-22 + +### Added + +- Initial release: `get_credits`, `get_api_usage`, `validate_email`, `validate_email_batch`, bulk and AI-scoring file functions, and `get_activity_data`. + +[Unreleased]: https://github.com/zerobounce/zerobounce-c-api-wrapper/compare/v2.1.4...HEAD +[2.1.3]: https://github.com/zerobounce/zerobounce-c-api-wrapper/compare/v2.1.2...v2.1.3 +[2.1.2]: https://github.com/zerobounce/zerobounce-c-api-wrapper/compare/v2.1.0...v2.1.2 +[2.1.0]: https://github.com/zerobounce/zerobounce-c-api-wrapper/compare/v2.0.10...v2.1.0 +[2.0.10]: https://github.com/zerobounce/zerobounce-c-api-wrapper/compare/v2.0.0...v2.0.10 +[2.0.0]: https://github.com/zerobounce/zerobounce-c-api-wrapper/compare/v1.1.0...v2.0.0 +[1.1.0]: https://github.com/zerobounce/zerobounce-c-api-wrapper/compare/v1.0.0...v1.1.0 +[1.0.0]: https://github.com/zerobounce/zerobounce-c-api-wrapper/releases/tag/v1.0.0 diff --git a/CMakeLists.txt b/CMakeLists.txt index 3def030..6cdb5be 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -57,6 +57,16 @@ target_include_directories(${PROJECT_NAME} PUBLIC include ${json-c_SOURCE_DIR}) target_link_libraries(${PROJECT_NAME} PUBLIC json-c libcurl) enable_testing() -add_executable(ZeroBounceTests test/ZeroBounceTest.c) +add_executable(ZeroBounceTests test/ZeroBounceTest.c test/OfflineApiTest.c test/OfflineWarmupTest.c) target_link_libraries(ZeroBounceTests PRIVATE ${PROJECT_NAME} unity) target_include_directories(ZeroBounceTests PRIVATE ${fff_SOURCE_DIR}) + +# The live suite calls the real API and spends credits, so it is opt-in and +# never built by a plain `cmake --build build`: +# cmake -S . -B build-live -DZEROBOUNCE_BUILD_LIVE_TESTS=ON +# cmake --build build-live --target ZeroBounceLiveApi +option(ZEROBOUNCE_BUILD_LIVE_TESTS "Build the opt-in live API suite" OFF) +if(ZEROBOUNCE_BUILD_LIVE_TESTS) + add_executable(ZeroBounceLiveApi live/live_api.c) + target_link_libraries(ZeroBounceLiveApi PRIVATE ${PROJECT_NAME}) +endif() diff --git a/README.md b/README.md index a726d28..8728e0a 100644 --- a/README.md +++ b/README.md @@ -200,6 +200,18 @@ get_file(zb, file_id, local_download_path, on_success_get_file, on_error); Bulk validation uses the [v2 bulk API](https://www.zerobounce.net/docs/email-validation-api-quickstart/v2-send-file). Optional [v2 get file](https://www.zerobounce.net/docs/email-validation-api-quickstart/v2-get-file) query parameters are passed with `get_file_with_options` / `scoring_get_file_with_options` and a `GetFileOptions` value from `new_get_file_options()`. Set `download_type` to `ZB_DOWNLOAD_TYPE_PHASE_1`, `ZB_DOWNLOAD_TYPE_PHASE_2`, or `ZB_DOWNLOAD_TYPE_COMBINED`; set `activity_data` to `0` or `1` when you want `activity_data=false` or `true` (validation `get_file_with_options` only; scoring omits this parameter). On `SendFileOptions`, set `allow_phase_2_is_set` to `true` and `allow_phase_2` to the desired value to send `allow_phase_2` for validation `send_file` only (not `scoring_send_file`). +A result file is downloaded into memory first, which is fine for an ordinary +list. For one too large to buffer, `get_file_streaming` (and its AI scoring +counterpart `scoring_get_file_streaming`) writes the body straight to disk as +it arrives. It is opt-in because a streamed body cannot be inspected as a +whole, and getfile answers an unknown or deleted file id with a JSON error +rather than a file: the first 4 KB are held back and checked, so an error still +reaches the error callback and no partial file is left behind. + +```c +get_file_streaming(zb, file_id, local_download_path, NULL, on_success_get_file, on_error); +``` + If the HTTP status is an error, or the body is a JSON error (including some HTTP 200 responses with `"success": false`), the error callback runs with a parsed message—not the get-file success callback. To inspect a raw body string yourself, use `zb_get_file_json_indicates_error(body)`; use `zb_format_get_file_error_message(body)` for a human-readable line (caller must `free()` the returned pointer). `ZBFileStatusResponse` includes `file_phase_2_status` when the API returns it. @@ -349,6 +361,165 @@ search_domain_by_company_name( ``` +## Validate options + +`validate_email_with_options` takes a `ZBValidateOptions`. A tri-state field is +`-1` to omit, `0` false and `1` true; booleans go on the wire as the strings +`"true"`/`"false"`. + +```c +ZBValidateOptions options = new_validate_options(); +options.timeout = 30; /* 3-60 seconds */ +options.activity_data = 1; /* fills active_in_days / active_first_seen */ +options.domain_info = 1; /* fills domain_website_exists / domain_registrant_company_name */ +options.greylist_processing = 1; +options.callback_url = "https://example.com/greylist"; +options.use_post = 1; /* form-encoded POST /v2/validate instead of GET */ + +validate_email_with_options(zb, "", "", &options, on_validate, on_error); +``` + +`callback_url` is where the greylist retry posts its result, so it is rejected +without `greylist_processing = 1` — before any request is sent. While that retry +is still running the API answers with a `Message` instead of a result, which +lands on `response.message`. + +## S3 and SFTP/FTP bulk + +Queue a job on a file that already sits in your Amazon S3 bucket, or on your +SFTP/FTP server. See the [S3](https://www.zerobounce.net/docs/api-integrations/amazon-s3-bulk-api) +and [SFTP](https://www.zerobounce.net/docs/api-integrations/sftp_bulk_api) docs. +These live on the bulkapi host *without* the `/v2` path. + +Every one of these jobs needs both `file_name` and `return_url`. The docs call +`validate_sftp_file`'s `file_name` optional, but the live API answers +`Empty parameter: file_name` without it, so the SDK checks both before sending. + +```c +ZBBulkFileJobOptions options = new_bulk_file_job_options(); + +/* Amazon S3. file_name is the full path, as returned by list_s3_files. */ +options.file_name = "lists/leads.csv"; +options.return_url = "https://example.com/zerobounce-done"; +email_finder_s3_file(zb, &options, on_send, on_error); + +options.remove_duplicates = 1; +domain_search_s3_file(zb, &options, on_send, on_error); + +/* SFTP/FTP. The server, path and protocol come from your ZeroBounce settings. */ +options = new_bulk_file_job_options(); +options.file_name = "my_file.txt"; +options.return_url = "https://example.com/zerobounce-done"; +validate_sftp_file(zb, &options, on_send, on_error); +score_sftp_file(zb, &options, on_send, on_error); +email_finder_sftp_file(zb, &options, on_send, on_error); +domain_search_sftp_file(zb, &options, on_send, on_error); + +/* Note the field: this one takes export_path, not export_bucket. + NULL uses the export path from your settings. */ +export_sftp_file(zb, "", "/exports", on_send, on_error); +``` + +## Inbox Placement Tester + +Runs on its own host and sends camelCase JSON bodies. It authenticates with an +`Authorization: Bearer` header rather than the `api_key` parameter the rest of +the API takes, and it spends the account's inbox-test credits. + +```c +get_inbox_placement_providers(zb, on_raw, on_error); +get_inbox_placement_test_types(zb, on_raw, on_error); + +ZBCreateInboxPlacementTestOptions create = new_create_inbox_placement_test_options(); +create.test_type = 1; +create.sender_email = "news@example.com"; +create.webhook_url = "https://example.com/ipt-webhook"; +create_inbox_placement_test(zb, "Launch", &create, on_raw, on_error); + +/* Send your campaign to the seeds you got back, then read the results. */ +get_inbox_placement_test(zb, "", on_raw, on_error); + +ZBListInboxPlacementTestsOptions list = new_list_inbox_placement_tests_options(); +list.status = "finished"; +list.page = 1; +list_inbox_placement_tests(zb, &list, on_raw, on_error); + +set_inbox_placement_webhook(zb, "https://example.com/ipt-webhook", on_raw, on_error); +get_inbox_placement_webhook(zb, on_raw, on_error); +delete_inbox_placement_test(zb, "", on_raw, on_error); +``` + +## List evaluator + +Upload a list for evaluation, read the result, and hand a finished evaluation +back for bulk validation. The evaluator has its own base URL, and the file id +is one path segment, so a slash in it stays encoded. + +```c +send_list_evaluator_file(zb, "emails.csv", 1, on_send, on_error); +get_list_evaluator_result(zb, "", on_raw, on_error); + +/* The id of a finished evaluation, not a path: the live endpoint takes it as + a form field. */ +send_evaluated_file(zb, "", on_send, on_error); +``` + +## Warmup API + +The [Warmup API](https://www.zerobounce.net/docs/warmup-api) is a separate +product: its own host, its own team-scoped key, and an `API-Key` header instead +of an `api_key` parameter. Use `ZeroBounceWarmup` with a Warmup API key; +`ZeroBounce` stays for the validation key. + +Every call hands the raw JSON body to its callback, or an empty string for the +documented empty successes (HTTP 204 and an empty 200). The two download +endpoints hand over CSV text and write it to a path when one is given. + +```c +#include "ZeroBounce/ZeroBounceWarmup.h" + +ZeroBounceWarmup *zbw = new_zero_bounce_warmup_instance(); +zero_bounce_warmup_initialize(zbw, ""); + +warmup_list_sending_accounts(zbw, on_raw, on_error); +warmup_get_engagement_rules(zbw, on_raw, on_error); +warmup_get_progress_today(zbw, on_raw, on_error); + +/* Add a from-address you run yourself. A standard rule is 0 to 4. */ +ZBWarmupAddSendingAccountOptions add = new_warmup_add_sending_account_options(); +add.engagement_rule_id = 0; +add.ignore_conflict = 1; +warmup_add_sending_account(zbw, "sender@example.com", ZB_WARMUP_SELF_MANAGED, &add, + on_raw, on_error); + +/* A custom rule: reply_pct 5-45, important_pct 5-40, click_pct 0-70 and at + most important_pct x 1.75. All of it is checked before anything is sent. */ +warmup_create_custom_engagement(zbw, "sender@example.com", 10, 20, 30, on_raw, on_error); + +/* CSV, and saved to the path as well. */ +warmup_download_seeds(zbw, "seeds.csv", on_raw, on_error); + +/* Pass an email address, not a bare domain: that is what the live API wants. */ +warmup_check_domain_dmarc(zbw, "sender@example.com", NULL, on_raw, on_error); + +zero_bounce_warmup_free(zbw); +``` + +`warmup_test_smtp_configuration` stores nothing and sends its test message to a +ZeroBounce test recipient, not to `from_address`. The live API answers a failed +SMTP login with HTTP 500, which arrives as an error with +`ZBErrorResponse.status_code` 500; the password never appears in that message. + +## Per-API base URLs + +The list evaluator and the Inbox Placement Tester are separate hosts, so +`zero_bounce_initialize_with_base_url` alone leaves them pointing at the live API. + +```c +zero_bounce_set_list_evaluator_base_url(zb, "https://bulkapi.zerobounce.net/v2/listevaluator"); +zero_bounce_set_inbox_placement_base_url(zb, "https://bulkapi.zerobounce.net/api/ipt"); +``` + ## DEVELOPMENT It is recommended that the development should be done on Linux. diff --git a/documentation.md b/documentation.md index ce33f61..ff305ff 100644 --- a/documentation.md +++ b/documentation.md @@ -189,6 +189,18 @@ get_file(zb, file_id, local_download_path, on_success_get_file, on_error); Bulk validation uses the [v2 bulk API](https://www.zerobounce.net/docs/email-validation-api-quickstart/v2-send-file). Optional [v2 get file](https://www.zerobounce.net/docs/email-validation-api-quickstart/v2-get-file) query parameters are passed with `get_file_with_options` / `scoring_get_file_with_options` and a `GetFileOptions` value from `new_get_file_options()`. Set `download_type` to `ZB_DOWNLOAD_TYPE_PHASE_1`, `ZB_DOWNLOAD_TYPE_PHASE_2`, or `ZB_DOWNLOAD_TYPE_COMBINED`; set `activity_data` to `0` or `1` when you want `activity_data=false` or `true` (validation `get_file_with_options` only; scoring omits this parameter). On `SendFileOptions`, set `allow_phase_2_is_set` to `true` and `allow_phase_2` to the desired value to send `allow_phase_2` for validation `send_file` only (not `scoring_send_file`). +A result file is downloaded into memory first, which is fine for an ordinary +list. For one too large to buffer, `get_file_streaming` (and its AI scoring +counterpart `scoring_get_file_streaming`) writes the body straight to disk as +it arrives. It is opt-in because a streamed body cannot be inspected as a +whole, and getfile answers an unknown or deleted file id with a JSON error +rather than a file: the first 4 KB are held back and checked, so an error still +reaches the error callback and no partial file is left behind. + +```c +get_file_streaming(zb, file_id, local_download_path, NULL, on_success_get_file, on_error); +``` + If the HTTP status is an error, or the body is a JSON error (including some HTTP 200 responses with `"success": false`), the error callback runs with a parsed message—not the get-file success callback. To inspect a raw body string yourself, use `zb_get_file_json_indicates_error(body)`; use `zb_format_get_file_error_message(body)` for a human-readable line (caller must `free()` the returned pointer). `ZBFileStatusResponse` includes `file_phase_2_status` when the API returns it. @@ -337,7 +349,166 @@ search_domain_by_company_name( ); ``` -#### DEVELOPMENT +#### Validate options + +`validate_email_with_options` takes a `ZBValidateOptions`. A tri-state field is +`-1` to omit, `0` false and `1` true; booleans go on the wire as the strings +`"true"`/`"false"`. + +```c +ZBValidateOptions options = new_validate_options(); +options.timeout = 30; /* 3-60 seconds */ +options.activity_data = 1; /* fills active_in_days / active_first_seen */ +options.domain_info = 1; /* fills domain_website_exists / domain_registrant_company_name */ +options.greylist_processing = 1; +options.callback_url = "https://example.com/greylist"; +options.use_post = 1; /* form-encoded POST /v2/validate instead of GET */ + +validate_email_with_options(zb, "", "", &options, on_validate, on_error); +``` + +`callback_url` is where the greylist retry posts its result, so it is rejected +without `greylist_processing = 1` — before any request is sent. While that retry +is still running the API answers with a `Message` instead of a result, which +lands on `response.message`. + +## S3 and SFTP/FTP bulk + +Queue a job on a file that already sits in your Amazon S3 bucket, or on your +SFTP/FTP server. See the [S3](https://www.zerobounce.net/docs/api-integrations/amazon-s3-bulk-api) +and [SFTP](https://www.zerobounce.net/docs/api-integrations/sftp_bulk_api) docs. +These live on the bulkapi host *without* the `/v2` path. + +Every one of these jobs needs both `file_name` and `return_url`. The docs call +`validate_sftp_file`'s `file_name` optional, but the live API answers +`Empty parameter: file_name` without it, so the SDK checks both before sending. + +```c +ZBBulkFileJobOptions options = new_bulk_file_job_options(); + +/* Amazon S3. file_name is the full path, as returned by list_s3_files. */ +options.file_name = "lists/leads.csv"; +options.return_url = "https://example.com/zerobounce-done"; +email_finder_s3_file(zb, &options, on_send, on_error); + +options.remove_duplicates = 1; +domain_search_s3_file(zb, &options, on_send, on_error); + +/* SFTP/FTP. The server, path and protocol come from your ZeroBounce settings. */ +options = new_bulk_file_job_options(); +options.file_name = "my_file.txt"; +options.return_url = "https://example.com/zerobounce-done"; +validate_sftp_file(zb, &options, on_send, on_error); +score_sftp_file(zb, &options, on_send, on_error); +email_finder_sftp_file(zb, &options, on_send, on_error); +domain_search_sftp_file(zb, &options, on_send, on_error); + +/* Note the field: this one takes export_path, not export_bucket. + NULL uses the export path from your settings. */ +export_sftp_file(zb, "", "/exports", on_send, on_error); +``` + +## Inbox Placement Tester + +Runs on its own host and sends camelCase JSON bodies. It authenticates with an +`Authorization: Bearer` header rather than the `api_key` parameter the rest of +the API takes, and it spends the account's inbox-test credits. + +```c +get_inbox_placement_providers(zb, on_raw, on_error); +get_inbox_placement_test_types(zb, on_raw, on_error); + +ZBCreateInboxPlacementTestOptions create = new_create_inbox_placement_test_options(); +create.test_type = 1; +create.sender_email = "news@example.com"; +create.webhook_url = "https://example.com/ipt-webhook"; +create_inbox_placement_test(zb, "Launch", &create, on_raw, on_error); + +/* Send your campaign to the seeds you got back, then read the results. */ +get_inbox_placement_test(zb, "", on_raw, on_error); + +ZBListInboxPlacementTestsOptions list = new_list_inbox_placement_tests_options(); +list.status = "finished"; +list.page = 1; +list_inbox_placement_tests(zb, &list, on_raw, on_error); + +set_inbox_placement_webhook(zb, "https://example.com/ipt-webhook", on_raw, on_error); +get_inbox_placement_webhook(zb, on_raw, on_error); +delete_inbox_placement_test(zb, "", on_raw, on_error); +``` + +## List evaluator + +Upload a list for evaluation, read the result, and hand a finished evaluation +back for bulk validation. The evaluator has its own base URL, and the file id +is one path segment, so a slash in it stays encoded. + +```c +send_list_evaluator_file(zb, "emails.csv", 1, on_send, on_error); +get_list_evaluator_result(zb, "", on_raw, on_error); + +/* The id of a finished evaluation, not a path: the live endpoint takes it as + a form field. */ +send_evaluated_file(zb, "", on_send, on_error); +``` + +## Warmup API + +The [Warmup API](https://www.zerobounce.net/docs/warmup-api) is a separate +product: its own host, its own team-scoped key, and an `API-Key` header instead +of an `api_key` parameter. Use `ZeroBounceWarmup` with a Warmup API key; +`ZeroBounce` stays for the validation key. + +Every call hands the raw JSON body to its callback, or an empty string for the +documented empty successes (HTTP 204 and an empty 200). The two download +endpoints hand over CSV text and write it to a path when one is given. + +```c +#include "ZeroBounce/ZeroBounceWarmup.h" + +ZeroBounceWarmup *zbw = new_zero_bounce_warmup_instance(); +zero_bounce_warmup_initialize(zbw, ""); + +warmup_list_sending_accounts(zbw, on_raw, on_error); +warmup_get_engagement_rules(zbw, on_raw, on_error); +warmup_get_progress_today(zbw, on_raw, on_error); + +/* Add a from-address you run yourself. A standard rule is 0 to 4. */ +ZBWarmupAddSendingAccountOptions add = new_warmup_add_sending_account_options(); +add.engagement_rule_id = 0; +add.ignore_conflict = 1; +warmup_add_sending_account(zbw, "sender@example.com", ZB_WARMUP_SELF_MANAGED, &add, + on_raw, on_error); + +/* A custom rule: reply_pct 5-45, important_pct 5-40, click_pct 0-70 and at + most important_pct x 1.75. All of it is checked before anything is sent. */ +warmup_create_custom_engagement(zbw, "sender@example.com", 10, 20, 30, on_raw, on_error); + +/* CSV, and saved to the path as well. */ +warmup_download_seeds(zbw, "seeds.csv", on_raw, on_error); + +/* Pass an email address, not a bare domain: that is what the live API wants. */ +warmup_check_domain_dmarc(zbw, "sender@example.com", NULL, on_raw, on_error); + +zero_bounce_warmup_free(zbw); +``` + +`warmup_test_smtp_configuration` stores nothing and sends its test message to a +ZeroBounce test recipient, not to `from_address`. The live API answers a failed +SMTP login with HTTP 500, which arrives as an error with +`ZBErrorResponse.status_code` 500; the password never appears in that message. + +## Per-API base URLs + +The list evaluator and the Inbox Placement Tester are separate hosts, so +`zero_bounce_initialize_with_base_url` alone leaves them pointing at the live API. + +```c +zero_bounce_set_list_evaluator_base_url(zb, "https://bulkapi.zerobounce.net/v2/listevaluator"); +zero_bounce_set_inbox_placement_base_url(zb, "https://bulkapi.zerobounce.net/api/ipt"); +``` + +## DEVELOPMENT It is recommended that the development should be done on Linux. diff --git a/include/ZeroBounce/ZBErrorResponse.h b/include/ZeroBounce/ZBErrorResponse.h index e316b3b..ac33d0c 100644 --- a/include/ZeroBounce/ZBErrorResponse.h +++ b/include/ZeroBounce/ZBErrorResponse.h @@ -21,6 +21,12 @@ typedef struct { bool success; StringVector errors; + /** + * The HTTP status the request came back with, or 0 when the error was + * raised before a response arrived (a rejected argument, a transport + * failure). Lets a caller tell 429 from 500 without parsing the message. + */ + long status_code; } ZBErrorResponse; /** @@ -46,6 +52,24 @@ char* zb_error_response_to_string(ZBErrorResponse* response); */ ZBErrorResponse parse_error(const char* error); +/** + * @brief As parse_error, but records the HTTP status the response carried. + * + * @param error error string + * @param status_code HTTP status, or 0 when there was no response + * @return ZBErrorResponse new instance + */ +ZBErrorResponse parse_error_with_status(const char* error, long status_code); + +/** + * @brief 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". + * + * @param body response body + * @return true when the body is a failure + */ +bool zb_success_means_failure(const char* body); + /** * @brief Function used to compare ZBErrorResponse instances. * diff --git a/include/ZeroBounce/ZBGetApiUsageResponse.h b/include/ZeroBounce/ZBGetApiUsageResponse.h index 18cde57..b4a250a 100644 --- a/include/ZeroBounce/ZBGetApiUsageResponse.h +++ b/include/ZeroBounce/ZBGetApiUsageResponse.h @@ -43,6 +43,8 @@ typedef struct { int sub_status_accept_all; int sub_status_gold; int sub_status_role_based_accept_all; + int sub_status_leading_period_removed; + int sub_status_alias_address; char* start_date; char* end_date; char* error; diff --git a/include/ZeroBounce/ZBPriority3Options.h b/include/ZeroBounce/ZBPriority3Options.h new file mode 100644 index 0000000..f43156b --- /dev/null +++ b/include/ZeroBounce/ZBPriority3Options.h @@ -0,0 +1,50 @@ +#ifndef ZB_PRIORITY3_OPTIONS_H +#define ZB_PRIORITY3_OPTIONS_H + +#include + +/** + * Optional columns for bulk email finder sendfile. + * Column indexes start at 1. has_header_row: -1 omit, 0 sends "false", 1 sends "true". + */ +typedef struct { + int first_name_column; + int last_name_column; + int middle_name_column; + int full_name_column; + int has_header_row; +} EmailFinderSendFileOptions; + +typedef struct { + int has_header_row; +} DomainSearchSendFileOptions; + +typedef struct { + const char *file_name; + int has_header_row; + int remove_duplicates; + int remove_duplicate; + int email_address_column; + const char *return_url; + int first_name_column; + int last_name_column; + int gender_column; + int ip_address_column; + bool allow_phase_2_is_set; + bool allow_phase_2; +} ValidateS3FileOptions; + +typedef struct { + const char *file_name; + int has_header_row; + int remove_duplicates; + int remove_duplicate; + int email_address_column; +} ScoreS3FileOptions; + +EmailFinderSendFileOptions new_email_finder_send_file_options(void); +DomainSearchSendFileOptions new_domain_search_send_file_options(void); +ValidateS3FileOptions new_validate_s3_file_options(void); +ScoreS3FileOptions new_score_s3_file_options(void); + +#endif diff --git a/include/ZeroBounce/ZBScoreEmailResponse.h b/include/ZeroBounce/ZBScoreEmailResponse.h new file mode 100644 index 0000000..186554d --- /dev/null +++ b/include/ZeroBounce/ZBScoreEmailResponse.h @@ -0,0 +1,28 @@ +#ifndef ZBSCOREEMAILRESPONSE_H +#define ZBSCOREEMAILRESPONSE_H + +#include + +#include + +/** + * @brief Response for GET/POST /scoring (real-time AI scoring). + */ +typedef struct { + char* email; + int score; + bool score_is_set; +} ZBScoreEmailResponse; + +ZBScoreEmailResponse new_zb_score_email_response(); + +char* zb_score_email_response_to_string(ZBScoreEmailResponse* response); + +ZBScoreEmailResponse zb_score_email_response_from_json(const json_object* j); + +int compare_zb_score_email_response( + const ZBScoreEmailResponse* response1, + const ZBScoreEmailResponse* response2 +); + +#endif diff --git a/include/ZeroBounce/ZBValidateResponse.h b/include/ZeroBounce/ZBValidateResponse.h index e667536..74f0b0c 100644 --- a/include/ZeroBounce/ZBValidateResponse.h +++ b/include/ZeroBounce/ZBValidateResponse.h @@ -55,7 +55,8 @@ typedef enum { RoleBasedCatchAll, Disposable, Toxic, - AcceptAll + AcceptAll, + AiAgentMailbox } ZBValidateSubStatus; /** @@ -79,6 +80,8 @@ typedef struct { char* domain; char* did_you_mean; char* domain_age_days; + char* active_in_days; + char* active_first_seen; bool free_email; bool mx_found; char* mx_record; diff --git a/include/ZeroBounce/ZeroBounce.h b/include/ZeroBounce/ZeroBounce.h index edef966..4783017 100644 --- a/include/ZeroBounce/ZeroBounce.h +++ b/include/ZeroBounce/ZeroBounce.h @@ -19,9 +19,11 @@ #include "ZeroBounce/ZBGetFileResponse.h" #include "ZeroBounce/ZBDeleteFileResponse.h" #include "ZeroBounce/ZBActivityDataResponse.h" +#include "ZeroBounce/ZBScoreEmailResponse.h" #include "ZeroBounce/ZBFindEmailResponse.h" #include "ZeroBounce/ZBDomainSearchResponse.h" #include "ZeroBounce/ZBApiURL.h" +#include "ZeroBounce/ZBPriority3Options.h" typedef void (*OnErrorCallback)(ZBErrorResponse error_response); typedef void (*OnSuccessCallbackCredits)(ZBCreditsResponse response); @@ -33,8 +35,11 @@ typedef void (*OnSuccessCallbackFileStatus)(ZBFileStatusResponse response); typedef void (*OnSuccessCallbackGetFile)(ZBGetFileResponse response); typedef void (*OnSuccessCallbackDeleteFile)(ZBDeleteFileResponse response); typedef void (*OnSuccessCallbackActivityData)(ZBActivityDataResponse response); +typedef void (*OnSuccessCallbackScoreEmail)(ZBScoreEmailResponse response); typedef void (*OnSuccessCallbackFindEmail)(ZBFindEmailResponse response); typedef void (*OnSuccessCallbackDomainSearch)(ZBDomainSearchResponse response); +/** Raw JSON body; valid only for the duration of the callback. */ +typedef void (*OnSuccessCallbackRawJson)(const char* json_response); /** @@ -80,6 +85,34 @@ SendFileOptions new_send_file_options(); */ GetFileOptions new_get_file_options(void); +/** + * Optional query/body parameters for GET /validate and POST /validatebatch. + * timeout: -1 to omit. activity_data / verify_plus: -1 omit, 0 false, 1 true. + */ +typedef struct { + int timeout; + int activity_data; + int verify_plus; + /** -1 omit, 0 false, 1 true. Fills domain_website_exists and + * domain_registrant_company_name on the response. */ + int domain_info; + /** -1 omit, 0 false, 1 true. Retries a greylisted address in the background. */ + int greylist_processing; + /** + * Where the greylist retry posts its result. NULL to omit. It needs + * greylist_processing set to 1 and is rejected without it, and when blank, + * before any request is sent. + */ + const char *callback_url; + /** -1 omit, 1 use form-encoded POST for /validate */ + int use_post; +} ZBValidateOptions; + +/** + * @brief Default ZBValidateOptions (all parameters omitted). + */ +ZBValidateOptions new_validate_options(void); + /** * @brief Whether a getfile response body looks like a JSON error payload (including HTTP 200). */ @@ -91,12 +124,19 @@ bool zb_get_file_json_indicates_error(const char *body); char *zb_format_get_file_error_message(const char *body); +/** Cap JSON API responses so error pages cannot exhaust memory (F-01). */ +#define ZB_MAX_JSON_RESPONSE_BYTES (16u * 1024u * 1024u) +/** Cap bulk getfile downloads (F-01). */ +#define ZB_MAX_GETFILE_BYTES (256u * 1024u * 1024u) + /** * Struct used to store the response data of a request. + * max_bytes: 0 uses ZB_MAX_JSON_RESPONSE_BYTES; set ZB_MAX_GETFILE_BYTES for getfile. */ typedef struct { char *response; size_t size; + size_t max_bytes; } memory; @@ -131,6 +171,14 @@ char* get_content_type_value(CURL* curl); typedef struct { char* api_key; char* api_base_url; + const char* bulk_api_root_url; + /** + * The list evaluator and the Inbox Placement Tester have their own base + * URLs, not paths built from the bulk one. Overriding api_base_url alone + * leaves them pointing at the live API. + */ + const char* list_evaluator_base_url; + const char* inbox_placement_base_url; const char* bulk_api_base_url; const char* bulk_api_scoring_base_url; } ZeroBounce; @@ -175,7 +223,6 @@ void zero_bounce_initialize_with_base_url(ZeroBounce* zb, const char* api_key, Z * @param error_callback error callback * @return **true** if the [api_key] is null or **false** otherwise */ -static bool zero_bounce_invalid_api_key(ZeroBounce *zb, OnErrorCallback error_callback); /** * @brief Internal function used to make libcurl requests. @@ -362,6 +409,18 @@ void validate_email( OnErrorCallback error_callback ); +/** + * @brief Validates the given email with optional v2 query parameters. + */ +void validate_email_with_options( + ZeroBounce *zb, + char* email, + char* ip_address, + const ZBValidateOptions *options, + OnSuccessCallbackValidate success_callback, + OnErrorCallback error_callback +); + /** * @brief Validates a batch of emails. * @@ -377,6 +436,17 @@ void validate_email_batch( OnErrorCallback error_callback ); +/** + * @brief Validates a batch of emails with optional v2 body parameters. + */ +void validate_email_batch_with_options( + ZeroBounce *zb, + EmailToValidateVector email_batch, + const ZBValidateOptions *options, + OnSuccessCallbackValidateBatch success_callback, + OnErrorCallback error_callback +); + /** * @brief The send file API allows user to send a file for bulk email validation. * @@ -428,6 +498,41 @@ void get_file( OnErrorCallback error_callback ); +/** + * @brief As get_file, but written straight to disk as it arrives instead of + * being held in memory first — use it for a download too large to buffer. + * + * It is opt-in because a streamed body cannot be inspected as a whole, and + * getfile answers an unknown or deleted file id with a JSON error rather than + * a file. The first 4 KB are held back and checked, so an error still reaches + * the error callback and no partial file is left behind. + * + * @param zb ZeroBounce pointer + * @param file_id the returned file ID when calling send file API + * @param local_download_path the path to which to download the file + * @param options optional v2 query parameters, or NULL + * @param success_callback success callback + * @param error_callback error callback + */ +void get_file_streaming( + ZeroBounce *zb, + char* file_id, + char* local_download_path, + const GetFileOptions *options, + OnSuccessCallbackGetFile success_callback, + OnErrorCallback error_callback +); + +/** @brief The AI scoring counterpart of get_file_streaming. */ +void scoring_get_file_streaming( + ZeroBounce *zb, + char* file_id, + char* local_download_path, + const GetFileOptions *options, + OnSuccessCallbackGetFile success_callback, + OnErrorCallback error_callback +); + /** * @brief Bulk get file with optional v2 query parameters (download_type, activity_data for validation only). */ @@ -550,6 +655,26 @@ void get_activity_data( OnErrorCallback error_callback ); +/** + * @brief Real-time AI scoring for a single email (GET /scoring). + */ +void score_email( + ZeroBounce *zb, + char* email, + OnSuccessCallbackScoreEmail success_callback, + OnErrorCallback error_callback +); + +/** + * @brief Real-time AI scoring for a single email (POST /scoring, form-encoded). + */ +void score_email_post( + ZeroBounce *zb, + char* email, + OnSuccessCallbackScoreEmail success_callback, + OnErrorCallback error_callback +); + /** * @brief Email Address Search - Identifies and validates a person’s primary email address * @@ -697,6 +822,316 @@ void search_domain_by_company_name( OnErrorCallback error_callback ); +/* Priority 3 — bulk email finder, domain search, list evaluator, filters, S3 */ + +void send_email_finder_file( + ZeroBounce *zb, + char *file_path, + int domain_column, + const EmailFinderSendFileOptions *options, + OnSuccessCallbackSendFile success_callback, + OnErrorCallback error_callback +); + +void email_finder_file_status( + ZeroBounce *zb, + char *file_id, + OnSuccessCallbackFileStatus success_callback, + OnErrorCallback error_callback +); + +void email_finder_get_file( + ZeroBounce *zb, + char *file_id, + char *local_download_path, + OnSuccessCallbackGetFile success_callback, + OnErrorCallback error_callback +); + +void email_finder_delete_file( + ZeroBounce *zb, + char *file_id, + OnSuccessCallbackDeleteFile success_callback, + OnErrorCallback error_callback +); + +void send_domain_search_file( + ZeroBounce *zb, + char *file_path, + int domain_column, + const DomainSearchSendFileOptions *options, + OnSuccessCallbackSendFile success_callback, + OnErrorCallback error_callback +); + +void domain_search_file_status( + ZeroBounce *zb, + char *file_id, + OnSuccessCallbackFileStatus success_callback, + OnErrorCallback error_callback +); + +void domain_search_get_file( + ZeroBounce *zb, + char *file_id, + char *local_download_path, + OnSuccessCallbackGetFile success_callback, + OnErrorCallback error_callback +); + +void domain_search_delete_file( + ZeroBounce *zb, + char *file_id, + OnSuccessCallbackDeleteFile success_callback, + OnErrorCallback error_callback +); + +void send_list_evaluator_file( + ZeroBounce *zb, + char *file_path, + int email_address_column, + OnSuccessCallbackSendFile success_callback, + OnErrorCallback error_callback +); + +void get_list_evaluator_result( + ZeroBounce *zb, + char *file_id, + OnSuccessCallbackRawJson success_callback, + OnErrorCallback error_callback +); + +void send_evaluated_file( + ZeroBounce *zb, + char *file_path, + OnSuccessCallbackSendFile success_callback, + OnErrorCallback error_callback +); + +void add_filter( + ZeroBounce *zb, + char *rule, + char *target, + char *value, + OnSuccessCallbackRawJson success_callback, + OnErrorCallback error_callback +); + +void list_filters( + ZeroBounce *zb, + bool use_post, + OnSuccessCallbackRawJson success_callback, + OnErrorCallback error_callback +); + +void delete_filter( + ZeroBounce *zb, + char *rule, + char *target, + char *value, + OnSuccessCallbackRawJson success_callback, + OnErrorCallback error_callback +); + +void validate_s3_file( + ZeroBounce *zb, + const ValidateS3FileOptions *options, + OnSuccessCallbackSendFile success_callback, + OnErrorCallback error_callback +); + +void score_s3_file( + ZeroBounce *zb, + const ScoreS3FileOptions *options, + OnSuccessCallbackSendFile success_callback, + OnErrorCallback error_callback +); + +void export_s3_file( + ZeroBounce *zb, + char *file_id, + char *export_bucket, + OnSuccessCallbackSendFile success_callback, + OnErrorCallback error_callback +); + +void list_s3_files( + ZeroBounce *zb, + const char *bucket, + const char *search, + OnSuccessCallbackRawJson success_callback, + OnErrorCallback error_callback +); + +/** @brief Overrides the list evaluator base URL. */ +void zero_bounce_set_list_evaluator_base_url(ZeroBounce *zb, const char *url); + +/** @brief Overrides the Inbox Placement Tester base URL. */ +void zero_bounce_set_inbox_placement_base_url(ZeroBounce *zb, const char *url); + +/** + * @brief The documented fields shared by the six S3 and SFTP/FTP file jobs. + * + * file_name is required by every job except validate_sftp_file, where the docs + * list it as optional: leaving it NULL there uses the input bucket configured + * in your ZeroBounce settings. remove_duplicates is -1 to omit, 0 false, 1 true. + */ +typedef struct { + const char *file_name; + bool has_header_row; + int remove_duplicates; + /** NULL to omit. */ + const char *return_url; +} ZBBulkFileJobOptions; + +/** @brief has_header_row true, remove_duplicates and return_url omitted. */ +ZBBulkFileJobOptions new_bulk_file_job_options(void); + +/* S3 bulk — https://www.zerobounce.net/docs/api-integrations/amazon-s3-bulk-api */ + +void email_finder_s3_file( + ZeroBounce *zb, + const ZBBulkFileJobOptions *options, + OnSuccessCallbackSendFile success_callback, + OnErrorCallback error_callback +); + +void domain_search_s3_file( + ZeroBounce *zb, + const ZBBulkFileJobOptions *options, + OnSuccessCallbackSendFile success_callback, + OnErrorCallback error_callback +); + +/* SFTP/FTP bulk — https://www.zerobounce.net/docs/api-integrations/sftp_bulk_api + * The server, path and protocol are never sent by the SDK; they come from your + * ZeroBounce settings. */ + +/** + * @brief Validates a file from your SFTP/FTP server. + * + * This is the one job whose file_name the docs list as optional: leave + * options->file_name NULL to use the input bucket from your settings. + */ +void validate_sftp_file( + ZeroBounce *zb, + const ZBBulkFileJobOptions *options, + OnSuccessCallbackSendFile success_callback, + OnErrorCallback error_callback +); + +void score_sftp_file( + ZeroBounce *zb, + const ZBBulkFileJobOptions *options, + OnSuccessCallbackSendFile success_callback, + OnErrorCallback error_callback +); + +void email_finder_sftp_file( + ZeroBounce *zb, + const ZBBulkFileJobOptions *options, + OnSuccessCallbackSendFile success_callback, + OnErrorCallback error_callback +); + +void domain_search_sftp_file( + ZeroBounce *zb, + const ZBBulkFileJobOptions *options, + OnSuccessCallbackSendFile success_callback, + OnErrorCallback error_callback +); + +/** + * @brief Exports a validated or scored file back to your SFTP/FTP server. + * + * Note the field name: this endpoint takes export_path, not the export_bucket + * that export_s3_file sends. Pass NULL to use the one from your settings. + */ +void export_sftp_file( + ZeroBounce *zb, + const char *file_id, + const char *export_path, + OnSuccessCallbackSendFile success_callback, + OnErrorCallback error_callback +); + +/* Inbox Placement Tester (/api/ipt). Its own host, the API key on the query + * string, camelCase JSON bodies, and errors under a "detail" key. Spends the + * account's inbox-test credits. */ + +/** @brief The optional fields of a new test. Only what is set is sent. */ +typedef struct { + /** -1 to omit. */ + int test_type; + const char *sender_email; + const char *webhook_url; +} ZBCreateInboxPlacementTestOptions; + +ZBCreateInboxPlacementTestOptions new_create_inbox_placement_test_options(void); + +/** @brief The documented filters of the test list. page / page_size: -1 to omit. */ +typedef struct { + const char *status; + int page; + int page_size; +} ZBListInboxPlacementTestsOptions; + +ZBListInboxPlacementTestsOptions new_list_inbox_placement_tests_options(void); + +void get_inbox_placement_providers( + ZeroBounce *zb, + OnSuccessCallbackRawJson success_callback, + OnErrorCallback error_callback +); + +void get_inbox_placement_test_types( + ZeroBounce *zb, + OnSuccessCallbackRawJson success_callback, + OnErrorCallback error_callback +); + +void create_inbox_placement_test( + ZeroBounce *zb, + const char *name, + const ZBCreateInboxPlacementTestOptions *options, + OnSuccessCallbackRawJson success_callback, + OnErrorCallback error_callback +); + +void get_inbox_placement_test( + ZeroBounce *zb, + const char *test_id, + OnSuccessCallbackRawJson success_callback, + OnErrorCallback error_callback +); + +void list_inbox_placement_tests( + ZeroBounce *zb, + const ZBListInboxPlacementTestsOptions *options, + OnSuccessCallbackRawJson success_callback, + OnErrorCallback error_callback +); + +void delete_inbox_placement_test( + ZeroBounce *zb, + const char *test_id, + OnSuccessCallbackRawJson success_callback, + OnErrorCallback error_callback +); + +void get_inbox_placement_webhook( + ZeroBounce *zb, + OnSuccessCallbackRawJson success_callback, + OnErrorCallback error_callback +); + +void set_inbox_placement_webhook( + ZeroBounce *zb, + const char *webhook_url, + OnSuccessCallbackRawJson success_callback, + OnErrorCallback error_callback +); + /** * @brief Email Address Search - Identifies and validates a person’s primary email address * @@ -733,4 +1168,21 @@ void zero_bounce_set_http_perform_for_test(ZBHttpPerformFn fn); */ const char* zero_bounce_get_last_request_url_for_test(void); +/** + * @brief Test hook: returns the JSON body of the most recent validatebatch request. + */ +const char* zero_bounce_get_last_post_body_for_test(void); + +/** + * Thread-local snapshot of the request about to be performed. + * Safe to read from the HTTP test hook on the same thread. + */ +const char *zero_bounce_test_method(void); +const char *zero_bounce_test_url(void); +const char *zero_bounce_test_body(void); +const char *zero_bounce_test_extra_header(void); +int zero_bounce_test_field_count(void); +const char *zero_bounce_test_field_name(int index); +const char *zero_bounce_test_field_value(int index); + #endif \ No newline at end of file diff --git a/include/ZeroBounce/ZeroBounceWarmup.h b/include/ZeroBounce/ZeroBounceWarmup.h new file mode 100644 index 0000000..501297c --- /dev/null +++ b/include/ZeroBounce/ZeroBounceWarmup.h @@ -0,0 +1,239 @@ +#ifndef ZEROBOUNCEWARMUP_H +#define ZEROBOUNCEWARMUP_H + +/** + * 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. Use ZeroBounceWarmup with a Warmup API key; ZeroBounce stays for + * the validation key, and the two never mix. + * + * Every call hands the raw JSON body to its success callback, or an empty + * string for the documented empty successes (HTTP 204 and an empty 200). The + * two download endpoints hand over CSV text instead. + */ + +#include + +#include "ZeroBounce/ZeroBounce.h" + +/** Onboarding models accepted by warmup_add_sending_account. */ +#define ZB_WARMUP_SELF_MANAGED "self_managed" +#define ZB_WARMUP_ZB_MANAGED "zb_managed" + +/** + * The Warmup client. One instance per Warmup API key; the key is bound to one + * team. + */ +typedef struct { + char *api_key; + const char *base_url; +} ZeroBounceWarmup; + +/** ZB managed onboarding only. A NULL field is left out of the request. */ +typedef struct { + const char *company_name; + const char *company_website; + const char *company_description; + /** Any of transactional, newsletter, marketing, comma separated. */ + const char *types_of_emails; + const char *example_email; +} ZBWarmupEmailContent; + +/** ZB managed onboarding only, and write-only on the API. */ +typedef struct { + /** -1 to omit, 0 false, 1 true. */ + int weekdays_only; + const char *timezone; + /** -99 to omit, otherwise -11 to 14. */ + int timezone_offset; + /** YYYY-MM-DD, or NULL to omit. */ + const char *warmup_start_date; +} ZBWarmupManagedSending; + +/** Options for warmup_add_sending_account. */ +typedef struct { + /** Required for self_managed (0 to 4), ignored for zb_managed; -1 omits. */ + int engagement_rule_id; + /** Self-managed only: -1 to omit, 0 false, 1 true. */ + int ignore_conflict; + /** ZB managed only; NULL to omit. */ + const ZBWarmupEmailContent *email_content; + /** ZB managed only; NULL to omit. */ + const ZBWarmupManagedSending *managed_sending; +} ZBWarmupAddSendingAccountOptions; + +/** Options for warmup_list_from_addresses. */ +typedef struct { + /** Letters, digits and . - _ @ + only; NULL to omit. */ + const char *search_from; + /** -1 to omit. The API defaults to 0. */ + int offset; + /** -1 to omit. The API defaults to 100. */ + int limit; +} ZBWarmupListFromAddressesOptions; + +ZBWarmupEmailContent new_warmup_email_content(void); +ZBWarmupManagedSending new_warmup_managed_sending(void); +ZBWarmupAddSendingAccountOptions new_warmup_add_sending_account_options(void); +ZBWarmupListFromAddressesOptions new_warmup_list_from_addresses_options(void); + +/** A new client, with the default base URL and no key yet. */ +ZeroBounceWarmup *new_zero_bounce_warmup_instance(void); +/** Sets the Warmup API key. It is sent in an API-Key header. */ +void zero_bounce_warmup_initialize(ZeroBounceWarmup *zbw, const char *api_key); +/** Overrides the base URL ZeroBounce gave you. Must be https. */ +void zero_bounce_warmup_set_base_url(ZeroBounceWarmup *zbw, const char *url); +/** Frees the client and its key. */ +void zero_bounce_warmup_free(ZeroBounceWarmup *zbw); + +/* -- sending accounts ---------------------------------------------------- */ + +/** Every sending account of the key's team. An empty team answers an empty list. */ +void warmup_list_sending_accounts( + ZeroBounceWarmup *zbw, OnSuccessCallbackRawJson success_callback, OnErrorCallback error_callback); + +void warmup_get_sending_account( + ZeroBounceWarmup *zbw, const char *from_address, + OnSuccessCallbackRawJson success_callback, OnErrorCallback error_callback); + +/** + * Adds a from-address to warmup. For a ZB managed account a 200 means + * accepted; the mailbox is provisioned afterwards. + */ +void warmup_add_sending_account( + ZeroBounceWarmup *zbw, const char *from_address, const char *onboarding_model, + const ZBWarmupAddSendingAccountOptions *options, + OnSuccessCallbackRawJson success_callback, OnErrorCallback error_callback); + +/** + * Changes an account's engagement rule, status and auto-enable flag. + * status_id is 0 to 8 (4 Paused, 5 Disabled); auto_enable is accepted only + * for an account that is not active. + */ +void warmup_update_sending_account( + ZeroBounceWarmup *zbw, const char *from_address, int engagement_rule_id, int status_id, + bool auto_enable, OnSuccessCallbackRawJson success_callback, OnErrorCallback error_callback); + +/** Removes a sending account. Destructive and cannot be undone. */ +void warmup_delete_sending_account( + ZeroBounceWarmup *zbw, const char *from_address, + OnSuccessCallbackRawJson success_callback, OnErrorCallback error_callback); + +/** + * Moves an account and its history to a new from-address. Despite the path it + * uses, nothing about SMTP credentials is stored. + */ +void warmup_change_from_address( + ZeroBounceWarmup *zbw, const char *old_from_address, const char *new_from_address, + OnSuccessCallbackRawJson success_callback, OnErrorCallback error_callback); + +void warmup_list_from_addresses( + ZeroBounceWarmup *zbw, const ZBWarmupListFromAddressesOptions *options, + OnSuccessCallbackRawJson success_callback, OnErrorCallback error_callback); + +/* -- engagement rules ---------------------------------------------------- */ + +/** The rule catalogue: -1 Custom, 0 Default, 1 Slow Pace, 2 Repair, 3 Boost, 4 Protect. */ +void warmup_get_engagement_rules( + ZeroBounceWarmup *zbw, OnSuccessCallbackRawJson success_callback, OnErrorCallback error_callback); + +/** Sets a standard rule (0 to 4) on an address. */ +void warmup_set_engagement_rule( + ZeroBounceWarmup *zbw, const char *c_from, int rule_id, + OnSuccessCallbackRawJson success_callback, OnErrorCallback error_callback); + +void warmup_get_custom_engagement( + ZeroBounceWarmup *zbw, const char *c_from, + OnSuccessCallbackRawJson success_callback, OnErrorCallback error_callback); + +/** + * Creates a custom rule. reply_pct 5-45, important_pct 5-40, click_pct 0-70 + * and at most important_pct x 1.75. Starts a 7 day cooldown on updates. + */ +void warmup_create_custom_engagement( + ZeroBounceWarmup *zbw, const char *c_from, int reply_pct, int important_pct, int click_pct, + OnSuccessCallbackRawJson success_callback, OnErrorCallback error_callback); + +/** Updates a custom rule. During the 7 day cooldown this fails with HTTP 429. */ +void warmup_update_custom_engagement( + ZeroBounceWarmup *zbw, const char *c_from, int reply_pct, int important_pct, int click_pct, + OnSuccessCallbackRawJson success_callback, OnErrorCallback error_callback); + +/** Deletes a custom rule, falling back to standard rule engagement_rule_id (1 to 4). */ +void warmup_delete_custom_engagement( + ZeroBounceWarmup *zbw, const char *c_from, int engagement_rule_id, + OnSuccessCallbackRawJson success_callback, OnErrorCallback error_callback); + +/* -- seeds, progress and statistics -------------------------------------- */ + +void warmup_get_seeds( + ZeroBounceWarmup *zbw, OnSuccessCallbackRawJson success_callback, OnErrorCallback error_callback); + +/** + * The seed list as CSV (columns seed,provider). When download_path is not + * NULL the body is written there as well. + */ +void warmup_download_seeds( + ZeroBounceWarmup *zbw, const char *download_path, + OnSuccessCallbackRawJson success_callback, OnErrorCallback error_callback); + +/** The number of seeds. Answers a bare number, not an object. */ +void warmup_get_seed_count( + ZeroBounceWarmup *zbw, OnSuccessCallbackRawJson success_callback, OnErrorCallback error_callback); + +void warmup_get_progress_today( + ZeroBounceWarmup *zbw, OnSuccessCallbackRawJson success_callback, OnErrorCallback error_callback); + +void warmup_get_sent_today( + ZeroBounceWarmup *zbw, OnSuccessCallbackRawJson success_callback, OnErrorCallback error_callback); + +void warmup_get_emails_per_day( + ZeroBounceWarmup *zbw, OnSuccessCallbackRawJson success_callback, OnErrorCallback error_callback); + +/** Statistics for an address. An address with no history answers zeros. */ +void warmup_get_statistics( + ZeroBounceWarmup *zbw, const char *from_address, + OnSuccessCallbackRawJson success_callback, OnErrorCallback error_callback); + +/** Daily statistics between two YYYY-MM-DD dates, inclusive. */ +void warmup_get_statistics_per_day( + ZeroBounceWarmup *zbw, const char *from_address, const char *start_date, const char *end_date, + OnSuccessCallbackRawJson success_callback, OnErrorCallback error_callback); + +void warmup_get_statistics_detailed( + ZeroBounceWarmup *zbw, const char *c_from, + OnSuccessCallbackRawJson success_callback, OnErrorCallback error_callback); + +/** An address's statistics as CSV; also written to download_path when set. */ +void warmup_download_statistics( + ZeroBounceWarmup *zbw, const char *c_from, const char *download_path, + OnSuccessCallbackRawJson success_callback, OnErrorCallback error_callback); + +void warmup_get_from_address_statistics( + ZeroBounceWarmup *zbw, const char *from_address, + OnSuccessCallbackRawJson success_callback, OnErrorCallback error_callback); + +/* -- deliverability checks ----------------------------------------------- */ + +/** + * SPF, DKIM and DMARC status of a from-address's domain. Pass an email + * address: the live API answers "Please provide a valid email address" for a + * bare domain, although the docs describe a domain. dkim_selector may be NULL. + */ +void warmup_check_domain_dmarc( + ZeroBounceWarmup *zbw, const char *from_address, const char *dkim_selector, + OnSuccessCallbackRawJson success_callback, OnErrorCallback error_callback); + +/** + * Checks that SMTP credentials connect and hand off a message. Nothing is + * stored, and the test message goes to a ZeroBounce test recipient, not + * from_address. The live API answers a failed SMTP login with HTTP 500. + */ +void warmup_test_smtp_configuration( + ZeroBounceWarmup *zbw, const char *from_address, const char *username, const char *password, + const char *server, int port, + OnSuccessCallbackRawJson success_callback, OnErrorCallback error_callback); + +#endif diff --git a/include/ZeroBounce/zb_internal.h b/include/ZeroBounce/zb_internal.h new file mode 100644 index 0000000..5462341 --- /dev/null +++ b/include/ZeroBounce/zb_internal.h @@ -0,0 +1,76 @@ +#ifndef ZB_INTERNAL_H +#define ZB_INTERNAL_H + +#include "ZeroBounce/ZeroBounce.h" + +bool zero_bounce_invalid_api_key(ZeroBounce *zb, OnErrorCallback error_callback); +/* Whether a key is free of surrounding whitespace and control characters. */ +bool zb_api_key_is_clean(const char *api_key); +/* + * Refuses a URL that is not https, and warns once when a regional base URL is + * paired with the single global bulk host. + */ +bool zb_check_request_url(const char *url, OnErrorCallback error_callback); +/* Replaces the value of any api_key parameter in a message with REDACTED. */ +char *zb_redact_api_key(const char *text); +char *zb_url_escape(const char *value); +int zb_make_request( + char *url_path, + char *request_type, + char *header, + memory *response_data, + long *http_code, + OnErrorCallback error_callback +); +/* + * A request with an explicit method and an optional body. Needed by the Inbox + * Placement Tester, which uses PUT and DELETE and sends JSON rather than a + * form body. + */ +int zb_make_request_with_method_and_body( + char* url_path, + const char* method, + const char* body, + const char* content_type_header, + memory* response_data, + long* http_code, + OnErrorCallback error_callback +); + +/* + * As zb_make_request_with_method_and_body, plus one extra request header. + * The Inbox Placement Tester needs Authorization: Bearer, and the Warmup API + * an API-Key header. + */ +int zb_make_request_with_extra_header( + char* url_path, + const char* method, + const char* body, + const char* content_type_header, + const char* extra_header, + memory* response_data, + long* http_code, + OnErrorCallback error_callback +); + +int zb_make_request_with_post_body( + char *url_path, + char *body, + memory *response_data, + long *http_code, + OnErrorCallback error_callback +); +void zero_bounce_record_request_url_for_test(const char *url); +void zb_note_http_request(const char *method, const char *url, const char *body); +void zb_note_http_field(const char *name, const char *value); +void zb_note_http_header(const char *header); +int zb_http_perform_if_mock(memory *response_data, long *http_code, char **content_type_out); + +char *zb_message_from_body(const char *body, long status_code); +bool zb_body_looks_like_html(const char *body); +void zb_report_body_error(const char *body, long status_code, OnErrorCallback error_callback); +/** Parses a 2xx non-HTML JSON body. On failure, reports a short error and returns false. */ +bool zb_read_success_json(const char *body, long status_code, json_object **out, OnErrorCallback error_callback); +bool zb_batch_has_account_error(const json_object *batch, char **message_out); + +#endif diff --git a/live/live_api.c b/live/live_api.c new file mode 100644 index 0000000..8b9eb9b --- /dev/null +++ b/live/live_api.c @@ -0,0 +1,745 @@ +/* + * Opt-in live API suite: calls the SDK against the real API to check payloads + * and response shapes. + * + * It is its own executable, built only when ZEROBOUNCE_BUILD_LIVE_TESTS=ON, so + * `cmake --build build` and CI never compile it and `ctest` never runs it: + * + * cmake -S . -B build-live -DZEROBOUNCE_BUILD_LIVE_TESTS=ON + * cmake --build build-live --target ZeroBounceLiveApi + * export ZEROBOUNCE_API_KEY=... # validation API (skipped without it) + * export ZEROBOUNCE_WARMUP_API_KEY=... # Warmup API (skipped without it) + * ./build-live/bin/ZeroBounceLiveApi + * + * 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 in the + * same run even when a check 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. + */ + +#include +#include +#include +#include +#include +#include + +#include "ZeroBounce/ZeroBounce.h" +#include "ZeroBounce/ZeroBounceWarmup.h" + +/* Well formed but matching no file: a malformed id can be Cloudflare blocked + instead of answered. */ +#define UNKNOWN_ID "0d1b7a4e-1111-2222-3333-444455556666" +#define CALLBACK "https://example.com/zerobounce-sdk-live-test" +#define SANDBOX "valid@example.com" + +static int passed; +static int failed; +static int skipped; +static char failures[64][512]; +static int failure_count; + +static const char *api_key; +static const char *warmup_key; +static bool spend_flag; +static bool changes_flag; + +static ZeroBounce *zb; +static ZeroBounceWarmup *zbw; + +/* What the last callback saw. */ +static bool last_ok; +static char last_error[1024]; +static long last_status; +static char last_raw[65536]; + +static const char *env_or_empty(const char *name) { + const char *value = getenv(name); + return value ? value : ""; +} + +static void redact(char *text) { + const char *secrets[2]; + int i; + secrets[0] = api_key; + secrets[1] = warmup_key; + for (i = 0; i < 2; i++) { + const char *secret = secrets[i]; + char *at; + size_t len; + if (!secret || !secret[0]) { + continue; + } + len = strlen(secret); + while ((at = strstr(text, secret)) != NULL) { + memmove(at + 3, at + len, strlen(at + len) + 1); + memcpy(at, "***", 3); + } + } +} + +static void reset_result(void) { + last_ok = false; + last_status = 0; + last_error[0] = '\0'; + last_raw[0] = '\0'; +} + +static void on_error(ZBErrorResponse error) { + size_t i; + size_t used = 0; + last_ok = false; + last_status = error.status_code; + last_error[0] = '\0'; + for (i = 0; i < error.errors.size; i++) { + const char *part = error.errors.data[i] ? error.errors.data[i] : ""; + int written = snprintf(last_error + used, sizeof(last_error) - used, + "%s%s", used ? " " : "", part); + if (written < 0 || (size_t)written >= sizeof(last_error) - used) { + break; + } + used += (size_t)written; + } + redact(last_error); +} + +static void on_raw(const char *json_response) { + last_ok = true; + snprintf(last_raw, sizeof(last_raw), "%s", json_response ? json_response : ""); +} + +static void on_credits(ZBCreditsResponse response) { last_ok = true; (void)response; } +static void on_api_usage(ZBGetApiUsageResponse response) { last_ok = true; (void)response; } +static void on_validate(ZBValidateResponse response) { last_ok = true; (void)response; } +static void on_score(ZBScoreEmailResponse response) { last_ok = true; (void)response; } +static void on_activity(ZBActivityDataResponse response) { last_ok = true; (void)response; } +static void on_find_email(ZBFindEmailResponse response) { last_ok = true; (void)response; } +static void on_domain(ZBDomainSearchResponse response) { last_ok = true; (void)response; } +static void on_delete(ZBDeleteFileResponse response) { last_ok = true; (void)response; } +static void on_get_file(ZBGetFileResponse response) { last_ok = true; (void)response; } + +static long long credits_before = -2; +static long long credits_after = -2; +static void on_credits_before(ZBCreditsResponse response) { + last_ok = true; + credits_before = response.credits; +} +static void on_credits_after(ZBCreditsResponse response) { + last_ok = true; + credits_after = response.credits; +} + +static char batch_account_error[512]; +static void on_batch(ZBValidateBatchResponse response) { + size_t i; + last_ok = true; + batch_account_error[0] = '\0'; + for (i = 0; i < response.errors.size; i++) { + const char *address = response.errors.data[i].email_address; + if (address && strcasecmp(address, "all") == 0) { + snprintf(batch_account_error, sizeof(batch_account_error), "%s", + response.errors.data[i].error ? response.errors.data[i].error : "unknown"); + } + } +} + +static char send_file_id[128]; +static void on_send_file(ZBSendFileResponse response) { + last_ok = true; + snprintf(send_file_id, sizeof(send_file_id), "%s", response.file_id ? response.file_id : ""); +} + +static char status_file_id[128]; +static void on_file_status(ZBFileStatusResponse response) { + last_ok = true; + snprintf(status_file_id, sizeof(status_file_id), "%s", response.file_id ? response.file_id : ""); +} + +static void pass(const char *name) { + passed++; + printf(" + %s\n", name); + fflush(stdout); +} + +static void fail(const char *name, const char *problem) { + char safe[512]; + failed++; + snprintf(safe, sizeof(safe), "%s", problem); + redact(safe); + if (failure_count < (int)(sizeof(failures) / sizeof(failures[0]))) { + snprintf(failures[failure_count++], sizeof(failures[0]), "%s: %s", name, safe); + } + printf(" X %s\n %s\n", name, safe); + fflush(stdout); +} + +static void skip(const char *name, const char *reason) { + skipped++; + printf(" - SKIP %s (%s)\n", name, reason); + fflush(stdout); +} + +/* The common shape: the call ran, and either it succeeded or it did not. */ +static void check_ok(const char *name) { + if (last_ok) { + pass(name); + } else { + fail(name, last_error[0] ? last_error : "no response"); + } +} + +static void iso_days_ago(int days, char *out, size_t out_len) { + time_t now = time(NULL) - (time_t)days * 24 * 3600; + struct tm value; + localtime_r(&now, &value); + strftime(out, out_len, "%Y-%m-%d", &value); +} + +static struct tm days_ago(int days) { + time_t now = time(NULL) - (time_t)days * 24 * 3600; + struct tm value; + localtime_r(&now, &value); + return value; +} + +/* -- validation API, free -------------------------------------------------- */ + +static void validation_free(void) { + ZBValidateOptions options; + EmailToValidateVector batch; + ZBEmailToValidate row = {SANDBOX, ""}; + ZBBulkFileJobOptions job; + ZBListInboxPlacementTestsOptions listing; + + printf("Validation API (free calls)\n"); + + reset_result(); + get_credits(zb, on_credits_before, on_error); + check_ok("getCredits returns a balance"); + + reset_result(); + { + struct tm from = days_ago(7); + struct tm to = days_ago(0); + get_api_usage(zb, from, to, on_api_usage, on_error); + } + check_ok("getApiUsage parses the documented date formats"); + + reset_result(); + validate_email(zb, SANDBOX, "", on_validate, on_error); + check_ok("validate on a sandbox address spends nothing"); + + /* 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. */ + reset_result(); + options = new_validate_options(); + options.use_post = true; + options.timeout = 30; + validate_email_with_options(zb, SANDBOX, "", &options, on_validate, on_error); + check_ok("validate works over POST as well as GET"); + + reset_result(); + options = new_validate_options(); + options.timeout = 30; + batch = email_to_validate_vector_init(); + email_to_validate_vector_append(&batch, row); + validate_email_batch_with_options(zb, batch, &options, on_batch, on_error); + email_to_validate_vector_free(&batch); + if (!last_ok) { + fail("LIVE: validateBatch accepts timeout as a string", + last_error[0] ? last_error : "no response"); + } else if (batch_account_error[0]) { + fail("LIVE: validateBatch accepts timeout as a string", batch_account_error); + } else { + pass("LIVE: validateBatch accepts timeout as a string"); + } + + reset_result(); + file_status(zb, UNKNOWN_ID, on_file_status, on_error); + if (last_ok) { + fail("an unknown file id is a short, clean error", "fileStatus unexpectedly succeeded"); + } else if (strlen(last_error) >= 500 || strstr(last_error, "= 500) { + fail("listS3files answers (empty without an S3 integration)", "message too long"); + } else { + pass("listS3files answers (empty without an S3 integration)"); + } + + reset_result(); + job = new_bulk_file_job_options(); + job.file_name = "f.csv"; + job.has_header_row = true; + email_finder_s3_file(zb, &job, on_send_file, on_error); + if (last_ok) { + fail("LIVE: S3/SFTP jobs reject a payload without return_url before sending", + "a payload without return_url was accepted"); + } else if (!strstr(last_error, "return_url")) { + fail("LIVE: S3/SFTP jobs reject a payload without return_url before sending", last_error); + } else { + pass("LIVE: S3/SFTP jobs reject a payload without return_url before sending"); + } + + /* Without an S3 integration this fails on the integration, not the payload. */ + reset_result(); + job.return_url = CALLBACK; + email_finder_s3_file(zb, &job, on_send_file, on_error); + if (!last_ok && (strstr(last_error, "return URL is not permitted") || + strstr(last_error, "This field is required"))) { + fail("LIVE: a complete S3 job payload is accepted by the API", last_error); + } else { + pass("LIVE: a complete S3 job payload is accepted by the API"); + } + + reset_result(); + list_filters(zb, false, on_raw, on_error); + check_ok("filters list"); + + /* LIVE: an api_key parameter is rejected as unauthorized here. */ + reset_result(); + get_inbox_placement_providers(zb, on_raw, on_error); + check_ok("LIVE: the Inbox Placement Tester accepts a Bearer key"); + + reset_result(); + get_inbox_placement_test_types(zb, on_raw, on_error); + check_ok("inbox placement test types"); + + /* LIVE: the listing is GET /tests (plural); /test is the single-test path + and answers 'Method "GET" not allowed'. */ + reset_result(); + listing = new_list_inbox_placement_tests_options(); + listing.page = 1; + listing.page_size = 10; + list_inbox_placement_tests(zb, &listing, on_raw, on_error); + if (!last_ok) { + fail("LIVE: inbox placement tests are listed from /tests, and the webhook reads back", + last_error[0] ? last_error : "no response"); + } else { + reset_result(); + get_inbox_placement_webhook(zb, on_raw, on_error); + check_ok("LIVE: inbox placement tests are listed from /tests, and the webhook reads back"); + } + + reset_result(); + get_list_evaluator_result(zb, UNKNOWN_ID, on_raw, on_error); + if (last_ok) { + fail("list evaluator result for an unknown id fails cleanly", + "an unknown evaluation id unexpectedly succeeded"); + } else if (strlen(last_error) >= 500) { + fail("list evaluator result for an unknown id fails cleanly", "message too long"); + } else { + pass("list evaluator result for an unknown id fails cleanly"); + } + + /* LIVE: the file id is a form field. Appended to the path it is a 404. */ + reset_result(); + send_evaluated_file(zb, UNKNOWN_ID, on_send_file, on_error); + if (last_ok) { + fail("LIVE: sendEvaluatedFile posts the file id as a form field", + "an unknown evaluation id unexpectedly succeeded"); + } else if (strstr(last_error, "not allowed")) { + fail("LIVE: sendEvaluatedFile posts the file id as a form field", last_error); + } else { + pass("LIVE: sendEvaluatedFile posts the file id as a form field"); + } + + reset_result(); + get_credits(zb, on_credits_after, on_error); + if (!last_ok) { + fail("the free calls did not change the credit balance", + last_error[0] ? last_error : "no response"); + } else if (credits_after != credits_before) { + char message[128]; + snprintf(message, sizeof(message), "credits went from %lld to %lld", + credits_before, credits_after); + fail("the free calls did not change the credit balance", message); + } else { + pass("the free calls did not change the credit balance"); + } +} + +/* -- validation API, spends credits ---------------------------------------- */ + +static char bulk_file_id[128]; + +static void validation_spend(void) { + ZBValidateOptions options; + SendFileOptions send_options; + + printf("\nValidation API (ZEROBOUNCE_LIVE_SPEND=1)\n"); + + /* 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. */ + reset_result(); + options = new_validate_options(); + options.timeout = 30; + options.activity_data = 1; + options.verify_plus = 1; + options.domain_info = 1; + options.greylist_processing = 1; + options.callback_url = CALLBACK; + validate_email_with_options(zb, SANDBOX, "", &options, on_validate, on_error); + check_ok("validate accepts every documented option"); + + reset_result(); + score_email(zb, SANDBOX, on_score, on_error); + check_ok("scoreEmail"); + + reset_result(); + score_email_post(zb, SANDBOX, on_score, on_error); + check_ok("scoreEmail over POST"); + + reset_result(); + get_activity_data(zb, SANDBOX, on_activity, on_error); + check_ok("getActivityData"); + + reset_result(); + find_email_by_domain_first_last_name(zb, "example.com", "John", "Doe", on_find_email, on_error); + check_ok("email finder"); + + reset_result(); + search_domain_by_domain(zb, "example.com", on_domain, on_error); + check_ok("domain search"); + + reset_result(); + bulk_file_id[0] = '\0'; + send_file_id[0] = '\0'; + { + const char *path = "zb-sdk-live.csv"; + FILE *out = fopen(path, "w"); + if (!out) { + fail("bulk validation round trip: upload, status, delete", + "could not write the upload file"); + } else { + fputs("email\nvalid@example.com\ninvalid@example.com\n", out); + fclose(out); + send_options = new_send_file_options(); + send_options.has_header_row = true; + send_file(zb, (char *)path, 1, send_options, on_send_file, on_error); + remove(path); + if (!last_ok) { + fail("bulk validation round trip: upload, status, delete", + last_error[0] ? last_error : "no response"); + } else if (!send_file_id[0]) { + fail("bulk validation round trip: upload, status, delete", + "no file_id in the upload response"); + } else { + snprintf(bulk_file_id, sizeof(bulk_file_id), "%s", send_file_id); + reset_result(); + status_file_id[0] = '\0'; + file_status(zb, bulk_file_id, on_file_status, on_error); + if (!last_ok) { + fail("bulk validation round trip: upload, status, delete", + last_error[0] ? last_error : "no response"); + } else if (strcmp(status_file_id, bulk_file_id) != 0) { + fail("bulk validation round trip: upload, status, delete", + "status did not echo the file id"); + } else { + pass("bulk validation round trip: upload, status, delete"); + } + } + } + } + + if (!bulk_file_id[0]) { + skip("the uploaded validation job is deleted again", + "upload did not produce a file id"); + return; + } + /* LIVE: a just-accepted job answers "File cannot be found" for a short + while, so the delete is retried rather than leaving it behind. */ + { + int attempt; + char last[512] = ""; + for (attempt = 0; attempt < 12; attempt++) { + reset_result(); + delete_file(zb, bulk_file_id, on_delete, on_error); + if (last_ok) { + pass("the uploaded validation job is deleted again"); + return; + } + snprintf(last, sizeof(last), "%s", last_error); + sleep(10); + } + { + char message[640]; + snprintf(message, sizeof(message), "could not delete %s after retrying: %s", + bulk_file_id, last); + fail("the uploaded validation job is deleted again", message); + } + } +} + +/* -- validation API, reversible account changes ---------------------------- */ + +static void validation_changes(void) { + char value[128]; + bool added; + + printf("\nValidation API (ZEROBOUNCE_LIVE_CHANGES=1)\n"); + snprintf(value, sizeof(value), "zb-sdk-live-%lld.example.com", (long long)time(NULL)); + + reset_result(); + add_filter(zb, "allow", "domain", value, on_raw, on_error); + added = last_ok; + check_ok("addFilter creates a reversible allow rule"); + + if (!added) { + skip("the test filter is removed again", "filter was not created"); + return; + } + reset_result(); + delete_filter(zb, "allow", "domain", value, on_raw, on_error); + check_ok("the test filter is removed again"); +} + +/* -- Warmup API ------------------------------------------------------------ */ + +/* + * The from-address of the first sending account this team has, or "": the + * statistics endpoints answer "Sending account ... was not found" for anything + * else. + */ +static void first_sending_address(char *out, size_t out_len) { + const char *at; + const char *value; + const char *end; + + out[0] = '\0'; + reset_result(); + warmup_list_sending_accounts(zbw, on_raw, on_error); + if (!last_ok) { + return; + } + at = strstr(last_raw, "\"from_address\""); + if (!at) { + at = strstr(last_raw, "\"c_from\""); + } + if (!at) { + return; + } + value = strchr(at + 1, ':'); + if (!value) { + return; + } + value = strchr(value, '"'); + if (!value) { + return; + } + value++; + end = strchr(value, '"'); + if (!end || end == value || (size_t)(end - value) >= out_len) { + return; + } + memcpy(out, value, (size_t)(end - value)); + out[end - value] = '\0'; +} + +static void warmup_free_reads(void) { + char known[256]; + char start[16]; + char end[16]; + + printf("\nWarmup API (reads)\n"); + + reset_result(); + warmup_list_sending_accounts(zbw, on_raw, on_error); + check_ok("listSendingAccounts"); + + reset_result(); + warmup_list_from_addresses(zbw, NULL, on_raw, on_error); + check_ok("listFromAddresses"); + + reset_result(); + warmup_get_engagement_rules(zbw, on_raw, on_error); + check_ok("getEngagementRules"); + + reset_result(); + warmup_get_seeds(zbw, on_raw, on_error); + check_ok("getSeeds"); + + reset_result(); + warmup_get_seed_count(zbw, on_raw, on_error); + check_ok("getSeedCount"); + + reset_result(); + warmup_get_progress_today(zbw, on_raw, on_error); + check_ok("getProgressToday"); + + reset_result(); + warmup_get_sent_today(zbw, on_raw, on_error); + check_ok("getSentToday"); + + reset_result(); + warmup_get_emails_per_day(zbw, on_raw, on_error); + check_ok("getEmailsPerDay"); + + reset_result(); + warmup_download_seeds(zbw, NULL, on_raw, on_error); + if (!last_ok) { + fail("downloadSeeds returns CSV text", last_error[0] ? last_error : "no response"); + } else if (!last_raw[0]) { + fail("downloadSeeds returns CSV text", "empty CSV body"); + } else if (last_raw[0] == '{') { + fail("downloadSeeds returns CSV text", "got JSON instead of CSV"); + } else { + pass("downloadSeeds returns CSV text"); + } + + /* LIVE: a bare domain is answered with "Please provide a valid email + address", although the docs describe a domain. */ + reset_result(); + warmup_check_domain_dmarc(zbw, "example.com", NULL, on_raw, on_error); + if (last_ok) { + fail("LIVE: checkDomainDmarc rejects a bare domain before sending", + "a bare domain unexpectedly succeeded"); + } else if (!strstr(last_error, "email address")) { + fail("LIVE: checkDomainDmarc rejects a bare domain before sending", last_error); + } else { + pass("LIVE: checkDomainDmarc rejects a bare domain before sending"); + } + + reset_result(); + warmup_check_domain_dmarc(zbw, "sender@example.com", NULL, on_raw, on_error); + check_ok("LIVE: checkDomainDmarc accepts an email address"); + + first_sending_address(known, sizeof(known)); + if (!known[0]) { + const char *reason = "this team has no sending account"; + skip("statistics for a registered from-address", reason); + skip("statistics per day over a short range", reason); + skip("detailed statistics and the CSV download for that address", reason); + skip("from-address statistics", reason); + } else { + reset_result(); + warmup_get_statistics(zbw, known, on_raw, on_error); + check_ok("statistics for a registered from-address"); + + reset_result(); + iso_days_ago(6, start, sizeof(start)); + iso_days_ago(0, end, sizeof(end)); + warmup_get_statistics_per_day(zbw, known, start, end, on_raw, on_error); + check_ok("statistics per day over a short range"); + + reset_result(); + warmup_get_statistics_detailed(zbw, known, on_raw, on_error); + if (!last_ok) { + fail("detailed statistics and the CSV download for that address", + last_error[0] ? last_error : "no response"); + } else { + reset_result(); + warmup_download_statistics(zbw, known, NULL, on_raw, on_error); + check_ok("detailed statistics and the CSV download for that address"); + } + + reset_result(); + warmup_get_from_address_statistics(zbw, known, on_raw, on_error); + check_ok("from-address statistics"); + } + + reset_result(); + warmup_get_custom_engagement(zbw, "no-such-address@example.com", on_raw, on_error); + if (last_ok) { + pass("an unknown Warmup resource fails with a status code"); + } else if (last_status == 0) { + fail("an unknown Warmup resource fails with a status code", "no status code on the error"); + } else if (warmup_key[0] && strstr(last_error, warmup_key)) { + fail("an unknown Warmup resource fails with a status code", + "the key leaked into the message"); + } else { + pass("an unknown Warmup resource fails with a status code"); + } +} + +static void warmup_changes(void) { + char address[128]; + ZBWarmupAddSendingAccountOptions options; + bool added; + + printf("\nWarmup API (ZEROBOUNCE_LIVE_CHANGES=1)\n"); + snprintf(address, sizeof(address), "zb-sdk-live-%lld@example.com", (long long)time(NULL)); + + reset_result(); + options = new_warmup_add_sending_account_options(); + options.engagement_rule_id = 0; + options.ignore_conflict = 1; + warmup_add_sending_account(zbw, address, ZB_WARMUP_SELF_MANAGED, &options, on_raw, on_error); + added = last_ok; + check_ok("a self-managed sending account can be added"); + + if (!added) { + skip("the test sending account is removed again", "account was not created"); + return; + } + reset_result(); + warmup_delete_sending_account(zbw, address, on_raw, on_error); + check_ok("the test sending account is removed again"); +} + +int main(void) { + int i; + + api_key = env_or_empty("ZEROBOUNCE_API_KEY"); + warmup_key = env_or_empty("ZEROBOUNCE_WARMUP_API_KEY"); + spend_flag = strcmp(env_or_empty("ZEROBOUNCE_LIVE_SPEND"), "1") == 0; + changes_flag = strcmp(env_or_empty("ZEROBOUNCE_LIVE_CHANGES"), "1") == 0; + + printf("ZeroBounce live API suite\n"); + printf(" validation key: %s\n", api_key[0] ? "set" : "MISSING"); + printf(" warmup key: %s\n", warmup_key[0] ? "set" : "MISSING"); + printf(" flags: SPEND=%d CHANGES=%d\n\n", spend_flag ? 1 : 0, changes_flag ? 1 : 0); + fflush(stdout); + + if (api_key[0]) { + zb = new_zero_bounce_instance(); + zero_bounce_initialize(zb, api_key); + validation_free(); + if (spend_flag) { + validation_spend(); + } + if (changes_flag) { + validation_changes(); + } + } + + if (warmup_key[0]) { + zbw = new_zero_bounce_warmup_instance(); + zero_bounce_warmup_initialize(zbw, warmup_key); + warmup_free_reads(); + if (changes_flag) { + warmup_changes(); + } + zero_bounce_warmup_free(zbw); + } + + printf("\n%d passed, %d failed, %d skipped\n", passed, failed, skipped); + if (failure_count) { + printf("\nFailures:\n"); + for (i = 0; i < failure_count; i++) { + printf(" - %s\n", failures[i]); + } + } + return failed == 0 ? 0 : 1; +} diff --git a/src/Priority3.c b/src/Priority3.c new file mode 100644 index 0000000..ecb8a4f --- /dev/null +++ b/src/Priority3.c @@ -0,0 +1,1524 @@ +#include +#include +#include +#include +#include + +#include + +#include "ZeroBounce/ZeroBounce.h" +#include "ZeroBounce/zb_internal.h" + +static bool p3_is_blank(const char *value); +static char *p3_escape_path_segment(const char *value); + +typedef void (*p3_mime_fields_fn)(curl_mime *multipart, void *ctx); + +EmailFinderSendFileOptions new_email_finder_send_file_options(void) { + EmailFinderSendFileOptions o = {0, 0, 0, 0, -1}; + return o; +} + +DomainSearchSendFileOptions new_domain_search_send_file_options(void) { + DomainSearchSendFileOptions o = {-1}; + return o; +} + +ValidateS3FileOptions new_validate_s3_file_options(void) { + ValidateS3FileOptions o = {0}; + o.has_header_row = -1; + o.remove_duplicates = -1; + o.remove_duplicate = -1; + o.email_address_column = -1; + return o; +} + +ScoreS3FileOptions new_score_s3_file_options(void) { + ScoreS3FileOptions o = {0}; + o.has_header_row = -1; + o.remove_duplicates = -1; + o.remove_duplicate = -1; + o.email_address_column = -1; + return o; +} + +static bool p3_append_form_field(char **body, size_t *cap, const char *key, const char *value) { + if (!key || !value) { + return false; + } + char *ek = zb_url_escape(key); + char *ev = zb_url_escape(value); + if (!ek || !ev) { + free(ek); + free(ev); + return false; + } + size_t need = strlen(ek) + strlen(ev) + 2 + (*body ? strlen(*body) + 1 : 0); + if (*body && strchr(*body, '=')) { + need += 1; + } + if (need > *cap) { + size_t new_cap = (*cap == 0) ? 256 : *cap * 2; + bool was_empty = (*body == NULL); + char *n; + while (new_cap < need) { + new_cap *= 2; + } + n = realloc(*body, new_cap); + if (!n) { + free(ek); + free(ev); + return false; + } + /* realloc(NULL, n) is malloc: the block is uninitialized, so the + strlen and strcat below would read and append past garbage, and + that garbage would go out on the wire. */ + if (was_empty) { + n[0] = '\0'; + } + *body = n; + *cap = new_cap; + } + if (!*body) { + *body = calloc(1, *cap); + if (!*body) { + free(ek); + free(ev); + return false; + } + } + size_t len = strlen(*body); + if (len > 0) { + strcat(*body, "&"); + } + strcat(*body, ek); + strcat(*body, "="); + strcat(*body, ev); + free(ek); + free(ev); + return true; +} + +static void p3_append_optional_bool_form(char **body, size_t *cap, const char *key, int tri_state) { + if (tri_state < 0) { + return; + } + p3_append_form_field(body, cap, key, tri_state ? "true" : "false"); +} + +static void p3_send_json_success( + memory *response_data, + long http_code, + OnSuccessCallbackRawJson success_callback, + OnErrorCallback error_callback +) { + const char *body = response_data->response ? response_data->response : ""; + if (http_code > 299 || zb_body_looks_like_html(body) || body[0] == '\0') { + zb_report_body_error(body, http_code, error_callback); + return; + } + if (body[0] == '{' || body[0] == '[') { + json_object *parsed = json_tokener_parse(body); + if (!parsed) { + zb_report_body_error(body, http_code, error_callback); + return; + } + json_object_put(parsed); + } else { + zb_report_body_error(body, http_code, error_callback); + return; + } + if (success_callback) { + success_callback(body); + } +} + +static void p3_raw_json_get( + ZeroBounce *zb, + char *url_path, + OnSuccessCallbackRawJson success_callback, + OnErrorCallback error_callback +) { + memory response_data = {0}; + long http_code = 0; + if (!zb_make_request(url_path, "GET", "Accept: application/json", &response_data, &http_code, error_callback)) { + free(response_data.response); + free(url_path); + return; + } + p3_send_json_success(&response_data, http_code, success_callback, error_callback); + free(response_data.response); + free(url_path); +} + +static void p3_raw_json_form_post( + ZeroBounce *zb, + char *url_path, + char *body, + OnSuccessCallbackRawJson success_callback, + OnErrorCallback error_callback +) { + memory response_data = {0}; + long http_code = 0; + if (!zb_make_request_with_post_body(url_path, body, &response_data, &http_code, error_callback)) { + free(response_data.response); + free(url_path); + free(body); + return; + } + p3_send_json_success(&response_data, http_code, success_callback, error_callback); + free(response_data.response); + free(url_path); + free(body); +} + +static void p3_send_file_json_success( + memory *response_data, + long http_code, + OnSuccessCallbackSendFile success_callback, + OnErrorCallback error_callback +) { + json_object *j_obj = NULL; + if (!zb_read_success_json(response_data->response, http_code, &j_obj, error_callback)) { + return; + } + if (success_callback) { + if (j_obj == NULL) { + if (error_callback) { + error_callback(parse_error("Failed to parse json string")); + } + return; + } + success_callback(zb_send_file_response_from_json(j_obj)); + json_object_put(j_obj); + } +} + +static void p3_post_send_file_form( + ZeroBounce *zb, + const char *url, + char *body, + OnSuccessCallbackSendFile success_callback, + OnErrorCallback error_callback +) { + char *url_path = strdup(url); + if (!url_path) { + free(body); + fprintf(stderr, "Memory allocation failed.\n"); + exit(EXIT_FAILURE); + } + memory response_data = {0}; + long http_code = 0; + if (!zb_make_request_with_post_body(url_path, body, &response_data, &http_code, error_callback)) { + free(response_data.response); + free(url_path); + return; + } + p3_send_file_json_success(&response_data, http_code, success_callback, error_callback); + free(response_data.response); + free(url_path); +} + +static void p3_mime_add_int(curl_mime *multipart, const char *name, int value) { + char buf[16]; + snprintf(buf, sizeof(buf), "%d", value); + zb_note_http_field(name, buf); + curl_mimepart *part = curl_mime_addpart(multipart); + curl_mime_name(part, name); + curl_mime_data(part, buf, CURL_ZERO_TERMINATED); +} + +static void p3_mime_add_optional_bool(curl_mime *multipart, const char *name, int tri_state) { + const char *text; + curl_mimepart *part; + if (tri_state < 0) { + return; + } + text = tri_state ? "true" : "false"; + zb_note_http_field(name, text); + part = curl_mime_addpart(multipart); + curl_mime_name(part, name); + curl_mime_data(part, text, CURL_ZERO_TERMINATED); +} + +static void p3_bulk_root_multipart( + ZeroBounce *zb, + const char *url, + char *file_path, + p3_mime_fields_fn add_fields, + void *fields_ctx, + OnSuccessCallbackSendFile success_callback, + OnErrorCallback error_callback +) { + if (zero_bounce_invalid_api_key(zb, error_callback)) { + return; + } + + memory response_data = {0}; + char *url_path = strdup(url); + if (!url_path) { + fprintf(stderr, "Memory allocation failed.\n"); + exit(EXIT_FAILURE); + } + + long http_code = 0; + zb_note_http_request("POST", url_path, NULL); + if (!zb_check_request_url(url_path, error_callback)) { + goto cleanup; + } + zb_note_http_field("api_key", zb->api_key ? zb->api_key : ""); + zb_note_http_field("file", ""); + if (add_fields) { + CURL *note_curl = curl_easy_init(); + if (note_curl) { + curl_mime *note_mime = curl_mime_init(note_curl); + if (note_mime) { + add_fields(note_mime, fields_ctx); + curl_mime_free(note_mime); + } + curl_easy_cleanup(note_curl); + } + } + if (zb_http_perform_if_mock(&response_data, &http_code, NULL)) { + p3_send_file_json_success(&response_data, http_code, success_callback, error_callback); + goto cleanup; + } + + CURL *curl = curl_easy_init(); + if (!curl) { + if (error_callback) { + error_callback(parse_error("Failed to initialize libcurl")); + } + goto cleanup; + } + + curl_easy_setopt(curl, CURLOPT_CUSTOMREQUEST, "POST"); + curl_easy_setopt(curl, CURLOPT_URL, url_path); + struct curl_slist *headers = NULL; + headers = curl_slist_append(headers, "Content-Type: multipart/form-data"); + curl_easy_setopt(curl, CURLOPT_HTTPHEADER, headers); + + curl_mime *multipart = curl_mime_init(curl); + curl_mimepart *part = curl_mime_addpart(multipart); + curl_mime_name(part, "api_key"); + curl_mime_data(part, zb->api_key, CURL_ZERO_TERMINATED); + + part = curl_mime_addpart(multipart); + curl_mime_name(part, "file"); + curl_mime_filedata(part, file_path); + + if (add_fields) { + add_fields(multipart, fields_ctx); + } + + curl_easy_setopt(curl, CURLOPT_MIMEPOST, multipart); + set_write_callback(curl, &response_data); + + CURLcode res = curl_easy_perform(curl); + if (res != CURLE_OK) { + char error_message[300]; + snprintf( + error_message, + sizeof(error_message), + "Failed to perform request. CURLcode: %d. Error: %s", + res, + curl_easy_strerror(res) + ); + if (error_callback) { + error_callback(parse_error(error_message)); + } + curl_mime_free(multipart); + curl_easy_cleanup(curl); + curl_slist_free_all(headers); + goto cleanup; + } + + http_code = get_http_code(curl); + curl_mime_free(multipart); + curl_easy_cleanup(curl); + curl_slist_free_all(headers); + + p3_send_file_json_success(&response_data, http_code, success_callback, error_callback); + +cleanup: + free(url_path); + free(response_data.response); +} + +static void p3_bulk_root_get_file( + ZeroBounce *zb, + const char *base_url, + char *file_id, + char *local_download_path, + OnSuccessCallbackGetFile success_callback, + OnErrorCallback error_callback +) { + if (zero_bounce_invalid_api_key(zb, error_callback)) { + return; + } + + char *api_key_esc = zb_url_escape(zb->api_key); + char *file_id_esc = zb_url_escape(file_id); + const char *url_pattern = "%s/getfile?api_key=%s&file_id=%s"; + int url_path_len = snprintf(NULL, 0, url_pattern, base_url, api_key_esc, file_id_esc); + char *url_path = malloc((size_t)url_path_len + 1); + if (!url_path) { + free(api_key_esc); + free(file_id_esc); + fprintf(stderr, "Memory allocation failed.\n"); + exit(EXIT_FAILURE); + } + snprintf(url_path, (size_t)url_path_len + 1, url_pattern, base_url, api_key_esc, file_id_esc); + free(api_key_esc); + free(file_id_esc); + + memory response_data = {0}; + response_data.max_bytes = ZB_MAX_GETFILE_BYTES; + long http_code = 0; + char *content_type = NULL; + + zb_note_http_request("GET", url_path, NULL); + if (!zb_http_perform_if_mock(&response_data, &http_code, &content_type)) { + CURL *curl = curl_easy_init(); + if (!curl) { + if (error_callback) { + error_callback(parse_error("Failed to initialize libcurl")); + } + goto cleanup; + } + curl_easy_setopt(curl, CURLOPT_CUSTOMREQUEST, "GET"); + curl_easy_setopt(curl, CURLOPT_URL, url_path); + struct curl_slist *headers = NULL; + headers = curl_slist_append(headers, "Accept: application/json"); + curl_easy_setopt(curl, CURLOPT_HTTPHEADER, headers); + set_write_callback(curl, &response_data); + CURLcode res = curl_easy_perform(curl); + if (res != CURLE_OK) { + if (error_callback) { + error_callback(parse_error(curl_easy_strerror(res))); + } + curl_easy_cleanup(curl); + curl_slist_free_all(headers); + goto cleanup; + } + http_code = get_http_code(curl); + content_type = get_content_type_value(curl); + curl_easy_cleanup(curl); + curl_slist_free_all(headers); + } + + const char *body = response_data.response ? response_data.response : ""; + if (http_code > 299 || zb_body_looks_like_html(body) || body[0] == '\0' || + zb_get_file_json_indicates_error(body)) { + zb_report_body_error(body, http_code, error_callback); + } else if (success_callback) { + FILE *file_stream = fopen(local_download_path, "wb"); + if (file_stream == NULL) { + if (error_callback) { + error_callback(parse_error("Invalid file path")); + } + } else { + fwrite(response_data.response, 1, response_data.size, file_stream); + fclose(file_stream); + ZBGetFileResponse response = new_zb_get_file_response(); + response.success = true; + response.local_file_path = local_download_path; + success_callback(response); + } + } + +cleanup: + free(url_path); + free(response_data.response); + free(content_type); +} + +static void p3_bulk_root_file_status( + ZeroBounce *zb, + const char *base_url, + char *file_id, + OnSuccessCallbackFileStatus success_callback, + OnErrorCallback error_callback +) { + if (zero_bounce_invalid_api_key(zb, error_callback)) { + return; + } + char *api_key_esc = zb_url_escape(zb->api_key); + char *file_id_esc = zb_url_escape(file_id); + const char *url_pattern = "%s/filestatus?api_key=%s&file_id=%s"; + int url_path_len = snprintf(NULL, 0, url_pattern, base_url, api_key_esc, file_id_esc); + char *url_path = malloc((size_t)url_path_len + 1); + snprintf(url_path, (size_t)url_path_len + 1, url_pattern, base_url, api_key_esc, file_id_esc); + free(api_key_esc); + free(file_id_esc); + + memory response_data = {0}; + long http_code = 0; + if (!zb_make_request(url_path, "GET", "Accept: application/json", &response_data, &http_code, error_callback)) { + free(response_data.response); + free(url_path); + return; + } + { + json_object *j_obj = NULL; + if (zb_read_success_json(response_data.response, http_code, &j_obj, error_callback) && success_callback) { + success_callback(zb_file_status_response_from_json(j_obj)); + json_object_put(j_obj); + } + } + free(response_data.response); + free(url_path); +} + +static void p3_bulk_root_delete_file( + ZeroBounce *zb, + const char *base_url, + char *file_id, + OnSuccessCallbackDeleteFile success_callback, + OnErrorCallback error_callback +) { + if (zero_bounce_invalid_api_key(zb, error_callback)) { + return; + } + char *api_key_esc = zb_url_escape(zb->api_key); + char *file_id_esc = zb_url_escape(file_id); + const char *url_pattern = "%s/deletefile?api_key=%s&file_id=%s"; + int url_path_len = snprintf(NULL, 0, url_pattern, base_url, api_key_esc, file_id_esc); + char *url_path = malloc((size_t)url_path_len + 1); + snprintf(url_path, (size_t)url_path_len + 1, url_pattern, base_url, api_key_esc, file_id_esc); + free(api_key_esc); + free(file_id_esc); + + memory response_data = {0}; + long http_code = 0; + if (!zb_make_request(url_path, "GET", "Accept: application/json", &response_data, &http_code, error_callback)) { + free(response_data.response); + free(url_path); + return; + } + { + json_object *j_obj = NULL; + if (zb_read_success_json(response_data.response, http_code, &j_obj, error_callback) && success_callback) { + success_callback(zb_delete_file_response_from_json(j_obj)); + json_object_put(j_obj); + } + } + free(response_data.response); + free(url_path); +} + +typedef struct { + int domain_column; + const EmailFinderSendFileOptions *options; +} EmailFinderMimeCtx; + +static void p3_email_finder_mime_fields(curl_mime *multipart, void *ctx) { + EmailFinderMimeCtx *c = (EmailFinderMimeCtx *)ctx; + p3_mime_add_int(multipart, "domain_column", c->domain_column); + if (c->options) { + p3_mime_add_optional_bool(multipart, "has_header_row", c->options->has_header_row); + if (c->options->first_name_column > 0) { + p3_mime_add_int(multipart, "first_name_column", c->options->first_name_column); + } + if (c->options->last_name_column > 0) { + p3_mime_add_int(multipart, "last_name_column", c->options->last_name_column); + } + if (c->options->middle_name_column > 0) { + p3_mime_add_int(multipart, "middle_name_column", c->options->middle_name_column); + } + if (c->options->full_name_column > 0) { + p3_mime_add_int(multipart, "full_name_column", c->options->full_name_column); + } + } +} + +typedef struct { + int domain_column; + const DomainSearchSendFileOptions *options; +} DomainSearchMimeCtx; + +static void p3_domain_search_mime_fields(curl_mime *multipart, void *ctx) { + DomainSearchMimeCtx *c = (DomainSearchMimeCtx *)ctx; + p3_mime_add_int(multipart, "domain_column", c->domain_column); + if (c->options) { + p3_mime_add_optional_bool(multipart, "has_header_row", c->options->has_header_row); + } +} + +typedef struct { + int email_address_column; +} ListEvaluatorMimeCtx; + +static void p3_list_evaluator_mime_fields(curl_mime *multipart, void *ctx) { + ListEvaluatorMimeCtx *c = (ListEvaluatorMimeCtx *)ctx; + p3_mime_add_int(multipart, "email_address_column", c->email_address_column); +} + +void send_email_finder_file( + ZeroBounce *zb, + char *file_path, + int domain_column, + const EmailFinderSendFileOptions *options, + OnSuccessCallbackSendFile success_callback, + OnErrorCallback error_callback +) { + if (domain_column < 1) { + if (error_callback) { + error_callback(parse_error("Empty parameter: domain_column")); + } + return; + } + char url[512]; + snprintf(url, sizeof(url), "%s/email-finder/sendfile", zb->bulk_api_root_url); + EmailFinderMimeCtx ctx = {domain_column, options}; + p3_bulk_root_multipart( + zb, url, file_path, p3_email_finder_mime_fields, &ctx, success_callback, error_callback + ); +} + +void email_finder_file_status( + ZeroBounce *zb, + char *file_id, + OnSuccessCallbackFileStatus success_callback, + OnErrorCallback error_callback +) { + char base[256]; + snprintf(base, sizeof(base), "%s/email-finder", zb->bulk_api_root_url); + p3_bulk_root_file_status(zb, base, file_id, success_callback, error_callback); +} + +void email_finder_get_file( + ZeroBounce *zb, + char *file_id, + char *local_download_path, + OnSuccessCallbackGetFile success_callback, + OnErrorCallback error_callback +) { + char base[256]; + snprintf(base, sizeof(base), "%s/email-finder", zb->bulk_api_root_url); + p3_bulk_root_get_file(zb, base, file_id, local_download_path, success_callback, error_callback); +} + +void email_finder_delete_file( + ZeroBounce *zb, + char *file_id, + OnSuccessCallbackDeleteFile success_callback, + OnErrorCallback error_callback +) { + char base[256]; + snprintf(base, sizeof(base), "%s/email-finder", zb->bulk_api_root_url); + p3_bulk_root_delete_file(zb, base, file_id, success_callback, error_callback); +} + +void send_domain_search_file( + ZeroBounce *zb, + char *file_path, + int domain_column, + const DomainSearchSendFileOptions *options, + OnSuccessCallbackSendFile success_callback, + OnErrorCallback error_callback +) { + if (domain_column < 1) { + if (error_callback) { + error_callback(parse_error("Empty parameter: domain_column")); + } + return; + } + char url[512]; + snprintf(url, sizeof(url), "%s/domain-search/sendfile", zb->bulk_api_root_url); + DomainSearchMimeCtx ctx = {domain_column, options}; + p3_bulk_root_multipart( + zb, url, file_path, p3_domain_search_mime_fields, &ctx, success_callback, error_callback + ); +} + +void domain_search_file_status( + ZeroBounce *zb, + char *file_id, + OnSuccessCallbackFileStatus success_callback, + OnErrorCallback error_callback +) { + char base[256]; + snprintf(base, sizeof(base), "%s/domain-search", zb->bulk_api_root_url); + p3_bulk_root_file_status(zb, base, file_id, success_callback, error_callback); +} + +void domain_search_get_file( + ZeroBounce *zb, + char *file_id, + char *local_download_path, + OnSuccessCallbackGetFile success_callback, + OnErrorCallback error_callback +) { + char base[256]; + snprintf(base, sizeof(base), "%s/domain-search", zb->bulk_api_root_url); + p3_bulk_root_get_file(zb, base, file_id, local_download_path, success_callback, error_callback); +} + +void domain_search_delete_file( + ZeroBounce *zb, + char *file_id, + OnSuccessCallbackDeleteFile success_callback, + OnErrorCallback error_callback +) { + char base[256]; + snprintf(base, sizeof(base), "%s/domain-search", zb->bulk_api_root_url); + p3_bulk_root_delete_file(zb, base, file_id, success_callback, error_callback); +} + +void send_list_evaluator_file( + ZeroBounce *zb, + char *file_path, + int email_address_column, + OnSuccessCallbackSendFile success_callback, + OnErrorCallback error_callback +) { + if (email_address_column < 1) { + if (error_callback) { + error_callback(parse_error("Empty parameter: email_address_column")); + } + return; + } + char url[512]; + snprintf(url, sizeof(url), "%s/listevaluator/", zb->bulk_api_base_url); + ListEvaluatorMimeCtx ctx = {email_address_column}; + p3_bulk_root_multipart( + zb, url, file_path, p3_list_evaluator_mime_fields, &ctx, success_callback, error_callback + ); +} + +void get_list_evaluator_result( + ZeroBounce *zb, + char *file_id, + OnSuccessCallbackRawJson success_callback, + OnErrorCallback error_callback +) { + if (zero_bounce_invalid_api_key(zb, error_callback)) { + return; + } + char *api_key_esc; + char *file_id_esc; + const char *url_pattern = "%s/%s/?api_key=%s"; + int url_path_len; + char *url_path; + + if (p3_is_blank(file_id)) { + if (error_callback) { + error_callback(parse_error("Empty parameter: file_id")); + } + return; + } + + /* The file id is one path segment, so a slash in it must stay encoded. */ + file_id_esc = p3_escape_path_segment(file_id); + if (!file_id_esc) { + if (error_callback) { + error_callback(parse_error("Invalid parameter: file_id")); + } + return; + } + api_key_esc = zb_url_escape(zb->api_key); + url_path_len = snprintf(NULL, 0, url_pattern, zb->list_evaluator_base_url, file_id_esc, api_key_esc); + url_path = malloc((size_t)url_path_len + 1); + snprintf(url_path, (size_t)url_path_len + 1, url_pattern, zb->list_evaluator_base_url, file_id_esc, api_key_esc); + free(api_key_esc); + free(file_id_esc); + p3_raw_json_get(zb, url_path, success_callback, error_callback); +} + +void send_evaluated_file( + ZeroBounce *zb, + char *file_id, + OnSuccessCallbackSendFile success_callback, + OnErrorCallback error_callback +) { + char *body = NULL; + size_t cap = 0; + char url[512]; + + if (zero_bounce_invalid_api_key(zb, error_callback)) { + return; + } + if (p3_is_blank(file_id)) { + if (error_callback) { + error_callback(parse_error("Empty parameter: file_id")); + } + return; + } + + /* + * LIVE: send-evaluated-file takes the id of a finished evaluation as a + * form field, not an upload. The trailing slash is part of the endpoint. + */ + p3_append_form_field(&body, &cap, "api_key", zb->api_key); + p3_append_form_field(&body, &cap, "file_id", file_id); + snprintf(url, sizeof(url), "%s/send-evaluated-file/", zb->bulk_api_base_url); + p3_post_send_file_form(zb, url, body, success_callback, error_callback); +} + +void add_filter( + ZeroBounce *zb, + char *rule, + char *target, + char *value, + OnSuccessCallbackRawJson success_callback, + OnErrorCallback error_callback +) { + if (zero_bounce_invalid_api_key(zb, error_callback)) { + return; + } + char *body = NULL; + size_t cap = 0; + p3_append_form_field(&body, &cap, "api_key", zb->api_key); + p3_append_form_field(&body, &cap, "rule", rule ? rule : ""); + p3_append_form_field(&body, &cap, "target", target ? target : ""); + p3_append_form_field(&body, &cap, "value", value ? value : ""); + char url[512]; + snprintf(url, sizeof(url), "%s/filters/add", zb->api_base_url); + p3_raw_json_form_post(zb, strdup(url), body, success_callback, error_callback); +} + +void list_filters( + ZeroBounce *zb, + bool use_post, + OnSuccessCallbackRawJson success_callback, + OnErrorCallback error_callback +) { + if (zero_bounce_invalid_api_key(zb, error_callback)) { + return; + } + if (use_post) { + char *body = NULL; + size_t cap = 0; + p3_append_form_field(&body, &cap, "api_key", zb->api_key); + char url[512]; + snprintf(url, sizeof(url), "%s/filters/list", zb->api_base_url); + p3_raw_json_form_post(zb, strdup(url), body, success_callback, error_callback); + } else { + char *api_key_esc = zb_url_escape(zb->api_key); + const char *url_pattern = "%s/filters/list?api_key=%s"; + int url_path_len = snprintf(NULL, 0, url_pattern, zb->api_base_url, api_key_esc); + char *url_path = malloc((size_t)url_path_len + 1); + snprintf(url_path, (size_t)url_path_len + 1, url_pattern, zb->api_base_url, api_key_esc); + free(api_key_esc); + p3_raw_json_get(zb, url_path, success_callback, error_callback); + } +} + +void delete_filter( + ZeroBounce *zb, + char *rule, + char *target, + char *value, + OnSuccessCallbackRawJson success_callback, + OnErrorCallback error_callback +) { + if (zero_bounce_invalid_api_key(zb, error_callback)) { + return; + } + char *body = NULL; + size_t cap = 0; + p3_append_form_field(&body, &cap, "api_key", zb->api_key); + p3_append_form_field(&body, &cap, "rule", rule ? rule : ""); + p3_append_form_field(&body, &cap, "target", target ? target : ""); + p3_append_form_field(&body, &cap, "value", value ? value : ""); + char url[512]; + snprintf(url, sizeof(url), "%s/filters/delete", zb->api_base_url); + p3_raw_json_form_post(zb, strdup(url), body, success_callback, error_callback); +} + +static char *p3_build_validate_s3_body(ZeroBounce *zb, const ValidateS3FileOptions *options) { + char *body = NULL; + size_t cap = 0; + p3_append_form_field(&body, &cap, "file_name", options->file_name ? options->file_name : ""); + p3_append_optional_bool_form(&body, &cap, "has_header_row", options->has_header_row); + p3_append_optional_bool_form(&body, &cap, "remove_duplicates", options->remove_duplicates); + p3_append_optional_bool_form(&body, &cap, "remove_duplicate", options->remove_duplicate); + if (options->email_address_column >= 0) { + char col[16]; + snprintf(col, sizeof(col), "%d", options->email_address_column); + p3_append_form_field(&body, &cap, "email_address_column", col); + } + if (options->return_url && options->return_url[0]) { + p3_append_form_field(&body, &cap, "return_url", options->return_url); + } + if (options->first_name_column > 0) { + char col[16]; + snprintf(col, sizeof(col), "%d", options->first_name_column); + p3_append_form_field(&body, &cap, "first_name_column", col); + } + if (options->last_name_column > 0) { + char col[16]; + snprintf(col, sizeof(col), "%d", options->last_name_column); + p3_append_form_field(&body, &cap, "last_name_column", col); + } + if (options->gender_column > 0) { + char col[16]; + snprintf(col, sizeof(col), "%d", options->gender_column); + p3_append_form_field(&body, &cap, "gender_column", col); + } + if (options->ip_address_column > 0) { + char col[16]; + snprintf(col, sizeof(col), "%d", options->ip_address_column); + p3_append_form_field(&body, &cap, "ip_address_column", col); + } + if (options->allow_phase_2_is_set) { + p3_append_form_field(&body, &cap, "allow_phase_2", options->allow_phase_2 ? "true" : "false"); + } + p3_append_form_field(&body, &cap, "api_key", zb->api_key); + return body; +} + +void validate_s3_file( + ZeroBounce *zb, + const ValidateS3FileOptions *options, + OnSuccessCallbackSendFile success_callback, + OnErrorCallback error_callback +) { + if (zero_bounce_invalid_api_key(zb, error_callback)) { + return; + } + if (!options || !options->file_name || !options->file_name[0]) { + if (error_callback) { + error_callback(parse_error("Empty parameter: file_name")); + } + return; + } + char *body = p3_build_validate_s3_body(zb, options); + char url[512]; + snprintf(url, sizeof(url), "%s/validateS3file", zb->bulk_api_root_url); + p3_post_send_file_form(zb, url, body, success_callback, error_callback); +} + +void score_s3_file( + ZeroBounce *zb, + const ScoreS3FileOptions *options, + OnSuccessCallbackSendFile success_callback, + OnErrorCallback error_callback +) { + if (zero_bounce_invalid_api_key(zb, error_callback)) { + return; + } + if (!options || !options->file_name || !options->file_name[0]) { + if (error_callback) { + error_callback(parse_error("Empty parameter: file_name")); + } + return; + } + char *body = NULL; + size_t cap = 0; + p3_append_form_field(&body, &cap, "file_name", options->file_name); + p3_append_form_field(&body, &cap, "api_key", zb->api_key); + p3_append_optional_bool_form(&body, &cap, "has_header_row", options->has_header_row); + p3_append_optional_bool_form(&body, &cap, "remove_duplicates", options->remove_duplicates); + p3_append_optional_bool_form(&body, &cap, "remove_duplicate", options->remove_duplicate); + if (options->email_address_column >= 0) { + char col[16]; + snprintf(col, sizeof(col), "%d", options->email_address_column); + p3_append_form_field(&body, &cap, "email_address_column", col); + } + char url[512]; + snprintf(url, sizeof(url), "%s/scoreS3file", zb->bulk_api_root_url); + p3_post_send_file_form(zb, url, body, success_callback, error_callback); +} + +void list_s3_files( + ZeroBounce *zb, + const char *bucket, + const char *search, + OnSuccessCallbackRawJson success_callback, + OnErrorCallback error_callback +) { + if (zero_bounce_invalid_api_key(zb, error_callback)) { + return; + } + char *api_key_esc = zb_url_escape(zb->api_key); + char *bucket_esc = (bucket && bucket[0]) ? zb_url_escape(bucket) : NULL; + char *search_esc = (search && search[0]) ? zb_url_escape(search) : NULL; + if (!api_key_esc) { + free(bucket_esc); + free(search_esc); + if (error_callback) { + error_callback(parse_error("Memory allocation failed")); + } + return; + } + const char *url_pattern = "%s/listS3files?api_key=%s%s%s%s%s"; + const char *bucket_part = bucket_esc ? "&bucket=" : ""; + const char *search_part = search_esc ? "&search=" : ""; + int url_path_len = snprintf( + NULL, + 0, + url_pattern, + zb->bulk_api_root_url, + api_key_esc, + bucket_part, + bucket_esc ? bucket_esc : "", + search_part, + search_esc ? search_esc : "" + ); + char *url_path = malloc((size_t)url_path_len + 1); + if (!url_path) { + free(api_key_esc); + free(bucket_esc); + free(search_esc); + if (error_callback) { + error_callback(parse_error("Memory allocation failed")); + } + return; + } + snprintf( + url_path, + (size_t)url_path_len + 1, + url_pattern, + zb->bulk_api_root_url, + api_key_esc, + bucket_part, + bucket_esc ? bucket_esc : "", + search_part, + search_esc ? search_esc : "" + ); + free(api_key_esc); + free(bucket_esc); + free(search_esc); + p3_raw_json_get(zb, url_path, success_callback, error_callback); +} + +void export_s3_file( + ZeroBounce *zb, + char *file_id, + char *export_bucket, + OnSuccessCallbackSendFile success_callback, + OnErrorCallback error_callback +) { + if (zero_bounce_invalid_api_key(zb, error_callback)) { + return; + } + if (!file_id || !file_id[0]) { + if (error_callback) { + error_callback(parse_error("Empty parameter: file_id")); + } + return; + } + if (!export_bucket || !export_bucket[0]) { + if (error_callback) { + error_callback(parse_error("Empty parameter: export_bucket")); + } + return; + } + char *body = NULL; + size_t cap = 0; + p3_append_form_field(&body, &cap, "file_id", file_id); + p3_append_form_field(&body, &cap, "export_bucket", export_bucket); + p3_append_form_field(&body, &cap, "api_key", zb->api_key); + char url[512]; + snprintf(url, sizeof(url), "%s/exportS3file", zb->bulk_api_root_url); + p3_post_send_file_form(zb, url, body, success_callback, error_callback); +} + +/* ------------------------------------------------------------------ */ +/* S3 email finder / domain search, SFTP/FTP bulk, Inbox Placement */ +/* Ported from the Python SDK (b0444f8 and 407f0a8). */ +/* ------------------------------------------------------------------ */ + +void zero_bounce_set_list_evaluator_base_url(ZeroBounce *zb, const char *url) { + if (zb && url) { + zb->list_evaluator_base_url = url; + } +} + +void zero_bounce_set_inbox_placement_base_url(ZeroBounce *zb, const char *url) { + if (zb && url) { + zb->inbox_placement_base_url = url; + } +} + +ZBBulkFileJobOptions new_bulk_file_job_options(void) { + ZBBulkFileJobOptions o; + o.file_name = NULL; + o.has_header_row = true; + o.remove_duplicates = -1; + o.return_url = NULL; + return o; +} + +ZBCreateInboxPlacementTestOptions new_create_inbox_placement_test_options(void) { + ZBCreateInboxPlacementTestOptions o; + o.test_type = -1; + o.sender_email = NULL; + o.webhook_url = NULL; + return o; +} + +ZBListInboxPlacementTestsOptions new_list_inbox_placement_tests_options(void) { + ZBListInboxPlacementTestsOptions o; + o.status = NULL; + o.page = -1; + o.page_size = -1; + return o; +} + +static bool p3_is_blank(const char *value) { + const char *p; + if (!value) { + return true; + } + for (p = value; *p; p++) { + if (!isspace((unsigned char)*p)) { + return false; + } + } + return true; +} + +/* + * 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. + * + * LIVE: file_name and return_url are required on every one of these jobs, + * validateSFTPfile included, although the docs call its file_name optional. + * Without either the API answers "Empty parameter: file_name" / + * "Empty parameter: return_url", so both are checked before sending. + */ +static void p3_bulk_root_file_job( + ZeroBounce *zb, + const char *path, + const ZBBulkFileJobOptions *options, + OnSuccessCallbackSendFile success_callback, + OnErrorCallback error_callback +) { + char *body = NULL; + size_t cap = 0; + char url[512]; + + if (zero_bounce_invalid_api_key(zb, error_callback)) { + return; + } + if (!options || p3_is_blank(options->file_name)) { + if (error_callback) { + error_callback(parse_error("Empty parameter: file_name")); + } + return; + } + if (p3_is_blank(options->return_url)) { + if (error_callback) { + error_callback(parse_error("Empty parameter: return_url")); + } + return; + } + + p3_append_form_field(&body, &cap, "api_key", zb->api_key); + p3_append_form_field(&body, &cap, "has_header_row", options->has_header_row ? "true" : "false"); + p3_append_form_field(&body, &cap, "file_name", options->file_name); + p3_append_optional_bool_form(&body, &cap, "remove_duplicates", options->remove_duplicates); + p3_append_form_field(&body, &cap, "return_url", options->return_url); + + snprintf(url, sizeof(url), "%s%s", zb->bulk_api_root_url, path); + p3_post_send_file_form(zb, url, body, success_callback, error_callback); +} + +static void p3_export_bulk_file( + ZeroBounce *zb, + const char *path, + const char *destination_field, + const char *file_id, + const char *destination, + OnSuccessCallbackSendFile success_callback, + OnErrorCallback error_callback +) { + char *body = NULL; + size_t cap = 0; + char url[512]; + + if (zero_bounce_invalid_api_key(zb, error_callback)) { + return; + } + if (p3_is_blank(file_id)) { + if (error_callback) { + error_callback(parse_error("Empty parameter: file_id")); + } + return; + } + if (destination && p3_is_blank(destination)) { + if (error_callback) { + char message[64]; + snprintf(message, sizeof(message), "Empty parameter: %s", destination_field); + error_callback(parse_error(message)); + } + return; + } + + p3_append_form_field(&body, &cap, "api_key", zb->api_key); + p3_append_form_field(&body, &cap, "file_id", file_id); + if (destination) { + p3_append_form_field(&body, &cap, destination_field, destination); + } + + snprintf(url, sizeof(url), "%s%s", zb->bulk_api_root_url, path); + p3_post_send_file_form(zb, url, body, success_callback, error_callback); +} + +void email_finder_s3_file( + ZeroBounce *zb, + const ZBBulkFileJobOptions *options, + OnSuccessCallbackSendFile success_callback, + OnErrorCallback error_callback +) { + p3_bulk_root_file_job(zb, "/emailFinderS3file", options, success_callback, error_callback); +} + +void domain_search_s3_file( + ZeroBounce *zb, + const ZBBulkFileJobOptions *options, + OnSuccessCallbackSendFile success_callback, + OnErrorCallback error_callback +) { + p3_bulk_root_file_job(zb, "/domainSearchS3file", options, success_callback, error_callback); +} + +void validate_sftp_file( + ZeroBounce *zb, + const ZBBulkFileJobOptions *options, + OnSuccessCallbackSendFile success_callback, + OnErrorCallback error_callback +) { + /* The one job whose file_name the docs make optional. */ + p3_bulk_root_file_job(zb, "/validateSFTPfile", options, success_callback, error_callback); +} + +void score_sftp_file( + ZeroBounce *zb, + const ZBBulkFileJobOptions *options, + OnSuccessCallbackSendFile success_callback, + OnErrorCallback error_callback +) { + p3_bulk_root_file_job(zb, "/scoreSFTPfile", options, success_callback, error_callback); +} + +void email_finder_sftp_file( + ZeroBounce *zb, + const ZBBulkFileJobOptions *options, + OnSuccessCallbackSendFile success_callback, + OnErrorCallback error_callback +) { + p3_bulk_root_file_job(zb, "/emailFinderSFTPfile", options, success_callback, error_callback); +} + +void domain_search_sftp_file( + ZeroBounce *zb, + const ZBBulkFileJobOptions *options, + OnSuccessCallbackSendFile success_callback, + OnErrorCallback error_callback +) { + p3_bulk_root_file_job(zb, "/domainSearchSFTPfile", options, success_callback, error_callback); +} + +void export_sftp_file( + ZeroBounce *zb, + const char *file_id, + const char *export_path, + OnSuccessCallbackSendFile success_callback, + OnErrorCallback error_callback +) { + p3_export_bulk_file( + zb, "/exportSFTPfile", "export_path", file_id, export_path, + success_callback, error_callback + ); +} + +/* + * One Inbox Placement Tester call. + * + * Unlike the rest of the SDK this one authenticates with an Authorization: + * Bearer header, sends camelCase JSON bodies, and uses PUT and DELETE as well + * as GET and POST, so it builds the request itself. + * + * LIVE: 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. + */ +static void p3_inbox_placement_request( + ZeroBounce *zb, + const char *method, + const char *path, + const char *extra_query, + const char *body, + OnSuccessCallbackRawJson success_callback, + OnErrorCallback error_callback +) { + char *url_path; + char *auth_header; + int url_len; + int auth_len; + const char *query = extra_query ? extra_query : ""; + memory response_data = {0}; + long http_code = 0; + + if (zero_bounce_invalid_api_key(zb, error_callback)) { + return; + } + + /* The caller builds the query with a leading '&'; without an api_key in + front of it, the first separator has to become a '?'. */ + if (query[0] == '&') { + query++; + } + url_len = snprintf( + NULL, 0, "%s%s%s%s", + zb->inbox_placement_base_url, path, query[0] ? "?" : "", query + ); + url_path = malloc((size_t)url_len + 1); + if (!url_path) { + fprintf(stderr, "Memory allocation failed.\n"); + exit(EXIT_FAILURE); + } + snprintf( + url_path, (size_t)url_len + 1, "%s%s%s%s", + zb->inbox_placement_base_url, path, query[0] ? "?" : "", query + ); + + auth_len = snprintf(NULL, 0, "Authorization: Bearer %s", zb->api_key); + auth_header = malloc((size_t)auth_len + 1); + if (!auth_header) { + free(url_path); + fprintf(stderr, "Memory allocation failed.\n"); + exit(EXIT_FAILURE); + } + snprintf(auth_header, (size_t)auth_len + 1, "Authorization: Bearer %s", zb->api_key); + + if (!zb_make_request_with_extra_header( + url_path, method, body, "Content-Type: application/json", auth_header, + &response_data, &http_code, error_callback)) { + free(auth_header); + free(response_data.response); + free(url_path); + return; + } + + /* DELETE answers with an empty body. */ + if (http_code <= 299 && (!response_data.response || response_data.response[0] == '\0')) { + if (success_callback) { + success_callback("{}"); + } + } else { + p3_send_json_success(&response_data, http_code, success_callback, error_callback); + } + free(auth_header); + free(response_data.response); + free(url_path); +} + +/* + * URL-encodes a value as a single path segment. + * + * zb_url_escape is a query encoder and turns a space into '+', which is a + * literal plus inside a path, so each one becomes %20 here. + */ +static char *p3_escape_path_segment(const char *value) { + char *escaped; + /* Everything else, slashes included, is percent-encoded and stays inside + the segment; "." and ".." would walk the request elsewhere. */ + if (value && (strcmp(value, ".") == 0 || strcmp(value, "..") == 0)) { + return NULL; + } + escaped = zb_url_escape(value); + size_t len = strlen(escaped); + char *out = malloc(len * 3 + 1); + size_t i; + size_t j = 0; + + if (!out) { + free(escaped); + fprintf(stderr, "Memory allocation failed.\n"); + exit(EXIT_FAILURE); + } + for (i = 0; i < len; i++) { + if (escaped[i] == '+') { + out[j++] = '%'; + out[j++] = '2'; + out[j++] = '0'; + } else { + out[j++] = escaped[i]; + } + } + out[j] = '\0'; + free(escaped); + return out; +} + +static char *p3_inbox_placement_test_segment(const char *test_id, OnErrorCallback error_callback) { + char *segment; + if (p3_is_blank(test_id)) { + if (error_callback) { + error_callback(parse_error("Empty parameter: test_id")); + } + return NULL; + } + segment = p3_escape_path_segment(test_id); + if (!segment && error_callback) { + error_callback(parse_error("Invalid parameter: test_id")); + } + return segment; +} + +static void p3_json_append(char **body, size_t *cap, const char *fragment) { + size_t needed = (*body ? strlen(*body) : 0) + strlen(fragment) + 1; + if (needed > *cap) { + size_t new_cap = needed * 2; + char *grown = realloc(*body, new_cap); + if (!grown) { + fprintf(stderr, "Memory allocation failed.\n"); + exit(EXIT_FAILURE); + } + if (!*body) { + grown[0] = '\0'; + } + *body = grown; + *cap = new_cap; + } + strcat(*body, fragment); +} + +void get_inbox_placement_providers( + ZeroBounce *zb, + OnSuccessCallbackRawJson success_callback, + OnErrorCallback error_callback +) { + p3_inbox_placement_request(zb, "GET", "/providers", NULL, NULL, success_callback, error_callback); +} + +void get_inbox_placement_test_types( + ZeroBounce *zb, + OnSuccessCallbackRawJson success_callback, + OnErrorCallback error_callback +) { + p3_inbox_placement_request(zb, "GET", "/test-types", NULL, NULL, success_callback, error_callback); +} + +void create_inbox_placement_test( + ZeroBounce *zb, + const char *name, + const ZBCreateInboxPlacementTestOptions *options, + OnSuccessCallbackRawJson success_callback, + OnErrorCallback error_callback +) { + char *body = NULL; + size_t cap = 0; + char buf[1024]; + + if (p3_is_blank(name)) { + if (error_callback) { + error_callback(parse_error("Empty parameter: name")); + } + return; + } + + /* camelCase JSON, with unset fields left out. */ + snprintf(buf, sizeof(buf), "{\"name\":\"%s\"", name); + p3_json_append(&body, &cap, buf); + if (options) { + if (options->test_type >= 0) { + snprintf(buf, sizeof(buf), ",\"testType\":%d", options->test_type); + p3_json_append(&body, &cap, buf); + } + if (options->sender_email) { + snprintf(buf, sizeof(buf), ",\"senderEmail\":\"%s\"", options->sender_email); + p3_json_append(&body, &cap, buf); + } + if (options->webhook_url) { + snprintf(buf, sizeof(buf), ",\"webhookUrl\":\"%s\"", options->webhook_url); + p3_json_append(&body, &cap, buf); + } + } + p3_json_append(&body, &cap, "}"); + + p3_inbox_placement_request(zb, "POST", "/test", NULL, body, success_callback, error_callback); + free(body); +} + +void get_inbox_placement_test( + ZeroBounce *zb, + const char *test_id, + OnSuccessCallbackRawJson success_callback, + OnErrorCallback error_callback +) { + char path[512]; + char *segment = p3_inbox_placement_test_segment(test_id, error_callback); + if (!segment) { + return; + } + snprintf(path, sizeof(path), "/test/%s", segment); + free(segment); + p3_inbox_placement_request(zb, "GET", path, NULL, NULL, success_callback, error_callback); +} + +void list_inbox_placement_tests( + ZeroBounce *zb, + const ZBListInboxPlacementTestsOptions *options, + OnSuccessCallbackRawJson success_callback, + OnErrorCallback error_callback +) { + char query[512]; + size_t used = 0; + query[0] = '\0'; + if (options) { + if (options->status) { + char *escaped = zb_url_escape(options->status); + used += (size_t)snprintf(query + used, sizeof(query) - used, "&status=%s", escaped); + free(escaped); + } + if (options->page >= 0) { + used += (size_t)snprintf(query + used, sizeof(query) - used, "&page=%d", options->page); + } + if (options->page_size >= 0) { + snprintf(query + used, sizeof(query) - used, "&pageSize=%d", options->page_size); + } + } + /* + * LIVE: the listing is GET /tests (plural). /test is the single-test path + * and answers 'Method "GET" not allowed'. + */ + p3_inbox_placement_request(zb, "GET", "/tests", query, NULL, success_callback, error_callback); +} + +void delete_inbox_placement_test( + ZeroBounce *zb, + const char *test_id, + OnSuccessCallbackRawJson success_callback, + OnErrorCallback error_callback +) { + char path[512]; + char *segment = p3_inbox_placement_test_segment(test_id, error_callback); + if (!segment) { + return; + } + snprintf(path, sizeof(path), "/test/%s", segment); + free(segment); + p3_inbox_placement_request(zb, "DELETE", path, NULL, NULL, success_callback, error_callback); +} + +void get_inbox_placement_webhook( + ZeroBounce *zb, + OnSuccessCallbackRawJson success_callback, + OnErrorCallback error_callback +) { + p3_inbox_placement_request(zb, "GET", "/webhook", NULL, NULL, success_callback, error_callback); +} + +void set_inbox_placement_webhook( + ZeroBounce *zb, + const char *webhook_url, + OnSuccessCallbackRawJson success_callback, + OnErrorCallback error_callback +) { + char body[1024]; + if (p3_is_blank(webhook_url)) { + if (error_callback) { + error_callback(parse_error("Empty parameter: webhook_url")); + } + return; + } + snprintf(body, sizeof(body), "{\"webhookUrl\":\"%s\"}", webhook_url); + p3_inbox_placement_request(zb, "PUT", "/webhook", NULL, body, success_callback, error_callback); +} diff --git a/src/ZBErrorResponse.c b/src/ZBErrorResponse.c index f76379d..17aec7c 100644 --- a/src/ZBErrorResponse.c +++ b/src/ZBErrorResponse.c @@ -1,13 +1,17 @@ +#include #include +#include #include +#include -#include "ZeroBounce/ZBErrorResponse.h" +#include "ZeroBounce/zb_internal.h" ZBErrorResponse new_zb_error_response() { ZBErrorResponse response; response.success = false; response.errors = string_vector_init(); + response.status_code = 0; return response; } @@ -32,7 +36,12 @@ char* zb_error_response_to_string(ZBErrorResponse* response) { } ZBErrorResponse parse_error(const char* error) { + return parse_error_with_status(error, 0); +} + +ZBErrorResponse parse_error_with_status(const char* error, long status_code) { ZBErrorResponse response = new_zb_error_response(); + response.status_code = status_code; if (!error || strlen(error) == 0) { return response; @@ -89,6 +98,319 @@ ZBErrorResponse parse_error(const char* error) { return response; } +static const char *zb_skip_ws(const char *value) { + if (!value) { + return ""; + } + while (*value && isspace((unsigned char)*value)) { + value++; + } + return value; +} + +static char *zb_dup(const char *value) { + char *copy = strdup(value ? value : ""); + return copy ? copy : strdup(""); +} + +static int zb_starts_with(const char *value, const char *prefix) { + size_t n = strlen(prefix); + return strncmp(value, prefix, n) == 0; +} + +static char *zb_strip_error_prefix(const char *value) { + char *message = zb_dup(zb_skip_ws(value)); + if (!message) { + return strdup(""); + } + for (;;) { + char *start = message; + while (*start && isspace((unsigned char)*start)) { + start++; + } + if (start != message) { + memmove(message, start, strlen(start) + 1); + } + if (zb_starts_with(message, "Error:")) { + memmove(message, message + 6, strlen(message + 6) + 1); + continue; + } + if (zb_starts_with(message, "[Error]:")) { + memmove(message, message + 8, strlen(message + 8) + 1); + continue; + } + return message; + } +} + +static void zb_lower_prefix(const char *value, char *out, size_t out_len) { + size_t i = 0; + if (!value) { + out[0] = '\0'; + return; + } + for (; value[i] && i + 1 < out_len && i < 2000; i++) { + out[i] = (char)tolower((unsigned char)value[i]); + } + out[i] = '\0'; +} + +bool zb_body_looks_like_html(const char *body) { + char lower[2001]; + zb_lower_prefix(body, lower, sizeof(lower)); + return strstr(lower, "'); + if (!open_end) { + return NULL; + } + close = strstr(open_end, ""); + if (!close) { + return NULL; + } + len = (size_t)(close - open_end - 1); + title = (char *)malloc(len + 1); + if (!title) { + return NULL; + } + memcpy(title, body + (open_end - lower) + 1, len); + title[len] = '\0'; + return title; +} + +static bool zb_json_key_has_error_or_message(json_object *object) { + json_object_object_foreach(object, key, val) { + (void)val; + if (strstr(key, "error") || strstr(key, "message")) { + return true; + } + } + return false; +} + +char *zb_message_from_body(const char *body, long status_code) { + const char *trimmed = zb_skip_ws(body); + char status_buf[16]; + if (!trimmed[0]) { + snprintf(status_buf, sizeof(status_buf), "%ld", status_code); + char *empty = (char *)malloc(80); + if (!empty) { + return strdup("Empty response from ZeroBounce"); + } + snprintf(empty, 80, "Empty response from ZeroBounce (HTTP %s)", status_buf); + return empty; + } + if (zb_body_looks_like_html(body)) { + char *title = zb_html_title(body); + char *message; + if (!title || !title[0]) { + free(title); + return strdup("ZeroBounce returned an HTML error page instead of an API response."); + } + message = (char *)malloc(strlen(title) + 96); + if (!message) { + free(title); + return strdup("ZeroBounce returned an HTML error page instead of an API response."); + } + snprintf( + message, + strlen(title) + 96, + "ZeroBounce returned an HTML error page (\"%s\") instead of an API response.", + title + ); + free(title); + return message; + } + if (trimmed[0] == '{' || trimmed[0] == '[') { + json_object *parsed = json_tokener_parse(trimmed); + if (!parsed) { + return strdup("ZeroBounce returned a response that is not valid JSON."); + } + if (json_object_is_type(parsed, json_type_object)) { + json_object *title = json_object_object_get(parsed, "title"); + if (title && json_object_is_type(title, json_type_string)) { + const char *title_text = json_object_get_string(title); + json_object *cf = json_object_object_get(parsed, "cloudflare_error"); + bool access_denied = title_text && + (strstr(title_text, "Access denied") || strstr(title_text, "1020")); + bool cloudflare = cf && json_object_is_type(cf, json_type_boolean) && json_object_get_boolean(cf); + if (access_denied && (cloudflare || !zb_json_key_has_error_or_message(parsed))) { + char *copy = zb_dup(title_text); + json_object_put(parsed); + return copy; + } + } + } + json_object_put(parsed); + return zb_dup(trimmed); + } + if (strlen(trimmed) > 1000) { + return strdup("ZeroBounce returned a response that is not valid JSON."); + } + { + char *message = zb_strip_error_prefix(trimmed); + if (!message || !message[0]) { + free(message); + snprintf(status_buf, sizeof(status_buf), "%ld", status_code); + message = (char *)malloc(80); + if (!message) { + return strdup("Empty response from ZeroBounce"); + } + snprintf(message, 80, "Empty response from ZeroBounce (HTTP %s)", status_buf); + } + return message; + } +} + +char *zb_redact_api_key(const char *text) { + const char *src = text ? text : ""; + size_t len = strlen(src); + char *out = malloc(len + 16); + size_t i = 0; + size_t j = 0; + + if (!out) { + return NULL; + } + while (i < len) { + if (strncmp(src + i, "api_key=", 8) == 0) { + memcpy(out + j, "api_key=REDACTED", 16); + j += 16; + i += 8; + /* Skip the value, up to the next separator. */ + while (i < len && src[i] != '&' && src[i] != ' ' && src[i] != '"') { + i++; + } + continue; + } + out[j++] = src[i++]; + } + out[j] = '\0'; + return out; +} + +void zb_report_body_error(const char *body, long status_code, OnErrorCallback error_callback) { + char *message; + char *safe; + if (!error_callback) { + return; + } + if (status_code >= 300 && status_code < 400) { + /* FOLLOWLOCATION is off: a redirect could send the api_key to another + host, so it is reported rather than followed. */ + error_callback(parse_error_with_status( + "ZeroBounce returned a redirect, which this client does not follow.", status_code + )); + return; + } + message = zb_message_from_body(body, status_code); + safe = zb_redact_api_key(message); + error_callback(parse_error_with_status(safe ? safe : message, status_code)); + free(safe); + free(message); +} + +bool zb_success_means_failure(const char *body) { + const char *text = zb_skip_ws(body ? body : ""); + json_object *parsed; + json_object *success; + bool failed = false; + + if (text[0] != '{') { + return false; + } + parsed = json_tokener_parse(text); + if (!parsed) { + return false; + } + if (json_object_object_get_ex(parsed, "success", &success)) { + if (json_object_is_type(success, json_type_boolean)) { + failed = !json_object_get_boolean(success); + } else if (json_object_is_type(success, json_type_string)) { + const char *value = json_object_get_string(success); + failed = value && (strcasecmp(value, "false") == 0); + } + } + json_object_put(parsed); + return failed; +} + +bool zb_read_success_json(const char *body, long status_code, json_object **out, OnErrorCallback error_callback) { + const char *text = body ? body : ""; + json_object *parsed; + if (!out) { + return false; + } + *out = NULL; + /* + * LIVE: a "success": false field on an HTTP 200 is a failure, not a + * result. getfile and deletefile report an unknown or deleted file id + * that way, and the caller should not have to inspect a flag to notice. + */ + if (status_code > 299 || zb_body_looks_like_html(text) || + zb_skip_ws(text)[0] == '\0' || zb_success_means_failure(text)) { + zb_report_body_error(text, status_code, error_callback); + return false; + } + parsed = json_tokener_parse(text); + if (!parsed) { + zb_report_body_error(text, status_code, error_callback); + return false; + } + *out = parsed; + return true; +} + +bool zb_batch_has_account_error(const json_object *batch, char **message_out) { + json_object *errors = NULL; + size_t i; + size_t count; + if (message_out) { + *message_out = NULL; + } + if (!batch || !json_object_object_get_ex(batch, "errors", &errors) || + !json_object_is_type(errors, json_type_array)) { + return false; + } + count = json_object_array_length(errors); + for (i = 0; i < count; i++) { + json_object *item = json_object_array_get_idx(errors, i); + json_object *address = item ? json_object_object_get(item, "email_address") : NULL; + const char *address_text = address && json_object_is_type(address, json_type_string) + ? json_object_get_string(address) : NULL; + if (address_text && strcmp(address_text, "all") == 0) { + json_object *error = json_object_object_get(item, "error"); + const char *error_text = error && json_object_is_type(error, json_type_string) + ? json_object_get_string(error) : NULL; + if (message_out) { + *message_out = zb_dup( + error_text && error_text[0] + ? error_text + : "Invalid API Key or your account ran out of credits" + ); + } + return true; + } + } + return false; +} + int compare_zb_error_response(const ZBErrorResponse* response1, const ZBErrorResponse* response2) { if (response1->success != response2->success) { return 0; diff --git a/src/ZBGetApiUsageResponse.c b/src/ZBGetApiUsageResponse.c index 3265879..a6160af 100644 --- a/src/ZBGetApiUsageResponse.c +++ b/src/ZBGetApiUsageResponse.c @@ -42,6 +42,8 @@ ZBGetApiUsageResponse new_zb_get_api_usage_response() { response.sub_status_accept_all = 0; response.sub_status_gold = 0; response.sub_status_role_based_accept_all = 0; + response.sub_status_leading_period_removed = 0; + response.sub_status_alias_address = 0; response.start_date = ""; response.end_date = ""; response.error = ""; @@ -86,6 +88,8 @@ char* zb_get_api_usage_response_to_string(ZBGetApiUsageResponse* response) { ", sub_status_accept_all=%d" ", sub_status_gold=%d" ", sub_status_role_based_accept_all=%d" + ", sub_status_leading_period_removed=%d" + ", sub_status_alias_address=%d" ", start_date='%s'" ", end_date='%s'" ", error='%s'" @@ -128,6 +132,8 @@ char* zb_get_api_usage_response_to_string(ZBGetApiUsageResponse* response) { response->sub_status_accept_all, response->sub_status_gold, response->sub_status_role_based_accept_all, + response->sub_status_leading_period_removed, + response->sub_status_alias_address, response->start_date, response->end_date, response->error @@ -179,6 +185,8 @@ char* zb_get_api_usage_response_to_string(ZBGetApiUsageResponse* response) { response->sub_status_accept_all, response->sub_status_gold, response->sub_status_role_based_accept_all, + response->sub_status_leading_period_removed, + response->sub_status_alias_address, response->start_date, response->end_date, response->error @@ -205,7 +213,7 @@ ZBGetApiUsageResponse zb_get_api_usage_response_from_json(const json_object* j) r.sub_status_mail_server_temporary_error = *(int*)get_json_value(j, json_type_int, "sub_status_mail_server_temporary_error", &(int){0}); r.sub_status_mail_server_did_not_respond = *(int*)get_json_value(j, json_type_int, "sub_status_mail_server_did_not_respond", &(int){0}); r.sub_status_grey_listed = *(int*)get_json_value(j, json_type_int, "sub_status_greylisted", &(int){0}); - r.sub_status_anti_spam_system = *(int*)get_json_value(j, json_type_int, "sub_status_anti_spam_system", &(int){0}); + r.sub_status_anti_spam_system = *(int*)get_json_value(j, json_type_int, "sub_status_antispam_system", &(int){0}); r.sub_status_does_not_accept_mail = *(int*)get_json_value(j, json_type_int, "sub_status_does_not_accept_mail", &(int){0}); r.sub_status_exception_occurred = *(int*)get_json_value(j, json_type_int, "sub_status_exception_occurred", &(int){0}); r.sub_status_failed_syntax_check = *(int*)get_json_value(j, json_type_int, "sub_status_failed_syntax_check", &(int){0}); @@ -224,6 +232,8 @@ ZBGetApiUsageResponse zb_get_api_usage_response_from_json(const json_object* j) r.sub_status_accept_all = *(int*)get_json_value(j, json_type_int, "sub_status_accept_all", &(int){0}); r.sub_status_gold = *(int*)get_json_value(j, json_type_int, "sub_status_gold", &(int){0}); r.sub_status_role_based_accept_all = *(int*)get_json_value(j, json_type_int, "sub_status_role_based_accept_all", &(int){0}); + r.sub_status_leading_period_removed = *(int*)get_json_value(j, json_type_int, "sub_status_leading_period_removed", &(int){0}); + r.sub_status_alias_address = *(int*)get_json_value(j, json_type_int, "sub_status_alias_address", &(int){0}); r.start_date = *(char**)get_json_value(j, json_type_string, "start_date", &(char*){""}); r.end_date = *(char**)get_json_value(j, json_type_string, "end_date", &(char*){""}); r.error = *(char**)get_json_value(j, json_type_string, "error", &(char*){""}); @@ -267,6 +277,8 @@ int compare_zb_get_api_usage_response(const ZBGetApiUsageResponse* response1, co response1->sub_status_accept_all == response2->sub_status_accept_all && response1->sub_status_gold == response2->sub_status_gold && response1->sub_status_role_based_accept_all == response2->sub_status_role_based_accept_all && + response1->sub_status_leading_period_removed == response2->sub_status_leading_period_removed && + response1->sub_status_alias_address == response2->sub_status_alias_address && strcmp(response1->start_date, response2->start_date) == 0 && strcmp(response1->end_date, response2->end_date) == 0 && strcmp(response1->error, response2->error) == 0; diff --git a/src/ZBScoreEmailResponse.c b/src/ZBScoreEmailResponse.c new file mode 100644 index 0000000..a60b67a --- /dev/null +++ b/src/ZBScoreEmailResponse.c @@ -0,0 +1,83 @@ +#include +#include + +#include "ZeroBounce/ZBScoreEmailResponse.h" +#include "ZeroBounce/utils.h" + +ZBScoreEmailResponse new_zb_score_email_response() { + ZBScoreEmailResponse response; + response.email = ""; + response.score = 0; + response.score_is_set = false; + return response; +} + +char* zb_score_email_response_to_string(ZBScoreEmailResponse* response) { + const char *serialization = "ZBScoreEmailResponse{" + "email='%s', " + "score=%d, " + "score_is_set=%d" + "}"; + + int size = snprintf( + NULL, 0, serialization, + response->email, + response->score, + response->score_is_set ? 1 : 0 + ); + + if (size < 0) { + return NULL; + } + + char* buffer = (char*) malloc(size + 1); + if (!buffer) { + return NULL; + } + + snprintf( + buffer, size + 1, serialization, + response->email, + response->score, + response->score_is_set ? 1 : 0 + ); + return buffer; +} + +static bool parse_score_field(const json_object* j, int* score_out) { + json_object* score_obj = NULL; + if (!json_object_object_get_ex(j, "score", &score_obj) || score_obj == NULL) { + return false; + } + if (json_object_is_type(score_obj, json_type_int)) { + *score_out = json_object_get_int(score_obj); + return true; + } + if (json_object_is_type(score_obj, json_type_string)) { + *score_out = atoi(json_object_get_string(score_obj)); + return true; + } + return false; +} + +ZBScoreEmailResponse zb_score_email_response_from_json(const json_object* j) { + ZBScoreEmailResponse r = new_zb_score_email_response(); + + r.email = *(char**)get_json_value(j, json_type_string, "email", &(char*){""}); + int score = 0; + if (parse_score_field(j, &score)) { + r.score = score; + r.score_is_set = true; + } + + return r; +} + +int compare_zb_score_email_response( + const ZBScoreEmailResponse* response1, + const ZBScoreEmailResponse* response2 +) { + return strcmp(response1->email, response2->email) == 0 && + response1->score == response2->score && + response1->score_is_set == response2->score_is_set; +} diff --git a/src/ZBValidateBatchResponse.c b/src/ZBValidateBatchResponse.c index 97fa6ea..771e884 100644 --- a/src/ZBValidateBatchResponse.c +++ b/src/ZBValidateBatchResponse.c @@ -117,6 +117,8 @@ void validate_response_vector_append(ValidateResponseVector* vector, const ZBVal vector->data[vector->size].domain = strdup(response.domain); vector->data[vector->size].did_you_mean = strdup(response.did_you_mean); vector->data[vector->size].domain_age_days = strdup(response.domain_age_days); + vector->data[vector->size].active_in_days = strdup(response.active_in_days); + vector->data[vector->size].active_first_seen = strdup(response.active_first_seen); vector->data[vector->size].free_email = response.free_email; vector->data[vector->size].mx_found = response.mx_found; vector->data[vector->size].catchall_domain = response.catchall_domain; @@ -144,6 +146,8 @@ void validate_response_vector_free(ValidateResponseVector* vector) { free(vector->data[i].domain); free(vector->data[i].did_you_mean); free(vector->data[i].domain_age_days); + free(vector->data[i].active_in_days); + free(vector->data[i].active_first_seen); free(vector->data[i].mx_record); free(vector->data[i].smtp_provider); free(vector->data[i].first_name); diff --git a/src/ZBValidateRespone.c b/src/ZBValidateRespone.c index 8643d14..eafcabb 100644 --- a/src/ZBValidateRespone.c +++ b/src/ZBValidateRespone.c @@ -71,6 +71,8 @@ ZBValidateSubStatus sub_status_from_string(const char* string) { return Toxic; } else if (strcmp(string, "accept_all") == 0) { return AcceptAll; + } else if (strcmp(string, "ai_agent_mailbox") == 0) { + return AiAgentMailbox; } return None; @@ -86,6 +88,8 @@ ZBValidateResponse new_zb_validate_response() { response.domain = ""; response.did_you_mean = ""; response.domain_age_days = ""; + response.active_in_days = ""; + response.active_first_seen = ""; response.free_email = false; response.mx_found = false; response.mx_record = ""; @@ -204,6 +208,8 @@ ZBValidateResponse zb_validate_response_from_json(const json_object* j) { r.domain = *(char**)get_json_value(j, json_type_string, "domain", &(char*){""}); r.did_you_mean = *(char**)get_json_value(j, json_type_string, "did_you_mean", &(char*){""}); r.domain_age_days = *(char**)get_json_value(j, json_type_string, "domain_age_days", &(char*){""}); + r.active_in_days = *(char**)get_json_value(j, json_type_string, "active_in_days", &(char*){""}); + r.active_first_seen = *(char**)get_json_value(j, json_type_string, "active_first_seen", &(char*){""}); r.free_email = *(bool*)get_json_value(j, json_type_boolean, "free_email", &(bool){false}); if (strcasecmp(*(char**)get_json_value(j, json_type_string, "mx_found", &(char*){"false"}), "true") == 0) { @@ -242,6 +248,8 @@ int compare_zb_validate_response(const ZBValidateResponse* response1, const ZBVa strcmp(response1->domain, response2->domain) == 0 && strcmp(response1->did_you_mean, response2->did_you_mean) == 0 && strcmp(response1->domain_age_days, response2->domain_age_days) == 0 && + strcmp(response1->active_in_days, response2->active_in_days) == 0 && + strcmp(response1->active_first_seen, response2->active_first_seen) == 0 && response1->free_email == response2->free_email && response1->mx_found == response2->mx_found && strcmp(response1->mx_record, response2->mx_record) == 0 && diff --git a/src/ZeroBounce.c b/src/ZeroBounce.c index 79c8029..fa92e15 100644 --- a/src/ZeroBounce.c +++ b/src/ZeroBounce.c @@ -3,6 +3,7 @@ #include #include #include +#include #include #include @@ -19,19 +20,32 @@ #include #include "ZeroBounce/ZeroBounce.h" +#include "ZeroBounce/zb_internal.h" static ZBHttpPerformFn s_http_perform = NULL; static char s_last_request_url_for_test[1024] = {0}; +static char s_last_post_body_for_test[4096] = {0}; void zero_bounce_set_http_perform_for_test(ZBHttpPerformFn fn) { s_http_perform = fn; } +int zb_http_perform_if_mock(memory *response_data, long *http_code, char **content_type_out) { + if (!s_http_perform) { + return 0; + } + return s_http_perform(response_data, http_code, content_type_out); +} + const char* zero_bounce_get_last_request_url_for_test(void) { return s_last_request_url_for_test[0] ? s_last_request_url_for_test : NULL; } -static void zero_bounce_record_request_url_for_test(const char* url) { +const char* zero_bounce_get_last_post_body_for_test(void) { + return s_last_post_body_for_test[0] ? s_last_post_body_for_test : NULL; +} + +void zero_bounce_record_request_url_for_test(const char* url) { if (!url) { s_last_request_url_for_test[0] = '\0'; return; @@ -40,37 +54,139 @@ static void zero_bounce_record_request_url_for_test(const char* url) { s_last_request_url_for_test[sizeof(s_last_request_url_for_test) - 1] = '\0'; } -static char* zb_url_escape(const char* value) { +#define ZB_TEST_FIELD_MAX 48 + +typedef struct { + char method[16]; + char url[8192]; + char body[32768]; + struct { + char name[64]; + char value[1024]; + } fields[ZB_TEST_FIELD_MAX]; + int field_count; + /* The one extra header the request carried, "Name: value", or "". */ + char extra_header[512]; +} ZBTlsRequest; + +static _Thread_local ZBTlsRequest zb_tls_request; + +/* + * The region of the client making the current call, for the one-time bulk-host + * warning. Recorded at the guard every public call already runs, so the + * transports do not have to carry the client around. + */ +static _Thread_local const char *zb_tls_region = NULL; + + +static void zb_copy_cap(char *dest, size_t dest_len, const char *src) { + if (!dest || dest_len == 0) { + return; + } + if (!src) { + dest[0] = '\0'; + return; + } + snprintf(dest, dest_len, "%s", src); +} + +void zb_note_http_request(const char *method, const char *url, const char *body) { + memset(&zb_tls_request, 0, sizeof(zb_tls_request)); + zb_copy_cap(zb_tls_request.method, sizeof(zb_tls_request.method), method ? method : ""); + zb_copy_cap(zb_tls_request.url, sizeof(zb_tls_request.url), url ? url : ""); + zb_copy_cap(zb_tls_request.body, sizeof(zb_tls_request.body), body ? body : ""); + zero_bounce_record_request_url_for_test(url); + if (body) { + strncpy(s_last_post_body_for_test, body, sizeof(s_last_post_body_for_test) - 1); + s_last_post_body_for_test[sizeof(s_last_post_body_for_test) - 1] = '\0'; + } +} + +void zb_note_http_header(const char *header) { + zb_copy_cap(zb_tls_request.extra_header, sizeof(zb_tls_request.extra_header), header ? header : ""); +} + +void zb_note_http_field(const char *name, const char *value) { + int index; + if (zb_tls_request.field_count >= ZB_TEST_FIELD_MAX) { + return; + } + index = zb_tls_request.field_count++; + zb_copy_cap(zb_tls_request.fields[index].name, sizeof(zb_tls_request.fields[index].name), name); + zb_copy_cap(zb_tls_request.fields[index].value, sizeof(zb_tls_request.fields[index].value), value); +} + +const char *zero_bounce_test_method(void) { return zb_tls_request.method; } +const char *zero_bounce_test_url(void) { return zb_tls_request.url; } +const char *zero_bounce_test_body(void) { return zb_tls_request.body; } +const char *zero_bounce_test_extra_header(void) { return zb_tls_request.extra_header; } +int zero_bounce_test_field_count(void) { return zb_tls_request.field_count; } +const char *zero_bounce_test_field_name(int index) { + if (index < 0 || index >= zb_tls_request.field_count) return ""; + return zb_tls_request.fields[index].name; +} +const char *zero_bounce_test_field_value(int index) { + if (index < 0 || index >= zb_tls_request.field_count) return ""; + return zb_tls_request.fields[index].value; +} + +char* zb_url_escape(const char* value) { char* escaped = curl_easy_escape(NULL, value ? value : "", 0); char* copy = escaped ? strdup(escaped) : NULL; curl_free(escaped); - if (!copy) { - fprintf(stderr, "Memory allocation failed.\n"); - exit(EXIT_FAILURE); - } return copy; } -static void zb_append_query_param(StringVector* vector, const char* name, const char* value) { +static bool zb_append_query_param(StringVector* vector, const char* name, const char* value) { if (!value || !strlen(value)) { - return; + return true; } size_t prefix_len = strlen(name) + 3; char* prefix = (char*)malloc(prefix_len); if (!prefix) { - fprintf(stderr, "Memory allocation failed.\n"); - exit(EXIT_FAILURE); + return false; } snprintf(prefix, prefix_len, "&%s=", name); string_vector_append(vector, prefix); char* escaped = zb_url_escape(value); + if (!escaped) { + return false; + } string_vector_append(vector, escaped); free(escaped); + return true; +} + +static bool zb_url_append_suffix(char **url_path, const char *suffix) { + if (!url_path || !*url_path || !suffix) { + return false; + } + size_t old_len = strlen(*url_path); + size_t suffix_len = strlen(suffix); + char *extended = realloc(*url_path, old_len + suffix_len + 1); + if (!extended) { + return false; + } + memcpy(extended + old_len, suffix, suffix_len + 1); + *url_path = extended; + return true; +} + +static size_t zb_response_max_bytes(const memory *mem) { + if (mem->max_bytes != 0) { + return mem->max_bytes; + } + return ZB_MAX_JSON_RESPONSE_BYTES; } static size_t write_callback(void *data, size_t size, size_t nmemb, void *clientp) { size_t real_size = size * nmemb; memory *mem = (memory*)clientp; + size_t cap = zb_response_max_bytes(mem); + + if (mem->size + real_size > cap) { + return 0; + } char *ptr = realloc(mem->response, mem->size + real_size + 1); if(ptr == NULL) @@ -89,6 +205,14 @@ void set_write_callback(CURL* curl, memory* response_data) { curl_easy_setopt(curl, CURLOPT_WRITEDATA, (void*)response_data); curl_easy_setopt(curl, CURLOPT_TIMEOUT, 120L); curl_easy_setopt(curl, CURLOPT_CONNECTTIMEOUT, 30L); + /* A redirect could send the api_key to another host, so one is reported + rather than followed, and only https is ever dialled. */ + curl_easy_setopt(curl, CURLOPT_FOLLOWLOCATION, 0L); +#if LIBCURL_VERSION_NUM >= 0x075500 /* 7.85.0 */ + curl_easy_setopt(curl, CURLOPT_PROTOCOLS_STR, "https"); +#else + curl_easy_setopt(curl, CURLOPT_PROTOCOLS, (long)CURLPROTO_HTTPS); +#endif } long get_http_code(CURL* curl) { @@ -131,6 +255,96 @@ GetFileOptions new_get_file_options(void) { return o; } +ZBValidateOptions new_validate_options(void) { + ZBValidateOptions o = { -1, -1, -1, -1, -1, NULL, -1 }; + return o; +} + +/* + * The callback is where the greylist retry posts its result, so it is + * meaningless - and rejected by the API - without the retry itself. Checked + * before any request is sent. + */ +static bool zb_check_callback_url( + const ZBValidateOptions *options, OnErrorCallback error_callback +) { + const char *url; + const char *p; + if (!options || !options->callback_url) { + return true; + } + url = options->callback_url; + for (p = url; *p; p++) { + if (!isspace((unsigned char)*p)) { + break; + } + } + if (*p == '\0') { + if (error_callback) { + error_callback(parse_error("Empty parameter: callback_url")); + } + return false; + } + if (options->greylist_processing != 1) { + if (error_callback) { + error_callback(parse_error( + "Invalid parameter: callback_url requires greylist_processing to be true" + )); + } + return false; + } + return true; +} + +/* Appends the three options this port adds to a query string or a form body. */ +static bool zb_append_validate_extras(char **target, const ZBValidateOptions *options) { + if (options->domain_info >= 0) { + if (!zb_url_append_suffix( + target, options->domain_info ? "&domain_info=true" : "&domain_info=false")) { + return false; + } + } + if (options->greylist_processing >= 0) { + if (!zb_url_append_suffix( + target, + options->greylist_processing + ? "&greylist_processing=true" + : "&greylist_processing=false")) { + return false; + } + } + if (options->callback_url) { + char *escaped = zb_url_escape(options->callback_url); + bool ok = zb_url_append_suffix(target, "&callback_url=") + && zb_url_append_suffix(target, escaped); + free(escaped); + if (!ok) { + return false; + } + } + return true; +} + +static bool zb_check_validate_timeout(int timeout, OnErrorCallback error_callback) { + if (timeout < 0 || (timeout >= 3 && timeout <= 60)) { + return true; + } + if (error_callback) { + error_callback(parse_error("Invalid parameter: timeout (must be between 3 and 60 seconds)")); + } + return false; +} + +static bool zb_check_validate_batch_timeout(int timeout, OnErrorCallback error_callback) { + if (timeout < 0 || (timeout >= 10 && timeout <= 120)) { + return true; + } + if (error_callback) { + error_callback(parse_error("Invalid parameter: timeout (must be between 10 and 120 seconds)")); + } + return false; +} + static bool zb_ct_includes_application_json(const char *content_type) { if (!content_type) { @@ -347,6 +561,9 @@ ZeroBounce* new_zero_bounce_instance() { zb->api_key = NULL; zb->api_base_url = "https://api.zerobounce.net/v2"; + zb->bulk_api_root_url = "https://bulkapi.zerobounce.net"; + zb->list_evaluator_base_url = "https://bulkapi.zerobounce.net/v2/listevaluator"; + zb->inbox_placement_base_url = "https://bulkapi.zerobounce.net/api/ipt"; zb->bulk_api_base_url = "https://bulkapi.zerobounce.net/v2"; zb->bulk_api_scoring_base_url = "https://bulkapi.zerobounce.net/v2/scoring"; @@ -363,7 +580,36 @@ ZeroBounce* zero_bounce_get_instance() { } +/* + * A key with surrounding whitespace or control characters would otherwise be + * quoted, unredacted, in a transport error, so it is refused here. An empty + * key stays deferred to zero_bounce_invalid_api_key, which names the call + * that initializes the client. + */ +bool zb_api_key_is_clean(const char *api_key) { + const char *p; + if (!api_key || !api_key[0]) { + return true; + } + if (isspace((unsigned char)api_key[0]) || + isspace((unsigned char)api_key[strlen(api_key) - 1])) { + return false; + } + for (p = api_key; *p; p++) { + if ((unsigned char)*p < 32 || (unsigned char)*p == 127) { + return false; + } + } + return true; +} + void zero_bounce_initialize(ZeroBounce* zb, const char* api_key) { + if (!zb_api_key_is_clean(api_key)) { + fprintf(stderr, + "ZeroBounce: api_key has surrounding whitespace or control characters; " + "it was not accepted.\n"); + return; + } if (zb->api_key) { free(zb->api_key); } @@ -372,6 +618,12 @@ void zero_bounce_initialize(ZeroBounce* zb, const char* api_key) { void zero_bounce_initialize_with_base_url(ZeroBounce* zb, const char* api_key, ZBApiURL api_base_url) { + if (!zb_api_key_is_clean(api_key)) { + fprintf(stderr, + "ZeroBounce: api_key has surrounding whitespace or control characters; " + "it was not accepted.\n"); + return; + } if (zb->api_key) { free(zb->api_key); } @@ -379,8 +631,58 @@ void zero_bounce_initialize_with_base_url(ZeroBounce* zb, const char* api_key, Z zb->api_base_url = base_url_string_from_zb_api_url(api_base_url); } +/* The host part of a URL, lowercased, or "" when there is none. */ +static void zb_host_of(const char *url, char *out, size_t out_len) { + const char *start = url ? strstr(url, "://") : NULL; + const char *host = start ? start + 3 : (url ? url : ""); + const char *end = host; + size_t i = 0; + while (*end && *end != '/' && *end != '?' && *end != '#') { + end++; + } + while (host + i < end && i + 1 < out_len) { + out[i] = (char)tolower((unsigned char)host[i]); + i++; + } + out[i] = '\0'; +} + +bool zb_check_request_url(const char *url, OnErrorCallback error_callback) { + static int warned_about_region = 0; + char host[256]; + + if (!url || strncmp(url, "https://", 8) != 0) { + if (error_callback) { + error_callback(parse_error("url must be an https:// URL")); + } + return false; + } + zb_host_of(url, host, sizeof(host)); + if (!warned_about_region && zb_tls_region && + strcmp(host, "bulkapi.zerobounce.net") == 0) { + warned_about_region = 1; + fprintf(stderr, + "ZeroBounce warning: the base URL is the %s API, but this request goes to " + "bulkapi.zerobounce.net. 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. Set the matching base URLs with " + "zero_bounce_set_bulk_api_base_url and friends if ZeroBounce gave you " + "regional ones.\n", + zb_tls_region); + } + return true; +} -static bool zero_bounce_invalid_api_key(ZeroBounce *zb, OnErrorCallback error_callback) { + +bool zero_bounce_invalid_api_key(ZeroBounce *zb, OnErrorCallback error_callback) { + zb_tls_region = NULL; + if (zb && zb->api_base_url) { + if (strstr(zb->api_base_url, "api-eu.zerobounce.net")) { + zb_tls_region = "EU"; + } else if (strstr(zb->api_base_url, "api-us.zerobounce.net")) { + zb_tls_region = "US"; + } + } if (zb->api_key == NULL || strlen(zb->api_key) == 0) { ZBErrorResponse error_response = parse_error( "ZeroBounce is not initialized. Please call zero_bounce_initialize(zb_instance, api_key); first" @@ -392,7 +694,146 @@ static bool zero_bounce_invalid_api_key(ZeroBounce *zb, OnErrorCallback error_ca } -static int make_request( +int zb_make_request_with_method_and_body( + char* url_path, + const char* method, + const char* body, + const char* content_type_header, + memory* response_data, + long* http_code, + OnErrorCallback error_callback +) { + return zb_make_request_with_extra_header( + url_path, method, body, content_type_header, NULL, + response_data, http_code, error_callback + ); +} + +int zb_make_request_with_extra_header( + char* url_path, + const char* method, + const char* body, + const char* content_type_header, + const char* extra_header, + memory* response_data, + long* http_code, + OnErrorCallback error_callback +) { + zb_note_http_request(method, url_path, body); + zb_note_http_header(extra_header); + + if (!zb_check_request_url(url_path, error_callback)) { + return 0; + } + + if (s_http_perform) { + return s_http_perform(response_data, http_code, NULL); + } + + CURL* curl = curl_easy_init(); + if (!curl) { + error_callback(parse_error("Failed to initialize libcurl")); + return 0; + } + + curl_easy_setopt(curl, CURLOPT_CUSTOMREQUEST, method); + curl_easy_setopt(curl, CURLOPT_URL, url_path); + + struct curl_slist* headers = NULL; + headers = curl_slist_append(headers, "Accept: application/json"); + if (body && content_type_header) { + headers = curl_slist_append(headers, content_type_header); + } + if (extra_header) { + headers = curl_slist_append(headers, extra_header); + } + curl_easy_setopt(curl, CURLOPT_HTTPHEADER, headers); + if (body) { + curl_easy_setopt(curl, CURLOPT_COPYPOSTFIELDS, body); + } + + set_write_callback(curl, response_data); + + CURLcode res = curl_easy_perform(curl); + if (res != CURLE_OK) { + char error_message[300]; + snprintf( + error_message, sizeof(error_message), + "Failed to perform request. CURLcode: %d. Error: %s", + res, curl_easy_strerror(res) + ); + error_callback(parse_error(error_message)); + curl_easy_cleanup(curl); + curl_slist_free_all(headers); + return 0; + } + + *http_code = get_http_code(curl); + curl_easy_cleanup(curl); + curl_slist_free_all(headers); + return 1; +} + +int zb_make_request_with_post_body( + char* url_path, + char* body, + memory* response_data, + long* http_code, + OnErrorCallback error_callback +) { + zb_note_http_request("POST", url_path, body ? body : ""); + + if (!zb_check_request_url(url_path, error_callback)) { + return 0; + } + + if (s_http_perform) { + return s_http_perform(response_data, http_code, NULL); + } + + CURL* curl = curl_easy_init(); + if (!curl) { + error_callback(parse_error("Failed to initialize libcurl")); + curl_easy_cleanup(curl); + return 0; + } + + curl_easy_setopt(curl, CURLOPT_CUSTOMREQUEST, "POST"); + curl_easy_setopt(curl, CURLOPT_URL, url_path); + + struct curl_slist* headers = NULL; + headers = curl_slist_append(headers, "Accept: application/json"); + headers = curl_slist_append(headers, "Content-Type: application/x-www-form-urlencoded"); + curl_easy_setopt(curl, CURLOPT_HTTPHEADER, headers); + curl_easy_setopt(curl, CURLOPT_COPYPOSTFIELDS, body); + + set_write_callback(curl, response_data); + + CURLcode res = curl_easy_perform(curl); + if (res != CURLE_OK) { + char error_message[300]; + sprintf( + error_message, + "Failed to perform request. CURLcode: %d. Error: %s", + res, + curl_easy_strerror(res) + ); + error_callback(parse_error(error_message)); + + curl_easy_cleanup(curl); + curl_slist_free_all(headers); + return 0; + } + + *http_code = get_http_code(curl); + + curl_easy_cleanup(curl); + curl_slist_free_all(headers); + + return 1; +} + +int zb_make_request( char* url_path, char* request_type, char* header, @@ -400,6 +841,11 @@ static int make_request( long* http_code, OnErrorCallback error_callback ) { + zb_note_http_request(request_type ? request_type : "GET", url_path, NULL); + + if (!zb_check_request_url(url_path, error_callback)) { + return 0; + } if (s_http_perform) { return s_http_perform(response_data, http_code, NULL); } @@ -444,6 +890,45 @@ static int make_request( return 1; } +static void note_send_file_fields( + ZeroBounce *zb, + bool scoring, + int email_address_column_index, + SendFileOptions options +) { + char int_str_option[16]; + zb_note_http_field("api_key", zb->api_key ? zb->api_key : ""); + zb_note_http_field("file", ""); + snprintf(int_str_option, sizeof(int_str_option), "%d", email_address_column_index); + zb_note_http_field("email_address_column", int_str_option); + if (!scoring) { + if (options.first_name_column > 0) { + snprintf(int_str_option, sizeof(int_str_option), "%d", options.first_name_column); + zb_note_http_field("first_name_column", int_str_option); + } + if (options.last_name_column > 0) { + snprintf(int_str_option, sizeof(int_str_option), "%d", options.last_name_column); + zb_note_http_field("last_name_column", int_str_option); + } + if (options.gender_column > 0) { + snprintf(int_str_option, sizeof(int_str_option), "%d", options.gender_column); + zb_note_http_field("gender_column", int_str_option); + } + if (options.ip_address_column > 0) { + snprintf(int_str_option, sizeof(int_str_option), "%d", options.ip_address_column); + zb_note_http_field("ip_address_column", int_str_option); + } + } + if (options.return_url && strlen(options.return_url) != 0) { + zb_note_http_field("return_url", options.return_url); + } + zb_note_http_field("has_header_row", options.has_header_row ? "true" : "false"); + zb_note_http_field("remove_duplicate", options.remove_duplicate ? "true" : "false"); + if (!scoring && options.allow_phase_2_is_set) { + zb_note_http_field("allow_phase_2", options.allow_phase_2 ? "true" : "false"); + } +} + static void send_file_internal( ZeroBounce *zb, bool scoring, @@ -474,6 +959,9 @@ static void send_file_internal( memory response_data = {0}; + zb_note_http_request("POST", url_path, NULL); + note_send_file_fields(zb, scoring, email_address_column_index, options); + if (s_http_perform) { long http_code = 0; if (!s_http_perform(&response_data, &http_code, NULL)) { @@ -481,11 +969,11 @@ static void send_file_internal( goto cleanup; } if (http_code > 299) { - if (error_callback) error_callback(parse_error(response_data.response)); + zb_report_body_error(response_data.response, http_code, error_callback); } else if (success_callback) { - json_object *j_obj = json_tokener_parse(response_data.response); - if (j_obj == NULL) { - error_callback(parse_error("Failed to parse json string")); + json_object *j_obj = NULL; + if (!zb_read_success_json(response_data.response, http_code, &j_obj, error_callback)) { + goto cleanup; } else { success_callback(zb_send_file_response_from_json(j_obj)); json_object_put(j_obj); @@ -601,14 +1089,11 @@ static void send_file_internal( curl_mime_free(multipart); if (http_code > 299) { - if (error_callback) { - error_callback(parse_error(response_data.response)); - } + zb_report_body_error(response_data.response, http_code, error_callback); } else { if (success_callback) { - json_object *j_obj = json_tokener_parse(response_data.response); - if (j_obj == NULL) { - error_callback(parse_error("Failed to parse json string")); + json_object *j_obj = NULL; + if (!zb_read_success_json(response_data.response, http_code, &j_obj, error_callback)) { goto cleanup; } @@ -658,19 +1143,16 @@ static void file_status_internal( memory response_data = {0}; long http_code; - if(!make_request(url_path, "GET", "Accept: application/json", &response_data, &http_code, error_callback)){ + if(!zb_make_request(url_path, "GET", "Accept: application/json", &response_data, &http_code, error_callback)){ goto cleanup; } if (http_code > 299) { - if (error_callback) { - error_callback(parse_error(response_data.response)); - } + zb_report_body_error(response_data.response, http_code, error_callback); } else { if (success_callback) { - json_object *j_obj = json_tokener_parse(response_data.response); - if (j_obj == NULL) { - error_callback(parse_error("Failed to parse json string")); + json_object *j_obj = NULL; + if (!zb_read_success_json(response_data.response, http_code, &j_obj, error_callback)) { goto cleanup; } @@ -710,9 +1192,16 @@ static void get_file_internal( } memory response_data = {0}; + response_data.max_bytes = ZB_MAX_GETFILE_BYTES; long http_code = 0; char* content_type = NULL; + zb_note_http_request("GET", url_path, NULL); + + if (!zb_check_request_url(url_path, error_callback)) { + goto cleanup; + } + if (s_http_perform) { if (!s_http_perform(&response_data, &http_code, &content_type)) { if (error_callback) error_callback(parse_error("Mock request failed")); @@ -767,15 +1256,8 @@ static void get_file_internal( const char *body = response_data.response ? response_data.response : ""; - if (http_code > 299) { - if (error_callback) { - char *fmt = NULL; - if (body[0] == '{') { - fmt = zb_format_get_file_error_message(body); - } - error_callback(parse_error(fmt && fmt[0] ? fmt : body)); - free(fmt); - } + if (http_code > 299 || zb_body_looks_like_html(body) || body[0] == '\0') { + zb_report_body_error(body, http_code, error_callback); } else if (success_callback) { if (zb_should_treat_getfile_body_as_error(body, content_type)) { if (error_callback) { @@ -865,19 +1347,16 @@ static void delete_file_internal( memory response_data = {0}; long http_code; - if(!make_request(url_path, "GET", "Accept: application/json", &response_data, &http_code, error_callback)){ + if(!zb_make_request(url_path, "GET", "Accept: application/json", &response_data, &http_code, error_callback)){ goto cleanup; } if (http_code > 299) { - if (error_callback) { - error_callback(parse_error(response_data.response)); - } + zb_report_body_error(response_data.response, http_code, error_callback); } else { if (success_callback) { - json_object *j_obj = json_tokener_parse(response_data.response); - if (j_obj == NULL) { - error_callback(parse_error("Failed to parse json string")); + json_object *j_obj = NULL; + if (!zb_read_success_json(response_data.response, http_code, &j_obj, error_callback)) { goto cleanup; } @@ -923,19 +1402,16 @@ static void find_email_internal( memory response_data = {0}; long http_code; - if(!make_request(url_path, "GET", "Accept: application/json", &response_data, &http_code, error_callback)){ + if(!zb_make_request(url_path, "GET", "Accept: application/json", &response_data, &http_code, error_callback)){ goto cleanup; } if (http_code > 299) { - if (error_callback) { - error_callback(parse_error(response_data.response)); - } + zb_report_body_error(response_data.response, http_code, error_callback); } else { if (success_callback) { - json_object *j_obj = json_tokener_parse(response_data.response); - if (j_obj == NULL) { - error_callback(parse_error("Failed to parse json string")); + json_object *j_obj = NULL; + if (!zb_read_success_json(response_data.response, http_code, &j_obj, error_callback)) { goto cleanup; } ZBFindEmailResponse response_obj = zb_find_email_response_from_json(j_obj); @@ -977,19 +1453,16 @@ static void search_domain_internal( memory response_data = {0}; long http_code; - if(!make_request(url_path, "GET", "Accept: application/json", &response_data, &http_code, error_callback)){ + if(!zb_make_request(url_path, "GET", "Accept: application/json", &response_data, &http_code, error_callback)){ goto cleanup; } if (http_code > 299) { - if (error_callback) { - error_callback(parse_error(response_data.response)); - } + zb_report_body_error(response_data.response, http_code, error_callback); } else { if (success_callback) { - json_object *j_obj = json_tokener_parse(response_data.response); - if (j_obj == NULL) { - error_callback(parse_error("Failed to parse json string")); + json_object *j_obj = NULL; + if (!zb_read_success_json(response_data.response, http_code, &j_obj, error_callback)) { goto cleanup; } ZBDomainSearchResponse response_obj = zb_domain_search_response_from_json(j_obj); @@ -1035,19 +1508,16 @@ void get_credits( memory response_data = {0}; long http_code; - if(!make_request(url_path, "GET", "Accept: application/json", &response_data, &http_code, error_callback)){ + if(!zb_make_request(url_path, "GET", "Accept: application/json", &response_data, &http_code, error_callback)){ goto cleanup; } if (http_code > 299) { - if (error_callback) { - error_callback(parse_error(response_data.response)); - } + zb_report_body_error(response_data.response, http_code, error_callback); } else { if (success_callback) { - json_object *j_obj = json_tokener_parse(response_data.response); - if (j_obj == NULL) { - error_callback(parse_error("Failed to parse json string")); + json_object *j_obj = NULL; + if (!zb_read_success_json(response_data.response, http_code, &j_obj, error_callback)) { goto cleanup; } @@ -1106,19 +1576,16 @@ void get_api_usage( memory response_data = {0}; long http_code; - if(!make_request(url_path, "GET", "Accept: application/json", &response_data, &http_code, error_callback)){ + if(!zb_make_request(url_path, "GET", "Accept: application/json", &response_data, &http_code, error_callback)){ goto cleanup; } if (http_code > 299) { - if (error_callback) { - error_callback(parse_error(response_data.response)); - } + zb_report_body_error(response_data.response, http_code, error_callback); } else { if (success_callback) { - json_object *j_obj = json_tokener_parse(response_data.response); - if (j_obj == NULL) { - error_callback(parse_error("Failed to parse json string")); + json_object *j_obj = NULL; + if (!zb_read_success_json(response_data.response, http_code, &j_obj, error_callback)) { goto cleanup; } @@ -1139,19 +1606,156 @@ void validate_email( char* ip_address, OnSuccessCallbackValidate success_callback, OnErrorCallback error_callback +) { + validate_email_with_options(zb, email, ip_address, NULL, success_callback, error_callback); +} + +void validate_email_with_options( + ZeroBounce *zb, + char* email, + char* ip_address, + const ZBValidateOptions *options, + OnSuccessCallbackValidate success_callback, + OnErrorCallback error_callback ) { if (zero_bounce_invalid_api_key(zb, error_callback)) return; - const char *url_pattern = "%s/validate?api_key=%s&email=%s&ip_address=%s"; + ZBValidateOptions default_options = new_validate_options(); + if (!options) { + options = &default_options; + } + if (!zb_check_validate_timeout(options->timeout, error_callback)) { + return; + } + if (!zb_check_callback_url(options, error_callback)) { + return; + } + + if (options->use_post == 1) { + char *url_path = malloc(strlen(zb->api_base_url) + 16); + if (!url_path) { + fprintf(stderr, "Memory allocation failed.\n"); + exit(EXIT_FAILURE); + } + snprintf(url_path, strlen(zb->api_base_url) + 16, "%s/validate", zb->api_base_url); + + char *api_key_esc = zb_url_escape(zb->api_key); + char *email_esc = zb_url_escape(email ? email : ""); + char *ip_esc = zb_url_escape(ip_address ? ip_address : ""); + if (!api_key_esc || !email_esc || !ip_esc) { + free(api_key_esc); + free(email_esc); + free(ip_esc); + free(url_path); + fprintf(stderr, "Memory allocation failed.\n"); + exit(EXIT_FAILURE); + } + + size_t body_cap = strlen(api_key_esc) + strlen(email_esc) + strlen(ip_esc) + 128; + if (options->timeout >= 0) { + body_cap += 32; + } + if (options->activity_data >= 0 || options->verify_plus >= 0) { + body_cap += 64; + } + char *post_body = malloc(body_cap); + if (!post_body) { + free(api_key_esc); + free(email_esc); + free(ip_esc); + free(url_path); + fprintf(stderr, "Memory allocation failed.\n"); + exit(EXIT_FAILURE); + } + snprintf( + post_body, body_cap, "api_key=%s&email=%s&ip_address=%s", + api_key_esc, email_esc, ip_esc + ); + free(api_key_esc); + free(email_esc); + free(ip_esc); + + if (options->timeout >= 0) { + char timeout_buf[16]; + snprintf(timeout_buf, sizeof(timeout_buf), "%d", options->timeout); + char *timeout_esc = zb_url_escape(timeout_buf); + if (!timeout_esc || !zb_url_append_suffix(&post_body, "&timeout=") || + !zb_url_append_suffix(&post_body, timeout_esc)) { + free(timeout_esc); + free(post_body); + free(url_path); + fprintf(stderr, "Memory allocation failed.\n"); + exit(EXIT_FAILURE); + } + free(timeout_esc); + } + if (options->activity_data >= 0) { + const char *suffix = options->activity_data ? "&activity_data=true" : "&activity_data=false"; + if (!zb_url_append_suffix(&post_body, suffix)) { + free(post_body); + free(url_path); + fprintf(stderr, "Memory allocation failed.\n"); + exit(EXIT_FAILURE); + } + } + if (options->verify_plus >= 0) { + const char *suffix = options->verify_plus ? "&verify_plus=true" : "&verify_plus=false"; + if (!zb_url_append_suffix(&post_body, suffix)) { + free(post_body); + free(url_path); + fprintf(stderr, "Memory allocation failed.\n"); + exit(EXIT_FAILURE); + } + } + if (!zb_append_validate_extras(&post_body, options)) { + free(post_body); + free(url_path); + fprintf(stderr, "Memory allocation failed.\n"); + exit(EXIT_FAILURE); + } + + zero_bounce_record_request_url_for_test(url_path); + + memory response_data = {0}; + long http_code; + + if (!zb_make_request_with_post_body(url_path, post_body, &response_data, &http_code, error_callback)) { + free(url_path); + free(post_body); + return; + } + + if (http_code > 299) { + zb_report_body_error(response_data.response, http_code, error_callback); + } else if (success_callback) { + json_object *j_obj = NULL; + if (!zb_read_success_json(response_data.response, http_code, &j_obj, error_callback)) { + /* reported */ + } else { + success_callback(zb_validate_response_from_json(j_obj)); + json_object_put(j_obj); + } + } + + free(url_path); + free(post_body); + free(response_data.response); + return; + } char* api_key_esc = zb_url_escape(zb->api_key); char* email_esc = zb_url_escape(email ? email : ""); char* ip_esc = zb_url_escape(ip_address ? ip_address : ""); - int url_path_len = snprintf( - NULL, 0, url_pattern, zb->api_base_url, api_key_esc, email_esc, ip_esc - ); - char *url_path = malloc(url_path_len + 1); + size_t url_cap = strlen(zb->api_base_url) + strlen(api_key_esc) + strlen(email_esc) + strlen(ip_esc) + 256; + if (options->timeout >= 0) { + url_cap += 32; + } + if (options->activity_data >= 0 || options->verify_plus >= 0) { + url_cap += 64; + } + + char *url_path = malloc(url_cap); if (!url_path) { free(api_key_esc); free(email_esc); @@ -1161,28 +1765,69 @@ void validate_email( } snprintf( - url_path, url_path_len + 1, url_pattern, zb->api_base_url, api_key_esc, email_esc, ip_esc + url_path, url_cap, "%s/validate?api_key=%s&email=%s&ip_address=%s", + zb->api_base_url, api_key_esc, email_esc, ip_esc ); + if (options->timeout >= 0) { + char timeout_buf[16]; + snprintf(timeout_buf, sizeof(timeout_buf), "%d", options->timeout); + char* timeout_esc = zb_url_escape(timeout_buf); + char* extended = realloc(url_path, strlen(url_path) + strlen(timeout_esc) + 32); + if (!extended) { + free(timeout_esc); + free(api_key_esc); + free(email_esc); + free(ip_esc); + free(url_path); + fprintf(stderr, "Memory allocation failed.\n"); + exit(EXIT_FAILURE); + } + url_path = extended; + strcat(url_path, "&timeout="); + strcat(url_path, timeout_esc); + free(timeout_esc); + } + if (options->activity_data >= 0) { + const char *suffix = options->activity_data ? "&activity_data=true" : "&activity_data=false"; + if (!zb_url_append_suffix(&url_path, suffix)) { + free(url_path); + fprintf(stderr, "Memory allocation failed.\n"); + exit(EXIT_FAILURE); + } + } + if (options->verify_plus >= 0) { + const char *suffix = options->verify_plus ? "&verify_plus=true" : "&verify_plus=false"; + if (!zb_url_append_suffix(&url_path, suffix)) { + free(url_path); + fprintf(stderr, "Memory allocation failed.\n"); + exit(EXIT_FAILURE); + } + } + if (!zb_append_validate_extras(&url_path, options)) { + free(url_path); + fprintf(stderr, "Memory allocation failed.\n"); + exit(EXIT_FAILURE); + } + free(api_key_esc); free(email_esc); free(ip_esc); + zero_bounce_record_request_url_for_test(url_path); + memory response_data = {0}; long http_code; - if(!make_request(url_path, "GET", "Accept: application/json", &response_data, &http_code, error_callback)){ + if(!zb_make_request(url_path, "GET", "Accept: application/json", &response_data, &http_code, error_callback)){ goto cleanup; } if (http_code > 299) { - if (error_callback) { - error_callback(parse_error(response_data.response)); - } + zb_report_body_error(response_data.response, http_code, error_callback); } else { if (success_callback) { - json_object *j_obj = json_tokener_parse(response_data.response); - if (j_obj == NULL) { - error_callback(parse_error("Failed to parse json string")); + json_object *j_obj = NULL; + if (!zb_read_success_json(response_data.response, http_code, &j_obj, error_callback)) { goto cleanup; } @@ -1196,15 +1841,32 @@ void validate_email( free(response_data.response); } - void validate_email_batch( ZeroBounce *zb, EmailToValidateVector email_batch, OnSuccessCallbackValidateBatch success_callback, OnErrorCallback error_callback +) { + validate_email_batch_with_options(zb, email_batch, NULL, success_callback, error_callback); +} + +void validate_email_batch_with_options( + ZeroBounce *zb, + EmailToValidateVector email_batch, + const ZBValidateOptions *options, + OnSuccessCallbackValidateBatch success_callback, + OnErrorCallback error_callback ) { if (zero_bounce_invalid_api_key(zb, error_callback)) return; + ZBValidateOptions default_options = new_validate_options(); + if (!options) { + options = &default_options; + } + if (!zb_check_validate_batch_timeout(options->timeout, error_callback)) { + return; + } + const char *url_pattern = "%s/validatebatch"; int url_path_len = snprintf( @@ -1228,17 +1890,54 @@ void validate_email_batch( for(size_t i = 0; i < email_batch.size; i++) { json_object *email_obj = json_object_new_object(); - json_object_object_add(email_obj, "email_address", json_object_new_string(email_batch.data[i].email_address)); - json_object_object_add(email_obj, "ip_address", json_object_new_string(email_batch.data[i].ip_address)); + json_object_object_add(email_obj, "email_address", json_object_new_string(email_batch.data[i].email_address ? email_batch.data[i].email_address : "")); + if (!email_batch.data[i].ip_address || !email_batch.data[i].ip_address[0]) { + json_object_object_add(email_obj, "ip_address", json_object_new_null()); + } else { + json_object_object_add(email_obj, "ip_address", json_object_new_string(email_batch.data[i].ip_address)); + } json_object_array_add(email_batch_json, email_obj); } json_object_object_add(payload, "api_key", json_object_new_string(zb->api_key)); json_object_object_add(payload, "email_batch", email_batch_json); + if (options->timeout >= 0) { + /* + * 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". + */ + char timeout_text[16]; + snprintf(timeout_text, sizeof(timeout_text), "%d", options->timeout); + json_object_object_add(payload, "timeout", json_object_new_string(timeout_text)); + } + if (options->activity_data >= 0) { + json_object_object_add( + payload, + "activity_data", + json_object_new_boolean(options->activity_data ? 1 : 0) + ); + } + if (options->verify_plus >= 0) { + json_object_object_add( + payload, + "verify_plus", + json_object_new_boolean(options->verify_plus ? 1 : 0) + ); + } + + const char* payload_str = json_object_to_json_string(payload); + zb_note_http_request("POST", url_path, payload_str); memory response_data = {0}; long http_code = 0; + if (!zb_check_request_url(url_path, error_callback)) { + json_object_put(payload); + free(url_path); + return; + } + if (s_http_perform) { json_object_put(payload); if (!s_http_perform(&response_data, &http_code, NULL)) { @@ -1292,18 +1991,23 @@ void validate_email_batch( } if (http_code > 299) { - if (error_callback) { - error_callback(parse_error(response_data.response)); - } + zb_report_body_error(response_data.response, http_code, error_callback); } else { if (success_callback) { - json_object *j_obj = json_tokener_parse(response_data.response); - if (j_obj == NULL) { - error_callback(parse_error("Failed to parse json string")); + json_object *j_obj = NULL; + if (!zb_read_success_json(response_data.response, http_code, &j_obj, error_callback)) { goto cleanup; } - success_callback(zb_validate_batch_response_from_json(j_obj)); + char *account_error = NULL; + if (zb_batch_has_account_error(j_obj, &account_error)) { + if (error_callback) { + error_callback(parse_error(account_error)); + } + free(account_error); + } else { + success_callback(zb_validate_batch_response_from_json(j_obj)); + } json_object_put(j_obj); } } @@ -1336,6 +2040,284 @@ void file_status( } + +/* + * A streaming getfile download: the body goes straight to disk as it arrives + * instead of being held in memory first. + * + * It is opt-in because a streamed body cannot be inspected as a whole, and + * getfile answers an unknown or deleted file id with a JSON error rather than + * a file. The first chunk is held back and checked; only once it is clearly + * not an error does the file get created and the rest stream through. + */ +#define ZB_STREAM_PROBE_BYTES 4096 + +typedef struct { + FILE *file; + const char *path; + char probe[ZB_STREAM_PROBE_BYTES + 1]; + size_t probe_size; + bool decided; + bool rejected; + bool open_failed; +} ZbDownloadStream; + +static bool zb_stream_open(ZbDownloadStream *stream) { + struct stat sb; + char *dir_path; + char *end; +#ifdef _WIN32 + const char path_separator = '\\'; +#else + const char path_separator = '/'; +#endif + + if (stat(stream->path, &sb) == 0 && S_ISDIR(sb.st_mode)) { + return false; + } + dir_path = strdup(stream->path); + if (dir_path) { + end = strrchr(dir_path, path_separator); + if (end) { + *end = '\0'; + mkdir(dir_path, 0777); + } + free(dir_path); + } + stream->file = fopen(stream->path, "wb"); + return stream->file != NULL; +} + +static size_t zb_stream_write(void *contents, size_t size, size_t nmemb, void *userp) { + ZbDownloadStream *stream = (ZbDownloadStream *)userp; + size_t total = size * nmemb; + + if (stream->rejected || stream->open_failed) { + /* Keep draining so libcurl reports the HTTP status, not a write error. */ + return total; + } + if (!stream->decided) { + size_t room = ZB_STREAM_PROBE_BYTES - stream->probe_size; + size_t take = total < room ? total : room; + memcpy(stream->probe + stream->probe_size, contents, take); + stream->probe_size += take; + stream->probe[stream->probe_size] = '\0'; + if (stream->probe_size < ZB_STREAM_PROBE_BYTES && take == total) { + return total; + } + /* Enough to tell a file from an error page. */ + stream->decided = true; + if (zb_body_looks_like_html(stream->probe) || zb_success_means_failure(stream->probe)) { + stream->rejected = true; + return total; + } + if (!zb_stream_open(stream)) { + stream->open_failed = true; + return total; + } + fwrite(stream->probe, 1, stream->probe_size, stream->file); + if (take < total) { + fwrite((const char *)contents + take, 1, total - take, stream->file); + } + return total; + } + fwrite(contents, 1, total, stream->file); + return total; +} + +/* Flushes whatever the probe still holds when the body was shorter than it. */ +static void zb_stream_finish(ZbDownloadStream *stream) { + if (stream->decided || stream->rejected || stream->open_failed) { + return; + } + stream->decided = true; + if (zb_body_looks_like_html(stream->probe) || zb_success_means_failure(stream->probe) || + stream->probe_size == 0) { + stream->rejected = true; + return; + } + if (!zb_stream_open(stream)) { + stream->open_failed = true; + return; + } + fwrite(stream->probe, 1, stream->probe_size, stream->file); +} + +static void get_file_streaming_internal( + ZeroBounce *zb, + bool scoring, + char *file_id, + char *local_download_path, + const GetFileOptions *options, + OnSuccessCallbackGetFile success_callback, + OnErrorCallback error_callback +) { + GetFileOptions default_opts = new_get_file_options(); + ZbDownloadStream stream; + char *url_path; + long http_code = 0; + CURL *curl; + CURLcode res; + struct curl_slist *headers = NULL; + + if (zero_bounce_invalid_api_key(zb, error_callback)) { + return; + } + if (!local_download_path || !local_download_path[0]) { + if (error_callback) { + error_callback(parse_error("Empty parameter: local_download_path")); + } + return; + } + if (!options) { + options = &default_opts; + } + url_path = zb_build_getfile_url(zb, scoring, file_id, options); + if (!url_path) { + if (error_callback) { + error_callback(parse_error("Failed to build getfile URL")); + } + return; + } + zb_note_http_request("GET", url_path, NULL); + if (!zb_check_request_url(url_path, error_callback)) { + free(url_path); + return; + } + + memset(&stream, 0, sizeof(stream)); + stream.path = local_download_path; + + if (s_http_perform) { + /* The offline suite drives the same probe-then-stream logic. */ + memory buffer = {0}; + buffer.max_bytes = ZB_MAX_GETFILE_BYTES; + if (!s_http_perform(&buffer, &http_code, NULL)) { + free(buffer.response); + free(url_path); + if (error_callback) { + error_callback(parse_error("Mock request failed")); + } + return; + } + if (buffer.response && buffer.size) { + zb_stream_write(buffer.response, 1, buffer.size, &stream); + } + free(buffer.response); + free(url_path); + zb_stream_finish(&stream); + if (stream.file) { + fclose(stream.file); + stream.file = NULL; + } + if (http_code > 299 || stream.rejected) { + remove(local_download_path); + zb_report_body_error(stream.probe, http_code, error_callback); + return; + } + if (stream.open_failed) { + if (error_callback) { + error_callback(parse_error("Invalid file path")); + } + return; + } + if (success_callback) { + ZBGetFileResponse response = new_zb_get_file_response(); + response.success = true; + response.local_file_path = local_download_path; + success_callback(response); + } + return; + } + + curl = curl_easy_init(); + if (!curl) { + free(url_path); + if (error_callback) { + error_callback(parse_error("Failed to initialize libcurl")); + } + return; + } + curl_easy_setopt(curl, CURLOPT_CUSTOMREQUEST, "GET"); + curl_easy_setopt(curl, CURLOPT_URL, url_path); + headers = curl_slist_append(headers, "Accept: application/json"); + curl_easy_setopt(curl, CURLOPT_HTTPHEADER, headers); + curl_easy_setopt(curl, CURLOPT_WRITEFUNCTION, zb_stream_write); + curl_easy_setopt(curl, CURLOPT_WRITEDATA, (void *)&stream); + curl_easy_setopt(curl, CURLOPT_TIMEOUT, 120L); + curl_easy_setopt(curl, CURLOPT_CONNECTTIMEOUT, 30L); + curl_easy_setopt(curl, CURLOPT_FOLLOWLOCATION, 0L); +#if LIBCURL_VERSION_NUM >= 0x075500 /* 7.85.0 */ + curl_easy_setopt(curl, CURLOPT_PROTOCOLS_STR, "https"); +#else + curl_easy_setopt(curl, CURLOPT_PROTOCOLS, (long)CURLPROTO_HTTPS); +#endif + + res = curl_easy_perform(curl); + zb_stream_finish(&stream); + if (stream.file) { + fclose(stream.file); + stream.file = NULL; + } + http_code = get_http_code(curl); + curl_easy_cleanup(curl); + curl_slist_free_all(headers); + free(url_path); + + if (res != CURLE_OK) { + char message[300]; + snprintf(message, sizeof(message), + "Failed to perform request. CURLcode: %d. Error: %s", + res, curl_easy_strerror(res)); + if (error_callback) { + error_callback(parse_error(message)); + } + remove(local_download_path); + return; + } + if (http_code > 299 || stream.rejected) { + remove(local_download_path); + zb_report_body_error(stream.probe, http_code, error_callback); + return; + } + if (stream.open_failed) { + if (error_callback) { + error_callback(parse_error("Invalid file path")); + } + return; + } + if (success_callback) { + ZBGetFileResponse response = new_zb_get_file_response(); + response.success = true; + response.local_file_path = local_download_path; + success_callback(response); + } +} + +void get_file_streaming( + ZeroBounce *zb, + char* file_id, + char* local_download_path, + const GetFileOptions *options, + OnSuccessCallbackGetFile success_callback, + OnErrorCallback error_callback +) { + get_file_streaming_internal(zb, false, file_id, local_download_path, options, + success_callback, error_callback); +} + +void scoring_get_file_streaming( + ZeroBounce *zb, + char* file_id, + char* local_download_path, + const GetFileOptions *options, + OnSuccessCallbackGetFile success_callback, + OnErrorCallback error_callback +) { + get_file_streaming_internal(zb, true, file_id, local_download_path, options, + success_callback, error_callback); +} + void get_file( ZeroBounce *zb, char* file_id, @@ -1453,19 +2435,16 @@ void get_activity_data( memory response_data = {0}; long http_code; - if(!make_request(url_path, "GET", "Accept: application/json", &response_data, &http_code, error_callback)){ + if(!zb_make_request(url_path, "GET", "Accept: application/json", &response_data, &http_code, error_callback)){ goto cleanup; } if (http_code > 299) { - if (error_callback) { - error_callback(parse_error(response_data.response)); - } + zb_report_body_error(response_data.response, http_code, error_callback); } else { if (success_callback) { - json_object *j_obj = json_tokener_parse(response_data.response); - if (j_obj == NULL) { - error_callback(parse_error("Failed to parse json string")); + json_object *j_obj = NULL; + if (!zb_read_success_json(response_data.response, http_code, &j_obj, error_callback)) { goto cleanup; } @@ -1479,6 +2458,111 @@ void get_activity_data( free(response_data.response); } +static void score_email_internal( + ZeroBounce *zb, + bool use_post, + char* email, + OnSuccessCallbackScoreEmail success_callback, + OnErrorCallback error_callback +) { + if (zero_bounce_invalid_api_key(zb, error_callback)) return; + + char* api_key_esc = zb_url_escape(zb->api_key); + char* email_esc = zb_url_escape(email ? email : ""); + + char *url_path = NULL; + char *post_body = NULL; + + if (use_post) { + const char *url_pattern = "%s/scoring"; + int url_path_len = snprintf(NULL, 0, url_pattern, zb->api_base_url); + url_path = malloc(url_path_len + 1); + if (!url_path) { + goto alloc_fail; + } + snprintf(url_path, url_path_len + 1, url_pattern, zb->api_base_url); + + const char *body_pattern = "api_key=%s&email=%s"; + int body_len = snprintf(NULL, 0, body_pattern, api_key_esc, email_esc); + post_body = malloc(body_len + 1); + if (!post_body) { + goto alloc_fail; + } + snprintf(post_body, body_len + 1, body_pattern, api_key_esc, email_esc); + } else { + const char *url_pattern = "%s/scoring?api_key=%s&email=%s"; + int url_path_len = snprintf( + NULL, 0, url_pattern, zb->api_base_url, api_key_esc, email_esc + ); + url_path = malloc(url_path_len + 1); + if (!url_path) { + goto alloc_fail; + } + snprintf( + url_path, url_path_len + 1, url_pattern, zb->api_base_url, api_key_esc, email_esc + ); + zero_bounce_record_request_url_for_test(url_path); + } + + free(api_key_esc); + api_key_esc = NULL; + free(email_esc); + email_esc = NULL; + + memory response_data = {0}; + long http_code = 0; + int ok = use_post + ? zb_make_request_with_post_body(url_path, post_body, &response_data, &http_code, error_callback) + : zb_make_request(url_path, "GET", "Accept: application/json", &response_data, &http_code, error_callback); + + if (!ok) { + goto cleanup; + } + + if (http_code > 299) { + zb_report_body_error(response_data.response, http_code, error_callback); + } else if (success_callback) { + json_object *j_obj = NULL; + if (!zb_read_success_json(response_data.response, http_code, &j_obj, error_callback)) { + goto cleanup; + } + + success_callback(zb_score_email_response_from_json(j_obj)); + json_object_put(j_obj); + } + + goto cleanup; + +alloc_fail: + free(api_key_esc); + free(email_esc); + fprintf(stderr, "Memory allocation failed.\n"); + exit(EXIT_FAILURE); + +cleanup: + free(url_path); + free(post_body); + free(response_data.response); +} + +void score_email( + ZeroBounce *zb, + char* email, + OnSuccessCallbackScoreEmail success_callback, + OnErrorCallback error_callback +) { + score_email_internal(zb, false, email, success_callback, error_callback); +} + +void score_email_post( + ZeroBounce *zb, + char* email, + OnSuccessCallbackScoreEmail success_callback, + OnErrorCallback error_callback +) { + score_email_internal(zb, true, email, success_callback, error_callback); +} + void find_email_by_domain_first_middle_last_name( ZeroBounce* zb, @@ -1595,19 +2679,16 @@ void find_email( memory response_data = {0}; long http_code; - if(!make_request(url_path, "GET", "Accept: application/json", &response_data, &http_code, error_callback)){ + if(!zb_make_request(url_path, "GET", "Accept: application/json", &response_data, &http_code, error_callback)){ goto cleanup; } if (http_code > 299) { - if (error_callback) { - error_callback(parse_error(response_data.response)); - } + zb_report_body_error(response_data.response, http_code, error_callback); } else { if (success_callback) { - json_object *j_obj = json_tokener_parse(response_data.response); - if (j_obj == NULL) { - error_callback(parse_error("Failed to parse json string")); + json_object *j_obj = NULL; + if (!zb_read_success_json(response_data.response, http_code, &j_obj, error_callback)) { goto cleanup; } ZBFindEmailResponse response_obj = zb_find_email_response_from_json(j_obj); diff --git a/src/ZeroBounceWarmup.c b/src/ZeroBounceWarmup.c new file mode 100644 index 0000000..d087593 --- /dev/null +++ b/src/ZeroBounceWarmup.c @@ -0,0 +1,877 @@ +#include +#include +#include +#include + +#include + +#include "ZeroBounce/ZeroBounceWarmup.h" +#include "ZeroBounce/zb_internal.h" + +#define ZB_WARMUP_DEFAULT_BASE_URL "https://warmup-api.zerobounce.net" +#define ZB_WARMUP_BASE_PATH "/api/v1/warmup" +/* team_id is required by the schema but always replaced with the key's team. */ +#define ZB_WARMUP_ANY_TEAM_ID 1 + +ZBWarmupEmailContent new_warmup_email_content(void) { + ZBWarmupEmailContent content = {NULL, NULL, NULL, NULL, NULL}; + return content; +} + +ZBWarmupManagedSending new_warmup_managed_sending(void) { + ZBWarmupManagedSending sending = {-1, NULL, -99, NULL}; + return sending; +} + +ZBWarmupAddSendingAccountOptions new_warmup_add_sending_account_options(void) { + ZBWarmupAddSendingAccountOptions options = {-1, -1, NULL, NULL}; + return options; +} + +ZBWarmupListFromAddressesOptions new_warmup_list_from_addresses_options(void) { + ZBWarmupListFromAddressesOptions options = {NULL, -1, -1}; + return options; +} + +ZeroBounceWarmup *new_zero_bounce_warmup_instance(void) { + ZeroBounceWarmup *zbw = malloc(sizeof(ZeroBounceWarmup)); + if (!zbw) { + fprintf(stderr, "Memory allocation failed.\n"); + exit(EXIT_FAILURE); + } + zbw->api_key = NULL; + zbw->base_url = ZB_WARMUP_DEFAULT_BASE_URL; + return zbw; +} + +void zero_bounce_warmup_initialize(ZeroBounceWarmup *zbw, const char *api_key) { + if (!zbw) { + return; + } + if (!zb_api_key_is_clean(api_key)) { + fprintf(stderr, + "ZeroBounce: the Warmup api_key has surrounding whitespace or control " + "characters; it was not accepted.\n"); + return; + } + free(zbw->api_key); + zbw->api_key = api_key ? strdup(api_key) : NULL; +} + +void zero_bounce_warmup_set_base_url(ZeroBounceWarmup *zbw, const char *url) { + if (zbw && url && url[0]) { + zbw->base_url = url; + } +} + +void zero_bounce_warmup_free(ZeroBounceWarmup *zbw) { + if (!zbw) { + return; + } + free(zbw->api_key); + free(zbw); +} + +/* -- argument checks ------------------------------------------------------ */ + +static bool warmup_is_blank(const char *value) { + const char *p; + if (!value) { + return true; + } + for (p = value; *p; p++) { + if (!isspace((unsigned char)*p)) { + return false; + } + } + return true; +} + +static bool warmup_ready(ZeroBounceWarmup *zbw, OnErrorCallback error_callback) { + if (zbw && zbw->api_key && zbw->api_key[0]) { + return true; + } + if (error_callback) { + error_callback(parse_error( + "The Warmup API is not initialized. Please call " + "zero_bounce_warmup_initialize(zbw_instance, api_key); first" + )); + } + return false; +} + +static bool warmup_required(const char *value, const char *name, OnErrorCallback error_callback) { + char message[128]; + if (!warmup_is_blank(value)) { + return true; + } + snprintf(message, sizeof(message), "Empty parameter: %s", name); + if (error_callback) { + error_callback(parse_error(message)); + } + return false; +} + +static bool warmup_in_range(int value, int low, int high, const char *name, OnErrorCallback error_callback) { + char message[160]; + if (value >= low && value <= high) { + return true; + } + snprintf(message, sizeof(message), "Invalid parameter: %s (must be %d to %d)", name, low, high); + if (error_callback) { + error_callback(parse_error(message)); + } + return false; +} + +static bool warmup_iso_date(const char *value, const char *name, OnErrorCallback error_callback) { + char message[160]; + int i; + bool ok = value && strlen(value) == 10; + for (i = 0; ok && i < 10; i++) { + if (i == 4 || i == 7) { + ok = value[i] == '-'; + } else { + ok = isdigit((unsigned char)value[i]) != 0; + } + } + if (ok) { + return true; + } + snprintf(message, sizeof(message), "Invalid parameter: %s (expected YYYY-MM-DD)", name); + if (error_callback) { + error_callback(parse_error(message)); + } + return false; +} + +/* -- request plumbing ----------------------------------------------------- */ + +/* Appends "name=value" to a query string, with the right separator. */ +static void warmup_add_query(char *query, size_t query_len, const char *name, const char *value) { + char *escaped = zb_url_escape(value ? value : ""); + size_t used = strlen(query); + snprintf(query + used, query_len - used, "%s%s=%s", + used ? "&" : "?", name, escaped ? escaped : ""); + free(escaped); +} + +/* + * 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. + */ +static char *warmup_redact(const char *text, const char *const *secrets, int secret_count) { + char *out = strdup(text ? text : ""); + int i; + if (!out) { + return NULL; + } + for (i = 0; i < secret_count; i++) { + const char *secret = secrets[i]; + char *at; + size_t len; + if (!secret || !secret[0]) { + continue; + } + len = strlen(secret); + while ((at = strstr(out, secret)) != NULL) { + memmove(at + 3, at + len, strlen(at + len) + 1); + memcpy(at, "***", 3); + } + } + return out; +} + +/* Reports an error with the secrets taken out of its message. */ +static void warmup_report( + ZBErrorResponse error, const char *const *secrets, int secret_count, + OnErrorCallback error_callback +) { + size_t i; + if (!error_callback) { + return; + } + for (i = 0; i < error.errors.size; i++) { + char *safe = warmup_redact(error.errors.data[i], secrets, secret_count); + if (safe) { + free(error.errors.data[i]); + error.errors.data[i] = safe; + } + } + error_callback(error); +} + +typedef struct { + OnErrorCallback error_callback; + const char *const *secrets; + int secret_count; +} WarmupErrorCtx; + +static _Thread_local WarmupErrorCtx warmup_error_ctx; + +static void warmup_on_error(ZBErrorResponse error) { + warmup_report(error, warmup_error_ctx.secrets, warmup_error_ctx.secret_count, + warmup_error_ctx.error_callback); +} + +/* + * One Warmup request. The body is handed to success_callback raw; HTTP 204 + * and an empty 200 are the documented empty successes and come back as "". + */ +static void warmup_send( + ZeroBounceWarmup *zbw, + const char *method, + const char *path, + const char *query, + const char *body, + const char *const *secrets, + int secret_count, + bool expect_csv, + const char *download_path, + OnSuccessCallbackRawJson success_callback, + OnErrorCallback error_callback +) { + char *url_path; + char *auth_header; + int url_len; + int auth_len; + memory response_data = {0}; + long http_code = 0; + const char *text; + + if (!warmup_ready(zbw, error_callback)) { + return; + } + + url_len = snprintf(NULL, 0, "%s%s%s%s", zbw->base_url, ZB_WARMUP_BASE_PATH, path, query ? query : ""); + url_path = malloc((size_t)url_len + 1); + auth_len = snprintf(NULL, 0, "API-Key: %s", zbw->api_key); + auth_header = malloc((size_t)auth_len + 1); + if (!url_path || !auth_header) { + free(url_path); + free(auth_header); + fprintf(stderr, "Memory allocation failed.\n"); + exit(EXIT_FAILURE); + } + snprintf(url_path, (size_t)url_len + 1, "%s%s%s%s", + zbw->base_url, ZB_WARMUP_BASE_PATH, path, query ? query : ""); + snprintf(auth_header, (size_t)auth_len + 1, "API-Key: %s", zbw->api_key); + + warmup_error_ctx.error_callback = error_callback; + warmup_error_ctx.secrets = secrets; + warmup_error_ctx.secret_count = secret_count; + + if (!zb_make_request_with_extra_header( + url_path, method, body, "Content-Type: application/json", auth_header, + &response_data, &http_code, warmup_on_error)) { + free(auth_header); + free(url_path); + free(response_data.response); + return; + } + + text = response_data.response ? response_data.response : ""; + if (http_code > 299 || zb_body_looks_like_html(text)) { + zb_report_body_error(text, http_code, warmup_on_error); + } else if (expect_csv) { + /* An error comes back labelled as JSON, so it is not a file. */ + if (!text[0] || text[0] == '{' || text[0] == '[') { + zb_report_body_error(text, http_code, warmup_on_error); + } else { + if (download_path && download_path[0]) { + FILE *handle = fopen(download_path, "wb"); + if (handle) { + fwrite(text, 1, strlen(text), handle); + fclose(handle); + } else if (error_callback) { + error_callback(parse_error("Could not open download_path for writing")); + } + } + if (success_callback) { + success_callback(text); + } + } + } else if (success_callback) { + /* 204 and an empty 200 are the documented empty successes. */ + success_callback(text); + } + + free(auth_header); + free(url_path); + free(response_data.response); +} + +static void warmup_json( + ZeroBounceWarmup *zbw, const char *method, const char *path, const char *query, + const char *body, OnSuccessCallbackRawJson success_callback, OnErrorCallback error_callback +) { + warmup_send(zbw, method, path, query, body, NULL, 0, false, NULL, + success_callback, error_callback); +} + +/* -- sending accounts ----------------------------------------------------- */ + +void warmup_list_sending_accounts( + ZeroBounceWarmup *zbw, OnSuccessCallbackRawJson success_callback, OnErrorCallback error_callback +) { + warmup_json(zbw, "GET", "/sending-accounts", NULL, NULL, success_callback, error_callback); +} + +void warmup_get_sending_account( + ZeroBounceWarmup *zbw, const char *from_address, + OnSuccessCallbackRawJson success_callback, OnErrorCallback error_callback +) { + char query[1024] = ""; + if (!warmup_ready(zbw, error_callback) || + !warmup_required(from_address, "from_address", error_callback)) { + return; + } + warmup_add_query(query, sizeof(query), "from_address", from_address); + warmup_json(zbw, "GET", "/sending-account", query, NULL, success_callback, error_callback); +} + +void warmup_add_sending_account( + ZeroBounceWarmup *zbw, const char *from_address, const char *onboarding_model, + const ZBWarmupAddSendingAccountOptions *options, + OnSuccessCallbackRawJson success_callback, OnErrorCallback error_callback +) { + json_object *payload; + bool self_managed; + + if (!warmup_ready(zbw, error_callback) || + !warmup_required(from_address, "from_address", error_callback)) { + return; + } + if (!onboarding_model || + (strcmp(onboarding_model, ZB_WARMUP_SELF_MANAGED) != 0 && + strcmp(onboarding_model, ZB_WARMUP_ZB_MANAGED) != 0)) { + if (error_callback) { + error_callback(parse_error( + "Invalid parameter: onboarding_model (must be self_managed or zb_managed)")); + } + return; + } + self_managed = strcmp(onboarding_model, ZB_WARMUP_SELF_MANAGED) == 0; + + if (self_managed) { + if (!options || options->engagement_rule_id < 0) { + if (error_callback) { + error_callback(parse_error( + "Empty parameter: engagement_rule_id (required for self_managed)")); + } + return; + } + if (!warmup_in_range(options->engagement_rule_id, 0, 4, "engagement_rule_id", error_callback)) { + return; + } + if (options->email_content || options->managed_sending) { + if (error_callback) { + error_callback(parse_error( + "Invalid parameter: email_content and managed_sending are for zb_managed")); + } + return; + } + } + + payload = json_object_new_object(); + json_object_object_add(payload, "from_address", json_object_new_string(from_address)); + json_object_object_add(payload, "onboarding_model", json_object_new_string(onboarding_model)); + if (self_managed) { + json_object_object_add( + payload, "engagement_rule_id", json_object_new_int(options->engagement_rule_id)); + } + if (options && options->ignore_conflict >= 0) { + json_object_object_add( + payload, "ignore_conflict", json_object_new_boolean(options->ignore_conflict ? 1 : 0)); + } + if (options && options->email_content) { + const ZBWarmupEmailContent *content = options->email_content; + json_object *node = json_object_new_object(); + if (content->company_name) { + json_object_object_add(node, "company_name", json_object_new_string(content->company_name)); + } + if (content->company_website) { + json_object_object_add(node, "company_website", json_object_new_string(content->company_website)); + } + if (content->company_description) { + json_object_object_add( + node, "company_description", json_object_new_string(content->company_description)); + } + if (content->types_of_emails) { + json_object *types = json_object_new_array(); + const char *p = content->types_of_emails; + while (*p) { + char item[32]; + size_t n = 0; + while (*p == ' ' || *p == ',') { + p++; + } + while (*p && *p != ',' && n + 1 < sizeof(item)) { + item[n++] = (char)tolower((unsigned char)*p++); + } + while (n > 0 && item[n - 1] == ' ') { + n--; + } + item[n] = '\0'; + if (!n) { + continue; + } + if (strcmp(item, "transactional") != 0 && strcmp(item, "newsletter") != 0 && + strcmp(item, "marketing") != 0) { + json_object_put(types); + json_object_put(node); + json_object_put(payload); + if (error_callback) { + error_callback(parse_error( + "Invalid parameter: types_of_emails " + "(transactional, newsletter and/or marketing)")); + } + return; + } + json_object_array_add(types, json_object_new_string(item)); + } + json_object_object_add(node, "types_of_emails", types); + } + if (content->example_email) { + json_object_object_add(node, "example_email", json_object_new_string(content->example_email)); + } + json_object_object_add(payload, "email_content", node); + } + if (options && options->managed_sending) { + const ZBWarmupManagedSending *sending = options->managed_sending; + json_object *node = json_object_new_object(); + if (sending->weekdays_only >= 0) { + json_object_object_add( + node, "weekdays_only", json_object_new_boolean(sending->weekdays_only ? 1 : 0)); + } + if (sending->timezone) { + json_object_object_add(node, "timezone", json_object_new_string(sending->timezone)); + } + if (sending->timezone_offset != -99) { + if (!warmup_in_range(sending->timezone_offset, -11, 14, "timezone_offset", error_callback)) { + json_object_put(node); + json_object_put(payload); + return; + } + json_object_object_add( + node, "timezone_offset", json_object_new_int(sending->timezone_offset)); + } + if (sending->warmup_start_date) { + if (!warmup_iso_date(sending->warmup_start_date, "warmup_start_date", error_callback)) { + json_object_put(node); + json_object_put(payload); + return; + } + json_object_object_add( + node, "warmup_start_date", json_object_new_string(sending->warmup_start_date)); + } + json_object_object_add(payload, "managed_sending", node); + } + + warmup_json(zbw, "POST", "/sending-accounts", NULL, + json_object_to_json_string(payload), success_callback, error_callback); + json_object_put(payload); +} + +void warmup_update_sending_account( + ZeroBounceWarmup *zbw, const char *from_address, int engagement_rule_id, int status_id, + bool auto_enable, OnSuccessCallbackRawJson success_callback, OnErrorCallback error_callback +) { + json_object *payload; + + if (!warmup_ready(zbw, error_callback) || + !warmup_required(from_address, "from_address", error_callback) || + !warmup_in_range(engagement_rule_id, 0, 4, "engagement_rule_id", error_callback) || + !warmup_in_range(status_id, 0, 8, "status_id", error_callback)) { + return; + } + payload = json_object_new_object(); + json_object_object_add(payload, "team_id", json_object_new_int(ZB_WARMUP_ANY_TEAM_ID)); + json_object_object_add(payload, "from_address", json_object_new_string(from_address)); + json_object_object_add(payload, "engagement_rule_id", json_object_new_int(engagement_rule_id)); + json_object_object_add(payload, "status_id", json_object_new_int(status_id)); + json_object_object_add(payload, "auto_enable", json_object_new_boolean(auto_enable ? 1 : 0)); + warmup_json(zbw, "PUT", "/sending-accounts", NULL, + json_object_to_json_string(payload), success_callback, error_callback); + json_object_put(payload); +} + +void warmup_delete_sending_account( + ZeroBounceWarmup *zbw, const char *from_address, + OnSuccessCallbackRawJson success_callback, OnErrorCallback error_callback +) { + char query[1024] = ""; + if (!warmup_ready(zbw, error_callback) || + !warmup_required(from_address, "from_address", error_callback)) { + return; + } + warmup_add_query(query, sizeof(query), "from_address", from_address); + warmup_json(zbw, "DELETE", "/sending-accounts", query, NULL, success_callback, error_callback); +} + +void warmup_change_from_address( + ZeroBounceWarmup *zbw, const char *old_from_address, const char *new_from_address, + OnSuccessCallbackRawJson success_callback, OnErrorCallback error_callback +) { + json_object *payload; + + if (!warmup_ready(zbw, error_callback) || + !warmup_required(old_from_address, "old_from_address", error_callback) || + !warmup_required(new_from_address, "new_from_address", error_callback)) { + return; + } + payload = json_object_new_object(); + json_object_object_add(payload, "team_id", json_object_new_int(ZB_WARMUP_ANY_TEAM_ID)); + json_object_object_add(payload, "old_from_address", json_object_new_string(old_from_address)); + json_object_object_add(payload, "new_from_address", json_object_new_string(new_from_address)); + warmup_json(zbw, "PUT", "/smtp-credentials", NULL, + json_object_to_json_string(payload), success_callback, error_callback); + json_object_put(payload); +} + +void warmup_list_from_addresses( + ZeroBounceWarmup *zbw, const ZBWarmupListFromAddressesOptions *options, + OnSuccessCallbackRawJson success_callback, OnErrorCallback error_callback +) { + char query[1024] = ""; + char number[32]; + + if (!warmup_ready(zbw, error_callback)) { + return; + } + if (options && options->search_from) { + const char *p; + for (p = options->search_from; *p; p++) { + if (!isalnum((unsigned char)*p) && !strchr(".-_@+", *p)) { + if (error_callback) { + error_callback(parse_error( + "Invalid parameter: search_from (letters, digits and . - _ @ + only)")); + } + return; + } + } + warmup_add_query(query, sizeof(query), "search_from", options->search_from); + } + /* The API defaults these to 0 and 100. */ + if (options && options->offset >= 0) { + snprintf(number, sizeof(number), "%d", options->offset); + warmup_add_query(query, sizeof(query), "offset", number); + } + if (options && options->limit >= 0) { + snprintf(number, sizeof(number), "%d", options->limit); + warmup_add_query(query, sizeof(query), "limit", number); + } + warmup_json(zbw, "GET", "/from-addresses", query, NULL, success_callback, error_callback); +} + +/* -- engagement rules ----------------------------------------------------- */ + +void warmup_get_engagement_rules( + ZeroBounceWarmup *zbw, OnSuccessCallbackRawJson success_callback, OnErrorCallback error_callback +) { + warmup_json(zbw, "GET", "/engagement-rules", NULL, NULL, success_callback, error_callback); +} + +void warmup_set_engagement_rule( + ZeroBounceWarmup *zbw, const char *c_from, int rule_id, + OnSuccessCallbackRawJson success_callback, OnErrorCallback error_callback +) { + char query[1024] = ""; + json_object *payload; + + if (!warmup_ready(zbw, error_callback) || + !warmup_required(c_from, "c_from", error_callback) || + !warmup_in_range(rule_id, 0, 4, "rule_id", error_callback)) { + return; + } + warmup_add_query(query, sizeof(query), "c_from", c_from); + payload = json_object_new_object(); + json_object_object_add(payload, "rule_id", json_object_new_int(rule_id)); + warmup_json(zbw, "PUT", "/engagement-rule", query, + json_object_to_json_string(payload), success_callback, error_callback); + json_object_put(payload); +} + +void warmup_get_custom_engagement( + ZeroBounceWarmup *zbw, const char *c_from, + OnSuccessCallbackRawJson success_callback, OnErrorCallback error_callback +) { + char path[640]; + char *segment; + + if (!warmup_ready(zbw, error_callback) || + !warmup_required(c_from, "c_from", error_callback)) { + return; + } + /* c_from is one path segment here, so a slash must stay encoded, and a + "." or ".." would walk the request out of the endpoint. */ + if (strcmp(c_from, ".") == 0 || strcmp(c_from, "..") == 0) { + if (error_callback) { + error_callback(parse_error("Invalid parameter: c_from")); + } + return; + } + segment = zb_url_escape(c_from); + snprintf(path, sizeof(path), "/custom-engagement/%s", segment ? segment : ""); + free(segment); + warmup_json(zbw, "GET", path, NULL, NULL, success_callback, error_callback); +} + +/* + * reply_pct 5-45, important_pct 5-40, click_pct 0-70 and at most + * important_pct x 1.75. Returns NULL when an argument was rejected. + */ +static json_object *warmup_engagement_payload( + const char *c_from, int reply_pct, int important_pct, int click_pct, + OnErrorCallback error_callback +) { + json_object *payload; + + if (!warmup_required(c_from, "c_from", error_callback) || + !warmup_in_range(reply_pct, 5, 45, "reply_pct", error_callback) || + !warmup_in_range(important_pct, 5, 40, "important_pct", error_callback) || + !warmup_in_range(click_pct, 0, 70, "click_pct", error_callback)) { + return NULL; + } + if (click_pct * 100 > important_pct * 175) { + if (error_callback) { + error_callback(parse_error("Invalid parameter: click_pct (at most important_pct x 1.75)")); + } + return NULL; + } + payload = json_object_new_object(); + json_object_object_add(payload, "c_from", json_object_new_string(c_from)); + json_object_object_add(payload, "reply_pct", json_object_new_int(reply_pct)); + json_object_object_add(payload, "important_pct", json_object_new_int(important_pct)); + json_object_object_add(payload, "click_pct", json_object_new_int(click_pct)); + return payload; +} + +void warmup_create_custom_engagement( + ZeroBounceWarmup *zbw, const char *c_from, int reply_pct, int important_pct, int click_pct, + OnSuccessCallbackRawJson success_callback, OnErrorCallback error_callback +) { + json_object *payload; + if (!warmup_ready(zbw, error_callback)) { + return; + } + payload = warmup_engagement_payload(c_from, reply_pct, important_pct, click_pct, error_callback); + if (!payload) { + return; + } + warmup_json(zbw, "POST", "/custom-engagement", NULL, + json_object_to_json_string(payload), success_callback, error_callback); + json_object_put(payload); +} + +void warmup_update_custom_engagement( + ZeroBounceWarmup *zbw, const char *c_from, int reply_pct, int important_pct, int click_pct, + OnSuccessCallbackRawJson success_callback, OnErrorCallback error_callback +) { + json_object *payload; + if (!warmup_ready(zbw, error_callback)) { + return; + } + payload = warmup_engagement_payload(c_from, reply_pct, important_pct, click_pct, error_callback); + if (!payload) { + return; + } + warmup_json(zbw, "PUT", "/custom-engagement", NULL, + json_object_to_json_string(payload), success_callback, error_callback); + json_object_put(payload); +} + +void warmup_delete_custom_engagement( + ZeroBounceWarmup *zbw, const char *c_from, int engagement_rule_id, + OnSuccessCallbackRawJson success_callback, OnErrorCallback error_callback +) { + json_object *payload; + + if (!warmup_ready(zbw, error_callback) || + !warmup_required(c_from, "c_from", error_callback) || + !warmup_in_range(engagement_rule_id, 1, 4, "engagement_rule_id", error_callback)) { + return; + } + payload = json_object_new_object(); + json_object_object_add(payload, "c_from", json_object_new_string(c_from)); + json_object_object_add(payload, "engagement_rule_id", json_object_new_int(engagement_rule_id)); + warmup_json(zbw, "DELETE", "/custom-engagement", NULL, + json_object_to_json_string(payload), success_callback, error_callback); + json_object_put(payload); +} + +/* -- seeds, progress and statistics --------------------------------------- */ + +void warmup_get_seeds( + ZeroBounceWarmup *zbw, OnSuccessCallbackRawJson success_callback, OnErrorCallback error_callback +) { + warmup_json(zbw, "GET", "/seeds", NULL, NULL, success_callback, error_callback); +} + +void warmup_download_seeds( + ZeroBounceWarmup *zbw, const char *download_path, + OnSuccessCallbackRawJson success_callback, OnErrorCallback error_callback +) { + warmup_send(zbw, "GET", "/seeds/download", NULL, NULL, NULL, 0, true, download_path, + success_callback, error_callback); +} + +void warmup_get_seed_count( + ZeroBounceWarmup *zbw, OnSuccessCallbackRawJson success_callback, OnErrorCallback error_callback +) { + warmup_json(zbw, "GET", "/seed-count", NULL, NULL, success_callback, error_callback); +} + +void warmup_get_progress_today( + ZeroBounceWarmup *zbw, OnSuccessCallbackRawJson success_callback, OnErrorCallback error_callback +) { + warmup_json(zbw, "GET", "/progress-today", NULL, NULL, success_callback, error_callback); +} + +void warmup_get_sent_today( + ZeroBounceWarmup *zbw, OnSuccessCallbackRawJson success_callback, OnErrorCallback error_callback +) { + warmup_json(zbw, "GET", "/sent-today", NULL, NULL, success_callback, error_callback); +} + +void warmup_get_emails_per_day( + ZeroBounceWarmup *zbw, OnSuccessCallbackRawJson success_callback, OnErrorCallback error_callback +) { + warmup_json(zbw, "GET", "/emails-per-day", NULL, NULL, success_callback, error_callback); +} + +void warmup_get_statistics( + ZeroBounceWarmup *zbw, const char *from_address, + OnSuccessCallbackRawJson success_callback, OnErrorCallback error_callback +) { + char query[1024] = ""; + if (!warmup_ready(zbw, error_callback) || + !warmup_required(from_address, "from_address", error_callback)) { + return; + } + warmup_add_query(query, sizeof(query), "from_address", from_address); + warmup_json(zbw, "GET", "/statistics", query, NULL, success_callback, error_callback); +} + +void warmup_get_statistics_per_day( + ZeroBounceWarmup *zbw, const char *from_address, const char *start_date, const char *end_date, + OnSuccessCallbackRawJson success_callback, OnErrorCallback error_callback +) { + char query[1024] = ""; + + if (!warmup_ready(zbw, error_callback) || + !warmup_required(from_address, "from_address", error_callback) || + !warmup_iso_date(start_date, "start_date", error_callback) || + !warmup_iso_date(end_date, "end_date", error_callback)) { + return; + } + if (strcmp(start_date, end_date) > 0) { + if (error_callback) { + error_callback(parse_error("Invalid parameter: start_date is later than end_date")); + } + return; + } + warmup_add_query(query, sizeof(query), "from_address", from_address); + warmup_add_query(query, sizeof(query), "start_date", start_date); + warmup_add_query(query, sizeof(query), "end_date", end_date); + warmup_json(zbw, "GET", "/statistics-per-day", query, NULL, success_callback, error_callback); +} + +void warmup_get_statistics_detailed( + ZeroBounceWarmup *zbw, const char *c_from, + OnSuccessCallbackRawJson success_callback, OnErrorCallback error_callback +) { + char query[1024] = ""; + if (!warmup_ready(zbw, error_callback) || + !warmup_required(c_from, "c_from", error_callback)) { + return; + } + warmup_add_query(query, sizeof(query), "c_from", c_from); + warmup_json(zbw, "GET", "/statistics-detailed", query, NULL, success_callback, error_callback); +} + +void warmup_download_statistics( + ZeroBounceWarmup *zbw, const char *c_from, const char *download_path, + OnSuccessCallbackRawJson success_callback, OnErrorCallback error_callback +) { + char query[1024] = ""; + if (!warmup_ready(zbw, error_callback) || + !warmup_required(c_from, "c_from", error_callback)) { + return; + } + warmup_add_query(query, sizeof(query), "c_from", c_from); + warmup_send(zbw, "GET", "/statistics/download", query, NULL, NULL, 0, true, download_path, + success_callback, error_callback); +} + +void warmup_get_from_address_statistics( + ZeroBounceWarmup *zbw, const char *from_address, + OnSuccessCallbackRawJson success_callback, OnErrorCallback error_callback +) { + char query[1024] = ""; + if (!warmup_ready(zbw, error_callback) || + !warmup_required(from_address, "from_address", error_callback)) { + return; + } + warmup_add_query(query, sizeof(query), "from_address", from_address); + warmup_json(zbw, "GET", "/from-address-statistics", query, NULL, success_callback, error_callback); +} + +/* -- deliverability checks ------------------------------------------------ */ + +void warmup_check_domain_dmarc( + ZeroBounceWarmup *zbw, const char *from_address, const char *dkim_selector, + OnSuccessCallbackRawJson success_callback, OnErrorCallback error_callback +) { + char query[1024] = ""; + + if (!warmup_ready(zbw, error_callback) || + !warmup_required(from_address, "from_address", error_callback)) { + return; + } + /* 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 (!strchr(from_address, '@')) { + if (error_callback) { + error_callback(parse_error( + "Invalid parameter: from_address (an email address, e.g. sender@example.com)")); + } + return; + } + warmup_add_query(query, sizeof(query), "from_address", from_address); + if (dkim_selector && dkim_selector[0]) { + warmup_add_query(query, sizeof(query), "dkim_selector", dkim_selector); + } + warmup_json(zbw, "GET", "/check-domain-dmarc", query, NULL, success_callback, error_callback); +} + +void warmup_test_smtp_configuration( + ZeroBounceWarmup *zbw, const char *from_address, const char *username, const char *password, + const char *server, int port, + OnSuccessCallbackRawJson success_callback, OnErrorCallback error_callback +) { + json_object *payload; + const char *secrets[2]; + + if (!warmup_ready(zbw, error_callback) || + !warmup_required(from_address, "from_address", error_callback) || + !warmup_required(username, "username", error_callback) || + !warmup_required(password, "password", error_callback) || + !warmup_required(server, "server", error_callback) || + !warmup_in_range(port, 1, 65535, "port", error_callback)) { + return; + } + secrets[0] = password; + secrets[1] = username; + + payload = json_object_new_object(); + json_object_object_add(payload, "from_address", json_object_new_string(from_address)); + json_object_object_add(payload, "username", json_object_new_string(username)); + json_object_object_add(payload, "password", json_object_new_string(password)); + json_object_object_add(payload, "server", json_object_new_string(server)); + json_object_object_add(payload, "port", json_object_new_int(port)); + warmup_send(zbw, "POST", "/test-smtp-configuration", NULL, + json_object_to_json_string(payload), secrets, 2, false, NULL, + success_callback, error_callback); + json_object_put(payload); +} diff --git a/test/OfflineApiTest.c b/test/OfflineApiTest.c new file mode 100644 index 0000000..6973b0e --- /dev/null +++ b/test/OfflineApiTest.c @@ -0,0 +1,1425 @@ +#include +#include +#include +#include +#include + +#include "unity.h" +#include "ZeroBounce/ZeroBounce.h" +#include "ZeroBounce/zb_internal.h" + +#define OFFLINE_MAX 64 + +typedef struct { + char method[16]; + char host[128]; + char path[256]; + char api_key[256]; + char body[8192]; + char query[4096]; + /* The one extra header the request carried, "Name: value", or "". */ + char extra_header[512]; +} OfflineRequest; + +static OfflineRequest g_requests[OFFLINE_MAX]; +static int g_count; +static pthread_mutex_t g_mu = PTHREAD_MUTEX_INITIALIZER; +static char g_error_text[1024]; +static int g_error_called; +static long g_error_status; +static int g_success_called; +static char g_echo[256]; +static _Thread_local char tls_echo[256]; + +static const char *html_page(void) { + static char *page = NULL; + if (!page) { + size_t i; + page = malloc(256 + 20000 * 16); + strcpy(page, "Access denied | zerobounce.net | Zero Bounce"); + for (i = 0; i < 20000; i++) { + strcat(page, "

blocked

"); + } + strcat(page, ""); + } + return page; +} + +static void url_decode(const char *in, char *out, size_t out_len) { + size_t o = 0; + for (size_t i = 0; in[i] && o + 1 < out_len; i++) { + if (in[i] == '%' && in[i + 1] && in[i + 2]) { + char hex[3] = {in[i + 1], in[i + 2], 0}; + out[o++] = (char)strtol(hex, NULL, 16); + i += 2; + } else if (in[i] == '+') { + out[o++] = ' '; + } else { + out[o++] = in[i]; + } + } + out[o] = '\0'; +} + +static void split_url(const char *url, char *host, char *path, char *query) { + const char *start = strstr(url, "://"); + const char *host_start = start ? start + 3 : url; + const char *path_start = strchr(host_start, '/'); + const char *query_start = strchr(host_start, '?'); + host[0] = path[0] = query[0] = '\0'; + if (!path_start) { + snprintf(host, 128, "%s", host_start); + snprintf(path, 256, "/"); + return; + } + snprintf(host, 128, "%.*s", (int)(path_start - host_start), host_start); + if (!query_start) { + snprintf(path, 256, "%s", path_start); + return; + } + snprintf(path, 256, "%.*s", (int)(query_start - path_start), path_start); + snprintf(query, 4096, "%s", query_start + 1); +} + +static int query_value(const char *query, const char *key, char *out, size_t out_len) { + size_t key_len = strlen(key); + const char *p = query; + while (p && *p) { + if ((p == query || p[-1] == '&') && strncmp(p, key, key_len) == 0 && p[key_len] == '=') { + char raw[1024]; + const char *end = strchr(p + key_len + 1, '&'); + size_t n = end ? (size_t)(end - (p + key_len + 1)) : strlen(p + key_len + 1); + if (n >= sizeof(raw)) n = sizeof(raw) - 1; + memcpy(raw, p + key_len + 1, n); + raw[n] = '\0'; + url_decode(raw, out, out_len); + return 1; + } + p = strchr(p, '&'); + if (p) p++; + } + return 0; +} + +static int field_value(const char *name, char *out, size_t out_len) { + int count = zero_bounce_test_field_count(); + for (int i = 0; i < count; i++) { + if (strcmp(zero_bounce_test_field_name(i), name) == 0) { + snprintf(out, out_len, "%s", zero_bounce_test_field_value(i)); + return 1; + } + } + return 0; +} + +static void remember(const OfflineRequest *request) { + pthread_mutex_lock(&g_mu); + if (g_count < OFFLINE_MAX) { + g_requests[g_count++] = *request; + } + pthread_mutex_unlock(&g_mu); +} + +static void set_body(memory *response_data, const char *text) { + size_t len = strlen(text); + response_data->response = malloc(len + 1); + memcpy(response_data->response, text, len + 1); + response_data->size = len; +} + +static int offline_perform(memory *response_data, long *http_code, char **content_type_out) { + OfflineRequest request; + char key[256] = ""; + const char *url = zero_bounce_test_url(); + const char *body = zero_bounce_test_body(); + memset(&request, 0, sizeof(request)); + snprintf(request.method, sizeof(request.method), "%s", zero_bounce_test_method()); + split_url(url ? url : "", request.host, request.path, request.query); + snprintf(request.body, sizeof(request.body), "%s", body ? body : ""); + if (!query_value(request.query, "api_key", key, sizeof(key))) { + query_value(request.body, "api_key", key, sizeof(key)); + } + if (!key[0]) { + const char *marker = strstr(request.body, "\"api_key\""); + if (marker) { + marker += 9; + while (*marker == ' ' || *marker == ':' || *marker == '\t') marker++; + if (*marker == '"') { + const char *end; + marker++; + end = strchr(marker, '"'); + if (end && (size_t)(end - marker) < sizeof(key)) { + memcpy(key, marker, (size_t)(end - marker)); + key[end - marker] = '\0'; + } + } + } + } + if (!key[0]) { + field_value("api_key", key, sizeof(key)); + } + snprintf(request.extra_header, sizeof(request.extra_header), "%s", + zero_bounce_test_extra_header() ? zero_bounce_test_extra_header() : ""); + if (!key[0]) { + /* The Inbox Placement Tester and the Warmup API carry the key in a + header instead of a parameter. */ + const char *header = request.extra_header; + const char *value = NULL; + if (strncmp(header, "Authorization: Bearer ", 22) == 0) { + value = header + 22; + } else if (strncmp(header, "API-Key: ", 9) == 0) { + value = header + 9; + } + if (value) { + snprintf(key, sizeof(key), "%s", value); + } + } + snprintf(request.api_key, sizeof(request.api_key), "%s", key); + remember(&request); + + usleep((useconds_t)(rand() % 21) * 1000); + + const char *content = "application/json"; + *http_code = 200; + if (strncmp(key, "html-403-", 9) == 0 || strncmp(key, "html-200-", 9) == 0) { + *http_code = strncmp(key, "html-403-", 9) == 0 ? 403 : 200; + set_body(response_data, html_page()); + content = "text/html; charset=UTF-8"; + } else if (strncmp(key, "empty-", 6) == 0) { + set_body(response_data, ""); + } else if (strncmp(key, "malformed-", 10) == 0) { + char *junk = malloc(6000); + strcpy(junk, "<<>>"); + memset(junk + 14, 'x', 5000); + junk[5014] = '\0'; + set_body(response_data, junk); + free(junk); + } else if (strncmp(key, "success-false-str", 17) == 0) { + /* The API also sends "success" as the string "false". */ + set_body(response_data, "{\"success\":\"False\",\"message\":\"File cannot be found.\"}"); + } else if (strncmp(key, "success-false", 13) == 0) { + set_body(response_data, "{\"success\":false,\"message\":\"File cannot be found.\"}"); + } else if (strncmp(key, "redirect-302", 12) == 0) { + *http_code = 302; + set_body(response_data, ""); + } else if (strncmp(key, "cf-json-", 8) == 0) { + *http_code = 403; + set_body(response_data, + "{\"type\":\"https://developers.cloudflare.com/support/troubleshooting/http-status-codes/cloudflare-1xxx-errors/error-1020/\"," + "\"title\":\"Error 1020: Access denied\",\"status\":403,\"error_code\":1020,\"cloudflare_error\":true}"); + } else if (strncmp(key, "ipt-detail", 10) == 0) { + /* The Inbox Placement Tester is a Django REST framework app and + reports errors under "detail". */ + *http_code = 404; + set_body(response_data, "{\"detail\":\"Inbox placement test not found.\"}"); + } else if (strncmp(key, "double-error-", 13) == 0) { + *http_code = 400; + set_body(response_data, "Error: Error: api_key is invalid"); + content = "text/plain"; + } else if (strstr(request.path, "validatebatch") && strncmp(key, "batch-account-", 14) == 0) { + set_body(response_data, + "{\"email_batch\":[],\"errors\":[{\"email_address\":\"all\",\"error\":\"Invalid API Key or your account ran out of credits\"}]}"); + } else if (strstr(request.path, "validatebatch") && strncmp(key, "batch-partial-", 14) == 0) { + set_body(response_data, + "{\"email_batch\":[{\"address\":\"good@example.com\",\"status\":\"valid\"}],\"errors\":[{\"email_address\":\"bad@\",\"error\":\"Invalid email address\"}]}"); + } else if (strstr(request.path, "getfile")) { + char csv[512]; + snprintf(csv, sizeof(csv), "email\n%s@example.com\n", key); + set_body(response_data, csv); + content = "application/octet-stream"; + } else if (strstr(request.path, "listS3files")) { + char json[512]; + snprintf(json, sizeof(json), "[{\"name\":\"%s\"}]", key); + set_body(response_data, json); + } else if (strstr(request.path, "validatebatch")) { + char json[512]; + snprintf(json, sizeof(json), "{\"email_batch\":[{\"address\":\"%s\",\"status\":\"valid\"}],\"errors\":[]}", key); + set_body(response_data, json); + } else if (strcmp(request.path + strlen(request.path) - 9, "/validate") == 0 || + (strlen(request.path) >= 9 && strstr(request.path, "/validate") && !strstr(request.path, "validatebatch") && !strstr(request.path, "validateS3"))) { + char json[256]; + snprintf(json, sizeof(json), "{\"address\":\"%s\",\"status\":\"valid\"}", key); + set_body(response_data, json); + } else if (strstr(request.path, "/scoring") && !strstr(request.path, "sendfile") && !strstr(request.path, "filestatus") && !strstr(request.path, "getfile") && !strstr(request.path, "deletefile")) { + char json[256]; + snprintf(json, sizeof(json), "{\"email\":\"%s\",\"score\":10}", key); + set_body(response_data, json); + } else if (strstr(request.path, "getcredits")) { + set_body(response_data, "{\"Credits\":\"7\"}"); + } else if (strstr(request.path, "getapiusage")) { + set_body(response_data, "{\"total\":1}"); + } else if (strstr(request.path, "/activity")) { + set_body(response_data, "{\"found\":true,\"active_in_days\":\"3\"}"); + } else if (strstr(request.path, "guessformat")) { + char json[256]; + snprintf(json, sizeof(json), "{\"email\":\"%s\",\"domain\":\"%s\",\"other_domain_formats\":[]}", key, key); + set_body(response_data, json); + } else if (strstr(request.path, "filters") || strstr(request.path, "listevaluator")) { + char json[256]; + snprintf(json, sizeof(json), "{\"echo_api_key\":\"%s\"}", key); + set_body(response_data, json); + } else { + char json[512]; + snprintf(json, sizeof(json), + "{\"success\":true,\"message\":\"ok\",\"file_id\":\"%s\",\"file_name\":\"f.csv\",\"email\":\"%s\",\"domain\":\"%s\",\"other_domain_formats\":[]}", + key, key, key); + set_body(response_data, json); + } + if (content_type_out) { + *content_type_out = strdup(content); + } + return 1; +} + +static void reset_offline(void) { + pthread_mutex_lock(&g_mu); + g_count = 0; + pthread_mutex_unlock(&g_mu); + g_error_called = 0; + g_success_called = 0; + g_error_text[0] = '\0'; + g_echo[0] = '\0'; + zero_bounce_set_http_perform_for_test(offline_perform); +} + +static ZeroBounce *client_with(const char *key) { + ZeroBounce *zb = new_zero_bounce_instance(); + zero_bounce_initialize(zb, key); + return zb; +} + +static void on_error(ZBErrorResponse error) { + size_t used = 0; + g_error_called = 1; + g_error_status = error.status_code; + g_error_text[0] = '\0'; + for (size_t i = 0; i < error.errors.size; i++) { + const char *part = error.errors.data[i] ? error.errors.data[i] : ""; + size_t n = strlen(part); + if (used && used + 1 < sizeof(g_error_text)) { + g_error_text[used++] = ' '; + } + if (used + n >= sizeof(g_error_text)) { + n = sizeof(g_error_text) - used - 1; + } + memcpy(g_error_text + used, part, n); + used += n; + g_error_text[used] = '\0'; + } +} + +static void expect_short_error(void) { + TEST_ASSERT_TRUE(g_error_called); + TEST_ASSERT_TRUE(strlen(g_error_text) < 500); + TEST_ASSERT_NULL(strstr(g_error_text, "blocked

")); + TEST_ASSERT_NULL(strstr(g_error_text, "Error: Error:")); + TEST_ASSERT_NULL(strstr(g_error_text, "[json.exception")); +} + +static OfflineRequest only_request(void) { + OfflineRequest empty; + memset(&empty, 0, sizeof(empty)); + TEST_ASSERT_EQUAL_INT(1, g_count); + if (g_count == 0) return empty; + return g_requests[0]; +} + +static int has_field(const char *name, const char *value) { + for (int i = 0; i < zero_bounce_test_field_count(); i++) { + if (strcmp(zero_bounce_test_field_name(i), name) == 0) { + if (!value || strcmp(zero_bounce_test_field_value(i), value) == 0) return 1; + } + } + return 0; +} + +static void on_credits(ZBCreditsResponse response) { + g_success_called = 1; + TEST_ASSERT_EQUAL_INT(7, response.credits); +} + +static void on_validate(ZBValidateResponse response) { + g_success_called = 1; + snprintf(tls_echo, sizeof(tls_echo), "%s", response.address ? response.address : ""); + snprintf(g_echo, sizeof(g_echo), "%s", tls_echo); +} + +static void on_batch(ZBValidateBatchResponse response) { + g_success_called = 1; + if (response.email_batch.size > 0) { + snprintf(g_echo, sizeof(g_echo), "%s", response.email_batch.data[0].address); + } + g_requests[0].body[0] = g_requests[0].body[0]; + if (response.errors.size > 0) { + snprintf(g_error_text, sizeof(g_error_text), "%s", response.errors.data[0].email_address); + } +} + +static void on_send(ZBSendFileResponse response) { + g_success_called = 1; + snprintf(g_echo, sizeof(g_echo), "%s", response.file_id ? response.file_id : ""); +} + +static void on_status(ZBFileStatusResponse response) { + g_success_called = 1; + snprintf(g_echo, sizeof(g_echo), "%s", response.file_id ? response.file_id : ""); +} + +static void on_get(ZBGetFileResponse response) { + g_success_called = 1; + (void)response; +} + +static void on_delete(ZBDeleteFileResponse response) { + g_success_called = 1; + (void)response; +} + +static void on_activity(ZBActivityDataResponse response) { + g_success_called = 1; + TEST_ASSERT_TRUE(response.found); +} + +static void on_score(ZBScoreEmailResponse response) { + g_success_called = 1; + snprintf(g_echo, sizeof(g_echo), "%s", response.email ? response.email : ""); +} + +static void on_find(ZBFindEmailResponse response) { + g_success_called = 1; + snprintf(g_echo, sizeof(g_echo), "%s", response.email ? response.email : ""); +} + +static void on_domain(ZBDomainSearchResponse response) { + g_success_called = 1; + snprintf(g_echo, sizeof(g_echo), "%s", response.domain ? response.domain : ""); +} + +static void on_usage(ZBGetApiUsageResponse response) { + g_success_called = 1; + TEST_ASSERT_EQUAL_INT(1, response.total); +} + +static char g_raw[1024]; +static void on_raw(const char *json) { + g_success_called = 1; + snprintf(g_raw, sizeof(g_raw), "%s", json ? json : ""); +} + +void test_offline_get_credits(void) { + reset_offline(); + ZeroBounce *zb = client_with("credits-key"); + get_credits(zb, on_credits, on_error); + TEST_ASSERT_TRUE(g_success_called); + OfflineRequest request = only_request(); + TEST_ASSERT_EQUAL_STRING("GET", request.method); + TEST_ASSERT_EQUAL_STRING("api.zerobounce.net", request.host); + TEST_ASSERT_EQUAL_STRING("/v2/getcredits", request.path); + TEST_ASSERT_EQUAL_STRING("credits-key", request.api_key); +} + +void test_offline_validate_options(void) { + reset_offline(); + ZeroBounce *zb = client_with("validate-key"); + ZBValidateOptions options = new_validate_options(); + options.activity_data = 0; + options.verify_plus = 1; + validate_email_with_options(zb, "person@example.com", "", &options, on_validate, on_error); + TEST_ASSERT_EQUAL_STRING("validate-key", g_echo); + OfflineRequest request = only_request(); + TEST_ASSERT_EQUAL_STRING("GET", request.method); + TEST_ASSERT_EQUAL_STRING("/v2/validate", request.path); + char value[64]; + TEST_ASSERT_TRUE(query_value(request.query, "email", value, sizeof(value))); + TEST_ASSERT_EQUAL_STRING("person@example.com", value); + TEST_ASSERT_TRUE(query_value(request.query, "activity_data", value, sizeof(value))); + TEST_ASSERT_EQUAL_STRING("false", value); + TEST_ASSERT_TRUE(query_value(request.query, "verify_plus", value, sizeof(value))); + TEST_ASSERT_EQUAL_STRING("true", value); + TEST_ASSERT_FALSE(query_value(request.query, "timeout", value, sizeof(value))); +} + +void test_offline_validate_post(void) { + reset_offline(); + ZeroBounce *zb = client_with("post-key"); + ZBValidateOptions options = new_validate_options(); + options.use_post = 1; + options.timeout = 15; + validate_email_with_options(zb, "person@example.com", "203.0.113.5", &options, on_validate, on_error); + OfflineRequest request = only_request(); + TEST_ASSERT_EQUAL_STRING("POST", request.method); + TEST_ASSERT_EQUAL_STRING("/v2/validate", request.path); + TEST_ASSERT_NOT_NULL(strstr(request.body, "api_key=post-key")); + TEST_ASSERT_NOT_NULL(strstr(request.body, "email=person%40example.com")); + TEST_ASSERT_NOT_NULL(strstr(request.body, "timeout=15")); + TEST_ASSERT_NULL(strstr(request.body, "activity_data")); +} + +void test_offline_batch_omits_and_keeps_zero_ip(void) { + reset_offline(); + ZeroBounce *zb = client_with("batch-key"); + EmailToValidateVector emails = email_to_validate_vector_init(); + ZBEmailToValidate a = {"a@example.com", ""}; + ZBEmailToValidate b = {"b@example.com", "203.0.113.9"}; + email_to_validate_vector_append(&emails, a); + email_to_validate_vector_append(&emails, b); + validate_email_batch(zb, emails, on_batch, on_error); + TEST_ASSERT_EQUAL_STRING("batch-key", g_echo); + OfflineRequest request = only_request(); + TEST_ASSERT_EQUAL_STRING("POST", request.method); + TEST_ASSERT_EQUAL_STRING("api.zerobounce.net", request.host); + TEST_ASSERT_EQUAL_STRING("/v2/validatebatch", request.path); + TEST_ASSERT_NOT_NULL(strstr(request.body, "null")); + TEST_ASSERT_NOT_NULL(strstr(request.body, "203.0.113.9")); + TEST_ASSERT_NULL(strstr(request.body, "\"timeout\"")); + email_to_validate_vector_free(&emails); +} + +void test_offline_batch_explicit_false(void) { + reset_offline(); + ZeroBounce *zb = client_with("batch-opt"); + EmailToValidateVector emails = email_to_validate_vector_init(); + ZBEmailToValidate a = {"a@example.com", "0.0.0.0"}; + email_to_validate_vector_append(&emails, a); + ZBValidateOptions options = new_validate_options(); + options.timeout = 30; + options.activity_data = 0; + options.verify_plus = 1; + validate_email_batch_with_options(zb, emails, &options, on_batch, on_error); + OfflineRequest request = only_request(); + TEST_ASSERT_NOT_NULL(strstr(request.body, "\"timeout\"")); + TEST_ASSERT_NOT_NULL(strstr(request.body, "30")); + TEST_ASSERT_NOT_NULL(strstr(request.body, "\"activity_data\"")); + TEST_ASSERT_NOT_NULL(strstr(request.body, "false")); + TEST_ASSERT_NOT_NULL(strstr(request.body, "\"verify_plus\"")); + TEST_ASSERT_NOT_NULL(strstr(request.body, "true")); + TEST_ASSERT_NOT_NULL(strstr(request.body, "0.0.0.0")); + email_to_validate_vector_free(&emails); +} + +void test_offline_send_file_bools_and_zero_column(void) { + reset_offline(); + ZeroBounce *zb = client_with("file-key"); + SendFileOptions options = new_send_file_options(); + options.return_url = "https://example.com/hook"; + options.allow_phase_2_is_set = true; + options.allow_phase_2 = false; + options.remove_duplicate = false; + options.has_header_row = true; + send_file(zb, "email_file.csv", 0, options, on_send, on_error); + TEST_ASSERT_EQUAL_STRING("file-key", g_echo); + OfflineRequest request = only_request(); + TEST_ASSERT_EQUAL_STRING("POST", request.method); + TEST_ASSERT_EQUAL_STRING("bulkapi.zerobounce.net", request.host); + TEST_ASSERT_EQUAL_STRING("/v2/sendfile", request.path); + TEST_ASSERT_TRUE(has_field("email_address_column", "0")); + TEST_ASSERT_TRUE(has_field("file", "")); + TEST_ASSERT_TRUE(has_field("has_header_row", "true")); + TEST_ASSERT_TRUE(has_field("remove_duplicate", "false")); + TEST_ASSERT_TRUE(has_field("allow_phase_2", "false")); + TEST_ASSERT_FALSE(has_field("first_name_column", NULL)); +} + +void test_offline_scoring_send_omits_validation_fields(void) { + reset_offline(); + ZeroBounce *zb = client_with("score-file"); + SendFileOptions options = new_send_file_options(); + options.first_name_column = 2; + options.allow_phase_2_is_set = true; + options.allow_phase_2 = true; + scoring_send_file(zb, "email_file.csv", 1, options, on_send, on_error); + OfflineRequest request = only_request(); + TEST_ASSERT_EQUAL_STRING("/v2/scoring/sendfile", request.path); + TEST_ASSERT_FALSE(has_field("first_name_column", NULL)); + TEST_ASSERT_FALSE(has_field("allow_phase_2", NULL)); + TEST_ASSERT_TRUE(has_field("has_header_row", "true")); +} + +void test_offline_file_status_and_delete(void) { + reset_offline(); + ZeroBounce *zb = client_with("status-key"); + file_status(zb, "file-1", on_status, on_error); + OfflineRequest request = only_request(); + TEST_ASSERT_EQUAL_STRING("/v2/filestatus", request.path); + TEST_ASSERT_EQUAL_STRING("bulkapi.zerobounce.net", request.host); + char value[64]; + TEST_ASSERT_TRUE(query_value(request.query, "file_id", value, sizeof(value))); + TEST_ASSERT_EQUAL_STRING("file-1", value); + reset_offline(); + scoring_file_status(zb, "file-2", on_status, on_error); + TEST_ASSERT_EQUAL_STRING("/v2/scoring/filestatus", only_request().path); + reset_offline(); + delete_file(zb, "file-1", on_delete, on_error); + TEST_ASSERT_EQUAL_STRING("/v2/deletefile", only_request().path); + reset_offline(); + scoring_delete_file(zb, "file-2", on_delete, on_error); + TEST_ASSERT_EQUAL_STRING("/v2/scoring/deletefile", only_request().path); +} + +void test_offline_get_file_options(void) { + reset_offline(); + ZeroBounce *zb = client_with("getfile-key"); + GetFileOptions options = new_get_file_options(); + options.download_type = ZB_DOWNLOAD_TYPE_PHASE_2; + options.activity_data = 1; + get_file_with_options(zb, "file-9", "test/downloads/offline-get.csv", &options, on_get, on_error); + TEST_ASSERT_TRUE(g_success_called); + OfflineRequest request = only_request(); + TEST_ASSERT_EQUAL_STRING("/v2/getfile", request.path); + char value[64]; + TEST_ASSERT_TRUE(query_value(request.query, "download_type", value, sizeof(value))); + TEST_ASSERT_EQUAL_STRING("phase_2", value); + TEST_ASSERT_TRUE(query_value(request.query, "activity_data", value, sizeof(value))); + TEST_ASSERT_EQUAL_STRING("true", value); + reset_offline(); + GetFileOptions scoring = new_get_file_options(); + scoring.download_type = ZB_DOWNLOAD_TYPE_PHASE_1; + scoring.activity_data = 1; + scoring_get_file_with_options(zb, "file-8", "test/downloads/offline-score.csv", &scoring, on_get, on_error); + request = only_request(); + TEST_ASSERT_EQUAL_STRING("/v2/scoring/getfile", request.path); + TEST_ASSERT_TRUE(query_value(request.query, "download_type", value, sizeof(value))); + TEST_ASSERT_FALSE(query_value(request.query, "activity_data", value, sizeof(value))); +} + +void test_offline_activity_usage_score(void) { + reset_offline(); + ZeroBounce *zb = client_with("usage-key"); + struct tm start = {0}; + struct tm end = {0}; + start.tm_year = 118; start.tm_mon = 0; start.tm_mday = 1; + end.tm_year = 118; end.tm_mon = 0; end.tm_mday = 31; + get_api_usage(zb, start, end, on_usage, on_error); + OfflineRequest request = only_request(); + TEST_ASSERT_EQUAL_STRING("/v2/getapiusage", request.path); + char value[32]; + TEST_ASSERT_TRUE(query_value(request.query, "start_date", value, sizeof(value))); + TEST_ASSERT_EQUAL_STRING("2018-01-01", value); + reset_offline(); + get_activity_data(zb, "person@example.com", on_activity, on_error); + TEST_ASSERT_EQUAL_STRING("/v2/activity", only_request().path); + reset_offline(); + score_email(zb, "person@example.com", on_score, on_error); + TEST_ASSERT_EQUAL_STRING("usage-key", g_echo); + request = only_request(); + TEST_ASSERT_EQUAL_STRING("GET", request.method); + TEST_ASSERT_EQUAL_STRING("/v2/scoring", request.path); + reset_offline(); + score_email_post(zb, "person@example.com", on_score, on_error); + request = only_request(); + TEST_ASSERT_EQUAL_STRING("POST", request.method); + TEST_ASSERT_NOT_NULL(strstr(request.body, "email=person%40example.com")); +} + +void test_offline_finder_and_domain(void) { + reset_offline(); + ZeroBounce *zb = client_with("finder-key"); + find_email_by_domain_first_middle_last_name(zb, "example.com", "Ada", "M", "Lovelace", on_find, on_error); + OfflineRequest request = only_request(); + TEST_ASSERT_EQUAL_STRING("/v2/guessformat", request.path); + char value[64]; + TEST_ASSERT_TRUE(query_value(request.query, "domain", value, sizeof(value))); + TEST_ASSERT_EQUAL_STRING("example.com", value); + TEST_ASSERT_FALSE(query_value(request.query, "company_name", value, sizeof(value))); + reset_offline(); + find_email_by_company_name_first_name(zb, "ZeroBounce", "Ada", on_find, on_error); + request = only_request(); + TEST_ASSERT_TRUE(query_value(request.query, "company_name", value, sizeof(value))); + TEST_ASSERT_EQUAL_STRING("ZeroBounce", value); + TEST_ASSERT_FALSE(query_value(request.query, "domain", value, sizeof(value))); + TEST_ASSERT_FALSE(query_value(request.query, "last_name", value, sizeof(value))); + reset_offline(); + search_domain_by_domain(zb, "example.com", on_domain, on_error); + TEST_ASSERT_FALSE(query_value(only_request().query, "company_name", value, sizeof(value))); + reset_offline(); + search_domain_by_company_name(zb, "ZeroBounce", on_domain, on_error); + request = only_request(); + TEST_ASSERT_TRUE(query_value(request.query, "company_name", value, sizeof(value))); + TEST_ASSERT_FALSE(query_value(request.query, "domain", value, sizeof(value))); +} + +void test_offline_priority3(void) { + reset_offline(); + ZeroBounce *zb = client_with("p3-key"); + EmailFinderSendFileOptions finder = new_email_finder_send_file_options(); + finder.has_header_row = 0; + finder.first_name_column = 0; + send_email_finder_file(zb, "email_file.csv", 1, &finder, on_send, on_error); + OfflineRequest request = only_request(); + TEST_ASSERT_EQUAL_STRING("bulkapi.zerobounce.net", request.host); + TEST_ASSERT_EQUAL_STRING("/email-finder/sendfile", request.path); + TEST_ASSERT_TRUE(has_field("domain_column", "1")); + TEST_ASSERT_TRUE(has_field("has_header_row", "false")); + TEST_ASSERT_FALSE(has_field("first_name_column", NULL)); + reset_offline(); + email_finder_file_status(zb, "fid", on_status, on_error); + TEST_ASSERT_EQUAL_STRING("/email-finder/filestatus", only_request().path); + reset_offline(); + email_finder_get_file(zb, "fid", "test/downloads/finder.csv", on_get, on_error); + TEST_ASSERT_EQUAL_STRING("/email-finder/getfile", only_request().path); + reset_offline(); + email_finder_delete_file(zb, "fid", on_delete, on_error); + TEST_ASSERT_EQUAL_STRING("/email-finder/deletefile", only_request().path); + reset_offline(); + DomainSearchSendFileOptions domain_opts = new_domain_search_send_file_options(); + domain_opts.has_header_row = 0; + send_domain_search_file(zb, "email_file.csv", 2, &domain_opts, on_send, on_error); + request = only_request(); + TEST_ASSERT_EQUAL_STRING("/domain-search/sendfile", request.path); + TEST_ASSERT_TRUE(has_field("has_header_row", "false")); + TEST_ASSERT_TRUE(has_field("domain_column", "2")); + reset_offline(); + domain_search_file_status(zb, "fid", on_status, on_error); + TEST_ASSERT_EQUAL_STRING("/domain-search/filestatus", only_request().path); + reset_offline(); + domain_search_get_file(zb, "fid", "test/downloads/domain.csv", on_get, on_error); + TEST_ASSERT_EQUAL_STRING("/domain-search/getfile", only_request().path); + reset_offline(); + domain_search_delete_file(zb, "fid", on_delete, on_error); + TEST_ASSERT_EQUAL_STRING("/domain-search/deletefile", only_request().path); + reset_offline(); + send_list_evaluator_file(zb, "email_file.csv", 1, on_send, on_error); + TEST_ASSERT_EQUAL_STRING("/v2/listevaluator/", only_request().path); + TEST_ASSERT_TRUE(has_field("email_address_column", "1")); + reset_offline(); + get_list_evaluator_result(zb, "fid", on_raw, on_error); + TEST_ASSERT_NOT_NULL(strstr(g_raw, "p3-key")); + TEST_ASSERT_EQUAL_STRING("/v2/listevaluator/fid/", only_request().path); + reset_offline(); + /* LIVE: send-evaluated-file takes the file id as a form field, not an + upload, and the trailing slash is part of the endpoint. */ + send_evaluated_file(zb, "fid-1", on_send, on_error); + request = only_request(); + TEST_ASSERT_EQUAL_STRING("POST", request.method); + TEST_ASSERT_EQUAL_STRING("/v2/send-evaluated-file/", request.path); + TEST_ASSERT_NOT_NULL(strstr(request.body, "file_id=fid-1")); + reset_offline(); + add_filter(zb, "exclude", "domain", "example.com", on_raw, on_error); + request = only_request(); + TEST_ASSERT_EQUAL_STRING("POST", request.method); + TEST_ASSERT_EQUAL_STRING("/v2/filters/add", request.path); + TEST_ASSERT_NOT_NULL(strstr(request.body, "rule=exclude")); + reset_offline(); + list_filters(zb, false, on_raw, on_error); + TEST_ASSERT_EQUAL_STRING("GET", only_request().method); + TEST_ASSERT_EQUAL_STRING("/v2/filters/list", only_request().path); + reset_offline(); + list_filters(zb, true, on_raw, on_error); + TEST_ASSERT_EQUAL_STRING("POST", only_request().method); + reset_offline(); + delete_filter(zb, "exclude", "domain", "example.com", on_raw, on_error); + TEST_ASSERT_EQUAL_STRING("/v2/filters/delete", only_request().path); + reset_offline(); + ValidateS3FileOptions s3 = new_validate_s3_file_options(); + s3.file_name = "inbox.csv"; + s3.email_address_column = 0; + s3.has_header_row = 0; + s3.remove_duplicate = 1; + validate_s3_file(zb, &s3, on_send, on_error); + request = only_request(); + TEST_ASSERT_EQUAL_STRING("/validateS3file", request.path); + TEST_ASSERT_NOT_NULL(strstr(request.body, "email_address_column=0")); + TEST_ASSERT_NOT_NULL(strstr(request.body, "has_header_row=false")); + TEST_ASSERT_NOT_NULL(strstr(request.body, "remove_duplicate=true")); + TEST_ASSERT_NULL(strstr(request.body, "first_name_column")); + reset_offline(); + ScoreS3FileOptions score = new_score_s3_file_options(); + score.file_name = "inbox.csv"; + score_s3_file(zb, &score, on_send, on_error); + request = only_request(); + TEST_ASSERT_EQUAL_STRING("/scoreS3file", request.path); + TEST_ASSERT_NULL(strstr(request.body, "email_address_column")); + reset_offline(); + export_s3_file(zb, "fid", "bucket-out", on_send, on_error); + request = only_request(); + TEST_ASSERT_EQUAL_STRING("/exportS3file", request.path); + TEST_ASSERT_NOT_NULL(strstr(request.body, "export_bucket=bucket-out")); + reset_offline(); + list_s3_files(zb, NULL, NULL, on_raw, on_error); + request = only_request(); + TEST_ASSERT_EQUAL_STRING("/listS3files", request.path); + TEST_ASSERT_NULL(strstr(request.query, "bucket=")); + TEST_ASSERT_NOT_NULL(strstr(g_raw, "p3-key")); + reset_offline(); + list_s3_files(zb, "inbox", "csv", on_raw, on_error); + request = only_request(); + TEST_ASSERT_NOT_NULL(strstr(request.query, "bucket=inbox")); + TEST_ASSERT_NOT_NULL(strstr(request.query, "search=csv")); +} + +void test_offline_validate_new_options(void) { + reset_offline(); + ZeroBounce *zb = client_with("validate-key"); + ZBValidateOptions options = new_validate_options(); + + validate_email_with_options(zb, "a@example.com", "", &options, on_validate, on_error); + OfflineRequest request = only_request(); + TEST_ASSERT_NULL(strstr(request.query, "domain_info")); + TEST_ASSERT_NULL(strstr(request.query, "greylist_processing")); + TEST_ASSERT_NULL(strstr(request.query, "callback_url")); + + reset_offline(); + options.domain_info = 1; + options.greylist_processing = 1; + options.callback_url = "https://example.com/greylist"; + validate_email_with_options(zb, "a@example.com", "", &options, on_validate, on_error); + request = only_request(); + /* Booleans go on the wire as the strings "true"/"false". */ + TEST_ASSERT_NOT_NULL(strstr(request.query, "domain_info=true")); + TEST_ASSERT_NOT_NULL(strstr(request.query, "greylist_processing=true")); + TEST_ASSERT_NOT_NULL(strstr(request.query, "callback_url=")); + + reset_offline(); + options.use_post = 1; + options.domain_info = 0; + validate_email_with_options(zb, "a@example.com", "", &options, on_validate, on_error); + request = only_request(); + TEST_ASSERT_EQUAL_STRING("POST", request.method); + TEST_ASSERT_NOT_NULL(strstr(request.body, "domain_info=false")); + TEST_ASSERT_NOT_NULL(strstr(request.body, "callback_url=")); +} + +void test_offline_callback_url_requires_greylist(void) { + reset_offline(); + ZeroBounce *zb = client_with("validate-key"); + ZBValidateOptions options = new_validate_options(); + + options.callback_url = "https://example.com/greylist"; + validate_email_with_options(zb, "a@example.com", "", &options, on_validate, on_error); + TEST_ASSERT_TRUE(g_error_called); + TEST_ASSERT_NOT_NULL(strstr(g_error_text, "greylist_processing")); + + reset_offline(); + options.greylist_processing = 0; + validate_email_with_options(zb, "a@example.com", "", &options, on_validate, on_error); + TEST_ASSERT_TRUE(g_error_called); + TEST_ASSERT_NOT_NULL(strstr(g_error_text, "greylist_processing")); + + reset_offline(); + options.greylist_processing = 1; + options.callback_url = " "; + validate_email_with_options(zb, "a@example.com", "", &options, on_validate, on_error); + TEST_ASSERT_TRUE(g_error_called); + TEST_ASSERT_NOT_NULL(strstr(g_error_text, "callback_url")); + + TEST_ASSERT_EQUAL_INT(0, g_count); +} + +void test_offline_s3_and_sftp_jobs(void) { + reset_offline(); + ZeroBounce *zb = client_with("bulk-key"); + ZBBulkFileJobOptions options = new_bulk_file_job_options(); + + options.file_name = "lists/leads.csv"; + options.return_url = "https://example.com/done"; + email_finder_s3_file(zb, &options, on_send, on_error); + OfflineRequest request = only_request(); + TEST_ASSERT_EQUAL_STRING("POST", request.method); + TEST_ASSERT_EQUAL_STRING("bulkapi.zerobounce.net", request.host); + /* The S3 and SFTP jobs live on the bulkapi root, WITHOUT /v2. */ + TEST_ASSERT_EQUAL_STRING("/emailFinderS3file", request.path); + TEST_ASSERT_NOT_NULL(strstr(request.body, "file_name=lists%2Fleads.csv")); + TEST_ASSERT_NOT_NULL(strstr(request.body, "return_url=")); + TEST_ASSERT_NOT_NULL(strstr(request.body, "has_header_row=true")); + /* Options that were never set stay off the request entirely. */ + TEST_ASSERT_NULL(strstr(request.body, "remove_duplicates")); + + reset_offline(); + options.has_header_row = false; + options.remove_duplicates = 1; + domain_search_s3_file(zb, &options, on_send, on_error); + request = only_request(); + TEST_ASSERT_EQUAL_STRING("/domainSearchS3file", request.path); + TEST_ASSERT_NOT_NULL(strstr(request.body, "has_header_row=false")); + TEST_ASSERT_NOT_NULL(strstr(request.body, "remove_duplicates=true")); + + /* 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 sending. */ + reset_offline(); + options = new_bulk_file_job_options(); + options.return_url = "https://example.com/done"; + validate_sftp_file(zb, &options, on_send, on_error); + TEST_ASSERT_EQUAL_INT(0, g_count); + TEST_ASSERT_NOT_NULL(strstr(g_error_text, "file_name")); + + reset_offline(); + options = new_bulk_file_job_options(); + options.file_name = "my_file.txt"; + validate_sftp_file(zb, &options, on_send, on_error); + TEST_ASSERT_EQUAL_INT(0, g_count); + TEST_ASSERT_NOT_NULL(strstr(g_error_text, "return_url")); + + reset_offline(); + options.return_url = "https://example.com/done"; + validate_sftp_file(zb, &options, on_send, on_error); + request = only_request(); + TEST_ASSERT_EQUAL_STRING("/validateSFTPfile", request.path); + TEST_ASSERT_NOT_NULL(strstr(request.body, "file_name=my_file.txt")); + TEST_ASSERT_NOT_NULL(strstr(request.body, "return_url=")); + + reset_offline(); + score_sftp_file(zb, &options, on_send, on_error); + TEST_ASSERT_EQUAL_STRING("/scoreSFTPfile", only_request().path); + reset_offline(); + email_finder_sftp_file(zb, &options, on_send, on_error); + TEST_ASSERT_EQUAL_STRING("/emailFinderSFTPfile", only_request().path); + reset_offline(); + domain_search_sftp_file(zb, &options, on_send, on_error); + TEST_ASSERT_EQUAL_STRING("/domainSearchSFTPfile", only_request().path); + + /* exportSFTPfile takes export_path, not the export_bucket exportS3file uses. */ + reset_offline(); + export_sftp_file(zb, "fid", "/exports", on_send, on_error); + request = only_request(); + TEST_ASSERT_EQUAL_STRING("/exportSFTPfile", request.path); + TEST_ASSERT_NOT_NULL(strstr(request.body, "file_id=fid")); + TEST_ASSERT_NOT_NULL(strstr(request.body, "export_path=%2Fexports")); + TEST_ASSERT_NULL(strstr(request.body, "export_bucket")); + + reset_offline(); + export_sftp_file(zb, "fid", NULL, on_send, on_error); + TEST_ASSERT_NULL(strstr(only_request().body, "export_path")); +} + +void test_offline_bulk_jobs_reject_before_request(void) { + reset_offline(); + ZeroBounce *zb = client_with("bulk-key"); + ZBBulkFileJobOptions options = new_bulk_file_job_options(); + + email_finder_s3_file(zb, &options, on_send, on_error); + TEST_ASSERT_TRUE(g_error_called); + TEST_ASSERT_NOT_NULL(strstr(g_error_text, "file_name")); + + /* A blank name is rejected just like a missing one. */ + g_error_called = 0; + options.file_name = " "; + validate_sftp_file(zb, &options, on_send, on_error); + TEST_ASSERT_TRUE(g_error_called); + TEST_ASSERT_NOT_NULL(strstr(g_error_text, "file_name")); + + g_error_called = 0; + send_evaluated_file(zb, " ", on_send, on_error); + TEST_ASSERT_TRUE(g_error_called); + TEST_ASSERT_NOT_NULL(strstr(g_error_text, "file_id")); + + g_error_called = 0; + export_sftp_file(zb, " ", NULL, on_send, on_error); + TEST_ASSERT_TRUE(g_error_called); + TEST_ASSERT_NOT_NULL(strstr(g_error_text, "file_id")); + + g_error_called = 0; + export_sftp_file(zb, "fid", " ", on_send, on_error); + TEST_ASSERT_TRUE(g_error_called); + TEST_ASSERT_NOT_NULL(strstr(g_error_text, "export_path")); + + TEST_ASSERT_EQUAL_INT(0, g_count); +} + +void test_offline_list_evaluator_base_url(void) { + reset_offline(); + ZeroBounce *zb = client_with("eval-key"); + + get_list_evaluator_result(zb, "a b/c", on_raw, on_error); + OfflineRequest request = only_request(); + TEST_ASSERT_EQUAL_STRING("bulkapi.zerobounce.net", request.host); + /* The file id is one path segment, so a slash in it must stay encoded and + a space must not become a plus. */ + TEST_ASSERT_EQUAL_STRING("/v2/listevaluator/a%20b%2Fc/", request.path); + + reset_offline(); + get_list_evaluator_result(zb, " ", on_raw, on_error); + TEST_ASSERT_TRUE(g_error_called); + TEST_ASSERT_NOT_NULL(strstr(g_error_text, "file_id")); + TEST_ASSERT_EQUAL_INT(0, g_count); + + /* It is a separate host, not a path built from the bulk base. */ + reset_offline(); + zero_bounce_set_list_evaluator_base_url(zb, "https://bulk.example.test/v2/listevaluator"); + get_list_evaluator_result(zb, "fid", on_raw, on_error); + TEST_ASSERT_EQUAL_STRING("bulk.example.test", only_request().host); + zero_bounce_set_list_evaluator_base_url(zb, "https://bulkapi.zerobounce.net/v2/listevaluator"); +} + +void test_offline_inbox_placement(void) { + reset_offline(); + ZeroBounce *zb = client_with("ipt-key"); + + get_inbox_placement_providers(zb, on_raw, on_error); + OfflineRequest request = only_request(); + TEST_ASSERT_EQUAL_STRING("/api/ipt/providers", request.path); + /* 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. */ + TEST_ASSERT_EQUAL_STRING("Authorization: Bearer ipt-key", request.extra_header); + TEST_ASSERT_NULL(strstr(request.query, "api_key=")); + TEST_ASSERT_NULL(strstr(request.body, "api_key=")); + + reset_offline(); + get_inbox_placement_test_types(zb, on_raw, on_error); + TEST_ASSERT_EQUAL_STRING("/api/ipt/test-types", only_request().path); + + reset_offline(); + ZBCreateInboxPlacementTestOptions create = new_create_inbox_placement_test_options(); + create.test_type = 1; + create.sender_email = "news@example.com"; + create.webhook_url = "https://example.com/ipt"; + create_inbox_placement_test(zb, "Launch", &create, on_raw, on_error); + request = only_request(); + TEST_ASSERT_EQUAL_STRING("POST", request.method); + TEST_ASSERT_EQUAL_STRING("/api/ipt/test", request.path); + /* Bodies are camelCase JSON, and unset fields are left out. */ + TEST_ASSERT_EQUAL_STRING( + "{\"name\":\"Launch\",\"testType\":1,\"senderEmail\":\"news@example.com\"," + "\"webhookUrl\":\"https://example.com/ipt\"}", + request.body + ); + + reset_offline(); + create_inbox_placement_test(zb, "Launch", NULL, on_raw, on_error); + TEST_ASSERT_EQUAL_STRING("{\"name\":\"Launch\"}", only_request().body); + + reset_offline(); + ZBListInboxPlacementTestsOptions list = new_list_inbox_placement_tests_options(); + list.status = "finished"; + list.page = 1; + list.page_size = 25; + list_inbox_placement_tests(zb, &list, on_raw, on_error); + request = only_request(); + /* LIVE: the listing is GET /tests (plural). /test is the single-test path + and answers 'Method "GET" not allowed'. */ + TEST_ASSERT_EQUAL_STRING("/api/ipt/tests", request.path); + TEST_ASSERT_NOT_NULL(strstr(request.query, "status=finished")); + TEST_ASSERT_NOT_NULL(strstr(request.query, "page=1")); + TEST_ASSERT_NOT_NULL(strstr(request.query, "pageSize=25")); + + reset_offline(); + list_inbox_placement_tests(zb, NULL, on_raw, on_error); + request = only_request(); + TEST_ASSERT_NULL(strstr(request.query, "status=")); + TEST_ASSERT_NULL(strstr(request.query, "page=")); + + reset_offline(); + delete_inbox_placement_test(zb, "a b/c", on_raw, on_error); + request = only_request(); + TEST_ASSERT_EQUAL_STRING("DELETE", request.method); + TEST_ASSERT_EQUAL_STRING("/api/ipt/test/a%20b%2Fc", request.path); + + reset_offline(); + set_inbox_placement_webhook(zb, "https://example.com/ipt", on_raw, on_error); + request = only_request(); + TEST_ASSERT_EQUAL_STRING("PUT", request.method); + TEST_ASSERT_EQUAL_STRING("/api/ipt/webhook", request.path); + TEST_ASSERT_EQUAL_STRING("{\"webhookUrl\":\"https://example.com/ipt\"}", request.body); + + reset_offline(); + get_inbox_placement_webhook(zb, on_raw, on_error); + TEST_ASSERT_EQUAL_STRING("/api/ipt/webhook", only_request().path); + + reset_offline(); + get_inbox_placement_test(zb, " ", on_raw, on_error); + TEST_ASSERT_TRUE(g_error_called); + TEST_ASSERT_NOT_NULL(strstr(g_error_text, "test_id")); + g_error_called = 0; + create_inbox_placement_test(zb, " ", NULL, on_raw, on_error); + TEST_ASSERT_TRUE(g_error_called); + TEST_ASSERT_NOT_NULL(strstr(g_error_text, "name")); + g_error_called = 0; + set_inbox_placement_webhook(zb, "", on_raw, on_error); + TEST_ASSERT_TRUE(g_error_called); + TEST_ASSERT_NOT_NULL(strstr(g_error_text, "webhook_url")); + TEST_ASSERT_EQUAL_INT(0, g_count); +} + +void test_offline_inbox_placement_detail_error(void) { + reset_offline(); + ZeroBounce *zb = client_with("ipt-detail"); + + get_inbox_placement_test(zb, "123", on_raw, on_error); + + TEST_ASSERT_TRUE(g_error_called); + TEST_ASSERT_NOT_NULL(strstr(g_error_text, "Inbox placement test not found.")); +} + +void test_offline_batch_timeout_is_a_string(void) { + reset_offline(); + ZeroBounce *zb = client_with("batch-timeout"); + EmailToValidateVector emails = email_to_validate_vector_init(); + ZBEmailToValidate a = {"a@example.com", ""}; + ZBValidateOptions options = new_validate_options(); + + email_to_validate_vector_append(&emails, a); + options.timeout = 30; + validate_email_batch_with_options(zb, emails, &options, on_batch, on_error); + + /* 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". */ + TEST_ASSERT_NOT_NULL(strstr(only_request().body, "\"timeout\": \"30\"")); + TEST_ASSERT_NULL(strstr(only_request().body, "\"timeout\": 30")); + email_to_validate_vector_free(&emails); +} + +void test_offline_success_false_on_200_is_an_error(void) { + reset_offline(); + /* LIVE: getfile and deletefile report an unknown or deleted file id with + HTTP 200 and a "success": false field. That is a failure, not a result, + and the status is kept on the error. */ + ZeroBounce *zb = client_with("success-false"); + + delete_file(zb, "fid", on_delete, on_error); + TEST_ASSERT_TRUE(g_error_called); + TEST_ASSERT_NOT_NULL(strstr(g_error_text, "File cannot be found")); + TEST_ASSERT_EQUAL_INT(200, (int)g_error_status); + + g_error_called = 0; + reset_offline(); + zb = client_with("success-false-str"); + delete_file(zb, "fid", on_delete, on_error); + TEST_ASSERT_TRUE(g_error_called); +} + +void test_offline_safety_rails(void) { + reset_offline(); + ZeroBounce *zb = client_with("rails-key"); + + /* Only https is ever dialled: a plain-http base URL would put the key on + the wire in the clear. */ + zb->api_base_url = "http://api.zerobounce.net/v2"; + get_credits(zb, on_credits, on_error); + TEST_ASSERT_TRUE(g_error_called); + TEST_ASSERT_NOT_NULL(strstr(g_error_text, "https")); + TEST_ASSERT_EQUAL_INT(0, g_count); + + /* A path segment of "." or ".." would walk the request out of the + endpoint it was meant for. */ + reset_offline(); + zb = client_with("rails-key"); + get_inbox_placement_test(zb, "..", on_raw, on_error); + TEST_ASSERT_TRUE(g_error_called); + TEST_ASSERT_NOT_NULL(strstr(g_error_text, "test_id")); + TEST_ASSERT_EQUAL_INT(0, g_count); + + reset_offline(); + get_list_evaluator_result(zb, ".", on_raw, on_error); + TEST_ASSERT_TRUE(g_error_called); + TEST_ASSERT_NOT_NULL(strstr(g_error_text, "file_id")); + TEST_ASSERT_EQUAL_INT(0, g_count); + + /* A key with surrounding whitespace or control characters would be + quoted, unredacted, in a transport error. */ + TEST_ASSERT_TRUE(zb_api_key_is_clean("abc123")); + TEST_ASSERT_FALSE(zb_api_key_is_clean(" abc123")); + TEST_ASSERT_FALSE(zb_api_key_is_clean("abc123\n")); + TEST_ASSERT_FALSE(zb_api_key_is_clean("abc\t123")); + + /* An api_key never reaches an error message. */ + { + char *safe = zb_redact_api_key("GET https://api.zerobounce.net/v2/validate?api_key=sekrit&email=a"); + TEST_ASSERT_NOT_NULL(safe); + TEST_ASSERT_NULL(strstr(safe, "sekrit")); + TEST_ASSERT_NOT_NULL(strstr(safe, "api_key=REDACTED")); + TEST_ASSERT_NOT_NULL(strstr(safe, "&email=a")); + free(safe); + } +} + +void test_offline_redirect_is_not_followed(void) { + reset_offline(); + /* FOLLOWLOCATION is off: a redirect could send the api_key to another + host, so it is reported rather than followed. */ + ZeroBounce *zb = client_with("redirect-302"); + + get_credits(zb, on_credits, on_error); + TEST_ASSERT_TRUE(g_error_called); + TEST_ASSERT_NOT_NULL(strstr(g_error_text, "redirect")); + TEST_ASSERT_EQUAL_INT(302, (int)g_error_status); +} + +void test_offline_streaming_download(void) { + const char *path = "test/downloads/offline-stream.csv"; + reset_offline(); + /* The body goes straight to disk instead of being buffered first. */ + ZeroBounce *zb = client_with("stream-key"); + + remove(path); + get_file_streaming(zb, "fid", (char *)path, NULL, on_get, on_error); + TEST_ASSERT_TRUE(g_success_called); + TEST_ASSERT_FALSE(g_error_called); + { + FILE *handle = fopen(path, "rb"); + char first[32] = {0}; + TEST_ASSERT_NOT_NULL(handle); + TEST_ASSERT_TRUE(fread(first, 1, sizeof(first) - 1, handle) > 0); + fclose(handle); + TEST_ASSERT_EQUAL_INT(0, strncmp(first, "email", 5)); + } + remove(path); + + /* getfile answers an unknown or deleted id with a JSON error rather than + a file, so the probe must catch it and leave nothing behind. */ + reset_offline(); + zb = client_with("success-false"); + get_file_streaming(zb, "fid", (char *)path, NULL, on_get, on_error); + TEST_ASSERT_TRUE(g_error_called); + TEST_ASSERT_NOT_NULL(strstr(g_error_text, "File cannot be found")); + TEST_ASSERT_NULL(fopen(path, "rb")); + + /* An HTML error page is caught the same way. */ + reset_offline(); + zb = client_with("html-200-stream"); + get_file_streaming(zb, "fid", (char *)path, NULL, on_get, on_error); + TEST_ASSERT_TRUE(g_error_called); + TEST_ASSERT_NULL(strstr(g_error_text, " 100000); +} + +void test_offline_html_200_validate(void) { + reset_offline(); + ZeroBounce *zb = client_with("html-200-user"); + validate_email(zb, "a@example.com", "", on_validate, on_error); + TEST_ASSERT_FALSE(g_success_called); + expect_short_error(); + TEST_ASSERT_NOT_NULL(strstr(g_error_text, "Access denied")); +} + +void test_offline_html_200_get_file(void) { + reset_offline(); + ZeroBounce *zb = client_with("html-200-file"); + const char *path = "test/downloads/offline-html.csv"; + remove(path); + get_file(zb, "fid", (char *)path, on_get, on_error); + TEST_ASSERT_FALSE(g_success_called); + expect_short_error(); + TEST_ASSERT_NOT_NULL(strstr(g_error_text, "Access denied")); + FILE *f = fopen(path, "rb"); + TEST_ASSERT_NULL(f); +} + +void test_offline_cloudflare_json(void) { + reset_offline(); + ZeroBounce *zb = client_with("cf-json-user"); + get_credits(zb, on_credits, on_error); + expect_short_error(); + TEST_ASSERT_NULL(strstr(g_error_text, "developers.cloudflare.com")); + TEST_ASSERT_NOT_NULL(strstr(g_error_text, "Access denied")); +} + +void test_offline_empty_and_malformed(void) { + reset_offline(); + ZeroBounce *zb = client_with("empty-user"); + validate_email(zb, "a@example.com", "", on_validate, on_error); + expect_short_error(); + TEST_ASSERT_NOT_NULL(strstr(g_error_text, "Empty response")); + reset_offline(); + ZeroBounce *bad = client_with("malformed-user"); + validate_email(bad, "a@example.com", "", on_validate, on_error); + expect_short_error(); + TEST_ASSERT_NOT_NULL(strstr(g_error_text, "not valid JSON")); + TEST_ASSERT_NULL(strstr(g_error_text, "<<>>")); +} + +void test_offline_double_error_prefix(void) { + reset_offline(); + ZeroBounce *zb = client_with("double-error-user"); + get_activity_data(zb, "a@example.com", on_activity, on_error); + expect_short_error(); + TEST_ASSERT_EQUAL_STRING("api_key is invalid", g_error_text); +} + +void test_offline_batch_account_error(void) { + reset_offline(); + ZeroBounce *zb = client_with("batch-account-user"); + EmailToValidateVector emails = email_to_validate_vector_init(); + ZBEmailToValidate a = {"a@example.com", ""}; + email_to_validate_vector_append(&emails, a); + validate_email_batch(zb, emails, on_batch, on_error); + TEST_ASSERT_FALSE(g_success_called); + expect_short_error(); + TEST_ASSERT_NOT_NULL(strstr(g_error_text, "Invalid API Key")); + TEST_ASSERT_NOT_NULL(strstr(g_error_text, "ran out of credits")); + email_to_validate_vector_free(&emails); +} + +void test_offline_batch_partial(void) { + reset_offline(); + ZeroBounce *zb = client_with("batch-partial-user"); + EmailToValidateVector emails = email_to_validate_vector_init(); + ZBEmailToValidate a = {"good@example.com", ""}; + ZBEmailToValidate b = {"bad@", ""}; + email_to_validate_vector_append(&emails, a); + email_to_validate_vector_append(&emails, b); + validate_email_batch(zb, emails, on_batch, on_error); + TEST_ASSERT_TRUE(g_success_called); + TEST_ASSERT_EQUAL_STRING("good@example.com", g_echo); + TEST_ASSERT_EQUAL_STRING("bad@", g_error_text); + TEST_ASSERT_FALSE(g_error_called); + email_to_validate_vector_free(&emails); +} + +typedef struct { + int index; + char echo[64]; +} ConcArg; + +static void *conc_thread(void *arg) { + ConcArg *item = arg; + char key[32]; + snprintf(key, sizeof(key), "conc-%d", item->index); + ZeroBounce *zb = client_with(key); + tls_echo[0] = '\0'; + validate_email(zb, "person@example.com", "", on_validate, on_error); + snprintf(item->echo, sizeof(item->echo), "%s", tls_echo); + return NULL; +} + +void test_offline_concurrent_clients(void) { + reset_offline(); + enum { N = 12 }; + pthread_t threads[N]; + ConcArg args[N]; + for (int i = 0; i < N; i++) { + args[i].index = i; + args[i].echo[0] = '\0'; + pthread_create(&threads[i], NULL, conc_thread, &args[i]); + } + for (int i = 0; i < N; i++) pthread_join(threads[i], NULL); + int seen[N] = {0}; + TEST_ASSERT_EQUAL_INT(N, g_count); + for (int i = 0; i < g_count; i++) { + int n = -1; + sscanf(g_requests[i].api_key, "conc-%d", &n); + TEST_ASSERT_TRUE(n >= 0 && n < N); + seen[n]++; + } + for (int i = 0; i < N; i++) { + char key[32]; + snprintf(key, sizeof(key), "conc-%d", i); + TEST_ASSERT_EQUAL_INT(1, seen[i]); + TEST_ASSERT_EQUAL_STRING(key, args[i].echo); + } +} + +void offline_run_tests(void) { + RUN_TEST(test_offline_get_credits); + RUN_TEST(test_offline_validate_options); + RUN_TEST(test_offline_validate_post); + RUN_TEST(test_offline_batch_omits_and_keeps_zero_ip); + RUN_TEST(test_offline_batch_explicit_false); + RUN_TEST(test_offline_send_file_bools_and_zero_column); + RUN_TEST(test_offline_scoring_send_omits_validation_fields); + RUN_TEST(test_offline_file_status_and_delete); + RUN_TEST(test_offline_get_file_options); + RUN_TEST(test_offline_activity_usage_score); + RUN_TEST(test_offline_finder_and_domain); + RUN_TEST(test_offline_priority3); + RUN_TEST(test_offline_validate_new_options); + RUN_TEST(test_offline_callback_url_requires_greylist); + RUN_TEST(test_offline_s3_and_sftp_jobs); + RUN_TEST(test_offline_bulk_jobs_reject_before_request); + RUN_TEST(test_offline_batch_timeout_is_a_string); + RUN_TEST(test_offline_success_false_on_200_is_an_error); + RUN_TEST(test_offline_safety_rails); + RUN_TEST(test_offline_redirect_is_not_followed); + RUN_TEST(test_offline_streaming_download); + RUN_TEST(test_offline_form_body_is_not_prefixed_with_garbage); + RUN_TEST(test_offline_list_evaluator_base_url); + RUN_TEST(test_offline_inbox_placement); + RUN_TEST(test_offline_inbox_placement_detail_error); + RUN_TEST(test_offline_rejects_before_request); + RUN_TEST(test_offline_html_403); + RUN_TEST(test_offline_html_200_validate); + RUN_TEST(test_offline_html_200_get_file); + RUN_TEST(test_offline_cloudflare_json); + RUN_TEST(test_offline_empty_and_malformed); + RUN_TEST(test_offline_double_error_prefix); + RUN_TEST(test_offline_batch_account_error); + RUN_TEST(test_offline_batch_partial); + RUN_TEST(test_offline_concurrent_clients); +} diff --git a/test/OfflineWarmupTest.c b/test/OfflineWarmupTest.c new file mode 100644 index 0000000..ac14801 --- /dev/null +++ b/test/OfflineWarmupTest.c @@ -0,0 +1,539 @@ +/* + * Offline tests for the Warmup API client. + * + * Every request is answered by the stub installed with + * zero_bounce_set_http_perform_for_test; nothing here reaches the live API. + * The assertions are on the url, the method, the body and the one header that + * actually went out. + */ + +#include +#include +#include + +#include "unity.h" +#include "ZeroBounce/ZeroBounce.h" +#include "ZeroBounce/ZeroBounceWarmup.h" +#include "ZeroBounce/zb_internal.h" + +#define WARMUP_ROOT "https://warmup-api.zerobounce.net/api/v1/warmup" + +static char w_url[2048]; +static char w_method[16]; +static char w_body[4096]; +static char w_header[512]; +static char w_raw[4096]; +static char w_error[1024]; +static int w_requests; +static int w_error_called; +static int w_success_called; +static long w_status; +static long w_next_status = 200; +static const char *w_next_body = "{\"ok\":true}"; + +static void w_on_raw(const char *json_response) { + w_success_called = 1; + snprintf(w_raw, sizeof(w_raw), "%s", json_response ? json_response : ""); +} + +static void w_on_error(ZBErrorResponse error) { + size_t i; + size_t used = 0; + w_error_called = 1; + w_status = error.status_code; + w_error[0] = '\0'; + for (i = 0; i < error.errors.size; i++) { + const char *part = error.errors.data[i] ? error.errors.data[i] : ""; + int written = snprintf(w_error + used, sizeof(w_error) - used, "%s%s", used ? " " : "", part); + if (written < 0 || (size_t)written >= sizeof(w_error) - used) { + break; + } + used += (size_t)written; + } +} + +static int warmup_perform(memory *response_data, long *http_code, char **content_type_out) { + size_t len; + const char *url = zero_bounce_test_url(); + const char *body = zero_bounce_test_body(); + const char *header = zero_bounce_test_extra_header(); + + w_requests++; + snprintf(w_url, sizeof(w_url), "%s", url ? url : ""); + snprintf(w_method, sizeof(w_method), "%s", zero_bounce_test_method()); + snprintf(w_body, sizeof(w_body), "%s", body ? body : ""); + snprintf(w_header, sizeof(w_header), "%s", header ? header : ""); + + len = strlen(w_next_body); + response_data->response = malloc(len + 1); + memcpy(response_data->response, w_next_body, len + 1); + response_data->size = len; + *http_code = w_next_status; + if (content_type_out) { + *content_type_out = strdup("application/json"); + } + return 1; +} + +static void w_reset(void) { + zero_bounce_set_http_perform_for_test(warmup_perform); + w_url[0] = w_method[0] = w_body[0] = w_header[0] = w_raw[0] = w_error[0] = '\0'; + w_requests = 0; + w_error_called = 0; + w_success_called = 0; + w_status = 0; + w_next_status = 200; + w_next_body = "{\"ok\":true}"; +} + +static ZeroBounceWarmup *w_client(const char *key) { + ZeroBounceWarmup *zbw = new_zero_bounce_warmup_instance(); + zero_bounce_warmup_initialize(zbw, key); + return zbw; +} + +void test_warmup_api_key_header_and_own_host(void) { + w_reset(); + ZeroBounceWarmup *zbw = w_client("warm-key"); + + warmup_list_sending_accounts(zbw, w_on_raw, w_on_error); + + TEST_ASSERT_EQUAL_STRING("GET", w_method); + /* Its own host and base path, not the validation API's. */ + TEST_ASSERT_EQUAL_STRING(WARMUP_ROOT "/sending-accounts", w_url); + /* The Warmup API authenticates with an API-Key header; the validation + api_key never appears. */ + TEST_ASSERT_EQUAL_STRING("API-Key: warm-key", w_header); + TEST_ASSERT_NULL(strstr(w_url, "api_key")); + TEST_ASSERT_TRUE(w_success_called); + + zero_bounce_warmup_free(zbw); +} + +void test_warmup_uninitialized_sends_nothing(void) { + w_reset(); + ZeroBounceWarmup *zbw = new_zero_bounce_warmup_instance(); + + warmup_get_seeds(zbw, w_on_raw, w_on_error); + + TEST_ASSERT_TRUE(w_error_called); + TEST_ASSERT_NOT_NULL(strstr(w_error, "zero_bounce_warmup_initialize")); + TEST_ASSERT_EQUAL_INT(0, w_requests); + + /* A key with surrounding whitespace or control characters would be + quoted, unredacted, in a transport error. */ + zero_bounce_warmup_initialize(zbw, " warm-key"); + TEST_ASSERT_NULL(zbw->api_key); + zero_bounce_warmup_initialize(zbw, "warm-key"); + TEST_ASSERT_EQUAL_STRING("warm-key", zbw->api_key); + + zero_bounce_warmup_free(zbw); +} + +void test_warmup_reads_use_the_documented_paths(void) { + w_reset(); + ZeroBounceWarmup *zbw = w_client("warm-key"); + + warmup_get_sending_account(zbw, "sender@example.com", w_on_raw, w_on_error); + TEST_ASSERT_EQUAL_STRING(WARMUP_ROOT "/sending-account?from_address=sender%40example.com", w_url); + + warmup_get_engagement_rules(zbw, w_on_raw, w_on_error); + TEST_ASSERT_EQUAL_STRING(WARMUP_ROOT "/engagement-rules", w_url); + + warmup_get_seeds(zbw, w_on_raw, w_on_error); + TEST_ASSERT_EQUAL_STRING(WARMUP_ROOT "/seeds", w_url); + + warmup_get_seed_count(zbw, w_on_raw, w_on_error); + TEST_ASSERT_EQUAL_STRING(WARMUP_ROOT "/seed-count", w_url); + + warmup_get_progress_today(zbw, w_on_raw, w_on_error); + TEST_ASSERT_EQUAL_STRING(WARMUP_ROOT "/progress-today", w_url); + + warmup_get_sent_today(zbw, w_on_raw, w_on_error); + TEST_ASSERT_EQUAL_STRING(WARMUP_ROOT "/sent-today", w_url); + + warmup_get_emails_per_day(zbw, w_on_raw, w_on_error); + TEST_ASSERT_EQUAL_STRING(WARMUP_ROOT "/emails-per-day", w_url); + + warmup_get_statistics(zbw, "sender@example.com", w_on_raw, w_on_error); + TEST_ASSERT_EQUAL_STRING(WARMUP_ROOT "/statistics?from_address=sender%40example.com", w_url); + + warmup_get_statistics_detailed(zbw, "sender@example.com", w_on_raw, w_on_error); + TEST_ASSERT_EQUAL_STRING(WARMUP_ROOT "/statistics-detailed?c_from=sender%40example.com", w_url); + + warmup_get_from_address_statistics(zbw, "sender@example.com", w_on_raw, w_on_error); + TEST_ASSERT_EQUAL_STRING( + WARMUP_ROOT "/from-address-statistics?from_address=sender%40example.com", w_url); + + /* c_from is one path segment here, so a slash must stay encoded. */ + warmup_get_custom_engagement(zbw, "a b/c", w_on_raw, w_on_error); + TEST_ASSERT_EQUAL_STRING(WARMUP_ROOT "/custom-engagement/a%20b%2Fc", w_url); + + TEST_ASSERT_FALSE(w_error_called); + zero_bounce_warmup_free(zbw); +} + +void test_warmup_empty_successes(void) { + w_reset(); + ZeroBounceWarmup *zbw = w_client("warm-key"); + + /* 204 and an empty 200 are the documented empty successes. */ + w_next_status = 204; + w_next_body = ""; + warmup_get_sent_today(zbw, w_on_raw, w_on_error); + TEST_ASSERT_TRUE(w_success_called); + TEST_ASSERT_EQUAL_STRING("", w_raw); + TEST_ASSERT_FALSE(w_error_called); + + w_next_status = 200; + w_next_body = ""; + warmup_get_sent_today(zbw, w_on_raw, w_on_error); + TEST_ASSERT_EQUAL_STRING("", w_raw); + TEST_ASSERT_FALSE(w_error_called); + + zero_bounce_warmup_free(zbw); +} + +void test_warmup_add_sending_account(void) { + w_reset(); + ZeroBounceWarmup *zbw = w_client("warm-key"); + ZBWarmupAddSendingAccountOptions options = new_warmup_add_sending_account_options(); + ZBWarmupEmailContent content = new_warmup_email_content(); + ZBWarmupManagedSending sending = new_warmup_managed_sending(); + + options.engagement_rule_id = 2; + options.ignore_conflict = 1; + warmup_add_sending_account(zbw, "sender@example.com", ZB_WARMUP_SELF_MANAGED, &options, + w_on_raw, w_on_error); + TEST_ASSERT_EQUAL_STRING("POST", w_method); + TEST_ASSERT_EQUAL_STRING(WARMUP_ROOT "/sending-accounts", w_url); + TEST_ASSERT_NOT_NULL(strstr(w_body, "\"onboarding_model\": \"self_managed\"")); + TEST_ASSERT_NOT_NULL(strstr(w_body, "\"engagement_rule_id\": 2")); + TEST_ASSERT_NOT_NULL(strstr(w_body, "\"ignore_conflict\": true")); + + /* A self-managed account needs an engagement rule, and the ZB managed + extras belong to the other model. */ + w_reset(); + options = new_warmup_add_sending_account_options(); + warmup_add_sending_account(zbw, "sender@example.com", ZB_WARMUP_SELF_MANAGED, &options, + w_on_raw, w_on_error); + TEST_ASSERT_TRUE(w_error_called); + TEST_ASSERT_NOT_NULL(strstr(w_error, "engagement_rule_id")); + TEST_ASSERT_EQUAL_INT(0, w_requests); + + w_reset(); + options.engagement_rule_id = 0; + options.email_content = &content; + warmup_add_sending_account(zbw, "sender@example.com", ZB_WARMUP_SELF_MANAGED, &options, + w_on_raw, w_on_error); + TEST_ASSERT_TRUE(w_error_called); + TEST_ASSERT_NOT_NULL(strstr(w_error, "zb_managed")); + + w_reset(); + warmup_add_sending_account(zbw, "sender@example.com", "whatever", NULL, w_on_raw, w_on_error); + TEST_ASSERT_TRUE(w_error_called); + TEST_ASSERT_NOT_NULL(strstr(w_error, "onboarding_model")); + + /* ZB managed takes the content and sending blocks. */ + w_reset(); + content.company_name = "Example"; + content.types_of_emails = "newsletter, marketing"; + sending.weekdays_only = 1; + sending.timezone_offset = 3; + sending.warmup_start_date = "2026-11-02"; + options = new_warmup_add_sending_account_options(); + options.email_content = &content; + options.managed_sending = &sending; + warmup_add_sending_account(zbw, "sender@example.com", ZB_WARMUP_ZB_MANAGED, &options, + w_on_raw, w_on_error); + TEST_ASSERT_FALSE(w_error_called); + TEST_ASSERT_NOT_NULL(strstr(w_body, "\"company_name\": \"Example\"")); + TEST_ASSERT_NOT_NULL(strstr(w_body, "\"types_of_emails\": [ \"newsletter\", \"marketing\" ]")); + TEST_ASSERT_NOT_NULL(strstr(w_body, "\"timezone_offset\": 3")); + TEST_ASSERT_NOT_NULL(strstr(w_body, "\"warmup_start_date\": \"2026-11-02\"")); + TEST_ASSERT_NULL(strstr(w_body, "engagement_rule_id")); + + w_reset(); + content.types_of_emails = "postcards"; + warmup_add_sending_account(zbw, "sender@example.com", ZB_WARMUP_ZB_MANAGED, &options, + w_on_raw, w_on_error); + TEST_ASSERT_TRUE(w_error_called); + TEST_ASSERT_NOT_NULL(strstr(w_error, "types_of_emails")); + TEST_ASSERT_EQUAL_INT(0, w_requests); + + w_reset(); + content.types_of_emails = "marketing"; + sending.timezone_offset = 15; + warmup_add_sending_account(zbw, "sender@example.com", ZB_WARMUP_ZB_MANAGED, &options, + w_on_raw, w_on_error); + TEST_ASSERT_TRUE(w_error_called); + TEST_ASSERT_NOT_NULL(strstr(w_error, "timezone_offset")); + + w_reset(); + sending.timezone_offset = 3; + sending.warmup_start_date = "02/11/2026"; + warmup_add_sending_account(zbw, "sender@example.com", ZB_WARMUP_ZB_MANAGED, &options, + w_on_raw, w_on_error); + TEST_ASSERT_TRUE(w_error_called); + TEST_ASSERT_NOT_NULL(strstr(w_error, "warmup_start_date")); + TEST_ASSERT_EQUAL_INT(0, w_requests); + + zero_bounce_warmup_free(zbw); +} + +void test_warmup_update_and_delete_sending_account(void) { + w_reset(); + ZeroBounceWarmup *zbw = w_client("warm-key"); + + warmup_update_sending_account(zbw, "sender@example.com", 1, 4, true, w_on_raw, w_on_error); + TEST_ASSERT_EQUAL_STRING("PUT", w_method); + TEST_ASSERT_EQUAL_STRING(WARMUP_ROOT "/sending-accounts", w_url); + TEST_ASSERT_NOT_NULL(strstr(w_body, "\"team_id\": 1")); + TEST_ASSERT_NOT_NULL(strstr(w_body, "\"status_id\": 4")); + TEST_ASSERT_NOT_NULL(strstr(w_body, "\"auto_enable\": true")); + + warmup_delete_sending_account(zbw, "sender@example.com", w_on_raw, w_on_error); + TEST_ASSERT_EQUAL_STRING("DELETE", w_method); + TEST_ASSERT_EQUAL_STRING( + WARMUP_ROOT "/sending-accounts?from_address=sender%40example.com", w_url); + + /* Despite the path, nothing about SMTP credentials is stored. */ + warmup_change_from_address(zbw, "old@example.com", "new@example.com", w_on_raw, w_on_error); + TEST_ASSERT_EQUAL_STRING("PUT", w_method); + TEST_ASSERT_EQUAL_STRING(WARMUP_ROOT "/smtp-credentials", w_url); + TEST_ASSERT_NOT_NULL(strstr(w_body, "\"old_from_address\": \"old@example.com\"")); + + w_reset(); + warmup_update_sending_account(zbw, "sender@example.com", 9, 4, false, w_on_raw, w_on_error); + TEST_ASSERT_TRUE(w_error_called); + TEST_ASSERT_NOT_NULL(strstr(w_error, "engagement_rule_id")); + + w_reset(); + warmup_update_sending_account(zbw, "sender@example.com", 1, 9, false, w_on_raw, w_on_error); + TEST_ASSERT_TRUE(w_error_called); + TEST_ASSERT_NOT_NULL(strstr(w_error, "status_id")); + TEST_ASSERT_EQUAL_INT(0, w_requests); + + zero_bounce_warmup_free(zbw); +} + +void test_warmup_list_from_addresses(void) { + w_reset(); + ZeroBounceWarmup *zbw = w_client("warm-key"); + ZBWarmupListFromAddressesOptions options = new_warmup_list_from_addresses_options(); + + warmup_list_from_addresses(zbw, NULL, w_on_raw, w_on_error); + TEST_ASSERT_EQUAL_STRING(WARMUP_ROOT "/from-addresses", w_url); + + options.search_from = "sender@example.com"; + options.offset = 20; + options.limit = 50; + warmup_list_from_addresses(zbw, &options, w_on_raw, w_on_error); + TEST_ASSERT_EQUAL_STRING( + WARMUP_ROOT "/from-addresses?search_from=sender%40example.com&offset=20&limit=50", w_url); + + w_reset(); + options.search_from = "a b"; + warmup_list_from_addresses(zbw, &options, w_on_raw, w_on_error); + TEST_ASSERT_TRUE(w_error_called); + TEST_ASSERT_NOT_NULL(strstr(w_error, "search_from")); + TEST_ASSERT_EQUAL_INT(0, w_requests); + + zero_bounce_warmup_free(zbw); +} + +void test_warmup_engagement_rules(void) { + w_reset(); + ZeroBounceWarmup *zbw = w_client("warm-key"); + + warmup_set_engagement_rule(zbw, "sender@example.com", 3, w_on_raw, w_on_error); + TEST_ASSERT_EQUAL_STRING("PUT", w_method); + TEST_ASSERT_EQUAL_STRING(WARMUP_ROOT "/engagement-rule?c_from=sender%40example.com", w_url); + TEST_ASSERT_NOT_NULL(strstr(w_body, "\"rule_id\": 3")); + + warmup_create_custom_engagement(zbw, "sender@example.com", 10, 20, 30, w_on_raw, w_on_error); + TEST_ASSERT_EQUAL_STRING("POST", w_method); + TEST_ASSERT_EQUAL_STRING(WARMUP_ROOT "/custom-engagement", w_url); + TEST_ASSERT_NOT_NULL(strstr(w_body, "\"reply_pct\": 10")); + + warmup_update_custom_engagement(zbw, "sender@example.com", 10, 20, 30, w_on_raw, w_on_error); + TEST_ASSERT_EQUAL_STRING("PUT", w_method); + + warmup_delete_custom_engagement(zbw, "sender@example.com", 2, w_on_raw, w_on_error); + TEST_ASSERT_EQUAL_STRING("DELETE", w_method); + TEST_ASSERT_NOT_NULL(strstr(w_body, "\"engagement_rule_id\": 2")); + + /* The ranges are checked before anything is sent. */ + w_reset(); + warmup_create_custom_engagement(zbw, "sender@example.com", 4, 20, 10, w_on_raw, w_on_error); + TEST_ASSERT_NOT_NULL(strstr(w_error, "reply_pct")); + + w_reset(); + warmup_create_custom_engagement(zbw, "sender@example.com", 10, 41, 10, w_on_raw, w_on_error); + TEST_ASSERT_NOT_NULL(strstr(w_error, "important_pct")); + + /* click_pct is capped at important_pct x 1.75 as well as at 70. */ + w_reset(); + warmup_create_custom_engagement(zbw, "sender@example.com", 10, 20, 36, w_on_raw, w_on_error); + TEST_ASSERT_NOT_NULL(strstr(w_error, "click_pct")); + TEST_ASSERT_EQUAL_INT(0, w_requests); + + w_reset(); + warmup_create_custom_engagement(zbw, "sender@example.com", 10, 20, 35, w_on_raw, w_on_error); + TEST_ASSERT_FALSE(w_error_called); + TEST_ASSERT_EQUAL_INT(1, w_requests); + + /* Falling back to the custom rule itself is not a standard rule. */ + w_reset(); + warmup_delete_custom_engagement(zbw, "sender@example.com", 0, w_on_raw, w_on_error); + TEST_ASSERT_NOT_NULL(strstr(w_error, "engagement_rule_id")); + TEST_ASSERT_EQUAL_INT(0, w_requests); + + zero_bounce_warmup_free(zbw); +} + +void test_warmup_statistics_per_day_dates(void) { + w_reset(); + ZeroBounceWarmup *zbw = w_client("warm-key"); + + warmup_get_statistics_per_day(zbw, "sender@example.com", "2026-10-01", "2026-10-07", + w_on_raw, w_on_error); + TEST_ASSERT_EQUAL_STRING( + WARMUP_ROOT "/statistics-per-day?from_address=sender%40example.com" + "&start_date=2026-10-01&end_date=2026-10-07", w_url); + + w_reset(); + warmup_get_statistics_per_day(zbw, "sender@example.com", "10/01/2026", "2026-10-07", + w_on_raw, w_on_error); + TEST_ASSERT_NOT_NULL(strstr(w_error, "start_date")); + + w_reset(); + warmup_get_statistics_per_day(zbw, "sender@example.com", "2026-10-08", "2026-10-07", + w_on_raw, w_on_error); + TEST_ASSERT_NOT_NULL(strstr(w_error, "later than")); + TEST_ASSERT_EQUAL_INT(0, w_requests); + + zero_bounce_warmup_free(zbw); +} + +void test_warmup_check_domain_dmarc_needs_an_email(void) { + w_reset(); + ZeroBounceWarmup *zbw = w_client("warm-key"); + + warmup_check_domain_dmarc(zbw, "sender@example.com", "selector1", w_on_raw, w_on_error); + TEST_ASSERT_EQUAL_STRING( + WARMUP_ROOT "/check-domain-dmarc?from_address=sender%40example.com" + "&dkim_selector=selector1", w_url); + + warmup_check_domain_dmarc(zbw, "sender@example.com", NULL, w_on_raw, w_on_error); + TEST_ASSERT_EQUAL_STRING( + WARMUP_ROOT "/check-domain-dmarc?from_address=sender%40example.com", w_url); + + /* LIVE: the docs describe a domain, but a bare domain is answered with + "Please provide a valid email address". */ + w_reset(); + warmup_check_domain_dmarc(zbw, "example.com", NULL, w_on_raw, w_on_error); + TEST_ASSERT_TRUE(w_error_called); + TEST_ASSERT_NOT_NULL(strstr(w_error, "email address")); + TEST_ASSERT_EQUAL_INT(0, w_requests); + + zero_bounce_warmup_free(zbw); +} + +void test_warmup_test_smtp_configuration(void) { + w_reset(); + ZeroBounceWarmup *zbw = w_client("warm-key"); + + warmup_test_smtp_configuration(zbw, "sender@example.com", "sender@example.com", "sekrit", + "smtp.example.com", 587, w_on_raw, w_on_error); + TEST_ASSERT_EQUAL_STRING("POST", w_method); + TEST_ASSERT_EQUAL_STRING(WARMUP_ROOT "/test-smtp-configuration", w_url); + TEST_ASSERT_NOT_NULL(strstr(w_body, "\"port\": 587")); + + /* LIVE: a failed SMTP login is answered with HTTP 500, and the password + must not travel out in the error. */ + w_reset(); + w_next_status = 500; + w_next_body = "{\"detail\":\"login failed for sekrit\"}"; + warmup_test_smtp_configuration(zbw, "sender@example.com", "sender@example.com", "sekrit", + "smtp.example.com", 587, w_on_raw, w_on_error); + TEST_ASSERT_TRUE(w_error_called); + TEST_ASSERT_EQUAL_INT(500, (int)w_status); + TEST_ASSERT_NULL(strstr(w_error, "sekrit")); + TEST_ASSERT_NOT_NULL(strstr(w_error, "***")); + + w_reset(); + warmup_test_smtp_configuration(zbw, "sender@example.com", "sender@example.com", "sekrit", + "smtp.example.com", 0, w_on_raw, w_on_error); + TEST_ASSERT_TRUE(w_error_called); + TEST_ASSERT_NOT_NULL(strstr(w_error, "port")); + TEST_ASSERT_EQUAL_INT(0, w_requests); + + zero_bounce_warmup_free(zbw); +} + +void test_warmup_csv_downloads(void) { + w_reset(); + ZeroBounceWarmup *zbw = w_client("warm-key"); + + w_next_body = "seed,provider\na@example.com,gmail\n"; + warmup_download_seeds(zbw, NULL, w_on_raw, w_on_error); + TEST_ASSERT_EQUAL_STRING(WARMUP_ROOT "/seeds/download", w_url); + TEST_ASSERT_EQUAL_INT(0, strncmp(w_raw, "seed,provider", 13)); + + w_next_body = "date,sent\n2026-10-01,10\n"; + warmup_download_statistics(zbw, "sender@example.com", NULL, w_on_raw, w_on_error); + TEST_ASSERT_EQUAL_STRING( + WARMUP_ROOT "/statistics/download?c_from=sender%40example.com", w_url); + + /* An error comes back as JSON, so it is not a file. */ + w_reset(); + w_next_body = "{\"detail\":\"nothing here\"}"; + warmup_download_seeds(zbw, NULL, w_on_raw, w_on_error); + TEST_ASSERT_TRUE(w_error_called); + TEST_ASSERT_NOT_NULL(strstr(w_error, "nothing here")); + + zero_bounce_warmup_free(zbw); +} + +void test_warmup_blank_arguments_are_rejected(void) { + w_reset(); + ZeroBounceWarmup *zbw = w_client("warm-key"); + + warmup_get_sending_account(zbw, " ", w_on_raw, w_on_error); + TEST_ASSERT_NOT_NULL(strstr(w_error, "from_address")); + + w_reset(); + warmup_get_statistics_detailed(zbw, " ", w_on_raw, w_on_error); + TEST_ASSERT_NOT_NULL(strstr(w_error, "c_from")); + + /* A path segment of "." or ".." would walk the request out of the + endpoint it was meant for. */ + w_reset(); + warmup_get_custom_engagement(zbw, "..", w_on_raw, w_on_error); + TEST_ASSERT_NOT_NULL(strstr(w_error, "c_from")); + + w_reset(); + warmup_change_from_address(zbw, "old@example.com", " ", w_on_raw, w_on_error); + TEST_ASSERT_NOT_NULL(strstr(w_error, "new_from_address")); + TEST_ASSERT_EQUAL_INT(0, w_requests); + + zero_bounce_warmup_free(zbw); +} + +void warmup_run_tests(void) { + RUN_TEST(test_warmup_api_key_header_and_own_host); + RUN_TEST(test_warmup_uninitialized_sends_nothing); + RUN_TEST(test_warmup_reads_use_the_documented_paths); + RUN_TEST(test_warmup_empty_successes); + RUN_TEST(test_warmup_add_sending_account); + RUN_TEST(test_warmup_update_and_delete_sending_account); + RUN_TEST(test_warmup_list_from_addresses); + RUN_TEST(test_warmup_engagement_rules); + RUN_TEST(test_warmup_statistics_per_day_dates); + RUN_TEST(test_warmup_check_domain_dmarc_needs_an_email); + RUN_TEST(test_warmup_test_smtp_configuration); + RUN_TEST(test_warmup_csv_downloads); + RUN_TEST(test_warmup_blank_arguments_are_rejected); + zero_bounce_set_http_perform_for_test(NULL); +} diff --git a/test/ZeroBounceTest.c b/test/ZeroBounceTest.c index 9fd173a..8c3efad 100644 --- a/test/ZeroBounceTest.c +++ b/test/ZeroBounceTest.c @@ -5,6 +5,9 @@ #include "ZeroBounce/ZeroBounce.h" +void offline_run_tests(void); +void warmup_run_tests(void); + DEFINE_FFF_GLOBALS; static ZeroBounce* zb; @@ -18,6 +21,7 @@ static ZBFileStatusResponse expected_file_status_response; static ZBGetFileResponse expected_get_file_response; static ZBDeleteFileResponse expected_delete_file_response; static ZBActivityDataResponse expected_activity_data_response; +static ZBScoreEmailResponse expected_score_email_response; static ZBFindEmailResponse expected_find_email_response; static ZBDomainSearchResponse expected_domain_search_response; @@ -104,6 +108,27 @@ void on_success_activity_data_invalid(ZBActivityDataResponse response) { TEST_FAIL_MESSAGE(zb_activity_data_response_to_string(&response)); } +void on_success_score_email_valid(ZBScoreEmailResponse response) { + TEST_ASSERT_EQUAL_INT(1, compare_zb_score_email_response(&expected_score_email_response, &response)); +} + +static char last_raw_json_buffer[4096]; +static const char *last_raw_json_response = NULL; + +void on_success_raw_json(const char *json_response) { + if (!json_response) { + last_raw_json_buffer[0] = '\0'; + } else { + strncpy(last_raw_json_buffer, json_response, sizeof(last_raw_json_buffer) - 1); + last_raw_json_buffer[sizeof(last_raw_json_buffer) - 1] = '\0'; + } + last_raw_json_response = last_raw_json_buffer; +} + +void on_success_score_email_invalid(ZBScoreEmailResponse response) { + TEST_FAIL_MESSAGE(zb_score_email_response_to_string(&response)); +} + void on_success_find_email_valid(ZBFindEmailResponse response) { TEST_ASSERT_TRUE( zb_find_email_response_compare(&expected_find_email_response, &response) @@ -323,6 +348,41 @@ void test_validate_email_invalid(void) validate_email(zb, "valid@example.com", "127.0.0.1", on_success_validate_invalid, on_error_invalid); } +void test_sub_status_ai_agent_mailbox(void) +{ + // Returned under both valid and catch-all; the mapping is status-independent. + TEST_ASSERT_EQUAL_INT(AiAgentMailbox, sub_status_from_string("ai_agent_mailbox")); +} + +void test_sub_status_ai_agent_mailbox_parsed_from_response(void) +{ + const char* valid_json = + "{\n" + " \"address\": \"agent@example.com\",\n" + " \"status\": \"valid\",\n" + " \"sub_status\": \"ai_agent_mailbox\"\n" + "}"; + ZBValidateResponse parsed = zb_validate_response_from_json(json_tokener_parse(valid_json)); + TEST_ASSERT_EQUAL_INT(AiAgentMailbox, parsed.sub_status); + + const char* catch_all_json = + "{\n" + " \"address\": \"agent@example.com\",\n" + " \"status\": \"catch-all\",\n" + " \"sub_status\": \"ai_agent_mailbox\"\n" + "}"; + ZBValidateResponse parsed_catch_all = zb_validate_response_from_json(json_tokener_parse(catch_all_json)); + TEST_ASSERT_EQUAL_INT(AiAgentMailbox, parsed_catch_all.sub_status); +} + +void test_sub_status_unknown_falls_back_to_none(void) +{ + // sub_status_from_string ends in `return None`, so a sub_status added to + // the API after this SDK shipped maps to None rather than reading past the + // end of the chain or leaving the field uninitialised. + TEST_ASSERT_EQUAL_INT(None, sub_status_from_string("some_future_sub_status")); +} + void test_validate_email_valid(void) { response_json = "{\n" @@ -367,6 +427,105 @@ void test_validate_email_batch_invalid(void) validate_email_batch(zb, emails, on_success_validate_batch_invalid, on_error_invalid); } +void test_validate_email_with_options_query_params(void) +{ + response_json = "{\"address\":\"valid@example.com\",\"status\":\"valid\",\"sub_status\":\"\",\"processed_at\":\"2023-04-05 13:29:47.553\"}"; + + get_http_code_fake.return_val = 200; + + expected_validate_response = zb_validate_response_from_json(json_tokener_parse(response_json)); + + ZBValidateOptions options = new_validate_options(); + options.timeout = 30; + options.activity_data = 1; + options.verify_plus = 0; + + validate_email_with_options( + zb, "valid@example.com", "", &options, on_success_validate_valid, on_error_valid + ); + + const char* url = zero_bounce_get_last_request_url_for_test(); + TEST_ASSERT_NOT_NULL(url); + TEST_ASSERT_NOT_NULL(strstr(url, "timeout=30")); + TEST_ASSERT_NOT_NULL(strstr(url, "activity_data=true")); + TEST_ASSERT_NOT_NULL(strstr(url, "verify_plus=false")); +} + +void test_validate_email_with_options_post_form(void) +{ + response_json = "{\"address\":\"valid@example.com\",\"status\":\"valid\",\"sub_status\":\"\",\"processed_at\":\"2023-04-05 13:29:47.553\"}"; + + get_http_code_fake.return_val = 200; + + expected_validate_response = zb_validate_response_from_json(json_tokener_parse(response_json)); + + ZBValidateOptions options = new_validate_options(); + options.use_post = 1; + options.timeout = 30; + + validate_email_with_options( + zb, "valid@example.com", "", &options, on_success_validate_valid, on_error_valid + ); + + const char* url = zero_bounce_get_last_request_url_for_test(); + TEST_ASSERT_NOT_NULL(url); + TEST_ASSERT_NOT_NULL(strstr(url, "/validate")); + + const char* body = zero_bounce_get_last_post_body_for_test(); + TEST_ASSERT_NOT_NULL(body); + TEST_ASSERT_NOT_NULL(strstr(body, "api_key=")); + TEST_ASSERT_NOT_NULL(strstr(body, "email=")); + TEST_ASSERT_NOT_NULL(strstr(body, "timeout=30")); +} + +void test_list_s3_files_valid(void) +{ + response_json = "[{\"name\":\"uploads/list.csv\",\"size\":100,\"last_modified\":\"2024-01-01T00:00:00Z\"}]"; + last_raw_json_response = NULL; + + get_http_code_fake.return_val = 200; + + list_s3_files(zb, "my-bucket", "list", on_success_raw_json, on_error_valid); + + const char* url = zero_bounce_get_last_request_url_for_test(); + TEST_ASSERT_NOT_NULL(url); + TEST_ASSERT_NOT_NULL(strstr(url, "/listS3files")); + TEST_ASSERT_NOT_NULL(strstr(url, "bucket=my-bucket")); + TEST_ASSERT_NOT_NULL(strstr(url, "search=list")); + TEST_ASSERT_NOT_NULL(last_raw_json_response); + TEST_ASSERT_NOT_NULL(strstr(last_raw_json_response, "uploads/list.csv")); +} + +void test_validate_email_batch_with_options_body(void) +{ + response_json = "{\"email_batch\":[],\"errors\":[]}"; + + get_http_code_fake.return_val = 200; + + expected_validate_batch_response = zb_validate_batch_response_from_json(json_tokener_parse(response_json)); + + EmailToValidateVector emails = email_to_validate_vector_init(); + ZBEmailToValidate email = {"valid@example.com", "1.1.1.1"}; + email_to_validate_vector_append(&emails, email); + + ZBValidateOptions options = new_validate_options(); + options.timeout = 60; + options.verify_plus = 1; + + validate_email_batch_with_options( + zb, emails, &options, on_success_validate_batch_valid, on_error_valid + ); + + const char* body = zero_bounce_get_last_post_body_for_test(); + TEST_ASSERT_NOT_NULL(body); + TEST_ASSERT_NOT_NULL(strstr(body, "\"timeout\"")); + TEST_ASSERT_NOT_NULL(strstr(body, "60")); + TEST_ASSERT_NOT_NULL(strstr(body, "\"verify_plus\"")); + TEST_ASSERT_NOT_NULL(strstr(body, "true")); + + email_to_validate_vector_free(&emails); +} + void test_validate_email_batch_uses_api_url(void) { response_json = "{\"email_batch\":[],\"errors\":[]}"; @@ -865,6 +1024,63 @@ void test_search_domain_status_valid_payload(void) { } +void test_score_email_valid(void) { + response_json = "{\"email\":\"mark@gmail.com\",\"score\":10}"; + + expected_score_email_response = zb_score_email_response_from_json( + json_tokener_parse(response_json) + ); + + get_http_code_fake.return_val = 200; + score_email(zb, "mark@gmail.com", on_success_score_email_valid, on_error_valid); + + const char* url = zero_bounce_get_last_request_url_for_test(); + TEST_ASSERT_NOT_NULL(url); + TEST_ASSERT_NOT_NULL(strstr(url, "/scoring?")); + TEST_ASSERT_NOT_NULL(strstr(url, "email=")); +} + +void test_validate_s3_file_valid(void) { + response_json = "{\"success\":true,\"file_id\":\"s3-1\",\"message\":\"\"}"; + + expected_send_file_response = zb_send_file_response_from_json( + json_tokener_parse(response_json) + ); + + get_http_code_fake.return_val = 200; + ValidateS3FileOptions options = new_validate_s3_file_options(); + options.file_name = "bucket/key.csv"; + validate_s3_file(zb, &options, on_success_send_file_valid, on_error_valid); + + const char* url = zero_bounce_get_last_request_url_for_test(); + TEST_ASSERT_NOT_NULL(url); + TEST_ASSERT_NOT_NULL(strstr(url, "/validateS3file")); + + const char* body = zero_bounce_get_last_post_body_for_test(); + TEST_ASSERT_NOT_NULL(body); + TEST_ASSERT_NOT_NULL(strstr(body, "file_name=")); +} + +void test_score_email_post_valid(void) { + response_json = "{\"email\":\"mark@gmail.com\",\"score\":10}"; + + expected_score_email_response = zb_score_email_response_from_json( + json_tokener_parse(response_json) + ); + + get_http_code_fake.return_val = 200; + score_email_post(zb, "mark@gmail.com", on_success_score_email_valid, on_error_valid); + + const char* url = zero_bounce_get_last_request_url_for_test(); + TEST_ASSERT_NOT_NULL(url); + TEST_ASSERT_NOT_NULL(strstr(url, "/scoring")); + + const char* body = zero_bounce_get_last_post_body_for_test(); + TEST_ASSERT_NOT_NULL(body); + TEST_ASSERT_NOT_NULL(strstr(body, "api_key=")); + TEST_ASSERT_NOT_NULL(strstr(body, "email=")); +} + void test_search_domain_status_serialize_functions() { response_json = "{\n" " \"domain\": \"example.com\",\n" @@ -914,6 +1130,9 @@ int main(void) { UNITY_BEGIN(); + RUN_TEST(test_sub_status_ai_agent_mailbox); + RUN_TEST(test_sub_status_ai_agent_mailbox_parsed_from_response); + RUN_TEST(test_sub_status_unknown_falls_back_to_none); RUN_TEST(test_get_credits_invalid); RUN_TEST(test_get_credits_valid); RUN_TEST(test_get_api_usage_invalid); @@ -921,8 +1140,11 @@ int main(void) RUN_TEST(test_get_api_usage_response_parse_and_compare); RUN_TEST(test_validate_email_invalid); RUN_TEST(test_validate_email_valid); + RUN_TEST(test_validate_email_with_options_query_params); + RUN_TEST(test_validate_email_with_options_post_form); RUN_TEST(test_validate_email_batch_invalid); RUN_TEST(test_validate_email_batch_uses_api_url); + RUN_TEST(test_validate_email_batch_with_options_body); RUN_TEST(test_validate_email_batch_valid); RUN_TEST(test_send_file_invalid); RUN_TEST(test_send_file_valid); @@ -943,11 +1165,18 @@ int main(void) RUN_TEST(test_scoring_get_file_valid); RUN_TEST(test_scoring_delete_file_invalid); RUN_TEST(test_scoring_delete_file_valid); + RUN_TEST(test_score_email_valid); + RUN_TEST(test_score_email_post_valid); + RUN_TEST(test_validate_s3_file_valid); + RUN_TEST(test_list_s3_files_valid); RUN_TEST(test_find_email_status_invalid_payload); RUN_TEST(test_find_email_status_valid_payload); RUN_TEST(test_search_domain_status_invalid_payload); RUN_TEST(test_search_domain_status_valid_payload); RUN_TEST(test_search_domain_status_serialize_functions); + offline_run_tests(); + warmup_run_tests(); + return UNITY_END(); } \ No newline at end of file