unisender-ruby 0.1.0 → 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 (123) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +97 -0
  3. data/CODE_OF_CONDUCT.md +90 -0
  4. data/CONTRIBUTING.md +23 -2
  5. data/README.md +53 -90
  6. data/SECURITY.md +13 -0
  7. data/docs/api.md +395 -210
  8. data/docs/execution.md +89 -0
  9. data/docs/import-recovery.md +41 -0
  10. data/docs/live-contracts.md +34 -0
  11. data/docs/migration-0.2.md +129 -0
  12. data/docs/rails.md +248 -0
  13. data/docs/releases.md +22 -0
  14. data/docs/testing.md +83 -0
  15. data/docs/usage.md +144 -0
  16. data/docs/webhooks.md +41 -0
  17. data/examples/campaign.rb +5 -6
  18. data/examples/campaign_lifecycle.rb +28 -0
  19. data/examples/check_connection.rb +1 -1
  20. data/examples/events.rb +1 -1
  21. data/examples/export.rb +20 -6
  22. data/examples/import.rb +3 -2
  23. data/examples/import_recovery.rb +70 -0
  24. data/examples/quickstart.rb +2 -1
  25. data/examples/subscribe.rb +2 -2
  26. data/lib/unisender/acceptance.rb +104 -0
  27. data/lib/unisender/attachment.rb +47 -0
  28. data/lib/unisender/batch_report.rb +115 -0
  29. data/lib/unisender/batch_summary.rb +87 -0
  30. data/lib/unisender/batches.rb +41 -12
  31. data/lib/unisender/campaign_workflow.rb +72 -0
  32. data/lib/unisender/client.rb +65 -33
  33. data/lib/unisender/client_configuration.rb +50 -0
  34. data/lib/unisender/client_execution.rb +43 -0
  35. data/lib/unisender/client_state.rb +31 -0
  36. data/lib/unisender/client_validation.rb +97 -0
  37. data/lib/unisender/configuration.rb +151 -0
  38. data/lib/unisender/contact_records.rb +251 -0
  39. data/lib/unisender/diagnostic_urls.rb +68 -0
  40. data/lib/unisender/diagnostics.rb +83 -0
  41. data/lib/unisender/download_target.rb +61 -0
  42. data/lib/unisender/download_transfer.rb +43 -0
  43. data/lib/unisender/downloader.rb +99 -0
  44. data/lib/unisender/error_diagnostics.rb +83 -0
  45. data/lib/unisender/errors.rb +125 -8
  46. data/lib/unisender/event_batches.rb +40 -0
  47. data/lib/unisender/event_payload.rb +50 -22
  48. data/lib/unisender/events.rb +21 -5
  49. data/lib/unisender/execution_context.rb +114 -0
  50. data/lib/unisender/form.rb +20 -12
  51. data/lib/unisender/helpers.rb +70 -10
  52. data/lib/unisender/hooks.rb +79 -0
  53. data/lib/unisender/import_cells.rb +62 -0
  54. data/lib/unisender/input_types.rb +30 -0
  55. data/lib/unisender/inputs.rb +262 -0
  56. data/lib/unisender/iteration.rb +15 -0
  57. data/lib/unisender/iterators.rb +60 -0
  58. data/lib/unisender/mail_inputs.rb +37 -0
  59. data/lib/unisender/named_inputs.rb +52 -0
  60. data/lib/unisender/observation.rb +30 -0
  61. data/lib/unisender/operation_contracts.rb +106 -0
  62. data/lib/unisender/operations/contacts_1.rb +4 -3
  63. data/lib/unisender/operations/contacts_2.rb +23 -4
  64. data/lib/unisender/operations/messages_1.rb +7 -4
  65. data/lib/unisender/operations/messages_2.rb +8 -3
  66. data/lib/unisender/operations/messages_3.rb +1 -1
  67. data/lib/unisender/operations/notes_1.rb +1 -1
  68. data/lib/unisender/operations/statistics_1.rb +3 -3
  69. data/lib/unisender/operations/templates_1.rb +2 -2
  70. data/lib/unisender/outcomes.rb +320 -0
  71. data/lib/unisender/page_response.rb +48 -0
  72. data/lib/unisender/paginated_operations.rb +43 -2
  73. data/lib/unisender/pagination.rb +53 -20
  74. data/lib/unisender/persistent_transport.rb +104 -0
  75. data/lib/unisender/rate_limiter.rb +72 -0
  76. data/lib/unisender/record.rb +210 -0
  77. data/lib/unisender/record_rows.rb +69 -0
  78. data/lib/unisender/records.rb +614 -0
  79. data/lib/unisender/request_execution.rb +108 -0
  80. data/lib/unisender/response.rb +13 -10
  81. data/lib/unisender/result_registry.rb +72 -0
  82. data/lib/unisender/results.rb +105 -0
  83. data/lib/unisender/retry_policy.rb +10 -16
  84. data/lib/unisender/serialization.rb +54 -0
  85. data/lib/unisender/statistic_records.rb +129 -0
  86. data/lib/unisender/task_response.rb +85 -0
  87. data/lib/unisender/task_waiter.rb +25 -11
  88. data/lib/unisender/testing/assertions.rb +48 -0
  89. data/lib/unisender/testing/fixtures.rb +24 -0
  90. data/lib/unisender/testing.rb +91 -0
  91. data/lib/unisender/transport.rb +17 -9
  92. data/lib/unisender/validation.rb +11 -9
  93. data/lib/unisender/version.rb +1 -1
  94. data/lib/unisender/webhooks/tokens.rb +38 -0
  95. data/lib/unisender/webhooks.rb +230 -0
  96. data/lib/unisender.rb +42 -0
  97. data/sig/acceptance.rbs +30 -0
  98. data/sig/attachment.rbs +15 -0
  99. data/sig/batch_summary.rbs +38 -0
  100. data/sig/campaign_workflow.rbs +20 -0
  101. data/sig/configuration.rbs +62 -0
  102. data/sig/connection_controls.rbs +55 -0
  103. data/sig/diagnostics.rbs +37 -0
  104. data/sig/domain_records.rbs +96 -0
  105. data/sig/downloads.rbs +53 -0
  106. data/sig/execution.rbs +52 -0
  107. data/sig/execution_context.rbs +46 -0
  108. data/sig/hooks.rbs +18 -0
  109. data/sig/inputs.rbs +84 -0
  110. data/sig/iterators.rbs +11 -0
  111. data/sig/mail_inputs.rbs +8 -0
  112. data/sig/manifest.yaml +11 -0
  113. data/sig/operation_contracts.rbs +28 -0
  114. data/sig/operations.rbs +73 -69
  115. data/sig/outcomes.rbs +90 -0
  116. data/sig/record.rbs +77 -0
  117. data/sig/records.rbs +182 -0
  118. data/sig/serialization.rbs +13 -0
  119. data/sig/tasks.rbs +26 -0
  120. data/sig/testing.rbs +54 -0
  121. data/sig/unisender.rbs +128 -39
  122. data/sig/webhooks.rbs +55 -0
  123. metadata +88 -4
