diff options
author | Lars Wirzenius <liw@liw.fi> | 2019-11-02 10:54:13 +0200 |
---|---|---|
committer | Lars Wirzenius <liw@liw.fi> | 2019-11-02 10:54:13 +0200 |
commit | 2828885db093be86ef5b2c58f5c05ac3c4ed3664 (patch) | |
tree | 8185b82968b220aa543936e45be34fc239a5c8ea /slime-0.11 | |
download | slime-master.tar.gz |
Diffstat (limited to 'slime-0.11')
-rw-r--r-- | slime-0.11/Changes | 120 | ||||
-rw-r--r-- | slime-0.11/Makefile | 47 | ||||
-rw-r--r-- | slime-0.11/README | 250 | ||||
-rw-r--r-- | slime-0.11/StringIO2.py | 159 | ||||
-rw-r--r-- | slime-0.11/TODO | 169 | ||||
-rw-r--r-- | slime-0.11/doc/slime-manual.sgml | 36 | ||||
-rw-r--r-- | slime-0.11/helpers.py | 85 | ||||
-rw-r--r-- | slime-0.11/mhlib2.py | 915 | ||||
-rw-r--r-- | slime-0.11/slime | 5 | ||||
-rw-r--r-- | slime-0.11/slime.html | 112 | ||||
-rw-r--r-- | slime-0.11/slime_abstract.py | 541 | ||||
-rw-r--r-- | slime-0.11/slime_draft.py | 52 | ||||
-rw-r--r-- | slime-0.11/slime_folderinfo.py | 77 | ||||
-rw-r--r-- | slime-0.11/slime_mh.py | 245 | ||||
-rw-r--r-- | slime-0.11/slime_pgpmime.py | 168 | ||||
-rw-r--r-- | slime-0.11/slime_root.py | 77 | ||||
-rw-r--r-- | slime-0.11/slime_send.py | 171 | ||||
-rw-r--r-- | slime-0.11/slime_unix.py | 183 | ||||
-rw-r--r-- | slime-0.11/ui.py | 1058 | ||||
-rw-r--r-- | slime-0.11/ui_compose.py | 96 | ||||
-rw-r--r-- | slime-0.11/ui_config.py | 103 | ||||
-rw-r--r-- | slime-0.11/ui_helpers.py | 155 |
22 files changed, 4824 insertions, 0 deletions
diff --git a/slime-0.11/Changes b/slime-0.11/Changes new file mode 100644 index 0000000..ca73d00 --- /dev/null +++ b/slime-0.11/Changes @@ -0,0 +1,120 @@ +Version 0.11 (released Mon, 15 Sep 1997 00:36:15 +0300): + + * Added slime_folderinfo.py to CVS tree (and, therefore, + the distribution). + * Confused notes about new abstract classes for folders and messages. + +Version 0.10 (released Sun, 17 Aug 1997 18:49:31 +0300): + + * Fixed bug in rescanning MH subfolders that broke things for + the second time the folder was scanned. + * Show message count in folder list only for inboxes (speeds + things up quite a bit). + * Let user edit folder-specific info. At the moment the only + available bit for editing is whether a folder should be scanned + for new messages, but more will follow. + * Remove user's address(es) from recipients in replies. + * Composition window ask for conformation for send, abort, etc. + * MH folders are only rescanned if the directory has been modified + since the previous scan. This makes new message check fast. + * helpers.find_folder_by_name caches results. + * Made the frequency of new message checks settable. + * Main window shows total number of unread messages. + * Folder count is updated in main window. + +Version 0.9 (released Fri, 15 Aug 1997 00:32:23 +0300): + + * Added support for signature files. + * Added support for a default Reply-To header. + * Program is now started with the command `slime'. + * Added `make install'. + * Configuration can be saved. Note: Old version of pyliw had a + bug, and a newer version is needed. + * Show image/gif inline. + * Folders can be opened and closed in the folder list without + selecting them, by clicking on them with mouse button 2 (middle + one on a three-button mouse). + * Replies: Quotes are attributed. + * Folder list shows number of messages in each folder. + * Separated SlimeFolder.rescan_folder into .rescan_messages and + .rescan_subfolders. This speeds up searching for a specific + folder (the FCC folder) rather a bit. + * Use full pathnames when comparing folder names. + * User can enter names of inboxes. These are scanned for new + messages at program startup and periodically while running + (every five minutes, not yet settable). The user interface is + stupid (user must enter filenames), and will be changed + in a future version. + +Version 0.8 (released Sun, 10 Aug 1997 20:02:36 +0300) + + * Added the beginnings of a manual. Well, a skeleton to hold what + will be written later. + * PGP passphrase is remembered for N minutes (default is 60). + * Added support for an external editor. + * Copies of saved messages can be saved to a folder. + +Version 0.7 (released Sat, 9 Aug 1997 17:20:04 +0300) + + * Added `clean' target to Makefile. + * Modal dialog (YesNoDialog) releases grab immediately after + user has selected something. + * Added rudimentary configuration menu. + * Remove duplicate addresses when creating reply template. + * Remove empty headers when sending. + * Drafts are removed from folder when sent. + * "Compose message" will re-edit saved draft, if one is selected. + * Add date to message being sent. + * Added rudimentary PGP/MIME signed message creation. + * Temporary files are created with secretive permissions. + +Version 0.6 (released Sun, 27 Jul 1997 23:09:45 +0300) + + * Beginnings of support to send and reply to mail. Works, but + is still quite clumsy. Requires that localhost runs an SMTP + server. + +Version 0.5 (released Sat, 26 Jul 1997 20:08:08 +0300) + + * Put the Slime homepage under CVS. + * Added basic PGP/MIME support. May be specific Exmh messages, + but we'll see. + +Version 0.4 (released Fri, 25 Jul 1997 10:21:03 +0300) + + * Implemented folder.add_message, copy_message_here, and + move_message_here. + * Mouse button 3 moves current message to different folder + (as in exmh). + +Version 0.3 (released Wed, 23 Jul 1997 01:59:01 +0300) + + * Rescan in menu actually rescans. + * Identifiers for class-internal use start with underscore. + * Show the whole line in message list in bold for new articles. + * Exit asks for confirmation. + * Ask for permission before committing a folder. + +Version 0.2 (released Tue, 22 Jul 1997 01:06:46 +0300) + + * Use References in addition to In-Reply-To when building threads. + * Added command button for "Delete" to message window. + * Don't display empty "important" headers in message window. + * Call them "selected", not "important" headers. + * Automatic recognition whether ~/Mail is MH or unix top folder. + * Replace a newline in a subject with a space, for folder table + of contents purposes. + * Format dates correctly, even if they are just two digits. + * Message window scrolls by height-2, not height-3 lines. + * Subject in window title of message window has maximum length. + * Messages try to make sure they can't be used while the folder + they're in is closed. + * Unix messages use the file handle in the parent folder, instead + of keeping a copy for themselves. This way, if the parent wants + to close it, the messages will cause errors if they try to use it. + * Scrolling up and down one line at a time with cursor keys. + * In list of folders, subtrees can now be opened and closed. + * Added dirty bit to folder. This allow commit to do nothing, unless + necessary. + * Added and fixed caching of headers, to speed up threading, + among other things. diff --git a/slime-0.11/Makefile b/slime-0.11/Makefile new file mode 100644 index 0000000..e222167 --- /dev/null +++ b/slime-0.11/Makefile @@ -0,0 +1,47 @@ +prefix=/usr/local +bindir=$(prefix)/bin +libdir=$(prefix)/lib/slime + +# The following is for my private use, not for other people. +release_root=$(HOME)/public_html/Slime + +all: + echo foo + +install: + test -d $(bindir) || mkdir -p $(bindir) + test -d $(libdir) || mkdir -p $(libdir) + sed 's @slimedir@ $(libdir) g' slime > $(bindir)/slime + chmod a+rx $(bindir)/slime + cp *.py $(libdir) + chmod a+r $(libdir)/* + python -c 'import compileall; compileall.main()' $(libdir) + +manual: + cd doc; debiandoc2html slime-manual.sgml + +release: + if [ -z "$(version)" ]; then \ + echo "Use 'make release version=1.2.3' to make a release."; \ + exit 1; fi + rm -rf x + mkdir x + cd x && tag=version_`echo $(version) | tr . _` && \ + cvs -q export -d slime-$(version) -r $$tag slime && \ + tar cf - slime-$(version) | gzip -9 > ../slime-$(version).tar.gz + cp slime-$(version).tar.gz $(release_root)/slime.tar.gz + cp x/slime-$(version)/README $(release_root)/README + cp x/slime-$(version)/Changes $(release_root)/Changes + cp x/slime-$(version)/slime.html $(release_root)/index.html + cd x/slime-$(version) && $(MAKE) manual && \ + rm -rf $(release_root)/slime-manual.html && \ + cp -a doc/slime-manual.html $(release_root) + rm -rf x + +profile: + python -c 'import profile, slime_unix; profile.run("slime_unix.profile_main()", "profile.out")' + python -c 'import pstats; p=pstats.Stats("profile.out"); p.strip_dirs().sort_stats("stdname").print_stats(); p.print_callers(); p.print_callees()' > profile.txt + +clean: + rm -f *.pyc + rm -rf doc/slime-manual.html diff --git a/slime-0.11/README b/slime-0.11/README new file mode 100644 index 0000000..41ef3b6 --- /dev/null +++ b/slime-0.11/README @@ -0,0 +1,250 @@ +README for Stupid little mailer, or Slime for short +version 0.11 +by Lars Wirzenius, liw+slime@iki.fi + + +At the moment, this file is a random collection of notes for a new +mailer, code name Slime, which may or may not become a reality. + +Note that Slime is very much alpha level code, and is quite fragile. +Be careful. + +Installation and use + + 0. Make sure you have Python (with Tkinter) and Mitch Chapman's + UITools package installed, and that Python can find UITools. + You can find a link to UITools on the Slime home page. Set + the PYTHONPATH environment variable to include the directory + where it is installed, if necessary. + + 1. Unpack slime-<version>.tar.gz. + + 2. Run "python ui.py". + + 3. Play with it and watch your mail disappear. + + 4. Report any bugs and ideas to author. + +Background: Why a new mailer? What is missing from existing ones? + + I have not been satisfied by existing mailers, because + they are not very suitable for large amounts of mail, + do not support PGP or MIME well, don't have both a text + and an X interface, are too slow, or are otherwise + unsuitable. For example, I currently use Exmh, which + is good, but slow, and lacks a text interface. Emacs + has at least two fairly good mailers, but I won't use + Emacs as my editor. + + I could find a fairly good one and improve that, but most + good mailers are huge programs, and learning them well + enough to improve them significantly is a large task. + Some would also require me to learn a new language. For + example, Exmh is written in Tcl/Tk, but I refuse to + learn Tcl, since I don't like the language. + + Most of my requirements are simple enough to implement, + it's just that no mailer happens to satisfy all of them. + So, lured by the simplicity and hopefully complete + supporting libraries of Python, I'm considering to + write yet another mailer. + + That won't surprise my friends. After all, I wrote my + own editor, as well. :) + +Basic requirements + + * Must be simple to implement, since I can't afford to + spend lots of time on this. + + * Fast and efficient, within reason: if it feels fast enough, + it's fast enough, the point is not to optimize it for speed. + + * The implementation must be modular, so that support for new + mail folder formats, mail retrieval and sending mechanisms, + user interfaces, and so on can be easily added. + + I will probably implement only stuff that I need myself, + but other people should have the possiblity to extend it. + + * Excellent PGP integration, including PGP/MIME, application/pgp, + plain PGP messages, key extraction, fetching missing keys, + sending keys as attachments or in mail bodies, and so on. + Users should be able to basically ignore PGP support, as long + as everything works (but the mailer should scream when a letter + has a bad signature, for example). + + * Excellent MIME support, at least as good as Exmh. Attachments + and stuff should "just work". The mailer should show unknown + content types as well as it can, e.g., by showing all printable + characters, at least as an option. + + * Must work for huge amounts of e-mail correspondence, on the order of + thousands per day (e.g., via many large-volume mailing lists). + This means supporting arbitrary numbers of "inbox" folders + (including many APOP or IMAP folders) at the same time, doing + real message threading, supporting archiving and full text + searches, finding addresses easily, and keeping track of + messages that need to be processed still (this is different + from being unread). + + * The user interface should be simple to learn and use. Typical + operations should be a single mouse click or key press. All + dangerous operations (including deletion of mail) should be + difficult to invoke by accident. Going through unread + mail should happen just by pressing a space bar, + even if the mail is in many folders. + + * Configuration must be easy (not just via editing a text file) + for users. This means something like the Pine configuration + menu. Configuration should not be necessary to use the software, + however. + + * It must be possible to view any number of folders and messages + at once, and to edit any number of replies at once. A draft + folder a la MH must be supported. + + * Must be compatible with other mailers, i.e., not require that + users convert their folders to a particular folder format. + This makes it possible to use Slime concurrently with another + mailer (important during development and when users try it out). + + * Maybe: virtual folders, annotations, links between messages. + But these will probably be too much work to implement. + +Design + + Slime will be implemented in Python, if at all, and + will consist of some number of classes that implement + various features. Some of these classes will be abstract, + they will just define an interface to a large number + of similar objects. For example, there will be some + kind of Folder class, which defines the interface to a + mail folder, and different subclasses will implement + the interface to allow access to mailbox files, MH + directories, Maildir directories, IMAP folders, and + so on. The rest of the program will then not need to + care what the actual folder format is. + +Folder hierarchy + + A folder collection, such as ~/Mail for MH, or ~/mail for + Pine, is a folder, with no messages, just subfolders. + This way the folders in a collection form a natural + hierarchy. + + There is a SlimeFolderRoot class that lists all + folder collections. The user may add and remove folder + collections, represented as subfolders (of course), using + some sort of menu selection. An instance of this class + is made persistent (loaded at startup, saved at exit). + +Status header + + The following is gleaned from mutt sources: + + D = deleted + * = flagged + r = replied + - = read + O = old + N = new? + + And only one character? + + Unknown status chars: + + R + A + + +Redesigned abstract classes + + module is slime.py + + Exception: + error = 'slime.error' + + def thread_messages(list_of_messages): + def find_folder(folder_name): + + class Folder: + def __init__(self, full_name): + + def get_full_name(self): + def get_short_name(self): + + def delete(self): + + def lock(self): + def unlock(self): + + def scan_messages(self, smart=1): + def list_messages(self, choose=None): + def create_message(self, file): + def copy_message(self, msg): + def move_message(self, msg): + + def scan_subfolders(self, smart=1): + def list_subfolders(self): + def scan_subfolders(self): + def create_subfolder(self, relative_name): + + def is_clean(self): + def make_dirty(self): + def make_clean(self): + def commit(self): + + class Message: + def __init__(self, folder): + + def forget_headers(self): + def read_headers(self): + + def is_dirty(self): + def make_clean(self): + + def set_status(self, new_status): + def get_status(self): + def has_status(self, status): + def give_status(self, status): + def remove_status(self, status): + + def set_contents(self, file): + + def get_file(self): + + # union of the interfaces of rfc822.Message, mimetools.Message, + # and mhlib.Message. + ... + # or maybe there is a better interface that could be + # implemented by scratch to avoid using multiple layers? + + def test(): + - open folder + - count # of messages + - list some of the headers of each message + - thread messages + - delete message + - add message + - copy/move message to another folder (to each folder type) + - have N processes try to modify the folder at once + (some create messages, others delete them, some just + read the folder) + + + - communication between different windows? + + class MessageWindow: + def __init__(self): + def set(self, message): + + class MessageListWindow: + def __init__(self): + def set(self, list_of_messages): + def update(self, message): + + class FolderListWindow: + def __init__(self): + def set(self, list_of_folders): + def update(self, folder): diff --git a/slime-0.11/StringIO2.py b/slime-0.11/StringIO2.py new file mode 100644 index 0000000..505f057 --- /dev/null +++ b/slime-0.11/StringIO2.py @@ -0,0 +1,159 @@ +# class StringIO implements file-like objects that read/write a +# string buffer (a.k.a. "memory files"). +# +# This implements (nearly) all stdio methods. +# +# f = StringIO() # ready for writing +# f = StringIO(buf) # ready for reading +# f.close() # explicitly release resources held +# flag = f.isatty() # always false +# pos = f.tell() # get current position +# f.seek(pos) # set current position +# f.seek(pos, mode) # mode 0: absolute; 1: relative; 2: relative to EOF +# buf = f.read() # read until EOF +# buf = f.read(n) # read up to n bytes +# buf = f.readline() # read until end of line ('\n') or EOF +# list = f.readlines()# list of f.readline() results until EOF +# f.write(buf) # write at current position +# f.writelines(list) # for line in list: f.write(line) +# f.getvalue() # return whole file's contents as a string +# +# Notes: +# - Using a real file is often faster (but less convenient). +# - fileno() is left unimplemented so that code which uses it triggers +# an exception early. +# - Seeking far beyond EOF and then writing will insert real null +# bytes that occupy space in the buffer. +# - There's a simple test set (see end of this file). + +import string + +class StringIO: + def __init__(self, buf = ''): + self.buf = buf + self.len = len(buf) + self.buflist = [] + self.pos = 0 + self.closed = 0 + self.softspace = 0 + def close(self): + if not self.closed: + self.closed = 1 + del self.buf, self.pos + def isatty(self): + return 0 + def seek(self, pos, mode = 0): + if self.buflist: + self.buf = self.buf + string.joinfields(self.buflist, '') + self.buflist = [] + if mode == 1: + pos = pos + self.pos + elif mode == 2: + pos = pos + self.len + self.pos = max(0, pos) + def tell(self): + return self.pos + def read(self, n = -1): + if self.buflist: + self.buf = self.buf + string.joinfields(self.buflist, '') + self.buflist = [] + if n < 0: + newpos = self.len + else: + newpos = min(self.pos+n, self.len) + r = self.buf[self.pos:newpos] + self.pos = newpos + return r + def readline(self, length = None): + if self.buflist: + self.buf = self.buf + string.joinfields(self.buflist, '') + self.buflist = [] + i = string.find(self.buf, '\n', self.pos) + if i < 0: + newpos = self.len + else: + newpos = i+1 + if not length is None: + if self.pos + length < newpos: + newpos = self.pos + length + r = self.buf[self.pos:newpos] + self.pos = newpos + return r + def readlines(self): + lines = [] + line = self.readline() + while line: + lines.append(line) + line = self.readline() + return lines + def write(self, s): + if not s: return + if self.pos > self.len: + self.buflist.append('\0'*(self.pos - self.len)) + self.len = self.pos + newpos = self.pos + len(s) + if self.pos < self.len: + if self.buflist: + self.buf = self.buf + string.joinfields(self.buflist, '') + self.buflist = [] + self.buflist = [self.buf[:self.pos], s, self.buf[newpos:]] + self.buf = '' + else: + self.buflist.append(s) + self.len = newpos + self.pos = newpos + def writelines(self, list): + self.write(string.joinfields(list, '')) + def flush(self): + pass + def getvalue(self): + if self.buflist: + self.buf = self.buf + string.joinfields(self.buflist, '') + self.buflist = [] + return self.buf + + +# A little test suite + +def test(): + import sys + if sys.argv[1:]: + file = sys.argv[1] + else: + file = '/etc/passwd' + lines = open(file, 'r').readlines() + text = open(file, 'r').read() + f = StringIO() + for line in lines[:-2]: + f.write(line) + f.writelines(lines[-2:]) + if f.getvalue() != text: + raise RuntimeError, 'write failed' + length = f.tell() + print 'File length =', length + f.seek(len(lines[0])) + f.write(lines[1]) + f.seek(0) + print 'First line =', `f.readline()` + here = f.tell() + line = f.readline() + print 'Second line =', `line` + f.seek(-len(line), 1) + line2 = f.read(len(line)) + if line != line2: + raise RuntimeError, 'bad result after seek back' + f.seek(len(line2), 1) + list = f.readlines() + line = list[-1] + f.seek(f.tell() - len(line)) + line2 = f.read() + if line != line2: + raise RuntimeError, 'bad result after seek back from EOF' + print 'Read', len(list), 'more lines' + print 'File length =', f.tell() + if f.tell() != length: + raise RuntimeError, 'bad length' + f.close() + +if __name__ == '__main__': + test() diff --git a/slime-0.11/TODO b/slime-0.11/TODO new file mode 100644 index 0000000..c4bc5c4 --- /dev/null +++ b/slime-0.11/TODO @@ -0,0 +1,169 @@ +Slimy things to do, and various notes +------------------------------------- + +Things to add or change before next release: + + * mouse-3 doesn't always work (if there's new mail?); don't use + integer indexes into list? + * error messages with popups, not pring + * rescan the current folder as well, periodically + * decrement_unread: only if message is in inbox folder; and other + problems leading to -1 unread + * put send and abort in compose window further away from each other + * msgwin title: just name or addr, not both + +Later stuff: + +* Misc: + * add X-Mailer header? + * mark replied messages with special status + * Copy, move should be done at commit time, and should update the + folder window. Copied/moved should be marked with some other + status character then D (C and M, perhaps). + * allow editing of signature-file from within slime (new option + to Config and ConfigEditor: CONFIG_STRING_LONG, which is same + as CONFIG_STRING, but ConfigEditor gives a multiline editor) + * speed up threading and other slow operations + * _intern_string should make sure it doesn't grow too big + * run PGP and other external program async. (allow using slime + while they're running, e.g., to kill the external program); + ditto for scanning inboxes; separate engine and ui to different + processes? + * implement locking: folder.open probably shouldn't lock folder (folder + is kept open while viewed, so that would prevent new mail from + arriving); instead, lock when operations on folder require it (rescan, + commit, others?), and raise exception LockException if can't get lock + - open imap folder locks it? + - some lockfiles should be refreshed every N minutes; what if + rescan/commit is slow? even just reading one message might + be too slow! use separate thread? + - after getting lock, make sure the folder is as we expect it to be + * spool should be mailbox + * automatic fcc to folder of replied-to message for replies? or always + a given folder? or never? (settable) + * quote: ignore signature (optional) + * long references: remove from middle, not end + * installation: ask for and set #!-headers in scripts + * address parsing breaks if addresses have commas (and in other ways) + * avoid using msg.getfulltext; implement msg.getfile + instead; this should reduce memory usage + * settable: ask whether to move read mail to received (default=no?) + or have an MH-style inbox and an "inc"? + * print letters in some nice way +* Unix folders: + * unix mailbox commit rewrites 'From ' lines (and wrongly!); + when reading a mailbox, store the From_ lines; invent one for new + messages (abstract class should have function to return it so it + is kept when moving/copying messages from one mbox to another) + * unix mailbox commit: use better filename and make sure it doesn't exist yet + * unix mailboxes do not handle messages with identical headers; + invent a better way to recognize new messages (just scan + after last message? but that doesn't notice deleted ones); + just use offset in file? + * unix mailbox commit: should check that file hasn't been modified since + last scan, to prevent losing stuff +* MH: + * MH copy_message, move_message: if source is also in MH folder, use + MH routines (much faster); for other message types, add and use + a msg.get_file that returns msg._fp; do _not_ read everything into + memory + * MH folders don't handle renumbering of folders while program is running; + use inode numbers for message identity, not message numbers; this + makes it robust against external folder packing + * MH: should folder be packed? make it an option later? +* MIME: + * mimify at send: deduce and set character set correctly, and other + MIME headers too + * decode header mime encodings + * handle uuencoded stuff + * support other charsets than latin1/usascii + * save part to file/pipe +* PGP: + * send pgp/mime signed messages: canonicalize message correctly + * support application/pgp (read only), text/plain that begins with + (empty lines) and ^---BEGIN PGP; showing and sending + * learn about S/MIME +* User interface: + * move, copy message: select folder from menu (in absence of dnd) + * keyboard controls should work in both windows + * It should be possible to change folders without committing + (i.e., to forget changes to folder). + * Do cursor magic so that a suitable cursor is set everywhere, + and so that a busy cursor is shown, when doing slow stuff. + * selection list: move barcursor first + * set mouse cursor to clock while busy + * allow selecting multiple lines in ftoc, and only allow selecting lines + * space should go to next new message, in any folder, if at end of one + folder + * there should be some key for "go to next new message, anywhere" + * handle WM_DELETE_WINDOW protocol + self.top.protocol("WM_DELETE_WINDOW", self.deleteWindow) + * improve error messages (catch exceptions, popup error message box) + * mime multiparts: toggle showing each part separately + * horizontal scrollbars in selectionlists (put into ScrolledText) + * configuration window: + - show threaded or sort by date/subject/subject+date/from + (should be settable per ftoc-window as well) + - hide only headers known to be unimportant? settable! + - have one "all options" window, and make cfgwins scrollable + * make subwindows in main window resizable with mouse + * make columns in ftoc resizeable by mouse and configurable + * make dates in ftoc nicer and configurable + * ftoc: more info per message (to, first N lines of message (after + quotes)) + * clone main window + * msgwin: optionally do paste-source with quotes, and paste-sink + as well + * "catchup" +* Documentation: + * start a manual + * write description of overall structure (for programmers) + * find good specification for "Status:" and "X-Status:" headers +* Python: + * early "SeX" messages from da Silva have bad dates, but should be + parseable; Python's getdate doesn't do it, though + * pythons getdate doesn't do timezone? runtime patch? + * python mailbox.py: of what use is _Mailbox.seek, and does it work + at all? + +Text mode interface (someone else will have to write it): + + * text interface should have a `really terse' mode for slow + modems (<=14k :) + * text interface: two windows, one shows a letter, one writes reply + +Lunatic ideas for after 1.0: + + * simple inline-html support (incoming) + * good printer support + * mixmaster support (and other anon mail support) + * internal editor should allow creation of MIME messages + * picons + * change pop/imap password securely from mailer (=> user does not need + to manually log into server at all) + * put ui and engine in different threads? + * address book a la exmh (and Emacs bbdb?): grab addresses from every + shown message, and have search tool (ctrl-tab) in mail editor; a + trad. alias system as well; grab data to aliases from mail, including + signature + * have address book integrate into external (non-existing) database of + contact info (names, phone numbers, e-mail addresses, etc); grab data + from signature + * folder specific signatures + * grab url's from letters, store in suitable file + * show text/html + * netiquette stuff: check for too much quotes, etc + * spam filter on inbox(es)? (move spam-looking stuff into separate folder, + or something); generic filtering? + * global operations (based on filters): set/reset flag, delete/copy/forward + /whatever flagged mail + * user defined shortcuts + * reply to many letters at once (quote all of them) + * interface for subscribing and unsubscribing to mailing lists + * compress folders + * killfile and scoring + * remove duplicates based on message-id + * mailing list (un)subscription tool + * mark as read/deleted in all folders, when many inboxes for + different lists have the same message + diff --git a/slime-0.11/doc/slime-manual.sgml b/slime-0.11/doc/slime-manual.sgml new file mode 100644 index 0000000..a52c814 --- /dev/null +++ b/slime-0.11/doc/slime-manual.sgml @@ -0,0 +1,36 @@ +<!doctype debiandoc system> +<book> + +<title>Stupid Little Mailer: User's Guide +<author>Lars Wirzenius <email>liw@iki.fi +<version>Version very-pre-alpha + +<abstract>Stupid Little Mailer, or Slime for short, is a mail user +agent. + +<copyright>Copyright © 1997 Lars Wirzenius. + +<p>Slime, including this manual, is free software; +you may redistribute it and/or modify it under the terms +of the GNU General Public License as published by the Free +Software Foundation; either version 2, or (at your option) +any later version. + +<p>This is distributed in the hope that it will be useful, +but without any warranty; without even the implied warranty of +merchantability or fitness for a particular purpose. See the +GNU General Public License for more details. + +<p>You should have received a copy of the GNU General +Public License with your Debian GNU/Linux system, in +/usr/doc/copyright/GPL, or with the SeX source +package as the file COPYING. If not, write to the Free Software +Foundation, Inc., 675 Mass Ave, Cambridge, MA 02139, USA. + +<toc> + +<chapt>Introduction + +<p>This manual will be written later. + +</book> diff --git a/slime-0.11/helpers.py b/slime-0.11/helpers.py new file mode 100644 index 0000000..46a5e11 --- /dev/null +++ b/slime-0.11/helpers.py @@ -0,0 +1,85 @@ +import spawn, tempfile, time, os, FCNTL + +def run_pgp(path, username, password, options, files, stdin=""): + env = {} + for key in os.environ.keys(): + env[key] = os.environ[key] + env["PGPPASSFD"] = "0" + + if password: + stdin = password + "\n" + stdin + + args = options[:] + if username: + args.append("+myname=" + username) + args = args + files + + return spawn.run(path, stdin=stdin, args=args, env=env) + +def read_text_from_named_file(filename): + f = open(filename, "r") + text = f.read() + f.close() + return text + +def write_text_to_named_file(filename, text): + fd = os.open(filename, + FCNTL.O_WRONLY | FCNTL.O_CREAT | FCNTL.O_EXCL, 0600) + os.write(fd, text) + os.close(fd) + +def rfc822_date(): + mon = ["", "Jan", "Feb", "Mar", "Apr", "May", "Jun", + "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"] + dow = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"] + + if time.daylight: + date = time.localtime(time.time()) + if time.altzone < 0: + tzsign = '+' + secs = -time.altzone + else: + tzsign = '-' + secs = time.altzone + tzhours = secs / (60*60) + tzmins = secs % (60*60) + else: + date = time.gmtime(time.time()) + tzsign = '+' + tzhours = 0 + tzmins = 0 + + return "%s, %2d %s %d %02d:%02d:%02d %c%02d%02d" % \ + (dow[date[6]], date[2], mon[date[1]], date[0], + date[3], date[4], date[5], tzsign, tzhours, tzmins) + +_name_to_folder_cache = {} + +def clear_name_to_folder_cache(): + global _name_to_folder_cache + _name_to_folder_cache = {} + +def find_folder_by_name(folder_name, folder): + global _name_to_folder_cache + if _name_to_folder_cache.has_key(folder_name): + return _name_to_folder_cache[folder_name] + return recursive_find_folder_by_name(folder_name, folder) + +def recursive_find_folder_by_name(folder_name, folder): + global _name_to_folder_cache + _name_to_folder_cache[repr(folder)] = folder + + if repr(folder) == folder_name: + return folder + was_open = folder.is_open() + if not was_open: + folder.open() + folder.rescan_subfolders() + f = None + for subfolder in folder.list_all_subfolders(): + f = recursive_find_folder_by_name(folder_name, subfolder) + if f: + break + if not was_open: + folder.close() + return f diff --git a/slime-0.11/mhlib2.py b/slime-0.11/mhlib2.py new file mode 100644 index 0000000..c932441 --- /dev/null +++ b/slime-0.11/mhlib2.py @@ -0,0 +1,915 @@ +# MH interface -- purely object-oriented (well, almost) +# +# Executive summary: +# +# import mhlib +# +# mh = mhlib.MH() # use default mailbox directory and profile +# mh = mhlib.MH(mailbox) # override mailbox location (default from profile) +# mh = mhlib.MH(mailbox, profile) # override mailbox and profile +# +# mh.error(format, ...) # print error message -- can be overridden +# s = mh.getprofile(key) # profile entry (None if not set) +# path = mh.getpath() # mailbox pathname +# name = mh.getcontext() # name of current folder +# mh.setcontext(name) # set name of current folder +# +# list = mh.listfolders() # names of top-level folders +# list = mh.listallfolders() # names of all folders, including subfolders +# list = mh.listsubfolders(name) # direct subfolders of given folder +# list = mh.listallsubfolders(name) # all subfolders of given folder +# +# mh.makefolder(name) # create new folder +# mh.deletefolder(name) # delete folder -- must have no subfolders +# +# f = mh.openfolder(name) # new open folder object +# +# f.error(format, ...) # same as mh.error(format, ...) +# path = f.getfullname() # folder's full pathname +# path = f.getsequencesfilename() # full pathname of folder's sequences file +# path = f.getmessagefilename(n) # full pathname of message n in folder +# +# list = f.listmessages() # list of messages in folder (as numbers) +# n = f.getcurrent() # get current message +# f.setcurrent(n) # set current message +# list = f.parsesequence(seq) # parse msgs syntax into list of messages +# n = f.getlast() # get last message (0 if no messagse) +# f.setlast(n) # set last message (internal use only) +# +# dict = f.getsequences() # dictionary of sequences in folder {name: list} +# f.putsequences(dict) # write sequences back to folder +# +# f.removemessages(list) # remove messages in list from folder +# f.refilemessages(list, tofolder) # move messages in list to other folder +# f.movemessage(n, tofolder, ton) # move one message to a given destination +# f.copymessage(n, tofolder, ton) # copy one message to a given destination +# +# m = f.openmessage(n) # new open message object (costs a file descriptor) +# m is a derived class of mimetools.Message(rfc822.Message), with: +# s = m.getheadertext() # text of message's headers +# s = m.getheadertext(pred) # text of message's headers, filtered by pred +# s = m.getbodytext() # text of message's body, decoded +# s = m.getbodytext(0) # text of message's body, not decoded +# +# XXX To do, functionality: +# - annotate messages +# - create, send messages +# +# XXX To do, organization: +# - move IntSet to separate file +# - move most Message functionality to module mimetools + + +# Customizable defaults + +MH_PROFILE = '~/.mh_profile' +PATH = '~/Mail' +MH_SEQUENCES = '.mh_sequences' +FOLDER_PROTECT = 0700 + + +# Imported modules + +import os +import sys +from stat import ST_NLINK +import regex +import string +import mimetools +import multifile +import shutil + + +# Exported constants + +Error = 'mhlib.Error' + + +# Class representing a particular collection of folders. +# Optional constructor arguments are the pathname for the directory +# containing the collection, and the MH profile to use. +# If either is omitted or empty a default is used; the default +# directory is taken from the MH profile if it is specified there. + +class MH: + + # Constructor + def __init__(self, path = None, profile = None): + if not profile: profile = MH_PROFILE + self.profile = os.path.expanduser(profile) + if not path: path = self.getprofile('Path') + if not path: path = PATH + if not os.path.isabs(path) and path[0] != '~': + path = os.path.join('~', path) + path = os.path.expanduser(path) + if not os.path.isdir(path): raise Error, 'MH() path not found' + self.path = path + + # String representation + def __repr__(self): + return 'MH(%s, %s)' % (`self.path`, `self.profile`) + + # Routine to print an error. May be overridden by a derived class + def error(self, msg, *args): + sys.stderr.write('MH error: %s\n' % (msg % args)) + + # Return a profile entry, None if not found + def getprofile(self, key): + return pickline(self.profile, key) + + # Return the path (the name of the collection's directory) + def getpath(self): + return self.path + + # Return the name of the current folder + def getcontext(self): + context = pickline(os.path.join(self.getpath(), 'context'), + 'Current-Folder') + if not context: context = 'inbox' + return context + + # Set the name of the current folder + def setcontext(self, context): + fn = os.path.join(self.getpath(), 'context') + f = open(fn, "w") + f.write("Current-Folder: %s\n" % context) + f.close() + + # Return the names of the top-level folders + def listfolders(self): + folders = [] + path = self.getpath() + for name in os.listdir(path): + if name in (os.curdir, os.pardir): continue + fullname = os.path.join(path, name) + if os.path.isdir(fullname): + folders.append(name) + folders.sort() + return folders + + # Return the names of the subfolders in a given folder + # (prefixed with the given folder name) + def listsubfolders(self, name): + fullname = os.path.join(self.path, name) + # Get the link count so we can avoid listing folders + # that have no subfolders. + st = os.stat(fullname) + nlinks = st[ST_NLINK] + if nlinks <= 2: + return [] + subfolders = [] + subnames = os.listdir(fullname) + for subname in subnames: + if subname in (os.curdir, os.pardir): continue + fullsubname = os.path.join(fullname, subname) + if os.path.isdir(fullsubname): + name_subname = os.path.join(name, subname) + subfolders.append(name_subname) + # Stop looking for subfolders when + # we've seen them all + nlinks = nlinks - 1 + if nlinks <= 2: + break + subfolders.sort() + return subfolders + + # Return the names of all folders, including subfolders, recursively + def listallfolders(self): + return self.listallsubfolders('') + + # Return the names of subfolders in a given folder, recursively + def listallsubfolders(self, name): + fullname = os.path.join(self.path, name) + # Get the link count so we can avoid listing folders + # that have no subfolders. + st = os.stat(fullname) + nlinks = st[ST_NLINK] + if nlinks <= 2: + return [] + subfolders = [] + subnames = os.listdir(fullname) + for subname in subnames: + if subname in (os.curdir, os.pardir): continue + if subname[0] == ',' or isnumeric(subname): continue + fullsubname = os.path.join(fullname, subname) + if os.path.isdir(fullsubname): + name_subname = os.path.join(name, subname) + subfolders.append(name_subname) + if not os.path.islink(fullsubname): + subsubfolders = self.listallsubfolders( + name_subname) + subfolders = subfolders + subsubfolders + # Stop looking for subfolders when + # we've seen them all + nlinks = nlinks - 1 + if nlinks <= 2: + break + subfolders.sort() + return subfolders + + # Return a new Folder object for the named folder + def openfolder(self, name): + return Folder(self, name) + + # Create a new folder. This raises os.error if the folder + # cannot be created + def makefolder(self, name): + protect = pickline(self.profile, 'Folder-Protect') + if protect and isnumeric(protect): + mode = string.atoi(protect, 8) + else: + mode = FOLDER_PROTECT + os.mkdir(os.path.join(self.getpath(), name), mode) + + # Delete a folder. This removes files in the folder but not + # subdirectories. If deleting the folder itself fails it + # raises os.error + def deletefolder(self, name): + fullname = os.path.join(self.getpath(), name) + for subname in os.listdir(fullname): + if subname in (os.curdir, os.pardir): continue + fullsubname = os.path.join(fullname, subname) + try: + os.unlink(fullsubname) + except os.error: + self.error('%s not deleted, continuing...' % + fullsubname) + os.rmdir(fullname) + + +# Class representing a particular folder + +numericprog = regex.compile('[1-9][0-9]*') +def isnumeric(str): + return numericprog.match(str) == len(str) + +class Folder: + + # Constructor + def __init__(self, mh, name): + self.mh = mh + self.name = name + if not os.path.isdir(self.getfullname()): + raise Error, 'no folder %s' % name + + # String representation + def __repr__(self): + return 'Folder(%s, %s)' % (`self.mh`, `self.name`) + + # Error message handler + def error(self, *args): + apply(self.mh.error, args) + + # Return the full pathname of the folder + def getfullname(self): + return os.path.join(self.mh.path, self.name) + + # Return the full pathname of the folder's sequences file + def getsequencesfilename(self): + return os.path.join(self.getfullname(), MH_SEQUENCES) + + # Return the full pathname of a message in the folder + def getmessagefilename(self, n): + return os.path.join(self.getfullname(), str(n)) + + # Return list of direct subfolders + def listsubfolders(self): + return self.mh.listsubfolders(self.name) + + # Return list of all subfolders + def listallsubfolders(self): + return self.mh.listallsubfolders(self.name) + + # Return the list of messages currently present in the folder. + # As a side effect, set self.last to the last message (or 0) + def listmessages(self): + messages = [] + for name in os.listdir(self.getfullname()): + if name[0] != "," and \ + numericprog.match(name) == len(name): + messages.append(string.atoi(name)) + messages.sort() + if messages: + self.last = max(messages) + else: + self.last = 0 + return messages + + # Return the set of sequences for the folder + def getsequences(self): + sequences = {} + fullname = self.getsequencesfilename() + try: + f = open(fullname, 'r') + except IOError: + return sequences + while 1: + line = f.readline() + if not line: break + fields = string.splitfields(line, ':') + if len(fields) <> 2: + self.error('bad sequence in %s: %s' % + (fullname, string.strip(line))) + key = string.strip(fields[0]) + value = IntSet(string.strip(fields[1]), ' ').tolist() + sequences[key] = value + return sequences + + # Write the set of sequences back to the folder + def putsequences(self, sequences): + fullname = self.getsequencesfilename() + f = None + for key in sequences.keys(): + s = IntSet('', ' ') + s.fromlist(sequences[key]) + if not f: f = open(fullname, 'w') + f.write('%s: %s\n' % (key, s.tostring())) + if not f: + try: + os.unlink(fullname) + except os.error: + pass + else: + f.close() + + # Return the current message. Raise KeyError when there is none + def getcurrent(self): + return min(self.getsequences()['cur']) + + # Set the current message + def setcurrent(self, n): + updateline(self.getsequencesfilename(), 'cur', str(n), 0) + + # Parse an MH sequence specification into a message list. + def parsesequence(self, seq): + if seq == "all": + return self.listmessages() + try: + n = string.atoi(seq, 10) + except string.atoi_error: + n = 0 + if n > 0: + return [n] + if regex.match("^last:", seq) >= 0: + try: + n = string.atoi(seq[5:]) + except string.atoi_error: + n = 0 + if n > 0: + return self.listmessages()[-n:] + if regex.match("^first:", seq) >= 0: + try: + n = string.atoi(seq[6:]) + except string.atoi_error: + n = 0 + if n > 0: + return self.listmessages()[:n] + dict = self.getsequences() + if dict.has_key(seq): + return dict[seq] + context = self.mh.getcontext() + # Complex syntax -- use pick + pipe = os.popen("pick +%s %s 2>/dev/null" % (self.name, seq)) + data = pipe.read() + sts = pipe.close() + self.mh.setcontext(context) + if sts: + raise Error, "unparseable sequence %s" % `seq` + items = string.split(data) + return map(string.atoi, items) + + # Open a message -- returns a Message object + def openmessage(self, n): + return Message(self, n) + + # Remove one or more messages -- may raise os.error + def removemessages(self, list): + errors = [] + deleted = [] + for n in list: + path = self.getmessagefilename(n) + commapath = self.getmessagefilename(',' + str(n)) + try: + os.unlink(commapath) + except os.error: + pass + try: + os.rename(path, commapath) + except os.error, msg: + errors.append(msg) + else: + deleted.append(n) + if deleted: + self.removefromallsequences(deleted) + if errors: + if len(errors) == 1: + raise os.error, errors[0] + else: + raise os.error, ('multiple errors:', errors) + + # Refile one or more messages -- may raise os.error. + # 'tofolder' is an open folder object + def refilemessages(self, list, tofolder, keepsequences=0): + errors = [] + refiled = {} + for n in list: + ton = tofolder.getlast() + 1 + path = self.getmessagefilename(n) + topath = tofolder.getmessagefilename(ton) + try: + os.rename(path, topath) + except os.error: + # Try copying + try: + shutil.copy2(path, topath) + os.unlink(path) + except (IOError, os.error), msg: + errors.append(msg) + try: + os.unlink(topath) + except os.error: + pass + continue + tofolder.setlast(ton) + refiled[n] = ton + if refiled: + if keepsequences: + tofolder._copysequences(self, refiled.items()) + self.removefromallsequences(refiled.keys()) + if errors: + if len(errors) == 1: + raise os.error, errors[0] + else: + raise os.error, ('multiple errors:', errors) + + # Helper for refilemessages() to copy sequences + def _copysequences(self, fromfolder, refileditems): + fromsequences = fromfolder.getsequences() + tosequences = self.getsequences() + changed = 0 + for name, seq in fromsequences.items(): + try: + toseq = tosequences[name] + new = 0 + except: + toseq = [] + new = 1 + for fromn, ton in refileditems: + if fromn in seq: + toseq.append(ton) + changed = 1 + if new and toseq: + tosequences[name] = toseq + if changed: + self.putsequences(tosequences) + + # Move one message over a specific destination message, + # which may or may not already exist. + def movemessage(self, n, tofolder, ton): + path = self.getmessagefilename(n) + # Open it to check that it exists + f = open(path) + f.close() + del f + topath = tofolder.getmessagefilename(ton) + backuptopath = tofolder.getmessagefilename(',%d' % ton) + try: + os.rename(topath, backuptopath) + except os.error: + pass + try: + os.rename(path, topath) + except os.error: + # Try copying + ok = 0 + try: + tofolder.setlast(None) + shutil.copy2(path, topath) + ok = 1 + finally: + if not ok: + try: + os.unlink(topath) + except os.error: + pass + os.unlink(path) + self.removefromallsequences([n]) + + # Copy one message over a specific destination message, + # which may or may not already exist. + def copymessage(self, n, tofolder, ton): + path = self.getmessagefilename(n) + # Open it to check that it exists + f = open(path) + f.close() + del f + topath = tofolder.getmessagefilename(ton) + backuptopath = tofolder.getmessagefilename(',%d' % ton) + try: + os.rename(topath, backuptopath) + except os.error: + pass + ok = 0 + try: + tofolder.setlast(None) + shutil.copy2(path, topath) + ok = 1 + finally: + if not ok: + try: + os.unlink(topath) + except os.error: + pass + + # Create a message, with text from the open file txt. + def createmessage(self, n, txt): + path = self.getmessagefilename(n) + backuppath = self.getmessagefilename(',%d' % n) + try: + os.rename(path, backuppath) + except os.error: + pass + ok = 0 + try: + f = open(path, "w") + while 1: + buf = txt.read(16*1024) + if not buf: + break + f.write(buf) + f.close() + ok = 1 + finally: + if not ok: + try: + os.unlink(path) + except os.error: + pass + + # Remove one or more messages from all sequeuces (including last) + def removefromallsequences(self, list): + if hasattr(self, 'last') and self.last in list: + del self.last + sequences = self.getsequences() + changed = 0 + for name, seq in sequences.items(): + for n in list: + if n in seq: + seq.remove(n) + changed = 1 + if not seq: + del sequences[name] + if changed: + self.putsequences(sequences) + + # Return the last message number + def getlast(self): + if not hasattr(self, 'last'): + messages = self.listmessages() + return self.last + + # Set the last message number + def setlast(self, last): + if last is None: + if hasattr(self, 'last'): + del self.last + else: + self.last = last + +class Message(mimetools.Message): + + # Constructor + def __init__(self, f, n, fp = None): + self.folder = f + self.number = n + if not fp: + path = f.getmessagefilename(n) + fp = open(path, 'r') + mimetools.Message.__init__(self, fp) + + # String representation + def __repr__(self): + return 'Message(%s, %s)' % (repr(self.folder), self.number) + + # Return the message's header text as a string. If an + # argument is specified, it is used as a filter predicate to + # decide which headers to return (its argument is the header + # name converted to lower case). + def getheadertext(self, pred = None): + if not pred: + return string.joinfields(self.headers, '') + headers = [] + hit = 0 + for line in self.headers: + if line[0] not in string.whitespace: + i = string.find(line, ':') + if i > 0: + hit = pred(string.lower(line[:i])) + if hit: headers.append(line) + return string.joinfields(headers, '') + + # Return the message's body text as string. This undoes a + # Content-Transfer-Encoding, but does not interpret other MIME + # features (e.g. multipart messages). To suppress to + # decoding, pass a 0 as argument + def getbodytext(self, decode = 1): + self.fp.seek(self.startofbody) + encoding = self.getencoding() + if not decode or encoding in ('7bit', '8bit', 'binary'): + return self.fp.read() + from StringIO import StringIO + output = StringIO() + mimetools.decode(self.fp, output, encoding) + return output.getvalue() + + # Only for multipart messages: return the message's body as a + # list of SubMessage objects. Each submessage object behaves + # (almost) as a Message object. + def getbodyparts(self): + if self.getmaintype() != 'multipart': + raise Error, \ + 'Content-Type is not multipart/*' + bdry = self.getparam('boundary') + if not bdry: + raise Error, 'multipart/* without boundary param' + self.fp.seek(self.startofbody) + mf = multifile.MultiFile(self.fp) + mf.push(bdry) + parts = [] + while mf.next(): + n = str(self.number) + '.' + `1 + len(parts)` + part = SubMessage(self.folder, n, mf) + parts.append(part) + mf.pop() + return parts + + # Return body, either a string or a list of messages + def getbody(self): + if self.getmaintype() == 'multipart': + return self.getbodyparts() + else: + return self.getbodytext() + + +class SubMessage(Message): + + # Constructor + def __init__(self, f, n, fp): + Message.__init__(self, f, n, fp) + if self.getmaintype() == 'multipart': + self.body = Message.getbodyparts(self) + else: + self.body = Message.getbodytext(self) + # XXX If this is big, should remember file pointers + + # String representation + def __repr__(self): + f, n, fp = self.folder, self.number, self.fp + return 'SubMessage(%s, %s, %s)' % (f, n, fp) + + def getbodytext(self): + if type(self.body) == type(''): + return self.body + + def getbodyparts(self): + if type(self.body) == type([]): + return self.body + + def getbody(self): + return self.body + + +# Class implementing sets of integers. +# +# This is an efficient representation for sets consisting of several +# continuous ranges, e.g. 1-100,200-400,402-1000 is represented +# internally as a list of three pairs: [(1,100), (200,400), +# (402,1000)]. The internal representation is always kept normalized. +# +# The constructor has up to three arguments: +# - the string used to initialize the set (default ''), +# - the separator between ranges (default ',') +# - the separator between begin and end of a range (default '-') +# The separators may be regular expressions and should be different. +# +# The tostring() function yields a string that can be passed to another +# IntSet constructor; __repr__() is a valid IntSet constructor itself. +# +# XXX The default begin/end separator means that negative numbers are +# not supported very well. +# +# XXX There are currently no operations to remove set elements. + +class IntSet: + + def __init__(self, data = None, sep = ',', rng = '-'): + self.pairs = [] + self.sep = sep + self.rng = rng + if data: self.fromstring(data) + + def reset(self): + self.pairs = [] + + def __cmp__(self, other): + return cmp(self.pairs, other.pairs) + + def __hash__(self): + return hash(self.pairs) + + def __repr__(self): + return 'IntSet(%s, %s, %s)' % (`self.tostring()`, + `self.sep`, `self.rng`) + + def normalize(self): + self.pairs.sort() + i = 1 + while i < len(self.pairs): + alo, ahi = self.pairs[i-1] + blo, bhi = self.pairs[i] + if ahi >= blo-1: + self.pairs[i-1:i+1] = [ + (alo, max(ahi, bhi))] + else: + i = i+1 + + def tostring(self): + s = '' + for lo, hi in self.pairs: + if lo == hi: t = `lo` + else: t = `lo` + self.rng + `hi` + if s: s = s + (self.sep + t) + else: s = t + return s + + def tolist(self): + l = [] + for lo, hi in self.pairs: + m = range(lo, hi+1) + l = l + m + return l + + def fromlist(self, list): + for i in list: + self.append(i) + + def clone(self): + new = IntSet() + new.pairs = self.pairs[:] + return new + + def min(self): + return self.pairs[0][0] + + def max(self): + return self.pairs[-1][-1] + + def contains(self, x): + for lo, hi in self.pairs: + if lo <= x <= hi: return 1 + return 0 + + def append(self, x): + for i in range(len(self.pairs)): + lo, hi = self.pairs[i] + if x < lo: # Need to insert before + if x+1 == lo: + self.pairs[i] = (x, hi) + else: + self.pairs.insert(i, (x, x)) + if i > 0 and x-1 == self.pairs[i-1][1]: + # Merge with previous + self.pairs[i-1:i+1] = [ + (self.pairs[i-1][0], + self.pairs[i][1]) + ] + return + if x <= hi: # Already in set + return + i = len(self.pairs) - 1 + if i >= 0: + lo, hi = self.pairs[i] + if x-1 == hi: + self.pairs[i] = lo, x + return + self.pairs.append((x, x)) + + def addpair(self, xlo, xhi): + if xlo > xhi: return + self.pairs.append((xlo, xhi)) + self.normalize() + + def fromstring(self, data): + import string, regsub + new = [] + for part in regsub.split(data, self.sep): + list = [] + for subp in regsub.split(part, self.rng): + s = string.strip(subp) + list.append(string.atoi(s)) + if len(list) == 1: + new.append((list[0], list[0])) + elif len(list) == 2 and list[0] <= list[1]: + new.append((list[0], list[1])) + else: + raise ValueError, 'bad data passed to IntSet' + self.pairs = self.pairs + new + self.normalize() + + +# Subroutines to read/write entries in .mh_profile and .mh_sequences + +def pickline(file, key, casefold = 1): + try: + f = open(file, 'r') + except IOError: + return None + pat = key + ':' + if casefold: + prog = regex.compile(pat, regex.casefold) + else: + prog = regex.compile(pat) + while 1: + line = f.readline() + if not line: break + if prog.match(line) >= 0: + text = line[len(key)+1:] + while 1: + line = f.readline() + if not line or \ + line[0] not in string.whitespace: + break + text = text + line + return string.strip(text) + return None + +def updateline(file, key, value, casefold = 1): + try: + f = open(file, 'r') + lines = f.readlines() + f.close() + except IOError: + lines = [] + pat = key + ':\(.*\)\n' + if casefold: + prog = regex.compile(pat, regex.casefold) + else: + prog = regex.compile(pat) + if value is None: + newline = None + else: + newline = '%s: %s\n' % (key, value) + for i in range(len(lines)): + line = lines[i] + if prog.match(line) == len(line): + if newline is None: + del lines[i] + else: + lines[i] = newline + break + else: + if newline is not None: + lines.append(newline) + tempfile = file + "~" + f = open(tempfile, 'w') + for line in lines: + f.write(line) + f.close() + os.rename(tempfile, file) + + +# Test program + +def test(): + global mh, f + os.system('rm -rf $HOME/Mail/@test') + mh = MH() + def do(s): print s; print eval(s) + do('mh.listfolders()') + do('mh.listallfolders()') + testfolders = ['@test', '@test/test1', '@test/test2', + '@test/test1/test11', '@test/test1/test12', + '@test/test1/test11/test111'] + for t in testfolders: do('mh.makefolder(%s)' % `t`) + do('mh.listsubfolders(\'@test\')') + do('mh.listallsubfolders(\'@test\')') + f = mh.openfolder('@test') + do('f.listsubfolders()') + do('f.listallsubfolders()') + do('f.getsequences()') + seqs = f.getsequences() + seqs['foo'] = IntSet('1-10 12-20', ' ').tolist() + print seqs + f.putsequences(seqs) + do('f.getsequences()') + testfolders.reverse() + for t in testfolders: do('mh.deletefolder(%s)' % `t`) + do('mh.getcontext()') + context = mh.getcontext() + f = mh.openfolder(context) + do('f.listmessages()') + do('f.getcurrent()') + + +if __name__ == '__main__': + test() diff --git a/slime-0.11/slime b/slime-0.11/slime new file mode 100644 index 0000000..d699796 --- /dev/null +++ b/slime-0.11/slime @@ -0,0 +1,5 @@ +#!/bin/sh + +PYTHONPATH="@slimedir@:$PYTHONPATH" +export PYTHONPATH +python @slimedir@/ui.py diff --git a/slime-0.11/slime.html b/slime-0.11/slime.html new file mode 100644 index 0000000..575ed0d --- /dev/null +++ b/slime-0.11/slime.html @@ -0,0 +1,112 @@ +<html> +<head> +<title>Slime - the Stupid little mailer</title> +</head> +<body> + +<h1>Slime - the Stupid little mailer</h1> + +<p>See the <a href="README">README</a> for more info, and <a +href="Changes">Changes</a>, if you've seen any previous version. +The <a href="slime-manual.html/index.html">manual</a> is not +yet written. + +<p>Screenshots: <a href="slime-main.jpg">main window</a> (jpeg, 25 kB), +<a href="slime-msg.jpg">message window</a> (jpeg, 25 kB). These are +of version 0.2, or something. + +<p>You need the <a href="slime.tar.gz">Slime source package</a>, +my <a href="../programs/pyliw-0.3.tar.gz">pyliw package (version 0.3)</a>, +Mitch Chapman's <a href="uitools.tar.gz">UITools</a>, and +<a href="http://www.python.org/">Python</a>. <strong>Note</strong> +that version 0.9 of Slime needs version 0.3 of pyliw (older versions +had a bug, now fixed). + + +<h2>A small collection of links</h2> + +<p>Message formats: +<ul> +<li><a href="http://www.c2.org/~raph/pgpmime.html">PGP/MIME homepage</a> +<li><a href="http://www.c2.org/~raph/impl.html">PGP/MIME implementation notes</a> +<li><a href="http://ftp.funet.fi/pub/doc/rfc/rfc2015.txt">RFC 2015 (PGP/MIME)</a> +<li><a href="http://ftp.funet.fi/pub/doc/rfc/rfc1847.txt">RFC 1847 (Security + multiparts for MIME)</a> +<li><a href="http://ftp.funet.fi/pub/doc/rfc/rfc822.txt">RFC 822 (Message + format for mail)</a> +<li><a href="http://ftp.funet.fi/pub/doc/rfc/rfc1036.txt">RFC 1036 (Message + format for news)</a> +<li><a href="ftp://ftp.zoo.toronto.edu/pub/news.txt.Z">Son-of-1036</a> by + Henry Spencer +<li><a href="http://ftp.funet.fi/pub/doc/rfc/rfc1892.txt">RFC 1892 (Bounces)</a> +<li><a href="http://ftp.funet.fi/pub/doc/rfc/rfc1892.txt">RFC 1893 (Enhanced + Mail System Status Codes)</a> +<li><a href="http://ftp.funet.fi/pub/doc/rfc/rfc1892.txt">RFC 1894 (Extensible + Message Format for Delivery Status Notifications)</a> +<li><a href="http://ftp.funet.fi/pub/doc/rfc/rfc2045.txt">RFC 2045 (MIME I: + Message Bodies)</a> +<li><a href="http://ftp.funet.fi/pub/doc/rfc/rfc2046.txt">RFC 2046 (MIME II: + Media Types)</a> +<li><a href="http://ftp.funet.fi/pub/doc/rfc/rfc2047.txt">RFC 2047 (MIME III: + Header Extensions for Non-ASCII Text)</a> +<li><a href="http://ftp.funet.fi/pub/doc/rfc/rfc2048.txt">RFC 2048 (MIME IV: + Registration Procedures)</a> +<li><a href="http://www.cs.ruu.nl/wais/html/na-dir/mail/mime-faq/.html">MIME + FAQ (in HTML)</a> +</ul> + +<p>Protocol related stuff: +<ul> +<li><a href="http://ftp.funet.fi/pub/doc/rfc/rfc821.txt">RFC 821 (SMTP)</a> +<li><a href="http://ftp.funet.fi/pub/doc/rfc/rfc1939.txt">RFC 1939 (POP3)</a> +<li><a href="http://www.imap.org">IMAP</a> +<li><a href="http://ftp.funet.fi/pub/doc/rfc/rfc977.txt">RFC (NNTP)</a> +<li><a href="http://ftp.funet.fi/pub/doc/rfc/rfc1957.txt">RFC 1957 (Observations + on POP implementations)</a> +</ul> + +<p>Mailbox formats +<ul> +<li><a href="http://www.qmail.org/qmail-manual-html/man5/mbox.html">Normal Unix mbox</a> +<li><a href="">(?)MH folders</a> +<li><a href="http://www.qmail.org/qmail-manual-html/man5/maildir.html">Maildir</a> +</ul> + +<p>Other mailers and mail-related software: +<ul> +<li><a href="http://www.washington.edu/pine/tech-notes/">Pine technical + notes</a> +<li><a href="http://www.ifi.uio.no/~larsi/notes/notes.html">GNUS's notes + on news</a> +<li><a href="http://www.cs.hmc.edu/~me/mutt/index.html">Mutt</a> +<li><a href="http://www.sendmail.org/">Sendmail</a> +<li><a href="http://www.qmail.org/">Qmail</a> +<li><a href="">(?)Exim</a> +<li><a href="">(?)Smail</a> +<li><a href="">(?)Procmail</a> +<li><a href="http://andrew2.andrew.cmu.edu/cyrus/imapd/">Cyrus IMAP Server</a> +<li><a href=""></a> +</ul> + +<p>Other stuff +<ul> +<li><a href="http://ds.internic.net/internet-drafts/draft-dusse-smime-msg-02.txt">S/MIME</a> +<li><a href="http://www.c2.org/~raph/comparison.html">Comparison between e-mail encryption protocols</a> +<li><a href="">Heikki Kantola</a> +<li><a href="http://http.bsd.uchicago.edu/%7Et-pierce/news/">Tim Pierce's + news stuff</a> +<li>Bill Wohler's + <a href="http://www.worldtalk.com/html/msg_resources/email_ref.html">Email + References</a> +<li>Project Andrew + <a href="http://andrew2.andrew.cmu.edu/cyrus/email/email.html">Email + Web Resources</a> +<li><a href="http://www2.thecia.net/users/rnewman/Good_Netkeeping_Seal">Good + Net-Keeping Seal of Approval</a> +<li><a href="http://www.imc.org/workshop/mail-archive/0364.html">S/MIME insecurity</a> +<li><a href="http://www.imc.org">Internet Mail Consortium</a> +<li><a href="http://developer.netscape.com/software/index.html">netscape LDAP</a> +</ul> + +</body> +</html> diff --git a/slime-0.11/slime_abstract.py b/slime-0.11/slime_abstract.py new file mode 100644 index 0000000..4de3b82 --- /dev/null +++ b/slime-0.11/slime_abstract.py @@ -0,0 +1,541 @@ +"""Abstract class definition for mail folders and messages. + +This module defines two classes, SlimeFolder and SlimeMessage, which +define an interface to mail folders and messages, respectively. +Classes derived from SlimeFolder and SlimeMessage give access to +different kinds of folders, e.g., slime_mh.py defines SlimeFolder_MH +and SlimeMessage_MH for MH folders. + +Other parts of Slime use the interface defined by this module +to access folders and messags, without having to care about the +details of each folder type. + +The interface is intended to become generic enough for any kind of +folder, including all types of local files, and remote ones as +defined by POP and IMAP. It will certainly require refinement. + +Lars Wirzenius <liw@iki.fi>""" + + +import string, time, regsub +import StringIO + + +# +# Exceptions that may be raised by SlimeFolder and SlimeMessage subclasses. +# +UnimplementedError = "UnimplementedError" +Error = "Error" + + +def notdone(): + """Raise hell, because subclass didn't implement all methods.""" + raise UnimplementedError, "Subclass didn't implement all methods." + +class SlimeFolder: + """Abstract interface to a mail folder.""" + + def __init__(self): + self._is_open = 0 + self._name = None + self._messages = [] + self._last_new_messages = [] + self._last_deleted_messages = [] + self._subfolders = [] + self._last_new_folders = [] + self._last_deleted_folders = [] + self._threads = [] + self._dirty = 0 + + def __repr__(self): + """Return textual name of subfolder.""" + return self._name + + def get_nice_name(self): + """Return a user-friendly representation of the name.""" + x = string.split(self._name, "/") + return x[-1] + + def is_clean(self): + """Is this folder dirty?""" + return not self._dirty + + def make_dirty(self): + """Mark the folder as dirty; commit needs to be done.""" + self._dirty = 1 + + def make_clean(self): + """Mark the folder as clean; commit doesn't need to done.""" + self._dirty = 0 + + def open(self): + """Open the folder. Operations below require an open folder.""" + notdone() + + def close(self): + """Close the folder. + + This should call msg.uncache_headers for every message.""" + notdone() + + def is_open(self): + """Is this folder open?""" + return self._is_open + + def _assert_open(self): + """Make sure this folder is open.""" + if not self._is_open: + raise Error, "Using closed folder " + repr(self) + + def rescan_subfolders(self): + """Check for changes to subfolders. + + This method sets self._subfolders.""" + notdone() + + def rescan_messages(self): + """Check for changes to messages in folder. + + This method sets self._messages.""" + notdone() + + def list_all_messages(self): + """Return list of SlimeMessages for all messages here.""" + self._assert_open() + return self._messages + + def add_message(self, file): + """Add message in file to folder, return new SlimeMessage. + + This will change the folder immediately (i.e., doesn't + wait until commit).""" + notdone() + + def move_message_here(self, msg): + """Move a message from another folder to this folder. + + This will change the target folder immediately (i.e., doesn't + wait until commit), but the message won't be deleted from + the other folder until commit.""" + self._assert_open() + newmsg = self.copy_message_here(msg) + msg.delete_self() + return newmsg + + def copy_message_here(self, msg): + """Copy a message from another folder to this folder. + + This will change the folder immediately (i.e., doesn't + wait until commit).""" + self._assert_open() + txt = msg.getfulltext() + return self.add_message(StringIO.StringIO(txt)) + + def list_all_threads(self): + """Return the thread forest of a folder.""" + self._assert_open() + if not self._threads: + self.rethread_folder() + return self._threads + + def rethread_folder(self): + """Build new thread data structure.""" + self._assert_open() + children = {} + ids = {} + roots = [] + for msg in self._messages: + parent = msg.get_parent_id() + date = msg.get_linear_time() + subj = string.lower(msg["subject"]) + subj = regsub.gsub("^re: *", "", subj) + if parent: + if children.has_key(parent): + children[parent].append(date, subj, msg) + else: + children[parent] = [(date, subj, msg)] + else: + roots.append(date, subj, msg) + id = msg["message-id"] + if id: + ids[id] = msg + for id in children.keys(): + children[id].sort() + if not ids.has_key(id): + for date, subj, msg in children[id]: + roots.append(date, subj, msg) + + subjects = {} + for date, subj, msg in roots: + subjects[subj] = (date, subj, msg) + aux_roots = [] + for subj in subjects.keys(): + aux_roots.append(subjects[subj]) + aux_roots.sort() + real_roots = [] + for date, subj, msg in aux_roots: + list = [] + for date2, subj2, msg2 in roots: + if subj == subj2: + list.append(date2, msg2) + list.sort() + real_roots = real_roots + list + + self._threads = [] + for date, msg in real_roots: + self.append_thread(0, msg, children) + + def append_thread(self, level, msg, children): + """Append msg to self._threads, and then all its children.""" + self._threads.append(level, msg) + id = msg["message-id"] + if children.has_key(id): + level = level + 1 + for date, subj, child in children[id]: + self.append_thread(level, child, children) + + def get_msg_level(self, msg): + """Return indentation level of message in thread display.""" + self._assert_open() + for i, m in self._threads: + if m == msg: + return i + return 0 + + def list_all_subfolders(self): + """Return list of SlimeFolder objects for subfolders.""" + self._assert_open() + return self._subfolders + + def add_subfolder(self, name): + """Add or create a new subfolder named `name'.""" + notdone() + + def move_subfolder_here(self, subfolder_object): + """Move the subfolder to this folder.""" + # xxx placeholder + notdone() + + def delete_self(self): + """Remove this folder.""" + # xxx place holder + notdone() + + def rename_self(self, new_name): + """Rename this folder to have a new name.""" + # xxx place holder + notdone() + + def commit_changes(self): + """Commit all changes to folder and its messages.""" + notdone() + +_interned_strings = {} +def _intern_string(str): + """Make sure there is only one instance of a given string.""" + try: + str = _interned_strings[str] + except KeyError: + _interned_strings[str] = str + return str + +interesting_headers = ["from", "subject", "to", "cc", "date", "status", + "in-reply-to", "message-id", "references"] + +class SlimeMessage: + """Abstract interface to a single e-mail message. + + Status: Every message has a status, which is a string consisting + of single-character flags (graphical flags only): + + N = new message + D = message should be deleted at commit time + (still searching for good info on the chars) + + """ + + # Note that some operations may require changes to the folder + # the message resides in. Changes are commited only after the + # folder is commited. + + + def __init__(self, folder): + """Initialize the message. + + Derived classes must call this.""" + self._folder = folder + self._msg = None + self._status = "" + self._cached_headers = {} + self._cached_date = None + self._open_count = 0 + + def _open(self): + """Open the message. Done automatically in background.""" + notdone() + + def _close(self): + """Close the message. Done automatically in background.""" + notdone() + + def _assert_open(self): + """Assert that the folder this message is in is open.""" + self._folder._assert_open() + + def uncache_headers(self): + """Forget about cached headers.""" + self._assert_open() + self._cached_headers = {} + + def get_status(self): + """Return status of this message (read/unread, etc).""" + self._assert_open() + return self._status + + def _clean_status(self, status): + cleaned = "" + for c in "ND": + if c in status: + cleaned = cleaned + c + return cleaned + + def set_status(self, new_status): + """Change the status of this message, don't mark folder dirty.""" + self._assert_open() + self._status = self._clean_status(new_status) + + def has_status(self, statuschar): + """Does the message status have statuschar?""" + self._assert_open() + return string.find(self._status, statuschar) >= 0 + + def give_status(self, statuschar): + """Add statuschar to status of message.""" + self._assert_open() + cleaned = self._clean_status(statuschar) + if statuschar and string.find(self._status, cleaned) == -1: + self._folder.make_dirty() + self._status = self._status + cleaned + + def remove_status(self, statuschar): + """Remove statuschar from status of message.""" + self._assert_open() + if statuschar: + i = string.find(self._status, statuschar) + if i >= 0: + self._folder.make_dirty() + self._status = self._status[:i] + \ + self._status[i+1:] + + def change_text(self, file): + """Change the contents of the message to that in file. + + Note that not all folder formats support this + functionality easily. It is needed for the draft + folder only, and that one is always an MH folder, + so it gives an error for other folder types.""" + raise Error, "change_text is not supported for this folder" + + def delete_self(self): + """Delete this message from the folder it is in.""" + self._assert_open() + self.give_status("D") + self._folder.make_dirty() + + def undelete_self(self): + """Undelete this message from the folder it is in.""" + self._assert_open() + self.remove_status("D") + + def _find_ids(self, str): + """Return list of Message-ID's in a string.""" + str = string.join(string.split(str, "\n"), " ") + str = string.join(string.split(str, "\t"), " ") + str = regsub.sub("^[^<]*", "", str) + str = regsub.gsub(">[^<]*<", "> <", str) + str = regsub.sub(">[^<]*$", ">", str) + str = regsub.sub(" *", " ", str) + return string.split(str) + + def get_parent_id(self): + """Return the Message-ID of the message this is a reply to.""" + self._assert_open() + irt = self["in-reply-to"] + if irt: + ids = self._find_ids(irt) + if ids: + return ids[0] + refs = self["references"] + if refs: + ids = self._find_ids(refs) + if ids: + return ids[-1] + return "" + + def get_linear_time(self): + """Return the Date: header converted to seconds since epoch.""" + self._assert_open() + date = self.getdate("date") + if date is None: + return 0 + return time.mktime(date) + + def getfulltext(self): + """Return full text of message.""" + return self.getheadertext() + "\n" + self.getbodytext(0) + + # + # The following are the interface provided together by + # rfc822.Message, mimetools.Message, and mhlib.Message. + # + + def getallmatchingheaders(self, name): + self._open() + x = self._msg.getallmatchingheaders(name) + self._close() + return x + def getfirstmatchingheader(self, name): + self._open() + x = self._msg.getfirstmatchingheader(name) + self._close() + return x + def getrawheader(self, name): + self._open() + x = self._msg.getrawheader(name) + self._close() + return x + def getheader(self, name): + self._open() + x = self._msg.getheader(name) + self._close() + return x + def getaddr(self, name): + self._open() + x = self._msg.getaddr(name) + self._close() + return x + def getaddrlist(self, name): + self._open() + x = self._msg.getaddrlist(name) + self._close() + return x + def getdate(self, name): + if string.lower(name) == "date": + if not self._cached_date: + self._open() + self._cache_headers() + self._close() + return self._cached_date + else: + self._open() + date = self._msg.getdate(name) + self._close() + return date + def __len__(self): + self._open() + x = self._msg.__len__() + self._close() + return x + def _cache_headers(self): + self._assert_open() + for h in interesting_headers: + try: + x = self._msg.__getitem__(h) + except KeyError: + x = "" + x = _intern_string(x) + self._cached_headers[h] = x + self._cached_date = self._msg.getdate("date") + if not self._cached_date: + self._cached_date = (1970,1,1,0,0,0,3,1,0) + def __getitem__(self, name): + # Note: we assume it is benign to return "" for missing header. + name = string.lower(name) + if self._cached_headers.has_key(name): + return self._cached_headers[name] + self._open() + try: + x = self._msg.__getitem__(name) + except KeyError: + x = "" + x = _intern_string(x) + self._cached_headers[name] = x + self._close() + return x + def has_key(self, name): + self._open() + x = self._msg.has_key(name) + self._close() + return x + def keys(self): + self._open() + x = self._msg.keys() + self._close() + return x + def values(self): + self._open() + x = self._msg.values() + self._close() + return x + def items(self): + self._open() + x = self._msg.items() + self._close() + return x + + def getplist(self): + self._open() + x = self._msg.getplist() + self._close() + return x + def getparam(self, name): + self._open() + x = self._msg.getparam(name) + self._close() + return x + def getparamnames(self): + self._open() + x = self._msg.getparamnames() + self._close() + return x + def getencoding(self): + self._open() + x = self._msg.getencoding() + self._close() + return x + def gettype(self): + self._open() + x = self._msg.gettype() + self._close() + return x + def getmaintype(self): + self._open() + x = self._msg.getmaintype() + self._close() + return x + def getsubtype(self): + self._open() + x = self._msg.getsubtype() + self._close() + return x + + def getheadertext(self, pred = None): + self._open() + x = self._msg.getheadertext(pred) + self._close() + return x + def getbodytext(self, decode = 1): + self._open() + x = self._msg.getbodytext(decode) + self._close() + return x + def getbodyparts(self): + self._open() + x = self._msg.getbodyparts() + self._close() + return x + def getbody(self): + self._open() + x = self._msg.getbody() + self._close() + return x diff --git a/slime-0.11/slime_draft.py b/slime-0.11/slime_draft.py new file mode 100644 index 0000000..dfc1053 --- /dev/null +++ b/slime-0.11/slime_draft.py @@ -0,0 +1,52 @@ +"""Handle the draft folder.""" + +import os, slime_mh +import mhlib2 +mhlib = mhlib2 + +SLIME_DIR = "~/.slime" +DRAFT_FOLDER = "~/.slime/internal_folders" +DRAFT_PROFILE = "~/.slime/.internal_mh_profile" +slime_dir = None +draft_path = None +draft_profile = None + +draft_mh = None +draft_folder = None + +def make_draft_folder(): + global draft_mh, draft_folder, draft_path, draft_profile, slime_dir + + if not draft_path: + slime_dir = os.path.expanduser(SLIME_DIR) + draft_path = os.path.expanduser(DRAFT_FOLDER) + draft_profile = os.path.expanduser(DRAFT_PROFILE) + if not os.path.isdir(draft_path): + os.mkdir(slime_dir) + os.mkdir(draft_path) + os.mkdir(os.path.join(draft_path, "drafts")) + f = open(os.path.join(draft_path, ".folders"), "w") + f.write("drafts\n") + f.close() + if not os.path.isfile(draft_profile): + f = open(draft_profile, "w") + f.close() + + draft_mh = mhlib.MH(draft_path, draft_profile) + draft_folder = SlimeFolder_Draft('drafts', draft_mh) + return draft_folder + +class SlimeFolder_Draft(slime_mh.SlimeFolder_MH): + def close(self): + pass + + +def main(): + f = draft_top_folder() + f.open() + f.rescan_subfolders() + print f.list_all_subfolders() + f.close() + +if __name__ == "__main__": + main() diff --git a/slime-0.11/slime_folderinfo.py b/slime-0.11/slime_folderinfo.py new file mode 100644 index 0000000..041b2f1 --- /dev/null +++ b/slime-0.11/slime_folderinfo.py @@ -0,0 +1,77 @@ +import helpers, string, os + +_folderinfo = {} + +def load_folderinfo(filename): + global _folderinfo + try: + f = open(filename, "r") + except IOError: + _folderinfo = {} + return + while 1: + line = f.readline() + if not line: + break + foldername = string.split(line, "\n")[0] + dict = {} + while 1: + line = f.readline() + if not line or line == "\n": + break + parts = string.split(line, " ", 1) + field = parts[0] + value = string.split(parts[1], "\n")[0] + dict[field] = value + _folderinfo[foldername] = dict + f.close() + +def save_folderinfo(filename): + text = [] + for foldername in _folderinfo.keys(): + text.append("%s\n" % foldername) + dict = _folderinfo[foldername] + for field in dict.keys(): + text.append("%s %s\n" % (field, dict[field])) + text.append("\n") + text = string.join(text, "") + helpers.write_text_to_named_file(filename + ".new", text) + try: + os.remove(filename) + except os.error: + pass + os.rename(filename + ".new", filename) + +def get_folderinfo(foldername, field): + try: + return _folderinfo[foldername][field] + except KeyError: + return None + +def set_folderinfo(foldername, field, value): + try: + dict = _folderinfo[foldername] + except KeyError: + dict = {} + _folderinfo[foldername] = dict + dict[field] = value + +def get_folders_with_info(field, value): + list = [] + for foldername in _folderinfo.keys(): + try: + if _folderinfo[foldername][field] == value: + list.append(foldername) + except KeyError: + pass + return list + +def main(): + load_folderinfo("folderinfo.test") + print get_folderinfo("/home/liw/Mail/inbox", "inbox") + set_folderinfo("/home/liw/Mail/inbox", "inbox", "no") + print get_folderinfo("/home/liw/Mail/inbox", "inbox") + save_folderinfo("folderinfo.test") + +if __name__ == "__main__": + main() diff --git a/slime-0.11/slime_mh.py b/slime-0.11/slime_mh.py new file mode 100644 index 0000000..a4571d6 --- /dev/null +++ b/slime-0.11/slime_mh.py @@ -0,0 +1,245 @@ +"""Implement interface to MH folders.""" + +from slime_abstract import * +import os + +import mhlib2 +mhlib = mhlib2 # kludge until Python accepts my modified mhlib.py +import StringIO2 +StringIO = StringIO2 + +default_mh = None + +def mh_top_folder(): + """Return SlimeFolder with top level MH folders.""" + global default_mh + if not default_mh: + default_mh = mhlib.MH() + mhroot = SlimeFolder_MHroot(default_mh) + mhroot.open() + mhroot.rescan_subfolders() + if mhroot.list_all_subfolders(): + mhroot.close() + return mhroot + mhroot.close() + return None + +class SlimeFolder_MHroot(SlimeFolder): + """Root of all MH folders.""" + + def __init__(self, mh): + SlimeFolder.__init__(self) + self._mh = mh + self._name = mh.getpath() + self._subfolders = [] + + def open(self): + self._is_open = 1 + self.rescan_subfolders() + + def close(self): + self._is_open = 0 + + def commit_changes(self): + self._assert_open() + + def rescan_messages(self): + pass + + def rescan_subfolders(self): + self._assert_open() + list = [] + for name in self._mh.listfolders(): + if not os.path.isabs(name): + name = os.path.join(self._mh.getpath(), name) + new = None + for subfolder in self._subfolders: + if name == repr(subfolder): + new = subfolder + break + if new is None: + new = SlimeFolder_MH(name, self._mh) + list.append(new) + self._subfolders = list + +class SlimeFolder_MH(SlimeFolder): + """Abstract interface to an MH mail folder.""" + + def __init__(self, name, mh): + SlimeFolder.__init__(self) + self.mh = mh + self._subfolders = [] + self._name = os.path.join(mh.getpath(), name) + self.mh_folder = self.mh.openfolder(self._name) + self._last_message_scan = 0 + + def open(self): + self._is_open = 1 + self._old_numbers = [] + + def close(self): + """Close the folder.""" + for msg in self._messages: + msg.uncache_headers() + self._is_open = 0 + + def rescan_messages(self): + self._assert_open() + + dirname = self.mh_folder.getfullname() + dirmtime = os.stat(dirname)[8] + if dirmtime == self._last_message_scan: + return + self._last_message_scan = dirmtime + + seq = self.mh_folder.getsequences() + if seq.has_key("unseen"): + unseen = seq["unseen"] + else: + unseen = [] + + numbers = self.mh_folder.listmessages() + list = [] + for n in numbers: + if n in self._old_numbers: + for msg in self._messages: + if msg._id == n: + list.append(msg) + break + else: + msg = SlimeMessage_MH(self, n) + if n in unseen: + msg.set_status("N") + list.append(msg) + self._messages = list + self._old_numbers = numbers + self._threads = [] + + def rescan_subfolders(self): + oldlist = [] + for old in self._subfolders: + oldlist.append(repr(old)) + list = [] + for name in self.mh_folder.listsubfolders(): + fullname = os.path.join(self.mh.getpath(), name) + try: + i = oldlist.index(fullname) + new = self._subfolders[i] + except ValueError: + new = SlimeFolder_MH(name, self.mh) + list.append(new) + self._subfolders = list + + def add_message(self, file): + self._assert_open() + f = self.mh_folder + n = f.getlast() + 1 + f.createmessage(n, file) + msg = SlimeMessage_MH(self, n) + self._messages.append(msg) + self._old_numbers.append(n) + return msg + + def commit_changes(self): + if self._dirty: + self._assert_open() + unseen = [] + list = [] + for msg in self._messages: + if msg.has_status("D"): + list.append(msg._id) + elif msg.has_status("N"): + unseen.append(msg._id) + self.mh_folder.removemessages(list) + + seq = self.mh_folder.getsequences() + if seq.has_key("unseen"): + unseen.sort() + seq["unseen"] = unseen + self.mh_folder.putsequences(seq) + + self._dirty = 0 + + self.rescan_messages() + +class SlimeMessage_MH(SlimeMessage): + """A SlimeMessage interface to mhlib.Message.""" + + def __init__(self, folder, id): + SlimeMessage.__init__(self, folder) + self._msg = None + self._id = id + + def _open(self): + self._folder._assert_open() + self._open_count = self._open_count + 1 + if not self._msg: + self._msg = mhlib.Message(self._folder.mh_folder, + self._id) + # make sure stuff is cached + if not self._cached_date: + self._cache_headers() + + def _close(self): + self._folder._assert_open() + self._open_count = self._open_count - 1 + if self._open_count == 0: + self._msg = None + + def change_text(self, file): + self._folder._assert_open() + if self._open_count != 0: + raise Error, "change_text needs closed message" + filename = self._folder.mh_folder.getmessagefilename(self._id) + newname = filename + '.new' + f = open(newname, "w") + while 1: + buf = file.read(16*1024) + if not buf: + break + f.write(buf) + f.close() + os.rename(newname, filename) + self.uncache_headers() + +def main(): + root = mh_top_folder() + if not root: + print "no MH root" + else: +# root.open() +# root.rescan_subfolders() +# print root.list_all_subfolders() +# root.close() + foo(root) + +def foo(folder): + folder.open() + folder.rescan_subfolders() + folder.close() + folder.open() + folder.rescan_subfolders() + print folder + for sub in folder.list_all_subfolders(): + foo(sub) + folder.close() + +def profile_main(): + import time + f = mh_top_folder() + f.open() + f.rescan_subfolders() + for sub in f.list_all_subfolders(): + if sub.get_nice_name() == "cola-incoming": + sub.open() + print "computing threads" + start = time.time() + sub.rescan_messages() + sub.list_all_threads() + end = time.time() + print "threads computed", end - start + sub.close() + f.close() + +if __name__ == "__main__": + main() diff --git a/slime-0.11/slime_pgpmime.py b/slime-0.11/slime_pgpmime.py new file mode 100644 index 0000000..e02d06d --- /dev/null +++ b/slime-0.11/slime_pgpmime.py @@ -0,0 +1,168 @@ +"""PGP/MIME interface for Slime. + +Parts of this module is based on the algorithms in pgpExec.tcl in +Exmh 2.0gamma. + +""" + +from slime_abstract import * +from slime_mh import * +from slime_unix import * +from ui_config import config +import slime_send, helpers + +import tempfile, regex, mimetools, os, FCNTL + + +# Exception names for results of PGP stuff +SecretMissing = "slime_pgp.SecretMissing" +PublicMissing = "slime_pgp.PublicMissing" +GoodUntrustedSignature = "slime_pgp.GoodUntrustedSignature" +BadUntrustedSignature = "slime_pgp.BadUntrustedSignature" +BadTrustedSignature = "slime_pgp.BadTrustedSignature" +OtherResult = "slime_pgp.OtherResult" + +# Regular expressions for interpreting the PGP results +secret_missing_pat = \ + regex.compile("This.*do not have the secret key.*file", regex.casefold) +public_missing_pat = \ + regex.compile("Can't.*can't check signature integrity", regex.casefold) +good_signature_pat = \ + regex.compile("Good signature", regex.casefold) +confidence_pat = \ + regex.compile("WARNING:.*confidence", regex.casefold) +doesnt_match_pat = \ + regex.compile("WARNING:.*doesn't match", regex.casefold) + +def interpret_pgp(exit_code, stdout, stderr): + """Interpret the output of PGP, raise exception for errors.""" + tuple = (exit_code, stdout, stderr) + + if secret_missing_pat.search(stdout) >= 0: + raise SecretMissing, tuple + elif public_missing_pat.search(stdout) >= 0: + raise PublicMissing, tuple + elif good_signature_pat.search(stdout) >= 0: + if confidence_pat.search(stdout) >= 0: + raise GoodUntrustedSignature, tuple + else: + return # Yay! It's OK! Way to go, man! + elif doesnt_match_pat.search(stdout) >= 0: + if confidence_pat.search(stdout) >= 0: + raise BadUntrustedSignature, tuple + else: + raise BadTrustedSignature, tuple + else: + raise OtherResult, tuple + +def write_to_named_file(filename, msg): + helpers.write_text_to_named_file(filename, + msg.getheadertext() + "\n" + msg.getbodytext()[:-1]) + # XXX note that the [:-1] part is a kludge -- exmh + # creates an extra newline or something + +def check_signature(msg): + """Check signature of a PGP/MIME message, raise exception for errors.""" + if msg.gettype() != "multipart/signed" or \ + not "protocol" in msg.getparamnames() or \ + msg.getparam("protocol") != "application/pgp-signature" or \ + not "micalg" in msg.getparamnames() or \ + msg.getparam("micalg") != "pgp-md5": + return None + + parts = msg.getbodyparts() + msgtxt = tempfile.mktemp() + msgsig = tempfile.mktemp() + + write_to_named_file(msgtxt, parts[0]) + write_to_named_file(msgsig, parts[1]) + + exit_code, stdout, stderr = \ + helpers.run_pgp(config["pgp-path"], "", "", [], [msgsig, msgtxt]) + os.remove(msgtxt) + os.remove(msgsig) + interpret_pgp(exit_code, stdout, stderr) + +def filter_content_headers(headername): + headername = string.lower(headername) + return not headername in ["content-type", "content-transfer-encoding"] + +def make_signature(msg, username, password): + """Return string with text of signed message.""" + + body_type = msg["content-type"] + body_xfer = msg["content-transfer-encoding"] + if not body_type or body_type == "text/plain": + charset = slime_send.deduce_charset(msg.getbodytext()) + body_type = "text/plain; charset=%s" % charset + if charset == "us-ascii": + body_xfer = "7bit" + else: + body_xfer = "8bit" + + msg_headers = msg.getheadertext(filter_content_headers) + msg_body = msg.getbodytext(decode=0) + + boundary = mimetools.choose_boundary() + msg_type = 'multipart/signed; boundary=%s;\n' + \ + ' micalg=pgp-md5; protocol="application/pgp-signature"' + msg_type = msg_type % boundary + + part1_headers = "Content-Type: %s\nContent-Transfer-Encoding: %s\n" % \ + (body_type, body_xfer) + part1 = part1_headers + "\n" + msg_body + + exit_code, stdout, stderr = \ + helpers.run_pgp(config["pgp-path"], username, password, + ["-sbatf"], [], stdin=part1) + if exit_code != 0: + raise OtherResult, (exit_code, stdout, stderr) + + part2_headers = "Content-Type: application/pgp\n" + part2 = part2_headers + "\n" + stdout + + return "%sContent-Type: %s\n\n--%s\n%s\n--%s\n%s\n--%s--\n" % \ + (msg_headers, msg_type, boundary, part1, + boundary, part2, boundary) + + +def test_check_signature(): + mhroot = mh_top_folder() + mhroot.open() + mhroot.rescan_subfolders() + for sub in mhroot.list_all_subfolders(): + if sub.get_nice_name() == "sent-mail": + sub.open() + sub.rescan_messages() + list = sub.list_all_messages()[:1] + if list: + msg = list[0] + result = check_signature(msg) + print "Check result:" + print result + sub.close() + mhroot.close() + +def test_make_signature(): + import sys + mhroot = mh_top_folder() + mhroot.open() + mhroot.rescan_subfolders() + for sub in mhroot.list_all_subfolders(): + if sub.get_nice_name() == "drafts": + sub.open() + sub.rescan_messages() + list = sub.list_all_messages()[:1] + if list: + msg = list[0] + print "Enter username:" + username = sys.stdin.readline()[:-1] + print "Enter password:" + password = sys.stdin.readline()[:-1] + result = make_signature(msg, username, password) + print result + sub.close() + mhroot.close() + +if __name__ == "__main__": + test_check_signature() diff --git a/slime-0.11/slime_root.py b/slime-0.11/slime_root.py new file mode 100644 index 0000000..c265801 --- /dev/null +++ b/slime-0.11/slime_root.py @@ -0,0 +1,77 @@ +"""Implement the root folder.""" + +from slime_abstract import SlimeFolder, Error + +class SlimeFolder_Root(SlimeFolder): + """The root folder that lists all folder collections.""" + + def __init__(self): + SlimeFolder.__init__(self) + self._name = "Root folder" + + def open(self): + self._is_open = 1 + + def close(self): + self._is_open = 0 + + def rescan_subfolders(f): + self._assert_open() + + def rescan_messages(f): + self._assert_open() + + def add_message(self, message): + raise Error, "Can't add messages to root folder." + + def delete_message(self, id): + raise Error, "Can't delete messages from root folder." + + def open_message(self, id): + raise Error, "Can't open messages in root folder." + + def add_subfolder(self, object): + """Add a subfolder `object'. + + Note that the interface is different from SlimeFolder: + the root folder can't know the type of the folder, so + it gets an object, instead of just the name.""" + self._assert_open() + self._subfolders.append(object) + + def delete_self(self): + raise Error, "Can't remove root folder." + + def rename_self(self, new_name): + raise Error, "Can't rename root folder." + + def commit_changes(self): + # For the root folder, everything is happens at once. + self._assert_open() + + +def main(): + """Test the root folder.""" + from slime_mh import * + from slime_unix import * + + r = SlimeFolder_Root() + + r.open() + r.add_subfolder(SlimeFolder_Unix(os.path.expanduser("~/mail"))) + + for f in r.list_all_subfolders(): + print repr(f) + f.open() + f.rescan_subfolders() + f.rescan_messages() + for m in f.list_all_threads()[:2]: + print m["subject"] + print "Subfolders:", f.list_all_subfolders() + f.close() + print + + r.close() + +if __name__ == "__main__": + main() diff --git a/slime-0.11/slime_send.py b/slime-0.11/slime_send.py new file mode 100644 index 0000000..3e6c3b8 --- /dev/null +++ b/slime-0.11/slime_send.py @@ -0,0 +1,171 @@ +import smtplib, string, regex, regsub, pwd, os, socket, rfc822, time +import helpers, StringIO +from ui_config import config + +draft_txt = """From: %s +Reply-to: %s +To: +Cc: +Subject: + +%s""" + +reply_txt = """From: %s +Reply-to: %s +To: %s +Cc: %s +Subject: %s%s +In-Reply-To: %s +References: %s + +%s: +%s + +%s""" + +_user_address = None + +def deduce_user_address(): + global _user_address + if config["from-address"]: + return config["from-address"] + if _user_address is None: + userinfo = pwd.getpwuid(os.getuid()) + host = socket.gethostname() + ip = socket.gethostbyname(host) + fqdn = socket.gethostbyaddr(ip)[0] + _user_address = "%s@%s" % (userinfo[0], fqdn) + if userinfo[4]: + x = regsub.sub(",*$", "", userinfo[4]) + _user_address = "%s <%s>" % (x, _user_address) + return _user_address + +def get_signature_text(): + try: + pathname = os.path.expanduser(config["signature-file"]) + sig = "-- \n" + helpers.read_text_from_named_file(pathname) + except IOError: + sig = "" + return sig + +def make_new_message_text(): + return draft_txt % (deduce_user_address(), config["reply-to"], + get_signature_text()) + +def remove_duplicate_addrs(recipients, used_addrs): + result = [] + for recipient in string.split(recipients, ","): + name, addr = rfc822.parseaddr(recipient) + if not addr in used_addrs: + result.append(recipient) + used_addrs.append(addr) + return string.join(result, ","), used_addrs + +def make_reply_message_text(msg): + user_addr = deduce_user_address() + reply_to = config["reply-to"] + to_addrs = msg["to"] + if to_addrs: + to_addrs = msg["from"] + ", " + to_addrs + else: + to_addrs = msg["from"] + cc_addrs = msg["cc"] + + user_addr_name, user_addr_addr = rfc822.parseaddr(user_addr) + reply_to_name, reply_to_addr = rfc822.parseaddr(reply_to) + + if user_addr_addr and reply_to_addr: + used_addrs = [user_addr_addr, reply_to_addr] + elif user_addr_addr: + used_addrs = [user_addr_addr] + elif reply_to_addr: + used_addrs = [reply_to_addr] + else: + used_addrs = [] + to_addrs, used_addrs = remove_duplicate_addrs(to_addrs, used_addrs) + cc_addrs, used_addrs = remove_duplicate_addrs(cc_addrs, used_addrs) + + subject = msg["subject"] + if string.lower(subject[:3]) == "re:": + re = "" + else: + re = "Re: " + + msgid = msg["message-id"] + refs = msg["references"] + " " + msgid + while len(refs) > config["max-references-length"]: + new_refs = regsub.sub("[^ ]* ", "", refs) + if len(new_refs) == refs: + break + refs = new_refs + + quoted_author = msg["from"] + quoted = msg.getbodytext() + quoted = regsub.gsub("^", "> ", quoted) + if quoted and quoted[-1] != "\n": + quoted = quoted + "\n" + + return reply_txt % (user_addr, reply_to, to_addrs, + cc_addrs, re, subject, msgid, refs, + quoted_author, quoted, get_signature_text()) + +empty_trans = string.maketrans("", "") +sevenbit_chars = None + +def deduce_charset(str): + """Deduce the character set used in a string.""" + global sevenbit_chars + if not sevenbit_chars: + list = [] + for code in range(128): + list.append(chr(code)) + sevenbit_chars = string.join(list, "") + if string.translate(str, empty_trans, sevenbit_chars): + return "ISO-8859-1" + return "us-ascii" + +empty_header = regex.compile("^[^:][^:]*:[ \t]*$") +date_header = regex.compile("^Date:", regex.casefold) + +def prepare_for_send(msg): + nonempty = [] + hit = 0 + for line in string.split(msg.getheadertext(), "\n"): + if line and not line[0] in string.whitespace: + if date_header.match(line) == -1: + hit = (empty_header.match(line) == -1) + else: + hit = 0 + if line and hit: + nonempty.append(line) + nonempty.append("Date: %s" % helpers.rfc822_date()) + nonempty.append("") + headers = string.join(nonempty, "\n") + + msg.change_text(StringIO.StringIO(headers + "\n" + msg.getbodytext(0))) + +def send_msg(msg): + name, sender = msg.getaddr("from") + receivers = [] + for header in ["to", "cc"]: + if msg[header]: + for name, addr in msg.getaddrlist(header): + receivers.append(addr) + + smtp = smtplib.SMTP(config["smtp-server"]) + smtp.helo() + smtp.mail_from(sender) + for receiver in receivers: + smtp.rcpt_to(receiver) + text = string.split(msg.getfulltext(), "\n") + smtp.data(text) + smtp.quit() + +def main(): + print deduce_user_address() + print rfc822_date() + print deduce_charset("this is us-ascii") + print deduce_charset("tämä on latin1:tä") + +if __name__ == "__main__": + main() diff --git a/slime-0.11/slime_unix.py b/slime-0.11/slime_unix.py new file mode 100644 index 0000000..0410aa0 --- /dev/null +++ b/slime-0.11/slime_unix.py @@ -0,0 +1,183 @@ +import mhlib, os, StringIO2 +StringIO = StringIO2 +from slime_abstract import * +from mailbox import UnixMailbox, _Subfile + +def unix_top_folder(dirname): + """Return SlimeFolder with top level Unix folders.""" + dirname = os.path.expanduser(dirname) + if os.path.isdir(dirname): + if not os.path.isfile(os.path.join(dirname, "context")): + return SlimeFolder_Unix(dirname) + return None + +class SlimeFolder_Unix(SlimeFolder): + """A SlimeFolder interface to ordinary Unix mailbox folders.""" + + def __init__(self, name): + SlimeFolder.__init__(self) + if not os.path.isabs(name): + name = os.path.join(os.getcwd(), name) + self._name = name + self._is_dir = 0 + + def open(self): + self._is_open = 1 + if os.path.isdir(self._name): + self._is_dir = 1 + else: + self._is_dir = 0 + self._fp = open(self._name, 'r+') + + def close(self): + for msg in self._messages: + msg.uncache_headers() + if not self._is_dir: + self._fp = None + self._is_open = 0 + + def rescan_messages(self): + self._assert_open() + if not self._is_dir: + new_messages = [] + mb = UnixMailbox(self._fp) + while 1: + m = mb.next() + if not m: + break + mm = SlimeMessage_Unix(self, m.fp) + for msg in self._messages: + if mm._checksum == msg._checksum: + mm = msg + break + mm.set_status(mm["status"]) + new_messages.append(mm) + self._messages = new_messages + self._threads = [] + + def rescan_subfolders(self): + self._assert_open() + if self._is_dir: + list = [] + for pathname in os.listdir(self._name): + p = os.path.join(self._name, pathname) + list.append(SlimeFolder_Unix(p)) + self._subfolders = list + + def add_message(self, file): + self._assert_open() + + dummy = mhlib.Message('NONE', 1, file) + self._fp.seek(0,2) + start = self._fp.tell() + self._fp.write(self._make_separator(dummy)) + del dummy + file.seek(0,0) + while 1: + buf = file.read(16*1024) + if not buf: + break + self._fp.write(buf) + end = self._fp.tell() + subfile = _Subfile(self._fp, start, end) + msg = SlimeMessage_Unix(self, subfile) + self._messages.append(msg) + return msg + + def commit_changes(self): + self._assert_open() + if not self._is_dir and self._dirty: + newname = self._name + ".new" + f = open(newname, "w") + for msg in self._messages: + if not msg.has_status("D"): + f.write(self._make_separator(msg)) + f.write(msg.getfulltext()) + f.close() + os.rename(newname, self._name) + + self._messages = [] + self._dirty = 0 + self.open() + self.rescan_messages() + self.rescan_subfolders() + + def _make_separator(self, msg): + date = msg.getdate("date") + if date is None: + date = (1970,1,1,0,0,0,3,1,0) + mon = ["", "Jan", "Feb", "Mar", "Apr", "May", + "Jun", "Jul", "Aug", "Sep", "Oct", + "Nov", "Dec"][date[1]] + dow = ["Mon", "Tue", "Wed", "Thu", "Fri", + "Sat", "Sun"][date[6]] + year = date[0] + if year < 1900: + year = year + 1900 + name, addr = msg.getaddr("from") + if not addr: + addr = "foo" + return "From %s %s %s %2d %2d:%02d:%02d %d\n" % \ + (addr, dow, mon, date[2], date[3], date[4], + date[5], year) + +class SlimeMessage_Unix(SlimeMessage): + """A SlimeMessage interface to messages in Unix mailbox folders.""" + + def __init__(self, folder, subfile): + SlimeMessage.__init__(self, folder) + self._start = subfile.start + self._stop = subfile.stop + self._checksum = self._start + + def _open(self): + self._folder._assert_open() + self._open_count = self._open_count + 1 + if not self._msg: + self._fp = _Subfile(self._folder._fp, + self._start, self._stop) + self._msg = mhlib.Message('NONE', 1, self._fp) + + # make sure stuff is cached + if not self._cached_date: + self._cache_headers() + + def _close(self): + self._folder._assert_open() + self._open_count = self._open_count - 1 + if self._open_count == 0: + self._msg = None + self._fp.close() + +def main(): + f = SlimeFolder_Unix(os.path.expanduser("~/mail")) + f.open() + f.rescan_subfolders() + for sub in f.list_all_subfolders(): + sub.open() + sub.rescan_messages() + for msg in sub.list_all_messages()[:2]: + print sub._name, msg["subject"] + sub.close() + print + f.close() + +def profile_main(): + import time + f = unix_top_folder("~/mail") + f.open() + f.rescan_subfolders() + for sub in f.list_all_subfolders(): + if repr(sub) == "/home/liw/mail/yow3": + sub.open() + print "computing threads" + start = time.time() + sub.rescan_messages() + sub.list_all_threads() + end = time.time() + print "threads computed", end - start + sub.close() + f.close() + +if __name__ == "__main__": + main() diff --git a/slime-0.11/ui.py b/slime-0.11/ui.py new file mode 100644 index 0000000..016f5ca --- /dev/null +++ b/slime-0.11/ui.py @@ -0,0 +1,1058 @@ +from Tkinter import * +from ScrolledText import ScrolledText + +import string, time, tempfile +import Shells, Menu, ButtonBox +import slime_abstract, slime_pgpmime, slime_send, ui_config +config = ui_config.config + +from ui_helpers import * +from ui_compose import * +from slime_folderinfo import * + +INITIAL_INBOX_SCAN_DELAY = 5*1000 + +folderinfo_file = os.path.expanduser("~/.slime/folderinfo") + +root = None +draft_folder = None +fcc_folder = None + +pgp_password = None +pgp_timestamp = 0 + +class DummyFeedback: + def delete_msg(self, foo, msg, msgwin): pass + def undelete_msg(self, foo, msg, msgwin): pass + def close_msgwin(self, foo, msgwin): pass + def next_msg(self, foo, msg, msgwin): pass + def prev_msg(self, foo, msg, msgwin): pass + def mark_unread_msg(self, foo, msg, msgwin): pass + + def send_msg(self, msg): pass + +class MessageWindow: + + _all_msgwin = [] + + def __init__(self): + self._all_msgwin.append(self) + + self.top = Toplevel() + self.top.title("Slime Message Window") + self.top.iconname("Slime Message Window") + + self.feedback = DummyFeedback() + self.shorten_headers = 1 + + self.top.bind("n", self.next_msg) + self.top.bind("p", self.prev_msg) + self.top.bind("d", self.delete_msg) + self.top.bind("u", self.undelete_msg) + self.top.bind("<Key-Down>", self.next_line) + self.top.bind("<Key-Up>", self.prev_line) + self.top.bind("<Key-Next>", self.next_page) + self.top.bind("<Key-Prior>", self.prev_page) + self.top.bind("<Key-space>", self.next_page_or_msg) + + menubar = Menu.Menubar(self.top) + Menu.Pulldown(menubar, "Menu", + ["Reply", self.reply_to_msg], + ["-"], + ["Delete", self.delete_msg], + ["Undelete", self.undelete_msg], + ["Mark unread", self.undelete_msg], + ["-"], + ["Next", self.next_msg], + ["Prev", self.prev_msg], + ["-"], + ["Show selected headers", self.show_important_headers], + ["Show full headers", self.show_full_headers], + ["-"], + ["Close", self.close_msgwin]) + + buttonbar = Frame(self.top) + buttonbar.pack(fill=X) + CommandButton(buttonbar, "Delete", self.delete_msg) + + self.textwin = ScrolledText(self.top) + self.textwin.pack(expand=YES, fill=BOTH) + self.textwin.config(background="white") + self.textwin.config(font=config["normal-font"]) + self.disable() + + statusbar = Frame(self.top) + statusbar.pack(fill=X) + b = Label(statusbar, text="Status:") + b.pack(side=LEFT) + self.statuswin = Label(statusbar, text="") + self.statuswin.config(relief=SUNKEN) + self.statuswin.pack(side=LEFT) + b = Label(statusbar, text="Size:") + b.pack(side=LEFT) + self.sizewin = Label(statusbar, text="", relief=SUNKEN) + self.sizewin.pack(side=LEFT) + + self.feedback = None + self.msg = None + + def show_full_headers(self): + self.shorten_headers = 0 + self.re_set() + + def show_important_headers(self): + self.shorten_headers = 1 + self.re_set() + + def place(self, x, y): + self.top.geometry("+%d+%d" % (x,y)) + + def enable(self): + self.textwin.config(state="normal") + + def disable(self): + self.textwin.config(state="disabled") + + def reply_to_msg(self, event=None): + self.feedback.reply_to_msg(self.msg) + + def delete_msg(self, event=None): + self.feedback.delete_msg(event, self.msg, self) + + def undelete_msg(self, event=None): + self.feedback.undelete_msg(event, self.msg, self) + + def close_msgwin(self, event=None): + self.feedback.close_msgwin(self) + self.top.destroy() + self._all_msgwin.remove(self) + + def next_msg(self, event=None): + self.feedback.next_msg(event, self.msg, self) + + def prev_msg(self, event=None): + self.feedback.prev_msg(event, self.msg, self) + + def get_top_index(self): + index = self.textwin.index("@0,0") + pair = string.split(index, ".") + return string.atoi(pair[0]), string.atoi(pair[1]) + + def height(self): + return string.atoi(self.textwin.cget("height")) + + def next_line(self, event=None): + line, col = self.get_top_index() + self.textwin.yview(line) + + def prev_line(self, event=None): + line, col = self.get_top_index() + line = line - 1 + if line < 0: + line = 0 + self.textwin.yview(line-1) + + def next_page(self, event=None): + line, col = self.get_top_index() + line = line + (self.height() - 2) + self.textwin.yview(line-1) + + def next_page_or_msg(self, event=None): + line, col = self.get_top_index() + line2 = line + (self.height() - 2) + if line2 >= self.lines() - 1: + self.next_msg() + else: + self.textwin.yview(line2-1) + + def prev_page(self, event=None): + line, col = self.get_top_index() + line = line - (self.height() - 2) + if line < 1: + line = 1 + self.textwin.yview(line-1) + + def mark_unread_msg(self, event=None): + self.feedback.mark_unread_msg(event, self.msg, self) + + def set(self, msg, feedback): + if feedback: + self.feedback = feedback + else: + self.feedback = DummyFeedback() + self.msg = msg + self.enable() + self.textwin.delete('0.0', 'end') + for tag in self.textwin.tag_names(): + self.textwin.tag_delete(tag) + self.append_part(msg) + self.append_separator() + self.disable() + x = "%.20s: %.30s" % (msg["from"], msg["subject"]) + self.top.title(x) + self.top.iconname(x) + self.update_status() + + def re_set(self): + self.set(self.msg, self.feedback) + + def update_status(self): + for msgwin in self._all_msgwin: + x = msgwin.msg.get_status() + if x == "": + x = " " + msgwin.statuswin.config(text=x) + msgwin.sizewin.config(text="%d" % self.lines()) + + def append_part(self, part): + self.append_headers(part) + type = part.getmaintype() + subtype = part.getsubtype() + if type == "multipart": + if subtype == "signed": + self.append_multipart_signed(part) + else: + self.append_multipart_any(part) + elif type == "image" and subtype == "gif": + self.append_separator() + self.append_image_gif(part) + else: + self.append_separator() + self.append_text_any(part) + + def append_multipart_signed(self, part): + if ui_config.config["check-pgp-signatures"]: + try: + slime_pgpmime.check_signature(part) + self.append_line("Signature: OK\n") + except slime_pgpmime.SecretMissing, detail: + self.append_error("Signature: Secret key is missing\n") + except slime_pgpmime.PublicMissing, detail: + self.append_error("Signature: Public key is missing\n") + except slime_pgpmime.GoodUntrustedSignature, detail: + self.append_error("Signature: Good, but untrusted signature\n") + except slime_pgpmime.BadUntrustedSignature, detail: + self.append_error("Signature: Bad signature\n") + except slime_pgpmime.BadTrustedSignature, detail: + self.append_error("Signature: Bad signature\n") + except slime_pgpmime.OtherResult, detail: + self.append_error("Signature: Unknown result\n") + else: + self.append_line("Signature: unchecked\n") + self.append_multipart_any(part) + + def append_multipart_any(self, part): + for p in part.getbodyparts(): + self.append_part(p) + + def append_error(self, line): + self.textwin.insert('end', line) + tag = self.tag_last_line('error') + self.textwin.tag_config(tag, background='red') + + def append_line(self, line): + self.textwin.insert('end', line) + + def append_image_gif(self, part): + data = part.getbodytext() + tempname = tempfile.mktemp() + helpers.write_text_to_named_file(tempname, data) + photo = PhotoImage(file=tempname) + label = Label(master=self.textwin, image=photo) + label.pack() + self.textwin.window_create('end', window=label) + os.remove(tempname) + + def append_text_any(self, part): + self.textwin.insert('end', part.getbodytext()) + + def append_separator(self): + str = "" + self.textwin.insert("end", str + "\n") + tag = self.tag_last_line("sep") + self.textwin.tag_config(tag, background='lightgrey', + relief=RAISED, borderwidth=2, font='5x7') + + def lines(self): + index = self.textwin.index("end") + pair = string.split(index, ".") + return string.atoi(pair[0]) - 2 + + def tag_last_line(self, tag_prefix): + lines = self.lines() + tag = "%s-%d" % (tag_prefix, lines) + self.textwin.tag_add(tag, "%d.0" % lines, "%d.0" % (lines + 1)) + return tag + + def append_headers(self, part): + if self.shorten_headers: + h = [] + headers = string.split(config["important-headers"]) + for header in headers: + try: + v = part[header] + if v: + h.append("%s: %s\n" % + (header, v)) + except KeyError: + pass + h = string.join(h, "") + self.textwin.insert('end', h) + else: + self.textwin.insert('end', part.getheadertext()) + +class SelectionList(ScrolledText): + def __init__(self, master): + ScrolledText.__init__(self, master) + self.pack(side=LEFT, expand=YES, fill=BOTH) + self.config(wrap="none", spacing1=1, exportselection=NO) + self.config(background="white") + self.config(font=config["normal-font"]) + self.bind("<Button-1>", self.select) + self.bind("<Double-Button-1>", self.select_double) + self.bind("<Button-2>", self.select2) + self.bind("<Double-Button-2>", self.select2_double) + self.bind("<Button-3>", self.select3) + self.bind("<Double-Button-3>", self.select3_double) + self.disable() + self.current = None + + def get_top_index(self): + index = self.index("@0,0") + pair = string.split(index, ".") + return string.atoi(pair[0]), string.atoi(pair[1]) + + def enable(self): + self.config(state="normal") + + def disable(self): + self.config(state="disabled") + + def select_hook(self): + pass + def double_hook(self): + pass + + def select2_hook(self, lineno): + pass + def double2_hook(self, lineno): + pass + + def select3_hook(self, lineno): + pass + def double3_hook(self, lineno): + pass + + def update_cursor(self): + if not self.current is None: + self.tag_delete("curline") + self.tag_add("curline", + "%d.0" % self.current, + "%d.0 lineend + 1 chars" % (self.current)) + self.tag_config("curline", background="darkgrey") + + def select_by_lineno(self, lineno): + self.prev_current = self.current + self.current = lineno + self.select_hook() + self.update_cursor() + self.see("%d.0" % (lineno + 4)) + self.see("%d.0" % (lineno)) + + def _get_lineno(self, event): + index = self.index("@%d,%d" % (event.x, event.y)) + pair = string.split(index, ".") + return string.atoi(pair[0]) + + def select(self, event): + self.select_by_lineno(self._get_lineno(event)) + def select_double(self, event): + self.double_hook() + + def select2(self, event): + self.select2_hook(self._get_lineno(event)) + def select2_double(self, event): + self.double2_hook(self._get_lineno(event)) + + def select3(self, event): + self.select3_hook(self._get_lineno(event)) + def select3_double(self, event): + self.double3_hook(self._get_lineno(event)) + +class FolderTocWindow(SelectionList): + def __init__(self, master): + SelectionList.__init__(self, master) + self.config(width=60, height=15) + self.folder = None + self.msg = None + self.msgwin = None + self.msg_dict = {} + self.msg_count = 0 + + self.focus_set() + self.bind("n", self.next_msg) + self.bind("p", self.prev_msg) + self.bind("d", self.delete_msg) + self.bind("u", self.undelete_msg) + + def set(self, folder, mcount_win, folder_win): + self.enable() + self.delete('0.0', 'end') + n = 0 + self.folder = folder + self.msg = None + self.msg_dict = {} + self.msg_count = len(folder.list_all_threads()) + for indent_level, msg in folder.list_all_threads(): + if n > 0: + self.insert('end', '\n') + n = n + 1 + self.msg_dict[msg] = n + + parts = self.make_toc_parts(indent_level, msg) + line = self.make_toc_line(parts) + self.insert('end', line) + self.set_tags(parts, line, n) + self.disable() + self.mcount_win = mcount_win + self.mcount_win.config(text="%d" % self.msg_count) + self.folder_win = folder_win + + def re_set(self): + if self.folder: + old_msg = self.msg + self.folder.rescan_messages() + self.set(self.folder, self.mcount_win, self.folder_win) + if self.msg_dict.has_key(old_msg): + self.select_by_lineno(self.msg_dict[old_msg]) + elif self.msgwin: + self.msgwin.close_msgwin() + + def make_toc_line(self, parts): + return "%s %s %-20.20s %s%s" % \ + (parts[0], parts[4], parts[3], parts[1], parts[2]) + + def make_toc_parts(self, indent_level, msg): + if msg.has_status("N"): + status = "N" + elif msg.has_status("D"): + status = "D" + else: + status = " " + if indent_level <= config["max-subject-indent"]: + indent = "%*s" % (indent_level * 2, "") + else: + indent = "%*s(%d) " % \ + (config["max-subject-indent"] * 2, "", + indent_level) + name, addr = msg.getaddr("from") + if not name: + name = addr + subject = msg["subject"] + if subject: + subject = string.join(string.split(subject, "\n"), " ") + else: + subject = "<none>" + date_tuple = msg.getdate("date") + if date_tuple is None: + date = "%10s" % "" + else: + if date_tuple[0] < 1900: + year = 1900 + date_tuple[0] + else: + year = date_tuple[0] + date = "%04d-%02d-%02d" % \ + (year, date_tuple[1], date_tuple[2]) + + return status, indent, subject, name, date + + def set_tags(self, parts, line, lineno): +# sub1 = len(parts[0]) + 1 + len(parts[4]) + 22 + len(parts[1]) +# sub2 = sub1 + len(parts[2]) + + sub1 = 0 + sub2 = len(line) + + if parts[0] == "N": + font = config["unread-font"] + else: + font = config["normal-font"] + self.add_tag(".subject", lineno, sub1, sub2, font) + + def add_tag(self, suffix, lineno, fromcol, tocol, font): + tag = "%d.%s" % (lineno, suffix) + self.tag_add(tag, + "%d.%d" % (lineno, fromcol), + "%d.%d" % (lineno, tocol)) + self.tag_configure(tag, font=font) + + def update_toc_line(self, msg): + if not self.msg_dict.has_key(msg): + return + lineno = self.msg_dict[msg] + parts = self.make_toc_parts(self.folder.get_msg_level(msg), msg) + line = self.make_toc_line(parts) + self.enable() + self.delete("%d.0" % lineno, "%d.end" % lineno) + self.insert("%d.0" % lineno, line) + self.disable() + self.set_tags(parts, line, lineno) + self.folder_win.redraw_folder(self.folder) + + def select_hook(self): + foo, self.msg = self.folder.list_all_threads()[self.current-1] + if self.msg.has_status("N"): + self.msg.remove_status("N") + self.folder_win.decrement_unread() + + if not self.msgwin: + self.msgwin = MessageWindow() + self.put_at_good_position(self.msgwin) + self.msgwin.set(self.msg, self) + self.update_toc_line(self.msg) + self.update_cursor() + + def put_at_good_position(self, msgwin): + x = self.winfo_rootx() + y = self.winfo_rooty() + w = self.winfo_width() + h = self.winfo_height() + msgwin.place(x+w/10, y+h+h/10) + + def double_hook(self): + msg = self.folder.list_all_messages()[self.current-1] + msgwin = MessageWindow() + msgwin.set(msg, self) + + def delete_msg(self, event=None, msg=None, msgwin=None): + if msg is None: + msg = self.msg + msgwin = self.msgwin + if msg: + msg.delete_self() + msgwin.update_status() + if self.msg_dict.has_key(msg): + self.update_toc_line(msg) + self.update_cursor() + self.next_msg(event, msg, msgwin) + + def undelete_msg(self, event=None, msg=None, msgwin=None): + if msg is None: + msg = self.msg + msgwin = self.msgwin + msg.undelete_self() + if self.msg_dict.has_key(msg): + self.update_toc_line(msg) + self.update_cursor() + + def mark_unread_msg(self, event=None, msg=None, msgwin=None): + if msg is None: + msg = self.msg + msgwin = self.msgwin + msg.give_status("N") + if self.msg_dict.has_key(msg): + self.update_toc_line(msg) + self.update_cursor() + + def next_msg(self, event=None, msg=None, msgwin=None): + if msg is None: + msg = self.msg + msgwin = self.msgwin + if msgwin == self.msgwin and self.current < self.msg_count: + self.select_by_lineno(self.current+1) + + def prev_msg(self, event=None, msg=None, msgwin=None): + if msg is None: + msg = self.msg + msgwin = self.msgwin + if msgwin == self.msgwin and self.current > 1: + self.select_by_lineno(self.current-1) + + def close_msgwin(self, msgwin): + if msgwin == self.msgwin: + self.msgwin = None + + def compose_msg(self): + if self.folder == draft_folder and self.msg: + c = ComposeWindow(self, self.msg, self) + c.show() + else: + file = StringIO.StringIO(slime_send.make_new_message_text()) + msg = draft_folder.add_message(file) + self.refresh_draft_toc() + c = ComposeWindow(self, msg, self) + c.show() + + def reply_to_msg(self, origmsg): + txt = slime_send.make_reply_message_text(origmsg) + file = StringIO.StringIO(txt) + msg = draft_folder.add_message(file) + self.refresh_draft_toc() + c = ComposeWindow(self, msg, self) + c.show() + + def refresh_draft_toc(self): + draft_folder.commit_changes() + if self.folder == draft_folder: + self.re_set() + + def get_pgp_authentication(self, msg): + global pgp_password, pgp_timestamp + + if config["pgp-username"]: + username = config["pgp-username"] + else: + d = PgpUsernameDialog(self, msg["from"]) + username = d.show() + if not username: + return None, None + + now = time.time() + diff = now - pgp_timestamp + if diff < 0 or diff > 60*config["pgp-max-password-age"]: + p = PasswordDialog(self) + password = p.show() + if not password: + return None, None + pgp_password = password + pgp_timestamp = now + else: + password = pgp_password + + return username, password + + def send_msg(self, msg): + if config["make-pgp-signatures"]: + username, password = self.get_pgp_authentication(msg) + if username is None: + return 0 + try: + new_text = slime_pgpmime.make_signature(msg, + username, password) + except slime_pgpmime.OtherResult, detail: + d = ErrorBox(self, "PGP failed", detail[2]) + d.show() + return 0 + msg.change_text(StringIO.StringIO(new_text)) + slime_send.prepare_for_send(msg) + slime_send.send_msg(msg) + if self.store_to_fcc_folder(msg) == 0: + return 0 + msg.delete_self() + self.refresh_draft_toc() + return 1 + + def store_to_fcc_folder(self, msg): + global fcc_folder, tops + if not config["fcc-folder"]: + return 1 + if not fcc_folder: + fcc_folder = \ + helpers.find_folder_by_name(config["fcc-folder"], + root) + if not fcc_folder: + d = ErrorBox(self, "No FCC folder", + ("There is no folder named\n%s\n" + + "No copy of message has been saved") \ + % (config["fcc-folder"])) + d.show() + return 0 + try: + was_open = fcc_folder.is_open() + if not was_open: + fcc_folder.open() + fcc_folder.rescan_messages() + fcc_folder.copy_message_here(msg) + if not was_open: + fcc_folder.close() + except slime_abstract.Error, detail: + d = ErrorBox(self, "FCC copy failed", + "Saving copy of sent message failed:\n" + \ + detail) + return 0 + return 1 + +class FolderWindow(SelectionList): + def __init__(self, master): + SelectionList.__init__(self, master) + self.config(width=25, height=15) + self.pack(expand=NO) + self.folder_to_lineno = {} + self.folder = None + self.msgwin = None + self.msg = None + self.main_win = None + + def decrement_unread(self): + if self.main_win: + self.main_win.decrement_unread() + + def get_folders(self, root, indent=0, recurse=1): + list = [] + for sub in root.list_all_subfolders(): + list.append(sub.get_nice_name(), sub) + list.sort() + result = [] + for tuple in list: + sub = tuple[1] + result.append(indent, sub) + if recurse: + sub.open() + sub.rescan_messages() + sub.rescan_subfolders() + result = result + self.get_folders(sub, + indent+1, recurse-1) + sub.close() + return result + + def format_folder(self, level, folder, count_width, max_folder_indent): + is_inbox = (get_folderinfo(repr(folder), "inbox") == "yes") + if is_inbox: + was_open = folder.is_open() + if not was_open: + folder.open() + folder.rescan_messages() + msgs = folder.list_all_messages() + else: + was_open = 1 + msgs = [] + + if is_inbox: + cnt = "%*s" % (count_width, len(msgs)) + else: + cnt = "%*s" % (count_width, "") + + if level <= max_folder_indent: + indent = "%*s" % (level*2, "") + else: + indent = "%*s(%d) " % (max_folder_indent*2, "", level) + str = "%s %s%s" % (cnt, indent, folder.get_nice_name()) + + font = config["normal-font"] + for msg in msgs: + if msg.has_status("N"): + font = config["unread-font"] + + if is_inbox and not was_open: + folder.close() + + return str, font + + def draw_folders(self): + line, col = self.get_top_index() + self.enable() + self.delete('0.0', 'end') + self.folder_to_lineno = {} + lineno = 1 + for level, f in self.folders: + self.folder_to_lineno[f] = lineno + s, font = self.format_folder(level, f, + config["count-width"], + config["max-folder-indent"]) + if lineno > 1: + self.insert('end', '\n') + self.insert('end', s) + self.change_font_on_line(lineno, font) + lineno = lineno + 1 + self.disable() + try: + self.current = self.folder_to_lineno[self.folder] + except KeyError: + self.current = None + self.update_cursor() + self.yview(line-1) + self.fcount_win.config(text="%d" % len(self.folders)) + + def change_font_on_line(self, lineno, font): + tag = "%d.font" % (lineno) + self.tag_delete(tag) + self.tag_add(tag, + "%d.0" % (lineno), + "%d.0 lineend" % (lineno)) + self.tag_config(tag, font=font) + + def redraw_folder(self, folder): + if self.folder_to_lineno.has_key(folder): + lineno = self.folder_to_lineno[folder] + for level, f in self.folders: + if f == folder: + break + s, font = self.format_folder(level, folder, + config["count-width"], + config["max-folder-indent"]) + self.enable() + self.delete("%d.0" % lineno, "%d.0 lineend" % lineno) + self.insert("%d.0" % lineno, s) + self.change_font_on_line(lineno, font) + self.disable() + if self.folder == folder: + self.update_cursor() + + def set(self, slime_root, toc_win, fcount_win, mcount_win, main_win): + self.slime_root = slime_root + self.toc_win = toc_win + self.fcount_win = fcount_win + self.mcount_win = mcount_win + self.folders = self.get_folders(slime_root, recurse=0) + self.draw_folders() + self.main_win = main_win + + def show_subfolders(self, index): + this = self.folders[index] + was_open = this[1].is_open() + if not was_open: + this[1].open() + this[1].rescan_subfolders() + subs = self.get_folders(this[1], this[0]+1, recurse=0) + if not was_open: + this[1].close() + self.folders = self.folders[:index+1] + subs + \ + self.folders[index+1:] + self.draw_folders() + + def hide_subfolders(self, index): + this = self.folders[index] + next = self.folders[index+1] + i = index+1 + while i < len(self.folders) and this[0] < self.folders[i][0]: + i = i + 1 + self.folders = self.folders[:index+1] + self.folders[i:] + self.draw_folders() + + def show_or_hide_subfolders(self, index=None): + if index is None: + index = self.current-1 + this = self.folders[index] + if index+1 < len(self.folders): + next = self.folders[index+1] + if this[0] < next[0]: + self.hide_subfolders(index) + else: + self.show_subfolders(index) + else: + self.show_subfolders(index) + + def select_hook(self): + new_folder = self.folders[self.current-1][1] + if not self.folder: + self.folder = new_folder + self.folder.open() + self.folder.rescan_subfolders() + self.toc_win.set(self.folder, self.mcount_win, self) + self.show_or_hide_subfolders() + elif self.folder == new_folder: + self.folder.rescan_subfolders() + self.toc_win.re_set() + self.show_or_hide_subfolders() + else: + if self.commit_changes(re_set = 0): + self.folder.close() + self.folder = new_folder + self.folder.open() + self.folder.rescan_subfolders() + self.folder.rescan_messages() + self.toc_win.set(self.folder, self.mcount_win, + self) + self.show_or_hide_subfolders() + else: + self.current = self.prev_current + + def commit_changes(self, re_set = 1): + if self.folder and not self.folder.is_clean(): + d = YesNoDialog(self, + "Commit folder %s?" % \ + self.folder.get_nice_name()) + doit = d.show() + d.destroy() + if doit: + self.folder.commit_changes() + self.redraw_folder(self.folder) + if re_set: + self.toc_win.re_set() + return 1 + return 0 + return 1 + + def select2_hook(self, lineno): + self.show_or_hide_subfolders(lineno-1) + + def select3_hook(self, lineno): + if not self.folder: + print "no open folder" + elif not self.toc_win.msg: + print "no selected message" + else: + target = self.folders[lineno-1][1] + was_open = target.is_open() + if not was_open: + target.open() + target.rescan_messages() + newmsg = target.move_message_here(self.toc_win.msg) + if not was_open: + target.close() + self.toc_win.delete_msg() + +class MainWindow(Frame): + default_toplevel_used = 0 + def __init__(self, slime_root): + if self.default_toplevel_used: + master = Toplevel() + else: + master = None + self.default_toplevel_used = 1 + Frame.__init__(self, master) + self.pack(expand=YES, fill=BOTH) + t = self.winfo_toplevel() + t.title("Slime") + t.iconname("Slime") + + menubar = Menu.Menubar(self) + + self.slime_root = slime_root + f = Frame(self) + f.pack(expand=YES, fill=BOTH) + self.folder_win = FolderWindow(f) + self.ftoc_win = FolderTocWindow(f) + + statusbar = Frame(self) + statusbar.pack(fill=X) + b = Label(statusbar, text="Folders:") + b.pack(side=LEFT) + self.fcount_win = Label(statusbar, text="", relief=SUNKEN) + self.fcount_win.pack(side=LEFT) + + b = Label(statusbar, text="Unread:") + b.pack(side=LEFT) + self.total_unread_win = Label(statusbar, text="", relief=SUNKEN) + self.total_unread_win.pack(side=LEFT) + + self.mcount_win = Label(statusbar, text="", relief=SUNKEN) + self.mcount_win.pack(side=RIGHT) + b = Label(statusbar, text="Messages:") + b.pack(side=RIGHT) + + self.folder_win.set(self.slime_root, self.ftoc_win, + self.fcount_win, self.mcount_win, self) + + Menu.Pulldown(menubar, "&Folder", + ["Scan inboxes", self.scan_inboxes], + ["Rescan folder", self.ftoc_win.re_set], + ["Commit folder", self.folder_win.commit_changes], + ["-"], + ["Compose message", self.ftoc_win.compose_msg], + ["-"], + ["Edit folder options", self.edit_folderinfo], + ["-"], + ["Options", ui_config.edit_config], + ["Save options", self.save_config], + ["-"], + ["Exit", self.quit_program]) + + self.unread = 0 + self.after(INITIAL_INBOX_SCAN_DELAY, self.do_inbox_scan) + + def save_config(self): + ui_config.save_config() + d = InfoBox(self, "Config saved", "Configuration has been saved") + d.show() + + def edit_folderinfo(self): + if self.folder_win.folder is None: + return + name = repr(self.folder_win.folder) + is_inbox = (get_folderinfo(name, "inbox") == "yes") + d = FolderInfoEditor(self, name, is_inbox) + is_inbox = d.show() + if is_inbox is None: + return + if is_inbox: + set_folderinfo(name, "inbox", "yes") + else: + set_folderinfo(name, "inbox", "no") + self.folder_win.redraw_folder(self.folder_win.folder) + + def do_inbox_scan(self): + self.scan_inboxes() + self.after(config["inbox-scan-frequency"]*60*1000, + self.do_inbox_scan) + + def scan_inboxes(self): + global root + names = get_folders_with_info("inbox", "yes") + helpers.clear_name_to_folder_cache() + unread = 0 + for name in names: + folder = helpers.find_folder_by_name(name, root) + was_open = folder.is_open() + if not was_open: + folder.open() + folder.rescan_messages() + folder.rescan_subfolders() + for msg in folder.list_all_messages(): + if msg.has_status("N"): + unread = unread + 1 + self.folder_win.redraw_folder(folder) + if not was_open: + folder.close() + helpers.clear_name_to_folder_cache() + self.set_unread(unread) + + def set_unread(self, unread): + self.unread = unread + self.total_unread_win.config(text="%d" % unread) + + def decrement_unread(self): + self.set_unread(self.unread - 1) + + def quit_program(self): + d = YesNoDialog(self, "Really exit?") + if d.show(): + self.folder_win.commit_changes() + self.quit() + +def main(): + from slime_mh import * + from slime_unix import * + from slime_root import * + from slime_draft import * + import os + global draft_folder, root + + ui_config.load_config() + load_folderinfo(folderinfo_file) + + root = SlimeFolder_Root() + root.open() + + draft_folder = make_draft_folder() + draft_folder.open() + draft_folder.rescan_messages() + tops = [draft_folder] + maybe_tops = [mh_top_folder(), + unix_top_folder("~/Mail"), + unix_top_folder("~/mail")] + for f in maybe_tops: + if f: + tops.append(f) + if len(tops) == 1: + tops[0].open() + tops[0].rescan_messages() + tops[0].rescan_subfolders() + if len(tops[0].list_all_messages()) == 0: + for f in tops[0].list_all_subfolders(): + root.add_subfolder(f) + tops[0].close() + tops = [] + else: + tops[0].close() + for f in tops: + root.add_subfolder(f) + + mainwin = MainWindow(root) + mainwin.mainloop() + save_folderinfo(folderinfo_file) + draft_folder.close() + root.close() + +if __name__ == "__main__": + main() diff --git a/slime-0.11/ui_compose.py b/slime-0.11/ui_compose.py new file mode 100644 index 0000000..1fd970b --- /dev/null +++ b/slime-0.11/ui_compose.py @@ -0,0 +1,96 @@ +from Tkinter import * +from ScrolledText import * +from ui_helpers import * +from ui_config import config + +import Shells, Menu, StringIO, helpers, spawn, os, tempfile + +class ComposeWindow(Shells.NonModal): + def __init__(self, master, msg, feedback): + Shells.NonModal.__init__(self, master) + + self.msg = msg + self.feedback = feedback + + self.top.title("Slime Composition Window") + self.top.iconname("Slime Composition Window") + + menubar = Menu.Menubar(self.top) + Menu.Pulldown(menubar, "Menu", + ["Use external editor", self.use_external], + ["Save", self.save_msg], + ["Postpone", self.postpone_msg], + ["Send", self.send_msg], + ["Abort", self.abort_msg]) + + buttonbar = Frame(self.top) + buttonbar.pack(fill=X) + CommandButton(buttonbar, "Send", self.send_msg) + + self.textwin = ScrolledText(self.top) + self.textwin.pack(expand=YES, fill=BOTH) + self.textwin.focus_set() + self.textwin.config(background="white") + self.textwin.config(font=config["normal-font"]) + + self.textwin.insert('0.0', self.msg.getfulltext()) + self.textwin.mark_set('insert', '0.0') + + def use_external(self): + name = config["external-editor"] + if not name: + return + self.save_msg() + tempname = tempfile.mktemp() + text = self.textwin.get('0.0', 'end') + helpers.write_text_to_named_file(tempname, text) + self.top.iconify() + exit_code, stdout, stderr = \ + spawn.run(name, stdin='', args=[tempname], + env=os.environ) + self.top.deiconify() + if exit_code != 0 or stderr != '': + d = ErrorBox(self, "External editor failed", + "The external editor failed:\n" + stderr) + d.show() + os.remove(tempname) + return + text = helpers.read_text_from_named_file(tempname) + os.remove(tempname) + self.textwin.delete('0.0', 'end') + self.textwin.insert('0.0', text) + self.textwin.mark_set('insert', '0.0') + + def save_msg(self): + txt = self.textwin.get('0.0', 'end') + txtfile = StringIO.StringIO(txt) + self.msg.change_text(txtfile) + self.feedback.refresh_draft_toc() + + def send_msg(self): + d = YesNoDialog(self.top, "Send message?") + if d.show(): + self.save_msg() + if self.feedback.send_msg(self.msg): + self.terminate() + + def postpone_msg(self): + self.save_msg() + self.terminate() + d = InfoBox(self.top, "Message postponed", + "Message has been postponed.") + d.show() + + def abort_msg(self): + d = YesNoDialog(self.top, "Abort message?") + if d.show(): + self.msg.delete_self() + self.save_msg() + self.terminate() + + def deleteWindow(self): + self.abort_msg() + + def show(self): + Shells.NonModal.show(self) + self.textwin.focus_set() diff --git a/slime-0.11/ui_config.py b/slime-0.11/ui_config.py new file mode 100644 index 0000000..f184797 --- /dev/null +++ b/slime-0.11/ui_config.py @@ -0,0 +1,103 @@ +import os +from Config import * +from ConfigEditor import * + +config_file = "~/.slime/config" + +config_vars = { + +"count-width": (CONFIG_INTEGER, 3, "Width for message count in folder list"), + +"from-address": (CONFIG_STRING, "", "User's address (automatic, if empty)"), +"reply-to": (CONFIG_STRING, "", "Default Reply-To"), +"fcc-folder": (CONFIG_STRING, "", + "Folder to store copy of sent mail (no copy, if empty)"), +"signature-file": (CONFIG_STRING, "~/.signature", "Signature file"), + +"make-pgp-signatures": (CONFIG_BOOLEAN, 0, "Make PGP signatures?"), +"check-pgp-signatures": (CONFIG_BOOLEAN, 0, "Check PGP signatures?"), +"pgp-path": (CONFIG_STRING, "/usr/bin/pgp", "PGP command (full path)"), +"pgp-username": (CONFIG_STRING, "", "PGP username"), +"pgp-max-password-age": (CONFIG_INTEGER, 60, + "Time (in minutes) to remember PGP passphrase"), + +"smtp-server": (CONFIG_STRING, "localhost", "SMTP server"), +"max-references-length": (CONFIG_INTEGER, 500, + "Max length for References header"), +"external-editor": (CONFIG_STRING, "", "External editor"), + +"normal-font": (CONFIG_STRING, "-misc-fixed-medium-r-semicondensed--13-120-75-75-c-60-iso8859-1", + "Normal font"), +"unread-font": (CONFIG_STRING, "-misc-fixed-bold-r-semicondensed--13-120-75-75-c-60-iso8859-1", + "Unread font"), +"command-font": (CONFIG_STRING, "-misc-fixed-medium-r-normal--10-*-*-*-*-*-iso8859-1", + "Command button font"), +"max-subject-indent": (CONFIG_INTEGER, 4, + "Max subject indentation"), +"max-folder-indent": (CONFIG_INTEGER, 2, + "Max folder indentation"), +"important-headers": (CONFIG_STRING, "From To Cc Date Subject", + "Headers shown normally"), + +"inbox-scan-frequency": (CONFIG_INTEGER, 1, + "Frequency of new message scan (minutes)"), + +} + +config_layout = [ + ["Mail sending", + "from-address", + "reply-to", + "smtp-server", + "max-references-length", + "fcc-folder", + "external-editor", + "signature-file", + ], + ["PGP", + "make-pgp-signatures", + "check-pgp-signatures", + "pgp-path", + "pgp-username", + "pgp-max-password-age", + ], + ["Appearance", + "normal-font", + "unread-font", + "max-subject-indent", + "max-folder-indent", + "important-headers", + "count-width", + ], + ["Misc", + "inbox-scan-frequency", + ], +] + +config = Config(config_vars) + +def edit_config(): + global config, config_layout + ConfigEditor(config, config_layout) + +def load_config(): + p = os.path.expanduser(config_file) + if os.path.isfile(p): + f = open(p, "r") + config.read(f) + f.close() + +def save_config(): + p = os.path.expanduser(config_file) + if os.path.isfile(p): + pnew = p + ".new" + fp = open(p, "r") + fpnew = open(pnew, "w") + config.rewrite(fp, fpnew) + fp.close() + fpnew.close() + os.rename(p + ".new", p) + else: + f = open(p, "w") + config.write(f) + f.close() diff --git a/slime-0.11/ui_helpers.py b/slime-0.11/ui_helpers.py new file mode 100644 index 0000000..ad52f94 --- /dev/null +++ b/slime-0.11/ui_helpers.py @@ -0,0 +1,155 @@ +from Tkinter import * +import Shells, ButtonBox +from ui_config import config + +class CommandButton(Button): + def __init__(self, master, text, command): + Button.__init__(self, master=master, text=text, + font=config["command-font"], padx=1, pady=0, + command=command) + self.pack(side=LEFT) + +class ModalDialog(Shells.Modal): + def terminate(self): + Shells.Modal.terminate(self) + self.top.update() + + def show(self): + apply(Shells.NonModal.show, (self,)) + self.initFocus() + self.top.waitvar(self.waitVar) + return self.result + + def initFocus(self): + pass + +class PgpUsernameDialog(ModalDialog): + def __init__(self, infrontof, default_value): + ModalDialog.__init__(self, infrontof) + self.top.title("Enter PGP username") + self.top.iconname("Enter PGP username") + Label(self.top, text="Please enter the PGP username").pack() + self.entry = Entry(self.top, width=40) + self.entry.pack() + self.entry.bind("<Key-Return>", self.ok) + self.entry.bind("<Key-Escape>", self.cancel) + b = ButtonBox.T(self.top, + ("OK", self.ok), ("Cancel", self.cancel)) + self.default_value = default_value + + def ok(self, event=None): + self.terminate() + self.result = self.entry.get() + + def cancel(self, event=None): + self.terminate() + self.result = None + + def initControls(self): + self.entry.delete('0', 'end') + self.entry.insert('0', self.default_value) + + def initFocus(self): + self.entry.focus_set() + +class PasswordDialog(ModalDialog): + def __init__(self, infrontof): + ModalDialog.__init__(self, infrontof) + self.top.title("Enter PGP Passphrase") + self.top.iconname("Enter PGP Passphrase") + Label(self.top, text="Please enter the PGP passphrase").pack() + self.entry = Entry(self.top, show="*", width=40) + self.entry.pack() + self.entry.bind("<Key-Return>", self.ok) + self.entry.bind("<Key-Escape>", self.cancel) + b = ButtonBox.T(self.top, + ("OK", self.ok), ("Cancel", self.cancel)) + + def ok(self, event=None): + self.terminate() + self.result = self.entry.get() + + def cancel(self, event=None): + self.terminate() + self.result = None + + def initControls(self): + self.entry.delete('0', 'end') + + def initFocus(self): + self.entry.focus_set() + +class YesNoDialog(ModalDialog): + def __init__(self, infrontof, question, title=None): + ModalDialog.__init__(self, infrontof) + if not title: + title = question + self.top.title(title) + self.top.iconname(title) + Label(self.top, text=question).pack() + b = ButtonBox.T(self.top, + ("OK", self.ok), ("Cancel", self.cancel)) + + def ok(self): + self.result = 1 + self.terminate() + + def cancel(self): + self.result = 0 + self.terminate() + +class NonModalDialog(Shells.NonModal): + def terminate(self): + Shells.NonModal.terminate(self) + self.top.update() + +class InfoBox(NonModalDialog): + def __init__(self, infrontof, title, text): + NonModalDialog.__init__(self, infrontof) + self.top.title(title) + self.top.iconname(title) + Message(self.top, text=text, width=250).pack() + Button(self.top, text="Dismiss", command=self.terminate).pack() + +class ErrorBox(InfoBox): + def __init__(self, infrontof, title, text): + InfoBox.__init__(self, infrontof, "Error: " + title, text) + +class FolderInfoEditor(ModalDialog): + def __init__(self, infrontof, foldername, is_inbox_now): + ModalDialog.__init__(self, infrontof) + self.top.title("Folder settings") + self.top.iconname("Folder settings") + + Label(self.top, text="Settings for folder %s" % foldername).pack() + + frame = Frame(self.top) + frame.pack(fill=X) + Label(frame, text="Scan for new messages?").pack(side=LEFT) + self.var = BooleanVar() + self.var.set(is_inbox_now) + self.is_inbox = Checkbutton(frame, variable=self.var, + command=self.setOnOffText) + self.is_inbox.pack(side=LEFT) + + frame = Frame(self.top) + frame.pack(fill=X) + Button(frame, text="OK", command=self.ok).pack(side=LEFT) + Button(frame, text="Cancel", command=self.cancel).pack(side=LEFT) + + self.setOnOffText() + + def setOnOffText(self): + if self.var.get() == 0: + text = "No" + else: + text = "Yes" + self.is_inbox.config(text=text) + + def ok(self): + self.result = self.var.get() + self.terminate() + + def cancel(self): + self.result = None + self.terminate() |