From adb9391081dd254d9a7715dd2789124247f3bee4 Mon Sep 17 00:00:00 2001 From: Francisc Balint <7178547+franciscbalint@users.noreply.github.com> Date: Sun, 25 Oct 2026 19:31:26 +0400 Subject: [PATCH] fix: send the parameters these endpoints actually require, and stop building JSON by hand Audited the S3/SFTP file jobs and the Inbox Placement Tester against the documented 74 operations and the live API. Several calls could not succeed as the SDK built them, and the Tester built its request bodies in a way that a quote in a caller's string could break. Memory safety first: - The Tester's JSON bodies were concatenated into a fixed 1024-byte buffer with no escaping, so a quote or a backslash in `name`, `senderEmail` or `webhookUrl` closed the string early and injected whatever followed, and anything longer than the buffer was silently truncated mid-field. Every value is escaped now and the bodies grow to fit. - `send_file` dereferenced `SendFileOptions.return_url` without a NULL check on the live request path, although the same value is checked where the request is noted. A caller that zeroed the struct rather than calling `new_send_file_options` crashed. Guarded. Parameters: - `validate_s3_file` and `score_s3_file` now require `return_url`, as the other six jobs already did: POST /validateS3file without it answers 400 "Provided return URL is not permitted: None". `ScoreS3FileOptions` had no `return_url` field at all, so that job could never succeed. Both now go through the same helper as the other jobs, which is also why `validate_s3_file` stops sending `remove_duplicate` and `allow_phase_2`: the endpoint documents `remove_duplicates`, and `allow_phase_2` belongs to the multipart bulk `send_file`. - `export_s3_file` rejected a missing `export_bucket` as an empty parameter although the docs make it optional, and its SFTP twin treats it as optional. It is optional here too now. - `send_email_finder_file` enforces the documented "first_name_column or full_name_column": without one the endpoint has no name to search for. - `send_list_evaluator_file` built its URL from `bulk_api_base_url` while the result call used `list_evaluator_base_url`, so `zero_bounce_set_list_evaluator_base_url` split the two halves of the same feature across different hosts. It honours the override now. - `list_inbox_placement_tests` sent `pageSize`, which the API accepts and then silently ignores, so a caller asking for a page size quietly got the default page. It sends `page_size`, bounded to 1-100, and gained the documented `createdAfter` / `createdBefore` filters. - `create_inbox_placement_test` gained the unsupported `providerIds`, `seedIds` and `providers[{id, maxSeeds}]`, and enforces name <= 64, testType 1|2|3 and senderEmail when testType is 1. Also: the regional-host warning told users to call `zero_bounce_set_bulk_api_base_url`, which did not exist anywhere in the codebase and left the three bulk URLs hardcoded to production. Added it along with the root and scoring setters. Corrected the header comment claiming the Tester's key goes on the query string; the code has always used `Authorization: Bearer`, and the comment was telling readers to leak a key. Co-Authored-By: Claude Opus 5 --- CHANGELOG.md | 18 +- README.md | 47 ++- documentation.md | 47 ++- include/ZeroBounce/ZBPriority3Options.h | 26 +- include/ZeroBounce/ZeroBounce.h | 80 +++- src/Priority3.c | 488 +++++++++++++++++------- src/ZeroBounce.c | 28 +- test/OfflineApiTest.c | 343 ++++++++++++++++- test/ZeroBounceTest.c | 4 + 9 files changed, 917 insertions(+), 164 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index fbaf297..72cbec2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,16 +12,18 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - `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 email finder (`send_email_finder_file`, `email_finder_file_status`, `email_finder_get_file`, `email_finder_delete_file`). `send_email_finder_file` needs one of `first_name_column` and `full_name_column`, and rejects a call with neither before sending. - 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. +- S3 bulk (`validate_s3_file`, `score_s3_file`, `export_s3_file`, `list_s3_files`, `email_finder_s3_file`, `domain_search_s3_file`). `validate_s3_file` and `score_s3_file` require `file_name` and `return_url` like the other jobs, so `ScoreS3FileOptions` has a `return_url` field; `export_s3_file`'s `export_bucket` is optional, as `export_path` is on the SFTP twin. `ValidateS3FileOptions.remove_duplicate`, `allow_phase_2` and `allow_phase_2_is_set` are not sent: the endpoint documents `remove_duplicates`, and `allow_phase_2` belongs to the multipart bulk `send_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. `export_sftp_file` takes `export_path` rather than the `export_bucket` `export_s3_file` sends, and both are optional. - `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`. +- 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. Every value in a body is JSON-escaped and the bodies grow to fit, so a quote, a backslash or a long URL can no longer break or truncate one. +- `ZBCreateInboxPlacementTestOptions` carries `provider_ids`, `seed_ids` and `providers` (a `ZBInboxPlacementProvider` array of `id` plus `max_seeds`), sent as `providerIds`, `seedIds` and `providers`. `create_inbox_placement_test` requires a `name` of at most 64 characters, a `test_type` of 1, 2 or 3, and a `sender_email` when `test_type` is 1. +- `ZBListInboxPlacementTestsOptions` carries the documented `created_after` and `created_before` filters, and `page_size` has to be between 1 and 100. +- `zero_bounce_set_list_evaluator_base_url`, `zero_bounce_set_inbox_placement_base_url`, `zero_bounce_set_bulk_api_base_url`, `zero_bounce_set_bulk_api_root_url` and `zero_bounce_set_bulk_api_scoring_base_url`. The bulk hosts are not built from `api_base_url`, so until now there was no way to move them, although the regional-host warning told you to. - `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. @@ -34,10 +36,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - 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. + - Every S3/SFTP job requires both `file_name` and `return_url`, `validate_s3_file`, `score_s3_file` and `validate_sftp_file` included, although the docs call several of them optional. `POST /validateS3file` without a `return_url` answers 400 `Provided return URL is not permitted: None`, so both are checked before the request is sent. + - `list_inbox_placement_tests` sends `page_size`. `pageSize`, which the SDK used to send, is accepted and then silently ignored, so a caller asking for a given page size quietly got the default page. - **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. +- The list evaluator uses its own base URL (`.../v2/listevaluator`) instead of building the path from the bulk base, for the upload as well as the result, and the file id is encoded as a single path segment. `send_list_evaluator_file` used to ignore `zero_bounce_set_list_evaluator_base_url` and build its URL from the bulk base, which split the two halves across different hosts. `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. @@ -56,6 +59,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - 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`. +- `send_file` and `scoring_send_file` no longer dereference a NULL `SendFileOptions.return_url`. A caller that zeroed the struct instead of calling `new_send_file_options` crashed on the request path. ### Security diff --git a/README.md b/README.md index 8728e0a..31f40fd 100644 --- a/README.md +++ b/README.md @@ -391,9 +391,11 @@ SFTP/FTP server. See the [S3](https://www.zerobounce.net/docs/api-integrations/a 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. +Every one of these jobs needs both `file_name` and `return_url`, `validate_s3_file` +and `score_s3_file` included. The docs call several of them optional, but the live +API answers `Empty parameter: file_name` without the one and +`Provided return URL is not permitted: None` without the other, so the SDK checks +both before sending. ```c ZBBulkFileJobOptions options = new_bulk_file_job_options(); @@ -418,6 +420,22 @@ 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); + +/* validate_s3_file and score_s3_file take the same two required fields plus + their own columns. */ +ValidateS3FileOptions validate = new_validate_s3_file_options(); +validate.file_name = "lists/leads.csv"; +validate.return_url = "https://example.com/zerobounce-done"; +validate.email_address_column = 1; +validate_s3_file(zb, &validate, on_send, on_error); + +ScoreS3FileOptions score = new_score_s3_file_options(); +score.file_name = "lists/leads.csv"; +score.return_url = "https://example.com/zerobounce-done"; +score_s3_file(zb, &score, on_send, on_error); + +/* export_bucket is optional: NULL uses the one from your settings. */ +export_s3_file(zb, "", NULL, on_send, on_error); ``` ## Inbox Placement Tester @@ -430,10 +448,20 @@ the API takes, and it spends the account's inbox-test credits. get_inbox_placement_providers(zb, on_raw, on_error); get_inbox_placement_test_types(zb, on_raw, on_error); +/* name is at most 64 characters, test_type is 1, 2 or 3, and a test_type of 1 + needs a sender_email. */ +static const int provider_ids[] = {3, 7}; +static const ZBInboxPlacementProvider providers[] = {{3, 10}}; + 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"; +/* Optional: pick the providers, the seeds, or a per-provider seed cap. */ +create.provider_ids = provider_ids; +create.provider_ids_count = 2; +create.providers = providers; +create.providers_count = 1; create_inbox_placement_test(zb, "Launch", &create, on_raw, on_error); /* Send your campaign to the seeds you got back, then read the results. */ @@ -442,6 +470,9 @@ get_inbox_placement_test(zb, "", on_raw, on_error); ZBListInboxPlacementTestsOptions list = new_list_inbox_placement_tests_options(); list.status = "finished"; list.page = 1; +list.page_size = 25; /* 1 to 100 */ +list.created_after = "2026-01-01"; +list.created_before = "2026-02-01"; list_inbox_placement_tests(zb, &list, on_raw, on_error); set_inbox_placement_webhook(zb, "https://example.com/ipt-webhook", on_raw, on_error); @@ -512,10 +543,16 @@ SMTP login with HTTP 500, which arrives as an error with ## 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. +The bulk hosts, the list evaluator and the Inbox Placement Tester are not built +from `api_base_url`, so `zero_bounce_initialize_with_base_url` alone leaves them +pointing at the live API. Each one has a setter. They keep the pointer they are +given rather than a copy, so the string has to outlive the client. ```c +zero_bounce_set_bulk_api_base_url(zb, "https://bulkapi.zerobounce.net/v2"); +zero_bounce_set_bulk_api_scoring_base_url(zb, "https://bulkapi.zerobounce.net/v2/scoring"); +/* The S3 and SFTP jobs use the bulk host root, without /v2. */ +zero_bounce_set_bulk_api_root_url(zb, "https://bulkapi.zerobounce.net"); 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"); ``` diff --git a/documentation.md b/documentation.md index ff305ff..e5e5fb7 100644 --- a/documentation.md +++ b/documentation.md @@ -379,9 +379,11 @@ SFTP/FTP server. See the [S3](https://www.zerobounce.net/docs/api-integrations/a 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. +Every one of these jobs needs both `file_name` and `return_url`, `validate_s3_file` +and `score_s3_file` included. The docs call several of them optional, but the live +API answers `Empty parameter: file_name` without the one and +`Provided return URL is not permitted: None` without the other, so the SDK checks +both before sending. ```c ZBBulkFileJobOptions options = new_bulk_file_job_options(); @@ -406,6 +408,22 @@ 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); + +/* validate_s3_file and score_s3_file take the same two required fields plus + their own columns. */ +ValidateS3FileOptions validate = new_validate_s3_file_options(); +validate.file_name = "lists/leads.csv"; +validate.return_url = "https://example.com/zerobounce-done"; +validate.email_address_column = 1; +validate_s3_file(zb, &validate, on_send, on_error); + +ScoreS3FileOptions score = new_score_s3_file_options(); +score.file_name = "lists/leads.csv"; +score.return_url = "https://example.com/zerobounce-done"; +score_s3_file(zb, &score, on_send, on_error); + +/* export_bucket is optional: NULL uses the one from your settings. */ +export_s3_file(zb, "", NULL, on_send, on_error); ``` ## Inbox Placement Tester @@ -418,10 +436,20 @@ the API takes, and it spends the account's inbox-test credits. get_inbox_placement_providers(zb, on_raw, on_error); get_inbox_placement_test_types(zb, on_raw, on_error); +/* name is at most 64 characters, test_type is 1, 2 or 3, and a test_type of 1 + needs a sender_email. */ +static const int provider_ids[] = {3, 7}; +static const ZBInboxPlacementProvider providers[] = {{3, 10}}; + 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"; +/* Optional: pick the providers, the seeds, or a per-provider seed cap. */ +create.provider_ids = provider_ids; +create.provider_ids_count = 2; +create.providers = providers; +create.providers_count = 1; create_inbox_placement_test(zb, "Launch", &create, on_raw, on_error); /* Send your campaign to the seeds you got back, then read the results. */ @@ -430,6 +458,9 @@ get_inbox_placement_test(zb, "", on_raw, on_error); ZBListInboxPlacementTestsOptions list = new_list_inbox_placement_tests_options(); list.status = "finished"; list.page = 1; +list.page_size = 25; /* 1 to 100 */ +list.created_after = "2026-01-01"; +list.created_before = "2026-02-01"; list_inbox_placement_tests(zb, &list, on_raw, on_error); set_inbox_placement_webhook(zb, "https://example.com/ipt-webhook", on_raw, on_error); @@ -500,10 +531,16 @@ SMTP login with HTTP 500, which arrives as an error with ## 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. +The bulk hosts, the list evaluator and the Inbox Placement Tester are not built +from `api_base_url`, so `zero_bounce_initialize_with_base_url` alone leaves them +pointing at the live API. Each one has a setter. They keep the pointer they are +given rather than a copy, so the string has to outlive the client. ```c +zero_bounce_set_bulk_api_base_url(zb, "https://bulkapi.zerobounce.net/v2"); +zero_bounce_set_bulk_api_scoring_base_url(zb, "https://bulkapi.zerobounce.net/v2/scoring"); +/* The S3 and SFTP jobs use the bulk host root, without /v2. */ +zero_bounce_set_bulk_api_root_url(zb, "https://bulkapi.zerobounce.net"); 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"); ``` diff --git a/include/ZeroBounce/ZBPriority3Options.h b/include/ZeroBounce/ZBPriority3Options.h index f43156b..0e4d22e 100644 --- a/include/ZeroBounce/ZBPriority3Options.h +++ b/include/ZeroBounce/ZBPriority3Options.h @@ -4,8 +4,11 @@ #include /** - * Optional columns for bulk email finder sendfile. + * Columns for bulk email finder sendfile. * Column indexes start at 1. has_header_row: -1 omit, 0 sends "false", 1 sends "true". + * + * One of first_name_column and full_name_column is required: the endpoint has + * no name to search for without it. */ typedef struct { int first_name_column; @@ -19,27 +22,48 @@ typedef struct { int has_header_row; } DomainSearchSendFileOptions; +/** + * Options for validate_s3_file. file_name and return_url are both required: + * without a return_url the API answers + * "Provided return URL is not permitted: None". + * + * remove_duplicate, allow_phase_2 and allow_phase_2_is_set are no longer sent. + * validateS3file documents remove_duplicate**s**, and allow_phase_2 belongs to + * the multipart bulk sendfile, not to this job. They are kept so existing code + * still compiles. + */ typedef struct { const char *file_name; int has_header_row; int remove_duplicates; + /** Deprecated: not sent. Use remove_duplicates. */ int remove_duplicate; int email_address_column; + /** Required. */ const char *return_url; int first_name_column; int last_name_column; int gender_column; int ip_address_column; + /** Deprecated: not sent. */ bool allow_phase_2_is_set; + /** Deprecated: not sent. */ bool allow_phase_2; } ValidateS3FileOptions; +/** + * Options for score_s3_file. file_name and return_url are both required, as + * they are for every other S3 and SFTP file job. + */ typedef struct { const char *file_name; int has_header_row; int remove_duplicates; + /** Deprecated: not sent. Use remove_duplicates. */ int remove_duplicate; int email_address_column; + /** Required. */ + const char *return_url; } ScoreS3FileOptions; EmailFinderSendFileOptions new_email_finder_send_file_options(void); diff --git a/include/ZeroBounce/ZeroBounce.h b/include/ZeroBounce/ZeroBounce.h index 4783017..18686b2 100644 --- a/include/ZeroBounce/ZeroBounce.h +++ b/include/ZeroBounce/ZeroBounce.h @@ -963,18 +963,34 @@ void list_s3_files( OnErrorCallback error_callback ); +/* The base URL overrides. Each one keeps the pointer it is given rather than a + * copy, so the string has to outlive the client. */ + /** @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 Overrides the bulk file base URL. Ends in /v2. */ +void zero_bounce_set_bulk_api_base_url(ZeroBounce *zb, const char *url); + +/** + * @brief Overrides the bulk host root, which the S3 and SFTP/FTP jobs use. + * + * This one has no /v2 on the end. + */ +void zero_bounce_set_bulk_api_root_url(ZeroBounce *zb, const char *url); + +/** @brief Overrides the bulk AI scoring base URL. Ends in /v2/scoring. */ +void zero_bounce_set_bulk_api_scoring_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. + * LIVE: file_name and return_url are required by every one of them, + * validate_sftp_file included, although the docs call its file_name optional. + * remove_duplicates is -1 to omit, 0 false, 1 true. */ typedef struct { const char *file_name; @@ -1010,8 +1026,8 @@ void domain_search_s3_file( /** * @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. + * LIVE: the docs call this job's file_name optional, but the API answers + * "Empty parameter: file_name" without it, so it is required here too. */ void validate_sftp_file( ZeroBounce *zb, @@ -1055,25 +1071,62 @@ void export_sftp_file( 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. */ +/* Inbox Placement Tester (/api/ipt). Its own host, camelCase JSON bodies, and + * errors under a "detail" key. Spends the account's inbox-test credits. + * + * LIVE: 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. */ + +/** + * @brief One entry of a new test's providers[] array. + * + * id is the provider id from get_inbox_placement_providers. max_seeds caps how + * many of that provider's seed addresses the test uses; -1 leaves it out. + */ +typedef struct { + int id; + int max_seeds; +} ZBInboxPlacementProvider; -/** @brief The optional fields of a new test. Only what is set is sent. */ +/** + * @brief The optional fields of a new test. Only what is set is sent. + * + * test_type is 1, 2 or 3, and a test_type of 1 requires sender_email. + * The three arrays are left out when their count is 0. + */ typedef struct { - /** -1 to omit. */ + /** -1 to omit. 1, 2 or 3 otherwise. */ int test_type; + /** Required when test_type is 1. */ const char *sender_email; const char *webhook_url; + /** Provider ids from get_inbox_placement_providers. */ + const int *provider_ids; + size_t provider_ids_count; + /** Seed ids to use instead of the whole seed list. */ + const int *seed_ids; + size_t seed_ids_count; + /** Per-provider seed caps. */ + const ZBInboxPlacementProvider *providers; + size_t providers_count; } ZBCreateInboxPlacementTestOptions; ZBCreateInboxPlacementTestOptions new_create_inbox_placement_test_options(void); -/** @brief The documented filters of the test list. page / page_size: -1 to omit. */ +/** + * @brief The documented filters of the test list. + * + * page / page_size: -1 to omit; page_size has to be between 1 and 100. + * created_after / created_before are the documented date filters; NULL omits + * them. + */ typedef struct { const char *status; int page; int page_size; + const char *created_after; + const char *created_before; } ZBListInboxPlacementTestsOptions; ZBListInboxPlacementTestsOptions new_list_inbox_placement_tests_options(void); @@ -1090,6 +1143,11 @@ void get_inbox_placement_test_types( OnErrorCallback error_callback ); +/** + * @brief Starts a new inbox placement test. + * + * name is required and is at most 64 characters. + */ void create_inbox_placement_test( ZeroBounce *zb, const char *name, diff --git a/src/Priority3.c b/src/Priority3.c index ecb8a4f..d1b6f73 100644 --- a/src/Priority3.c +++ b/src/Priority3.c @@ -13,6 +13,32 @@ 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); +/* The job-specific form fields an S3 or SFTP file job sends on top of the four + shared ones. */ +typedef void (*p3_extra_form_fields_fn)(char **body, size_t *cap, const void *ctx); + +static void p3_bulk_root_file_job_ex( + ZeroBounce *zb, + const char *path, + const char *file_name, + int has_header_row, + int remove_duplicates, + const char *return_url, + p3_extra_form_fields_fn add_extra_fields, + const void *extra_ctx, + OnSuccessCallbackSendFile success_callback, + OnErrorCallback 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 +); EmailFinderSendFileOptions new_email_finder_send_file_options(void) { EmailFinderSendFileOptions o = {0, 0, 0, 0, -1}; @@ -39,6 +65,7 @@ ScoreS3FileOptions new_score_s3_file_options(void) { o.remove_duplicates = -1; o.remove_duplicate = -1; o.email_address_column = -1; + o.return_url = NULL; return o; } @@ -568,6 +595,15 @@ void send_email_finder_file( } return; } + /* The endpoint has no name to search for without one of the two. */ + if (!options || (options->first_name_column < 1 && options->full_name_column < 1)) { + if (error_callback) { + error_callback(parse_error( + "Empty parameter: first_name_column or full_name_column (one is required)" + )); + } + return; + } char url[512]; snprintf(url, sizeof(url), "%s/email-finder/sendfile", zb->bulk_api_root_url); EmailFinderMimeCtx ctx = {domain_column, options}; @@ -680,7 +716,10 @@ void send_list_evaluator_file( return; } char url[512]; - snprintf(url, sizeof(url), "%s/listevaluator/", zb->bulk_api_base_url); + /* The list evaluator is a base URL of its own, the same one + get_list_evaluator_result reads, so the override set with + zero_bounce_set_list_evaluator_base_url moves both halves together. */ + snprintf(url, sizeof(url), "%s/", zb->list_evaluator_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 @@ -827,46 +866,37 @@ void delete_filter( 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); +static void p3_append_column_form(char **body, size_t *cap, const char *key, int column) { + char col[16]; + if (column <= 0) { + return; + } + snprintf(col, sizeof(col), "%d", column); + p3_append_form_field(body, cap, key, col); +} + +/* The columns validateS3file takes on top of the four fields every S3 and SFTP + job sends. email_address_column is 1-based, -1 omits it, and 0 is sent. */ +static void p3_validate_s3_extra_fields(char **body, size_t *cap, const void *ctx) { + const ValidateS3FileOptions *options = (const ValidateS3FileOptions *)ctx; 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); + p3_append_form_field(body, cap, "email_address_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) { + p3_append_column_form(body, cap, "first_name_column", options->first_name_column); + p3_append_column_form(body, cap, "last_name_column", options->last_name_column); + p3_append_column_form(body, cap, "gender_column", options->gender_column); + p3_append_column_form(body, cap, "ip_address_column", options->ip_address_column); +} + +static void p3_score_s3_extra_fields(char **body, size_t *cap, const void *ctx) { + const ScoreS3FileOptions *options = (const ScoreS3FileOptions *)ctx; + if (options->email_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"); + snprintf(col, sizeof(col), "%d", options->email_address_column); + p3_append_form_field(body, cap, "email_address_column", col); } - p3_append_form_field(&body, &cap, "api_key", zb->api_key); - return body; } void validate_s3_file( @@ -875,19 +905,18 @@ void validate_s3_file( 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); + ValidateS3FileOptions empty = new_validate_s3_file_options(); + const ValidateS3FileOptions *o = options ? options : ∅ + /* + * The same required-parameter rules as the other five jobs, so it goes + * through the same helper. remove_duplicate and allow_phase_2 are not sent: + * validateS3file documents remove_duplicate**s**, and allow_phase_2 is a + * field of the multipart bulk sendfile. + */ + p3_bulk_root_file_job_ex( + zb, "/validateS3file", o->file_name, o->has_header_row, o->remove_duplicates, + o->return_url, p3_validate_s3_extra_fields, o, success_callback, error_callback + ); } void score_s3_file( @@ -896,30 +925,12 @@ void score_s3_file( 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); + ScoreS3FileOptions empty = new_score_s3_file_options(); + const ScoreS3FileOptions *o = options ? options : ∅ + p3_bulk_root_file_job_ex( + zb, "/scoreS3file", o->file_name, o->has_header_row, o->remove_duplicates, + o->return_url, p3_score_s3_extra_fields, o, success_callback, error_callback + ); } void list_s3_files( @@ -991,29 +1002,12 @@ void export_s3_file( 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); + /* export_bucket is optional, as it is on the SFTP twin: left out, the + endpoint uses the export bucket from your ZeroBounce settings. */ + p3_export_bulk_file( + zb, "/exportS3file", "export_bucket", file_id, export_bucket, + success_callback, error_callback + ); } /* ------------------------------------------------------------------ */ @@ -1047,6 +1041,12 @@ ZBCreateInboxPlacementTestOptions new_create_inbox_placement_test_options(void) o.test_type = -1; o.sender_email = NULL; o.webhook_url = NULL; + o.provider_ids = NULL; + o.provider_ids_count = 0; + o.seed_ids = NULL; + o.seed_ids_count = 0; + o.providers = NULL; + o.providers_count = 0; return o; } @@ -1055,6 +1055,8 @@ ZBListInboxPlacementTestsOptions new_list_inbox_placement_tests_options(void) { o.status = NULL; o.page = -1; o.page_size = -1; + o.created_after = NULL; + o.created_before = NULL; return o; } @@ -1072,20 +1074,28 @@ static bool p3_is_blank(const char *value) { } /* - * POSTs one of the six documented S3 / SFTP file jobs on the bulkapi root. + * POSTs one of the eight 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. + * Every one of them sends the same four fields, so they all come through here. + * Booleans go on the wire as the strings "true"/"false", an option the caller + * left unset is not sent at all, and add_extra_fields adds whatever one job + * takes on top of the four (the validateS3file and scoreS3file columns). * * 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. + * "Provided return URL is not permitted: None", so both are checked before + * sending. */ -static void p3_bulk_root_file_job( +static void p3_bulk_root_file_job_ex( ZeroBounce *zb, const char *path, - const ZBBulkFileJobOptions *options, + const char *file_name, + int has_header_row, + int remove_duplicates, + const char *return_url, + p3_extra_form_fields_fn add_extra_fields, + const void *extra_ctx, OnSuccessCallbackSendFile success_callback, OnErrorCallback error_callback ) { @@ -1096,13 +1106,13 @@ static void p3_bulk_root_file_job( if (zero_bounce_invalid_api_key(zb, error_callback)) { return; } - if (!options || p3_is_blank(options->file_name)) { + if (p3_is_blank(file_name)) { if (error_callback) { error_callback(parse_error("Empty parameter: file_name")); } return; } - if (p3_is_blank(options->return_url)) { + if (p3_is_blank(return_url)) { if (error_callback) { error_callback(parse_error("Empty parameter: return_url")); } @@ -1110,15 +1120,34 @@ static void p3_bulk_root_file_job( } 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); + p3_append_optional_bool_form(&body, &cap, "has_header_row", has_header_row); + p3_append_form_field(&body, &cap, "file_name", file_name); + p3_append_optional_bool_form(&body, &cap, "remove_duplicates", remove_duplicates); + p3_append_form_field(&body, &cap, "return_url", return_url); + if (add_extra_fields) { + add_extra_fields(&body, &cap, extra_ctx); + } 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_bulk_root_file_job( + ZeroBounce *zb, + const char *path, + const ZBBulkFileJobOptions *options, + OnSuccessCallbackSendFile success_callback, + OnErrorCallback error_callback +) { + ZBBulkFileJobOptions empty = new_bulk_file_job_options(); + const ZBBulkFileJobOptions *o = options ? options : ∅ + /* has_header_row is a plain bool here, so it is always sent. */ + p3_bulk_root_file_job_ex( + zb, path, o->file_name, o->has_header_row ? 1 : 0, o->remove_duplicates, + o->return_url, NULL, NULL, success_callback, error_callback + ); +} + static void p3_export_bulk_file( ZeroBounce *zb, const char *path, @@ -1362,7 +1391,7 @@ static char *p3_inbox_placement_test_segment(const char *test_id, OnErrorCallbac return segment; } -static void p3_json_append(char **body, size_t *cap, const char *fragment) { +static void p3_str_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; @@ -1380,6 +1409,162 @@ static void p3_json_append(char **body, size_t *cap, const char *fragment) { strcat(*body, fragment); } +/* + * Escapes a value for use inside a JSON string. + * + * The Inbox Placement Tester bodies used to be concatenated into a fixed + * buffer with no escaping at all, so a quote or a backslash in a test name, a + * sender address or a webhook URL ended the string early: the body was either + * rejected as malformed or carried fields the caller never asked for, and + * anything longer than the buffer was silently cut off. Every value goes + * through here now, and the buffers grow to fit. + */ +static char *p3_json_escape(const char *value) { + const char *in = value ? value : ""; + size_t len = strlen(in); + /* Worst case is \u00XX: six bytes out for one byte in. */ + char *out = malloc(len * 6 + 1); + size_t i; + size_t j = 0; + + if (!out) { + fprintf(stderr, "Memory allocation failed.\n"); + exit(EXIT_FAILURE); + } + for (i = 0; i < len; i++) { + unsigned char c = (unsigned char)in[i]; + switch (c) { + case '"': out[j++] = '\\'; out[j++] = '"'; break; + case '\\': out[j++] = '\\'; out[j++] = '\\'; break; + case '\b': out[j++] = '\\'; out[j++] = 'b'; break; + case '\f': out[j++] = '\\'; out[j++] = 'f'; break; + case '\n': out[j++] = '\\'; out[j++] = 'n'; break; + case '\r': out[j++] = '\\'; out[j++] = 'r'; break; + case '\t': out[j++] = '\\'; out[j++] = 't'; break; + default: + if (c < 0x20) { + snprintf(out + j, 7, "\\u%04x", c); + j += 6; + } else { + out[j++] = (char)c; + } + break; + } + } + out[j] = '\0'; + return out; +} + +/* + * One "&key=value" query filter, url-encoded and appended to a buffer that + * grows to fit. p3_inbox_placement_request turns the leading '&' into the '?'. + */ +static void p3_query_append_string(char **query, size_t *cap, const char *key, const char *value) { + char *escaped; + if (!value || !value[0]) { + return; + } + escaped = zb_url_escape(value); + if (!escaped) { + fprintf(stderr, "Memory allocation failed.\n"); + exit(EXIT_FAILURE); + } + p3_str_append(query, cap, "&"); + p3_str_append(query, cap, key); + p3_str_append(query, cap, "="); + p3_str_append(query, cap, escaped); + free(escaped); +} + +static void p3_query_append_int(char **query, size_t *cap, const char *key, int value) { + char number[16]; + snprintf(number, sizeof(number), "%d", value); + p3_str_append(query, cap, "&"); + p3_str_append(query, cap, key); + p3_str_append(query, cap, "="); + p3_str_append(query, cap, number); +} + +/* A comma between two fields, but not after the opening brace. */ +static void p3_json_append_separator(char **body, size_t *cap) { + size_t len = *body ? strlen(*body) : 0; + if (len > 0 && (*body)[len - 1] != '{') { + p3_str_append(body, cap, ","); + } +} + +static void p3_json_append_string_field(char **body, size_t *cap, const char *key, const char *value) { + char *escaped = p3_json_escape(value); + p3_json_append_separator(body, cap); + p3_str_append(body, cap, "\""); + p3_str_append(body, cap, key); + p3_str_append(body, cap, "\":\""); + p3_str_append(body, cap, escaped); + p3_str_append(body, cap, "\""); + free(escaped); +} + +static void p3_json_append_int_field(char **body, size_t *cap, const char *key, int value) { + char number[16]; + snprintf(number, sizeof(number), "%d", value); + p3_json_append_separator(body, cap); + p3_str_append(body, cap, "\""); + p3_str_append(body, cap, key); + p3_str_append(body, cap, "\":"); + p3_str_append(body, cap, number); +} + +static void p3_json_append_int_array_field( + char **body, + size_t *cap, + const char *key, + const int *values, + size_t count +) { + size_t i; + if (!values || count == 0) { + return; + } + p3_json_append_separator(body, cap); + p3_str_append(body, cap, "\""); + p3_str_append(body, cap, key); + p3_str_append(body, cap, "\":["); + for (i = 0; i < count; i++) { + char number[16]; + snprintf(number, sizeof(number), "%s%d", i ? "," : "", values[i]); + p3_str_append(body, cap, number); + } + p3_str_append(body, cap, "]"); +} + +/* providers is an array of objects: [{"id":3,"maxSeeds":10}, ...]. */ +static void p3_json_append_providers_field( + char **body, + size_t *cap, + const ZBInboxPlacementProvider *providers, + size_t count +) { + size_t i; + if (!providers || count == 0) { + return; + } + p3_json_append_separator(body, cap); + p3_str_append(body, cap, "\"providers\":["); + for (i = 0; i < count; i++) { + char entry[64]; + if (providers[i].max_seeds >= 0) { + snprintf( + entry, sizeof(entry), "%s{\"id\":%d,\"maxSeeds\":%d}", + i ? "," : "", providers[i].id, providers[i].max_seeds + ); + } else { + snprintf(entry, sizeof(entry), "%s{\"id\":%d}", i ? "," : "", providers[i].id); + } + p3_str_append(body, cap, entry); + } + p3_str_append(body, cap, "]"); +} + void get_inbox_placement_providers( ZeroBounce *zb, OnSuccessCallbackRawJson success_callback, @@ -1396,6 +1581,8 @@ void get_inbox_placement_test_types( p3_inbox_placement_request(zb, "GET", "/test-types", NULL, NULL, success_callback, error_callback); } +#define ZB_INBOX_PLACEMENT_NAME_MAX 64 + void create_inbox_placement_test( ZeroBounce *zb, const char *name, @@ -1405,7 +1592,6 @@ void create_inbox_placement_test( ) { char *body = NULL; size_t cap = 0; - char buf[1024]; if (p3_is_blank(name)) { if (error_callback) { @@ -1413,25 +1599,51 @@ void create_inbox_placement_test( } return; } + if (strlen(name) > ZB_INBOX_PLACEMENT_NAME_MAX) { + if (error_callback) { + error_callback(parse_error("Invalid parameter: name (at most 64 characters)")); + } + return; + } + if (options && options->test_type >= 0 && + (options->test_type < 1 || options->test_type > 3)) { + if (error_callback) { + error_callback(parse_error("Invalid parameter: test_type (must be 1, 2 or 3)")); + } + return; + } + /* A seed-list test (type 1) has nowhere to send from without a sender. */ + if (options && options->test_type == 1 && p3_is_blank(options->sender_email)) { + if (error_callback) { + error_callback(parse_error("Empty parameter: sender_email (required when test_type is 1)")); + } + return; + } /* camelCase JSON, with unset fields left out. */ - snprintf(buf, sizeof(buf), "{\"name\":\"%s\"", name); - p3_json_append(&body, &cap, buf); + p3_str_append(&body, &cap, "{"); + p3_json_append_string_field(&body, &cap, "name", name); if (options) { if (options->test_type >= 0) { - snprintf(buf, sizeof(buf), ",\"testType\":%d", options->test_type); - p3_json_append(&body, &cap, buf); + p3_json_append_int_field(&body, &cap, "testType", options->test_type); } if (options->sender_email) { - snprintf(buf, sizeof(buf), ",\"senderEmail\":\"%s\"", options->sender_email); - p3_json_append(&body, &cap, buf); + p3_json_append_string_field(&body, &cap, "senderEmail", options->sender_email); } if (options->webhook_url) { - snprintf(buf, sizeof(buf), ",\"webhookUrl\":\"%s\"", options->webhook_url); - p3_json_append(&body, &cap, buf); + p3_json_append_string_field(&body, &cap, "webhookUrl", options->webhook_url); } + p3_json_append_int_array_field( + &body, &cap, "providerIds", options->provider_ids, options->provider_ids_count + ); + p3_json_append_int_array_field( + &body, &cap, "seedIds", options->seed_ids, options->seed_ids_count + ); + p3_json_append_providers_field( + &body, &cap, options->providers, options->providers_count + ); } - p3_json_append(&body, &cap, "}"); + p3_str_append(&body, &cap, "}"); p3_inbox_placement_request(zb, "POST", "/test", NULL, body, success_callback, error_callback); free(body); @@ -1459,20 +1671,31 @@ void list_inbox_placement_tests( 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); + char *query = NULL; + size_t cap = 0; + + if (options && options->page_size >= 0 && + (options->page_size < 1 || options->page_size > 100)) { + if (error_callback) { + error_callback(parse_error("Invalid parameter: page_size (must be between 1 and 100)")); } + return; + } + + if (options) { + p3_query_append_string(&query, &cap, "status", options->status); + p3_query_append_string(&query, &cap, "createdAfter", options->created_after); + p3_query_append_string(&query, &cap, "createdBefore", options->created_before); if (options->page >= 0) { - used += (size_t)snprintf(query + used, sizeof(query) - used, "&page=%d", options->page); + p3_query_append_int(&query, &cap, "page", options->page); } if (options->page_size >= 0) { - snprintf(query + used, sizeof(query) - used, "&pageSize=%d", options->page_size); + /* + * LIVE: the filter is page_size. pageSize is accepted and then + * silently ignored, so a caller asking for 10 rows quietly got the + * default page. + */ + p3_query_append_int(&query, &cap, "page_size", options->page_size); } } /* @@ -1480,6 +1703,7 @@ void list_inbox_placement_tests( * and answers 'Method "GET" not allowed'. */ p3_inbox_placement_request(zb, "GET", "/tests", query, NULL, success_callback, error_callback); + free(query); } void delete_inbox_placement_test( @@ -1512,13 +1736,17 @@ void set_inbox_placement_webhook( OnSuccessCallbackRawJson success_callback, OnErrorCallback error_callback ) { - char body[1024]; + char *body = NULL; + size_t cap = 0; 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_str_append(&body, &cap, "{"); + p3_json_append_string_field(&body, &cap, "webhookUrl", webhook_url); + p3_str_append(&body, &cap, "}"); p3_inbox_placement_request(zb, "PUT", "/webhook", NULL, body, success_callback, error_callback); + free(body); } diff --git a/src/ZeroBounce.c b/src/ZeroBounce.c index fa92e15..f6759b0 100644 --- a/src/ZeroBounce.c +++ b/src/ZeroBounce.c @@ -631,6 +631,29 @@ 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 bulk hosts are not built from api_base_url, so initializing with a + * regional base URL leaves them on the global bulk host. These three let a + * caller point them somewhere else, as zb_check_request_url's warning says. + */ +void zero_bounce_set_bulk_api_base_url(ZeroBounce *zb, const char *url) { + if (zb && url) { + zb->bulk_api_base_url = url; + } +} + +void zero_bounce_set_bulk_api_root_url(ZeroBounce *zb, const char *url) { + if (zb && url) { + zb->bulk_api_root_url = url; + } +} + +void zero_bounce_set_bulk_api_scoring_base_url(ZeroBounce *zb, const char *url) { + if (zb && url) { + zb->bulk_api_scoring_base_url = 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; @@ -1040,7 +1063,10 @@ static void send_file_internal( } } - if (strlen(options.return_url) != 0) { + /* A caller that zeroed SendFileOptions instead of calling + new_send_file_options leaves return_url NULL, so it is checked here the + same way the field notes above check it. */ + if (options.return_url && options.return_url[0] != '\0') { part = curl_mime_addpart(multipart); curl_mime_name(part, "return_url"); curl_mime_data(part, options.return_url, CURL_ZERO_TERMINATED); diff --git a/test/OfflineApiTest.c b/test/OfflineApiTest.c index 6973b0e..d03220b 100644 --- a/test/OfflineApiTest.c +++ b/test/OfflineApiTest.c @@ -646,12 +646,16 @@ void test_offline_priority3(void) { EmailFinderSendFileOptions finder = new_email_finder_send_file_options(); finder.has_header_row = 0; finder.first_name_column = 0; + /* One of first_name_column and full_name_column is required; a 0 column is + no column at all, so the full name column carries this request. */ + finder.full_name_column = 3; 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_TRUE(has_field("full_name_column", "3")); TEST_ASSERT_FALSE(has_field("first_name_column", NULL)); reset_offline(); email_finder_file_status(zb, "fid", on_status, on_error); @@ -716,20 +720,39 @@ void test_offline_priority3(void) { s3.file_name = "inbox.csv"; s3.email_address_column = 0; s3.has_header_row = 0; + s3.remove_duplicates = 1; s3.remove_duplicate = 1; + s3.allow_phase_2_is_set = true; + s3.allow_phase_2 = true; + s3.return_url = "https://example.com/done"; validate_s3_file(zb, &s3, on_send, on_error); request = only_request(); + TEST_ASSERT_EQUAL_STRING("POST", request.method); + TEST_ASSERT_EQUAL_STRING("bulkapi.zerobounce.net", request.host); TEST_ASSERT_EQUAL_STRING("/validateS3file", request.path); + TEST_ASSERT_EQUAL_STRING("p3-key", request.api_key); + TEST_ASSERT_EQUAL_INT(0, strncmp(request.body, "api_key=", 8)); + TEST_ASSERT_NOT_NULL(strstr(request.body, "file_name=inbox.csv")); 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_NOT_NULL(strstr(request.body, "remove_duplicates=true")); + TEST_ASSERT_NOT_NULL(strstr(request.body, "return_url=https%3A%2F%2Fexample.com%2Fdone")); TEST_ASSERT_NULL(strstr(request.body, "first_name_column")); + /* validateS3file documents remove_duplicate*s*, and allow_phase_2 belongs + to the multipart bulk sendfile; neither of the two goes on this wire. */ + TEST_ASSERT_NULL(strstr(request.body, "remove_duplicate=true")); + TEST_ASSERT_NULL(strstr(request.body, "allow_phase_2")); + TEST_ASSERT_EQUAL_STRING("p3-key", g_echo); reset_offline(); ScoreS3FileOptions score = new_score_s3_file_options(); score.file_name = "inbox.csv"; + score.return_url = "https://example.com/done"; score_s3_file(zb, &score, on_send, on_error); request = only_request(); + TEST_ASSERT_EQUAL_STRING("POST", request.method); TEST_ASSERT_EQUAL_STRING("/scoreS3file", request.path); + TEST_ASSERT_NOT_NULL(strstr(request.body, "file_name=inbox.csv")); + TEST_ASSERT_NOT_NULL(strstr(request.body, "return_url=https%3A%2F%2Fexample.com%2Fdone")); TEST_ASSERT_NULL(strstr(request.body, "email_address_column")); reset_offline(); export_s3_file(zb, "fid", "bucket-out", on_send, on_error); @@ -737,6 +760,14 @@ void test_offline_priority3(void) { TEST_ASSERT_EQUAL_STRING("/exportS3file", request.path); TEST_ASSERT_NOT_NULL(strstr(request.body, "export_bucket=bucket-out")); reset_offline(); + /* The export bucket is optional, the same as export_path on the SFTP twin: + without it the endpoint uses the one from the account settings. */ + export_s3_file(zb, "fid", NULL, on_send, on_error); + request = only_request(); + TEST_ASSERT_EQUAL_STRING("/exportS3file", request.path); + TEST_ASSERT_NOT_NULL(strstr(request.body, "file_id=fid")); + TEST_ASSERT_NULL(strstr(request.body, "export_bucket")); + reset_offline(); list_s3_files(zb, NULL, NULL, on_raw, on_error); request = only_request(); TEST_ASSERT_EQUAL_STRING("/listS3files", request.path); @@ -918,6 +949,167 @@ void test_offline_bulk_jobs_reject_before_request(void) { TEST_ASSERT_EQUAL_INT(0, g_count); } +void test_offline_s3_jobs_require_a_return_url(void) { + reset_offline(); + ZeroBounce *zb = client_with("s3-key"); + ValidateS3FileOptions validate = new_validate_s3_file_options(); + ScoreS3FileOptions score = new_score_s3_file_options(); + + /* LIVE: POST /validateS3file without a return_url answers 400 "Provided + return URL is not permitted: None", so it never leaves the SDK. */ + validate.file_name = "inbox.csv"; + validate_s3_file(zb, &validate, on_send, on_error); + TEST_ASSERT_TRUE(g_error_called); + TEST_ASSERT_NOT_NULL(strstr(g_error_text, "return_url")); + + g_error_called = 0; + validate.file_name = " "; + validate.return_url = "https://example.com/done"; + validate_s3_file(zb, &validate, on_send, on_error); + TEST_ASSERT_TRUE(g_error_called); + TEST_ASSERT_NOT_NULL(strstr(g_error_text, "file_name")); + + /* scoreS3file had no return_url field at all and so could never send one. */ + g_error_called = 0; + score.file_name = "inbox.csv"; + score_s3_file(zb, &score, on_send, on_error); + TEST_ASSERT_TRUE(g_error_called); + TEST_ASSERT_NOT_NULL(strstr(g_error_text, "return_url")); + + g_error_called = 0; + score_s3_file(zb, NULL, on_send, on_error); + TEST_ASSERT_TRUE(g_error_called); + TEST_ASSERT_NOT_NULL(strstr(g_error_text, "file_name")); + + g_error_called = 0; + export_s3_file(zb, "fid", " ", on_send, on_error); + TEST_ASSERT_TRUE(g_error_called); + TEST_ASSERT_NOT_NULL(strstr(g_error_text, "export_bucket")); + + TEST_ASSERT_EQUAL_INT(0, g_count); +} + +void test_offline_changed_calls_report_error_bodies(void) { + ValidateS3FileOptions validate = new_validate_s3_file_options(); + ScoreS3FileOptions score = new_score_s3_file_options(); + ZBListInboxPlacementTestsOptions list = new_list_inbox_placement_tests_options(); + + validate.file_name = "inbox.csv"; + validate.return_url = "https://example.com/done"; + score.file_name = "inbox.csv"; + score.return_url = "https://example.com/done"; + + /* An HTTP 200 with "success": false is a failure, and the status stays on + the error. */ + reset_offline(); + validate_s3_file(client_with("success-false"), &validate, on_send, on_error); + TEST_ASSERT_FALSE(g_success_called); + 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); + + /* A blocked request answers with an HTML page; it must not reach the + caller as a result, or whole, as an error. */ + reset_offline(); + score_s3_file(client_with("html-403-user"), &score, on_send, on_error); + TEST_ASSERT_FALSE(g_success_called); + expect_short_error(); + TEST_ASSERT_EQUAL_INT(403, (int)g_error_status); + + reset_offline(); + export_s3_file(client_with("empty-user"), "fid", NULL, on_send, on_error); + TEST_ASSERT_FALSE(g_success_called); + expect_short_error(); + + /* The Tester is a Django REST framework app and reports errors under + "detail". */ + reset_offline(); + create_inbox_placement_test(client_with("ipt-detail"), "Launch", NULL, on_raw, on_error); + TEST_ASSERT_FALSE(g_success_called); + TEST_ASSERT_NOT_NULL(strstr(g_error_text, "Inbox placement test not found.")); + TEST_ASSERT_EQUAL_STRING("Authorization: Bearer ipt-detail", only_request().extra_header); + TEST_ASSERT_NULL(strstr(only_request().query, "api_key=")); + + reset_offline(); + list_inbox_placement_tests(client_with("malformed-ipt"), &list, on_raw, on_error); + TEST_ASSERT_FALSE(g_success_called); + expect_short_error(); + + reset_offline(); + send_list_evaluator_file(client_with("html-200-eval"), "email_file.csv", 1, on_send, on_error); + TEST_ASSERT_FALSE(g_success_called); + expect_short_error(); +} + +void test_offline_email_finder_file_needs_a_name_column(void) { + reset_offline(); + ZeroBounce *zb = client_with("finder-key"); + EmailFinderSendFileOptions options = new_email_finder_send_file_options(); + + /* The endpoint has no name to search for without one of the two columns. */ + send_email_finder_file(zb, "email_file.csv", 1, &options, on_send, on_error); + TEST_ASSERT_TRUE(g_error_called); + TEST_ASSERT_NOT_NULL(strstr(g_error_text, "first_name_column")); + TEST_ASSERT_NOT_NULL(strstr(g_error_text, "full_name_column")); + + g_error_called = 0; + send_email_finder_file(zb, "email_file.csv", 1, NULL, on_send, on_error); + TEST_ASSERT_TRUE(g_error_called); + TEST_ASSERT_NOT_NULL(strstr(g_error_text, "full_name_column")); + TEST_ASSERT_EQUAL_INT(0, g_count); + + g_error_called = 0; + options.first_name_column = 2; + send_email_finder_file(zb, "email_file.csv", 1, &options, on_send, on_error); + TEST_ASSERT_FALSE(g_error_called); + TEST_ASSERT_EQUAL_STRING("/email-finder/sendfile", only_request().path); + TEST_ASSERT_TRUE(has_field("first_name_column", "2")); +} + +void test_offline_send_file_without_a_return_url(void) { + reset_offline(); + ZeroBounce *zb = client_with("send-key"); + /* A caller that zeroes SendFileOptions rather than calling + new_send_file_options leaves return_url NULL. That used to reach an + unguarded strlen on the live request path. */ + SendFileOptions options; + memset(&options, 0, sizeof(options)); + + send_file(zb, "email_file.csv", 1, options, on_send, on_error); + + TEST_ASSERT_FALSE(g_error_called); + TEST_ASSERT_EQUAL_STRING("/v2/sendfile", only_request().path); + TEST_ASSERT_FALSE(has_field("return_url", NULL)); +} + +void test_offline_bulk_base_url_overrides(void) { + reset_offline(); + ZeroBounce *zb = client_with("base-key"); + SendFileOptions options = new_send_file_options(); + ZBBulkFileJobOptions job = new_bulk_file_job_options(); + + /* The bulk hosts are not built from api_base_url, so each one has its own + setter. The region warning in zb_check_request_url points at these. */ + zero_bounce_set_bulk_api_base_url(zb, "https://bulk.example.test/v2"); + zero_bounce_set_bulk_api_scoring_base_url(zb, "https://bulk.example.test/v2/scoring"); + zero_bounce_set_bulk_api_root_url(zb, "https://bulk.example.test"); + + send_file(zb, "email_file.csv", 1, options, on_send, on_error); + TEST_ASSERT_EQUAL_STRING("bulk.example.test", only_request().host); + TEST_ASSERT_EQUAL_STRING("/v2/sendfile", only_request().path); + + reset_offline(); + scoring_send_file(zb, "email_file.csv", 1, options, on_send, on_error); + TEST_ASSERT_EQUAL_STRING("/v2/scoring/sendfile", only_request().path); + + reset_offline(); + job.file_name = "leads.csv"; + job.return_url = "https://example.com/done"; + domain_search_s3_file(zb, &job, on_send, on_error); + TEST_ASSERT_EQUAL_STRING("bulk.example.test", only_request().host); + TEST_ASSERT_EQUAL_STRING("/domainSearchS3file", only_request().path); +} + void test_offline_list_evaluator_base_url(void) { reset_offline(); ZeroBounce *zb = client_with("eval-key"); @@ -940,7 +1132,24 @@ void test_offline_list_evaluator_base_url(void) { 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); + + /* Both halves of the list evaluator follow the override. The upload used + to be built from bulk_api_base_url, which split the two across hosts. */ + reset_offline(); + send_list_evaluator_file(zb, "email_file.csv", 1, on_send, on_error); + request = only_request(); + TEST_ASSERT_EQUAL_STRING("POST", request.method); + TEST_ASSERT_EQUAL_STRING("bulk.example.test", request.host); + TEST_ASSERT_EQUAL_STRING("/v2/listevaluator/", request.path); + TEST_ASSERT_TRUE(has_field("email_address_column", "1")); + TEST_ASSERT_TRUE(has_field("api_key", "eval-key")); + zero_bounce_set_list_evaluator_base_url(zb, "https://bulkapi.zerobounce.net/v2/listevaluator"); + reset_offline(); + send_list_evaluator_file(zb, "email_file.csv", 1, on_send, on_error); + request = only_request(); + TEST_ASSERT_EQUAL_STRING("bulkapi.zerobounce.net", request.host); + TEST_ASSERT_EQUAL_STRING("/v2/listevaluator/", request.path); } void test_offline_inbox_placement(void) { @@ -963,17 +1172,28 @@ void test_offline_inbox_placement(void) { reset_offline(); ZBCreateInboxPlacementTestOptions create = new_create_inbox_placement_test_options(); + static const int provider_ids[] = {3, 7}; + static const int seed_ids[] = {11}; + static const ZBInboxPlacementProvider providers[] = {{3, 10}, {7, -1}}; create.test_type = 1; create.sender_email = "news@example.com"; create.webhook_url = "https://example.com/ipt"; + create.provider_ids = provider_ids; + create.provider_ids_count = 2; + create.seed_ids = seed_ids; + create.seed_ids_count = 1; + create.providers = providers; + create.providers_count = 2; 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. */ + /* Bodies are camelCase JSON, and unset fields are left out. A provider + without a maxSeeds carries only its id. */ TEST_ASSERT_EQUAL_STRING( "{\"name\":\"Launch\",\"testType\":1,\"senderEmail\":\"news@example.com\"," - "\"webhookUrl\":\"https://example.com/ipt\"}", + "\"webhookUrl\":\"https://example.com/ipt\",\"providerIds\":[3,7]," + "\"seedIds\":[11],\"providers\":[{\"id\":3,\"maxSeeds\":10},{\"id\":7}]}", request.body ); @@ -986,6 +1206,8 @@ void test_offline_inbox_placement(void) { list.status = "finished"; list.page = 1; list.page_size = 25; + list.created_after = "2026-01-01"; + list.created_before = "2026-02-01"; 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 @@ -993,7 +1215,12 @@ void test_offline_inbox_placement(void) { 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")); + TEST_ASSERT_NOT_NULL(strstr(request.query, "createdAfter=2026-01-01")); + TEST_ASSERT_NOT_NULL(strstr(request.query, "createdBefore=2026-02-01")); + /* LIVE: the page size filter is page_size. pageSize is accepted and then + silently ignored, so the caller quietly got the default page. */ + TEST_ASSERT_NOT_NULL(strstr(request.query, "page_size=25")); + TEST_ASSERT_NULL(strstr(request.query, "pageSize")); reset_offline(); list_inbox_placement_tests(zb, NULL, on_raw, on_error); @@ -1033,6 +1260,107 @@ void test_offline_inbox_placement(void) { TEST_ASSERT_EQUAL_INT(0, g_count); } +void test_offline_inbox_placement_limits(void) { + reset_offline(); + ZeroBounce *zb = client_with("ipt-key"); + ZBCreateInboxPlacementTestOptions create = new_create_inbox_placement_test_options(); + ZBListInboxPlacementTestsOptions list = new_list_inbox_placement_tests_options(); + char long_name[80]; + + /* The name is capped at 64 characters. */ + memset(long_name, 'n', sizeof(long_name) - 1); + long_name[sizeof(long_name) - 1] = '\0'; + create_inbox_placement_test(zb, long_name, NULL, on_raw, on_error); + TEST_ASSERT_TRUE(g_error_called); + TEST_ASSERT_NOT_NULL(strstr(g_error_text, "name")); + + g_error_called = 0; + long_name[64] = '\0'; + create_inbox_placement_test(zb, long_name, NULL, on_raw, on_error); + TEST_ASSERT_FALSE(g_error_called); + TEST_ASSERT_EQUAL_INT(1, g_count); + + /* testType is 1, 2 or 3. */ + reset_offline(); + create.test_type = 4; + create_inbox_placement_test(zb, "Launch", &create, on_raw, on_error); + TEST_ASSERT_TRUE(g_error_called); + TEST_ASSERT_NOT_NULL(strstr(g_error_text, "test_type")); + + /* A type 1 test has nowhere to send from without a sender address. */ + g_error_called = 0; + create.test_type = 1; + create.sender_email = NULL; + create_inbox_placement_test(zb, "Launch", &create, on_raw, on_error); + TEST_ASSERT_TRUE(g_error_called); + TEST_ASSERT_NOT_NULL(strstr(g_error_text, "sender_email")); + + /* Types 2 and 3 do not need one. */ + g_error_called = 0; + create.test_type = 2; + create_inbox_placement_test(zb, "Launch", &create, on_raw, on_error); + TEST_ASSERT_FALSE(g_error_called); + TEST_ASSERT_EQUAL_STRING("{\"name\":\"Launch\",\"testType\":2}", only_request().body); + + /* page_size is between 1 and 100. */ + reset_offline(); + list.page_size = 0; + list_inbox_placement_tests(zb, &list, on_raw, on_error); + TEST_ASSERT_TRUE(g_error_called); + TEST_ASSERT_NOT_NULL(strstr(g_error_text, "page_size")); + + g_error_called = 0; + list.page_size = 101; + list_inbox_placement_tests(zb, &list, on_raw, on_error); + TEST_ASSERT_TRUE(g_error_called); + TEST_ASSERT_NOT_NULL(strstr(g_error_text, "page_size")); + TEST_ASSERT_EQUAL_INT(0, g_count); + + g_error_called = 0; + list.page_size = 100; + list_inbox_placement_tests(zb, &list, on_raw, on_error); + TEST_ASSERT_FALSE(g_error_called); + TEST_ASSERT_NOT_NULL(strstr(only_request().query, "page_size=100")); +} + +void test_offline_inbox_placement_json_is_escaped(void) { + reset_offline(); + ZeroBounce *zb = client_with("ipt-key"); + ZBCreateInboxPlacementTestOptions create = new_create_inbox_placement_test_options(); + char long_url[2048]; + OfflineRequest request; + + /* + * The bodies used to be concatenated into a fixed 1024-byte buffer with no + * escaping, so a quote closed the JSON string early and injected whatever + * followed, and anything longer than the buffer was cut off mid-field. + */ + create.sender_email = "a\"b\\c@example.com"; + create.webhook_url = "https://example.com/a\tb"; + create_inbox_placement_test(zb, "He said \"go\"", &create, on_raw, on_error); + request = only_request(); + TEST_ASSERT_EQUAL_STRING( + "{\"name\":\"He said \\\"go\\\"\"," + "\"senderEmail\":\"a\\\"b\\\\c@example.com\"," + "\"webhookUrl\":\"https://example.com/a\\tb\"}", + request.body + ); + + /* A long value grows the body instead of being truncated. */ + reset_offline(); + memset(long_url, 'u', sizeof(long_url) - 1); + long_url[sizeof(long_url) - 1] = '\0'; + memcpy(long_url, "https://example.com/", 20); + set_inbox_placement_webhook(zb, long_url, on_raw, on_error); + request = only_request(); + TEST_ASSERT_EQUAL_STRING("PUT", request.method); + TEST_ASSERT_EQUAL_STRING("/api/ipt/webhook", request.path); + /* {"webhookUrl":"…"} is the url plus 17 bytes of wrapper. */ + TEST_ASSERT_EQUAL_INT((int)(strlen(long_url) + 17), (int)strlen(request.body)); + TEST_ASSERT_NOT_NULL(strstr(request.body, long_url)); + TEST_ASSERT_EQUAL_STRING("}", request.body + strlen(request.body) - 1); +} + void test_offline_inbox_placement_detail_error(void) { reset_offline(); ZeroBounce *zb = client_with("ipt-detail"); @@ -1409,8 +1737,15 @@ void offline_run_tests(void) { 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_s3_jobs_require_a_return_url); + RUN_TEST(test_offline_changed_calls_report_error_bodies); + RUN_TEST(test_offline_email_finder_file_needs_a_name_column); + RUN_TEST(test_offline_send_file_without_a_return_url); + RUN_TEST(test_offline_bulk_base_url_overrides); RUN_TEST(test_offline_list_evaluator_base_url); RUN_TEST(test_offline_inbox_placement); + RUN_TEST(test_offline_inbox_placement_limits); + RUN_TEST(test_offline_inbox_placement_json_is_escaped); RUN_TEST(test_offline_inbox_placement_detail_error); RUN_TEST(test_offline_rejects_before_request); RUN_TEST(test_offline_html_403); diff --git a/test/ZeroBounceTest.c b/test/ZeroBounceTest.c index 8c3efad..40fbe99 100644 --- a/test/ZeroBounceTest.c +++ b/test/ZeroBounceTest.c @@ -1050,6 +1050,9 @@ void test_validate_s3_file_valid(void) { get_http_code_fake.return_val = 200; ValidateS3FileOptions options = new_validate_s3_file_options(); options.file_name = "bucket/key.csv"; + /* LIVE: without a return_url the API answers 400 "Provided return URL is + not permitted: None", so the SDK requires one. */ + options.return_url = "https://example.com/done"; validate_s3_file(zb, &options, on_success_send_file_valid, on_error_valid); const char* url = zero_bounce_get_last_request_url_for_test(); @@ -1059,6 +1062,7 @@ void test_validate_s3_file_valid(void) { const char* body = zero_bounce_get_last_post_body_for_test(); TEST_ASSERT_NOT_NULL(body); TEST_ASSERT_NOT_NULL(strstr(body, "file_name=")); + TEST_ASSERT_NOT_NULL(strstr(body, "return_url=")); } void test_score_email_post_valid(void) {