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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +15 -0
- data/CONTRIBUTING.md +22 -0
- data/LICENSE +21 -0
- data/README.md +185 -0
- data/SECURITY.md +28 -0
- data/app/controllers/action_mailbox/ingresses/mailcore/inbound_emails_controller.rb +59 -0
- data/docs/cobertura.md +140 -0
- data/docs/integracao-api-interrupcao.json +27 -0
- data/docs/integracao-api.json +48 -0
- data/docs/rails.md +185 -0
- data/docs/ruby-3.3.13-http-padrao.json +22 -0
- data/docs/ruby-3.3.13.json +66 -0
- data/docs/ruby-3.4.12-http-padrao.json +22 -0
- data/docs/ruby-3.4.12.json +66 -0
- data/docs/ruby-4.0.9-http-padrao.json +22 -0
- data/docs/ruby-4.0.9.json +66 -0
- data/docs/sinatra.md +94 -0
- data/docs/verificacao.md +100 -0
- data/exemplos/enviar.rb +14 -0
- data/exemplos/sinatra.rb +65 -0
- data/lib/mailcore/action_mailer.rb +124 -0
- data/lib/mailcore/assinatura.rb +64 -0
- data/lib/mailcore/cliente.rb +27 -0
- data/lib/mailcore/configuracao.rb +30 -0
- data/lib/mailcore/engine.rb +71 -0
- data/lib/mailcore/erros.rb +25 -0
- data/lib/mailcore/limite_ingress.rb +51 -0
- data/lib/mailcore/railtie.rb +16 -0
- data/lib/mailcore/recursos/automacoes.rb +52 -0
- data/lib/mailcore/recursos/base.rb +29 -0
- data/lib/mailcore/recursos/caixas.rb +199 -0
- data/lib/mailcore/recursos/campanhas.rb +72 -0
- data/lib/mailcore/recursos/chaves_api.rb +23 -0
- data/lib/mailcore/recursos/cli.rb +24 -0
- data/lib/mailcore/recursos/contatos.rb +179 -0
- data/lib/mailcore/recursos/dominios.rb +35 -0
- data/lib/mailcore/recursos/emails.rb +54 -0
- data/lib/mailcore/recursos/eventos.rb +31 -0
- data/lib/mailcore/recursos/modelos.rb +106 -0
- data/lib/mailcore/recursos/recebimento.rb +54 -0
- data/lib/mailcore/recursos/webhooks.rb +57 -0
- data/lib/mailcore/resposta.rb +22 -0
- data/lib/mailcore/transporte.rb +284 -0
- data/lib/mailcore/versao.rb +5 -0
- data/lib/mailcore.rb +36 -0
- 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
|
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)
|