sup 0.0.8 → 0.1

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 (57) hide show
  1. data/HACKING +6 -36
  2. data/History.txt +11 -0
  3. data/Manifest.txt +5 -0
  4. data/README.txt +13 -31
  5. data/Rakefile +3 -3
  6. data/bin/sup +167 -89
  7. data/bin/sup-add +39 -29
  8. data/bin/sup-config +57 -31
  9. data/bin/sup-sync +60 -54
  10. data/bin/sup-sync-back +143 -0
  11. data/doc/FAQ.txt +56 -19
  12. data/doc/Philosophy.txt +34 -33
  13. data/doc/TODO +76 -46
  14. data/doc/UserGuide.txt +142 -122
  15. data/lib/sup.rb +76 -36
  16. data/lib/sup/account.rb +27 -19
  17. data/lib/sup/buffer.rb +130 -44
  18. data/lib/sup/contact.rb +1 -1
  19. data/lib/sup/draft.rb +1 -2
  20. data/lib/sup/imap.rb +64 -19
  21. data/lib/sup/index.rb +95 -16
  22. data/lib/sup/keymap.rb +1 -1
  23. data/lib/sup/label.rb +31 -5
  24. data/lib/sup/maildir.rb +7 -5
  25. data/lib/sup/mbox.rb +34 -15
  26. data/lib/sup/mbox/loader.rb +30 -12
  27. data/lib/sup/mbox/ssh-loader.rb +7 -5
  28. data/lib/sup/message.rb +93 -44
  29. data/lib/sup/modes/buffer-list-mode.rb +1 -1
  30. data/lib/sup/modes/completion-mode.rb +55 -0
  31. data/lib/sup/modes/compose-mode.rb +6 -25
  32. data/lib/sup/modes/contact-list-mode.rb +1 -1
  33. data/lib/sup/modes/edit-message-mode.rb +119 -29
  34. data/lib/sup/modes/file-browser-mode.rb +108 -0
  35. data/lib/sup/modes/forward-mode.rb +3 -20
  36. data/lib/sup/modes/inbox-mode.rb +9 -12
  37. data/lib/sup/modes/label-list-mode.rb +28 -46
  38. data/lib/sup/modes/label-search-results-mode.rb +1 -16
  39. data/lib/sup/modes/line-cursor-mode.rb +44 -5
  40. data/lib/sup/modes/person-search-results-mode.rb +1 -16
  41. data/lib/sup/modes/reply-mode.rb +18 -31
  42. data/lib/sup/modes/resume-mode.rb +6 -6
  43. data/lib/sup/modes/scroll-mode.rb +6 -5
  44. data/lib/sup/modes/search-results-mode.rb +6 -17
  45. data/lib/sup/modes/thread-index-mode.rb +70 -28
  46. data/lib/sup/modes/thread-view-mode.rb +65 -29
  47. data/lib/sup/person.rb +71 -30
  48. data/lib/sup/poll.rb +13 -4
  49. data/lib/sup/rfc2047.rb +61 -0
  50. data/lib/sup/sent.rb +7 -5
  51. data/lib/sup/source.rb +12 -9
  52. data/lib/sup/suicide.rb +36 -0
  53. data/lib/sup/tagger.rb +6 -6
  54. data/lib/sup/textfield.rb +76 -14
  55. data/lib/sup/thread.rb +97 -123
  56. data/lib/sup/util.rb +167 -1
  57. metadata +30 -5
data/lib/sup/maildir.rb CHANGED
@@ -11,20 +11,24 @@ module Redwood
11
11
  class Maildir < Source
12
12
  SCAN_INTERVAL = 30 # seconds
13
13
 
14
- def initialize uri, last_date=nil, usual=true, archived=false, id=nil
15
- super
14
+ yaml_properties :uri, :cur_offset, :usual, :archived, :id, :labels
15
+ def initialize uri, last_date=nil, usual=true, archived=false, id=nil, labels=[]
16
+ super uri, last_date, usual, archived, id
16
17
  uri = URI(uri)
17
18
 
18
19
  raise ArgumentError, "not a maildir URI" unless uri.scheme == "maildir"
19
20
  raise ArgumentError, "maildir URI cannot have a host: #{uri.host}" if uri.host
20
21
 
21
22
  @dir = uri.path
