Class: Mailscope::Message

Inherits:
Object
  • Object
show all
Defined in:
lib/mailscope/message.rb

Overview

A captured message. Reads from the metadata index for anything the list view needs, and lazily parses the raw .eml for bodies, headers and attachments, so rendering the sidebar never touches the full message.

Defined Under Namespace

Classes: Address, Attachment

Constant Summary collapse

METADATA_VERSION =
1
PREVIEW_LENGTH =
180

Instance Attribute Summary collapse

Class Method Summary collapse

Instance Method Summary collapse

Constructor Details

#initialize(metadata, store:) ⇒ Message

Returns a new instance of Message.



37
38
39
40
41
# File 'lib/mailscope/message.rb', line 37

def initialize(, store:)
  @metadata = 
  @id       = ['id']
  @store    = store
end

Instance Attribute Details

#idObject (readonly)

Returns the value of attribute id.



35
36
37
# File 'lib/mailscope/message.rb', line 35

def id
  @id
end

#metadataObject (readonly)

Returns the value of attribute metadata.



35
36
37
# File 'lib/mailscope/message.rb', line 35

def 
  @metadata
end

#storeObject (readonly)

Returns the value of attribute store.



35
36
37
# File 'lib/mailscope/message.rb', line 35

def store
  @store
end

Class Method Details

.address_list(mail, field) ⇒ Object



191
192
193
194
195
196
197
198
199
# File 'lib/mailscope/message.rb', line 191

def address_list(mail, field)
  field_value = mail[field]
  addrs = field_value.respond_to?(:addrs) ? field_value.addrs : nil
  return [] unless addrs

  addrs.map { |a| { 'name' => a.display_name, 'address' => a.address } }
rescue StandardError
  Array(mail.send(field)).map { |a| { 'name' => nil, 'address' => a } }
end

.attachments_for(mail) ⇒ Object



208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
# File 'lib/mailscope/message.rb', line 208

def attachments_for(mail)
  mail.attachments.each_with_index.map do |attachment, index|
    {
      'id' => format('%02d', index),
      'filename' => sanitize_filename(attachment.filename.to_s),
      'content_type' => attachment.mime_type,
      'size' => attachment.body.decoded.bytesize,
      'cid' => attachment.cid,
      'inline' => attachment.inline?
    }
  rescue StandardError
    { 'id' => format('%02d', index), 'filename' => "attachment-#{index}",
      'content_type' => 'application/octet-stream', 'size' => 0, 'cid' => nil, 'inline' => false }
  end
end

.mailer_for(mail) ⇒ Object

ActionMailer sets delivery_handler to the mailer class; the header is an escape hatch for anything delivering mail by hand.



257
258
259
260
261
262
263
264
265
# File 'lib/mailscope/message.rb', line 257

def mailer_for(mail)
  header = mail['X-Mailscope-Mailer']
  return header.to_s if header

  handler = mail.delivery_handler if mail.respond_to?(:delivery_handler)
  handler.respond_to?(:name) ? handler.name : nil
rescue StandardError
  nil
end

.metadata_for(mail, id:, size:) ⇒ Object

Builds the metadata index for a freshly delivered mail. This is the only place that knows the on-disk contract, so bumping METADATA_VERSION only needs a change here plus a reader fallback.



171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
# File 'lib/mailscope/message.rb', line 171

def (mail, id:, size:)
  {
    'version' => METADATA_VERSION,
    'id' => id,
    'sent_at' => Time.now.utc.iso8601(3),
    'subject' => mail.subject.to_s,
    'from' => address_list(mail, :from),
    'to' => address_list(mail, :to),
    'cc' => address_list(mail, :cc),
    'bcc' => address_list(mail, :bcc),
    'reply_to' => address_list(mail, :reply_to),
    'message_id' => mail.message_id,
    'mailer' => mailer_for(mail),
    'parts' => parts_for(mail),
    'attachments' => attachments_for(mail),
    'preview' => preview_for(mail),
    'size' => size
  }
end

.parts_for(mail) ⇒ Object



201
202
203
204
205
206
# File 'lib/mailscope/message.rb', line 201

def parts_for(mail)
  parts = []
  parts << 'html' if mail.html_part || mail.mime_type == 'text/html'
  parts << 'text' if mail.text_part || mail.mime_type == 'text/plain' || mail.mime_type.nil?
  parts
end

.preview_for(mail) ⇒ Object

Short plain-text snippet for the message list.



225
226
227
228
229
# File 'lib/mailscope/message.rb', line 225

def preview_for(mail)
  squeeze_preview(preview_source(mail))
rescue StandardError
  ''
end

.preview_source(mail) ⇒ Object



231
232
233
234
235
236
237
238
239
# File 'lib/mailscope/message.rb', line 231

def preview_source(mail)
  text = mail.text_part || (mail.mime_type == 'text/plain' ? mail : nil)
  return text.decoded if text

  html = mail.html_part || (mail.mime_type == 'text/html' ? mail : nil)
  return '' unless html

  html.decoded.gsub(%r{<(script|style)[^>]*>.*?</\1>}mi, ' ').gsub(/<[^>]+>/, ' ')
end

.sanitize_filename(name) ⇒ Object



246
247
248
249
250
251
252
253
# File 'lib/mailscope/message.rb', line 246

