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,178 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "base"
|
|
4
|
+
|
|
5
|
+
module Haskimail
|
|
6
|
+
module Resources
|
|
7
|
+
# Shared logic for outbound searches limited to count + offset <= 10000.
|
|
8
|
+
# @api private
|
|
9
|
+
class OutboundSearch < Base
|
|
10
|
+
# Documented maximum of count.
|
|
11
|
+
MAX_PAGE_SIZE = 500
|
|
12
|
+
# Documented maximum of count + offset.
|
|
13
|
+
MAX_WINDOW = 10_000
|
|
14
|
+
|
|
15
|
+
private
|
|
16
|
+
|
|
17
|
+
def search(endpoint, filters, mapping)
|
|
18
|
+
check_paging!(filters[:count], filters[:offset], max_count: MAX_PAGE_SIZE, max_window: MAX_WINDOW)
|
|
19
|
+
api_get(endpoint, query(filters, mapping))
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def iterate(endpoint, items_key, filters, mapping, page_size)
|
|
23
|
+
reject_paging!(filters)
|
|
24
|
+
check_page_size!(page_size, MAX_PAGE_SIZE)
|
|
25
|
+
base = query(filters, mapping)
|
|
26
|
+
paginate(items_key, page_size: page_size, max_window: MAX_WINDOW) do |count, offset|
|
|
27
|
+
api_get(endpoint, base.merge("count" => count, "offset" => offset))
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# Sent messages: https://haskimail.ru/developer/api/messages-api
|
|
33
|
+
class Messages < OutboundSearch
|
|
34
|
+
# Keyword => query parameter.
|
|
35
|
+
FILTERS = PAGING.merge(
|
|
36
|
+
recipient: "recipient", from_email: "fromemail", tag: "tag", subject: "subject",
|
|
37
|
+
message_stream: "messagestream", from_date: "fromdate", to_date: "todate"
|
|
38
|
+
).freeze
|
|
39
|
+
|
|
40
|
+
# GET /messages — newest first, one record per recipient.
|
|
41
|
+
# `from_date`/`to_date` accept Date, Time (converted to Moscow time) or String.
|
|
42
|
+
# @return [ApiObject] TotalCount, Messages[]
|
|
43
|
+
def list(**filters)
|
|
44
|
+
object(search("/messages", filters, FILTERS))
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# Iterates over search results (the API caps the window at 10 000 records).
|
|
48
|
+
# @return [Paginator]
|
|
49
|
+
def each(page_size: MAX_PAGE_SIZE, **filters, &block)
|
|
50
|
+
run(iterate("/messages", "Messages", filters, FILTERS, page_size), block)
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# GET /messages/{messageid}/details
|
|
54
|
+
# @raise [UnprocessableEntityError] ErrorCode 701 when not found
|
|
55
|
+
def details(message_id)
|
|
56
|
+
object(api_get(path("/messages/%s/details", message_id)))
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# Open events: https://haskimail.ru/developer/api/messages-api
|
|
61
|
+
class Opens < OutboundSearch
|
|
62
|
+
# Keyword => query parameter.
|
|
63
|
+
FILTERS = PAGING.merge(
|
|
64
|
+
recipient: "recipient", tag: "tag", message_stream: "messagestream",
|
|
65
|
+
from_date: "fromdate", to_date: "todate"
|
|
66
|
+
).freeze
|
|
67
|
+
# Filters of #for_message.
|
|
68
|
+
MESSAGE_FILTERS = PAGING.merge(recipient: "recipient", tag: "tag", message_stream: "messagestream").freeze
|
|
69
|
+
|
|
70
|
+
# GET /messages/opens
|
|
71
|
+
# @return [ApiObject] TotalCount, Opens[]
|
|
72
|
+
def list(**filters)
|
|
73
|
+
object(search("/messages/opens", filters, FILTERS))
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
# @return [Paginator]
|
|
77
|
+
def each(page_size: MAX_PAGE_SIZE, **filters, &block)
|
|
78
|
+
run(iterate("/messages/opens", "Opens", filters, FILTERS, page_size), block)
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
# GET /messages/opens/{messageid} — all opens of one email.
|
|
82
|
+
def for_message(message_id, **filters)
|
|
83
|
+
object(search(path("/messages/opens/%s", message_id), filters, MESSAGE_FILTERS))
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
# Link clicks: https://haskimail.ru/developer/api/messages-api
|
|
88
|
+
class Clicks < OutboundSearch
|
|
89
|
+
# Same filters as Opens.
|
|
90
|
+
FILTERS = Opens::FILTERS
|
|
91
|
+
# Same filters as Opens#for_message.
|
|
92
|
+
MESSAGE_FILTERS = Opens::MESSAGE_FILTERS
|
|
93
|
+
|
|
94
|
+
# GET /messages/clicks
|
|
95
|
+
# @return [ApiObject] TotalCount, Clicks[]
|
|
96
|
+
def list(**filters)
|
|
97
|
+
object(search("/messages/clicks", filters, FILTERS))
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
# @return [Paginator]
|
|
101
|
+
def each(page_size: MAX_PAGE_SIZE, **filters, &block)
|
|
102
|
+
run(iterate("/messages/clicks", "Clicks", filters, FILTERS, page_size), block)
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
# GET /messages/clicks/{messageid} — all clicks of one email.
|
|
106
|
+
def for_message(message_id, **filters)
|
|
107
|
+
object(search(path("/messages/clicks/%s", message_id), filters, MESSAGE_FILTERS))
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
# Inbound messages (server token only): https://haskimail.ru/developer/api/messages-api
|
|
112
|
+
class InboundMessages < Base
|
|
113
|
+
# Keyword => query parameter.
|
|
114
|
+
FILTERS = PAGING.merge(
|
|
115
|
+
recipient: "recipient", from_email: "fromemail", subject: "subject",
|
|
116
|
+
mailbox_hash: "mailboxhash", status: "status", from_date: "fromdate", to_date: "todate"
|
|
117
|
+
).freeze
|
|
118
|
+
# Accepted values of the status filter.
|
|
119
|
+
STATUSES = %w[queued processed blocked failed scheduled].freeze
|
|
120
|
+
# Documented maximum of count.
|
|
121
|
+
MAX_PAGE_SIZE = 500
|
|
122
|
+
|
|
123
|
+
# GET /messages/inbound — last 45 days, newest first.
|
|
124
|
+
# @option filters [String, Symbol] :status one of STATUSES, any case
|
|
125
|
+
# (the API itself reports statuses capitalized: "Blocked")
|
|
126
|
+
# @return [ApiObject] TotalCount, InboundMessages[]
|
|
127
|
+
def list(**filters)
|
|
128
|
+
object(api_get("/messages/inbound", build_query(filters)))
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
# @return [Paginator]
|
|
132
|
+
def each(page_size: MAX_PAGE_SIZE, **filters, &block)
|
|
133
|
+
reject_paging!(filters)
|
|
134
|
+
check_page_size!(page_size, MAX_PAGE_SIZE)
|
|
135
|
+
base = build_query(filters)
|
|
136
|
+
paginator = paginate("InboundMessages", page_size: page_size) do |count, offset|
|
|
137
|
+
api_get("/messages/inbound", base.merge("count" => count, "offset" => offset))
|
|
138
|
+
end
|
|
139
|
+
run(paginator, block)
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
# GET /messages/inbound/{messageid}/details
|
|
143
|
+
def details(message_id)
|
|
144
|
+
object(api_get(path("/messages/inbound/%s/details", message_id)))
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
# PUT /messages/inbound/{messageid}/bypass — delivers a Blocked message to the webhook.
|
|
148
|
+
def bypass(message_id)
|
|
149
|
+
object(api_put(path("/messages/inbound/%s/bypass", message_id), idempotent: false))
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
# PUT /messages/inbound/{messageid}/retry — re-delivers a Failed message.
|
|
153
|
+
def retry(message_id)
|
|
154
|
+
object(api_put(path("/messages/inbound/%s/retry", message_id), idempotent: false))
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
private
|
|
158
|
+
|
|
159
|
+
def build_query(filters)
|
|
160
|
+
check_paging!(filters[:count], filters[:offset], max_count: MAX_PAGE_SIZE)
|
|
161
|
+
unless filters[:status].nil?
|
|
162
|
+
wanted = filters[:status].to_s.b.downcase # ASCII-only, safe for any bytes
|
|
163
|
+
status = STATUSES.find { |name| name == wanted }
|
|
164
|
+
raise ValidationError, "status must be one of: #{STATUSES.join(', ')}" unless status
|
|
165
|
+
|
|
166
|
+
filters = filters.merge(status: status)
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
# This endpoint expects ISO-like "2026-09-01T12:00:00" timestamps.
|
|
170
|
+
prepared = filters.to_h do |key, value|
|
|
171
|
+
value = value.to_time if value.is_a?(DateTime)
|
|
172
|
+
[key, value.is_a?(Time) ? Util.format_time(value, separator: "T") : value]
|
|
173
|
+
end
|
|
174
|
+
query(prepared, FILTERS)
|
|
175
|
+
end
|
|
176
|
+
end
|
|
177
|
+
end
|
|
178
|
+
end
|
|
@@ -0,0 +1,297 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "base"
|
|
4
|
+
|
|
5
|
+
module Haskimail
|
|
6
|
+
module Resources
|
|
7
|
+
# Bounces: https://haskimail.ru/developer/api/bounce-api
|
|
8
|
+
class Bounces < Base
|
|
9
|
+
# Keyword => query parameter.
|
|
10
|
+
FILTERS = PAGING.merge(
|
|
11
|
+
type: "type", email_filter: "emailFilter", tag: "tag", message_id: "messageID",
|
|
12
|
+
message_stream: "messagestream", from_date: "fromdate", to_date: "todate"
|
|
13
|
+
).freeze
|
|
14
|
+
|
|
15
|
+
# Values accepted by the `type:` filter. The delivery-stats spellings
|
|
16
|
+
# (BulkBounce, SpamBlock) are accepted by the API as well.
|
|
17
|
+
TYPES = %w[HardBounce SoftBounce BlkBounce SpmBounce BulkBounce SpamBlock].freeze
|
|
18
|
+
|
|
19
|
+
# GET /deliverystats — bounce counts by type for the last 45 days.
|
|
20
|
+
# @return [ApiObject] Bounces[] (Type, Name, Count)
|
|
21
|
+
def delivery_stats
|
|
22
|
+
object(api_get("/deliverystats"))
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# GET /bounces — newest first.
|
|
26
|
+
# @return [ApiObject] TotalCount, Bounces[]
|
|
27
|
+
def list(**filters)
|
|
28
|
+
object(api_get("/bounces", build_query(filters)))
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# @return [Paginator]
|
|
32
|
+
def each(page_size: 500, **filters, &block)
|
|
33
|
+
reject_paging!(filters)
|
|
34
|
+
base = build_query(filters)
|
|
35
|
+
paginator = paginate("Bounces", page_size: page_size) do |count, offset|
|
|
36
|
+
api_get("/bounces", base.merge("count" => count, "offset" => offset))
|
|
37
|
+
end
|
|
38
|
+
run(paginator, block)
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
private
|
|
42
|
+
|
|
43
|
+
def build_query(filters)
|
|
44
|
+
check_paging!(filters[:count], filters[:offset])
|
|
45
|
+
type = filters[:type]
|
|
46
|
+
raise ValidationError, "type must be one of: #{TYPES.join(', ')}" if type && !TYPES.include?(type.to_s)
|
|
47
|
+
|
|
48
|
+
query(Dates.date_only(filters), FILTERS)
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# Converts date filters for endpoints that accept only dates.
|
|
53
|
+
# @api private
|
|
54
|
+
module Dates
|
|
55
|
+
# Filters converted to YYYY-MM-DD.
|
|
56
|
+
KEYS = %i[from_date to_date].freeze
|
|
57
|
+
|
|
58
|
+
# Converts from_date/to_date for endpoints that accept only YYYY-MM-DD.
|
|
59
|
+
def self.date_only(filters)
|
|
60
|
+
filters.to_h { |key, value| KEYS.include?(key) ? [key, Util.format_date(value)] : [key, value] }
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# Message streams: https://haskimail.ru/developer/api/message-streams-api
|
|
65
|
+
class MessageStreams < Base
|
|
66
|
+
# Accepted message_stream_type values.
|
|
67
|
+
TYPES = %w[transactional broadcasts].freeze
|
|
68
|
+
|
|
69
|
+
# GET /message-streams (archived streams included)
|
|
70
|
+
# @return [ApiObject] TotalCount, MessageStreams[]
|
|
71
|
+
def list
|
|
72
|
+
object(api_get("/message-streams"))
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
# GET /message-streams/{streamid}
|
|
76
|
+
def get(stream_id)
|
|
77
|
+
object(api_get(path("/message-streams/%s", stream_id)))
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
# POST /message-streams
|
|
81
|
+
# @param message_stream_type [String, Symbol] "transactional" or "broadcasts"
|
|
82
|
+
# @param server_id [Integer, nil] required with the account token
|
|
83
|
+
def create(name:, message_stream_type:, server_id: nil)
|
|
84
|
+
if server_id.nil? && @client.is_a?(AccountClient)
|
|
85
|
+
raise ValidationError, "server_id is required with the account token"
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
unless TYPES.include?(Util.strip_text(message_stream_type, "message_stream_type").downcase)
|
|
89
|
+
raise ValidationError, "message_stream_type must be transactional or broadcasts"
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
payload = Util.to_api(name: name, message_stream_type: message_stream_type.to_s, server_id: server_id)
|
|
93
|
+
object(api_post("/message-streams", payload))
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
# PATCH /message-streams/{streamid} — only the name can be changed.
|
|
97
|
+
def update(stream_id, name:)
|
|
98
|
+
raise ValidationError, "name must not be blank" if Util.blank?(name)
|
|
99
|
+
|
|
100
|
+
object(api_patch(path("/message-streams/%s", stream_id), { "Name" => name }))
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
# POST /message-streams/{streamid}/archive — deleted after 45 days unless unarchived.
|
|
104
|
+
def archive(stream_id)
|
|
105
|
+
object(api_post(path("/message-streams/%s/archive", stream_id), idempotent: true))
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
# POST /message-streams/{streamid}/unarchive
|
|
109
|
+
def unarchive(stream_id)
|
|
110
|
+
object(api_post(path("/message-streams/%s/unarchive", stream_id), idempotent: true))
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
# Suppression lists: https://haskimail.ru/developer/api/suppressions-api
|
|
115
|
+
class Suppressions < Base
|
|
116
|
+
# GET /message-streams/{streamid}/suppressions/dump
|
|
117
|
+
# @return [ApiObject] Suppressions[] (EmailAddress, SuppressionReason, Origin, CreatedAt)
|
|
118
|
+
def list(stream_id)
|
|
119
|
+
object(api_get(path("/message-streams/%s/suppressions/dump", stream_id)))
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
# POST /message-streams/{streamid}/suppressions
|
|
123
|
+
# @param emails [Array<String>, String]
|
|
124
|
+
# @return [ApiObject] per-address Status: Suppressed / Failed
|
|
125
|
+
def create(stream_id, emails)
|
|
126
|
+
object(api_post(path("/message-streams/%s/suppressions", stream_id), payload(emails), idempotent: true))
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
# POST /message-streams/{streamid}/suppressions/delete
|
|
130
|
+
# @return [ApiObject] per-address Status: Deleted / Failed
|
|
131
|
+
def delete(stream_id, emails)
|
|
132
|
+
object(api_post(path("/message-streams/%s/suppressions/delete", stream_id), payload(emails),
|
|
133
|
+
idempotent: true))
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
private
|
|
137
|
+
|
|
138
|
+
def payload(emails)
|
|
139
|
+
list = Array(emails).map { |email| Util.strip_text(email, "Email address") }
|
|
140
|
+
raise ValidationError, "At least one email address is required" if list.empty?
|
|
141
|
+
raise ValidationError, "Email addresses must not be blank" if list.any?(&:empty?)
|
|
142
|
+
|
|
143
|
+
{ "Suppressions" => list.map { |email| { "EmailAddress" => email } } }
|
|
144
|
+
end
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
# Webhooks: https://haskimail.ru/developer/api/webhooks-api
|
|
148
|
+
class Webhooks < Base
|
|
149
|
+
# Keyword => API trigger name.
|
|
150
|
+
TRIGGERS = {
|
|
151
|
+
delivery: "Delivery", bounce: "Bounce", spam_complaint: "SpamComplaint",
|
|
152
|
+
open: "Open", click: "Click", subscription_change: "SubscriptionChange"
|
|
153
|
+
}.freeze
|
|
154
|
+
|
|
155
|
+
# GET /webhooks
|
|
156
|
+
# @return [ApiObject] Webhooks[]
|
|
157
|
+
def list
|
|
158
|
+
object(api_get("/webhooks"))
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
# GET /webhooks/{webhookid}
|
|
162
|
+
def get(webhook_id)
|
|
163
|
+
object(api_get(path("/webhooks/%s", webhook_id)))
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
# POST /webhooks
|
|
167
|
+
#
|
|
168
|
+
# client.webhooks.create(url: "https://example.com/haski",
|
|
169
|
+
# message_stream: "2081",
|
|
170
|
+
# triggers: { delivery: true, bounce: true, spam_complaint: true })
|
|
171
|
+
#
|
|
172
|
+
# Triggers not listed are disabled. The long API form
|
|
173
|
+
# (`{ "Bounce" => { "Enabled" => true } }`) is accepted too.
|
|
174
|
+
def create(url:, message_stream: nil, triggers: {})
|
|
175
|
+
payload = { "Url" => check_url!(url), "Triggers" => normalize_triggers(triggers) }
|
|
176
|
+
payload["MessageStream"] = message_stream.to_s unless message_stream.nil?
|
|
177
|
+
object(api_post("/webhooks", payload))
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
# PUT /webhooks/{webhookid} — only given triggers change; the stream cannot change.
|
|
181
|
+
def update(webhook_id, url: nil, triggers: nil)
|
|
182
|
+
payload = {}
|
|
183
|
+
payload["Url"] = check_url!(url) unless url.nil?
|
|
184
|
+
payload["Triggers"] = normalize_triggers(triggers) unless triggers.nil? || triggers == {}
|
|
185
|
+
raise ValidationError, "Nothing to update: pass url: and/or triggers:" if payload.empty?
|
|
186
|
+
|
|
187
|
+
object(api_put(path("/webhooks/%s", webhook_id), payload))
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
# DELETE /webhooks/{webhookid}
|
|
191
|
+
def delete(webhook_id)
|
|
192
|
+
object(api_delete(path("/webhooks/%s", webhook_id)))
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
private
|
|
196
|
+
|
|
197
|
+
def check_url!(url)
|
|
198
|
+
uri = URI.parse(url.to_s)
|
|
199
|
+
valid = %w[http https].include?(uri.scheme) && !uri.host.to_s.empty?
|
|
200
|
+
raise ValidationError, "Webhook url must be an http(s) URL" unless valid
|
|
201
|
+
|
|
202
|
+
url.to_s
|
|
203
|
+
rescue URI::InvalidURIError
|
|
204
|
+
raise ValidationError, "Webhook url is not a valid URL"
|
|
205
|
+
end
|
|
206
|
+
|
|
207
|
+
def normalize_triggers(triggers)
|
|
208
|
+
raise ValidationError, "triggers must be a Hash" unless triggers.is_a?(Hash)
|
|
209
|
+
|
|
210
|
+
triggers.each_with_object({}) do |(name, value), result|
|
|
211
|
+
api_name = name.is_a?(Symbol) ? TRIGGERS[name] : name.to_s
|
|
212
|
+
if api_name.nil? || !TRIGGERS.value?(api_name)
|
|
213
|
+
raise ValidationError, "Unknown trigger #{name.inspect}. Allowed: #{TRIGGERS.keys.join(', ')}"
|
|
214
|
+
end
|
|
215
|
+
|
|
216
|
+
result[api_name] = { "Enabled" => enabled_flag(name, value) }
|
|
217
|
+
end
|
|
218
|
+
end
|
|
219
|
+
|
|
220
|
+
def enabled_flag(name, value)
|
|
221
|
+
if value.is_a?(Hash)
|
|
222
|
+
key = ["Enabled", :enabled, :Enabled, "enabled"].find { |candidate| value.key?(candidate) }
|
|
223
|
+
value = key.nil? ? nil : value[key]
|
|
224
|
+
end
|
|
225
|
+
return value if [true, false].include?(value)
|
|
226
|
+
|
|
227
|
+
raise ValidationError, "Trigger #{name.inspect} must be true or false"
|
|
228
|
+
end
|
|
229
|
+
end
|
|
230
|
+
|
|
231
|
+
# Inbound blocking rules (server token): https://haskimail.ru/developer/api/inbound-rules-triggers-api
|
|
232
|
+
class InboundRules < Base
|
|
233
|
+
# Documented maximum of count.
|
|
234
|
+
MAX_PAGE_SIZE = 500
|
|
235
|
+
|
|
236
|
+
# GET /triggers/inboundrules
|
|
237
|
+
# @return [ApiObject] TotalCount, InboundRules[] (ID, Rule)
|
|
238
|
+
def list(count: nil, offset: nil)
|
|
239
|
+
check_paging!(count, offset, max_count: MAX_PAGE_SIZE)
|
|
240
|
+
object(api_get("/triggers/inboundrules", query({ count: count, offset: offset }, PAGING)))
|
|
241
|
+
end
|
|
242
|
+
|
|
243
|
+
# @return [Paginator]
|
|
244
|
+
def each(page_size: MAX_PAGE_SIZE, &block)
|
|
245
|
+
check_page_size!(page_size, MAX_PAGE_SIZE)
|
|
246
|
+
paginator = paginate("InboundRules", page_size: page_size) do |count, offset|
|
|
247
|
+
api_get("/triggers/inboundrules", { "count" => count, "offset" => offset })
|
|
248
|
+
end
|
|
249
|
+
run(paginator, block)
|
|
250
|
+
end
|
|
251
|
+
|
|
252
|
+
# POST /triggers/inboundrules
|
|
253
|
+
# @param rule [String] email address ("spam@example.com") or whole domain ("example.org")
|
|
254
|
+
def create(rule)
|
|
255
|
+
text = Util.strip_text(rule, "Rule")
|
|
256
|
+
raise ValidationError, "rule must not be blank" if text.empty?
|
|
257
|
+
|
|
258
|
+
object(api_post("/triggers/inboundrules", { "Rule" => text }))
|
|
259
|
+
end
|
|
260
|
+
|
|
261
|
+
# DELETE /triggers/inboundrules/{triggerid}
|
|
262
|
+
def delete(rule_id)
|
|
263
|
+
object(api_delete(path("/triggers/inboundrules/%s", rule_id)))
|
|
264
|
+
end
|
|
265
|
+
end
|
|
266
|
+
|
|
267
|
+
# Outbound statistics: https://haskimail.ru/developer/api/stats-api
|
|
268
|
+
#
|
|
269
|
+
# Every method accepts `from_date:`, `to_date:` (Date, Time or "YYYY-MM-DD")
|
|
270
|
+
# and `message_stream:`.
|
|
271
|
+
class Stats < Base
|
|
272
|
+
# Keyword => query parameter.
|
|
273
|
+
FILTERS = { from_date: "fromdate", to_date: "todate", message_stream: "messagestream" }.freeze
|
|
274
|
+
|
|
275
|
+
# Method name => endpoint.
|
|
276
|
+
ENDPOINTS = {
|
|
277
|
+
summary: "/stats",
|
|
278
|
+
sends: "/stats/sends",
|
|
279
|
+
bounces: "/stats/bounces",
|
|
280
|
+
spam_complaints: "/stats/spam",
|
|
281
|
+
opens: "/stats/opens",
|
|
282
|
+
clicks: "/stats/clicks",
|
|
283
|
+
opens_by_platform: "/stats/opens/platforms",
|
|
284
|
+
opens_by_email_client: "/stats/opens/emailclients",
|
|
285
|
+
clicks_by_browser_family: "/stats/clicks/browserfamilies",
|
|
286
|
+
clicks_by_platform: "/stats/clicks/platforms"
|
|
287
|
+
}.freeze
|
|
288
|
+
|
|
289
|
+
ENDPOINTS.each do |name, endpoint|
|
|
290
|
+
define_method(name) do |**filters|
|
|
291
|
+
# @type self: Stats
|
|
292
|
+
object(api_get(endpoint, query(Dates.date_only(filters), FILTERS)))
|
|
293
|
+
end
|
|
294
|
+
end
|
|
295
|
+
end
|
|
296
|
+
end
|
|
297
|
+
end
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "base"
|
|
4
|
+
|
|
5
|
+
module Haskimail
|
|
6
|
+
module Resources
|
|
7
|
+
# Templates: https://haskimail.ru/developer/api/templates-api
|
|
8
|
+
# Sending by template lives in {Emails#deliver_with_template}.
|
|
9
|
+
class Templates < Base
|
|
10
|
+
# GET /templates
|
|
11
|
+
# @return [ApiObject] TotalCount, Templates[]
|
|
12
|
+
def list(count: nil, offset: nil)
|
|
13
|
+
check_paging!(count, offset)
|
|
14
|
+
object(api_get("/templates", query({ count: count, offset: offset }, PAGING)))
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
# Iterates over all templates, requesting pages lazily.
|
|
18
|
+
# @return [Paginator]
|
|
19
|
+
def each(page_size: 500, &block)
|
|
20
|
+
paginator = paginate("Templates", page_size: page_size) do |count, offset|
|
|
21
|
+
api_get("/templates", { "count" => count, "offset" => offset })
|
|
22
|
+
end
|
|
23
|
+
run(paginator, block)
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# GET /templates/{templateIdOrAlias}
|
|
27
|
+
# @param id_or_alias [Integer, String]
|
|
28
|
+
def get(id_or_alias)
|
|
29
|
+
object(api_get(path("/templates/%s", id_or_alias)))
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# POST /templates
|
|
33
|
+
# client.templates.create(name: "order-created", subject: "Заказ {{order}}",
|
|
34
|
+
# html_body: "<p>{{name}}</p>", text_body: "{{name}}")
|
|
35
|
+
def create(params = nil, **fields)
|
|
36
|
+
payload = Util.to_api(merge_params(params, fields))
|
|
37
|
+
raise ValidationError, "name is required" if Util.blank?(payload["Name"])
|
|
38
|
+
|
|
39
|
+
object(api_post("/templates", payload))
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# PUT /templates/{templateIdOrAlias}
|
|
43
|
+
#
|
|
44
|
+
# The API overwrites all four fields and blanks any that are omitted,
|
|
45
|
+
# so all four are required here to prevent accidental data loss.
|
|
46
|
+
# Pass "" explicitly to clear a field.
|
|
47
|
+
def update(id_or_alias, name:, subject:, html_body:, text_body:)
|
|
48
|
+
payload = { "Name" => name, "Subject" => subject, "HtmlBody" => html_body, "TextBody" => text_body }
|
|
49
|
+
payload.each do |field, value|
|
|
50
|
+
raise ValidationError, "#{field} must be a String (use \"\" to clear it)" unless value.is_a?(String)
|
|
51
|
+
end
|
|
52
|
+
raise ValidationError, "name must not be blank" if Util.blank?(name)
|
|
53
|
+
|
|
54
|
+
object(api_put(path("/templates/%s", id_or_alias), payload))
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# DELETE /templates/{templateIdOrAlias}
|
|
58
|
+
def delete(id_or_alias)
|
|
59
|
+
object(api_delete(path("/templates/%s", id_or_alias)))
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# POST /templates/validate — renders a template without saving or sending.
|
|
63
|
+
# Answers 200 even if the template has errors: check `all_content_is_valid`.
|
|
64
|
+
#
|
|
65
|
+
# result = client.templates.validate(subject: "Заказ {{order}}",
|
|
66
|
+
# test_render_model: { "{{order}}" => "1042" })
|
|
67
|
+
# result.all_content_is_valid # => true
|
|
68
|
+
def validate(params = nil, **fields)
|
|
69
|
+
payload = Util.to_api(merge_params(params, fields))
|
|
70
|
+
unless %w[Subject HtmlBody TextBody].any? { |key| payload.key?(key) }
|
|
71
|
+
raise ValidationError, "At least one of subject, html_body, text_body is required"
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
object(api_post("/templates/validate", payload, idempotent: true))
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# Templates as seen by the account token; adds moving between servers.
|
|
79
|
+
class AccountTemplates < Templates
|
|
80
|
+
# POST /templates — with the account token `associated_server_id:` is
|
|
81
|
+
# required (the server the template is created on).
|
|
82
|
+
def create(params = nil, **fields)
|
|
83
|
+
merged = merge_params(params, fields)
|
|
84
|
+
unless Util.to_api(merged).key?("AssociatedServerId")
|
|
85
|
+
raise ValidationError, "associated_server_id is required with the account token"
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
super(merged)
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
# PUT /templates/push — MOVES (not copies) every template of one server
|
|
92
|
+
# to another. Use `perform_changes: false` for a dry run.
|
|
93
|
+
#
|
|
94
|
+
# @param perform_changes [Boolean] required on purpose: no default for a destructive call
|
|
95
|
+
def push(source_server_id:, destination_server_id:, perform_changes:)
|
|
96
|
+
raise ValidationError, "perform_changes must be true or false" unless [true, false].include?(perform_changes)
|
|
97
|
+
|
|
98
|
+
payload = {
|
|
99
|
+
"SourceServerID" => source_server_id,
|
|
100
|
+
"DestinationServerID" => destination_server_id,
|
|
101
|
+
"PerformChanges" => perform_changes
|
|
102
|
+
}
|
|
103
|
+
object(api_put("/templates/push", payload, idempotent: false))
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
end
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Haskimail
|
|
4
|
+
# Addresses that simulate bounces and spam complaints. The email goes nowhere
|
|
5
|
+
# and Haskimail creates the event immediately — as for a real bounce — so you
|
|
6
|
+
# can test bounce handling and webhooks. They work on live and sandbox
|
|
7
|
+
# servers, do not use the email quota, but do NOT work with {TEST_TOKEN}.
|
|
8
|
+
# https://haskimail.ru/developer/user-guide/sandbox-mode/generate-fake-bounces
|
|
9
|
+
#
|
|
10
|
+
# client.emails.deliver(from: "shop@example.com", to: Haskimail::TestAddresses::HARD_BOUNCE,
|
|
11
|
+
# subject: "Проверка", text_body: "Тест")
|
|
12
|
+
module TestAddresses
|
|
13
|
+
# Domain of the test addresses.
|
|
14
|
+
DOMAIN = "bounce-testing.haskimail.ru"
|
|
15
|
+
|
|
16
|
+
# Hard bounce, SMTP code 550 (the recipient does not exist).
|
|
17
|
+
HARD_BOUNCE = "hardbounce@#{DOMAIN}".freeze
|
|
18
|
+
# Soft bounce, SMTP code 450 (the mailbox is temporarily unavailable).
|
|
19
|
+
SOFT_BOUNCE = "softbounce@#{DOMAIN}".freeze
|
|
20
|
+
# Blocked by the recipient's mail server, SMTP code 554.
|
|
21
|
+
BLOCKED = "blocked@#{DOMAIN}".freeze
|
|
22
|
+
# Spam complaint.
|
|
23
|
+
SPAM_COMPLAINT = "spamnotification@#{DOMAIN}".freeze
|
|
24
|
+
end
|
|
25
|
+
end
|