gmail-agent 0.3.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- gmail_agent/__init__.py +8 -0
- gmail_agent/__main__.py +5 -0
- gmail_agent/auth.py +231 -0
- gmail_agent/cli.py +775 -0
- gmail_agent/compose.py +218 -0
- gmail_agent/config.py +65 -0
- gmail_agent/errors.py +31 -0
- gmail_agent/files.py +62 -0
- gmail_agent/gmail.py +863 -0
- gmail_agent/guard.py +187 -0
- gmail_agent/mime.py +256 -0
- gmail_agent/server.py +455 -0
- gmail_agent/setup.py +313 -0
- gmail_agent-0.3.0.dist-info/METADATA +451 -0
- gmail_agent-0.3.0.dist-info/RECORD +18 -0
- gmail_agent-0.3.0.dist-info/WHEEL +4 -0
- gmail_agent-0.3.0.dist-info/entry_points.txt +2 -0
- gmail_agent-0.3.0.dist-info/licenses/LICENSE +21 -0
gmail_agent/__init__.py
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
"""Gmail for AI agents: a CLI and an MCP server."""
|
|
2
|
+
|
|
3
|
+
from importlib.metadata import PackageNotFoundError, version
|
|
4
|
+
|
|
5
|
+
try:
|
|
6
|
+
__version__ = version("gmail-agent") # pyproject.toml is the one place the version lives
|
|
7
|
+
except PackageNotFoundError: # a source tree that was never installed
|
|
8
|
+
__version__ = "0+unknown"
|
gmail_agent/__main__.py
ADDED
gmail_agent/auth.py
ADDED
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
"""OAuth: the one-time browser login, and loading/refreshing the saved token."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import contextlib
|
|
6
|
+
import json
|
|
7
|
+
import os
|
|
8
|
+
import sys
|
|
9
|
+
import tempfile
|
|
10
|
+
import threading
|
|
11
|
+
from collections.abc import Callable
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
from typing import Any
|
|
14
|
+
|
|
15
|
+
from google.auth.exceptions import RefreshError, TransportError
|
|
16
|
+
from google.auth.transport.requests import Request
|
|
17
|
+
from google.oauth2.credentials import Credentials
|
|
18
|
+
|
|
19
|
+
from . import config
|
|
20
|
+
from .errors import GmailAgentError, SetupError
|
|
21
|
+
|
|
22
|
+
SETUP_HINT = "See https://github.com/dremok/gmail-agent#google-cloud-setup"
|
|
23
|
+
CONSOLE = "https://console.cloud.google.com"
|
|
24
|
+
LOGIN_TIMEOUT = 300 # seconds to wait for the browser to come back from the consent screen
|
|
25
|
+
# Token endpoint errors that mean the OAuth client itself is gone or unknown.
|
|
26
|
+
CLIENT_GONE = {"invalid_client", "deleted_client"}
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _missing_client_error() -> SetupError:
|
|
30
|
+
return SetupError(
|
|
31
|
+
f"No OAuth client file at {config.credentials_path()}. Run: gmail-agent setup. It "
|
|
32
|
+
"walks you through creating a Desktop OAuth client in Google Cloud, installs it and "
|
|
33
|
+
f"logs in. {SETUP_HINT}"
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def client_project() -> str | None:
|
|
38
|
+
"""The Google Cloud project of the installed OAuth client, for console links."""
|
|
39
|
+
try:
|
|
40
|
+
data = json.loads(config.credentials_path().read_text())
|
|
41
|
+
except (OSError, ValueError):
|
|
42
|
+
return None
|
|
43
|
+
installed = data.get("installed") if isinstance(data, dict) else None
|
|
44
|
+
return installed.get("project_id") if isinstance(installed, dict) else None
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def console_url(path: str, project: str | None = None) -> str:
|
|
48
|
+
project = project or client_project()
|
|
49
|
+
return f"{CONSOLE}{path}" + (f"?project={project}" if project else "")
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _client_gone() -> SetupError:
|
|
53
|
+
return SetupError(
|
|
54
|
+
f"Google no longer accepts the OAuth client in {config.credentials_path()}: it was "
|
|
55
|
+
"deleted, or unused for six months. Create a new one: run gmail-agent setup."
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def token_rejected(e: RefreshError) -> SetupError:
|
|
60
|
+
data = e.args[1] if len(e.args) > 1 and isinstance(e.args[1], dict) else {}
|
|
61
|
+
if data.get("error") in CLIENT_GONE:
|
|
62
|
+
return _client_gone()
|
|
63
|
+
return SetupError(
|
|
64
|
+
f"Google rejected the saved token ({e}). Run: gmail-agent login. If this happens "
|
|
65
|
+
"every 7 days, your OAuth app is in Testing mode; publish it (status 'In production') "
|
|
66
|
+
f"at {console_url('/auth/audience')}. {SETUP_HINT}"
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _login_refused(e: Exception) -> SetupError:
|
|
71
|
+
"""Errors from the consent flow (oauthlib's OAuth2Error has the OAuth error code)."""
|
|
72
|
+
code = getattr(e, "error", None)
|
|
73
|
+
if code in CLIENT_GONE:
|
|
74
|
+
return _client_gone()
|
|
75
|
+
if code == "access_denied":
|
|
76
|
+
return SetupError(
|
|
77
|
+
"Google did not grant access. Either the consent screen was cancelled, or the app "
|
|
78
|
+
"is in Testing mode and this address is not a test user. Add it under Test users, "
|
|
79
|
+
f"or publish the app, at {console_url('/auth/audience')}, then log in again."
|
|
80
|
+
)
|
|
81
|
+
description = getattr(e, "description", None) or str(e)
|
|
82
|
+
return SetupError(f"Google refused the login ({code}: {description}). {SETUP_HINT}")
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def granted_scopes(creds: Credentials) -> list[str]:
|
|
86
|
+
"""What the user actually ticked on the consent screen (they can untick scopes)."""
|
|
87
|
+
granted = creds.granted_scopes or creds.scopes or []
|
|
88
|
+
return granted.split() if isinstance(granted, str) else list(granted)
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def login(level: str = config.DEFAULT_LEVEL, open_browser: bool = True) -> Credentials:
|
|
92
|
+
"""Run the browser consent flow and save the token. Needs credentials.json."""
|
|
93
|
+
from google_auth_oauthlib.flow import InstalledAppFlow, WSGITimeoutError
|
|
94
|
+
from oauthlib.oauth2.rfc6749.errors import OAuth2Error
|
|
95
|
+
|
|
96
|
+
if level not in config.LEVELS:
|
|
97
|
+
raise GmailAgentError(
|
|
98
|
+
f"Unknown scope level {level!r}. Use one of: {', '.join(config.LEVELS)}"
|
|
99
|
+
)
|
|
100
|
+
client = config.credentials_path()
|
|
101
|
+
if not client.exists():
|
|
102
|
+
raise _missing_client_error()
|
|
103
|
+
try:
|
|
104
|
+
flow = InstalledAppFlow.from_client_secrets_file(str(client), config.LEVELS[level])
|
|
105
|
+
except (ValueError, json.JSONDecodeError) as e:
|
|
106
|
+
raise SetupError(
|
|
107
|
+
f"{client} is not a valid OAuth client file ({e}). Download the JSON for a client "
|
|
108
|
+
f"of type 'Desktop app'. {SETUP_HINT}"
|
|
109
|
+
) from e
|
|
110
|
+
# The flow prints the login URL; keep it on stderr so `--json` output stays clean.
|
|
111
|
+
with contextlib.redirect_stdout(sys.stderr):
|
|
112
|
+
try:
|
|
113
|
+
creds = flow.run_local_server(
|
|
114
|
+
port=0,
|
|
115
|
+
open_browser=open_browser,
|
|
116
|
+
authorization_prompt_message="Open this URL in a browser to log in:\n{url}",
|
|
117
|
+
success_message="gmail-agent is logged in. You can close this tab.",
|
|
118
|
+
timeout_seconds=LOGIN_TIMEOUT,
|
|
119
|
+
)
|
|
120
|
+
except WSGITimeoutError as e:
|
|
121
|
+
raise SetupError(
|
|
122
|
+
f"The browser did not come back within {LOGIN_TIMEOUT // 60} minutes. Run "
|
|
123
|
+
"gmail-agent login again and finish the consent screen. If Google showed "
|
|
124
|
+
"'Access blocked', add your address as a test user or publish the app at "
|
|
125
|
+
f"{console_url('/auth/audience')}."
|
|
126
|
+
) from e
|
|
127
|
+
except OAuth2Error as e:
|
|
128
|
+
raise _login_refused(e) from e
|
|
129
|
+
save_token(creds, scopes=granted_scopes(creds))
|
|
130
|
+
return creds
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def save_token(creds: Credentials, scopes: list[str] | None = None) -> Path:
|
|
134
|
+
"""Write the token with owner-only permissions (0600), replacing any old one atomically.
|
|
135
|
+
|
|
136
|
+
Each save writes its own temporary file, so two processes refreshing at the same time
|
|
137
|
+
(the CLI and an MCP server, say) cannot leave a half-written token behind.
|
|
138
|
+
"""
|
|
139
|
+
data = json.loads(creds.to_json())
|
|
140
|
+
if scopes is not None:
|
|
141
|
+
data["scopes"] = scopes
|
|
142
|
+
return write_private(config.token_path(), json.dumps(data))
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def write_private(path: Path, text: str) -> Path:
|
|
146
|
+
"""Write `text` readable by the owner only (0600, in a 0700 folder), replacing `path`
|
|
147
|
+
atomically through a temporary file of its own."""
|
|
148
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
149
|
+
os.chmod(path.parent, 0o700)
|
|
150
|
+
fd, tmp = tempfile.mkstemp(dir=path.parent, prefix=f".{path.stem}-", suffix=".tmp")
|
|
151
|
+
try:
|
|
152
|
+
with os.fdopen(fd, "w") as f:
|
|
153
|
+
f.write(text)
|
|
154
|
+
os.replace(tmp, path)
|
|
155
|
+
except BaseException:
|
|
156
|
+
with contextlib.suppress(OSError):
|
|
157
|
+
os.unlink(tmp)
|
|
158
|
+
raise
|
|
159
|
+
return path
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def logout() -> bool:
|
|
163
|
+
"""Delete the saved token. Returns False if there was none."""
|
|
164
|
+
path = config.token_path()
|
|
165
|
+
if not path.exists():
|
|
166
|
+
return False
|
|
167
|
+
path.unlink()
|
|
168
|
+
return True
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def saved_scopes() -> list[str] | None:
|
|
172
|
+
"""The scopes recorded in the saved token, without touching the network. None if there is
|
|
173
|
+
no readable token."""
|
|
174
|
+
try:
|
|
175
|
+
scopes = json.loads(config.token_path().read_text()).get("scopes")
|
|
176
|
+
except (OSError, ValueError):
|
|
177
|
+
return None
|
|
178
|
+
return scopes.split() if isinstance(scopes, str) else scopes
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def load_credentials() -> Credentials:
|
|
182
|
+
"""Load the saved token, refreshing it if needed. Never opens a browser."""
|
|
183
|
+
path = config.token_path()
|
|
184
|
+
if not path.exists():
|
|
185
|
+
if not config.credentials_path().exists():
|
|
186
|
+
raise _missing_client_error()
|
|
187
|
+
raise SetupError(f"Not logged in (no token at {path}). Run: gmail-agent login")
|
|
188
|
+
try:
|
|
189
|
+
creds = Credentials.from_authorized_user_file(str(path))
|
|
190
|
+
except (ValueError, json.JSONDecodeError) as e:
|
|
191
|
+
raise SetupError(f"Token file {path} is unreadable ({e}). Run: gmail-agent login") from e
|
|
192
|
+
|
|
193
|
+
if not creds.valid:
|
|
194
|
+
if not creds.refresh_token:
|
|
195
|
+
raise SetupError(f"Token at {path} has no refresh token. Run: gmail-agent login")
|
|
196
|
+
try:
|
|
197
|
+
creds.refresh(Request())
|
|
198
|
+
except RefreshError as e:
|
|
199
|
+
raise token_rejected(e) from e
|
|
200
|
+
except TransportError as e:
|
|
201
|
+
raise SetupError(f"Could not reach Google to refresh the token: {e}") from e
|
|
202
|
+
save_token(creds)
|
|
203
|
+
return creds
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def build_service(creds: Credentials, transport: Callable[[], Any] | None = None):
|
|
207
|
+
"""The Gmail API service, safe to use from several threads at once.
|
|
208
|
+
|
|
209
|
+
httplib2 is not thread-safe, and the MCP server runs tool calls in worker threads, so
|
|
210
|
+
sharing one connection crashes the process when calls overlap. Every thread gets its own
|
|
211
|
+
authorized connection instead (google-api-python-client's documented requestBuilder
|
|
212
|
+
pattern). `transport` makes the plain httplib2.Http for each thread; tests replace it.
|
|
213
|
+
"""
|
|
214
|
+
import google_auth_httplib2
|
|
215
|
+
from googleapiclient.discovery import build
|
|
216
|
+
from googleapiclient.http import HttpRequest, build_http
|
|
217
|
+
|
|
218
|
+
new_transport = transport or build_http
|
|
219
|
+
local = threading.local()
|
|
220
|
+
|
|
221
|
+
def thread_http() -> google_auth_httplib2.AuthorizedHttp:
|
|
222
|
+
if not hasattr(local, "http"):
|
|
223
|
+
local.http = google_auth_httplib2.AuthorizedHttp(creds, http=new_transport())
|
|
224
|
+
return local.http
|
|
225
|
+
|
|
226
|
+
def request_builder(_http, *args, **kwargs) -> HttpRequest:
|
|
227
|
+
return HttpRequest(thread_http(), *args, **kwargs)
|
|
228
|
+
|
|
229
|
+
return build(
|
|
230
|
+
"gmail", "v1", http=thread_http(), requestBuilder=request_builder, cache_discovery=False
|
|
231
|
+
)
|