mirror of https://github.com/docusealco/docuseal
commit
22d4c1bd10
@ -0,0 +1,38 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class SendTemplateArchivedWebhookRequestJob
|
||||
include Sidekiq::Job
|
||||
|
||||
sidekiq_options queue: :webhooks
|
||||
|
||||
MAX_ATTEMPTS = 10
|
||||
|
||||
def perform(params = {})
|
||||
template = Template.find_by(id: params['template_id'])
|
||||
|
||||
return unless template
|
||||
|
||||
webhook_url = WebhookUrl.find_by(id: params['webhook_url_id'])
|
||||
|
||||
return unless webhook_url
|
||||
|
||||
attempt = params['attempt'].to_i
|
||||
|
||||
return if webhook_url.url.blank? || webhook_url.events.exclude?('template.archived')
|
||||
|
||||
resp = SendWebhookRequest.call(webhook_url, event_type: 'template.archived',
|
||||
event_uuid: params['event_uuid'],
|
||||
record: template,
|
||||
attempt:,
|
||||
data: template.as_json(only: %i[id archived_at]))
|
||||
|
||||
if (resp.nil? || resp.status.to_i >= 400) && attempt <= MAX_ATTEMPTS &&
|
||||
(!Docuseal.multitenant? || template.account.account_configs.exists?(key: :plan))
|
||||
SendTemplateArchivedWebhookRequestJob.perform_in((2**attempt).minutes, {
|
||||
**params,
|
||||
'attempt' => attempt + 1,
|
||||
'last_status' => resp&.status.to_i
|
||||
})
|
||||
end
|
||||
end
|
||||
end
|
||||
@ -0,0 +1,119 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
module HtmlToPlainText
|
||||
module_function
|
||||
|
||||
def call(html, line_length = 65)
|
||||
return '' if html.nil? || html.strip.empty?
|
||||
|
||||
cleaned = html.gsub(%r{<!-- start text/html -->.*?<!-- end text/html -->}m, '')
|
||||
|
||||
doc = Nokogiri::HTML.fragment(cleaned)
|
||||
|
||||
doc.css('script').each(&:remove)
|
||||
|
||||
result = process_nodes(doc, line_length)
|
||||
|
||||
result.gsub!(/\r\n?/, "\n")
|
||||
result.gsub!(/[ \t]*\u00A0+[ \t]*/, ' ')
|
||||
result.gsub!(/\n[ \t]+/, "\n")
|
||||
result.gsub!(/[ \t]+\n/, "\n")
|
||||
result.gsub!(/\n{3,}/, "\n\n")
|
||||
|
||||
result = word_wrap(result, line_length)
|
||||
|
||||
result.gsub!(/\(([ \n])(http[^)]+)([\n ])\)/) do
|
||||
"#{"\n" if ::Regexp.last_match(1) == "\n"}( #{::Regexp.last_match(2)} )#{"\n" if ::Regexp.last_match(3) == "\n"}"
|
||||
end
|
||||
|
||||
result.strip
|
||||
end
|
||||
|
||||
def process_nodes(node, line_length)
|
||||
result = +''
|
||||
|
||||
node.children.each do |child|
|
||||
case child
|
||||
when Nokogiri::XML::Text
|
||||
result << child.text
|
||||
when Nokogiri::XML::Comment
|
||||
next
|
||||
when Nokogiri::XML::Element
|
||||
result << process_element(child, line_length)
|
||||
end
|
||||
end
|
||||
|
||||
result
|
||||
end
|
||||
|
||||
def process_element(node, line_length)
|
||||
case node.name
|
||||
when 'br'
|
||||
"\n"
|
||||
when 'p', 'div'
|
||||
inner = process_nodes(node, line_length)
|
||||
inner.strip.empty? ? '' : "#{inner}\n\n"
|
||||
when 'img'
|
||||
node['alt'] || ''
|
||||
when 'a'
|
||||
process_link(node, line_length)
|
||||
when /\Ah([1-6])\z/
|
||||
process_heading(node, ::Regexp.last_match(1).to_i, line_length)
|
||||
when 'li'
|
||||
inner = process_nodes(node, line_length)
|
||||
"* #{inner.strip}\n"
|
||||
else
|
||||
process_nodes(node, line_length)
|
||||
end
|
||||
end
|
||||
|
||||
def process_link(node, line_length)
|
||||
text = process_nodes(node, line_length).strip
|
||||
return '' if text.empty?
|
||||
|
||||
href = node['href']
|
||||
href = href.sub(/\Amailto:/i, '') if href
|
||||
|
||||
if href.nil? || text.casecmp(href.strip) == 0
|
||||
text
|
||||
else
|
||||
"#{text} ( #{href.strip} )"
|
||||
end
|
||||
end
|
||||
|
||||
def process_heading(node, level, line_length)
|
||||
text = +''
|
||||
node.children.each do |child|
|
||||
text << if child.name == 'br'
|
||||
"\n"
|
||||
else
|
||||
child.text
|
||||
end
|
||||
end
|
||||
text.strip!
|
||||
|
||||
hlength = text.each_line.map { |l| l.strip.length }.max || 0
|
||||
hlength = line_length if hlength > line_length
|
||||
|
||||
decorated = case level
|
||||
when 1
|
||||
"#{'*' * hlength}\n#{text}\n#{'*' * hlength}"
|
||||
when 2
|
||||
"#{'-' * hlength}\n#{text}\n#{'-' * hlength}"
|
||||
else
|
||||
"#{text}\n#{'-' * hlength}"
|
||||
end
|
||||
|
||||
"\n\n#{decorated}\n\n"
|
||||
end
|
||||
|
||||
def word_wrap(txt, line_length)
|
||||
txt.split("\n").map do |line|
|
||||
if line.length > line_length
|
||||
line.gsub(/(.{1,#{line_length}})(\s+|$)/, "\\1\n").strip
|
||||
else
|
||||
line
|
||||
end
|
||||
end.join("\n")
|
||||
end
|
||||
end
|
||||
@ -0,0 +1,65 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
module HtmlToPlainTextInterceptor
|
||||
module_function
|
||||
|
||||
def delivering_email(message)
|
||||
process(message)
|
||||
end
|
||||
|
||||
def previewing_email(message)
|
||||
process(message)
|
||||
end
|
||||
|
||||
def process(message)
|
||||
return message unless html_part(message)
|
||||
return message if message.text_part
|
||||
|
||||
add_text_part(message)
|
||||
|
||||
message
|
||||
end
|
||||
|
||||
def add_text_part(message)
|
||||
html = html_part(message).decoded
|
||||
text = HtmlToPlainText.call(html)
|
||||
|
||||
text_part = Mail::Part.new do
|
||||
content_type 'text/plain; charset=UTF-8'
|
||||
body text
|
||||
end
|
||||
|
||||
if pure_html_message?(message)
|
||||
message.body = nil
|
||||
message.content_type = 'multipart/alternative'
|
||||
message.add_part(text_part)
|
||||
message.add_part(Mail::Part.new do
|
||||
content_type 'text/html; charset=UTF-8'
|
||||
body html
|
||||
end)
|
||||
else
|
||||
alternative = Mail::Part.new(content_type: 'multipart/alternative')
|
||||
alternative.add_part(text_part)
|
||||
alternative.add_part(message.html_part)
|
||||
replace_part(message.parts, message.html_part, alternative)
|
||||
end
|
||||
end
|
||||
|
||||
def pure_html_message?(message)
|
||||
message.content_type.to_s.include?('text/html')
|
||||
end
|
||||
|
||||
def html_part(message)
|
||||
pure_html_message?(message) ? message : message.html_part
|
||||
end
|
||||
|
||||
def replace_part(parts, old_part, new_part)
|
||||
if (index = parts.index(old_part))
|
||||
parts[index] = new_part
|
||||
else
|
||||
parts.each do |part|
|
||||
replace_part(part.parts, old_part, new_part) if part.respond_to?(:parts)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@ -0,0 +1,100 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
RSpec.describe SendTemplateArchivedWebhookRequestJob do
|
||||
let(:account) { create(:account) }
|
||||
let(:user) { create(:user, account:) }
|
||||
let(:template) { create(:template, account:, author: user) }
|
||||
let(:webhook_url) { create(:webhook_url, account:, events: ['template.archived']) }
|
||||
|
||||
before do
|
||||
create(:encrypted_config, key: EncryptedConfig::ESIGN_CERTS_KEY,
|
||||
value: GenerateCertificate.call.transform_values(&:to_pem))
|
||||
end
|
||||
|
||||
describe '#perform' do
|
||||
around do |example|
|
||||
freeze_time { example.run }
|
||||
end
|
||||
|
||||
before do
|
||||
stub_request(:post, webhook_url.url).to_return(status: 200)
|
||||
end
|
||||
|
||||
it 'sends a webhook request' do
|
||||
described_class.new.perform('template_id' => template.id, 'webhook_url_id' => webhook_url.id,
|
||||
'event_uuid' => SecureRandom.uuid)
|
||||
|
||||
expect(WebMock).to have_requested(:post, webhook_url.url).with(
|
||||
body: {
|
||||
'event_type' => 'template.archived',
|
||||
'timestamp' => /.*/,
|
||||
'data' => template.reload.as_json(only: %i[id archived_at])
|
||||
},
|
||||
headers: {
|
||||
'Content-Type' => 'application/json',
|
||||
'User-Agent' => 'DocuSeal.com Webhook'
|
||||
}
|
||||
).once
|
||||
end
|
||||
|
||||
it 'sends a webhook request with the secret' do
|
||||
webhook_url.update(secret: { 'X-Secret-Header' => 'secret_value' })
|
||||
described_class.new.perform('template_id' => template.id, 'webhook_url_id' => webhook_url.id,
|
||||
'event_uuid' => SecureRandom.uuid)
|
||||
|
||||
expect(WebMock).to have_requested(:post, webhook_url.url).with(
|
||||
body: {
|
||||
'event_type' => 'template.archived',
|
||||
'timestamp' => /.*/,
|
||||
'data' => template.reload.as_json(only: %i[id archived_at])
|
||||
},
|
||||
headers: {
|
||||
'Content-Type' => 'application/json',
|
||||
'User-Agent' => 'DocuSeal.com Webhook',
|
||||
'X-Secret-Header' => 'secret_value'
|
||||
}
|
||||
).once
|
||||
end
|
||||
|
||||
it "doesn't send a webhook request if the event is not in the webhook's events" do
|
||||
webhook_url.update!(events: ['template.updated'])
|
||||
|
||||
described_class.new.perform('template_id' => template.id, 'webhook_url_id' => webhook_url.id,
|
||||
'event_uuid' => SecureRandom.uuid)
|
||||
|
||||
expect(WebMock).not_to have_requested(:post, webhook_url.url)
|
||||
end
|
||||
|
||||
it 'sends again if the response status is 400 or higher' do
|
||||
stub_request(:post, webhook_url.url).to_return(status: 401)
|
||||
|
||||
event_uuid = SecureRandom.uuid
|
||||
|
||||
expect do
|
||||
described_class.new.perform('template_id' => template.id, 'webhook_url_id' => webhook_url.id,
|
||||
'event_uuid' => event_uuid)
|
||||
end.to change(described_class.jobs, :size).by(1)
|
||||
|
||||
expect(WebMock).to have_requested(:post, webhook_url.url).once
|
||||
|
||||
args = described_class.jobs.last['args'].first
|
||||
|
||||
expect(args['attempt']).to eq(1)
|
||||
expect(args['last_status']).to eq(401)
|
||||
expect(args['event_uuid']).to eq(event_uuid)
|
||||
expect(args['webhook_url_id']).to eq(webhook_url.id)
|
||||
expect(args['template_id']).to eq(template.id)
|
||||
end
|
||||
|
||||
it "doesn't send again if the max attempts is reached" do
|
||||
stub_request(:post, webhook_url.url).to_return(status: 401)
|
||||
|
||||
expect do
|
||||
described_class.new.perform('template_id' => template.id, 'webhook_url_id' => webhook_url.id,
|
||||
'event_uuid' => SecureRandom.uuid, 'attempt' => 11)
|
||||
end.not_to change(described_class.jobs, :size)
|
||||
|
||||
expect(WebMock).to have_requested(:post, webhook_url.url).once
|
||||
end
|
||||
end
|
||||
end
|
||||
Loading…
Reference in new issue