diff --git a/README.md b/README.md index e2f51ea..3f20c38 100644 --- a/README.md +++ b/README.md @@ -2,13 +2,17 @@ **Google Workspace** for a [protoAgent](https://github.com/protoLabsAI/protoAgent) agent — built to grow. Ships **Gmail (read + draft)**, **Calendar (read)**, and **Drive (read)** today over a service-agnostic OAuth/REST core, so Docs / Sheets / further services are additive modules, not a rewrite. -Pull-mode posture: the agent lists, searches, reads, **drafts**, and can **mark mail read** — it never sends, archives, deletes, or auto-replies. A human reviews drafts in the Drafts folder and sends them. +Pull-mode posture: the agent lists, searches, reads, **drafts**, and does **mailbox hygiene** (mark read, label, archive) — it never sends, deletes, or auto-replies. A human reviews drafts in the Drafts folder and sends them. ## Tools - `gmail_list_unread(label, max)` · `gmail_search(query, max)` · `gmail_get_thread(thread_id)` — read. - `gmail_create_draft(body, thread_id | to+subject, …)` — **draft only, never sends**. -- `gmail_mark_read(message_ids | thread_id)` — clears UNREAD only; never archives/deletes. +- `gmail_mark_read(message_ids | thread_id)` — clears UNREAD only. +- `gmail_label(message_ids | thread_id, add, remove, archive)` — label by name (auto-creates), archive = remove INBOX; adding TRASH/SPAM refused. +- `gmail_get_attachment(message_id, attachment_id, filename)` — text attachments return content; binaries save to the workspace. (`gmail_get_thread` now lists attachments.) +- `gmail_list_drafts(max)` · `gmail_update_draft(draft_id, body, …)` — revise drafts; **still never sends**. - `calendar_list_upcoming(days, calendar_id)` · `calendar_event_detail(event_id, calendar_id)` — read. +- `calendar_availability(days)` — free/busy blocks. · `calendar_search(query, days_back, days_ahead)` — text search. - `drive_search(query, max)` · `drive_read(file_id, max_chars)` — read; Docs export as text, Sheets as CSV, Slides as text. ## Architecture diff --git a/__init__.py b/__init__.py index c0cff99..e20bd7b 100644 --- a/__init__.py +++ b/__init__.py @@ -5,9 +5,9 @@ service (``gmail.py``, ``gcal.py`` — Drive / Docs / Sheets drop in the same way), so expanding to the wider Workspace is new tool functions, not a re-architecture. -Pull-mode posture: the agent lists, searches, reads, DRAFTS, and can mark mail -read — it never sends, archives, deletes, or auto-replies; a human reviews drafts -and sends them. Credentials come from plugin +Pull-mode posture: the agent lists, searches, reads, DRAFTS, and does mailbox +hygiene (mark read, label, archive) — it never sends, deletes, or auto-replies; +a human reviews drafts and sends them. Credentials come from plugin config (``google.*``) with env fallbacks (``GOOGLE_CLIENT_ID`` / ``GOOGLE_CLIENT_SECRET`` / ``GOOGLE_REFRESH_TOKEN``). Mint the refresh token with the full scope set you intend to grow into so adding a service needs no re-consent. @@ -131,6 +131,121 @@ def gmail_create_draft(body: str, thread_id: str = "", to: str = "", subject: st return f"Draft created (id {out['draftId']}) to {out['to'] or '(thread)'} — \"{out['subject']}\". In Drafts; not sent." +@tool +def gmail_label(message_ids: list[str] | None = None, thread_id: str = "", + add: list[str] | None = None, remove: list[str] | None = None, + archive: bool = False) -> str: + """Add/remove Gmail labels (by name) on messages or a whole thread; archive=True + removes INBOX (archives — mail stays searchable under All Mail). Labels being + added are created if missing. Never deletes: adding TRASH/SPAM is refused. + + Args: + message_ids: message ids (from gmail_list_unread / gmail_search). + thread_id: apply to every message in this thread instead. + add: label names to add (e.g. ["Receipts"]). + remove: label names to remove (e.g. ["INBOX"] — same as archive=True). + archive: also remove INBOX. + """ + from . import gmail + + if not thread_id and not message_ids: + return "Provide message_ids (from gmail_list_unread / gmail_search) or a thread_id." + remove = list(remove or []) + (["INBOX"] if archive else []) + if not (add or remove): + return "Provide labels to add/remove, or archive=True." + out = _run(gmail.modify_labels, _creds(), message_ids, thread_id, add, remove) + if isinstance(out, str): + return out + what = " + ".join(filter(None, [f"+{', '.join(add)}" if add else "", f"-{', '.join(remove)}" if remove else ""])) + created = f" (created: {', '.join(out['created'])})" if out.get("created") else "" + target = f"thread {out['threadId']}" if out.get("threadId") else f"{out['modified']} message(s)" + return f"Labels {what} applied to {target}.{created}" + + +def _attachments_dir(): + """Where binary attachments land — the agent workspace when hosted, tmp otherwise.""" + from pathlib import Path + + try: + from infra.paths import instance_paths # host-only, lazy + + d = instance_paths().workspace_dir / "attachments" + except Exception: # noqa: BLE001 — no host (tests) ⇒ tmp + import tempfile + + d = Path(tempfile.gettempdir()) / "google-plugin-attachments" + d.mkdir(parents=True, exist_ok=True) + return d + + +_TEXTY_EXTS = {".txt", ".md", ".csv", ".json", ".xml", ".yaml", ".yml", ".log", ".eml", ".html", ".htm", ".ics"} + + +@tool +def gmail_get_attachment(message_id: str, attachment_id: str, filename: str = "attachment.bin", + max_chars: int = 20000) -> str: + """Fetch one email attachment (ids from gmail_get_thread's attachments). Text + files return their content; binaries are saved into the agent workspace and the + path is returned. + + Args: + message_id: the message the attachment belongs to. + attachment_id: the attachment id from gmail_get_thread. + filename: original filename (drives text-vs-binary handling and the saved name). + max_chars: truncate text content to this many characters (default 20000). + """ + import os + + from . import gmail + + out = _run(gmail.get_attachment, _creds(), message_id, attachment_id) + if isinstance(out, str): + return out + safe = os.path.basename(filename).strip() or "attachment.bin" + ext = os.path.splitext(safe)[1].lower() + if ext in _TEXTY_EXTS: + text = out.decode("utf-8", "replace") + return json.dumps({"filename": safe, "truncated": len(text) > int(max_chars), + "content": text[: int(max_chars)]}, indent=2) + dest = _attachments_dir() / safe + if dest.exists(): + dest = dest.with_name(f"{message_id[:8]}-{safe}") + dest.write_bytes(out) + return json.dumps({"filename": safe, "savedTo": str(dest), "bytes": len(out)}, indent=2) + + +@tool +def gmail_list_drafts(max: int = 20) -> str: + """List Gmail drafts (id, to, subject, snippet) — e.g. to find one to revise. Read-only. + + Args: + max: max drafts (default 20, cap 100). + """ + from . import gmail + + out = _run(gmail.list_drafts, _creds(), max) + return out if isinstance(out, str) else json.dumps({"count": len(out), "drafts": out}, indent=2) + + +@tool +def gmail_update_draft(draft_id: str, body: str, to: str = "", subject: str = "") -> str: + """Rewrite an existing Gmail draft's body (and optionally to/subject). Reply + headers and thread are preserved. Still a DRAFT — never sends. + + Args: + draft_id: the draft id (from gmail_list_drafts or gmail_create_draft). + body: the new plain-text body (replaces the old one). + to: override recipient (optional). + subject: override subject (optional). + """ + from . import gmail + + out = _run(gmail.update_draft, _creds(), draft_id, body, to, subject) + if isinstance(out, str): + return out + return f"Draft {out['draftId']} updated — to {out['to'] or '(thread)'}, \"{out['subject']}\". In Drafts; not sent." + + @tool def gmail_mark_read(message_ids: list[str] | None = None, thread_id: str = "") -> str: """Mark Gmail messages as read (clears the UNREAD label — nothing is archived, @@ -169,6 +284,39 @@ def calendar_list_upcoming(days: int = 7, calendar_id: str = "primary") -> str: return out if isinstance(out, str) else json.dumps({"calendarId": calendar_id, "days": days, "count": len(out), "events": out}, indent=2) +@tool +def calendar_availability(days: int = 7, calendar_id: str = "primary") -> str: + """Busy time blocks over the next N days (free/busy) — derive free slots from the + gaps. Read-only. + + Args: + days: lookahead window in days (default 7, cap 90). + calendar_id: calendar id (default "primary"). + """ + from . import gcal + + out = _run(gcal.free_busy, _creds(), days, calendar_id) + return out if isinstance(out, str) else json.dumps(out, indent=2) + + +@tool +def calendar_search(query: str, days_back: int = 30, days_ahead: int = 180, + calendar_id: str = "primary", max: int = 20) -> str: + """Search calendar events by text in a window around today (past + future). Read-only. + + Args: + query: free-text search (title, description, attendees). + days_back: how far back to look (default 30, cap 365). + days_ahead: how far ahead to look (default 180, cap 365). + calendar_id: calendar id (default "primary"). + max: max events (default 20, cap 50). + """ + from . import gcal + + out = _run(gcal.search_events, _creds(), query, days_back, days_ahead, calendar_id, max) + return out if isinstance(out, str) else json.dumps({"query": query, "count": len(out), "events": out}, indent=2) + + @tool def calendar_event_detail(event_id: str, calendar_id: str = "primary") -> str: """Read full detail for one calendar event (description, attendees + RSVP, organizer). Read-only. @@ -217,7 +365,8 @@ def drive_read(file_id: str, max_chars: int = 20000) -> str: # Registered tools, grouped by service. Append new service tools here as they land. TOOLS = [ gmail_list_unread, gmail_search, gmail_get_thread, gmail_create_draft, gmail_mark_read, - calendar_list_upcoming, calendar_event_detail, + gmail_label, gmail_get_attachment, gmail_list_drafts, gmail_update_draft, + calendar_list_upcoming, calendar_event_detail, calendar_availability, calendar_search, drive_search, drive_read, ] diff --git a/gcal.py b/gcal.py index 71ee028..6d1e316 100644 --- a/gcal.py +++ b/gcal.py @@ -11,7 +11,8 @@ from .auth import Creds, request -BASE = "https://www.googleapis.com/calendar/v3/calendars" +API = "https://www.googleapis.com/calendar/v3" +BASE = f"{API}/calendars" def _summary(e: dict) -> dict: @@ -38,6 +39,35 @@ def list_upcoming(creds: Creds, days: int = 7, calendar_id: str = "primary", return [_summary(e) for e in (data.get("items") or [])] +def free_busy(creds: Creds, days: int = 7, calendar_id: str = "primary", + *, client=None, now_iso: str | None = None) -> dict: + """Busy blocks over the next N days — the agent derives free slots from the gaps.""" + start = datetime.now(timezone.utc) if now_iso is None else datetime.fromisoformat(now_iso) + end = start + timedelta(days=min(int(days), 90)) + data = request(creds, "POST", f"{API}/freeBusy", json={ + "timeMin": start.isoformat(), "timeMax": end.isoformat(), "items": [{"id": calendar_id}], + }, client=client) + cal = (data.get("calendars") or {}).get(calendar_id) or {} + out = {"timeMin": start.isoformat(), "timeMax": end.isoformat(), "busy": cal.get("busy") or []} + if cal.get("errors"): + out["errors"] = cal["errors"] + return out + + +def search_events(creds: Creds, query: str, days_back: int = 30, days_ahead: int = 180, + calendar_id: str = "primary", max_results: int = 20, + *, client=None, now_iso: str | None = None) -> list[dict]: + """Text search over events in a window around now (past and future).""" + now = datetime.now(timezone.utc) if now_iso is None else datetime.fromisoformat(now_iso) + data = request(creds, "GET", f"{BASE}/{calendar_id}/events", params={ + "q": query, + "timeMin": (now - timedelta(days=min(int(days_back), 365))).isoformat(), + "timeMax": (now + timedelta(days=min(int(days_ahead), 365))).isoformat(), + "singleEvents": "true", "orderBy": "startTime", "maxResults": min(int(max_results), 50), + }, client=client) + return [_summary(e) for e in (data.get("items") or [])] + + def event_detail(creds: Creds, event_id: str, calendar_id: str = "primary", *, client=None) -> dict: e = request(creds, "GET", f"{BASE}/{calendar_id}/events/{event_id}", client=client) s = _summary(e) diff --git a/gmail.py b/gmail.py index 93eddbf..7014fd8 100644 --- a/gmail.py +++ b/gmail.py @@ -10,7 +10,7 @@ import base64 from email.utils import formatdate -from .auth import Creds, request +from .auth import Creds, GoogleError, request BASE = "https://gmail.googleapis.com/gmail/v1/users/me" @@ -42,6 +42,23 @@ def _decode(data: str) -> str: return "" +def _attachments(payload: dict) -> list[dict]: + """Real attachments (named parts with an attachmentId), depth-first.""" + out, stack = [], [payload] + while stack: + part = stack.pop(0) + body = part.get("body", {}) + if part.get("filename") and body.get("attachmentId"): + out.append({ + "filename": part["filename"], + "mimeType": part.get("mimeType", ""), + "size": body.get("size", 0), + "attachmentId": body["attachmentId"], + }) + stack.extend(part.get("parts", []) or []) + return out + + def _body(payload: dict, limit: int = 8192) -> str: """First text/plain part (fall back to text/html), depth-first.""" stack = [payload] @@ -81,10 +98,96 @@ def get_thread(creds: Creds, thread_id: str, *, client=None) -> list[dict]: for m in data.get("messages", []): s = _summary(m) s["body"] = _body(m.get("payload", {})) + atts = _attachments(m.get("payload", {})) + if atts: + s["attachments"] = atts + out.append(s) + return out + + +def get_attachment(creds: Creds, message_id: str, attachment_id: str, *, client=None) -> bytes: + """Fetch one attachment's raw bytes (ids come from get_thread's attachments).""" + data = request(creds, "GET", f"{BASE}/messages/{message_id}/attachments/{attachment_id}", client=client) + return base64.urlsafe_b64decode(str(data.get("data", "")).encode()) + + +def list_labels(creds: Creds, *, client=None) -> list[dict]: + data = request(creds, "GET", f"{BASE}/labels", client=client) + return [{"id": lb.get("id", ""), "name": lb.get("name", ""), "type": lb.get("type", "")} + for lb in (data.get("labels") or [])] + + +def modify_labels(creds: Creds, message_ids: list[str] | None = None, thread_id: str = "", + add: list[str] | None = None, remove: list[str] | None = None, *, client=None) -> dict: + """Add/remove labels by NAME on messages (batchModify) or a whole thread. + + Unknown labels being ADDED are created (a new label is harmless); unknown + labels being REMOVED are an error. Posture guard: adding TRASH or SPAM is + refused — this plugin never deletes or spams mail (archiving = removing INBOX). + """ + add = [a for a in (add or []) if a] + remove = [r for r in (remove or []) if r] + forbidden = sorted({a.upper() for a in add} & {"TRASH", "SPAM"}) + if forbidden: + raise GoogleError(f"refusing to add {forbidden} — this plugin never deletes or spams mail") + existing = {lb["name"].lower(): lb["id"] for lb in list_labels(creds, client=client)} + add_ids, created = [], [] + for name in add: + lid = existing.get(name.lower()) + if lid is None: + made = request(creds, "POST", f"{BASE}/labels", json={"name": name}, client=client) + lid = made["id"] + created.append(name) + add_ids.append(lid) + remove_ids = [] + for name in remove: + lid = existing.get(name.lower()) + if lid is None: + known = ", ".join(sorted(existing)) or "none" + raise GoogleError(f"no label named {name!r} to remove — labels: {known}") + remove_ids.append(lid) + payload: dict = {} + if add_ids: + payload["addLabelIds"] = add_ids + if remove_ids: + payload["removeLabelIds"] = remove_ids + if thread_id: + data = request(creds, "POST", f"{BASE}/threads/{thread_id}/modify", json=payload, client=client) + return {"threadId": thread_id, "modified": len(data.get("messages") or []) or 1, "created": created} + ids = [i for i in (message_ids or []) if i][:1000] + request(creds, "POST", f"{BASE}/messages/batchModify", json={**payload, "ids": ids}, client=client) + return {"modified": len(ids), "created": created} + + +def list_drafts(creds: Creds, max_results: int = 20, *, client=None) -> list[dict]: + listing = request(creds, "GET", f"{BASE}/drafts", + params={"maxResults": min(int(max_results), 100)}, client=client) + out = [] + for d in listing.get("drafts") or []: + full = request(creds, "GET", f"{BASE}/drafts/{d['id']}", params={"format": "metadata"}, client=client) + s = _summary(full.get("message") or {}) + s["draftId"] = d["id"] out.append(s) return out +def update_draft(creds: Creds, draft_id: str, body: str, to: str = "", subject: str = "", *, client=None) -> dict: + """Replace a draft's body (and optionally to/subject), preserving reply headers + and thread. Still a DRAFT — never sends.""" + cur = request(creds, "GET", f"{BASE}/drafts/{draft_id}", + params={"format": "metadata", + "metadataHeaders": ["To", "Subject", "In-Reply-To", "References"]}, client=client) + msg = cur.get("message") or {} + h = msg.get("payload", {}).get("headers", []) + to = to or _header(h, "To") + subject = subject or _header(h, "Subject") + message: dict = {"raw": build_draft_raw(body, to, subject, _header(h, "In-Reply-To"), _header(h, "References"))} + if msg.get("threadId"): + message["threadId"] = msg["threadId"] + d = request(creds, "PUT", f"{BASE}/drafts/{draft_id}", json={"message": message}, client=client) + return {"draftId": d.get("id", draft_id), "to": to, "subject": subject, "sent": False} + + def mark_read(creds: Creds, message_ids: list[str] | None = None, thread_id: str = "", *, client=None) -> dict: """Clear the UNREAD label from specific messages (batchModify) or a whole thread. diff --git a/protoagent.plugin.yaml b/protoagent.plugin.yaml index 0e11f7d..a86f2d9 100644 --- a/protoagent.plugin.yaml +++ b/protoagent.plugin.yaml @@ -1,11 +1,10 @@ id: google name: Google Workspace -version: 0.3.0 +version: 0.4.0 description: >- - Google Workspace for the agent, built to grow. Ships Gmail (read + draft), - Calendar (read), and Drive (read) today — gmail_list_unread, gmail_search, - gmail_get_thread, gmail_create_draft (DRAFT ONLY — never sends), gmail_mark_read, - calendar_list_upcoming, calendar_event_detail, drive_search, drive_read — over a + Google Workspace for the agent, built to grow. Gmail (read, draft-only — never + sends — plus mailbox hygiene: mark read, label, archive, attachments, draft + revision), Calendar (read + free/busy + search), Drive (read) — over a service-agnostic OAuth/REST core (no SDK) so Docs and Sheets are additive modules, not a rewrite. Pull-mode: the agent reads and drafts; a human reviews and sends. Needs a Google OAuth client + refresh token; @@ -17,7 +16,7 @@ enabled: false min_protoagent_version: "0.71.0" # manifest public_paths (#1345) capabilities: network: [oauth2.googleapis.com, gmail.googleapis.com, www.googleapis.com] - filesystem: none + filesystem: workspace (attachment downloads) # Console view (ADR 0026) — connection status + unread mail + upcoming events. # The page is public (browser iframe can't carry a bearer); its data is fetched diff --git a/pyproject.toml b/pyproject.toml index 151742a..71c8434 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "google-plugin" -version = "0.3.0" +version = "0.4.0" description = "Google Workspace plugin for protoAgent — Gmail (read + draft) + Calendar (read) over a service-agnostic OAuth/REST core, built to grow." requires-python = ">=3.11" diff --git a/tests/test_plugin.py b/tests/test_plugin.py index 7dcee35..4f40b3b 100644 --- a/tests/test_plugin.py +++ b/tests/test_plugin.py @@ -68,8 +68,14 @@ def test_register_wires_tools_and_routers(registry): "gmail_get_thread", "gmail_create_draft", "gmail_mark_read", + "gmail_label", + "gmail_get_attachment", + "gmail_list_drafts", + "gmail_update_draft", "calendar_list_upcoming", "calendar_event_detail", + "calendar_availability", + "calendar_search", "drive_search", "drive_read", } diff --git a/tests/test_triage.py b/tests/test_triage.py new file mode 100644 index 0000000..e21406a --- /dev/null +++ b/tests/test_triage.py @@ -0,0 +1,246 @@ +"""v0.4.0 batch — labels/archive, attachments, draft revision, free-busy, event search.""" + +from __future__ import annotations + +import base64 +import json + +import google_plugin as plugin +import httpx +import pytest + +from google_plugin import auth, gcal, gmail +from google_plugin.auth import Creds, GoogleError + +CREDS = Creds("cid", "csecret", "rtok") + +LABELS = { + "labels": [ + {"id": "INBOX", "name": "INBOX", "type": "system"}, + {"id": "UNREAD", "name": "UNREAD", "type": "system"}, + {"id": "Label_7", "name": "Receipts", "type": "user"}, + ] +} + + +def _client(handler): + auth._TOKEN_CACHE.clear() + return httpx.Client(transport=httpx.MockTransport(handler)) + + +def _token_or(handler): + def h(request: httpx.Request) -> httpx.Response: + if request.url.host == "oauth2.googleapis.com": + return httpx.Response(200, json={"access_token": "at", "expires_in": 3600}) + return handler(request) + + return h + + +# ── Labels / archive ────────────────────────────────────────────────────────── + + +def test_modify_labels_resolves_names_and_creates_missing(): + calls = [] + + def handler(request: httpx.Request) -> httpx.Response: + path = request.url.path + if path.endswith("/labels") and request.method == "GET": + return httpx.Response(200, json=LABELS) + if path.endswith("/labels") and request.method == "POST": + body = json.loads(request.read()) + calls.append(("create", body["name"])) + return httpx.Response(200, json={"id": "Label_9", "name": body["name"]}) + if path.endswith("/messages/batchModify"): + calls.append(("batch", json.loads(request.read()))) + return httpx.Response(204) + return httpx.Response(404, json={"error": {"message": path}}) + + with _client(_token_or(handler)) as c: + out = gmail.modify_labels(CREDS, ["m1", "m2"], add=["receipts", "Travel"], remove=["INBOX"], client=c) + assert out == {"modified": 2, "created": ["Travel"]} + assert ("create", "Travel") in calls # "receipts" matched Receipts case-insensitively, not re-created + batch = dict(calls)["batch"] + assert batch == {"addLabelIds": ["Label_7", "Label_9"], "removeLabelIds": ["INBOX"], "ids": ["m1", "m2"]} + + +def test_modify_labels_refuses_trash_and_spam(): + with pytest.raises(GoogleError, match="never deletes"): + gmail.modify_labels(CREDS, ["m1"], add=["Trash"]) + + +def test_modify_labels_unknown_remove_errors_with_hint(): + def handler(request: httpx.Request) -> httpx.Response: + return httpx.Response(200, json=LABELS) + + with _client(_token_or(handler)) as c: + with pytest.raises(GoogleError, match="no label named 'Bogus'.*receipts"): + gmail.modify_labels(CREDS, ["m1"], remove=["Bogus"], client=c) + + +def test_label_tool_archive_flag_and_targets(monkeypatch): + monkeypatch.setattr(plugin, "_CREDS", Creds("c", "s", "r")) + assert "message_ids" in plugin.gmail_label.invoke({"add": ["X"]}) + assert "labels to add" in plugin.gmail_label.invoke({"message_ids": ["m1"]}) + seen = {} + monkeypatch.setattr( + gmail, + "modify_labels", + lambda creds, ids, tid, add, remove, **kw: seen.update(remove=remove) or {"modified": 1, "created": []}, + ) + out = plugin.gmail_label.invoke({"message_ids": ["m1"], "archive": True}) + assert seen["remove"] == ["INBOX"] and "1 message(s)" in out + + +# ── Attachments ─────────────────────────────────────────────────────────────── + + +def test_get_thread_lists_attachments(): + def handler(request: httpx.Request) -> httpx.Response: + return httpx.Response( + 200, + json={ + "messages": [ + { + "id": "m1", + "threadId": "t1", + "snippet": "s", + "payload": { + "headers": [], + "parts": [ + {"mimeType": "text/plain", "body": {"data": base64.urlsafe_b64encode(b"hi").decode()}}, + { + "mimeType": "application/pdf", + "filename": "invoice.pdf", + "body": {"attachmentId": "att-1", "size": 999}, + }, + ], + }, + } + ] + }, + ) + + with _client(_token_or(handler)) as c: + msgs = gmail.get_thread(CREDS, "t1", client=c) + assert msgs[0]["attachments"] == [ + {"filename": "invoice.pdf", "mimeType": "application/pdf", "size": 999, "attachmentId": "att-1"} + ] + + +def test_get_attachment_tool_text_vs_binary(monkeypatch, tmp_path): + monkeypatch.setattr(plugin, "_CREDS", Creds("c", "s", "r")) + monkeypatch.setattr(plugin, "_attachments_dir", lambda: tmp_path) + monkeypatch.setattr(gmail, "get_attachment", lambda creds, mid, aid, **kw: b"col1,col2\n1,2") + out = json.loads( + plugin.gmail_get_attachment.invoke({"message_id": "m1", "attachment_id": "a1", "filename": "data.csv"}) + ) + assert out["content"] == "col1,col2\n1,2" and out["truncated"] is False + + monkeypatch.setattr(gmail, "get_attachment", lambda creds, mid, aid, **kw: b"\x89PNG...") + out = json.loads( + plugin.gmail_get_attachment.invoke({"message_id": "m1", "attachment_id": "a1", "filename": "../evil/logo.png"}) + ) + assert out["savedTo"].startswith(str(tmp_path)) and "evil" not in out["savedTo"] # path-sanitized + assert out["bytes"] == 7 + + +# ── Draft revision ──────────────────────────────────────────────────────────── + + +def test_list_drafts_summarizes(): + def handler(request: httpx.Request) -> httpx.Response: + path = request.url.path + if path.endswith("/drafts"): + return httpx.Response(200, json={"drafts": [{"id": "d1"}]}) + return httpx.Response( + 200, + json={ + "message": { + "id": "m1", + "threadId": "t1", + "snippet": "draft snip", + "payload": {"headers": [{"name": "To", "value": "x@y.com"}, {"name": "Subject", "value": "Hello"}]}, + } + }, + ) + + with _client(_token_or(handler)) as c: + drafts = gmail.list_drafts(CREDS, client=c) + assert drafts[0]["draftId"] == "d1" and drafts[0]["to"] == "x@y.com" and drafts[0]["subject"] == "Hello" + + +def test_update_draft_preserves_headers_and_thread(): + seen = {} + + def handler(request: httpx.Request) -> httpx.Response: + if request.method == "GET": + return httpx.Response( + 200, + json={ + "message": { + "id": "m1", + "threadId": "t1", + "payload": { + "headers": [ + {"name": "To", "value": "bob@x.com"}, + {"name": "Subject", "value": "Re: hi"}, + {"name": "In-Reply-To", "value": ""}, + ] + }, + } + }, + ) + seen["put"] = json.loads(request.read()) + return httpx.Response(200, json={"id": "d1"}) + + with _client(_token_or(handler)) as c: + out = gmail.update_draft(CREDS, "d1", "new body", client=c) + assert out == {"draftId": "d1", "to": "bob@x.com", "subject": "Re: hi", "sent": False} + assert seen["put"]["message"]["threadId"] == "t1" + raw = base64.urlsafe_b64decode(seen["put"]["message"]["raw"].encode()).decode() + assert raw.endswith("new body") and "In-Reply-To: " in raw + + +# ── Calendar: free/busy + search ────────────────────────────────────────────── + + +def test_free_busy_extracts_busy_blocks(): + def handler(request: httpx.Request) -> httpx.Response: + assert request.url.path.endswith("/freeBusy") + body = json.loads(request.read()) + assert body["items"] == [{"id": "primary"}] + return httpx.Response( + 200, + json={ + "calendars": {"primary": {"busy": [{"start": "2026-07-04T09:00:00Z", "end": "2026-07-04T10:00:00Z"}]}} + }, + ) + + with _client(_token_or(handler)) as c: + out = gcal.free_busy(CREDS, days=3, client=c, now_iso="2026-07-03T00:00:00+00:00") + assert out["busy"] == [{"start": "2026-07-04T09:00:00Z", "end": "2026-07-04T10:00:00Z"}] + assert out["timeMin"] == "2026-07-03T00:00:00+00:00" + + +def test_search_events_passes_query_and_window(): + def handler(request: httpx.Request) -> httpx.Response: + params = dict(request.url.params) + assert params["q"] == "dentist" and params["orderBy"] == "startTime" + return httpx.Response( + 200, + json={ + "items": [ + { + "id": "e9", + "summary": "Dentist", + "start": {"dateTime": "2026-07-10T15:00:00Z"}, + "end": {"dateTime": "2026-07-10T16:00:00Z"}, + } + ] + }, + ) + + with _client(_token_or(handler)) as c: + events = gcal.search_events(CREDS, "dentist", client=c, now_iso="2026-07-03T00:00:00+00:00") + assert events[0]["title"] == "Dentist"