feat(email): Integrate LeadMail API for transactional emails
Some checks failed
Lock Threads / action (push) Has been cancelled
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:
@@ -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=
|
||||
|
||||
105
app/delivery_methods/leadmail_delivery.rb
Normal file
105
app/delivery_methods/leadmail_delivery.rb
Normal 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
|
||||
@@ -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']
|
||||
|
||||
62
docs/LEADMAIL_INTEGRATION.md
Normal file
62
docs/LEADMAIL_INTEGRATION.md
Normal 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
|
||||
```
|
||||
180
spec/delivery_methods/leadmail_delivery_spec.rb
Normal file
180
spec/delivery_methods/leadmail_delivery_spec.rb
Normal 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
|
||||
Reference in New Issue
Block a user