feat(email): Integrate LeadMail API for transactional emails
Some checks failed
Lock Threads / action (push) Has been cancelled

Replace SMTP with LeadMail API service for sending system transactional emails (password resets, invitations, notifications). LeadMail provides built-in email verification via Verifalia and async queue-based sending.

Configuration:
- Set LEADMAIL_API_TOKEN and LEADMAIL_API_URL in .env
- Falls back to SMTP if LeadMail token not present
- Works via custom ActionMailer delivery method (stable across upstream merges)

Includes:
- LeadmailDelivery class with full spec coverage
- Support for multipart messages, attachments, CC/BCC
- Error handling and logging with message tracking
- Documentation in docs/LEADMAIL_INTEGRATION.md
This commit is contained in:
netlas
2026-04-21 22:51:02 +03:00
parent ddc4c916a4
commit 52988dea5b
5 changed files with 366 additions and 4 deletions

View File

@@ -0,0 +1,105 @@
class LeadmailDelivery
attr_reader :settings
def initialize(settings)
@settings = settings
end
def deliver!(message)
payload = build_payload(message)
response = http_client.post('/emails/send', payload)
unless response.success?
raise LeadmailDeliveryError, "LeadMail API error: #{response.status} - #{response.body}"
end
response_data = JSON.parse(response.body)
message.header['X-LeadMail-Log-ID'] = response_data.dig('data', 'log_id')
Rails.logger.info("Email sent via LeadMail: log_id=#{response_data.dig('data', 'log_id')}, to=#{message.to.inspect}")
response
rescue StandardError => e
Rails.logger.error("LeadMail delivery failed: #{e.message}\nPayload: #{payload.inspect}")
raise
end
private
def build_payload(message)
{
from: extract_from(message),
to: extract_recipients(message.to),
cc: extract_recipients(message.cc),
bcc: extract_recipients(message.bcc),
reply_to: extract_reply_to(message),
subject: message.subject,
html_body: html_body(message),
text_body: text_body(message),
attachments: extract_attachments(message),
options: {
on_verification_failure: 'strip',
allow_disposable: false
}
}.compact
end
def extract_from(message)
from_address = message.from&.first
from_display = message[:from]&.display_names&.first
{
email: from_address,
name: from_display
}.compact
end
def extract_recipients(addresses)
return nil if addresses.blank?
Array(addresses).map { |email| { email: email } }
end
def extract_reply_to(message)
reply_to_address = message.reply_to&.first
return nil if reply_to_address.blank?
{ email: reply_to_address }
end
def html_body(message)
return message.html_part.body.to_s if message.html_part.present?
return message.body.to_s if message.content_type&.start_with?('text/html')
nil
end
def text_body(message)
return message.text_part.body.to_s if message.text_part.present?
return message.body.to_s if message.content_type&.start_with?('text/plain')
nil
end
def extract_attachments(message)
return nil if message.attachments.blank?
message.attachments.map do |attachment|
{
filename: attachment.filename,
mime_type: attachment.mime_type || 'application/octet-stream',
content: Base64.strict_encode64(attachment.body.to_s)
}
end
end
def http_client
@http_client ||= Faraday.new(
url: settings[:api_url],
headers: {
'Authorization' => "Bearer #{settings[:token]}",
'Content-Type' => 'application/json'
}
)
end
end
class LeadmailDeliveryError < StandardError; end