sup 0.19.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
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