sup 0.19.0
Sign up to get free protection for your applications and to get access to all the features.
- 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
|