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.
- checksums.yaml +7 -0
- data/.gitignore +17 -0
- data/.travis.yml +12 -0
- data/CONTRIBUTORS +84 -0
- data/Gemfile +3 -0
- data/HACKING +42 -0
- data/History.txt +361 -0
- data/LICENSE +280 -0
- data/README.md +70 -0
- data/Rakefile +12 -0
- data/ReleaseNotes +231 -0
- data/bin/sup +434 -0
- data/bin/sup-add +118 -0
- data/bin/sup-config +243 -0
- data/bin/sup-dump +43 -0
- data/bin/sup-import-dump +101 -0
- data/bin/sup-psych-ify-config-files +21 -0
- data/bin/sup-recover-sources +87 -0
- data/bin/sup-sync +210 -0
- data/bin/sup-sync-back-maildir +127 -0
- data/bin/sup-tweak-labels +140 -0
- data/contrib/colorpicker.rb +100 -0
- data/contrib/completion/_sup.zsh +114 -0
- data/devel/console.sh +3 -0
- data/devel/count-loc.sh +3 -0
- data/devel/load-index.rb +9 -0
- data/devel/profile.rb +12 -0
- data/devel/start-console.rb +5 -0
- data/doc/FAQ.txt +119 -0
- data/doc/Hooks.txt +79 -0
- data/doc/Philosophy.txt +69 -0
- data/lib/sup.rb +467 -0
- data/lib/sup/account.rb +90 -0
- data/lib/sup/buffer.rb +768 -0
- data/lib/sup/colormap.rb +239 -0
- data/lib/sup/contact.rb +67 -0
- data/lib/sup/crypto.rb +461 -0
- data/lib/sup/draft.rb +119 -0
- data/lib/sup/hook.rb +159 -0
- data/lib/sup/horizontal_selector.rb +59 -0
- data/lib/sup/idle.rb +42 -0
- data/lib/sup/index.rb +882 -0
- data/lib/sup/interactive_lock.rb +89 -0
- data/lib/sup/keymap.rb +140 -0
- data/lib/sup/label.rb +87 -0
- data/lib/sup/logger.rb +77 -0
- data/lib/sup/logger/singleton.rb +10 -0
- data/lib/sup/maildir.rb +257 -0
- data/lib/sup/mbox.rb +187 -0
- data/lib/sup/message.rb +803 -0
- data/lib/sup/message_chunks.rb +328 -0
- data/lib/sup/mode.rb +140 -0
- data/lib/sup/modes/buffer_list_mode.rb +50 -0
- data/lib/sup/modes/completion_mode.rb +55 -0
- data/lib/sup/modes/compose_mode.rb +38 -0
- data/lib/sup/modes/console_mode.rb +125 -0
- data/lib/sup/modes/contact_list_mode.rb +148 -0
- data/lib/sup/modes/edit_message_async_mode.rb +110 -0
- data/lib/sup/modes/edit_message_mode.rb +728 -0
- data/lib/sup/modes/file_browser_mode.rb +109 -0
- data/lib/sup/modes/forward_mode.rb +82 -0
- data/lib/sup/modes/help_mode.rb +19 -0
- data/lib/sup/modes/inbox_mode.rb +85 -0
- data/lib/sup/modes/label_list_mode.rb +138 -0
- data/lib/sup/modes/label_search_results_mode.rb +38 -0
- data/lib/sup/modes/line_cursor_mode.rb +203 -0
- data/lib/sup/modes/log_mode.rb +57 -0
- data/lib/sup/modes/person_search_results_mode.rb +12 -0
- data/lib/sup/modes/poll_mode.rb +19 -0
- data/lib/sup/modes/reply_mode.rb +228 -0
- data/lib/sup/modes/resume_mode.rb +52 -0
- data/lib/sup/modes/scroll_mode.rb +252 -0
- data/lib/sup/modes/search_list_mode.rb +204 -0
- data/lib/sup/modes/search_results_mode.rb +59 -0
- data/lib/sup/modes/text_mode.rb +76 -0
- data/lib/sup/modes/thread_index_mode.rb +1033 -0
- data/lib/sup/modes/thread_view_mode.rb +941 -0
- data/lib/sup/person.rb +134 -0
- data/lib/sup/poll.rb +272 -0
- data/lib/sup/rfc2047.rb +56 -0
- data/lib/sup/search.rb +110 -0
- data/lib/sup/sent.rb +58 -0
- data/lib/sup/service/label_service.rb +45 -0
- data/lib/sup/source.rb +244 -0
- data/lib/sup/tagger.rb +50 -0
- data/lib/sup/textfield.rb +253 -0
- data/lib/sup/thread.rb +452 -0
- data/lib/sup/time.rb +93 -0
- data/lib/sup/undo.rb +38 -0
- data/lib/sup/update.rb +30 -0
- data/lib/sup/util.rb +747 -0
- data/lib/sup/util/ncurses.rb +274 -0
- data/lib/sup/util/path.rb +9 -0
- data/lib/sup/util/query.rb +17 -0
- data/lib/sup/util/uri.rb +15 -0
- data/lib/sup/version.rb +3 -0
- data/sup.gemspec +53 -0
- data/test/dummy_source.rb +61 -0
- data/test/gnupg_test_home/gpg.conf +1 -0
- data/test/gnupg_test_home/pubring.gpg +0 -0
- data/test/gnupg_test_home/receiver_pubring.gpg +0 -0
- data/test/gnupg_test_home/receiver_secring.gpg +0 -0
- data/test/gnupg_test_home/receiver_trustdb.gpg +0 -0
- data/test/gnupg_test_home/secring.gpg +0 -0
- data/test/gnupg_test_home/sup-test-2@foo.bar.asc +20 -0
- data/test/gnupg_test_home/trustdb.gpg +0 -0
- data/test/integration/test_label_service.rb +18 -0
- data/test/messages/bad-content-transfer-encoding-1.eml +8 -0
- data/test/messages/binary-content-transfer-encoding-2.eml +21 -0
- data/test/messages/missing-line.eml +9 -0
- data/test/test_crypto.rb +109 -0
- data/test/test_header_parsing.rb +168 -0
- data/test/test_helper.rb +7 -0
- data/test/test_message.rb +532 -0
- data/test/test_messages_dir.rb +147 -0
- data/test/test_yaml_migration.rb +85 -0
- data/test/test_yaml_regressions.rb +17 -0
- data/test/unit/service/test_label_service.rb +19 -0
- data/test/unit/test_horizontal_selector.rb +40 -0
- data/test/unit/util/test_query.rb +46 -0
- data/test/unit/util/test_string.rb +57 -0
- data/test/unit/util/test_uri.rb +19 -0
- metadata +423 -0
data/lib/sup/mbox.rb
ADDED
|
@@ -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
|
data/lib/sup/message.rb
ADDED
|
@@ -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
|