6 ## Maildir doesn't provide an ordered unique id, which is what Sup
7 ## requires to be really useful. So we must maintain, in memory, a
8 ## mapping between Sup "ids" (timestamps, essentially) and the
11 class Maildir < Source
12 SCAN_INTERVAL = 30 # seconds
13 MYHOSTNAME = Socket.gethostname
15 ## remind me never to use inheritance again.
16 yaml_properties :uri, :cur_offset, :usual, :archived, :id, :labels, :mtimes
17 def initialize uri, last_date=nil, usual=true, archived=false, id=nil, labels=[], mtimes={}
18 super uri, last_date, usual, archived, id
19 uri = URI(Source.expand_filesystem_uri(uri))
21 raise ArgumentError, "not a maildir URI" unless uri.scheme == "maildir"
22 raise ArgumentError, "maildir URI cannot have a host: #{uri.host}" if uri.host
23 raise ArgumentError, "maildir URI must have a path component" unless uri.path
26 @labels = Set.new(labels || [])
31 #the mtime from the subdirs in the maildir with the unix epoch as default.
32 #these are used to determine whether scanning the directory for new mail
33 #is a worthwhile effort
34 @mtimes = { 'cur' => Time.at(0), 'new' => Time.at(0) }.merge(mtimes || {})
35 @dir_ids = { 'cur' => [], 'new' => [] }
38 def file_path; @dir end
39 def self.suggest_labels_for path; [] end
40 def is_source_for? uri; super || (URI(Source.expand_filesystem_uri(uri)) == URI(self.uri)); end
44 return unless start_offset
46 start = @ids.index(cur_offset || start_offset) or raise OutOfSyncSourceError, "Unknown message id #{cur_offset || start_offset}." # couldn't find the most recent email
49 def store_message date, from_email, &block
51 new_fn = new_maildir_basefn + ':2,S'
52 Dir.chdir(@dir) do |d|
53 tmp_path = File.join(@dir, 'tmp', new_fn)
54 new_path = File.join(@dir, 'new', new_fn)
56 sleep 2 if File.stat(tmp_path)
59 rescue Errno::ENOENT #this is what we want.
61 File.open(tmp_path, 'w') do |f|
62 yield f #provide a writable interface for the caller
66 File.link tmp_path, new_path
69 File.unlink tmp_path if File.exists? tmp_path
77 def each_raw_message_line id
79 with_file_for(id) do |f|
88 with_file_for(id) { |f| parse_raw_email_header f }
93 with_file_for(id) { |f| RMail::Parser.read f }
99 with_file_for(id) do |f|
100 until f.eof? || (l = f.gets) =~ /^$/
109 with_file_for(id) { |f| f.read }
112 def scan_mailbox opts={}
113 return unless @ids.empty? || opts[:rescan]
114 return if @last_scan && (Time.now - @last_scan) < SCAN_INTERVAL
116 initial_poll = @ids.empty?
118 Redwood::log "scanning maildir #@dir..."
120 @mtimes.each_key do |d|
121 subdir = File.join(@dir, d)
122 raise FatalSourceError, "#{subdir} not a directory" unless File.directory? subdir
124 mtime = File.mtime subdir
126 #only scan the dir if the mtime is more recent (or we haven't polled
128 if @mtimes[d] < mtime || initial_poll
131 Dir[File.join(subdir, '*')].map do |fn|
137 Redwood::log "no poll on #{d}. mtime on indicates no new messages."
140 @ids = @dir_ids.values.flatten.uniq.sort!
141 rescue SystemCallError, IOError => e
142 raise FatalSourceError, "Problem scanning Maildir directories: #{e.message}."
145 Redwood::log "done scanning maildir"
146 @last_scan = Time.now
148 synchronized :scan_mailbox
152 return unless start_offset
154 start = @ids.index(cur_offset || start_offset) or raise OutOfSyncSourceError, "Unknown message id #{cur_offset || start_offset}." # couldn't find the most recent email
156 start.upto(@ids.length - 1) do |i|
159 yield id, @labels + (seen?(id) ? [] : [:unread]) + (trashed?(id) ? [:deleted] : []) + (flagged?(id) ? [:starred] : [])
169 scan_mailbox :rescan => true
173 def pct_done; 100.0 * (@ids.index(cur_offset) || 0).to_f / (@ids.length - 1).to_f; end
175 def draft? msg; maildir_data(msg)[2].include? "D"; end
176 def flagged? msg; maildir_data(msg)[2].include? "F"; end
177 def passed? msg; maildir_data(msg)[2].include? "P"; end
178 def replied? msg; maildir_data(msg)[2].include? "R"; end
179 def seen? msg; maildir_data(msg)[2].include? "S"; end
180 def trashed? msg; maildir_data(msg)[2].include? "T"; end
182 def mark_draft msg; maildir_mark_file msg, "D" unless draft? msg; end
183 def mark_flagged msg; maildir_mark_file msg, "F" unless flagged? msg; end
184 def mark_passed msg; maildir_mark_file msg, "P" unless passed? msg; end
185 def mark_replied msg; maildir_mark_file msg, "R" unless replied? msg; end
186 def mark_seen msg; maildir_mark_file msg, "S" unless seen? msg; end
187 def mark_trashed msg; maildir_mark_file msg, "T" unless trashed? msg; end
192 #doing this means 1 syscall instead of 2 (File.mtime, File.size).
193 #makes a noticeable difference on nfs.
195 # use 7 digits for the size. why 7? seems nice.
196 sprintf("%d%07d", stat.mtime, stat.size % 10000000).to_i
199 def new_maildir_basefn
201 "#{Time.now.to_i.to_s}.#{$$}#{Kernel.rand(1000000)}.#{MYHOSTNAME}"
205 fn = @ids_to_fns[id] or raise OutOfSyncSourceError, "No such id: #{id.inspect}."
207 File.open(fn) { |f| yield f }
208 rescue SystemCallError, IOError => e
209 raise FatalSourceError, "Problem reading file for id #{id.inspect}: #{fn.inspect}: #{e.message}."
214 fn = File.basename @ids_to_fns[msg]
215 fn =~ %r{^([^:,]+):([12]),([DFPRST]*)$}
216 [($1 || fn), ($2 || "2"), ($3 || "")]
219 ## not thread-safe on msg
220 def maildir_mark_file msg, flag
221 orig_path = @ids_to_fns[msg]
222 orig_base, orig_fn = File.split(orig_path)
223 new_base = orig_base.slice(0..-4) + 'cur'
224 tmp_base = orig_base.slice(0..-4) + 'tmp'
225 md_base, md_ver, md_flags = maildir_data msg
226 md_flags += flag; md_flags = md_flags.split(//).sort.join.squeeze
227 new_path = File.join new_base, "#{md_base}:#{md_ver},#{md_flags}"
228 tmp_path = File.join tmp_base, "#{md_base}:#{md_ver},#{md_flags}"
229 File.link orig_path, tmp_path
230 File.unlink orig_path
231 File.link tmp_path, new_path
233 @ids_to_fns[msg] = new_path