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
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -224,3 +224,6 @@ test-swarm-registry.json
# plan documents
.plans/
.v2_plans/

# draft documents
.drafts/
28 changes: 28 additions & 0 deletions spec/openapi.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -209,6 +209,18 @@ paths:
application/json:
schema:
$ref: '#/components/schemas/DraftDeleteResponse'
patch:
tags:
- drafts
summary: Update a specific message draft by ID
operationId: patch_draft_drafts__draft_id__patch
responses:
'200':
description: Successful Response
content:
application/json:
schema:
$ref: '#/components/schemas/DraftPatchResponse'
/drafts/{draft_id}/send:
post:
tags:
Expand Down Expand Up @@ -1294,6 +1306,22 @@ components:
description: 'Corresponds to `GET /drafts/{draft_id}`.

Contains a specific message draft inside the user-agent''s drafts box.'
DraftPatchResponse:
properties:
entry:
$ref: '#/components/schemas/MAILDraftsEntry'
metadata:
additionalProperties: true
type: object
title: Metadata
type: object
required:
- entry
- metadata
title: DraftPatchResponse
description: 'Corresponds to `PATCH /drafts/{draft_id}`.

Contains the updated message draft in the user-agent''s drafts box.'
DraftPostResponse:
properties:
entry:
Expand Down
3 changes: 2 additions & 1 deletion src/mail/client/docs/reference/cli.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ mail [option]... <command> [argument]...

### Core MAIL Operations

- `compose`: Draft a new MAIL message. Accepts `--tags TAG...` to attach slug tags.
- `compose`: Draft a new MAIL message. The body may be passed inline or read from a file with `-F`/`--body-file PATH` (provide exactly one). 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...`.
Expand All @@ -22,6 +22,7 @@ mail [option]... <command> [argument]...
- `outbox-open`: Open a specific message by ID in your MAIL outbox.
- `drafts`: List your existing MAIL message drafts.
- `drafts-open`: Open a specific existing draft by ID.
- `draft-edit`: Edit fields on an existing draft by ID. Accepts a new body inline or via `-F`/`--body-file PATH`, plus `--subject SUBJECT`, `--reply-to ID`, and `--tags TAG...` (pass `--tags` with no values to clear all tags). Omitted fields are left unchanged.
- `trash`: Open your MAIL trash box.
- `trash-open`: Open a specific message by ID in your MAIL trash box.

Expand Down
8 changes: 8 additions & 0 deletions src/mail/client/docs/tutorials/quickstart.md
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,14 @@ MAIL_TOKEN=... \
uv run mail compose "Hello, world!" "This is a test message body"
```

For a longer body, read it from a file instead of passing it inline with `-F`/`--body-file`:

```bash
MAIL_SERVER=... \
MAIL_TOKEN=... \
uv run mail compose "Hello, world!" --body-file my-message-body.md
```

You should then see the newly-created draft printed to the console.
This includes the draft's unique ID; copy this for use in subsequent operations.

Expand Down
64 changes: 63 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_drafts_patch,
cmd_forward,
cmd_inbox,
cmd_inbox_open,
Expand Down Expand Up @@ -83,6 +84,7 @@
("outbox-open (Oopen, Oo)", "Open an outbox message by ID."),
("drafts (d)", "List message drafts."),
("drafts-open (do)", "Open a draft by ID."),
("draft-edit (de)", "Edit an existing draft by ID."),
("trash (t)", "List trashed messages."),
("trash-open (to)", "Open a trashed message by ID."),
],
Expand Down Expand Up @@ -131,6 +133,23 @@ def _add_tags_arg(parser: argparse.ArgumentParser) -> None:
)


def _add_body_file_arg(parser: argparse.ArgumentParser) -> None:
"""
Register the shared ``-F``/``--body-file`` flag for draft-creating commands
(compose, draft-edit). The flag names a path whose UTF-8 contents become the
message body, as an alternative to passing the body inline.
"""

parser.add_argument(
"-F",
"--body-file",
dest="body_file",
default=None,
metavar="PATH",
help="read the message body from the file at this path",
)


