Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
37 changes: 31 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -81,27 +81,27 @@ 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

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: '<p>Issue description</p>',
requester_email: 'customer@example.com'
)

# Access issue properties directly
Expand Down Expand Up @@ -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:
Expand Down
8 changes: 1 addition & 7 deletions pylon-api/.rubocop.yml
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
require:
plugins:
- rubocop-rake
- rubocop-rspec

Expand Down Expand Up @@ -73,12 +73,6 @@ RSpec/NestedGroups:
RSpec/PredicateMatcher:
Enabled: false

RSpec/Capybara:
Enabled: false

RSpec/FactoryBot:
Enabled: false

RSpec/MultipleExpectations:
Enabled: false

Expand Down
10 changes: 10 additions & 0 deletions pylon-api/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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
Expand Down
4 changes: 2 additions & 2 deletions pylon-api/Gemfile
Original file line number Diff line number Diff line change
Expand Up @@ -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
24 changes: 17 additions & 7 deletions pylon-api/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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: '<p>Issue description</p>',
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
Expand Down
1 change: 1 addition & 0 deletions pylon-api/lib/pylon.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
25 changes: 24 additions & 1 deletion pylon-api/lib/pylon/client.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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<Models::EmailSuppression>] 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
Expand Down
13 changes: 13 additions & 0 deletions pylon-api/lib/pylon/models/email_suppression.rb
Original file line number Diff line number Diff line change
@@ -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
4 changes: 2 additions & 2 deletions pylon-api/lib/pylon/version.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
104 changes: 103 additions & 1 deletion pylon-api/spec/pylon/client_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}

Expand Down Expand Up @@ -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" }
Expand Down
Loading