sup 0.3 → 0.4

Sign up to get free protection for your applications and to get access to all the features.

Potentially problematic release.


This version of sup might be problematic. Click here for more details.

Files changed (52) hide show
  1. data/HACKING +31 -9
  2. data/History.txt +7 -0
  3. data/Manifest.txt +2 -0
  4. data/Rakefile +9 -5
  5. data/bin/sup +81 -57
  6. data/bin/sup-config +1 -1
  7. data/bin/sup-sync +3 -0
  8. data/bin/sup-tweak-labels +127 -0
  9. data/doc/TODO +23 -12
  10. data/lib/sup.rb +13 -11
  11. data/lib/sup/account.rb +25 -12
  12. data/lib/sup/buffer.rb +61 -41
  13. data/lib/sup/colormap.rb +2 -0
  14. data/lib/sup/contact.rb +28 -18
  15. data/lib/sup/crypto.rb +86 -31
  16. data/lib/sup/draft.rb +12 -6
  17. data/lib/sup/horizontal-selector.rb +47 -0
  18. data/lib/sup/imap.rb +50 -37
  19. data/lib/sup/index.rb +76 -13
  20. data/lib/sup/keymap.rb +27 -8
  21. data/lib/sup/maildir.rb +1 -1
  22. data/lib/sup/mbox/loader.rb +1 -1
  23. data/lib/sup/message-chunks.rb +43 -15
  24. data/lib/sup/message.rb +67 -31
  25. data/lib/sup/mode.rb +40 -9
  26. data/lib/sup/modes/completion-mode.rb +1 -1
  27. data/lib/sup/modes/compose-mode.rb +3 -3
  28. data/lib/sup/modes/contact-list-mode.rb +12 -8
  29. data/lib/sup/modes/edit-message-mode.rb +100 -36
  30. data/lib/sup/modes/file-browser-mode.rb +1 -0
  31. data/lib/sup/modes/forward-mode.rb +43 -8
  32. data/lib/sup/modes/inbox-mode.rb +8 -5
  33. data/lib/sup/modes/label-search-results-mode.rb +12 -1
  34. data/lib/sup/modes/line-cursor-mode.rb +4 -7
  35. data/lib/sup/modes/reply-mode.rb +59 -54
  36. data/lib/sup/modes/resume-mode.rb +6 -6
  37. data/lib/sup/modes/scroll-mode.rb +4 -3
  38. data/lib/sup/modes/search-results-mode.rb +8 -5
  39. data/lib/sup/modes/text-mode.rb +19 -2
  40. data/lib/sup/modes/thread-index-mode.rb +109 -40
  41. data/lib/sup/modes/thread-view-mode.rb +180 -49
  42. data/lib/sup/person.rb +3 -3
  43. data/lib/sup/poll.rb +9 -8
  44. data/lib/sup/rfc2047.rb +7 -1
  45. data/lib/sup/sent.rb +1 -1
  46. data/lib/sup/tagger.rb +10 -4
  47. data/lib/sup/textfield.rb +7 -7
  48. data/lib/sup/thread.rb +86 -49
  49. data/lib/sup/update.rb +11 -0
  50. data/lib/sup/util.rb +74 -34
  51. data/test/test_message.rb +441 -0
  52. metadata +136 -117
@@ -7,7 +7,7 @@ class Keymap
7
7
  yield self if block_given?
8
8
  end
9
9
 
10
- def keysym_to_keycode k
10
+ def self.keysym_to_keycode k
11
11
  case k
12
12
  when :down: Curses::KEY_DOWN
13
13
  when :up: Curses::KEY_UP
@@ -31,7 +31,7 @@ class Keymap
31
31
  end
32
32
  end
33
33
 
34
- def keysym_to_string k
34
+ def self.keysym_to_string k
35
35
  case k
36
36
  when :down: "<down arrow>"
37
37
  when :up: "<up arrow>"
@@ -60,25 +60,44 @@ class Keymap
60
60
  entry = [action, help, keys]
61
61
  @order << entry
62
62
  keys.each do |k|
63
- raise ArgumentError, "key #{k} already defined (action #{action})" if @map.include? k
64
- kc = keysym_to_keycode k
63
+ kc = Keymap.keysym_to_keycode k
64
+ raise ArgumentError, "key '#{k}' already defined (as #{@map[kc].first})" if @map.include? kc
65
65
  @map[kc] = entry
66
66
  end
67
67
  end
68
68
 
69
+ def add_multi prompt, key
70
+ submap = Keymap.new
71
+ add submap, prompt, key
72
+ yield submap
73
+ end
74
+
69
75
  def action_for kc
70
76
  action, help, keys = @map[kc]
71
- action
77
+ [action, help]
72
78
  end
73
79
 
80
+ def has_key? k; @map[k] end
81
+
74
82
  def keysyms; @map.values.map { |action, help, keys| keys }.flatten; end
75
83
 
76
- def help_text except_for={}
77
- lines = @order.map do |action, help, keys|
84
+ def help_lines except_for={}, prefix=""
85
+ lines = [] # :(
86
+ @order.each do |action, help, keys|
78
87
  valid_keys = keys.select { |k| !except_for[k] }
79
88
  next if valid_keys.empty?
80
- [valid_keys.map { |k| keysym_to_string k }.join(", "), help]
89
+ case action
90
+ when Symbol
91
+ lines << [valid_keys.map { |k| prefix + Keymap.keysym_to_string(k) }.join(", "), help]
92
+ when Keymap
93
+ lines += action.help_lines({}, prefix + Keymap.keysym_to_string(keys.first))
94
+ end
81
95
  end.compact
96
+ lines
97
+ end
98
+
99
+ def help_text except_for={}
100
+ lines = help_lines except_for
82
101
  llen = lines.max_of { |a, b| a.length }
83
102
  lines.map { |a, b| sprintf " %#{llen}s : %s", a, b }.join("\n")
84
103
  end
@@ -144,7 +144,7 @@ private
144
144
 
145
145
  def make_id fn
146
146
  # use 7 digits for the size. why 7? seems nice.
147
- sprintf("%d%07d", File.mtime(fn), File.size(fn)).to_i
147
+ sprintf("%d%07d", File.mtime(fn), File.size(fn) % 10000000).to_i
148
148
  end
149
149
 
150
150
  def with_file_for id
@@ -37,7 +37,7 @@ class Loader < Source
37
37
  if File.dirname(path) =~ /\b(var|usr|spool)\b/
38
38
  []
39
39
  else
40
- [File.basename(path).intern]
40
+ [File.basename(path).downcase.intern]
41
41
  end
42
42
  end
43
43
 
@@ -1,3 +1,5 @@
1
+ require 'tempfile'
2
+
1
3
  ## Here we define all the "chunks" that a message is parsed
2
4
  ## into. Chunks are used by ThreadViewMode to render a message. Chunks
3
5
  ## are used for both MIME stuff like attachments, for Sup's parsing of
@@ -29,6 +31,14 @@
29
31
  ## included as quoted text during a reply. Text, Quotes, and mime-parsed
30
32
  ## attachments are quotable; Signatures are not.
31
33
 
34
+ ## monkey-patch time: make temp files have the right extension
35
+ class Tempfile
36
+ def make_tmpname basename, n
37
+ sprintf '%d-%d-%s', $$, n, basename
38
+ end
39
+ end
40
+
41
+
32
42
  module Redwood
33
43
  module Chunk
34
44
  class Attachment
@@ -36,14 +46,23 @@ module Chunk
36
46
  Executes when decoding a MIME attachment.
37
47
  Variables:
38
48
  content_type: the content-type of the message
39
- filename: the filename of the attachment as saved to disk (generated
40
- on the fly, so don't call more than once)
49
+ filename: the filename of the attachment as saved to disk
41
50
  sibling_types: if this attachment is part of a multipart MIME attachment,
42
51
  an array of content-types for all attachments. Otherwise,
43
52
  the empty array.
44
53
  Return value:
45
54
  The decoded text of the attachment, or nil if not decoded.
46
55
  EOS
56
+
57
+ HookManager.register "mime-view", <<EOS
58
+ Executes when viewing a MIME attachment, i.e., launching a separate
59
+ viewer program.
60
+ Variables:
61
+ content_type: the content-type of the attachment
62
+ filename: the filename of the attachment as saved to disk
63
+ Return value:
64
+ True if the viewing was successful, false otherwise.
65
+ EOS
47
66
  #' stupid ruby-mode
48
67
 
49
68
  ## raw_content is the post-MIME-decode content. this is used for
@@ -54,7 +73,8 @@ EOS
54
73
  def initialize content_type, filename, encoded_content, sibling_types
55
74
  @content_type = content_type
56
75
  @filename = filename
57
- @quotable = false # only quotable if we can parse it through the mime-decode hook
76
+ @quotable = false # changed to true if we can parse it through the
77
+ # mime-decode hook, or if it's plain text
58
78
  @raw_content =
59
79
  if encoded_content.body
60
80
  encoded_content.decode
@@ -62,19 +82,21 @@ EOS
62
82
  "For some bizarre reason, RubyMail was unable to parse this attachment.\n"
63
83
  end
64
84
 
65
- @lines =
85
+ text =
66
86
  case @content_type
67
87
  when /^text\/plain\b/
68
- Message.convert_from(@raw_content, encoded_content.charset).split("\n")
88
+ Message.convert_from @raw_content, encoded_content.charset
69
89
  else
70
- text = HookManager.run "mime-decode", :content_type => content_type,
71
- :filename => lambda { write_to_disk },
72
- :sibling_types => sibling_types
73
- if text
74
- @quotable = true
75
- text.split("\n")
76
- end
90
+ HookManager.run "mime-decode", :content_type => content_type,
91
+ :filename => lambda { write_to_disk },
92
+ :sibling_types => sibling_types
77
93
  end
94
+
95
+ @lines = nil
96
+ if text
97
+ @lines = text.gsub("\r\n", "\n").gsub(/\t/, " ").gsub(/\r/, "").split("\n")
98
+ @quotable = true
99
+ end
78
100
  end
79
101
 
80
102
  def color; :none end
@@ -93,14 +115,20 @@ EOS
93
115
  def expandable?; !viewable? end
94
116
  def initial_state; :open end
95
117
  def viewable?; @lines.nil? end
96
- def view!
97
- path = write_to_disk
118
+ def view_default! path
98
119
  system "/usr/bin/run-mailcap --action=view #{@content_type}:#{path} > /dev/null 2> /dev/null"
99
120
  $? == 0
100
121
  end
101
122
 
123
+ def view!
124
+ path = write_to_disk
125
+ ret = HookManager.run "mime-view", :content_type => @content_type,
126
+ :filename => path
127
+ view_default! path unless ret
128
+ end
129
+
102
130
  def write_to_disk
103
- file = Tempfile.new "redwood.attachment"
131
+ file = Tempfile.new(@filename || "sup-attachment")
104
132
  file.print @raw_content
105
133
  file.close
106
134
  file.path
@@ -1,4 +1,3 @@
1
- require 'tempfile'
2
1
  require 'time'
3
2
  require 'iconv'
4
3
 
@@ -30,7 +29,7 @@ class Message
30
29
 
31
30
  QUOTE_PATTERN = /^\s{0,4}[>|\}]/
32
31
  BLOCK_QUOTE_PATTERN = /^-----\s*Original Message\s*----+$/
33
- QUOTE_START_PATTERN = /(^\s*Excerpts from)|(^\s*In message )|(^\s*In article )|(^\s*Quoting )|((wrote|writes|said|says)\s*:\s*$)/
32
+ QUOTE_START_PATTERN = /\w.*:$/
34
33
  SIG_PATTERN = /(^-- ?$)|(^\s*----------+\s*$)|(^\s*_________+\s*$)|(^\s*--~--~-)|(^\s*--\+\+\*\*==)/
