codemail 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/LICENSE +21 -0
- data/README.md +31 -0
- data/lib/codemail/errors.rb +28 -0
- data/lib/codemail/version.rb +3 -0
- data/lib/codemail.rb +172 -0
- metadata +54 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: 8642b106a9ff9e6155704db2a0c3c0796c4a54b167bb4f61e19939c3492fed39
|
|
4
|
+
data.tar.gz: 534bf7af8368ce614660e7db9e715331118ac7e531136e9c8ddce0cb02820786
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: 887d88c965334332ef40410ee229308a529716da7ad7566df3a700c59e4f0c12bb7b285b1928468071bf6244a7ce7c8e2130dc9ce07ae95f0cdaa2de75fdd99d
|
|
7
|
+
data.tar.gz: d0e0e268d8008e77ce87d820ef8eb28b0a3256780b805eedd3d6634166941dc256ff9e7d3574f6296c0094a9579b93717103604f6ab8dda9cfe838c877fd6aae
|
data/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Codemail
|
|
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,31 @@
|
|
|
1
|
+
# Codemail para Ruby
|
|
2
|
+
|
|
3
|
+
```bash
|
|
4
|
+
gem install codemail
|
|
5
|
+
```
|
|
6
|
+
|
|
7
|
+
```ruby
|
|
8
|
+
require "codemail"
|
|
9
|
+
|
|
10
|
+
cm = Codemail::Client.new("cm_live_...") # ou defina CODEMAIL_API_KEY
|
|
11
|
+
|
|
12
|
+
r = cm.emails.create(
|
|
13
|
+
from: "alerta@meudominio.com.br", # precisa ser do domínio da chave
|
|
14
|
+
to: "ana@cliente.com", # um endereço ou um array
|
|
15
|
+
subject: "Seu pedido foi enviado",
|
|
16
|
+
html: "<p>Olá!</p>",
|
|
17
|
+
attachments: [Codemail::Attachment.from_file("nota.pdf")]
|
|
18
|
+
)
|
|
19
|
+
puts r.id, r.status # status: queued (o envio é assíncrono)
|
|
20
|
+
|
|
21
|
+
cm.emails.get(r.id)
|
|
22
|
+
cm.emails.events(r.id)
|
|
23
|
+
cm.emails.list(status: "bounced", limit: 20)
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
`emails.create` é o `send` dos outros SDKs (em Ruby, `send` já é um método de `Object`); `deliver` é um alias.
|
|
27
|
+
|
|
28
|
+
- **Idempotente por padrão** e com retentativas (rede, 429 com `Retry-After`, 5xx), até `max_retries` (padrão 2).
|
|
29
|
+
- **Erros** (`Codemail::...`): `AuthenticationError`, `PermissionDeniedError`, `NotFoundError`, `InvalidRequestError`
|
|
30
|
+
(`#param`), `RateLimitError` (`#retry_after`), `ServerError`, `ConnectionError`; todos herdam de `Codemail::Error`.
|
|
31
|
+
- Só biblioteca padrão. Ruby 3.0+.
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
module Codemail
|
|
2
|
+
# Base de todos os erros do SDK. Nunca carrega a chave de API.
|
|
3
|
+
class Error < StandardError
|
|
4
|
+
attr_reader :status, :type, :param, :retry_after
|
|
5
|
+
|
|
6
|
+
def initialize(message, status: nil, type: nil, param: nil, retry_after: nil)
|
|
7
|
+
super(message)
|
|
8
|
+
@status = status
|
|
9
|
+
@type = type
|
|
10
|
+
@param = param
|
|
11
|
+
@retry_after = retry_after
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def to_s
|
|
15
|
+
return super unless status
|
|
16
|
+
|
|
17
|
+
"#{super} (HTTP #{status}#{type ? ", #{type}" : ""})"
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
class AuthenticationError < Error; end # 401
|
|
22
|
+
class PermissionDeniedError < Error; end # 403
|
|
23
|
+
class NotFoundError < Error; end # 404
|
|
24
|
+
class InvalidRequestError < Error; end # 400, 422 (param indica o campo)
|
|
25
|
+
class RateLimitError < Error; end # 429 (retry_after em segundos)
|
|
26
|
+
class ServerError < Error; end # 5xx
|
|
27
|
+
class ConnectionError < Error; end # sem resposta
|
|
28
|
+
end
|
data/lib/codemail.rb
ADDED
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
require "json"
|
|
2
|
+
require "net/http"
|
|
3
|
+
require "securerandom"
|
|
4
|
+
require "time"
|
|
5
|
+
require "uri"
|
|
6
|
+
require_relative "codemail/version"
|
|
7
|
+
require_relative "codemail/errors"
|
|
8
|
+
|
|
9
|
+
# SDK Ruby da Codemail.
|
|
10
|
+
#
|
|
11
|
+
# cm = Codemail::Client.new("cm_live_...")
|
|
12
|
+
# r = cm.emails.create(from: "ola@meudominio.com.br", to: "ana@cliente.com", subject: "Oi", text: "Olá!")
|
|
13
|
+
# r.id # => "..."
|
|
14
|
+
module Codemail
|
|
15
|
+
BASE_URL = "https://api.codemail.com.br/v1".freeze
|
|
16
|
+
TETO_ESPERA = 30.0
|
|
17
|
+
|
|
18
|
+
# Anexo. `content` são os bytes do arquivo (o SDK codifica em base64).
|
|
19
|
+
Attachment = Struct.new(:filename, :content, :content_type, keyword_init: true) do
|
|
20
|
+
def self.from_file(path, filename: nil, content_type: nil)
|
|
21
|
+
new(filename: filename || File.basename(path), content: File.binread(path),
|
|
22
|
+
content_type: content_type || "application/octet-stream")
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def to_h
|
|
26
|
+
{ filename: filename, content: [content.to_s].pack("m0"), content_type: content_type || "application/octet-stream" }
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
SendResult = Struct.new(:id, :status, :to, :cc, :bcc, :subject, :created_at, keyword_init: true)
|
|
31
|
+
Email = Struct.new(:id, :status, :from, :to, :subject, :error, :created_at, :updated_at, keyword_init: true)
|
|
32
|
+
EmailEvent = Struct.new(:type, :occurred_at, keyword_init: true)
|
|
33
|
+
EmailList = Struct.new(:data, :total, :limit, :offset, keyword_init: true)
|
|
34
|
+
|
|
35
|
+
def self.hora(v)
|
|
36
|
+
return nil if v.nil? || v.to_s.empty?
|
|
37
|
+
|
|
38
|
+
s = v.to_s
|
|
39
|
+
# A API devolve UTC sem fuso (2026-10-15T12:00:00).
|
|
40
|
+
Time.parse(s =~ /(Z|[+-]\d\d:?\d\d)\z/ ? s : "#{s}Z")
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
class Emails
|
|
44
|
+
def initialize(client)
|
|
45
|
+
@client = client
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# Enfileira um e-mail. `from` precisa ser do domínio da chave. O envio é assíncrono (status "queued").
|
|
49
|
+
def create(from:, to:, subject: nil, text: nil, html: nil, from_name: nil, cc: nil, bcc: nil, reply_to: nil,
|
|
50
|
+
attachments: nil, idempotency_key: nil)
|
|
51
|
+
destinos = Array(to)
|
|
52
|
+
raise InvalidRequestError.new("Informe ao menos um destinatário em `to`.", param: "to") if destinos.empty?
|
|
53
|
+
|
|
54
|
+
corpo = { from: from, to: destinos }
|
|
55
|
+
{ from_name: from_name, cc: cc && Array(cc), bcc: bcc && Array(bcc), reply_to: reply_to, subject: subject,
|
|
56
|
+
text: text, html: html }.each { |k, v| corpo[k] = v unless v.nil? }
|
|
57
|
+
if attachments && !attachments.empty?
|
|
58
|
+
corpo[:attachments] = attachments.map { |a| a.respond_to?(:to_h) && !a.is_a?(Hash) ? a.to_h : a }
|
|
59
|
+
end
|
|
60
|
+
r = @client.request(:post, "/emails", body: corpo, idempotency_key: idempotency_key || SecureRandom.uuid)
|
|
61
|
+
SendResult.new(id: r["id"], status: r["status"], to: r["to"] || [], cc: r["cc"] || [], bcc: r["bcc"] || [],
|
|
62
|
+
subject: r["subject"].to_s, created_at: Codemail.hora(r["created_at"]))
|
|
63
|
+
end
|
|
64
|
+
alias deliver create
|
|
65
|
+
|
|
66
|
+
def get(id)
|
|
67
|
+
email(@client.request(:get, "/emails/#{URI.encode_www_form_component(id)}"))
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def list(status: nil, search: nil, start_date: nil, end_date: nil, limit: nil, offset: nil)
|
|
71
|
+
dia = ->(d) { d.respond_to?(:strftime) ? d.strftime("%Y-%m-%d") : d }
|
|
72
|
+
r = @client.request(:get, "/emails", query: { status: status, search: search, start_date: dia.(start_date),
|
|
73
|
+
end_date: dia.(end_date), limit: limit, offset: offset })
|
|
74
|
+
EmailList.new(data: (r["data"] || []).map { |m| email(m) }, total: r["total"], limit: r["limit"],
|
|
75
|
+
offset: r["offset"])
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def events(id)
|
|
79
|
+
r = @client.request(:get, "/emails/#{URI.encode_www_form_component(id)}/events")
|
|
80
|
+
(r["data"] || []).map { |e| EmailEvent.new(type: e["type"], occurred_at: Codemail.hora(e["occurred_at"])) }
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
private
|
|
84
|
+
|
|
85
|
+
def email(d)
|
|
86
|
+
Email.new(id: d["id"], status: d["status"], from: d["mail_from"], to: d["to"] || [], subject: d["subject"],
|
|
87
|
+
error: d["error"], created_at: Codemail.hora(d["created_at"]), updated_at: Codemail.hora(d["updated_at"]))
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
class Client
|
|
92
|
+
attr_reader :emails, :base_url
|
|
93
|
+
|
|
94
|
+
def initialize(api_key = nil, base_url: nil, timeout: 30, max_retries: 2)
|
|
95
|
+
key = api_key || ENV["CODEMAIL_API_KEY"]
|
|
96
|
+
raise AuthenticationError, "Informe a chave de API ou defina CODEMAIL_API_KEY." if key.nil? || key.empty?
|
|
97
|
+
|
|
98
|
+
@api_key = key
|
|
99
|
+
@base_url = (base_url || ENV["CODEMAIL_API_URL"] || BASE_URL).sub(%r{/+\z}, "")
|
|
100
|
+
@timeout = timeout
|
|
101
|
+
@max_retries = [max_retries.to_i, 0].max
|
|
102
|
+
@emails = Emails.new(self)
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
# A chave nunca aparece em inspect/logs.
|
|
106
|
+
def inspect
|
|
107
|
+
"#<Codemail::Client base_url=#{@base_url.inspect}>"
|
|
108
|
+
end
|
|
109
|
+
alias to_s inspect
|
|
110
|
+
|
|
111
|
+
# @api private
|
|
112
|
+
def request(metodo, caminho, body: nil, query: nil, idempotency_key: nil)
|
|
113
|
+
uri = URI.parse(@base_url + caminho)
|
|
114
|
+
q = (query || {}).reject { |_, v| v.nil? }
|
|
115
|
+
uri.query = URI.encode_www_form(q) unless q.empty?
|
|
116
|
+
tentativa = 0
|
|
117
|
+
loop do
|
|
118
|
+
begin
|
|
119
|
+
return uma_vez(metodo, uri, body, idempotency_key)
|
|
120
|
+
rescue ConnectionError, RateLimitError, ServerError => e
|
|
121
|
+
raise if tentativa >= @max_retries
|
|
122
|
+
|
|
123
|
+
espera = e.retry_after ? [e.retry_after, TETO_ESPERA].min : 0.5 * (2**tentativa) * (0.5 + rand / 2)
|
|
124
|
+
sleep(espera)
|
|
125
|
+
tentativa += 1
|
|
126
|
+
end
|
|
127
|
+
end
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
private
|
|
131
|
+
|
|
132
|
+
def uma_vez(metodo, uri, body, idem)
|
|
133
|
+
req = (metodo == :post ? Net::HTTP::Post : Net::HTTP::Get).new(uri)
|
|
134
|
+
req["Authorization"] = "Bearer #{@api_key}"
|
|
135
|
+
req["Accept"] = "application/json"
|
|
136
|
+
req["User-Agent"] = "codemail-ruby/#{VERSION}"
|
|
137
|
+
req["Idempotency-Key"] = idem if idem
|
|
138
|
+
if body
|
|
139
|
+
req["Content-Type"] = "application/json"
|
|
140
|
+
req.body = JSON.generate(body)
|
|
141
|
+
end
|
|
142
|
+
res = Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == "https", open_timeout: @timeout,
|
|
143
|
+
read_timeout: @timeout) { |h| h.request(req) }
|
|
144
|
+
return res.body.to_s.empty? ? {} : JSON.parse(res.body) if res.code.to_i.between?(200, 299)
|
|
145
|
+
|
|
146
|
+
raise erro_http(res)
|
|
147
|
+
rescue SocketError, SystemCallError, Net::OpenTimeout, Net::ReadTimeout, IOError, OpenSSL::SSL::SSLError => e
|
|
148
|
+
raise ConnectionError, "Não foi possível falar com a Codemail: #{e.class}: #{e.message}"
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
def erro_http(res)
|
|
152
|
+
status = res.code.to_i
|
|
153
|
+
info = begin
|
|
154
|
+
JSON.parse(res.body.to_s)["error"] || {}
|
|
155
|
+
rescue JSON::ParserError
|
|
156
|
+
{}
|
|
157
|
+
end
|
|
158
|
+
ra = res["Retry-After"]
|
|
159
|
+
retry_after = ra && ra =~ /\A\d+(\.\d+)?\z/ ? ra.to_f : nil
|
|
160
|
+
classe = case status
|
|
161
|
+
when 401 then AuthenticationError
|
|
162
|
+
when 403 then PermissionDeniedError
|
|
163
|
+
when 404 then NotFoundError
|
|
164
|
+
when 429 then RateLimitError
|
|
165
|
+
when 500.. then ServerError
|
|
166
|
+
else InvalidRequestError
|
|
167
|
+
end
|
|
168
|
+
classe.new(info["message"] || "A API respondeu #{status}", status: status, type: info["type"],
|
|
169
|
+
param: info["param"], retry_after: retry_after)
|
|
170
|
+
end
|
|
171
|
+
end
|
|
172
|
+
end
|
metadata
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
|
2
|
+
name: codemail
|
|
3
|
+
version: !ruby/object:Gem::Version
|
|
4
|
+
version: 0.1.0
|
|
5
|
+
platform: ruby
|
|
6
|
+
authors:
|
|
7
|
+
- Codemail
|
|
8
|
+
autorequire:
|
|
9
|
+
bindir: bin
|
|
10
|
+
cert_chain: []
|
|
11
|
+
date: 2026-10-19 00:00:00.000000000 Z
|
|
12
|
+
dependencies: []
|
|
13
|
+
description: Cliente oficial da API do Transacional da Codemail, com idempotência
|
|
14
|
+
automática, retentativas e erros tipados.
|
|
15
|
+
email:
|
|
16
|
+
- suporte@codemail.com.br
|
|
17
|
+
executables: []
|
|
18
|
+
extensions: []
|
|
19
|
+
extra_rdoc_files: []
|
|
20
|
+
files:
|
|
21
|
+
- LICENSE
|
|
22
|
+
- README.md
|
|
23
|
+
- lib/codemail.rb
|
|
24
|
+
- lib/codemail/errors.rb
|
|
25
|
+
- lib/codemail/version.rb
|
|
26
|
+
homepage: https://codemail.com.br
|
|
27
|
+
licenses:
|
|
28
|
+
- MIT
|
|
29
|
+
metadata:
|
|
30
|
+
homepage_uri: https://codemail.com.br
|
|
31
|
+
source_code_uri: https://github.com/codemail/codemail-ruby
|
|
32
|
+
bug_tracker_uri: https://github.com/codemail/codemail-ruby/issues
|
|
33
|
+
changelog_uri: https://github.com/codemail/codemail-ruby/blob/main/CHANGELOG.md
|
|
34
|
+
rubygems_mfa_required: 'true'
|
|
35
|
+
post_install_message:
|
|
36
|
+
rdoc_options: []
|
|
37
|
+
require_paths:
|
|
38
|
+
- lib
|
|
39
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
|
40
|
+
requirements:
|
|
41
|
+
- - ">="
|
|
42
|
+
- !ruby/object:Gem::Version
|
|
43
|
+
version: '3.0'
|
|
44
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
45
|
+
requirements:
|
|
46
|
+
- - ">="
|
|
47
|
+
- !ruby/object:Gem::Version
|
|
48
|
+
version: '0'
|
|
49
|
+
requirements: []
|
|
50
|
+
rubygems_version: 3.5.22
|
|
51
|
+
signing_key:
|
|
52
|
+
specification_version: 4
|
|
53
|
+
summary: 'SDK Ruby da Codemail: envio de e-mail transacional por API.'
|
|
54
|
+
test_files: []
|