mailscope 0.1.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.
Files changed (39) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +68 -0
  3. data/LICENSE.txt +27 -0
  4. data/README.md +210 -0
  5. data/app/assets/mailscope/INTER-LICENSE.txt +92 -0
  6. data/app/assets/mailscope/favicon.svg +6 -0
  7. data/app/assets/mailscope/inter-latin-ext.woff2 +0 -0
  8. data/app/assets/mailscope/inter-latin.woff2 +0 -0
  9. data/app/assets/mailscope/mailscope.css +594 -0
  10. data/app/assets/mailscope/mailscope.js +559 -0
  11. data/app/controllers/mailscope/application_controller.rb +30 -0
  12. data/app/controllers/mailscope/assets_controller.rb +24 -0
  13. data/app/controllers/mailscope/messages_controller.rb +139 -0
  14. data/app/helpers/mailscope/application_helper.rb +124 -0
  15. data/app/views/layouts/mailscope/application.html.erb +30 -0
  16. data/app/views/mailscope/messages/_blank_pane.html.erb +9 -0
  17. data/app/views/mailscope/messages/_list.html.erb +34 -0
  18. data/app/views/mailscope/messages/_list_item.html.erb +39 -0
  19. data/app/views/mailscope/messages/_pane.html.erb +151 -0
  20. data/app/views/mailscope/messages/_rail.html.erb +33 -0
  21. data/app/views/mailscope/messages/_shortcuts.html.erb +21 -0
  22. data/app/views/mailscope/messages/index.html.erb +66 -0
  23. data/app/views/mailscope/shared/_icons.html.erb +58 -0
  24. data/config/locales/mailscope.en.yml +105 -0
  25. data/config/locales/mailscope.pt-BR.yml +105 -0
  26. data/config/routes.rb +24 -0
  27. data/lib/mailscope/body_renderer.rb +142 -0
  28. data/lib/mailscope/configuration.rb +65 -0
  29. data/lib/mailscope/delivery_method.rb +45 -0
  30. data/lib/mailscope/engine.rb +21 -0
  31. data/lib/mailscope/mailbox.rb +39 -0
  32. data/lib/mailscope/message.rb +268 -0
  33. data/lib/mailscope/query.rb +54 -0
  34. data/lib/mailscope/storage/base.rb +35 -0
  35. data/lib/mailscope/storage/filesystem.rb +214 -0
  36. data/lib/mailscope/storage.rb +23 -0
  37. data/lib/mailscope/version.rb +5 -0
  38. data/lib/mailscope.rb +60 -0
  39. metadata +128 -0
