demarches-normaliennes/app/lib/dolist/api.rb

328 lines
11 KiB
Ruby
Raw Normal View History

require "support/jsv"
2023-04-24 16:10:12 +02:00
module Dolist
class API
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_KEY = 7
STATUS_KEY = 72
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
# those code are just undocumented
IGNORABLE_API_ERROR_CODE = [
"458",
"402"
]
# see: https://usercampaign.dolist.net/wp-content/uploads/2022/12/Comprendre-les-Opt-out-tableau-v2.pdf
IGNORABLE_CONTACT_STATUSES = [
"4", # Le serveur distant n'accepte pas le mail car il identifie que ladresse e-mail est en erreur.
"7" # Suite à un envoi, le serveur distant accepte le mail dans un premier temps mais envoie une erreur définitive car ladresse e-mail est en erreur. L'adresse e-mail nexiste pas ou n'existe plus.
]
class << self
def save_rate_limit_headers(headers)
self.limit_remaining = headers["X-Rate-Limit-Remaining"].to_i
self.limit_reset_at = Time.zone.at(headers["X-Rate-Limit-Reset"].to_i / 1_000)
end
def near_rate_limit?
return if limit_remaining.nil?
limit_remaining < 20 # keep 20 requests for non background API calls
end
def sleep_until_limit_reset
return if limit_reset_at.nil? || limit_reset_at.past?
sleep (limit_reset_at - Time.zone.now).ceil
end
def sendable?(mail)
return false if mail.to.blank? # recipient are mandatory
return false if mail.bcc.present? # no bcc support
# Mail having attachments are not yet supported in our account
mail.attachments.none? { !_1.inline? }
end
end
2023-04-24 16:10:12 +02:00
def properly_configured?
client_key.present?
end
2023-04-24 16:10:12 +02:00
def send_email(mail)
if mail.attachments.any? { !_1.inline? }
return send_email_with_attachment(mail)
end
2023-04-24 16:10:12 +02:00
body = { "TransactionalSending": prepare_mail_body(mail) }
2023-04-24 16:10:12 +02:00
url = format_url(EMAIL_SENDING_TRANSACTIONAL)
post(url, body.to_json)
end
2023-04-24 16:10:12 +02:00
def send_email_with_attachment(mail)
uri = URI(format_url(EMAIL_SENDING_TRANSACTIONAL_ATTACHMENT))
2022-04-26 11:44:42 +02:00
2023-04-24 16:10:12 +02:00
request = Net::HTTP::Post.new(uri)
2023-04-24 16:10:12 +02:00
default_headers.each do |key, value|
next if key.to_s == "Content-Type"
request[key] = value
end
2023-04-24 16:10:12 +02:00
boundary = "---011000010111000001101001" # any random string not present in the body
request.content_type = "multipart/form-data; boundary=#{boundary}"
2023-04-24 16:10:12 +02:00
body = "--#{boundary}\r\n"
2023-04-24 16:10:12 +02:00
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
2023-04-24 16:10:12 +02:00
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
2023-04-24 16:10:12 +02:00
body << "\r\n--#{boundary}--\r\n"
body << "\r\n"
2023-04-24 16:10:12 +02:00
request.body = body
2023-04-24 16:10:12 +02:00
http = Net::HTTP.new(uri.host, uri.port)
http.use_ssl = true
2023-04-24 16:10:12 +02:00
response = http.request(request)
2023-04-24 16:10:12 +02:00
if response.body.empty?
fail "Dolist API returned an empty response"
else
JSON.parse(response.body)
end
end
2023-04-24 16:10:12 +02:00
def sent_mails(email_address)
contact_id = fetch_contact_id(email_address)
if contact_id.nil?
Rails.logger.info "Dolist::API: no contact found for email address '#{email_address}'"
return []
end
2023-04-24 16:10:12 +02:00
dolist_messages = fetch_dolist_messages(contact_id)
2023-04-24 16:10:12 +02:00
dolist_messages.map { |m| to_sent_mail(email_address, contact_id, m) }
rescue StandardError => e
Rails.logger.error e.message
[]
end
2023-04-24 16:10:12 +02:00
def senders
get format_url(EMAIL_MESSAGES_ADRESSES_PACKSENDERS)
end
2023-04-24 16:10:12 +02:00
def replies
get format_url(EMAIL_MESSAGES_ADRESSES_REPLIES)
2022-04-26 11:44:42 +02:00
end
2023-04-24 16:10:12 +02:00
# Une adresse e-mail peut ne pas être adressable pour différentes raisons (injoignable, plainte pour spam, blocage dun FAI).
# Dans ce cas lAPI denvoi transactionnel renvoie différentes erreurs.
# Pour connaitre exactement le statut dune adresse, je vous invite à récupérer le champ 72 du contact à partir de son adresse e-mail avec la méthode https://api.dolist.com/documentation/index.html#/40e7751d00dc3-rechercher-un-contact
#
# La liste des différents statuts est disponible sur https://usercampaign.dolist.net/wp-content/uploads/2022/12/Comprendre-les-Opt-out-tableau-v2.pdf
def fetch_contact_status(email_address)
url = format(Dolist::API::CONTACT_URL, account_id: account_id)
body = {
Query: {
FieldValueList: [{ ID: 7, Value: email_address }],
OutputFieldIDList: [72]
}
}.to_json
fields = post(url, body).fetch("FieldList", [])
if fields.empty?
Sentry.with_scope do |scope|
scope.set_extra(:email, email_address)
Sentry.capture_message("Dolist::API: contact not found")
end
return nil
end
fields.find { _1['ID'] == 72 }.fetch('Value')
2023-04-24 16:10:12 +02:00
end
2022-04-26 11:44:42 +02:00
2023-04-24 16:10:12 +02:00
def ignorable_error?(response, mail)
error_code = response&.dig("ResponseStatus", "ErrorCode")
invalid_contact_status = if ignorable_api_error_code?(error_code)
fetch_contact_status(mail.to.first)
else
nil
end
[error_code, invalid_contact_status]
end
2022-04-26 11:44:42 +02:00
2023-04-24 16:10:12 +02:00
private
2023-04-24 16:10:12 +02:00
def ignorable_api_error_code?(api_error_code)
IGNORABLE_API_ERROR_CODE.include?(api_error_code)
end
2023-04-24 16:10:12 +02:00
def ignorable_contact_status?(contact_status)
IGNORABLE_CONTACT_STATUSES.include?(contact_status)
end
2023-04-24 16:10:12 +02:00
def format_url(base)
format(base, account_id: account_id)
end
2023-04-24 16:10:12 +02:00
def sender_id
Rails.cache.fetch("dolist_api_sender_id", expires_in: 1.hour) do
senders.dig("ItemList", 0, "Sender", "ID")
end
end
2023-04-24 16:10:12 +02:00
def get(url)
response = Typhoeus.get(url, headers: default_headers).tap do
self.class.save_rate_limit_headers(_1.headers)
end
2023-04-24 16:10:12 +02:00
JSON.parse(response.response_body)
end
2023-04-24 16:10:12 +02:00
def post(url, body)
response = Typhoeus.post(url, body:, headers: default_headers).tap do
self.class.save_rate_limit_headers(_1.headers)
end
2023-04-24 16:10:12 +02:00
if response.response_body.empty?
fail "Empty response from Dolist API"
else
JSON.parse(response.response_body)
end
end
2023-04-24 16:10:12 +02:00
def default_headers
{
"Content-Type": 'application/json',
"Accept": 'application/json',
"X-API-Key": client_key
}
end
2023-04-24 16:10:12 +02:00
def client_key
Rails.application.secrets.dolist[:api_key]
end
2023-04-24 16:10:12 +02:00
def account_id
Rails.application.secrets.dolist[:account_id]
end
2023-04-24 16:10:12 +02:00
# https://api.dolist.com/documentation/index.html#/b3A6Mzg0MTQ0MDc-rechercher-un-contact
def fetch_contact_id(email_address)
url = format(CONTACT_URL, account_id: account_id)
2022-04-26 11:44:42 +02:00
2023-04-24 16:10:12 +02:00
body = {
Query: { FieldValueList: [{ ID: EMAIL_KEY, Value: email_address }] }
}.to_json
2022-04-26 11:44:42 +02:00
2023-04-24 16:10:12 +02:00
post(url, body)["ID"]
end
2022-04-26 11:44:42 +02:00
2023-04-24 16:10:12 +02:00
# https://api.dolist.com/documentation/index.html#/b3A6Mzg0MTQ4MDk-recuperer-les-statistiques-des-envois-pour-un-contact
def fetch_dolist_messages(contact_id)
url = format(EMAIL_LOGS_URL, account_id: account_id)
2022-04-26 11:44:42 +02:00
2023-04-24 16:10:12 +02:00
body = { SearchQuery: { ContactID: contact_id } }.to_json
2022-04-26 11:44:42 +02:00
2023-04-24 16:10:12 +02:00
post(url, body)["ItemList"]
end
2023-04-24 16:10:12 +02:00
# see: https://api.dolist.com/documentation/index.html#/7edc2948ba01f-creer-un-envoi-transactionnel
def prepare_mail_body(mail)
{
"Type": "TransactionalService",
"Contact": {
"FieldList": [
{
"ID": EMAIL_KEY,
"Value": mail.to.first
}
]
},
"Message": {
"Name": mail['X-Dolist-Message-Name'].value,
"Subject": mail.subject,
"SenderID": sender_id,
"ForceHttp": false, # ForceHttp : force le tracking http non sécurisé (True/False).
"Format": "html",
"DisableOpenTracking": true, # DisableOpenTracking : désactivation du tracking d'ouverture (True/False).
"IsTrackingValidated": true # IsTrackingValidated : est-ce que le tracking de ce message est validé ? (True/False). Passez la valeur True pour un envoi transactionnel.
},
"MessageContent": {
"SourceCode": mail_source_code(mail),
"EncodingType": "UTF8",
"EnableTrackingDetection": false # EnableTrackingDetection : booléen pour lactivation du tracking personnalisé des liens des messages utilisés lors des précédents envois (True/False).
}
}
2023-04-24 16:10:12 +02:00
end
2023-04-24 16:10:12 +02:00
def to_sent_mail(email_address, contact_id, dolist_message)
SentMail.new(
from: ENV['DOLIST_NO_REPLY_EMAIL'],
to: email_address,
subject: dolist_message['SendingName'],
delivered_at: Time.zone.parse(dolist_message['SendDate']),
status: status(dolist_message),
service_name: 'Dolist',
external_url: format(DOLIST_WEB_DASHBOARD, account_id: account_id, contact_id: contact_id)
)
end
2022-04-26 11:44:42 +02:00
2023-04-24 16:10:12 +02:00
def status(dolist_message)
case dolist_message.fetch_values('Status', 'IsDelivered')
in ['Sent', true]
"delivered"
in ['Sent', false]
"sent (delivered ?)"
in [status, _]
status
end
2022-04-26 11:44:42 +02:00
end
2023-04-24 16:10:12 +02:00
def mail_source_code(mail)
if mail.html_part.nil? && mail.text_part.nil?
mail.decoded
else
mail.html_part.body.decoded
end
end
2023-04-24 16:10:12 +02:00
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"
2023-04-24 16:10:12 +02:00
field_name = File.basename(attachment.filename, File.extname(attachment.filename))
attachment_content = attachment.body.decoded
attachment_base64 = Base64.strict_encode64(attachment_content)
2023-04-24 16:10:12 +02:00
Dolist::Base64File.new(field_name:, filename: attachment.filename, mime_type: attachment.mime_type, content: attachment_base64)
end
end
end
2022-04-26 11:44:42 +02:00
end