unisender-ruby 0.1.1 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (121) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +89 -0
  3. data/CONTRIBUTING.md +10 -1
  4. data/README.md +55 -112
  5. data/docs/api.md +395 -210
  6. data/docs/execution.md +89 -0
  7. data/docs/import-recovery.md +41 -0
  8. data/docs/live-contracts.md +34 -0
  9. data/docs/migration-0.2.md +129 -0
  10. data/docs/rails.md +248 -0
  11. data/docs/releases.md +4 -4
  12. data/docs/testing.md +83 -0
  13. data/docs/usage.md +144 -0
  14. data/docs/webhooks.md +41 -0
  15. data/examples/campaign.rb +5 -6
  16. data/examples/campaign_lifecycle.rb +28 -0
  17. data/examples/check_connection.rb +1 -1
  18. data/examples/events.rb +1 -1
  19. data/examples/export.rb +20 -6
  20. data/examples/import.rb +3 -2
  21. data/examples/import_recovery.rb +70 -0
  22. data/examples/quickstart.rb +1 -1
  23. data/examples/subscribe.rb +2 -2
  24. data/lib/unisender/acceptance.rb +104 -0
  25. data/lib/unisender/attachment.rb +47 -0
  26. data/lib/unisender/batch_report.rb +115 -0
  27. data/lib/unisender/batch_summary.rb +87 -0
  28. data/lib/unisender/batches.rb +41 -12
  29. data/lib/unisender/campaign_workflow.rb +72 -0
  30. data/lib/unisender/client.rb +65 -33
  31. data/lib/unisender/client_configuration.rb +50 -0
  32. data/lib/unisender/client_execution.rb +43 -0
  33. data/lib/unisender/client_state.rb +31 -0
  34. data/lib/unisender/client_validation.rb +97 -0
  35. data/lib/unisender/configuration.rb +151 -0
  36. data/lib/unisender/contact_records.rb +251 -0
  37. data/lib/unisender/diagnostic_urls.rb +68 -0
  38. data/lib/unisender/diagnostics.rb +83 -0
  39. data/lib/unisender/download_target.rb +61 -0
  40. data/lib/unisender/download_transfer.rb +43 -0
  41. data/lib/unisender/downloader.rb +99 -0
  42. data/lib/unisender/error_diagnostics.rb +83 -0
  43. data/lib/unisender/errors.rb +125 -8
  44. data/lib/unisender/event_batches.rb +40 -0
  45. data/lib/unisender/event_payload.rb +50 -22
  46. data/lib/unisender/events.rb +21 -5
  47. data/lib/unisender/execution_context.rb +114 -0
  48. data/lib/unisender/form.rb +20 -12
  49. data/lib/unisender/helpers.rb +70 -10
  50. data/lib/unisender/hooks.rb +79 -0
  51. data/lib/unisender/import_cells.rb +62 -0
  52. data/lib/unisender/input_types.rb +30 -0
  53. data/lib/unisender/inputs.rb +262 -0
  54. data/lib/unisender/iteration.rb +15 -0
  55. data/lib/unisender/iterators.rb +60 -0
  56. data/lib/unisender/mail_inputs.rb +37 -0
  57. data/lib/unisender/named_inputs.rb +52 -0
  58. data/lib/unisender/observation.rb +30 -0
  59. data/lib/unisender/operation_contracts.rb +106 -0
  60. data/lib/unisender/operations/contacts_1.rb +4 -3
  61. data/lib/unisender/operations/contacts_2.rb +23 -4
  62. data/lib/unisender/operations/messages_1.rb +7 -4
  63. data/lib/unisender/operations/messages_2.rb +8 -3
  64. data/lib/unisender/operations/messages_3.rb +1 -1
  65. data/lib/unisender/operations/notes_1.rb +1 -1
  66. data/lib/unisender/operations/statistics_1.rb +3 -3
  67. data/lib/unisender/operations/templates_1.rb +2 -2
  68. data/lib/unisender/outcomes.rb +320 -0
  69. data/lib/unisender/page_response.rb +48 -0
  70. data/lib/unisender/paginated_operations.rb +43 -2
  71. data/lib/unisender/pagination.rb +53 -20
  72. data/lib/unisender/persistent_transport.rb +104 -0
  73. data/lib/unisender/rate_limiter.rb +72 -0
  74. data/lib/unisender/record.rb +210 -0
  75. data/lib/unisender/record_rows.rb +69 -0
  76. data/lib/unisender/records.rb +614 -0
  77. data/lib/unisender/request_execution.rb +108 -0
  78. data/lib/unisender/response.rb +13 -10
  79. data/lib/unisender/result_registry.rb +72 -0
  80. data/lib/unisender/results.rb +105 -0
  81. data/lib/unisender/retry_policy.rb +10 -16
  82. data/lib/unisender/serialization.rb +54 -0
  83. data/lib/unisender/statistic_records.rb +129 -0
  84. data/lib/unisender/task_response.rb +85 -0
  85. data/lib/unisender/task_waiter.rb +25 -11
  86. data/lib/unisender/testing/assertions.rb +48 -0
  87. data/lib/unisender/testing/fixtures.rb +24 -0
  88. data/lib/unisender/testing.rb +91 -0
  89. data/lib/unisender/transport.rb +17 -9
  90. data/lib/unisender/validation.rb +11 -9
  91. data/lib/unisender/version.rb +1 -1
  92. data/lib/unisender/webhooks/tokens.rb +38 -0
  93. data/lib/unisender/webhooks.rb +230 -0
  94. data/lib/unisender.rb +42 -0
  95. data/sig/acceptance.rbs +30 -0
  96. data/sig/attachment.rbs +15 -0
  97. data/sig/batch_summary.rbs +38 -0
  98. data/sig/campaign_workflow.rbs +20 -0
  99. data/sig/configuration.rbs +62 -0
  100. data/sig/connection_controls.rbs +55 -0
  101. data/sig/diagnostics.rbs +37 -0
  102. data/sig/domain_records.rbs +96 -0
  103. data/sig/downloads.rbs +53 -0
  104. data/sig/execution.rbs +52 -0
  105. data/sig/execution_context.rbs +46 -0
  106. data/sig/hooks.rbs +18 -0
  107. data/sig/inputs.rbs +84 -0
  108. data/sig/iterators.rbs +11 -0
  109. data/sig/mail_inputs.rbs +8 -0
  110. data/sig/manifest.yaml +11 -0
  111. data/sig/operation_contracts.rbs +28 -0
  112. data/sig/operations.rbs +73 -69
  113. data/sig/outcomes.rbs +90 -0
  114. data/sig/record.rbs +77 -0
  115. data/sig/records.rbs +182 -0
  116. data/sig/serialization.rbs +13 -0
  117. data/sig/tasks.rbs +26 -0
  118. data/sig/testing.rbs +54 -0
  119. data/sig/unisender.rbs +128 -39
  120. data/sig/webhooks.rbs +55 -0
  121. metadata +84 -3
