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
56 # XXX To do, functionality:
60 # XXX To do, organization:
61 # - move IntSet to separate file
62 # - move most Message functionality to module mimetools
65 # Customizable defaults
67 MH_PROFILE
= '~/.mh_profile'
69 MH_SEQUENCES
= '.mh_sequences'
70 FOLDER_PROTECT
= 0o700
81 from bisect
import bisect
83 __all__
= ["MH","Error","Folder","Message"]
87 class Error(Exception):
92 """Class representing a particular collection of folders.
93 Optional constructor arguments are the pathname for the directory
94 containing the collection, and the MH profile to use.
95 If either is omitted or empty a default is used; the default
96 directory is taken from the MH profile if it is specified there."""
98 def __init__(self
, path
= None, profile
= None):
100 if profile
is None: profile
= MH_PROFILE
101 self
.profile
= os
.path
.expanduser(profile
)
102 if path
is None: 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')
111 """String representation."""
112 return 'MH(%r, %r)' % (self
.path
, self
.profile
)
114 def error(self
, msg
, *args
):
115 """Routine to print an error. May be overridden by a derived class."""
116 sys
.stderr
.write('MH error: %s\n' % (msg
% args
))
118 def getprofile(self
, key
):
119 """Return a profile entry, None if not found."""
120 return pickline(self
.profile
, key
)
123 """Return the path (the name of the collection's directory)."""
126 def getcontext(self
):
127 """Return the name of the current folder."""
128 context
= pickline(os
.path
.join(self
.getpath(), 'context'),
130 if not context
: context
= 'inbox'
133 def setcontext(self
, context
):
134 """Set the name of the current folder."""
135 fn
= os
.path
.join(self
.getpath(), 'context')
137 f
.write("Current-Folder: %s\n" % context
)
140 def listfolders(self
):
141 """Return the names of the top-level folders."""
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 def listsubfolders(self
, name
):
152 """Return the names of the subfolders in a given folder
153 (prefixed with the given folder 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 nlinks
= os
.stat(fullname
).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 def listallfolders(self
):
176 """Return the names of all folders and subfolders, recursively."""
177 return self
.listallsubfolders('')
179 def listallsubfolders(self
, name
):
180 """Return the names of subfolders in a given folder, recursively."""
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 nlinks
= os
.stat(fullname
).st_nlink
188 subnames
= os
.listdir(fullname
)
189 for subname
in subnames
:
190 if subname
[0] == ',' or isnumeric(subname
): continue
191 fullsubname
= os
.path
.join(fullname
, subname
)
192 if os
.path
.isdir(fullsubname
):
193 name_subname
= os
.path
.join(name
, subname
)
194 subfolders
.append(name_subname
)
195 if not os
.path
.islink(fullsubname
):
196 subsubfolders
= self
.listallsubfolders(
198 subfolders
= subfolders
+ subsubfolders
199 # Stop looking for subfolders when
200 # we've seen them all
207 def openfolder(self
, name
):
208 """Return a new Folder object for the named folder."""
209 return Folder(self
, name
)
211 def makefolder(self
, name
):
212 """Create a new folder (or raise os.error if it cannot be created)."""
213 protect
= pickline(self
.profile
, 'Folder-Protect')
214 if protect
and isnumeric(protect
):
215 mode
= int(protect
, 8)
217 mode
= FOLDER_PROTECT
218 os
.mkdir(os
.path
.join(self
.getpath(), name
), mode
)
220 def deletefolder(self
, name
):
221 """Delete a folder. This removes files in the folder but not
222 subdirectories. Raise os.error if deleting the folder itself fails."""
223 fullname
= os
.path
.join(self
.getpath(), name
)
224 for subname
in os
.listdir(fullname
):
225 fullsubname
= os
.path
.join(fullname
, subname
)
227 os
.unlink(fullsubname
)
229 self
.error('%s not deleted, continuing...' %
234 numericprog
= re
.compile('^[1-9][0-9]*$')
236 return numericprog
.match(str) is not None
239 """Class representing a particular folder."""
241 def __init__(self
, mh
, name
):
245 if not os
.path
.isdir(self
.getfullname()):
246 raise Error('no folder %s' % name
)
249 """String representation."""
250 return 'Folder(%r, %r)' % (self
.mh
, self
.name
)
252 def error(self
, *args
):
253 """Error message handler."""
256 def getfullname(self
):
257 """Return the full pathname of the folder."""
258 return os
.path
.join(self
.mh
.path
, self
.name
)
260 def getsequencesfilename(self
):
261 """Return the full pathname of the folder's sequences file."""
262 return os
.path
.join(self
.getfullname(), MH_SEQUENCES
)
264 def getmessagefilename(self
, n
):
265 """Return the full pathname of a message in the folder."""
266 return os
.path
.join(self
.getfullname(), str(n
))
268 def listsubfolders(self
):
269 """Return list of direct subfolders."""
270 return self
.mh
.listsubfolders(self
.name
)
272 def listallsubfolders(self
):
273 """Return list of all subfolders."""
274 return self
.mh
.listallsubfolders(self
.name
)
276 def listmessages(self
):
277 """Return the list of messages currently present in the folder.
278 As a side effect, set self.last to the last message (or 0)."""
280 match
= numericprog
.match
281 append
= messages
.append
282 for name
in os
.listdir(self
.getfullname()):
285 messages
= sorted(map(int, messages
))
287 self
.last
= messages
[-1]
292 def getsequences(self
):
293 """Return the set of sequences for the folder."""
295 fullname
= self
.getsequencesfilename()
297 f
= open(fullname
, 'r')
303 fields
= line
.split(':')
305 self
.error('bad sequence in %s: %s' %
306 (fullname
, line
.strip()))
307 key
= fields
[0].strip()
308 value
= IntSet(fields
[1].strip(), ' ').tolist()
309 sequences
[key
] = value
312 def putsequences(self
, sequences
):
313 """Write the set of sequences back to the folder."""
314 fullname
= self
.getsequencesfilename()
316 for key
, seq
in sequences
.items():
319 if not f
: f
= open(fullname
, 'w')
320 f
.write('%s: %s\n' % (key
, s
.tostring()))
329 def getcurrent(self
):
330 """Return the current message. Raise Error when there is none."""
331 seqs
= self
.getsequences()
333 return max(seqs
['cur'])
334 except (ValueError, KeyError):
335 raise Error("no cur message")
337 def setcurrent(self
, n
):
338 """Set the current message."""
339 updateline(self
.getsequencesfilename(), 'cur', str(n
), 0)
341 def parsesequence(self
, seq
):
342 """Parse an MH sequence specification into a message list.
343 Attempt to mimic mh-sequence(5) as close as possible.
344 Also attempt to mimic observed behavior regarding which
345 conditions cause which error messages."""
346 # XXX Still not complete (see mh-format(5)).
348 # - 'prev', 'next' as count
349 # - Sequence-Negation option
350 all
= self
.listmessages()
351 # Observed behavior: test for empty folder is done first
353 raise Error("no messages in %s" % self
.name
)
354 # Common case first: all is frequently the default
357 # Test for X:Y before X-Y because 'seq:-n' matches both
360 head
, dir, tail
= seq
[:i
], '', seq
[i
+1:]
362 dir, tail
= tail
[:1], tail
[1:]
363 if not isnumeric(tail
):
364 raise Error("bad message list %s" % seq
)
367 except (ValueError, OverflowError):
368 # Can't use sys.maxsize because of i+count below
371 anchor
= self
._parseindex
(head
, all
)
373 seqs
= self
.getsequences()
376 msg
= "bad message list %s" % seq
377 raise Error(msg
).with_traceback(sys
.exc_info()[2])
380 raise Error("sequence %s empty" % head
)
387 if head
in ('prev', 'last'):
390 i
= bisect(all
, anchor
)
391 return all
[max(0, i
-count
):i
]
393 i
= bisect(all
, anchor
-1)
394 return all
[i
:i
+count
]
398 begin
= self
._parseindex
(seq
[:i
], all
)
399 end
= self
._parseindex
(seq
[i
+1:], all
)
400 i
= bisect(all
, begin
-1)
404 raise Error("bad message list %s" % seq
)
406 # Neither X:Y nor X-Y; must be a number or a (pseudo-)sequence
408 n
= self
._parseindex
(seq
, all
)
410 seqs
= self
.getsequences()
413 msg
= "bad message list %s" % seq
419 raise Error("message %d doesn't exist" % n
)
421 raise Error("no %s message" % seq
)
425 def _parseindex(self
, seq
, all
):
426 """Internal: parse a message number (or cur, first, etc.)."""
430 except (OverflowError, ValueError):
432 if seq
in ('cur', '.'):
433 return self
.getcurrent()
439 n
= self
.getcurrent()
444 raise Error("no next message")
446 n
= self
.getcurrent()
449 raise Error("no prev message")
453 raise Error("no prev message")
456 def openmessage(self
, n
):
457 """Open a message -- returns a Message object."""
458 return Message(self
, n
)
460 def removemessages(self
, list):
461 """Remove one or more messages -- may raise os.error."""
465 path
= self
.getmessagefilename(n
)
466 commapath
= self
.getmessagefilename(',' + str(n
))
472 os
.rename(path
, commapath
)
473 except os
.error
as msg
:
478 self
.removefromallsequences(deleted
)
481 raise os
.error(errors
[0])
483 raise os
.error('multiple errors:', errors
)
485 def refilemessages(self
, list, tofolder
, keepsequences
=0):
486 """Refile one or more messages -- may raise os.error.
487 'tofolder' is an open folder object."""
491 ton
= tofolder
.getlast() + 1
492 path
= self
.getmessagefilename(n
)
493 topath
= tofolder
.getmessagefilename(ton
)
495 os
.rename(path
, topath
)
499 shutil
.copy2(path
, topath
)
501 except (IOError, os
.error
) as msg
:
508 tofolder
.setlast(ton
)
512 tofolder
._copysequences
(self
, refiled
.items())
513 self
.removefromallsequences(refiled
.keys())
516 raise os
.error(errors
[0])
518 raise os
.error('multiple errors:', errors
)
520 def _copysequences(self
, fromfolder
, refileditems
):
521 """Helper for refilemessages() to copy sequences."""
522 fromsequences
= fromfolder
.getsequences()
523 tosequences
= self
.getsequences()
525 for name
, seq
in fromsequences
.items():
527 toseq
= tosequences
[name
]
532 for fromn
, ton
in refileditems
:
537 tosequences
[name
] = toseq
539 self
.putsequences(tosequences
)
541 def movemessage(self
, n
, tofolder
, ton
):
542 """Move one message over a specific destination message,
543 which may or may not already exist."""
544 path
= self
.getmessagefilename(n
)
545 # Open it to check that it exists
549 topath
= tofolder
.getmessagefilename(ton
)
550 backuptopath
= tofolder
.getmessagefilename(',%d' % ton
)
552 os
.rename(topath
, backuptopath
)
556 os
.rename(path
, topath
)
561 tofolder
.setlast(None)
562 shutil
.copy2(path
, topath
)
571 self
.removefromallsequences([n
])
573 def copymessage(self
, n
, tofolder
, ton
):
574 """Copy one message over a specific destination message,
575 which may or may not already exist."""
576 path
= self
.getmessagefilename(n
)
577 # Open it to check that it exists
581 topath
= tofolder
.getmessagefilename(ton
)
582 backuptopath
= tofolder
.getmessagefilename(',%d' % ton
)
584 os
.rename(topath
, backuptopath
)
589 tofolder
.setlast(None)
590 shutil
.copy2(path
, topath
)
599 def createmessage(self
, n
, txt
):
600 """Create a message, with text from the open file txt."""
601 path
= self
.getmessagefilename(n
)
602 backuppath
= self
.getmessagefilename(',%d' % n
)
604 os
.rename(path
, backuppath
)
612 buf
= txt
.read(BUFSIZE
)
625 def removefromallsequences(self
, list):
626 """Remove one or more messages from all sequences (including last)
627 -- but not from 'cur'!!!"""
628 if hasattr(self
, 'last') and self
.last
in list:
630 sequences
= self
.getsequences()
632 for name
, seq
in sequences
.items():
642 self
.putsequences(sequences
)
645 """Return the last message number."""
646 if not hasattr(self
, 'last'):
647 self
.listmessages() # Set self.last
650 def setlast(self
, last
):
651 """Set the last message number."""
653 if hasattr(self
, 'last'):
658 class Message(mimetools
.Message
):
660 def __init__(self
, f
, n
, fp
= None):
665 path
= f
.getmessagefilename(n
)
667 mimetools
.Message
.__init
__(self
, fp
)
670 """String representation."""
671 return 'Message(%s, %s)' % (repr(self
.folder
), self
.number
)
673 def getheadertext(self
, pred
= None):
674 """Return the message's header text as a string. If an
675 argument is specified, it is used as a filter predicate to
676 decide which headers to return (its argument is the header
677 name converted to lower case)."""
679 return ''.join(self
.headers
)
682 for line
in self
.headers
:
683 if not line
[0].isspace():
686 hit
= pred(line
[:i
].lower())
687 if hit
: headers
.append(line
)
688 return ''.join(headers
)
690 def getbodytext(self
, decode
= 1):
691 """Return the message's body text as string. This undoes a
692 Content-Transfer-Encoding, but does not interpret other MIME
693 features (e.g. multipart messages). To suppress decoding,
694 pass 0 as an argument."""
695 self
.fp
.seek(self
.startofbody
)
696 encoding
= self
.getencoding()
697 if not decode
or encoding
in ('', '7bit', '8bit', 'binary'):
698 return self
.fp
.read()
699 from io
import StringIO
701 mimetools
.decode(self
.fp
, output
, encoding
)
702 return output
.getvalue()
704 def getbodyparts(self
):
705 """Only for multipart messages: return the message's body as a
706 list of SubMessage objects. Each submessage object behaves
707 (almost) as a Message object."""
708 if self
.getmaintype() != 'multipart':
709 raise Error('Content-Type is not multipart/*')
710 bdry
= self
.getparam('boundary')
712 raise Error('multipart/* without boundary param')
713 self
.fp
.seek(self
.startofbody
)
714 mf
= multifile
.MultiFile(self
.fp
)
718 n
= "%s.%r" % (self
.number
, 1 + len(parts
))
719 part
= SubMessage(self
.folder
, n
, mf
)
725 """Return body, either a string or a list of messages."""
726 if self
.getmaintype() == 'multipart':
727 return self
.getbodyparts()
729 return self
.getbodytext()
732 class SubMessage(Message
):
734 def __init__(self
, f
, n
, fp
):
736 Message
.__init
__(self
, f
, n
, fp
)
737 if self
.getmaintype() == 'multipart':
738 self
.body
= Message
.getbodyparts(self
)
740 self
.body
= Message
.getbodytext(self
)
741 self
.bodyencoded
= Message
.getbodytext(self
, decode
=0)
742 # XXX If this is big, should remember file pointers
745 """String representation."""
746 f
, n
, fp
= self
.folder
, self
.number
, self
.fp
747 return 'SubMessage(%s, %s, %s)' % (f
, n
, fp
)
749 def getbodytext(self
, decode
= 1):
751 return self
.bodyencoded
752 if type(self
.body
) == type(''):
755 def getbodyparts(self
):
756 if type(self
.body
) == type([]):
764 """Class implementing sets of integers.
766 This is an efficient representation for sets consisting of several
767 continuous ranges, e.g. 1-100,200-400,402-1000 is represented
768 internally as a list of three pairs: [(1,100), (200,400),
769 (402,1000)]. The internal representation is always kept normalized.
771 The constructor has up to three arguments:
772 - the string used to initialize the set (default ''),
773 - the separator between ranges (default ',')
774 - the separator between begin and end of a range (default '-')
775 The separators must be strings (not regexprs) and should be different.
777 The tostring() function yields a string that can be passed to another
778 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.
786 def __init__(self
, data
= None, sep
= ',', rng
= '-'):
790 if data
: self
.fromstring(data
)
795 def __cmp__(self
, other
):
796 return cmp(self
.pairs
, other
.pairs
)
799 return hash(self
.pairs
)
802 return 'IntSet(%r, %r, %r)' % (self
.tostring(), self
.sep
, self
.rng
)
807 while i
< len(self
.pairs
):
808 alo
, ahi
= self
.pairs
[i
-1]
809 blo
, bhi
= self
.pairs
[i
]
811 self
.pairs
[i
-1:i
+1] = [(alo
, max(ahi
, bhi
))]
817 for lo
, hi
in self
.pairs
:
818 if lo
== hi
: t
= repr(lo
)
819 else: t
= repr(lo
) + self
.rng
+ repr(hi
)
820 if s
: s
= s
+ (self
.sep
+ t
)
826 for lo
, hi
in self
.pairs
:
827 m
= list(range(lo
, hi
+1))
831 def fromlist(self
, list):
837 new
.pairs
= self
.pairs
[:]
841 return self
.pairs
[0][0]
844 return self
.pairs
[-1][-1]
846 def contains(self
, x
):
847 for lo
, hi
in self
.pairs
:
848 if lo
<= x
<= hi
: return True
852 for i
in range(len(self
.pairs
)):
853 lo
, hi
= self
.pairs
[i
]
854 if x
< lo
: # Need to insert before
856 self
.pairs
[i
] = (x
, hi
)
858 self
.pairs
.insert(i
, (x
, x
))
859 if i
> 0 and x
-1 == self
.pairs
[i
-1][1]:
860 # Merge with previous
861 self
.pairs
[i
-1:i
+1] = [
866 if x
<= hi
: # Already in set
868 i
= len(self
.pairs
) - 1
870 lo
, hi
= self
.pairs
[i
]
872 self
.pairs
[i
] = lo
, x
874 self
.pairs
.append((x
, x
))
876 def addpair(self
, xlo
, xhi
):
878 self
.pairs
.append((xlo
, xhi
))
881 def fromstring(self
, data
):
883 for part
in data
.split(self
.sep
):
885 for subp
in part
.split(self
.rng
):
889 new
.append((list[0], list[0]))
890 elif len(list) == 2 and list[0] <= list[1]:
891 new
.append((list[0], list[1]))
893 raise ValueError('bad data passed to IntSet')
894 self
.pairs
= self
.pairs
+ new
898 # Subroutines to read/write entries in .mh_profile and .mh_sequences
900 def pickline(file, key
, casefold
= 1):
905 pat
= re
.escape(key
) + ':'
906 prog
= re
.compile(pat
, casefold
and re
.IGNORECASE
)
911 text
= line
[len(key
)+1:]
914 if not line
or not line
[0].isspace():
920 def updateline(file, key
, value
, casefold
= 1):
923 lines
= f
.readlines()
927 pat
= re
.escape(key
) + ':(.*)\n'
928 prog
= re
.compile(pat
, casefold
and re
.IGNORECASE
)
932 newline
= '%s: %s\n' % (key
, value
)
933 for i
in range(len(lines
)):
942 if newline
is not None:
943 lines
.append(newline
)
944 tempfile
= file + "~"
945 f
= open(tempfile
, 'w')
949 os
.rename(tempfile
, file)
956 os
.system('rm -rf $HOME/Mail/@test')
958 def do(s
): print(s
); print(eval(s
))
959 do('mh.listfolders()')
960 do('mh.listallfolders()')
961 testfolders
= ['@test', '@test/test1', '@test/test2',
962 '@test/test1/test11', '@test/test1/test12',
963 '@test/test1/test11/test111']
964 for t
in testfolders
: do('mh.makefolder(%r)' % (t
,))
965 do('mh.listsubfolders(\'@test\')')
966 do('mh.listallsubfolders(\'@test\')')
967 f
= mh
.openfolder('@test')
968 do('f.listsubfolders()')
969 do('f.listallsubfolders()')
970 do('f.getsequences()')
971 seqs
= f
.getsequences()
972 seqs
['foo'] = IntSet('1-10 12-20', ' ').tolist()
975 do('f.getsequences()')
976 for t
in reversed(testfolders
): do('mh.deletefolder(%r)' % (t
,))
977 do('mh.getcontext()')
978 context
= mh
.getcontext()
979 f
= mh
.openfolder(context
)
981 for seq
in ('first', 'last', 'cur', '.', 'prev', 'next',
982 'first:3', 'last:3', 'cur:3', 'cur:-3',
984 '1:3', '1:-3', '100:3', '100:-3', '10000:3', '10000:-3',
987 do('f.parsesequence(%r)' % (seq
,))
990 stuff
= os
.popen("pick %r 2>/dev/null" % (seq
,)).read()
991 list = map(int, stuff
.split())
992 print(list, "<-- pick")
993 do('f.listmessages()')
996 if __name__
== '__main__':