sup 0.19.0

Sign up to get free protection for your applications and to get access to all the features.
Files changed (123) hide show
  1. checksums.yaml +7 -0
  2. data/.gitignore +17 -0
  3. data/.travis.yml +12 -0
  4. data/CONTRIBUTORS +84 -0
  5. data/Gemfile +3 -0
  6. data/HACKING +42 -0
  7. data/History.txt +361 -0
  8. data/LICENSE +280 -0
  9. data/README.md +70 -0
  10. data/Rakefile +12 -0
  11. data/ReleaseNotes +231 -0
  12. data/bin/sup +434 -0
  13. data/bin/sup-add +118 -0
  14. data/bin/sup-config +243 -0
  15. data/bin/sup-dump +43 -0
  16. data/bin/sup-import-dump +101 -0
  17. data/bin/sup-psych-ify-config-files +21 -0
  18. data/bin/sup-recover-sources +87 -0
  19. data/bin/sup-sync +210 -0
  20. data/bin/sup-sync-back-maildir +127 -0
  21. data/bin/sup-tweak-labels +140 -0
  22. data/contrib/colorpicker.rb +100 -0
  23. data/contrib/completion/_sup.zsh +114 -0
  24. data/devel/console.sh +3 -0
  25. data/devel/count-loc.sh +3 -0
  26. data/devel/load-index.rb +9 -0
  27. data/devel/profile.rb +12 -0
  28. data/devel/start-console.rb +5 -0
  29. data/doc/FAQ.txt +119 -0
  30. data/doc/Hooks.txt +79 -0
  31. data/doc/Philosophy.txt +69 -0
  32. data/lib/sup.rb +467 -0
  33. data/lib/sup/account.rb +90 -0
  34. data/lib/sup/buffer.rb +768 -0
  35. data/lib/sup/colormap.rb +239 -0
  36. data/lib/sup/contact.rb +67 -0
  37. data/lib/sup/crypto.rb +461 -0
  38. data/lib/sup/draft.rb +119 -0
  39. data/lib/sup/hook.rb +159 -0
  40. data/lib/sup/horizontal_selector.rb +59 -0
  41. data/lib/sup/idle.rb +42 -0
  42. data/lib/sup/index.rb +882 -0
  43. data/lib/sup/interactive_lock.rb +89 -0
  44. data/lib/sup/keymap.rb +140 -0
  45. data/lib/sup/label.rb +87 -0
  46. data/lib/sup/logger.rb +77 -0
  47. data/lib/sup/logger/singleton.rb +10 -0
  48. data/lib/sup/maildir.rb +257 -0
  49. data/lib/sup/mbox.rb +187 -0
  50. data/lib/sup/message.rb +803 -0
  51. data/lib/sup/message_chunks.rb +328 -0
  52. data/lib/sup/mode.rb +140 -0
  53. data/lib/sup/modes/buffer_list_mode.rb +50 -0
  54. data/lib/sup/modes/completion_mode.rb +55 -0
  55. data/lib/sup/modes/compose_mode.rb +38 -0
  56. data/lib/sup/modes/console_mode.rb +125 -0
  57. data/lib/sup/modes/contact_list_mode.rb +148 -0
  58. data/lib/sup/modes/edit_message_async_mode.rb +110 -0
  59. data/lib/sup/modes/edit_message_mode.rb +728 -0
  60. data/lib/sup/modes/file_browser_mode.rb +109 -0
  61. data/lib/sup/modes/forward_mode.rb +82 -0
  62. data/lib/sup/modes/help_mode.rb +19 -0
  63. data/lib/sup/modes/inbox_mode.rb +85 -0
  64. data/lib/sup/modes/label_list_mode.rb +138 -0
  65. data/lib/sup/modes/label_search_results_mode.rb +38 -0
  66. data/lib/sup/modes/line_cursor_mode.rb +203 -0
  67. data/lib/sup/modes/log_mode.rb +57 -0
  68. data/lib/sup/modes/person_search_results_mode.rb +12 -0
  69. data/lib/sup/modes/poll_mode.rb +19 -0
  70. data/lib/sup/modes/reply_mode.rb +228 -0
  71. data/lib/sup/modes/resume_mode.rb +52 -0
  72. data/lib/sup/modes/scroll_mode.rb +252 -0
  73. data/lib/sup/modes/search_list_mode.rb +204 -0
  74. data/lib/sup/modes/search_results_mode.rb +59 -0
  75. data/lib/sup/modes/text_mode.rb +76 -0
  76. data/lib/sup/modes/thread_index_mode.rb +1033 -0
  77. data/lib/sup/modes/thread_view_mode.rb +941 -0
  78. data/lib/sup/person.rb +134 -0
  79. data/lib/sup/poll.rb +272 -0
  80. data/lib/sup/rfc2047.rb +56 -0
  81. data/lib/sup/search.rb +110 -0
  82. data/lib/sup/sent.rb +58 -0
  83. data/lib/sup/service/label_service.rb +45 -0
  84. data/lib/sup/source.rb +244 -0
  85. data/lib/sup/tagger.rb +50 -0
  86. data/lib/sup/textfield.rb +253 -0
  87. data/lib/sup/thread.rb +452 -0
  88. data/lib/sup/time.rb +93 -0
  89. data/lib/sup/undo.rb +38 -0
  90. data/lib/sup/update.rb +30 -0
  91. data/lib/sup/util.rb +747 -0
  92. data/lib/sup/util/ncurses.rb +274 -0
  93. data/lib/sup/util/path.rb +9 -0
  94. data/lib/sup/util/query.rb +17 -0
  95. data/lib/sup/util/uri.rb +15 -0
  96. data/lib/sup/version.rb +3 -0
  97. data/sup.gemspec +53 -0
  98. data/test/dummy_source.rb +61 -0
  99. data/test/gnupg_test_home/gpg.conf +1 -0
  100. data/test/gnupg_test_home/pubring.gpg +0 -0
  101. data/test/gnupg_test_home/receiver_pubring.gpg +0 -0
  102. data/test/gnupg_test_home/receiver_secring.gpg +0 -0
  103. data/test/gnupg_test_home/receiver_trustdb.gpg +0 -0
  104. data/test/gnupg_test_home/secring.gpg +0 -0
  105. data/test/gnupg_test_home/sup-test-2@foo.bar.asc +20 -0
  106. data/test/gnupg_test_home/trustdb.gpg +0 -0
  107. data/test/integration/test_label_service.rb +18 -0
  108. data/test/messages/bad-content-transfer-encoding-1.eml +8 -0
  109. data/test/messages/binary-content-transfer-encoding-2.eml +21 -0
  110. data/test/messages/missing-line.eml +9 -0
  111. data/test/test_crypto.rb +109 -0
  112. data/test/test_header_parsing.rb +168 -0
  113. data/test/test_helper.rb +7 -0
  114. data/test/test_message.rb +532 -0
  115. data/test/test_messages_dir.rb +147 -0
  116. data/test/test_yaml_migration.rb +85 -0
  117. data/test/test_yaml_regressions.rb +17 -0
  118. data/test/unit/service/test_label_service.rb +19 -0
  119. data/test/unit/test_horizontal_selector.rb +40 -0
  120. data/test/unit/util/test_query.rb +46 -0
  121. data/test/unit/util/test_string.rb +57 -0
  122. data/test/unit/util/test_uri.rb +19 -0
  123. metadata +423 -0