@@ -0,0 +1,91 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../unisender"
4
+ require_relative "testing/assertions"
5
+ require_relative "testing/fixtures"
6
+
7
+ module Unisender
8
+ # Optional deterministic transport support for application tests.
9
+ module Testing
10
+ Request = Data.define(:uri, :headers, :body)
11
+ Reply = Data.define(:operation, :response, :expected_form, :expected_json)
12
+
13
+ # Explicit body access is private data; inspection and form access redact credentials.
14
+ class Request
15
+ def operation
16
+ uri.host == "event.unisender.com" ? "track_events" : uri.path.to_s.split("/api/", 2).last.to_s
17
+ end
18
+
19
+ def form
20
+ URI.decode_www_form(body).to_h.except("api_key")
21
+ end
22
+
23
+ def json
24
+ JSON.parse(body) # : json
25
+ end
26
+
27
+ def inspect
28
+ "#<Unisender::Testing::Request>"
29
+ end
30
+
31
+ alias to_s inspect
32
+ end
33
+
34
+ # Queued responses never open a socket.
35
+ class Transport
36
+ def initialize
37
+ @replies = []
38
+ @requests = []
39
+ end
40
+
41
+ # rubocop:disable-next Metrics/ParameterLists -- Expectations belong to their queued response.
42
+ def enqueue(data:, http_status: 200, headers: {}, operation: nil, expected_form: nil, expected_json: nil)
43
+ reply = HTTPResponse.new(status: http_status, headers:, body: JSON.generate(data))
44
+ enqueue_response(reply, operation:, expected_form:, expected_json:)
45
+ end
46
+
47
+ def enqueue_response(response, operation: nil, expected_form: nil, expected_json: nil)
48
+ Assertions.validate(expected_form, expected_json)
49
+ @replies << Reply.new(operation:, response:, expected_form:, expected_json:)
50
+ self
51
+ end
52
+
53
+ def requests
54
+ @requests.dup.freeze
55
+ end
56
+
57
+ def verify!
58
+ raise "#{@replies.size} queued response(s) were not consumed" unless @replies.empty?
59
+
60
+ self
61
+ end
62
+
63
+ def post(uri, headers:, body:)
64
+ request = Request.new(uri: uri.dup.freeze, headers: headers.dup.freeze, body: body.dup.freeze)
65
+ @requests << request
66
+ response = next_response(request)
67
+ raise response if response.is_a?(StandardError)
68
+
69
+ response
70
+ end
71
+
72
+ def inspect
73
+ "#<Unisender::Testing::Transport>"
74
+ end
75
+
76
+ private
77
+
78
+ def next_response(request)
79
+ reply = @replies.shift or raise "Unexpected request: no queued response"
80
+ if reply.operation && reply.operation != request.operation
81
+ expected = Error.safe_operation(reply.operation) || "request"
82
+ actual = Error.safe_operation(request.operation) || "request"
83
+ raise "Unexpected request: expected #{expected}, received #{actual}"
84
+ end
85
+
86
+ Assertions.check(request, reply.expected_form, reply.expected_json)
87
+ reply.response
88
+ end
89
+ end
90
+ end
91
+ end
@@ -16,11 +16,10 @@ module Unisender
16
16
  @read_timeout = read_timeout
