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
97 changes: 97 additions & 0 deletions RELEASE_NOTES.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
# Release notes

## Unreleased — multi-domain support

This release adds **light multi-domain support**: one AIMX install can
host multiple sending and receiving domains. Each domain has its own
DKIM keypair, its own catchall, and its own mailboxes. The first entry
in `domains` is the default — bare local parts resolve against it.

The change preserves the single-binary, single-operator model. There
are no new multi-tenant features (no per-domain ACLs, no per-domain
rate limits, no hosted-service surface).

### What changes on first restart after upgrade

The upgrade migration runs **atomically on the first `aimx serve`
startup under the new binary**. It is idempotent (guarded by
`/var/lib/aimx/.layout-version`) — subsequent restarts are no-ops.

For an existing single-domain install on `mydomain.com`:

1. **`config.toml` is visibly rewritten** from the v1 shape to the
normalized multi-domain shape:
- `domain = "mydomain.com"` → `domains = ["mydomain.com"]`
- `[mailboxes.info]` → `[mailboxes."info@mydomain.com"]` (every
local-part-keyed mailbox is re-keyed to its FQDN)
- Per-domain sub-tables (`[domain."<d>"]`) remain absent until you
add an override.

2. **Storage relocates** from
`/var/lib/aimx/{inbox,sent}/<mailbox>/` to
`/var/lib/aimx/<domain>/{inbox,sent}/<mailbox>/`. The renames use
`rename(2)` — same-filesystem, constant time, atomic.

3. **DKIM keys relocate** from `/etc/aimx/dkim/{private,public}.key`
to `/etc/aimx/dkim/<domain>/{private,public}.key`. The key
material is unchanged; only the on-disk path moves.

4. A `.layout-version` marker is written so the migration runs
exactly once.

The change is **purely structural** — there are no semantic
differences for single-domain installs. Inbound continues to route to
the same mailboxes, outbound continues to sign with the same DKIM
key, hooks continue to fire the same way. `aimx mailboxes list` and
the MCP `mailbox_list` tool return FQDN names
(`info@mydomain.com`) instead of bare local parts — that's the only
observable difference at the API boundary.

`aimx upgrade` prints a one-screen reminder of these points before
completing.

### What's new

- `aimx domains list` — print configured domains with DKIM status and
per-domain mailbox counts.
- `aimx domains add <domain>` — append a domain to `domains`,
generate a DKIM keypair, print DNS records, verify, hot-reload.
- `aimx domains remove <domain> [--force]` — remove a domain, with
cascade-delete via `--force`. Last-domain hard-block; DKIM keys
preserved on disk.
- `aimx dkim-keygen --domain <domain>` — generate or rotate keys for
a specific domain.
- Per-domain config sub-tables: `[domain."<domain>"]` supports
optional `signature`, `dkim_selector`, `trust`, `trusted_senders`
overrides. Resolution order is per-mailbox → per-domain → global.
- Per-domain DKIM signing: outbound signs with the From: domain's
key, never `domains[0]`'s.
- Per-domain catchall: `*@<domain>` is independent per domain.
- `aimx doctor` reports per-domain DKIM, mailbox counts, and unread
counts; marks the default domain.
- MCP tools (`mailbox_list`, `email_list`, etc.) return and accept
FQDN mailbox names. Bare local parts still resolve to the default
domain for backward compatibility.

### Rollback

Rollback to a pre-multi-domain binary is documented in
[`book/multi-domain.md`](book/multi-domain.md#rollback-procedure).
Short version: stop the daemon, move storage and DKIM keys back to
the v1 paths, hand-edit `config.toml` back to the v1 shape, delete
`/var/lib/aimx/.layout-version`, install the older binary, restart.
The procedure is mechanical and lossless if you're still on a single
domain and haven't added a second domain since the upgrade.

### Where to go next

- [`book/multi-domain.md`](book/multi-domain.md) — full operator
reference (CLI, per-domain config, DKIM, storage, upgrade
migration, rollback).
- [`book/cli.md#domain-management`](book/cli.md#domain-management) — `aimx
domains list / add / remove` reference.
- [`book/troubleshooting.md#multi-domain`](book/troubleshooting.md#multi-domain)
— corrupted marker, EXDEV, half-migrated state,
DKIM-key-not-found.
- [`book/faq.md#multi-domain`](book/faq.md#multi-domain) — quick
answers.
84 changes: 64 additions & 20 deletions agents/common/aimx-primer.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@ For full reference material, see the files in `references/`:
- `references/frontmatter.md`: complete frontmatter schema
- `references/workflows.md`: worked examples for common tasks
- `references/hooks.md`: creating hooks via MCP (mailbox-owner model)
- `references/multi-domain.md`: multi-domain installs — default domain,
FQDN disambiguation, sending from a non-default domain
- `references/troubleshooting.md`: error codes and recovery steps

At runtime, `/var/lib/aimx/README.md` is the authoritative guide to the data
Expand All @@ -22,9 +24,13 @@ startup.
Your interface to aimx is the MCP tools (`mailbox_list`, `email_send`,
`email_reply`, `mailbox_create`, `hook_create`, …). The `aimx` binary on
PATH is the host operator's CLI — do not invoke it. **You never need to
know the configured domain**: `email_send(from_mailbox: "agent", ...)`
takes the local part only and the daemon constructs `agent@<domain>` from
`mailbox_list().address` server-side.
know the configured domain on a single-domain install**:
`email_send(from_mailbox: "agent", ...)` takes the local part only and
the daemon constructs `agent@<domain>` from `mailbox_list().address`
server-side. On a multi-domain install, bare local parts resolve to the
**default domain** (the first entry in `domains`); to send from a
non-default domain, pass the full FQDN (`from_mailbox:
"agent@side-project.com"`). See `references/multi-domain.md`.

If the `mcp__aimx__*` tools (or your client's equivalent) are not in your
tool list, the MCP server is not registered. Tell the user to run
Expand Down Expand Up @@ -91,6 +97,34 @@ On a single-user box (the common case) the model is invisible: your
one user owns every mailbox. On a multi-user box it gives real
isolation — alice's agent cannot see, read, or act on bob's mail.

## Multi-domain installs

aimx may be configured with one domain or several. The list lives in
`config.toml`'s `domains` array; the **first entry is the default**.
Two rules govern how you address mailboxes:

1. **Bare local parts default to the default domain.** Passing
`from_mailbox: "agent"` to `email_send` (or `mailbox: "agent"` to any
mailbox-scoped tool) resolves server-side to `agent@<domains[0]>`.
This is the single-domain behavior preserved on multi-domain
installs.
2. **To target a non-default domain, pass the FQDN.** On an install with
`domains = ["a.com", "b.com"]`, an agent that owns
`support@b.com` sends with `from_mailbox: "support@b.com"`. The
daemon picks the b.com DKIM key and the message is signed as b.com.

`mailbox_list()` returns FQDN names unambiguously
(`{name: "support@b.com", address: "support@b.com", ...}`). Don't
strip the `@<domain>` suffix when threading the name through subsequent
tool calls — `mailbox: "support"` on a multi-domain install where both
`support@a.com` and `support@b.com` exist would silently target the
default-domain mailbox. The FQDN is the unambiguous identifier.

Domain management itself (adding or removing a domain, generating a
DKIM keypair for a new domain) is operator-only and requires `sudo`. No
MCP tools exist for domain CRUD — see `references/multi-domain.md` for
why and how operators do it.

## MCP tools: quick reference

All 11 tools are served by the `aimx` binary over stdio. They return
Expand Down Expand Up @@ -180,28 +214,33 @@ argv to use.
`0700 <owner>:<owner>` perms enforced by the daemon, not filesystem
obscurity. -->

aimx stores mail under a data directory (default `/var/lib/aimx/`):
aimx stores mail under a data directory (default `/var/lib/aimx/`),
nested by domain:

```
/var/lib/aimx/ # root:root 0755 (traversable)
├── README.md # agent-facing layout guide (auto-generated)
├── inbox/ # root:root 0755
│ ├── <mailbox>/ # <owner>:<owner> 0700
│ │ ├── 2026-04-15-143022-meeting-notes.md
│ │ └── 2026-04-15-153300-invoice-march/ # attachment bundle
│ │ ├── 2026-04-15-153300-invoice-march.md
│ │ ├── invoice.pdf
│ │ └── receipt.png
│ └── catchall/ # aimx-catchall:aimx-catchall 0700
│ └── ...
└── sent/ # root:root 0755
└── <mailbox>/ # <owner>:<owner> 0700
└── 2026-04-15-160145-re-meeting-notes.md
├── .layout-version # migration marker (do not edit)
└── <domain>/ # one per configured domain, 0755
├── inbox/ # root:root 0755
│ ├── <mailbox>/ # <owner>:<owner> 0700
│ │ ├── 2026-04-15-143022-meeting-notes.md
│ │ └── 2026-04-15-153300-invoice-march/ # attachment bundle
│ │ ├── 2026-04-15-153300-invoice-march.md
│ │ ├── invoice.pdf
│ │ └── receipt.png
│ └── catchall/ # aimx-catchall:aimx-catchall 0700
│ └── ...
└── sent/ # root:root 0755
└── <mailbox>/ # <owner>:<owner> 0700
└── 2026-04-15-160145-re-meeting-notes.md
```

Each mailbox directory is `0700 <owner>:<owner>`, so only the owner and
root can read or traverse in. Your MCP process runs as your uid and only
sees mailboxes you own.
sees mailboxes you own. `mailbox_list()` returns absolute `inbox_path` /
`sent_path` values that already include the per-domain nesting — use
them verbatim with filesystem tools instead of reconstructing paths.

- **Filenames** follow `YYYY-MM-DD-HHMMSS-<slug>.md` (UTC). The slug is
derived from the subject: lowercase, non-alphanumeric chars replaced with
Expand All @@ -218,12 +257,17 @@ not readable by agents):

```
/etc/aimx/
├── config.toml # main config (root:root 640)
├── config.toml # main config (root:root 640)
└── dkim/
├── private.key # DKIM signing key (root:root 600)
└── public.key # publishable (root:root 644)
└── <domain>/ # one per configured domain (root:root 700)
├── private.key # DKIM signing key (root:root 600)
└── public.key # publishable (root:root 644)
```

Each configured domain has its own DKIM keypair under
`/etc/aimx/dkim/<domain>/`. The daemon picks the right key based on the
From: domain of each outbound message.

## Frontmatter: key fields

Each email file has TOML frontmatter between `+++` delimiters. The fields
Expand Down
Loading
Loading