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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +89 -0
- data/CONTRIBUTING.md +10 -1
- data/README.md +55 -112
- data/docs/api.md +395 -210
- data/docs/execution.md +89 -0
- data/docs/import-recovery.md +41 -0
- data/docs/live-contracts.md +34 -0
- data/docs/migration-0.2.md +129 -0
- data/docs/rails.md +248 -0
- data/docs/releases.md +4 -4
- data/docs/testing.md +83 -0
- data/docs/usage.md +144 -0
- data/docs/webhooks.md +41 -0
- data/examples/campaign.rb +5 -6
- data/examples/campaign_lifecycle.rb +28 -0
- data/examples/check_connection.rb +1 -1
- data/examples/events.rb +1 -1
- data/examples/export.rb +20 -6
- data/examples/import.rb +3 -2
- data/examples/import_recovery.rb +70 -0
- data/examples/quickstart.rb +1 -1
- data/examples/subscribe.rb +2 -2
- data/lib/unisender/acceptance.rb +104 -0
- data/lib/unisender/attachment.rb +47 -0
- data/lib/unisender/batch_report.rb +115 -0
- data/lib/unisender/batch_summary.rb +87 -0
- data/lib/unisender/batches.rb +41 -12
- data/lib/unisender/campaign_workflow.rb +72 -0
- data/lib/unisender/client.rb +65 -33
- data/lib/unisender/client_configuration.rb +50 -0
- data/lib/unisender/client_execution.rb +43 -0
- data/lib/unisender/client_state.rb +31 -0
- data/lib/unisender/client_validation.rb +97 -0
- data/lib/unisender/configuration.rb +151 -0
- data/lib/unisender/contact_records.rb +251 -0
- data/lib/unisender/diagnostic_urls.rb +68 -0
- data/lib/unisender/diagnostics.rb +83 -0
- data/lib/unisender/download_target.rb +61 -0
- data/lib/unisender/download_transfer.rb +43 -0
- data/lib/unisender/downloader.rb +99 -0
- data/lib/unisender/error_diagnostics.rb +83 -0
- data/lib/unisender/errors.rb +125 -8
- data/lib/unisender/event_batches.rb +40 -0
- data/lib/unisender/event_payload.rb +50 -22
- data/lib/unisender/events.rb +21 -5
- data/lib/unisender/execution_context.rb +114 -0
- data/lib/unisender/form.rb +20 -12
- data/lib/unisender/helpers.rb +70 -10
- data/lib/unisender/hooks.rb +79 -0
- data/lib/unisender/import_cells.rb +62 -0
- data/lib/unisender/input_types.rb +30 -0
- data/lib/unisender/inputs.rb +262 -0
- data/lib/unisender/iteration.rb +15 -0
- data/lib/unisender/iterators.rb +60 -0
- data/lib/unisender/mail_inputs.rb +37 -0
- data/lib/unisender/named_inputs.rb +52 -0
- data/lib/unisender/observation.rb +30 -0
- data/lib/unisender/operation_contracts.rb +106 -0
- data/lib/unisender/operations/contacts_1.rb +4 -3
- data/lib/unisender/operations/contacts_2.rb +23 -4
- data/lib/unisender/operations/messages_1.rb +7 -4
- data/lib/unisender/operations/messages_2.rb +8 -3
- data/lib/unisender/operations/messages_3.rb +1 -1
- data/lib/unisender/operations/notes_1.rb +1 -1
- data/lib/unisender/operations/statistics_1.rb +3 -3
- data/lib/unisender/operations/templates_1.rb +2 -2
- data/lib/unisender/outcomes.rb +320 -0
- data/lib/unisender/page_response.rb +48 -0
- data/lib/unisender/paginated_operations.rb +43 -2
- data/lib/unisender/pagination.rb +53 -20
- data/lib/unisender/persistent_transport.rb +104 -0
- data/lib/unisender/rate_limiter.rb +72 -0
- data/lib/unisender/record.rb +210 -0
- data/lib/unisender/record_rows.rb +69 -0
- data/lib/unisender/records.rb +614 -0
- data/lib/unisender/request_execution.rb +108 -0
- data/lib/unisender/response.rb +13 -10
- data/lib/unisender/result_registry.rb +72 -0
- data/lib/unisender/results.rb +105 -0
- data/lib/unisender/retry_policy.rb +10 -16
- data/lib/unisender/serialization.rb +54 -0
- data/lib/unisender/statistic_records.rb +129 -0
- data/lib/unisender/task_response.rb +85 -0
- data/lib/unisender/task_waiter.rb +25 -11
- data/lib/unisender/testing/assertions.rb +48 -0
- data/lib/unisender/testing/fixtures.rb +24 -0
- data/lib/unisender/testing.rb +91 -0
- data/lib/unisender/transport.rb +17 -9
- data/lib/unisender/validation.rb +11 -9
- data/lib/unisender/version.rb +1 -1
- data/lib/unisender/webhooks/tokens.rb +38 -0
- data/lib/unisender/webhooks.rb +230 -0
- data/lib/unisender.rb +42 -0
- data/sig/acceptance.rbs +30 -0
- data/sig/attachment.rbs +15 -0
- data/sig/batch_summary.rbs +38 -0
- data/sig/campaign_workflow.rbs +20 -0
- data/sig/configuration.rbs +62 -0
- data/sig/connection_controls.rbs +55 -0
- data/sig/diagnostics.rbs +37 -0
- data/sig/domain_records.rbs +96 -0
- data/sig/downloads.rbs +53 -0
- data/sig/execution.rbs +52 -0
- data/sig/execution_context.rbs +46 -0
- data/sig/hooks.rbs +18 -0
- data/sig/inputs.rbs +84 -0
- data/sig/iterators.rbs +11 -0
- data/sig/mail_inputs.rbs +8 -0
- data/sig/manifest.yaml +11 -0
- data/sig/operation_contracts.rbs +28 -0
- data/sig/operations.rbs +73 -69
- data/sig/outcomes.rbs +90 -0
- data/sig/record.rbs +77 -0
- data/sig/records.rbs +182 -0
- data/sig/serialization.rbs +13 -0
- data/sig/tasks.rbs +26 -0
- data/sig/testing.rbs +54 -0
- data/sig/unisender.rbs +128 -39
- data/sig/webhooks.rbs +55 -0
- metadata +84 -3
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Unisender
|
|
4
|
+
# Immutable options; injected transports remain owned by the caller.
|
|
5
|
+
# rubocop:disable-next Metrics/ClassLength -- Owns the complete public configuration and resource lifetime.
|
|
6
|
+
class Configuration
|
|
7
|
+
DEFAULT_KEYS = %i[sender_name sender_email lang].freeze
|
|
8
|
+
TIMEOUT_KEYS = %i[open_timeout read_timeout write_timeout persistent].freeze
|
|
9
|
+
RETRY_KEYS = %i[max_retries max_elapsed].freeze
|
|
10
|
+
attr_reader :api_key, :language, :transport, :retry_policy, :message_defaults, :instrumentation, :rate_limiter,
|
|
11
|
+
:event_rate_limiter, :acceptance_instrumentation
|
|
12
|
+
|
|
13
|
+
# rubocop:disable-next Metrics/ParameterLists, Metrics/MethodLength -- Keep immutable public options together.
|
|
14
|
+
def initialize(api_key:, language: :en, transport: nil, retry_policy: nil, open_timeout: nil,
|
|
15
|
+
read_timeout: nil, write_timeout: nil, max_retries: nil, max_elapsed: nil,
|
|
16
|
+
message_defaults: {}, instrumentation: nil, persistent: false, rate_limiter: nil,
|
|
17
|
+
event_rate_limiter: nil, acceptance_instrumentation: nil)
|
|
18
|
+
@api_key, @language = credentials(api_key, language)
|
|
19
|
+
@message_defaults = defaults(message_defaults)
|
|
20
|
+
@instrumentation = instrumentation
|
|
21
|
+
@transport = build_transport(transport, open_timeout, read_timeout, write_timeout, persistent)
|
|
22
|
+
@retry_policy = build_retry(retry_policy, max_retries, max_elapsed)
|
|
23
|
+
initialize_resources(rate_limiter, transport, event_rate_limiter, acceptance_instrumentation)
|
|
24
|
+
@options = { api_key: @api_key, language: @language, transport:, retry_policy:, open_timeout:,
|
|
25
|
+
read_timeout:, write_timeout:, max_retries:, max_elapsed:, message_defaults: @message_defaults,
|
|
26
|
+
instrumentation:, persistent:, rate_limiter:, event_rate_limiter:,
|
|
27
|
+
acceptance_instrumentation: }.freeze
|
|
28
|
+
freeze
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def options_for(changes)
|
|
32
|
+
options = @options.dup
|
|
33
|
+
clear_alternative(options, changes, :transport, TIMEOUT_KEYS)
|
|
34
|
+
clear_alternative(options, changes, :retry_policy, RETRY_KEYS)
|
|
35
|
+
options.merge!(changes)
|
|
36
|
+
merge_defaults(options, changes)
|
|
37
|
+
options
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def merge_defaults(options, changes)
|
|
41
|
+
return unless changes.key?(:message_defaults)
|
|
42
|
+
|
|
43
|
+
value = changes.fetch(:message_defaults)
|
|
44
|
+
case value
|
|
45
|
+
when Hash then options[:message_defaults] = @message_defaults.merge(normalize_defaults(value))
|
|
46
|
+
else raise ArgumentError, "Message defaults must be a hash"
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def inspect
|
|
51
|
+
"#<Unisender::Configuration>"
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def check_open!
|
|
55
|
+
@state.check_open!
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def close
|
|
59
|
+
@state.close do
|
|
60
|
+
transport = @transport
|
|
61
|
+
if @owns_transport
|
|
62
|
+
case transport
|
|
63
|
+
when PersistentTransport then transport.close
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
private
|
|
70
|
+
|
|
71
|
+
def clear_alternative(options, changes, object, settings)
|
|
72
|
+
if changes.key?(object)
|
|
73
|
+
settings.each { |key| options.delete(key) }
|
|
74
|
+
elsif settings.any? { |key| changes.key?(key) }
|
|
75
|
+
options.delete(object)
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def build_transport(transport, open_timeout, read_timeout, write_timeout, persistent)
|
|
80
|
+
validate_conflicts(transport, [open_timeout, read_timeout, write_timeout])
|
|
81
|
+
raise ArgumentError, "Persistent mode conflicts with an explicit transport" if transport && persistent
|
|
82
|
+
|
|
83
|
+
factory = persistent ? PersistentTransport : Transport
|
|
84
|
+
transport || factory.new(**timeout_options(open_timeout, read_timeout, write_timeout))
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def initialize_resources(limiter, transport, event_limiter, acceptance_callback)
|
|
88
|
+
@rate_limiter = limiter
|
|
89
|
+
@event_rate_limiter = event_limiter
|
|
90
|
+
@acceptance_instrumentation = acceptance_callback
|
|
91
|
+
@owns_transport = case transport
|
|
92
|
+
when nil then true
|
|
93
|
+
else false
|
|
94
|
+
end
|
|
95
|
+
@state = ClientState.new
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def timeout_options(open_timeout, read_timeout, write_timeout)
|
|
99
|
+
defaults = { open_timeout: 5, read_timeout: 60, write_timeout: 30 }
|
|
100
|
+
defaults.merge({ open_timeout:, read_timeout:, write_timeout: }.compact)
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def build_retry(policy, max_retries, max_elapsed)
|
|
104
|
+
validate_conflicts(policy, [max_retries, max_elapsed])
|
|
105
|
+
policy || RetryPolicy.new(max_retries: max_retries.nil? ? 0 : max_retries,
|
|
106
|
+
max_elapsed: max_elapsed.nil? ? 60 : max_elapsed)
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
def credentials(key, language)
|
|
110
|
+
unless key.is_a?(String) && !key.strip.empty? && !key.match?(/[\r\n]/)
|
|
111
|
+
raise ArgumentError, "api_key must be a nonempty string without line breaks"
|
|
112
|
+
end
|
|
113
|
+
raise ArgumentError, "language must be en or ru" unless %w[en ru].include?(language.to_s)
|
|
114
|
+
|
|
115
|
+
[key.dup.freeze, language.to_s.freeze]
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
def validate_conflicts(object, settings)
|
|
119
|
+
return unless object && settings.any? { |value| !value.nil? }
|
|
120
|
+
|
|
121
|
+
raise ArgumentError, "Explicit objects cannot be combined with their configuration options"
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
def defaults(values)
|
|
125
|
+
normalize_defaults(values).compact.freeze
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
def normalize_defaults(values)
|
|
129
|
+
validate_default_keys(values)
|
|
130
|
+
names = values.keys.map(&:to_sym)
|
|
131
|
+
unless names.uniq == names && names.all? { |key| DEFAULT_KEYS.include?(key) }
|
|
132
|
+
raise ArgumentError, "Unknown or duplicate message default"
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
values.to_h { |key, value| [key.to_sym, default_value(value)] }
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
def validate_default_keys(values)
|
|
139
|
+
return if values.is_a?(Hash) && values.keys.all? { |key| key.is_a?(String) || key.is_a?(Symbol) }
|
|
140
|
+
|
|
141
|
+
raise ArgumentError, "Message defaults require string or symbol keys"
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
def default_value(value)
|
|
145
|
+
return nil if value.nil?
|
|
146
|
+
raise ArgumentError, "Message defaults must be strings or nil" unless value.is_a?(String)
|
|
147
|
+
|
|
148
|
+
value.dup.freeze
|
|
149
|
+
end
|
|
150
|
+
end
|
|
151
|
+
end
|
|
@@ -0,0 +1,251 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Unisender
|
|
4
|
+
# Email details returned by getContact.
|
|
5
|
+
class EmailContact < Record
|
|
6
|
+
def email
|
|
7
|
+
text!("email")
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
def status
|
|
11
|
+
text("status")
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def availability
|
|
15
|
+
text("availability")
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def added_at
|
|
19
|
+
text("added_at")
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def last_send_datetime
|
|
23
|
+
text("last_send_datetime")
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def last_delivery_datetime
|
|
27
|
+
text("last_delivery_datetime")
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def last_read_datetime
|
|
31
|
+
text("last_read_datetime")
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def last_click_datetime
|
|
35
|
+
text("last_click_datetime")
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def rating
|
|
39
|
+
number("rating")
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
protected
|
|
43
|
+
|
|
44
|
+
def normalize
|
|
45
|
+
email
|
|
46
|
+
status
|
|
47
|
+
availability
|
|
48
|
+
added_at
|
|
49
|
+
last_send_datetime
|
|
50
|
+
last_delivery_datetime
|
|
51
|
+
last_read_datetime
|
|
52
|
+
last_click_datetime
|
|
53
|
+
rating
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# Optional phone details returned by getContact.
|
|
58
|
+
class PhoneContact < Record
|
|
59
|
+
def phone
|
|
60
|
+
text!("phone")
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def status
|
|
64
|
+
text("status")
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def availability
|
|
68
|
+
text("availability")
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def added_at
|
|
72
|
+
text("added_at")
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
protected
|
|
76
|
+
|
|
77
|
+
def normalize
|
|
78
|
+
phone
|
|
79
|
+
status
|
|
80
|
+
availability
|
|
81
|
+
added_at
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
# A contact's membership in one list.
|
|
86
|
+
class ContactList < IdentifierRecord
|
|
87
|
+
def status
|
|
88
|
+
text("status")
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def added_at
|
|
92
|
+
text("added_at")
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
protected
|
|
96
|
+
|
|
97
|
+
def normalize
|
|
98
|
+
super
|
|
99
|
+
status
|
|
100
|
+
added_at
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
# Optional account details remain accessible through their original keys.
|
|
105
|
+
class Contact < Record
|
|
106
|
+
def email
|
|
107
|
+
value = self["email"]
|
|
108
|
+
return value if value.nil? || value.is_a?(String)
|
|
109
|
+
return child(EmailContact, value.to_h, "email") if value.is_a?(Record)
|
|
110
|
+
|
|
111
|
+
invalid!("email")
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def email_address
|
|
115
|
+
value = email
|
|
116
|
+
value.is_a?(EmailContact) ? value.email : value
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
def phone
|
|
120
|
+
value = self["phone"]
|
|
121
|
+
return value if value.nil? || value.is_a?(String)
|
|
122
|
+
return child(PhoneContact, value.to_h, "phone") if value.is_a?(Record)
|
|
123
|
+
|
|
124
|
+
invalid!("phone")
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
def phone_number
|
|
128
|
+
value = phone
|
|
129
|
+
value.is_a?(PhoneContact) ? value.phone : value
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
def lists
|
|
133
|
+
value = self["lists"]
|
|
134
|
+
return nil if value.nil?
|
|
135
|
+
|
|
136
|
+
Outcomes.records(value, "lists").each_with_index.map do |item, index|
|
|
137
|
+
child(ContactList, item.to_h, "lists[#{index}]")
|
|
138
|
+
end.freeze
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
def fields
|
|
142
|
+
value = self["fields"]
|
|
143
|
+
return value if value.nil? || value.is_a?(Record)
|
|
144
|
+
|
|
145
|
+
invalid!("fields")
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
protected
|
|
149
|
+
|
|
150
|
+
def normalize
|
|
151
|
+
email
|
|
152
|
+
phone
|
|
153
|
+
lists
|
|
154
|
+
fields
|
|
155
|
+
end
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
# Contact details with transport metadata.
|
|
159
|
+
class ContactResponse < ObjectResponse
|
|
160
|
+
def email
|
|
161
|
+
result.email
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
def email_address
|
|
165
|
+
result.email_address
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
def phone
|
|
169
|
+
result.phone
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
def phone_number
|
|
173
|
+
result.phone_number
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
def lists
|
|
177
|
+
result.lists
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
def fields
|
|
181
|
+
result.fields
|
|
182
|
+
end
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
# Sender-domain fields retain upstream capitalization in key-based access.
|
|
186
|
+
class SenderDomain < Record
|
|
187
|
+
def domain
|
|
188
|
+
text!("Domain")
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
def status
|
|
192
|
+
text("Status")
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
def key
|
|
196
|
+
text("key")
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
protected
|
|
200
|
+
|
|
201
|
+
def normalize
|
|
202
|
+
domain
|
|
203
|
+
status
|
|
204
|
+
key
|
|
205
|
+
end
|
|
206
|
+
end
|
|
207
|
+
|
|
208
|
+
# A group is the unit yielded by sender-domain pagination.
|
|
209
|
+
class SenderDomainGroup < Record
|
|
210
|
+
def status
|
|
211
|
+
text("status")
|
|
212
|
+
end
|
|
213
|
+
|
|
214
|
+
def domains
|
|
215
|
+
Outcomes.records(self["domains"], "domains").each_with_index.map do |item, index|
|
|
216
|
+
child(SenderDomain, item.to_h, "domains[#{index}]")
|
|
217
|
+
end.freeze
|
|
218
|
+
end
|
|
219
|
+
|
|
220
|
+
protected
|
|
221
|
+
|
|
222
|
+
def normalize
|
|
223
|
+
status
|
|
224
|
+
invalid!("domains") unless self["domains"].is_a?(Array)
|
|
225
|
+
domains
|
|
226
|
+
end
|
|
227
|
+
end
|
|
228
|
+
|
|
229
|
+
# Dynamic field IDs remain string keys.
|
|
230
|
+
class ContactFields < Record
|
|
231
|
+
def field_values
|
|
232
|
+
value = self["fieldValues"]
|
|
233
|
+
return value if value.is_a?(Record)
|
|
234
|
+
|
|
235
|
+
invalid!("fieldValues")
|
|
236
|
+
end
|
|
237
|
+
|
|
238
|
+
protected
|
|
239
|
+
|
|
240
|
+
def normalize
|
|
241
|
+
field_values
|
|
242
|
+
end
|
|
243
|
+
end
|
|
244
|
+
|
|
245
|
+
# Dynamic contact values with transport metadata.
|
|
246
|
+
class ContactFieldsResponse < ObjectResponse
|
|
247
|
+
def field_values
|
|
248
|
+
result.field_values
|
|
249
|
+
end
|
|
250
|
+
end
|
|
251
|
+
end
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Unisender
|
|
4
|
+
module Diagnostics
|
|
5
|
+
# Official operation pages; only recognized names can produce a link.
|
|
6
|
+
URLS = {
|
|
7
|
+
"createList" => "https://www.unisender.com/ru/support/api/contacts/createlist/",
|
|
8
|
+
"deleteList" => "https://www.unisender.com/ru/support/api/contacts/deletelist/",
|
|
9
|
+
"exclude" => "https://www.unisender.com/ru/support/api/contacts/exclude/",
|
|
10
|
+
"async/exportContacts" => "https://www.unisender.com/ru/support/api/contacts/exportcontacts/",
|
|
11
|
+
"getContactCount" => "https://www.unisender.com/ru/support/api/contacts/getcontactcount/",
|
|
12
|
+
"getLists" => "https://www.unisender.com/ru/support/api/contacts/getlists/",
|
|
13
|
+
"getTotalContactsCount" => "https://www.unisender.com/ru/support/api/contacts/gettotalcontactscount/",
|
|
14
|
+
"importContacts" => "https://www.unisender.com/ru/support/api/contacts/importcontacts/",
|
|
15
|
+
"subscribe" => "https://www.unisender.com/ru/support/api/contacts/subscribe/",
|
|
16
|
+
"updateList" => "https://www.unisender.com/ru/support/api/contacts/updatelist/",
|
|
17
|
+
"isContactInLists" => "https://www.unisender.com/ru/support/api/contacts/iscontactinlists/",
|
|
18
|
+
"getContact" => "https://www.unisender.com/ru/support/api/contacts/getcontact/",
|
|
19
|
+
"unsubscribe" => "https://www.unisender.com/ru/support/api/contacts/unsubscribe/",
|
|
20
|
+
"cancelCampaign" => "https://www.unisender.com/ru/support/api/messages/cancel-campaign/",
|
|
21
|
+
"checkEmail" => "https://www.unisender.com/ru/support/api/messages/check-email/",
|
|
22
|
+
"checkSms" => "https://www.unisender.com/ru/support/api/messages/check-sms/",
|
|
23
|
+
"createCampaign" => "https://www.unisender.com/ru/support/api/messages/createcampaign/",
|
|
24
|
+
"createEmailMessage" => "https://www.unisender.com/ru/support/api/messages/createemailmessage/",
|
|
25
|
+
"createSmsMessage" => "https://www.unisender.com/ru/support/api/messages/createsmsmessage/",
|
|
26
|
+
"deleteMessage" => "https://www.unisender.com/ru/support/api/messages/deletemessage/",
|
|
27
|
+
"getActualMessageVersion" => "https://www.unisender.com/ru/support/api/messages/get-actual-message-version/",
|
|
28
|
+
"getWebVersion" => "https://www.unisender.com/ru/support/api/messages/getwebversion/",
|
|
29
|
+
"sendEmail" => "https://www.unisender.com/ru/support/api/messages/sendemail/",
|
|
30
|
+
"sendSms" => "https://www.unisender.com/ru/support/api/messages/sendsms/",
|
|
31
|
+
"sendTestEmail" => "https://www.unisender.com/ru/support/api/messages/sendtestemail/",
|
|
32
|
+
"updateEmailMessage" => "https://www.unisender.com/ru/support/api/messages/updateemailmessage/",
|
|
33
|
+
"updateOptInEmail" => "https://www.unisender.com/ru/support/api/messages/updateoptinemail/",
|
|
34
|
+
"getSenderDomainList" => "https://www.unisender.com/ru/support/api/messages/getsenderdomainlist/",
|
|
35
|
+
"getCampaignCommonStats" => "https://www.unisender.com/ru/support/api/statistics/get-campaign-common-stats/",
|
|
36
|
+
"async/getCampaignDeliveryStats" => "https://www.unisender.com/ru/support/api/statistics/getcampaigndeliverystats/",
|
|
37
|
+
"getCampaignStatus" => "https://www.unisender.com/ru/support/api/statistics/getcampaignstatus/",
|
|
38
|
+
"getMessages" => "https://www.unisender.com/ru/support/api/statistics/getmessages/",
|
|
39
|
+
"getVisitedLinks" => "https://www.unisender.com/ru/support/api/statistics/getvisitedlinks/",
|
|
40
|
+
"listMessages" => "https://www.unisender.com/ru/support/api/statistics/listmessages/",
|
|
41
|
+
"getCampaigns" => "https://www.unisender.com/ru/support/api/statistics/getcampaigns/",
|
|
42
|
+
"getMessage" => "https://www.unisender.com/ru/support/api/statistics/getmessage/",
|
|
43
|
+
"async/getTaskResult" => "https://www.unisender.com/ru/support/api/statistics/../contacts/exportcontacts/",
|
|
44
|
+
"createEmailTemplate" => "https://www.unisender.com/ru/support/api/templates/createemailtemplate/",
|
|
45
|
+
"deleteTemplate" => "https://www.unisender.com/ru/support/api/templates/deletetemplate/",
|
|
46
|
+
"getTemplate" => "https://www.unisender.com/ru/support/api/templates/gettemplate/",
|
|
47
|
+
"getTemplates" => "https://www.unisender.com/ru/support/api/templates/gettemplates/",
|
|
48
|
+
"listTemplates" => "https://www.unisender.com/ru/support/api/templates/listtemplates/",
|
|
49
|
+
"updateEmailTemplate" => "https://www.unisender.com/ru/support/api/templates/updateemailtemplate/",
|
|
50
|
+
"createField" => "https://www.unisender.com/ru/support/api/inputs/createfield/",
|
|
51
|
+
"deleteField" => "https://www.unisender.com/ru/support/api/inputs/deletefield/",
|
|
52
|
+
"deleteTag" => "https://www.unisender.com/ru/support/api/inputs/deletetag/",
|
|
53
|
+
"getFields" => "https://www.unisender.com/ru/support/api/inputs/getfields/",
|
|
54
|
+
"getTags" => "https://www.unisender.com/ru/support/api/inputs/gettags/",
|
|
55
|
+
"updateField" => "https://www.unisender.com/ru/support/api/inputs/updatefield/",
|
|
56
|
+
"getContactFieldValues" => "https://www.unisender.com/ru/support/api/inputs/getcontactfieldvalues/",
|
|
57
|
+
"createSubscriberNote" => "https://www.unisender.com/ru/support/api/notes/createsubscibernote/",
|
|
58
|
+
"updateSubscriberNote" => "https://www.unisender.com/ru/support/api/notes/updatesubcribernote/",
|
|
59
|
+
"deleteSubscriberNote" => "https://www.unisender.com/ru/support/api/notes/deletesubscribernote/",
|
|
60
|
+
"getSubscriberNote" => "https://www.unisender.com/ru/support/api/notes/getsubscribernote/",
|
|
61
|
+
"getSubscriberNotes" => "https://www.unisender.com/ru/support/api/notes/getsubscribernotes/",
|
|
62
|
+
"setHook" => "https://www.unisender.com/ru/support/api/common/sistema-uvedomlenij-o-sobytiyah-webhooks/",
|
|
63
|
+
"listHooks" => "https://www.unisender.com/ru/support/api/common/sistema-uvedomlenij-o-sobytiyah-webhooks/",
|
|
64
|
+
"removeHook" => "https://www.unisender.com/ru/support/api/common/sistema-uvedomlenij-o-sobytiyah-webhooks/",
|
|
65
|
+
"track_events" => "https://www.unisender.com/ru/support/api/event-tracking-api/"
|
|
66
|
+
}.freeze
|
|
67
|
+
end
|
|
68
|
+
end
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Unisender
|
|
4
|
+
# Safe error metadata excludes payload values and arbitrary exception text.
|
|
5
|
+
module Diagnostics
|
|
6
|
+
# @type var types: Hash[Module, String]
|
|
7
|
+
types = { String => "String", Integer => "Integer", Float => "Float", Symbol => "Symbol",
|
|
8
|
+
Array => "Array", Hash => "Hash", NilClass => "NilClass",
|
|
9
|
+
TrueClass => "Boolean", FalseClass => "Boolean" }
|
|
10
|
+
TYPES = types.freeze
|
|
11
|
+
FIELDS = (OperationContracts::DEFINITIONS.values.flat_map { |definition| definition.last.keys } +
|
|
12
|
+
%w[parameters max_bytes batch_size retention filename email_list_ids phone_list_ids
|
|
13
|
+
email_subscribe_times phone_subscribe_times tags]).uniq.freeze
|
|
14
|
+
|
|
15
|
+
def self.input_path(path)
|
|
16
|
+
root = path.split(/[.\[]/, 2).first.to_s
|
|
17
|
+
return "parameters" unless FIELDS.include?(root)
|
|
18
|
+
|
|
19
|
+
path.split(/\.(?![^\[]*\])/, -1).map { |segment| path_segment(segment) }.join(".")
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def self.path_segment(segment)
|
|
23
|
+
name = segment.split("[", 2).first.to_s
|
|
24
|
+
return "*" unless FIELDS.include?(name)
|
|
25
|
+
|
|
26
|
+
suffix = segment.delete_prefix(name)
|
|
27
|
+
return "#{name}[*]" unless suffix.match?(/\A(?:\[[^\]]*\])*\z/)
|
|
28
|
+
|
|
29
|
+
indices = suffix.split("]").map { |part| part.match?(/\A\[\d+\z/) ? "#{part}]" : "[*]" }
|
|
30
|
+
name + indices.join
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def self.type_name(value)
|
|
34
|
+
TYPES.each { |type, name| return name if value.is_a?(type) }
|
|
35
|
+
"Object"
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def self.for_error(error)
|
|
39
|
+
values = common_fields(error)
|
|
40
|
+
values.merge!(error.is_a?(ValidationError) ? validation_fields(error) : error_fields(error))
|
|
41
|
+
cause = underlying(error) if error.is_a?(Error)
|
|
42
|
+
values = for_error(cause).merge(values) if cause
|
|
43
|
+
values.compact.freeze
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def self.common_fields(error)
|
|
47
|
+
{ operation: error.operation, category: error.category.to_s,
|
|
48
|
+
documentation_url: URLS[error.operation.to_s] }.compact
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def self.validation_fields(error)
|
|
52
|
+
{ path: error.path, code: error.code.to_s, expected_type: error.expected_type, actual_type: error.actual_type }
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def self.error_fields(error)
|
|
56
|
+
values = context_fields(error)
|
|
57
|
+
values[:code] = error.safe_code if error.is_a?(APIError)
|
|
58
|
+
values.merge!(batch_fields(error)) if error.is_a?(BatchError)
|
|
59
|
+
if error.is_a?(InvalidResponseError)
|
|
60
|
+
values.merge!(path: error.path, expected_type: error.expected_type, actual_type: error.actual_type)
|
|
61
|
+
end
|
|
62
|
+
values.compact
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def self.context_fields(error)
|
|
66
|
+
{ http_status: error.http_status, request_id: error.request_id, task_uuid: error.task_uuid,
|
|
67
|
+
next_offset: error.next_offset, completed_pages: error.completed_pages, bytes_written: error.bytes_written }
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def self.batch_fields(error)
|
|
71
|
+
{ completed_batches: error.completed_responses.size,
|
|
72
|
+
failed_from: error.failed_range.begin, failed_to: error.failed_range.end }
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def self.underlying(error)
|
|
76
|
+
case error
|
|
77
|
+
when BatchError, CampaignWorkflowError
|
|
78
|
+
cause = error.error
|
|
79
|
+
cause if cause.is_a?(Error) || cause.is_a?(ValidationError)
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
end
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "ipaddr"
|
|
4
|
+
require "socket"
|
|
5
|
+
require "timeout"
|
|
6
|
+
|
|
7
|
+
module Unisender
|
|
8
|
+
# Resolves once and returns a public address for a verified HTTPS connection.
|
|
9
|
+
class DownloadTarget
|
|
10
|
+
GLOBAL_V6 = IPAddr.new("2000::/3").freeze
|
|
11
|
+
# Conservative exclusions based on the IANA special-purpose address registries.
|
|
12
|
+
# https://www.iana.org/assignments/iana-ipv6-special-registry/
|
|
13
|
+
# https://www.iana.org/assignments/iana-ipv4-special-registry/
|
|
14
|
+
BLOCKED = %w[
|
|
15
|
+
0.0.0.0/8 10.0.0.0/8 100.64.0.0/10 127.0.0.0/8 169.254.0.0/16 172.16.0.0/12
|
|
16
|
+
192.0.0.0/24 192.0.2.0/24 192.88.99.0/24 192.168.0.0/16 198.18.0.0/15 198.51.100.0/24 203.0.113.0/24
|
|
17
|
+
224.0.0.0/4 240.0.0.0/4 ::/128 ::1/128 fc00::/7 fe80::/10 ff00::/8 2001:db8::/32
|
|
18
|
+
2001::/23 2002::/16 3fff::/20
|
|
19
|
+
].map { |range| IPAddr.new(range) }.freeze
|
|
20
|
+
|
|
21
|
+
def initialize(timeout:, resolver: nil)
|
|
22
|
+
@timeout = timeout
|
|
23
|
+
@resolver = resolver || ->(host) { Addrinfo.getaddrinfo(host, 443, nil, :STREAM).map(&:ip_address).uniq }
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def resolve(url)
|
|
27
|
+
uri = validated_uri(url)
|
|
28
|
+
addresses = resolved_addresses(uri.hostname.to_s)
|
|
29
|
+
[uri, addresses.fetch(0)]
|
|
30
|
+
rescue URI::InvalidURIError, IPAddr::InvalidAddressError
|
|
31
|
+
raise DownloadError.new(reason: :invalid_url), cause: nil
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
private
|
|
35
|
+
|
|
36
|
+
def validated_uri(url)
|
|
37
|
+
uri = URI(url)
|
|
38
|
+
unless uri.scheme == "https" && uri.port == 443 && uri.hostname && !uri.userinfo
|
|
39
|
+
raise DownloadError.new(reason: :invalid_url)
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
uri
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def resolved_addresses(host)
|
|
46
|
+
addresses = Timeout.timeout(@timeout) { @resolver.call(host) }
|
|
47
|
+
if addresses.empty? || addresses.any? { |address| blocked?(address) }
|
|
48
|
+
raise DownloadError.new(reason: :blocked_destination)
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
addresses
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def blocked?(address)
|
|
55
|
+
ip = IPAddr.new(address).native
|
|
56
|
+
return true if ip.ipv6? && !GLOBAL_V6.include?(ip)
|
|
57
|
+
|
|
58
|
+
BLOCKED.any? { |range| range.include?(ip) }
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
end
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Unisender
|
|
4
|
+
# Counts only writes acknowledged by the caller-owned destination.
|
|
5
|
+
class DownloadTransfer
|
|
6
|
+
attr_reader :bytes_written
|
|
7
|
+
|
|
8
|
+
def initialize(io, max_bytes, execution: nil)
|
|
9
|
+
raise ArgumentError, "max_bytes must be a positive integer" unless max_bytes.is_a?(Integer) && max_bytes.positive?
|
|
10
|
+
|
|
11
|
+
@io = io
|
|
12
|
+
@max_bytes = max_bytes
|
|
13
|
+
@bytes_written = 0
|
|
14
|
+
@execution = execution
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def write(chunk)
|
|
18
|
+
@execution&.check!
|
|
19
|
+
raise DownloadError.new(reason: :too_large) if @bytes_written + chunk.bytesize > @max_bytes
|
|
20
|
+
|
|
21
|
+
copy_chunk(chunk)
|
|
22
|
+
rescue DownloadError, CancelledError, DeadlineExceededError
|
|
23
|
+
raise
|
|
24
|
+
rescue StandardError
|
|
25
|
+
raise DownloadError.new(reason: :write_failed), cause: nil
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
private
|
|
29
|
+
|
|
30
|
+
def copy_chunk(chunk)
|
|
31
|
+
offset = 0
|
|
32
|
+
while offset < chunk.bytesize
|
|
33
|
+
@execution&.check!
|
|
34
|
+
part = chunk.byteslice(offset..).to_s
|
|
35
|
+
written = @io.write(part)
|
|
36
|
+
raise DownloadError.new(reason: :write_failed) unless written.positive? && written <= part.bytesize
|
|
37
|
+
|
|
38
|
+
offset += written
|
|
39
|
+
@bytes_written += written
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|