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
data/docs/usage.md ADDED
@@ -0,0 +1,144 @@
1
+ # Using the client
2
+
3
+ ## Responses and failures
4
+
5
+ Named operations return typed responses. Collections support `each`, `map`, and other `Enumerable` methods. Object results expose documented fields directly. `result` contains the typed value; `data` preserves the full upstream JSON object. `http_status`, `headers`, and `warnings` retain response metadata. Values are deeply immutable; use `result.to_h` for a mutable object copy. See [the 0.2 migration guide](migration-0.2.md).
6
+
7
+ Collections also support `length`, `at`, slices, and `fetch` with a default or block.
8
+ `send_sms(phone: [...], sender:, text:)` accepts phone arrays and always returns a receipt collection, including for one phone.
9
+ Records provide `key?`, `keys`, `values`, and `fetch` with the same missing-value behavior as a Ruby Hash.
10
+ Object responses forward these lookups. Record equality is class-sensitive; use `to_h` to compare with a Hash.
11
+ Object responses expose `to_h`. `as_json` and `to_json` serialize the result payload, without HTTP metadata or the response envelope.
12
+ Both serializers accept `only:` and `except:` field names. Filters select top-level fields on each record; unsupported options raise `ArgumentError`.
13
+ Use `contact.email_address` and `contact.phone_number` for stable address readers; omitted contact details remain `nil`.
14
+
15
+ Named methods use snake_case and reject unknown keywords. Optional `nil` values are omitted, preserving server defaults. API messages default to English; use `Client.new(api_key: ..., language: :ru)` for Russian. Message-level `lang:` is a separate setting.
16
+
17
+ Campaign `Time` values use explicit UTC and minute precision. String timestamps retain the specified or account-default timezone.
18
+
19
+ Library-detected input failures raise `ValidationError`, an `ArgumentError` subclass with `field`, `code`, and `operation`.
20
+ Named calls reject incorrect scalar and collection types before networking.
21
+ Use `client.validate!(:subscribe, email: "person@example.test", list_ids: [123])` to check arguments and encoding offline.
22
+ Validation returns `true` or raises. It applies client defaults and consumes supplied import enumerables, but makes no requests.
23
+ Known flags accept only booleans or 0/1. Template types and note ordering also accept documented Symbol choices.
24
+ `send_email` requests detailed recipient acceptance by default; set `error_checking: false` for the legacy response or `nil` to omit it.
25
+ Use `accepted_ids`, `accepted_recipients`, `rejected_recipients`, and `unknown_recipients` to inspect send outcomes.
26
+ `reply_to:` and `priority:` merge with Hash headers; conflicting or multiline values are rejected.
27
+ `images_as:` accepts `:attachments`, `:only_links`, or `:user_default` as well as their String forms.
28
+
29
+ Use this error handler in your application's `request.rb`:
30
+
31
+ ```ruby
32
+ begin
33
+ client.get_lists
34
+ rescue Unisender::APIError => error
35
+ warn error.message
36
+ # Fix credentials, permissions, or parameters using error.code.
37
+ rescue Unisender::HTTPError => error
38
+ warn error.message
39
+ # Check error.response.http_status and error.response.headers.
40
+ rescue Unisender::TransportError, Unisender::InvalidResponseError => error
41
+ warn error.message
42
+ # Check service availability before deciding whether to repeat the operation.
43
+ end
44
+ ```
45
+
46
+ `APIError` also covers API failures returned with HTTP 200; an accompanying result is discarded. `HTTPError` covers non-2xx statuses, including redirects. `InvalidResponseError` covers malformed successful responses. `TimeoutError` is a `TransportError`. All derive from `Unisender::Error`.
47
+
48
+ Error messages identify known operations and HTTP statuses or documented API codes. Unknown wire method names and unrecognized API codes are not copied into messages. `error.operation` is available separately. Credentials, request bodies, and contact data are omitted. Explicit accessors such as `error.details`, `error.response.data`, and `response.data` can contain private information. No request logging is enabled.
49
+
50
+ A successful envelope does not mean every recipient succeeded. Inspect `send_email(error_checking: 1, ...)` recipient errors and import counters. A `BatchError` provides `completed_responses`, the zero-based, end-exclusive `failed_range`, and the original `error`. Accepted changes remain applied; do not repeat the whole input automatically. The [batch example](../examples/import.rb) shows how to inspect partial results.
51
+
52
+ ## Timeouts, retries, and pagination
53
+
54
+ In `quickstart.rb`, replace the client construction with:
55
+
56
+ ```ruby
57
+ client = Unisender::Client.new(
58
+ api_key: ENV.fetch("UNISENDER_API_KEY"),
59
+ open_timeout: 5, read_timeout: 60, write_timeout: 30,
60
+ max_retries: 2, max_elapsed: 60
61
+ )
62
+ ```
63
+
64
+ Those are the default transport timeouts in seconds. TLS certificates are verified and redirects are rejected. Connections are separate by default; `persistent: true` enables reuse. **Retries default to zero.** The opt-in policy retries only explicitly identified read-only operations, at most twice, with backoff, jitter, and `Retry-After`. Sends, mutations, imports, exports, events, and unknown methods are never retried automatically. The elapsed retry budget is not a hard request deadline; in-flight requests use transport timeouts.
65
+
66
+ `each_page` and `each_*` helpers return lazy Enumerators. Consume them, or supply a block to a named iterator, to make requests. Batch command methods execute immediately and return a `BatchReport`. Stopping enumeration prevents subsequent requests. A fetched page exposes `each_page` and `auto_each`; ordinary `each` only visits that page. `each_page` yields responses, accepts a supported Ruby method name, and raises `PaginationError` at `max_pages`. `wait_for_task` polls an existing job; its deadline is checked between requests. See [helper limits and recovery behavior](api.md#convenience-operations) and [upstream rate limits](https://www.unisender.com/ru/support/api/common/api-limits/).
67
+
68
+ All page iterators accept blocks. Fetched pages expose `offset`, `limit`, and `next_offset` for explicit checkpoints.
69
+ Task handles provide `completed?`, `failed?`, and `pending?`. Calling `wait` on a completed handle returns it without another request.
70
+ Waiting on a failed handle raises `TaskFailedError` immediately, preserving its response and task UUID.
71
+
72
+ ## Low-level access and types
73
+
74
+ `client.request("getLists", params: {})` accepts exact upstream names. It handles authentication, encoding, and failures, but does not perform operation-specific conversions. Comma-separated fields must already be strings; nested arrays and hashes become bracketed form fields. Credentials and JSON format cannot be overridden.
75
+
76
+ RBS signatures ship with the gem. `Response[Result]` describes a named operation's result; the low-level method keeps the general JSON type. Named results validate documented field shapes while retaining unknown fields. Unknown fields remain accessible through `response.data`. Consumer examples are checked with Steep alongside library code.
77
+
78
+ ## Configuration and application tests
79
+
80
+ `Client.from_env` reads `UNISENDER_API_KEY` from the supplied environment. It does not load `.env`.
81
+ Use `client.with(...)` to create an independent configuration variant.
82
+ `message_defaults:` accepts only `sender_name`, `sender_email`, and `lang`; explicit call arguments take precedence.
83
+
84
+ An optional `instrumentation:` callable receives operation, attempt, duration, HTTP status, outcome, error category, and upstream request ID when available.
85
+ It receives no request payload. Exceptions raised by the callback do not change the operation's result.
86
+
87
+ Errors expose `category`, `recovery_hint`, `request_id` when supplied, `transient?`, and `safe_to_retry?`.
88
+ Use `error.diagnostics` for a frozen, payload-free Hash with known operation, field, HTTP, and recovery context.
89
+ Validation and result-shape failures expose `path`, `expected_type`, `actual_type`, and `documentation_url` when available.
90
+ A transient failure does not imply that repeating a mutation is safe.
91
+ Import and send reports expose `complete?`, `partial?`, `uncertain?`, and `failures`.
92
+ These describe acceptance, not final message delivery.
93
+
94
+ Imports expose typed `row_warnings` separately from failures and envelope warnings.
95
+ The `records:` input accepts arrays in documented list/tag cells and Time values in subscription-time cells.
96
+ `unsubscribe(email: ..., list_ids: [...])` and `exclude(phone: ..., list_ids: [...])` provide explicit contact selectors.
97
+ `get_contact_count(list_id:, contact_type: :email, search:, tag_id:)` builds filters; the existing `params:` form remains available separately.
98
+
99
+ See [application testing](testing.md), [Rails integration](rails.md), and the [migration guide](migration-0.2.md).
100
+ The [execution guide](execution.md) covers lazy consumption, checkpoints, shared request budgets, cancellation, and summary retention. The [webhook guide](webhooks.md) covers hook management and authenticated receivers. [Staging contract checks](live-contracts.md) are explicitly opt-in.
101
+
102
+ Use `client.with_execution(timeout: 30, cancellation: token) { |scoped| ... }` for one cooperative budget across related operations. `token` is a `Unisender::CancellationToken`; calling `cancel` stops work at the next boundary. In-flight IO and callbacks are not interrupted.
103
+ For large imports, `import_contacts_in_batches(records:, retention: :summary, on_progress:)` returns counts without retaining successful response bodies. Full report retention remains the default.
104
+
105
+ ## Optional workflow helpers
106
+
107
+ Use connection reuse for repeated calls and close clients when their lifetime ends:
108
+
109
+ `Unisender::Client.open(api_key: ..., persistent: true) { |client| ... }` closes owned resources when the block exits, including after an exception.
110
+ It returns the block's result and never closes an injected transport.
111
+
112
+ ```ruby
113
+ client = Unisender::Client.from_env(
114
+ persistent: true,
115
+ rate_limiter: Unisender::RateLimiter.unisender
116
+ )
117
+ begin
118
+ client.get_lists.each { |list| puts list.title }
119
+ ensure
120
+ client.close
121
+ end
122
+ ```
123
+
124
+ Pacing is opt-in, process-local, and separate from retries. Share a limiter between clients that share an account budget. The built-in preset covers marketing requests; Event Tracking has a separate budget. An injected transport remains caller-owned. Create clients after forking where possible; persistent transports discard inherited connections on a PID change.
125
+
126
+ Set `event_rate_limiter: Unisender::RateLimiter.new(global: [count, seconds])` to pace Event Tracking independently.
127
+ Choose the event budget for your application; the library supplies no provider rate preset for this protocol.
128
+
129
+ `Attachment.from_path(path)` and `Attachment.from_io(io, filename:)` read binary data into an immutable snapshot. Pass one attachment, an array, or the existing filename-to-bytes hash as `attachments:`. The helper never closes a caller-owned IO.
130
+
131
+ Pass `max_bytes:` to bound attachment reads. Direct imports inspect at most 501 records; use batching for more than 500 records.
132
+
133
+ `send_campaign(message: {...}, campaign: {...})` validates both steps before creating the message and returns `receipt.message` and `receipt.campaign`. If campaign creation fails, `CampaignWorkflowError#message_response` preserves the created message. The workflow cannot roll back account changes.
134
+
135
+ Completed export tasks can stream into a caller-owned IO:
136
+
137
+ ```ruby
138
+ completed = client.export_contacts(list_id: 123).wait
139
+ File.open("contacts.csv", "wb") do |file|
140
+ completed.download_to(file, max_bytes: 20 * 1024 * 1024)
141
+ end
142
+ ```
143
+
144
+ Downloads are explicit, use a separate connection without API credentials, and accept only public HTTPS destinations on port 443. Redirects are rejected. Set a byte limit; `DownloadError#bytes_written` reports confirmed partial output, which remains in your IO. See [helper contracts](api.md#optional-workflow-helpers) for configuration and failure details.
data/docs/webhooks.md ADDED
@@ -0,0 +1,41 @@
1
+ # Receiving UniSender webhooks
2
+
3
+ Webhook delivery is separate from outbound `track_events`. Register a dedicated HTTPS receiver:
4
+
5
+ ```ruby
6
+ client.set_hook(hook_url: "https://example.com/unisender",
7
+ events: { email_status: %w[ok_read ok_link_visited], subscribe: "*" })
8
+ client.list_hooks.each { |hook| puts hook.id }
9
+ client.remove_hook(hook_url: "https://example.com/unisender")
10
+ ```
11
+
12
+ `set_hook` replaces the registration for the same URL. Its defaults are `event_format: :json_post_gzip`, `single_event: false`, and `status: :active`. Mutation methods return raw `Response` payloads because their success fields are not specified. The registration check sends an empty GET and expects HTTP 200. It does not authenticate an event.
13
+
14
+ Read the request body once, impose a server-side request limit, then verify it before processing:
15
+
16
+ ```ruby
17
+ require "unisender/webhooks"
18
+
19
+ raw = request.body.read(1_048_577)
20
+ notification = Unisender::Webhooks.verify(
21
+ body: raw, api_key: credentials.fetch(:unisender_api_key),
22
+ content_encoding: request.get_header("HTTP_CONTENT_ENCODING")
23
+ )
24
+ notification.users.each do |user|
25
+ user.events.each { |event| persist_event(user.login, event.to_h) }
26
+ end
27
+ ```
28
+
29
+ The verifier preserves unknown fields and accepts `events` or `Events` within each user, rejecting conflicting keys. It supports only the documented batched JSON format, including gzip. It bounds compressed input, decompressed input, nesting, and event count. Defaults are 1 MiB, 4 MiB, 64 levels, and 10,000 events. It rejects duplicate JSON keys and trailing compressed data. `WebhookError#reason` contains no payload data.
30
+
31
+ Authentication follows the upstream MD5 procedure on the original decompressed JSON, replacing only the top-level auth value with the JSON-encoded key. Never parse and regenerate the JSON before verification. Authentication alone does not prevent replay. Deduplicate and process events idempotently in durable application storage. Return HTTP 200 only after durable acceptance; keep processing short. Delivery can repeat, and an unsuccessful receiver is retried before registration stops. Handle malformed data without logging bodies, API keys, or contact information.
32
+
33
+ ## Protocol security limitation
34
+
35
+ The [supported webhook protocol](https://www.unisender.com/ru/support/api/common/sistema-uvedomlenij-o-sobytiyah-webhooks/) requires MD5 and documents no stronger signature mode. Replacing MD5 locally would reject valid notifications.
36
+
37
+ This library accepts that protocol limitation. The exception covers only the digest in `Unisender::Webhooks.authenticate`, tracked by [CodeQL alert #3](https://github.com/svyatov/unisender-ruby/security/code-scanning/3). Its disposition is `won't fix`, not `false positive`. Other uses of weak cryptography remain subject to CodeQL checks.
38
+
39
+ Require HTTPS, retain the request limits, and deduplicate events in application storage. These controls do not remove MD5's cryptographic limitations. Reassess the exception if UniSender introduces stronger webhook authentication.
40
+
41
+ These contracts are based on [UniSender's webhook documentation](https://www.unisender.com/ru/support/api/common/sistema-uvedomlenij-o-sobytiyah-webhooks/) and offline fixtures. A designated test account and an authorized receiver are still required to verify live delivery.
data/examples/campaign.rb CHANGED
@@ -5,9 +5,8 @@
5
5
  require "unisender"
6
6
 
7
7
  client = Unisender::Client.new(api_key: ENV.fetch("UNISENDER_API_KEY"))
8
- message = client.create_email_message(
9
- sender_name: "Example", sender_email: ENV.fetch("UNISENDER_SENDER_EMAIL"),
10
- list_id: ENV.fetch("UNISENDER_LIST_ID"), subject: "Product news", body: "<p>Hello</p>"
11
- )
12
- campaign = client.create_campaign(message_id: message.result.fetch("message_id"))
13
- puts campaign.result.fetch("campaign_id")
8
+ receipt = client.send_campaign(message: {
9
+ sender_name: "Example", sender_email: ENV.fetch("UNISENDER_SENDER_EMAIL"),
10
+ list_id: ENV.fetch("UNISENDER_LIST_ID"), subject: "Product news", body: "<p>Hello</p>"
11
+ })
12
+ puts receipt.campaign.campaign_id
@@ -0,0 +1,28 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Requires the API key, list ID, sender email, test email, and ISO 8601 start time below.
4
+ # Creates a message, sends a test, and schedules a campaign to consenting test recipients.
5
+ # Stop on failure. Reconcile saved IDs before rerunning any mutation.
6
+ require "unisender"
7
+
8
+ client = Unisender::Client.new(api_key: ENV.fetch("UNISENDER_API_KEY"))
9
+ list_id = ENV.fetch("UNISENDER_LIST_ID")
10
+ sender = ENV.fetch("UNISENDER_SENDER_EMAIL")
11
+ test_email = ENV.fetch("UNISENDER_TEST_EMAIL")
12
+ start_time = Time.iso8601(ENV.fetch("UNISENDER_START_TIME"))
13
+ raise ArgumentError, "Choose a future start time" unless start_time > Time.now
14
+
15
+ # 1. Creates message content, without launching the campaign.
16
+ message = client.create_email_message(sender_name: "Example", sender_email: sender,
17
+ list_id:, subject: "Product news", body: "<p>Hello</p>")
18
+ puts "Save message_id: #{message.message_id}"
19
+ # 2. Reads the created content. Save the ID in application storage before continuing.
20
+ draft = client.get_message(id: message.message_id)
21
+ puts "Review subject: #{draft.subject}"
22
+ # 3. Sends only to the explicit test address. The provider can require address verification.
23
+ client.send_test_email(id: message.message_id, email: test_email)
24
+ # 4. Schedules delivery to the list. Time is converted to explicit UTC, at minute precision.
25
+ campaign = client.create_campaign(message_id: message.message_id, start_time:)
26
+ puts "Save campaign_id: #{campaign.campaign_id}"
27
+ # 5. Reads status once. Repeat only this read when observing the same campaign.
28
+ puts "Campaign status: #{client.get_campaign_status(campaign_id: campaign.campaign_id).status}"
@@ -9,7 +9,7 @@ abort "Set UNISENDER_API_KEY before running this example." if api_key.nil? || ap
9
9
  begin
10
10
  client = Unisender::Client.new(api_key:)
11
11
  response = client.get_lists
12
- puts "Connected to UniSender: HTTP #{response.status}, #{response.result.size} list(s)."
12
+ puts "Connected to UniSender: HTTP #{response.http_status}, #{response.result.size} list(s)."
13
13
  rescue ArgumentError, Unisender::Error => e
14
14
  warn e.message
15
15
  exit 1
data/examples/events.rb CHANGED
@@ -9,7 +9,7 @@ begin
9
9
  client.track_events_in_batches(events: [
10
10
  { name: "purchase", email: ENV.fetch("UNISENDER_CONTACT_EMAIL"), total: 1200,
11
11
  currency: "RUB" }
12
- ]).each { |response| puts "Recorded batch: HTTP #{response.status}" }
12
+ ]).each { |response| puts "Recorded batch: HTTP #{response.http_status}" }
13
13
  rescue Unisender::BatchError => e
14
14
  warn "Review events #{e.failed_range}; completed batches: #{e.completed_responses.size}"
15
15
  failure = e.error
data/examples/export.rb CHANGED
@@ -1,11 +1,25 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- # Requires UNISENDER_API_KEY and UNISENDER_LIST_ID. Starts an export job.
4
- # The returned download URL grants access to contact data; keep it private.
3
+ # Requires UNISENDER_API_KEY, UNISENDER_LIST_ID, UNISENDER_EXPORT_STATE,
4
+ # and UNISENDER_EXPORT_PATH. Starts or resumes an export job.
5
5
  require "unisender"
6
6
 
7
7
  client = Unisender::Client.new(api_key: ENV.fetch("UNISENDER_API_KEY"))
8
- job = client.export_contacts(list_id: ENV.fetch("UNISENDER_LIST_ID"))
9
- completed = client.wait_for_task(task_uuid: job.result.fetch("task_uuid"))
10
- download_url = completed.result.fetch("file_to_download")
11
- puts "Export ready at host: #{URI(download_url).host}"
8
+ state = ENV.fetch("UNISENDER_EXPORT_STATE")
9
+ destination = ENV.fetch("UNISENDER_EXPORT_PATH")
10
+ if File.exist?(state)
11
+ job = client.get_task_result(task_uuid: File.read(state).strip)
12
+ else
13
+ job = client.export_contacts(list_id: ENV.fetch("UNISENDER_LIST_ID"))
14
+ File.open("#{state}.tmp", "w", 0o600) { |file| file.write(job.task_uuid) }
15
+ File.rename("#{state}.tmp", state)
16
+ end
17
+ # A crash before saving the UUID requires reconciliation before starting another job.
18
+ completed = job.wait
19
+ File.open("#{destination}.part", "wb", 0o600) do |file|
20
+ completed.download_to(file, max_bytes: 100 * 1024 * 1024)
21
+ end
22
+ File.rename("#{destination}.part", destination)
23
+ File.delete(state)
24
+ puts "Export saved"
25
+ # On failure, keep the UUID. A retry truncates .part and downloads the same job.
data/examples/import.rb CHANGED
@@ -6,12 +6,13 @@ require "unisender"
6
6
  client = Unisender::Client.new(api_key: ENV.fetch("UNISENDER_API_KEY"))
7
7
  begin
8
8
  client.import_contacts_in_batches(
9
- field_names: %w[email Name], data: [[ENV.fetch("UNISENDER_CONTACT_EMAIL"), "Example"]]
10
- ).each { |response| puts "Imported batch: HTTP #{response.status}" }
9
+ records: [{ email: ENV.fetch("UNISENDER_CONTACT_EMAIL"), Name: "Example" }]
10
+ ).each { |response| puts "Imported batch: HTTP #{response.http_status}" }
11
11
  rescue Unisender::BatchError => e
12
12
  warn "Completed batches: #{e.completed_responses.size}; review rows #{e.failed_range}"
13
13
  if e.error.is_a?(Unisender::PartialFailure)
14
14
  warn "Some rows in the failed batch were accepted. Inspect error.error.response.result privately."
15
+ e.report.failures.each { |failure| warn "Rejected row #{failure.index}: #{failure.code}" }
15
16
  end
16
17
  warn e.error.message
17
18
  # Stop here. Reconcile accepted rows before building a new input; never replay all rows blindly.
@@ -0,0 +1,70 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "unisender"
4
+ require "digest"
5
+
6
+ # Application-owned file checkpoint. Use a private directory and one worker per state file.
7
+ class RecoverableImport
8
+ def initialize(client, state_path)
9
+ @client = client
10
+ @state_path = state_path
11
+ end
12
+
13
+ def run(rows, batch_size: 500)
14
+ raise ArgumentError, "Invalid batch size" unless (1..500).cover?(batch_size)
15
+
16
+ state = read_state(rows)
17
+ offset = resume_index(state, rows.size)
18
+ rows.drop(offset).each_slice(batch_size) { |batch| offset = import_batch(batch, state, offset) }
19
+ offset
20
+ end
21
+
22
+ private
23
+
24
+ def resume_index(state, size)
25
+ raise "Reconcile the recorded in-flight rows before resuming" if state["in_flight"]
26
+
27
+ offset = state.fetch("next_index")
28
+ raise "Invalid checkpoint index" unless offset.is_a?(Integer) && (0..size).cover?(offset)
29
+
30
+ offset
31
+ end
32
+
33
+ def import_batch(batch, state, offset)
34
+ state["in_flight"] = batch.map { |row| row.fetch("id") }
35
+ save(state) # Durable intent precedes the remote mutation.
36
+ records = batch.map { |row| row.fetch("fields") }
37
+ import_records(records)
38
+ state["next_index"] = offset + batch.size
39
+ state.delete("in_flight")
40
+ save(state) # Crash before this write requires reconciliation, not replay.
41
+ offset + batch.size
42
+ end
43
+
44
+ def import_records(records)
45
+ response = @client.import_contacts(records:).raise_for_partial_failure!
46
+ raise Unisender::PartialFailure, response unless response.total == records.size
47
+ end
48
+
49
+ def read_state(rows)
50
+ digest = Digest::SHA256.hexdigest(JSON.generate(rows))
51
+ return { "source_digest" => digest, "next_index" => 0 } unless File.exist?(@state_path)
52
+
53
+ state = JSON.parse(File.read(@state_path)) # : Unisender::json
54
+ raise "Invalid checkpoint" unless state.is_a?(Hash)
55
+ raise "Source changed; reconcile before resuming" unless state["source_digest"] == digest
56
+
57
+ state
58
+ end
59
+
60
+ def save(state)
61
+ temporary = "#{@state_path}.#{Process.pid}.tmp"
62
+ File.open(temporary, File::WRONLY | File::CREAT | File::EXCL, 0o600) do |file|
63
+ file.write(JSON.generate(state))
64
+ file.flush
65
+ file.fsync
66
+ end
67
+ File.rename(temporary, @state_path)
68
+ File.open(File.dirname(@state_path), &:fsync)
69
+ end
70
+ end
@@ -3,5 +3,5 @@
3
3
  require "unisender"
4
4
 
5
5
  client = Unisender::Client.new(api_key: ENV.fetch("UNISENDER_API_KEY"))
6
- client.get_lists.result.each { |list| puts list.fetch("title") }
6
+ client.get_lists.each { |list| puts list.title }
7
7
  # Example output for a list named News: News
@@ -7,6 +7,6 @@ require "unisender"
7
7
  client = Unisender::Client.new(api_key: ENV.fetch("UNISENDER_API_KEY"))
8
8
  response = client.subscribe(
9
9
  list_ids: [ENV.fetch("UNISENDER_LIST_ID")],
10
- fields: { email: ENV.fetch("UNISENDER_CONTACT_EMAIL") }
10
+ email: ENV.fetch("UNISENDER_CONTACT_EMAIL")
11
11
  )
12
- puts response.result.fetch("person_id")
12
+ puts response.person_id
@@ -0,0 +1,104 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Unisender
4
+ AcceptanceEvent = Data.define(:operation, :outcome, :input_count, :accepted_count, :rejected_count,
5
+ :unknown_count, :row_warning_count, :request_id)
6
+
7
+ # Acceptance accounting is separate from HTTP and request-attempt outcomes.
8
+ module Acceptance
9
+ def self.input_count(operation, params)
10
+ case operation
11
+ when "sendEmail" then params[:cc] ? 2 : 1
12
+ when "sendSms"
13
+ phone = params[:phone]
14
+ phone.split(",").size if phone.is_a?(String)
15
+ when "importContacts"
16
+ data = params[:data]
17
+ data.size if data.is_a?(Array)
18
+ end
19
+ end
20
+
21
+ def self.event(operation, count, response, error)
22
+ accepted, rejected, unknown = bounded_counts(count, counts(count, response, error))
23
+ request_id = response&.headers&.[]("x-request-id") || (error.is_a?(Error) ? error.request_id : nil)
24
+ AcceptanceEvent.new(operation: Error.safe_operation(operation) || "request",
25
+ outcome: state(response, error, unknown), input_count: count,
26
+ accepted_count: accepted, rejected_count: rejected, unknown_count: unknown,
27
+ row_warning_count: warning_count(response), request_id:)
28
+ end
29
+
30
+ def self.bounded_counts(count, values)
31
+ return [nil, nil, nil] if values.compact.any?(&:negative?)
32
+
33
+ accepted, rejected, unknown = values
34
+ known = accepted.to_i + rejected.to_i + unknown.to_i
35
+ known > count ? [nil, nil, nil] : values
36
+ end
37
+
38
+ def self.warning_count(response)
39
+ return response.row_warnings.size if response.is_a?(ImportResponse)
40
+
41
+ response ? 0 : nil
42
+ end
43
+
44
+ def self.counts(count, response, error)
45
+ case response
46
+ when SendResponse then send_counts(count, response)
47
+ when ImportResponse then import_counts(count, response)
48
+ when EventResponse then [count, 0, 0]
49
+ when CollectionResponse then [response.size, 0, [count - response.size, 0].max]
50
+ else
51
+ error.is_a?(EventError) && error.http_status == 401 ? [0, count, 0] : [nil, nil, nil]
52
+ end
53
+ end
54
+
55
+ def self.send_counts(count, response)
56
+ accepted = response.email_id ? count : response.accepted_recipients.size
57
+ rejected = response.rejected_recipients.size
58
+ [accepted, rejected, [count - accepted - rejected, response.unknown_recipients.size, 0].max]
59
+ end
60
+
61
+ def self.import_counts(count, response)
62
+ total = response.total
63
+ accepted = total && total >= response.invalid ? total - response.invalid : nil
64
+ [accepted, response.invalid, total ? [count - total, 0].max : nil]
65
+ end
66
+
67
+ def self.state(response, error, unknown)
68
+ return failure_state(error) if error
69
+ return :uncertain unless unknown&.zero?
70
+ return result_state(response) if response.is_a?(OutcomeResponse)
71
+
72
+ :complete
73
+ end
74
+
75
+ def self.failure_state(error)
76
+ error.is_a?(EventError) && error.http_status == 401 ? :rejected : :uncertain
77
+ end
78
+
79
+ def self.result_state(response)
80
+ return :complete if response.complete?
81
+ return :partial if response.partial?
82
+
83
+ :rejected
84
+ end
85
+
86
+ def self.emit(callback, event)
87
+ callback.call(event)
88
+ rescue StandardError
89
+ nil
90
+ end
91
+ end
92
+
93
+ # Optional callbacks expose acceptance independently of request attempts.
94
+ class Client
95
+ private
96
+
97
+ def observe_acceptance(operation, count, response, error)
98
+ callback = @configuration.acceptance_instrumentation
99
+ return unless callback && count
100
+
101
+ Acceptance.emit(callback, Acceptance.event(operation, count, response, error))
102
+ end
103
+ end
104
+ end
@@ -0,0 +1,47 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Unisender
4
+ # A snapshot of binary data; reading an IO never transfers ownership.
5
+ class Attachment
6
+ attr_reader :filename, :bytes
7
+
8
+ def initialize(filename:, bytes:)
9
+ if filename.empty? || filename.match?(/[\[\]\r\n\0]/)
10
+ raise ArgumentError, "Attachment filename must be nonempty without brackets or line breaks"
11
+ end
12
+
13
+ @filename = filename.dup.freeze
14
+ @bytes = bytes.b.freeze
15
+ freeze
16
+ end
17
+
18
+ def self.from_path(path, filename: File.basename(path), max_bytes: nil)
19
+ File.open(path, "rb") { |io| from_io(io, filename:, max_bytes:) }
20
+ rescue SystemCallError
21
+ raise ArgumentError, "Attachment file could not be read", cause: nil
22
+ end
23
+
24
+ def self.from_io(io, filename:, max_bytes: nil)
25
+ size = read_size(max_bytes)
26
+ bytes = size ? io.read(size) : io.read
27
+ raise ArgumentError, "Attachment IO must return a string" unless bytes.is_a?(String)
28
+ raise ValidationError.new(field: "attachments", code: :too_large) if max_bytes && bytes.bytesize > max_bytes
29
+
30
+ new(filename:, bytes:)
31
+ end
32
+
33
+ def self.read_size(max_bytes)
34
+ return nil if max_bytes.nil?
35
+ unless max_bytes.is_a?(Integer) && max_bytes.positive?
36
+ raise ValidationError.new(field: "max_bytes", code: :invalid_range)
37
+ end
38
+
39
+ max_bytes + 1
40
+ end
41
+ private_class_method :read_size
42
+
43
+ def inspect
44
+ "#<Unisender::Attachment>"
45
+ end
46
+ end
47
+ end
@@ -0,0 +1,115 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Unisender
4
+ # Completed responses can be inspected repeatedly without repeating requests.
5
+ class BatchReport
6
+ include Enumerable
7
+
8
+ attr_reader :responses
9
+
10
+ def initialize(responses, error: nil, failed_range: nil, offsets: nil)
11
+ @responses = responses.dup.freeze
12
+ @error = error
13
+ @failed_range = failed_range
14
+ @offsets = offsets&.dup&.freeze
15
+ freeze
16
+ end
17
+
18
+ def each(&block)
19
+ return responses.each unless block
20
+
21
+ responses.each(&block)
22
+ end
23
+
24
+ def complete?
25
+ @error.nil?
26
+ end
27
+
28
+ def completed_batches
29
+ responses.size
30
+ end
31
+
32
+ def partial?
33
+ !complete? && (!responses.empty? || failed_response&.partial? == true)
34
+ end
35
+
36
+ def uncertain?
37
+ error = @error
38
+ case error
39
+ when nil, ArgumentError then false
40
+ when PartialFailure
41
+ response = failed_response
42
+ response.nil? || response.uncertain?
43
+ when EventError then error.report.uncertain?
44
+ else true
45
+ end
46
+ end
47
+
48
+ def failures
49
+ response = failed_response
50
+ return [] unless response
51
+
52
+ offset = @failed_range&.begin || 0
53
+ response.failures.map { |failure| failure.with_offset(offset) }
54
+ end
55
+
56
+ def row_warnings
57
+ # @type var warnings: Array[Failure]
58
+ warnings = []
59
+ # @type var offset: Integer?
60
+ offset = 0
61
+ offsets = @offsets
62
+ responses.each_with_index do |response, index|
63
+ next unless response.is_a?(ImportResponse)
64
+
65
+ warnings.concat(warning_rows(response, offsets ? offsets.fetch(index) : offset))
66
+ count = response.total
67
+ offset = offset && count ? offset + count : nil
68
+ end
69
+ warnings + failed_warnings
70
+ end
71
+
72
+ def total
73
+ results = import_results
74
+ return nil unless results
75
+
76
+ totals = results.map(&:total)
77
+ totals.include?(nil) ? nil : totals.compact.sum
78
+ end
79
+
80
+ def invalid
81
+ import_results&.sum(&:invalid)
82
+ end
83
+
84
+ private
85
+
86
+ def failed_warnings
87
+ failed = failed_response
88
+ failed.is_a?(ImportResponse) ? warning_rows(failed, @failed_range&.begin) : []
89
+ end
90
+
91
+ def warning_rows(response, offset)
92
+ response.row_warnings.map do |warning|
93
+ offset ? warning.with_offset(offset) : Failure.new(warning.to_h.merge("index" => nil))
94
+ end
95
+ end
96
+
97
+ def failed_response
98
+ error = @error
99
+ return nil unless error.is_a?(PartialFailure)
100
+
101
+ response = error.response
102
+ response if response.is_a?(OutcomeResponse)
103
+ end
104
+
105
+ def import_results
106
+ return nil if @error && !failed_response
107
+
108
+ all = responses.dup
109
+ failed = failed_response
110
+ all << failed if failed
111
+ results = all.filter_map { |response| response.result if response.is_a?(ImportResponse) }
112
+ results if results.size == all.size
113
+ end
114
+ end
115
+ end