23
+ @labels = (labels || []).freeze
22
24
  @ids = []
23
25
  @ids_to_fns = {}
24
26
  @last_scan = nil
25
27
  @mutex = Mutex.new
26
28
  end
27
29
 
30
+ def self.suggest_labels_for path; [] end
31
+
28
32
  def check
29
33
  scan_mailbox
30
34
  start = @ids.index(cur_offset || start_offset) or raise OutOfSyncSourceError, "Unknown message id #{cur_offset || start_offset}." # couldn't find the most recent email
@@ -89,7 +93,7 @@ class Maildir < Source
89
93
  start.upto(@ids.length - 1) do |i|
90
94
  id = @ids[i]
91
95
  self.cur_offset = id
92
- yield id, (@ids_to_fns[id] =~ /,.*R.*$/ ? [] : [:unread])
96
+ yield id, @labels + (@ids_to_fns[id] =~ /,.*R.*$/ ? [] : [:unread])
93
97
  end
94
98
  end
95
99
 
@@ -122,6 +126,4 @@ private
122
126
  end
123
127
  end
124
128
 
125
- Redwood::register_yaml(Maildir, %w(uri cur_offset usual archived id))
126
-
127
129
  end
data/lib/sup/mbox.rb CHANGED
@@ -1,10 +1,14 @@
1
1
  require "sup/mbox/loader"
2
2
  require "sup/mbox/ssh-file"
3
3
  require "sup/mbox/ssh-loader"
4
+ require "sup/rfc2047"
4
5
 
5
6
  module Redwood
6
7
 
7
- ## some utility functions
8
+ ## some utility functions. actually these are not mbox-specific at all
9
+ ## and should be moved somewhere else.
10
+ ##
11
+ ## TODO: move functionality to somewhere better, like message.rb
8
12
  module MBox
9
13
  BREAK_RE = /^From \S+/
10
14
 
@@ -16,31 +20,46 @@ module MBox
16
20
  ## when scanning over large mbox files.
17
21
  while(line = f.gets)
18
22
  case line
19
- when /^(From):\s+(.*)$/i,
20
- /^(To):\s+(.*)$/i,
21
- /^(Cc):\s+(.*)$/i,
22
- /^(Bcc):\s+(.*)$/i,
23
- /^(Subject):\s+(.*)$/i,
24
- /^(Date):\s+(.*)$/i,
25
- /^(Message-Id):\s+<(.*)>$/i,
26
- /^(References):\s+(.*)$/i,
27
- /^(In-Reply-To):\s+(.*)$/i,
28
- /^(Reply-To):\s+(.*)$/i,
29
- /^(List-Post):\s+(.*)$/i,
30
- /^(Status):\s+(.*)$/i: header[last = $1] = $2
23
+ when /^(From):\s+(.*?)\s*$/i,
24
+ /^(To):\s+(.*?)\s*$/i,
25
+ /^(Cc):\s+(.*?)\s*$/i,
26
+ /^(Bcc):\s+(.*?)\s*$/i,
27
+ /^(Subject):\s+(.*?)\s*$/i,
28
+ /^(Date):\s+(.*?)\s*$/i,
29
+ /^(References):\s+(.*?)\s*$/i,
30
+ /^(In-Reply-To):\s+(.*?)\s*$/i,
31
+ /^(Reply-To):\s+(.*?)\s*$/i,
32
+ /^(List-Post):\s+(.*?)\s*$/i,
33
+ /^(Status):\s+(.*?)\s*$/i: header[last = $1] = $2
34
+ when /^(Message-Id):\s+(.*?)\s*$/i: header[mid_field = last = $1] = $2
31
35
 
32
36
  ## these next three can occur multiple times, and we want the
33
37
  ## first one
34
38
  when /^(Delivered-To):\s+(.*)$/i,
35
39
  /^(X-Original-To):\s+(.*)$/i,
36
- /^(Envelope-To):\s+(.*)$/i: header[last = $1.downcase] ||= $2
40
+ /^(Envelope-To):\s+(.*)$/i: header[last = $1] ||= $2
37
41
 
38
42
  when /^$/: break
39
- when /:/: last = nil
43
+ when /:/: last = nil # some other header we don't care about
40
44
  else
41
45
  header[last] += " " + line.chomp.gsub(/^\s+/, "") if last
