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