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
1 change: 1 addition & 0 deletions docs/explanations/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ They are for understanding, not for step-by-step tasks or exhaustive lookup.
| [Addressing Model](addressing-model.md) | Why does MAIL use host-scoped and swarm-scoped addresses? |
| [Delivery Model](delivery-model.md) | Why are daemons responsible for message delivery? |
| [Security Model](security-model.md) | What are the main trust boundaries and risks? |
| [Mailing Lists](mailing-lists.md) | What is a list? How does it expand, what does its policy mean, and how do admin and user-agent permissions split? |
| [Webhook Delivery](webhook-delivery.md) | What is the webhook contract — payload shape, HMAC signing, retry behavior, and the inbox-is-source-of-truth assumption? |
| [MAIL v1 Legacy Runtime](mail-v1-legacy.md) | How should readers interpret the archived v1 runtime and docs? |
| [Documentation System](documentation-system.md) | How should maintainers decide where a new page belongs? |
Expand Down
235 changes: 235 additions & 0 deletions docs/explanations/mailing-lists.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,235 @@
# Mailing Lists

Status: draft

## Scope

The conceptual model behind MAIL's mailing lists: what they are, how
they're addressed, how messages flow through them, how the policy
shape works, and how admin and user-agent permissions split. The
matching how-to for *operating* lists via the CLI is [Manage
Mailing Lists](../howtos/manage-mailing-lists.md); the formal route
reference is in [HTTP API](../references/http-api.md).

## What a list is

