diff --git a/README.md b/README.md index cd9561f..c6580ae 100644 --- a/README.md +++ b/README.md @@ -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` @@ -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 @@ -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 diff --git a/openspec/changes/add-gmail-draft-delete/proposal.md b/openspec/changes/add-gmail-draft-delete/proposal.md new file mode 100644 index 0000000..0a99a2f --- /dev/null +++ b/openspec/changes/add-gmail-draft-delete/proposal.md @@ -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 diff --git a/openspec/changes/add-gmail-draft-delete/specs/email-categorize/spec.md b/openspec/changes/add-gmail-draft-delete/specs/email-categorize/spec.md new file mode 100644 index 0000000..fba516a --- /dev/null +++ b/openspec/changes/add-gmail-draft-delete/specs/email-categorize/spec.md @@ -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 diff --git a/openspec/changes/add-gmail-draft-delete/specs/email-security/spec.md b/openspec/changes/add-gmail-draft-delete/specs/email-security/spec.md new file mode 100644 index 0000000..a796063 --- /dev/null +++ b/openspec/changes/add-gmail-draft-delete/specs/email-security/spec.md @@ -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 diff --git a/openspec/changes/add-gmail-draft-delete/specs/provider-gmail/spec.md b/openspec/changes/add-gmail-draft-delete/specs/provider-gmail/spec.md new file mode 100644 index 0000000..5f43418 --- /dev/null +++ b/openspec/changes/add-gmail-draft-delete/specs/provider-gmail/spec.md @@ -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 diff --git a/openspec/changes/add-gmail-draft-delete/tasks.md b/openspec/changes/add-gmail-draft-delete/tasks.md new file mode 100644 index 0000000..5a29cce --- /dev/null +++ b/openspec/changes/add-gmail-draft-delete/tasks.md @@ -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. diff --git a/packages/email-core/src/actions/draft.ts b/packages/email-core/src/actions/draft.ts index 003105d..f1b5b36 100644 --- a/packages/email-core/src/actions/draft.ts +++ b/packages/email-core/src/actions/draft.ts @@ -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() diff --git a/packages/email-core/src/actions/label.test.ts b/packages/email-core/src/actions/label.test.ts index dbdc0ae..642531c 100644 --- a/packages/email-core/src/actions/label.test.ts +++ b/packages/email-core/src/actions/label.test.ts @@ -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; @@ -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): 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' } }); + }); +}); diff --git a/packages/email-core/src/actions/label.ts b/packages/email-core/src/actions/label.ts index 539cfa7..2f3f9da 100644 --- a/packages/email-core/src/actions/label.ts +++ b/packages/email-core/src/actions/label.ts @@ -2,6 +2,8 @@ import { z } from 'zod'; import type { EmailAction } from './registry.js'; import { checkDeletePolicy, type DeletePolicy } from '../security/receive-allowlist.js'; +import { MessageNotFoundError } from '../providers/provider.js'; +import { handleProviderError } from './compose-helpers.js'; const LabelEmailInput = z.object({ id: z.string().optional(), @@ -12,7 +14,12 @@ const LabelEmailInput = z.object({ const LabelEmailOutput = z.object({ success: z.boolean(), - error: z.object({ code: z.string(), message: z.string(), recoverable: z.boolean() }).optional(), + error: z.object({ + code: z.string(), + message: z.string(), + recoverable: z.boolean(), + retryAfter: z.number().optional(), + }).optional(), }); export const labelEmailAction: EmailAction< @@ -88,7 +95,8 @@ export const markReadAction: EmailAction, z.infer< // delete_email — disabled by default const DeleteEmailInput = z.object({ - id: z.string(), + id: z.string() + .describe('Message id to delete. On Gmail this must be a draft: either its draft id or the id of its message. A Gmail draft gets a new message id on every update; its draft id stays stable.'), user_explicitly_requested_deletion: z.boolean(), hard_delete: z.boolean().optional().default(false), mailbox: z.string().optional(), @@ -96,7 +104,9 @@ const DeleteEmailInput = z.object({ export const deleteEmailAction: EmailAction, z.infer> = { name: 'delete_email', - description: 'Delete an email (disabled by default, requires explicit configuration)', + description: 'Delete an email (disabled by default, requires explicit configuration). ' + + 'On Gmail only drafts can be deleted under the default scopes, and a draft delete is permanent ' + + '(Gmail has no trash for drafts); deleting sent or received Gmail mail returns NOT_SUPPORTED.', input: DeleteEmailInput, output: LabelEmailOutput, annotations: { readOnlyHint: false, destructiveHint: true }, @@ -114,7 +124,14 @@ export const deleteEmailAction: EmailAction, z. return { success: false, error: { code: 'NOT_SUPPORTED', message: 'Provider does not support deletion', recoverable: false } }; } - await ctx.provider.deleteMessage(input.id, input.hard_delete); + try { + await ctx.provider.deleteMessage(input.id, input.hard_delete); + } catch (err) { + if (err instanceof MessageNotFoundError) { + return { success: false, error: { code: 'MESSAGE_NOT_FOUND', message: err.message, recoverable: false } }; + } + return handleProviderError(err, 'DELETE_FAILED'); + } return { success: true }; }, }; diff --git a/packages/provider-gmail/src/email-gmail-provider.test.ts b/packages/provider-gmail/src/email-gmail-provider.test.ts index 1a485a9..7bb2a49 100644 --- a/packages/provider-gmail/src/email-gmail-provider.test.ts +++ b/packages/provider-gmail/src/email-gmail-provider.test.ts @@ -4,6 +4,7 @@ import { AttachmentNotFoundError, MessageNotFoundError, cancelScheduledSendAction, + deleteEmailAction, listScheduledSendsAction, sendDraftAction, sendEmailAction, @@ -1626,3 +1627,240 @@ describe('provider-gmail/buildRawMessage attachments', () => { } }); }); + +describe('provider-gmail/Draft Deletion', () => { + const notFound = () => Object.assign(new Error('Requested entity was not found.'), { code: 404 }); + const invalidId = () => Object.assign(new Error('Invalid id value'), { code: 400 }); + + function draftBackingMessage(id = 'msg-draft-1') { + return { + id, + threadId: 'thread-draft', + labelIds: ['DRAFT'], + payload: { + headers: [ + { name: 'From', value: 'me@corp.com' }, + { name: 'To', value: 'bob@corp.com' }, + { name: 'Subject', value: 'Superseded draft' }, + ], + body: { data: Buffer.from('Old body').toString('base64url') }, + }, + }; + } + + function draftClient(overrides: Partial = {}) { + return createMockGmailClient({ + // Message ids are not draft ids: drafts.get rejects them. + getDraft: vi.fn().mockRejectedValue(notFound()), + getMessage: vi.fn().mockResolvedValue(draftBackingMessage()), + listDrafts: vi.fn().mockResolvedValue({ + drafts: [ + { id: 'r-other', messageId: 'msg-other' }, + { id: 'r-target', messageId: 'msg-draft-1' }, + ], + }), + deleteDraft: vi.fn().mockResolvedValue(undefined), + ...overrides, + }); + } + + it('Scenario: Delete a Gmail draft by draft id', async () => { + const client = draftClient({ + getDraft: vi.fn().mockResolvedValue({ id: 'r-target', message: draftBackingMessage() }), + }); + const provider = new GmailEmailProvider(client); + + await provider.deleteMessage('r-target', false); + + expect(client.getDraft).toHaveBeenCalledWith('r-target'); + expect(client.listDrafts).not.toHaveBeenCalled(); + expect(client.deleteDraft).toHaveBeenCalledWith('r-target'); + }); + + it('Scenario: Delete a Gmail draft by its message id', async () => { + const client = draftClient(); + const provider = new GmailEmailProvider(client); + + await provider.deleteMessage('msg-draft-1', false); + + expect(client.getMessage).toHaveBeenCalledWith('msg-draft-1'); + expect(client.deleteDraft).toHaveBeenCalledWith('r-target'); + expect(client.deleteDraft).toHaveBeenCalledTimes(1); + }); + + it('Scenario: Draft id resolution follows drafts.list pagination', async () => { + const listDrafts = vi.fn() + .mockResolvedValueOnce({ drafts: [{ id: 'r-other', messageId: 'msg-other' }], nextPageToken: 'page-2' }) + .mockResolvedValueOnce({ drafts: [{ id: 'r-target', messageId: 'msg-draft-1' }] }); + const client = draftClient({ getDraft: vi.fn().mockRejectedValue(invalidId()), listDrafts }); + const provider = new GmailEmailProvider(client); + + await provider.deleteMessage('msg-draft-1'); + + expect(listDrafts).toHaveBeenNthCalledWith(1, { maxResults: 500, pageToken: undefined }); + expect(listDrafts).toHaveBeenNthCalledWith(2, { maxResults: 500, pageToken: 'page-2' }); + expect(client.deleteDraft).toHaveBeenCalledWith('r-target'); + }); + + it('Scenario: Deleting sent or received Gmail mail is not supported', async () => { + const client = draftClient({ + getMessage: vi.fn().mockResolvedValue({ ...draftBackingMessage('msg-inbox'), labelIds: ['INBOX'] }), + }); + const provider = new GmailEmailProvider(client); + + const err = await provider.deleteMessage('msg-inbox').catch((e: unknown) => e); + + expect(err).toMatchObject({ code: 'NOT_SUPPORTED', recoverable: false }); + expect((err as Error).message).toMatch(/only drafts can be deleted/i); + expect((err as Error).message).toContain('gmail.modify'); + expect(client.listDrafts).not.toHaveBeenCalled(); + expect(client.deleteDraft).not.toHaveBeenCalled(); + expect(client.modifyMessage).not.toHaveBeenCalled(); + }); + + it('Scenario: Deleting an unknown Gmail id reports not found', async () => { + const client = draftClient({ getMessage: vi.fn().mockRejectedValue(notFound()) }); + const provider = new GmailEmailProvider(client); + + await expect(provider.deleteMessage('nope')).rejects.toBeInstanceOf(MessageNotFoundError); + expect(client.deleteDraft).not.toHaveBeenCalled(); + }); + + it('Scenario: A DRAFT-labelled message missing from drafts.list reports not found', async () => { + const client = draftClient({ listDrafts: vi.fn().mockResolvedValue({ drafts: [] }) }); + const provider = new GmailEmailProvider(client); + + await expect(provider.deleteMessage('msg-draft-1')).rejects.toBeInstanceOf(MessageNotFoundError); + expect(client.deleteDraft).not.toHaveBeenCalled(); + }); + + it('Scenario: Gmail draft delete ignores hard and is always permanent', async () => { + const client = draftClient(); + const provider = new GmailEmailProvider(client); + + await provider.deleteMessage('msg-draft-1', true); + + expect(client.deleteDraft).toHaveBeenCalledWith('r-target'); + expect(client.modifyMessage).not.toHaveBeenCalled(); + }); + + it('Scenario: drafts.delete failures are classified as provider errors', async () => { + const client = draftClient({ + deleteDraft: vi.fn().mockRejectedValue(Object.assign(new Error('Backend Error'), { code: 503 })), + }); + const provider = new GmailEmailProvider(client); + + const err = await provider.deleteMessage('msg-draft-1').catch((e: unknown) => e); + + expect(err).toMatchObject({ name: 'ProviderError', provider: 'gmail' }); + }); + + it('Scenario: Gmail client without drafts.delete reports NOT_SUPPORTED', async () => { + const client = draftClient(); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + delete (client as any).deleteDraft; + const provider = new GmailEmailProvider(client); + + await expect(provider.deleteMessage('msg-draft-1')).rejects.toMatchObject({ code: 'NOT_SUPPORTED' }); + expect(client.getDraft).not.toHaveBeenCalled(); + }); + + it('Scenario: Gmail draft delete stays behind the delete policy gate', async () => { + const client = draftClient(); + const provider = new GmailEmailProvider(client); + const request = { id: 'msg-draft-1', user_explicitly_requested_deletion: true, hard_delete: false }; + + const disabled = await deleteEmailAction.run({ provider } as never, request); + expect(disabled).toMatchObject({ success: false, error: { code: 'DELETE_DISABLED' } }); + + const noIntent = await deleteEmailAction.run( + { provider, deleteEnabled: true } as never, + { ...request, user_explicitly_requested_deletion: false }, + ); + expect(noIntent).toMatchObject({ success: false, error: { code: 'DELETE_DISABLED' } }); + + const hardWithoutGate = await deleteEmailAction.run( + { provider, deleteEnabled: true, hardDeleteAllowed: false } as never, + { ...request, hard_delete: true }, + ); + expect(hardWithoutGate).toMatchObject({ success: false, error: { code: 'DELETE_DISABLED' } }); + expect(client.deleteDraft).not.toHaveBeenCalled(); + + // AGENT_EMAIL_DELETE_ENABLED alone is enough: a draft delete is permanent, + // but the draft was never sent. + const enabled = await deleteEmailAction.run({ provider, deleteEnabled: true } as never, request); + expect(enabled).toEqual({ success: true }); + expect(client.deleteDraft).toHaveBeenCalledWith('r-target'); + }); + + it('Scenario: delete_email on Gmail sent mail returns NOT_SUPPORTED', async () => { + const client = draftClient({ + getMessage: vi.fn().mockResolvedValue({ ...draftBackingMessage('msg-sent'), labelIds: ['SENT'] }), + }); + const provider = new GmailEmailProvider(client); + + const result = await deleteEmailAction.run( + { provider, deleteEnabled: true } as never, + { id: 'msg-sent', user_explicitly_requested_deletion: true, hard_delete: false }, + ); + + expect(result).toMatchObject({ success: false, error: { code: 'NOT_SUPPORTED', recoverable: false } }); + expect(result.error?.message).toContain('gmail.modify'); + }); + + it('Scenario: A drafts.delete 404 after resolution counts as deleted', async () => { + // googleapis retries a DELETE after a 5xx; if the first attempt already + // removed the draft, the retry sees 404. + const client = draftClient({ deleteDraft: vi.fn().mockRejectedValue(notFound()) }); + const provider = new GmailEmailProvider(client); + + await expect(provider.deleteMessage('msg-draft-1')).resolves.toBeUndefined(); + expect(client.deleteDraft).toHaveBeenCalledWith('r-target'); + }); + + it('Scenario: Gmail client without drafts.list resolves only draft ids', async () => { + const client = draftClient(); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + delete (client as any).listDrafts; + const provider = new GmailEmailProvider(client); + + await expect(provider.deleteMessage('msg-draft-1')).rejects.toBeInstanceOf(MessageNotFoundError); + expect(client.getMessage).not.toHaveBeenCalled(); + expect(client.deleteDraft).not.toHaveBeenCalled(); + }); + + it('Scenario: update_draft accepts a Gmail draft message id', async () => { + const client = draftClient(); + const provider = new GmailEmailProvider(client); + + const result = await provider.updateDraft('msg-draft-1', { subject: 'Replacement subject' }); + + expect(result).toMatchObject({ success: true }); + expect(client.updateDraft).toHaveBeenCalledWith('r-target', expect.any(String), 'thread-draft'); + const raw = lastRaw(client.updateDraft as ReturnType, 1); + expect(raw).toContain('Subject: Replacement subject'); + expect(raw).toContain('Old body'); + }); + + it('Scenario: update_draft refuses a Gmail message that is not a draft', async () => { + const client = draftClient({ + getMessage: vi.fn().mockResolvedValue({ ...draftBackingMessage('msg-sent'), labelIds: ['SENT'] }), + }); + const provider = new GmailEmailProvider(client); + + const result = await provider.updateDraft('msg-sent', { subject: 'x' }); + + expect(result).toMatchObject({ success: false, error: { code: 'NOT_A_DRAFT' } }); + expect(client.updateDraft).not.toHaveBeenCalled(); + }); + + it('Scenario: Draft reply status resolves a Gmail draft message id', async () => { + const message = draftBackingMessage(); + message.payload.headers.push({ name: 'X-Agent-Draft-Origin', value: 'non_reply' }); + const client = draftClient({ getMessage: vi.fn().mockResolvedValue(message) }); + const provider = new GmailEmailProvider(client); + + await expect(provider.getDraftReplyStatus('msg-draft-1')).resolves.toBe('non_reply'); + await expect(provider.getDraft('msg-draft-1')).resolves.toMatchObject({ id: 'msg-draft-1', isDraft: true }); + }); +}); diff --git a/packages/provider-gmail/src/email-gmail-provider.ts b/packages/provider-gmail/src/email-gmail-provider.ts index 14721d2..ea69fc6 100644 --- a/packages/provider-gmail/src/email-gmail-provider.ts +++ b/packages/provider-gmail/src/email-gmail-provider.ts @@ -14,8 +14,8 @@ import type { OutboundAttachment, DraftReplyStatus, } from '@usejunior/email-core'; -import { AttachmentNotFoundError, MessageNotFoundError } from '@usejunior/email-core'; -import { gmailProviderError } from './errors.js'; +import { AttachmentNotFoundError, MessageNotFoundError, ProviderError } from '@usejunior/email-core'; +import { getErrorStatus, gmailProviderError, isMissingOrInvalidIdError } from './errors.js'; // Gmail label mapping const FOLDER_TO_LABEL: Record = { @@ -63,6 +63,19 @@ export interface GmailApiClient { * falls back to a structured NOT_SUPPORTED error when this is missing. */ updateDraft?(draftId: string, raw: string, threadId?: string): Promise<{ id: string; message: { id: string; threadId: string } }>; + /** + * One page of `drafts.list`. Used to map a draft's message id back to its + * draft id. Optional so older concrete clients keep type-checking; without + * it only draft ids resolve. + */ + listDrafts?(opts?: { maxResults?: number; pageToken?: string }): Promise<{ drafts: Array<{ id: string; messageId?: string }>; nextPageToken?: string }>; + /** + * Permanently delete a draft (`drafts.delete`, authorized by + * `gmail.compose`). Gmail has no trash for drafts. Optional so older + * concrete clients keep type-checking; `deleteMessage` reports + * NOT_SUPPORTED when it is missing. + */ + deleteDraft?(draftId: string): Promise; } interface GmailMessagePart { @@ -120,10 +133,48 @@ export class GmailEmailProvider { } async getDraft(draftId: string): Promise { - const draft = await this.client.getDraft(draftId); + const draft = await this.findDraft(draftId); + if (!draft) throw new MessageNotFoundError(`Gmail message ${draftId} is not a draft`); return mapGmailMessage(draft.message); } + /** + * Fetch a draft by its draft id (`r…`) or by the id of its message, which + * is what `list_emails(folder: "drafts")`, `read_email`, and `get_thread` + * report. Returns undefined when the id names a message that is not a + * draft; throws MessageNotFoundError when it names nothing. + */ + private async findDraft(id: string): Promise<{ id: string; message: GmailMessage } | undefined> { + try { + return await this.client.getDraft(id); + } catch (err) { + if (!isMissingOrInvalidIdError(err)) throw err; + // Without drafts.list only draft ids resolve, so a miss is final. + if (!this.client.listDrafts) throw new MessageNotFoundError(`Gmail draft ${id} not found`); + } + + let message: GmailMessage; + try { + message = await this.client.getMessage(id); + } catch (err) { + if (isMissingOrInvalidIdError(err)) throw new MessageNotFoundError(`Gmail message ${id} not found`); + throw err; + } + if (!message.labelIds?.includes('DRAFT')) return undefined; + + let pageToken: string | undefined; + do { + const page = await this.client.listDrafts!({ maxResults: 500, pageToken }); + const match = page.drafts.find(draft => draft.messageId === message.id); + if (match) return { id: match.id, message }; + pageToken = page.nextPageToken; + } while (pageToken); + + // DRAFT-labelled but absent from drafts.list: it was sent or deleted + // between the two reads. + throw new MessageNotFoundError(`No Gmail draft found for message ${id}`); + } + async searchMessages(query: string, _folder?: string, limit?: number, offset?: number): Promise { const response = await this.client.listMessages({ q: query, maxResults: (offset ?? 0) + (limit ?? 50) }); if (!response.messages?.length) return []; @@ -344,7 +395,13 @@ export class GmailEmailProvider { // current draft, merge the partial over it, and re-upload. Preserve // threading headers from the original draft so edits don't silently // lose thread association. - const draftResource = await this.client.getDraft(draftId); + const draftResource = await this.findDraft(draftId); + if (!draftResource) { + return { + success: false, + error: { code: 'NOT_A_DRAFT', message: `Gmail message ${draftId} is not a draft`, recoverable: false }, + }; + } const current = mapGmailMessage(draftResource.message); const draftOrigin = getRecognizedDraftOrigin( getHeader(draftResource.message, DRAFT_ORIGIN_HEADER), @@ -400,7 +457,7 @@ export class GmailEmailProvider { draftOrigin, }); - const result = await this.client.updateDraft(draftId, raw, current.threadId); + const result = await this.client.updateDraft(draftResource.id, raw, current.threadId); return { success: true, draftId: result.id }; } catch (err) { return { @@ -415,7 +472,8 @@ export class GmailEmailProvider { } async getDraftReplyStatus(draftId: string): Promise { - const draftResource = await this.client.getDraft(draftId); + const draftResource = await this.findDraft(draftId); + if (!draftResource) return 'indeterminate'; const draft = mapGmailMessage(draftResource.message); if (draft.isDraft !== true) return 'indeterminate'; @@ -434,6 +492,45 @@ export class GmailEmailProvider { return draft.inReplyTo && draft.inReplyTo.trim().length > 0 ? 'reply' : 'indeterminate'; } + /** + * Delete a draft, by draft id or by its message id. Drafts are the only + * thing the default `gmail.readonly` + `gmail.compose` grant can delete + * (`drafts.delete`); trashing or deleting sent or received mail needs + * `gmail.modify`, which Agent Email does not request. Gmail has no trash for + * drafts, so the delete is permanent whatever `hard` says. + */ + async deleteMessage(messageId: string, _hard?: boolean): Promise { + if (!this.client.deleteDraft) { + throw new ProviderError('NOT_SUPPORTED', 'Gmail client does not support draft deletion', 'gmail', false); + } + + let draft: { id: string; message: GmailMessage } | undefined; + try { + draft = await this.findDraft(messageId); + } catch (err) { + if (err instanceof MessageNotFoundError) throw err; + throw gmailProviderError(err, 'idempotent-read'); + } + if (!draft) { + throw new ProviderError( + 'NOT_SUPPORTED', + 'Only drafts can be deleted with the current Gmail scopes (gmail.readonly + gmail.compose). Trashing or deleting sent or received mail requires gmail.modify, which Agent Email does not request.', + 'gmail', + false, + ); + } + + try { + await this.client.deleteDraft(draft.id); + } catch (err) { + // The draft resolved a moment ago, so a 404 here means it is already + // gone — typically because googleapis retried a DELETE whose first + // attempt succeeded but returned 5xx. The caller's goal is met. + if (getErrorStatus(err) === 404) return; + throw gmailProviderError(err, 'idempotent-write'); + } + } + // NemoClaw egress domains static get egressDomains(): string[] { return ['gmail.googleapis.com', 'oauth2.googleapis.com', 'pubsub.googleapis.com']; diff --git a/packages/provider-gmail/src/errors.ts b/packages/provider-gmail/src/errors.ts index 0438d6b..f376e8f 100644 --- a/packages/provider-gmail/src/errors.ts +++ b/packages/provider-gmail/src/errors.ts @@ -26,6 +26,20 @@ export function getErrorMessage(err: unknown): string | undefined { return undefined; } +/** + * True when Gmail rejected an id as unknown (404) or as the wrong kind of id + * (400 "Invalid id value", e.g. a draft id passed to `messages.get`). Callers + * use it to retry the lookup against the other id space. + */ +export function isMissingOrInvalidIdError(err: unknown): boolean { + const status = getErrorStatus(err); + if (status === 404) return true; + if (status !== 400) return false; + + const message = getErrorMessage(err); + return typeof message === 'string' && /invalid id value/i.test(message); +} + function getErrorReason(err: unknown): string | undefined { const record = err as { errors?: Array<{ reason?: unknown }>; diff --git a/packages/provider-gmail/src/googleapis-client.test.ts b/packages/provider-gmail/src/googleapis-client.test.ts index 9cb6380..e872121 100644 --- a/packages/provider-gmail/src/googleapis-client.test.ts +++ b/packages/provider-gmail/src/googleapis-client.test.ts @@ -33,6 +33,17 @@ function createMockApi() { get: vi.fn().mockResolvedValue({ data: { id: 'd-1', message: message('m-draft', 't-draft') } }), send: vi.fn().mockResolvedValue({ data: { id: 'd-1', message: { id: 'm-sent-draft', threadId: 't-draft' } } }), update: vi.fn().mockResolvedValue({ data: { id: 'd-1', message: { id: 'm-updated-draft', threadId: 't-draft' } } }), + list: vi.fn().mockResolvedValue({ + data: { + drafts: [ + { id: 'd-1', message: { id: 'm-draft', threadId: 't-draft' } }, + { id: null, message: { id: 'm-orphan' } }, + { id: 'd-2' }, + ], + nextPageToken: 'next', + }, + }), + delete: vi.fn().mockResolvedValue({}), }, threads: { get: vi.fn().mockResolvedValue({ data: { id: 't-1', messages: [message('m-1', 't-1')] } }), @@ -319,6 +330,28 @@ describe('provider-gmail/GoogleapisGmailClient', () => { }); }); + it('Scenario: listDrafts maps draft ids to their message ids', async () => { + const api = createMockApi(); + const client = createClient(api); + + const result = await client.listDrafts({ maxResults: 500, pageToken: 'page-1' }); + + expect(api.users.drafts.list).toHaveBeenCalledWith({ userId: 'me', maxResults: 500, pageToken: 'page-1' }); + expect(result).toEqual({ + drafts: [{ id: 'd-1', messageId: 'm-draft' }, { id: 'd-2', messageId: undefined }], + nextPageToken: 'next', + }); + }); + + it('Scenario: deleteDraft uses Gmail drafts.delete', async () => { + const api = createMockApi(); + const client = createClient(api); + + await client.deleteDraft('d-1'); + + expect(api.users.drafts.delete).toHaveBeenCalledWith({ userId: 'me', id: 'd-1' }); + }); + it('Scenario: modifyMessage forwards label mutations', async () => { const api = createMockApi(); const client = createClient(api); diff --git a/packages/provider-gmail/src/googleapis-client.ts b/packages/provider-gmail/src/googleapis-client.ts index 01a1d4b..906fc49 100644 --- a/packages/provider-gmail/src/googleapis-client.ts +++ b/packages/provider-gmail/src/googleapis-client.ts @@ -1,7 +1,7 @@ import { gmail } from '@googleapis/gmail'; import type { GmailAuthManager } from './auth.js'; import type { GmailApiClient, GmailMessage } from './email-gmail-provider.js'; -import { getErrorMessage, getErrorStatus } from './errors.js'; +import { isMissingOrInvalidIdError } from './errors.js'; interface MessageSummary { id?: string | null; @@ -67,6 +67,15 @@ interface GmailDraftsApi { id: string; requestBody: { message: { raw: string; threadId?: string } }; }): Promise<{ data?: GmailDraft }>; + list(args: { + userId: string; + maxResults?: number; + pageToken?: string; + }): Promise<{ data?: { drafts?: Array<{ id?: string | null; message?: MessageSummary | null }>; nextPageToken?: string | null } }>; + delete(args: { + userId: string; + id: string; + }): Promise; } interface GmailThreadsApi { @@ -110,15 +119,6 @@ function requireDraft(draft: GmailDraft | undefined, op: string): { id: string; }; } -function shouldFallbackToDraftLookup(err: unknown): boolean { - const status = getErrorStatus(err); - if (status === 404) return true; - if (status !== 400) return false; - - const message = getErrorMessage(err); - return typeof message === 'string' && /invalid id value/i.test(message); -} - export class GoogleapisGmailClient implements GmailApiClient { private readonly api: GmailApiInstance; @@ -166,7 +166,7 @@ export class GoogleapisGmailClient implements GmailApiClient { return response.data; } catch (err) { - if (!shouldFallbackToDraftLookup(err)) throw err; + if (!isMissingOrInvalidIdError(err)) throw err; const draft = await this.api.users.drafts.get({ userId: 'me', @@ -284,4 +284,23 @@ export class GoogleapisGmailClient implements GmailApiClient { return requireDraft(response.data, 'drafts.update'); } + + async listDrafts(opts: { maxResults?: number; pageToken?: string } = {}): Promise<{ drafts: Array<{ id: string; messageId?: string }>; nextPageToken?: string }> { + const response = await this.api.users.drafts.list({ + userId: 'me', + maxResults: opts.maxResults, + pageToken: opts.pageToken, + }); + + return { + drafts: (response.data?.drafts ?? []) + .filter((draft): draft is { id: string; message?: MessageSummary | null } => !!draft.id) + .map(draft => ({ id: draft.id, messageId: draft.message?.id ?? undefined })), + nextPageToken: response.data?.nextPageToken ?? undefined, + }; + } + + async deleteDraft(draftId: string): Promise { + await this.api.users.drafts.delete({ userId: 'me', id: draftId }); + } }