Skip to content

Show an agent's Mail draft as a compact card in chat - #8018

Open
3mdistal wants to merge 6 commits into
mainfrom
mail/compact-draft-card
Open

3mdistal wants to merge 6 commits into
mainfrom
mail/compact-draft-card

Conversation

@3mdistal

@3mdistal 3mdistal commented Oct 22, 2026 •

Copy link
Copy Markdown
Contributor

Problem

Outside agents, such as Claude, ChatGPT or Cursor connected to Mail over Model Context Protocol (MCP), write emails with Mail's manage-draft action. Chat apps that support MCP Apps then embed the result in the conversation, so the person can check the draft and send it. Until now that embed was the whole Mail inbox with the compose window open, in a 900px-tall frame. Most of it went to the sidebar and message list, the draft sat in a corner, and at phone width it was barely usable.

Approach

The embed now points at a new /draft?id=… route that shows only what the person needs to review and send:

  • A reply shows the conversation, then the editable draft. Older messages fold to one line each (sender, snippet, date), and the latest one is open. Beyond the last three, earlier messages fold behind an "Earlier messages (n)" button.
  • A new email shows just the editable draft.

The draft is Mail's real composer, not a copy: recipients, Cc and Bcc, the From picker (when more than one account is connected), formatting, attachments, delete, Send and scheduling work as in the inbox, and edits save to the same draft. The host's "Open in Mail" button still opens the full inbox with this draft.

After Send, the card shows the usual undo window, then "Message sent." It also covers a scheduled send, a deleted draft, a failed send or delete, a missing draft and a thread that couldn't load. When the person sends, schedules or deletes, the card tells the chat's model, so the agent doesn't keep editing a draft that's gone. It reports only what really happened: a delete once the draft and its saved copy in Gmail are both gone, and nothing when the draft was edited while it was being scheduled. That edited draft stays open, with a toast saying the earlier version is scheduled. When an agent drafted a reply without naming its thread, Send and scheduling wait the moment it takes the card to look the thread up, so a scheduled reply lands in its conversation.

This pairs with #8005, which adds manage-draft to the tools Mail offers outside agents. This PR only changes what they see.

Screenshots

Rendered as an MCP App in MCPJam's ChatGPT emulator, from a production build with fake mail.

Reply New email
Light Reply card in light theme: two folded earlier messages, the latest message open, then the draft with Send New email card in light theme: recipients, subject, body and Send
Dark Reply card in dark theme New email card in dark theme

At 380px, the width of Claude's phone app, the thread gives up more height so the draft stays readable:

Reply, light Reply, dark New, light New, dark
Reply card at phone width, light Reply card at phone width, dark New email card at phone width, light New email card at phone width, dark
Other states
Sent Send failed Thread couldn't load
Card showing Message sent Failed to send banner over the still-editable draft Notice that earlier messages couldn't load, with the draft still editable
Attachment (upload stubbed) Deleted in the card
Draft with an attached file chip Draft deleted message

What changed

All in templates/mail:

  • actions/manage-draft.ts: create and update return the /draft path as the embed, with the inbox link as deepLinkUrl for "Open in Mail". The frame height drops from 900 to 604.
  • app/routes/draft.tsx and app/components/email/DraftCard.tsx: the new route and card. AppLayout renders /draft without the app shell, as it already does for /email. The inbox's toasts for a failed save or delete live in that shell, so the card shows them itself. A reply's draft gets a thread ID only when it already has one or Mail looks it up from the message it answers. Send and Send later wait for that lookup, and its first answer sticks, so a reply's thread can't change while it's being scheduled. When the lookup fails, the card may still try the message's own ID to show the conversation, but never puts that guess on the draft.
  • ComposeModal.tsx: a presentation="card" mode, an onSendProgress callback and a sendDisabled prop. The card mode drops the window's title bar, autofocus and Escape-to-close, shows Cc and Bcc when the draft already has them, and hides the subject on replies. When a caller passes onSendProgress, it gets the send states and the send toasts are skipped. sendDisabled disables Send and Send later, including their keyboard shortcuts. If the draft changes while a schedule request is in flight, including a move to another thread, the edited draft stays open and the "Scheduled for…" toast shows in both modes.
  • EmailThread.tsx exports the folded and open message rows for the card to reuse; their reply and forward buttons hide when no handler is passed. use-compose-state.ts exposes isError and refetch, so the card can tell a missing draft from one that couldn't load. Its discard now resolves to whether the draft and its saved copy in Gmail were both deleted; existing callers ignore the result.
  • New mail.draftCard strings in all 11 locales, and a changelog entry.

