Skip to content

fix: cap HTTP response growth in write_callback - #10

Merged
franciscbalint merged 19 commits into
mainfrom
fix/response-size-cap-f01
Oct 24, 2026
Merged

franciscbalint merged 19 commits into
mainfrom
fix/response-size-cap-f01

Conversation

@franciscbalint

@franciscbalint franciscbalint commented Oct 17, 2026 •

Copy link
Copy Markdown
Collaborator

Summary

  • Response size caps (F-01) for JSON and getfile downloads

Priority 2 — /v2/scoring (real-time AI scoring)

  • score_email (GET) and score_email_post (form POST) on main API base URL
  • ZBScoreEmailResponse (email, score)

Priority 3 — bulk finder, list evaluator, filters, S3

  • ZBPriority3Options.h + Priority3.c: email finder / domain search, list evaluator, send_evaluated_file, filters, S3 validate/score/export
  • Raw JSON callbacks for filter/list-evaluator GET responses

Test plan

  • ./build/bin/ZeroBounceTests (39 tests passed, incl. test_validate_s3_file_valid)

Made with Cursor


RD-66: ai_agent_mailbox sub_status

Folded in from the former feature/ai-agent-mailbox-substatus branch, which has been deleted now that the commit lives here.

Adds the ai_agent_mailbox sub_status, which the API returns alongside both valid and catch-all. Tests cover both pairings, plus an unrecognised sub_status to pin that a value added to the API after this SDK ships degrades gracefully instead of failing the whole validate call.

No sub_status_ai_agent_mailbox usage counter was added: it is not documented.


Parity with the Python SDK, verified against the live API

Brings this SDK to the same surface as the (merged) Python SDK and checks every
call against the real API. Where the live API disagrees with the docs, the API
wins and the call site says so in a LIVE: comment.

A real bug the live run found

p3_append_form_field builds every form-encoded body. It starts from a NULL
buffer with a zero capacity and grows it with realloc, which for a NULL
pointer is malloc: the block comes back uninitialized. The code then took
strlen of it and strcat'ed onto it without terminating it first, so
whatever bytes the allocator happened to hand over were sent in front of the
first field.

It usually looked fine, because a fresh page is zeroed. Once the process had
allocated and freed enough, it was not — the live suite hit it as a Cloudflare
block (error 1020) on add_filter, and it affected send_evaluated_file and
the S3/SFTP file jobs just as much. Fixed, with an offline test pinning that
each of those bodies starts at api_key=.

Corrected against the live API

  • validatebatch takes timeout as a JSON string. Sent as a number the
    API rejects the whole batch with the account-level "Invalid API Key or your
    account ran out of credits"
    — an error that looks nothing like a bad field.
  • The Inbox Placement Tester authenticates with Authorization: Bearer,
    not the api_key query parameter the rest of the API takes. As a parameter
    the request is rejected as unauthorized, and the key would end up in whatever
    logs the query string. 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 now checked before the request leaves.
  • send_evaluated_file takes a file id as a form field, not a path to
    upload. Its second argument changed accordingly — the one breaking change
    here.
  • A "success": false field on an HTTP 200 is a failure, not a result.
    get_file and delete_file report an unknown or deleted file id that way,
    so it now reaches the error callback. ZBErrorResponse.status_code carries
    the HTTP status so a caller can tell 429 from 500 without parsing the text.

