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
data/exemplos/sinatra.rb
ADDED
|
@@ -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
|