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,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,6 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Haskimail
4
+ # Gem version (SemVer).
5
+ VERSION = "1.0.0"
6
+ 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