Added

  • ZeroBounceWarmup — a client for the 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. Two more live findings are baked in:
    check-domain-dmarc takes an email address (a bare domain is answered
    "Please provide a valid email address"), and test-smtp-configuration
    answers a failed SMTP login with HTTP 500. The Warmup key and the SMTP
    credentials are redacted out of any error that escapes the client.
  • Safety rails — a key with surrounding whitespace or control characters is
    rejected (it would otherwise be quoted unredacted in a transport error); only
    https:// URLs are called; a . or .. path id is rejected; redirects are
    never followed and only the https protocol is enabled; zb_redact_api_key
    strips the key out of any message built from a URL; and a regional base URL
    paired with the single global bulk host warns once.
  • get_file_streaming / scoring_get_file_streaming write a result file
    straight to disk as it arrives rather than buffering it in memory. Opt-in,
    because a streamed body cannot be inspected as a whole: the first 4 KB are
    held back and checked, so a JSON or HTML error still reaches the error
    callback and no partial file is left behind.
  • live/live_api.c, an opt-in live suite built only with
    -DZEROBOUNCE_BUILD_LIVE_TESTS=ON, so the default build and CI never compile
    it. Keys come from the environment only; ZEROBOUNCE_LIVE_SPEND=1 and
    ZEROBOUNCE_LIVE_CHANGES=1 opt in to calls that spend credits or make
    reversible account changes, and everything the changes tier creates is
    removed again in the same run. It never creates an Inbox Placement test, sets
    the Inbox Placement webhook, or starts ZB managed Warmup onboarding: the API
    cannot undo those.

Docs

README.md and documentation.md gain the Warmup API, the list evaluator and
the streaming download, and lose the two claims the live API contradicts.
AGENTS.md gains the live-API-wins rule, the safety rails, and a release rule:
run the live suite by hand with real keys before tagging, because the offline
suite cannot catch an API that has changed shape underneath the SDK.

Test plan

  • cmake --build build + ./build/bin/ZeroBounceTests — 92 tests, 0 failures
  • Live suite against the real API with both flags set —
    40 passed, 0 failed, 4 skipped (the four skips are the per-address
    Warmup statistics; this team has no sending account)
  • Both also run on Linux in a container, since this machine's network
    policy blocks freshly built native binaries
  • No key is written to a file, a commit, a log or a fixture; keys were
    passed as environment variables only

🤖 Generated with Claude Code

franciscbalint and others added 19 commits October 18, 2026 00:36
Align JSON and getfile download limits with Rust SDK caps and stop exiting
the process on URL-escape allocation failures in query building.

Co-authored-by: Cursor <cursoragent@cursor.com>
Add validate_*_with_options APIs, batch body test hook, and active_in_days/active_first_seen on responses.

Co-authored-by: Cursor <cursoragent@cursor.com>
Grow validate query URLs with realloc before appending optional params, and copy activity fields in batch response vectors to avoid heap corruption in tests.

Co-authored-by: Cursor <cursoragent@cursor.com>
json-c may insert spaces after colons in serialized batch payloads.

Co-authored-by: Cursor <cursoragent@cursor.com>
Add ZBScoreEmailResponse plus score_email and score_email_post with form-encoded POST, matching JS scoreEmail parity.

Co-authored-by: Cursor <cursoragent@cursor.com>
Add Priority3 C API surface aligned with C++/JS bulkapi endpoints and Unity mock tests for validateS3file.

Co-authored-by: Cursor <cursoragent@cursor.com>
Add form-encoded POST option on single validate (alongside GET) and GET
listS3files on bulkapi root, with unit tests and API parity doc updates.
Wire ZBValidateOptions.use_post for validate_email_with_options and add list_s3_files Unity tests.

Co-authored-by: Cursor <cursoragent@cursor.com>
HTML pages, empty bodies, and account-level batch failures were reaching callers as raw payloads or successes. Record each request in the test hook so the default Unity run checks endpoints without the network.

Co-authored-by: Cursor <cursoragent@cursor.com>
…TP bulk

Ports 407f0a8 and b0444f8 from the Python SDK, on top of the parity work
already on this branch.

Added:
- ZBValidateOptions gains domain_info, greylist_processing and callback_url.
  Booleans go on the wire as the strings true/false on both the GET and the
  form POST. callback_url is where the greylist retry posts its result, so it
  is rejected without greylist_processing, and when blank, before any 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.
- email_finder_s3_file and domain_search_s3_file, from
  https://www.zerobounce.net/docs/api-integrations/amazon-s3-bulk-api
- validate_sftp_file, score_sftp_file, email_finder_sftp_file,
  domain_search_sftp_file and export_sftp_file, from
  https://www.zerobounce.net/docs/api-integrations/sftp_bulk_api
