1 # MH interface -- purely object-oriented (well, almost)
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
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 # mh.setcontext(name) # set name of current folder
17 # list = mh.listfolders() # names of top-level folders
18 # list = mh.listallfolders() # names of all folders, including subfolders
19 # list = mh.listsubfolders(name) # direct subfolders of given folder
20 # list = mh.listallsubfolders(name) # all subfolders of given folder
22 # mh.makefolder(name) # create new folder
23 # mh.deletefolder(name) # delete folder -- must have no subfolders
25 # f = mh.openfolder(name) # new open folder object
27 # f.error(format, ...) # same as mh.error(format, ...)
28 # path = f.getfullname() # folder's full pathname
29 # path = f.getsequencesfilename() # full pathname of folder's sequences file
30 # path = f.getmessagefilename(n) # full pathname of message n in folder
32 # list = f.listmessages() # list of messages in folder (as numbers)
33 # n = f.getcurrent() # get current message
34 # f.setcurrent(n) # set current message
35 # list = f.parsesequence(seq) # parse msgs syntax into list of messages
36 # n = f.getlast() # get last message (0 if no messagse)
37 # f.setlast(n) # set last message (internal use only)
39 # dict = f.getsequences() # dictionary of sequences in folder {name: list}
40 # f.putsequences(dict) # write sequences back to folder
42 # f.createmessage(n, fp) # add message from file f as number n
43 # f.removemessages(list) # remove messages in list from folder
44 # f.refilemessages(list, tofolder) # move messages in list to other folder
45 # f.movemessage(n, tofolder, ton) # move one message to a given destination
46 # f.copymessage(n, tofolder, ton) # copy one message to a given destination
48 # m = f.openmessage(n) # new open message object (costs a file descriptor)
49 # m is a derived class of mimetools.Message(rfc822.Message), with:
50 # s = m.getheadertext() # text of message's headers
51 # s = m.getheadertext(pred) # text of message's headers, filtered by pred
52 # s = m.getbodytext() # text of message's body, decoded
53 # s = m.getbodytext(0) # text of message's body, not decoded
55 # XXX To do, functionality:
59 # XXX To do, organization:
60 # - move IntSet to separate file
61 # - move most Message functionality to module mimetools
64 # Customizable defaults
66 MH_PROFILE
= '~/.mh_profile'
68 MH_SEQUENCES
= '.mh_sequences'
76 from stat
import ST_NLINK
82 from bisect
import bisect
90 # Class representing a particular collection of folders.
91 # Optional constructor arguments are the pathname for the directory
92 # containing the collection, and the MH profile to use.
93 # If either is omitted or empty a default is used; the default
94 # directory is taken from the MH profile if it is specified there.
99 def __init__(self
, path
= None, profile
= None):
100 if not profile
: profile
= MH_PROFILE
101 self
.profile
= os
.path
.expanduser(profile
)
102 if not path
: path
= self
.getprofile('Path')
103 if not path
: path
= PATH
104 if not os
.path
.isabs(path
) and path
[0] != '~':
105 path
= os
.path
.join('~', path
)
106 path
= os
.path
.expanduser(path
)
107 if not os
.path
.isdir(path
): raise Error
, 'MH() path not found'
110 # String representation
112 return 'MH(%s, %s)' % (`self
.path`
, `self
.profile`
)
114 # Routine to print an error. May be overridden by a derived class
115 def error(self
, msg
, *args
):
116 sys
.stderr
.write('MH error: %s\n' % (msg
% args
))
118 # Return a profile entry, None if not found
119 def getprofile(self
, key
):
120 return pickline(self
.profile
, key
)
122 # Return the path (the name of the collection's directory)
126 # Return the name of the current folder
127 def getcontext(self
):
128 context
= pickline(os
.path
.join(self
.getpath(), 'context'),
130 if not context
: context
= 'inbox'
133 # Set the name of the current folder
134 def setcontext(self
, context
):
135 fn
= os
.path
.join(self
.getpath(), 'context')
137 f
.write("Current-Folder: %s\n" % context
)
140 # Return the names of the top-level folders
141 def listfolders(self
):
143 path
= self
.getpath()
144 for name
in os
.listdir(path
):
145 fullname
= os
.path
.join(path
, name
)
146 if os
.path
.isdir(fullname
):
151 # Return the names of the subfolders in a given folder
152 # (prefixed with the given folder name)
153 def listsubfolders(self
, name
):
154 fullname
= os
.path
.join(self
.path
, name
)
155 # Get the link count so we can avoid listing folders
156 # that have no subfolders.
157 st
= os
.stat(fullname
)
158 nlinks
= st
[ST_NLINK
]
162 subnames
= os
.listdir(fullname
)
163 for subname
in subnames
:
164 fullsubname
= os
.path
.join(fullname
, subname
)
165 if os
.path
.isdir(fullsubname
):
166 name_subname
= os
.path
.join(name
, subname
)
167 subfolders
.append(name_subname
)
168 # Stop looking for subfolders when
169 # we've seen them all
176 # Return the names of all folders, including subfolders, recursively
177 def listallfolders(self
):
178 return self
.listallsubfolders('')
180 # Return the names of subfolders in a given folder, recursively
181 def listallsubfolders(self
, name
):
182 fullname
= os
.path
.join(self
.path
, name
)
183 # Get the link count so we can avoid listing folders
184 # that have no subfolders.
185 st
= os
.stat(fullname
)
186 nlinks
= st
[ST_NLINK
]
190 subnames
= os
.listdir(fullname
)
191 for subname
in subnames
:
192 if subname
[0] == ',' or isnumeric(subname
): continue
193 fullsubname
= os
.path
.join(fullname
, subname
)
194 if os
.path
.isdir(fullsubname
):
195 name_subname
= os
.path
.join(name
, subname
)
196 subfolders
.append(name_subname
)
197 if not os
.path
.islink(fullsubname
):
198 subsubfolders
= self
.listallsubfolders(
200 subfolders
= subfolders
+ subsubfolders
201 # Stop looking for subfolders when
202 # we've seen them all
209 # Return a new Folder object for the named folder
210 def openfolder(self
, name
):
211 return Folder(self
, name
)
213 # Create a new folder. This raises os.error if the folder
215 def makefolder(self
, name
):
216 protect
= pickline(self
.profile
, 'Folder-Protect')
217 if protect
and isnumeric(protect
):
218 mode
= string
.atoi(protect
, 8)
220 mode
= FOLDER_PROTECT
221 os
.mkdir(os
.path
.join(self
.getpath(), name
), mode
)
223 # Delete a folder. This removes files in the folder but not
224 # subdirectories. If deleting the folder itself fails it
226 def deletefolder(self
, name
):
227 fullname
= os
.path
.join(self
.getpath(), name
)
228 for subname
in os
.listdir(fullname
):
229 fullsubname
= os
.path
.join(fullname
, subname
)
231 os
.unlink(fullsubname
)
233 self
.error('%s not deleted, continuing...' %
238 # Class representing a particular folder
240 numericprog
= re
.compile('^[1-9][0-9]*$')
242 return numericprog
.match(str) is not None
247 def __init__(self
, mh
, name
):
250 if not os
.path
.isdir(self
.getfullname()):
251 raise Error
, 'no folder %s' % name
253 # String representation
255 return 'Folder(%s, %s)' % (`self
.mh`
, `self
.name`
)
257 # Error message handler
258 def error(self
, *args
):
259 apply(self
.mh
.error
, args
)
261 # Return the full pathname of the folder
262 def getfullname(self
):
263 return os
.path
.join(self
.mh
.path
, self
.name
)
265 # Return the full pathname of the folder's sequences file
266 def getsequencesfilename(self
):
267 return os
.path
.join(self
.getfullname(), MH_SEQUENCES
)
269 # Return the full pathname of a message in the folder
270 def getmessagefilename(self
, n
):
271 return os
.path
.join(self
.getfullname(), str(n
))
273 # Return list of direct subfolders
274 def listsubfolders(self
):
275 return self
.mh
.listsubfolders(self
.name
)
277 # Return list of all subfolders
278 def listallsubfolders(self
):
279 return self
.mh
.listallsubfolders(self
.name
)
281 # Return the list of messages currently present in the folder.
282 # As a side effect, set self.last to the last message (or 0)
283 def listmessages(self
):
285 match
= numericprog
.match
286 append
= messages
.append
287 for name
in os
.listdir(self
.getfullname()):
290 messages
= map(string
.atoi
, messages
)
293 self
.last
= messages
[-1]
298 # Return the set of sequences for the folder
299 def getsequences(self
):
301 fullname
= self
.getsequencesfilename()
303 f
= open(fullname
, 'r')
309 fields
= string
.splitfields(line
, ':')
311 self
.error('bad sequence in %s: %s' %
312 (fullname
, string
.strip(line
)))
313 key
= string
.strip(fields
[0])
314 value
= IntSet(string
.strip(fields
[1]), ' ').tolist()
315 sequences
[key
] = value
318 # Write the set of sequences back to the folder
319 def putsequences(self
, sequences
):
320 fullname
= self
.getsequencesfilename()
322 for key
in sequences
.keys():
324 s
.fromlist(sequences
[key
])
325 if not f
: f
= open(fullname
, 'w')
326 f
.write('%s: %s\n' % (key
, s
.tostring()))
335 # Return the current message. Raise KeyError when there is none
336 def getcurrent(self
):
337 seqs
= self
.getsequences()
339 return max(seqs
['cur'])
340 except (ValueError, KeyError):
341 raise Error
, "no cur message"
343 # Set the current message
344 def setcurrent(self
, n
):
345 updateline(self
.getsequencesfilename(), 'cur', str(n
), 0)
347 # Parse an MH sequence specification into a message list.
348 # Attempt to mimic mh-sequence(5) as close as possible.
349 # Also attempt to mimic observed behavior regarding which
350 # conditions cause which error messages
351 def parsesequence(self
, seq
):
352 # XXX Still not complete (see mh-format(5)).
354 # - 'prev', 'next' as count
355 # - Sequence-Negation option
356 all
= self
.listmessages()
357 # Observed behavior: test for empty folder is done first
359 raise Error
, "no messages in %s" % self
.name
360 # Common case first: all is frequently the default
363 # Test for X:Y before X-Y because 'seq:-n' matches both
364 i
= string
.find(seq
, ':')
366 head
, dir, tail
= seq
[:i
], '', seq
[i
+1:]
368 dir, tail
= tail
[:1], tail
[1:]
369 if not isnumeric(tail
):
370 raise Error
, "bad message list %s" % seq
372 count
= string
.atoi(tail
)
373 except (ValueError, OverflowError):
374 # Can't use sys.maxint because of i+count below
377 anchor
= self
._parseindex
(head
, all
)
379 seqs
= self
.getsequences()
380 if not seqs
.has_key(head
):
382 msg
= "bad message list %s" % seq
383 raise Error
, msg
, sys
.exc_info()[2]
386 raise Error
, "sequence %s empty" % head
393 if head
in ('prev', 'last'):
396 i
= bisect(all
, anchor
)
397 return all
[max(0, i
-count
):i
]
399 i
= bisect(all
, anchor
-1)
400 return all
[i
:i
+count
]
402 i
= string
.find(seq
, '-')
404 begin
= self
._parseindex
(seq
[:i
], all
)
405 end
= self
._parseindex
(seq
[i
+1:], all
)
406 i
= bisect(all
, begin
-1)
410 raise Error
, "bad message list %s" % seq
412 # Neither X:Y nor X-Y; must be a number or a (pseudo-)sequence
414 n
= self
._parseindex
(seq
, all
)
416 seqs
= self
.getsequences()
417 if not seqs
.has_key(seq
):
419 msg
= "bad message list %s" % seq
425 raise Error
, "message %d doesn't exist" % n
427 raise Error
, "no %s message" % seq
431 # Internal: parse a message number (or cur, first, etc.)
432 def _parseindex(self
, seq
, all
):
435 return string
.atoi(seq
)
436 except (OverflowError, ValueError):
438 if seq
in ('cur', '.'):
439 return self
.getcurrent()
445 n
= self
.getcurrent()
450 raise Error
, "no next message"
452 n
= self
.getcurrent()
455 raise Error
, "no prev message"
459 raise Error
, "no prev message"
462 # Open a message -- returns a Message object
463 def openmessage(self
, n
):
464 return Message(self
, n
)
466 # Remove one or more messages -- may raise os.error
467 def removemessages(self
, list):
471 path
= self
.getmessagefilename(n
)
472 commapath
= self
.getmessagefilename(',' + str(n
))
478 os
.rename(path
, commapath
)
479 except os
.error
, msg
:
484 self
.removefromallsequences(deleted
)
487 raise os
.error
, errors
[0]
489 raise os
.error
, ('multiple errors:', errors
)
491 # Refile one or more messages -- may raise os.error.
492 # 'tofolder' is an open folder object
493 def refilemessages(self
, list, tofolder
, keepsequences
=0):
497 ton
= tofolder
.getlast() + 1
498 path
= self
.getmessagefilename(n
)
499 topath
= tofolder
.getmessagefilename(ton
)
501 os
.rename(path
, topath
)
505 shutil
.copy2(path
, topath
)
507 except (IOError, os
.error
), msg
:
514 tofolder
.setlast(ton
)
518 tofolder
._copysequences
(self
, refiled
.items())
519 self
.removefromallsequences(refiled
.keys())
522 raise os
.error
, errors
[0]
524 raise os
.error
, ('multiple errors:', errors
)
526 # Helper for refilemessages() to copy sequences
527 def _copysequences(self
, fromfolder
, refileditems
):
528 fromsequences
= fromfolder
.getsequences()
529 tosequences
= self
.getsequences()
531 for name
, seq
in fromsequences
.items():
533 toseq
= tosequences
[name
]
538 for fromn
, ton
in refileditems
:
543 tosequences
[name
] = toseq
545 self
.putsequences(tosequences
)
547 # Move one message over a specific destination message,
548 # which may or may not already exist.
549 def movemessage(self
, n
, tofolder
, ton
):
550 path
= self
.getmessagefilename(n
)
551 # Open it to check that it exists
555 topath
= tofolder
.getmessagefilename(ton
)
556 backuptopath
= tofolder
.getmessagefilename(',%d' % ton
)
558 os
.rename(topath
, backuptopath
)
562 os
.rename(path
, topath
)
567 tofolder
.setlast(None)
568 shutil
.copy2(path
, topath
)
577 self
.removefromallsequences([n
])
579 # Copy one message over a specific destination message,
580 # which may or may not already exist.
581 def copymessage(self
, n
, tofolder
, ton
):
582 path
= self
.getmessagefilename(n
)
583 # Open it to check that it exists
587 topath
= tofolder
.getmessagefilename(ton
)
588 backuptopath
= tofolder
.getmessagefilename(',%d' % ton
)
590 os
.rename(topath
, backuptopath
)
595 tofolder
.setlast(None)
596 shutil
.copy2(path
, topath
)
605 # Create a message, with text from the open file txt.
606 def createmessage(self
, n
, txt
):
607 path
= self
.getmessagefilename(n
)
608 backuppath
= self
.getmessagefilename(',%d' % n
)
610 os
.rename(path
, backuppath
)
618 buf
= txt
.read(BUFSIZE
)
631 # Remove one or more messages from all sequeuces (including last)
632 # -- but not from 'cur'!!!
633 def removefromallsequences(self
, list):
634 if hasattr(self
, 'last') and self
.last
in list:
636 sequences
= self
.getsequences()
638 for name
, seq
in sequences
.items():
648 self
.putsequences(sequences
)
650 # Return the last message number
652 if not hasattr(self
, 'last'):
653 messages
= self
.listmessages()
656 # Set the last message number
657 def setlast(self
, last
):
659 if hasattr(self
, 'last'):
664 class Message(mimetools
.Message
):
667 def __init__(self
, f
, n
, fp
= None):
671 path
= f
.getmessagefilename(n
)
673 mimetools
.Message
.__init
__(self
, fp
)
675 # String representation
677 return 'Message(%s, %s)' % (repr(self
.folder
), self
.number
)
679 # Return the message's header text as a string. If an
680 # argument is specified, it is used as a filter predicate to
681 # decide which headers to return (its argument is the header
682 # name converted to lower case).
683 def getheadertext(self
, pred
= None):
685 return string
.joinfields(self
.headers
, '')
688 for line
in self
.headers
:
689 if line
[0] not in string
.whitespace
:
690 i
= string
.find(line
, ':')
692 hit
= pred(string
.lower(line
[:i
]))
693 if hit
: headers
.append(line
)
694 return string
.joinfields(headers
, '')
696 # Return the message's body text as string. This undoes a
697 # Content-Transfer-Encoding, but does not interpret other MIME
698 # features (e.g. multipart messages). To suppress to
699 # decoding, pass a 0 as argument
700 def getbodytext(self
, decode
= 1):
701 self
.fp
.seek(self
.startofbody
)
702 encoding
= self
.getencoding()
703 if not decode
or encoding
in ('', '7bit', '8bit', 'binary'):
704 return self
.fp
.read()
705 from StringIO
import StringIO
707 mimetools
.decode(self
.fp
, output
, encoding
)
708 return output
.getvalue()
710 # Only for multipart messages: return the message's body as a
711 # list of SubMessage objects. Each submessage object behaves
712 # (almost) as a Message object.
713 def getbodyparts(self
):
714 if self
.getmaintype() != 'multipart':
715 raise Error
, 'Content-Type is not multipart/*'
716 bdry
= self
.getparam('boundary')
718 raise Error
, 'multipart/* without boundary param'
719 self
.fp
.seek(self
.startofbody
)
720 mf
= multifile
.MultiFile(self
.fp
)
724 n
= str(self
.number
) + '.' + `
1 + len(parts
)`
725 part
= SubMessage(self
.folder
, n
, mf
)
730 # Return body, either a string or a list of messages
732 if self
.getmaintype() == 'multipart':
733 return self
.getbodyparts()
735 return self
.getbodytext()
738 class SubMessage(Message
):
741 def __init__(self
, f
, n
, fp
):
742 Message
.__init
__(self
, f
, n
, fp
)
743 if self
.getmaintype() == 'multipart':
744 self
.body
= Message
.getbodyparts(self
)
746 self
.body
= Message
.getbodytext(self
)
747 self
.bodyencoded
= Message
.getbodytext(self
, decode
=0)
748 # XXX If this is big, should remember file pointers
750 # String representation
752 f
, n
, fp
= self
.folder
, self
.number
, self
.fp
753 return 'SubMessage(%s, %s, %s)' % (f
, n
, fp
)
755 def getbodytext(self
, decode
= 1):
757 return self
.bodyencoded
758 if type(self
.body
) == type(''):
761 def getbodyparts(self
):
762 if type(self
.body
) == type([]):
769 # Class implementing sets of integers.
771 # This is an efficient representation for sets consisting of several
772 # continuous ranges, e.g. 1-100,200-400,402-1000 is represented
773 # internally as a list of three pairs: [(1,100), (200,400),
774 # (402,1000)]. The internal representation is always kept normalized.
776 # The constructor has up to three arguments:
777 # - the string used to initialize the set (default ''),
778 # - the separator between ranges (default ',')
779 # - the separator between begin and end of a range (default '-')
780 # The separators must be strings (not regexprs) and should be different.
782 # The tostring() function yields a string that can be passed to another
783 # IntSet constructor; __repr__() is a valid IntSet constructor itself.
785 # XXX The default begin/end separator means that negative numbers are
786 # not supported very well.
788 # XXX There are currently no operations to remove set elements.
792 def __init__(self
, data
= None, sep
= ',', rng
= '-'):
796 if data
: self
.fromstring(data
)
801 def __cmp__(self
, other
):
802 return cmp(self
.pairs
, other
.pairs
)
805 return hash(self
.pairs
)
808 return 'IntSet(%s, %s, %s)' % (`self
.tostring()`
,
809 `self
.sep`
, `self
.rng`
)
814 while i
< len(self
.pairs
):
815 alo
, ahi
= self
.pairs
[i
-1]
816 blo
, bhi
= self
.pairs
[i
]
818 self
.pairs
[i
-1:i
+1] = [(alo
, max(ahi
, bhi
))]
824 for lo
, hi
in self
.pairs
:
825 if lo
== hi
: t
= `lo`
826 else: t
= `lo`
+ self
.rng
+ `hi`
827 if s
: s
= s
+ (self
.sep
+ t
)
833 for lo
, hi
in self
.pairs
:
838 def fromlist(self
, list):
844 new
.pairs
= self
.pairs
[:]
848 return self
.pairs
[0][0]
851 return self
.pairs
[-1][-1]
853 def contains(self
, x
):
854 for lo
, hi
in self
.pairs
:
855 if lo
<= x
<= hi
: return 1
859 for i
in range(len(self
.pairs
)):
860 lo
, hi
= self
.pairs
[i
]
861 if x
< lo
: # Need to insert before
863 self
.pairs
[i
] = (x
, hi
)
865 self
.pairs
.insert(i
, (x
, x
))
866 if i
> 0 and x
-1 == self
.pairs
[i
-1][1]:
867 # Merge with previous
868 self
.pairs
[i
-1:i
+1] = [
873 if x
<= hi
: # Already in set
875 i
= len(self
.pairs
) - 1
877 lo
, hi
= self
.pairs
[i
]
879 self
.pairs
[i
] = lo
, x
881 self
.pairs
.append((x
, x
))
883 def addpair(self
, xlo
, xhi
):
885 self
.pairs
.append((xlo
, xhi
))
888 def fromstring(self
, data
):
891 for part
in string
.splitfields(data
, self
.sep
):
893 for subp
in string
.splitfields(part
, self
.rng
):
894 s
= string
.strip(subp
)
895 list.append(string
.atoi(s
))
897 new
.append((list[0], list[0]))
898 elif len(list) == 2 and list[0] <= list[1]:
899 new
.append((list[0], list[1]))
901 raise ValueError, 'bad data passed to IntSet'
902 self
.pairs
= self
.pairs
+ new
906 # Subroutines to read/write entries in .mh_profile and .mh_sequences
908 def pickline(file, key
, casefold
= 1):
913 pat
= re
.escape(key
) + ':'
914 prog
= re
.compile(pat
, casefold
and re
.IGNORECASE
)
919 text
= line
[len(key
)+1:]
922 if not line
or line
[0] not in string
.whitespace
:
925 return string
.strip(text
)
928 def updateline(file, key
, value
, casefold
= 1):
931 lines
= f
.readlines()
935 pat
= re
.escape(key
) + ':(.*)\n'
936 prog
= re
.compile(pat
, casefold
and re
.IGNORECASE
)
940 newline
= '%s: %s\n' % (key
, value
)
941 for i
in range(len(lines
)):
950 if newline
is not None:
951 lines
.append(newline
)
952 tempfile
= file + "~"
953 f
= open(tempfile
, 'w')
957 os
.rename(tempfile
, file)
964 os
.system('rm -rf $HOME/Mail/@test')
966 def do(s
): print s
; print eval(s
)
967 do('mh.listfolders()')
968 do('mh.listallfolders()')
969 testfolders
= ['@test', '@test/test1', '@test/test2',
970 '@test/test1/test11', '@test/test1/test12',
971 '@test/test1/test11/test111']
972 for t
in testfolders
: do('mh.makefolder(%s)' % `t`
)
973 do('mh.listsubfolders(\'@test\')')
974 do('mh.listallsubfolders(\'@test\')')
975 f
= mh
.openfolder('@test')
976 do('f.listsubfolders()')
977 do('f.listallsubfolders()')
978 do('f.getsequences()')
979 seqs
= f
.getsequences()
980 seqs
['foo'] = IntSet('1-10 12-20', ' ').tolist()
983 do('f.getsequences()')
984 testfolders
.reverse()
985 for t
in testfolders
: do('mh.deletefolder(%s)' % `t`
)
986 do('mh.getcontext()')
987 context
= mh
.getcontext()
988 f
= mh
.openfolder(context
)
990 for seq
in ['first', 'last', 'cur', '.', 'prev', 'next',
991 'first:3', 'last:3', 'cur:3', 'cur:-3',
993 '1:3', '1:-3', '100:3', '100:-3', '10000:3', '10000:-3',
996 do('f.parsesequence(%s)' % `seq`
)
999 stuff
= os
.popen("pick %s 2>/dev/null" % `seq`
).read()
1000 list = map(string
.atoi
, string
.split(stuff
))
1001 print list, "<-- pick"
1002 do('f.listmessages()')
1005 if __name__
== '__main__':