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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +89 -0
- data/CONTRIBUTING.md +10 -1
- data/README.md +55 -112
- data/docs/api.md +395 -210
- data/docs/execution.md +89 -0
- data/docs/import-recovery.md +41 -0
- data/docs/live-contracts.md +34 -0
- data/docs/migration-0.2.md +129 -0
- data/docs/rails.md +248 -0
- data/docs/releases.md +4 -4
- data/docs/testing.md +83 -0
- data/docs/usage.md +144 -0
- data/docs/webhooks.md +41 -0
- data/examples/campaign.rb +5 -6
- data/examples/campaign_lifecycle.rb +28 -0
- data/examples/check_connection.rb +1 -1
- data/examples/events.rb +1 -1
- data/examples/export.rb +20 -6
- data/examples/import.rb +3 -2
- data/examples/import_recovery.rb +70 -0
- data/examples/quickstart.rb +1 -1
- data/examples/subscribe.rb +2 -2
- data/lib/unisender/acceptance.rb +104 -0
- data/lib/unisender/attachment.rb +47 -0
- data/lib/unisender/batch_report.rb +115 -0
- data/lib/unisender/batch_summary.rb +87 -0
- data/lib/unisender/batches.rb +41 -12
- data/lib/unisender/campaign_workflow.rb +72 -0
- data/lib/unisender/client.rb +65 -33
- data/lib/unisender/client_configuration.rb +50 -0
- data/lib/unisender/client_execution.rb +43 -0
- data/lib/unisender/client_state.rb +31 -0
- data/lib/unisender/client_validation.rb +97 -0
- data/lib/unisender/configuration.rb +151 -0
- data/lib/unisender/contact_records.rb +251 -0
- data/lib/unisender/diagnostic_urls.rb +68 -0
- data/lib/unisender/diagnostics.rb +83 -0
- data/lib/unisender/download_target.rb +61 -0
- data/lib/unisender/download_transfer.rb +43 -0
- data/lib/unisender/downloader.rb +99 -0
- data/lib/unisender/error_diagnostics.rb +83 -0
- data/lib/unisender/errors.rb +125 -8
- data/lib/unisender/event_batches.rb +40 -0
- data/lib/unisender/event_payload.rb +50 -22
- data/lib/unisender/events.rb +21 -5
- data/lib/unisender/execution_context.rb +114 -0
- data/lib/unisender/form.rb +20 -12
- data/lib/unisender/helpers.rb +70 -10
- data/lib/unisender/hooks.rb +79 -0
- data/lib/unisender/import_cells.rb +62 -0
- data/lib/unisender/input_types.rb +30 -0
- data/lib/unisender/inputs.rb +262 -0
- data/lib/unisender/iteration.rb +15 -0
- data/lib/unisender/iterators.rb +60 -0
- data/lib/unisender/mail_inputs.rb +37 -0
- data/lib/unisender/named_inputs.rb +52 -0
- data/lib/unisender/observation.rb +30 -0
- data/lib/unisender/operation_contracts.rb +106 -0
- data/lib/unisender/operations/contacts_1.rb +4 -3
- data/lib/unisender/operations/contacts_2.rb +23 -4
- data/lib/unisender/operations/messages_1.rb +7 -4
- data/lib/unisender/operations/messages_2.rb +8 -3
- data/lib/unisender/operations/messages_3.rb +1 -1
- data/lib/unisender/operations/notes_1.rb +1 -1
- data/lib/unisender/operations/statistics_1.rb +3 -3
- data/lib/unisender/operations/templates_1.rb +2 -2
- data/lib/unisender/outcomes.rb +320 -0
- data/lib/unisender/page_response.rb +48 -0
- data/lib/unisender/paginated_operations.rb +43 -2
- data/lib/unisender/pagination.rb +53 -20
- data/lib/unisender/persistent_transport.rb +104 -0
- data/lib/unisender/rate_limiter.rb +72 -0
- data/lib/unisender/record.rb +210 -0
- data/lib/unisender/record_rows.rb +69 -0
- data/lib/unisender/records.rb +614 -0
- data/lib/unisender/request_execution.rb +108 -0
- data/lib/unisender/response.rb +13 -10
- data/lib/unisender/result_registry.rb +72 -0
- data/lib/unisender/results.rb +105 -0
- data/lib/unisender/retry_policy.rb +10 -16
- data/lib/unisender/serialization.rb +54 -0
- data/lib/unisender/statistic_records.rb +129 -0
- data/lib/unisender/task_response.rb +85 -0
- data/lib/unisender/task_waiter.rb +25 -11
- data/lib/unisender/testing/assertions.rb +48 -0
- data/lib/unisender/testing/fixtures.rb +24 -0
- data/lib/unisender/testing.rb +91 -0
- data/lib/unisender/transport.rb +17 -9
- data/lib/unisender/validation.rb +11 -9
- data/lib/unisender/version.rb +1 -1
- data/lib/unisender/webhooks/tokens.rb +38 -0
- data/lib/unisender/webhooks.rb +230 -0
- data/lib/unisender.rb +42 -0
- data/sig/acceptance.rbs +30 -0
- data/sig/attachment.rbs +15 -0
- data/sig/batch_summary.rbs +38 -0
- data/sig/campaign_workflow.rbs +20 -0
- data/sig/configuration.rbs +62 -0
- data/sig/connection_controls.rbs +55 -0
- data/sig/diagnostics.rbs +37 -0
- data/sig/domain_records.rbs +96 -0
- data/sig/downloads.rbs +53 -0
- data/sig/execution.rbs +52 -0
- data/sig/execution_context.rbs +46 -0
- data/sig/hooks.rbs +18 -0
- data/sig/inputs.rbs +84 -0
- data/sig/iterators.rbs +11 -0
- data/sig/mail_inputs.rbs +8 -0
- data/sig/manifest.yaml +11 -0
- data/sig/operation_contracts.rbs +28 -0
- data/sig/operations.rbs +73 -69
- data/sig/outcomes.rbs +90 -0
- data/sig/record.rbs +77 -0
- data/sig/records.rbs +182 -0
- data/sig/serialization.rbs +13 -0
- data/sig/tasks.rbs +26 -0
- data/sig/testing.rbs +54 -0
- data/sig/unisender.rbs +128 -39
- data/sig/webhooks.rbs +55 -0
- 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
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
)
|
|
12
|
-
|
|
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.
|
|
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.
|
|
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
|
|
4
|
-
#
|
|
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
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
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
|
-
|
|
10
|
-
).each { |response| puts "Imported batch: HTTP #{response.
|
|
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
|
data/examples/quickstart.rb
CHANGED
data/examples/subscribe.rb
CHANGED
|
@@ -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
|
-
|
|
10
|
+
email: ENV.fetch("UNISENDER_CONTACT_EMAIL")
|
|
11
11
|
)
|
|
12
|
-
puts response.
|
|
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
|