Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add support for storing letters and attachments on AWS s3 #129

Open
wants to merge 3 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
55 changes: 52 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
13 changes: 10 additions & 3 deletions app/controllers/letter_opener_web/letters_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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

Expand All @@ -50,13 +51,19 @@ 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

def routes
LetterOpenerWeb.railtie_routes_url_helpers
end

private

def letter_model
LetterOpenerWeb.config.letters_location == :s3 ? LetterOpenerWeb::AwsLetter : LetterOpenerWeb::Letter
end
end
end
89 changes: 89 additions & 0 deletions app/models/letter_opener_web/aws_letter.rb
Original file line number Diff line number Diff line change
@@ -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
25 changes: 25 additions & 0 deletions app/models/letter_opener_web/s3_message.rb
Original file line number Diff line number Diff line change
@@ -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
1 change: 1 addition & 0 deletions letter_opener_web.gemspec
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down
12 changes: 11 additions & 1 deletion lib/letter_opener_web.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
9 changes: 8 additions & 1 deletion lib/letter_opener_web/delivery_method.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading