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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 22 additions & 11 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -235,14 +235,24 @@ is marked as mailbox-mutating:
| `delete_inbox_rule` | Delete a server-side inbox rule (requires operator env + caller flag) (Microsoft 365) | destructive |

This is a shared cross-provider tool surface. Although `label_email`,
`flag_email`, `mark_read`, `move_to_folder`, and `delete_email` are advertised,
the Gmail adapter intentionally does not implement those mutation capabilities.
They fail closed before any Gmail mutation request: unsupported actions return
`NOT_SUPPORTED`, while deletion can stop first at the default `DELETE_DISABLED`
operator gate. The default `gmail.readonly` plus `gmail.compose` grant would not
authorize those mutations either; do not add `gmail.modify` to try to make the
tools available. They remain available only for providers whose adapters and
grants support them.
`flag_email`, `mark_read`, and `move_to_folder` are advertised, the Gmail
adapter intentionally does not implement those mutation capabilities. They fail
closed with `NOT_SUPPORTED` before any Gmail mutation request. The default
`gmail.readonly` plus `gmail.compose` grant would not authorize those mutations
either; do not add `gmail.modify` to try to make the tools available. They
remain available only for providers whose adapters and grants support them.

On Gmail, `delete_email` deletes **drafts only**, through `users.drafts.delete`,
which `gmail.compose` authorizes. Pass either the draft id (`r…`) or the id of
the draft's message (what `list_emails` with `folder: "drafts"` returns). The
delete is permanent, because Gmail has no trash for drafts, but it still sits
behind the same `AGENT_EMAIL_DELETE_ENABLED` gate and
`user_explicitly_requested_deletion` flag; it does not need
`AGENT_EMAIL_HARD_DELETE_ENABLED`. Deleting a sent or received Gmail message
returns `NOT_SUPPORTED`, since it would need `gmail.modify`. `update_draft`
likewise accepts either id on Gmail. Gmail gives a draft a new message id every
time it is updated, while its draft id stays the same, so prefer the draft id
(or list the drafts again) after an edit.

Every row returned by `list_emails`, `search_emails`, and `get_thread`, and the
`read_email` response, carries an always-present `isDraft` boolean. `isDraft: true`
Expand Down Expand Up @@ -520,8 +530,9 @@ https://www.googleapis.com/auth/gmail.compose
`gmail.compose` manages drafts and sending but does not authorize
`users.threads.get` or `users.messages.get`. The default grant does not include
`gmail.modify`. Separately, the Gmail adapter intentionally leaves label
changes, read-state changes, moves, trash, and delete operations unsupported,
so those tools fail closed with `NOT_SUPPORTED`. Add both scopes to your OAuth
changes, read-state changes, moves, trash, and deletion of sent or received
mail unsupported, so those tools fail closed with `NOT_SUPPORTED`. Deleting a
draft works under the default grant. Add both scopes to your OAuth
consent screen.

Deployments that explicitly set `GMAIL_OAUTH_SCOPES` must update it to the two
Expand Down Expand Up @@ -621,7 +632,7 @@ Agent Email ships with restrictive defaults that you loosen as needed:
- **Send allowlist**: empty by default -- agents cannot send email until you add recipients
- **Receive allowlist**: accepts all by default -- controls which senders trigger the watcher
- **Delete disabled**: agents cannot delete email by default. Two gates must both be satisfied:
1. operator sets `AGENT_EMAIL_DELETE_ENABLED=true` in the email-agent-mcp process environment (and `AGENT_EMAIL_HARD_DELETE_ENABLED=true` for permanent deletion). Restart required after change.
1. operator sets `AGENT_EMAIL_DELETE_ENABLED=true` in the email-agent-mcp process environment (and `AGENT_EMAIL_HARD_DELETE_ENABLED=true` for permanent deletion). Restart required after change. Gmail can delete only drafts, and a draft delete is always permanent; it needs only `AGENT_EMAIL_DELETE_ENABLED`.
2. the caller passes `user_explicitly_requested_deletion: true` on the tool call.
- **Error sanitization**: API keys, file paths, and stack traces are redacted from error responses
- **Body file sandboxing**: no `../` traversal, no symlinks, binary detection
Expand Down
19 changes: 19 additions & 0 deletions openspec/changes/add-gmail-draft-delete/proposal.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
# Gmail Draft-Only Deletion

