Files
PaperClipAI/server/src/services/gmail-archive-tool-policy.test.ts
T
Leia MarshallandLeia Marshall e4ce989099 Add opt-in Gmail archive capability (#16331)
## Thinking Path

> - Paperclip separates Gmail capabilities by reviewed OAuth scope and
tool policy.
> - Existing Gmail connections support reading and draft creation but
not bounded archiving.
> - Gmail archive requires `gmail.modify`, so it must not widen existing
read or draft grants.
> - This change adds an opt-in archive profile and limits it to removing
only the `INBOX` label.
> - A final pre-dispatch check preserves that boundary for approved and
replayed calls.

## Linked Issues or Issue Description

**What existing behavior does this improve?**

This improves the existing Gmail connection and tool-governance
behavior.

**Subsystem affected**

Cross-cutting: `packages/shared` and `server`.

**Current behavior**

Paperclip can read Gmail and create drafts. It does not provide an
opt-in, bounded action for archiving a thread.

**Proposed behavior**

Paperclip offers a separate Read & archive connection. It exposes
`unlabel_thread` only for that connection and accepts only `threadId`
with `labelIds: ["INBOX"]`.

**Reason and benefit**

Users can archive processed Gmail threads while sending, trashing, and
other label changes remain disabled.

**Breaking changes**

None. Existing read and draft methods keep their current scopes and
defaults.

Related work: #15545 adds a unified Google Workspace connector and keeps
Gmail draft-only. #15412 adds a separate Gmail REST server without label
mutations. Neither PR adds this bounded archive capability.

## What Changed

- Added the `gmail.archive` connector profile.
- Added managed and customer-owned archive connection methods.
- Added an Inbox-only schema and final pre-dispatch argument guard.
- Classified archive as destructive and added Ask-first defaults for the
archive methods.
- Added permission-review records and focused tests.

## Verification

- `pnpm --filter @paperclipai/server typecheck`
- `pnpm --filter @paperclipai/server build`
- `pnpm --filter @paperclipai/shared typecheck`
- Focused Vitest suites: 42 tests passed.
- App-definition ingestion: 103 definitions and 0 review flags.
- `git diff --check`

## Risks

The main risk is accidental scope or operation expansion. The new
profile is opt-in. Existing profiles retain their scopes. The gateway
validates the exact Inbox-only payload immediately before dispatch.
Production rollout requires separate OAuth configuration and fresh
consent.

## Model Used

Unverified — reliable runtime model metadata was not available. Confirm
the model attribution before publishing.

## Checklist

- [x] I have included a concise design rationale
- [x] I have specified that model attribution is unverified
- [x] I have checked ROADMAP.md and found no Gmail archive entry
- [x] I have searched GitHub for duplicate or related PRs and linked
them above
- [x] I have described the issue in this PR
- [x] I have not referenced internal or instance-local issues
- [x] My branch name describes the change
- [x] Local tests and builds pass
- [x] I have added or updated tests
- [x] I have updated relevant documentation and permission records
- [x] I have documented risks
- [ ] All hosted CI gates are green
- [ ] Greptile and human review are complete

---------

Co-authored-by: Leia Marshall <leia@paperclip.ing>
2026-10-15 09:24:31 -07:00

79 lines
3.1 KiB
TypeScript

import { describe, expect, it } from "vitest";
import {
assertGmailArchiveToolArgumentsSupported,
gmailArchiveToolDescription,
gmailArchiveToolInputSchema,
} from "./gmail-archive-tool-policy.js";
const archive = { config: {
sourceTemplateKey: "gmail",
connectionMethodKey: "paperclip-archive",
oauth: { connectorProfile: "gmail.archive" },
} };
describe("Gmail archive tool policy", () => {
it("accepts only removal of the Inbox label from one thread", () => {
expect(() => assertGmailArchiveToolArgumentsSupported(archive, "unlabel_thread", {
threadId: "thread-123",
labelIds: ["INBOX"],
})).not.toThrow();
for (const parameters of [
{ threadId: "thread-123", labelIds: ["STARRED"] },
{ threadId: "thread-123", labelIds: ["INBOX", "UNREAD"] },
{ threadId: "thread-123", label_ids: ["INBOX"] },
{ threadId: "", labelIds: ["INBOX"] },
{ threadId: "thread-123", labelIds: ["INBOX"], extra: true },
"encoded",
]) {
expect(() => assertGmailArchiveToolArgumentsSupported(archive, "unlabel_thread", parameters))
.toThrow(expect.objectContaining({
status: 400,
details: { code: "gmail_archive_arguments_unsupported" },
}));
}
});
it("requires an explicit archive connection even for the official Gmail endpoint", () => {
for (const config of [
{ sourceTemplateKey: "gmail", connectionMethodKey: "paperclip-read", oauth: { connectorProfile: "gmail.read" } },
{ sourceTemplateKey: "gmail", connectionMethodKey: "paperclip-draft", oauth: { connectorProfile: "gmail.draft" } },
{ url: "https://gmailmcp.googleapis.com/mcp/v1" },
]) {
expect(() => assertGmailArchiveToolArgumentsSupported({ config }, "unlabel_thread", {
threadId: "thread-123", labelIds: ["INBOX"],
})).toThrow(expect.objectContaining({
status: 400,
details: { code: "gmail_archive_profile_required" },
}));
}
});
it("publishes an Inbox-only schema without mutating the provider schema", () => {
const providerSchema = { type: "object", properties: {
threadId: { type: "string" },
labelIds: { type: "array", items: { type: "string" } },
} };
const projected = gmailArchiveToolInputSchema(archive, "gmail/unlabel_thread", providerSchema);
expect(projected).toMatchObject({
properties: { labelIds: { items: { enum: ["INBOX"] }, minItems: 1, maxItems: 1 } },
required: ["threadId", "labelIds"],
additionalProperties: false,
});
expect(providerSchema.properties.labelIds.items).toEqual({ type: "string" });
});
it("describes archive narrowly and leaves unrelated tools unchanged", () => {
expect(gmailArchiveToolDescription(archive, "unlabel_thread", "Remove labels."))
.toContain("removing only its Inbox label");
expect(gmailArchiveToolDescription(archive, "search_threads", "Search mail."))
.toBe("Search mail.");
expect(() => assertGmailArchiveToolArgumentsSupported(
{ config: { sourceTemplateKey: "other" } },
"unlabel_thread",
{ anything: true },
)).not.toThrow();
});
});