blob: 5bdda7220dd202057410b8db826fe03f9a524dfe [file] [log] [blame]
Guido van Rossum56013131994-06-23 12:06:02 +00001# MH interface -- purely object-oriented (well, almost)
2#
3# Executive summary:
4#
5# import mhlib
6#
7# mh = mhlib.MH() # use default mailbox directory and profile
8# mh = mhlib.MH(mailbox) # override mailbox location (default from profile)
9# mh = mhlib.MH(mailbox, profile) # override mailbox and profile
10#
11# mh.error(format, ...) # print error message -- can be overridden
12# s = mh.getprofile(key) # profile entry (None if not set)
13# path = mh.getpath() # mailbox pathname
14# name = mh.getcontext() # name of current folder
15#
16# list = mh.listfolders() # names of top-level folders
17# list = mh.listallfolders() # names of all folders, including subfolders
18# list = mh.listsubfolders(name) # direct subfolders of given folder
19# list = mh.listallsubfolders(name) # all subfolders of given folder
20#
21# mh.makefolder(name) # create new folder
22# mh.deletefolder(name) # delete folder -- must have no subfolders
23#
24# f = mh.openfolder(name) # new open folder object
25#
26# f.error(format, ...) # same as mh.error(format, ...)
27# path = f.getfullname() # folder's full pathname
28# path = f.getsequencesfilename() # full pathname of folder's sequences file
29# path = f.getmessagefilename(n) # full pathname of message n in folder
30#
31# list = f.listmessages() # list of messages in folder (as numbers)
32# n = f.getcurrent() # get current message
33# f.setcurrent(n) # set current message
34#
35# dict = f.getsequences() # dictionary of sequences in folder {name: list}
36# f.putsequences(dict) # write sequences back to folder
37#
38# m = f.openmessage(n) # new open message object (costs a file descriptor)
Guido van Rossum85347411994-09-09 11:10:15 +000039# m is a derived class of mimetools.Message(rfc822.Message), with:
Guido van Rossum56013131994-06-23 12:06:02 +000040# s = m.getheadertext() # text of message's headers
41# s = m.getheadertext(pred) # text of message's headers, filtered by pred
42# s = m.getbodytext() # text of message's body, decoded
43# s = m.getbodytext(0) # text of message's body, not decoded
44#
45# XXX To do, functionality:
46# - remove, refile messages
47# - annotate messages
48# - create, send messages
49#
50# XXX To do, orgaanization:
51# - move IntSet to separate file
52# - move most Message functionality to module mimetools
53
54
55# Customizable defaults
56
57MH_PROFILE = '~/.mh_profile'
58PATH = '~/Mail'
59MH_SEQUENCES = '.mh_sequences'
60FOLDER_PROTECT = 0700
61
62
63# Imported modules
64
65import os
66from stat import ST_NLINK
67import regex
68import string
69import mimetools
70import multifile
71
72
73# Exported constants
74
75Error = 'mhlib.Error'
76
77
78# Class representing a particular collection of folders.
79# Optional constructor arguments are the pathname for the directory
80# containing the collection, and the MH profile to use.
81# If either is omitted or empty a default is used; the default
82# directory is taken from the MH profile if it is specified there.
83
84class MH:
85
86 # Constructor
87 def __init__(self, path = None, profile = None):
88 if not profile: profile = MH_PROFILE
89 self.profile = os.path.expanduser(profile)
90 if not path: path = self.getprofile('Path')
91 if not path: path = PATH
92 if not os.path.isabs(path) and path[0] != '~':
93 path = os.path.join('~', path)
94 path = os.path.expanduser(path)
95 if not os.path.isdir(path): raise Error, 'MH() path not found'
96 self.path = path
97
98 # String representation
99 def __repr__(self):
100 return 'MH(%s, %s)' % (`self.path`, `self.profile`)
101
102 # Routine to print an error. May be overridden by a derived class
103 def error(self, msg, *args):
104 sys.stderr.write('MH error: %\n' % (msg % args))
105
106 # Return a profile entry, None if not found
107 def getprofile(self, key):
108 return pickline(self.profile, key)
109
110 # Return the path (the name of the collection's directory)
111 def getpath(self):
112 return self.path
113
114 # Return the name of the current folder
115 def getcontext(self):
116 context = pickline(os.path.join(self.getpath(), 'context'),
117 'Current-Folder')
118 if not context: context = 'inbox'
119 return context
120
121 # Return the names of the top-level folders
122 def listfolders(self):
123 folders = []
124 path = self.getpath()
125 for name in os.listdir(path):
126 if name in (os.curdir, os.pardir): continue
127 fullname = os.path.join(path, name)
128 if os.path.isdir(fullname):
129 folders.append(name)
130 folders.sort()
131 return folders
132
133 # Return the names of the subfolders in a given folder
134 # (prefixed with the given folder name)
135 def listsubfolders(self, name):
136 fullname = os.path.join(self.path, name)
137 # Get the link count so we can avoid listing folders
138 # that have no subfolders.
139 st = os.stat(fullname)
140 nlinks = st[ST_NLINK]
141 if nlinks <= 2:
142 return []
143 subfolders = []
144 subnames = os.listdir(fullname)
145 for subname in subnames:
146 if subname in (os.curdir, os.pardir): continue
147 fullsubname = os.path.join(fullname, subname)
148 if os.path.isdir(fullsubname):
149 name_subname = os.path.join(name, subname)
150 subfolders.append(name_subname)
151 # Stop looking for subfolders when
152 # we've seen them all
153 nlinks = nlinks - 1
154 if nlinks <= 2:
155 break
156 subfolders.sort()
157 return subfolders
158
159 # Return the names of all folders, including subfolders, recursively
160 def listallfolders(self):
161 return self.listallsubfolders('')
162
163 # Return the names of subfolders in a given folder, recursively
164 def listallsubfolders(self, name):
165 fullname = os.path.join(self.path, name)
166 # Get the link count so we can avoid listing folders
167 # that have no subfolders.
168 st = os.stat(fullname)
169 nlinks = st[ST_NLINK]
170 if nlinks <= 2:
171 return []
172 subfolders = []
173 subnames = os.listdir(fullname)
174 for subname in subnames:
175 if subname in (os.curdir, os.pardir): continue
176 if subname[0] == ',' or isnumeric(subname): continue
177 fullsubname = os.path.join(fullname, subname)
178 if os.path.isdir(fullsubname):
179 name_subname = os.path.join(name, subname)
180 subfolders.append(name_subname)
181 if not os.path.islink(fullsubname):
182 subsubfolders = self.listallsubfolders(
183 name_subname)
184 subfolders = subfolders + subsubfolders
185 # Stop looking for subfolders when
186 # we've seen them all
187 nlinks = nlinks - 1
188 if nlinks <= 2:
189 break
190 subfolders.sort()
191 return subfolders
192
193 # Return a new Folder object for the named folder
194 def openfolder(self, name):
195 return Folder(self, name)
196
197 # Create a new folder. This raises os.error if the folder
198 # cannot be created
199 def makefolder(self, name):
200 protect = pickline(self.profile, 'Folder-Protect')
201 if protect and isnumeric(protect):
202 mode = eval('0' + protect)
203 else:
204 mode = FOLDER_PROTECT
205 os.mkdir(os.path.join(self.getpath(), name), mode)
206
207 # Delete a folder. This removes files in the folder but not
208 # subdirectories. If deleting the folder itself fails it
209 # raises os.error
210 def deletefolder(self, name):
211 fullname = os.path.join(self.getpath(), name)
212 for subname in os.listdir(fullname):
213 if subname in (os.curdir, os.pardir): continue
214 fullsubname = os.path.join(fullname, subname)
215 try:
216 os.unlink(fullsubname)
217 except os.error:
218 self.error('%s not deleted, continuing...' %
219 fullsubname)
220 os.rmdir(fullname)
221
222
223# Class representing a particular folder
224
225numericprog = regex.compile('[1-9][0-9]*')
226def isnumeric(str):
227 return numericprog.match(str) == len(str)
228
229class Folder:
230
231 # Constructor
232 def __init__(self, mh, name):
233 self.mh = mh
234 self.name = name
235 if not os.path.isdir(self.getfullname()):
236 raise Error, 'no folder %s' % name
237
238 # String representation
239 def __repr__(self):
240 return 'Folder(%s, %s)' % (`self.mh`, `self.name`)
241
242 # Error message handler
243 def error(self, *args):
244 apply(self.mh.error, args)
245
246 # Return the full pathname of the folder
247 def getfullname(self):
248 return os.path.join(self.mh.path, self.name)
249
250 # Return the full pathname of the folder's sequences file
251 def getsequencesfilename(self):
252 return os.path.join(self.getfullname(), MH_SEQUENCES)
253
254 # Return the full pathname of a message in the folder
255 def getmessagefilename(self, n):
256 return os.path.join(self.getfullname(), str(n))
257
258 # Return list of direct subfolders
259 def listsubfolders(self):
260 return self.mh.listsubfolders(self.name)
261
262 # Return list of all subfolders
263 def listallsubfolders(self):
264 return self.mh.listallsubfolders(self.name)
265
266 # Return the list of messages currently present in the folder.
267 # As a side effect, set self.last to the last message (or 0)
268 def listmessages(self):
269 messages = []
270 for name in os.listdir(self.getfullname()):
271 if isnumeric(name):
272 messages.append(eval(name))
273 messages.sort()
274 if messages:
275 self.last = max(messages)
276 else:
277 self.last = 0
278 return messages
279
280 # Return the set of sequences for the folder
281 def getsequences(self):
282 sequences = {}
283 fullname = self.getsequencesfilename()
284 try:
285 f = open(fullname, 'r')
286 except IOError:
287 return sequences
288 while 1:
289 line = f.readline()
290 if not line: break
291 fields = string.splitfields(line, ':')
292 if len(fields) <> 2:
293 self.error('bad sequence in %s: %s' %
294 (fullname, string.strip(line)))
295 key = string.strip(fields[0])
296 value = IntSet(string.strip(fields[1]), ' ').tolist()
297 sequences[key] = value
298 return sequences
299
300 # Write the set of sequences back to the folder
301 def putsequences(self, sequences):
302 fullname = self.getsequencesfilename()
Guido van Rossum85347411994-09-09 11:10:15 +0000303 f = None
Guido van Rossum56013131994-06-23 12:06:02 +0000304 for key in sequences.keys():
305 s = IntSet('', ' ')
306 s.fromlist(sequences[key])
Guido van Rossum85347411994-09-09 11:10:15 +0000307 if not f: f = open(fullname, 'w')
Guido van Rossum56013131994-06-23 12:06:02 +0000308 f.write('%s: %s\n' % (key, s.tostring()))
Guido van Rossum85347411994-09-09 11:10:15 +0000309 if not f:
310 try:
311 os.unlink(fullname)
312 except os.error:
313 pass
314 else:
315 f.close()
Guido van Rossum56013131994-06-23 12:06:02 +0000316
317 # Return the current message. Raise KeyError when there is none
318 def getcurrent(self):
319 return min(self.getsequences()['cur'])
320
321 # Set the current message
322 def setcurrent(self, n):
323 updateline(self.getsequencesfilename(), 'cur', str(n), 0)
324
Guido van Rossum85347411994-09-09 11:10:15 +0000325 # Open a message -- returns a Message object
Guido van Rossum56013131994-06-23 12:06:02 +0000326 def openmessage(self, n):
327 path = self.getmessagefilename(n)
328 return Message(self, n)
329
330 # Remove one or more messages -- may raise os.error
331 def removemessages(self, list):
332 errors = []
333 deleted = []
334 for n in list:
335 path = self.getmessagefilename(n)
336 commapath = self.getmessagefilename(',' + str(n))
337 try:
338 os.unlink(commapath)
339 except os.error:
340 pass
341 try:
342 os.rename(path, commapath)
343 except os.error, msg:
344 errors.append(msg)
345 else:
346 deleted.append(n)
347 if deleted:
348 self.removefromallsequences(deleted)
349 if errors:
350 if len(errors) == 1:
351 raise os.error, errors[0]
352 else:
353 raise os.error, ('multiple errors:', errors)
354
355 # Refile one or more messages -- may raise os.error.
356 # 'tofolder' is an open folder object
357 def refilemessages(self, list, tofolder):
358 errors = []
359 refiled = []
360 for n in list:
361 ton = tofolder.getlast() + 1
362 path = self.getmessagefilename(n)
363 topath = tofolder.getmessagefilename(ton)
364 try:
365 os.rename(path, topath)
366 # XXX What if it's on a different filesystem?
367 except os.error, msg:
368 errors.append(msg)
369 else:
370 tofolder.setlast(ton)
371 refiled.append(n)
372 if refiled:
373 self.removefromallsequences(refiled)
374 if errors:
375 if len(errors) == 1:
376 raise os.error, errors[0]
377 else:
378 raise os.error, ('multiple errors:', errors)
379
380 # Remove one or more messages from all sequeuces (including last)
381 def removefromallsequences(self, list):
382 if hasattr(self, 'last') and self.last in list:
383 del self.last
384 sequences = self.getsequences()
385 changed = 0
386 for name, seq in sequences.items():
387 for n in list:
388 if n in seq:
389 seq.remove(n)
390 changed = 1
391 if not seq:
392 del sequences[name]
393 if changed:
Guido van Rossum5f47e571994-07-14 14:01:00 +0000394 self.putsequences(sequences)
Guido van Rossum56013131994-06-23 12:06:02 +0000395
396 # Return the last message number
397 def getlast(self):
398 if not hasattr(self, 'last'):
399 messages = self.listmessages()
400 return self.last
401
402 # Set the last message number
403 def setlast(self, last):
404 if last is None:
405 if hasattr(self, 'last'):
406 del self.last
407 else:
408 self.last = last
409
410class Message(mimetools.Message):
411
412 # Constructor
413 def __init__(self, f, n, fp = None):
414 self.folder = f
415 self.number = n
416 if not fp:
417 path = f.getmessagefilename(n)
418 fp = open(path, 'r')
419 mimetools.Message.__init__(self, fp)
420
421 # String representation
422 def __repr__(self):
423 return 'Message(%s, %s)' % (repr(self.folder), self.number)
424
425 # Return the message's header text as a string. If an
426 # argument is specified, it is used as a filter predicate to
427 # decide which headers to return (its argument is the header
428 # name converted to lower case).
429 def getheadertext(self, pred = None):
430 if not pred:
431 return string.joinfields(self.headers, '')
432 headers = []
433 hit = 0
434 for line in self.headers:
435 if line[0] not in string.whitespace:
436 i = string.find(line, ':')
437 if i > 0:
438 hit = pred(string.lower(line[:i]))
439 if hit: headers.append(line)
440 return string.joinfields(headers, '')
441
442 # Return the message's body text as string. This undoes a
443 # Content-Transfer-Encoding, but does not interpret other MIME
444 # features (e.g. multipart messages). To suppress to
445 # decoding, pass a 0 as argument
446 def getbodytext(self, decode = 1):
447 self.fp.seek(self.startofbody)
448 encoding = self.getencoding()
449 if not decode or encoding in ('7bit', '8bit', 'binary'):
450 return self.fp.read()
451 from StringIO import StringIO
452 output = StringIO()
453 mimetools.decode(self.fp, output, encoding)
454 return output.getvalue()
455
456 # Only for multipart messages: return the message's body as a
457 # list of SubMessage objects. Each submessage object behaves
458 # (almost) as a Message object.
459 def getbodyparts(self):
460 if self.getmaintype() != 'multipart':
461 raise Error, \
462 'Content-Type is not multipart/*'
463 bdry = self.getparam('boundary')
464 if not bdry:
465 raise Error, 'multipart/* without boundary param'
466 self.fp.seek(self.startofbody)
467 mf = multifile.MultiFile(self.fp)
468 mf.push(bdry)
469 parts = []
470 while mf.next():
471 n = str(self.number) + '.' + `1 + len(parts)`
472 part = SubMessage(self.folder, n, mf)
473 parts.append(part)
474 mf.pop()
475 return parts
476
477 # Return body, either a string or a list of messages
478 def getbody(self):
479 if self.getmaintype() == 'multipart':
480 return self.getbodyparts()
481 else:
482 return self.getbodytext()
483
484
485class SubMessage(Message):
486
487 # Constructor
488 def __init__(self, f, n, fp):
489 Message.__init__(self, f, n, fp)
490 if self.getmaintype() == 'multipart':
491 self.body = Message.getbodyparts(self)
492 else:
493 self.body = Message.getbodytext(self)
494 # XXX If this is big, should remember file pointers
495
496 # String representation
497 def __repr__(self):
498 f, n, fp = self.folder, self.number, self.fp
499 return 'SubMessage(%s, %s, %s)' % (f, n, fp)
500
501 def getbodytext(self):
502 if type(self.body) == type(''):
503 return self.body
504
505 def getbodyparts(self):
506 if type(self.body) == type([]):
507 return self.body
508
509 def getbody(self):
510 return self.body
511
512
513# Class implementing sets of integers.
514#
515# This is an efficient representation for sets consisting of several
516# continuous ranges, e.g. 1-100,200-400,402-1000 is represented
517# internally as a list of three pairs: [(1,100), (200,400),
518# (402,1000)]. The internal representation is always kept normalized.
519#
520# The constructor has up to three arguments:
521# - the string used to initialize the set (default ''),
522# - the separator between ranges (default ',')
523# - the separator between begin and end of a range (default '-')
524# The separators may be regular expressions and should be different.
525#
526# The tostring() function yields a string that can be passed to another
527# IntSet constructor; __repr__() is a valid IntSet constructor itself.
528#
529# XXX The default begin/end separator means that negative numbers are
530# not supported very well.
531#
532# XXX There are currently no operations to remove set elements.
533
534class IntSet:
535
536 def __init__(self, data = None, sep = ',', rng = '-'):
537 self.pairs = []
538 self.sep = sep
539 self.rng = rng
540 if data: self.fromstring(data)
541
542 def reset(self):
543 self.pairs = []
544
545 def __cmp__(self, other):
546 return cmp(self.pairs, other.pairs)
547
548 def __hash__(self):
549 return hash(self.pairs)
550
551 def __repr__(self):
552 return 'IntSet(%s, %s, %s)' % (`self.tostring()`,
553 `self.sep`, `self.rng`)
554
555 def normalize(self):
556 self.pairs.sort()
557 i = 1
558 while i < len(self.pairs):
559 alo, ahi = self.pairs[i-1]
560 blo, bhi = self.pairs[i]
561 if ahi >= blo-1:
562 self.pairs[i-1:i+1] = [
563 (alo, max(ahi, bhi))]
564 else:
565 i = i+1
566
567 def tostring(self):
568 s = ''
569 for lo, hi in self.pairs:
570 if lo == hi: t = `lo`
571 else: t = `lo` + self.rng + `hi`
572 if s: s = s + (self.sep + t)
573 else: s = t
574 return s
575
576 def tolist(self):
577 l = []
578 for lo, hi in self.pairs:
579 m = range(lo, hi+1)
580 l = l + m
581 return l
582
583 def fromlist(self, list):
584 for i in list:
585 self.append(i)
586
587 def clone(self):
588 new = IntSet()
589 new.pairs = self.pairs[:]
590 return new
591
592 def min(self):
593 return self.pairs[0][0]
594
595 def max(self):
596 return self.pairs[-1][-1]
597
598 def contains(self, x):
599 for lo, hi in self.pairs:
600 if lo <= x <= hi: return 1
601 return 0
602
603 def append(self, x):
604 for i in range(len(self.pairs)):
605 lo, hi = self.pairs[i]
606 if x < lo: # Need to insert before
607 if x+1 == lo:
608 self.pairs[i] = (x, hi)
609 else:
610 self.pairs.insert(i, (x, x))
611 if i > 0 and x-1 == self.pairs[i-1][1]:
612 # Merge with previous
613 self.pairs[i-1:i+1] = [
614 (self.pairs[i-1][0],
615 self.pairs[i][1])
616 ]
617 return
618 if x <= hi: # Already in set
619 return
620 i = len(self.pairs) - 1
621 if i >= 0:
622 lo, hi = self.pairs[i]
623 if x-1 == hi:
624 self.pairs[i] = lo, x
625 return
626 self.pairs.append((x, x))
627
628 def addpair(self, xlo, xhi):
629 if xlo > xhi: return
630 self.pairs.append((xlo, xhi))
631 self.normalize()
632
633 def fromstring(self, data):
634 import string, regsub
635 new = []
636 for part in regsub.split(data, self.sep):
637 list = []
638 for subp in regsub.split(part, self.rng):
639 s = string.strip(subp)
640 list.append(string.atoi(s))
641 if len(list) == 1:
642 new.append((list[0], list[0]))
643 elif len(list) == 2 and list[0] <= list[1]:
644 new.append((list[0], list[1]))
645 else:
646 raise ValueError, 'bad data passed to IntSet'
647 self.pairs = self.pairs + new
648 self.normalize()
649
650
651# Subroutines to read/write entries in .mh_profile and .mh_sequences
652
653def pickline(file, key, casefold = 1):
654 try:
655 f = open(file, 'r')
656 except IOError:
657 return None
658 pat = key + ':'
659 if casefold:
660 prog = regex.compile(pat, regex.casefold)
661 else:
662 prog = regex.compile(pat)
663 while 1:
664 line = f.readline()
665 if not line: break
666 if prog.match(line) == len(line):
667 text = line[len(key)+1:]
668 while 1:
669 line = f.readline()
670 if not line or \
671 line[0] not in string.whitespace:
672 break
673 text = text + line
674 return string.strip(text)
675 return None
676
677def updateline(file, key, value, casefold = 1):
678 try:
679 f = open(file, 'r')
680 lines = f.readlines()
681 f.close()
682 except IOError:
683 lines = []
684 pat = key + ':\(.*\)\n'
685 if casefold:
686 prog = regex.compile(pat, regex.casefold)
687 else:
688 prog = regex.compile(pat)
689 if value is None:
690 newline = None
691 else:
692 newline = '%s: %s' % (key, value)
693 for i in range(len(lines)):
694 line = lines[i]
695 if prog.match(line) == len(line):
696 if newline is None:
697 del lines[i]
698 else:
699 lines[i] = newline
700 break
701 else:
702 if newline is not None:
703 lines.append(newline)
704 f = open(tempfile, 'w')
705 for line in lines:
706 f.write(line)
707 f.close()
708
709
710# Test program
711
712def test():
713 global mh, f
714 os.system('rm -rf $HOME/Mail/@test')
715 mh = MH()
716 def do(s): print s; print eval(s)
717 do('mh.listfolders()')
718 do('mh.listallfolders()')
719 testfolders = ['@test', '@test/test1', '@test/test2',
720 '@test/test1/test11', '@test/test1/test12',
721 '@test/test1/test11/test111']
722 for t in testfolders: do('mh.makefolder(%s)' % `t`)
723 do('mh.listsubfolders(\'@test\')')
724 do('mh.listallsubfolders(\'@test\')')
725 f = mh.openfolder('@test')
726 do('f.listsubfolders()')
727 do('f.listallsubfolders()')
728 do('f.getsequences()')
729 seqs = f.getsequences()
730 seqs['foo'] = IntSet('1-10 12-20', ' ').tolist()
731 print seqs
732 f.putsequences(seqs)
733 do('f.getsequences()')
734 testfolders.reverse()
735 for t in testfolders: do('mh.deletefolder(%s)' % `t`)
736 do('mh.getcontext()')
737 context = mh.getcontext()
738 f = mh.openfolder(context)
739 do('f.listmessages()')
740 do('f.getcurrent()')
741
742
743if __name__ == '__main__':
744 test()