Skip to content

feat(api): forward inbound messages — /messages/{id}/forward across SDKs, CLI, MCP#171

Merged
jiashuoz merged 2 commits into
mainfrom
feat/forward-message-api
May 28, 2026
Merged

feat(api): forward inbound messages — /messages/{id}/forward across SDKs, CLI, MCP#171
jiashuoz merged 2 commits into
mainfrom
feat/forward-message-api

Conversation

@jiashuoz
Copy link
Copy Markdown
Member

Summary

Adds a Forward endpoint mirroring the existing Reply path: POST /api/v1/agents/{email}/messages/{id}/forward.

  • Server compose: caller's optional comment, then a Gmail-style `---------- Forwarded message ---------` header block (From / Date / Subject / To / Cc), then the original body best-effort extracted from the inbound's stored MIME (text/plain, text/html, multipart/alternative, multipart/mixed, quoted-printable, base64).
  • Threading: forwards ship as a new thread — no `In-Reply-To` / `References` headers. Callers can pass `conversation_id` to bind explicitly.
  • HITL fix: `buildSendRequestFromMessage` now only copies `email_message_id` → `ReplyToMessageID` when `type="reply"`, so HITL-approved forwards don't accidentally inherit threading headers and stitch into the original conversation.
  • `InboundContext` review pane: forwards still persist `email_message_id` on the pending row, so reviewers see what's being forwarded.
  • Migration 019 extends the `messages.message_type` CHECK constraint to allow `'forward'` (mirrors the pattern from 008_loopback_method.sql).
  • Compile fix: `internal/e2e/e2e_test.go` had three local-struct declarations of `To` as `string` that should have been `[]string` — pre-existing, unrelated to this change but blocking build. Fixed in this PR.

Surfaces shipped

  • Go handler + 7 integration tests (auth, not-found, wrong-agent, unverified-domain, missing-recipients, SMTP happy-path, HITL hold)
  • Compose helpers + 12 unit tests
  • TypeScript SDK: `api.forwardMessage`, `client.forward()`, `InboundEmail.forward()`
  • Python SDK: regenerated types
  • CLI: `e2a forward --to … [--body …]`
  • MCP: `forward_message` tool + tests

What's NOT in scope (deferred)

  • Auto-forwarding the original's attachments (caller can attach manually; future `include_original_attachments` flag)
  • Conversations / Threads API
  • Labels
  • Drafts

Test plan

  • Go: `make test-unit && make test-integration` for `internal/agent` + `internal/outbound` — all green
  • TS SDK: 85 tests pass
  • CLI: 100 tests pass
  • MCP: 89 tests pass (1 new forward test added)
  • Live e2e against running service: in progress on this branch — register agent, inject inbound via SMTP, call `POST /forward`, verify SMTP receives forwarded message with `Subject: Fwd: …`, divider, original body, no `In-Reply-To`.

Pre-existing failures NOT caused by this PR:

  • `TestInboundDelivered` / `TestReplayProtection` in `internal/e2e` (signature verification failures, reproducible on main with my e2e compile fix applied but backend changes stashed)

🤖 Generated with Claude Code

jiashuoz and others added 2 commits May 27, 2026 20:22
…CLI/MCP

Mirrors the existing reply path: handler validates ownership, applies
HITL/idempotency/rate-limit/domain checks, then composes via new
outbound.BuildForward{Subject,Body,HTMLBody} helpers — best-effort MIME
extraction of the original body, Gmail-style header block, no
In-Reply-To/References (forwards are new threads).

HITL fix: buildSendRequestFromMessage now only copies email_message_id
into ReplyToMessageID when type="reply", so approved forwards don't
accidentally stitch into the original thread on send.

Includes:
- migration 019 extending messages.message_type CHECK with 'forward'
- TS SDK forwardMessage in api/client/inbound-email
- CLI: e2a forward <msg-id> --to … [--body …]
- MCP: forward_message tool
- Compile fix on a pre-existing internal/e2e mismatch where local
  structs declared To as string instead of []string

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…ests

Review follow-ups:

1. BuildForwardBody mangled CRLF input. The naive ReplaceAll("\n","\r\n")
   turned existing "\r\n" into "\r\r\n", which renders as a literal CR
   in some clients. Most real inbound bodies arrive CRLF-terminated, so
   the bug would fire on virtually every multi-line forward.
   Fix: normalize "\r\n" → "\n" first, then convert to "\r\n".

2. performSelfSend hardcoded message_type="send" for the loopback row.
   Pre-existing latent bug — self-replies also recorded as "send".
   Fix: thread msgType through; all three callers (send/reply/forward)
   now record their actual intent.

3. Add the 4 integration tests the review called out:
   - TestForwardMessageSelfForwardUsesLoopback (loopback path coverage)
   - TestForwardMessageHTMLAtSMTP (multipart/alternative at the wire)
   - TestForwardMessageWithAttachments (caller-supplied attachment passthrough)
   - TestForwardMessageIdempotentReplay (Idempotency-Key replay shape)

4. Add 2 CRLF regression tests on BuildForwardBody (both CRLF and LF-only
   inputs must emit clean CRLF, never "\r\r\n").

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@jiashuoz jiashuoz force-pushed the feat/forward-message-api branch from 0dd179c to cd50568 Compare May 28, 2026 03:22
@jiashuoz jiashuoz merged commit 0dbf92c into main May 28, 2026
12 checks passed
jiashuoz added a commit that referenced this pull request May 28, 2026
Closes the parity gap from PR #171 (Forward feature). TS SDK, CLI, and
MCP shipped with Forward; the Python SDK didn't. Adds matching surface
to both sync and async variants.

Raw (api.py / async_client.py):
- forward_message(agent_email, message_id, body: ForwardMessageRequest,
  idempotency_key) → SendEmailResponse

High-level (client.py / async_client.py):
- forward(message_id, to, body=None, html_body=None, cc=None, bcc=None,
  conversation_id=None, attachments=None, agent_email=None,
  idempotency_key=None) → SendResult
  Builds the ForwardMessageRequest from kwargs, base64-encodes
  attachments via the existing _serialize_attachments helper, returns
  the SendResult dataclass — same shape callers already use for
  reply() and send().

Tests (+5):
- api.py: POST /forward with Idempotency-Key header threaded through
- client.py: high-level forward() returns SendResult; attachments +
  conversation_id round-trip correctly through the wire
- async_client.py: async high-level forward() smoke test

202 tests pass (was 197).

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
jiashuoz added a commit that referenced this pull request May 28, 2026
Adds .github/pull_request_template.md as a forcing function against
the parity gap we hit on #171 (Forward shipped to Go/TS/CLI/MCP but
missed Python; needed #175 to close). The template surfaces the full
client matrix (Go, TS SDK, Python SDK sync+async, CLI, MCP) plus
migration safety, generated-types refresh, and a per-surface tests
row.

GitHub auto-fills this on `gh pr create` and the web "New PR" form,
so it's free to author and free to enforce. Rows are individually
deletable for PRs that don't touch the API or any client.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
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.

1 participant