thousandmails 1.0.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,177 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "net/http"
4
+ require "openssl"
5
+ require "uri"
6
+
7
+ module ThousandMails
8
+ # The HTTP layer under ThousandMails::Http.
9
+ #
10
+ # Split out so the retry policy, the URL building and the error mapping can be
11
+ # tested — and swapped — independently of how bytes actually reach the network.
12
+ # Pass your own implementation as `ThousandMails::Client.new(transport:)` to route
13
+ # requests through an existing connection pool, a proxy, or a test double.
14
+ #
15
+ # The bundled NetHttpTransport uses nothing but the standard library, so the
16
+ # gem installs with no dependencies at all.
17
+ module Transports
18
+ # One outbound HTTP call, fully materialised.
19
+ class Request
20
+ attr_reader :http_method, :url, :headers, :body, :timeout_ms
21
+
22
+ def initialize(http_method, url, headers, body = nil, timeout_ms = 0)
23
+ @http_method = http_method
24
+ @url = url
25
+ @headers = headers
26
+ @body = body
27
+ # Milliseconds. 0 disables the timeout, which is what the SSE endpoint needs.
28
+ @timeout_ms = timeout_ms
29
+ end
30
+
31
+ def timeout_seconds
32
+ timeout_ms.positive? ? timeout_ms / 1000.0 : nil
33
+ end
34
+ end
35
+
36
+ # A completed response, body already read.
37
+ class Response
38
+ attr_reader :status, :headers, :body
39
+
40
+ def initialize(status, headers, body)
41
+ @status = status
42
+ # Header names are lower-cased, so lookups never have to guess the casing.
43
+ @headers = headers
44
+ @body = body
45
+ end
46
+
47
+ def ok?
48
+ status >= 200 && status < 300
49
+ end
50
+ end
51
+
52
+ # What Http needs from the network.
53
+ class Transport
54
+ # Perform the call and read the whole body.
55
+ #
56
+ # A non-2xx is a normal return, not an exception — Http decides what a
57
+ # status means. Only a failure to complete the exchange at all raises, and
58
+ # it must raise ThousandMails::ConnectionError (or ThousandMails::TimeoutError).
59
+ def send_request(_request)
60
+ raise NotImplementedError, "#{self.class}#send_request"
61
+ end
62
+
63
+ # Perform the call and hand each chunk of the body to the block as it
64
+ # arrives. Returning false from the block closes the connection.
65
+ #
66
+ # The Node SDK's `open()` hands back a readable body; Ruby's Net::HTTP is
67
+ # push-based, so the consumer arrives as a block instead. EventStream turns
68
+ # that back into a pull-based Enumerator with a Fiber.
69
+ def stream(_request)
70
+ raise NotImplementedError, "#{self.class}#stream"
71
+ end
72
+ end
73
+
74
+ # The default transport: Net::HTTP, no third-party gems.
75
+ class NetHttpTransport < Transport
76
+ # Net::HTTP needs a read timeout even on the event stream, which is idle by
77
+ # design between events. The server sends a `: ping` comment every 25s, so
78
+ # anything past this means the connection is genuinely gone rather than
79
+ # quiet, and reconnecting is the right answer.
80
+ STREAM_READ_TIMEOUT_SECONDS = 60
81
+
82
+ def send_request(request)
83
+ with_session(request) do |session, prepared|
84
+ response = session.request(prepared)
85
+ Response.new(response.code.to_i, headers_of(response), response.body.to_s)
86
+ end
87
+ end
88
+
89
+ def stream(request, &on_chunk)
90
+ with_session(request, read_timeout: STREAM_READ_TIMEOUT_SECONDS) do |session, prepared|
91
+ result = nil
92
+
93
+ begin
94
+ session.request(prepared) do |response|
95
+ status = response.code.to_i
96
+ headers = headers_of(response)
97
+
98
+ # A non-2xx on the stream endpoint carries a normal JSON body, so
99
+ # it is read whole and handed back for Http to turn into a typed
100
+ # error.
101
+ unless status >= 200 && status < 300
102
+ result = Response.new(status, headers, response.read_body.to_s)
103
+ next
104
+ end
105
+
106
+ # Set before reading, because stopping unwinds past this point.
107
+ result = Response.new(status, headers, "")
108
+ response.read_body { |chunk| raise StopStreaming unless on_chunk.call(chunk) }
109
+ end
110
+ rescue StopStreaming
111
+ # The consumer asked to stop.
112
+ #
113
+ # The rescue sits out here, outside `session.request`, on purpose. If
114
+ # the raise were caught inside the block, Net::HTTP would carry on to
115
+ # `HTTPResponse#reading_body`, whose next act is to drain whatever is
116
+ # left of the body so the connection can be reused. On a stream that
117
+ # stays open by design there is no "rest of the body", so it would
118
+ # block until the read timeout — leaving `close` looking like it did
119
+ # nothing and the reader thread parked. Letting the exception unwind
120
+ # past that point closes the socket instead.
121
+ end
122
+
123
+ result
124
+ end
125
+ end
126
+
127
+ private
128
+
129
+ # Raised to break out of Net::HTTP's chunk loop when the consumer is done.
130
+ class StopStreaming < StandardError; end
131
+
132
+ def with_session(request, read_timeout: nil)
133
+ uri = URI.parse(request.url)
134
+ timeout = read_timeout || request.timeout_seconds
135
+
136
+ session = Net::HTTP.new(uri.host, uri.port)
137
+ session.use_ssl = uri.scheme == "https"
138
+ if timeout
139
+ session.open_timeout = timeout
140
+ session.read_timeout = timeout
141
+ session.write_timeout = timeout if session.respond_to?(:write_timeout=)
142
+ end
143
+
144
+ session.start do |started|
145
+ yield(started, build_request(request, uri))
146
+ end
147
+ rescue Net::OpenTimeout, Net::ReadTimeout, Net::WriteTimeout => e
148
+ raise TimeoutError.new("#{request.http_method} #{request.url} timed out", cause: e)
149
+ rescue SocketError, SystemCallError, IOError, OpenSSL::SSL::SSLError, Net::HTTPBadResponse => e
150
+ raise ConnectionError.new("#{request.http_method} #{request.url} failed: #{e.message}", cause: e)
151
+ end
152
+
153
+ def build_request(request, uri)
154
+ klass = case request.http_method.to_s.upcase
155
+ when "GET" then Net::HTTP::Get
156
+ when "POST" then Net::HTTP::Post
157
+ when "PUT" then Net::HTTP::Put
158
+ when "PATCH" then Net::HTTP::Patch
159
+ when "DELETE" then Net::HTTP::Delete
160
+ else raise ConfigError, "Unsupported HTTP method: #{request.http_method}"
161
+ end
162
+
163
+ path = uri.request_uri
164
+ prepared = klass.new(path)
165
+ request.headers.each { |name, value| prepared[name] = value }
166
+ prepared.body = request.body if request.body
167
+ prepared
168
+ end
169
+
170
+ def headers_of(response)
171
+ headers = {}
172
+ response.each_header { |name, value| headers[name.to_s.downcase] = value }
173
+ headers
174
+ end
175
+ end
176
+ end
177
+ end
@@ -0,0 +1,263 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ThousandMails
4
+ # Local pre-flight checks that mirror src/backend/client/services/Sendpipeline.js.
5
+ #
6
+ # They run in the same order the server runs them, so a message rejected here
7
+ # would have been rejected there with the same complaint — the SDK just saves
8
+ # the round trip. Anything the server alone can know (does this sender exist,
9
+ # is the domain verified, is the recipient suppressed) is deliberately left to
10
+ # the API.
11
+ #
12
+ # Pass `validate_input: false` to the client to skip all of it.
13
+ #
14
+ # Message hashes may be keyed with symbols or strings; both are read, and the
15
+ # wire format is always the string keys the API documents.
16
+ module Validate
17
+ # Keys the API understands. Anything else is treated as a typo.
18
+ KNOWN_FIELDS = %w[
19
+ senderemail from to cc bcc subject text html
20
+ templateid templateId templaterequiredfields templateRequiredFields
21
+ attachments
22
+ ].freeze
23
+
24
+ module_function
25
+
26
+ # Validate one message and return it normalised. Attachments are checked
27
+ # separately (ThousandMails::Attachments) because only the multipart endpoints
28
+ # take them.
29
+ def message(input, validate_recipients: true)
30
+ raise InvalidInputError, "A message object is required" unless input.is_a?(Hash) && !input.empty?
31
+
32
+ payload = stringify_keys(input)
33
+
34
+ # `from`, `templateId` and `templateRequiredFields` are accepted as aliases
35
+ # so the SDK reads naturally to anyone coming from another mail library;
36
+ # the wire format stays exactly what the API documents.
37
+ sender = payload["senderemail"] || payload["from"]
38
+ template = payload["templateid"] || payload["templateId"]
39
+ fields = payload["templaterequiredfields"] || payload["templateRequiredFields"]
40
+
41
+ subject = payload["subject"]
42
+ text = payload["text"]
43
+ html = payload["html"]
44
+
45
+ if sender.nil? || sender.to_s.empty?
46
+ raise InvalidInputError.new("senderemail is required", { field: "senderemail" })
47
+ end
48
+
49
+ normalised_sender = sender.to_s.strip.downcase
50
+ unless Constants::EMAIL_PATTERN.match?(normalised_sender)
51
+ raise InvalidInputError.new(
52
+ "senderemail is not a valid email address",
53
+ { field: "senderemail", value: normalised_sender }
54
+ )
55
+ end
56
+
57
+ to = normalise_recipients(payload["to"], "to")
58
+ cc = normalise_recipients(payload["cc"], "cc")
59
+ bcc = normalise_recipients(payload["bcc"], "bcc")
60
+
61
+ raise InvalidInputError.new("to is required", { field: "to" }) if to[:value].nil?
62
+
63
+ if validate_recipients
64
+ assert_addresses(to, "to")
65
+ assert_addresses(cc, "cc")
66
+ assert_addresses(bcc, "bcc")
67
+ end
68
+
69
+ has_template = !template.nil? && !template.to_s.empty?
70
+ has_raw = present?(subject) || present?(text) || present?(html)
71
+
72
+ if has_template && has_raw
73
+ raise InvalidInputError, "Send either templateid or subject/text/html, not both"
74
+ end
75
+
76
+ unless has_template
77
+ raise InvalidInputError.new("subject is required", { field: "subject" }) unless present?(subject)
78
+
79
+ unless present?(text) || present?(html)
80
+ raise InvalidInputError.new("text or html is required", { field: "text" })
81
+ end
82
+
83
+ # The server resolves `{{placeholders}}` in raw bodies too, and 422s when
84
+ # a value is missing. It cannot see inside a stored template from here,
85
+ # so only the raw path is checked locally.
86
+ required = placeholders_in([subject, text, html])
87
+ unless required.empty?
88
+ provided = fields.is_a?(Hash) ? stringify_keys(fields) : {}
89
+ missing = required.reject { |name| present?(provided[name]) }
90
+
91
+ unless missing.empty?
92
+ raise InvalidInputError.new(
93
+ "templaterequiredfields is missing required values",
94
+ { requiredFields: required, missingFields: missing }
95
+ )
96
+ end
97
+ end
98
+ end
99
+
100
+ if !fields.nil? && !fields.is_a?(Hash)
101
+ raise InvalidInputError.new(
102
+ "templaterequiredfields must be an object",
103
+ { field: "templaterequiredfields" }
104
+ )
105
+ end
106
+
107
+ unknown = payload.keys - KNOWN_FIELDS
108
+ unless unknown.empty?
109
+ # The API ignores unknown keys, so a typo like `htlm` would silently send
110
+ # a message with no HTML body. Refusing here is the whole point of
111
+ # pre-flight.
112
+ raise InvalidInputError.new(
113
+ "Unknown field(s): #{unknown.join(', ')}. The API ignores unrecognised " \
114
+ "keys, so this is almost certainly a typo.",
115
+ { fields: unknown }
116
+ )
117
+ end
118
+
119
+ compact(
120
+ "senderemail" => normalised_sender,
121
+ "to" => to[:value],
122
+ "cc" => cc[:value],
123
+ "bcc" => bcc[:value],
124
+ "subject" => subject,
125
+ "text" => text,
126
+ "html" => html,
127
+ "templateid" => has_template ? template : nil,
128
+ "templaterequiredfields" => fields.nil? ? nil : stringify_keys(fields)
129
+ )
130
+ end
131
+
132
+ # Validate a batch and return the normalised messages.
133
+ def batch(messages, multipart: false, **options)
134
+ entries = list_of(messages)
135
+
136
+ raise InvalidInputError, "messages must be a non-empty array" if entries.nil? || entries.empty?
137
+
138
+ maximum = multipart ? Constants::MAX_ATTACHMENT_BATCH : Constants::MAX_BATCH
139
+ if entries.length > maximum
140
+ raise InvalidInputError.new(
141
+ "A #{multipart ? 'multipart ' : ''}batch may contain at most #{maximum} messages",
142
+ { received: entries.length, max: maximum }
143
+ )
144
+ end
145
+
146
+ entries.each_with_index.map do |entry, index|
147
+ begin
148
+ message(entry, **options)
149
+ rescue InvalidInputError => e
150
+ raise e.with_index(index)
151
+ end
152
+ end
153
+ end
154
+
155
+ # Unwrap `[a, b]` or `{ messages: [a, b] }` into a plain array.
156
+ def list_of(messages)
157
+ return messages if messages.is_a?(Array)
158
+
159
+ if messages.is_a?(Hash)
160
+ inner = messages[:messages] || messages["messages"]
161
+ return inner if inner.is_a?(Array)
162
+ end
163
+
164
+ nil
165
+ end
166
+
167
+ # Normalise a recipient field to the shape the API accepts.
168
+ #
169
+ # `:splittable` records whether the caller handed us a bare string. Only then
170
+ # may a comma be read as a separator — once a `{ name:, address: }` entry has
171
+ # been flattened to "Doe, John <j@x.com>" the commas inside it are data.
172
+ def normalise_recipients(value, field)
173
+ return { value: nil, splittable: false } if value.nil? || value == "" || value == []
174
+
175
+ was_list = value.is_a?(Array)
176
+ entries = was_list ? value : [value]
177
+
178
+ cleaned = entries.filter_map do |entry|
179
+ case entry
180
+ when String
181
+ trimmed = entry.strip
182
+ trimmed.empty? ? nil : trimmed
183
+ when Hash
184
+ # { name:, address: } — the shape most mail libraries use.
185
+ record = stringify_keys(entry)
186
+ address = record["address"]
187
+ raise recipient_shape_error(field) if address.nil? || address.to_s.empty?
188
+
189
+ name = record["name"]
190
+ present?(name) ? "#{name} <#{address}>" : address.to_s
191
+ else
192
+ raise recipient_shape_error(field)
193
+ end
194
+ end
195
+
196
+ return { value: nil, splittable: false } if cleaned.empty?
197
+
198
+ # A bare string stays a string on the wire, exactly as it arrived.
199
+ {
200
+ value: was_list ? cleaned : cleaned.first,
201
+ splittable: !was_list && value.is_a?(String)
202
+ }
203
+ end
204
+
205
+ # Strip display names: "Ada <ada@x.com>" => "ada@x.com".
206
+ def bare_address(entry)
207
+ match = /<([^>]+)>/.match(entry.to_s)
208
+ (match ? match[1] : entry.to_s).strip
209
+ end
210
+
211
+ # Placeholder names appearing in any of the given strings, in first-seen order.
212
+ def placeholders_in(parts)
213
+ parts.filter_map { |part| part if part.is_a?(String) && !part.empty? }
214
+ .flat_map { |part| part.scan(Constants::PLACEHOLDER_PATTERN).flatten }
215
+ .uniq
216
+ end
217
+
218
+ # Drop nil values so they never reach the wire.
219
+ def compact(hash)
220
+ hash.reject { |_, value| value.nil? }
221
+ end
222
+
223
+ def stringify_keys(hash)
224
+ hash.each_with_object({}) { |(key, value), out| out[key.to_s] = value }
225
+ end
226
+
227
+ def present?(value)
228
+ !value.nil? && value != ""
229
+ end
230
+
231
+ def recipient_shape_error(field)
232
+ InvalidInputError.new(
233
+ "#{field} entries must be a string or { name:, address: }",
234
+ { field: field }
235
+ )
236
+ end
237
+
238
+ def assert_addresses(recipients, field)
239
+ value = recipients[:value]
240
+ return if value.nil?
241
+
242
+ parts = if value.is_a?(Array)
243
+ value
244
+ elsif recipients[:splittable]
245
+ value.to_s.split(",")
246
+ else
247
+ [value]
248
+ end
249
+
250
+ parts.each do |part|
251
+ address = bare_address(part)
252
+ next if address.empty?
253
+
254
+ unless Constants::EMAIL_PATTERN.match?(address)
255
+ raise InvalidInputError.new(
256
+ "\"#{address}\" in #{field} is not a valid email address",
257
+ { field: field, value: address }
258
+ )
259
+ end
260
+ end
261
+ end
262
+ end
263
+ end
@@ -0,0 +1,6 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ThousandMails
4
+ # The SDK version, sent on the User-Agent.
5
+ VERSION = "1.0.0"
6
+ end
@@ -0,0 +1,36 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Official Ruby SDK for the ThousandMails client API.
4
+ #
5
+ # require "thousandmails"
6
+ #
7
+ # thousandmails = ThousandMails.new(api_key: ENV["THOUSANDMAILS_API_KEY"])
8
+ # thousandmails.send_mail(
9
+ # senderemail: "noreply@yourdomain.com",
10
+ # to: "customer@example.com",
11
+ # subject: "Your receipt",
12
+ # html: "<p>Thanks for your order.</p>"
13
+ # )
14
+
15
+ require_relative "thousandmails/version"
16
+ require_relative "thousandmails/utils/constants"
17
+ require_relative "thousandmails/utils/errors"
18
+ require_relative "thousandmails/utils/options"
19
+ require_relative "thousandmails/utils/transport"
20
+ require_relative "thousandmails/utils/http"
21
+ require_relative "thousandmails/utils/validate"
22
+ require_relative "thousandmails/utils/attachments"
23
+ require_relative "thousandmails/utils/stream"
24
+ require_relative "thousandmails/resources/emails"
25
+ require_relative "thousandmails/resources/stats"
26
+ require_relative "thousandmails/resources/realtime"
27
+ require_relative "thousandmails/resources/logs"
28
+ require_relative "thousandmails/client"
29
+
30
+ module ThousandMails
31
+ # `ThousandMails.new(...)` is `ThousandMails::Client.new(...)`, so the entry point
32
+ # reads the same as it does in the Node, PHP and Python SDKs.
33
+ def self.new(...)
34
+ Client.new(...)
35
+ end
36
+ end
metadata ADDED
@@ -0,0 +1,58 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: thousandmails
3
+ version: !ruby/object:Gem::Version
4
+ version: 1.0.0
5
+ platform: ruby
6
+ authors:
7
+ - Hariraghav.S
8
+ bindir: bin
9
+ cert_chain: []
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
+ dependencies: []
12
+ description: Transactional email, attachments, statistics, delivery logs and a live
13
+ event stream, behind one client object.
14
+ executables: []
15
+ extensions: []
16
+ extra_rdoc_files: []
17
+ files:
18
+ - LICENSE
19
+ - README.md
20
+ - lib/thousandmails.rb
21
+ - lib/thousandmails/client.rb
22
+ - lib/thousandmails/resources/emails.rb
23
+ - lib/thousandmails/resources/logs.rb
24
+ - lib/thousandmails/resources/realtime.rb
25
+ - lib/thousandmails/resources/stats.rb
26
+ - lib/thousandmails/utils/attachments.rb
27
+ - lib/thousandmails/utils/constants.rb
28
+ - lib/thousandmails/utils/errors.rb
29
+ - lib/thousandmails/utils/http.rb
30
+ - lib/thousandmails/utils/options.rb
31
+ - lib/thousandmails/utils/stream.rb
32
+ - lib/thousandmails/utils/transport.rb
33
+ - lib/thousandmails/utils/validate.rb
34
+ - lib/thousandmails/version.rb
35
+ homepage: https://thousandmails.com
36
+ licenses:
37
+ - Nonstandard
38
+ metadata:
39
+ homepage_uri: https://thousandmails.com
40
+ rubygems_mfa_required: 'true'
41
+ rdoc_options: []
42
+ require_paths:
43
+ - lib
44
+ required_ruby_version: !ruby/object:Gem::Requirement
45
+ requirements:
46
+ - - ">="
47
+ - !ruby/object:Gem::Version
48
+ version: '3.0'
49
+ required_rubygems_version: !ruby/object:Gem::Requirement
50
+ requirements:
51
+ - - ">="
52
+ - !ruby/object:Gem::Version
53
+ version: '0'
54
+ requirements: []
55
+ rubygems_version: 4.0.16
56
+ specification_version: 4
57
+ summary: Official Ruby SDK for the ThousandMails client API
58
+ test_files: []