- The Inbox Placement Tester. The API key goes on the query string, bodies are
  camelCase JSON, unset fields are left out, and it needs PUT and DELETE, so
  zb_make_request_with_method_and_body is new.
- zero_bounce_set_list_evaluator_base_url and
  zero_bounce_set_inbox_placement_base_url.

Changed:
- The list evaluator uses its own base URL instead of building the path from
  the bulk base, and the file id is encoded as a single path segment.
  get_list_evaluator_result now rejects a blank file id before a request is
  sent. send_evaluated_file stays on the bulk base.

The six S3 / SFTP file jobs share ZBBulkFileJobOptions and
p3_bulk_root_file_job, which sends has_header_row and remove_duplicates as the
strings true/false and leaves remove_duplicates and return_url out until set.
Two shapes differ from the rest and are easy to get wrong:
- validate_sftp_file's file_name is optional; leaving it NULL uses the input
  bucket from the account's settings. Every other job requires it, and a blank
  name is still rejected.
- export_sftp_file sends export_path, not the export_bucket export_s3_file
  sends.

p3_escape_path_segment is new: zb_url_escape is a query encoder and turns a
space into "+", which is a literal plus inside a path. The list evaluator file
id and the Inbox Placement test id go through it.

parse_error already collects every value of an error body, so the Tester's
Django-style {"detail": ...} surfaces without a special case; a test now pins
that.

The new bulk assertions read OfflineRequest.body rather than has_field,
because p3_append_form_field does not call zb_note_http_field — that only
covers the multipart paths. AGENTS.md records this.

Tests: 70 pass, up from 63. Offline only — a fake perform hook records every
request and answers without the network.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The API now returns ai_agent_mailbox under both the valid and catch-all
statuses. No new status was added.

Adds AiAgentMailbox to the enum and the matching strcmp arm in
sub_status_from_string, following role_based_catch_all.

Unknown values were already safe: sub_status_from_string ends in `return
None`, so a sub_status added after this SDK shipped maps to None rather than
leaving the field uninitialised. A test pins that alongside the two new ones.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…jobs against the live API

Verified against the live API; where it disagrees with the docs the API wins
and the call site says so.

- validatebatch takes `timeout` as a JSON string. Sent as a number the API
  rejects the whole batch with the account-level "Invalid API Key or your
  account ran out of credits".
- The Inbox Placement Tester authenticates with `Authorization: Bearer`, not
  the `api_key` query parameter the rest of the API takes, and its listing is
  `GET /tests` (plural) — `/test` answers 'Method "GET" not allowed'.
  zb_make_request_with_extra_header carries the one extra header, and the
  offline stub reads the key from it the way the API does.
- Every S3/SFTP job requires both `file_name` and `return_url`, including
  validateSFTPfile, whose file_name the docs call optional.
- send_evaluated_file takes the id of a finished evaluation as a form field,
  not an upload, so its second argument is now a file id.
- A `"success": false` field on an HTTP 200 is a failure, not a result:
  getfile and deletefile report an unknown or deleted file id that way.
  ZBErrorResponse gained `status_code` so a caller can tell 429 from 500
  without parsing the message.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
- zb_api_key_is_clean rejects a key with surrounding whitespace or control
  characters, which would otherwise be quoted unredacted in a transport error.
  Both initializers run it.
- zb_check_request_url refuses anything that is not https, so the api_key is
  never put on the wire in the clear, and every transport calls it before the
  mock short-circuit so the offline suite sees the same rule.
- Redirects are never followed (CURLOPT_FOLLOWLOCATION 0, and only the https
  protocol is enabled) and a 3xx is reported as a redirect rather than parsed.
- p3_escape_path_segment rejects a '.' or '..' id, which would walk the
  request out of the endpoint it was meant for. The list evaluator file id and
  the Inbox Placement test id go through it.
- zb_redact_api_key strips the key out of any message built from a URL.
- A regional base URL paired with the single global bulk host warns once:
  ZeroBounce documents no regional variant of bulkapi.zerobounce.net.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The Warmup API is a separate product from email validation: its own host, its
