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.removemessages(list) # remove messages in list from folder
43 # f.refilemessages(list, tofolder) # move messages in list to other folder
44 # f.movemessage(n, tofolder, ton) # move one message to a given destination
45 # f.copymessage(n, tofolder, ton) # copy one message to a given destination
47 # m = f.openmessage(n) # new open message object (costs a file descriptor)
48 # m is a derived class of mimetools.Message(rfc822.Message), with:
49 # s = m.getheadertext() # text of message's headers
50 # s = m.getheadertext(pred) # text of message's headers, filtered by pred
51 # s = m.getbodytext() # text of message's body, decoded
52 # s = m.getbodytext(0) # text of message's body, not decoded
54 # XXX To do, functionality:
56 # - create, send messages
58 # XXX To do, organization:
59 # - move IntSet to separate file
60 # - move most Message functionality to module mimetools
63 # Customizable defaults
65 MH_PROFILE
= '~/.mh_profile'
67 MH_SEQUENCES
= '.mh_sequences'
75 from stat
import ST_NLINK
81 from bisect
import bisect
89 # Class representing a particular collection of folders.
90 # Optional constructor arguments are the pathname for the directory
91 # containing the collection, and the MH profile to use.
92 # If either is omitted or empty a default is used; the default
93 # directory is taken from the MH profile if it is specified there.
98 def __init__(self
, path
= None, profile
= None):
99 if not profile
: profile
= MH_PROFILE
100 self
.profile
= os
.path
.expanduser(profile
)
101 if not path
: path
= self
.getprofile('Path')
102 if not path
: path
= PATH
103 if not os
.path
.isabs(path
) and path
[0] != '~':
104 path
= os
.path
.join('~', path
)
105 path
= os
.path
.expanduser(path
)
106 if not os
.path
.isdir(path
): raise Error
, 'MH() path not found'
109 # String representation
111 return 'MH(%s, %s)' % (`self
.path`
, `self
.profile`
)
113 # Routine to print an error. May be overridden by a derived class
114 def error(self
, msg
, *args
):
115 sys
.stderr
.write('MH error: %s\n' % (msg
% args
))
117 # Return a profile entry, None if not found
118 def getprofile(self
, key
):
119 return pickline(self
.profile
, key
)
121 # Return the path (the name of the collection's directory)
125 # Return the name of the current folder
126 def getcontext(self
):
127 context
= pickline(os
.path
.join(self
.getpath(), 'context'),
129 if not context
: context
= 'inbox'
132 # Set the name of the current folder
133 def setcontext(self
, context
):
134 fn
= os
.path
.join(self
.getpath(), 'context')
136 f
.write("Current-Folder: %s\n" % context
)
139 # Return the names of the top-level folders
140 def listfolders(self
):
142 path
= self
.getpath()
143 for name
in os
.listdir(path
):
144 fullname
= os
.path
.join(path
, name
)
145 if os
.path
.isdir(fullname
):
150 # Return the names of the subfolders in a given folder
151 # (prefixed with the given folder name)
152 def listsubfolders(self
, name
):
153 fullname
= os
.path
.join(self
.path
, name
)
154 # Get the link count so we can avoid listing folders
155 # that have no subfolders.
156 st
= os
.stat(fullname
)
157 nlinks
= st
[ST_NLINK
]
161 subnames
= os
.listdir(fullname
)
162 for subname
in subnames
:
163 fullsubname
= os
.path
.join(fullname
, subname
)
164 if os
.path
.isdir(fullsubname
):
165 name_subname
= os
.path
.join(name
, subname
)
166 subfolders
.append(name_subname
)
167 # Stop looking for subfolders when
168 # we've seen them all
175 # Return the names of all folders, including subfolders, recursively
176 def listallfolders(self
):
177 return self
.listallsubfolders('')
179 # Return the names of subfolders in a given folder, recursively
180 def listallsubfolders(self
, name
):
181 fullname
= os
.path
.join(self
.path
, name
)
182 # Get the link count so we can avoid listing folders
183 # that have no subfolders.
184 st
= os
.stat(fullname
)
185 nlinks
= st
[ST_NLINK
]
189 subnames
= os
.listdir(fullname
)
190 for subname
in subnames
:
191 if subname
[0] == ',' or isnumeric(subname
): continue
192 fullsubname
= os
.path
.join(fullname
, subname
)
193 if os
.path
.isdir(fullsubname
):
194 name_subname
= os
.path
.join(name
, subname
)
195 subfolders
.append(name_subname
)
196 if not os
.path
.islink(fullsubname
):
197 subsubfolders
= self
.listallsubfolders(
199 subfolders
= subfolders
+ subsubfolders
200 # Stop looking for subfolders when
201 # we've seen them all
208 # Return a new Folder object for the named folder
209 def openfolder(self
, name
):
210 return Folder(self
, name
)
212 # Create a new folder. This raises os.error if the folder
214 def makefolder(self
, name
):
215 protect
= pickline(self
.profile
, 'Folder-Protect')
216 if protect
and isnumeric(protect
):
217 mode
= string
.atoi(protect
, 8)
219 mode
= FOLDER_PROTECT
220 os
.mkdir(os
.path
.join(self
.getpath(), name
), mode
)
222 # Delete a folder. This removes files in the folder but not
223 # subdirectories. If deleting the folder itself fails it
225 def deletefolder(self
, name
):
226 fullname
= os
.path
.join(self
.getpath(), name
)
227 for subname
in os
.listdir(fullname
):
228 fullsubname
= os
.path
.join(fullname
, subname
)
230 os
.unlink(fullsubname
)
232 self
.error('%s not deleted, continuing...' %
237 # Class representing a particular folder
239 numericprog
= re
.compile('^[1-9][0-9]*$')
241 return numericprog
.match(str) is not None
246 def __init__(self
, mh
, name
):
249 if not os
.path
.isdir(self
.getfullname()):
250 raise Error
, 'no folder %s' % name
252 # String representation
254 return 'Folder(%s, %s)' % (`self
.mh`
, `self
.name`
)
256 # Error message handler
257 def error(self
, *args
):
258 apply(self
.mh
.error
, args
)
260 # Return the full pathname of the folder
261 def getfullname(self
):
262 return os
.path
.join(self
.mh
.path
, self
.name
)
264 # Return the full pathname of the folder's sequences file
265 def getsequencesfilename(self
):
266 return os
.path
.join(self
.getfullname(), MH_SEQUENCES
)
268 # Return the full pathname of a message in the folder
269 def getmessagefilename(self
, n
):
270 return os
.path
.join(self
.getfullname(), str(n
))
272 # Return list of direct subfolders
273 def listsubfolders(self
):
274 return self
.mh
.listsubfolders(self
.name
)
276 # Return list of all subfolders
277 def listallsubfolders(self
):
278 return self
.mh
.listallsubfolders(self
.name
)
280 # Return the list of messages currently present in the folder.
281 # As a side effect, set self.last to the last message (or 0)
282 def listmessages(self
):
284 match
= numericprog
.match
285 append
= messages
.append
286 for name
in os
.listdir(self
.getfullname()):
289 messages
= map(string
.atoi
, messages
)
292 self
.last
= messages
[-1]
297 # Return the set of sequences for the folder
298 def getsequences(self
):
300 fullname
= self
.getsequencesfilename()
302 f
= open(fullname
, 'r')
308 fields
= string
.splitfields(line
, ':')
310 self
.error('bad sequence in %s: %s' %
311 (fullname
, string
.strip(line
)))
312 key
= string
.strip(fields
[0])
313 value
= IntSet(string
.strip(fields
[1]), ' ').tolist()
314 sequences
[key
] = value
317 # Write the set of sequences back to the folder
318 def putsequences(self
, sequences
):
319 fullname
= self
.getsequencesfilename()
321 for key
in sequences
.keys():
323 s
.fromlist(sequences
[key
])
324 if not f
: f
= open(fullname
, 'w')
325 f
.write('%s: %s\n' % (key
, s
.tostring()))
334 # Return the current message. Raise KeyError when there is none
335 def getcurrent(self
):
336 seqs
= self
.getsequences()
338 return max(seqs
['cur'])
339 except (ValueError, KeyError):
340 raise Error
, "no cur message"
342 # Set the current message
343 def setcurrent(self
, n
):
344 updateline(self
.getsequencesfilename(), 'cur', str(n
), 0)
346 # Parse an MH sequence specification into a message list.
347 # Attempt to mimic mh-sequence(5) as close as possible.
348 # Also attempt to mimic observed behavior regarding which
349 # conditions cause which error messages
350 def parsesequence(self
, seq
):
351 # XXX Still not complete (see mh-format(5)).
353 # - 'prev', 'next' as count
354 # - Sequence-Negation option
355 all
= self
.listmessages()
356 # Observed behavior: test for empty folder is done first
358 raise Error
, "no messages in %s" % self
.name
359 # Common case first: all is frequently the default
362 # Test for X:Y before X-Y because 'seq:-n' matches both
363 i
= string
.find(seq
, ':')
365 head
, dir, tail
= seq
[:i
], '', seq
[i
+1:]
367 dir, tail
= tail
[:1], tail
[1:]
368 if not isnumeric(tail
):
369 raise Error
, "bad message list %s" % seq
371 count
= string
.atoi(tail
)
372 except (ValueError, OverflowError):
373 # Can't use sys.maxint because of i+count below
376 anchor
= self
._parseindex
(head
, all
)
378 seqs
= self
.getsequences()
379 if not seqs
.has_key(head
):
381 msg
= "bad message list %s" % seq
382 raise Error
, msg
, sys
.exc_info()[2]
385 raise Error
, "sequence %s empty" % head
392 if head
in ('prev', 'last'):
395 i
= bisect(all
, anchor
)
396 return all
[max(0, i
-count
):i
]
398 i
= bisect(all
, anchor
-1)
399 return all
[i
:i
+count
]
401 i
= string
.find(seq
, '-')
403 begin
= self
._parseindex
(seq
[:i
], all
)
404 end
= self
._parseindex
(seq
[i
+1:], all
)
405 i
= bisect(all
, begin
-1)
409 raise Error
, "bad message list %s" % seq
411 # Neither X:Y nor X-Y; must be a number or a (pseudo-)sequence
413 n
= self
._parseindex
(seq
, all
)
415 seqs
= self
.getsequences()
416 if not seqs
.has_key(seq
):
418 msg
= "bad message list %s" % seq
424 raise Error
, "message %d doesn't exist" % n
426 raise Error
, "no %s message" % seq
430 # Internal: parse a message number (or cur, first, etc.)
431 def _parseindex(self
, seq
, all
):
434 return string
.atoi(seq
)
435 except (OverflowError, ValueError):
437 if seq
in ('cur', '.'):
438 return self
.getcurrent()
444 n
= self
.getcurrent()
449 raise Error
, "no next message"
451 n
= self
.getcurrent()
454 raise Error
, "no prev message"
458 raise Error
, "no prev message"
461 # Open a message -- returns a Message object
462 def openmessage(self
, n
):
463 return Message(self
, n
)
465 # Remove one or more messages -- may raise os.error
466 def removemessages(self
, list):
470 path
= self
.getmessagefilename(n
)
471 commapath
= self
.getmessagefilename(',' + str(n
))
477 os
.rename(path
, commapath
)
478 except os
.error
, msg
:
483 self
.removefromallsequences(deleted
)
486 raise os
.error
, errors
[0]
488 raise os
.error
, ('multiple errors:', errors
)
490 # Refile one or more messages -- may raise os.error.
491 # 'tofolder' is an open folder object
492 def refilemessages(self
, list, tofolder
, keepsequences
=0):
496 ton
= tofolder
.getlast() + 1
497 path
= self
.getmessagefilename(n
)
498 topath
= tofolder
.getmessagefilename(ton
)
500 os
.rename(path
, topath
)
504 shutil
.copy2(path
, topath
)
506 except (IOError, os
.error
), msg
:
513 tofolder
.setlast(ton
)
517 tofolder
._copysequences
(self
, refiled
.items())
518 self
.removefromallsequences(refiled
.keys())
521 raise os
.error
, errors
[0]
523 raise os
.error
, ('multiple errors:', errors
)
525 # Helper for refilemessages() to copy sequences
526 def _copysequences(self
, fromfolder
, refileditems
):
527 fromsequences
= fromfolder
.getsequences()
528 tosequences
= self
.getsequences()
530 for name
, seq
in fromsequences
.items():
532 toseq
= tosequences
[name
]
537 for fromn
, ton
in refileditems
:
542 tosequences
[name
] = toseq
544 self
.putsequences(tosequences
)
546 # Move one message over a specific destination message,
547 # which may or may not already exist.
548 def movemessage(self
, n
, tofolder
, ton
):
549 path
= self
.getmessagefilename(n
)
550 # Open it to check that it exists
554 topath
= tofolder
.getmessagefilename(ton
)
555 backuptopath
= tofolder
.getmessagefilename(',%d' % ton
)
557 os
.rename(topath
, backuptopath
)
561 os
.rename(path
, topath
)
566 tofolder
.setlast(None)
567 shutil
.copy2(path
, topath
)
576 self
.removefromallsequences([n
])
578 # Copy one message over a specific destination message,
579 # which may or may not already exist.
580 def copymessage(self
, n
, tofolder
, ton
):
581 path
= self
.getmessagefilename(n
)
582 # Open it to check that it exists
586 topath
= tofolder
.getmessagefilename(ton
)
587 backuptopath
= tofolder
.getmessagefilename(',%d' % ton
)
589 os
.rename(topath
, backuptopath
)
594 tofolder
.setlast(None)
595 shutil
.copy2(path
, topath
)
604 # Create a message, with text from the open file txt.
605 def createmessage(self
, n
, txt
):
606 path
= self
.getmessagefilename(n
)
607 backuppath
= self
.getmessagefilename(',%d' % n
)
609 os
.rename(path
, backuppath
)
617 buf
= txt
.read(BUFSIZE
)
630 # Remove one or more messages from all sequeuces (including last)
631 # -- but not from 'cur'!!!
632 def removefromallsequences(self
, list):
633 if hasattr(self
, 'last') and self
.last
in list:
635 sequences
= self
.getsequences()
637 for name
, seq
in sequences
.items():
647 self
.putsequences(sequences
)
649 # Return the last message number
651 if not hasattr(self
, 'last'):
652 messages
= self
.listmessages()
655 # Set the last message number
656 def setlast(self
, last
):
658 if hasattr(self
, 'last'):
663 class Message(mimetools
.Message
):
666 def __init__(self
, f
, n
, fp
= None):
670 path
= f
.getmessagefilename(n
)
672 mimetools
.Message
.__init
__(self
, fp
)
674 # String representation
676 return 'Message(%s, %s)' % (repr(self
.folder
), self
.number
)
678 # Return the message's header text as a string. If an
679 # argument is specified, it is used as a filter predicate to
680 # decide which headers to return (its argument is the header
681 # name converted to lower case).
682 def getheadertext(self
, pred
= None):
684 return string
.joinfields(self
.headers
, '')
687 for line
in self
.headers
:
688 if line
[0] not in string
.whitespace
:
689 i
= string
.find(line
, ':')
691 hit
= pred(string
.lower(line
[:i
]))
692 if hit
: headers
.append(line
)
693 return string
.joinfields(headers
, '')
695 # Return the message's body text as string. This undoes a
696 # Content-Transfer-Encoding, but does not interpret other MIME
697 # features (e.g. multipart messages). To suppress to
698 # decoding, pass a 0 as argument
699 def getbodytext(self
, decode
= 1):
700 self
.fp
.seek(self
.startofbody
)
701 encoding
= self
.getencoding()
702 if not decode
or encoding
in ('7bit', '8bit', 'binary'):
703 return self
.fp
.read()
704 from StringIO
import StringIO
706 mimetools
.decode(self
.fp
, output
, encoding
)
707 return output
.getvalue()
709 # Only for multipart messages: return the message's body as a
710 # list of SubMessage objects. Each submessage object behaves
711 # (almost) as a Message object.
712 def getbodyparts(self
):
713 if self
.getmaintype() != 'multipart':
714 raise Error
, 'Content-Type is not multipart/*'
715 bdry
= self
.getparam('boundary')
717 raise Error
, 'multipart/* without boundary param'
718 self
.fp
.seek(self
.startofbody
)
719 mf
= multifile
.MultiFile(self
.fp
)
723 n
= str(self
.number
) + '.' + `
1 + len(parts
)`
724 part
= SubMessage(self
.folder
, n
, mf
)
729 # Return body, either a string or a list of messages
731 if self
.getmaintype() == 'multipart':
732 return self
.getbodyparts()
734 return self
.getbodytext()
737 class SubMessage(Message
):
740 def __init__(self
, f
, n
, fp
):
741 Message
.__init
__(self
, f
, n
, fp
)
742 if self
.getmaintype() == 'multipart':
743 self
.body
= Message
.getbodyparts(self
)
745 self
.body
= Message
.getbodytext(self
)
746 # XXX If this is big, should remember file pointers
748 # String representation
750 f
, n
, fp
= self
.folder
, self
.number
, self
.fp
751 return 'SubMessage(%s, %s, %s)' % (f
, n
, fp
)
753 def getbodytext(self
):
754 if type(self
.body
) == type(''):
757 def getbodyparts(self
):
758 if type(self
.body
) == type([]):
765 # Class implementing sets of integers.
767 # This is an efficient representation for sets consisting of several
768 # continuous ranges, e.g. 1-100,200-400,402-1000 is represented
769 # internally as a list of three pairs: [(1,100), (200,400),
770 # (402,1000)]. The internal representation is always kept normalized.
772 # The constructor has up to three arguments:
773 # - the string used to initialize the set (default ''),
774 # - the separator between ranges (default ',')
775 # - the separator between begin and end of a range (default '-')
776 # The separators must be strings (not regexprs) and should be different.
778 # The tostring() function yields a string that can be passed to another
779 # IntSet constructor; __repr__() is a valid IntSet constructor itself.
781 # XXX The default begin/end separator means that negative numbers are
782 # not supported very well.
784 # XXX There are currently no operations to remove set elements.
788 def __init__(self
, data
= None, sep
= ',', rng
= '-'):
792 if data
: self
.fromstring(data
)
797 def __cmp__(self
, other
):
798 return cmp(self
.pairs
, other
.pairs
)
801 return hash(self
.pairs
)
804 return 'IntSet(%s, %s, %s)' % (`self
.tostring()`
,
805 `self
.sep`
, `self
.rng`
)
810 while i
< len(self
.pairs
):
811 alo
, ahi
= self
.pairs
[i
-1]
812 blo
, bhi
= self
.pairs
[i
]
814 self
.pairs
[i
-1:i
+1] = [(alo
, max(ahi
, bhi
))]
820 for lo
, hi
in self
.pairs
:
821 if lo
== hi
: t
= `lo`
822 else: t
= `lo`
+ self
.rng
+ `hi`
823 if s
: s
= s
+ (self
.sep
+ t
)
829 for lo
, hi
in self
.pairs
:
834 def fromlist(self
, list):
840 new
.pairs
= self
.pairs
[:]
844 return self
.pairs
[0][0]
847 return self
.pairs
[-1][-1]
849 def contains(self
, x
):
850 for lo
, hi
in self
.pairs
:
851 if lo
<= x
<= hi
: return 1
855 for i
in range(len(self
.pairs
)):
856 lo
, hi
= self
.pairs
[i
]
857 if x
< lo
: # Need to insert before
859 self
.pairs
[i
] = (x
, hi
)
861 self
.pairs
.insert(i
, (x
, x
))
862 if i
> 0 and x
-1 == self
.pairs
[i
-1][1]:
863 # Merge with previous
864 self
.pairs
[i
-1:i
+1] = [
869 if x
<= hi
: # Already in set
871 i
= len(self
.pairs
) - 1
873 lo
, hi
= self
.pairs
[i
]
875 self
.pairs
[i
] = lo
, x
877 self
.pairs
.append((x
, x
))
879 def addpair(self
, xlo
, xhi
):
881 self
.pairs
.append((xlo
, xhi
))
884 def fromstring(self
, data
):
887 for part
in string
.splitfields(data
, self
.sep
):
889 for subp
in string
.splitfields(part
, self
.rng
):
890 s
= string
.strip(subp
)
891 list.append(string
.atoi(s
))
893 new
.append((list[0], list[0]))
894 elif len(list) == 2 and list[0] <= list[1]:
895 new
.append((list[0], list[1]))
897 raise ValueError, 'bad data passed to IntSet'
898 self
.pairs
= self
.pairs
+ new
902 # Subroutines to read/write entries in .mh_profile and .mh_sequences
904 def pickline(file, key
, casefold
= 1):
909 pat
= re
.escape(key
) + ':'
910 prog
= re
.compile(pat
, casefold
and re
.IGNORECASE
)
915 text
= line
[len(key
)+1:]
918 if not line
or line
[0] not in string
.whitespace
:
921 return string
.strip(text
)
924 def updateline(file, key
, value
, casefold
= 1):
927 lines
= f
.readlines()
931 pat
= re
.escape(key
) + ':(.*)\n'
932 prog
= re
.compile(pat
, casefold
and re
.IGNORECASE
)
936 newline
= '%s: %s\n' % (key
, value
)
937 for i
in range(len(lines
)):
946 if newline
is not None:
947 lines
.append(newline
)
948 tempfile
= file + "~"
949 f
= open(tempfile
, 'w')
953 os
.rename(tempfile
, file)
960 os
.system('rm -rf $HOME/Mail/@test')
962 def do(s
): print s
; print eval(s
)
963 do('mh.listfolders()')
964 do('mh.listallfolders()')
965 testfolders
= ['@test', '@test/test1', '@test/test2',
966 '@test/test1/test11', '@test/test1/test12',
967 '@test/test1/test11/test111']
968 for t
in testfolders
: do('mh.makefolder(%s)' % `t`
)
969 do('mh.listsubfolders(\'@test\')')
970 do('mh.listallsubfolders(\'@test\')')
971 f
= mh
.openfolder('@test')
972 do('f.listsubfolders()')
973 do('f.listallsubfolders()')
974 do('f.getsequences()')
975 seqs
= f
.getsequences()
976 seqs
['foo'] = IntSet('1-10 12-20', ' ').tolist()
979 do('f.getsequences()')
980 testfolders
.reverse()
981 for t
in testfolders
: do('mh.deletefolder(%s)' % `t`
)
982 do('mh.getcontext()')
983 context
= mh
.getcontext()
984 f
= mh
.openfolder(context
)
986 for seq
in ['first', 'last', 'cur', '.', 'prev', 'next',
987 'first:3', 'last:3', 'cur:3', 'cur:-3',
989 '1:3', '1:-3', '100:3', '100:-3', '10000:3', '10000:-3',
992 do('f.parsesequence(%s)' % `seq`
)
995 stuff
= os
.popen("pick %s 2>/dev/null" % `seq`
).read()
996 list = map(string
.atoi
, string
.split(stuff
))
997 print list, "<-- pick"
998 do('f.listmessages()')
1001 if __name__
== '__main__':