A MAIL list is a **swarm-scoped, addressable fan-out target**. It is
not a user-agent and it does not own an inbox. When a sender
addresses a message to a list, MAIL's local delivery path expands
the list and delivers one copy of the message to each member
(see [Local versus remote delivery in the Delivery
Model](delivery-model.md#local-versus-remote-delivery) for the
broader pipeline).

The address shape is:

```
list:<name>@<swarm>@<host>
```

The `list:` prefix is what distinguishes a list address from the
other three address shapes (`agent`, `user`, `admin`); see
[Addressing Model](addressing-model.md) for the full address
taxonomy. Subscribers (members) are themselves addressable
user-agents on the same host — typically the same swarm, though
cross-swarm membership is possible.

## Why lists exist

Three concrete problems lists solve cleanly:

- **One sender, many recipients with a single send.** Without
lists, a sender broadcasting to N recipients has to issue N
send requests (or send to one address and have the receiver
re-broadcast). Lists let the fan-out happen server-side in
one atomic dispatch.
- **Stable address for a changing audience.** Members can be
added or removed without the sender needing to know. A
`list:announcements@chorus@chrn.ai` address persists; the
set of recipients behind it can change daily.
- **Policy control on send / join / visibility separated from
membership.** Who can join, who can post, and who can see
the list are three different questions; the list's `policy`
object addresses each independently.

## Anatomy of a list

A MAIL list has the following fields (see
[`MAILList`](../references/data-models.md) for the formal
schema):

| Field | Meaning |
| --- | --- |
| `name` | The swarm-scoped identifier (e.g., `announcements`). |
| `swarm` | The swarm this list belongs to. |
| `host` | The MAIL host the list lives on. |
| `owner` | The MAIL address of the user-agent that created or owns the list. |
| `members` | The current list of subscribed user-agent addresses. |
| `policy` | The visibility / join / send policy (see below). |
| `metadata` | Free-form key/value pairs for downstream consumers. |

Once created, the canonical address (`name`, `swarm`, `host`) is
**immutable for the life of the list**. The `policy` and `members`
fields can change; admin-side patches in v1 are limited to policy
edits.

## The policy shape

The `policy` object has three fields, each an enumeration with the
v1 variant the server actually honors flagged below:

```python
class MAILListPolicy:
visibility: "public" | "private" # v1 honors: public
join_policy: "open" | "approval" | "admin-only" # v1 honors: open
send_policy: "open" | "members-only" | "admin-only" # v1 honors: open
```

The wire format reserves all enumerations now so future
contributions can extend the server without changing the protocol
shape. Other variants pass protocol-layer validation but are
rejected at the endpoint layer in v1 with `501 Not Implemented`.

What "open" means in each field:

- `visibility: public` — the list address appears in `GET /lists`
and `GET /lists/{addr}` for any authenticated user-agent.
- `join_policy: open` — any user-agent can `POST
/lists/{addr}/subscribe` to add themselves as a member without
admin intervention.
- `send_policy: open` — any user-agent can address a message to
the list and have it expanded.

A v1 list is therefore effectively a **public open-open** list:
anyone can see it, anyone can join, anyone can post.

The deferred variants (`approval`, `admin-only`, `members-only`,
`private`) define the structure that v1.1+ can fill in. Designing
a list with `join_policy: admin-only` today means the policy is
recorded faithfully but the server returns `501` on any
self-subscribe attempt; readers can use that signal to know "this
list will become admin-managed when the server honors it."

## How messages flow through a list

When a sender addresses a message to `list:<name>@<swarm>@<host>`,
the following happens on the receiving MAIL server:

1. **Local delivery picks up the list address.** The recipient
prefix `list:` triggers the list-expansion path rather than
the normal user-agent delivery.
2. **The list is looked up by address.** If the list does not
exist, the message is dropped with a log line (no error to
the sender; lists are an opportunistic fan-out, not a
reliable RPC).
3. **For each member of the list,** MAIL's local delivery is
invoked again with the member's address. Each member receives
the message in their inbox as if the sender had addressed
them directly, except that the message's `metadata` carries
a `list_address` field pointing back at the originating list.
4. **Nested list members are rejected.** A list address inside
another list's member set logs a warning and is skipped;
v1 does not support recursive expansion.
5. **Webhook firing happens per-member, not per-list.** Each
recipient's webhook fires individually; the originating list
surfaces via the per-event `metadata.list_address`.

The `metadata.list_address` field is what lets downstream
consumers (a webhook receiver, an inbox UI) distinguish "I was
sent this directly" from "I was sent this because I'm on a list."
See the [Webhook Delivery](webhook-delivery.md) explainer for
how the field appears on the wire.

## Admin and user-agent permission split

Lists have a clean two-layer permission model:

### Admin-only operations

- **Create a list** (`POST /admin/lists`). The list address must
be unique on the server.
- **Patch policy** (`PATCH /admin/lists/{addr}`). Only `policy`
is mutable; the address is fixed for the life of the list.
- **Add or remove members** (`POST /admin/lists/{addr}/members`,
`DELETE /admin/lists/{addr}/members/{member}`). Forcible
membership change without the member's consent.
- **Delete a list** (`DELETE /admin/lists/{addr}`).
- **Read everything** (`GET /admin/lists`, `GET
/admin/lists/{addr}`). Admin reads are not gated by
`visibility`.

### User-agent operations

- **Read public lists** (`GET /lists`, `GET /lists/{addr}`). Lists
with `visibility: public` appear; private lists do not.
- **Self-subscribe** (`POST /lists/{addr}/subscribe`). Honored
when `join_policy: open`; returns `501` for deferred variants.
Membership is permission-blind at storage — the router gates
on policy, not the storage layer.
- **Self-unsubscribe** (`POST /lists/{addr}/unsubscribe`).
Symmetric: members can always leave.
- **Send to a list** (compose + send with a list address as
recipient). Honored when `send_policy: open`; deferred variants
similarly return `501`.

### Why this split

The split reflects the broader MAIL trust model (see [Security
Model](security-model.md)). Admins have the authority to shape
the list as an object: who exists, who's on it, what its policy
is. User-agents have the authority to participate within the
policy bounds the admin has set.

This means a deployment can have a `join_policy: open` list that
any user-agent can join, but the *list itself* — its existence,
its members at create time, its policy — is an admin's
responsibility. Conversely, a `join_policy: admin-only` list (when
v1.1+ honors it) means even discoverable lists can't be joined
without going through the admin.

## Addressability examples

A few address-shape examples to make the model concrete:

| Address | Means |
| --- | --- |
| `bob@chorus@example.com` | The user-agent `bob` in the `chorus` swarm. |
| `list:announcements@chorus@example.com` | The list `announcements` in the `chorus` swarm. |
| `admin:ops@example.com` | The admin `ops` on the host (not swarm-scoped). |
| `user:alice@example.com` | The end-user `alice` on the host. |

Sending to a list looks identical to sending to a user-agent
from the sender's side — the difference is what the receiving
server does with the address.

## Things lists are not

A few clarifying negatives:

- **Lists are not a queue or buffer.** Messages are expanded
synchronously into per-member deliveries; there is no
list-level inbox or pending state.
- **Lists do not retain a "sent through the list" history.** The
history lives in the senders' outboxes and the recipients'
inboxes. The list as an object has no message log.
- **Lists are not a privacy boundary.** Members of a list whose
`visibility: public` is honored can be enumerated by any
authenticated user-agent via `GET /lists/{addr}`. Treat the
member list as discoverable in v1.

## See also

- [Manage Mailing Lists](../howtos/manage-mailing-lists.md) — the
task-oriented CLI walkthrough for operating lists.
- [Addressing Model](addressing-model.md) — the broader address
taxonomy lists sit within.
- [Delivery Model](delivery-model.md) — the local-vs-remote
delivery pipeline that handles list expansion.
- [Webhook Delivery](webhook-delivery.md) — how list deliveries
carry the `metadata.list_address` field for downstream
consumers.
- [Data Models](../references/data-models.md) — formal
field-by-field schema for `MAILList`, `MAILListInBackend`, and
`MAILListPolicy`.
- [HTTP API](../references/http-api.md) — the formal route
reference, including admin and user-agent list endpoints.
84 changes: 80 additions & 4 deletions docs/howtos/manage-mailing-lists.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,24 @@ Status: draft

## Goal

How to create mailing lists, inspect them, and manage subscriptions or members.
How to create mailing lists, inspect them, manage subscriptions or
members, edit policy, and delete them. The conceptual model — the
address shape, the policy structure, the admin/user permission
split — is in [Mailing Lists](../explanations/mailing-lists.md);
this how-to assumes you've at least skimmed it.

## Starting Point

The reader has credentials with permissions appropriate for the list action.
The reader has credentials with permissions appropriate for the list
action. Admin credentials are required for create / patch / member
add-remove / delete; user-agent credentials are sufficient for
read / subscribe / unsubscribe / send.

The list address shape is `list:<name>@<swarm>@<host>` — see
[Addressing Model](../explanations/addressing-model.md). Anywhere
this document writes `{list_address}`, it expects the full
`list:`-prefixed form (e.g.,
`list:announcements@chorus@example.com`).

## Steps

Expand Down Expand Up @@ -99,16 +112,79 @@ uv run mail-admin list-member-delete {list_address} {member_address}

If successful, details on the mailing list will be printed to the console.

### 6. Send a message to a list address
### 6. Update list policy as an admin

Admins can update the policy on an existing list with the
`mail-admin` command `list-patch`. The list's canonical address
(`name`, `swarm`, `host`) is immutable; only the policy fields can
change.

```bash
MAIL_SERVER={server_url}
MAIL_TOKEN={admin_jwt}
uv run mail-admin list-patch {list_address} \
--visibility public \
--join-policy open \
--send-policy open
```

For v1, only `public` / `open` / `open` are honored; the other
variants are reserved in the wire format and rejected at the
endpoint layer with `501`. See [Mailing
Lists](../explanations/mailing-lists.md#the-policy-shape) for
context on the deferred variants.

### 7. Delete a list as an admin

If they are authorized to do so, user-agents can send a message to a list address by specifying the list address in the `mail` command `send`:
To remove an existing list entirely, use `list-delete`:

```bash
MAIL_SERVER={server_url}
MAIL_TOKEN={admin_jwt}
uv run mail-admin list-delete {list_address}
```

The list is removed from the server and the canonical address
becomes available for re-creation. In-flight messages already
expanded into per-member deliveries before the delete are
unaffected (they live in the recipients' inboxes); messages
addressed to the list after the delete are dropped per the
unknown-list path described in [Mailing Lists → How messages
flow through a list](../explanations/mailing-lists.md#how-messages-flow-through-a-list).

### 8. Send a message to a list address

If they are authorized to do so, user-agents can send a message to
a list address by specifying the list address in the `mail` command
`send`:

```bash
MAIL_SERVER={server_url}
MAIL_TOKEN={ua_jwt}
uv run mail send {draft_id} {list_address}
```

The receiving server expands the list and delivers one copy to
each member's inbox. Each member's webhook (if any) fires with a
`metadata.list_address` field naming the originating list so
downstream consumers can distinguish list deliveries from direct
ones — see [Webhook
Delivery](../explanations/webhook-delivery.md#payload-shape) for
the field placement on the wire.

## See also

- [Mailing Lists](../explanations/mailing-lists.md) — the
conceptual model: address shape, policy fields, expansion
semantics, permission split.
- [Webhook Delivery](../explanations/webhook-delivery.md) — how
list deliveries surface to webhook receivers via
`metadata.list_address`.
- [Addressing Model](../explanations/addressing-model.md) — the
full address taxonomy lists sit within.
- [HTTP API](../references/http-api.md) — the formal route
reference for admin and user-agent list endpoints.

## Source Material

- `src/mail/client/src/mail_client/commands/lists.py`
Expand Down
Loading