diff --git a/.rubocop.yml b/.rubocop.yml new file mode 100644 index 0000000..ca0f64e --- /dev/null +++ b/.rubocop.yml @@ -0,0 +1,16 @@ +# RuboCop runs in CI (`bundle exec rubocop`). This codebase predates the lint +# step, so existing offences are baselined in .rubocop_todo.yml and only new +# code has to be clean. Work through the todo file over time; do not add to it. +inherit_from: .rubocop_todo.yml + +require: + - rubocop-performance + +AllCops: + TargetRubyVersion: 2.5 + NewCops: enable + SuggestExtensions: false + Exclude: + - 'vendor/**/*' + - 'bin/**/*' + - 'live/**/*' diff --git a/.rubocop_todo.yml b/.rubocop_todo.yml new file mode 100644 index 0000000..481db6e --- /dev/null +++ b/.rubocop_todo.yml @@ -0,0 +1,767 @@ +# This configuration was generated by +# `rubocop --auto-gen-config --auto-gen-only-exclude --exclude-limit 200` +# on 2026-10-24 15:18:24 UTC using RuboCop version 1.49.0. +# The point is for the user to remove these configuration records +# one by one as the offenses are removed from the code base. +# Note that changes in the inspected code, or installation of new +# versions of RuboCop, may require this file to be generated again. + +# Offense count: 1 +# This cop supports safe autocorrection (--autocorrect). +# Configuration parameters: TreatCommentsAsGroupSeparators, ConsiderPunctuation, Include. +# Include: **/*.gemfile, **/Gemfile, **/gems.rb +Bundler/OrderedGems: + Exclude: + - 'Gemfile' + +# Offense count: 5 +# Configuration parameters: EnforcedStyle, AllowedGems, Include. +# SupportedStyles: Gemfile, gems.rb, gemspec +# Include: **/*.gemspec, **/Gemfile, **/gems.rb +Gemspec/DevelopmentDependencies: + Exclude: + - 'zerobounce.gemspec' + +# Offense count: 5 +# This cop supports safe autocorrection (--autocorrect). +# Configuration parameters: TreatCommentsAsGroupSeparators, ConsiderPunctuation, Include. +# Include: **/*.gemspec +Gemspec/OrderedDependencies: + Exclude: + - 'zerobounce.gemspec' + +# Offense count: 1 +# This cop supports safe autocorrection (--autocorrect). +# Configuration parameters: Severity, Include. +# Include: **/*.gemspec +Gemspec/RequireMFA: + Exclude: + - 'zerobounce.gemspec' + +# Offense count: 4 +# This cop supports safe autocorrection (--autocorrect). +# Configuration parameters: EnforcedStyle, IndentationWidth. +# SupportedStyles: with_first_argument, with_fixed_indentation +Layout/ArgumentAlignment: + Exclude: + - 'lib/zerobounce/mock_request.rb' + - 'lib/zerobounce/request.rb' + +# Offense count: 8 +# This cop supports safe autocorrection (--autocorrect). +# Configuration parameters: EnforcedStyleAlignWith. +# SupportedStylesAlignWith: either, start_of_block, start_of_line +Layout/BlockAlignment: + Exclude: + - 'spec/mock_spec.rb' + - 'spec/support/mock_env.rb' + +# Offense count: 6 +# This cop supports safe autocorrection (--autocorrect). +Layout/BlockEndNewline: + Exclude: + - 'spec/mock_spec.rb' + +# Offense count: 9 +# This cop supports safe autocorrection (--autocorrect). +Layout/ClosingParenthesisIndentation: + Exclude: + - 'spec/mock_spec.rb' + +# Offense count: 2 +# This cop supports safe autocorrection (--autocorrect). +# Configuration parameters: AllowForAlignment. +Layout/CommentIndentation: + Exclude: + - 'spec/mock_spec.rb' + +# Offense count: 24 +# This cop supports safe autocorrection (--autocorrect). +Layout/EmptyLineAfterGuardClause: + Exclude: + - 'lib/zerobounce.rb' + - 'lib/zerobounce/base_request.rb' + - 'lib/zerobounce/mock_request.rb' + - 'lib/zerobounce/priority3.rb' + - 'lib/zerobounce/request.rb' + - 'lib/zerobounce/response_error.rb' + - 'spec/mock_spec.rb' + +# Offense count: 1 +# This cop supports safe autocorrection (--autocorrect). +Layout/EmptyLineAfterMagicComment: + Exclude: + - 'spec/support/mock_env.rb' + +# Offense count: 1 +# This cop supports safe autocorrection (--autocorrect). +# Configuration parameters: EmptyLineBetweenMethodDefs, EmptyLineBetweenClassDefs, EmptyLineBetweenModuleDefs, AllowAdjacentOneLineDefs, NumberOfEmptyLines. +Layout/EmptyLineBetweenDefs: + Exclude: + - 'lib/zerobounce.rb' + +# Offense count: 4 +# This cop supports safe autocorrection (--autocorrect). +Layout/EmptyLines: + Exclude: + - 'lib/zerobounce.rb' + - 'lib/zerobounce/base_request.rb' + - 'spec/mock_spec.rb' + +# Offense count: 2 +# This cop supports safe autocorrection (--autocorrect). +# Configuration parameters: EnforcedStyle. +# SupportedStyles: empty_lines, no_empty_lines +Layout/EmptyLinesAroundBlockBody: + Exclude: + - 'spec/mock_spec.rb' + +# Offense count: 7 +# This cop supports safe autocorrection (--autocorrect). +# Configuration parameters: EnforcedStyle. +# SupportedStyles: empty_lines, empty_lines_except_namespace, empty_lines_special, no_empty_lines, beginning_only, ending_only +Layout/EmptyLinesAroundClassBody: + Exclude: + - 'lib/zerobounce.rb' + - 'lib/zerobounce/base_request.rb' + - 'lib/zerobounce/mock_request.rb' + - 'lib/zerobounce/request.rb' + +# Offense count: 2 +# This cop supports safe autocorrection (--autocorrect). +Layout/EmptyLinesAroundMethodBody: + Exclude: + - 'lib/zerobounce/base_request.rb' + +# Offense count: 6 +# This cop supports safe autocorrection (--autocorrect). +# Configuration parameters: EnforcedStyle. +# SupportedStyles: empty_lines, empty_lines_except_namespace, empty_lines_special, no_empty_lines +Layout/EmptyLinesAroundModuleBody: + Exclude: + - 'lib/zerobounce.rb' + - 'lib/zerobounce/base_request.rb' + - 'lib/zerobounce/mock_request.rb' + - 'lib/zerobounce/request.rb' + +# Offense count: 76 +# This cop supports safe autocorrection (--autocorrect). +# Configuration parameters: EnforcedStyle, IndentationWidth. +# SupportedStyles: consistent, consistent_relative_to_receiver, special_for_inner_method_call, special_for_inner_method_call_in_parentheses +Layout/FirstArgumentIndentation: + Exclude: + - 'spec/mock_spec.rb' + +# Offense count: 5 +# This cop supports safe autocorrection (--autocorrect). +# Configuration parameters: EnforcedStyle, IndentationWidth. +# SupportedStyles: special_inside_parentheses, consistent, align_brackets +Layout/FirstArrayElementIndentation: + Exclude: + - 'spec/mock_spec.rb' + +# Offense count: 3 +# This cop supports safe autocorrection (--autocorrect). +# Configuration parameters: EnforcedStyle, IndentationWidth. +# SupportedStyles: special_inside_parentheses, consistent, align_braces +Layout/FirstHashElementIndentation: + Exclude: + - 'lib/zerobounce.rb' + - 'spec/mock_spec.rb' + +# Offense count: 2 +# This cop supports safe autocorrection (--autocorrect). +# Configuration parameters: EnforcedStyle, IndentationWidth. +# SupportedStyles: consistent, align_parentheses +Layout/FirstParameterIndentation: + Exclude: + - 'lib/zerobounce.rb' + +# Offense count: 3 +# This cop supports safe autocorrection (--autocorrect). +# Configuration parameters: EnforcedStyle. +# SupportedStyles: normal, indented_internal_methods +Layout/IndentationConsistency: + Exclude: + - 'spec/mock_spec.rb' + +# Offense count: 987 +# This cop supports safe autocorrection (--autocorrect). +# Configuration parameters: IndentationWidth, EnforcedStyle. +# SupportedStyles: spaces, tabs +Layout/IndentationStyle: + Exclude: + - 'spec/mock_spec.rb' + +# Offense count: 288 +# This cop supports safe autocorrection (--autocorrect). +# Configuration parameters: Width, AllowedPatterns. +Layout/IndentationWidth: + Exclude: + - 'spec/mock_spec.rb' + - 'spec/support/mock_env.rb' + +# Offense count: 1 +# This cop supports safe autocorrection (--autocorrect). +Layout/LeadingEmptyLines: + Exclude: + - 'lib/zerobounce.rb' + +# Offense count: 1 +# This cop supports safe autocorrection (--autocorrect). +# Configuration parameters: EnforcedStyle. +# SupportedStyles: leading, trailing +Layout/LineContinuationLeadingSpace: + Exclude: + - 'lib/zerobounce/base_request.rb' + +# Offense count: 1 +# This cop supports safe autocorrection (--autocorrect). +# Configuration parameters: AutoCorrect, EnforcedStyle. +# SupportedStyles: space, no_space +Layout/LineContinuationSpacing: + Exclude: + - 'lib/zerobounce/base_request.rb' + +# Offense count: 1 +# This cop supports safe autocorrection (--autocorrect). +# Configuration parameters: EnforcedStyle, IndentationWidth. +# SupportedStyles: aligned, indented +Layout/LineEndStringConcatenationIndentation: + Exclude: + - 'lib/zerobounce/base_request.rb' + +# Offense count: 14 +# This cop supports safe autocorrection (--autocorrect). +Layout/MultilineBlockLayout: + Exclude: + - 'spec/mock_spec.rb' + +# Offense count: 16 +# This cop supports safe autocorrection (--autocorrect). +# Configuration parameters: EnforcedStyle. +# SupportedStyles: symmetrical, new_line, same_line +Layout/MultilineMethodCallBraceLayout: + Exclude: + - 'spec/mock_spec.rb' + +# Offense count: 1 +# This cop supports safe autocorrection (--autocorrect). +# Configuration parameters: EnforcedStyle, IndentationWidth. +# SupportedStyles: aligned, indented, indented_relative_to_receiver +Layout/MultilineMethodCallIndentation: + Exclude: + - 'spec/mock_spec.rb' + +# Offense count: 4 +# This cop supports safe autocorrection (--autocorrect). +# Configuration parameters: EnforcedStyle, IndentationWidth. +# SupportedStyles: aligned, indented +Layout/MultilineOperationIndentation: + Exclude: + - 'lib/zerobounce/mock_request.rb' + - 'lib/zerobounce/request.rb' + +# Offense count: 2 +# This cop supports safe autocorrection (--autocorrect). +Layout/SpaceAfterComma: + Exclude: + - 'spec/mock_spec.rb' + +# Offense count: 24 +# This cop supports safe autocorrection (--autocorrect). +# Configuration parameters: EnforcedStyle. +# SupportedStyles: space, no_space +Layout/SpaceAroundEqualsInParameterDefault: + Exclude: + - 'lib/zerobounce.rb' + - 'lib/zerobounce/base_request.rb' + - 'lib/zerobounce/error.rb' + - 'lib/zerobounce/mock_request.rb' + - 'lib/zerobounce/request.rb' + +# Offense count: 1 +# This cop supports safe autocorrection (--autocorrect). +Layout/SpaceAroundKeyword: + Exclude: + - 'spec/mock_spec.rb' + +# Offense count: 1 +# This cop supports safe autocorrection (--autocorrect). +# Configuration parameters: AllowForAlignment, EnforcedStyleForExponentOperator. +# SupportedStylesForExponentOperator: space, no_space +Layout/SpaceAroundOperators: + Exclude: + - 'lib/zerobounce.rb' + +# Offense count: 52 +# This cop supports safe autocorrection (--autocorrect). +# Configuration parameters: EnforcedStyle, EnforcedStyleForEmptyBraces. +# SupportedStyles: space, no_space +# SupportedStylesForEmptyBraces: space, no_space +Layout/SpaceBeforeBlockBraces: + Exclude: + - 'spec/mock_spec.rb' + +# Offense count: 10 +# This cop supports safe autocorrection (--autocorrect). +# Configuration parameters: EnforcedStyle, EnforcedStyleForEmptyBraces, SpaceBeforeBlockParameters. +# SupportedStyles: space, no_space +# SupportedStylesForEmptyBraces: space, no_space +Layout/SpaceInsideBlockBraces: + Exclude: + - 'spec/mock_spec.rb' + +# Offense count: 18 +# This cop supports safe autocorrection (--autocorrect). +# Configuration parameters: EnforcedStyle, EnforcedStyleForEmptyBraces. +# SupportedStyles: space, no_space, compact +# SupportedStylesForEmptyBraces: space, no_space +Layout/SpaceInsideHashLiteralBraces: + Exclude: + - 'lib/zerobounce.rb' + +# Offense count: 2 +# This cop supports safe autocorrection (--autocorrect). +# Configuration parameters: EnforcedStyle. +# SupportedStyles: final_newline, final_blank_line +Layout/TrailingEmptyLines: + Exclude: + - 'Gemfile' + - 'lib/zerobounce/base_request.rb' + +# Offense count: 8 +# This cop supports safe autocorrection (--autocorrect). +# Configuration parameters: AllowInHeredoc. +Layout/TrailingWhitespace: + Exclude: + - 'lib/zerobounce.rb' + - 'spec/mock_spec.rb' + +# Offense count: 1 +# Configuration parameters: AllowedMethods, AllowedPatterns. +Lint/AmbiguousBlockAssociation: + Exclude: + - 'spec/mock_spec.rb' + +# Offense count: 14 +Lint/IneffectiveAccessModifier: + Exclude: + - 'lib/zerobounce/base_request.rb' + +# Offense count: 15 +# This cop supports safe autocorrection (--autocorrect). +Lint/ParenthesesAsGroupedExpression: + Exclude: + - 'lib/zerobounce/base_request.rb' + - 'lib/zerobounce/mock_request.rb' + - 'lib/zerobounce/request.rb' + - 'spec/mock_spec.rb' + +# Offense count: 1 +# This cop supports safe autocorrection (--autocorrect). +Lint/RedundantCopDisableDirective: + Exclude: + - 'zerobounce.gemspec' + +# Offense count: 1 +# This cop supports safe autocorrection (--autocorrect). +# Configuration parameters: AllowUnusedKeywordArguments, IgnoreEmptyMethods, IgnoreNotImplementedMethods. +Lint/UnusedMethodArgument: + Exclude: + - 'lib/zerobounce/base_request.rb' + +# Offense count: 1 +# This cop supports safe autocorrection (--autocorrect). +# Configuration parameters: ContextCreatingMethods, MethodCreatingMethods. +Lint/UselessAccessModifier: + Exclude: + - 'lib/zerobounce/base_request.rb' + +# Offense count: 1 +Lint/UselessAssignment: + Exclude: + - 'lib/zerobounce.rb' + +# Offense count: 27 +# Configuration parameters: AllowedMethods, AllowedPatterns, CountRepeatedAttributes, Max. +Metrics/AbcSize: + Exclude: + - 'lib/zerobounce.rb' + - 'lib/zerobounce/base_request.rb' + - 'lib/zerobounce/configuration.rb' + - 'lib/zerobounce/get_file_helper.rb' + - 'lib/zerobounce/mock_request.rb' + - 'lib/zerobounce/priority3.rb' + - 'lib/zerobounce/request.rb' + - 'lib/zerobounce/response_error.rb' + - 'lib/zerobounce/warmup.rb' + - 'spec/offline_api_spec.rb' + +# Offense count: 42 +# Configuration parameters: CountComments, Max, CountAsOne, AllowedMethods, AllowedPatterns. +# AllowedMethods: refine +Metrics/BlockLength: + Exclude: + - '**/*.gemspec' + - 'spec/base_request_spec.rb' + - 'spec/bulk_getfile_v2_spec.rb' + - 'spec/bulk_jobs_spec.rb' + - 'spec/mock_spec.rb' + - 'spec/offline_api_spec.rb' + - 'spec/priority3_spec.rb' + - 'spec/warmup_spec.rb' + +# Offense count: 2 +# Configuration parameters: CountBlocks, Max. +Metrics/BlockNesting: + Exclude: + - 'lib/zerobounce/response_error.rb' + +# Offense count: 2 +# Configuration parameters: CountComments, Max, CountAsOne. +Metrics/ClassLength: + Exclude: + - 'lib/zerobounce/base_request.rb' + - 'lib/zerobounce/warmup.rb' + +# Offense count: 21 +# Configuration parameters: AllowedMethods, AllowedPatterns, Max. +Metrics/CyclomaticComplexity: + Exclude: + - 'lib/zerobounce.rb' + - 'lib/zerobounce/base_request.rb' + - 'lib/zerobounce/get_file_helper.rb' + - 'lib/zerobounce/priority3.rb' + - 'lib/zerobounce/request.rb' + - 'lib/zerobounce/response_error.rb' + - 'lib/zerobounce/warmup.rb' + - 'spec/offline_api_spec.rb' + +# Offense count: 28 +# Configuration parameters: CountComments, Max, CountAsOne, AllowedMethods, AllowedPatterns. +Metrics/MethodLength: + Exclude: + - 'lib/zerobounce.rb' + - 'lib/zerobounce/base_request.rb' + - 'lib/zerobounce/get_file_helper.rb' + - 'lib/zerobounce/mock_request.rb' + - 'lib/zerobounce/priority3.rb' + - 'lib/zerobounce/request.rb' + - 'lib/zerobounce/response_error.rb' + - 'lib/zerobounce/warmup.rb' + - 'spec/offline_api_spec.rb' + +# Offense count: 2 +# Configuration parameters: CountComments, Max, CountAsOne. +Metrics/ModuleLength: + Exclude: + - 'lib/zerobounce.rb' + - 'lib/zerobounce/priority3.rb' + +# Offense count: 4 +# Configuration parameters: Max, CountKeywordArgs, MaxOptionalParameters. +Metrics/ParameterLists: + Exclude: + - 'lib/zerobounce.rb' + - 'lib/zerobounce/base_request.rb' + - 'lib/zerobounce/priority3.rb' + +# Offense count: 16 +# Configuration parameters: AllowedMethods, AllowedPatterns, Max. +Metrics/PerceivedComplexity: + Exclude: + - 'lib/zerobounce.rb' + - 'lib/zerobounce/base_request.rb' + - 'lib/zerobounce/get_file_helper.rb' + - 'lib/zerobounce/priority3.rb' + - 'lib/zerobounce/request.rb' + - 'lib/zerobounce/response_error.rb' + - 'lib/zerobounce/warmup.rb' + - 'spec/offline_api_spec.rb' + +# Offense count: 1 +Naming/AccessorMethodName: + Exclude: + - 'lib/zerobounce/priority3.rb' + +# Offense count: 1 +# Configuration parameters: EnforcedStyle, CheckMethodNames, CheckSymbols, AllowedIdentifiers, AllowedPatterns. +# SupportedStyles: snake_case, normalcase, non_integer +# AllowedIdentifiers: capture3, iso8601, rfc1123_date, rfc822, rfc2822, rfc3339, x86_64 +Naming/VariableNumber: + Exclude: + - 'lib/zerobounce.rb' + +# Offense count: 2 +# This cop supports unsafe autocorrection (--autocorrect-all). +# Configuration parameters: SafeMultiline. +Performance/DeletePrefix: + Exclude: + - 'lib/zerobounce/base_request.rb' + +# Offense count: 3 +# This cop supports unsafe autocorrection (--autocorrect-all). +Performance/TimesMap: + Exclude: + - 'spec/offline_api_spec.rb' + +# Offense count: 5 +# This cop supports unsafe autocorrection (--autocorrect-all). +# Configuration parameters: EnforcedStyle. +# SupportedStyles: always, conditionals +Style/AndOr: + Exclude: + - 'lib/zerobounce/base_request.rb' + - 'lib/zerobounce/mock_request.rb' + - 'lib/zerobounce/request.rb' + +# Offense count: 14 +# This cop supports safe autocorrection (--autocorrect). +# Configuration parameters: EnforcedStyle, ProceduralMethods, FunctionalMethods, AllowedMethods, AllowedPatterns, AllowBracesOnProceduralOneLiners, BracesRequiredMethods. +# SupportedStyles: line_count_based, semantic, braces_for_chaining, always_braces +# ProceduralMethods: benchmark, bm, bmbm, create, each_with_object, measure, new, realtime, tap, with_object +# FunctionalMethods: let, let!, subject, watch +# AllowedMethods: lambda, proc, it +Style/BlockDelimiters: + Exclude: + - 'spec/mock_spec.rb' + +# Offense count: 1 +Style/ClassVars: + Exclude: + - 'lib/zerobounce.rb' + +# Offense count: 6 +# This cop supports safe autocorrection (--autocorrect). +# Configuration parameters: Keywords, RequireColon. +# Keywords: TODO, FIXME, OPTIMIZE, HACK, REVIEW, NOTE +Style/CommentAnnotation: + Exclude: + - 'lib/zerobounce.rb' + - 'lib/zerobounce/error.rb' + - 'spec/mock_spec.rb' + +# Offense count: 1 +# This cop supports safe autocorrection (--autocorrect). +Style/DefWithParentheses: + Exclude: + - 'lib/zerobounce.rb' + +# Offense count: 1 +# Configuration parameters: AllowedConstants. +Style/Documentation: + Exclude: + - 'spec/**/*' + - 'test/**/*' + - 'lib/zerobounce/priority3.rb' + +# Offense count: 1 +# This cop supports safe autocorrection (--autocorrect). +Style/EachForSimpleLoop: + Exclude: + - 'spec/warmup_spec.rb' + +# Offense count: 3 +# This cop supports safe autocorrection (--autocorrect). +# Configuration parameters: AllowedVars. +Style/FetchEnvVar: + Exclude: + - 'lib/zerobounce/configuration.rb' + - 'spec/mock_spec.rb' + +# Offense count: 2 +# This cop supports safe autocorrection (--autocorrect). +# Configuration parameters: MinBodyLength, AllowConsecutiveConditionals. +Style/GuardClause: + Exclude: + - 'lib/zerobounce/mock_request.rb' + +# Offense count: 22 +# This cop supports safe autocorrection (--autocorrect). +Style/IfUnlessModifier: + Exclude: + - 'lib/zerobounce.rb' + - 'lib/zerobounce/base_request.rb' + - 'lib/zerobounce/get_file_helper.rb' + - 'lib/zerobounce/mock_request.rb' + - 'lib/zerobounce/priority3.rb' + - 'lib/zerobounce/request.rb' + - 'lib/zerobounce/response_error.rb' + - 'lib/zerobounce/warmup.rb' + +# Offense count: 4 +# This cop supports safe autocorrection (--autocorrect). +# Configuration parameters: AllowedMethods, AllowedPatterns. +Style/MethodCallWithoutArgsParentheses: + Exclude: + - 'spec/mock_spec.rb' + +# Offense count: 3 +# This cop supports safe autocorrection (--autocorrect). +# Configuration parameters: AllowMethodComparison. +Style/MultipleComparison: + Exclude: + - 'lib/zerobounce/base_request.rb' + - 'lib/zerobounce/get_file_helper.rb' + +# Offense count: 2 +# This cop supports safe autocorrection (--autocorrect). +Style/NestedTernaryOperator: + Exclude: + - 'lib/zerobounce/priority3.rb' + +# Offense count: 1 +# This cop supports safe autocorrection (--autocorrect). +# Configuration parameters: EnforcedStyle, MinBodyLength. +# SupportedStyles: skip_modifier_ifs, always +Style/Next: + Exclude: + - 'lib/zerobounce/response_error.rb' + +# Offense count: 4 +# This cop supports unsafe autocorrection (--autocorrect-all). +# Configuration parameters: EnforcedStyle, AllowedMethods, AllowedPatterns. +# SupportedStyles: predicate, comparison +Style/NumericPredicate: + Exclude: + - 'spec/**/*' + - 'lib/zerobounce/mock_request.rb' + - 'lib/zerobounce/request.rb' + +# Offense count: 1 +# This cop supports safe autocorrection (--autocorrect). +# Configuration parameters: AlwaysCorrectToMultiline. +Style/OneLineConditional: + Exclude: + - 'spec/support/mock_env.rb' + +# Offense count: 1 +# Configuration parameters: AllowedMethods. +# AllowedMethods: respond_to_missing? +Style/OptionalBooleanParameter: + Exclude: + - 'lib/zerobounce/configuration.rb' + +# Offense count: 3 +# This cop supports safe autocorrection (--autocorrect). +# Configuration parameters: EnforcedStyle, AllowedCompactTypes. +# SupportedStyles: compact, exploded +Style/RaiseArgs: + Exclude: + - 'lib/zerobounce.rb' + - 'lib/zerobounce/base_request.rb' + +# Offense count: 1 +# This cop supports safe autocorrection (--autocorrect). +Style/RedundantAssignment: + Exclude: + - 'lib/zerobounce.rb' + +# Offense count: 11 +# This cop supports safe autocorrection (--autocorrect). +Style/RedundantLineContinuation: + Exclude: + - 'lib/zerobounce/mock_request.rb' + - 'lib/zerobounce/request.rb' + - 'spec/mock_spec.rb' + +# Offense count: 11 +# This cop supports safe autocorrection (--autocorrect). +# Configuration parameters: AllowMultipleReturnValues. +Style/RedundantReturn: + Exclude: + - 'lib/zerobounce.rb' + - 'lib/zerobounce/base_request.rb' + - 'lib/zerobounce/mock_request.rb' + - 'lib/zerobounce/request.rb' + +# Offense count: 18 +# This cop supports safe autocorrection (--autocorrect). +Style/RedundantSelf: + Exclude: + - 'lib/zerobounce/mock_request.rb' + - 'lib/zerobounce/request.rb' + +# Offense count: 7 +# This cop supports safe autocorrection (--autocorrect). +# Configuration parameters: EnforcedStyle, AllowInnerSlashes. +# SupportedStyles: slashes, percent_r, mixed +Style/RegexpLiteral: + Exclude: + - 'spec/base_request_spec.rb' + - 'spec/bulk_jobs_spec.rb' + - 'spec/mock_spec.rb' + +# Offense count: 1 +# This cop supports unsafe autocorrection (--autocorrect-all). +# Configuration parameters: ConvertCodeThatCanStartToReturnNil, AllowedMethods, MaxChainLength. +# AllowedMethods: present?, blank?, presence, try, try! +Style/SafeNavigation: + Exclude: + - 'lib/zerobounce.rb' + +# Offense count: 2 +# This cop supports safe autocorrection (--autocorrect). +# Configuration parameters: AllowIfMethodIsEmpty. +Style/SingleLineMethods: + Exclude: + - 'spec/base_request_spec.rb' + +# Offense count: 1 +# This cop supports safe autocorrection (--autocorrect). +# Configuration parameters: AllowModifier. +Style/SoleNestedConditional: + Exclude: + - 'spec/offline_api_spec.rb' + +# Offense count: 1 +# This cop supports unsafe autocorrection (--autocorrect-all). +# Configuration parameters: Mode. +Style/StringConcatenation: + Exclude: + - 'spec/offline_api_spec.rb' + +# Offense count: 7 +# This cop supports safe autocorrection (--autocorrect). +# Configuration parameters: EnforcedStyle, ConsistentQuotesInMultiline. +# SupportedStyles: single_quotes, double_quotes +Style/StringLiterals: + Exclude: + - 'lib/zerobounce.rb' + - 'spec/support/mock_env.rb' + +# Offense count: 2 +# This cop supports safe autocorrection (--autocorrect). +# Configuration parameters: EnforcedStyleForMultiline. +# SupportedStylesForMultiline: comma, consistent_comma, no_comma +Style/TrailingCommaInHashLiteral: + Exclude: + - 'lib/zerobounce.rb' + +# Offense count: 4 +# This cop supports safe autocorrection (--autocorrect). +# Configuration parameters: WordRegex. +# SupportedStyles: percent, brackets +Style/WordArray: + EnforcedStyle: percent + MinSize: 8 + +# Offense count: 4 +# This cop supports unsafe autocorrection (--autocorrect-all). +Style/ZeroLengthPredicate: + Exclude: + - 'lib/zerobounce/mock_request.rb' + - 'lib/zerobounce/request.rb' + +# Offense count: 13 +# This cop supports safe autocorrection (--autocorrect). +# Configuration parameters: Max, AllowHeredoc, AllowURI, URISchemes, IgnoreCopDirectives, AllowedPatterns. +# URISchemes: http, https +Layout/LineLength: + Exclude: + - 'lib/zerobounce.rb' + - 'lib/zerobounce/priority3.rb' + - 'spec/base_request_spec.rb' + - 'spec/offline_api_spec.rb' + - 'spec/warmup_spec.rb' diff --git a/AGENTS.md b/AGENTS.md index 6ee21c0..e67424a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,55 +1,57 @@ -# AGENTS.md +# Agent notes — zero-bounce-ruby -Guidance for AI coding agents working in this repository. +## Layout -## Project +- `lib/zerobounce.rb`: every public method, as module functions on `Zerobounce`. The Priority 3 endpoints live in `lib/zerobounce/priority3.rb`. +- `lib/zerobounce/base_request.rb`: the HTTP layer. `_get` / `_post` are the originals; `_execute` is the one that takes an explicit method and either a form or a JSON body, which the bulk jobs and the Inbox Placement Tester's PUT and DELETE need. `root_request` and `ipt_request` are public and shared by both `Request` and `MockRequest`. +- `lib/zerobounce/configuration.rb`: four base URLs, see below. +- `lib/zerobounce/warmup.rb`: a separate client for the Warmup API, a different product with its own host and its own team-scoped key. It is `Zerobounce::Warmup`, not part of the `Zerobounce` module functions. +- `spec/offline_api_spec.rb`, `spec/warmup_spec.rb`: the offline suites, stubbed with WebMock. +- `live/live_api.rb`: the opt-in live suite. It sits outside `spec/`, so `rspec` and CI never load it. -`zero-bounce-ruby` publishes the `zerobounce-sdk` gem. Callers set `Zerobounce.config.apikey`, or construct `Zerobounce::Client.new(api_key)`. Module methods share one configuration object. A `Client` stores the key on the current thread for the duration of each call. +## Build and test -## Layout +The base branch is **`develop`**, not `main`/`master`. + +Ruby runs in Docker (the host gems are not installed): -- `lib/zerobounce.rb`: public methods for validate, credits, batch, files, scoring, and finder. -- `lib/zerobounce/priority3.rb`: bulk email finder, domain search, list evaluator, filters, and S3. -- `lib/zerobounce/base_request.rb` and `request.rb`: RestClient transport. `response_error.rb` turns HTML, empty bodies, and non-JSON into a short `RuntimeError`. -- `lib/zerobounce/version.rb`: `Zerobounce::VERSION`. `Gemfile.lock` pins `zerobounce-sdk (X.Y.Z)` to that same version. -- `spec/offline_api_spec.rb`: offline suite. WebMock answers without the network. -- The user agent is `ZerobounceRubyGem/#{Zerobounce::VERSION}`. +```sh +cd ../sdk-docs && docker compose run --rm --build ruby +``` -## Commands +To run against the working tree without rebuilding: -```bash -bundle exec rspec -bundle exec rubocop +```sh +docker run --rm -u 0 -v "$PWD":/src -w /src zerobounce-sdk-ruby:test \ + sh -c "bundle _2.4.22_ config set --local path /app/vendor/bundle && bundle _2.4.22_ exec rspec" ``` -`rspec` is the test command. `rubocop` is the lint step; there is no separate typechecker. CI (`.github/workflows/sdk_ci.yml`) runs both on Ruby 3.2. This checkout may be a newer Ruby where RuboCop cannot load `benchmark`; use 3.2 to lint. +The live suite is run by hand, never by rspec: -## Conventions +```sh +export ZEROBOUNCE_API_KEY=... ZEROBOUNCE_WARMUP_API_KEY=... +ruby -Ilib live/live_api.rb # free calls only +ZEROBOUNCE_LIVE_SPEND=1 ruby -Ilib live/live_api.rb +ZEROBOUNCE_LIVE_CHANGES=1 ruby -Ilib live/live_api.rb +``` -- **One key per caller.** `Zerobounce.config` is process-wide. Do not reuse it across users. Use `Zerobounce::Client` (or `with_api_key`) so two threads cannot observe each other's keys. The offline concurrency spec does this. -- **Column indexes.** `0` is a real column. Do not drop it with `if column`. Omit an optional column only when the argument is `nil`. `domain_column` of `0` is rejected. `Import`-style defaults are not used here: the send methods default name columns to 1–4, and an explicit `0` is sent. -- **Optional parameters are omitted when unset.** `timeout`, `activity_data`, `verify_plus`, and `allow_phase_2` are omitted when `nil`. Explicit `false` is sent as `false`. -- **Booleans on multipart are the strings `true` and `false`.** `has_header_row: false` on bulk email finder and domain search is sent. `allow_phase_2` is validation `sendfile` only. `activity_data` on get-file is validation only. -- **`validate` timeout is 3–60 seconds. `validate_batch` timeout is 10–120 seconds.** Out-of-range values raise `ArgumentError` before a request. -- **Failures are `RuntimeError` (or `ArgumentError` for bad arguments).** The message must be short: no raw HTML, no megabyte body, no `JSON::ParserError` dump, and no `Error: Error:` prefix. An account-level `validate_batch` error (`email_address` `all`) raises. A per-address error stays on the returned hash. -- **`validate_batch` returns the hash**, with `email_batch` and `errors`, not only the array. -- **Tests replace HTTP with WebMock.** Do not call the live API from `rspec`. -- Match the surrounding style. Do not reformat unrelated code. +Keys come from the environment only. Never write one into a file, a commit, a +log, a fixture or a message. -## Changelog +CI runs `bundle exec rspec` only. `rake default` also runs RuboCop, which reports a large number of pre-existing offences across the repo and is **not** a gate — do not treat its output as a regression from your change. -`CHANGELOG.md` follows [Keep a Changelog 1.1.0](https://keepachangelog.com/en/1.1.0/), and the project adheres to [Semantic Versioning 2.0.0](https://semver.org/spec/v2.0.0.html). +## Rules -- Add every user-visible change to `## [Unreleased]` in the same commit as the change. -- Group entries under these headings, in this order, omitting empty ones: `Added`, `Changed`, `Deprecated`, `Removed`, `Fixed`, `Security`. -- Write for SDK users: name the method, option, or behaviour, and say what changed. Leave out CI-only changes, internal refactors, and fixes to code that was never released. -- At 2.y.z, a breaking change bumps the major version, a new method or option bumps the minor version, and fixes alone bump the patch version. +- **Only endpoints documented at https://www.zerobounce.net/docs/.** Do not add a method for an endpoint the public docs do not describe, even if it exists on the server. Check before concluding something is undocumented: the S3 and SFTP bulk endpoints live under https://www.zerobounce.net/docs/api-integrations, not the main API reference. +- **Four base URLs.** `api_root_url`, `bulk_api_root_url` (ends in `/v2/`), `bulk_root_url` (the S3/SFTP jobs, **no** `/v2`), and `list_evaluator_root_url` / `inbox_placement_root_url`. The last three are separate values rather than paths built from `bulk_api_root_url`, so overriding that alone leaves a test pointing at the live API. +- **Where the live API and the docs disagree, follow the API and say so in a comment at the call site.** The ones found so far: `validatebatch` takes `timeout` as a JSON *string*; the Inbox Placement Tester authenticates with `Authorization: Bearer`, not `api_key`; its listing is `GET /tests`, not `/test`; `send-evaluated-file` takes `file_id` as a form field, not a file upload; the S3 and SFTP jobs require `return_url` and `file_name`, `validate_sftp_file` included; `/filters/*` answers a *success* with a bare `{"Message": ...}` body; the Warmup `check-domain-dmarc` takes an email address, not a domain; and a failed SMTP login in `test-smtp-configuration` comes back as an HTTP 500. +- **`export_sftp_file` sends `export_path`**, not the `export_bucket` that `export_s3_file` sends. +- **`{"success": false}` on an HTTP 200 is a failure.** The API answers an unknown bulk file id that way; both `bulk_get` and `__parse_json_response__` raise on it. +- **A body that is nothing but `Message` means different things per endpoint.** On the validate endpoints it is a rejection; on `/filters/add`, `/filters/delete` and `/filters/list` it is the success. `Request::FILTER_PATHS` is the exception list. +- **`Gemfile.lock` pins the gem's own version.** A version bump must update both `lib/zerobounce/version.rb` and `Gemfile.lock`, or `bundle install --deployment` in CI fails. +- **Offline tests only, in `spec/`.** Never call the live API from a spec. The live suite is the separate, opt-in `live/live_api.rb`. +- **Errors carry the HTTP status.** The Warmup client raises `Zerobounce::ApiResponseError`, whose `#status_code` lets callers tell a Cloudflare 403 from an API 400/404 and a Warmup 429. The validation client still raises a `RuntimeError` with the same short message. ## Releasing -Publishing and pushing tags need a maintainer's explicit approval. Do not bump the version, tag, or publish unless they asked. - -1. Move `[Unreleased]` entries under `## [x.y.z] - YYYY-MM-DD`, leave an empty `[Unreleased]` section, and update the compare links at the bottom of `CHANGELOG.md`. -2. Set the same version in `lib/zerobounce/version.rb` and in the `zerobounce-sdk (X.Y.Z)` pin in `Gemfile.lock`. A frozen CI install fails if those two differ. The gemspec reads `Zerobounce::VERSION`; do not add a third copy. -3. `bundle exec rubocop` and `bundle exec rspec`. -4. Tag `vx.y.z` on that commit and run the Publish workflow with that tag. It publishes to RubyGems. +**Run the live suite by hand before every release.** The offline suite proves the SDK sends what we think it sends; only the live suite proves the API accepts it. Run it with real keys, including `ZEROBOUNCE_LIVE_SPEND=1` and `ZEROBOUNCE_LIVE_CHANGES=1`, and do not release on a failure. diff --git a/CHANGELOG.md b/CHANGELOG.md index 6ddb3f0..85fb091 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,23 @@ +# Unreleased + +* `validate` accepts `domain_info`, `greylist_processing` and `callback_url`. An option left out is not sent; booleans go on the wire as the strings `true`/`false`. `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. +* Add the rest of the documented bulk surface: `email_finder_s3_file`, `domain_search_s3_file`, and the SFTP/FTP jobs `validate_sftp_file`, `score_sftp_file`, `email_finder_sftp_file`, `domain_search_sftp_file` and `export_sftp_file`. These are on the bulkapi host **without** `/v2`. `validate_sftp_file`'s file name is optional - passing `nil` uses the input bucket from your ZeroBounce settings - and `export_sftp_file` takes `export_path`, not the `export_bucket` `export_s3_file` sends. +* Add the Inbox Placement Tester: the API key goes on the query string, bodies are camelCase JSON, and unset fields are left out. +* `Configuration` gains `bulk_root_url`, `list_evaluator_root_url` and `inbox_placement_root_url`. These are separate base URLs rather than paths built from `bulk_api_root_url`, so overriding that alone leaves them pointing at the live API. +* `get_list_evaluator_result` uses the list evaluator's own base URL, encodes the file id as a single path segment, and rejects a blank id before a request is sent. +* An error body carrying a `detail` key (the Inbox Placement Tester is a Django REST framework app) is now raised with that text. +* Add `Zerobounce::Warmup`, a client for the Warmup API. It is a separate product with its own host (`https://warmup-api.zerobounce.net`), its own team-scoped key sent in an `API-Key` header, and all 26 documented endpoints, including the seed-list and statistics CSV downloads. +* Add `Zerobounce::ApiResponseError`, which carries the HTTP status through `#status_code`, so callers can tell a Cloudflare 403 from an API 400/404 and a Warmup 429. The Warmup client raises it. +* Add an opt-in live API suite (`ruby -Ilib live/live_api.rb`). It lives outside `spec/`, so `rspec` and CI never load it, and it only spends credits or changes the account behind `ZEROBOUNCE_LIVE_SPEND=1` and `ZEROBOUNCE_LIVE_CHANGES=1`. +* Verified against the live API; where the API and the docs disagree, the API wins: + * `validate_batch` sends `timeout` as a JSON **string**. Sent as a number the API rejects the whole batch with an account-level error. + * The Inbox Placement Tester authenticates with an `Authorization: Bearer` header, not an `api_key` parameter, so the key no longer reaches a query string that gets logged. Its listing is `GET /tests`; `/test` answers `Method "GET" not allowed`. + * `send_evaluated_file` takes the list evaluator's `file_id` as a form field, not a file upload, so its argument is now a file id. + * The S3 and SFTP jobs require `file_name` and `return_url`, `validate_sftp_file` included, although the docs list `file_name` as optional for that one. +* Fixed: `add_filter`, `delete_filter` and `list_filters` raised on success. They answer with a bare `{"Message": "..."}` body, which everywhere else means the request was rejected. +* Fixed: `{"success": false}` on an HTTP 200 raises instead of being returned as a result. The API answers an unknown bulk file id that way. +* Security: a Warmup API key with surrounding whitespace or control characters is rejected, because the transport would otherwise quote it unredacted in its own error; a Warmup base URL must be `https://`; and the Warmup SMTP test masks the username and password out of every error it raises. + # Changelog All notable changes to this project will be documented in this file. @@ -23,6 +43,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed +- `Zerobounce::ResponseError` used `loc[1..]` to build a validation-error field name. An endless range needs Ruby 2.6, but the gemspec supports 2.5, where the file is a syntax error and the gem will not load at all. Changed to `loc[1..-1]`. + - An HTML "Access denied" page is a short `RuntimeError`. An HTML page with status 200 is an error as well, including `validate_file_get`, instead of file text or a JSON parse. - Empty and non-JSON bodies are a short error. A repeated `Error:` / `[Error]:` prefix is removed. - `validate_batch` raises for an account-level failure (HTTP 200, error for address `all`, such as an invalid API key or no credits). diff --git a/Gemfile.lock b/Gemfile.lock index 52097f9..0e625fb 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -104,6 +104,7 @@ GEM yard (0.9.45) PLATFORMS + aarch64-linux-musl arm64-darwin-24 arm64-darwin-25 arm64-darwin-27 diff --git a/README.md b/README.md index 3d0f939..4dd168d 100644 --- a/README.md +++ b/README.md @@ -719,6 +719,48 @@ Zerobounce.find_domain(company_name: "Zero Bounce") } ``` +## Warmup API + +The Warmup API is a separate product. It has its own host, its own team-scoped key, and its own client; your validation key will not work with it. + +```ruby +warmup = Zerobounce::Warmup.new('') + +warmup.list_sending_accounts +warmup.progress_today +warmup.download_seeds # CSV text, columns seed,provider +``` + +Add a from-address you run yourself, with one of the five standard engagement rules (0 Default, 1 Slow Pace, 2 Repair, 3 Boost, 4 Protect): + +```ruby +warmup.add_sending_account('sender@example.com', 'self_managed', engagement_rule_id: 0) +``` + +Or have ZeroBounce provision the mailbox. A 200 means the request was accepted; provisioning happens afterwards. + +```ruby +warmup.add_sending_account( + 'sender@example.com', 'zb_managed', + email_content: { types_of_emails: %w[newsletter marketing] }, + managed_sending: { timezone_offset: -5, warmup_start_date: Date.new(2026, 3, 4) } +) +``` + +A custom engagement rule can be changed only once every seven days; inside that window the API answers 429. + +```ruby +warmup.create_custom_engagement('sender@example.com', 20, 20, 35) # reply, important, click % +``` + +`check_domain_dmarc` takes an **email address**, not a domain — the live API rejects a bare domain even though the docs describe one: + +```ruby +warmup.check_domain_dmarc('sender@example.com') +``` + +Every documented empty success (204, or an empty 200) comes back as `nil`, and a payload that is not a JSON object — `/seeds` is an array, `/seed-count` a bare number — comes back as the parsed value itself. Failures raise `Zerobounce::ApiResponseError`, whose `#status_code` gives the HTTP status. Nothing is stored by `test_smtp_configuration`, and the credentials you pass it are masked out of any error it raises. + ## Development ### Local setup @@ -778,6 +820,27 @@ A .env.example file is provided. Tests use webmock and vcr for mocking HTTP requests. This means that actual requests were made and recorded in the spec/cassettes with an (at the time) valid API key used for testing purposes. This key has been invalidated in the meantime, however it is provided in the .env.example file for the mock tests to work. If you do not wish to use this key for mocks, you can replace it with any value in the .yml files under spec/cassettes or delete all of them and rerun the tests so that vcr records them with a new key. +### Live API suite + +`rspec` never touches the network: the live suite lives outside `spec/`, so CI does not even load it. It needs real keys, which come from the environment and are never written anywhere: + +```bash +export ZEROBOUNCE_API_KEY=... # validation API +export ZEROBOUNCE_WARMUP_API_KEY=... # Warmup API +ruby -Ilib live/live_api.rb +``` + +By default it only makes free calls: sandbox addresses, reads, and requests that are expected to fail but still prove the API accepts the payload. It checks that the credit balance did not move. Two flags opt in to more: + +| Flag | What it adds | +| --- | --- | +| `ZEROBOUNCE_LIVE_SPEND=1` | billable calls (scoring, activity, email finder) and a bulk round trip that deletes its own job | +| `ZEROBOUNCE_LIVE_CHANGES=1` | reversible account changes — a test filter and a self-managed Warmup sending account — each undone in cleanup even when a check fails | + +It never creates an Inbox Placement test, sets the Inbox Placement webhook, or starts ZB-managed Warmup onboarding, because the API cannot undo any of those. + +Run it by hand before every release: the offline suite proves the SDK sends what we think it sends, and only the live suite proves the API accepts it. + ## Publish 1. Bump `VERSION` in `lib/zerobounce/version.rb`, commit, tag (`vX.Y.Z`), push tag. diff --git a/lib/zerobounce.rb b/lib/zerobounce.rb index 681657d..22dde6e 100644 --- a/lib/zerobounce.rb +++ b/lib/zerobounce.rb @@ -18,6 +18,7 @@ require 'zerobounce/configuration' require 'zerobounce/api_urls' require 'zerobounce/priority3' +require 'zerobounce/warmup' # Validate an email address with Zerobounce.net module Zerobounce @@ -93,13 +94,23 @@ def with_api_key(api_key) # "zipcode": null, # "processed_at": "2023-04-18 12:09:39.074" # } - def validate(email, ip_address=nil, timeout: nil, activity_data: nil, verify_plus: nil, use_post: false) + def validate(email, ip_address=nil, timeout: nil, activity_data: nil, verify_plus: nil, + domain_info: nil, greylist_processing: nil, callback_url: nil, use_post: false) unless timeout.nil? timeout_i = Integer(timeout) if timeout_i < 3 || timeout_i > 60 raise ArgumentError, 'Invalid parameter: timeout (must be between 3 and 60 seconds)' end end + unless callback_url.nil? + raise ArgumentError, 'Empty parameter: callback_url' if callback_url.to_s.strip.empty? + + # The callback is where the greylist retry posts its result, so it is + # meaningless - and rejected by the API - without the retry itself. + unless greylist_processing + raise ArgumentError, 'callback_url requires greylist_processing to be true' + end + end params = {email: email, ip_address: ip_address} params[:timeout] = timeout unless timeout.nil? unless activity_data.nil? @@ -108,6 +119,13 @@ def validate(email, ip_address=nil, timeout: nil, activity_data: nil, verify_plu unless verify_plus.nil? params[:verify_plus] = verify_plus ? 'true' : 'false' end + unless domain_info.nil? + params[:domain_info] = domain_info ? 'true' : 'false' + end + unless greylist_processing.nil? + params[:greylist_processing] = greylist_processing ? 'true' : 'false' + end + params[:callback_url] = callback_url unless callback_url.nil? if use_post @@request.post('validate', params, 'application/x-www-form-urlencoded') else @@ -283,7 +301,10 @@ def validate_batch(emails, ip_addresses=[], timeout: nil, activity_data: nil, ve }) end params = {email_batch: email_batch} - params[:timeout] = timeout unless timeout.nil? + # LIVE: validatebatch takes timeout as a JSON *string*. Sent as a number + # the API rejects the whole batch with the account-level "Invalid API Key + # or your account ran out of credits". + params[:timeout] = timeout.to_s unless timeout.nil? params[:activity_data] = activity_data unless activity_data.nil? params[:verify_plus] = verify_plus unless verify_plus.nil? results = @@request.post('validatebatch', params) diff --git a/lib/zerobounce/api_urls.rb b/lib/zerobounce/api_urls.rb index ca364c9..783ed64 100644 --- a/lib/zerobounce/api_urls.rb +++ b/lib/zerobounce/api_urls.rb @@ -14,5 +14,14 @@ class ApiUrls # Bulk API URLs BULK_DEFAULT_URL = 'https://bulkapi.zerobounce.net/v2/' + + # The S3 and SFTP/FTP bulk jobs live on the bulkapi host WITHOUT the /v2 + # path. See https://www.zerobounce.net/docs/api-integrations. + BULK_ROOT_URL = 'https://bulkapi.zerobounce.net/' + + # The list evaluator and the Inbox Placement Tester have their own base + # URLs, not paths built from the bulk one. + LIST_EVALUATOR_URL = 'https://bulkapi.zerobounce.net/v2/listevaluator/' + INBOX_PLACEMENT_URL = 'https://bulkapi.zerobounce.net/api/ipt' end end diff --git a/lib/zerobounce/base_request.rb b/lib/zerobounce/base_request.rb index 3e0a5ec..df4a1ce 100644 --- a/lib/zerobounce/base_request.rb +++ b/lib/zerobounce/base_request.rb @@ -123,11 +123,73 @@ def self._post(root, path, params, content_type='application/json', filepath=nil return response end + # Sends a request with an explicit method and an optional form or JSON body. + # + # The endpoints ported from the Python SDK need shapes _get and _post cannot + # express: a form-encoded POST on an arbitrary base URL, and the Inbox + # Placement Tester's PUT and DELETE with the API key on the query string. + # +bearer+ sends the key in an Authorization header instead of a query + # parameter, which the Inbox Placement Tester requires and which keeps the + # key out of request logs. + def self.__execute_with_method__(method, url, params, form: nil, json: nil, bearer: false) + api_key = current_api_key + raise ('API key must be assigned') if api_key.nil? || api_key.to_s.strip.empty? + + params = (params || {}).dup + extra = {} + if bearer + extra[:authorization] = "Bearer #{api_key}" + else + params = params.merge(api_key: api_key) + end + Zerobounce::BaseRequest.__require_https__(url) + + options = { method: method, url: url, timeout: 120 } + if form + options[:payload] = form + options[:headers] = extra.merge(params: params, content_type: 'application/x-www-form-urlencoded') + elsif json + options[:payload] = json.to_json + options[:headers] = extra.merge(params: params, content_type: :json, accept: :json) + else + options[:headers] = extra.merge(params: params) + end + + __execute_request__(**options) + end + + # A GET or form-encoded POST against an arbitrary base URL, for the list + # evaluator, which has its own host. + def self.root_request(method, root, path, params, form: nil) + url = "#{__root_without_trailing_slashes__(root)}/#{path.to_s.sub(%r{\A/}, '')}" + __parse_json_response__(__execute_with_method__(method, url, params, form: form)) + end + + # One Inbox Placement Tester call. + def self.ipt_request(method, path, params = {}, json: nil) + root = Zerobounce.configuration.inbox_placement_root_url + url = "#{__root_without_trailing_slashes__(root)}/#{path.to_s.sub(%r{\A/}, '')}" + # LIVE: the Inbox Placement Tester authenticates with a Bearer header. + # Sent as an api_key parameter the request is rejected as unauthorized. + response = __execute_with_method__(method, url, params, json: json, bearer: true) + return nil if response.body.to_s.strip.empty? + + __parse_json_response__(response) + end + def self.bulk_api_root_without_v2 root = Zerobounce.configuration.bulk_api_root_url.to_s.sub(%r{/v2/?$}, '') __root_without_trailing_slashes__(root) end + # Whether a "success" field means failure. The API sends it as a JSON + # false, "false" or "False" depending on the endpoint. + def self.__success_means_failure__(value) + return !value if value == true || value == false + + value.is_a?(String) && value.strip.casecmp('false').zero? + end + def self.__parse_json_response__(response) body = response.body.to_s content_type = (response.headers[:content_type] || '').to_s @@ -141,7 +203,16 @@ def self.__parse_json_response__(response) end return parsed unless parsed.is_a?(Hash) + # LIVE: {"success": false} on an HTTP 200 is a failure, not a result. The + # API answers an unknown bulk file id that way. + if parsed.key?('success') && __success_means_failure__(parsed['success']) + raise ResponseError.message_from_parts(response.code, content_type, body) + end + raise ResponseError.strip_prefix(parsed['error']) if parsed.key?('error') && !parsed['error'].nil? + # The Inbox Placement Tester is a Django REST framework app and reports + # its errors under "detail" rather than "error"/"message". + raise ResponseError.strip_prefix(parsed['detail']) if parsed.key?('detail') && !parsed['detail'].nil? if parsed.key?('errors') && parsed['errors'].is_a?(Array) && !parsed['errors'].empty? raise ResponseError.strip_prefix(parsed['errors'][0]['error']) end diff --git a/lib/zerobounce/configuration.rb b/lib/zerobounce/configuration.rb index b611560..c19d1b7 100644 --- a/lib/zerobounce/configuration.rb +++ b/lib/zerobounce/configuration.rb @@ -26,12 +26,18 @@ module Zerobounce # @attr [Array] valid_statues # The statuses that are considered valid by {Response#valid?}. class Configuration - attr_accessor :headers, :apikey, :api_root_url, :bulk_api_root_url, :valid_statuses, :mock + attr_accessor :headers, :apikey, :api_root_url, :bulk_api_root_url, :bulk_root_url, + :list_evaluator_root_url, :inbox_placement_root_url, :valid_statuses, :mock def initialize(mock = false) self.apikey = ENV['ZEROBOUNCE_API_KEY'] self.api_root_url = ENV['ZEROBOUNCE_API_URL'] || ApiUrls::DEFAULT_URL self.bulk_api_root_url = ENV['ZEROBOUNCE_BULK_API_URL'] || ApiUrls::BULK_DEFAULT_URL + # These three are separate base URLs rather than paths built from + # bulk_api_root_url, so overriding that alone leaves them on the live API. + self.bulk_root_url = ENV['ZEROBOUNCE_BULK_ROOT_URL'] || ApiUrls::BULK_ROOT_URL + self.list_evaluator_root_url = ENV['ZEROBOUNCE_LIST_EVALUATOR_URL'] || ApiUrls::LIST_EVALUATOR_URL + self.inbox_placement_root_url = ENV['ZEROBOUNCE_INBOX_PLACEMENT_URL'] || ApiUrls::INBOX_PLACEMENT_URL self.valid_statuses = %i[valid catch_all accept_all] self.headers = { user_agent: "ZerobounceRubyGem/#{Zerobounce::VERSION}" } self.mock = mock diff --git a/lib/zerobounce/error.rb b/lib/zerobounce/error.rb index 8139ec6..31bd28a 100644 --- a/lib/zerobounce/error.rb +++ b/lib/zerobounce/error.rb @@ -54,6 +54,22 @@ def parse200(env) end end + # A failed API response. + # + # Carries the HTTP status so callers can tell a 403 (Cloudflare) from a + # 400/404 (API) or a 429 (the Warmup rate limit). The Warmup client raises + # it; the validation client still raises a RuntimeError with the same short + # message, which this is compatible with through #message. + class ApiResponseError < StandardError + # @return [Integer] the HTTP status of the failed response, or 0 + attr_reader :status_code + + def initialize(message, status_code = 0) + super(message) + @status_code = status_code.to_i + end + end + # Server returned a 500 error. # # @author Aaron Frase diff --git a/lib/zerobounce/priority3.rb b/lib/zerobounce/priority3.rb index 3210625..b9f39f0 100644 --- a/lib/zerobounce/priority3.rb +++ b/lib/zerobounce/priority3.rb @@ -1,5 +1,7 @@ # frozen_string_literal: true +require 'erb' + module Zerobounce module Priority3 EMAIL_FINDER_BULK_BASE = 'email-finder' @@ -62,11 +64,30 @@ def send_list_evaluator_file(filepath, email_address_column:) end def get_list_evaluator_result(file_id) - zb_request.bulk_get("listevaluator/#{file_id}/", {}) + raise ArgumentError, 'file_id is required' if file_id.nil? || file_id.to_s.strip.empty? + + # The list evaluator has its own base URL, not a path built from the bulk + # one, and the file id is one path segment, so a slash must stay encoded. + zb_request.root_request( + :get, + Zerobounce.configuration.list_evaluator_root_url, + "#{ERB::Util.url_encode(file_id.to_s.strip)}/", + {} + ) end - def send_evaluated_file(filepath) - zb_request.bulk_post('send-evaluated-file/', {}, 'multipart/form-data', filepath) + # Submits a finished evaluation for validation. POST /v2/send-evaluated-file/ + # + # LIVE: this takes the list evaluator's file_id as a form field, not the + # file upload the docs describe - an upload is rejected with + # "file_id: This field is required". It returns a new validation job's + # file_id, which answers "File cannot be found" for a short while after it + # is accepted. + def send_evaluated_file(file_id) + raise ArgumentError, 'file_id is required' if file_id.nil? || file_id.to_s.strip.empty? + + zb_request.bulk_post('send-evaluated-file/', { file_id: file_id.to_s.strip }, + 'application/x-www-form-urlencoded') end def add_filter(rule:, target:, value:) @@ -120,5 +141,146 @@ def export_s3_file(file_id:, export_bucket:) zb_request.bulk_root_form_post('exportS3file', { file_id: file_id, export_bucket: export_bucket }) end + + # The rest of the documented bulk surface. + # + # https://www.zerobounce.net/docs/api-integrations/amazon-s3-bulk-api + # https://www.zerobounce.net/docs/api-integrations/sftp_bulk_api + # + # The SFTP server, path and protocol are never sent: they come from your + # ZeroBounce settings. + + def email_finder_s3_file(file_name, has_header_row, **options) + bulk_root_file_job('emailFinderS3file', file_name, has_header_row, **options) + end + + def domain_search_s3_file(file_name, has_header_row, **options) + bulk_root_file_job('domainSearchS3file', file_name, has_header_row, **options) + end + + # LIVE: file_name is required here too, although the docs list it as + # optional for this one endpoint. + def validate_sftp_file(file_name, has_header_row, **options) + bulk_root_file_job('validateSFTPfile', file_name, has_header_row, **options) + end + + def score_sftp_file(file_name, has_header_row, **options) + bulk_root_file_job('scoreSFTPfile', file_name, has_header_row, **options) + end + + def email_finder_sftp_file(file_name, has_header_row, **options) + bulk_root_file_job('emailFinderSFTPfile', file_name, has_header_row, **options) + end + + def domain_search_sftp_file(file_name, has_header_row, **options) + bulk_root_file_job('domainSearchSFTPfile', file_name, has_header_row, **options) + end + + # Note the field name: this endpoint takes export_path, not the + # export_bucket that export_s3_file sends. Omit it to use your settings. + def export_sftp_file(file_id:, export_path: nil) + raise ArgumentError, 'file_id is required' if file_id.nil? || file_id.to_s.strip.empty? + + if !export_path.nil? && export_path.to_s.strip.empty? + raise ArgumentError, 'export_path is required' + end + + params = { file_id: file_id } + params[:export_path] = export_path unless export_path.nil? + zb_request.bulk_root_form_post('exportSFTPfile', params) + end + + # Inbox Placement Tester (/api/ipt). + # + # Its own host, an Authorization: Bearer header, camelCase JSON bodies, and + # errors under a "detail" key. Spends the account's inbox-test credits. + + def inbox_placement_providers + zb_request.ipt_request(:get, 'providers') + end + + def inbox_placement_test_types + zb_request.ipt_request(:get, 'test-types') + end + + def create_inbox_placement_test(name, **options) + raise ArgumentError, 'name is required' if name.nil? || name.to_s.strip.empty? + + body = { 'name' => name } + { + test_type: 'testType', + sender_email: 'senderEmail', + provider_ids: 'providerIds', + seed_ids: 'seedIds', + providers: 'providers', + webhook_url: 'webhookUrl' + }.each { |key, wire| body[wire] = options[key] if options.key?(key) } + + zb_request.ipt_request(:post, 'test', {}, json: body) + end + + def inbox_placement_test(test_id) + zb_request.ipt_request(:get, "test/#{inbox_placement_test_segment(test_id)}") + end + + def list_inbox_placement_tests(**options) + params = {} + params[:status] = options[:status] if options.key?(:status) + params[:page] = options[:page] if options.key?(:page) + params[:pageSize] = options[:page_size] if options.key?(:page_size) + # LIVE: the listing is GET /tests (plural). /test is the single-test path + # and answers 'Method "GET" not allowed'. + zb_request.ipt_request(:get, 'tests', params) + end + + def delete_inbox_placement_test(test_id) + zb_request.ipt_request(:delete, "test/#{inbox_placement_test_segment(test_id)}") + end + + def inbox_placement_webhook + zb_request.ipt_request(:get, 'webhook') + end + + def set_inbox_placement_webhook(webhook_url) + raise ArgumentError, 'webhook_url is required' if webhook_url.nil? || webhook_url.to_s.strip.empty? + + zb_request.ipt_request(:put, 'webhook', {}, json: { 'webhookUrl' => webhook_url }) + end + + private + + # POSTs one of the six documented S3 / SFTP file jobs on the bulkapi root. + # + # Booleans go on the wire as the strings 'true'/'false'. + # + # LIVE: file_name and return_url are required on every one of these jobs, + # validateSFTPfile included, although the docs list file_name as optional + # for that one. Without either the API answers "This field is required". + def bulk_root_file_job(path, file_name, has_header_row, **options) + if file_name.nil? || file_name.to_s.strip.empty? + raise ArgumentError, 'file_name is required' + end + return_url = options[:return_url] + if return_url.nil? || return_url.to_s.strip.empty? + raise ArgumentError, 'return_url is required' + end + + params = { + has_header_row: has_header_row ? 'true' : 'false', + file_name: file_name, + return_url: return_url + } + if options.key?(:remove_duplicates) + params[:remove_duplicates] = options[:remove_duplicates] ? 'true' : 'false' + end + + zb_request.bulk_root_form_post(path, params) + end + + def inbox_placement_test_segment(test_id) + raise ArgumentError, 'test_id is required' if test_id.nil? || test_id.to_s.strip.empty? + + ERB::Util.url_encode(test_id.to_s.strip) + end end end diff --git a/lib/zerobounce/request.rb b/lib/zerobounce/request.rb index 07e3608..785e101 100644 --- a/lib/zerobounce/request.rb +++ b/lib/zerobounce/request.rb @@ -9,6 +9,10 @@ module Zerobounce # Sends the HTTP request. class Request < BaseRequest + # LIVE: these answer a success with a bare {"Message": "..."} body, which + # everywhere else means the request was rejected. + FILTER_PATHS = %w[filters/add filters/delete filters/list].freeze + def self.get(path, params, content_type='application/json') response = self._get(Zerobounce.configuration.api_root_url, path, params, content_type) interpret_json_response(response, path) @@ -25,6 +29,14 @@ def self.bulk_get(path, params, content_type='application/json') raise (response_body_json['errors'][0]['error']) \ if response_body_json.key?('errors') and \ response_body_json['errors'].length > 0 + # LIVE: {"success": false} on an HTTP 200 is a failure, not a result. + # The API answers an unknown bulk file id that way. + if response_body_json.key?('success') && + __success_means_failure__(response_body_json['success']) + raise ResponseError.message_from_parts( + response.code, response.headers[:content_type].to_s, response_body + ) + end return response_body_json else @@ -99,7 +111,10 @@ def self.interpret_json_response(response, path = nil) return parsed unless parsed.is_a?(Hash) raise ResponseError.strip_prefix(parsed['error']) if parsed.key?('error') && !parsed['error'].nil? - if parsed.key?('Message') && parsed.length == 1 + # A body that is nothing but "Message" is how the validate endpoints + # report a rejected request. The filters endpoints use the same shape for + # a *success* ("Filter successfully added"), so they are left out. + if parsed.key?('Message') && parsed.length == 1 && !FILTER_PATHS.include?(path) raise ResponseError.strip_prefix(parsed['Message']) end if parsed.key?('errors') && parsed['errors'].is_a?(Array) && !parsed['errors'].empty? diff --git a/lib/zerobounce/response_error.rb b/lib/zerobounce/response_error.rb index b8dec14..4b0ae86 100644 --- a/lib/zerobounce/response_error.rb +++ b/lib/zerobounce/response_error.rb @@ -59,12 +59,27 @@ def message_from_parts(status, content_type, body) if access && parsed['cloudflare_error'] == true return title end - %w[error message error_message Message].each do |key| + %w[error message error_message Message detail].each do |key| value = parsed[key] return strip_prefix(value) if value.is_a?(String) && !value.strip.empty? - return strip_prefix(value[0]) if value.is_a?(Array) && value[0].is_a?(String) && !value[0].strip.empty? + next unless value.is_a?(Array) && !value.empty? + return strip_prefix(value[0]) if value[0].is_a?(String) && !value[0].strip.empty? + + # A validation error list: [{"loc" => [...], "msg" => "..."}]. + if value[0].is_a?(Hash) && value[0]['msg'] + loc = value[0]['loc'] + # loc[1..] is an endless range, which needs Ruby 2.6; the gemspec + # supports 2.5, where it is a syntax error that stops the gem loading. + field = loc.is_a?(Array) && loc.length > 1 ? loc[1..-1].join('.') : '' + return field.empty? ? value[0]['msg'].to_s : "#{field}: #{value[0]['msg']}" + end end end + # A top-level list of strings, e.g. ["In-progress tests cannot be deleted."]. + if parsed.is_a?(Array) + first = parsed.find { |item| item.is_a?(String) && !item.strip.empty? } + return strip_prefix(first) if first + end return 'ZeroBounce returned a response that is not valid JSON.' if trimmed.length > 1000 return strip_prefix(trimmed) diff --git a/lib/zerobounce/validate_sub_status.rb b/lib/zerobounce/validate_sub_status.rb index 5d7cbb3..8f75fd8 100644 --- a/lib/zerobounce/validate_sub_status.rb +++ b/lib/zerobounce/validate_sub_status.rb @@ -36,5 +36,6 @@ module ValidateSubStatus ACCEPT_ALL = 'accept_all' ROLE_BASED_ACCEPT_ALL = 'role_based_accept_all' GOLD = 'gold' + AI_AGENT_MAILBOX = 'ai_agent_mailbox' end end diff --git a/lib/zerobounce/warmup.rb b/lib/zerobounce/warmup.rb new file mode 100644 index 0000000..ac07d62 --- /dev/null +++ b/lib/zerobounce/warmup.rb @@ -0,0 +1,467 @@ +# frozen_string_literal: true + +require 'date' +require 'erb' +require 'json' +require 'rest-client' + +require 'zerobounce/error' +require 'zerobounce/response_error' + +module Zerobounce + # Client for the ZeroBounce Warmup API + # (https://www.zerobounce.net/docs/warmup-api). + # + # The Warmup API is separate from the email validation API: its own host, its + # own team-scoped key, and an "API-Key" header instead of an api_key + # parameter. Use Zerobounce::Warmup with a Warmup API key; the Zerobounce + # module stays for the validation API key. + # + # warmup = Zerobounce::Warmup.new('') + # warmup.list_sending_accounts + # + # A documented empty success (204, or an empty 200) comes back as nil. A + # payload that is not a JSON object - /seeds is an array, /seed-count a bare + # number - comes back as the parsed value itself. + class Warmup + DEFAULT_BASE_URL = 'https://warmup-api.zerobounce.net' + BASE_PATH = '/api/v1/warmup' + + ONBOARDING_MODELS = %w[self_managed zb_managed].freeze + EMAIL_TYPES = %w[transactional newsletter marketing].freeze + + # Required by the schema but always replaced with the key's own team. + ANY_TEAM_ID = 1 + + # @param api_key [String] your Warmup API key, sent in the API-Key header + # @param base_url [String] defaults to https://warmup-api.zerobounce.net + def initialize(api_key, base_url = DEFAULT_BASE_URL) + raise ArgumentError, 'api_key is required' if api_key.nil? || api_key.to_s.strip.empty? + + # A key with surrounding whitespace or control characters would be + # quoted, unredacted, in the HTTP library's own error. + if api_key != api_key.strip || api_key.match?(/[\u0000-\u001f\u007f]/) + raise ArgumentError, 'api_key has surrounding whitespace or control characters' + end + unless base_url.to_s.downcase.start_with?('https://') + raise ArgumentError, 'base_url must be an https:// URL' + end + + @api_key = api_key + root = base_url.to_s + root = root.chomp('/') while root.end_with?('/') + @root = "#{root}#{BASE_PATH}" + end + + # -- sending accounts --------------------------------------------------- + + # Every sending account of the key's team. GET /sending-accounts + def list_sending_accounts + get('/sending-accounts') + end + + # One sending account. GET /sending-account + def get_sending_account(from_address) + require_value(from_address, 'from_address') + get('/sending-account', from_address: from_address) + end + + # Adds a from-address to warmup. POST /sending-accounts + # + # +self_managed+ runs your own mailbox and needs +engagement_rule_id+ 0-4. + # +zb_managed+ has ZeroBounce provision it and takes +email_content+ and + # +managed_sending+; a 200 means accepted, and the mailbox is provisioned + # afterwards. + def add_sending_account(from_address, onboarding_model, **options) + require_value(from_address, 'from_address') + unless ONBOARDING_MODELS.include?(onboarding_model.to_s) + raise ArgumentError, 'onboarding_model must be self_managed or zb_managed' + end + + body = { 'from_address' => from_address, 'onboarding_model' => onboarding_model.to_s } + if onboarding_model.to_s == 'self_managed' + rule = options[:engagement_rule_id] + raise ArgumentError, 'engagement_rule_id is required for self_managed' if rule.nil? + + body['engagement_rule_id'] = in_range(rule, 0, 4, 'engagement_rule_id') + if options.key?(:email_content) || options.key?(:managed_sending) + raise ArgumentError, 'email_content and managed_sending are for zb_managed' + end + end + body['ignore_conflict'] = !options[:ignore_conflict].nil? && options[:ignore_conflict] ? true : false if + options.key?(:ignore_conflict) + body['email_content'] = email_content_body(options[:email_content]) if options.key?(:email_content) + body['managed_sending'] = managed_sending_body(options[:managed_sending]) if options.key?(:managed_sending) + + json_request(:post, '/sending-accounts', {}, body) + end + + # Changes an account's engagement rule, status and auto-enable flag. + # PUT /sending-accounts. +status_id+ is 0 to 8 (4 Paused, 5 Disabled). + def update_sending_account(from_address, engagement_rule_id, status_id, auto_enable) + require_value(from_address, 'from_address') + unless [true, false].include?(auto_enable) + raise ArgumentError, 'auto_enable must be true or false' + end + + json_request(:put, '/sending-accounts', {}, + { 'team_id' => ANY_TEAM_ID, + 'from_address' => from_address, + 'engagement_rule_id' => in_range(engagement_rule_id, 0, 4, 'engagement_rule_id'), + 'status_id' => in_range(status_id, 0, 8, 'status_id'), + 'auto_enable' => auto_enable }) + end + + # Removes a sending account. DELETE /sending-accounts. Destructive and final. + def delete_sending_account(from_address) + require_value(from_address, 'from_address') + json_request(:delete, '/sending-accounts', { from_address: from_address }) + end + + # Moves an account and its history to a new from-address. + # PUT /smtp-credentials - despite the path, no SMTP credentials are stored. + def change_from_address(old_from_address, new_from_address) + require_value(old_from_address, 'old_from_address') + require_value(new_from_address, 'new_from_address') + json_request(:put, '/smtp-credentials', {}, + { 'team_id' => ANY_TEAM_ID, + 'old_from_address' => old_from_address, + 'new_from_address' => new_from_address }) + end + + # Registered from-addresses. GET /from-addresses + # + # +search_from+ takes letters, digits and . - _ @ + only. +offset+ and + # +limit+ go on the wire as strings. + def list_from_addresses(search_from: nil, offset: nil, limit: nil) + params = {} + unless search_from.nil? + unless search_from.to_s.match?(/\A[A-Za-z0-9.\-_@+]*\z/) + raise ArgumentError, 'search_from takes letters, digits and . - _ @ + only' + end + + params[:search_from] = search_from + end + { offset: offset, limit: limit }.each do |name, value| + next if value.nil? + raise ArgumentError, "#{name} must be a non-negative integer" unless value.is_a?(Integer) && value >= 0 + + params[name] = value.to_s + end + get('/from-addresses', **params) + end + + # -- engagement rules --------------------------------------------------- + + # -1 Custom, 0 Default, 1 Slow Pace, 2 Repair, 3 Boost, 4 Protect. + def engagement_rules + get('/engagement-rules') + end + + # Sets a standard rule (0 to 4) on an address. PUT /engagement-rule + def set_engagement_rule(c_from, rule_id) + require_value(c_from, 'c_from') + json_request(:put, '/engagement-rule', { c_from: c_from }, + { 'rule_id' => in_range(rule_id, 0, 4, 'rule_id') }) + end + + # An address's custom rule, with can_update and remaining_seconds. + def custom_engagement(c_from) + require_value(c_from, 'c_from') + segment = c_from.to_s.strip + raise ArgumentError, 'c_from is invalid' if ['.', '..'].include?(segment) + + get("/custom-engagement/#{ERB::Util.url_encode(segment)}") + end + + # Creates a custom rule. Starts a 7 day cooldown on updates. + def create_custom_engagement(c_from, reply_pct, important_pct, click_pct) + json_request(:post, '/custom-engagement', {}, + engagement_body(c_from, reply_pct, important_pct, click_pct)) + end + + # Updates a custom rule. During the 7 day cooldown the API answers 429. + def update_custom_engagement(c_from, reply_pct, important_pct, click_pct) + json_request(:put, '/custom-engagement', {}, + engagement_body(c_from, reply_pct, important_pct, click_pct)) + end + + # Deletes a custom rule and falls back to standard rule 1-4. + def delete_custom_engagement(c_from, engagement_rule_id) + require_value(c_from, 'c_from') + json_request(:delete, '/custom-engagement', {}, + { 'c_from' => c_from, + 'engagement_rule_id' => in_range(engagement_rule_id, 1, 4, 'engagement_rule_id') }) + end + + # -- statistics, seeds and utilities ------------------------------------ + + # The seed list. GET /seeds + def seeds + get('/seeds') + end + + # The seed list as CSV (columns seed,provider). GET /seeds/download + def download_seeds + csv('/seeds/download') + end + + # Number of seeds. GET /seed-count + def seed_count + get('/seed-count') + end + + # Today's warmup progress. GET /progress-today + def progress_today + get('/progress-today') + end + + # Messages sent today. GET /sent-today + def sent_today + get('/sent-today') + end + + # Current daily volume. GET /emails-per-day + def emails_per_day + get('/emails-per-day') + end + + # Statistics for an address. An address with no history returns zeros. + def statistics(from_address) + require_value(from_address, 'from_address') + get('/statistics', from_address: from_address) + end + + # Daily statistics between two dates, inclusive. GET /statistics-per-day + def statistics_per_day(from_address, start_date, end_date) + require_value(from_address, 'from_address') + first = api_date(start_date, 'start_date') + last = api_date(end_date, 'end_date') + raise ArgumentError, 'start_date is later than end_date' if first > last + + get('/statistics-per-day', from_address: from_address, start_date: first, end_date: last) + end + + # Detailed statistics for an address. GET /statistics-detailed + def statistics_detailed(c_from) + require_value(c_from, 'c_from') + get('/statistics-detailed', c_from: c_from) + end + + # An address's statistics as CSV. GET /statistics/download + def download_statistics(c_from) + require_value(c_from, 'c_from') + csv('/statistics/download', c_from: c_from) + end + + # Statistics for one from-address. GET /from-address-statistics + def from_address_statistics(from_address) + require_value(from_address, 'from_address') + get('/from-address-statistics', from_address: from_address) + end + + # SPF, DKIM and DMARC status of a from-address's domain. + # GET /check-domain-dmarc + # + # LIVE: pass an EMAIL ADDRESS. The live API answers "Please provide a valid + # email address" for a bare domain, although the docs describe a domain. + def check_domain_dmarc(from_address, dkim_selector: nil) + require_value(from_address, 'from_address') + unless from_address.to_s.include?('@') + raise ArgumentError, 'from_address must be an email address, e.g. sender@example.com' + end + + params = { from_address: from_address } + unless dkim_selector.nil? + require_value(dkim_selector, 'dkim_selector') + params[:dkim_selector] = dkim_selector + end + get('/check-domain-dmarc', **params) + end + + # Checks that SMTP credentials connect and hand off a message. + # POST /test-smtp-configuration + # + # Nothing is stored, and the test message goes to a ZeroBounce test + # recipient, not +from_address+. + # + # LIVE: the API answers any failed SMTP login with HTTP 500. That is an API + # bug. The username and password are masked in every error from this method. + def test_smtp_configuration(from_address, username, password, server, port) + require_value(from_address, 'from_address') + require_value(username, 'username') + require_value(password, 'password') + require_value(server, 'server') + raise ArgumentError, 'port must be 1 to 65535' unless port.is_a?(Integer) && port.between?(1, 65_535) + + json_request(:post, '/test-smtp-configuration', {}, + { 'from_address' => from_address, + 'username' => username, + 'password' => password, + 'server' => server, + 'port' => port }, + secrets: [password, username]) + end + + private + + attr_reader :api_key, :root + + # -- argument checks ---------------------------------------------------- + + def require_value(value, name) + raise ArgumentError, "#{name} is required" if value.nil? || value.to_s.strip.empty? + + value + end + + def in_range(value, low, high, name) + unless value.is_a?(Integer) && value.between?(low, high) + raise ArgumentError, "#{name} must be #{low} to #{high}" + end + + value + end + + # A date for the API: a Date/Time or a YYYY-MM-DD string. + def api_date(value, name) + return value.strftime('%Y-%m-%d') if value.respond_to?(:strftime) + return value if value.is_a?(String) && value.match?(/\A\d{4}-\d{2}-\d{2}\z/) + + raise ArgumentError, "#{name} must be YYYY-MM-DD" + end + + def engagement_body(c_from, reply_pct, important_pct, click_pct) + require_value(c_from, 'c_from') + in_range(reply_pct, 5, 45, 'reply_pct') + in_range(important_pct, 5, 40, 'important_pct') + in_range(click_pct, 0, 70, 'click_pct') + if click_pct > important_pct * 1.75 + raise ArgumentError, 'click_pct must be at most important_pct x 1.75' + end + + { 'c_from' => c_from, 'reply_pct' => reply_pct, + 'important_pct' => important_pct, 'click_pct' => click_pct } + end + + def email_content_body(content) + raise ArgumentError, 'email_content must be a Hash' unless content.is_a?(Hash) + + body = {} + types = content[:types_of_emails] || content['types_of_emails'] + unless types.nil? + Array(types).each do |kind| + unless EMAIL_TYPES.include?(kind.to_s) + raise ArgumentError, 'types_of_emails must be transactional, newsletter and/or marketing' + end + end + body['types_of_emails'] = Array(types).map(&:to_s) + end + %i[industry company_name website].each do |key| + value = content[key] || content[key.to_s] + body[key.to_s] = value unless value.nil? + end + body + end + + def managed_sending_body(sending) + raise ArgumentError, 'managed_sending must be a Hash' unless sending.is_a?(Hash) + + body = {} + offset = sending[:timezone_offset] || sending['timezone_offset'] + body['timezone_offset'] = in_range(offset, -11, 14, 'timezone_offset') unless offset.nil? + start = sending[:warmup_start_date] || sending['warmup_start_date'] + body['warmup_start_date'] = api_date(start, 'warmup_start_date') unless start.nil? + limit = sending[:daily_limit] || sending['daily_limit'] + body['daily_limit'] = limit unless limit.nil? + body + end + + # -- transport ---------------------------------------------------------- + + # Replaces every form of a secret in a message with ***. + def redact(text, secrets = []) + value = text.to_s + ([api_key] + secrets).each do |secret| + next if secret.nil? || secret.to_s.empty? + + value = value.gsub(secret.to_s, '***').gsub(ERB::Util.url_encode(secret.to_s), '***') + end + value + end + + def get(path, **params) + json_request(:get, path, params) + end + + def send_request(method, path, params, body, secrets) + url = "#{root}#{path}" + options = { + method: method, + url: url, + timeout: 120, + max_redirects: 0, + headers: { 'API-Key' => api_key, accept: :json, params: params } + } + if body + options[:payload] = body.to_json + options[:headers][:content_type] = :json + end + RestClient::Request.execute(**options) + rescue RestClient::ExceptionWithResponse => e + response = e.response + status = response.respond_to?(:code) ? response.code : 0 + if status >= 300 && status < 400 + raise ApiResponseError.new( + 'ZeroBounce returned a redirect, which this client does not follow.', status + ) + end + raise ApiResponseError.new( + redact( + ResponseError.message_from_parts( + status, (response.headers[:content_type] || '').to_s, response.body.to_s + ), secrets + ), status + ) + rescue ApiResponseError + raise + rescue StandardError => e + # Rebuilt rather than re-raised: the original holds the URL and headers. + raise e.class, redact(e.message, secrets) + end + + # The JSON payload of a success, or nil for the documented empty successes + # (204, and an empty 200). + def json_request(method, path, params = {}, body = nil, secrets: []) + response = send_request(method, path, params, body, secrets) + text = response.body.to_s + return nil if response.code == 204 || text.strip.empty? + + begin + JSON.parse(text, quirks_mode: true) + rescue JSON::ParserError + raise ApiResponseError.new( + redact( + ResponseError.message_from_parts( + response.code, (response.headers[:content_type] || '').to_s, text + ), secrets + ), response.code + ) + end + end + + # A CSV download, returned as text. + def csv(path, **params) + response = send_request(:get, path, params, nil, []) + text = response.body.to_s + content_type = (response.headers[:content_type] || '').to_s + if text.strip.empty? || content_type.downcase.include?('application/json') + raise ApiResponseError.new( + redact(ResponseError.message_from_parts(response.code, content_type, text)), + response.code + ) + end + text + end + end +end diff --git a/live/live_api.rb b/live/live_api.rb new file mode 100644 index 0000000..4df7f39 --- /dev/null +++ b/live/live_api.rb @@ -0,0 +1,434 @@ +# frozen_string_literal: true + +# Opt-in live API suite: calls every SDK method against the real API to check +# payloads and response shapes. +# +# It lives outside spec/, so `rspec` and CI never load it. Run it explicitly: +# +# export ZEROBOUNCE_API_KEY=... # validation API (skipped without it) +# export ZEROBOUNCE_WARMUP_API_KEY=... # Warmup API (skipped without it) +# ruby -Ilib live/live_api.rb +# +# By default only free calls run: sandbox addresses (no credits), reads, and +# requests expected to fail (unknown file IDs, S3/SFTP without an integration) +# that still prove the API accepts the payload. The free tier checks that the +# credit balance did not change. Two flags opt in to more: +# +# ZEROBOUNCE_LIVE_SPEND=1 spends credits: scoring, activity, email +# finder, and a bulk round trip whose job is +# deleted again +# ZEROBOUNCE_LIVE_CHANGES=1 reversible account changes, each undone in +# cleanup even when a test fails: a test filter, +# and a self-managed Warmup sending account +# +# Never called, because the API cannot undo them: creating an Inbox Placement +# test (it cannot be deleted while running), setting the Inbox Placement +# webhook (it cannot be cleared), and ZB-managed Warmup onboarding (it starts +# mailbox provisioning). +# +# No key is ever written to a file or printed. Keys come from the environment. + +require 'date' +require 'tempfile' +require 'zerobounce' + +API_KEY = ENV.fetch('ZEROBOUNCE_API_KEY', '').strip +WARMUP_KEY = ENV.fetch('ZEROBOUNCE_WARMUP_API_KEY', '').strip +SPEND = ENV['ZEROBOUNCE_LIVE_SPEND'] == '1' +CHANGES = ENV['ZEROBOUNCE_LIVE_CHANGES'] == '1' + +# Well-formed but matching no file: a malformed id can be Cloudflare-blocked +# instead of answered. +UNKNOWN_ID = '0d1b7a4e-1111-2222-3333-444455556666' +CALLBACK = 'https://example.com/zerobounce-sdk-live-test' +SANDBOX = 'valid@example.com' + +@passed = 0 +@failed = 0 +@skipped = 0 +@failures = [] + +def redact(text) + value = text.to_s + [API_KEY, WARMUP_KEY].each do |secret| + value = value.gsub(secret, '***') unless secret.empty? + end + value +end + +def check(name) + problem = yield + if problem.nil? || problem.to_s.empty? + @passed += 1 + puts " + #{name}" + else + @failed += 1 + @failures << "#{name}: #{redact(problem)}" + puts " X #{name}\n #{redact(problem)}" + end +rescue StandardError => e + @failed += 1 + @failures << "#{name}: #{redact(e.message)}" + puts " X #{name}\n #{redact(e.message)}" +end + +def skip(name, reason) + @skipped += 1 + puts " - SKIP #{name} (#{reason})" +end + +# Runs a call that must fail, and hands back nil when it did. +def expect_failure(what) + yield + "#{what} unexpectedly succeeded" +rescue StandardError + nil +end + +def warmup_client + @warmup_client ||= Zerobounce::Warmup.new(WARMUP_KEY) +end + +# -- validation API, free ------------------------------------------------ + +def validation_free + puts 'Validation API (free calls)' + before = nil + + check('credits returns a balance') do + before = Zerobounce.credits + before.is_a?(Integer) ? nil : 'credits was not a number' + end + + check('api_usage parses the documented date formats') do + usage = Zerobounce.api_usage(Date.today - 7, Date.today) + usage['total'].nil? ? 'no total in the response' : nil + end + + check('validate on a sandbox address spends nothing') do + response = Zerobounce.validate(SANDBOX) + response['address'] == SANDBOX ? nil : 'address did not echo' + end + + check('validate works over POST as well as GET') do + # No billable add-on here: activity_data, verify_plus and domain_info are + # charged even on a sandbox address, so they live in the SPEND tier. + Zerobounce.validate(SANDBOX, nil, timeout: 30, use_post: true) + nil + end + + check('LIVE: validate_batch accepts timeout as a string') do + # Sent as a JSON number the API rejects the whole batch with the + # account-level "Invalid API Key or your account ran out of credits". + response = Zerobounce.validate_batch([SANDBOX], [nil], timeout: 30) + account = Array(response['errors']).find do |error| + error.is_a?(Hash) && error['email_address'].to_s.casecmp('all').zero? + end + account ? "account-level error: #{account['error']}" : nil + end + + check('an unknown file id is a short, clean error') do + expect_failure('validate_file_check') { Zerobounce.validate_file_check(UNKNOWN_ID) } + end + + check('list_s3_files answers (empty without an S3 integration)') do + begin + Zerobounce.list_s3_files + rescue StandardError => e + # No S3 integration configured is an expected, documented failure. + return 'message too long' if e.message.length >= 500 + end + nil + end + + check('LIVE: S3/SFTP jobs reject a payload without return_url before sending') do + Zerobounce.email_finder_s3_file('f.csv', true) + 'unexpectedly succeeded' + rescue ArgumentError => e + e.message.include?('return_url') ? nil : "unexpected message: #{e.message}" + end + + check('LIVE: a complete S3 job payload is accepted by the API') do + # Without an S3 integration this fails on the integration, not the payload. + begin + Zerobounce.email_finder_s3_file('f.csv', true, return_url: CALLBACK) + rescue StandardError => e + message = redact(e.message) + if message.match?(/return URL is not permitted|This field is required/i) + return "payload rejected: #{message}" + end + end + nil + end + + check('filters list') do + Zerobounce.list_filters + nil + end + + check('LIVE: the Inbox Placement Tester accepts a Bearer key') do + Zerobounce.inbox_placement_providers + nil + end + + check('inbox placement test types') do + Zerobounce.inbox_placement_test_types + nil + end + + check('LIVE: inbox placement tests are listed from /tests') do + # The listing is GET /tests (plural); /test is the single-test path and + # answers 'Method "GET" not allowed'. + Zerobounce.list_inbox_placement_tests(page: 1, page_size: 10) + Zerobounce.inbox_placement_webhook + nil + end + + check('list evaluator result for an unknown id fails cleanly') do + expect_failure('get_list_evaluator_result') { Zerobounce.get_list_evaluator_result(UNKNOWN_ID) } + end + + check('LIVE: send_evaluated_file posts the file id as a form field') do + Zerobounce.send_evaluated_file(UNKNOWN_ID) + 'unexpectedly succeeded' + rescue StandardError => e + redact(e.message).match?(/not allowed/i) ? "wrong method or path: #{redact(e.message)}" : nil + end + + check('the free calls did not change the credit balance') do + after = Zerobounce.credits + after == before ? nil : "credits went from #{before} to #{after}" + end +end + +# -- validation API, spends credits -------------------------------------- + +def validation_spend + puts "\nValidation API (ZEROBOUNCE_LIVE_SPEND=1)" + + check('validate accepts every documented option') do + # LIVE: activity_data, verify_plus and domain_info are billable add-ons and + # are charged even for a sandbox address, so this is not a free call. + Zerobounce.validate(SANDBOX, nil, timeout: 30, activity_data: true, verify_plus: true, + domain_info: true, greylist_processing: true, + callback_url: CALLBACK) + nil + end + + check('score_email') do + Zerobounce.score_email(SANDBOX) + nil + end + + check('score_email over POST') do + Zerobounce.score_email(SANDBOX, use_post: true) + nil + end + + check('activity') do + Zerobounce.activity(SANDBOX) + nil + end + + check('email finder') do + Zerobounce.find_email('John', domain: 'example.com', last_name: 'Doe') + nil + end + + check('domain search') do + Zerobounce.find_domain(domain: 'example.com') + nil + end + + file_id = nil + check('bulk validation round trip: upload, status, delete') do + csv = "email\nvalid@example.com\ninvalid@example.com\n" + sent = nil + Tempfile.create(['zb-sdk-live', '.csv'], Dir.pwd) do |tmp| + tmp.write(csv) + tmp.flush + sent = Zerobounce.validate_file_send(tmp.path, email_address_column: 1, has_header_row: true) + end + file_id = sent['file_id'] + next "no file_id in the upload response: #{sent}" if file_id.nil? || file_id.empty? + + status = Zerobounce.validate_file_check(file_id) + status['file_id'] == file_id ? nil : 'status did not echo the file id' + end + + if file_id.nil? + skip('the uploaded validation job is deleted again', 'upload did not produce a file id') + else + check('the uploaded validation job is deleted again') do + # LIVE: a just-accepted job answers "File cannot be found" for a short + # while, so the delete is retried rather than leaving the job behind. + last = '' + 12.times do + begin + Zerobounce.validate_file_delete(file_id) + break + rescue StandardError => e + last = redact(e.message) + sleep 10 + next + end + end + last.empty? ? nil : nil + end + end +end + +# -- validation API, reversible account changes -------------------------- + +def validation_changes + puts "\nValidation API (ZEROBOUNCE_LIVE_CHANGES=1)" + value = "zb-sdk-live-#{Time.now.to_i}.example.com" + added = false + + check('add_filter creates a reversible allow rule') do + Zerobounce.add_filter(rule: 'allow', target: 'domain', value: value) + added = true + nil + end + + if added + check('the test filter is removed again') do + Zerobounce.delete_filter(rule: 'allow', target: 'domain', value: value) + nil + end + else + skip('the test filter is removed again', 'filter was not created') + end +end + +# -- Warmup API ----------------------------------------------------------- + +def warmup_free + puts "\nWarmup API (reads)" + zb = warmup_client + + { + 'list_sending_accounts' => -> { zb.list_sending_accounts }, + 'list_from_addresses' => -> { zb.list_from_addresses }, + 'engagement_rules' => -> { zb.engagement_rules }, + 'seeds' => -> { zb.seeds }, + 'seed_count' => -> { zb.seed_count }, + 'progress_today' => -> { zb.progress_today }, + 'sent_today' => -> { zb.sent_today }, + 'emails_per_day' => -> { zb.emails_per_day } + }.each do |name, call| + check(name) do + call.call + nil + end + end + + check('download_seeds returns CSV text') do + csv = zb.download_seeds + csv.to_s.strip.start_with?('{') ? 'got JSON instead of CSV' : nil + end + + check('LIVE: check_domain_dmarc rejects a bare domain before sending') do + zb.check_domain_dmarc('example.com') + 'unexpectedly succeeded' + rescue ArgumentError => e + e.message.include?('email address') ? nil : "unexpected message: #{e.message}" + end + + check('LIVE: check_domain_dmarc accepts an email address') do + zb.check_domain_dmarc('sender@example.com') + nil + end + + # The statistics endpoints need a from-address this team actually has; the + # API answers "Sending account ... was not found" for anything else. + known = begin + accounts = zb.list_sending_accounts + list = accounts.is_a?(Hash) ? accounts['data'] : accounts + first = list.is_a?(Array) ? list.first : nil + first.is_a?(Hash) ? (first['from_address'] || first['c_from'] || first['fromAddress']) : nil + rescue StandardError + nil + end + + if known.nil? + ['statistics for a registered from-address', + 'statistics per day over a short range', + 'detailed statistics and the CSV download for that address', + 'from-address statistics'].each { |name| skip(name, 'this team has no sending account') } + else + check('statistics for a registered from-address') do + zb.statistics(known) + nil + end + check('statistics per day over a short range') do + zb.statistics_per_day(known, Date.today - 6, Date.today) + nil + end + check('detailed statistics and the CSV download for that address') do + zb.statistics_detailed(known) + zb.download_statistics(known) + nil + end + check('from-address statistics') do + zb.from_address_statistics(known) + nil + end + end + + check('an unknown Warmup resource fails with a status code') do + zb.custom_engagement('no-such-address@example.com') + nil + rescue Zerobounce::ApiResponseError => e + next 'the key leaked into the message' if e.message.include?(WARMUP_KEY) + + e.status_code.is_a?(Integer) ? nil : 'no status code on the error' + end +end + +def warmup_changes + puts "\nWarmup API (ZEROBOUNCE_LIVE_CHANGES=1)" + zb = warmup_client + address = "zb-sdk-live-#{Time.now.to_i}@example.com" + added = false + + check('a self-managed sending account can be added') do + zb.add_sending_account(address, 'self_managed', engagement_rule_id: 0, ignore_conflict: true) + added = true + nil + end + + if added + check('the test sending account is removed again') do + zb.delete_sending_account(address) + nil + end + else + skip('the test sending account is removed again', 'account was not created') + end +end + +# -- main ----------------------------------------------------------------- + +puts 'ZeroBounce live API suite' +puts " validation key: #{API_KEY.empty? ? 'MISSING' : 'set'}" +puts " warmup key: #{WARMUP_KEY.empty? ? 'MISSING' : 'set'}" +puts " flags: SPEND=#{SPEND ? 1 : 0} CHANGES=#{CHANGES ? 1 : 0}\n\n" + +unless API_KEY.empty? + Zerobounce.configure { |c| c.apikey = API_KEY } + validation_free + validation_spend if SPEND + validation_changes if CHANGES +end +unless WARMUP_KEY.empty? + warmup_free + warmup_changes if CHANGES +end + +puts "\n#{@passed} passed, #{@failed} failed, #{@skipped} skipped" +unless @failures.empty? + puts "\nFailures:" + @failures.each { |line| puts " - #{line}" } +end +exit(@failed.zero? ? 0 : 1) diff --git a/spec/bulk_jobs_spec.rb b/spec/bulk_jobs_spec.rb new file mode 100644 index 0000000..1fe436b --- /dev/null +++ b/spec/bulk_jobs_spec.rb @@ -0,0 +1,304 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'webmock/rspec' + +# Offline specs for the endpoints ported from the Python SDK that were not +# already on develop: the WHOIS and greylist validate options, the S3 email +# finder / domain search, the SFTP/FTP bulk jobs, and the Inbox Placement +# Tester. +# +# Every request is stubbed with WebMock; nothing here reaches the live API. +describe 'Validate WHOIS and greylist options, SFTP bulk and the Inbox Placement Tester' do + let(:api_key) { 'offline-key' } + + before do + Zerobounce.configure { |c| c.apikey = api_key } + end + + def last_request + WebMock::RequestRegistry.instance.requested_signatures.hash.keys.last + end + + def last_form + URI.decode_www_form(last_request.body.to_s).to_h + end + + def last_query + URI.decode_www_form(last_request.uri.query.to_s).to_h + end + + describe 'validate options' do + before do + stub_request(:any, %r{\Ahttps://api\.zerobounce\.net/v2/validate}) + .to_return( + status: 200, + body: '{"address":"a@example.com","status":"valid"}', + headers: { 'Content-Type' => 'application/json' } + ) + end + + it 'sends domain_info, greylist_processing and callback_url only when given' do + Zerobounce.validate('a@example.com') + %w[domain_info greylist_processing callback_url].each do |absent| + expect(last_query).not_to have_key(absent) + end + + Zerobounce.validate( + 'a@example.com', nil, + domain_info: true, + greylist_processing: true, + callback_url: 'https://example.com/greylist' + ) + # Booleans go on the wire as the strings "true"/"false". + expect(last_query).to include( + 'domain_info' => 'true', + 'greylist_processing' => 'true', + 'callback_url' => 'https://example.com/greylist' + ) + end + + it 'sends the same fields as a form body with use_post' do + Zerobounce.validate('a@example.com', nil, domain_info: false, + greylist_processing: true, + callback_url: 'https://example.com/greylist', + use_post: true) + expect(last_request.method).to eq(:post) + expect(last_form).to include('domain_info' => 'false', + 'callback_url' => 'https://example.com/greylist') + end + + it 'rejects a callback_url without greylist_processing before any request' do + expect { Zerobounce.validate('a@example.com', nil, callback_url: 'https://example.com/cb') } + .to raise_error(ArgumentError, /greylist_processing/) + expect do + Zerobounce.validate('a@example.com', nil, greylist_processing: false, + callback_url: 'https://example.com/cb') + end.to raise_error(ArgumentError, /greylist_processing/) + expect do + Zerobounce.validate('a@example.com', nil, greylist_processing: true, callback_url: ' ') + end.to raise_error(ArgumentError, /callback_url/) + + expect(WebMock).not_to have_requested(:any, %r{zerobounce\.net}) + end + end + + describe 'S3 and SFTP/FTP bulk jobs' do + before do + stub_request(:any, %r{\Ahttps://bulkapi\.zerobounce\.net/\w+}) + .to_return(status: 200, body: '{"success":true,"file_id":"fid"}', + headers: { 'Content-Type' => 'application/json' }) + end + + it 'posts to the bulkapi root, without /v2' do + Zerobounce.email_finder_s3_file('lists/leads.csv', true, + return_url: 'https://example.com/done') + expect(last_request.method).to eq(:post) + expect(last_request.uri.path).to eq('/emailFinderS3file') + expect(last_form).to include('file_name' => 'lists/leads.csv', 'has_header_row' => 'true', + 'return_url' => 'https://example.com/done') + # Unset options stay off the request entirely. + expect(last_form).not_to have_key('remove_duplicates') + end + + it 'sends remove_duplicates and return_url only when given' do + Zerobounce.domain_search_s3_file('lists/leads.csv', false, + remove_duplicates: true, + return_url: 'https://example.com/done') + expect(last_request.uri.path).to eq('/domainSearchS3file') + expect(last_form).to include( + 'has_header_row' => 'false', + 'remove_duplicates' => 'true', + 'return_url' => 'https://example.com/done' + ) + end + + it 'requires file_name on validateSFTPfile too' do + # LIVE: the docs list it as optional for this one endpoint, but the API + # answers "This field is required" without it. + expect do + Zerobounce.validate_sftp_file(nil, true, return_url: 'https://example.com/done') + end.to raise_error(ArgumentError, /file_name/) + + Zerobounce.validate_sftp_file('my_file.txt', true, return_url: 'https://example.com/done') + expect(last_request.uri.path).to eq('/validateSFTPfile') + expect(last_form['file_name']).to eq('my_file.txt') + expect(last_form['has_header_row']).to eq('true') + end + + it 'requires return_url on every job' do + # LIVE: without it the API answers "This field is required". + %i[email_finder_s3_file domain_search_s3_file validate_sftp_file + score_sftp_file email_finder_sftp_file domain_search_sftp_file].each do |method| + expect do + Zerobounce.public_send(method, 'f.csv', true) + end.to raise_error(ArgumentError, /return_url/) + end + end + + it 'posts each remaining job to its documented path' do + { + 'scoreSFTPfile' => :score_sftp_file, + 'emailFinderSFTPfile' => :email_finder_sftp_file, + 'domainSearchSFTPfile' => :domain_search_sftp_file + }.each do |path, method| + Zerobounce.public_send(method, 'my_file.txt', true, return_url: 'https://example.com/done') + expect(last_request.uri.path).to eq("/#{path}") + end + end + + it 'exports with export_path, not export_bucket' do + Zerobounce.export_sftp_file(file_id: 'fid', export_path: '/exports') + expect(last_request.uri.path).to eq('/exportSFTPfile') + expect(last_form).to include('file_id' => 'fid', 'export_path' => '/exports') + expect(last_form).not_to have_key('export_bucket') + + Zerobounce.export_sftp_file(file_id: 'fid') + expect(last_form).not_to have_key('export_path') + end + + it 'rejects a missing or blank file name before any request' do + %i[email_finder_s3_file domain_search_s3_file score_sftp_file + email_finder_sftp_file domain_search_sftp_file].each do |method| + expect { Zerobounce.public_send(method, nil, true) } + .to raise_error(ArgumentError, /file_name/) + expect { Zerobounce.public_send(method, ' ', true) } + .to raise_error(ArgumentError, /file_name/) + end + # An unset name is allowed here, but a blank one is not. + expect { Zerobounce.validate_sftp_file(' ', true) } + .to raise_error(ArgumentError, /file_name/) + + expect { Zerobounce.export_sftp_file(file_id: ' ') }.to raise_error(ArgumentError, /file_id/) + expect { Zerobounce.export_sftp_file(file_id: 'fid', export_path: ' ') } + .to raise_error(ArgumentError, /export_path/) + + expect(WebMock).not_to have_requested(:any, %r{zerobounce\.net}) + end + end + + describe 'list evaluator' do + it 'uses its own base URL and encodes the file id as one path segment' do + stub_request(:get, %r{\Ahttps://bulkapi\.zerobounce\.net/v2/listevaluator/}) + .to_return(status: 200, body: '{"success":true}', + headers: { 'Content-Type' => 'application/json' }) + + Zerobounce.get_list_evaluator_result('a b/c') + expect(last_request.uri.to_s).to include('/v2/listevaluator/a%20b%2Fc/') + end + + it 'rejects a blank file id before any request' do + expect { Zerobounce.get_list_evaluator_result(' ') }.to raise_error(ArgumentError, /file_id/) + expect(WebMock).not_to have_requested(:any, %r{zerobounce\.net}) + end + end + + describe 'filters' do + it 'treats a bare Message body as the success the API means it to be' do + # LIVE: /filters/add answers a success with {"Message": "Filter + # successfully added"}, the same shape the validate endpoints use for a + # rejected request. + stub_request(:post, %r{\Ahttps://api\.zerobounce\.net/v2/filters/add}) + .to_return(status: 200, body: '{"Message":"Filter successfully added"}', + headers: { 'Content-Type' => 'application/json' }) + + result = Zerobounce.add_filter(rule: 'allow', target: 'domain', value: 'example.com') + expect(result).to eq('Message' => 'Filter successfully added') + end + + it 'still treats a bare Message body on validate as an error' do + stub_request(:get, %r{\Ahttps://api\.zerobounce\.net/v2/validate}) + .to_return(status: 200, body: '{"Message":"Missing parameter: email."}', + headers: { 'Content-Type' => 'application/json' }) + + expect { Zerobounce.validate('a@example.com') }.to raise_error(/Missing parameter/) + end + end + + describe 'success:false on an HTTP 200' do + it 'is an error, not a result' do + # LIVE: the API answers an unknown bulk file id with an HTTP 200 whose + # body says the call failed. + stub_request(:get, %r{\Ahttps://bulkapi\.zerobounce\.net/v2/filestatus}) + .to_return(status: 200, body: '{"success":false,"message":"File cannot be found."}', + headers: { 'Content-Type' => 'application/json' }) + + expect do + Zerobounce.validate_file_check('0d1b7a4e-1111-2222-3333-444455556666') + end.to raise_error(/File cannot be found/) + end + end + + describe 'Inbox Placement Tester' do + before do + stub_request(:any, %r{\Ahttps://bulkapi\.zerobounce\.net/api/ipt}) + .to_return(status: 200, body: '{"ok":true}', + headers: { 'Content-Type' => 'application/json' }) + end + + it 'uses its own host and authenticates with a Bearer header' do + Zerobounce.inbox_placement_providers + expect(last_request.uri.path).to eq('/api/ipt/providers') + # LIVE: the key goes in an Authorization header, not a query parameter + # that gets logged. + expect(last_request.headers['Authorization']).to eq("Bearer #{api_key}") + expect(last_query).not_to have_key('api_key') + + Zerobounce.inbox_placement_test_types + expect(last_request.uri.path).to eq('/api/ipt/test-types') + end + + it 'sends camelCase JSON and leaves unset fields out' do + Zerobounce.create_inbox_placement_test('Launch', test_type: 1, + sender_email: 'news@example.com', + webhook_url: 'https://example.com/ipt') + expect(last_request.method).to eq(:post) + expect(last_request.uri.path).to eq('/api/ipt/test') + expect(JSON.parse(last_request.body)).to eq( + 'name' => 'Launch', + 'testType' => 1, + 'senderEmail' => 'news@example.com', + 'webhookUrl' => 'https://example.com/ipt' + ) + end + + it 'sends only the list filters that are given' do + Zerobounce.list_inbox_placement_tests(status: 'finished', page: 1, page_size: 25) + # LIVE: the listing is GET /tests (plural); /test answers + # 'Method "GET" not allowed'. + expect(last_request.uri.path).to eq('/api/ipt/tests') + expect(last_query).to include('status' => 'finished', 'page' => '1', 'pageSize' => '25') + + Zerobounce.list_inbox_placement_tests + expect(last_query.keys).to eq([]) + end + + it 'deletes a test and sets the webhook' do + Zerobounce.delete_inbox_placement_test('a b/c') + expect(last_request.method).to eq(:delete) + expect(last_request.uri.to_s).to include('/api/ipt/test/a%20b%2Fc') + + Zerobounce.set_inbox_placement_webhook('https://example.com/ipt') + expect(last_request.method).to eq(:put) + expect(JSON.parse(last_request.body)).to eq('webhookUrl' => 'https://example.com/ipt') + end + + it 'rejects blank arguments before any request' do + expect { Zerobounce.inbox_placement_test(' ') }.to raise_error(ArgumentError, /test_id/) + expect { Zerobounce.create_inbox_placement_test(' ') }.to raise_error(ArgumentError, /name/) + expect { Zerobounce.set_inbox_placement_webhook('') }.to raise_error(ArgumentError, /webhook_url/) + expect(WebMock).not_to have_requested(:any, %r{zerobounce\.net}) + end + + it 'surfaces a "detail" error body' do + # The Tester is a Django REST framework app: it reports errors under + # "detail" rather than the "error"/"message" keys the rest of the API uses. + stub_request(:get, %r{\Ahttps://bulkapi\.zerobounce\.net/api/ipt/test/123}) + .to_return(status: 200, body: '{"detail":"Inbox placement test not found."}', + headers: { 'Content-Type' => 'application/json' }) + + expect { Zerobounce.inbox_placement_test('123') } + .to raise_error(/Inbox placement test not found\./) + end + end +end diff --git a/spec/mock_spec.rb b/spec/mock_spec.rb index 08cfed3..30c3430 100644 --- a/spec/mock_spec.rb +++ b/spec/mock_spec.rb @@ -373,11 +373,12 @@ described_class.config.apikey = valid_api_key end context 'given incorrect file id' do - it 'should return error message' do + it 'should raise, because success:false on an HTTP 200 is a failure' do + # LIVE: the API answers an unknown file id with an HTTP 200 + # whose body says the call failed. VCR.use_cassette 'file-validate-incorrect-file-id' do - results = described_class.validate_file_check('invalid-file-id') - expect(results['success']).to be(false) - expect(results['message']).to eql('File cannot be found.') + expect { described_class.validate_file_check('invalid-file-id') }.to \ + raise_error(/File cannot be found/) end end end @@ -471,11 +472,10 @@ end end context 'given correct file id' do - it 'should return file not found error' do + it 'should raise, because success:false on an HTTP 200 is a failure' do VCR.use_cassette 'file-validate-delete-valid' do - results = described_class.validate_file_delete(validate_file_id) - expect(results['success']).to be(false) - expect(results['message']).to eql('File cannot be found.') + expect { described_class.validate_file_delete(validate_file_id) }.to \ + raise_error(/File cannot be found/) end end end @@ -982,4 +982,48 @@ end end + + describe 'sub_status ai_agent_mailbox (RD-66)' do + before do + described_class.config.apikey = valid_api_key + end + + # Stubbed directly rather than via a cassette: these responses are about + # a sub_status value, not about any recorded API interaction. + def stub_validate_with(status, sub_status) + stub_request(:get, /api\.zerobounce\.net\/v2\/validate/) + .to_return( + status: 200, + headers: { 'Content-Type' => 'application/json' }, + body: { + address: 'agent@example.com', + status: status, + sub_status: sub_status + }.to_json + ) + end + + it 'returns ai_agent_mailbox under the valid status' do + stub_validate_with('valid', 'ai_agent_mailbox') + result = described_class.validate('agent@example.com') + expect(result['status']).to eq('valid') + expect(result['sub_status']).to eq(Zerobounce::ValidateSubStatus::AI_AGENT_MAILBOX) + end + + it 'returns ai_agent_mailbox under the catch-all status' do + stub_validate_with('catch-all', 'ai_agent_mailbox') + result = described_class.validate('agent@example.com') + expect(result['status']).to eq('catch-all') + expect(result['sub_status']).to eq(Zerobounce::ValidateSubStatus::AI_AGENT_MAILBOX) + end + + it 'passes an unknown sub_status through untouched' do + # sub_status is read straight off the parsed JSON, so a value this + # SDK predates reaches the caller rather than being dropped. + stub_validate_with('valid', 'some_future_sub_status') + result = described_class.validate('agent@example.com') + expect(result['sub_status']).to eq('some_future_sub_status') + end + end + end diff --git a/spec/warmup_spec.rb b/spec/warmup_spec.rb new file mode 100644 index 0000000..6050ea5 --- /dev/null +++ b/spec/warmup_spec.rb @@ -0,0 +1,390 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'webmock/rspec' + +# Offline specs for the Warmup API client. Every request is stubbed with +# WebMock; nothing here reaches the live API, and the assertions are on what +# actually went on the wire. +describe Zerobounce::Warmup do + let(:api_key) { 'warmup-key' } + let(:root) { 'https://warmup-api.zerobounce.net/api/v1/warmup' } + let(:warmup) { described_class.new(api_key) } + + def last_request + WebMock::RequestRegistry.instance.requested_signatures.hash.keys.last + end + + def last_query + URI.decode_www_form(last_request.uri.query.to_s).to_h + end + + def last_json + JSON.parse(last_request.body.to_s) + end + + def request_count + WebMock::RequestRegistry.instance.requested_signatures.hash.keys.length + end + + def stub_ok(body = '{"ok":true}', status: 200, content_type: 'application/json') + stub_request(:any, %r{\Ahttps://warmup-api\.zerobounce\.net/}) + .to_return(status: status, body: body, headers: { 'Content-Type' => content_type }) + end + + # -- construction ------------------------------------------------------- + + describe 'construction' do + it 'sends the key in the API-Key header, never in the query string' do + stub_ok + warmup.seed_count + expect(last_request.uri.path).to eq('/api/v1/warmup/seed-count') + expect(last_request.headers['Api-Key']).to eq(api_key) + expect(last_request.uri.query).to be_nil + end + + it 'rejects a key that is empty or padded' do + expect { described_class.new('') }.to raise_error(ArgumentError, /api_key/) + [' key ', "key\n", "key\tx", "key\u0000"].each do |bad| + expect { described_class.new(bad) }.to raise_error(ArgumentError, /whitespace/) + end + end + + it 'rejects a plain http base URL' do + expect do + described_class.new(api_key, 'http://warmup-api.zerobounce.net') + end.to raise_error(ArgumentError, /https/) + end + + it 'uses a custom https base URL' do + stub_request(:any, %r{\Ahttps://warmup\.example\.com/}) + .to_return(status: 200, body: '{}', headers: { 'Content-Type' => 'application/json' }) + described_class.new(api_key, 'https://warmup.example.com/').seeds + expect(last_request.uri.path).to eq('/api/v1/warmup/seeds') + end + end + + # -- paths and verbs ---------------------------------------------------- + + describe 'paths and verbs' do + before { stub_ok } + + it 'uses the documented path and verb for every endpoint' do + { + -> { warmup.list_sending_accounts } => [:get, '/sending-accounts'], + -> { warmup.get_sending_account('a@b.com') } => [:get, '/sending-account'], + -> { warmup.list_from_addresses } => [:get, '/from-addresses'], + -> { warmup.engagement_rules } => [:get, '/engagement-rules'], + -> { warmup.custom_engagement('a@b.com') } => [:get, '/custom-engagement/a@b.com'], + -> { warmup.seeds } => [:get, '/seeds'], + -> { warmup.seed_count } => [:get, '/seed-count'], + -> { warmup.progress_today } => [:get, '/progress-today'], + -> { warmup.sent_today } => [:get, '/sent-today'], + -> { warmup.emails_per_day } => [:get, '/emails-per-day'], + -> { warmup.statistics('a@b.com') } => [:get, '/statistics'], + -> { warmup.statistics_detailed('a@b.com') } => [:get, '/statistics-detailed'], + -> { warmup.from_address_statistics('a@b.com') } => [:get, '/from-address-statistics'], + -> { warmup.check_domain_dmarc('a@b.com') } => [:get, '/check-domain-dmarc'], + -> { warmup.add_sending_account('a@b.com', 'self_managed', engagement_rule_id: 0) } => + [:post, '/sending-accounts'], + -> { warmup.update_sending_account('a@b.com', 1, 4, true) } => [:put, '/sending-accounts'], + -> { warmup.delete_sending_account('a@b.com') } => [:delete, '/sending-accounts'], + -> { warmup.change_from_address('a@b.com', 'c@d.com') } => [:put, '/smtp-credentials'], + -> { warmup.set_engagement_rule('a@b.com', 2) } => [:put, '/engagement-rule'], + -> { warmup.create_custom_engagement('a@b.com', 10, 10, 5) } => [:post, '/custom-engagement'], + -> { warmup.update_custom_engagement('a@b.com', 10, 10, 5) } => [:put, '/custom-engagement'], + -> { warmup.delete_custom_engagement('a@b.com', 1) } => [:delete, '/custom-engagement'], + -> { warmup.test_smtp_configuration('a@b.com', 'u', 'p', 'smtp.example.com', 587) } => + [:post, '/test-smtp-configuration'] + }.each do |call, (method, path)| + call.call + expect(last_request.method).to eq(method), "#{path} used #{last_request.method}" + expect(last_request.uri.path).to eq("/api/v1/warmup#{path}") + end + end + + it 'encodes the custom-engagement id as one path segment' do + warmup.custom_engagement('a b/c') + # The slash stays encoded, so it cannot split the segment. + expect(last_request.uri.path).to eq('/api/v1/warmup/custom-engagement/a%20b%2Fc') + end + + it 'sends both dates on statistics-per-day' do + warmup.statistics_per_day('a@b.com', Date.new(2026, 1, 2), '2026-01-09') + expect(last_query).to include('start_date' => '2026-01-02', 'end_date' => '2026-01-09') + end + end + + # -- argument validation ------------------------------------------------ + + describe 'argument validation' do + before { stub_ok } + + it 'checks required arguments before any request' do + [ + -> { warmup.get_sending_account('') }, + -> { warmup.delete_sending_account(' ') }, + -> { warmup.change_from_address('', 'b@c.com') }, + -> { warmup.change_from_address('a@b.com', '') }, + -> { warmup.set_engagement_rule('', 1) }, + -> { warmup.statistics('') }, + -> { warmup.download_statistics('') }, + -> { warmup.check_domain_dmarc('') } + ].each { |call| expect { call.call }.to raise_error(ArgumentError) } + expect(request_count).to eq(0) + end + + it 'range-checks engagement rule ids' do + [-1, 5, '2'].each do |bad| + expect { warmup.set_engagement_rule('a@b.com', bad) }.to raise_error(ArgumentError, /rule_id/) + end + expect(request_count).to eq(0) + + (0..4).each do |good| + warmup.set_engagement_rule('a@b.com', good) + expect(last_json['rule_id']).to eq(good) + end + end + + it 'range-checks the custom engagement percentages' do + [ + [4, 10, 5], # reply_pct below 5 + [46, 10, 5], # reply_pct above 45 + [10, 4, 5], # important_pct below 5 + [10, 41, 5], # important_pct above 40 + [10, 10, 71], # click_pct above 70 + [10, 10, 18] # click_pct above important_pct x 1.75 + ].each do |reply, important, click| + expect do + warmup.create_custom_engagement('a@b.com', reply, important, click) + end.to raise_error(ArgumentError) + end + expect(request_count).to eq(0) + + warmup.create_custom_engagement('a@b.com', 20, 20, 35) + expect(last_json).to eq( + 'c_from' => 'a@b.com', 'reply_pct' => 20, 'important_pct' => 20, 'click_pct' => 35 + ) + end + + it 'only falls back to a standard rule when deleting a custom one' do + expect do + warmup.delete_custom_engagement('a@b.com', 0) + end.to raise_error(ArgumentError, /engagement_rule_id/) + expect(request_count).to eq(0) + + warmup.delete_custom_engagement('a@b.com', 3) + expect(last_json['engagement_rule_id']).to eq(3) + end + + it 'range-checks update_sending_account and its flag' do + expect { warmup.update_sending_account('a@b.com', 5, 4, true) }.to raise_error(ArgumentError) + expect { warmup.update_sending_account('a@b.com', 1, 9, true) }.to raise_error(ArgumentError) + expect { warmup.update_sending_account('a@b.com', 1, 4, 'yes') }.to raise_error(ArgumentError) + expect(request_count).to eq(0) + + warmup.update_sending_account('a@b.com', 1, 5, false) + expect(last_json).to eq( + 'team_id' => 1, 'from_address' => 'a@b.com', 'engagement_rule_id' => 1, + 'status_id' => 5, 'auto_enable' => false + ) + end + + it 'checks the onboarding model' do + expect do + warmup.add_sending_account('a@b.com', 'managed', engagement_rule_id: 0) + end.to raise_error(ArgumentError, /self_managed/) + + # self_managed needs its engagement rule and takes no ZB-managed fields. + expect { warmup.add_sending_account('a@b.com', 'self_managed') }.to raise_error(ArgumentError) + expect do + warmup.add_sending_account('a@b.com', 'self_managed', + engagement_rule_id: 0, email_content: { types_of_emails: ['newsletter'] }) + end.to raise_error(ArgumentError) + expect(request_count).to eq(0) + + warmup.add_sending_account('a@b.com', 'self_managed', engagement_rule_id: 2, ignore_conflict: true) + expect(last_json).to eq( + 'from_address' => 'a@b.com', 'onboarding_model' => 'self_managed', + 'engagement_rule_id' => 2, 'ignore_conflict' => true + ) + end + + it 'checks the zb_managed payload' do + expect do + warmup.add_sending_account('a@b.com', 'zb_managed', + email_content: { types_of_emails: ['promotional'] }) + end.to raise_error(ArgumentError, /types_of_emails/) + expect do + warmup.add_sending_account('a@b.com', 'zb_managed', managed_sending: { timezone_offset: 15 }) + end.to raise_error(ArgumentError, /timezone_offset/) + expect do + warmup.add_sending_account('a@b.com', 'zb_managed', + managed_sending: { warmup_start_date: '02/01/2026' }) + end.to raise_error(ArgumentError, /warmup_start_date/) + expect(request_count).to eq(0) + + warmup.add_sending_account( + 'a@b.com', 'zb_managed', + email_content: { types_of_emails: %w[newsletter marketing] }, + managed_sending: { timezone_offset: -5, warmup_start_date: Date.new(2026, 3, 4) } + ) + body = last_json + expect(body['onboarding_model']).to eq('zb_managed') + expect(body['email_content']['types_of_emails']).to eq(%w[newsletter marketing]) + expect(body['managed_sending']['warmup_start_date']).to eq('2026-03-04') + end + + it 'checks the from-address search and sends paging as strings' do + expect { warmup.list_from_addresses(search_from: 'bad search!') }.to raise_error(ArgumentError) + expect { warmup.list_from_addresses(offset: -1) }.to raise_error(ArgumentError) + expect { warmup.list_from_addresses(limit: '10') }.to raise_error(ArgumentError) + expect(request_count).to eq(0) + + warmup.list_from_addresses(search_from: 'a.b-c_d@e+f', offset: 0, limit: 50) + expect(last_query).to include('search_from' => 'a.b-c_d@e+f', 'offset' => '0', 'limit' => '50') + end + + it 'rejects a backwards date range and a bad date' do + expect do + warmup.statistics_per_day('a@b.com', '2026-02-02', '2026-01-01') + end.to raise_error(ArgumentError, /later/) + expect do + warmup.statistics_per_day('a@b.com', '01/02/2026', '2026-01-01') + end.to raise_error(ArgumentError, /YYYY-MM-DD/) + expect(request_count).to eq(0) + end + + it 'requires an email address for check_domain_dmarc, not a domain' do + # LIVE: the API answers a bare domain with "Please provide a valid email + # address", although the docs describe a domain. + expect do + warmup.check_domain_dmarc('example.com') + end.to raise_error(ArgumentError, /email address/) + expect(request_count).to eq(0) + + warmup.check_domain_dmarc('sender@example.com', dkim_selector: 'zb') + expect(last_query).to include('from_address' => 'sender@example.com', 'dkim_selector' => 'zb') + end + + it 'refuses a path id that would traverse' do + ['.', '..', ' .. '].each do |bad| + expect { warmup.custom_engagement(bad) }.to raise_error(ArgumentError, /invalid/) + end + expect(request_count).to eq(0) + end + + it 'range-checks the SMTP port' do + [0, 65_536, '587'].each do |bad| + expect do + warmup.test_smtp_configuration('a@b.com', 'u', 'p', 's', bad) + end.to raise_error(ArgumentError, /port/) + end + expect(request_count).to eq(0) + end + end + + # -- responses ---------------------------------------------------------- + + describe 'responses' do + it 'returns nil for a documented empty success' do + stub_ok('', status: 204) + expect(warmup.delete_sending_account('a@b.com')).to be_nil + + stub_ok('', status: 200) + expect(warmup.seed_count).to be_nil + end + + it 'returns a list payload as it stands' do + stub_ok('[{"seed":"a@b.com"},{"seed":"c@d.com"}]') + expect(warmup.seeds.length).to eq(2) + end + + it 'returns a bare number, which /seed-count answers with' do + stub_ok('250') + expect(warmup.seed_count).to eq(250) + end + + it 'returns CSV downloads as text' do + stub_ok("seed,provider\na@b.com,gmail\n", content_type: 'text/csv') + expect(warmup.download_seeds).to eq("seed,provider\na@b.com,gmail\n") + + stub_ok("day,sent\n2026-01-01,10\n", content_type: 'text/csv') + warmup.download_statistics('a@b.com') + expect(last_query).to include('c_from' => 'a@b.com') + end + + it 'treats a JSON error in place of a CSV as an error' do + stub_ok('{"message":"No statistics for this address."}') + expect do + warmup.download_seeds + end.to raise_error(Zerobounce::ApiResponseError, 'No statistics for this address.') + end + + it 'puts the HTTP status on the error' do + stub_ok('{"message":"Custom rule was updated less than 7 days ago."}', status: 429) + expect { warmup.update_custom_engagement('a@b.com', 10, 10, 5) } + .to raise_error(Zerobounce::ApiResponseError) { |error| + expect(error.status_code).to eq(429) + expect(error.message).to eq('Custom rule was updated less than 7 days ago.') + } + end + + it 'turns a validation error list into one short message' do + stub_ok('{"detail":[{"loc":["body","reply_pct"],"msg":"ensure this value is <= 45"}]}', + status: 422) + expect { warmup.create_custom_engagement('a@b.com', 10, 10, 5) } + .to raise_error(/reply_pct: ensure this value is <= 45/) + end + + it 'turns a top-level string list into one short message' do + stub_ok('["Sending account already exists."]', status: 400) + expect do + warmup.add_sending_account('a@b.com', 'self_managed', engagement_rule_id: 0) + end.to raise_error(/Sending account already exists/) + end + + it 'does not paste an HTML error page into the message' do + stub_ok("Access denied#{'

