Skip to content

fix: S3 bulk jobs drifted from the shared required-field helper - #18

Merged
franciscbalint merged 1 commit into
mainfrom
fix/s3-bulk-job-required-fields
Oct 25, 2026
Merged

franciscbalint merged 1 commit into
mainfrom
fix/s3-bulk-job-required-fields

Conversation

@franciscbalint

Copy link
Copy Markdown
Collaborator

Why

validateS3File (op 31) and scoreS3File (op 32) each built their own form instead of going through bulkFileJobFields, and both had drifted from the four S3/SFTP jobs that use it. That duplication is the bug:

  • The live API requires return_url on these endpoints. POST /validateS3file without it answers 400 {"error_message":"Provided return URL is not permitted: None"}. ScoreS3FileOptions had no returnUrl field at all, so a required parameter was unreachable from the public API and every scoreS3File call failed; validateS3File sent it only when it happened to be non-empty.
  • has_header_row was sent only when set on these two, while the other four always send it.
  • Both sent an undocumented singular remove_duplicate alongside remove_duplicates. remove_duplicate belongs to the bulk /v2/sendfile upload, where SendFileOptions still carries it.

What changed

Op 31 / 32 — validateS3File, scoreS3File
Both now build their shared fields through bulkFileJobFields and append only the extra fields they alone take (email_address_column, the name/gender/IP columns and allow_phase_2 for validate; email_address_column for score). fileName and returnUrl are required, blank-rejected before the request. ScoreS3FileOptions gains returnUrl; both option structs lose removeDuplicate and take a plain bool hasHeaderRow = true, matching BulkFileJobOptions.

Op 35 — exportS3File
export_bucket is optional per the docs, and exportSFTPFile already treats export_path that way. It now takes std::optional<std::string> and omits the field when unset; a bucket that was passed but is blank is still an error. Existing call sites passing a string literal keep compiling.

Op 20 — sendEmailFinderFile
Only domain_column was validated, so an upload with no name column reached the API and was refused there. One of first_name_column and full_name_column is now required up front, matching the Java and Python SDKs' message.

Bulk base URLs
bulkApiRootUrl, bulkApiBaseUrl and bulkApiScoringBaseUrl were const members with no setter, so ops 11-27 and 30-40 could only ever reach bulkapi.zerobounce.net. They now have setBulkApiRootUrl / setBulkApiBaseUrl / setBulkApiScoringBaseUrl, in the style of the existing setListEvaluatorBaseUrl / setInboxPlacementBaseUrl. The list evaluator and Inbox Placement Tester are still governed by their own setters.

ZeroBounceWarmup::initialize
It rejected a padded key or a non-https base URL by leaving the client un-ready with nothing to read back, and warmupReady then guessed at the reason. initialize now records why it refused; isReady() and getInitializeError() expose it, and the error callback repeats that exact reason. The https requirement stands: the Warmup key travels in an API-Key request header, so a plain-http base URL would put it on the wire in the clear — and the offline suite injects a BaseRequestHandler, so a plain-HTTP local mock is not needed to test it.

Stale comment
The comment above the Inbox Placement Tester block claimed the key "goes on the query string rather than in the body"; the implementation 30 lines below has used Authorization: Bearer for a while. Fixed in both Priority3.cpp and ZeroBounce.h.

team_id — investigated, left alone

The hardcoded AnyTeamId = 1 on PUT /sending-accounts and PUT /smtp-credentials is not a guess. The Python reference SDK sends team_id: _ANY_TEAM_ID = 1 on both, with the same "required by the schema but always replaced with the key's team" comment, and the Java, Go and C# SDKs all do the same. The C++ code already matches the yardstick, so nothing changed here.

Tests

New gtest cases with the mocked HTTP handler, asserting method, full URL, form fields, key placement and response parsing:

  • OfflineApi.ValidateAndScoreS3FileShareTheBulkFileJobFields — both jobs' full field set, the key in the body and not the query string, no remove_duplicate, and the blank/unset return_url and file_name rejections.
  • OfflineApi.AValidateS3FileReturnUrlRejectionIsReadBack — the verbatim live 400 {"error_message":"Provided return URL is not permitted: None"} body is read back as a short message with status 400.
  • OfflineApi.ExportS3FileTreatsTheBucketAsOptional
  • OfflineApi.EmailFinderSendFileRequiresAFirstOrFullNameColumn
  • OfflineApi.TheBulkBaseUrlsCanBePointedAtATestHost — bulk validation, scoring, an SFTP job and the bulk finder follow the setters; the list evaluator does not.
  • OfflineWarmup.InitializeReportsWhyItRejectedTheArguments
$ cmake --build build
[100%] Built target ZeroBounceTests

$ ctest --test-dir build --output-on-failure
100% tests passed, 0 tests failed out of 126

Total Test time (real) =   2.97 sec

The opt-in live suite (-DZEROBOUNCE_BUILD_LIVE_TESTS=ON) was also compiled to confirm it still typechecks. It was not run: that needs real keys and spends credits, and per AGENTS.md it is a pre-release step for a maintainer.

🤖 Generated with Claude Code

validateS3File and scoreS3File each built their own form instead of going
through bulkFileJobFields, and both had drifted from the four jobs that do.
The live API requires return_url on every one of these endpoints — POST
/validateS3file without it answers 400 {"error_message":"Provided return URL
is not permitted: None"} — but scoreS3File had no returnUrl field at all, so
a required parameter was unreachable from the public API and every call
failed, and validateS3File sent it only when it happened to be non-empty.
Both now build their shared fields through the helper and add only the extra
fields they alone take, so the next change to what the API requires is made
in one place. They also stop sending the undocumented singular
remove_duplicate alongside remove_duplicates: that field belongs to the bulk
/v2/sendfile upload, where SendFileOptions still carries it.

exportS3File hard-rejected an empty export_bucket although the docs make it
optional and exportSFTPFile already treats export_path that way; it now takes
an optional bucket and omits the field when unset.

sendEmailFinderFile validated only domain_column, so an upload with no name
column reached the API and was refused there; one of first_name_column and
full_name_column is now required before the request.

bulkApiRootUrl, bulkApiBaseUrl and bulkApiScoringBaseUrl were const with no
setter, so operations 11-27 and 30-40 could only ever reach production and
none of them could be pointed at a test host. They now have setters in the
same style as setListEvaluatorBaseUrl and setInboxPlacementBaseUrl.

ZeroBounceWarmup::initialize rejected a bad key or a non-https base URL by
leaving the client un-ready with nothing to read back, and warmupReady then
guessed at the reason. initialize now records why it refused; isReady() and
getInitializeError() expose it, and the error callback repeats that reason
rather than a guess. The https requirement stands: the Warmup key travels in
a request header, so a plain-http base URL would put it on the wire in clear.

Also corrects a stale comment claiming the Inbox Placement Tester key goes on
the query string; the code 30 lines below has used a Bearer header for a while.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@franciscbalint
franciscbalint merged commit ac2cb74 into main Oct 25, 2026
6 checks passed
@franciscbalint
franciscbalint deleted the fix/s3-bulk-job-required-fields branch October 25, 2026 21:19
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant