6 class MessageFormatError < StandardError; end
8 ## a Message is what's threaded.
10 ## it is also where the parsing for quotes and signatures is done, but
11 ## that should be moved out to a separate class at some point (because
12 ## i would like, for example, to be able to add in a ruby-talk
13 ## specific module that would detect and link to /ruby-talk:\d+/
14 ## sequences in the text of an email. (how sweet would that be?)
16 ## this class cathces all source exceptions. if the underlying source throws
17 ## an error, it is caught and handled.
21 RE_PATTERN = /^((re|re[\[\(]\d[\]\)]):\s*)+/i
23 ## some utility methods
25 def normalize_subj s; s.gsub(RE_PATTERN, ""); end
26 def subj_is_reply? s; s =~ RE_PATTERN; end
27 def reify_subj s; subj_is_reply?(s) ? s : "Re: " + s; end
30 QUOTE_PATTERN = /^\s{0,4}[>|\}]/
31 BLOCK_QUOTE_PATTERN = /^-----\s*Original Message\s*----+$/
32 QUOTE_START_PATTERN = /(^\s*Excerpts from)|(^\s*In message )|(^\s*In article )|(^\s*Quoting )|((wrote|writes|said|says)\s*:\s*$)/
33 SIG_PATTERN = /(^-- ?$)|(^\s*----------+\s*$)|(^\s*_________+\s*$)|(^\s*--~--~-)|(^\s*--\+\+\*\*==)/
35 MAX_SIG_DISTANCE = 15 # lines from the end
37 DEFAULT_SENDER = "(missing sender)"
39 attr_reader :id, :date, :from, :subj, :refs, :replytos, :to, :source,
40 :cc, :bcc, :labels, :list_address, :recipient_email, :replyto,
41 :source_info, :list_subscribe, :list_unsubscribe
43 bool_reader :dirty, :source_marked_read, :snippet_contains_encrypted_content
45 ## if you specify a :header, will use values from that. otherwise,
46 ## will try and load the header from the source.
48 @source = opts[:source] or raise ArgumentError, "source can't be nil"
49 @source_info = opts[:source_info] or raise ArgumentError, "source_info can't be nil"
50 @snippet = opts[:snippet]
51 @snippet_contains_encrypted_content = false
52 @have_snippet = !(opts[:snippet].nil? || opts[:snippet].empty?)
53 @labels = [] + (opts[:labels] || [])
58 parse_header(opts[:header] || @source.load_header(@source_info))
61 def parse_header header
62 header.each { |k, v| header[k.downcase] = v }
65 if header["message-id"]
66 sanitize_message_id header["message-id"]
68 returning("sup-faked-" + Digest::MD5.hexdigest(raw_header)) do |id|
69 Redwood::log "faking message-id for message from #@from: #{id}"
75 PersonManager.person_for header["from"]
77 name = "Sup Auto-generated Fake Sender <sup@fake.sender.example.com>"
78 Redwood::log "faking from for message #@id: #{name}"
79 PersonManager.person_for name
90 rescue ArgumentError => e
91 raise MessageFormatError, "unparsable date #{header['date']}: #{e.message}"
94 Redwood::log "faking date header for #{@id}"
98 @subj = header.member?("subject") ? header["subject"].gsub(/\s+/, " ").gsub(/\s+$/, "") : DEFAULT_SUBJECT
99 @to = PersonManager.people_for header["to"]
100 @cc = PersonManager.people_for header["cc"]
101 @bcc = PersonManager.people_for header["bcc"]
102 @refs = (header["references"] || "").scan(/<(.+?)>/).map { |x| sanitize_message_id x.first }
103 @replytos = (header["in-reply-to"] || "").scan(/<(.+?)>/).map { |x| sanitize_message_id x.first }
105 @replyto = PersonManager.person_for header["reply-to"]
107 if header["list-post"]
108 @list_address = PersonManager.person_for header["list-post"].gsub(/^<mailto:|>$/, "")
113 @recipient_email = header["envelope-to"] || header["x-original-to"] || header["delivered-to"]
114 @source_marked_read = header["status"] == "RO"
115 @list_subscribe = header["list-subscribe"]
116 @list_unsubscribe = header["list-unsubscribe"]
118 private :parse_header
120 def snippet; @snippet || (chunks && @snippet); end
121 def is_list_message?; !@list_address.nil?; end
122 def is_draft?; @source.is_a? DraftLoader; end
124 raise "not a draft" unless is_draft?
125 @source.fn_for_offset @source_info
128 def sanitize_message_id mid; mid.gsub(/\s/, "") end
131 index.sync_message self if @dirty
135 def has_label? t; @labels.member? t; end
137 return if @labels.member? t
142 return unless @labels.member? t
161 ## this is called when the message body needs to actually be loaded.
162 def load_from_source!
164 if @source.has_errors?
165 [Chunk::Text.new(error_message(@source.error.message).split("\n"))]
168 ## we need to re-read the header because it contains information
169 ## that we don't store in the index. actually i think it's just
170 ## the mailing list address (if any), so this is kinda overkill.
171 ## i could just store that in the index, but i think there might
172 ## be other things like that in the future, and i'd rather not
174 ## actually, it's also the differentiation between to/cc/bcc,
175 ## so i will keep this.
176 parse_header @source.load_header(@source_info)
177 message_to_chunks @source.load_message(@source_info)
178 rescue SourceError, SocketError, MessageFormatError => e
179 Redwood::log "problem getting messages from #{@source}: #{e.message}"
180 ## we need force_to_top here otherwise this window will cover
181 ## up the error message one
183 Redwood::report_broken_sources :force_to_top => true
184 [Chunk::Text.new(error_message(e.message).split("\n"))]
189 def error_message msg
193 ***********************************************************************
194 An error occurred while loading this message. It is possible that
195 the source has changed, or (in the case of remote sources) is down.
196 You can check the log for errors, though hopefully an error window
197 should have popped up at some point.
199 The message location was:
200 #@source##@source_info
201 ***********************************************************************
203 The error message was:
208 ## wrap any source methods that might throw sourceerrors
209 def with_source_errors_handled
212 rescue SourceError => e
213 Redwood::log "problem getting messages from #{@source}: #{e.message}"
215 Redwood::report_broken_sources :force_to_top => true
216 error_message e.message
221 with_source_errors_handled { @source.raw_header @source_info }
225 with_source_errors_handled { @source.raw_message @source_info }
228 ## much faster than raw_message
229 def each_raw_message_line &b
230 with_source_errors_handled { @source.each_raw_message_line(@source_info, &b) }
236 from && "#{from.name} #{from.email}",
237 to.map { |p| "#{p.name} #{p.email}" },
238 cc.map { |p| "#{p.name} #{p.email}" },
239 bcc.map { |p| "#{p.name} #{p.email}" },
240 chunks.select { |c| c.is_a? Chunk::Text }.map { |c| c.lines },
241 Message.normalize_subj(subj),
242 ].flatten.compact.join " "
245 def quotable_body_lines
246 chunks.find_all { |c| c.quotable? }.map { |c| c.lines }.flatten
249 def quotable_header_lines
250 ["From: #{@from.full_address}"] +
251 (@to.empty? ? [] : ["To: " + @to.map { |p| p.full_address }.join(", ")]) +
252 (@cc.empty? ? [] : ["Cc: " + @cc.map { |p| p.full_address }.join(", ")]) +
253 (@bcc.empty? ? [] : ["Bcc: " + @bcc.map { |p| p.full_address }.join(", ")]) +
254 ["Date: #{@date.rfc822}",
260 ## here's where we handle decoding mime attachments. unfortunately
261 ## but unsurprisingly, the world of mime attachments is a bit of a
262 ## mess. as an empiricist, i'm basing the following behavior on
263 ## observed mail rather than on interpretations of rfcs, so probably
264 ## this will have to be tweaked.
266 ## the general behavior i want is: ignore content-disposition, at
267 ## least in so far as it suggests something being inline vs being an
268 ## attachment. (because really, that should be the recipient's
269 ## decision to make.) if a mime part is text/plain, OR if the user
270 ## decoding hook converts it, then decode it and display it
271 ## inline. for these decoded attachments, if it has associated
272 ## filename, then make it collapsable and individually saveable;
273 ## otherwise, treat it as regular body text.
275 ## everything else is just an attachment and is not displayed
278 ## so, in contrast to mutt, the user is not exposed to the workings
279 ## of the gruesome slaughterhouse and sausage factory that is a
280 ## mime-encoded message, but need only see the delicious end
283 def multipart_signed_to_chunks m
285 Redwood::log "warning: multipart/signed with #{m.body.size} parts (expecting 2)"
289 payload, signature = m.body
290 if signature.multipart?
291 Redwood::log "warning: multipart/signed with payload multipart #{payload.multipart?} and signature multipart #{signature.multipart?}"
295 ## this probably will never happen
296 if payload.header.content_type == "application/pgp-signature"
297 Redwood::log "warning: multipart/signed with payload content type #{payload.header.content_type}"
301 if signature.header.content_type != "application/pgp-signature"
302 ## unknown signature type; just ignore.
303 #Redwood::log "warning: multipart/signed with signature content type #{signature.header.content_type}"
307 [CryptoManager.verify(payload, signature), message_to_chunks(payload)].flatten.compact
310 def multipart_encrypted_to_chunks m
312 Redwood::log "warning: multipart/encrypted with #{m.body.size} parts (expecting 2)"
316 control, payload = m.body
317 if control.multipart?
318 Redwood::log "warning: multipart/encrypted with control multipart #{control.multipart?} and payload multipart #{payload.multipart?}"
322 if payload.header.content_type != "application/octet-stream"
323 Redwood::log "warning: multipart/encrypted with payload content type #{payload.header.content_type}"
327 if control.header.content_type != "application/pgp-encrypted"
328 Redwood::log "warning: multipart/encrypted with control content type #{signature.header.content_type}"
332 decryptedm, sig, notice = CryptoManager.decrypt payload
333 children = message_to_chunks(decryptedm, true) if decryptedm
334 [notice, sig, children].flatten.compact
337 def message_to_chunks m, encrypted=false, sibling_types=[]
340 case m.header.content_type
341 when "multipart/signed"
342 multipart_signed_to_chunks m
343 when "multipart/encrypted"
344 multipart_encrypted_to_chunks m
348 sibling_types = m.body.map { |p| p.header.content_type }
349 chunks = m.body.map { |p| message_to_chunks p, encrypted, sibling_types }.flatten.compact
353 elsif m.header.content_type == "message/rfc822"
354 payload = RMail::Parser.read(m.body)
355 from = payload.header.from.first
356 from_person = from ? PersonManager.person_for(from.format) : nil
357 [Chunk::EnclosedMessage.new(from_person, payload.to_s)]
360 ## first, paw through the headers looking for a filename
361 if m.header["Content-Disposition"] && m.header["Content-Disposition"] =~ /filename="?(.*?[^\\])("|;|$)/
363 elsif m.header["Content-Type"] && m.header["Content-Type"] =~ /name="?(.*?[^\\])("|;|$)/
366 ## haven't found one, but it's a non-text message. fake
369 ## TODO: make this less lame.
370 elsif m.header["Content-Type"] && m.header["Content-Type"] !~ /^text\/plain/
372 case m.header["Content-Type"]
373 when /text\/html/: "html"
374 when /image\/(.*)/: $1
377 ["sup-attachment-#{Time.now.to_i}-#{rand 10000}", extension].join(".")
380 ## if there's a filename, we'll treat it as an attachment.
382 [Chunk::Attachment.new(m.header.content_type, filename, m, sibling_types)]
384 ## otherwise, it's body text
386 body = Message.convert_from m.decode, m.charset if m.body
387 text_to_chunks (body || "").normalize_whitespace.split("\n"), encrypted
392 def self.convert_from body, charset
393 charset = "utf-8" if charset =~ /UTF_?8/i
395 raise MessageFormatError, "RubyMail decode returned a null body" unless body
396 return body unless charset
397 Iconv.iconv($encoding + "//IGNORE", charset, body + " ").join[0 .. -2]
398 rescue Errno::EINVAL, Iconv::InvalidEncoding, Iconv::IllegalSequence, MessageFormatError => e
399 Redwood::log "warning: error (#{e.class.name}) decoding message body from #{charset}: #{e.message}"
400 File.open("sup-unable-to-decode.txt", "w") { |f| f.write body }
405 ## parse the lines of text into chunk objects. the heuristics here
406 ## need tweaking in some nice manner. TODO: move these heuristics
407 ## into the classes themselves.
408 def text_to_chunks lines, encrypted
409 state = :text # one of :text, :quote, or :sig
413 lines.each_with_index do |line, i|
414 nextline = lines[(i + 1) ... lines.length].find { |l| l !~ /^\s*$/ } # skip blank lines
420 if line =~ QUOTE_PATTERN || (line =~ QUOTE_START_PATTERN && (nextline =~ QUOTE_PATTERN || nextline =~ QUOTE_START_PATTERN))
422 elsif line =~ SIG_PATTERN && (lines.length - i) < MAX_SIG_DISTANCE
424 elsif line =~ BLOCK_QUOTE_PATTERN
425 newstate = :block_quote
429 chunks << Chunk::Text.new(chunk_lines) unless chunk_lines.empty?
439 if line =~ QUOTE_PATTERN || line =~ QUOTE_START_PATTERN #|| line =~ /^\s*$/
441 elsif line =~ SIG_PATTERN && (lines.length - i) < MAX_SIG_DISTANCE
448 if chunk_lines.empty?
451 chunks << Chunk::Quote.new(chunk_lines)
457 when :block_quote, :sig
461 if !@have_snippet && state == :text && (@snippet.nil? || @snippet.length < SNIPPET_LEN) && line !~ /[=\*#_-]{3,}/ && line !~ /^\s*$/
463 @snippet += " " unless @snippet.empty?
464 @snippet += line.gsub(/^\s+/, "").gsub(/[\r\n]/, "").gsub(/\s+/, " ")
465 @snippet = @snippet[0 ... SNIPPET_LEN].chomp
466 @dirty = true unless encrypted && $config[:discard_snippets_from_encrypted_messages]
467 @snippet_contains_encrypted_content = true if encrypted
473 when :quote, :block_quote
474 chunks << Chunk::Quote.new(chunk_lines) unless chunk_lines.empty?
476 chunks << Chunk::Text.new(chunk_lines) unless chunk_lines.empty?
478 chunks << Chunk::Signature.new(chunk_lines) unless chunk_lines.empty?