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

@@ -95,6 +95,11 @@ SMTP_OPENSSL_VERIFY_MODE=peer
# SMTP_OPEN_TIMEOUT
# SMTP_READ_TIMEOUT
# LeadMail API (alternative to SMTP for transactional emails)
# If LEADMAIL_API_TOKEN is set, it takes precedence over SMTP configuration
# LEADMAIL_API_TOKEN=lm_your_token_here
# LEADMAIL_API_URL=https://mail.leadmagnet.dev/api/v1
# Mail Incoming
# This is the domain set for the reply emails when conversation continuity is enabled
MAILER_INBOUND_EMAIL_DOMAIN=

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

View File

@@ -27,11 +27,21 @@ Rails.application.configure do
smtp_settings[:open_timeout] = ENV['SMTP_OPEN_TIMEOUT'].to_i if ENV['SMTP_OPEN_TIMEOUT'].present?
smtp_settings[:read_timeout] = ENV['SMTP_READ_TIMEOUT'].to_i if ENV['SMTP_READ_TIMEOUT'].present?
config.action_mailer.delivery_method = :smtp unless Rails.env.test?
# Use LeadMail API if configured
if ENV['LEADMAIL_API_TOKEN'].present?
ActionMailer::Base.add_delivery_method :leadmail, LeadmailDelivery
config.action_mailer.delivery_method = :leadmail
config.action_mailer.leadmail_settings = {
api_url: ENV.fetch('LEADMAIL_API_URL', 'https://mail.leadmagnet.dev/api/v1'),
token: ENV['LEADMAIL_API_TOKEN']
}
elsif !Rails.env.test?
config.action_mailer.delivery_method = :smtp
config.action_mailer.smtp_settings = smtp_settings
# Use sendmail if using postfix for email
config.action_mailer.delivery_method = :sendmail if ENV['SMTP_ADDRESS'].blank?
end
# You can use letter opener for your local development by setting the environment variable
config.action_mailer.delivery_method = :letter_opener if Rails.env.development? && ENV['LETTER_OPENER']

View File

@@ -0,0 +1,62 @@
# LeadMail Integration
Chatwoot is configured to use LeadMail API for sending transactional emails (password resets, invites, notifications, etc.).
## Configuration
Set these environment variables:
```env
LEADMAIL_API_TOKEN=lm_your_token_here
LEADMAIL_API_URL=https://mail.leadmagnet.dev/api/v1
```
## How It Works
1. **Priority**: If `LEADMAIL_API_TOKEN` is present, LeadMail is used. Otherwise, falls back to SMTP.
2. **Async Processing**: LeadMail accepts emails asynchronously (202 Accepted) and queues them for background processing.
3. **Email Verification**: Includes Verifalia verification to filter invalid/disposable emails (configurable via `on_verification_failure` option).
4. **Message Tracking**: Each sent email receives a `log_id` stored in the `X-LeadMail-Log-ID` header for debugging.
## Email Types Using This
All transactional emails:
- User account invitations
- Password reset links
- Account notifications (e.g., OAuth disconnects)
- Digest emails
- System-generated messages
## Not Affected
- **Email inbox replies**: Each email inbox can use its own SMTP credentials (configured per-inbox in dashboard)
- **Conversation messages**: Only transactional system emails use LeadMail
## Fallback Behavior
If LeadMail API is unavailable:
- Errors are logged with the failed payload
- Delivery exceptions propagate (configured via `config.action_mailer.raise_delivery_errors`)
- SMTP fallback is NOT automatic; you must not set `LEADMAIL_API_TOKEN` to use SMTP
## Debugging
Check logs for:
```
Email sent via LeadMail: log_id=12345, to=["recipient@example.com"]
```
Failures include the payload for investigation:
```
LeadMail delivery failed: [error message]
Payload: {...}
```
## Testing
The delivery method is fully spec'd in `spec/delivery_methods/leadmail_delivery_spec.rb`.
Run tests:
```bash
bundle exec rspec spec/delivery_methods/leadmail_delivery_spec.rb
```

View File

