Skip to content

enhanced gmail gatekeeper support - #367

Open
maxwellpeterson wants to merge 8 commits into
mainfrom
mpeterson/gmail
Open

enhanced gmail gatekeeper support#367
maxwellpeterson wants to merge 8 commits into
mainfrom
mpeterson/gmail

Conversation

@maxwellpeterson

@maxwellpeterson maxwellpeterson commented Aug 27, 2026

Copy link
Copy Markdown
Member

This PR adds a bunch of new features to the Gmail portion of the Google gatekeeper. The existing gatekeeper connection scopes (whole account, search query, specific label) are unchanged.

For human reviewers, I would focus on the gatekeeper interface changes in packages/gatekeeper-google/src/types.d.ts and let your agents handle the rest.

Features added by this PR

  • Richer lookup/search: searchThreads(), listMessages(), searchMessages(), getMessage(), and getThread()
  • Message-level mutations, plus star(), unstar(), applyLabel(), and removeLabel()
  • Message headers and attachment APIs, including attachment metadata and binary content reads
  • Expanded sending and replies with CC, BCC, HTML, and explicit recipient overrides
  • Gmail-style inline forwarding, including original body and attachments. Previously forwarding attached the original message as an .eml
  • Complete draft lifecycle: list, get, create, update, delete, and send drafts, including reply/forward draft creation
  • Label management: list, create, rename, delete, apply, and remove labels
@github-actions github-actions Bot added the gatekeeper Changes to a gatekeeper integration label Aug 27, 2026
github-advanced-security[bot]

This comment was marked as resolved.

@github-actions

Copy link
Copy Markdown

Preview: pr367-mpeterson-gmail

https://pr367-mpeterson-gmail-router.cloudflare-os-previews.workers.dev

Dashboard · deleted when this PR closes

@devin-ai-integration devin-ai-integration Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Note

This report is out of date. Scroll down for Devin Review's latest report on this PR.

✅ Devin Review: No Issues Found

Devin Review analyzed this PR and found no bugs or issues to report.

Devin Review
@maxwellpeterson
maxwellpeterson marked this pull request as ready for review August 28, 2026 16:08
devin-ai-integration[bot]

This comment was marked as resolved.

@ask-bonk

ask-bonk Bot commented Aug 28, 2026

Copy link
Copy Markdown

Findings

  • P1 google-api.ts:1098: quoted or negated in:trash/in:spam terms enable includeSpamTrash, broadening restricted searches to mail outside the configured scope.
  • P1 gmail.ts:2949: multi-message mutations are not tracked as uncertain writes. If one message succeeds and a later one fails, rejectAction() records rejection while Gmail remains partially mutated.
  • P1 gmail.ts:3389: an accepted label creation with a lost/malformed response permanently wedges the action. Retries refuse reconciliation and rejection is blocked.
  • P1 gmail.ts:3403: label renames lack uncertain-write tracking. A successful rename with a failed response can subsequently be recorded as rejected.
  • P1 google-api.ts:875: extractRfc822Attachments() throws on base64-encoded .eml attachments despite parseSafeGmailDraft() intending to defer them to PostalMime. Such drafts cannot be updated, deleted, or sent.
  • P2 gmail.ts:2990: after the stable Google subject matches, a legitimate Workspace email rename still permanently rejects the binding as a different account.
  • P2 gmail.ts:1978: messagesVisibleTo() compares a normalized display-name string against bare participant addresses, so inputs such as Person <person@example.com> return no matches.
  • P2 google-api.ts:1135: awaiting response-body cancellation can mask the HTTP status. A definitive 4xx may consequently be treated as an uncertain write and leave its action unrejectable.

github run

ask-bonk[bot]

This comment was marked as resolved.

@ask-bonk

ask-bonk Bot commented Aug 28, 2026

Copy link
Copy Markdown

Submitted 8 actionable inline findings.

github run

ask-bonk[bot]

This comment was marked as resolved.

@ask-bonk

ask-bonk Bot commented Aug 28, 2026

