├── .github ├── FUNDING.yml └── workflows │ └── python-publish.yml ├── .gitignore ├── LICENSE ├── MANIFEST.in ├── README.md ├── setup.py ├── simplegmail ├── __init__.py ├── attachment.py ├── gmail.py ├── label.py ├── message.py └── query.py └── tests └── test_query.py /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | # These are supported funding model platforms 2 | 3 | github: jeremyephron 4 | -------------------------------------------------------------------------------- /.github/workflows/python-publish.yml: -------------------------------------------------------------------------------- 1 | # This workflow will upload a Python Package using Twine when a release is created 2 | # For more information see: https://help.github.com/en/actions/language-and-framework-guides/using-python-with-github-actions#publishing-to-package-registries 3 | 4 | name: Upload Python Package 5 | 6 | on: 7 | release: 8 | types: [created] 9 | 10 | jobs: 11 | deploy: 12 | 13 | runs-on: ubuntu-20.04 14 | 15 | steps: 16 | - uses: actions/checkout@v2 17 | - name: Set up Python 18 | uses: actions/setup-python@v2 19 | with: 20 | python-version: '3.6.7' 21 | - name: Install dependencies 22 | run: | 23 | python -m pip install --upgrade pip 24 | pip install setuptools wheel twine 25 | - name: Build and publish 26 | env: 27 | TWINE_USERNAME: '__token__' 28 | TWINE_PASSWORD: ${{ secrets.PYPI_API_TOKEN }} 29 | run: | 30 | python setup.py sdist bdist_wheel 31 | twine upload dist/* 32 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | wheels/ 23 | *.egg-info/ 24 | .installed.cfg 25 | *.egg 26 | MANIFEST 27 | 28 | # PyInstaller 29 | # Usually these files are written by a python script from a template 30 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 31 | *.manifest 32 | *.spec 33 | 34 | # Installer logs 35 | pip-log.txt 36 | pip-delete-this-directory.txt 37 | 38 | # Unit test / coverage reports 39 | htmlcov/ 40 | .tox/ 41 | .nox/ 42 | .coverage 43 | .coverage.* 44 | .cache 45 | nosetests.xml 46 | coverage.xml 47 | *.cover 48 | .hypothesis/ 49 | .pytest_cache/ 50 | 51 | # Translations 52 | *.mo 53 | *.pot 54 | 55 | # Django stuff: 56 | *.log 57 | local_settings.py 58 | db.sqlite3 59 | 60 | # Flask stuff: 61 | instance/ 62 | .webassets-cache 63 | 64 | # Scrapy stuff: 65 | .scrapy 66 | 67 | # Sphinx documentation 68 | docs/_build/ 69 | 70 | # PyBuilder 71 | target/ 72 | 73 | # Jupyter Notebook 74 | .ipynb_checkpoints 75 | 76 | # IPython 77 | profile_default/ 78 | ipython_config.py 79 | 80 | # pyenv 81 | .python-version 82 | 83 | # celery beat schedule file 84 | celerybeat-schedule 85 | 86 | # SageMath parsed files 87 | *.sage.py 88 | 89 | # Environments 90 | .env 91 | .venv 92 | env/ 93 | venv/ 94 | ENV/ 95 | env.bak/ 96 | venv.bak/ 97 | .env*/ 98 | 99 | # Spyder project settings 100 | .spyderproject 101 | .spyproject 102 | 103 | # Rope project settings 104 | .ropeproject 105 | 106 | # mkdocs documentation 107 | /site 108 | 109 | # mypy 110 | .mypy_cache/ 111 | .dmypy.json 112 | dmypy.json 113 | 114 | # Pyre type checker 115 | .pyre/ 116 | 117 | client_secret.json 118 | gmail-token.json 119 | gmail_token.json 120 | test.py 121 | 122 | .DS_Store 123 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2018 Jeremy Ephron Barenholtz 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include LICENSE 2 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # simplegmail 2 | [![PyPI Downloads](https://img.shields.io/pypi/dm/simplegmail.svg?label=PyPI%20downloads)]( 3 | https://pypi.org/project/simplegmail/) 4 | 5 | A simple Gmail API client in Python for applications. 6 | 7 | --- 8 | 9 | Currently Supported Behavior: 10 | - Sending html messages 11 | - Sending messages with attachments 12 | - Sending messages with your Gmail account signature 13 | - Retrieving messages with the full suite of Gmail's search capabilities 14 | - Retrieving messages with attachments, and downloading attachments 15 | - Modifying message labels (includes marking as read/unread, important/not 16 | important, starred/unstarred, trash/untrash, inbox/archive) 17 | 18 | ## Table of Contents 19 | 20 | - [Getting Started](#getting-started) 21 | - [Installation](#installation) 22 | - [Usage](#usage) 23 | - [Send a simple message](#send-a-simple-message) 24 | - [Send a message with attachments, cc, bcc fields](#send-a-message-with-attachments-cc-bcc-fields) 25 | - [Retrieving messages](#retrieving-messages) 26 | - [Marking messages](#marking-messages) 27 | - [Changing message labels](#changing-message-labels) 28 | - [Downloading attachments](#downloading-attachments) 29 | - [Retrieving messages with queries](#retrieving-messages-advanced-with-queries) 30 | - [Retrieving messages with more advanced queries](#retrieving-messages-more-advanced-with-more-queries) 31 | - [Feedback](#feedback) 32 | 33 | ## Getting Started 34 | 35 | The only setup required is to download an OAuth 2.0 Client ID file from Google 36 | that will authorize your application. 37 | 38 | This can be done at: https://console.developers.google.com/apis/credentials. 39 | For those who haven't created a credential for Google's API, after clicking the 40 | link above (and logging in to the appropriate account), 41 | 42 | 1. Select/create the project that this authentication is for (if creating a new 43 | project make sure to configure the OAuth consent screen; you only need to set 44 | an Application name) 45 | 46 | 2. Click on the "Dashboard" tab, then "Enable APIs and Services". Search for 47 | Gmail and enable. 48 | 49 | 3. Click on the Credentials tab, then "Create Credentials" > "OAuth client ID". 50 | 51 | 4. Select what kind of application this is for, and give it a memorable name. 52 | Fill out all necessary information for the credential (e.g., if choosing 53 | "Web Application" make sure to add an Authorized Redirect URI. See 54 | https://developers.google.com/identity/protocols/oauth2 for more infomation). 55 | 56 | 5. Back on the credentials screen, click the download icon next to the 57 | credential you just created to download it as a JSON object. 58 | 59 | 6. Save this file as "client_secret.json" and place it in the root directory of 60 | your application. (The `Gmail` class takes in an argument for the name of this 61 | file if you choose to name it otherwise.) 62 | 63 | The first time you create a new instance of the `Gmail` class, a browser window 64 | will open, and you'll be asked to give permissions to the application. This 65 | will save an access token in a file named "gmail-token.json", and only needs to 66 | occur once. 67 | 68 | Additionally, you will need to ensure IMAP is enabled in your Gmail account 69 | settings. 70 | 71 | You are now good to go! 72 | 73 | Note about authentication method: I have opted not to use a username-password 74 | authentication (through imap/smtp), since using Google's authorization is both 75 | significantly safer and avoids clashing with Google's many security measures. 76 | 77 | ## Installation 78 | 79 | Install using `pip` (Python3). 80 | 81 | ```bash 82 | pip3 install simplegmail 83 | ``` 84 | 85 | ## Usage 86 | 87 | ### Send a simple message: 88 | 89 | ```python 90 | from simplegmail import Gmail 91 | 92 | gmail = Gmail() # will open a browser window to ask you to log in and authenticate 93 | 94 | params = { 95 | "to": "you@youremail.com", 96 | "sender": "me@myemail.com", 97 | "subject": "My first email", 98 | "msg_html": "

Woah, my first email!


This is an HTML email.", 99 | "msg_plain": "Hi\nThis is a plain text email.", 100 | "signature": True # use my account signature 101 | } 102 | message = gmail.send_message(**params) # equivalent to send_message(to="you@youremail.com", sender=...) 103 | ``` 104 | 105 | ### Send a message with attachments, cc, bcc fields: 106 | 107 | ```python 108 | from simplegmail import Gmail 109 | 110 | gmail = Gmail() 111 | 112 | params = { 113 | "to": "you@youremail.com", 114 | "sender": "me@myemail.com", 115 | "cc": ["bob@bobsemail.com"], 116 | "bcc": ["marie@gossip.com", "hidden@whereami.com"], 117 | "subject": "My first email", 118 | "msg_html": "

Woah, my first email!


