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.
- checksums.yaml +7 -0
- data/LICENSE +70 -0
- data/README.md +390 -0
- data/lib/thousandmails/client.rb +159 -0
- data/lib/thousandmails/resources/emails.rb +172 -0
- data/lib/thousandmails/resources/logs.rb +204 -0
- data/lib/thousandmails/resources/realtime.rb +112 -0
- data/lib/thousandmails/resources/stats.rb +122 -0
- data/lib/thousandmails/utils/attachments.rb +291 -0
- data/lib/thousandmails/utils/constants.rb +100 -0
- data/lib/thousandmails/utils/errors.rb +197 -0
- data/lib/thousandmails/utils/http.rb +287 -0
- data/lib/thousandmails/utils/options.rb +29 -0
- data/lib/thousandmails/utils/stream.rb +252 -0
- data/lib/thousandmails/utils/transport.rb +177 -0
- data/lib/thousandmails/utils/validate.rb +263 -0
- data/lib/thousandmails/version.rb +6 -0
- data/lib/thousandmails.rb +36 -0
- metadata +58 -0
|
@@ -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,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: []
|