mailcore 0.1.0.alpha.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.
Files changed (47) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +15 -0
  3. data/CONTRIBUTING.md +22 -0
  4. data/LICENSE +21 -0
  5. data/README.md +185 -0
  6. data/SECURITY.md +28 -0
  7. data/app/controllers/action_mailbox/ingresses/mailcore/inbound_emails_controller.rb +59 -0
  8. data/docs/cobertura.md +140 -0
  9. data/docs/integracao-api-interrupcao.json +27 -0
  10. data/docs/integracao-api.json +48 -0
  11. data/docs/rails.md +185 -0
  12. data/docs/ruby-3.3.13-http-padrao.json +22 -0
  13. data/docs/ruby-3.3.13.json +66 -0
  14. data/docs/ruby-3.4.12-http-padrao.json +22 -0
  15. data/docs/ruby-3.4.12.json +66 -0
  16. data/docs/ruby-4.0.9-http-padrao.json +22 -0
  17. data/docs/ruby-4.0.9.json +66 -0
  18. data/docs/sinatra.md +94 -0
  19. data/docs/verificacao.md +100 -0
  20. data/exemplos/enviar.rb +14 -0
  21. data/exemplos/sinatra.rb +65 -0
  22. data/lib/mailcore/action_mailer.rb +124 -0
  23. data/lib/mailcore/assinatura.rb +64 -0
  24. data/lib/mailcore/cliente.rb +27 -0
  25. data/lib/mailcore/configuracao.rb +30 -0
  26. data/lib/mailcore/engine.rb +71 -0
  27. data/lib/mailcore/erros.rb +25 -0
  28. data/lib/mailcore/limite_ingress.rb +51 -0
  29. data/lib/mailcore/railtie.rb +16 -0
  30. data/lib/mailcore/recursos/automacoes.rb +52 -0
  31. data/lib/mailcore/recursos/base.rb +29 -0
  32. data/lib/mailcore/recursos/caixas.rb +199 -0
  33. data/lib/mailcore/recursos/campanhas.rb +72 -0
  34. data/lib/mailcore/recursos/chaves_api.rb +23 -0
  35. data/lib/mailcore/recursos/cli.rb +24 -0
  36. data/lib/mailcore/recursos/contatos.rb +179 -0
  37. data/lib/mailcore/recursos/dominios.rb +35 -0
  38. data/lib/mailcore/recursos/emails.rb +54 -0
  39. data/lib/mailcore/recursos/eventos.rb +31 -0
  40. data/lib/mailcore/recursos/modelos.rb +106 -0
  41. data/lib/mailcore/recursos/recebimento.rb +54 -0
  42. data/lib/mailcore/recursos/webhooks.rb +57 -0
  43. data/lib/mailcore/resposta.rb +22 -0
  44. data/lib/mailcore/transporte.rb +284 -0
  45. data/lib/mailcore/versao.rb +5 -0
  46. data/lib/mailcore.rb +36 -0
  47. metadata +189 -0
