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/execution.md ADDED
@@ -0,0 +1,89 @@
1
+ # Consumption, ownership, and recovery
2
+
3
+ `client.get_lists.each` iterates the fetched response without a network request. An all-page iterator explicitly requests more pages:
4
+
5
+ ```ruby
6
+ client.each_message(date_from: "2026-01-01", date_to: "2026-01-02")
7
+ .lazy.map(&:id).take(10).force
8
+ ```
9
+
10
+ `map` alone consumes an entire Enumerable immediately. Use `.lazy.map` for early stopping, or `.to_a` when you intend to retain every item. Each new enumeration of a page iterator starts another read sequence. Page offsets do not promise a stable server snapshot.
11
+
12
+ ```ruby
13
+ client.each_page(:get_messages, date_from: "2026-01-01", date_to: "2026-01-02") do |page|
14
+ process_page(page)
15
+ save_next_offset(page.next_offset) # Save only after processing succeeds.
16
+ end
17
+ ```
18
+
19
+ Batch commands such as `import_contacts_in_batches` execute immediately. `each_import_batch` and `each_event_batch` execute when consumed. Re-enumerating them repeats mutations if the source can be replayed. A non-rewindable source may instead be exhausted. Do not use enumeration as a retry mechanism.
20
+
21
+ The [import example](../examples/import.rb) uses record hashes and reports failed source ranges. Persist the source and reconcile accepted rows before retrying an ambiguous failure. Row indices are zero-based. Do not interpret an HTTP success as complete acceptance or as recipient delivery.
22
+
23
+ The [export example](../examples/export.rb) saves the task UUID, resumes the saved job, bounds the download, and renames a completed `.part` file. Use private paths on the same filesystem for atomic renames. State persistence and starting a remote job cannot be one atomic transaction: if the process stops before the UUID is stored, reconcile the job before starting another. A partial file contains private contact data and needs the same access controls as the final file.
24
+
25
+ ## Shared request budgets
26
+
27
+ One `RateLimiter` coordinates clients in one Ruby process. It cannot coordinate worker processes, other applications using the same key, or callers sharing an egress IP. Route those calls through one application-owned request worker or gateway that serializes acquisition against a shared store and accounts for every request. Keep its policy aligned with the [upstream limits](https://www.unisender.com/ru/support/api/common/api-limits/).
28
+
29
+ Give each worker an explicit client and close it when the worker stops. Inject the client into application services. Do not construct persistent clients before forking. Keep contact data and raw error details out of logs; log the operation, category, and request ID instead.
30
+
31
+ ### Client ownership
32
+
33
+ | Client or resource | Lifetime and sharing |
34
+ | --- | --- |
35
+ | Default client | Creates a separate connection for each request; close it when its application owner stops. |
36
+ | Persistent client | Serializes requests on its built-in transport. Sharing one client does not increase request concurrency. |
37
+ | `client.with(...)` | Creates an independent client lifetime. An injected transport remains shared and caller-owned. |
38
+ | `with_execution` client | Borrows its parent's transport and expires when the block ends. Close the parent at its normal lifetime boundary. |
39
+ | Injected transports and callbacks | The application supplies any synchronization their implementation needs. |
40
+ | Forked worker | Construct clients after forking. Built-in persistent transports discard inherited connections on a PID change. |
41
+
42
+ Both API protocols send a User-Agent containing the gem and Ruby versions, without account or host information.
43
+
44
+ ## Cooperative execution scopes
45
+
46
+ ```ruby
47
+ token = Unisender::CancellationToken.new
48
+ client.with_execution(timeout: 30, cancellation: token,
49
+ instrumentation: ->(event) { record_timing(event.kind, event.duration) }) do |scoped|
50
+ scoped.each_import_batch(records: records) { |response| process_result(response) }
51
+ end
52
+ # Another thread can call token.cancel.
53
+ ```
54
+
55
+ The timeout is one monotonic budget for the scope. Requests, retry delays, pacing, task polling, and batch boundaries use that budget. Nested scopes also obey their parent's deadline and cancellation. The scoped client borrows the parent's transport; closing it does not close the parent. Scoped clients and derived clients cannot be used after the block ends.
56
+
57
+ Cancellation and deadlines are cooperative. They do not forcibly interrupt an in-flight request, callback, or custom transport. Built-in downloads check between streamed chunks and preserve confirmed bytes in the error. Successful in-flight mutations return their result; the next boundary stops further work. Built-in socket timeouts are reduced to the remaining budget before IO. Do not use this scope as a hard wall-clock timeout. `CancelledError` and `DeadlineExceededError` have separate categories. Batch failures retain completed work, including when cancellation occurs before another batch.
58
+
59
+ The scope's instrumentation emits payload-free `ExecutionEvent` values for `workflow`, `retry_wait`, `rate_limit_wait`, and `poll_wait`. Existing request instrumentation measures individual network attempts and includes the upstream request ID when available. Observer exceptions do not change the operation's result. Progress callbacks are application work, so their exceptions stop the job with completed-work context.
60
+
61
+ ## Acceptance metrics
62
+
63
+ Pass `acceptance_instrumentation: ->(event) { record_acceptance(event) }` when constructing a client.
64
+ The callback receives an immutable `AcceptanceEvent` for each attempted named send, import, or Event Tracking request.
65
+ Reads and offline validation do not emit acceptance events.
66
+
67
+ Events contain `operation`, `outcome`, `input_count`, `accepted_count`, `rejected_count`, `unknown_count`, `row_warning_count`, and `request_id`.
68
+ Counts unavailable from the response are `nil`. They are not inferred from HTTP success.
69
+ An Event Tracking HTTP 400 response can mean partial acceptance, with unknown counts.
70
+ Transport failures also leave acceptance counts unknown. HTTP 401 rejects the event batch.
71
+ Outcomes are `:complete`, `:partial`, `:rejected`, or `:uncertain`.
72
+
73
+ Acceptance describes provider acknowledgement, not delivery to a recipient.
74
+ Request `instrumentation:` separately reports network attempts and can report success when acceptance is rejected.
75
+ Callbacks receive no payload, contact address, credential, or response body. Callback exceptions do not change operation results.
76
+
77
+ ## Retaining large jobs
78
+
79
+ Full `BatchReport` history remains the default. For large imports or event streams, select `retention: :summary` on either an immediate or lazy batch helper. Immediate calls return a non-enumerable `BatchSummary`; lazy calls still yield each response but retain no successful response bodies internally.
80
+
81
+ ```ruby
82
+ summary = client.import_contacts_in_batches(
83
+ records: records, retention: :summary,
84
+ on_progress: ->(progress) { save_checkpoint(progress.next_index) }
85
+ )
86
+ puts summary.completed_items
87
+ ```
88
+
89
+ `BatchProgress` snapshots contain completed batch and input counts, next source index, invalid count, warning count, and the current `response`. Counts describe completed batches. Summary failures retain the failed response and its row details. Successful warning details and response payloads are discarded, so persist anything you need in the response or progress callback. Retaining progress snapshots also retains their responses. Applications can still retain unbounded data themselves, for example by calling `to_a` on a lazy batch helper.
@@ -0,0 +1,41 @@
1
+ # Recovering imports
2
+
3
+ Keep source rows and application IDs stable for the whole job. Store them in private application storage.
4
+ The [RecoverableImport example](../examples/import_recovery.rb) accepts rows with separate application IDs and API fields.
5
+ It persists the source checksum, next completed index, and IDs of the in-flight rows.
6
+
7
+ Run it from an application file with an injected client:
8
+
9
+ ```ruby
10
+ require "unisender"
11
+ require_relative "import_recovery"
12
+
13
+ rows = [
14
+ { "id" => "customer-42", "fields" => { "email" => "person@example.test", "Name" => "Example" } }
15
+ ]
16
+ client = Unisender::Client.from_env
17
+ completed = RecoverableImport.new(client, "/private/job-42.json").run(rows)
18
+ puts "Completed rows: #{completed}"
19
+ ```
20
+
21
+ Use a writable private directory on a filesystem that supports atomic rename. Only one worker may own the checkpoint.
22
+ For concurrent workers or transactional guarantees, use a database job row and lock, as in the [Rails integration](rails.md).
23
+ Every row must contain the same API field keys. Application IDs are not sent to UniSender.
24
+
25
+ The example writes intent before sending each batch. It advances the completed index only after complete acceptance.
26
+ The response must report zero invalid rows and a total equal to the submitted batch size.
27
+ Missing or inconsistent totals preserve the in-flight marker for reconciliation.
28
+ A restart with an in-flight marker refuses to send. This covers a timeout, partial response, callback failure, or process crash after sending.
29
+
30
+ 1. Read the persisted application IDs and the original source. Keep the source order unchanged.
31
+ 2. Inspect `PartialFailure#response` privately for known acceptance and failure details, when available. Row positions are zero-based within that request.
32
+ 3. Reconcile every in-flight row against the account. A missing response does not prove rejection.
33
+ 4. Record the reconciliation decision in application storage. Build a separate job for confirmed rejected or unconsumed rows.
34
+
35
+ Never clear the marker solely because a request failed. A completed batch can already have changed the account.
36
+ Source checksums detect accidental edits; they do not provide remote idempotency.
37
+ For `BatchError`, `failed_range` identifies source positions, not safe retry input. `error.diagnostics` is safe for logs; raw reports contain contact data.
38
+
39
+ Application job IDs provide correlation across logs, checkpoints, and manual reconciliation.
40
+ For direct email, carry an application reference in documented metadata and retain returned message IDs.
41
+ Do not treat `ref_key` as an idempotency guarantee.
@@ -0,0 +1,34 @@
1
+ # Staging contract checks
2
+
3
+ Offline fixtures establish deterministic behavior. They do not establish the response shapes or permissions of a live account. The repository has not run this harness against an authorized account.
4
+
5
+ Obtain explicit permission and use a designated test account before running either command. Set `UNISENDER_API_KEY` privately in your environment. Do not save it in a fixture or command transcript.
6
+
7
+ ```sh
8
+ UNISENDER_LIVE=1 UNISENDER_LIVE_MODE=read bundle exec ruby -r ./scripts/live_contracts -e 'LiveContracts.run(ENV, $stdout)'
9
+ ```
10
+
11
+ Read mode requests lists, fields, and tags. Mutation mode creates one randomly named empty list, reads it back, and deletes that exact list in `ensure`:
12
+
13
+ ```sh
14
+ UNISENDER_LIVE=1 UNISENDER_LIVE_MODE=mutation bundle exec ruby -r ./scripts/live_contracts -e 'LiveContracts.run(ENV, $stdout)'
15
+ ```
16
+
17
+ Record the date, Ruby version, phase, service environment, and observed contract failures. Keep response bodies private. An interrupted process can leave its created list behind; reconcile that list before rerunning. The harness does not send messages, change contacts, register webhooks, or validate webhook delivery. Those need a separately approved account scenario and receiver.
18
+
19
+ ## Follow-up evidence gates
20
+
21
+ These scenarios remain pending explicit authorization and a designated test account.
22
+ Approve the exact sender, recipients, lists, and receiver before any send or contact mutation.
23
+
24
+ | Case | Approved action needed | Evidence to retain |
25
+ | --- | --- | --- |
26
+ | L1 | Run the read-only harness | Successful typed results, HTTP status, tested account environment |
27
+ | L2 | Create and delete one empty test list | Exact created ID and confirmed cleanup |
28
+ | L3 | Schedule one message with an offset Time | Stored UTC minute, campaign ID, account timezone, final cancellation or completion |
29
+ | L4 | Send SMS to one and multiple owned test numbers | Object/array wire cardinality and receipt IDs |
30
+ | L5 | Submit a controlled partially invalid email/import batch | Accepted/rejected/unknown counts, warning paths, zero automatic replay |
31
+ | L6 | Register a temporary HTTPS receiver and trigger an owned test delivery | Authentication, duplicate handling, status projection, registration cleanup |
32
+
33
+ Record timestamps and library/Ruby versions. Retain only sanitized findings in this repository.
34
+ Keep raw provider responses in private test storage. A green offline suite does not close these gates.
@@ -0,0 +1,129 @@
1
+ # Migrating to 0.2
2
+
3
+ All changes on this branch remain unreleased. Execution scopes, summary retention, and webhooks are opt-in. `RequestEvent` adds a `request_id` field. Full batch history remains the default; summary mode discards successful payloads and successful row-warning details. See [execution](execution.md) and [webhooks](webhooks.md).
4
+
5
+ Named operations return typed records and collections. `request` retains raw JSON results.
6
+
7
+ ## Result access
8
+
9
+ `send_sms` always returns a collection. Replace `response.sms_id` with `response.fetch(0).sms_id` for a single recipient.
10
+ Phone arrays are accepted; the raw upstream object or array remains available through `response.data`.
11
+
12
+ `JSON.generate(response)` serializes the typed payload. Use `response.data` when you need the upstream envelope.
13
+ Collections support slices and fetch defaults; object responses provide `to_h`.
14
+ `as_json` and `to_json` honor top-level `only:` and `except:` filters and reject unsupported options.
15
+ Contact address strings are available through `email_address` and `phone_number`, independent of nested detail shapes.
16
+
17
+ ```ruby
18
+ # Before
19
+ client.get_lists.result.each { |list| puts list.fetch("title") }
20
+ client.create_list(title: "News").result.fetch("id")
21
+
22
+ # After
23
+ client.get_lists.each { |list| puts list.title }
24
+ client.create_list(title: "News").id
25
+ ```
26
+
27
+ Use `response.result` for the typed result and `response.data` for the original JSON envelope.
28
+ Records retain string-key lookup and unknown fields. `to_h` returns a detached mutable copy.
29
+ Record equality requires the same concrete class. Use `to_h` when comparing a record with a Hash.
30
+ Equivalent records work as Hash keys and in Sets. Object responses also expose `key?`, `keys`, and `values`.
31
+
32
+ ## HTTP metadata
33
+
34
+ Replace `response.status` with `response.http_status` when reading the HTTP code.
35
+ `response.status` exposes a documented result status when the operation supplies one.
36
+ Headers and warnings retain their existing names.
37
+
38
+ ## Predicates and singular results
39
+
40
+ Use `contact_in_lists?` in conditionals. It returns a boolean and accepts `condition: :any` or `:all`.
41
+ The response-returning `is_contact_in_lists` method remains available.
42
+
43
+ `get_message(id:)` returns one message. Empty results raise `NotFoundError`; multiple results raise `InvalidResponseError`.
44
+ Count fields use integers. Tabular visited-link results become enumerable records keyed by the returned column names.
45
+
46
+ ## Execution and pagination
47
+
48
+ Page iterators now execute supplied blocks. Avoid passing a block unless you intend to consume the iterator immediately.
49
+ Successful pages expose checkpoint metadata through `offset`, `limit`, and `next_offset`.
50
+ Completed task handles return immediately from `wait`; a supplied callback receives the existing handle once.
51
+ Failed handles also invoke the callback once, then raise `TaskFailedError` without another request.
52
+
53
+ `import_contacts_in_batches` and `track_events_in_batches` execute immediately and return a `BatchReport`.
54
+ Use `each_import_batch` and `each_event_batch` for lazy execution. Each accepts a block or returns an Enumerator.
55
+ Reading a report repeatedly does not repeat requests.
56
+
57
+ Named paginated reads request 100 items unless you supply `limit`.
58
+ Use a fetched page's `each_page` or `auto_each` to continue from that page without refetching it.
59
+ Named iterators such as `each_message` yield typed items across pages and accept a block.
60
+ Invalid pagination arguments raise when constructing the iterator. HTTP requests remain lazy.
61
+ Pagination errors expose `next_offset` and `completed_pages` for recovery. Direct paginated reads now reject invalid limits and offsets before making requests.
62
+
63
+ ## Inputs and tasks
64
+
65
+ Named methods reject wrong scalar and collection types before a request. Use arrays or comma-separated strings for list IDs and field IDs.
66
+ `client.validate!(operation, **arguments)` checks a named request, Event Tracking, or a campaign workflow without networking.
67
+ It applies the same defaults and structural rules as execution. Import validation consumes its source; recreate an enumerable before sending.
68
+
69
+ Campaign `Time` inputs use UTC with `timezone: "UTC"`. Seconds are truncated to the API's minute precision.
70
+ Conflicting timezone arguments raise `ValidationError`. String inputs retain their explicit or account-default timezone.
71
+
72
+ Named flags reject values outside booleans and 0/1 before networking. Known closed choices reject unsupported values.
73
+ Library validation failures expose structured fields through `ValidationError`, while remaining rescuable as `ArgumentError`.
74
+ `error.diagnostics` provides safe structured context for validation, transport, API, task, and batch failures.
75
+ Paths identify documented fields and row positions. Dynamic field names are masked; raw payload access remains explicit.
76
+ `send_email` defaults `error_checking` to true. Use recipient results for IDs; explicit false retains the legacy format.
77
+ `accepted_ids` works with both formats. Recipient views retain rejected and unknown outcomes separately.
78
+ Named `reply_to:` and `priority:` headers reject conflicts. Unsupported `images_as` values fail locally.
79
+ List/tag arrays in import records become comma-separated scalar cells. Subscription-time arrays retain their list-ID order.
80
+
81
+ Supply `email:` directly to `subscribe`, or continue using `fields:`.
82
+ Subscription modes accept `:request_confirmation`, `:already_confirmed`, and `:confirm_if_needed`.
83
+ Overwrite modes accept `:preserve`, `:replace`, and `:update`. Numeric modes retain their meanings.
84
+
85
+ Imports accept `records:` with consistent keys, including `email`. This input is mutually exclusive with `field_names:` and `data:`.
86
+ Record key order may differ; the first record determines column order. Missing columns must be supplied explicitly.
87
+ Arrays validate before sending. Streaming input validates as consumed.
88
+
89
+ Timestamp arguments accept `Time` and convert it to the operation's UTC format. String inputs pass through unchanged.
90
+ `ValidationError` provides `field`, `code`, and optional `operation` without including private values.
91
+
92
+ Export and delivery-report responses are task handles. Call `job.wait` explicitly to poll.
93
+ A block passed to `wait` receives each observed state. Polling deadlines retain the last response and task UUID. Request failures during polling preserve their original exception class and add `task_uuid` and `last_response`.
94
+
95
+ ## Partial success
96
+
97
+ Import, send, and event responses expose `complete?`, `partial?`, `uncertain?`, and `failures`.
98
+ These describe request acceptance, not final message delivery. Call `raise_for_partial_failure!` to reject incomplete acceptance explicitly.
99
+
100
+ `BatchError#report` retains completed responses, known import totals, and failures with global input indices.
101
+ Unknown acceptance remains unknown; the client does not count an uncertain request as rejected.
102
+ Event batches respect the encoded payload limit as well as the event-count limit.
103
+ Streaming input failures retain completed batches. No failed request or input chunk is replayed automatically.
104
+
105
+ ## Configuration and diagnostics
106
+
107
+ Pass timeout and retry options directly to `Client.new`, or retain explicit transport and policy objects.
108
+ Combining an explicit object with its construction options raises `ArgumentError`.
109
+ `Client.from_env` reads the API key without loading files. `client.with(...)` leaves the original unchanged.
110
+
111
+ `message_defaults` supplies sender name, sender email, and message language for message creation and direct email sends.
112
+ Explicit arguments override defaults; explicit `nil` suppresses a default.
113
+
114
+ Use `error.category`, `error.recovery_hint`, and `error.request_id` for diagnosis.
115
+ `error.transient?` describes the failure. `error.safe_to_retry?` also checks the operation.
116
+ Neither enables retries automatically.
117
+
118
+ The optional instrumentation callback receives safe metadata for each attempt.
119
+ See [application testing](testing.md) for deterministic responses and failure scenarios.
120
+
121
+ ## Optional helpers
122
+
123
+ Use `persistent: true` for connection reuse and `client.close` when the client is no longer needed.
124
+ An injected transport remains caller-owned. Request pacing is opt-in through `rate_limiter:` and is independent of retry settings.
125
+
126
+
127
+ Attachments accept `Attachment.from_path`, `Attachment.from_io`, one attachment, or an array, alongside existing hashes.
128
+ `send_campaign(message:, campaign:)` returns both responses and retains a created message if the campaign step fails.
129
+ Completed tasks expose `download_to(io, max_bytes:)`. Downloads never happen automatically; errors retain confirmed partial byte counts.
data/docs/rails.md ADDED
@@ -0,0 +1,248 @@
1
+ # Rails integration
2
+
3
+ This example connects a background import, durable recovery state, and an authenticated delivery receiver.
4
+ The gem has no Rails dependency. Add it to your application as described in the [README](../README.md).
5
+ The example uses Rails 8.1 and a database with transactions and unique indexes.
6
+
7
+ ## Store recovery and delivery state
8
+
9
+ Add `db/migrate/20261026000000_create_unisender_state.rb`, then run `bin/rails db:migrate`:
10
+
11
+ <!-- file: db/migrate/20261026000000_create_unisender_state.rb -->
12
+ ```ruby
13
+ class CreateUnisenderState < ActiveRecord::Migration[8.1]
14
+ def change
15
+ create_table :import_runs do |t|
16
+ t.json :source, null: false
17
+ t.integer :next_index, null: false, default: 0
18
+ t.json :in_flight
19
+ t.string :status, null: false, default: "ready"
20
+ t.json :diagnostics
21
+ end
22
+ create_table :delivery_events do |t|
23
+ t.string :fingerprint, null: false
24
+ t.string :account, null: false
25
+ t.json :payload, null: false
26
+ t.datetime :processed_at
27
+ end
28
+ add_index :delivery_events, :fingerprint, unique: true
29
+ create_table :email_deliveries do |t|
30
+ t.string :account, null: false
31
+ t.string :email_id, null: false
32
+ t.string :status
33
+ t.datetime :observed_at
34
+ end
35
+ add_index :email_deliveries, [:account, :email_id], unique: true
36
+ end
37
+ end
38
+ ```
39
+
40
+ Add these models to `app/models`:
41
+
42
+ <!-- file: app/models/import_run.rb -->
43
+ ```ruby
44
+ class ImportRun < ApplicationRecord
45
+ attr_readonly :source
46
+ end
47
+ ```
48
+
49
+ <!-- file: app/models/delivery_event.rb -->
50
+ ```ruby
51
+ class DeliveryEvent < ApplicationRecord
52
+ end
53
+ ```
54
+
55
+ <!-- file: app/models/email_delivery.rb -->
56
+ ```ruby
57
+ class EmailDelivery < ApplicationRecord
58
+ end
59
+ ```
60
+
61
+ Store source rows as `{"id" => "customer-42", "fields" => {"email" => "person@example.test"}}`.
62
+ Keep all row fields consistent. Application IDs remain local; only `fields` are sent.
63
+ Do not edit an import's source after creating it.
64
+
65
+ ## Import through an injected client
66
+
67
+ Add `app/services/import_processor.rb`:
68
+
69
+ <!-- file: app/services/import_processor.rb -->
70
+ ```ruby
71
+ class ImportProcessor
72
+ def initialize(run, client:)
73
+ @run, @client = run, client
74
+ end
75
+
76
+ def call
77
+ while (work = claim)
78
+ batch, offset = work
79
+ response = @client.import_contacts(records: batch.map { |row| row.fetch("fields") }).raise_for_partial_failure!
80
+ raise Unisender::PartialFailure, response unless response.total == batch.size
81
+ @run.with_lock do
82
+ @run.update!(next_index: offset + batch.size, in_flight: nil, status: "ready")
83
+ end
84
+ end
85
+ rescue Unisender::Error => error
86
+ @run.update!(status: "needs_review", diagnostics: error.diagnostics)
87
+ raise
88
+ end
89
+
90
+ private
91
+
92
+ def claim
93
+ @run.with_lock do
94
+ raise "Reconcile in-flight rows before resuming" if @run.in_flight
95
+ batch = @run.source.slice(@run.next_index, 500)
96
+ if batch.empty?
97
+ @run.update!(status: "completed")
98
+ nil
99
+ else
100
+ @run.update!(status: "running", in_flight: batch.map { |row| row.fetch("id") })
101
+ [batch, @run.next_index]
102
+ end
103
+ end
104
+ end
105
+ end
106
+ ```
107
+
108
+ The claim transaction commits before the network call. A crash leaves `in_flight` intact.
109
+ A duplicate job cannot send that batch again. A completed batch advances the checkpoint in another transaction.
110
+ Never wrap the whole import in a database transaction, because rollback would erase the recovery marker.
111
+ Keep each job's source and checkpoints on the primary database, not an eventually consistent replica.
112
+
113
+ Add `app/jobs/import_contacts_job.rb`:
114
+
115
+ <!-- file: app/jobs/import_contacts_job.rb -->
116
+ ```ruby
117
+ class ImportContactsJob < ApplicationJob
118
+ def perform(run_id)
119
+ Unisender::Client.open(api_key: Rails.application.credentials.fetch(:unisender_api_key)) do |client|
120
+ ImportProcessor.new(ImportRun.find(run_id), client: client).call
121
+ end
122
+ end
123
+ end
124
+ ```
125
+
126
+ Start the job from application code with `ImportContactsJob.perform_later(run.id)`.
127
+ Use your application's durable Active Job backend. Do not configure automatic mutation retries.
128
+ If a backend redelivers a crashed job, the persisted marker stops the remote call.
129
+ Reconcile marked rows using the [import recovery procedure](import-recovery.md), then create a separate job for safe remaining work.
130
+ `diagnostics` contains safe metadata; `source`, `in_flight`, and response payloads are private application data.
131
+
132
+ ## Authenticate and persist webhook events
133
+
134
+ Add this route inside `Rails.application.routes.draw` in `config/routes.rb`:
135
+
136
+ ```ruby
137
+ match "/unisender", to: "unisender_webhooks#receive", via: [:get, :post]
138
+ ```
139
+
140
+ Add `app/controllers/unisender_webhooks_controller.rb`:
141
+
142
+ <!-- file: app/controllers/unisender_webhooks_controller.rb -->
143
+ ```ruby
144
+ require "unisender/webhooks"
145
+
146
+ class UnisenderWebhooksController < ActionController::API
147
+ def receive
148
+ return head :ok if request.get?
149
+ notification = Unisender::Webhooks.verify(
150
+ body: request.body.read(1_048_577),
151
+ api_key: Rails.application.credentials.fetch(:unisender_api_key),
152
+ content_encoding: request.get_header("HTTP_CONTENT_ENCODING")
153
+ )
154
+ DeliveryEvent.transaction do
155
+ notification.users.each do |user|
156
+ user.events.each do |event|
157
+ value = [user.login, canonical(event.to_h)]
158
+ fingerprint = Digest::SHA256.hexdigest(JSON.generate(value))
159
+ DeliveryEvent.create_or_find_by!(fingerprint: fingerprint) do |record|
160
+ record.account = user.login
161
+ record.payload = event.to_h
162
+ end
163
+ end
164
+ end
165
+ end
166
+ head :ok
167
+ rescue Unisender::WebhookError
168
+ head :bad_request
169
+ end
170
+
171
+ private
172
+
173
+ def canonical(value)
174
+ case value
175
+ when Hash then value.sort.to_h.transform_values { |child| canonical(child) }
176
+ when Array then value.map { |child| canonical(child) }
177
+ else value
178
+ end
179
+ end
180
+ end
181
+ ```
182
+
183
+ GET acknowledges registration only. POST verifies the original body before parsing it into application records.
184
+ Set a matching request-body limit in the web server. Return HTTP 200 only after the event transaction commits.
185
+ The unique fingerprint suppresses repeated event content, even when a batch is regrouped or object keys change order.
186
+ This is an application deduplication rule, not a provider event identifier. Retain the fingerprint for your full replay window.
187
+ Serve this endpoint over HTTPS. Exclude webhook bodies from request logging and error-reporting attachments.
188
+
189
+ Add the payload keys to `config/initializers/filter_parameter_logging.rb`:
190
+
191
+ ```ruby
192
+ Rails.application.config.filter_parameters += [:auth, :api_key, :events_by_user]
193
+ ```
194
+
195
+ Configure external error and HTTP logging tools separately; Rails parameter filters do not control those tools.
196
+
197
+ ## Process persisted events separately
198
+
199
+ Add `app/jobs/process_delivery_events_job.rb`:
200
+
201
+ <!-- file: app/jobs/process_delivery_events_job.rb -->
202
+ ```ruby
203
+ class ProcessDeliveryEventsJob < ApplicationJob
204
+ def perform
205
+ DeliveryEvent.where(processed_at: nil).find_each do |event|
206
+ event.with_lock do
207
+ next if event.processed_at
208
+ apply_status(event)
209
+ event.update!(processed_at: Time.current)
210
+ end
211
+ end
212
+ end
213
+
214
+ private
215
+
216
+ def apply_status(event)
217
+ payload = event.payload
218
+ return unless payload["event_name"] == "email_status"
219
+ data = payload.fetch("event_data")
220
+ return unless data["email_id"]
221
+ delivery = EmailDelivery.create_or_find_by!(account: event.account, email_id: data.fetch("email_id").to_s)
222
+ observed = Time.find_zone!("UTC").parse(payload.fetch("event_time"))
223
+ delivery.with_lock do
224
+ if delivery.observed_at.nil? || observed > delivery.observed_at
225
+ delivery.update!(status: data.fetch("status"), observed_at: observed)
226
+ end
227
+ end
228
+ end
229
+ end
230
+ ```
231
+
232
+ Schedule this job periodically through your job backend. It also recovers events persisted before a process crash.
233
+ Event processing and the processed marker commit together. Repeated processing does not repeat the state change.
234
+ The timestamp guard prevents older notifications from replacing newer status.
235
+ This example projects direct-email delivery, using IDs returned by `send_email.accepted_ids`.
236
+ Campaign notifications use `campaign_id` and recipient email; add a separate projection when your application needs campaign delivery.
237
+ Equal timestamps keep the first observed status. Define a domain-specific precedence if your application needs finer ordering.
238
+
239
+ Receipt and processing are separate: the receiver persists, then the worker updates application state.
240
+ Database failures produce a non-200 response and allow provider redelivery. Authentication failures never create event records.
241
+ Signature details and provider limitations are in the [webhook guide](webhooks.md).
242
+
243
+ ## Test without an account
244
+
245
+ Inject `Unisender::Testing::Transport` into `ImportProcessor` and queue `Fixtures.import_result` responses.
246
+ Test complete acceptance, partial acceptance, transport failure, duplicate jobs, and restart from an in-flight marker.
247
+ For the receiver, sign synthetic payloads with a fake key. Test invalid auth, duplicate delivery, reordered JSON keys, and older events.
248
+ See [testing support](testing.md) for request assertions and [serialization](usage.md#responses-and-failures) for `only:` and `except:`.
data/docs/releases.md CHANGED
@@ -9,11 +9,11 @@ Starting with 0.1.1, GitHub releases include the gem, an SPDX inventory of its p
9
9
  Download and verify a release in your terminal:
10
10
 
11
11
  ```sh
12
- gh release download v0.1.1 --repo svyatov/unisender-ruby --dir release
12
+ gh release download v0.2.0 --repo svyatov/unisender-ruby --dir release
13
13
  cd release
14
- gh attestation verify unisender-ruby.gem --repo svyatov/unisender-ruby --signer-workflow svyatov/unisender-ruby/.github/workflows/release.yml --source-ref refs/tags/v0.1.1
15
- gh attestation verify sbom.spdx.json --repo svyatov/unisender-ruby --signer-workflow svyatov/unisender-ruby/.github/workflows/release.yml --source-ref refs/tags/v0.1.1
16
- gh attestation verify SHA256SUMS --repo svyatov/unisender-ruby --signer-workflow svyatov/unisender-ruby/.github/workflows/release.yml --source-ref refs/tags/v0.1.1
14
+ gh attestation verify unisender-ruby.gem --repo svyatov/unisender-ruby --signer-workflow svyatov/unisender-ruby/.github/workflows/release.yml --source-ref refs/tags/v0.2.0
15
+ gh attestation verify sbom.spdx.json --repo svyatov/unisender-ruby --signer-workflow svyatov/unisender-ruby/.github/workflows/release.yml --source-ref refs/tags/v0.2.0
16
+ gh attestation verify SHA256SUMS --repo svyatov/unisender-ruby --signer-workflow svyatov/unisender-ruby/.github/workflows/release.yml --source-ref refs/tags/v0.2.0
17
17
  shasum -a 256 -c SHA256SUMS
18
18
  ```
19
19
 
data/docs/testing.md ADDED
@@ -0,0 +1,83 @@
1
+ # Testing application integrations
2
+
3
+ Load the optional fake transport explicitly. It has no runtime dependencies and never opens a socket.
4
+
5
+ ```ruby
6
+ require "unisender/testing"
7
+
8
+ transport = Unisender::Testing::Transport.new
9
+ transport.enqueue(operation: "getLists", data: {
10
+ "result" => [{ "id" => 42, "title" => "News" }]
11
+ })
12
+ client = Unisender::Client.new(api_key: "test-key", transport:)
13
+
14
+ raise "Unexpected lists" unless client.get_lists.map(&:id) == [42]
15
+ raise "Missing format" unless transport.requests.first.form["format"] == "json"
16
+ transport.verify!
17
+ ```
18
+
19
+ Each request consumes one queued response. Missing responses and operation mismatches raise immediately.
20
+ Call `verify!` at the end of a test to reject unused responses. Mismatch messages contain only recognized operation names.
21
+ Use `enqueue(data:, http_status:, headers:)` for API errors, HTTP failures, warnings, and partial results.
22
+ Use `enqueue_response` with an `HTTPResponse` for malformed JSON or an exception for transport failures.
23
+
24
+ Request inspection omits payloads. `form` excludes the API key.
25
+ Explicit `body`, `headers`, and `uri` access can expose credentials or contact data. Keep those values out of test failure output.
26
+ Queued fixtures should use fake addresses and keys.
27
+
28
+ ## Fixtures and request expectations
29
+
30
+ `Fixtures.list`, `Fixtures.import_result`, `Fixtures.task`, and `Fixtures.recipient` return result values without the outer `result` envelope.
31
+ Required keywords describe the minimum usable fixture; optional keywords add failures, IDs, and download URLs.
32
+
33
+ Add this expectation to an application test:
34
+
35
+ ```ruby
36
+ transport.enqueue(
37
+ operation: "importContacts",
38
+ expected_form: { "field_names[0]" => "email", "data[0][0]" => "person@example.test" },
39
+ data: { "result" => Unisender::Testing::Fixtures.import_result(total: 1) }
40
+ )
41
+ ```
42
+
43
+ `expected_form:` checks selected encoded fields. `expected_json:` checks nested object subsets and positional array prefixes.
44
+ Choose one per reply. Additional request fields are allowed; missing or different expected fields fail immediately.
45
+ Error messages contain a redacted field path, without expected or actual payload values. API keys cannot be form expectations.
46
+ Use exact request counts and `verify!` when order and extra requests matter.
47
+
48
+ For Event Tracking, use `transport.requests.last.json` to inspect the decoded JSON payload with its `events` array.
49
+ Like `body`, this reader contains contact data. Compare selected fields instead of printing the full payload.
50
+
51
+ ## Type checking in an application
52
+
53
+ The gem packages RBS under `sig/`, including `sig/manifest.yaml`. With Steep installed in your application's development bundle, use this target in `Steepfile`:
54
+
55
+ ```ruby
56
+ target :app do
57
+ check "app"
58
+ library "unisender-ruby"
59
+ library "net-http", "json", "uri", "openssl", "time", "ipaddr", "zlib", "stringio"
60
+ end
61
+ ```
62
+
63
+ Run `bundle exec steep check`. The gem includes its signatures; copying the library's source into your application is unnecessary.
64
+
65
+ ### Cursor setup
66
+
67
+ Use [Steep](https://github.com/soutaro/steep) in your development bundle and the publisher's [Steep extension](https://marketplace.visualstudio.com/items?itemName=soutaro.steep-vscode).
68
+ The extension repository is [soutaro/steep-vscode](https://github.com/soutaro/steep-vscode).
69
+ If Cursor's registry does not list it, download the publisher's VSIX from Visual Studio Marketplace and use **Extensions: Install from VSIX**.
70
+
71
+ 1. Add `gem "steep", group: :development` to your application's Gemfile, then run `bundle install`.
72
+ 2. Add the `Steepfile` target above, changing `check "app"` to your source directory.
73
+ 3. Open the project folder in Cursor and run **Steep: Restart** from the command palette.
74
+ 4. Use **Trigger Suggest** for methods, **Show or Focus Hover** for keywords and return types, and the Problems panel for invalid calls.
75
+
76
+ The extension launches `bin/steep langserver` when a binstub exists, otherwise `bundle exec steep langserver`.
77
+ Ensure Cursor uses the same Ruby and bundle as your terminal. A version-manager shim must resolve Ruby in the project directory.
78
+ On macOS, open the canonical folder path, such as `/private/tmp/project` rather than its `/tmp/project` symlink.
79
+ Mismatched document paths can hide diagnostics even while the language server runs.
80
+
81
+ Verified with Cursor 3.27.14, Steep extension 0.10.0, Steep 2.1.0, RBS 4.2.0, and Ruby 4.0.9.
82
+ The consumer used an installed 0.2.0 gem outside this checkout.
83
+ Observed in the editor: `get_*` completion, `CollectionResponse[List]` hover, required `title: String` keyword help, and rejection of `title: false`.