| 1 | |
|---|
| 2 | |
|---|
| 3 | |
|---|
| 4 | |
|---|
| 5 | import re |
|---|
| 6 | import imaplib |
|---|
| 7 | import shlex |
|---|
| 8 | import datetime |
|---|
| 9 | |
|---|
| 10 | |
|---|
| 11 | import imap_utf7 |
|---|
| 12 | from fixed_offset import FixedOffset |
|---|
| 13 | |
|---|
| 14 | __all__ = ['IMAPClient', 'DELETED', 'SEEN', 'ANSWERED', 'FLAGGED', 'DRAFT', |
|---|
| 15 | 'RECENT'] |
|---|
| 16 | |
|---|
| 17 | |
|---|
| 18 | DELETED = r'\Deleted' |
|---|
| 19 | SEEN = r'\Seen' |
|---|
| 20 | ANSWERED = r'\Answered' |
|---|
| 21 | FLAGGED = r'\Flagged' |
|---|
| 22 | DRAFT = r'\Draft' |
|---|
| 23 | RECENT = r'\Recent' |
|---|
| 24 | |
|---|
| 25 | |
|---|
| 26 | class 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 | |
|---|
| 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 | |
|---|
| 122 | |
|---|
| 123 | |
|---|
| 124 | |
|---|
| 125 | |
|---|
| 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 | |
|---|
| 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 | |
|---|
| 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:] |
|---|
| 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 | |
|---|
| 562 | class 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 | |
|---|
| 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 | |
|---|
| 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 | |
|---|
| 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 | |
|---|
| 617 | msgid = long(item) |
|---|
| 618 | |
|---|
| 619 | else: |
|---|
| 620 | if isinstance(item, Literal): |
|---|
| 621 | |
|---|
| 622 | arg = literal_data |
|---|
| 623 | else: |
|---|
| 624 | arg = item |
|---|
| 625 | |
|---|
| 626 | |
|---|
| 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 | |
|---|
| 661 | return dt.astimezone(FixedOffset.for_system()).replace(tzinfo=None) |
|---|
| 662 | |
|---|
| 663 | |
|---|
| 664 | def do_default(self, arg): |
|---|
| 665 | return arg |
|---|
| 666 | |
|---|
| 667 | |
|---|
| 668 | class 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+' + |
|---|
| 678 | '((?:\d+)' + |
|---|
| 679 | '|(?:{\d+?})' + |
|---|
| 680 | '|' + QUOTED_STRING + |
|---|
| 681 | '|' + PAREN_LIST + |
|---|
| 682 | ')\s*')) |
|---|
| 683 | |
|---|
| 684 | DATA_RE = re.compile(( |
|---|
| 685 | '(' + QUOTED_STRING + |
|---|
| 686 | '|' + PAREN_LIST + |
|---|
| 687 | '|(?:\S+)' + |
|---|
| 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] |
|---|
| 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 | |
|---|
| 730 | class 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 | |
|---|
| 746 | def 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 | |
|---|
| 766 | def 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 | |
|---|
| 781 | def 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 | |
|---|
| 794 | def 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 | |
|---|