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 +7 -0
- data/LICENSE +21 -0
- data/README.md +316 -0
- data/lib/nafru_email/app.rb +196 -0
- data/lib/nafru_email/client.rb +83 -0
- data/lib/nafru_email/dev_outbox.rb +71 -0
- data/lib/nafru_email/error.rb +77 -0
- data/lib/nafru_email/http.rb +184 -0
- data/lib/nafru_email/pagination.rb +26 -0
- data/lib/nafru_email/resources.rb +183 -0
- data/lib/nafru_email/version.rb +8 -0
- data/lib/nafru_email/webhook.rb +58 -0
- data/lib/nafru_email.rb +12 -0
- data/sig/nafru_email.rbs +370 -0
- metadata +63 -0
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
|
+
[](https://rubygems.org/gems/nafru-email)
|
|
9
|
+
[](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
|