]> git.cworth.org Git - sup/blob - lib/sup/message.rb
add the ability to joining threads forcefully to ThreadSet
[sup] / lib / sup / message.rb
1 require 'time'
2 require 'iconv'
3
4 module Redwood
5
6 class MessageFormatError < StandardError; end
7
8 ## a Message is what's threaded.
9 ##
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?)
15 ##
16 ## this class cathces all source exceptions. if the underlying source throws
17 ## an error, it is caught and handled.
18
19 class Message
20   SNIPPET_LEN = 80
21   RE_PATTERN = /^((re|re[\[\(]\d[\]\)]):\s*)+/i
22
23   ## some utility methods
24   class << self
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
28   end
29
30   QUOTE_PATTERN = /^\s{0,4}[>|\}]/
31   BLOCK_QUOTE_PATTERN = /^-----\s*Original Message\s*----+$/
32   QUOTE_START_PATTERN = /\w/
33   SIG_PATTERN = /(^-- ?$)|(^\s*----------+\s*$)|(^\s*_________+\s*$)|(^\s*--~--~-)|(^\s*--\+\+\*\*==)/
34
35   MAX_SIG_DISTANCE = 15 # lines from the end
36   DEFAULT_SUBJECT = ""
37   DEFAULT_SENDER = "(missing sender)"
38
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
42
43   bool_reader :dirty, :source_marked_read, :snippet_contains_encrypted_content
44
45   ## if you specify a :header, will use values from that. otherwise,
46   ## will try and load the header from the source.
47   def initialize opts
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] || [])
54     @dirty = false
55     @encrypted = false
56     @chunks = nil
57
58     ## we need to initialize this. see comments in parse_header as to
59     ## why.
60     @refs = []
61
62     parse_header(opts[:header] || @source.load_header(@source_info))
63   end
64
65   def parse_header header
66     header.each { |k, v| header[k.downcase] = v }
67
68     fakeid = nil
69     fakename = nil
70
71     @id =
72       if header["message-id"]
73         sanitize_message_id header["message-id"]
74       else
75         fakeid = "sup-faked-" + Digest::MD5.hexdigest(raw_header)
76       end
77     
78     @from =
79       if header["from"]
80         PersonManager.person_for header["from"]
81       else
82         fakename = "Sup Auto-generated Fake Sender <sup@fake.sender.example.com>"
83         PersonManager.person_for fakename
84       end
85
86     Redwood::log "faking message-id for message from #@from: #{id}" if fakeid
87     Redwood::log "faking from for message #@id: #{fakename}" if fakename
88
89     date = header["date"]
90     @date =
91       case date
92       when Time
93         date
94       when String
95         begin
96           Time.parse date
97         rescue ArgumentError => e
98           raise MessageFormatError, "unparsable date #{header['date']}: #{e.message}"
99         end
100       else
101         Redwood::log "faking date header for #{@id}"
102         Time.now
103       end
104
105     @subj = header.member?("subject") ? header["subject"].gsub(/\s+/, " ").gsub(/\s+$/, "") : DEFAULT_SUBJECT
106     @to = PersonManager.people_for header["to"]
107     @cc = PersonManager.people_for header["cc"]
108     @bcc = PersonManager.people_for header["bcc"]
109
110     ## before loading our full header from the source, we can actually
111     ## have some extra refs set by the UI. (this happens when the user
112     ## joins threads manually). so we will merge the current refs values
113     ## in here.
114     refs = (header["references"] || "").scan(/<(.+?)>/).map { |x| sanitize_message_id x.first }
115     @refs = (@refs + refs).uniq
116     @replytos = (header["in-reply-to"] || "").scan(/<(.+?)>/).map { |x| sanitize_message_id x.first }
117
118     @replyto = PersonManager.person_for header["reply-to"]
119     @list_address =
120       if header["list-post"]
121         @list_address = PersonManager.person_for header["list-post"].gsub(/^<mailto:|>$/, "")
122       else
123         nil
124       end
125
126     @recipient_email = header["envelope-to"] || header["x-original-to"] || header["delivered-to"]
127     @source_marked_read = header["status"] == "RO"
128     @list_subscribe = header["list-subscribe"]
129     @list_unsubscribe = header["list-unsubscribe"]
130   end
131   private :parse_header
132
133   def add_ref ref
134     @refs << ref
135     @dirty = true
136   end
137
138   def snippet; @snippet || (chunks && @snippet); end
139   def is_list_message?; !@list_address.nil?; end
140   def is_draft?; @source.is_a? DraftLoader; end
141   def draft_filename
142     raise "not a draft" unless is_draft?
143     @source.fn_for_offset @source_info
144   end
145
146   def sanitize_message_id mid; mid.gsub(/\s/, "") end
147
148   def save index
149     index.sync_message self if @dirty
150     @dirty = false
151   end
152
153   def has_label? t; @labels.member? t; end
154   def add_label t
155     return if @labels.member? t
156     @labels.push t
157     @dirty = true
158   end
159   def remove_label t
160     return unless @labels.member? t
161     @labels.delete t
162     @dirty = true
163   end
164
165   def recipients
166     @to + @cc + @bcc
167   end
168
169   def labels= l
170     @labels = l
171     @dirty = true
172   end
173
174   def chunks
175     load_from_source!
176     @chunks
177   end
178
179   ## this is called when the message body needs to actually be loaded.
180   def load_from_source!
181     @chunks ||=
182       if @source.has_errors?
183         [Chunk::Text.new(error_message(@source.error.message).split("\n"))]
184       else
185         begin
186           ## we need to re-read the header because it contains information
187           ## that we don't store in the index. actually i think it's just
188           ## the mailing list address (if any), so this is kinda overkill.
189           ## i could just store that in the index, but i think there might
190           ## be other things like that in the future, and i'd rather not
191           ## bloat the index.
192           ## actually, it's also the differentiation between to/cc/bcc,
193           ## so i will keep this.
194           parse_header @source.load_header(@source_info)
195           message_to_chunks @source.load_message(@source_info)
196         rescue SourceError, SocketError, MessageFormatError => e
197           Redwood::log "problem getting messages from #{@source}: #{e.message}"
198           ## we need force_to_top here otherwise this window will cover
199           ## up the error message one
200           @source.error ||= e
201           Redwood::report_broken_sources :force_to_top => true
202           [Chunk::Text.new(error_message(e.message).split("\n"))]
203         end
204       end
205   end
206
207   def error_message msg
208     <<EOS
209 #@snippet...
210
211 ***********************************************************************
212  An error occurred while loading this message. It is possible that
213  the source has changed, or (in the case of remote sources) is down.
214  You can check the log for errors, though hopefully an error window
215  should have popped up at some point.
216
217  The message location was:
218  #@source##@source_info
219 ***********************************************************************
220
221 The error message was:
222   #{msg}
223 EOS
224   end
225
226   ## wrap any source methods that might throw sourceerrors
227   def with_source_errors_handled
228     begin
229       yield
230     rescue SourceError => e
231       Redwood::log "problem getting messages from #{@source}: #{e.message}"
232       @source.error ||= e
233       Redwood::report_broken_sources :force_to_top => true
234       error_message e.message
235     end
236   end
237
238   def raw_header
239     with_source_errors_handled { @source.raw_header @source_info }
240   end
241
242   def raw_message
243     with_source_errors_handled { @source.raw_message @source_info }
244   end
245
246   ## much faster than raw_message
247   def each_raw_message_line &b
248     with_source_errors_handled { @source.each_raw_message_line(@source_info, &b) }
249   end
250
251   def content
252     load_from_source!
253     [
254       from && "#{from.name} #{from.email}",
255       to.map { |p| "#{p.name} #{p.email}" },
256       cc.map { |p| "#{p.name} #{p.email}" },
257       bcc.map { |p| "#{p.name} #{p.email}" },
258       chunks.select { |c| c.is_a? Chunk::Text }.map { |c| c.lines },
259       Message.normalize_subj(subj),
260     ].flatten.compact.join " "
261   end
262
263   def quotable_body_lines
264     chunks.find_all { |c| c.quotable? }.map { |c| c.lines }.flatten
265   end
266
267   def quotable_header_lines
268     ["From: #{@from.full_address}"] +
269       (@to.empty? ? [] : ["To: " + @to.map { |p| p.full_address }.join(", ")]) +
270       (@cc.empty? ? [] : ["Cc: " + @cc.map { |p| p.full_address }.join(", ")]) +
271       (@bcc.empty? ? [] : ["Bcc: " + @bcc.map { |p| p.full_address }.join(", ")]) +
272       ["Date: #{@date.rfc822}",
273        "Subject: #{@subj}"]
274   end
275
276 private
277
278   ## here's where we handle decoding mime attachments. unfortunately
279   ## but unsurprisingly, the world of mime attachments is a bit of a
280   ## mess. as an empiricist, i'm basing the following behavior on
281   ## observed mail rather than on interpretations of rfcs, so probably
282   ## this will have to be tweaked.
283   ##
284   ## the general behavior i want is: ignore content-disposition, at
285   ## least in so far as it suggests something being inline vs being an
286   ## attachment. (because really, that should be the recipient's
287   ## decision to make.) if a mime part is text/plain, OR if the user
288   ## decoding hook converts it, then decode it and display it
289   ## inline. for these decoded attachments, if it has associated
290   ## filename, then make it collapsable and individually saveable;
291   ## otherwise, treat it as regular body text.
292   ##
293   ## everything else is just an attachment and is not displayed
294   ## inline.
295   ##
296   ## so, in contrast to mutt, the user is not exposed to the workings
297   ## of the gruesome slaughterhouse and sausage factory that is a
298   ## mime-encoded message, but need only see the delicious end
299   ## product.
300
301   def multipart_signed_to_chunks m
302     if m.body.size != 2
303       Redwood::log "warning: multipart/signed with #{m.body.size} parts (expecting 2)"
304       return
305     end
306
307     payload, signature = m.body
308     if signature.multipart?
309       Redwood::log "warning: multipart/signed with payload multipart #{payload.multipart?} and signature multipart #{signature.multipart?}"
310       return
311     end
312
313     ## this probably will never happen
314     if payload.header.content_type == "application/pgp-signature"
315       Redwood::log "warning: multipart/signed with payload content type #{payload.header.content_type}"
316       return
317     end
318
319     if signature.header.content_type != "application/pgp-signature"
320       ## unknown signature type; just ignore.
321       #Redwood::log "warning: multipart/signed with signature content type #{signature.header.content_type}"
322       return
323     end
324
325     [CryptoManager.verify(payload, signature), message_to_chunks(payload)].flatten.compact
326   end
327
328   def multipart_encrypted_to_chunks m
329     if m.body.size != 2
330       Redwood::log "warning: multipart/encrypted with #{m.body.size} parts (expecting 2)"
331       return
332     end
333
334     control, payload = m.body
335     if control.multipart?
336       Redwood::log "warning: multipart/encrypted with control multipart #{control.multipart?} and payload multipart #{payload.multipart?}"
337       return
338     end
339
340     if payload.header.content_type != "application/octet-stream"
341       Redwood::log "warning: multipart/encrypted with payload content type #{payload.header.content_type}"
342       return
343     end
344
345     if control.header.content_type != "application/pgp-encrypted"
346       Redwood::log "warning: multipart/encrypted with control content type #{signature.header.content_type}"
347       return
348     end
349
350     decryptedm, sig, notice = CryptoManager.decrypt payload
351     children = message_to_chunks(decryptedm, true) if decryptedm
352     [notice, sig, children].flatten.compact
353   end
354
355   def message_to_chunks m, encrypted=false, sibling_types=[]
356     if m.multipart?
357       chunks =
358         case m.header.content_type
359         when "multipart/signed"
360           multipart_signed_to_chunks m
361         when "multipart/encrypted"
362           multipart_encrypted_to_chunks m
363         end
364
365       unless chunks
366         sibling_types = m.body.map { |p| p.header.content_type }
367         chunks = m.body.map { |p| message_to_chunks p, encrypted, sibling_types }.flatten.compact
368       end
369
370       chunks
371     elsif m.header.content_type == "message/rfc822"
372       payload = RMail::Parser.read(m.body)
373       from = payload.header.from.first
374       from_person = from ? PersonManager.person_for(from.format) : nil
375       [Chunk::EnclosedMessage.new(from_person, payload.to_s)]
376     else
377       filename =
378         ## first, paw through the headers looking for a filename
379         if m.header["Content-Disposition"] && m.header["Content-Disposition"] =~ /filename="?(.*?[^\\])("|;|$)/
380           $1
381         elsif m.header["Content-Type"] && m.header["Content-Type"] =~ /name="?(.*?[^\\])("|;|$)/
382           $1
383
384         ## haven't found one, but it's a non-text message. fake
385         ## it.
386         ##
387         ## TODO: make this less lame.
388         elsif m.header["Content-Type"] && m.header["Content-Type"] !~ /^text\/plain/
389           extension =
390             case m.header["Content-Type"]
391             when /text\/html/: "html"
392             when /image\/(.*)/: $1
393             end
394
395           ["sup-attachment-#{Time.now.to_i}-#{rand 10000}", extension].join(".")
396         end
397
398       ## if there's a filename, we'll treat it as an attachment.
399       if filename
400         [Chunk::Attachment.new(m.header.content_type, filename, m, sibling_types)]
401
402       ## otherwise, it's body text
403       else
404         body = Message.convert_from m.decode, m.charset if m.body
405         text_to_chunks (body || "").normalize_whitespace.split("\n"), encrypted
406       end
407     end
408   end
409
410   def self.convert_from body, charset
411     charset = "utf-8" if charset =~ /UTF_?8/i
412     begin
413       raise MessageFormatError, "RubyMail decode returned a null body" unless body
414       return body unless charset
415       Iconv.iconv($encoding + "//IGNORE", charset, body + " ").join[0 .. -2]
416     rescue Errno::EINVAL, Iconv::InvalidEncoding, Iconv::IllegalSequence, MessageFormatError => e
417       Redwood::log "warning: error (#{e.class.name}) decoding message body from #{charset}: #{e.message}"
418       File.open("sup-unable-to-decode.txt", "w") { |f| f.write body }
419       body
420     end
421   end
422
423   ## parse the lines of text into chunk objects.  the heuristics here
424   ## need tweaking in some nice manner. TODO: move these heuristics
425   ## into the classes themselves.
426   def text_to_chunks lines, encrypted
427     state = :text # one of :text, :quote, or :sig
428     chunks = []
429     chunk_lines = []
430
431     lines.each_with_index do |line, i|
432       nextline = lines[(i + 1) ... lines.length].find { |l| l !~ /^\s*$/ } # skip blank lines
433
434       case state
435       when :text
436         newstate = nil
437
438         if line =~ QUOTE_START_PATTERN && nextline =~ QUOTE_PATTERN
439           newstate = :quote
440         elsif line =~ SIG_PATTERN && (lines.length - i) < MAX_SIG_DISTANCE
441           newstate = :sig
442         elsif line =~ BLOCK_QUOTE_PATTERN
443           newstate = :block_quote
444         end
445
446         if newstate
447           chunks << Chunk::Text.new(chunk_lines) unless chunk_lines.empty?
448           chunk_lines = [line]
449           state = newstate
450         else
451           chunk_lines << line
452         end
453
454       when :quote
455         newstate = nil
456
457         if line =~ QUOTE_PATTERN || (line =~ /^\s*$/ && nextline =~ QUOTE_PATTERN)
458           chunk_lines << line
459         elsif line =~ SIG_PATTERN && (lines.length - i) < MAX_SIG_DISTANCE
460           newstate = :sig
461         else
462           newstate = :text
463         end
464
465         if newstate
466           if chunk_lines.empty?
467             # nothing
468           else
469             chunks << Chunk::Quote.new(chunk_lines)
470           end
471           chunk_lines = [line]
472           state = newstate
473         end
474
475       when :block_quote, :sig
476         chunk_lines << line
477       end
478
479       if !@have_snippet && state == :text && (@snippet.nil? || @snippet.length < SNIPPET_LEN) && line !~ /[=\*#_-]{3,}/ && line !~ /^\s*$/
480         @snippet ||= ""
481         @snippet += " " unless @snippet.empty?
482         @snippet += line.gsub(/^\s+/, "").gsub(/[\r\n]/, "").gsub(/\s+/, " ")
483         @snippet = @snippet[0 ... SNIPPET_LEN].chomp
484         @dirty = true unless encrypted && $config[:discard_snippets_from_encrypted_messages]
485         @snippet_contains_encrypted_content = true if encrypted
486       end
487     end
488
489     ## final object
490     case state
491     when :quote, :block_quote
492       chunks << Chunk::Quote.new(chunk_lines) unless chunk_lines.empty?
493     when :text
494       chunks << Chunk::Text.new(chunk_lines) unless chunk_lines.empty?
495     when :sig
496       chunks << Chunk::Signature.new(chunk_lines) unless chunk_lines.empty?
497     end
498     chunks
499   end
500 end
501
502 end