From c40c5f6afad465dcf32e37d77a529117e4d9a139 Mon Sep 17 00:00:00 2001 From: Ben Odom Date: Sun, 20 Sep 2026 13:17:54 -0500 Subject: [PATCH 1/2] add email_suppressions support --- CHANGELOG.md | 17 +++ README.md | 37 ++++++- pylon-api/CHANGELOG.md | 10 ++ pylon-api/README.md | 24 ++-- pylon-api/lib/pylon.rb | 1 + pylon-api/lib/pylon/client.rb | 25 ++++- .../lib/pylon/models/email_suppression.rb | 13 +++ pylon-api/lib/pylon/version.rb | 4 +- pylon-api/spec/pylon/client_spec.rb | 104 +++++++++++++++++- 9 files changed, 218 insertions(+), 17 deletions(-) create mode 100644 pylon-api/lib/pylon/models/email_suppression.rb diff --git a/CHANGELOG.md b/CHANGELOG.md index b16ac7d..ace13ad 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,21 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [1.2.0] - 2026-09-20 + +### Added +- `list_email_suppressions` for `GET /email-suppressions` with optional `email`, `cursor`, and `limit` query parameters. Pass `email` to check a single address; an empty collection means it is not suppressed. +- `delete_email_suppression` for `DELETE /email-suppressions/{id}` +- `Pylon::Models::EmailSuppression` model (`id`, `email`, `reason`, `created_at`, `bounce_details`) + +### Changed +- Documentation only: README examples now use `body_html` when creating issues and note the 365-day window for `list_issues`. No existing method signatures, paths, or return types changed. + +## [1.1.1] - 2025-04-18 + +### Fixed +- Fixed URL-based file attachments by ensuring they use proper multipart form encoding + ## [1.1.0] - 2024-04-16 ### Added @@ -51,6 +66,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - YARD documentation for all methods - MIT License +[1.2.0]: https://github.com/benjodo/pylon-api/compare/v1.1.1...v1.2.0 +[1.1.1]: https://github.com/benjodo/pylon-api/compare/v1.1.0...v1.1.1 [1.1.0]: https://github.com/benjodo/pylon-api/compare/v1.0.0...v1.1.0 [1.0.0]: https://github.com/benjodo/pylon-api/compare/v0.2.0...v1.0.0 [0.2.0]: https://github.com/benjodo/pylon-api/compare/v0.1.0...v0.2.0 diff --git a/README.md b/README.md index e01739a..ff9b221 100644 --- a/README.md +++ b/README.md @@ -81,7 +81,7 @@ response = account._response #### Issues ```ruby -# List issues (requires time range, max 30 days) +# List issues (requires time range, max 365 days) start_time = Time.now.utc - 86400 # 24 hours ago end_time = Time.now.utc @@ -89,19 +89,19 @@ issues = client.list_issues( start_time: start_time.iso8601, end_time: end_time.iso8601, page: 1, - per_page: 20, - status: 'open' # optional filter + per_page: 20 ) # Iterate through issues issues.each do |issue| - puts "#{issue.id}: #{issue.title} (#{issue.status})" + puts "#{issue.id}: #{issue.title} (#{issue.state})" end -# Create an issue +# Create an issue (title and body_html are required by the API) issue = client.create_issue( title: 'New Issue', - description: 'Issue description' + body_html: '

Issue description

