From 9fe13b3d89907e37f0ead8e8567ae3d89d54b675 Mon Sep 17 00:00:00 2001 From: Alexey Grigorev Date: Wed, 4 Nov 2026 17:37:00 +0100 Subject: [PATCH 1/6] fix(google): preserve reply drafts and complete explicit-account CLI behavior --- dapier_cli/cli/calendar.py | 18 +++---- dapier_cli/cli/gdrive.py | 16 +++---- dapier_cli/cli/gmail.py | 63 ++++++++++++++----------- dapier_cli/commands/calendar.py | 5 +- dapier_cli/commands/gdrive.py | 32 ++++++------- dapier_cli/commands/gmail.py | 50 ++++++++++++++------ plugins/google/README.md | 49 +++++++++++++++++-- src/dapier/api/agent/calendar_ops.py | 62 +++++++++++------------- src/dapier/api/agent/gdrive_ops.py | 9 +++- src/dapier/api/agent/gmail.py | 3 +- src/dapier/api/agent/gmail_ops.py | 47 +++++++++++++----- src/dapier/api/agent/google_accounts.py | 10 ++-- tests/test_calendar_agent_cli.py | 30 +++++++++++- tests/test_gmail_gdrive_cli.py | 57 +++++++++++++++++++++- tests/test_gmail_gdrive_ops_cli.py | 38 +++++++++++++++ tests/test_google_account_selection.py | 6 +-- 16 files changed, 351 insertions(+), 144 deletions(-) diff --git a/dapier_cli/cli/calendar.py b/dapier_cli/cli/calendar.py index bb05aba..ed4af23 100644 --- a/dapier_cli/cli/calendar.py +++ b/dapier_cli/cli/calendar.py @@ -9,16 +9,16 @@ EPILOG = """examples: dapier calendar accounts # list Google accounts serving Calendar dapier calendar calendars --account me@gmail.com - dapier calendar events --time-min 2026-11-04T00:00:00Z --time-max 2026-11-11T00:00:00Z + dapier calendar events --time-min 2026-11-04T00:00:00Z --time-max 2026-11-11T00:00:00Z --account me@gmail.com dapier calendar freebusy --calendar primary --calendar work@group.calendar.google.com \\ --time-min 2026-11-04T00:00:00Z --time-max 2026-11-05T00:00:00Z dapier calendar create --summary "Sync" --start 2026-11-05T09:00:00Z \\ --end 2026-11-05T10:00:00Z --attendee peer@example.com - dapier calendar create ... --send-updates all # default none: no guest email - dapier calendar create ... --preview # review the JSON, then run without --preview - dapier calendar delete EVENT_ID ---account takes the verified email or the connection id; when several Google -accounts serve Calendar it is required. Event windows are bounded + dapier calendar create ... --send-updates all --account me@gmail.com # default none: no guest email + dapier calendar create ... --preview --account me@gmail.com # review the JSON, then run without --preview + dapier calendar delete EVENT_ID --account me@gmail.com +--account is required on every operation except accounts; use the verified +email or connection id. Event windows are bounded (<= 366 days). Writes never invite guests unless --send-updates says so; --preview prints the exact request body without sending anything.""" @@ -28,7 +28,7 @@ def _write_args(p): p.add_argument("--calendar", default=None, help="Calendar id (default primary)") - p.add_argument("--account") + p.add_argument("--account", required=True) p.add_argument("--summary", default=None) p.add_argument("--start", default=None, help="ISO 8601 start (or date for all-day)") p.add_argument("--end", default=None, help="ISO 8601 end (or date for all-day)") @@ -66,7 +66,7 @@ def register(sub): acc.add_argument("--json", action="store_true") def base_args(x): - x.add_argument("--account") + x.add_argument("--account", required=True) cals = sp.add_parser("calendars", help="List the account's calendars") base_args(cals) @@ -108,7 +108,7 @@ def base_args(x): delete = sp.add_parser("delete", help="Delete an event (guests notified per --send-updates)") delete.add_argument("event_id") delete.add_argument("--calendar", default=None) - delete.add_argument("--account") + delete.add_argument("--account", required=True) delete.add_argument("--send-updates", default="none", choices=["all", "externalOnly", "none"]) delete.add_argument("--json", action="store_true") diff --git a/dapier_cli/cli/gdrive.py b/dapier_cli/cli/gdrive.py index 0b9978c..e0b5dc9 100644 --- a/dapier_cli/cli/gdrive.py +++ b/dapier_cli/cli/gdrive.py @@ -8,13 +8,13 @@ EPILOG = """examples: dapier gdrive accounts # list Google accounts serving Drive dapier gdrive list --account me@gmail.com --max 20 - dapier gdrive search "name contains 'invoice'" - dapier gdrive download FILE_ID --out ./report.pdf - dapier gdrive upload ./notes.txt --name notes.txt --folder FOLDER_ID - dapier gdrive share FILE_ID --role reader --with user --email peer@example.com - dapier gdrive trash FILE_ID # recoverable; --permanent destroys ---account takes the verified email or the connection id; when several Google -accounts serve Drive it is required, and an ambiguous fragment is rejected. + dapier gdrive search "name contains 'invoice'" --account me@gmail.com + dapier gdrive download FILE_ID --out ./report.pdf --account me@gmail.com + dapier gdrive upload ./notes.txt --name notes.txt --folder FOLDER_ID --account me@gmail.com + dapier gdrive share FILE_ID --role reader --with user --email peer@example.com --account me@gmail.com + dapier gdrive trash FILE_ID --account me@gmail.com # recoverable; --permanent destroys +--account is required on every operation except accounts; use the verified +email or connection id. Ambiguous fragments are rejected. Google-native docs export on download (doc/slide → PDF, sheet → CSV, drawing → PNG; override with --export-mime).""" @@ -40,7 +40,7 @@ def register(sub): acc.add_argument("--json", action="store_true") def account_arg(x): - x.add_argument("--account") + x.add_argument("--account", required=True) listing = sp.add_parser("list", help="List files, newest first") listing.add_argument("--query", default=None, help="Extra Drive query, e.g. name contains 'x'") diff --git a/dapier_cli/cli/gmail.py b/dapier_cli/cli/gmail.py index a1de9fd..01a993d 100644 --- a/dapier_cli/cli/gmail.py +++ b/dapier_cli/cli/gmail.py @@ -8,14 +8,14 @@ EPILOG = """examples: dapier gmail accounts # list Google accounts (email + connection id) dapier gmail search "from:boss is:unread" --account me@gmail.com - dapier gmail read MSG_ID --save-attachments ./att - dapier gmail send --to a@b.c --subject "Hi" --file body.txt --attach report.pdf - dapier gmail reply MSG_ID --text "On it" # direct send in the original thread - dapier gmail reply MSG_ID --draft # park it as an editable reply draft - dapier gmail draft create --to a@b.c --subject "Invoice" --text "..." --attach inv.pdf - dapier gmail draft send DRAFT_ID ---account takes the verified email or the connection id; when several Google -accounts serve Gmail it is required, and an ambiguous fragment is rejected.""" + dapier gmail read MSG_ID --save-attachments ./att --account me@gmail.com + dapier gmail send --to a@b.c --subject "Hi" --file body.txt --attach report.pdf --account me@gmail.com + dapier gmail reply MSG_ID --text "On it" --account me@gmail.com # direct send in the original thread + dapier gmail reply MSG_ID --draft --account me@gmail.com # park it as an editable reply draft + dapier gmail draft create --to a@b.c --subject "Invoice" --text "..." --attach inv.pdf --account me@gmail.com + dapier gmail draft send DRAFT_ID --account me@gmail.com +--account is required on every operation except accounts; use the verified +email or connection id. Ambiguous fragments are rejected.""" COMMANDS = ("accounts", "search", "list", "read", "attachment", "threads", "thread", "labels", "mark", "send", "reply", "draft") @@ -63,7 +63,7 @@ def register(sub): read = sub_p.add_parser("read", help="Read one message (headers, body, attachments)") read.add_argument("message_id") - read.add_argument("--account") + read.add_argument("--account", required=True) read.add_argument("--html", action="store_true", help="Include the HTML part") read.add_argument("--json", action="store_true") read.add_argument("--save-attachments", default=None, metavar="DIR", @@ -72,7 +72,7 @@ def register(sub): att = sub_p.add_parser("attachment", help="Save one attachment by id") att.add_argument("message_id") att.add_argument("attachment_id") - att.add_argument("--account") + att.add_argument("--account", required=True) att.add_argument("--out", default=None, help="Output path (default: the filename)") threads = sub_p.add_parser("threads", help="List threads (optionally searched)") @@ -81,20 +81,20 @@ def register(sub): thread = sub_p.add_parser("thread", help="Read one thread with all messages") thread.add_argument("thread_id") - thread.add_argument("--account") + thread.add_argument("--account", required=True) thread.add_argument("--json", action="store_true") labels = sub_p.add_parser("labels", help="List the account's labels") - labels.add_argument("--account") + labels.add_argument("--account", required=True) labels.add_argument("--json", action="store_true") mark = sub_p.add_parser("mark", help="Mark read/unread, archive, trash, label") mark.add_argument("message_id") - mark.add_argument("--account") + mark.add_argument("--account", required=True) mode = mark.add_mutually_exclusive_group() mode.add_argument("--read", action="store_true") mode.add_argument("--unread", action="store_true") - mode.add_argument("--archive", action="store_true") + mark.add_argument("--archive", action="store_true") mode.add_argument("--trash", action="store_true") mode.add_argument("--untrash", action="store_true") mark.add_argument("--add-label", action="append", default=[]) @@ -107,7 +107,10 @@ def register(sub): reply = sub_p.add_parser("reply", help="Reply to a message in its thread (send or --draft)") reply.add_argument("message_id") - reply.add_argument("--account") + reply.add_argument("--account", required=True) + reply.add_argument("--to", help="Explicit recipients instead of the inferred reply recipients") + reply.add_argument("--cc", help="Explicit Cc recipients") + reply.add_argument("--bcc", help="Explicit Bcc recipients") reply.add_argument("--all", dest="reply_all", action="store_true", help="Reply to all original recipients") reply.add_argument("--draft", action="store_true", @@ -123,38 +126,41 @@ def register(sub): dupdate = draft_sp.add_parser("update", help="Replace a draft's content") dupdate.add_argument("draft_id") - _recipient_args(dupdate) + _recipient_args(dupdate, required=False) _common_message(dupdate) dlist = draft_sp.add_parser("list", help="List drafts") - dlist.add_argument("--account") + dlist.add_argument("--account", required=True) dlist.add_argument("--json", action="store_true") + dlist.add_argument("--max", type=int, default=25) + dlist.add_argument("--page-token") dget = draft_sp.add_parser("get", help="Show one draft") dget.add_argument("draft_id") - dget.add_argument("--account") + dget.add_argument("--account", required=True) dget.add_argument("--json", action="store_true") ddelete = draft_sp.add_parser("delete", help="Delete a draft") ddelete.add_argument("draft_id") - ddelete.add_argument("--account") + ddelete.add_argument("--account", required=True) dsend = draft_sp.add_parser("send", help="Send an existing draft") dsend.add_argument("draft_id") - dsend.add_argument("--account") + dsend.add_argument("--account", required=True) dsend.add_argument("--json", action="store_true") def _list_args(p): - p.add_argument("--account") + p.add_argument("--account", required=True) p.add_argument("--max", type=int, default=25) + p.add_argument("--json", action="store_true") p.add_argument("--label", default=None, help="Filter by label id, e.g. INBOX") p.add_argument("--page-token", default=None) -def _recipient_args(p): - p.add_argument("--account") - p.add_argument("--to", required=True) +def _recipient_args(p, required=True): + p.add_argument("--account", required=True) + p.add_argument("--to", required=required) p.add_argument("--cc", default=None) p.add_argument("--bcc", default=None) p.add_argument("--subject", default=None) @@ -167,10 +173,10 @@ def run(args, api_url, debug, child=None): return c.gmail_accounts(api_url, as_json=args.json, debug=debug) if args.command == "search": return c.gmail_search(api_url, args.query, account=account, max_results=args.max, - label=args.label, page_token=args.page_token, debug=debug) + label=args.label, page_token=args.page_token, as_json=args.json, debug=debug) if args.command == "list": return c.gmail_list(api_url, account=account, max_results=args.max, - label=args.label, page_token=args.page_token, debug=debug) + label=args.label, page_token=args.page_token, as_json=args.json, debug=debug) if args.command == "read": return c.gmail_read(api_url, args.message_id, account=account, include_html=args.html, as_json=args.json, save_attachments=args.save_attachments, debug=debug) @@ -202,7 +208,7 @@ def run(args, api_url, debug, child=None): if args.command == "reply": return c.gmail_reply(api_url, args.message_id, account=account, reply_all=args.reply_all, text=args.text, file=args.file, attach=args.attach, - draft=args.draft, as_json=args.json, + draft=args.draft, to=args.to, cc=args.cc, bcc=args.bcc, as_json=args.json, preview=getattr(args, "preview", False), debug=debug) if args.command == "draft": return _run_draft(args, api_url, account, debug) @@ -213,7 +219,8 @@ def _run_draft(args, api_url, account, debug): c = commands name = args.draft_command if name == "list": - return c.gmail_draft_list(api_url, account=account, as_json=args.json, debug=debug) + return c.gmail_draft_list(api_url, account=account, as_json=args.json, debug=debug, + max_results=args.max, page_token=args.page_token) if name == "get": return c.gmail_draft_get(api_url, args.draft_id, account=account, as_json=args.json, debug=debug) if name == "delete": diff --git a/dapier_cli/commands/calendar.py b/dapier_cli/commands/calendar.py index bdfd4a1..fc16d02 100644 --- a/dapier_cli/commands/calendar.py +++ b/dapier_cli/commands/calendar.py @@ -21,7 +21,7 @@ def _account_params(account, extra=None): params = dict(extra or {}) if account: params["account"] = account - return "?" + urlencode(params) if params else {} + return "?" + urlencode(params) if params else "" def calendar_accounts(api_url, as_json=False, debug=False): @@ -143,7 +143,8 @@ def calendar_freebusy(api_url, account=None, calendar_ids=None, time_min=None, def _write_body(account, calendar, send_updates, fields): - body = {key: value for key, value in (fields or {}).items() if value} + body = {({"timezone_name": "timezone", "payload": "event"}.get(key, key)): value + for key, value in (fields or {}).items() if value is not None and value != []} if account: body["account"] = account if calendar: diff --git a/dapier_cli/commands/gdrive.py b/dapier_cli/commands/gdrive.py index d400892..f600e93 100644 --- a/dapier_cli/commands/gdrive.py +++ b/dapier_cli/commands/gdrive.py @@ -112,7 +112,7 @@ def gdrive_download(api_url, file_id, account=None, out=None, export_mime=None, + _account_params(account, {"export_mime": export_mime} if export_mime else None), debug=debug) data_bytes = base64.b64decode(data.get("data_b64") or "") - target = out or data.get("name") or f"drive-{file_id}" + target = out or os.path.basename(data.get("name") or "") or f"drive-{file_id}" with open(target, "wb") as handle: handle.write(data_bytes) print(f"saved {len(data_bytes)} bytes to {target} " @@ -130,12 +130,12 @@ def gdrive_upload(api_url, path, name=None, account=None, folder=None, mime=None "mime_type": mime or mimetypes.guess_type(filename)[0] or "application/octet-stream", } - if preview: - return preview_body(body) if account: body["account"] = account if folder: body["folder_id"] = folder + if preview: + return preview_body(body) data = api.call(api_url, "POST", BASE + "/files", body=body, debug=debug) if as_json: print_json(data) @@ -151,10 +151,6 @@ def gdrive_update(api_url, file_id, account=None, name=None, file=None, mime=Non body = {} if preview and not (name or file or mime): raise api.ApiError("update needs a new name and/or --file content to preview") - if file: - body["content_b64"] = base64.b64encode(_read_bytes(file)).decode("ascii") - if preview: - return preview_body(body) if account: body["account"] = account if name: @@ -163,6 +159,8 @@ def gdrive_update(api_url, file_id, account=None, name=None, file=None, mime=Non body["mime_type"] = mime if file: body["content_b64"] = base64.b64encode(_read_bytes(file)).decode("ascii") + if preview: + return preview_body(body) data = api.call(api_url, "PATCH", BASE + f"/files/{file_id}" + _account_params(account), body=body, debug=debug) if as_json: @@ -175,10 +173,10 @@ def gdrive_update(api_url, file_id, account=None, name=None, file=None, mime=Non def gdrive_copy(api_url, file_id, account=None, name=None, as_json=False, preview=False, debug=False): body = {"account": account} if account else {} - if preview: - return preview_body(body) if name: body["name"] = name + if preview: + return preview_body(body) data = api.call(api_url, "POST", BASE + f"/files/{file_id}/copy" + _account_params(account), body=body, debug=debug) if as_json: @@ -191,14 +189,14 @@ def gdrive_copy(api_url, file_id, account=None, name=None, as_json=False, previe def gdrive_move(api_url, file_id, account=None, add_folder=None, remove_folder=None, as_json=False, preview=False, debug=False): body = {} - if preview: - return preview_body(body) if account: body["account"] = account if add_folder: body["add_parent"] = add_folder if remove_folder: body["remove_parent"] = remove_folder + if preview: + return preview_body(body) data = api.call(api_url, "POST", BASE + f"/files/{file_id}/move" + _account_params(account), body=body, debug=debug) if as_json: @@ -211,12 +209,12 @@ def gdrive_move(api_url, file_id, account=None, add_folder=None, remove_folder=N def gdrive_share(api_url, file_id, account=None, role="reader", share_type="user", email=None, as_json=False, preview=False, debug=False): body = {"role": role, "share_type": share_type} - if preview: - return preview_body(body) if account: body["account"] = account if email: body["email_address"] = email + if preview: + return preview_body(body) data = api.call(api_url, "POST", BASE + f"/files/{file_id}/share" + _account_params(account), body=body, debug=debug) if as_json: @@ -231,10 +229,10 @@ def gdrive_share(api_url, file_id, account=None, role="reader", share_type="user def gdrive_trash(api_url, file_id, account=None, permanent=False, as_json=False, preview=False, debug=False): body = {"permanent": bool(permanent)} - if preview: - return preview_body(body) if account: body["account"] = account + if preview: + return preview_body(body) data = api.call(api_url, "POST", BASE + f"/files/{file_id}/trash" + _account_params(account), body=body, debug=debug) if as_json: @@ -250,12 +248,12 @@ def gdrive_trash(api_url, file_id, account=None, permanent=False, as_json=False, def gdrive_mkdir(api_url, name, account=None, parent=None, as_json=False, preview=False, debug=False): body = {"name": name} - if preview: - return preview_body(body) if account: body["account"] = account if parent: body["parent_id"] = parent + if preview: + return preview_body(body) data = api.call(api_url, "POST", BASE + "/folders", body=body, debug=debug) if as_json: print_json(data) diff --git a/dapier_cli/commands/gmail.py b/dapier_cli/commands/gmail.py index d14cbe6..8230e8b 100644 --- a/dapier_cli/commands/gmail.py +++ b/dapier_cli/commands/gmail.py @@ -78,20 +78,20 @@ def gmail_accounts(api_url, as_json=False, debug=False): def gmail_search(api_url, query, account=None, max_results=25, label=None, - page_token=None, debug=False): + page_token=None, as_json=False, debug=False): return _list_messages(api_url, query=query, account=account, max_results=max_results, label=label, - page_token=page_token, debug=debug) + page_token=page_token, as_json=as_json, debug=debug) def gmail_list(api_url, account=None, max_results=25, label=None, - page_token=None, debug=False): + page_token=None, as_json=False, debug=False): return _list_messages(api_url, query=None, account=account, max_results=max_results, label=label, - page_token=page_token, debug=debug) + page_token=page_token, as_json=as_json, debug=debug) -def _list_messages(api_url, query, account, max_results, label, page_token, debug): +def _list_messages(api_url, query, account, max_results, label, page_token, as_json, debug): params = {"max": int(max_results)} if query: params["q"] = query @@ -103,6 +103,9 @@ def _list_messages(api_url, query, account, max_results, label, page_token, debu params["page_token"] = page_token data = api.call(api_url, "GET", BASE + "/messages?" + urlencode(params), debug=debug) messages = data.get("messages") or [] + if as_json: + print_json(data) + return 0 print(f"{'ID':<20} {'FROM':<32} {'DATE':<26} SUBJECT") for item in messages: print(f"{str(item.get('id') or ''):<20} " @@ -162,6 +165,11 @@ def gmail_read(api_url, message_id, account=None, include_html=False, as_json=Fa return 0 +def _decode_attachment(value): + value = str(value or "") + return base64.urlsafe_b64decode(value + "=" * (-len(value) % 4)) + + def _save_all_attachments(api_url, message, directory, account, debug): os.makedirs(directory, exist_ok=True) params = {"account": account} if account else None @@ -172,7 +180,7 @@ def _save_all_attachments(api_url, message, directory, account, debug): blob = api.call(api_url, "GET", BASE + f"/messages/{message['id']}" f"/attachments/{attachment_id}" + ("?" + urlencode(params) if params else ""), debug=debug) - data = base64.b64decode(blob.get("data_b64") or "") + data = _decode_attachment(blob.get("data_b64")) filename = item.get("filename") or f"attachment-{attachment_id}" with open(os.path.join(directory, os.path.basename(filename)), "wb") as handle: handle.write(data) @@ -182,7 +190,7 @@ def gmail_attachment(api_url, message_id, attachment_id, account=None, out=None, params = {"account": account} if account else None blob = api.call(api_url, "GET", BASE + f"/messages/{message_id}/attachments/{attachment_id}" + ("?" + urlencode(params) if params else ""), debug=debug) - data = base64.b64decode(blob.get("data_b64") or "") + data = _decode_attachment(blob.get("data_b64")) target = out or f"attachment-{attachment_id}" with open(target, "wb") as handle: handle.write(data) @@ -281,8 +289,6 @@ def gmail_send(api_url, account=None, to=None, cc=None, bcc=None, subject=None, "subject": subject, "attachments": _attachments(attach), } - if preview: - return preview_body(body) if account: body["account"] = account if cc: @@ -292,6 +298,8 @@ def gmail_send(api_url, account=None, to=None, cc=None, bcc=None, subject=None, text = _body_text(text, file) if text: body["text"] = text + if preview: + return preview_body(body) data = api.call(api_url, "POST", BASE + "/send", body=body, debug=debug) _print_sent(data, as_json) return 0 @@ -299,23 +307,29 @@ def gmail_send(api_url, account=None, to=None, cc=None, bcc=None, subject=None, def gmail_reply(api_url, message_id, account=None, reply_all=False, text=None, file=None, attach=None, draft=False, as_json=False, preview=False, - debug=False): + debug=False, to=None, cc=None, bcc=None): body = {"message_id": message_id, "reply_all": bool(reply_all), "draft": bool(draft), "attachments": _attachments(attach)} - if preview: - return preview_body(body) + for name, value in (("to", to), ("cc", cc), ("bcc", bcc)): + if value is not None: + body[name] = value if account: body["account"] = account text = _body_text(text, file) if text: body["text"] = text + if preview: + return preview_body(body) data = api.call(api_url, "POST", BASE + "/reply", body=body, debug=debug) _print_sent(data, as_json) return 0 -def gmail_draft_list(api_url, account=None, as_json=False, debug=False): - params = {} +def gmail_draft_list(api_url, account=None, as_json=False, debug=False, + max_results=25, page_token=None): + params = {"max": max_results} + if page_token: + params["page_token"] = page_token if account: params["account"] = account data = api.call(api_url, "GET", BASE + "/drafts?" + urlencode(params), debug=debug) @@ -329,6 +343,8 @@ def gmail_draft_list(api_url, account=None, as_json=False, debug=False): f"{str(item.get('to') or ''):<36} " f"{str(item.get('subject') or '(no subject)')[:60]}") print(f"({data.get('count') or 0} drafts)") + if data.get("next_page_token"): + print(f"next page: --page-token {data['next_page_token']}") return 0 @@ -388,6 +404,9 @@ def gmail_draft_create(api_url, account=None, to=None, cc=None, bcc=None, data = api.call(api_url, "POST", BASE + "/drafts", body=_draft_body(account, to, cc, bcc, subject, text, file, attach), debug=debug) + if as_json: + print_json(data) + return 0 print(f"ok: draft {data.get('id')} created " f"({'send it: dapier gmail draft send ' + str(data.get('id'))})") return 0 @@ -403,5 +422,8 @@ def gmail_draft_update(api_url, draft_id, account=None, to=None, cc=None, + ("?" + urlencode(params) if params else ""), body=_draft_body(account, to, cc, bcc, subject, text, file, attach), debug=debug) + if as_json: + print_json(data) + return 0 print(f"ok: draft {data.get('id')} updated") return 0 diff --git a/plugins/google/README.md b/plugins/google/README.md index e5c3e42..6f6a261 100644 --- a/plugins/google/README.md +++ b/plugins/google/README.md @@ -268,16 +268,16 @@ destroys), `restore` (un-trash), `mkdir`. required, ≤ 366 days), `event`, `freebusy`, and `create`/`update`/`delete` (summary/start/end, timezone, attendees, `--recurse` RRULEs, reminder overrides, attachment links, all-day form, or a reviewed JSON event -resource via `--event-file`). Guest email is never accidental: every write -carries Google's `sendUpdates` and defaults to `none` — invitations only -go out when `--send-updates all|externalOnly` is named. +resource via `--event-file`). Every write carries Google's `sendUpdates` and defaults to `none`; use +`--send-updates all|externalOnly` to request guest notifications. Google may +still send certain emails even with `none`; see its API documentation. -Write verbs on all three nouns take `--preview`: print the exact JSON +Composition, Drive upload/copy/move/share/trash/mkdir, and Calendar write verbs take `--preview`: print the exact JSON request body (attachment/file payloads shown as sizes, not bytes) and exit — nothing is sent until the command is re-run without `--preview`. **Account selection:** every operation takes `--account `. -With several Google accounts the choice is required; an omitted or +The choice is required even with one account; an omitted or ambiguous selection is rejected with the candidate list — the CLI never silently defaults a write to the wrong account. @@ -711,3 +711,42 @@ video or Slack notification. Renewal runs every four days, leaving one day of margin against the observed five-day hub lease. The renewal Lambda reads the four live workflow/trigger tables with scoped read-only permissions. + +### Google CLI follow-up behavior + +Every operation except `accounts` requires `--account EMAIL_OR_CONNECTION_ID`, +even with one connected account. Gmail `list` and `search` support `--json`; +`draft list --max N --page-token TOKEN` exposes bounded draft pagination. + +`gmail reply MESSAGE_ID --draft --account EMAIL --text "Reply" --attach file.pdf` +creates a reply draft. Edit it with +`gmail draft update DRAFT_ID --account EMAIL --file revised.txt --attach revised.pdf`; +its thread, reply headers, recipients and subject are retained unless replaced. +Draft body and attachments are replaced in full; include every attachment you +want to keep. `draft get --json` includes text and attachment metadata. +`gmail reply MESSAGE_ID --account EMAIL --file reply.txt` sends directly; +`gmail draft send DRAFT_ID --account EMAIL` sends an existing draft. Reply-all +uses parsed To/Cc addresses and excludes the sender account and Bcc recipients. + +Calendar updates accept partial fields, e.g. +`calendar update EVENT_ID --account EMAIL --summary "New title"`. Windows +require RFC3339 timestamps with timezone offsets; all-day event resources still +use dates. Reviewed JSON goes through `--event-file FILE` (or `-` for stdin). +Attachment links set the provider's supportsAttachments flag. The CLI passes +`sendUpdates=none` by default; choose `--send-updates all|externalOnly` to request +notifications. Google's notification behavior still applies; do not treat +`none` as a guarantee that Google can never send any email. + +Previews show supplied account, body and other parameters; binary content is +represented by its size. Drive metadata-only updates use the standard files +endpoint; content updates support replacing a file with an empty file. + +Limits: files and attachments travel as base64 JSON through the API and must fit +its request/response limits; resumable large-file uploads are not implemented. +Drive sharing creates a permission; permission listing, editing and removal are +not exposed yet. Raw Gmail `.eml` and multi-tab Sheets CSV export remain in the +read-only `google-data-pull/gpull.py` helper. + +Provider references: [Gmail threading](https://developers.google.com/workspace/gmail/api/guides/threads), +[Drive metadata updates](https://developers.google.com/workspace/drive/api/guides/manage-uploads), +[Calendar attachment and notification parameters](https://developers.google.com/workspace/calendar/api/v3/reference/events/insert). diff --git a/src/dapier/api/agent/calendar_ops.py b/src/dapier/api/agent/calendar_ops.py index 6d93566..01b45d5 100644 --- a/src/dapier/api/agent/calendar_ops.py +++ b/src/dapier/api/agent/calendar_ops.py @@ -125,23 +125,17 @@ def _bounded_window(time_min, time_max): start = _parse_moment(time_min, "time_min") end = _parse_moment(time_max, "time_max") try: - fmt = "%Y-%m-%d" if ("T" not in start and "T" not in end) else None - if fmt: - lower = datetime.strptime(start, fmt) - upper = datetime.strptime(end, fmt) - else: - _start = start[:-1] + "+00:00" if start.endswith("Z") else start - _end = end[:-1] + "+00:00" if end.endswith("Z") else end - lower = datetime.fromisoformat(_start) - upper = datetime.fromisoformat(_end) + lower = datetime.fromisoformat(start.replace("Z", "+00:00")) + upper = datetime.fromisoformat(end.replace("Z", "+00:00")) + if lower.tzinfo is None or upper.tzinfo is None: + raise ValueError("timezone missing") except ValueError: - lower, upper = None, None - if lower and upper: - if lower >= upper: - raise CalendarOpsError("time_min must come before time_max") - if upper - lower > timedelta(days=MAX_WINDOW_DAYS): - raise CalendarOpsError( - f"the window exceeds {MAX_WINDOW_DAYS} days; narrow time_min/time_max") + raise CalendarOpsError("time_min/time_max must be RFC3339 datetimes with timezone offsets") from None + if lower >= upper: + raise CalendarOpsError("time_min must come before time_max") + if upper - lower > timedelta(days=MAX_WINDOW_DAYS): + raise CalendarOpsError( + f"the window exceeds {MAX_WINDOW_DAYS} days; narrow time_min/time_max") return start, end @@ -281,7 +275,7 @@ def freebusy(account, calendar_ids=None, time_min=None, time_max=None): def _event_body(summary=None, start=None, end=None, timezone_name=None, description=None, location=None, attendees=None, recurrence=None, - reminders=None, attachments=None, all_day=False, payload=None): + reminders=None, attachments=None, all_day=False, payload=None, partial=False): """The event resource: a reviewed raw payload, or structured fields.""" if payload is not None: if any((summary, start, end, description, location, attendees, recurrence, @@ -291,20 +285,18 @@ def _event_body(summary=None, start=None, end=None, timezone_name=None, if not isinstance(payload, dict) or not payload: raise CalendarOpsError("event payload must be a non-empty JSON object") return payload - if not summary: - raise CalendarOpsError("a summary is required") - if not start or not end: - raise CalendarOpsError("start and end are required (ISO 8601)") - if all_day: - start_block = {"date": str(start)[:10]} - end_block = {"date": str(end)[:10]} - else: - start_block = {"dateTime": str(start)} - end_block = {"dateTime": str(end)} - if timezone_name: - start_block["timeZone"] = timezone_name - end_block["timeZone"] = timezone_name - body = {"summary": summary, "start": start_block, "end": end_block} + if not partial and (not summary or not start or not end): + raise CalendarOpsError("summary, start and end are required") + body = {} + if summary is not None: + body["summary"] = summary + for key, value in (("start", start), ("end", end)): + if value is not None: + _parse_moment(value, key) + block = {"date" if all_day else "dateTime": str(value)} + if timezone_name and not all_day: + block["timeZone"] = timezone_name + body[key] = block if description: body["description"] = description if location: @@ -322,13 +314,15 @@ def _event_body(summary=None, start=None, end=None, timezone_name=None, for entry in attachments if isinstance(entry, dict) and str(entry.get("file_url") or entry.get("fileUrl") or "").strip() ] + if not body: + raise CalendarOpsError("update needs at least one field") return body def create_event(account, calendar_id=DEFAULT_CALENDAR_ID, send_updates="none", **fields): _connection_id, _connection, token = _connection_and_token(account) body = _event_body(**fields) - params = {"sendUpdates": _check_send_updates(send_updates)} + params = {"sendUpdates": _check_send_updates(send_updates), "supportsAttachments": "true"} data = _call(token, "POST", _events_url(calendar_id, "", params), body) return _event_view(data) @@ -338,8 +332,8 @@ def update_event(account, event_id, calendar_id=DEFAULT_CALENDAR_ID, send_update """PATCH one event; only the given structured fields change (a reviewed raw payload replaces via the same PATCH semantics).""" _connection_id, _connection, token = _connection_and_token(account) - body = _event_body(**fields) - params = {"sendUpdates": _check_send_updates(send_updates)} + body = _event_body(partial=True, **fields) + params = {"sendUpdates": _check_send_updates(send_updates), "supportsAttachments": "true"} url = _events_url(calendar_id, f"/{quote(str(event_id), safe='')}", params) data = _call(token, "PATCH", url, body) return _event_view(data) diff --git a/src/dapier/api/agent/gdrive_ops.py b/src/dapier/api/agent/gdrive_ops.py index 5824f84..eec1659 100644 --- a/src/dapier/api/agent/gdrive_ops.py +++ b/src/dapier/api/agent/gdrive_ops.py @@ -282,14 +282,19 @@ def update(account, file_id, name=None, content_b64=None, mime_type=None): metadata["name"] = str(name).strip() if mime_type: metadata["mimeType"] = mime_type - content = _b64_decode(content_b64) if content_b64 else None + content = _b64_decode(content_b64) if content_b64 is not None else None if content is None and not metadata: raise GDriveOpsError("update needs a new name and/or content") quoted = quote(str(file_id), safe="") url = (f"{DRIVE_UPLOAD_URL}/{quoted}?" + urlencode({ "uploadType": "multipart", "supportsAllDrives": "true", "fields": FILE_FIELDS})) - data = _upload_call(token, "PATCH", url, metadata, content) + if content is None: + url = f"{DRIVE_FILES_URL}/{quoted}?" + urlencode({ + "supportsAllDrives": "true", "fields": FILE_FIELDS}) + data = _call(token, "PATCH", url, metadata) + else: + data = _upload_call(token, "PATCH", url, metadata, content) return _file_view(data) diff --git a/src/dapier/api/agent/gmail.py b/src/dapier/api/agent/gmail.py index 3b7e833..60537de 100644 --- a/src/dapier/api/agent/gmail.py +++ b/src/dapier/api/agent/gmail.py @@ -196,7 +196,8 @@ def reply_api(event): reply_all=bool(body.get("reply_all")), text=body.get("text"), html=body.get("html"), attachments=body.get("attachments"), - draft=bool(body.get("draft"))) + draft=bool(body.get("draft")), to=body.get("to"), + cc=body.get("cc"), bcc=body.get("bcc")) except (gmail_ops.GmailOpsError, GoogleAccountError) as exc: return _error(exc) _audit(payload.get("account") or "gmail", diff --git a/src/dapier/api/agent/gmail_ops.py b/src/dapier/api/agent/gmail_ops.py index f803e54..1646274 100644 --- a/src/dapier/api/agent/gmail_ops.py +++ b/src/dapier/api/agent/gmail_ops.py @@ -20,6 +20,7 @@ import base64 import re +from email.utils import getaddresses from urllib.parse import quote, urlencode from ...connections import discovery as provider @@ -189,6 +190,8 @@ def _draft_view(data): "message_id": message.get("id"), "thread_id": message.get("threadId"), **headers, + "text": _part_texts(payload)[0], + "attachments": _attachments_of(payload), } @@ -348,9 +351,9 @@ def _parse_addresses(value): if value in (None, "", []): return [] if isinstance(value, str): - return [address.strip() for address in value.split(",") if address.strip()] + return [address for _name, address in getaddresses([value]) if address] if isinstance(value, (list, tuple)): - return [str(item).strip() for item in value if str(item).strip()] + return [address for _name, address in getaddresses([str(item) for item in value]) if address] raise GmailOpsError("recipients must be a string or list of addresses") @@ -366,6 +369,12 @@ def _compose(token, *, to, cc=None, bcc=None, subject, text=None, html=None, _parse_addresses(bcc), [], subject, text or "", html or "", _attachments_b64(attachments), "", in_reply_to=in_reply_to, references=references) + for part, spec in zip(message.iter_attachments(), attachments or []): + mime = spec.get("mime_type") + if mime: + if not re.fullmatch(r"[A-Za-z0-9!#$&^_.+-]+/[A-Za-z0-9!#$&^_.+-]+", str(mime)): + raise GmailOpsError("attachment MIME type must be type/subtype") + part.set_type(mime) return _b64url_encode(message.as_bytes()) @@ -381,7 +390,7 @@ def send(account, *, to, cc=None, bcc=None, subject=None, text=None, html=None, def reply(account, message_id, *, reply_all=False, text=None, html=None, - attachments=None, draft=False): + attachments=None, draft=False, to=None, cc=None, bcc=None): """Reply to one message in its thread: recipients from Reply-To/From (reply-all adds the original To+Cc minus the account itself), ``Re:`` subject, In-Reply-To/References from the original, and the original @@ -396,13 +405,14 @@ def reply(account, message_id, *, reply_all=False, text=None, html=None, reply_address = headers.get("reply-to") or headers.get("from") or "" recipients = _parse_addresses(reply_address) if reply_all: - everyone = _parse_addresses(headers.get("to")) + _parse_addresses(headers.get("cc")) \ - + _parse_addresses(headers.get("bcc")) + everyone = _parse_addresses(headers.get("to")) + _parse_addresses(headers.get("cc")) seen = {address.lower() for address in recipients} for address in everyone: if address.lower() not in seen and address.lower() != sender.lower(): recipients.append(address) seen.add(address.lower()) + if to is not None: + recipients = _parse_addresses(to) if not recipients: raise GmailOpsError("the original message names no reply address", status=502) subject = headers.get("subject") or "(no subject)" @@ -411,7 +421,7 @@ def reply(account, message_id, *, reply_all=False, text=None, html=None, message_id_header = (headers.get("message-id") or "").strip() references = " ".join(part for part in (headers.get("references") or "", message_id_header) if part.strip()) - raw = _compose(token, to=", ".join(recipients), subject=subject, text=text, + raw = _compose(token, to=", ".join(recipients), cc=cc, bcc=bcc, subject=subject, text=text, html=html, attachments=attachments, in_reply_to=message_id_header or None, references=references or None) @@ -439,13 +449,26 @@ def create_draft(account, *, to, cc=None, bcc=None, subject=None, text=None, def update_draft(account, draft_id, *, to, cc=None, bcc=None, subject=None, text=None, html=None, attachments=None): - """Full replace of one draft's content (drafts.update).""" - _connection_id, _connection, token = _connection_and_token(account) + """Replace draft content while retaining its reply thread and headers. - raw = _compose(token, to=to, cc=cc, bcc=bcc, subject=subject or "(no subject)", - text=text, html=html, attachments=attachments) - data = _call(token, "PUT", f"/users/me/drafts/{quote(str(draft_id), safe='')}", - {"message": {"raw": raw}}) + Body and attachments are a full replacement; omitted recipients and + subject retain their stored values so a reply draft stays a reply. + """ + _connection_id, _connection, token = _connection_and_token(account) + path = f"/users/me/drafts/{quote(str(draft_id), safe='')}" + stored = _call(token, "GET", path) + message = stored.get("message") or {} + headers = _headers_of(message.get("payload") or {}) + raw = _compose(token, to=to if to is not None else headers.get("to"), + cc=cc if cc is not None else headers.get("cc"), + bcc=bcc if bcc is not None else headers.get("bcc"), + subject=subject if subject is not None else headers.get("subject", "(no subject)"), + text=text, html=html, attachments=attachments, + in_reply_to=headers.get("in-reply-to"), references=headers.get("references")) + replacement = {"raw": raw} + if message.get("threadId"): + replacement["threadId"] = message["threadId"] + data = _call(token, "PUT", path, {"message": replacement}) return _draft_view(data) diff --git a/src/dapier/api/agent/google_accounts.py b/src/dapier/api/agent/google_accounts.py index 6170184..d0b6f43 100644 --- a/src/dapier/api/agent/google_accounts.py +++ b/src/dapier/api/agent/google_accounts.py @@ -5,8 +5,7 @@ its connection id, or a unique fragment of either. The resolver here is the single gate every ``/api/agent/gmail/*`` and ``/api/agent/gdrive/*`` operation goes through — it picks exactly one connection and never silently -switches accounts: an omitted ``--account`` resolves only when exactly one -connected account serves the service, an ambiguous fragment is an error +switches accounts: an omitted ``--account`` is rejected, an ambiguous fragment is an error that names the candidates, and every error lists the accounts that exist so the operator can re-run with an explicit choice. """ @@ -68,9 +67,8 @@ def resolve_account(service_id, account=None): ``account`` (the CLI's ``--account``) matches a connection id exactly, a verified email exactly (case-insensitive), or a unique fragment of - email/title. Without it, exactly one connected account must serve the - service — with several, the choice is the operator's and the error - names them. Raises GoogleAccountError on anything ambiguous. + email/title. Without it, the request is rejected and the error names the + available accounts. Raises GoogleAccountError on anything ambiguous. """ accounts = list_accounts(service_id) connected = [view for view in accounts if view.get("status") == connections.STATUS_CONNECTED] @@ -80,8 +78,6 @@ def resolve_account(service_id, account=None): f"No Google connection grants {service_id}; connect one first " "(dapier connections create)", accounts=accounts) if account is None or not str(account).strip(): - if len(candidates) == 1: - return candidates[0]["connection_id"] raise GoogleAccountError( "--account is required: " + str(len(candidates)) + " Google accounts serve " diff --git a/tests/test_calendar_agent_cli.py b/tests/test_calendar_agent_cli.py index a5057e1..c0cf7b5 100644 --- a/tests/test_calendar_agent_cli.py +++ b/tests/test_calendar_agent_cli.py @@ -337,7 +337,7 @@ def test_calendar_cli_parser(): "--start", "2026-11-05T09:00:00Z", "--end", "2026-11-05T10:00:00Z", "--attendee", "a@b.c", "--recurse", "RRULE:FREQ=DAILY", - "--send-updates", "all", "--preview"]) + "--send-updates", "all", "--preview", "--account", ACCOUNT_A]) assert create.send_updates == "all" and create.preview assert create.attendee == ["a@b.c"] @@ -355,3 +355,31 @@ def test_preview_masks_attachment_bytes(monkeypatch, tmp_path, capsys): assert rc == 0 and parsed["preview"] is True assert "<9 bytes>" in parsed["would_send"]["attachments"][0]["content_b64"] assert "AAEB" not in out # the bytes themselves never print + + +def test_calendar_partial_update_and_attachments(calendar_world, cal_provider): + cal_provider.on("PATCH", "/events/e1?", {"id": "e1", "summary": "Renamed"}) + calendar_ops.update_event(ACCOUNT_A, "e1", summary="Renamed") + assert cal_provider.calls[-1]["payload"] == {"summary": "Renamed"} + assert "supportsAttachments=true" in cal_provider.calls[-1]["url"] + + +@pytest.mark.parametrize("lower,upper", [ + ("2026-11-01", "2026-11-02"), + ("2026-11-01T09:00:00", "2026-11-02T09:00:00Z"), +]) +def test_calendar_window_requires_timezone(lower, upper): + with pytest.raises(calendar_ops.CalendarOpsError, match="timezone"): + calendar_ops._bounded_window(lower, upper) + + +def test_calendar_cli_maps_payload_and_timezone_to_api(monkeypatch): + from dapier_cli.commands import calendar + calls = [] + monkeypatch.setattr(calendar.api, 'call', lambda *a, **k: calls.append(k) or {}) + calendar.calendar_write('http://api', 'POST', account=ACCOUNT_A, + fields={'payload': {'summary': 'reviewed'}}) + assert calls[-1]['body']['event'] == {'summary': 'reviewed'} + calendar.calendar_write('http://api', 'POST', account=ACCOUNT_A, + fields={'timezone_name': 'Europe/Berlin'}) + assert calls[-1]['body']['timezone'] == 'Europe/Berlin' diff --git a/tests/test_gmail_gdrive_cli.py b/tests/test_gmail_gdrive_cli.py index 236469c..b859421 100644 --- a/tests/test_gmail_gdrive_cli.py +++ b/tests/test_gmail_gdrive_cli.py @@ -26,7 +26,7 @@ def test_cli_nouns_register(): up = parser.parse_args(["gdrive", "upload", "f.txt", "--name", "f.txt", "--account", ACCOUNT_A]) assert up.path == "f.txt" - reply = parser.parse_args(["gmail", "reply", "M1", "--all", "--draft", "--file", "-"]) + reply = parser.parse_args(["gmail", "reply", "M1", "--all", "--draft", "--file", "-", "--account", ACCOUNT_A]) assert reply.reply_all and reply.draft and reply.file == "-" @@ -100,3 +100,58 @@ def fake_call(api_url, method, path, body=None, timeout=20, debug=False): assert calls[3]["body"]["permanent"] is True + + +def test_gmail_urlsafe_attachment_download(monkeypatch, tmp_path): + from dapier_cli.commands import gmail + binary = b'\xfb\xff\xfe\x00' + monkeypatch.setattr(gmail.api, 'call', lambda *a, **k: { + 'data_b64': base64.urlsafe_b64encode(binary).decode().rstrip('=')}) + target = tmp_path / 'attachment.bin' + gmail.gmail_attachment('http://api', 'M', 'A', account=ACCOUNT_A, out=str(target)) + assert target.read_bytes() == binary + + +def test_gmail_search_json_and_draft_pagination(monkeypatch, capsys): + import json + from dapier_cli.commands import gmail + calls = [] + def call(*args, **kwargs): + calls.append(args) + return {'messages': [], 'drafts': [], 'next_page_token': 'next'} + monkeypatch.setattr(gmail.api, 'call', call) + gmail.gmail_search('http://api', 'is:unread', account=ACCOUNT_A, as_json=True) + assert json.loads(capsys.readouterr().out)['next_page_token'] == 'next' + gmail.gmail_draft_list('http://api', account=ACCOUNT_A, max_results=10, page_token='p') + assert 'max=10' in calls[-1][2] and 'page_token=p' in calls[-1][2] + + +def test_previews_include_all_write_fields_and_never_call_api(monkeypatch, tmp_path, capsys): + import json + from dapier_cli.commands import gmail, gdrive + def unexpected(*args, **kwargs): + raise AssertionError('preview must not call the API') + monkeypatch.setattr(gmail.api, 'call', unexpected) + body_file = tmp_path / 'body.txt' + body_file.write_text('Body ü') + gmail.gmail_send('http://api', account=ACCOUNT_A, to='peer@corp.com', cc='copy@corp.com', + subject='Subject', file=str(body_file), preview=True) + body = json.loads(capsys.readouterr().out)['would_send'] + assert body['account'] == ACCOUNT_A and body['text'] == 'Body ü' + assert body['cc'] == 'copy@corp.com' + gdrive.gdrive_move('http://api', 'F', account=ACCOUNT_A, add_folder='NEW', remove_folder='OLD', preview=True) + body = json.loads(capsys.readouterr().out)['would_send'] + assert body == {'account': ACCOUNT_A, 'add_parent': 'NEW', 'remove_parent': 'OLD'} + gdrive.gdrive_update('http://api', 'F', account=ACCOUNT_A, name='new', file=str(body_file), preview=True) + body = json.loads(capsys.readouterr().out)['would_send'] + assert body['name'] == 'new' and body['account'] == ACCOUNT_A + assert body['content_b64'].endswith('bytes>') + + +def test_cli_requires_account_for_each_service(): + import pytest + from dapier_cli.cli import build_parser + for command in (['gmail', 'list'], ['gmail', 'draft', 'send', 'D'], + ['gdrive', 'list'], ['calendar', 'calendars']): + with pytest.raises(SystemExit): + build_parser().parse_args(command) diff --git a/tests/test_gmail_gdrive_ops_cli.py b/tests/test_gmail_gdrive_ops_cli.py index 3f57dd6..b5f795f 100644 --- a/tests/test_gmail_gdrive_ops_cli.py +++ b/tests/test_gmail_gdrive_ops_cli.py @@ -314,6 +314,7 @@ def test_gmail_draft_create_update_send_delete(google_world, gmail_provider): assert draft["id"] == "d1" assert gmail_provider.calls[-1]["payload"]["message"]["raw"] + gmail_provider.on("GET", "/drafts/d1", {"id": "d1", "message": {"threadId": "t2"}}) gmail_provider.on("PUT", "/drafts/d1", {"id": "d1", "message": {"id": "m2"}}) updated = gmail_ops.update_draft(ACCOUNT_A, "d1", to="a@b.c", subject="Inv2", text="new") assert updated["id"] == "d1" @@ -627,3 +628,40 @@ def test_route_gdrive_dispatch_and_audit(google_world, gdrive_provider, monkeypa # --- CLI thin clients ------------------------------------------------------------ + + +def test_edit_reply_draft_retains_thread_headers_and_attachment(google_world, gmail_provider): + headers = _ORIGINAL_FULL["payload"]["headers"] + [ + {"name": "In-Reply-To", "value": ""}] + gmail_provider.on("GET", "/drafts/reply1", { + "id": "reply1", "message": {"threadId": "t1", "payload": {"headers": headers}}}) + gmail_provider.on("GET", "/users/me/profile", {"emailAddress": ACCOUNT_B}) + gmail_provider.on("PUT", "/drafts/reply1", {"id": "reply1"}) + gmail_ops.update_draft(ACCOUNT_B, "reply1", to=None, text="Edited ü", attachments=[{ + "filename": "report.bin", "content_b64": base64.b64encode(b"\xff\x00").decode()}]) + sent = gmail_provider.calls[-1]["payload"]["message"] + assert sent["threadId"] == "t1" + message = email.message_from_bytes(base64.urlsafe_b64decode(sent["raw"]), policy=email.policy.default) + assert message["In-Reply-To"] == "" + assert message["References"] == "" + assert message["Subject"] == "Quarterly plan" + assert list(message.iter_attachments())[0].get_content() == b"\xff\x00" + assert all(c["token"] == "token-google-b" for c in gmail_provider.calls) + + +def test_reply_all_handles_named_addresses_without_disclosing_bcc(google_world, gmail_provider): + original = json.loads(json.dumps(_ORIGINAL_FULL)) + original["payload"]["headers"] += [ + {"name": "To", "value": '"Self, A" , "Peer, P" '}, + {"name": "Bcc", "value": "hidden@corp.com"}] + gmail_provider.on("GET", "/messages/m1?format=full", original) + gmail_provider.on("GET", "/users/me/profile", {"emailAddress": ACCOUNT_A}) + gmail_provider.on("POST", "/messages/send", {"id": "sent"}) + result = gmail_ops.reply(ACCOUNT_A, "m1", reply_all=True, text="OK") + assert result["reply_to"] == ["careers@corp.com", "peer@corp.com", "watcher@corp.com"] + + +def test_drive_metadata_update_uses_standard_endpoint(google_world, gdrive_provider): + gdrive_provider.on("PATCH", "/drive/v3/files/f1", {"id": "f1", "name": "Renamed"}) + assert gdrive_ops.update(ACCOUNT_A, "f1", name="Renamed")["name"] == "Renamed" + assert "/upload/" not in gdrive_provider.calls[-1]["url"] diff --git a/tests/test_google_account_selection.py b/tests/test_google_account_selection.py index 4f7c310..33994e2 100644 --- a/tests/test_google_account_selection.py +++ b/tests/test_google_account_selection.py @@ -77,8 +77,8 @@ def test_resolve_by_id_email_and_fragment(two_accounts): def test_omitted_account_single_connected_resolves(two_accounts): - # drive has exactly one connected account: no --account needed - assert google_accounts.resolve_account("drive", None) == "google-a" + with pytest.raises(google_accounts.GoogleAccountError, match="--account is required"): + google_accounts.resolve_account("drive", None) def test_omitted_account_multiple_is_rejected(two_accounts): @@ -113,7 +113,7 @@ def test_disconnected_duplicate_never_shadows_connected(monkeypatch): monkeypatch.setattr(google_accounts, "_tables", lambda: (object(), object())) monkeypatch.setattr(google_accounts.connections, "_scan_all", lambda table: conns) # the connected sign-in wins when it is the only connected match - assert google_accounts.resolve_account("gmail", None) == "google-b" + assert google_accounts.resolve_account("gmail", "alexey@gmail.com") == "google-b" def test_no_accounts_is_a_clear_error(monkeypatch): From 2a1a6cafee62f4c4a96a7b09951aef23688c4694 Mon Sep 17 00:00:00 2001 From: Alexey Grigorev Date: Wed, 4 Nov 2026 17:37:00 +0100 Subject: [PATCH 2/6] feat(console): expose Google operations through shared API handlers --- plugins/google/README.md | 6 ++ src/dapier/api/admin/dispatch.py | 17 ++- src/dapier/api/agent/common.py | 8 ++ src/dapier/api/router.py | 1 + src/web/js/views/connections.js | 9 +- src/web/js/views/google-tools.js | 160 ++++++++++++++++++++++++++++ tests/test_google_console_parity.py | 81 ++++++++++++++ 7 files changed, 280 insertions(+), 2 deletions(-) create mode 100644 src/web/js/views/google-tools.js create mode 100644 tests/test_google_console_parity.py diff --git a/plugins/google/README.md b/plugins/google/README.md index 6f6a261..773d12a 100644 --- a/plugins/google/README.md +++ b/plugins/google/README.md @@ -750,3 +750,9 @@ read-only `google-data-pull/gpull.py` helper. Provider references: [Gmail threading](https://developers.google.com/workspace/gmail/api/guides/threads), [Drive metadata updates](https://developers.google.com/workspace/drive/api/guides/manage-uploads), [Calendar attachment and notification parameters](https://developers.google.com/workspace/calendar/api/v3/reference/events/insert). + +The console exposes these same operations from **Connections → Google tools**. +Choose a service and operation, fill in IDs and JSON parameters, attach local +files when applicable, then Preview or Fetch/Apply. The selected connection ID +is fixed for the request. Admin routes pass session, CSRF and role checks before +calling the same handlers as the agent API; provider logic is shared. diff --git a/src/dapier/api/admin/dispatch.py b/src/dapier/api/admin/dispatch.py index 55c7eb5..6c918e4 100644 --- a/src/dapier/api/admin/dispatch.py +++ b/src/dapier/api/admin/dispatch.py @@ -321,8 +321,23 @@ def route(event, method, path): return http._operator_error_500(event, exc, traceback_text=traceback.format_exc()) +def _route_google(event, method, path, operator_payload, operator_subject): + """Console aliases use the same Google operation handlers as the CLI.""" + if not re.match(r"^/api/admin/(gmail|gdrive|calendar)/", path): + return None + console_event = {**event, "_console_google_payload": operator_payload, + "_console_google_subject": operator_subject} + agent_path = path.replace("/api/admin/", "/api/agent/", 1) + for router in (agent.gmail.route_gmail, agent.gdrive.route_gdrive, + agent.calendar.route_calendar): + response = router(console_event, method, agent_path) + if response is not None: + return response + return None + + def _route_domains(event, method, path, operator_payload, operator_subject): - for router in (_route_overview_runs, _route_usage_errors, _route_inbox, + for router in (_route_google, _route_overview_runs, _route_usage_errors, _route_inbox, _route_connections, _route_grants_tokens, _route_ops_email, _route_storage, _route_triggers, _route_connection_tokens): response = router(event, method, path, operator_payload, operator_subject) diff --git a/src/dapier/api/agent/common.py b/src/dapier/api/agent/common.py index 81056f3..3bd4c3f 100644 --- a/src/dapier/api/agent/common.py +++ b/src/dapier/api/agent/common.py @@ -173,6 +173,14 @@ def require_operator(event, action): the audit action recorded on a denial. A DTC identity passes the same operator allowlist the console gate applies; API tokens pass only through their grants.""" + # Set only by the admin dispatcher after its session, CSRF and role gates. + console = event.get("_console_google_payload") + if console is not None: + subject = event["_console_google_subject"] + if actors.action_allowed(console, action): + return subject, None + audit.emit("unknown", action, subject, outcome="denied-not-operator") + return None, _json_response(403, {"error": "Not available for this role"}) subject, error = authenticate(event) if error: return None, error diff --git a/src/dapier/api/router.py b/src/dapier/api/router.py index 3973cbb..d5237fe 100644 --- a/src/dapier/api/router.py +++ b/src/dapier/api/router.py @@ -75,6 +75,7 @@ def _static(path): "/assets/js/workflow-names.js": ("js/workflow-names.js", "text/javascript; charset=utf-8"), "/assets/js/catalog-labels.js": ("js/catalog-labels.js", "text/javascript; charset=utf-8"), "/assets/js/ui.js": ("js/ui.js", "text/javascript; charset=utf-8"), + "/assets/js/views/google-tools.js": ("js/views/google-tools.js", "text/javascript; charset=utf-8"), "/assets/js/views/connections.js": ("js/views/connections.js", "text/javascript; charset=utf-8"), "/assets/js/views/credentials.js": ("js/views/credentials.js", "text/javascript; charset=utf-8"), "/assets/js/views/oauth-clients.js": ("js/views/oauth-clients.js", "text/javascript; charset=utf-8"), diff --git a/src/web/js/views/connections.js b/src/web/js/views/connections.js index 04a7033..17911dd 100644 --- a/src/web/js/views/connections.js +++ b/src/web/js/views/connections.js @@ -5,6 +5,7 @@ import { $, $$, notice, serviceMark, whenMenusClosed, openWhenReady } from '../u import { api } from '../api.js'; import { escapeHtml, formatTimestamp, statusLine } from '../format.js'; import { refresh } from './overview.js'; +import { openGoogleTools } from './google-tools.js'; /* Keep ids, labels, providers, connectionId, and default scopes in lockstep with src/dapier/connections/services.py — tests/test_connection_services.py @@ -900,6 +901,10 @@ function renderConnectionsNow(connections) { })); $$('.connection-remove').forEach((button) => button.addEventListener('click', () => deleteConnection(button.dataset.connection))); $$('.connection-finish').forEach((button) => button.addEventListener('click', () => openZoomWebhookSetup(button.dataset.connection))); + $$('.connection-google-tools').forEach((button) => button.addEventListener('click', () => { + const connection = ((state.data || {}).connections || []).find((c) => c.connection_id === button.dataset.connection); + if (connection) openGoogleTools(connection); + })); bindConnectButtons(); bindOAuthLinks(); } @@ -988,7 +993,9 @@ function accountActions(connection, { unfinished = false, title } = {}) { const manage = ``; const test = connection.provider === 'llm' ? `` : ''; - return { html: remove + next + test + manage, many: Boolean(remove || next || test) }; + const tools = connection.provider === 'google' + ? `` : ''; + return { html: remove + next + test + tools + manage, many: Boolean(remove || next || test || tools) }; } /* Another service's view of a shared sign-in: the account, Details, status, diff --git a/src/web/js/views/google-tools.js b/src/web/js/views/google-tools.js new file mode 100644 index 0000000..7c61bda --- /dev/null +++ b/src/web/js/views/google-tools.js @@ -0,0 +1,160 @@ +/* Console client for the same Google handlers used by dapier gmail/gdrive/calendar. */ +import { api } from '../api.js'; +import { escapeHtml } from '../format.js'; + +// Labels, routes and example parameters only; validation and writes belong to the API. +const OPERATIONS = { + gmail: [ + ['List messages', 'GET', 'messages', {max: 25}], + ['Search messages', 'GET', 'messages', {q: 'is:unread', max: 25}], + ['Read message', 'GET', 'messages/{message_id}', {}], + ['Download attachment', 'GET', 'messages/{message_id}/attachments/{attachment_id}', {}], + ['List threads', 'GET', 'threads', {max: 25}], + ['Read thread', 'GET', 'threads/{thread_id}', {}], + ['List labels', 'GET', 'labels', {}], + ['Change labels or status', 'POST', 'messages/{message_id}/modify', {mark_read: true}], + ['Create draft', 'POST', 'drafts', {to: '', subject: '', text: ''}], + ['Update draft', 'PUT', 'drafts/{draft_id}', {text: ''}], + ['List drafts', 'GET', 'drafts', {max: 25}], + ['Read draft', 'GET', 'drafts/{draft_id}', {}], + ['Delete draft', 'DELETE', 'drafts/{draft_id}', {}], + ['Send draft', 'POST', 'drafts/{draft_id}/send', {}], + ['Send email', 'POST', 'send', {to: '', subject: '', text: ''}], + ['Reply directly', 'POST', 'reply', {message_id: '', text: '', reply_all: false}], + ['Create reply draft', 'POST', 'reply', {message_id: '', text: '', reply_all: false, draft: true}], + ], + gdrive: [ + ['Search or list files', 'GET', 'files', {max: 25}], + ['File metadata', 'GET', 'files/{file_id}', {}], + ['Download or export', 'GET', 'files/{file_id}/content', {}], + ['Upload file', 'POST', 'files', {name: ''}], + ['Update file', 'PATCH', 'files/{file_id}', {name: ''}], + ['Create folder', 'POST', 'folders', {name: ''}], + ['Copy file', 'POST', 'files/{file_id}/copy', {name: ''}], + ['Move file', 'POST', 'files/{file_id}/move', {add_parent: '', remove_parent: ''}], + ['Share file', 'POST', 'files/{file_id}/share', {role: 'reader', share_type: 'user', email_address: ''}], + ['Trash file', 'POST', 'files/{file_id}/trash', {permanent: false}], + ['Restore file', 'POST', 'files/{file_id}/restore', {}], + ], + calendar: [ + ['List calendars', 'GET', 'calendars', {max: 25}], + ['Search or list events', 'GET', 'events', {time_min: '', time_max: '', max: 25}], + ['Read event', 'GET', 'events/{event_id}', {}], + ['Free / busy', 'POST', 'freebusy', {calendar_ids: ['primary'], time_min: '', time_max: ''}], + ['Create event', 'POST', 'events', {summary: '', start: '', end: '', send_updates: 'none'}], + ['Update event', 'PATCH', 'events/{event_id}', {summary: '', send_updates: 'none'}], + ['Delete event', 'DELETE', 'events/{event_id}', {send_updates: 'none'}], + ], +}; + +function bytes64(bytes) { + let text = ''; + for (const byte of bytes) text += String.fromCharCode(byte); + return btoa(text); +} + +export function openGoogleTools(connection) { + document.getElementById('google-tools-dialog')?.remove(); + const dialog = document.createElement('dialog'); + dialog.id = 'google-tools-dialog'; + dialog.className = 'dk-dialog'; + dialog.setAttribute('aria-labelledby', 'google-tools-title'); + dialog.innerHTML = `

