haskimail-rails 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.
Files changed (42) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +15 -0
  3. data/LICENSE.txt +21 -0
  4. data/README.md +267 -0
  5. data/lib/generators/haskimail/install/install_generator.rb +32 -0
  6. data/lib/generators/haskimail/install/templates/haskimail.rb +27 -0
  7. data/lib/haskimail/attachment.rb +84 -0
  8. data/lib/haskimail/clients.rb +78 -0
  9. data/lib/haskimail/configuration.rb +89 -0
  10. data/lib/haskimail/connection.rb +268 -0
  11. data/lib/haskimail/errors.rb +130 -0
  12. data/lib/haskimail/paginator.rb +48 -0
  13. data/lib/haskimail/rails/delivery_method.rb +56 -0
  14. data/lib/haskimail/rails/mail_converter.rb +130 -0
  15. data/lib/haskimail/rails/mailer_helpers.rb +42 -0
  16. data/lib/haskimail/rails/message_options.rb +88 -0
  17. data/lib/haskimail/rails/railtie.rb +28 -0
  18. data/lib/haskimail/resources/account_templates.rb +53 -0
  19. data/lib/haskimail/resources/base.rb +75 -0
  20. data/lib/haskimail/resources/bounces.rb +43 -0
  21. data/lib/haskimail/resources/domains.rb +42 -0
  22. data/lib/haskimail/resources/emails.rb +149 -0
  23. data/lib/haskimail/resources/inbound_rules.rb +33 -0
  24. data/lib/haskimail/resources/message_streams.rb +46 -0
  25. data/lib/haskimail/resources/messages.rb +135 -0
  26. data/lib/haskimail/resources/servers.rb +89 -0
  27. data/lib/haskimail/resources/stats.rb +89 -0
  28. data/lib/haskimail/resources/suppressions.rb +39 -0
  29. data/lib/haskimail/resources/templates.rb +68 -0
  30. data/lib/haskimail/resources/webhooks.rb +80 -0
  31. data/lib/haskimail/response_handler.rb +68 -0
  32. data/lib/haskimail/secret.rb +40 -0
  33. data/lib/haskimail/serializer.rb +75 -0
  34. data/lib/haskimail/util.rb +110 -0
  35. data/lib/haskimail/version.rb +5 -0
  36. data/lib/haskimail/webhooks/endpoint.rb +146 -0
  37. data/lib/haskimail/webhooks/event.rb +103 -0
  38. data/lib/haskimail/webhooks/inbound_message.rb +100 -0
  39. data/lib/haskimail/webhooks.rb +87 -0
  40. data/lib/haskimail-rails.rb +19 -0
  41. data/lib/haskimail.rb +58 -0
  42. metadata +132 -0