own team-scoped key, and an API-Key header instead of an api_key parameter.
ZeroBounceWarmup takes the Warmup key; ZeroBounce stays for the validation
key, and the two never mix.

All 26 documented endpoints, each handing the raw JSON body to its callback,
or an empty string for the documented empty successes (HTTP 204 and an empty
200). The two CSV endpoints hand over the text and write it to a path when
one is given.

Arguments are checked before anything is sent: the engagement ranges
(standard rules 0-4, reply_pct 5-45, important_pct 5-40, click_pct 0-70 and
at most important_pct x 1.75), ISO dates, the timezone offset, the email
types and the SMTP port.

Verified against the live API:
- check-domain-dmarc takes an email address. The docs describe a domain, but
  a bare domain is answered with "Please provide a valid email address".
- test-smtp-configuration answers a failed SMTP login with HTTP 500.

The Warmup key and the SMTP credentials are redacted out of any error that
escapes the client, and the key is checked for whitespace and control
characters the same way the validation key is.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
live/live_api.c is built only with -DZEROBOUNCE_BUILD_LIVE_TESTS=ON, so
`cmake --build build` and CI never compile it and the default test run never
touches the network. Keys come from the environment only:

  ZEROBOUNCE_API_KEY           the validation API (skipped without it)
  ZEROBOUNCE_WARMUP_API_KEY    the Warmup API (skipped without it)
  ZEROBOUNCE_LIVE_SPEND=1      calls that spend credits
  ZEROBOUNCE_LIVE_CHANGES=1    reversible account changes, undone in the
                               same run even when a check fails

By default it only makes free calls: sandbox addresses, reads, and requests
expected to fail that still prove the API accepts the payload. The free tier
checks that the credit balance did not move.

Never called, because the API cannot undo them: creating an Inbox Placement
test, setting the Inbox Placement webhook, and ZB managed Warmup onboarding.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
get_file buffers the whole body in memory before writing it. For a list too
large to hold, get_file_streaming and scoring_get_file_streaming write it out
as it arrives.

They are opt-in rather than the default because a streamed body cannot be
inspected as a whole, and getfile answers an unknown or deleted file id with a
JSON error rather than a file. The first 4 KB are held back and checked, so an
error still reaches the error callback and no partial file is left behind; the
offline suite drives the same probe-then-stream path through the test hook.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
README.md and documentation.md gain the Warmup API, the list evaluator
(including send_evaluated_file now taking a file id), and the streaming
download, and lose the two claims the live API contradicts: that the Inbox
Placement Tester takes the key on the query string, and that
validate_sftp_file's file_name is optional.

AGENTS.md gains the live-API-wins rule with the differences found so far, the
safety rails, how to build and run the live suite, and a release rule: run it
by hand with real keys before tagging, because the offline suite cannot catch
an API that has changed shape underneath the SDK.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
p3_append_form_field starts from a NULL buffer with a zero capacity and grows
it with realloc, which for a NULL pointer is malloc: the block comes back
uninitialized. The code then took strlen of it and strcat'ed onto it without
terminating it first, so whatever bytes the allocator happened to hand over
were sent in front of the first field.

It usually looked fine, because a fresh page is zeroed; once the process had
allocated and freed enough, it was not. The live suite caught it as a
Cloudflare block on add_filter, which also affected send_evaluated_file and
the S3/SFTP file jobs. The offline test pins that each of those bodies starts
at api_key=.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Found by auditing every SDK's api-usage model against the keys the live API
actually returns, after the same class of bug turned up in Rust and iOS.

The API sends sub_status_antispam_system. This SDK asked json-c for
sub_status_anti_spam_system, with an underscore between anti and spam, so the
counter silently stayed 0 on every call. The struct field keeps its name, so
callers are unaffected; only the key it reads is corrected.

sub_status_leading_period_removed and sub_status_alias_address are returned by
the API and were not read at all. Both are now parsed, printed and compared.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@franciscbalint
franciscbalint merged commit 2d96d0f into main Oct 24, 2026
6 checks passed
@franciscbalint
franciscbalint deleted the fix/response-size-cap-f01 branch October 24, 2026 19:07
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