├── .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 | [](
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 |
--------------------------------------------------------------------------------