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,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