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,37 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Unisender
4
+ # Named mail headers merge without silently replacing caller-supplied values.
5
+ module MailInputs
6
+ def self.normalize(params)
7
+ extra = headers(params)
8
+ result = params.except(:reply_to, :priority)
9
+ result[:headers] = merge(params[:headers], extra) unless extra.empty?
10
+ result
11
+ end
12
+
13
+ def self.headers(params)
14
+ { "Reply-To" => header(params[:reply_to]), "Priority" => header(params[:priority]) }.compact
15
+ end
16
+
17
+ def self.header(value)
18
+ return nil if value.nil?
19
+ unless value.is_a?(String) && !value.match?(/[\r\n]/)
20
+ raise ValidationError.new(field: "headers", code: :invalid_header)
21
+ end
22
+
23
+ value
24
+ end
25
+
26
+ def self.merge(original, extra)
27
+ return extra if original.nil?
28
+
29
+ names = extra.keys.map(&:downcase)
30
+ unless original.is_a?(Hash) && original.keys.none? { |key| names.include?(key.to_s.downcase) }
31
+ raise ValidationError.new(field: "headers", code: :conflicting_input)
32
+ end
33
+
34
+ original.merge(extra)
35
+ end
36
+ end
37
+ end
@@ -0,0 +1,52 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Unisender
4
+ # Closed choices are validated only for named operations.
5
+ module NamedInputs
6
+ # @type var empty: Hash[Symbol, Array[String]]
7
+ empty = {}
8
+ EMPTY_ENUMS = empty.freeze
9
+ FLAGS = {
10
+ "setHook" => %i[single_event],
11
+ "getContact" => %i[include_lists include_fields include_details],
12
+ "createCampaign" => %i[track_read track_links track_ga],
13
+ "createEmailMessage" => %i[generate_text], "sendEmail" => %i[track_read track_links error_checking],
14
+ "importContacts" => %i[overwrite_tags overwrite_lists], "getVisitedLinks" => %i[group],
15
+ "getSubscriberNotes" => %i[is_pinned]
16
+ }.transform_values(&:freeze).freeze
17
+ ENUMS = {
18
+ "sendEmail" => { images_as: %w[attachments only_links user_default].freeze }.freeze,
19
+ "setHook" => { event_format: %w[json_post json_post_gzip http_get], status: %w[active disabled] },
20
+ "getTemplates" => { type: %w[user system].freeze }.freeze,
21
+ "listTemplates" => { type: %w[user system].freeze }.freeze,
22
+ "exclude" => { contact_type: %w[email phone].freeze }.freeze,
23
+ "unsubscribe" => { contact_type: %w[email phone].freeze }.freeze,
24
+ "getSubscriberNotes" => { order_type: %w[ASC DESC].freeze,
25
+ order_by: %w[created_at updated_at pinned_at].freeze }.freeze
26
+ }.freeze
27
+
28
+ def self.normalize(method, params)
29
+ FLAGS.fetch(method, []).each { |key| flag!(key, params[key], method) }
30
+ ENUMS.fetch(method, EMPTY_ENUMS).each do |key, choices|
31
+ value = params[key]
32
+ params[key] = choice(value, key, choices, method) unless value.nil?
33
+ end
34
+ params
35
+ end
36
+
37
+ def self.flag!(key, value, operation)
38
+ return if value.nil? || value == true || value == false
39
+ return if value.is_a?(Integer) && (0..1).cover?(value)
40
+
41
+ raise ValidationError.new(field: key.to_s, code: :invalid_flag, operation:)
42
+ end
43
+
44
+ def self.choice(value, key, choices, operation)
45
+ text = value.to_s
46
+ text = text.upcase if key == :order_type
47
+ return text if (value.is_a?(String) || value.is_a?(Symbol)) && choices.include?(text)
48
+
49
+ raise ValidationError.new(field: key.to_s, code: :invalid_choice, operation:)
50
+ end
51
+ end
52
+ end
@@ -0,0 +1,30 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Unisender
4
+ # Metadata deliberately excludes request bodies, headers, and response payloads.
5
+ RequestEvent = Data.define(:operation, :attempt, :duration, :http_status, :outcome, :error_category, :request_id)
6
+
7
+ # Callback failures cannot change the outcome of an API request.
8
+ module Observation
9
+ def self.emit(callback, event)
10
+ return unless callback
11
+
12
+ callback.call(event)
13
+ rescue StandardError
14
+ nil
15
+ end
16
+
17
+ def self.event(operation, attempt, started, response, error)
18
+ RequestEvent.new(operation: Error.safe_operation(operation) || "request", attempt:,
19
+ duration: Process.clock_gettime(Process::CLOCK_MONOTONIC) - started,
20
+ http_status: response&.status, outcome: error ? :failure : :success,
21
+ error_category: category(error), request_id: response&.headers&.[]("x-request-id"))
22
+ end
23
+
24
+ def self.category(error)
25
+ return nil unless error
26
+
27
+ error.is_a?(Error) ? error.category : :unexpected
28
+ end
29
+ end
30
+ end
@@ -0,0 +1,106 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Unisender
4
+ # Named input shapes are checked against the public RBS in the test suite.
5
+ module OperationContracts
6
+ # rubocop:disable Layout/LineLength -- Static input contracts mirror explicit public signatures.
7
+ # @type var definitions: Hash[String, [String, Array[String], Hash[String, Array[String]]]]
8
+ definitions = {
9
+ "createList" => ["create_list", ["title"], { "title" => ["string"], "before_subscribe_url" => ["string"], "after_subscribe_url" => ["string"], "sub_user_rights" => ["hash"] }],
10
+ "deleteList" => ["delete_list", ["list_id"], { "list_id" => %w[string integer] }],
11
+ "exclude" => ["exclude", [], { "contact_type" => %w[string symbol], "contact" => ["string"], "email" => ["string"], "phone" => ["string"], "list_ids" => ["string", "array:string", "array:integer"] }],
12
+ "async/exportContacts" => ["export_contacts", [], { "notify_url" => ["string"], "list_id" => %w[string integer], "field_names" => ["array:string"], "email" => ["string"], "phone" => ["string"], "tag" => ["string"], "email_status" => ["string"], "phone_status" => ["string"], "contact_status" => ["string"] }],
13
+ "getContactCount" => ["get_contact_count", ["list_id"], { "list_id" => %w[string integer], "params" => ["hash"], "tag_id" => %w[string integer], "contact_type" => %w[string symbol], "search" => ["string"] }],
14
+ "getLists" => ["get_lists", [], {}],
15
+ "getTotalContactsCount" => ["get_total_contacts_count", ["login"], { "login" => ["string"] }],
16
+ "importContacts" => ["import_contacts", [], { "field_names" => ["array:string"], "data" => ["array:array:parameter"], "records" => ["enumerable"], "overwrite_tags" => %w[boolean integer], "overwrite_lists" => %w[boolean integer], "telegram_bot_channel_id" => ["string"] }],
17
+ "subscribe" => ["subscribe", ["list_ids"], { "list_ids" => ["string", "array:string", "array:integer"], "fields" => ["hash"], "email" => ["string"], "tags" => ["string", "array:string", "array:integer"], "double_optin" => %w[integer symbol], "overwrite" => %w[integer symbol] }],
18
+ "updateList" => ["update_list", %w[list_id title], { "list_id" => %w[string integer], "title" => ["string"], "before_subscribe_url" => ["string"], "after_subscribe_url" => ["string"], "sub_user_rights" => ["hash"] }],
19
+ "isContactInLists" => ["is_contact_in_lists", %w[email list_ids condition], { "email" => ["string"], "list_ids" => ["string", "array:string", "array:integer"], "condition" => ["string"] }],
20
+ "getContact" => ["get_contact", ["email"], { "email" => ["string"], "include_lists" => %w[boolean integer], "include_fields" => %w[boolean integer], "include_details" => %w[boolean integer] }],
21
+ "unsubscribe" => ["unsubscribe", [], { "contact_type" => %w[string symbol], "contact" => ["string"], "email" => ["string"], "phone" => ["string"], "list_ids" => ["string", "array:string", "array:integer"] }],
22
+ "cancelCampaign" => ["cancel_campaign", ["campaign_id"], { "campaign_id" => %w[string integer] }],
23
+ "checkEmail" => ["check_email", ["email_id"], { "email_id" => ["string", "array:string", "array:integer"] }],
24
+ "checkSms" => ["check_sms", ["sms_id"], { "sms_id" => %w[string integer] }],
25
+ "createCampaign" => ["create_campaign", ["message_id"], { "message_id" => %w[string integer], "start_time" => %w[string time], "timezone" => ["string"], "track_read" => %w[boolean integer], "track_links" => %w[boolean integer], "contacts" => ["string", "array:string", "array:integer"], "contacts_url" => ["string"], "track_ga" => %w[boolean integer], "payment_limit" => %w[integer float], "payment_currency" => ["string"], "ga_medium" => ["string"], "ga_source" => ["string"], "ga_campaign" => ["string"], "ga_content" => ["string"], "ga_term" => ["string"] }],
26
+ "createEmailMessage" => ["create_email_message", %w[list_id sender_name sender_email], { "sender_name" => ["string"], "sender_email" => ["string"], "list_id" => %w[string integer], "subject" => ["string"], "body" => ["string"], "text_body" => ["string"], "generate_text" => %w[boolean integer], "tag" => ["string"], "attachments" => ["hash", "attachment", "array:attachment"], "lang" => ["string"], "template_id" => %w[string integer], "system_template_id" => %w[string integer], "wrap_type" => ["string"], "categories" => ["string", "array:string", "array:integer"] }],
27
+ "createSmsMessage" => ["create_sms_message", %w[sender body list_id], { "sender" => ["string"], "body" => ["string"], "list_id" => %w[string integer], "tag" => ["string"] }],
28
+ "deleteMessage" => ["delete_message", ["message_id"], { "message_id" => %w[string integer] }],
29
+ "getActualMessageVersion" => ["get_actual_message_version", ["message_id"], { "message_id" => %w[string integer] }],
30
+ "getWebVersion" => ["get_web_version", ["campaign_id"], { "campaign_id" => %w[string integer] }],
31
+ "sendEmail" => ["send_email", %w[email subject body list_id sender_name sender_email], { "email" => ["string"], "sender_name" => ["string"], "sender_email" => ["string"], "subject" => ["string"], "body" => ["string"], "list_id" => %w[string integer], "attachments" => ["hash", "attachment", "array:attachment"], "lang" => ["string"], "track_read" => %w[boolean integer], "track_links" => %w[boolean integer], "cc" => ["string"], "headers" => %w[string hash], "images_as" => %w[string symbol], "ref_key" => ["string"], "error_checking" => %w[boolean integer], "metadata" => ["hash"], "reply_to" => ["string"], "priority" => ["string"] }],
32
+ "sendSms" => ["send_sms", %w[phone sender text], { "phone" => ["string", "array:string"], "sender" => ["string"], "text" => ["string"] }],
33
+ "sendTestEmail" => ["send_test_email", %w[email id], { "email" => ["string", "array:string", "array:integer"], "id" => %w[string integer] }],
34
+ "updateEmailMessage" => ["update_email_message", ["id"], { "id" => %w[string integer], "sender_name" => ["string"], "sender_email" => ["string"], "subject" => ["string"], "body" => ["string"], "list_id" => %w[string integer], "text_body" => ["string"], "lang" => ["string"], "categories" => ["string", "array:string", "array:integer"] }],
35
+ "updateOptInEmail" => ["update_opt_in_email", %w[sender_name sender_email subject body list_id], { "sender_name" => ["string"], "sender_email" => ["string"], "subject" => ["string"], "body" => ["string"], "list_id" => %w[string integer] }],
36
+ "getSenderDomainList" => ["get_sender_domain_list", ["username"], { "username" => ["string"], "domain" => ["string"], "limit" => ["integer"], "offset" => ["integer"] }],
37
+ "getCampaignCommonStats" => ["get_campaign_common_stats", ["campaign_id"], { "campaign_id" => %w[string integer] }],
38
+ "async/getCampaignDeliveryStats" => ["get_campaign_delivery_stats", ["campaign_id"], { "campaign_id" => %w[string integer], "notify_url" => ["string"], "changed_since" => %w[string time], "field_ids" => ["array:string", "array:integer"] }],
39
+ "getCampaignStatus" => ["get_campaign_status", ["campaign_id"], { "campaign_id" => %w[string integer] }],
40
+ "getMessages" => ["get_messages", %w[date_from date_to], { "date_from" => %w[string time], "date_to" => %w[string time], "limit" => ["integer"], "offset" => ["integer"] }],
41
+ "getVisitedLinks" => ["get_visited_links", ["campaign_id"], { "campaign_id" => %w[string integer], "group" => %w[boolean integer] }],
42
+ "listMessages" => ["list_messages", %w[date_from date_to], { "date_from" => %w[string time], "date_to" => %w[string time], "limit" => ["integer"], "offset" => ["integer"] }],
43
+ "getCampaigns" => ["get_campaigns", ["from"], { "from" => %w[string time], "to" => %w[string time], "limit" => ["integer"], "offset" => ["integer"] }],
44
+ "getMessage" => ["get_message", ["id"], { "id" => %w[string integer] }],
45
+ "async/getTaskResult" => ["get_task_result", ["task_uuid"], { "task_uuid" => ["string"] }],
46
+ "createEmailTemplate" => ["create_email_template", %w[title subject body], { "title" => ["string"], "subject" => ["string"], "body" => ["string"], "description" => ["string"], "text_body" => ["string"], "lang" => ["string"] }],
47
+ "deleteTemplate" => ["delete_template", ["template_id"], { "template_id" => %w[string integer] }],
48
+ "getTemplate" => ["get_template", [], { "template_id" => %w[string integer], "system_template_id" => %w[string integer] }],
49
+ "getTemplates" => ["get_templates", [], { "type" => %w[string symbol], "date_from" => %w[string time], "date_to" => %w[string time], "limit" => ["integer"], "offset" => ["integer"] }],
50
+ "listTemplates" => ["list_templates", [], { "type" => %w[string symbol], "date_from" => %w[string time], "date_to" => %w[string time], "limit" => ["integer"], "offset" => ["integer"] }],
51
+ "updateEmailTemplate" => ["update_email_template", ["template_id"], { "template_id" => %w[string integer], "title" => ["string"], "description" => ["string"], "subject" => ["string"], "body" => ["string"], "text_body" => ["string"], "lang" => ["string"] }],
52
+ "createField" => ["create_field", %w[name type], { "name" => ["string"], "type" => ["string"], "public_name" => ["string"] }],
53
+ "deleteField" => ["delete_field", ["id"], { "id" => %w[string integer] }],
54
+ "deleteTag" => ["delete_tag", ["id"], { "id" => %w[string integer] }],
55
+ "getFields" => ["get_fields", [], {}],
56
+ "getTags" => ["get_tags", [], {}],
57
+ "updateField" => ["update_field", %w[id name], { "id" => %w[string integer], "name" => ["string"], "public_name" => ["string"] }],
58
+ "getContactFieldValues" => ["get_contact_field_values", %w[email field_ids], { "email" => ["string"], "field_ids" => ["string", "array:string", "array:integer"] }],
59
+ "createSubscriberNote" => ["create_subscriber_note", %w[subscriber_id content], { "subscriber_id" => %w[string integer], "content" => ["string"] }],
60
+ "updateSubscriberNote" => ["update_subscriber_note", %w[id content], { "id" => %w[string integer], "content" => ["string"] }],
61
+ "deleteSubscriberNote" => ["delete_subscriber_note", ["id"], { "id" => %w[string integer] }],
62
+ "getSubscriberNote" => ["get_subscriber_note", ["id"], { "id" => %w[string integer] }],
63
+ "getSubscriberNotes" => ["get_subscriber_notes", ["subscriber_id"], { "subscriber_id" => %w[string integer], "limit" => ["integer"], "offset" => ["integer"], "order_type" => %w[string symbol], "order_by" => %w[string symbol], "is_pinned" => %w[boolean integer] }],
64
+ "setHook" => ["set_hook", %w[hook_url events], { "hook_url" => ["string"], "events" => ["hash"], "event_format" => %w[symbol string], "max_parallel" => ["integer"], "single_event" => %w[boolean integer], "status" => %w[symbol string] }],
65
+ "listHooks" => ["list_hooks", [], {}],
66
+ "removeHook" => ["remove_hook", ["hook_url"], { "hook_url" => ["string"] }]
67
+ }
68
+ DEFINITIONS = definitions.transform_values(&:freeze).freeze
69
+ # rubocop:enable Layout/LineLength
70
+
71
+ def self.wire_name(name)
72
+ entry = DEFINITIONS.find { |_wire, definition| definition.first == name.to_s }
73
+ raise ArgumentError, "Unsupported named operation" unless entry
74
+
75
+ entry.first
76
+ end
77
+
78
+ def self.validate!(wire, params)
79
+ _name, required, types = DEFINITIONS.fetch(wire)
80
+ validate_keys!(wire, params)
81
+ params.each do |key, value|
82
+ InputTypes.validate!(value, types.fetch(key.to_s), key.to_s) unless value.nil?
83
+ end
84
+ Validation.required!(params, required)
85
+ extra = { "getContactCount" => %w[params], "subscribe" => %w[fields], "importContacts" => %w[field_names data],
86
+ "exclude" => %w[contact_type contact], "unsubscribe" => %w[contact_type contact] }
87
+ Validation.required!(params, extra.fetch(wire, []))
88
+ end
89
+
90
+ def self.validate_keys!(wire, params)
91
+ types = DEFINITIONS.fetch(wire).last
92
+ return if params.keys.all? { |key| key.is_a?(Symbol) && types.key?(key.to_s) }
93
+
94
+ raise ValidationError.new(field: "parameters", code: :unknown_keyword)
95
+ end
96
+
97
+ def self.comma_fields(wire)
98
+ {
99
+ "subscribe" => %w[list_ids tags], "exclude" => %w[list_ids], "unsubscribe" => %w[list_ids],
100
+ "checkEmail" => %w[email_id], "createCampaign" => %w[contacts],
101
+ "createEmailMessage" => %w[categories], "updateEmailMessage" => %w[categories],
102
+ "sendTestEmail" => %w[email], "getContactFieldValues" => %w[field_ids]
103
+ }.fetch(wire, [])
104
+ end
105
+ end
106
+ end
@@ -16,8 +16,8 @@ module Unisender
16
16
  end
