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/contact.rb CHANGED
@@ -12,7 +12,7 @@ class ContactManager
12
12
  IO.foreach(fn) do |l|
13
13
  l =~ /^(\S+): (.*)$/ or raise "can't parse #{fn} line #{l.inspect}"
14
14
  aalias, addr = $1, $2
15
- p = Person.for addr
15
+ p = PersonManager.person_for addr, :definitive => true
16
16
  @p2a[p] = aalias
17
17
  @a2p[aalias] = p
18
18
  end
data/lib/sup/draft.rb CHANGED
@@ -42,6 +42,7 @@ end
42
42
 
43
43
  class DraftLoader < Source
44
44
  attr_accessor :dir
45
+ yaml_properties :cur_offset
45
46
 
46
47
  def initialize cur_offset=0
47
48
  dir = Redwood::DRAFT_DIR
@@ -119,6 +120,4 @@ private
119
120
  end
120
121
  end
121
122
 
122
- Redwood::register_yaml(DraftLoader, %w(cur_offset))
123
-
124
123
  end
data/lib/sup/imap.rb CHANGED
@@ -6,7 +6,7 @@ require 'rmail'
6
6
 
7
7
  ## fucking imap fucking sucks. what the FUCK kind of committee of
8
8
  ## dunces designed this shit.
9
-
9
+ ##
10
10
  ## imap talks about 'unique ids' for messages, to be used for
11
11
  ## cross-session identification. great---just what sup needs! except
12
12
  ## it turns out the uids can be invalidated every time the
@@ -19,13 +19,25 @@ require 'rmail'
19
19
  ## does. thus the so-called uids are absolutely useless and imap
20
20
  ## provides no cross-session way of uniquely identifying a
21
21
  ## message. but thanks for the "strong recommendation", guys!
22
-
22
+ ##
23
23
  ## so right now i'm using the 'internal date' and the size of each
24
24
  ## message to uniquely identify it, and i scan over the entire mailbox
25
25
  ## each time i open it to map those things to message ids. that can be
26
26
  ## slow for large mailboxes, and we'll just have to hope that there
27
27
  ## are no collisions. ho ho! a perfectly reasonable solution!
28
-
28
+ ##
29
+ ## and here's another thing. check out RFC2060 2.2.2 paragraph 5:
30
+ ##
31
+ ## A client MUST be prepared to accept any server response at all times.
32
+ ## This includes server data that was not requested.
33
+ ##
34
+ ## yeah. that totally makes a lot of sense. and once again, the idiocy
35
+ ## of the spec actually happens in practice. you'll request flags for
36
+ ## one message, and get it interspersed with a random bunch of flags
37
+ ## for some other messages, including a different set of flags for the
38
+ ## same message! totally ok by the imap spec. totally retarded by any
39
+ ## other metric.
40
+ ##
29
41
  ## fuck you, imap committee. you managed to design something nearly as
30
42
  ## shitty as mbox but goddamn THIRTY YEARS LATER.
31
43
  module Redwood
@@ -34,11 +46,13 @@ class IMAP < Source
34
46
  SCAN_INTERVAL = 60 # seconds
35
47
 
36
48
  ## upon these errors we'll try to rereconnect a few times
37
- RECOVERABLE_ERRORS = [ Errno::EPIPE, Errno::ETIMEDOUT ]
49
+ RECOVERABLE_ERRORS = [ Errno::EPIPE, Errno::ETIMEDOUT, OpenSSL::SSL::SSLError ]
38
50
 
39
51
  attr_accessor :username, :password
52
+ yaml_properties :uri, :username, :password, :cur_offset, :usual,
53
+ :archived, :id, :labels
40
54
 
41
- def initialize uri, username, password, last_idate=nil, usual=true, archived=false, id=nil
55
+ def initialize uri, username, password, last_idate=nil, usual=true, archived=false, id=nil, labels=[]
42
56
  raise ArgumentError, "username and password must be specified" unless username && password
43
57
  raise ArgumentError, "not an imap uri" unless uri =~ %r!imaps?://!
44
58
 
@@ -51,11 +65,19 @@ class IMAP < Source
51
65
  @imap_ids = {}
52
66
  @ids = []
53
67
  @last_scan = nil
