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,291 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "base64"
|
|
4
|
+
require "json"
|
|
5
|
+
require "securerandom"
|
|
6
|
+
|
|
7
|
+
module ThousandMails
|
|
8
|
+
# Turns whatever a caller hands us into the multipart body the attachment
|
|
9
|
+
# endpoints expect, applying the same ceilings the server does
|
|
10
|
+
# (src/backend/client/middleware/Attachment.js) so an oversized or unsupported
|
|
11
|
+
# file fails here rather than after the upload.
|
|
12
|
+
#
|
|
13
|
+
# Accepted forms:
|
|
14
|
+
#
|
|
15
|
+
# "/path/to/invoice.pdf" a path on disk
|
|
16
|
+
# { path: "/tmp/x.pdf", filename: "invoice.pdf" } a path with a chosen name
|
|
17
|
+
# { filename: "report.csv", content: "a,b\n1,2" } bytes or text in memory
|
|
18
|
+
# { filename: "x.png", content: "<base64>", encoding: "base64" }
|
|
19
|
+
module Attachments
|
|
20
|
+
# One attachment, read and checked.
|
|
21
|
+
ResolvedFile = Struct.new(:filename, :content, :content_type)
|
|
22
|
+
|
|
23
|
+
module_function
|
|
24
|
+
|
|
25
|
+
# The server refuses control characters and path separators outright, because
|
|
26
|
+
# the name is echoed into a Content-Disposition header.
|
|
27
|
+
def assert_safe_filename(filename, label)
|
|
28
|
+
if filename.nil? || filename.strip.empty?
|
|
29
|
+
raise InvalidInputError.new("Each attachment must have a filename", { field: label })
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
if filename.length > Constants::MAX_FILENAME_LENGTH
|
|
33
|
+
raise InvalidInputError.new(
|
|
34
|
+
"Attachment filenames may be at most #{Constants::MAX_FILENAME_LENGTH} characters",
|
|
35
|
+
{ field: label, filename: filename }
|
|
36
|
+
)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
filename.each_char do |char|
|
|
40
|
+
code = char.ord
|
|
41
|
+
next unless code < 0x20 || code == 0x7F || code == 0x2F || code == 0x5C
|
|
42
|
+
|
|
43
|
+
raise InvalidInputError.new(
|
|
44
|
+
"\"#{filename}\" is not a valid file name: no path separators or control characters",
|
|
45
|
+
{ field: label, filename: filename }
|
|
46
|
+
)
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def extension_of(filename)
|
|
51
|
+
dot = filename.rindex(".")
|
|
52
|
+
dot && dot.positive? ? filename[(dot + 1)..].downcase : ""
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# Read and check one attachment.
|
|
56
|
+
def resolve_one(entry, label)
|
|
57
|
+
filename, content = read_entry(entry, label)
|
|
58
|
+
|
|
59
|
+
assert_safe_filename(filename, label)
|
|
60
|
+
|
|
61
|
+
extension = extension_of(filename)
|
|
62
|
+
content_type = Constants::ATTACHMENT_TYPES[extension]
|
|
63
|
+
if content_type.nil?
|
|
64
|
+
raise InvalidInputError.new(
|
|
65
|
+
"\"#{filename}\" has an unsupported file type. " \
|
|
66
|
+
"Allowed: #{Constants::ALLOWED_EXTENSIONS.join(', ')}",
|
|
67
|
+
{ field: label, allowed: Constants::ALLOWED_EXTENSIONS, filename: filename }
|
|
68
|
+
)
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
size = content.bytesize
|
|
72
|
+
if size.zero?
|
|
73
|
+
raise InvalidInputError.new("\"#{filename}\" is empty", { field: label, filename: filename })
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
if size > Constants::MAX_FILE_BYTES
|
|
77
|
+
raise InvalidInputError.new(
|
|
78
|
+
"\"#{filename}\" is #{size} bytes; the per-file limit is #{Constants::MAX_FILE_BYTES}",
|
|
79
|
+
{ field: label, filename: filename, size: size, max: Constants::MAX_FILE_BYTES }
|
|
80
|
+
)
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
ResolvedFile.new(filename, content, content_type)
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
# Resolve one message's attachments, enforcing the per-message count, the
|
|
87
|
+
# per-request byte ceiling and the duplicate-name rule the server applies.
|
|
88
|
+
def resolve_group(attachments, label = "attachments")
|
|
89
|
+
entries = attachments.is_a?(Array) ? attachments : [attachments]
|
|
90
|
+
|
|
91
|
+
if entries.empty? || (entries.length == 1 && blank?(entries.first))
|
|
92
|
+
raise InvalidInputError.new(
|
|
93
|
+
"#{label} is required — the attachment endpoints need at least one file " \
|
|
94
|
+
"(use send_mail for a message without files)",
|
|
95
|
+
{ field: label }
|
|
96
|
+
)
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
if entries.length > Constants::MAX_ATTACHMENTS_PER_MESSAGE
|
|
100
|
+
raise InvalidInputError.new(
|
|
101
|
+
"A message may carry at most #{Constants::MAX_ATTACHMENTS_PER_MESSAGE} attachments",
|
|
102
|
+
{ field: label, received: entries.length }
|
|
103
|
+
)
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
resolved = []
|
|
107
|
+
seen = {}
|
|
108
|
+
total = 0
|
|
109
|
+
|
|
110
|
+
entries.each do |entry|
|
|
111
|
+
file = resolve_one(entry, label)
|
|
112
|
+
|
|
113
|
+
key = file.filename.downcase
|
|
114
|
+
if seen.key?(key)
|
|
115
|
+
raise InvalidInputError.new(
|
|
116
|
+
"Duplicate attachment filename \"#{file.filename}\"",
|
|
117
|
+
{ field: label, filename: file.filename }
|
|
118
|
+
)
|
|
119
|
+
end
|
|
120
|
+
seen[key] = true
|
|
121
|
+
|
|
122
|
+
total += file.content.bytesize
|
|
123
|
+
if total > Constants::MAX_REQUEST_BYTES
|
|
124
|
+
raise InvalidInputError.new(
|
|
125
|
+
"Attachments exceed the #{Constants::MAX_REQUEST_BYTES} byte per-request limit",
|
|
126
|
+
{ field: label, size: total, max: Constants::MAX_REQUEST_BYTES }
|
|
127
|
+
)
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
resolved << file
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
resolved
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
# Body for POST /client/sendmail/attachment.
|
|
137
|
+
# @return [Array] `[[body, content_type], files]`
|
|
138
|
+
def single_form(message, attachments)
|
|
139
|
+
files = resolve_group(attachments)
|
|
140
|
+
|
|
141
|
+
parts = message.filter_map { |name, value| field(name.to_s, value) }
|
|
142
|
+
files.each { |file| parts << ["attachments", file] }
|
|
143
|
+
|
|
144
|
+
[encode(parts), files]
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
# Body for POST /client/send/attachment/batch. Files bind to their message by
|
|
148
|
+
# index through the field name `attachments[<index>]`, and every message in
|
|
149
|
+
# the batch must carry at least one.
|
|
150
|
+
# @return [Array] `[[body, content_type], groups]`
|
|
151
|
+
def batch_form(messages, attachments_by_index)
|
|
152
|
+
groups = []
|
|
153
|
+
total_files = 0
|
|
154
|
+
|
|
155
|
+
messages.each_index do |index|
|
|
156
|
+
files = resolve_group(attachments_by_index[index], "attachments[#{index}]")
|
|
157
|
+
total_files += files.length
|
|
158
|
+
groups << files
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
if total_files > Constants::MAX_FILES_PER_BATCH_REQUEST
|
|
162
|
+
raise InvalidInputError.new(
|
|
163
|
+
"A multipart batch may carry at most #{Constants::MAX_FILES_PER_BATCH_REQUEST} files in total",
|
|
164
|
+
{ received: total_files, max: Constants::MAX_FILES_PER_BATCH_REQUEST }
|
|
165
|
+
)
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
parts = [["messages", JSON.generate(messages)]]
|
|
169
|
+
groups.each_with_index do |files, index|
|
|
170
|
+
files.each { |file| parts << ["attachments[#{index}]", file] }
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
[encode(parts), groups]
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
# Encode parts as multipart/form-data.
|
|
177
|
+
#
|
|
178
|
+
# Built by hand rather than through a MIME library so in-memory content needs
|
|
179
|
+
# no temp file, and so the test suite can read back exactly what would have
|
|
180
|
+
# gone over the wire.
|
|
181
|
+
#
|
|
182
|
+
# Each part is `[name, value]` where value is either a String (a plain field)
|
|
183
|
+
# or a ResolvedFile (a file part).
|
|
184
|
+
#
|
|
185
|
+
# @return [Array] `[body, content_type]`
|
|
186
|
+
def encode(parts)
|
|
187
|
+
boundary = "----ThousandMailsFormBoundary#{SecureRandom.hex(16)}"
|
|
188
|
+
|
|
189
|
+
# Built in binary throughout. File content read off disk is ASCII-8BIT and
|
|
190
|
+
# a filename or field value may be UTF-8; appending one to the other in a
|
|
191
|
+
# single-encoding buffer raises Encoding::CompatibilityError, so every
|
|
192
|
+
# piece is converted before it goes in.
|
|
193
|
+
body = +"".b
|
|
194
|
+
|
|
195
|
+
parts.each do |name, value|
|
|
196
|
+
body << "--#{boundary}\r\n".b
|
|
197
|
+
|
|
198
|
+
if value.is_a?(ResolvedFile)
|
|
199
|
+
body << "Content-Disposition: form-data; name=\"#{name}\"; " \
|
|
200
|
+
"filename=\"#{escape_header_value(value.filename)}\"\r\n".b
|
|
201
|
+
body << "Content-Type: #{value.content_type}\r\n\r\n".b
|
|
202
|
+
body << value.content.b
|
|
203
|
+
else
|
|
204
|
+
body << "Content-Disposition: form-data; name=\"#{name}\"\r\n\r\n".b
|
|
205
|
+
body << value.to_s.b
|
|
206
|
+
end
|
|
207
|
+
|
|
208
|
+
body << "\r\n".b
|
|
209
|
+
end
|
|
210
|
+
|
|
211
|
+
body << "--#{boundary}--\r\n".b
|
|
212
|
+
|
|
213
|
+
[body, "multipart/form-data; boundary=#{boundary}"]
|
|
214
|
+
end
|
|
215
|
+
|
|
216
|
+
# Multipart carries every non-file part as a string, so structured values are
|
|
217
|
+
# JSON-encoded exactly the way the server's coerceMessage expects them.
|
|
218
|
+
def field(name, value)
|
|
219
|
+
return nil if value.nil? || value == "" || value == []
|
|
220
|
+
return [name, value ? "true" : "false"] if value == true || value == false
|
|
221
|
+
return [name, JSON.generate(value)] if value.is_a?(Hash) || value.is_a?(Array)
|
|
222
|
+
|
|
223
|
+
[name, value.to_s]
|
|
224
|
+
end
|
|
225
|
+
|
|
226
|
+
def read_entry(entry, label)
|
|
227
|
+
case entry
|
|
228
|
+
when String
|
|
229
|
+
[File.basename(entry), read_file(entry, label)]
|
|
230
|
+
when Hash
|
|
231
|
+
record = entry.each_with_object({}) { |(key, value), out| out[key.to_s] = value }
|
|
232
|
+
|
|
233
|
+
if record["path"] && !record["path"].to_s.empty?
|
|
234
|
+
path = record["path"].to_s
|
|
235
|
+
filename = blank?(record["filename"]) ? File.basename(path) : record["filename"].to_s
|
|
236
|
+
[filename, read_file(path, label)]
|
|
237
|
+
else
|
|
238
|
+
filename = record["filename"].to_s
|
|
239
|
+
content = to_bytes(record["content"], record["encoding"])
|
|
240
|
+
if content.nil?
|
|
241
|
+
shown = filename.empty? ? "(unnamed)" : filename
|
|
242
|
+
raise InvalidInputError.new(
|
|
243
|
+
"Attachment \"#{shown}\" needs `content` (a String of bytes) or `path`",
|
|
244
|
+
{ field: label }
|
|
245
|
+
)
|
|
246
|
+
end
|
|
247
|
+
[filename, content]
|
|
248
|
+
end
|
|
249
|
+
else
|
|
250
|
+
raise InvalidInputError.new(
|
|
251
|
+
"An attachment must be a file path or a hash with { filename:, content: }",
|
|
252
|
+
{ field: label }
|
|
253
|
+
)
|
|
254
|
+
end
|
|
255
|
+
end
|
|
256
|
+
|
|
257
|
+
def to_bytes(content, encoding)
|
|
258
|
+
return nil unless content.is_a?(String)
|
|
259
|
+
|
|
260
|
+
if encoding.to_s == "base64"
|
|
261
|
+
begin
|
|
262
|
+
return content.unpack1("m0")
|
|
263
|
+
rescue ArgumentError
|
|
264
|
+
return nil
|
|
265
|
+
end
|
|
266
|
+
end
|
|
267
|
+
|
|
268
|
+
content
|
|
269
|
+
end
|
|
270
|
+
|
|
271
|
+
def read_file(target, label)
|
|
272
|
+
File.binread(target)
|
|
273
|
+
rescue SystemCallError, IOError => e
|
|
274
|
+
raise InvalidInputError.new(
|
|
275
|
+
"Could not read attachment \"#{target}\": #{e.message}",
|
|
276
|
+
{ field: label },
|
|
277
|
+
cause: e
|
|
278
|
+
)
|
|
279
|
+
end
|
|
280
|
+
|
|
281
|
+
# assert_safe_filename has already refused control characters and separators,
|
|
282
|
+
# so a quote is the only thing left that could break out of the header.
|
|
283
|
+
def escape_header_value(value)
|
|
284
|
+
value.gsub('"', "%22")
|
|
285
|
+
end
|
|
286
|
+
|
|
287
|
+
def blank?(value)
|
|
288
|
+
value.nil? || value == "" || value == [] || value == {}
|
|
289
|
+
end
|
|
290
|
+
end
|
|
291
|
+
end
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ThousandMails
|
|
4
|
+
# Values mirrored from the server so the SDK can reject a bad request locally
|
|
5
|
+
# instead of spending a round trip on it.
|
|
6
|
+
#
|
|
7
|
+
# Every constant here has a counterpart in src/backend/client. When the server
|
|
8
|
+
# changes a limit, change it here too — the pairs are called out per entry.
|
|
9
|
+
module Constants
|
|
10
|
+
# Every client endpoint hangs off this prefix (backend/client/routes/sendmail.routes.js).
|
|
11
|
+
CLIENT_PREFIX = "/client"
|
|
12
|
+
|
|
13
|
+
# Milliseconds, not seconds, so the Node, PHP, Python and Ruby SDKs
|
|
14
|
+
# configure identically.
|
|
15
|
+
DEFAULT_TIMEOUT_MS = 30_000
|
|
16
|
+
DEFAULT_MAX_RETRIES = 2
|
|
17
|
+
|
|
18
|
+
# The host used when nothing overrides it.
|
|
19
|
+
FALLBACK_BASE_URL = "https://beta.thousandmails.com/mailerapi"
|
|
20
|
+
|
|
21
|
+
# --- send limits ---------------------------------------------------------
|
|
22
|
+
|
|
23
|
+
# Sendpipeline.MAX_BATCH
|
|
24
|
+
MAX_BATCH = 100
|
|
25
|
+
# middleware/Attachment.MAX_ATTACHMENT_BATCH
|
|
26
|
+
MAX_ATTACHMENT_BATCH = 20
|
|
27
|
+
# middleware/Attachment.MAX_ATTACHMENTS_PER_MESSAGE
|
|
28
|
+
MAX_ATTACHMENTS_PER_MESSAGE = 5
|
|
29
|
+
# middleware/Attachment.MAX_FILE_BYTES
|
|
30
|
+
MAX_FILE_BYTES = 5 * 1024 * 1024
|
|
31
|
+
# middleware/Attachment.MAX_REQUEST_BYTES
|
|
32
|
+
MAX_REQUEST_BYTES = 20 * 1024 * 1024
|
|
33
|
+
# middleware/Attachment.MAX_FILES_PER_BATCH_REQUEST
|
|
34
|
+
MAX_FILES_PER_BATCH_REQUEST = 20
|
|
35
|
+
# middleware/Attachment.MAX_FILENAME_LENGTH
|
|
36
|
+
MAX_FILENAME_LENGTH = 200
|
|
37
|
+
|
|
38
|
+
# middleware/Attachment.ALLOWED_TYPES — extension => Content-Type. The server
|
|
39
|
+
# re-derives the type from the extension and sniffs the bytes, so the type we
|
|
40
|
+
# put on the multipart part is a courtesy, not a claim it trusts.
|
|
41
|
+
ATTACHMENT_TYPES = {
|
|
42
|
+
"csv" => "text/csv",
|
|
43
|
+
"xls" => "application/vnd.ms-excel",
|
|
44
|
+
"xlsx" => "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
|
45
|
+
"doc" => "application/msword",
|
|
46
|
+
"docx" => "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
|
47
|
+
"pdf" => "application/pdf",
|
|
48
|
+
"jpg" => "image/jpeg",
|
|
49
|
+
"jpeg" => "image/jpeg",
|
|
50
|
+
"png" => "image/png"
|
|
51
|
+
}.freeze
|
|
52
|
+
|
|
53
|
+
ALLOWED_EXTENSIONS = ATTACHMENT_TYPES.keys.freeze
|
|
54
|
+
|
|
55
|
+
# --- analytics -----------------------------------------------------------
|
|
56
|
+
|
|
57
|
+
# client/utils/Analytics.METRIC_FIELDS
|
|
58
|
+
METRIC_FIELDS = %w[
|
|
59
|
+
sent delivered deferred bounced opened clicked spam unsubscribed failed
|
|
60
|
+
].freeze
|
|
61
|
+
|
|
62
|
+
# client/utils/Analytics.METRIC_BY_TYPE keys — the `type` on a log event.
|
|
63
|
+
EVENT_TYPES = %w[
|
|
64
|
+
queued delivered deferred bounced opened clicked spam unsubscribed failed
|
|
65
|
+
].freeze
|
|
66
|
+
|
|
67
|
+
INTERVALS = %w[day week month].freeze
|
|
68
|
+
|
|
69
|
+
# Query-param ceilings enforced by the controllers. The server clamps rather
|
|
70
|
+
# than rejects, so these are used for documentation and local clamping only.
|
|
71
|
+
LIMITS = {
|
|
72
|
+
"realtimeMinutes" => { min: 1, max: 120, default: 60 },
|
|
73
|
+
"realtimeSeconds" => { min: 1, max: 600, default: 120 },
|
|
74
|
+
"activityLimit" => { min: 1, max: 100, default: 20 },
|
|
75
|
+
"logPageSize" => { min: 1, max: 200, default: 50 }
|
|
76
|
+
}.freeze
|
|
77
|
+
|
|
78
|
+
# The row ceiling on GET /client/logs/export.
|
|
79
|
+
LOG_EXPORT_ROWS = 5000
|
|
80
|
+
|
|
81
|
+
# Sendpipeline.EMAIL_PATTERN, character for character. \A and \z rather than
|
|
82
|
+
# ^ and $, because Ruby's anchors match at a line break and JavaScript's do
|
|
83
|
+
# not — the server would refuse "a@b.com\n", so this has to as well.
|
|
84
|
+
EMAIL_PATTERN = /\A[^\s@]+@(?!-)[a-z0-9-]{1,63}(?<!-)(\.(?!-)[a-z0-9-]{1,63}(?<!-))+\z/i
|
|
85
|
+
|
|
86
|
+
# Sendpipeline.PLACEHOLDER_PATTERN.
|
|
87
|
+
PLACEHOLDER_PATTERN = /\{\{\s*([\w.-]+)\s*\}\}/
|
|
88
|
+
|
|
89
|
+
# Where the API lives. Callers configure nothing but their API key, so this
|
|
90
|
+
# has to be the real host — override it only to point at a different
|
|
91
|
+
# deployment, with `ThousandMails::Client.new(base_url:)` or THOUSANDMAILS_BASE_URL.
|
|
92
|
+
#
|
|
93
|
+
# Read on each call rather than captured at load, so setting the variable
|
|
94
|
+
# after the gem has been required still takes effect.
|
|
95
|
+
def self.default_base_url
|
|
96
|
+
from_env = ENV["THOUSANDMAILS_BASE_URL"]
|
|
97
|
+
from_env.nil? || from_env.empty? ? FALLBACK_BASE_URL : from_env
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
end
|
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "time"
|
|
4
|
+
|
|
5
|
+
module ThousandMails
|
|
6
|
+
# Every failure the SDK raises is a ThousandMails::Error, so a caller can rescue
|
|
7
|
+
# one type and branch on the specific subclass.
|
|
8
|
+
#
|
|
9
|
+
# The API answers with two body shapes — `{ "message": ... }` for validation
|
|
10
|
+
# and lookup failures, `{ "error": ... }` for authentication and rate limiting
|
|
11
|
+
# — so the message is pulled from whichever is present rather than assuming one.
|
|
12
|
+
class Error < StandardError
|
|
13
|
+
# The underlying failure, when this one wraps something lower down.
|
|
14
|
+
attr_reader :cause_error
|
|
15
|
+
|
|
16
|
+
def initialize(message, cause: nil)
|
|
17
|
+
super(message)
|
|
18
|
+
@cause_error = cause
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
# Bad or missing SDK configuration (no API key, unusable base_url).
|
|
23
|
+
class ConfigError < Error; end
|
|
24
|
+
|
|
25
|
+
# A request rejected locally, before any bytes went out — a missing sender, an
|
|
26
|
+
# oversized attachment, a batch over the limit. Mirrors the server's own checks
|
|
27
|
+
# so the failure arrives in microseconds instead of after a round trip.
|
|
28
|
+
class InvalidInputError < Error
|
|
29
|
+
# Everything the failure carried, including the keys promoted below.
|
|
30
|
+
attr_reader :details
|
|
31
|
+
# The offending field, when one field is to blame.
|
|
32
|
+
attr_reader :field
|
|
33
|
+
# Placeholders the body referenced, and those left without a value.
|
|
34
|
+
attr_reader :required_fields, :missing_fields
|
|
35
|
+
# Position in the batch, set when a batch entry is at fault.
|
|
36
|
+
attr_reader :index
|
|
37
|
+
|
|
38
|
+
def initialize(message, details = {}, cause: nil)
|
|
39
|
+
super(message, cause: cause)
|
|
40
|
+
@details = details || {}
|
|
41
|
+
@field = @details[:field] || @details["field"]
|
|
42
|
+
@required_fields = @details[:requiredFields] || @details["requiredFields"]
|
|
43
|
+
@missing_fields = @details[:missingFields] || @details["missingFields"]
|
|
44
|
+
@index = @details[:index] || @details["index"]
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# Any other detail the failure carried (`value`, `filename`, `max`, …).
|
|
48
|
+
def detail(key)
|
|
49
|
+
@details[key] || @details[key.to_s] || @details[key.to_sym]
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# Re-label the message and record the batch position. Used by
|
|
53
|
+
# Validate.batch so a rejected entry says which one it was.
|
|
54
|
+
def with_index(index)
|
|
55
|
+
self.class.new(
|
|
56
|
+
"messages[#{index}]: #{message}",
|
|
57
|
+
@details.merge(index: index),
|
|
58
|
+
cause: @cause_error
|
|
59
|
+
)
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# The request never completed: DNS, TCP, TLS, or a dropped socket.
|
|
64
|
+
class ConnectionError < Error; end
|
|
65
|
+
|
|
66
|
+
# The request exceeded the configured timeout and was aborted.
|
|
67
|
+
class TimeoutError < ConnectionError; end
|
|
68
|
+
|
|
69
|
+
# The caller stopped the work in progress — closing a live event stream.
|
|
70
|
+
class AbortError < Error; end
|
|
71
|
+
|
|
72
|
+
# The API answered with a non-2xx status.
|
|
73
|
+
class APIError < Error
|
|
74
|
+
attr_reader :status, :body, :headers, :http_method, :url
|
|
75
|
+
|
|
76
|
+
def initialize(message, status: 0, body: nil, headers: {}, http_method: "", url: "")
|
|
77
|
+
super(message)
|
|
78
|
+
@status = status
|
|
79
|
+
@body = body
|
|
80
|
+
@headers = headers || {}
|
|
81
|
+
@http_method = http_method
|
|
82
|
+
@url = url
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
# 401 — header missing, malformed, or the key is unknown.
|
|
87
|
+
class AuthenticationError < APIError; end
|
|
88
|
+
|
|
89
|
+
# 403 — the key is inactive, its owner is gone, the sender is pinned to a
|
|
90
|
+
# different IP, or every recipient is suppressed. When suppression is the
|
|
91
|
+
# cause the response carries the offending addresses, exposed as `suppressed`.
|
|
92
|
+
class PermissionError < APIError
|
|
93
|
+
attr_reader :suppressed
|
|
94
|
+
|
|
95
|
+
def initialize(message, **options)
|
|
96
|
+
super
|
|
97
|
+
@suppressed = body["suppressed"] if body.is_a?(Hash) && body["suppressed"].is_a?(Array)
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
# 400 — a reference that does not resolve: unknown template, unknown sender,
|
|
102
|
+
# unknown message or event id. The API uses 400 rather than 404 for these.
|
|
103
|
+
class BadRequestError < APIError; end
|
|
104
|
+
|
|
105
|
+
# 404 — no such route.
|
|
106
|
+
class NotFoundError < APIError; end
|
|
107
|
+
|
|
108
|
+
# 422 — the request was understood and rejected. Placeholder failures carry
|
|
109
|
+
# `required_fields`/`missing_fields`; attachment failures carry `field`.
|
|
110
|
+
class ValidationError < APIError
|
|
111
|
+
attr_reader :required_fields, :missing_fields, :field, :allowed
|
|
112
|
+
|
|
113
|
+
def initialize(message, **options)
|
|
114
|
+
super
|
|
115
|
+
payload = body.is_a?(Hash) ? body : {}
|
|
116
|
+
@required_fields = payload["requiredFields"]
|
|
117
|
+
@missing_fields = payload["missingFields"]
|
|
118
|
+
@field = payload["field"]
|
|
119
|
+
@allowed = payload["allowed"]
|
|
120
|
+
end
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
# 413 — an attachment or form field exceeded the size ceiling.
|
|
124
|
+
class PayloadTooLargeError < APIError; end
|
|
125
|
+
|
|
126
|
+
# 415 — an attachment endpoint was called without multipart/form-data.
|
|
127
|
+
class UnsupportedMediaTypeError < APIError; end
|
|
128
|
+
|
|
129
|
+
# 429 — the per-key limiter tripped. `retry_after` is in seconds.
|
|
130
|
+
class RateLimitError < APIError
|
|
131
|
+
attr_reader :retry_after
|
|
132
|
+
|
|
133
|
+
def initialize(message, **options)
|
|
134
|
+
super
|
|
135
|
+
@retry_after = Errors.parse_retry_after(headers)
|
|
136
|
+
end
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
# 5xx — the API or the transport behind it failed.
|
|
140
|
+
class ServerError < APIError; end
|
|
141
|
+
|
|
142
|
+
# 503 — too many attachment uploads are already in flight. The request was
|
|
143
|
+
# refused before any bytes were buffered, so retrying it is always safe.
|
|
144
|
+
class ServiceUnavailableError < ServerError; end
|
|
145
|
+
|
|
146
|
+
# Builds the right APIError subclass for a response.
|
|
147
|
+
module Errors
|
|
148
|
+
BY_STATUS = {
|
|
149
|
+
400 => BadRequestError,
|
|
150
|
+
401 => AuthenticationError,
|
|
151
|
+
403 => PermissionError,
|
|
152
|
+
404 => NotFoundError,
|
|
153
|
+
413 => PayloadTooLargeError,
|
|
154
|
+
415 => UnsupportedMediaTypeError,
|
|
155
|
+
422 => ValidationError,
|
|
156
|
+
429 => RateLimitError,
|
|
157
|
+
503 => ServiceUnavailableError
|
|
158
|
+
}.freeze
|
|
159
|
+
|
|
160
|
+
def self.from_response(status, body, headers, http_method, url)
|
|
161
|
+
klass = BY_STATUS[status] || (status >= 500 ? ServerError : APIError)
|
|
162
|
+
klass.new(
|
|
163
|
+
message_of(body, status),
|
|
164
|
+
status: status,
|
|
165
|
+
body: body,
|
|
166
|
+
headers: headers,
|
|
167
|
+
http_method: http_method,
|
|
168
|
+
url: url
|
|
169
|
+
)
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
def self.message_of(body, status)
|
|
173
|
+
return body.strip[0, 500] if body.is_a?(String) && !body.strip.empty?
|
|
174
|
+
|
|
175
|
+
if body.is_a?(Hash)
|
|
176
|
+
return body["message"] if body["message"].is_a?(String)
|
|
177
|
+
return body["error"] if body["error"].is_a?(String)
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
"ThousandMails API request failed with status #{status}"
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
# Retry-After is either a delay in seconds or an HTTP date; normalise to seconds.
|
|
184
|
+
def self.parse_retry_after(headers)
|
|
185
|
+
raw = headers["retry-after"] if headers.respond_to?(:[])
|
|
186
|
+
return nil if raw.nil? || raw.to_s.empty?
|
|
187
|
+
|
|
188
|
+
return Float(raw) if raw.to_s.match?(/\A\s*-?\d+(\.\d+)?\s*\z/)
|
|
189
|
+
|
|
190
|
+
begin
|
|
191
|
+
[0.0, Time.httpdate(raw.to_s).to_f - Time.now.to_f].max
|
|
192
|
+
rescue ArgumentError
|
|
193
|
+
nil
|
|
194
|
+
end
|
|
195
|
+
end
|
|
196
|
+
end
|
|
197
|
+
end
|