diff --git a/.rubocop.yml b/.rubocop.yml index 5a2721f..e1c38b5 100644 --- a/.rubocop.yml +++ b/.rubocop.yml @@ -13,6 +13,9 @@ Naming/MethodParameterName: RSpec/MultipleExpectations: Max: 4 +RSpec/NestedGroups: + Max: 4 + Style/Documentation: Enabled: false diff --git a/CHANGELOG.md b/CHANGELOG.md index 3608bb1..1df68b4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,3 +5,8 @@ ## [1.0.1] - 2022-06-20 - Update packed files list + + +## [1.1.0] - 2022-07-22 + +- Add ActionMailer support diff --git a/Gemfile.lock b/Gemfile.lock index 653ebd1..fe7855e 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -1,7 +1,9 @@ PATH remote: . specs: - mailtrap (1.0.1) + mailtrap (1.1.0) + mail + net-smtp GEM remote: https://rubygems.org/ @@ -13,6 +15,10 @@ GEM rexml diff-lcs (1.5.0) hashdiff (1.0.1) + mail (2.7.1) + mini_mime (>= 0.1.1) + mini_mime (1.1.2) + net-smtp (0.1.0) parallel (1.22.1) parser (3.1.2.0) ast (~> 2.4.1) diff --git a/README.md b/README.md index 134da1b..68f1cad 100644 --- a/README.md +++ b/README.md @@ -87,6 +87,29 @@ client = Mailtrap::Sending::Client.new(api_key: 'your-api-key') client.send(mail) ``` +### ActionMailer + +This gem also adds ActionMailer delivery method. To configure it, add following to your ActionMailer configuration (in Rails projects located in `config/$ENVIRONMENT.rb`): +```ruby +config.action_mailer.delivery_method = :mailtrap +config.action_mailer.mailtrap_settings = { + api_key: ENV.fetch('MAILTRAP_API_KEY'), + api_host: ENV.fetch('MAILTRAP_API_HOST'), + api_port: ENV.fetch('MAILTRAP_API_PORT') +} +``` +And continue to use ActionMailer as usual. + +To add `category` and `custom_variables`, add them to the mail generation: +```ruby +mail( + to: 'your@email.com', + subject: 'You are awesome!', + category: 'Test category', + custom_variables: { test_variable: 'abc' } +) +``` + ## Development After checking out the repo, run `bin/setup` to install dependencies. Then, run `rake spec` to run the tests. You can also run `bin/console` for an interactive prompt that will allow you to experiment. diff --git a/lib/mailtrap.rb b/lib/mailtrap.rb index 8714c28..18a63c1 100644 --- a/lib/mailtrap.rb +++ b/lib/mailtrap.rb @@ -1,5 +1,6 @@ # frozen_string_literal: true +require_relative 'mailtrap/action_mailer' if defined? ActionMailer require_relative 'mailtrap/sending' require_relative 'mailtrap/version' diff --git a/lib/mailtrap/action_mailer.rb b/lib/mailtrap/action_mailer.rb new file mode 100644 index 0000000..255c6bc --- /dev/null +++ b/lib/mailtrap/action_mailer.rb @@ -0,0 +1,8 @@ +# frozen_string_literal: true + +require_relative 'action_mailer/delivery_method' +require_relative 'action_mailer/railtie' if defined? Rails + +module Mailtrap + module ActionMailer; end +end diff --git a/lib/mailtrap/action_mailer/delivery_method.rb b/lib/mailtrap/action_mailer/delivery_method.rb new file mode 100644 index 0000000..21f9e89 --- /dev/null +++ b/lib/mailtrap/action_mailer/delivery_method.rb @@ -0,0 +1,27 @@ +# frozen_string_literal: true + +require 'base64' + +module Mailtrap + module ActionMailer + class DeliveryMethod + attr_accessor :settings + + def initialize(settings) + self.settings = settings + end + + def deliver!(message) + mail = Mailtrap::Sending::Convert.from_message(message) + + client.send(mail) + end + + private + + def client + @client ||= Mailtrap::Sending::Client.new(**settings.slice(:api_key, :api_host, :api_port)) + end + end + end +end diff --git a/lib/mailtrap/action_mailer/railtie.rb b/lib/mailtrap/action_mailer/railtie.rb new file mode 100644 index 0000000..f0a303f --- /dev/null +++ b/lib/mailtrap/action_mailer/railtie.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +module Mailtrap + module ActionMailer + class Railtie < Rails::Railtie + initializer 'mailtrap_action_mailer.add_delivery_method', before: 'action_mailer.set_configs' do + ActiveSupport.on_load(:action_mailer) do + ::ActionMailer::Base.add_delivery_method(:mailtrap, Mailtrap::ActionMailer::DeliveryMethod) + end + end + end + end +end diff --git a/lib/mailtrap/sending.rb b/lib/mailtrap/sending.rb index a22d35a..e3485cd 100644 --- a/lib/mailtrap/sending.rb +++ b/lib/mailtrap/sending.rb @@ -2,6 +2,7 @@ require_relative 'sending/attachment' require_relative 'sending/client' +require_relative 'sending/convert' require_relative 'sending/mail' module Mailtrap diff --git a/lib/mailtrap/sending/convert.rb b/lib/mailtrap/sending/convert.rb new file mode 100644 index 0000000..087f709 --- /dev/null +++ b/lib/mailtrap/sending/convert.rb @@ -0,0 +1,81 @@ +# frozen_string_literal: true + +module Mailtrap + module Sending + module Convert + class << self + def from_message(message) # rubocop:disable Metrics/AbcSize, Metrics/MethodLength, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity + Mailtrap::Sending::Mail.new( + from: prepare_address(message['from']&.address_list&.addresses&.first), + to: prepare_addresses(message['to']&.address_list&.addresses), + cc: prepare_addresses(message['cc']&.address_list&.addresses), + bcc: prepare_addresses(message['bcc']&.address_list&.addresses), + subject: message.subject, + text: prepare_text_part(message), + html: prepare_html_part(message), + headers: prepare_headers(message), + attachments: prepare_attachments(message.attachments), + category: message['category']&.unparsed_value, + custom_variables: message['custom_variables']&.unparsed_value + ) + end + + private + + PROCESSED_HEADERS = %w[ + from + to + cc + bcc + subject + category + customvariables + contenttype + ].freeze + + def prepare_addresses(addresses) + Array(addresses).map { |address| prepare_address(address) } + end + + def prepare_headers(message) + message + .header_fields + .reject { |header| PROCESSED_HEADERS.include?(header.name.downcase.delete('-')) } + .to_h { |header| [header.name, header.value] } + .compact + end + + def prepare_address(address) + { + email: address.address, + name: address.display_name + }.compact + end + + def prepare_attachments(attachments_list = []) + attachments_list.map do |attachment| + { + content: Base64.strict_encode64(attachment.body.decoded), + type: attachment.mime_type, + filename: attachment.filename, + disposition: attachment.header[:content_disposition]&.disposition_type, + content_id: attachment.header[:content_id]&.field&.content_id + } + end + end + + def prepare_html_part(message) + return message.body.decoded if message.mime_type == 'text/html' + + message.html_part&.decoded + end + + def prepare_text_part(message) + return message.body.decoded if message.mime_type == 'text/plain' || message.mime_type.nil? + + message.text_part&.decoded + end + end + end + end +end diff --git a/lib/mailtrap/version.rb b/lib/mailtrap/version.rb index cf89bc7..8637015 100644 --- a/lib/mailtrap/version.rb +++ b/lib/mailtrap/version.rb @@ -1,5 +1,5 @@ # frozen_string_literal: true module Mailtrap - VERSION = '1.0.1' + VERSION = '1.1.0' end diff --git a/mailtrap.gemspec b/mailtrap.gemspec index 69333a2..d200cf5 100644 --- a/mailtrap.gemspec +++ b/mailtrap.gemspec @@ -19,6 +19,9 @@ Gem::Specification.new do |spec| spec.metadata['changelog_uri'] = 'https://github.com/railsware/mailtrap-ruby/blob/main/CHANGELOG.md' spec.metadata['rubygems_mfa_required'] = 'true' + spec.add_dependency 'mail' + spec.add_dependency 'net-smtp' + # Specify which files should be added to the gem when it is released. # The `git ls-files -z` loads the files in the RubyGem that have been added into git. spec.files = Dir.chdir(File.expand_path(__dir__)) do diff --git a/spec/fixtures/files/attachments/file.png b/spec/fixtures/files/attachments/file.png new file mode 100644 index 0000000..0901973 Binary files /dev/null and b/spec/fixtures/files/attachments/file.png differ diff --git a/spec/fixtures/files/attachments/file.txt b/spec/fixtures/files/attachments/file.txt new file mode 100644 index 0000000..a496efe --- /dev/null +++ b/spec/fixtures/files/attachments/file.txt @@ -0,0 +1 @@ +This is a text file diff --git a/spec/fixtures/vcr_cassettes/Mailtrap_ActionMailer_DeliveryMethod/_deliver_/converts_the_message_and_sends_via_API.yml b/spec/fixtures/vcr_cassettes/Mailtrap_ActionMailer_DeliveryMethod/_deliver_/converts_the_message_and_sends_via_API.yml new file mode 100644 index 0000000..6772813 --- /dev/null +++ b/spec/fixtures/vcr_cassettes/Mailtrap_ActionMailer_DeliveryMethod/_deliver_/converts_the_message_and_sends_via_API.yml @@ -0,0 +1,41 @@ +--- +http_interactions: +- request: + method: post + uri: https://send.api.mailtrap.io/api/send + body: + encoding: UTF-8 + string: '{"to":[{"email":"to_1@railsware.com","name":"To 1"},{"email":"to_2@railsware.com"}],"from":{"email":"mailtrap@mailtrap.io","name":"Mailtrap + Test"},"cc":[{"email":"cc_1@railsware.com"},{"email":"cc_2@railsware.com","name":"Cc + 2"}],"bcc":[{"email":"bcc_1@railsware.com"},{"email":"bcc_2@railsware.com"}],"subject":"You + are awesome!","html":"<div>HTML part</div>","text":"Some text","attachments":[{"content":"VGhpcyBpcyBhIHRleHQgZmlsZQo=","type":"text/plain","filename":"file.txt","disposition":"attachment","content_id":"txt_content_id@test.mail"},{"content":"iVBORw0KGgoAAAANSUhEUgAAABgAAAAYCAYAAADgdz34AAAABmJLR0QA/wD/AP+gvaeTAAABiElEQVRIid3VSW5UMRCA4U8d0gIx7AjhAghBTgFICAkxRFHukDDDIRiuwI7xPNAMISQRV4CABFmkWbie2nrt9muaFZTkxavhr7Jfucz/Ln2s4hU28B0/sBO6VczPCl/GNoYd6wsuZ3ELWKuBe3iSAQa4g7M4jEM4jRt4g5+4lMEHEbc+KUED/xVOvY5iThXgg/gek+UMfq4CbstU8L7RmU/c3qxwUkc0TnN/CT9Ycn4djrenhB/H24j5iMXQX8TTUsCncD6T6daUtzyp8hNSV30uJfgWAUfje70AqMFF7FC6kGOy20rQPoKTBd1ii3EsbF9LCUpH1K62q1uWwr5RSvAyjHdb+rzqSZU38iB8npWMTZu+M96mzU5qfT6H98FYKTn0sRUONwv2hQqcNK+G2FSZsNeNRsX5CqwtF7CHfVzpcn6cJbmlfqsPSJXvRczDaarpZUmaf3JP6pAjsZZw3+jM9/FIffKOyTXpRnY9OJu4+ifgXOaljnghtedurA94HraZn8x/Q34DYaON8Fk9Z1IAAAAASUVORK5CYII=","type":"image/png","filename":"file.png","disposition":"inline","content_id":"png_content_id@test.mail"}],"headers":{"Reply-To":"reply-to@railsware.com","X-Special-Domain-Specific-Header":"SecretValue","One-more-custom-header":"CustomValue"},"category":"Module + Test"}' + headers: + Accept-Encoding: + - gzip;q=1.0,deflate;q=0.6,identity;q=0.3 + Accept: + - "*/*" + User-Agent: + - Ruby + Authorization: + - Bearer <BEARER_TOKEN> + Content-Type: + - application/json + response: + status: + code: 200 + message: OK + headers: + Date: + - Fri, 22 Jul 2022 15:46:59 GMT + Content-Type: + - application/json; charset=utf-8 + Content-Length: + - '266' + Connection: + - keep-alive + body: + encoding: UTF-8 + string: '{"success":true,"message_ids":["858cbc46-09d5-11ed-91e0-0a58a9feac02","858cbc5c-09d5-11ed-91e0-0a58a9feac02","858cbc6d-09d5-11ed-91e0-0a58a9feac02","858cbc7e-09d5-11ed-91e0-0a58a9feac02","858cbc90-09d5-11ed-91e0-0a58a9feac02","858cbca1-09d5-11ed-91e0-0a58a9feac02"]}' + recorded_at: Fri, 22 Jul 2022 15:46:59 GMT +recorded_with: VCR 6.1.0 diff --git a/spec/mailtrap/action_mailer/delivery_method_spec.rb b/spec/mailtrap/action_mailer/delivery_method_spec.rb new file mode 100644 index 0000000..352f342 --- /dev/null +++ b/spec/mailtrap/action_mailer/delivery_method_spec.rb @@ -0,0 +1,49 @@ +# frozen_string_literal: true + +require 'mailtrap/action_mailer' + +RSpec.describe Mailtrap::ActionMailer::DeliveryMethod, :vcr do + describe '#deliver!' do + subject(:deliver!) { described_class.new(settings).deliver!(message) } + + let(:settings) { { api_key: 'correct-api-key' } } + let(:message) { Mail::Message.new(params) } + let(:params) do + { + from: 'Mailtrap Test <mailtrap@mailtrap.io>', + to: 'To 1 <to_1@railsware.com>, to_2@railsware.com', + cc: 'cc_1@railsware.com, Cc 2 <cc_2@railsware.com>', + bcc: 'bcc_1@railsware.com, bcc_2@railsware.com', + reply_to: 'reply-to@railsware.com', + subject: 'You are awesome!', + category: 'Module Test' + } + end + let(:expected_message_ids) do + %w[ + 858cbc46-09d5-11ed-91e0-0a58a9feac02 + 858cbc5c-09d5-11ed-91e0-0a58a9feac02 + 858cbc6d-09d5-11ed-91e0-0a58a9feac02 + 858cbc7e-09d5-11ed-91e0-0a58a9feac02 + 858cbc90-09d5-11ed-91e0-0a58a9feac02 + 858cbca1-09d5-11ed-91e0-0a58a9feac02 + ] + end + + before do + allow(::Mail::ContentTypeField).to receive(:generate_boundary).and_return('--==_mimepart_random_boundary') + message.text_part = 'Some text' + message.html_part = '<div>HTML part</div>' + message.headers('X-Special-Domain-Specific-Header': 'SecretValue') + message.headers('One-more-custom-header': 'CustomValue') + message.attachments['file.txt'] = File.read('spec/fixtures/files/attachments/file.txt') + message.attachments['file.txt'].content_id = '<txt_content_id@test.mail>' + message.attachments.inline['file.png'] = File.read('spec/fixtures/files/attachments/file.png') + message.attachments['file.png'].content_id = '<png_content_id@test.mail>' + end + + it 'converts the message and sends via API' do + expect(deliver!).to eq({ success: true, message_ids: expected_message_ids }) + end + end +end diff --git a/spec/mailtrap/sending/convert_spec.rb b/spec/mailtrap/sending/convert_spec.rb new file mode 100644 index 0000000..ddb9f71 --- /dev/null +++ b/spec/mailtrap/sending/convert_spec.rb @@ -0,0 +1,130 @@ +# frozen_string_literal: true + +RSpec.describe Mailtrap::Sending::Convert do + describe '.from_message' do + subject(:mail) do + described_class.from_message(message) + end + + let(:message) { ::Mail::Message.new(**message_params) } + let(:message_params) do + { + from: 'Mailtrap Test <mailtrap@mailtrap.io>', + to: 'To 1 <to_1@railsware.com>, to_2@railsware.com', + cc: 'cc_1@railsware.com, Cc 2 <cc_2@railsware.com>', + bcc: 'bcc_1@railsware.com, bcc_2@railsware.com', + subject: 'You are awesome!', + body: 'Text body', + category: 'Module Test' + } + end + + its(:from) { is_expected.to eq({ name: 'Mailtrap Test', email: 'mailtrap@mailtrap.io' }) } + its(:to) { is_expected.to eq([{ name: 'To 1', email: 'to_1@railsware.com' }, { email: 'to_2@railsware.com' }]) } + its(:cc) { is_expected.to eq([{ email: 'cc_1@railsware.com' }, { name: 'Cc 2', email: 'cc_2@railsware.com' }]) } + its(:bcc) { is_expected.to eq([{ email: 'bcc_1@railsware.com' }, { email: 'bcc_2@railsware.com' }]) } + its(:subject) { is_expected.to eq('You are awesome!') } + its(:text) { is_expected.to eq('Text body') } + its(:category) { is_expected.to eq('Module Test') } + + describe '#headers' do + subject(:headers) { mail.headers } + + it { is_expected.to be_empty } + + context 'when custom headers added' do + let(:expected_headers) do + { + 'X-Special-Domain-Specific-Header' => 'SecretValue', + 'Reply-To' => 'Reply To <reply-to@railsware.com>', + 'One-more-custom-header' => 'CustomValue' + } + end + + before do + message.reply_to = 'Reply To <reply-to@railsware.com>' + message.headers('X-Special-Domain-Specific-Header': 'SecretValue') + message.headers('One-more-custom-header': 'CustomValue') + end + + it { is_expected.to eq(expected_headers) } + end + end + + describe '#attachment' do + subject(:json_attachments) { mail.attachments.map(&:as_json) } + + let(:expected_json_attachment) do + [ + { + 'content' => 'VGhpcyBpcyBhIHRleHQgZmlsZQo=', + 'disposition' => 'attachment', + 'filename' => 'file.txt', + 'content_id' => a_string_ending_with('@test.mail'), + 'type' => 'text/plain' + }, + { + 'content' => 'iVBORw0KGgoAAAANSUhEUgAAABgAAAAYCAYAAADgdz34AAAABmJLR0QA/wD/AP+gvaeTAAABiElEQVRIid3VSW5UMRCA4U8d0gIx7AjhAghBTgFICAkxRFHukDDDIRiuwI7xPNAMISQRV4CABFmkWbie2nrt9muaFZTkxavhr7Jfucz/Ln2s4hU28B0/sBO6VczPCl/GNoYd6wsuZ3ELWKuBe3iSAQa4g7M4jEM4jRt4g5+4lMEHEbc+KUED/xVOvY5iThXgg/gek+UMfq4CbstU8L7RmU/c3qxwUkc0TnN/CT9Ycn4djrenhB/H24j5iMXQX8TTUsCncD6T6daUtzyp8hNSV30uJfgWAUfje70AqMFF7FC6kGOy20rQPoKTBd1ii3EsbF9LCUpH1K62q1uWwr5RSvAyjHdb+rzqSZU38iB8npWMTZu+M96mzU5qfT6H98FYKTn0sRUONwv2hQqcNK+G2FSZsNeNRsX5CqwtF7CHfVzpcn6cJbmlfqsPSJXvRczDaarpZUmaf3JP6pAjsZZw3+jM9/FIffKOyTXpRnY9OJu4+ifgXOaljnghtedurA94HraZn8x/Q34DYaON8Fk9Z1IAAAAASUVORK5CYII=', # rubocop:disable Layout/LineLength + 'disposition' => 'inline', + 'filename' => 'file.png', + 'content_id' => a_string_ending_with('@test.mail'), + 'type' => 'image/png' + } + ] + end + + before do + message.attachments['file.txt'] = File.read('spec/fixtures/files/attachments/file.txt') + message.attachments.inline['file.png'] = File.read('spec/fixtures/files/attachments/file.png') + allow(Socket).to receive(:gethostname).and_return('test') + end + + it { is_expected.to include(*expected_json_attachment) } + end + + describe 'text content' do + before do + message_params.delete(:body) + end + + it 'has empty text by default' do + expect(mail.text).to be_empty + expect(mail.html).to be_nil + end + + context 'when only text part is present' do + before do + message.text_part = 'Some text' + end + + it 'only text is present' do + expect(mail.text).to eq('Some text') + expect(mail.html).to be_nil + end + end + + context 'when only html part is present' do + before do + message.html_part = '<div>HTML part</div>' + end + + it 'only html is present' do + expect(mail.text).to be_nil + expect(mail.html).to eq('<div>HTML part</div>') + end + end + + context 'when both text and html part are present' do + before do + message.text_part = 'Some text' + message.html_part = '<div>HTML part</div>' + end + + it 'only html is present' do + expect(mail.text).to eq('Some text') + expect(mail.html).to eq('<div>HTML part</div>') + end + end + end + end +end diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb index 8399de1..e447644 100644 --- a/spec/spec_helper.rb +++ b/spec/spec_helper.rb @@ -1,5 +1,6 @@ # frozen_string_literal: true +require 'mail' require 'mailtrap' require 'rspec/its' require 'vcr'