54
- @labels = [:unread]
55
- @labels << mailbox.intern unless mailbox =~ /inbox/i
68
+ @labels = (labels || []).freeze
69
+ @say_id = nil
56
70
  @mutex = Mutex.new
57
71
  end
58
72
 
73
+ def self.suggest_labels_for path
74
+ if path =~ /inbox/i
75
+ [path.intern]
76
+ else
77
+ []
78
+ end
79
+ end
80
+
59
81
  def host; @parsed_uri.host; end
60
82
  def port; @parsed_uri.port || (ssl? ? 993 : 143); end
61
83
  def mailbox
@@ -88,6 +110,7 @@ class IMAP < Source
88
110
  def raw_header id
89
111
  unsynchronized_scan_mailbox
90
112
  header, flags = get_imap_fields id, 'RFC822.HEADER', 'FLAGS'
113
+ ## very bad. this is very very bad. very bad bad bad.
91
114
  header = header + "Status: RO\n" if flags.include? :Seen # fake an mbox-style read header # TODO: improve source-marked-as-read reporting system
92
115
  header.gsub(/\r\n/, "\n")
93
116
  end
@@ -115,9 +138,9 @@ class IMAP < Source
115
138
 
116
139
  return if last_id == @ids.length
117
140
 
118
- Redwood::log "fetching IMAP headers #{(@ids.length + 1) .. last_id}"
119
- values = safely { @imap.fetch((@ids.length + 1) .. last_id, ['RFC822.SIZE', 'INTERNALDATE']) }
120
- values.each do |v|
141
+ range = (@ids.length + 1) .. last_id
142
+ Redwood::log "fetching IMAP headers #{range}"
143
+ fetch(range, ['RFC822.SIZE', 'INTERNALDATE']).each do |v|
121
144
  id = make_id v
122
145
  @ids << id
123
146
  @imap_ids[id] = v.seqno
@@ -137,7 +160,7 @@ class IMAP < Source
137
160
  start.upto(ids.length - 1) do |i|
138
161
  id = ids[i]
139
162
  self.cur_offset = id
140
- yield id, @labels.clone
163
+ yield id, @labels
141
164
  end
142
165
  end
143
166
 
@@ -157,6 +180,24 @@ class IMAP < Source
157
180
 
158
181
  private
159
182
 
183
+ def fetch ids, fields
184
+ results = safely { @imap.fetch ids, fields }
185
+ good_results =
186
+ if ids.respond_to? :member?
187
+ results.find_all { |r| ids.member?(r.seqno) && fields.all? { |f| r.attr.member?(f) } }
188
+ else
189
+ results.find_all { |r| ids == r.seqno && fields.all? { |f| r.attr.member?(f) } }
190
+ end
191
+
192
+ if good_results.empty?
193
+ raise FatalSourceError, "no IMAP response for #{ids} containing all fields #{fields.join(', ')} (got #{results.size} results)"
194
+ elsif good_results.size < results.size
195
+ Redwood::log "Your IMAP server sucks. It sent #{results.size} results for a request for #{good_results.size} messages. What are you using, Binc?"
196
+ end
197
+
198
+ good_results
199
+ end
200
+
160
201
  def unsafe_connect
161
202
  say "Connecting to IMAP server #{host}:#{port}..."
162
203
 
@@ -209,6 +250,10 @@ private
209
250
 
210
251
  def make_id imap_stuff
211
252
  # use 7 digits for the size. why 7? seems nice.
253
+ %w(RFC822.SIZE INTERNALDATE).each do |w|
254
+ raise FatalSourceError, "requested data not in IMAP response: #{w}" unless imap_stuff.attr[w]
255
+ end
256
+
212
257
  msize, mdate = imap_stuff.attr['RFC822.SIZE'] % 10000000, Time.parse(imap_stuff.attr["INTERNALDATE"])
213
258
  sprintf("%d%07d", mdate.to_i, msize).to_i
214
259
  end
@@ -217,11 +262,11 @@ private
217
262
  imap_id = @imap_ids[id] or raise OutOfSyncSourceError, "Unknown message id #{id}"
218
263
 
219
264
  retried = false
220
- results = safely { @imap.fetch imap_id, (fields + ['RFC822.SIZE', 'INTERNALDATE']).uniq }.first
221
- got_id = make_id results
265
+ result = fetch(imap_id, (fields + ['RFC822.SIZE', 'INTERNALDATE']).uniq).first
266
+ got_id = make_id result
222
267
  raise OutOfSyncSourceError, "IMAP message mismatch: requested #{id}, got #{got_id}." unless got_id == id
