lib/sup/maildir.rb (8056B) - raw
1 require 'uri'
2 require 'set'
3
4 module Redwood
5
6 class Maildir < Source
7 include SerializeLabelsNicely
8 MYHOSTNAME = Socket.gethostname
9
10 ## remind me never to use inheritance again.
11 yaml_properties :uri, :usual, :archived, :sync_back, :id, :labels
12 def initialize uri, usual=true, archived=false, sync_back=true, id=nil, labels=[]
13 super uri, usual, archived, id
14 @expanded_uri = Source.expand_filesystem_uri(uri)
15 parts = /^([a-zA-Z0-9]*:(\/\/)?)(.*)/.match @expanded_uri
16 if parts
17 prefix = parts[1]
18 @path = parts[3]
19 uri = URI(prefix + Source.encode_path_for_uri(@path))
20 else
21 uri = URI(Source.encode_path_for_uri @path)
22 @path = uri.path
23 end
24
25 raise ArgumentError, "not a maildir URI" unless uri.scheme == "maildir"
26 raise ArgumentError, "maildir URI cannot have a host: #{uri.host}" unless uri.host.nil? || uri.host.empty?
27 raise ArgumentError, "maildir URI must have a path component" unless uri.path
28
29 @sync_back = sync_back
30 # sync by default if not specified
31 @sync_back = true if @sync_back.nil?
32
33 @dir = URI.decode_www_form_component uri.path
34 @labels = Set.new(labels || [])
35 @mutex = Mutex.new
36 @ctimes = { 'cur' => Time.at(0), 'new' => Time.at(0) }
37 end
38
39 def file_path; @dir end
40 def self.suggest_labels_for path; [] end
41 def is_source_for? uri; super || (uri == @expanded_uri); end
42
43 def supported_labels?
44 [:draft, :starred, :forwarded, :replied, :unread, :deleted]
45 end
46
47 def sync_back_enabled?
48 @sync_back
49 end
50
51 def store_message date, from_email, &block
52 stored = false
53 new_fn = new_maildir_basefn + ':2,S'
54 Dir.chdir(@dir) do |d|
55 tmp_path = File.join(@dir, 'tmp', new_fn)
56 new_path = File.join(@dir, 'new', new_fn)
57 begin
58 sleep 2 if File.stat(tmp_path)
59
60 File.stat(tmp_path)
61 rescue Errno::ENOENT #this is what we want.
62 begin
63 File.open(tmp_path, 'wb') do |f|
64 yield f #provide a writable interface for the caller
65 f.fsync
66 end
67
68 File.safe_link tmp_path, new_path
69 stored = true
70 ensure
71 File.unlink tmp_path if File.exist? tmp_path
72 end
73 end #rescue Errno...
74 end #Dir.chdir
75
76 stored
77 end
78
79 def each_raw_message_line id
80 with_file_for(id) do |f|
81 until f.eof?
82 yield f.gets
83 end
84 end
85 end
86
87 def load_header id
88 with_file_for(id) { |f| parse_raw_email_header f }
89 end
90
91 def load_message id
92 with_file_for(id) { |f| RMail::Parser.read f }
93 end
94
95 def sync_back id, labels
96 synchronize do
97 debug "syncing back maildir message #{id} with flags #{labels.to_a}"
98 flags = maildir_reconcile_flags id, labels
99 maildir_mark_file id, flags
100 end
101 end
102
103 def raw_header id
104 ret = ""
105 with_file_for(id) do |f|
106 until f.eof? || (l = f.gets) =~ /^$/
107 ret += l
108 end
109 end
110 ret
111 end
112
113 def raw_message id
114 with_file_for(id) { |f| f.read }
115 end
116
117 def fallback_date_for_message id
118 File.mtime File.join(@dir, id)
119 end
120
121 ## XXX use less memory
122 def poll
123 added = []
124 deleted = []
125 updated = []
126 @ctimes.each do |d,prev_ctime|
127 subdir = File.join @dir, d
128 debug "polling maildir #{subdir}"
129 raise FatalSourceError, "#{subdir} not a directory" unless File.directory? subdir
130 ctime = File.ctime subdir
131 next if prev_ctime >= ctime
132 @ctimes[d] = ctime
133
134 old_ids = benchmark(:maildir_read_index) { Index.instance.enum_for(:each_source_info, self.id, "#{d}/").to_a }
135 new_ids = benchmark(:maildir_read_dir) {
136 Dir.open(subdir).select {
137 |f| !File.directory? f}.map {
138 |x| File.join(d,File.basename(x)) }.sort }
139 added += new_ids - old_ids
140 deleted += old_ids - new_ids
141 debug "#{old_ids.size} in index, #{new_ids.size} in filesystem"
142 end
143
144 ## find updated mails by checking if an id is in both added and
145 ## deleted arrays, meaning that its flags changed or that it has
146 ## been moved, these ids need to be removed from added and deleted
147 add_to_delete = del_to_delete = []
148 map = Hash.new { |hash, key| hash[key] = [] }
149 deleted.each do |id_del|
150 map[maildir_data(id_del)[0]].push id_del
151 end
152 added.each do |id_add|
153 map[maildir_data(id_add)[0]].each do |id_del|
154 updated.push [ id_del, id_add ]
155 add_to_delete.push id_add
156 del_to_delete.push id_del
157 end
158 end
159 added -= add_to_delete
160 deleted -= del_to_delete
161 debug "#{added.size} added, #{deleted.size} deleted, #{updated.size} updated"
162 total_size = added.size+deleted.size+updated.size
163
164 added.each_with_index do |id,i|
165 yield :add,
166 :info => id,
167 :labels => @labels + maildir_labels(id) + [:inbox],
168 :progress => i.to_f/total_size
169 end
170
171 deleted.each_with_index do |id,i|
172 yield :delete,
173 :info => id,
174 :progress => (i.to_f+added.size)/total_size
175 end
176
177 updated.each_with_index do |id,i|
178 yield :update,
179 :old_info => id[0],
180 :new_info => id[1],
181 :labels => @labels + maildir_labels(id[1]),
182 :progress => (i.to_f+added.size+deleted.size)/total_size
183 end
184 nil
185 end
186
187 def labels? id
188 maildir_labels id
189 end
190
191 def maildir_labels id
192 flags = maildir_flags id
193 labels = []
194 labels << :unread unless flags.member? :seen
195 labels << :deleted if flags.member? :trashed
196 labels << :starred if flags.member? :flagged
197 labels << :forwarded if flags.member? :passed
198 labels << :replied if flags.member? :replied
199 labels << :draft if flags.member? :draft
200 labels
201 end
202
203 def maildir_flags id
204 maildir_data(id)[2].each_char.map do |c|
205 case c
206 when 'D' then :draft
207 when 'F' then :flagged
208 when 'P' then :passed
209 when 'R' then :replied
210 when 'S' then :seen
211 when 'T' then :trashed
212 end
213 end
214 end
215
216 def valid? id
217 File.exist? File.join(@dir, id)
218 end
219
220 private
221
222 def new_maildir_basefn
223 Kernel::srand()
224 "#{Time.now.to_i.to_s}.#{$$}#{Kernel.rand(1000000)}.#{MYHOSTNAME}"
225 end
226
227 def with_file_for id
228 fn = File.join(@dir, id)
229 begin
230 File.open(fn, 'rb') { |f| yield f }
231 rescue SystemCallError, IOError => e
232 raise FatalSourceError, "Problem reading file for id #{id.inspect}: #{fn.inspect}: #{e.message}."
233 end
234 end
235
236 def maildir_data id
237 id = File.basename id
238 # Flags we recognize are DFPRST
239 id =~ %r{^([^:]+):([12]),([A-Za-z]*)$}
240 [($1 || id), ($2 || "2"), ($3 || "")]
241 end
242
243 def maildir_reconcile_flags id, labels
244 new_flags = Set.new( maildir_data(id)[2].each_char )
245
246 # Set flags based on labels for the six flags we recognize
247 if labels.member? :draft then new_flags.add?( "D" ) else new_flags.delete?( "D" ) end
248 if labels.member? :starred then new_flags.add?( "F" ) else new_flags.delete?( "F" ) end
249 if labels.member? :forwarded then new_flags.add?( "P" ) else new_flags.delete?( "P" ) end
250 if labels.member? :replied then new_flags.add?( "R" ) else new_flags.delete?( "R" ) end
251 if not labels.member? :unread then new_flags.add?( "S" ) else new_flags.delete?( "S" ) end
252 if labels.member? :deleted or labels.member? :killed then new_flags.add?( "T" ) else new_flags.delete?( "T" ) end
253
254 ## Flags must be stored in ASCII order according to Maildir
255 ## documentation
256 new_flags.to_a.sort.join
257 end
258
259 def maildir_mark_file orig_path, flags
260 @mutex.synchronize do
261 new_base = (flags.include?("S")) ? "cur" : "new"
262 md_base, md_ver, md_flags = maildir_data orig_path
263
264 return if md_flags == flags
265
266 new_loc = File.join new_base, "#{md_base}:#{md_ver},#{flags}"
267 orig_path = File.join @dir, orig_path
268 new_path = File.join @dir, new_loc
269 tmp_path = File.join @dir, "tmp", "#{md_base}:#{md_ver},#{flags}"
270
271 File.safe_link orig_path, tmp_path
272 File.unlink orig_path
273 File.safe_link tmp_path, new_path
274 File.unlink tmp_path
275
276 new_loc
277 end
278 end
279 end
280
281 end