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,250 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "date"
|
|
4
|
+
require "json"
|
|
5
|
+
require "time"
|
|
6
|
+
require "uri"
|
|
7
|
+
|
|
8
|
+
require_relative "errors"
|
|
9
|
+
|
|
10
|
+
module Haskimail
|
|
11
|
+
# @api private
|
|
12
|
+
# Internal helpers: request-key conversion, path escaping, date formatting.
|
|
13
|
+
module Util
|
|
14
|
+
# API field names that do not follow plain CamelCase of the snake_case key.
|
|
15
|
+
IRREGULAR_KEYS = {
|
|
16
|
+
id: "ID",
|
|
17
|
+
server_id: "ServerID",
|
|
18
|
+
template_id: "TemplateId",
|
|
19
|
+
associated_server_id: "AssociatedServerId"
|
|
20
|
+
}.freeze
|
|
21
|
+
|
|
22
|
+
# Values under these keys are user data and are sent verbatim:
|
|
23
|
+
# their keys are never renamed and nil values are kept.
|
|
24
|
+
OPAQUE_KEYS = %w[Metadata TemplateModel TestRenderModel].freeze
|
|
25
|
+
|
|
26
|
+
# The API interprets every timestamp as Moscow time.
|
|
27
|
+
MOSCOW_OFFSET = "+03:00"
|
|
28
|
+
# The same offset in seconds, for Time#getlocal.
|
|
29
|
+
MOSCOW_UTC_OFFSET = 3 * 60 * 60
|
|
30
|
+
|
|
31
|
+
# Characters that must never reach an email header: control characters
|
|
32
|
+
# (except TAB) and Unicode line separators.
|
|
33
|
+
UNSAFE_HEADER_CHARS = /[\x00-\x08\x0A-\x1F\x7F\u0085\u2028\u2029]/
|
|
34
|
+
|
|
35
|
+
module_function
|
|
36
|
+
|
|
37
|
+
# Converts a Ruby-style key into the API field name.
|
|
38
|
+
# :html_body => "HtmlBody"
|
|
39
|
+
# :HtmlBody => "HtmlBody"
|
|
40
|
+
# "HtmlBody" => "HtmlBody" (strings are always taken verbatim)
|
|
41
|
+
def api_key(key)
|
|
42
|
+
case key
|
|
43
|
+
when String then key
|
|
44
|
+
when Symbol
|
|
45
|
+
IRREGULAR_KEYS.fetch(key) do
|
|
46
|
+
name = key.to_s
|
|
47
|
+
name.match?(/[A-Z]/) ? name : name.split("_").map(&:capitalize).join
|
|
48
|
+
end
|
|
49
|
+
else
|
|
50
|
+
raise ValidationError, "Field names must be Symbols or Strings, got #{key.class}"
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# Deep-converts a request body into API form: renames keys, drops nil
|
|
55
|
+
# values (except inside opaque user data) and rejects duplicate fields
|
|
56
|
+
# such as `{ html_body: "a", "HtmlBody" => "b" }`.
|
|
57
|
+
def to_api(value, depth = 0)
|
|
58
|
+
check_depth!(depth)
|
|
59
|
+
case value
|
|
60
|
+
when Hash
|
|
61
|
+
value.each_with_object({}) do |(key, item), result|
|
|
62
|
+
next if item.nil?
|
|
63
|
+
|
|
64
|
+
name = api_key(key)
|
|
65
|
+
raise ValidationError, "Field #{name} is given more than once" if result.key?(name)
|
|
66
|
+
|
|
67
|
+
result[name] = OPAQUE_KEYS.include?(name) ? utf8_strings(item, depth + 1) : to_api(item, depth + 1)
|
|
68
|
+
end
|
|
69
|
+
when Array then value.map { |item| to_api(item, depth + 1) }
|
|
70
|
+
when String then utf8(value)
|
|
71
|
+
else value.respond_to?(:to_haskimail) ? to_api(value.to_haskimail, depth + 1) : value
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
# JSON is UTF-8: binary strings holding UTF-8 are relabelled, other
|
|
76
|
+
# encodings converted. Broken text raises ValidationError (json 3.0 will
|
|
77
|
+
# raise on binary input, so this keeps behaviour identical everywhere).
|
|
78
|
+
def utf8(text)
|
|
79
|
+
return text if text.encoding == Encoding::UTF_8 && text.valid_encoding?
|
|
80
|
+
|
|
81
|
+
converted = if text.encoding == Encoding::BINARY
|
|
82
|
+
text.dup.force_encoding(Encoding::UTF_8)
|
|
83
|
+
else
|
|
84
|
+
text.encode(Encoding::UTF_8)
|
|
85
|
+
end
|
|
86
|
+
raise ValidationError, "Text is not valid UTF-8: #{text.b[0, 40].inspect}" unless converted.valid_encoding?
|
|
87
|
+
|
|
88
|
+
converted
|
|
89
|
+
rescue EncodingError
|
|
90
|
+
raise ValidationError, "Text in #{text.encoding} cannot be converted to UTF-8"
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
# Converts strings inside opaque user data, keeping its keys as given.
|
|
94
|
+
def utf8_strings(value, depth = 0)
|
|
95
|
+
check_depth!(depth)
|
|
96
|
+
case value
|
|
97
|
+
when Hash then value.to_h { |key, item| [key.is_a?(String) ? utf8(key) : key, utf8_strings(item, depth + 1)] }
|
|
98
|
+
when Array then value.map { |item| utf8_strings(item, depth + 1) }
|
|
99
|
+
when String then utf8(value)
|
|
100
|
+
else value
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
# Request bodies deeper than JSON's own nesting limit (or cyclic ones)
|
|
105
|
+
# are refused before they reach the encoder.
|
|
106
|
+
MAX_DEPTH = 100
|
|
107
|
+
|
|
108
|
+
def check_depth!(depth)
|
|
109
|
+
raise ValidationError, "Request body is nested deeper than #{MAX_DEPTH} levels (or cyclic)" if depth > MAX_DEPTH
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
# True for nil, "" and whitespace — never raises on broken encodings.
|
|
113
|
+
def blank?(value)
|
|
114
|
+
text = value.to_s
|
|
115
|
+
text = text.scrub unless text.valid_encoding?
|
|
116
|
+
text.strip.empty?
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
# Stripped text; raises ValidationError instead of Encoding errors.
|
|
120
|
+
def strip_text(value, field)
|
|
121
|
+
text = value.to_s
|
|
122
|
+
raise ValidationError, "#{field} contains invalid #{text.encoding} bytes" unless text.valid_encoding?
|
|
123
|
+
|
|
124
|
+
text.strip
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
# Builds "/a/<escaped>/b" from a template with `%s` placeholders.
|
|
128
|
+
def path(template, *segments)
|
|
129
|
+
format(template, *segments.map { |segment| escape_segment(segment) })
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
# Escapes one URL path segment. Prevents path traversal and injection of
|
|
133
|
+
# extra segments or query strings through user-supplied IDs/aliases.
|
|
134
|
+
def escape_segment(value)
|
|
135
|
+
text = case value
|
|
136
|
+
when Integer
|
|
137
|
+
raise ValidationError, "ID must be positive, got #{value}" unless value.positive?
|
|
138
|
+
|
|
139
|
+
value.to_s
|
|
140
|
+
when String, Symbol then value.to_s
|
|
141
|
+
else raise ValidationError, "ID must be an Integer or String, got #{value.class}"
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
raise ValidationError, "ID must not be blank" if strip_text(text, "ID").empty?
|
|
145
|
+
raise ValidationError, "ID must not be #{text.inspect}" if %w[. ..].include?(text)
|
|
146
|
+
|
|
147
|
+
URI.encode_www_form_component(text).gsub("+", "%20")
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
# Converts a query hash: symbol keys are mapped through `mapping`
|
|
151
|
+
# (unknown symbols raise), string keys are sent verbatim; nil is dropped.
|
|
152
|
+
def query(params, mapping)
|
|
153
|
+
params.each_with_object({}) do |(key, value), result|
|
|
154
|
+
next if value.nil?
|
|
155
|
+
|
|
156
|
+
name = case key
|
|
157
|
+
when String then key
|
|
158
|
+
when Symbol
|
|
159
|
+
mapping.fetch(key) do
|
|
160
|
+
raise ValidationError,
|
|
161
|
+
"Unknown parameter #{key.inspect}. Allowed: #{mapping.keys.map(&:inspect).join(', ')}"
|
|
162
|
+
end
|
|
163
|
+
else raise ValidationError, "Parameter names must be Symbols or Strings"
|
|
164
|
+
end
|
|
165
|
+
result[name] = query_value(value)
|
|
166
|
+
end
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
# Converts one query value to its API string form.
|
|
170
|
+
def query_value(value)
|
|
171
|
+
case value
|
|
172
|
+
when Time then format_time(value)
|
|
173
|
+
when DateTime then format_time(value.to_time)
|
|
174
|
+
when Date then value.strftime("%Y-%m-%d")
|
|
175
|
+
when true, false, Numeric, String, Symbol then value.to_s
|
|
176
|
+
else raise ValidationError, "Unsupported query value #{value.class}"
|
|
177
|
+
end
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
# Time is converted to Moscow time — the API interprets all timestamps as MSK.
|
|
181
|
+
def format_time(time, separator: " ")
|
|
182
|
+
time.getlocal(MOSCOW_UTC_OFFSET).strftime("%Y-%m-%d#{separator}%H:%M:%S")
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
# Date-only parameter (stats, bounces): Time is reduced to its Moscow date.
|
|
186
|
+
def format_date(value)
|
|
187
|
+
case value
|
|
188
|
+
when nil then nil
|
|
189
|
+
when DateTime then format_date(value.to_time)
|
|
190
|
+
when Time then value.getlocal(MOSCOW_UTC_OFFSET).strftime("%Y-%m-%d")
|
|
191
|
+
when Date then value.strftime("%Y-%m-%d")
|
|
192
|
+
when String then value
|
|
193
|
+
else raise ValidationError, "Expected Date, Time or String, got #{value.class}"
|
|
194
|
+
end
|
|
195
|
+
end
|
|
196
|
+
|
|
197
|
+
# Parses an API timestamp: "YYYY-MM-DD HH:MM:SS" (Moscow time), ISO 8601,
|
|
198
|
+
# or RFC 2822 ("Sat, 31 Oct 2026 21:15:56 +0300", the Date of inbound mail).
|
|
199
|
+
# @return [Time, nil] nil for an empty or zero ("0000-00-00 00:00:00") value
|
|
200
|
+
def parse_time(value)
|
|
201
|
+
text = value.to_s.strip
|
|
202
|
+
return nil if text.empty?
|
|
203
|
+
return Time.rfc2822(text) if text.match?(/\A(?:[A-Za-z]{3},\s*)?\d{1,2}\s+[A-Za-z]{3}\s+\d{4}\s/)
|
|
204
|
+
|
|
205
|
+
if text.match?(/\A\d{4}-\d\d-\d\dT/)
|
|
206
|
+
# Timestamps without an offset are Moscow time, not the host's zone.
|
|
207
|
+
text = text.sub(/z\z/, "Z").sub(/([+-]\d\d)\z/, '\\1:00')
|
|
208
|
+
text += MOSCOW_OFFSET unless text.match?(/(?:Z|[+-]\d\d:?\d\d)\z/)
|
|
209
|
+
return Time.iso8601(text)
|
|
210
|
+
end
|
|
211
|
+
|
|
212
|
+
Time.strptime("#{text} #{MOSCOW_OFFSET}", "%Y-%m-%d %H:%M:%S %z")
|
|
213
|
+
rescue ArgumentError, EncodingError
|
|
214
|
+
nil
|
|
215
|
+
end
|
|
216
|
+
|
|
217
|
+
# Freezes a Hash/Array tree in place.
|
|
218
|
+
def deep_freeze(value)
|
|
219
|
+
case value
|
|
220
|
+
when Hash then value.each_value { |item| deep_freeze(item) }
|
|
221
|
+
when Array then value.each { |item| deep_freeze(item) }
|
|
222
|
+
end
|
|
223
|
+
value.freeze
|
|
224
|
+
end
|
|
225
|
+
|
|
226
|
+
# Mutable deep copy of a Hash/Array tree.
|
|
227
|
+
def deep_dup(value)
|
|
228
|
+
case value
|
|
229
|
+
when Hash then value.to_h { |key, item| [key.dup, deep_dup(item)] }
|
|
230
|
+
when Array then value.map { |item| deep_dup(item) }
|
|
231
|
+
when String then value.dup
|
|
232
|
+
else value
|
|
233
|
+
end
|
|
234
|
+
end
|
|
235
|
+
|
|
236
|
+
# Parses JSON from the network. `JSON.parse` never instantiates classes
|
|
237
|
+
# from "json_class" keys (unlike `JSON.load`), on json 2.x and 3.x alike.
|
|
238
|
+
# @raise [JSON::ParserError] including JSON::NestingError
|
|
239
|
+
def parse_json(body)
|
|
240
|
+
JSON.parse(body, max_nesting: 100)
|
|
241
|
+
end
|
|
242
|
+
|
|
243
|
+
# @raise [ValidationError] when the value cannot be encoded
|
|
244
|
+
def generate_json(value)
|
|
245
|
+
JSON.generate(value)
|
|
246
|
+
rescue JSON::JSONError, Encoding::UndefinedConversionError => e
|
|
247
|
+
raise ValidationError, "Request body cannot be encoded as JSON: #{e.message}"
|
|
248
|
+
end
|
|
249
|
+
end
|
|
250
|
+
end
|
|
@@ -0,0 +1,395 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "digest"
|
|
4
|
+
require "json"
|
|
5
|
+
require "openssl"
|
|
6
|
+
require "strscan"
|
|
7
|
+
|
|
8
|
+
require_relative "errors"
|
|
9
|
+
require_relative "util"
|
|
10
|
+
require_relative "api_object"
|
|
11
|
+
|
|
12
|
+
module Haskimail
|
|
13
|
+
# Parsing and verification of incoming webhooks.
|
|
14
|
+
# https://haskimail.ru/developer/webhooks/webhooks-overview
|
|
15
|
+
#
|
|
16
|
+
# Rails example:
|
|
17
|
+
#
|
|
18
|
+
# def haskimail
|
|
19
|
+
# event = Haskimail::Webhook.construct_event(
|
|
20
|
+
# request.raw_post, signing_key: ENV.fetch("HASKIMAIL_WEBHOOK_KEY")
|
|
21
|
+
# )
|
|
22
|
+
# ProcessHaskimailEventJob.perform_later(event.to_h) # answer fast, work later
|
|
23
|
+
# head :ok
|
|
24
|
+
# rescue Haskimail::SignatureVerificationError, Haskimail::ValidationError
|
|
25
|
+
# head :forbidden # event webhooks are not retried after a 4xx
|
|
26
|
+
# end
|
|
27
|
+
#
|
|
28
|
+
# Inbound email is different: Haskimail waits up to 60 seconds, retries any
|
|
29
|
+
# answer except 2xx and 403, and a 403 stops retries for good
|
|
30
|
+
# (see examples/rails_webhook_controller.rb).
|
|
31
|
+
module Webhook
|
|
32
|
+
# Default size limits (override with `max_size:`). 1 MB is far above any
|
|
33
|
+
# event payload. Inbound emails carry Base64 attachments and, when enabled,
|
|
34
|
+
# the raw source, and the documentation sets no limit, so the default is generous.
|
|
35
|
+
MAX_EVENT_SIZE = 1024 * 1024
|
|
36
|
+
# Default limit for inbound emails.
|
|
37
|
+
MAX_INBOUND_SIZE = 64 * 1024 * 1024
|
|
38
|
+
|
|
39
|
+
# Shapes of the two signed fields. They are concatenated without a
|
|
40
|
+
# delimiter before hashing, so constraining them limits how an attacker
|
|
41
|
+
# could shift the boundary between them.
|
|
42
|
+
RECIPIENT_FORMAT = /\A[^@\s]+@[^@\s]+\z/
|
|
43
|
+
# MessageID: letters, digits, "-" and "_" (empty for events without an email).
|
|
44
|
+
MESSAGE_ID_FORMAT = /\A[0-9A-Za-z_-]*\z/
|
|
45
|
+
|
|
46
|
+
# Fields whose look-alikes (e.g. "Recipient") are refused, because the SDK
|
|
47
|
+
# reads them and only some of them are signed.
|
|
48
|
+
GUARDED_FIELDS = %w[recipient messageid secret event email].freeze
|
|
49
|
+
|
|
50
|
+
# Parsing a JSON integer with millions of digits takes tens of seconds
|
|
51
|
+
# on some Ruby/json versions, so numbers this long are refused. Digits
|
|
52
|
+
# inside strings (an inbound email body, say) are fine.
|
|
53
|
+
HUGE_NUMBER = /(?<![0-9])[0-9]{10000}/
|
|
54
|
+
# A complete JSON string; possessive, so scanning stays linear.
|
|
55
|
+
JSON_STRING = /"(?:[^"\\]++|\\.)*+"/
|
|
56
|
+
# A webhook body must be a JSON object.
|
|
57
|
+
JSON_OBJECT_START = /\A[ \t\r\n]*+\{/
|
|
58
|
+
|
|
59
|
+
module_function
|
|
60
|
+
|
|
61
|
+
# Parses an event body and verifies its signature.
|
|
62
|
+
#
|
|
63
|
+
# The signature covers only `recipient` and `message_id` (a limitation of
|
|
64
|
+
# the Haskimail protocol): other fields such as `event` are not signed.
|
|
65
|
+
# Keep the webhook URL secret, use HTTPS and, ideally, HTTP basic auth too.
|
|
66
|
+
#
|
|
67
|
+
# @param payload [String] raw request body (do not re-serialize parsed params)
|
|
68
|
+
# @param signing_key [String] webhook signing key from the Haskimail UI
|
|
69
|
+
# @return [Event]
|
|
70
|
+
# @raise [SignatureVerificationError] signature missing or wrong
|
|
71
|
+
# @raise [ValidationError] body is not a well-formed event
|
|
72
|
+
def construct_event(payload, signing_key:, max_size: MAX_EVENT_SIZE)
|
|
73
|
+
event = parse_event(payload, max_size: max_size)
|
|
74
|
+
unless valid_signature?(event.to_h, signing_key: signing_key)
|
|
75
|
+
raise SignatureVerificationError, "Webhook signature verification failed"
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
event
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
# Parses an event body WITHOUT verifying it. Use only behind another
|
|
82
|
+
# authentication layer (HTTP basic auth, IP allow-list).
|
|
83
|
+
# @return [Event]
|
|
84
|
+
def parse_event(payload, max_size: MAX_EVENT_SIZE)
|
|
85
|
+
data = parse_object(payload, max_size)
|
|
86
|
+
# Field access is case-insensitive, so "Recipient" next to "recipient"
|
|
87
|
+
# could make the application read a different value than was signed.
|
|
88
|
+
guarded = data.keys.map { |key| ApiObject.normalize(key) }.select { |key| GUARDED_FIELDS.include?(key) }
|
|
89
|
+
raise ValidationError, "Webhook payload has ambiguous duplicate fields" if guarded.uniq.size != guarded.size
|
|
90
|
+
|
|
91
|
+
Event.new(data)
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
# `secret == md5(recipient + message_id + signing_key)`, compared in
|
|
95
|
+
# constant time. Both signed fields must be present with exact names.
|
|
96
|
+
#
|
|
97
|
+
# @param event [Hash, Event] parsed payload
|
|
98
|
+
# @param signing_key [String]
|
|
99
|
+
# @raise [ConfigurationError] when signing_key is blank
|
|
100
|
+
def valid_signature?(event, signing_key:)
|
|
101
|
+
raise ConfigurationError, "signing_key must not be blank" if Util.blank?(signing_key)
|
|
102
|
+
|
|
103
|
+
key = signing_key.to_s
|
|
104
|
+
return false unless event.respond_to?(:to_h)
|
|
105
|
+
|
|
106
|
+
data = event.to_h
|
|
107
|
+
recipient = data["recipient"]
|
|
108
|
+
# Events not tied to an email carry "" (a missing/null value is signed as "").
|
|
109
|
+
message_id = data["message_id"].nil? ? "" : data["message_id"]
|
|
110
|
+
secret = data["secret"]
|
|
111
|
+
return false unless recipient.is_a?(String) && RECIPIENT_FORMAT.match?(recipient)
|
|
112
|
+
return false unless message_id.is_a?(String) && MESSAGE_ID_FORMAT.match?(message_id)
|
|
113
|
+
return false unless secret.is_a?(String) && !secret.empty?
|
|
114
|
+
|
|
115
|
+
expected = Digest::MD5.hexdigest("#{recipient}#{message_id}#{key}")
|
|
116
|
+
OpenSSL.secure_compare(expected, secret.downcase)
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
# Parses the inbound-email webhook (Postmark-compatible format, no signature:
|
|
120
|
+
# protect the URL with HTTP basic auth — `https://user:pass@host/path`).
|
|
121
|
+
# @return [InboundMessage]
|
|
122
|
+
def parse_inbound(payload, max_size: MAX_INBOUND_SIZE)
|
|
123
|
+
InboundMessage.new(parse_object(payload, max_size))
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
# @api private
|
|
127
|
+
# @return [Hash]
|
|
128
|
+
def parse_object(payload, limit)
|
|
129
|
+
# Rack's raw_post is binary; the body itself must be UTF-8 JSON.
|
|
130
|
+
text = payload.to_s.dup.force_encoding(Encoding::UTF_8)
|
|
131
|
+
raise WebhookPayloadTooLargeError, "Webhook payload is larger than #{limit} bytes" if text.bytesize > limit
|
|
132
|
+
raise ValidationError, "Webhook payload is not valid UTF-8" unless text.valid_encoding?
|
|
133
|
+
raise ValidationError, "Webhook payload must be a JSON object" unless JSON_OBJECT_START.match?(text)
|
|
134
|
+
raise ValidationError, "Webhook payload contains an oversized number" if huge_number?(text)
|
|
135
|
+
|
|
136
|
+
# Starts with "{", so a successful parse is always a Hash.
|
|
137
|
+
Util.parse_json(text)
|
|
138
|
+
rescue JSON::ParserError => e
|
|
139
|
+
raise ValidationError, "Webhook payload is not valid JSON: #{e.message}"
|
|
140
|
+
end
|
|
141
|
+
private_class_method :parse_object
|
|
142
|
+
|
|
143
|
+
# @api private
|
|
144
|
+
# True when a run of 10 000+ digits appears outside JSON strings.
|
|
145
|
+
# A single regex pass rules out almost every body; only bodies that do
|
|
146
|
+
# contain such a run are walked token by token (linear; an unterminated
|
|
147
|
+
# string ends the walk, the JSON parser rejects such a body anyway).
|
|
148
|
+
def huge_number?(text)
|
|
149
|
+
return false unless HUGE_NUMBER.match?(text)
|
|
150
|
+
|
|
151
|
+
scanner = StringScanner.new(text)
|
|
152
|
+
until scanner.eos?
|
|
153
|
+
scanner.skip(/[^"0-9]++/)
|
|
154
|
+
next if scanner.skip(JSON_STRING)
|
|
155
|
+
return false if scanner.check(/"/)
|
|
156
|
+
return true if scanner.match?(HUGE_NUMBER)
|
|
157
|
+
|
|
158
|
+
scanner.skip(/[0-9]++/)
|
|
159
|
+
end
|
|
160
|
+
false
|
|
161
|
+
end
|
|
162
|
+
private_class_method :huge_number?
|
|
163
|
+
|
|
164
|
+
# A delivery/bounce/open/click/spam-complaint/subscription-change event.
|
|
165
|
+
# All raw fields remain available (`event.recipient`, `event["code"]`...).
|
|
166
|
+
class Event < ApiObject
|
|
167
|
+
# Lower-case `event` value => #type.
|
|
168
|
+
TYPES = {
|
|
169
|
+
"sent" => :sent, # sandbox servers only
|
|
170
|
+
"delivered" => :delivered,
|
|
171
|
+
"bounced" => :bounced,
|
|
172
|
+
"complained" => :complained,
|
|
173
|
+
"opened" => :opened,
|
|
174
|
+
"clicked" => :clicked,
|
|
175
|
+
"subscriptionchange" => :subscription_change
|
|
176
|
+
}.freeze
|
|
177
|
+
|
|
178
|
+
# bounce_category value => Symbol.
|
|
179
|
+
BOUNCE_CATEGORIES = {
|
|
180
|
+
"hrd" => :hard_bounce,
|
|
181
|
+
"sft" => :soft_bounce,
|
|
182
|
+
"blk" => :blocked,
|
|
183
|
+
"spm" => :spam_block
|
|
184
|
+
}.freeze
|
|
185
|
+
|
|
186
|
+
# Normalized event type. The docs use both cases ("complained" and
|
|
187
|
+
# "COMPLAINED"), so comparison is case-insensitive.
|
|
188
|
+
# @return [Symbol] one of TYPES values, or :unknown
|
|
189
|
+
def type
|
|
190
|
+
TYPES.fetch(self["event"].to_s.downcase, :unknown)
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
TYPES.each_value do |name|
|
|
194
|
+
define_method(:"#{name}?") do
|
|
195
|
+
# @type self: Event
|
|
196
|
+
type == name
|
|
197
|
+
end
|
|
198
|
+
end
|
|
199
|
+
|
|
200
|
+
# The signed recipient field (exact key, never a look-alike).
|
|
201
|
+
# @return [String, nil]
|
|
202
|
+
def recipient
|
|
203
|
+
exact("recipient")
|
|
204
|
+
end
|
|
205
|
+
|
|
206
|
+
# MessageID of the email (signed field); "" for events not tied to an email.
|
|
207
|
+
def message_id
|
|
208
|
+
exact("message_id").to_s
|
|
209
|
+
end
|
|
210
|
+
|
|
211
|
+
# Raw `email` field (an unsigned copy of `recipient`).
|
|
212
|
+
def email
|
|
213
|
+
exact("email")
|
|
214
|
+
end
|
|
215
|
+
|
|
216
|
+
# Tag of the email (sent as `campaign_id`).
|
|
217
|
+
def tag
|
|
218
|
+
self["campaign_id"].to_s
|
|
219
|
+
end
|
|
220
|
+
|
|
221
|
+
# Metadata of the email. The API sends it as a JSON string (or, in some
|
|
222
|
+
# examples, as an object); both are returned as a Hash.
|
|
223
|
+
# @return [Hash]
|
|
224
|
+
def metadata
|
|
225
|
+
raw = to_h["custom_vars"]
|
|
226
|
+
case raw
|
|
227
|
+
when Hash then raw
|
|
228
|
+
when String
|
|
229
|
+
parsed = raw.strip.empty? ? {} : Util.parse_json(raw)
|
|
230
|
+
parsed.is_a?(Hash) ? parsed : {}
|
|
231
|
+
else {}
|
|
232
|
+
end
|
|
233
|
+
rescue JSON::ParserError
|
|
234
|
+
{}
|
|
235
|
+
end
|
|
236
|
+
|
|
237
|
+
# @return [Integer, nil]
|
|
238
|
+
def server_id
|
|
239
|
+
value = self["server_id"]
|
|
240
|
+
value.nil? ? nil : Integer(value.to_s, 10)
|
|
241
|
+
rescue ArgumentError
|
|
242
|
+
nil
|
|
243
|
+
end
|
|
244
|
+
|
|
245
|
+
# @return [String, nil]
|
|
246
|
+
def stream_id
|
|
247
|
+
value = self["stream_id"]
|
|
248
|
+
value&.to_s
|
|
249
|
+
end
|
|
250
|
+
|
|
251
|
+
# Event time as Time (UTC field preferred, Moscow time as fallback).
|
|
252
|
+
# @return [Time, nil]
|
|
253
|
+
def occurred_at
|
|
254
|
+
utc = self["event_time_utc"].to_s
|
|
255
|
+
return Util.parse_time(self["event_time"]) unless utc.match?(/\A\d{4}-\d\d-\d\d \d\d:\d\d:\d\d\z/)
|
|
256
|
+
|
|
257
|
+
Time.strptime("#{utc} +00:00", "%Y-%m-%d %H:%M:%S %z").utc
|
|
258
|
+
rescue ArgumentError
|
|
259
|
+
Util.parse_time(self["event_time"])
|
|
260
|
+
end
|
|
261
|
+
|
|
262
|
+
# Bounce category: :hard_bounce, :soft_bounce, :blocked, :spam_block.
|
|
263
|
+
# @return [Symbol, nil]
|
|
264
|
+
def bounce_category
|
|
265
|
+
BOUNCE_CATEGORIES[self["bounce_category"].to_s]
|
|
266
|
+
end
|
|
267
|
+
|
|
268
|
+
# Subscription change: true when the address was added to the suppression list.
|
|
269
|
+
def suppressed?
|
|
270
|
+
%w[true 1].include?(self["SuppressSending"].to_s.strip.downcase)
|
|
271
|
+
end
|
|
272
|
+
|
|
273
|
+
# Key for de-duplication: Haskimail may deliver the same event twice.
|
|
274
|
+
# Clicks include the link, so clicks on two links in the same second
|
|
275
|
+
# stay distinct.
|
|
276
|
+
# @return [String]
|
|
277
|
+
def idempotency_key
|
|
278
|
+
[type, message_id, recipient, exact("event_time"), exact("url_original")].join("|")
|
|
279
|
+
end
|
|
280
|
+
end
|
|
281
|
+
|
|
282
|
+
# Inbound email as delivered to the inbound webhook.
|
|
283
|
+
class InboundMessage < ApiObject
|
|
284
|
+
# @return [Array<InboundAttachment>] decoded attachments
|
|
285
|
+
def attachments
|
|
286
|
+
items = to_h["Attachments"]
|
|
287
|
+
return [] unless items.is_a?(Array)
|
|
288
|
+
|
|
289
|
+
items.filter_map do |item|
|
|
290
|
+
next unless item.is_a?(Hash)
|
|
291
|
+
|
|
292
|
+
InboundAttachment.new(
|
|
293
|
+
name: item["Name"].to_s,
|
|
294
|
+
content_type: item["ContentType"].to_s,
|
|
295
|
+
content_id: item["ContentID"].to_s,
|
|
296
|
+
content: item["Content"].to_s.unpack1("m")
|
|
297
|
+
)
|
|
298
|
+
end
|
|
299
|
+
end
|
|
300
|
+
|
|
301
|
+
# @return [String, nil] value of a header by case-insensitive name
|
|
302
|
+
def header(name)
|
|
303
|
+
wanted = name.to_s.downcase
|
|
304
|
+
headers = to_h["Headers"]
|
|
305
|
+
return nil unless headers.is_a?(Array)
|
|
306
|
+
|
|
307
|
+
headers.each do |header|
|
|
308
|
+
return header["Value"] if header.is_a?(Hash) && header["Name"].to_s.downcase == wanted
|
|
309
|
+
end
|
|
310
|
+
nil
|
|
311
|
+
end
|
|
312
|
+
|
|
313
|
+
# @return [Float, nil] value of the X-Spam-Score header
|
|
314
|
+
def spam_score
|
|
315
|
+
value = header("X-Spam-Score")
|
|
316
|
+
value.nil? ? nil : Float(value)
|
|
317
|
+
rescue ArgumentError, TypeError # not a number, or not even a String
|
|
318
|
+
nil
|
|
319
|
+
end
|
|
320
|
+
|
|
321
|
+
# Short form: bodies, headers and Base64 attachments (up to 64 MB) are
|
|
322
|
+
# left out. Use #to_h for everything.
|
|
323
|
+
def inspect
|
|
324
|
+
summary = %w[MessageID From To Subject].filter_map do |key|
|
|
325
|
+
value = exact(key)
|
|
326
|
+
"#{key}=#{value.inspect}" unless value.nil?
|
|
327
|
+
end
|
|
328
|
+
attachments = exact("Attachments")
|
|
329
|
+
summary << "attachments=#{attachments.size}" if attachments.is_a?(Array)
|
|
330
|
+
"#<#{self.class.name} #{summary.join(' ')}>"
|
|
331
|
+
end
|
|
332
|
+
alias to_s inspect
|
|
333
|
+
|
|
334
|
+
# `pp` and IRB print the same short form.
|
|
335
|
+
def pretty_print(printer)
|
|
336
|
+
printer.text(inspect)
|
|
337
|
+
end
|
|
338
|
+
end
|
|
339
|
+
|
|
340
|
+
# Decoded attachment of an inbound email. The name comes from the sender:
|
|
341
|
+
# never use it as a file path without sanitizing.
|
|
342
|
+
class InboundAttachment
|
|
343
|
+
attr_reader :name, :content_type, :content_id, :content
|
|
344
|
+
|
|
345
|
+
# @param name [String] file name chosen by the sender
|
|
346
|
+
# @param content_type [String]
|
|
347
|
+
# @param content_id [String] "" for regular attachments
|
|
348
|
+
# @param content [String] decoded bytes
|
|
349
|
+
def initialize(name:, content_type:, content_id:, content:)
|
|
350
|
+
@name = name
|
|
351
|
+
@content_type = content_type
|
|
352
|
+
@content_id = content_id
|
|
353
|
+
@content = content
|
|
354
|
+
freeze
|
|
355
|
+
end
|
|
356
|
+
|
|
357
|
+
# True for inline attachments (with a content ID).
|
|
358
|
+
def inline?
|
|
359
|
+
!content_id.empty?
|
|
360
|
+
end
|
|
361
|
+
|
|
362
|
+
# Decoded size in bytes.
|
|
363
|
+
def size
|
|
364
|
+
content.bytesize
|
|
365
|
+
end
|
|
366
|
+
|
|
367
|
+
# @return [Hash{Symbol => String}]
|
|
368
|
+
def to_h
|
|
369
|
+
{ name: name, content_type: content_type, content_id: content_id, content: content }
|
|
370
|
+
end
|
|
371
|
+
|
|
372
|
+
# Same class and same fields.
|
|
373
|
+
def ==(other)
|
|
374
|
+
other.instance_of?(self.class) && other.to_h == to_h
|
|
375
|
+
end
|
|
376
|
+
alias eql? ==
|
|
377
|
+
|
|
378
|
+
# Consistent with #eql?.
|
|
379
|
+
def hash
|
|
380
|
+
[self.class, to_h].hash
|
|
381
|
+
end
|
|
382
|
+
|
|
383
|
+
# Name, type and size; never the content.
|
|
384
|
+
def inspect
|
|
385
|
+
"#<#{self.class.name} name=#{name.inspect} content_type=#{content_type.inspect} size=#{size}>"
|
|
386
|
+
end
|
|
387
|
+
alias to_s inspect
|
|
388
|
+
|
|
389
|
+
# `pp` and IRB print the same short form, never the content.
|
|
390
|
+
def pretty_print(printer)
|
|
391
|
+
printer.text(inspect)
|
|
392
|
+
end
|
|
393
|
+
end
|
|
394
|
+
end
|
|
395
|
+
end
|
data/lib/haskimail.rb
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "haskimail/version"
|
|
4
|
+
require_relative "haskimail/errors"
|
|
5
|
+
require_relative "haskimail/redacted"
|
|
6
|
+
require_relative "haskimail/configuration"
|
|
7
|
+
require_relative "haskimail/util"
|
|
8
|
+
require_relative "haskimail/api_object"
|
|
9
|
+
require_relative "haskimail/attachment"
|
|
10
|
+
require_relative "haskimail/http/net_http_transport"
|
|
11
|
+
require_relative "haskimail/http/retry_policy"
|
|
12
|
+
require_relative "haskimail/http/response_parser"
|
|
13
|
+
require_relative "haskimail/base_client"
|
|
14
|
+
require_relative "haskimail/resources/base"
|
|
15
|
+
require_relative "haskimail/resources/emails"
|
|
16
|
+
require_relative "haskimail/resources/templates"
|
|
17
|
+
require_relative "haskimail/resources/messages"
|
|
18
|
+
require_relative "haskimail/resources/server_resources"
|
|
19
|
+
require_relative "haskimail/resources/account_resources"
|
|
20
|
+
require_relative "haskimail/clients"
|
|
21
|
+
require_relative "haskimail/webhook"
|
|
22
|
+
require_relative "haskimail/test_addresses"
|
|
23
|
+
|
|
24
|
+
# Ruby SDK for the Haskimail transactional email API.
|
|
25
|
+
# https://haskimail.ru/developer
|
|
26
|
+
module Haskimail
|
|
27
|
+
class << self
|
|
28
|
+
# Parses an API timestamp ("YYYY-MM-DD HH:MM:SS" in Moscow time) into Time.
|
|
29
|
+
# @return [Time, nil]
|
|
30
|
+
def parse_time(value)
|
|
31
|
+
Util.parse_time(value)
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|