17
17
 
18
18
  # https://www.unisender.com/ru/support/api/contacts/exclude/
19
- def exclude(contact_type:, contact:, list_ids: nil)
20
- params = { contact_type:, contact:, list_ids: }
19
+ def exclude(contact_type: nil, contact: nil, email: nil, phone: nil, list_ids: nil)
20
+ params = ContactInputs.contact_selector({ contact_type:, contact:, email:, phone:, list_ids: }, "exclude")
21
21
  invoke("exclude", params, required: %w[contact_type contact], comma: %w[list_ids])
22
22
  end
23
23
 
@@ -31,7 +31,8 @@ module Unisender
31
31
  end
32
32
 
33
33
  # https://www.unisender.com/ru/support/api/contacts/getcontactcount/
34
- def get_contact_count(list_id:, params:)
34
+ def get_contact_count(list_id:, params: nil, tag_id: nil, contact_type: nil, search: nil)
35
+ params = ContactInputs.count_filters(params, tag_id, contact_type, search)
35
36
  params = { list_id:, params: }
36
37
  invoke("getContactCount", params, required: %w[list_id params])
37
38
  end
@@ -4,14 +4,26 @@ module Unisender
4
4
  # Contacts operations from the official UniSender API.
5
5
  class Client
6
6
  # https://www.unisender.com/ru/support/api/contacts/importcontacts/
