]> git.cworth.org Git - sup/blob - lib/sup/maildir.rb
Merge branch 'various-api-refactors' into next
[sup] / lib / sup / maildir.rb
1 require 'rmail'
2 require 'uri'
3
4 module Redwood
5
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
9 ## pathnames on disk.
10
11 class Maildir < Source
12   include SerializeLabelsNicely
13   SCAN_INTERVAL = 30 # seconds
14   MYHOSTNAME = Socket.gethostname
15
16   ## remind me never to use inheritance again.
17   yaml_properties :uri, :cur_offset, :usual, :archived, :id, :labels, :mtimes
18   def initialize uri, last_date=nil, usual=true, archived=false, id=nil, labels=[], mtimes={}
19     super uri, last_date, usual, archived, id
20     uri = URI(Source.expand_filesystem_uri(uri))
21
22     raise ArgumentError, "not a maildir URI" unless uri.scheme == "maildir"
23     raise ArgumentError, "maildir URI cannot have a host: #{uri.host}" if uri.host
24     raise ArgumentError, "maildir URI must have a path component" unless uri.path
25
26     @dir = uri.path
27     @labels = Set.new(labels || [])
28     @ids = []
29     @ids_to_fns = {}
30     @last_scan = nil
31     @mutex = Mutex.new
32     #the mtime from the subdirs in the maildir with the unix epoch as default.
33     #these are used to determine whether scanning the directory for new mail
34     #is a worthwhile effort
35     @mtimes = { 'cur' => Time.at(0), 'new' => Time.at(0) }.merge(mtimes || {})
36     @dir_ids = { 'cur' => [], 'new' => [] }
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(Source.expand_filesystem_uri(uri)) == URI(self.uri)); end
42
43   def check
44     scan_mailbox
45     return unless start_offset
46
47     start = @ids.index(cur_offset || start_offset) or raise OutOfSyncSourceError, "Unknown message id #{cur_offset || start_offset}." # couldn't find the most recent email
48   end
49
50   def store_message date, from_email, &block
51     stored = false
52     new_fn = new_maildir_basefn + ':2,S'
53     Dir.chdir(@dir) do |d|
54       tmp_path = File.join(@dir, 'tmp', new_fn)
55       new_path = File.join(@dir, 'new', new_fn)
56       begin
57         sleep 2 if File.stat(tmp_path)
58
59         File.stat(tmp_path)
60       rescue Errno::ENOENT #this is what we want.
61         begin
62           File.open(tmp_path, 'w') do |f|
63             yield f #provide a writable interface for the caller
64             f.fsync
65           end
66
67           File.link tmp_path, new_path
68           stored = true
69         ensure
70           File.unlink tmp_path if File.exists? tmp_path
71         end
72       end #rescue Errno...
73     end #Dir.chdir
74
75     stored
76   end
77
78   def each_raw_message_line id
79     scan_mailbox
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     scan_mailbox
89     with_file_for(id) { |f| parse_raw_email_header f }
90   end
91
92   def load_message id
93     scan_mailbox
94     with_file_for(id) { |f| RMail::Parser.read f }
95   end
96
97   def raw_header id
98     scan_mailbox
99     ret = ""
100     with_file_for(id) do |f|
101       until f.eof? || (l = f.gets) =~ /^$/
102         ret += l
103       end
104     end
105     ret
106   end
107
108   def raw_message id
109     scan_mailbox
110     with_file_for(id) { |f| f.read }
111   end
112
113   def scan_mailbox opts={}
114     return unless @ids.empty? || opts[:rescan]
115     return if @last_scan && (Time.now - @last_scan) < SCAN_INTERVAL
116
117     initial_poll = @ids.empty?
118
119     debug "scanning maildir #@dir..."
120     begin
121       @mtimes.each_key do |d|
122         subdir = File.join(@dir, d)
123         raise FatalSourceError, "#{subdir} not a directory" unless File.directory? subdir
124
125         mtime = File.mtime subdir
126
127         #only scan the dir if the mtime is more recent (or we haven't polled
128         #since startup)
129         if @mtimes[d] < mtime || initial_poll
130           @mtimes[d] = mtime
131           @dir_ids[d] = []
132           Dir[File.join(subdir, '*')].map do |fn|
133             id = make_id fn
134             @dir_ids[d] << id
135             @ids_to_fns[id] = fn
136           end
137         else
138           debug "no poll on #{d}.  mtime on indicates no new messages."
139         end
140       end
141       @ids = @dir_ids.values.flatten.uniq.sort!
142     rescue SystemCallError, IOError => e
143       raise FatalSourceError, "Problem scanning Maildir directories: #{e.message}."
144     end
145     
146     debug "done scanning maildir"
147     @last_scan = Time.now
148   end
149   synchronized :scan_mailbox
150
151   def each
152     scan_mailbox
153     return unless start_offset
154
155     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
157     start.upto(@ids.length - 1) do |i|         
158       id = @ids[i]
159       self.cur_offset = id
160       yield id, @labels + (seen?(id) ? [] : [:unread]) + (trashed?(id) ? [:deleted] : []) + (flagged?(id) ? [:starred] : [])
161     end
162   end
163
164   def start_offset
165     scan_mailbox
166     @ids.first
167   end
168
169   def end_offset
170     scan_mailbox :rescan => true
171     @ids.last + 1
172   end
173
174   def pct_done; 100.0 * (@ids.index(cur_offset) || 0).to_f / (@ids.length - 1).to_f; end
175
176   def draft? msg; maildir_data(msg)[2].include? "D"; end
177   def flagged? msg; maildir_data(msg)[2].include? "F"; end
178   def passed? msg; maildir_data(msg)[2].include? "P"; end
179   def replied? msg; maildir_data(msg)[2].include? "R"; end
180   def seen? msg; maildir_data(msg)[2].include? "S"; end
181   def trashed? msg; maildir_data(msg)[2].include? "T"; end
182
183   def mark_draft msg; maildir_mark_file msg, "D" unless draft? msg; end
184   def mark_flagged msg; maildir_mark_file msg, "F" unless flagged? msg; end
185   def mark_passed msg; maildir_mark_file msg, "P" unless passed? msg; end
186   def mark_replied msg; maildir_mark_file msg, "R" unless replied? msg; end
187   def mark_seen msg; maildir_mark_file msg, "S" unless seen? msg; end
188   def mark_trashed msg; maildir_mark_file msg, "T" unless trashed? msg; end
189
190 private
191
192   def make_id fn
193     #doing this means 1 syscall instead of 2 (File.mtime, File.size).
194     #makes a noticeable difference on nfs.
195     stat = File.stat(fn)
196     # use 7 digits for the size. why 7? seems nice.
197     sprintf("%d%07d", stat.mtime, stat.size % 10000000).to_i
198   end
199
200   def new_maildir_basefn
201     Kernel::srand()
202     "#{Time.now.to_i.to_s}.#{$$}#{Kernel.rand(1000000)}.#{MYHOSTNAME}"
203   end
204
205   def with_file_for id
206     fn = @ids_to_fns[id] or raise OutOfSyncSourceError, "No such id: #{id.inspect}."
207     begin
208       File.open(fn) { |f| yield f }
209     rescue SystemCallError, IOError => e
210       raise FatalSourceError, "Problem reading file for id #{id.inspect}: #{fn.inspect}: #{e.message}."
211     end
212   end
213
214   def maildir_data msg
215     fn = File.basename @ids_to_fns[msg]
216     fn =~ %r{^([^:]+):([12]),([DFPRST]*)$}
217     [($1 || fn), ($2 || "2"), ($3 || "")]
218   end
219
220   ## not thread-safe on msg
221   def maildir_mark_file msg, flag
222     orig_path = @ids_to_fns[msg]
223     orig_base, orig_fn = File.split(orig_path)
224     new_base = orig_base.slice(0..-4) + 'cur'
225     tmp_base = orig_base.slice(0..-4) + 'tmp'
226     md_base, md_ver, md_flags = maildir_data msg
227     md_flags += flag; md_flags = md_flags.split(//).sort.join.squeeze
228     new_path = File.join new_base, "#{md_base}:#{md_ver},#{md_flags}"
229     tmp_path = File.join tmp_base, "#{md_base}:#{md_ver},#{md_flags}"
230     File.link orig_path, tmp_path
231     File.unlink orig_path
232     File.link tmp_path, new_path
233     File.unlink tmp_path
234     @ids_to_fns[msg] = new_path
235   end
236 end
237
238 end