diff --git a/README.md b/README.md index d29bd7a..a7fa46c 100644 --- a/README.md +++ b/README.md @@ -92,9 +92,58 @@ end You might also want to have a look at the sources for the [demo](http://letter-opener-web.herokuapp.com) available at https://github.com/fgrehm/letter_opener_web_demo. -**NOTICE: Using this gem on Heroku will only work if your app has just one Dyno -and does not send emails from background jobs. For updates on this matter please -subscribe to [GH-35](https://github.com/fgrehm/letter_opener_web/issues/35)** +### Usage with Amazon S3 to support multiple separated instances + +If you are using this gem on Heroku and your application is not using one Dyno or your have containerized setup, the default configuration won't work as the e-mail is saved on the server. You can use S3 bucket instead. + +**1. Configure AWS environment:** + +* Create new non-public bucket, note the name and the region +* Create new user using IAM or use existing one for which you already have `aws_access_key_id` and `aws_secret_access_key` +* Assign proper policy to the user. Replace `your-bucket-name` with the name of the bucket you have created + +```json +{ + "Version": "2012-10-17", + "Statement": [ + { + "Sid": "VisualEditor0", + "Effect": "Allow", + "Action": [ + "s3:PutObject", + "s3:PutObjectAcl", + "s3:GetObject", + "s3:DeleteObject*" + ], + "Resource": "arn:aws:s3:::your-bucket-name/*" + }, + { + "Sid": "VisualEditor1", + "Effect": "Allow", + "Action": [ + "s3:ListBucket" + ], + "Resource": "arn:aws:s3:::your-bucket-name" + } + ] +} +``` + +**2. Update gem configuration:** + +Add the following configuration to the initializer (or environment files): + +```ruby +LetterOpenerWeb.configure do |config| + config.aws_access_key_id = ENV['AWS_ACCESS_KEY_ID'] + config.aws_secret_access_key = ENV['AWS_SECRET_ACCESS_KEY'] + config.aws_region = ENV['AWS_REGION'] + config.aws_bucket = ENV['AWS_BUCKET'] + config.letters_location = :s3 +end +``` + +When you send e-mail with attachment(s), the presigned link is generated to attachment that is valid for 1 week. ## Acknowledgements diff --git a/app/controllers/letter_opener_web/letters_controller.rb b/app/controllers/letter_opener_web/letters_controller.rb index 7eaebd6..3d927b9 100644 --- a/app/controllers/letter_opener_web/letters_controller.rb +++ b/app/controllers/letter_opener_web/letters_controller.rb @@ -10,7 +10,7 @@ class LettersController < ApplicationController before_action :load_letter, only: %i[show attachment destroy] def index - @letters = LetterOpenerWeb::Letter.search + @letters = letter_model.search end def show @@ -26,12 +26,13 @@ def attachment file = @letter.attachments[filename] return render plain: 'Attachment not found!', status: 404 unless file.present? + return redirect_to(file, allow_other_host: true) if LetterOpenerWeb.config.letters_location == :s3 send_file(file, filename: filename, disposition: 'inline') end def clear - LetterOpenerWeb::Letter.destroy_all + letter_model.destroy_all redirect_to routes.letters_path end @@ -50,7 +51,7 @@ def check_style end def load_letter - @letter = LetterOpenerWeb::Letter.find(params[:id]) + @letter = letter_model.find(params[:id]) head :not_found unless @letter.valid? end @@ -58,5 +59,11 @@ def load_letter def routes LetterOpenerWeb.railtie_routes_url_helpers end + + private + + def letter_model + LetterOpenerWeb.config.letters_location == :s3 ? LetterOpenerWeb::AwsLetter : LetterOpenerWeb::Letter + end end end diff --git a/app/models/letter_opener_web/aws_letter.rb b/app/models/letter_opener_web/aws_letter.rb new file mode 100644 index 0000000..42f9c00 --- /dev/null +++ b/app/models/letter_opener_web/aws_letter.rb @@ -0,0 +1,89 @@ +# frozen_string_literal: true + +module LetterOpenerWeb + class AwsLetter < LetterOpenerWeb::Letter + def self.search + letters = LetterOpenerWeb.aws_client.list_objects_v2(bucket: LetterOpenerWeb.config.aws_bucket, delimiter: '/').common_prefixes.map do |folder| + new(id: folder.prefix.gsub('/', '')) + end + + letters.reverse + end + + def self.destroy_all + letters = LetterOpenerWeb.aws_client.list_objects_v2(bucket: LetterOpenerWeb.config.aws_bucket).contents.map(&:key) + + LetterOpenerWeb.aws_client.delete_objects( + bucket: LetterOpenerWeb.config.aws_bucket, + delete: { + objects: letters.map { |key| { key: key } }, + quiet: false + } + ) + end + + def initialize(params) + @id = params.fetch(:id) + end + + def attachments + @attachments ||= LetterOpenerWeb.aws_client.list_objects_v2( + bucket: LetterOpenerWeb.config.aws_bucket, prefix: "#{@id}/attachments/" + ).contents.each_with_object({}) do |file, hash| + hash[File.basename(file.key)] = attachment_url(file.key) + end + end + + def delete + return unless valid? + + letters = LetterOpenerWeb.aws_client.list_objects_v2(bucket: LetterOpenerWeb.config.aws_bucket, prefix: @id).contents.map(&:key) + + LetterOpenerWeb.aws_client.delete_objects( + bucket: LetterOpenerWeb.config.aws_bucket, + delete: { + objects: letters.map { |key| { key: key } }, + quiet: false + } + ) + end + + def valid? + LetterOpenerWeb.aws_client.list_objects_v2(bucket: LetterOpenerWeb.config.aws_bucket, prefix: @id).contents.any? + end + + private + + def attachment_url(key) + bucket = Aws::S3::Bucket.new( + name: LetterOpenerWeb.config.aws_bucket, client: LetterOpenerWeb.aws_client + ) + + obj = bucket.object(key) + obj.presigned_url(:get, expires_in: 1.week.to_i) + end + + def objects + @objects ||= {} + end + + def read_file(style) + return objects[style] if objects.key?(style) + + response = LetterOpenerWeb.aws_client.get_object(bucket: LetterOpenerWeb.config.aws_bucket, key: "#{@id}/#{style}.html") + + response.body.read.tap do |value| + objects[style] = value + end + rescue Aws::S3::Errors::NoSuchKey + '' + end + + def style_exists?(style) + return !objects[style].empty? if objects.key?(style) + + objects[style] = read_file(style) + !objects[style].empty? + end + end +end diff --git a/app/models/letter_opener_web/s3_message.rb b/app/models/letter_opener_web/s3_message.rb new file mode 100644 index 0000000..63a966e --- /dev/null +++ b/app/models/letter_opener_web/s3_message.rb @@ -0,0 +1,25 @@ +# frozen_string_literal: true + +module LetterOpenerWeb + class S3Message < LetterOpener::Message + def render + mail.attachments.each do |attachment| + filename = attachment_filename(attachment) + + LetterOpenerWeb.aws_client.put_object( + bucket: LetterOpenerWeb.config.aws_bucket, + key: "#{@location}/attachments/#{filename}", + body: attachment.body.raw_source + ) + + @attachments << [attachment.filename, "attachments/#{filename}"] + end + + LetterOpenerWeb.aws_client.put_object( + bucket: LetterOpenerWeb.config.aws_bucket, + key: "#{@location}/#{type}.html", + body: ERB.new(template).result(binding) + ) + end + end +end diff --git a/letter_opener_web.gemspec b/letter_opener_web.gemspec index 339f3e7..e5a13cb 100644 --- a/letter_opener_web.gemspec +++ b/letter_opener_web.gemspec @@ -24,6 +24,7 @@ Gem::Specification.new do |gem| gem.add_dependency 'letter_opener', '~> 1.7' gem.add_dependency 'railties', '>= 5.2' gem.add_dependency 'rexml' + gem.add_dependency 'aws-sdk-s3', '~> 1.142' gem.add_development_dependency 'rails', '~> 6.1' gem.add_development_dependency 'rspec-rails', '~> 5.0' diff --git a/lib/letter_opener_web.rb b/lib/letter_opener_web.rb index 38e6e59..b2d554e 100644 --- a/lib/letter_opener_web.rb +++ b/lib/letter_opener_web.rb @@ -3,10 +3,11 @@ require 'letter_opener_web/version' require 'letter_opener_web/engine' require 'rexml/document' +require 'aws-sdk-s3' module LetterOpenerWeb class Config - attr_accessor :letters_location + attr_accessor :letters_location, :aws_access_key_id, :aws_secret_access_key, :aws_region, :aws_bucket end def self.config @@ -21,5 +22,14 @@ def self.configure def self.reset! @config = nil + @aws_client = nil + end + + def self.aws_client + @aws_client ||= ::Aws::S3::Client.new( + access_key_id: LetterOpenerWeb.config.aws_access_key_id, + secret_access_key: LetterOpenerWeb.config.aws_secret_access_key, + region: LetterOpenerWeb.config.aws_region + ) end end diff --git a/lib/letter_opener_web/delivery_method.rb b/lib/letter_opener_web/delivery_method.rb index d8a95b3..c885f3b 100644 --- a/lib/letter_opener_web/delivery_method.rb +++ b/lib/letter_opener_web/delivery_method.rb @@ -7,8 +7,15 @@ class DeliveryMethod < LetterOpener::DeliveryMethod def deliver!(mail) original = ENV['LAUNCHY_DRY_RUN'] ENV['LAUNCHY_DRY_RUN'] = 'true' + + if LetterOpenerWeb.config.letters_location == :s3 + validate_mail!(mail) + location = "#{Time.now.to_f.to_s.tr('.', '_')}_#{Digest::SHA1.hexdigest(mail.encoded)[0..6]}" - super + messages = LetterOpenerWeb::S3Message.rendered_messages(mail, location: location, message_template: settings[:message_template]) + else + super + end rescue Launchy::CommandNotFoundError # Ignore for non-executable Launchy environment. ensure diff --git a/spec/controllers/letter_opener_web/letters_controller_spec.rb b/spec/controllers/letter_opener_web/letters_controller_spec.rb index ce0c772..1af918a 100644 --- a/spec/controllers/letter_opener_web/letters_controller_spec.rb +++ b/spec/controllers/letter_opener_web/letters_controller_spec.rb @@ -7,6 +7,151 @@ after(:each) { LetterOpenerWeb.reset! } + context 'when letters location is on AWS S3 bucket' do + before do + LetterOpenerWeb.configure do |config| + config.aws_access_key_id = 'aws_access_key_id' + config.aws_secret_access_key = 'aws_secret_access_key' + config.aws_region = 'aws_region' + config.aws_bucket = 'aws_bucket' + config.letters_location = :s3 + end + end + + describe 'GET index' do + before do + allow(LetterOpenerWeb::AwsLetter).to receive(:search) + get :index + end + + it 'searches for all letters' do + expect(LetterOpenerWeb::AwsLetter).to have_received(:search) + end + + it 'returns an HTML 200 response' do + expect(response.status).to eq(200) + expect(response.content_type).to eq('text/html; charset=utf-8') + end + end + + describe 'GET show' do + let(:id) { 'an-id' } + let(:rich_text) { 'rich text href="plain.html"' } + let(:plain_text) { 'plain text href="rich.html"' } + let(:letter) { double(:letter, rich_text: rich_text, plain_text: plain_text, id: id) } + + shared_examples 'found letter examples' do |letter_style| + before(:each) do + expect(LetterOpenerWeb::AwsLetter).to receive(:find).with(id).and_return(letter) + expect(letter).to receive(:valid?).and_return(true) + get :show, params: { id: id, style: letter_style } + end + + it 'renders an HTML 200 response' do + expect(response.status).to eq(200) + expect(response.content_type).to eq('text/html; charset=utf-8') + end + end + + context 'rich text version' do + include_examples 'found letter examples', 'rich' + + it 'renders the rich text contents' do + expect(response.body).to match(/^rich text/) + end + + it 'fixes plain text link' do + expect(response.body).not_to match(/href="plain.html"/) + expect(response.body).to match(/href="#{Regexp.escape letter_path(id: id, style: 'plain')}"/) + end + end + + context 'plain text version' do + include_examples 'found letter examples', 'plain' + + it 'renders the plain text contents' do + expect(response.body).to match(/^plain text/) + end + + it 'fixes rich text link' do + expect(response.body).not_to match(/href="rich.html"/) + expect(response.body).to match(/href="#{Regexp.escape letter_path(id: id, style: 'rich')}"/) + end + end + + context 'with wrong parameters' do + it 'should return 404 when invalid id given' do + letter = double(:letter, attachments: {}, valid?: false) + allow(LetterOpenerWeb::AwsLetter).to receive(:find).with(id).and_return(letter) + + get :show, params: { id: id, style: 'rich' } + expect(response.status).to eq(404) + end + end + end + + describe 'GET attachment' do + let(:id) { 'an-id' } + let(:attachment_path) { 'path/to/attachment' } + let(:file_name) { 'image.jpg' } + let(:letter) { double(:letter, attachments: { file_name => attachment_path }, id: id) } + + before do + allow(LetterOpenerWeb::AwsLetter).to receive(:find).with(id).and_return(letter) + allow(letter).to receive(:valid?).and_return(true) + end + + it 'redirects to the s3 file' do + allow(controller).to receive(:redirect_to) { controller.head :found } + get :attachment, params: { id: id, file: file_name.gsub(/\.\w+/, ''), format: File.extname(file_name)[1..] } + + expect(response.status).to eq(302) + expect(controller).to have_received(:redirect_to) + .with(attachment_path, allow_other_host: true) + end + + it "throws a 404 if attachment file can't be found" do + get :attachment, params: { id: id, file: 'unknown', format: 'woot' } + expect(response.status).to eq(404) + end + end + + describe 'DELETE clear' do + before { allow(LetterOpenerWeb::AwsLetter).to receive(:destroy_all) } + + it 'removes all letters' do + delete :clear + expect(LetterOpenerWeb::AwsLetter).to have_received(:destroy_all) + end + + it 'redirects back to index' do + delete :clear + expect(response).to redirect_to(letters_path) + end + end + + describe 'DELETE destroy' do + let(:id) { 'an-id' } + + it 'removes the selected letter' do + allow_any_instance_of(LetterOpenerWeb::AwsLetter).to receive(:valid?).and_return(true) + expect_any_instance_of(LetterOpenerWeb::AwsLetter).to receive(:delete) + delete :destroy, params: { id: id } + end + + it 'throws a 404 if attachment is not present on s3' do + bad_id = '../an-id' + + allow_any_instance_of(LetterOpenerWeb::AwsLetter).to receive(:valid?).and_return(false) + expect_any_instance_of(LetterOpenerWeb::AwsLetter).not_to receive(:delete) + + delete :destroy, params: { id: bad_id } + + expect(response.status).to eq(404) + end + end + end + describe 'GET index' do before do allow(LetterOpenerWeb::Letter).to receive(:search) diff --git a/spec/models/letter_opener_web/aws_letter_spec.rb b/spec/models/letter_opener_web/aws_letter_spec.rb new file mode 100644 index 0000000..7b8bb71 --- /dev/null +++ b/spec/models/letter_opener_web/aws_letter_spec.rb @@ -0,0 +1,174 @@ +# frozen_string_literal: true + +RSpec.describe LetterOpenerWeb::AwsLetter do + subject { described_class.new(id: letter_id) } + + let(:aws_access_key_id) { 'aws_access_key_id' } + let(:aws_secret_access_key) { 'aws_secret_access_key' } + let(:aws_region) { 'aws_region' } + let(:aws_bucket) { 'aws_bucket' } + let(:letter_id) { 'letter_id' } + + let(:s3_client) do + instance_double(Aws::S3::Client) + end + + before :each do + LetterOpenerWeb.configure do |config| + config.aws_access_key_id = aws_access_key_id + config.aws_secret_access_key = aws_secret_access_key + config.aws_region = aws_region + config.aws_bucket = aws_bucket + end + + expect(Aws::S3::Client) + .to receive(:new) + .with( + access_key_id: aws_access_key_id, + secret_access_key: aws_secret_access_key, + region: aws_region + ) + .and_return(s3_client) + end + + after :each do + LetterOpenerWeb.reset! + end + + describe '#delete' do + let(:s3_content) { instance_double(Aws::S3::Types::Object, key: '123') } + + it 'does not remove letter if it does not exist' do + expect(s3_client) + .to receive(:list_objects_v2) + .with(bucket: aws_bucket, prefix: letter_id) + .and_return( + instance_double(Aws::S3::Types::ListObjectsV2Output, contents: []) + ) + expect(s3_client) + .not_to receive(:delete_objects) + + subject.delete + end + + it 'removes letter if it exists' do + expect(s3_client) + .to receive(:list_objects_v2) + .with(bucket: aws_bucket, prefix: letter_id) + .and_return( + instance_double(Aws::S3::Types::ListObjectsV2Output, contents: [s3_content]) + ) + .twice + + expect(s3_client) + .to receive(:delete_objects) + .with( + bucket: aws_bucket, + delete: { + objects: [key: s3_content.key], + quiet: false + } + ) + + subject.delete + end + end + + describe '.destroy_all' do + let(:s3_content) { instance_double(Aws::S3::Types::Object, key: '123') } + + it 'removes all letters' do + expect(s3_client) + .to receive(:list_objects_v2) + .with(bucket: aws_bucket) + .and_return( + instance_double(Aws::S3::Types::ListObjectsV2Output, contents: [s3_content]) + ) + expect(s3_client) + .to receive(:delete_objects) + .with( + bucket: aws_bucket, + delete: { + objects: [key: s3_content.key], + quiet: false + } + ) + + described_class.destroy_all + end + end + + describe '.search' do + it 'returns all letters' do + expect(s3_client) + .to receive(:list_objects_v2) + .with(bucket: aws_bucket, delimiter: '/') + .and_return( + instance_double(Aws::S3::Types::ListObjectsV2Output, common_prefixes: [ + instance_double(Aws::S3::Types::CommonPrefix, prefix: '1111_1111/') + ]) + ) + + letters = described_class.search + + expect(letters.size).to eq(1) + expect(letters.first.id).to eq('1111_1111') + end + end + + describe '#valid?' do + it 'returns true if the letter exists' do + expect(s3_client) + .to receive(:list_objects_v2) + .with(bucket: aws_bucket, prefix: letter_id) + .and_return( + instance_double(Aws::S3::Types::ListObjectsV2Output, contents: [ + instance_double(Aws::S3::Types::Object) + ]) + ) + + expect(subject).to be_valid + end + + it 'returns false if the letter does not exist' do + expect(s3_client) + .to receive(:list_objects_v2) + .with(bucket: aws_bucket, prefix: letter_id) + .and_return( + instance_double(Aws::S3::Types::ListObjectsV2Output, contents: []) + ) + + expect(subject).not_to be_valid + end + end + + describe '#attachments' do + let(:s3_content) { instance_double(Aws::S3::Types::Object, key: '123') } + let(:presigned_url) { 'https://presigned-url' } + let(:s3_bucket) { instance_double(Aws::S3::Bucket, object: double) } + let(:s3_object) { instance_double(Aws::S3::Object, presigned_url: double) } + + it 'returns attachments with presigned urls' do + expect(s3_client) + .to receive(:list_objects_v2) + .with(bucket: aws_bucket, prefix: "#{letter_id}/attachments/") + .and_return( + instance_double(Aws::S3::Types::ListObjectsV2Output, contents: [s3_content]) + ) + expect(Aws::S3::Bucket) + .to receive(:new) + .with(name: aws_bucket, client: s3_client) + .and_return(s3_bucket) + expect(s3_bucket) + .to receive(:object) + .with(s3_content.key) + .and_return(s3_object) + expect(s3_object) + .to receive(:presigned_url) + .with(:get, expires_in: 1.week.to_i) + .and_return(presigned_url) + + expect(subject.attachments).to eq(s3_content.key => presigned_url) + end + end +end