17
17
  @write_timeout = write_timeout
18
18
  @http_factory = http_factory || ->(uri) { Net::HTTP.new(uri.hostname.to_s, uri.port) }
19
- freeze
20
19
  end
21
20
 
22
- def post(uri, headers:, body:)
23
- exchange(uri, headers:, body:)
21
+ def post(uri, headers:, body:, execution: nil)
22
+ exchange(uri, headers:, body:, execution:)
24
23
  rescue Timeout::Error
25
24
  raise TimeoutError, "UniSender request timed out", cause: nil
26
25
  rescue OpenSSL::SSL::SSLError
@@ -31,23 +30,32 @@ module Unisender
31
30
 
32
31
  private
33
32
 
34
- def exchange(uri, headers:, body:)
33
+ def exchange(uri, headers:, body:, execution:)
35
34
  request = Net::HTTP::Post.new(uri, headers)
36
35
  request.body = body
37
- response = configured_http(uri).request(request)
36
+ response = perform(uri, request, execution:)
38
37
  headers = response.to_hash.transform_values { |items| items.join(", ") }
39
38
  HTTPResponse.new(status: response.code.to_i, headers:, body: response.body.to_s)
40
39
  end
41
40
 
42
- def configured_http(uri)
41
+ def perform(uri, request, execution:)
42
+ configured_http(uri, execution:).request(request)
43
+ end
44
+
45
+ def configured_http(uri, execution: nil)
43
46
  http = @http_factory.call(uri)
44
47
  http.use_ssl = true
45
48
  http.verify_mode = OpenSSL::SSL::VERIFY_PEER
46
- http.open_timeout = @open_timeout
47
- http.read_timeout = @read_timeout
48
- http.write_timeout = @write_timeout
49
+ configure_timeouts(http, execution)
49
50
  http.max_retries = 0
50
51
  http
51
52
  end
53
+
54
+ def configure_timeouts(http, execution)
55
+ remaining = execution&.remaining
56
+ http.open_timeout = remaining ? [@open_timeout, remaining].min : @open_timeout
57
+ http.read_timeout = remaining ? [@read_timeout, remaining].min : @read_timeout
58
+ http.write_timeout = remaining ? [@write_timeout, remaining].min : @write_timeout
59
+ end
52
60
  end
