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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +68 -0
- data/LICENSE.txt +27 -0
- data/README.md +210 -0
- data/app/assets/mailscope/INTER-LICENSE.txt +92 -0
- data/app/assets/mailscope/favicon.svg +6 -0
- data/app/assets/mailscope/inter-latin-ext.woff2 +0 -0
- data/app/assets/mailscope/inter-latin.woff2 +0 -0
- data/app/assets/mailscope/mailscope.css +594 -0
- data/app/assets/mailscope/mailscope.js +559 -0
- data/app/controllers/mailscope/application_controller.rb +30 -0
- data/app/controllers/mailscope/assets_controller.rb +24 -0
- data/app/controllers/mailscope/messages_controller.rb +139 -0
- data/app/helpers/mailscope/application_helper.rb +124 -0
- data/app/views/layouts/mailscope/application.html.erb +30 -0
- data/app/views/mailscope/messages/_blank_pane.html.erb +9 -0
- data/app/views/mailscope/messages/_list.html.erb +34 -0
- data/app/views/mailscope/messages/_list_item.html.erb +39 -0
- data/app/views/mailscope/messages/_pane.html.erb +151 -0
- data/app/views/mailscope/messages/_rail.html.erb +33 -0
- data/app/views/mailscope/messages/_shortcuts.html.erb +21 -0
- data/app/views/mailscope/messages/index.html.erb +66 -0
- data/app/views/mailscope/shared/_icons.html.erb +58 -0
- data/config/locales/mailscope.en.yml +105 -0
- data/config/locales/mailscope.pt-BR.yml +105 -0
- data/config/routes.rb +24 -0
- data/lib/mailscope/body_renderer.rb +142 -0
- data/lib/mailscope/configuration.rb +65 -0
- data/lib/mailscope/delivery_method.rb +45 -0
- data/lib/mailscope/engine.rb +21 -0
- data/lib/mailscope/mailbox.rb +39 -0
- data/lib/mailscope/message.rb +268 -0
- data/lib/mailscope/query.rb +54 -0
- data/lib/mailscope/storage/base.rb +35 -0
- data/lib/mailscope/storage/filesystem.rb +214 -0
- data/lib/mailscope/storage.rb +23 -0
- data/lib/mailscope/version.rb +5 -0
- data/lib/mailscope.rb +60 -0
- 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(/ ?/, ' ').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
|
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)
|