root/imapclient/imapclient.py @ 91:cd7e6ad6d118

Revision 91:cd7e6ad6d118, 25.0 KB (checked in by msmits@…, 8 months ago)

Backslash escaped double quotes in folder names now handled correctly

Also extracted the common code for list_folders and
list_sub_folders. The tests act on that directly.

Line 
1# Copyright (c) 2009, Menno Smits
2# Released subject to the New BSD License
3# Please see http://en.wikipedia.org/wiki/BSD_licenses
4
5import re
6import imaplib
7import shlex
8import datetime
9#imaplib.Debug = 5
10
11import imap_utf7
12from fixed_offset import FixedOffset
13
14__all__ = ['IMAPClient', 'DELETED', 'SEEN', 'ANSWERED', 'FLAGGED', 'DRAFT',
15    'RECENT']
16
17# System flags
18DELETED = r'\Deleted'
19SEEN = r'\Seen'
20ANSWERED = r'\Answered'
21FLAGGED = r'\Flagged'
22DRAFT = r'\Draft'
23RECENT = r'\Recent'         # This flag is read-only
24
25
26class IMAPClient(object):
27    """
28    A Pythonic, easy-to-use IMAP client class.
29
30    Unlike imaplib, arguments and returns values are Pythonic and readily
31    usable. Exceptions are raised when problems occur (no error checking of
32    return values is required).
33
34    Message unique identifiers (UID) can be used with any call. The use_uid
35    argument to the constructor and the use_uid attribute control whether UIDs
36    are used.
37
38    Any method that accepts message id's takes either a sequence containing
39    message IDs (eg. [1,2,3]) or a single message ID as an integer.
40
41    Any method that accepts message flags takes either a sequence containing
42    message flags (eg. [DELETED, 'foo', 'Bar']) or a single message flag (eg.
43    'Foo'). See the constants at the top of this file for commonly used flags.
44
45    Any method that takes a folder name will accept a standard string or a
46    unicode string. Unicode strings will be transparently encoded using
47    modified UTF-7 as specified by RFC-2060. Such folder names will be returned
48    as unicode strings by methods that return folder names.
49
50    Transparent folder name encoding can be enabled or disabled with the
51    folder_encode attribute. It defaults to True.
52
53    The IMAP related exceptions that will be raised by this class are:
54        IMAPClient.Error
55        IMAPClient.AbortError
56        IMAPClient.ReadOnlyError
57    These are aliases for the imaplib.IMAP4 exceptions of the same name. Socket
58    errors may also be raised in the case of network errors.
59    """
60
61    Error = imaplib.IMAP4.error
62    AbortError = imaplib.IMAP4.abort
63    ReadOnlyError = imaplib.IMAP4.readonly
64
65    re_sep = re.compile('^\(\("[^"]*" "([^"]+)"\)\)')
66#     re_folder = re.compile('\([^)]*\) "[^"]+" "?([^"]+)"?')
67    re_folder = re.compile(r'\([^)]*\) "[^"]+" (?P<qqq>"?)(?P<folder>.+)(?P=qqq)')
68    re_status = re.compile(r'^\s*"?(?P<folder>[^"]+)"?\s+'
69                           r'\((?P<status_items>.*)\)$')
70
71    def __init__(self, host, port=None, use_uid=True, ssl=False):
72        """Initialise object instance and connect to the remote IMAP server.
73
74        @param host: The IMAP server address/hostname to connect to.
75        @param port: The port number to use (default is 143, 993 for SSL).
76        @param use_uid: Should message UIDs be used (default is True).
77        @param ssl: Make an SSL connection (default is False)
78        """
79        if ssl:
80            ImapClass = imaplib.IMAP4_SSL
81            default_port = 993
82        else:
83            ImapClass = imaplib.IMAP4
84            default_port = 143
85
86        if port is None:
87            port = default_port
88
89        self._imap = ImapClass(host, port)
90        self.use_uid = use_uid
91        self.folder_encode = True
92
93   
94    def login(self, username, password):
95        """Perform a simple login
96        """
97        typ, data = self._imap.login(username, password)
98        self._checkok('login', typ, data)
99        return data[0]
100
101
102    def logout(self):
103        """Perform a logout
104        """
105        typ, data = self._imap.logout()
106        self._checkbye('logout', typ, data)
107        return data[0]
108
109
110    def capabilities(self):
111        """Returns the server capability list
112        """
113        return self._imap.capabilities
114
115
116    def has_capability(self, capability):
117        """Checks if the server has the given capability.
118
119        @param capability: capability to test (eg 'SORT')
120        """
121        # FIXME: this will not detect capabilities that are backwards
122        # compatible with the current level. For instance the SORT
123        # capabilities may in the future be named SORT2 which is
124        # still compatible with the current standard and will not
125        # be detected by this method.
126        if capability.upper() in self._imap.capabilities:
127            return True
128        else:
129            return False
130
131    def get_folder_delimiter(self):
132        """Determine the folder separator used by the IMAP server.
133
134        @return: The folder separator.
135        @rtype: string
136        """
137        typ, data = self._imap.namespace()
138        self._checkok('namespace', typ, data)
139
140        match = self.re_sep.match(data[0])
141        if match:
142            return match.group(1)
143        else:
144            raise self.Error('could not determine folder separator')
145
146
147    def list_folders(self, directory="", pattern="*"):
148        """Get a listing of folders on the server.
149
150        The default behaviour (no args) will list all folders for the logged in
151        user.
152
153        @param directory: The base directory to look for folders from.
154        @param pattern: A pattern to match against folder names. Only folder
155            names matching this pattern will be returned. Wildcards accepted.
156        @return: A list of folder names. Each folder name will be either a
157            string or a unicode string (if the folder on the server required
158            decoding). If the folder_encode attribute is False, no decoding
159            will be performed and only ordinary strings will be returned.
160        """
161        typ, data = self._imap.list(directory, pattern)
162        self._checkok('list', typ, data)
163        return self._proc_folder_list(data)
164
165
166    def list_sub_folders(self, directory="", pattern="*"):
167        """Get a listing of subscribed folders on the server.
168
169        The default behaviour (no args) will list all subscribed folders for the
170        logged in user.
171
172        @param directory: The base directory to look for folders from.
173        @param pattern: A pattern to match against folder names. Only folder
174            names matching this pattern will be returned. Wildcards accepted.
175        @return: A list of folder names. As per the return of list_folders().
176        """
177        typ, data = self._imap.lsub(directory, pattern)
178        self._checkok('lsub', typ, data)
179        return self._proc_folder_list(data)
180
181
182    def _proc_folder_list(self, folder_data):
183        folders = []
184        for line in folder_data:
185            #TODO can the FetchParser code be adapted for use here?
186            folder_text = None
187            if isinstance(line, tuple):
188                folder_text = line[-1]
189            else:
190                match = self.re_folder.match(line)
191                if match:
192                    folder_text = match.group('folder')
193                    folder_text = folder_text.replace(r'\"', '"')
194            if folder_text is not None:
195                folders.append(self._decode_folder_name(folder_text))
196        return folders
197       
198
199    def select_folder(self, folder):
200        """Select the current folder on the server. Future calls to methods
201        such as search and fetch will act on the selected folder.
202
203        @param folder: The folder name.
204        @return: Number of messages in the folder.
205        @rtype: long int
206        """
207        typ, data = self._imap.select(self._encode_folder_name(folder))
208        self._checkok('select', typ, data)
209        return long(data[0])
210
211
212    def folder_status(self, folder, what=None):
213        """Requests the status from folder.
214
215        @param folder: The folder name.
216        @param what: A sequence of status items to query. Defaults to
217            ('MESSAGES', 'RECENT', 'UIDNEXT', 'UIDVALIDITY', 'UNSEEN').
218        @return: Dictionary of the status items for the folder. The keys match
219            the items specified in the what parameter.
220        @rtype: dict
221        """
222        if what is None:
223            what = ('MESSAGES', 'RECENT', 'UIDNEXT', 'UIDVALIDITY', 'UNSEEN')
224        elif isinstance(what, basestring):
225            what = (what,)
226        what_ = '(%s)' % (' '.join(what))
227
228        typ, data = self._imap.status(self._encode_folder_name(folder), what_)
229        self._checkok('status', typ, data)
230
231        match = self.re_status.match(data[0])
232        if not match:
233            raise self.Error('Could not get the folder status')
234
235        out = {}
236        status_items = match.group('status_items').strip().split()
237        while status_items:
238            key = status_items.pop(0)
239            value = long(status_items.pop(0))
240            out[key] = value
241        return out
242
243
244    def close_folder(self):
245        """Close the currently selected folder.
246
247        @return: Server response.
248        """
249        typ, data = self._imap.close()
250        self._checkok('close', typ, data)
251        return data[0]
252
253
254    def create_folder(self, folder):
255        """Create a new folder on the server.
256
257        @param folder: The folder name.
258        @return: Server response.
259        """
260        typ, data = self._imap.create(self._encode_folder_name(folder))
261        self._checkok('create', typ, data)
262        return data[0]
263
264
265    def delete_folder(self, folder):
266        """Delete a new folder on the server.
267
268        @param folder: Folder name to delete.
269        @return: Server response.
270        """
271        typ, data = self._imap.delete(self._encode_folder_name(folder))
272        self._checkok('delete', typ, data)
273        return data[0]
274
275
276    def folder_exists(self, folder):
277        """Determine if a folder exists on the server.
278
279        @param folder: Full folder name to look for.
280        @return: True if the folder exists. False otherwise.
281        """
282        typ, data = self._imap.list('', self._encode_folder_name(folder))
283        self._checkok('list', typ, data)
284        return len(data) == 1 and data[0] != None
285
286
287    def subscribe_folder(self, folder):
288        """Subscribe to a folder.
289
290        @param folder: Folder name to subscribe to.
291        @return: Server response message.
292        """
293        typ, data = self._imap.subscribe(self._encode_folder_name(folder))
294        self._checkok('subscribe', typ, data)
295        return data
296
297
298    def unsubscribe_folder(self, folder):
299        """Unsubscribe a folder.
300
301        @param folder: Folder name to unsubscribe.
302        @return: Server response message.
303        """
304        typ, data = self._imap.unsubscribe(self._encode_folder_name(folder))
305        self._checkok('unsubscribe', typ, data)
306        return data
307
308
309    def search(self, criteria='ALL', charset=None):
310        if not criteria:
311            raise ValueError('no criteria specified')
312
313        if isinstance(criteria, basestring):
314            criteria = (criteria,)
315        crit_list = ['(%s)' % c for c in criteria]
316
317        if self.use_uid:
318            if charset is None:
319                typ, data = self._imap.uid('SEARCH', *crit_list)
320            else:
321                typ, data = self._imap.uid('SEARCH', 'CHARSET', charset,
322                    *crit_list)
323        else:
324            typ, data = self._imap.search(charset, *crit_list)
325
326        self._checkok('search', typ, data)
327
328        return [ long(i) for i in data[0].split() ]
329
330
331    def sort(self, sort_criteria, criteria='ALL', charset='UTF-8' ):
332        """Returns a list of messages sorted by sort_criteria.
333
334        Note that this is an extension to the IMAP4:
335        http://www.ietf.org/internet-drafts/draft-ietf-imapext-sort-19.txt
336        """
337        if not criteria:
338            raise ValueError('no criteria specified')
339
340        if not self.has_capability('SORT'):
341            raise self.Error('The server does not support the SORT extension')
342
343        if isinstance(criteria, basestring):
344            criteria = (criteria,)
345        crit_list = ['(%s)' % c for c in criteria]
346
347        sort_criteria = seq_to_parenlist([ s.upper() for s in sort_criteria])
348
349        if self.use_uid:
350            typ, data = self._imap.uid('SORT', sort_criteria, charset,
351                *crit_list)
352        else:
353            typ, data = self._imap.sort(sort_criteria, charset, *crit_list)
354
355        self._checkok('sort', typ, data)
356
357        return [ long(i) for i in data[0].split() ]
358
359
360    def get_flags(self, messages):
361        """Return the flags set for messages
362
363        @param messages: Message IDs to check flags for
364        @return: As for add_f
365            { msgid1: [flag1, flag2, ... ], }
366        """
367        response = self.fetch(messages, ['FLAGS'])
368        return self._flatten_dict(response)
369
370
371    def add_flags(self, messages, flags):
372        """Add one or more flags to messages
373
374        @param messages: Message IDs to add flags to
375        @param flags: Sequence of flags to add
376        @return: The flags set for each message ID as a dictionary
377            { msgid1: [flag1, flag2, ... ], }
378        """
379        return self._store('+FLAGS', messages, flags)
380
381
382    def remove_flags(self, messages, flags):
383        """Remove one or more flags from messages
384
385        @param messages: Message IDs to remove flags from
386        @param flags: Sequence of flags to remove
387        @return: As for get_flags.
388        """
389        return self._store('-FLAGS', messages, flags)
390
391
392    def set_flags(self, messages, flags):
393        """Set the flags for messages
394
395        @param messages: Message IDs to set flags for
396        @param flags: Sequence of flags to set
397        @return: As for get_flags.
398        """
399        return self._store('FLAGS', messages, flags)
400
401
402    def delete_messages(self, messages):
403        """Short-hand method for deleting one or more messages
404
405        @param messages: Message IDs to mark for deletion.
406        @return: Same as for get_flags.
407        """
408        return self.add_flags(messages, DELETED)
409
410
411    def fetch(self, messages, parts):
412        """Retrieve selected data items for one or more messages.
413
414        @param messages: Message IDs to fetch.
415        @param parts: A sequence of data items to retrieve.
416        @return: A dictionary indexed by message number. Each item is itself a
417            dictionary containing the requested message parts.
418            INTERNALDATE parts will be returned as datetime objects converted
419            to the local machine's time zone.
420        """
421        if not messages:
422            return {}
423
424        msg_list = messages_to_str(messages)
425        parts_list = seq_to_parenlist([p.upper() for p in parts])
426
427        if self.use_uid:
428            typ, data = self._imap.uid('FETCH', msg_list, parts_list)
429        else:
430            typ, data = self._imap.fetch(msg_list, parts_list)
431        self._checkok('fetch', typ, data)
432
433        parser = FetchParser()
434        return parser(data)
435
436
437    def append(self, folder, msg, flags=(), msg_time=None):
438        """Append a message to a folder
439
440        @param folder: Folder name to append to.
441        @param msg: Message body as a string.
442        @param flags: Sequnce of message flags to set. If not specified no
443            flags will be set.
444        @param msg_time: Optional date and time to set for the message. The
445            server will set a time if it isn't specified. If msg_time contains
446            timezone information (tzinfo), this will be honoured. Otherwise the
447            local machine's time zone sent to the server.
448        @type msg_time: datetime.datetime
449        @return: The append response returned by the server.
450        @rtype: str
451        """
452        if msg_time:
453            time_val = '"%s"' % datetime_to_imap(msg_time)
454        else:
455            time_val = None
456
457        flags_list = seq_to_parenlist(flags)
458
459        typ, data = self._imap.append(self._encode_folder_name(folder),
460                                      flags_list, time_val, msg)
461        self._checkok('append', typ, data)
462
463        return data[0]
464
465
466    def expunge(self):
467        typ, data = self._imap.expunge()
468        self._checkok('expunge', typ, data)
469        #TODO: expunge response
470
471
472    def getacl(self, folder):
473        """Get the ACL for a folder
474
475        @param folder: Folder name to get the ACL for.
476        @return: A list of (who, acl) tuples
477        """
478        typ, data = self._imap.getacl(folder)
479        self._checkok('getacl', typ, data)
480
481        parts = shlex.split(data[0])
482        parts = parts[1:]       # First item is folder name
483
484        out = []
485        for i in xrange(0, len(parts), 2):
486            out.append((parts[i], parts[i+1]))
487        return out
488
489
490    def setacl(self, folder, who, what):
491        """Set an ACL for a folder
492
493        @param folder: Folder name to set an ACL for.
494        @param who: User or group ID for the ACL.
495        @param what: A string describing the ACL. Set to '' to remove an ACL.
496        @return: Server response string.
497        """
498        typ, data = self._imap.setacl(folder, who, what)
499        self._checkok('setacl', typ, data)
500        return data[0]
501
502
503    def _check_resp(self, expected, command, typ, data):
504        """Check command responses for errors.
505
506        @raise: Error if a command failed.
507        """
508        if typ != expected:
509            raise self.Error('%s failed: %r' % (command, data[0]))
510
511
512    def _checkok(self, command, typ, data):
513        self._check_resp('OK', command, typ, data)
514
515
516    def _checkbye(self, command, typ, data):
517        self._check_resp('BYE', command, typ, data)
518
519
520    def _store(self, cmd, messages, flags):
521        """Worker function for flag manipulation functions
522
523        @param cmd: STORE command to use (eg. '+FLAGS')
524        @param messages: Sequence of message IDs
525        @param flags: Sequence of flags to set.
526        @return: The flags set for each message ID as a dictionary
527            { msgid1: [flag1, flag2, ... ], }
528        """
529        if not messages:
530            return {}
531
532        msg_list = messages_to_str(messages)
533        flag_list = seq_to_parenlist(flags)
534
535        if self.use_uid:
536            typ, data = self._imap.uid('STORE', msg_list, cmd, flag_list)
537        else:
538            typ, data = self._imap.store(msg_list, cmd, flag_list)
539        self._checkok('store', typ, data)
540
541        return self._flatten_dict(FetchParser()(data))
542
543
544    def _flatten_dict(self, fetch_dict):
545        return dict([
546            (msgid, data.values()[0])
547            for msgid, data in fetch_dict.iteritems()
548            ])
549
550    def _decode_folder_name(self, name):
551        if self.folder_encode:
552            return imap_utf7.decode(name)
553        return name
554
555
556    def _encode_folder_name(self, name):
557        if self.folder_encode:
558            return imap_utf7.encode(name)
559        return name
560
561
562class FetchParser(object):
563    """
564    Parse an IMAP FETCH response and convert the return values to useful Python
565    values.
566    """
567
568    def parse(self, response):
569        out = {}
570        for response_item in response:
571            msgid, data = self.parse_data(response_item)
572
573            if msgid != None:
574                # Response for a new message
575                current_msg_data = {}
576                out[msgid] = current_msg_data
577
578            current_msg_data.update(data)
579
580        return out
581
582    __call__ = parse
583
584    def parse_data(self, data):
585        out = {}
586
587        if isinstance(data, str):
588            if data == ')':
589                # End of response for current message
590                return None, {}
591
592        elif isinstance(data, tuple):
593            data, literal_data = data
594
595        else:
596            raise ValueError("don't know how to handle %r" % data)
597
598        data = data.lstrip()
599        if data[0].isdigit():
600            # Get message ID
601            msgid, data = data.split(None, 1)
602            msgid = long(msgid)
603
604            assert data.startswith('('), data
605            data = data[1:]
606            if data.endswith(')'):
607                data = data[:-1]
608
609        else:
610            msgid = None
611
612        for name, item in FetchTokeniser().process_pairs(data):
613            name = name.upper()
614
615            if name == 'UID':
616                # Using UID's, override the message ID
617                msgid = long(item)
618
619            else:
620                if isinstance(item, Literal):
621                    #assert len(data) == item.length
622                    arg = literal_data
623                else:
624                    arg = item
625
626                # Call handler function based on the response type
627                methname = 'do_'+name.upper().replace('.', '_')
628                meth = getattr(self, methname, self.do_default)
629                out[name] = meth(arg)
630
631        return msgid, out
632
633    def do_INTERNALDATE(self, arg):
634        """Process an INTERNALDATE response
635
636        @param arg: A quoted IMAP INTERNALDATE string
637            (eg. " 9-Feb-2007 17:08:08 +0000")
638        @return: datetime.datetime instance for the given time (in UTC)
639        """
640        arg = 'INTERNALDATE "%s"' % arg
641        mo = imaplib.InternalDate.match(arg)
642        if not mo:
643            raise ValueError("couldn't parse date %r" % arg)
644
645        zoneh = int(mo.group('zoneh'))
646        zonem = (zoneh * 60) + int(mo.group('zonem'))
647        if mo.group('zonen') == '-':
648            zonem = -zonem
649        tz = FixedOffset(zonem)
650
651        year = int(mo.group('year'))
652        mon = imaplib.Mon2num[mo.group('mon')]
653        day = int(mo.group('day'))
654        hour = int(mo.group('hour'))
655        min = int(mo.group('min'))
656        sec = int(mo.group('sec'))
657
658        dt = datetime.datetime(year, mon, day, hour, min, sec, 0, tz)
659
660        # Normalise to host system's timezone
661        return dt.astimezone(FixedOffset.for_system()).replace(tzinfo=None)
662
663
664    def do_default(self, arg):
665        return arg
666
667
668class FetchTokeniser(object):
669    """
670    General response tokenizer and converter
671    """
672
673    QUOTED_STRING = '(?:".*?")'
674    PAREN_LIST = '(?:\(.*?\))'
675
676    PAIR_RE = re.compile((
677        '([\w\.]+(?:\[[^\]]+\]+)?)\s+' +    # name (matches "FOO", "FOO.BAR" & "BODY[SECTION STUFF]")
678        '((?:\d+)' +                        # bare integer
679        '|(?:{\d+?})' +                     # IMAP literal
680        '|' + QUOTED_STRING +
681        '|' + PAREN_LIST +
682        ')\s*'))
683
684    DATA_RE = re.compile((
685        '(' + QUOTED_STRING +
686        '|' + PAREN_LIST +
687        '|(?:\S+)' +            # word
688        ')\s*'))
689
690    def process_pairs(self, s):
691        """Break up and convert a string of FETCH response pairs
692
693        @param s: FETCH response string eg. "FOO 12 BAH (1 abc def "foo bar")"
694        @return: Tokenised and converted input return as (name, data) pairs.
695        """
696        out = []
697        for m in strict_finditer(self.PAIR_RE, s):
698            name, data = m.groups()
699            out.append((name, self.nativefy(data)))
700        return out
701
702    def process_list(self, s):
703        """Break up and convert a string of data items
704
705        @param s: FETCH response string eg. "(1 abc def "foo bar")"
706        @return: A list of converted items.
707        """
708        if s == '':
709            return []
710        out = []
711        for m in strict_finditer(self.DATA_RE, s):
712            out.append(self.nativefy(m.group(1)))
713        return out
714
715    def nativefy(self, s):
716        if s.startswith('"'):
717            return s[1:-1]      # Debracket
718        elif s.startswith('{'):
719            return Literal(long(s[1:-1]))
720        elif s.startswith('('):
721            return self.process_list(s[1:-1])
722        elif s.isdigit():
723            return long(s)
724        elif s.upper() == 'NIL':
725            return None
726        else:
727            return s
728
729
730class Literal(object):
731    """
732    Simple class to represent a literal token in the fetch response
733    (eg. "{21}")
734    """
735
736    def __init__(self, length):
737        self.length = length
738
739    def __eq__(self, other):
740        return self.length == other.length
741
742    def __str__(self):
743        return '{%d}' % self.length
744
745
746def strict_finditer(regex, s):
747    """Like re.finditer except the regex must match from exactly where the
748    previous match ended and all the entire input must be matched.
749    """
750    i = 0
751    matched = False
752    while 1:
753        match = regex.match(s[i:])
754        if match:
755            matched = True
756            i += match.end()
757            yield match
758        else:
759            if (len(s) > 0 and not matched) or i < len(s):
760                raise ValueError("failed to match all of input. "
761                        "%r remains" % s[i:])
762            else:
763                return
764
765
766def messages_to_str(messages):
767    """Convert a sequence of messages ids or a single message id into an
768    message ID list for use with IMAP commands.
769
770    @param messages: A sequence of messages IDs or a single message ID.
771        (eg. [1,4,5,7,8])
772    @return: Message list string (eg. "1,4,5,6,8")
773    """
774    if isinstance(messages, (str, int, long)):
775        messages = (messages,)
776    elif not isinstance(messages, (tuple, list)):
777        raise ValueError('invalid message list: %r' % messages)
778    return ','.join([str(m) for m in messages])
779
780
781def seq_to_parenlist(flags):
782    """Convert a sequence into parenthised list for use with IMAP commands
783
784    @param flags: Sequence to process (eg. ['abc', 'def'])
785    @return: IMAP parenthenised list (eg. '(abc def)')
786    """
787    if isinstance(flags, str):
788        flags = (flags,)
789    elif not isinstance(flags, (tuple, list)):
790        raise ValueError('invalid flags list: %r' % flags)
791    return '(%s)' % ' '.join(flags)
792
793
794def datetime_to_imap(dt):
795    """Convert a datetime instance to a IMAP datetime string
796
797    If timezone information is missing the current system timezone is used.
798    """
799    if not dt.tzinfo:
800        dt = dt.replace(tzinfo=FixedOffset.for_system())
801
802    return dt.strftime("%d-%b-%Y %H:%M:%S %z")
803   
Note: See TracBrowser for help on using the browser.