feat: add forward_email tool - #184
Conversation
Codecov Report❌ Patch coverage is
📢 Thoughts on this report? Let us know! |
|
Thanks for adding this. A forward tool is a useful and reasonably scoped addition, and I like that it reuses the existing send path and keeps forwarded content plain text for now. I found one blocking issue: In original = await self.incoming_client.get_email_body_by_id(email_id, mailbox)This does not pass The same issue exists for attachments: await self.incoming_client.extract_attachments(email_id, mailbox)
Could you update Please also add regression tests for:
There is also a MIME preservation issue when re-attaching original attachments: subtype = mime_type.split("/", 1)[1] if "/" in mime_type else "octet-stream"
part = MIMEApplication(payload, _subtype=subtype)
A few smaller follow-ups:
The rest of the direction looks good: recipient allowlist is enforced at the tool layer, read-only accounts are guarded, and BCC handling follows the existing send/Sent-save pattern. |
…ME type Addresses maintainer review on Wh1isper#184. Security (blocking): forward_email bypassed the sender allowlist from Wh1isper#180. - forward_email now reads get_settings().allowed_senders and threads it into both get_email_body_by_id and extract_attachments. A blocked original returns None (indistinguishable from missing), so it never reaches extract_attachments or send_email. - extract_attachments gains an allowed_senders param and checks the From header via _batch_fetch_senders/sender_allowed BEFORE fetching the body; a blocked message yields [] (indistinguishable from no-attachments/missing). MIME: re-attached parts used MIMEApplication, forcing every attachment to application/* (image/png -> application/png). Now built with MIMEBase(maintype, subtype) + base64 so the original type is preserved. extra_parts type hints widened to list[MIMEBase]. Smaller review follow-ups: - Tool description notes forwarded content is parsed plain text (HTML lost; may be truncated). - Read-only tool-visibility tests now assert forward_email is hidden/shown. - _format_forwarded_text labels recipients "Recipients:" (the parsed list may include Cc), not "To:". Regression tests: blocked sender not read/sent; blocked attachments not fetched; allowed sender fetched; image/png MIME preserved through the forward path. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
|
Thanks for the thorough review — all addressed, and rebased onto current Sender allowlist (blocking):
MIME preservation: switched from Smaller follow-ups:
Patch coverage is back to ~100%. |
Wh1isper
left a comment
There was a problem hiding this comment.
wh1isper's agent here: this is an agent-assisted maintainer review of HEAD c535e8cbb79f810ea78a2c8e244c8cc8123c920b.
Thank you for addressing the earlier sender-allowlist and MIME main-type feedback. Those fixes look good. I found three remaining correctness/security blockers before this can merge.
1. Validate email_id before any IMAP I/O
forward_email accepts an unconstrained string and ultimately passes it to imap.uid("fetch", email_id, ...) through _batch_fetch_senders() and _fetch_email_with_formats(). aioimaplib concatenates command arguments and does not reject CR/LF. Inputs such as UID sets or a CRLF-bearing value can therefore change the generated IMAP command and potentially bypass the application mutation controls.
Please add a centralized client/handler validation requiring one decimal IMAP UID in the valid range before opening an IMAP connection. The MCP schema can also use a pattern, but schema validation must not be the only defense. Add tests for CR/LF, spaces, 1:*, 1,2, zero, and out-of-range values, asserting that invalid input performs no IMAP I/O.
The underlying sink is shared by some existing tools, so a centralized fix is preferable to adding another unvalidated path.
2. Do not silently send a forward when attachment retrieval failed
_fetch_email_with_formats() can swallow failures and return None; extract_attachments() then maps missing mail, IMAP failure, raw-message extraction failure, and a genuinely attachment-free message to the same [] result. forward_email() treats that as success and sends a message without the original attachments.
Please distinguish a successfully parsed message with no attachments from an attachment-fetch/parse failure. Any failure must abort before SMTP. Ideally, parse the body and attachments from the same allowlist-protected raw-message fetch to avoid the second IMAP round trip and TOCTOU behavior.
3. Preserve valid .eml and empty attachments
At mcp_email_server/emails/classic.py:1266-1275, get_payload(decode=True) returns None for common message/rfc822 attachments and b"" for zero-byte attachments; the truthiness check drops both. Rebuilding parts from only (filename, mime_type, bytes) also loses MIME parameters and metadata such as smime-type, Content-ID, and Content-Language.
Please preserve/copy the original attachment MIME parts where possible, and add tests for message/rfc822, zero-byte, and nested multipart attachments. A fetch/parse failure must also be covered by a test that asserts send_email is not called.
Please also rebase after #194 lands; the branches currently conflict in tests/test_mcp_tools.py. The full PR suite is green, but it does not exercise these cases.
…ME type Addresses maintainer review on Wh1isper#184. Security (blocking): forward_email bypassed the sender allowlist from Wh1isper#180. - forward_email now reads get_settings().allowed_senders and threads it into both get_email_body_by_id and extract_attachments. A blocked original returns None (indistinguishable from missing), so it never reaches extract_attachments or send_email. - extract_attachments gains an allowed_senders param and checks the From header via _batch_fetch_senders/sender_allowed BEFORE fetching the body; a blocked message yields [] (indistinguishable from no-attachments/missing). MIME: re-attached parts used MIMEApplication, forcing every attachment to application/* (image/png -> application/png). Now built with MIMEBase(maintype, subtype) + base64 so the original type is preserved. extra_parts type hints widened to list[MIMEBase]. Smaller review follow-ups: - Tool description notes forwarded content is parsed plain text (HTML lost; may be truncated). - Read-only tool-visibility tests now assert forward_email is hidden/shown. - _format_forwarded_text labels recipients "Recipients:" (the parsed list may include Cc), not "To:". Regression tests: blocked sender not read/sent; blocked attachments not fetched; allowed sender fetched; image/png MIME preserved through the forward path. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
c535e8c to
b3c4ad1
Compare
|
Thank you for the work on The feature is still not present on current The updated implementation should, in particular:
Please rework this PR on top of current |
b3c4ad1 to
3a743ee
Compare
Forward an existing message to new recipients, re-attaching the original's MIME parts. Reworked against the Local Email App V2 application boundaries as requested in the review of Wh1isper#184, rather than rebasing the previous app.py/classic-handler implementation. Application layer (application/mutations.py) - ForwardCommand (a ComposeCommand carrying source_email_id, source_mailbox and include_attachments), ForwardSource/ForwardSourcePart, and ForwardService. - Three independent provider effects, each preceded by a fresh authority and policy resolution: the IMAP source read, SMTP delivery, and the sent copy. The recipient allowlist is validated against the pre-open snapshot and again against the opened account. - A failed, denied, cancelled or ambiguous source read aborts before the outgoing provider is opened, so a forward can never be delivered without the parts it was meant to carry. Sentinel ValueError and MutationProviderError propagate out of execute(); only TimeoutError is caught, and it is re-raised as MutationProviderError rather than being folded into a delivery outcome. - Forward bounds reuse APPLICATION_LIMITS: part count, per-part and aggregate attachment bytes, and the derived subject. The derived body (caller note plus forwarded block) is re-validated through ComposeCommand.validate, so an oversized forward is rejected rather than truncated. - The sent-copy tail of SendService.execute is extracted into a shared _complete_send() used by both services, keeping the ambiguity and reconciliation semantics in one place. SendService behavior is unchanged. - Results reuse SendMutationOutcome and existing detail tags. Provider layer (emails/classic.py) - EmailClient.fetch_forward_source() reads the source in a single IMAP session, enforcing the sender allowlist before the body is fetched so a blocked source stays indistinguishable from a missing one. Every failure raises; there is no empty-list sentinel that could be mistaken for "no attachments". - normalize_forwarded_part() round-trips each source part through compat32 before it is attached. Source parts are parsed under policy=default, while compose_message builds compat32 containers that are later flattened under SMTP, SMTPUTF8, compat32 for the IMAP append, and again by aiosmtplib. Attaching a structured part directly loses the RFC 2231 charset label and re-encodes the parameter as unknown-8bit, and does so only when the source header needs refolding. The round-trip freezes part headers as opaque strings, so every send path emits identical bytes. - Parts are re-attached rather than rebuilt from get_payload(decode=True), which preserves message/rfc822 subtrees, zero-byte parts, Content-ID, Content-Transfer-Encoding and Content-Type parameters. - compose_message() and send_email_with_outcome() accept keyword-only extra_parts; existing call sites and behavior are untouched. - When forwarded parts would place raw 8-bit octets on a session that did not advertise 8BITMIME, the transaction fails before MAIL with smtp-8bitmime-required, mirroring the existing smtp-utf8-unsupported pre-flight rejection. send_email_with_outcome has no general 7-bit downgrade; that gap is pre-existing and left for a separate change. Transport and adapter - ClassicMutationProvider.fetch_forward_source()/forward(), both bounded, with the same capability_unavailable guard as send for IMAP-only accounts. - forward_email is annotated as a non-read-only, non-idempotent, open-world mutation, matching send_email, and the catalog fixture is regenerated. - smtp-8bitmime-required is added to the public send detail allowlist so the reason survives redaction. Docs and spec - docs/tools.md, docs/guides.md and docs/configuration.md cover the tool, its annotations, the plain-text quoting limitation and the SMTP requirement. - spec/07 gains a Forward subsection and acceptance criterion 07.12; spec/12 extends the corresponding verification row. Tests: +83 unit and contract tests plus GreenMail stdio E2E coverage that verifies the delivered message and the sent copy over imaplib rather than trusting the server's own response. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
Reworked from scratch against the V2 application boundaries per your 2026-07-25 review — this replaces the old Mapping to your rework brief:
Local gates: full suite 1347 passed (+83 new), Two deliberate calls to flag:
|
Codecov Report❌ Patch coverage is Additional details and impacted files@@ Coverage Diff @@
## main #184 +/- ##
=======================================
+ Coverage 84.3% 84.7% +0.4%
=======================================
Files 30 30
Lines 8545 8726 +181
Branches 1185 1210 +25
=======================================
+ Hits 7204 7395 +191
+ Misses 942 935 -7
+ Partials 399 396 -3 ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
Wh1isper
left a comment
There was a problem hiding this comment.
Thank you for reworking this from scratch against the current application/provider boundaries. This is a substantial improvement over the previous implementation: the source read is now allowlist-protected and fail-closed, MIME parts are retained rather than rebuilt from decoded payloads, authority is reopened between effects, and delivery/Sent-copy ambiguity reuses the existing send semantics.
I reviewed HEAD 3a743ee935f83402d1514c70fdfa87144c32f821. I found one P1 content-integrity blocker and one P2 capability-ordering issue before this is ready to merge. The shared SMTP 7-bit/8-bit/binary transport concern will be handled separately in #233, after which this branch should rebase and reuse that common implementation.
P1: the forwarded body is still silently truncated at 20,000 characters
fetch_forward_source() calls:
email_data = self._parse_email_data(raw_email, email_id)without overriding _parse_email_data()'s MAX_BODY_LENGTH = 20000 default. _parse_email_data() therefore replaces a longer body with the first 20,000 characters plus ...[TRUNCATED], and the application layer validates and sends that already-truncated value.
This contradicts the new documentation, which says that forwarded content is never silently truncated and that an oversized composed body is rejected. It also recreates the silent content-loss behavior the new fail-closed source-read design is intended to prevent.
Please make the forward path parse enough content for APPLICATION_LIMITS.body_bytes to remain authoritative. One simple option is to use a forward-specific window larger than the byte limit, so any parser truncation necessarily produces a value that application validation rejects rather than sends.
Please add concrete-provider regression tests proving that:
- a source body longer than 20,000 characters but within the 1 MiB compose limit is forwarded in full;
- a source body exceeding the compose limit is rejected before the outgoing provider is opened.
P2: an IMAP-only account reads the complete source before discovering that it cannot send
ForwardService.execute() opens the incoming provider and fetches the complete source before it opens the outgoing provider. The absence of SMTP is only detected later in ClassicMutationProvider.forward():
client = self._handler.outgoing_client
if client is None:
raise MutationProviderError(...)An IMAP-only account therefore logs in, downloads, and parses the source message and attachments before the operation reports that forwarding is unavailable. This does not match the new configuration documentation, which says the source read occurs only after the send-capability check.
Please reject a non-send-capable account before source-message I/O, while preserving late secret resolution. I would avoid opening the outgoing provider merely as a capability probe, because that resolves the outgoing secret too early; exposing a non-secret send-capability flag in the authority snapshot would keep the boundary cleaner.
Please add an application-level test proving that an IMAP-only account performs no incoming provider access for forward_email.
Shared SMTP transport handling will be addressed in #233
The new smtp-8bitmime-required preflight is directionally correct and is a reasonable fail-closed response to forwarded parts containing raw 8-bit octets.
However, the current extra_parts-specific check is not a complete SMTP transport classifier. In particular, bytes.isascii() does not detect NUL or other content requiring binary transport, and 8BITMIME does not make Content-Transfer-Encoding: binary safe to submit through SMTP DATA.
This is a shared send_email_with_outcome boundary rather than a forward-specific concern, so we will address the general transport classification in #233 instead of asking this PR to grow a second partial implementation. The intended scope for #233 is conservative pre-MAIL validation, not a recursive MIME downgrade.
Please focus this PR on the two forward-specific findings above. Once #233 lands, please rebase this branch and use the shared transport handling rather than retaining the extra_parts-specific _part_emits_raw_8bit() guard.
The remaining direction looks good. In particular, the single-session allowlist-protected source read, fail-closed attachment handling, retained MIME subtrees, centralized limits, and shared delivery/Sent-copy outcome semantics are all aligned with the current architecture.
Forward an existing message to new recipients, re-attaching the original's MIME parts. Reworked against the Local Email App V2 application boundaries as requested in the review of Wh1isper#184, rather than rebasing the previous app.py/classic-handler implementation. Application layer (application/mutations.py) - ForwardCommand (a ComposeCommand carrying source_email_id, source_mailbox and include_attachments), ForwardSource/ForwardSourcePart, and ForwardService. - Three independent provider effects, each preceded by a fresh authority and policy resolution: the IMAP source read, SMTP delivery, and the sent copy. The recipient allowlist is validated against the pre-open snapshot and again against the opened account. - A failed, denied, cancelled or ambiguous source read aborts before the outgoing provider is opened, so a forward can never be delivered without the parts it was meant to carry. Sentinel ValueError and MutationProviderError propagate out of execute(); only TimeoutError is caught, and it is re-raised as MutationProviderError rather than being folded into a delivery outcome. - Forward bounds reuse APPLICATION_LIMITS: part count, per-part and aggregate attachment bytes, and the derived subject. The derived body (caller note plus forwarded block) is re-validated through ComposeCommand.validate, so an oversized forward is rejected rather than truncated. - The sent-copy tail of SendService.execute is extracted into a shared _complete_send() used by both services, keeping the ambiguity and reconciliation semantics in one place. SendService behavior is unchanged. - Results reuse SendMutationOutcome and existing detail tags. Provider layer (emails/classic.py) - EmailClient.fetch_forward_source() reads the source in a single IMAP session, enforcing the sender allowlist before the body is fetched so a blocked source stays indistinguishable from a missing one. Every failure raises; there is no empty-list sentinel that could be mistaken for "no attachments". - normalize_forwarded_part() round-trips each source part through compat32 before it is attached. Source parts are parsed under policy=default, while compose_message builds compat32 containers that are later flattened under SMTP, SMTPUTF8, compat32 for the IMAP append, and again by aiosmtplib. Attaching a structured part directly loses the RFC 2231 charset label and re-encodes the parameter as unknown-8bit, and does so only when the source header needs refolding. The round-trip freezes part headers as opaque strings, so every send path emits identical bytes. - Parts are re-attached rather than rebuilt from get_payload(decode=True), which preserves message/rfc822 subtrees, zero-byte parts, Content-ID, Content-Transfer-Encoding and Content-Type parameters. - compose_message() and send_email_with_outcome() accept keyword-only extra_parts; existing call sites and behavior are untouched. - When forwarded parts would place raw 8-bit octets on a session that did not advertise 8BITMIME, the transaction fails before MAIL with smtp-8bitmime-required, mirroring the existing smtp-utf8-unsupported pre-flight rejection. send_email_with_outcome has no general 7-bit downgrade; that gap is pre-existing and left for a separate change. Transport and adapter - ClassicMutationProvider.fetch_forward_source()/forward(), both bounded, with the same capability_unavailable guard as send for IMAP-only accounts. - forward_email is annotated as a non-read-only, non-idempotent, open-world mutation, matching send_email, and the catalog fixture is regenerated. - smtp-8bitmime-required is added to the public send detail allowlist so the reason survives redaction. Docs and spec - docs/tools.md, docs/guides.md and docs/configuration.md cover the tool, its annotations, the plain-text quoting limitation and the SMTP requirement. - spec/07 gains a Forward subsection and acceptance criterion 07.12; spec/12 extends the corresponding verification row. Tests: +83 unit and contract tests plus GreenMail stdio E2E coverage that verifies the delivered message and the sent copy over imaplib rather than trusting the server's own response. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
Both findings addressed, and the branch is rebased onto current P1 — silent 20k truncation (fixed):
Both tests were verified to fail against the previous code before the fix. P2 — IMAP-only account reads the source before failing (fixed): Shared transport handling (#233/#234): Spec: #234 took acceptance criterion 07.12, so the forward criterion is now 07.13; Local gates: |
|
@jbkjr I think we're not far from a complete merge. Please resolve the conflicts and have agent review it again. |
…eview fixes (P1 truncation window, P2 can_send preflight, 8bit container labeling) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
3a743ee to
e41c3a3
Compare
Forward an existing message to new recipients, re-attaching the original's MIME parts. Reworked against the Local Email App V2 application boundaries as requested in the review of Wh1isper#184, rather than rebasing the previous app.py/classic-handler implementation. Application layer (application/mutations.py) - ForwardCommand (a ComposeCommand carrying source_email_id, source_mailbox and include_attachments), ForwardSource/ForwardSourcePart, and ForwardService. - Three independent provider effects, each preceded by a fresh authority and policy resolution: the IMAP source read, SMTP delivery, and the sent copy. The recipient allowlist is validated against the pre-open snapshot and again against the opened account. - A failed, denied, cancelled or ambiguous source read aborts before the outgoing provider is opened, so a forward can never be delivered without the parts it was meant to carry. Sentinel ValueError and MutationProviderError propagate out of execute(); only TimeoutError is caught, and it is re-raised as MutationProviderError rather than being folded into a delivery outcome. - Forward bounds reuse APPLICATION_LIMITS: part count, per-part and aggregate attachment bytes, and the derived subject. The derived body (caller note plus forwarded block) is re-validated through ComposeCommand.validate, so an oversized forward is rejected rather than truncated. - The sent-copy tail of SendService.execute is extracted into a shared _complete_send() used by both services, keeping the ambiguity and reconciliation semantics in one place. SendService behavior is unchanged. - Results reuse SendMutationOutcome and existing detail tags. Provider layer (emails/classic.py) - EmailClient.fetch_forward_source() reads the source in a single IMAP session, enforcing the sender allowlist before the body is fetched so a blocked source stays indistinguishable from a missing one. Every failure raises; there is no empty-list sentinel that could be mistaken for "no attachments". - normalize_forwarded_part() round-trips each source part through compat32 before it is attached. Source parts are parsed under policy=default, while compose_message builds compat32 containers that are later flattened under SMTP, SMTPUTF8, compat32 for the IMAP append, and again by aiosmtplib. Attaching a structured part directly loses the RFC 2231 charset label and re-encodes the parameter as unknown-8bit, and does so only when the source header needs refolding. The round-trip freezes part headers as opaque strings, so every send path emits identical bytes. - Parts are re-attached rather than rebuilt from get_payload(decode=True), which preserves message/rfc822 subtrees, zero-byte parts, Content-ID, Content-Transfer-Encoding and Content-Type parameters. - compose_message() and send_email_with_outcome() accept keyword-only extra_parts; existing call sites and behavior are untouched. - When forwarded parts would place raw 8-bit octets on a session that did not advertise 8BITMIME, the transaction fails before MAIL with smtp-8bitmime-required, mirroring the existing smtp-utf8-unsupported pre-flight rejection. send_email_with_outcome has no general 7-bit downgrade; that gap is pre-existing and left for a separate change. Transport and adapter - ClassicMutationProvider.fetch_forward_source()/forward(), both bounded, with the same capability_unavailable guard as send for IMAP-only accounts. - forward_email is annotated as a non-read-only, non-idempotent, open-world mutation, matching send_email, and the catalog fixture is regenerated. - smtp-8bitmime-required is added to the public send detail allowlist so the reason survives redaction. Docs and spec - docs/tools.md, docs/guides.md and docs/configuration.md cover the tool, its annotations, the plain-text quoting limitation and the SMTP requirement. - spec/07 gains a Forward subsection and acceptance criterion 07.12; spec/12 extends the corresponding verification row. Tests: +83 unit and contract tests plus GreenMail stdio E2E coverage that verifies the delivered message and the sent copy over imaplib rather than trusting the server's own response. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
e41c3a3 to
431a857
Compare
|
Conflicts resolved and the agent review re-run, per your request. New head: On the conflicts: my mistake — yesterday's rework was force-pushed to the wrong branch of my fork ( Agent review: I ran a ten-angle multi-agent review over the full diff (line-by-line, removed-behavior, cross-file tracing, language pitfalls, wrapper correctness, plus reuse/simplification/efficiency/altitude/conventions), adversarially verified the contested findings by reproduction, and finished with an independent gap sweep. It surfaced real issues, which this amendment fixes:
Two behaviors surfaced by the review that I deliberately did not change, flagged for your disposition:
Also noted as small follow-ups rather than grown into this PR: the Sent copy of an 8-bit forward rides a plain IMAP literal (fine for GreenMail/Dovecot-class servers; a literal8/BINARY path would be a separate change), the per-part serialize count on the forward path, and deduplicating the three near-identical IMAP read prologues. Local gates on |
…r strip, command locks, required can_send, fixed-shape errors, single parse) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Wh1isper
left a comment
There was a problem hiding this comment.
Reviewed current head after maintainer fixes. Forwarded-part bounds now use the conservative SMTP/SMTPUTF8 wire serialization size, source sender policy is revalidated before SMTP with privacy-preserving precedence, and the spec/docs/tests match the implementation. Local make check, make test (1392 passed), make docs-test, and GreenMail E2E (5 passed) all pass.
Summary
Adds a
forward_emailtool — forward an existing message to new recipients, re-attaching the original's attachments. This is a common action that currently isn't possible (you can reply viasend_emailthreading, but not forward with attachments).Behaviour mirrors
send_email: it fetches the original, builds aFwd:subject (not double-prefixed), appends a plain-text "---------- Forwarded message ----------" block below any optional note, re-attaches the original attachments, sends, and saves a copy to Sent when configured.Supporting changes (small, and reused by the existing send path)
EmailClient.extract_attachments— fetch an email and return its attachments as(filename, mime_type, data)tuples, built on the same fetch/parse helpersdownload_attachmentalready uses.compose_message/_create_message_with_attachments/EmailClient.send_emailgain an optionalextra_partsargument for attaching prebuilt in-memory MIME parts (the re-forwarded attachments). Existing behaviour is unchanged when it isn't passed.EmailHandlerinterface; the tool enforces the recipient allowlist exactly likesend_email.Forwarded content is sent as plain text (the parsed body, which is already text even for HTML originals) to stay simple and universally readable; a richer HTML forward could be a follow-up.
Tests
Fwd:case, and the not-found error.uv run pytest),ruff checkclean.