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,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