@@ -6,11 +6,11 @@ module Unisender
6
6
  def self.encode(params)
7
7
  URI.encode_www_form(pairs(params, nil, 0))
8
8
  rescue EncodingError
9
- raise ArgumentError, "Parameters must contain valid UTF-8 text", cause: nil
9
+ raise ValidationError.new(field: "parameters", code: :invalid_encoding), cause: nil
10
10
  end
11
11
 
12
12
  def self.pairs(value, prefix, depth)
13
- raise ArgumentError, "Parameters are nested too deeply" if depth > 32
13
+ raise ValidationError.new(field: prefix.to_s, code: :too_deep) if depth > 32
14
14
 
15
15
  case value
16
16
  when Hash
@@ -24,43 +24,51 @@ module Unisender
24
24
 
25
25
  def self.hash_pairs(value, prefix, depth)
26
26
  keys = value.keys.map(&:to_s)
27
- raise ArgumentError, "Duplicate parameter names" unless keys.uniq.size == keys.size
27
+ raise ValidationError.new(field: "parameters", code: :duplicate_key) unless keys.uniq.size == keys.size
28
28
 
29
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?(/[\[\]]/)
30
+ name = key_name(key)
32
31
  next [] if item.nil?
33
32
 
34
33
  pairs(item, prefix ? "#{prefix}[#{name}]" : name, depth + 1)
35
34
  end
36
35
  end
37
36
 
37
+ def self.key_name(key)
38
+ name = key.to_s.encode(Encoding::UTF_8)
39
+ raise ValidationError.new(field: "parameters", code: :invalid_key) if name.match?(/[\[\]]/)
40
+
41
+ name
42
+ end
43
+
38
44
  def self.scalar(value, name)
39
45
  case value
40
46
  when String
41
- name.start_with?("attachments[") ? value.b : text(value)
47
+ name.start_with?("attachments[") ? value.b : text(value, name)
42
48
  when true then "1"