@@ -0,0 +1,268 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+ require 'mail'
5
+ require 'time'
6
+
7
+ module Mailscope
8
+ # A captured message. Reads from the metadata index for anything the list
9
+ # view needs, and lazily parses the raw `.eml` for bodies, headers and
10
+ # attachments, so rendering the sidebar never touches the full message.
11
+ class Message
12
+ METADATA_VERSION = 1
13
+ PREVIEW_LENGTH = 180
14
+
15
+ # rubocop:disable Lint/StructNewOverride -- `size` mirrors the metadata key
16
+ # and these structs are plain value objects, never enumerated.
17
+ Attachment = Struct.new(:id, :filename, :content_type, :size, :cid, :inline, keyword_init: true) do
18
+ def inline? = !!inline
19
+
20
+ def image? = content_type.to_s.start_with?('image/')
21
+
22
+ def to_h = { id:, filename:, content_type:, size:, cid:, inline: }
23
+ end
24
+
25
+ # rubocop:enable Lint/StructNewOverride
26
+
27
+ Address = Struct.new(:name, :address, keyword_init: true) do
28
+ def display = name.to_s.empty? ? address.to_s : "#{name} <#{address}>"
29
+
30
+ def short = name.to_s.empty? ? address.to_s : name.to_s
31
+
32
+ def to_h = { name:, address: }
33
+ end
34
+
35
+ attr_reader :id, :metadata, :store
36
+
37
+ def initialize(metadata, store:)
38
+ @metadata = metadata
39
+ @id = metadata['id']
40
+ @store = store
41
+ end
42
+
43
+ # --- indexed data (cheap: comes straight from metadata.json) ------------
44
+
45
+ def subject
46
+ value = metadata['subject'].to_s
47
+ value.empty? ? Mailscope.translate('message.no_subject', '(no subject)') : value
48
+ end
49
+
50
+ def sent_at
51
+ @sent_at ||= Time.parse(metadata['sent_at'])
52
+ rescue ArgumentError, TypeError
53
+ Time.at(0)
54
+ end
55
+
56
+ def from = addresses('from')
57
+ def to = addresses('to')
58
+ def cc = addresses('cc')
59
+ def bcc = addresses('bcc')
60
+ def reply_to = addresses('reply_to')
61
+
62
+ def recipients = to + cc + bcc
63
+
64
+ # Every mailbox this message landed in, used to build the mailbox rail.
65
+ def mailbox_keys = recipients.map { |a| a.address.to_s.downcase }.reject(&:empty?).uniq
66
+
67
+ def to_param = id
68
+
69
+ def message_id = metadata['message_id']
70
+ def mailer = metadata['mailer']
71
+ def size = metadata['size'].to_i
72
+ def preview = metadata['preview'].to_s
73
+
74
+ def html? = parts.include?('html')
75
+ def text? = parts.include?('text')
76
+
77
+ def parts = Array(metadata['parts'])
78
+
79
+ def default_part = html? ? 'html' : 'text'
80
+
81
+ def attachments
82
+ @attachments ||= Array(metadata['attachments']).map do |raw|
83
+ Attachment.new(
84
+ id: raw['id'], filename: raw['filename'], content_type: raw['content_type'],
85
+ size: raw['size'], cid: raw['cid'], inline: raw['inline']
86
+ )
87
+ end
88
+ end
89
+
90
+ def visible_attachments = attachments.reject(&:inline?)
91
+
92
+ def attachment(attachment_id) = attachments.find { |a| a.id == attachment_id }
93
+
94
+ # --- lazy data (parses the raw .eml) ------------------------------------
95
+
96
+ def raw = @raw ||= store.read_raw(id)
97
+
98
+ def mail
99
+ @mail ||= Mail.read_from_string(raw)
100
+ rescue StandardError => e
101
+ Mailscope.logger.warn("[mailscope] could not parse #{id}: #{e.class}: #{e.message}")
102
+ @mail = Mail.new
103
+ end
104
+
105
+ def html_body = @html_body ||= decode(html_part)
106
+
107
+ def text_body = @text_body ||= decode(text_part)
108
+
109
+ def header_pairs
110
+ @header_pairs ||= mail.header.fields.map { |f| [f.name.to_s, safe_field_value(f)] }
111
+ end
112
+
113
+ def attachment_body(attachment_id)
114
+ found = mail.attachments.each_with_index.find { |_, i| attachment_key(i) == attachment_id }
115
+ found && found[0].body.decoded
116
+ end
117
+
118
+ def to_h
119
+ metadata.merge(
120
+ 'subject' => subject,
121
+ 'sent_at' => sent_at.iso8601,
122
+ 'mailboxes' => mailbox_keys
123
+ )
124
+ end
125
+
126
+ private
127
+
128
+ def addresses(key)
129
+ Array(metadata[key]).map { |a| Address.new(name: a['name'], address: a['address']) }
130
+ end
131
+
132
+ def html_part
133
+ mail.html_part || (mail.mime_type == 'text/html' ? mail : nil)
134
+ end
135
+
136
+ def text_part
137
+ mail.text_part || (mail.mime_type == 'text/plain' || mail.mime_type.nil? ? mail : nil)
138
+ end
139
+
140
+ def decode(part)
141
+ return nil unless part
142
+
143
+ part.decoded.dup.force_encoding(part_charset(part)).scrub
144
+ rescue StandardError => e
145
+ Mailscope.logger.warn("[mailscope] could not decode #{id}: #{e.class}: #{e.message}")
146
+ nil
147
+ end
148
+
149
+ def part_charset(part)
150
+ Encoding.find(part.charset.to_s)
151
+ rescue ArgumentError
152
+ Encoding::UTF_8
153
+ end
154
+
155
+ def safe_field_value(field)
156
+ field.decoded.to_s
157
+ rescue StandardError
158
+ field.value.to_s
159
+ end
160
+
161
+ def attachment_key(index)
162
+ format('%02d', index)
163
+ end
164
+
165
+ # --- writing -------------------------------------------------------------
166
+
167
+ class << self
168
+ # Builds the metadata index for a freshly delivered mail. This is the
169
+ # only place that knows the on-disk contract, so bumping
170
+ # METADATA_VERSION only needs a change here plus a reader fallback.
171
+ def metadata_for(mail, id:, size:)
172
+ {
173
+ 'version' => METADATA_VERSION,
174
+ 'id' => id,
175
+ 'sent_at' => Time.now.utc.iso8601(3),
176
+ 'subject' => mail.subject.to_s,
177
+ 'from' => address_list(mail, :from),
178
+ 'to' => address_list(mail, :to),
179
+ 'cc' => address_list(mail, :cc),
180
+ 'bcc' => address_list(mail, :bcc),
181
+ 'reply_to' => address_list(mail, :reply_to),
182
+ 'message_id' => mail.message_id,
183
+ 'mailer' => mailer_for(mail),
184
+ 'parts' => parts_for(mail),
185
+ 'attachments' => attachments_for(mail),
186
+ 'preview' => preview_for(mail),
187
+ 'size' => size
188
+ }
189
+ end
190
+
191
+ def address_list(mail, field)
192
+ field_value = mail[field]
193
+ addrs = field_value.respond_to?(:addrs) ? field_value.addrs : nil
194
+ return [] unless addrs
195
+
196
+ addrs.map { |a| { 'name' => a.display_name, 'address' => a.address } }
197
+ rescue StandardError
198
+ Array(mail.send(field)).map { |a| { 'name' => nil, 'address' => a } }
199
+ end
200
+
201
+ def parts_for(mail)
202
+ parts = []
203
+ parts << 'html' if mail.html_part || mail.mime_type == 'text/html'
204
+ parts << 'text' if mail.text_part || mail.mime_type == 'text/plain' || mail.mime_type.nil?
205
+ parts
206
+ end
207
+
208
+ def attachments_for(mail)
209
+ mail.attachments.each_with_index.map do |attachment, index|
210
+ {
211
+ 'id' => format('%02d', index),
212
+ 'filename' => sanitize_filename(attachment.filename.to_s),
213
+ 'content_type' => attachment.mime_type,
214
+ 'size' => attachment.body.decoded.bytesize,
215
+ 'cid' => attachment.cid,
216
+ 'inline' => attachment.inline?
217
+ }
218
+ rescue StandardError
219
+ { 'id' => format('%02d', index), 'filename' => "attachment-#{index}",
220
+ 'content_type' => 'application/octet-stream', 'size' => 0, 'cid' => nil, 'inline' => false }
221
+ end
222
+ end
223
+
224
+ # Short plain-text snippet for the message list.
225
+ def preview_for(mail)
226
+ squeeze_preview(preview_source(mail))
227
+ rescue StandardError
228
+ ''
229
+ end
230
+
231
+ def preview_source(mail)
232
+ text = mail.text_part || (mail.mime_type == 'text/plain' ? mail : nil)
233
+ return text.decoded if text
234
+
235
+ html = mail.html_part || (mail.mime_type == 'text/html' ? mail : nil)
236
+ return '' unless html
237
+
238
+ html.decoded.gsub(%r{<(script|style)[^>]*>.*?</\1>}mi, ' ').gsub(/<[^>]+>/, ' ')
239
+ end
240
+
241
+ def squeeze_preview(body)
242
+ body.to_s.dup.force_encoding(Encoding::UTF_8).scrub
243
+ .gsub(/&nbsp;?/, ' ').squeeze(" \t\n").strip[0, PREVIEW_LENGTH].to_s
244
+ end
245
+
246
+ def sanitize_filename(name)
247
+ cleaned = name.encode(Encoding::UTF_8, invalid: :replace, undef: :replace, replace: '_').strip
248
+ # Drop any directory part first: transliterating separators afterwards
249
+ # would leave "../../etc/passwd" as a single (harmless but ugly) name.
250
+ cleaned = File.basename(cleaned.tr('\\', '/'))
251
+ cleaned = cleaned.tr("\u{202E}%$|:;\t\r\n", '-').delete_prefix('.')
252
+ cleaned.empty? ? 'attachment' : cleaned
253
+ end
254
+
255
+ # ActionMailer sets `delivery_handler` to the mailer class; the header is
256
+ # an escape hatch for anything delivering mail by hand.
257
+ def mailer_for(mail)
258
+ header = mail['X-Mailscope-Mailer']
259
+ return header.to_s if header
260
+
261
+ handler = mail.delivery_handler if mail.respond_to?(:delivery_handler)
262
+ handler.respond_to?(:name) ? handler.name : nil
263
+ rescue StandardError
264
+ nil
265
+ end
266
+ end
267
+ end
268
+ end
@@ -0,0 +1,54 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Mailscope
4
+ # Filters the in-memory message list. Small enough to stay honest about what
5
+ # it does: no index, just a scan over metadata that is already loaded.
6
+ class Query
7
+ attr_reader :messages, :mailbox, :term, :only
8
+
9
+ def initialize(messages, mailbox: nil, term: nil, only: nil)
10
+ @messages = messages
11
+ @mailbox = mailbox.to_s.downcase.presence
12
+ @term = term.to_s.strip.presence
13
+ @only = only.to_s.presence
14
+ end
15
+
16
+ def results
17
+ @results ||= messages.select { |m| matches_mailbox?(m) && matches_only?(m) && matches_term?(m) }
18
+ end
19
+
20
+ def filtered? = !(mailbox.nil? && term.nil? && only.nil?)
21
+
22
+ private
23
+
24
+ def matches_mailbox?(message)
25
+ mailbox.nil? || message.mailbox_keys.include?(mailbox)
26
+ end
27
+
28
+ def matches_only?(message)
29
+ case only
30
+ when 'attachments' then message.visible_attachments.any?
31
+ when 'html' then message.html?
32
+ when 'text' then message.text? && !message.html?
33
+ else true
34
+ end
35
+ end
36
+
37
+ def matches_term?(message)
38
+ return true if term.nil?
39
+
40
+ needle = term.downcase
41
+ haystack(message).any? { |value| value.include?(needle) }
42
+ end
43
+
44
+ def haystack(message)
45
+ @haystacks ||= {}
46
+ @haystacks[message.id] ||= [
47
+ message.subject,
48
+ message.preview,
49
+ message.mailer.to_s,
50
+ *(message.from + message.recipients).map(&:display)
51
+ ].map { |v| v.to_s.downcase }
52
+ end
53
+ end
54
+ end
@@ -0,0 +1,35 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Mailscope
4
+ module Storage
5
+ # The contract every adapter has to satisfy. Kept deliberately small so a
6
+ # database- or Redis-backed adapter stays a weekend of work.
7
+ class Base
8
+ # @return [Array<Mailscope::Message>] newest first
9
+ def all = raise(NotImplementedError)
10
+
11
+ # @return [Mailscope::Message, nil]
12
+ def find(id) = raise(NotImplementedError)
13
+
14
+ # Persists a delivered Mail::Message. @return [String] the new id
15
+ def store(mail) = raise(NotImplementedError)
16
+
17
+ # @return [String] raw RFC822 source
18
+ def read_raw(id) = raise(NotImplementedError)
19
+
20
+ # @return [String, nil] decoded attachment bytes
21
+ def read_attachment(id, attachment_id) = raise(NotImplementedError)
22
+
23
+ def delete(id) = raise(NotImplementedError)
24
+
25
+ def clear = raise(NotImplementedError)
26
+
27
+ # Applies the retention policy (max_letters:, max_age:) and returns the
28
+ # number of messages removed. Adapters without retention keep this no-op.
29
+ def prune(**) = 0
30
+
31
+ # Cheap change token so the browser can poll without transferring the list.
32
+ def revision = all.first&.id.to_s
33
+ end
34
+ end
35
+ end
@@ -0,0 +1,214 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'fileutils'
4
+ require 'json'
5
+ require 'securerandom'
6
+
7
+ module Mailscope
8
+ module Storage
9
+ # One directory per message:
10
+ #
11
+ # <location>/<id>/metadata.json index used by the list view
12
+ # <location>/<id>/mail.eml pristine RFC822 source
13
+ #
14
+ # Ids are lexicographically sortable by time, so listing never stats files.
15
+ class Filesystem < Base
16
+ ID_FORMAT = /\A[0-9]{8}-[0-9]{6}-[0-9]{6}-[0-9a-f]{8}\z/
17
+ # letter_opener / letter_opener_web laid out directories like
18
+ # "1358825621_ba83a22"; we still accept those for read-only migration.
19
+ LEGACY_ID_FORMAT = /\A[0-9]+_[0-9]+_?[0-9a-f]+\z|\A[0-9]+_[0-9a-f]+\z/
20
+
21
+ attr_reader :location
22
+
23
+ def initialize(location:)
24
+ super()
25
+ @location = Pathname(location.to_s)
26
+ end
27
+
28
+ def all
29
+ ids.map { |id| build(id) }.compact
30
+ end
31
+
32
+ def find(id)
33
+ id = validate_id!(id)
34
+ build(id)
35
+ end
36
+
37
+ def store(mail)
38
+ id = generate_id
39
+ dir = location.join(id)
40
+ FileUtils.mkdir_p(dir)
41
+
42
+ raw = mail.encoded
43
+ File.binwrite(dir.join('mail.eml'), raw)
44
+
45
+ metadata = Message.metadata_for(mail, id: id, size: raw.bytesize)
46
+ write_json(dir.join('metadata.json'), metadata)
47
+ id
48
+ end
49
+
50
+ def read_raw(id)
51
+ path = path_for(id, 'mail.eml')
52
+ return File.binread(path).force_encoding(Encoding::UTF_8).scrub if path&.file?
53
+
54
+ legacy_raw(id)
55
+ end
56
+
57
+ def read_attachment(id, attachment_id)
58
+ find(id)&.attachment_body(attachment_id)
59
+ end
60
+
61
+ # @return [String, nil] the id that was removed, or nil if it was gone
62
+ def delete(id)
63
+ dir = dir_for(id)
64
+ return nil unless dir&.directory?
65
+
66
+ FileUtils.rm_rf(dir.to_s)
67
+ id
68
+ end
69
+
70
+ def clear
71
+ return 0 unless location.directory?
72
+
73
+ removed = ids.size
74
+ # Empty the directory instead of removing it, so a watcher holding the
75
+ # path (or a mounted volume) keeps working.
76
+ Dir.children(location).each { |child| FileUtils.rm_rf(location.join(child).to_s) }
77
+ removed
78
+ end
79
+
80
+ def prune(max_letters: nil, max_age: nil)
81
+ removed = max_age ? prune_by_age(max_age) : 0
82
+ removed + (max_letters ? prune_by_count(max_letters) : 0)
83
+ end
84
+
85
+ def revision
86
+ "#{ids.size}:#{ids.first}"
87
+ end
88
+
89
+ private
90
+
91
+ def prune_by_age(max_age)
92
+ cutoff = Time.now - max_age.to_i
93
+ expired = ids.select do |id|
94
+ message = build(id)
95
+ message && message.sent_at < cutoff
96
+ end
97
+ expired.count { |id| delete(id) }
98
+ end
99
+
100
+ def prune_by_count(max_letters)
101
+ ids.drop(max_letters).count { |id| delete(id) }
102
+ end
103
+
104
+ def ids
105
+ return [] unless location.directory?
106
+
107
+ Dir.children(location)
108
+ .select { |name| valid_id?(name) && location.join(name).directory? }
109
+ .sort
110
+ .reverse
111
+ end
112
+
113
+ def build(id)
114
+ dir = location.join(id)
115
+ metadata_path = dir.join('metadata.json')
116
+
117
+ metadata = if metadata_path.file?
118
+ parse_json(metadata_path)
119
+ elsif dir.directory?
120
+ legacy_metadata(id, dir)
121
+ end
122
+ return nil unless metadata
123
+
124
+ Message.new(metadata, store: self)
125
+ end
126
+
127
+ def generate_id
128
+ loop do
129
+ now = Time.now.utc
130
+ id = format('%<stamp>s-%<usec>06d-%<nonce>s',
131
+ stamp: now.strftime('%Y%m%d-%H%M%S'), usec: now.usec, nonce: SecureRandom.hex(4))
132
+ return id unless location.join(id).exist?
133
+ end
134
+ end
135
+
136
+ def valid_id?(name)
137
+ ID_FORMAT.match?(name) || LEGACY_ID_FORMAT.match?(name)
138
+ end
139
+
140
+ def validate_id!(id)
141
+ id = id.to_s
142
+ raise InvalidLetterId, "invalid message id: #{id.inspect}" unless valid_id?(id)
143
+
144
+ id
145
+ end
146
+
147
+ def dir_for(id)
148
+ dir = location.join(validate_id!(id)).cleanpath
149
+ # Defence in depth: the id regexp already rules out traversal.
150
+ return nil unless dir.to_s.start_with?(location.cleanpath.to_s + File::SEPARATOR)
151
+
152
+ dir
153
+ end
154
+
155
+ def path_for(id, filename)
156
+ dir_for(id)&.join(filename)
157
+ end
158
+
159
+ def parse_json(path)
160
+ JSON.parse(File.read(path))
161
+ rescue JSON::ParserError, SystemCallError => e
162
+ Mailscope.logger.warn("[mailscope] unreadable metadata at #{path}: #{e.message}")
163
+ nil
164
+ end
165
+
166
+ def write_json(path, data)
167
+ tmp = "#{path}.#{Process.pid}.tmp"
168
+ File.write(tmp, JSON.pretty_generate(data))
169
+ File.rename(tmp, path)
170
+ end
171
+
172
+ # --- letter_opener compatibility ----------------------------------------
173
+
174
+ def legacy_raw(id)
175
+ dir = dir_for(id)
176
+ return nil unless dir&.directory?
177
+
178
+ html = %w[rich.html plain.html].map { |f| dir.join(f) }.find(&:file?)
179
+ return nil unless html
180
+
181
+ # Wrap the rendered HTML in a minimal envelope so the rest of the app
182
+ # can treat legacy directories like any other message.
183
+ body = File.read(html)
184
+ Mail.new do
185
+ content_type 'text/html; charset=UTF-8'
186
+ body body
187
+ end.encoded
188
+ end
189
+
190
+ def legacy_metadata(id, dir)
191
+ html = %w[rich.html plain.html].map { |f| dir.join(f) }.find(&:file?)
192
+ return nil unless html
193
+
194
+ parts = []
195
+ parts << 'html' if dir.join('rich.html').file?
196
+ parts << 'text' if dir.join('plain.html').file?
197
+
198
+ {
199
+ 'version' => 0, 'id' => id, 'sent_at' => File.mtime(dir).utc.iso8601(3),
200
+ 'subject' => legacy_subject(html), 'from' => [], 'to' => [], 'cc' => [], 'bcc' => [],
201
+ 'reply_to' => [], 'message_id' => nil, 'mailer' => nil, 'parts' => parts,
202
+ 'attachments' => [], 'preview' => Mailscope.translate('storage.imported', 'Message imported from letter_opener'),
203
+ 'size' => File.size(html), 'legacy' => true
204
+ }
205
+ end
206
+
207
+ def legacy_subject(path)
208
+ File.read(path)[%r{<title>(.*?)</title>}m, 1].to_s.strip
209
+ rescue StandardError
210
+ ''
211
+ end
212
+ end
213
+ end
214
+ end
@@ -0,0 +1,23 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'mailscope/storage/base'
4
+ require 'mailscope/storage/filesystem'
5
+
6
+ module Mailscope
7
+ module Storage
8
+ ADAPTERS = { filesystem: 'Mailscope::Storage::Filesystem' }.freeze
9
+
10
+ def self.register(name, class_name)
11
+ ADAPTERS.merge!(name.to_sym => class_name)
12
+ end
13
+
14
+ def self.build(adapter, **options)
15
+ return adapter unless adapter.is_a?(Symbol) || adapter.is_a?(String)
16
+
17
+ class_name = ADAPTERS.fetch(adapter.to_sym) do
18
+ raise ArgumentError, "unknown Mailscope storage adapter: #{adapter.inspect}"
19
+ end
20
+ Object.const_get(class_name).new(**options)
21
+ end
22
+ end
23
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Mailscope
4
+ VERSION = '0.1.0'
5
+ end
data/lib/mailscope.rb ADDED
@@ -0,0 +1,60 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'pathname'
4
+ require 'tmpdir'
5
+
6
+ require 'mailscope/version'
7
+ require 'mailscope/configuration'
8
+ require 'mailscope/message'
9
+ require 'mailscope/storage'
10
+ require 'mailscope/mailbox'
11
+ require 'mailscope/query'
12
+ require 'mailscope/body_renderer'
13
+
14
+ module Mailscope
15
+ class Error < StandardError; end
16
+ class InvalidLetterId < Error; end
17
+
18
+ class << self
19
+ def config
20
+ @config ||= Configuration.new
21
+ end
22
+
23
+ def configure
24
+ yield config if block_given?
25
+ @storage = nil
26
+ config
27
+ end
28
+
29
+ def reset!
30
+ @config = nil
31
+ @storage = nil
32
+ end
33
+
34
+ # The resolved storage adapter instance.
35
+ def storage
36
+ @storage ||= Storage.build(config.storage, location: config.location)
37
+ end
38
+
39
+ def deliveries
40
+ storage.all
41
+ end
42
+
43
+ # Translates through the host app's I18n when available; the fallback keeps
44
+ # the library usable outside Rails.
45
+ def translate(key, fallback, **options)
46
+ return fallback unless defined?(::I18n)
47
+
48
+ ::I18n.t("mailscope.#{key}", default: fallback, **options)
49
+ end
50
+
51
+ def logger
52
+ return Rails.logger if defined?(Rails) && Rails.logger
53
+
54
+ @logger ||= Logger.new($stderr)
55
+ end
56
+ end
57
+ end
58
+
59
+ # The engine reaches back into Mailscope.config, so it loads after the module body.
60
+ require 'mailscope/engine' if defined?(Rails::Engine)