7 class MessageFormatError < StandardError; end
9 ## a Message is what's threaded.
11 ## it is also where the parsing for quotes and signatures is done, but
12 ## that should be moved out to a separate class at some point (because
13 ## i would like, for example, to be able to add in a ruby-talk
14 ## specific module that would detect and link to /ruby-talk:\d+/
15 ## sequences in the text of an email. (how sweet would that be?)
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 = /(^\s*Excerpts from)|(^\s*In message )|(^\s*In article )|(^\s*Quoting )|((wrote|writes|said|says)\s*:\s*$)/
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, :list_address, :recipient_email, :replyto,
40 bool_reader :dirty, :source_marked_read
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 @have_snippet = !opts[:snippet].nil?
49 @labels = [] + (opts[:labels] || [])
53 parse_header(opts[:header] || @source.load_header(@source_info))
56 def parse_header header
57 header.each { |k, v| header[k.downcase] = v }
59 @from = PersonManager.person_for header["from"]
61 @id = header["message-id"]
63 id.gsub!(/^\s+|\s+$/, "")
65 @id = "sup-faked-" + Digest::MD5.hexdigest(raw_header)
66 Redwood::log "faking message-id for message from #@from: #@id"
77 rescue ArgumentError => e
78 raise MessageFormatError, "unparsable date #{header['date']}: #{e.message}"
81 Redwood::log "faking date header for #{@id}"
85 @subj = header.member?("subject") ? header["subject"].gsub(/\s+/, " ").gsub(/\s+$/, "") : DEFAULT_SUBJECT
86 @to = PersonManager.people_for header["to"]
87 @cc = PersonManager.people_for header["cc"]
88 @bcc = PersonManager.people_for header["bcc"]
89 @refs = (header["references"] || "").gsub(/[<>]/, "").split(/\s+/).flatten
90 @replytos = (header["in-reply-to"] || "").scan(/<(.*?)>/).flatten
91 @replyto = PersonManager.person_for header["reply-to"]
93 if header["list-post"]
94 @list_address = PersonManager.person_for header["list-post"].gsub(/^<mailto:|>$/, "")
99 @recipient_email = header["envelope-to"] || header["x-original-to"] || header["delivered-to"]
100 @source_marked_read = header["status"] == "RO"
102 private :parse_header
104 def snippet; @snippet || chunks && @snippet; end
105 def is_list_message?; !@list_address.nil?; end
106 def is_draft?; @source.is_a? DraftLoader; end
108 raise "not a draft" unless is_draft?
109 @source.fn_for_offset @source_info
113 index.sync_message self if @dirty
117 def has_label? t; @labels.member? t; end
119 return if @labels.member? t
124 return unless @labels.member? t
138 ## this is called when the message body needs to actually be loaded.
139 def load_from_source!
141 if @source.has_errors?
142 [Chunk::Text.new(error_message(@source.error.message.split("\n")))]
145 ## we need to re-read the header because it contains information
146 ## that we don't store in the index. actually i think it's just
147 ## the mailing list address (if any), so this is kinda overkill.
148 ## i could just store that in the index, but i think there might
149 ## be other things like that in the future, and i'd rather not
151 ## actually, it's also the differentiation between to/cc/bcc,
152 ## so i will keep this.
153 parse_header @source.load_header(@source_info)
154 message_to_chunks @source.load_message(@source_info)
155 rescue SourceError, SocketError, MessageFormatError => e
156 Redwood::log "problem getting messages from #{@source}: #{e.message}"
157 ## we need force_to_top here otherwise this window will cover
158 ## up the error message one
159 Redwood::report_broken_sources :force_to_top => true
160 [Chunk::Text.new(error_message(e.message))]
165 def error_message msg
169 ***********************************************************************
170 An error occurred while loading this message. It is possible that
171 the source has changed, or (in the case of remote sources) is down.
172 You can check the log for errors, though hopefully an error window
173 should have popped up at some point.
175 The message location was:
176 #@source##@source_info
177 ***********************************************************************
179 The error message was:
184 def with_source_errors_handled
187 rescue SourceError => e
188 Redwood::log "problem getting messages from #{@source}: #{e.message}"
189 error_message e.message
194 with_source_errors_handled { @source.raw_header @source_info }
198 with_source_errors_handled { @source.raw_message @source_info }
201 ## much faster than raw_message
202 def each_raw_message_line &b
203 with_source_errors_handled { @source.each_raw_message_line(@source_info, &b) }
209 from && "#{from.name} #{from.email}",
210 to.map { |p| "#{p.name} #{p.email}" },
211 cc.map { |p| "#{p.name} #{p.email}" },
212 bcc.map { |p| "#{p.name} #{p.email}" },
213 chunks.select { |c| c.is_a? Chunk::Text }.map { |c| c.lines },
214 Message.normalize_subj(subj),
215 ].flatten.compact.join " "
219 chunks.find_all { |c| c.is_a?(Chunk::Text) || c.is_a?(Chunk::Quote) }.map { |c| c.lines }.flatten
222 def basic_header_lines
223 ["From: #{@from.full_address}"] +
224 (@to.empty? ? [] : ["To: " + @to.map { |p| p.full_address }.join(", ")]) +
225 (@cc.empty? ? [] : ["Cc: " + @cc.map { |p| p.full_address }.join(", ")]) +
226 (@bcc.empty? ? [] : ["Bcc: " + @bcc.map { |p| p.full_address }.join(", ")]) +
227 ["Date: #{@date.rfc822}",
233 ## here's where we handle decoding mime attachments. unfortunately
234 ## but unsurprisingly, the world of mime attachments is a bit of a
235 ## mess. as an empiricist, i'm basing the following behavior on
236 ## observed mail rather than on interpretations of rfcs, so probably
237 ## this will have to be tweaked.
239 ## the general behavior i want is: ignore content-disposition, at
240 ## least in so far as it suggests something being inline vs being an
241 ## attachment. (because really, that should be the recipient's
242 ## decision to make.) if a mime part is text/plain, OR if the user
243 ## decoding hook converts it, then decode it and display it
244 ## inline. for these decoded attachments, if it has associated
245 ## filename, then make it collapsable and individually saveable;
246 ## otherwise, treat it as regular body text.
248 ## everything else is just an attachment and is not displayed
251 ## so, in contrast to mutt, the user is not exposed to the workings
252 ## of the gruesome slaughterhouse and sausage factory that is a
253 ## mime-encoded message, but need only see the delicious end
256 def multipart_signed_to_chunks m
257 # Redwood::log ">> multipart SIGNED: #{m.header['Content-Type']}: #{m.body.size}"
259 Redwood::log "warning: multipart/signed with #{m.body.size} parts (expecting 2)"
263 payload, signature = m.body
264 if signature.multipart?
265 Redwood::log "warning: multipart/signed with payload multipart #{payload.multipart?} and signature multipart #{signature.multipart?}"
269 if payload.header.content_type == "application/pgp-signature"
270 Redwood::log "warning: multipart/signed with payload content type #{payload.header.content_type}"
274 if signature.header.content_type != "application/pgp-signature"
275 Redwood::log "warning: multipart/signed with signature content type #{signature.header.content_type}"
279 [CryptoManager.verify(payload, signature), message_to_chunks(payload)].flatten.compact
282 def multipart_encrypted_to_chunks m
283 Redwood::log ">> multipart ENCRYPTED: #{m.header['Content-Type']}: #{m.body.size}"
285 Redwood::log "warning: multipart/encrypted with #{m.body.size} parts (expecting 2)"
289 control, payload = m.body
290 if control.multipart?
291 Redwood::log "warning: multipart/encrypted with control multipart #{control.multipart?} and payload multipart #{payload.multipart?}"
295 if payload.header.content_type != "application/octet-stream"
296 Redwood::log "warning: multipart/encrypted with payload content type #{payload.header.content_type}"
300 if control.header.content_type != "application/pgp-encrypted"
301 Redwood::log "warning: multipart/encrypted with control content type #{signature.header.content_type}"
305 decryptedm, sig, notice = CryptoManager.decrypt payload
306 children = message_to_chunks(decryptedm) if decryptedm
307 [notice, sig, children].flatten.compact
310 def message_to_chunks m, sibling_types=[]
313 case m.header.content_type
314 when "multipart/signed"
315 multipart_signed_to_chunks m
316 when "multipart/encrypted"
317 multipart_encrypted_to_chunks m
321 sibling_types = m.body.map { |p| p.header.content_type }
322 chunks = m.body.map { |p| message_to_chunks p, sibling_types }.flatten.compact
326 elsif m.header.content_type == "message/rfc822"
327 payload = RMail::Parser.read(m.body)
328 from = payload.header.from.first
329 from_person = from ? PersonManager.person_for(from.format) : nil
330 [Chunk::EnclosedMessage.new(from_person, payload.to_s)]
333 ## first, paw through the headers looking for a filename
334 if m.header["Content-Disposition"] &&
335 m.header["Content-Disposition"] =~ /filename="?(.*?[^\\])("|;|$)/
337 elsif m.header["Content-Type"] &&
338 m.header["Content-Type"] =~ /name=(.*?)(;|$)/
341 ## haven't found one, but it's a non-text message. fake
343 elsif m.header["Content-Type"] && m.header["Content-Type"] !~ /^text\/plain/
344 "sup-attachment-#{Time.now.to_i}-#{rand 10000}"
347 ## if there's a filename, we'll treat it as an attachment.
349 [Chunk::Attachment.new(m.header.content_type, filename, m, sibling_types)]
351 ## otherwise, it's body text
353 body = Message.convert_from m.decode, m.charset
354 text_to_chunks body.normalize_whitespace.split("\n")
359 def self.convert_from body, charset
360 return body unless charset
363 Iconv.iconv($encoding, charset, body).join
364 rescue Errno::EINVAL, Iconv::InvalidEncoding, Iconv::IllegalSequence => e
365 Redwood::log "warning: error (#{e.class.name}) decoding message body from #{charset}: #{e.message}"
366 File.open("sup-unable-to-decode.txt", "w") { |f| f.write body }
371 ## parse the lines of text into chunk objects. the heuristics here
372 ## need tweaking in some nice manner. TODO: move these heuristics
373 ## into the classes themselves.
374 def text_to_chunks lines
375 state = :text # one of :text, :quote, or :sig
379 lines.each_with_index do |line, i|
380 nextline = lines[(i + 1) ... lines.length].find { |l| l !~ /^\s*$/ } # skip blank lines
386 if line =~ QUOTE_PATTERN || (line =~ QUOTE_START_PATTERN && (nextline =~ QUOTE_PATTERN || nextline =~ QUOTE_START_PATTERN))
388 elsif line =~ SIG_PATTERN && (lines.length - i) < MAX_SIG_DISTANCE
390 elsif line =~ BLOCK_QUOTE_PATTERN
391 newstate = :block_quote
395 chunks << Chunk::Text.new(chunk_lines) unless chunk_lines.empty?
405 if line =~ QUOTE_PATTERN || line =~ QUOTE_START_PATTERN #|| line =~ /^\s*$/
407 elsif line =~ SIG_PATTERN && (lines.length - i) < MAX_SIG_DISTANCE
414 if chunk_lines.empty?
417 chunks << Chunk::Quote.new(chunk_lines)
423 when :block_quote, :sig
427 if !@have_snippet && state == :text && (@snippet.nil? || @snippet.length < SNIPPET_LEN) && line !~ /[=\*#_-]{3,}/ && line !~ /^\s*$/
428 @snippet += " " unless @snippet.empty?
429 @snippet += line.gsub(/^\s+/, "").gsub(/[\r\n]/, "").gsub(/\s+/, " ")
430 @snippet = @snippet[0 ... SNIPPET_LEN].chomp
436 when :quote, :block_quote
437 chunks << Chunk::Quote.new(chunk_lines) unless chunk_lines.empty?
439 chunks << Chunk::Text.new(chunk_lines) unless chunk_lines.empty?
441 chunks << Chunk::Signature.new(chunk_lines) unless chunk_lines.empty?