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.
@@ -0,0 +1,126 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "base64"
4
+
5
+ module PostmarkClient
6
+ # Represents an email attachment for the Postmark API.
7
+ #
8
+ # @example Creating a simple attachment
9
+ # attachment = PostmarkClient::Attachment.new(
10
+ # name: "document.pdf",
11
+ # content: File.read("document.pdf"),
12
+ # content_type: "application/pdf"
13
+ # )
14
+ #
15
+ # @example Creating an inline image attachment
16
+ # attachment = PostmarkClient::Attachment.new(
17
+ # name: "logo.png",
18
+ # content: File.read("logo.png"),
19
+ # content_type: "image/png",
20
+ # content_id: "cid:logo.png"
21
+ # )
22
+ #
23
+ # @example Creating from a file path
24
+ # attachment = PostmarkClient::Attachment.from_file("path/to/file.pdf")
25
+ class Attachment
26
+ # @return [String] the filename of the attachment
27
+ attr_accessor :name
28
+
29
+ # @return [String] the Base64-encoded content of the attachment
30
+ attr_accessor :content
31
+
32
+ # @return [String] the MIME type of the attachment
33
+ attr_accessor :content_type
34
+
35
+ # @return [String, nil] the content ID for inline attachments
36
+ attr_accessor :content_id
37
+
38
+ # Initialize a new attachment
39
+ #
40
+ # @param name [String] the filename
41
+ # @param content [String] the file content (will be Base64 encoded if not already)
42
+ # @param content_type [String] the MIME type
43
+ # @param content_id [String, nil] optional content ID for inline images
44
+ # @param base64_encoded [Boolean] whether content is already Base64 encoded
45
+ def initialize(name:, content:, content_type:, content_id: nil, base64_encoded: false)
46
+ @name = name
47
+ @content = base64_encoded ? content : Base64.strict_encode64(content)
48
+ @content_type = content_type
49
+ @content_id = content_id
50
+ end
51
+
52
+ # Create an attachment from a file path
53
+ #
54
+ # @param file_path [String] path to the file
55
+ # @param content_type [String, nil] optional MIME type (auto-detected if not provided)
56
+ # @param content_id [String, nil] optional content ID for inline images
57
+ # @return [Attachment] a new attachment instance
58
+ def self.from_file(file_path, content_type: nil, content_id: nil)
59
+ name = File.basename(file_path)
60
+ content = File.binread(file_path)
61
+ detected_content_type = content_type || detect_content_type(name)
62
+
63
+ new(
64
+ name: name,
65
+ content: content,
66
+ content_type: detected_content_type,
67
+ content_id: content_id
68
+ )
69
+ end
70
+
71
+ # Convert the attachment to a hash for API requests
72
+ #
73
+ # @return [Hash] the attachment as a hash
74
+ def to_h
75
+ hash = {
76
+ "Name" => name,
77
+ "Content" => content,
78
+ "ContentType" => content_type
79
+ }
80
+ hash["ContentID"] = content_id if content_id
81
+ hash
82
+ end
83
+
84
+ # Check if this is an inline attachment
85
+ #
86
+ # @return [Boolean] true if this is an inline attachment
87
+ def inline?
88
+ !content_id.nil?
89
+ end
90
+
91
+ private
92
+
93
+ # Detect content type from file extension
94
+ #
95
+ # @param filename [String] the filename
96
+ # @return [String] the detected MIME type
97
+ def self.detect_content_type(filename)
98
+ extension = File.extname(filename).downcase.delete(".")
99
+
100
+ CONTENT_TYPES.fetch(extension, "application/octet-stream")
101
+ end
102
+
103
+ # Common content types by file extension
104
+ CONTENT_TYPES = {
105
+ "pdf" => "application/pdf",
106
+ "doc" => "application/msword",
107
+ "docx" => "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
108
+ "xls" => "application/vnd.ms-excel",
109
+ "xlsx" => "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
110
+ "png" => "image/png",
111
+ "jpg" => "image/jpeg",
112
+ "jpeg" => "image/jpeg",
113
+ "gif" => "image/gif",
114
+ "svg" => "image/svg+xml",
115
+ "txt" => "text/plain",
116
+ "html" => "text/html",
117
+ "htm" => "text/html",
118
+ "css" => "text/css",
119
+ "js" => "application/javascript",
120
+ "json" => "application/json",
121
+ "xml" => "application/xml",
122
+ "zip" => "application/zip",
123
+ "csv" => "text/csv"
124
+ }.freeze
125
+ end
126
+ end
@@ -0,0 +1,254 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PostmarkClient
4
+ # Represents an email message for the Postmark API.
5
+ # Provides a clean Ruby interface for building email payloads.
6
+ #
7
+ # @example Creating a simple email
8
+ # email = PostmarkClient::Email.new(
9
+ # from: "sender@example.com",
10
+ # to: "recipient@example.com",
11
+ # subject: "Hello!",
12
+ # text_body: "Hello, World!"
13
+ # )
14
+ #
15
+ # @example Creating an email with HTML and attachments
16
+ # email = PostmarkClient::Email.new(
17
+ # from: "John Doe <john@example.com>",
18
+ # to: ["alice@example.com", "bob@example.com"],
19
+ # cc: "manager@example.com",
20
+ # subject: "Monthly Report",
21
+ # html_body: "<h1>Report</h1><p>See attached.</p>",
22
+ # text_body: "Report - See attached.",
23
+ # track_opens: true
24
+ # )
25
+ # email.add_attachment(name: "report.pdf", content: pdf_data, content_type: "application/pdf")
26
+ #
27
+ # @example Using the builder pattern
28
+ # email = PostmarkClient::Email.new
29
+ # .from("sender@example.com")
30
+ # .to("recipient@example.com")
31
+ # .subject("Hello")
32
+ # .text_body("Hello, World!")
33
+ class Email
34
+ # @return [String] sender email address
35
+ attr_accessor :from
36
+
37
+ # @return [String, Array<String>] recipient email address(es)
38
+ attr_accessor :to
39
+
40
+ # @return [String, Array<String>, nil] CC recipient email address(es)
41
+ attr_accessor :cc
42
+
43
+ # @return [String, Array<String>, nil] BCC recipient email address(es)
44
+ attr_accessor :bcc
45
+
46
+ # @return [String] email subject
47
+ attr_accessor :subject
48
+
49
+ # @return [String, nil] HTML email body
50
+ attr_accessor :html_body
51
+
52
+ # @return [String, nil] plain text email body
53
+ attr_accessor :text_body
54
+
55
+ # @return [String, nil] reply-to email address
56
+ attr_accessor :reply_to
57
+
58
+ # @return [String, nil] email tag for categorization
59
+ attr_accessor :tag
60
+
61
+ # @return [Array<Hash>] custom email headers
62
+ attr_accessor :headers
63
+
64
+ # @return [Boolean] whether to track email opens
65
+ attr_accessor :track_opens
66
+
67
+ # @return [String] link tracking setting ("None", "HtmlAndText", "HtmlOnly", "TextOnly")
68
+ attr_accessor :track_links
69
+
70
+ # @return [Array<Attachment>] email attachments
71
+ attr_accessor :attachments
72
+
73
+ # @return [Hash] custom metadata key-value pairs
74
+ attr_accessor :metadata
75
+
76
+ # @return [String] message stream identifier
77
+ attr_accessor :message_stream
78
+
79
+ # Valid link tracking options
80
+ TRACK_LINKS_OPTIONS = %w[None HtmlAndText HtmlOnly TextOnly].freeze
81
+
82
+ # Initialize a new email
83
+ #
84
+ # @param from [String, nil] sender email address
85
+ # @param to [String, Array<String>, nil] recipient email address(es)
86
+ # @param cc [String, Array<String>, nil] CC recipient(s)
87
+ # @param bcc [String, Array<String>, nil] BCC recipient(s)
88
+ # @param subject [String, nil] email subject
89
+ # @param html_body [String, nil] HTML body
90
+ # @param text_body [String, nil] plain text body
91
+ # @param reply_to [String, nil] reply-to address
92
+ # @param tag [String, nil] email tag
93
+ # @param headers [Array<Hash>, nil] custom headers
94
+ # @param track_opens [Boolean, nil] track opens
95
+ # @param track_links [String, nil] link tracking setting
96
+ # @param attachments [Array<Attachment>, nil] attachments
97
+ # @param metadata [Hash, nil] custom metadata
98
+ # @param message_stream [String, nil] message stream
99
+ def initialize(
100
+ from: nil,
101
+ to: nil,
102
+ cc: nil,
103
+ bcc: nil,
104
+ subject: nil,
105
+ html_body: nil,
106
+ text_body: nil,
107
+ reply_to: nil,
108
+ tag: nil,
109
+ headers: nil,
110
+ track_opens: nil,
111
+ track_links: nil,
112
+ attachments: nil,
113
+ metadata: nil,
114
+ message_stream: nil
115
+ )
116
+ @from = from
117
+ @to = to
118
+ @cc = cc
119
+ @bcc = bcc
120
+ @subject = subject
121
+ @html_body = html_body
122
+ @text_body = text_body
123
+ @reply_to = reply_to
124
+ @tag = tag
125
+ @headers = headers || []
126
+ @track_opens = track_opens
127
+ @track_links = track_links
128
+ @attachments = attachments || []
129
+ @metadata = metadata || {}
130
+ @message_stream = message_stream || PostmarkClient.configuration.default_message_stream
131
+ end
132
+
133
+ # Add a custom header to the email
134
+ #
135
+ # @param name [String] header name
136
+ # @param value [String] header value
137
+ # @return [self] returns self for method chaining
138
+ def add_header(name:, value:)
139
+ @headers << { "Name" => name, "Value" => value }
140
+ self
141
+ end
142
+
143
+ # Add an attachment to the email
144
+ #
145
+ # @param attachment [Attachment] an Attachment instance
146
+ # @return [self] returns self for method chaining
147
+ #
148
+ # @overload add_attachment(attachment)
149
+ # @param attachment [Attachment] an Attachment instance
150
+ #
151
+ # @overload add_attachment(name:, content:, content_type:, content_id: nil)
152
+ # @param name [String] filename
153
+ # @param content [String] file content
154
+ # @param content_type [String] MIME type
155
+ # @param content_id [String, nil] content ID for inline attachments
156
+ def add_attachment(attachment = nil, **kwargs)
157
+ if attachment.is_a?(Attachment)
158
+ @attachments << attachment
159
+ elsif kwargs.any?
160
+ @attachments << Attachment.new(**kwargs)
161
+ else
162
+ raise ArgumentError, "Must provide an Attachment instance or attachment parameters"
163
+ end
164
+ self
165
+ end
166
+
167
+ # Add a file as an attachment
168
+ #
169
+ # @param file_path [String] path to the file
170
+ # @param content_type [String, nil] optional MIME type
171
+ # @param content_id [String, nil] optional content ID
172
+ # @return [self] returns self for method chaining
173
+ def attach_file(file_path, content_type: nil, content_id: nil)
174
+ @attachments << Attachment.from_file(file_path, content_type: content_type, content_id: content_id)
175
+ self
176
+ end
177
+
178
+ # Add metadata to the email
179
+ #
180
+ # @param key [String, Symbol] metadata key
181
+ # @param value [String] metadata value
182
+ # @return [self] returns self for method chaining
183
+ def add_metadata(key, value)
184
+ @metadata[key.to_s] = value
185
+ self
186
+ end
187
+
188
+ # Validate the email before sending
189
+ #
190
+ # @return [Boolean] true if valid
191
+ # @raise [ValidationError] if validation fails
192
+ def validate!
193
+ raise ValidationError, "From address is required" if from.nil? || from.empty?
194
+ raise ValidationError, "To address is required" if to.nil? || (to.is_a?(Array) && to.empty?) || (to.is_a?(String) && to.empty?)
195
+ raise ValidationError, "Either HtmlBody or TextBody is required" if (html_body.nil? || html_body.empty?) && (text_body.nil? || text_body.empty?)
196
+
197
+ if track_links && !TRACK_LINKS_OPTIONS.include?(track_links)
198
+ raise ValidationError, "TrackLinks must be one of: #{TRACK_LINKS_OPTIONS.join(', ')}"
199
+ end
200
+
201
+ true
202
+ end
203
+
204
+ # Check if the email is valid
205
+ #
206
+ # @return [Boolean] true if valid, false otherwise
207
+ def valid?
208
+ validate!
209
+ true
210
+ rescue ValidationError
211
+ false
212
+ end
213
+
214
+ # Convert the email to a hash for API requests
215
+ #
216
+ # @return [Hash] the email as a hash matching Postmark API format
217
+ def to_h
218
+ hash = {}
219
+
220
+ hash["From"] = from if from
221
+ hash["To"] = normalize_recipients(to) if to
222
+ hash["Cc"] = normalize_recipients(cc) if cc
223
+ hash["Bcc"] = normalize_recipients(bcc) if bcc
224
+ hash["Subject"] = subject if subject
225
+ hash["HtmlBody"] = html_body if html_body
226
+ hash["TextBody"] = text_body if text_body
227
+ hash["ReplyTo"] = reply_to if reply_to
228
+ hash["Tag"] = tag if tag
229
+ hash["Headers"] = headers if headers.any?
230
+ hash["TrackOpens"] = track_opens unless track_opens.nil?
231
+ hash["TrackLinks"] = track_links if track_links
232
+ hash["Attachments"] = attachments.map(&:to_h) if attachments.any?
233
+ hash["Metadata"] = metadata if metadata.any?
234
+ hash["MessageStream"] = message_stream if message_stream
235
+
236
+ hash
237
+ end
238
+
239
+ # Alias for to_h
240
+ alias_method :to_api_hash, :to_h
241
+
242
+ private
243
+
244
+ # Normalize recipients to comma-separated string
245
+ #
246
+ # @param recipients [String, Array<String>] recipient(s)
247
+ # @return [String] comma-separated recipients
248
+ def normalize_recipients(recipients)
249
+ return recipients if recipients.is_a?(String)
250
+
251
+ recipients.join(", ")
252
+ end
253
+ end
254
+ end
@@ -0,0 +1,88 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PostmarkClient
4
+ # Represents a response from the Postmark Email API.
5
+ # Wraps the API response in a convenient Ruby object.
6
+ #
7
+ # @example Handling a successful response
8
+ # response = client.send_email(email)
9
+ # puts "Message ID: #{response.message_id}"
10
+ # puts "Submitted at: #{response.submitted_at}"
11
+ #
12
+ # @example Checking for errors
13
+ # if response.success?
14
+ # puts "Email sent successfully!"
15
+ # else
16
+ # puts "Error: #{response.message}"
17
+ # end
18
+ class EmailResponse
19
+ # @return [String] recipient email address
20
+ attr_reader :to
21
+
22
+ # @return [Time] timestamp when the email was submitted
23
+ attr_reader :submitted_at
24
+
25
+ # @return [String] unique message identifier
26
+ attr_reader :message_id
27
+
28
+ # @return [Integer] error code (0 means success)
29
+ attr_reader :error_code
30
+
31
+ # @return [String] response message
32
+ attr_reader :message
33
+
34
+ # @return [Hash] raw response data
35
+ attr_reader :raw_response
36
+
37
+ # Initialize from API response hash
38
+ #
39
+ # @param response [Hash] the API response
40
+ def initialize(response)
41
+ @raw_response = response
42
+ @to = response["To"]
43
+ @submitted_at = parse_timestamp(response["SubmittedAt"])
44
+ @message_id = response["MessageID"]
45
+ @error_code = response["ErrorCode"]
46
+ @message = response["Message"]
47
+ end
48
+
49
+ # Check if the email was sent successfully
50
+ #
51
+ # @return [Boolean] true if error_code is 0
52
+ def success?
53
+ error_code == 0
54
+ end
55
+
56
+ # Check if there was an error
57
+ #
58
+ # @return [Boolean] true if error_code is not 0
59
+ def error?
60
+ !success?
61
+ end
62
+
63
+ # Get a string representation of the response
64
+ #
65
+ # @return [String] human-readable response summary
66
+ def to_s
67
+ if success?
68
+ "Email sent to #{to} (Message ID: #{message_id})"
69
+ else
70
+ "Error #{error_code}: #{message}"
71
+ end
72
+ end
73
+
74
+ private
75
+
76
+ # Parse ISO 8601 timestamp string to Time object
77
+ #
78
+ # @param timestamp [String, nil] the timestamp string
79
+ # @return [Time, nil] parsed Time object
80
+ def parse_timestamp(timestamp)
81
+ return nil if timestamp.nil?
82
+
83
+ Time.parse(timestamp)
84
+ rescue ArgumentError
85
+ nil
86
+ end
87
+ end
88
+ end
@@ -0,0 +1,159 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PostmarkClient
4
+ module Resources
5
+ # Email resource client for sending emails via the Postmark API.
6
+ #
7
+ # @example Sending a simple email
8
+ # emails = PostmarkClient::Resources::Emails.new
9
+ # response = emails.send_email(
10
+ # from: "sender@example.com",
11
+ # to: "recipient@example.com",
12
+ # subject: "Hello!",
13
+ # text_body: "Hello, World!"
14
+ # )
15
+ #
16
+ # @example Sending an email with an Email model
17
+ # email = PostmarkClient::Email.new(
18
+ # from: "sender@example.com",
19
+ # to: "recipient@example.com",
20
+ # subject: "Hello!",
21
+ # html_body: "<h1>Hello, World!</h1>"
22
+ # )
23
+ # response = emails.send(email)
24
+ #
25
+ # @example Sending with a custom API token
26
+ # emails = PostmarkClient::Resources::Emails.new(api_token: "my-token")
27
+ # response = emails.send(email)
28
+ class Emails < Client::Base
29
+ # Send a single email
30
+ #
31
+ # @param email [Email, Hash] the email to send
32
+ # @return [EmailResponse] the API response
33
+ #
34
+ # @raise [ValidationError] if the email is invalid
35
+ # @raise [ApiError] if the API returns an error
36
+ #
37
+ # @example With Email model
38
+ # response = emails.send(email)
39
+ #
40
+ # @example With hash
41
+ # response = emails.send({
42
+ # from: "sender@example.com",
43
+ # to: "recipient@example.com",
44
+ # subject: "Test",
45
+ # text_body: "Hello"
46
+ # })
47
+ def send(email)
48
+ email = normalize_email(email)
49
+ email.validate!
50
+
51
+ response = post("/email", email.to_h)
52
+ EmailResponse.new(response)
53
+ end
54
+
55
+ # Convenience method to send an email with parameters
56
+ #
57
+ # @param from [String] sender email address
58
+ # @param to [String, Array<String>] recipient email address(es)
59
+ # @param subject [String] email subject
60
+ # @param kwargs [Hash] additional email options
61
+ # @option kwargs [String] :html_body HTML email body
62
+ # @option kwargs [String] :text_body plain text email body
63
+ # @option kwargs [String] :cc CC recipients
64
+ # @option kwargs [String] :bcc BCC recipients
65
+ # @option kwargs [String] :reply_to reply-to address
66
+ # @option kwargs [String] :tag email tag
67
+ # @option kwargs [Boolean] :track_opens whether to track opens
68
+ # @option kwargs [String] :track_links link tracking setting
69
+ # @option kwargs [Hash] :metadata custom metadata
70
+ # @option kwargs [String] :message_stream message stream
71
+ # @return [EmailResponse] the API response
72
+ #
73
+ # @example
74
+ # response = emails.send_email(
75
+ # from: "sender@example.com",
76
+ # to: "recipient@example.com",
77
+ # subject: "Hello!",
78
+ # text_body: "Hello, World!",
79
+ # track_opens: true
80
+ # )
81
+ def send_email(from:, to:, subject:, **kwargs)
82
+ email = Email.new(
83
+ from: from,
84
+ to: to,
85
+ subject: subject,
86
+ **kwargs
87
+ )
88
+ send(email)
89
+ end
90
+
91
+ # Send a batch of emails (up to 500)
92
+ #
93
+ # @param emails [Array<Email, Hash>] array of emails to send
94
+ # @return [Array<EmailResponse>] array of API responses
95
+ #
96
+ # @raise [ValidationError] if any email is invalid
97
+ # @raise [ApiError] if the API returns an error
98
+ # @raise [ArgumentError] if batch exceeds 500 emails
99
+ #
100
+ # @example
101
+ # emails_to_send = [
102
+ # { from: "a@example.com", to: "b@example.com", subject: "Hi", text_body: "Hello" },
103
+ # { from: "a@example.com", to: "c@example.com", subject: "Hi", text_body: "Hello" }
104
+ # ]
105
+ # responses = client.send_batch(emails_to_send)
106
+ def send_batch(emails)
107
+ raise ArgumentError, "Batch cannot exceed 500 emails" if emails.length > 500
108
+
109
+ normalized = emails.map { |e| normalize_email(e) }
110
+ normalized.each(&:validate!)
111
+
112
+ payload = normalized.map(&:to_h)
113
+ responses = post("/email/batch", payload)
114
+
115
+ responses.map { |r| EmailResponse.new(r) }
116
+ end
117
+
118
+ private
119
+
120
+ # Normalize email input to Email model
121
+ #
122
+ # @param email [Email, Hash] the email input
123
+ # @return [Email] normalized email model
124
+ def normalize_email(email)
125
+ return email if email.is_a?(Email)
126
+
127
+ Email.new(**symbolize_keys(email))
128
+ end
129
+
130
+ # Convert hash keys to symbols
131
+ #
132
+ # @param hash [Hash] hash with string or symbol keys
133
+ # @return [Hash] hash with symbol keys
134
+ def symbolize_keys(hash)
135
+ hash.transform_keys do |key|
136
+ case key
137
+ when "From", :From then :from
138
+ when "To", :To then :to
139
+ when "Cc", :Cc then :cc
140
+ when "Bcc", :Bcc then :bcc
141
+ when "Subject", :Subject then :subject
142
+ when "HtmlBody", :HtmlBody then :html_body
143
+ when "TextBody", :TextBody then :text_body
144
+ when "ReplyTo", :ReplyTo then :reply_to
145
+ when "Tag", :Tag then :tag
146
+ when "Headers", :Headers then :headers
147
+ when "TrackOpens", :TrackOpens then :track_opens
148
+ when "TrackLinks", :TrackLinks then :track_links
149
+ when "Attachments", :Attachments then :attachments
150
+ when "Metadata", :Metadata then :metadata
151
+ when "MessageStream", :MessageStream then :message_stream
152
+ else
153
+ key.to_s.gsub(/([A-Z])/, '_\1').downcase.delete_prefix("_").to_sym
154
+ end
155
+ end
156
+ end
157
+ end
158
+ end
159
+ end
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PostmarkClient
4
+ # Current version of the PostmarkClient gem
5
+ # @return [String] the semantic version number
6
+ VERSION = "0.1.0"
7
+ end