This is an HTML email.", 119 | "msg_plain": "Hi\nThis is a plain text email.", 120 | "attachments": ["path/to/something/cool.pdf", "path/to/image.jpg", "path/to/script.py"], 121 | "signature": True # use my account signature 122 | } 123 | message = gmail.send_message(**params) # equivalent to send_message(to="you@youremail.com", sender=...) 124 | ``` 125 | 126 | It couldn't be easier! 127 | 128 | ### Retrieving messages: 129 | 130 | ```python 131 | from simplegmail import Gmail 132 | 133 | gmail = Gmail() 134 | 135 | # Unread messages in your inbox 136 | messages = gmail.get_unread_inbox() 137 | 138 | # Starred messages 139 | messages = gmail.get_starred_messages() 140 | 141 | # ...and many more easy to use functions can be found in gmail.py! 142 | 143 | # Print them out! 144 | for message in messages: 145 | print("To: " + message.recipient) 146 | print("From: " + message.sender) 147 | print("Subject: " + message.subject) 148 | print("Date: " + message.date) 149 | print("Preview: " + message.snippet) 150 | 151 | print("Message Body: " + message.plain) # or message.html 152 | ``` 153 | 154 | ### Marking messages: 155 | 156 | ```python 157 | from simplegmail import Gmail 158 | 159 | gmail = Gmail() 160 | 161 | messages = gmail.get_unread_inbox() 162 | 163 | message_to_read = messages[0] 164 | message_to_read.mark_as_read() 165 | 166 | # Oops, I want to mark as unread now 167 | message_to_read.mark_as_unread() 168 | 169 | message_to_star = messages[1] 170 | message_to_star.star() 171 | 172 | message_to_trash = messages[2] 173 | message_to_trash.trash() 174 | 175 | # ...and many more functions can be found in message.py! 176 | ``` 177 | 178 | ### Changing message labels: 179 | 180 | ```python 181 | from simplegmail import Gmail 182 | 183 | gmail = Gmail() 184 | 185 | # Get the label objects for your account. Each label has a specific ID that 186 | # you need, not just the name! 187 | labels = gmail.list_labels() 188 | 189 | # To find a label by the name that you know (just an example): 190 | finance_label = list(filter(lambda x: x.name == 'Finance', labels))[0] 191 | 192 | messages = gmail.get_unread_inbox() 193 | 194 | # We can add/remove a label 195 | message = messages[0] 196 | message.add_label(finance_label) 197 | 198 | # We can "move" a message from one label to another 199 | message.modify_labels(to_add=labels[10], to_remove=finance_label) 200 | 201 | # ...check out the code in message.py for more! 202 | ``` 203 | 204 | ### Downloading attachments: 205 | 206 | ```python 207 | from simplegmail import Gmail 208 | 209 | gmail = Gmail() 210 | 211 | messages = gmail.get_unread_inbox() 212 | 213 | message = messages[0] 214 | if message.attachments: 215 | for attm in message.attachments: 216 | print('File: ' + attm.filename) 217 | attm.save() # downloads and saves each attachment under it's stored 218 | # filename. You can download without saving with `attm.download()` 219 | 220 | ``` 221 | 222 | ### Retrieving messages (advanced, with queries!): 223 | 224 | ```python 225 | from simplegmail import Gmail 226 | from simplegmail.query import construct_query 227 | 228 | gmail = Gmail() 229 | 230 | # Unread messages in inbox with label "Work" 231 | labels = gmail.list_labels() 232 | work_label = list(filter(lambda x: x.name == 'Work', labels))[0] 233 | 234 | messages = gmail.get_unread_inbox(labels=[work_label]) 235 | 236 | # For even more control use queries: 237 | # Messages that are: newer than 2 days old, unread, labeled "Finance" or both "Homework" and "CS" 238 | query_params = { 239 | "newer_than": (2, "day"), 240 | "unread": True, 241 | "labels":[["Work"], ["Homework", "CS"]] 242 | } 243 | 244 | messages = gmail.get_messages(query=construct_query(query_params)) 245 | 246 | # We could have also accomplished this with 247 | # messages = gmail.get_unread_messages(query=construct_query(newer_than=(2, "day"), labels=[["Work"], ["Homework", "CS"]])) 248 | # There are many, many different ways of achieving the same result with search. 249 | ``` 250 | 251 | ### Retrieving messages (more advanced, with more queries!): 252 | 253 | ```python 254 | from simplegmail import Gmail 255 | from simplegmail.query import construct_query 256 | 257 | gmail = Gmail() 258 | 259 | # For even more control use queries: 260 | # Messages that are either: 261 | # newer than 2 days old, unread, labeled "Finance" or both "Homework" and "CS" 262 | # or 263 | # newer than 1 month old, unread, labeled "Top Secret", but not starred. 264 | 265 | labels = gmail.list_labels() 266 | 267 | # Construct our two queries separately 268 | query_params_1 = { 269 | "newer_than": (2, "day"), 270 | "unread": True, 271 | "labels":[["Finance"], ["Homework", "CS"]] 272 | } 273 | 274 | query_params_2 = { 275 | "newer_than": (1, "month"), 276 | "unread": True, 277 | "labels": ["Top Secret"], 278 | "exclude_starred": True 279 | } 280 | 281 | # construct_query() will create both query strings and "or" them together. 282 | messages = gmail.get_messages(query=construct_query(query_params_1, query_params_2)) 283 | ``` 284 | 285 | For more on what you can do with queries, read the docstring for `construct_query()` in `query.py`. 286 | 287 | ## Feedback 288 | 289 | If there is functionality you'd like to see added, or any bugs in this project, 290 | please let me know by posting an issue or submitting a pull request! 291 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | import setuptools 2 | 3 | setuptools.setup( 4 | name="simplegmail", 5 | version="4.1.1", 6 | url="https://github.com/jeremyephron/simplegmail", 7 | author="Jeremy Ephron", 8 | author_email="jeremye@cs.stanford.edu", 9 | description="A simple Python API client for Gmail.", 10 | long_description=open('README.md').read(), 11 | long_description_content_type='text/markdown', 12 | packages=setuptools.find_packages(), 13 | install_requires=[ 14 | 'google-api-python-client>=1.7.3', 15 | 'beautifulsoup4>=4.0.0', 16 | 'python-dateutil>=2.8.1', 17 | 'oauth2client>=4.1.3', 18 | 'lxml>=4.4.2' 19 | ], 20 | setup_requires=["pytest-runner"], 21 | tests_require=["pytest"], 22 | classifiers=[ 23 | 'Development Status :: 5 - Production/Stable', 24 | 'Programming Language :: Python', 25 | 'Programming Language :: Python :: 3', 26 | 'Programming Language :: Python :: 3.6', 27 | "License :: OSI Approved :: MIT License", 28 | "Operating System :: OS Independent", 29 | ], 30 | ) 31 | -------------------------------------------------------------------------------- /simplegmail/__init__.py: -------------------------------------------------------------------------------- 1 | from simplegmail.gmail import Gmail 2 | from simplegmail import query 3 | from simplegmail import label 4 | 5 | __all__ = ['Gmail', 'query', 'label'] 6 | -------------------------------------------------------------------------------- /simplegmail/attachment.py: -------------------------------------------------------------------------------- 1 | """ 2 | File: attachment.py 3 | ------------------- 4 | This module contains the implementation of the Attachment object. 5 | 6 | """ 7 | 8 | import base64 # for base64.urlsafe_b64decode 9 | import os # for os.path.exists 10 | from typing import Optional 11 | 12 | class Attachment(object): 13 | """ 14 | The Attachment class for attachments to emails in your Gmail mailbox. This 15 | class should not be manually instantiated. 16 | 17 | Args: 18 | service: The Gmail service object. 19 | user_id: The username of the account the message belongs to. 20 | msg_id: The id of message the attachment belongs to. 21 | att_id: The id of the attachment. 22 | filename: The filename associated with the attachment. 23 | filetype: The mime type of the file. 24 | data: The raw data of the file. Default None. 25 | 26 | Attributes: 27 | _service (googleapiclient.discovery.Resource): The Gmail service object. 28 | user_id (str): The username of the account the message belongs to. 29 | msg_id (str): The id of message the attachment belongs to. 30 | id (str): The id of the attachment. 31 | filename (str): The filename associated with the attachment. 32 | filetype (str): The mime type of the file. 33 | data (bytes): The raw data of the file. 34 | 35 | """ 36 | 37 | def __init__( 38 | self, 39 | service: 'googleapiclient.discovery.Resource', 40 | user_id: str, 41 | msg_id: str, 42 | att_id: str, 43 | filename: str, 44 | filetype: str, 45 | data: Optional[bytes] = None 46 | ) -> None: 47 | self._service = service 48 | self.user_id = user_id 49 | self.msg_id = msg_id 50 | self.id = att_id 51 | self.filename = filename 52 | self.filetype = filetype 53 | self.data = data 54 | 55 | def download(self) -> None: 56 | """ 57 | Downloads the data for an attachment if it does not exist. 58 | 59 | Raises: 60 | googleapiclient.errors.HttpError: There was an error executing the 61 | HTTP request. 62 | 63 | """ 64 | 65 | if self.data is not None: 66 | return 67 | 68 | res = self._service.users().messages().attachments().get( 69 | userId=self.user_id, messageId=self.msg_id, id=self.id 70 | ).execute() 71 | 72 | data = res['data'] 73 | self.data = base64.urlsafe_b64decode(data) 74 | 75 | def save( 76 | self, 77 | filepath: Optional[str] = None, 78 | overwrite: bool = False 79 | ) -> None: 80 | """ 81 | Saves the attachment. Downloads file data if not downloaded. 82 | 83 | Args: 84 | filepath: where to save the attachment. Default None, which uses 85 | the filename stored. 86 | overwrite: whether to overwrite existing files. Default False. 87 | 88 | Raises: 89 | FileExistsError: if the call would overwrite an existing file and 90 | overwrite is not set to True. 91 | 92 | """ 93 | 94 | if filepath is None: 95 | filepath = self.filename 96 | 97 | if self.data is None: 98 | self.download() 99 | 100 | if not overwrite and os.path.exists(filepath): 101 | raise FileExistsError( 102 | f"Cannot overwrite file '{filepath}'. Use overwrite=True if " 103 | f"you would like to overwrite the file." 104 | ) 105 | 106 | with open(filepath, 'wb') as f: 107 | f.write(self.data) 108 | 109 | -------------------------------------------------------------------------------- /simplegmail/gmail.py: -------------------------------------------------------------------------------- 1 | """ 2 | File: gmail.py 3 | -------------- 4 | Home to the main Gmail service object. Currently supports sending mail (with 5 | attachments) and retrieving mail with the full suite of Gmail search options. 6 | 7 | """ 8 | 9 | import base64 10 | from email.mime.audio import MIMEAudio 11 | from email.mime.application import MIMEApplication 12 | from email.mime.base import MIMEBase 13 | from email.mime.image import MIMEImage 14 | from email.mime.multipart import MIMEMultipart 15 | from email.mime.text import MIMEText 16 | import html 17 | import math 18 | import mimetypes 19 | import os 20 | import re 21 | import threading 22 | from typing import List, Optional 23 | 24 | from bs4 import BeautifulSoup 25 | import dateutil.parser as parser 26 | from googleapiclient.discovery import build 27 | from googleapiclient.errors import HttpError 28 | from httplib2 import Http 29 | from oauth2client import client, file, tools 30 | from oauth2client.clientsecrets import InvalidClientSecretsError 31 | 32 | from simplegmail import label 33 | from simplegmail.attachment import Attachment 34 | from simplegmail.label import Label 35 | from simplegmail.message import Message 36 | 37 | 38 | class Gmail(object): 39 | """ 40 | The Gmail class which serves as the entrypoint for the Gmail service API. 41 | 42 | Args: 43 | client_secret_file: The path of the user's client secret file. 44 | creds_file: The path of the auth credentials file (created on first 45 | call). 46 | access_type: Whether to request a refresh token for usage without a 47 | user necessarily present. Either 'online' or 'offline'. 48 | 49 | Attributes: 50 | client_secret_file (str): The name of the user's client secret file. 51 | service (googleapiclient.discovery.Resource): The Gmail service object. 52 | 53 | """ 54 | 55 | # Allow Gmail to read and write emails, and access settings like aliases. 56 | _SCOPES = [ 57 | 'https://www.googleapis.com/auth/gmail.modify', 58 | 'https://www.googleapis.com/auth/gmail.settings.basic' 59 | ] 60 | 61 | # If you don't have a client secret file, follow the instructions at: 62 | # https://developers.google.com/gmail/api/quickstart/python 63 | # Make sure the client secret file is in the root directory of your app. 64 | 65 | def __init__( 66 | self, 67 | client_secret_file: str = 'client_secret.json', 68 | creds_file: str = 'gmail_token.json', 69 | access_type: str = 'offline', 70 | noauth_local_webserver: bool = False, 71 | _creds: Optional[client.OAuth2Credentials] = None, 72 | ) -> None: 73 | self.client_secret_file = client_secret_file 74 | self.creds_file = creds_file 75 | 76 | try: 77 | # The file gmail_token.json stores the user's access and refresh 78 | # tokens, and is created automatically when the authorization flow 79 | # completes for the first time. 80 | if _creds: 81 | self.creds = _creds 82 | else: 83 | store = file.Storage(self.creds_file) 84 | self.creds = store.get() 85 | 86 | if not self.creds or self.creds.invalid: 87 | flow = client.flow_from_clientsecrets( 88 | self.client_secret_file, self._SCOPES 89 | ) 90 | 91 | flow.params['access_type'] = access_type 92 | flow.params['prompt'] = 'consent' 93 | 94 | args = [] 95 | if noauth_local_webserver: 96 | args.append('--noauth_local_webserver') 97 | 98 | flags = tools.argparser.parse_args(args) 99 | self.creds = tools.run_flow(flow, store, flags) 100 | 101 | self._service = build( 102 | 'gmail', 'v1', http=self.creds.authorize(Http()), 103 | cache_discovery=False 104 | ) 105 | 106 | except InvalidClientSecretsError: 107 | raise FileNotFoundError( 108 | "Your 'client_secret.json' file is nonexistent. Make sure " 109 | "the file is in the root directory of your application. If " 110 | "you don't have a client secrets file, go to https://" 111 | "developers.google.com/gmail/api/quickstart/python, and " 112 | "follow the instructions listed there." 113 | ) 114 | 115 | @property 116 | def service(self) -> 'googleapiclient.discovery.Resource': 117 | # Since the token is only used through calls to the service object, 118 | # this ensure that the token is always refreshed before use. 119 | if self.creds.access_token_expired: 120 | self.creds.refresh(Http()) 121 | 122 | return self._service 123 | 124 | def send_message( 125 | self, 126 | sender: str, 127 | to: str, 128 | subject: str = '', 129 | msg_html: Optional[str] = None, 130 | msg_plain: Optional[str] = None, 131 | cc: Optional[List[str]] = None, 132 | bcc: Optional[List[str]] = None, 133 | attachments: Optional[List[str]] = None, 134 | signature: bool = False, 135 | user_id: str = 'me' 136 | ) -> Message: 137 | """ 138 | Sends an email. 139 | 140 | Args: 141 | sender: The email address the message is being sent from. 142 | to: The email address the message is being sent to. 143 | subject: The subject line of the email. 144 | msg_html: The HTML message of the email. 145 | msg_plain: The plain text alternate message of the email. This is 146 | often displayed on slow or old browsers, or if the HTML message 147 | is not provided. 148 | cc: The list of email addresses to be cc'd. 149 | bcc: The list of email addresses to be bcc'd. 150 | attachments: The list of attachment file names. 151 | signature: Whether the account signature should be added to the 152 | message. 153 | user_id: The address of the sending account. 'me' for the 154 | default address associated with the account. 155 | 156 | Returns: 157 | The Message object representing the sent message. 158 | 159 | Raises: 160 | googleapiclient.errors.HttpError: There was an error executing the 161 | HTTP request. 162 | 163 | """ 164 | 165 | msg = self._create_message( 166 | sender, to, subject, msg_html, msg_plain, cc=cc, bcc=bcc, 167 | attachments=attachments, signature=signature, user_id=user_id 168 | ) 169 | 170 | try: 171 | req = self.service.users().messages().send(userId='me', body=msg) 172 | res = req.execute() 173 | return self._build_message_from_ref(user_id, res, 'reference') 174 | 175 | except HttpError as error: 176 | # Pass along the error 177 | raise error 178 | 179 | def get_unread_inbox( 180 | self, 181 | user_id: str = 'me', 182 | labels: Optional[List[Label]] = None, 183 | query: str = '', 184 | attachments: str = 'reference' 185 | ) -> List[Message]: 186 | """ 187 | Gets unread messages from your inbox. 188 | 189 | Args: 190 | user_id: The user's email address. By default, the authenticated 191 | user. 192 | labels: Labels that messages must match. 193 | query: A Gmail query to match. 194 | attachments: Accepted values are 'ignore' which completely 195 | ignores all attachments, 'reference' which includes attachment 196 | information but does not download the data, and 'download' which 197 | downloads the attachment data to store locally. Default 198 | 'reference'. 199 | 200 | Returns: 201 | A list of message objects. 202 | 203 | Raises: 204 | googleapiclient.errors.HttpError: There was an error executing the 205 | HTTP request. 206 | 207 | """ 208 | 209 | if labels is None: 210 | labels = [] 211 | 212 | labels.append(label.INBOX) 213 | return self.get_unread_messages(user_id, labels, query) 214 | 215 | def get_starred_messages( 216 | self, 217 | user_id: str = 'me', 218 | labels: Optional[List[Label]] = None, 219 | query: str = '', 220 | attachments: str = 'reference', 221 | include_spam_trash: bool = False 222 | ) -> List[Message]: 223 | """ 224 | Gets starred messages from your account. 225 | 226 | Args: 227 | user_id: The user's email address. By default, the authenticated 228 | user. 229 | labels: Label IDs messages must match. 230 | query: A Gmail query to match. 231 | attachments: accepted values are 'ignore' which completely 232 | ignores all attachments, 'reference' which includes attachment 233 | information but does not download the data, and 'download' which 234 | downloads the attachment data to store locally. Default 235 | 'reference'. 236 | include_spam_trash: Whether to include messages from spam or trash. 237 | 238 | Returns: 239 | A list of message objects. 240 | 241 | Raises: 242 | googleapiclient.errors.HttpError: There was an error executing the 243 | HTTP request. 244 | 245 | """ 246 | 247 | if labels is None: 248 | labels = [] 249 | 250 | labels.append(label.STARRED) 251 | return self.get_messages(user_id, labels, query, attachments, 252 | include_spam_trash) 253 | 254 | def get_important_messages( 255 | self, 256 | user_id: str = 'me', 257 | labels: Optional[List[Label]] = None, 258 | query: str = '', 259 | attachments: str = 'reference', 260 | include_spam_trash: bool = False 261 | ) -> List[Message]: 262 | """ 263 | Gets messages marked important from your account. 264 | 265 | Args: 266 | user_id: The user's email address. By default, the authenticated 267 | user. 268 | labels: Label IDs messages must match. 269 | query: A Gmail query to match. 270 | attachments: accepted values are 'ignore' which completely 271 | ignores all attachments, 'reference' which includes attachment 272 | information but does not download the data, and 'download' which 273 | downloads the attachment data to store locally. Default 274 | 'reference'. 275 | include_spam_trash: Whether to include messages from spam or trash. 276 | 277 | Returns: 278 | A list of message objects. 279 | 280 | Raises: 281 | googleapiclient.errors.HttpError: There was an error executing the 282 | HTTP request. 283 | 284 | """ 285 | 286 | if labels is None: 287 | labels = [] 288 | 289 | labels.append(label.IMPORTANT) 290 | return self.get_messages(user_id, labels, query, attachments, 291 | include_spam_trash) 292 | 293 | def get_unread_messages( 294 | self, 295 | user_id: str = 'me', 296 | labels: Optional[List[Label]] = None, 297 | query: str = '', 298 | attachments: str = 'reference', 299 | include_spam_trash: bool = False 300 | ) -> List[Message]: 301 | """ 302 | Gets unread messages from your account. 303 | 304 | Args: 305 | user_id: The user's email address. By default, the authenticated 306 | user. 307 | labels: Label IDs messages must match. 308 | query: A Gmail query to match. 309 | attachments: accepted values are 'ignore' which completely 310 | ignores all attachments, 'reference' which includes attachment 311 | information but does not download the data, and 'download' which 312 | downloads the attachment data to store locally. Default 313 | 'reference'. 314 | include_spam_trash: Whether to include messages from spam or trash. 315 | 316 | Returns: 317 | A list of message objects. 318 | 319 | Raises: 320 | googleapiclient.errors.HttpError: There was an error executing the 321 | HTTP request. 322 | 323 | """ 324 | 325 | if labels is None: 326 | labels = [] 327 | 328 | labels.append(label.UNREAD) 329 | return self.get_messages(user_id, labels, query, attachments, 330 | include_spam_trash) 331 | 332 | def get_drafts( 333 | self, 334 | user_id: str = 'me', 335 | labels: Optional[List[Label]] = None, 336 | query: str = '', 337 | attachments: str = 'reference', 338 | include_spam_trash: bool = False 339 | ) -> List[Message]: 340 | """ 341 | Gets drafts saved in your account. 342 | 343 | Args: 344 | user_id: The user's email address. By default, the authenticated 345 | user. 346 | labels: Label IDs messages must match. 347 | query: A Gmail query to match. 348 | attachments: accepted values are 'ignore' which completely 349 | ignores all attachments, 'reference' which includes attachment 350 | information but does not download the data, and 'download' which 351 | downloads the attachment data to store locally. Default 352 | 'reference'. 353 | include_spam_trash: Whether to include messages from spam or trash. 354 | 355 | Returns: 356 | A list of message objects. 357 | 358 | Raises: 359 | googleapiclient.errors.HttpError: There was an error executing the 360 | HTTP request. 361 | 362 | """ 363 | 364 | if labels is None: 365 | labels = [] 366 | 367 | labels.append(label.DRAFT) 368 | return self.get_messages(user_id, labels, query, attachments, 369 | include_spam_trash) 370 | 371 | def get_sent_messages( 372 | self, 373 | user_id: str = 'me', 374 | labels: Optional[List[Label]] = None, 375 | query: str = '', 376 | attachments: str = 'reference', 377 | include_spam_trash: bool = False 378 | ) -> List[Message]: 379 | """ 380 | Gets sent messages from your account. 381 | 382 | Args: 383 | user_id: The user's email address. By default, the authenticated 384 | user. 385 | labels: Label IDs messages must match. 386 | query: A Gmail query to match. 387 | attachments: accepted values are 'ignore' which completely 388 | ignores all attachments, 'reference' which includes attachment 389 | information but does not download the data, and 'download' which 390 | downloads the attachment data to store locally. Default 391 | 'reference'. 392 | include_spam_trash: Whether to include messages from spam or trash. 393 | 394 | Returns: 395 | A list of message objects. 396 | 397 | Raises: 398 | googleapiclient.errors.HttpError: There was an error executing the 399 | HTTP request. 400 | 401 | """ 402 | 403 | if labels is None: 404 | labels = [] 405 | 406 | labels.append(label.SENT) 407 | return self.get_messages(user_id, labels, query, attachments, 408 | include_spam_trash) 409 | 410 | def get_trash_messages( 411 | self, 412 | user_id: str = 'me', 413 | labels: Optional[List[Label]] = None, 414 | query: str = '', 415 | attachments: str = 'reference' 416 | ) -> List[Message]: 417 | 418 | """ 419 | Gets messages in your trash from your account. 420 | 421 | Args: 422 | user_id: The user's email address. By default, the authenticated 423 | user. 424 | labels: Label IDs messages must match. 425 | query: A Gmail query to match. 426 | attachments: accepted values are 'ignore' which completely 427 | ignores all attachments, 'reference' which includes attachment 428 | information but does not download the data, and 'download' which 429 | downloads the attachment data to store locally. Default 430 | 'reference'. 431 | 432 | Returns: 433 | A list of message objects. 434 | 435 | Raises: 436 | googleapiclient.errors.HttpError: There was an error executing the 437 | HTTP request. 438 | 439 | """ 440 | 441 | if labels is None: 442 | labels = [] 443 | 444 | labels.append(label.TRASH) 445 | return self.get_messages(user_id, labels, query, attachments, True) 446 | 447 | def get_spam_messages( 448 | self, 449 | user_id: str = 'me', 450 | labels: Optional[List[Label]] = None, 451 | query: str = '', 452 | attachments: str = 'reference' 453 | ) -> List[Message]: 454 | """ 455 | Gets messages marked as spam from your account. 456 | 457 | Args: 458 | user_id: The user's email address. By default, the authenticated 459 | user. 460 | labels: Label IDs messages must match. 461 | query: A Gmail query to match. 462 | attachments: accepted values are 'ignore' which completely 463 | ignores all attachments, 'reference' which includes attachment 464 | information but does not download the data, and 'download' which 465 | downloads the attachment data to store locally. Default 466 | 'reference'. 467 | 468 | Returns: 469 | A list of message objects. 470 | 471 | Raises: 472 | googleapiclient.errors.HttpError: There was an error executing the 473 | HTTP request. 474 | 475 | """ 476 | 477 | 478 | if labels is None: 479 | labels = [] 480 | 481 | labels.append(label.SPAM) 482 | return self.get_messages(user_id, labels, query, attachments, True) 483 | 484 | def get_messages( 485 | self, 486 | user_id: str = 'me', 487 | labels: Optional[List[Label]] = None, 488 | query: str = '', 489 | attachments: str = 'reference', 490 | include_spam_trash: bool = False 491 | ) -> List[Message]: 492 | """ 493 | Gets messages from your account. 494 | 495 | Args: 496 | user_id: the user's email address. Default 'me', the authenticated 497 | user. 498 | labels: label IDs messages must match. 499 | query: a Gmail query to match. 500 | attachments: accepted values are 'ignore' which completely 501 | ignores all attachments, 'reference' which includes attachment 502 | information but does not download the data, and 'download' which 503 | downloads the attachment data to store locally. Default 504 | 'reference'. 505 | include_spam_trash: whether to include messages from spam or trash. 506 | 507 | Returns: 508 | A list of message objects. 509 | 510 | Raises: 511 | googleapiclient.errors.HttpError: There was an error executing the 512 | HTTP request. 513 | 514 | """ 515 | 516 | if labels is None: 517 | labels = [] 518 | 519 | labels_ids = [ 520 | lbl.id if isinstance(lbl, Label) else lbl for lbl in labels 521 | ] 522 | 523 | try: 524 | response = self.service.users().messages().list( 525 | userId=user_id, 526 | q=query, 527 | labelIds=labels_ids, 528 | includeSpamTrash=include_spam_trash 529 | ).execute() 530 | 531 | message_refs = [] 532 | if 'messages' in response: # ensure request was successful 533 | message_refs.extend(response['messages']) 534 | 535 | while 'nextPageToken' in response: 536 | page_token = response['nextPageToken'] 537 | response = self.service.users().messages().list( 538 | userId=user_id, 539 | q=query, 540 | labelIds=labels_ids, 541 | includeSpamTrash=include_spam_trash, 542 | pageToken=page_token 543 | ).execute() 544 | 545 | message_refs.extend(response['messages']) 546 | 547 | return self._get_messages_from_refs(user_id, message_refs, 548 | attachments) 549 | 550 | except HttpError as error: 551 | # Pass along the error 552 | raise error 553 | 554 | def list_labels(self, user_id: str = 'me') -> List[Label]: 555 | """ 556 | Retrieves all labels for the specified user. 557 | 558 | These Label objects are to be used with other functions like 559 | modify_labels(). 560 | 561 | Args: 562 | user_id: The user's email address. By default, the authenticated 563 | user. 564 | 565 | Returns: 566 | The list of Label objects. 567 | 568 | Raises: 569 | googleapiclient.errors.HttpError: There was an error executing the 570 | HTTP request. 571 | 572 | """ 573 | 574 | try: 575 | res = self.service.users().labels().list( 576 | userId=user_id 577 | ).execute() 578 | 579 | except HttpError as error: 580 | # Pass along the error 581 | raise error 582 | 583 | else: 584 | labels = [Label(name=x['name'], id=x['id']) for x in res['labels']] 585 | return labels 586 | 587 | def create_label( 588 | self, 589 | name: str, 590 | user_id: str = 'me' 591 | ) -> Label: 592 | """ 593 | Creates a new label. 594 | 595 | Args: 596 | name: The display name of the new label. 597 | user_id: The user's email address. By default, the authenticated 598 | user. 599 | 600 | Returns: 601 | The created Label object. 602 | 603 | Raises: 604 | googleapiclient.errors.HttpError: There was an error executing the 605 | HTTP request. 606 | 607 | """ 608 | 609 | body = { 610 | "name": name, 611 | 612 | # TODO: In the future, can add the following fields: 613 | # "messageListVisibility" 614 | # "labelListVisibility" 615 | # "color" 616 | } 617 | 618 | try: 619 | res = self.service.users().labels().create( 620 | userId=user_id, 621 | body=body 622 | ).execute() 623 | 624 | except HttpError as error: 625 | # Pass along the error 626 | raise error 627 | 628 | else: 629 | return Label(res['name'], res['id']) 630 | 631 | def delete_label(self, label: Label, user_id: str = 'me') -> None: 632 | """ 633 | Deletes a label. 634 | 635 | Args: 636 | label: The label to delete. 637 | user_id: The user's email address. By default, the authenticated 638 | user. 639 | 640 | Raises: 641 | googleapiclient.errors.HttpError: There was an error executing the 642 | HTTP request. 643 | 644 | """ 645 | 646 | try: 647 | self.service.users().labels().delete( 648 | userId=user_id, 649 | id=label.id 650 | ).execute() 651 | 652 | except HttpError as error: 653 | # Pass along the error 654 | raise error 655 | 656 | def _get_messages_from_refs( 657 | self, 658 | user_id: str, 659 | message_refs: List[dict], 660 | attachments: str = 'reference', 661 | parallel: bool = True 662 | ) -> List[Message]: 663 | """ 664 | Retrieves the actual messages from a list of references. 665 | 666 | Args: 667 | user_id: The account the messages belong to. 668 | message_refs: A list of message references with keys id, threadId. 669 | attachments: Accepted values are 'ignore' which completely ignores 670 | all attachments, 'reference' which includes attachment 671 | information but does not download the data, and 'download' 672 | which downloads the attachment data to store locally. Default 673 | 'reference'. 674 | parallel: Whether to retrieve messages in parallel. Default true. 675 | Currently parallelization is always on, since there is no 676 | reason to do otherwise. 677 | 678 | 679 | Returns: 680 | A list of Message objects. 681 | 682 | Raises: 683 | googleapiclient.errors.HttpError: There was an error executing the 684 | HTTP request. 685 | 686 | """ 687 | 688 | if not message_refs: 689 | return [] 690 | 691 | if not parallel: 692 | return [self._build_message_from_ref(user_id, ref, attachments) 693 | for ref in message_refs] 694 | 695 | max_num_threads = 12 # empirically chosen, prevents throttling 696 | target_msgs_per_thread = 10 # empirically chosen 697 | num_threads = min( 698 | math.ceil(len(message_refs) / target_msgs_per_thread), 699 | max_num_threads 700 | ) 701 | batch_size = math.ceil(len(message_refs) / num_threads) 702 | message_lists = [None] * num_threads 703 | 704 | def thread_download_batch(thread_num): 705 | gmail = Gmail(_creds=self.creds) 706 | 707 | start = thread_num * batch_size 708 | end = min(len(message_refs), (thread_num + 1) * batch_size) 709 | message_lists[thread_num] = [ 710 | gmail._build_message_from_ref( 711 | user_id, message_refs[i], attachments 712 | ) 713 | for i in range(start, end) 714 | ] 715 | 716 | gmail.service.close() 717 | 718 | threads = [ 719 | threading.Thread(target=thread_download_batch, args=(i,)) 720 | for i in range(num_threads) 721 | ] 722 | 723 | for t in threads: 724 | t.start() 725 | 726 | for t in threads: 727 | t.join() 728 | 729 | return sum(message_lists, []) 730 | 731 | def _build_message_from_ref( 732 | self, 733 | user_id: str, 734 | message_ref: dict, 735 | attachments: str = 'reference' 736 | ) -> Message: 737 | """ 738 | Creates a Message object from a reference. 739 | 740 | Args: 741 | user_id: The username of the account the message belongs to. 742 | message_ref: The message reference object returned from the Gmail 743 | API. 744 | attachments: Accepted values are 'ignore' which completely ignores 745 | all attachments, 'reference' which includes attachment 746 | information but does not download the data, and 'download' which 747 | downloads the attachment data to store locally. Default 748 | 'reference'. 749 | 750 | Returns: 751 | The Message object. 752 | 753 | Raises: 754 | googleapiclient.errors.HttpError: There was an error executing the 755 | HTTP request. 756 | 757 | """ 758 | 759 | try: 760 | # Get message JSON 761 | message = self.service.users().messages().get( 762 | userId=user_id, id=message_ref['id'] 763 | ).execute() 764 | 765 | except HttpError as error: 766 | # Pass along the error 767 | raise error 768 | 769 | else: 770 | msg_id = message['id'] 771 | thread_id = message['threadId'] 772 | label_ids = [] 773 | if 'labelIds' in message: 774 | user_labels = {x.id: x for x in self.list_labels(user_id=user_id)} 775 | label_ids = [user_labels[x] for x in message['labelIds']] 776 | snippet = html.unescape(message['snippet']) 777 | 778 | payload = message['payload'] 779 | headers = payload['headers'] 780 | 781 | # Get header fields (date, from, to, subject) 782 | date = '' 783 | sender = '' 784 | recipient = '' 785 | subject = '' 786 | msg_hdrs = {} 787 | cc = [] 788 | bcc = [] 789 | for hdr in headers: 790 | if hdr['name'].lower() == 'date': 791 | try: 792 | date = str(parser.parse(hdr['value']).astimezone()) 793 | except Exception: 794 | date = hdr['value'] 795 | elif hdr['name'].lower() == 'from': 796 | sender = hdr['value'] 797 | elif hdr['name'].lower() == 'to': 798 | recipient = hdr['value'] 799 | elif hdr['name'].lower() == 'subject': 800 | subject = hdr['value'] 801 | elif hdr['name'].lower() == 'cc': 802 | cc = hdr['value'].split(', ') 803 | elif hdr['name'].lower() == 'bcc': 804 | bcc = hdr['value'].split(', ') 805 | 806 | msg_hdrs[hdr['name']] = hdr['value'] 807 | 808 | parts = self._evaluate_message_payload( 809 | payload, user_id, message_ref['id'], attachments 810 | ) 811 | 812 | plain_msg = None 813 | html_msg = None 814 | attms = [] 815 | for part in parts: 816 | if part['part_type'] == 'plain': 817 | if plain_msg is None: 818 | plain_msg = part['body'] 819 | else: 820 | plain_msg += '\n' + part['body'] 821 | elif part['part_type'] == 'html': 822 | if html_msg is None: 823 | html_msg = part['body'] 824 | else: 825 | html_msg += '
' + part['body'] 826 | elif part['part_type'] == 'attachment': 827 | attm = Attachment(self.service, user_id, msg_id, 828 | part['attachment_id'], part['filename'], 829 | part['filetype'], part['data']) 830 | attms.append(attm) 831 | 832 | return Message( 833 | self.service, 834 | self.creds, 835 | user_id, 836 | msg_id, 837 | thread_id, 838 | recipient, 839 | sender, 840 | subject, 841 | date, 842 | snippet, 843 | plain_msg, 844 | html_msg, 845 | label_ids, 846 | attms, 847 | msg_hdrs, 848 | cc, 849 | bcc 850 | ) 851 | 852 | def _evaluate_message_payload( 853 | self, 854 | payload: dict, 855 | user_id: str, 856 | msg_id: str, 857 | attachments: str = 'reference' 858 | ) -> List[dict]: 859 | """ 860 | Recursively evaluates a message payload. 861 | 862 | Args: 863 | payload: The message payload object (response from Gmail API). 864 | user_id: The current account address (default 'me'). 865 | msg_id: The id of the message. 866 | attachments: Accepted values are 'ignore' which completely ignores 867 | all attachments, 'reference' which includes attachment 868 | information but does not download the data, and 'download' which 869 | downloads the attachment data to store locally. Default 870 | 'reference'. 871 | 872 | Returns: 873 | A list of message parts. 874 | 875 | Raises: 876 | googleapiclient.errors.HttpError: There was an error executing the 877 | HTTP request. 878 | 879 | """ 880 | 881 | if 'attachmentId' in payload['body']: # if it's an attachment 882 | if attachments == 'ignore': 883 | return [] 884 | 885 | att_id = payload['body']['attachmentId'] 886 | filename = payload['filename'] 887 | if not filename: 888 | filename = 'unknown' 889 | 890 | obj = { 891 | 'part_type': 'attachment', 892 | 'filetype': payload['mimeType'], 893 | 'filename': filename, 894 | 'attachment_id': att_id, 895 | 'data': None 896 | } 897 | 898 | if attachments == 'reference': 899 | return [obj] 900 | 901 | else: # attachments == 'download' 902 | if 'data' in payload['body']: 903 | data = payload['body']['data'] 904 | else: 905 | res = self.service.users().messages().attachments().get( 906 | userId=user_id, messageId=msg_id, id=att_id 907 | ).execute() 908 | data = res['data'] 909 | 910 | file_data = base64.urlsafe_b64decode(data) 911 | obj['data'] = file_data 912 | return [obj] 913 | 914 | elif payload['mimeType'] == 'text/html': 915 | data = payload['body']['data'] 916 | data = base64.urlsafe_b64decode(data) 917 | body = BeautifulSoup(data, 'lxml', from_encoding='utf-8').body 918 | return [{ 'part_type': 'html', 'body': str(body) }] 919 | 920 | elif payload['mimeType'] == 'text/plain': 921 | data = payload['body']['data'] 922 | data = base64.urlsafe_b64decode(data) 923 | body = data.decode('UTF-8') 924 | return [{ 'part_type': 'plain', 'body': body }] 925 | 926 | elif payload['mimeType'].startswith('multipart'): 927 | ret = [] 928 | if 'parts' in payload: 929 | for part in payload['parts']: 930 | ret.extend(self._evaluate_message_payload(part, user_id, msg_id, 931 | attachments)) 932 | return ret 933 | 934 | return [] 935 | 936 | def _create_message( 937 | self, 938 | sender: str, 939 | to: str, 940 | subject: str = '', 941 | msg_html: str = None, 942 | msg_plain: str = None, 943 | cc: List[str] = None, 944 | bcc: List[str] = None, 945 | attachments: List[str] = None, 946 | signature: bool = False, 947 | user_id: str = 'me' 948 | ) -> dict: 949 | """ 950 | Creates the raw email message to be sent. 951 | 952 | Args: 953 | sender: The email address the message is being sent from. 954 | to: The email address the message is being sent to. 955 | subject: The subject line of the email. 956 | msg_html: The HTML message of the email. 957 | msg_plain: The plain text alternate message of the email (for slow 958 | or old browsers). 959 | cc: The list of email addresses to be Cc'd. 960 | bcc: The list of email addresses to be Bcc'd 961 | attachments: A list of attachment file paths. 962 | signature: Whether the account signature should be added to the 963 | message. Will add the signature to your HTML message only, or a 964 | create a HTML message if none exists. 965 | 966 | Returns: 967 | The message dict. 968 | 969 | """ 970 | 971 | msg = MIMEMultipart('mixed' if attachments else 'alternative') 972 | msg['To'] = to 973 | msg['From'] = sender 974 | msg['Subject'] = subject 975 | 976 | if cc: 977 | msg['Cc'] = ', '.join(cc) 978 | 979 | if bcc: 980 | msg['Bcc'] = ', '.join(bcc) 981 | 982 | if signature: 983 | m = re.match(r'.+\s<(?P.+@.+\..+)>', sender) 984 | address = m.group('addr') if m else sender 985 | account_sig = self._get_alias_info(address, user_id)['signature'] 986 | 987 | if msg_html is None: 988 | msg_html = '' 989 | 990 | msg_html += "

" + account_sig 991 | 992 | attach_plain = MIMEMultipart('alternative') if attachments else msg 993 | attach_html = MIMEMultipart('related') if attachments else msg 994 | 995 | if msg_plain: 996 | attach_plain.attach(MIMEText(msg_plain, 'plain')) 997 | 998 | if msg_html: 999 | attach_html.attach(MIMEText(msg_html, 'html')) 1000 | 1001 | if attachments: 1002 | attach_plain.attach(attach_html) 1003 | msg.attach(attach_plain) 1004 | 1005 | self._ready_message_with_attachments(msg, attachments) 1006 | 1007 | return { 1008 | 'raw': base64.urlsafe_b64encode(msg.as_string().encode()).decode() 1009 | } 1010 | 1011 | def _ready_message_with_attachments( 1012 | self, 1013 | msg: MIMEMultipart, 1014 | attachments: List[str] 1015 | ) -> None: 1016 | """ 1017 | Converts attachment filepaths to MIME objects and adds them to msg. 1018 | 1019 | Args: 1020 | msg: The message to add attachments to. 1021 | attachments: A list of attachment file paths. 1022 | 1023 | """ 1024 | 1025 | for filepath in attachments: 1026 | content_type, encoding = mimetypes.guess_type(filepath) 1027 | 1028 | if content_type is None or encoding is not None: 1029 | content_type = 'application/octet-stream' 1030 | 1031 | main_type, sub_type = content_type.split('/', 1) 1032 | with open(filepath, 'rb') as file: 1033 | raw_data = file.read() 1034 | 1035 | attm: MIMEBase 1036 | if main_type == 'text': 1037 | attm = MIMEText(raw_data.decode('UTF-8'), _subtype=sub_type) 1038 | elif main_type == 'image': 1039 | attm = MIMEImage(raw_data, _subtype=sub_type) 1040 | elif main_type == 'audio': 1041 | attm = MIMEAudio(raw_data, _subtype=sub_type) 1042 | elif main_type == 'application': 1043 | attm = MIMEApplication(raw_data, _subtype=sub_type) 1044 | else: 1045 | attm = MIMEBase(main_type, sub_type) 1046 | attm.set_payload(raw_data) 1047 | 1048 | fname = os.path.basename(filepath) 1049 | attm.add_header('Content-Disposition', 'attachment', filename=fname) 1050 | msg.attach(attm) 1051 | 1052 | def _get_alias_info( 1053 | self, 1054 | send_as_email: str, 1055 | user_id: str = 'me' 1056 | ) -> dict: 1057 | """ 1058 | Returns the alias info of an email address on the authenticated 1059 | account. 1060 | 1061 | Response data is of the following form: 1062 | { 1063 | "sendAsEmail": string, 1064 | "displayName": string, 1065 | "replyToAddress": string, 1066 | "signature": string, 1067 | "isPrimary": boolean, 1068 | "isDefault": boolean, 1069 | "treatAsAlias": boolean, 1070 | "smtpMsa": { 1071 | "host": string, 1072 | "port": integer, 1073 | "username": string, 1074 | "password": string, 1075 | "securityMode": string 1076 | }, 1077 | "verificationStatus": string 1078 | } 1079 | 1080 | Args: 1081 | send_as_email: The alias account information is requested for 1082 | (could be the primary account). 1083 | user_id: The user ID of the authenticated user the account the 1084 | alias is for (default "me"). 1085 | 1086 | Returns: 1087 | The dict of alias info associated with the account. 1088 | 1089 | """ 1090 | 1091 | req = self.service.users().settings().sendAs().get( 1092 | sendAsEmail=send_as_email, userId=user_id) 1093 | 1094 | res = req.execute() 1095 | return res 1096 | -------------------------------------------------------------------------------- /simplegmail/label.py: -------------------------------------------------------------------------------- 1 | """ 2 | File: label.py 3 | -------------- 4 | Gmail reserved system labels and the Label class. 5 | 6 | """ 7 | 8 | 9 | class Label: 10 | """ 11 | A Gmail label object. 12 | 13 | This class should not typically be constructed directly but rather returned 14 | from Gmail.list_labels(). 15 | 16 | Args: 17 | name: The name of the Label. 18 | id: The ID of the label. 19 | 20 | Attributes: 21 | name (str): The name of the Label. 22 | id (str): The ID of the label. 23 | 24 | """ 25 | 26 | def __init__(self, name: str, id: str) -> None: 27 | self.name = name 28 | self.id = id 29 | 30 | def __repr__(self) -> str: 31 | return f'Label(name={self.name!r}, id={self.id!r})' 32 | 33 | def __str__(self) -> str: 34 | return self.name 35 | 36 | def __hash__(self) -> int: 37 | return hash(self.id) 38 | 39 | def __eq__(self, other) -> bool: 40 | if isinstance(other, str): 41 | # Can be compared to a string of the label ID 42 | return self.id == other 43 | elif isinstance(other, Label): 44 | return self.id == other.id 45 | else: 46 | return False 47 | 48 | 49 | INBOX = Label('INBOX', 'INBOX') 50 | SPAM = Label('SPAM', 'SPAM') 51 | TRASH = Label('TRASH', 'TRASH') 52 | UNREAD = Label('UNREAD', 'UNREAD') 53 | STARRED = Label('STARRED', 'STARRED') 54 | SENT = Label('SENT', 'SENT') 55 | IMPORTANT = Label('IMPORTANT', 'IMPORTANT') 56 | DRAFT = Label('DRAFT', 'DRAFT') 57 | PERSONAL = Label('CATEGORY_PERSONAL', 'CATEGORY_PERSONAL') 58 | SOCIAL = Label('CATEGORY_SOCIAL', 'CATEGORY_SOCIAL') 59 | PROMOTIONS = Label('CATEGORY_PROMOTIONS', 'CATEGORY_PROMOTIONS') 60 | UPDATES = Label('CATEGORY_UPDATES', 'CATEGORY_UPDATES') 61 | FORUMS = Label('CATEGORY_FORUMS', 'CATEGORY_FORUMS') 62 | -------------------------------------------------------------------------------- /simplegmail/message.py: -------------------------------------------------------------------------------- 1 | """ 2 | File: message.py 3 | ---------------- 4 | This module contains the implementation of the Message object. 5 | 6 | """ 7 | 8 | from typing import List, Optional, Union 9 | 10 | from httplib2 import Http 11 | from googleapiclient.errors import HttpError 12 | 13 | from simplegmail import label 14 | from simplegmail.attachment import Attachment 15 | from simplegmail.label import Label 16 | 17 | 18 | class Message(object): 19 | """ 20 | The Message class for emails in your Gmail mailbox. This class should not 21 | be manually constructed. Contains all information about the associated 22 | message, and can be used to modify the message's labels (e.g., marking as 23 | read/unread, archiving, moving to trash, starring, etc.). 24 | 25 | Args: 26 | service: the Gmail service object. 27 | user_id: the username of the account the message belongs to. 28 | msg_id: the message id. 29 | thread_id: the thread id. 30 | recipient: who the message was addressed to. 31 | sender: who the message was sent from. 32 | subject: the subject line of the message. 33 | date: the date the message was sent. 34 | snippet: the snippet line for the message. 35 | plain: the plaintext contents of the message. Default None. 36 | html: the HTML contents of the message. Default None. 37 | label_ids: the ids of labels associated with this message. Default []. 38 | attachments: a list of attachments for the message. Default []. 39 | headers: a dict of header values. Default {} 40 | cc: who the message was cc'd on the message. 41 | bcc: who the message was bcc'd on the message. 42 | 43 | Attributes: 44 | _service (googleapiclient.discovery.Resource): the Gmail service object. 45 | user_id (str): the username of the account the message belongs to. 46 | id (str): the message id. 47 | recipient (str): who the message was addressed to. 48 | sender (str): who the message was sent from. 49 | subject (str): the subject line of the message. 50 | date (str): the date the message was sent. 51 | snippet (str): the snippet line for the message. 52 | plain (str): the plaintext contents of the message. 53 | html (str): the HTML contents of the message. 54 | label_ids (List[str]): the ids of labels associated with this message. 55 | attachments (List[Attachment]): a list of attachments for the message. 56 | headers (dict): a dict of header values. 57 | cc (List[str]): who the message was cc'd on the message. 58 | bcc (List[str]): who the message was bcc'd on the message. 59 | 60 | """ 61 | 62 | def __init__( 63 | self, 64 | service: 'googleapiclient.discovery.Resource', 65 | creds: 'oauth2client.client.OAuth2Credentials', 66 | user_id: str, 67 | msg_id: str, 68 | thread_id: str, 69 | recipient: str, 70 | sender: str, 71 | subject: str, 72 | date: str, 73 | snippet, 74 | plain: Optional[str] = None, 75 | html: Optional[str] = None, 76 | label_ids: Optional[List[str]] = None, 77 | attachments: Optional[List[Attachment]] = None, 78 | headers: Optional[dict] = None, 79 | cc: Optional[List[str]] = None, 80 | bcc: Optional[List[str]] = None 81 | ) -> None: 82 | self._service = service 83 | self.creds = creds 84 | self.user_id = user_id 85 | self.id = msg_id 86 | self.thread_id = thread_id 87 | self.recipient = recipient 88 | self.sender = sender 89 | self.subject = subject 90 | self.date = date 91 | self.snippet = snippet 92 | self.plain = plain 93 | self.html = html 94 | self.label_ids = label_ids or [] 95 | self.attachments = attachments or [] 96 | self.headers = headers or {} 97 | self.cc = cc or [] 98 | self.bcc = bcc or [] 99 | 100 | @property 101 | def service(self) -> 'googleapiclient.discovery.Resource': 102 | if self.creds.access_token_expired: 103 | self.creds.refresh(Http()) 104 | 105 | return self._service 106 | 107 | def __repr__(self) -> str: 108 | """Represents the object by its sender, recipient, and id.""" 109 | 110 | return ( 111 | f'Message(to: {self.recipient}, from: {self.sender}, id: {self.id})' 112 | ) 113 | 114 | def mark_as_read(self) -> None: 115 | """ 116 | Marks this message as read (by removing the UNREAD label). 117 | 118 | Raises: 119 | googleapiclient.errors.HttpError: There was an error executing the 120 | HTTP request. 121 | 122 | """ 123 | 124 | self.remove_label(label.UNREAD) 125 | 126 | def mark_as_unread(self) -> None: 127 | """ 128 | Marks this message as unread (by adding the UNREAD label). 129 | 130 | Raises: 131 | googleapiclient.errors.HttpError: There was an error executing the 132 | HTTP request. 133 | 134 | """ 135 | 136 | self.add_label(label.UNREAD) 137 | 138 | def mark_as_spam(self) -> None: 139 | """ 140 | Marks this message as spam (by adding the SPAM label). 141 | 142 | Raises: 143 | googleapiclient.errors.HttpError: There was an error executing the 144 | HTTP request. 145 | 146 | """ 147 | 148 | self.add_label(label.SPAM) 149 | 150 | def mark_as_not_spam(self) -> None: 151 | """ 152 | Marks this message as not spam (by removing the SPAM label). 153 | 154 | Raises: 155 | googleapiclient.errors.HttpError: There was an error executing the 156 | HTTP request. 157 | 158 | """ 159 | 160 | self.remove_label(label.SPAM) 161 | 162 | def mark_as_important(self) -> None: 163 | """ 164 | Marks this message as important (by adding the IMPORTANT label). 165 | 166 | Raises: 167 | googleapiclient.errors.HttpError: There was an error executing the 168 | HTTP request. 169 | 170 | """ 171 | 172 | self.add_label(label.IMPORTANT) 173 | 174 | def mark_as_not_important(self) -> None: 175 | """ 176 | Marks this message as not important (by removing the IMPORTANT label). 177 | 178 | Raises: 179 | googleapiclient.errors.HttpError: There was an error executing the 180 | HTTP request. 181 | 182 | """ 183 | 184 | self.remove_label(label.IMPORTANT) 185 | 186 | def star(self) -> None: 187 | """ 188 | Stars this message (by adding the STARRED label). 189 | 190 | Raises: 191 | googleapiclient.errors.HttpError: There was an error executing the 192 | HTTP request. 193 | 194 | """ 195 | 196 | self.add_label(label.STARRED) 197 | 198 | def unstar(self) -> None: 199 | """ 200 | Unstars this message (by removing the STARRED label). 201 | 202 | Raises: 203 | googleapiclient.errors.HttpError: There was an error executing the 204 | HTTP request. 205 | 206 | """ 207 | 208 | self.remove_label(label.STARRED) 209 | 210 | def move_to_inbox(self) -> None: 211 | """ 212 | Moves an archived message to your inbox (by adding the INBOX label). 213 | 214 | """ 215 | 216 | self.add_label(label.INBOX) 217 | 218 | def archive(self) -> None: 219 | """ 220 | Archives the message (removes from inbox by removing the INBOX label). 221 | 222 | Raises: 223 | googleapiclient.errors.HttpError: There was an error executing the 224 | HTTP request. 225 | 226 | """ 227 | 228 | self.remove_label(label.INBOX) 229 | 230 | def trash(self) -> None: 231 | """ 232 | Moves this message to the trash. 233 | 234 | Raises: 235 | googleapiclient.errors.HttpError: There was an error executing the 236 | HTTP request. 237 | 238 | """ 239 | 240 | try: 241 | res = self._service.users().messages().trash( 242 | userId=self.user_id, id=self.id, 243 | ).execute() 244 | 245 | except HttpError as error: 246 | # Pass error along 247 | raise error 248 | 249 | else: 250 | assert label.TRASH in res['labelIds'], \ 251 | f'An error occurred in a call to `trash`.' 252 | 253 | self.label_ids = res['labelIds'] 254 | 255 | def untrash(self) -> None: 256 | """ 257 | Removes this message from the trash. 258 | 259 | Raises: 260 | googleapiclient.errors.HttpError: There was an error executing the 261 | HTTP request. 262 | 263 | """ 264 | 265 | try: 266 | res = self._service.users().messages().untrash( 267 | userId=self.user_id, id=self.id, 268 | ).execute() 269 | 270 | except HttpError as error: 271 | # Pass error along 272 | raise error 273 | 274 | else: 275 | assert label.TRASH not in res['labelIds'], \ 276 | f'An error occurred in a call to `untrash`.' 277 | 278 | self.label_ids = res['labelIds'] 279 | 280 | def move_from_inbox(self, to: Union[Label, str]) -> None: 281 | """ 282 | Moves a message from your inbox to another label "folder". 283 | 284 | Args: 285 | to: The label to move to. 286 | 287 | Raises: 288 | googleapiclient.errors.HttpError: There was an error executing the 289 | HTTP request. 290 | 291 | """ 292 | 293 | self.modify_labels(to, label.INBOX) 294 | 295 | def add_label(self, to_add: Union[Label, str]) -> None: 296 | """ 297 | Adds the given label to the message. 298 | 299 | Args: 300 | to_add: The label to add. 301 | 302 | Raises: 303 | googleapiclient.errors.HttpError: There was an error executing the 304 | HTTP request. 305 | 306 | """ 307 | 308 | self.add_labels([to_add]) 309 | 310 | def add_labels(self, to_add: Union[List[Label], List[str]]) -> None: 311 | """ 312 | Adds the given labels to the message. 313 | 314 | Args: 315 | to_add: The list of labels to add. 316 | 317 | Raises: 318 | googleapiclient.errors.HttpError: There was an error executing the 319 | HTTP request. 320 | 321 | """ 322 | 323 | self.modify_labels(to_add, []) 324 | 325 | def remove_label(self, to_remove: Union[Label, str]) -> None: 326 | """ 327 | Removes the given label from the message. 328 | 329 | Args: 330 | to_remove: The label to remove. 331 | 332 | Raises: 333 | googleapiclient.errors.HttpError: There was an error executing the 334 | HTTP request. 335 | 336 | """ 337 | 338 | self.remove_labels([to_remove]) 339 | 340 | def remove_labels(self, to_remove: Union[List[Label], List[str]]) -> None: 341 | """ 342 | Removes the given labels from the message. 343 | 344 | Args: 345 | to_remove: The list of labels to remove. 346 | 347 | Raises: 348 | googleapiclient.errors.HttpError: There was an error executing the 349 | HTTP request. 350 | 351 | """ 352 | 353 | self.modify_labels([], to_remove) 354 | 355 | def modify_labels( 356 | self, 357 | to_add: Union[Label, str, List[Label], List[str]], 358 | to_remove: Union[Label, str, List[Label], List[str]] 359 | ) -> None: 360 | """ 361 | Adds or removes the specified label. 362 | 363 | Args: 364 | to_add: The label or list of labels to add. 365 | to_remove: The label or list of labels to remove. 366 | 367 | Raises: 368 | googleapiclient.errors.HttpError: There was an error executing the 369 | HTTP request. 370 | 371 | """ 372 | 373 | if isinstance(to_add, (Label, str)): 374 | to_add = [to_add] 375 | 376 | if isinstance(to_remove, (Label, str)): 377 | to_remove = [to_remove] 378 | 379 | try: 380 | res = self._service.users().messages().modify( 381 | userId=self.user_id, id=self.id, 382 | body=self._create_update_labels(to_add, to_remove) 383 | ).execute() 384 | 385 | except HttpError as error: 386 | # Pass along error 387 | raise error 388 | 389 | else: 390 | assert all([lbl in res['labelIds'] for lbl in to_add]) \ 391 | and all([lbl not in res['labelIds'] for lbl in to_remove]), \ 392 | 'An error occurred while modifying message label.' 393 | 394 | self.label_ids = res['labelIds'] 395 | 396 | def _create_update_labels( 397 | self, 398 | to_add: Union[List[Label], List[str]] = None, 399 | to_remove: Union[List[Label], List[str]] = None 400 | ) -> dict: 401 | """ 402 | Creates an object for updating message label. 403 | 404 | Args: 405 | to_add: A list of labels to add. 406 | to_remove: A list of labels to remove. 407 | 408 | Returns: 409 | The modify labels object to pass to the Gmail API. 410 | 411 | """ 412 | 413 | if to_add is None: 414 | to_add = [] 415 | 416 | if to_remove is None: 417 | to_remove = [] 418 | 419 | return { 420 | 'addLabelIds': [ 421 | lbl.id if isinstance(lbl, Label) else lbl for lbl in to_add 422 | ], 423 | 'removeLabelIds': [ 424 | lbl.id if isinstance(lbl, Label) else lbl for lbl in to_remove 425 | ] 426 | } 427 | -------------------------------------------------------------------------------- /simplegmail/query.py: -------------------------------------------------------------------------------- 1 | """ 2 | File: query.py 3 | -------------- 4 | This module contains functions for constructing Gmail search queries. 5 | 6 | """ 7 | 8 | from typing import List, Union 9 | 10 | 11 | def construct_query(*query_dicts, **query_terms) -> str: 12 | """ 13 | Constructs a query from either: 14 | 15 | (1) a list of dictionaries representing queries to "or" (only one of the 16 | queries needs to match). Each of these dictionaries should be made up 17 | of keywords as specified below. 18 | 19 | E.g.: 20 | construct_query( 21 | {'sender': 'someone@email.com', 'subject': 'Meeting'}, 22 | {'sender': ['boss@inc.com', 'hr@inc.com'], 'newer_than': (5, "day")} 23 | ) 24 | 25 | Will return a query which matches all messages that either match the 26 | all the fields in the first dictionary or match all the fields in the 27 | second dictionary. 28 | 29 | -- OR -- 30 | 31 | (2) Keyword arguments specifying individual query terms (each keyword will 32 | be and'd). 33 | 34 | 35 | To negate any term, set it as the value of "exclude_" instead of 36 | "" (for example, since `labels=['finance', 'bills']` will match 37 | messages with both the 'finance' and 'bills' labels, 38 | `exclude_labels=['finance', 'bills']` will exclude messages that have both 39 | labels. To exclude either you must specify 40 | `exclude_labels=[['finance'], ['bills']]`, which negates 41 | '(finance OR bills)'. 42 | 43 | For all keywords whose values are not booleans, you can indicate you'd 44 | like to "and" multiple values by placing them in a tuple (), or "or" 45 | multiple values by placing them in a list []. 46 | 47 | Keyword Arguments: 48 | sender (str): Who the message is from. 49 | E.g.: sender='someone@email.com' 50 | sender=['john@doe.com', 'jane@doe.com'] # OR 51 | 52 | recipient (str): Who the message is to. 53 | E.g.: recipient='someone@email.com' 54 | 55 | subject (str): The subject of the message. E.g.: subject='Meeting' 56 | 57 | labels (List[str]): Labels applied to the message (all must match). 58 | E.g.: labels=['Work', 'HR'] # Work AND HR 59 | labels=[['Work', 'HR'], ['Home']] # (Work AND HR) OR Home 60 | 61 | attachment (bool): The message has an attachment. E.g.: attachment=True 62 | 63 | spec_attachment (str): The message has an attachment with a 64 | specific name or file type. 65 | E.g.: spec_attachment='pdf', 66 | spec_attachment='homework.docx' 67 | 68 | exact_phrase (str): The message contains an exact phrase. 69 | E.g.: exact_phrase='I need help' 70 | exact_phrase=('help me', 'homework') # AND 71 | 72 | cc (str): Recipient in the cc field. E.g.: cc='john@email.com' 73 | 74 | bcc (str): Recipient in the bcc field. E.g.: bcc='jane@email.com' 75 | 76 | before (str): The message was sent before a date. 77 | E.g.: before='2004/04/27' 78 | 79 | after (str): The message was sent after a date. 80 | E.g.: after='2004/04/27' 81 | 82 | older_than (Tuple[int, str]): The message was sent before a given 83 | time period. 84 | E.g.: older_than=(3, "day") 85 | older_than=(1, "month") 86 | older_than=(2, "year") 87 | 88 | newer_than (Tuple[int, str]): The message was sent after a given 89 | time period. 90 | E.g.: newer_than=(3, "day") 91 | newer_than=(1, "month") 92 | newer_than=(2, "year") 93 | 94 | near_words (Tuple[str, str, int]): The message contains two words near 95 | each other. (The third item is the max number of words between the 96 | two words). E.g.: near_words=('CS', 'hw', 5) 97 | 98 | starred (bool): The message was starred. E.g.: starred=True 99 | 100 | snoozed (bool): The message was snoozed. E.g.: snoozed=True 101 | 102 | unread (bool): The message is unread. E.g.: unread=True 103 | 104 | read (bool): The message has been read. E.g.: read=True 105 | 106 | important (bool): The message was marked as important. 107 | E.g.: important=True 108 | 109 | drive (bool): The message contains a Google Drive attachment. 110 | E.g.: drive=True 111 | 112 | docs (bool): The message contains a Google Docs attachment. 113 | E.g.: docs=True 114 | 115 | sheets (bool): The message contains a Google Sheets attachment. 116 | E.g.: sheets=True 117 | 118 | slides (bool): The message contains a Google Slides attachment. 119 | E.g.: slides=True 120 | 121 | list (str): The message is from a mailing list. 122 | E.g.: list=info@example.com 123 | 124 | in (str): The message is in a folder. 125 | E.g.: in=anywhere 126 | in=chats 127 | in=trash 128 | 129 | delivered_to (str): The message was delivered to a given address. 130 | E.g.: deliveredto=username@gmail.com 131 | 132 | category (str): The message is in a given category. 133 | E.g.: category=primary 134 | 135 | larger (str): The message is larger than a certain size in bytes. 136 | E.g.: larger=10M 137 | 138 | smaller (str): The message is smaller than a certain size in bytes 139 | E.g.: smaller=10M 140 | 141 | id (str): The message has a given message-id header. 142 | E.g.: id=339376385@example.com 143 | 144 | has (str): The message has a given attribute. 145 | E.g.: has=userlabels 146 | has=nouserlabels 147 | 148 | Note: Labels are only added to a message, and not an entire 149 | conversation. 150 | 151 | Returns: 152 | The query string. 153 | 154 | """ 155 | 156 | if query_dicts: 157 | return _or([construct_query(**query) for query in query_dicts]) 158 | 159 | terms = [] 160 | for key, val in query_terms.items(): 161 | exclude = False 162 | if key.startswith('exclude'): 163 | exclude = True 164 | key = key[len('exclude_'):] 165 | 166 | query_fn = globals()[f"_{key}"] 167 | conjunction = _and if isinstance(val, tuple) else _or 168 | 169 | if key in ['newer_than', 'older_than', 'near_words']: 170 | if isinstance(val[0], (tuple, list)): 171 | term = conjunction([query_fn(*v) for v in val]) 172 | else: 173 | term = query_fn(*val) 174 | 175 | elif key == 'labels': 176 | if isinstance(val[0], (tuple, list)): 177 | term = conjunction([query_fn(labels) for labels in val]) 178 | else: 179 | term = query_fn(val) 180 | 181 | elif isinstance(val, (tuple, list)): 182 | term = conjunction([query_fn(v) for v in val]) 183 | 184 | else: 185 | term = query_fn(val) if not isinstance(val, bool) else query_fn() 186 | 187 | if exclude: 188 | term = _exclude(term) 189 | 190 | terms.append(term) 191 | 192 | return _and(terms) 193 | 194 | 195 | def _and(queries: List[str]) -> str: 196 | """ 197 | Returns a query term matching the "and" of all query terms. 198 | 199 | Args: 200 | queries: A list of query terms to and. 201 | 202 | Returns: 203 | The query string. 204 | 205 | """ 206 | 207 | if len(queries) == 1: 208 | return queries[0] 209 | 210 | return f'({" ".join(queries)})' 211 | 212 | 213 | def _or(queries: List[str]) -> str: 214 | """ 215 | Returns a query term matching the "or" of all query terms. 216 | 217 | Args: 218 | queries: A list of query terms to or. 219 | 220 | Returns: 221 | The query string. 222 | 223 | """ 224 | 225 | if len(queries) == 1: 226 | return queries[0] 227 | 228 | return '{' + ' '.join(queries) + '}' 229 | 230 | 231 | def _exclude(term: str) -> str: 232 | """ 233 | Returns a query term excluding messages that match the given query term. 234 | 235 | Args: 236 | term: The query term to be excluded. 237 | 238 | Returns: 239 | The query string. 240 | 241 | """ 242 | 243 | return f'-{term}' 244 | 245 | 246 | def _sender(sender: str) -> str: 247 | """ 248 | Returns a query term matching "from". 249 | 250 | Args: 251 | sender: The sender of the message. 252 | 253 | Returns: 254 | The query string. 255 | 256 | """ 257 | 258 | return f'from:{sender}' 259 | 260 | 261 | def _recipient(recipient: str) -> str: 262 | """ 263 | Returns a query term matching "to". 264 | 265 | Args: 266 | recipient: The recipient of the message. 267 | 268 | Returns: 269 | The query string. 270 | 271 | """ 272 | 273 | return f'to:{recipient}' 274 | 275 | 276 | def _subject(subject: str) -> str: 277 | """ 278 | Returns a query term matching "subject". 279 | 280 | Args: 281 | subject: The subject of the message. 282 | 283 | Returns: 284 | The query string. 285 | 286 | """ 287 | 288 | return f'subject:{subject}' 289 | 290 | 291 | def _labels(labels: Union[List[str], str]) -> str: 292 | """ 293 | Returns a query term matching a multiple labels. 294 | 295 | Works with a single label (str) passed in, instead of the expected list. 296 | 297 | Args: 298 | labels: A list of labels the message must have applied. 299 | 300 | Returns: 301 | The query string. 302 | 303 | """ 304 | 305 | if isinstance(labels, str): # called the wrong function 306 | return _label(labels) 307 | 308 | return _and([_label(label) for label in labels]) 309 | 310 | 311 | def _label(label: str) -> str: 312 | """ 313 | Returns a query term matching a label. 314 | 315 | Args: 316 | label: The label the message must have applied. 317 | 318 | Returns: 319 | The query string. 320 | 321 | """ 322 | 323 | return f'label:{label}' 324 | 325 | 326 | def _spec_attachment(name_or_type: str) -> str: 327 | """ 328 | Returns a query term matching messages that have attachments with a 329 | certain name or file type. 330 | 331 | Args: 332 | name_or_type: The specific name of file type to match. 333 | 334 | Returns: 335 | The query string. 336 | 337 | """ 338 | 339 | return f'filename:{name_or_type}' 340 | 341 | 342 | def _exact_phrase(phrase: str) -> str: 343 | """ 344 | Returns a query term matching messages that have an exact phrase. 345 | 346 | Args: 347 | phrase: The exact phrase to match. 348 | 349 | Returns: 350 | The query string. 351 | 352 | """ 353 | 354 | return f'"{phrase}"' 355 | 356 | 357 | def _starred() -> str: 358 | """Returns a query term matching messages that are starred.""" 359 | 360 | return 'is:starred' 361 | 362 | 363 | def _snoozed() -> str: 364 | """Returns a query term matching messages that are snoozed.""" 365 | 366 | return 'is:snoozed' 367 | 368 | 369 | def _unread() -> str: 370 | """Returns a query term matching messages that are unread.""" 371 | 372 | return 'is:unread' 373 | 374 | 375 | def _read() -> str: 376 | """Returns a query term matching messages that are read.""" 377 | 378 | return 'is:read' 379 | 380 | 381 | def _important() -> str: 382 | """Returns a query term matching messages that are important.""" 383 | 384 | return 'is:important' 385 | 386 | 387 | def _cc(recipient: str) -> str: 388 | """ 389 | Returns a query term matching messages that have certain recipients in 390 | the cc field. 391 | 392 | Args: 393 | recipient: The recipient in the cc field to match. 394 | 395 | Returns: 396 | The query string. 397 | 398 | """ 399 | 400 | return f'cc:{recipient}' 401 | 402 | 403 | def _bcc(recipient: str) -> str: 404 | """ 405 | Returns a query term matching messages that have certain recipients in 406 | the bcc field. 407 | 408 | Args: 409 | recipient: The recipient in the bcc field to match. 410 | 411 | Returns: 412 | The query string. 413 | 414 | """ 415 | 416 | return f'bcc:{recipient}' 417 | 418 | 419 | def _after(date: str) -> str: 420 | """ 421 | Returns a query term matching messages sent after a given date. 422 | 423 | Args: 424 | date: The date messages must be sent after. 425 | 426 | Returns: 427 | The query string. 428 | 429 | """ 430 | 431 | return f'after:{date}' 432 | 433 | 434 | def _before(date: str) -> str: 435 | """ 436 | Returns a query term matching messages sent before a given date. 437 | 438 | Args: 439 | date: The date messages must be sent before. 440 | 441 | Returns: 442 | The query string. 443 | 444 | """ 445 | 446 | return f'before:{date}' 447 | 448 | 449 | def _older_than(number: int, unit: str) -> str: 450 | """ 451 | Returns a query term matching messages older than a time period. 452 | 453 | Args: 454 | number: The number of units of time of the period. 455 | unit: The unit of time: "day", "month", or "year". 456 | 457 | Returns: 458 | The query string. 459 | 460 | """ 461 | 462 | return f'older_than:{number}{unit[0]}' 463 | 464 | 465 | def _newer_than(number: int, unit: str) -> str: 466 | """ 467 | Returns a query term matching messages newer than a time period. 468 | 469 | Args: 470 | number: The number of units of time of the period. 471 | unit: The unit of time: 'day', 'month', or 'year'. 472 | 473 | Returns: 474 | The query string. 475 | 476 | """ 477 | 478 | return f'newer_than:{number}{unit[0]}' 479 | 480 | 481 | def _near_words( 482 | first: str, 483 | second: str, 484 | distance: int, 485 | exact: bool = False 486 | ) -> str: 487 | """ 488 | Returns a query term matching messages that two words within a certain 489 | distance of each other. 490 | 491 | Args: 492 | first: The first word to search for. 493 | second: The second word to search for. 494 | distance: How many words apart first and second can be. 495 | exact: Whether first must come before second [default False]. 496 | 497 | Returns: 498 | The query string. 499 | 500 | """ 501 | 502 | query = f'{first} AROUND {distance} {second}' 503 | if exact: 504 | query = '"' + query + '"' 505 | 506 | return query 507 | 508 | 509 | def _attachment() -> str: 510 | """Returns a query term matching messages that have attachments.""" 511 | 512 | return 'has:attachment' 513 | 514 | 515 | def _drive() -> str: 516 | """ 517 | Returns a query term matching messages that have Google Drive attachments. 518 | 519 | """ 520 | 521 | return 'has:drive' 522 | 523 | 524 | def _docs() -> str: 525 | """ 526 | Returns a query term matching messages that have Google Docs attachments. 527 | 528 | """ 529 | 530 | return 'has:document' 531 | 532 | 533 | def _sheets() -> str: 534 | """ 535 | Returns a query term matching messages that have Google Sheets attachments. 536 | 537 | """ 538 | 539 | return 'has:spreadsheet' 540 | 541 | 542 | def _slides() -> str: 543 | """ 544 | Returns a query term matching messages that have Google Slides attachments. 545 | 546 | """ 547 | 548 | return 'has:presentation' 549 | 550 | 551 | def _list(list_name: str) -> str: 552 | """ 553 | Returns a query term matching messages from a mailing list. 554 | 555 | Args: 556 | list_name: The name of the mailing list. 557 | 558 | Returns: 559 | The query string. 560 | 561 | """ 562 | 563 | return f'list:{list_name}' 564 | 565 | 566 | def _in(folder_name: str) -> str: 567 | """ 568 | Returns a query term matching messages from a folder. 569 | 570 | Args: 571 | folder_name: The name of the folder. 572 | 573 | Returns: 574 | The query string. 575 | 576 | """ 577 | 578 | return f'in:{folder_name}' 579 | 580 | 581 | def _delivered_to(address: str) -> str: 582 | """ 583 | Returns a query term matching messages delivered to an address. 584 | 585 | Args: 586 | address: The email address the messages are delivered to. 587 | 588 | Returns: 589 | The query string. 590 | 591 | """ 592 | 593 | return f'deliveredto:{address}' 594 | 595 | 596 | def _category(category: str) -> str: 597 | """ 598 | Returns a query term matching messages belonging to a category. 599 | 600 | Args: 601 | category: The category the messages belong to. 602 | 603 | Returns: 604 | The query string. 605 | 606 | """ 607 | 608 | return f'category:{category}' 609 | 610 | 611 | def _larger(size: str) -> str: 612 | """ 613 | Returns a query term matching messages larger than a certain size. 614 | 615 | Args: 616 | size: The minimum size of the messages in bytes. Suffixes are allowed, 617 | e.g., "10M". 618 | 619 | Returns: 620 | The query string. 621 | 622 | """ 623 | 624 | return f'larger:{size}' 625 | 626 | 627 | def _smaller(size: str) -> str: 628 | """ 629 | Returns a query term matching messages smaller than a certain size. 630 | 631 | Args: 632 | size: The maximum size of the messages in bytes. Suffixes are allowed, 633 | e.g., "10M". 634 | 635 | Returns: 636 | The query string. 637 | 638 | """ 639 | 640 | return f'smaller:{size}' 641 | 642 | 643 | def _id(message_id: str) -> str: 644 | """ 645 | Returns a query term matching messages with the message ID. 646 | 647 | Args: 648 | message_id: The RFC822 message ID. 649 | 650 | Returns: 651 | The query string. 652 | 653 | """ 654 | 655 | return f'rfc822msgid:{message_id}' 656 | 657 | 658 | def _has(attribute: str) -> str: 659 | """ 660 | Returns a query term matching messages with an attribute. 661 | 662 | Args: 663 | attribute: The attribute of the messages. E.g., "nouserlabels". 664 | 665 | Returns: 666 | The query string. 667 | 668 | """ 669 | 670 | return f'has:{attribute}' 671 | -------------------------------------------------------------------------------- /tests/test_query.py: -------------------------------------------------------------------------------- 1 | from simplegmail import query 2 | 3 | class TestQuery(object): 4 | 5 | def test_and(self): 6 | _and = query._and 7 | 8 | expect = "(((a b c) (d e f)) ((g h i) j))" 9 | string = _and([ 10 | _and([ 11 | _and(['a', 'b', 'c']), 12 | _and(['d', 'e', 'f']) 13 | ]), 14 | _and([ 15 | _and(['g', 'h', 'i']), 16 | 'j' 17 | ]) 18 | ]) 19 | assert string == expect 20 | 21 | def test_or(self): 22 | _or = query._or 23 | 24 | expect = "{{{a b c} {d e f}} {{g h i} j}}" 25 | string = _or([ 26 | _or([ 27 | _or(['a', 'b', 'c']), 28 | _or(['d', 'e', 'f']) 29 | ]), 30 | _or([ 31 | _or(['g', 'h', 'i']), 32 | 'j' 33 | ]) 34 | ]) 35 | assert string == expect 36 | 37 | def test_exclude(self): 38 | _exclude = query._exclude 39 | 40 | expect = '-a' 41 | string = _exclude('a') 42 | assert string == expect 43 | 44 | def test_construct_query_from_keywords(self): 45 | expect = "({from:john@doe.com from:jane@doe.com} subject:meeting)" 46 | query_string = query.construct_query( 47 | sender=['john@doe.com', 'jane@doe.com'], subject='meeting' 48 | ) 49 | assert query_string == expect 50 | 51 | expect = "(-is:starred (label:work label:HR))" 52 | query_string = query.construct_query(exclude_starred=True, 53 | labels=['work', 'HR']) 54 | assert query_string == expect 55 | 56 | expect = "{(label:work label:HR) (label:wife label:house)}" 57 | query_string = query.construct_query( 58 | labels=[['work', 'HR'], ['wife', 'house']] 59 | ) 60 | assert query_string == expect 61 | 62 | def test_construct_query_from_dicts(self): 63 | expect = "{(from:john@doe.com newer_than:1d {subject:meeting subject:HR}) (to:jane@doe.com CS AROUND 5 homework)}" 64 | query_string = query.construct_query( 65 | dict( 66 | sender='john@doe.com', 67 | newer_than=(1, 'day'), 68 | subject=['meeting', 'HR'] 69 | ), 70 | dict( 71 | recipient='jane@doe.com', 72 | near_words=('CS', 'homework', 5) 73 | ) 74 | ) 75 | assert query_string == expect 76 | --------------------------------------------------------------------------------