Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions src/mail/client/docs/reference/cli.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ mail [option]... <command> [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: <original subject>`. 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: <original subject>`. 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.
Expand Down Expand Up @@ -64,3 +65,12 @@ defaults to `Re: <original subject>`):
mail reply <message_id> "Thanks, acknowledged."
mail reply <message_id> "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: <original subject>`):

```bash
mail forward <message_id> sage@chorus@localhost
mail forward <message_id> sage@chorus@localhost philosopher@chorus@localhost \
--note "Please take a look." --subject "Heads up" --tags fyi
```
24 changes: 24 additions & 0 deletions src/mail/client/docs/tutorials/quickstart.md
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,30 @@ uv run mail send <draft_id> 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: <original subject>`.
To forward an inbox message to `sage@chorus@example.com`:

```bash
MAIL_SERVER=... \
MAIL_TOKEN=... \
uv run mail forward <message_id> 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 <message_id> 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)
Expand Down
33 changes: 32 additions & 1 deletion src/mail/client/src/mail_client/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
cmd_compose,
cmd_drafts,
cmd_drafts_open,
cmd_forward,
cmd_inbox,
cmd_inbox_open,
cmd_list_get,
Expand Down Expand Up @@ -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."),
Expand Down Expand Up @@ -109,13 +111,14 @@
"mail send <draft-id> user@example",
"mail inbox-open <message-id>",
'mail reply <message-id> "Thanks, acknowledged."',
"mail forward <message-id> 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).
"""

Expand Down Expand Up @@ -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: <original subject>')",
)
_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(
Expand Down
2 changes: 2 additions & 0 deletions src/mail/client/src/mail_client/commands/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -57,6 +58,7 @@
"cmd_daemon_post",
"cmd_drafts",
"cmd_drafts_open",
"cmd_forward",
"cmd_inbox",
"cmd_inbox_open",
"cmd_list_delete",
Expand Down
155 changes: 155 additions & 0 deletions src/mail/client/src/mail_client/commands/forward.py
Original file line number Diff line number Diff line change
@@ -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: <original subject>`, 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")
1 change: 1 addition & 0 deletions tests/unit/test_cli_help.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
Loading
Loading