lib/sup/mbox.rb (5567B) - raw
1 require 'uri'
2 require 'set'
3 require 'time'
4
5 module Redwood
6
7 class MBox < Source
8 BREAK_RE = /^From \S+ (.+)$/
9
10 include SerializeLabelsNicely
11 yaml_properties :uri, :usual, :archived, :id, :labels
12
13 attr_reader :labels
14
15 ## uri_or_fp is horrific. need to refactor.
16 def initialize uri_or_fp, usual=true, archived=false, id=nil, labels=nil
17 @mutex = Mutex.new
18 @labels = Set.new((labels || []) - LabelManager::RESERVED_LABELS)
19
20 case uri_or_fp
21 when String
22 @expanded_uri = Source.expand_filesystem_uri(uri_or_fp)
23 parts = /^([a-zA-Z0-9]*:(\/\/)?)(.*)/.match @expanded_uri
24 if parts
25 prefix = parts[1]
26 @path = parts[3]
27 uri = URI(prefix + Source.encode_path_for_uri(@path))
28 else
29 uri = URI(Source.encode_path_for_uri @expanded_uri)
30 @path = uri.path
31 end
32
33 raise ArgumentError, "not an mbox uri" unless uri.scheme == "mbox"
34 raise ArgumentError, "mbox URI ('#{uri}') cannot have a host: #{uri.host}" unless uri.host.nil? || uri.host.empty?
35 raise ArgumentError, "mbox URI must have a path component" unless uri.path
36 @f = nil
37 else
38 @f = uri_or_fp
39 @path = uri_or_fp.path
40 @expanded_uri = "mbox://#{Source.encode_path_for_uri @path}"
41 end
42
43 super uri_or_fp, usual, archived, id
44 end
45
46 def file_path; @path end
47 def is_source_for? uri; super || (uri == @expanded_uri) end
48
49 def self.suggest_labels_for path
50 ## heuristic: use the filename as a label, unless the file
51 ## has a path that probably represents an inbox.
52 if File.dirname(path) =~ /\b(var|usr|spool)\b/
53 []
54 else
55 [File.basename(path).downcase.intern]
56 end
57 end
58
59 def ensure_open
60 @f = File.open @path, 'rb' if @f.nil?
61 end
62 private :ensure_open
63
64 def go_idle
65 @mutex.synchronize do
66 return if @f.nil? or @path.nil?
67 @f.close
68 @f = nil
69 end
70 end
71
72 def load_header offset
73 header = nil
74 @mutex.synchronize do
75 ensure_open
76 @f.seek offset
77 header = parse_raw_email_header @f
78 end
79 header
80 end
81
82 def load_message offset
83 @mutex.synchronize do
84 ensure_open
85 @f.seek offset
86 begin
87 ## don't use RMail::Mailbox::MBoxReader because it doesn't properly ignore
88 ## "From" at the start of a message body line.
89 string = +""
90 until @f.eof? || MBox::is_break_line?(l = @f.gets)
91 string << l
92 end
93 RMail::Parser.read string
94 rescue RMail::Parser::Error => e
95 raise FatalSourceError, "error parsing mbox file: #{e.message}"
96 end
97 end
98 end
99
100 def raw_header offset
101 ret = +""
102 @mutex.synchronize do
103 ensure_open
104 @f.seek offset
105 until @f.eof? || (l = @f.gets) =~ /^\r*$/
106 ret << l
107 end
108 end
109 ret
110 end
111
112 def raw_message offset
113 enum_for(:each_raw_message_line, offset).reduce(:+)
114 end
115
116 def store_message date, from_email, &block
117 need_blank = File.exist?(@path) && !File.zero?(@path)
118 File.open(@path, "ab") do |f|
119 f.puts if need_blank
120 f.puts "From #{from_email} #{date.asctime}"
121 yield f
122 end
123 end
124
125 ## apparently it's a million times faster to call this directly if
126 ## we're just moving messages around on disk, than reading things
127 ## into memory with raw_message.
128 ##
129 def each_raw_message_line offset
130 @mutex.synchronize do
131 ensure_open
132 @f.seek offset
133 until @f.eof? || MBox::is_break_line?(l = @f.gets)
134 yield l
135 end
136 end
137 end
138
139 def fallback_date_for_message offset
140 ## This is a bit awkward... We treat the From line as a delimiter,
141 ## not part of the message. So the offset is pointing *after* the
142 ## From line for the desired message. With a bit of effort we can
143 ## scan backwards to find its From line and extract a date from it.
144 buf = @mutex.synchronize do
145 ensure_open
146 start = offset
147 loop do
148 start = (start - 200).clamp 0, 2**64
149 @f.seek start
150 buf = @f.read (offset - start)
151 break buf if buf.include? ?\n or start == 0
152 end
153 end
154 BREAK_RE.match buf.lines.last do |m|
155 Time.strptime m[1], "%a %b %d %H:%M:%S %Y"
156 end
157 end
158
159 def default_labels
160 [:inbox, :unread]
161 end
162
163 def poll
164 first_offset = first_new_message
165 offset = first_offset
166 end_offset = File.size @f
167 while offset and offset < end_offset
168 yield :add,
169 :info => offset,
170 :labels => (labels + default_labels),
171 :progress => (offset - first_offset).to_f/end_offset
172 offset = next_offset offset
173 end
174 end
175
176 def next_offset offset
177 @mutex.synchronize do
178 ensure_open
179 @f.seek offset
180 nil while line = @f.gets and not MBox::is_break_line? line
181 offset = @f.tell
182 offset != File.size(@f) ? offset : nil
183 end
184 end
185
186 ## TODO optimize this by iterating over allterms list backwards or
187 ## storing source_info negated
188 def last_indexed_message
189 benchmark(:mbox_read_index) { Index.instance.enum_for(:each_source_info, self.id).map(&:to_i).max }
190 end
191
192 ## offset of first new message or nil
193 def first_new_message
194 next_offset(last_indexed_message || 0)
195 end
196
197 def self.is_break_line? l
198 l =~ BREAK_RE or return false
199 time = $1
200 begin
201 Time.strptime time, "%a %b %d %H:%M:%S %Y"
202 true
203 rescue NoMethodError, ArgumentError
204 warn "found invalid date in potential mbox split line, not splitting: #{l.inspect}"
205 false
206 end
207 end
208
209 class Loader < self
210 yaml_properties :uri, :usual, :archived, :id, :labels
211 end
212 end
213 end