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) {