Copy link
Copy Markdown

Submitted 8 actionable inline findings.

github run

@ndisidore ndisidore left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

First pass focusing mostly on the api. But looking really good here! There's a lot to these google things

Comment thread packages/gatekeeper-google/src/types.d.ts
Comment thread packages/gatekeeper-google/src/types.d.ts Outdated
Comment thread packages/gatekeeper-google/src/types.d.ts Outdated
Comment thread packages/gatekeeper-google/src/types.d.ts
Comment thread packages/gatekeeper-google/src/gmail.ts
Comment thread packages/gatekeeper-google/src/gmail.ts
store.clearApplying(actionId);
break;
}
if (action.expectedProviderMessageId &&

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P1] Clear uncertainty once the draft is proven to still exist

After an ambiguous DELETE, this retry GET returning the same draft ID proves that the previous delete did not complete. If the user edited that draft meanwhile, either of these conflict checks throws while gmail:applying remains set; rejectAction() then refuses the action forever and the pending-delete overlay keeps the real draft hidden. Clear the uncertain-write marker before reporting the external-change conflict, or otherwise make the action rejectable.

Comment thread packages/gatekeeper-google/src/google-api.ts Outdated
Comment thread packages/gatekeeper-google/src/google-api.ts
@ask-bonk

ask-bonk Bot commented Aug 29, 2026

Copy link
Copy Markdown

Submitted 5 actionable inline findings.

github run

devin-ai-integration[bot]

This comment was marked as resolved.

@ask-bonk

ask-bonk Bot commented Aug 31, 2026

Copy link
Copy Markdown

Findings

  • P1 gmail.ts:1850: Restricted getMessage() and getThread() query Gmail before authorization. Existing out-of-scope IDs and nonexistent IDs produce distinguishable errors, leaking mailbox membership beyond the granted query/label scope.
  • P1 google-api.ts:1714: PostalMime normalizes 7bit/8bit attachment CRLFs to LF. Draft updates and inline forwards therefore silently corrupt attachment bytes. Preserve decoded octets from the raw MIME.
  • P2 google-api.ts:1654: A filename-less text body carrying a Content-ID, such as a multipart/related root selected by start, is classified as an attachment. getContent() then omits the actual body.
  • P2 gmail.ts:2954: Draft update/delete/send increment the resource version before submitAction() succeeds. Submission failure leaves no pending action but permanently invalidates existing attachment capabilities.
  • P2 gmail-state.ts:470: Draft fingerprints discard recipient display names. Reconciliation can accept externally modified From, Reply-To, or recipient names as the approved draft.
  • P2 types.d.ts:500: GmailMutableSystemLabel omits the five manually applicable CATEGORY_* labels. listLabels() returns them, but callers cannot apply or remove them.
  • P2 google-api.ts:1000: mimeHeaderSeparator() prefers any later CRLF separator over an earlier LF separator. LF-formatted MIME containing \r\n\r\n in an attachment body cannot be updated, deleted, or sent.
  • P2 gmail.ts:3184: After an uncertain message mutation, a definitive 404 never clears gmail:applying. If the target was subsequently deleted, the action becomes permanently unreconcilable and unrejectable.

github run

- split out GmailScopedSession interface
- add more metadata to thread interface
- add method for reading mailbox address
devin-ai-integration[bot]

This comment was marked as resolved.

ask-bonk[bot]

This comment was marked as resolved.

@ask-bonk

ask-bonk Bot commented Aug 31, 2026

Copy link
Copy Markdown

Submitted 7 actionable inline findings.

github run

devin-ai-integration[bot]

This comment was marked as resolved.

@ask-bonk

ask-bonk Bot commented Aug 31, 2026

Copy link
Copy Markdown

Findings

  • P2 google-api.ts:975: RFC 2231 filenames are always decoded as UTF-8. Valid non-UTF-8 values throw, blocking draft update/delete/send.
  • P2 google-api.ts:1268: Multipart boundary search is not line-anchored. Boundary-like text in a legal preamble can make valid drafts unparseable.
  • P2 google-api.ts:2703, google-api.ts:2801: Send responses only require truthy IDs. Malformed IDs become permanent receipts, making returned GmailMessageId values unusable after the action is removed.