def sanitize_filename(name)
  cleaned = name.encode(Encoding::UTF_8, invalid: :replace, undef: :replace, replace: '_').strip
  # Drop any directory part first: transliterating separators afterwards
  # would leave "../../etc/passwd" as a single (harmless but ugly) name.
  cleaned = File.basename(cleaned.tr('\\', '/'))
  cleaned = cleaned.tr("\u{202E}%$|:;\t\r\n", '-').delete_prefix('.')
  cleaned.empty? ? 'attachment' : cleaned
end

.squeeze_preview(body) ⇒ Object



241
242
243
244
# File 'lib/mailscope/message.rb', line 241

def squeeze_preview(body)
  body.to_s.dup.force_encoding(Encoding::UTF_8).scrub
      .gsub(/&nbsp;?/, ' ').squeeze(" \t\n").strip[0, PREVIEW_LENGTH].to_s
end

Instance Method Details

#attachment(attachment_id) ⇒ Object



92
# File 'lib/mailscope/message.rb', line 92

def attachment(attachment_id) = attachments.find { |a| a.id == attachment_id }

#attachment_body(attachment_id) ⇒ Object



113
114
115
116
# File 'lib/mailscope/message.rb', line 113

def attachment_body(attachment_id)
  found = mail.attachments.each_with_index.find { |_, i| attachment_key(i) == attachment_id }
  found && found[0].body.decoded
end

#attachmentsObject



81
82
83
84
85
86
87
88
# File 'lib/mailscope/message.rb', line 81

def attachments
  @attachments ||= Array(['attachments']).map do |raw|
    Attachment.new(
      id: raw['id'], filename: raw['filename'], content_type: raw['content_type'],
      size: raw['size'], cid: raw['cid'], inline: raw['inline']
    )
  end
end

#bccObject



59
# File 'lib/mailscope/message.rb', line 59

def bcc      = addresses('bcc')

#ccObject



58
# File 'lib/mailscope/message.rb', line 58

def cc       = addresses('cc')

#default_partObject



79
# File 'lib/mailscope/message.rb', line 79

def default_part = html? ? 'html' : 'text'

#fromObject



56
# File 'lib/mailscope/message.rb', line 56

def from     = addresses('from')

#header_pairsObject



109
110
111
# File 'lib/mailscope/message.rb', line 109

def header_pairs
  @header_pairs ||= mail.header.fields.map { |f| [f.name.to_s, safe_field_value(f)] }
end

#html?Boolean

Returns:

  • (Boolean)


74
# File 'lib/mailscope/message.rb', line 74

def html? = parts.include?('html')

#html_bodyObject



105
# File 'lib/mailscope/message.rb', line 105

def html_body = @html_body ||= decode(html_part)

#mailObject



98
99
100
101
102
103
# File 'lib/mailscope/message.rb', line 98

def mail
  @mail ||= Mail.read_from_string(raw)
rescue StandardError => e
  Mailscope.logger.warn("[mailscope] could not parse #{id}: #{e.class}: #{e.message}")
  @mail = Mail.new
end

#mailbox_keysObject

Every mailbox this message landed in, used to build the mailbox rail.



65
# File 'lib/mailscope/message.rb', line 65

def mailbox_keys = recipients.map { |a| a.address.to_s.downcase }.reject(&:empty?).uniq

#mailerObject



70
# File 'lib/mailscope/message.rb', line 70

def mailer      = ['mailer']

#message_idObject



69
# File 'lib/mailscope/message.rb', line 69

def message_id  = ['message_id']

#partsObject



77
# File 'lib/mailscope/message.rb', line 77

def parts = Array(['parts'])

#previewObject



72
# File 'lib/mailscope/message.rb', line 72

def preview     = ['preview'].to_s

#rawObject

--- lazy data (parses the raw .eml) ------------------------------------



96
# File 'lib/mailscope/message.rb', line 96

def raw = @raw ||= store.read_raw(id)

#recipientsObject



62
# File 'lib/mailscope/message.rb', line 62

def recipients = to + cc + bcc

#reply_toObject



60
# File 'lib/mailscope/message.rb', line 60

def reply_to = addresses('reply_to')

#sent_atObject



50
51
52
53
54
# File 'lib/mailscope/message.rb', line 50

def sent_at
  @sent_at ||= Time.parse(['sent_at'])
rescue ArgumentError, TypeError
  Time.at(0)
end

#sizeObject



71
# File 'lib/mailscope/message.rb', line 71

def size        = ['size'].to_i

#subjectObject

--- indexed data (cheap: comes straight from metadata.json) ------------



45
46
47
48
# File 'lib/mailscope/message.rb', line 45

def subject
  value = ['subject'].to_s
  value.empty? ? Mailscope.translate('message.no_subject', '(no subject)') : value
end

#text?Boolean

Returns:

  • (Boolean)


75
# File 'lib/mailscope/message.rb', line 75

def text? = parts.include?('text')

#text_bodyObject



107
# File 'lib/mailscope/message.rb', line 107

def text_body = @text_body ||= decode(text_part)

#toObject



57
# File 'lib/mailscope/message.rb', line 57

def to       = addresses('to')

#to_hObject



118
119
120
121
122
123
124
# File 'lib/mailscope/message.rb', line 118

def to_h
  .merge(
    'subject' => subject,
    'sent_at' => sent_at.iso8601,
    'mailboxes' => mailbox_keys
  )
end

#to_paramObject



67
# File 'lib/mailscope/message.rb', line 67

def to_param    = id

#visible_attachmentsObject



90
# File 'lib/mailscope/message.rb', line 90

def visible_attachments = attachments.reject(&:inline?)