@@ -0,0 +1,135 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Haskimail
4
+ module Resources
5
+ # Sent and received messages, opens and clicks:
6
+ # https://haskimail.ru/developer/api/messages-api
7
+ class Messages < Base
8
+ DEFAULT_PAGE_SIZE = 100
9
+ MAX_PAGE_SIZE = 500
10
+ MAX_WINDOW = 10_000 # count + offset limit for outbound, opens and clicks
11
+ INBOUND_STATUSES = %w[queued processed blocked failed scheduled].freeze
12
+
13
+ OUTBOUND_FILTERS = {
14
+ recipient: "recipient", from_email: "fromemail", tag: "tag", subject: "subject",
15
+ message_stream: "messagestream", from_date: "fromdate", to_date: "todate"
16
+ }.freeze
17
+ EVENT_FILTERS = {
18
+ recipient: "recipient", tag: "tag", message_stream: "messagestream",
19
+ from_date: "fromdate", to_date: "todate"
20
+ }.freeze
21
+ MESSAGE_EVENT_FILTERS = EVENT_FILTERS.except(:from_date, :to_date).freeze
22
+ INBOUND_FILTERS = {
23
+ recipient: "recipient", from_email: "fromemail", subject: "subject", mailbox_hash: "mailboxhash",
24
+ status: "status", from_date: "fromdate", to_date: "todate"
25
+ }.freeze
26
+
27
+ # ---- Outbound -------------------------------------------------------
28
+
29
+ # @return [Hash] { total_count:, messages: [...] } — one record per recipient
30
+ def outbound(count: DEFAULT_PAGE_SIZE, offset: 0, **filters)
31
+ api_get(path(:messages), windowed_query(count, offset, filters, OUTBOUND_FILTERS))
32
+ end
33
+
34
+ def each_outbound(page_size: DEFAULT_PAGE_SIZE, **filters, &block)
35
+ iterate(:messages, page_size, MAX_WINDOW, block, max_page_size: MAX_PAGE_SIZE) do |count, offset|
36
+ outbound(count: count, offset: offset, **filters)
37
+ end
38
+ end
39
+
40
+ def outbound_details(message_id)
41
+ api_get(path(:messages, message_id, :details))
42
+ end
43
+
44
+ # ---- Opens ------------------------------------------------------------
45
+
46
+ # @return [Hash] { total_count:, opens: [...] }
47
+ def opens(count: DEFAULT_PAGE_SIZE, offset: 0, **filters)
48
+ api_get(path(:messages, :opens), windowed_query(count, offset, filters, EVENT_FILTERS))
49
+ end
50
+
51
+ def each_open(page_size: DEFAULT_PAGE_SIZE, **filters, &block)
52
+ iterate(:opens, page_size, MAX_WINDOW, block, max_page_size: MAX_PAGE_SIZE) do |count, offset|
53
+ opens(count: count, offset: offset, **filters)
54
+ end
55
+ end
56
+
57
+ def opens_for(message_id, count: DEFAULT_PAGE_SIZE, offset: 0, **filters)
58
+ api_get(path(:messages, :opens, message_id),
59
+ windowed_query(count, offset, filters, MESSAGE_EVENT_FILTERS))
60
+ end
61
+
62
+ # ---- Clicks -----------------------------------------------------------
63
+
64
+ # @return [Hash] { total_count:, clicks: [...] }
65
+ def clicks(count: DEFAULT_PAGE_SIZE, offset: 0, **filters)
66
+ api_get(path(:messages, :clicks), windowed_query(count, offset, filters, EVENT_FILTERS))
67
+ end
68
+
69
+ def each_click(page_size: DEFAULT_PAGE_SIZE, **filters, &block)
70
+ iterate(:clicks, page_size, MAX_WINDOW, block, max_page_size: MAX_PAGE_SIZE) do |count, offset|
71
+ clicks(count: count, offset: offset, **filters)
72
+ end
73
+ end
74
+
75
+ def clicks_for(message_id, count: DEFAULT_PAGE_SIZE, offset: 0, **filters)
76
+ api_get(path(:messages, :clicks, message_id),
77
+ windowed_query(count, offset, filters, MESSAGE_EVENT_FILTERS))
78
+ end
79
+
80
+ # ---- Inbound ----------------------------------------------------------
81
+
82
+ # @return [Hash] { total_count:, inbound_messages: [...] } (last 45 days)
83
+ def inbound(count: DEFAULT_PAGE_SIZE, offset: 0, **filters)
84
+ Util.assert_known_keys!(filters.keys, INBOUND_FILTERS.keys, "inbound filter")
85
+ if filters[:status] && !INBOUND_STATUSES.include?(filters[:status].to_s.downcase)
86
+ raise ArgumentError, "status must be one of: #{INBOUND_STATUSES.join(', ')}"
87
+ end
88
+
89
+ query = pagination_query(count, offset, max_count: MAX_PAGE_SIZE)
90
+ filters.each do |key, value|
91
+ query[INBOUND_FILTERS.fetch(key)] = format_filter(key, value, separator: "T")
92
+ end
93
+ query["status"] = query["status"].downcase if query["status"]
94
+ api_get(path(:messages, :inbound), query)
95
+ end
96
+
97
+ def each_inbound(page_size: DEFAULT_PAGE_SIZE, **filters, &block)
98
+ iterate(:inbound_messages, page_size, nil, block, max_page_size: MAX_PAGE_SIZE) do |count, offset|
99
+ inbound(count: count, offset: offset, **filters)
100
+ end
101
+ end
102
+
103
+ def inbound_details(message_id)
104
+ api_get(path(:messages, :inbound, message_id, :details))
105
+ end
106
+
107
+ # Delivers a Blocked inbound message to the webhook, skipping block rules and the spam filter.
108
+ # Never retried automatically: a repeat could post the message to your webhook twice.
109
+ def bypass_inbound(message_id)
110
+ api_put(path(:messages, :inbound, message_id, :bypass), idempotent: false)
111
+ end
112
+
113
+ # Re-sends a Failed inbound message to the webhook.
114
+ def retry_inbound(message_id)
115
+ api_put(path(:messages, :inbound, message_id, :retry), idempotent: false)
116
+ end
117
+
118
+ private
119
+
120
+ def windowed_query(count, offset, filters, allowed)
121
+ Util.assert_known_keys!(filters.keys, allowed.keys, "filter")
122
+ query = pagination_query(count, offset, max_count: MAX_PAGE_SIZE, max_window: MAX_WINDOW)
123
+ filters.each { |key, value| query[allowed.fetch(key)] = format_filter(key, value) }
124
+ query
125
+ end
126
+
127
+ def format_filter(key, value, separator: " ")
128
+ return nil if value.nil?
129
+ return Util.format_datetime(value, separator: separator) if %i[from_date to_date].include?(key)
130
+
131
+ value.to_s
132
+ end
133
+ end
134
+ end
135
+ end
@@ -0,0 +1,89 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Haskimail
4
+ module Resources
5
+ # Servers (account token): https://haskimail.ru/developer/api/servers-api
6
+ class Servers < Base
7
+ DEFAULT_PAGE_SIZE = 50
8
+ COLORS = %w[purple blue turquoise green red orange yellow grey].freeze
9
+ TRACK_LINKS = %w[None HtmlAndText HtmlOnly TextOnly].freeze
10
+ DELIVERY_TYPES = %w[Live Sandbox].freeze
11
+
12
+ UPDATABLE = %i[
13
+ name color smtp_api_activated track_opens track_links inbound_hook_url inbound_domain
14
+ inbound_spam_threshold raw_email_enabled
15
+ ].freeze
16
+ CREATABLE = (UPDATABLE + %i[delivery_type]).freeze
17
+
18
+ def get(server_id)
19
+ api_get(path(:servers, server_id))
20
+ end
21
+
22
+ # @return [Hash] { total_count:, servers: [...] }
23
+ def list(count: DEFAULT_PAGE_SIZE, offset: 0)
24
+ api_get(path(:servers), pagination_query(count, offset))
25
+ end
26
+
27
+ def each(page_size: DEFAULT_PAGE_SIZE, &block)
28
+ iterate(:servers, page_size, nil, block) { |count, offset| list(count: count, offset: offset) }
29
+ end
30
+
31
+ # @example
32
+ # account.servers.create(name: "Интернет-магазин", color: "purple", track_links: "HtmlAndText")
33
+ # @note the response contains server API tokens (:api_tokens) — treat it as a secret.
34
+ def create(name:, color: "purple", **attributes)
35
+ require_value!(:name, name)
36
+ api_post(path(:servers), build(CREATABLE, attributes.merge(name: name, color: color)))
37
+ end
38
+
39
+ # Partial update: send only the attributes to change. DeliveryType cannot be changed.
40
+ def update(server_id, **attributes)
41
+ raise ArgumentError, "Nothing to update" if attributes.empty?
42
+
43
+ api_put(path(:servers, server_id), build(UPDATABLE, attributes))
44
+ end
45
+
46
+ # Deletes the server together with its inbound rules. Irreversible.
47
+ def delete(server_id)
48
+ api_delete(path(:servers, server_id))
49
+ end
50
+
51
+ # Issues an additional server API token. The plain-text value is returned
52
+ # only once, in this response — treat it as a secret.
53
+ #
54
+ # @example
55
+ # account.servers.issue_token(11834, name: "Интеграция 1С")
56
+ # @return [Hash] { token:, server_id: }
57
+ def issue_token(server_id, name: nil)
58
+ body = name.nil? ? nil : { "Name" => name.to_s.strip }
59
+ api_post(path(:servers, server_id, :tokens, :new), body, idempotent: false)
60
+ end
61
+
62
+ private
63
+
64
+ def build(allowed, attributes)
65
+ Util.assert_known_keys!(attributes.keys, allowed, "server attribute")
66
+ validate!(attributes)
67
+ Serializer.to_api(attributes.transform_keys(&:to_sym))
68
+ end
69
+
70
+ def validate!(attrs)
71
+ check_enum!(attrs, :color, COLORS) { |value| value.to_s.downcase }
72
+ check_enum!(attrs, :delivery_type, DELIVERY_TYPES)
73
+ check_enum!(attrs, :track_links, TRACK_LINKS) unless [true, false].include?(attrs[:track_links])
74
+
75
+ threshold = attrs[:inbound_spam_threshold]
76
+ return if threshold.nil? || (threshold.is_a?(Integer) && threshold.between?(0, 30) && (threshold % 5).zero?)
77
+
78
+ raise ArgumentError, "inbound_spam_threshold must be 0..30 in steps of 5"
79
+ end
80
+
81
+ def check_enum!(attrs, key, allowed)
82
+ return unless attrs.key?(key)
83
+
84
+ value = block_given? ? yield(attrs[key]) : attrs[key].to_s
85
+ raise ArgumentError, "#{key} must be one of: #{allowed.join(', ')}" unless allowed.include?(value)
86
+ end
87
+ end
88
+ end
89
+ end
@@ -0,0 +1,89 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Haskimail
4
+ module Resources
5
+ # Sending statistics: https://haskimail.ru/developer/api/stats-api
6
+ #
7
+ # Every method accepts from_date:, to_date: (Date, Time or "YYYY-MM-DD")
8
+ # and message_stream:.
9
+ class Stats < Base
10
+ FILTERS = { from_date: "fromdate", to_date: "todate", message_stream: "messagestream" }.freeze
11
+
12
+ # @return [Hash] { sent:, bounced:, opened:, clicked:, unsubscribed:, spam_complaints:,
13
+ # bounce_rate:, spam_complaints_rate:, open_rate:, click_rate: }
14
+ def overview(**filters)
15
+ fetch(path(:stats), filters)
16
+ end
17
+
18
+ # @return [Hash] { days: [{ date:, sent: }] }
19
+ def sends(**filters)
20
+ fetch(path(:stats, :sends), filters)
21
+ end
22
+
23
+ # @return [Hash] { days: [{ date:, hard_bounce:, soft_bounce: }] }
24
+ def bounces(**filters)
25
+ fetch(path(:stats, :bounces), filters)
26
+ end
27
+
28
+ # @return [Hash] { days: [{ date:, spam_complaint: }] }
29
+ def spam_complaints(**filters)
30
+ fetch(path(:stats, :spam), filters)
31
+ end
32
+
33
+ # @return [Hash] { days: [{ date:, opens: }] }
34
+ def opens(**filters)
35
+ fetch(path(:stats, :opens), filters)
36
+ end
37
+
38
+ # @return [Hash] { days: [{ date:, clicks: }] }
39
+ def clicks(**filters)
40
+ fetch(path(:stats, :clicks), filters)
41
+ end
42
+
43
+ # @return [Hash] { days: [{ date:, desktop:, mobile:, web_mail:, unknown: }], desktop:, ... }
44
+ def open_platforms(**filters)
45
+ fetch(path(:stats, :opens, :platforms), filters)
46
+ end
47
+
48
+ # @return [Hash] { days: [{ date:, platforms... }], desktop:, mobile:, web_mail:, unknown: }
49
+ def click_platforms(**filters)
50
+ fetch(path(:stats, :clicks, :platforms), filters)
51
+ end
52
+
53
+ # Client names are data, so they are kept verbatim:
54
+ # @return [Hash] { days: [{ date: "2026-10-01", counts: { "Apple Mail" => 17 } }],
55
+ # totals: { "Apple Mail" => 32 } }
56
+ def email_clients(**filters)
57
+ breakdown(path(:stats, :opens, :emailclients), filters)
58
+ end
59
+
60
+ # @return [Hash] { days: [{ date:, counts: { "Chrome" => 9 } }], totals: { "Chrome" => 23 } }
61
+ def browser_families(**filters)
62
+ breakdown(path(:stats, :clicks, :browserfamilies), filters)
63
+ end
64
+
65
+ private
66
+
67
+ def fetch(api_path, filters)
68
+ api_get(api_path, query(filters))
69
+ end
70
+
71
+ def breakdown(api_path, filters)
72
+ raw = connection.get(api_path, query: query(filters)) || {}
73
+ days = Array(raw["Days"]).map do |day|
74
+ { date: day["Date"], counts: day.except("Date") }
75
+ end
76
+ { days: days, totals: raw.except("Days") }
77
+ end
78
+
79
+ def query(filters)
80
+ Util.assert_known_keys!(filters.keys, FILTERS.keys, "stats filter")
81
+ filters.each_with_object({}) do |(key, value), query|
82
+ next if value.nil?
83
+
84
+ query[FILTERS.fetch(key)] = key == :message_stream ? value.to_s : Util.format_date(value)
85
+ end
86
+ end
87
+ end
88
+ end
89
+ end
@@ -0,0 +1,39 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Haskimail
4
+ module Resources
5
+ # Suppression lists of a message stream: https://haskimail.ru/developer/api/suppressions-api
6
+ class Suppressions < Base
7
+ REASONS = %w[HardBounce SpamComplaint ManualSuppression].freeze
8
+
9
+ # @return [Hash] { suppressions: [{ email_address:, suppression_reason:, origin:, created_at: }] }
10
+ def list(stream_id)
11
+ api_get(path(:"message-streams", stream_id, :suppressions, :dump))
12
+ end
13
+ alias dump list
14
+
15
+ # Stops sending to the addresses through this stream.
16
+ # @return [Hash] { suppressions: [{ email_address:, status: "Suppressed"|"Failed", message: }] }
17
+ def create(stream_id, email_addresses)
18
+ api_post(path(:"message-streams", stream_id, :suppressions), body(email_addresses), idempotent: true)
19
+ end
20
+ alias suppress create
21
+
22
+ # Only manual and hard-bounce suppressions can be removed.
23
+ # @return [Hash] { suppressions: [{ email_address:, status: "Deleted"|"Failed", message: }] }
24
+ def delete(stream_id, email_addresses)
25
+ api_post(path(:"message-streams", stream_id, :suppressions, :delete), body(email_addresses), idempotent: true)
26
+ end
27
+ alias unsuppress delete
28
+
29
+ private
30
+
31
+ def body(email_addresses)
32
+ list = Array(email_addresses).map { |email| email.to_s.strip }.reject(&:empty?).uniq
33
+ raise ArgumentError, "At least one email address is required" if list.empty?
34
+
35
+ { "Suppressions" => list.map { |email| { "EmailAddress" => email } } }
36
+ end
37
+ end
38
+ end
39
+ end
@@ -0,0 +1,68 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Haskimail
4
+ module Resources
5
+ # Templates stored on a server: https://haskimail.ru/developer/api/templates-api
6
+ class Templates < Base
7
+ DEFAULT_PAGE_SIZE = 500
8
+
9
+ # @param id_or_alias [Integer, String] template ID or name
10
+ def get(id_or_alias)
11
+ api_get(path(:templates, id_or_alias))
12
+ end
13
+
14
+ # @return [Hash] { total_count:, templates: [{ template_id:, name: }] }
15
+ def list(count: DEFAULT_PAGE_SIZE, offset: 0)
16
+ api_get(path(:templates), pagination_query(count, offset))
17
+ end
18
+
19
+ # @return [Enumerator] all templates, fetched page by page
20
+ def each(page_size: DEFAULT_PAGE_SIZE, &block)
21
+ iterate(:templates, page_size, nil, block) { |count, offset| list(count: count, offset: offset) }
22
+ end
23
+
24
+ # @return [Hash] { template_id:, name: }
25
+ def create(name:, subject: nil, html_body: nil, text_body: nil, associated_server_id: nil)
26
+ require_value!(:name, name)
27
+ body = { "Name" => name, "Subject" => subject, "HtmlBody" => html_body,
28
+ "TextBody" => text_body, "AssociatedServerId" => associated_server_id }
29
+ api_post(path(:templates), Util.compact(body))
30
+ end
31
+
32
+ # Replaces the whole template. The API blanks every field that is not sent,
33
+ # so all four attributes are required. Use #patch to change a single field.
34
+ def update(id_or_alias, name:, subject:, html_body:, text_body:)
35
+ require_value!(:name, name)
36
+ api_put(path(:templates, id_or_alias), {
37
+ "Name" => name, "Subject" => subject.to_s, "HtmlBody" => html_body.to_s, "TextBody" => text_body.to_s
38
+ })
39
+ end
40
+
41
+ # Reads the template and updates only the given attributes.
42
+ # Not atomic: a concurrent change between the read and the write is overwritten.
43
+ def patch(id_or_alias, **changes)
44
+ Util.assert_known_keys!(changes.keys, %i[name subject html_body text_body], "template")
45
+ current = get(id_or_alias)
46
+ attributes = %i[name subject html_body text_body].to_h { |key| [key, current[key]] }
47
+ update(id_or_alias, **attributes, **changes)
48
+ end
49
+
50
+ def delete(id_or_alias)
51
+ api_delete(path(:templates, id_or_alias))
52
+ end
53
+
54
+ # Renders a template without saving or sending anything.
55
+ # @return [Hash] { all_content_is_valid:, subject: {...}, html_body: {...}, text_body: {...},
56
+ # suggested_template_model: {...} }
57
+ def validate(subject: nil, html_body: nil, text_body: nil, test_render_model: nil)
58
+ if [subject, html_body, text_body].all? { |part| Util.blank?(part) }
59
+ raise ArgumentError, "At least one of subject, html_body or text_body is required"
60
+ end
61
+
62
+ body = Util.compact("Subject" => subject, "HtmlBody" => html_body, "TextBody" => text_body,
63
+ "TestRenderModel" => test_render_model)
64
+ Serializer.from_api(connection.post(path(:templates, :validate), body, idempotent: true))
65
+ end
66
+ end
67
+ end
68
+ end
@@ -0,0 +1,80 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "uri"
4
+
5
+ module Haskimail
6
+ module Resources
7
+ # Webhook management: https://haskimail.ru/developer/api/webhooks-api
8
+ # (for parsing incoming webhook requests see Haskimail::Webhooks)
9
+ class Webhooks < Base
10
+ TRIGGERS = %i[delivery bounce spam_complaint open click subscription_change].freeze
11
+
12
+ # @return [Hash] { webhooks: [{ id:, url:, message_stream:, triggers: {...} }] }
13
+ def list
14
+ api_get(path(:webhooks))
15
+ end
16
+
17
+ def get(webhook_id)
18
+ api_get(path(:webhooks, webhook_id))
19
+ end
20
+
21
+ # @param triggers [Hash] e.g. { delivery: true, bounce: true } or
22
+ # { delivery: { enabled: true } }. Events not listed are disabled.
23
+ # @example
24
+ # client.webhooks.create(url: "https://example.com/haskimail/events",
25
+ # message_stream: "2081", triggers: { delivery: true, bounce: true })
26
+ def create(url:, triggers:, message_stream: nil)
27
+ api_post(path(:webhooks), Util.compact(
28
+ "Url" => validate_url!(url),
29
+ "MessageStream" => message_stream&.to_s,
30
+ "Triggers" => build_triggers(triggers)
31
+ ))
32
+ end
33
+
34
+ # Only the given attributes and events change; the stream cannot be changed.
35
+ def update(webhook_id, url: nil, triggers: nil)
36
+ body = {}
37
+ body["Url"] = validate_url!(url) unless url.nil?
38
+ body["Triggers"] = build_triggers(triggers) unless triggers.nil?
39
+ raise ArgumentError, "Nothing to update: pass url: and/or triggers:" if body.empty?
40
+
41
+ api_put(path(:webhooks, webhook_id), body)
42
+ end
43
+
44
+ def delete(webhook_id)
45
+ api_delete(path(:webhooks, webhook_id))
46
+ end
47
+
48
+ private
49
+
50
+ def validate_url!(url)
51
+ uri = URI.parse(url.to_s)
52
+ unless uri.is_a?(URI::HTTP) && uri.host && !uri.host.empty?
53
+ raise ArgumentError, "url must be an absolute http(s) URL"
54
+ end
55
+
56
+ url.to_s
57
+ rescue URI::InvalidURIError
58
+ raise ArgumentError, "url must be an absolute http(s) URL"
59
+ end
60
+
61
+ def build_triggers(triggers)
62
+ raise ArgumentError, "triggers must be a Hash" unless triggers.is_a?(Hash)
63
+ raise ArgumentError, "triggers must list at least one event: #{TRIGGERS.join(', ')}" if triggers.empty?
64
+
65
+ Util.assert_known_keys!(triggers.keys, TRIGGERS, "trigger")
66
+ triggers.to_h do |event, setting|
67
+ enabled = if setting.is_a?(Hash)
68
+ setting.fetch(:enabled) do
69
+ setting.fetch("Enabled", setting["enabled"])
70
+ end
71
+ else
72
+ setting
73
+ end
74
+ require_boolean!("triggers[:#{event}]", enabled)
75
+ [Serializer.api_key(event.to_sym), { "Enabled" => enabled }]
76
+ end
77
+ end
78
+ end
79
+ end
80
+ end
@@ -0,0 +1,68 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module Haskimail
6
+ # Turns a Net::HTTPResponse into parsed JSON or raises the matching error.
7
+ module ResponseHandler
8
+ MAX_ERROR_BODY = 500
9
+
10
+ module_function
11
+
12
+ # @return [Hash, Array, nil] parsed JSON body
13
+ def call(response)
14
+ status = response.code.to_i
15
+ headers = response.each_header.to_h
16
+ body = parse_body(response.body, status)
17
+
18
+ if status.between?(200, 299)
19
+ raise_for_error_code!(body, status, headers)
20
+ return body
21
+ end
22
+
23
+ error_code = body.is_a?(Hash) ? integer(body["ErrorCode"]) : nil
24
+ api_message = body.is_a?(Hash) ? body["Message"] : body
25
+ raise ApiError.for(
26
+ status: status,
27
+ error_code: error_code,
28
+ message: error_message(api_message, status, error_code),
29
+ body: body,
30
+ headers: headers
31
+ )
32
+ end
33
+
34
+ # Some endpoints report failures as {"ErrorCode": N} even with HTTP 200.
35
+ def raise_for_error_code!(body, status, headers)
36
+ return unless body.is_a?(Hash)
37
+
38
+ code = integer(body["ErrorCode"])
39
+ return if code.nil? || code.zero?
40
+
41
+ raise ApiError.new(error_message(body["Message"], status, code),
42
+ status: status, error_code: code, body: body, headers: headers)
43
+ end
44
+
45
+ def integer(value)
46
+ value.nil? ? nil : Integer(value, exception: false)
47
+ end
48
+
49
+ def parse_body(raw, status)
50
+ text = raw.to_s.dup.force_encoding(Encoding::UTF_8)
51
+ return nil if text.strip.empty?
52
+
53
+ JSON.parse(text)
54
+ rescue JSON::ParserError
55
+ raise InvalidResponseError.new("Haskimail returned invalid JSON (HTTP #{status})", status: status) if status < 300
56
+
57
+ text.byteslice(0, MAX_ERROR_BODY).scrub
58
+ end
59
+
60
+ def error_message(api_message, status, error_code)
61
+ details = ["HTTP #{status}"]
62
+ details << "ErrorCode #{error_code}" if error_code
63
+ text = api_message.to_s.strip
64
+ text = "Haskimail API error" if text.empty?
65
+ "#{text} (#{details.join(', ')})"
66
+ end
67
+ end
68
+ end
@@ -0,0 +1,40 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Haskimail
4
+ # Wraps a credential so that it cannot leak through #inspect, #to_s,
5
+ # string interpolation, JSON/ActiveSupport serialization or error trackers
6
+ # that dump instance variables. Use #reveal to read the real value.
7
+ class Secret
8
+ REDACTED = "[REDACTED]"
9
+
10
+ def initialize(value)
11
+ @value = value.to_s.dup.freeze
12
+ freeze
13
+ end
14
+
15
+ def reveal
16
+ @value
17
+ end
18
+
19
+ def empty?
20
+ @value.empty?
21
+ end
22
+
23
+ def ==(other)
24
+ other.is_a?(Secret) && Webhooks.secure_compare(reveal, other.reveal)
25
+ end
26
+ alias eql? ==
27
+
28
+ def hash
29
+ [self.class, @value].hash
30
+ end
31
+
32
+ def inspect
33
+ "#<#{self.class.name} #{Util.redact(@value)}>"
34
+ end
35
+
36
+ def to_s = REDACTED
37
+ def as_json(*) = REDACTED
38
+ def to_json(*) = REDACTED.to_json
39
+ end
40
+ end
@@ -0,0 +1,75 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Haskimail
4
+ # Converts between idiomatic Ruby hashes and the PascalCase JSON of the API.
5
+ #
6
+ # Requests: snake_case keys (Symbols or Strings, e.g. from params or a
7
+ # HashWithIndifferentAccess) are camelized (`:html_body` -> "HtmlBody",
8
+ # "message_id" -> "MessageID"). Keys that already start with an uppercase
9
+ # letter ("HtmlBody") are sent verbatim. Two keys that map to the same API
10
+ # field raise ArgumentError instead of silently overwriting each other.
11
+ #
12
+ # Responses: keys become snake_case Symbols ("MessageID" -> :message_id,
13
+ # "DKIMVerified" -> :dkim_verified).
14
+ #
15
+ # Values under "opaque" keys (Metadata, TemplateModel, ...) are user data and
16
+ # are never transformed in either direction: their keys are template
17
+ # placeholders such as "{{name}}" or arbitrary metadata names.
18
+ module Serializer
19
+ OPAQUE_KEYS = %w[Metadata TemplateModel TestRenderModel SuggestedTemplateModel].freeze
20
+
21
+ REQUEST_KEY_OVERRIDES = {
22
+ "id" => "ID",
23
+ "message_id" => "MessageID",
24
+ "content_id" => "ContentID",
25
+ "server_id" => "ServerID",
26
+ "source_server_id" => "SourceServerID",
27
+ "destination_server_id" => "DestinationServerID"
28
+ }.freeze
29
+
30
+ module_function
31
+
32
+ def to_api(value)
33
+ case value
34
+ when Hash
35
+ value.each_with_object({}) do |(key, val), out|
36
+ api_key = api_key(key)
37
+ raise ArgumentError, "Duplicate field #{api_key} (given as #{key.inspect})" if out.key?(api_key)
38
+
39
+ out[api_key] = OPAQUE_KEYS.include?(api_key) ? val : to_api(val)
40
+ end
41
+ when Array then value.map { |item| to_api(item) }
42
+ else value
43
+ end
44
+ end
45
+
46
+ def from_api(value)
47
+ case value
48
+ when Hash
49
+ value.each_with_object({}) do |(key, val), out|
50
+ out[ruby_key(key)] = OPAQUE_KEYS.include?(key.to_s) ? val : from_api(val)
51
+ end
52
+ when Array then value.map { |item| from_api(item) }
53
+ else value
54
+ end
55
+ end
56
+
57
+ def api_key(key)
58
+ name = key.to_s
59
+ return name if name.match?(/\A[A-Z]/)
60
+
61
+ REQUEST_KEY_OVERRIDES.fetch(name) do
62
+ name.split("_").map { |part| part.empty? ? part : part[0].upcase + part[1..] }.join
63
+ end
64
+ end
65
+
66
+ def ruby_key(key)
67
+ key.to_s
68
+ .gsub(/([A-Z\d]+)([A-Z][a-z])/, '\1_\2')
69
+ .gsub(/([a-z\d])([A-Z])/, '\1_\2')
70
+ .tr("-", "_")
71
+ .downcase
72
+ .to_sym
73
+ end
74
+ end
75
+ end