Merge pull request #8533 from colinux/feat-dolist-send-mail-api

Email: API Dolist pour envoyer des emails transactionnels
This commit is contained in:
mfo 2023-02-03 14:36:53 +01:00 committed by GitHub
commit 48503e0743
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
14 changed files with 298 additions and 9 deletions

View file

@ -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

View file

@ -0,0 +1,3 @@
module Dolist
Base64File = Struct.new(:field_name, :filename, :mime_type, :content, keyword_init: true)
end

View file

@ -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

View file

@ -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
View 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"

View file

@ -0,0 +1,5 @@
class Array
def to_jsv
"[" + reject(&:nil?).map(&:to_jsv).join(",") + "]"
end
end

View file

@ -0,0 +1,5 @@
class TrueClass
def to_jsv
"True"
end
end

View 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

View file

@ -0,0 +1,5 @@
class Numeric
def to_jsv
self
end
end

View 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

View file

@ -0,0 +1,5 @@
class Symbol
def to_jsv
to_s.to_jsv
end
end

View file

@ -0,0 +1,5 @@
class FalseClass
def to_jsv
"False"
end
end

View 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

View file

@ -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