postmark_ruby_client 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 4d157f97b4114e08d81c6c5a7cba8d0e5862c86ecce6cbc520b91764271a8c88
4
+ data.tar.gz: 248c8666681e1f6baf961c0778eae8bb976b8343c5d78a06012e1ab75bdb6cbf
5
+ SHA512:
6
+ metadata.gz: 7c128a5537f655491133f0a5f0c58e037549234a61feeb340e802a296dca3102d17696e387af9b17269277161cf1bae598163afb0ab68b198f0bd7e951cb65cd
7
+ data.tar.gz: 44fcd733e4e2e2935e33d5fb86f3ee6947d37294b6369942a613aa670e77d5d4b362fc83610b31a431873958d7468c07c90516d3148b6607ce9463fa5afc4221
data/.rspec ADDED
@@ -0,0 +1,3 @@
1
+ --format documentation
2
+ --color
3
+ --require spec_helper
data/CHANGELOG.md ADDED
@@ -0,0 +1,28 @@
1
+ # Changelog
2
+
3
+ All notable changes to this project will be documented in this file.
4
+
5
+ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
6
+ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
+
8
+ ## [Unreleased]
9
+
10
+ ## [0.1.0] - 2026-01-15
11
+
12
+ ### Added
13
+
14
+ - Initial release
15
+ - Email API support for sending single emails
16
+ - Email API support for batch sending (up to 500 emails)
17
+ - Email model with full Postmark API field support
18
+ - Attachment support with auto Base64 encoding
19
+ - File attachment helper method
20
+ - Inline attachment support for HTML emails
21
+ - Custom header support
22
+ - Metadata support
23
+ - Link and open tracking configuration
24
+ - Global configuration via initializer
25
+ - Per-request API token override
26
+ - Comprehensive error handling (ValidationError, ApiError, ConnectionError)
27
+ - Full RSpec test suite
28
+ - YARD documentation
data/LICENSE.txt ADDED
@@ -0,0 +1,21 @@
1
+ The MIT License (MIT)
2
+
3
+ Copyright (c) 2026 Alvaro Delgado
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in
13
+ all copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
21
+ THE SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,278 @@
1
+ # postmark_ruby_client
2
+
3
+ A clean, extensible Ruby client for the [Postmark](https://postmarkapp.com) transactional email API. Built with Faraday and designed for Rails 8+ applications.
4
+
5
+ ## Features
6
+
7
+ - **Simple API**: Intuitive Ruby interface for sending emails
8
+ - **Extensible Design**: Base client class makes it easy to add new API endpoints
9
+ - **Full Email Support**: HTML/text bodies, attachments, custom headers, metadata, tracking
10
+ - **Batch Sending**: Send up to 500 emails in a single API call
11
+ - **Type Safety**: Validation before API calls to catch errors early
12
+ - **Configurable**: Global configuration with per-request overrides
13
+
14
+ ## Installation
15
+
16
+ Add this line to your application's Gemfile:
17
+
18
+ ```ruby
19
+ gem 'postmark_ruby_client'
20
+ ```
21
+
22
+ And then execute:
23
+
24
+ ```bash
25
+ bundle install
26
+ ```
27
+
28
+ Or install it yourself:
29
+
30
+ ```bash
31
+ gem install postmark_ruby_client
32
+ ```
33
+
34
+ ## Configuration
35
+
36
+ Configure the gem with your Postmark server API token. In a Rails application, create an initializer:
37
+
38
+ ```ruby
39
+ # config/initializers/postmark_ruby_client.rb
40
+ PostmarkClient.configure do |config|
41
+ config.api_token = ENV["POSTMARK_API_TOKEN"]
42
+
43
+ # Optional settings with defaults
44
+ config.default_message_stream = "outbound" # Default message stream
45
+ config.timeout = 30 # Request timeout in seconds
46
+ config.open_timeout = 10 # Connection timeout in seconds
47
+ config.track_opens = false # Default open tracking
48
+ config.track_links = "None" # Default link tracking
49
+ end
50
+ ```
51
+
52
+ The API token can also be set via the `POSTMARK_API_TOKEN` environment variable.
53
+
54
+ ## Usage
55
+
56
+ ### Sending a Simple Email
57
+
58
+ ```ruby
59
+ # Using the convenience method
60
+ response = PostmarkClient.deliver(
61
+ from: "sender@example.com",
62
+ to: "recipient@example.com",
63
+ subject: "Hello!",
64
+ text_body: "Hello, World!"
65
+ )
66
+
67
+ if response.success?
68
+ puts "Email sent! Message ID: #{response.message_id}"
69
+ else
70
+ puts "Error: #{response.message}"
71
+ end
72
+ ```
73
+
74
+ ### Using the Email Model
75
+
76
+ ```ruby
77
+ email = PostmarkClient::Email.new(
78
+ from: "John Doe <john@example.com>",
79
+ to: ["alice@example.com", "bob@example.com"],
80
+ cc: "manager@example.com",
81
+ bcc: "archive@example.com",
82
+ subject: "Monthly Report",
83
+ html_body: "<h1>Report</h1><p>See attached.</p>",
84
+ text_body: "Report - See attached.",
85
+ reply_to: "support@example.com",
86
+ tag: "monthly-report",
87
+ track_opens: true,
88
+ track_links: "HtmlAndText",
89
+ metadata: { "client_id" => "12345" }
90
+ )
91
+
92
+ client = PostmarkClient::Resources::Emails.new
93
+ response = client.send(email)
94
+ ```
95
+
96
+ ### Adding Attachments
97
+
98
+ ```ruby
99
+ email = PostmarkClient::Email.new(
100
+ from: "sender@example.com",
101
+ to: "recipient@example.com",
102
+ subject: "Files attached",
103
+ text_body: "Please see the attached files."
104
+ )
105
+
106
+ # Add attachment from parameters
107
+ email.add_attachment(
108
+ name: "document.pdf",
109
+ content: File.binread("path/to/document.pdf"),
110
+ content_type: "application/pdf"
111
+ )
112
+
113
+ # Or attach directly from a file path
114
+ email.attach_file("path/to/image.png")
115
+
116
+ # Inline attachments for HTML emails
117
+ email.html_body = '<p>Logo: <img src="cid:logo.png"/></p>'
118
+ email.add_attachment(
119
+ name: "logo.png",
120
+ content: File.binread("logo.png"),
121
+ content_type: "image/png",
122
+ content_id: "cid:logo.png"
123
+ )
124
+ ```
125
+
126
+ ### Custom Headers
127
+
128
+ ```ruby
129
+ email = PostmarkClient::Email.new(
130
+ from: "sender@example.com",
131
+ to: "recipient@example.com",
132
+ subject: "Custom headers",
133
+ text_body: "Hello"
134
+ )
135
+
136
+ email.add_header(name: "X-Custom-Header", value: "custom-value")
137
+ email.add_header(name: "X-Priority", value: "1")
138
+ ```
139
+
140
+ ### Batch Sending
141
+
142
+ Send up to 500 emails in a single API call:
143
+
144
+ ```ruby
145
+ emails = users.map do |user|
146
+ {
147
+ from: "notifications@example.com",
148
+ to: user.email,
149
+ subject: "Your weekly digest",
150
+ text_body: "Here's what you missed..."
151
+ }
152
+ end
153
+
154
+ client = PostmarkClient::Resources::Emails.new
155
+ responses = client.send_batch(emails)
156
+
157
+ responses.each do |response|
158
+ if response.success?
159
+ puts "Sent to #{response.to}"
160
+ else
161
+ puts "Failed: #{response.message}"
162
+ end
163
+ end
164
+ ```
165
+
166
+ ### Using a Custom API Token
167
+
168
+ Override the global configuration for specific requests:
169
+
170
+ ```ruby
171
+ # For a different Postmark server
172
+ client = PostmarkClient::Resources::Emails.new(api_token: "different-token")
173
+ response = client.send(email)
174
+
175
+ # Or with the convenience method
176
+ client = PostmarkClient.emails(api_token: "different-token")
177
+ response = client.send_email(
178
+ from: "sender@example.com",
179
+ to: "recipient@example.com",
180
+ subject: "Hello",
181
+ text_body: "World"
182
+ )
183
+ ```
184
+
185
+ ### Error Handling
186
+
187
+ ```ruby
188
+ begin
189
+ response = PostmarkClient.deliver(email)
190
+ rescue PostmarkClient::ValidationError => e
191
+ # Email failed local validation before sending
192
+ puts "Validation error: #{e.message}"
193
+ rescue PostmarkClient::ApiError => e
194
+ # Postmark API returned an error
195
+ puts "API error #{e.error_code}: #{e.message}"
196
+ puts "Full response: #{e.response}"
197
+ rescue PostmarkClient::ConnectionError => e
198
+ # Network connectivity issues
199
+ puts "Connection error: #{e.message}"
200
+ rescue PostmarkClient::ConfigurationError => e
201
+ # Missing or invalid configuration
202
+ puts "Configuration error: #{e.message}"
203
+ end
204
+ ```
205
+
206
+ ## API Reference
207
+
208
+ ### PostmarkClient::Email
209
+
210
+ | Attribute | Type | Description |
211
+ |-----------|------|-------------|
212
+ | `from` | String | Sender email (required) |
213
+ | `to` | String/Array | Recipient email(s) (required) |
214
+ | `cc` | String/Array | CC recipient(s) |
215
+ | `bcc` | String/Array | BCC recipient(s) |
216
+ | `subject` | String | Email subject |
217
+ | `html_body` | String | HTML email body |
218
+ | `text_body` | String | Plain text body |
219
+ | `reply_to` | String | Reply-to address |
220
+ | `tag` | String | Email tag for categorization |
221
+ | `headers` | Array | Custom email headers |
222
+ | `track_opens` | Boolean | Enable open tracking |
223
+ | `track_links` | String | Link tracking ("None", "HtmlAndText", "HtmlOnly", "TextOnly") |
224
+ | `attachments` | Array | Email attachments |
225
+ | `metadata` | Hash | Custom metadata key-value pairs |
226
+ | `message_stream` | String | Message stream identifier |
227
+
228
+ ### PostmarkClient::EmailResponse
229
+
230
+ | Method | Returns | Description |
231
+ |--------|---------|-------------|
232
+ | `success?` | Boolean | True if email was sent successfully |
233
+ | `error?` | Boolean | True if there was an error |
234
+ | `message_id` | String | Unique message identifier |
235
+ | `to` | String | Recipient address |
236
+ | `submitted_at` | Time | Timestamp when email was submitted |
237
+ | `error_code` | Integer | Postmark error code (0 = success) |
238
+ | `message` | String | Response message |
239
+ | `raw_response` | Hash | Full API response |
240
+
241
+ ## Development
242
+
243
+ After checking out the repo, run `bin/setup` to install dependencies. Then, run `rake spec` to run the tests.
244
+
245
+ ```bash
246
+ # Install dependencies
247
+ bundle install
248
+
249
+ # Run tests
250
+ bundle exec rspec
251
+
252
+ # Generate documentation
253
+ bundle exec yard doc
254
+ ```
255
+
256
+ # View documentation in browser
257
+ bundle exec yard server
258
+ # Then open http://localhost:8808
259
+
260
+
261
+ ## Contributing
262
+
263
+ Bug reports and pull requests are welcome on GitHub at https://github.com/yourusername/postmark_ruby_client.
264
+
265
+ 1. Fork the repository
266
+ 2. Create your feature branch (`git checkout -b feature/my-new-feature`)
267
+ 3. Commit your changes (`git commit -am 'Add some feature'`)
268
+ 4. Push to the branch (`git push origin feature/my-new-feature`)
269
+ 5. Create a new Pull Request
270
+
271
+ ## License
272
+
273
+ The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
274
+
275
+ ## Related Resources
276
+
277
+ - [Postmark API Documentation](https://postmarkapp.com/developer)
278
+ - [Postmark Email API Reference](https://postmarkapp.com/developer/api/email-api)
data/Rakefile ADDED
@@ -0,0 +1,24 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "bundler/gem_tasks"
4
+ require "rspec/core/rake_task"
5
+
6
+ RSpec::Core::RakeTask.new(:spec)
7
+
8
+ task default: %i[spec]
9
+
10
+ namespace :doc do
11
+ desc "Generate YARD documentation"
12
+ task :yard do
13
+ sh "yard doc --output-dir doc/yard"
14
+ end
15
+ end
16
+
17
+ desc "Open an IRB console with the gem loaded"
18
+ task :console do
19
+ require "irb"
20
+ require_relative "lib/postmark_client"
21
+
22
+ ARGV.clear
23
+ IRB.start
24
+ end
@@ -0,0 +1,163 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "faraday"
4
+ require "json"
5
+
6
+ module PostmarkClient
7
+ module Client
8
+ # Base client class for all Postmark API interactions.
9
+ # Provides common HTTP functionality using Faraday.
10
+ #
11
+ # @example Creating a custom resource client
12
+ # class MyResource < PostmarkClient::Client::Base
13
+ # def fetch(id)
14
+ # get("/my-resource/#{id}")
15
+ # end
16
+ # end
17
+ #
18
+ # @abstract Subclass and implement specific API resource methods
19
+ class Base
20
+ # Postmark API base URL
21
+ API_BASE_URL = "https://api.postmarkapp.com"
22
+
23
+ # @return [String] the API token for authentication
24
+ attr_reader :api_token
25
+
26
+ # @return [Hash] additional options passed to the client
27
+ attr_reader :options
28
+
29
+ # Initialize a new API client
30
+ #
31
+ # @param api_token [String, nil] the Postmark server API token.
32
+ # Falls back to PostmarkClient.configuration.api_token if not provided.
33
+ # @param options [Hash] additional configuration options
34
+ # @option options [Integer] :timeout request timeout in seconds (default: 30)
35
+ # @option options [Integer] :open_timeout connection open timeout in seconds (default: 10)
36
+ # @option options [String] :base_url override the default API base URL
37
+ #
38
+ # @raise [PostmarkClient::ConfigurationError] if no API token is available
39
+ def initialize(api_token: nil, **options)
40
+ @api_token = api_token || PostmarkClient.configuration.api_token
41
+ @options = options
42
+
43
+ raise ConfigurationError, "API token is required" if @api_token.nil? || @api_token.empty?
44
+ end
45
+
46
+ protected
47
+
48
+ # Perform a GET request to the Postmark API
49
+ #
50
+ # @param path [String] the API endpoint path
51
+ # @param params [Hash] query parameters
52
+ # @return [Hash] parsed JSON response
53
+ def get(path, params = {})
54
+ request(:get, path, params)
55
+ end
56
+
57
+ # Perform a POST request to the Postmark API
58
+ #
59
+ # @param path [String] the API endpoint path
60
+ # @param body [Hash] request body
61
+ # @return [Hash] parsed JSON response
62
+ def post(path, body = {})
63
+ request(:post, path, body)
64
+ end
65
+
66
+ # Perform a PUT request to the Postmark API
67
+ #
68
+ # @param path [String] the API endpoint path
69
+ # @param body [Hash] request body
70
+ # @return [Hash] parsed JSON response
71
+ def put(path, body = {})
72
+ request(:put, path, body)
73
+ end
74
+
75
+ # Perform a DELETE request to the Postmark API
76
+ #
77
+ # @param path [String] the API endpoint path
78
+ # @param params [Hash] query parameters
79
+ # @return [Hash] parsed JSON response
80
+ def delete(path, params = {})
81
+ request(:delete, path, params)
82
+ end
83
+
84
+ private
85
+
86
+ # Build and return a configured Faraday connection
87
+ #
88
+ # @return [Faraday::Connection] configured connection instance
89
+ def connection
90
+ @connection ||= Faraday.new(url: base_url) do |conn|
91
+ conn.request :json
92
+ conn.response :json, content_type: /\bjson$/
93
+ conn.response :raise_error
94
+
95
+ conn.headers["Accept"] = "application/json"
96
+ conn.headers["Content-Type"] = "application/json"
97
+ conn.headers["X-Postmark-Server-Token"] = api_token
98
+
99
+ conn.options.timeout = options.fetch(:timeout, 30)
100
+ conn.options.open_timeout = options.fetch(:open_timeout, 10)
101
+
102
+ conn.adapter Faraday.default_adapter
103
+ end
104
+ end
105
+
106
+ # Get the base URL for API requests
107
+ #
108
+ # @return [String] the API base URL
109
+ def base_url
110
+ options.fetch(:base_url, API_BASE_URL)
111
+ end
112
+
113
+ # Perform an HTTP request and handle the response
114
+ #
115
+ # @param method [Symbol] HTTP method (:get, :post, :put, :delete)
116
+ # @param path [String] the API endpoint path
117
+ # @param payload [Hash] request body or query parameters
118
+ # @return [Hash] parsed JSON response
119
+ #
120
+ # @raise [PostmarkClient::ApiError] on API error responses
121
+ # @raise [PostmarkClient::ConnectionError] on network errors
122
+ def request(method, path, payload = {})
123
+ response = case method
124
+ when :get, :delete
125
+ connection.public_send(method, path, payload)
126
+ when :post, :put
127
+ connection.public_send(method, path, payload)
128
+ end
129
+
130
+ response.body
131
+ rescue Faraday::ClientError => e
132
+ handle_client_error(e)
133
+ rescue Faraday::ConnectionFailed, Faraday::TimeoutError => e
134
+ raise ConnectionError, "Connection failed: #{e.message}"
135
+ end
136
+
137
+ # Handle Faraday client errors and raise appropriate exceptions
138
+ #
139
+ # @param error [Faraday::ClientError] the caught error
140
+ # @raise [PostmarkClient::ApiError] with details from the response
141
+ def handle_client_error(error)
142
+ body = parse_error_body(error.response&.dig(:body))
143
+ error_code = body["ErrorCode"] || error.response&.dig(:status)
144
+ message = body["Message"] || error.message
145
+
146
+ raise ApiError.new(message, error_code: error_code, response: body)
147
+ end
148
+
149
+ # Parse error body which may be a string or hash
150
+ #
151
+ # @param body [String, Hash, nil] the response body
152
+ # @return [Hash] parsed body
153
+ def parse_error_body(body)
154
+ return {} if body.nil?
155
+ return body if body.is_a?(Hash)
156
+
157
+ JSON.parse(body)
158
+ rescue JSON::ParserError
159
+ {}
160
+ end
161
+ end
162
+ end
163
+ end
@@ -0,0 +1,79 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PostmarkClient
4
+ # Configuration class for PostmarkClient gem.
5
+ # Stores global settings that apply to all API clients.
6
+ #
7
+ # @example Configuring the gem in a Rails initializer
8
+ # # config/initializers/postmark_ruby_client.rb
9
+ # PostmarkClient.configure do |config|
10
+ # config.api_token = ENV["POSTMARK_API_TOKEN"]
11
+ # config.default_message_stream = "outbound"
12
+ # config.timeout = 60
13
+ # end
14
+ #
15
+ # @example Accessing configuration
16
+ # PostmarkClient.configuration.api_token
17
+ class Configuration
18
+ # @return [String, nil] the Postmark server API token
19
+ attr_accessor :api_token
20
+
21
+ # @return [String] the default message stream for emails (default: "outbound")
22
+ attr_accessor :default_message_stream
23
+
24
+ # @return [Integer] request timeout in seconds (default: 30)
25
+ attr_accessor :timeout
26
+
27
+ # @return [Integer] connection open timeout in seconds (default: 10)
28
+ attr_accessor :open_timeout
29
+
30
+ # @return [Boolean] whether to track email opens by default (default: false)
31
+ attr_accessor :track_opens
32
+
33
+ # @return [String] default link tracking setting (default: "None")
34
+ # Valid values: "None", "HtmlAndText", "HtmlOnly", "TextOnly"
35
+ attr_accessor :track_links
36
+
37
+ # Initialize configuration with default values
38
+ def initialize
39
+ @api_token = ENV.fetch("POSTMARK_API_TOKEN", nil)
40
+ @default_message_stream = "outbound"
41
+ @timeout = 30
42
+ @open_timeout = 10
43
+ @track_opens = false
44
+ @track_links = "None"
45
+ end
46
+ end
47
+
48
+ class << self
49
+ # @return [Configuration] the global configuration instance
50
+ attr_writer :configuration
51
+
52
+ # Get the current configuration, initializing if necessary
53
+ #
54
+ # @return [Configuration] the global configuration instance
55
+ def configuration
56
+ @configuration ||= Configuration.new
57
+ end
58
+
59
+ # Configure the gem using a block
60
+ #
61
+ # @yield [Configuration] the configuration instance
62
+ # @return [void]
63
+ #
64
+ # @example
65
+ # PostmarkClient.configure do |config|
66
+ # config.api_token = "your-token"
67
+ # end
68
+ def configure
69
+ yield(configuration)
70
+ end
71
+
72
+ # Reset the configuration to defaults
73
+ #
74
+ # @return [void]
75
+ def reset_configuration!
76
+ @configuration = Configuration.new
77
+ end
78
+ end
79
+ end
@@ -0,0 +1,52 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PostmarkClient
4
+ # Base error class for all PostmarkClient errors
5
+ class Error < StandardError; end
6
+
7
+ # Raised when configuration is invalid or missing
8
+ #
9
+ # @example
10
+ # raise ConfigurationError, "API token is required"
11
+ class ConfigurationError < Error; end
12
+
13
+ # Raised when there are network connectivity issues
14
+ #
15
+ # @example
16
+ # raise ConnectionError, "Connection timeout"
17
+ class ConnectionError < Error; end
18
+
19
+ # Raised when the Postmark API returns an error response
20
+ #
21
+ # @example Handling API errors
22
+ # begin
23
+ # client.send_email(email)
24
+ # rescue PostmarkClient::ApiError => e
25
+ # puts "Error #{e.error_code}: #{e.message}"
26
+ # puts "Response: #{e.response}"
27
+ # end
28
+ class ApiError < Error
29
+ # @return [Integer, String, nil] the Postmark error code
30
+ attr_reader :error_code
31
+
32
+ # @return [Hash, nil] the full error response body
33
+ attr_reader :response
34
+
35
+ # Initialize a new API error
36
+ #
37
+ # @param message [String] the error message
38
+ # @param error_code [Integer, String, nil] the Postmark error code
39
+ # @param response [Hash, nil] the full response body
40
+ def initialize(message, error_code: nil, response: nil)
41
+ @error_code = error_code
42
+ @response = response
43
+ super(message)
44
+ end
45
+ end
46
+
47
+ # Raised when email validation fails before sending
48
+ #
49
+ # @example
50
+ # raise ValidationError, "From address is required"
51
+ class ValidationError < Error; end
52
+ end