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,65 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'sinatra/base'
4
+ require 'mailcore'
5
+ require 'openssl'
6
+ require 'digest'
7
+
8
+ class ExemploMailcoreSinatra < Sinatra::Base
9
+ disable :show_exceptions, :dump_errors, :logging, :sessions, :static
10
+ set :raise_errors, false
11
+
12
+ def self.construir(ambiente: ENV, cliente: nil)
13
+ chave, token = ambiente.values_at('MAILCORE_API_KEY', 'TOKEN_APLICACAO')
14
+ remetente, destinatario = ambiente.values_at('MAILCORE_REMETENTE', 'MAILCORE_DESTINATARIO')
15
+ valido = chave.is_a?(String) && chave.match?(/\A[\x21-\x7e]{1,512}\z/) &&
16
+ token.is_a?(String) && token.match?(/\A[A-Za-z0-9_-]{32,256}\z/) &&
17
+ [remetente, destinatario].all? { |valor| valor.is_a?(String) && valor.match?(/\A[^\r\n\x00]{1,320}\z/) }
18
+ raise ArgumentError, 'Configure as credenciais e os endereços fixos da aplicação.' unless valido
19
+ resumo = Digest::SHA256.digest(token)
20
+ if OpenSSL.fixed_length_secure_compare(resumo, Digest::SHA256.digest(chave))
21
+ raise ArgumentError, 'Use credenciais diferentes para a aplicação e a API.'
22
+ end
23
+ sdk = cliente || Mailcore::Cliente.new(chave_api: chave, url_base: ambiente.fetch('MAILCORE_API_URL', Mailcore.configuracao.url_base))
24
+ Class.new(self) do
25
+ set :cliente_mailcore, sdk
26
+ set :resumo_token, resumo
27
+ set :pedido_mailcore, { remetente: remetente, destinatarios: [destinatario].freeze,
28
+ assunto: 'Mensagem de exemplo', texto: 'Integração Mailcore com Sinatra.' }.freeze
29
+ end
30
+ end
31
+
32
+ before do
33
+ content_type :json
34
+ headers 'cache-control' => 'no-store'
35
+ end
36
+
37
+ post '/enviar' do
38
+ autorizacao = request.env['HTTP_AUTHORIZATION'].to_s.match(/\ABearer ([A-Za-z0-9_-]{32,256})\z/)
39
+ halt 401, JSON.generate(erro: 'Acesso não autorizado.') unless autorizacao &&
40
+ OpenSSL.fixed_length_secure_compare(Digest::SHA256.digest(autorizacao[1]), settings.resumo_token)
41
+ chave = request.env['HTTP_IDEMPOTENCY_KEY'].to_s
42
+ halt 400, JSON.generate(erro: 'Informe uma chave de idempotência válida.') unless chave.match?(/\A[\x21-\x7e]{1,256}\z/) && !chave.include?(',')
43
+ halt 415, JSON.generate(erro: 'Envie um objeto JSON.') unless request.media_type == 'application/json'
44
+ corpo = request.body.read(4097).to_s.dup
45
+ halt 413, JSON.generate(erro: 'Corpo da requisição excede o limite.') if corpo.bytesize > 4096
46
+ corpo.force_encoding(Encoding::UTF_8)
47
+ halt 400, JSON.generate(erro: 'Envie um objeto JSON válido.') unless corpo.valid_encoding?
48
+ halt 400, JSON.generate(erro: 'Envie um objeto JSON válido.') unless JSON.parse(corpo).is_a?(Hash)
49
+ resposta = settings.cliente_mailcore.emails.enviar(settings.pedido_mailcore.dup, chave_idempotencia: chave)
50
+ dados = resposta.dados
51
+ halt 502, JSON.generate(erro: 'Resposta de aceitação inválida.') unless dados.is_a?(Hash) &&
52
+ dados[:id].is_a?(String) && !dados[:id].empty? && dados[:estado].is_a?(String)
53
+ status 202
54
+ JSON.generate(id: dados[:id], estado: dados[:estado])
55
+ rescue JSON::ParserError
56
+ halt 400, JSON.generate(erro: 'Envie um objeto JSON válido.')
57
+ rescue Mailcore::ErroApi => erro
58
+ halt(erro.status.to_i.between?(400, 599) ? erro.status : 502, JSON.generate(erro: 'Não foi possível aceitar a mensagem.'))
59
+ rescue Mailcore::Erro, Mailcore::ErroConfiguracao
60
+ halt 502, JSON.generate(erro: 'Não foi possível confirmar a aceitação da mensagem.')
61
+ end
62
+
63
+ not_found { JSON.generate(erro: 'Rota não encontrada.') }
64
+ error { JSON.generate(erro: 'Não foi possível concluir a operação.') }
65
+ end
@@ -0,0 +1,124 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'base64'
4
+ require 'mailcore'
5
+
6
+ module Mailcore
7
+ # Converte mensagens Mail sem implementar uma fila ou um transporte SMTP.
8
+ class EntregaActionMailer
9
+ CAMPOS_CLIENTE = %i[chave_api url_base tempo_limite tempo_conexao certificado_ca limite_resposta_bytes].freeze
10
+ CABECALHOS_RESERVADOS = %w[
11
+ from to cc bcc sender return-path reply-to subject message-id in-reply-to
12
+ references date received mime-version content-type content-transfer-encoding
13
+ authentication-results dkim-signature authorization mailcore-opcoes
14
+ mailcore_opcoes
15
+ ].freeze
16
+
17
+ attr_reader :settings
18
+
19
+ def initialize(opcoes = {})
20
+ @settings = { return_response: true }.merge(opcoes.transform_keys(&:to_sym))
21
+ end
22
+
23
+ def deliver!(mensagem)
24
+ opcoes = opcoes_mensagem(mensagem)
25
+ pedido = converter(mensagem)
26
+ pedido[:tags] = opcoes[:tags] if opcoes.key?(:tags)
27
+ pedido[:agendada_em] = opcoes[:agendada_em] if opcoes.key?(:agendada_em)
28
+ chave = opcoes.fetch(:chave_idempotencia, settings[:chave_idempotencia])
29
+ argumentos = chave.nil? ? {} : { chave_idempotencia: chave }
30
+ resposta = cliente.emails.enviar(pedido, **argumentos)
31
+ identificador = resposta.dados[:id]
32
+ mensagem.message_id = identificador if identificador
33
+ resposta
34
+ end
35
+
36
+ def inspect
37
+ "#<#{self.class.name}>"
38
+ end
39
+
40
+ private
41
+
42
+ def cliente
43
+ return settings[:cliente] if settings[:cliente]
44
+
45
+ configuracao = settings.select { |nome, _| CAMPOS_CLIENTE.include?(nome) }
46
+ return Mailcore.cliente if configuracao.empty?
47
+
48
+ Mailcore::Cliente.new(**Mailcore.configuracao.opcoes_cliente.merge(configuracao))
49
+ end
50
+
51
+ def opcoes_mensagem(mensagem)
52
+ valor = mensagem[:mailcore_opcoes]&.unparsed_value
53
+ return {} if valor.nil?
54
+ raise ArgumentError, 'mailcore_opcoes deve ser um Hash.' unless valor.is_a?(Hash)
55
+
56
+ opcoes = valor.transform_keys(&:to_sym)
57
+ desconhecidas = opcoes.keys - %i[chave_idempotencia tags agendada_em]
58
+ raise ArgumentError, 'Opção de mensagem não reconhecida.' unless desconhecidas.empty?
59
+
60
+ opcoes
61
+ end
62
+
63
+ def converter(mensagem)
64
+ remetentes = Array(mensagem[:from]&.formatted)
65
+ raise ArgumentError, 'Informe exatamente um remetente.' unless remetentes.length == 1
66
+ raise ArgumentError, 'Informe no máximo um endereço de resposta.' if Array(mensagem.reply_to).length > 1
67
+
68
+ pedido = { remetente: remetentes.first, destinatarios: Array(mensagem.to), assunto: mensagem.subject }
69
+ pedido[:cc] = mensagem.cc if mensagem.cc && !mensagem.cc.empty?
70
+ pedido[:cco] = mensagem.bcc if mensagem.bcc && !mensagem.bcc.empty?
71
+ pedido[:responder_para] = mensagem.reply_to.first if mensagem.reply_to && !mensagem.reply_to.empty?
72
+ pedido.merge!(conteudos(mensagem))
73
+ cabecalhos = mensagem.header.fields.each_with_object({}) do |campo, resultado|
74
+ nome = campo.name.to_s
75
+ normalizado = nome.downcase
76
+ next if CABECALHOS_RESERVADOS.include?(normalizado)
77
+ next if normalizado.start_with?('x-postal-', 'x-mailcore-', 'arc-', 'content-')
78
+
79
+ resultado[nome] = campo.decoded.to_s
80
+ end
81
+ pedido[:cabecalhos] = cabecalhos unless cabecalhos.empty?
82
+ anexos = mensagem.attachments.map do |parte|
83
+ anexo = {
84
+ nome: parte.filename,
85
+ tipo_conteudo: parte.mime_type || 'application/octet-stream',
86
+ conteudo_base64: Base64.strict_encode64(parte.body.decoded.b)
87
+ }
88
+ anexo[:id_conteudo] = parte.cid if parte.inline?
89
+ anexo
90
+ end
91
+ pedido[:anexos] = anexos unless anexos.empty?
92
+ pedido
93
+ end
94
+
95
+ def conteudos(mensagem)
96
+ if mensagem.multipart?
97
+ partes = partes_de_corpo(mensagem)
98
+ texto = partes.find { |parte| parte.mime_type == 'text/plain' }
99
+ html = partes.find { |parte| parte.mime_type == 'text/html' }
100
+ {}.tap do |resultado|
101
+ resultado[:texto] = decodificar_texto(texto) if texto
102
+ resultado[:html] = decodificar_texto(html) if html
103
+ end
104
+ elsif mensagem.mime_type == 'text/html'
105
+ { html: decodificar_texto(mensagem) }
106
+ elsif mensagem.mime_type.nil? || mensagem.mime_type == 'text/plain'
107
+ { texto: decodificar_texto(mensagem) }
108
+ else
109
+ raise ArgumentError, 'Use conteúdo de mensagem em texto ou HTML.'
110
+ end
111
+ end
112
+
113
+ def decodificar_texto(parte)
114
+ parte.body.decoded.dup.force_encoding(parte.charset || 'UTF-8').encode(Encoding::UTF_8)
115
+ end
116
+
117
+ def partes_de_corpo(parte)
118
+ return [] if parte.attachment?
119
+ return [parte] unless parte.multipart?
120
+
121
+ parte.parts.flat_map { |filha| partes_de_corpo(filha) }
122
+ end
123
+ end
124
+ end
@@ -0,0 +1,64 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'base64'
4
+ require 'openssl'
5
+ require 'json'
6
+
7
+ module Mailcore
8
+ module Assinatura
9
+ module_function
10
+
11
+ # O corpo precisa ser o recebido na rede, antes de interpretar ou normalizar JSON.
12
+ def verificar(corpo_bruto:, cabecalhos:, segredo:, agora: Time.now, tolerancia: 300)
13
+ unless corpo_bruto.is_a?(String) && corpo_bruto.bytesize <= 8 * 1024 * 1024 &&
14
+ segredo.is_a?(String) && segredo.start_with?('whsec_') && segredo.bytesize <= 1024 &&
15
+ tolerancia.is_a?(Integer) && tolerancia.between?(1, 300)
16
+ falhar
17
+ end
18
+ recebidos = {}
19
+ cabecalhos.each do |nome, valor|
20
+ nome = nome.to_s.downcase.tr('_', '-').delete_prefix('http-')
21
+ next unless %w[svix-id svix-timestamp svix-signature].include?(nome)
22
+
23
+ falhar if recebidos.key?(nome) || !valor.is_a?(String)
24
+ recebidos[nome] = valor
25
+ end
26
+ id = recebidos['svix-id']
27
+ instante = recebidos['svix-timestamp']
28
+ assinaturas = recebidos['svix-signature']
29
+ falhar unless id&.match?(/\A[\x21-\x7e]{1,256}\z/) &&
30
+ instante&.match?(/\A[1-9][0-9]{0,15}\z/) &&
31
+ assinaturas && assinaturas.bytesize <= 4096 &&
32
+ (agora.to_i - instante.to_i).abs <= tolerancia
33
+
34
+ chave = Base64.strict_decode64(segredo.delete_prefix('whsec_'))
35
+ falhar if chave.bytesize < 16
36
+ esperado = OpenSSL::HMAC.digest('SHA256', chave, "#{id}.#{instante}.".b + corpo_bruto.b)
37
+ valido = assinaturas.split(' ').first(32).any? do |assinatura|
38
+ versao, valor = assinatura.split(',', 2)
39
+ next false unless versao == 'v1' && valor
40
+
41
+ begin
42
+ recebido = Base64.strict_decode64(valor)
43
+ recebido.bytesize == esperado.bytesize && OpenSSL.fixed_length_secure_compare(recebido, esperado)
44
+ rescue ArgumentError
45
+ false
46
+ end
47
+ end
48
+ falhar unless valido
49
+ evento = JSON.parse(corpo_bruto, symbolize_names: true)
50
+ falhar unless evento.is_a?(Hash) && evento[:type].is_a?(String) &&
51
+ evento[:created_at].is_a?(String) && evento.key?(:data)
52
+ evento
53
+ rescue ErroAssinatura
54
+ raise
55
+ rescue StandardError
56
+ falhar
57
+ end
58
+
59
+ def falhar
60
+ raise ErroAssinatura.new('Não foi possível verificar a autenticidade do webhook.',
61
+ codigo: 'assinatura_invalida'), cause: nil
62
+ end
63
+ end
64
+ end
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Mailcore
4
+ class Cliente
5
+ RECURSOS = {
6
+ emails: Recursos::Emails, lotes: Recursos::Lotes, dominios: Recursos::Dominios,
7
+ chaves_api: Recursos::ChavesApi, contatos: Recursos::Contatos,
8
+ segmentos: Recursos::Segmentos, topicos: Recursos::Topicos,
9
+ propriedades_contatos: Recursos::PropriedadesContatos, modelos: Recursos::Modelos,
10
+ webhooks: Recursos::Webhooks, campanhas: Recursos::Campanhas,
11
+ automacoes: Recursos::Automacoes, eventos: Recursos::Eventos,
12
+ recebimento: Recursos::Recebimento, caixas: Recursos::Caixas, cli: Recursos::CliMailcore
13
+ }.freeze
14
+ attr_reader(*RECURSOS.keys)
15
+
16
+ def initialize(chave_api: -> { ENV['MAILCORE_API_KEY'] }, **opcoes)
17
+ transporte = Transporte.new(chave_api: chave_api, **opcoes)
18
+ RECURSOS.each do |nome, classe|
19
+ instance_variable_set("@#{nome}", classe.new(transporte))
20
+ end
21
+ end
22
+
23
+ def inspect
24
+ '#<Mailcore::Cliente credenciais=protegidas>'
25
+ end
26
+ end
27
+ end
@@ -0,0 +1,30 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Mailcore
4
+ class Configuracao
5
+ attr_accessor :chave_api, :url_base, :tempo_limite, :tempo_conexao,
6
+ :limite_resposta_bytes, :certificado_ca, :permitir_http_local,
7
+ :segredo_webhook
8
+
9
+ def initialize
10
+ @chave_api = -> { ENV['MAILCORE_API_KEY'] }
11
+ @url_base = 'https://mailcore.com.br/v1'
12
+ @tempo_limite = 30
13
+ @tempo_conexao = 10
14
+ @limite_resposta_bytes = 16 * 1024 * 1024
15
+ @permitir_http_local = false
16
+ @certificado_ca = nil
17
+ @segredo_webhook = -> { ENV['MAILCORE_WEBHOOK_SECRET'] }
18
+ end
19
+
20
+ def opcoes_cliente
21
+ { chave_api: chave_api, url_base: url_base, tempo_limite: tempo_limite,
22
+ tempo_conexao: tempo_conexao, limite_resposta_bytes: limite_resposta_bytes,
23
+ certificado_ca: certificado_ca, permitir_http_local: permitir_http_local }
24
+ end
25
+
26
+ def inspect
27
+ '#<Mailcore::Configuracao credenciais=protegidas>'
28
+ end
29
+ end
30
+ end
@@ -0,0 +1,71 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'rails/engine'
4
+ require 'action_mailbox/engine'
5
+ require 'mail'
6
+ require 'mailcore'
7
+ require 'mailcore/limite_ingress'
8
+
9
+ module Mailcore
10
+ class ErroEnvelopeActionMailbox < ArgumentError; end
11
+
12
+ # Preserva os bytes MIME e acrescenta somente o envelope autenticado do evento.
13
+ class MensagemActionMailbox
14
+ def initialize(email_id:, destinatarios:, cliente: Mailcore.cliente)
15
+ @email_id = email_id
16
+ @destinatarios = validar_destinatarios(destinatarios)
17
+ @cliente = cliente
18
+ end
19
+
20
+ def original
21
+ fonte = @cliente.recebimento.baixar_original(@email_id).dados
22
+ unless fonte.is_a?(String) && (fonte.include?("\r\n\r\n") || fonte.include?("\n\n"))
23
+ raise Mailcore::Erro, 'O original recebido não contém uma mensagem MIME válida.'
24
+ end
25
+
26
+ prefixo = @destinatarios.sort.map { |endereco| "X-Original-To: #{endereco}\r\n" }.join.b
27
+ prefixo + fonte.b
28
+ end
29
+
30
+ def inspect
31
+ "#<#{self.class.name}>"
32
+ end
33
+
34
+ private
35
+
36
+ def validar_destinatarios(destinatarios)
37
+ unless destinatarios.is_a?(Array) && !destinatarios.empty? && destinatarios.length <= 100
38
+ raise ErroEnvelopeActionMailbox, 'O evento deve informar os destinatários do envelope.'
39
+ end
40
+
41
+ destinatarios.each do |endereco|
42
+ unless endereco.is_a?(String) && endereco.bytesize <= 254 &&
43
+ endereco.bytes.all? { |byte| byte.between?(33, 126) }
44
+ raise ErroEnvelopeActionMailbox, 'Destinatário de envelope inválido.'
45
+ end
46
+ analisado = Mail::Address.new(endereco)
47
+ unless analisado.address == endereco && analisado.local && analisado.domain
48
+ raise ErroEnvelopeActionMailbox, 'Destinatário de envelope inválido.'
49
+ end
50
+ end
51
+ destinatarios.uniq
52
+ rescue Mail::Field::ParseError
53
+ raise ErroEnvelopeActionMailbox, 'Destinatário de envelope inválido.'
54
+ end
55
+ end
56
+
57
+ class Engine < ::Rails::Engine
58
+ config.root = File.expand_path('../..', __dir__)
59
+
60
+ initializer 'mailcore.action_mailbox.limite' do |aplicacao|
61
+ aplicacao.middleware.insert_before 0, ::Mailcore::LimiteIngress
62
+ end
63
+
64
+ initializer 'mailcore.action_mailbox.rotas' do |aplicacao|
65
+ aplicacao.routes.append do
66
+ post '/rails/action_mailbox/mailcore/inbound_emails',
67
+ to: 'action_mailbox/ingresses/mailcore/inbound_emails#create', format: false
68
+ end
69
+ end
70
+ end
71
+ end
@@ -0,0 +1,25 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Mailcore
4
+ class Erro < StandardError
5
+ attr_reader :codigo, :status, :cabecalhos
6
+
7
+ def initialize(mensagem = 'Não foi possível concluir a operação.', codigo: 'erro_mailcore', status: nil, cabecalhos: {})
8
+ super(mensagem)
9
+ @codigo = codigo
10
+ @status = status
11
+ @cabecalhos = cabecalhos.freeze
12
+ end
13
+ end
14
+
15
+ class ErroApi < Erro
16
+ def initialize(codigo:, mensagem:, status:, cabecalhos: {})
17
+ super(mensagem, codigo: codigo, status: status, cabecalhos: cabecalhos)
18
+ end
19
+ end
20
+
21
+ class ErroConfiguracao < ArgumentError; end
22
+ class ErroTransporte < Erro; end
23
+ class ErroResposta < Erro; end
24
+ class ErroAssinatura < Erro; end
25
+ end
@@ -0,0 +1,51 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'stringio'
4
+
5
+ module Mailcore
6
+ # Atua antes dos parsers e da instrumentação do Rails, somente no ingress.
7
+ class LimiteIngress
8
+ CAMINHO = '/rails/action_mailbox/mailcore/inbound_emails'
9
+ LIMITE_BYTES = 256 * 1024
10
+ FILTROS = [:data, :_json, :authorization, /svix[-_]signature/i].freeze
11
+
12
+ def initialize(aplicacao)
13
+ @aplicacao = aplicacao
14
+ end
15
+
16
+ def call(ambiente)
17
+ caminho = ambiente['PATH_INFO'].to_s.squeeze('/').delete_suffix('/')
18
+ return @aplicacao.call(ambiente) unless ambiente['REQUEST_METHOD'] == 'POST' && caminho == CAMINHO
19
+
20
+ tamanho = ambiente['CONTENT_LENGTH'].to_s
21
+ return recusar(413) if tamanho.match?(/\A[0-9]+\z/) && tamanho.to_i > LIMITE_BYTES
22
+
23
+ entrada = ambiente['rack.input']
24
+ return recusar(400) unless entrada.respond_to?(:read)
25
+
26
+ begin
27
+ corpo = entrada.read(LIMITE_BYTES + 1).to_s.b
28
+ rescue IOError, EOFError
29
+ return recusar(400)
30
+ end
31
+ return recusar(413) if corpo.bytesize > LIMITE_BYTES
32
+
33
+ ambiente['rack.input'] = StringIO.new(corpo)
34
+ ambiente['CONTENT_LENGTH'] = corpo.bytesize.to_s
35
+ ambiente.delete('HTTP_TRANSFER_ENCODING')
36
+ ambiente['RAW_POST_DATA'] = corpo
37
+ # O controller usa somente o corpo assinado. Evita parsing e logs JSON
38
+ # automáticos anteriores à autenticação, inclusive em JSON malformado.
39
+ ambiente['action_dispatch.request.request_parameters'] = {}
40
+ ambiente['action_dispatch.parameter_filter'] = Array(ambiente['action_dispatch.parameter_filter']).dup + FILTROS
41
+ @aplicacao.call(ambiente)
42
+ end
43
+
44
+ private
45
+
46
+ def recusar(status)
47
+ [status, { 'content-type' => 'application/json; charset=utf-8', 'cache-control' => 'no-store' },
48
+ ['{"erro":"Não foi possível aceitar o corpo do webhook."}']]
49
+ end
50
+ end
51
+ end
@@ -0,0 +1,16 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'rails/railtie'
4
+ require 'mailcore/action_mailer'
5
+
6
+ module Mailcore
7
+ class Railtie < ::Rails::Railtie
8
+ initializer 'mailcore.action_mailer' do
9
+ ActiveSupport.on_load(:action_mailer) do
10
+ add_delivery_method :mailcore, ::Mailcore::EntregaActionMailer
11
+ end
12
+ end
13
+ end
14
+ end
15
+
16
+ require 'mailcore/engine' if defined?(::ActionMailbox::Engine)
@@ -0,0 +1,52 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Mailcore
4
+ module Recursos
5
+ class ExecucoesAutomacoes < Base
6
+ def listar(automacao_id, filtros = {}, **opcoes)
7
+ executar('GET', "/automations/#{segmento(automacao_id)}/runs", consulta: filtros, **opcoes)
8
+ end
9
+
10
+ def obter(automacao_id, execucao_id, **opcoes)
11
+ executar('GET', "/automations/#{segmento(automacao_id)}/runs/#{segmento(execucao_id)}", **opcoes)
12
+ end
13
+ end
14
+
15
+ class Automacoes < Base
16
+ attr_reader :execucoes
17
+
18
+ def initialize(transporte)
19
+ super(transporte)
20
+ @execucoes = ExecucoesAutomacoes.new(transporte)
21
+ end
22
+
23
+ def criar(pedido, **opcoes)
24
+ executar('POST', "/automations", corpo: pedido, **opcoes)
25
+ end
26
+
27
+ def listar(filtros = {}, **opcoes)
28
+ executar('GET', "/automations", consulta: filtros, **opcoes)
29
+ end
30
+
31
+ def obter(id, **opcoes)
32
+ executar('GET', "/automations/#{segmento(id)}", **opcoes)
33
+ end
34
+
35
+ def atualizar(id, pedido, **opcoes)
36
+ executar('PATCH', "/automations/#{segmento(id)}", corpo: pedido, **opcoes)
37
+ end
38
+
39
+ def remover(id, **opcoes)
40
+ executar('DELETE', "/automations/#{segmento(id)}", **opcoes)
41
+ end
42
+
43
+ def duplicar(id, pedido = {}, **opcoes)
44
+ executar('POST', "/automations/#{segmento(id)}/duplicate", corpo: pedido, **opcoes)
45
+ end
46
+
47
+ def parar(id, pedido, **opcoes)
48
+ executar('POST', "/automations/#{segmento(id)}/stop", corpo: pedido, **opcoes)
49
+ end
50
+ end
51
+ end
52
+ end
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Mailcore
4
+ module Recursos
5
+ class Base
6
+ def initialize(transporte)
7
+ @transporte = transporte
8
+ end
9
+
10
+ private
11
+
12
+ def executar(metodo, caminho, **opcoes, &bloco)
13
+ @transporte.executar(metodo, caminho, **opcoes, &bloco)
14
+ end
15
+
16
+ # Percent-encoding por byte evita transformar identificadores em caminhos.
17
+ def segmento(valor)
18
+ unless valor.is_a?(String) && !valor.empty? && !['.', '..'].include?(valor) &&
19
+ valor.valid_encoding? && !valor.match?(/[\x00-\x1f\x7f]/)
20
+ raise ErroConfiguracao, 'Informe um identificador não vazio e sem controles.'
21
+ end
22
+ valor.encode(Encoding::UTF_8).bytes.map do |byte|
23
+ caractere = byte.chr
24
+ caractere.match?(/[A-Za-z0-9_.~!-]/) ? caractere : format('%%%02X', byte)
25
+ end.join
26
+ end
27
+ end
28
+ end
29
+ end