', + requester_email: 'customer@example.com' ) # Access issue properties directly @@ -185,6 +185,31 @@ attachment = client.create_attachment(nil, ) ``` +#### Email Suppressions + +Pylon stops sending email to an address once it hard bounces or is suppressed manually. Use these methods to check and clear suppressions. + +```ruby +# Check whether a single address is suppressed +suppressions = client.list_email_suppressions(email: 'user@example.com') + +if suppressions.size.zero? + puts 'not suppressed' +else + suppression = suppressions.first + puts "#{suppression.email} suppressed (#{suppression.reason}) at #{suppression.created_at}" + puts suppression.bounce_details if suppression.bounce_details +end + +# List suppressions, most recent first, using the API's cursor pagination +page = client.list_email_suppressions(limit: 100) +pagination = page._response.body['pagination'] +next_page = client.list_email_suppressions(cursor: pagination['cursor']) if pagination['has_next_page'] + +# Remove a suppression so Pylon resumes sending to the address +client.delete_email_suppression(suppression.id) +``` + ## Error Handling The client will raise different types of errors based on the API response: diff --git a/pylon-api/CHANGELOG.md b/pylon-api/CHANGELOG.md index 7712cc6..ac3be88 100644 --- a/pylon-api/CHANGELOG.md +++ b/pylon-api/CHANGELOG.md @@ -1,5 +1,15 @@ # Changelog +## [1.2.0] - 2026-09-20 + +### Added +- `list_email_suppressions` for `GET /email-suppressions` with optional `email`, `cursor`, and `limit` query parameters. Pass `email` to check a single address; an empty collection means it is not suppressed. +- `delete_email_suppression` for `DELETE /email-suppressions/{id}` +- `Pylon::Models::EmailSuppression` model (`id`, `email`, `reason`, `created_at`, `bounce_details`) + +### Changed +- Documentation only: README examples now use `body_html` when creating issues and note the 365-day window for `list_issues`. No existing method signatures, paths, or return types changed. + ## [1.1.1] - 2025-04-18 ### Fixed diff --git a/pylon-api/README.md b/pylon-api/README.md index 740b895..f57b256 100644 --- a/pylon-api/README.md +++ b/pylon-api/README.md @@ -55,25 +55,35 @@ account, response = client.get_account('account_id') #### Issues ```ruby -# List issues (requires time range, max 30 days) +# List issues (requires time range, max 365 days) start_time = Time.now.utc - 86400 # 24 hours ago end_time = Time.now.utc -issues, response = client.list_issues( +issues = client.list_issues( start_time: start_time.strftime("%Y-%m-%dT%H:%M:%SZ"), end_time: end_time.strftime("%Y-%m-%dT%H:%M:%SZ"), page: 1, - per_page: 20, - status: 'open' # optional filter + per_page: 20 ) -# Create an issue -issue, response = client.create_issue( +# Create an issue (title and body_html are required by the API) +issue = client.create_issue( title: 'New Issue', - description: 'Issue description' + body_html: '

Issue description

