validops-east-1 commited on
Commit
55ecbeb
·
1 Parent(s): f3ab5f0

feat: gmail api integration

Browse files
app/api/server.py CHANGED
@@ -121,6 +121,8 @@ async def lifespan(app: FastAPI):
121
  await close_maps_service()
122
  from app.api.v1.gcs import close_gcs_service
123
  await close_gcs_service()
 
 
124
  from app.services.media_storage_service import close_storage_service
125
  await close_storage_service()
126
  from app.utils.http_utils import close_shared_aiohttp_sessions
 
121
  await close_maps_service()
122
  from app.api.v1.gcs import close_gcs_service
123
  await close_gcs_service()
124
+ from app.api.v1.gmail import close_gmail_service
125
+ await close_gmail_service()
126
  from app.services.media_storage_service import close_storage_service
127
  await close_storage_service()
128
  from app.utils.http_utils import close_shared_aiohttp_sessions
app/api/v1/gmail.py ADDED
@@ -0,0 +1,1181 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import time
4
+ from typing import Any, Dict, List, Optional
5
+
6
+ from fastapi import APIRouter, Depends, Header, HTTPException
7
+
8
+ from app.config import get_settings
9
+ from app.core.logger import get_logger
10
+ from app.models.schemas import (
11
+ GmailAttachmentContent,
12
+ GmailBatchDeleteRequest,
13
+ GmailBatchModifyRequest,
14
+ GmailComposeMessageRequest,
15
+ GmailDraftCreateRequest,
16
+ GmailDraftSendRequest,
17
+ GmailDraftUpdateRequest,
18
+ GmailFilterCreateRequest,
19
+ GmailGenericResponse,
20
+ GmailInsertMessageRequest,
21
+ GmailLabelRequest,
22
+ GmailMessageModifyRequest,
23
+ GmailRawMessageRequest,
24
+ GmailRefreshResponse,
25
+ GmailSendResponse,
26
+ GmailSettingsUpdateRequest,
27
+ )
28
+ from app.services.gmail_service import (
29
+ GmailAPIError,
30
+ GmailCredentials,
31
+ GmailService,
32
+ )
33
+
34
+ router = APIRouter(prefix="/google/gmail", tags=["Gmail"])
35
+ _logger = get_logger(__name__)
36
+ _settings = get_settings()
37
+
38
+ _gmail_service = GmailService()
39
+
40
+
41
+ def get_gmail_service() -> GmailService:
42
+ return _gmail_service
43
+
44
+
45
+ async def close_gmail_service() -> None:
46
+ await _gmail_service.close()
47
+
48
+
49
+ def _credentials(
50
+ x_access_token: Optional[str] = Header(None, alias="X-Access-Token"),
51
+ x_refresh_token: Optional[str] = Header(None, alias="X-Refresh-Token"),
52
+ x_client_id: Optional[str] = Header(None, alias="X-Client-Id"),
53
+ x_client_secret: Optional[str] = Header(None, alias="X-Client-Secret"),
54
+ x_token_expires_at: Optional[float] = Header(None, alias="X-Token-Expires-At"),
55
+ ) -> GmailCredentials:
56
+ if not x_access_token:
57
+ raise HTTPException(
58
+ status_code=401,
59
+ detail="X-Access-Token header is required.",
60
+ )
61
+ return GmailCredentials(
62
+ access_token=x_access_token,
63
+ refresh_token=x_refresh_token,
64
+ client_id=x_client_id,
65
+ client_secret=x_client_secret,
66
+ expires_at=x_token_expires_at,
67
+ )
68
+
69
+
70
+ def _http_error(exc: GmailAPIError) -> HTTPException:
71
+ return HTTPException(status_code=exc.status_code, detail=exc.message)
72
+
73
+
74
+ def _elapsed_ms(start: float) -> float:
75
+ return round((time.perf_counter() - start) * 1000, 2)
76
+
77
+
78
+ def _ok(
79
+ start: float,
80
+ creds: GmailCredentials,
81
+ data: Any = None,
82
+ *,
83
+ error: Optional[str] = None,
84
+ ) -> GmailGenericResponse:
85
+ return GmailGenericResponse(
86
+ success=error is None,
87
+ time_ms=_elapsed_ms(start),
88
+ data=data,
89
+ refreshed_access_token=creds.refreshed_access_token,
90
+ error=error,
91
+ )
92
+
93
+
94
+ def _user_id(delegate: str) -> str:
95
+ return delegate or "me"
96
+
97
+
98
+ def _attachment_content(
99
+ message_id: str,
100
+ attachment_id: str,
101
+ raw: Dict[str, Any],
102
+ ) -> GmailAttachmentContent:
103
+ return GmailAttachmentContent(
104
+ message_id=message_id,
105
+ attachment_id=attachment_id,
106
+ filename=raw.get("filename", ""),
107
+ mime_type=raw.get("mimeType", ""),
108
+ data_base64=raw.get("data", ""),
109
+ size_bytes=int(raw.get("size", 0) or 0),
110
+ )
111
+
112
+
113
+ # ---------------------------------------------------------------------------
114
+ # Service metadata & token lifecycle
115
+ # ---------------------------------------------------------------------------
116
+
117
+ @router.get("/scopes", response_model=GmailGenericResponse,
118
+ summary="List all supported Gmail OAuth scopes")
119
+ async def list_scopes(
120
+ service: GmailService = Depends(get_gmail_service),
121
+ ):
122
+ start = time.perf_counter()
123
+ scopes = await service.list_available_scopes()
124
+ _logger.info("Listed %d Gmail scopes (%.2fms)", len(scopes), _elapsed_ms(start))
125
+ return GmailGenericResponse(success=True, time_ms=_elapsed_ms(start), data=scopes)
126
+
127
+
128
+ @router.post("/token/refresh", response_model=GmailRefreshResponse,
129
+ summary="Refresh an access token (stateless, returned to the client)")
130
+ async def token_refresh(
131
+ body: Dict[str, str],
132
+ service: GmailService = Depends(get_gmail_service),
133
+ ):
134
+ start = time.perf_counter()
135
+ client_id = body.get("client_id", "")
136
+ client_secret = body.get("client_secret", "")
137
+ refresh_token = body.get("refresh_token", "")
138
+ if not (client_id and client_secret and refresh_token):
139
+ raise HTTPException(
140
+ status_code=400,
141
+ detail="client_id, client_secret and refresh_token are required.",
142
+ )
143
+ try:
144
+ tokens = await service.refresh_access_token(
145
+ client_id=client_id,
146
+ client_secret=client_secret,
147
+ refresh_token=refresh_token,
148
+ )
149
+ except GmailAPIError as exc:
150
+ raise _http_error(exc) from exc
151
+ _logger.info("Gmail token refreshed (%.2fms)", _elapsed_ms(start))
152
+ return GmailRefreshResponse(
153
+ success=True,
154
+ access_token=tokens.get("access_token"),
155
+ expires_in=int(tokens.get("expires_in", 0)),
156
+ token_type=tokens.get("token_type", "Bearer"),
157
+ scope=tokens.get("scope"),
158
+ )
159
+
160
+
161
+ # ---------------------------------------------------------------------------
162
+ # Profile
163
+ # ---------------------------------------------------------------------------
164
+
165
+ @router.get("/profile", response_model=GmailGenericResponse,
166
+ summary="Get the authenticated user's Gmail profile")
167
+ async def get_profile(
168
+ creds: GmailCredentials = Depends(_credentials),
169
+ service: GmailService = Depends(get_gmail_service),
170
+ ):
171
+ start = time.perf_counter()
172
+ try:
173
+ data = await service.get_profile(creds)
174
+ except GmailAPIError as exc:
175
+ raise _http_error(exc) from exc
176
+ _logger.info("Gmail profile fetched (%.2fms)", _elapsed_ms(start))
177
+ return _ok(start, creds, data)
178
+
179
+
180
+ # ---------------------------------------------------------------------------
181
+ # Messages
182
+ # ---------------------------------------------------------------------------
183
+
184
+ @router.get("/messages", response_model=GmailGenericResponse,
185
+ summary="List messages (supports Gmail search query and pagination)")
186
+ async def list_messages(
187
+ creds: GmailCredentials = Depends(_credentials),
188
+ service: GmailService = Depends(get_gmail_service),
189
+ q: Optional[str] = None,
190
+ label_ids: Optional[str] = None,
191
+ max_results: Optional[int] = None,
192
+ page_token: Optional[str] = None,
193
+ include_spam_trash: bool = False,
194
+ user_id: Optional[str] = None,
195
+ ):
196
+ start = time.perf_counter()
197
+ try:
198
+ data = await service.list_messages(
199
+ creds,
200
+ user_id=user_id or "me",
201
+ q=q,
202
+ label_ids=label_ids.split(",") if label_ids else None,
203
+ max_results=max_results,
204
+ page_token=page_token,
205
+ include_spam_trash=include_spam_trash,
206
+ )
207
+ except GmailAPIError as exc:
208
+ raise _http_error(exc) from exc
209
+ _logger.info("Gmail messages listed (%.2fms)", _elapsed_ms(start))
210
+ return _ok(start, creds, data)
211
+
212
+
213
+ @router.get("/messages/{message_id}", response_model=GmailGenericResponse,
214
+ summary="Get a single message (format: minimal, metadata, full, raw)")
215
+ async def get_message(
216
+ message_id: str,
217
+ creds: GmailCredentials = Depends(_credentials),
218
+ service: GmailService = Depends(get_gmail_service),
219
+ format: str = "full",
220
+ metadata_headers: Optional[str] = None,
221
+ user_id: Optional[str] = None,
222
+ ):
223
+ start = time.perf_counter()
224
+ try:
225
+ data = await service.get_message(
226
+ creds,
227
+ message_id,
228
+ user_id=user_id or "me",
229
+ format=format,
230
+ metadata_headers=metadata_headers.split(",") if metadata_headers else None,
231
+ )
232
+ except GmailAPIError as exc:
233
+ raise _http_error(exc) from exc
234
+ _logger.info("Gmail message %s fetched (%.2fms)", message_id, _elapsed_ms(start))
235
+ return _ok(start, creds, data)
236
+
237
+
238
+ @router.get("/messages/{message_id}/parsed", response_model=GmailGenericResponse,
239
+ summary="Get a message parsed into headers, bodies and attachment metadata")
240
+ async def get_parsed_message(
241
+ message_id: str,
242
+ creds: GmailCredentials = Depends(_credentials),
243
+ service: GmailService = Depends(get_gmail_service),
244
+ user_id: Optional[str] = None,
245
+ ):
246
+ start = time.perf_counter()
247
+ try:
248
+ parsed = await service.get_parsed_message(creds, message_id, user_id=user_id or "me")
249
+ except GmailAPIError as exc:
250
+ raise _http_error(exc) from exc
251
+ _logger.info("Gmail message %s parsed (%.2fms)", message_id, _elapsed_ms(start))
252
+ return _ok(start, creds, parsed.model_dump())
253
+
254
+
255
+ @router.get("/messages/{message_id}/attachments/{attachment_id}",
256
+ response_model=GmailGenericResponse,
257
+ summary="Get a message attachment's content (base64url)")
258
+ async def get_attachment(
259
+ message_id: str,
260
+ attachment_id: str,
261
+ creds: GmailCredentials = Depends(_credentials),
262
+ service: GmailService = Depends(get_gmail_service),
263
+ user_id: Optional[str] = None,
264
+ ):
265
+ start = time.perf_counter()
266
+ try:
267
+ raw = await service.get_attachment(
268
+ creds, message_id, attachment_id, user_id=user_id or "me"
269
+ )
270
+ except GmailAPIError as exc:
271
+ raise _http_error(exc) from exc
272
+ content = _attachment_content(message_id, attachment_id, raw)
273
+ _logger.info(
274
+ "Gmail attachment %s fetched for message %s (%.2fms)",
275
+ attachment_id,
276
+ message_id,
277
+ _elapsed_ms(start),
278
+ )
279
+ return _ok(start, creds, content.model_dump())
280
+
281
+
282
+ @router.post("/messages/send", response_model=GmailSendResponse,
283
+ summary="Send an email built from a structured compose request")
284
+ async def send_composed(
285
+ body: GmailComposeMessageRequest,
286
+ creds: GmailCredentials = Depends(_credentials),
287
+ service: GmailService = Depends(get_gmail_service),
288
+ ):
289
+ start = time.perf_counter()
290
+ try:
291
+ data = await service.send_composed(creds, body, user_id=_user_id(body.delegate))
292
+ except GmailAPIError as exc:
293
+ raise _http_error(exc) from exc
294
+ _logger.info("Gmail email sent (%.2fms)", _elapsed_ms(start))
295
+ return GmailSendResponse(
296
+ success=True,
297
+ time_ms=_elapsed_ms(start),
298
+ message_id=data.get("id"),
299
+ thread_id=data.get("threadId"),
300
+ label_ids=data.get("labelIds"),
301
+ refreshed_access_token=creds.refreshed_access_token,
302
+ data=data,
303
+ )
304
+
305
+
306
+ @router.post("/messages/send/raw", response_model=GmailSendResponse,
307
+ summary="Send a pre-encoded RFC 2822 message")
308
+ async def send_raw(
309
+ body: GmailRawMessageRequest,
310
+ creds: GmailCredentials = Depends(_credentials),
311
+ service: GmailService = Depends(get_gmail_service),
312
+ ):
313
+ start = time.perf_counter()
314
+ try:
315
+ data = await service.send_raw(
316
+ creds,
317
+ body.raw,
318
+ user_id=_user_id(body.delegate),
319
+ thread_id=body.thread_id,
320
+ labels=body.labels,
321
+ )
322
+ except GmailAPIError as exc:
323
+ raise _http_error(exc) from exc
324
+ _logger.info("Gmail raw message sent (%.2fms)", _elapsed_ms(start))
325
+ return GmailSendResponse(
326
+ success=True,
327
+ time_ms=_elapsed_ms(start),
328
+ message_id=data.get("id"),
329
+ thread_id=data.get("threadId"),
330
+ label_ids=data.get("labelIds"),
331
+ refreshed_access_token=creds.refreshed_access_token,
332
+ data=data,
333
+ )
334
+
335
+
336
+ @router.post("/messages", response_model=GmailSendResponse,
337
+ summary="Insert a message directly into the mailbox (does not send)")
338
+ async def insert_message(
339
+ body: GmailInsertMessageRequest,
340
+ creds: GmailCredentials = Depends(_credentials),
341
+ service: GmailService = Depends(get_gmail_service),
342
+ ):
343
+ start = time.perf_counter()
344
+ try:
345
+ data = await service.insert_message(
346
+ creds,
347
+ body.raw,
348
+ user_id=_user_id(body.delegate),
349
+ label_ids=body.label_ids,
350
+ internal_date_source=body.internal_date_source,
351
+ )
352
+ except GmailAPIError as exc:
353
+ raise _http_error(exc) from exc
354
+ _logger.info("Gmail message inserted (%.2fms)", _elapsed_ms(start))
355
+ return GmailSendResponse(
356
+ success=True,
357
+ time_ms=_elapsed_ms(start),
358
+ message_id=data.get("id"),
359
+ thread_id=data.get("threadId"),
360
+ refreshed_access_token=creds.refreshed_access_token,
361
+ data=data,
362
+ )
363
+
364
+
365
+ @router.post("/messages/batchModify", response_model=GmailGenericResponse,
366
+ summary="Add/remove labels across many messages")
367
+ async def batch_modify(
368
+ body: GmailBatchModifyRequest,
369
+ creds: GmailCredentials = Depends(_credentials),
370
+ service: GmailService = Depends(get_gmail_service),
371
+ ):
372
+ start = time.perf_counter()
373
+ try:
374
+ await service.batch_modify(
375
+ creds,
376
+ body.ids,
377
+ user_id="me",
378
+ add_label_ids=body.add_label_ids,
379
+ remove_label_ids=body.remove_label_ids,
380
+ )
381
+ except GmailAPIError as exc:
382
+ raise _http_error(exc) from exc
383
+ _logger.info("Gmail batch modify on %d messages (%.2fms)", len(body.ids), _elapsed_ms(start))
384
+ return _ok(start, creds, {"modified": len(body.ids)})
385
+
386
+
387
+ @router.post("/messages/batchDelete", response_model=GmailGenericResponse,
388
+ summary="Permanently delete many messages")
389
+ async def batch_delete(
390
+ body: GmailBatchDeleteRequest,
391
+ creds: GmailCredentials = Depends(_credentials),
392
+ service: GmailService = Depends(get_gmail_service),
393
+ ):
394
+ start = time.perf_counter()
395
+ try:
396
+ await service.batch_delete(creds, body.ids, user_id="me")
397
+ except GmailAPIError as exc:
398
+ raise _http_error(exc) from exc
399
+ _logger.info("Gmail batch delete on %d messages (%.2fms)", len(body.ids), _elapsed_ms(start))
400
+ return _ok(start, creds, {"deleted": len(body.ids)})
401
+
402
+
403
+ @router.post("/messages/{message_id}/modify", response_model=GmailGenericResponse,
404
+ summary="Add/remove labels on a single message (e.g. mark read/unread)")
405
+ async def modify_message(
406
+ message_id: str,
407
+ body: GmailMessageModifyRequest,
408
+ creds: GmailCredentials = Depends(_credentials),
409
+ service: GmailService = Depends(get_gmail_service),
410
+ user_id: Optional[str] = None,
411
+ ):
412
+ start = time.perf_counter()
413
+ try:
414
+ data = await service.modify_message(
415
+ creds,
416
+ message_id,
417
+ user_id=user_id or "me",
418
+ add_label_ids=body.add_label_ids,
419
+ remove_label_ids=body.remove_label_ids,
420
+ )
421
+ except GmailAPIError as exc:
422
+ raise _http_error(exc) from exc
423
+ _logger.info("Gmail message %s modified (%.2fms)", message_id, _elapsed_ms(start))
424
+ return _ok(start, creds, data)
425
+
426
+
427
+ @router.post("/messages/{message_id}/trash", response_model=GmailGenericResponse,
428
+ summary="Move a message to trash")
429
+ async def trash_message(
430
+ message_id: str,
431
+ creds: GmailCredentials = Depends(_credentials),
432
+ service: GmailService = Depends(get_gmail_service),
433
+ user_id: Optional[str] = None,
434
+ ):
435
+ start = time.perf_counter()
436
+ try:
437
+ data = await service.trash_message(creds, message_id, user_id=user_id or "me")
438
+ except GmailAPIError as exc:
439
+ raise _http_error(exc) from exc
440
+ _logger.info("Gmail message %s trashed (%.2fms)", message_id, _elapsed_ms(start))
441
+ return _ok(start, creds, data)
442
+
443
+
444
+ @router.post("/messages/{message_id}/untrash", response_model=GmailGenericResponse,
445
+ summary="Restore a message from trash")
446
+ async def untrash_message(
447
+ message_id: str,
448
+ creds: GmailCredentials = Depends(_credentials),
449
+ service: GmailService = Depends(get_gmail_service),
450
+ user_id: Optional[str] = None,
451
+ ):
452
+ start = time.perf_counter()
453
+ try:
454
+ data = await service.untrash_message(creds, message_id, user_id=user_id or "me")
455
+ except GmailAPIError as exc:
456
+ raise _http_error(exc) from exc
457
+ _logger.info("Gmail message %s untrashed (%.2fms)", message_id, _elapsed_ms(start))
458
+ return _ok(start, creds, data)
459
+
460
+
461
+ @router.delete("/messages/{message_id}", response_model=GmailGenericResponse,
462
+ summary="Permanently delete a message")
463
+ async def delete_message(
464
+ message_id: str,
465
+ creds: GmailCredentials = Depends(_credentials),
466
+ service: GmailService = Depends(get_gmail_service),
467
+ user_id: Optional[str] = None,
468
+ ):
469
+ start = time.perf_counter()
470
+ try:
471
+ await service.delete_message(creds, message_id, user_id=user_id or "me")
472
+ except GmailAPIError as exc:
473
+ raise _http_error(exc) from exc
474
+ _logger.info("Gmail message %s deleted (%.2fms)", message_id, _elapsed_ms(start))
475
+ return _ok(start, creds, {"deleted": message_id})
476
+
477
+
478
+ # ---------------------------------------------------------------------------
479
+ # Drafts
480
+ # ---------------------------------------------------------------------------
481
+
482
+ @router.get("/drafts", response_model=GmailGenericResponse,
483
+ summary="List drafts")
484
+ async def list_drafts(
485
+ creds: GmailCredentials = Depends(_credentials),
486
+ service: GmailService = Depends(get_gmail_service),
487
+ max_results: Optional[int] = None,
488
+ page_token: Optional[str] = None,
489
+ q: Optional[str] = None,
490
+ user_id: Optional[str] = None,
491
+ ):
492
+ start = time.perf_counter()
493
+ try:
494
+ data = await service.list_drafts(
495
+ creds,
496
+ user_id=user_id or "me",
497
+ max_results=max_results,
498
+ page_token=page_token,
499
+ q=q,
500
+ )
501
+ except GmailAPIError as exc:
502
+ raise _http_error(exc) from exc
503
+ _logger.info("Gmail drafts listed (%.2fms)", _elapsed_ms(start))
504
+ return _ok(start, creds, data)
505
+
506
+
507
+ @router.get("/drafts/{draft_id}", response_model=GmailGenericResponse,
508
+ summary="Get a single draft")
509
+ async def get_draft(
510
+ draft_id: str,
511
+ creds: GmailCredentials = Depends(_credentials),
512
+ service: GmailService = Depends(get_gmail_service),
513
+ format: str = "full",
514
+ user_id: Optional[str] = None,
515
+ ):
516
+ start = time.perf_counter()
517
+ try:
518
+ data = await service.get_draft(creds, draft_id, user_id=user_id or "me", format=format)
519
+ except GmailAPIError as exc:
520
+ raise _http_error(exc) from exc
521
+ _logger.info("Gmail draft %s fetched (%.2fms)", draft_id, _elapsed_ms(start))
522
+ return _ok(start, creds, data)
523
+
524
+
525
+ @router.post("/drafts", response_model=GmailGenericResponse,
526
+ summary="Create a draft from a pre-encoded RFC 2822 message")
527
+ async def create_draft(
528
+ body: GmailDraftCreateRequest,
529
+ creds: GmailCredentials = Depends(_credentials),
530
+ service: GmailService = Depends(get_gmail_service),
531
+ ):
532
+ start = time.perf_counter()
533
+ try:
534
+ data = await service.create_draft(
535
+ creds,
536
+ body.raw,
537
+ user_id=_user_id(body.delegate),
538
+ thread_id=body.thread_id,
539
+ )
540
+ except GmailAPIError as exc:
541
+ raise _http_error(exc) from exc
542
+ _logger.info("Gmail draft created (%.2fms)", _elapsed_ms(start))
543
+ return _ok(start, creds, data)
544
+
545
+
546
+ @router.put("/drafts/{draft_id}", response_model=GmailGenericResponse,
547
+ summary="Replace an existing draft")
548
+ async def update_draft(
549
+ draft_id: str,
550
+ body: GmailDraftUpdateRequest,
551
+ creds: GmailCredentials = Depends(_credentials),
552
+ service: GmailService = Depends(get_gmail_service),
553
+ ):
554
+ start = time.perf_counter()
555
+ try:
556
+ data = await service.update_draft(
557
+ creds,
558
+ draft_id,
559
+ body.raw,
560
+ user_id=_user_id(body.delegate),
561
+ thread_id=body.thread_id,
562
+ )
563
+ except GmailAPIError as exc:
564
+ raise _http_error(exc) from exc
565
+ _logger.info("Gmail draft %s updated (%.2fms)", draft_id, _elapsed_ms(start))
566
+ return _ok(start, creds, data)
567
+
568
+
569
+ @router.post("/drafts/send", response_model=GmailSendResponse,
570
+ summary="Send an existing draft")
571
+ async def send_draft(
572
+ body: GmailDraftSendRequest,
573
+ creds: GmailCredentials = Depends(_credentials),
574
+ service: GmailService = Depends(get_gmail_service),
575
+ ):
576
+ start = time.perf_counter()
577
+ try:
578
+ data = await service.send_draft(creds, body.draft_id, user_id=_user_id(body.delegate))
579
+ except GmailAPIError as exc:
580
+ raise _http_error(exc) from exc
581
+ _logger.info("Gmail draft %s sent (%.2fms)", body.draft_id, _elapsed_ms(start))
582
+ return GmailSendResponse(
583
+ success=True,
584
+ time_ms=_elapsed_ms(start),
585
+ message_id=data.get("id"),
586
+ thread_id=data.get("threadId"),
587
+ refreshed_access_token=creds.refreshed_access_token,
588
+ data=data,
589
+ )
590
+
591
+
592
+ @router.delete("/drafts/{draft_id}", response_model=GmailGenericResponse,
593
+ summary="Delete a draft")
594
+ async def delete_draft(
595
+ draft_id: str,
596
+ creds: GmailCredentials = Depends(_credentials),
597
+ service: GmailService = Depends(get_gmail_service),
598
+ user_id: Optional[str] = None,
599
+ ):
600
+ start = time.perf_counter()
601
+ try:
602
+ await service.delete_draft(creds, draft_id, user_id=user_id or "me")
603
+ except GmailAPIError as exc:
604
+ raise _http_error(exc) from exc
605
+ _logger.info("Gmail draft %s deleted (%.2fms)", draft_id, _elapsed_ms(start))
606
+ return _ok(start, creds, {"deleted": draft_id})
607
+
608
+
609
+ # ---------------------------------------------------------------------------
610
+ # Threads
611
+ # ---------------------------------------------------------------------------
612
+
613
+ @router.get("/threads", response_model=GmailGenericResponse,
614
+ summary="List threads")
615
+ async def list_threads(
616
+ creds: GmailCredentials = Depends(_credentials),
617
+ service: GmailService = Depends(get_gmail_service),
618
+ q: Optional[str] = None,
619
+ label_ids: Optional[str] = None,
620
+ max_results: Optional[int] = None,
621
+ page_token: Optional[str] = None,
622
+ user_id: Optional[str] = None,
623
+ ):
624
+ start = time.perf_counter()
625
+ try:
626
+ data = await service.list_threads(
627
+ creds,
628
+ user_id=user_id or "me",
629
+ q=q,
630
+ label_ids=label_ids.split(",") if label_ids else None,
631
+ max_results=max_results,
632
+ page_token=page_token,
633
+ )
634
+ except GmailAPIError as exc:
635
+ raise _http_error(exc) from exc
636
+ _logger.info("Gmail threads listed (%.2fms)", _elapsed_ms(start))
637
+ return _ok(start, creds, data)
638
+
639
+
640
+ @router.get("/threads/{thread_id}", response_model=GmailGenericResponse,
641
+ summary="Get a single thread with all its messages")
642
+ async def get_thread(
643
+ thread_id: str,
644
+ creds: GmailCredentials = Depends(_credentials),
645
+ service: GmailService = Depends(get_gmail_service),
646
+ format: str = "full",
647
+ user_id: Optional[str] = None,
648
+ ):
649
+ start = time.perf_counter()
650
+ try:
651
+ data = await service.get_thread(creds, thread_id, user_id=user_id or "me", format=format)
652
+ except GmailAPIError as exc:
653
+ raise _http_error(exc) from exc
654
+ _logger.info("Gmail thread %s fetched (%.2fms)", thread_id, _elapsed_ms(start))
655
+ return _ok(start, creds, data)
656
+
657
+
658
+ @router.post("/threads/{thread_id}/modify", response_model=GmailGenericResponse,
659
+ summary="Add/remove labels on all messages in a thread")
660
+ async def modify_thread(
661
+ thread_id: str,
662
+ body: GmailMessageModifyRequest,
663
+ creds: GmailCredentials = Depends(_credentials),
664
+ service: GmailService = Depends(get_gmail_service),
665
+ user_id: Optional[str] = None,
666
+ ):
667
+ start = time.perf_counter()
668
+ try:
669
+ data = await service.modify_thread(
670
+ creds,
671
+ thread_id,
672
+ user_id=user_id or "me",
673
+ add_label_ids=body.add_label_ids,
674
+ remove_label_ids=body.remove_label_ids,
675
+ )
676
+ except GmailAPIError as exc:
677
+ raise _http_error(exc) from exc
678
+ _logger.info("Gmail thread %s modified (%.2fms)", thread_id, _elapsed_ms(start))
679
+ return _ok(start, creds, data)
680
+
681
+
682
+ @router.post("/threads/{thread_id}/trash", response_model=GmailGenericResponse,
683
+ summary="Move all messages in a thread to trash")
684
+ async def trash_thread(
685
+ thread_id: str,
686
+ creds: GmailCredentials = Depends(_credentials),
687
+ service: GmailService = Depends(get_gmail_service),
688
+ user_id: Optional[str] = None,
689
+ ):
690
+ start = time.perf_counter()
691
+ try:
692
+ data = await service.trash_thread(creds, thread_id, user_id=user_id or "me")
693
+ except GmailAPIError as exc:
694
+ raise _http_error(exc) from exc
695
+ _logger.info("Gmail thread %s trashed (%.2fms)", thread_id, _elapsed_ms(start))
696
+ return _ok(start, creds, data)
697
+
698
+
699
+ @router.post("/threads/{thread_id}/untrash", response_model=GmailGenericResponse,
700
+ summary="Restore all messages in a thread from trash")
701
+ async def untrash_thread(
702
+ thread_id: str,
703
+ creds: GmailCredentials = Depends(_credentials),
704
+ service: GmailService = Depends(get_gmail_service),
705
+ user_id: Optional[str] = None,
706
+ ):
707
+ start = time.perf_counter()
708
+ try:
709
+ data = await service.untrash_thread(creds, thread_id, user_id=user_id or "me")
710
+ except GmailAPIError as exc:
711
+ raise _http_error(exc) from exc
712
+ _logger.info("Gmail thread %s untrashed (%.2fms)", thread_id, _elapsed_ms(start))
713
+ return _ok(start, creds, data)
714
+
715
+
716
+ @router.delete("/threads/{thread_id}", response_model=GmailGenericResponse,
717
+ summary="Permanently delete a thread")
718
+ async def delete_thread(
719
+ thread_id: str,
720
+ creds: GmailCredentials = Depends(_credentials),
721
+ service: GmailService = Depends(get_gmail_service),
722
+ user_id: Optional[str] = None,
723
+ ):
724
+ start = time.perf_counter()
725
+ try:
726
+ await service.delete_thread(creds, thread_id, user_id=user_id or "me")
727
+ except GmailAPIError as exc:
728
+ raise _http_error(exc) from exc
729
+ _logger.info("Gmail thread %s deleted (%.2fms)", thread_id, _elapsed_ms(start))
730
+ return _ok(start, creds, {"deleted": thread_id})
731
+
732
+
733
+ # ---------------------------------------------------------------------------
734
+ # Labels
735
+ # ---------------------------------------------------------------------------
736
+
737
+ @router.get("/labels", response_model=GmailGenericResponse,
738
+ summary="List all labels")
739
+ async def list_labels(
740
+ creds: GmailCredentials = Depends(_credentials),
741
+ service: GmailService = Depends(get_gmail_service),
742
+ user_id: Optional[str] = None,
743
+ ):
744
+ start = time.perf_counter()
745
+ try:
746
+ data = await service.list_labels(creds, user_id=user_id or "me")
747
+ except GmailAPIError as exc:
748
+ raise _http_error(exc) from exc
749
+ _logger.info("Gmail labels listed (%.2fms)", _elapsed_ms(start))
750
+ return _ok(start, creds, data)
751
+
752
+
753
+ @router.get("/labels/{label_id}", response_model=GmailGenericResponse,
754
+ summary="Get a single label")
755
+ async def get_label(
756
+ label_id: str,
757
+ creds: GmailCredentials = Depends(_credentials),
758
+ service: GmailService = Depends(get_gmail_service),
759
+ user_id: Optional[str] = None,
760
+ ):
761
+ start = time.perf_counter()
762
+ try:
763
+ data = await service.get_label(creds, label_id, user_id=user_id or "me")
764
+ except GmailAPIError as exc:
765
+ raise _http_error(exc) from exc
766
+ _logger.info("Gmail label %s fetched (%.2fms)", label_id, _elapsed_ms(start))
767
+ return _ok(start, creds, data)
768
+
769
+
770
+ @router.post("/labels", response_model=GmailGenericResponse,
771
+ summary="Create a user label")
772
+ async def create_label(
773
+ body: GmailLabelRequest,
774
+ creds: GmailCredentials = Depends(_credentials),
775
+ service: GmailService = Depends(get_gmail_service),
776
+ user_id: Optional[str] = None,
777
+ ):
778
+ start = time.perf_counter()
779
+ try:
780
+ data = await service.create_label(creds, body, user_id=user_id or "me")
781
+ except GmailAPIError as exc:
782
+ raise _http_error(exc) from exc
783
+ _logger.info("Gmail label '%s' created (%.2fms)", body.name, _elapsed_ms(start))
784
+ return _ok(start, creds, data)
785
+
786
+
787
+ @router.put("/labels/{label_id}", response_model=GmailGenericResponse,
788
+ summary="Update a user label")
789
+ async def update_label(
790
+ label_id: str,
791
+ body: GmailLabelRequest,
792
+ creds: GmailCredentials = Depends(_credentials),
793
+ service: GmailService = Depends(get_gmail_service),
794
+ user_id: Optional[str] = None,
795
+ ):
796
+ start = time.perf_counter()
797
+ try:
798
+ data = await service.update_label(creds, label_id, body, user_id=user_id or "me")
799
+ except GmailAPIError as exc:
800
+ raise _http_error(exc) from exc
801
+ _logger.info("Gmail label %s updated (%.2fms)", label_id, _elapsed_ms(start))
802
+ return _ok(start, creds, data)
803
+
804
+
805
+ @router.delete("/labels/{label_id}", response_model=GmailGenericResponse,
806
+ summary="Delete a user label")
807
+ async def delete_label(
808
+ label_id: str,
809
+ creds: GmailCredentials = Depends(_credentials),
810
+ service: GmailService = Depends(get_gmail_service),
811
+ user_id: Optional[str] = None,
812
+ ):
813
+ start = time.perf_counter()
814
+ try:
815
+ await service.delete_label(creds, label_id, user_id=user_id or "me")
816
+ except GmailAPIError as exc:
817
+ raise _http_error(exc) from exc
818
+ _logger.info("Gmail label %s deleted (%.2fms)", label_id, _elapsed_ms(start))
819
+ return _ok(start, creds, {"deleted": label_id})
820
+
821
+
822
+ # ---------------------------------------------------------------------------
823
+ # Settings
824
+ # ---------------------------------------------------------------------------
825
+
826
+ @router.get("/settings/auto-forwarding", response_model=GmailGenericResponse,
827
+ summary="Get auto-forwarding settings")
828
+ async def get_auto_forwarding(
829
+ creds: GmailCredentials = Depends(_credentials),
830
+ service: GmailService = Depends(get_gmail_service),
831
+ user_id: Optional[str] = None,
832
+ ):
833
+ start = time.perf_counter()
834
+ try:
835
+ data = await service.get_auto_forwarding(creds, user_id=user_id or "me")
836
+ except GmailAPIError as exc:
837
+ raise _http_error(exc) from exc
838
+ _logger.info("Gmail auto-forwarding fetched (%.2fms)", _elapsed_ms(start))
839
+ return _ok(start, creds, data)
840
+
841
+
842
+ @router.put("/settings/auto-forwarding", response_model=GmailGenericResponse,
843
+ summary="Update auto-forwarding settings")
844
+ async def update_auto_forwarding(
845
+ body: GmailSettingsUpdateRequest,
846
+ creds: GmailCredentials = Depends(_credentials),
847
+ service: GmailService = Depends(get_gmail_service),
848
+ user_id: Optional[str] = None,
849
+ ):
850
+ start = time.perf_counter()
851
+ try:
852
+ data = await service.update_auto_forwarding(creds, body.payload, user_id=user_id or "me")
853
+ except GmailAPIError as exc:
854
+ raise _http_error(exc) from exc
855
+ _logger.info("Gmail auto-forwarding updated (%.2fms)", _elapsed_ms(start))
856
+ return _ok(start, creds, data)
857
+
858
+
859
+ @router.get("/settings/vacation", response_model=GmailGenericResponse,
860
+ summary="Get vacation responder settings")
861
+ async def get_vacation(
862
+ creds: GmailCredentials = Depends(_credentials),
863
+ service: GmailService = Depends(get_gmail_service),
864
+ user_id: Optional[str] = None,
865
+ ):
866
+ start = time.perf_counter()
867
+ try:
868
+ data = await service.get_vacation(creds, user_id=user_id or "me")
869
+ except GmailAPIError as exc:
870
+ raise _http_error(exc) from exc
871
+ _logger.info("Gmail vacation settings fetched (%.2fms)", _elapsed_ms(start))
872
+ return _ok(start, creds, data)
873
+
874
+
875
+ @router.put("/settings/vacation", response_model=GmailGenericResponse,
876
+ summary="Update vacation responder settings")
877
+ async def update_vacation(
878
+ body: GmailSettingsUpdateRequest,
879
+ creds: GmailCredentials = Depends(_credentials),
880
+ service: GmailService = Depends(get_gmail_service),
881
+ user_id: Optional[str] = None,
882
+ ):
883
+ start = time.perf_counter()
884
+ try:
885
+ data = await service.update_vacation(creds, body.payload, user_id=user_id or "me")
886
+ except GmailAPIError as exc:
887
+ raise _http_error(exc) from exc
888
+ _logger.info("Gmail vacation settings updated (%.2fms)", _elapsed_ms(start))
889
+ return _ok(start, creds, data)
890
+
891
+
892
+ @router.get("/settings/filters", response_model=GmailGenericResponse,
893
+ summary="List all filters")
894
+ async def list_filters(
895
+ creds: GmailCredentials = Depends(_credentials),
896
+ service: GmailService = Depends(get_gmail_service),
897
+ user_id: Optional[str] = None,
898
+ ):
899
+ start = time.perf_counter()
900
+ try:
901
+ data = await service.list_filters(creds, user_id=user_id or "me")
902
+ except GmailAPIError as exc:
903
+ raise _http_error(exc) from exc
904
+ _logger.info("Gmail filters listed (%.2fms)", _elapsed_ms(start))
905
+ return _ok(start, creds, data)
906
+
907
+
908
+ @router.get("/settings/filters/{filter_id}", response_model=GmailGenericResponse,
909
+ summary="Get a single filter")
910
+ async def get_filter(
911
+ filter_id: str,
912
+ creds: GmailCredentials = Depends(_credentials),
913
+ service: GmailService = Depends(get_gmail_service),
914
+ user_id: Optional[str] = None,
915
+ ):
916
+ start = time.perf_counter()
917
+ try:
918
+ data = await service.get_filter(creds, filter_id, user_id=user_id or "me")
919
+ except GmailAPIError as exc:
920
+ raise _http_error(exc) from exc
921
+ _logger.info("Gmail filter %s fetched (%.2fms)", filter_id, _elapsed_ms(start))
922
+ return _ok(start, creds, data)
923
+
924
+
925
+ @router.post("/settings/filters", response_model=GmailGenericResponse,
926
+ summary="Create a filter")
927
+ async def create_filter(
928
+ body: GmailFilterCreateRequest,
929
+ creds: GmailCredentials = Depends(_credentials),
930
+ service: GmailService = Depends(get_gmail_service),
931
+ user_id: Optional[str] = None,
932
+ ):
933
+ start = time.perf_counter()
934
+ try:
935
+ data = await service.create_filter(creds, body, user_id=user_id or "me")
936
+ except GmailAPIError as exc:
937
+ raise _http_error(exc) from exc
938
+ _logger.info("Gmail filter created (%.2fms)", _elapsed_ms(start))
939
+ return _ok(start, creds, data)
940
+
941
+
942
+ @router.delete("/settings/filters/{filter_id}", response_model=GmailGenericResponse,
943
+ summary="Delete a filter")
944
+ async def delete_filter(
945
+ filter_id: str,
946
+ creds: GmailCredentials = Depends(_credentials),
947
+ service: GmailService = Depends(get_gmail_service),
948
+ user_id: Optional[str] = None,
949
+ ):
950
+ start = time.perf_counter()
951
+ try:
952
+ await service.delete_filter(creds, filter_id, user_id=user_id or "me")
953
+ except GmailAPIError as exc:
954
+ raise _http_error(exc) from exc
955
+ _logger.info("Gmail filter %s deleted (%.2fms)", filter_id, _elapsed_ms(start))
956
+ return _ok(start, creds, {"deleted": filter_id})
957
+
958
+
959
+ @router.get("/settings/forwarding-addresses", response_model=GmailGenericResponse,
960
+ summary="List forwarding addresses")
961
+ async def list_forwarding_addresses(
962
+ creds: GmailCredentials = Depends(_credentials),
963
+ service: GmailService = Depends(get_gmail_service),
964
+ user_id: Optional[str] = None,
965
+ ):
966
+ start = time.perf_counter()
967
+ try:
968
+ data = await service.list_forwarding_addresses(creds, user_id=user_id or "me")
969
+ except GmailAPIError as exc:
970
+ raise _http_error(exc) from exc
971
+ _logger.info("Gmail forwarding addresses listed (%.2fms)", _elapsed_ms(start))
972
+ return _ok(start, creds, data)
973
+
974
+
975
+ @router.get("/settings/forwarding-addresses/{forwarding_email}", response_model=GmailGenericResponse,
976
+ summary="Get a forwarding address")
977
+ async def get_forwarding_address(
978
+ forwarding_email: str,
979
+ creds: GmailCredentials = Depends(_credentials),
980
+ service: GmailService = Depends(get_gmail_service),
981
+ user_id: Optional[str] = None,
982
+ ):
983
+ start = time.perf_counter()
984
+ try:
985
+ data = await service.get_forwarding_address(creds, forwarding_email, user_id=user_id or "me")
986
+ except GmailAPIError as exc:
987
+ raise _http_error(exc) from exc
988
+ _logger.info("Gmail forwarding address %s fetched (%.2fms)", forwarding_email, _elapsed_ms(start))
989
+ return _ok(start, creds, data)
990
+
991
+
992
+ @router.post("/settings/forwarding-addresses", response_model=GmailGenericResponse,
993
+ summary="Create a forwarding address")
994
+ async def create_forwarding_address(
995
+ body: Dict[str, str],
996
+ creds: GmailCredentials = Depends(_credentials),
997
+ service: GmailService = Depends(get_gmail_service),
998
+ user_id: Optional[str] = None,
999
+ ):
1000
+ start = time.perf_counter()
1001
+ forwarding_email = body.get("forwarding_email", "")
1002
+ if not forwarding_email:
1003
+ raise HTTPException(status_code=400, detail="forwarding_email is required.")
1004
+ try:
1005
+ data = await service.create_forwarding_address(creds, forwarding_email, user_id=user_id or "me")
1006
+ except GmailAPIError as exc:
1007
+ raise _http_error(exc) from exc
1008
+ _logger.info("Gmail forwarding address %s created (%.2fms)", forwarding_email, _elapsed_ms(start))
1009
+ return _ok(start, creds, data)
1010
+
1011
+
1012
+ @router.delete("/settings/forwarding-addresses/{forwarding_email}", response_model=GmailGenericResponse,
1013
+ summary="Delete a forwarding address")
1014
+ async def delete_forwarding_address(
1015
+ forwarding_email: str,
1016
+ creds: GmailCredentials = Depends(_credentials),
1017
+ service: GmailService = Depends(get_gmail_service),
1018
+ user_id: Optional[str] = None,
1019
+ ):
1020
+ start = time.perf_counter()
1021
+ try:
1022
+ await service.delete_forwarding_address(creds, forwarding_email, user_id=user_id or "me")
1023
+ except GmailAPIError as exc:
1024
+ raise _http_error(exc) from exc
1025
+ _logger.info("Gmail forwarding address %s deleted (%.2fms)", forwarding_email, _elapsed_ms(start))
1026
+ return _ok(start, creds, {"deleted": forwarding_email})
1027
+
1028
+
1029
+ @router.get("/settings/send-as", response_model=GmailGenericResponse,
1030
+ summary="List send-as aliases")
1031
+ async def list_send_as(
1032
+ creds: GmailCredentials = Depends(_credentials),
1033
+ service: GmailService = Depends(get_gmail_service),
1034
+ user_id: Optional[str] = None,
1035
+ ):
1036
+ start = time.perf_counter()
1037
+ try:
1038
+ data = await service.list_send_as(creds, user_id=user_id or "me")
1039
+ except GmailAPIError as exc:
1040
+ raise _http_error(exc) from exc
1041
+ _logger.info("Gmail send-as aliases listed (%.2fms)", _elapsed_ms(start))
1042
+ return _ok(start, creds, data)
1043
+
1044
+
1045
+ @router.get("/settings/send-as/{send_as_email}", response_model=GmailGenericResponse,
1046
+ summary="Get a send-as alias")
1047
+ async def get_send_as(
1048
+ send_as_email: str,
1049
+ creds: GmailCredentials = Depends(_credentials),
1050
+ service: GmailService = Depends(get_gmail_service),
1051
+ user_id: Optional[str] = None,
1052
+ ):
1053
+ start = time.perf_counter()
1054
+ try:
1055
+ data = await service.get_send_as(creds, send_as_email, user_id=user_id or "me")
1056
+ except GmailAPIError as exc:
1057
+ raise _http_error(exc) from exc
1058
+ _logger.info("Gmail send-as %s fetched (%.2fms)", send_as_email, _elapsed_ms(start))
1059
+ return _ok(start, creds, data)
1060
+
1061
+
1062
+ @router.post("/settings/send-as", response_model=GmailGenericResponse,
1063
+ summary="Create a send-as alias")
1064
+ async def create_send_as(
1065
+ body: GmailSettingsUpdateRequest,
1066
+ creds: GmailCredentials = Depends(_credentials),
1067
+ service: GmailService = Depends(get_gmail_service),
1068
+ user_id: Optional[str] = None,
1069
+ ):
1070
+ start = time.perf_counter()
1071
+ try:
1072
+ data = await service.create_send_as(creds, body.payload, user_id=user_id or "me")
1073
+ except GmailAPIError as exc:
1074
+ raise _http_error(exc) from exc
1075
+ _logger.info("Gmail send-as created (%.2fms)", _elapsed_ms(start))
1076
+ return _ok(start, creds, data)
1077
+
1078
+
1079
+ @router.patch("/settings/send-as/{send_as_email}", response_model=GmailGenericResponse,
1080
+ summary="Update a send-as alias")
1081
+ async def update_send_as(
1082
+ send_as_email: str,
1083
+ body: GmailSettingsUpdateRequest,
1084
+ creds: GmailCredentials = Depends(_credentials),
1085
+ service: GmailService = Depends(get_gmail_service),
1086
+ user_id: Optional[str] = None,
1087
+ ):
1088
+ start = time.perf_counter()
1089
+ try:
1090
+ data = await service.update_send_as(creds, send_as_email, body.payload, user_id=user_id or "me")
1091
+ except GmailAPIError as exc:
1092
+ raise _http_error(exc) from exc
1093
+ _logger.info("Gmail send-as %s updated (%.2fms)", send_as_email, _elapsed_ms(start))
1094
+ return _ok(start, creds, data)
1095
+
1096
+
1097
+ @router.delete("/settings/send-as/{send_as_email}", response_model=GmailGenericResponse,
1098
+ summary="Delete a send-as alias")
1099
+ async def delete_send_as(
1100
+ send_as_email: str,
1101
+ creds: GmailCredentials = Depends(_credentials),
1102
+ service: GmailService = Depends(get_gmail_service),
1103
+ user_id: Optional[str] = None,
1104
+ ):
1105
+ start = time.perf_counter()
1106
+ try:
1107
+ await service.delete_send_as(creds, send_as_email, user_id=user_id or "me")
1108
+ except GmailAPIError as exc:
1109
+ raise _http_error(exc) from exc
1110
+ _logger.info("Gmail send-as %s deleted (%.2fms)", send_as_email, _elapsed_ms(start))
1111
+ return _ok(start, creds, {"deleted": send_as_email})
1112
+
1113
+
1114
+ @router.get("/settings/delegates", response_model=GmailGenericResponse,
1115
+ summary="List delegates")
1116
+ async def list_delegates(
1117
+ creds: GmailCredentials = Depends(_credentials),
1118
+ service: GmailService = Depends(get_gmail_service),
1119
+ user_id: Optional[str] = None,
1120
+ ):
1121
+ start = time.perf_counter()
1122
+ try:
1123
+ data = await service.list_delegates(creds, user_id=user_id or "me")
1124
+ except GmailAPIError as exc:
1125
+ raise _http_error(exc) from exc
1126
+ _logger.info("Gmail delegates listed (%.2fms)", _elapsed_ms(start))
1127
+ return _ok(start, creds, data)
1128
+
1129
+
1130
+ @router.get("/settings/delegates/{delegate_email}", response_model=GmailGenericResponse,
1131
+ summary="Get a delegate")
1132
+ async def get_delegate(
1133
+ delegate_email: str,
1134
+ creds: GmailCredentials = Depends(_credentials),
1135
+ service: GmailService = Depends(get_gmail_service),
1136
+ user_id: Optional[str] = None,
1137
+ ):
1138
+ start = time.perf_counter()
1139
+ try:
1140
+ data = await service.get_delegate(creds, delegate_email, user_id=user_id or "me")
1141
+ except GmailAPIError as exc:
1142
+ raise _http_error(exc) from exc
1143
+ _logger.info("Gmail delegate %s fetched (%.2fms)", delegate_email, _elapsed_ms(start))
1144
+ return _ok(start, creds, data)
1145
+
1146
+
1147
+ @router.post("/settings/delegates", response_model=GmailGenericResponse,
1148
+ summary="Create a delegate")
1149
+ async def create_delegate(
1150
+ body: Dict[str, str],
1151
+ creds: GmailCredentials = Depends(_credentials),
1152
+ service: GmailService = Depends(get_gmail_service),
1153
+ user_id: Optional[str] = None,
1154
+ ):
1155
+ start = time.perf_counter()
1156
+ delegate_email = body.get("delegate_email", "")
1157
+ if not delegate_email:
1158
+ raise HTTPException(status_code=400, detail="delegate_email is required.")
1159
+ try:
1160
+ data = await service.create_delegate(creds, delegate_email, user_id=user_id or "me")
1161
+ except GmailAPIError as exc:
1162
+ raise _http_error(exc) from exc
1163
+ _logger.info("Gmail delegate %s created (%.2fms)", delegate_email, _elapsed_ms(start))
1164
+ return _ok(start, creds, data)
1165
+
1166
+
1167
+ @router.delete("/settings/delegates/{delegate_email}", response_model=GmailGenericResponse,
1168
+ summary="Delete a delegate")
1169
+ async def delete_delegate(
1170
+ delegate_email: str,
1171
+ creds: GmailCredentials = Depends(_credentials),
1172
+ service: GmailService = Depends(get_gmail_service),
1173
+ user_id: Optional[str] = None,
1174
+ ):
1175
+ start = time.perf_counter()
1176
+ try:
1177
+ await service.delete_delegate(creds, delegate_email, user_id=user_id or "me")
1178
+ except GmailAPIError as exc:
1179
+ raise _http_error(exc) from exc
1180
+ _logger.info("Gmail delegate %s deleted (%.2fms)", delegate_email, _elapsed_ms(start))
1181
+ return _ok(start, creds, {"deleted": delegate_email})
app/api/v1/router.py CHANGED
@@ -12,6 +12,7 @@ from app.api.v1 import (
12
  database,
13
  embeddings,
14
  gcs,
 
15
  google_maps,
16
  google_oauth,
17
  json_extract,
@@ -21,6 +22,7 @@ from app.api.v1 import (
21
  qr_generator,
22
  reconcile,
23
  scheduler,
 
24
  scraper,
25
  semantic_router,
26
  sql_validator,
@@ -57,7 +59,9 @@ api_v1_router.include_router(webhook_socket.router, tags=["Webhook / Socket"])
57
  api_v1_router.include_router(csv_analysis.router, tags=["CSV Analysis"])
58
  api_v1_router.include_router(google_maps.router, tags=["Google Maps"])
59
  api_v1_router.include_router(google_oauth.router, tags=["Google OAuth"])
 
60
  api_v1_router.include_router(gcs.router, tags=["Google Cloud Storage"])
 
61
  api_v1_router.include_router(media_convert.router, tags=["Media-to-Media Conversion"])
62
  api_v1_router.include_router(json_extract.router, tags=["JSON Extractor"])
63
  api_v1_router.include_router(keys_extract.router, prefix="/json", tags=["Keys Extractor"])
 
12
  database,
13
  embeddings,
14
  gcs,
15
+ gmail,
16
  google_maps,
17
  google_oauth,
18
  json_extract,
 
22
  qr_generator,
23
  reconcile,
24
  scheduler,
25
+ scopes,
26
  scraper,
27
  semantic_router,
28
  sql_validator,
 
59
  api_v1_router.include_router(csv_analysis.router, tags=["CSV Analysis"])
60
  api_v1_router.include_router(google_maps.router, tags=["Google Maps"])
61
  api_v1_router.include_router(google_oauth.router, tags=["Google OAuth"])
62
+ api_v1_router.include_router(scopes.router, tags=["Google Scopes"])
63
  api_v1_router.include_router(gcs.router, tags=["Google Cloud Storage"])
64
+ api_v1_router.include_router(gmail.router, tags=["Gmail"])
65
  api_v1_router.include_router(media_convert.router, tags=["Media-to-Media Conversion"])
66
  api_v1_router.include_router(json_extract.router, tags=["JSON Extractor"])
67
  api_v1_router.include_router(keys_extract.router, prefix="/json", tags=["Keys Extractor"])
app/api/v1/scopes/__init__.py ADDED
@@ -0,0 +1,12 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ from fastapi import APIRouter
4
+
5
+ from . import detail, list, names, set
6
+
7
+ router = APIRouter(tags=["Google Scopes"])
8
+ _PREFIX = "/google/scopes"
9
+ router.include_router(list.router, prefix=_PREFIX)
10
+ router.include_router(names.router, prefix=_PREFIX)
11
+ router.include_router(detail.router, prefix=_PREFIX)
12
+ router.include_router(set.router, prefix=_PREFIX)
app/api/v1/scopes/deps.py ADDED
@@ -0,0 +1,13 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ from app.services.google_scope_service import GoogleScopeService
4
+
5
+ SOURCE = "google_scopes_data.GOOGLE_APIS (in-memory Python object structure)"
6
+ REFERENCE = "https://developers.google.com/identity/protocols/oauth2/scopes"
7
+ GENERATED = "2026-08-02"
8
+
9
+ _service = GoogleScopeService()
10
+
11
+
12
+ def get_scope_service() -> GoogleScopeService:
13
+ return _service
app/api/v1/scopes/detail.py ADDED
@@ -0,0 +1,81 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import time
4
+ from typing import List, Optional, Union
5
+
6
+ from fastapi import APIRouter, HTTPException, Query
7
+
8
+ from app.models.schemas import (
9
+ GoogleApiScopesResponse,
10
+ GoogleScopeNamesResponse,
11
+ )
12
+ from app.services.google_scope_service import GoogleScopeError
13
+
14
+ from .deps import GENERATED, REFERENCE, SOURCE, get_scope_service
15
+
16
+ router = APIRouter()
17
+
18
+
19
+ @router.get("/{api_name}", response_model=Union[GoogleApiScopesResponse, GoogleScopeNamesResponse])
20
+ async def get_google_api_scopes(
21
+ api_name: str,
22
+ scope_name: Optional[str] = Query(None, description="Keep scopes whose name or URI contains this value"),
23
+ permission_level: Optional[List[str]] = Query(None, description="Filter scopes by permission level (repeatable)"),
24
+ required: Optional[bool] = Query(None, description="Filter scopes by required flag"),
25
+ recommended: Optional[bool] = Query(None, description="Filter scopes by recommended flag"),
26
+ scope_name_only: bool = Query(False, description="Return only scope names instead of full scope objects"),
27
+ ) -> Union[GoogleApiScopesResponse, GoogleScopeNamesResponse]:
28
+ """Return a single Google integration's scopes, with optional scope filters."""
29
+ from .list import _validate_permission_levels
30
+
31
+ _validate_permission_levels(permission_level)
32
+ started = time.perf_counter()
33
+ service = get_scope_service()
34
+ try:
35
+ plans = await service.load_all_plans()
36
+ state = await service.load_state()
37
+ data = service.build_data(plans, state)
38
+ except GoogleScopeError as exc:
39
+ return GoogleApiScopesResponse(
40
+ success=False,
41
+ time_ms=round((time.perf_counter() - started) * 1000, 3),
42
+ error=exc.message,
43
+ )
44
+
45
+ filtered = service.filter_data(
46
+ data,
47
+ api_name=api_name,
48
+ scope_name=scope_name,
49
+ permission_level=permission_level,
50
+ required=required,
51
+ recommended=recommended,
52
+ scope_name_only=scope_name_only,
53
+ )
54
+ if not filtered:
55
+ raise HTTPException(status_code=404, detail=f"Unknown API id '{api_name}'")
56
+
57
+ entry = filtered[0]
58
+ if scope_name_only:
59
+ return GoogleScopeNamesResponse(api_name=entry["id"], scopes=entry["scopes"])
60
+
61
+ return GoogleApiScopesResponse(
62
+ success=True,
63
+ time_ms=round((time.perf_counter() - started) * 1000, 3),
64
+ count=1,
65
+ total=1,
66
+ page=1,
67
+ page_size=1,
68
+ total_pages=1,
69
+ generated=GENERATED,
70
+ reference=REFERENCE,
71
+ source=SOURCE,
72
+ filters={
73
+ "api_name": api_name,
74
+ "scope_name": scope_name,
75
+ "permission_level": permission_level,
76
+ "required": required,
77
+ "recommended": recommended,
78
+ "scope_name_only": scope_name_only,
79
+ },
80
+ data=[entry],
81
+ )
app/api/v1/scopes/list.py ADDED
@@ -0,0 +1,162 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import time
4
+ from typing import List, Optional, Union
5
+
6
+ from fastapi import APIRouter, HTTPException, Query
7
+
8
+ from app.models.schemas import (
9
+ GoogleApiIdsResponse,
10
+ GoogleApiScopesResponse,
11
+ GoogleScopeNamesEntry,
12
+ GoogleScopeNamesListResponse,
13
+ GoogleScopeNamesResponse,
14
+ )
15
+ from app.services.google_scope_service import GoogleScopeError
16
+
17
+ from .deps import GENERATED, REFERENCE, SOURCE, get_scope_service
18
+
19
+ router = APIRouter()
20
+
21
+ _VALID_PERMISSION_LEVELS = frozenset({"non-sensitive", "sensitive", "restricted"})
22
+
23
+
24
+ def _filters_echo(
25
+ api_name: Optional[str],
26
+ oauth_only: bool,
27
+ no_scope: bool,
28
+ search: Optional[str],
29
+ scope_name: Optional[str],
30
+ permission_level: Optional[List[str]],
31
+ required: Optional[bool],
32
+ recommended: Optional[bool],
33
+ scope_name_only: bool,
34
+ ids_only: bool,
35
+ ) -> dict:
36
+ return {
37
+ "api_name": api_name,
38
+ "oauth_only": oauth_only,
39
+ "no_scope": no_scope,
40
+ "search": search,
41
+ "scope_name": scope_name,
42
+ "permission_level": permission_level,
43
+ "required": required,
44
+ "recommended": recommended,
45
+ "scope_name_only": scope_name_only,
46
+ "ids_only": ids_only,
47
+ }
48
+
49
+
50
+ def _validate_permission_levels(permission_level: Optional[List[str]]) -> None:
51
+ if permission_level:
52
+ bad = [p for p in permission_level if p.strip().lower() not in _VALID_PERMISSION_LEVELS]
53
+ if bad:
54
+ raise HTTPException(
55
+ status_code=422,
56
+ detail=f"Invalid permission_level values: {', '.join(bad)}. "
57
+ "Allowed: non-sensitive, sensitive, restricted.",
58
+ )
59
+
60
+
61
+ @router.get("", response_model=Union[GoogleApiScopesResponse, GoogleScopeNamesResponse, GoogleScopeNamesListResponse, GoogleApiIdsResponse])
62
+ async def list_google_scopes(
63
+ api_name: Optional[str] = Query(None, description="Filter to a single API id (e.g. gmail, youtube, drive)"),
64
+ oauth_only: bool = Query(False, description="Only APIs that require OAuth scopes"),
65
+ no_scope: bool = Query(False, description="Only APIs that do not require OAuth scopes"),
66
+ search: Optional[str] = Query(None, description="Free-text search across API id/name and scope fields"),
67
+ scope_name: Optional[str] = Query(None, description="Keep scopes whose name or URI contains this value"),
68
+ permission_level: Optional[List[str]] = Query(None, description="Filter scopes by permission level (repeatable)"),
69
+ required: Optional[bool] = Query(None, description="Filter scopes by required flag"),
70
+ recommended: Optional[bool] = Query(None, description="Filter scopes by recommended flag"),
71
+ scope_name_only: bool = Query(False, description="Return only scope names instead of full scope objects"),
72
+ ids_only: bool = Query(False, description="Return only the matching API ids"),
73
+ page: int = Query(1, ge=1, description="Page number"),
74
+ page_size: int = Query(50, ge=1, le=200, description="Items per page"),
75
+ ) -> Union[GoogleApiScopesResponse, GoogleScopeNamesResponse, GoogleScopeNamesListResponse, GoogleApiIdsResponse]:
76
+ """List all Google integrations with their OAuth scopes and current selection.
77
+
78
+ Supports combining every filter: ``api_name``, ``oauth_only``/``no_scope``,
79
+ ``search``, ``scope_name``, ``permission_level``, ``required``,
80
+ ``recommended``, ``scope_name_only``, ``ids_only`` and pagination.
81
+ """
82
+ _validate_permission_levels(permission_level)
83
+ started = time.perf_counter()
84
+ service = get_scope_service()
85
+ try:
86
+ plans = await service.load_all_plans()
87
+ state = await service.load_state()
88
+ data = service.build_data(plans, state)
89
+ except GoogleScopeError as exc:
90
+ return GoogleApiScopesResponse(
91
+ success=False,
92
+ time_ms=round((time.perf_counter() - started) * 1000, 3),
93
+ error=exc.message,
94
+ )
95
+
96
+ filtered = service.filter_data(
97
+ data,
98
+ api_name=api_name,
99
+ oauth_only=oauth_only,
100
+ no_scope=no_scope,
101
+ search=search,
102
+ scope_name=scope_name,
103
+ permission_level=permission_level,
104
+ required=required,
105
+ recommended=recommended,
106
+ scope_name_only=scope_name_only,
107
+ )
108
+
109
+ if ids_only:
110
+ return GoogleApiIdsResponse(
111
+ success=True,
112
+ time_ms=round((time.perf_counter() - started) * 1000, 3),
113
+ count=len(filtered),
114
+ ids=[e["id"] for e in filtered],
115
+ )
116
+
117
+ # Compact single-API shape for api_name + scope_name_only (see example).
118
+ if scope_name_only and api_name:
119
+ entry = next((e for e in filtered if e["id"] == api_name), None)
120
+ if entry is None:
121
+ raise HTTPException(status_code=404, detail=f"Unknown API id '{api_name}'")
122
+ return GoogleScopeNamesResponse(api_name=entry["id"], scopes=entry["scopes"])
123
+
124
+ if scope_name_only:
125
+ start = (page - 1) * page_size
126
+ chunk = filtered[start : start + page_size]
127
+ return GoogleScopeNamesListResponse(
128
+ success=True,
129
+ time_ms=round((time.perf_counter() - started) * 1000, 3),
130
+ count=len(chunk),
131
+ data=[GoogleScopeNamesEntry(api_name=e["id"], scopes=e["scopes"]) for e in chunk],
132
+ )
133
+
134
+ total = len(filtered)
135
+ total_pages = max(1, (total + page_size - 1) // page_size)
136
+ start = (page - 1) * page_size
137
+ chunk = filtered[start : start + page_size]
138
+ return GoogleApiScopesResponse(
139
+ success=True,
140
+ time_ms=round((time.perf_counter() - started) * 1000, 3),
141
+ count=len(chunk),
142
+ total=total,
143
+ page=page,
144
+ page_size=page_size,
145
+ total_pages=total_pages,
146
+ generated=GENERATED,
147
+ reference=REFERENCE,
148
+ source=SOURCE,
149
+ filters=_filters_echo(
150
+ api_name=api_name,
151
+ oauth_only=oauth_only,
152
+ no_scope=no_scope,
153
+ search=search,
154
+ scope_name=scope_name,
155
+ permission_level=permission_level,
156
+ required=required,
157
+ recommended=recommended,
158
+ scope_name_only=scope_name_only,
159
+ ids_only=ids_only,
160
+ ),
161
+ data=chunk,
162
+ )
app/api/v1/scopes/names.py ADDED
@@ -0,0 +1,76 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import time
4
+ from typing import List, Optional, Union
5
+
6
+ from fastapi import APIRouter, HTTPException, Query
7
+
8
+ from app.models.schemas import (
9
+ GoogleScopeNamesEntry,
10
+ GoogleScopeNamesListResponse,
11
+ GoogleScopeNamesResponse,
12
+ )
13
+ from app.services.google_scope_service import GoogleScopeError
14
+
15
+ from .deps import get_scope_service
16
+
17
+ router = APIRouter()
18
+
19
+
20
+ @router.get("/names", response_model=Union[GoogleScopeNamesResponse, GoogleScopeNamesListResponse])
21
+ async def list_scope_names(
22
+ api_name: Optional[str] = Query(None, description="Filter to a single API id"),
23
+ oauth_only: bool = Query(False, description="Only APIs that require OAuth scopes"),
24
+ no_scope: bool = Query(False, description="Only APIs that do not require OAuth scopes"),
25
+ search: Optional[str] = Query(None, description="Free-text search across API id/name and scope fields"),
26
+ scope_name: Optional[str] = Query(None, description="Keep scopes whose name or URI contains this value"),
27
+ permission_level: Optional[List[str]] = Query(None, description="Filter scopes by permission level (repeatable)"),
28
+ required: Optional[bool] = Query(None, description="Filter scopes by required flag"),
29
+ recommended: Optional[bool] = Query(None, description="Filter scopes by recommended flag"),
30
+ page: int = Query(1, ge=1),
31
+ page_size: int = Query(50, ge=1, le=200),
32
+ ) -> Union[GoogleScopeNamesResponse, GoogleScopeNamesListResponse]:
33
+ """Return only the scope names for the matching APIs."""
34
+ from .list import _validate_permission_levels
35
+
36
+ _validate_permission_levels(permission_level)
37
+ started = time.perf_counter()
38
+ service = get_scope_service()
39
+ try:
40
+ plans = await service.load_all_plans()
41
+ state = await service.load_state()
42
+ data = service.build_data(plans, state)
43
+ except GoogleScopeError as exc:
44
+ return GoogleScopeNamesListResponse(
45
+ success=False,
46
+ time_ms=round((time.perf_counter() - started) * 1000, 3),
47
+ error=exc.message,
48
+ )
49
+
50
+ filtered = service.filter_data(
51
+ data,
52
+ api_name=api_name,
53
+ oauth_only=oauth_only,
54
+ no_scope=no_scope,
55
+ search=search,
56
+ scope_name=scope_name,
57
+ permission_level=permission_level,
58
+ required=required,
59
+ recommended=recommended,
60
+ scope_name_only=True,
61
+ )
62
+
63
+ if api_name:
64
+ entry = next((e for e in filtered if e["id"] == api_name), None)
65
+ if entry is None:
66
+ raise HTTPException(status_code=404, detail=f"Unknown API id '{api_name}'")
67
+ return GoogleScopeNamesResponse(api_name=entry["id"], scopes=entry["scopes"])
68
+
69
+ start = (page - 1) * page_size
70
+ chunk = filtered[start : start + page_size]
71
+ return GoogleScopeNamesListResponse(
72
+ success=True,
73
+ time_ms=round((time.perf_counter() - started) * 1000, 3),
74
+ count=len(chunk),
75
+ data=[GoogleScopeNamesEntry(api_name=e["id"], scopes=e["scopes"]) for e in chunk],
76
+ )
app/api/v1/scopes/set.py ADDED
@@ -0,0 +1,45 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import time
4
+
5
+ from fastapi import APIRouter, HTTPException
6
+
7
+ from app.models.schemas import (
8
+ GoogleApiScopesResponse,
9
+ GoogleSetScopesRequest,
10
+ )
11
+ from app.services.google_scope_service import GoogleScopeError
12
+
13
+ from .deps import GENERATED, REFERENCE, SOURCE, get_scope_service
14
+
15
+ router = APIRouter()
16
+
17
+
18
+ @router.put("", response_model=GoogleApiScopesResponse)
19
+ async def set_google_scopes(body: GoogleSetScopesRequest) -> GoogleApiScopesResponse:
20
+ """Set the current OAuth scopes for one or more Google integrations.
21
+
22
+ The selection is validated against the catalog and stored in memory as a
23
+ Python object structure (resets on restart). Pass ``reset: true`` to restore
24
+ recommended defaults.
25
+ """
26
+ started = time.perf_counter()
27
+ service = get_scope_service()
28
+ try:
29
+ state = await service.set_current_scopes(
30
+ updates=body.apis or {}, reset=body.reset
31
+ )
32
+ plans = await service.load_all_plans()
33
+ data = service.build_data(plans, state)
34
+ return GoogleApiScopesResponse(
35
+ success=True,
36
+ time_ms=round((time.perf_counter() - started) * 1000, 3),
37
+ count=len(data),
38
+ total=len(data),
39
+ generated=GENERATED,
40
+ reference=REFERENCE,
41
+ source=SOURCE,
42
+ data=data,
43
+ )
44
+ except GoogleScopeError as exc:
45
+ raise HTTPException(status_code=exc.status_code, detail=exc.message) from exc
app/config.py CHANGED
@@ -124,6 +124,20 @@ class Settings(BaseSettings):
124
  google_oauth_state_ttl_minutes: int = 10
125
  google_oauth_jwks_ttl_seconds: int = 3600
126
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
127
  # Media-to-Media conversion settings
128
  media_output_dir: str = "./data/media-convert"
129
  media_max_workers: int = 4
 
124
  google_oauth_state_ttl_minutes: int = 10
125
  google_oauth_jwks_ttl_seconds: int = 3600
126
 
127
+ # Gmail API settings
128
+ gmail_api_base_url: str = "https://gmail.googleapis.com/gmail/v1"
129
+ gmail_batch_url: str = "https://gmail.googleapis.com/batch/gmail/v1"
130
+ gmail_timeout: float = 30.0
131
+ gmail_max_retries: int = 3
132
+ gmail_base_backoff_seconds: float = 1.0
133
+ gmail_max_backoff_seconds: float = 32.0
134
+ gmail_retryable_statuses: str = "429,500,502,503,504"
135
+ gmail_max_batch_size: int = 50
136
+ gmail_max_attachment_bytes: int = 25 * 1024 * 1024
137
+ gmail_max_payload_bytes: int = 35 * 1024 * 1024
138
+ gmail_default_scope: str = "https://www.googleapis.com/auth/gmail.modify"
139
+ gmail_refresh_buffer_seconds: int = 60
140
+
141
  # Media-to-Media conversion settings
142
  media_output_dir: str = "./data/media-convert"
143
  media_max_workers: int = 4
app/models/schemas.py CHANGED
@@ -959,6 +959,100 @@ class GoogleOAuthVerifyResponse(BaseModel):
959
  error: Optional[str] = None
960
 
961
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
962
  # ---------------------------------------------------------------------------
963
  # Google Maps Static API
964
  # ---------------------------------------------------------------------------
@@ -1639,3 +1733,226 @@ class MediaImageUrlRequest(BaseModel):
1639
  if not v.startswith(("http://", "https://")):
1640
  raise ValueError("Only http/https URLs are supported.")
1641
  return v
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
959
  error: Optional[str] = None
960
 
961
 
962
+ # ---------------------------------------------------------------------------
963
+ # Google per-API scope catalog (single endpoint to view/set current scopes)
964
+ # ---------------------------------------------------------------------------
965
+
966
+ class GoogleScopeDetail(BaseModel):
967
+ """Metadata for a single OAuth scope (from the in-memory scope catalog)."""
968
+
969
+ name: Optional[str] = Field(None, description="Short scope name")
970
+ uri: Optional[str] = Field(None, description="Full OAuth scope URI")
971
+ description: Optional[str] = Field(None, description="What the scope allows")
972
+ permission_level: Optional[str] = Field(
973
+ None, description="non-sensitive | sensitive | restricted"
974
+ )
975
+ required: Optional[bool] = Field(None, description="Whether the scope is required")
976
+ recommended: Optional[bool] = Field(None, description="Whether the scope is the recommended default")
977
+ reason: Optional[str] = Field(None, description="Why the scope may be needed")
978
+ least_privilege_recommendation: Optional[str] = Field(
979
+ None, description="Least-privilege guidance for this scope"
980
+ )
981
+
982
+
983
+ class GoogleApiScopeEntry(BaseModel):
984
+ """Scope catalog entry for a single Google integration."""
985
+
986
+ id: Optional[str] = Field(None, description="Stable API identifier (file name)")
987
+ api: Optional[str] = Field(None, description="Display name of the Google API")
988
+ version: Optional[str] = Field(None, description="API version (e.g. v3)")
989
+ base_url: Optional[str] = Field(None, description="Google API base URL")
990
+ batch_endpoint: Optional[str] = Field(None, description="Batch endpoint, if any")
991
+ auth_model: Optional[str] = Field(None, description="Authentication model (OAuth / API key / service account)")
992
+ scope_required: Optional[bool] = Field(None, description="Whether the API needs OAuth scopes")
993
+ docs_url: Optional[str] = Field(None, description="Official Google docs reference")
994
+ info: Optional[str] = Field(None, description="Extra note for APIs without scopes")
995
+ current_scopes: List[str] = Field(default_factory=list, description="Currently selected scopes")
996
+ default_scopes: List[str] = Field(default_factory=list, description="Recommended default scopes")
997
+ scopes: List[GoogleScopeDetail] = Field(default_factory=list, description="All documented scopes")
998
+ recommended_combinations: Dict[str, List[str]] = Field(
999
+ default_factory=dict, description="Commonly used scope combinations"
1000
+ )
1001
+
1002
+
1003
+ class GoogleApiScopesResponse(BaseModel):
1004
+ success: bool
1005
+ time_ms: float
1006
+ count: int = 0
1007
+ total: int = 0
1008
+ page: int = 1
1009
+ page_size: int = 50
1010
+ total_pages: int = 1
1011
+ generated: Optional[str] = None
1012
+ reference: Optional[str] = None
1013
+ source: Optional[str] = None
1014
+ filters: Optional[Dict[str, Any]] = None
1015
+ data: List[GoogleApiScopeEntry] = Field(default_factory=list)
1016
+ error: Optional[str] = None
1017
+
1018
+
1019
+ class GoogleScopeNamesResponse(BaseModel):
1020
+ """Compact response for ``scope_name_only=true`` with a single API."""
1021
+
1022
+ api_name: str
1023
+ scopes: List[str] = Field(default_factory=list)
1024
+
1025
+
1026
+ class GoogleScopeNamesEntry(BaseModel):
1027
+ api_name: str
1028
+ scopes: List[str] = Field(default_factory=list)
1029
+
1030
+
1031
+ class GoogleScopeNamesListResponse(BaseModel):
1032
+ success: bool
1033
+ time_ms: float
1034
+ count: int = 0
1035
+ data: List[GoogleScopeNamesEntry] = Field(default_factory=list)
1036
+ error: Optional[str] = None
1037
+
1038
+
1039
+ class GoogleApiIdsResponse(BaseModel):
1040
+ success: bool
1041
+ time_ms: float
1042
+ count: int = 0
1043
+ ids: List[str] = Field(default_factory=list)
1044
+ error: Optional[str] = None
1045
+
1046
+
1047
+ class GoogleSetScopesRequest(BaseModel):
1048
+ """Set the current scopes for one or more Google integrations."""
1049
+
1050
+ apis: Optional[Dict[str, List[str]]] = Field(
1051
+ None, description="Map of API id to the list of scopes to select"
1052
+ )
1053
+ reset: bool = Field(False, description="Restore all APIs to their recommended defaults")
1054
+
1055
+
1056
  # ---------------------------------------------------------------------------
1057
  # Google Maps Static API
1058
  # ---------------------------------------------------------------------------
 
1733
  if not v.startswith(("http://", "https://")):
1734
  raise ValueError("Only http/https URLs are supported.")
1735
  return v
1736
+
1737
+
1738
+ # ---------------------------------------------------------------------------
1739
+ # Gmail API (stateless, client-supplied OAuth credentials)
1740
+ # ---------------------------------------------------------------------------
1741
+
1742
+ class GmailScope(Enum):
1743
+ """All documented Gmail OAuth scopes (see developers.google.com/gmail/api/auth/scopes)."""
1744
+
1745
+ READONLY = "https://www.googleapis.com/auth/gmail.readonly"
1746
+ MODIFY = "https://www.googleapis.com/auth/gmail.modify"
1747
+ SEND = "https://www.googleapis.com/auth/gmail.send"
1748
+ COMPOSE = "https://www.googleapis.com/auth/gmail.compose"
1749
+ METADATA = "https://www.googleapis.com/auth/gmail.metadata"
1750
+ INSERT = "https://www.googleapis.com/auth/gmail.insert"
1751
+ LABELS = "https://www.googleapis.com/auth/gmail.labels"
1752
+ SETTINGS_BASIC = "https://www.googleapis.com/auth/gmail.settings.basic"
1753
+ SETTINGS_SHARING = "https://www.googleapis.com/auth/gmail.settings.sharing"
1754
+ MAIL_GOOGLE = "https://mail.google.com/"
1755
+ ADDONS_CURRENT_ACTION = "https://www.googleapis.com/auth/gmail.addons.current.action.compose"
1756
+ ADDONS_CURRENT_MESSAGE_METADATA = "https://www.googleapis.com/auth/gmail.addons.current.message.metadata"
1757
+ ADDONS_CURRENT_MESSAGE_READONLY = "https://www.googleapis.com/auth/gmail.addons.current.message.readonly"
1758
+ ADDONS_CURRENT_SAFE_DOWNLOAD = "https://www.googleapis.com/auth/gmail.addons.current.message.safe_download"
1759
+
1760
+ @property
1761
+ def permission_level(self) -> str:
1762
+ if self in (GmailScope.MAIL_GOOGLE, GmailScope.SETTINGS_SHARING):
1763
+ return "restricted"
1764
+ if self in (
1765
+ GmailScope.COMPOSE,
1766
+ GmailScope.METADATA,
1767
+ GmailScope.READONLY,
1768
+ GmailScope.MODIFY,
1769
+ GmailScope.INSERT,
1770
+ GmailScope.SETTINGS_BASIC,
1771
+ ):
1772
+ return "sensitive"
1773
+ return "non-sensitive"
1774
+
1775
+
1776
+ class GmailComposeAttachment(BaseModel):
1777
+ """An email attachment encoded in base64 (max ~25 MB per file)."""
1778
+
1779
+ filename: str = Field(..., min_length=1, max_length=255, description="Attachment file name")
1780
+ content_base64: str = Field(..., min_length=1, description="Base64 (standard) encoded file content")
1781
+ mime_type: Optional[str] = Field(None, description="Explicit MIME type; auto-detected from extension if omitted")
1782
+
1783
+
1784
+ class GmailComposeMessageRequest(BaseModel):
1785
+ """Structured email used to build an RFC 822 MIME message before sending."""
1786
+
1787
+ subject: str = Field(..., min_length=1, max_length=998, description="Email subject")
1788
+ body: str = Field("", description="Plain-text body")
1789
+ to: List[str] = Field(..., min_length=1, description="Primary recipients")
1790
+ cc: Optional[List[str]] = Field(None, description="Carbon-copy recipients")
1791
+ bcc: Optional[List[str]] = Field(None, description="Blind carbon-copy recipients")
1792
+ reply_to: Optional[List[str]] = Field(None, description="Reply-To addresses")
1793
+ from_name: Optional[str] = Field(None, max_length=255, description="Display name for the mailbox sender")
1794
+ html_body: Optional[str] = Field(None, description="HTML body; used when provided, otherwise a plain body is sent")
1795
+ attachments: Optional[List[GmailComposeAttachment]] = Field(None, max_length=20, description="Attachments (max 20)")
1796
+ thread_id: Optional[str] = Field(None, description="Gmail thread ID to continue")
1797
+ labels: Optional[List[str]] = Field(None, description="Label IDs to apply after sending")
1798
+ headers: Optional[Dict[str, str]] = Field(None, description="Custom MIME headers")
1799
+ in_reply_to: Optional[str] = Field(None, description="Message-ID being replied to")
1800
+ references: Optional[List[str]] = Field(None, description="References header values for threading")
1801
+ delegate: str = Field("me", description="Mailbox to act on (default 'me' = authenticated user)")
1802
+
1803
+ @field_validator("attachments")
1804
+ @classmethod
1805
+ def _limit_attachments(cls, v: Optional[List[GmailComposeAttachment]]) -> Optional[List[GmailComposeAttachment]]:
1806
+ if v is not None and len(v) > 20:
1807
+ raise ValueError("Maximum 20 attachments per email.")
1808
+ return v
1809
+
1810
+
1811
+ class GmailRawMessageRequest(BaseModel):
1812
+ """Send an already-encoded RFC 2822 message."""
1813
+
1814
+ raw: str = Field(..., min_length=1, description="Base64url-encoded RFC 2822 message")
1815
+ thread_id: Optional[str] = Field(None, description="Thread ID to associate the message with")
1816
+ labels: Optional[List[str]] = Field(None, description="Label IDs to apply after sending")
1817
+ delegate: str = Field("me", description="Mailbox to act on (default 'me')")
1818
+
1819
+
1820
+ class GmailMessageModifyRequest(BaseModel):
1821
+ add_label_ids: Optional[List[str]] = Field(None, description="Label IDs to add")
1822
+ remove_label_ids: Optional[List[str]] = Field(None, description="Label IDs to remove")
1823
+
1824
+
1825
+ class GmailBatchModifyRequest(BaseModel):
1826
+ ids: List[str] = Field(..., min_length=1, max_length=1000, description="Message IDs to modify")
1827
+ add_label_ids: Optional[List[str]] = Field(None, description="Label IDs to add to each message")
1828
+ remove_label_ids: Optional[List[str]] = Field(None, description="Label IDs to remove from each message")
1829
+
1830
+
1831
+ class GmailBatchDeleteRequest(BaseModel):
1832
+ ids: List[str] = Field(..., min_length=1, max_length=1000, description="Message IDs to permanently delete")
1833
+
1834
+
1835
+ class GmailInsertMessageRequest(BaseModel):
1836
+ raw: str = Field(..., min_length=1, description="Base64url-encoded RFC 2822 message")
1837
+ label_ids: Optional[List[str]] = Field(None, description="Label IDs to apply on insert")
1838
+ internal_date_source: Optional[str] = Field(None, pattern="^(dateHeader|receivedTime)$", description="Source for internal date")
1839
+ delegate: str = Field("me", description="Mailbox to act on (default 'me')")
1840
+
1841
+
1842
+ class GmailDraftCreateRequest(BaseModel):
1843
+ raw: str = Field(..., min_length=1, description="Base64url-encoded RFC 2822 message")
1844
+ thread_id: Optional[str] = Field(None, description="Thread ID to associate the draft with")
1845
+ delegate: str = Field("me", description="Mailbox to act on (default 'me')")
1846
+
1847
+
1848
+ class GmailDraftUpdateRequest(BaseModel):
1849
+ raw: str = Field(..., min_length=1, description="Base64url-encoded RFC 2822 message")
1850
+ thread_id: Optional[str] = Field(None, description="Thread ID to associate the draft with")
1851
+ delegate: str = Field("me", description="Mailbox to act on (default 'me')")
1852
+
1853
+
1854
+ class GmailDraftSendRequest(BaseModel):
1855
+ draft_id: str = Field(..., min_length=1, description="ID of the draft to send")
1856
+ delegate: str = Field("me", description="Mailbox to act on (default 'me')")
1857
+
1858
+
1859
+ class GmailLabelRequest(BaseModel):
1860
+ name: str = Field(..., min_length=1, max_length=255, description="Label name")
1861
+ label_list_visibility: Optional[str] = Field(None, pattern="^(labelShow|labelHide|labelShowIfUnread)$", description="Label list visibility")
1862
+ message_list_visibility: Optional[str] = Field(None, pattern="^(show|hide)$", description="Message list visibility")
1863
+ color: Optional[Dict[str, str]] = Field(None, description="Label color (background/text color hex values)")
1864
+
1865
+
1866
+ class GmailMessageAttachmentMeta(BaseModel):
1867
+ attachment_id: str = Field(..., description="Gmail attachment ID")
1868
+ filename: str = Field("", description="Attachment file name")
1869
+ mime_type: str = Field("", description="Attachment MIME type")
1870
+ size_bytes: int = Field(0, description="Decoded size in bytes")
1871
+
1872
+
1873
+ class GmailParsedMessage(BaseModel):
1874
+ """Convenience view of a message with headers, bodies and attachment metadata."""
1875
+
1876
+ id: str = Field("", description="Message ID")
1877
+ thread_id: str = Field("", description="Thread ID")
1878
+ label_ids: List[str] = Field(default_factory=list, description="Applied label IDs")
1879
+ snippet: str = Field("", description="Snippet of the message body")
1880
+ history_id: Optional[str] = Field(None, description="History ID for sync")
1881
+ headers: Dict[str, str] = Field(default_factory=dict, description="All message headers")
1882
+ sender: Optional[str] = Field(None, description="From header value")
1883
+ to: str = Field("", description="To header value")
1884
+ cc: str = Field("", description="Cc header value")
1885
+ bcc: str = Field("", description="Bcc header value")
1886
+ subject: str = Field("", description="Subject header value")
1887
+ date: Optional[str] = Field(None, description="Date header value")
1888
+ plain_body: Optional[str] = Field(None, description="Decoded plain-text body")
1889
+ html_body: Optional[str] = Field(None, description="Decoded HTML body")
1890
+ attachments: List[GmailMessageAttachmentMeta] = Field(default_factory=list, description="Attachment metadata")
1891
+
1892
+
1893
+ class GmailAttachmentContent(BaseModel):
1894
+ message_id: str = Field(..., description="Parent message ID")
1895
+ attachment_id: str = Field(..., description="Attachment ID")
1896
+ filename: str = Field("", description="Attachment file name")
1897
+ mime_type: str = Field("", description="Attachment MIME type")
1898
+ data_base64: str = Field("", description="Base64url-encoded attachment bytes")
1899
+ size_bytes: int = Field(0, description="Decoded size in bytes")
1900
+
1901
+
1902
+ class GmailSettingsUpdateRequest(BaseModel):
1903
+ """Generic passthrough for heterogeneous settings endpoints."""
1904
+
1905
+ payload: Dict[str, Any] = Field(..., description="Full settings resource body (see Gmail API reference)")
1906
+
1907
+ @field_validator("payload")
1908
+ @classmethod
1909
+ def _non_empty(cls, v: Dict[str, Any]) -> Dict[str, Any]:
1910
+ if not v:
1911
+ raise ValueError("payload must not be empty.")
1912
+ return v
1913
+
1914
+
1915
+ class GmailFilterCreateRequest(BaseModel):
1916
+ criteria: Dict[str, Any] = Field(default_factory=dict, description="Filter criteria (from, to, subject, query, etc.)")
1917
+ action: Dict[str, Any] = Field(default_factory=dict, description="Filter actions (addLabelIds, forward, etc.)")
1918
+
1919
+
1920
+ class GmailSendAsRequest(BaseModel):
1921
+ display_name: Optional[str] = Field(None, description="Display name shown to recipients")
1922
+ is_default: Optional[bool] = Field(None, description="Whether this is the default send-as alias")
1923
+ reply_to_address: Optional[str] = Field(None, description="Reply-To address")
1924
+ signature: Optional[str] = Field(None, description="Email signature (HTML)")
1925
+
1926
+
1927
+ class GmailGenericResponse(BaseModel):
1928
+ """Standard Gmail operation envelope."""
1929
+
1930
+ success: bool
1931
+ time_ms: float = 0.0
1932
+ data: Optional[Any] = Field(None, description="Raw Gmail API payload")
1933
+ refreshed_access_token: Optional[str] = Field(None, description="New access token when an automatic refresh occurred; persist it client-side")
1934
+ error: Optional[str] = Field(None, description="Human-readable error when success is false")
1935
+
1936
+
1937
+ class GmailSendResponse(BaseModel):
1938
+ """Result of sending/inserting a message."""
1939
+
1940
+ success: bool
1941
+ time_ms: float = 0.0
1942
+ message_id: Optional[str] = Field(None, description="Created message ID")
1943
+ thread_id: Optional[str] = Field(None, description="Created thread ID")
1944
+ label_ids: Optional[List[str]] = Field(None, description="Labels applied after send")
1945
+ refreshed_access_token: Optional[str] = Field(None, description="New access token when an automatic refresh occurred")
1946
+ data: Optional[Any] = Field(None, description="Raw Gmail API payload")
1947
+ error: Optional[str] = Field(None, description="Human-readable error when success is false")
1948
+
1949
+
1950
+ class GmailRefreshResponse(BaseModel):
1951
+ """Stateless token refresh result returned to the client for its own storage."""
1952
+
1953
+ success: bool
1954
+ access_token: Optional[str] = None
1955
+ expires_in: Optional[int] = None
1956
+ token_type: Optional[str] = None
1957
+ scope: Optional[str] = None
1958
+ error: Optional[str] = None
app/services/gmail_service.py ADDED
@@ -0,0 +1,905 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ import random
5
+ import time
6
+ from dataclasses import dataclass, field
7
+ from typing import Any, Dict, List, Optional
8
+
9
+ import httpx
10
+
11
+ from app.config import get_settings
12
+ from app.core.logger import get_logger
13
+ from app.models.schemas import (
14
+ GmailComposeMessageRequest,
15
+ GmailFilterCreateRequest,
16
+ GmailLabelRequest,
17
+ GmailParsedMessage,
18
+ )
19
+ from app.utils.gmail_utils import (
20
+ GmailMessageValidationError,
21
+ build_mime_message,
22
+ parse_message,
23
+ )
24
+ from app.utils.http_utils import SharedAsyncClient
25
+
26
+ _logger = get_logger(__name__)
27
+ _settings = get_settings()
28
+
29
+ _RETRYABLE_STATUS = frozenset({
30
+ int(code)
31
+ for code in _settings.gmail_retryable_statuses.split(",")
32
+ if code.strip().isdigit()
33
+ })
34
+
35
+
36
+ class GmailAPIError(Exception):
37
+ """Raised for upstream Gmail API failures that map to a client-facing error."""
38
+
39
+ def __init__(self, message: str, status_code: int = 400, *, reason: str = "") -> None:
40
+ super().__init__(message)
41
+ self.message = message
42
+ self.status_code = status_code
43
+ self.reason = reason
44
+
45
+
46
+ @dataclass
47
+ class GmailCredentials:
48
+ """Client-supplied OAuth credentials (never persisted by this backend).
49
+
50
+ ``refreshed_access_token`` is populated whenever the service transparently
51
+ refreshes the access token, so the stateless client can persist it itself.
52
+ """
53
+
54
+ access_token: str
55
+ refresh_token: Optional[str] = None
56
+ client_id: Optional[str] = None
57
+ client_secret: Optional[str] = None
58
+ expires_at: Optional[float] = None
59
+ refreshed_access_token: Optional[str] = field(default=None, repr=False)
60
+
61
+ @property
62
+ def can_refresh(self) -> bool:
63
+ return bool(self.refresh_token and self.client_id and self.client_secret)
64
+
65
+ @property
66
+ def needs_proactive_refresh(self) -> bool:
67
+ if self.expires_at is None:
68
+ return False
69
+ return time.time() >= self.expires_at - _settings.gmail_refresh_buffer_seconds
70
+
71
+
72
+ class GmailService:
73
+ """Stateless, fully-async abstraction over the Gmail REST API.
74
+
75
+ Credentials are supplied by the caller on every invocation. Access tokens
76
+ are transparently refreshed against Google's token endpoint when a
77
+ ``refresh_token`` + ``client_id`` + ``client_secret`` are provided, and the
78
+ resulting token is surfaced via ``GmailCredentials.refreshed_access_token``.
79
+ """
80
+
81
+ def __init__(self) -> None:
82
+ self._http = SharedAsyncClient(
83
+ timeout=httpx.Timeout(_settings.gmail_timeout),
84
+ follow_redirects=False,
85
+ )
86
+
87
+ async def _get_client(self) -> httpx.AsyncClient:
88
+ return await self._http.get()
89
+
90
+ async def close(self) -> None:
91
+ await self._http.close()
92
+
93
+ # ------------------------------------------------------------------
94
+ # Core request pipeline: auth, retry, backoff, error mapping
95
+ # ------------------------------------------------------------------
96
+
97
+ async def _request(
98
+ self,
99
+ creds: GmailCredentials,
100
+ method: str,
101
+ path: str,
102
+ *,
103
+ params: Optional[Dict[str, Any]] = None,
104
+ json_body: Optional[Any] = None,
105
+ ) -> Any:
106
+ """Send a Gmail API request with proactive + on-401 refresh and retries."""
107
+ if creds.needs_proactive_refresh:
108
+ await self._refresh(creds, context=f"{method} {path}")
109
+
110
+ refresh_done = False
111
+ attempt = 0
112
+ while True:
113
+ response = await self._send(
114
+ creds, method, path, params=params, json_body=json_body
115
+ )
116
+ if 200 <= response.status_code < 300:
117
+ return self._decode(response)
118
+
119
+ if response.status_code == 401 and creds.can_refresh and not refresh_done:
120
+ _logger.warning(
121
+ "Gmail 401 for %s %s; refreshing access token once",
122
+ method,
123
+ path,
124
+ )
125
+ await self._refresh(creds, context=f"{method} {path}")
126
+ refresh_done = True
127
+ continue
128
+
129
+ if response.status_code in _RETRYABLE_STATUS and attempt < _settings.gmail_max_retries:
130
+ await self._backoff(response, attempt)
131
+ attempt += 1
132
+ continue
133
+
134
+ raise self._map_error(response, creds)
135
+
136
+ async def _send(
137
+ self,
138
+ creds: GmailCredentials,
139
+ method: str,
140
+ path: str,
141
+ *,
142
+ params: Optional[Dict[str, Any]] = None,
143
+ json_body: Optional[Any] = None,
144
+ ) -> httpx.Response:
145
+ client = await self._get_client()
146
+ url = f"{_settings.gmail_api_base_url}{path}"
147
+ headers = {
148
+ "Authorization": f"Bearer {creds.access_token}",
149
+ "Accept": "application/json",
150
+ }
151
+ try:
152
+ return await client.request(
153
+ method,
154
+ url,
155
+ params=params,
156
+ json=json_body,
157
+ headers=headers,
158
+ )
159
+ except httpx.TimeoutException as exc:
160
+ raise GmailAPIError(
161
+ f"Gmail API request timed out for {method} {path}.", status_code=504
162
+ ) from exc
163
+ except httpx.RequestError as exc:
164
+ raise GmailAPIError(
165
+ f"Failed to reach Gmail API: {exc}", status_code=502
166
+ ) from exc
167
+
168
+ @staticmethod
169
+ def _decode(response: httpx.Response) -> Any:
170
+ if not response.content:
171
+ return None
172
+ try:
173
+ return response.json()
174
+ except ValueError:
175
+ return response.text
176
+
177
+ async def _backoff(self, response: httpx.Response, attempt: int) -> None:
178
+ retry_after = response.headers.get("Retry-After")
179
+ delay: Optional[float] = None
180
+ if retry_after:
181
+ try:
182
+ delay = float(retry_after)
183
+ except (TypeError, ValueError):
184
+ delay = None
185
+ if delay is None:
186
+ base = _settings.gmail_base_backoff_seconds * (2 ** attempt)
187
+ delay = min(base, _settings.gmail_max_backoff_seconds) * (0.5 + random.random())
188
+ delay = min(delay, _settings.gmail_max_backoff_seconds + 5.0)
189
+ _logger.warning(
190
+ "Gmail transient HTTP %s; retrying in %.2fs (attempt %d/%d)",
191
+ response.status_code,
192
+ delay,
193
+ attempt + 1,
194
+ _settings.gmail_max_retries,
195
+ )
196
+ await asyncio.sleep(delay)
197
+
198
+ def _map_error(self, response: httpx.Response, creds: GmailCredentials) -> GmailAPIError:
199
+ status = response.status_code
200
+ reason = ""
201
+ detail = ""
202
+ try:
203
+ body = response.json()
204
+ error = body.get("error") or {}
205
+ if isinstance(error, dict):
206
+ detail = error.get("message", "")
207
+ errors = error.get("errors") or []
208
+ if errors and isinstance(errors[0], dict):
209
+ reason = errors[0].get("reason", "")
210
+ except Exception:
211
+ body = {}
212
+ detail = response.text[:500]
213
+
214
+ _logger.error(
215
+ "Gmail API error: HTTP %s reason=%s detail=%s", status, reason, detail
216
+ )
217
+
218
+ if status == 401:
219
+ if creds.can_refresh:
220
+ return GmailAPIError(
221
+ "Access token is invalid or expired and could not be refreshed. "
222
+ "Re-authorize via POST /api/v1/google/oauth/auth-url.",
223
+ status_code=401,
224
+ reason=reason,
225
+ )
226
+ return GmailAPIError(
227
+ "Access token is invalid or expired. Refresh it via "
228
+ "POST /api/v1/google/oauth/refresh (or /api/v1/gmail/token/refresh) "
229
+ "and retry, or supply a fresh access token.",
230
+ status_code=401,
231
+ reason=reason,
232
+ )
233
+ if status == 403 and reason == "PERMISSION_DENIED":
234
+ return GmailAPIError(
235
+ "Permission denied. The access token may be missing the required "
236
+ "Gmail scope (see /api/v1/google/gmail/scopes).",
237
+ status_code=403,
238
+ reason=reason,
239
+ )
240
+ if status == 429:
241
+ return GmailAPIError(
242
+ "Gmail API rate limit exceeded. Please retry after a short delay.",
243
+ status_code=429,
244
+ reason=reason,
245
+ )
246
+ if status in _RETRYABLE_STATUS:
247
+ return GmailAPIError(
248
+ detail or f"Gmail API service error (HTTP {status}).",
249
+ status_code=status,
250
+ reason=reason,
251
+ )
252
+ return GmailAPIError(
253
+ detail or f"Gmail API error (HTTP {status}).",
254
+ status_code=status,
255
+ reason=reason,
256
+ )
257
+
258
+ # ------------------------------------------------------------------
259
+ # Token lifecycle
260
+ # ------------------------------------------------------------------
261
+
262
+ async def refresh_access_token(
263
+ self,
264
+ *,
265
+ client_id: str,
266
+ client_secret: str,
267
+ refresh_token: str,
268
+ ) -> Dict[str, Any]:
269
+ """Refresh an access token without touching the mailbox (standalone)."""
270
+ tokens = await self._refresh(
271
+ creds=None,
272
+ client_id=client_id,
273
+ client_secret=client_secret,
274
+ refresh_token=refresh_token,
275
+ context="standalone refresh",
276
+ )
277
+ return tokens
278
+
279
+ async def _refresh(
280
+ self,
281
+ creds: Optional[GmailCredentials],
282
+ *,
283
+ context: str,
284
+ client_id: Optional[str] = None,
285
+ client_secret: Optional[str] = None,
286
+ refresh_token: Optional[str] = None,
287
+ ) -> Dict[str, Any]:
288
+ if creds is not None:
289
+ client_id = creds.client_id
290
+ client_secret = creds.client_secret
291
+ refresh_token = creds.refresh_token
292
+
293
+ if not (client_id and client_secret and refresh_token):
294
+ raise GmailAPIError(
295
+ "Token refresh requires refresh_token, client_id and client_secret.",
296
+ status_code=400,
297
+ )
298
+
299
+ client = await self._get_client()
300
+ data = {
301
+ "client_id": client_id,
302
+ "client_secret": client_secret,
303
+ "refresh_token": refresh_token,
304
+ "grant_type": "refresh_token",
305
+ }
306
+ try:
307
+ response = await client.post(_settings.google_oauth_token_url, data=data)
308
+ except httpx.TimeoutException as exc:
309
+ raise GmailAPIError(
310
+ "Google token refresh timed out.", status_code=504
311
+ ) from exc
312
+ except httpx.RequestError as exc:
313
+ raise GmailAPIError(
314
+ f"Failed to reach Google token endpoint: {exc}", status_code=502
315
+ ) from exc
316
+
317
+ if response.status_code == 200:
318
+ tokens = response.json()
319
+ new_token = tokens.get("access_token")
320
+ if not new_token:
321
+ raise GmailAPIError(
322
+ "Google did not return an access token.", status_code=502
323
+ )
324
+ expires_in = int(tokens.get("expires_in", 3600))
325
+ if creds is not None:
326
+ creds.access_token = new_token
327
+ creds.expires_at = time.time() + expires_in
328
+ creds.refreshed_access_token = new_token
329
+ _logger.info("Gmail access token refreshed (%s)", context)
330
+ return tokens
331
+
332
+ try:
333
+ body = response.json()
334
+ error = body.get("error", "")
335
+ description = body.get("error_description", "")
336
+ except Exception:
337
+ error = ""
338
+ description = ""
339
+ body = {}
340
+
341
+ if error == "invalid_grant":
342
+ raise GmailAPIError(
343
+ description or "Refresh token is invalid, expired, or revoked. "
344
+ "Re-authorize via POST /api/v1/google/oauth/auth-url.",
345
+ status_code=401,
346
+ reason=error,
347
+ )
348
+ if error == "invalid_client":
349
+ raise GmailAPIError(
350
+ description or "Invalid client_id or client_secret.",
351
+ status_code=401,
352
+ reason=error,
353
+ )
354
+ raise GmailAPIError(
355
+ description or f"Google token refresh failed (HTTP {response.status_code}).",
356
+ status_code=response.status_code if response.status_code >= 400 else 502,
357
+ reason=error,
358
+ )
359
+
360
+ # ------------------------------------------------------------------
361
+ # Users profile & scopes
362
+ # ------------------------------------------------------------------
363
+
364
+ async def get_profile(self, creds: GmailCredentials, user_id: str = "me") -> Dict[str, Any]:
365
+ return await self._request(creds, "GET", f"/users/{user_id}/profile")
366
+
367
+ async def list_available_scopes(self) -> List[Dict[str, Any]]:
368
+ from app.models.schemas import GmailScope
369
+
370
+ return [
371
+ {
372
+ "name": scope.name,
373
+ "uri": scope.value,
374
+ "permission_level": scope.permission_level,
375
+ }
376
+ for scope in GmailScope
377
+ ]
378
+
379
+ # ------------------------------------------------------------------
380
+ # Messages
381
+ # ------------------------------------------------------------------
382
+
383
+ async def list_messages(
384
+ self,
385
+ creds: GmailCredentials,
386
+ user_id: str = "me",
387
+ *,
388
+ q: Optional[str] = None,
389
+ label_ids: Optional[List[str]] = None,
390
+ max_results: Optional[int] = None,
391
+ page_token: Optional[str] = None,
392
+ include_spam_trash: bool = False,
393
+ batch_size: Optional[int] = None,
394
+ ) -> Dict[str, Any]:
395
+ params: Dict[str, Any] = {}
396
+ if q is not None:
397
+ params["q"] = q
398
+ if label_ids:
399
+ params["labelIds"] = label_ids
400
+ if max_results is not None:
401
+ params["maxResults"] = max_results
402
+ if page_token:
403
+ params["pageToken"] = page_token
404
+ if include_spam_trash:
405
+ params["includeSpamTrash"] = "true"
406
+ if batch_size is not None:
407
+ params["batchSize"] = batch_size
408
+ return await self._request(
409
+ creds, "GET", f"/users/{user_id}/messages", params=params or None
410
+ )
411
+
412
+ async def get_message(
413
+ self,
414
+ creds: GmailCredentials,
415
+ message_id: str,
416
+ user_id: str = "me",
417
+ *,
418
+ format: str = "full",
419
+ metadata_headers: Optional[List[str]] = None,
420
+ headers: Optional[List[str]] = None,
421
+ ) -> Dict[str, Any]:
422
+ params: Dict[str, Any] = {"format": format}
423
+ if metadata_headers:
424
+ params["metadataHeaders"] = metadata_headers
425
+ if headers:
426
+ params["headers"] = headers
427
+ return await self._request(
428
+ creds, "GET", f"/users/{user_id}/messages/{message_id}", params=params
429
+ )
430
+
431
+ async def get_parsed_message(
432
+ self,
433
+ creds: GmailCredentials,
434
+ message_id: str,
435
+ user_id: str = "me",
436
+ *,
437
+ format: str = "full",
438
+ ) -> GmailParsedMessage:
439
+ data = await self.get_message(creds, message_id, user_id=user_id, format=format)
440
+ return parse_message(data)
441
+
442
+ async def send_raw(
443
+ self,
444
+ creds: GmailCredentials,
445
+ raw: str,
446
+ user_id: str = "me",
447
+ *,
448
+ thread_id: Optional[str] = None,
449
+ labels: Optional[List[str]] = None,
450
+ ) -> Dict[str, Any]:
451
+ body: Dict[str, Any] = {"raw": raw}
452
+ if thread_id:
453
+ body["threadId"] = thread_id
454
+ if labels:
455
+ body["labelIds"] = labels
456
+ return await self._request(
457
+ creds, "POST", f"/users/{user_id}/messages/send", json_body=body
458
+ )
459
+
460
+ async def send_composed(
461
+ self,
462
+ creds: GmailCredentials,
463
+ request: GmailComposeMessageRequest,
464
+ user_id: str = "me",
465
+ ) -> Dict[str, Any]:
466
+ try:
467
+ raw = build_mime_message(request)
468
+ except GmailMessageValidationError as exc:
469
+ raise GmailAPIError(str(exc), status_code=400) from exc
470
+ return await self.send_raw(
471
+ creds,
472
+ raw,
473
+ user_id=user_id,
474
+ thread_id=request.thread_id,
475
+ labels=request.labels,
476
+ )
477
+
478
+ async def insert_message(
479
+ self,
480
+ creds: GmailCredentials,
481
+ raw: str,
482
+ user_id: str = "me",
483
+ *,
484
+ label_ids: Optional[List[str]] = None,
485
+ internal_date_source: Optional[str] = None,
486
+ ) -> Dict[str, Any]:
487
+ body: Dict[str, Any] = {"raw": raw}
488
+ if label_ids:
489
+ body["labelIds"] = label_ids
490
+ if internal_date_source:
491
+ body["internalDateSource"] = internal_date_source
492
+ return await self._request(
493
+ creds, "POST", f"/users/{user_id}/messages", json_body=body
494
+ )
495
+
496
+ async def modify_message(
497
+ self,
498
+ creds: GmailCredentials,
499
+ message_id: str,
500
+ user_id: str = "me",
501
+ *,
502
+ add_label_ids: Optional[List[str]] = None,
503
+ remove_label_ids: Optional[List[str]] = None,
504
+ ) -> Dict[str, Any]:
505
+ body: Dict[str, Any] = {}
506
+ if add_label_ids:
507
+ body["addLabelIds"] = add_label_ids
508
+ if remove_label_ids:
509
+ body["removeLabelIds"] = remove_label_ids
510
+ return await self._request(
511
+ creds, "POST", f"/users/{user_id}/messages/{message_id}/modify", json_body=body
512
+ )
513
+
514
+ async def batch_modify(
515
+ self,
516
+ creds: GmailCredentials,
517
+ ids: List[str],
518
+ user_id: str = "me",
519
+ *,
520
+ add_label_ids: Optional[List[str]] = None,
521
+ remove_label_ids: Optional[List[str]] = None,
522
+ ) -> None:
523
+ body: Dict[str, Any] = {"ids": ids}
524
+ if add_label_ids:
525
+ body["addLabelIds"] = add_label_ids
526
+ if remove_label_ids:
527
+ body["removeLabelIds"] = remove_label_ids
528
+ await self._request(creds, "POST", f"/users/{user_id}/messages/batchModify", json_body=body)
529
+
530
+ async def batch_delete(
531
+ self, creds: GmailCredentials, ids: List[str], user_id: str = "me"
532
+ ) -> None:
533
+ await self._request(
534
+ creds, "POST", f"/users/{user_id}/messages/batchDelete", json_body={"ids": ids}
535
+ )
536
+
537
+ async def trash_message(
538
+ self, creds: GmailCredentials, message_id: str, user_id: str = "me"
539
+ ) -> Dict[str, Any]:
540
+ return await self._request(
541
+ creds, "POST", f"/users/{user_id}/messages/{message_id}/trash"
542
+ )
543
+
544
+ async def untrash_message(
545
+ self, creds: GmailCredentials, message_id: str, user_id: str = "me"
546
+ ) -> Dict[str, Any]:
547
+ return await self._request(
548
+ creds, "POST", f"/users/{user_id}/messages/{message_id}/untrash"
549
+ )
550
+
551
+ async def delete_message(
552
+ self, creds: GmailCredentials, message_id: str, user_id: str = "me"
553
+ ) -> None:
554
+ await self._request(creds, "DELETE", f"/users/{user_id}/messages/{message_id}")
555
+
556
+ async def get_attachment(
557
+ self,
558
+ creds: GmailCredentials,
559
+ message_id: str,
560
+ attachment_id: str,
561
+ user_id: str = "me",
562
+ ) -> Dict[str, Any]:
563
+ return await self._request(
564
+ creds,
565
+ "GET",
566
+ f"/users/{user_id}/messages/{message_id}/attachments/{attachment_id}",
567
+ )
568
+
569
+ # ------------------------------------------------------------------
570
+ # Drafts
571
+ # ------------------------------------------------------------------
572
+
573
+ async def list_drafts(
574
+ self,
575
+ creds: GmailCredentials,
576
+ user_id: str = "me",
577
+ *,
578
+ max_results: Optional[int] = None,
579
+ page_token: Optional[str] = None,
580
+ q: Optional[str] = None,
581
+ include_spam_trash: bool = False,
582
+ ) -> Dict[str, Any]:
583
+ params: Dict[str, Any] = {}
584
+ if max_results is not None:
585
+ params["maxResults"] = max_results
586
+ if page_token:
587
+ params["pageToken"] = page_token
588
+ if q:
589
+ params["q"] = q
590
+ if include_spam_trash:
591
+ params["includeSpamTrash"] = "true"
592
+ return await self._request(creds, "GET", f"/users/{user_id}/drafts", params=params or None)
593
+
594
+ async def get_draft(
595
+ self,
596
+ creds: GmailCredentials,
597
+ draft_id: str,
598
+ user_id: str = "me",
599
+ *,
600
+ format: str = "full",
601
+ ) -> Dict[str, Any]:
602
+ return await self._request(
603
+ creds, "GET", f"/users/{user_id}/drafts/{draft_id}", params={"format": format}
604
+ )
605
+
606
+ async def create_draft(
607
+ self,
608
+ creds: GmailCredentials,
609
+ raw: str,
610
+ user_id: str = "me",
611
+ *,
612
+ thread_id: Optional[str] = None,
613
+ ) -> Dict[str, Any]:
614
+ body: Dict[str, Any] = {"message": {"raw": raw}}
615
+ if thread_id:
616
+ body["message"]["threadId"] = thread_id
617
+ return await self._request(creds, "POST", f"/users/{user_id}/drafts", json_body=body)
618
+
619
+ async def update_draft(
620
+ self,
621
+ creds: GmailCredentials,
622
+ draft_id: str,
623
+ raw: str,
624
+ user_id: str = "me",
625
+ *,
626
+ thread_id: Optional[str] = None,
627
+ ) -> Dict[str, Any]:
628
+ body: Dict[str, Any] = {"message": {"raw": raw}}
629
+ if thread_id:
630
+ body["message"]["threadId"] = thread_id
631
+ return await self._request(
632
+ creds, "PUT", f"/users/{user_id}/drafts/{draft_id}", json_body=body
633
+ )
634
+
635
+ async def send_draft(
636
+ self, creds: GmailCredentials, draft_id: str, user_id: str = "me"
637
+ ) -> Dict[str, Any]:
638
+ return await self._request(
639
+ creds, "POST", f"/users/{user_id}/drafts/send", json_body={"id": draft_id}
640
+ )
641
+
642
+ async def delete_draft(
643
+ self, creds: GmailCredentials, draft_id: str, user_id: str = "me"
644
+ ) -> None:
645
+ await self._request(creds, "DELETE", f"/users/{user_id}/drafts/{draft_id}")
646
+
647
+ # ------------------------------------------------------------------
648
+ # Threads
649
+ # ------------------------------------------------------------------
650
+
651
+ async def list_threads(
652
+ self,
653
+ creds: GmailCredentials,
654
+ user_id: str = "me",
655
+ *,
656
+ q: Optional[str] = None,
657
+ label_ids: Optional[List[str]] = None,
658
+ max_results: Optional[int] = None,
659
+ page_token: Optional[str] = None,
660
+ include_spam_trash: bool = False,
661
+ ) -> Dict[str, Any]:
662
+ params: Dict[str, Any] = {}
663
+ if q:
664
+ params["q"] = q
665
+ if label_ids:
666
+ params["labelIds"] = label_ids
667
+ if max_results is not None:
668
+ params["maxResults"] = max_results
669
+ if page_token:
670
+ params["pageToken"] = page_token
671
+ if include_spam_trash:
672
+ params["includeSpamTrash"] = "true"
673
+ return await self._request(creds, "GET", f"/users/{user_id}/threads", params=params or None)
674
+
675
+ async def get_thread(
676
+ self,
677
+ creds: GmailCredentials,
678
+ thread_id: str,
679
+ user_id: str = "me",
680
+ *,
681
+ format: str = "full",
682
+ metadata_headers: Optional[List[str]] = None,
683
+ ) -> Dict[str, Any]:
684
+ params: Dict[str, Any] = {"format": format}
685
+ if metadata_headers:
686
+ params["metadataHeaders"] = metadata_headers
687
+ return await self._request(
688
+ creds, "GET", f"/users/{user_id}/threads/{thread_id}", params=params
689
+ )
690
+
691
+ async def modify_thread(
692
+ self,
693
+ creds: GmailCredentials,
694
+ thread_id: str,
695
+ user_id: str = "me",
696
+ *,
697
+ add_label_ids: Optional[List[str]] = None,
698
+ remove_label_ids: Optional[List[str]] = None,
699
+ ) -> Dict[str, Any]:
700
+ body: Dict[str, Any] = {}
701
+ if add_label_ids:
702
+ body["addLabelIds"] = add_label_ids
703
+ if remove_label_ids:
704
+ body["removeLabelIds"] = remove_label_ids
705
+ return await self._request(
706
+ creds, "POST", f"/users/{user_id}/threads/{thread_id}/modify", json_body=body
707
+ )
708
+
709
+ async def trash_thread(
710
+ self, creds: GmailCredentials, thread_id: str, user_id: str = "me"
711
+ ) -> Dict[str, Any]:
712
+ return await self._request(
713
+ creds, "POST", f"/users/{user_id}/threads/{thread_id}/trash"
714
+ )
715
+
716
+ async def untrash_thread(
717
+ self, creds: GmailCredentials, thread_id: str, user_id: str = "me"
718
+ ) -> Dict[str, Any]:
719
+ return await self._request(
720
+ creds, "POST", f"/users/{user_id}/threads/{thread_id}/untrash"
721
+ )
722
+
723
+ async def delete_thread(
724
+ self, creds: GmailCredentials, thread_id: str, user_id: str = "me"
725
+ ) -> None:
726
+ await self._request(creds, "DELETE", f"/users/{user_id}/threads/{thread_id}")
727
+
728
+ # ------------------------------------------------------------------
729
+ # Labels
730
+ # ------------------------------------------------------------------
731
+
732
+ async def list_labels(self, creds: GmailCredentials, user_id: str = "me") -> Dict[str, Any]:
733
+ return await self._request(creds, "GET", f"/users/{user_id}/labels")
734
+
735
+ async def get_label(
736
+ self, creds: GmailCredentials, label_id: str, user_id: str = "me"
737
+ ) -> Dict[str, Any]:
738
+ return await self._request(creds, "GET", f"/users/{user_id}/labels/{label_id}")
739
+
740
+ async def create_label(
741
+ self, creds: GmailCredentials, label: GmailLabelRequest, user_id: str = "me"
742
+ ) -> Dict[str, Any]:
743
+ body: Dict[str, Any] = {"name": label.name}
744
+ if label.label_list_visibility:
745
+ body["labelListVisibility"] = label.label_list_visibility
746
+ if label.message_list_visibility:
747
+ body["messageListVisibility"] = label.message_list_visibility
748
+ if label.color:
749
+ body["color"] = label.color
750
+ return await self._request(creds, "POST", f"/users/{user_id}/labels", json_body=body)
751
+
752
+ async def update_label(
753
+ self,
754
+ creds: GmailCredentials,
755
+ label_id: str,
756
+ label: GmailLabelRequest,
757
+ user_id: str = "me",
758
+ ) -> Dict[str, Any]:
759
+ body: Dict[str, Any] = {"name": label.name}
760
+ if label.label_list_visibility:
761
+ body["labelListVisibility"] = label.label_list_visibility
762
+ if label.message_list_visibility:
763
+ body["messageListVisibility"] = label.message_list_visibility
764
+ if label.color:
765
+ body["color"] = label.color
766
+ return await self._request(
767
+ creds, "PUT", f"/users/{user_id}/labels/{label_id}", json_body=body
768
+ )
769
+
770
+ async def delete_label(
771
+ self, creds: GmailCredentials, label_id: str, user_id: str = "me"
772
+ ) -> None:
773
+ await self._request(creds, "DELETE", f"/users/{user_id}/labels/{label_id}")
774
+
775
+ # ------------------------------------------------------------------
776
+ # Settings: auto-forwarding / vacation / filters / forwarding / send-as / delegates
777
+ # ------------------------------------------------------------------
778
+
779
+ async def get_auto_forwarding(self, creds: GmailCredentials, user_id: str = "me") -> Dict[str, Any]:
780
+ return await self._request(creds, "GET", f"/users/{user_id}/settings/autoForwarding")
781
+
782
+ async def update_auto_forwarding(
783
+ self, creds: GmailCredentials, payload: Dict[str, Any], user_id: str = "me"
784
+ ) -> Dict[str, Any]:
785
+ return await self._request(
786
+ creds, "PUT", f"/users/{user_id}/settings/autoForwarding", json_body=payload
787
+ )
788
+
789
+ async def get_vacation(self, creds: GmailCredentials, user_id: str = "me") -> Dict[str, Any]:
790
+ return await self._request(creds, "GET", f"/users/{user_id}/settings/vacation")
791
+
792
+ async def update_vacation(
793
+ self, creds: GmailCredentials, payload: Dict[str, Any], user_id: str = "me"
794
+ ) -> Dict[str, Any]:
795
+ return await self._request(
796
+ creds, "PUT", f"/users/{user_id}/settings/vacation", json_body=payload
797
+ )
798
+
799
+ async def list_filters(self, creds: GmailCredentials, user_id: str = "me") -> Dict[str, Any]:
800
+ return await self._request(creds, "GET", f"/users/{user_id}/settings/filters")
801
+
802
+ async def get_filter(
803
+ self, creds: GmailCredentials, filter_id: str, user_id: str = "me"
804
+ ) -> Dict[str, Any]:
805
+ return await self._request(creds, "GET", f"/users/{user_id}/settings/filters/{filter_id}")
806
+
807
+ async def create_filter(
808
+ self, creds: GmailCredentials, req: GmailFilterCreateRequest, user_id: str = "me"
809
+ ) -> Dict[str, Any]:
810
+ body: Dict[str, Any] = {"criteria": req.criteria or {}, "action": req.action or {}}
811
+ return await self._request(creds, "POST", f"/users/{user_id}/settings/filters", json_body=body)
812
+
813
+ async def delete_filter(
814
+ self, creds: GmailCredentials, filter_id: str, user_id: str = "me"
815
+ ) -> None:
816
+ await self._request(creds, "DELETE", f"/users/{user_id}/settings/filters/{filter_id}")
817
+
818
+ async def list_forwarding_addresses(self, creds: GmailCredentials, user_id: str = "me") -> Dict[str, Any]:
819
+ return await self._request(creds, "GET", f"/users/{user_id}/settings/forwardingAddresses")
820
+
821
+ async def get_forwarding_address(
822
+ self, creds: GmailCredentials, forwarding_email: str, user_id: str = "me"
823
+ ) -> Dict[str, Any]:
824
+ return await self._request(
825
+ creds, "GET", f"/users/{user_id}/settings/forwardingAddresses/{forwarding_email}"
826
+ )
827
+
828
+ async def create_forwarding_address(
829
+ self, creds: GmailCredentials, forwarding_email: str, user_id: str = "me"
830
+ ) -> Dict[str, Any]:
831
+ return await self._request(
832
+ creds,
833
+ "POST",
834
+ f"/users/{user_id}/settings/forwardingAddresses",
835
+ json_body={"forwardingEmail": forwarding_email},
836
+ )
837
+
838
+ async def delete_forwarding_address(
839
+ self, creds: GmailCredentials, forwarding_email: str, user_id: str = "me"
840
+ ) -> None:
841
+ await self._request(
842
+ creds, "DELETE", f"/users/{user_id}/settings/forwardingAddresses/{forwarding_email}"
843
+ )
844
+
845
+ async def list_send_as(self, creds: GmailCredentials, user_id: str = "me") -> Dict[str, Any]:
846
+ return await self._request(creds, "GET", f"/users/{user_id}/settings/sendAs")
847
+
848
+ async def get_send_as(
849
+ self, creds: GmailCredentials, send_as_email: str, user_id: str = "me"
850
+ ) -> Dict[str, Any]:
851
+ return await self._request(
852
+ creds, "GET", f"/users/{user_id}/settings/sendAs/{send_as_email}"
853
+ )
854
+
855
+ async def create_send_as(
856
+ self, creds: GmailCredentials, payload: Dict[str, Any], user_id: str = "me"
857
+ ) -> Dict[str, Any]:
858
+ return await self._request(
859
+ creds, "POST", f"/users/{user_id}/settings/sendAs", json_body=payload
860
+ )
861
+
862
+ async def update_send_as(
863
+ self,
864
+ creds: GmailCredentials,
865
+ send_as_email: str,
866
+ payload: Dict[str, Any],
867
+ user_id: str = "me",
868
+ ) -> Dict[str, Any]:
869
+ return await self._request(
870
+ creds, "PATCH", f"/users/{user_id}/settings/sendAs/{send_as_email}", json_body=payload
871
+ )
872
+
873
+ async def delete_send_as(
874
+ self, creds: GmailCredentials, send_as_email: str, user_id: str = "me"
875
+ ) -> None:
876
+ await self._request(
877
+ creds, "DELETE", f"/users/{user_id}/settings/sendAs/{send_as_email}"
878
+ )
879
+
880
+ async def list_delegates(self, creds: GmailCredentials, user_id: str = "me") -> Dict[str, Any]:
881
+ return await self._request(creds, "GET", f"/users/{user_id}/settings/delegates")
882
+
883
+ async def get_delegate(
884
+ self, creds: GmailCredentials, delegate_email: str, user_id: str = "me"
885
+ ) -> Dict[str, Any]:
886
+ return await self._request(
887
+ creds, "GET", f"/users/{user_id}/settings/delegates/{delegate_email}"
888
+ )
889
+
890
+ async def create_delegate(
891
+ self, creds: GmailCredentials, delegate_email: str, user_id: str = "me"
892
+ ) -> Dict[str, Any]:
893
+ return await self._request(
894
+ creds,
895
+ "POST",
896
+ f"/users/{user_id}/settings/delegates",
897
+ json_body={"delegateEmail": delegate_email},
898
+ )
899
+
900
+ async def delete_delegate(
901
+ self, creds: GmailCredentials, delegate_email: str, user_id: str = "me"
902
+ ) -> None:
903
+ await self._request(
904
+ creds, "DELETE", f"/users/{user_id}/settings/delegates/{delegate_email}"
905
+ )
app/services/google_scope_service.py ADDED
@@ -0,0 +1,245 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ from typing import Any, Dict, List, Optional
4
+
5
+ from app.services.google_scopes_data import GOOGLE_APIS
6
+
7
+ # OIDC short names that are conventionally sent without a full URI. They are
8
+ # already present as scope names for the oauth_signin plan, but accepting them
9
+ # explicitly keeps validation predictable for all APIs.
10
+ _OIDC_SHORT_NAMES = frozenset({"openid", "email", "profile"})
11
+
12
+
13
+ class GoogleScopeError(Exception):
14
+ """Raised for invalid scope configuration requests."""
15
+
16
+ def __init__(self, message: str, status_code: int = 400) -> None:
17
+ super().__init__(message)
18
+ self.message = message
19
+ self.status_code = status_code
20
+
21
+
22
+ def _default_scopes(plan: Dict[str, Any]) -> List[str]:
23
+ """Derive the recommended default scopes for a plan."""
24
+ pn = plan.get("production_notes") or {}
25
+ dflt = pn.get("default_scope_request")
26
+ if dflt:
27
+ return [s.strip() for s in dflt.split() if s.strip()]
28
+ recs = plan.get("recommended_combinations") or {}
29
+ if recs:
30
+ first = next(iter(recs.values()))
31
+ return list(first)
32
+ return []
33
+
34
+
35
+ def _allowed_scopes(plan: Dict[str, Any]) -> frozenset:
36
+ """Set of scope identifiers (URI or short name) accepted for a plan."""
37
+ allowed = set(_OIDC_SHORT_NAMES)
38
+ for scope in plan.get("scopes") or []:
39
+ if scope.get("uri"):
40
+ allowed.add(scope["uri"])
41
+ if scope.get("name"):
42
+ allowed.add(scope["name"])
43
+ return frozenset(allowed)
44
+
45
+
46
+ class GoogleScopeService:
47
+ """Serves the Google per-API scope catalog entirely from memory.
48
+
49
+ The catalog is a Python object structure (``google_scopes_data.GOOGLE_APIS``)
50
+ and the currently selected scopes are held as an in-memory dict of objects.
51
+ No JSON files are read or written.
52
+ """
53
+
54
+ def __init__(self) -> None:
55
+ self._plans: Dict[str, Dict[str, Any]] = dict(GOOGLE_APIS)
56
+ self._current_scopes: Optional[Dict[str, List[str]]] = None
57
+
58
+ @property
59
+ def catalog(self) -> Dict[str, Dict[str, Any]]:
60
+ return self._plans
61
+
62
+ @property
63
+ def current_scopes(self) -> Dict[str, List[str]]:
64
+ return self._state()
65
+
66
+ def _state(self) -> Dict[str, List[str]]:
67
+ if self._current_scopes is None:
68
+ self._current_scopes = {
69
+ pid: _default_scopes(plan) for pid, plan in self._plans.items()
70
+ }
71
+ return self._current_scopes
72
+
73
+ async def load_all_plans(self) -> Dict[str, Dict[str, Any]]:
74
+ return self._plans
75
+
76
+ async def load_default_state(self) -> Dict[str, List[str]]:
77
+ return {pid: _default_scopes(plan) for pid, plan in self._plans.items()}
78
+
79
+ async def load_state(self) -> Dict[str, List[str]]:
80
+ return self._state()
81
+
82
+ async def set_current_scopes(
83
+ self, updates: Dict[str, List[str]], reset: bool = False
84
+ ) -> Dict[str, List[str]]:
85
+ """Validate and store the requested current scopes in memory.
86
+
87
+ Raises GoogleScopeError with a 400 status for unknown API ids or scopes
88
+ that are not defined in the catalog.
89
+ """
90
+ if reset:
91
+ self._current_scopes = {
92
+ pid: _default_scopes(plan) for pid, plan in self._plans.items()
93
+ }
94
+ return self._current_scopes
95
+
96
+ current = self._state()
97
+ errors: List[str] = []
98
+ for pid, scopes in (updates or {}).items():
99
+ if pid not in self._plans:
100
+ errors.append(f"Unknown API id '{pid}'. Valid ids: {', '.join(sorted(self._plans))}")
101
+ continue
102
+ allowed = _allowed_scopes(self._plans[pid])
103
+ cleaned = [str(s).strip() for s in scopes if str(s).strip()]
104
+ bad = [s for s in cleaned if s not in allowed]
105
+ if bad:
106
+ errors.append(f"Invalid scopes for '{pid}': {', '.join(bad)}")
107
+ continue
108
+ current[pid] = cleaned
109
+
110
+ if errors:
111
+ raise GoogleScopeError("; ".join(errors), status_code=400)
112
+
113
+ self._current_scopes = current
114
+ return current
115
+
116
+ @staticmethod
117
+ def _scope_text(scope: Dict[str, Any]) -> str:
118
+ return " ".join(
119
+ str(scope.get(k) or "") for k in ("name", "uri", "description")
120
+ ).lower()
121
+
122
+ @staticmethod
123
+ def _scope_matches(
124
+ scope: Dict[str, Any],
125
+ *,
126
+ scope_name: Optional[str] = None,
127
+ permission_level: Optional[List[str]] = None,
128
+ required: Optional[bool] = None,
129
+ recommended: Optional[bool] = None,
130
+ ) -> bool:
131
+ if scope_name:
132
+ needle = scope_name.lower()
133
+ if needle not in (str(scope.get("name") or "").lower()) and needle not in (
134
+ str(scope.get("uri") or "").lower()
135
+ ):
136
+ return False
137
+ if permission_level:
138
+ level = str(scope.get("permission_level") or "").lower()
139
+ if level not in {p.lower() for p in permission_level}:
140
+ return False
141
+ if required is not None and bool(scope.get("required")) is not required:
142
+ return False
143
+ if recommended is not None and bool(scope.get("recommended")) is not recommended:
144
+ return False
145
+ return True
146
+
147
+ @classmethod
148
+ def filter_data(
149
+ cls,
150
+ data: List[Dict[str, Any]],
151
+ *,
152
+ api_name: Optional[str] = None,
153
+ oauth_only: bool = False,
154
+ no_scope: bool = False,
155
+ search: Optional[str] = None,
156
+ scope_name: Optional[str] = None,
157
+ permission_level: Optional[List[str]] = None,
158
+ required: Optional[bool] = None,
159
+ recommended: Optional[bool] = None,
160
+ scope_name_only: bool = False,
161
+ ) -> List[Dict[str, Any]]:
162
+ """Apply discoverability filters to the built catalog entries.
163
+
164
+ - ``api_name`` keeps a single API (case-insensitive id match).
165
+ - ``oauth_only`` / ``no_scope`` filter by whether scopes are required.
166
+ - ``search`` free-text matches the API id/name and scope fields.
167
+ - ``scope_name``, ``permission_level``, ``required``, ``recommended``
168
+ filter the ``scopes`` array of each entry; entries left with zero
169
+ matching scopes are dropped unless ``api_name`` was requested.
170
+ - ``scope_name_only`` replaces each ``scopes`` array with the list of
171
+ scope names (falling back to the URI).
172
+ """
173
+ api_name_l = api_name.strip().lower() if api_name else None
174
+ scope_filters_active = any(
175
+ [scope_name, permission_level, required is not None, recommended is not None]
176
+ )
177
+ needle = search.strip().lower() if search else None
178
+ levels = [p.strip().lower() for p in (permission_level or []) if p.strip()]
179
+
180
+ filtered: List[Dict[str, Any]] = []
181
+ for entry in data:
182
+ if api_name_l and str(entry.get("id") or "").lower() != api_name_l:
183
+ continue
184
+ if oauth_only and not entry.get("scope_required"):
185
+ continue
186
+ if no_scope and entry.get("scope_required"):
187
+ continue
188
+
189
+ haystack = (
190
+ f"{entry.get('id') or ''} {entry.get('api') or ''} "
191
+ + " ".join(cls._scope_text(s) for s in entry.get("scopes") or [])
192
+ ).lower()
193
+ if needle and needle not in haystack:
194
+ continue
195
+
196
+ scopes = entry.get("scopes") or []
197
+ if scope_filters_active:
198
+ scopes = [
199
+ s for s in scopes
200
+ if cls._scope_matches(
201
+ s,
202
+ scope_name=scope_name,
203
+ permission_level=levels or None,
204
+ required=required,
205
+ recommended=recommended,
206
+ )
207
+ ]
208
+ if not scopes and not api_name_l:
209
+ continue
210
+
211
+ item = dict(entry)
212
+ if scope_name_only:
213
+ item["scopes"] = [str(s.get("name") or s.get("uri")) for s in scopes]
214
+ else:
215
+ item["scopes"] = scopes
216
+ filtered.append(item)
217
+ return filtered
218
+
219
+ @staticmethod
220
+ def build_data(
221
+ plans: Dict[str, Dict[str, Any]],
222
+ state: Dict[str, List[str]],
223
+ ) -> List[Dict[str, Any]]:
224
+ """Merge catalog plans with current scopes into the API response payload."""
225
+ data: List[Dict[str, Any]] = []
226
+ for pid in sorted(plans):
227
+ plan = plans[pid]
228
+ data.append(
229
+ {
230
+ "id": pid,
231
+ "api": plan.get("api"),
232
+ "version": plan.get("version"),
233
+ "base_url": plan.get("base_url"),
234
+ "batch_endpoint": plan.get("batch_endpoint"),
235
+ "auth_model": plan.get("auth_model"),
236
+ "scope_required": plan.get("scope_required", False),
237
+ "docs_url": plan.get("docs_url"),
238
+ "info": plan.get("info"),
239
+ "current_scopes": state.get(pid, []),
240
+ "default_scopes": _default_scopes(plan),
241
+ "scopes": plan.get("scopes", []),
242
+ "recommended_combinations": plan.get("recommended_combinations", {}),
243
+ }
244
+ )
245
+ return data
app/services/google_scopes_data.py ADDED
The diff for this file is too large to render. See raw diff
 
app/utils/gmail_utils.py ADDED
@@ -0,0 +1,293 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import base64
4
+ import binascii
5
+ import mimetypes
6
+ import os
7
+ from email import message_from_bytes
8
+ from email.mime.application import MIMEApplication
9
+ from email.mime.audio import MIMEAudio
10
+ from email.mime.base import MIMEBase
11
+ from email.mime.image import MIMEImage
12
+ from email.mime.multipart import MIMEMultipart
13
+ from email.mime.text import MIMEText
14
+ from typing import Any, Dict, List, Optional
15
+
16
+ from app.config import get_settings
17
+ from app.models.schemas import (
18
+ GmailComposeMessageRequest,
19
+ GmailMessageAttachmentMeta,
20
+ GmailParsedMessage,
21
+ )
22
+
23
+ _settings = get_settings()
24
+
25
+
26
+ class GmailMessageValidationError(ValueError):
27
+ """Raised when a composed message fails size/content validation."""
28
+
29
+
30
+ class GmailParseError(ValueError):
31
+ """Raised when a Gmail API message payload cannot be parsed."""
32
+
33
+
34
+ _EXTENSION_TO_MIME: Dict[str, str] = {
35
+ ".pdf": "application/pdf",
36
+ ".png": "image/png",
37
+ ".jpg": "image/jpeg",
38
+ ".jpeg": "image/jpeg",
39
+ ".gif": "image/gif",
40
+ ".bmp": "image/bmp",
41
+ ".webp": "image/webp",
42
+ ".txt": "text/plain",
43
+ ".csv": "text/csv",
44
+ ".json": "application/json",
45
+ ".xml": "application/xml",
46
+ ".zip": "application/zip",
47
+ ".doc": "application/msword",
48
+ ".docx": "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
49
+ ".xls": "application/vnd.ms-excel",
50
+ ".xlsx": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
51
+ ".ppt": "application/vnd.ms-powerpoint",
52
+ ".pptx": "application/vnd.openxmlformats-officedocument.presentationml.presentation",
53
+ ".mp3": "audio/mpeg",
54
+ ".wav": "audio/wav",
55
+ ".mp4": "video/mp4",
56
+ ".mov": "video/quicktime",
57
+ }
58
+
59
+ _IMMUTABLE_HEADERS = frozenset({
60
+ "from", "to", "subject", "cc", "bcc", "reply-to",
61
+ "in-reply-to", "references", "content-type", "mime-version",
62
+ "content-transfer-encoding",
63
+ })
64
+
65
+
66
+ def guess_mime_type(filename: str) -> str:
67
+ """Best-effort MIME type from a filename; falls back to octet-stream."""
68
+ mime_type, _ = mimetypes.guess_type(filename)
69
+ if mime_type is None:
70
+ ext = os.path.splitext(filename)[1].lower()
71
+ mime_type = _EXTENSION_TO_MIME.get(ext, "application/octet-stream")
72
+ return mime_type
73
+
74
+
75
+ def _create_attachment_part(filename: str, content_bytes: bytes, mime_type: str) -> MIMEBase:
76
+ main_type, _, sub_type = mime_type.partition("/")
77
+ sub_type = sub_type or "octet-stream"
78
+
79
+ if main_type == "image":
80
+ part: MIMEBase = MIMEImage(content_bytes, _subtype=sub_type)
81
+ elif main_type == "audio":
82
+ part = MIMEAudio(content_bytes, _subtype=sub_type)
83
+ elif main_type == "application":
84
+ part = MIMEApplication(content_bytes, _subtype=sub_type, Name=filename)
85
+ else:
86
+ part = MIMEBase(main_type or "application", sub_type)
87
+ part.set_payload(content_bytes)
88
+
89
+ part.add_header("Content-Disposition", f'attachment; filename="{filename}"')
90
+ part.add_header("Content-ID", f"<{filename}>")
91
+ return part
92
+
93
+
94
+ def build_mime_message(
95
+ request: GmailComposeMessageRequest,
96
+ ) -> str:
97
+ """Build an RFC 2822 message from a structured compose request.
98
+
99
+ Returns the base64url-encoded message string accepted by the Gmail API.
100
+ The ``From`` header is intentionally omitted so Gmail fills it with the
101
+ authenticated mailbox address (client-supplied, stateless design).
102
+ """
103
+ message = MIMEMultipart("mixed")
104
+
105
+ to = request.to
106
+ if not to:
107
+ raise GmailMessageValidationError("At least one 'to' recipient is required.")
108
+
109
+ message["To"] = ", ".join(to)
110
+ message["Subject"] = request.subject
111
+
112
+ if request.cc:
113
+ message["Cc"] = ", ".join(request.cc)
114
+ if request.bcc:
115
+ message["Bcc"] = ", ".join(request.bcc)
116
+ if request.reply_to:
117
+ message["Reply-To"] = ", ".join(request.reply_to)
118
+ if request.in_reply_to:
119
+ message["In-Reply-To"] = request.in_reply_to
120
+ if request.references:
121
+ message["References"] = " ".join(request.references)
122
+
123
+ if request.headers:
124
+ for key, value in request.headers.items():
125
+ if key.lower() not in _IMMUTABLE_HEADERS:
126
+ message[key] = value
127
+
128
+ alternative = MIMEMultipart("alternative")
129
+ if request.body:
130
+ alternative.attach(MIMEText(request.body, "plain", "utf-8"))
131
+ if request.html_body:
132
+ alternative.attach(MIMEText(request.html_body, "html", "utf-8"))
133
+ elif request.body:
134
+ alternative.attach(MIMEText(request.body, "html", "utf-8"))
135
+
136
+ if alternative.get_payload():
137
+ message.attach(alternative)
138
+
139
+ if request.attachments:
140
+ total_bytes = sum(
141
+ len(_b64_decode(s.content_base64)) for s in request.attachments
142
+ )
143
+ if total_bytes > _settings.gmail_max_payload_bytes:
144
+ raise GmailMessageValidationError(
145
+ f"Total attachment payload of {total_bytes} bytes exceeds the "
146
+ f"limit of {_settings.gmail_max_payload_bytes} bytes."
147
+ )
148
+
149
+ for spec in request.attachments:
150
+ content = _b64_decode(spec.content_base64)
151
+ if len(content) > _settings.gmail_max_attachment_bytes:
152
+ raise GmailMessageValidationError(
153
+ f"Attachment '{spec.filename}' is {len(content)} bytes, exceeding "
154
+ f"the {_settings.gmail_max_attachment_bytes} byte per-file limit."
155
+ )
156
+ mime_type = spec.mime_type or guess_mime_type(spec.filename)
157
+ message.attach(_create_attachment_part(spec.filename, content, mime_type))
158
+
159
+ return encode_mime_to_base64(message)
160
+
161
+
162
+ def encode_mime_to_base64(mime_message: Any) -> str:
163
+ """Base64url-encode a MIME message for the Gmail API."""
164
+ return base64.urlsafe_b64encode(mime_message.as_bytes()).decode("ascii")
165
+
166
+
167
+ def _b64_decode(data: str) -> bytes:
168
+ """Decode base64url (or standard base64) into bytes."""
169
+ try:
170
+ return base64.urlsafe_b64decode(data.encode("ascii"))
171
+ except (binascii.Error, ValueError) as exc:
172
+ raise GmailMessageValidationError(
173
+ "Attachment content must be valid base64."
174
+ ) from exc
175
+
176
+
177
+ def _base64_decode_text(data: str) -> str:
178
+ if not data:
179
+ return ""
180
+ try:
181
+ return _b64_decode(data).decode("utf-8", errors="replace")
182
+ except GmailMessageValidationError:
183
+ return ""
184
+
185
+
186
+ def parse_message(message: Dict[str, Any]) -> GmailParsedMessage:
187
+ """Parse a Gmail ``format=full`` or ``format=metadata`` message resource.
188
+
189
+ Handles the ``raw`` format transparently when present. Recursively extracts
190
+ plain/HTML bodies and top-level attachment metadata.
191
+ """
192
+ try:
193
+ message_id = message.get("id", "")
194
+ thread_id = message.get("threadId", "")
195
+ label_ids = message.get("labelIds") or []
196
+ snippet = message.get("snippet", "")
197
+ history_id = message.get("historyId")
198
+
199
+ headers: Dict[str, str] = {}
200
+ plain_body: Optional[str] = None
201
+ html_body: Optional[str] = None
202
+ attachments: List[GmailMessageAttachmentMeta] = []
203
+
204
+ payload = message.get("payload") or {}
205
+ if message.get("raw"):
206
+ decoded = _b64_decode(message["raw"])
207
+ parsed = message_from_bytes(decoded)
208
+ headers = {
209
+ h: v for h, v in parsed.items()
210
+ }
211
+ if parsed.get_content_type() == "multipart/mixed":
212
+ for part in parsed.walk():
213
+ ctype = part.get_content_type()
214
+ if ctype == "text/plain":
215
+ plain_body = part.get_payload(decode=True).decode("utf-8", errors="replace")
216
+ elif ctype == "text/html":
217
+ html_body = part.get_payload(decode=True).decode("utf-8", errors="replace")
218
+ else:
219
+ filename = part.get_filename()
220
+ if filename:
221
+ attachments.append(GmailMessageAttachmentMeta(
222
+ attachment_id="",
223
+ filename=filename,
224
+ mime_type=part.get_content_type(),
225
+ size_bytes=len(part.get_payload(decode=True) or b""),
226
+ ))
227
+ elif parsed.get_content_type() == "text/plain":
228
+ plain_body = parsed.get_payload(decode=True).decode("utf-8", errors="replace")
229
+ else:
230
+ for header in payload.get("headers") or []:
231
+ name = header.get("name", "")
232
+ if name:
233
+ headers[name] = header.get("value", "")
234
+ plain_body, html_body, attachments = _walk_payload(payload)
235
+
236
+ subject = headers.get("Subject", headers.get("subject", ""))
237
+ if subject and headers.get("Subject") is None and "subject" in headers:
238
+ subject = headers["subject"]
239
+
240
+ return GmailParsedMessage(
241
+ id=message_id,
242
+ thread_id=thread_id,
243
+ label_ids=label_ids,
244
+ snippet=snippet,
245
+ history_id=history_id,
246
+ headers=headers,
247
+ sender=headers.get("From", ""),
248
+ to=headers.get("To", ""),
249
+ cc=headers.get("Cc", ""),
250
+ bcc=headers.get("Bcc", ""),
251
+ subject=subject,
252
+ date=headers.get("Date"),
253
+ plain_body=plain_body,
254
+ html_body=html_body,
255
+ attachments=attachments,
256
+ )
257
+ except GmailMessageValidationError as exc:
258
+ raise GmailParseError(str(exc)) from exc
259
+
260
+
261
+ def _walk_payload(
262
+ payload: Dict[str, Any],
263
+ ) -> tuple[Optional[str], Optional[str], List[GmailMessageAttachmentMeta]]:
264
+ """Recursively extract text bodies and attachment metadata from a payload tree."""
265
+ plain_body: Optional[str] = None
266
+ html_body: Optional[str] = None
267
+ attachments: List[GmailMessageAttachmentMeta] = []
268
+
269
+ def visit(node: Dict[str, Any]) -> None:
270
+ nonlocal plain_body, html_body
271
+ mime_type = node.get("mimeType", "")
272
+ filename = node.get("filename", "")
273
+ body = node.get("body") or {}
274
+ parts = node.get("parts") or []
275
+
276
+ if filename:
277
+ attachments.append(GmailMessageAttachmentMeta(
278
+ attachment_id=body.get("attachmentId", ""),
279
+ filename=filename,
280
+ mime_type=mime_type,
281
+ size_bytes=int(body.get("size", 0) or 0),
282
+ ))
283
+
284
+ if mime_type == "text/plain" and plain_body is None:
285
+ plain_body = _base64_decode_text(body.get("data", ""))
286
+ elif mime_type == "text/html" and html_body is None:
287
+ html_body = _base64_decode_text(body.get("data", ""))
288
+
289
+ for child in parts:
290
+ visit(child)
291
+
292
+ visit(payload)
293
+ return plain_body, html_body, attachments