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