53
61
  end
@@ -19,20 +19,19 @@ module Unisender
19
19
  def self.campaign!(params)
20
20
  return unless params[:contacts] && params[:contacts_url]
21
21
 
22
- raise ArgumentError,
23
- "contacts and contacts_url are mutually exclusive"
22
+ raise ValidationError.new(field: "contacts", code: :conflicting_input)
24
23
  end
25
24
 
26
25
  def self.template!(params)
27
26
  return unless params[:template_id].nil? && params[:system_template_id].nil?
28
27
 
29
- raise ArgumentError, "Provide template_id or system_template_id"
28
+ raise ValidationError.new(field: "template_id", code: :required_alternative)
30
29
  end
31
30
 
32
31
  def self.import!(params)
33
32
  fields = params[:field_names]
34
33
  unless fields.is_a?(Array) && fields.all?(String) && fields.include?("email") && fields.uniq == fields
35
- raise ArgumentError, "field_names must contain unique column names including email"
34
+ raise ValidationError.new(field: "field_names", code: :invalid_columns)
36
35
  end
37
36
 
38
37
  rows!(params[:data], fields.size)
@@ -42,14 +41,14 @@ module Unisender
42
41
  unless rows.is_a?(Array) && (1..500).cover?(rows.size) && rows.all? do |row|
43
42
  row.is_a?(Array) && row.size == width
44
43
  end
45
- raise ArgumentError, "data must contain 1 to 500 rows matching field_names"
44
+ raise ValidationError.new(field: "data", code: :invalid_rows)
46
45
  end
47
46
  end
48
47
 
49
48
  def self.required!(params, names)
50
49
  names.each do |name|
51
50
  value = params[name.to_sym]
52
- raise ArgumentError, "Missing required parameter: #{name}" if missing?(value)
51
+ raise ValidationError.new(field: name, code: :required) if missing?(value)
53
52
  end
54
53
  end
55
54
 
@@ -61,9 +60,12 @@ module Unisender
61
60
  end
62
61
  end
63
62
 
64
- def self.comma_list(value)
65
- unless value.all? { |item| item.is_a?(String) || item.is_a?(Integer) }
66
- raise ArgumentError, "Comma-separated lists require strings or integers"
63
+ def self.comma_list(value, field: "parameters")
64
+ value.each_with_index do |item, index|
65
+ next if item.is_a?(String) || item.is_a?(Integer)
66
+
67
+ raise ValidationError.new(field: "#{field}[#{index}]", code: :invalid_list,
68
+ expected_type: "String or Integer", actual_type: Diagnostics.type_name(item))
67
69
  end
68
70
 
69
71
  value.join(",")
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Unisender
4
- VERSION = "0.1.1"
4
+ VERSION = "0.2.0"
5
5
  end