@@ -0,0 +1,180 @@
require 'rails_helper'
RSpec.describe LeadmailDelivery do
let(:settings) do
{
api_url: 'https://mail.leadmagnet.dev/api/v1',
token: 'lm_test_token_123'
}
end
let(:delivery) { described_class.new(settings) }
let(:message) do
Mail.new do
from 'sender@example.com'
to 'recipient@example.com'
subject 'Test Subject'
body 'Plain text body'
end
end
describe '#deliver!' do
it 'sends email via LeadMail API' do
stub_request(:post, 'https://mail.leadmagnet.dev/api/v1/emails/send')
.with(
headers: { 'Authorization' => 'Bearer lm_test_token_123' },
body: hash_including(
subject: 'Test Subject',
from: { email: 'sender@example.com' },
to: [{ email: 'recipient@example.com' }]
)
)
.to_return(
status: 202,
body: { success: true, data: { log_id: 12345, status: 'queued' } }.to_json
)
delivery.deliver!(message)
expect(message.header['X-LeadMail-Log-ID'].value).to eq('12345')
end
it 'handles multipart messages (HTML + text)' do
html_message = Mail.new do
from 'sender@example.com'
to 'recipient@example.com'
subject 'HTML Email'
text_part do
body 'Plain text version'
end
html_part do
content_type 'text/html; charset=UTF-8'
body '<h1>HTML version</h1>'
end
end
stub_request(:post, 'https://mail.leadmagnet.dev/api/v1/emails/send')
.to_return(
status: 202,
body: { success: true, data: { log_id: 12346, status: 'queued' } }.to_json
)
delivery.deliver!(html_message)
expect(a_request(:post, 'https://mail.leadmagnet.dev/api/v1/emails/send')
.with { |req| body_hash = JSON.parse(req.body); body_hash['text_body'] == 'Plain text version' && body_hash['html_body'] == '<h1>HTML version</h1>' })
.to have_been_made
end
it 'includes CC and BCC recipients' do
message_with_cc = Mail.new do
from 'sender@example.com'
to 'recipient@example.com'
cc 'cc@example.com'
bcc 'bcc@example.com'
subject 'With CC/BCC'
body 'Test'
end
stub_request(:post, 'https://mail.leadmagnet.dev/api/v1/emails/send')
.to_return(
status: 202,
body: { success: true, data: { log_id: 12347, status: 'queued' } }.to_json
)
delivery.deliver!(message_with_cc)
expect(a_request(:post, 'https://mail.leadmagnet.dev/api/v1/emails/send')
.with { |req| body_hash = JSON.parse(req.body); body_hash['cc'] == [{ 'email' => 'cc@example.com' }] && body_hash['bcc'] == [{ 'email' => 'bcc@example.com' }] })
.to have_been_made
end
it 'handles reply-to header' do
message_with_reply = Mail.new do
from 'sender@example.com'
to 'recipient@example.com'
reply_to 'reply@example.com'
subject 'With Reply-To'
body 'Test'
end
stub_request(:post, 'https://mail.leadmagnet.dev/api/v1/emails/send')
.to_return(
status: 202,
body: { success: true, data: { log_id: 12348, status: 'queued' } }.to_json
)
delivery.deliver!(message_with_reply)
expect(a_request(:post, 'https://mail.leadmagnet.dev/api/v1/emails/send')
.with { |req| body_hash = JSON.parse(req.body); body_hash['reply_to'] == { 'email' => 'reply@example.com' } })
.to have_been_made
end
it 'encodes attachments in base64' do
message_with_attachment = Mail.new do
from 'sender@example.com'
to 'recipient@example.com'
subject 'With Attachment'
body 'Test'
add_file filename: 'test.txt', content: 'File content'
end
stub_request(:post, 'https://mail.leadmagnet.dev/api/v1/emails/send')
.to_return(
status: 202,
body: { success: true, data: { log_id: 12349, status: 'queued' } }.to_json
)
delivery.deliver!(message_with_attachment)
expect(a_request(:post, 'https://mail.leadmagnet.dev/api/v1/emails/send')
.with { |req| body_hash = JSON.parse(req.body); body_hash['attachments'].first['content'].present? })
.to have_been_made
end
it 'raises LeadmailDeliveryError on API failure' do
stub_request(:post, 'https://mail.leadmagnet.dev/api/v1/emails/send')
.to_return(status: 500, body: 'Internal Server Error')
expect { delivery.deliver!(message) }
.to raise_error(LeadmailDeliveryError, /LeadMail API error: 500/)
end
it 'sets default options for verification and disposable emails' do
stub_request(:post, 'https://mail.leadmagnet.dev/api/v1/emails/send')
.to_return(
status: 202,
body: { success: true, data: { log_id: 12350, status: 'queued' } }.to_json
)
delivery.deliver!(message)
expect(a_request(:post, 'https://mail.leadmagnet.dev/api/v1/emails/send')
.with { |req| body_hash = JSON.parse(req.body); body_hash['options']['on_verification_failure'] == 'strip' && body_hash['options']['allow_disposable'] == false })
.to have_been_made
end
it 'logs successful delivery' do
stub_request(:post, 'https://mail.leadmagnet.dev/api/v1/emails/send')
.to_return(
status: 202,
body: { success: true, data: { log_id: 12351, status: 'queued' } }.to_json
)
expect(Rails.logger).to receive(:info).with(match(/Email sent via LeadMail.*log_id=12351/))
delivery.deliver!(message)
end
it 'logs delivery errors' do
stub_request(:post, 'https://mail.leadmagnet.dev/api/v1/emails/send')
.to_raise(StandardError.new('Network error'))
expect(Rails.logger).to receive(:error).with(match(/LeadMail delivery failed/))
expect { delivery.deliver!(message) }.to raise_error(StandardError)
end
end
end