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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +15 -0
- data/LICENSE.txt +21 -0
- data/README.md +267 -0
- data/lib/generators/haskimail/install/install_generator.rb +32 -0
- data/lib/generators/haskimail/install/templates/haskimail.rb +27 -0
- data/lib/haskimail/attachment.rb +84 -0
- data/lib/haskimail/clients.rb +78 -0
- data/lib/haskimail/configuration.rb +89 -0
- data/lib/haskimail/connection.rb +268 -0
- data/lib/haskimail/errors.rb +130 -0
- data/lib/haskimail/paginator.rb +48 -0
- data/lib/haskimail/rails/delivery_method.rb +56 -0
- data/lib/haskimail/rails/mail_converter.rb +130 -0
- data/lib/haskimail/rails/mailer_helpers.rb +42 -0
- data/lib/haskimail/rails/message_options.rb +88 -0
- data/lib/haskimail/rails/railtie.rb +28 -0
- data/lib/haskimail/resources/account_templates.rb +53 -0
- data/lib/haskimail/resources/base.rb +75 -0
- data/lib/haskimail/resources/bounces.rb +43 -0
- data/lib/haskimail/resources/domains.rb +42 -0
- data/lib/haskimail/resources/emails.rb +149 -0
- data/lib/haskimail/resources/inbound_rules.rb +33 -0
- data/lib/haskimail/resources/message_streams.rb +46 -0
- data/lib/haskimail/resources/messages.rb +135 -0
- data/lib/haskimail/resources/servers.rb +89 -0
- data/lib/haskimail/resources/stats.rb +89 -0
- data/lib/haskimail/resources/suppressions.rb +39 -0
- data/lib/haskimail/resources/templates.rb +68 -0
- data/lib/haskimail/resources/webhooks.rb +80 -0
- data/lib/haskimail/response_handler.rb +68 -0
- data/lib/haskimail/secret.rb +40 -0
- data/lib/haskimail/serializer.rb +75 -0
- data/lib/haskimail/util.rb +110 -0
- data/lib/haskimail/version.rb +5 -0
- data/lib/haskimail/webhooks/endpoint.rb +146 -0
- data/lib/haskimail/webhooks/event.rb +103 -0
- data/lib/haskimail/webhooks/inbound_message.rb +100 -0
- data/lib/haskimail/webhooks.rb +87 -0
- data/lib/haskimail-rails.rb +19 -0
- data/lib/haskimail.rb +58 -0
- 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
|