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 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
@@ -0,0 +1,3 @@
1
+ module Codemail
2
+ VERSION = "0.1.0"
3
+ 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: []