@@ -0,0 +1,187 @@
1
+ require 'uri'
2
+ require 'set'
3
+
4
+ module Redwood
5
+
6
+ class MBox < Source
7
+ BREAK_RE = /^From \S+ (.+)$/
8
+
9
+ include SerializeLabelsNicely
10
+ yaml_properties :uri, :usual, :archived, :id, :labels
11
+
12
+ attr_reader :labels
13
+
14
+ ## uri_or_fp is horrific. need to refactor.
15
+ def initialize uri_or_fp, usual=true, archived=false, id=nil, labels=nil
16
+ @mutex = Mutex.new
17
+ @labels = Set.new((labels || []) - LabelManager::RESERVED_LABELS)
18
+
19
+ case uri_or_fp
20
+ when String
21
+ @expanded_uri = Source.expand_filesystem_uri(uri_or_fp)
22
+ uri = URI(@expanded_uri)
23
+ raise ArgumentError, "not an mbox uri" unless uri.scheme == "mbox"
24
+ raise ArgumentError, "mbox URI ('#{uri}') cannot have a host: #{uri.host}" if uri.host
25
+ raise ArgumentError, "mbox URI must have a path component" unless uri.path
26
+ @f = nil
27
+ @path = uri.path
28
+ else
29
+ @f = uri_or_fp
30
+ @path = uri_or_fp.path
31
+ @expanded_uri = "mbox://#{@path}"
32
+ end
33
+
34
+ super uri_or_fp, usual, archived, id
35
+ end
36
+
37
+ def file_path; @path end
38
+ def is_source_for? uri; super || (uri == @expanded_uri) end
39
+
40
+ def self.suggest_labels_for path
41
+ ## heuristic: use the filename as a label, unless the file
42
+ ## has a path that probably represents an inbox.
43
+ if File.dirname(path) =~ /\b(var|usr|spool)\b/
44
+ []
45
+ else
46
+ [File.basename(path).downcase.intern]
47
+ end
48
+ end
49
+
50
+ def ensure_open
51
+ @f = File.open @path, 'rb' if @f.nil?
52
+ end
53
+ private :ensure_open
54
+
55
+ def go_idle
56
+ @mutex.synchronize do
57
+ return if @f.nil? or @path.nil?
58
+ @f.close
59
+ @f = nil
60
+ end
61
+ end
62
+
63
+ def load_header offset
64
+ header = nil
65
+ @mutex.synchronize do
66
+ ensure_open
67
+ @f.seek offset
68
+ header = parse_raw_email_header @f
69
+ end
70
+ header
71
+ end
72
+
73
+ def load_message offset
74
+ @mutex.synchronize do
75
+ ensure_open
76
+ @f.seek offset
77
+ begin
78
+ ## don't use RMail::Mailbox::MBoxReader because it doesn't properly ignore
79
+ ## "From" at the start of a message body line.
80
+ string = ""
81
+ until @f.eof? || MBox::is_break_line?(l = @f.gets)
82
+ string << l
83
+ end
84
+ RMail::Parser.read string
85
+ rescue RMail::Parser::Error => e
86
+ raise FatalSourceError, "error parsing mbox file: #{e.message}"
87
+ end
88
+ end
89
+ end
90
+
91
+ def raw_header offset
92
+ ret = ""
93
+ @mutex.synchronize do
94
+ ensure_open
95
+ @f.seek offset
96
+ until @f.eof? || (l = @f.gets) =~ /^\r*$/
97
+ ret << l
98
+ end
99
+ end
100
+ ret
101
+ end
102
+
103
+ def raw_message offset
104
+ ret = ""
105
+ each_raw_message_line(offset) { |l| ret << l }
106
+ ret
107
+ end
108
+
109
+ def store_message date, from_email, &block
110
+ need_blank = File.exists?(@path) && !File.zero?(@path)
111
+ File.open(@path, "ab") do |f|
112
+ f.puts if need_blank
113
+ f.puts "From #{from_email} #{date.asctime}"
114
+ yield f
115
+ end
116
+ end
117
+
118
+ ## apparently it's a million times faster to call this directly if
119
+ ## we're just moving messages around on disk, than reading things
120
+ ## into memory with raw_message.
121
+ ##
122
+ def each_raw_message_line offset
123
+ @mutex.synchronize do
124
+ ensure_open
125
+ @f.seek offset
126
+ until @f.eof? || MBox::is_break_line?(l = @f.gets)
127
+ yield l
128
+ end
129
+ end
130
+ end
131
+
132
+ def default_labels
133
+ [:inbox, :unread]
134
+ end
135
+
136
+ def poll
137
+ first_offset = first_new_message
138
+ offset = first_offset
139
+ end_offset = File.size @f
140
+ while offset and offset < end_offset
141
+ yield :add,
142
+ :info => offset,
143
+ :labels => (labels + default_labels),
144
+ :progress => (offset - first_offset).to_f/end_offset
145
+ offset = next_offset offset
146
+ end
147
+ end
148
+
149
+ def next_offset offset
150
+ @mutex.synchronize do
151
+ ensure_open
152
+ @f.seek offset
153
+ nil while line = @f.gets and not MBox::is_break_line? line
154
+ offset = @f.tell
155
+ offset != File.size(@f) ? offset : nil
156
+ end
157
+ end
158
+
159
+ ## TODO optimize this by iterating over allterms list backwards or
160
+ ## storing source_info negated
161
+ def last_indexed_message
162
+ benchmark(:mbox_read_index) { Index.instance.enum_for(:each_source_info, self.id).map(&:to_i).max }
163
+ end
164
+
165
+ ## offset of first new message or nil
166
+ def first_new_message
167
+ next_offset(last_indexed_message || 0)
168
+ end
169
+
170
+ def self.is_break_line? l
171
+ l =~ BREAK_RE or return false
172
+ time = $1
173
+ begin
174
+ ## hack -- make Time.parse fail when trying to substitute values from Time.now
175
+ Time.parse time, 0
176
+ true
177
+ rescue NoMethodError, ArgumentError
178
+ warn "found invalid date in potential mbox split line, not splitting: #{l.inspect}"
179
+ false
180
+ end
181
+ end
182
+
183
+ class Loader < self
184
+ yaml_properties :uri, :usual, :archived, :id, :labels
185
+ end
186
+ end
187
+ end
@@ -0,0 +1,803 @@
1
+ # encoding: UTF-8
2
+
3
+ require 'time'
4
+
5
+ module Redwood
6
+
7
+ ## a Message is what's threaded.
8
+ ##
9
+ ## it is also where the parsing for quotes and signatures is done, but
10
+ ## that should be moved out to a separate class at some point (because
11
+ ## i would like, for example, to be able to add in a ruby-talk
12
+ ## specific module that would detect and link to /ruby-talk:\d+/
13
+ ## sequences in the text of an email. (how sweet would that be?)
14
+
15
+ class Message
16
+ SNIPPET_LEN = 80
17
+ RE_PATTERN = /^((re|re[\[\(]\d[\]\)]):\s*)+/i
18
+
19
+ ## some utility methods
20
+ class << self
21
+ def normalize_subj s; s.gsub(RE_PATTERN, ""); end
22
+ def subj_is_reply? s; s =~ RE_PATTERN; end
23
+ def reify_subj s; subj_is_reply?(s) ? s : "Re: " + s; end
24
+ end
25
+
26
+ QUOTE_PATTERN = /^\s{0,4}[>|\}]/
27
+ BLOCK_QUOTE_PATTERN = /^-----\s*Original Message\s*----+$/
28
+ SIG_PATTERN = /(^(- )*-- ?$)|(^\s*----------+\s*$)|(^\s*_________+\s*$)|(^\s*--~--~-)|(^\s*--\+\+\*\*==)/
29
+
30
+ GPG_SIGNED_START = "-----BEGIN PGP SIGNED MESSAGE-----"
31
+ GPG_SIGNED_END = "-----END PGP SIGNED MESSAGE-----"
32
+ GPG_START = "-----BEGIN PGP MESSAGE-----"
33
+ GPG_END = "-----END PGP MESSAGE-----"
34
+ GPG_SIG_START = "-----BEGIN PGP SIGNATURE-----"
35
+ GPG_SIG_END = "-----END PGP SIGNATURE-----"
36
+
37
+ MAX_SIG_DISTANCE = 15 # lines from the end
38
+ DEFAULT_SUBJECT = ""
39
+ DEFAULT_SENDER = "(missing sender)"
40
+ MAX_HEADER_VALUE_SIZE = 4096
41
+
42
+ attr_reader :id, :date, :from, :subj, :refs, :replytos, :to,
43
+ :cc, :bcc, :labels, :attachments, :list_address, :recipient_email, :replyto,
44
+ :list_subscribe, :list_unsubscribe
45
+
46
+ bool_reader :dirty, :source_marked_read, :snippet_contains_encrypted_content
47
+
48
+ attr_accessor :locations
49
+
50
+ ## if you specify a :header, will use values from that. otherwise,
51
+ ## will try and load the header from the source.
52
+ def initialize opts
53
+ @locations = opts[:locations] or raise ArgumentError, "locations can't be nil"
54
+ @snippet = opts[:snippet]
55
+ @snippet_contains_encrypted_content = false
56
+ @have_snippet = !(opts[:snippet].nil? || opts[:snippet].empty?)
57
+ @labels = Set.new(opts[:labels] || [])
58
+ @dirty = false
59
+ @encrypted = false
60
+ @chunks = nil
61
+ @attachments = []
62
+
63
+ ## we need to initialize this. see comments in parse_header as to
64
+ ## why.
65
+ @refs = []
66
+
67
+ #parse_header(opts[:header] || @source.load_header(@source_info))
68
+ end
69
+
70
+ def decode_header_field v
71
+ return unless v
72
+ return v unless v.is_a? String
73
+ return unless v.size < MAX_HEADER_VALUE_SIZE # avoid regex blowup on spam
74
+ d = v.dup
75
+ d = d.transcode($encoding, 'ASCII')
76
+ Rfc2047.decode_to $encoding, d
77
+ end
78
+
79
+ def parse_header encoded_header
80
+ header = SavingHash.new { |k| decode_header_field encoded_header[k] }
81
+
82
+ @id = ''
83
+ if header["message-id"]
84
+ mid = header["message-id"] =~ /<(.+?)>/ ? $1 : header["message-id"]
85
+ @id = sanitize_message_id mid
86
+ end
87
+ if (not @id.include? '@') || @id.length < 6
88
+ @id = "sup-faked-" + Digest::MD5.hexdigest(raw_header)
89
+ #from = header["from"]
90
+ #debug "faking non-existent message-id for message from #{from}: #{id}"
91
+ end
92
+
93
+ @from = Person.from_address(if header["from"]
94
+ header["from"]
95
+ else
96
+ name = "Sup Auto-generated Fake Sender <sup@fake.sender.example.com>"
97
+ #debug "faking non-existent sender for message #@id: #{name}"
98
+ name
99
+ end)
100
+
101
+ @date = case(date = header["date"])
102
+ when Time
103
+ date
104
+ when String
105
+ begin
106
+ Time.parse date
107
+ rescue ArgumentError => e
108
+ #debug "faking mangled date header for #{@id} (orig #{header['date'].inspect} gave error: #{e.message})"
109
+ Time.now
110
+ end
111
+ else
112
+ #debug "faking non-existent date header for #{@id}"
113
+ Time.now
114
+ end
115
+
116
+ subj = header["subject"]
117
+ subj = subj ? subj.fix_encoding! : nil
118
+ @subj = subj ? subj.gsub(/\s+/, " ").gsub(/\s+$/, "") : DEFAULT_SUBJECT
119
+ @to = Person.from_address_list header["to"]
120
+ @cc = Person.from_address_list header["cc"]
121
+ @bcc = Person.from_address_list header["bcc"]
122
+
123
+ ## before loading our full header from the source, we can actually
124
+ ## have some extra refs set by the UI. (this happens when the user
125
+ ## joins threads manually). so we will merge the current refs values
126
+ ## in here.
127
+ refs = (header["references"] || "").scan(/<(.+?)>/).map { |x| sanitize_message_id x.first }
128
+ @refs = (@refs + refs).uniq
129
+ @replytos = (header["in-reply-to"] || "").scan(/<(.+?)>/).map { |x| sanitize_message_id x.first }
130
+
131
+ @replyto = Person.from_address header["reply-to"]
132
+ @list_address = if header["list-post"]
133
+ address = if header["list-post"] =~ /mailto:(.*?)[>\s$]/
134
+ $1
135
+ elsif header["list-post"] =~ /@/
136
+ header["list-post"] # just try the whole fucking thing
137
+ end
138
+ address && Person.from_address(address)
139
+ elsif header["x-mailing-list"]
140
+ Person.from_address header["x-mailing-list"]
141
+ end
142
+
143
+ @recipient_email = header["envelope-to"] || header["x-original-to"] || header["delivered-to"]
144
+ @source_marked_read = header["status"] == "RO"
145
+ @list_subscribe = header["list-subscribe"]
146
+ @list_unsubscribe = header["list-unsubscribe"]
147
+ end
148
+
149
+ ## Expected index entry format:
150
+ ## :message_id, :subject => String
151
+ ## :date => Time
152
+ ## :refs, :replytos => Array of String
153
+ ## :from => Person
154
+ ## :to, :cc, :bcc => Array of Person
155
+ def load_from_index! entry
156
+ @id = entry[:message_id]
157
+ @from = entry[:from]
158
+ @date = entry[:date]
159
+ @subj = entry[:subject]
160
+ @to = entry[:to]
161
+ @cc = entry[:cc]
162
+ @bcc = entry[:bcc]
163
+ @refs = (@refs + entry[:refs]).uniq
164
+ @replytos = entry[:replytos]
165
+
166
+ @replyto = nil
167
+ @list_address = nil
168
+ @recipient_email = nil
169
+ @source_marked_read = false
170
+ @list_subscribe = nil
171
+ @list_unsubscribe = nil
172
+ end
173
+
174
+ def add_ref ref
175
+ @refs << ref
176
+ @dirty = true
177
+ end
178
+
179
+ def remove_ref ref
180
+ @dirty = true if @refs.delete ref
181
+ end
182
+
183
+ attr_reader :snippet
184
+ def is_list_message?; !@list_address.nil?; end
185
+ def is_draft?; @labels.member? :draft; end
186
+ def draft_filename
187
+ raise "not a draft" unless is_draft?
188
+ source.fn_for_offset source_info
189
+ end
190
+
191
+ ## sanitize message ids by removing spaces and non-ascii characters.
192
+ ## also, truncate to 255 characters. all these steps are necessary
193
+ ## to make the index happy. of course, we probably fuck up a couple
194
+ ## valid message ids as well. as long as we're consistent, this
195
+ ## should be fine, though.
196
+ ##
197
+ ## also, mostly the message ids that are changed by this belong to
198
+ ## spam email.
199
+ ##
200
+ ## an alternative would be to SHA1 or MD5 all message ids on a regular basis.
201
+ ## don't tempt me.
202
+ def sanitize_message_id mid; mid.gsub(/(\s|[^\000-\177])+/, "")[0..254] end
203
+
204
+ def clear_dirty
205
+ @dirty = false
206
+ end
207
+
208
+ def has_label? t; @labels.member? t; end
209
+ def add_label l
210
+ l = l.to_sym
211
+ return if @labels.member? l
212
+ @labels << l
213
+ @dirty = true
214
+ end
215
+ def remove_label l
216
+ l = l.to_sym
217
+ return unless @labels.member? l
218
+ @labels.delete l
219
+ @dirty = true
220
+ end
221
+
222
+ def recipients
223
+ @to + @cc + @bcc
224
+ end
225
+
226
+ def labels= l
227
+ raise ArgumentError, "not a set" unless l.is_a?(Set)
228
+ raise ArgumentError, "not a set of labels" unless l.all? { |ll| ll.is_a?(Symbol) }
229
+ return if @labels == l
230
+ @labels = l
231
+ @dirty = true
232
+ end
233
+
234
+ def chunks
235
+ load_from_source!
236
+ @chunks
237
+ end
238
+
239
+ def location
240
+ @locations.find { |x| x.valid? } || raise(OutOfSyncSourceError.new)
241
+ end
242
+
243
+ def source
244
+ location.source
245
+ end
246
+
247
+ def source_info
248
+ location.info
249
+ end
250
+
251
+ ## this is called when the message body needs to actually be loaded.
252
+ def load_from_source!
253
+ @chunks ||=
254
+ begin
255
+ ## we need to re-read the header because it contains information
256
+ ## that we don't store in the index. actually i think it's just
257
+ ## the mailing list address (if any), so this is kinda overkill.
258
+ ## i could just store that in the index, but i think there might
259
+ ## be other things like that in the future, and i'd rather not
260
+ ## bloat the index.
261
+ ## actually, it's also the differentiation between to/cc/bcc,
262
+ ## so i will keep this.
263
+ rmsg = location.parsed_message
264
+ parse_header rmsg.header
265
+ message_to_chunks rmsg
266
+ rescue SourceError, SocketError, RMail::EncodingUnsupportedError => e
267
+ warn "problem reading message #{id}"
268
+ debug "could not load message: #{location.inspect}, exception: #{e.inspect}"
269
+
270
+ [Chunk::Text.new(error_message.split("\n"))]
271
+ end
272
+ end
273
+
274
+ def error_message
275
+ <<EOS
276
+ #@snippet...
277
+
278
+ ***********************************************************************
279
+ An error occurred while loading this message.
280
+ ***********************************************************************
281
+ EOS
282
+ end
283
+
284
+ def raw_header
285
+ location.raw_header
286
+ end
287
+
288
+ def raw_message
289
+ location.raw_message
290
+ end
291
+
292
+ def each_raw_message_line &b
293
+ location.each_raw_message_line &b
294
+ end
295
+
296
+ def sync_back
297
+ @locations.map { |l| l.sync_back @labels, self }.any? do
298
+ UpdateManager.relay self, :updated, self
299
+ end
300
+ end
301
+
302
+ def merge_labels_from_locations merge_labels
303
+ ## Get all labels from all locations
304
+ location_labels = Set.new([])
305
+
306
+ @locations.each do |l|
307
+ if l.valid?
308
+ location_labels = location_labels.union(l.labels?)
309
+ end
310
+ end
311
+
312
+ ## Add to the message labels the intersection between all location
313
+ ## labels and those we want to merge
314
+ location_labels = location_labels.intersection(merge_labels.to_set)
315
+
316
+ if not location_labels.empty?
317
+ @labels = @labels.union(location_labels)
318
+ @dirty = true
319
+ end
320
+ end
321
+
322
+ ## returns all the content from a message that will be indexed
323
+ def indexable_content
324
+ load_from_source!
325
+ [
326
+ from && from.indexable_content,
327
+ to.map { |p| p.indexable_content },
328
+ cc.map { |p| p.indexable_content },
329
+ bcc.map { |p| p.indexable_content },
330
+ indexable_chunks.map { |c| c.lines },
331
+ indexable_subject,
332
+ ].flatten.compact.join " "
333
+ end
334
+
335
+ def indexable_body
336
+ indexable_chunks.map { |c| c.lines }.flatten.compact.join " "
337
+ end
338
+
339
+ def indexable_chunks
340
+ chunks.select { |c| c.is_a? Chunk::Text } || []
341
+ end
342
+
343
+ def indexable_subject
344
+ Message.normalize_subj(subj)
345
+ end
346
+
347
+ def quotable_body_lines
348
+ chunks.find_all { |c| c.quotable? }.map { |c| c.lines }.flatten
349
+ end
350
+
351
+ def quotable_header_lines
352
+ ["From: #{@from.full_address}"] +
353
+ (@to.empty? ? [] : ["To: " + @to.map { |p| p.full_address }.join(", ")]) +
354
+ (@cc.empty? ? [] : ["Cc: " + @cc.map { |p| p.full_address }.join(", ")]) +
355
+ (@bcc.empty? ? [] : ["Bcc: " + @bcc.map { |p| p.full_address }.join(", ")]) +
356
+ ["Date: #{@date.rfc822}",
357
+ "Subject: #{@subj}"]
358
+ end
359
+
360
+ def self.build_from_source source, source_info
361
+ m = Message.new :locations => [Location.new(source, source_info)]
362
+ m.load_from_source!
363
+ m
364
+ end
365
+
366
+ private
367
+
368
+ ## here's where we handle decoding mime attachments. unfortunately
369
+ ## but unsurprisingly, the world of mime attachments is a bit of a
370
+ ## mess. as an empiricist, i'm basing the following behavior on
371
+ ## observed mail rather than on interpretations of rfcs, so probably
372
+ ## this will have to be tweaked.
373
+ ##
374
+ ## the general behavior i want is: ignore content-disposition, at
375
+ ## least in so far as it suggests something being inline vs being an
376
+ ## attachment. (because really, that should be the recipient's
377
+ ## decision to make.) if a mime part is text/plain, OR if the user
378
+ ## decoding hook converts it, then decode it and display it
379
+ ## inline. for these decoded attachments, if it has associated
380
+ ## filename, then make it collapsable and individually saveable;
381
+ ## otherwise, treat it as regular body text.
382
+ ##
383
+ ## everything else is just an attachment and is not displayed
384
+ ## inline.
385
+ ##
386
+ ## so, in contrast to mutt, the user is not exposed to the workings
387
+ ## of the gruesome slaughterhouse and sausage factory that is a
388
+ ## mime-encoded message, but need only see the delicious end
389
+ ## product.
390
+
391
+ def multipart_signed_to_chunks m
392
+ if m.body.size != 2
393
+ warn "multipart/signed with #{m.body.size} parts (expecting 2)"
394
+ return
395
+ end
396
+
397
+ payload, signature = m.body
398
+ if signature.multipart?
399
+ warn "multipart/signed with payload multipart #{payload.multipart?} and signature multipart #{signature.multipart?}"
400
+ return
401
+ end
402
+
403
+ ## this probably will never happen
404
+ if payload.header.content_type && payload.header.content_type.downcase == "application/pgp-signature"
405
+ warn "multipart/signed with payload content type #{payload.header.content_type}"
406
+ return
407
+ end
408
+
409
+ if signature.header.content_type && signature.header.content_type.downcase != "application/pgp-signature"
410
+ ## unknown signature type; just ignore.
411
+ #warn "multipart/signed with signature content type #{signature.header.content_type}"
412
+ return
413
+ end
414
+
415
+ [CryptoManager.verify(payload, signature), message_to_chunks(payload)].flatten.compact
416
+ end
417
+
418
+ def multipart_encrypted_to_chunks m
419
+ if m.body.size != 2
420
+ warn "multipart/encrypted with #{m.body.size} parts (expecting 2)"
421
+ return
422
+ end
423
+
424
+ control, payload = m.body
425
+ if control.multipart?
426
+ warn "multipart/encrypted with control multipart #{control.multipart?} and payload multipart #{payload.multipart?}"
427
+ return
428
+ end
429
+
430
+ if payload.header.content_type && payload.header.content_type.downcase != "application/octet-stream"
431
+ warn "multipart/encrypted with payload content type #{payload.header.content_type}"
432
+ return
433
+ end
434
+
435
+ if control.header.content_type && control.header.content_type.downcase != "application/pgp-encrypted"
436
+ warn "multipart/encrypted with control content type #{signature.header.content_type}"
437
+ return
438
+ end
439
+
440
+ notice, sig, decryptedm = CryptoManager.decrypt payload
441
+ if decryptedm # managed to decrypt
442
+ children = message_to_chunks(decryptedm, true)
443
+ [notice, sig].compact + children
444
+ else
445
+ [notice]
446
+ end
447
+ end
448
+
449
+ ## takes a RMail::Message, breaks it into Chunk:: classes.
450
+ def message_to_chunks m, encrypted=false, sibling_types=[]
451
+ if m.multipart?
452
+ chunks =
453
+ case m.header.content_type.downcase
454
+ when "multipart/signed"
455
+ multipart_signed_to_chunks m
456
+ when "multipart/encrypted"
457
+ multipart_encrypted_to_chunks m
458
+ end
459
+
460
+ unless chunks
461
+ sibling_types = m.body.map { |p| p.header.content_type }
462
+ chunks = m.body.map { |p| message_to_chunks p, encrypted, sibling_types }.flatten.compact
463
+ end
464
+
465
+ chunks
466
+ elsif m.header.content_type && m.header.content_type.downcase == "message/rfc822"
467
+ encoding = m.header["Content-Transfer-Encoding"]
468
+ if m.body
469
+ body =
470
+ case encoding
471
+ when "base64"
472
+ m.body.unpack("m")[0]
473
+ when "quoted-printable"
474
+ m.body.unpack("M")[0]
475
+ when "7bit", "8bit", nil
476
+ m.body
477
+ else
478
+ raise RMail::EncodingUnsupportedError, encoding.inspect
479
+ end
480
+ body = body.normalize_whitespace
481
+ payload = RMail::Parser.read(body)
482
+ from = payload.header.from.first ? payload.header.from.first.format : ""
483
+ to = payload.header.to.map { |p| p.format }.join(", ")
484
+ cc = payload.header.cc.map { |p| p.format }.join(", ")
485
+ subj = decode_header_field(payload.header.subject) || DEFAULT_SUBJECT
486
+ subj = Message.normalize_subj(subj.gsub(/\s+/, " ").gsub(/\s+$/, ""))
487
+ msgdate = payload.header.date
488
+ from_person = from ? Person.from_address(decode_header_field(from)) : nil
489
+ to_people = to ? Person.from_address_list(decode_header_field(to)) : nil
490
+ cc_people = cc ? Person.from_address_list(decode_header_field(cc)) : nil
491
+ [Chunk::EnclosedMessage.new(from_person, to_people, cc_people, msgdate, subj)] + message_to_chunks(payload, encrypted)
492
+ else
493
+ debug "no body for message/rfc822 enclosure; skipping"
494
+ []
495
+ end
496
+ elsif m.header.content_type && m.header.content_type.downcase == "application/pgp" && m.body
497
+ ## apparently some versions of Thunderbird generate encryped email that
498
+ ## does not follow RFC3156, e.g. messages with X-Enigmail-Version: 0.95.0
499
+ ## they have no MIME multipart and just set the body content type to
500
+ ## application/pgp. this handles that.
501
+ ##
502
+ ## TODO 1: unduplicate code between here and
503
+ ## multipart_encrypted_to_chunks
504
+ ## TODO 2: this only tries to decrypt. it cannot handle inline PGP
505
+ notice, sig, decryptedm = CryptoManager.decrypt m.body
506
+ if decryptedm # managed to decrypt
507
+ children = message_to_chunks decryptedm, true
508
+ [notice, sig].compact + children
509
+ else
510
+ ## try inline pgp signed
511
+ chunks = inline_gpg_to_chunks m.body, $encoding, (m.charset || $encoding)
512
+ if chunks
513
+ chunks
514
+ else
515
+ [notice]
516
+ end
517
+ end
518
+ else
519
+ filename =
520
+ ## first, paw through the headers looking for a filename.
521
+ ## RFC 2183 (Content-Disposition) specifies that disposition-parms are
522
+ ## separated by ";". So, we match everything up to " and ; (if present).
523
+ if m.header["Content-Disposition"] && m.header["Content-Disposition"] =~ /filename="?(.*?[^\\])("|;|\z)/m
524
+ $1
525
+ elsif m.header["Content-Type"] && m.header["Content-Type"] =~ /name="?(.*?[^\\])("|;|\z)/im
526
+ $1
527
+
528
+ ## haven't found one, but it's a non-text message. fake
529
+ ## it.
530
+ ##
531
+ ## TODO: make this less lame.
532
+ elsif m.header["Content-Type"] && m.header["Content-Type"] !~ /^text\/plain/i
533
+ extension =
534
+ case m.header["Content-Type"]
535
+ when /text\/html/ then "html"
536
+ when /image\/(.*)/ then $1
537
+ end
538
+
539
+ ["sup-attachment-#{Time.now.to_i}-#{rand 10000}", extension].join(".")
540
+ end
541
+
542
+ ## if there's a filename, we'll treat it as an attachment.
543
+ if filename
544
+ ## filename could be 2047 encoded
545
+ filename = Rfc2047.decode_to $encoding, filename
546
+ # add this to the attachments list if its not a generated html
547
+ # attachment (should we allow images with generated names?).
548
+ # Lowercase the filename because searches are easier that way
549
+ @attachments.push filename.downcase unless filename =~ /^sup-attachment-/
550
+ add_label :attachment unless filename =~ /^sup-attachment-/
551
+ content_type = (m.header.content_type || "application/unknown").downcase # sometimes RubyMail gives us nil
552
+ [Chunk::Attachment.new(content_type, filename, m, sibling_types)]
553
+
554
+ ## otherwise, it's body text
555
+ else
556
+ ## Decode the body, charset conversion will follow either in
557
+ ## inline_gpg_to_chunks (for inline GPG signed messages) or
558
+ ## a few lines below (messages without inline GPG)
559
+ body = m.body ? m.decode : ""
560
+
561
+ ## Check for inline-PGP
562
+ chunks = inline_gpg_to_chunks body, $encoding, (m.charset || $encoding)
563
+ return chunks if chunks
564
+
565
+ if m.body
566
+ ## if there's no charset, use the current encoding as the charset.
567
+ ## this ensures that the body is normalized to avoid non-displayable
568
+ ## characters
569
+ body = m.decode.transcode($encoding, m.charset)
570
+ else
571
+ body = ""
572
+ end
573
+
574
+ text_to_chunks(body.normalize_whitespace.split("\n"), encrypted)
575
+ end
576
+ end
577
+ end
578
+
579
+ ## looks for gpg signed (but not encrypted) inline messages inside the
580
+ ## message body (there is no extra header for inline GPG) or for encrypted
581
+ ## (and possible signed) inline GPG messages
582
+ def inline_gpg_to_chunks body, encoding_to, encoding_from
583
+ lines = body.split("\n")
584
+
585
+ # First case: Message is enclosed between
586
+ #
587
+ # -----BEGIN PGP SIGNED MESSAGE-----
588
+ # and
589
+ # -----END PGP SIGNED MESSAGE-----
590
+ #
591
+ # In some cases, END PGP SIGNED MESSAGE doesn't appear
592
+ # (and may leave strange -----BEGIN PGP SIGNATURE----- ?)
593
+ gpg = lines.between(GPG_SIGNED_START, GPG_SIGNED_END)
594
+ # between does not check if GPG_END actually exists
595
+ # Reference: http://permalink.gmane.org/gmane.mail.sup.devel/641
596
+ if !gpg.empty?
597
+ msg = RMail::Message.new
598
+ msg.body = gpg.join("\n")
599
+
600
+ body = body.transcode(encoding_to, encoding_from)
601
+ lines = body.split("\n")
602
+ sig = lines.between(GPG_SIGNED_START, GPG_SIG_START)
603
+ startidx = lines.index(GPG_SIGNED_START)
604
+ endidx = lines.index(GPG_SIG_END)
605
+ before = startidx != 0 ? lines[0 .. startidx-1] : []
606
+ after = endidx ? lines[endidx+1 .. lines.size] : []
607
+
608
+ # sig contains BEGIN PGP SIGNED MESSAGE and END PGP SIGNATURE, so
609
+ # we ditch them. sig may also contain the hash used by PGP (with a
610
+ # newline), so we also skip them
611
+ sig_start = sig[1].match(/^Hash:/) ? 3 : 1
612
+ sig_end = sig.size-2
613
+ payload = RMail::Message.new
614
+ payload.body = sig[sig_start, sig_end].join("\n")
615
+ return [text_to_chunks(before, false),
616
+ CryptoManager.verify(nil, msg, false),
617
+ message_to_chunks(payload),
618
+ text_to_chunks(after, false)].flatten.compact
619
+ end
620
+
621
+ # Second case: Message is encrypted
622
+
623
+ gpg = lines.between(GPG_START, GPG_END)
624
+ # between does not check if GPG_END actually exists
625
+ if !gpg.empty? && !lines.index(GPG_END).nil?
626
+ msg = RMail::Message.new
627
+ msg.body = gpg.join("\n")
628
+
629
+ startidx = lines.index(GPG_START)
630
+ before = startidx != 0 ? lines[0 .. startidx-1] : []
631
+ after = lines[lines.index(GPG_END)+1 .. lines.size]
632
+
633
+ notice, sig, decryptedm = CryptoManager.decrypt msg, true
634
+ chunks = if decryptedm # managed to decrypt
635
+ children = message_to_chunks(decryptedm, true)
636
+ [notice, sig].compact + children
637
+ else
638
+ [notice]
639
+ end
640
+ return [text_to_chunks(before, false),
641
+ chunks,
642
+ text_to_chunks(after, false)].flatten.compact
643
+ end
644
+ end
645
+
646
+ ## parse the lines of text into chunk objects. the heuristics here
647
+ ## need tweaking in some nice manner. TODO: move these heuristics
648
+ ## into the classes themselves.
649
+ def text_to_chunks lines, encrypted
650
+ state = :text # one of :text, :quote, or :sig
651
+ chunks = []
652
+ chunk_lines = []
653
+ nextline_index = -1
654
+
655
+ lines.each_with_index do |line, i|
656
+ if i >= nextline_index
657
+ # look for next nonblank line only when needed to avoid O(n²)
658
+ # behavior on sequences of blank lines
659
+ if nextline_index = lines[(i+1)..-1].index { |l| l !~ /^\s*$/ } # skip blank lines
660
+ nextline_index += i + 1
661
+ nextline = lines[nextline_index]
662
+ else
663
+ nextline_index = lines.length
664
+ nextline = nil
665
+ end
666
+ end
667
+
668
+ case state
669
+ when :text
670
+ newstate = nil
671
+
672
+ ## the following /:$/ followed by /\w/ is an attempt to detect the
673
+ ## start of a quote. this is split into two regexen because the
674
+ ## original regex /\w.*:$/ had very poor behavior on long lines
675
+ ## like ":a:a:a:a:a" that occurred in certain emails.
676
+ if line =~ QUOTE_PATTERN || (line =~ /:$/ && line =~ /\w/ && nextline =~ QUOTE_PATTERN)
677
+ newstate = :quote
678
+ elsif line =~ SIG_PATTERN && (lines.length - i) < MAX_SIG_DISTANCE && !lines[(i+1)..-1].index { |l| l =~ /^-- $/ }
679
+ newstate = :sig
680
+ elsif line =~ BLOCK_QUOTE_PATTERN
681
+ newstate = :block_quote
682
+ end
683
+
684
+ if newstate
685
+ chunks << Chunk::Text.new(chunk_lines) unless chunk_lines.empty?
686
+ chunk_lines = [line]
687
+ state = newstate
688
+ else
689
+ chunk_lines << line
690
+ end
691
+
692
+ when :quote
693
+ newstate = nil
694
+
695
+ if line =~ QUOTE_PATTERN || (line =~ /^\s*$/ && nextline =~ QUOTE_PATTERN)
696
+ chunk_lines << line
697
+ elsif line =~ SIG_PATTERN && (lines.length - i) < MAX_SIG_DISTANCE
698
+ newstate = :sig
699
+ else
700
+ newstate = :text
701
+ end
702
+
703
+ if newstate
704
+ if chunk_lines.empty?
705
+ # nothing
706
+ else
707
+ chunks << Chunk::Quote.new(chunk_lines)
708
+ end
709
+ chunk_lines = [line]
710
+ state = newstate
711
+ end
712
+
713
+ when :block_quote, :sig
714
+ chunk_lines << line
715
+ end
716
+
717
+ if !@have_snippet && state == :text && (@snippet.nil? || @snippet.length < SNIPPET_LEN) && line !~ /[=\*#_-]{3,}/ && line !~ /^\s*$/
718
+ @snippet ||= ""
719
+ @snippet += " " unless @snippet.empty?
720
+ @snippet += line.gsub(/^\s+/, "").gsub(/[\r\n]/, "").gsub(/\s+/, " ")
721
+ oldlen = @snippet.length
722
+ @snippet = @snippet[0 ... SNIPPET_LEN].chomp
723
+ @snippet += "..." if @snippet.length < oldlen
724
+ @dirty = true unless encrypted && $config[:discard_snippets_from_encrypted_messages]
725
+ @snippet_contains_encrypted_content = true if encrypted
726
+ end
727
+ end
728
+
729
+ ## final object
730
+ case state
731
+ when :quote, :block_quote
732
+ chunks << Chunk::Quote.new(chunk_lines) unless chunk_lines.empty?
733
+ when :text
734
+ chunks << Chunk::Text.new(chunk_lines) unless chunk_lines.empty?
735
+ when :sig
736
+ chunks << Chunk::Signature.new(chunk_lines) unless chunk_lines.empty?
737
+ end
738
+ chunks
739
+ end
740
+ end
741
+
742
+ class Location
743
+ attr_reader :source
744
+ attr_reader :info
745
+
746
+ def initialize source, info
747
+ @source = source
748
+ @info = info
749
+ end
750
+
751
+ def raw_header
752
+ source.raw_header info
753
+ end
754
+
755
+ def raw_message
756
+ source.raw_message info
757
+ end
758
+
759
+ def sync_back labels, message
760
+ synced = false
761
+ return synced unless sync_back_enabled? and valid?
762
+ source.synchronize do
763
+ new_info = source.sync_back(@info, labels)
764
+ if new_info
765
+ @info = new_info
766
+ Index.sync_message message, true
767
+ synced = true
768
+ end
769
+ end
770
+ synced
771
+ end
772
+
773
+ def sync_back_enabled?
774
+ source.respond_to? :sync_back and $config[:sync_back_to_maildir] and source.sync_back_enabled?
775
+ end
776
+
777
+ ## much faster than raw_message
778
+ def each_raw_message_line &b
779
+ source.each_raw_message_line info, &b
780
+ end
781
+
782
+ def parsed_message
783
+ source.load_message info
784
+ end
785
+
786
+ def valid?
787
+ source.valid? info
788
+ end
789
+
790
+ def labels?
791
+ source.labels? info
792
+ end
793
+
794
+ def == o
795
+ o.source.id == source.id and o.info == info
796
+ end
797
+
798
+ def hash
799
+ [source.id, info].hash
800
+ end
801
+ end
802
+
803
+ end