diff --git a/CHANGELOG.md b/CHANGELOG.md deleted file mode 100644 index fb80012..0000000 --- a/CHANGELOG.md +++ /dev/null @@ -1,10 +0,0 @@ -# Changelog - - - -## 0.1.9 - -### New Features - -- Audit send events ([#10](https://github.com/AnswerDotAI/solvemail/issues/10)) - diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..3b106e8 --- /dev/null +++ b/LICENSE @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright 2022, fastai + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/MANIFEST.in b/MANIFEST.in index a33db56..4d39333 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -1,2 +1,5 @@ +include pyproject.toml +include LICENSE +include CONTRIBUTING.md include README.md -include CHANGELOG.md +recursive-exclude * __pycache__ diff --git a/README.md b/README.md index 4099eee..9bb99c5 100644 --- a/README.md +++ b/README.md @@ -1,180 +1,202 @@ # solvemail -A simple Gmail / Google Workspace email client built on the official Gmail API, using the fastai/fastcore coding style. -## Install - -```bash -pip install solvemail + + +solvemail is an async Python client for Gmail. It wraps the Gmail REST +API in four small classes +([`Email`](https://AnswerDotAI.github.io/solvemail/core.html#email), +[`Thread`](https://AnswerDotAI.github.io/solvemail/core.html#thread), +[`Draft`](https://AnswerDotAI.github.io/solvemail/core.html#draft), and +[`Label`](https://AnswerDotAI.github.io/solvemail/core.html#label)), so +you can search your mail, read message bodies and attachments, organize +with labels, and send or reply, all with plain method calls. + +It is built on [fastgws](https://github.com/AnswerDotAI/fastgws) for +OAuth, and runs every call asynchronously, so you `await` each one. +Fetching is lazy: an +[`Email`](https://AnswerDotAI.github.io/solvemail/core.html#email) or +[`Thread`](https://AnswerDotAI.github.io/solvemail/core.html#thread) +starts as just an id and loads the rest of itself only when you read a +part that needs it. You choose how much comes back with the `fmt` +argument: `metadata` for headers and a snippet, `full` for the whole +payload, `minimal` for ids and labels, and `raw` for the original RFC +2822 bytes. + +## Installation + +Install latest from [pypi](https://pypi.org/project/solvemail/) + +``` sh +$ pip install solvemail ``` -Or for development: - -```bash -pip install -e . +## How to use + +solvemail signs in with OAuth 2.0 through +[fastgws](https://github.com/AnswerDotAI/fastgws), which expects an +OAuth client secret at `~/.config/fastgws/credentials.json`. The fastgws +docs cover how to create that file from a Google Cloud project. The +first connection opens a browser to authorize the scopes you asked for, +then caches the token so later runs donβt prompt again. + +Create a +[`Gmail`](https://AnswerDotAI.github.io/solvemail/core.html#gmail) +client with the scopes you need: `readonly` to read and search, `modify` +to also add and remove labels, or `full` for everything including +permanent deletion. `profile()` returns the signed-in account, with the +address on its `email` attribute. + +``` python +gmail = Gmail(scopes='full') +profile = await gmail.profile() +me = profile.email +me ``` -## OAuth setup + 'nc@answer.ai' -For detailed instructions on setting up Google Cloud credentials, see [ezgmail's excellent documentation](https://github.com/asweigart/ezgmail#enable-the-gmail-api). +### Sending and replying -In brief: +`send` builds and sends a message. The body is markdown and is converted +to HTML for you, and `att=` takes a list of file paths. `reply` keeps +the conversation threaded by setting the right `In-Reply-To`, +`References`, and `Re:` subject; `reply_draft` makes a draft you can +review first. Pass `reply_all=True` to cc the other recipients. +`forward` re-sends a message, pasting in the original content and +re-attaching its files. -1. Create an OAuth Client ID (Desktop app) in [Google Cloud Console](https://console.cloud.google.com) and enable the Gmail API -2. Download the client secrets JSON as `credentials.json` -3. Put `credentials.json` next to your script (or pass its path) - -On first run, `solvemail` will open a browser to authorize and will save `token.json`. +``` python +em = await gmail.send(to=me, subj='solvemail html test', + body='Hello from **solvemail**!', + html='
Hello from solvemail!
', + att=['otter.png']) +await em.reply(body='Cute otter!') +``` -## Quick start +)
-
-# View email with headers (as dict or plain text)
-view_email(e.id) # Returns dict with headers + body
-view_email(e.id, as_json=False) # Returns formatted text
-
-# View full thread
-view_thread(t.id) # Dict of email id -> email dict
-view_thread(t.id, as_json=False) # Concatenated text with separators
-```
-
-### Inbox helpers
-
-```python
-view_inbox(max_emails=20) # Batch fetch inbox emails
-view_inbox_threads(max_threads=20) # Batch fetch inbox threads
-view_inbox(unread=True) # Only unread
-```
+ Cute otter!
### Labels
-```python
-labels() # List all labels
-label('INBOX') # Get by name or id
-find_labels('project') # Search labels
-create_label('My Label') # Create new label
-```
-
-### Drafts
-
-```python
-drafts() # List drafts
-create_draft(to='...', subj='...', body='...')
-reply_to_thread(thread_id, body='...')
+Labels are Gmailβs tags. System labels like `INBOX` and `STARRED` use
+their name as their id; custom labels get an id like `Label_42`. You can
+pass a name or an id anywhere a label is expected, and solvemail
+resolves it for you. `modify` adds and removes labels, and there are
+shortcuts for the common ones.
+
+``` python
+lbl = await gmail.create_label('Otters')
+await em.modify(add='Otters', rm='INBOX')
+await em.mark_read()
+await em.star()
+await em.labels
```
-### Bulk operations
+ ['Otters', 'STARRED', 'SENT']
-```python
-# Batch modify labels (auto-chunks, no 1000 email limit)
-ids = [e.id for e in search_emails('in:inbox')]
-batch_label(ids, add=['SPAM'], rm=['INBOX'])
+### Unsubscribe
-# Trash multiple emails
-trash_emails(ids)
+Newsletters usually carry a `List-Unsubscribe` header saying how to opt
+out, either a `mailto:` address or a one-click URL. `unsubscribe` reads
+that header and does whichever the sender offered, so you do not have to
+hunt for the link buried in the email.
-# Permanently delete (requires full mail scope)
-batch_delete(ids)
+``` python
+await em.unsubscribe()
```
-## Testing
-
-Set these env vars to run e2e tests against a throwaway Gmail/Workspace account:
-
-- `GMAILX_CREDS` β path to `credentials.json`
-- `GMAILX_TOKEN` β path to `token.json` (will be created if missing)
-- `GMAILX_E2E` β set to `1` to enable e2e tests
-
-```bash
-pytest -q
-```
-## Development
+### Use as an LLM skill
-This is a fastship project. Use `ship-*` commands to manage.
+solvemail registers a
+[pyskills](https://github.com/AnswerDotAI/pyskills) skill, so an LLM
+host such as solveit can load it and use the read-only API (searching,
+reading, labels) without write access. Sending, replying, forwarding,
+and deleting are documented in the skill but are not enabled by default,
+so you turn them on deliberately when a task needs them.
## Credits
-Inspired by [ezgmail](https://github.com/asweigart/ezgmail) by [Al Sweigart](https://inventwithpython.com/) β thanks Al for the great work! The ezgmail repo also has excellent documentation on setting up Gmail API credentials.
+Inspired by [ezgmail](https://github.com/asweigart/ezgmail) by [Al
+Sweigart](https://inventwithpython.com/) β thanks Al for the great work!
+The ezgmail repo also has excellent documentation on setting up Gmail
+API credentials.
diff --git a/_config.yml b/_config.yml
deleted file mode 100644
index fff4ab9..0000000
--- a/_config.yml
+++ /dev/null
@@ -1 +0,0 @@
-theme: jekyll-theme-minimal
diff --git a/_layouts/default.html b/_layouts/default.html
deleted file mode 100644
index b44decc..0000000
--- a/_layouts/default.html
+++ /dev/null
@@ -1,52 +0,0 @@
-
-
-
-
-
-
-
-{% seo %}
-
- {% include head-custom.html %}
-
-
-
-
- {{ site.title | default: site.github.repository_name }}
-
- {% if site.logo %}
-
- {% endif %}
-
- {{ site.description | default: site.github.project_tagline }}
-
- {% if site.github.is_project_page %}
-
- {% endif %}
-
- {% if site.github.is_user_page %}
-
- {% endif %}
-
- {% if site.show_downloads %}
-
- {% endif %}
-
-
-
- {{ content }}
-
-
-
-
-
-
-
diff --git a/index_files/figure-commonmark/cell-6-output-1.png b/index_files/figure-commonmark/cell-6-output-1.png
new file mode 100644
index 0000000..285d71a
Binary files /dev/null and b/index_files/figure-commonmark/cell-6-output-1.png differ
diff --git a/nbs/00_core.ipynb b/nbs/00_core.ipynb
new file mode 100644
index 0000000..85bb4cf
--- /dev/null
+++ b/nbs/00_core.ipynb
@@ -0,0 +1,1908 @@
+{
+ "cells": [
+ {
+ "cell_type": "markdown",
+ "id": "a8db652c",
+ "metadata": {},
+ "source": [
+ "# core\n",
+ "\n",
+ "> User facing API for solvemail"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "id": "2b251152",
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "#| default_exp core"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "id": "8d34f0e6",
+ "metadata": {},
+ "source": [
+ "## Imports"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "id": "fe46425b",
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "#| export\n",
+ "from bs4 import BeautifulSoup\n",
+ "from email.utils import parsedate_to_datetime\n",
+ "from fastcore.all import *\n",
+ "from fastgws import GMail\n",
+ "from fastgws.auth import *\n",
+ "from solvemail.email import *\n",
+ "\n",
+ "import asyncio,html,httpx,mistletoe,sys"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "id": "3ed014f0",
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "from fastcore.test import *\n",
+ "from IPython.display import IFrame\n",
+ "from PIL import Image"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "id": "4d2a6b9d",
+ "metadata": {},
+ "source": [
+ "Gmail's API is a single **REST API** (request/response over HTTPS), organized around a few core resource types:\n",
+ "\n",
+ "- **Messages** β individual emails. Each has headers (from, to, subject, date), a body (plain text and/or HTML), labels, and optional attachments.\n",
+ "- **Threads** β conversations. A thread groups related messages (an email and its replies) under one id.\n",
+ "- **Drafts** β unsent messages. A draft wraps a message you can update and later send.\n",
+ "- **Labels** β Gmail's version of folders/tags (`INBOX`, `UNREAD`, `STARRED`, plus your own custom labels). Messages and threads can have many labels at once.\n",
+ "\n",
+ "Gmail uses **OAuth 2.0**: the user grants your app permission to act on their behalf, and you receive short-lived access tokens (auto-refreshed) rather than a single static secret. The permissions you request are controlled by **scopes** β e.g. `gmail.readonly` to only read, or `https://mail.google.com/` for full access (needed to permanently delete).\n",
+ "\n",
+ "To get started you need an OAuth client secret (`credentials.json`) from a Google Cloud project:\n",
+ "\n",
+ "1. Go to the [Google Cloud Console](https://console.cloud.google.com) and create a project (or pick an existing one).\n",
+ "2. Under **APIs & Services β Library**, find the **Gmail API** and enable it.\n",
+ "3. Under **APIs & Services β OAuth consent screen**, configure the consent screen (set the app name, your email, and add your Google account as a test user while in \"Testing\" mode).\n",
+ "4. Under **APIs & Services β Credentials**, click **Create Credentials β OAuth client ID**, choose **Desktop app** (or Web app with a redirect URI), and create it.\n",
+ "5. Download the JSON β this is your `credentials.json`. Keep it secret; it identifies your app to Google.\n",
+ "6. `solvemail` relies on `fastgws`, which expects this `credentials.json` to be placed your `~/.config/fastgws` folder.\n",
+ "\n",
+ "On first run, the OAuth flow opens a browser asking the user to authorize the requested scopes, then caches the resulting token so subsequent runs don't re-prompt."
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "id": "da884c81",
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "#| export\n",
+ "class Gmail:\n",
+ " def __init__(self, client, user_id='me'):\n",
+ " store_attr()\n",
+ " self._lbls = None\n",
+ "\n",
+ " @classmethod\n",
+ " async def init(cls, scopes='readonly', creds=None, user_id='me',\n",
+ " redirect_uri='https://oauth.appapis.org/redirect',\n",
+ " listen=False, port=0, open_url=print):\n",
+ " if scopes == 'full': scopes = ['https://mail.google.com/']\n",
+ " else: scopes = [f'https://www.googleapis.com/auth/gmail.{x}' for x in listify(scopes)]\n",
+ " if creds is None:\n",
+ " creds = await oauth_creds(scopes=scopes, redirect_uri=redirect_uri, listen=listen,\n",
+ " port=port, open_url=open_url)\n",
+ " return cls(GMail(creds=creds), user_id=user_id)\n",
+ "\n",
+ " async def profile(self):\n",
+ " \"Profile resource with `email` attribute\"\n",
+ " d = await self.client.users.get_profile(user_id=self.user_id)\n",
+ " return AttrDict(d, email=d.emailAddress)"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "id": "fb956f03",
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "gmail = await Gmail.init(scopes='full')\n",
+ "profile = await gmail.profile()\n",
+ "me = profile.email\n",
+ "me"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "id": "6c72c93b",
+ "metadata": {},
+ "source": [
+ "### Emails"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "id": "1ec52e09",
+ "metadata": {},
+ "source": [
+ "An **email** is a single message. Gmail returns it as a `payload` β a nested tree of MIME parts. A simple email has one part; a typical one is `multipart/alternative` (a `text/plain` and a `text/html` version of the same content); one with files is `multipart/mixed` (the body parts plus attachments).\n",
+ "\n",
+ "Fetching is lazy: an `Email` starts as just an id, and is filled in only as needed. How much you get back depends on the `fmt` you ask for β `metadata` brings the headers and snippet (enough for a listing), while `full` brings the entire payload including the body parts. So accessing a header lazily fetches at `metadata`, but the first time you read a body part we upgrade to `full`. (`minimal` gives just ids and labels; `raw` gives the original RFC 2822 bytes.)\n",
+ "\n",
+ "`Email` provides helpers for the common parts:\n",
+ "\n",
+ "- **`text`** β the raw `text/plain` body, as the sender wrote it.\n",
+ "- **`html(clean=True)`** β the `text/html` body. With `clean=True` we strip Gmail's signature blocks and quoted reply chains, so you get just the new content. Falls back to the text part wrapped in `` if there's no HTML.\n",
+ "- **`body(clean=True)`** β the cleaned HTML flattened back to readable plain text (`
` and block tags become newlines). This is usually what you want for reading or feeding to an LLM."
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "id": "9563f05e",
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "#| export\n",
+ "def _flat_hdrs(d):\n",
+ " keep = {'date','from','to','subject','message-id','references',\n",
+ " 'reply-to','list-unsubscribe','list-unsubscribe-post'}\n",
+ " hdrs = {x['name'].lower(): x['value'] for x in d.payload.headers if x['name'].lower() in keep}\n",
+ " if 'from' in hdrs: hdrs['frm'] = hdrs.pop('from')\n",
+ " if 'subject' in hdrs: hdrs['subj'] = hdrs.pop('subject')\n",
+ " if 'message-id' in hdrs: hdrs['rfcid'] = hdrs.pop('message-id')\n",
+ " d.update(hdrs)\n",
+ " return d\n",
+ "\n",
+ "def _has_att(e): return e.payload.get('mimeType')=='multipart/mixed'\n",
+ "\n",
+ "class Email(AttrDict):\n",
+ " def __init__(self, gmail, d):\n",
+ " super().__init__(d)\n",
+ " self.gmail = gmail\n",
+ "\n",
+ " def __repr__(self):\n",
+ " if 'payload' not in self: return f'Email({self.id})'\n",
+ " lbls = ','.join(self.get('labelIds') or [])\n",
+ " att = 'π' if _has_att(self) else ''\n",
+ " snip = html.unescape(self.get('snippet') or '')\n",
+ " return f'Email({self.id}: [{lbls}] {att}{self.get(\"frm\", \"\")} | {self.get(\"subj\", \"\")}\\n{snip})'\n",
+ "\n",
+ " async def refresh(self, fmt='metadata'):\n",
+ " d = await self.gmail.client.users.messages.get(user_id='me', id=self.id, format=fmt)\n",
+ " self.update(_flat_hdrs(d))\n",
+ " return self\n",
+ "\n",
+ " @classmethod\n",
+ " async def fetch(cls, gmail, id): return await cls(gmail, dict(id=id)).refresh('metadata')"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "id": "7bbd1dfc",
+ "metadata": {},
+ "source": [
+ "An **email** is a single message. Gmail returns it as a `payload` β a nested tree of MIME parts. A simple email has one part; a typical one is `multipart/alternative` (a `text/plain` and a `text/html` version of the same content); one with files is `multipart/mixed` (the body parts plus attachments).\n",
+ "\n",
+ "Fetching is lazy: an `Email` starts as just an id, and the full payload is only fetched from Gmail the first time you access a part that needs it.\n",
+ "\n",
+ "`Email` provides helpers for the common parts:\n",
+ "\n",
+ "- **`text`** β the raw `text/plain` body, as the sender wrote it.\n",
+ "- **`html(clean=True)`** β the `text/html` body. With `clean=True` we strip Gmail's signature blocks and quoted reply chains, so you get just the new content. Falls back to the text part wrapped in `` if there's no HTML.\n",
+ "- **`body(clean=True)`** β the cleaned HTML flattened back to readable plain text (`
` and block tags become newlines). This is usually what you want for reading or feeding to an LLM.\n"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "id": "353d1436",
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "#| export\n",
+ "@patch\n",
+ "@delegates(mk_email)\n",
+ "async def send(self:Gmail, thread_id=None, **kwargs):\n",
+ " sys.audit('solvemail.Gmail.send', kwargs.get('to'), kwargs.get('body'))\n",
+ " msg = dict(raw=raw_email(mk_email(**kwargs)))\n",
+ " if thread_id: msg['thread_id'] = thread_id\n",
+ " return Email(self, await self.client.users.messages.send(user_id=self.user_id, **msg))"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "id": "e306f97e",
+ "metadata": {},
+ "outputs": [
+ {
+ "data": {
+ "text/markdown": [
+ "\n",
+ "\n",
+ "```python\n",
+ "Email(19ee16b82df630b7)\n",
+ "```\n",
+ "\n",
+ ""
+ ],
+ "text/plain": [
+ "Email(19ee16b82df630b7)"
+ ]
+ },
+ "execution_count": null,
+ "metadata": {},
+ "output_type": "execute_result"
+ }
+ ],
+ "source": [
+ "em = await gmail.send(to=me, subj='solvemail html test',\n",
+ " body='Hello from **solvemail**!',\n",
+ " html='Hello from solvemail!
',\n",
+ " att=['otter.png'])\n",
+ "em"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "id": "4a7fd890",
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "#| export\n",
+ "@patch(as_prop=True)\n",
+ "async def text(self:Email):\n",
+ " if 'payload' not in self or 'parts' not in self.payload: await self.refresh('full')\n",
+ " return txt_part(self.payload)"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "id": "56d92bab",
+ "metadata": {},
+ "outputs": [
+ {
+ "name": "stdout",
+ "output_type": "stream",
+ "text": [
+ "Hello from **solvemail**!\n"
+ ]
+ },
+ {
+ "name": "stdout",
+ "output_type": "stream",
+ "text": [
+ "\n"
+ ]
+ }
+ ],
+ "source": [
+ "print(await em.text)"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "id": "6344fe31",
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "#| export\n",
+ "def _clean_html(h):\n",
+ " \"Strip Gmail signatures and reply quotes from html `h`\"\n",
+ " soup = BeautifulSoup(h, 'html.parser')\n",
+ " for sig in soup.select('.gmail_signature, .gmail_signature_prefix'): sig.decompose()\n",
+ " for q in soup.select('.gmail_quote'):\n",
+ " prev = q.find_previous_sibling()\n",
+ " if prev and prev.get_text(strip=True): q.decompose()\n",
+ " return str(soup)\n",
+ "\n",
+ "@patch\n",
+ "async def html(self:Email, clean=True):\n",
+ " if 'payload' not in self or 'parts' not in self.payload: await self.refresh('full')\n",
+ " h = html_part(self.payload)\n",
+ " return _clean_html(h) if h and clean else h"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "id": "21783fc3",
+ "metadata": {},
+ "outputs": [
+ {
+ "data": {
+ "text/html": [
+ "\n",
+ " \n",
+ " "
+ ],
+ "text/plain": [
+ ""
+ ]
+ },
+ "execution_count": null,
+ "metadata": {},
+ "output_type": "execute_result"
+ }
+ ],
+ "source": [
+ "IFrame(src='about:blank', width='100%', height=50,\n",
+ " extras=[f'srcdoc=\"{html.escape(await em.html())}\"'])"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "id": "d0554d5f",
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "#| export\n",
+ "@patch\n",
+ "async def body(self:Email, clean=True):\n",
+ " soup = BeautifulSoup(await self.html(clean), 'html.parser')\n",
+ " for br in soup.find_all('br'): br.replace_with('\\n')\n",
+ " for tag in soup.find_all(['p','div']): tag.append('\\n')\n",
+ " return re.sub(r'\\n{3,}', '\\n\\n', soup.get_text().strip())"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "id": "5699b65b",
+ "metadata": {},
+ "outputs": [
+ {
+ "name": "stdout",
+ "output_type": "stream",
+ "text": [
+ "Hello from solvemail!\n"
+ ]
+ }
+ ],
+ "source": [
+ "print(await em.body())"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "id": "9f7aaf60",
+ "metadata": {},
+ "source": [
+ "You can search uses Gmail's own query syntax like `from:foo@bar.com`, `subject:invoice`, `has:attachment`, `is:unread`, or `after:2026/01/01`. `search` returns `Email`s (refreshed to `metadata`, so you get senders, subjects, and snippets)."
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "id": "2d474ef0",
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "#| export\n",
+ "def _email_row(e, att=None):\n",
+ " att = _has_att(e) if att is None else att\n",
+ " return f'| {e.id} | {e.frm or \"\"} | {e.get(\"subj\") or \"\"} | {\"π\" if att else \"\"} | {e.get(\"date\") or \"\"} |'\n",
+ "\n",
+ "_email_hdr = '| id | from | subj | π | date |\\n|-|-|-|-|-|\\n'\n",
+ "class Emails(L):\n",
+ " def __init__(self, d, *rest, gmail=None, **kwargs):\n",
+ " super().__init__(d, *rest, **kwargs)\n",
+ " self.gmail = gmail\n",
+ "\n",
+ " def _repr_markdown_(self): return _email_hdr + '\\n'.join(self.map(_email_row))\n",
+ "\n",
+ " async def refresh(self, fmt='metadata'):\n",
+ " if not self: return self\n",
+ " reqs = [(e.id, self.gmail.client._gservice.users().messages().get(userId='me', id=e.id, format=fmt)) for e in self]\n",
+ " res = self.gmail.client.batch_get(reqs)\n",
+ " for e in self:\n",
+ " d = res.get(e.id)\n",
+ " if d: e.update(_flat_hdrs(dict2obj(d)))\n",
+ " return self\n",
+ "\n",
+ "@patch\n",
+ "async def _search(self:Gmail, rsrc, key, cls, coll, q=None, max_results=10, **kwargs):\n",
+ " r = await getattr(self.client.users, rsrc).list(user_id=self.user_id, q=q, max_results=max_results, **kwargs)\n",
+ " os = L(r.get(key) or []).map(lambda o: cls(self, o))\n",
+ " return await coll(os, gmail=self).refresh()\n",
+ "\n",
+ "@patch\n",
+ "async def search_emails(self:Gmail, q=None, max_results=20, **kwargs):\n",
+ " return await self._search('messages', 'messages', Email, Emails, q, max_results, **kwargs)\n",
+ "\n"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "id": "979798e9",
+ "metadata": {},
+ "outputs": [
+ {
+ "data": {
+ "text/markdown": [
+ "\n",
+ "\n",
+ "| id | from | subj | π | date |\n",
+ "|-|-|-|-|-|\n",
+ "| 19ee16b82df630b7 | Nathan Cooper | solvemail html test | π | Fri, 19 Jun 2026 19:46:19 +0000 |\n",
+ "\n",
+ " "
+ ],
+ "text/plain": [
+ "[Email(19ee16b82df630b7: [UNREAD,SENT,INBOX] πNathan Cooper | solvemail html test\n",
+ "Hello from solvemail!)]"
+ ]
+ },
+ "execution_count": null,
+ "metadata": {},
+ "output_type": "execute_result"
+ }
+ ],
+ "source": [
+ "ems = await gmail.search_emails('has:attachment', max_results=1)\n",
+ "ems"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "id": "3e088c6f",
+ "metadata": {},
+ "source": [
+ "#### Attachments"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "id": "7b931c9a",
+ "metadata": {},
+ "source": [
+ "Emails can carry file attachments, and they show up as extra parts in the payload. `attachments` pulls those out for you as `EmailAttachment` objects, so you get attribute access to things like `filename`, `mimeType`, and `size`. Each one is lazy β the actual bytes aren't downloaded until you call `fetch`, and you can pass `save=` a directory or filename to write it straight to disk."
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "id": "2ecd04dc",
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "#| export\n",
+ "def hsize(n):\n",
+ " \"Human-readable byte size\"\n",
+ " for u in ' KMGT':\n",
+ " if n < 1024: return f'{n:.0f}{u.strip()}B' if u==' ' else f'{n:.1f}{u}B'\n",
+ " n /= 1024\n",
+ " return f'{n:.1f}PB'\n",
+ "\n",
+ "class EmailAttachment(AttrDict):\n",
+ " def __init__(self, gmail, mid, d):\n",
+ " super().__init__(d)\n",
+ " self.gmail = gmail\n",
+ " self.mid = mid\n",
+ "\n",
+ " def __repr__(self): return f'EmailAttachment({self.filename!r}, {self.mimeType}, {hsize(self.body.size)})'\n",
+ "\n",
+ " async def fetch(self, save=None):\n",
+ " r = await self.gmail.client.users.messages.attachments.get( user_id='me', message_id=self.mid,\n",
+ " id=self.body.attachmentId)\n",
+ " data = b64d(r.data)\n",
+ " if save:\n",
+ " p = Path(save)\n",
+ " if p.is_dir(): p = p/self.filename\n",
+ " p.write_bytes(data)\n",
+ " return data\n",
+ "\n",
+ "@patch(as_prop=True)\n",
+ "async def attachments(self:Email):\n",
+ " if 'parts' not in self.payload: await self.refresh('full')\n",
+ " return att_parts(self.payload).map(lambda a: EmailAttachment(self.gmail, self.id, a))"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "id": "1dc46376",
+ "metadata": {},
+ "outputs": [
+ {
+ "data": {
+ "text/markdown": [
+ "\n",
+ "\n",
+ "```python\n",
+ "EmailAttachment('otter.png', image/png, 1.5MB)\n",
+ "```\n",
+ "\n",
+ ""
+ ],
+ "text/plain": [
+ "EmailAttachment('otter.png', image/png, 1.5MB)"
+ ]
+ },
+ "execution_count": null,
+ "metadata": {},
+ "output_type": "execute_result"
+ }
+ ],
+ "source": [
+ "em = ems[0]\n",
+ "att = (await em.attachments)[0]\n",
+ "att"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "id": "360cc3cb",
+ "metadata": {},
+ "outputs": [
+ {
+ "data": {
+ "image/jpeg": "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",
+ "image/png": "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",
+ "text/plain": [
+ ""
+ ]
+ },
+ "execution_count": null,
+ "metadata": {},
+ "output_type": "execute_result"
+ }
+ ],
+ "source": [
+ "await att.fetch(save='otter_test.png')\n",
+ "Image.open('otter_test.png').resize((128,128))"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "id": "bd09ef81",
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "#| export\n",
+ "@patch\n",
+ "async def delete(self:Email):\n",
+ " sys.audit('solvemail.Email.delete')\n",
+ " return await self.gmail.client.users.messages.delete(user_id='me', id=self.id)"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "id": "dfaa4f8c",
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "await em.delete()"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "id": "c4c240b5",
+ "metadata": {},
+ "source": [
+ "### Drafts"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "id": "366f7981",
+ "metadata": {},
+ "source": [
+ "A **draft** is an unsent message. `Draft` subclasses `Email`, so everything you've seen β `text`, `html()`, `body()`, attachments β works on it too; it just carries an extra `draft_id` and reads from the drafts endpoint."
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "id": "e0d020b1",
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "#| export\n",
+ "class Draft(Email):\n",
+ " def __init__(self, gmail, d):\n",
+ " self.draft_id = d.get('id')\n",
+ " super().__init__(gmail, d.get('message') or {})\n",
+ "\n",
+ " def __repr__(self): return f'Draft({self.draft_id}: ' + super().__repr__()[6:]\n",
+ "\n",
+ " async def refresh(self, fmt='metadata'):\n",
+ " d = await self.gmail.client.users.drafts.get(user_id='me', id=self.draft_id, format=fmt)\n",
+ " self.draft_id = d['id']\n",
+ " self.update(_flat_hdrs(d.message))\n",
+ " return self\n",
+ "\n",
+ " @classmethod\n",
+ " async def fetch(cls, gmail, id): return await cls(gmail, dict(id=id)).refresh('metadata')\n",
+ "\n",
+ "@patch\n",
+ "@delegates(mk_email)\n",
+ "async def create_draft(self:Gmail, thread_id=None, **kwargs):\n",
+ " msg = dict(raw=raw_email(mk_email(**kwargs)))\n",
+ " if thread_id: msg['thread_id'] = thread_id\n",
+ " d = await self.client.users.drafts.create(user_id=self.user_id, message=msg)\n",
+ " return await Draft(self, d).refresh('metadata')"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "id": "ca6d15c9",
+ "metadata": {},
+ "outputs": [
+ {
+ "data": {
+ "text/markdown": [
+ "\n",
+ "\n",
+ "```python\n",
+ "Draft(r5733922656234131461: 19ee16b8f25cbd3c: [DRAFT] Nathan Cooper | draft test\n",
+ "This is a draft!)\n",
+ "```\n",
+ "\n",
+ " "
+ ],
+ "text/plain": [
+ "Draft(r5733922656234131461: 19ee16b8f25cbd3c: [DRAFT] Nathan Cooper | draft test\n",
+ "This is a draft!)"
+ ]
+ },
+ "execution_count": null,
+ "metadata": {},
+ "output_type": "execute_result"
+ }
+ ],
+ "source": [
+ "dr = await gmail.create_draft(to='nc@answer.ai', subj='draft test', body='This is a draft!')\n",
+ "dr"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "id": "6e007ac3",
+ "metadata": {},
+ "source": [
+ "Drafts can be updated before sent"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "id": "6fa74f13",
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "#| export\n",
+ "@patch\n",
+ "@delegates(mk_email)\n",
+ "async def update_draft(self:Draft, thread_id=None, **kwargs):\n",
+ " defaults = {k: self[k] for k in ('to','subj','cc') if self.get(k)}\n",
+ " msg = dict(raw=raw_email(mk_email(**{**defaults, **kwargs})))\n",
+ " if thread_id or self.get('threadId'): msg['thread_id'] = thread_id or self.threadId\n",
+ " d = await self.gmail.client.users.drafts.update(user_id='me', id=self.draft_id, message=msg)\n",
+ " self.draft_id = d['id']\n",
+ " return await self.refresh('metadata')"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "id": "6f7f7507",
+ "metadata": {},
+ "outputs": [
+ {
+ "data": {
+ "text/markdown": [
+ "\n",
+ "\n",
+ "```python\n",
+ "Draft(r5733922656234131461: 19ee16b9102159b9: [DRAFT] Nathan Cooper | draft test\n",
+ "after update!)\n",
+ "```\n",
+ "\n",
+ " "
+ ],
+ "text/plain": [
+ "Draft(r5733922656234131461: 19ee16b9102159b9: [DRAFT] Nathan Cooper | draft test\n",
+ "after update!)"
+ ]
+ },
+ "execution_count": null,
+ "metadata": {},
+ "output_type": "execute_result"
+ }
+ ],
+ "source": [
+ "await dr.update_draft(body='after update!')"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "id": "8e386292",
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "#| export\n",
+ "def _draft_row(d): return f'| {d.draft_id} | {d.get(\"frm\") or \"\"} | {d.get(\"subj\") or \"\"} | {d.get(\"date\") or \"\"} |'\n",
+ "\n",
+ "_draft_hdr = '| draft_id | from | subj | date |\\n|-|-|-|-|\\n'\n",
+ "class Drafts(L):\n",
+ " def __init__(self, d, *rest, gmail=None, **kwargs):\n",
+ " super().__init__(d, *rest, **kwargs)\n",
+ " self.gmail = gmail\n",
+ "\n",
+ " def _repr_markdown_(self): return _draft_hdr + '\\n'.join(self.map(_draft_row))\n",
+ "\n",
+ " async def refresh(self, fmt='metadata'):\n",
+ " if not self: return self\n",
+ " reqs = [(d.draft_id, self.gmail.client._gservice.users().drafts().get(userId='me', id=d.draft_id, format=fmt)) for d in self]\n",
+ " res = self.gmail.client.batch_get(reqs)\n",
+ " for d in self:\n",
+ " r = res.get(d.draft_id)\n",
+ " if r:\n",
+ " d.draft_id = r['id']\n",
+ " d.update(_flat_hdrs(dict2obj(r['message'])))\n",
+ " return self\n",
+ "\n",
+ "@patch\n",
+ "async def search_drafts(self:Gmail, q=None, max_results=10, **kwargs):\n",
+ " return await self._search('drafts', 'drafts', Draft, Drafts, q, max_results, **kwargs)"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "id": "54fb0086",
+ "metadata": {},
+ "outputs": [
+ {
+ "data": {
+ "text/markdown": [
+ "\n",
+ "\n",
+ "| draft_id | from | subj | date |\n",
+ "|-|-|-|-|\n",
+ "| r5733922656234131461 | Nathan Cooper | draft test | Fri, 19 Jun 2026 15:46:23 -0400 |\n",
+ "\n",
+ " "
+ ],
+ "text/plain": [
+ "[Draft(r5733922656234131461: 19ee16b9102159b9: [DRAFT] Nathan Cooper | draft test\n",
+ "after update!)]"
+ ]
+ },
+ "execution_count": null,
+ "metadata": {},
+ "output_type": "execute_result"
+ }
+ ],
+ "source": [
+ "drs = await gmail.search_drafts(max_results=1)\n",
+ "drs"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "id": "e0d2be45",
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "#| export\n",
+ "@patch\n",
+ "async def send(self:Draft):\n",
+ " sys.audit('solvemail.Draft.send')\n",
+ " return Email(self.gmail, await self.gmail.client.users.drafts.send(user_id='me', id=self.draft_id))"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "id": "70378f07",
+ "metadata": {},
+ "outputs": [
+ {
+ "data": {
+ "text/markdown": [
+ "\n",
+ "\n",
+ "```python\n",
+ "Email(19ee16b962833a2a)\n",
+ "```\n",
+ "\n",
+ ""
+ ],
+ "text/plain": [
+ "Email(19ee16b962833a2a)"
+ ]
+ },
+ "execution_count": null,
+ "metadata": {},
+ "output_type": "execute_result"
+ }
+ ],
+ "source": [
+ "em = await dr.send()\n",
+ "em"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "id": "71337504",
+ "metadata": {},
+ "source": [
+ "A reply contains special headers to enable mail clients to properly associate the email as part of a thread. `_reply_hdrs` is a helper to properly parse these:\n",
+ "\n",
+ "- **`to`** β reply to the sender, preferring their `Reply-To` header if they set one, otherwise the `From` address.\n",
+ "- **`subj`** β the original subject prefixed with `Re:` (unless it already starts with one).\n",
+ "- **`In-Reply-To`** β the parent's `Message-ID`, telling clients exactly which email this answers.\n",
+ "- **`References`** β the parent's existing `References` chain plus its `Message-ID`, building the full ancestry of the conversation."
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "id": "bac9930b",
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "#| export\n",
+ "def _reply_hdrs(m, me=None):\n",
+ " \"Compute reply headers from parent email `m` (pass `me` for reply-all cc)\"\n",
+ " subj = m.get('subj') or ''\n",
+ " if not re.match(r'(?i)^re:', subj): subj = f'Re: {subj}'\n",
+ " refs = ' '.join(filter(None, [m.get('references'), m.get('rfcid')]))\n",
+ " to = m.get('reply-to') or m.get('frm')\n",
+ " cc = None\n",
+ " if me:\n",
+ " cc = {a.strip() for a in f\"{m.get('to','')},{m.get('cc','')}\".split(',')\n",
+ " if a.strip() and a.strip().lower() != me.lower()} - {to}\n",
+ " cc = ','.join(cc) or None\n",
+ " return dict(to=to, subj=subj, in_reply_to=m.get('rfcid'), refs=refs or None, cc=cc)"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "id": "c1e25b4b",
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "await em.refresh('metadata')\n",
+ "h = _reply_hdrs(em)\n",
+ "test_eq(h['to'], em.frm)\n",
+ "test_eq(h['subj'], 'Re: draft test')\n",
+ "test_eq(h['in_reply_to'], em.rfcid)\n",
+ "test_eq(h['refs'], em.rfcid)"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "id": "1b36c005",
+ "metadata": {},
+ "source": [
+ "`reply` is just `reply_draft` followed by a send β so anything that holds for reply drafts holds for replies too."
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "id": "33058858",
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "#| export\n",
+ "async def _reply_quote(e):\n",
+ " \"Build gmail_quote attribution + blockquote for replied-to email `e`\"\n",
+ " h = await e.html(clean=False) or f\"{await e.text}\"\n",
+ " when = parsedate_to_datetime(e.get('date')).strftime('%a, %b %-d, %Y at %-I:%M %p')\n",
+ " attrib = f\"On {when} {html.escape(e.get('frm'))} wrote:\"\n",
+ " return (f'{attrib}
'\n",
+ " f'{h}
')\n",
+ "\n",
+ "@patch\n",
+ "@delegates(mk_email, but=['to','subj','body','html','headers','cc'])\n",
+ "async def reply_draft(self:Email, body=None, html=None, reply_all=False, **kwargs):\n",
+ " if 'payload' not in self: await self.refresh('metadata')\n",
+ " me = (await self.gmail.profile()).email if reply_all else None\n",
+ " rh = _reply_hdrs(self, me)\n",
+ " hdrs = {k.replace('_','-'):v for k,v in dict(In_Reply_To=rh['in_reply_to'], References=rh['refs']).items() if v}\n",
+ " new = html or (mistletoe.markdown(body) if body else '')\n",
+ " html = new + await _reply_quote(self)\n",
+ " msg = dict(raw=raw_email(mk_email(to=rh['to'], subj=rh['subj'], cc=rh['cc'],\n",
+ " body=body or '', html=html, headers=hdrs, **kwargs)),\n",
+ " thread_id=self.threadId)\n",
+ " d = await self.gmail.client.users.drafts.create(user_id='me', message=msg)\n",
+ " return await Draft(self.gmail, d).refresh('metadata')\n",
+ "\n",
+ "@patch\n",
+ "@delegates(mk_email, but=['to','subj','headers'])\n",
+ "async def reply(self:Email, **kwargs): return await (await self.reply_draft(**kwargs)).send()"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "id": "064ecd85",
+ "metadata": {},
+ "outputs": [
+ {
+ "data": {
+ "text/markdown": [
+ "\n",
+ "\n",
+ "```python\n",
+ "Email(19ee16b9c2bc9c5b: [UNREAD,SENT,INBOX] Nathan Cooper | Re: draft test\n",
+ "replying from solvemail v2! On Fri, Jun 19, 2026 at 7:46 PM Nathan Cooper wrote: after update!)\n",
+ "```\n",
+ "\n",
+ " "
+ ],
+ "text/plain": [
+ "Email(19ee16b9c2bc9c5b: [UNREAD,SENT,INBOX] Nathan Cooper | Re: draft test\n",
+ "replying from solvemail v2! On Fri, Jun 19, 2026 at 7:46 PM Nathan Cooper wrote: after update!)"
+ ]
+ },
+ "execution_count": null,
+ "metadata": {},
+ "output_type": "execute_result"
+ }
+ ],
+ "source": [
+ "em = await em.reply(body='replying from solvemail v2!')\n",
+ "await em.refresh()"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "id": "5d7a8815",
+ "metadata": {},
+ "source": [
+ "### Threads"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "id": "4600d9d3",
+ "metadata": {},
+ "source": [
+ "A **thread** is a conversation β an email plus all its replies, grouped under one id. `Thread` wraps the list of messages as `Email` objects, so once you've fetched one you can index into it and use everything you already know about emails. Like emails, threads fetch lazily and respect the same `fmt` levels."
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "id": "e437064d",
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "#| export\n",
+ "class Thread(AttrDict):\n",
+ " def __init__(self, gmail, d):\n",
+ " super().__init__(d)\n",
+ " self.gmail = gmail\n",
+ " self.emails = Emails(self.get('messages') or []).map(lambda o: Email(gmail, o))\n",
+ "\n",
+ " def __repr__(self):\n",
+ " if not self.emails: return f'Thread({self.id})'\n",
+ " m = self.emails[-1]\n",
+ " att = 'π' if any(e.payload.get('mimeType')=='multipart/mixed' for e in self.emails) else ''\n",
+ " return f'Thread({self.id}: {len(self.emails)} emails, [{\",\".join(m.labelIds or [])}] {att}{m.frm} -> {m.to} | {m.subj})'\n",
+ " def _repr_markdown_(self): return self.emails._repr_markdown_()\n",
+ "\n",
+ " async def refresh(self, fmt='metadata'):\n",
+ " d = await self.gmail.client.users.threads.get(user_id='me', id=self.id, format=fmt)\n",
+ " self.update(d)\n",
+ " self.emails = await Emails([Email(self.gmail, o) for o in d.messages], gmail=self.gmail).refresh(fmt)\n",
+ " return self\n",
+ "\n",
+ " @classmethod\n",
+ " async def fetch(cls, gmail, id): return await cls(gmail, dict(id=id)).refresh('metadata')\n",
+ "\n",
+ " @property\n",
+ " def labelIds(self): return list({l for e in self.emails for l in (e.labelIds or [])})\n",
+ "\n",
+ " @property\n",
+ " async def attachments(self):\n",
+ " atts = await asyncio.gather(*self.emails.map(lambda e: e.attachments))\n",
+ " return L(a for es in atts for a in es)"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "id": "6b0b2074",
+ "metadata": {},
+ "outputs": [
+ {
+ "data": {
+ "text/markdown": [
+ "\n",
+ "\n",
+ "| id | from | subj | π | date |\n",
+ "|-|-|-|-|-|\n",
+ "| 19ee16b962833a2a | Nathan Cooper | draft test | | Fri, 19 Jun 2026 19:46:24 +0000 |\n",
+ "| 19ee16b9c2bc9c5b | Nathan Cooper | Re: draft test | | Fri, 19 Jun 2026 14:46:26 -0500 |\n",
+ "\n",
+ " "
+ ],
+ "text/plain": [
+ "Thread(19ee16b8f25cbd3c: 2 emails, [UNREAD,SENT,INBOX] Nathan Cooper -> Nathan Cooper | Re: draft test)"
+ ]
+ },
+ "execution_count": null,
+ "metadata": {},
+ "output_type": "execute_result"
+ }
+ ],
+ "source": [
+ "th = await Thread.fetch(gmail, em.threadId)\n",
+ "th"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "id": "e90616f7",
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "#| export\n",
+ "@patch(as_prop=True)\n",
+ "def last(self:Thread): return self.emails[-1]\n",
+ "\n",
+ "@patch\n",
+ "@delegates(mk_email, but=['to','subj','headers'])\n",
+ "async def reply_draft(self:Thread, **kwargs):\n",
+ " dr = await self.last.reply_draft(**kwargs)\n",
+ " await self.refresh('metadata')\n",
+ " return dr\n",
+ "\n",
+ "@patch\n",
+ "@delegates(mk_email, but=['to','subj','headers'])\n",
+ "async def reply(self:Thread, **kwargs):\n",
+ " em = await self.last.reply(**kwargs)\n",
+ " await self.refresh('metadata')\n",
+ " return em"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "id": "e0a9801d",
+ "metadata": {},
+ "outputs": [
+ {
+ "data": {
+ "text/markdown": [
+ "\n",
+ "\n",
+ "| id | from | subj | π | date |\n",
+ "|-|-|-|-|-|\n",
+ "| 19ee16b962833a2a | Nathan Cooper | draft test | | Fri, 19 Jun 2026 19:46:24 +0000 |\n",
+ "| 19ee16b9c2bc9c5b | Nathan Cooper | Re: draft test | | Fri, 19 Jun 2026 14:46:26 -0500 |\n",
+ "| 19ee16ba7d9d8e15 | Nathan Cooper | Re: draft test | | Fri, 19 Jun 2026 15:46:29 -0400 |\n",
+ "\n",
+ " "
+ ],
+ "text/plain": [
+ "Thread(19ee16b8f25cbd3c: 3 emails, [UNREAD,SENT,INBOX] Nathan Cooper -> Nathan Cooper | Re: draft test)"
+ ]
+ },
+ "execution_count": null,
+ "metadata": {},
+ "output_type": "execute_result"
+ }
+ ],
+ "source": [
+ "await th.reply(body='Replying to the thread!')\n",
+ "th"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "id": "1fa3424e",
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "#| export\n",
+ "def _thread_row(t): return _email_row(t.emails[-1], any(_has_att(e) for e in t.emails)) + f' {len(t.emails)} |'\n",
+ "\n",
+ "_thread_hdr = _email_hdr.replace('|\\n|-', '| emails |\\n|-').replace('-|\\n', '-|-|\\n')\n",
+ "class Threads(L):\n",
+ " def __init__(self, d, *rest, gmail=None, **kwargs):\n",
+ " super().__init__(d, *rest, **kwargs)\n",
+ " self.gmail = gmail\n",
+ "\n",
+ " def _repr_markdown_(self): return _thread_hdr + '\\n'.join(self.map(_thread_row))\n",
+ "\n",
+ " async def refresh(self, fmt='metadata'):\n",
+ " if not self: return self\n",
+ " reqs = [(t.id, self.gmail.client._gservice.users().threads().get(userId='me', id=t.id, format=fmt)) for t in self]\n",
+ " res = self.gmail.client.batch_get(reqs)\n",
+ " for t in self:\n",
+ " d = res.get(t.id)\n",
+ " if d:\n",
+ " t.update(dict2obj(d))\n",
+ " t.emails = await Emails(L(dict2obj(d).messages).map(lambda m: Email(t.gmail, m)), gmail=self.gmail).refresh()\n",
+ " return self\n",
+ "\n",
+ "@patch\n",
+ "async def search_threads(self:Gmail, q=None, max_results=10, **kwargs):\n",
+ " return await self._search('threads', 'threads', Thread, Threads, q, max_results, **kwargs)"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "id": "6f679e67",
+ "metadata": {},
+ "outputs": [
+ {
+ "data": {
+ "text/markdown": [
+ "\n",
+ "\n",
+ "| id | from | subj | π | date | emails |\n",
+ "|-|-|-|-|-|-|\n",
+ "| 19ee16ba7d9d8e15 | Nathan Cooper | Re: draft test | | Fri, 19 Jun 2026 15:46:29 -0400 | 3 |\n",
+ "\n",
+ " "
+ ],
+ "text/plain": [
+ "[Thread(19ee16b8f25cbd3c: 3 emails, [UNREAD,SENT,INBOX] Nathan Cooper -> Nathan Cooper | Re: draft test)]"
+ ]
+ },
+ "execution_count": null,
+ "metadata": {},
+ "output_type": "execute_result"
+ }
+ ],
+ "source": [
+ "await gmail.search_threads(max_results=1)"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "id": "b6efdaba",
+ "metadata": {},
+ "source": [
+ "### Forwarding"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "id": "f0601596",
+ "metadata": {},
+ "source": [
+ "Forwarding isn't a real operation at the email level β a forward is just a new message with the original content pasted into its body and the original subject prefixed with `Fwd:`. Unlike a reply, the original attachments don't come along on their own, so we re-download each one and attach it to the new message. We forward the whole conversation, not just one message: `Email.forward` fetches the thread and includes every message up to and including itself (sorted by `internalDate`), and `Thread.forward` just forwards from its last message. The forwarded bodies are kept uncleaned so signatures and earlier quotes stay intact."
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "id": "3650c674",
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "#| export\n",
+ "async def _fwd_one(e):\n",
+ " \"Build (html_block, att_tuples) for one forwarded message `e`\"\n",
+ " hdr = (f\"\\n\\n---------- Forwarded message ----------\\n\\n\"\n",
+ " f\"**From:** {e.get('frm')} \\n**Date:** {e.get('date')} \\n\"\n",
+ " f\"**Subject:** {e.get('subj')} \\n**To:** {e.get('to')} \\n\")\n",
+ " body = await e.html(clean=False) or f\"{await e.text}\"\n",
+ " atts = await e.attachments\n",
+ " tups = await asyncio.gather(*atts.map(lambda a: a.fetch()))\n",
+ " return mistletoe.markdown(hdr) + body, [(a.filename, d, a.mimeType) for a,d in zip(atts,tups)]\n",
+ "\n",
+ "@patch\n",
+ "@delegates(mk_email, but=['subj','html','att','headers'])\n",
+ "async def forward(self:Email, to, subj=None, body=None, att=None, **kwargs):\n",
+ " if 'internalDate' not in self: await self.refresh('full')\n",
+ " th = await Thread.fetch(self.gmail, self.threadId)\n",
+ " ems = th.emails.sorted(lambda e: int(e.internalDate)).filter(lambda e: int(e.internalDate) <= int(self.internalDate))\n",
+ " parts = await asyncio.gather(*ems.map(_fwd_one))\n",
+ " html = (mistletoe.markdown(body) if body else '') + ''.join(h for h,_ in parts)\n",
+ " att = list(att or []) + [a for _,fa in parts for a in fa]\n",
+ " return await self.gmail.send(to=to, subj=subj or f\"Fwd: {self.get('subj')}\", body=body or '', html=html, att=att, **kwargs)\n",
+ "\n",
+ "@patch\n",
+ "@delegates(Email.forward)\n",
+ "async def forward(self:Thread, **kwargs): return await self.last.forward(**kwargs)"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "id": "7f2eb8b0",
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "await th.reply(body='Cute otter!', att=['otter.png'])\n",
+ "fwd = await th.forward(to=me, body='FYI, see below')\n",
+ "await fwd.refresh('full')\n",
+ "\n",
+ "h = await fwd.html(clean=False)\n",
+ "test_eq(h.count('Forwarded message'), 4) # both messages included\n",
+ "fwd_atts = await fwd.attachments\n",
+ "test_eq(L(fwd_atts).attrgot('filename'), ['otter.png']) # original file re-attached"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "id": "6f677ea1",
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "await fwd.delete()"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "id": "81eec22f",
+ "metadata": {},
+ "source": [
+ "### Labels"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "id": "3634f7ad",
+ "metadata": {},
+ "source": [
+ "Labels are Gmail's tags. System labels (`INBOX`, `STARRED`) use their name as their id, but custom labels have an id like `Label_42` that differs from their name. So anywhere you pass a label, we resolve names to ids for you β `modify(add='Receipts')` works by name. The label list is cached after the first fetch and cleared on any create/rename/delete."
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "id": "15a23629",
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "#| export\n",
+ "_sys_lbls = set('INBOX SPAM TRASH UNREAD STARRED IMPORTANT SENT DRAFT CHAT '\n",
+ " 'CATEGORY_FORUMS CATEGORY_UPDATES CATEGORY_PERSONAL '\n",
+ " 'CATEGORY_PROMOTIONS CATEGORY_SOCIAL'.split())\n",
+ "def _norm_lbl(l): return l.upper() if isinstance(l,str) and l.upper() in _sys_lbls else l\n",
+ "\n",
+ "class Label(AttrDict):\n",
+ " def __init__(self, gmail, d):\n",
+ " super().__init__(d)\n",
+ " self.gmail = gmail\n",
+ " def __repr__(self): return f'Label({self.id}:{self.name})'\n",
+ " @property\n",
+ " def is_sys(self): return self.get('type')=='system' or self.id in _sys_lbls\n",
+ "\n",
+ " async def refresh(self):\n",
+ " self.update(await self.gmail.client.users.labels.get(user_id='me', id=self.id))\n",
+ " self.gmail._lbls = None\n",
+ " return self\n",
+ " async def delete(self):\n",
+ " sys.audit('solvemail.Label.delete')\n",
+ " await self.gmail.client.users.labels.delete(user_id='me', id=self.id)\n",
+ " self.gmail._lbls = None\n",
+ " async def patch(self, **kwargs):\n",
+ " self.update(await self.gmail.client.users.labels.patch(user_id='me', id=self.id, **kwargs))\n",
+ " self.gmail._lbls = None\n",
+ " return self\n",
+ " async def rename(self, name): return await self.patch(name=name)\n",
+ "\n",
+ "@patch\n",
+ "async def labels(self:Gmail, refresh=False):\n",
+ " if refresh or self._lbls is None:\n",
+ " d = await self.client.users.labels.list(user_id=self.user_id)\n",
+ " self._lbls = L(d.get('labels') or []).map(lambda o: Label(self, o))\n",
+ " return self._lbls\n",
+ "\n",
+ "@patch\n",
+ "async def label(self:Gmail, lbl, refresh=False):\n",
+ " lbl = _norm_lbl(lbl)\n",
+ " if isinstance(lbl, Label): return lbl\n",
+ " lbls = await self.labels(refresh=refresh)\n",
+ " d = {**{o.name:o for o in lbls}, **{o.id:o for o in lbls}}\n",
+ " if lbl in d: return d[lbl]\n",
+ " raise KeyError(f'Unknown label: {lbl}')\n",
+ "\n",
+ "@patch\n",
+ "async def lbl_ids(self:Gmail, lbls):\n",
+ " if lbls is None: return []\n",
+ " async def _one(l):\n",
+ " l = _norm_lbl(l)\n",
+ " if isinstance(l, Label): return l.id\n",
+ " if isinstance(l, str) and l in _sys_lbls: return l\n",
+ " try: return (await self.label(l)).id\n",
+ " except KeyError: return l\n",
+ " ls = L(lbls) if is_listy(lbls) else L([lbls])\n",
+ " return L(await asyncio.gather(*ls.map(_one))).filter().unique().items\n",
+ "\n",
+ "@patch\n",
+ "async def create_label(self:Gmail, name):\n",
+ " d = await self.client.users.labels.create(user_id=self.user_id, name=name)\n",
+ " self._lbls = None\n",
+ " return Label(self, d)"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "id": "dd915637",
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "lbl = await gmail.create_label('Receipt')\n",
+ "test_eq(lbl.name, 'Receipt')\n",
+ "assert not lbl.is_sys"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "id": "9ed8bac9",
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "#| export\n",
+ "@patch\n",
+ "async def modify(self:Email, add=None, rm=None):\n",
+ " return self.update(await self.gmail.client.users.messages.modify(\n",
+ " user_id='me', id=self.id,\n",
+ " add_label_ids=await self.gmail.lbl_ids(add),\n",
+ " remove_label_ids=await self.gmail.lbl_ids(rm)))\n",
+ "\n",
+ "@patch\n",
+ "async def modify(self:Thread, add=None, rm=None):\n",
+ " await self.gmail.client.users.threads.modify(\n",
+ " user_id='me', id=self.id,\n",
+ " add_label_ids=await self.gmail.lbl_ids(add),\n",
+ " remove_label_ids=await self.gmail.lbl_ids(rm))\n",
+ " return await self.refresh()"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "id": "a5cb93a5",
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "await em.modify(add=lbl.name)\n",
+ "await em.refresh('metadata')\n",
+ "assert lbl.id in em.labelIds\n",
+ "assert 'INBOX' in em.labelIds"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "id": "120c0a48",
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "#| export\n",
+ "@patch\n",
+ "async def find_labels(self:Gmail, term, regex=False):\n",
+ " lbls = await self.labels()\n",
+ " if regex: return lbls.filter(lambda o: re.search(term, o.name))\n",
+ " return lbls.filter(lambda o: term.lower() in o.name.lower())"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "id": "2ab1080e",
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "# lookup + normalization\n",
+ "test_eq((await gmail.label('inbox')).id, 'INBOX') # case-insensitive system\n",
+ "test_eq(await gmail.lbl_ids(['inbox', lbl.name]), ['INBOX', lbl.id])\n",
+ "test_eq((await gmail.find_labels('receipt'))[0].id, lbl.id)\n",
+ "\n",
+ "await lbl.rename('Invoice')\n",
+ "test_eq((await gmail.label(lbl.id, refresh=True)).name, 'Invoice')\n",
+ "\n",
+ "await em.delete()\n",
+ "await lbl.delete()"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "id": "2f89132b",
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "#| export\n",
+ "@patch(as_prop=True)\n",
+ "async def labels(self:Email):\n",
+ " if 'labelIds' not in self: await self.refresh('metadata')\n",
+ " return L(await asyncio.gather(*L(self.labelIds).map(self.gmail.label))).attrgot('name')\n",
+ "\n",
+ "@patch(as_prop=True)\n",
+ "async def labels(self:Thread):\n",
+ " return L(await asyncio.gather(*L(self.labelIds).map(self.gmail.label))).attrgot('name')"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "id": "e1600ded",
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "#| export\n",
+ "#| hide\n",
+ "@patch\n",
+ "async def mark_read(self:(Email,Thread)): return await self.modify(rm='UNREAD')\n",
+ "@patch\n",
+ "async def mark_unread(self:(Email,Thread)): return await self.modify(add='UNREAD')\n",
+ "@patch\n",
+ "async def star(self:(Email,Thread)): return await self.modify(add='STARRED')\n",
+ "@patch\n",
+ "async def unstar(self:(Email,Thread)): return await self.modify(rm='STARRED')\n",
+ "@patch\n",
+ "async def archive(self:(Email,Thread)): return await self.modify(rm='INBOX')\n",
+ "@patch\n",
+ "async def inbox(self:(Email,Thread)): return await self.modify(add='INBOX')"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "id": "6aaf035a",
+ "metadata": {},
+ "outputs": [
+ {
+ "data": {
+ "text/plain": [
+ "['STARRED', 'UNREAD', 'SENT', 'INBOX']"
+ ]
+ },
+ "execution_count": null,
+ "metadata": {},
+ "output_type": "execute_result"
+ }
+ ],
+ "source": [
+ "await th.star()\n",
+ "await th.labels"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "id": "b7bef90f",
+ "metadata": {},
+ "source": [
+ "Gmail treats trashing and deletions differently. Trashing moves an email or thread to the `TRASH` label, where Gmail keeps it for 30 days before purging it for good. `untrash` reverses that, so it's a safe, recoverable operation (unlike `delete`, which is permanent)."
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "id": "7bc49692",
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "#| export\n",
+ "@patch\n",
+ "async def trash(self:Email):\n",
+ " sys.audit('solvemail.Email.trash')\n",
+ " return self.update(await self.gmail.client.users.messages.trash(user_id='me', id=self.id))\n",
+ "@patch\n",
+ "async def untrash(self:Email): return self.update(await self.gmail.client.users.messages.untrash(user_id='me', id=self.id))\n",
+ "\n",
+ "@patch\n",
+ "async def trash(self:Thread):\n",
+ " sys.audit('solvemail.Thread.trash')\n",
+ " await self.gmail.client.users.threads.trash(user_id='me', id=self.id)\n",
+ " return await self.refresh()\n",
+ "@patch\n",
+ "async def untrash(self:Thread):\n",
+ " await self.gmail.client.users.threads.untrash(user_id='me', id=self.id)\n",
+ " return await self.refresh()"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "id": "c5e5bbdf",
+ "metadata": {},
+ "outputs": [
+ {
+ "data": {
+ "text/plain": [
+ "['TRASH', 'STARRED', 'UNREAD', 'SENT']"
+ ]
+ },
+ "execution_count": null,
+ "metadata": {},
+ "output_type": "execute_result"
+ }
+ ],
+ "source": [
+ "await th.trash()\n",
+ "await th.labels"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "id": "383dd011",
+ "metadata": {},
+ "outputs": [
+ {
+ "data": {
+ "text/plain": [
+ "['STARRED', 'UNREAD', 'SENT']"
+ ]
+ },
+ "execution_count": null,
+ "metadata": {},
+ "output_type": "execute_result"
+ }
+ ],
+ "source": [
+ "await th.untrash()\n",
+ "await th.labels"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "id": "5f900aa0",
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "#| export\n",
+ "@patch\n",
+ "async def delete(self:Draft):\n",
+ " sys.audit('solvemail.Draft.delete')\n",
+ " return await self.gmail.client.users.drafts.delete(user_id='me', id=self.draft_id)\n",
+ "\n",
+ "@patch\n",
+ "async def delete(self:Thread):\n",
+ " sys.audit('solvemail.Thread.delete')\n",
+ " return await self.gmail.client.users.threads.delete(user_id='me', id=self.id)"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "id": "68b2b6cf",
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "await th.delete()"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "id": "5a46a5ad",
+ "metadata": {},
+ "source": [
+ "### Batch Operations"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "id": "3cacd9da",
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "#| export\n",
+ "@patch\n",
+ "async def batch_modify(self:Gmail, ids, add=None, rm=None):\n",
+ " \"Add/remove `add`/`rm` labels from all messages in `ids`\"\n",
+ " return await self.client.users.messages.batch_modify(\n",
+ " user_id=self.user_id, ids=listify(ids),\n",
+ " add_label_ids=await self.lbl_ids(add),\n",
+ " remove_label_ids=await self.lbl_ids(rm))\n",
+ "\n",
+ "@patch\n",
+ "async def batch_delete(self:Gmail, ids):\n",
+ " sys.audit('solvemail.Gmail.batch_delete', listify(ids))\n",
+ " return await self.client.users.messages.batch_delete(\n",
+ " user_id=self.user_id, ids=listify(ids))\n",
+ "\n",
+ "@patch\n",
+ "async def modify(self:Emails, add=None, rm=None):\n",
+ " return await self.gmail.batch_modify(self.attrgot('id'), add, rm)\n",
+ "\n",
+ "@patch\n",
+ "async def delete(self:Emails):\n",
+ " return await self.gmail.batch_delete(self.attrgot('id'))\n",
+ "\n",
+ "@patch\n",
+ "async def trash(self:Emails): return await self.modify(add='TRASH')\n",
+ "@patch\n",
+ "async def untrash(self:Emails): return await self.modify(rm='TRASH')\n",
+ "@patch\n",
+ "async def mark_read(self:Emails): return await self.modify(rm='UNREAD')\n",
+ "@patch\n",
+ "async def mark_unread(self:Emails): return await self.modify(add='UNREAD')\n",
+ "@patch\n",
+ "async def star(self:Emails): return await self.modify(add='STARRED')\n",
+ "@patch\n",
+ "async def unstar(self:Emails): return await self.modify(rm='STARRED')\n",
+ "@patch\n",
+ "async def archive(self:Emails): return await self.modify(rm='INBOX')"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "id": "b5ac0cdd",
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "sent = await asyncio.gather(*[gmail.send(to=me, subj=f'batch test {i}', body=f'test {i}') for i in range(3)])\n",
+ "ems = await Emails(sent, gmail=gmail).refresh('metadata')"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "id": "bf780976",
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "# batch modify: add a label and mark unread\n",
+ "lbl = await gmail.create_label('BatchTest')\n",
+ "await ems.modify(add=[lbl.name, 'UNREAD'])\n",
+ "await ems.refresh()\n",
+ "test_eq([lbl.id in e.labelIds for e in ems], [True]*3)\n",
+ "test_eq(['UNREAD' in e.labelIds for e in ems], [True]*3)"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "id": "ca7cef79",
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "# batch convenience: mark read + star\n",
+ "await ems.mark_read()\n",
+ "await ems.star()\n",
+ "await ems.refresh()\n",
+ "test_eq(['UNREAD' in e.labelIds for e in ems], [False]*3)\n",
+ "test_eq(['STARRED' in e.labelIds for e in ems], [True]*3)"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "id": "bbbad9e9",
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "# batch trash + untrash\n",
+ "await ems.trash()\n",
+ "await ems.refresh()\n",
+ "test_eq(['TRASH' in e.labelIds for e in ems], [True]*3)\n",
+ "await ems.untrash()\n",
+ "await ems.refresh()\n",
+ "test_eq(['TRASH' in e.labelIds for e in ems], [False]*3)"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "id": "9f52aa57",
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "# batch delete (permanent)\n",
+ "ids = ems.attrgot('id')\n",
+ "await ems.delete()\n",
+ "await lbl.delete()"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "id": "fe55c1ca",
+ "metadata": {},
+ "source": [
+ "### Unsubscribe"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "id": "51a2d940",
+ "metadata": {},
+ "source": [
+ "Well-behaved senders include a `List-Unsubscribe` header telling you how to opt out β either a `mailto:` address to email or a URL to POST to (flagged one-click by `List-Unsubscribe-Post`). `unsubscribe` reads that header and does whichever the sender offered, so you don't have to hunt for the link buried in the email."
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "id": "b5122885",
+ "metadata": {},
+ "outputs": [
+ {
+ "data": {
+ "text/plain": [
+ "(Email(19ee16c06af33255: [UNREAD,SENT,INBOX] Nathan Cooper | fake newsletter\n",
+ " Pretend marketing content),\n",
+ " Email(19ee16c082c6fc98: [UNREAD,SENT,INBOX] Nathan Cooper | fake newsletter (http)\n",
+ " Pretend marketing content))"
+ ]
+ },
+ "execution_count": null,
+ "metadata": {},
+ "output_type": "execute_result"
+ }
+ ],
+ "source": [
+ "sub1 = await gmail.send( to=me, subj='fake newsletter',\n",
+ " body='Pretend marketing content',\n",
+ " headers={'List-Unsubscribe': f''})\n",
+ "sub2 = await gmail.send( to=me, subj='fake newsletter (http)',\n",
+ " body='Pretend marketing content',\n",
+ " headers={'List-Unsubscribe': '',\n",
+ " 'List-Unsubscribe-Post': 'List-Unsubscribe=One-Click'})\n",
+ "\n",
+ "await sub1.refresh('metadata'), await sub2.refresh('metadata')"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "id": "b4a4f6f0",
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "#| export\n",
+ "def _parse_unsub(unsub, post=None):\n",
+ " \"Parse a List-Unsubscribe header into an action dict\"\n",
+ " if not unsub: return None\n",
+ " for url in re.findall(r'<([^>]+)>', unsub):\n",
+ " if url.startswith('mailto:'):\n",
+ " addr,_,qs = url[7:].partition('?')\n",
+ " subj = dict(p.split('=',1) for p in qs.split('&')).get('subject','unsubscribe') if qs else 'unsubscribe'\n",
+ " return dict(kind='mailto', addr=addr, subj=subj)\n",
+ " if url.startswith('http'):\n",
+ " return dict(kind='http', url=url, post=post or 'List-Unsubscribe=One-Click')\n",
+ " return None"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "id": "939e0ca2",
+ "metadata": {},
+ "outputs": [
+ {
+ "data": {
+ "text/plain": [
+ "({'kind': 'mailto', 'addr': 'nc@answer.ai', 'subj': 'unsubscribe'},\n",
+ " {'kind': 'http',\n",
+ " 'url': 'https://fast-http-bin.pla.sh/post',\n",
+ " 'post': 'List-Unsubscribe=One-Click'})"
+ ]
+ },
+ "execution_count": null,
+ "metadata": {},
+ "output_type": "execute_result"
+ }
+ ],
+ "source": [
+ "unsub1_md = _parse_unsub(sub1['list-unsubscribe'])\n",
+ "unsub2_md = _parse_unsub(sub2['list-unsubscribe'], sub2['list-unsubscribe-post'])\n",
+ "unsub1_md, unsub2_md"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "id": "47c21da4",
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "#| export\n",
+ "@patch\n",
+ "async def unsubscribe(self:Email):\n",
+ " \"Unsubscribe via List-Unsubscribe header (mailto or HTTP one-click POST)\"\n",
+ " if 'payload' not in self: await self.refresh('metadata')\n",
+ " act = _parse_unsub(self.get('list-unsubscribe'), self.get('list-unsubscribe-post'))\n",
+ " if not act: return None\n",
+ " if act['kind'] == 'mailto': return await self.gmail.send(to=act['addr'], subj=act['subj'], body='unsubscribe')\n",
+ " return httpx.post(act['url'], content=act['post'],\n",
+ " headers={'Content-Type':'application/x-www-form-urlencoded'})\n",
+ "\n",
+ "@patch\n",
+ "async def unsubscribe(self:Thread): return await self.last.unsubscribe()"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "id": "32617ea9",
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "unsub1 = await (await sub1.unsubscribe()).refresh('metadata')\n",
+ "unsub2 = (await sub2.unsubscribe()).json()\n",
+ "\n",
+ "test_eq(unsub1_md['addr'], unsub1.to)\n",
+ "test_eq(unsub1_md['subj'], unsub1.subj)\n",
+ "test_eq(unsub2_md['url'], unsub2['url'])\n",
+ "test_eq(unsub2['form'], {'List-Unsubscribe': 'One-Click'})"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "id": "411d31e3",
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "await sub1.delete()\n",
+ "await sub2.delete()\n",
+ "await unsub1.delete()"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "id": "53ac9006",
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "#| hide\n",
+ "import nbdev; nbdev.nbdev_export()"
+ ]
+ }
+ ],
+ "metadata": {
+ "solveit": {
+ "default_code": false,
+ "mode": "learning",
+ "use_thinking": false,
+ "use_tools": true,
+ "ver": 2
+ }
+ },
+ "nbformat": 4,
+ "nbformat_minor": 5
+}
diff --git a/nbs/_quarto.yml b/nbs/_quarto.yml
new file mode 100644
index 0000000..59a1e49
--- /dev/null
+++ b/nbs/_quarto.yml
@@ -0,0 +1,22 @@
+project:
+ type: website
+
+format:
+ html:
+ theme: cosmo
+ css: styles.css
+ toc: true
+ keep-md: true
+ commonmark: default
+
+website:
+ twitter-card: true
+ open-graph: true
+ repo-actions: [issue]
+ navbar:
+ background: primary
+ search: true
+ sidebar:
+ style: floating
+
+metadata-files: [nbdev.yml, sidebar.yml]
\ No newline at end of file
diff --git a/nbs/index.ipynb b/nbs/index.ipynb
new file mode 100644
index 0000000..1b7d65f
--- /dev/null
+++ b/nbs/index.ipynb
@@ -0,0 +1,438 @@
+{
+ "cells": [
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "id": "78e82b58",
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "#| hide\n",
+ "from PIL import Image\n",
+ "from solvemail.core import *"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "id": "b6954cf2",
+ "metadata": {},
+ "source": [
+ "# solvemail\n",
+ "\n",
+ "> Gmail / Google Workspace email client built on the Gmail API"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "id": "144a867b",
+ "metadata": {},
+ "source": [
+ "solvemail is an async Python client for Gmail. It wraps the Gmail REST API in four small classes (`Email`, `Thread`, `Draft`, and `Label`), so you can search your mail, read message bodies and attachments, organize with labels, and send or reply, all with plain method calls."
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "id": "a7fbc513",
+ "metadata": {},
+ "source": [
+ "It is built on [fastgws](https://github.com/AnswerDotAI/fastgws) for OAuth, and runs every call asynchronously, so you `await` each one. Fetching is lazy: an `Email` or `Thread` starts as just an id and loads the rest of itself only when you read a part that needs it. You choose how much comes back with the `fmt` argument: `metadata` for headers and a snippet, `full` for the whole payload, `minimal` for ids and labels, and `raw` for the original RFC 2822 bytes."
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "id": "53f1ab2d",
+ "metadata": {},
+ "source": [
+ "## Installation"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "id": "0e92406f",
+ "metadata": {},
+ "source": [
+ "Install latest from [pypi][pypi]\n",
+ "\n",
+ "```sh\n",
+ "$ pip install solvemail\n",
+ "```\n",
+ "\n",
+ "[pypi]: https://pypi.org/project/solvemail/"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "id": "ec04ea4d",
+ "metadata": {},
+ "source": [
+ "## How to use"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "id": "4e3e8365",
+ "metadata": {},
+ "source": [
+ "solvemail signs in with OAuth 2.0 through [fastgws](https://github.com/AnswerDotAI/fastgws), which expects an OAuth client secret at `~/.config/fastgws/credentials.json`. The fastgws docs cover how to create that file from a Google Cloud project. The first connection opens a browser to authorize the scopes you asked for, then caches the token so later runs don't prompt again."
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "id": "eb420e15",
+ "metadata": {},
+ "source": [
+ "Create a `Gmail` client with the scopes you need: `readonly` to read and search, `modify` to also add and remove labels, or `full` for everything including permanent deletion. `profile()` returns the signed-in account, with the address on its `email` attribute."
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "id": "2c530316",
+ "metadata": {},
+ "outputs": [
+ {
+ "data": {
+ "text/plain": [
+ "'nc@answer.ai'"
+ ]
+ },
+ "execution_count": null,
+ "metadata": {},
+ "output_type": "execute_result"
+ }
+ ],
+ "source": [
+ "gmail = Gmail(scopes='full')\n",
+ "profile = await gmail.profile()\n",
+ "me = profile.email\n",
+ "me"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "id": "0752caa0",
+ "metadata": {},
+ "source": [
+ "### Sending and replying"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "id": "67709761",
+ "metadata": {},
+ "source": [
+ "`send` builds and sends a message. The body is markdown and is converted to HTML for you, and `att=` takes a list of file paths. `reply` keeps the conversation threaded by setting the right `In-Reply-To`, `References`, and `Re:` subject; `reply_draft` makes a draft you can review first. Pass `reply_all=True` to cc the other recipients. `forward` re-sends a message, pasting in the original content and re-attaching its files."
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "id": "cfa6fd73",
+ "metadata": {},
+ "outputs": [
+ {
+ "data": {
+ "text/markdown": [
+ "\n",
+ "\n",
+ "```python\n",
+ "Email(19ec726b6b18f114)\n",
+ "```\n",
+ "\n",
+ ""
+ ],
+ "text/plain": [
+ "Email(19ec726b6b18f114)"
+ ]
+ },
+ "execution_count": null,
+ "metadata": {},
+ "output_type": "execute_result"
+ }
+ ],
+ "source": [
+ "em = await gmail.send(to=me, subj='solvemail html test',\n",
+ " body='Hello from **solvemail**!',\n",
+ " html='Hello from solvemail!
',\n",
+ " att=['otter.png'])\n",
+ "await em.reply(body='Cute otter!')"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "id": "054a40ba",
+ "metadata": {},
+ "source": [
+ "### Searching"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "id": "4d70370e",
+ "metadata": {},
+ "source": [
+ "`search` takes Gmail's own query syntax, the same operators you would type in the Gmail search box: `from:`, `subject:`, `has:attachment`, `is:unread`, `newer_than:7d`, and so on. It returns `Email` objects already filled in to `metadata`, so you get senders, subjects, and snippets without a second call. Use `search_threads` to get whole conversations instead, and `search_drafts` for drafts."
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "id": "c032b0c5",
+ "metadata": {},
+ "outputs": [
+ {
+ "data": {
+ "text/markdown": [
+ "\n",
+ "\n",
+ "| id | from | subj | π | date |\n",
+ "|-|-|-|-|-|\n",
+ "| 19ec726b0b4efb16 | Nathan Cooper | solvemail html test | π | Sun, 14 Jun 2026 10:21:01 -0700 |\n",
+ "\n",
+ " "
+ ],
+ "text/plain": [
+ "[Email(19ec726b0b4efb16: [UNREAD,SENT,INBOX] πNathan Cooper | solvemail html test\n",
+ "Hello from solvemail!)]"
+ ]
+ },
+ "execution_count": null,
+ "metadata": {},
+ "output_type": "execute_result"
+ }
+ ],
+ "source": [
+ "ems = await gmail.search('has:attachment', max_results=1)\n",
+ "ems"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "id": "65b23340",
+ "metadata": {},
+ "source": [
+ "### Reading emails"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "id": "17034ad8",
+ "metadata": {},
+ "source": [
+ "Three helpers give you the body at increasing levels of cleanup. `text` is the raw plain-text part as the sender wrote it. `html(clean=True)` returns the HTML with Gmail signatures and quoted reply chains stripped out. `body(clean=True)` flattens that cleaned HTML back to readable text, which is usually what you want for reading or passing to an LLM. Headers are available as attributes once fetched: `frm`, `to`, `subj`, `date`, and `labelIds`."
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "id": "624d4bd1",
+ "metadata": {},
+ "outputs": [
+ {
+ "name": "stdout",
+ "output_type": "stream",
+ "text": [
+ "Hello from solvemail!\n"
+ ]
+ }
+ ],
+ "source": [
+ "em = ems[0]\n",
+ "print(await em.body())"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "id": "871ade2c",
+ "metadata": {},
+ "source": [
+ "### Attachments"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "id": "9f94e445",
+ "metadata": {},
+ "source": [
+ "`attachments` pulls a message's files out as `EmailAttachment` objects, with `filename`, `mimeType`, and `body.size` available before you download anything. Each one is lazy: call `fetch()` to get the bytes, or pass `save=` a folder or filename to write it straight to disk."
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "id": "da40b9fd",
+ "metadata": {},
+ "outputs": [
+ {
+ "data": {
+ "image/jpeg": "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",
+ "image/png": "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",
+ "text/plain": [
+ ""
+ ]
+ },
+ "execution_count": null,
+ "metadata": {},
+ "output_type": "execute_result"
+ }
+ ],
+ "source": [
+ "att = (await em.attachments)[0]\n",
+ "await att.fetch(save='otter_test.png')\n",
+ "Image.open('otter_test.png').resize((128,128))"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "id": "8fcfd12b",
+ "metadata": {},
+ "source": [
+ "### Threads"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "id": "5278f8f7",
+ "metadata": {},
+ "source": [
+ "A thread is a conversation: an email and its replies grouped under one id. `search_threads` returns `Thread` objects, each holding its messages as `Email` objects on `.emails`, with `.last` for the most recent. Everything you know about an email works on each message in the thread."
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "id": "e3da57a3",
+ "metadata": {},
+ "outputs": [
+ {
+ "name": "stdout",
+ "output_type": "stream",
+ "text": [
+ "Cute otter!\n"
+ ]
+ }
+ ],
+ "source": [
+ "th = (await gmail.search_threads(max_results=1))[0]\n",
+ "print(await th.last.body())"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "id": "3188d495",
+ "metadata": {},
+ "source": [
+ "### Labels"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "id": "24f47b87",
+ "metadata": {},
+ "source": [
+ "Labels are Gmail's tags. System labels like `INBOX` and `STARRED` use their name as their id; custom labels get an id like `Label_42`. You can pass a name or an id anywhere a label is expected, and solvemail resolves it for you. `modify` adds and removes labels, and there are shortcuts for the common ones."
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "id": "29a2210b",
+ "metadata": {},
+ "outputs": [
+ {
+ "data": {
+ "text/plain": [
+ "['Otters', 'STARRED', 'SENT']"
+ ]
+ },
+ "execution_count": null,
+ "metadata": {},
+ "output_type": "execute_result"
+ }
+ ],
+ "source": [
+ "lbl = await gmail.create_label('Otters')\n",
+ "await em.modify(add='Otters', rm='INBOX')\n",
+ "await em.mark_read()\n",
+ "await em.star()\n",
+ "await em.labels"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "id": "8be13516",
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "#| hide\n",
+ "# clean up\n",
+ "await lbl.delete()\n",
+ "await th.delete()"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "id": "6a5253ab",
+ "metadata": {},
+ "source": [
+ "### Unsubscribe"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "id": "82ee0063",
+ "metadata": {},
+ "source": [
+ "Newsletters usually carry a `List-Unsubscribe` header saying how to opt out, either a `mailto:` address or a one-click URL. `unsubscribe` reads that header and does whichever the sender offered, so you do not have to hunt for the link buried in the email.\n",
+ "\n",
+ "```python\n",
+ "await em.unsubscribe()\n",
+ "```"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "id": "8510c26f",
+ "metadata": {},
+ "source": [
+ "### Use as an LLM skill"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "id": "88eeee6d",
+ "metadata": {},
+ "source": [
+ "solvemail registers a [pyskills](https://github.com/AnswerDotAI/pyskills) skill, so an LLM host such as solveit can load it and use the read-only API (searching, reading, labels) without write access. Sending, replying, forwarding, and deleting are documented in the skill but are not enabled by default, so you turn them on deliberately when a task needs them."
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "id": "8815e442",
+ "metadata": {},
+ "source": [
+ "## Credits"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "id": "f6667eed",
+ "metadata": {},
+ "source": [
+ "Inspired by [ezgmail](https://github.com/asweigart/ezgmail) by [Al Sweigart](https://inventwithpython.com/) β thanks Al for the great work! The ezgmail repo also has excellent documentation on setting up Gmail API credentials."
+ ]
+ }
+ ],
+ "metadata": {
+ "solveit": {
+ "default_code": false,
+ "mode": "concise",
+ "use_thinking": true,
+ "use_tools": true,
+ "ver": 2
+ }
+ },
+ "nbformat": 4,
+ "nbformat_minor": 5
+}
diff --git a/nbs/nbdev.yml b/nbs/nbdev.yml
new file mode 100644
index 0000000..e2b59a8
--- /dev/null
+++ b/nbs/nbdev.yml
@@ -0,0 +1,9 @@
+project:
+ output-dir: _docs
+
+website:
+ title: "solvemail"
+ site-url: "https://AnswerDotAI.github.io/solvemail"
+ description: "Gmail / Google Workspace email client built on the Gmail API"
+ repo-branch: main
+ repo-url: "https://github.com/AnswerDotAI/solvemail"
diff --git a/nbs/otter.png b/nbs/otter.png
new file mode 100644
index 0000000..9567d39
Binary files /dev/null and b/nbs/otter.png differ
diff --git a/nbs/otter_test.png b/nbs/otter_test.png
new file mode 100644
index 0000000..9567d39
Binary files /dev/null and b/nbs/otter_test.png differ
diff --git a/nbs/styles.css b/nbs/styles.css
new file mode 100644
index 0000000..66ccc49
--- /dev/null
+++ b/nbs/styles.css
@@ -0,0 +1,37 @@
+.cell {
+ margin-bottom: 1rem;
+}
+
+.cell > .sourceCode {
+ margin-bottom: 0;
+}
+
+.cell-output > pre {
+ margin-bottom: 0;
+}
+
+.cell-output > pre, .cell-output > .sourceCode > pre, .cell-output-stdout > pre {
+ margin-left: 0.8rem;
+ margin-top: 0;
+ background: none;
+ border-left: 2px solid lightsalmon;
+ border-top-left-radius: 0;
+ border-top-right-radius: 0;
+}
+
+.cell-output > .sourceCode {
+ border: none;
+}
+
+.cell-output > .sourceCode {
+ background: none;
+ margin-top: 0;
+}
+
+div.description {
+ padding-left: 2px;
+ padding-top: 5px;
+ font-style: italic;
+ font-size: 135%;
+ opacity: 70%;
+}
diff --git a/pyproject.toml b/pyproject.toml
index 29a6442..f9ec0d1 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -1,51 +1,38 @@
[build-system]
-requires = ["setuptools>=68", "wheel"]
+requires = ["setuptools>=64"]
build-backend = "setuptools.build_meta"
[project]
name = "solvemail"
dynamic = ["version"]
-description = "A fastai-style Gmail API client with convenient read/write support"
+description = "Gmail / Google Workspace email client built on the Gmail API"
readme = "README.md"
requires-python = ">=3.10"
license = {text = "Apache-2.0"}
-authors = [{name="Answer.AI"}]
-dependencies = [
- "fastcore>=1.7.0",
- "google-api-python-client>=2.120.0",
- "google-auth>=2.30.0",
- "google-auth-oauthlib>=1.2.0",
- "google-auth-httplib2>=0.2.0",
- "httpx>=0.27.0",
- "beautifulsoup4>=4.12.0",
- "mistletoe>=1.3.0",
+authors = [{name = "Nathan Cooper", email = "nathanacooper@proton.me"}]
+keywords = ['nbdev']
+classifiers = [
+ "Programming Language :: Python :: 3",
+ "Programming Language :: Python :: 3 :: Only",
]
+dependencies = ['fastcore', 'fastgws>=0.2.4']
-[project.optional-dependencies]
-dev = [
- "pytest>=8.0.0",
- "pytest-timeout>=2.2.0",
- "build",
- "twine",
- "fastship>=0.0.10",
-]
+[project.urls]
+Repository = "https://github.com/AnswerDotAI/solvemail"
+Documentation = "https://AnswerDotAI.github.io/solvemail/"
+
+[project.entry-points.nbdev]
+solvemail = "solvemail._modidx:d"
[tool.setuptools.dynamic]
-version = { attr = "solvemail.__version__" }
+version = {attr = "solvemail.__version__"}
[tool.setuptools.packages.find]
-include = ["solvemail", "solvemail.*"]
-
-[tool.pytest.ini_options]
-addopts = "-q"
-testpaths = ["tests"]
-timeout = 120
-filterwarnings = [
- "ignore::DeprecationWarning:httplib2.*:",
- "ignore::pytest.PytestConfigWarning",
-]
+include = ["solvemail"]
-[tool.fastship]
-branch = "main"
-changelog_file = "CHANGELOG.md"
+[project.entry-points.pyskills]
+"solvemail.skill" = "solvemail.skill"
+[tool.nbdev]
+allowed_metadata_keys = ['solveit']
+allowed_cell_metadata_keys = ['solveit_ai']
diff --git a/solvemail/__init__.py b/solvemail/__init__.py
index ecffb9b..3dc1f76 100644
--- a/solvemail/__init__.py
+++ b/solvemail/__init__.py
@@ -1,52 +1 @@
-__version__ = "0.1.10"
-
-
-
-from fastcore.utils import *
-from functools import wraps
-from inspect import signature
-from . import auth,core
-from .auth import *
-from .core import *
-import time
-
-__all__ = [
- 'init','g','solvemail_tools','refresh_solvemail','wait_secs'
- ] + auth.__all__ + core.__all__ + [
- k for k in dir(Gmail) if not k.startswith('_')]
-
-def __dir__(): return __all__
-
-_g = None
-
-def _proxy(name):
- method = getattr(Gmail, name)
- @wraps(method)
- def fn(*a, **kw): return getattr(g(), name)(*a, **kw)
- return fn
-
-def refresh_solvemail():
- "Reload Gmail methods"
- for _k in dir(Gmail):
- if callable(getattr(Gmail, _k)) and not _k.startswith('_'): globals()[_k] = _proxy(_k)
-
-refresh_solvemail()
-
-def init(creds=None, creds_path='credentials.json', token_path='token.json', scopes=None, user_id='me',
- interactive=True, redirect_uri=None, retries=3):
- "Create a global `Gmail` client using `creds_path`/`token_path` and `scopes`"
- global _g
- if creds is None: creds = oauth_creds(creds_path=creds_path, token_path=token_path, scopes=scopes, interactive=interactive, redirect_uri=redirect_uri)
- _g = Gmail(creds=creds, user_id=user_id, retries=retries)
-
-def g():
- "Return the global `Gmail` client"
- if _g is None: raise AttributeError('Call solvemail.init(...) first')
- return _g
-
-def solvemail_tools(): return '&`[search_threads, search_emails, thread, draft, drafts, labels, label, find_labels, profile, send, reply_draft, reply_to_thread, create_label, trash_emails, view_inbox, view_inbox_threads, view_email, view_thread, batch_delete, batch_label, create_draft, email, send_drafts, report_spam]`'
-
-def wait_secs(secs: float = 1.0):
- "Pause for `secs` seconds; use if rate limited"
- time.sleep(secs)
- return f"Waited {secs}s"
+__version__ = "0.1.0"
diff --git a/solvemail/_modidx.py b/solvemail/_modidx.py
new file mode 100644
index 0000000..90f02cb
--- /dev/null
+++ b/solvemail/_modidx.py
@@ -0,0 +1,122 @@
+# Autogenerated by nbdev
+
+d = { 'settings': { 'branch': 'main',
+ 'doc_baseurl': '/solvemail',
+ 'doc_host': 'https://AnswerDotAI.github.io',
+ 'git_url': 'https://github.com/AnswerDotAI/solvemail',
+ 'lib_path': 'solvemail'},
+ 'syms': { 'solvemail.core': { 'solvemail.core.(Email, Thread).archive': ('core.html#(email, thread).archive', 'solvemail/core.py'),
+ 'solvemail.core.(Email, Thread).inbox': ('core.html#(email, thread).inbox', 'solvemail/core.py'),
+ 'solvemail.core.(Email, Thread).mark_read': ('core.html#(email, thread).mark_read', 'solvemail/core.py'),
+ 'solvemail.core.(Email, Thread).mark_unread': ( 'core.html#(email, thread).mark_unread',
+ 'solvemail/core.py'),
+ 'solvemail.core.(Email, Thread).star': ('core.html#(email, thread).star', 'solvemail/core.py'),
+ 'solvemail.core.(Email, Thread).unstar': ('core.html#(email, thread).unstar', 'solvemail/core.py'),
+ 'solvemail.core.Draft': ('core.html#draft', 'solvemail/core.py'),
+ 'solvemail.core.Draft.__init__': ('core.html#draft.__init__', 'solvemail/core.py'),
+ 'solvemail.core.Draft.__repr__': ('core.html#draft.__repr__', 'solvemail/core.py'),
+ 'solvemail.core.Draft.delete': ('core.html#draft.delete', 'solvemail/core.py'),
+ 'solvemail.core.Draft.fetch': ('core.html#draft.fetch', 'solvemail/core.py'),
+ 'solvemail.core.Draft.refresh': ('core.html#draft.refresh', 'solvemail/core.py'),
+ 'solvemail.core.Draft.send': ('core.html#draft.send', 'solvemail/core.py'),
+ 'solvemail.core.Draft.update_draft': ('core.html#draft.update_draft', 'solvemail/core.py'),
+ 'solvemail.core.Drafts': ('core.html#drafts', 'solvemail/core.py'),
+ 'solvemail.core.Drafts.__init__': ('core.html#drafts.__init__', 'solvemail/core.py'),
+ 'solvemail.core.Drafts._repr_markdown_': ('core.html#drafts._repr_markdown_', 'solvemail/core.py'),
+ 'solvemail.core.Drafts.refresh': ('core.html#drafts.refresh', 'solvemail/core.py'),
+ 'solvemail.core.Email': ('core.html#email', 'solvemail/core.py'),
+ 'solvemail.core.Email.__init__': ('core.html#email.__init__', 'solvemail/core.py'),
+ 'solvemail.core.Email.__repr__': ('core.html#email.__repr__', 'solvemail/core.py'),
+ 'solvemail.core.Email.attachments': ('core.html#email.attachments', 'solvemail/core.py'),
+ 'solvemail.core.Email.body': ('core.html#email.body', 'solvemail/core.py'),
+ 'solvemail.core.Email.delete': ('core.html#email.delete', 'solvemail/core.py'),
+ 'solvemail.core.Email.fetch': ('core.html#email.fetch', 'solvemail/core.py'),
+ 'solvemail.core.Email.forward': ('core.html#email.forward', 'solvemail/core.py'),
+ 'solvemail.core.Email.html': ('core.html#email.html', 'solvemail/core.py'),
+ 'solvemail.core.Email.labels': ('core.html#email.labels', 'solvemail/core.py'),
+ 'solvemail.core.Email.modify': ('core.html#email.modify', 'solvemail/core.py'),
+ 'solvemail.core.Email.refresh': ('core.html#email.refresh', 'solvemail/core.py'),
+ 'solvemail.core.Email.reply': ('core.html#email.reply', 'solvemail/core.py'),
+ 'solvemail.core.Email.reply_draft': ('core.html#email.reply_draft', 'solvemail/core.py'),
+ 'solvemail.core.Email.text': ('core.html#email.text', 'solvemail/core.py'),
+ 'solvemail.core.Email.trash': ('core.html#email.trash', 'solvemail/core.py'),
+ 'solvemail.core.Email.unsubscribe': ('core.html#email.unsubscribe', 'solvemail/core.py'),
+ 'solvemail.core.Email.untrash': ('core.html#email.untrash', 'solvemail/core.py'),
+ 'solvemail.core.EmailAttachment': ('core.html#emailattachment', 'solvemail/core.py'),
+ 'solvemail.core.EmailAttachment.__init__': ('core.html#emailattachment.__init__', 'solvemail/core.py'),
+ 'solvemail.core.EmailAttachment.__repr__': ('core.html#emailattachment.__repr__', 'solvemail/core.py'),
+ 'solvemail.core.EmailAttachment.fetch': ('core.html#emailattachment.fetch', 'solvemail/core.py'),
+ 'solvemail.core.Emails': ('core.html#emails', 'solvemail/core.py'),
+ 'solvemail.core.Emails.__init__': ('core.html#emails.__init__', 'solvemail/core.py'),
+ 'solvemail.core.Emails._repr_markdown_': ('core.html#emails._repr_markdown_', 'solvemail/core.py'),
+ 'solvemail.core.Emails.archive': ('core.html#emails.archive', 'solvemail/core.py'),
+ 'solvemail.core.Emails.delete': ('core.html#emails.delete', 'solvemail/core.py'),
+ 'solvemail.core.Emails.mark_read': ('core.html#emails.mark_read', 'solvemail/core.py'),
+ 'solvemail.core.Emails.mark_unread': ('core.html#emails.mark_unread', 'solvemail/core.py'),
+ 'solvemail.core.Emails.modify': ('core.html#emails.modify', 'solvemail/core.py'),
+ 'solvemail.core.Emails.refresh': ('core.html#emails.refresh', 'solvemail/core.py'),
+ 'solvemail.core.Emails.star': ('core.html#emails.star', 'solvemail/core.py'),
+ 'solvemail.core.Emails.trash': ('core.html#emails.trash', 'solvemail/core.py'),
+ 'solvemail.core.Emails.unstar': ('core.html#emails.unstar', 'solvemail/core.py'),
+ 'solvemail.core.Emails.untrash': ('core.html#emails.untrash', 'solvemail/core.py'),
+ 'solvemail.core.Gmail': ('core.html#gmail', 'solvemail/core.py'),
+ 'solvemail.core.Gmail.__init__': ('core.html#gmail.__init__', 'solvemail/core.py'),
+ 'solvemail.core.Gmail._search': ('core.html#gmail._search', 'solvemail/core.py'),
+ 'solvemail.core.Gmail.batch_delete': ('core.html#gmail.batch_delete', 'solvemail/core.py'),
+ 'solvemail.core.Gmail.batch_modify': ('core.html#gmail.batch_modify', 'solvemail/core.py'),
+ 'solvemail.core.Gmail.create_draft': ('core.html#gmail.create_draft', 'solvemail/core.py'),
+ 'solvemail.core.Gmail.create_label': ('core.html#gmail.create_label', 'solvemail/core.py'),
+ 'solvemail.core.Gmail.find_labels': ('core.html#gmail.find_labels', 'solvemail/core.py'),
+ 'solvemail.core.Gmail.init': ('core.html#gmail.init', 'solvemail/core.py'),
+ 'solvemail.core.Gmail.label': ('core.html#gmail.label', 'solvemail/core.py'),
+ 'solvemail.core.Gmail.labels': ('core.html#gmail.labels', 'solvemail/core.py'),
+ 'solvemail.core.Gmail.lbl_ids': ('core.html#gmail.lbl_ids', 'solvemail/core.py'),
+ 'solvemail.core.Gmail.profile': ('core.html#gmail.profile', 'solvemail/core.py'),
+ 'solvemail.core.Gmail.search_drafts': ('core.html#gmail.search_drafts', 'solvemail/core.py'),
+ 'solvemail.core.Gmail.search_emails': ('core.html#gmail.search_emails', 'solvemail/core.py'),
+ 'solvemail.core.Gmail.search_threads': ('core.html#gmail.search_threads', 'solvemail/core.py'),
+ 'solvemail.core.Gmail.send': ('core.html#gmail.send', 'solvemail/core.py'),
+ 'solvemail.core.Label': ('core.html#label', 'solvemail/core.py'),
+ 'solvemail.core.Label.__init__': ('core.html#label.__init__', 'solvemail/core.py'),
+ 'solvemail.core.Label.__repr__': ('core.html#label.__repr__', 'solvemail/core.py'),
+ 'solvemail.core.Label.delete': ('core.html#label.delete', 'solvemail/core.py'),
+ 'solvemail.core.Label.is_sys': ('core.html#label.is_sys', 'solvemail/core.py'),
+ 'solvemail.core.Label.patch': ('core.html#label.patch', 'solvemail/core.py'),
+ 'solvemail.core.Label.refresh': ('core.html#label.refresh', 'solvemail/core.py'),
+ 'solvemail.core.Label.rename': ('core.html#label.rename', 'solvemail/core.py'),
+ 'solvemail.core.Thread': ('core.html#thread', 'solvemail/core.py'),
+ 'solvemail.core.Thread.__init__': ('core.html#thread.__init__', 'solvemail/core.py'),
+ 'solvemail.core.Thread.__repr__': ('core.html#thread.__repr__', 'solvemail/core.py'),
+ 'solvemail.core.Thread._repr_markdown_': ('core.html#thread._repr_markdown_', 'solvemail/core.py'),
+ 'solvemail.core.Thread.attachments': ('core.html#thread.attachments', 'solvemail/core.py'),
+ 'solvemail.core.Thread.delete': ('core.html#thread.delete', 'solvemail/core.py'),
+ 'solvemail.core.Thread.fetch': ('core.html#thread.fetch', 'solvemail/core.py'),
+ 'solvemail.core.Thread.forward': ('core.html#thread.forward', 'solvemail/core.py'),
+ 'solvemail.core.Thread.labelIds': ('core.html#thread.labelids', 'solvemail/core.py'),
+ 'solvemail.core.Thread.labels': ('core.html#thread.labels', 'solvemail/core.py'),
+ 'solvemail.core.Thread.last': ('core.html#thread.last', 'solvemail/core.py'),
+ 'solvemail.core.Thread.modify': ('core.html#thread.modify', 'solvemail/core.py'),
+ 'solvemail.core.Thread.refresh': ('core.html#thread.refresh', 'solvemail/core.py'),
+ 'solvemail.core.Thread.reply': ('core.html#thread.reply', 'solvemail/core.py'),
+ 'solvemail.core.Thread.reply_draft': ('core.html#thread.reply_draft', 'solvemail/core.py'),
+ 'solvemail.core.Thread.trash': ('core.html#thread.trash', 'solvemail/core.py'),
+ 'solvemail.core.Thread.unsubscribe': ('core.html#thread.unsubscribe', 'solvemail/core.py'),
+ 'solvemail.core.Thread.untrash': ('core.html#thread.untrash', 'solvemail/core.py'),
+ 'solvemail.core.Threads': ('core.html#threads', 'solvemail/core.py'),
+ 'solvemail.core.Threads.__init__': ('core.html#threads.__init__', 'solvemail/core.py'),
+ 'solvemail.core.Threads._repr_markdown_': ('core.html#threads._repr_markdown_', 'solvemail/core.py'),
+ 'solvemail.core.Threads.refresh': ('core.html#threads.refresh', 'solvemail/core.py'),
+ 'solvemail.core._clean_html': ('core.html#_clean_html', 'solvemail/core.py'),
+ 'solvemail.core._draft_row': ('core.html#_draft_row', 'solvemail/core.py'),
+ 'solvemail.core._email_row': ('core.html#_email_row', 'solvemail/core.py'),
+ 'solvemail.core._flat_hdrs': ('core.html#_flat_hdrs', 'solvemail/core.py'),
+ 'solvemail.core._fwd_one': ('core.html#_fwd_one', 'solvemail/core.py'),
+ 'solvemail.core._has_att': ('core.html#_has_att', 'solvemail/core.py'),
+ 'solvemail.core._norm_lbl': ('core.html#_norm_lbl', 'solvemail/core.py'),
+ 'solvemail.core._parse_unsub': ('core.html#_parse_unsub', 'solvemail/core.py'),
+ 'solvemail.core._reply_hdrs': ('core.html#_reply_hdrs', 'solvemail/core.py'),
+ 'solvemail.core._reply_quote': ('core.html#_reply_quote', 'solvemail/core.py'),
+ 'solvemail.core._thread_row': ('core.html#_thread_row', 'solvemail/core.py'),
+ 'solvemail.core.hsize': ('core.html#hsize', 'solvemail/core.py')},
+ 'solvemail.email': {},
+ 'solvemail.skill': {}}}
diff --git a/solvemail/auth.py b/solvemail/auth.py
deleted file mode 100644
index 506b5c0..0000000
--- a/solvemail/auth.py
+++ /dev/null
@@ -1,65 +0,0 @@
-from fastcore.utils import *
-from pathlib import Path
-import os,sys,webbrowser
-from google.auth.transport.requests import Request
-from google.oauth2.credentials import Credentials
-from google.oauth2 import service_account
-from google_auth_oauthlib.flow import InstalledAppFlow,Flow
-from googleapiclient.discovery import build
-
-__all__ = ['df_scopes','oauth_creds','svc_acct_creds','gmail_service','browser_available']
-
-df_scopes = [
- 'https://www.googleapis.com/auth/gmail.modify',
- 'https://www.googleapis.com/auth/gmail.compose',
- 'https://www.googleapis.com/auth/gmail.labels',
-]
-
-def browser_available():
- "Check if a browser can be opened in current environment"
- if os.environ.get('NO_BROWSER'): return False
- if os.environ.get('SSH_CONNECTION') and not os.environ.get('DISPLAY'): return False
- if os.path.exists('/.dockerenv'): return False
- if os.environ.get('container'): return False
- if sys.platform.startswith('linux') and not os.environ.get('DISPLAY') and not os.environ.get('WAYLAND_DISPLAY'): return False
- try: webbrowser.get(); return True
- except webbrowser.Error: return False
-
-def oauth_creds(creds_path='credentials.json', token_path='token.json', scopes=None, interactive=True, redirect_uri=None):
- "OAuth creds from `creds_path`/`token_path` for `scopes`"
- scopes = ifnone(scopes, df_scopes)
- creds_path,token_path = Path(creds_path),Path(token_path)
- creds = Credentials.from_authorized_user_file(str(token_path), scopes) if token_path.exists() else None
- if creds and creds.valid: return creds
- if creds and creds.expired and creds.refresh_token:
- creds.refresh(Request())
- token_path.write_text(creds.to_json())
- return creds
- if not interactive: raise ValueError('Missing or invalid token, and `interactive=False`')
- if browser_available() and not redirect_uri:
- auth_flow = InstalledAppFlow.from_client_secrets_file(str(creds_path), scopes=scopes)
- creds = auth_flow.run_local_server()
- else:
- auth_flow = Flow.from_client_secrets_file(str(creds_path), scopes=scopes)
- auth_flow.redirect_uri = ifnone(redirect_uri, 'http://localhost/')
- auth_url, _ = auth_flow.authorization_url(access_type='offline', prompt='consent')
- if IN_NOTEBOOK:
- from IPython.display import display, HTML
- handle = display(HTML(f'Click to authorize'), display_id=True)
- else: print(f'Authorize here: {auth_url}')
- code = input("Paste the code: ")
- auth_flow.fetch_token(code=code)
- creds = auth_flow.credentials
- if IN_NOTEBOOK: handle.update(HTML('Auth complete'))
- token_path.write_text(creds.to_json())
- return creds
-
-def svc_acct_creds(sa_path,scopes=None,subject=None):
- "Service account creds from `sa_path`, optionally delegated to `subject`"
- scopes = ifnone(scopes,df_scopes)
- creds = service_account.Credentials.from_service_account_file(str(sa_path),scopes=scopes)
- return creds.with_subject(subject) if subject else creds
-
-def gmail_service(creds,cache_discovery=False):
- "Build a Gmail API service from `creds`"
- return build('gmail','v1',credentials=creds,cache_discovery=cache_discovery)
diff --git a/solvemail/core.py b/solvemail/core.py
index 719572b..b0f17f4 100644
--- a/solvemail/core.py
+++ b/solvemail/core.py
@@ -1,764 +1,598 @@
-from fastcore.utils import *
-from fastcore.meta import *
-import re,time,html,httpx,mistletoe,sys
-from bs4 import BeautifulSoup
-from googleapiclient.errors import HttpError
-from .auth import gmail_service
-from .email import b64d,mk_email,raw_email,parse_raw,hdrs_dict,att_parts,txt_part,html_part
-
-__all__ = ['Gmail','Label','Email','Thread','Draft']
+"""User facing API for solvemail
-_sys_lbls = {o for o in 'INBOX SPAM TRASH UNREAD STARRED IMPORTANT SENT DRAFT CHAT CATEGORY_FORUMS CATEGORY_UPDATES CATEGORY_PERSONAL CATEGORY_PROMOTIONS CATEGORY_SOCIAL'.split()}
+Docs: https://AnswerDotAI.github.io/solvemailcore.html.md"""
-def _as_id(o,attr='id'): return getattr(o,attr) if hasattr(o,attr) else o
-def _norm_lbl(l): return l.upper() if isinstance(l,str) and l.upper() in _sys_lbls else l
-def _uniq(xs): return L(xs).filter().unique().items
-def _exp_backoff(i,base=0.25,cap=4): time.sleep(min(cap,base*(2**i)))
+# AUTOGENERATED! DO NOT EDIT! File to edit: ../nbs/00_core.ipynb.
-class Label:
- def __init__(self,gmail,d): store_attr('gmail,d')
- def __repr__(self): return f'Label({self.id}:{self.name})'
- @property
- def id(self): return self.d.get('id')
- @property
- def name(self): return self.d.get('name')
- @property
- def is_sys(self): return self.d.get('type')=='system' or self.id in _sys_lbls
+# %% auto #0
+__all__ = ['Gmail', 'Email', 'Emails', 'hsize', 'EmailAttachment', 'Draft', 'Drafts', 'Thread', 'Threads', 'Label']
- def refresh(self):
- "Reload this label"
- self.d = self.gmail._exec(self.gmail._u.labels().get(userId=self.gmail.user_id,id=self.id))
- self.gmail._lbls = None
- return self
+# %% ../nbs/00_core.ipynb #fe46425b
+from bs4 import BeautifulSoup
+from email.utils import parsedate_to_datetime
+from fastcore.all import *
+from fastgws import GMail
+from fastgws.auth import *
+from .email import *
- def delete(self):
- "Delete this label"
- res = self.gmail._exec(self.gmail._u.labels().delete(userId=self.gmail.user_id,id=self.id))
- self.gmail._lbls = None
- return res
+import asyncio,html,httpx,mistletoe,sys
- def patch(self,**kwargs):
- "Patch this label using `kwargs`"
- self.d = self.gmail._exec(self.gmail._u.labels().patch(userId=self.gmail.user_id,id=self.id,body=kwargs))
- self.gmail._lbls = None
- return self
-
- def rename(self,name):
- "Rename this label to `name`"
- return self.patch(name=name)
+# %% ../nbs/00_core.ipynb #da884c81
+class Gmail:
+ def __init__(self, client, user_id='me'):
+ store_attr()
+ self._lbls = None
-class Email:
- def __init__(self,gmail,id=None,d=None):
- store_attr('gmail')
- self.d = d or {}
- self._id = ifnone(id,self.d.get('id'))
- self._cache = {}
+ @classmethod
+ async def init(cls, scopes='readonly', creds=None, user_id='me',
+ redirect_uri='https://oauth.appapis.org/redirect',
+ listen=False, port=0, open_url=print):
+ if scopes == 'full': scopes = ['https://mail.google.com/']
+ else: scopes = [f'https://www.googleapis.com/auth/gmail.{x}' for x in listify(scopes)]
+ if creds is None:
+ creds = await oauth_creds(scopes=scopes, redirect_uri=redirect_uri, listen=listen,
+ port=port, open_url=open_url)
+ return cls(GMail(creds=creds), user_id=user_id)
+
+ async def profile(self):
+ "Profile resource with `email` attribute"
+ d = await self.client.users.get_profile(user_id=self.user_id)
+ return AttrDict(d, email=d.emailAddress)
+
+# %% ../nbs/00_core.ipynb #9563f05e
+def _flat_hdrs(d):
+ keep = {'date','from','to','subject','message-id','references',
+ 'reply-to','list-unsubscribe','list-unsubscribe-post'}
+ hdrs = {x['name'].lower(): x['value'] for x in d.payload.headers if x['name'].lower() in keep}
+ if 'from' in hdrs: hdrs['frm'] = hdrs.pop('from')
+ if 'subject' in hdrs: hdrs['subj'] = hdrs.pop('subject')
+ if 'message-id' in hdrs: hdrs['rfcid'] = hdrs.pop('message-id')
+ d.update(hdrs)
+ return d
+
+def _has_att(e): return e.payload.get('mimeType')=='multipart/mixed'
+
+class Email(AttrDict):
+ def __init__(self, gmail, d):
+ super().__init__(d)
+ self.gmail = gmail
def __repr__(self):
- if not self.d.get('payload'): return f'Email({self.id})'
- lbls = ','.join(self.label_ids) if self.label_ids else ''
- att = 'π' if self.has_att else ''
- return f'Email({self.id}: [{lbls}] {att}{self.frm} | {self.subj}\n{self.snip})'
-
- @property
- def id(self): return self._id
- @property
- def thread_id(self): return self.d.get('threadId')
- @property
- def label_ids(self): return L(self.d.get('labelIds',[]))
- @property
- def snip(self): return html.unescape(self.d.get('snippet') or '')
- @property
- def has_att(self):
- if not self.d.get('payload'): return None
- return self.d['payload'].get('mimeType') == 'multipart/mixed'
-
- def get(self,
- fmt:str='full', # Format: 'full', 'metadata', 'minimal', or 'raw'
- metadata_headers=None # Headers to include in metadata format
- ):
- "Fetch email data from Gmail"
- body = dict(userId=self.gmail.user_id,id=self.id,format=fmt)
- if metadata_headers: body['metadataHeaders'] = L(metadata_headers).items
- self.d = self.gmail._exec(self.gmail._u.messages().get(**body))
- self._cache = {}
+ if 'payload' not in self: return f'Email({self.id})'
+ lbls = ','.join(self.get('labelIds') or [])
+ att = 'π' if _has_att(self) else ''
+ snip = html.unescape(self.get('snippet') or '')
+ return f'Email({self.id}: [{lbls}] {att}{self.get("frm", "")} | {self.get("subj", "")}\n{snip})'
+
+ async def refresh(self, fmt='metadata'):
+ d = await self.gmail.client.users.messages.get(user_id='me', id=self.id, format=fmt)
+ self.update(_flat_hdrs(d))
return self
- def hdrs(self,
- refresh:bool=False # Refresh from API?
- ):
- "Get lowercased headers dict"
- if refresh or 'hdrs' not in self._cache:
- if not self.d.get('payload'): self.get(fmt='metadata')
- self._cache['hdrs'] = hdrs_dict(self.d.get('payload',{}).get('headers',[]))
- return self._cache['hdrs']
-
- @property
- def subj(self): return self.hdrs().get('subject')
- @property
- def frm(self): return self.hdrs().get('from')
- @property
- def to(self): return self.hdrs().get('to')
- @property
- def rfcid(self): return self.hdrs().get('message-id')
- @property
- def refs(self): return self.hdrs().get('references')
-
- def _has_body(self):
- p = self.d.get('payload',{})
- return p.get('body') or p.get('parts')
-
- def text(self):
- "Get plain text body"
- if not self._has_body(): self.get(fmt='full')
- return txt_part(self.d.get('payload'))
-
- def html(self,
- clean:bool=True # strip reply quotations and signatures?
- ):
- "Get HTML body (optionally cleaned), falls back to text wrapped in pre"
- if not self._has_body(): self.get(fmt='full')
- h = html_part(self.d.get('payload'))
- if not h:
- t = txt_part(self.d.get('payload'))
- h = f'{t}' if t else None
- if not h or not clean: return h
- soup = BeautifulSoup(h, 'html.parser')
- for sig in soup.select('.gmail_signature, .gmail_signature_prefix'): sig.decompose()
- for q in soup.select('.gmail_quote'):
- prev = q.find_previous_sibling()
- if prev and prev.get_text(strip=True): q.decompose()
- return str(soup)
-
- def body(self,
- clean:bool=True # strip reply quotations and signatures?
- ):
- "Get (optionally cleaned) text body"
- soup = BeautifulSoup(self.html(clean=clean), 'html.parser')
- for br in soup.find_all('br'): br.replace_with('\n')
- for tag in soup.find_all(['p', 'div']): tag.append('\n')
- return re.sub(r'\n{3,}', '\n\n', soup.get_text().strip())
-
- def _repr_html_(self):
- h = self.hdrs()
- parts = [f"From: {h.get('from','')}", f"Date: {h.get('date','')}", f"To: {h.get('to','')}"]
- if h.get('cc'): parts.append(f"Cc: {h.get('cc')}")
- if h.get('bcc'): parts.append(f"Bcc: {h.get('bcc')}")
- parts.append(f"Subject: {h.get('subject','')}")
- atts = self.att_parts()
- if atts: parts.append(f"π Attachments: {', '.join(p.get('filename') for p in atts)}")
- hdr = '
'.join(parts)
- return f"{hdr}
{self.html(True)}"
-
- def raw(self,
- refresh:bool=False # Refresh from API?
- ):
- "Get base64url raw RFC 2822 email"
- if refresh or 'raw' not in self._cache:
- d = self.gmail._exec(self.gmail._u.messages().get(userId=self.gmail.user_id,id=self.id,format='raw'))
- self._cache['raw'] = d.get('raw')
- return self._cache['raw']
-
- def email(self,
- refresh:bool=False # Refresh from API?
- ):
- "Get parsed EmailMessage object"
- if refresh or 'email' not in self._cache: self._cache['email'] = parse_raw(self.raw(refresh=refresh))
- return self._cache['email']
-
- def modify(self,
- add:list=None, # Label ids/names to add
- rm:list=None # Label ids/names to remove
- ):
- "Modify labels on this email"
- add,rm = self.gmail.lbl_ids(add),self.gmail.lbl_ids(rm)
- body = dict(addLabelIds=add,removeLabelIds=rm)
- self.d = self.gmail._exec(self.gmail._u.messages().modify(userId=self.gmail.user_id,id=self.id,body=body))
- self._cache = {}
+ @classmethod
+ async def fetch(cls, gmail, id): return await cls(gmail, dict(id=id)).refresh('metadata')
+
+# %% ../nbs/00_core.ipynb #353d1436
+@patch
+@delegates(mk_email)
+async def send(self:Gmail, thread_id=None, **kwargs):
+ sys.audit('solvemail.Gmail.send', kwargs.get('to'), kwargs.get('body'))
+ msg = dict(raw=raw_email(mk_email(**kwargs)))
+ if thread_id: msg['thread_id'] = thread_id
+ return Email(self, await self.client.users.messages.send(user_id=self.user_id, **msg))
+
+# %% ../nbs/00_core.ipynb #4a7fd890
+@patch(as_prop=True)
+async def text(self:Email):
+ if 'payload' not in self or 'parts' not in self.payload: await self.refresh('full')
+ return txt_part(self.payload)
+
+# %% ../nbs/00_core.ipynb #6344fe31
+def _clean_html(h):
+ "Strip Gmail signatures and reply quotes from html `h`"
+ soup = BeautifulSoup(h, 'html.parser')
+ for sig in soup.select('.gmail_signature, .gmail_signature_prefix'): sig.decompose()
+ for q in soup.select('.gmail_quote'):
+ prev = q.find_previous_sibling()
+ if prev and prev.get_text(strip=True): q.decompose()
+ return str(soup)
+
+@patch
+async def html(self:Email, clean=True):
+ if 'payload' not in self or 'parts' not in self.payload: await self.refresh('full')
+ h = html_part(self.payload)
+ return _clean_html(h) if h and clean else h
+
+# %% ../nbs/00_core.ipynb #d0554d5f
+@patch
+async def body(self:Email, clean=True):
+ soup = BeautifulSoup(await self.html(clean), 'html.parser')
+ for br in soup.find_all('br'): br.replace_with('\n')
+ for tag in soup.find_all(['p','div']): tag.append('\n')
+ return re.sub(r'\n{3,}', '\n\n', soup.get_text().strip())
+
+# %% ../nbs/00_core.ipynb #2d474ef0
+def _email_row(e, att=None):
+ att = _has_att(e) if att is None else att
+ return f'| {e.id} | {e.frm or ""} | {e.get("subj") or ""} | {"π" if att else ""} | {e.get("date") or ""} |'
+
+_email_hdr = '| id | from | subj | π | date |\n|-|-|-|-|-|\n'
+class Emails(L):
+ def __init__(self, d, *rest, gmail=None, **kwargs):
+ super().__init__(d, *rest, **kwargs)
+ self.gmail = gmail
+
+ def _repr_markdown_(self): return _email_hdr + '\n'.join(self.map(_email_row))
+
+ async def refresh(self, fmt='metadata'):
+ if not self: return self
+ reqs = [(e.id, self.gmail.client._gservice.users().messages().get(userId='me', id=e.id, format=fmt)) for e in self]
+ res = self.gmail.client.batch_get(reqs)
+ for e in self:
+ d = res.get(e.id)
+ if d: e.update(_flat_hdrs(dict2obj(d)))
return self
- def add_labels(self,*lbls): return self.modify(add=lbls)
- def rm_labels(self,*lbls): return self.modify(rm=lbls)
- def mark_read(self): return self.rm_labels('UNREAD')
- def mark_unread(self): return self.add_labels('UNREAD')
- def star(self): return self.add_labels('STARRED')
- def unstar(self): return self.rm_labels('STARRED')
- def archive(self): return self.rm_labels('INBOX')
- def inbox(self): return self.add_labels('INBOX')
-
- def trash(self):
- "Move email to trash"
- self.d = self.gmail._exec(self.gmail._u.messages().trash(userId=self.gmail.user_id,id=self.id))
- self._cache = {}
+@patch
+async def _search(self:Gmail, rsrc, key, cls, coll, q=None, max_results=10, **kwargs):
+ r = await getattr(self.client.users, rsrc).list(user_id=self.user_id, q=q, max_results=max_results, **kwargs)
+ os = L(r.get(key) or []).map(lambda o: cls(self, o))
+ return await coll(os, gmail=self).refresh()
+
+@patch
+async def search_emails(self:Gmail, q=None, max_results=20, **kwargs):
+ return await self._search('messages', 'messages', Email, Emails, q, max_results, **kwargs)
+
+
+
+# %% ../nbs/00_core.ipynb #2ecd04dc
+def hsize(n):
+ "Human-readable byte size"
+ for u in ' KMGT':
+ if n < 1024: return f'{n:.0f}{u.strip()}B' if u==' ' else f'{n:.1f}{u}B'
+ n /= 1024
+ return f'{n:.1f}PB'
+
+class EmailAttachment(AttrDict):
+ def __init__(self, gmail, mid, d):
+ super().__init__(d)
+ self.gmail = gmail
+ self.mid = mid
+
+ def __repr__(self): return f'EmailAttachment({self.filename!r}, {self.mimeType}, {hsize(self.body.size)})'
+
+ async def fetch(self, save=None):
+ r = await self.gmail.client.users.messages.attachments.get( user_id='me', message_id=self.mid,
+ id=self.body.attachmentId)
+ data = b64d(r.data)
+ if save:
+ p = Path(save)
+ if p.is_dir(): p = p/self.filename
+ p.write_bytes(data)
+ return data
+
+@patch(as_prop=True)
+async def attachments(self:Email):
+ if 'parts' not in self.payload: await self.refresh('full')
+ return att_parts(self.payload).map(lambda a: EmailAttachment(self.gmail, self.id, a))
+
+# %% ../nbs/00_core.ipynb #bd09ef81
+@patch
+async def delete(self:Email):
+ sys.audit('solvemail.Email.delete')
+ return await self.gmail.client.users.messages.delete(user_id='me', id=self.id)
+
+# %% ../nbs/00_core.ipynb #e0d020b1
+class Draft(Email):
+ def __init__(self, gmail, d):
+ self.draft_id = d.get('id')
+ super().__init__(gmail, d.get('message') or {})
+
+ def __repr__(self): return f'Draft({self.draft_id}: ' + super().__repr__()[6:]
+
+ async def refresh(self, fmt='metadata'):
+ d = await self.gmail.client.users.drafts.get(user_id='me', id=self.draft_id, format=fmt)
+ self.draft_id = d['id']
+ self.update(_flat_hdrs(d.message))
return self
- def untrash(self):
- "Remove email from trash"
- self.d = self.gmail._exec(self.gmail._u.messages().untrash(userId=self.gmail.user_id,id=self.id))
- self._cache = {}
+ @classmethod
+ async def fetch(cls, gmail, id): return await cls(gmail, dict(id=id)).refresh('metadata')
+
+@patch
+@delegates(mk_email)
+async def create_draft(self:Gmail, thread_id=None, **kwargs):
+ msg = dict(raw=raw_email(mk_email(**kwargs)))
+ if thread_id: msg['thread_id'] = thread_id
+ d = await self.client.users.drafts.create(user_id=self.user_id, message=msg)
+ return await Draft(self, d).refresh('metadata')
+
+# %% ../nbs/00_core.ipynb #6fa74f13
+@patch
+@delegates(mk_email)
+async def update_draft(self:Draft, thread_id=None, **kwargs):
+ defaults = {k: self[k] for k in ('to','subj','cc') if self.get(k)}
+ msg = dict(raw=raw_email(mk_email(**{**defaults, **kwargs})))
+ if thread_id or self.get('threadId'): msg['thread_id'] = thread_id or self.threadId
+ d = await self.gmail.client.users.drafts.update(user_id='me', id=self.draft_id, message=msg)
+ self.draft_id = d['id']
+ return await self.refresh('metadata')
+
+# %% ../nbs/00_core.ipynb #8e386292
+def _draft_row(d): return f'| {d.draft_id} | {d.get("frm") or ""} | {d.get("subj") or ""} | {d.get("date") or ""} |'
+
+_draft_hdr = '| draft_id | from | subj | date |\n|-|-|-|-|\n'
+class Drafts(L):
+ def __init__(self, d, *rest, gmail=None, **kwargs):
+ super().__init__(d, *rest, **kwargs)
+ self.gmail = gmail
+
+ def _repr_markdown_(self): return _draft_hdr + '\n'.join(self.map(_draft_row))
+
+ async def refresh(self, fmt='metadata'):
+ if not self: return self
+ reqs = [(d.draft_id, self.gmail.client._gservice.users().drafts().get(userId='me', id=d.draft_id, format=fmt)) for d in self]
+ res = self.gmail.client.batch_get(reqs)
+ for d in self:
+ r = res.get(d.draft_id)
+ if r:
+ d.draft_id = r['id']
+ d.update(_flat_hdrs(dict2obj(r['message'])))
return self
- def delete(self):
- "Permanently delete email (requires full mail scope)"
- return self.gmail._exec(self.gmail._u.messages().delete(userId=self.gmail.user_id,id=self.id))
-
- def att_parts(self):
- "Return attachment parts for this email"
- if not self.d.get('payload'): self.get(fmt='full')
- return att_parts(self.d.get('payload'))
-
- def att(self, part):
- "Download attachment"
- part = part if isinstance(part,dict) else self.att_parts()[part]
- aid = part['body']['attachmentId']
- res = self.gmail._exec(self.gmail._u.messages().attachments().get(userId=self.gmail.user_id,messageId=self.id,id=aid))
- return b64d(res.get('data',''))
-
- def reply_draft(self,
- body:str=None, # Plain text body
- html:str=None, # HTML body
- **kwargs
- ):
- "Create a reply draft"
- return self.gmail.reply_draft(self,body=body,html=html,**kwargs)
-
- def reply(self,
- body:str=None, # Plain text body
- html:str=None, # HTML body
- **kwargs
- ):
- "Send a reply"
- return self.reply_draft(body=body,html=html,**kwargs).send()
-
- def unsubscribe(self):
- "Unsubscribe using List-Unsubscribe header (mailto or HTTP POST)"
- h = self.hdrs()
- unsub = h.get('list-unsubscribe')
- if not unsub: return None
- post_body = h.get('list-unsubscribe-post', 'List-Unsubscribe=One-Click')
- urls = re.findall(r'<([^>]+)>', unsub)
- for url in urls:
- if url.startswith('mailto:'):
- parts = url[7:].split('?', 1)
- to = parts[0]
- subj = dict(p.split('=',1) for p in parts[1].split('&')).get('subject','unsubscribe') if len(parts)>1 else 'unsubscribe'
- return self.gmail._send(mk_email(to=to, subj=subj, body='unsubscribe'))
- if url.startswith('http'):
- resp = httpx.post(url, content=post_body, headers={'Content-Type': 'application/x-www-form-urlencoded'})
- return resp
- return None
-
-class Thread:
- def __init__(self,gmail,id=None,d=None):
- store_attr('gmail')
- self.d = d or {}
- self._id = ifnone(id,self.d.get('id'))
- self._cache = {}
+@patch
+async def search_drafts(self:Gmail, q=None, max_results=10, **kwargs):
+ return await self._search('drafts', 'drafts', Draft, Drafts, q, max_results, **kwargs)
+
+# %% ../nbs/00_core.ipynb #e0d2be45
+@patch
+async def send(self:Draft):
+ sys.audit('solvemail.Draft.send')
+ return Email(self.gmail, await self.gmail.client.users.drafts.send(user_id='me', id=self.draft_id))
+
+# %% ../nbs/00_core.ipynb #bac9930b
+def _reply_hdrs(m, me=None):
+ "Compute reply headers from parent email `m` (pass `me` for reply-all cc)"
+ subj = m.get('subj') or ''
+ if not re.match(r'(?i)^re:', subj): subj = f'Re: {subj}'
+ refs = ' '.join(filter(None, [m.get('references'), m.get('rfcid')]))
+ to = m.get('reply-to') or m.get('frm')
+ cc = None
+ if me:
+ cc = {a.strip() for a in f"{m.get('to','')},{m.get('cc','')}".split(',')
+ if a.strip() and a.strip().lower() != me.lower()} - {to}
+ cc = ','.join(cc) or None
+ return dict(to=to, subj=subj, in_reply_to=m.get('rfcid'), refs=refs or None, cc=cc)
+
+# %% ../nbs/00_core.ipynb #33058858
+async def _reply_quote(e):
+ "Build gmail_quote attribution + blockquote for replied-to email `e`"
+ h = await e.html(clean=False) or f"{await e.text}"
+ when = parsedate_to_datetime(e.get('date')).strftime('%a, %b %-d, %Y at %-I:%M %p')
+ attrib = f"On {when} {html.escape(e.get('frm'))} wrote:"
+ return (f'{attrib}
'
+ f'{h}
')
+
+@patch
+@delegates(mk_email, but=['to','subj','body','html','headers','cc'])
+async def reply_draft(self:Email, body=None, html=None, reply_all=False, **kwargs):
+ if 'payload' not in self: await self.refresh('metadata')
+ me = (await self.gmail.profile()).email if reply_all else None
+ rh = _reply_hdrs(self, me)
+ hdrs = {k.replace('_','-'):v for k,v in dict(In_Reply_To=rh['in_reply_to'], References=rh['refs']).items() if v}
+ new = html or (mistletoe.markdown(body) if body else '')
+ html = new + await _reply_quote(self)
+ msg = dict(raw=raw_email(mk_email(to=rh['to'], subj=rh['subj'], cc=rh['cc'],
+ body=body or '', html=html, headers=hdrs, **kwargs)),
+ thread_id=self.threadId)
+ d = await self.gmail.client.users.drafts.create(user_id='me', message=msg)
+ return await Draft(self.gmail, d).refresh('metadata')
+
+@patch
+@delegates(mk_email, but=['to','subj','headers'])
+async def reply(self:Email, **kwargs): return await (await self.reply_draft(**kwargs)).send()
+
+# %% ../nbs/00_core.ipynb #e437064d
+class Thread(AttrDict):
+ def __init__(self, gmail, d):
+ super().__init__(d)
+ self.gmail = gmail
+ self.emails = Emails(self.get('messages') or []).map(lambda o: Email(gmail, o))
def __repr__(self):
- n = len(self.d.get('messages', []))
- if not n: return f'Thread({self.id})'
- m = Email(self.gmail, d=self.d['messages'][-1])
- if not m.d.get('snippet'): m.get(fmt='metadata')
- lbls = ','.join(m.label_ids) if m.label_ids else ''
- return f'Thread({self.id}: {n} emails, [{lbls}] {m.frm} -> {m.to} | {m.subj}\n{m.snip})'
-
- def __getitem__(self, i): return self.emails()[i]
-
- @property
- def id(self): return self._id
- @property
- def hist_id(self): return self.d.get('historyId')
-
- def get(self,fmt='full',metadata_headers=None):
- "Fetch thread with `fmt`"
- body = dict(userId=self.gmail.user_id,id=self.id,format=fmt)
- if metadata_headers: body['metadataHeaders'] = L(metadata_headers).items
- self.d = self.gmail._exec(self.gmail._u.threads().get(**body))
- self._cache = {}
+ if not self.emails: return f'Thread({self.id})'
+ m = self.emails[-1]
+ att = 'π' if any(e.payload.get('mimeType')=='multipart/mixed' for e in self.emails) else ''
+ return f'Thread({self.id}: {len(self.emails)} emails, [{",".join(m.labelIds or [])}] {att}{m.frm} -> {m.to} | {m.subj})'
+ def _repr_markdown_(self): return self.emails._repr_markdown_()
+
+ async def refresh(self, fmt='metadata'):
+ d = await self.gmail.client.users.threads.get(user_id='me', id=self.id, format=fmt)
+ self.update(d)
+ self.emails = await Emails([Email(self.gmail, o) for o in d.messages], gmail=self.gmail).refresh(fmt)
return self
- def emails(self,refresh=False,fmt='metadata'):
- "Return emails in this thread"
- if refresh or 'emails' not in self._cache:
- if not self.d.get('messages'): self.get(fmt=fmt)
- self._cache['emails'] = L(self.d.get('messages',[])).map(lambda o: Email(self.gmail,d=o))
- return self._cache['emails']
-
- def last(self): return self.emails()[-1]
-
- def modify(self,add=None,rm=None):
- "Modify labels on this thread"
- add,rm = self.gmail.lbl_ids(add),self.gmail.lbl_ids(rm)
- body = dict(addLabelIds=add,removeLabelIds=rm)
- self.d = self.gmail._exec(self.gmail._u.threads().modify(userId=self.gmail.user_id,id=self.id,body=body))
- self._cache = {}
- return self
-
- def add_labels(self,*lbls): return self.modify(add=lbls)
- def rm_labels(self,*lbls): return self.modify(rm=lbls)
+ @classmethod
+ async def fetch(cls, gmail, id): return await cls(gmail, dict(id=id)).refresh('metadata')
- def trash(self):
- "Move thread to trash"
- self.d = self.gmail._exec(self.gmail._u.threads().trash(userId=self.gmail.user_id,id=self.id))
- self._cache = {}
- return self
+ @property
+ def labelIds(self): return list({l for e in self.emails for l in (e.labelIds or [])})
- def untrash(self):
- "Remove thread from trash"
- self.d = self.gmail._exec(self.gmail._u.threads().untrash(userId=self.gmail.user_id,id=self.id))
- self._cache = {}
+ @property
+ async def attachments(self):
+ atts = await asyncio.gather(*self.emails.map(lambda e: e.attachments))
+ return L(a for es in atts for a in es)
+
+# %% ../nbs/00_core.ipynb #e90616f7
+@patch(as_prop=True)
+def last(self:Thread): return self.emails[-1]
+
+@patch
+@delegates(mk_email, but=['to','subj','headers'])
+async def reply_draft(self:Thread, **kwargs):
+ dr = await self.last.reply_draft(**kwargs)
+ await self.refresh('metadata')
+ return dr
+
+@patch
+@delegates(mk_email, but=['to','subj','headers'])
+async def reply(self:Thread, **kwargs):
+ em = await self.last.reply(**kwargs)
+ await self.refresh('metadata')
+ return em
+
+# %% ../nbs/00_core.ipynb #1fa3424e
+def _thread_row(t): return _email_row(t.emails[-1], any(_has_att(e) for e in t.emails)) + f' {len(t.emails)} |'
+
+_thread_hdr = _email_hdr.replace('|\n|-', '| emails |\n|-').replace('-|\n', '-|-|\n')
+class Threads(L):
+ def __init__(self, d, *rest, gmail=None, **kwargs):
+ super().__init__(d, *rest, **kwargs)
+ self.gmail = gmail
+
+ def _repr_markdown_(self): return _thread_hdr + '\n'.join(self.map(_thread_row))
+
+ async def refresh(self, fmt='metadata'):
+ if not self: return self
+ reqs = [(t.id, self.gmail.client._gservice.users().threads().get(userId='me', id=t.id, format=fmt)) for t in self]
+ res = self.gmail.client.batch_get(reqs)
+ for t in self:
+ d = res.get(t.id)
+ if d:
+ t.update(dict2obj(d))
+ t.emails = await Emails(L(dict2obj(d).messages).map(lambda m: Email(t.gmail, m)), gmail=self.gmail).refresh()
return self
- def delete(self):
- "Permanently delete thread (requires full mail scope)"
- return self.gmail._exec(self.gmail._u.threads().delete(userId=self.gmail.user_id,id=self.id))
-
- def reply_draft(self,body=None,html=None,**kwargs):
- "Create a reply draft to the last email in this thread"
- return self.last().reply_draft(body=body,html=html,thread_id=self.id,**kwargs)
-
- def reply(self,body=None,html=None,**kwargs):
- "Send a reply to the last email in this thread"
- return self.reply_draft(body=body,html=html,**kwargs).send()
-
-class Draft:
- def __init__(self,gmail,id=None,d=None):
- store_attr('gmail')
- self.d = d or {}
- self._id = ifnone(id,self.d.get('id'))
+@patch
+async def search_threads(self:Gmail, q=None, max_results=10, **kwargs):
+ return await self._search('threads', 'threads', Thread, Threads, q, max_results, **kwargs)
+
+# %% ../nbs/00_core.ipynb #3650c674
+async def _fwd_one(e):
+ "Build (html_block, att_tuples) for one forwarded message `e`"
+ hdr = (f"\n\n---------- Forwarded message ----------\n\n"
+ f"**From:** {e.get('frm')} \n**Date:** {e.get('date')} \n"
+ f"**Subject:** {e.get('subj')} \n**To:** {e.get('to')} \n")
+ body = await e.html(clean=False) or f"{await e.text}"
+ atts = await e.attachments
+ tups = await asyncio.gather(*atts.map(lambda a: a.fetch()))
+ return mistletoe.markdown(hdr) + body, [(a.filename, d, a.mimeType) for a,d in zip(atts,tups)]
+
+@patch
+@delegates(mk_email, but=['subj','html','att','headers'])
+async def forward(self:Email, to, subj=None, body=None, att=None, **kwargs):
+ if 'internalDate' not in self: await self.refresh('full')
+ th = await Thread.fetch(self.gmail, self.threadId)
+ ems = th.emails.sorted(lambda e: int(e.internalDate)).filter(lambda e: int(e.internalDate) <= int(self.internalDate))
+ parts = await asyncio.gather(*ems.map(_fwd_one))
+ html = (mistletoe.markdown(body) if body else '') + ''.join(h for h,_ in parts)
+ att = list(att or []) + [a for _,fa in parts for a in fa]
+ return await self.gmail.send(to=to, subj=subj or f"Fwd: {self.get('subj')}", body=body or '', html=html, att=att, **kwargs)
+
+@patch
+@delegates(Email.forward)
+async def forward(self:Thread, **kwargs): return await self.last.forward(**kwargs)
+
+# %% ../nbs/00_core.ipynb #15a23629
+_sys_lbls = set('INBOX SPAM TRASH UNREAD STARRED IMPORTANT SENT DRAFT CHAT '
+ 'CATEGORY_FORUMS CATEGORY_UPDATES CATEGORY_PERSONAL '
+ 'CATEGORY_PROMOTIONS CATEGORY_SOCIAL'.split())
+def _norm_lbl(l): return l.upper() if isinstance(l,str) and l.upper() in _sys_lbls else l
- def __repr__(self):
- if not self.d.get('message'): return f'Draft({self.id})'
- m = self.email
- if m and not m.d.get('payload'): m.get(fmt='metadata')
- if not m: return f'Draft({self.id})'
- return f'Draft({self.id}: {m.to} | {m.subj}\n{m.snip})'
- @property
- def id(self): return self._id
- @property
- def email(self):
- m = self.d.get('message',{})
- return Email(self.gmail,id=m.get('id'),d=m) if m else None
+class Label(AttrDict):
+ def __init__(self, gmail, d):
+ super().__init__(d)
+ self.gmail = gmail
+ def __repr__(self): return f'Label({self.id}:{self.name})'
@property
- def thread_id(self): return (self.d.get('message') or {}).get('threadId')
+ def is_sys(self): return self.get('type')=='system' or self.id in _sys_lbls
- def get(self,fmt='full'):
- "Fetch draft, loading the underlying email with `fmt`"
- self.d = self.gmail._exec(self.gmail._u.drafts().get(userId=self.gmail.user_id,id=self.id,format=fmt))
+ async def refresh(self):
+ self.update(await self.gmail.client.users.labels.get(user_id='me', id=self.id))
+ self.gmail._lbls = None
return self
-
- def delete(self):
- "Delete this draft"
- return self.gmail._exec(self.gmail._u.drafts().delete(userId=self.gmail.user_id,id=self.id))
-
- @delegates(mk_email)
- def update(self,email=None,thread_id=None,**kwargs):
- "Update this draft with `email` (EmailMessage) or build from kwargs"
- email = ifnone(email,mk_email(**kwargs))
- body = dict(message=dict(raw=raw_email(email)))
- if thread_id or self.thread_id: body['message']['threadId'] = ifnone(thread_id,self.thread_id)
- self.d = self.gmail._exec(self.gmail._u.drafts().update(userId=self.gmail.user_id,id=self.id,body=body))
+ async def delete(self):
+ sys.audit('solvemail.Label.delete')
+ await self.gmail.client.users.labels.delete(user_id='me', id=self.id)
+ self.gmail._lbls = None
+ async def patch(self, **kwargs):
+ self.update(await self.gmail.client.users.labels.patch(user_id='me', id=self.id, **kwargs))
+ self.gmail._lbls = None
return self
-
- @delegates(mk_email)
- def send(self,email=None,thread_id=None,**kwargs):
- "Send this draft (optionally updating email from `email` or kwargs)"
- sys.audit('solvemail.senddraft', self.id)
- body = dict(id=self.id)
- if email or kwargs:
- email = ifnone(email,mk_email(**kwargs))
- body['message'] = dict(raw=raw_email(email))
- if thread_id or self.thread_id: body['message']['threadId'] = ifnone(thread_id,self.thread_id)
- res = self.gmail._exec(self.gmail._u.drafts().send(userId=self.gmail.user_id,body=body))
- return Email(self.gmail,d=res)
-
-class Gmail:
- def __init__(self, creds, user_id='me', retries=3):
- "Gmail client using OAuth `creds`"
- store_attr()
- self.s = gmail_service(creds)
- self._u = self.s.users()
- self._lbls = None
-
- def _exec(self,req):
- for i in range(self.retries+1):
- try: return req.execute(num_retries=0)
- except HttpError as e:
- if e.resp.status in (429,500,503) and i=limit: return
- tok = d.get('nextPageToken')
- if not tok: break
-
- def search_emails(self,
- q:str=None, # Gmail search query (e.g. 'is:unread from:foo')
- label_ids:list=None, # Filter by label ids/names
- max_results:int=50, # Max emails to return (None for all)
- include_spam_trash:bool=False, # Include spam/trash?
- fmt:str=None # Format: 'full', 'metadata', 'minimal' (None for ids only; use 'metadata' for snippets)
- ):
- "Search emails using Gmail query; set fmt='metadata' or 'full' to batch-fetch and see snippets/bodies"
- page_sz = min(max_results,500) if max_results else 500
- kwargs = dict(userId=self.user_id,maxResults=page_sz,includeSpamTrash=include_spam_trash)
- if q: kwargs['q'] = q
- if label_ids: kwargs['labelIds'] = self.lbl_ids(label_ids)
- it = self._list(self._u.messages().list,'messages',limit=max_results,**kwargs)
- emails = L(it).map(lambda o: Email(self,d=o))
- return self.get_emails(emails, fmt=fmt) if fmt else emails
-
- def search_threads(self,
- q:str=None, # Gmail search query (e.g. 'is:unread from:foo')
- label_ids:list=None, # Filter by label ids/names
- max_results:int=50, # Max threads to return (None for all)
- include_spam_trash:bool=False, # Include spam/trash?
- fmt:str=None # Format: 'full', 'metadata', 'minimal' (None for ids only; use 'metadata' for snippets)
- ):
- "Search threads using Gmail query; set fmt='metadata' or 'full' to batch-fetch and see snippets/bodies"
- page_sz = min(max_results,500) if max_results else 500
- kwargs = dict(userId=self.user_id,maxResults=page_sz,includeSpamTrash=include_spam_trash)
- if q: kwargs['q'] = q
- if label_ids: kwargs['labelIds'] = self.lbl_ids(label_ids)
- it = self._list(self._u.threads().list,'threads',limit=max_results,**kwargs)
- threads = L(it).map(lambda o: Thread(self,d=o))
- return self.get_threads(threads, fmt=fmt) if fmt else threads
-
- def drafts(self,
- q:str=None, # Gmail search query
- max_results:int=50 # Max drafts to return (None for all)
- ): # List of Draft objects
- "List drafts"
- page_sz = min(max_results,500) if max_results else 500
- kwargs = dict(userId=self.user_id,maxResults=page_sz)
- if q: kwargs['q'] = q
- it = self._list(self._u.drafts().list,'drafts',limit=max_results,**kwargs)
- return L(it).map(lambda o: Draft(self,d=o))
-
- def _send(self, email, thread_id:str=None):
- "Send `email` without auditing"
- body = dict(raw=raw_email(email))
- if thread_id: body['threadId'] = thread_id
- res = self._exec(self._u.messages().send(userId=self.user_id,body=body))
- return Email(self,d=res)
-
- @delegates(mk_email, but=['headers','att'])
- def send(self,
- email=None, # EmailMessage to send, or None to build from kwargs
- thread_id:str=None, # Thread id to reply in
- **kwargs
- ):
- "Send email (pass `to`, `subj`, `body` etc or an EmailMessage)"
- sys.audit('solvemail.send', kwargs.get('to'), kwargs.get('body'))
- email = ifnone(email,mk_email(**kwargs))
- return self._send(email, thread_id)
-
- def _create_draft(self, email, thread_id:str=None):
- "Create a draft from an EmailMessage"
- body = dict(message=dict(raw=raw_email(email)))
- if thread_id: body['message']['threadId'] = thread_id
- res = self._exec(self._u.drafts().create(userId=self.user_id, body=body))
- return Draft(self, d=res)
-
- def _fwd_body(self, fwd_email_id, body, subj, att, **kwargs):
- "Build forwarded email as EmailMessage"
- e = self.email(fwd_email_id, fmt='full')
- h = e.hdrs()
- fwd_hdr = f"""
-
----------- Forwarded email ---------
-
-**From:** {h.get('from')}
-**Date:** {h.get('date')}
-**Subject:** {e.subj}
-**To:** {h.get('to')}
-
-"""
- body = (body or '') + fwd_hdr
- html = mistletoe.markdown(body) + (e.html(clean=False) or f"{e.text()}")
- def tup(p): return (p.get('filename'), e.att(p), p.get('mimeType', 'application/octet-stream'))
- att = att + [tup(p) for p in e.att_parts()]
- return mk_email(body=body, subj=subj or f"Fwd: {e.subj}", html=html, att=att, **kwargs)
-
- @delegates(mk_email, but=['html', 'body', 'headers'])
- def create_draft(self, email=None, body:str=None, subj:str=None, thread_id:str=None, fwd_email_id:str=None, att:list=None, **kwargs):
- "Create a draft (body is markdown)"
- att = list(att or [])
- if fwd_email_id: email = self._fwd_body(fwd_email_id, body, subj, att, **kwargs)
- elif email is None:
- html = mistletoe.markdown(body) if body else None
- email = mk_email(body=body or '', subj=subj, html=html, att=att, **kwargs)
- return self._create_draft(email, thread_id)
-
- def _reply_headers(self,m,to=None,subj=None,refs=None,in_reply_to=None):
- h = m.hdrs()
- to = ifnone(to,h.get('reply-to') or h.get('from'))
- subj = ifnone(subj,h.get('subject') or '')
- subj = subj if re.match(r'(?i)^re:',subj or '') else f'Re: {subj}'
- in_reply_to = ifnone(in_reply_to,h.get('message-id'))
- refs0 = h.get('references')
- refs = ifnone(refs,refs0)
- if refs and in_reply_to and in_reply_to not in refs: refs = f'{refs} {in_reply_to}'
- if not refs and in_reply_to: refs = in_reply_to
- return dict(to=to,subj=subj,refs=refs,in_reply_to=in_reply_to)
-
- @delegates(create_draft, but=['fwd_email_id', 'subj', 'thread_id', 'headers'])
- def reply_draft(self, o:str, to:str=None, subj:str=None, body:str=None, thread_id:str=None, **kwargs):
- "Create a reply draft for email/thread `o`"
- t = self.thread(o, fmt='metadata') if isinstance(o, str) else o
- if isinstance(t, Email): t = self.thread(t.thread_id, fmt='metadata')
- last = t.emails()[-1]
- rh = self._reply_headers(last, to=to, subj=subj)
- html = mistletoe.markdown(body) if body else None
- headers = {}
- if rh['in_reply_to']: headers['In-Reply-To'] = rh['in_reply_to']
- if rh['refs']: headers['References'] = rh['refs']
- email = mk_email(to=rh['to'], subj=rh['subj'], body=body or '', html=html, headers=headers, **kwargs)
- return self._create_draft(email, thread_id or t.id)
-
- def reply_to_thread(self,
- thread_id:str, # Thread id to reply to
- body:str, # Plain text body
- html:str=None, # HTML body
- reply_all:bool=True # Reply to all recipients?
- ): # Created reply draft
- "Create a reply draft for a thread"
- t = self.thread(thread_id)
- m = t.last().get(fmt='metadata')
- h = m.hdrs()
- to = h.get('reply-to') or h.get('from', '')
- cc = None
- if reply_all:
- me = self.profile().email.lower()
- cc = {a.strip() for a in (h.get('to','')+','+h.get('cc','')).split(',') if a.strip() and a.strip().lower() != me} - {to}
- cc = ','.join(cc) or None
- return t.reply_draft(body=body, html=html, to=to, cc=cc)
-
- def _batch_label(self, ids, add=None, rm=None, delay=0):
- if delay: time.sleep(delay)
- body = dict(ids=list(ids), addLabelIds=self.lbl_ids(add), removeLabelIds=self.lbl_ids(rm))
- return self._exec(self._u.messages().batchModify(userId=self.user_id, body=body))
-
- def batch_label(self,
- ids:list, # Email ids (no limit)
- add:list=None, # Label ids/names to add
- rm:list=None, # Label ids/names to remove
- chunk_sz:int=999, # Chunk size (max 1000)
- delay:float=0.5 # Delay between chunks in seconds
- ): # List of API responses
- "Batch modify labels on emails, auto-chunking"
- ids = _uniq(L(ids).map(_as_id))
- return [self._batch_label(b, add, rm, delay if i else 0) for i,b in enumerate(chunked(ids, chunk_sz))]
-
- def batch_delete(self,
- ids:list # Email ids to delete permanently (max 1000)
- ): # API response
- "Permanently delete emails (requires full mail scope)"
- ids = _uniq(L(ids).map(_as_id))
- body = dict(ids=ids)
- return self._exec(self._u.messages().batchDelete(userId=self.user_id,body=body))
-
- def trash_emails(self,
- ids:list # Email ids to trash
- ): # List of trashed emails
- "Move emails to trash"
- return L(ids).map(_as_id).map(lambda i: self._exec(self._u.messages().trash(userId=self.user_id,id=i)))
-
- def report_spam(self,
- ids:list # Email ids to report as spam
- ):
- "Report emails as spam"
- return self.batch_label(ids, add=['SPAM'], rm=['INBOX'])
-
- def _chunk_get(self, chunk, cls, api, fmt, callback, sleep_before=0):
- import uuid
- if sleep_before: time.sleep(sleep_before)
- results,id_map = {},{}
- def _cb(id, resp, exc):
- if exc: raise exc
- results[id_map[id]] = cls(self, d=resp)
- if callback: callback(results[id_map[id]])
- batch = self.s.new_batch_http_request()
- for o in chunk:
- oid = o.id if hasattr(o, 'id') else o
- uid = f"{oid}_{uuid.uuid4().hex[:8]}"
- id_map[uid] = oid
- batch.add(api.get(userId=self.user_id, id=oid, format=fmt), callback=_cb, request_id=uid)
- batch.execute(http=self.s._http)
- return results
-
- def _batch_get(self, items, cls, api, fmt='metadata', callback=None, chunk_sz=10, delay=0.1):
- all_results = {}
- for i,chunk in enumerate(chunked(items, chunk_sz)): all_results |= self._chunk_get(chunk, cls, api, fmt, callback, delay if i else 0)
- return L(all_results[o.id if hasattr(o,'id') else o] for o in items)
-
- def send_drafts(self,
- ids: str|list[str] # id(s) of drafts to send
- ):
- "Send one or more drafts by id"
- return L(listify(ids)).map(lambda i: self.draft(i).send())
-
- def get_emails(self, emails, fmt='metadata', callback=None):
- "Batch fetch multiple emails"
- return self._batch_get(emails, Email, self._u.messages(), fmt, callback)
-
- def get_threads(self, threads, fmt='metadata', callback=None):
- "Batch fetch multiple threads"
- return self._batch_get(threads, Thread, self._u.threads(), fmt, callback)
-
- def view_inbox(self, max_emails=20, unread=False):
- "Search and batch-fetch inbox emails"
- q = 'in:inbox is:unread' if unread else 'in:inbox'
- emails = self.search_emails(q, max_results=max_emails)
- return self.get_emails(emails, fmt='full')
-
- def view_inbox_threads(self, max_threads=20, unread=False):
- "Search and batch-fetch inbox threads"
- q = 'in:inbox is:unread' if unread else 'in:inbox'
- threads = self.search_threads(q, max_results=max_threads)
- return self.get_threads(threads, fmt='full')
-
- def view_emails(self,
- ids:list, # Email ids to fetch
- fmt:str='metadata' # Format: 'full', 'metadata', or 'minimal'
- ):
- "Batch fetch emails and return summary dicts"
- emails = self.get_emails(ids, fmt=fmt)
- return [dict(id=e.id, thread_id=e.thread_id, frm=e.frm, to=e.to, subject=e.subj, snippet=e.snip) for e in emails]
-
- def view_threads(self,
- ids:list, # Thread ids to fetch
- fmt:str='metadata' # Format: 'full', 'metadata', or 'minimal'
- ):
- "Batch fetch threads and return summary with email list"
- threads = self.get_threads(ids, fmt=fmt)
- return [dict(id=t.id, emails=[dict(id=e.id, frm=e.frm, to=e.to, subject=e.subj,
- snippet=e.snip, labels=list(e.label_ids)) for e in t.emails()]) for t in threads]
-
- def view_email(self,
- id:str, # Email id
- clean:bool=True, # Strip reply quotations and signatures?
- as_text:bool=True, # Return text body (vs HTML)?
- as_json:bool=True # Return dict (vs formatted string)?
- ):
- "View email body with optional headers/metadata. This is primarily for LLM and programmatic use. Humans use `email()` to get HTML view."
- e = self.email(id, fmt='full')
- body = e.body(clean) if as_text else e.html(clean)
- h = e.hdrs()
- atts = [dict(filename=p.get('filename'), mime=p.get('mimeType')) for p in e.att_parts()]
- if not as_json:
- parts = [f"From: {h.get('from','')}", f"Date: {h.get('date','')}", f"To: {h.get('to','')}"]
- if h.get('cc'): parts.append(f"Cc: {h.get('cc')}")
- if h.get('bcc'): parts.append(f"Bcc: {h.get('bcc')}")
- parts.append(f"Subject: {h.get('subject','')}")
- if atts: parts.append(f"Attachments: {', '.join(a['filename'] for a in atts)}")
- return '\n'.join(parts) + '\n\n' + body
- return dict(id=e.id, thread_id=e.thread_id, frm=h.get('from'), to=h.get('to'), cc=h.get('cc'),
- date=h.get('date'), subject=h.get('subject'), atts=atts, body=body)
-
- def view_thread(self,
- id:str, # Thread id
- clean:bool=True, # Strip reply quotations and signatures?
- as_text:bool=True, # Return text body (vs HTML)?
- as_json:bool=True # Return dict (vs formatted string)?
- ):
- "View thread emails with optional headers/metadata. This is primarily for LLM and programmatic use. Humans use `thread()` to get HTML view."
- t = self.thread(id, fmt='full')
- res = {e.id: self.view_email(e.id, clean=clean, as_text=as_text, as_json=as_json) for e in t.emails()}
- if as_json: return res
- return ('\n\n' + '='*60 + '\n\n').join(res.values())
+ async def rename(self, name): return await self.patch(name=name)
+
+@patch
+async def labels(self:Gmail, refresh=False):
+ if refresh or self._lbls is None:
+ d = await self.client.users.labels.list(user_id=self.user_id)
+ self._lbls = L(d.get('labels') or []).map(lambda o: Label(self, o))
+ return self._lbls
+
+@patch
+async def label(self:Gmail, lbl, refresh=False):
+ lbl = _norm_lbl(lbl)
+ if isinstance(lbl, Label): return lbl
+ lbls = await self.labels(refresh=refresh)
+ d = {**{o.name:o for o in lbls}, **{o.id:o for o in lbls}}
+ if lbl in d: return d[lbl]
+ raise KeyError(f'Unknown label: {lbl}')
+
+@patch
+async def lbl_ids(self:Gmail, lbls):
+ if lbls is None: return []
+ async def _one(l):
+ l = _norm_lbl(l)
+ if isinstance(l, Label): return l.id
+ if isinstance(l, str) and l in _sys_lbls: return l
+ try: return (await self.label(l)).id
+ except KeyError: return l
+ ls = L(lbls) if is_listy(lbls) else L([lbls])
+ return L(await asyncio.gather(*ls.map(_one))).filter().unique().items
+
+@patch
+async def create_label(self:Gmail, name):
+ d = await self.client.users.labels.create(user_id=self.user_id, name=name)
+ self._lbls = None
+ return Label(self, d)
+
+# %% ../nbs/00_core.ipynb #9ed8bac9
+@patch
+async def modify(self:Email, add=None, rm=None):
+ return self.update(await self.gmail.client.users.messages.modify(
+ user_id='me', id=self.id,
+ add_label_ids=await self.gmail.lbl_ids(add),
+ remove_label_ids=await self.gmail.lbl_ids(rm)))
+
+@patch
+async def modify(self:Thread, add=None, rm=None):
+ await self.gmail.client.users.threads.modify(
+ user_id='me', id=self.id,
+ add_label_ids=await self.gmail.lbl_ids(add),
+ remove_label_ids=await self.gmail.lbl_ids(rm))
+ return await self.refresh()
+
+# %% ../nbs/00_core.ipynb #120c0a48
+@patch
+async def find_labels(self:Gmail, term, regex=False):
+ lbls = await self.labels()
+ if regex: return lbls.filter(lambda o: re.search(term, o.name))
+ return lbls.filter(lambda o: term.lower() in o.name.lower())
+
+# %% ../nbs/00_core.ipynb #2f89132b
+@patch(as_prop=True)
+async def labels(self:Email):
+ if 'labelIds' not in self: await self.refresh('metadata')
+ return L(await asyncio.gather(*L(self.labelIds).map(self.gmail.label))).attrgot('name')
+
+@patch(as_prop=True)
+async def labels(self:Thread):
+ return L(await asyncio.gather(*L(self.labelIds).map(self.gmail.label))).attrgot('name')
+
+# %% ../nbs/00_core.ipynb #e1600ded
+@patch
+async def mark_read(self:(Email,Thread)): return await self.modify(rm='UNREAD')
+@patch
+async def mark_unread(self:(Email,Thread)): return await self.modify(add='UNREAD')
+@patch
+async def star(self:(Email,Thread)): return await self.modify(add='STARRED')
+@patch
+async def unstar(self:(Email,Thread)): return await self.modify(rm='STARRED')
+@patch
+async def archive(self:(Email,Thread)): return await self.modify(rm='INBOX')
+@patch
+async def inbox(self:(Email,Thread)): return await self.modify(add='INBOX')
+
+# %% ../nbs/00_core.ipynb #7bc49692
+@patch
+async def trash(self:Email):
+ sys.audit('solvemail.Email.trash')
+ return self.update(await self.gmail.client.users.messages.trash(user_id='me', id=self.id))
+@patch
+async def untrash(self:Email): return self.update(await self.gmail.client.users.messages.untrash(user_id='me', id=self.id))
+
+@patch
+async def trash(self:Thread):
+ sys.audit('solvemail.Thread.trash')
+ await self.gmail.client.users.threads.trash(user_id='me', id=self.id)
+ return await self.refresh()
+@patch
+async def untrash(self:Thread):
+ await self.gmail.client.users.threads.untrash(user_id='me', id=self.id)
+ return await self.refresh()
+
+# %% ../nbs/00_core.ipynb #5f900aa0
+@patch
+async def delete(self:Draft):
+ sys.audit('solvemail.Draft.delete')
+ return await self.gmail.client.users.drafts.delete(user_id='me', id=self.draft_id)
+
+@patch
+async def delete(self:Thread):
+ sys.audit('solvemail.Thread.delete')
+ return await self.gmail.client.users.threads.delete(user_id='me', id=self.id)
+
+# %% ../nbs/00_core.ipynb #3cacd9da
+@patch
+async def batch_modify(self:Gmail, ids, add=None, rm=None):
+ "Add/remove `add`/`rm` labels from all messages in `ids`"
+ return await self.client.users.messages.batch_modify(
+ user_id=self.user_id, ids=listify(ids),
+ add_label_ids=await self.lbl_ids(add),
+ remove_label_ids=await self.lbl_ids(rm))
+
+@patch
+async def batch_delete(self:Gmail, ids):
+ sys.audit('solvemail.Gmail.batch_delete', listify(ids))
+ return await self.client.users.messages.batch_delete(
+ user_id=self.user_id, ids=listify(ids))
+
+@patch
+async def modify(self:Emails, add=None, rm=None):
+ return await self.gmail.batch_modify(self.attrgot('id'), add, rm)
+
+@patch
+async def delete(self:Emails):
+ return await self.gmail.batch_delete(self.attrgot('id'))
+
+@patch
+async def trash(self:Emails): return await self.modify(add='TRASH')
+@patch
+async def untrash(self:Emails): return await self.modify(rm='TRASH')
+@patch
+async def mark_read(self:Emails): return await self.modify(rm='UNREAD')
+@patch
+async def mark_unread(self:Emails): return await self.modify(add='UNREAD')
+@patch
+async def star(self:Emails): return await self.modify(add='STARRED')
+@patch
+async def unstar(self:Emails): return await self.modify(rm='STARRED')
+@patch
+async def archive(self:Emails): return await self.modify(rm='INBOX')
+
+# %% ../nbs/00_core.ipynb #b4a4f6f0
+def _parse_unsub(unsub, post=None):
+ "Parse a List-Unsubscribe header into an action dict"
+ if not unsub: return None
+ for url in re.findall(r'<([^>]+)>', unsub):
+ if url.startswith('mailto:'):
+ addr,_,qs = url[7:].partition('?')
+ subj = dict(p.split('=',1) for p in qs.split('&')).get('subject','unsubscribe') if qs else 'unsubscribe'
+ return dict(kind='mailto', addr=addr, subj=subj)
+ if url.startswith('http'):
+ return dict(kind='http', url=url, post=post or 'List-Unsubscribe=One-Click')
+ return None
+
+# %% ../nbs/00_core.ipynb #47c21da4
+@patch
+async def unsubscribe(self:Email):
+ "Unsubscribe via List-Unsubscribe header (mailto or HTTP one-click POST)"
+ if 'payload' not in self: await self.refresh('metadata')
+ act = _parse_unsub(self.get('list-unsubscribe'), self.get('list-unsubscribe-post'))
+ if not act: return None
+ if act['kind'] == 'mailto': return await self.gmail.send(to=act['addr'], subj=act['subj'], body='unsubscribe')
+ return httpx.post(act['url'], content=act['post'],
+ headers={'Content-Type':'application/x-www-form-urlencoded'})
+
+@patch
+async def unsubscribe(self:Thread): return await self.last.unsubscribe()
diff --git a/solvemail/email.py b/solvemail/email.py
index e80e6ae..b15f14a 100644
--- a/solvemail/email.py
+++ b/solvemail/email.py
@@ -95,3 +95,4 @@ def html_part(p):
def att_parts(p):
"Return attachment parts from `payload`"
return L(walk_parts(p)).filter(lambda o: o.get('filename') and o.get('body',{}).get('attachmentId'))
+
diff --git a/solvemail/skill.py b/solvemail/skill.py
new file mode 100644
index 0000000..c2bb422
--- /dev/null
+++ b/solvemail/skill.py
@@ -0,0 +1,139 @@
+"""Load this skill when an agent needs to read, search, and organize Gmail using solvemail. It covers connecting to Gmail, searching messages and threads, reading bodies and attachments, and managing labels. Sending, replying, forwarding, and trashing are documented for reference but are not enabled by default.
+
+Connections use the `Gmail` client: `gmail = Gmail(scopes='readonly')`. Scopes control what the underlying OAuth token may do β `'readonly'` for reading and searching, `'modify'` to also add and remove labels, or `'full'` for everything including permanent deletion. The first connection opens a browser to authorize, then caches the token so later runs don't re-prompt. `gmail.profile()` returns the account profile, with the address on its `email` attribute.
+
+solvemail is organized around four resource types:
+
+- **Email** β a single message, with headers (from, to, subject, date), a body, labels, and optional attachments.
+- **Thread** β a conversation: an email and its replies grouped under one id, holding its messages as a list of `Email` objects.
+- **Draft** β an unsent message. `Draft` subclasses `Email`, so everything that works on an email works on a draft too.
+- **Label** β Gmail's version of folders/tags, both system labels (`INBOX`, `UNREAD`, `STARRED`) and your own custom ones.
+
+Fetching is lazy. An `Email` or `Thread` starts as just an id and fills in only as needed. How much comes back depends on the `fmt` you ask for: `metadata` brings headers and a snippet (enough for a listing), `full` brings the whole payload including the body and attachments, `minimal` gives just ids and labels, and `raw` gives the original RFC 2822 bytes. Reading a header lazily fetches at `metadata`; reading a body upgrades to `full`.
+
+All solvemail methods are async, so `await` them.
+
+# Searching
+
+Search uses Gmail's own query syntax β the same as the Gmail search box. Common operators: `from:foo@bar.com`, `to:me`, `subject:invoice`, `has:attachment`, `is:unread`, `is:starred`, `label:Receipts`, `in:inbox`, and date filters like `after:2026/01/01` or `newer_than:7d`. Combine them freely, e.g. `from:stripe has:attachment newer_than:30d`.
+
+`gmail.search(q, max_results=20)` returns a list of `Email` objects, each refreshed to `metadata` so you get senders, subjects, dates, and snippets without a second fetch:
+
+ ems = await gmail.search('from:stripe has:attachment', max_results=10)
+
+`gmail.search_threads(q, max_results=10)` works the same way but returns whole conversations as `Thread` objects, which is usually what you want when replies matter:
+
+ ths = await gmail.search_threads('subject:invoice newer_than:30d')
+
+`gmail.search_drafts(q, max_results=10)` lists drafts as `Draft` objects.
+
+Search is keyword-driven and is not proof of absence. If a search comes back empty, try alternate terms β sender domains, usernames, filenames, error strings β before concluding something isn't there.
+
+# Reading emails and threads
+
+Once you have an `Email`, three helpers get you the body at different levels of cleanup:
+
+- **`await e.text`** β the raw `text/plain` body, as the sender wrote it (a property, no parens).
+- **`await e.html(clean=True)`** β the `text/html` body. With `clean=True` it strips Gmail signature blocks and quoted reply chains, leaving just the new content. Falls back to the text part wrapped in `` if there's no HTML.
+- **`await e.body(clean=True)`** β the cleaned HTML flattened back to readable plain text. This is usually what you want for reading or feeding to an LLM.
+
+A `Thread` holds its messages as `Email` objects on `.emails`, and `t.last` is the most recent. Index into it directly (`t.emails[0]`) and use everything above on each message:
+
+ th = await gmail.search_threads('subject:invoice')
+ last = th[0].last
+ print(await last.body())
+
+Headers are available as attributes once fetched: `e.frm`, `e.to`, `e.subj`, `e.date`, plus `e.labelIds` for the labels on the message.
+
+# Attachments
+
+Attachments show up as extra parts in an email's payload. `await e.attachments` pulls them out as `EmailAttachment` objects, giving you attribute access to `filename`, `mimeType`, and `body.size`. A thread's `await t.attachments` gathers them across every message in the conversation.
+
+Each attachment is lazy β the bytes aren't downloaded until you call `await att.fetch()`. Pass `save=` a directory or filename to write it straight to disk instead of holding the bytes in memory:
+
+ atts = await em.attachments
+ data = await atts[0].fetch() # bytes in memory
+ await atts[0].fetch(save='~/Downloads') # write to a folder
+
+To find emails that carry files in the first place, search with `has:attachment` (or `has:drive`, `has:document`, etc). Inspect `filename`, `mimeType`, and `body.size` before fetching, and only pull the bytes when the task actually needs them.
+
+# Sending, replying, forwarding (not enabled by default)
+
+These are write operations. They're documented here so you understand the API, but they're not in this skill's allowed set β ask the user to enable them deliberately if the task requires it.
+
+**Send** a new message. Body is markdown (auto-converted to HTML); pass `att=` a list of file paths to attach:
+
+ await gmail.send(to='a@b.com', subj='Hi', body='Hello from **solvemail**!', att=['report.pdf'])
+
+**Reply** keeps the conversation threaded β it sets the right `In-Reply-To`/`References` headers and `Re:` subject for you. Use `reply` on an `Email` or `Thread` to send straight away, or `reply_draft` to create a draft you can review first. Pass `reply_all=True` to cc the other recipients:
+
+ await em.reply(body='Thanks, got it!')
+ dr = await th.reply_draft(body='Looking into this.', reply_all=True)
+
+**Forward** pastes the original content into a new message with a `Fwd:` subject, re-attaching the original files. Forwarding from an `Email` includes every message in the thread up to and including itself; from a `Thread` it forwards from the last message:
+
+ await em.forward(to='c@d.com', body='FYI, see below')
+
+A **draft** can be updated with `await dr.update_draft(body='new text')` and sent with `await dr.send()`.
+
+# Labels and organizing
+
+Labels are Gmail's tags. System labels (`INBOX`, `STARRED`, `UNREAD`) use their name as their id; custom labels have an id like `Label_42` that differs from their name. You can pass either a name or an id anywhere a label is expected β solvemail resolves names to ids for you.
+
+List and look up labels:
+
+ lbls = await gmail.labels()
+ lbl = await gmail.label('Receipts') # by name or id
+ hits = await gmail.find_labels('rec') # substring match
+
+Add or remove labels on an `Email` or `Thread` with `modify`, and there are shortcuts for the common ones:
+
+ await em.modify(add='Receipts', rm='INBOX')
+ await em.mark_read() # rm UNREAD
+ await em.star() # add STARRED
+ await em.archive() # rm INBOX
+
+Create and rename custom labels with `await gmail.create_label('Receipts')` and `await lbl.rename('Invoices')`.
+
+Gmail treats trashing and deleting differently. Trashing moves a message or thread to the `TRASH` label, where Gmail keeps it for 30 days before purging it; `untrash` reverses that, so it's recoverable. Deleting is permanent and needs the `'full'` scope.
+
+These are write operations and are **not enabled by default**:
+
+ await em.trash() # recoverable
+ await em.untrash()
+ await em.delete() # permanent, requires 'full' scope
+
+# Unsubscribe
+
+Well-behaved senders include a `List-Unsubscribe` header describing how to opt out β either a `mailto:` address to email or a URL to POST to (flagged one-click by `List-Unsubscribe-Post`). `await em.unsubscribe()` reads that header and does whichever the sender offered, so you don't have to hunt for the link buried in the message. On a `Thread`, it unsubscribes using the last message.
+
+Note that the `mailto:` path sends an email, so this is a write operation and is not enabled by default.
+
+# Gotchas
+
+All solvemail methods are async β `await` them, including the `text` property (`await e.text`).
+
+`text` is a property (no parens), but `html()` and `body()` are methods that take a `clean` argument.
+
+Fetching is lazy and `fmt`-dependent. Search returns emails at `metadata` (headers + snippet); reading a body upgrades that message to `full`. If a body looks empty, the message may only be at `metadata` β reading `body()`/`html()`/`text` will fetch the full payload for you.
+
+Scopes gate what you can do: `'readonly'` can't change labels, and permanent `delete` needs `'full'`. A permission error usually means the client was created with too narrow a scope.
+
+Search is keyword-driven and can miss messages or return thin snippets β treat it as a way to gather candidates, not proof that something does or doesn't exist.
+
+Dates are strings, not datetime objects."""
+from pyskills.core import allow
+from solvemail.core import (Gmail, Email, Emails, Thread, Threads,
+ Draft, Drafts, Label, EmailAttachment)
+
+__all__ = ['Gmail', 'Email', 'Emails', 'Thread', 'Threads',
+ 'Draft', 'Drafts', 'Label', 'EmailAttachment']
+
+allow({Gmail: ['profile', 'search', 'search_threads', 'search_drafts', 'create_draft',
+ 'labels', 'label', 'find_labels', 'create_label', 'lbl_ids'],
+ Email: ['refresh', 'html', 'body', 'modify',
+ 'mark_read', 'mark_unread', 'star', 'unstar', 'archive', 'inbox'],
+ Thread: ['refresh', 'modify',
+ 'mark_read', 'mark_unread', 'star', 'unstar', 'archive', 'inbox'],
+ Label: ['refresh', 'rename'],
+ EmailAttachment: ['fetch']})
\ No newline at end of file
diff --git a/tests/conftest.py b/tests/conftest.py
deleted file mode 100644
index 203d52d..0000000
--- a/tests/conftest.py
+++ /dev/null
@@ -1,14 +0,0 @@
-import os,pytest
-from solvemail import Gmail
-
-def _env(k,df=None):
- v = os.environ.get(k,df)
- return v if v not in ('',None) else None
-
-@pytest.fixture(scope='session')
-def g():
- if _env('GMAILX_E2E')!='1': pytest.skip('Set GMAILX_E2E=1 to run e2e tests')
- creds = _env('GMAILX_CREDS','credentials.json')
- token = _env('GMAILX_TOKEN','token.json')
- interactive = _env('GMAILX_INTERACTIVE','1')=='1'
- return Gmail(creds_path=creds,token_path=token,interactive=interactive)
diff --git a/tests/test_e2e.py b/tests/test_e2e.py
deleted file mode 100644
index 333ae2d..0000000
--- a/tests/test_e2e.py
+++ /dev/null
@@ -1,59 +0,0 @@
-import time,uuid,pytest
-from fastcore.test import test_eq,test
-
-test_eq.__test__ = False
-test.__test__ = False
-
-def _poll(f,cond,max_wait=30,slp=1):
- t0 = time.time()
- while True:
- o = f()
- if cond(o): return o
- if time.time()-t0>max_wait: return o
- time.sleep(slp)
-
-@pytest.mark.timeout(120)
-def test_labels(g):
- uid = uuid.uuid4().hex[:10]
- nm = f'solvemail-e2e-{uid}'
- lbl = g.create_label(nm)
- try:
- test_eq(g.label(nm).id,lbl.id)
- test(lbl.id in [o.id for o in g.find_labels(uid)])
- lbl.rename(nm+'-renamed')
- test_eq(g.label(nm+'-renamed').id,lbl.id)
- finally:
- try: lbl.delete()
- except Exception: pass
-
-@pytest.mark.timeout(120)
-def test_send_reply_draft_and_attachments(g):
- me = g.profile().email
- uid = uuid.uuid4().hex[:10]
- subj = f'solvemail e2e {uid}'
- lbl = g.create_label(f'solvemail-e2e-thread-{uid}')
- m = None
- try:
- att = [('att.txt',b'hello solvemail','text/plain')]
- m = g.send(to=me,subj=subj,body='hello',att=att)
- m = _poll(lambda: g.email(m.id,fmt='full'),lambda o: bool(o.d.get('payload')),max_wait=20)
- aps = m.att_parts()
- test(len(aps)>0)
- test_eq(m.att(0),b'hello solvemail')
-
- t = g.thread(m.thread_id,fmt='metadata')
- t.add_labels(lbl)
- t = g.thread(t.id,fmt='metadata')
- test(all(lbl.id in o.d.get('labelIds',[]) for o in t.emails()))
-
- n0 = len(g.thread(t.id,fmt='metadata').emails())
- d = t.reply_draft(body='reply')
- sent = d.send()
- _poll(lambda: len(g.thread(t.id,fmt='metadata').emails()),lambda n: n>n0,max_wait=30)
- test(sent.thread_id==t.id)
- finally:
- try:
- if m: g.thread(m.thread_id).trash()
- except Exception: pass
- try: lbl.delete()
- except Exception: pass
diff --git a/tests/test_unit.py b/tests/test_unit.py
deleted file mode 100644
index 5f575a3..0000000
--- a/tests/test_unit.py
+++ /dev/null
@@ -1,57 +0,0 @@
-import sys
-from fastcore.test import test_eq as eq
-from solvemail.email import b64e,b64d,mk_email,raw_email,parse_raw
-from solvemail.core import Gmail,Email,Draft
-
-def test_b64_roundtrip():
- b = b'abc123\x00\xff'
- eq(b64d(b64e(b)),b)
-
-def test_email_roundtrip():
- m = mk_email(to='a@example.com',subj='s',body='hi',html='hi')
- m2 = parse_raw(raw_email(m))
- eq(m2['To'],'a@example.com')
- eq(m2['Subject'],'s')
-
-def test_send_audits(monkeypatch):
- events,sent = [],[]
- monkeypatch.setattr(sys,'audit',lambda *o: events.append(o))
- g = object.__new__(Gmail)
- g._send = lambda email,thread_id=None: sent.append((email,thread_id)) or 'sent'
- eq(Gmail.send(g,to='a@example.com',subj='s',body='hi',thread_id='t'),'sent')
- eq(events,[('solvemail.send','a@example.com','hi')])
- eq(sent[0][0]['To'],'a@example.com')
- eq(sent[0][1],'t')
-
-def test_draft_send_audits(monkeypatch):
- events = []
- monkeypatch.setattr(sys,'audit',lambda *o: events.append(o))
- class _Drafts:
- def send(self,userId,body):
- self.body = body
- return body
- class _Users:
- def __init__(self): self.ds = _Drafts()
- def drafts(self): return self.ds
- class _Gmail:
- user_id = 'me'
- def __init__(self): self._u = _Users()
- def _exec(self,req): return {'id':'m1'}
- g = _Gmail()
- eq(Draft(g,id='d1').send().id,'m1')
- eq(events,[('solvemail.senddraft','d1')])
- eq(g._u.ds.body,{'id':'d1'})
-
-def test_unsubscribe_mailto_does_not_audit(monkeypatch):
- events,sent = [],[]
- monkeypatch.setattr(sys,'audit',lambda *o: events.append(o))
- class _Gmail:
- def _send(self,email,thread_id=None):
- sent.append((email,thread_id))
- return 'sent'
- d = {'payload': {'headers': [{'name':'List-Unsubscribe','value':''}]}}
- eq(Email(_Gmail(),d=d).unsubscribe(),'sent')
- eq(events,[])
- eq(sent[0][0]['To'],'unsub@example.com')
- eq(sent[0][0]['Subject'],'bye')
- eq(sent[0][0].get_content().strip(),'unsubscribe')