42
46
  end
43
47
  end
48
+
49
+ if mid_field && header[mid_field] && header[mid_field] =~ /<(.*?)>/
50
+ header[mid_field] = $1
51
+ end
52
+
53
+ header.each do |k, v|
54
+ next unless Rfc2047.is_encoded? v
55
+ header[k] =
56
+ begin
57
+ Rfc2047.decode_to $encoding, v
58
+ rescue Errno::EINVAL, Iconv::InvalidEncoding, Iconv::IllegalSequence => e
59
+ Redwood::log "warning: error decoding RFC 2047 header: #{e.message}"
60
+ v
61
+ end
62
+ end
44
63
  header
45
64
  end
46
65
 
@@ -5,26 +5,36 @@ module Redwood
5
5
  module MBox
6
6
 
7
7
  class Loader < Source
8
- def initialize uri_or_fp, start_offset=nil, usual=true, archived=false, id=nil
9
- super
8
+ yaml_properties :uri, :cur_offset, :usual, :archived, :id, :labels
9
+ def initialize uri_or_fp, start_offset=nil, usual=true, archived=false, id=nil, labels=[]
10
+ super uri_or_fp, start_offset, usual, archived, id
10
11
 
11
12
  @mutex = Mutex.new
12
- @labels = [:unread]
13
+ @labels = (labels || []).freeze
13
14
 
14
15
  case uri_or_fp
15
16
  when String
16
17
  uri = URI(uri_or_fp)
17
18
  raise ArgumentError, "not an mbox uri" unless uri.scheme == "mbox"
18
19
  raise ArgumentError, "mbox uri ('#{uri}') cannot have a host: #{uri.host}" if uri.host
19
- ## heuristic: use the filename as a label, unless the file
20
- ## has a path that probably represents an inbox.
21
- @labels << File.basename(uri.path).intern unless File.dirname(uri.path) =~ /\b(var|usr|spool)\b/
22
20
  @f = File.open uri.path
23
21
  else
24
22
  @f = uri_or_fp
25
23
  end
26
24
  end
27
25
 
26
+ def file_path; URI(uri).path end
27
+
28
+ def self.suggest_labels_for path
29
+ ## heuristic: use the filename as a label, unless the file
30
+ ## has a path that probably represents an inbox.
31
+ if File.dirname(path) =~ /\b(var|usr|spool)\b/
32
+ []
33
+ else
34
+ [File.basename(path).intern]
35
+ end
36
+ end
37
+
28
38
  def check
29
39
  if (cur_offset ||= start_offset) > end_offset
30
40
  raise OutOfSyncSourceError, "mbox file is smaller than last recorded message offset. Messages have probably been deleted by another client."
@@ -73,14 +83,24 @@ class Loader < Source
73
83
 
74
84
  def raw_full_message offset
75
85
  ret = ""
86
+ each_raw_full_message_line(offset) { |l| ret += l }
87
+ ret
88
+ end
89
+
90
+ ## apparently it's a million times faster to call this directly if
91
+ ## we're just moving messages around on disk, than reading things
92
+ ## into memory with raw_full_message.
93
+ ##
94
+ ## i hoped never to have to move shit around on disk but
95
+ ## sup-sync-back has to do it.
96
+ def each_raw_full_message_line offset
76
97
  @mutex.synchronize do
77
98
  @f.seek offset
78
- @f.gets # skip mbox header
99
+ yield @f.gets
79
100
  until @f.eof? || (l = @f.gets) =~ BREAK_RE
80
- ret += l
101
+ yield l
81
102
  end
82
103
  end
83
- ret
84
104
  end
85
105
 
86
106
  def next
@@ -117,11 +137,9 @@ class Loader < Source
117
137
  end
118
138
 
119
139
  self.cur_offset = next_offset
120
- [returned_offset, @labels.clone]
140
+ [returned_offset, @labels]
121
141
  end
122
142
  end
123
143
 
124
- Redwood::register_yaml(Loader, %w(uri cur_offset usual archived id))
125
-
126
144
  end
127
145
  end
@@ -6,7 +6,10 @@ module MBox
6
6
  class SSHLoader < Source
7
7
  attr_accessor :username, :password
8
8
 