7
- def import_contacts(field_names:, data:, overwrite_tags: nil, overwrite_lists: nil,
7
+ # rubocop:disable-next Metrics/ParameterLists -- Accepts either records or the documented matrix form.
8
+ def import_contacts(field_names: nil, data: nil, records: nil, overwrite_tags: nil, overwrite_lists: nil,
8
9
  telegram_bot_channel_id: nil)
10
+ if records
11
+ if field_names || data
12
+ raise ValidationError.new(field: "records", code: :conflicting_input, operation: "importContacts")
13
+ end
14
+
15
+ rows = RecordRows.bounded(records)
16
+ data = rows.to_a
17
+ field_names = rows.field_names
18
+ end
9
19
  params = { field_names:, data:, overwrite_tags:, overwrite_lists:, telegram_bot_channel_id: }
10
20
  invoke("importContacts", params, required: %w[field_names data])
11
21
  end
12
22
 
13
23
  # https://www.unisender.com/ru/support/api/contacts/subscribe/
14
- def subscribe(list_ids:, fields:, tags: nil, double_optin: nil, overwrite: nil)
24
+ # rubocop:disable-next Metrics/ParameterLists -- Adds email to the documented subscription options.
25
+ def subscribe(list_ids:, fields: nil, email: nil, tags: nil, double_optin: nil, overwrite: nil)
26
+ fields = Inputs.contact_fields(fields, email)
15
27
  params = { list_ids:, fields:, tags:, double_optin:, overwrite: }
16
28
  invoke("subscribe", params, required: %w[list_ids fields], comma: %w[list_ids tags])
17
29
  end
@@ -29,6 +41,13 @@ module Unisender
29
41
  invoke("isContactInLists", params, required: %w[email list_ids condition], comma: %w[list_ids])
30
42
  end
31
43
 
44
+ def contact_in_lists?(email:, list_ids:, condition:)
45
+ wire = { any: "or", all: "and" }.fetch(condition) do
46
+ raise ValidationError.new(field: "condition", code: :invalid_choice, operation: "isContactInLists")
47
+ end
48
+ is_contact_in_lists(email:, list_ids:, condition: wire).result
49
+ end
50
+
32
51
  # https://www.unisender.com/ru/support/api/contacts/getcontact/
33
52
  def get_contact(email:, include_lists: nil, include_fields: nil, include_details: nil)
34
53
  params = { email:, include_lists:, include_fields:, include_details: }
@@ -36,8 +55,8 @@ module Unisender
36
55
  end
37
56
 
38
57
  # https://www.unisender.com/ru/support/api/contacts/unsubscribe/
39
- def unsubscribe(contact_type:, contact:, list_ids: nil)
40
- params = { contact_type:, contact:, list_ids: }
58
+ def unsubscribe(contact_type: nil, contact: nil, email: nil, phone: nil, list_ids: nil)
59
+ params = ContactInputs.contact_selector({ contact_type:, contact:, email:, phone:, list_ids: }, "unsubscribe")
41
60
  invoke("unsubscribe", params, required: %w[contact_type contact], comma: %w[list_ids])
42
61
  end
43
62
  end
@@ -29,18 +29,21 @@ module Unisender
29
29
  params = { message_id:, start_time:, timezone:, track_read:, track_links:, contacts:, contacts_url:,
30
30
  track_ga:, payment_limit:, payment_currency:, ga_medium:, ga_source:, ga_campaign:,
31
31
  ga_content:, ga_term: }
32
- invoke("createCampaign", params, required: %w[message_id], comma: %w[contacts])
32
+ invoke("createCampaign", params, required: CAMPAIGN_REQUIRED, comma: %w[contacts])
33
33
  end
34
34
 
35
35
  # https://www.unisender.com/ru/support/api/messages/createemailmessage/
36
+ # rubocop:disable Layout/LineLength -- The static annotation mirrors the complete keyword signature.
37
+ # @type method create_email_message: (?sender_name: (String | Symbol)?, ?sender_email: (String | Symbol)?, list_id: id, ?subject: String | nil, ?body: String | nil, ?text_body: String | nil, ?generate_text: flag | nil, ?tag: String | nil, ?attachments: Hash[String, String] | Attachment | Array[Attachment] | nil, ?lang: String | Symbol | nil, ?template_id: id | nil, ?system_template_id: id | nil, ?wrap_type: String | nil, ?categories: String | Array[id] | nil) -> MessageCreatedResponse
38
+ # rubocop:enable Layout/LineLength
36
39
  # rubocop:disable-next Metrics/ParameterLists -- Mirrors the documented operation.
37
- def create_email_message(sender_name:, sender_email:, list_id:, subject: nil, body: nil, text_body: nil,
38
- generate_text: nil, tag: nil, attachments: nil, lang: nil, template_id: nil,
40
+ def create_email_message(list_id:, sender_name: UNSET, sender_email: UNSET, subject: nil, body: nil, text_body: nil,
41
+ generate_text: nil, tag: nil, attachments: nil, lang: UNSET, template_id: nil,
39
42
  system_template_id: nil,
40
43
  wrap_type: nil, categories: nil)
41
44
  params = { sender_name:, sender_email:, list_id:, subject:, body:, text_body:, generate_text:, tag:,
42
45
  attachments:, lang:, template_id:, system_template_id:, wrap_type:, categories: }
43
- invoke("createEmailMessage", params, required: %w[sender_name sender_email list_id], comma: %w[categories])
46
+ invoke("createEmailMessage", params, required: EMAIL_REQUIRED, comma: %w[categories])
44
47
  end
45
48
 
46
49
  # https://www.unisender.com/ru/support/api/messages/createsmsmessage/
@@ -16,12 +16,17 @@ module Unisender
16
16
  end
17
17
 
18
18
  # https://www.unisender.com/ru/support/api/messages/sendemail/
19
+ # rubocop:disable Layout/LineLength -- The static annotation mirrors the complete keyword signature.
20
+ # @type method send_email: (email: String, ?sender_name: (String | Symbol)?, ?sender_email: (String | Symbol)?, subject: String, body: String, list_id: id, ?attachments: Hash[String, String] | Attachment | Array[Attachment] | nil, ?lang: String | Symbol | nil, ?track_read: flag | nil, ?track_links: flag | nil, ?cc: String | nil, ?headers: String | params | nil, ?images_as: String | Symbol | nil, ?ref_key: String | nil, ?error_checking: flag | nil, ?metadata: params | nil, ?reply_to: String | nil, ?priority: String | nil) -> SendResponse
21
+ # rubocop:enable Layout/LineLength
19
22
  # rubocop:disable-next Metrics/ParameterLists -- Mirrors the documented operation.
20
- def send_email(email:, sender_name:, sender_email:, subject:, body:, list_id:, attachments: nil, lang: nil,
23
+ def send_email(email:, subject:, body:, list_id:, sender_name: UNSET, sender_email: UNSET,
24
+ attachments: nil, lang: UNSET,
21
25
  track_read: nil, track_links: nil, cc: nil, headers: nil, images_as: nil, ref_key: nil,
22
- error_checking: nil, metadata: nil)
26
+ error_checking: true, metadata: nil, reply_to: nil, priority: nil)
23
27
  params = { email:, sender_name:, sender_email:, subject:, body:, list_id:, attachments:, lang:,
24
- track_read:, track_links:, cc:, headers:, images_as:, ref_key:, error_checking:, metadata: }
28
+ track_read:, track_links:, cc:, headers:, images_as:, ref_key:, error_checking:, metadata:,
29
+ reply_to:, priority: }
25
30
  invoke("sendEmail", params, required: %w[email sender_name sender_email subject body list_id])
26
31
  end
27
32
 
@@ -6,7 +6,7 @@ module Unisender
6
6
  # https://www.unisender.com/ru/support/api/messages/getsenderdomainlist/
7
7
  def get_sender_domain_list(username:, domain: nil, limit: nil, offset: nil)
8
8
  params = { username:, domain:, limit:, offset: }
9
- PaginatedOperations.call(self, :get_sender_domain_list, params)
9
+ PaginatedOperations.call(self, :get_sender_domain_list, params) # : PageResponse[SenderDomainGroup]
10
10
  end
11
11
  end
12
12
  end
@@ -32,7 +32,7 @@ module Unisender
32
32
  def get_subscriber_notes(subscriber_id:, limit: nil, offset: nil, order_type: nil, order_by: nil,
33
33
  is_pinned: nil)
34
34
  params = { subscriber_id:, limit:, offset:, order_type:, order_by:, is_pinned: }
35
- PaginatedOperations.call(self, :get_subscriber_notes, params)
35
+ PaginatedOperations.call(self, :get_subscriber_notes, params) # : PageResponse[Note]
36
36
  end
37
37
  end
38
38
  end
@@ -24,7 +24,7 @@ module Unisender
24
24
  # https://www.unisender.com/ru/support/api/statistics/getmessages/
25
25
  def get_messages(date_from:, date_to:, limit: nil, offset: nil)
26
26
  params = { date_from:, date_to:, limit:, offset: }
27
- PaginatedOperations.call(self, :get_messages, params)
27
+ PaginatedOperations.call(self, :get_messages, params) # : PageResponse[Message]
28
28
  end
29
29
 
30
30
  # https://www.unisender.com/ru/support/api/statistics/getvisitedlinks/
@@ -36,13 +36,13 @@ module Unisender
36
36
  # https://www.unisender.com/ru/support/api/statistics/listmessages/
37
37
  def list_messages(date_from:, date_to:, limit: nil, offset: nil)
38
38
  params = { date_from:, date_to:, limit:, offset: }
39
- PaginatedOperations.call(self, :list_messages, params)
39
+ PaginatedOperations.call(self, :list_messages, params) # : PageResponse[Message]
40
40
  end
41
41
 
42
42
  # https://www.unisender.com/ru/support/api/statistics/getcampaigns/
43
43
  def get_campaigns(from:, to: nil, limit: nil, offset: nil)
44
44
  params = { from:, to:, limit:, offset: }
45
- PaginatedOperations.call(self, :get_campaigns, params)
45
+ PaginatedOperations.call(self, :get_campaigns, params) # : PageResponse[Campaign]
46
46
  end
47
47
  end
48
48
  end
@@ -25,13 +25,13 @@ module Unisender
25
25
  # https://www.unisender.com/ru/support/api/templates/gettemplates/
26
26
  def get_templates(type: nil, date_from: nil, date_to: nil, limit: nil, offset: nil)
27
27
  params = { type:, date_from:, date_to:, limit:, offset: }
28
- PaginatedOperations.call(self, :get_templates, params)
28
+ PaginatedOperations.call(self, :get_templates, params) # : PageResponse[Template]
29
29
  end
30
30
 
31
31
  # https://www.unisender.com/ru/support/api/templates/listtemplates/
32
32
  def list_templates(type: nil, date_from: nil, date_to: nil, limit: nil, offset: nil)
33
33
  params = { type:, date_from:, date_to:, limit:, offset: }
34
- PaginatedOperations.call(self, :list_templates, params)
34
+ PaginatedOperations.call(self, :list_templates, params) # : PageResponse[Template]
35
35
  end
36
36
 
37
37
  # https://www.unisender.com/ru/support/api/templates/updateemailtemplate/