From 732a3d2749920ecc6eaac489ebb46e36ca125140 Mon Sep 17 00:00:00 2001 From: Addison Kline <77369109+addisonkline@users.noreply.github.com> Date: Thu, 18 Jun 2026 16:18:58 -0400 Subject: [PATCH] feat: support message forwarding --- src/mail/client/docs/reference/cli.md | 10 ++ src/mail/client/docs/tutorials/quickstart.md | 24 +++ src/mail/client/src/mail_client/cli.py | 33 +++- .../src/mail_client/commands/__init__.py | 2 + .../src/mail_client/commands/forward.py | 155 ++++++++++++++++++ tests/unit/test_cli_help.py | 1 + tests/unit/test_client_commands.py | 148 +++++++++++++++++ 7 files changed, 372 insertions(+), 1 deletion(-) create mode 100644 src/mail/client/src/mail_client/commands/forward.py diff --git a/src/mail/client/docs/reference/cli.md b/src/mail/client/docs/reference/cli.md index b582234..a2b9134 100644 --- a/src/mail/client/docs/reference/cli.md +++ b/src/mail/client/docs/reference/cli.md @@ -15,6 +15,7 @@ mail [option]... [argument]... - `compose`: Draft a new MAIL message. Accepts `--tags TAG...` to attach slug tags. - `send`: Send an existing draft by ID to the specified recipient(s). Accepts `--tags TAG...`, which are merged with the draft's tags. - `reply`: Reply to an existing inbox message by ID. Addresses the reply to the original sender and defaults the subject to `Re: `. Accepts `--subject SUBJECT` and `--tags TAG...`. +- `forward`: Forward an existing inbox message by ID to one or more new recipient(s). Encodes the original message (sender, recipients, subject, and body) into the forwarded body and defaults the subject to `Fwd: `. Accepts `--note NOTE` to prepend a note above the forwarded message, plus `--subject SUBJECT` and `--tags TAG...`. - `inbox`: Open your MAIL inbox. - `inbox-open`: Open a specific message by ID in your MAIL inbox. - `outbox`: Open your MAIL outbox. @@ -64,3 +65,12 @@ defaults to `Re: `): mail reply "Thanks, acknowledged." mail reply "See attached." --subject "Follow-up" --tags project-x ``` + +Forward a message from your inbox to new recipient(s) (the original message is +encoded into the forwarded body, subject defaults to `Fwd: `): + +```bash +mail forward sage@chorus@localhost +mail forward sage@chorus@localhost philosopher@chorus@localhost \ + --note "Please take a look." --subject "Heads up" --tags fyi +``` diff --git a/src/mail/client/docs/tutorials/quickstart.md b/src/mail/client/docs/tutorials/quickstart.md index 13d1417..8eb3d29 100644 --- a/src/mail/client/docs/tutorials/quickstart.md +++ b/src/mail/client/docs/tutorials/quickstart.md @@ -79,6 +79,30 @@ uv run mail send supervisor@default@example.com You should then see the MAIL message created and sent to the `supervisor@default@example.com`. This includes the message's unique ID. +## Forward a Message + +If a message in your inbox is relevant to other user-agents, you can forward it +to one or more new recipients with the `forward` command. +MAIL encodes the original message (its sender, recipients, subject, and body) into +the forwarded message's body, and defaults the subject to `Fwd: `. +To forward an inbox message to `sage@chorus@example.com`: + +```bash +MAIL_SERVER=... \ +MAIL_TOKEN=... \ +uv run mail forward sage@chorus@example.com +``` + +You can supply multiple recipients, prepend your own note with `--note`, and +override the subject with `--subject`: + +```bash +MAIL_SERVER=... \ +MAIL_TOKEN=... \ +uv run mail forward sage@chorus@example.com philosopher@chorus@example.com \ + --note "Please take a look." +``` + ## See Also - `mail-swarms-client` CLI reference: [reference/cli.md](/docs/reference/cli.md) diff --git a/src/mail/client/src/mail_client/cli.py b/src/mail/client/src/mail_client/cli.py index 8dd69f1..34d660e 100644 --- a/src/mail/client/src/mail_client/cli.py +++ b/src/mail/client/src/mail_client/cli.py @@ -12,6 +12,7 @@ cmd_compose, cmd_drafts, cmd_drafts_open, + cmd_forward, cmd_inbox, cmd_inbox_open, cmd_list_get, @@ -75,6 +76,7 @@ ("compose (c)", "Draft a new MAIL message."), ("send (s)", "Send a drafted message."), ("reply (r)", "Reply to an inbox message by ID."), + ("forward (f)", "Forward an inbox message to new recipient(s)."), ("inbox (i)", "List your inbox messages."), ("inbox-open (open, o)", "Open an inbox message by ID."), ("outbox (O)", "List your sent messages."), @@ -109,13 +111,14 @@ "mail send user@example", "mail inbox-open ", 'mail reply "Thanks, acknowledged."', + "mail forward sage@chorus@localhost", ] def _add_tags_arg(parser: argparse.ArgumentParser) -> None: """ Register the shared ``--tags`` flag for message-creating commands - (compose, send, reply). Tags are slug strings used to categorize a + (compose, send, reply, forward). Tags are slug strings used to categorize a message; the default is an empty list (no tags). """ @@ -260,6 +263,34 @@ def build_parser() -> argparse.ArgumentParser: _add_tags_arg(reply_p) reply_p.set_defaults(func=cmd_reply, cmd="reply") + # command `forward` + forward_d = "forward an existing inbox message to new recipient(s)" + forward_p = subparsers.add_parser( + "forward", + aliases=["f"], + prog="mail forward", + help=forward_d, + description=forward_d, + ) + forward_p.add_argument( + "message_id", help="the ID of the inbox message to forward" + ) + forward_p.add_argument( + "to", nargs="+", help="the address(es) to forward this message to" + ) + forward_p.add_argument( + "--note", + default=None, + help="an optional note to prepend above the forwarded message", + ) + forward_p.add_argument( + "--subject", + default=None, + help="the subject of the forward (default: 'Fwd: ')", + ) + _add_tags_arg(forward_p) + forward_p.set_defaults(func=cmd_forward, cmd="forward") + # command `inbox` inbox_d = "open your MAIL inbox" inbox_p = subparsers.add_parser( diff --git a/src/mail/client/src/mail_client/commands/__init__.py b/src/mail/client/src/mail_client/commands/__init__.py index e406d9f..8914fd5 100644 --- a/src/mail/client/src/mail_client/commands/__init__.py +++ b/src/mail/client/src/mail_client/commands/__init__.py @@ -9,6 +9,7 @@ from .daemon_post import cmd_daemon_post from .drafts import cmd_drafts from .drafts_open import cmd_drafts_open +from .forward import cmd_forward from .inbox import cmd_inbox from .inbox_open import cmd_inbox_open from .list_delete import cmd_list_delete @@ -57,6 +58,7 @@ "cmd_daemon_post", "cmd_drafts", "cmd_drafts_open", + "cmd_forward", "cmd_inbox", "cmd_inbox_open", "cmd_list_delete", diff --git a/src/mail/client/src/mail_client/commands/forward.py b/src/mail/client/src/mail_client/commands/forward.py new file mode 100644 index 0000000..28aa5cd --- /dev/null +++ b/src/mail/client/src/mail_client/commands/forward.py @@ -0,0 +1,155 @@ +# SPDX-License-Identifier: Apache-2.0 +# Copyright (c) 2026 Addison Kline + +import os +from argparse import Namespace + +import httpx +from mail_protocol.core.messages import MAILMessage +from mail_protocol.network.requests import DraftPostRequest, DraftSendPostRequest +from mail_protocol.network.responses import ( + DraftPostResponse, + DraftSendPostResponse, + InboxMessageGetResponse, +) +from pydantic import ValidationError + +USER_AGENT = "Multi-Agent-Interface-Layer-CLI-Client/2.0.0 (github.com/charonlabs/mail)" + + +def _forward_subject(original_subject: str) -> str: + """ + Derive the default subject for a forward. An existing `Fwd:` prefix + (case-insensitive) is preserved rather than duplicated. + """ + + if original_subject.lower().startswith("fwd:"): + return original_subject + return f"Fwd: {original_subject}" + + +def _forward_body(original: MAILMessage, note: str | None) -> str: + """ + Encode the original message into the forwarded body, optionally prefixed + with the forwarding user-agent's own note. The quoted block mirrors the + familiar email "forwarded message" convention so the original sender, + recipients, subject, and body remain legible to the new recipient(s). + """ + + forwarded_block = ( + "---------- Forwarded message ----------\n" + f"From: {original.sender}\n" + f"Date: {original.sent_at}\n" + f"Subject: {original.subject}\n" + f"To: {', '.join(original.recipients)}\n" + "\n" + f"{original.body}" + ) + if note: + return f"{note}\n\n{forwarded_block}" + return forwarded_block + + +def cmd_forward(args: Namespace) -> None: + """ + Forward an existing inbox message to one or more new recipient(s). + + Fetches the original message, builds a draft whose body encodes the + original (sender, recipients, subject, and body) along with an optional + note, defaults the subject to `Fwd: `, then sends it to + the specified recipient(s). + """ + + # 1. check that the required env vars are provided + MAIL_SERVER = os.getenv("MAIL_SERVER") + if MAIL_SERVER is None: + raise ValueError("env var MAIL_SERVER is required") + MAIL_TOKEN = os.getenv("MAIL_TOKEN") + if MAIL_TOKEN is None: + raise ValueError("env var MAIL_TOKEN is required") + + headers = { + "Authorization": f"Bearer {MAIL_TOKEN}", + "User-Agent": USER_AGENT, + "Content-Type": "application/json", + } + + # 2. fetch the original message from the user's inbox + original_response = httpx.get( + url=f"{MAIL_SERVER}/inbox/{args.message_id}", + headers=headers, + ) + if original_response.status_code != 200: + raise RuntimeError( + f"get inbox entry request to {MAIL_SERVER} failed with status code " + f"{original_response.status_code}" + ) + try: + original_obj = InboxMessageGetResponse.model_validate(original_response.json()) + except ValidationError as e: + raise RuntimeError(f"response validation failed: {e}") + + original = original_obj.entry.message + subject = args.subject if args.subject else _forward_subject(original.subject) + + # 3. create the forward draft, encoding the original message in the body + draft_payload = DraftPostRequest( + subject=subject, + body=_forward_body(original, args.note), + tags=args.tags, + ) + draft_response = httpx.post( + url=f"{MAIL_SERVER}/drafts", + headers=headers, + json=draft_payload.model_dump(), + ) + if draft_response.status_code != 200: + raise RuntimeError( + f"post draft request to {MAIL_SERVER} failed with status code " + f"{draft_response.status_code}" + ) + try: + draft_obj = DraftPostResponse.model_validate(draft_response.json()) + except ValidationError as e: + raise RuntimeError(f"response validation failed: {e}") + + draft_id = draft_obj.entry.draft.draft_id + + # 4. send the draft to the specified recipient(s) + send_payload = DraftSendPostRequest(recipients=args.to) + send_response = httpx.post( + url=f"{MAIL_SERVER}/drafts/{draft_id}/send", + headers=headers, + json=send_payload.model_dump(), + ) + if send_response.status_code != 200: + raise RuntimeError( + f"send request to {MAIL_SERVER} failed with status code " + f"{send_response.status_code}" + ) + try: + send_obj = DraftSendPostResponse.model_validate(send_response.json()) + except ValidationError as e: + raise RuntimeError(f"response validation failed: {e}") + + # 5. print the forwarded message + match args.output: + case "json": + print(send_obj.model_dump_json()) + case "text": + _print_text(send_obj) + + +def _print_text(response_obj: DraftSendPostResponse) -> None: + message = response_obj.message + print("=== Message Forwarded ===") + print(f"Message ID: {message.message_id}") + print(f"Sent At: {message.sent_at}") + print(f"Sender: {message.sender}") + print("Recipient(s):") + for recipient in message.recipients: + print(f"- {recipient}") + print(f"Subject: {message.subject}") + if message.tags: + print(f"Tags: {', '.join(message.tags)}") + print(f"Body:\n{message.body}\n") diff --git a/tests/unit/test_cli_help.py b/tests/unit/test_cli_help.py index f7c188f..51dfff2 100644 --- a/tests/unit/test_cli_help.py +++ b/tests/unit/test_cli_help.py @@ -28,6 +28,7 @@ def test_mail_help_uses_categorized_command_sections() -> None: assert "{ping,p,login" not in help_text assert 'mail compose "Status update"' in help_text assert "reply (r)" in help_text + assert "forward (f)" in help_text def test_mail_admin_help_uses_categorized_command_sections() -> None: diff --git a/tests/unit/test_client_commands.py b/tests/unit/test_client_commands.py index 21e3cc0..3619df6 100644 --- a/tests/unit/test_client_commands.py +++ b/tests/unit/test_client_commands.py @@ -18,6 +18,7 @@ import respx from mail_client.commands import ( cmd_compose, + cmd_forward, cmd_inbox, cmd_login, cmd_ping, @@ -395,3 +396,150 @@ def test_reply_raises_when_original_missing(client_env) -> None: tags=[], ) ) + + +# ─── forward ─────────────────────────────────────────────────────── + + +def _mock_forward_routes(draft_id: str, forwarded_message: dict): + """Register the three calls a forward makes: fetch, draft, send.""" + + inbox_route = respx.get(f"{SERVER}/inbox/{ORIGINAL_ID}").mock( + return_value=httpx.Response(200, json=_inbox_entry(ORIGINAL_MESSAGE)) + ) + draft = { + "draft_id": draft_id, + "subject": forwarded_message["subject"], + "body": forwarded_message["body"], + "created_at": "2026-06-12T09:10:00+00:00", + "updated_at": None, + "reply_to": None, + "tags": forwarded_message["tags"], + } + draft_route = respx.post(f"{SERVER}/drafts").mock( + return_value=httpx.Response( + 200, + json={ + "entry": {"draft": draft, "sent_at": None, "sent_by": None}, + "metadata": {}, + }, + ) + ) + send_route = respx.post(f"{SERVER}/drafts/{draft_id}/send").mock( + return_value=httpx.Response( + 200, json={"message": forwarded_message, "metadata": {}} + ) + ) + return inbox_route, draft_route, send_route + + +@respx.mock +def test_forward_defaults_subject_and_encodes_original( + client_env, capsys: pytest.CaptureFixture +) -> None: + draft_id = "55555555-5555-4555-8555-555555555555" + forwarded_message = { + "mail_version": "2.0", + "message_id": "77777777-7777-4777-8777-777777777777", + "reply_to": None, + "sender": "user:alice@localhost", + "recipients": ["sage@chorus@localhost"], + "subject": "Fwd: Original subject", + "body": "encoded", + "tags": [], + "sent_at": "2026-06-12T09:11:00+00:00", + "metadata": {}, + } + _, draft_route, send_route = _mock_forward_routes(draft_id, forwarded_message) + + cmd_forward( + Namespace( + output="text", + message_id=ORIGINAL_ID, + to=["sage@chorus@localhost"], + note=None, + subject=None, + tags=[], + ) + ) + + # The draft defaults the subject to "Fwd: ..." and is NOT a reply. + draft_body = json.loads(draft_route.calls[0].request.content) + assert draft_body["subject"] == "Fwd: Original subject" + assert draft_body["reply_to"] is None + # The encoded body carries the original sender, recipients, and content. + assert "---------- Forwarded message ----------" in draft_body["body"] + assert f"From: {ORIGINAL_MESSAGE['sender']}" in draft_body["body"] + assert "To: user:alice@localhost" in draft_body["body"] + assert ORIGINAL_MESSAGE["body"] in draft_body["body"] + # No note was supplied, so the body starts with the forwarded block. + assert draft_body["body"].startswith("---------- Forwarded message ----------") + + # The forward is addressed to the user-specified recipient(s). + send_body = json.loads(send_route.calls[0].request.content) + assert send_body == { + "recipients": ["sage@chorus@localhost"], + "tags": [], + } + out = capsys.readouterr().out + assert "Message Forwarded" in out + + +@respx.mock +def test_forward_honors_note_subject_and_tags( + client_env, capsys: pytest.CaptureFixture +) -> None: + draft_id = "55555555-5555-4555-8555-555555555555" + forwarded_message = { + "mail_version": "2.0", + "message_id": "77777777-7777-4777-8777-777777777777", + "reply_to": None, + "sender": "user:alice@localhost", + "recipients": ["sage@chorus@localhost", "philosopher@chorus@localhost"], + "subject": "Custom subject", + "body": "encoded", + "tags": ["fyi", "project-x"], + "sent_at": "2026-06-12T09:11:00+00:00", + "metadata": {}, + } + _, draft_route, send_route = _mock_forward_routes(draft_id, forwarded_message) + + cmd_forward( + Namespace( + output="text", + message_id=ORIGINAL_ID, + to=["sage@chorus@localhost", "philosopher@chorus@localhost"], + note="Please take a look.", + subject="Custom subject", + tags=["fyi", "project-x"], + ) + ) + + draft_body = json.loads(draft_route.calls[0].request.content) + assert draft_body["subject"] == "Custom subject" + assert draft_body["tags"] == ["fyi", "project-x"] + # The note is prepended above the forwarded block. + assert draft_body["body"].startswith("Please take a look.\n\n") + assert "---------- Forwarded message ----------" in draft_body["body"] + + send_body = json.loads(send_route.calls[0].request.content) + assert send_body["recipients"] == [ + "sage@chorus@localhost", + "philosopher@chorus@localhost", + ] + + +@respx.mock +def test_forward_raises_when_original_missing(client_env) -> None: + respx.get(f"{SERVER}/inbox/{ORIGINAL_ID}").mock(return_value=httpx.Response(404)) + with pytest.raises(RuntimeError, match="404"): + cmd_forward( + Namespace( + output="text", + message_id=ORIGINAL_ID, + to=["sage@chorus@localhost"], + note=None, + subject=None, + tags=[], + ) + )