223
268
 
224
- fields.map { |f| results.attr[f] }
269
+ fields.map { |f| result.attr[f] or raise FatalSourceError, "empty response from IMAP server: #{f}" }
225
270
  end
226
271
 
227
272
  ## execute a block, connected if unconnected, re-connected up to 3
@@ -233,20 +278,20 @@ private
233
278
  begin
234
279
  unsafe_connect unless @imap
235
280
  yield
236
- rescue *RECOVERABLE_ERRORS
281
+ rescue *RECOVERABLE_ERRORS => e
237
282
  if (retries += 1) <= 3
238
283
  @imap = nil
284
+ Redwood::log "got #{e.class.name}: #{e.message.inspect}"
285
+ sleep 2
239
286
  retry
240
287
  end
241
288
  raise
242
289
  end
243
- rescue SocketError, Net::IMAP::Error, SystemCallError, IOError => e
244
- raise FatalSourceError, "While communicating with IMAP server: #{e.message}"
290
+ rescue SocketError, Net::IMAP::Error, SystemCallError, IOError, OpenSSL::SSL::SSLError => e
291
+ raise FatalSourceError, "While communicating with IMAP server (type #{e.class.name}): #{e.message.inspect}"
245
292
  end
246
293
  end
247
294
 
248
295
  end
249
296
 
250
- Redwood::register_yaml(IMAP, %w(uri username password cur_offset usual archived id))
251
-
252
297
  end
data/lib/sup/index.rb CHANGED
@@ -7,6 +7,14 @@ require 'ferret'
7
7
  module Redwood
8
8
 
9
9
  class Index
10
+ class LockError < StandardError
11
+ def initialize h
12
+ @h = h
13
+ end
14
+
15
+ def method_missing m; @h[m.to_s] end
16
+ end
17
+
10
18
  include Singleton
11
19
 
12
20
  attr_reader :index
@@ -16,14 +24,79 @@ class Index
16
24
  @sources_dirty = false
17
25
 
18
26
  wsa = Ferret::Analysis::WhiteSpaceAnalyzer.new false
19
- sa = Ferret::Analysis::StandardAnalyzer.new Ferret::Analysis::FULL_ENGLISH_STOP_WORDS, true
27
+ sa = Ferret::Analysis::StandardAnalyzer.new [], true
20
28
  @analyzer = Ferret::Analysis::PerFieldAnalyzer.new wsa
21
29
  @analyzer[:body] = sa
22
- @qparser ||= Ferret::QueryParser.new :default_field => :body, :analyzer => @analyzer
30
+ @analyzer[:subject] = sa
31
+ @qparser ||= Ferret::QueryParser.new :default_field => :body, :analyzer => @analyzer, :or_default => false
32
+ @lock = Lockfile.new lockfile, :retries => 0, :max_age => nil
23
33
 
24
34
  self.class.i_am_the_instance self
25
35
  end
26
36
 
37
+ def lockfile; File.join @dir, "lock" end
38
+
39
+ def lock
40
+ Redwood::log "locking #{lockfile}..."
41
+ begin
42
+ @lock.lock
43
+ rescue Lockfile::MaxTriesLockError
44
+ raise LockError, @lock.lockinfo_on_disk
45
+ end
46
+ end
47
+
48
+ def start_lock_update_thread
49
+ @lock_update_thread = Redwood::reporting_thread do
50
+ while true
51
+ sleep 30
52
+ @lock.touch_yourself
53
+ end
54
+ end
55
+ end
56
+
57
+ def stop_lock_update_thread
58
+ @lock_update_thread.kill if @lock_update_thread
59
+ @lock_update_thread = nil
60
+ end
61
+
62
+ def fancy_lock_error_message_for e
63
+ secs = Time.now - e.mtime
64
+ mins = secs.to_i / 60
65
+ time =
66
+ if mins == 0
67
+ "#{secs.to_i} seconds"
68
+ else
69
+ "#{mins} minutes"
70
+ end
71
+
72
+ <<EOS
73
+ Error: the sup index is locked by another process! User '#{e.user}' on
74
+ host '#{e.host}' is running #{e.pname} with pid #{e.pid}. The process was alive
75
+ as of #{time} ago.
76
+ EOS
77
+ end
78
+
79
+ def lock_or_die
80
+ begin
81
+ lock
82
+ rescue LockError => e
83
+ $stderr.puts fancy_lock_error_message_for(e)
84
+ $stderr.puts <<EOS
85
+
86
+ You can wait for the process to finish, or, if it crashed and left a
87
+ stale lock file behind, you can manually delete #{@lock.path}.
88
+ EOS
89
+ exit
90
+ end
91
+ end
92
+
93
+ def unlock
94
+ if @lock && @lock.locked?
95
+ Redwood::log "unlocking #{lockfile}..."
96
+ @lock.unlock
97
+ end
98
+ end
99
+
27
100
  def load
