Summary
read_email returns from / to / cc as strings in name-address form (Name <email>). The compose actions that accept string recipients — send_email, create_draft, update_draft, reply_to_email — do not parse that form before building provider payloads. Each string is wrapped directly as { email: string }, which violates the internal EmailAddress shape ({ name?: string, email: string }) and makes downstream behavior provider-dependent:
- Gmail writes the unparsed string verbatim into the raw header value.
- Microsoft Graph forwards the full string as
emailAddress.address.
Net effect: tool output is not safely round-trippable into tool input when display names are present. No validation error is surfaced to the caller.
Reproduction
The strongest code-backed repro is any recipient whose display name would normally require quoting (e.g. contains a comma).
- Start with a message whose recipient header is
"Doe, Jane" <jane@example.com>.
read_email returns that value as Doe, Jane <jane@example.com> — the quotes are dropped in the to/cc/from output field (see renderAddress usage in packages/email-core/src/actions/read.ts).
- Pass that value back into a compose action:
await reply_to_email({
message_id: "<provider message id>",
mailbox: "me@example.com",
cc: ["Doe, Jane <jane@example.com>"],
body: "...",
draft: true,
});
The action treats the whole string as the bare email. No parse error is raised.
Root cause
The compose actions blindly wrap input strings instead of parsing them:
packages/email-core/src/actions/reply.ts — replyToEmailAction (draft path and send path)
packages/email-core/src/actions/send.ts — sendEmailAction (draft path and send path)
packages/email-core/src/actions/draft.ts — createDraftAction and updateDraftAction
The pattern in each: cc: input.cc?.map(email => ({ email })) and to: recipients.map(email => ({ email })). Grep for .map(email => ({ email })) across packages/email-core/src/actions/ to enumerate every site.
Downstream provider behavior then diverges:
- In
packages/provider-gmail/src/email-gmail-provider.ts, formatAddressList() emits a.email verbatim when a.name is absent. A value like { email: "Doe, Jane <jane@example.com>" } goes into the header as-is.
- In
packages/provider-microsoft/src/email-graph-provider.ts, Graph payloads set emailAddress.address = r.email, so the whole string becomes the address field value.
Expected behavior
Any compose action that accepts string recipients should either:
- Parse name-address strings into the internal
EmailAddress shape ({ name, email }), or
- Reject non-bare address strings with a clear validation error
Silently treating the full string as EmailAddress.email is the current behavior and should not be.
Confirmed scope
Same blind wrapping in:
reply_to_email draft and send paths (reply.ts in replyToEmailAction)
send_email draft and send paths (send.ts in sendEmailAction)
create_draft reply-draft cc, standard-draft to / cc (draft.ts in createDraftAction)
update_draft partial to / cc (draft.ts in updateDraftAction)
bcc is not currently on the public action surface, so this issue is intentionally limited to to and cc.
Not claimed
This write-up deliberately does not claim a specific provider-facing outward symptom (e.g., "Gmail silently drops" or "Graph returns 400"). The source makes the type-shape mismatch clear; the exact failure mode depends on provider, address shape, and transport (draft vs. send, Graph PATCH vs. POST). A targeted integration test would nail the observable behavior per path.
Suggested fix shape
A shared parseAddressString(raw: string): EmailAddress helper in email-core (or colocated with the EmailAddress type), applied at every .map(email => ({ email })) site. Keep the MCP tool wire format (z.array(z.string())) unchanged; parse at the action layer.
Summary
read_emailreturnsfrom/to/ccas strings in name-address form (Name <email>). The compose actions that accept string recipients —send_email,create_draft,update_draft,reply_to_email— do not parse that form before building provider payloads. Each string is wrapped directly as{ email: string }, which violates the internalEmailAddressshape ({ name?: string, email: string }) and makes downstream behavior provider-dependent:emailAddress.address.Net effect: tool output is not safely round-trippable into tool input when display names are present. No validation error is surfaced to the caller.
Reproduction
The strongest code-backed repro is any recipient whose display name would normally require quoting (e.g. contains a comma).
"Doe, Jane" <jane@example.com>.read_emailreturns that value asDoe, Jane <jane@example.com>— the quotes are dropped in theto/cc/fromoutput field (seerenderAddressusage inpackages/email-core/src/actions/read.ts).The action treats the whole string as the bare email. No parse error is raised.
Root cause
The compose actions blindly wrap input strings instead of parsing them:
packages/email-core/src/actions/reply.ts—replyToEmailAction(draft path and send path)packages/email-core/src/actions/send.ts—sendEmailAction(draft path and send path)packages/email-core/src/actions/draft.ts—createDraftActionandupdateDraftActionThe pattern in each:
cc: input.cc?.map(email => ({ email }))andto: recipients.map(email => ({ email })). Grep for.map(email => ({ email }))acrosspackages/email-core/src/actions/to enumerate every site.Downstream provider behavior then diverges:
packages/provider-gmail/src/email-gmail-provider.ts,formatAddressList()emitsa.emailverbatim whena.nameis absent. A value like{ email: "Doe, Jane <jane@example.com>" }goes into the header as-is.packages/provider-microsoft/src/email-graph-provider.ts, Graph payloads setemailAddress.address = r.email, so the whole string becomes theaddressfield value.Expected behavior
Any compose action that accepts string recipients should either:
EmailAddressshape ({ name, email }), orSilently treating the full string as
EmailAddress.emailis the current behavior and should not be.Confirmed scope
Same blind wrapping in:
reply_to_emaildraft and send paths (reply.tsinreplyToEmailAction)send_emaildraft and send paths (send.tsinsendEmailAction)create_draftreply-draftcc, standard-draftto/cc(draft.tsincreateDraftAction)update_draftpartialto/cc(draft.tsinupdateDraftAction)bccis not currently on the public action surface, so this issue is intentionally limited totoandcc.Not claimed
This write-up deliberately does not claim a specific provider-facing outward symptom (e.g., "Gmail silently drops" or "Graph returns 400"). The source makes the type-shape mismatch clear; the exact failure mode depends on provider, address shape, and transport (draft vs. send, Graph PATCH vs. POST). A targeted integration test would nail the observable behavior per path.
Suggested fix shape
A shared
parseAddressString(raw: string): EmailAddresshelper inemail-core(or colocated with theEmailAddresstype), applied at every.map(email => ({ email }))site. Keep the MCP tool wire format (z.array(z.string())) unchanged; parse at the action layer.