35
34
 
36
35
  MAX_SIG_DISTANCE = 15 # lines from the end
@@ -39,45 +38,54 @@ class Message
39
38
 
40
39
  attr_reader :id, :date, :from, :subj, :refs, :replytos, :to, :source,
41
40
  :cc, :bcc, :labels, :list_address, :recipient_email, :replyto,
42
- :source_info, :chunks, :list_subscribe, :list_unsubscribe
41
+ :source_info, :list_subscribe, :list_unsubscribe
43
42
 
44
- bool_reader :dirty, :source_marked_read
43
+ bool_reader :dirty, :source_marked_read, :snippet_contains_encrypted_content
45
44
 
46
45
  ## if you specify a :header, will use values from that. otherwise,
47
46
  ## will try and load the header from the source.
48
47
  def initialize opts
49
48
  @source = opts[:source] or raise ArgumentError, "source can't be nil"
50
49
  @source_info = opts[:source_info] or raise ArgumentError, "source_info can't be nil"
51
- @snippet = opts[:snippet] || ""
52
- @have_snippet = !opts[:snippet].nil?
50
+ @snippet = opts[:snippet]
51
+ @snippet_contains_encrypted_content = false
52
+ @have_snippet = !(opts[:snippet].nil? || opts[:snippet].empty?)
53
53
  @labels = [] + (opts[:labels] || [])
54
54
  @dirty = false
55
+ @encrypted = false
55
56
  @chunks = nil
56
57
 
58
+ ## we need to initialize this. see comments in parse_header as to
59
+ ## why.
60
+ @refs = []
61
+
57
62
  parse_header(opts[:header] || @source.load_header(@source_info))
58
63
  end
59
64
 
60
65
  def parse_header header
61
66
  header.each { |k, v| header[k.downcase] = v }
62
-
67
+
68
+ fakeid = nil
69
+ fakename = nil
70
+
63
71
  @id =
64
72
  if header["message-id"]
65
73
  sanitize_message_id header["message-id"]
66
74
  else
67
- returning("sup-faked-" + Digest::MD5.hexdigest(raw_header)) do |id|
68
- Redwood::log "faking message-id for message from #@from: #{id}"
69
- end
75
+ fakeid = "sup-faked-" + Digest::MD5.hexdigest(raw_header)
70
76
  end
71
77
 
72
78
  @from =
73
79
  if header["from"]
74
80
  PersonManager.person_for header["from"]
75
81
  else
76
- name = "Sup Auto-generated Fake Sender <sup@fake.sender.example.com>"
77
- Redwood::log "faking from for message #@id: #{name}"
78
- PersonManager.person_for name
82
+ fakename = "Sup Auto-generated Fake Sender <sup@fake.sender.example.com>"
83
+ PersonManager.person_for fakename
79
84
  end
80
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
+
81
89
  date = header["date"]
82
90
  @date =
83
91
  case date
@@ -98,7 +106,13 @@ class Message
98
106
  @to = PersonManager.people_for header["to"]
99
107
  @cc = PersonManager.people_for header["cc"]
100
108
  @bcc = PersonManager.people_for header["bcc"]
101
- @refs = (header["references"] || "").scan(/<(.+?)>/).map { |x| sanitize_message_id x.first }
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
102
116
  @replytos = (header["in-reply-to"] || "").scan(/<(.+?)>/).map { |x| sanitize_message_id x.first }
103
117
 
104
118
  @replyto = PersonManager.person_for header["reply-to"]
@@ -116,7 +130,12 @@ class Message
116
130
  end
117
131
  private :parse_header
118
132
 