@@ -0,0 +1,38 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Unisender
4
+ module Webhooks
5
+ # Scan strings and structural tokens once, preserving original byte offsets.
6
+ class Tokens
7
+ def initialize(text)
8
+ @text = text
9
+ end
10
+
11
+ def each
12
+ index = 0
13
+ while index < @text.bytesize
14
+ token, finish = read(index)
15
+ yield token, index, finish if token.start_with?('"') || "{}[],".include?(token)
16
+ index = finish
17
+ end
18
+ end
19
+
20
+ private
21
+
22
+ def read(index)
23
+ finish = @text.getbyte(index) == 34 ? string_end(index + 1) : index + 1
24
+ [@text.byteslice(index...finish).to_s, finish]
25
+ end
26
+
27
+ def string_end(index)
28
+ while index < @text.bytesize
29
+ byte = @text.getbyte(index)
30
+ return index + 1 if byte == 34
31
+
32
+ index += byte == 92 ? 2 : 1
33
+ end
34
+ raise WebhookError, :malformed_payload
35
+ end
36
+ end
37
+ end
38
+ end
@@ -0,0 +1,230 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../unisender"
4
+ require "digest"
5
+ require "zlib"
6
+ require "stringio"
7
+ require_relative "webhooks/tokens"
8
+
9
+ module Unisender
10
+ # Rejected inbound data never appears in exception messages.
11
+ class WebhookError < Error
12
+ attr_reader :reason
13
+
14
+ def initialize(reason)
15
+ @reason = reason
16
+ super("Invalid webhook: #{reason}")
17
+ end
18
+
19
+ def category
20
+ :invalid_webhook
21
+ end
22
+ end
23
+
24
+ # A notification event keeps its schema-specific fields in event_data.
25
+ class WebhookEvent < Record
26
+ def event_name
27
+ text!("event_name")
28
+ end
29
+
30
+ def event_time
31
+ text!("event_time")
32
+ end
33
+
34
+ def event_data
35
+ value = self["event_data"]
36
+ raise WebhookError, :invalid_event unless value.is_a?(Record)
37
+
38
+ value
39
+ end
40
+
41
+ protected
42
+
43
+ def normalize
44
+ event_name
45
+ event_time
46
+ event_data
47
+ end
48
+ end
49
+
50
+ # Events grouped by the account login from the notification.
51
+ class WebhookUser < Record
52
+ def login
53
+ text!("login")
54
+ end
55
+
56
+ def events
57
+ raise WebhookError, :conflicting_events if key?("events") && key?("Events")
58
+
59
+ values = self[key?("events") ? "events" : "Events"]
60
+ raise WebhookError, :unsupported_shape unless values.is_a?(Array)
61
+
62
+ Outcomes.records(values, "events").map { |event| WebhookEvent.new(event.to_h) }
63
+ end
64
+
65
+ protected
66
+
67
+ def normalize
68
+ login
69
+ raise WebhookError, :unsupported_shape unless key?("events") || key?("Events")
70
+
71
+ events
72
+ end
73
+ end
74
+
75
+ # Authenticated notification payload with typed user and event readers.
76
+ class WebhookNotification < Record
77
+ def users
78
+ Outcomes.records(self["events_by_user"], "events_by_user").map { |user| WebhookUser.new(user.to_h) }
79
+ end
80
+
81
+ def events
82
+ users.flat_map(&:events)
83
+ end
84
+
85
+ protected
86
+
87
+ def normalize
88
+ raise WebhookError, :unsupported_shape unless self["events_by_user"].is_a?(Array)
89
+
90
+ users
91
+ end
92
+ end
93
+
94
+ # The documented signature covers original decompressed JSON bytes.
95
+ module Webhooks
96
+ # The token walk rejects duplicate keys even on older JSON parsers.
97
+ class ObjectKeys
98
+ attr_reader :key
99
+
100
+ def initialize
101
+ @seen = {}
102
+ @key = nil
103
+ end
104
+
105
+ def register(token)
106
+ return if @key
107
+
108
+ key = JSON.parse(token) # : String
109
+ raise WebhookError, :duplicate_key if @seen.key?(key)
110
+
111
+ @seen[key] = true
112
+ @key = key
113
+ end
114
+
115
+ def next_key
116
+ @key = nil
117
+ end
118
+ end
119
+
120
+ # rubocop:disable-next Metrics/ParameterLists -- Independent receiver limits are explicit.
121
+ def self.verify(body:, api_key:, content_encoding: nil, max_bytes: 1_048_576,
122
+ max_decompressed_bytes: 4_194_304, max_events: 10_000)
123
+ validate_limits(max_bytes, max_decompressed_bytes, max_events)
124
+ raise WebhookError, :too_large if body.bytesize > max_bytes
125
+
126
+ bytes = decompress(body, content_encoding, max_decompressed_bytes)
127
+ data = decode(bytes)
128
+ authenticate(bytes, data, api_key)
129
+ notification = WebhookNotification.new(data)
130
+ raise WebhookError, :too_many_events if notification.events.size > max_events
131
+
132
+ notification
133
+ rescue JSON::ParserError, EncodingError, InvalidResponseError, TypeError, ArgumentError
134
+ raise WebhookError, :malformed_payload, cause: nil
135
+ end
136
+
137
+ def self.decode(bytes)
138
+ data = JSON.parse(bytes, max_nesting: 64) # : json
139
+ raise WebhookError, :unsupported_shape unless data.is_a?(Hash)
140
+
141
+ data
142
+ end
143
+
144
+ def self.validate_limits(*limits)
145
+ return if limits.all? { |value| value.is_a?(Integer) && value.positive? }
146
+
147
+ raise WebhookError, :invalid_limits
148
+ end
149
+
150
+ def self.decompress(body, encoding, limit)
151
+ case encoding
152
+ when nil, "", "identity" then bounded(body, limit)
153
+ when "gzip" then inflate(body, limit)
154
+ else raise WebhookError, :unsupported_encoding
155
+ end
156
+ rescue Zlib::Error, EOFError
157
+ raise WebhookError, :invalid_gzip, cause: nil
158
+ end
159
+
160
+ def self.inflate(body, limit)
161
+ gzip = Zlib::GzipReader.new(StringIO.new(body))
162
+ begin
163
+ result = bounded(gzip.read(limit + 1).to_s, limit)
164
+ raise WebhookError, :trailing_data unless gzip.unused.to_s.empty?
165
+
166
+ result
167
+ ensure
168
+ gzip.close
169
+ end
170
+ end
171
+
172
+ def self.bounded(body, limit)
173
+ raise WebhookError, :too_large if body.bytesize > limit
174
+
175
+ text = body.dup.force_encoding(Encoding::UTF_8)
176
+ raise WebhookError, :invalid_encoding unless text.valid_encoding?
177
+
178
+ text
179
+ end
180
+
181
+ def self.authenticate(bytes, data, api_key)
182
+ auth = auth_value(data)
183
+ start, finish = auth_span(bytes)
184
+ signed = bytes.byteslice(0...start).to_s + JSON.generate(api_key) + bytes.byteslice(finish..).to_s
185
+ # UniSender requires MD5; the accepted protocol exception is documented in docs/webhooks.md.
186
+ expected = Digest::MD5.hexdigest(signed)
187
+ raise WebhookError, :invalid_auth unless secure_equal?(auth, expected)
188
+ end
189
+
190
+ def self.auth_value(data)
191
+ auth = data["auth"]
192
+ raise WebhookError, :invalid_auth unless auth.is_a?(String) && auth.match?(/\A[0-9a-f]{32}\z/)
193
+
194
+ auth
195
+ end
196
+
197
+ def self.secure_equal?(actual, expected)
198
+ actual.bytes.zip(expected.bytes).reduce(0) { |memo, (a, b)| memo | (a ^ b.to_i) }.zero?
199
+ end
200
+
201
+ # rubocop:disable-next Metrics/MethodLength, Metrics/CyclomaticComplexity -- Preserve JSON spans.
202
+ def self.auth_span(bytes)
203
+ # @type var stack: Array[ObjectKeys?]
204
+ stack = []
205
+ # @type var span: [Integer, Integer]?
206
+ span = nil
207
+ Tokens.new(bytes).each do |token, start, finish|
208
+ case token
209
+ when "{" then stack << ObjectKeys.new
210
+ when "[" then stack << nil
211
+ when "}", "]" then stack.pop
212
+ when "," then stack.last&.next_key
213
+ else
214
+ span = register_string(stack, token, start, finish) || span
215
+ end
216
+ end
217
+ span || raise(WebhookError, :invalid_auth)
218
+ end
219
+
220
+ def self.register_string(stack, token, start, finish)
221
+ frame = stack.last
222
+ return unless frame
223
+
224
+ # @type var span: [Integer, Integer]?
225
+ span = [start, finish] if stack.size == 1 && frame.key == "auth"
226
+ frame.register(token)
227
+ span
228
+ end
229
+ end
230
+ end
data/lib/unisender.rb CHANGED
@@ -4,19 +4,60 @@ require_relative "unisender/version"
4
4
  require "uri"