def _add_box_filter_args(box_parser: argparse.ArgumentParser) -> None:
"""
Register the shared query-param flags for the "GET box" commands
Expand Down Expand Up @@ -224,7 +243,13 @@ def build_parser() -> argparse.ArgumentParser:
description=compose_d,
)
compose_p.add_argument("subject", help="the subject line of the message to draft")
compose_p.add_argument("body", help="the body of the message to draft")
compose_p.add_argument(
"body",
nargs="?",
default=None,
help="the body of the message to draft (omit when using --body-file)",
)
_add_body_file_arg(compose_p)
_add_tags_arg(compose_p)
compose_p.set_defaults(func=cmd_compose, cmd="compose")

Expand Down Expand Up @@ -355,6 +380,43 @@ def build_parser() -> argparse.ArgumentParser:
drafts_open_p.add_argument("draft_id", help="the ID of the drafted message to open")
drafts_open_p.set_defaults(func=cmd_drafts_open, cmd="drafts-open")

# command `draft-edit`
draft_edit_d = "edit fields on an existing message draft by ID"
draft_edit_p = subparsers.add_parser(
"draft-edit",
aliases=["de"],
prog="mail draft-edit",
help=draft_edit_d,
description=draft_edit_d,
)
draft_edit_p.add_argument("draft_id", help="the ID of the draft to edit")
draft_edit_p.add_argument(
"body",
nargs="?",
default=None,
help="the new body of the draft (omit to leave it unchanged)",
)
draft_edit_p.add_argument(
"--subject",
default=None,
help="the new subject of the draft (omit to leave it unchanged)",
)
_add_body_file_arg(draft_edit_p)
draft_edit_p.add_argument(
"--reply-to",
dest="reply_to",
default=None,
help="the message ID this draft replies to (omit to leave it unchanged)",
)
draft_edit_p.add_argument(
"--tags",
nargs="*",
default=None,
metavar="TAG",
help="replace the draft's tags (pass with no values to clear all tags)",
)
draft_edit_p.set_defaults(func=cmd_drafts_patch, cmd="draft-edit")

# command `trash`
trash_d = "list your existing trashed messages"
trash_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 .drafts_patch import cmd_drafts_patch
from .forward import cmd_forward
from .inbox import cmd_inbox
from .inbox_open import cmd_inbox_open
Expand Down Expand Up @@ -58,6 +59,7 @@
"cmd_daemon_post",
"cmd_drafts",
"cmd_drafts_open",
"cmd_drafts_patch",
"cmd_forward",
"cmd_inbox",
"cmd_inbox_open",
Expand Down
40 changes: 40 additions & 0 deletions src/mail/client/src/mail_client/commands/_body.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
# SPDX-License-Identifier: Apache-2.0
# Copyright (c) 2026 Addison Kline

from pathlib import Path


def resolve_body(body: str | None, body_file: str | None) -> str:
"""
Resolve a message body from the mutually exclusive ``body`` (inline) and
``body_file`` (path) CLI arguments used by ``compose``.

Exactly one of the two must be supplied. When ``body_file`` is given, its
contents are read as UTF-8 text. Raises ``ValueError`` (surfaced by the
CLI as a command error) when neither or both are provided, or when the
file cannot be read.
"""

if body is not None and body_file is not None:
raise ValueError("provide either a body argument or --body-file, not both")
if body is not None:
return body
if body_file is not None:
try:
return Path(body_file).read_text(encoding="utf-8")
except OSError as e:
raise ValueError(f"could not read body file {body_file!r}: {e}")
raise ValueError("a message body is required: pass it inline or via --body-file")


def resolve_optional_body(body: str | None, body_file: str | None) -> str | None:
"""
Variant of :func:`resolve_body` for partial-update commands (draft-edit)
where the body is optional. Returns ``None`` when neither argument is
supplied (meaning "leave the body unchanged"); otherwise behaves like
:func:`resolve_body`, including the "not both" guard.
"""

if body is None and body_file is None:
return None
return resolve_body(body, body_file)
5 changes: 4 additions & 1 deletion src/mail/client/src/mail_client/commands/compose.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@
from mail_protocol.network.responses import DraftPostResponse
from pydantic import ValidationError

from mail_client.commands._body import resolve_body


def cmd_compose(args: Namespace) -> None:
"""
Expand All @@ -24,9 +26,10 @@ def cmd_compose(args: Namespace) -> None:
raise ValueError("env var MAIL_TOKEN is required")

