5 ## a Message is what's threaded.
7 ## it is also where the parsing for quotes and signatures is done, but
8 ## that should be moved out to a separate class at some point (because
9 ## i would like, for example, to be able to add in a ruby-talk
10 ## specific module that would detect and link to /ruby-talk:\d+/
11 ## sequences in the text of an email. (how sweet would that be?)
13 ## this class catches all source exceptions. if the underlying source
14 ## throws an error, it is caught and handled.
18 RE_PATTERN = /^((re|re[\[\(]\d[\]\)]):\s*)+/i
20 ## some utility methods
22 def normalize_subj s; s.gsub(RE_PATTERN, ""); end
23 def subj_is_reply? s; s =~ RE_PATTERN; end
24 def reify_subj s; subj_is_reply?(s) ? s : "Re: " + s; end
27 QUOTE_PATTERN = /^\s{0,4}[>|\}]/
28 BLOCK_QUOTE_PATTERN = /^-----\s*Original Message\s*----+$/
29 QUOTE_START_PATTERN = /\w.*:$/
30 SIG_PATTERN = /(^-- ?$)|(^\s*----------+\s*$)|(^\s*_________+\s*$)|(^\s*--~--~-)|(^\s*--\+\+\*\*==)/
32 MAX_SIG_DISTANCE = 15 # lines from the end
34 DEFAULT_SENDER = "(missing sender)"
36 attr_reader :id, :date, :from, :subj, :refs, :replytos, :to, :source,
37 :cc, :bcc, :labels, :attachments, :list_address, :recipient_email, :replyto,
38 :source_info, :list_subscribe, :list_unsubscribe
40 bool_reader :dirty, :source_marked_read, :snippet_contains_encrypted_content
42 ## if you specify a :header, will use values from that. otherwise,
43 ## will try and load the header from the source.
45 @source = opts[:source] or raise ArgumentError, "source can't be nil"
46 @source_info = opts[:source_info] or raise ArgumentError, "source_info can't be nil"
47 @snippet = opts[:snippet]
48 @snippet_contains_encrypted_content = false
49 @have_snippet = !(opts[:snippet].nil? || opts[:snippet].empty?)
50 @labels = (opts[:labels] || []).to_set_of_symbols
56 ## we need to initialize this. see comments in parse_header as to
60 #parse_header(opts[:header] || @source.load_header(@source_info))
63 def parse_header header
64 ## forcibly decode these headers from and to the current encoding,
65 ## which serves to strip out characters that aren't displayable
66 ## (and which would otherwise be screwing up the display)
67 %w(from to subject cc bcc).each do |f|
68 header[f] = Iconv.easy_decode($encoding, $encoding, header[f]) if header[f]
71 @id = if header["message-id"]
72 mid = header["message-id"] =~ /<(.+?)>/ ? $1 : header["message-id"]
73 sanitize_message_id mid
75 id = "sup-faked-" + Digest::MD5.hexdigest(raw_header)
77 #Redwood::log "faking non-existent message-id for message from #{from}: #{id}"
81 @from = Person.from_address(if header["from"]
84 name = "Sup Auto-generated Fake Sender <sup@fake.sender.example.com>"
85 #Redwood::log "faking non-existent sender for message #@id: #{name}"
89 @date = case(date = header["date"])
95 rescue ArgumentError => e
96 #Redwood::log "faking mangled date header for #{@id} (orig #{header['date'].inspect} gave error: #{e.message})"
100 #Redwood::log "faking non-existent date header for #{@id}"
104 @subj = header.member?("subject") ? header["subject"].gsub(/\s+/, " ").gsub(/\s+$/, "") : DEFAULT_SUBJECT
105 @to = Person.from_address_list header["to"]
106 @cc = Person.from_address_list header["cc"]
107 @bcc = Person.from_address_list header["bcc"]
109 ## before loading our full header from the source, we can actually
110 ## have some extra refs set by the UI. (this happens when the user
111 ## joins threads manually). so we will merge the current refs values
113 refs = (header["references"] || "").scan(/<(.+?)>/).map { |x| sanitize_message_id x.first }
114 @refs = (@refs + refs).uniq
115 @replytos = (header["in-reply-to"] || "").scan(/<(.+?)>/).map { |x| sanitize_message_id x.first }
117 @replyto = Person.from_address header["reply-to"]
119 if header["list-post"]
120 @list_address = Person.from_address header["list-post"].gsub(/^<mailto:|>$/, "")
125 @recipient_email = header["envelope-to"] || header["x-original-to"] || header["delivered-to"]
126 @source_marked_read = header["status"] == "RO"
127 @list_subscribe = header["list-subscribe"]
128 @list_unsubscribe = header["list-unsubscribe"]
137 @dirty = true if @refs.delete ref
140 def snippet; @snippet || (chunks && @snippet); end
141 def is_list_message?; !@list_address.nil?; end
142 def is_draft?; @source.is_a? DraftLoader; end
144 raise "not a draft" unless is_draft?
145 @source.fn_for_offset @source_info
148 ## sanitize message ids by removing spaces and non-ascii characters.
149 ## also, truncate to 255 characters. all these steps are necessary
150 ## to make ferret happy. of course, we probably fuck up a couple
151 ## valid message ids as well. as long as we're consistent, this
152 ## should be fine, though.
154 ## also, mostly the message ids that are changed by this belong to
157 ## an alternative would be to SHA1 or MD5 all message ids on a regular basis.
159 def sanitize_message_id mid; mid.gsub(/(\s|[^\000-\177])+/, "")[0..254] end
163 index.sync_message self
168 def has_label? t; @labels.member? t; end
170 return if @labels.member? t
171 @labels = (@labels + [t]).to_set_of_symbols
175 return unless @labels.member? t
185 @labels = l.to_set_of_symbols
194 ## this is called when the message body needs to actually be loaded.
195 def load_from_source!
197 if @source.respond_to?(:has_errors?) && @source.has_errors?
198 [Chunk::Text.new(error_message(@source.error.message).split("\n"))]
201 ## we need to re-read the header because it contains information
202 ## that we don't store in the index. actually i think it's just
203 ## the mailing list address (if any), so this is kinda overkill.
204 ## i could just store that in the index, but i think there might
205 ## be other things like that in the future, and i'd rather not
207 ## actually, it's also the differentiation between to/cc/bcc,
208 ## so i will keep this.
209 parse_header @source.load_header(@source_info)
210 message_to_chunks @source.load_message(@source_info)
211 rescue SourceError, SocketError => e
212 Redwood::log "problem getting messages from #{@source}: #{e.message}"
213 ## we need force_to_top here otherwise this window will cover
214 ## up the error message one
216 Redwood::report_broken_sources :force_to_top => true
217 [Chunk::Text.new(error_message(e.message).split("\n"))]
222 def error_message msg
226 ***********************************************************************
227 An error occurred while loading this message. It is possible that
228 the source has changed, or (in the case of remote sources) is down.
229 You can check the log for errors, though hopefully an error window
230 should have popped up at some point.
232 The message location was:
233 #@source##@source_info
234 ***********************************************************************
236 The error message was:
241 ## wrap any source methods that might throw sourceerrors
242 def with_source_errors_handled
245 rescue SourceError => e
246 Redwood::log "problem getting messages from #{@source}: #{e.message}"
248 Redwood::report_broken_sources :force_to_top => true
249 error_message e.message
254 with_source_errors_handled { @source.raw_header @source_info }
258 with_source_errors_handled { @source.raw_message @source_info }
261 ## much faster than raw_message
262 def each_raw_message_line &b
263 with_source_errors_handled { @source.each_raw_message_line(@source_info, &b) }
266 ## returns all the content from a message that will be indexed
267 def indexable_content
270 from && from.indexable_content,
271 to.map { |p| p.indexable_content },
272 cc.map { |p| p.indexable_content },
273 bcc.map { |p| p.indexable_content },
274 chunks.select { |c| c.is_a? Chunk::Text }.map { |c| c.lines },
275 Message.normalize_subj(subj),
276 ].flatten.compact.join " "
279 def quotable_body_lines
280 chunks.find_all { |c| c.quotable? }.map { |c| c.lines }.flatten
283 def quotable_header_lines
284 ["From: #{@from.full_address}"] +
285 (@to.empty? ? [] : ["To: " + @to.map { |p| p.full_address }.join(", ")]) +
286 (@cc.empty? ? [] : ["Cc: " + @cc.map { |p| p.full_address }.join(", ")]) +
287 (@bcc.empty? ? [] : ["Bcc: " + @bcc.map { |p| p.full_address }.join(", ")]) +
288 ["Date: #{@date.rfc822}",
294 ## here's where we handle decoding mime attachments. unfortunately
295 ## but unsurprisingly, the world of mime attachments is a bit of a
296 ## mess. as an empiricist, i'm basing the following behavior on
297 ## observed mail rather than on interpretations of rfcs, so probably
298 ## this will have to be tweaked.
300 ## the general behavior i want is: ignore content-disposition, at
301 ## least in so far as it suggests something being inline vs being an
302 ## attachment. (because really, that should be the recipient's
303 ## decision to make.) if a mime part is text/plain, OR if the user
304 ## decoding hook converts it, then decode it and display it
305 ## inline. for these decoded attachments, if it has associated
306 ## filename, then make it collapsable and individually saveable;
307 ## otherwise, treat it as regular body text.
309 ## everything else is just an attachment and is not displayed
312 ## so, in contrast to mutt, the user is not exposed to the workings
313 ## of the gruesome slaughterhouse and sausage factory that is a
314 ## mime-encoded message, but need only see the delicious end
317 def multipart_signed_to_chunks m
319 Redwood::log "warning: multipart/signed with #{m.body.size} parts (expecting 2)"
323 payload, signature = m.body
324 if signature.multipart?
325 Redwood::log "warning: multipart/signed with payload multipart #{payload.multipart?} and signature multipart #{signature.multipart?}"
329 ## this probably will never happen
330 if payload.header.content_type == "application/pgp-signature"
331 Redwood::log "warning: multipart/signed with payload content type #{payload.header.content_type}"
335 if signature.header.content_type != "application/pgp-signature"
336 ## unknown signature type; just ignore.
337 #Redwood::log "warning: multipart/signed with signature content type #{signature.header.content_type}"
341 [CryptoManager.verify(payload, signature), message_to_chunks(payload)].flatten.compact
344 def multipart_encrypted_to_chunks m
346 Redwood::log "warning: multipart/encrypted with #{m.body.size} parts (expecting 2)"
350 control, payload = m.body
351 if control.multipart?
352 Redwood::log "warning: multipart/encrypted with control multipart #{control.multipart?} and payload multipart #{payload.multipart?}"
356 if payload.header.content_type != "application/octet-stream"
357 Redwood::log "warning: multipart/encrypted with payload content type #{payload.header.content_type}"
361 if control.header.content_type != "application/pgp-encrypted"
362 Redwood::log "warning: multipart/encrypted with control content type #{signature.header.content_type}"
366 decryptedm, sig, notice = CryptoManager.decrypt payload
367 children = message_to_chunks(decryptedm, true) if decryptedm
368 [notice, sig, children].flatten.compact
371 ## takes a RMail::Message, breaks it into Chunk:: classes.
372 def message_to_chunks m, encrypted=false, sibling_types=[]
375 case m.header.content_type
376 when "multipart/signed"
377 multipart_signed_to_chunks m
378 when "multipart/encrypted"
379 multipart_encrypted_to_chunks m
383 sibling_types = m.body.map { |p| p.header.content_type }
384 chunks = m.body.map { |p| message_to_chunks p, encrypted, sibling_types }.flatten.compact
388 elsif m.header.content_type == "message/rfc822"
389 payload = RMail::Parser.read(m.body)
390 from = payload.header.from.first
391 from_person = from ? Person.from_address(from.format) : nil
392 [Chunk::EnclosedMessage.new(from_person, payload.to_s)] +
393 message_to_chunks(payload, encrypted)
396 ## first, paw through the headers looking for a filename
397 if m.header["Content-Disposition"] && m.header["Content-Disposition"] =~ /filename="?(.*?[^\\])("|;|$)/
399 elsif m.header["Content-Type"] && m.header["Content-Type"] =~ /name="?(.*?[^\\])("|;|$)/
402 ## haven't found one, but it's a non-text message. fake
405 ## TODO: make this less lame.
406 elsif m.header["Content-Type"] && m.header["Content-Type"] !~ /^text\/plain/
408 case m.header["Content-Type"]
409 when /text\/html/: "html"
410 when /image\/(.*)/: $1
413 ["sup-attachment-#{Time.now.to_i}-#{rand 10000}", extension].join(".")
416 ## if there's a filename, we'll treat it as an attachment.
418 # add this to the attachments list if its not a generated html
419 # attachment (should we allow images with generated names?).
420 # Lowercase the filename because searches are easier that way
421 @attachments.push filename.downcase unless filename =~ /^sup-attachment-/
422 add_label :attachment unless filename =~ /^sup-attachment-/
423 content_type = m.header.content_type || "application/unknown" # sometimes RubyMail gives us nil
424 [Chunk::Attachment.new(content_type, filename, m, sibling_types)]
426 ## otherwise, it's body text
428 ## if there's no charset, use the current encoding as the charset.
429 ## this ensures that the body is normalized to avoid non-displayable
431 body = Iconv.easy_decode($encoding, m.charset || $encoding, m.decode) if m.body
432 text_to_chunks((body || "").normalize_whitespace.split("\n"), encrypted)
437 ## parse the lines of text into chunk objects. the heuristics here
438 ## need tweaking in some nice manner. TODO: move these heuristics
439 ## into the classes themselves.
440 def text_to_chunks lines, encrypted
441 state = :text # one of :text, :quote, or :sig
445 lines.each_with_index do |line, i|
446 nextline = lines[(i + 1) ... lines.length].find { |l| l !~ /^\s*$/ } # skip blank lines
452 if line =~ QUOTE_PATTERN || (line =~ QUOTE_START_PATTERN && nextline =~ QUOTE_PATTERN)
454 elsif line =~ SIG_PATTERN && (lines.length - i) < MAX_SIG_DISTANCE
456 elsif line =~ BLOCK_QUOTE_PATTERN
457 newstate = :block_quote
461 chunks << Chunk::Text.new(chunk_lines) unless chunk_lines.empty?
471 if line =~ QUOTE_PATTERN || (line =~ /^\s*$/ && nextline =~ QUOTE_PATTERN)
473 elsif line =~ SIG_PATTERN && (lines.length - i) < MAX_SIG_DISTANCE
480 if chunk_lines.empty?
483 chunks << Chunk::Quote.new(chunk_lines)
489 when :block_quote, :sig
493 if !@have_snippet && state == :text && (@snippet.nil? || @snippet.length < SNIPPET_LEN) && line !~ /[=\*#_-]{3,}/ && line !~ /^\s*$/
495 @snippet += " " unless @snippet.empty?
496 @snippet += line.gsub(/^\s+/, "").gsub(/[\r\n]/, "").gsub(/\s+/, " ")
497 @snippet = @snippet[0 ... SNIPPET_LEN].chomp
498 @dirty = true unless encrypted && $config[:discard_snippets_from_encrypted_messages]
499 @snippet_contains_encrypted_content = true if encrypted
505 when :quote, :block_quote
506 chunks << Chunk::Quote.new(chunk_lines) unless chunk_lines.empty?
508 chunks << Chunk::Text.new(chunk_lines) unless chunk_lines.empty?
510 chunks << Chunk::Signature.new(chunk_lines) unless chunk_lines.empty?