nafru-email 0.1.1

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 ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 6b0757004d9bfc0f06bd29e97e0ffb2a83ff5f57584415e4ee1245c500a5441a
4
+ data.tar.gz: 1b0bd6c50163282e5e2201783486da5743109bf501dfca6d092e83c82907a9f4
5
+ SHA512:
6
+ metadata.gz: aa7c5d92768166f66b849eb8542c35db61265fcf41100c2baea4c7a6f07c71f4fd3eab60060837da8fb445a903c42ce49fe8d714d126074c1bacb0cb3a335217
7
+ data.tar.gz: a14f1717cac80ebd6da7e48e5c9ea9342c30ff847a8551f77df76fed87755a2a1386468f38b8de92c862e55c97b10f5840c8e2664bd932f9472cc365ee59c579
data/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Nafru
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,316 @@
1
+ # Nafru Email Ruby SDK
2
+
3
+ The official **Nafru Email Ruby SDK** lets you send email from Ruby (and Rails) through the Nafru Email API. Use it for
4
+ transactional email (sign-up confirmations, password resets, receipts) and marketing broadcasts, to manage contacts, templates and
5
+ domains, to trigger automations with custom events, and to verify webhooks. The email API is Resend-compatible, so request and
6
+ response shapes will feel familiar if you are coming from Resend.
7
+
8
+ [![Gem Version](https://img.shields.io/gem/v/nafru-email)](https://rubygems.org/gems/nafru-email)
9
+ [![License: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](https://github.com/nafrucom/nafru-email-sdks/blob/main/ruby/LICENSE)
10
+
11
+ ## Requirements
12
+
13
+ - Ruby 3.1 or later
14
+ - No dependencies: only the standard library (`net/http`, `json`, `openssl`).
15
+
16
+ ## Installation
17
+
18
+ ```sh
19
+ gem install nafru-email
20
+ # or in your Gemfile: gem "nafru-email"
21
+ ```
22
+
23
+ ## Quick start: send an email
24
+
25
+ ```ruby
26
+ require "nafru_email"
27
+
28
+ nafru = NafruEmail.new("nafru_123")
29
+ # NafruEmail.new with no key reads the NAFRU_API_KEY environment variable.
30
+ # Optional settings: NafruEmail.new("nafru_123", base_url: "http://localhost:48787/api", max_retries: 2, timeout: 30)
31
+ # base_url defaults to Nafru::Email::DEFAULT_BASE_URL (https://email.nafru.com/api).
32
+
33
+ email = nafru.emails.send(
34
+ {
35
+ from: "Acme <hello@example.com>",
36
+ to: "ali@example.org",
37
+ subject: "Welcome",
38
+ html: "<p>Hello!</p>",
39
+ scheduled_at: "in 1 hour" # optional: ISO 8601 or a relative expression, up to 30 days ahead
40
+ },
41
+ idempotency_key: "signup-1042" # retrying with the same key returns the same email
42
+ )
43
+ email[:id]
44
+ ```
45
+
46
+ Body fields are `snake_case`, exactly as in the API (`reply_to`, `scheduled_at`, `first_name`); there is no conversion layer. Input
47
+ hashes may use symbol or string keys; responses are hashes with **symbol keys**. The full shapes live in `sig/nafru_email.rbs` (checkable
48
+ with Steep/RBS).
49
+
50
+ > `emails.send` and `events.send` shadow Ruby's `Object#send`; use `__send__` / `public_send` if you need dynamic dispatch.
51
+
52
+ ## No real sending in development (dev outbox)
53
+
54
+ When `NODE_ENV=development`, `emails.send` and `emails.batch` (and `emails.send` on a connection client) do **not** send anything.
55
+ Each email is written under `.nafru/emails/` as plain text (`.txt`, headers + body) plus an `.html` file with the same name, and the
56
+ file paths and plain text are printed to the console. The response is `{ id: "dev_…" }`. Change the folder with
57
+ `dev_outbox: "tmp/mail"`, or turn it off with `dev_outbox: false`.
58
+
59
+ ## Errors
60
+
61
+ Every failed call raises `Nafru::Email::Error`:
62
+
63
+ ```ruby
64
+ begin
65
+ nafru.emails.send({ to: "ali@example.org", text: "hi" })
66
+ rescue Nafru::Email::Error => e
67
+ e.status_code # Integer; nil on network errors and timeouts
68
+ e.name # validation_error, not_found, rate_limit_exceeded, application_error, timeout_error, …
69
+ e.message
70
+ e.errors # on 422: [{ field:, message: }]
71
+ e.retry_after # on 429: seconds to wait
72
+ end
73
+ ```
74
+
75
+ Configuration problems such as a missing API key (none passed and `NAFRU_API_KEY` unset) raise `ArgumentError`.
76
+
77
+ ## Retries, timeouts and idempotency
78
+
79
+ - `429` is retried for every method, honoring `retry-after` (the request was rejected before it was processed).
80
+ - `5xx` responses and network errors are retried for `GET`/`DELETE` (300 ms × 2ⁿ, at most 8 s).
81
+ - `POST`/`PATCH` are **not** retried on server errors, because the email could go out twice. Pass an `idempotency_key` and they are.
82
+ - Defaults: 2 retries (`max_retries:`) and a 30 s timeout (`timeout:`, in seconds).
83
+ - Custom transport (`transport:`): any object that responds to `call(method:, url:, headers:, body:, timeout:)` and returns a
84
+ `Nafru::Email::Response` (tests, proxies, persistent connection pools). The default `Net::HTTP` transport opens a new connection
85
+ per request.
86
+
87
+ ## Resources
88
+
89
+ | Resource | Methods |
90
+ | -------------------- | -------------------------------------------------------------------------------------------------------------- |
91
+ | `emails` | `send` `batch` `get` `list` `update` `cancel` |
92
+ | `domains` | `create` `get` `list` `update` `remove` `verify` |
93
+ | `contacts` | `create` `get` `list` `update` `remove`, `segments.{list,add,remove}`, `topics.{list,update}` (by id or email) |
94
+ | `contact_properties` | `create` `get` `list` `update` `remove` |
95
+ | `segments` | `create` `get` `list` `remove` `contacts` |
96
+ | `topics` | `create` `get` `list` `update` `remove` |
97
+ | `broadcasts` | `create` `get` `list` `update` `remove` `send` `cancel` |
98
+ | `templates` | `create` `get` `list` `update` `remove` `publish` `duplicate` `put_translation` `remove_translation` |
99
+ | `brand` | `get` `update`: workspace brand (`update` replaces the whole brand; read it with `get` first) |
100
+ | `webhooks` | `create` `get` `list` `update` `remove` |
101
+ | `api_keys` | `create` `list` `remove` |
102
+ | `events` | `send` `batch` `list`: custom events that trigger automations |
103
+
104
+ ## Pagination
105
+
106
+ Lists are cursor-based (`limit`, `after`, `before`). To walk every page (the enumerator raises if a page fails):
107
+
108
+ ```ruby
109
+ Nafru::Email.paginate(nafru.contacts.method(:list), limit: 100).each do |contact|
110
+ puts contact[:email]
111
+ end
112
+ ```
113
+
114
+ ## Templates
115
+
116
+ ```ruby
117
+ nafru.templates.create({ name: "Welcome", alias: "welcome", subject: "Welcome {{{NAME|friend}}}",
118
+ html: "<p>Hello {{{NAME|friend}}}</p>", brand: { mode: "inherit" } })
119
+ nafru.templates.publish("welcome")
120
+ nafru.emails.send({ from: "Acme <hello@acme.example>", to: "ali@example.org",
121
+ template: { id: "welcome", variables: { NAME: "Ali" } } })
122
+ ```
123
+
124
+ ## Recurring broadcasts and data sources
125
+
126
+ A broadcast with `recurrence` repeats on a schedule, and `data` / `data_source` fill the template's `{{{data.*}}}` variables:
127
+
128
+ ```ruby
129
+ nafru.broadcasts.create({
130
+ segment_id: "seg_…", from: "Acme <hello@acme.example>", subject: "Weekly digest",
131
+ html: "<p>Plan: {{{data.plan}}}</p>",
132
+ recurrence: { type: "weekly", days: [1], time: "09:00" }, # monthly { day:, time: } · interval { every_days:, time: }
133
+ data_source: { type: "url", url: "https://acme.example/digest.json", header_name: "x-key", header_value: "…" }
134
+ })
135
+ nafru.broadcasts.update("bc_…", { recurrence: nil }) # nil clears a field; omitted fields are left unchanged
136
+ nafru.broadcasts.send("bc_…", { data: { plan: "pro" } })
137
+ ```
138
+
139
+ Because responses are hashes, an unknown `type` (a new recurrence kind, for example) does not break parsing; it is returned as is.
140
+
141
+ ## Custom events
142
+
143
+ ```ruby
144
+ nafru.events.send({ name: "plan_upgraded", contact: { email: "ali@example.org" }, properties: { plan: "pro", seats: 3 } })
145
+ ```
146
+
147
+ ## Verifying webhook signatures
148
+
149
+ Webhooks are signed in the Svix / Standard Webhooks format. Use the **raw** request body; JSON that has been parsed and re-encoded
150
+ will not match the signature.
151
+
152
+ ```ruby
153
+ # Rails: request.raw_post; Rack: request.body.read
154
+ ok = Nafru::Email::Webhook.verify(
155
+ payload: request.raw_post,
156
+ headers: {
157
+ id: request.headers["svix-id"],
158
+ timestamp: request.headers["svix-timestamp"],
159
+ signature: request.headers["svix-signature"]
160
+ },
161
+ secret: ENV.fetch("NAFRU_WEBHOOK_SECRET") # the signing_secret from the webhooks.create response
162
+ )
163
+ head :bad_request unless ok
164
+ ```
165
+
166
+ Timestamps more than 5 minutes in the past or future are rejected (`tolerance_seconds:`). During key rotation several signatures may
167
+ be sent; one valid signature is enough. A malformed `secret` (no `whsec_` prefix, not base64) raises `ArgumentError`.
168
+
169
+ ## Connect with Nafru (for apps)
170
+
171
+ If you want your customers to send from **their own** Nafru Email account, don't ask them to paste an API key. Register your app in
172
+ Nafru Email (Developer → Apps) and use `NafruConnect`, which implements an OAuth flow with PKCE.
173
+
174
+ ```ruby
175
+ connect = NafruConnect.new(
176
+ client_id: ENV.fetch("NAFRU_CLIENT_ID"),
177
+ client_secret: ENV.fetch("NAFRU_CLIENT_SECRET") # server side only
178
+ # base_url: optional (defaults to https://email.nafru.com/api; use "http://localhost:48787/api" locally)
179
+ # web_url: the consent screen; defaults to base_url with the trailing /api removed
180
+ )
181
+
182
+ # 1) Redirect the customer. Store code_verifier and state in the session.
183
+ auth = connect.authorize_url(
184
+ redirect_uri: "https://myapp.example/nafru/callback",
185
+ state: session[:nafru_state] = SecureRandom.hex(16),
186
+ scopes: %w[emails.send templates.read senders.read connection.read]
187
+ )
188
+ session[:nafru_verifier] = auth[:code_verifier]
189
+ redirect_to auth[:url], allow_other_host: true
190
+
191
+ # 2) On the callback, check state and exchange the code for a connection.
192
+ grant = connect.exchange(code: params[:code], code_verifier: session[:nafru_verifier], redirect_uri: redirect_uri)
193
+ # Map grant[:connection_id] to the customer; store grant[:refresh_token] ENCRYPTED.
194
+
195
+ # 3) Call the API on the customer's behalf. The access token refreshes automatically; the refresh token CHANGES on every refresh.
196
+ connection = connect.connection(connection_id, refresh_token: load(connection_id)) do |tokens|
197
+ save(connection_id, tokens[:refresh_token]) # the old token is invalid until this is persisted
198
+ end
199
+
200
+ connection.status # ready, missing: ["domain", "sender", …], setup_url
201
+ connection.senders.list
202
+ connection.templates.list # the customer's templates + your app's bundle
203
+ connection.emails.send({ from: from, to: to, template: { id: "welcome", variables: {} } })
204
+ ```
205
+
206
+ - If the customer disconnected your app or the refresh token is invalid, `Error#name == "connection_revoked"`; ask the customer to
207
+ connect again.
208
+ - Concurrent threads sharing one connection object share a single refresh (single flight, `Mutex`). If several processes (Puma
209
+ workers, Sidekiq) use the same connection, keep the refresh token in a shared, consistent store and serialize refreshes with a
210
+ lock: reusing an old refresh token revokes the connection for security.
211
+ - If `on_token_refresh` raises, the call fails with `token_persist_failed`; the new token stays in memory and the next call retries
212
+ saving it. Don't call the same connection from inside the callback (the lock is not reentrant).
213
+ - Management endpoints (API keys, domains, contacts, broadcasts) cannot be called with a connection token; only sending, reading and
214
+ discovery are available.
215
+ - When the customer closes their account in your app, call `connect.revoke(refresh_token: token)`.
216
+
217
+ ## Deprecation warnings
218
+
219
+ If the API has deprecated this SDK version (`Deprecation` response header), the client prints a warning to stderr once per client.
220
+ The call itself is not affected. Deprecated versions and their sunset dates are listed in
221
+ [deprecations.json](https://github.com/nafrucom/nafru-email-sdks/blob/main/deprecations.json).
222
+
223
+ ## Security
224
+
225
+ - **Keep keys server-side.** Never embed your API key or `client_secret` in browser or mobile code; even a `sending_access` key
226
+ can send email. Store refresh tokens encrypted.
227
+ - **HTTPS enforced.** `base_url` and `web_url` must use `https`; plain `http` is accepted only for `localhost`, `*.localhost`,
228
+ `127.0.0.0/8` and `[::1]` (local development). Anything else raises `ArgumentError` at construction time. TLS certificates are
229
+ always verified; there is no option to turn that off.
230
+ - **Redirects refused.** The API never redirects, so a `3xx` response is not followed (that could leak `Authorization` to another
231
+ host); it raises `Nafru::Email::Error` with `name == "unexpected_redirect"` and is not retried.
232
+ - **Input checks.** Path parameters are percent-encoded and an empty, `.` or `..` segment raises `ArgumentError` before any request;
233
+ `idempotency_key` may not be empty or contain control characters (no header injection). Response bodies are capped at 10 MB
234
+ (`response_too_large`).
235
+ - **Secrets redacted.** `inspect`, `to_s` and `pp` of `NafruEmail`, `NafruConnect`, connections and `ConnectionGrant` never show the
236
+ API key, client secret or tokens (`[REDACTED]`); error messages never include them. Read the value explicitly
237
+ (`grant.refresh_token`) when you store it.
238
+ - **Webhooks.** Verify against the **raw** request body (`request.raw_post`); comparison is constant-time, timestamps outside ±5
239
+ minutes are rejected, missing headers return `false`, and at most 32 signatures per header are checked.
240
+ - **Reporting vulnerabilities.** Please don't open a public issue. Use GitHub
241
+ [private vulnerability reporting](https://github.com/nafrucom/nafru-email-sdks/security/advisories/new) or email security@nafru.com; see
242
+ [SECURITY.md](https://github.com/nafrucom/nafru-email-sdks/blob/main/SECURITY.md).
243
+
244
+ ## Development
245
+
246
+ Contributions are welcome; see [CONTRIBUTING.md](https://github.com/nafrucom/nafru-email-sdks/blob/main/CONTRIBUTING.md).
247
+
248
+ ```sh
249
+ bundle install
250
+ bundle exec rake test
251
+ gem build nafru-email.gemspec
252
+ ```
253
+
254
+ The version lives in one place, `lib/nafru_email/version.rb` (`VERSION`); the gemspec reads it from there.
255
+
256
+ ## Troubleshooting
257
+
258
+ Every API failure raises `Nafru::Email::Error`; check `e.name`, `e.status_code` and `e.message`. Configuration mistakes raise
259
+ `ArgumentError` before any request is made.
260
+
261
+ - **`missing_api_key` / `invalid_api_key` / `unauthorized` (401/403).** The key is missing, wrong or revoked. `NafruEmail.new` with no
262
+ argument reads `NAFRU_API_KEY`; if that is unset too it raises `ArgumentError` ("api_key gerekli"). Create a new key in the
263
+ dashboard if it was revoked.
264
+ - **`forbidden` (403).** The key lacks permission: a `sending_access` key can send email but cannot list or manage resources, and
265
+ may be restricted to one domain. Use a `full_access` key for management calls.
266
+ - **`validation_error` (422).** The request body is invalid. `e.errors` lists each problem as `{ field:, message: }`.
267
+ - **`rate_limit_exceeded` (429).** Retried automatically (up to `max_retries`, honoring `Retry-After`, capped at 8 s). If it still
268
+ fails, `e.retry_after` tells you how many seconds to wait.
269
+ - **`timeout_error` / `application_error` with `status_code == nil`.** A network failure or timeout (`timeout:` defaults to 30 s).
270
+ `GET`/`DELETE` are retried; a `POST`/`PATCH` is retried only with an `idempotency_key`, so pass one to make sending safe to retry.
271
+ - **`unexpected_redirect` (3xx).** The API never redirects. The `base_url` is probably wrong (missing `/api`, `http` → `https`
272
+ upgrade, a login page) or a proxy is in between. Redirects are never followed or retried.
273
+ - **`ArgumentError: base_url https kullanmalı (http yalnız localhost için)`.** `base_url`/`web_url` must be `https`; `http` is only
274
+ allowed for `localhost`, `*.localhost`, `127.0.0.0/8` and `[::1]`.
275
+ - **`response_too_large`.** The response body exceeded 10 MB. This points to a misbehaving proxy or the wrong `base_url`.
276
+ - **`connection_revoked` (Connect).** The customer disconnected your app or the refresh token is invalid or was reused. Ask the
277
+ customer to connect again.
278
+ - **`token_persist_failed` (Connect).** Your `on_token_refresh` callback raised. The new refresh token is kept in memory and saving it
279
+ is retried on the next call; fix the storage, because the old token is no longer valid.
280
+ - **`Webhook.verify` returns `false`.** Pass the **raw** body (`request.raw_post`, not re-serialized JSON). Use that webhook's own
281
+ `whsec_…` signing secret. Pass the `svix-id`, `svix-timestamp` and `svix-signature` headers. Keep the server clock in sync
282
+ (timestamps more than 5 minutes off are rejected).
283
+ - **Emails are not being sent.** With `NODE_ENV=development` the dev outbox is on: emails are written to `.nafru/emails/` and the
284
+ paths printed instead of sending. Pass `dev_outbox: false` to send for real.
285
+
286
+ ## Links
287
+
288
+ - API documentation: https://email.nafru.com
289
+ - Source and issues: https://github.com/nafrucom/nafru-email-sdks
290
+
291
+ Other official SDKs:
292
+
293
+ | Language | Package |
294
+ | -------------------- | ----------------------------------------------------------------------- |
295
+ | TypeScript / Node.js | [`@nafrucom/email`](https://www.npmjs.com/package/@nafrucom/email) |
296
+ | Python | [python](https://github.com/nafrucom/nafru-email-sdks/tree/main/python) |
297
+ | PHP | [php](https://github.com/nafrucom/nafru-email-sdks/tree/main/php) |
298
+ | Ruby | [ruby](https://github.com/nafrucom/nafru-email-sdks/tree/main/ruby) |
299
+ | Java | [java](https://github.com/nafrucom/nafru-email-sdks/tree/main/java) |
300
+ | .NET (C#) | [dotnet](https://github.com/nafrucom/nafru-email-sdks/tree/main/dotnet) |
301
+ | Go | [go](https://github.com/nafrucom/nafru-email-sdks/tree/main/go) |
302
+ | Rust | [rust](https://github.com/nafrucom/nafru-email-sdks/tree/main/rust) |
303
+ | Swift | [swift](https://github.com/nafrucom/nafru-email-sdks/tree/main/swift) |
304
+
305
+ ## Support
306
+
307
+ - Questions and how-to: [GitHub Discussions](https://github.com/nafrucom/nafru-email-sdks/discussions)
308
+ - Bugs in this SDK: [GitHub issues](https://github.com/nafrucom/nafru-email-sdks/issues)
309
+ - Account, billing and deliverability: destek@nafru.com
310
+ - Security issues: see [SECURITY.md](https://github.com/nafrucom/nafru-email-sdks/blob/main/SECURITY.md) (do not open a public issue)
311
+
312
+ More details are in [SUPPORT.md](https://github.com/nafrucom/nafru-email-sdks/blob/main/SUPPORT.md).
313
+
314
+ ## License
315
+
316
+ MIT. See [LICENSE](https://github.com/nafrucom/nafru-email-sdks/blob/main/ruby/LICENSE).
@@ -0,0 +1,196 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "openssl"
4
+ require "securerandom"
5
+ require "uri"
6
+
7
+ module Nafru
8
+ module Email
9
+ # Uygulamanın müşteriden isteyebileceği izinler.
10
+ SCOPES = %w[emails.send emails.read templates.read domains.read senders.read connection.read].freeze
11
+
12
+ def self.base64url(bytes) = [bytes].pack("m0").tr("+/", "-_").delete("=")
13
+
14
+ # PKCE (S256): 32 rastgele bayttan 43 karakterlik doğrulayıcı ve SHA-256 sınaması.
15
+ # @return [Hash] `{ verifier:, challenge: }`
16
+ def self.create_pkce
17
+ verifier = base64url(SecureRandom.random_bytes(32))
18
+ { verifier: verifier, challenge: base64url(OpenSSL::Digest::SHA256.digest(verifier)) }
19
+ end
20
+
21
+ # `exchange` sonucu. `inspect`/`to_s` yenileme belirtecini göstermez; değere `grant.refresh_token` ya da `grant[:refresh_token]` ile erişin.
22
+ ConnectionGrant = Struct.new(:connection_id, :refresh_token, :scopes, keyword_init: true) do
23
+ def inspect = "#<ConnectionGrant connection_id=#{connection_id.inspect} refresh_token=[REDACTED] scopes=#{scopes.inspect}>"
24
+ alias_method :to_s, :inspect
25
+
26
+ def pretty_print(pp) = pp.text(inspect)
27
+ end
28
+
29
+ # "Nafru ile bağlan" uçları: izin adresi, kod değişimi, iptal.
30
+ class Connect
31
+ include Redacted
32
+
33
+ def initialize(oauth, client_id:, client_secret:, web_url:)
34
+ @oauth = oauth
35
+ @client_id = client_id
36
+ @client_secret = client_secret
37
+ Email.require_https!(web_url, "web_url")
38
+ @web = web_url.sub(%r{/+\z}, "")
39
+ end
40
+
41
+ # Müşteriyi yönlendireceğiniz "Nafru ile bağlan" adresi. `code_verifier`'ı oturumda saklayın; dönüşte `exchange`'e verin.
42
+ # Dönüşte `state`'i kendi sakladığınız değerle karşılaştırmak sizin sorumluluğunuzdur.
43
+ # @return [Hash] `{ url:, code_verifier: }`
44
+ def authorize_url(redirect_uri:, state:, scopes:, login_hint: nil)
45
+ raise ArgumentError, "Nafru SDK: state gerekli (CSRF koruması)" if state.to_s.empty?
46
+ raise ArgumentError, "Nafru SDK: en az bir izin (scopes) gerekli" if scopes.empty?
47
+
48
+ pkce = Email.create_pkce
49
+ params = {
50
+ client_id: @client_id, redirect_uri: redirect_uri, state: state, scope: scopes.join(" "),
51
+ code_challenge: pkce[:challenge], code_challenge_method: "S256"
52
+ }
53
+ params[:login_hint] = login_hint if login_hint && !login_hint.empty?
54
+ { url: "#{@web}/connect?#{URI.encode_www_form(params)}", code_verifier: pkce[:verifier] }
55
+ end
56
+
57
+ # Dönüşteki tek kullanımlık `code`'u bağlantıya çevirir (sunucunuzdan çağırın).
58
+ # @return [ConnectionGrant]
59
+ def exchange(code:, code_verifier:, redirect_uri:)
60
+ data = @oauth.request("POST", "/oauth/token", body: {
61
+ grant_type: "authorization_code", client_id: @client_id, client_secret: @client_secret,
62
+ code: code, code_verifier: code_verifier, redirect_uri: redirect_uri
63
+ })
64
+ ConnectionGrant.new(connection_id: data[:connection_id], refresh_token: data[:refresh_token], scopes: data[:scopes])
65
+ end
66
+
67
+ # Bağlantıyı uygulama tarafından iptal eder (müşteri hesabını kapattığında). Belirteçler anında geçersiz olur.
68
+ def revoke(refresh_token:)
69
+ @oauth.request("POST", "/oauth/revoke", body: { client_id: @client_id, client_secret: @client_secret, token: refresh_token })
70
+ nil
71
+ end
72
+ end
73
+
74
+ # Bağlantının erişim belirtecini tutar ve yeniler. Eşzamanlı çağrılar TEK yenilemeyi paylaşır (tek uçuş): dönüşlü yenileme
75
+ # belirteci ikinci bir eşzamanlı yenilemede "yeniden kullanım" sayılır ve bağlantıyı iptal ettirirdi.
76
+ class TokenManager
77
+ include Redacted
78
+
79
+ # Süre dolmadan bu kadar önce yenilenir (saat farkı ve ağ gecikmesi payı).
80
+ EARLY_REFRESH = 30
81
+
82
+ def initialize(oauth, client_id:, client_secret:, refresh_token:, on_token_refresh:)
83
+ @oauth = oauth
84
+ @client_id = client_id
85
+ @client_secret = client_secret
86
+ @refresh_token = refresh_token
87
+ @on_token_refresh = on_token_refresh
88
+ @access_token = nil
89
+ @expires_at = 0
90
+ # Yenileme sunucuda başarılı oldu ama yeni yenileme belirteci saklanamadı: eski belirteç artık geçersiz, bu kaybolmamalı.
91
+ @unpersisted = false
92
+ @mutex = Mutex.new
93
+ end
94
+
95
+ # Geçerli erişim belirteci; yoksa ya da dolmak üzereyse yeniler. Kilit, bekleyenlerin tek yenilemenin sonucunu görmesini sağlar.
96
+ def access_token
97
+ @mutex.synchronize do
98
+ persist if @unpersisted
99
+ return @access_token if @access_token && now < @expires_at - EARLY_REFRESH
100
+
101
+ refresh
102
+ end
103
+ end
104
+
105
+ # Sunucu 401 `invalid_token` dedi: eldeki belirteci at ve yenile (aynı belirteçle eşzamanlı 401'ler tek yenilemeyi paylaşır).
106
+ def invalidate(stale)
107
+ @mutex.synchronize do
108
+ @access_token = nil if @access_token == stale
109
+ @access_token || refresh
110
+ end
111
+ end
112
+
113
+ private
114
+
115
+ def now = Process.clock_gettime(Process::CLOCK_MONOTONIC)
116
+
117
+ def persist
118
+ @on_token_refresh.call({ refresh_token: @refresh_token })
119
+ @unpersisted = false
120
+ rescue StandardError => e
121
+ @unpersisted = true
122
+ raise Error.new("Yeni yenileme belirteci saklanamadı: #{e.message}", name: "token_persist_failed")
123
+ end
124
+
125
+ def refresh
126
+ data = begin
127
+ @oauth.request("POST", "/oauth/token", body: {
128
+ grant_type: "refresh_token", client_id: @client_id, client_secret: @client_secret, refresh_token: @refresh_token
129
+ })
130
+ rescue Error => e
131
+ # Geçersiz/tüketilmiş belirteç ya da iptal: kalıcı hata. Ağ ve 5xx geçici hata olarak olduğu gibi döner.
132
+ raise e unless %w[invalid_grant connection_revoked].include?(e.name) ||
133
+ (e.status_code == 403 && e.name != "rate_limit_exceeded")
134
+
135
+ raise Error.new("Bağlantı iptal edilmiş ya da yenileme belirteci geçersiz; müşterinin yeniden bağlanması gerekir.",
136
+ name: "connection_revoked", status_code: e.status_code, errors: e.errors, retry_after: e.retry_after)
137
+ end
138
+ @refresh_token = data[:refresh_token]
139
+ @access_token = data[:access_token]
140
+ @expires_at = now + data[:expires_in].to_f
141
+ persist
142
+ @access_token
143
+ end
144
+ end
145
+
146
+ # Bir müşteri bağlantısı adına çağrı yapar. Her denemede `TokenManager#access_token` ile belirteç alınır; 401 `invalid_token`
147
+ # (süre dolmuş / yenilenmiş) ise bir kez yenileyip tekrar dener.
148
+ class Connection
149
+ include Redacted
150
+
151
+ attr_reader :id, :senders, :templates, :emails
152
+
153
+ # Bağlantı istemcisinde yalnız gönderim ve okuma vardır.
154
+ class Emails
155
+ def initialize(emails) = @emails = emails
156
+ def send(...) = @emails.send(...)
157
+ def get(...) = @emails.get(...)
158
+ def list(...) = @emails.list(...)
159
+ end
160
+
161
+ Senders = Struct.new(:conn) do
162
+ def list = conn.request("GET", "/senders")
163
+ end
164
+
165
+ # Müşterinin yayınlanmış şablonları ile uygulamanın paketi: anahtar başına tek öğe (müşterinin kendi kopyası paketi gölgeler).
166
+ Templates = Struct.new(:conn) do
167
+ def list = conn.request("GET", "/templates")
168
+ end
169
+
170
+ def initialize(id, tokens:, http_factory:, dev_outbox: false)
171
+ @id = id
172
+ @tokens = tokens
173
+ @current = nil
174
+ @http = http_factory.call(lambda {
175
+ @current = @tokens.access_token
176
+ })
177
+ @senders = Senders.new(self)
178
+ @templates = Templates.new(self)
179
+ @emails = Emails.new(Resources::Emails.new(self, dev_outbox))
180
+ end
181
+
182
+ # `GET /connection`: hazır mı, eksik ne, ücret modu ve müşterinin kurulum adresi.
183
+ def status = request("GET", "/connection")
184
+
185
+ # @api private
186
+ def request(method, path, **options)
187
+ @http.request(method, path, **options)
188
+ rescue Error => e
189
+ raise unless e.name == "invalid_token" && @current
190
+
191
+ @tokens.invalidate(@current)
192
+ @http.request(method, path, **options)
193
+ end
194
+ end
195
+ end
196
+ end
@@ -0,0 +1,83 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "forwardable"
4
+
5
+ module Nafru
6
+ module Email
7
+ # Nafru Email üretim API'si; `base_url` verilmezse (ya da boşsa) kullanılır.
8
+ DEFAULT_BASE_URL = "https://email.nafru.com/api"
9
+ end
10
+ end
11
+
12
+ # API anahtarıyla istemci (Resend tarzı): `NafruEmail.new("nafru_...")`. Anahtar verilmezse `NAFRU_API_KEY` okunur.
13
+ class NafruEmail
14
+ include Nafru::Email::Redacted
15
+
16
+ attr_reader :emails, :domains, :contacts, :contact_properties, :segments, :topics, :broadcasts, :templates, :brand,
17
+ :webhooks, :api_keys, :events
18
+
19
+ # @param api_key [String, nil] Dashboard'dan üretilen API anahtarı; boşsa `NAFRU_API_KEY`. Tarayıcıya/istemciye gömmeyin.
20
+ # @param base_url [String, nil] API'nin kök adresi; varsayılan `DEFAULT_BASE_URL` (yerelde `http://localhost:48787/api`).
21
+ # @param transport [#call, nil] özel taşıyıcı (test, proxy); bkz. `NET_HTTP_TRANSPORT`.
22
+ # @param max_retries [Integer] `429` ve güvenli isteklerde sunucu/ağ hatası için yeniden deneme; `0` kapatır.
23
+ # @param timeout [Numeric] istek başına zaman aşımı (saniye).
24
+ # @param dev_outbox [String, false, nil] verilirse `emails.send`/`batch` göndermez, bu klasöre yazar;
25
+ # `nil`: `NODE_ENV=development` iken `.nafru/emails`; `false` her zaman gerçek gönderim.
26
+ def initialize(api_key = nil, base_url: nil, transport: nil, max_retries: 2, timeout: 30, dev_outbox: nil)
27
+ api_key = ENV.fetch("NAFRU_API_KEY", nil) if api_key.to_s.empty?
28
+ raise ArgumentError, "Nafru SDK: api_key gerekli" if api_key.to_s.empty?
29
+ base_url = Nafru::Email::DEFAULT_BASE_URL if base_url.to_s.empty?
30
+
31
+ @http = Nafru::Email::Http.new(api_key: api_key, base_url: base_url, transport: transport, max_retries: max_retries, timeout: timeout)
32
+ @emails = Nafru::Email::Resources::Emails.new(@http, Nafru::Email::DevOutbox.resolve(dev_outbox))
33
+ @domains = Nafru::Email::Resources::Domains.new(@http)
34
+ @contacts = Nafru::Email::Resources::Contacts.new(@http)
35
+ @contact_properties = Nafru::Email::Resources::ContactProperties.new(@http)
36
+ @segments = Nafru::Email::Resources::Segments.new(@http)
37
+ @topics = Nafru::Email::Resources::Topics.new(@http)
38
+ @broadcasts = Nafru::Email::Resources::Broadcasts.new(@http)
39
+ @templates = Nafru::Email::Resources::Templates.new(@http)
40
+ @brand = Nafru::Email::Resources::Brand.new(@http)
41
+ @webhooks = Nafru::Email::Resources::Webhooks.new(@http)
42
+ @api_keys = Nafru::Email::Resources::ApiKeys.new(@http)
43
+ @events = Nafru::Email::Resources::Events.new(@http)
44
+ end
45
+ end
46
+
47
+ # "Nafru ile bağlan" fabrikası: müşterinin Nafru Email hesabına tek tıkla bağlanır, o hesap adına gönderir.
48
+ # Tek alan adı/anahtar için `NafruEmail` kullanın.
49
+ class NafruConnect
50
+ extend Forwardable
51
+ include Nafru::Email::Redacted
52
+
53
+ # `authorize_url(redirect_uri:, state:, scopes:, login_hint: nil)`, `exchange(code:, code_verifier:, redirect_uri:)`,
54
+ # `revoke(refresh_token:)`: bkz. `Nafru::Email::Connect`.
55
+ def_delegators :@connect, :authorize_url, :exchange, :revoke
56
+
57
+ # @param client_secret [String] yalnız sunucunuzda.
58
+ # @param base_url [String, nil] API'nin kök adresi; varsayılan `DEFAULT_BASE_URL`.
59
+ # @param web_url [String, nil] izin ekranının kök adresi; verilmezse `base_url` sonundaki `/api` atılır.
60
+ def initialize(client_id:, client_secret:, base_url: nil, web_url: nil, transport: nil, max_retries: 2, timeout: 30,
61
+ dev_outbox: nil)
62
+ raise ArgumentError, "Nafru SDK: client_id gerekli" if client_id.to_s.empty?
63
+ raise ArgumentError, "Nafru SDK: client_secret gerekli" if client_secret.to_s.empty?
64
+ base_url = Nafru::Email::DEFAULT_BASE_URL if base_url.to_s.empty?
65
+
66
+ @credentials = { client_id: client_id, client_secret: client_secret }
67
+ @shared = { base_url: base_url, transport: transport, max_retries: max_retries, timeout: timeout }
68
+ @dev_outbox = Nafru::Email::DevOutbox.resolve(dev_outbox)
69
+ # Token uçları kimlik bilgisini gövdede taşır; Authorization başlığı yok.
70
+ @oauth = Nafru::Email::Http.new(**@shared, authorize: -> {})
71
+ @connect = Nafru::Email::Connect.new(@oauth, **@credentials, web_url: web_url || base_url.sub(%r{/api/*\z}, ""))
72
+ end
73
+
74
+ # Saklanan yenileme belirteciyle bir müşteri bağlantısı. Yenileme otomatiktir; yeni belirteç `on_token_refresh` ile gelir.
75
+ # @param on_token_refresh [#call] `{ refresh_token: }` alır; yeni değeri KALICI yazmadan eskisi geçersizdir. Hata fırlatırsa
76
+ # çağrı `token_persist_failed` ile hata verir. Blok olarak da verilebilir.
77
+ def connection(connection_id, refresh_token:, on_token_refresh: nil, &block)
78
+ callback = on_token_refresh || block or raise ArgumentError, "Nafru SDK: on_token_refresh gerekli"
79
+ tokens = Nafru::Email::TokenManager.new(@oauth, **@credentials, refresh_token: refresh_token, on_token_refresh: callback)
80
+ Nafru::Email::Connection.new(connection_id, tokens: tokens, dev_outbox: @dev_outbox,
81
+ http_factory: ->(authorize) { Nafru::Email::Http.new(**@shared, authorize: authorize) })
82
+ end
83
+ end