letter_opener_web-redis 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 210afa6ebf7862a38c9b5fd79e6d53f5331dcc91bafed3d386ef24e8a2b4125c
4
+ data.tar.gz: 5d0b6bd37962133afd3efc1d42208c035df1fa41702ffbfae84f32a0db21b921
5
+ SHA512:
6
+ metadata.gz: d1381185ae7a6dc594a0e3969c54ffab48386013c1ab5a2c4e0122fb876aeafd8011ffbc60e72d2240e86068affa2dd0f4b812e4bb4fbcec5ef7f07a35a42c60
7
+ data.tar.gz: cfd73ec500699cf764e2ac07881e1dc9b3def67717f62bfe0877db625e101a837eef4274352fe137fc8a3fc9a6c2d2e66d87363e7720fc9b36e7718eb3c9b408
data/LICENSE ADDED
@@ -0,0 +1,22 @@
1
+ Copyright (c) 2025 Letter Opener Web Redis Contributors
2
+
3
+ MIT License
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining
6
+ a copy of this software and associated documentation files (the
7
+ "Software"), to deal in the Software without restriction, including
8
+ without limitation the rights to use, copy, modify, merge, publish,
9
+ distribute, sublicense, and/or sell copies of the Software, and to
10
+ permit persons to whom the Software is furnished to do so, subject to
11
+ the following conditions:
12
+
13
+ The above copyright notice and this permission notice shall be
14
+ included in all copies or substantial portions of the Software.
15
+
16
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
17
+ EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
18
+ MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
19
+ NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
20
+ LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
21
+ OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
22
+ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,68 @@
1
+ # LetterOpenerWeb::Redis
2
+
3
+ Redis storage backend for [letter_opener_web](https://github.com/fgrehm/letter_opener_web).
4
+
5
+ This gem allows you to store email letters and attachments in Redis instead of the local filesystem, enabling multi-instance deployments and shared email viewing across application servers.
6
+
7
+ ## Installation
8
+
9
+ Add these lines to your application's Gemfile:
10
+
11
+ ```ruby
12
+ gem 'letter_opener_web'
13
+ gem 'letter_opener_web-redis', require: 'letter_opener_web_redis'
14
+ ```
15
+
16
+ ## Configuration
17
+
18
+ ### 1. Configure Redis Connection
19
+
20
+ Set up your Redis connection in your Rails configuration file (e.g., `config/environments/development.rb`):
21
+
22
+ ```ruby
23
+ # Require the gem first
24
+ require 'letter_opener_web_redis'
25
+
26
+ LetterOpenerWeb.configure do |config|
27
+ # Use the Redis storage backend
28
+ config.letter_class = LetterOpenerWeb::RedisLetter
29
+
30
+ # Redis Configuration
31
+ config.redis_url = ENV.fetch('REDIS_URL', 'redis://localhost:6379/0')
32
+ config.redis_namespace = 'letter_opener' # optional, defaults to 'letter_opener'
33
+ end
34
+ ```
35
+
36
+ ### 2. Configure ActionMailer
37
+
38
+ Update your ActionMailer configuration to use the Redis delivery method:
39
+
40
+ ```ruby
41
+ config.action_mailer.delivery_method = :letter_opener_web_redis
42
+ ```
43
+
44
+ ## Storage Structure
45
+
46
+ Emails are stored in Redis with the following key structure:
47
+
48
+ ```
49
+ {namespace}:letters:ids # Set of all letter IDs
50
+ {namespace}:letter:{id}:metadata # JSON metadata (id, sent_at, attachments)
51
+ {namespace}:letter:{id}:plain # Plain text HTML
52
+ {namespace}:letter:{id}:rich # Rich HTML
53
+ {namespace}:letter:{id}:attachment:{filename} # Base64-encoded attachment content
54
+ ```
55
+
56
+ Where `{namespace}` defaults to `letter_opener` but can be customized via configuration.
57
+
58
+ ## Memory Considerations
59
+
60
+ Since Redis stores all data in memory, be mindful of:
61
+
62
+ - **Email Size**: Large attachments will consume significant memory
63
+ - **Retention**: Consider implementing TTL (time-to-live) for old emails
64
+ - **Limits**: Monitor Redis memory usage and set appropriate limits
65
+
66
+ ## License
67
+
68
+ The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'letter_opener'
4
+
5
+ module LetterOpenerWeb
6
+ class RedisDeliveryMethod < LetterOpener::DeliveryMethod
7
+ def deliver!(mail)
8
+ location = create_location
9
+ message = RedisMessage.new(mail, location: location)
10
+ message.render
11
+ end
12
+
13
+ private
14
+
15
+ def create_location
16
+ location = File.join(settings[:location], "#{Time.now.to_i}_#{SecureRandom.hex(16)}")
17
+ FileUtils.mkdir_p(location)
18
+ location
19
+ end
20
+ end
21
+ end
@@ -0,0 +1,186 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'redis'
4
+ require 'json'
5
+
6
+ module LetterOpenerWeb
7
+ # Redis-based Letter implementation
8
+ #
9
+ # Stores and retrieves letters from Redis instead of the local filesystem.
10
+ # Letters are stored as Redis hashes with the following structure:
11
+ # - letter:{id}:plain - Plain text HTML
12
+ # - letter:{id}:rich - Rich HTML
13
+ # - letter:{id}:metadata - JSON with id, sent_at, attachments
14
+ # - letter:{id}:attachment:{filename} - Attachment content
15
+ # - letters:ids - Set of all letter IDs
16
+ class RedisLetter
17
+ include LetterOpenerWeb::LetterInterface
18
+
19
+ def self.redis
20
+ @redis ||= Redis.new(
21
+ url: LetterOpenerWeb.config.redis_url,
22
+ timeout: 5
23
+ )
24
+ end
25
+
26
+ def self.key_prefix
27
+ namespace = LetterOpenerWeb.config.redis_namespace
28
+ namespace ? "#{namespace}:" : ''
29
+ end
30
+
31
+ def self.search
32
+ letter_ids = redis.smembers("#{key_prefix}letters:ids")
33
+
34
+ letters = letter_ids.map do |id|
35
+ metadata = redis.get("#{key_prefix}letter:#{id}:metadata")
36
+ next unless metadata
37
+
38
+ data = JSON.parse(metadata)
39
+ new(id: data['id'], sent_at: Time.parse(data['sent_at']))
40
+ end.compact
41
+
42
+ letters.sort_by(&:sent_at).reverse
43
+ end
44
+
45
+ def self.find(id)
46
+ new(id: id)
47
+ end
48
+
49
+ def self.destroy_all
50
+ # Get all letter IDs
51
+ letter_ids = redis.smembers("#{key_prefix}letters:ids")
52
+
53
+ # Delete all keys associated with each letter
54
+ letter_ids.each do |id|
55
+ keys = redis.keys("#{key_prefix}letter:#{id}:*")
56
+ redis.del(*keys) if keys.any?
57
+ end
58
+
59
+ # Clear the IDs set
60
+ redis.del("#{key_prefix}letters:ids")
61
+ end
62
+
63
+ def initialize(params)
64
+ @id = params.fetch(:id)
65
+ @sent_at = params[:sent_at] || Time.now
66
+ end
67
+
68
+ def headers
69
+ html = read_file('rich') if file_exists?('rich')
70
+ html ||= read_file('plain')
71
+
72
+ return 'UNABLE TO PARSE HEADERS' if html.blank?
73
+
74
+ match_data = html.match(%r{<body>\s*<div[^>]+id="container">\s*<div[^>]+id="message_headers">\s*(<dl>.+</dl>)}m)
75
+ return remove_attachments_link(match_data[1]).html_safe if match_data && match_data[1].present?
76
+
77
+ 'UNABLE TO PARSE HEADERS'
78
+ end
79
+
80
+ def plain_text
81
+ @plain_text ||= adjust_link_targets(read_file('plain'))
82
+ end
83
+
84
+ def rich_text
85
+ @rich_text ||= adjust_link_targets(read_file('rich'))
86
+ end
87
+
88
+ def attachments
89
+ @attachments ||= begin
90
+ metadata = redis.get("#{key_prefix}letter:#{id}:metadata")
91
+ return {} unless metadata
92
+
93
+ data = JSON.parse(metadata)
94
+ data['attachments'] || {}
95
+ end
96
+ end
97
+
98
+ def delete
99
+ return unless valid?
100
+
101
+ keys = redis.keys("#{key_prefix}letter:#{id}:*")
102
+ redis.del(*keys) if keys.any?
103
+ redis.srem("#{key_prefix}letters:ids", id)
104
+ end
105
+
106
+ def valid?
107
+ file_exists?('rich') || file_exists?('plain')
108
+ end
109
+
110
+ def to_param
111
+ id
112
+ end
113
+
114
+ def default_style
115
+ file_exists?('rich') ? 'rich' : 'plain'
116
+ end
117
+
118
+ def send_attachment(controller, filename)
119
+ return unless attachments.key?(filename)
120
+
121
+ encoded = redis.get("#{key_prefix}letter:#{id}:attachment:#{filename}")
122
+ return unless encoded
123
+
124
+ content = Base64.strict_decode64(encoded)
125
+ controller.send_data(content, filename: filename, type: 'application/octet-stream', disposition: 'inline')
126
+ rescue ArgumentError
127
+ nil
128
+ end
129
+
130
+ private
131
+
132
+ def redis
133
+ self.class.redis
134
+ end
135
+
136
+ def key_prefix
137
+ self.class.key_prefix
138
+ end
139
+
140
+ def read_file(style)
141
+ redis.get("#{key_prefix}letter:#{id}:#{style}") || ''
142
+ end
143
+
144
+ def file_exists?(style)
145
+ redis.exists("#{key_prefix}letter:#{id}:#{style}") == 1
146
+ end
147
+
148
+ def remove_attachments_link(headers)
149
+ xml = REXML::Document.new(headers)
150
+ label_element = xml.root.elements.find { |e| e.get_text&.value&.match?(/attachments:/i) }
151
+
152
+ if label_element
153
+ xml.root.delete_element(label_element.next_element)
154
+ xml.root.delete_element(label_element)
155
+ end
156
+
157
+ xml.to_s
158
+ end
159
+
160
+ def adjust_link_targets(contents)
161
+ return contents if contents.blank?
162
+
163
+ contents.scan(%r{<a\s[^>]+>(?:.|\s)*?</a>}).each do |link|
164
+ fixed_link = fix_link_html(link)
165
+ xml = REXML::Document.new(fixed_link).root
166
+ next if xml.attributes['href'] =~ /(plain|rich).html/
167
+
168
+ xml.attributes['target'] = '_blank'
169
+ xml.add_text('') unless xml.text
170
+ contents.gsub!(link, xml.to_s)
171
+ end
172
+ contents
173
+ end
174
+
175
+ def fix_link_html(link_html)
176
+ link_html.dup.tap do |fixed_link|
177
+ fixed_link.gsub!('<br>', '<br/>')
178
+ fixed_link.scan(/<img(?:[^>]+?)>/).each do |img|
179
+ fixed_img = img.dup
180
+ fixed_img.gsub!(/>$/, '/>') unless img =~ %r{/>$}
181
+ fixed_link.gsub!(img, fixed_img)
182
+ end
183
+ end
184
+ end
185
+ end
186
+ end
@@ -0,0 +1,101 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'letter_opener'
4
+ require 'json'
5
+ require 'base64'
6
+
7
+ module LetterOpenerWeb
8
+ # Extends LetterOpener::Message to store email content in Redis
9
+ #
10
+ # This class intercepts the message delivery and stores the generated
11
+ # HTML files and attachments in Redis instead of keeping them on local disk.
12
+ class RedisMessage < LetterOpener::Message
13
+ def render
14
+ # Call parent to generate the HTML files locally first
15
+ super
16
+
17
+ # Store in Redis
18
+ store_in_redis
19
+
20
+ # Clean up local files after storing
21
+ cleanup_local_files
22
+ end
23
+
24
+ private
25
+
26
+ attr_reader :location
27
+
28
+ def store_in_redis
29
+ store_html_files
30
+ store_attachments
31
+ store_metadata
32
+ register_letter_id
33
+ end
34
+
35
+ def store_html_files
36
+ %w[rich plain].each do |style|
37
+ file_path = "#{location}/#{style}.html"
38
+ next unless File.exist?(file_path)
39
+
40
+ content = File.read(file_path)
41
+ redis.set("#{key_prefix}letter:#{letter_id}:#{style}", content)
42
+ end
43
+ end
44
+
45
+ def store_attachments
46
+ attachments_dir = "#{location}/attachments"
47
+ return unless Dir.exist?(attachments_dir)
48
+
49
+ attachment_data = {}
50
+
51
+ Dir.glob("#{attachments_dir}/*").each do |file_path|
52
+ filename = File.basename(file_path)
53
+
54
+ # Store attachment content as base64
55
+ content = File.binread(file_path)
56
+ encoded = Base64.strict_encode64(content)
57
+
58
+ redis.set("#{key_prefix}letter:#{letter_id}:attachment:#{filename}", encoded)
59
+
60
+ # Track attachment in metadata
61
+ attachment_data[filename] = filename
62
+ end
63
+
64
+ @attachment_data = attachment_data
65
+ end
66
+
67
+ def store_metadata
68
+ metadata = {
69
+ id: letter_id,
70
+ sent_at: Time.now.iso8601,
71
+ attachments: @attachment_data || {}
72
+ }
73
+
74
+ redis.set("#{key_prefix}letter:#{letter_id}:metadata", metadata.to_json)
75
+ end
76
+
77
+ def register_letter_id
78
+ redis.sadd("#{key_prefix}letters:ids", letter_id)
79
+ end
80
+
81
+ def cleanup_local_files
82
+ FileUtils.rm_rf(location)
83
+ end
84
+
85
+ def letter_id
86
+ @letter_id ||= File.basename(location)
87
+ end
88
+
89
+ def redis
90
+ @redis ||= Redis.new(
91
+ url: LetterOpenerWeb.config.redis_url,
92
+ timeout: 5
93
+ )
94
+ end
95
+
96
+ def key_prefix
97
+ namespace = LetterOpenerWeb.config.redis_namespace
98
+ namespace ? "#{namespace}:" : ''
99
+ end
100
+ end
101
+ end
@@ -0,0 +1,31 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'letter_opener_web'
4
+ require 'letter_opener_web/redis_letter'
5
+ require 'letter_opener_web/redis_message'
6
+ require 'letter_opener_web/redis_delivery_method'
7
+
8
+ # Add Redis-specific configuration to LetterOpenerWeb::Config immediately
9
+ LetterOpenerWeb::Config.class_eval do
10
+ attr_accessor :redis_url, :redis_namespace
11
+
12
+ # Set default Redis namespace
13
+ def redis_namespace
14
+ @redis_namespace ||= 'letter_opener'
15
+ end
16
+ end
17
+
18
+ module LetterOpenerWeb
19
+ module RedisStorage
20
+ class Railtie < Rails::Railtie
21
+ initializer 'letter_opener_web_redis.configure' do
22
+ # Register custom delivery method that uses RedisDeliveryMethod
23
+ ActionMailer::Base.add_delivery_method(
24
+ :letter_opener_web_redis,
25
+ LetterOpenerWeb::RedisDeliveryMethod,
26
+ location: Rails.root.join('tmp', 'letter_opener')
27
+ )
28
+ end
29
+ end
30
+ end
31
+ end
metadata ADDED
@@ -0,0 +1,106 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: letter_opener_web-redis
3
+ version: !ruby/object:Gem::Version
4
+ version: 1.0.0
5
+ platform: ruby
6
+ authors:
7
+ - Adnan Ali
8
+ autorequire:
9
+ bindir: bin
10
+ cert_chain: []
11
+ date: 2025-10-26 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: letter_opener_web
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - "~>"
18
+ - !ruby/object:Gem::Version
19
+ version: '3.0'
20
+ type: :runtime
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - "~>"
25
+ - !ruby/object:Gem::Version
26
+ version: '3.0'
27
+ - !ruby/object:Gem::Dependency
28
+ name: redis
29
+ requirement: !ruby/object:Gem::Requirement
30
+ requirements:
31
+ - - ">="
32
+ - !ruby/object:Gem::Version
33
+ version: '4.0'
34
+ type: :runtime
35
+ prerelease: false
36
+ version_requirements: !ruby/object:Gem::Requirement
37
+ requirements:
38
+ - - ">="
39
+ - !ruby/object:Gem::Version
40
+ version: '4.0'
41
+ - !ruby/object:Gem::Dependency
42
+ name: rspec
43
+ requirement: !ruby/object:Gem::Requirement
44
+ requirements:
45
+ - - "~>"
46
+ - !ruby/object:Gem::Version
47
+ version: '3.0'
48
+ type: :development
49
+ prerelease: false
50
+ version_requirements: !ruby/object:Gem::Requirement
51
+ requirements:
52
+ - - "~>"
53
+ - !ruby/object:Gem::Version
54
+ version: '3.0'
55
+ - !ruby/object:Gem::Dependency
56
+ name: rubocop
57
+ requirement: !ruby/object:Gem::Requirement
58
+ requirements:
59
+ - - "~>"
60
+ - !ruby/object:Gem::Version
61
+ version: '1.0'
62
+ type: :development
63
+ prerelease: false
64
+ version_requirements: !ruby/object:Gem::Requirement
65
+ requirements:
66
+ - - "~>"
67
+ - !ruby/object:Gem::Version
68
+ version: '1.0'
69
+ description: Provides Redis-based storage for letter_opener_web, enabling multi-instance
70
+ deployments and shared email viewing across application servers.
71
+ email:
72
+ - adnan.ali@gmail.com
73
+ executables: []
74
+ extensions: []
75
+ extra_rdoc_files: []
76
+ files:
77
+ - LICENSE
78
+ - README.md
79
+ - lib/letter_opener_web/redis_delivery_method.rb
80
+ - lib/letter_opener_web/redis_letter.rb
81
+ - lib/letter_opener_web/redis_message.rb
82
+ - lib/letter_opener_web_redis.rb
83
+ homepage: https://github.com/WizaCo/letter_opener_web-redis
84
+ licenses:
85
+ - MIT
86
+ metadata: {}
87
+ post_install_message:
88
+ rdoc_options: []
89
+ require_paths:
90
+ - lib
91
+ required_ruby_version: !ruby/object:Gem::Requirement
92
+ requirements:
93
+ - - ">="
94
+ - !ruby/object:Gem::Version
95
+ version: 3.1.0
96
+ required_rubygems_version: !ruby/object:Gem::Requirement
97
+ requirements:
98
+ - - ">="
99
+ - !ruby/object:Gem::Version
100
+ version: '0'
101
+ requirements: []
102
+ rubygems_version: 3.3.26
103
+ signing_key:
104
+ specification_version: 4
105
+ summary: Redis storage backend for letter_opener_web
106
+ test_files: []