28
101
  load_sources
29
102
  load_index
@@ -116,7 +189,7 @@ class Index
116
189
  end
117
190
 
118
191
  def save_index fn=File.join(@dir, "ferret")
119
- # don't have to do anything, apparently
192
+ # don't have to do anything, apparently
120
193
  end
121
194
 
122
195
  def contains_id? id
@@ -143,25 +216,25 @@ class Index
143
216
 
144
217
  def num_results_for opts={}
145
218
  return 0 if @index.size == 0 # otherwise ferret barfs ###TODO: remove this once my ferret patch is accepted
219
+
146
220
  q = build_query opts
147
- index.search(q).total_hits
221
+ index.search(q, :limit => 1).total_hits
148
222
  end
149
223
 
150
224
  ## yield all messages in the thread containing 'm' by repeatedly
151
- ## querying the index. uields pairs of message ids and
225
+ ## querying the index. yields pairs of message ids and
152
226
  ## message-building lambdas, so that building an unwanted message
153
227
  ## can be skipped in the block if desired.
154
228
  ##
155
229
  ## stops loading any thread if a message with a :killed flag is found.
156
-
157
230
  SAME_SUBJECT_DATE_LIMIT = 7
158
231
  def each_message_in_thread_for m, opts={}
232
+ Redwood::log "Building thread for #{m.id}: #{m.subj}"
159
233
  messages = {}
160
234
  searched = {}
161
235
  num_queries = 0
162
236
 
163
- ## todo: make subject querying configurable
164
- if true # do subject queries
237
+ if $config[:thread_by_subject] # do subject queries
165
238
  date_min = m.date - (SAME_SUBJECT_DATE_LIMIT * 12 * 3600)
166
239
  date_max = m.date + (SAME_SUBJECT_DATE_LIMIT * 12 * 3600)
167
240
 
@@ -196,14 +269,15 @@ class Index
196
269
  break if opts[:limit] && messages.size >= opts[:limit]
197
270
  break if @index[docid][:label].split(/\s+/).include? "killed" unless opts[:load_killed]
198
271
  mid = @index[docid][:message_id]
199
- unless messages.member? mid
272
+ unless messages.member?(mid)
273
+ Redwood::log "got #{mid} as a child of #{id}"
200
274
  messages[mid] ||= lambda { build_message docid }
201
275
  refs = @index[docid][:refs].split(" ")
202
276
  pending += refs
203
277
  end
204
278
  end
205
279
  end
206
- Redwood::log "ran #{num_queries} queries to build thread of #{messages.size} messages for #{m.id}" if num_queries > 0
280
+ Redwood::log "ran #{num_queries} queries to build thread of #{messages.size + 1} messages for #{m.id}" if num_queries > 0
207
281
  messages.each { |mid, builder| yield mid, builder }
208
282
  end
209
283
 
@@ -261,11 +335,9 @@ class Index
261
335
  t = @index[docid][:to]
262
336
 
263
337
  if AccountManager.is_account_email? f
264
- t.split(" ").each { |e| #Redwood::log "adding #{e} because there's a message to him from account email #{f}";
265
- contacts[Person.for(e)] = true }
338
+ t.split(" ").each { |e| contacts[PersonManager.person_for(e)] = true }
266
339
  else
267
- #Redwood::log "adding from #{f} because there's a message from him to #{t}"
268
- contacts[Person.for(f)] = true
340
+ contacts[PersonManager.person_for(f)] = true
269
341
  end
270
342
  end
271
343
 