github run

@devin-ai-integration devin-ai-integration Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Note

This report is out of date. Scroll down for Devin Review's latest report on this PR.

Devin Review found 1 new potential issue.

1 flag not posted on this PR by your GitHub settings — view it in Devin Review. (Configure)

Devin Review
Comment thread packages/gatekeeper-google/src/gmail.ts Outdated
Comment on lines +2216 to +2222
const canonical = await resolveMutableLabel(this.#ctx, label);
if (canonical.type !== "custom") throw new Error("System Gmail labels cannot be renamed.");
const resource = ensureLabelResource(this.#ctx, canonical);
const dependencies = dependenciesFor(this.#ctx.store, resource.logicalId, "label");
await submitAction(this.#ctx, {
type: "labelRename", labelId: resource.logicalId, name,
expectedName: canonical.name, dependsOn: dependencies,

@devin-ai-integration devin-ai-integration Bot Aug 31, 2026

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Concurrent label renames fail

Concurrent renameLabel calls can capture the same old name before either queues. The second approval fails after the first rename changes that name.

Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

@ask-bonk

ask-bonk Bot commented Aug 31, 2026

Copy link
Copy Markdown
  • P1 gmail.ts:2491: whole-mailbox thread mutations store only the thread ID. Messages arriving after approval submission are also mutated at application time despite never being approved. Snapshot message IDs or detect changed membership.
  • P1 gmail.ts:3950: after an ambiguous draft send, a surviving unchanged draft proves the send failed, but the action remains permanently uncertain and unrejectable. Reconcile against the draft before throwing.
  • P2 gmail.ts:1670: restricted draft access ignores messages sent through the same binding. Reply/forward drafts sourced from such messages become inaccessible unless the outgoing message independently matches the original restriction.
  • P2 gmail.ts:3840: an uncertain draft update becomes permanently stuck if the draft is subsequently deleted. Handle 404 as a terminal missing-resource state.
  • P2 google-api.ts:954: rebuilding message/rfc822 attachments rejects valid binary transfer encoding, preventing affected drafts from being updated, forwarded, or sent.
  • P2 google-api.ts:2079: unsupported but valid MIME charsets silently fall back to UTF-8, returning corrupted message bodies instead of failing closed.
  • P2 google-api.ts:2190: draft reconstruction converts extension dispositions to attachment and drops disposition parameters, silently changing valid MIME during unrelated updates.

github run

@devin-ai-integration devin-ai-integration Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Devin Review found 3 new potential issues.

2 flags not posted on this PR by your GitHub settings — view them in Devin Review. (Configure)

Devin Review
Comment on lines +3796 to +3798
if (isLegacyOutboundGmailAction(action)) {
action = await upgradeLegacyOutboundAction(api, store, actionId, action);
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 Legacy sends can duplicate email

After an interrupted legacy send, upgradeLegacyOutboundAction assigns a new identity and retries it. Gmail can deliver the same email twice.

Prompt for agents
Legacy outbound actions persisted by the previous implementation have no stable Message-ID or write receipt. A provider write may have succeeded before the worker persisted action removal. The migration in packages/gatekeeper-google/src/gmail.ts upgrades such an action with a fresh Message-ID and sends it, which cannot reconcile the earlier delivery and can duplicate it. Preserve fail-closed handling for legacy sends with uncertain history, or introduce a reconciliation strategy based on data that existed before this deployment. Do not retry them under a newly generated identity.
Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

Comment on lines +3234 to +3239
const recipientPatch = {
...(patch.to !== undefined ? {to: normalizeEmailRecipients(patch.to)} : {}),
...(patch.cc !== undefined ? {cc: normalizeEmailRecipients(patch.cc)} : {}),
...(patch.bcc !== undefined ? {bcc: normalizeEmailRecipients(patch.bcc)} : {}),
};
const patched = applyGmailDraftPatch(state, {...patch, ...recipientPatch});

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Draft updates duplicate recipients

When update changes one recipient field, it skips cross-field normalization. The draft can list one address in multiple recipient fields.

Prompt for agents
GmailDraftStub.update in packages/gatekeeper-google/src/gmail.ts normalizes patch.to, patch.cc, and patch.bcc separately, unlike create/send/reply paths that use normalizeAggregateRecipients. Merge each supplied field with the current draft recipients, then enforce one aggregate recipient set before staging the patch. Define how earlier-field precedence interacts with GmailDraftPatch's promise that omitted fields remain unchanged; rejecting a cross-field duplicate may preserve that contract better than silently editing an omitted field. Add tests where a patched To/CC/BCC address already exists in another field.
Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

Comment on lines +986 to +991
function formatApprovalField(label: string, value: string): string {
// Reject an individually oversized field before newline expansion can allocate millions of
// intermediate strings. The complete rendered description is checked again at submission.
validateApprovalDescription(value);
const block = value.split(/\r\n|\r|\n/).map(line => ` ${line}`).join("\n");
return `**${label}:**\n\n${block}`;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟨 Email content can spoof approvals

formatApprovalField uses indentation that blank lines can terminate. Crafted email content can render forged approval details and mislead the approver.

Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

throw new Error(
"The Gmail draft changed identity while it was being read. Retry it.", {cause: error});
}
current.status = "deleted";

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P1] Do not tombstone a draft whose send is uncertain

If drafts.send delivered the message but its response was lost, the action remains gmail:applying and Gmail legitimately returns 404 for the consumed draft. A subsequent read reaches this branch and changes the resource to deleted. On retry, the delivered message can be found and verified, but completeSentAction() requires the draft resource to still be active, while rejectAction() refuses an applying action. The accepted send is then permanently unreconcilable. Preserve the resource state while a draft-send outcome is uncertain, or allow the verified send completion to consume this tombstone.

}
throw error;
}
if (current.id !== receipt.draftId || current.message.id !== receipt.messageId) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P2] Compare the reconciled draft before rejecting its new message ID

A successful draft update stores this receipt before the baseline GET. If that GET fails and another Gmail client subsequently saves the exact same approved MIME, Gmail assigns a new message ID. This check rejects the retry before comparing fingerprints, even though the provider state is exactly the approved output; the receipt and applying marker also make the action unrejectable. Parse and compare the fingerprint first so an identical provider-normalized draft can complete reconciliation despite a later revision ID.

filename,
contentType,
data: foldBase64(bytesToBase64(bytes)),
...(attachment.disposition === "inline" ? {disposition: "inline" as const} : {}),

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P2] Preserve the PostalMime related attachment flag

A normal multipart/related CID image can have no Content-Disposition; PostalMime represents it as related: true, disposition: null. This conversion (and the analogous one in parseGmailDraft) ignores related, so rebuilding an inline forward or imported draft emits the image as an ordinary attachment rather than a related inline part. Strict clients stop rendering the CID image, and an ambiguous imported-draft update can no longer reconcile because the approved null disposition becomes attachment. Treat attachment.related as inline/related when reconstructing the MIME.

} else {
const full = await this.#ctx.api.getMessageFull(this.#messageId);
if (full.threadId !== this.#threadId) throw new Error("Gmail message identity changed.");
headers = (full.payload?.headers ?? []).map(header => ({

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P2] Bound the message headers returned over RPC

Unlike body and attachment reads, this maps every provider header without a count or aggregate byte limit. Header content is sender-controlled and a valid Gmail message can devote megabytes to many headers, so getHeaders() can create an oversized RPC result or exhaust the Worker while materializing it. Enforce bounded header count and aggregate UTF-8 bytes before authorization/return.

@ask-bonk

ask-bonk Bot commented Sep 1, 2026

Copy link
Copy Markdown

Submitted 4 actionable inline findings.

github run

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

gatekeeper Changes to a gatekeeper integration

4 participants