@@ -0,0 +1,106 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Mailcore
4
+ module Recursos
5
+ class VersoesModelo < Base
6
+ def listar(modelo_id, filtros = {}, **opcoes)
7
+ executar('GET', "/templates/#{segmento(modelo_id)}/versions", consulta: filtros, **opcoes)
8
+ end
9
+
10
+ def obter(modelo_id, versao_id, **opcoes)
11
+ executar('GET', "/templates/#{segmento(modelo_id)}/versions/#{segmento(versao_id)}", **opcoes)
12
+ end
13
+
14
+ def restaurar(modelo_id, versao_id, pedido, **opcoes)
15
+ executar(
16
+ 'POST',
17
+ "/templates/#{segmento(modelo_id)}/versions/#{segmento(versao_id)}/restore",
18
+ corpo: pedido,
19
+ **opcoes,
20
+ )
21
+ end
22
+
23
+ def prever(modelo_id, versao_id, pedido = {}, **opcoes)
24
+ executar(
25
+ 'POST',
26
+ "/templates/#{segmento(modelo_id)}/versions/#{segmento(versao_id)}/preview",
27
+ corpo: pedido,
28
+ **opcoes,
29
+ )
30
+ end
31
+ end
32
+
33
+ class PastasModelo < Base
34
+ def listar(**opcoes)
35
+ executar('GET', "/template-folders", **opcoes)
36
+ end
37
+
38
+ def criar(pedido, **opcoes)
39
+ executar('POST', "/template-folders", corpo: pedido, **opcoes)
40
+ end
41
+
42
+ def atualizar(id, pedido, **opcoes)
43
+ executar('PATCH', "/template-folders/#{segmento(id)}", corpo: pedido, **opcoes)
44
+ end
45
+
46
+ def remover(id, **opcoes)
47
+ executar('DELETE', "/template-folders/#{segmento(id)}", **opcoes)
48
+ end
49
+ end
50
+
51
+ class Modelos < Base
52
+ attr_reader :versoes, :pastas
53
+
54
+ def initialize(transporte)
55
+ super(transporte)
56
+ @versoes = VersoesModelo.new(transporte)
57
+ @pastas = PastasModelo.new(transporte)
58
+ end
59
+
60
+ def criar(pedido, **opcoes)
61
+ executar('POST', "/templates", corpo: pedido, **opcoes)
62
+ end
63
+
64
+ def listar(filtros = {}, **opcoes)
65
+ executar('GET', "/templates", consulta: filtros, **opcoes)
66
+ end
67
+
68
+ def obter(id, **opcoes)
69
+ executar('GET', "/templates/#{segmento(id)}", **opcoes)
70
+ end
71
+
72
+ def atualizar(id, pedido, **opcoes)
73
+ executar('PATCH', "/templates/#{segmento(id)}", corpo: pedido, **opcoes)
74
+ end
75
+
76
+ def remover(id, **opcoes)
77
+ executar('DELETE', "/templates/#{segmento(id)}", **opcoes)
78
+ end
79
+
80
+ def prever(id, pedido, **opcoes)
81
+ executar('POST', "/templates/#{segmento(id)}/preview", corpo: pedido, **opcoes)
82
+ end
83
+
84
+ def obter_previa(organizacao_id, previa_id, **opcoes)
85
+ executar(
86
+ 'GET',
87
+ "/template-previews/#{segmento(organizacao_id)}/#{segmento(previa_id)}",
88
+ formato: :texto,
89
+ **opcoes,
90
+ )
91
+ end
92
+
93
+ def testar(id, pedido, **opcoes)
94
+ executar('POST', "/templates/#{segmento(id)}/test", corpo: pedido, **opcoes)
95
+ end
96
+
97
+ def publicar(id, pedido, **opcoes)
98
+ executar('POST', "/templates/#{segmento(id)}/publish", corpo: pedido, **opcoes)
99
+ end
100
+
101
+ def duplicar(id, pedido = {}, **opcoes)
102
+ executar('POST', "/templates/#{segmento(id)}/duplicate", corpo: pedido, **opcoes)
103
+ end
104
+ end
105
+ end
106
+ end
@@ -0,0 +1,54 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Mailcore
4
+ module Recursos
5
+ class Recebimento < Base
6
+ def listar(filtros = {}, **opcoes)
7
+ executar('GET', "/receiving", consulta: filtros, **opcoes)
8
+ end
9
+
10
+ def obter(id, filtros = {}, **opcoes)
11
+ executar('GET', "/receiving/#{segmento(id)}", consulta: filtros, **opcoes)
12
+ end
13
+
14
+ def listar_anexos(id, **opcoes)
15
+ executar('GET', "/receiving/#{segmento(id)}/attachments", **opcoes)
16
+ end
17
+
18
+ def obter_anexo(id, anexo_id, **opcoes)
19
+ executar('GET', "/receiving/#{segmento(id)}/attachments/#{segmento(anexo_id)}", **opcoes)
20
+ end
21
+
22
+ def baixar_original(id, **opcoes)
23
+ executar('GET', "/receiving/#{segmento(id)}/raw", formato: :bytes, **opcoes)
24
+ end
25
+
26
+ def baixar_anexo(id, anexo_id, **opcoes)
27
+ executar(
28
+ 'GET',
29
+ "/receiving/#{segmento(id)}/attachments/#{segmento(anexo_id)}/download",
30
+ formato: :bytes,
31
+ **opcoes,
32
+ )
33
+ end
34
+
35
+ def baixar_original_fluxo(id, **opcoes, &bloco)
36
+ raise ArgumentError, 'Forneça um bloco para consumir o download.' unless bloco
37
+
38
+ executar('GET', "/receiving/#{segmento(id)}/raw", formato: :fluxo, **opcoes, &bloco)
39
+ end
40
+
41
+ def baixar_anexo_fluxo(id, anexo_id, **opcoes, &bloco)
42
+ raise ArgumentError, 'Forneça um bloco para consumir o download.' unless bloco
43
+
44
+ executar(
45
+ 'GET',
46
+ "/receiving/#{segmento(id)}/attachments/#{segmento(anexo_id)}/download",
47
+ formato: :fluxo,
48
+ **opcoes,
49
+ &bloco
50
+ )
51
+ end
52
+ end
53
+ end
54
+ end
@@ -0,0 +1,57 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Mailcore
4
+ module Recursos
5
+ class EventosWebhook < Base
6
+ def listar(webhook_id, filtros = {}, **opcoes)
7
+ executar('GET', "/webhooks/#{segmento(webhook_id)}/events", consulta: filtros, **opcoes)
8
+ end
9
+
10
+ def obter(webhook_id, evento_id, **opcoes)
11
+ executar('GET', "/webhooks/#{segmento(webhook_id)}/events/#{segmento(evento_id)}", **opcoes)
12
+ end
13
+
14
+ def listar_tentativas(webhook_id, evento_id, filtros = {}, **opcoes)
15
+ executar(
16
+ 'GET',
17
+ "/webhooks/#{segmento(webhook_id)}/events/#{segmento(evento_id)}/attempts",
18
+ consulta: filtros,
19
+ **opcoes,
20
+ )
21
+ end
22
+
23
+ def reenviar(webhook_id, evento_id, **opcoes)
24
+ executar('POST', "/webhooks/#{segmento(webhook_id)}/events/#{segmento(evento_id)}/replay", **opcoes)
25
+ end
26
+ end
27
+
28
+ class Webhooks < Base
29
+ attr_reader :eventos
30
+
31
+ def initialize(transporte)
32
+ super(transporte)
33
+ @eventos = EventosWebhook.new(transporte)
34
+ end
35
+
36
+ def criar(pedido, **opcoes)
37
+ executar('POST', "/webhooks", corpo: pedido, **opcoes)
38
+ end
39
+
40
+ def listar(filtros = {}, **opcoes)
41
+ executar('GET', "/webhooks", consulta: filtros, **opcoes)
42
+ end
43
+
44
+ def obter(id, **opcoes)
45
+ executar('GET', "/webhooks/#{segmento(id)}", **opcoes)
46
+ end
47
+
48
+ def atualizar(id, pedido, **opcoes)
49
+ executar('PATCH', "/webhooks/#{segmento(id)}", corpo: pedido, **opcoes)
50
+ end
51
+
52
+ def remover(id, **opcoes)
53
+ executar('DELETE', "/webhooks/#{segmento(id)}", **opcoes)
54
+ end
55
+ end
56
+ end
57
+ end
@@ -0,0 +1,22 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Mailcore
4
+ # JSON mantém o envelope da API; downloads usam String binária ou contagem de bytes.
5
+ class Resposta
6
+ attr_reader :dados, :status, :cabecalhos
7
+
8
+ def initialize(dados:, status:, cabecalhos: {})
9
+ @dados = dados
10
+ @status = status
11
+ @cabecalhos = cabecalhos.freeze
12
+ end
13
+
14
+ def [](chave)
15
+ dados[chave]
16
+ end
17
+
18
+ def inspect
19
+ "#<Mailcore::Resposta status=#{status}>"
20
+ end
21
+ end
22
+ end
@@ -0,0 +1,284 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'net/http'
4
+ require 'json'
5
+ require 'openssl'
6
+ require 'timeout'
7
+ require 'uri'
8
+
9
+ module Mailcore
10
+ # Sessão por operação: seguro entre threads e após fork, sem estado de requisição global.
11
+ class Transporte
12
+ RESERVADOS = %w[authorization proxy-authorization cookie host content-length content-type
13
+ accept connection transfer-encoding idempotency-key user-agent accept-encoding].freeze
14
+ OMITIDOS = %w[set-cookie authorization proxy-authorization].freeze
15
+ METODOS = { 'GET' => Net::HTTP::Get, 'POST' => Net::HTTP::Post, 'PUT' => Net::HTTP::Put,
16
+ 'PATCH' => Net::HTTP::Patch, 'DELETE' => Net::HTTP::Delete }.freeze
17
+ class PrazoInterno < Timeout::Error; end
18
+ class FalhaConsumidor < StandardError
19
+ attr_reader :original
20
+
21
+ def initialize(original)
22
+ @original = original
23
+ end
24
+ end
25
+
26
+ def initialize(chave_api:, url_base: 'https://mailcore.com.br/v1', tempo_limite: 30,
27
+ tempo_conexao: 10, limite_resposta_bytes: 16 * 1024 * 1024,
28
+ certificado_ca: nil, permitir_http_local: false)
29
+ validar_chave(chave_api) unless chave_api.respond_to?(:call)
30
+ @chave_api = chave_api.is_a?(String) ? chave_api.dup.freeze : chave_api
31
+ @base = validar_base(url_base, permitir_http_local)
32
+ @tempo_limite = validar_tempo(tempo_limite)
33
+ @tempo_conexao = validar_tempo(tempo_conexao)
34
+ @limite_resposta_bytes = validar_limite(limite_resposta_bytes)
35
+ unless certificado_ca.nil? || (certificado_ca.is_a?(String) && File.file?(certificado_ca))
36
+ raise ErroConfiguracao, 'O certificado da autoridade deve apontar para um arquivo acessível.'
37
+ end
38
+ @certificado_ca = certificado_ca&.dup&.freeze
39
+ end
40
+
41
+ def inspect
42
+ '#<Mailcore::Transporte credenciais=protegidas>'
43
+ end
44
+
45
+ def executar(metodo, caminho, corpo: nil, consulta: nil, formato: :json, aceitar_vazio: false,
46
+ chave_idempotencia: nil, tempo_limite: @tempo_limite,
47
+ limite_resposta_bytes: @limite_resposta_bytes, cabecalhos: {}, &bloco)
48
+ tempo = validar_tempo(tempo_limite)
49
+ limite = validar_limite(limite_resposta_bytes)
50
+ raise ErroConfiguracao, 'Formato de resposta inválido.' unless %i[json texto bytes fluxo].include?(formato)
51
+ raise ErroConfiguracao, 'Forneça um bloco para consumir o fluxo.' if formato == :fluxo && !bloco
52
+ classe = METODOS[metodo]
53
+ raise ErroConfiguracao, 'Método HTTP não permitido.' unless classe
54
+ url = criar_url(caminho, consulta)
55
+ pedido = classe.new(url.request_uri)
56
+ pedido['Accept'] = %i[bytes fluxo].include?(formato) ? 'application/octet-stream' : (formato == :texto ? 'text/html' : 'application/json')
57
+ pedido['Accept-Encoding'] = 'identity'
58
+ pedido['User-Agent'] = "mailcore-ruby/#{VERSAO}"
59
+ preparar_cabecalhos(pedido, cabecalhos, chave_idempotencia)
60
+ unless corpo.nil?
61
+ validar_json(corpo)
62
+ pedido.body = JSON.generate(corpo)
63
+ # Importações CSV usam o limite JSON maior do servidor; cada rota valida seu próprio DTO.
64
+ raise ErroConfiguracao, 'O corpo JSON ultrapassa 50 MiB.' if pedido.body.bytesize > 50 * 1024 * 1024
65
+ pedido['Content-Type'] = 'application/json'
66
+ end
67
+ chave = resolver_chave
68
+ pedido['Authorization'] = "Bearer #{chave}"
69
+ recebidos = {}
70
+ status = nil
71
+ # Além dos prazos de socket, cobre o total da operação, inclusive respostas lentas em partes.
72
+ Timeout.timeout(tempo, PrazoInterno) do
73
+ limite_instante = Process.clock_gettime(Process::CLOCK_MONOTONIC) + tempo
74
+ http = criar_http(url, tempo)
75
+ http.start do
76
+ http.request(pedido) do |resposta|
77
+ status = resposta.code.to_i
78
+ recebidos = resposta.each_header.reject { |nome, _| OMITIDOS.include?(nome) }.to_h.freeze
79
+ if status.between?(300, 399)
80
+ raise ErroApi.new(codigo: 'redirecionamento_recusado', mensagem: 'A API respondeu com um redirecionamento recusado.', status: status, cabecalhos: recebidos)
81
+ end
82
+ tamanho = resposta['content-length']
83
+ excedida(status, recebidos) if tamanho && tamanho.to_i > limite
84
+ bytes = String.new(encoding: Encoding::BINARY)
85
+ total = 0
86
+ resposta.read_body do |parte|
87
+ restante = limite_instante - Process.clock_gettime(Process::CLOCK_MONOTONIC)
88
+ raise PrazoInterno if restante <= 0
89
+ http.read_timeout = restante
90
+ total += parte.bytesize
91
+ excedida(status, recebidos) if total > limite
92
+ if formato == :fluxo && status.between?(200, 299)
93
+ begin
94
+ bloco.call(parte.b) unless parte.empty?
95
+ rescue PrazoInterno
96
+ raise
97
+ rescue StandardError => erro
98
+ raise FalhaConsumidor.new(erro)
99
+ end
100
+ else
101
+ bytes << parte.b
102
+ end
103
+ end
104
+ if tamanho && tamanho.match?(/\A[0-9]+\z/) && total != tamanho.to_i
105
+ raise ErroTransporte.new('A resposta terminou antes do tamanho anunciado.', codigo: 'resposta_incompleta', status: status, cabecalhos: recebidos)
106
+ end
107
+ unless status.between?(200, 299)
108
+ falha_http(bytes, status, recebidos)
109
+ end
110
+ dados = case formato
111
+ when :fluxo then total
112
+ when :bytes then bytes
113
+ when :texto then bytes.force_encoding(Encoding::UTF_8)
114
+ else
115
+ if bytes.empty? && (status == 204 || aceitar_vazio)
116
+ nil
117
+ else
118
+ begin
119
+ JSON.parse(bytes, symbolize_names: true)
120
+ rescue JSON::ParserError, EncodingError
121
+ raise ErroResposta.new('A API retornou JSON inválido.', codigo: 'resposta_invalida', status: status, cabecalhos: recebidos), cause: nil
122
+ end
123
+ end
124
+ end
125
+ return Resposta.new(dados: dados, status: status, cabecalhos: recebidos)
126
+ end
127
+ end
128
+ end
129
+ rescue FalhaConsumidor => erro
130
+ raise erro.original
131
+ rescue PrazoInterno, Net::OpenTimeout, Net::ReadTimeout, Net::WriteTimeout
132
+ raise ErroTransporte.new('A operação excedeu o prazo configurado.', codigo: 'prazo_excedido', cabecalhos: recebidos || {}), cause: nil
133
+ rescue SocketError, IOError, SystemCallError, OpenSSL::SSL::SSLError, Net::HTTPBadResponse, Net::ProtocolError
134
+ raise ErroTransporte.new('Não foi possível concluir a comunicação com a API.', codigo: 'falha_de_rede', cabecalhos: recebidos || {}), cause: nil
135
+ end
136
+
137
+ private
138
+
139
+ def validar_tempo(valor)
140
+ unless valor.is_a?(Numeric) && valor.finite? && valor.positive? && valor <= 86_400
141
+ raise ErroConfiguracao, 'O prazo deve ser positivo, em segundos, até 86400.'
142
+ end
143
+ valor
144
+ end
145
+
146
+ def validar_limite(valor)
147
+ raise ErroConfiguracao, 'O limite deve ser um inteiro positivo em bytes.' unless valor.is_a?(Integer) && valor.positive?
148
+ valor
149
+ end
150
+
151
+ def validar_chave(valor)
152
+ unless valor.is_a?(String) && valor.match?(/\A[\x21-\x7e]+\z/)
153
+ raise ErroConfiguracao, 'Informe a chave de API Mailcore ou configure MAILCORE_API_KEY.'
154
+ end
155
+ valor
156
+ end
157
+
158
+ def resolver_chave
159
+ valor = @chave_api.respond_to?(:call) ? @chave_api.call : @chave_api
160
+ validar_chave(valor)
161
+ rescue StandardError
162
+ raise ErroConfiguracao, 'Não foi possível obter uma chave de API válida.', cause: nil
163
+ end
164
+
165
+ def validar_base(valor, permitir_http_local)
166
+ base = URI.parse(valor)
167
+ local = %w[localhost 127.0.0.1 ::1].include?(base.hostname)
168
+ unless base.is_a?(URI::HTTP) && base.host && !base.userinfo && !base.query && !base.fragment &&
169
+ (base.scheme == 'https' || (base.scheme == 'http' && local && permitir_http_local == true))
170
+ raise ErroConfiguracao, 'Use URL base HTTPS sem credenciais, parâmetros ou fragmentos.'
171
+ end
172
+ base.path = base.path.sub(%r{/+\z}, '')
173
+ base.freeze
174
+ rescue URI::InvalidURIError, TypeError, NoMethodError
175
+ raise ErroConfiguracao, 'Use URL base HTTPS válida.', cause: nil
176
+ end
177
+
178
+ def criar_url(caminho, consulta)
179
+ unless caminho.is_a?(String) && caminho.start_with?('/') && !caminho.start_with?('//') &&
180
+ !caminho.match?(/[?#\\\x00-\x20]/) && !caminho.split('/').any? { |parte| ['.', '..'].include?(parte) }
181
+ raise ErroConfiguracao, 'Caminho de recurso inválido.'
182
+ end
183
+ url = @base.dup
184
+ url.path = @base.path + caminho
185
+ unless consulta.nil? || consulta.is_a?(Hash)
186
+ raise ErroConfiguracao, 'A consulta deve ser um Hash de valores simples.'
187
+ end
188
+ pares = (consulta || {}).filter_map do |nome, valor|
189
+ next if valor.nil?
190
+ unless [String, Symbol, Numeric, TrueClass, FalseClass].any? { |tipo| valor.is_a?(tipo) }
191
+ raise ErroConfiguracao, 'A consulta deve conter apenas valores simples.'
192
+ end
193
+ [nome.to_s, valor.to_s]
194
+ end
195
+ url.query = URI.encode_www_form(pares) unless pares.empty?
196
+ url
197
+ end
198
+
199
+ def preparar_cabecalhos(pedido, cabecalhos, chave_idempotencia)
200
+ raise ErroConfiguracao, 'Cabeçalhos devem ser um Hash.' unless cabecalhos.is_a?(Hash)
201
+ cabecalhos.each do |nome, valor|
202
+ nome = nome.to_s
203
+ unless nome.match?(/\A[!#$%&'*+.^_`|~0-9A-Za-z-]+\z/) && !RESERVADOS.include?(nome.downcase) &&
204
+ valor.is_a?(String) && !valor.match?(/[\x00-\x1f\x7f]/)
205
+ raise ErroConfiguracao, 'Cabeçalho inválido ou reservado pelo SDK.'
206
+ end
207
+ pedido[nome] = valor
208
+ end
209
+ return if chave_idempotencia.nil?
210
+ unless chave_idempotencia.is_a?(String) && chave_idempotencia.match?(/\A[\x21-\x7e]{1,256}\z/)
211
+ raise ErroConfiguracao, 'A chave de idempotência deve ter de 1 a 256 caracteres ASCII visíveis.'
212
+ end
213
+ pedido['Idempotency-Key'] = chave_idempotencia
214
+ end
215
+
216
+ def validar_json(valor, profundidade = 0)
217
+ raise ErroConfiguracao, 'Corpo JSON profundo demais.' if profundidade > 100
218
+ case valor
219
+ when Hash
220
+ nomes = {}
221
+ valor.each do |nome, item|
222
+ unless nome.is_a?(String) || nome.is_a?(Symbol)
223
+ raise ErroConfiguracao, 'As chaves do JSON devem ser strings ou símbolos únicos.'
224
+ end
225
+ normalizado = validar_texto(nome.to_s)
226
+ raise ErroConfiguracao, 'As chaves do JSON devem ser únicas em UTF-8.' if nomes.key?(normalizado)
227
+ nomes[normalizado] = true
228
+ validar_json(item, profundidade + 1)
229
+ end
230
+ when Array then valor.each { |item| validar_json(item, profundidade + 1) }
231
+ when String
232
+ validar_texto(valor)
233
+ when Integer, Float
234
+ raise ErroConfiguracao, 'Número JSON deve ser finito.' unless valor.finite?
235
+ when TrueClass, FalseClass, NilClass then nil
236
+ else raise ErroConfiguracao, 'O corpo deve conter somente valores JSON.'
237
+ end
238
+ end
239
+
240
+ def validar_texto(valor)
241
+ texto = valor.encode(Encoding::UTF_8)
242
+ unless valor.valid_encoding? && texto.valid_encoding?
243
+ raise ErroConfiguracao, 'Texto JSON deve usar UTF-8 válido.'
244
+ end
245
+ texto
246
+ rescue EncodingError
247
+ raise ErroConfiguracao, 'Texto JSON deve usar UTF-8 válido.', cause: nil
248
+ end
249
+
250
+ def criar_http(url, tempo)
251
+ # Não herda proxies do ambiente nem repete GET automaticamente após desconexão.
252
+ http = Net::HTTP.new(url.hostname, url.port, nil)
253
+ http.max_retries = 0
254
+ http.ignore_eof = false if http.respond_to?(:ignore_eof=)
255
+ http.open_timeout = [@tempo_conexao, tempo].min
256
+ http.read_timeout = tempo
257
+ http.write_timeout = tempo
258
+ http.use_ssl = url.scheme == 'https'
259
+ if http.use_ssl?
260
+ http.verify_mode = OpenSSL::SSL::VERIFY_PEER
261
+ http.verify_hostname = true
262
+ http.min_version = OpenSSL::SSL::TLS1_2_VERSION
263
+ http.ca_file = @certificado_ca if @certificado_ca
264
+ end
265
+ http
266
+ end
267
+
268
+ def excedida(status, recebidos)
269
+ raise ErroResposta.new('A resposta excedeu o limite de bytes configurado.', codigo: 'resposta_excedida', status: status, cabecalhos: recebidos)
270
+ end
271
+
272
+ def falha_http(bytes, status, recebidos)
273
+ begin
274
+ erro = JSON.parse(bytes, symbolize_names: true)[:erro]
275
+ if erro.is_a?(Hash) && erro[:codigo].is_a?(String) && erro[:mensagem].is_a?(String)
276
+ raise ErroApi.new(codigo: erro[:codigo], mensagem: erro[:mensagem], status: status, cabecalhos: recebidos)
277
+ end
278
+ rescue JSON::ParserError, TypeError, NoMethodError
279
+ # Proxies podem responder texto/HTML; não reproduzir esse corpo na exceção.
280
+ end
281
+ raise ErroApi.new(codigo: 'erro_http', mensagem: 'A API retornou erro HTTP sem detalhes JSON válidos.', status: status, cabecalhos: recebidos)
282
+ end
283
+ end
284
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Mailcore
4
+ VERSAO = '0.1.0.alpha.1'
5
+ end
data/lib/mailcore.rb ADDED
@@ -0,0 +1,36 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'mailcore/versao'
4
+ require_relative 'mailcore/erros'
5
+ require_relative 'mailcore/resposta'
6
+ require_relative 'mailcore/configuracao'
7
+ require_relative 'mailcore/transporte'
8
+ require_relative 'mailcore/assinatura'
9
+ require_relative 'mailcore/recursos/base'
10
+ %w[emails dominios chaves_api contatos modelos webhooks campanhas automacoes eventos recebimento caixas cli].each do |nome|
11
+ require_relative "mailcore/recursos/#{nome}"
12
+ end
13
+ require_relative 'mailcore/cliente'
14
+
15
+ module Mailcore
16
+ class << self
17
+ def configuracao
18
+ @configuracao ||= Configuracao.new
19
+ end
20
+
21
+ # Configure no boot. Para credenciais distintas em paralelo, use instâncias Cliente.
22
+ def configurar
23
+ yield configuracao
24
+ end
25
+
26
+ def cliente
27
+ Cliente.new(**configuracao.opcoes_cliente)
28
+ end
29
+
30
+ def verificar_assinatura(**opcoes)
31
+ Assinatura.verificar(**opcoes)
32
+ end
33
+ end
34
+ end
35
+
36
+ require_relative 'mailcore/railtie' if defined?(Rails::Railtie)