119
- def snippet; @snippet || chunks && @snippet; end
133
+ def add_ref ref
134
+ @refs << ref
135
+ @dirty = true
136
+ end
137
+
138
+ def snippet; @snippet || (chunks && @snippet); end
120
139
  def is_list_message?; !@list_address.nil?; end
121
140
  def is_draft?; @source.is_a? DraftLoader; end
122
141
  def draft_filename
@@ -152,11 +171,16 @@ class Message
152
171
  @dirty = true
153
172
  end
154
173
 
174
+ def chunks
175
+ load_from_source!
176
+ @chunks
177
+ end
178
+
155
179
  ## this is called when the message body needs to actually be loaded.
156
180
  def load_from_source!
157
181
  @chunks ||=
158
182
  if @source.has_errors?
159
- [Chunk::Text.new(error_message(@source.error.message.split("\n")))]
183
+ [Chunk::Text.new(error_message(@source.error.message).split("\n"))]
160
184
  else
161
185
  begin
162
186
  ## we need to re-read the header because it contains information
@@ -175,7 +199,7 @@ class Message
175
199
  ## up the error message one
176
200
  @source.error ||= e
177
201
  Redwood::report_broken_sources :force_to_top => true
178
- [Chunk::Text.new(error_message(e.message))]
202
+ [Chunk::Text.new(error_message(e.message).split("\n"))]
179
203
  end
180
204
  end
181
205
  end
@@ -275,7 +299,6 @@ private
275
299
  ## product.
276
300
 
277
301
  def multipart_signed_to_chunks m
278
- # Redwood::log ">> multipart SIGNED: #{m.header['Content-Type']}: #{m.body.size}"
279
302
  if m.body.size != 2
280
303
  Redwood::log "warning: multipart/signed with #{m.body.size} parts (expecting 2)"
281
304
  return
@@ -287,13 +310,15 @@ private
287
310
  return
288
311
  end
289
312
 
313
+ ## this probably will never happen
290
314
  if payload.header.content_type == "application/pgp-signature"
291
315
  Redwood::log "warning: multipart/signed with payload content type #{payload.header.content_type}"
292
316
  return
293
317
  end
294
318
 
295
319
  if signature.header.content_type != "application/pgp-signature"
296
- Redwood::log "warning: multipart/signed with signature content type #{signature.header.content_type}"
320
+ ## unknown signature type; just ignore.
321
+ #Redwood::log "warning: multipart/signed with signature content type #{signature.header.content_type}"
297
322
  return
298
323
  end
299
324
 
@@ -301,7 +326,6 @@ private
301
326
  end
302
327
 
303
328
  def multipart_encrypted_to_chunks m
304
- Redwood::log ">> multipart ENCRYPTED: #{m.header['Content-Type']}: #{m.body.size}"
305
329
  if m.body.size != 2
306
330
  Redwood::log "warning: multipart/encrypted with #{m.body.size} parts (expecting 2)"
307
331
  return
@@ -324,11 +348,11 @@ private
324
348
  end
325
349
 
326
350
  decryptedm, sig, notice = CryptoManager.decrypt payload
327
- children = message_to_chunks(decryptedm) if decryptedm
351
+ children = message_to_chunks(decryptedm, true) if decryptedm
328
352
  [notice, sig, children].flatten.compact
329
353
  end
330
354
 
331
- def message_to_chunks m, sibling_types=[]
355
+ def message_to_chunks m, encrypted=false, sibling_types=[]
332
356
  if m.multipart?
333
357
  chunks =
334
358
  case m.header.content_type
@@ -340,7 +364,7 @@ private
340
364
 
341
365
  unless chunks
342
366
  sibling_types = m.body.map { |p| p.header.content_type }
343
- chunks = m.body.map { |p| message_to_chunks p, sibling_types }.flatten.compact
367
+ chunks = m.body.map { |p| message_to_chunks p, encrypted, sibling_types }.flatten.compact
344
368
  end
345
369
 
346
370
  chunks
@@ -359,8 +383,16 @@ private
359
383
 
360
384
  ## haven't found one, but it's a non-text message. fake