9
- def initialize uri, username=nil, password=nil, start_offset=nil, usual=true, archived=false, id=nil
9
+ yaml_properties :uri, :username, :password, :cur_offset, :usual,
10
+ :archived, :id, :labels
11
+
12
+ def initialize uri, username=nil, password=nil, start_offset=nil, usual=true, archived=false, id=nil, labels=[]
10
13
  raise ArgumentError, "not an mbox+ssh uri: #{uri.inspect}" unless uri =~ %r!^mbox\+ssh://!
11
14
 
12
15
  super uri, start_offset, usual, archived, id
@@ -16,6 +19,7 @@ class SSHLoader < Source
16
19
  @password = password
17
20
  @uri = uri
18
21
  @cur_offset = start_offset
22
+ @labels = (labels || []).freeze
19
23
 
20
24
  opts = {}
21
25
  opts[:username] = @username if @username
@@ -26,10 +30,10 @@ class SSHLoader < Source
26
30
 
27
31
  ## heuristic: use the filename as a label, unless the file
28
32
  ## has a path that probably represents an inbox.
29
- @labels = [:unread]
30
- @labels << File.basename(filename).intern unless File.dirname(filename) =~ /\b(var|usr|spool)\b/
31
33
  end
32
34
 
35
+ def self.suggest_labels_for path; Loader.suggest_labels_for(path) end
36
+
33
37
  def connect; safely { @f.connect }; end
34
38
  def host; @parsed_uri.host; end
35
39
  def filename; @parsed_uri.path[1..-1] end
@@ -66,7 +70,5 @@ class SSHLoader < Source
66
70
  end
67
71
  end
68
72
 
69
- Redwood::register_yaml(SSHLoader, %w(uri username password cur_offset usual archived id))
70
-
71
73
  end
72
74
  end
data/lib/sup/message.rb CHANGED
@@ -1,5 +1,6 @@
1
1
  require 'tempfile'
2
2
  require 'time'
3
+ require 'iconv'
3
4
 
4
5
  module Redwood
5
6
 
@@ -12,9 +13,6 @@ class MessageFormatError < StandardError; end
12
13
  ## i would like, for example, to be able to add in a ruby-talk
13
14
  ## specific module that would detect and link to /ruby-talk:\d+/
14
15
  ## sequences in the text of an email. (how sweet would that be?)
15
- ##
16
- ## TODO: integrate with user's addressbook to render names
17
- ## appropriately.
18
16
  class Message
19
17
  SNIPPET_LEN = 80
20
18
  WRAP_LEN = 80 # wrap at this width
@@ -28,28 +26,30 @@ class Message
28
26
  end
29
27
 
30
28
  class Attachment
31
- attr_reader :content_type, :desc, :filename
32
- def initialize content_type, desc, part
29
+ attr_reader :content_type, :filename, :content, :lines
30
+ def initialize content_type, filename, content
33
31
  @content_type = content_type
34
- @desc = desc
35
- @part = part
36
- @file = nil
37
- desc =~ /filename="(.*?)"/ && @filename = $1
32
+ @filename = filename
33
+ @content = content
34
+
35
+ if inlineable?
36
+ @lines = to_s.split("\n")
37
+ end
38
38
  end
39
39
 
40
40
  def view!
41
- unless @file
42
- @file = Tempfile.new "redwood.attachment"
43
- @file.print self
44
- @file.close
45
- end
41
+ file = Tempfile.new "redwood.attachment"
42
+ file.print raw_content
43
+ file.close
46
44
 
47
- ## TODO: handle unknown mime-types
48
- system "/usr/bin/run-mailcap --action=view #{@content_type}:#{@file.path}"
45
+ system "/usr/bin/run-mailcap --action=view #{@content_type}:#{file.path} >& /dev/null"
49
46
  $? == 0
50
47
  end
51
48
 
52
- def to_s; @part.decode; end
49
+ def to_s; Message.decode_and_convert @content; end
50
+ def raw_content; @content.decode end
51
+
52
+ def inlineable?; @content_type =~ /^text\/plain/ end
53
53
  end
54
54
 
55
55
  class Text
@@ -74,10 +74,12 @@ class Message
74
74
  end
75
75
  end
76
76
 
77
+
77
78
  QUOTE_PATTERN = /^\s{0,4}[>|\}]/