', + requester_email: 'customer@example.com' ) ``` +#### Email Suppressions + +```ruby +# Check whether a single address is suppressed (empty collection means not suppressed) +suppressions = client.list_email_suppressions(email: 'user@example.com') + +# Remove a suppression +client.delete_email_suppression(suppressions.first.id) unless suppressions.size.zero? +``` + #### Teams ```ruby diff --git a/pylon-api/lib/pylon.rb b/pylon-api/lib/pylon.rb index ac47cff..ae7846f 100644 --- a/pylon-api/lib/pylon.rb +++ b/pylon-api/lib/pylon.rb @@ -17,6 +17,7 @@ require_relative "pylon/models/contact" require_relative "pylon/models/ticket_form" require_relative "pylon/models/article" +require_relative "pylon/models/email_suppression" require_relative "pylon/client" module Pylon diff --git a/pylon-api/lib/pylon/client.rb b/pylon-api/lib/pylon/client.rb index 056a538..6bf0779 100644 --- a/pylon-api/lib/pylon/client.rb +++ b/pylon-api/lib/pylon/client.rb @@ -152,7 +152,30 @@ def create_custom_field(params) post("/custom_fields", body: params) end - # Lists issues within a specified time range (max 30 days) + # List email suppressions, most recently suppressed first + # + # Pass +email+ to check a single address. An empty collection means the + # address is not suppressed. Uses the API's cursor-based pagination. + # + # @param email [String, nil] Only return the suppression for this address, if one exists + # @param cursor [String, nil] Cursor for the next page of results + # @param limit [Integer, nil] Number of suppressions to fetch (API default 100, max 1000) + # @return [Models::Collection] Collection of email suppression objects + def list_email_suppressions(email: nil, cursor: nil, limit: nil) + query = { email: email, cursor: cursor, limit: limit }.compact + get("/email-suppressions", query: query, + model_class: Models::EmailSuppression, collection: true) + end + + # Remove an email suppression so Pylon resumes sending to the address + # + # @param suppression_id [String] The ID of the email suppression to remove + # @return [Array(Hash, Faraday::Response)] Response data and raw response + def delete_email_suppression(suppression_id) + delete("/email-suppressions/#{suppression_id}") + end + + # Lists issues within a specified time range (max 365 days) # # @param start_time [String] Start time in RFC3339 format # @param end_time [String] End time in RFC3339 format diff --git a/pylon-api/lib/pylon/models/email_suppression.rb b/pylon-api/lib/pylon/models/email_suppression.rb new file mode 100644 index 0000000..37b7cba --- /dev/null +++ b/pylon-api/lib/pylon/models/email_suppression.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +module Pylon + module Models + # An email address Pylon will not send to, either because a message + # hard bounced or because someone suppressed it manually. + # + # Attributes: id, email, reason ("hard_bounce" or "manual"), + # created_at (RFC3339), bounce_details + class EmailSuppression < Base + end + end +end diff --git a/pylon-api/lib/pylon/version.rb b/pylon-api/lib/pylon/version.rb index 8c98661..11004f1 100644 --- a/pylon-api/lib/pylon/version.rb +++ b/pylon-api/lib/pylon/version.rb @@ -4,9 +4,9 @@ module Pylon # Major version for breaking changes MAJOR = 1 # Minor version for new features - MINOR = 1 + MINOR = 2 # Patch version for bug fixes - PATCH = 1 + PATCH = 0 # Pre-release version (optional) PRE = nil diff --git a/pylon-api/spec/pylon/client_spec.rb b/pylon-api/spec/pylon/client_spec.rb index d57206b..c64da79 100644 --- a/pylon-api/spec/pylon/client_spec.rb +++ b/pylon-api/spec/pylon/client_spec.rb @@ -19,7 +19,7 @@ def stub_pylon_request(method, path, response_body: {}, status: 200, query: {}, "Accept" => "application/json", "Accept-Encoding" => "gzip;q=1.0,deflate;q=0.6,identity;q=0.3", "Content-Type" => "application/json", - "User-Agent" => "Faraday v1.10.4", + "User-Agent" => "Faraday v#{Faraday::VERSION}", "Authorization" => "Bearer test_api_key" } @@ -274,6 +274,108 @@ def stub_pylon_request(method, path, response_body: {}, status: 200, query: {}, end end + describe "email suppressions" do + describe "#list_email_suppressions" do + let(:email) { "bounced@example.com" } + let(:suppression_data) do + [{ + "id" => "sup_1", + "email" => email, + "reason" => "hard_bounce", + "created_at" => "2026-09-01T12:00:00Z", + "bounce_details" => "550 5.1.1 The email account does not exist" + }] + end + + context "when checking a single address" do + before do + stub_pylon_request(:get, "/email-suppressions", + response_body: { "data" => suppression_data, + "pagination" => { "cursor" => "", "has_next_page" => false } }, + query: { email: email }, + headers: auth_headers.merge(rate_limit_headers)) + end + + # rubocop:disable RSpec/ExampleLength + it "returns a collection of email suppressions" do + suppressions = client.list_email_suppressions(email: email) + expect(suppressions).to be_a(Pylon::Models::Collection) + expect(suppressions.size).to eq(1) + expect(suppressions._response.headers["x-rate-limit-remaining"]).to eq("99") + + suppression = suppressions[0] + expect(suppression).to be_a(Pylon::Models::EmailSuppression) + expect(suppression.id).to eq("sup_1") + expect(suppression.email).to eq(email) + expect(suppression.reason).to eq("hard_bounce") + expect(suppression.created_at).to eq("2026-09-01T12:00:00Z") + expect(suppression.bounce_details).to eq("550 5.1.1 The email account does not exist") + end + # rubocop:enable RSpec/ExampleLength + end + + context "when the address is not suppressed" do + before do + stub_pylon_request(:get, "/email-suppressions", + response_body: { "data" => [], + "pagination" => { "cursor" => "", "has_next_page" => false } }, + query: { email: email }, + headers: auth_headers.merge(rate_limit_headers)) + end + + it "returns an empty collection" do + suppressions = client.list_email_suppressions(email: email) + expect(suppressions).to be_a(Pylon::Models::Collection) + expect(suppressions.size).to eq(0) + end + end + + context "when listing without filters" do + before do + stub_pylon_request(:get, "/email-suppressions", + response_body: { "data" => suppression_data }, + headers: auth_headers.merge(rate_limit_headers)) + end + + it "omits nil query parameters" do + suppressions = client.list_email_suppressions + expect(suppressions.size).to eq(1) + expect(a_request(:get, "https://api.usepylon.com/email-suppressions")).to have_been_made.once + end + end + + context "when paginating with cursor and limit" do + before do + stub_pylon_request(:get, "/email-suppressions", + response_body: { "data" => suppression_data }, + query: { cursor: "abc123", limit: 50 }, + headers: auth_headers.merge(rate_limit_headers)) + end + + it "sends cursor and limit as query parameters" do + suppressions = client.list_email_suppressions(cursor: "abc123", limit: 50) + expect(suppressions.size).to eq(1) + end + end + end + + describe "#delete_email_suppression" do + let(:suppression_id) { "sup_1" } + + before do + stub_pylon_request(:delete, "/email-suppressions/#{suppression_id}", + response_body: { "request_id" => "req_1" }, + headers: auth_headers.merge(rate_limit_headers)) + end + + it "deletes the suppression and returns the response data" do + data, response = client.delete_email_suppression(suppression_id) + expect(data).to eq({ "request_id" => "req_1" }) + expect(response.status).to eq(200) + end + end + end + describe "issues" do describe "#list_issues" do let(:start_time) { "2024-03-14T00:00:00Z" } From cbf57c1b68fba76f5b3bb4af6c40da5c974b4f13 Mon Sep 17 00:00:00 2001 From: Ben Odom Date: Sun, 20 Sep 2026 13:31:09 -0500 Subject: [PATCH 2/2] rubocop ci fixes --- pylon-api/.rubocop.yml | 8 +------- pylon-api/Gemfile | 4 ++-- 2 files changed, 3 insertions(+), 9 deletions(-) diff --git a/pylon-api/.rubocop.yml b/pylon-api/.rubocop.yml index 61389f5..42095fc 100644 --- a/pylon-api/.rubocop.yml +++ b/pylon-api/.rubocop.yml @@ -1,4 +1,4 @@ -require: +plugins: - rubocop-rake - rubocop-rspec @@ -73,12 +73,6 @@ RSpec/NestedGroups: RSpec/PredicateMatcher: Enabled: false -RSpec/Capybara: - Enabled: false - -RSpec/FactoryBot: - Enabled: false - RSpec/MultipleExpectations: Enabled: false diff --git a/pylon-api/Gemfile b/pylon-api/Gemfile index 82c3347..3211e97 100644 --- a/pylon-api/Gemfile +++ b/pylon-api/Gemfile @@ -7,6 +7,6 @@ gem "faraday", "~> 1.10.4" gem "json", "~> 2.0" group :development do - gem "rubocop-rake", "~> 0.6.0" - gem "rubocop-rspec", "~> 2.26.1" + gem "rubocop-rake", "~> 0.7" + gem "rubocop-rspec", "~> 3.0" end