diff --git a/setup.py b/setup.py index 6880b79..1e44471 100644 --- a/setup.py +++ b/setup.py @@ -2,7 +2,7 @@ setuptools.setup( name="simplegmail", - version="2.1.0", + version="3.0.0", url="https://github.com/jeremyephron/simple-gmail", author="Jeremy Ephron", author_email="jeremyephron@gmail.com", diff --git a/simplegmail/__init__.py b/simplegmail/__init__.py index 2623d57..1191b65 100644 --- a/simplegmail/__init__.py +++ b/simplegmail/__init__.py @@ -1,5 +1,5 @@ from simplegmail.gmail import Gmail from simplegmail import query -from simplegmail import labels +from simplegmail import label -__all__ = ['Gmail', 'query', 'labels'] +__all__ = ['Gmail', 'query', 'label'] diff --git a/simplegmail/attachment.py b/simplegmail/attachment.py index ecc0630..079f7c2 100644 --- a/simplegmail/attachment.py +++ b/simplegmail/attachment.py @@ -1,10 +1,13 @@ """ +File: attachment.py +------------------- This module contains the implementation of the Attachment object. """ import base64 # for base64.urlsafe_b64decode import os # for os.path.exists +from typing import Optional class Attachment(object): """ @@ -12,27 +15,35 @@ class Attachment(object): class should not be manually instantiated. Args: - service (googleapiclient.discovery.Resource): the Gmail service object. - user_id (str): the username of the account the message belongs to. - msg_id (str): the id of message the attachment belongs to. - att_id (str): the id of the attachment. - filename (str): the filename associated with the attachment. - filetype (str): the mime type of the file. - data (bytes): the raw data of the file. Default None. + service: The Gmail service object. + user_id: The username of the account the message belongs to. + msg_id: The id of message the attachment belongs to. + att_id: The id of the attachment. + filename: The filename associated with the attachment. + filetype: The mime type of the file. + data: The raw data of the file. Default None. Attributes: - _service (googleapiclient.discovery.Resource): the Gmail service object. - user_id (str): the username of the account the message belongs to. - msg_id (str): the id of message the attachment belongs to. - id (str): the id of the attachment. - filename (str): the filename associated with the attachment. - filetype (str): the mime type of the file. - data (bytes): the raw data of the file. + _service (googleapiclient.discovery.Resource): The Gmail service object. + user_id (str): The username of the account the message belongs to. + msg_id (str): The id of message the attachment belongs to. + id (str): The id of the attachment. + filename (str): The filename associated with the attachment. + filetype (str): The mime type of the file. + data (bytes): The raw data of the file. """ - def __init__(self, service, user_id, msg_id, att_id, filename, filetype, - data=None): + def __init__( + self, + service: 'googleapiclient.discovery.Resource', + user_id: str, + msg_id: str, + att_id: str, + filename: str, + filetype: str, + data: Optional[bytes] = None + ) -> None: self._service = service self.user_id = user_id self.msg_id = msg_id @@ -41,10 +52,14 @@ def __init__(self, service, user_id, msg_id, att_id, filename, filetype, self.filetype = filetype self.data = data - def download(self): + def download(self) -> None: """ Downloads the data for an attachment if it does not exist. + Raises: + googleapiclient.errors.HttpError: There was an error executing the + HTTP request. + """ if self.data is not None: @@ -57,18 +72,23 @@ def download(self): data = res['data'] self.data = base64.urlsafe_b64decode(data) - def save(self, filepath=None, overwrite=False): + def save( + self, + filepath: Optional[str] = None, + overwrite: bool = False + ) -> None: """ Saves the attachment. Downloads file data if not downloaded. Args: - filepath (str): where to save the attachment. Default None, which - uses the filename stored. - overwrite (bool): whether to overwrite existing files. Default False. + filepath: where to save the attachment. Default None, which uses + the filename stored. + overwrite: whether to overwrite existing files. Default False. Raises: FileExistsError: if the call would overwrite an existing file and overwrite is not set to True. + """ if filepath is None: diff --git a/simplegmail/gmail.py b/simplegmail/gmail.py index e84dd6b..4f1b980 100644 --- a/simplegmail/gmail.py +++ b/simplegmail/gmail.py @@ -1,4 +1,6 @@ """ +File: gmail.py +-------------- Home to the main Gmail service object. Currently supports sending mail (with attachments) and retrieving mail with the full suite of Gmail search options. @@ -15,6 +17,7 @@ import html # for html.unescape import mimetypes # for mimetypes.guesstype import os # for os.path.basename +from typing import List, Optional, Union from bs4 import BeautifulSoup # for parsing email HTML import dateutil.parser as parser # for parsing email date @@ -24,8 +27,8 @@ from oauth2client import client, file, tools from oauth2client.clientsecrets import InvalidClientSecretsError -from simplegmail import labels -from simplegmail.labels import Label +from simplegmail import label +from simplegmail.label import Label from simplegmail.message import Message from simplegmail.attachment import Attachment @@ -35,16 +38,16 @@ class Gmail(object): The Gmail class which serves as the entrypoint for the Gmail service API. Args: - client_secret_file (str): Optional. The name of the user's client - secret file. Default 'client_secret.json'. + client_secret_file: The name of the user's client secret file. Attributes: + client_secret_file (str): The name of the user's client secret file. service (googleapiclient.discovery.Resource): The Gmail service object. """ # Allow Gmail to read and write emails, and access settings like aliases. - SCOPES = [ + _SCOPES = [ 'https://www.googleapis.com/auth/gmail.modify', 'https://www.googleapis.com/auth/gmail.settings.basic' ] @@ -52,27 +55,30 @@ class Gmail(object): # If you don't have a client secret file, follow the instructions at: # https://developers.google.com/gmail/api/quickstart/python # Make sure the client secret file is in the root directory of your app. - CREDENTIALS_FILE = 'gmail-token.json' + _CREDENTIALS_FILE = 'gmail_token.json' - def __init__(self, client_secret_file='client_secret.json'): + def __init__(self, client_secret_file: str = 'client_secret.json') -> None: self.client_secret_file = client_secret_file try: # The file gmail-token.json stores the user's access and refresh # tokens, and is created automatically when the authorization flow # completes for the first time. - store = file.Storage(self.CREDENTIALS_FILE) + store = file.Storage(self._CREDENTIALS_FILE) creds = store.get() if not creds or creds.invalid: # Will ask you to authenticate an account in your browser. - flow = client.flow_from_clientsecrets(self.client_secret_file, - self.SCOPES) + flow = client.flow_from_clientsecrets( + self.client_secret_file, self._SCOPES + ) creds = tools.run_flow(flow, store) - self.service = build('gmail', 'v1', http=creds.authorize(Http()), - cache_discovery=False) + self.service = build( + 'gmail', 'v1', http=creds.authorize(Http()), + cache_discovery=False + ) except InvalidClientSecretsError: raise FileNotFoundError( @@ -83,39 +89,51 @@ def __init__(self, client_secret_file='client_secret.json'): "follow the instructions listed there." ) - def send_message(self, sender, to, subject='', msg_html=None, - msg_plain=None, cc=None, bcc=None, attachments=None, - signature=False, user_id='me'): + def send_message( + 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, + attachments: Optional[List[str]] = None, + signature: bool = False, + user_id: str = 'me' + ) -> Message: """ Sends an email. Args: - sender (str): The email address the message is being sent from. - to (str): The email address the message is being sent to. - subject (str): The subject line of the email. Default ''. - msg_html (str): The HTML message of the email. Default None. - msg_plain (str): The plain text alternate message of the email (for - slow or old browsers). Default None. - cc (List[str]): The list of email addresses to be Cc'd. Default - None. - bcc (List[str]): The list of email addresses to be Bcc'd. - Default None. - attachments (List[str]): The list of attachment file names. Default - None. - signature (bool): Whether the account signature should be added to - the message. Default False. - user_id (str): Optional. The address of the sending account. - Default 'me'. + sender: The email address the message is being sent from. + to: The email address the message 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. + attachments: The list of attachment file names. + signature: Whether the account signature should be added to the + message. + user_id: The address of the sending account. 'me' for the + default address associated with the account. Returns: - (dict) The dict response of the message if successful. - (str) "Error" if unsuccessful. + The Message object representing the sent message. + + Raises: + googleapiclient.errors.HttpError: There was an error executing the + HTTP request. """ - msg = self._create_message(sender, to, subject, msg_html, msg_plain, - cc=cc, bcc=bcc, attachments=attachments, - signature=signature) + msg = self._create_message( + sender, to, subject, msg_html, msg_plain, cc=cc, bcc=bcc, + attachments=attachments, signature=signature + ) try: req = self.service.users().messages().send(userId='me', body=msg) @@ -123,275 +141,358 @@ def send_message(self, sender, to, subject='', msg_html=None, return self._build_message_from_ref(user_id, res, 'reference') except HttpError as error: - print(f"An error has occurred: {error}") - return "Error" + # Pass along the error + raise error - def get_unread_inbox(self, user_id='me', label_ids=None, query='', - attachments='reference'): + def get_unread_inbox( + self, + user_id: str = 'me', + labels: Optional[List[Label]] = None, + query: str = '', + attachments: Union['ignore', 'reference', 'download'] = 'reference' + ) -> List[Message]: """ Gets unread messages from your inbox. Args: - user_id (str): The user's email address [by default, the - authenticated user]. - label_ids (List[str]): Label IDs messages must match. - query (str): A Gmail query to match. - attachments (str): accepted values are 'ignore' which completely + user_id: The user's email address. By default, the authenticated + user. + labels: Labels that messages 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'. Returns: - List[Message]: a list of message objects. + A list of message objects. + + Raises: + googleapiclient.errors.HttpError: There was an error executing the + HTTP request. """ - if label_ids is None: - label_ids = [] + if labels is None: + labels = [] - label_ids.append(labels.INBOX) - return self.get_unread_messages(user_id, label_ids, query) + labels.append(label.INBOX) + return self.get_unread_messages(user_id, labels, query) - def get_starred_messages(self, user_id='me', label_ids=None, query='', - attachments='reference', include_spam_trash=False): + def get_starred_messages( + self, + user_id: str = 'me', + labels: Optional[List[Label]] = None, + query: str = '', + attachments: Union['ignore', 'reference', 'download'] = 'reference', + include_spam_trash: bool = False + ) -> List[Message]: """ Gets starred messages from your account. Args: - user_id (str): The user's email address [by default, the - authenticated user]. - label_ids (List[str]): Label IDs messages must match. - query (str): A Gmail query to match. - attachments (str): accepted values are 'ignore' which completely + user_id: The user's email address. By default, the authenticated + user. + labels: Label IDs messages 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 (bool): Whether to include messages from spam - or trash. + include_spam_trash: Whether to include messages from spam or trash. Returns: - List[Message]: a list of message objects. + A list of message objects. + + Raises: + googleapiclient.errors.HttpError: There was an error executing the + HTTP request. """ - if label_ids is None: - label_ids = [] + if labels is None: + labels = [] - label_ids.append(labels.STARRED) - return self.get_messages(user_id, label_ids, query, attachments, + labels.append(label.STARRED) + return self.get_messages(user_id, labels, query, attachments, include_spam_trash) - def get_important_messages(self, user_id='me', label_ids=None, query='', - attachments='reference', - include_spam_trash=False): + def get_important_messages( + self, + user_id: str = 'me', + labels: Optional[List[Label]] = None, + query: str = '', + attachments: Union['ignore', 'reference', 'download'] = 'reference', + include_spam_trash: bool = False + ) -> List[Message]: """ Gets messages marked important from your account. Args: - user_id (str): The user's email address [by default, the - authenticated user]. - label_ids (List[str]): Label IDs messages must match. - query (str): A Gmail query to match. - attachments (str): accepted values are 'ignore' which completely + user_id: The user's email address. By default, the authenticated + user. + labels: Label IDs messages 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 (bool): Whether to include messages from spam - or trash. + include_spam_trash: Whether to include messages from spam or trash. Returns: - List[Message]: a list of message objects. + A list of message objects. + + Raises: + googleapiclient.errors.HttpError: There was an error executing the + HTTP request. """ - if label_ids is None: - label_ids = [] + if labels is None: + labels = [] - label_ids.append(labels.IMPORTANT) - return self.get_messages(user_id, label_ids, query, attachments, + labels.append(label.IMPORTANT) + return self.get_messages(user_id, labels, query, attachments, include_spam_trash) - def get_unread_messages(self, user_id='me', label_ids=None, query='', - attachments='reference', include_spam_trash=False): + def get_unread_messages( + self, + user_id: str = 'me', + labels: Optional[List[Label]] = None, + query: str = '', + attachments: Union['ignore', 'reference', 'download'] = 'reference', + include_spam_trash: bool = False + ) -> List[Message]: """ Gets unread messages from your account. Args: - user_id (str): The user's email address [by default, the - authenticated user]. - label_ids (List[str]): Label IDs messages must match. - query (str): A Gmail query to match. - attachments (str): accepted values are 'ignore' which completely + user_id: The user's email address. By default, the authenticated + user. + labels: Label IDs messages 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 (bool): Whether to include messages from spam - or trash. + include_spam_trash: Whether to include messages from spam or trash. Returns: - List[Message]: a list of message objects. + A list of message objects. + + Raises: + googleapiclient.errors.HttpError: There was an error executing the + HTTP request. """ - if label_ids is None: - label_ids = [] + if labels is None: + labels = [] - label_ids.append(labels.UNREAD) - return self.get_messages(user_id, label_ids, query, attachments, + labels.append(label.UNREAD) + return self.get_messages(user_id, labels, query, attachments, include_spam_trash) - def get_drafts(self, user_id='me', label_ids=None, query='', - attachments='reference', include_spam_trash=False): + def get_drafts( + self, + user_id: str = 'me', + labels: Optional[List[Label]] = None, + query: str = '', + attachments: Union['ignore', 'reference', 'download'] = 'reference', + include_spam_trash: bool = False + ) -> List[Message]: """ Gets drafts saved in your account. Args: - user_id (str): The user's email address [by default, the - authenticated user]. - label_ids (List[str]): Label IDs messages must match. - query (str): A Gmail query to match. - attachments (str): accepted values are 'ignore' which completely + user_id: The user's email address. By default, the authenticated + user. + labels: Label IDs messages 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 (bool): Whether to include messages from spam - or trash. + include_spam_trash: Whether to include messages from spam or trash. Returns: - List[Message]: a list of message objects. + A list of message objects. + + Raises: + googleapiclient.errors.HttpError: There was an error executing the + HTTP request. """ - if label_ids is None: - label_ids = [] + if labels is None: + labels = [] - label_ids.append(labels.DRAFTS) - return self.get_messages(user_id, label_ids, query, attachments, + labels.append(label.DRAFT) + return self.get_messages(user_id, labels, query, attachments, include_spam_trash) - def get_sent_messages(self, user_id='me', label_ids=None, query='', - attachments='reference', include_spam_trash=False): + def get_sent_messages( + self, + user_id: str = 'me', + labels: Optional[List[Label]] = None, + query: str = '', + attachments: Union['ignore', 'reference', 'download'] = 'reference', + include_spam_trash: bool = False + ) -> List[Message]: """ Gets sent messages from your account. - Args: - user_id (str): The user's email address [by default, the - authenticated user]. - label_ids (List[str]): Label IDs messages must match. - query (str): A Gmail query to match. - attachments (str): accepted values are 'ignore' which completely + Args: + user_id: The user's email address. By default, the authenticated + user. + labels: Label IDs messages 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 (bool): Whether to include messages from spam - or trash. + include_spam_trash: Whether to include messages from spam or trash. Returns: - List[Message]: a list of message objects. + A list of message objects. + + Raises: + googleapiclient.errors.HttpError: There was an error executing the + HTTP request. """ - if label_ids is None: - label_ids = [] + if labels is None: + labels = [] - label_ids.append(labels.SENT) - return self.get_messages(user_id, label_ids, query, attachments, + labels.append(label.SENT) + return self.get_messages(user_id, labels, query, attachments, include_spam_trash) - def get_trash_messages(self, user_id='me', label_ids=None, query='', - attachments='reference'): + def get_trash_messages( + self, + user_id: str = 'me', + labels: Optional[List[Label]] = None, + query: str = '', + attachments: Union['ignore', 'reference', 'download'] = 'reference' + ) -> List[Message]: """ Gets messages in your trash from your account. Args: - user_id (str): The user's email address [by default, the - authenticated user]. - label_ids (List[str]): Label IDs messages must match. - query (str): A Gmail query to match. - attachments (str): accepted values are 'ignore' which completely + user_id: The user's email address. By default, the authenticated + user. + labels: Label IDs messages 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'. Returns: - List[Message]: a list of message objects. + A list of message objects. + + Raises: + googleapiclient.errors.HttpError: There was an error executing the + HTTP request. """ - if label_ids is None: - label_ids = [] + if labels is None: + labels = [] - label_ids.append(labels.TRASH) - return self.get_messages(user_id, label_ids, query, attachments, True) + labels.append(label.TRASH) + return self.get_messages(user_id, labels, query, attachments, True) - def get_spam_messages(self, user_id='me', label_ids=None, query='', - attachments='reference'): + def get_spam_messages( + self, + user_id: str = 'me', + labels: Optional[List[Label]] = None, + query: str = '', + attachments: Union['ignore', 'reference', 'download'] = 'reference' + ) -> List[Message]: """ Gets messages marked as spam from your account. Args: - user_id (str): The user's email address [by default, the - authenticated user]. - label_ids (List[str]): Label IDs messages must match. - query (str): A Gmail query to match. - attachments (str): accepted values are 'ignore' which completely + user_id: The user's email address. By default, the authenticated + user. + labels: Label IDs messages 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'. Returns: - List[Message]: a list of message objects. + A list of message objects. + + Raises: + googleapiclient.errors.HttpError: There was an error executing the + HTTP request. """ - if label_ids is None: - label_ids = [] + + if labels is None: + labels = [] - label_ids.append(labels.SPAM) - return self.get_messages(user_id, label_ids, query, attachments, True) + labels.append(label.SPAM) + return self.get_messages(user_id, labels, query, attachments, True) - def get_messages(self, user_id='me', label_ids=None, query='', - attachments='reference', include_spam_trash=False): + def get_messages( + self, + user_id: str = 'me', + labels: Optional[List[Label]] = None, + query: str = '', + attachments: Union['ignore', 'reference', 'download'] = 'reference', + include_spam_trash: bool = False + ) -> List[Message]: """ Gets messages from your account. Args: - user_id (str): the user's email address. Default 'me', the - authenticated user. - label_ids (List[str]): label IDs messages must match. - query (str): a Gmail query to match. - attachments (str): accepted values are 'ignore' which completely + user_id: the user's email address. Default 'me', the authenticated + user. + labels: label IDs messages 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 (bool): whether to include messages from spam - or trash. + include_spam_trash: whether to include messages from spam or trash. Returns: - List[Message]: a list of message objects. - + A list of message objects. + + Raises: + googleapiclient.errors.HttpError: There was an error executing the + HTTP request. + """ - if label_ids is None: - label_ids = [] + if labels is None: + labels = [] - label_ids = [lbl.id for lbl in label_ids] + labels_ids = [ + lbl.id if isinstance(lbl, Label) else lbl for lbl in labels + ] try: response = self.service.users().messages().list( userId=user_id, q=query, - labelIds=label_ids + labelIds=labels_ids ).execute() message_refs = [] @@ -403,7 +504,7 @@ def get_messages(self, user_id='me', label_ids=None, query='', response = self.service.users().messages().list( userId=user_id, q=query, - labelIds=label_ids, + labelIds=labels_ids, pageToken=page_token ).execute() @@ -413,9 +514,10 @@ def get_messages(self, user_id='me', label_ids=None, query='', attachments) except HttpError as error: - print(f"An error occurred: {error}") + # Pass along the error + raise error - def list_labels(self, user_id='me'): + def list_labels(self, user_id: str = 'me') -> List[Label]: """ Retrieves all labels for the specified user. @@ -423,11 +525,15 @@ def list_labels(self, user_id='me'): modify_labels(). Args: - user_id (str): the account the messages belong to. Default 'me' - refers to the main account. + user_id: The user's email address. By default, the authenticated + user. Returns: - List[Label]: the list of Label objects. + The list of Label objects. + + Raises: + googleapiclient.errors.HttpError: There was an error executing the + HTTP request. """ @@ -437,52 +543,67 @@ def list_labels(self, user_id='me'): ).execute() except HttpError as error: - print(f'An error occurred: {error}') + # Pass along the error + raise error else: labels = [Label(name=x['name'], id=x['id']) for x in res['labels']] return labels - def _get_messages_from_refs(self, user_id, message_refs, - attachments='reference'): + def _get_messages_from_refs( + self, + user_id: str, + message_refs: List[dict], + attachments: Union['ignore', 'reference', 'download'] = 'reference' + ) -> List[Message]: """ Retrieves the actual messages from a list of references. Args: - user_id (str): The account the messages belong to. - message_refs (List[dict]): A list of message references of the form - {id, threadId}. - attachments (str): Accepted values are 'ignore' which completely - ignores all attachments, 'reference' which includes attachment + user_id: The account the messages belong to. + message_refs: A list of message 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'. Returns: - List[Message]: a list of Message objects. + A list of Message objects. + + Raises: + googleapiclient.errors.HttpError: There was an error executing the + HTTP request. """ return [self._build_message_from_ref(user_id, ref, attachments) for ref in message_refs] - def _build_message_from_ref(self, user_id, message_ref, - attachments='reference'): + def _build_message_from_ref( + self, + user_id: str, + message_ref: dict, + attachments: Union['ignore', 'reference', 'download'] = 'reference' + ) -> Message: """ Creates a Message object from a reference. Args: - user_id (str): the username of the account the message belongs to. - message_ref (dict): the message reference object return from the - Gmail API. - attachments (str): Accepted values are 'ignore' which completely - ignores all attachments, 'reference' which includes attachment + user_id: The username of the account the message belongs to. + message_ref: The message reference object return 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: - Message: the Message object. + The Message object. + + Raises: + googleapiclient.errors.HttpError: There was an error executing the + HTTP request. """ try: @@ -492,7 +613,8 @@ def _build_message_from_ref(self, user_id, message_ref, ).execute() except HttpError as error: - print(f'An error occurred while retreiving a message: {error}') + # Pass along the error + raise error else: msg_id = message['id'] @@ -550,24 +672,32 @@ def _build_message_from_ref(self, user_id, message_ref, sender, subject, date, snippet, plain_msg, html_msg, label_ids, attms) - def _evaluate_message_payload(self, payload, user_id, msg_id, - attachments='reference'): + def _evaluate_message_payload( + self, + payload: dict, + user_id: str, + msg_id: str, + attachments: Union['ignore', 'reference', 'download'] = 'reference' + ) ->List[dict]: """ Recursively evaluates a message payload. Args: - payload (dict): the message payload object (response from Gmail - API). - user_id (str): the current account address (default 'me'). - msg_id (str): the id of the message. - attachments (str): Accepted values are 'ignore' which completely - ignores all attachments, 'reference' which includes attachment + payload: The message payload object (response from Gmail API). + user_id: The current account address (default 'me'). + msg_id: The id of the message. + 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: - List[dict]: a list of message parts. + A list of message parts. + + Raises: + googleapiclient.errors.HttpError: There was an error executing the + HTTP request. """ @@ -625,25 +755,34 @@ def _evaluate_message_payload(self, payload, user_id, msg_id, return [] - def _create_message(self, sender, to, subject='', msg_html=None, - msg_plain=None, cc=None, bcc=None, attachments=None, - signature=False): + def _create_message( + self, + sender: str, + to: str, + subject: str = '', + msg_html: str = None, + msg_plain: str = None, + cc: List[str] = None, + bcc: List[str] = None, + attachments: List[str] = None, + signature: bool = False + ) -> dict: """ Creates the raw email message to be sent. Args: - sender (str): The email address the message is being sent from. - to (str): The email address the message is being sent to. - subject (str): The subject line of the email. - msg_html (str): The HTML message of the email. - msg_plain (str): The plain text alternate message of the email (for - slow or old browsers). - cc (List[str]): The list of email addresses to be Cc'd. - bcc (List[str]): The list of email addresses to be Bcc'd - signature (bool): 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. + sender: The email address the message is being sent from. + to: The email address the message 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 (for slow + or old browsers). + cc: The list of email addresses to be Cc'd. + bcc: The list of email addresses to be Bcc'd + attachments: A list of attachment file paths. + 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. Returns: The message dict. @@ -687,13 +826,17 @@ def _create_message(self, sender, to, subject='', msg_html=None, 'raw': base64.urlsafe_b64encode(msg.as_string().encode()).decode() } - def _ready_message_with_attachments(self, msg, attachments): + def _ready_message_with_attachments( + self, + msg: MIMEMultipart, + attachments: List[str] + ) -> None: """ Converts attachment filepaths to MIME objects and adds them to msg. Args: - msg (MIMEMultipart): The message to add attachments to. - attachments (List[str]): A list of attachment file paths. + msg: The message to add attachments to. + attachments: A list of attachment file paths. """ @@ -722,7 +865,11 @@ def _ready_message_with_attachments(self, msg, attachments): attm.add_header('Content-Disposition', 'attachment', filename=fname) msg.attach(attm) - def _get_alias_info(self, send_as_email, user_id="me"): + def _get_alias_info( + self, + send_as_email: str, + user_id: str = 'me' + ) -> dict: """ Returns the alias info of an email address on the authenticated account. @@ -747,10 +894,13 @@ def _get_alias_info(self, send_as_email, user_id="me"): } Args: - send_as_email (str): The alias account information is requested for - (could be the primary account). - user_id (str): The user ID of the authenticated user the - account the alias is for (default "me"). + send_as_email: The alias account information is requested for + (could be the primary account). + user_id: The user ID of the authenticated user the account the + alias is for (default "me"). + + Returns: + The dict of alias info associated with the account. """ diff --git a/simplegmail/label.py b/simplegmail/label.py new file mode 100644 index 0000000..16b9687 --- /dev/null +++ b/simplegmail/label.py @@ -0,0 +1,61 @@ +""" +File: label.py +-------------- +Gmail reserved system labels and the Label class. + +""" + + +class Label: + """ + A Gmail label object. + + This class should not typically be constructed directly but rather returned + from Gmail.list_labels(). + + Args: + name: The name of the Label. + id: The ID of the label. + + Attributes: + name (str): The name of the Label. + id (str): The ID of the label. + + """ + + def __init__(self, name: str, id: str) -> None: + self.name = name + self.id = id + + def __repr__(self) -> str: + return f'Label(name={self.name!r}, id={self.id!r})' + + def __str__(self) -> str: + return self.name + + def __hash__(self) -> int: + return hash(self.id) + + def __eq__(self, other) -> bool: + if isinstance(other, str): + # Can be compared to a string of the label ID + return self.id == other + elif isinstance(other, Label): + return self.id == other.id + else: + return False + + +INBOX = Label('INBOX', 'INBOX') +SPAM = Label('SPAM', 'SPAM') +TRASH = Label('TRASH', 'TRASH') +UNREAD = Label('UNREAD', 'UNREAD') +STARRED = Label('STARRED', 'STARRED') +SENT = Label('SENT', 'SENT') +IMPORTANT = Label('IMPORTANT', 'IMPORTANT') +DRAFT = Label('DRAFT', 'DRAFT') +PERSONAL = Label('CATEGORY_PERSONAL', 'CATEGORY_PERSONAL') +SOCIAL = Label('CATEGORY_SOCIAL', 'CATEGORY_SOCIAL') +PROMOTIONS = Label('CATEGORY_PROMOTIONS', 'CATEGORY_PROMOTIONS') +UPDATES = Label('CATEGORY_UPDATES', 'CATEGORY_UPDATES') +FORUMS = Label('CATEGORY_FORUMS', 'CATEGORY_FORUMS') diff --git a/simplegmail/labels.py b/simplegmail/labels.py deleted file mode 100644 index fdbfe9f..0000000 --- a/simplegmail/labels.py +++ /dev/null @@ -1,25 +0,0 @@ -"""Gmail reserved system labels.""" - -from dataclasses import dataclass - - -@dataclass -class Label: - name: str - id: str - - -INBOX = Label('INBOX', 'INBOX') -SPAM = Label('SPAM', 'SPAM') -TRASH = Label('TRASH', 'TRASH') -UNREAD = Label('UNREAD', 'UNREAD') -STARRED = Label('STARRED', 'STARRED') -SENT = Label('SENT', 'SENT') -IMPORTANT = Label('IMPORTANT', 'IMPORTANT') -DRAFT = Label('DRAFT', 'DRAFT') -PERSONAL = Label('CATEGORY_PERSONAL', 'CATEGORY_PERSONAL') -SOCIAL = Label('CATEGORY_SOCIAL', 'CATEGORY_SOCIAL') -PROMOTIONS = Label('CATEGORY_PROMOTIONS', 'CATEGORY_PROMOTIONS') -UPDATES = Label('CATEGORY_UPDATES', 'CATEGORY_UPDATES') -FORUMS = Label('CATEGORY_FORUMS', 'CATEGORY_FORUMS') - diff --git a/simplegmail/message.py b/simplegmail/message.py index 2d33363..c90fa03 100644 --- a/simplegmail/message.py +++ b/simplegmail/message.py @@ -1,34 +1,40 @@ -"""This module contains the implementation of the Message object.""" +""" +File: message.py +---------------- +This module contains the implementation of the Message object. + +""" + +from typing import List, Optional, Union from googleapiclient.errors import HttpError -from simplegmail import labels -from simplegmail.labels import Label +from simplegmail import label +from simplegmail.attachment import Attachment +from simplegmail.label import Label class Message(object): """ The Message class for emails in your Gmail mailbox. This class should not be - manually instantiated. Contains all information about the associated + manually constructed. Contains all information about the associated message, and can be used to modify the message's labels (e.g., marking as read/unread, archiving, moving to trash, starring, etc.). Args: - service (googleapiclient.discovery.Resource): the Gmail service object. - user_id (str): the username of the account the message belongs to. - msg_id (str): the message id. - thread_id (str): the thread id. - recipient (str): who the message was addressed to. - sender (str): who the message was sent from. - subject (str): the subject line of the message. - date (str): the date the message was sent. - snippet (str): the snippet line for the message. - plain (str): the plaintext contents of the message. Default None. - html (str): the HTML contents of the message. Default None. - label_ids (List[str]): the ids of labels associated with this message. - Default []. - attachments (List[Attachment]): a list of attachments for the message. - Default []. + service: the Gmail service object. + user_id: the username of the account the message belongs to. + msg_id: the message id. + thread_id: the thread id. + recipient: who the message was addressed to. + sender: who the message was sent from. + subject: the subject line of the message. + date: the date the message was sent. + snippet: the snippet line for the message. + plain: the plaintext contents of the message. Default None. + html: the HTML contents of the message. Default None. + label_ids: the ids of labels associated with this message. Default []. + attachments: a list of attachments for the message. Default []. Attributes: _service (googleapiclient.discovery.Resource): the Gmail service object. @@ -46,9 +52,22 @@ class Message(object): """ - def __init__(self, service, user_id, msg_id, thread_id, recipient, sender, - subject, date, snippet, plain=None, html=None, label_ids=None, - attachments=None): + def __init__( + self, + service: 'googleapiclient.discovery.Resource', + user_id: str, + msg_id: str, + thread_id: str, + recipient: str, + sender: str, + subject: str, + date: str, + snippet, + plain: Optional[str] = None, + html: Optional[str] = None, + label_ids: Optional[List[str]] = None, + attachments: Optional[List[Attachment]] = None + ) -> None: self._service = service self.user_id = user_id self.id = msg_id @@ -66,202 +85,135 @@ def __init__(self, service, user_id, msg_id, thread_id, recipient, sender, def __repr__(self) -> str: """Represents the object by its sender, recipient, and id.""" - return (f'Message(to: {self.recipient:.12}' - f'{"..." if len(self.recipient) > 12 else ""}, ' - f'from: {self.sender:.12}' - f'{"..." if len(self.sender) > 12 else ""}, id: {self.id})') - - def mark_as_read(self): - """Marks this message as read (by removing the UNREAD label).""" + return ( + f'Message(to: {self.recipient}, from: {self.sender}, id: {self.id})' + ) - try: - res = self._service.users().messages().modify( - userId=self.user_id, id=self.id, - body=self._create_update_labels(to_remove=[labels.UNREAD]) - ).execute() - - except HttpError as error: - print(f'An error occurred: {error}') + def mark_as_read(self) -> None: + """ + Marks this message as read (by removing the UNREAD label). - else: - assert labels.UNREAD not in res['labelIds'], \ - f'An error occurred in a call to `mark_as_read`.' - - self.label_ids = res['labelIds'] - - def mark_as_unread(self): - """Marks this message as unread (by adding the UNREAD label).""" - - try: - res = self._service.users().messages().modify( - userId=self.user_id, id=self.id, - body=self._create_update_labels(to_add=[labels.UNREAD]) - ).execute() + Raises: + googleapiclient.errors.HttpError: There was an error executing the + HTTP request. - except HttpError as error: - print(f'An error occurred: {error}') + """ - else: - assert labels.UNREAD in res['labelIds'], \ - f'An error occurred in a call to `mark_as_unread`.' + self.remove_label(label.UNREAD) - self.label_ids = res['labelIds'] + def mark_as_unread(self) -> None: + """ + Marks this message as unread (by adding the UNREAD label). - def mark_as_spam(self): - """Marks this message as spam (by adding the SPAM label).""" + Raises: + googleapiclient.errors.HttpError: There was an error executing the + HTTP request. - try: - res = self._service.users().messages().modify( - userId=self.user_id, id=self.id, - body=self._create_update_labels(to_add=[labels.SPAM]) - ).execute() + """ - except HttpError as error: - print(f'An error occurred: {error}') + self.add_label(label.UNREAD) - else: - assert labels.SPAM in res['labelIds'], \ - f'An error occurred in a call to `mark_as_spam()`' + def mark_as_spam(self) -> None: + """ + Marks this message as spam (by adding the SPAM label). - self.label_ids = res['labelIds'] + Raises: + googleapiclient.errors.HttpError: There was an error executing the + HTTP request. - def mark_as_not_spam(self): - """Marks this message as not spam (by removing the SPAM label).""" + """ - try: - res = self._service.users().messages().modify( - userId=self.user_id, id=self.id, - body=self._create_update_labels(to_remove=[labels.SPAM]) - ).execute() + self.add_label(label.SPAM) - except HttpError as error: - print(f'An error occurred: {error}') + def mark_as_not_spam(self) -> None: + """ + Marks this message as not spam (by removing the SPAM label). - else: - assert labels.SPAM not in res['labelIds'], \ - f'An error occurred in a call to `mark_as_not_spam()`' + Raises: + googleapiclient.errors.HttpError: There was an error executing the + HTTP request. - self.label_ids = res['labelIds'] + """ - def mark_as_important(self): - """Marks this message as important (by adding the IMPORTANT label).""" + self.remove_label(label.SPAM) - try: - res = self._service.users().messages().modify( - userId=self.user_id, id=self.id, - body=self._create_update_labels(to_add=[labels.IMPORTANT]) - ).execute() + def mark_as_important(self) -> None: + """ + Marks this message as important (by adding the IMPORTANT label). - except HttpError as error: - print(f'An error occurred: {error}') + Raises: + googleapiclient.errors.HttpError: There was an error executing the + HTTP request. - else: - assert labels.IMPORTANT in res['labelIds'], \ - f'An error occurred in a call to `mark_as_important()`' + """ - self.label_ids = res['labelIds'] + self.add_label(label.IMPORTANT) - def mark_as_not_important(self): + def mark_as_not_important(self) -> None: """ Marks this message as not important (by removing the IMPORTANT label). - """ - - try: - res = self._service.users().messages().modify( - userId=self.user_id, id=self.id, - body=self._create_update_labels(to_remove=[labels.IMPORTANT]) - ).execute() - - except HttpError as error: - print(f'An error occurred: {error}') - - else: - assert labels.IMPORTANT not in res['labelIds'], \ - f'An error occurred in a call to `mark_as_not_important()`' - - self.label_ids = res['labelIds'] - - def star(self): - """Stars this message (by adding the STARRED label).""" + Raises: + googleapiclient.errors.HttpError: There was an error executing the + HTTP request. - try: - res = self._service.users().messages().modify( - userId=self.user_id, id=self.id, - body=self._create_update_labels(to_add=[labels.STARRED]) - ).execute() - - except HttpError as error: - print(f'An error occurred: {error}') + """ - else: - assert labels.STARRED in res['labelIds'], \ - f'An error occurred in a call to `star()`' + self.remove_label(label.IMPORTANT) - self.label_ids = res['labelIds'] + def star(self) -> None: + """ + Stars this message (by adding the STARRED label). - def unstar(self): - """Unstars this message (by removing the STARRED label).""" + Raises: + googleapiclient.errors.HttpError: There was an error executing the + HTTP request. - try: - res = self._service.users().messages().modify( - userId=self.user_id, id=self.id, - body=self._create_update_labels(to_remove=[labels.STARRED]) - ).execute() + """ - except HttpError as error: - print(f'An error occurred: {error}') + self.add_label(label.STARRED) - else: - assert labels.STARRED not in res['labelIds'], \ - f'An error occurred in a call to `unstar()`' + def unstar(self) -> None: + """ + Unstars this message (by removing the STARRED label). + + Raises: + googleapiclient.errors.HttpError: There was an error executing the + HTTP request. + + """ - self.label_ids = res['labelIds'] + self.remove_label(label.STARRED) - def move_to_inbox(self): + def move_to_inbox(self) -> None: """ Moves an archived message to your inbox (by adding the INBOX label). """ - try: - res = self._service.users().messages().modify( - userId=self.user_id, id=self.id, - body=self._create_update_labels(to_add=[labels.INBOX]) - ).execute() - - except HttpError as error: - print(f'An error occurred: {error}') - - else: - assert labels.INBOX in res['labelIds'], \ - f'An error occurred in a call to `move_to_inbox()`' + self.add_label(label.INBOX) - self.label_ids = res['labelIds'] - - def archive(self): + def archive(self) -> None: """ Archives the message (removes from inbox by removing the INBOX label). - """ + Raises: + googleapiclient.errors.HttpError: There was an error executing the + HTTP request. - try: - res = self._service.users().messages().modify( - userId=self.user_id, id=self.id, - body=self._create_update_labels(to_remove=[labels.INBOX]) - ).execute() + """ - except HttpError as error: - print(f'An error occurred: {error}') + self.remove_label(label.INBOX) - else: - assert labels.INBOX not in res['labelIds'], \ - f'An error occurred in a call to `archive()`' + def trash(self) -> None: + """ + Moves this message to the trash. - self.label_ids = res['labelIds'] + Raises: + googleapiclient.errors.HttpError: There was an error executing the + HTTP request. - def trash(self): - """Moves this message to the trash.""" + """ try: res = self._service.users().messages().trash( @@ -269,16 +221,24 @@ def trash(self): ).execute() except HttpError as error: - print(f'An error occurred: {error}') + # Pass error along + raise error else: - assert labels.TRASH in res['labelIds'], \ + assert label.TRASH in res['labelIds'], \ f'An error occurred in a call to `trash`.' self.label_ids = res['labelIds'] - def untrash(self): - """Removes this message from the trash.""" + def untrash(self) -> None: + """ + Removes this message from the trash. + + Raises: + googleapiclient.errors.HttpError: There was an error executing the + HTTP request. + + """ try: res = self._service.users().messages().untrash( @@ -286,72 +246,112 @@ def untrash(self): ).execute() except HttpError as error: - print(f'An error occurred: {error}') + # Pass error along + raise error else: - assert labels.TRASH not in res['labelIds'], \ + assert label.TRASH not in res['labelIds'], \ f'An error occurred in a call to `untrash`.' self.label_ids = res['labelIds'] - def add_label(self, to_add): + def move_from_inbox(self, to: Union[Label, str]) -> None: + """ + Moves a message from your inbox to another label "folder". + + Args: + to: The label to move to. + + Raises: + googleapiclient.errors.HttpError: There was an error executing the + HTTP request. + + """ + + self.modify_labels(to, label.INBOX) + + def add_label(self, to_add: Union[Label, str]) -> None: """ Adds the given label to the message. Args: - to_add (str): the label ID to add. + to_add: The label to add. + + Raises: + googleapiclient.errors.HttpError: There was an error executing the + HTTP request. """ self.add_labels([to_add]) - def add_labels(self, to_add): + def add_labels(self, to_add: Union[List[Label], List[str]]) -> None: """ Adds the given labels to the message. Args: - to_add (List[str]): the list of label IDs to add. + to_add: The list of labels to add. + + Raises: + googleapiclient.errors.HttpError: There was an error executing the + HTTP request. """ self.modify_labels(to_add, []) - def remove_label(self, to_remove): + def remove_label(self, to_remove: Union[Label, str]) -> None: """ Removes the given label from the message. Args: - to_remove (str): the label ID to remove. + to_remove: The label to remove. + + Raises: + googleapiclient.errors.HttpError: There was an error executing the + HTTP request. """ self.remove_labels([to_remove]) - def remove_labels(self, to_remove): + def remove_labels(self, to_remove: Union[List[Label], List[str]]) -> None: """ Removes the given labels from the message. Args: - to_remove (List[str]): the list of label IDs to remove. + to_remove: The list of labels to remove. + + Raises: + googleapiclient.errors.HttpError: There was an error executing the + HTTP request. """ self.modify_labels([], to_remove) - def modify_labels(self, to_add, to_remove): + def modify_labels( + self, + to_add: Union[Label, str, List[Label], List[str]], + to_remove: Union[Label, str, List[Label], List[str]] + ) -> None: """ - Adds or removes the specified labels. + Adds or removes the specified label. Args: - to_add (List[str]): the list of label IDs to add. - to_remove (List[str]): the list of label IDs to remove. + to_add: The label or list of labels to add. + to_remove: The label or list of labels to remove. + + Raises: + googleapiclient.errors.HttpError: There was an error executing the + HTTP request. """ - if isinstance(to_add, Label): + if isinstance(to_add, (Label, str)): to_add = [to_add] - if isinstance(to_remove, Label): + if isinstance(to_remove, (Label, str)): to_remove = [to_remove] try: @@ -361,36 +361,30 @@ def modify_labels(self, to_add, to_remove): ).execute() except HttpError as error: - print(f'An error occurred: {error}') + # Pass along error + raise error else: assert all([lbl in res['labelIds'] for lbl in to_add]) \ and all([lbl not in res['labelIds'] for lbl in to_remove]), \ - 'An error occurred while modifying message labels.' + 'An error occurred while modifying message label.' self.label_ids = res['labelIds'] - def move_from_inbox(self, to): - """ - Moves a message from your inbox to another label "folder". - - Args: - to (Label): the label to move to. - - """ - - self.modify_labels(to, labels.INBOX) - - def _create_update_labels(self, to_add=None, to_remove=None): + def _create_update_labels( + self, + to_add: Union[List[Label], List[str]] = None, + to_remove: Union[List[Label], List[str]] = None + ) -> dict: """ - Creates an object for updating message labels. + Creates an object for updating message label. Args: - to_add (List[str]): a list of label IDs to add. - to_remove (List[str]): a list of label IDs to remove. + to_add: A list of labels to add. + to_remove: A list of labels to remove. Returns: - dict: the modify labels object to pass to the Gmail API. + The modify labels object to pass to the Gmail API. """ @@ -401,6 +395,10 @@ def _create_update_labels(self, to_add=None, to_remove=None): to_remove = [] return { - 'addLabelIds': [lbl.id for lbl in to_add], - 'removeLabelIds': [lbl.id for lbl in to_remove] + 'addLabelIds': [ + lbl.id if isinstance(lbl, Label) else lbl for lbl in to_add + ], + 'removeLabelIds': [ + lbl.id if isinstance(lbl, Label) else lbl for lbl in to_remove + ] } diff --git a/simplegmail/query.py b/simplegmail/query.py index 286c25b..01838d3 100644 --- a/simplegmail/query.py +++ b/simplegmail/query.py @@ -1,4 +1,9 @@ -"""This module contains functions for constructing Gmail search queries.""" +""" +File: query.py +-------------- +This module contains functions for constructing Gmail search queries. + +""" def construct_query(*query_dicts, **query_terms): """