# 2. hit the server endpoint `POST /drafts`
body = resolve_body(args.body, args.body_file)
payload = DraftPostRequest(
subject=args.subject,
body=args.body,
body=body,
tags=args.tags,
)
response = httpx.post(
Expand Down
86 changes: 86 additions & 0 deletions src/mail/client/src/mail_client/commands/drafts_patch.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
# SPDX-License-Identifier: Apache-2.0
# Copyright (c) 2026 Addison Kline

import os
from argparse import Namespace

import httpx
from mail_protocol.network.requests import DraftPatchRequest
from mail_protocol.network.responses import DraftPatchResponse
from pydantic import ValidationError

from mail_client.commands._body import resolve_optional_body


def cmd_drafts_patch(args: Namespace) -> None:
"""
Update an existing message draft for the current MAIL user.
"""

# 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")

# 2. hit the server endpoint `PATCH /drafts/{draft_id}`
body = resolve_optional_body(args.body, args.body_file)
payload = DraftPatchRequest(
subject=args.subject,
body=body,
reply_to=args.reply_to,
tags=args.tags,
)
response = httpx.patch(
url=f"{MAIL_SERVER}/drafts/{args.draft_id}",
headers={
"Authorization": f"Bearer {MAIL_TOKEN}",
"User-Agent": "Multi-Agent-Interface-Layer-CLI-Client/2.0.0 (github.com/charonlabs/mail)",
"Content-Type": "application/json",
},
json=payload.model_dump(),
)

# 3. parse and validate server response
if response.status_code != 200:
raise RuntimeError(
f"patch draft request to {MAIL_SERVER} failed with status code {response.status_code}"
)

response_json = response.json()
try:
response_obj = DraftPatchResponse.model_validate(response_json)
except ValidationError as e:
raise RuntimeError(f"response validation failed: {e}")

# 4. print the updated draft
match args.output:
case "json":
_print_json(response_obj)
case "text":
_print_text(response_obj)


def _print_json(response_obj: DraftPatchResponse) -> None:
print(response_obj.model_dump_json())


def _print_text(response_obj: DraftPatchResponse) -> None:
entry = response_obj.entry
draft = entry.draft

print("=== Draft ===")
print(f"Draft ID: {draft.draft_id}")
print(f"Created At: {draft.created_at}")
print(f"Updated At: {draft.updated_at}")
print(f"Subject: {draft.subject}")
if draft.reply_to is not None:
print(f"In Reply To: {draft.reply_to}")
if draft.tags:
print(f"Tags: {', '.join(draft.tags)}")
print(f"Body:\n{draft.body}\n")
print("=== Entry Data ===")
print(f"Sent At: {entry.sent_at}")
print(f"Sent By: {entry.sent_by}")
17 changes: 17 additions & 0 deletions src/mail/protocol/src/mail_protocol/network/requests.py
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,23 @@ class DraftPostRequest(BaseModel):
tags: Annotated[list[str], AfterValidator(validate_message_tags)] = []


class DraftPatchRequest(BaseModel):
"""
Corresponds to `PATCH /drafts/{draft_id}`.
Contains the fields to update on an existing MAIL message draft.

Every field is optional: a field left unset (``None``) is not modified,
so callers can patch a single field without resending the rest. The one
asymmetry is ``tags`` — sending ``tags: []`` clears all tags, while
omitting ``tags`` leaves the existing tags untouched.
"""

subject: Annotated[str, AfterValidator(validate_message_subject)] | None = None
body: Annotated[str, AfterValidator(validate_message_body)] | None = None
reply_to: Annotated[str, AfterValidator(validate_uuid)] | None = None
tags: Annotated[list[str], AfterValidator(validate_message_tags)] | None = None


class DraftSendPostRequest(BaseModel):
"""
Corresponds to `POST /drafts/{draft_id}/send`.
Expand Down
10 changes: 10 additions & 0 deletions src/mail/protocol/src/mail_protocol/network/responses.py
Original file line number Diff line number Diff line change
Expand Up @@ -203,6 +203,16 @@ class DraftGetResponse(BaseModel):
metadata: dict[str, Any]


class DraftPatchResponse(BaseModel):
"""
Corresponds to `PATCH /drafts/{draft_id}`.
Contains the updated message draft in the user-agent's drafts box.
"""

entry: MAILDraftsEntry
metadata: dict[str, Any]


class DraftDeleteResponse(BaseModel):
"""
Corresponds to `DELETE /drafts/{draft_id}`.
Expand Down
1 change: 1 addition & 0 deletions src/mail/server/docs/reference/http.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ This document serves as a reference for the MAIL (Mult-Agent Interface Layer) HT
- `GET /drafts/`: Get a list of message drafts in the logged-in user-agent's draft box.
- `POST /drafts/`: Create a new message draft to be stored in the logged-in user-agent's draft box.
- `GET /drafts/{draft_id}`: Get a specific message draft by ID from the logged-in user-agent's draft box.
- `PATCH /drafts/{draft_id}`: Update fields on a specific message draft by ID in the logged-in user-agent's draft box.
- `DELETE /drafts/{draft_id}`: Delete a specific message draft by ID from the logged-in user-agent's draft box.
- `POST /drafts/{draft_id}/send`: Send a message from a draft by ID in the logged-in user-agent's draft box.

Expand Down
Loading
Loading