Safety and operations

  • Rollout: chat apps still show inline MCP Apps only where AGENT_NATIVE_MCP_APPS_INLINE (or its per-email allowlist) is on. Elsewhere, agents get the same text result and link as before. This PR adds no flag.
  • Data and rollback: no schema, migration or permission change. The card uses the inbox's draft and thread endpoints and sends through the same POST /api/emails/send. Reverting the PR restores the full-inbox embed.
  • Height: 604 is the 560px document that core allows an inline chat app (MCP_CHAT_BRIDGE_VIEWPORT_HEIGHT in packages/core/src/client/embed-auth.ts), plus the host bar's 44px. A shorter frame for new emails would need core to accept a height per embed, so both cases use 604 and the card fits itself to it.

Verification

  • Automated: new DraftCard tests (16) cover the reply and new-email layouts, folding, every outcome state, the message to the model, a delete that doesn't stick, a failed save, a reply whose Send waits for its thread lookup, one whose lookup fails, and two connected accounts reaching the From picker. Five ComposeModal tests cover the card mode (send states replace toasts; copied Cc shows without a click; a draft edited while scheduling stays open; so does one moved to another thread; sendDisabled holds Send and scheduling), and manage-draft.spec.ts checks the embed path and deep link. The full Mail suite (1,941 tests), agent-native typecheck and all 93 pnpm guards pass at the current head.
  • Real interface: before the review fixes, a local production build, rendered as an MCP App in MCPJam's ChatGPT and Cursor emulators at 864px and 380px, light and dark, signed in as a test account with fake mail.
    • Send showed Undo for about 10 seconds, then "Message sent." The message reached Sent, and the host received one model update, {draftId, status: "sent"}. Undo returned to the editable draft.
    • A forced send failure showed "Failed to send email" and kept the draft editable. A failing thread request showed its notice, kept the draft editable and requested the thread only once.
    • Deleting in the card showed "Draft deleted.", and a reload showed "Draft not found." That notice is now its title alone.
    • Edits survived a reload. Nothing takes focus on load, Tab reaches every control in order, Fullscreen fills the frame, and Open in Mail opens the inbox with this draft.
  • Not verified:
    • Real Claude or ChatGPT. That needs a public HTTPS tunnel to a local server holding the test account's Gmail tokens, plus a chat account with custom connectors; neither was set up. MCPJam's Claude emulator refuses to frame the app (frame-src 'self'), so Claude's layout is inferred from the 380px render. Beta, once this and Expose Mail draft review to connected agents #8005 merge, is the first real-host check.
    • Two live accounts. The test workspace has one Google account; the unit test covers the From picker.
    • A real attachment upload. The local build has no file storage, so the card showed the composer's "not configured" toast; a stubbed upload showed the file chip with remove.
    • The review fixes in a browser. A delete that doesn't stick, a failed save, a draft edited while scheduling, Send waiting for a reply's thread lookup and a reply whose lookup fails are covered by unit tests only.
    • The scheduled state in a browser. Scheduling failed on an unrelated local database error, so only the unit test covers it. The schedule menu itself fits at 380×560.

Review focus

  1. ComposeModal.tsx wraps the title bar in {!isCard && …}, so most of that file's diff is re-indentation. Hide whitespace to see the logic.
  2. With onSendProgress, the send toasts are skipped and the card shows each state instead. Is there a path where a failure shows neither? The schedule-failure toast still shows in both modes on purpose, as does "Scheduled for…" when the draft was edited mid-schedule.
  3. The 604 height depends on core's 560px limit. If core changes that number, this one has to follow.
  4. Send stays disabled until a reply's thread lookup returns or fails. That's one request, retried once. If it hangs, the card can't send, though "Open in Mail" still can. Is that a fair price for keeping scheduled replies in their thread?
  5. When the person sends, schedules or deletes, the card tells the model "The user sent email draft X from the Mail card." with {draftId, status}, and reports a delete only once it has stuck. Is that the right shape for agents to act on?

Follow-ups

  • Per-embed heights in core, so a new email can get a shorter card. A later product decision.
  • Host theme. Embeds follow Mail's theme or the operating system, not the chat app's. That's framework-wide, also a later decision.

@github-actions

Copy link
Copy Markdown
Contributor

Here's a visual recap of what changed:

Visual recap

Open the full interactive recap

@greptile-apps

greptile-apps Bot commented Oct 22, 2026 •

Copy link
Copy Markdown

RetriggerView in GreptileConfidence Score: 4/5

[Medium impact] The changes affect Mail’s agent-draft card and the actions available inside it.

Restore draft save and delete warnings and satisfy the density rule before merging.

Findings

  1. P1 Draft failures lose their warnings ▶
  2. P2 Empty card breaks density rules ▶

Summary

Adds a compact /draft chat card using Mail’s existing composer.

  • Agent-created drafts open in a compact card with their reply thread.
  • Mail’s composer fits inside the draft card and reports what happens after Send.
  • manage-draft opens the card while keeping a link to the full inbox.

