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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
223 changes: 223 additions & 0 deletions scripts/migrate_messages_v2.py
Original file line number Diff line number Diff line change
@@ -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 <messages>.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()
16 changes: 16 additions & 0 deletions spec/SPEC.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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).
Expand All @@ -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
Expand Down
36 changes: 35 additions & 1 deletion spec/openapi.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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:
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand All @@ -1804,11 +1836,13 @@ components:
title: Metadata
type: object
required:
- mail_version
- message_id
- sender
- recipients
- subject
- body
- tags
- sent_at
- metadata
title: MAILMessage
Expand Down
22 changes: 20 additions & 2 deletions src/mail/client/docs/reference/cli.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,9 @@ mail [option]... <command> [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: <original subject>`. 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.
Expand Down Expand Up @@ -46,3 +47,20 @@ mail [option]... <command> [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 <draft_id> sage@chorus@localhost --tags urgent
```

Reply to a message in your inbox (replies to the original sender, subject
defaults to `Re: <original subject>`):

```bash
mail reply <message_id> "Thanks, acknowledged."
mail reply <message_id> "See attached." --subject "Follow-up" --tags project-x
```
Loading
Loading