diff --git a/.env.example b/.env.example index bc7380a29..7bb64d392 100644 --- a/.env.example +++ b/.env.example @@ -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= diff --git a/app/delivery_methods/leadmail_delivery.rb b/app/delivery_methods/leadmail_delivery.rb new file mode 100644 index 000000000..467419364 --- /dev/null +++ b/app/delivery_methods/leadmail_delivery.rb @@ -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 diff --git a/config/initializers/mailer.rb b/config/initializers/mailer.rb index 3d585d8b6..ab08a917a 100644 --- a/config/initializers/mailer.rb +++ b/config/initializers/mailer.rb @@ -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? - config.action_mailer.smtp_settings = smtp_settings + # 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? + # 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'] diff --git a/docs/LEADMAIL_INTEGRATION.md b/docs/LEADMAIL_INTEGRATION.md new file mode 100644 index 000000000..ecd2aec43 --- /dev/null +++ b/docs/LEADMAIL_INTEGRATION.md @@ -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 +``` diff --git a/spec/delivery_methods/leadmail_delivery_spec.rb b/spec/delivery_methods/leadmail_delivery_spec.rb new file mode 100644 index 000000000..f340943a2 --- /dev/null +++ b/spec/delivery_methods/leadmail_delivery_spec.rb @@ -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 '