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
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,12 @@

**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, and **drafts** — it never sends or auto-replies. A human reviews drafts in the Drafts folder and sends them.
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.

## 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.
- `calendar_list_upcoming(days, calendar_id)` · `calendar_event_detail(event_id, calendar_id)` — read.
- `drive_search(query, max)` · `drive_read(file_id, max_chars)` — read; Docs export as text, Sheets as CSV, Slides as text.

Expand Down
29 changes: 26 additions & 3 deletions __init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +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, and DRAFTS — it never sends or
auto-replies; a human reviews drafts and sends them. Credentials come from plugin
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
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 @@ -130,6 +131,28 @@ 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_mark_read(message_ids: list[str] | None = None, thread_id: str = "") -> str:
"""Mark Gmail messages as read (clears the UNREAD label — nothing is archived,
deleted, or sent). Pass message_ids from gmail_list_unread/gmail_search, OR a
thread_id to mark a whole thread.

Args:
message_ids: message ids to mark read (up to 1000).
thread_id: mark every message in this thread instead.
"""
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."
out = _run(gmail.mark_read, _creds(), message_ids, thread_id)
if isinstance(out, str):
return out
if out.get("threadId"):
return f"Marked thread {out['threadId']} read ({out['marked']} message(s))."
return f"Marked {out['marked']} message(s) read."


# ── Calendar (read-only) ──────────────────────────────────────────────────────

@tool
Expand Down Expand Up @@ -193,7 +216,7 @@ 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_list_unread, gmail_search, gmail_get_thread, gmail_create_draft, gmail_mark_read,
calendar_list_upcoming, calendar_event_detail,
drive_search, drive_read,
]
Expand Down
20 changes: 19 additions & 1 deletion gmail.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
"""Gmail service — read + draft (never send). Built on the shared auth core.
"""Gmail service — read, draft (never send), and mark-read. Built on the shared auth core.

One of several Workspace service modules; mirrors the protoWorkstacean Gmail tools.
Mark-read is the one mailbox mutation beyond drafts: it only clears the UNREAD
label — it never archives, deletes, or sends.
"""

from __future__ import annotations
Expand Down Expand Up @@ -83,6 +85,22 @@ def get_thread(creds: Creds, thread_id: str, *, client=None) -> list[dict]:
return out


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.

The only mutation this performs is removing UNREAD — nothing is archived,
deleted, or sent. Needs the gmail.modify scope.
"""
if thread_id:
data = request(creds, "POST", f"{BASE}/threads/{thread_id}/modify",
json={"removeLabelIds": ["UNREAD"]}, client=client)
return {"threadId": thread_id, "marked": len(data.get("messages") or []) or 1}
ids = [i for i in (message_ids or []) if i][:1000] # batchModify caps at 1000
request(creds, "POST", f"{BASE}/messages/batchModify",
json={"ids": ids, "removeLabelIds": ["UNREAD"]}, client=client) # 204 on success
return {"marked": len(ids)}


def build_draft_raw(body: str, to: str = "", subject: str = "",
in_reply_to: str = "", references: str = "") -> str:
"""RFC-822 → base64url, the shape drafts.create wants."""
Expand Down
4 changes: 2 additions & 2 deletions protoagent.plugin.yaml
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
id: google
name: Google Workspace
version: 0.2.0
version: 0.3.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_get_thread, gmail_create_draft (DRAFT ONLY — never sends), gmail_mark_read,
calendar_list_upcoming, calendar_event_detail, drive_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
Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[project]
name = "google-plugin"
version = "0.2.0"
version = "0.3.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"

Expand Down
9 changes: 9 additions & 0 deletions tests/test_plugin.py
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,7 @@ def test_register_wires_tools_and_routers(registry):
"gmail_search",
"gmail_get_thread",
"gmail_create_draft",
"gmail_mark_read",
"calendar_list_upcoming",
"calendar_event_detail",
"drive_search",
Expand Down Expand Up @@ -103,6 +104,14 @@ def test_draft_tool_requires_target(monkeypatch):
assert "thread_id" in out and "subject" in out


def test_mark_read_tool_requires_target_and_reports_count(monkeypatch):
monkeypatch.setattr(plugin, "_CREDS", Creds("c", "s", "r"))
assert "message_ids" in plugin.gmail_mark_read.invoke({})
monkeypatch.setattr(gmail, "mark_read", lambda creds, ids, tid, **kw: {"marked": len(ids)})
out = plugin.gmail_mark_read.invoke({"message_ids": ["m1", "m2"]})
assert out == "Marked 2 message(s) read."


# ── Console view (the four rules) ─────────────────────────────────────────────


Expand Down
32 changes: 32 additions & 0 deletions tests/test_services.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
from __future__ import annotations

import base64
import json

import httpx
import pytest
Expand Down Expand Up @@ -156,6 +157,37 @@ def test_calendar_list_and_detail(client):
assert detail["organizer"]["email"] == "o@x.com"


def test_mark_read_batch_removes_unread_label():
seen = {}

def handler(request: httpx.Request) -> httpx.Response:
if request.url.host == "oauth2.googleapis.com":
return httpx.Response(200, json={"access_token": "at", "expires_in": 3600})
seen["path"] = request.url.path
seen["body"] = json.loads(request.read().decode())
return httpx.Response(204)

auth._TOKEN_CACHE.clear()
with httpx.Client(transport=httpx.MockTransport(handler)) as c:
out = gmail.mark_read(CREDS, ["m1", "m2", ""], client=c)
assert out == {"marked": 2} # blank id dropped
assert seen["path"].endswith("/messages/batchModify")
assert seen["body"] == {"ids": ["m1", "m2"], "removeLabelIds": ["UNREAD"]}


def test_mark_read_thread_modifies_whole_thread():
def handler(request: httpx.Request) -> httpx.Response:
if request.url.host == "oauth2.googleapis.com":
return httpx.Response(200, json={"access_token": "at", "expires_in": 3600})
assert request.url.path.endswith("/threads/t1/modify")
return httpx.Response(200, json={"id": "t1", "messages": [{"id": "m1"}, {"id": "m2"}]})

auth._TOKEN_CACHE.clear()
with httpx.Client(transport=httpx.MockTransport(handler)) as c:
out = gmail.mark_read(CREDS, thread_id="t1", client=c)
assert out == {"threadId": "t1", "marked": 2}


# ── Error surfacing + 401 retry ───────────────────────────────────────────────


Expand Down
Loading