78
79
  BLOCK_QUOTE_PATTERN = /^-----\s*Original Message\s*----+$/
79
80
  QUOTE_START_PATTERN = /(^\s*Excerpts from)|(^\s*In message )|(^\s*In article )|(^\s*Quoting )|((wrote|writes|said|says)\s*:\s*$)/
80
81
  SIG_PATTERN = /(^-- ?$)|(^\s*----------+\s*$)|(^\s*_________+\s*$)|(^\s*--~--~-)/
82
+
81
83
  MAX_SIG_DISTANCE = 15 # lines from the end
82
84
  DEFAULT_SUBJECT = "(missing subject)"
83
85
  DEFAULT_SENDER = "(missing sender)"
@@ -95,7 +97,7 @@ class Message
95
97
  @source_info = opts[:source_info] or raise ArgumentError, "source_info can't be nil"
96
98
  @snippet = opts[:snippet] || ""
97
99
  @have_snippet = !opts[:snippet].nil?
98
- @labels = opts[:labels] || []
100
+ @labels = [] + (opts[:labels] || [])
99
101
  @dirty = false
100
102
  @chunks = nil
101
103
 
@@ -118,17 +120,17 @@ class Message
118
120
  end
119
121
 
120
122
  @subj = header.member?("subject") ? header["subject"].gsub(/\s+/, " ").gsub(/\s+$/, "") : DEFAULT_SUBJECT
121
- @from = Person.for header["from"]
122
- @to = Person.for_several header["to"]
123
- @cc = Person.for_several header["cc"]
124
- @bcc = Person.for_several header["bcc"]
123
+ @from = PersonManager.person_for header["from"]
124
+ @to = PersonManager.people_for header["to"]
125
+ @cc = PersonManager.people_for header["cc"]
126
+ @bcc = PersonManager.people_for header["bcc"]
125
127
  @id = header["message-id"]
126
128
  @refs = (header["references"] || "").gsub(/[<>]/, "").split(/\s+/).flatten
127
129
  @replytos = (header["in-reply-to"] || "").scan(/<(.*?)>/).flatten
128
- @replyto = Person.for header["reply-to"]
130
+ @replyto = PersonManager.person_for header["reply-to"]
129
131
  @list_address =
130
132
  if header["list-post"]
131
- @list_address = Person.for header["list-post"].gsub(/^<mailto:|>$/, "")
133
+ @list_address = PersonManager.person_for header["list-post"].gsub(/^<mailto:|>$/, "")
132
134
  else
133
135
  nil
134
136
  end
@@ -140,7 +142,7 @@ class Message
140
142
 
141
143
  def snippet; @snippet || chunks && @snippet; end
142
144
  def is_list_message?; !@list_address.nil?; end
143
- def is_draft?; DraftLoader === @source; end
145
+ def is_draft?; @source.is_a? DraftLoader; end
144
146
  def draft_filename
145
147
  raise "not a draft" unless is_draft?
146
148
  @source.fn_for_offset @source_info
@@ -190,6 +192,7 @@ class Message
190
192
  read_header @source.load_header(@source_info)
191
193
  message_to_chunks @source.load_message(@source_info)
192
194
  rescue SourceError, SocketError, MessageFormatError => e
195
+ Redwood::log "problem getting messages from #{@source}: #{e.message}"
193
196
  ## we need force_to_top here otherwise this window will cover
194
197
  ## up the error message one
195
198
  Redwood::report_broken_sources :force_to_top => true
@@ -221,6 +224,7 @@ EOS
221
224
  begin
222
225
  @source.raw_header @source_info
223
226
  rescue SourceError => e
227
+ Redwood::log "problem getting messages from #{@source}: #{e.message}"
224
228
  error_message e.message
225
229
  end
226
230
  end
@@ -229,6 +233,7 @@ EOS
229
233
  begin
230
234
  @source.raw_full_message @source_info
231
235
  rescue SourceError => e
236
+ Redwood::log "problem getting messages from #{@source}: #{e.message}"
232
237
  error_message(e.message)
233
238
  end
234
239
  end
@@ -260,22 +265,71 @@ EOS
260
265
 
261
266
  private
262
267
 