## Why

`delete_email` cannot delete anything on a Gmail mailbox, even with `AGENT_EMAIL_DELETE_ENABLED=true`, because the Gmail provider implements no `deleteMessage` (issue #218). A superseded draft therefore cannot be removed once a replacement exists, and the fallback of stripping its recipients is unsafe: Gmail refills a reply draft's thread recipient, so a retired "SUPERSEDED - do not send" draft was sent to a customer. Google's `users.drafts.delete` accepts `gmail.compose`, so deleting a draft needs no new scope and no restricted-scope verification.

## What Changes

- The Gmail provider implements `deleteMessage` for drafts only. It accepts a draft id (`r…`) or the id of the draft's message, resolving the latter through `users.drafts.list`, and calls `users.drafts.delete`. The delete is permanent whatever `hard_delete` says, because Gmail has no trash for drafts.
- A sent or received Gmail message is refused with `NOT_SUPPORTED`, naming `gmail.modify` as the scope it would need. Agent Email still does not request `gmail.modify`.
- The policy gate is unchanged. A Gmail draft delete needs `AGENT_EMAIL_DELETE_ENABLED=true` and `user_explicitly_requested_deletion: true`, and does not need `AGENT_EMAIL_HARD_DELETE_ENABLED`: it is irreversible, but the draft was never sent.
- `update_draft` on Gmail accepts the draft's message id as well as its draft id, using the same resolution, so a draft created in the Gmail UI and found through `list_emails(folder: "drafts")` can be edited.
- `delete_email` returns provider failures as structured errors (`NOT_SUPPORTED`, `MESSAGE_NOT_FOUND`, `DELETE_FAILED`) instead of throwing.
- Out of scope: `send_draft` still requires the draft id, because its duplicate-send guard keys on the id the caller passes; a `retire_draft` helper is left for later.

## Impact

- Affected specs: `provider-gmail`, `email-categorize`, `email-security` (Delete Policy carve-out for never-delivered items a provider cannot trash)
- Affected code: `provider-gmail` (provider, googleapis client, errors), `email-core` (`delete_email`, `update_draft` description), README
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
## ADDED Requirements

### Requirement: Delete Provider Errors

`delete_email` SHALL return provider failures as structured results rather than throwing: a `ProviderError` keeps its code, message, and recoverability; `MessageNotFoundError` becomes `MESSAGE_NOT_FOUND`; any other failure becomes `DELETE_FAILED`.

#### Scenario: Provider refusal is returned as a structured error
- **WHEN** the provider's `deleteMessage` raises `ProviderError('NOT_SUPPORTED', …)`
- **THEN** `delete_email` returns `{success: false, error: {code: "NOT_SUPPORTED", …}}`

#### Scenario: Unknown message id is returned as MESSAGE_NOT_FOUND
- **WHEN** the provider's `deleteMessage` raises `MessageNotFoundError`
- **THEN** `delete_email` returns `MESSAGE_NOT_FOUND`

#### Scenario: Unexpected delete failure falls back to DELETE_FAILED
- **WHEN** the provider's `deleteMessage` raises a plain error
- **THEN** `delete_email` returns `DELETE_FAILED` with the error message
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
## MODIFIED Requirements

### Requirement: Delete Policy

Delete SHALL be disabled by default. Two independent gates govern deletion: an operator-controlled env var (`AGENT_EMAIL_DELETE_ENABLED=true`) AND a per-call flag (`user_explicitly_requested_deletion: true`). Permanent deletion adds a third gate (`AGENT_EMAIL_HARD_DELETE_ENABLED=true`). All gates use strict-equality checks and accept only the literal string `'true'` to enable. Default to soft delete (move to Trash). Where a provider can only delete an item permanently and the item was never delivered — a Gmail draft, which Gmail cannot trash — the delete MAY proceed under the soft-delete gates alone; it SHALL still be refused when the caller passes `hard_delete: true` without `AGENT_EMAIL_HARD_DELETE_ENABLED=true`, and the provider SHALL document that the delete is permanent. Delivered mail on every provider keeps the soft-delete default. No bulk delete.

#### Scenario: Disabled by default
- **WHEN** `AGENT_EMAIL_DELETE_ENABLED` is unset or any value other than `'true'`
- **THEN** `delete_email` returns a `DELETE_DISABLED` error whose message names `AGENT_EMAIL_DELETE_ENABLED` so operators can self-remediate

#### Scenario: Soft delete
- **WHEN** `AGENT_EMAIL_DELETE_ENABLED=true` AND the caller passes `user_explicitly_requested_deletion: true`
- **THEN** the email is moved to Trash (soft delete)

#### Scenario: Hard delete requires both server gate and explicit flag
- **WHEN** `AGENT_EMAIL_HARD_DELETE_ENABLED=true` AND `hard_delete: true` is passed (with the soft-delete gates above also satisfied)
- **THEN** the email is permanently deleted

#### Scenario: Hard delete blocked when only soft is enabled
- **WHEN** `AGENT_EMAIL_DELETE_ENABLED=true` but `AGENT_EMAIL_HARD_DELETE_ENABLED` is not `'true'`
- **AND** the caller passes `hard_delete: true`
- **THEN** `delete_email` returns `DELETE_DISABLED` and the message names `AGENT_EMAIL_HARD_DELETE_ENABLED`

#### Scenario: Misconfigured env vars warn at startup
- **WHEN** an env var is set to a non-empty unsupported value (e.g. `'1'`, `'yes'`)
- **OR** `AGENT_EMAIL_HARD_DELETE_ENABLED=true` is set without `AGENT_EMAIL_DELETE_ENABLED=true`
- **THEN** the process logs a stderr warning at startup so operators can correct the config rather than silently fall back to disabled
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
## ADDED Requirements

### Requirement: Draft-Only Deletion

The Gmail provider SHALL implement `deleteMessage` for drafts only, using `users.drafts.delete`, which the default `gmail.compose` scope authorizes. It SHALL accept either a draft id or the id of the draft's message, resolving a message id to its draft id through `users.drafts.list` across every page. A Gmail draft delete SHALL be permanent regardless of `hard_delete`, because Gmail has no trash for drafts, and SHALL NOT call any `gmail.modify` endpoint.

A message that exists but is not a draft SHALL be refused with a non-recoverable `NOT_SUPPORTED` error stating that only drafts can be deleted with the current scopes and that sent or received mail would need `gmail.modify`. An id that names nothing, or a DRAFT-labelled message absent from `drafts.list`, SHALL raise `MessageNotFoundError`; so SHALL a non-draft id on a client without `drafts.list`, which can resolve only draft ids. A `drafts.delete` 404 for a draft that resolved immediately before SHALL count as a successful delete, because the draft is already gone (for example after the client retried a DELETE whose first attempt succeeded). Other `drafts.delete` failures SHALL be classified as Gmail provider errors. A Gmail client without draft deletion SHALL report `NOT_SUPPORTED`.

A Gmail draft delete SHALL remain behind the existing delete policy: it requires `AGENT_EMAIL_DELETE_ENABLED=true` and `user_explicitly_requested_deletion: true`, and a caller-supplied `hard_delete: true` still requires `AGENT_EMAIL_HARD_DELETE_ENABLED=true`. It SHALL NOT otherwise require the hard-delete gate.

#### Scenario: Delete a Gmail draft by draft id
- **WHEN** `deleteMessage` is called with a draft id
- **THEN** the provider calls `drafts.delete` with that id without listing drafts

#### Scenario: Delete a Gmail draft by its message id
- **WHEN** `deleteMessage` is called with the id of a DRAFT-labelled message
- **THEN** the provider finds the matching draft in `drafts.list` and calls `drafts.delete` with its draft id

#### Scenario: Draft id resolution follows drafts.list pagination
- **WHEN** the matching draft is on a later `drafts.list` page
- **THEN** the provider follows `nextPageToken` until it finds the draft

#### Scenario: Deleting sent or received Gmail mail is not supported
- **WHEN** `deleteMessage` is called with the id of a message without the DRAFT label
- **THEN** it fails with `NOT_SUPPORTED` naming `gmail.modify`, and no delete or modify request is made

#### Scenario: Deleting an unknown Gmail id reports not found
- **WHEN** the id names neither a draft nor a message
- **THEN** `deleteMessage` raises `MessageNotFoundError`

#### Scenario: A DRAFT-labelled message missing from drafts.list reports not found
- **WHEN** the message carries the DRAFT label but no draft in `drafts.list` points at it
- **THEN** `deleteMessage` raises `MessageNotFoundError` and deletes nothing

#### Scenario: Gmail draft delete ignores hard and is always permanent
- **WHEN** `deleteMessage` is called with `hard: true`
- **THEN** the provider calls `drafts.delete` exactly as for a soft delete

#### Scenario: drafts.delete failures are classified as provider errors
- **WHEN** `drafts.delete` fails with an HTTP error
- **THEN** `deleteMessage` raises a Gmail `ProviderError`

#### Scenario: A drafts.delete 404 after resolution counts as deleted
- **WHEN** the draft resolves but `drafts.delete` returns 404
- **THEN** `deleteMessage` succeeds

#### Scenario: Gmail client without drafts.list resolves only draft ids
- **WHEN** the concrete Gmail client has no `listDrafts` and the id is not a draft id
- **THEN** `deleteMessage` raises `MessageNotFoundError` without reading the message

#### Scenario: Gmail client without drafts.delete reports NOT_SUPPORTED
- **WHEN** the concrete Gmail client has no `deleteDraft`
- **THEN** `deleteMessage` raises `NOT_SUPPORTED` before any request

#### Scenario: Gmail draft delete stays behind the delete policy gate
- **WHEN** `delete_email` targets a Gmail draft with deletion disabled, without caller intent, or with `hard_delete: true` but no hard-delete gate
- **THEN** it returns `DELETE_DISABLED` and deletes nothing
- **AND WHEN** only `AGENT_EMAIL_DELETE_ENABLED` and caller intent are present
- **THEN** the draft is deleted

#### Scenario: delete_email on Gmail sent mail returns NOT_SUPPORTED
- **WHEN** `delete_email` targets a sent Gmail message with deletion enabled
- **THEN** it returns `{success: false, error: {code: "NOT_SUPPORTED"}}` naming `gmail.modify`

#### Scenario: listDrafts maps draft ids to their message ids
- **WHEN** the googleapis client lists drafts
- **THEN** it returns each draft id with its message id and the next page token, skipping drafts without an id

#### Scenario: deleteDraft uses Gmail drafts.delete
- **WHEN** the googleapis client deletes a draft
- **THEN** it calls `users.drafts.delete` for `me` with the draft id

### Requirement: Draft Id Resolution

Gmail draft reads and edits SHALL accept the id of a draft's message as well as its draft id, using the same resolution as draft deletion, so a draft found through `list_emails(folder: "drafts")`, `read_email`, or `get_thread` can be edited by the id those tools report.

#### Scenario: update_draft accepts a Gmail draft message id
- **WHEN** `updateDraft` is called with a draft's message id
- **THEN** the provider resolves the draft id and calls `drafts.update` with it, preserving the existing body

#### Scenario: update_draft refuses a Gmail message that is not a draft
- **WHEN** `updateDraft` is called with the id of a sent message
- **THEN** it returns `NOT_A_DRAFT` and makes no update

#### Scenario: Draft reply status resolves a Gmail draft message id
- **WHEN** `getDraftReplyStatus` or `getDraft` is called with a draft's message id
- **THEN** it reads the resolved draft
16 changes: 16 additions & 0 deletions openspec/changes/add-gmail-draft-delete/tasks.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
# Tasks

## 1. Gmail provider
- [x] 1.1 Add `listDrafts` and `deleteDraft` to the Gmail client (`users.drafts.list`, `users.drafts.delete`).
- [x] 1.2 Resolve a draft from a draft id or its message id, following `drafts.list` pagination.
- [x] 1.3 Implement draft-only `deleteMessage`; refuse non-drafts with `NOT_SUPPORTED` naming `gmail.modify`.
- [x] 1.4 Use the same resolution in `getDraft`, `getDraftReplyStatus`, and `updateDraft`.

## 2. Core
- [x] 2.1 Return `delete_email` provider failures as structured errors.
- [x] 2.2 Describe Gmail behavior on `delete_email` and `update_draft` inputs.

## 3. Docs and verification
- [x] 3.1 Document draft-only Gmail deletion in the README.
- [x] 3.2 Provider (mocked client), googleapis client, and policy-gate tests.
- [x] 3.3 Run build, lint, tests, spec coverage, and strict OpenSpec validation.
3 changes: 2 additions & 1 deletion packages/email-core/src/actions/draft.ts
Original file line number Diff line number Diff line change
Expand Up @@ -412,7 +412,8 @@ export const sendDraftAction: EmailAction<
// --- update_draft ---

const UpdateDraftInput = z.object({
draft_id: z.string(),
draft_id: z.string()
.describe('Draft to update. On Gmail either the draft id (r…) or the id of the draft\'s message, as list_emails(folder: "drafts") reports it.'),
to: z.string().or(z.array(z.string())).optional(),
cc: z.array(z.string()).optional(),
bcc: z.array(z.string()).optional()
Expand Down
34 changes: 34 additions & 0 deletions packages/email-core/src/actions/label.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { describe, it, expect, beforeEach } from 'vitest';
import { MockEmailProvider } from '../testing/mock-provider.js';
import { labelEmailAction, flagEmailAction, markReadAction, deleteEmailAction } from './label.js';
import type { ActionContext } from './registry.js';
import { MessageNotFoundError, ProviderError } from '../providers/provider.js';

let provider: MockEmailProvider;
let ctx: ActionContext;
Expand Down Expand Up @@ -149,3 +150,36 @@ describe('email-categorize/Delete Policy', () => {
expect(result.error!.message).toContain('Email deletion is disabled');
});
});

describe('email-categorize/Delete Provider Errors', () => {
const enabledCtx = (deleteMessage: () => Promise<void>): ActionContext =>
({ provider: { deleteMessage }, deleteEnabled: true } as unknown as ActionContext);
const request = { id: 'msg123', user_explicitly_requested_deletion: true, hard_delete: false };

it('Scenario: Provider refusal is returned as a structured error', async () => {
const result = await deleteEmailAction.run(
enabledCtx(() => Promise.reject(new ProviderError('NOT_SUPPORTED', 'Only drafts can be deleted', 'gmail', false))),
request,
);

expect(result).toEqual({
success: false,
error: { code: 'NOT_SUPPORTED', message: 'Only drafts can be deleted', recoverable: false },
});
});

it('Scenario: Unknown message id is returned as MESSAGE_NOT_FOUND', async () => {
const result = await deleteEmailAction.run(
enabledCtx(() => Promise.reject(new MessageNotFoundError('Gmail message msg123 not found'))),
request,
);

expect(result).toMatchObject({ success: false, error: { code: 'MESSAGE_NOT_FOUND', recoverable: false } });
});

it('Scenario: Unexpected delete failure falls back to DELETE_FAILED', async () => {
const result = await deleteEmailAction.run(enabledCtx(() => Promise.reject(new Error('boom'))), request);

expect(result).toMatchObject({ success: false, error: { code: 'DELETE_FAILED', message: 'boom' } });
});
});
Loading
Loading