5 Author: Piers Lauder <piers@cs.su.oz.au> December 1997.
9 Public functions: Internaldate2tuple
15 import re
, socket
, string
, time
, whrandom
22 AllowedVersions
= ('IMAP4REV1', 'IMAP4') # Most recent first
28 'APPEND': ('AUTH', 'SELECTED'),
29 'AUTHENTICATE': ('NONAUTH',),
30 'CAPABILITY': ('NONAUTH', 'AUTH', 'SELECTED', 'LOGOUT'),
31 'CHECK': ('SELECTED',),
32 'CLOSE': ('SELECTED',),
33 'COPY': ('SELECTED',),
34 'CREATE': ('AUTH', 'SELECTED'),
35 'DELETE': ('AUTH', 'SELECTED'),
36 'EXAMINE': ('AUTH', 'SELECTED'),
37 'EXPUNGE': ('SELECTED',),
38 'FETCH': ('SELECTED',),
39 'LIST': ('AUTH', 'SELECTED'),
40 'LOGIN': ('NONAUTH',),
41 'LOGOUT': ('NONAUTH', 'AUTH', 'SELECTED', 'LOGOUT'),
42 'LSUB': ('AUTH', 'SELECTED'),
43 'NOOP': ('NONAUTH', 'AUTH', 'SELECTED', 'LOGOUT'),
44 'RENAME': ('AUTH', 'SELECTED'),
45 'SEARCH': ('SELECTED',),
46 'SELECT': ('AUTH', 'SELECTED'),
47 'STATUS': ('AUTH', 'SELECTED'),
48 'STORE': ('SELECTED',),
49 'SUBSCRIBE': ('AUTH', 'SELECTED'),
51 'UNSUBSCRIBE': ('AUTH', 'SELECTED'),
54 # Patterns to match server responses
56 Continuation
= re
.compile(r
'\+ (?P<data>.*)')
57 Flags
= re
.compile(r
'.*FLAGS \((?P<flags>[^\)]*)\)')
58 InternalDate
= re
.compile(r
'.*INTERNALDATE "'
59 r
'(?P<day>[ 123][0-9])-(?P<mon>[A-Z][a-z][a-z])-(?P<year>[0-9][0-9][0-9][0-9])'
60 r
' (?P<hour>[0-9][0-9]):(?P<min>[0-9][0-9]):(?P<sec>[0-9][0-9])'
61 r
' (?P<zonen>[-+])(?P<zoneh>[0-9][0-9])(?P<zonem>[0-9][0-9])'
63 Literal
= re
.compile(r
'(?P<data>.*) {(?P<size>\d+)}$')
64 Response_code
= re
.compile(r
'\[(?P<type>[A-Z-]+)( (?P<data>[^\]]*))?\]')
65 Untagged_response
= re
.compile(r
'\* (?P<type>[A-Z-]+) (?P<data>.*)')
66 Untagged_status
= re
.compile(r
'\* (?P<data>\d+) (?P<type>[A-Z-]+)( (?P<data2>.*))?')
72 """IMAP4 client class.
74 Instantiate with: IMAP4([host[, port]])
76 host - host's name (default: localhost);
77 port - port number (default: standard IMAP4 port).
79 All IMAP4rev1 commands are supported by methods of the same
80 name (in lower-case). Each command returns a tuple: (type, [data, ...])
81 where 'type' is usually 'OK' or 'NO', and 'data' is either the
82 text from the tagged response, or untagged results from command.
84 Errors raise the exception class <instance>.error("<reason>").
85 IMAP4 server errors raise <instance>.abort("<reason>"),
86 which is a sub-class of 'error'.
89 class error(Exception): pass # Logical errors - debug required
90 class abort(error
): pass # Service errors - close and retry
93 def __init__(self
, host
= '', port
= IMAP4_PORT
):
98 self
.tagged_commands
= {} # Tagged commands awaiting response
99 self
.untagged_responses
= {} # {typ: [data, ...], ...}
100 self
.continuation_response
= '' # Last continuation response
103 # Open socket to server.
105 self
.sock
= socket
.socket(socket
.AF_INET
, socket
.SOCK_STREAM
)
106 self
.sock
.connect(self
.host
, self
.port
)
107 self
.file = self
.sock
.makefile('r')
109 # Create unique tag for this session,
110 # and compile tagged response matcher.
112 self
.tagpre
= Int2AP(whrandom
.random()*32000)
113 self
.tagre
= re
.compile(r
'(?P<tag>'
115 + r
'\d+) (?P<type>[A-Z]+) (?P<data>.*)')
117 # Get server welcome message,
118 # request and store CAPABILITY response.
120 if __debug__
and self
.debug
>= 1:
121 print '\tnew IMAP4 connection, tag=%s' % self
.tagpre
123 self
.welcome
= self
._get
_response
()
124 if self
.untagged_responses
.has_key('PREAUTH'):
126 elif self
.untagged_responses
.has_key('OK'):
127 self
.state
= 'NONAUTH'
128 # elif self.untagged_responses.has_key('BYE'):
130 raise self
.error(self
.welcome
)
133 self
._simple
_command
(cap
)
134 if not self
.untagged_responses
.has_key(cap
):
135 raise self
.error('no CAPABILITY response from server')
136 self
.capabilities
= tuple(string
.split(self
.untagged_responses
[cap
][-1]))
138 if __debug__
and self
.debug
>= 3:
139 print '\tCAPABILITIES: %s' % `self
.capabilities`
141 self
.PROTOCOL_VERSION
= None
142 for version
in AllowedVersions
:
143 if not version
in self
.capabilities
:
145 self
.PROTOCOL_VERSION
= version
147 if not self
.PROTOCOL_VERSION
:
148 raise self
.error('server not IMAP4 compliant')
151 def __getattr__(self
, attr
):
152 """Allow UPPERCASE variants of all following IMAP4 commands."""
153 if Commands
.has_key(attr
):
154 return eval("self.%s" % string
.lower(attr
))
155 raise AttributeError("Unknown IMAP4 command: '%s'" % attr
)
161 def append(self
, mailbox
, flags
, date_time
, message
):
162 """Append message to named mailbox.
164 (typ, [data]) = <instance>.append(mailbox, flags, date_time, message)
168 flags
= '(%s)' % flags
172 date_time
= Time2Internaldate(date_time
)
175 tag
= self
._command
(name
, mailbox
, flags
, date_time
, message
)
176 return self
._command
_complete
(name
, tag
)
179 def authenticate(self
, func
):
180 """Authenticate command - requires response processing.
184 raise self
.error('UNIMPLEMENTED')
188 """Checkpoint mailbox on server.
190 (typ, [data]) = <instance>.check()
192 return self
._simple
_command
('CHECK')
196 """Close currently selected mailbox.
198 Deleted messages are removed from writable mailbox.
199 This is the recommended command before 'LOGOUT'.
201 (typ, [data]) = <instance>.close()
204 try: typ
, dat
= self
._simple
_command
('CLOSE')
205 except EOFError: typ
, dat
= None, [None]
211 def copy(self
, message_set
, new_mailbox
):
212 """Copy 'message_set' messages onto end of 'new_mailbox'.
214 (typ, [data]) = <instance>.copy(message_set, new_mailbox)
216 return self
._simple
_command
('COPY', message_set
, new_mailbox
)
219 def create(self
, mailbox
):
220 """Create new mailbox.
222 (typ, [data]) = <instance>.create(mailbox)
224 return self
._simple
_command
('CREATE', mailbox
)
227 def delete(self
, mailbox
):
228 """Delete old mailbox.
230 (typ, [data]) = <instance>.delete(mailbox)
232 return self
._simple
_command
('DELETE', mailbox
)
236 """Permanently remove deleted items from selected mailbox.
238 Generates 'EXPUNGE' response for each deleted message.
240 (typ, [data]) = <instance>.expunge()
242 'data' is list of 'EXPUNGE'd message numbers in order received.
245 typ
, dat
= self
._simple
_command
(name
)
246 return self
._untagged
_response
(typ
, name
)
249 def fetch(self
, message_set
, message_parts
):
250 """Fetch (parts of) messages.
252 (typ, [data, ...]) = <instance>.fetch(message_set, message_parts)
254 'data' are tuples of message part envelope and data.
257 typ
, dat
= self
._simple
_command
(name
, message_set
, message_parts
)
258 return self
._untagged
_response
(typ
, name
)
261 def list(self
, directory
='""', pattern
='*'):
262 """List mailbox names in directory matching pattern.
264 (typ, [data]) = <instance>.list(directory='""', pattern='*')
266 'data' is list of LIST responses.
269 typ
, dat
= self
._simple
_command
(name
, directory
, pattern
)
270 return self
._untagged
_response
(typ
, name
)
273 def login(self
, user
, password
):
274 """Identify client using plaintext password.
276 (typ, [data]) = <instance>.list(user, password)
278 if not 'AUTH=LOGIN' in self
.capabilities \
279 and not 'AUTH-LOGIN' in self
.capabilities
:
280 raise self
.error("server doesn't allow LOGIN authorisation")
281 typ
, dat
= self
._simple
_command
('LOGIN', user
, password
)
283 raise self
.error(dat
)
289 """Shutdown connection to server.
291 (typ, [data]) = <instance>.logout()
293 Returns server 'BYE' response.
295 self
.state
= 'LOGOUT'
296 try: typ
, dat
= self
._simple
_command
('LOGOUT')
297 except EOFError: typ
, dat
= None, [None]
300 if self
.untagged_responses
.has_key('BYE'):
301 return 'BYE', self
.untagged_responses
['BYE']
305 def lsub(self
, directory
='""', pattern
='*'):
306 """List 'subscribed' mailbox names in directory matching pattern.
308 (typ, [data, ...]) = <instance>.lsub(directory='""', pattern='*')
310 'data' are tuples of message part envelope and data.
313 typ
, dat
= self
._simple
_command
(name
, directory
, pattern
)
314 return self
._untagged
_response
(typ
, name
)
318 """Prompt server for an update.
320 (typ, [data]) = <instance>.recent()
322 'data' is None if no new messages,
323 else value of RECENT response.
326 typ
, dat
= self
._untagged
_response
('OK', name
)
329 typ
, dat
= self
._simple
_command
('NOOP')
330 return self
._untagged
_response
(typ
, name
)
333 def rename(self
, oldmailbox
, newmailbox
):
334 """Rename old mailbox name to new.
336 (typ, data) = <instance>.rename(oldmailbox, newmailbox)
338 return self
._simple
_command
('RENAME', oldmailbox
, newmailbox
)
341 def response(self
, code
):
342 """Return data for response 'code' if received, or None.
344 (code, [data]) = <instance>.response(code)
346 return code
, self
.untagged_responses
.get(code
, [None])
349 def search(self
, charset
, criteria
):
350 """Search mailbox for matching messages.
352 (typ, [data]) = <instance>.search(charset, criteria)
354 'data' is space separated list of matching message numbers.
358 charset
= 'CHARSET ' + charset
359 typ
, dat
= self
._simple
_command
(name
, charset
, criteria
)
360 return self
._untagged
_response
(typ
, name
)
363 def select(self
, mailbox
='INBOX', readonly
=None):
366 (typ, [data]) = <instance>.select(mailbox='INBOX', readonly=None)
368 'data' is count of messages in mailbox ('EXISTS' response).
370 # Mandated responses are ('FLAGS', 'EXISTS', 'RECENT', 'UIDVALIDITY')
371 # Remove immediately interesting responses
372 for r
in ('EXISTS', 'READ-WRITE'):
373 if self
.untagged_responses
.has_key(r
):
374 del self
.untagged_responses
[r
]
379 typ
, dat
= self
._simple
_command
(name
, mailbox
)
381 self
.state
= 'SELECTED'
384 if not readonly
and not self
.untagged_responses
.has_key('READ-WRITE'):
385 raise self
.error('%s is not writable' % mailbox
)
386 return typ
, self
.untagged_responses
.get('EXISTS', [None])
389 def status(self
, mailbox
, names
):
390 """Request named status conditions for mailbox.
392 (typ, [data]) = <instance>.status(mailbox, names)
395 if self
.PROTOCOL_VERSION
== 'IMAP4':
396 raise self
.error('%s unimplemented in IMAP4 (obtain IMAP4rev1 server, or re-code)' % name
)
397 typ
, dat
= self
._simple
_command
(name
, mailbox
, names
)
398 return self
._untagged
_response
(typ
, name
)
401 def store(self
, message_set
, command
, flag_list
):
402 """Alters flag dispositions for messages in mailbox.
404 (typ, [data]) = <instance>.store(message_set, command, flag_list)
406 command
= '%s %s' % (command
, flag_list
)
407 typ
, dat
= self
._simple
_command
('STORE', message_set
, command
)
408 return self
._untagged
_response
(typ
, 'FETCH')
411 def subscribe(self
, mailbox
):
412 """Subscribe to new mailbox.
414 (typ, [data]) = <instance>.subscribe(mailbox)
416 return self
._simple
_command
('SUBSCRIBE', mailbox
)
419 def uid(self
, command
, args
):
420 """Execute "command args" with messages identified by UID,
421 rather than message number.
423 (typ, [data]) = <instance>.uid(command, args)
425 Returns response appropriate to 'command'.
428 typ
, dat
= self
._simple
_command
('UID', command
, args
)
429 if command
== 'SEARCH':
433 typ
, dat2
= self
._untagged
_response
(typ
, name
)
434 if dat2
[-1]: dat
= dat2
438 def unsubscribe(self
, mailbox
):
439 """Unsubscribe from old mailbox.
441 (typ, [data]) = <instance>.unsubscribe(mailbox)
443 return self
._simple
_command
('UNSUBSCRIBE', mailbox
)
446 def xatom(self
, name
, arg1
=None, arg2
=None):
447 """Allow simple extension commands
448 notified by server in CAPABILITY response.
450 (typ, [data]) = <instance>.xatom(name, arg1=None, arg2=None)
452 if name
[0] != 'X' or not name
in self
.capabilities
:
453 raise self
.error('unknown extension command: %s' % name
)
454 return self
._simple
_command
(name
, arg1
, arg2
)
461 def _append_untagged(self
, typ
, dat
):
463 if self
.untagged_responses
.has_key(typ
):
464 self
.untagged_responses
[typ
].append(dat
)
466 self
.untagged_responses
[typ
] = [dat
]
468 if __debug__
and self
.debug
>= 5:
469 print '\tuntagged_responses[%s] += %.20s..' % (typ
, `dat`
)
472 def _command(self
, name
, dat1
=None, dat2
=None, dat3
=None, literal
=None):
474 if self
.state
not in Commands
[name
]:
476 'command %s illegal in state %s' % (name
, self
.state
))
478 tag
= self
._new
_tag
()
479 data
= '%s %s' % (tag
, name
)
480 for d
in (dat1
, dat2
, dat3
):
481 if d
is not None: data
= '%s %s' % (data
, d
)
482 if literal
is not None:
483 data
= '%s {%s}' % (data
, len(literal
))
486 self
.sock
.send('%s%s' % (data
, CRLF
))
487 except socket
.error
, val
:
488 raise self
.abort('socket error: %s' % val
)
490 if __debug__
and self
.debug
>= 4:
491 print '\t> %s' % data
496 # Wait for continuation response
498 while self
._get
_response
():
499 if self
.tagged_commands
[tag
]: # BAD/NO?
504 if __debug__
and self
.debug
>= 4:
505 print '\twrite literal size %s' % len(literal
)
508 self
.sock
.send(literal
)
510 except socket
.error
, val
:
511 raise self
.abort('socket error: %s' % val
)
516 def _command_complete(self
, name
, tag
):
518 typ
, data
= self
._get
_tagged
_response
(tag
)
519 except self
.abort
, val
:
520 raise self
.abort('command: %s => %s' % (name
, val
))
521 except self
.error
, val
:
522 raise self
.error('command: %s => %s' % (name
, val
))
523 if self
.untagged_responses
.has_key('BYE') and name
!= 'LOGOUT':
524 raise self
.abort(self
.untagged_responses
['BYE'][-1])
526 raise self
.error('%s command error: %s %s' % (name
, typ
, data
))
530 def _get_response(self
):
532 # Read response and store.
534 # Returns None for continuation responses,
535 # otherwise first response line received
537 # Protocol mandates all lines terminated by CRLF.
539 resp
= self
._get
_line
()[:-2]
541 # Command completion response?
543 if self
._match
(self
.tagre
, resp
):
544 tag
= self
.mo
.group('tag')
545 if not self
.tagged_commands
.has_key(tag
):
546 raise self
.abort('unexpected tagged response: %s' % resp
)
548 typ
= self
.mo
.group('type')
549 dat
= self
.mo
.group('data')
550 self
.tagged_commands
[tag
] = (typ
, [dat
])
554 # '*' (untagged) responses?
556 if not self
._match
(Untagged_response
, resp
):
557 if self
._match
(Untagged_status
, resp
):
558 dat2
= self
.mo
.group('data2')
561 # Only other possibility is '+' (continuation) rsponse...
563 if self
._match
(Continuation
, resp
):
564 self
.continuation_response
= self
.mo
.group('data')
565 return None # NB: indicates continuation
567 raise self
.abort('unexpected response: %s' % resp
)
569 typ
= self
.mo
.group('type')
570 dat
= self
.mo
.group('data')
571 if dat2
: dat
= dat
+ ' ' + dat2
573 # Is there a literal to come?
575 while self
._match
(Literal
, dat
):
577 # Read literal direct from connection.
579 size
= string
.atoi(self
.mo
.group('size'))
580 if __debug__
and self
.debug
>= 4:
581 print '\tread literal size %s' % size
582 data
= self
.file.read(size
)
584 # Store response with literal as tuple
586 self
._append
_untagged
(typ
, (dat
, data
))
588 # Read trailer - possibly containing another literal
590 dat
= self
._get
_line
()[:-2]
592 self
._append
_untagged
(typ
, dat
)
594 # Bracketed response information?
596 if typ
in ('OK', 'NO', 'BAD') and self
._match
(Response_code
, dat
):
597 self
._append
_untagged
(self
.mo
.group('type'), self
.mo
.group('data'))
602 def _get_tagged_response(self
, tag
):
605 result
= self
.tagged_commands
[tag
]
606 if result
is not None:
607 del self
.tagged_commands
[tag
]
614 line
= self
.file.readline()
618 # Protocol mandates all lines terminated by CRLF
620 if __debug__
and self
.debug
>= 4:
621 print '\t< %s' % line
[:-2]
625 def _match(self
, cre
, s
):
627 # Run compiled regular expression match method on 's'.
628 # Save result, return success.
630 self
.mo
= cre
.match(s
)
631 if __debug__
and self
.mo
is not None and self
.debug
>= 5:
632 print "\tmatched r'%s' => %s" % (cre
.pattern
, `self
.mo
.groups()`
)
633 return self
.mo
is not None
638 tag
= '%s%s' % (self
.tagpre
, self
.tagnum
)
639 self
.tagnum
= self
.tagnum
+ 1
640 self
.tagged_commands
[tag
] = None
644 def _simple_command(self
, name
, dat1
=None, dat2
=None):
646 return self
._command
_complete
(name
, self
._command
(name
, dat1
, dat2
))
649 def _untagged_response(self
, typ
, name
):
651 if not self
.untagged_responses
.has_key(name
):
653 data
= self
.untagged_responses
[name
]
654 del self
.untagged_responses
[name
]
659 Mon2num
= {'Jan': 1, 'Feb': 2, 'Mar': 3, 'Apr': 4, 'May': 5, 'Jun': 6,
660 'Jul': 7, 'Aug': 8, 'Sep': 9, 'Oct': 10, 'Nov': 11, 'Dec': 12}
662 def Internaldate2tuple(resp
):
664 """Convert IMAP4 INTERNALDATE to UT.
666 Returns Python time module tuple.
669 mo
= InternalDate
.match(resp
)
673 mon
= Mon2num
[mo
.group('mon')]
674 zonen
= mo
.group('zonen')
676 for name
in ('day', 'year', 'hour', 'min', 'sec', 'zoneh', 'zonem'):
677 exec "%s = string.atoi(mo.group('%s'))" % (name
, name
)
679 # INTERNALDATE timezone must be subtracted to get UT
681 zone
= (zoneh
*60 + zonem
)*60
685 tt
= (year
, mon
, day
, hour
, min, sec
, -1, -1, -1)
687 utc
= time
.mktime(tt
)
689 # Following is necessary because the time module has no 'mkgmtime'.
690 # 'mktime' assumes arg in local timezone, so adds timezone/altzone.
692 lt
= time
.localtime(utc
)
693 if time
.daylight
and lt
[-1]:
694 zone
= zone
+ time
.altzone
696 zone
= zone
+ time
.timezone
698 return time
.localtime(utc
- zone
)
704 """Convert integer to A-P string representation."""
706 val
= ''; AP
= 'ABCDEFGHIJKLMNOP'
709 num
, mod
= divmod(num
, 16)
715 def ParseFlags(resp
):
717 """Convert IMAP4 flags response to python tuple."""
719 mo
= Flags
.match(resp
)
723 return tuple(string
.split(mo
.group('flags')))
726 def Time2Internaldate(date_time
):
728 """Convert 'date_time' to IMAP4 INTERNALDATE representation.
730 Return string in form: '"DD-Mmm-YYYY HH:MM:SS +HHMM"'
733 dttype
= type(date_time
)
734 if dttype
is type(1):
735 tt
= time
.localtime(date_time
)
736 elif dttype
is type(()):
738 elif dttype
is type(""):
739 return date_time
# Assume in correct format
740 else: raise ValueError
742 dt
= time
.strftime("%d-%b-%Y %H:%M:%S", tt
)
745 if time
.daylight
and tt
[-1]:
748 zone
= -time
.timezone
749 return '"' + dt
+ " %+02d%02d" % divmod(zone
/60, 60) + '"'
753 if __debug__
and __name__
== '__main__':
756 USER
= getpass
.getuser()
757 PASSWD
= getpass
.getpass()
760 ('login', (USER
, PASSWD
)),
761 ('create', ('/tmp/xxx',)),
762 ('rename', ('/tmp/xxx', '/tmp/yyy')),
763 ('CREATE', ('/tmp/yyz',)),
764 ('append', ('/tmp/yyz', None, None, 'From: anon@x.y.z\n\ndata...')),
765 ('select', ('/tmp/yyz',)),
767 ('uid', ('SEARCH', 'ALL')),
768 ('fetch', ('1', '(INTERNALDATE RFC822)')),
769 ('store', ('1', 'FLAGS', '(\Deleted)')),
776 ('response',('UIDVALIDITY',)),
777 ('uid', ('SEARCH', 'ALL')),
779 ('response', ('EXISTS',)),
784 typ
, dat
= apply(eval('M.%s' % cmd
), args
)
785 print ' %s %s\n => %s %s' % (cmd
, args
, typ
, dat
)
790 print 'PROTOCOL_VERSION = %s' % M
.PROTOCOL_VERSION
792 for cmd
,args
in test_seq1
:
795 for ml
in run('list', ('/tmp/', 'yy%')):
796 path
= string
.split(ml
)[-1]
797 run('delete', (path
,))
799 for cmd
,args
in test_seq2
:
802 if (cmd
,args
) != ('uid', ('SEARCH', 'ALL')):
805 uid
= string
.split(dat
[0])[-1]
807 '%s (FLAGS INTERNALDATE RFC822.SIZE RFC822.HEADER RFC822)' % uid
))