43
49
  when false then "0"
44
50
  when nil then ""
45
- else numeric(value)
51
+ else numeric(value, name)
46
52
  end
47
53
  end
48
54
 
49
- def self.text(value)
50
- raise ArgumentError, "Parameters must contain valid UTF-8 text" unless value.valid_encoding?
55
+ def self.text(value, field = "parameters")
56
+ raise ValidationError.new(field:, code: :invalid_encoding) unless value.valid_encoding?
51
57
 
52
58
  value.encode(Encoding::UTF_8)
59
+ rescue EncodingError
60
+ raise ValidationError.new(field:, code: :invalid_encoding), cause: nil
53
61
  end
54
62
 
55
- def self.numeric(value)
63
+ def self.numeric(value, field = "parameters")
56
64
  case value
57
65
  when Integer then value.to_s
58
66
  when Float
59
- raise ArgumentError, "Numeric parameters must be finite" unless value.finite?
67
+ raise ValidationError.new(field:, code: :invalid_number) unless value.finite?
60
68
 
61
69
  value.to_s
62
70
  else
63
- raise ArgumentError, "Unsupported parameter type"
71
+ raise ValidationError.new(field:, code: :invalid_type, actual_type: Diagnostics.type_name(value))
64
72
  end
65
73
  end
66
74
  end
@@ -3,26 +3,86 @@
3
3
  module Unisender
4
4
  # Explicit helpers for multiple requests.
5
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
6
+ def each_page(method, page_size: 100, max_pages: 1000, **params, &)
7
+ Iteration.call(Pagination.new(self, method, page_size, max_pages, params).each, &)
8
8
  end
9
9
 
10
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:)
11
+ def import_contacts_in_batches(field_names: nil, data: nil, records: nil, batch_size: 500, overwrite_tags: nil,
12
+ overwrite_lists: nil, telegram_bot_channel_id: nil, retention: :full,
13
+ on_progress: nil)
14
+ accumulator = BatchAccumulator.new(retention)
15
+ import_batches(field_names:, data:, records:, batch_size:, overwrite_tags:, overwrite_lists:,
16
+ telegram_bot_channel_id:, accumulator:, on_progress:).each { |_response| nil }
17
+ accumulator.report
18
+ rescue ValidationError => e
19
+ raise e.with_operation("importContacts"), cause: nil
20
+ end
21
+
22
+ # rubocop:disable-next Metrics/ParameterLists -- Includes the documented import options.
23
+ def each_import_batch(field_names: nil, data: nil, records: nil, batch_size: 500, overwrite_tags: nil,
24
+ overwrite_lists: nil, telegram_bot_channel_id: nil, retention: :full, on_progress: nil, &)
25
+ batches = import_batches(field_names:, data:, records:, batch_size:, overwrite_tags:, overwrite_lists:,
26
+ telegram_bot_channel_id:, retention:, on_progress:)
27
+ Iteration.call(batches, &)
28
+ rescue ValidationError => e
29
+ raise e.with_operation("importContacts"), cause: nil
30
+ end
31
+
32
+ # rubocop:disable-next Metrics/ParameterLists -- Shares the batch options without changing public signatures.
33
+ def import_batches(field_names:, data:, records:, batch_size:, overwrite_tags:, overwrite_lists:,
34
+ telegram_bot_channel_id:, accumulator: nil, retention: :full, on_progress: nil)
35
+ source = import_source(field_names, data, records)
36
+ Batches.each(source, batch_size, 500, accumulator:, retention:, on_progress:, execution: @execution) do |batch|
37
+ names = case source
38
+ when RecordRows then source.field_names
39
+ else field_names
40
+ end
41
+ import_contacts(field_names: names, data: batch, overwrite_tags:, overwrite_lists:, telegram_bot_channel_id:)
15
42
  end
16
43
  end
44
+ private :import_batches
45
+
46
+ def track_events_in_batches(events:, batch_size: 25, retention: :full, on_progress: nil)
47
+ accumulator = BatchAccumulator.new(retention)
48
+ event_batches(events, batch_size, accumulator:, on_progress:).each { |_response| nil }
49
+ accumulator.report
50
+ rescue ValidationError => e
51
+ raise e.with_operation("track_events"), cause: nil
52
+ end
53
+
54
+ def each_event_batch(events:, batch_size: 25, retention: :full, on_progress: nil, &)
55
+ Iteration.call(event_batches(events, batch_size, retention:, on_progress:), &)
56
+ rescue ValidationError => e
57
+ raise e.with_operation("track_events"), cause: nil
58
+ end
17
59
 
