haskimail 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/CHANGELOG.md +51 -0
- data/LICENSE +21 -0
- data/README.md +414 -0
- data/SECURITY.md +64 -0
- data/lib/haskimail/api_object.rb +294 -0
- data/lib/haskimail/attachment.rb +171 -0
- data/lib/haskimail/base_client.rb +199 -0
- data/lib/haskimail/clients.rb +146 -0
- data/lib/haskimail/configuration.rb +246 -0
- data/lib/haskimail/errors.rb +246 -0
- data/lib/haskimail/http/net_http_transport.rb +288 -0
- data/lib/haskimail/http/response_parser.rb +104 -0
- data/lib/haskimail/http/retry_policy.rb +92 -0
- data/lib/haskimail/redacted.rb +31 -0
- data/lib/haskimail/resources/account_resources.rb +157 -0
- data/lib/haskimail/resources/base.rb +193 -0
- data/lib/haskimail/resources/emails.rb +352 -0
- data/lib/haskimail/resources/messages.rb +178 -0
- data/lib/haskimail/resources/server_resources.rb +297 -0
- data/lib/haskimail/resources/templates.rb +107 -0
- data/lib/haskimail/test_addresses.rb +25 -0
- data/lib/haskimail/util.rb +250 -0
- data/lib/haskimail/version.rb +6 -0
- data/lib/haskimail/webhook.rb +395 -0
- data/lib/haskimail.rb +34 -0
- data/sig/haskimail/internal.rbs +247 -0
- data/sig/haskimail.rbs +619 -0
- metadata +78 -0
|
@@ -0,0 +1,294 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "util"
|
|
4
|
+
require_relative "http/response_parser"
|
|
5
|
+
|
|
6
|
+
module Haskimail
|
|
7
|
+
# Immutable wrapper around a JSON object returned by the API.
|
|
8
|
+
#
|
|
9
|
+
# Field access is lossless: the original keys are kept, and snake_case
|
|
10
|
+
# names are resolved to them on the fly.
|
|
11
|
+
#
|
|
12
|
+
# result = client.emails.deliver(...)
|
|
13
|
+
# result.message_id # => "6f1c0b2d..."
|
|
14
|
+
# result[:message_id] # same
|
|
15
|
+
# result["MessageID"] # exact API key
|
|
16
|
+
# result.to_h # => { "MessageID" => "...", ... } (mutable deep copy)
|
|
17
|
+
#
|
|
18
|
+
# Keys that are not identifiers (e.g. "Apple Mail" in e-mail client stats)
|
|
19
|
+
# are available through #[] with either spelling: `stats["Apple Mail"]`,
|
|
20
|
+
# `stats[:apple_mail]`.
|
|
21
|
+
class ApiObject
|
|
22
|
+
# @param data [Hash] parsed API object (deep-copied and frozen)
|
|
23
|
+
def initialize(data)
|
|
24
|
+
raise ArgumentError, "ApiObject expects a Hash, got #{data.class}" unless data.is_a?(Hash)
|
|
25
|
+
|
|
26
|
+
setup(Util.deep_freeze(Util.deep_dup(data)))
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# @param key [String, Symbol] exact API key or snake_case alias
|
|
30
|
+
# @return [Object, ApiObject, Array, nil]
|
|
31
|
+
def [](key)
|
|
32
|
+
original = resolve(key)
|
|
33
|
+
original.nil? ? nil : self.class.wrap(@data[original])
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# Like Hash#fetch, with the same key resolution as #[].
|
|
37
|
+
def fetch(key, *default, &block)
|
|
38
|
+
original = resolve(key)
|
|
39
|
+
return self.class.wrap(@data[original]) unless original.nil?
|
|
40
|
+
return yield(key) if block
|
|
41
|
+
return default.first unless default.empty?
|
|
42
|
+
|
|
43
|
+
raise KeyError.new("key not found: #{key.inspect}", receiver: self, key: key)
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# True when the key exists under any accepted spelling.
|
|
47
|
+
def key?(key)
|
|
48
|
+
!resolve(key).nil?
|
|
49
|
+
end
|
|
50
|
+
alias include? key?
|
|
51
|
+
|
|
52
|
+
# Like Hash#dig, with the same key resolution as #[].
|
|
53
|
+
def dig(key, *rest)
|
|
54
|
+
value = self[key]
|
|
55
|
+
return value if rest.empty? || value.nil?
|
|
56
|
+
|
|
57
|
+
value.respond_to?(:dig) ? value.dig(*rest) : nil
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# Original API keys.
|
|
61
|
+
def keys
|
|
62
|
+
@data.keys
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# Yields each original key with its (wrapped) value.
|
|
66
|
+
def each(&block)
|
|
67
|
+
return enum_for(:each) unless block
|
|
68
|
+
|
|
69
|
+
@data.each_key { |key| yield key, self[key] }
|
|
70
|
+
self
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
# @return [Hash] deep, mutable copy of the raw API data (String keys)
|
|
74
|
+
def to_h
|
|
75
|
+
Util.deep_dup(@data)
|
|
76
|
+
end
|
|
77
|
+
alias to_hash to_h
|
|
78
|
+
|
|
79
|
+
# JSON of the original API data.
|
|
80
|
+
def to_json(*args)
|
|
81
|
+
@data.to_json(*args)
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
# Value equality: same class and same data.
|
|
85
|
+
def ==(other)
|
|
86
|
+
other.instance_of?(self.class) && other.data == @data
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
# Strict equality (numbers compared by type too), consistent with #hash.
|
|
90
|
+
def eql?(other)
|
|
91
|
+
other.instance_of?(self.class) && other.data.eql?(@data)
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
# Consistent with #eql?.
|
|
95
|
+
def hash
|
|
96
|
+
[self.class, @data].hash
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
# Marshal support (e.g. Rails.cache): only the data is stored, and the
|
|
100
|
+
# loaded object is frozen again.
|
|
101
|
+
def marshal_dump
|
|
102
|
+
@data
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
# Restores an object written by #marshal_dump, frozen again.
|
|
106
|
+
# @api private
|
|
107
|
+
def marshal_load(data)
|
|
108
|
+
setup(Util.deep_freeze(data))
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
# Full data, for debugging.
|
|
112
|
+
def inspect
|
|
113
|
+
"#<#{self.class.name} #{@data.inspect}>"
|
|
114
|
+
end
|
|
115
|
+
alias to_s inspect
|
|
116
|
+
|
|
117
|
+
# `result.message_id` reads "MessageID". Setters, bang and predicate
|
|
118
|
+
# names are never resolved: the object is read-only.
|
|
119
|
+
def method_missing(name, *args, &block)
|
|
120
|
+
return super unless args.empty? && block.nil? && reader_name?(name)
|
|
121
|
+
|
|
122
|
+
original = resolve(name)
|
|
123
|
+
original.nil? ? super : self.class.wrap(@data[original])
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
def respond_to_missing?(name, include_private = false)
|
|
127
|
+
(reader_name?(name) && !resolve(name).nil?) || super
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
# Key form used for case- and punctuation-insensitive lookup:
|
|
131
|
+
# "MessageID", :message_id and "message-id" all become "messageid".
|
|
132
|
+
# @api private
|
|
133
|
+
def self.normalize(key)
|
|
134
|
+
key.to_s.downcase.gsub(/[^[:alnum:]]/, "")
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
# @api private
|
|
138
|
+
# Wraps already-frozen nested data without copying it again.
|
|
139
|
+
def self.wrap(value)
|
|
140
|
+
case value
|
|
141
|
+
when Hash then ApiObject.allocate.tap { |object| object.send(:setup, value) }
|
|
142
|
+
when Array then value.map { |item| wrap(item) }.freeze
|
|
143
|
+
else value
|
|
144
|
+
end
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
protected
|
|
148
|
+
|
|
149
|
+
# Frozen raw data, for comparisons between objects.
|
|
150
|
+
# @api private
|
|
151
|
+
attr_reader :data
|
|
152
|
+
|
|
153
|
+
private
|
|
154
|
+
|
|
155
|
+
def reader_name?(name)
|
|
156
|
+
!name.end_with?("=", "!", "?")
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
# Value under the exact API key, without case-insensitive matching.
|
|
160
|
+
def exact(key)
|
|
161
|
+
self.class.wrap(@data[key])
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
def setup(frozen_data)
|
|
165
|
+
@data = frozen_data
|
|
166
|
+
@index = @data.keys.each_with_object({}) do |key, index|
|
|
167
|
+
index[self.class.normalize(key)] ||= key
|
|
168
|
+
end.freeze
|
|
169
|
+
freeze
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
def resolve(key)
|
|
173
|
+
return key if key.is_a?(String) && @data.key?(key)
|
|
174
|
+
return nil unless key.is_a?(String) || key.is_a?(Symbol)
|
|
175
|
+
|
|
176
|
+
@index[self.class.normalize(key)]
|
|
177
|
+
end
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
# Result of sending one email (also one element of a batch result).
|
|
181
|
+
class SendResult < ApiObject
|
|
182
|
+
# @return [Integer, nil] nil when the API did not send a numeric code
|
|
183
|
+
def error_code
|
|
184
|
+
HTTP::ResponseParser.error_code(@data)
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
# True only for ErrorCode 0. `false` appears inside batch results —
|
|
188
|
+
# single sends raise instead.
|
|
189
|
+
def success?
|
|
190
|
+
error_code&.zero? == true
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
# MessageID, one per recipient (the API joins them with commas).
|
|
194
|
+
# @return [Array<String>]
|
|
195
|
+
def message_ids
|
|
196
|
+
self["MessageID"].to_s.split(",").map(&:strip).reject(&:empty?)
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
# @return [Time, nil]
|
|
200
|
+
def submitted_at_time
|
|
201
|
+
Util.parse_time(self["SubmittedAt"])
|
|
202
|
+
end
|
|
203
|
+
end
|
|
204
|
+
|
|
205
|
+
# Result of /email/batch and /email/batchWithTemplates.
|
|
206
|
+
# The API answers 200 even if some emails were rejected,
|
|
207
|
+
# so check #success? or #failed.
|
|
208
|
+
class BatchResult
|
|
209
|
+
include Enumerable
|
|
210
|
+
|
|
211
|
+
# @return [Array<SendResult>] frozen, in the order the emails were given
|
|
212
|
+
attr_reader :results
|
|
213
|
+
|
|
214
|
+
# @param results [Array<SendResult>]
|
|
215
|
+
def initialize(results)
|
|
216
|
+
@results = results.dup.freeze
|
|
217
|
+
freeze
|
|
218
|
+
end
|
|
219
|
+
|
|
220
|
+
# Yields each SendResult.
|
|
221
|
+
def each(&block)
|
|
222
|
+
return enum_for(:each) unless block
|
|
223
|
+
|
|
224
|
+
@results.each(&block)
|
|
225
|
+
self
|
|
226
|
+
end
|
|
227
|
+
|
|
228
|
+
# @return [SendResult, nil]
|
|
229
|
+
def [](index)
|
|
230
|
+
@results[index]
|
|
231
|
+
end
|
|
232
|
+
|
|
233
|
+
# Number of results (equals the number of emails sent).
|
|
234
|
+
def size
|
|
235
|
+
@results.size
|
|
236
|
+
end
|
|
237
|
+
alias length size
|
|
238
|
+
|
|
239
|
+
def empty?
|
|
240
|
+
@results.empty?
|
|
241
|
+
end
|
|
242
|
+
|
|
243
|
+
# True when every email was accepted.
|
|
244
|
+
def success?
|
|
245
|
+
@results.all?(&:success?)
|
|
246
|
+
end
|
|
247
|
+
|
|
248
|
+
# @return [Array<SendResult>]
|
|
249
|
+
def successful
|
|
250
|
+
@results.select(&:success?)
|
|
251
|
+
end
|
|
252
|
+
|
|
253
|
+
# @return [Array<SendResult>]
|
|
254
|
+
def failed
|
|
255
|
+
@results.reject(&:success?)
|
|
256
|
+
end
|
|
257
|
+
|
|
258
|
+
# @return [Array<SendResult>] mutable copy
|
|
259
|
+
def to_a
|
|
260
|
+
@results.dup
|
|
261
|
+
end
|
|
262
|
+
|
|
263
|
+
# @return [Array<Hash>] raw API data of every result
|
|
264
|
+
def as_json(*)
|
|
265
|
+
@results.map(&:to_h)
|
|
266
|
+
end
|
|
267
|
+
|
|
268
|
+
# JSON array of the raw API results.
|
|
269
|
+
# json's RBS types the state argument of Array#to_json too narrowly.
|
|
270
|
+
def to_json(*args)
|
|
271
|
+
as_json.to_json(*args) # steep:ignore
|
|
272
|
+
end
|
|
273
|
+
|
|
274
|
+
# Same class and equal results.
|
|
275
|
+
def ==(other)
|
|
276
|
+
other.instance_of?(self.class) && other.results == @results
|
|
277
|
+
end
|
|
278
|
+
|
|
279
|
+
def eql?(other)
|
|
280
|
+
other.instance_of?(self.class) && other.results.eql?(@results)
|
|
281
|
+
end
|
|
282
|
+
|
|
283
|
+
# Consistent with #eql?.
|
|
284
|
+
def hash
|
|
285
|
+
[self.class, @results].hash
|
|
286
|
+
end
|
|
287
|
+
|
|
288
|
+
# Summary: number of results and failures.
|
|
289
|
+
def inspect
|
|
290
|
+
"#<#{self.class.name} size=#{size} failed=#{failed.size}>"
|
|
291
|
+
end
|
|
292
|
+
alias to_s inspect
|
|
293
|
+
end
|
|
294
|
+
end
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "errors"
|
|
4
|
+
require_relative "util"
|
|
5
|
+
|
|
6
|
+
module Haskimail
|
|
7
|
+
# An email attachment. Encodes content to Base64 and refuses file types
|
|
8
|
+
# that Haskimail rejects, before anything goes over the network.
|
|
9
|
+
#
|
|
10
|
+
# Haskimail::Attachment.from_file("invoice.pdf")
|
|
11
|
+
# Haskimail::Attachment.new(name: "report.csv", content: csv_string, content_type: "text/csv")
|
|
12
|
+
# Haskimail::Attachment.from_file("logo.png", content_id: "cid:logo") # inline: <img src="cid:logo">
|
|
13
|
+
#
|
|
14
|
+
# Instances can be put straight into `attachments:` of any send method.
|
|
15
|
+
class Attachment
|
|
16
|
+
# https://haskimail.ru/developer/user-guide/send-email-with-api#attachments
|
|
17
|
+
FORBIDDEN_EXTENSIONS = %w[
|
|
18
|
+
vbs exe bin bat chm com cpl crt hlp hta inf ins isp jse lnk mdb pcd pif reg
|
|
19
|
+
scr sct shs vbe vba wsf wsh wsl msc msi msp mst
|
|
20
|
+
].freeze
|
|
21
|
+
|
|
22
|
+
# Total message size limit, attachments counted after Base64 encoding.
|
|
23
|
+
MAX_MESSAGE_SIZE = 10 * 1024 * 1024
|
|
24
|
+
|
|
25
|
+
# Content type guessed from the file extension.
|
|
26
|
+
MIME_TYPES = {
|
|
27
|
+
"pdf" => "application/pdf", "zip" => "application/zip", "json" => "application/json",
|
|
28
|
+
"xml" => "application/xml", "txt" => "text/plain", "csv" => "text/csv", "html" => "text/html",
|
|
29
|
+
"htm" => "text/html", "ics" => "text/calendar", "png" => "image/png", "jpg" => "image/jpeg",
|
|
30
|
+
"jpeg" => "image/jpeg", "gif" => "image/gif", "webp" => "image/webp", "svg" => "image/svg+xml",
|
|
31
|
+
"doc" => "application/msword", "xls" => "application/vnd.ms-excel",
|
|
32
|
+
"docx" => "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
|
33
|
+
"xlsx" => "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
|
34
|
+
"pptx" => "application/vnd.openxmlformats-officedocument.presentationml.presentation",
|
|
35
|
+
"odt" => "application/vnd.oasis.opendocument.text", "rtf" => "application/rtf",
|
|
36
|
+
"mp3" => "audio/mpeg", "mp4" => "video/mp4", "eml" => "message/rfc822"
|
|
37
|
+
}.freeze
|
|
38
|
+
|
|
39
|
+
# Content type for unknown extensions.
|
|
40
|
+
DEFAULT_CONTENT_TYPE = "application/octet-stream"
|
|
41
|
+
|
|
42
|
+
# RFC 2045 type/subtype, optionally followed by parameters
|
|
43
|
+
# ("text/plain; charset=utf-8").
|
|
44
|
+
CONTENT_TYPE_FORMAT = %r{\A[!#$%&'*+.^_`|~0-9A-Za-z-]+/[!#$%&'*+.^_`|~0-9A-Za-z-]+(?:[ \t]*;.*)?\z}
|
|
45
|
+
|
|
46
|
+
attr_reader :name, :content_type, :content_id
|
|
47
|
+
|
|
48
|
+
# @param path [String, Pathname]
|
|
49
|
+
# @raise [ValidationError] when the file cannot be read
|
|
50
|
+
def self.from_file(path, name: nil, content_type: nil, content_id: nil)
|
|
51
|
+
content = begin
|
|
52
|
+
File.binread(path.to_s)
|
|
53
|
+
rescue SystemCallError, IOError => e
|
|
54
|
+
raise ValidationError, "Attachment file #{path.to_s.inspect} cannot be read: #{e.message}"
|
|
55
|
+
end
|
|
56
|
+
new(name: name || File.basename(path.to_s), content: content, content_type: content_type, content_id: content_id)
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# @param name [String] file name shown to the recipient
|
|
60
|
+
# @param content [String] raw (not encoded) bytes
|
|
61
|
+
# @param content_type [String, nil] detected from the extension when nil
|
|
62
|
+
# @param content_id [String, nil] makes the attachment inline (`cid:...`)
|
|
63
|
+
def initialize(name:, content:, content_type: nil, content_id: nil)
|
|
64
|
+
@name = self.class.validate_name!(name)
|
|
65
|
+
raise ValidationError, "Attachment content must be a String" unless content.is_a?(String)
|
|
66
|
+
|
|
67
|
+
@encoded = [content].pack("m0").freeze
|
|
68
|
+
@content_type = self.class.validate_content_type!(content_type || self.class.detect_content_type(@name))
|
|
69
|
+
@content_id = content_id.nil? ? nil : self.class.validate_content_id!(content_id)
|
|
70
|
+
if @encoded.bytesize > MAX_MESSAGE_SIZE
|
|
71
|
+
raise ValidationError, "Attachment #{@name} exceeds 10 MB after Base64 encoding"
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
freeze
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
# True for inline attachments (with a content ID).
|
|
78
|
+
def inline?
|
|
79
|
+
!@content_id.nil?
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
# Size after Base64 encoding — the number Haskimail checks against its limit.
|
|
83
|
+
def encoded_size
|
|
84
|
+
@encoded.bytesize
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
# @return [Hash] API representation
|
|
88
|
+
def to_haskimail
|
|
89
|
+
hash = { "Name" => @name, "Content" => @encoded, "ContentType" => @content_type }
|
|
90
|
+
content_id = @content_id
|
|
91
|
+
hash["ContentID"] = content_id if content_id
|
|
92
|
+
hash
|
|
93
|
+
end
|
|
94
|
+
alias to_h to_haskimail
|
|
95
|
+
|
|
96
|
+
# Name, type and size; never the content.
|
|
97
|
+
def inspect
|
|
98
|
+
"#<#{self.class.name} name=#{@name.inspect} content_type=#{@content_type.inspect} " \
|
|
99
|
+
"encoded_size=#{encoded_size}#{" content_id=#{@content_id.inspect}" if inline?}>"
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
# @api private
|
|
103
|
+
def self.validate_name!(name)
|
|
104
|
+
text = name.to_s
|
|
105
|
+
raise ValidationError, "Attachment name must be valid #{text.encoding} text" unless text.valid_encoding?
|
|
106
|
+
|
|
107
|
+
text = Util.utf8(text) # binary names (e.g. from a multipart upload) are relabelled
|
|
108
|
+
raise ValidationError, "Attachment name must not be blank" if text.strip.empty?
|
|
109
|
+
if text.match?(/[[:cntrl:]]/) || Util::UNSAFE_HEADER_CHARS.match?(text)
|
|
110
|
+
raise ValidationError, "Attachment name must not contain control characters or line separators"
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
extension = extension_of(text)
|
|
114
|
+
if FORBIDDEN_EXTENSIONS.include?(extension)
|
|
115
|
+
raise ValidationError, "Attachments of type .#{extension} are not accepted by Haskimail"
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
text.dup.freeze
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
# @api private
|
|
122
|
+
# @return [String] frozen, valid content type
|
|
123
|
+
def self.validate_content_type!(value)
|
|
124
|
+
text = header_text!(value, "Attachment content type")
|
|
125
|
+
raise ValidationError, "Invalid attachment content type #{text.inspect}" unless CONTENT_TYPE_FORMAT.match?(text)
|
|
126
|
+
|
|
127
|
+
text
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
# @api private
|
|
131
|
+
# @return [String] frozen content ID ("cid:logo" or "logo")
|
|
132
|
+
def self.validate_content_id!(value)
|
|
133
|
+
text = header_text!(value, "Attachment content ID")
|
|
134
|
+
raise ValidationError, "Attachment content ID must not be blank" if text.strip.empty?
|
|
135
|
+
|
|
136
|
+
text
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
# @api private
|
|
140
|
+
def self.header_text!(value, label)
|
|
141
|
+
unless value.is_a?(String) || value.is_a?(Symbol) || value.is_a?(Integer)
|
|
142
|
+
raise ValidationError, "#{label} must be a String"
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
text = Util.utf8(value.to_s)
|
|
146
|
+
# TAB is allowed: it may separate parameters ("text/plain;\tcharset=utf-8").
|
|
147
|
+
raise ValidationError, "#{label} must not contain control characters" if text.match?(/[[:cntrl:]&&[^\t]]/)
|
|
148
|
+
raise ValidationError, "#{label} must not contain line separators" if Util::UNSAFE_HEADER_CHARS.match?(text)
|
|
149
|
+
|
|
150
|
+
text.dup.freeze
|
|
151
|
+
end
|
|
152
|
+
private_class_method :header_text!
|
|
153
|
+
|
|
154
|
+
# Windows ignores trailing dots and spaces ("virus.exe. " runs as .exe),
|
|
155
|
+
# so they are stripped before the check.
|
|
156
|
+
# @api private
|
|
157
|
+
def self.extension_of(name)
|
|
158
|
+
base = File.basename(name.sub(/[.\s]+\z/, ""))
|
|
159
|
+
# A name like ".exe" has no extname, but Windows still treats it as an .exe.
|
|
160
|
+
extension = File.extname(base).delete_prefix(".")
|
|
161
|
+
extension = base.delete_prefix(".") if extension.empty? && base.start_with?(".")
|
|
162
|
+
extension.downcase
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
# Content type for a file name, from MIME_TYPES.
|
|
166
|
+
# @api private
|
|
167
|
+
def self.detect_content_type(name)
|
|
168
|
+
MIME_TYPES.fetch(extension_of(name), DEFAULT_CONTENT_TYPE)
|
|
169
|
+
end
|
|
170
|
+
end
|
|
171
|
+
end
|
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "version"
|
|
4
|
+
require_relative "redacted"
|
|
5
|
+
require_relative "errors"
|
|
6
|
+
require_relative "configuration"
|
|
7
|
+
require_relative "util"
|
|
8
|
+
require_relative "api_object"
|
|
9
|
+
require_relative "http/net_http_transport"
|
|
10
|
+
require_relative "http/retry_policy"
|
|
11
|
+
require_relative "http/response_parser"
|
|
12
|
+
|
|
13
|
+
module Haskimail
|
|
14
|
+
# Shared HTTP plumbing for {ServerClient} and {AccountClient}:
|
|
15
|
+
# authentication, JSON, error mapping, retries and logging.
|
|
16
|
+
class BaseClient
|
|
17
|
+
include Redacted
|
|
18
|
+
|
|
19
|
+
# Methods accepted by #request.
|
|
20
|
+
HTTP_METHODS = %i[get post put patch delete].freeze
|
|
21
|
+
|
|
22
|
+
attr_reader :config
|
|
23
|
+
|
|
24
|
+
# @param token [String] API token
|
|
25
|
+
# @param transport [#call, nil] custom HTTP transport (see HTTP::NetHttpTransport)
|
|
26
|
+
# @param sleeper [#call, nil] used between retries; injectable for tests
|
|
27
|
+
# @param options [Hash] overrides of {Configuration} attributes
|
|
28
|
+
def initialize(token, transport: nil, sleeper: nil, **options)
|
|
29
|
+
@token = self.class.validate_token!(token)
|
|
30
|
+
@config = Haskimail.configuration.dup.update(**options).freeze
|
|
31
|
+
@transport = transport || HTTP::NetHttpTransport.new(proxy: @config.proxy, ca_file: @config.ca_file)
|
|
32
|
+
@retry_policy = HTTP::RetryPolicy.new(max_retries: @config.max_retries)
|
|
33
|
+
@sleeper = sleeper || ->(seconds) { sleep(seconds) }
|
|
34
|
+
@user_agent = build_user_agent
|
|
35
|
+
build_resources
|
|
36
|
+
freeze
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# Low-level escape hatch for endpoints not wrapped by the SDK.
|
|
40
|
+
#
|
|
41
|
+
# client.request(:get, "/messages", query: { "count" => 10 })
|
|
42
|
+
#
|
|
43
|
+
# @param method [Symbol] :get, :post, :put, :patch, :delete
|
|
44
|
+
# @param path [String] path starting with "/"
|
|
45
|
+
# @param query [Hash] query string parameters (sent verbatim)
|
|
46
|
+
# @param body [Hash, Array, nil] JSON body (sent verbatim, strings converted to UTF-8)
|
|
47
|
+
# @return [ApiObject, Array, nil]
|
|
48
|
+
def request(method, path, query: nil, body: nil)
|
|
49
|
+
wrap(perform(method, path, query: query, body: body))
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# The token is never shown.
|
|
53
|
+
def inspect
|
|
54
|
+
"#<#{self.class.name} base_url=#{config.base_url.inspect} token=[FILTERED]>"
|
|
55
|
+
end
|
|
56
|
+
alias to_s inspect
|
|
57
|
+
|
|
58
|
+
# @api private
|
|
59
|
+
def self.validate_token!(token)
|
|
60
|
+
raise ConfigurationError, "API token is required" if Util.blank?(token)
|
|
61
|
+
|
|
62
|
+
value = token.to_s.b
|
|
63
|
+
unless value.match?(/\A[\x21-\x7e]+\z/n)
|
|
64
|
+
raise ConfigurationError, "API token contains forbidden characters (spaces, line breaks or non-ASCII)"
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
value.dup.freeze
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# @api private
|
|
71
|
+
# Performs the request with retries and returns the parsed JSON (Hash/Array/nil).
|
|
72
|
+
def perform(method, path, query: nil, body: nil, idempotent: nil)
|
|
73
|
+
request = build_request(method, path, query, body, idempotent)
|
|
74
|
+
attempt = 0
|
|
75
|
+
|
|
76
|
+
loop do
|
|
77
|
+
response = attempt_request(request, attempt)
|
|
78
|
+
data = HTTP::ResponseParser.parse(response)
|
|
79
|
+
if @retry_policy.retry_response?(request, response, HTTP::ResponseParser.error_code(data), attempt)
|
|
80
|
+
attempt += 1
|
|
81
|
+
pause(attempt, response, "HTTP #{response.status}")
|
|
82
|
+
next
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
return checked(request, response, data)
|
|
86
|
+
rescue ConnectionError => e
|
|
87
|
+
raise unless @retry_policy.retry_error?(request, e, attempt)
|
|
88
|
+
|
|
89
|
+
attempt += 1
|
|
90
|
+
pause(attempt, nil, e.class.name)
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
# @api private
|
|
95
|
+
# Ensures a documented JSON-object response.
|
|
96
|
+
def expect_object(data)
|
|
97
|
+
return data if data.is_a?(Hash)
|
|
98
|
+
|
|
99
|
+
raise UnexpectedResponseError.new("Expected a JSON object from Haskimail, got #{data.class}. " \
|
|
100
|
+
"If this was a send request, the email MAY have been accepted — " \
|
|
101
|
+
"check the message log before re-sending", http_status: 200)
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
# Wraps parsed JSON: objects become ApiObject, arrays are mapped.
|
|
105
|
+
# @api private
|
|
106
|
+
def wrap(data)
|
|
107
|
+
case data
|
|
108
|
+
when Hash then ApiObject.new(data)
|
|
109
|
+
when Array then data.map { |item| item.is_a?(Hash) ? ApiObject.new(item) : item }
|
|
110
|
+
else data
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
protected
|
|
115
|
+
|
|
116
|
+
# Name of the authentication header.
|
|
117
|
+
def token_header
|
|
118
|
+
raise NotImplementedError
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
# Subclasses create their resource objects here.
|
|
122
|
+
def build_resources; end
|
|
123
|
+
|
|
124
|
+
private
|
|
125
|
+
|
|
126
|
+
def build_request(method, path, query, body, idempotent)
|
|
127
|
+
unless HTTP_METHODS.include?(method)
|
|
128
|
+
raise ValidationError, "HTTP method must be one of #{HTTP_METHODS.map(&:inspect).join(', ')}"
|
|
129
|
+
end
|
|
130
|
+
raise ValidationError, "path must start with '/'" unless path.is_a?(String) && path.start_with?("/")
|
|
131
|
+
|
|
132
|
+
headers = { "Accept" => "application/json", "User-Agent" => @user_agent, token_header => @token }
|
|
133
|
+
payload = nil
|
|
134
|
+
unless body.nil?
|
|
135
|
+
headers["Content-Type"] = "application/json"
|
|
136
|
+
payload = Util.generate_json(Util.utf8_strings(body))
|
|
137
|
+
end
|
|
138
|
+
HTTP::Request.new(method: method, url: build_url(path, query), headers: headers.freeze, body: payload,
|
|
139
|
+
idempotent: idempotent)
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
def build_url(path, query)
|
|
143
|
+
url = config.base_url + path
|
|
144
|
+
url += "?#{URI.encode_www_form(query)}" if query && !query.empty?
|
|
145
|
+
URI.parse(url)
|
|
146
|
+
url
|
|
147
|
+
rescue URI::InvalidURIError
|
|
148
|
+
raise ValidationError, "Invalid request path #{path.inspect}"
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
def checked(request, response, data)
|
|
152
|
+
if (200..299).cover?(response.status) && HTTP::ResponseParser.malformed_error_code?(data)
|
|
153
|
+
raise HTTP::ResponseParser.unexpected_body_error(request, response, "a non-numeric ErrorCode")
|
|
154
|
+
end
|
|
155
|
+
raise HTTP::ResponseParser.api_error(response, data) unless HTTP::ResponseParser.success?(response, data)
|
|
156
|
+
if data.nil? && !HTTP::ResponseParser.blank_body?(response.body)
|
|
157
|
+
raise HTTP::ResponseParser.unexpected_body_error(request, response)
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
data
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
def attempt_request(request, attempt)
|
|
164
|
+
started = monotonic_now
|
|
165
|
+
response = @transport.call(request, open_timeout: config.open_timeout,
|
|
166
|
+
read_timeout: config.read_timeout,
|
|
167
|
+
write_timeout: config.write_timeout)
|
|
168
|
+
log(:debug) do
|
|
169
|
+
"Haskimail #{request.method.upcase} #{log_path(request)} -> #{response.status} " \
|
|
170
|
+
"(#{((monotonic_now - started) * 1000).round} ms, attempt #{attempt + 1})"
|
|
171
|
+
end
|
|
172
|
+
response
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
def pause(attempt, response, reason)
|
|
176
|
+
delay = @retry_policy.delay(attempt, response)
|
|
177
|
+
log(:warn) { "Haskimail retry #{attempt}/#{config.max_retries} in #{delay.round(2)}s (#{reason})" }
|
|
178
|
+
@sleeper.call(delay)
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
# The query string is never logged: it contains recipient addresses (personal data).
|
|
182
|
+
def log_path(request)
|
|
183
|
+
URI.parse(request.url).path
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
def log(level, &)
|
|
187
|
+
config.logger&.public_send(level, &)
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
def monotonic_now
|
|
191
|
+
Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
def build_user_agent
|
|
195
|
+
agent = "haskimail-ruby/#{VERSION} ruby/#{RUBY_VERSION} (#{RUBY_PLATFORM})"
|
|
196
|
+
config.user_agent ? "#{agent} #{config.user_agent}" : agent
|
|
197
|
+
end
|
|
198
|
+
end
|
|
199
|
+
end
|