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
|
@@ -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("&", "&").gsub("<", "<").gsub(">", ">").gsub('"', """)
|
|
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(" ", " ").gsub("<", "<").gsub(">", ">").gsub(""", '"').gsub("&", "&")
|
|
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,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
|
data/lib/nafru_email.rb
ADDED
|
@@ -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
|
+
|