5
5
  require_relative "unisender/response"
6
6
  require_relative "unisender/errors"
7
+ require_relative "unisender/record"
8
+ require_relative "unisender/serialization"
9
+ require_relative "unisender/records"
10
+ require_relative "unisender/hooks"
11
+ require_relative "unisender/outcomes"
12
+ require_relative "unisender/contact_records"
13
+ require_relative "unisender/statistic_records"
14
+ require_relative "unisender/task_response"
15
+ require_relative "unisender/result_registry"
16
+ require_relative "unisender/results"
7
17
  require_relative "unisender/protocol"
8
18
  require_relative "unisender/form"
9
19
  require_relative "unisender/transport"
20
+ require_relative "unisender/persistent_transport"
21
+ require_relative "unisender/rate_limiter"
22
+ require_relative "unisender/download_target"
23
+ require_relative "unisender/download_transfer"
24
+ require_relative "unisender/downloader"
10
25
  require_relative "unisender/validation"
26
+ require_relative "unisender/attachment"
27
+ require_relative "unisender/inputs"
28
+ require_relative "unisender/named_inputs"
29
+ require_relative "unisender/mail_inputs"
30
+ require_relative "unisender/input_types"
31
+ require_relative "unisender/operation_contracts"
32
+ require_relative "unisender/diagnostic_urls"
33
+ require_relative "unisender/diagnostics"
34
+ require_relative "unisender/import_cells"
35
+ require_relative "unisender/record_rows"
11
36
  require_relative "unisender/event_payload"
