Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 6 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
157 changes: 153 additions & 4 deletions __init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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,
]

Expand Down
32 changes: 31 additions & 1 deletion gcal.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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)
Expand Down
105 changes: 104 additions & 1 deletion gmail.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"

Expand Down Expand Up @@ -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]
Expand Down Expand Up @@ -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.

Expand Down
Loading
Loading