361
385
  ## it.
386
+ ##
387
+ ## TODO: make this less lame.
362
388
  elsif m.header["Content-Type"] && m.header["Content-Type"] !~ /^text\/plain/
363
- "sup-attachment-#{Time.now.to_i}-#{rand 10000}"
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(".")
364
396
  end
365
397
 
366
398
  ## if there's a filename, we'll treat it as an attachment.
@@ -369,17 +401,18 @@ private
369
401
 
370
402
  ## otherwise, it's body text
371
403
  else
372
- body = Message.convert_from m.decode, m.charset
373
- text_to_chunks body.normalize_whitespace.split("\n")
404
+ body = Message.convert_from m.decode, m.charset if m.body
405
+ text_to_chunks((body || "").normalize_whitespace.split("\n"), encrypted)
374
406
  end
375
407
  end
376
408
  end
377
409
 
378
410
  def self.convert_from body, charset
411
+ charset = "utf-8" if charset =~ /UTF_?8/i
379
412
  begin
380
413
  raise MessageFormatError, "RubyMail decode returned a null body" unless body
381
414
  return body unless charset
382
- Iconv.iconv($encoding, charset, body).join
415
+ Iconv.iconv($encoding + "//IGNORE", charset, body + " ").join[0 .. -2]
383
416
  rescue Errno::EINVAL, Iconv::InvalidEncoding, Iconv::IllegalSequence, MessageFormatError => e
384
417
  Redwood::log "warning: error (#{e.class.name}) decoding message body from #{charset}: #{e.message}"
385
418
  File.open("sup-unable-to-decode.txt", "w") { |f| f.write body }
@@ -390,7 +423,7 @@ private
390
423
  ## parse the lines of text into chunk objects. the heuristics here
391
424
  ## need tweaking in some nice manner. TODO: move these heuristics
392
425
  ## into the classes themselves.
393
- def text_to_chunks lines
426
+ def text_to_chunks lines, encrypted
394
427
  state = :text # one of :text, :quote, or :sig
395
428
  chunks = []
396
429
  chunk_lines = []
@@ -402,7 +435,7 @@ private
402
435
  when :text
403
436
  newstate = nil
404
437
 
405
- if line =~ QUOTE_PATTERN || (line =~ QUOTE_START_PATTERN && (nextline =~ QUOTE_PATTERN || nextline =~ QUOTE_START_PATTERN))
438
+ if line =~ QUOTE_PATTERN || (line =~ QUOTE_START_PATTERN && nextline =~ QUOTE_PATTERN)
406
439
  newstate = :quote
407
440
  elsif line =~ SIG_PATTERN && (lines.length - i) < MAX_SIG_DISTANCE
408
441
  newstate = :sig
@@ -421,7 +454,7 @@ private
421
454
  when :quote
422
455
  newstate = nil
423
456
 
424
- if line =~ QUOTE_PATTERN || line =~ QUOTE_START_PATTERN #|| line =~ /^\s*$/
457
+ if line =~ QUOTE_PATTERN || (line =~ /^\s*$/ && nextline =~ QUOTE_PATTERN)
425
458
  chunk_lines << line
426
459
  elsif line =~ SIG_PATTERN && (lines.length - i) < MAX_SIG_DISTANCE
427
460
  newstate = :sig
@@ -442,11 +475,14 @@ private
442
475
  when :block_quote, :sig
443
476
  chunk_lines << line
444
477
  end
445
-
478
+
446
479
  if !@have_snippet && state == :text && (@snippet.nil? || @snippet.length < SNIPPET_LEN) && line !~ /[=\*#_-]{3,}/ && line !~ /^\s*$/
480
+ @snippet ||= ""
447
481
  @snippet += " " unless @snippet.empty?
448
482
  @snippet += line.gsub(/^\s+/, "").gsub(/[\r\n]/, "").gsub(/\s+/, " ")
449
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
450
486
  end
451
487
  end
452
488