12
37
  require_relative "unisender/retry_policy"
38
+ require_relative "unisender/error_diagnostics"
39
+ require_relative "unisender/observation"
40
+ require_relative "unisender/acceptance"
41
+ require_relative "unisender/client_state"
42
+ require_relative "unisender/configuration"
13
43
  require_relative "unisender/client"
44
+ require_relative "unisender/execution_context"
45
+ require_relative "unisender/client_execution"
46
+ require_relative "unisender/request_execution"
47
+ require_relative "unisender/client_configuration"
48
+ require_relative "unisender/client_validation"
14
49
  require_relative "unisender/events"
15
50
  require_relative "unisender/paginated_operations"
16
51
  require_relative "unisender/pagination"
52
+ require_relative "unisender/iteration"
53
+ require_relative "unisender/page_response"
17
54
  require_relative "unisender/batches"
55
+ require_relative "unisender/event_batches"
56
+ require_relative "unisender/batch_report"
57
+ require_relative "unisender/batch_summary"
18
58
  require_relative "unisender/task_waiter"
19
59
  require_relative "unisender/helpers"
60
+ require_relative "unisender/iterators"
20
61
  require_relative "unisender/operations/contacts_1"
21
62
  require_relative "unisender/operations/contacts_2"
22
63
  require_relative "unisender/operations/inputs_1"
@@ -27,3 +68,4 @@ require_relative "unisender/operations/notes_1"
27
68
  require_relative "unisender/operations/statistics_1"
28
69
  require_relative "unisender/operations/statistics_2"
29
70
  require_relative "unisender/operations/templates_1"