Acknowledged choices from 3mdistal: reply subjects are intentionally hidden; shorter per-embed heights and host-theme support are deferred.

Diagram

%%{init: {'theme': 'neutral'}}%%
flowchart TD
  A[manage-draft] --> B["/draft?id=…"]
  B --> C[DraftCard]
  C --> D[Conversation history]
  C --> E[ComposeModal card]
  E --> F[Existing save and delete hooks]
  F --> G[Failure events]
  G --> H[AppLayoutInner listeners skipped]
  E --> I[Send or schedule]
  I --> J[Card outcome]
  J --> K[Chat model update]
Loading

Reviews (1) · Last reviewed commit: "Test the Mail draft card's scheduled sta..." · Reviewed by Greptile

Comment thread templates/mail/app/components/layout/AppLayout.tsx
Comment thread templates/mail/app/components/email/DraftCard.tsx Outdated
builder-io-integration[bot]

This comment was marked as outdated.

When an outside agent drafts with manage-draft, MCP Apps hosts now embed
a new /draft route instead of the whole inbox. A reply shows the thread's
earlier messages folded to one line each, the latest message open, and
the editable draft with Send; a new email shows just the draft. "Open in
Mail" keeps the full-inbox deep link.

The card reuses the inbox's message rows and the real composer in a new
"card" presentation: no window chrome, no autofocus, copied recipients
shown, and send progress reported to the card instead of toasts. The card
shows sending, sent, scheduled, deleted, failed-send, missing-draft and
unreadable-thread states, and tells the chat's model when the user sends,
schedules or deletes the draft.
The card swallowed a failed model-context update into false, which the
no-silent-coercion guard rejects. It now logs a rejected or refused
update; the card itself already shows the outcome to the person.
@3mdistal
3mdistal force-pushed the mail/compact-draft-card branch from 50c447f to dd63f3a Compare October 22, 2026 18:40
builder-io-integration[bot]

This comment was marked as outdated.

- discard() now resolves to whether the draft and its saved mailbox copy
  are really gone. The card shows "Draft deleted." at once but tells the
  model only once the delete sticks, and shows a failure state if not.
- Scheduling a draft that was edited mid-request keeps the edited draft
  open, reports no outcome and says what was scheduled in a toast.
- Only a known or looked-up thread ID reaches the draft's replyToThreadId;
  a guessed one is used only to show the conversation.
- The card shows save and delete failures itself, since it renders
  without AppLayout, and its not-found notice drops the description.
builder-io-integration[bot]

This comment was marked as outdated.

The card adds a reply's thread ID once its lookup returns. If that
happened while a schedule request was in flight, isSameScheduledDraft
saw an edit, so the draft stayed open with no outcome reported and a
second send could duplicate the email. The thread ID is filled in, not
edited, so the comparison now leaves it out.
builder-io-integration[bot]

This comment was marked as outdated.

A reply drafted with only the message it answers gets its thread ID
from a lookup. Scheduling before that lookup returned sent the job
without a thread ID, and the scheduled-send path only threads a reply
when the payload has one. The card now holds Send and Send later
(a new sendDisabled prop on ComposeModal) until the lookup settles,
and keeps the lookup's first answer, so the thread can't change under
a schedule request.

That makes the earlier exclusion in isSameScheduledDraft unnecessary,
so it's gone: an agent moving the draft to another thread while it's
being scheduled counts as an edit again and keeps the draft open.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

@builder-io-integration builder-io-integration Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Builder reviewed your changes and has a few items to flag 🟡

Review Details

Incremental review summary

I reviewed PR #8018 at 8a7a30da against the previous reviewed head 2869ba10. The update restores comparison of replyToThreadId so explicit retargets during scheduling remain open, and holds sending/scheduling while a reply's thread lookup is pending. I verified and resolved the prior explicit-thread-change finding. The pending-lookup safeguard addresses the normal in-flight case; the existing thread-ID finding remains open for cases where lookup settles as an error or cannot run, so I did not repost it.

This remains a standard-risk Mail lifecycle change. I found one additional low-severity issue: the lookup error is stored as a settled result for that key, so a later successful refetch is ignored and the draft never receives its resolved thread ID. Because the error state also clears lookupPending, send and schedule are enabled in that state. No further confirmed issues were found in the incremental diff.

🧪 Browser testing: Skipped — browser-test-planner is unavailable in this environment; PR touches UI code.

Copy link
Copy Markdown
Contributor

Thanks for the update. The loading safeguard covers an in-flight lookup, but a lookup error is cached as a settled result; lookupPending then clears and Send/Schedule are re-enabled without a resolved reply thread ID, while later refetches are ignored. Please keep these actions disabled until a valid thread is resolved, or make an error retryable and test the recovery path.

This branch has not been deployed

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants