sup 0.1 → 0.2
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.
- data/History.txt +9 -0
- data/Manifest.txt +5 -1
- data/Rakefile +2 -1
- data/bin/sup +27 -10
- data/bin/sup-add +2 -1
- data/bin/sup-sync-back +51 -23
- data/doc/FAQ.txt +29 -37
- data/doc/Hooks.txt +38 -0
- data/doc/{UserGuide.txt → NewUserGuide.txt} +27 -21
- data/doc/TODO +91 -57
- data/lib/sup.rb +17 -1
- data/lib/sup/buffer.rb +80 -16
- data/lib/sup/colormap.rb +0 -2
- data/lib/sup/contact.rb +3 -2
- data/lib/sup/crypto.rb +110 -0
- data/lib/sup/draft.rb +2 -6
- data/lib/sup/hook.rb +131 -0
- data/lib/sup/imap.rb +27 -16
- data/lib/sup/index.rb +38 -14
- data/lib/sup/keymap.rb +0 -2
- data/lib/sup/label.rb +30 -9
- data/lib/sup/logger.rb +12 -1
- data/lib/sup/maildir.rb +48 -3
- data/lib/sup/mbox.rb +1 -1
- data/lib/sup/mbox/loader.rb +22 -12
- data/lib/sup/mbox/ssh-loader.rb +1 -1
- data/lib/sup/message-chunks.rb +198 -0
- data/lib/sup/message.rb +154 -115
- data/lib/sup/modes/compose-mode.rb +18 -0
- data/lib/sup/modes/contact-list-mode.rb +1 -1
- data/lib/sup/modes/edit-message-mode.rb +112 -31
- data/lib/sup/modes/file-browser-mode.rb +1 -1
- data/lib/sup/modes/inbox-mode.rb +1 -1
- data/lib/sup/modes/label-list-mode.rb +8 -6
- data/lib/sup/modes/label-search-results-mode.rb +4 -1
- data/lib/sup/modes/log-mode.rb +1 -1
- data/lib/sup/modes/reply-mode.rb +18 -16
- data/lib/sup/modes/search-results-mode.rb +1 -1
- data/lib/sup/modes/thread-index-mode.rb +61 -33
- data/lib/sup/modes/thread-view-mode.rb +111 -102
- data/lib/sup/person.rb +5 -1
- data/lib/sup/poll.rb +36 -7
- data/lib/sup/sent.rb +1 -0
- data/lib/sup/source.rb +7 -3
- data/lib/sup/textfield.rb +48 -34
- data/lib/sup/thread.rb +9 -5
- data/lib/sup/util.rb +16 -22
- metadata +7 -3
data/lib/sup/message.rb
CHANGED
@@ -15,9 +15,8 @@ class MessageFormatError < StandardError; end
|
|
15
15
|
## sequences in the text of an email. (how sweet would that be?)
|
16
16
|
class Message
|
17
17
|
SNIPPET_LEN = 80
|
18
|
-
WRAP_LEN = 80 # wrap at this width
|
19
18
|
RE_PATTERN = /^((re|re[\[\(]\d[\]\)]):\s*)+/i
|
20
|
-
|
19
|
+
|
21
20
|
## some utility methods
|
22
21
|
class << self
|
23
22
|
def normalize_subj s; s.gsub(RE_PATTERN, ""); end
|
@@ -25,63 +24,13 @@ class Message
|
|
25
24
|
def reify_subj s; subj_is_reply?(s) ? s : "Re: " + s; end
|
26
25
|
end
|
27
26
|
|
28
|
-
class Attachment
|
29
|
-
attr_reader :content_type, :filename, :content, :lines
|
30
|
-
def initialize content_type, filename, content
|
31
|
-
@content_type = content_type
|
32
|
-
@filename = filename
|
33
|
-
@content = content
|
34
|
-
|
35
|
-
if inlineable?
|
36
|
-
@lines = to_s.split("\n")
|
37
|
-
end
|
38
|
-
end
|
39
|
-
|
40
|
-
def view!
|
41
|
-
file = Tempfile.new "redwood.attachment"
|
42
|
-
file.print raw_content
|
43
|
-
file.close
|
44
|
-
|
45
|
-
system "/usr/bin/run-mailcap --action=view #{@content_type}:#{file.path} >& /dev/null"
|
46
|
-
$? == 0
|
47
|
-
end
|
48
|
-
|
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
|
-
end
|
54
|
-
|
55
|
-
class Text
|
56
|
-
attr_reader :lines
|
57
|
-
def initialize lines
|
58
|
-
## do some wrapping
|
59
|
-
@lines = lines.map { |l| l.chomp.wrap WRAP_LEN }.flatten
|
60
|
-
end
|
61
|
-
end
|
62
|
-
|
63
|
-
class Quote
|
64
|
-
attr_reader :lines
|
65
|
-
def initialize lines
|
66
|
-
@lines = lines
|
67
|
-
end
|
68
|
-
end
|
69
|
-
|
70
|
-
class Signature
|
71
|
-
attr_reader :lines
|
72
|
-
def initialize lines
|
73
|
-
@lines = lines
|
74
|
-
end
|
75
|
-
end
|
76
|
-
|
77
|
-
|
78
27
|
QUOTE_PATTERN = /^\s{0,4}[>|\}]/
|
79
28
|
BLOCK_QUOTE_PATTERN = /^-----\s*Original Message\s*----+$/
|
80
29
|
QUOTE_START_PATTERN = /(^\s*Excerpts from)|(^\s*In message )|(^\s*In article )|(^\s*Quoting )|((wrote|writes|said|says)\s*:\s*$)/
|
81
|
-
SIG_PATTERN = /(^-- ?$)|(^\s*----------+\s*$)|(^\s*_________+\s*$)|(^\s*--~--~-)/
|
30
|
+
SIG_PATTERN = /(^-- ?$)|(^\s*----------+\s*$)|(^\s*_________+\s*$)|(^\s*--~--~-)|(^\s*--\+\+\*\*==)/
|
82
31
|
|
83
32
|
MAX_SIG_DISTANCE = 15 # lines from the end
|
84
|
-
DEFAULT_SUBJECT = "
|
33
|
+
DEFAULT_SUBJECT = ""
|
85
34
|
DEFAULT_SENDER = "(missing sender)"
|
86
35
|
|
87
36
|
attr_reader :id, :date, :from, :subj, :refs, :replytos, :to, :source,
|
@@ -90,8 +39,8 @@ class Message
|
|
90
39
|
|
91
40
|
bool_reader :dirty, :source_marked_read
|
92
41
|
|
93
|
-
## if you specify a :header, will use values from that. otherwise,
|
94
|
-
## load the header from the source.
|
42
|
+
## if you specify a :header, will use values from that. otherwise,
|
43
|
+
## will try and load the header from the source.
|
95
44
|
def initialize opts
|
96
45
|
@source = opts[:source] or raise ArgumentError, "source can't be nil"
|
97
46
|
@source_info = opts[:source_info] or raise ArgumentError, "source_info can't be nil"
|
@@ -101,32 +50,45 @@ class Message
|
|
101
50
|
@dirty = false
|
102
51
|
@chunks = nil
|
103
52
|
|
104
|
-
|
53
|
+
parse_header(opts[:header] || @source.load_header(@source_info))
|
105
54
|
end
|
106
55
|
|
107
|
-
def
|
56
|
+
def parse_header header
|
108
57
|
header.each { |k, v| header[k.downcase] = v }
|
58
|
+
|
59
|
+
@from = PersonManager.person_for header["from"]
|
109
60
|
|
110
|
-
|
111
|
-
|
112
|
-
|
113
|
-
|
114
|
-
|
115
|
-
|
116
|
-
|
117
|
-
|
118
|
-
|
119
|
-
|
120
|
-
|
61
|
+
@id =
|
62
|
+
if header["message-id"]
|
63
|
+
sanitize_message_id header["message-id"]
|
64
|
+
else
|
65
|
+
"sup-faked-" + Digest::MD5.hexdigest(raw_header)
|
66
|
+
Redwood::log "faking message-id for message from #@from: #@id"
|
67
|
+
end
|
68
|
+
|
69
|
+
date = header["date"]
|
70
|
+
@date =
|
71
|
+
case date
|
72
|
+
when Time
|
73
|
+
date
|
74
|
+
when String
|
75
|
+
begin
|
76
|
+
Time.parse date
|
77
|
+
rescue ArgumentError => e
|
78
|
+
raise MessageFormatError, "unparsable date #{header['date']}: #{e.message}"
|
79
|
+
end
|
80
|
+
else
|
81
|
+
Redwood::log "faking date header for #{@id}"
|
82
|
+
Time.now
|
83
|
+
end
|
121
84
|
|
122
85
|
@subj = header.member?("subject") ? header["subject"].gsub(/\s+/, " ").gsub(/\s+$/, "") : DEFAULT_SUBJECT
|
123
|
-
@from = PersonManager.person_for header["from"]
|
124
86
|
@to = PersonManager.people_for header["to"]
|
125
87
|
@cc = PersonManager.people_for header["cc"]
|
126
88
|
@bcc = PersonManager.people_for header["bcc"]
|
127
|
-
@
|
128
|
-
@
|
129
|
-
|
89
|
+
@refs = (header["references"] || "").scan(/<(.+?)>/).map { |x| sanitize_message_id x.first }
|
90
|
+
@replytos = (header["in-reply-to"] || "").scan(/<(.+?)>/).map { |x| sanitize_message_id x.first }
|
91
|
+
|
130
92
|
@replyto = PersonManager.person_for header["reply-to"]
|
131
93
|
@list_address =
|
132
94
|
if header["list-post"]
|
@@ -138,7 +100,7 @@ class Message
|
|
138
100
|
@recipient_email = header["envelope-to"] || header["x-original-to"] || header["delivered-to"]
|
139
101
|
@source_marked_read = header["status"] == "RO"
|
140
102
|
end
|
141
|
-
private :
|
103
|
+
private :parse_header
|
142
104
|
|
143
105
|
def snippet; @snippet || chunks && @snippet; end
|
144
106
|
def is_list_message?; !@list_address.nil?; end
|
@@ -148,6 +110,8 @@ class Message
|
|
148
110
|
@source.fn_for_offset @source_info
|
149
111
|
end
|
150
112
|
|
113
|
+
def sanitize_message_id mid; mid.gsub(/\s/, "") end
|
114
|
+
|
151
115
|
def save index
|
152
116
|
index.sync_message self if @dirty
|
153
117
|
@dirty = false
|
@@ -178,7 +142,7 @@ class Message
|
|
178
142
|
def load_from_source!
|
179
143
|
@chunks ||=
|
180
144
|
if @source.has_errors?
|
181
|
-
[Text.new(error_message(@source.error.message.split("\n")))]
|
145
|
+
[Chunk::Text.new(error_message(@source.error.message.split("\n")))]
|
182
146
|
else
|
183
147
|
begin
|
184
148
|
## we need to re-read the header because it contains information
|
@@ -189,14 +153,14 @@ class Message
|
|
189
153
|
## bloat the index.
|
190
154
|
## actually, it's also the differentiation between to/cc/bcc,
|
191
155
|
## so i will keep this.
|
192
|
-
|
156
|
+
parse_header @source.load_header(@source_info)
|
193
157
|
message_to_chunks @source.load_message(@source_info)
|
194
158
|
rescue SourceError, SocketError, MessageFormatError => e
|
195
159
|
Redwood::log "problem getting messages from #{@source}: #{e.message}"
|
196
160
|
## we need force_to_top here otherwise this window will cover
|
197
161
|
## up the error message one
|
198
162
|
Redwood::report_broken_sources :force_to_top => true
|
199
|
-
[Text.new(error_message(e.message))]
|
163
|
+
[Chunk::Text.new(error_message(e.message))]
|
200
164
|
end
|
201
165
|
end
|
202
166
|
end
|
@@ -220,22 +184,26 @@ The error message was:
|
|
220
184
|
EOS
|
221
185
|
end
|
222
186
|
|
223
|
-
def
|
187
|
+
def with_source_errors_handled
|
224
188
|
begin
|
225
|
-
|
189
|
+
yield
|
226
190
|
rescue SourceError => e
|
227
191
|
Redwood::log "problem getting messages from #{@source}: #{e.message}"
|
228
192
|
error_message e.message
|
229
193
|
end
|
230
194
|
end
|
231
195
|
|
232
|
-
def
|
233
|
-
|
234
|
-
|
235
|
-
|
236
|
-
|
237
|
-
|
238
|
-
|
196
|
+
def raw_header
|
197
|
+
with_source_errors_handled { @source.raw_header @source_info }
|
198
|
+
end
|
199
|
+
|
200
|
+
def raw_message
|
201
|
+
with_source_errors_handled { @source.raw_message @source_info }
|
202
|
+
end
|
203
|
+
|
204
|
+
## much faster than raw_message
|
205
|
+
def each_raw_message_line &b
|
206
|
+
with_source_errors_handled { @source.each_raw_message_line(@source_info, &b) }
|
239
207
|
end
|
240
208
|
|
241
209
|
def content
|
@@ -245,13 +213,13 @@ EOS
|
|
245
213
|
to.map { |p| "#{p.name} #{p.email}" },
|
246
214
|
cc.map { |p| "#{p.name} #{p.email}" },
|
247
215
|
bcc.map { |p| "#{p.name} #{p.email}" },
|
248
|
-
chunks.select { |c| c.is_a? Text }.map { |c| c.lines },
|
216
|
+
chunks.select { |c| c.is_a? Chunk::Text }.map { |c| c.lines },
|
249
217
|
Message.normalize_subj(subj),
|
250
218
|
].flatten.compact.join " "
|
251
219
|
end
|
252
220
|
|
253
221
|
def basic_body_lines
|
254
|
-
chunks.find_all { |c| c.is_a?(Text) || c.is_a?(Quote) }.map { |c| c.lines }.flatten
|
222
|
+
chunks.find_all { |c| c.is_a?(Chunk::Text) || c.is_a?(Chunk::Quote) }.map { |c| c.lines }.flatten
|
255
223
|
end
|
256
224
|
|
257
225
|
def basic_header_lines
|
@@ -274,18 +242,95 @@ private
|
|
274
242
|
## the general behavior i want is: ignore content-disposition, at
|
275
243
|
## least in so far as it suggests something being inline vs being an
|
276
244
|
## attachment. (because really, that should be the recipient's
|
277
|
-
## decision to make.) if a mime part is text/plain,
|
278
|
-
##
|
279
|
-
##
|
280
|
-
##
|
245
|
+
## decision to make.) if a mime part is text/plain, OR if the user
|
246
|
+
## decoding hook converts it, then decode it and display it
|
247
|
+
## inline. for these decoded attachments, if it has associated
|
248
|
+
## filename, then make it collapsable and individually saveable;
|
249
|
+
## otherwise, treat it as regular body text.
|
250
|
+
##
|
251
|
+
## everything else is just an attachment and is not displayed
|
252
|
+
## inline.
|
281
253
|
##
|
282
254
|
## so, in contrast to mutt, the user is not exposed to the workings
|
283
255
|
## of the gruesome slaughterhouse and sausage factory that is a
|
284
256
|
## mime-encoded message, but need only see the delicious end
|
285
257
|
## product.
|
286
|
-
|
258
|
+
|
259
|
+
def multipart_signed_to_chunks m
|
260
|
+
# Redwood::log ">> multipart SIGNED: #{m.header['Content-Type']}: #{m.body.size}"
|
261
|
+
if m.body.size != 2
|
262
|
+
Redwood::log "warning: multipart/signed with #{m.body.size} parts (expecting 2)"
|
263
|
+
return
|
264
|
+
end
|
265
|
+
|
266
|
+
payload, signature = m.body
|
267
|
+
if signature.multipart?
|
268
|
+
Redwood::log "warning: multipart/signed with payload multipart #{payload.multipart?} and signature multipart #{signature.multipart?}"
|
269
|
+
return
|
270
|
+
end
|
271
|
+
|
272
|
+
if payload.header.content_type == "application/pgp-signature"
|
273
|
+
Redwood::log "warning: multipart/signed with payload content type #{payload.header.content_type}"
|
274
|
+
return
|
275
|
+
end
|
276
|
+
|
277
|
+
if signature.header.content_type != "application/pgp-signature"
|
278
|
+
Redwood::log "warning: multipart/signed with signature content type #{signature.header.content_type}"
|
279
|
+
return
|
280
|
+
end
|
281
|
+
|
282
|
+
[CryptoManager.verify(payload, signature), message_to_chunks(payload)].flatten.compact
|
283
|
+
end
|
284
|
+
|
285
|
+
def multipart_encrypted_to_chunks m
|
286
|
+
Redwood::log ">> multipart ENCRYPTED: #{m.header['Content-Type']}: #{m.body.size}"
|
287
|
+
if m.body.size != 2
|
288
|
+
Redwood::log "warning: multipart/encrypted with #{m.body.size} parts (expecting 2)"
|
289
|
+
return
|
290
|
+
end
|
291
|
+
|
292
|
+
control, payload = m.body
|
293
|
+
if control.multipart?
|
294
|
+
Redwood::log "warning: multipart/encrypted with control multipart #{control.multipart?} and payload multipart #{payload.multipart?}"
|
295
|
+
return
|
296
|
+
end
|
297
|
+
|
298
|
+
if payload.header.content_type != "application/octet-stream"
|
299
|
+
Redwood::log "warning: multipart/encrypted with payload content type #{payload.header.content_type}"
|
300
|
+
return
|
301
|
+
end
|
302
|
+
|
303
|
+
if control.header.content_type != "application/pgp-encrypted"
|
304
|
+
Redwood::log "warning: multipart/encrypted with control content type #{signature.header.content_type}"
|
305
|
+
return
|
306
|
+
end
|
307
|
+
|
308
|
+
decryptedm, sig, notice = CryptoManager.decrypt payload
|
309
|
+
children = message_to_chunks(decryptedm) if decryptedm
|
310
|
+
[notice, sig, children].flatten.compact
|
311
|
+
end
|
312
|
+
|
313
|
+
def message_to_chunks m, sibling_types=[]
|
287
314
|
if m.multipart?
|
288
|
-
|
315
|
+
chunks =
|
316
|
+
case m.header.content_type
|
317
|
+
when "multipart/signed"
|
318
|
+
multipart_signed_to_chunks m
|
319
|
+
when "multipart/encrypted"
|
320
|
+
multipart_encrypted_to_chunks m
|
321
|
+
end
|
322
|
+
|
323
|
+
unless chunks
|
324
|
+
sibling_types = m.body.map { |p| p.header.content_type }
|
325
|
+
chunks = m.body.map { |p| message_to_chunks p, sibling_types }.flatten.compact
|
326
|
+
end
|
327
|
+
|
328
|
+
chunks
|
329
|
+
elsif m.header.content_type == "message/rfc822"
|
330
|
+
payload = RMail::Parser.read(m.body)
|
331
|
+
from = payload.header.from.first
|
332
|
+
from_person = from ? PersonManager.person_for(from.format) : nil
|
333
|
+
[Chunk::EnclosedMessage.new(from_person, payload.to_s)]
|
289
334
|
else
|
290
335
|
filename =
|
291
336
|
## first, paw through the headers looking for a filename
|
@@ -304,32 +349,26 @@ private
|
|
304
349
|
|
305
350
|
## if there's a filename, we'll treat it as an attachment.
|
306
351
|
if filename
|
307
|
-
[Attachment.new(m.header.content_type, filename, m)]
|
352
|
+
[Chunk::Attachment.new(m.header.content_type, filename, m, sibling_types)]
|
308
353
|
|
309
354
|
## otherwise, it's body text
|
310
355
|
else
|
311
|
-
body = Message.
|
356
|
+
body = Message.convert_from m.decode, m.charset
|
312
357
|
text_to_chunks body.normalize_whitespace.split("\n")
|
313
358
|
end
|
314
359
|
end
|
315
360
|
end
|
316
361
|
|
317
|
-
def self.
|
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."
|
362
|
+
def self.convert_from body, charset
|
363
|
+
return body unless charset
|
324
364
|
|
325
|
-
|
326
|
-
|
327
|
-
|
328
|
-
|
329
|
-
|
330
|
-
|
365
|
+
begin
|
366
|
+
Iconv.iconv($encoding, charset, body).join
|
367
|
+
rescue Errno::EINVAL, Iconv::InvalidEncoding, Iconv::IllegalSequence => e
|
368
|
+
Redwood::log "warning: error (#{e.class.name}) decoding message body from #{charset}: #{e.message}"
|
369
|
+
File.open("sup-unable-to-decode.txt", "w") { |f| f.write body }
|
370
|
+
body
|
331
371
|
end
|
332
|
-
body
|
333
372
|
end
|
334
373
|
|
335
374
|
## parse the lines of text into chunk objects. the heuristics here
|
@@ -356,7 +395,7 @@ private
|
|
356
395
|
end
|
357
396
|
|
358
397
|
if newstate
|
359
|
-
chunks << Text.new(chunk_lines) unless chunk_lines.empty?
|
398
|
+
chunks << Chunk::Text.new(chunk_lines) unless chunk_lines.empty?
|
360
399
|
chunk_lines = [line]
|
361
400
|
state = newstate
|
362
401
|
else
|
@@ -378,7 +417,7 @@ private
|
|
378
417
|
if chunk_lines.empty?
|
379
418
|
# nothing
|
380
419
|
else
|
381
|
-
chunks << Quote.new(chunk_lines)
|
420
|
+
chunks << Chunk::Quote.new(chunk_lines)
|
382
421
|
end
|
383
422
|
chunk_lines = [line]
|
384
423
|
state = newstate
|
@@ -398,11 +437,11 @@ private
|
|
398
437
|
## final object
|
399
438
|
case state
|
400
439
|
when :quote, :block_quote
|
401
|
-
chunks << Quote.new(chunk_lines) unless chunk_lines.empty?
|
440
|
+
chunks << Chunk::Quote.new(chunk_lines) unless chunk_lines.empty?
|
402
441
|
when :text
|
403
|
-
chunks << Text.new(chunk_lines) unless chunk_lines.empty?
|
442
|
+
chunks << Chunk::Text.new(chunk_lines) unless chunk_lines.empty?
|
404
443
|
when :sig
|
405
|
-
chunks << Signature.new(chunk_lines) unless chunk_lines.empty?
|
444
|
+
chunks << Chunk::Signature.new(chunk_lines) unless chunk_lines.empty?
|
406
445
|
end
|
407
446
|
chunks
|
408
447
|
end
|
@@ -1,5 +1,17 @@
|
|
1
1
|
module Redwood
|
2
2
|
|
3
|
+
module CanSpawnComposeMode
|
4
|
+
def spawn_compose_mode opts={}
|
5
|
+
to = opts[:to] || BufferManager.ask_for_contacts(:people, "To: ") or return
|
6
|
+
cc = opts[:cc] || BufferManager.ask_for_contacts(:people, "Cc: ") or return if $config[:ask_for_cc]
|
7
|
+
bcc = opts[:bcc] || BufferManager.ask_for_contacts(:people, "Bcc: ") or return if $config[:ask_for_bcc]
|
8
|
+
|
9
|
+
mode = ComposeMode.new :to => to, :cc => cc, :bcc => bcc
|
10
|
+
BufferManager.spawn "New Message", mode
|
11
|
+
mode.edit_message
|
12
|
+
end
|
13
|
+
end
|
14
|
+
|
3
15
|
class ComposeMode < EditMessageMode
|
4
16
|
def initialize opts={}
|
5
17
|
header = {
|
@@ -13,6 +25,12 @@ class ComposeMode < EditMessageMode
|
|
13
25
|
|
14
26
|
super :header => header, :body => (opts[:body] || [])
|
15
27
|
end
|
28
|
+
|
29
|
+
def edit_message
|
30
|
+
edited = super
|
31
|
+
BufferManager.kill_buffer self.buffer unless edited
|
32
|
+
edited
|
33
|
+
end
|
16
34
|
end
|
17
35
|
|
18
36
|
end
|