71
+ require_relative "unisender/campaign_workflow"
@@ -0,0 +1,30 @@
1
+ module Unisender
2
+ class AcceptanceEvent < Data
3
+ attr_reader operation: String
4
+ attr_reader outcome: Symbol
5
+ attr_reader input_count: Integer
6
+ attr_reader accepted_count: Integer?
7
+ attr_reader rejected_count: Integer?
8
+ attr_reader unknown_count: Integer?
9
+ attr_reader row_warning_count: Integer?
10
+ attr_reader request_id: String?
11
+ def self.new: (operation: String, outcome: Symbol, input_count: Integer, accepted_count: Integer?, rejected_count: Integer?, unknown_count: Integer?, row_warning_count: Integer?, request_id: String?) -> AcceptanceEvent
12
+ end
13
+ module Acceptance
14
+ def self.input_count: (String, params) -> Integer?
15
+ def self.event: (String, Integer, Response?, StandardError?) -> AcceptanceEvent
16
+ def self.warning_count: (Response?) -> Integer?
17
+ def self.counts: (Integer, Response?, StandardError?) -> [Integer?, Integer?, Integer?]
18
+ def self.bounded_counts: (Integer, [Integer?, Integer?, Integer?]) -> [Integer?, Integer?, Integer?]
19
+ def self.send_counts: (Integer, SendResponse) -> [Integer, Integer, Integer]
20
+ def self.import_counts: (Integer, ImportResponse) -> [Integer?, Integer, Integer?]
21
+ def self.state: (Response?, StandardError?, Integer?) -> Symbol
22
+ def self.failure_state: (StandardError) -> Symbol
23
+ def self.result_state: (OutcomeResponse) -> Symbol
24
+ def self.emit: (^(AcceptanceEvent) -> void, AcceptanceEvent) -> void
25
+ end
26
+ class Client
27
+ private
28
+ def observe_acceptance: (String, Integer?, Response?, StandardError?) -> void
29
+ end
30
+ end
@@ -0,0 +1,15 @@
1
+ module Unisender
2
+ interface _Readable
3
+ def read: (?Integer) -> String?
4
+ end
5
+ class Attachment
6
+ attr_reader filename: String
7
+ attr_reader bytes: String
8
+ def initialize: (filename: String, bytes: String) -> void
9
+ def self.from_path: (String, ?filename: String, ?max_bytes: Integer?) -> Attachment
10
+ def self.from_io: (_Readable, filename: String, ?max_bytes: Integer?) -> Attachment
11
+ def inspect: () -> String
12
+ private
13
+ def self.read_size: (Integer?) -> Integer?
14
+ end
15
+ end
@@ -0,0 +1,38 @@
1
+ module Unisender
2
+ class BatchProgress < Data
3
+ attr_reader completed_batches: Integer
4
+ attr_reader completed_items: Integer
5
+ attr_reader next_index: Integer
6
+ attr_reader invalid: Integer
7
+ attr_reader warning_count: Integer
8
+ attr_reader response: Response?
9
+ def self.new: (completed_batches: Integer, completed_items: Integer, next_index: Integer, invalid: Integer, warning_count: Integer, response: Response?) -> BatchProgress
10
+ end
11
+ class BatchSummary
12
+ @failure: BatchReport
13
+ attr_reader completed_batches: Integer
14
+ attr_reader completed_items: Integer
15
+ attr_reader invalid: Integer
16
+ attr_reader warning_count: Integer
17
+ def initialize: (BatchProgress, ?error: StandardError?, ?failed_range: Range[Integer]?) -> void
18
+ def complete?: () -> bool
19
+ def partial?: () -> bool
20
+ def uncertain?: () -> bool
21
+ def failures: () -> Array[Failure]
22
+ def row_warnings: () -> Array[Failure]
23
+ end
24
+ class BatchAccumulator
25
+ @retention: Symbol?
26
+ @offsets: Array[Integer]
27
+ @completed_batches: Integer
28
+ @invalid: Integer
29
+ @warning_count: Integer
30
+ attr_reader responses: Array[Response]
31
+ attr_reader completed_items: Integer
32
+ def initialize: (Symbol?) -> void
33
+ def add: (Response, Integer) -> void
34
+ def progress: (?response: Response?) -> BatchProgress
35
+ def report: (?error: StandardError?, ?failed_range: Range[Integer]?) -> (BatchReport | BatchSummary)
36
+ def failure: (StandardError, Range[Integer]) -> BatchError
37
+ end
38
+ end
@@ -0,0 +1,20 @@
1
+ module Unisender
2
+ class CampaignReceipt < Data
3
+ attr_reader message: MessageCreatedResponse
4
+ attr_reader campaign: CampaignCreatedResponse
5
+ def self.new: (message: MessageCreatedResponse, campaign: CampaignCreatedResponse) -> CampaignReceipt
6
+ end
7
+ class CampaignWorkflowError < Error
8
+ attr_reader message_response: MessageCreatedResponse
9
+ attr_reader error: StandardError
10
+ def initialize: (MessageCreatedResponse, StandardError) -> void
11
+ end
12
+ class Client
13
+ def send_campaign: (message: Hash[Symbol, parameter], ?campaign: Hash[Symbol, parameter]) -> CampaignReceipt
14
+ private
15
+ def campaign_parameters: (Hash[Symbol, parameter], Hash[Symbol, parameter]) -> [params, params]
16
+ def workflow_keys!: (params, Symbol) -> void
17
+ def workflow_options!: (params, params) -> void
18
+ def finish_campaign: (MessageCreatedResponse, params) -> CampaignReceipt
19
+ end
20
+ end