From c6f23af462e8a0470c50de90567956b4666dbc8e Mon Sep 17 00:00:00 2001 From: Addison Kline <77369109+addisonkline@users.noreply.github.com> Date: Mon, 22 Jun 2026 16:53:01 -0400 Subject: [PATCH] feat: support draft editing and file-sourced draft bodies Add two draft enhancements: 1. `mail compose` can now read a draft body from a file via `-F`/`--body-file PATH` as an alternative to the inline positional body. Exactly one of the two must be supplied. 2. A new `PATCH /drafts/{draft_id}` endpoint lets an authenticated user-agent update an existing draft's subject, body, reply_to, and tags. Only supplied fields change (tags: [] clears, omitted leaves unchanged) and updated_at is refreshed on any edit. Exposed in the CLI as `mail draft-edit` (alias `de`). Regenerates spec/openapi.yaml and updates the HTTP and CLI docs. Co-Authored-By: Claude Opus 4.8 (1M context) --- .gitignore | 3 + spec/openapi.yaml | 28 ++++ src/mail/client/docs/reference/cli.md | 3 +- src/mail/client/docs/tutorials/quickstart.md | 8 + src/mail/client/src/mail_client/cli.py | 64 +++++++- .../src/mail_client/commands/__init__.py | 2 + .../client/src/mail_client/commands/_body.py | 40 +++++ .../src/mail_client/commands/compose.py | 5 +- .../src/mail_client/commands/drafts_patch.py | 86 +++++++++++ .../src/mail_protocol/network/requests.py | 17 +++ .../src/mail_protocol/network/responses.py | 10 ++ src/mail/server/docs/reference/http.md | 1 + .../server/src/mail_server/backends/base.py | 17 +++ .../src/mail_server/backends/memory/api.py | 51 +++++++ .../server/src/mail_server/routers/drafts.py | 27 ++++ src/mail/server/src/mail_server/validators.py | 15 ++ tests/integration/test_mailboxes.py | 141 ++++++++++++++++++ tests/unit/test_client_commands.py | 126 +++++++++++++++- 18 files changed, 639 insertions(+), 5 deletions(-) create mode 100644 src/mail/client/src/mail_client/commands/_body.py create mode 100644 src/mail/client/src/mail_client/commands/drafts_patch.py diff --git a/.gitignore b/.gitignore index c4c4fcf2..241aec9c 100644 --- a/.gitignore +++ b/.gitignore @@ -224,3 +224,6 @@ test-swarm-registry.json # plan documents .plans/ .v2_plans/ + +# draft documents +.drafts/ diff --git a/spec/openapi.yaml b/spec/openapi.yaml index 207becf5..d1a524b9 100644 --- a/spec/openapi.yaml +++ b/spec/openapi.yaml @@ -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: @@ -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: diff --git a/src/mail/client/docs/reference/cli.md b/src/mail/client/docs/reference/cli.md index a2b91349..dcb210ea 100644 --- a/src/mail/client/docs/reference/cli.md +++ b/src/mail/client/docs/reference/cli.md @@ -12,7 +12,7 @@ mail [option]... [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: `. 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...`. @@ -22,6 +22,7 @@ mail [option]... [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. diff --git a/src/mail/client/docs/tutorials/quickstart.md b/src/mail/client/docs/tutorials/quickstart.md index 8eb3d293..ae57bc86 100644 --- a/src/mail/client/docs/tutorials/quickstart.md +++ b/src/mail/client/docs/tutorials/quickstart.md @@ -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. diff --git a/src/mail/client/src/mail_client/cli.py b/src/mail/client/src/mail_client/cli.py index 34d660ea..8e961e5b 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_drafts_patch, cmd_forward, cmd_inbox, cmd_inbox_open, @@ -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."), ], @@ -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 @@ -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") @@ -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( diff --git a/src/mail/client/src/mail_client/commands/__init__.py b/src/mail/client/src/mail_client/commands/__init__.py index 8914fd5a..f5f5a1d1 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 .drafts_patch import cmd_drafts_patch from .forward import cmd_forward from .inbox import cmd_inbox from .inbox_open import cmd_inbox_open @@ -58,6 +59,7 @@ "cmd_daemon_post", "cmd_drafts", "cmd_drafts_open", + "cmd_drafts_patch", "cmd_forward", "cmd_inbox", "cmd_inbox_open", diff --git a/src/mail/client/src/mail_client/commands/_body.py b/src/mail/client/src/mail_client/commands/_body.py new file mode 100644 index 00000000..dc857b1f --- /dev/null +++ b/src/mail/client/src/mail_client/commands/_body.py @@ -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) diff --git a/src/mail/client/src/mail_client/commands/compose.py b/src/mail/client/src/mail_client/commands/compose.py index 6453e0a8..06429dbf 100644 --- a/src/mail/client/src/mail_client/commands/compose.py +++ b/src/mail/client/src/mail_client/commands/compose.py @@ -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: """ @@ -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( diff --git a/src/mail/client/src/mail_client/commands/drafts_patch.py b/src/mail/client/src/mail_client/commands/drafts_patch.py new file mode 100644 index 00000000..3193f7d6 --- /dev/null +++ b/src/mail/client/src/mail_client/commands/drafts_patch.py @@ -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}") diff --git a/src/mail/protocol/src/mail_protocol/network/requests.py b/src/mail/protocol/src/mail_protocol/network/requests.py index 17772646..f30e9455 100644 --- a/src/mail/protocol/src/mail_protocol/network/requests.py +++ b/src/mail/protocol/src/mail_protocol/network/requests.py @@ -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`. diff --git a/src/mail/protocol/src/mail_protocol/network/responses.py b/src/mail/protocol/src/mail_protocol/network/responses.py index 70283887..0489dd15 100644 --- a/src/mail/protocol/src/mail_protocol/network/responses.py +++ b/src/mail/protocol/src/mail_protocol/network/responses.py @@ -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}`. diff --git a/src/mail/server/docs/reference/http.md b/src/mail/server/docs/reference/http.md index 2c9d5d10..17c82627 100644 --- a/src/mail/server/docs/reference/http.md +++ b/src/mail/server/docs/reference/http.md @@ -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. diff --git a/src/mail/server/src/mail_server/backends/base.py b/src/mail/server/src/mail_server/backends/base.py index a26d0720..371193a0 100644 --- a/src/mail/server/src/mail_server/backends/base.py +++ b/src/mail/server/src/mail_server/backends/base.py @@ -41,6 +41,7 @@ BoxFilterParams, DaemonDeliverLocalRequest, DaemonDeliverRemoteRequest, + DraftPatchRequest, DraftPostRequest, DraftSendPostRequest, ) @@ -234,6 +235,22 @@ async def get_draft( pass + @abstractmethod + async def patch_draft( + self, + user_agent: MAILUserAgent, + draft_id: str, + payload: DraftPatchRequest, + ) -> MAILDraftsEntry: + """ + Update mutable fields on an existing message draft for this user-agent. + + Only the fields supplied on ``payload`` are modified; the rest are + left untouched. ``updated_at`` is refreshed on any successful edit. + """ + + pass + @abstractmethod async def delete_draft( self, user_agent: MAILUserAgent, draft_id: str diff --git a/src/mail/server/src/mail_server/backends/memory/api.py b/src/mail/server/src/mail_server/backends/memory/api.py index 92a548e4..600378be 100644 --- a/src/mail/server/src/mail_server/backends/memory/api.py +++ b/src/mail/server/src/mail_server/backends/memory/api.py @@ -40,6 +40,7 @@ BoxFilterParams, DaemonDeliverLocalRequest, DaemonDeliverRemoteRequest, + DraftPatchRequest, DraftPostRequest, DraftSendPostRequest, ) @@ -663,6 +664,56 @@ async def get_draft( return draft_entry + async def patch_draft( + self, + user_agent: MAILUserAgent, + draft_id: str, + payload: DraftPatchRequest, + ) -> MAILDraftsEntry: + """ + Update mutable fields on an existing message draft for this user-agent. + + Only the fields supplied on ``payload`` are modified; ``updated_at`` is + refreshed whenever a successful edit is applied. + """ + + ua_address = user_agent.get_address() + draft_ids = self.drafts.get(ua_address) + if draft_ids is None: + raise ValueError(f"no drafts box found for address {ua_address}") + if draft_id not in draft_ids: + raise ValueError( + f"draft with ID {draft_id} not found in draft box at address {ua_address}" + ) + + draft_entry = self.draft_entries.get(draft_id) + if draft_entry is None: + raise ValueError(f"draft with ID {draft_id} not found in draft box entries") + + # Only the fields explicitly supplied on the request are modified. A + # field left unset (``None``) is not part of the update — except + # ``tags``, where an empty list is a deliberate "clear all tags". + updated_fields: dict[str, Any] = {} + if payload.subject is not None: + updated_fields["subject"] = payload.subject + if payload.body is not None: + updated_fields["body"] = payload.body + if payload.reply_to is not None: + updated_fields["reply_to"] = payload.reply_to + if payload.tags is not None: + updated_fields["tags"] = payload.tags + + if not updated_fields: + return draft_entry + + updated_draft = draft_entry.draft.model_copy( + update={**updated_fields, "updated_at": datetime.now(UTC)} + ) + updated_entry = draft_entry.model_copy(update={"draft": updated_draft}) + self.draft_entries[draft_id] = updated_entry + + return updated_entry + async def delete_draft( self, user_agent: MAILUserAgent, draft_id: str ) -> MAILDraftsEntry: diff --git a/src/mail/server/src/mail_server/routers/drafts.py b/src/mail/server/src/mail_server/routers/drafts.py index ab6ba310..a3e7eb79 100644 --- a/src/mail/server/src/mail_server/routers/drafts.py +++ b/src/mail/server/src/mail_server/routers/drafts.py @@ -5,6 +5,7 @@ from mail_protocol.network.responses import ( DraftDeleteResponse, DraftGetResponse, + DraftPatchResponse, DraftPostResponse, DraftSendPostResponse, DraftsGetResponse, @@ -14,6 +15,7 @@ from mail_server.utils import build_box_metadata from mail_server.validators import ( validate_box_filter_params, + validate_patch_draft_request, validate_post_draft_request, validate_post_draft_send_request, ) @@ -81,6 +83,31 @@ async def get_draft(request: Request) -> DraftGetResponse: ) +@router.patch( + "/{draft_id}", + summary="Update a specific message draft by ID", + response_model=DraftPatchResponse, +) +async def patch_draft(request: Request) -> DraftPatchResponse: + backend = request.app.state.backend + user_agent = await validate_user_agent(backend=backend, request=request) + payload = await validate_patch_draft_request(request) + draft_id = request.path_params.get("draft_id") + try: + result = await backend.patch_draft( + user_agent=user_agent, draft_id=draft_id, payload=payload + ) + except ValueError: + raise HTTPException( + status_code=404, detail=f"draft with ID {draft_id} not found" + ) + + return DraftPatchResponse( + entry=result, + metadata={}, + ) + + @router.delete( "/{draft_id}", summary="Delete a specific message draft by ID", diff --git a/src/mail/server/src/mail_server/validators.py b/src/mail/server/src/mail_server/validators.py index a60e88da..4ce6f360 100644 --- a/src/mail/server/src/mail_server/validators.py +++ b/src/mail/server/src/mail_server/validators.py @@ -14,6 +14,7 @@ AuthPasswordResetRequest, BoxFilterParams, DaemonDeliverLocalRequest, + DraftPatchRequest, DraftPostRequest, DraftSendPostRequest, ListMemberPostRequest, @@ -62,6 +63,20 @@ async def validate_post_draft_request(request: Request) -> DraftPostRequest: ) +async def validate_patch_draft_request(request: Request) -> DraftPatchRequest: + """ + Ensure the request payload is valid for `PATCH /drafts/{draft_id}`. + """ + + try: + body = await request.json() + return DraftPatchRequest.model_validate(body) + except ValueError as e: + raise HTTPException( + status_code=422, detail=f"request body validation failed: {e}" + ) + + async def validate_post_draft_send_request(request: Request) -> DraftSendPostRequest: """ Ensure the request payload is valid for `POST /drafts/{draft_id}/send`. diff --git a/tests/integration/test_mailboxes.py b/tests/integration/test_mailboxes.py index d18ffa1f..24964a0e 100644 --- a/tests/integration/test_mailboxes.py +++ b/tests/integration/test_mailboxes.py @@ -269,6 +269,147 @@ def test_send_unknown_draft_returns_404(app_client: TestClient, headers_for) -> assert response.status_code == 404 +def test_patch_draft_updates_fields(app_client: TestClient, headers_for) -> None: + response = app_client.post( + "/drafts", + json={"subject": "Original", "body": "Original body", "tags": ["a"]}, + headers=headers_for(USER), + ) + draft = response.json()["entry"]["draft"] + draft_id = draft["draft_id"] + assert draft["updated_at"] is None + + response = app_client.patch( + f"/drafts/{draft_id}", + json={"subject": "Updated", "body": "Updated body", "tags": ["b", "c"]}, + headers=headers_for(USER), + ) + assert response.status_code == 200 + updated = response.json()["entry"]["draft"] + assert updated["subject"] == "Updated" + assert updated["body"] == "Updated body" + assert updated["tags"] == ["b", "c"] + assert updated["updated_at"] is not None + # the change is persisted, not just echoed back + response = app_client.get(f"/drafts/{draft_id}", headers=headers_for(USER)) + assert response.json()["entry"]["draft"]["subject"] == "Updated" + + +def test_patch_draft_partial_leaves_other_fields( + app_client: TestClient, headers_for +) -> None: + response = app_client.post( + "/drafts", + json={"subject": "Keep subject", "body": "Old body", "tags": ["x"]}, + headers=headers_for(USER), + ) + draft_id = response.json()["entry"]["draft"]["draft_id"] + + response = app_client.patch( + f"/drafts/{draft_id}", + json={"body": "New body"}, + headers=headers_for(USER), + ) + assert response.status_code == 200 + updated = response.json()["entry"]["draft"] + assert updated["body"] == "New body" + assert updated["subject"] == "Keep subject" + assert updated["tags"] == ["x"] + + +def test_patch_draft_empty_tags_clears_them( + app_client: TestClient, headers_for +) -> None: + response = app_client.post( + "/drafts", + json={"subject": "Subject", "body": "Body", "tags": ["x", "y"]}, + headers=headers_for(USER), + ) + draft_id = response.json()["entry"]["draft"]["draft_id"] + + response = app_client.patch( + f"/drafts/{draft_id}", + json={"tags": []}, + headers=headers_for(USER), + ) + assert response.status_code == 200 + assert response.json()["entry"]["draft"]["tags"] == [] + + +def test_patch_draft_rejects_overlong_subject( + app_client: TestClient, headers_for +) -> None: + response = app_client.post( + "/drafts", + json={"subject": "Subject", "body": "Body"}, + headers=headers_for(USER), + ) + draft_id = response.json()["entry"]["draft"]["draft_id"] + + response = app_client.patch( + f"/drafts/{draft_id}", + json={"subject": "x" * (MESSAGE_SUBJECT_LEN_MAX + 1)}, + headers=headers_for(USER), + ) + assert response.status_code == 422 + + +def test_patch_draft_unknown_id_returns_404( + app_client: TestClient, headers_for +) -> None: + response = app_client.patch( + "/drafts/11111111-1111-4111-8111-111111111111", + json={"subject": "Updated"}, + headers=headers_for(USER), + ) + assert response.status_code == 404 + + +def test_patch_draft_isolated_between_users( + app_client: TestClient, headers_for +) -> None: + response = app_client.post( + "/drafts", + json={"subject": "Private", "body": "Draft"}, + headers=headers_for(USER), + ) + draft_id = response.json()["entry"]["draft"]["draft_id"] + + response = app_client.patch( + f"/drafts/{draft_id}", + json={"subject": "Hijacked"}, + headers=headers_for(OTHER_USER), + ) + assert response.status_code == 404 + + +def test_patch_draft_then_send_uses_new_content( + app_client: TestClient, headers_for +) -> None: + response = app_client.post( + "/drafts", + json={"subject": "Original", "body": "Original body"}, + headers=headers_for(USER), + ) + draft_id = response.json()["entry"]["draft"]["draft_id"] + + app_client.patch( + f"/drafts/{draft_id}", + json={"subject": "Edited", "body": "Edited body"}, + headers=headers_for(USER), + ) + + response = app_client.post( + f"/drafts/{draft_id}/send", + json={"recipients": [OTHER_USER]}, + headers=headers_for(USER), + ) + assert response.status_code == 200 + message = response.json()["message"] + assert message["subject"] == "Edited" + assert message["body"] == "Edited body" + + # ─── Trash ───────────────────────────────────────────────────────── diff --git a/tests/unit/test_client_commands.py b/tests/unit/test_client_commands.py index 3619df62..bb5f3093 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_drafts_patch, cmd_forward, cmd_inbox, cmd_login, @@ -166,7 +167,11 @@ def test_compose_posts_draft_payload(client_env, capsys: pytest.CaptureFixture) }, ) ) - cmd_compose(Namespace(output="text", subject="A subject", body="A body.", tags=[])) + cmd_compose( + Namespace( + output="text", subject="A subject", body="A body.", body_file=None, tags=[] + ) + ) request = route.calls[0].request assert request.headers["Authorization"] == f"Bearer {TOKEN}" @@ -179,12 +184,129 @@ def test_compose_posts_draft_payload(client_env, capsys: pytest.CaptureFixture) assert "Draft ID: 55555555-5555-4555-8555-555555555555" in capsys.readouterr().out +@respx.mock +def test_compose_reads_body_from_file( + client_env, tmp_path, capsys: pytest.CaptureFixture +) -> None: + body_path = tmp_path / "message.md" + body_path.write_text("Body from a file.", encoding="utf-8") + route = respx.post(f"{SERVER}/drafts").mock( + return_value=httpx.Response( + 200, + json={ + "entry": { + "draft": { + "draft_id": "55555555-5555-4555-8555-555555555555", + "subject": "A subject", + "body": "Body from a file.", + "created_at": "2026-06-12T09:00:00+00:00", + "updated_at": None, + }, + "sent_at": None, + "sent_by": None, + }, + "metadata": {}, + }, + ) + ) + cmd_compose( + Namespace( + output="text", + subject="A subject", + body=None, + body_file=str(body_path), + tags=[], + ) + ) + + assert json.loads(route.calls[0].request.content)["body"] == "Body from a file." + + +def test_compose_rejects_both_body_and_body_file(client_env, tmp_path) -> None: + body_path = tmp_path / "message.md" + body_path.write_text("From file.", encoding="utf-8") + with pytest.raises(Exception): # noqa: B017 — ValueError from resolve_body + cmd_compose( + Namespace( + output="text", + subject="A subject", + body="Inline.", + body_file=str(body_path), + tags=[], + ) + ) + + +def test_compose_rejects_missing_body(client_env) -> None: + with pytest.raises(Exception): # noqa: B017 — ValueError from resolve_body + cmd_compose( + Namespace( + output="text", subject="A subject", body=None, body_file=None, tags=[] + ) + ) + + def test_compose_rejects_invalid_subject_before_any_request(client_env) -> None: """A malformed subject fails DraftPostRequest validation locally — no request reaches the server (SPEC.md §8.1).""" with pytest.raises(Exception): # noqa: B017 — pydantic ValidationError - cmd_compose(Namespace(output="text", subject="", body="A body.", tags=[])) + cmd_compose( + Namespace( + output="text", subject="", body="A body.", body_file=None, tags=[] + ) + ) + + +# ─── draft-edit ──────────────────────────────────────────────────── + + +@respx.mock +def test_draft_edit_patches_supplied_fields( + client_env, capsys: pytest.CaptureFixture +) -> None: + draft_id = "55555555-5555-4555-8555-555555555555" + route = respx.patch(f"{SERVER}/drafts/{draft_id}").mock( + return_value=httpx.Response( + 200, + json={ + "entry": { + "draft": { + "draft_id": draft_id, + "subject": "New subject", + "body": "Old body.", + "created_at": "2026-06-12T09:00:00+00:00", + "updated_at": "2026-06-12T10:00:00+00:00", + "tags": ["x"], + }, + "sent_at": None, + "sent_by": None, + }, + "metadata": {}, + }, + ) + ) + cmd_drafts_patch( + Namespace( + output="text", + draft_id=draft_id, + subject="New subject", + body=None, + body_file=None, + reply_to=None, + tags=None, + ) + ) + + request = route.calls[0].request + assert request.method == "PATCH" + assert json.loads(request.content) == { + "subject": "New subject", + "body": None, + "reply_to": None, + "tags": None, + } + assert "Subject: New subject" in capsys.readouterr().out # ─── send ──────────────────────────────────────────────────────────