18
- def track_events_in_batches(events:, batch_size: 25)
19
- Batches.each(events, batch_size, 25) { |batch| track_events(events: batch) }
60
+ def event_batches(events, batch_size, accumulator: nil, retention: :full, on_progress: nil)
61
+ chunks = EventBatches.each(events, batch_size)
62
+ Batches.each_chunks(chunks, accumulator:, retention:, on_progress:, execution: @execution) do |batch|
63
+ track_events(events: batch)
64
+ end
20
65
  end
66
+ private :event_batches
21
67
 
22
- def wait_for_task(task_uuid:, interval: 1, timeout: 60, clock: nil, sleeper: nil)
68
+ def wait_for_task(task_uuid:, interval: 1, timeout: 60, clock: nil, sleeper: nil, &progress)
23
69
  clock ||= -> { Process.clock_gettime(Process::CLOCK_MONOTONIC) }
24
70
  sleeper ||= ->(delay) { sleep(delay) }
25
- TaskWaiter.new(self, clock:, sleeper:).call(task_uuid, interval, timeout) # : Response[task_completed]
71
+ TaskWaiter.new(self, clock:, sleeper:, progress:).call(task_uuid, interval, timeout)
72
+ end
73
+
74
+ private
75
+
76
+ def import_source(field_names, data, records)
77
+ if records
78
+ raise ValidationError.new(field: "records", code: :conflicting_input) if field_names || data
79
+
80
+ return RecordRows.new(records)
81
+ end
82
+ raise ValidationError.new(field: "data", code: :required) unless data
83
+
84
+ Validation.required!({ field_names: }, %w[field_names])
85
+ data
26
86
  end
27
87
  end
28
88
  end
