From c17059ccb14cf0d3c5d346e0f3a3e2c2c05db9af Mon Sep 17 00:00:00 2001 From: Addison Kline <77369109+addisonkline@users.noreply.github.com> Date: Wed, 17 Jun 2026 18:22:36 -0400 Subject: [PATCH] feat: support message replies and tags MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add MAIL 2.0 message-reply and tag support across the protocol, server, and client, plus a migration path for existing deployments. Protocol: - MAILMessage gains required `mail_version` ("2.0") and `tags`, plus an optional `reply_to` referencing the replied-to message's id. - Thread `reply_to`/`tags` through DraftPostRequest, MAILDraft, and `tags` through DraftSendPostRequest (all default-safe so pre-2.0 drafts stay loadable). - Surface `reply_to` (msg_-prefixed) and `tags` in MAILMessageInWebhook. Server: - post_draft stores reply_to/tags on the draft; send_draft stamps mail_version, copies reply_to, and merges draft + send-time tags as an order-preserving union. - Webhook delivery payloads now carry reply_to/tags. Client: - New `reply` command (alias `r`): replies to the original sender, defaults the subject to `Re: `, sets reply_to. - `--tags` on compose, send, and reply; reply_to/tags shown in message output and listed in `mail --help`. Migration: - scripts/migrate_messages_v2.py backfills mail_version/tags on persisted message records (dry-run, backup, deployment overrides; idempotent). Tests/docs: - Update all MAILMessage construction sites; add coverage for tag validators, the reply command, draft/tag-merge behavior, webhook payload fields, and the migration script. - Document the new fields in SPEC.md (ยง7.8-7.10), regenerate spec/openapi.yaml, and update the client CLI reference. Co-Authored-By: Claude Opus 4.8 (1M context) --- scripts/migrate_messages_v2.py | 223 ++++++++++++++++++ spec/SPEC.md | 16 ++ spec/openapi.yaml | 36 ++- src/mail/client/docs/reference/cli.md | 22 +- src/mail/client/src/mail_client/cli.py | 40 ++++ .../src/mail_client/commands/__init__.py | 2 + .../src/mail_client/commands/compose.py | 5 + .../src/mail_client/commands/inbox_open.py | 4 + .../src/mail_client/commands/outbox_open.py | 4 + .../client/src/mail_client/commands/reply.py | 133 +++++++++++ .../client/src/mail_client/commands/send.py | 5 + .../src/mail_protocol/core/constants.py | 3 + .../protocol/src/mail_protocol/core/drafts.py | 7 + .../src/mail_protocol/core/messages.py | 6 +- .../src/mail_protocol/core/validators.py | 37 ++- .../src/mail_protocol/core/webhooks.py | 3 + .../src/mail_protocol/network/requests.py | 13 + .../server/src/mail_server/backends/base.py | 5 + .../src/mail_server/backends/memory/api.py | 11 + tests/contract/test_spec_delivery.py | 2 + tests/contract/test_spec_messages.py | 60 +++++ tests/integration/test_mailboxes.py | 4 + tests/integration/webhooks/test_delivery.py | 40 +++- tests/unit/test_cli_help.py | 1 + tests/unit/test_client_commands.py | 189 ++++++++++++++- tests/unit/test_draft_reply_tags.py | 96 ++++++++ tests/unit/test_mail_lists_send.py | 15 +- tests/unit/test_mail_trash_store.py | 2 + tests/unit/test_memory_fs_roundtrip.py | 2 + tests/unit/test_migrate_messages_v2.py | 102 ++++++++ tests/unit/test_protocol_models.py | 2 + tests/unit/test_protocol_validators.py | 31 +++ 32 files changed, 1103 insertions(+), 18 deletions(-) create mode 100644 scripts/migrate_messages_v2.py create mode 100644 src/mail/client/src/mail_client/commands/reply.py create mode 100644 tests/unit/test_draft_reply_tags.py create mode 100644 tests/unit/test_migrate_messages_v2.py diff --git a/scripts/migrate_messages_v2.py b/scripts/migrate_messages_v2.py new file mode 100644 index 00000000..1ae37788 --- /dev/null +++ b/scripts/migrate_messages_v2.py @@ -0,0 +1,223 @@ +# SPDX-License-Identifier: Apache-2.0 +# Copyright (c) 2026 Addison Kline + +""" +Migrate persisted ``MAILMessage`` records to the MAIL 2.0 schema. + +MAIL 2.0 adds two required fields to ``MAILMessage``: + +* ``mail_version`` (``"2.0"``) +* ``tags`` (a list of slug strings; empty by default) + +Messages persisted before these fields existed will fail +``MAILMessage.model_validate_json()`` on server startup and be silently +dropped by the memory backend's loader. This script walks a deployment's +``messages/`` directory and backfills the two new fields on any record that +is missing them, so an upgrade does not lose existing messages. + +The optional ``reply_to`` field has a default of ``None`` and therefore needs +no migration. + +Usage:: + + # preview changes for the default deployment + uv run python scripts/migrate_messages_v2.py --dry-run + + # migrate the default deployment in place (a backup is taken first) + uv run python scripts/migrate_messages_v2.py + + # migrate a named deployment, or an explicit messages directory + uv run python scripts/migrate_messages_v2.py --deployment my-deployment + uv run python scripts/migrate_messages_v2.py --messages-dir /path/to/messages +""" + +from __future__ import annotations + +import argparse +import json +import os +import shutil +import sys +import tempfile +from dataclasses import dataclass, field +from pathlib import Path + +MAIL_VERSION = "2.0" + +DEFAULT_DEPLOYMENTS_ROOT = Path.home().joinpath(".mail-swarms", "deployments") + + +@dataclass +class MigrationResult: + """Summary of a migration run.""" + + scanned: int = 0 + migrated: int = 0 + already_current: int = 0 + skipped: list[str] = field(default_factory=list) + + +def _atomic_write_text(path: Path, content: str) -> None: + """ + Atomically replace ``path`` with ``content`` (temp file + ``os.replace``). + Mirrors the memory backend's persistence write so the on-disk format and + durability guarantees match. + """ + + path.parent.mkdir(parents=True, exist_ok=True) + fd, tmp_name = tempfile.mkstemp( + prefix=f".{path.name}.", suffix=".tmp", dir=path.parent, text=True + ) + tmp_path = Path(tmp_name) + try: + with os.fdopen(fd, "w", encoding="utf-8") as tmp_file: + tmp_file.write(content) + tmp_file.flush() + os.fsync(tmp_file.fileno()) + os.replace(tmp_path, path) + except Exception: + tmp_path.unlink(missing_ok=True) + raise + + +def _needs_migration(record: dict) -> bool: + return "mail_version" not in record or "tags" not in record + + +def _upgrade_record(record: dict) -> dict: + """ + Return ``record`` with the MAIL 2.0 fields backfilled. Existing values are + left untouched; only missing fields are added. + """ + + if "mail_version" not in record: + record["mail_version"] = MAIL_VERSION + if "tags" not in record: + record["tags"] = [] + return record + + +def migrate_messages(messages_dir: Path, *, dry_run: bool = False) -> MigrationResult: + """ + Backfill MAIL 2.0 fields on every message file in ``messages_dir``. + + Files are read as raw JSON (the model is intentionally bypassed so that + pre-2.0 records can be loaded at all). Records missing ``mail_version`` or + ``tags`` are rewritten in place; records that already have both are left + untouched. Files that are not valid JSON objects are reported as skipped. + """ + + result = MigrationResult() + if not messages_dir.is_dir(): + raise FileNotFoundError(f"messages directory not found: {messages_dir}") + + for entry in sorted(messages_dir.iterdir()): + if not entry.is_file(): + continue + result.scanned += 1 + + try: + record = json.loads(entry.read_text(encoding="utf-8")) + except (json.JSONDecodeError, OSError) as e: + result.skipped.append(f"{entry.name}: unreadable ({e})") + continue + + if not isinstance(record, dict): + result.skipped.append(f"{entry.name}: not a JSON object") + continue + + if not _needs_migration(record): + result.already_current += 1 + continue + + result.migrated += 1 + if dry_run: + continue + + upgraded = _upgrade_record(record) + _atomic_write_text(entry, json.dumps(upgraded)) + + return result + + +def _resolve_messages_dir(args: argparse.Namespace) -> Path: + if args.messages_dir is not None: + return Path(args.messages_dir) + return Path(args.root).joinpath(args.deployment, "messages") + + +def _backup_dir(messages_dir: Path) -> Path: + backup = messages_dir.with_name(f"{messages_dir.name}.backup") + if backup.exists(): + raise FileExistsError( + f"backup already exists: {backup} (remove or rename it first)" + ) + shutil.copytree(messages_dir, backup) + return backup + + +def build_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser( + description="Migrate persisted MAILMessage records to the MAIL 2.0 schema." + ) + parser.add_argument( + "--deployment", + default="default", + help="deployment name under the deployments root (default: %(default)s)", + ) + parser.add_argument( + "--root", + default=str(DEFAULT_DEPLOYMENTS_ROOT), + help="deployments root directory (default: %(default)s)", + ) + parser.add_argument( + "--messages-dir", + default=None, + help="explicit path to a messages/ directory (overrides --deployment/--root)", + ) + parser.add_argument( + "--dry-run", + action="store_true", + help="report what would change without modifying any files", + ) + parser.add_argument( + "--no-backup", + action="store_true", + help="skip copying the messages directory to .backup before migrating", + ) + return parser + + +def main() -> None: + args = build_parser().parse_args() + messages_dir = _resolve_messages_dir(args) + + print(f"=== MAIL 2.0 message migration: {messages_dir} ===") + if not messages_dir.is_dir(): + print(f"โŒ messages directory not found: {messages_dir}") + sys.exit(1) + + if args.dry_run: + result = migrate_messages(messages_dir, dry_run=True) + print("๐Ÿ” DRY RUN โ€” no files were modified") + else: + # Probe first so we only take a backup when there is work to do. + preview = migrate_messages(messages_dir, dry_run=True) + if preview.migrated and not args.no_backup: + backup = _backup_dir(messages_dir) + print(f"โœ… backed up {preview.scanned} files to {backup}") + result = migrate_messages(messages_dir, dry_run=False) + + print(f"scanned: {result.scanned}") + print(f"migrated: {result.migrated}") + print(f"already current: {result.already_current}") + if result.skipped: + print(f"skipped: {len(result.skipped)}") + for note in result.skipped: + print(f" - {note}") + + print("๐ŸŽ‰ migration complete") + + +if __name__ == "__main__": + main() diff --git a/spec/SPEC.md b/spec/SPEC.md index 6d98284a..6b1d319e 100644 --- a/spec/SPEC.md +++ b/spec/SPEC.md @@ -44,6 +44,9 @@ * [7.5 Message Bodies](#75-message-bodies) * [7.6 Timestamps](#76-timestamps) * [7.7 Message Metadata](#77-message-metadata) + * [7.8 Protocol Version](#78-protocol-version) + * [7.9 Replies](#79-replies) + * [7.10 Message Tags](#710-message-tags) * [8 Delivery](#8-delivery) * [8.1 Pre-Send Errors](#81-pre-send-errors) * [8.2 Post-Send Errors](#82-post-send-errors) @@ -217,6 +220,18 @@ Every MAIL message MUST contain the timestamp string of the time it was sent by Every MAIL message MUST contain a field for implementer-defined message metadata, defined by `metadata`. This value MAY be an empty object (`{}`). Implementer-defined message data MUST be stored in the `metadata` field, rather than in the top level of the `MAILMessage` object itself. +### 7.8. Protocol Version + +Every MAIL message MUST declare the version of the MAIL protocol it conforms to, keyed by `mail_version`. This value MUST be a protocol version string per [Section 10](#10-versioning). For this revision of the protocol, the value MUST be `"2.0"`. + +### 7.9. Replies + +A MAIL message MAY indicate that it is a reply to an earlier message, keyed by `reply_to`. When present, this value MUST be the `message_id` (a [UUID][rfc-9562] per [Section 7.1](#71-message-ids)) of the message being replied to. When the field is absent or `null`, the message is not a reply. Implementers SHOULD reject a `reply_to` value that is not a well-formed message ID; they are NOT required to verify that the referenced message exists. + +### 7.10. Message Tags + +Every MAIL message MUST contain a field for sender-defined tags, keyed by `tags`. This value is an array of strings and MAY be empty (`[]`). Each tag MUST be a slug string: lowercase alphanumeric characters separated by single hyphens (matching `^[a-z0-9]+(?:-[a-z0-9]+)*$`). The reference implementation enforces a per-tag length of 1โ€“32 characters. Tags are advisory metadata used to categorize messages; their interpretation is implementer-defined. + ## 8. Delivery When a user-agent creates and sends a MAIL message, the new message is stored on the MAIL server, but is not yet delivered to the specified recipient(s). @@ -226,6 +241,7 @@ Said message MUST be delivered to its intended recipient(s) by an authorized MAI If an authorized user-agent attempts to create a message with a malformed subject (per [Section 7.4](#74-message-subjects)), the desired message MUST NOT be created and the user-agent MUST be notified. If an authorized user-agent attempts to create a message with a malformed body (per [Section 7.5](#75-message-bodies)), the desired message MUST NOT be created and the user-agent MUST be notified. +If an authorized user-agent attempts to create a message with one or more malformed tags (per [Section 7.10](#710-message-tags)), the desired message MUST NOT be created and the user-agent MUST be notified. If an authorized user-agent's message contains one or more malformed MAIL addresses (per [Section 6](#6-addresses)), the message MUST NOT be delivered and the sending user-agent MUST be notified. ### 8.2. Post-Send Errors diff --git a/spec/openapi.yaml b/spec/openapi.yaml index 3817096c..207becf5 100644 --- a/spec/openapi.yaml +++ b/spec/openapi.yaml @@ -1573,6 +1573,17 @@ components: format: date-time - type: 'null' title: Updated At + reply_to: + anyOf: + - type: string + - type: 'null' + title: Reply To + tags: + items: + type: string + type: array + title: Tags + default: [] type: object required: - draft_id @@ -1582,7 +1593,14 @@ components: title: MAILDraft description: 'A draft of an individual MAIL message. - Does not yet account for intended recipients.' + Does not yet account for intended recipients. + + + `reply_to` and `tags` carry forward onto the `MAILMessage` produced when + + the draft is sent. Both default to "no value" so drafts persisted before + + these fields existed remain loadable.' MAILDraftsEntry: properties: draft: @@ -1777,9 +1795,18 @@ components: protocol layer but are rejected at the endpoint layer.' MAILMessage: properties: + mail_version: + type: string + const: '2.0' + title: Mail Version message_id: type: string title: Message Id + reply_to: + anyOf: + - type: string + - type: 'null' + title: Reply To sender: type: string title: Sender @@ -1794,6 +1821,11 @@ components: body: type: string title: Body + tags: + items: + type: string + type: array + title: Tags sent_at: type: string format: date-time @@ -1804,11 +1836,13 @@ components: title: Metadata type: object required: + - mail_version - message_id - sender - recipients - subject - body + - tags - sent_at - metadata title: MAILMessage diff --git a/src/mail/client/docs/reference/cli.md b/src/mail/client/docs/reference/cli.md index 5aca9f08..b5822346 100644 --- a/src/mail/client/docs/reference/cli.md +++ b/src/mail/client/docs/reference/cli.md @@ -12,8 +12,9 @@ mail [option]... [argument]... ### Core MAIL Operations -- `compose`: Draft a new MAIL message. -- `send`: Send an existing draft by ID to the specified recipient(s). +- `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...`. - `inbox`: Open your MAIL inbox. - `inbox-open`: Open a specific message by ID in your MAIL inbox. - `outbox`: Open your MAIL outbox. @@ -46,3 +47,20 @@ mail [option]... [argument]... - `-o`/`--output`: Choose the style of console output for this command. - **Default**: `text` - **Choices**: `text`, `json` + +## Examples + +Draft and send a message with tags: + +```bash +mail compose "Status update" "All systems nominal." --tags weekly status +mail send sage@chorus@localhost --tags urgent +``` + +Reply to a message in your inbox (replies to the original sender, subject +defaults to `Re: `): + +```bash +mail reply "Thanks, acknowledged." +mail reply "See attached." --subject "Follow-up" --tags project-x +``` diff --git a/src/mail/client/src/mail_client/cli.py b/src/mail/client/src/mail_client/cli.py index e940c5d9..8dd69f12 100644 --- a/src/mail/client/src/mail_client/cli.py +++ b/src/mail/client/src/mail_client/cli.py @@ -22,6 +22,7 @@ cmd_outbox, cmd_outbox_open, cmd_ping, + cmd_reply, cmd_send, cmd_swarm_get, cmd_swarm_list, @@ -73,6 +74,7 @@ [ ("compose (c)", "Draft a new MAIL message."), ("send (s)", "Send a drafted message."), + ("reply (r)", "Reply to an inbox message by ID."), ("inbox (i)", "List your inbox messages."), ("inbox-open (open, o)", "Open an inbox message by ID."), ("outbox (O)", "List your sent messages."), @@ -106,9 +108,26 @@ 'mail compose "Status update" "The migration is complete."', "mail send user@example", "mail inbox-open ", + 'mail reply "Thanks, acknowledged."', ] +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 + message; the default is an empty list (no tags). + """ + + parser.add_argument( + "--tags", + nargs="*", + default=[], + metavar="TAG", + help="slug string tag(s) to attach to the message", + ) + + def _add_box_filter_args(box_parser: argparse.ArgumentParser) -> None: """ Register the shared query-param flags for the "GET box" commands @@ -203,6 +222,7 @@ def build_parser() -> argparse.ArgumentParser: ) 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") + _add_tags_arg(compose_p) compose_p.set_defaults(func=cmd_compose, cmd="compose") # command `send` @@ -218,8 +238,28 @@ def build_parser() -> argparse.ArgumentParser: send_p.add_argument( "to", nargs="+", help="the address(es) to deliver this message to" ) + _add_tags_arg(send_p) send_p.set_defaults(func=cmd_send, cmd="send") + # command `reply` + reply_d = "reply to an existing inbox message" + reply_p = subparsers.add_parser( + "reply", + aliases=["r"], + prog="mail reply", + help=reply_d, + description=reply_d, + ) + reply_p.add_argument("message_id", help="the ID of the inbox message to reply to") + reply_p.add_argument("body", help="the body of the reply") + reply_p.add_argument( + "--subject", + default=None, + help="the subject of the reply (default: 'Re: ')", + ) + _add_tags_arg(reply_p) + reply_p.set_defaults(func=cmd_reply, cmd="reply") + # 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 ce04b882..e406d9f9 100644 --- a/src/mail/client/src/mail_client/commands/__init__.py +++ b/src/mail/client/src/mail_client/commands/__init__.py @@ -26,6 +26,7 @@ from .outbox import cmd_outbox from .outbox_open import cmd_outbox_open from .ping import cmd_ping +from .reply import cmd_reply from .send import cmd_send from .swarm_delete import cmd_swarm_delete from .swarm_get import cmd_swarm_get @@ -73,6 +74,7 @@ "cmd_outbox", "cmd_outbox_open", "cmd_ping", + "cmd_reply", "cmd_send", "cmd_swarm_delete", "cmd_swarm_get", diff --git a/src/mail/client/src/mail_client/commands/compose.py b/src/mail/client/src/mail_client/commands/compose.py index cb77d96a..6453e0a8 100644 --- a/src/mail/client/src/mail_client/commands/compose.py +++ b/src/mail/client/src/mail_client/commands/compose.py @@ -27,6 +27,7 @@ def cmd_compose(args: Namespace) -> None: payload = DraftPostRequest( subject=args.subject, body=args.body, + tags=args.tags, ) response = httpx.post( url=f"{MAIL_SERVER}/drafts", @@ -70,6 +71,10 @@ def _print_text(response_obj: DraftPostResponse) -> None: print(f"Draft ID: {draft.draft_id}") print(f"Created At: {draft.created_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}") diff --git a/src/mail/client/src/mail_client/commands/inbox_open.py b/src/mail/client/src/mail_client/commands/inbox_open.py index 8fd7992b..ae578692 100644 --- a/src/mail/client/src/mail_client/commands/inbox_open.py +++ b/src/mail/client/src/mail_client/commands/inbox_open.py @@ -67,6 +67,10 @@ def _print_text(response_obj: InboxMessageGetResponse) -> None: for recipient in message.recipients: print(f"- {recipient}") print(f"Subject: {message.subject}") + if message.reply_to is not None: + print(f"In Reply To: {message.reply_to}") + if message.tags: + print(f"Tags: {', '.join(message.tags)}") print(f"Body:\n{message.body}\n") print("=== Inbox Entry Data ===") print(f"Received At: {entry.received_at}") diff --git a/src/mail/client/src/mail_client/commands/outbox_open.py b/src/mail/client/src/mail_client/commands/outbox_open.py index 853d01c0..2e52432e 100644 --- a/src/mail/client/src/mail_client/commands/outbox_open.py +++ b/src/mail/client/src/mail_client/commands/outbox_open.py @@ -67,6 +67,10 @@ def _print_text(response_obj: OutboxMessageGetResponse) -> None: for recipient in message.recipients: print(f"- {recipient}") print(f"Subject: {message.subject}") + if message.reply_to is not None: + print(f"In Reply To: {message.reply_to}") + if message.tags: + print(f"Tags: {', '.join(message.tags)}") print(f"Body:\n{message.body}\n") print("=== Outbox Entry Data ===") print(f"Delivered At: {entry.delivered_at}") diff --git a/src/mail/client/src/mail_client/commands/reply.py b/src/mail/client/src/mail_client/commands/reply.py new file mode 100644 index 00000000..c92545e4 --- /dev/null +++ b/src/mail/client/src/mail_client/commands/reply.py @@ -0,0 +1,133 @@ +# 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 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 _reply_subject(original_subject: str) -> str: + """ + Derive the default subject for a reply. An existing `Re:` prefix + (case-insensitive) is preserved rather than duplicated. + """ + + if original_subject.lower().startswith("re:"): + return original_subject + return f"Re: {original_subject}" + + +def cmd_reply(args: Namespace) -> None: + """ + Reply to an existing inbox message for the current MAIL user. + + Fetches the original message to derive the reply recipient (its sender) + and a default `Re:` subject, creates a draft that references the original + via `reply_to`, then sends it. + """ + + # 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 _reply_subject(original.subject) + + # 3. create the reply draft, referencing the original via `reply_to` + draft_payload = DraftPostRequest( + subject=subject, + body=args.body, + reply_to=original.message_id, + 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 back to the original sender + send_payload = DraftSendPostRequest(recipients=[original.sender]) + 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 sent reply + 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("=== Reply Sent ===") + print(f"Message ID: {message.message_id}") + print(f"In Reply To: {message.reply_to}") + 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/src/mail/client/src/mail_client/commands/send.py b/src/mail/client/src/mail_client/commands/send.py index 853bf819..b0fa9e72 100644 --- a/src/mail/client/src/mail_client/commands/send.py +++ b/src/mail/client/src/mail_client/commands/send.py @@ -26,6 +26,7 @@ def cmd_send(args: Namespace) -> None: # 2. hit the server endpoint `POST /drafts/{draft_id}/send` payload = DraftSendPostRequest( recipients=args.to, + tags=args.tags, ) response = httpx.post( url=f"{MAIL_SERVER}/drafts/{args.draft_id}/send", @@ -72,4 +73,8 @@ def _print_text(response_obj: DraftSendPostResponse) -> None: for recipient in message.recipients: print(f"- {recipient}") print(f"Subject: {message.subject}") + if message.reply_to is not None: + print(f"In Reply To: {message.reply_to}") + if message.tags: + print(f"Tags: {', '.join(message.tags)}") print(f"Body:\n{message.body}\n") diff --git a/src/mail/protocol/src/mail_protocol/core/constants.py b/src/mail/protocol/src/mail_protocol/core/constants.py index 00e9fdf0..dd3d4c90 100644 --- a/src/mail/protocol/src/mail_protocol/core/constants.py +++ b/src/mail/protocol/src/mail_protocol/core/constants.py @@ -7,6 +7,9 @@ MESSAGE_BODY_LEN_MIN = 1 MESSAGE_BODY_LEN_MAX = 65535 +MESSAGE_TAG_LEN_MIN = 1 +MESSAGE_TAG_LEN_MAX = 32 + AGENT_NAME_LEN_MIN = 1 AGENT_NAME_LEN_MAX = 31 diff --git a/src/mail/protocol/src/mail_protocol/core/drafts.py b/src/mail/protocol/src/mail_protocol/core/drafts.py index eaa3d183..974ec8f3 100644 --- a/src/mail/protocol/src/mail_protocol/core/drafts.py +++ b/src/mail/protocol/src/mail_protocol/core/drafts.py @@ -10,6 +10,7 @@ validate_mail_address, validate_message_body, validate_message_subject, + validate_message_tags, validate_uuid, ) @@ -18,6 +19,10 @@ class MAILDraft(BaseModel): """ A draft of an individual MAIL message. Does not yet account for intended recipients. + + `reply_to` and `tags` carry forward onto the `MAILMessage` produced when + the draft is sent. Both default to "no value" so drafts persisted before + these fields existed remain loadable. """ draft_id: Annotated[str, AfterValidator(validate_uuid)] @@ -25,6 +30,8 @@ class MAILDraft(BaseModel): body: Annotated[str, AfterValidator(validate_message_body)] created_at: datetime updated_at: datetime | None = None + reply_to: Annotated[str, AfterValidator(validate_uuid)] | None = None + tags: Annotated[list[str], AfterValidator(validate_message_tags)] = [] class MAILDraftsEntrySummary(BaseModel): diff --git a/src/mail/protocol/src/mail_protocol/core/messages.py b/src/mail/protocol/src/mail_protocol/core/messages.py index e2427123..50f465d2 100644 --- a/src/mail/protocol/src/mail_protocol/core/messages.py +++ b/src/mail/protocol/src/mail_protocol/core/messages.py @@ -2,7 +2,7 @@ # Copyright (c) 2025-26 Addison Kline from datetime import datetime -from typing import Annotated, Any +from typing import Annotated, Any, Literal from pydantic import AfterValidator, BaseModel @@ -11,6 +11,7 @@ validate_message_body, validate_message_recipients, validate_message_subject, + validate_message_tags, validate_uuid, ) @@ -33,11 +34,14 @@ class MAILMessage(BaseModel): A constructed message to be delivered via MAIL. """ + mail_version: Literal["2.0"] message_id: Annotated[str, AfterValidator(validate_uuid)] + reply_to: Annotated[str, AfterValidator(validate_uuid)] | None = None sender: Annotated[str, AfterValidator(validate_mail_address)] recipients: Annotated[list[str], AfterValidator(validate_message_recipients)] subject: Annotated[str, AfterValidator(validate_message_subject)] body: Annotated[str, AfterValidator(validate_message_body)] + tags: Annotated[list[str], AfterValidator(validate_message_tags)] sent_at: datetime metadata: dict[str, Any] diff --git a/src/mail/protocol/src/mail_protocol/core/validators.py b/src/mail/protocol/src/mail_protocol/core/validators.py index 828cdcb7..b88359e1 100644 --- a/src/mail/protocol/src/mail_protocol/core/validators.py +++ b/src/mail/protocol/src/mail_protocol/core/validators.py @@ -18,6 +18,8 @@ MESSAGE_BODY_LEN_MIN, MESSAGE_SUBJECT_LEN_MAX, MESSAGE_SUBJECT_LEN_MIN, + MESSAGE_TAG_LEN_MAX, + MESSAGE_TAG_LEN_MIN, SWARM_DESCRIPTION_LEN_MAX, SWARM_DESCRIPTION_LEN_MIN, SWARM_KEYWORD_LEN_MAX, @@ -105,9 +107,7 @@ def validate_mail_address(address: str) -> str: case _ if prefix == LIST_ADDRESS_PREFIX: validate_list_name(identifier) case _: - raise ValueError( - f"invalid MAIL address structure: {address}" - ) + raise ValueError(f"invalid MAIL address structure: {address}") else: # No prefix โ†’ agent address. validate_agent_name(first) @@ -160,6 +160,37 @@ def validate_message_recipients(addresses: list[str]) -> list[str]: return validate_mail_addresses(addresses) +def validate_message_tag(tag: str) -> str: + """ + Ensure that the given string is a valid MAIL message tag. + """ + + if len(tag) < MESSAGE_TAG_LEN_MIN: + raise ValueError( + f"message tag must be at least {MESSAGE_TAG_LEN_MIN} characters long" + ) + if len(tag) > MESSAGE_TAG_LEN_MAX: + raise ValueError( + f"message tag must be no longer than {MESSAGE_TAG_LEN_MAX} characters" + ) + if not string_is_slug(tag): + raise ValueError("message tag must be a slug string") + + return tag + + +def validate_message_tags(tags: list[str]) -> list[str]: + """ + Ensure that the given list is a valid list of MAIL message tags. + Can be of length 0. + """ + + for tag in tags: + validate_message_tag(tag) + + return tags + + def validate_local_address(address: str) -> str: """ Ensure that the given string is a valid MAIL local agent address (agent@swarm). diff --git a/src/mail/protocol/src/mail_protocol/core/webhooks.py b/src/mail/protocol/src/mail_protocol/core/webhooks.py index ac4f8910..78a1556a 100644 --- a/src/mail/protocol/src/mail_protocol/core/webhooks.py +++ b/src/mail/protocol/src/mail_protocol/core/webhooks.py @@ -10,6 +10,7 @@ validate_mail_address, validate_message_body, validate_message_subject, + validate_message_tags, validate_swarm_name, validate_url, validate_webhook_event_types, @@ -37,10 +38,12 @@ class MAILMessageInWebhook(BaseModel): """ message_id: Annotated[str, AfterValidator(validate_webhook_message_id)] + reply_to: Annotated[str, AfterValidator(validate_webhook_message_id)] | None = None sender: Annotated[str, AfterValidator(validate_mail_address)] recipient: Annotated[str, AfterValidator(validate_mail_address)] subject: Annotated[str, AfterValidator(validate_message_subject)] body: Annotated[str, AfterValidator(validate_message_body)] + tags: Annotated[list[str], AfterValidator(validate_message_tags)] = [] sent_at: datetime swarm: Annotated[str, AfterValidator(validate_swarm_name)] metadata: dict[str, Any] diff --git a/src/mail/protocol/src/mail_protocol/network/requests.py b/src/mail/protocol/src/mail_protocol/network/requests.py index ed94767c..17772646 100644 --- a/src/mail/protocol/src/mail_protocol/network/requests.py +++ b/src/mail/protocol/src/mail_protocol/network/requests.py @@ -16,11 +16,13 @@ validate_message_body, validate_message_recipients, validate_message_subject, + validate_message_tags, validate_swarm_description, validate_swarm_keywords, validate_swarm_name, validate_url, validate_user_name, + validate_uuid, validate_uuids, validate_webhook_event_types, ) @@ -54,19 +56,30 @@ class DraftPostRequest(BaseModel): """ Corresponds to `POST /drafts/`. Contains relevant information for creating a new MAIL message draft. + + `reply_to` optionally references the `message_id` of the message this + draft is replying to. `tags` is an optional list of sender-defined slug + strings used to categorize the eventual message. """ subject: Annotated[str, AfterValidator(validate_message_subject)] body: Annotated[str, AfterValidator(validate_message_body)] + reply_to: Annotated[str, AfterValidator(validate_uuid)] | None = None + tags: Annotated[list[str], AfterValidator(validate_message_tags)] = [] class DraftSendPostRequest(BaseModel): """ Corresponds to `POST /drafts/{draft_id}/send`. Contains relevant information for sending an existing draft as a MAIL message. + + `tags` is an optional list of sender-defined slug strings; any tags + supplied here are merged (union, order-preserving) with the tags already + stored on the draft. """ recipients: Annotated[list[str], AfterValidator(validate_message_recipients)] + tags: Annotated[list[str], AfterValidator(validate_message_tags)] = [] # diff --git a/src/mail/server/src/mail_server/backends/base.py b/src/mail/server/src/mail_server/backends/base.py index 42f2e361..a26d0720 100644 --- a/src/mail/server/src/mail_server/backends/base.py +++ b/src/mail/server/src/mail_server/backends/base.py @@ -686,10 +686,15 @@ async def _webhook_delivered_post( # MAILMessage stores a bare UUID; the webhook payload's # message_id is the prefixed form per validate_webhook_message_id. message_id=f"msg_{message.message_id}", + # reply_to, when set, is carried in the same prefixed form. + reply_to=( + f"msg_{message.reply_to}" if message.reply_to is not None else None + ), sender=message.sender, recipient=recipient, subject=message.subject, body=message.body, + tags=message.tags, sent_at=message.sent_at, swarm=recipient.split("@")[1], metadata=metadata, 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 f6ba79e1..92a548e4 100644 --- a/src/mail/server/src/mail_server/backends/memory/api.py +++ b/src/mail/server/src/mail_server/backends/memory/api.py @@ -631,6 +631,8 @@ async def post_draft( body=payload.body, created_at=datetime.now(UTC), updated_at=None, + reply_to=payload.reply_to, + tags=payload.tags, ) draft_entry = MAILDraftsEntry(draft=draft, sent_at=None) @@ -695,12 +697,21 @@ async def send_draft( draft = draft_entry.draft message_id = str(uuid.uuid4()) # make this different from draft_id + # Tags on the draft and tags supplied at send time are merged as an + # order-preserving union: draft tags first, then any new send tags. + tags = list(draft.tags) + for tag in payload.tags: + if tag not in tags: + tags.append(tag) message = MAILMessage( + mail_version="2.0", message_id=message_id, + reply_to=draft.reply_to, sender=ua_address, recipients=payload.recipients, subject=draft.subject, body=draft.body, + tags=tags, sent_at=datetime.now(UTC), metadata={}, ) diff --git a/tests/contract/test_spec_delivery.py b/tests/contract/test_spec_delivery.py index bc82980c..83cf0359 100644 --- a/tests/contract/test_spec_delivery.py +++ b/tests/contract/test_spec_delivery.py @@ -68,11 +68,13 @@ async def test_undeliverable_message_is_preserved_and_logged( message_id = "66666666-6666-4666-8666-666666666666" message = MAILMessage( + mail_version="2.0", message_id=message_id, sender="user:alice@localhost", recipients=["ghost@nowhere@localhost"], subject="Undeliverable", body="No such recipient.", + tags=[], sent_at=datetime(2026, 6, 12, 9, 0, tzinfo=UTC), metadata={}, ) diff --git a/tests/contract/test_spec_messages.py b/tests/contract/test_spec_messages.py index 2d4581a1..da2d5f16 100644 --- a/tests/contract/test_spec_messages.py +++ b/tests/contract/test_spec_messages.py @@ -20,11 +20,13 @@ def make_message(**overrides: Any) -> MAILMessage: fields: dict[str, Any] = { + "mail_version": "2.0", "message_id": "55555555-5555-4555-8555-555555555555", "sender": "user:alice@localhost", "recipients": ["sage@chorus@localhost"], "subject": "A subject", "body": "A body.", + "tags": [], "sent_at": datetime(2026, 6, 12, 9, 0, tzinfo=UTC), "metadata": {}, } @@ -142,11 +144,69 @@ def test_metadata_field_is_required_but_may_be_empty() -> None: assert make_message(metadata={}).metadata == {} with pytest.raises(ValidationError): MAILMessage( + mail_version="2.0", message_id="55555555-5555-4555-8555-555555555555", sender="user:alice@localhost", recipients=["sage@chorus@localhost"], subject="A subject", body="A body.", + tags=[], sent_at=datetime(2026, 6, 12, 9, 0, tzinfo=UTC), # metadata intentionally omitted ) + + +# โ”€โ”€โ”€ ยง7.8 Protocol Version โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + + +def test_mail_version_must_be_present_and_2_0() -> None: + """ยง7.8: every message MUST carry mail_version, pinned to "2.0".""" + + assert make_message().mail_version == "2.0" + with pytest.raises(ValidationError): + make_message(mail_version="1.0") + + +# โ”€โ”€โ”€ ยง7.9 Reply References โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + + +def test_reply_to_defaults_to_none() -> None: + """ยง7.9: reply_to is optional; absent means the message is not a reply.""" + + assert make_message().reply_to is None + + +def test_reply_to_accepts_uuid() -> None: + """ยง7.9: when present, reply_to MUST be the UUID of another message.""" + + original_id = "66666666-6666-4666-8666-666666666666" + assert make_message(reply_to=original_id).reply_to == original_id + + +def test_reply_to_rejects_non_uuid() -> None: + """ยง7.9: a malformed reply_to MUST be rejected.""" + + with pytest.raises(ValidationError): + make_message(reply_to="not-a-uuid") + + +# โ”€โ”€โ”€ ยง7.10 Tags โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + + +def test_tags_may_be_empty() -> None: + """ยง7.10: tags MUST be present; it MAY be an empty list.""" + + assert make_message(tags=[]).tags == [] + + +def test_tags_accept_slug_strings() -> None: + """ยง7.10: each tag MUST be a slug string.""" + + assert make_message(tags=["urgent", "project-x"]).tags == ["urgent", "project-x"] + + +def test_tags_reject_non_slug() -> None: + """ยง7.10: non-slug tags (spaces, uppercase, etc.) MUST be rejected.""" + + with pytest.raises(ValidationError): + make_message(tags=["Not A Slug"]) diff --git a/tests/integration/test_mailboxes.py b/tests/integration/test_mailboxes.py index 588e6d4e..d18ffa1f 100644 --- a/tests/integration/test_mailboxes.py +++ b/tests/integration/test_mailboxes.py @@ -274,11 +274,13 @@ def test_send_unknown_draft_returns_404(app_client: TestClient, headers_for) -> def _seed_trash(backend: MemoryBackend, owner: str) -> str: message = MAILMessage( + mail_version="2.0", message_id="22222222-2222-4222-8222-222222222222", sender="sage@chorus@localhost", recipients=[owner], subject="Trashed", body="This message was moved to trash.", + tags=[], sent_at=datetime(2026, 6, 11, tzinfo=UTC), metadata={}, ) @@ -352,11 +354,13 @@ def _seed_trash_n(backend: MemoryBackend, owner: str, n: int) -> list[str]: for i in range(n): message_id = f"{i:08d}-2222-4222-8222-222222222222" message = MAILMessage( + mail_version="2.0", message_id=message_id, sender="sage@chorus@localhost", recipients=[owner], subject=f"Trashed {i}", body="body", + tags=[], sent_at=datetime(2026, 6, 1, 12, n - i, tzinfo=UTC), # decreasing metadata={}, ) diff --git a/tests/integration/webhooks/test_delivery.py b/tests/integration/webhooks/test_delivery.py index 782cf20b..03353a8a 100644 --- a/tests/integration/webhooks/test_delivery.py +++ b/tests/integration/webhooks/test_delivery.py @@ -44,11 +44,13 @@ @pytest.fixture def message() -> MAILMessage: return MAILMessage( + mail_version="2.0", message_id=MESSAGE_ID, sender=SENDER, recipients=[RECIPIENT], subject="Webhook test", body="A body worth signing.", + tags=[], sent_at=datetime(2026, 6, 12, 9, 0, tzinfo=UTC), metadata={}, ) @@ -109,6 +111,36 @@ async def test_delivery_posts_expected_payload( assert payload_message["body"] == "A body worth signing." assert payload_message["swarm"] == "chorus" assert payload_message["metadata"] == {} + # A non-reply, untagged message carries the empty defaults. + assert payload_message["reply_to"] is None + assert payload_message["tags"] == [] + + +@respx.mock +@pytest.mark.asyncio +async def test_delivery_payload_carries_reply_to_and_tags( + pipeline_backend: MemoryBackend, +) -> None: + original_id = "44444444-4444-4444-4444-444444444444" + message = MAILMessage( + mail_version="2.0", + message_id=MESSAGE_ID, + reply_to=original_id, + sender=SENDER, + recipients=[RECIPIENT], + subject="Re: Webhook test", + body="A reply worth signing.", + tags=["urgent", "project-x"], + sent_at=datetime(2026, 6, 12, 9, 0, tzinfo=UTC), + metadata={}, + ) + route = respx.post(WEBHOOK_URL).mock(return_value=httpx.Response(200)) + await _fire(pipeline_backend, message) + + payload_message = json.loads(route.calls[0].request.content)["message"] + # reply_to is surfaced in the same msg_-prefixed form as message_id. + assert payload_message["reply_to"] == f"msg_{original_id}" + assert payload_message["tags"] == ["urgent", "project-x"] @respx.mock @@ -180,9 +212,7 @@ async def test_5xx_retries_until_success( assert recorded_sleeps == RETRY_LADDER[:2] # The event id is stable across attempts (receiver-side dedup key). - event_ids = { - call.request.headers["X-MAIL-Event-Id"] for call in route.calls - } + event_ids = {call.request.headers["X-MAIL-Event-Id"] for call in route.calls} assert len(event_ids) == 1 @@ -271,11 +301,13 @@ async def test_daemon_deliver_local_fires_registered_webhook( ) backend.inboxes[RECIPIENT] = [] message = MAILMessage( + mail_version="2.0", message_id=MESSAGE_ID, sender=SENDER, recipients=[RECIPIENT], subject="Wired", body="Through the whole pipeline.", + tags=[], sent_at=datetime(2026, 6, 12, 9, 0, tzinfo=UTC), metadata={}, ) @@ -337,11 +369,13 @@ async def test_delivery_to_non_agent_recipient_fires_no_webhook( ) backend.inboxes[user_address] = [] message = MAILMessage( + mail_version="2.0", message_id=MESSAGE_ID, sender="user:bob@localhost", recipients=[user_address], subject="No hook", body="Delivered silently.", + tags=[], sent_at=datetime(2026, 6, 12, 9, 0, tzinfo=UTC), metadata={}, ) diff --git a/tests/unit/test_cli_help.py b/tests/unit/test_cli_help.py index d455f981..f7c188f0 100644 --- a/tests/unit/test_cli_help.py +++ b/tests/unit/test_cli_help.py @@ -27,6 +27,7 @@ def test_mail_help_uses_categorized_command_sections() -> None: assert " Mailing Lists:" in help_text assert "{ping,p,login" not in help_text assert 'mail compose "Status update"' in help_text + assert "reply (r)" 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 e1dc5488..21e3cc0b 100644 --- a/tests/unit/test_client_commands.py +++ b/tests/unit/test_client_commands.py @@ -21,6 +21,7 @@ cmd_inbox, cmd_login, cmd_ping, + cmd_reply, cmd_send, ) @@ -164,11 +165,16 @@ def test_compose_posts_draft_payload(client_env, capsys: pytest.CaptureFixture) }, ) ) - cmd_compose(Namespace(output="text", subject="A subject", body="A body.")) + cmd_compose(Namespace(output="text", subject="A subject", body="A body.", tags=[])) request = route.calls[0].request assert request.headers["Authorization"] == f"Bearer {TOKEN}" - assert json.loads(request.content) == {"subject": "A subject", "body": "A body."} + assert json.loads(request.content) == { + "subject": "A subject", + "body": "A body.", + "reply_to": None, + "tags": [], + } assert "Draft ID: 55555555-5555-4555-8555-555555555555" in capsys.readouterr().out @@ -177,7 +183,7 @@ def test_compose_rejects_invalid_subject_before_any_request(client_env) -> None: 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.")) + cmd_compose(Namespace(output="text", subject="", body="A body.", tags=[])) # โ”€โ”€โ”€ send โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ @@ -189,21 +195,30 @@ def test_send_posts_recipients_to_draft_endpoint( ) -> None: draft_id = "55555555-5555-4555-8555-555555555555" message = { + "mail_version": "2.0", "message_id": "66666666-6666-4666-8666-666666666666", "sender": "user:alice@localhost", "recipients": ["sage@chorus@localhost"], "subject": "A subject", "body": "A body.", + "tags": [], "sent_at": "2026-06-12T09:00:00+00:00", "metadata": {}, } route = respx.post(f"{SERVER}/drafts/{draft_id}/send").mock( return_value=httpx.Response(200, json={"message": message, "metadata": {}}) ) - cmd_send(Namespace(output="text", draft_id=draft_id, to=["sage@chorus@localhost"])) + cmd_send( + Namespace( + output="text", draft_id=draft_id, to=["sage@chorus@localhost"], tags=[] + ) + ) request = route.calls[0].request - assert json.loads(request.content) == {"recipients": ["sage@chorus@localhost"]} + assert json.loads(request.content) == { + "recipients": ["sage@chorus@localhost"], + "tags": [], + } out = capsys.readouterr().out assert "- sage@chorus@localhost" in out @@ -216,5 +231,167 @@ def test_send_raises_on_non_200(client_env) -> None: ) with pytest.raises(RuntimeError, match="404"): cmd_send( - Namespace(output="text", draft_id=draft_id, to=["sage@chorus@localhost"]) + Namespace( + output="text", + draft_id=draft_id, + to=["sage@chorus@localhost"], + tags=[], + ) + ) + + +# โ”€โ”€โ”€ reply โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + + +def _inbox_entry(message: dict) -> dict: + return { + "entry": { + "message": message, + "received_at": "2026-06-12T09:05:00+00:00", + "delivered_by": "daemon:worker@localhost", + }, + "metadata": {}, + } + + +ORIGINAL_ID = "66666666-6666-4666-8666-666666666666" +ORIGINAL_MESSAGE = { + "mail_version": "2.0", + "message_id": ORIGINAL_ID, + "sender": "philosopher@chorus@localhost", + "recipients": ["user:alice@localhost"], + "subject": "Original subject", + "body": "The original body.", + "tags": [], + "sent_at": "2026-06-12T09:00:00+00:00", + "metadata": {}, +} + + +def _mock_reply_routes(draft_id: str, reply_message: dict): + """Register the three calls a reply 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": reply_message["subject"], + "body": reply_message["body"], + "created_at": "2026-06-12T09:10:00+00:00", + "updated_at": None, + "reply_to": ORIGINAL_ID, + "tags": reply_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": reply_message, "metadata": {}} + ) + ) + return inbox_route, draft_route, send_route + + +@respx.mock +def test_reply_defaults_subject_recipient_and_reply_to( + client_env, capsys: pytest.CaptureFixture +) -> None: + draft_id = "55555555-5555-4555-8555-555555555555" + reply_message = { + "mail_version": "2.0", + "message_id": "77777777-7777-4777-8777-777777777777", + "reply_to": ORIGINAL_ID, + "sender": "user:alice@localhost", + "recipients": ["philosopher@chorus@localhost"], + "subject": "Re: Original subject", + "body": "My reply.", + "tags": [], + "sent_at": "2026-06-12T09:11:00+00:00", + "metadata": {}, + } + _, draft_route, send_route = _mock_reply_routes(draft_id, reply_message) + + cmd_reply( + Namespace( + output="text", + message_id=ORIGINAL_ID, + body="My reply.", + subject=None, + tags=[], + ) + ) + + # The draft references the original and defaults the subject to "Re: ...". + draft_body = json.loads(draft_route.calls[0].request.content) + assert draft_body == { + "subject": "Re: Original subject", + "body": "My reply.", + "reply_to": ORIGINAL_ID, + "tags": [], + } + # The reply is addressed back to the original sender. + send_body = json.loads(send_route.calls[0].request.content) + assert send_body == { + "recipients": ["philosopher@chorus@localhost"], + "tags": [], + } + out = capsys.readouterr().out + assert "Reply Sent" in out + assert f"In Reply To: {ORIGINAL_ID}" in out + + +@respx.mock +def test_reply_honors_explicit_subject_and_tags( + client_env, capsys: pytest.CaptureFixture +) -> None: + draft_id = "55555555-5555-4555-8555-555555555555" + reply_message = { + "mail_version": "2.0", + "message_id": "77777777-7777-4777-8777-777777777777", + "reply_to": ORIGINAL_ID, + "sender": "user:alice@localhost", + "recipients": ["philosopher@chorus@localhost"], + "subject": "Custom subject", + "body": "My reply.", + "tags": ["urgent", "project-x"], + "sent_at": "2026-06-12T09:11:00+00:00", + "metadata": {}, + } + _, draft_route, _ = _mock_reply_routes(draft_id, reply_message) + + cmd_reply( + Namespace( + output="text", + message_id=ORIGINAL_ID, + body="My reply.", + subject="Custom subject", + tags=["urgent", "project-x"], + ) + ) + + draft_body = json.loads(draft_route.calls[0].request.content) + assert draft_body["subject"] == "Custom subject" + assert draft_body["tags"] == ["urgent", "project-x"] + + +@respx.mock +def test_reply_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_reply( + Namespace( + output="text", + message_id=ORIGINAL_ID, + body="My reply.", + subject=None, + tags=[], + ) ) diff --git a/tests/unit/test_draft_reply_tags.py b/tests/unit/test_draft_reply_tags.py new file mode 100644 index 00000000..203eac38 --- /dev/null +++ b/tests/unit/test_draft_reply_tags.py @@ -0,0 +1,96 @@ +# SPDX-License-Identifier: Apache-2.0 +# Copyright (c) 2026 Addison Kline + +""" +Server-side behavior for MAIL 2.0 reply/tag support: drafts carry reply_to +and tags through to the sent MAILMessage, and send-time tags merge with draft +tags as an order-preserving union. +""" + +import pytest +from mail_protocol.core.user_agents import MAILUser, MAILUserAgent +from mail_protocol.network.requests import DraftPostRequest, DraftSendPostRequest +from mail_server.backends.memory.api import MemoryBackend + +ORIGINAL_ID = "66666666-6666-4666-8666-666666666666" + + +def _make_user_agent() -> MAILUserAgent: + return MAILUserAgent( + user_agent=MAILUser(ua_type="user", user_id="alice", host="localhost") + ) + + +async def _seed_draft( + backend: MemoryBackend, user_agent: MAILUserAgent, payload: DraftPostRequest +) -> str: + address = user_agent.get_address() + backend.drafts[address] = [] + backend.outboxes[address] = [] + draft_entry = await backend.post_draft(user_agent, payload) + return draft_entry.draft.draft_id + + +@pytest.mark.asyncio +async def test_send_draft_propagates_mail_version_and_reply_to( + backend: MemoryBackend, +) -> None: + user_agent = _make_user_agent() + draft_id = await _seed_draft( + backend, + user_agent, + DraftPostRequest( + subject="Re: Hi", body="A reply.", reply_to=ORIGINAL_ID, tags=[] + ), + ) + + message = await backend.send_draft( + user_agent, + draft_id, + DraftSendPostRequest(recipients=["philosopher@chorus@localhost"]), + ) + + assert message.mail_version == "2.0" + assert message.reply_to == ORIGINAL_ID + + +@pytest.mark.asyncio +async def test_send_draft_merges_draft_and_send_tags(backend: MemoryBackend) -> None: + user_agent = _make_user_agent() + draft_id = await _seed_draft( + backend, + user_agent, + DraftPostRequest(subject="Tagged", body="Body.", tags=["alpha", "beta"]), + ) + + message = await backend.send_draft( + user_agent, + draft_id, + DraftSendPostRequest( + recipients=["philosopher@chorus@localhost"], tags=["beta", "gamma"] + ), + ) + + # Order-preserving union: draft tags first, then new send-time tags. + assert message.tags == ["alpha", "beta", "gamma"] + + +@pytest.mark.asyncio +async def test_send_draft_without_reply_or_tags_is_unmarked( + backend: MemoryBackend, +) -> None: + user_agent = _make_user_agent() + draft_id = await _seed_draft( + backend, + user_agent, + DraftPostRequest(subject="Plain", body="Body."), + ) + + message = await backend.send_draft( + user_agent, + draft_id, + DraftSendPostRequest(recipients=["philosopher@chorus@localhost"]), + ) + + assert message.reply_to is None + assert message.tags == [] diff --git a/tests/unit/test_mail_lists_send.py b/tests/unit/test_mail_lists_send.py index 411aa245..0cbbef63 100644 --- a/tests/unit/test_mail_lists_send.py +++ b/tests/unit/test_mail_lists_send.py @@ -67,11 +67,13 @@ def _seed_message( ) -> MAILMessage: now = datetime(2026, 6, 6, 12, 0, tzinfo=UTC) message = MAILMessage( + mail_version="2.0", message_id="22222222-2222-2222-2222-222222222222", sender=SENDER, recipients=recipients, subject="Daily briefing", body="Body text.", + tags=[], sent_at=now, metadata={}, ) @@ -295,7 +297,9 @@ async def test_handle_webhook_delivered_skips_non_agent_recipients( fired: list[tuple[str, MAILMessage]] = [] - async def fake_handle_webhook_delivered_for_url(*, url, recipient, message, secret, list_address=None): + async def fake_handle_webhook_delivered_for_url( + *, url, recipient, message, secret, list_address=None + ): fired.append((recipient, message)) monkeypatch.setattr( @@ -305,17 +309,23 @@ async def fake_handle_webhook_delivered_for_url(*, url, recipient, message, secr ) msg = MAILMessage( + mail_version="2.0", message_id="33333333-3333-3333-3333-333333333333", sender=SENDER, recipients=[ALICE], subject="s", body="b", + tags=[], sent_at=datetime(2026, 6, 12, tzinfo=UTC), metadata={}, ) # Non-agent recipients: no webhook task is created. - for non_agent in ["admin:ryan@chrn.ai", "user:dummy@chrn.ai", "daemon:first@chrn.ai"]: + for non_agent in [ + "admin:ryan@chrn.ai", + "user:dummy@chrn.ai", + "daemon:first@chrn.ai", + ]: await backend._handle_webhook_delivered(recipient=non_agent, message=msg) # Agent recipient: webhook task IS created. @@ -326,6 +336,7 @@ async def fake_handle_webhook_delivered_for_url(*, url, recipient, message, secr # backend's _handle_webhook_delivered scheduling them as Tasks. Give # the loop one tick. import asyncio as _asyncio + await _asyncio.sleep(0) fired_recipients = [r for r, _ in fired] diff --git a/tests/unit/test_mail_trash_store.py b/tests/unit/test_mail_trash_store.py index 6eacaa93..700ed620 100644 --- a/tests/unit/test_mail_trash_store.py +++ b/tests/unit/test_mail_trash_store.py @@ -22,11 +22,13 @@ def _make_user_agent() -> MAILUserAgent: def _make_message() -> MAILMessage: return MAILMessage( + mail_version="2.0", message_id="11111111-1111-4111-8111-111111111111", sender="philosopher@chorus@localhost", recipients=["user:ryan@localhost"], subject="Trash lookup", body="This message should be read from trash, not drafts.", + tags=[], sent_at=datetime(2026, 6, 10, tzinfo=UTC), metadata={}, ) diff --git a/tests/unit/test_memory_fs_roundtrip.py b/tests/unit/test_memory_fs_roundtrip.py index c4e55776..82874e97 100644 --- a/tests/unit/test_memory_fs_roundtrip.py +++ b/tests/unit/test_memory_fs_roundtrip.py @@ -33,11 +33,13 @@ def _message() -> MAILMessage: return MAILMessage( + mail_version="2.0", message_id=UUID, sender=USER, recipients=[AGENT], subject="Persisted", body="Survives a save/load cycle.", + tags=[], sent_at=NOW, metadata={}, ) diff --git a/tests/unit/test_migrate_messages_v2.py b/tests/unit/test_migrate_messages_v2.py new file mode 100644 index 00000000..5f912365 --- /dev/null +++ b/tests/unit/test_migrate_messages_v2.py @@ -0,0 +1,102 @@ +# SPDX-License-Identifier: Apache-2.0 +# Copyright (c) 2026 Addison Kline + +""" +Tests for scripts/migrate_messages_v2.py, the MAIL 2.0 message-schema +backfill migration. +""" + +import importlib.util +import json +import sys +from pathlib import Path + +import pytest +from mail_protocol.core.messages import MAILMessage + +_SCRIPT = Path(__file__).resolve().parents[2] / "scripts" / "migrate_messages_v2.py" +_spec = importlib.util.spec_from_file_location("migrate_messages_v2", _SCRIPT) +assert _spec is not None and _spec.loader is not None +migrate = importlib.util.module_from_spec(_spec) +# Register before exec so dataclass introspection can resolve the module. +sys.modules[_spec.name] = migrate +_spec.loader.exec_module(migrate) + + +PRE_V2_RECORD = { + "message_id": "55555555-5555-4555-8555-555555555555", + "sender": "user:alice@localhost", + "recipients": ["sage@chorus@localhost"], + "subject": "Legacy", + "body": "Persisted before MAIL 2.0.", + "sent_at": "2026-06-12T09:00:00+00:00", + "metadata": {}, +} + + +def _write(messages_dir: Path, record: dict) -> Path: + path = messages_dir / record["message_id"] + path.write_text(json.dumps(record), encoding="utf-8") + return path + + +def test_dry_run_reports_without_writing(tmp_path: Path) -> None: + messages = tmp_path / "messages" + messages.mkdir() + path = _write(messages, PRE_V2_RECORD) + + result = migrate.migrate_messages(messages, dry_run=True) + + assert result.scanned == 1 + assert result.migrated == 1 + assert result.already_current == 0 + # File on disk is untouched in dry-run mode. + assert json.loads(path.read_text()) == PRE_V2_RECORD + + +def test_migration_backfills_and_validates(tmp_path: Path) -> None: + messages = tmp_path / "messages" + messages.mkdir() + path = _write(messages, PRE_V2_RECORD) + + result = migrate.migrate_messages(messages, dry_run=False) + assert result.migrated == 1 + + upgraded = json.loads(path.read_text()) + assert upgraded["mail_version"] == "2.0" + assert upgraded["tags"] == [] + # The migrated record now passes the MAIL 2.0 model contract. + model = MAILMessage.model_validate(upgraded) + assert model.reply_to is None + + +def test_existing_fields_are_preserved(tmp_path: Path) -> None: + messages = tmp_path / "messages" + messages.mkdir() + record = dict(PRE_V2_RECORD) + record["mail_version"] = "2.0" + record["tags"] = ["already-tagged"] + path = _write(messages, record) + + result = migrate.migrate_messages(messages, dry_run=False) + + assert result.migrated == 0 + assert result.already_current == 1 + assert json.loads(path.read_text())["tags"] == ["already-tagged"] + + +def test_idempotent_second_run_is_noop(tmp_path: Path) -> None: + messages = tmp_path / "messages" + messages.mkdir() + _write(messages, PRE_V2_RECORD) + + migrate.migrate_messages(messages, dry_run=False) + second = migrate.migrate_messages(messages, dry_run=False) + + assert second.migrated == 0 + assert second.already_current == 1 + + +def test_missing_directory_raises(tmp_path: Path) -> None: + with pytest.raises(FileNotFoundError): + migrate.migrate_messages(tmp_path / "nope", dry_run=True) diff --git a/tests/unit/test_protocol_models.py b/tests/unit/test_protocol_models.py index d7bc2849..2beda6f4 100644 --- a/tests/unit/test_protocol_models.py +++ b/tests/unit/test_protocol_models.py @@ -22,11 +22,13 @@ def _message() -> MAILMessage: return MAILMessage( + mail_version="2.0", message_id=UUID, sender="user:alice@localhost", recipients=["sage@chorus@localhost"], subject="Hello", body="A body worth summarizing.", + tags=[], sent_at=NOW, metadata={"k": "v"}, ) diff --git a/tests/unit/test_protocol_validators.py b/tests/unit/test_protocol_validators.py index 6cfc373c..920d6205 100644 --- a/tests/unit/test_protocol_validators.py +++ b/tests/unit/test_protocol_validators.py @@ -67,6 +67,37 @@ def test_validate_mail_addresses_permits_empty_list() -> None: assert v.validate_mail_addresses([]) == [] +# โ”€โ”€โ”€ message tags (SPEC.md ยง7.10) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + + +@pytest.mark.parametrize("value", ["urgent", "project-x", "a", "v2-0", "x" * 32]) +def test_validate_message_tag_accepts_slugs(value: str) -> None: + assert v.validate_message_tag(value) == value + + +@pytest.mark.parametrize( + "value", + ["", "Urgent", "has space", "trailing-", "-leading", "under_score", "x" * 33], +) +def test_validate_message_tag_rejects_non_slugs(value: str) -> None: + with pytest.raises(ValueError): + v.validate_message_tag(value) + + +def test_validate_message_tags_permits_empty_list() -> None: + assert v.validate_message_tags([]) == [] + + +def test_validate_message_tags_accepts_list_of_slugs() -> None: + tags = ["urgent", "project-x"] + assert v.validate_message_tags(tags) == tags + + +def test_validate_message_tags_rejects_any_invalid_member() -> None: + with pytest.raises(ValueError): + v.validate_message_tags(["urgent", "Not A Slug"]) + + # โ”€โ”€โ”€ local addresses (agent@swarm) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€