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.
@@ -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"
@@ -0,0 +1,5 @@
1
+ import sys
2
+
3
+ from .cli import main
4
+
5
+ sys.exit(main())
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
+ )