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.
@@ -0,0 +1,193 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../errors"
4
+ require_relative "../util"
5
+ require_relative "../api_object"
6
+ require_relative "../redacted"
7
+
8
+ module Haskimail
9
+ # API resources. Instances are created by the clients — use
10
+ # `client.emails`, `client.templates` etc. instead of instantiating them.
11
+ module Resources
12
+ # Shared plumbing of every resource: requests, paging checks, wrapping.
13
+ # @api private
14
+ class Base
15
+ include Redacted
16
+
17
+ # Query names of the paging parameters.
18
+ PAGING = { count: "count", offset: "offset" }.freeze
19
+
20
+ def initialize(client)
21
+ @client = client
22
+ freeze
23
+ end
24
+
25
+ # Resource name only.
26
+ def inspect
27
+ "#<#{self.class.name}>"
28
+ end
29
+
30
+ private
31
+
32
+ def api_get(path, query = nil)
33
+ @client.perform(:get, path, query: query)
34
+ end
35
+
36
+ # POSTs without side effects on repeat (validation, archive, suppression
37
+ # lists) pass `idempotent: true` so network failures are retried.
38
+ def api_post(path, body = nil, idempotent: nil)
39
+ @client.perform(:post, path, body: body, idempotent: idempotent)
40
+ end
41
+
42
+ # PUTs that trigger side effects pass `idempotent: false` so they are
43
+ # never repeated automatically.
44
+ def api_put(path, body = nil, idempotent: nil)
45
+ @client.perform(:put, path, body: body, idempotent: idempotent)
46
+ end
47
+
48
+ def api_patch(path, body = nil)
49
+ @client.perform(:patch, path, body: body)
50
+ end
51
+
52
+ def api_delete(path)
53
+ @client.perform(:delete, path)
54
+ end
55
+
56
+ def object(data)
57
+ ApiObject.new(@client.expect_object(data))
58
+ end
59
+
60
+ # Every documented send response carries ErrorCode; without it the
61
+ # outcome is unknown (the email may or may not have been accepted).
62
+ def send_result(data)
63
+ result = SendResult.new(@client.expect_object(data))
64
+ return result unless result.error_code.nil?
65
+
66
+ raise UnexpectedResponseError.new("Send response without ErrorCode. The email MAY have been accepted — " \
67
+ "check the message log before re-sending", http_status: 200)
68
+ end
69
+
70
+ # Returns the paginator, or iterates it when a block is given.
71
+ def run(paginator, block)
72
+ block ? paginator.each(&block) : paginator
73
+ end
74
+
75
+ def path(template, *segments)
76
+ Util.path(template, *segments)
77
+ end
78
+
79
+ def query(params, mapping)
80
+ Util.query(params, mapping)
81
+ end
82
+
83
+ # Merges an optional positional Hash with keyword arguments, so both
84
+ # `deliver(from: "a")` and `deliver({ "From" => "a" })` work.
85
+ def merge_params(params, fields)
86
+ raise ValidationError, "Expected a Hash of fields, got #{params.class}" unless params.nil? || params.is_a?(Hash)
87
+
88
+ (params || {}).merge(fields)
89
+ end
90
+
91
+ def paginate(items_key, page_size:, max_window: nil)
92
+ Paginator.new(items_key: items_key, page_size: page_size, max_window: max_window) do |count, offset|
93
+ @client.expect_object(yield(count, offset))
94
+ end
95
+ end
96
+
97
+ # Documented count/offset ranges (e.g. messages: count 1..500,
98
+ # count + offset <= 10000), checked before the request. Without a
99
+ # documented maximum, count must still be a positive Integer.
100
+ def check_paging!(count, offset, max_count: nil, max_window: nil)
101
+ check_integer!(:count, count, 1, max_count) unless count.nil?
102
+ check_integer!(:offset, offset, 0, nil) unless offset.nil?
103
+ return unless max_window && count.to_i + offset.to_i > max_window
104
+
105
+ raise ValidationError, "count + offset must not exceed #{max_window}"
106
+ end
107
+
108
+ def check_integer!(name, value, min, max)
109
+ return if value.is_a?(Integer) && value.between?(min, max || Float::INFINITY)
110
+
111
+ range = max ? "between #{min} and #{max}" : "of at least #{min}"
112
+ raise ValidationError, "#{name} must be an Integer #{range}"
113
+ end
114
+
115
+ # Without a documented maximum, Paginator itself checks page_size.
116
+ def check_page_size!(page_size, max)
117
+ return if page_size.is_a?(Integer) && page_size.between?(1, max)
118
+
119
+ raise ValidationError, "page_size must be an Integer between 1 and #{max}"
120
+ end
121
+
122
+ def reject_paging!(filters)
123
+ clash = filters.keys & %i[count offset]
124
+ return if clash.empty?
125
+
126
+ raise ValidationError, "Use page_size: instead of #{clash.join('/')} when iterating"
127
+ end
128
+ end
129
+ end
130
+
131
+ # Lazily walks through `count`/`offset` pages. Enumerable, so `first(10)`,
132
+ # `lazy`, `each_slice` etc. only request the pages they actually need.
133
+ #
134
+ # client.messages.each(tag: "orders").each { |message| ... }
135
+ class Paginator
136
+ include Enumerable
137
+
138
+ def initialize(items_key:, page_size:, max_window: nil, &fetch)
139
+ unless page_size.is_a?(Integer) && page_size.positive?
140
+ raise ValidationError, "page_size must be a positive Integer"
141
+ end
142
+
143
+ @items_key = items_key
144
+ @page_size = page_size
145
+ @max_window = max_window
146
+ @fetch = fetch
147
+ freeze
148
+ end
149
+
150
+ # Yields every item, requesting pages lazily.
151
+ # @yieldparam item [ApiObject]
152
+ def each
153
+ return enum_for(:each) unless block_given?
154
+
155
+ offset = 0
156
+ loop do
157
+ count = @max_window ? [@page_size, @max_window - offset].min : @page_size
158
+ break unless count.positive?
159
+
160
+ page = @fetch.call(count, offset)
161
+ items = page.fetch(@items_key, [])
162
+ unless items.is_a?(Array) && items.all?(Hash)
163
+ raise UnexpectedResponseError.new("Expected #{@items_key} to be an array of objects", http_status: 200)
164
+ end
165
+
166
+ items.each { |item| yield ApiObject.new(item) }
167
+ offset += items.size
168
+ break if last_page?(page["TotalCount"], items.size, count, offset)
169
+ end
170
+ self
171
+ end
172
+
173
+ # With TotalCount the API says how many records exist, so a short page
174
+ # (the server may cap `count`) does not end the walk; only an empty page
175
+ # or reaching the total does. Without it, a short page is the last one.
176
+ def last_page?(total, size, count, offset)
177
+ return size < count if total.nil?
178
+
179
+ total = Integer(total, exception: false) if total.is_a?(String)
180
+ unless total.is_a?(Integer)
181
+ raise UnexpectedResponseError.new("TotalCount is not a number: #{total.inspect}", http_status: 200)
182
+ end
183
+
184
+ size.zero? || offset >= total
185
+ end
186
+ private :last_page?
187
+
188
+ # Items key and page size.
189
+ def inspect
190
+ "#<#{self.class.name} items=#{@items_key.inspect} page_size=#{@page_size}>"
191
+ end
192
+ end
193
+ end
@@ -0,0 +1,352 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "base"
4
+ require_relative "../attachment"
5
+
6
+ module Haskimail
7
+ module Resources
8
+ # Sending email: https://haskimail.ru/developer/api/email-api
9
+ #
10
+ # Fields may be given as snake_case keywords (`html_body:`) or with exact
11
+ # API names as String keys (`"HtmlBody"`). Documented limits are checked
12
+ # before anything is sent.
13
+ class Emails < Base
14
+ # Emails per batch request.
15
+ MAX_BATCH_SIZE = 500
16
+ # Size of a batch request.
17
+ MAX_BATCH_BYTES = 50 * 1024 * 1024
18
+
19
+ # Documented limits (https://haskimail.ru/developer/user-guide/send-email-with-api).
20
+ # Lengths are counted in UTF-16 code units, as Haskimail counts them.
21
+ MAX_RECIPIENTS = 50
22
+ # Length of From, in UTF-16 units.
23
+ MAX_FROM_LENGTH = 255
24
+ # Length of Subject, in UTF-16 units.
25
+ MAX_SUBJECT_LENGTH = 2000
26
+ # Length of Tag, in UTF-16 units.
27
+ MAX_TAG_LENGTH = 1000
28
+ # Size of HtmlBody and of TextBody, each.
29
+ MAX_BODY_BYTES = 5 * 1024 * 1024
30
+ # The whole message, attachments included (counted after Base64).
31
+ MAX_MESSAGE_BYTES = Attachment::MAX_MESSAGE_SIZE
32
+ # Field => maximum length.
33
+ LENGTH_LIMITS = { "From" => MAX_FROM_LENGTH, "Subject" => MAX_SUBJECT_LENGTH, "Tag" => MAX_TAG_LENGTH }.freeze
34
+
35
+ # Fields that become email headers: line breaks or other control
36
+ # characters there could inject extra headers (e.g. a hidden Bcc).
37
+ HEADER_FIELDS = %w[From To Cc Bcc ReplyTo Subject Tag].freeze
38
+ # Fields holding address lists (Arrays are joined with ", ").
39
+ ADDRESS_FIELDS = %w[To Cc Bcc ReplyTo].freeze
40
+ # Fields counted against MAX_RECIPIENTS.
41
+ RECIPIENT_FIELDS = %w[To Cc Bcc].freeze
42
+ # Email bodies.
43
+ BODY_FIELDS = %w[HtmlBody TextBody].freeze
44
+ # Exactly one of these selects the template.
45
+ TEMPLATE_FIELDS = %w[TemplateId TemplateAlias].freeze
46
+ # Every documented field of /email and /email/withTemplate. Other
47
+ # spellings ("subject", :replyTo, "reply_to") are mapped to these names,
48
+ # so no check can be bypassed by writing a field name differently.
49
+ EMAIL_FIELDS = (HEADER_FIELDS + BODY_FIELDS + TEMPLATE_FIELDS + %w[
50
+ TemplateModel InlineCss Headers Attachments Metadata MessageStream TrackOpens TrackLinks
51
+ ]).freeze
52
+ # Keys of one custom header.
53
+ HEADER_ITEM_FIELDS = %w[Name Value].freeze
54
+ # Keys of one attachment.
55
+ ATTACHMENT_FIELDS = %w[Name Content ContentType ContentID].freeze
56
+ # Control characters (except TAB) plus Unicode line separators.
57
+ UNSAFE_HEADER_CHARS = Util::UNSAFE_HEADER_CHARS
58
+ # RFC 5322 field-name: printable ASCII except ":".
59
+ HEADER_NAME = /\A[\x21-\x39\x3B-\x7E]+\z/n
60
+ # Quoted display name in an address list.
61
+ QUOTED = /"(?:\\.|[^"\\])*"/
62
+ # <address> part of an address.
63
+ ANGLE_ADDR = /<[^>]*>/
64
+ # (comment) in an address.
65
+ COMMENT = /\([^)]*\)/
66
+
67
+ # Number of addresses in `a@b.example, "Фамилия, Имя" <c@d.example>`: commas inside
68
+ # quoted names, comments or angle brackets do not separate addresses.
69
+ # @api private
70
+ def self.count_addresses(list)
71
+ list.to_s.gsub(QUOTED, '""').gsub(COMMENT, "").gsub(ANGLE_ADDR, "<>")
72
+ .split(",").count { |part| !part.strip.empty? }
73
+ end
74
+
75
+ # POST /email — sends one email.
76
+ #
77
+ # client.emails.deliver(
78
+ # from: "Магазин <shop@example.com>",
79
+ # to: "ivan@example.com",
80
+ # subject: "Заказ №1042 оформлен",
81
+ # html_body: "<p>Здравствуйте!</p>",
82
+ # text_body: "Здравствуйте!",
83
+ # tag: "orders",
84
+ # metadata: { order_id: "1042" },
85
+ # headers: { "X-Order-Id" => "1042" },
86
+ # attachments: [Haskimail::Attachment.from_file("invoice.pdf")],
87
+ # message_stream: "2081"
88
+ # )
89
+ #
90
+ # @return [SendResult]
91
+ # @raise [UnprocessableEntityError] when the email is rejected
92
+ def deliver(message = nil, **fields)
93
+ send_result(api_post("/email", prepare_plain(merge_params(message, fields))))
94
+ end
95
+
96
+ # POST /email/batch — up to 500 emails (plain or template-based) in one
97
+ # request. Never raises for individual rejected emails: inspect the
98
+ # returned {BatchResult}.
99
+ #
100
+ # @param messages [Array<Hash>]
101
+ # @return [BatchResult]
102
+ def deliver_batch(messages)
103
+ payload = prepare_batch(messages) { |message| prepare_batch_item(message) }
104
+ batch_result(api_post("/email/batch", check_batch_size!(payload)), payload.size)
105
+ end
106
+
107
+ # POST /email/withTemplate — sends one email based on a stored template.
108
+ #
109
+ # client.emails.deliver_with_template(
110
+ # template_alias: "order-created",
111
+ # template_model: { "{{name}}" => "Иван", "{{order}}" => "1042" },
112
+ # from: "shop@example.com", to: "ivan@example.com"
113
+ # )
114
+ #
115
+ # Note: TemplateModel keys are replaced literally, so they include the
116
+ # delimiters (`"{{name}}"`), exactly as in the documentation.
117
+ #
118
+ # @return [SendResult]
119
+ def deliver_with_template(message = nil, **fields)
120
+ send_result(api_post("/email/withTemplate", prepare_template(merge_params(message, fields))))
121
+ end
122
+
123
+ # POST /email/batchWithTemplates — up to 500 template emails.
124
+ # @return [BatchResult]
125
+ def deliver_batch_with_templates(messages)
126
+ payload = { "Messages" => prepare_batch(messages) { |message| prepare_template(message) } }
127
+ batch_result(api_post("/email/batchWithTemplates", check_batch_size!(payload)), payload["Messages"].size)
128
+ end
129
+
130
+ private
131
+
132
+ def prepare(message)
133
+ payload = api_fields(message)
134
+ # Stream IDs are strings in the API ("2081"); an Integer is accepted too.
135
+ payload["MessageStream"] = payload["MessageStream"].to_s if payload["MessageStream"].is_a?(Integer)
136
+ join_addresses!(payload)
137
+ %w[From To].each do |field|
138
+ raise ValidationError, "#{field} is required" if Util.blank?(payload[field])
139
+ end
140
+ check_header_fields!(payload)
141
+ check_limits!(payload)
142
+ normalize_headers!(payload)
143
+ check_size!(payload)
144
+ payload
145
+ end
146
+
147
+ def prepare_plain(message)
148
+ payload = prepare(message)
149
+ return payload if BODY_FIELDS.any? { |field| !payload[field].to_s.empty? }
150
+
151
+ raise ValidationError, "Either html_body or text_body is required"
152
+ end
153
+
154
+ def prepare_template(message)
155
+ payload = prepare(message)
156
+ given = TEMPLATE_FIELDS.count { |field| payload.key?(field) }
157
+ raise ValidationError, "template_id or template_alias is required" if given.zero?
158
+ raise ValidationError, "Pass either template_id or template_alias, not both" if given > 1
159
+
160
+ payload
161
+ end
162
+
163
+ # /email/batch accepts plain and template-based emails alike.
164
+ def prepare_batch_item(message)
165
+ fields = api_fields(message)
166
+ template = TEMPLATE_FIELDS.any? { |field| fields.key?(field) }
167
+ template ? prepare_template(message) : prepare_plain(message)
168
+ end
169
+
170
+ # Converts keys to API names and maps any other spelling of a
171
+ # documented field to its canonical name.
172
+ # Top-level keys are mapped first, so that Metadata and TemplateModel
173
+ # are recognised as user data (and left untouched) under any spelling.
174
+ def api_fields(message)
175
+ Util.to_api(canonicalize(expand_headers(message), EMAIL_FIELDS, "Field"))
176
+ end
177
+
178
+ # Renames keys that differ from a known field only in case, punctuation
179
+ # or Unicode compatibility form ("reply_to", "REPLYTO", "ſubject");
180
+ # unknown keys are kept as given.
181
+ def canonicalize(hash, fields, label)
182
+ index = fields.to_h { |field| [field_key(field), field] }
183
+ hash.each_with_object({}) do |(key, value), result|
184
+ name = index.fetch(field_key(key), key)
185
+ raise ValidationError, "#{label} #{name} is given more than once" if result.key?(name)
186
+
187
+ result[name] = value
188
+ end
189
+ end
190
+
191
+ def prepare_batch(messages)
192
+ raise ValidationError, "Batch must be an Array of Hashes" unless messages.is_a?(Array) && messages.all?(Hash)
193
+ unless messages.size.between?(1, MAX_BATCH_SIZE)
194
+ raise ValidationError, "Batch must contain 1..#{MAX_BATCH_SIZE} emails, got #{messages.size}"
195
+ end
196
+
197
+ messages.each_with_index.map do |message, index|
198
+ yield(message)
199
+ rescue ValidationError => e
200
+ raise ValidationError, "Email ##{index}: #{e.message}"
201
+ end
202
+ end
203
+
204
+ def check_batch_size!(payload)
205
+ size = Util.generate_json(payload).bytesize
206
+ return payload if size <= MAX_BATCH_BYTES
207
+
208
+ raise ValidationError, "Batch request is #{size} bytes; the limit is 50 MB"
209
+ end
210
+
211
+ # Accepts `headers: { "X-Id" => "1" }` as a shortcut for
212
+ # `[{ "Name" => "X-Id", "Value" => "1" }]`. Header names are kept
213
+ # verbatim (never camel-cased).
214
+ def expand_headers(message)
215
+ message.to_h do |key, value|
216
+ if headers_key?(key) && value.is_a?(Hash)
217
+ value = value.map { |name, item| { "Name" => name.to_s, "Value" => item.to_s } }
218
+ end
219
+ [key, value]
220
+ end
221
+ end
222
+
223
+ def headers_key?(key)
224
+ field_key(key) == "headers"
225
+ end
226
+
227
+ # Comparison form of a field name: NFKC (so "ſ" and fullwidth letters
228
+ # become ASCII), case-folded, letters and digits only.
229
+ def field_key(key)
230
+ return nil unless key.is_a?(String) || key.is_a?(Symbol)
231
+
232
+ Util.utf8(key.to_s).unicode_normalize(:nfkc).downcase(:fold).gsub(/[^[:alnum:]]/, "")
233
+ end
234
+
235
+ # `to: ["a@example.com", "b@example.com"]` becomes the comma-separated
236
+ # string the API expects.
237
+ def join_addresses!(payload)
238
+ ADDRESS_FIELDS.each do |field|
239
+ value = payload[field]
240
+ next unless value.is_a?(Array)
241
+
242
+ raise ValidationError, "#{field} must contain only Strings" unless value.all?(String)
243
+
244
+ payload[field] = value.join(", ")
245
+ end
246
+ end
247
+
248
+ def check_header_fields!(payload)
249
+ HEADER_FIELDS.each do |field|
250
+ value = payload[field]
251
+ next if value.nil?
252
+
253
+ value = payload[field] = value.to_s if value.is_a?(Symbol) || value.is_a?(Numeric)
254
+ raise ValidationError, "#{field} must be a String" unless value.is_a?(String)
255
+ raise ValidationError, "#{field} must not contain line breaks or control characters" if unsafe?(value)
256
+ end
257
+ end
258
+
259
+ def check_limits!(payload)
260
+ LENGTH_LIMITS.each do |field, limit|
261
+ length = utf16_length(payload[field])
262
+ raise ValidationError, "#{field} is longer than #{limit} characters (#{length})" if length > limit
263
+ end
264
+ BODY_FIELDS.each do |field|
265
+ size = payload[field].to_s.bytesize
266
+ raise ValidationError, "#{field} exceeds 5 MB (#{size} bytes)" if size > MAX_BODY_BYTES
267
+ end
268
+ recipients = RECIPIENT_FIELDS.sum { |field| self.class.count_addresses(payload[field]) }
269
+ return if recipients <= MAX_RECIPIENTS
270
+
271
+ raise ValidationError, "#{recipients} recipients in To, Cc and Bcc; at most #{MAX_RECIPIENTS} are allowed"
272
+ end
273
+
274
+ # @param value [String, nil] valid UTF-8 (Util.to_api converts every string)
275
+ def utf16_length(value)
276
+ value.nil? ? 0 : value.encode(Encoding::UTF_16LE).bytesize / 2
277
+ end
278
+
279
+ # @param value [String] valid UTF-8 (Util.to_api converts every string)
280
+ def unsafe?(value)
281
+ UNSAFE_HEADER_CHARS.match?(value)
282
+ end
283
+
284
+ def normalize_headers!(payload)
285
+ headers = payload["Headers"]
286
+ return if headers.nil?
287
+
288
+ unless headers.is_a?(Array) && headers.all?(Hash)
289
+ raise ValidationError, "headers must be a Hash or an Array of { name:, value: }"
290
+ end
291
+
292
+ payload["Headers"] = headers.map do |item|
293
+ header = canonicalize(item, HEADER_ITEM_FIELDS, "Header field")
294
+ name = header["Name"].to_s
295
+ raise ValidationError, "Invalid header name #{name.b.inspect}" unless HEADER_NAME.match?(name.b)
296
+
297
+ value = header["Value"].to_s
298
+ raise ValidationError, "Header #{name} must not contain line breaks or control characters" if unsafe?(value)
299
+
300
+ header.merge("Value" => value)
301
+ end
302
+ end
303
+
304
+ # Attachments are validated and the whole message (bodies + Base64
305
+ # attachments) must fit into 10 MB.
306
+ def check_size!(payload)
307
+ normalize_attachments!(payload)
308
+ attachments = (payload["Attachments"] || []).sum { |attachment| attachment["Content"].to_s.bytesize }
309
+ total = BODY_FIELDS.sum { |field| payload[field].to_s.bytesize } + attachments
310
+ return if total <= MAX_MESSAGE_BYTES
311
+
312
+ raise ValidationError, "The email is #{total} bytes with attachments; the limit is 10 MB"
313
+ end
314
+
315
+ # Name, ContentType and ContentID become MIME headers of the part, so
316
+ # they get the same checks as attachments built with Haskimail::Attachment.
317
+ def normalize_attachments!(payload)
318
+ attachments = payload["Attachments"]
319
+ return if attachments.nil?
320
+ raise ValidationError, "attachments must be an Array" unless attachments.is_a?(Array)
321
+
322
+ payload["Attachments"] = attachments.map do |item|
323
+ raise ValidationError, "Each attachment must be a Hash or Haskimail::Attachment" unless item.is_a?(Hash)
324
+
325
+ attachment = canonicalize(item, ATTACHMENT_FIELDS, "Attachment field")
326
+ Attachment.validate_name!(attachment["Name"])
327
+ Attachment.validate_content_type!(attachment["ContentType"]) unless attachment["ContentType"].nil?
328
+ Attachment.validate_content_id!(attachment["ContentID"]) unless attachment["ContentID"].nil?
329
+ attachment
330
+ end
331
+ end
332
+
333
+ # One result per email, in order; anything else means the outcome of
334
+ # some emails is unknown.
335
+ def batch_result(data, expected)
336
+ unless data.is_a?(Array) && data.all?(Hash) && data.size == expected
337
+ shape = data.is_a?(Array) ? "#{data.size} results for #{expected} emails" : data.class.to_s
338
+ raise UnexpectedResponseError.new("Unexpected batch response: #{shape}. Emails MAY have been " \
339
+ "accepted — check the message log before re-sending", http_status: 200)
340
+ end
341
+
342
+ results = data.map { |item| SendResult.new(item) }
343
+ if results.any? { |result| result.error_code.nil? }
344
+ raise UnexpectedResponseError.new("Batch result without a numeric ErrorCode. Emails MAY have been " \
345
+ "accepted — check the message log before re-sending", http_status: 200)
346
+ end
347
+
348
+ BatchResult.new(results)
349
+ end
350
+ end
351
+ end
352
+ end