@@ -278,6 +350,13 @@ class Index
278
350
  @sources_dirty = false
279
351
  end
280
352
 
353
+ def has_any_from_source_with_label? source, label
354
+ q = Ferret::Search::BooleanQuery.new
355
+ q.add_query Ferret::Search::TermQuery.new("source_id", source.id.to_s), :must
356
+ q.add_query Ferret::Search::TermQuery.new("label", label.to_s), :must
357
+ index.search(q, :limit => 1).total_hits > 0
358
+ end
359
+
281
360
  protected
282
361
 
283
362
  def parse_user_query_string str; @qparser.parse str; end
@@ -306,9 +385,9 @@ protected
306
385
  bakfn = fn + ".bak"
307
386
  if File.exists? fn
308
387
  File.chmod 0600, fn
309
- FileUtils.mv fn, bakfn, :force => true unless File.exists?(bakfn) && File.size(bakfn) > File.size(fn)
388
+ FileUtils.mv fn, bakfn, :force => true unless File.exists?(bakfn) && File.size(fn) == 0
310
389
  end
311
- Redwood::save_yaml_obj @sources.values, fn
390
+ Redwood::save_yaml_obj @sources.values.sort_by { |s| s.id.to_i }, fn, true
312
391
  File.chmod 0600, fn
313
392
  end
314
393
  @sources_dirty = false
data/lib/sup/keymap.rb CHANGED
@@ -81,7 +81,7 @@ class Keymap
81
81
  next if valid_keys.empty?
82
82
  [valid_keys.map { |k| keysym_to_string k }.join(", "), help]
83
83
  end.compact
84
- llen = lines.map { |a, b| a.length }.max
84
+ llen = lines.max_of { |a, b| a.length }
85
85
  lines.map { |a, b| sprintf " %#{llen}s : %s", a, b }.join("\n")
86
86
  end
87
87
  end
data/lib/sup/label.rb CHANGED
@@ -8,10 +8,10 @@ class LabelManager
8
8
  RESERVED_LABELS = [ :starred, :spam, :draft, :unread, :killed, :sent, :deleted ]
9
9
 
10
10
  ## labels which it nonetheless makes sense to search for by
11
- LISTABLE_LABELS = [ :starred, :spam, :draft, :sent, :killed, :deleted ]
11
+ LISTABLE_RESERVED_LABELS = [ :starred, :spam, :draft, :sent, :killed, :deleted ]
12
12
 
13
13
  ## labels that will never be displayed to the user
14
- HIDDEN_LABELS = [ :starred, :unread ]
14
+ HIDDEN_RESERVED_LABELS = [ :starred, :unread ]
15
15
 
16
16
  def initialize fn
17
17
  @fn = fn
@@ -22,15 +22,41 @@ class LabelManager
22
22
  []
23
23
  end
24
24
  @labels = {}
25
+ @modified = false
25
26
  labels.each { |t| @labels[t] = true }
26
27
 
27
28
  self.class.i_am_the_instance self
28
29
  end
29
30
 
30
- def user_labels; @labels.keys; end
31
- def << t; @labels[t] = true unless @labels.member?(t) || RESERVED_LABELS.member?(t); end
32
- def delete t; @labels.delete t; end
31
+ ## all listable (user-defined and system listable) labels, ordered
32
+ ## nicely and converted to pretty strings. use #label_for to recover
33
+ ## the original label.
34
+ def listable_label_strings
35
+ LISTABLE_RESERVED_LABELS.sort_by { |l| l.to_s }.map { |l| l.to_s.ucfirst } +
36
+ @labels.keys.map { |l| l.to_s }.sort
37
+ end
38
+
39
+ ## reverse the label->string mapping, for convenience!
40
+ def label_for string
41
+ string.downcase.intern
42
+ end
43
+
44
+ def << t
45
+ t = t.intern unless t.is_a? Symbol
46
+ unless @labels.member?(t) || RESERVED_LABELS.member?(t)
47
+ @labels[t] = true
48
+ @modified = true
49
+ end
50
+ end
51
+
52
+ def delete t
53
+ if @labels.delete t
54
+ @modified = true
55
+ end
56
+ end
57
+
33
58
  def save
59
+ return unless @modified
34
60
  File.open(@fn, "w") { |f| f.puts @labels.keys }
35
61
  end
36
62
  end