263
- ## everything RubyMail-specific goes here.
268
+ ## here's where we handle decoding mime attachments. unfortunately
269
+ ## but unsurprisingly, the world of mime attachments is a bit of a
270
+ ## mess. as an empiricist, i'm basing the following behavior on
271
+ ## observed mail rather than on interpretations of rfcs, so probably
272
+ ## this will have to be tweaked.
273
+ ##
274
+ ## the general behavior i want is: ignore content-disposition, at
275
+ ## least in so far as it suggests something being inline vs being an
276
+ ## attachment. (because really, that should be the recipient's
277
+ ## decision to make.) if a mime part is text/plain, then decode it
278
+ ## and display it inline. if it has associated filename, then make
279
+ ## it collapsable and individually saveable; otherwise, treat it as
280
+ ## regular body text.
281
+ ##
282
+ ## so, in contrast to mutt, the user is not exposed to the workings
283
+ ## of the gruesome slaughterhouse and sausage factory that is a
284
+ ## mime-encoded message, but need only see the delicious end
285
+ ## product.
264
286
  def message_to_chunks m
265
- ret = [] <<
266
- case m.header.content_type
267
- when "text/plain", nil
268
- m.body && body = m.decode or raise MessageFormatError, "For some bizarre reason, RubyMail was unable to parse this message."
269
- text_to_chunks body.normalize_whitespace.split("\n")
270
- when /^multipart\//
271
- nil
287
+ if m.multipart?
288
+ m.body.map { |p| message_to_chunks p }.flatten.compact # recurse
289
+ else
290
+ filename =
291
+ ## first, paw through the headers looking for a filename
292
+ if m.header["Content-Disposition"] &&
293
+ m.header["Content-Disposition"] =~ /filename="?(.*?[^\\])("|;|$)/
294
+ $1
295
+ elsif m.header["Content-Type"] &&
296
+ m.header["Content-Type"] =~ /name=(.*?)(;|$)/
297
+ $1
298
+
299
+ ## haven't found one, but it's a non-text message. fake
300
+ ## it.
301
+ elsif m.header["Content-Type"] && m.header["Content-Type"] !~ /^text\/plain/
302
+ "sup-attachment-#{Time.now.to_i}-#{rand 10000}"
303
+ end
304
+
305
+ ## if there's a filename, we'll treat it as an attachment.
306
+ if filename
307
+ [Attachment.new(m.header.content_type, filename, m)]
308
+
309
+ ## otherwise, it's body text
272
310
  else
273
- disp = m.header["Content-Disposition"] || ""
274
- Attachment.new m.header.content_type, disp.gsub(/[\s\n]+/, " "), m
311
+ body = Message.decode_and_convert m
312
+ text_to_chunks body.normalize_whitespace.split("\n")
275
313
  end
276
-
277
- m.each_part { |p| ret << message_to_chunks(p) } if m.multipart?
278
- ret.compact.flatten
314
+ end
315
+ end
316
+
317
+ def self.decode_and_convert m
318
+ charset =
319
+ if m.header.field?("content-type") && m.header.fetch("content-type") =~ /charset=(.*?)(;|$)/
320
+ $1
321
+ end
322
+
323
+ m.body && body = m.decode or raise MessageFormatError, "For some bizarre reason, RubyMail was unable to parse this message."
324
+
325
+ if charset
326
+ begin
327
+ body = Iconv.iconv($encoding, charset, body).join
328
+ rescue Errno::EINVAL, Iconv::InvalidEncoding, Iconv::IllegalSequence => e
329
+ Redwood::log "warning: error decoding message body from #{charset}: #{e.message}"
330
+ end
331
+ end
332
+ body
279
333
  end
280
334
 
281
335
  ## parse the lines of text into chunk objects. the heuristics here
@@ -323,8 +377,6 @@ private
323
377
  if newstate
324
378
  if chunk_lines.empty?
325
379
  # nothing
326
- elsif chunk_lines.size == 1
327
- chunks << Text.new(chunk_lines) # forget about one-line quotes
328
380
  else
329
381
  chunks << Quote.new(chunk_lines)
330
382
  end
@@ -332,10 +384,7 @@ private
332
384
  state = newstate
333
385
  end
334
386
 
335
- when :block_quote
336
- chunk_lines << line
337
-
338
- when :sig
387
+ when :block_quote, :sig
339
388
  chunk_lines << line
340
389
  end
341
390