Files
PaperClipAI/server/src/services/gmail-archive-tool-policy.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

111 lines
3.3 KiB
TypeScript

import { badRequest } from "../errors.js";
type Connection = { config: Record<string, unknown> };
function record(value: unknown): Record<string, unknown> {
return value !== null && typeof value === "object" && !Array.isArray(value)
? value as Record<string, unknown>
: {};
}
function leafName(name: string): string {
return (name.split(/[.:/]/).pop() ?? name)
.replace(/([a-z0-9])([A-Z])/g, "$1-$2")
.replace(/_/g, "-")
.toLowerCase();
}
function isOfficialGmailConnection(connection: Connection): boolean {
const config = connection.config;
const profile = record(config.oauth).connectorProfile;
if (
config.sourceTemplateKey === "gmail"
|| profile === "gmail.read"
|| profile === "gmail.draft"
|| profile === "gmail.archive"
) return true;
try {
return new URL(String(config.url)).hostname === "gmailmcp.googleapis.com";
} catch {
return false;
}
}
function isArchiveConnection(connection: Connection): boolean {
const config = connection.config;
return record(config.oauth).connectorProfile === "gmail.archive"
|| (config.sourceTemplateKey === "gmail"
&& (config.connectionMethodKey === "paperclip-archive"
|| config.connectionMethodKey === "customer-archive-oauth"));
}
function isUnlabelThread(connection: Connection, toolName: string): boolean {
return isOfficialGmailConnection(connection) && leafName(toolName) === "unlabel-thread";
}
export function assertGmailArchiveToolArgumentsSupported(
connection: Connection,
toolName: string,
parameters: unknown,
): void {
if (!isUnlabelThread(connection, toolName)) return;
if (!isArchiveConnection(connection)) {
throw badRequest("Gmail archiving requires an explicit Read & archive connection.", {
code: "gmail_archive_profile_required",
});
}
const supplied = record(parameters);
const keys = Object.keys(supplied).sort();
if (
keys.length !== 2
|| keys[0] !== "labelIds"
|| keys[1] !== "threadId"
|| typeof supplied.threadId !== "string"
|| supplied.threadId.trim().length === 0
|| !Array.isArray(supplied.labelIds)
|| supplied.labelIds.length !== 1
|| supplied.labelIds[0] !== "INBOX"
) {
throw badRequest("Gmail archive accepts only threadId and labelIds: [\"INBOX\"].", {
code: "gmail_archive_arguments_unsupported",
});
}
}
export function gmailArchiveToolInputSchema(
connection: Connection,
toolName: string,
schema: Record<string, unknown>,
): Record<string, unknown> {
if (!isUnlabelThread(connection, toolName) || !isArchiveConnection(connection)) return schema;
return {
type: "object",
properties: {
threadId: {
type: "string",
minLength: 1,
description: "The Gmail thread ID to archive.",
},
labelIds: {
type: "array",
items: { type: "string", enum: ["INBOX"] },
minItems: 1,
maxItems: 1,
description: "Must be exactly [\"INBOX\"].",
},
},
required: ["threadId", "labelIds"],
additionalProperties: false,
};
}
export function gmailArchiveToolDescription(
connection: Connection,
toolName: string,
description: string | null,
): string | null {
return isUnlabelThread(connection, toolName) && isArchiveConnection(connection)
? "Archive a Gmail thread by removing only its Inbox label. Requires human approval."
: description;
}