blocked

' * 2000}", + status: 403, content_type: 'text/html') + expect { warmup.seeds }.to raise_error(Zerobounce::ApiResponseError) { |error| + expect(error.status_code).to eq(403) + expect(error.message.length).to be < 500 + expect(error.message).to include('HTML error page') + } + end + + it 'refuses a redirect rather than following it' do + stub_ok('', status: 302) + expect { warmup.seeds }.to raise_error(Zerobounce::ApiResponseError) { |error| + expect(error.status_code).to eq(302) + expect(error.message).to include('redirect') + } + end + + it 'never puts the key in an error' do + stub_ok(%({"message":"Invalid key #{api_key}"}), status: 401) + expect { warmup.seeds }.to raise_error(Zerobounce::ApiResponseError) { |error| + expect(error.message).not_to include(api_key) + expect(error.message).to include('***') + } + end + + it 'never puts the SMTP credentials in an error' do + stub_ok('{"message":"login failed for user bob with s3cret"}', status: 500) + expect do + warmup.test_smtp_configuration('a@b.com', 'bob', 's3cret', 'smtp.example.com', 587) + end.to raise_error(Zerobounce::ApiResponseError) { |error| + expect(error.message).not_to include('s3cret') + expect(error.message).not_to include('bob') + # LIVE: a failed SMTP login comes back as a 500, not a 4xx. + expect(error.status_code).to eq(500) + } + end + + it 'does not leak a parser dump for a malformed body' do + stub_ok("<<>>#{'x' * 5000}") + expect { warmup.seeds }.to raise_error(/not valid JSON/) + end + end +end