Merge pull request #8533 from colinux/feat-dolist-send-mail-api
Email: API Dolist pour envoyer des emails transactionnels
This commit is contained in:
commit
48503e0743
14 changed files with 298 additions and 9 deletions
|
@ -1,8 +1,15 @@
|
||||||
|
require "support/jsv"
|
||||||
|
|
||||||
class Dolist::API
|
class Dolist::API
|
||||||
CONTACT_URL = "https://apiv9.dolist.net/v1/contacts/read?AccountID=%{account_id}"
|
CONTACT_URL = "https://apiv9.dolist.net/v1/contacts/read?AccountID=%{account_id}"
|
||||||
EMAIL_LOGS_URL = "https://apiv9.dolist.net/v1/statistics/email/sendings/transactional/search?AccountID=%{account_id}"
|
EMAIL_LOGS_URL = "https://apiv9.dolist.net/v1/statistics/email/sendings/transactional/search?AccountID=%{account_id}"
|
||||||
EMAIL_KEY = 7
|
EMAIL_KEY = 7
|
||||||
DOLIST_WEB_DASHBOARD = "https://campaign.dolist.net/#/%{account_id}/contacts/%{contact_id}/sendings"
|
DOLIST_WEB_DASHBOARD = "https://campaign.dolist.net/#/%{account_id}/contacts/%{contact_id}/sendings"
|
||||||
|
EMAIL_MESSAGES_ADRESSES_REPLIES = "https://apiv9.dolist.net/v1/email/messages/addresses/replies?AccountID=%{account_id}"
|
||||||
|
EMAIL_MESSAGES_ADRESSES_PACKSENDERS = "https://apiv9.dolist.net/v1/email/messages/addresses/packsenders?AccountID=%{account_id}"
|
||||||
|
EMAIL_SENDING_TRANSACTIONAL = "https://apiv9.dolist.net/v1/email/sendings/transactional?AccountID=%{account_id}"
|
||||||
|
EMAIL_SENDING_TRANSACTIONAL_ATTACHMENT = "https://apiv9.dolist.net/v1/email/sendings/transactional/attachment?AccountID=%{account_id}"
|
||||||
|
EMAIL_SENDING_TRANSACTIONAL_SEARCH = "https://apiv9.dolist.net/v1/email/sendings/transactional/search?AccountID=%{account_id}"
|
||||||
|
|
||||||
class_attribute :limit_remaining, :limit_reset_at
|
class_attribute :limit_remaining, :limit_reset_at
|
||||||
|
|
||||||
|
@ -23,12 +30,74 @@ class Dolist::API
|
||||||
|
|
||||||
sleep (limit_reset_at - Time.zone.now).ceil
|
sleep (limit_reset_at - Time.zone.now).ceil
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def sendable?(mail)
|
||||||
|
# Mail having attachments are not yet supported in our account
|
||||||
|
mail.attachments.none? { !_1.inline? }
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
def properly_configured?
|
def properly_configured?
|
||||||
client_key.present?
|
client_key.present?
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def send_email(mail)
|
||||||
|
if mail.attachments.any? { !_1.inline? }
|
||||||
|
return send_email_with_attachment(mail)
|
||||||
|
end
|
||||||
|
|
||||||
|
body = { "TransactionalSending": prepare_mail_body(mail) }
|
||||||
|
|
||||||
|
url = format_url(EMAIL_SENDING_TRANSACTIONAL)
|
||||||
|
post(url, body.to_json)
|
||||||
|
end
|
||||||
|
|
||||||
|
def send_email_with_attachment(mail)
|
||||||
|
uri = URI(format_url(EMAIL_SENDING_TRANSACTIONAL_ATTACHMENT))
|
||||||
|
|
||||||
|
request = Net::HTTP::Post.new(uri)
|
||||||
|
|
||||||
|
default_headers.each do |key, value|
|
||||||
|
next if key.to_s == "Content-Type"
|
||||||
|
request[key] = value
|
||||||
|
end
|
||||||
|
|
||||||
|
boundary = "---011000010111000001101001" # any random string not present in the body
|
||||||
|
request.content_type = "multipart/form-data; boundary=#{boundary}"
|
||||||
|
|
||||||
|
body = "--#{boundary}\r\n"
|
||||||
|
|
||||||
|
base64_files(mail.attachments).each do |file|
|
||||||
|
body << "Content-Disposition: form-data; name=\"#{file.field_name}\"; filename=\"#{file.filename}\"\r\n"
|
||||||
|
body << "Content-Type: #{file.mime_type}\r\n"
|
||||||
|
body << "\r\n"
|
||||||
|
body << file.content
|
||||||
|
body << "\r\n"
|
||||||
|
end
|
||||||
|
|
||||||
|
body << "\r\n--#{boundary}\r\n"
|
||||||
|
body << "Content-Disposition: form-data; name=\"TransactionalSending\"\r\n"
|
||||||
|
body << "Content-Type: text/plain; charset=utf-8\r\n"
|
||||||
|
body << "\r\n"
|
||||||
|
body << prepare_mail_body(mail).to_jsv
|
||||||
|
|
||||||
|
body << "\r\n--#{boundary}--\r\n"
|
||||||
|
body << "\r\n"
|
||||||
|
|
||||||
|
request.body = body
|
||||||
|
|
||||||
|
http = Net::HTTP.new(uri.host, uri.port)
|
||||||
|
http.use_ssl = true
|
||||||
|
|
||||||
|
response = http.request(request)
|
||||||
|
|
||||||
|
if response.body.empty?
|
||||||
|
fail "Dolist API returned an empty response"
|
||||||
|
else
|
||||||
|
JSON.parse(response.body)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
def sent_mails(email_address)
|
def sent_mails(email_address)
|
||||||
contact_id = fetch_contact_id(email_address)
|
contact_id = fetch_contact_id(email_address)
|
||||||
if contact_id.nil?
|
if contact_id.nil?
|
||||||
|
@ -44,9 +113,47 @@ class Dolist::API
|
||||||
[]
|
[]
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def senders
|
||||||
|
get format_url(EMAIL_MESSAGES_ADRESSES_PACKSENDERS)
|
||||||
|
end
|
||||||
|
|
||||||
|
def replies
|
||||||
|
get format_url(EMAIL_MESSAGES_ADRESSES_REPLIES)
|
||||||
|
end
|
||||||
|
|
||||||
private
|
private
|
||||||
|
|
||||||
def headers
|
def format_url(base)
|
||||||
|
format(base, account_id: account_id)
|
||||||
|
end
|
||||||
|
|
||||||
|
def sender_id
|
||||||
|
Rails.cache.fetch("dolist_api_sender_id", expires_in: 1.hour) do
|
||||||
|
senders.dig("ItemList", 0, "Sender", "ID")
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def get(url)
|
||||||
|
response = Typhoeus.get(url, headers: default_headers).tap do
|
||||||
|
self.class.save_rate_limit_headers(_1.headers)
|
||||||
|
end
|
||||||
|
|
||||||
|
JSON.parse(response.response_body)
|
||||||
|
end
|
||||||
|
|
||||||
|
def post(url, body)
|
||||||
|
response = Typhoeus.post(url, body:, headers: default_headers).tap do
|
||||||
|
self.class.save_rate_limit_headers(_1.headers)
|
||||||
|
end
|
||||||
|
|
||||||
|
if response.response_body.empty?
|
||||||
|
fail "Empty response from Dolist API"
|
||||||
|
else
|
||||||
|
JSON.parse(response.response_body)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def default_headers
|
||||||
{
|
{
|
||||||
"Content-Type": 'application/json',
|
"Content-Type": 'application/json',
|
||||||
"Accept": 'application/json',
|
"Accept": 'application/json',
|
||||||
|
@ -82,12 +189,32 @@ class Dolist::API
|
||||||
post(url, body)["ItemList"]
|
post(url, body)["ItemList"]
|
||||||
end
|
end
|
||||||
|
|
||||||
def post(url, body)
|
def prepare_mail_body(mail)
|
||||||
response = Typhoeus.post(url, body:, headers:).tap do
|
{
|
||||||
self.class.save_rate_limit_headers(_1.headers)
|
"Type": "TransactionalService",
|
||||||
end
|
"Contact": {
|
||||||
|
"FieldList": [
|
||||||
JSON.parse(response.response_body)
|
{
|
||||||
|
"ID": EMAIL_KEY,
|
||||||
|
"Value": mail.to.first
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"Message": {
|
||||||
|
"Name": mail['X-Dolist-Message-Name'].value,
|
||||||
|
"Subject": mail.subject,
|
||||||
|
"SenderID": sender_id,
|
||||||
|
"ForceHttp": true,
|
||||||
|
"Format": "html",
|
||||||
|
"DisableOpenTracking": true,
|
||||||
|
"IsTrackingValidated": true
|
||||||
|
},
|
||||||
|
"MessageContent": {
|
||||||
|
"SourceCode": mail_source_code(mail),
|
||||||
|
"EncodingType": "UTF8",
|
||||||
|
"EnableTrackingDetection": false
|
||||||
|
}
|
||||||
|
}
|
||||||
end
|
end
|
||||||
|
|
||||||
def to_sent_mail(email_address, contact_id, dolist_message)
|
def to_sent_mail(email_address, contact_id, dolist_message)
|
||||||
|
@ -112,4 +239,24 @@ class Dolist::API
|
||||||
status
|
status
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def mail_source_code(mail)
|
||||||
|
if mail.html_part.nil? && mail.text_part.nil?
|
||||||
|
mail.decoded
|
||||||
|
else
|
||||||
|
mail.html_part.body.decoded
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def base64_files(attachments)
|
||||||
|
attachments.map do |attachment|
|
||||||
|
raise ArgumentError, "Dolist API does not support non PDF attachments. Given #{attachment.filename} which has mime_type=#{attachment.mime_type}" unless attachment.mime_type == "application/pdf"
|
||||||
|
|
||||||
|
field_name = File.basename(attachment.filename, File.extname(attachment.filename))
|
||||||
|
attachment_content = attachment.body.decoded
|
||||||
|
attachment_base64 = Base64.strict_encode64(attachment_content)
|
||||||
|
|
||||||
|
Dolist::Base64File.new(field_name:, filename: attachment.filename, mime_type: attachment.mime_type, content: attachment_base64)
|
||||||
|
end
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
3
app/lib/dolist/base64_file.rb
Normal file
3
app/lib/dolist/base64_file.rb
Normal file
|
@ -0,0 +1,3 @@
|
||||||
|
module Dolist
|
||||||
|
Base64File = Struct.new(:field_name, :filename, :mime_type, :content, keyword_init: true)
|
||||||
|
end
|
|
@ -3,4 +3,10 @@
|
||||||
# so it would be shallowed otherwise.
|
# so it would be shallowed otherwise.
|
||||||
#
|
#
|
||||||
# TODO: add a test which verify that the error will permit the job to retry
|
# TODO: add a test which verify that the error will permit the job to retry
|
||||||
class MailDeliveryError < Exception; end # rubocop:disable Lint/InheritException
|
class MailDeliveryError < Exception # rubocop:disable Lint/InheritException
|
||||||
|
def initialize(original_exception)
|
||||||
|
super(original_exception.message)
|
||||||
|
|
||||||
|
set_backtrace(original_exception.backtrace)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
|
@ -108,6 +108,7 @@
|
||||||
<div class="mj-column-per-100 outlook-group-fix" style="vertical-align:top;display:inline-block;direction:ltr;font-size:13px;text-align:left;width:100%;">
|
<div class="mj-column-per-100 outlook-group-fix" style="vertical-align:top;display:inline-block;direction:ltr;font-size:13px;text-align:left;width:100%;">
|
||||||
<table role="presentation" cellpadding="0" cellspacing="0" width="100%" border="0">
|
<table role="presentation" cellpadding="0" cellspacing="0" width="100%" border="0">
|
||||||
<tbody>
|
<tbody>
|
||||||
|
<%= yield(:procedure_logo) %>
|
||||||
<tr>
|
<tr>
|
||||||
<td style="word-wrap:break-word;font-size:0px;padding:0px 25px 0px 25px;padding-top:0px;padding-bottom:0px;" align="left">
|
<td style="word-wrap:break-word;font-size:0px;padding:0px 25px 0px 25px;padding-top:0px;padding-bottom:0px;" align="left">
|
||||||
<div class="" style="cursor:auto;color:#55575d;font-family:Helvetica, Arial, sans-serif;font-size:13px;line-height:22px;text-align:left;">
|
<div class="" style="cursor:auto;color:#55575d;font-family:Helvetica, Arial, sans-serif;font-size:13px;line-height:22px;text-align:left;">
|
||||||
|
|
9
lib/support/jsv.rb
Normal file
9
lib/support/jsv.rb
Normal file
|
@ -0,0 +1,9 @@
|
||||||
|
# spec by dolist https://api.dolist.com/ConvertJSON-to-JSV.html
|
||||||
|
#
|
||||||
|
require_relative "jsv/core_ext/array"
|
||||||
|
require_relative "jsv/core_ext/false_class"
|
||||||
|
require_relative "jsv/core_ext/hash"
|
||||||
|
require_relative "jsv/core_ext/number"
|
||||||
|
require_relative "jsv/core_ext/string"
|
||||||
|
require_relative "jsv/core_ext/symbol"
|
||||||
|
require_relative "jsv/core_ext/true_class"
|
5
lib/support/jsv/core_ext/array.rb
Normal file
5
lib/support/jsv/core_ext/array.rb
Normal file
|
@ -0,0 +1,5 @@
|
||||||
|
class Array
|
||||||
|
def to_jsv
|
||||||
|
"[" + reject(&:nil?).map(&:to_jsv).join(",") + "]"
|
||||||
|
end
|
||||||
|
end
|
5
lib/support/jsv/core_ext/false_class.rb
Normal file
5
lib/support/jsv/core_ext/false_class.rb
Normal file
|
@ -0,0 +1,5 @@
|
||||||
|
class TrueClass
|
||||||
|
def to_jsv
|
||||||
|
"True"
|
||||||
|
end
|
||||||
|
end
|
11
lib/support/jsv/core_ext/hash.rb
Normal file
11
lib/support/jsv/core_ext/hash.rb
Normal file
|
@ -0,0 +1,11 @@
|
||||||
|
class Hash
|
||||||
|
def to_jsv
|
||||||
|
js_array = filter_map do |key, value|
|
||||||
|
next if value.nil? # skip nil values
|
||||||
|
|
||||||
|
"#{key.to_jsv}:#{value.to_jsv}"
|
||||||
|
end
|
||||||
|
|
||||||
|
"{" + js_array.join(",") + "}"
|
||||||
|
end
|
||||||
|
end
|
5
lib/support/jsv/core_ext/number.rb
Normal file
5
lib/support/jsv/core_ext/number.rb
Normal file
|
@ -0,0 +1,5 @@
|
||||||
|
class Numeric
|
||||||
|
def to_jsv
|
||||||
|
self
|
||||||
|
end
|
||||||
|
end
|
12
lib/support/jsv/core_ext/string.rb
Normal file
12
lib/support/jsv/core_ext/string.rb
Normal file
|
@ -0,0 +1,12 @@
|
||||||
|
class String
|
||||||
|
JSV_REGEX_SPECIAL_CHARS = /[\[\]\{\}"\,]/.freeze
|
||||||
|
|
||||||
|
def to_jsv
|
||||||
|
double_quoted = self.gsub('"', '""')
|
||||||
|
if match?(JSV_REGEX_SPECIAL_CHARS)
|
||||||
|
"\"#{double_quoted}\""
|
||||||
|
else
|
||||||
|
double_quoted
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
5
lib/support/jsv/core_ext/symbol.rb
Normal file
5
lib/support/jsv/core_ext/symbol.rb
Normal file
|
@ -0,0 +1,5 @@
|
||||||
|
class Symbol
|
||||||
|
def to_jsv
|
||||||
|
to_s.to_jsv
|
||||||
|
end
|
||||||
|
end
|
5
lib/support/jsv/core_ext/true_class.rb
Normal file
5
lib/support/jsv/core_ext/true_class.rb
Normal file
|
@ -0,0 +1,5 @@
|
||||||
|
class FalseClass
|
||||||
|
def to_jsv
|
||||||
|
"False"
|
||||||
|
end
|
||||||
|
end
|
75
spec/lib/support/jsv_spec.rb
Normal file
75
spec/lib/support/jsv_spec.rb
Normal file
|
@ -0,0 +1,75 @@
|
||||||
|
require_relative "../../../lib/support/jsv"
|
||||||
|
|
||||||
|
describe ".to_jsv support" do
|
||||||
|
it "converts a Hash to JSV" do
|
||||||
|
expect({}.to_jsv).to eq("{}")
|
||||||
|
expect({ "a" => "b" }.to_jsv).to eq("{a:b}")
|
||||||
|
end
|
||||||
|
|
||||||
|
it "converts an Array to JSV" do
|
||||||
|
expect([].to_jsv).to eq("[]")
|
||||||
|
expect(["a", "b"].to_jsv).to eq("[a,b]")
|
||||||
|
end
|
||||||
|
|
||||||
|
it "converts a String to JSV" do
|
||||||
|
expect("".to_jsv).to eq("")
|
||||||
|
expect("a".to_jsv).to eq("a")
|
||||||
|
|
||||||
|
# escape special characters
|
||||||
|
expect("a[b".to_jsv).to eq('"a[b"')
|
||||||
|
expect("a]b".to_jsv).to eq('"a]b"')
|
||||||
|
expect("a,b".to_jsv).to eq('"a,b"')
|
||||||
|
expect("a{b".to_jsv).to eq('"a{b"')
|
||||||
|
expect("a}b".to_jsv).to eq('"a}b"')
|
||||||
|
expect('a"b'.to_jsv).to eq('"a""b"')
|
||||||
|
end
|
||||||
|
|
||||||
|
it "skip null values" do
|
||||||
|
expect({ "a" => nil }.to_jsv).to eq("{}")
|
||||||
|
expect([nil].to_jsv).to eq("[]")
|
||||||
|
end
|
||||||
|
|
||||||
|
it "converts symbols like strings" do
|
||||||
|
expect({ a: :b }.to_jsv).to eq("{a:b}")
|
||||||
|
end
|
||||||
|
|
||||||
|
it "converts booleans" do
|
||||||
|
expect(true.to_jsv).to eq("True")
|
||||||
|
expect(false.to_jsv).to eq("False")
|
||||||
|
end
|
||||||
|
|
||||||
|
it "converts numbers" do
|
||||||
|
expect(1.to_jsv).to eq(1)
|
||||||
|
expect(3.14.to_jsv).to eq(3.14)
|
||||||
|
end
|
||||||
|
|
||||||
|
it "converts nested structures" do
|
||||||
|
expect({ "a" => { "b" => "c" } }.to_jsv).to eq("{a:{b:c}}")
|
||||||
|
expect({ "a" => ["b", "c"] }.to_jsv).to eq("{a:[b,c]}")
|
||||||
|
end
|
||||||
|
|
||||||
|
it "converts relastic structures" do
|
||||||
|
hash = {
|
||||||
|
Type: :TransactionalService,
|
||||||
|
"Contact": {
|
||||||
|
"FieldList": [
|
||||||
|
{
|
||||||
|
"ID": 3,
|
||||||
|
"Value": "glou[0]"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"Message": {
|
||||||
|
"Subject": "You, and me",
|
||||||
|
"ForceHttp": true,
|
||||||
|
"IsTrackingValidated": false,
|
||||||
|
"IgnoreMe": nil,
|
||||||
|
"SourceCode": "<html><body><p>Un mail tout simple pour commencer</p></body></html>",
|
||||||
|
"SourceWithQuote": 'Ceci est une double quote: "'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
expected = '{Type:TransactionalService,Contact:{FieldList:[{ID:3,Value:"glou[0]"}]},Message:{Subject:"You, and me",ForceHttp:True,IsTrackingValidated:False,SourceCode:<html><body><p>Un mail tout simple pour commencer</p></body></html>,SourceWithQuote:"Ceci est une double quote: """}}'
|
||||||
|
expect(hash.to_jsv).to eq(expected)
|
||||||
|
end
|
||||||
|
end
|
|
@ -1,7 +1,7 @@
|
||||||
# Preview all emails at http://localhost:3000/rails/mailers/dossier_mailer
|
# Preview all emails at http://localhost:3000/rails/mailers/dossier_mailer
|
||||||
class DossierMailerPreview < ActionMailer::Preview
|
class DossierMailerPreview < ActionMailer::Preview
|
||||||
def notify_new_draft
|
def notify_new_draft
|
||||||
DossierMailer.notify_new_draft(draft)
|
DossierMailer.with(dossier: draft).notify_new_draft
|
||||||
end
|
end
|
||||||
|
|
||||||
def notify_new_answer
|
def notify_new_answer
|
||||||
|
|
Loading…
Reference in a new issue