@@ -0,0 +1,79 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Unisender
4
+ # The documented hook record, with unknown fields retained through Record.
5
+ class Hook < IdentifierRecord
6
+ def url
7
+ text!("url")
8
+ end
9
+
10
+ def event_format
11
+ text!("event_format")
12
+ end
13
+
14
+ def max_parallel
15
+ integer!("max_parallel")
16
+ end
17
+
18
+ def single_event
19
+ integer!("single_event")
20
+ end
21
+
22
+ def status
23
+ text!("status")
24
+ end
25
+
26
+ def events
27
+ value = self["events"]
28
+ raise InvalidResponseError, "Expected hook event filters" unless value.is_a?(Record)
29
+
30
+ value
31
+ end
32
+
33
+ protected
34
+
35
+ def normalize
36
+ super
37
+ url
38
+ event_format
39
+ max_parallel
40
+ single_event
41
+ status
42
+ events
43
+ end
44
+ end
45
+
46
+ # Hook mutation result shapes are not specified by the upstream reference.
47
+ class Client
48
+ # https://www.unisender.com/ru/support/api/common/sistema-uvedomlenij-o-sobytiyah-webhooks/
49
+ # rubocop:disable-next Metrics/ParameterLists -- Mirrors the documented operation.
50
+ def set_hook(hook_url:, events:, event_format: :json_post_gzip, max_parallel: nil, single_event: false,
51
+ status: :active)
52
+ events = hook_events(events, max_parallel)
53
+ params = { hook_url:, events:, event_format:, max_parallel:, single_event:, status: }
54
+ request("setHook", params: prepare("setHook", params, required: %w[hook_url events]))
55
+ rescue ValidationError => e
56
+ raise e.with_operation("setHook"), cause: nil
57
+ end
58
+
59
+ def hook_events(events, max_parallel)
60
+ if !max_parallel.nil? && !(max_parallel.is_a?(Integer) && max_parallel.positive?)
61
+ raise ValidationError.new(field: "max_parallel", code: :invalid_range, operation: "setHook")
62
+ end
63
+ raise ValidationError.new(field: "events", code: :invalid_type, operation: "setHook") unless events.is_a?(Hash)
64
+
65
+ events.to_h do |name, filters|
66
+ [name, filters.is_a?(Array) ? Validation.comma_list(filters) : filters]
67
+ end
68
+ end
69
+ private :hook_events
70
+
71
+ def list_hooks
72
+ invoke("listHooks", {})
73
+ end
74
+
75
+ def remove_hook(hook_url:)
76
+ request("removeHook", params: prepare("removeHook", { hook_url: }, required: %w[hook_url]))
77
+ end
78
+ end
79
+ end
@@ -0,0 +1,62 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Unisender
4
+ # Converts only documented system columns on the records input path.
5
+ module ImportCells
6
+ LISTS = %w[tags email_list_ids email_unsubscribed_list_ids email_excluded_list_ids
7
+ phone_list_ids phone_unsubscribed_list_ids phone_excluded_list_ids].freeze
8
+ TIMES = %w[email_subscribe_times phone_subscribe_times].freeze
9
+
10
+ def self.convert(name, value, index)
11
+ case value
12
+ when Array then array(name, value, index)
13
+ when Time then TIMES.include?(name) ? times(value, index) : invalid!(index)
14
+ when Hash then invalid!(index)
15
+ else value
16
+ end
17
+ rescue ValidationError => e
18
+ raise ValidationError.new(field: "records[#{index}].#{name}", code: e.code, operation: "importContacts", index:),
19
+ cause: nil
20
+ end
21
+
22
+ def self.array(name, value, index)
23
+ return list(value, index) if LISTS.include?(name)
24
+ return times(value, index) if TIMES.include?(name)
25
+
26
+ invalid!(index)
27
+ end
28
+
29
+ def self.list(values, index)
30
+ values.map do |value|
31
+ invalid!(index) unless (value.is_a?(String) || value.is_a?(Integer)) && !value.to_s.include?(",")
32
+ value.to_s
33
+ end.join(",")
34
+ end
35
+
36
+ def self.times(value, index)
37
+ values = value.is_a?(Array) ? value : [value]
38
+ values.map do |item|
39
+ case item
40
+ when Time then item.getutc.strftime("%Y-%m-%d %H:%M:%S")
41
+ when String then item
42
+ else invalid!(index)
43
+ end
44
+ end.join(",")
45
+ end
46
+
47
+ def self.validate_times(record, index)
48
+ TIMES.each do |name|
49
+ times = record[name]
50
+ lists = record[name.sub("subscribe_times", "list_ids")]
51
+ next unless times.is_a?(Array) && lists.is_a?(Array) && times.size != lists.size
52
+
53
+ raise ValidationError.new(field: "records[#{index}].#{name}", code: :invalid_cell,
54
+ operation: "importContacts", index:)
55
+ end
56
+ end
57
+
58
+ def self.invalid!(index)
59
+ raise ValidationError.new(field: "records[#{index}]", code: :invalid_cell, operation: "importContacts", index:)
60
+ end
61
+ end
62
+ end
@@ -0,0 +1,30 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Unisender
4
+ # Named input types are structural; account rules remain server decisions.
5
+ module InputTypes
6
+ # @type var kinds: Hash[String, Module]
7
+ kinds = { "string" => String, "integer" => Integer, "float" => Float, "symbol" => Symbol,
8
+ "hash" => Hash, "time" => Time, "attachment" => Attachment, "enumerable" => Enumerable }
9
+ KINDS = kinds.freeze
10
+
11
+ def self.validate!(value, types, field)
12
+ arrays = types.grep(/\Aarray:/)
13
+ if value.is_a?(Array) && !arrays.empty?
14
+ elements = arrays.map { |type| type.delete_prefix("array:") }
15
+ value.each_with_index { |item, index| validate!(item, elements, "#{field}[#{index}]") }
16
+ elsif types.none? { |type| accepts?(value, type) }
17
+ raise ValidationError.new(field:, code: :invalid_type, expected_type: types.join(" | "),
18
+ actual_type: Diagnostics.type_name(value))
19
+ end
20
+ end
21
+
22
+ def self.accepts?(value, type)
23
+ return true if type == "parameter"
24
+ return [true, false].include?(value) if type == "boolean"
25
+
26
+ kind = KINDS[type]
27
+ kind ? value.is_a?(kind) : false
28
+ end
29
+ end
30
+ end
@@ -0,0 +1,262 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Unisender
4
+ # Local structural failures omit parameter values from diagnostics.
5
+ class ValidationError < ArgumentError
6
+ attr_reader :field, :code, :operation, :index, :expected_type, :actual_type
7
+
8
+ # rubocop:disable-next Metrics/ParameterLists -- Structured diagnostic fields remain explicit.
9
+ def initialize(field:, code:, operation: nil, index: nil, expected_type: nil, actual_type: nil)
10
+ @field = Diagnostics.input_path(field)
11
+ @code = code
12
+ @operation = operation
13
+ @index = index
14
+ @expected_type = expected_type
15
+ @actual_type = actual_type
16
+ super("Invalid parameter #{@field}: #{code}")
17
+ end
18
+
19
+ alias path field
20
+
21
+ def diagnostics
22
+ Diagnostics.for_error(self)
23
+ end
24
+
25
+ def documentation_url
26
+ Diagnostics::URLS[@operation.to_s]
27
+ end
28
+
29
+ def with_operation(operation)
30
+ @operation = Error.safe_operation(operation)
31
+ self
32
+ end
33
+
34
+ def category
35
+ :invalid_argument
36
+ end
37
+
38
+ def recovery_hint
39
+ "Check the operation parameters."
40
+ end
41
+
42
+ def transient?
43
+ false
44
+ end
45
+
46
+ def safe_to_retry?
47
+ false
48
+ end
49
+ end
50
+
51
+ # Converts convenience values only on the named-operation path.
52
+ module Inputs
53
+ MODES = {
54
+ double_optin: { request_confirmation: 0, already_confirmed: 3, confirm_if_needed: 4 }.freeze,
55
+ overwrite: { preserve: 0, replace: 1, update: 2 }.freeze
56
+ }.freeze
57
+ MINUTES = %w[getMessages listMessages getTemplates listTemplates createCampaign].freeze
58
+ SECONDS = %w[getCampaigns async/getCampaignDeliveryStats].freeze
59
+ DATES = %i[date_from date_to from to start_time changed_since].freeze
60
+
61
+ def self.normalize(method, params)
62
+ result = operation_values(method, NamedInputs.normalize(method, attachment_parameters(params)))
63
+ format = timestamp_format(method)
64
+ return result unless format
65
+
66
+ DATES.each { |key| result[key] = timestamp(result[key], format, key) if result.key?(key) }
67
+ result
68
+ end
69
+
70
+ def self.operation_values(method, result)
71
+ case method
72
+ when "sendSms" then result[:phone] = SmsInputs.phone(result[:phone])
73
+ when "sendEmail" then result = MailInputs.normalize(result)
74
+ when "createCampaign" then schedule_timezone!(result)
75
+ when "subscribe" then MODES.each { |key, modes| result[key] = mode(result[key], modes, key) }
76
+ end
77
+ result
78
+ end
79
+
80
+ def self.schedule_timezone!(params)
81
+ return unless params[:start_time].is_a?(Time)
82
+
83
+ unless params[:timezone].nil? || params[:timezone] == "UTC"
84
+ raise ValidationError.new(field: "timezone", code: :conflicting_input)
85
+ end
86
+
87
+ params[:timezone] = "UTC"
88
+ end
89
+
90
+ def self.attachment_parameters(params)
91
+ result = params.dup
92
+ result[:attachments] = attachments(result[:attachments]) if result.key?(:attachments)
93
+ result
94
+ end
95
+
96
+ def self.attachments(value)
97
+ return value if value.nil? || value.is_a?(Hash)
98
+
99
+ output = {} # : Hash[String, parameter]
100
+ attachment_items(value).each do |item|
101
+ raise ValidationError.new(field: "attachments", code: :duplicate_filename) if output.key?(item.filename)
102
+
103
+ output[item.filename] = item.bytes
104
+ end
105
+ output
106
+ end
107
+
108
+ def self.attachment_items(value)
109
+ items = value.is_a?(Attachment) ? [value] : value
110
+ raise ValidationError.new(field: "attachments", code: :invalid_type) unless items.is_a?(Array)
111
+
112
+ items.map do |item|
113
+ raise ValidationError.new(field: "attachments", code: :invalid_type) unless item.is_a?(Attachment)
114
+
115
+ item
116
+ end
117
+ end
118
+
119
+ def self.timestamp_format(method)
120
+ return "%Y-%m-%d %H:%M" if MINUTES.include?(method)
121
+ return "%Y-%m-%d %H:%M:%S" if SECONDS.include?(method)
122
+
123
+ nil
124
+ end
125
+
126
+ def self.mode(value, modes, field)
127
+ return nil if value.nil?
128
+ return modes.fetch(value) if value.is_a?(Symbol) && modes.key?(value)
129
+ return value if value.is_a?(Integer) && modes.value?(value)
130
+
131
+ raise ValidationError.new(field: field.to_s, code: :invalid_mode, operation: "subscribe")
132
+ end
133
+
134
+ def self.timestamp(value, format, field)
135
+ return value if value.nil? || value.is_a?(String)
136
+ return value.getutc.strftime(format) if value.is_a?(Time)
137
+
138
+ raise ValidationError.new(field: field.to_s, code: :invalid_timestamp)
139
+ end
140
+
141
+ def self.contact_fields(fields, email)
142
+ validate_email_alias(email)
143
+ unless fields.nil? || fields.is_a?(Hash)
144
+ raise ValidationError.new(field: "fields", code: :invalid_type, operation: "subscribe")
145
+ end
146
+
147
+ # @type var result: params
148
+ result = fields ? fields.dup : {}
149
+ names = result.keys.map(&:to_s)
150
+ raise ValidationError.new(field: "fields", code: :duplicate_key, operation: "subscribe") if names.uniq != names
151
+
152
+ merge_email(result, email) if email
153
+ validate_email(result)
154
+ result
155
+ end
156
+
157
+ def self.validate_email_alias(email)
158
+ return if email.nil? || email.is_a?(String)
159
+
160
+ raise ValidationError.new(field: "email", code: :invalid_type, operation: "subscribe",
161
+ expected_type: "String", actual_type: Diagnostics.type_name(email))
162
+ end
163
+
164
+ def self.merge_email(result, email)
165
+ existing = result[:email] || result["email"]
166
+ if existing && existing != email
167
+ raise ValidationError.new(field: "fields.email", code: :conflicting_value, operation: "subscribe")
168
+ end
169
+
170
+ result.delete(:email)
171
+ result["email"] = email
172
+ end
173
+
174
+ def self.validate_email(result)
175
+ address = result[:email] || result["email"]
176
+ return if address.is_a?(String) && !address.empty?
177
+
178
+ raise ValidationError.new(field: "fields.email", code: :required, operation: "subscribe")
179
+ end
180
+ end
181
+
182
+ # SMS numbers remain strings and share one bounded recipient list.
183
+ module SmsInputs
184
+ def self.phone(value)
185
+ text = case value
186
+ when Array then Validation.comma_list(value, field: "phone")
187
+ when String then value
188
+ else raise ValidationError.new(field: "phone", code: :invalid_type)
189
+ end
190
+ phones = text.split(",", -1)
191
+ unless (1..150).cover?(phones.size) && phones.none? { |phone| phone.strip.empty? }
192
+ raise ValidationError.new(field: "phone", code: :invalid_count)
193
+ end
194
+
195
+ text
196
+ end
197
+ end
198
+
199
+ # Contact selectors and filters keep their wire forms behind named inputs.
200
+ module ContactInputs
201
+ def self.contact_selector(params, operation)
202
+ selected = params.slice(:email, :phone).compact
203
+ return params.slice(:contact_type, :contact, :list_ids) if selected.empty?
204
+
205
+ validate_selector(params, selected, operation)
206
+ kind = selected.key?(:email) ? :email : :phone
207
+ { contact_type: kind.to_s, contact: selected.fetch(kind), list_ids: params[:list_ids] }
208
+ rescue ValidationError => e
209
+ raise e.with_operation(operation), cause: nil
210
+ end
211
+
212
+ def self.validate_selector(params, selected, operation)
213
+ if selected.size != 1 || params[:contact_type] || params[:contact]
214
+ raise ValidationError.new(field: "contact", code: :conflicting_input, operation:)
215
+ end
216
+
217
+ value = selected.values.fetch(0)
218
+ unless value.is_a?(String) && !value.empty?
219
+ raise ValidationError.new(field: "contact", code: :invalid_contact, operation:)
220
+ end
221
+
222
+ Validation.required!(params, %w[list_ids])
223
+ end
224
+
225
+ def self.count_filters(params, tag_id, contact_type, search)
226
+ params = filter_container(params)
227
+ validate_filters(tag_id, search)
228
+ if params && [tag_id, contact_type, search].any?
229
+ raise ValidationError.new(field: "params", code: :conflicting_input, operation: "getContactCount")
230
+ end
231
+ return params if params
232
+
233
+ type = count_contact_type(contact_type)
234
+ raise ValidationError.new(field: "contact_type", code: :required, operation: "getContactCount") if search && !type
235
+
236
+ { tagId: tag_id, type:, search: }.compact
237
+ end
238
+
239
+ def self.filter_container(value)
240
+ return value if value.nil? || value.is_a?(Hash)
241
+
242
+ raise ValidationError.new(field: "params", code: :invalid_type, operation: "getContactCount")
243
+ end
244
+
245
+ def self.validate_filters(tag_id, search)
246
+ if !tag_id.nil? && !tag_id.is_a?(String) && !tag_id.is_a?(Integer)
247
+ raise ValidationError.new(field: "tag_id", code: :invalid_type, operation: "getContactCount")
248
+ end
249
+ return if search.nil? || search.is_a?(String)
250
+
251
+ raise ValidationError.new(field: "search", code: :invalid_type, operation: "getContactCount")
252
+ end
253
+
254
+ def self.count_contact_type(value)
255
+ return nil if value.nil?
256
+ return "address" if %w[email address].include?(value.to_s)
257
+ return "phone" if value.to_s == "phone"
258
+
259
+ raise ValidationError.new(field: "contact_type", code: :invalid_choice, operation: "getContactCount")
260
+ end
261
+ end
262
+ end
@@ -0,0 +1,15 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Unisender
4
+ # Applies an optional block without changing the iterator's return type.
5
+ module Iteration
6
+ def self.items(pages)
7
+ Enumerator.new { |output| pages.each { |page| page.each { |item| output << item } } }
8
+ end
9
+
10
+ def self.call(items, &block)
11
+ items.each(&block) if block
12
+ items
13
+ end
14
+ end
15
+ end
@@ -0,0 +1,60 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Unisender
4
+ # Lazy typed iteration over every page of a supported operation.
5
+ class Client
6
+ def each_campaign(from:, to: nil, page_size: 100, max_pages: 1000, offset: 0, &)
7
+ pages = each_page(
8
+ :get_campaigns, from:, to:, page_size:, max_pages:, offset:
9
+ ) # : Enumerator[PageResponse[Campaign], void]
10
+ Iteration.call(Iteration.items(pages), &)
11
+ end
12
+
13
+ def each_message(date_from:, date_to:, page_size: 100, max_pages: 1000, offset: 0, &)
14
+ pages = each_page(
15
+ :get_messages, date_from:, date_to:, page_size:, max_pages:, offset:
16
+ ) # : Enumerator[PageResponse[Message], void]
17
+ Iteration.call(Iteration.items(pages), &)
18
+ end
19
+
20
+ def each_message_summary(date_from:, date_to:, page_size: 100, max_pages: 1000, offset: 0, &)
21
+ pages = each_page(
22
+ :list_messages, date_from:, date_to:, page_size:, max_pages:, offset:
23
+ ) # : Enumerator[PageResponse[Message], void]
24
+ Iteration.call(Iteration.items(pages), &)
25
+ end
26
+
27
+ # rubocop:disable-next Metrics/ParameterLists -- Includes the operation keywords and pagination controls.
28
+ def each_template(type: nil, date_from: nil, date_to: nil, page_size: 100, max_pages: 1000, offset: 0, &)
29
+ pages = each_page(
30
+ :get_templates, type:, date_from:, date_to:, page_size:, max_pages:, offset:
31
+ ) # : Enumerator[PageResponse[Template], void]
32
+ Iteration.call(Iteration.items(pages), &)
33
+ end
34
+
35
+ # rubocop:disable-next Metrics/ParameterLists -- Includes the operation keywords and pagination controls.
36
+ def each_template_summary(type: nil, date_from: nil, date_to: nil, page_size: 100, max_pages: 1000, offset: 0,
37
+ &)
38
+ pages = each_page(
39
+ :list_templates, type:, date_from:, date_to:, page_size:, max_pages:, offset:
40
+ ) # : Enumerator[PageResponse[Template], void]
41
+ Iteration.call(Iteration.items(pages), &)
42
+ end
43
+
44
+ def each_sender_domain_group(username:, domain: nil, page_size: 100, max_pages: 1000, offset: 0, &)
45
+ pages = each_page(
46
+ :get_sender_domain_list, username:, domain:, page_size:, max_pages:, offset:
47
+ ) # : Enumerator[PageResponse[SenderDomainGroup], void]
48
+ Iteration.call(Iteration.items(pages), &)
49
+ end
50
+
51
+ # rubocop:disable-next Metrics/ParameterLists -- Includes the operation keywords and pagination controls.
52
+ def each_subscriber_note(subscriber_id:, order_type: nil, order_by: nil,
53
+ is_pinned: nil, page_size: 100, max_pages: 1000, offset: 0, &)
54
+ pages = each_page(
55
+ :get_subscriber_notes, subscriber_id:, order_type:, order_by:, is_pinned:, page_size:, max_pages:, offset:
56
+ ) # : Enumerator[PageResponse[Note], void]
57
+ Iteration.call(Iteration.items(pages), &)
58
+ end
59
+ end
60
+ end