unisender-ruby 0.1.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 +25 -0
- data/CONTRIBUTING.md +43 -0
- data/LICENSE.txt +21 -0
- data/README.md +140 -0
- data/docs/api.md +1063 -0
- data/examples/campaign.rb +13 -0
- data/examples/check_connection.rb +16 -0
- data/examples/events.rb +20 -0
- data/examples/export.rb +11 -0
- data/examples/import.rb +18 -0
- data/examples/offline.rb +26 -0
- data/examples/quickstart.rb +6 -0
- data/examples/subscribe.rb +12 -0
- data/lib/unisender/batches.rb +31 -0
- data/lib/unisender/client.rb +59 -0
- data/lib/unisender/errors.rb +129 -0
- data/lib/unisender/event_payload.rb +63 -0
- data/lib/unisender/events.rb +19 -0
- data/lib/unisender/form.rb +67 -0
- data/lib/unisender/helpers.rb +28 -0
- data/lib/unisender/operations/contacts_1.rb +51 -0
- data/lib/unisender/operations/contacts_2.rb +44 -0
- data/lib/unisender/operations/inputs_1.rb +48 -0
- data/lib/unisender/operations/messages_1.rb +58 -0
- data/lib/unisender/operations/messages_2.rb +54 -0
- data/lib/unisender/operations/messages_3.rb +12 -0
- data/lib/unisender/operations/notes_1.rb +38 -0
- data/lib/unisender/operations/statistics_1.rb +48 -0
- data/lib/unisender/operations/statistics_2.rb +18 -0
- data/lib/unisender/operations/templates_1.rb +45 -0
- data/lib/unisender/paginated_operations.rb +42 -0
- data/lib/unisender/pagination.rb +68 -0
- data/lib/unisender/protocol.rb +46 -0
- data/lib/unisender/response.rb +64 -0
- data/lib/unisender/retry_policy.rb +82 -0
- data/lib/unisender/task_waiter.rb +52 -0
- data/lib/unisender/transport.rb +53 -0
- data/lib/unisender/validation.rb +72 -0
- data/lib/unisender/version.rb +5 -0
- data/lib/unisender.rb +29 -0
- data/sig/operations.rbs +78 -0
- data/sig/unisender.rbs +218 -0
- metadata +87 -0
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Requires UNISENDER_API_KEY, UNISENDER_LIST_ID, and UNISENDER_SENDER_EMAIL.
|
|
4
|
+
# Sends a campaign to the list. Use a verified sender and a list of consenting test recipients.
|
|
5
|
+
require "unisender"
|
|
6
|
+
|
|
7
|
+
client = Unisender::Client.new(api_key: ENV.fetch("UNISENDER_API_KEY"))
|
|
8
|
+
message = client.create_email_message(
|
|
9
|
+
sender_name: "Example", sender_email: ENV.fetch("UNISENDER_SENDER_EMAIL"),
|
|
10
|
+
list_id: ENV.fetch("UNISENDER_LIST_ID"), subject: "Product news", body: "<p>Hello</p>"
|
|
11
|
+
)
|
|
12
|
+
campaign = client.create_campaign(message_id: message.result.fetch("message_id"))
|
|
13
|
+
puts campaign.result.fetch("campaign_id")
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Requires UNISENDER_API_KEY. Reads lists without printing account data.
|
|
4
|
+
require "unisender"
|
|
5
|
+
|
|
6
|
+
api_key = ENV.fetch("UNISENDER_API_KEY", nil)
|
|
7
|
+
abort "Set UNISENDER_API_KEY before running this example." if api_key.nil? || api_key.strip.empty?
|
|
8
|
+
|
|
9
|
+
begin
|
|
10
|
+
client = Unisender::Client.new(api_key:)
|
|
11
|
+
response = client.get_lists
|
|
12
|
+
puts "Connected to UniSender: HTTP #{response.status}, #{response.result.size} list(s)."
|
|
13
|
+
rescue ArgumentError, Unisender::Error => e
|
|
14
|
+
warn e.message
|
|
15
|
+
exit 1
|
|
16
|
+
end
|
data/examples/events.rb
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Requires UNISENDER_API_KEY and UNISENDER_CONTACT_EMAIL.
|
|
4
|
+
# Records an event and can trigger account automation.
|
|
5
|
+
require "unisender"
|
|
6
|
+
|
|
7
|
+
client = Unisender::Client.new(api_key: ENV.fetch("UNISENDER_API_KEY"))
|
|
8
|
+
begin
|
|
9
|
+
client.track_events_in_batches(events: [
|
|
10
|
+
{ name: "purchase", email: ENV.fetch("UNISENDER_CONTACT_EMAIL"), total: 1200,
|
|
11
|
+
currency: "RUB" }
|
|
12
|
+
]).each { |response| puts "Recorded batch: HTTP #{response.status}" }
|
|
13
|
+
rescue Unisender::BatchError => e
|
|
14
|
+
warn "Review events #{e.failed_range}; completed batches: #{e.completed_responses.size}"
|
|
15
|
+
failure = e.error
|
|
16
|
+
if failure.is_a?(Unisender::EventError) && failure.possibly_partially_accepted?
|
|
17
|
+
warn "Valid events may already be accepted. Inspect failure.details privately before retrying."
|
|
18
|
+
end
|
|
19
|
+
warn failure.message
|
|
20
|
+
end
|
data/examples/export.rb
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Requires UNISENDER_API_KEY and UNISENDER_LIST_ID. Starts an export job.
|
|
4
|
+
# The returned download URL grants access to contact data; keep it private.
|
|
5
|
+
require "unisender"
|
|
6
|
+
|
|
7
|
+
client = Unisender::Client.new(api_key: ENV.fetch("UNISENDER_API_KEY"))
|
|
8
|
+
job = client.export_contacts(list_id: ENV.fetch("UNISENDER_LIST_ID"))
|
|
9
|
+
completed = client.wait_for_task(task_uuid: job.result.fetch("task_uuid"))
|
|
10
|
+
download_url = completed.result.fetch("file_to_download")
|
|
11
|
+
puts "Export ready at host: #{URI(download_url).host}"
|
data/examples/import.rb
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Requires UNISENDER_API_KEY and UNISENDER_CONTACT_EMAIL. Changes contacts.
|
|
4
|
+
require "unisender"
|
|
5
|
+
|
|
6
|
+
client = Unisender::Client.new(api_key: ENV.fetch("UNISENDER_API_KEY"))
|
|
7
|
+
begin
|
|
8
|
+
client.import_contacts_in_batches(
|
|
9
|
+
field_names: %w[email Name], data: [[ENV.fetch("UNISENDER_CONTACT_EMAIL"), "Example"]]
|
|
10
|
+
).each { |response| puts "Imported batch: HTTP #{response.status}" }
|
|
11
|
+
rescue Unisender::BatchError => e
|
|
12
|
+
warn "Completed batches: #{e.completed_responses.size}; review rows #{e.failed_range}"
|
|
13
|
+
if e.error.is_a?(Unisender::PartialFailure)
|
|
14
|
+
warn "Some rows in the failed batch were accepted. Inspect error.error.response.result privately."
|
|
15
|
+
end
|
|
16
|
+
warn e.error.message
|
|
17
|
+
# Stop here. Reconcile accepted rows before building a new input; never replay all rows blindly.
|
|
18
|
+
end
|
data/examples/offline.rb
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# No credentials or network access required.
|
|
4
|
+
require "unisender"
|
|
5
|
+
|
|
6
|
+
# Records requests and returns one deterministic response without a socket.
|
|
7
|
+
class FakeTransport
|
|
8
|
+
attr_reader :requests
|
|
9
|
+
|
|
10
|
+
def initialize
|
|
11
|
+
@requests = []
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def post(uri, headers:, body:)
|
|
15
|
+
@requests << { uri:, headers:, body: }
|
|
16
|
+
Unisender::HTTPResponse.new(
|
|
17
|
+
status: 200, headers: { "x-request-id" => "offline-example" },
|
|
18
|
+
body: '{"result":[{"id":42,"title":"Product news"}]}'
|
|
19
|
+
)
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
transport = FakeTransport.new
|
|
24
|
+
client = Unisender::Client.new(api_key: "test-key", transport:)
|
|
25
|
+
client.get_lists.result.each { |list| puts list.fetch("title") }
|
|
26
|
+
puts "Requests: #{transport.requests.size}"
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Requires UNISENDER_API_KEY, UNISENDER_LIST_ID, and UNISENDER_CONTACT_EMAIL.
|
|
4
|
+
# Changes the account and sends a subscription confirmation email.
|
|
5
|
+
require "unisender"
|
|
6
|
+
|
|
7
|
+
client = Unisender::Client.new(api_key: ENV.fetch("UNISENDER_API_KEY"))
|
|
8
|
+
response = client.subscribe(
|
|
9
|
+
list_ids: [ENV.fetch("UNISENDER_LIST_ID")],
|
|
10
|
+
fields: { email: ENV.fetch("UNISENDER_CONTACT_EMAIL") }
|
|
11
|
+
)
|
|
12
|
+
puts response.result.fetch("person_id")
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Unisender
|
|
4
|
+
# Executes one batch at a time; a failure never replays previous batches.
|
|
5
|
+
module Batches
|
|
6
|
+
def self.each(items, size, maximum, &operation)
|
|
7
|
+
raise ArgumentError, "Invalid batch_size" unless size.is_a?(Integer) && size.positive? && size <= maximum
|
|
8
|
+
|
|
9
|
+
Enumerator.new do |output|
|
|
10
|
+
# @type var completed: Array[Response]
|
|
11
|
+
completed = []
|
|
12
|
+
items.each_slice(size).with_index do |batch, index|
|
|
13
|
+
response = execute(batch, index * size, completed, &operation)
|
|
14
|
+
completed << response
|
|
15
|
+
output << response
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def self.execute(batch, offset, completed)
|
|
21
|
+
response = yield batch
|
|
22
|
+
result = response.result
|
|
23
|
+
raise PartialFailure, response if result.is_a?(Hash) && result.fetch("invalid", 0).to_s.to_i.positive?
|
|
24
|
+
|
|
25
|
+
response
|
|
26
|
+
rescue Error, ArgumentError => e
|
|
27
|
+
raise BatchError.new(completed_responses: completed, failed_range: offset...(offset + batch.size), error: e),
|
|
28
|
+
cause: nil
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Unisender
|
|
4
|
+
# Synchronous client for the documented UniSender API.
|
|
5
|
+
class Client
|
|
6
|
+
RESERVED_PARAMETERS = %w[api_key format].freeze
|
|
7
|
+
|
|
8
|
+
def initialize(api_key:, transport: Transport.new, language: :en, retry_policy: RetryPolicy.new)
|
|
9
|
+
unless api_key.is_a?(String) && !api_key.strip.empty? && !api_key.match?(/[\r\n]/)
|
|
10
|
+
raise ArgumentError, "api_key must be a nonempty string without line breaks"
|
|
11
|
+
end
|
|
12
|
+
raise ArgumentError, "language must be en or ru" unless %w[en ru].include?(language.to_s)
|
|
13
|
+
|
|
14
|
+
@api_key = api_key.dup.freeze
|
|
15
|
+
@language = language.to_s.dup.freeze
|
|
16
|
+
@transport = transport
|
|
17
|
+
@retry_policy = retry_policy
|
|
18
|
+
freeze
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def request(method, params: {})
|
|
22
|
+
validate_request(method, params)
|
|
23
|
+
uri = URI("https://api.unisender.com/#{@language}/api/#{method}")
|
|
24
|
+
headers = { "Content-Type" => "application/x-www-form-urlencoded; charset=UTF-8" }
|
|
25
|
+
body = Form.encode(params.merge("api_key" => @api_key, "format" => "json"))
|
|
26
|
+
@retry_policy.call(method) do
|
|
27
|
+
response = @transport.post(uri, headers:, body:)
|
|
28
|
+
Protocol.parse(response, method:)
|
|
29
|
+
end
|
|
30
|
+
rescue Error => e
|
|
31
|
+
raise e.with_operation(method), cause: nil
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def inspect
|
|
35
|
+
"#<Unisender::Client>"
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
private
|
|
39
|
+
|
|
40
|
+
def validate_request(method, params)
|
|
41
|
+
raise ArgumentError, "Invalid API method name" unless method.match?(%r{\A(?:async/)?[A-Za-z][A-Za-z0-9]*\z})
|
|
42
|
+
return unless params.keys.any? { |key| RESERVED_PARAMETERS.include?(key.to_s) }
|
|
43
|
+
|
|
44
|
+
raise ArgumentError, "api_key and format are managed by the client"
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# The wire response stays JSON; each named operation declares the documented result type.
|
|
48
|
+
# @type method invoke: (String, params, ?required: Array[String], ?comma: Array[String]) -> Response[json]
|
|
49
|
+
def invoke(method, params, required: [], comma: [])
|
|
50
|
+
Validation.required!(params, required)
|
|
51
|
+
Validation.operation!(method, params)
|
|
52
|
+
comma.each do |name|
|
|
53
|
+
value = params[name.to_sym]
|
|
54
|
+
params[name.to_sym] = Validation.comma_list(value) if value.is_a?(Array)
|
|
55
|
+
end
|
|
56
|
+
request(method, params:)
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
end
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Unisender
|
|
4
|
+
# Base class for client and service failures.
|
|
5
|
+
class Error < StandardError
|
|
6
|
+
OPERATIONS = %w[
|
|
7
|
+
createList deleteList exclude async/exportContacts getContactCount getLists getTotalContactsCount
|
|
8
|
+
importContacts subscribe updateList isContactInLists getContact unsubscribe cancelCampaign
|
|
9
|
+
checkEmail checkSms createCampaign createEmailMessage createSmsMessage deleteMessage
|
|
10
|
+
getActualMessageVersion getWebVersion sendEmail sendSms sendTestEmail updateEmailMessage
|
|
11
|
+
updateOptInEmail getSenderDomainList getCampaignCommonStats async/getCampaignDeliveryStats
|
|
12
|
+
getCampaignStatus getMessages getVisitedLinks listMessages getCampaigns getMessage async/getTaskResult
|
|
13
|
+
createEmailTemplate deleteTemplate getTemplate getTemplates listTemplates updateEmailTemplate
|
|
14
|
+
createField deleteField deleteTag getFields getTags updateField getContactFieldValues
|
|
15
|
+
createSubscriberNote updateSubscriberNote deleteSubscriberNote getSubscriberNote getSubscriberNotes
|
|
16
|
+
track_events
|
|
17
|
+
].freeze
|
|
18
|
+
private_constant :OPERATIONS
|
|
19
|
+
|
|
20
|
+
attr_reader :operation
|
|
21
|
+
|
|
22
|
+
def with_operation(operation)
|
|
23
|
+
@operation = OPERATIONS.include?(operation) ? operation.dup.freeze : nil
|
|
24
|
+
self
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def to_s
|
|
28
|
+
"UniSender #{@operation || 'request'} failed: #{super}"
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def inspect
|
|
32
|
+
"#<#{self.class}>"
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# Main API failure, including failures returned with HTTP 200.
|
|
37
|
+
class APIError < Error
|
|
38
|
+
# https://www.unisender.com/ru/support/api/common/api-errors/
|
|
39
|
+
SAFE_CODES = %w[
|
|
40
|
+
unspecified invalid_api_key access_denied unknown_method invalid_arg not_enough_money retry_later
|
|
41
|
+
api_call_limit_exceeded_for_api_key api_call_limit_exceeded_for_ip
|
|
42
|
+
].freeze
|
|
43
|
+
private_constant :SAFE_CODES
|
|
44
|
+
|
|
45
|
+
attr_reader :response, :code, :details
|
|
46
|
+
|
|
47
|
+
def initialize(response)
|
|
48
|
+
@response = response
|
|
49
|
+
@code = response.data["code"]
|
|
50
|
+
@details = response.data["error"]
|
|
51
|
+
super(SAFE_CODES.include?(@code) ? @code.to_s : "API error")
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# Non-success HTTP response, including redirects.
|
|
56
|
+
class HTTPError < Error
|
|
57
|
+
attr_reader :response
|
|
58
|
+
|
|
59
|
+
def initialize(response)
|
|
60
|
+
@response = response
|
|
61
|
+
super("HTTP #{response.status}")
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
class InvalidResponseError < Error; end
|
|
66
|
+
|
|
67
|
+
# Network or TLS failure with no complete HTTP response.
|
|
68
|
+
class TransportError < Error
|
|
69
|
+
def initialize(message, retryable: false)
|
|
70
|
+
@retryable = retryable
|
|
71
|
+
super(message)
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def retryable?
|
|
75
|
+
@retryable
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
class TimeoutError < TransportError; end
|
|
80
|
+
|
|
81
|
+
# A failed event request can still have accepted some events (HTTP 400).
|
|
82
|
+
class EventError < HTTPError
|
|
83
|
+
def code
|
|
84
|
+
response.data["code"]
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def details
|
|
88
|
+
response.data.fetch("details", response.data["error"])
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def possibly_partially_accepted?
|
|
92
|
+
response.status == 400
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
# Carries the completed batches and the range whose outcome needs inspection.
|
|
97
|
+
class BatchError < Error
|
|
98
|
+
attr_reader :completed_responses, :failed_range, :error
|
|
99
|
+
|
|
100
|
+
def initialize(completed_responses:, failed_range:, error:)
|
|
101
|
+
@completed_responses = completed_responses.dup.freeze
|
|
102
|
+
@failed_range = failed_range.freeze
|
|
103
|
+
@error = error
|
|
104
|
+
super("UniSender batch failed; inspect the completed responses and failed range")
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
class PaginationError < Error; end
|
|
109
|
+
|
|
110
|
+
# The API accepted the import request but rejected some records.
|
|
111
|
+
class PartialFailure < Error
|
|
112
|
+
attr_reader :response
|
|
113
|
+
|
|
114
|
+
def initialize(response)
|
|
115
|
+
@response = response
|
|
116
|
+
super("UniSender rejected part of the batch")
|
|
117
|
+
end
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
# Includes the last response for an unexpected or failed asynchronous task state.
|
|
121
|
+
class TaskError < Error
|
|
122
|
+
attr_reader :response
|
|
123
|
+
|
|
124
|
+
def initialize(response)
|
|
125
|
+
@response = response
|
|
126
|
+
super("UniSender task returned an unexpected state")
|
|
127
|
+
end
|
|
128
|
+
end
|
|
129
|
+
end
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Unisender
|
|
4
|
+
# Event Tracking uses JSON with its own batch and payload limits.
|
|
5
|
+
module EventPayload
|
|
6
|
+
def self.encode(events)
|
|
7
|
+
raise ArgumentError, "Expected 1 to 25 events" unless (1..25).cover?(events.size)
|
|
8
|
+
|
|
9
|
+
normalized = events.map { |event| validate(normalize(event, 0)) }
|
|
10
|
+
body = JSON.generate({ "events" => normalized })
|
|
11
|
+
raise ArgumentError, "Event payload exceeds 130 KB" if body.bytesize > 130 * 1024
|
|
12
|
+
|
|
13
|
+
body
|
|
14
|
+
rescue EncodingError, JSON::GeneratorError
|
|
15
|
+
raise ArgumentError, "Events must contain valid JSON values and UTF-8 text", cause: nil
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def self.validate(event)
|
|
19
|
+
raise ArgumentError, "Each event must be an object" unless event.is_a?(Hash)
|
|
20
|
+
raise ArgumentError, "Each event may have at most 25 fields" if event.size > 25
|
|
21
|
+
|
|
22
|
+
validate_name(event["name"])
|
|
23
|
+
validate_email(event["email"])
|
|
24
|
+
event
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def self.validate_name(name)
|
|
28
|
+
return if name.is_a?(String) && (1..40).cover?(name.size)
|
|
29
|
+
|
|
30
|
+
raise ArgumentError, "Event name must contain 1 to 40 characters"
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def self.validate_email(email)
|
|
34
|
+
return if email.is_a?(String) && email.ascii_only? && email.match?(/\A[^\s@]+@[^\s@]+\z/)
|
|
35
|
+
|
|
36
|
+
raise ArgumentError, "Event email must be an ASCII email address"
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def self.normalize(value, depth)
|
|
40
|
+
raise ArgumentError, "Events are nested too deeply" if depth > 32
|
|
41
|
+
|
|
42
|
+
case value
|
|
43
|
+
when Hash then normalize_hash(value, depth)
|
|
44
|
+
when Array then value.map { |item| normalize(item, depth + 1) }
|
|
45
|
+
when String then value.encode(Encoding::UTF_8)
|
|
46
|
+
when Integer, Float, true, false, nil then value
|
|
47
|
+
else raise ArgumentError, "Unsupported JSON value"
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def self.normalize_hash(value, depth)
|
|
52
|
+
# @type var result: Hash[String, json]
|
|
53
|
+
result = {}
|
|
54
|
+
value.each do |key, item|
|
|
55
|
+
name = key.to_s.encode(Encoding::UTF_8)
|
|
56
|
+
raise ArgumentError, "Duplicate event field names" if result.key?(name)
|
|
57
|
+
|
|
58
|
+
result[name] = normalize(item, depth + 1)
|
|
59
|
+
end
|
|
60
|
+
result
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
end
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Unisender
|
|
4
|
+
# JSON Event Tracking endpoint.
|
|
5
|
+
class Client
|
|
6
|
+
def track_events(events:)
|
|
7
|
+
body = EventPayload.encode(events)
|
|
8
|
+
headers = { "Content-Type" => "application/json", "Authorization" => "Bearer #{@api_key}" }
|
|
9
|
+
http = @transport.post(URI("https://event.unisender.com/v1/events/"), headers:, body:)
|
|
10
|
+
response = Response.new(data: Protocol.decode(http), status: http.status, headers: http.headers)
|
|
11
|
+
raise EventError, response, cause: nil unless (200..299).cover?(http.status)
|
|
12
|
+
raise InvalidResponseError, "Missing event success status", cause: nil unless response.data["success"] == true
|
|
13
|
+
|
|
14
|
+
response # : Response[event_accepted]
|
|
15
|
+
rescue Error => e
|
|
16
|
+
raise e.with_operation("track_events"), cause: nil
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
end
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Unisender
|
|
4
|
+
# UniSender accepts indexed form fields, not JSON, including for attachments.
|
|
5
|
+
module Form
|
|
6
|
+
def self.encode(params)
|
|
7
|
+
URI.encode_www_form(pairs(params, nil, 0))
|
|
8
|
+
rescue EncodingError
|
|
9
|
+
raise ArgumentError, "Parameters must contain valid UTF-8 text", cause: nil
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def self.pairs(value, prefix, depth)
|
|
13
|
+
raise ArgumentError, "Parameters are nested too deeply" if depth > 32
|
|
14
|
+
|
|
15
|
+
case value
|
|
16
|
+
when Hash
|
|
17
|
+
hash_pairs(value, prefix, depth)
|
|
18
|
+
when Array
|
|
19
|
+
value.each_with_index.flat_map { |item, index| pairs(item, "#{prefix}[#{index}]", depth + 1) }
|
|
20
|
+
else
|
|
21
|
+
[[prefix.to_s, scalar(value, prefix.to_s)]]
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def self.hash_pairs(value, prefix, depth)
|
|
26
|
+
keys = value.keys.map(&:to_s)
|
|
27
|
+
raise ArgumentError, "Duplicate parameter names" unless keys.uniq.size == keys.size
|
|
28
|
+
|
|
29
|
+
value.flat_map do |key, item|
|
|
30
|
+
name = key.to_s.encode(Encoding::UTF_8)
|
|
31
|
+
raise ArgumentError, "Parameter names cannot contain brackets" if name.match?(/[\[\]]/)
|
|
32
|
+
next [] if item.nil?
|
|
33
|
+
|
|
34
|
+
pairs(item, prefix ? "#{prefix}[#{name}]" : name, depth + 1)
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def self.scalar(value, name)
|
|
39
|
+
case value
|
|
40
|
+
when String
|
|
41
|
+
name.start_with?("attachments[") ? value.b : text(value)
|
|
42
|
+
when true then "1"
|
|
43
|
+
when false then "0"
|
|
44
|
+
when nil then ""
|
|
45
|
+
else numeric(value)
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def self.text(value)
|
|
50
|
+
raise ArgumentError, "Parameters must contain valid UTF-8 text" unless value.valid_encoding?
|
|
51
|
+
|
|
52
|
+
value.encode(Encoding::UTF_8)
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def self.numeric(value)
|
|
56
|
+
case value
|
|
57
|
+
when Integer then value.to_s
|
|
58
|
+
when Float
|
|
59
|
+
raise ArgumentError, "Numeric parameters must be finite" unless value.finite?
|
|
60
|
+
|
|
61
|
+
value.to_s
|
|
62
|
+
else
|
|
63
|
+
raise ArgumentError, "Unsupported parameter type"
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
end
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Unisender
|
|
4
|
+
# Explicit helpers for multiple requests.
|
|
5
|
+
class Client
|
|
6
|
+
def each_page(method, page_size: 100, max_pages: 1000, **params)
|
|
7
|
+
Pagination.new(self, method, page_size, max_pages, params).each
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
# rubocop:disable-next Metrics/ParameterLists -- Includes the documented import options.
|
|
11
|
+
def import_contacts_in_batches(field_names:, data:, batch_size: 500, overwrite_tags: nil,
|
|
12
|
+
overwrite_lists: nil, telegram_bot_channel_id: nil)
|
|
13
|
+
Batches.each(data, batch_size, 500) do |batch|
|
|
14
|
+
import_contacts(field_names:, data: batch, overwrite_tags:, overwrite_lists:, telegram_bot_channel_id:)
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def track_events_in_batches(events:, batch_size: 25)
|
|
19
|
+
Batches.each(events, batch_size, 25) { |batch| track_events(events: batch) }
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def wait_for_task(task_uuid:, interval: 1, timeout: 60, clock: nil, sleeper: nil)
|
|
23
|
+
clock ||= -> { Process.clock_gettime(Process::CLOCK_MONOTONIC) }
|
|
24
|
+
sleeper ||= ->(delay) { sleep(delay) }
|
|
25
|
+
TaskWaiter.new(self, clock:, sleeper:).call(task_uuid, interval, timeout) # : Response[task_completed]
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Unisender
|
|
4
|
+
# Contacts operations from the official UniSender API.
|
|
5
|
+
class Client
|
|
6
|
+
# https://www.unisender.com/ru/support/api/contacts/createlist/
|
|
7
|
+
def create_list(title:, before_subscribe_url: nil, after_subscribe_url: nil, sub_user_rights: nil)
|
|
8
|
+
params = { title:, before_subscribe_url:, after_subscribe_url:, sub_user_rights: }
|
|
9
|
+
invoke("createList", params, required: %w[title])
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
# https://www.unisender.com/ru/support/api/contacts/deletelist/
|
|
13
|
+
def delete_list(list_id:)
|
|
14
|
+
params = { list_id: }
|
|
15
|
+
invoke("deleteList", params, required: %w[list_id])
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
# https://www.unisender.com/ru/support/api/contacts/exclude/
|
|
19
|
+
def exclude(contact_type:, contact:, list_ids: nil)
|
|
20
|
+
params = { contact_type:, contact:, list_ids: }
|
|
21
|
+
invoke("exclude", params, required: %w[contact_type contact], comma: %w[list_ids])
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# https://www.unisender.com/ru/support/api/contacts/exportcontacts/
|
|
25
|
+
# rubocop:disable-next Metrics/ParameterLists -- Mirrors the documented operation.
|
|
26
|
+
def export_contacts(notify_url: nil, list_id: nil, field_names: nil, email: nil, phone: nil, tag: nil,
|
|
27
|
+
email_status: nil, phone_status: nil, contact_status: nil)
|
|
28
|
+
params = { notify_url:, list_id:, field_names:, email:, phone:, tag:, email_status:, phone_status:,
|
|
29
|
+
contact_status: }
|
|
30
|
+
invoke("async/exportContacts", params)
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# https://www.unisender.com/ru/support/api/contacts/getcontactcount/
|
|
34
|
+
def get_contact_count(list_id:, params:)
|
|
35
|
+
params = { list_id:, params: }
|
|
36
|
+
invoke("getContactCount", params, required: %w[list_id params])
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# https://www.unisender.com/ru/support/api/contacts/getlists/
|
|
40
|
+
# rubocop:disable-next Naming/AccessorMethodName -- Mirrors the documented operation.
|
|
41
|
+
def get_lists
|
|
42
|
+
invoke("getLists", {})
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# https://www.unisender.com/ru/support/api/contacts/gettotalcontactscount/
|
|
46
|
+
def get_total_contacts_count(login:)
|
|
47
|
+
params = { login: }
|
|
48
|
+
invoke("getTotalContactsCount", params, required: %w[login])
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Unisender
|
|
4
|
+
# Contacts operations from the official UniSender API.
|
|
5
|
+
class Client
|
|
6
|
+
# https://www.unisender.com/ru/support/api/contacts/importcontacts/
|
|
7
|
+
def import_contacts(field_names:, data:, overwrite_tags: nil, overwrite_lists: nil,
|
|
8
|
+
telegram_bot_channel_id: nil)
|
|
9
|
+
params = { field_names:, data:, overwrite_tags:, overwrite_lists:, telegram_bot_channel_id: }
|
|
10
|
+
invoke("importContacts", params, required: %w[field_names data])
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
# https://www.unisender.com/ru/support/api/contacts/subscribe/
|
|
14
|
+
def subscribe(list_ids:, fields:, tags: nil, double_optin: nil, overwrite: nil)
|
|
15
|
+
params = { list_ids:, fields:, tags:, double_optin:, overwrite: }
|
|
16
|
+
invoke("subscribe", params, required: %w[list_ids fields], comma: %w[list_ids tags])
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
# https://www.unisender.com/ru/support/api/contacts/updatelist/
|
|
20
|
+
def update_list(list_id:, title:, before_subscribe_url: nil, after_subscribe_url: nil, sub_user_rights: nil)
|
|
21
|
+
params = { list_id:, title:, before_subscribe_url:, after_subscribe_url:, sub_user_rights: }
|
|
22
|
+
invoke("updateList", params, required: %w[list_id title])
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# https://www.unisender.com/ru/support/api/contacts/iscontactinlists/
|
|
26
|
+
# rubocop:disable-next Naming/PredicatePrefix -- Mirrors the documented operation.
|
|
27
|
+
def is_contact_in_lists(email:, list_ids:, condition:)
|
|
28
|
+
params = { email:, list_ids:, condition: }
|
|
29
|
+
invoke("isContactInLists", params, required: %w[email list_ids condition], comma: %w[list_ids])
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# https://www.unisender.com/ru/support/api/contacts/getcontact/
|
|
33
|
+
def get_contact(email:, include_lists: nil, include_fields: nil, include_details: nil)
|
|
34
|
+
params = { email:, include_lists:, include_fields:, include_details: }
|
|
35
|
+
invoke("getContact", params, required: %w[email])
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# https://www.unisender.com/ru/support/api/contacts/unsubscribe/
|
|
39
|
+
def unsubscribe(contact_type:, contact:, list_ids: nil)
|
|
40
|
+
params = { contact_type:, contact:, list_ids: }
|
|
41
|
+
invoke("unsubscribe", params, required: %w[contact_type contact], comma: %w[list_ids])
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|