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.
@@ -0,0 +1,71 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+ require "json"
5
+ require "securerandom"
6
+ require "time"
7
+
8
+ module Nafru
9
+ module Email
10
+ module DevOutbox
11
+ module_function
12
+
13
+ # `dev_outbox` verilmezse: yalnız `NODE_ENV=development` iken `.nafru/emails`; aksi hâlde gerçek gönderim.
14
+ def resolve(dev_outbox)
15
+ return dev_outbox unless dev_outbox.nil?
16
+
17
+ ENV["NODE_ENV"] == "development" ? ".nafru/emails" : false
18
+ end
19
+
20
+ # E-postayı göndermeden `dir` altına `.txt` ve `.html` olarak yazar, yolu ve düz metni konsola basar.
21
+ def write(dir, input)
22
+ email = input.transform_keys(&:to_s)
23
+ now = Time.now.utc
24
+ id = "dev_#{(now.to_f * 1000).to_i.to_s(36)}#{SecureRandom.alphanumeric(6).downcase}"
25
+ base = File.expand_path("#{now.iso8601(3).tr(':.', '--')}-#{id}", dir)
26
+ headers = [
27
+ "From: #{email['from']}",
28
+ "To: #{list(email['to'])}",
29
+ email["cc"] && "Cc: #{list(email['cc'])}",
30
+ email["bcc"] && "Bcc: #{list(email['bcc'])}",
31
+ email["reply_to"] && "Reply-To: #{list(email['reply_to'])}",
32
+ "Subject: #{email['subject']}",
33
+ email["scheduled_at"] && "Scheduled-At: #{email['scheduled_at']}"
34
+ ].compact
35
+ body = body_text(email)
36
+ text = "#{headers.join("\n")}\n\n#{body}\n"
37
+ html = email["html"] || "<pre>#{escape_html(body)}</pre>"
38
+
39
+ FileUtils.mkdir_p(File.expand_path(dir))
40
+ File.write("#{base}.txt", text)
41
+ File.write("#{base}.html", html)
42
+ $stdout.puts "[nafru] dev: e-posta gönderilmedi\n #{base}.txt\n #{base}.html\n\n#{text}"
43
+ { id: id }
44
+ end
45
+
46
+ def list(value) = value.is_a?(Array) ? value.join(", ") : value.to_s
47
+
48
+ def escape_html(text) = text.gsub("&", "&amp;").gsub("<", "&lt;").gsub(">", "&gt;").gsub('"', "&quot;")
49
+
50
+ # shortcut: kaba HTML→metin dönüşümü (etiket silme); yalnız dev önizlemesi için, gerçek gönderime girmez.
51
+ def html_to_text(html)
52
+ html.gsub(%r{<(style|script)[\s\S]*?</\1>}i, "")
53
+ .gsub(%r{<br\s*/?>|</(p|div|h[1-6]|li|tr)>}i, "\n")
54
+ .gsub(/<[^>]+>/, "")
55
+ .gsub("&nbsp;", " ").gsub("&lt;", "<").gsub("&gt;", ">").gsub("&quot;", '"').gsub("&amp;", "&")
56
+ .gsub(/\n{3,}/, "\n\n")
57
+ .strip
58
+ end
59
+
60
+ def body_text(email)
61
+ return email["text"] if email["text"] && !email["text"].empty?
62
+ return html_to_text(email["html"]) if email["html"] && !email["html"].empty?
63
+
64
+ template = email["template"]&.transform_keys(&:to_s)
65
+ return "" unless template
66
+
67
+ "[şablon #{template['id']}]\n#{JSON.pretty_generate(template['variables'] || {})}"
68
+ end
69
+ end
70
+ end
71
+ end
@@ -0,0 +1,77 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Nafru
4
+ module Email
5
+ # Her başarısız çağrının fırlattığı hata. `status_code` ağ hatalarında `nil`'dır. `name`, sunucunun hata adıdır
6
+ # (`validation_error`, `rate_limit_exceeded`, `not_found`, …); ağ ve zaman aşımı için `application_error` / `timeout_error`.
7
+ class Error < StandardError
8
+ NAMES_BY_STATUS = {
9
+ 400 => "invalid_request",
10
+ 401 => "unauthorized",
11
+ 403 => "forbidden",
12
+ 404 => "not_found",
13
+ 409 => "conflict",
14
+ 413 => "payload_too_large",
15
+ 422 => "validation_error",
16
+ 429 => "rate_limit_exceeded"
17
+ }.freeze
18
+
19
+ # @return [Integer, nil]
20
+ attr_reader :status_code
21
+ # @return [String]
22
+ attr_reader :name
23
+ # @return [Array<Hash>, nil] 422'de alan ayrıntısı: `{ field:, message:, i18nTranslationKey: }`
24
+ attr_reader :errors
25
+ # @return [Numeric, nil] `429` yanıtındaki `retry-after` (saniye)
26
+ attr_reader :retry_after
27
+
28
+ def initialize(message, name:, status_code: nil, errors: nil, retry_after: nil)
29
+ super(message)
30
+ @name = name
31
+ @status_code = status_code
32
+ @errors = errors
33
+ @retry_after = retry_after
34
+ end
35
+
36
+ def to_h
37
+ { status_code: status_code, name: name, message: message, errors: errors, retry_after: retry_after }
38
+ .reject { |key, value| value.nil? && key != :status_code }
39
+ end
40
+
41
+ def self.network(message) = new(message, name: "application_error")
42
+
43
+ def self.timeout(seconds) = new("İstek #{seconds} sn içinde tamamlanmadı", name: "timeout_error")
44
+
45
+ # Standart gövde (`statusCode`, `name`, `message`, `errors`) yoksa durumdan makul bir hata kurar (ara katman HTML'i, boş gövde).
46
+ def self.parse(status, headers, body, reason = nil)
47
+ raw = headers["retry-after"]
48
+ retry_after = raw && Float(raw, exception: false)
49
+ retry_after = nil if retry_after&.negative?
50
+ retry_after = retry_after.to_i if retry_after && retry_after == retry_after.to_i
51
+ fallback = NAMES_BY_STATUS.fetch(status, "application_error")
52
+ unless body.is_a?(Hash) && body[:message].is_a?(String)
53
+ return new(reason.to_s.empty? ? "HTTP #{status}" : reason, name: fallback, status_code: status, retry_after: retry_after)
54
+ end
55
+
56
+ errors = body[:errors].is_a?(Array) ? body[:errors].grep(Hash).map { |entry| field_error(entry) } : []
57
+ new(
58
+ body[:message],
59
+ name: body[:name].is_a?(String) ? body[:name] : fallback,
60
+ status_code: body[:statusCode].is_a?(Integer) ? body[:statusCode] : status,
61
+ errors: errors.empty? ? nil : errors,
62
+ retry_after: retry_after
63
+ )
64
+ end
65
+
66
+ def self.field_error(entry)
67
+ error = {
68
+ field: entry[:field].is_a?(String) ? entry[:field] : "",
69
+ message: entry[:message].is_a?(String) ? entry[:message] : ""
70
+ }
71
+ error[:i18nTranslationKey] = entry[:i18nTranslationKey] if entry[:i18nTranslationKey].is_a?(String)
72
+ error
73
+ end
74
+ private_class_method :field_error
75
+ end
76
+ end
77
+ end
@@ -0,0 +1,184 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "ipaddr"
4
+ require "json"
5
+ require "net/http"
6
+ require "uri"
7
+
8
+ module Nafru
9
+ module Email
10
+ # Taşıyıcının döndürdüğü yanıt. `headers` anahtarları küçük harf olmalıdır.
11
+ Response = Struct.new(:status, :headers, :body, :reason, keyword_init: true)
12
+
13
+ # Yanıt gövdesi üst sınırı: bozuk ya da kötü niyetli bir ara katman belleği tüketemesin.
14
+ MAX_RESPONSE_BYTES = 10 * 1024 * 1024
15
+
16
+ def self.response_too_large = Error.new("Yanıt gövdesi #{MAX_RESPONSE_BYTES} baytı aşıyor", name: "response_too_large")
17
+
18
+ # Varsayılan taşıyıcı (Net::HTTP). Özel taşıyıcı aynı imzayla `call` yanıtlayan herhangi bir nesnedir.
19
+ # Net::HTTP yönlendirme izlemez ve TLS sertifikasını doğrular (VERIFY_PEER); bunu kapatan bir seçenek yoktur.
20
+ # shortcut: her istek yeni bağlantı açar (keep-alive yok); yüksek hacimde kalıcı bağlantılı bir taşıyıcı verin.
21
+ NET_HTTP_TRANSPORT = lambda do |method:, url:, headers:, body:, timeout:|
22
+ uri = URI(url)
23
+ request = Net::HTTPGenericRequest.new(method, !body.nil?, true, uri.request_uri, headers)
24
+ request.body = body
25
+ Net::HTTP.start(uri.hostname, uri.port, use_ssl: uri.scheme == "https",
26
+ open_timeout: timeout, read_timeout: timeout, write_timeout: timeout) do |conn|
27
+ conn.request(request) do |response|
28
+ buffer = +""
29
+ response.read_body do |chunk|
30
+ buffer << chunk
31
+ raise Email.response_too_large if buffer.bytesize > MAX_RESPONSE_BYTES
32
+ end
33
+ return Response.new(status: response.code.to_i, headers: response.each_header.to_h, body: buffer,
34
+ reason: response.message)
35
+ end
36
+ end
37
+ end
38
+
39
+ # http yalnız geri döngü/yerel adreslerde serbest: düz metin üzerinde API anahtarı ve belirteç taşınmasın.
40
+ def self.require_https!(url, label)
41
+ uri = URI(url)
42
+ host = uri.hostname.to_s.downcase
43
+ local = host == "localhost" || host.end_with?(".localhost") || (begin
44
+ IPAddr.new(host).loopback?
45
+ rescue IPAddr::Error
46
+ false
47
+ end)
48
+ return if uri.scheme == "https" || (uri.scheme == "http" && local)
49
+
50
+ raise ArgumentError, "Nafru SDK: #{label} https kullanmalı (http yalnız localhost için)"
51
+ rescue URI::InvalidURIError
52
+ raise ArgumentError, "Nafru SDK: #{label} geçerli bir adres değil"
53
+ end
54
+
55
+ # `Http`, `Connect` vb. sırları (anahtar, belirteç) tutar: `inspect`/`pp` çıktısına girmesinler.
56
+ module Redacted
57
+ def inspect = "#<#{self.class.name} [REDACTED]>"
58
+ alias to_s inspect
59
+ end
60
+
61
+ # İstek katmanı: kimlik, yeniden deneme, zaman aşımı, hata ayrıştırma.
62
+ class Http
63
+ include Redacted
64
+
65
+ MAX_BACKOFF = 8.0
66
+ BASE_BACKOFF = 0.3
67
+
68
+ # @param authorize [#call, nil] verilirse `api_key` yerine her denemede bundan alınan belirteç kullanılır (`nil`: başlık yok).
69
+ def initialize(base_url:, api_key: nil, authorize: nil, transport: nil, max_retries: 2, timeout: 30, user_agent: nil)
70
+ Email.require_https!(base_url, "base_url")
71
+ @base = base_url.sub(%r{/+\z}, "")
72
+ @api_key = api_key
73
+ @authorize = authorize
74
+ @transport = transport || NET_HTTP_TRANSPORT
75
+ @max_retries = max_retries
76
+ @timeout = timeout
77
+ @user_agent = user_agent || "nafru-email-ruby/#{VERSION}"
78
+ end
79
+
80
+ def self.serialize_query(query)
81
+ pairs = (query || {}).reject { |_, value| value.nil? || value == "" }.map { |key, value| [key.to_s, value.to_s] }
82
+ pairs.empty? ? "" : "?#{URI.encode_www_form(pairs)}"
83
+ end
84
+
85
+ # Yol parçası: kimlik ya da e-posta (kişi uçları e-postayı kimlik gibi kabul eder) güvenle kaçırılır (encodeURIComponent).
86
+ # Boş, `.` ve `..` reddedilir: URL normalleştirmesi bunları başka bir uca çevirirdi.
87
+ def self.seg(value)
88
+ text = value.to_s
89
+ raise ArgumentError, "Nafru SDK: yol parçası boş, '.' ya da '..' olamaz" if ["", ".", ".."].include?(text)
90
+
91
+ text.b.gsub(%r{[^A-Za-z0-9\-_.!~*'()]}) { |char| format("%%%02X", char.ord) }
92
+ end
93
+
94
+ # @return [Object] ayrıştırılmış JSON (sembol anahtarlı) ya da boş gövdede `nil`
95
+ # @raise [Error]
96
+ def request(method, path, query: nil, body: nil, idempotency_key: nil)
97
+ headers = { "accept" => "application/json", "user-agent" => @user_agent }
98
+ headers["authorization"] = "Bearer #{@api_key}" unless @authorize
99
+ payload = nil
100
+ unless body.nil?
101
+ payload = JSON.generate(body)
102
+ headers["content-type"] = "application/json"
103
+ end
104
+ unless idempotency_key.nil?
105
+ # Başlık enjeksiyonu: CR/LF ya da başka denetim karakteri başlığa giremez.
106
+ if idempotency_key.to_s.empty? || idempotency_key.to_s.match?(/[[:cntrl:]]/)
107
+ raise ArgumentError, "Nafru SDK: idempotency_key boş olamaz ve denetim karakteri içeremez"
108
+ end
109
+
110
+ headers["idempotency-key"] = idempotency_key.to_s
111
+ end
112
+ url = "#{@base}#{path}#{self.class.serialize_query(query)}"
113
+ # `GET`/`DELETE` yinelenebilir; `POST`/`PATCH` yalnız 429'da ya da `Idempotency-Key` varsa: anahtarsız sunucu hatasında e-posta çift gidebilir.
114
+ safe = %w[GET DELETE].include?(method) || !idempotency_key.nil?
115
+
116
+ attempt = 0
117
+ loop do
118
+ if @authorize
119
+ token = @authorize.call
120
+ token.nil? ? headers.delete("authorization") : headers["authorization"] = "Bearer #{token}"
121
+ end
122
+ last = attempt >= @max_retries
123
+
124
+ begin
125
+ response = @transport.call(method: method, url: url, headers: headers.dup, body: payload, timeout: @timeout)
126
+ rescue Error
127
+ raise
128
+ rescue StandardError => e
129
+ if !last && safe
130
+ pause([BASE_BACKOFF * (2**attempt), MAX_BACKOFF].min)
131
+ attempt += 1
132
+ next
133
+ end
134
+ raise e.is_a?(Timeout::Error) ? Error.timeout(@timeout) : Error.network(e.message.to_s.empty? ? "Sunucuya ulaşılamadı" : e.message)
135
+ end
136
+
137
+ raise Email.response_too_large if response.body.to_s.bytesize > MAX_RESPONSE_BYTES
138
+
139
+ # API hiç yönlendirmez; izlemek `Authorization`'ı başka bir sunucuya sızdırabilir. Yeniden denenmez.
140
+ if response.status.between?(300, 399)
141
+ raise Error.new("Beklenmeyen yönlendirme (HTTP #{response.status}); izlenmedi", name: "unexpected_redirect",
142
+ status_code: response.status)
143
+ end
144
+
145
+ response_headers = (response.headers || {}).transform_keys { |key| key.to_s.downcase }
146
+ warn_deprecated(response_headers)
147
+ data = parse_body(response.body)
148
+ return data if response.status.between?(200, 299)
149
+
150
+ error = Error.parse(response.status, response_headers, data, response.reason)
151
+ retryable = response.status == 429 || (response.status >= 500 && safe)
152
+ raise error unless retryable && !last
153
+
154
+ pause([error.retry_after || (BASE_BACKOFF * (2**attempt)), MAX_BACKOFF].min)
155
+ attempt += 1
156
+ end
157
+ end
158
+
159
+ private
160
+
161
+ def pause(seconds) = sleep(seconds)
162
+
163
+ # Sunucu bu SDK sürümünü kullanımdan kaldırdıysa istemci başına bir kez uyarır; çağrıyı asla bozmaz.
164
+ def warn_deprecated(headers)
165
+ return if @deprecation_warned || !headers.key?("deprecation")
166
+
167
+ @deprecation_warned = true
168
+ message = +"[nafru] Bu SDK sürümü (ruby #{VERSION}) kullanımdan kaldırıldı; lütfen güncelleyin."
169
+ message << " Son tarih: #{headers['sunset']}" if headers["sunset"]
170
+ link = headers["link"].to_s[/<([^>]+)>/, 1] || headers["link"]
171
+ message << " Ayrıntı: #{link}" if link && !link.empty?
172
+ warn message
173
+ end
174
+
175
+ def parse_body(text)
176
+ return nil if text.nil? || text.empty?
177
+
178
+ JSON.parse(text, symbolize_names: true)
179
+ rescue JSON::ParserError
180
+ nil
181
+ end
182
+ end
183
+ end
184
+ end
@@ -0,0 +1,26 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Nafru
4
+ module Email
5
+ # Bir liste çağrısını tüm sayfalar boyunca gezer (`after` imleci, en yeni önce sırasında ileri). Hata gelirse
6
+ # numaralandırıcı o hatayı fırlatır: sayfalama döngüsünde sessizce kesmek eksik veri okutur.
7
+ #
8
+ # Nafru::Email.paginate(nafru.contacts.method(:list), limit: 100).each { |contact| ... }
9
+ #
10
+ # @param list [#call] sorgu Hash'i alıp liste zarfı (`{ object:, data:, has_more: }`) döndüren çağrı
11
+ # @return [Enumerator]
12
+ def self.paginate(list, query = {})
13
+ query = query.reject { |key, _| %w[after before].include?(key.to_s) }
14
+ Enumerator.new do |yielder|
15
+ after = nil
16
+ loop do
17
+ page = list.call(after ? query.merge(after: after) : query)
18
+ page[:data].each { |item| yielder << item }
19
+ break if !page[:has_more] || page[:data].empty?
20
+
21
+ after = page[:data].last[:id]
22
+ end
23
+ end
24
+ end
25
+ end
26
+ end
@@ -0,0 +1,183 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Nafru
4
+ module Email
5
+ # Kaynaklar `Http#request` ile konuşur. Girdi ve yanıt alanları API'deki gibi snake_case'tir; şekiller `sig/nafru_email.rbs`'te.
6
+ module Resources
7
+ # Ortak taban: `http` `request(method, path, query:, body:, idempotency_key:)` yanıtlayan nesnedir.
8
+ class Base
9
+ def initialize(http) = @http = http
10
+
11
+ private
12
+
13
+ def req(method, path, **options) = @http.request(method, path, **options)
14
+
15
+ def seg(value) = Http.seg(value)
16
+ end
17
+
18
+ class Emails < Base
19
+ def initialize(http, dev_outbox = false)
20
+ super(http)
21
+ @dev_outbox = dev_outbox
22
+ end
23
+
24
+ # `POST /emails`. `scheduled_at` verilirse zamanlanır; `idempotency_key` ile yineleme güvenlidir.
25
+ def send(input, idempotency_key: nil)
26
+ return DevOutbox.write(@dev_outbox, input) if @dev_outbox
27
+
28
+ req("POST", "/emails", body: input, idempotency_key: idempotency_key)
29
+ end
30
+
31
+ # `POST /emails/batch`: en çok 100 e-posta; ek ve şablon desteklenmez.
32
+ def batch(input, idempotency_key: nil)
33
+ return { data: input.map { |item| DevOutbox.write(@dev_outbox, item) } } if @dev_outbox
34
+
35
+ req("POST", "/emails/batch", body: input, idempotency_key: idempotency_key)
36
+ end
37
+
38
+ def get(id) = req("GET", "/emails/#{seg(id)}")
39
+ def list(query = {}) = req("GET", "/emails", query: query)
40
+ # Zamanlanmış e-postanın gönderim anını değiştirir.
41
+ def update(id, input) = req("PATCH", "/emails/#{seg(id)}", body: input)
42
+ # Zamanlanmış e-postayı iptal eder.
43
+ def cancel(id) = req("POST", "/emails/#{seg(id)}/cancel")
44
+ end
45
+
46
+ class Domains < Base
47
+ def create(input) = req("POST", "/domains", body: input)
48
+ def get(id) = req("GET", "/domains/#{seg(id)}")
49
+ def list(query = {}) = req("GET", "/domains", query: query)
50
+ def update(id, input) = req("PATCH", "/domains/#{seg(id)}", body: input)
51
+ def remove(id) = req("DELETE", "/domains/#{seg(id)}")
52
+ # DNS kayıtlarını denetletir; sonuç `get` ile okunur.
53
+ def verify(id) = req("POST", "/domains/#{seg(id)}/verify")
54
+ end
55
+
56
+ # Kişi uçlarında `id` yerine e-posta adresi de verilebilir.
57
+ class Contacts < Base
58
+ attr_reader :segments, :topics
59
+
60
+ def initialize(http)
61
+ super
62
+ @segments = ContactSegments.new(http)
63
+ @topics = ContactTopics.new(http)
64
+ end
65
+
66
+ def create(input) = req("POST", "/contacts", body: input)
67
+ def get(id_or_email) = req("GET", "/contacts/#{seg(id_or_email)}")
68
+ def list(query = {}) = req("GET", "/contacts", query: query)
69
+ def update(id_or_email, input) = req("PATCH", "/contacts/#{seg(id_or_email)}", body: input)
70
+ def remove(id_or_email) = req("DELETE", "/contacts/#{seg(id_or_email)}")
71
+ end
72
+
73
+ class ContactSegments < Base
74
+ def list(id_or_email, query = {}) = req("GET", "/contacts/#{seg(id_or_email)}/segments", query: query)
75
+ # Kişiyi statik segmente ekler (dinamik segmentte 422).
76
+ def add(id_or_email, segment_id) = req("POST", "/contacts/#{seg(id_or_email)}/segments/#{seg(segment_id)}")
77
+ def remove(id_or_email, segment_id) = req("DELETE", "/contacts/#{seg(id_or_email)}/segments/#{seg(segment_id)}")
78
+ end
79
+
80
+ class ContactTopics < Base
81
+ def list(id_or_email, query = {}) = req("GET", "/contacts/#{seg(id_or_email)}/topics", query: query)
82
+
83
+ # `input`: `{ topics: [{ id:, subscription: "opt_in" | "opt_out" }] }`. Sunucu gövdede çıplak dizi bekler.
84
+ def update(id_or_email, input)
85
+ req("PATCH", "/contacts/#{seg(id_or_email)}/topics", body: input[:topics] || input["topics"])
86
+ end
87
+ end
88
+
89
+ class ContactProperties < Base
90
+ def create(input) = req("POST", "/contact-properties", body: input)
91
+ def get(id) = req("GET", "/contact-properties/#{seg(id)}")
92
+ def list(query = {}) = req("GET", "/contact-properties", query: query)
93
+ def update(id, input) = req("PATCH", "/contact-properties/#{seg(id)}", body: input)
94
+ def remove(id) = req("DELETE", "/contact-properties/#{seg(id)}")
95
+ end
96
+
97
+ class Segments < Base
98
+ def create(input) = req("POST", "/segments", body: input)
99
+ def get(id) = req("GET", "/segments/#{seg(id)}")
100
+ def list(query = {}) = req("GET", "/segments", query: query)
101
+ def remove(id) = req("DELETE", "/segments/#{seg(id)}")
102
+ # Segmentin kişileri; sayfalama kimlik sırasıyla, yalnız `after`.
103
+ def contacts(id, query = {}) = req("GET", "/segments/#{seg(id)}/contacts", query: query)
104
+ end
105
+
106
+ class Topics < Base
107
+ def create(input) = req("POST", "/topics", body: input)
108
+ def get(id) = req("GET", "/topics/#{seg(id)}")
109
+ def list(query = {}) = req("GET", "/topics", query: query)
110
+ def update(id, input) = req("PATCH", "/topics/#{seg(id)}", body: input)
111
+ def remove(id) = req("DELETE", "/topics/#{seg(id)}")
112
+ end
113
+
114
+ class Broadcasts < Base
115
+ # Taslak oluşturur; `send: true` ile aynı istekte gönderir.
116
+ def create(input) = req("POST", "/broadcasts", body: input)
117
+ def get(id) = req("GET", "/broadcasts/#{seg(id)}")
118
+ def list(query = {}) = req("GET", "/broadcasts", query: query)
119
+ # Yalnız taslakta (aksi 409).
120
+ def update(id, input) = req("PATCH", "/broadcasts/#{seg(id)}", body: input)
121
+ # Yalnız taslakta (aksi 409).
122
+ def remove(id) = req("DELETE", "/broadcasts/#{seg(id)}")
123
+ def send(id, input = {}) = req("POST", "/broadcasts/#{seg(id)}/send", body: input)
124
+ def cancel(id) = req("POST", "/broadcasts/#{seg(id)}/cancel")
125
+ end
126
+
127
+ # `id_or_alias`: kimlik ya da takma ad.
128
+ class Templates < Base
129
+ def create(input) = req("POST", "/templates", body: input)
130
+ def get(id_or_alias) = req("GET", "/templates/#{seg(id_or_alias)}")
131
+ def list(query = {}) = req("GET", "/templates", query: query)
132
+ # Taslağı kaydeder; yayımlanana kadar gönderimler eski sürümü kullanır.
133
+ def update(id_or_alias, input) = req("PATCH", "/templates/#{seg(id_or_alias)}", body: input)
134
+ def remove(id_or_alias) = req("DELETE", "/templates/#{seg(id_or_alias)}")
135
+ def publish(id_or_alias) = req("POST", "/templates/#{seg(id_or_alias)}/publish")
136
+ def duplicate(id_or_alias) = req("POST", "/templates/#{seg(id_or_alias)}/duplicate")
137
+
138
+ # Dil taslağını yazar; gönderimler bir sonraki yayından itibaren bu dili kullanır.
139
+ def put_translation(id_or_alias, locale, input)
140
+ req("PUT", "/templates/#{seg(id_or_alias)}/translations/#{seg(locale)}", body: input)
141
+ end
142
+
143
+ def remove_translation(id_or_alias, locale)
144
+ req("DELETE", "/templates/#{seg(id_or_alias)}/translations/#{seg(locale)}")
145
+ end
146
+ end
147
+
148
+ # Çalışma alanının markası: logo, renkler, yazı tipi, altbilgi. Şablonlar markayı `brand` alanıyla devralır.
149
+ class Brand < Base
150
+ def get = req("GET", "/brand")
151
+ # Markanın TAMAMINI değiştirir (gönderilmeyen alan "ayarlanmadı" olur).
152
+ def update(input) = req("PUT", "/brand", body: input)
153
+ end
154
+
155
+ class Webhooks < Base
156
+ # Yanıttaki `signing_secret` ile `Webhook.verify` kullanılır.
157
+ def create(input) = req("POST", "/webhooks", body: input)
158
+ def get(id) = req("GET", "/webhooks/#{seg(id)}")
159
+ def list(query = {}) = req("GET", "/webhooks", query: query)
160
+ def update(id, input) = req("PATCH", "/webhooks/#{seg(id)}", body: input)
161
+ def remove(id) = req("DELETE", "/webhooks/#{seg(id)}")
162
+ end
163
+
164
+ class ApiKeys < Base
165
+ def create(input) = req("POST", "/api-keys", body: input)
166
+ def list(query = {}) = req("GET", "/api-keys", query: query)
167
+ def remove(id) = req("DELETE", "/api-keys/#{seg(id)}")
168
+ end
169
+
170
+ # Otomasyonları tetikleyen özel olay API'si.
171
+ class Events < Base
172
+ def send(input, idempotency_key: nil) = req("POST", "/events", body: input, idempotency_key: idempotency_key)
173
+
174
+ # En çok 100 olay; biri geçersizse tüm istek 422.
175
+ def batch(input, idempotency_key: nil)
176
+ req("POST", "/events/batch", body: input, idempotency_key: idempotency_key)
177
+ end
178
+
179
+ def list(query = {}) = req("GET", "/events", query: query)
180
+ end
181
+ end
182
+ end
183
+ end
@@ -0,0 +1,8 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Nafru
4
+ module Email
5
+ VERSION = "0.1.1"
6
+ SDK_VERSION = VERSION
7
+ end
8
+ end
@@ -0,0 +1,58 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "openssl"
4
+
5
+ module Nafru
6
+ module Email
7
+ # Svix / Standard Webhooks uyumlu imza doğrulama.
8
+ #
9
+ # imzalanan = "#{svix-id}.#{svix-timestamp}.#{ham gövde}"
10
+ # anahtar = `whsec_` sonrası base64 çözülmüş `signing_secret`
11
+ # başlık = `v1,<base64 HMAC-SHA256>` (anahtar döndürmede boşlukla ayrılmış birden çok imza)
12
+ module Webhook
13
+ SECRET_PREFIX = "whsec_"
14
+ DEFAULT_TOLERANCE_SECONDS = 300
15
+ # Bir başlıkta en çok bu kadar imza denenir: aşırı uzun başlıkla CPU tüketilmesin.
16
+ MAX_SIGNATURES = 32
17
+
18
+ module_function
19
+
20
+ # İmza ve zaman damgası geçerliyse `true`. Bozuk imza ya da zaman damgası `false`; bozuk `secret` ArgumentError fırlatır.
21
+ #
22
+ # @param payload [String] HAM istek gövdesi (bayt bayt; Rails'te `request.raw_post`): ayrıştırılıp yeniden dizilmiş JSON imzayı bozar.
23
+ # @param headers [Hash] `{ id:, timestamp:, signature: }` (`svix-id`, `svix-timestamp`, `svix-signature`)
24
+ # @param secret [String] webhook'un `signing_secret` değeri (`whsec_…`)
25
+ # @param now [Time, Numeric] şimdiki zaman (Time ya da Unix saniyesi); testler için
26
+ def verify(payload:, headers:, secret:, tolerance_seconds: DEFAULT_TOLERANCE_SECONDS, now: Time.now)
27
+ raise ArgumentError, "Geçersiz imza anahtarı: whsec_ ile başlamalı" unless secret.start_with?(SECRET_PREFIX)
28
+
29
+ key = begin
30
+ secret.delete_prefix(SECRET_PREFIX).unpack1("m0")
31
+ rescue ArgumentError
32
+ raise ArgumentError, "Geçersiz imza anahtarı: base64 çözülemedi"
33
+ end
34
+
35
+ id, timestamp, signature = (headers || {}).to_h.transform_keys(&:to_sym).values_at(:id, :timestamp, :signature).map(&:to_s)
36
+ return false if [id, timestamp, signature].any?(&:empty?)
37
+
38
+ # Fark mutlak değerle alınır: hem eski hem gelecekteki zaman damgası reddedilir.
39
+ stamp = Float(timestamp, exception: false)
40
+ return false if stamp.nil? || !stamp.finite? || (now.to_i - stamp).abs > tolerance_seconds
41
+
42
+ expected = OpenSSL::HMAC.digest("SHA256", key, "#{id}.#{timestamp}.#{payload}")
43
+ signature.split(" ").first(MAX_SIGNATURES).any? do |candidate|
44
+ version, value = candidate.split(",", 2)
45
+ next false unless version == "v1" && value && !value.empty?
46
+
47
+ mac = begin
48
+ value.unpack1("m0")
49
+ rescue ArgumentError
50
+ next false
51
+ end
52
+ # Sabit zamanlı karşılaştırma (uzunluk gizli değil: HMAC-SHA256 hep 32 bayt).
53
+ mac.bytesize == expected.bytesize && OpenSSL.fixed_length_secure_compare(mac, expected)
54
+ end
55
+ end
56
+ end
57
+ end
58
+ end
@@ -0,0 +1,12 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "nafru_email/version"
4
+ require_relative "nafru_email/error"
5
+ require_relative "nafru_email/http"
6
+ require_relative "nafru_email/dev_outbox"
7
+ require_relative "nafru_email/resources"
8
+ require_relative "nafru_email/pagination"
9
+ require_relative "nafru_email/webhook"
10
+ require_relative "nafru_email/app"
11
+ require_relative "nafru_email/client"
12
+