Skip to content

read_email recipient strings do not round-trip through compose actions when display names are present #47

@stevenobiajulu

Description

@stevenobiajulu

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).

  1. Start with a message whose recipient header is "Doe, Jane" <jane@example.com>.
  2. 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).
  3. 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.tsreplyToEmailAction (draft path and send path)
  • packages/email-core/src/actions/send.tssendEmailAction (draft path and send path)
  • packages/email-core/src/actions/draft.tscreateDraftAction 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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions