diff --git a/__init__.py b/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/gmail/__init__.py b/gmail/__init__.py index f90b763..e3c290b 100644 --- a/gmail/__init__.py +++ b/gmail/__init__.py @@ -14,8 +14,7 @@ __copyright__ = 'Copyright 2013 Charlie Guo' from .gmail import Gmail -from .mailbox import Mailbox -from .message import Message +from .mailbox import Mailbox +from .message import Message from .exceptions import GmailException, ConnectionError, AuthenticationError from .utils import login, authenticate - diff --git a/gmail/exceptions.py b/gmail/exceptions.py index 3306d87..cc89155 100644 --- a/gmail/exceptions.py +++ b/gmail/exceptions.py @@ -13,11 +13,14 @@ class GmailException(RuntimeError): """There was an ambiguous exception that occurred while handling your request.""" + class ConnectionError(GmailException): """A Connection error occurred.""" + class AuthenticationError(GmailException): """Gmail Authentication failed.""" + class Timeout(GmailException): """The request timed out.""" diff --git a/gmail/gmail.py b/gmail/gmail.py index 7fb09e1..ea63cb8 100644 --- a/gmail/gmail.py +++ b/gmail/gmail.py @@ -5,6 +5,7 @@ from utf import encode as encode_utf7, decode as decode_utf7 from exceptions import * + class Gmail(): # GMail IMAP defaults GMAIL_IMAP_HOST = 'imap.gmail.com' @@ -26,10 +27,8 @@ def __init__(self): self.mailboxes = {} self.current_mailbox = None - # self.connect() - def connect(self, raise_errors=True): # try: # self.imap = imaplib.IMAP4_SSL(self.GMAIL_IMAP_HOST, self.GMAIL_IMAP_PORT) @@ -48,14 +47,15 @@ def connect(self, raise_errors=True): return self.imap - def fetch_mailboxes(self): response, mailbox_list = self.imap.list() if response == 'OK': for mailbox in mailbox_list: mailbox_name = mailbox.split('"/"')[-1].replace('"', '').strip() + mailbox_attrs = mailbox.split('"/"')[0] mailbox = Mailbox(self) mailbox.external_name = mailbox_name + mailbox.attrs = mailbox_attrs self.mailboxes[mailbox_name] = mailbox def use_mailbox(self, mailbox): @@ -88,8 +88,6 @@ def delete_mailbox(self, mailbox_name): self.imap.delete(mailbox_name) del self.mailboxes[mailbox_name] - - def login(self, username, password): self.username = username self.password = password @@ -105,7 +103,6 @@ def login(self, username, password): except imaplib.IMAP4.error: raise AuthenticationError - # smtp_login(username, password) return self.logged_in @@ -132,7 +129,6 @@ def logout(self): self.imap.logout() self.logged_in = False - def label(self, label_name): return self.mailbox(label_name) @@ -140,14 +136,13 @@ def find(self, mailbox_name="[Gmail]/All Mail", **kwargs): box = self.mailbox(mailbox_name) return box.mail(**kwargs) - def copy(self, uid, to_mailbox, from_mailbox=None): if from_mailbox: self.use_mailbox(from_mailbox) self.imap.uid('COPY', uid, to_mailbox) def fetch_multiple_messages(self, messages): - fetch_str = ','.join(messages.keys()) + fetch_str = ','.join(messages.keys()) response, results = self.imap.uid('FETCH', fetch_str, '(BODY.PEEK[] FLAGS X-GM-THRID X-GM-MSGID X-GM-LABELS)') for index in xrange(len(results) - 1): raw_message = results[index] @@ -157,7 +152,6 @@ def fetch_multiple_messages(self, messages): return messages - def labels(self, require_unicode=False): keys = self.mailboxes.keys() if require_unicode: diff --git a/gmail/mailbox.py b/gmail/mailbox.py index 0368034..c455d2e 100644 --- a/gmail/mailbox.py +++ b/gmail/mailbox.py @@ -1,6 +1,8 @@ -from message import Message +import re from utf import encode as encode_utf7, decode as decode_utf7 +from message import Message + class Mailbox(): @@ -9,6 +11,7 @@ def __init__(self, gmail, name="INBOX"): self.gmail = gmail self.date_format = "%d-%b-%Y" self.messages = {} + self.attrs = "" @property def external_name(self): @@ -25,21 +28,21 @@ def external_name(self, value): def mail(self, prefetch=False, **kwargs): search = ['ALL'] - kwargs.get('read') and search.append('SEEN') + kwargs.get('read') and search.append('SEEN') kwargs.get('unread') and search.append('UNSEEN') - kwargs.get('starred') and search.append('FLAGGED') + kwargs.get('starred') and search.append('FLAGGED') kwargs.get('unstarred') and search.append('UNFLAGGED') - kwargs.get('deleted') and search.append('DELETED') + kwargs.get('deleted') and search.append('DELETED') kwargs.get('undeleted') and search.append('UNDELETED') - kwargs.get('draft') and search.append('DRAFT') + kwargs.get('draft') and search.append('DRAFT') kwargs.get('undraft') and search.append('UNDRAFT') kwargs.get('before') and search.extend(['BEFORE', kwargs.get('before').strftime(self.date_format)]) - kwargs.get('after') and search.extend(['SINCE', kwargs.get('after').strftime(self.date_format)]) - kwargs.get('on') and search.extend(['ON', kwargs.get('on').strftime(self.date_format)]) + kwargs.get('after') and search.extend(['SINCE', kwargs.get('after').strftime(self.date_format)]) + kwargs.get('on') and search.extend(['ON', kwargs.get('on').strftime(self.date_format)]) kwargs.get('header') and search.extend(['HEADER', kwargs.get('header')[0], kwargs.get('header')[1]]) @@ -55,12 +58,13 @@ def mail(self, prefetch=False, **kwargs): kwargs.get('attachment') and search.extend(['HAS', 'attachment']) kwargs.get('query') and search.extend([kwargs.get('query')]) + kwargs.get('custom_query') and search.extend(kwargs.get('custom_query')) emails = [] # print search response, data = self.gmail.imap.uid('SEARCH', *search) - if response == 'OK': - uids = filter(None, data[0].split(' ')) # filter out empty strings + if response == 'OK': + uids = filter(None, data[0].split(' ')) # filter out empty strings for uid in uids: if not self.messages.get(uid): @@ -79,9 +83,8 @@ def mail(self, prefetch=False, **kwargs): def threads(self, prefetch=False, **kwargs): emails = [] response, data = self.gmail.imap.uid('SEARCH', 'ALL') - if response == 'OK': - uids = data[0].split(' ') - + if response == 'OK': + uids = data[0].split(' ') for uid in uids: if not self.messages.get(uid): diff --git a/gmail/message.py b/gmail/message.py index 9813ce4..d571ab5 100644 --- a/gmail/message.py +++ b/gmail/message.py @@ -3,12 +3,45 @@ import re import time import os -from email.header import decode_header, make_header + +from email.header import decode_header, HeaderParseError from imaplib import ParseFlags -class Message(): +def try_parse(header, encoding="ASCII"): + """ + Try to parse specified header using specified encoding. + On failure to do so, use ISO-8859-1, and then UTF-8. + Header and encoding are most often return from decode_header (but use try_decode instead of decode_header, see below). + """ + if encoding is None: + encoding = 'ASCII' + + try: + return unicode(header, encoding) + except (UnicodeDecodeError, LookupError): + try: + return unicode(header, 'ISO-8859-1') + except UnicodeDecodeError: + return unicode(header, 'UTF-8') + + +def try_decode(header): + """ + Try to decode specified header, + We need to wrap this in a try / except for the very rare case of FUCKED UP clients + using FUCKING non-standard base63 encoding. Wow. Such smart. Fucking faglords. + Please phpmailer.codeworxtech.com fix this shit or i'll fucking kill your family and set your house on fire. + See http://bugs.python.org/issue12489 + """ + try: + return decode_header(header) + except HeaderParseError: + return [[header, None]] + + +class Message(): def __init__(self, mailbox, uid): self.uid = uid self.mailbox = mailbox @@ -34,10 +67,8 @@ def __init__(self, mailbox, uid): self.thread_id = None self.thread = [] self.message_id = None - - self.attachments = None - + self.attachments = None def is_read(self): return ('\\Seen' in self.flags) @@ -45,12 +76,14 @@ def is_read(self): def read(self): flag = '\\Seen' self.gmail.imap.uid('STORE', self.uid, '+FLAGS', flag) - if flag not in self.flags: self.flags.append(flag) + if flag not in self.flags: + self.flags.append(flag) def unread(self): flag = '\\Seen' self.gmail.imap.uid('STORE', self.uid, '-FLAGS', flag) - if flag in self.flags: self.flags.remove(flag) + if flag in self.flags: + self.flags.remove(flag) def is_starred(self): return ('\\Flagged' in self.flags) @@ -58,12 +91,14 @@ def is_starred(self): def star(self): flag = '\\Flagged' self.gmail.imap.uid('STORE', self.uid, '+FLAGS', flag) - if flag not in self.flags: self.flags.append(flag) + if flag not in self.flags: + self.flags.append(flag) def unstar(self): flag = '\\Flagged' self.gmail.imap.uid('STORE', self.uid, '-FLAGS', flag) - if flag in self.flags: self.flags.remove(flag) + if flag in self.flags: + self.flags.remove(flag) def is_draft(self): return ('\\Draft' in self.flags) @@ -75,13 +110,14 @@ def has_label(self, label): def add_label(self, label): full_label = '%s' % label self.gmail.imap.uid('STORE', self.uid, '+X-GM-LABELS', full_label) - if full_label not in self.labels: self.labels.append(full_label) + if full_label not in self.labels: + self.labels.append(full_label) def remove_label(self, label): full_label = '%s' % label self.gmail.imap.uid('STORE', self.uid, '-X-GM-LABELS', full_label) - if full_label in self.labels: self.labels.remove(full_label) - + if full_label in self.labels: + self.labels.remove(full_label) def is_deleted(self): return ('\\Deleted' in self.flags) @@ -89,7 +125,8 @@ def is_deleted(self): def delete(self): flag = '\\Deleted' self.gmail.imap.uid('STORE', self.uid, '+FLAGS', flag) - if flag not in self.flags: self.flags.append(flag) + if flag not in self.flags: + self.flags.append(flag) trash = '[Gmail]/Trash' if '[Gmail]/Trash' in self.gmail.labels() else '[Gmail]/Bin' if self.mailbox.name not in ['[Gmail]/Bin', '[Gmail]/Trash']: @@ -100,14 +137,11 @@ def delete(self): # self.gmail.imap.uid('STORE', self.uid, '-FLAGS', flag) # if flag in self.flags: self.flags.remove(flag) - def move_to(self, name): self.gmail.copy(self.uid, name, self.mailbox.name) if name not in ['[Gmail]/Bin', '[Gmail]/Trash']: self.delete() - - def archive(self): self.move_to('[Gmail]/All Mail') @@ -128,34 +162,37 @@ def parse_labels(self, headers): else: return list() - def parse_subject(self, encoded_subject): - dh = decode_header(encoded_subject) - default_charset = 'ASCII' - return ''.join([ unicode(t[0], t[1] or default_charset) for t in dh ]) + def parse_header(self, encoded_header): + dh = try_decode(encoded_header) + return ''.join([try_parse(t[0], t[1]) for t in dh]) def parse(self, raw_message): raw_headers = raw_message[0] raw_email = raw_message[1] self.message = email.message_from_string(raw_email) - self.headers = self.parse_headers(self.message) - self.to = self.message['to'] - self.fr = self.message['from'] - self.delivered_to = self.message['delivered_to'] + self.headers = self.parse_headers(self.message) - self.subject = self.parse_subject(self.message['subject']) + self.to = self.parse_header(self.message['to']) + self.fr = self.parse_header(self.message['from']) + self.subject = self.parse_header(self.message['subject']) if self.message.get_content_maintype() == "multipart": for content in self.message.walk(): if content.get_content_type() == "text/plain": - self.body = content.get_payload(decode=True) + self.body = try_parse(content.get_payload(decode=True), content.get_content_charset()) elif content.get_content_type() == "text/html": - self.html = content.get_payload(decode=True) + self.html = try_parse(content.get_payload(decode=True), content.get_content_charset()) elif self.message.get_content_maintype() == "text": - self.body = self.message.get_payload() - - self.sent_at = datetime.datetime.fromtimestamp(time.mktime(email.utils.parsedate_tz(self.message['date'])[:9])) + if self.message.get_content_type() == "text/plain": + self.body = try_parse(self.message.get_payload(decode=True), self.message.get_content_charset()) + elif self.message.get_content_type() == "text/html": + self.html = try_parse(self.message.get_payload(decode=True), self.message.get_content_charset()) + try: + self.sent_at = datetime.datetime.fromtimestamp(time.mktime(email.utils.parsedate_tz(self.message['date'])[:9])) + except: + self.sent_at = datetime.datetime.now() self.flags = self.parse_flags(raw_headers) @@ -166,13 +203,11 @@ def parse(self, raw_message): if re.search(r'X-GM-MSGID (\d+)', raw_headers): self.message_id = re.search(r'X-GM-MSGID (\d+)', raw_headers).groups(1)[0] - # Parse attachments into attachment objects array for this message self.attachments = [ Attachment(attachment) for attachment in self.message._payload - if not isinstance(attachment, basestring) and attachment.get('Content-Disposition') is not None + if not isinstance(attachment, basestring) and attachment.get('Content-Disposition') is not None and attachment.get_filename() is not None ] - def fetch(self): if not self.message: @@ -193,7 +228,8 @@ def fetch_thread(self): received_messages = {} uids = results[0].split(' ') if response == 'OK': - for uid in uids: received_messages[uid] = Message(original_mailbox, uid) + for uid in uids: + received_messages[uid] = Message(original_mailbox, uid) self.gmail.fetch_multiple_messages(received_messages) self.mailbox.messages.update(received_messages) @@ -203,7 +239,8 @@ def fetch_thread(self): sent_messages = {} uids = results[0].split(' ') if response == 'OK': - for uid in uids: sent_messages[uid] = Message(self.gmail.mailboxes['[Gmail]/Sent Mail'], uid) + for uid in uids: + sent_messages[uid] = Message(self.gmail.mailboxes['[Gmail]/Sent Mail'], uid) self.gmail.fetch_multiple_messages(sent_messages) self.gmail.mailboxes['[Gmail]/Sent Mail'].messages.update(sent_messages) @@ -214,13 +251,22 @@ def fetch_thread(self): class Attachment: - def __init__(self, attachment): - self.name = attachment.get_filename() + try: + dh = try_decode(attachment.get_filename()) + self.name = ''.join([try_parse(t[0], t[1]) for t in dh]) + except UnicodeEncodeError: + self.name = attachment.get_filename() + # Raw file data - self.payload = attachment.get_payload(decode=True) - # Filesize in kilobytes - self.size = int(round(len(self.payload)/1000.0)) + if isinstance(attachment.get_payload(), basestring): + self.payload = attachment.get_payload(decode=True) + # Filesize in kilobytes + self.size = int(round(len(self.payload) / 1000.0)) + else: + # Special case. Seems to occurs only for EML attachments. + self.payload = None + self.size = None def save(self, path=None): if path is None: diff --git a/gmail/utf.py b/gmail/utf.py index c953e73..5a6864b 100644 --- a/gmail/utf.py +++ b/gmail/utf.py @@ -10,10 +10,10 @@ # distribute, sublicense, and/or sell copies of the Software, and to # permit persons to whom the Software is furnished to do so, subject to # the following conditions: -# +# # The above copyright notice and this permission notice shall be # included in all copies or substantial portions of the Software. -# +# # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, # EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF # MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND @@ -27,6 +27,7 @@ PRINTABLE = set(range(0x20, 0x26)) | set(range(0x27, 0x7f)) + def encode(s): """Encode a folder name using IMAP modified UTF-7 encoding. @@ -57,6 +58,7 @@ def extend_result_if_chars_buffered(): return ''.join(r) + def decode(s): """Decode a folder name from IMAP modified UTF-7 encoding to unicode. @@ -88,12 +90,14 @@ def decode(s): return ''.join(r) + def modified_utf7(s): # encode to utf-7: '\xff' => b'+AP8-', decode from latin-1 => '+AP8-' s_utf7 = s.encode('utf-7').decode('latin-1') return s_utf7[1:-1].replace('/', ',') + def modified_deutf7(s): s_utf7 = '+' + s.replace(',', '/') + '-' # encode to latin-1: '+AP8-' => b'+AP8-', decode from utf-7 => '\xff' - return s_utf7.encode('latin-1').decode('utf-7') \ No newline at end of file + return s_utf7.encode('latin-1').decode('utf-7') diff --git a/gmail/utils.py b/gmail/utils.py index 5b37e2b..b87bdbc 100644 --- a/gmail/utils.py +++ b/gmail/utils.py @@ -1,13 +1,13 @@ +from .gmail import Gmail -from .gmail import Gmail - def login(username, password): gmail = Gmail() gmail.login(username, password) return gmail + def authenticate(username, access_token): gmail = Gmail() gmail.authenticate(username, access_token) - return gmail \ No newline at end of file + return gmail