diff --git a/README.md b/README.md index b675921..ebec6b1 100644 --- a/README.md +++ b/README.md @@ -120,6 +120,24 @@ params = { message = gmail.send_message(**params) # equivalent to send_message(to="you@youremail.com", sender=...) ``` +### Create a draft: + +```python +from simplegmail import Gmail + +gmail = Gmail() # will open a browser window to ask you to log in and authenticate + +params = { + "to": "you@youremail.com", + "sender": "me@myemail.com", + "subject": "My first email", + "msg_html": "

Woah, my first email!


This is an HTML email.", + "msg_plain": "Hi\nThis is a plain text email.", + "signature": True # use my account signature +} +draft = gmail.create_draft(**params) # equivalent to create_draft(to="you@youremail.com", sender=...) +``` + It couldn't be easier! ### Retrieving messages: diff --git a/simplegmail/draft.py b/simplegmail/draft.py new file mode 100644 index 0000000..66a6fff --- /dev/null +++ b/simplegmail/draft.py @@ -0,0 +1,65 @@ +""" +File: draft.py +---------------- +This module contains the implementation of the Draft object. + +""" + +from typing import List, Optional, Union + +from httplib2 import Http +from googleapiclient.errors import HttpError + +from simplegmail import label +from simplegmail.attachment import Attachment +from simplegmail.label import Label +from simplegmail.message import Message + + +class Draft(object): + """ + The Draft class for drafts in your Gmail mailbox. This class should not + be manually constructed. Contains all information about the associated + draft. + + Args: + service: the Gmail service object. + user_id: the username of the account the draft belongs to. + id: the draft id. + message: the message. + + Attributes: + _service (googleapiclient.discovery.Resource): the Gmail service object. + user_id (str): the username of the account the message belongs to. + id (str): the draft id. + message (Message): the message. + + """ + + def __init__( + self, + service: 'googleapiclient.discovery.Resource', + creds: 'oauth2client.client.OAuth2Credentials', + user_id: str, + id: str, + message: Message + ) -> None: + self._service = service + self.creds = creds + self.user_id = user_id + self.id = id + self.message = message + + @property + def service(self) -> 'googleapiclient.discovery.Resource': + if self.creds.access_token_expired: + self.creds.refresh(Http()) + + return self._service + + def __repr__(self) -> str: + """Represents the object by its sender, recipient, and id.""" + + return ( + f'Draft(to: {self.recipient}, from: {self.sender}, id: {self.id})' + ) diff --git a/simplegmail/gmail.py b/simplegmail/gmail.py index 67aa2aa..d73d86e 100644 --- a/simplegmail/gmail.py +++ b/simplegmail/gmail.py @@ -31,8 +31,10 @@ from simplegmail import label from simplegmail.attachment import Attachment +from simplegmail.draft import Draft from simplegmail.label import Label from simplegmail.message import Message +from simplegmail.thread import Thread class Gmail(object): @@ -130,8 +132,11 @@ def send_message( msg_plain: Optional[str] = None, cc: Optional[List[str]] = None, bcc: Optional[List[str]] = None, + references: Optional[List[str]] = None, + in_reply_to: Optional[str] = None, attachments: Optional[List[str]] = None, signature: bool = False, + thread_id: Optional[str] = None, user_id: str = 'me' ) -> Message: """ @@ -147,9 +152,12 @@ def send_message( is not provided. cc: The list of email addresses to be cc'd. bcc: The list of email addresses to be bcc'd. + references: The list of Message-Ids to be referenced. + in_reply_to: The Message-Id to be replied to. attachments: The list of attachment file names. signature: Whether the account signature should be added to the message. + thread_id: The thread ID to add the reply to. user_id: The address of the sending account. 'me' for the default address associated with the account. @@ -164,7 +172,8 @@ def send_message( msg = self._create_message( sender, to, subject, msg_html, msg_plain, cc=cc, bcc=bcc, - attachments=attachments, signature=signature, user_id=user_id + references=references, in_reply_to=in_reply_to, + attachments=attachments, signature=signature, thread_id=thread_id, user_id=user_id ) try: @@ -176,6 +185,70 @@ def send_message( # Pass along the error raise error + def create_draft( + self, + sender: str, + to: str, + subject: str = '', + msg_html: Optional[str] = None, + msg_plain: Optional[str] = None, + cc: Optional[List[str]] = None, + bcc: Optional[List[str]] = None, + references: Optional[List[str]] = None, + in_reply_to: Optional[str] = None, + attachments: Optional[List[str]] = None, + signature: bool = False, + thread_id: Optional[str] = None, + user_id: str = 'me' + ) -> Message: + """ + Creates a draft. + + Args: + sender: The email address the draft is being sent from. + to: The email address the draft is being sent to. + subject: The subject line of the email. + msg_html: The HTML message of the email. + msg_plain: The plain text alternate message of the email. This is + often displayed on slow or old browsers, or if the HTML message + is not provided. + cc: The list of email addresses to be cc'd. + bcc: The list of email addresses to be bcc'd. + references: The list of Message-Ids to be referenced. + in_reply_to: The Message-Id to be replied to. + attachments: The list of attachment file names. + signature: Whether the account signature should be added to the + draft. + thread_id: The thread ID to add the reply to. + user_id: The address of the sending account. 'me' for the + default address associated with the account. + + Returns: + The Draft object representing the created draft. + + Raises: + googleapiclient.errors.HttpError: There was an error executing the + HTTP request. + + """ + + msg = { + 'message': self._create_message( + sender, to, subject, msg_html, msg_plain, cc=cc, bcc=bcc, + references=references, in_reply_to=in_reply_to, + attachments=attachments, signature=signature, thread_id=thread_id, user_id=user_id + ) + } + + try: + req = self.service.users().drafts().create(userId='me', body=msg) + res = req.execute() + return self._build_draft_from_ref(user_id, res, 'reference') + + except HttpError as error: + # Pass along the error + raise error + def get_unread_inbox( self, user_id: str = 'me', @@ -551,6 +624,76 @@ def get_messages( # Pass along the error raise error + def get_threads( + self, + user_id: str = 'me', + labels: Optional[List[Label]] = None, + query: str = '', + attachments: str = 'reference', + include_spam_trash: bool = False + ) -> List[Message]: + """ + Gets threads from your account. + + Args: + user_id: the user's email address. Default 'me', the authenticated + user. + labels: label IDs threads must match. + query: a Gmail query to match. + attachments: accepted values are 'ignore' which completely + ignores all attachments, 'reference' which includes attachment + information but does not download the data, and 'download' which + downloads the attachment data to store locally. Default + 'reference'. + include_spam_trash: whether to include threads from spam or trash. + + Returns: + A list of thread objects. + + Raises: + googleapiclient.errors.HttpError: There was an error executing the + HTTP request. + + """ + + if labels is None: + labels = [] + + labels_ids = [ + lbl.id if isinstance(lbl, Label) else lbl for lbl in labels + ] + + try: + response = self.service.users().threads().list( + userId=user_id, + q=query, + labelIds=labels_ids, + includeSpamTrash=include_spam_trash + ).execute() + + thread_refs = [] + if 'threads' in response: # ensure request was successful + thread_refs.extend(response['threads']) + + while 'nextPageToken' in response: + page_token = response['nextPageToken'] + response = self.service.users().threads().list( + userId=user_id, + q=query, + labelIds=labels_ids, + includeSpamTrash=include_spam_trash, + pageToken=page_token + ).execute() + + thread_refs.extend(response['threads']) + + return self._get_threads_from_refs(user_id, thread_refs, + attachments) + + except HttpError as error: + # Pass along the error + raise error + def list_labels(self, user_id: str = 'me') -> List[Label]: """ Retrieves all labels for the specified user. @@ -728,6 +871,81 @@ def thread_download_batch(thread_num): return sum(message_lists, []) + def _get_threads_from_refs( + self, + user_id: str, + thread_refs: List[dict], + attachments: str = 'reference', + parallel: bool = True + ) -> List[Message]: + """ + Retrieves the actual threads from a list of references. + + Args: + user_id: The account the threads belong to. + thread_refs: A list of thread references with keys id, threadId. + attachments: Accepted values are 'ignore' which completely ignores + all attachments, 'reference' which includes attachment + information but does not download the data, and 'download' + which downloads the attachment data to store locally. Default + 'reference'. + parallel: Whether to retrieve threads in parallel. Default true. + Currently parallelization is always on, since there is no + reason to do otherwise. + + + Returns: + A list of Thread objects. + + Raises: + googleapiclient.errors.HttpError: There was an error executing the + HTTP request. + + """ + + if not thread_refs: + return [] + + if not parallel: + return [self._build_thread_from_ref(user_id, ref, attachments) + for ref in thread_refs] + + max_num_threads = 12 # empirically chosen, prevents throttling + target_thrds_per_thread = 10 # empirically chosen + num_threads = min( + math.ceil(len(thread_refs) / target_thrds_per_thread), + max_num_threads + ) + batch_size = math.ceil(len(thread_refs) / num_threads) + thread_lists = [None] * num_threads + + def thread_download_batch(thread_num): + gmail = Gmail(_creds=self.creds) + + start = thread_num * batch_size + end = min(len(thread_refs), (thread_num + 1) * batch_size) + thread_lists[thread_num] = [ + gmail._build_thread_from_ref( + user_id, thread_refs[i], attachments + ) + for i in range(start, end) + ] + + gmail.service.close() + + threads = [ + threading.Thread(target=thread_download_batch, args=(i,)) + for i in range(num_threads) + ] + + for t in threads: + t.start() + + for t in threads: + t.join() + + return sum(thread_lists, []) + def _build_message_from_ref( self, user_id: str, @@ -849,6 +1067,115 @@ def _build_message_from_ref( bcc ) + def _build_thread_from_ref( + self, + user_id: str, + thread_ref: dict, + attachments: str = 'reference' + ) -> Message: + """ + Creates a Thread object from a reference. + + Args: + user_id: The username of the account the thread belongs to. + thread_ref: The thread reference object returned from the Gmail + API. + attachments: Accepted values are 'ignore' which completely ignores + all attachments, 'reference' which includes attachment + information but does not download the data, and 'download' which + downloads the attachment data to store locally. Default + 'reference'. + + Returns: + The Thread object. + + Raises: + googleapiclient.errors.HttpError: There was an error executing the + HTTP request. + + """ + + try: + # Get thread JSON + thread = self.service.users().threads().get( + userId=user_id, id=thread_ref['id'] + ).execute() + + except HttpError as error: + # Pass along the error + raise error + + else: + id = thread['id'] + # snippet = html.unescape(thread['snippet']) + snippet = '' + + message_refs = [] + if 'messages' in thread: # ensure request was successful + message_refs.extend(thread['messages']) + + messages = self._get_messages_from_refs(user_id, message_refs, + attachments) + + return Thread( + self.service, + self.creds, + user_id, + id, + snippet, + messages + ) + + def _build_draft_from_ref( + self, + user_id: str, + draft_ref: dict, + attachments: str = 'reference' + ) -> Draft: + """ + Creates a Draft object from a reference. + + Args: + user_id: The username of the account the draft belongs to. + draft_ref: The draft reference object returned from the Gmail + API. + attachments: Accepted values are 'ignore' which completely ignores + all attachments, 'reference' which includes attachment + information but does not download the data, and 'download' which + downloads the attachment data to store locally. Default + 'reference'. + + Returns: + The Draft object. + + Raises: + googleapiclient.errors.HttpError: There was an error executing the + HTTP request. + + """ + + try: + # Get draft JSON + draft = self.service.users().drafts().get( + userId=user_id, id=draft_ref['id'] + ).execute() + + except HttpError as error: + # Pass along the error + raise error + + else: + id = draft['id'] + message = self._build_message_from_ref(user_id, draft['message'], attachments) + + return Draft( + self.service, + self.creds, + user_id, + id, + message + ) + def _evaluate_message_payload( self, payload: dict, @@ -942,8 +1269,11 @@ def _create_message( msg_plain: str = None, cc: List[str] = None, bcc: List[str] = None, + references: List[str] = None, + in_reply_to: str = None, attachments: List[str] = None, signature: bool = False, + thread_id: str = None, user_id: str = 'me' ) -> dict: """ @@ -958,7 +1288,10 @@ def _create_message( or old browsers). cc: The list of email addresses to be Cc'd. bcc: The list of email addresses to be Bcc'd + references: The list of Message-Ids to be referenced + in_reply_to: The Message-Id to be replied to attachments: A list of attachment file paths. + thread_id: A thread ID to add the reply to. signature: Whether the account signature should be added to the message. Will add the signature to your HTML message only, or a create a HTML message if none exists. @@ -979,6 +1312,12 @@ def _create_message( if bcc: msg['Bcc'] = ', '.join(bcc) + if references: + msg['References'] = ' '.join(references) + + if in_reply_to: + msg['In-Reply-To'] = in_reply_to + if signature: m = re.match(r'.+\s<(?P.+@.+\..+)>', sender) address = m.group('addr') if m else sender @@ -1004,10 +1343,15 @@ def _create_message( self._ready_message_with_attachments(msg, attachments) - return { + response = { 'raw': base64.urlsafe_b64encode(msg.as_string().encode()).decode() } + if thread_id: + response['threadId'] = thread_id + + return response + def _ready_message_with_attachments( self, msg: MIMEMultipart, diff --git a/simplegmail/thread.py b/simplegmail/thread.py new file mode 100644 index 0000000..1772e80 --- /dev/null +++ b/simplegmail/thread.py @@ -0,0 +1,69 @@ +""" +File: thread.py +---------------- +This module contains the implementation of the Thread object. + +""" + +from typing import List, Optional, Union + +from httplib2 import Http +from googleapiclient.errors import HttpError + +from simplegmail import label +from simplegmail.attachment import Attachment +from simplegmail.label import Label +from simplegmail.message import Message + + +class Thread(object): + """ + The Thread class for threads in your Gmail mailbox. This class should not + be manually constructed. Contains all information about the associated + thread. + + Args: + service: the Gmail service object. + user_id: the username of the account the thread belongs to. + id: the thread id. + snippet: the snippet line for the thread. + messages: a list of message. + + Attributes: + _service (googleapiclient.discovery.Resource): the Gmail service object. + user_id (str): the username of the account the message belongs to. + id (str): the thread id. + snippet (str): the snippet line for the thread. + messages (List[Message]): a list of messages. + + """ + + def __init__( + self, + service: 'googleapiclient.discovery.Resource', + creds: 'oauth2client.client.OAuth2Credentials', + user_id: str, + id: str, + snippet: str, + messages: List[Message] + ) -> None: + self._service = service + self.creds = creds + self.user_id = user_id + self.id = id + self.snippet = snippet + self.messages = messages + + @property + def service(self) -> 'googleapiclient.discovery.Resource': + if self.creds.access_token_expired: + self.creds.refresh(Http()) + + return self._service + + def __repr__(self) -> str: + """Represents the object by its id.""" + + return ( + f'Thread(id: {self.id})' + )