Google tools

+
+

${escapeHtml(connection.verified_account_id || connection.connection_id)}

+ + +
+ + +

Use Preview to review parameters. Apply runs the selected operation on this account. Calendar notifications default to none; choose send_updates explicitly. List responses include next_page_token; pass it as page_token to continue.

+

+    
+
`; + document.body.append(dialog); + const find = (selector) => dialog.querySelector(selector); + const service = find('[name=service]'); + const operation = find('[name=operation]'); + const parameters = find('[name=parameters]'); + const result = find('[data-result]'); + const error = find('[role=alert]'); + const run = find('[data-run]'); + const selected = () => OPERATIONS[service.value][Number(operation.value)]; + function chooseOperation() { + const [, method, path, example] = selected(); + parameters.value = JSON.stringify(example, null, 2); + find('[data-ids]').innerHTML = [...path.matchAll(/\{(\w+)\}/g)].map((m) => + ``).join(''); + find('[name=files]').value = ''; + find('[data-files]').hidden = !(service.value === 'gmail' && ['send', 'reply', 'drafts', 'drafts/{draft_id}'].includes(path) && method !== 'GET') + && !(service.value === 'gdrive' && ['POST', 'PATCH'].includes(method) && ['files', 'files/{file_id}'].includes(path)); + run.textContent = method === 'GET' || path === 'freebusy' ? 'Fetch' : 'Apply'; + result.textContent = ''; + error.textContent = ''; + } + function chooseService() { + operation.innerHTML = OPERATIONS[service.value].map(([label], i) => ``).join(''); + chooseOperation(); + } + async function request() { + const [, method, template] = selected(); + let path = template; + for (const input of dialog.querySelectorAll('[data-key]')) { + if (!input.reportValidity()) throw new Error('Fill in the resource ID.'); + path = path.replace(`{${input.dataset.key}}`, encodeURIComponent(input.value)); + } + const fields = JSON.parse(parameters.value); + if (!fields || typeof fields !== 'object' || Array.isArray(fields)) throw new Error('Parameters must be a JSON object.'); + const body = {...fields, account: connection.connection_id}; + if (!find('[data-files]').hidden) { + const files = Array.from(find('[name=files]').files); + if (service.value === 'gmail' && files.length) { + body.attachments = await Promise.all(files.map(async (f) => ({filename: f.name, mime_type: f.type, + content_b64: bytes64(new Uint8Array(await f.arrayBuffer()))}))); + } else if (service.value === 'gdrive' && files.length) { + if (files.length !== 1) throw new Error('Choose one Drive file.'); + body.content_b64 = bytes64(new Uint8Array(await files[0].arrayBuffer())); + body.mime_type = files[0].type || 'application/octet-stream'; + } + } + const url = `/api/admin/${service.value}/${path}`; + return method === 'GET' || method === 'DELETE' + ? {method, url: `${url}?${new URLSearchParams(body)}`, ...(method === 'DELETE' ? {body} : {})} + : {method, url, body}; + } + async function execute(preview) { + error.textContent = ''; + try { + const req = await request(); + if (preview) { + const {content_b64, attachments, ...fields} = req.body || {}; + result.textContent = JSON.stringify({...req, body: req.body ? {...fields, + ...(content_b64 !== undefined ? {content_b64: '(file bytes)'} : {}), + ...(attachments ? {attachments: attachments.map(({content_b64: _bytes, ...a}) => a)} : {})} : undefined}, null, 2); + return; + } + run.disabled = true; + const data = await api(req.url, {method: req.method, ...(req.body ? {body: JSON.stringify(req.body)} : {})}); + if (data.data_b64 !== undefined) { + const binary = atob(data.data_b64.replaceAll('-', '+').replaceAll('_', '/')); + const bytes = Uint8Array.from(binary, (c) => c.charCodeAt(0)); + const url = URL.createObjectURL(new Blob([bytes], {type: data.mime_type || 'application/octet-stream'})); + const link = document.createElement('a'); + link.href = url; + link.download = (data.name || 'attachment').split(/[\\/]/).pop(); + link.textContent = 'Download file'; + result.replaceChildren(link); + link.addEventListener('click', () => setTimeout(() => URL.revokeObjectURL(url), 1000), {once: true}); + } else result.textContent = JSON.stringify(data, null, 2); + } catch (exc) { error.textContent = exc.message; } + finally { run.disabled = false; } + } + service.addEventListener('change', chooseService); + operation.addEventListener('change', chooseOperation); + find('[data-preview]').addEventListener('click', () => execute(true)); + run.addEventListener('click', () => execute(false)); + find('[data-close]').addEventListener('click', () => dialog.close()); + dialog.addEventListener('close', () => dialog.remove()); + chooseService(); + dialog.showModal(); +} diff --git a/tests/test_google_console_parity.py b/tests/test_google_console_parity.py new file mode 100644 index 0000000..4c3cbaa --- /dev/null +++ b/tests/test_google_console_parity.py @@ -0,0 +1,81 @@ +"""Console Google aliases share operator-gated handlers with the CLI.""" +import json + +import pytest + +from src.dapier.api import agent +from src.dapier.api.admin import dispatch + + +@pytest.mark.parametrize('service,route_name', [ + ('gmail', 'route_gmail'), ('gdrive', 'route_gdrive'), ('calendar', 'route_calendar')]) +def test_console_google_dispatch_reuses_handler(monkeypatch, service, route_name): + seen = [] + module = getattr(agent, service) + monkeypatch.setattr(module, route_name, lambda event, method, path: + seen.append((event, method, path)) or {'statusCode': 200}) + payload = {'email': 'operator@example.com'} + response = dispatch._route_google({'body': '{}'}, 'POST', f'/api/admin/{service}/test', + payload, 'operator-sub') + assert response['statusCode'] == 200 + event, method, path = seen[0] + assert path == f'/api/agent/{service}/test' and method == 'POST' + assert event['_console_google_subject'] == 'operator-sub' + assert event['_console_google_payload'] == payload + + +def test_console_google_requires_session_and_csrf(monkeypatch): + monkeypatch.setattr(dispatch.session, 'authenticated', lambda e: False) + assert dispatch.route({}, 'POST', '/api/admin/gmail/send')['statusCode'] == 401 + monkeypatch.setattr(dispatch.session, 'authenticated', lambda e: True) + monkeypatch.setattr(dispatch.session, '_csrf_ok', lambda e, m: False) + assert dispatch.route({}, 'POST', '/api/admin/gmail/send')['statusCode'] == 403 + + +def test_console_google_action_gate(monkeypatch): + from src.dapier.api.agent import common + event = {'_console_google_payload': {'email': 'operator@example.com'}, + '_console_google_subject': 'operator-sub'} + monkeypatch.setattr(common.actors, 'action_allowed', lambda p, a: a == 'gmail.reply') + monkeypatch.setattr(common.audit, 'emit', lambda *a, **k: None) + assert common.require_operator(event, 'gmail.reply') == ('operator-sub', None) + assert common.require_operator(event, 'gmail.send')[1]['statusCode'] == 403 + + +def test_google_tools_browser_preview_and_shared_request(): + pytest.importorskip('playwright.sync_api') + from playwright.sync_api import sync_playwright + from test_console_boot import console_server, _chromium + with console_server() as origin, sync_playwright() as playwright: + browser = _chromium(playwright) + page = browser.new_page() + calls = [] + def reply(route): + calls.append(route.request) + route.fulfill(json={'message_id': 'mock-send', 'thread_id': 'mock-thread'}) + page.route('**/api/admin/gmail/reply', reply) + page.goto(origin + '/') + page.locator('#app').wait_for(state='visible') + page.evaluate("""async () => { + const {openGoogleTools} = await import('/assets/js/views/google-tools.js'); + openGoogleTools({connection_id: 'google-explicit', verified_account_id: 'chosen@example.com'}); + }""") + dialog = page.locator('#google-tools-dialog') + dialog.locator('[name=operation]').select_option(label='Create reply draft') + dialog.locator('[name=parameters]').fill(json.dumps({ + 'message_id': 'm1', 'text': 'Reply ü', 'draft': True, + 'account': 'must-not-override-the-selected-account'})) + dialog.locator('[name=files]').set_input_files({ + 'name': 'report.bin', 'mimeType': 'application/octet-stream', 'buffer': b'\xff\x00'}) + dialog.locator('[data-preview]').click() + page.wait_for_function("() => document.querySelector('[data-result]').textContent.includes('google-explicit')") + assert not calls + assert 'report.bin' in dialog.locator('[data-result]').inner_text() + assert '/wA=' not in dialog.locator('[data-result]').inner_text() + dialog.locator('[data-run]').click() + page.wait_for_function("() => document.querySelector('[data-result]').textContent.includes('mock-send')") + body = calls[0].post_data_json + assert body['account'] == 'google-explicit' + assert body['draft'] is True and body['message_id'] == 'm1' + assert body['attachments'][0]['content_b64'] == '/wA=' + browser.close() From 8745dfe80ac98399ba67173ece6fb1670684346a Mon Sep 17 00:00:00 2001 From: Alexey Grigorev Date: Wed, 4 Nov 2026 17:37:43 +0100 Subject: [PATCH 3/6] docs(skills): use explicit-account Google CLI reads and documented fallbacks --- .agents/skills/dapier-cli/SKILL.md | 36 +-- .agents/skills/google-data-pull/SKILL.md | 338 ++++++++++------------- 2 files changed, 159 insertions(+), 215 deletions(-) diff --git a/.agents/skills/dapier-cli/SKILL.md b/.agents/skills/dapier-cli/SKILL.md index 0ac15bb..afb804b 100644 --- a/.agents/skills/dapier-cli/SKILL.md +++ b/.agents/skills/dapier-cli/SKILL.md @@ -77,38 +77,38 @@ uv run dapier runs events list --limit 10 # Gmail over a stored Google connection (token stays server-side) uv run dapier gmail accounts # Google accounts: email + connection id uv run dapier gmail search "from:boss is:unread" --account me@gmail.com -uv run dapier gmail read MSG_ID --save-attachments ./att +uv run dapier gmail read MSG_ID --save-attachments ./att --account me@gmail.com uv run dapier gmail send --to a@b.c --subject Hi --file body.txt --attach r.pdf --account me@gmail.com -uv run dapier gmail reply MSG_ID --draft --text "On it" # reply draft, thread preserved -uv run dapier gmail reply MSG_ID --all --text "Thanks all" # direct reply-all send -uv run dapier gmail draft create --to a@b.c --subject S --text ... --attach inv.pdf -uv run dapier gmail draft send DRAFT_ID -uv run dapier gmail mark MSG_ID --read --archive +uv run dapier gmail reply MSG_ID --draft --text "On it" --account me@gmail.com # reply draft, thread preserved +uv run dapier gmail reply MSG_ID --all --text "Thanks all" --account me@gmail.com # direct reply-all send +uv run dapier gmail draft create --to a@b.c --subject S --text ... --attach inv.pdf --account me@gmail.com +uv run dapier gmail draft send DRAFT_ID --account me@gmail.com +uv run dapier gmail mark MSG_ID --read --archive --account me@gmail.com # Google Drive over a stored Google connection uv run dapier gdrive accounts uv run dapier gdrive list --account me@gmail.com --max 20 -uv run dapier gdrive download FILE_ID --out ./out.pdf # native docs export (PDF/CSV/PNG) -uv run dapier gdrive upload ./f.txt --name f.txt --folder FOL_ID -uv run dapier gdrive share FILE_ID --role writer --with user --email p@x.y -uv run dapier gdrive trash FILE_ID # --permanent destroys -uv run dapier gdrive restore FILE_ID +uv run dapier gdrive download FILE_ID --out ./out.pdf --account me@gmail.com # native docs export (PDF/CSV/PNG) +uv run dapier gdrive upload ./f.txt --name f.txt --folder FOL_ID --account me@gmail.com +uv run dapier gdrive share FILE_ID --role writer --with user --email p@x.y --account me@gmail.com +uv run dapier gdrive trash FILE_ID --account me@gmail.com # --permanent destroys +uv run dapier gdrive restore FILE_ID --account me@gmail.com # Google Calendar (guest email only ever via --send-updates; default none) uv run dapier calendar calendars --account me@gmail.com -uv run dapier calendar events --time-min 2026-11-04T00:00:00Z --time-max 2026-11-11T00:00:00Z -uv run dapier calendar freebusy --calendar primary --time-min ... --time-max ... +uv run dapier calendar events --time-min 2026-11-04T00:00:00Z --time-max 2026-11-11T00:00:00Z --account me@gmail.com +uv run dapier calendar freebusy --calendar primary --time-min ... --time-max ... --account me@gmail.com uv run dapier calendar create --summary Sync --start 2026-11-05T09:00:00Z \ --end 2026-11-05T10:00:00Z --attendee peer@example.com --preview # review first -uv run dapier calendar create ... --send-updates all # actually invite -uv run dapier calendar update EVENT_ID --summary Renamed --calendar primary +uv run dapier calendar create ... --send-updates all --account me@gmail.com # actually invite +uv run dapier calendar update EVENT_ID --summary Renamed --calendar primary --account me@gmail.com ``` -With several Google accounts connected, `--account` (verified email or -connection id) is required on `gmail`/`gdrive`/`calendar` operations; +`--account` (verified email or connection id) is required even with one +connected account on `gmail`/`gdrive`/`calendar` operations; ambiguous or missing selections are rejected with the candidate list. Insufficient-scope operations fail with the provider's 403 plus a -`dapier connections edit --scopes` hint. Write verbs accept `--preview` +`dapier connections edit --scopes` hint. Composition, selected Drive writes, and Calendar writes accept `--preview` to print the exact request body without sending; calendar event windows are bounded (≤ 366 days) and guests are only emailed via `--send-updates`. diff --git a/.agents/skills/google-data-pull/SKILL.md b/.agents/skills/google-data-pull/SKILL.md index 3239214..9c1e7d2 100644 --- a/.agents/skills/google-data-pull/SKILL.md +++ b/.agents/skills/google-data-pull/SKILL.md @@ -1,251 +1,195 @@ --- name: google-data-pull -description: Pull data read-only from Alexey's Gmail (alexey.s.grigoriev@gmail.com and alexey@datatalks.club), Google Calendar, and Google Drive/Docs/Sheets using short-lived access tokens from Dapier's OAuth connections. Covers which connection serves which account, in-script token fetch via dtoken.py, Gmail search/pagination/bodies/attachments, Calendar events in a window, and Drive search plus Docs/Sheets export to text/CSV. Use when asked to read, search, export, or summarize mail, calendar events, or Drive files. +description: Read Google data (Gmail, Calendar, Drive/Docs/Sheets) for Alexey's accounts through the dapier CLI — `dapier gmail`, `dapier gdrive`, `dapier calendar` with explicit --account selection and `dapier gdocs` for Doc text. Covers account discovery, search/pagination/bodies/attachments, bounded calendar windows, free/busy, Drive search and export. gpull.py remains the fallback for raw .eml, unbounded sweeps, and multi-tab Sheets CSV. Use when asked to read, search, export, or summarize mail, calendar events, or Drive files. --- # Pull Google data through Dapier (read-only) -Dapier holds the Google OAuth connections. The refresh tokens and the OAuth -client secret never leave Dapier. Scripts fetch a short-lived access token -in-script via `dtoken.py` (next to this file) and keep it in a local -variable. `gpull.py`, next to this file, is a stdlib-only client that does -this itself and makes only GET requests. +Dapier holds the Google OAuth connections; refresh tokens and client secrets +never leave Dapier. The CLI talks to the agent API server-side — you never +touch a token. Most reads are one `dapier` command now (2026-11-04, +dapier PR #36); `gpull.py` (next to this file) stays for the operations the +CLI does not cover (raw `.eml`, unbounded sweeps, multi-tab Sheets CSV). -Run everything from the dapier repo root. +Run everything from the dapier repo root: -## Where credentials live +```bash +uv run dapier gmail --help +uv run dapier gdrive --help +uv run dapier calendar --help +``` -| What | Where | You touch it? | -|---|---|---| -| Refresh token, access token, client override | Dapier DynamoDB credential record `oauth#` | Never. Only the API reads it | -| Shared Google OAuth client | Dapier `oauth-clients` (`uv run dapier oauth-clients --help`) | No | -| Your DTC session that authorizes token requests | `~/.config/dapier/session.json` (owner-only) | Through `uv run dapier auth` only | -| Access token handed to you | Local variable from `dtoken.dapier_token()` | Never print, log, or persist it | +## Account discovery and selection (required) -## Which connection serves which account +List the connected Google accounts per service, then pass one explicitly on +every command: -All three are granted to the `admin-os` agent for Alexey's DTC identity -(`uv run dapier grants list`). +```bash +uv run dapier gmail accounts # email, connection id, refs per account +uv run dapier gdrive accounts +uv run dapier calendar accounts +``` -| Account | Connection id | Address it as | Read scopes | -|---|---|---|---| -| alexey.s.grigoriev@gmail.com | `google-calendar` | `gmail alexey.s.grigoriev`, `calendar alexey.s.grigoriev`, `drive alexey.s.grigoriev` | gmail.readonly, drive.readonly, spreadsheets, calendar.events.owned, calendar.freebusy | -| alexey@datatalks.club (mail) | `google-gmail-datatalks` | `gmail datatalks` | gmail.readonly | -| alexey@datatalks.club (Drive/Docs/Sheets) | `google-sheets` | `drive datatalks` | drive.readonly, spreadsheets | +- `--account` takes the verified email (`alexey@datatalks.club`) or the + connection id (`google-gmail-datatalks`) — a unique fragment works + (`--account datatalks`). +- The choice is required even with one connected account; an omitted + or ambiguous selection is rejected with the candidate list. Never guess: + the CLI will not silently pick for you, and you must not either. +- One Google sign-in serves several services; the accounts lists are + service-filtered (a Gmail-only connection does not appear under + `gdrive accounts`). -The DataTalks account has no Calendar connection. The personal connection -uses one token for Gmail, Calendar, and Drive. Its connection id is -`google-calendar` for historical reasons. +Current connections (verify before relying on this): -Check the current state before you rely on this table: +| Account | Connection id | Serves | +|---|---|---| +| alexey.s.grigoriev@gmail.com | `google-calendar` | gmail, calendar, drive (personal) | +| alexey@datatalks.club (mail) | `google-gmail-datatalks` | gmail only | +| alexey@datatalks.club (Drive) | `google-sheets` | drive, docs, sheets | +| orin@datatalks.club | `google-calendar-orin` | calendar | ```bash uv run dapier connections list # account, services, health uv run dapier connections show google-calendar # scopes, expiry (no secrets) -uv run dapier connections test google-gmail-datatalks ``` -## Getting a token - -`gpull.py` fetches the token itself — pass the connection reference and the -agent directly: +## Gmail ```bash -G=.agents/skills/google-data-pull/gpull.py -python3 $G --as "gmail datatalks" --agent admin-os whoami -# {"email": "alexey@datatalks.club", "expires_in": 3598, "scopes": [...]} +# newest messages / Gmail-search-syntax search (id, from, date, subject table) +uv run dapier gmail list --account datatalks --max 25 +uv run dapier gmail search "from:stripe.com newer_than:30d" --account datatalks +uv run dapier gmail search "has:attachment filename:pdf newer_than:60d" --account alexey.s.grigoriev +uv run dapier gmail list --account datatalks --label INBOX --max 50 + +# one message: headers, decoded text body, attachment list +uv run dapier gmail read MESSAGE_ID --account datatalks +uv run dapier gmail read MESSAGE_ID --account datatalks --save-attachments .tmp/google-pull/att +uv run dapier gmail attachment MESSAGE_ID ATTACHMENT_ID --account datatalks --out .tmp/google-pull/file.pdf +uv run dapier gmail thread THREAD_ID --account datatalks # whole conversation +uv run dapier gmail labels --account datatalks + +# pagination: --max caps a page (API cap 100); continue with the printed token +uv run dapier gmail search "newer_than:30d" --account datatalks --max 100 \ + --page-token ``` -The API returns a fresh access token after it checks that the provider -account matches the connection's verified account. The token lives only in -a local variable inside the script. It never appears in argv, the -environment, or the output. +## Calendar (alexey.s.grigoriev@gmail.com) -Own scripts use the same helper (`dtoken.py`, next to `gpull.py`): +```bash +uv run dapier calendar calendars --account alexey.s.grigoriev -```python -import sys -sys.path.insert(0, ".agents/skills/google-data-pull") -import dtoken -access_token = dtoken.dapier_token("gmail datatalks", "admin-os") -# ... Authorization: Bearer {access_token} ... -del access_token +# bounded window — both bounds required, span <= 366 days, occurrences expanded +uv run dapier calendar events --account alexey.s.grigoriev \ + --time-min "$(date -u +%Y-%m-%dT%H:%M:%SZ)" --time-max 2026-11-11T00:00:00Z --max 5 +uv run dapier calendar events --account alexey.s.grigoriev \ + --time-min 2026-10-01T00:00:00Z --time-max 2026-10-15T00:00:00Z --query podcast + +# free/busy for one or more calendars +uv run dapier calendar freebusy --account alexey.s.grigoriev \ + --calendar primary --time-min 2026-11-04T00:00:00Z --time-max 2026-11-05T00:00:00Z + +# one event +uv run dapier calendar event EVENT_ID --account alexey.s.grigoriev ``` -### Refresh handling - -- Dapier refreshes the token server-side. A request refreshes the token if - the stored access token has expired or Google rejects it. Then Dapier - re-verifies the account binding. You never handle a refresh token. -- A token can arrive with only a few minutes left. Dapier refreshes on expiry, - not ahead of it. `whoami` reports `expires_in`. Fetch one token per command - or batch. For a long job, fetch again for each chunk. Do not cache a token. -- An HTTP 401 from Google mid-run means the token expired. Fetch a new token - and run the command again. -- If the fetch fails with "no verified provider account", "revoked", - or a binding mismatch, the connection needs operator consent. Refresh it with - the `dapier-refresh-connections` skill or - `uv run dapier connections connect --agent admin-os`. Do not work - around it. +Known scope gap (unchanged): the `google-calendar` connection carries +`calendar.events.owned` + `calendar.freebusy` but **not** +`calendar.readonly`, so `calendar calendars` may fail with the provider's +403 (the CLI relays it with a `connections edit --scopes` hint) while +`events` works on owned calendars including `primary`. An operator adds the +scope and re-consents; do not work around it. -## Gmail +## Drive, Docs, Sheets ```bash -G=.agents/skills/google-data-pull/gpull.py -PULL="python3 $G --agent admin-os --as" - -# 5 newest messages (id, thread_id, subject, from, to, date, labels, snippet), JSONL -$PULL "gmail datatalks" gmail list --max 5 -$PULL "gmail alexey.s.grigoriev" gmail list --max 5 - -# Gmail search syntax (same as the Gmail search box) -$PULL "gmail datatalks" gmail list --q "from:stripe.com newer_than:30d" -$PULL "gmail datatalks" gmail list --q 'subject:(invoice OR receipt) after:2026/09/01 before:2026/10/01' -$PULL "gmail alexey.s.grigoriev" gmail list --q "has:attachment filename:pdf newer_than:60d" -$PULL "gmail datatalks" gmail list --q "in:sent to:someone@example.com" --max 10 -$PULL "gmail datatalks" gmail list --label INBOX --label UNREAD - -# Pagination: --max N follows nextPageToken until N messages; --max 0 = everything. -# --ids-only skips the per-message metadata request (much faster for large sweeps). -$PULL "gmail datatalks" gmail list --q "newer_than:30d" --ids-only --max 0 > ids.jsonl - -# Bodies: decoded text/plain (falls back to tag-stripped HTML) + attachment list -$PULL "gmail datatalks" gmail get -$PULL "gmail datatalks" gmail get --out .tmp/google-pull/msg.json -$PULL "gmail datatalks" gmail thread # whole conversation -$PULL "gmail datatalks" gmail raw --out .tmp/google-pull/msg.eml -$PULL "gmail alexey.s.grigoriev" gmail attachment --out .tmp/google-pull/file.pdf -$PULL "gmail datatalks" gmail labels +# name/Drive-query search, newest first +uv run dapier gdrive search "name contains 'newsletter'" --account datatalks --max 10 +uv run dapier gdrive search "fullText contains 'newsletter'" --account datatalks +uv run dapier gdrive list --account datatalks --max 25 + +# metadata, download/export (Google-native: doc/slide -> PDF, sheet -> CSV, drawing -> PNG) +uv run dapier gdrive meta FILE_ID --account datatalks +uv run dapier gdrive download DOC_ID --account datatalks --out .tmp/google-pull/doc.pdf +uv run dapier gdrive download DOC_ID --account datatalks --export-mime text/plain --out .tmp/google-pull/doc.txt ``` -`list` makes one request per message for metadata. For thousands of -messages, use `--ids-only` and fetch only the messages you need. +Docs text read/write stays on `dapier gdocs` (token minted in-process, +never printed): -## Calendar (alexey.s.grigoriev@gmail.com) +``` +dapier gdocs get # title, revision, plain text +dapier gdocs append --file text.txt # or --text "..." or stdin +dapier gdocs replace --find "PLACEHOLDER" --text "new" +``` -```bash -# Next 5 events from now on the primary calendar (recurring events expanded, ordered by start) -python3 $G --as "calendar alexey.s.grigoriev" --agent admin-os calendar events --start "$(date -u +%Y-%m-%dT%H:%M:%SZ)" --max 5 +Default `--as "drive datatalks"`; personal docs: `--as "drive +alexey.s.grigoriev"`. The account must have edit access. Write only when +the user asked for that write. -# A window: dates mean 00:00 UTC; --end is exclusive; --max 0 = every page -python3 $G --as "calendar alexey.s.grigoriev" --agent admin-os calendar events --start 2026-10-01 --end 2026-10-15 --max 0 -python3 $G --as "calendar alexey.s.grigoriev" --agent admin-os calendar events --start 2026-10-14T00:00:00+02:00 --end 2026-10-21T00:00:00+02:00 --max 0 -python3 $G --as "calendar alexey.s.grigoriev" --agent admin-os calendar events --q "podcast" --start 2026-10-01 -``` +## gpull.py fallback: what the CLI does not cover + +`gpull.py` (stdlib-only, GET-only, token in a local variable) remains the +tool for: -`calendar calendars` (the calendar list) currently fails with **HTTP 403 -insufficient authentication scopes**. The `google-calendar` connection has -`calendar.events.owned` and `calendar.freebusy` but not -`calendar.readonly`. The Google plugin README says it should have that scope. -`events` works on calendars the account owns, including `primary`. To read -other calendars or the calendar list, an operator must add the scope and then -give consent again. Keep the existing scopes, because `--scopes` sets the full -list: +1. **Raw messages** as `.eml`: `gmail raw --out msg.eml`. +2. **Unbounded sweeps**: `gmail list --ids-only --max 0` walks every page + without per-message metadata requests (the CLI caps a page at 100). +3. **Every spreadsheet tab as CSV** via the Sheets API + (`drive sheet --out-dir …`), preserving numeric amounts and spreadsheet-formatted + dates — DataOps bookkeeping keys on those filenames. `gdrive download` + on a sheet exports only the first tab. ```bash -uv run dapier connections edit google-calendar --scopes \ - https://www.googleapis.com/auth/calendar.events.owned \ - https://www.googleapis.com/auth/calendar.freebusy \ - https://www.googleapis.com/auth/calendar.readonly \ - https://www.googleapis.com/auth/documents \ - https://www.googleapis.com/auth/drive.readonly \ - https://www.googleapis.com/auth/gmail.readonly \ - https://www.googleapis.com/auth/gmail.send \ - https://www.googleapis.com/auth/spreadsheets \ - https://www.googleapis.com/auth/userinfo.email openid -uv run dapier connections connect google-calendar --agent admin-os # browser consent as alexey.s.grigoriev@gmail.com +G=.agents/skills/google-data-pull/gpull.py +python3 $G --as "gmail datatalks" --agent admin-os gmail list --q "newer_than:30d" --ids-only --max 0 > ids.jsonl +python3 $G --as "gmail datatalks" --agent admin-os gmail raw --out .tmp/google-pull/msg.eml +python3 $G --as "drive alexey.s.grigoriev" --agent admin-os drive sheet --out-dir .tmp/google-pull/sheet ``` -## Drive, Docs, Sheets +Custom scripts may still mint one token in-process via `dtoken.py` — for +the covered operations, prefer the CLI (no token handling at all): -```bash -PULL="python3 $G --agent admin-os --as" -# Name search, newest first: id, name, mime, modified, size, owners, link -$PULL "drive datatalks" drive search --name newsletter --max 10 -$PULL "drive alexey.s.grigoriev" drive search --name newsletter - -# Content search, type filter, shared drives, raw Drive query clauses (ANDed) -$PULL "drive datatalks" drive search --fulltext newsletter --mime application/vnd.google-apps.document -$PULL "drive datatalks" drive search --name newsletter --all-drives -$PULL "drive datatalks" drive search --q "modifiedTime > '2026-09-01T00:00:00'" --max 0 - -# Export: Google Doc -> text/plain, Sheet -> CSV (FIRST TAB ONLY), Slides -> text; -# non-Google files (PDF, images, ...) are downloaded as-is. -$PULL "drive datatalks" drive export --out .tmp/google-pull/doc.txt -$PULL "drive datatalks" drive export --mime text/markdown --out .tmp/google-pull/doc.md -$PULL "drive alexey.s.grigoriev" drive export --out .tmp/google-pull/sheet.csv -$PULL "drive alexey.s.grigoriev" drive export --out .tmp/google-pull/file.pdf -$PULL "drive datatalks" drive meta - -# Every tab of a spreadsheet as CSV (Sheets API), or one A1 range -$PULL "drive alexey.s.grigoriev" drive sheet --out-dir .tmp/google-pull/sheet -$PULL "drive datatalks" drive sheet --range "Sheet1!A1:D50" +```python +import sys +sys.path.insert(0, ".agents/skills/google-data-pull") +import dtoken +access_token = dtoken.dapier_token("gmail datatalks", "admin-os") +# ... Authorization: Bearer {access_token} ... +del access_token ``` -`drive sheet` writes one CSV per tab using unformatted cell values and -spreadsheet-formatted date/time strings. This preserves numeric amounts that -the sheet displays with rounding or separators and keeps dates in the format -the sheet shows. Filenames keep the existing sanitized tab-name convention. -When two titles would map to the same filename, `drive sheet --out-dir` fails -before reading cell values or writing any CSV. DataOps uses the filename as -the tab identity, so adding a suffix could make a tab look new to bookkeeping. -`drive export ` remains the Drive export path for existing -single-file consumers and exports only the first tab. - -The synthetic Dapier-to-DataOps regression is repeatable with -`python3 .agents/skills/google-data-pull/run_dataops_contract.py`. It checks -out DataOps PR 307 at its pinned consumer commit, mocks the Sheets API, and -runs the generated two-tab CSV through DataOps' dry-run path. The harness also -checks that Node reports the expected dry-run test title, so a stale name -filter cannot pass after selecting zero tests. Update the pin and test title -only after reviewing a changed PR head. - -Use the Drive connection of an account that can see the file. Many DataTalks -files are shared with both accounts. If one connection returns 404 for a -file, try the other one. +The DataOps regression harness is unchanged: +`python3 .agents/skills/google-data-pull/run_dataops_contract.py` (mocks +the Sheets API, dry-runs the pinned DataOps PR; see its header). ## Output conventions -- Listings print one JSON object per line (JSONL) to stdout. Pipe them to `jq` - or `python3 -c`, or redirect them to a file. -- Files go to the `--out` path. The written path is printed on stderr, and - `--out -` (the default for `export`/`raw`) writes to stdout. -- Put pulled data under `.tmp/google-pull/` (gitignored) or a scratch - directory outside the repo. Never commit pulled mail, events, or documents. -- Errors exit non-zero with `HTTP for : `. - The output never includes tokens. +- The CLI prints tables by default and `--json` for raw output; `gpull.py` + prints JSONL to stdout. Pipe to `jq`/`python3` or redirect. +- Files go to `--out` paths you name; keep pulled data under + `.tmp/google-pull/` (gitignored) or a scratch directory outside the repo. + Never commit pulled mail, events, or documents. +- Provider errors exit non-zero with the API's message (plus a + `connections edit --scopes` hint on 403s). Tokens never appear anywhere. ## Read-only safety rules -- Only read. Do not send, draft, label, archive, trash, or delete mail. Do not - create or edit events. Do not create, edit, share, or delete Drive files. - Some connections also have write scopes (`gmail.send`, `documents`, - `spreadsheets`, `calendar.events.owned`) that Dapier flows use. Do not use - them here. -- `gpull.py` sends only GET requests to Google APIs (and reads token metadata - from the tokeninfo endpoint). Its request helper refuses other methods. - Keep that guard when you extend it, and do not call Google write endpoints - with curl as a workaround. -- Never print, log, or write any token. Do not pass a token on a command - line, in the environment, or in a file. Refer to credentials only by - location (connection reference + agent). +- Only read. Do not send, draft, label, archive, trash, or delete mail. Do + not create, edit, or delete events. Do not create, edit, share, move, or + trash Drive files. **The new CLI nouns can write** (`gmail send/reply`, + `gdrive upload/share/trash`, `calendar create/update/delete`; composition + and selected Drive/Calendar writes have `--preview`, Calendar uses + `--send-updates`), but that does not change this + rule: use those verbs only when the task explicitly asks for exactly that + write. +- Exceptions stay as before: `dapier gdocs append|replace` when the user + asked for that Doc edit. +- Never print, log, or write any token; `dtoken` tokens live in a local + variable only. Do not pass tokens on a command line, in the environment, + or in files. - Do not add scopes, create connections, or change grants to finish a pull. Report what the operator needs to do (see the Calendar scope gap above). - Report counts and the minimum content needed. Mail, calendar, and Drive content is private. - -## Writing to Google Docs: `dapier gdocs` - -The Drive connections carry the `documents` scope, so Docs text can be read and -written with the CLI (token minted in-process, never printed): - -``` -dapier gdocs get # title, revision, plain text -dapier gdocs append --file text.txt # or --text "..." or stdin -dapier gdocs replace --find "PLACEHOLDER" --text "new" -``` - -Default `--as "drive datatalks"` (alexey@datatalks.club); personal docs: -`--as "drive alexey.s.grigoriev"`. The account must have edit access to the -doc. Write only when the user asked for that write. Drive files themselves -(create/share/move) are not covered. From d56b9111b9776172f51b3d6b026b079704590d75 Mon Sep 17 00:00:00 2001 From: Alexey Grigorev Date: Wed, 4 Nov 2026 17:39:07 +0100 Subject: [PATCH 4/6] test(console): count shared sign-in rows independently of action layout --- tests/test_console_connection_add.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/tests/test_console_connection_add.py b/tests/test_console_connection_add.py index 8c1b22d..6e539d4 100644 --- a/tests/test_console_connection_add.py +++ b/tests/test_console_connection_add.py @@ -123,8 +123,9 @@ def test_shared_sign_in_shows_once_with_compact_references(): "granted_scopes": ["https://www.googleapis.com/auth/gmail.readonly", "https://www.googleapis.com/auth/drive.readonly"]}] register = _render(connections)['#connection-register']['innerHTML'] - assert register.count('class="service-account"') == 1 - assert register.count('class="service-account service-ref"') == 1 + rows = re.findall(r'
  • Date: Wed, 4 Nov 2026 17:42:57 +0100 Subject: [PATCH 5/6] fix(console): open Google tools for paged connection rows --- src/web/js/views/connections.js | 2 +- tests/test_google_console_parity.py | 18 ++++++++++++++++++ 2 files changed, 19 insertions(+), 1 deletion(-) diff --git a/src/web/js/views/connections.js b/src/web/js/views/connections.js index 17911dd..7f763d2 100644 --- a/src/web/js/views/connections.js +++ b/src/web/js/views/connections.js @@ -902,7 +902,7 @@ function renderConnectionsNow(connections) { $$('.connection-remove').forEach((button) => button.addEventListener('click', () => deleteConnection(button.dataset.connection))); $$('.connection-finish').forEach((button) => button.addEventListener('click', () => openZoomWebhookSetup(button.dataset.connection))); $$('.connection-google-tools').forEach((button) => button.addEventListener('click', () => { - const connection = ((state.data || {}).connections || []).find((c) => c.connection_id === button.dataset.connection); + const connection = connections.find((c) => c.connection_id === button.dataset.connection); if (connection) openGoogleTools(connection); })); bindConnectButtons(); diff --git a/tests/test_google_console_parity.py b/tests/test_google_console_parity.py index 4c3cbaa..2e999a2 100644 --- a/tests/test_google_console_parity.py +++ b/tests/test_google_console_parity.py @@ -79,3 +79,21 @@ def reply(route): assert body['draft'] is True and body['message_id'] == 'm1' assert body['attachments'][0]['content_b64'] == '/wA=' browser.close() + + +def test_google_tools_opens_for_paged_connection_outside_overview(): + pytest.importorskip('playwright.sync_api') + from playwright.sync_api import sync_playwright + from test_console_boot import console_server, _chromium + with console_server() as origin, sync_playwright() as playwright: + browser = _chromium(playwright) + page = browser.new_page() + page.route('**/api/admin/connections?*', lambda route: route.fulfill(json={ + 'connections': [{'connection_id': 'paged-google', 'provider': 'google', + 'status': 'connected', 'verified_account_id': 'paged@example.com', + 'granted_scopes': ['https://www.googleapis.com/auth/gmail.readonly']}]})) + page.goto(origin + '/connections') + page.locator('.connection-google-tools').first.click() + page.locator('#google-tools-dialog').wait_for(state='visible') + assert 'paged@example.com' in page.locator('#google-tools-dialog').inner_text() + browser.close() From 102263184b4687060f29096ca36b6f975da56416 Mon Sep 17 00:00:00 2001 From: Alexey Grigorev Date: Wed, 4 Nov 2026 17:48:11 +0100 Subject: [PATCH 6/6] fix(cli): honor Drive mkdir preview at the command entrypoint --- dapier_cli/cli/gdrive.py | 2 +- tests/test_gmail_gdrive_cli.py | 14 ++++++++++++++ 2 files changed, 15 insertions(+), 1 deletion(-) diff --git a/dapier_cli/cli/gdrive.py b/dapier_cli/cli/gdrive.py index e0b5dc9..9bfe997 100644 --- a/dapier_cli/cli/gdrive.py +++ b/dapier_cli/cli/gdrive.py @@ -188,5 +188,5 @@ def run(args, api_url, debug, child=None): as_json=as_json, debug=debug) if args.command == "mkdir": return c.gdrive_mkdir(api_url, args.name, account=account, parent=args.parent, - as_json=as_json, debug=debug) + as_json=as_json, preview=args.preview, debug=debug) return 2 diff --git a/tests/test_gmail_gdrive_cli.py b/tests/test_gmail_gdrive_cli.py index b859421..560100b 100644 --- a/tests/test_gmail_gdrive_cli.py +++ b/tests/test_gmail_gdrive_cli.py @@ -155,3 +155,17 @@ def test_cli_requires_account_for_each_service(): ['gdrive', 'list'], ['calendar', 'calendars']): with pytest.raises(SystemExit): build_parser().parse_args(command) + + +def test_mkdir_entrypoint_forwards_preview_without_api_call(monkeypatch, capsys): + import json + from dapier_cli.cli import build_parser, gdrive + from dapier_cli import api + def unexpected(*args, **kwargs): + raise AssertionError('preview must never call the API') + monkeypatch.setattr(api, 'call', unexpected) + args = build_parser().parse_args(['gdrive', 'mkdir', 'Example', '--parent', 'PARENT', + '--account', ACCOUNT_A, '--preview']) + assert gdrive.run(args, 'http://api', False) == 0 + assert json.loads(capsys.readouterr().out)['would_send'] == { + 'account': ACCOUNT_A, 'name': 'Example', 'parent_id': 'PARENT'}