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
6 changes: 6 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -6,5 +6,11 @@
*.log
.env

# Mailu runtime: secrets, local network override, and state
deploy/mailu/mailu.env
deploy/mailu/docker-compose.override.yml
deploy/mailu/data/
deploy/mailu/certs/

# Claude Code local worktrees/state
.claude/
244 changes: 171 additions & 73 deletions cmd/agentbbs/main.go

Large diffs are not rendered by default.

36 changes: 36 additions & 0 deletions deploy/mailu/docker-compose.override.yml.example
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
# docker-compose.override.yml — copy to docker-compose.override.yml (gitignored).
# Compose loads this file automatically. It carries three fixes the trimmed base
# compose needs; see docs/mail.md for the full rationale.
networks:
default:
driver: bridge
ipam:
config:
# Mailu trusts SUBNET (mailu.env) as its internal network for
# service-to-service auth/relay; the real network MUST match it.
- subnet: 192.168.203.0/24
services:
# Mailu requires a DNSSEC-validating resolver or admin won't start.
resolver:
image: ghcr.io/mailu/unbound:2024.06
env_file: mailu.env
restart: always
networks:
default:
ipv4_address: 192.168.203.254
front: { dns: [192.168.203.254], depends_on: [resolver] }
admin: { dns: [192.168.203.254], depends_on: [resolver] }
imap:
dns: [192.168.203.254]
depends_on: [resolver]
# Publish Dovecot directly on loopback so the agentbbs gateway can use the
# master-user login (the front's nginx auth proxy rejects "<addr>*master").
# Plaintext is fine: the connection never leaves the host.
ports: ["127.0.0.1:14143:143"]
smtp: { dns: [192.168.203.254], depends_on: [resolver] }
antispam: { dns: [192.168.203.254], depends_on: [resolver] }
# In Mailu 2024.06 the webmail image is "webmail" (not "roundcube:2024.06").
webmail:
image: ghcr.io/mailu/webmail:2024.06
dns: [192.168.203.254]
depends_on: [resolver]
20 changes: 16 additions & 4 deletions deploy/mailu/mailu.env.example
Original file line number Diff line number Diff line change
@@ -1,15 +1,24 @@
# Mailu configuration for mail.profullstack.com — copy to deploy/mailu/mailu.env
# and fill the secrets. See docs/mail.md for the full setup (DNS, certs, gateway).
# Mailu configuration — copy to deploy/mailu/mailu.env and fill the secrets.
# See docs/mail.md for the full setup (DNS, certs, gateway).
#
# Generate secrets with: openssl rand -hex 16
#
# NOTE: DOMAIN is the member ADDRESS domain (the @-part); HOSTNAMES is the mail
# SERVER host (TLS/HELO + webmail/admin/API). These deliberately differ:
# members get <name>@bbs.profullstack.com, served from mail.profullstack.com.

# --- General -----------------------------------------------------------------
SECRET_KEY=CHANGEME_16_HEX # openssl rand -hex 16
DOMAIN=mail.profullstack.com # member addresses are <name>@mail.profullstack.com
DOMAIN=bbs.profullstack.com # member addresses are <name>@bbs.profullstack.com
HOSTNAMES=mail.profullstack.com,smtp.profullstack.com
POSTMASTER=postmaster
# Apex profullstack.com is reserved for corporate mail and is NOT served here.

# Admin REST API: agentbbs auto-provisions member mailboxes through it. Mirror
# this value into the agentbbs service as AGENTBBS_MAIL_API_TOKEN.
API=true
API_TOKEN=CHANGEME_api_token # openssl rand -hex 24

# TLS_FLAVOR=mail: Mailu does NOT run its own ACME (Caddy owns :80/:443). We feed
# it certs copied from Caddy's mail.profullstack.com cert (deploy/mailu/refresh-certs.sh).
TLS_FLAVOR=mail
Expand All @@ -32,13 +41,16 @@ MESSAGE_SIZE_LIMIT=52428800 # 50 MB
# A Dovecot master user lets the agentbbs gateway open any member's mailbox with
# one secret (login "<name>*<master>"). Created by deploy/mailu/provision-mailbox.sh.
# Mirror these into the agentbbs service env:
# AGENTBBS_MAIL_ADDR_DOMAIN=bbs.profullstack.com
# AGENTBBS_MAIL_DOMAIN=mail.profullstack.com
# AGENTBBS_MAIL_IMAP_ADDR=mail.profullstack.com:993
# AGENTBBS_MAIL_SMTP_ADDR=127.0.0.1:25
# AGENTBBS_MAIL_ADMIN_URL=http://127.0.0.1:8080
# AGENTBBS_MAIL_API_TOKEN=<the API_TOKEN above>
# AGENTBBS_MAIL_MASTER_USER=gateway
# AGENTBBS_MAIL_MASTER_PASS=<the master password you set>

# --- Admin bootstrap ---------------------------------------------------------
INITIAL_ADMIN_ACCOUNT=admin
INITIAL_ADMIN_DOMAIN=mail.profullstack.com
INITIAL_ADMIN_DOMAIN=bbs.profullstack.com
INITIAL_ADMIN_PW=CHANGEME_admin_password
3 changes: 2 additions & 1 deletion deploy/mailu/provision-mailbox.sh
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,8 @@
set -euo pipefail

MAILU_DIR="${MAILU_DIR:-/opt/agentbbs/deploy/mailu}"
DOMAIN="${MAIL_DOMAIN:-mail.profullstack.com}"
# The address domain (the @-part), which may differ from the mail server host.
DOMAIN="${MAIL_ADDR_DOMAIN:-${MAIL_DOMAIN:-bbs.profullstack.com}}"
MASTER_USER="${AGENTBBS_MAIL_MASTER_USER:-gateway}"
QUOTA_BYTES="${MAIL_QUOTA_BYTES:-1000000000}" # 1 GB

Expand Down
146 changes: 97 additions & 49 deletions docs/mail.md
Original file line number Diff line number Diff line change
@@ -1,16 +1,22 @@
# Mail — self-hosted Mailu at `mail.profullstack.com`
# Mail — self-hosted Mailu

AgentBBS gives **Founding Lifetime (paid) members** a real mailbox at
`<name>@mail.profullstack.com`, reached two ways:
AgentBBS gives **every verified member** (free and paid alike) a real mailbox at
`<name>@bbs.profullstack.com`, reached two ways:

- **Webmail** — `https://mail.profullstack.com` (Roundcube), the only
member-facing mail surface.
- **Webmail** — `https://mail.profullstack.com` (Roundcube).
- **AgentMail** — the in-BBS client (`internal/mailbox`): the `Mail` hub entry
or `ssh mail@bbs.profullstack.com` (a TUI for humans, a JSON bot mode for
agents). It connects to this stack.

Two distinct names are involved — don't conflate them:

| | value | role |
|---|---|---|
| **Address domain** | `bbs.profullstack.com` | the `@`-part of member addresses (`AGENTBBS_MAIL_ADDR_DOMAIN`) |
| **Mail server host** | `mail.profullstack.com` | where IMAP/SMTP/webmail actually run (`AGENTBBS_MAIL_DOMAIN`) |

The apex `profullstack.com` is **reserved for corporate mail** and is not served
here — member mail lives only on the `mail.` subdomain.
here.

## Architecture

Expand All @@ -19,97 +25,139 @@ Mailu (Postfix + Dovecot + Roundcube + rspamd) runs as a Docker Compose stack:

- Mailu owns the **mail ports** on the host: `25, 465, 587, 993, 995`.
- Mailu's HTTP front is bound to **loopback** (`127.0.0.1:8080`); **Caddy**
reverse-proxies `https://mail.profullstack.com` to it (webmail + admin).
reverse-proxies `https://mail.profullstack.com` to it (webmail + admin + API).
- **TLS:** `TLS_FLAVOR=mail` — Mailu does *not* run its own ACME (Caddy is the
only ACME client). Caddy obtains the `mail.profullstack.com` cert from its site
block; [`deploy/mailu/refresh-certs.sh`](../deploy/mailu/refresh-certs.sh)
copies it into Mailu and reloads it on renewal — the same pattern as the
Ergo/IRC and NNTP cert refreshers.
only ACME client). Caddy obtains the `mail.profullstack.com` cert; the cert
refresher copies it into Mailu and reloads on renewal.
- The **agentbbs gateway** reads/sends on behalf of members: IMAP via a Dovecot
**master user** (one secret opens any mailbox), SMTP via the co-located relay
on `127.0.0.1:25`. Members therefore never manage an IMAP/SMTP password.
- **Provisioning** is automatic: when a member verifies their email at `join@`
(or opens `Mail`), agentbbs ensures `<name>@bbs.profullstack.com` exists via
Mailu's **admin REST API** (`internal/mailu`, token = `API_TOKEN`). The manual
`deploy/mailu/provision-mailbox.sh` is only for the gateway master user and
backfills.

```
┌─────────── Caddy (:443) ───────────┐
webmail → │ mail.profullstack.com → 127.0.0.1:8080 (Mailu front, HTTP)
webmail → │ mail.profullstack.com → 127.0.0.1:8080 (Mailu front: webmail/admin/API)
└───────────────┬─────────────────────┘
│ copies LE cert (refresh-certs.sh)
clients → Mailu front (:25 :465 :587 :993 :995) ──→ Postfix / Dovecot / rspamd
agentbbs ──IMAP 993 (master user)──┘ ──SMTP 127.0.0.1:25 (local relay)──▶
agentbbs ──admin API (token) http://127.0.0.1:8080/api/v1──▶ (auto-provision)
```

## DNS

`mail.profullstack.com` and `smtp.profullstack.com` A records are added. Also set:
Mail is delivered to the **address domain** (`bbs.profullstack.com`), so its MX
must point at the **server host** (`mail.profullstack.com`):

| Type | Host | Value |
|---|---|---|
| A | `mail.profullstack.com` | host IP |
| A | `smtp.profullstack.com` | host IP |
| MX | `mail.profullstack.com` | `10 mail.profullstack.com.` |
| TXT (SPF) | `mail.profullstack.com` | `v=spf1 mx -all` |
| TXT (DMARC) | `_dmarc.mail.profullstack.com` | `v=DMARC1; p=quarantine; rua=mailto:postmaster@mail.profullstack.com` |
| TXT (DKIM) | `dkim._domainkey.mail.profullstack.com` | from `flask mailu config-export` after first boot |
| MX | `bbs.profullstack.com` | `10 mail.profullstack.com.` |
| TXT (SPF) | `bbs.profullstack.com` | `v=spf1 mx -all` |
| TXT (DMARC) | `_dmarc.bbs.profullstack.com` | `v=DMARC1; p=quarantine; rua=mailto:postmaster@bbs.profullstack.com` |
| TXT (DKIM) | `dkim._domainkey.bbs.profullstack.com` | from `flask mailu config-export` after first boot |
| PTR | host IP | `mail.profullstack.com` (set at your VPS provider) |

> **Port 25 / deliverability:** many cloud providers block outbound `:25` by
> default — request an unblock, set the PTR/rDNS, and warm the IP, or relay
> outbound through a smarthost. Inbound MX and the gateway's local submission
> work regardless.
> **Port 25 / deliverability:** many cloud providers (incl. DigitalOcean) block
> outbound `:25` by default — request an unblock, set the PTR/rDNS, and warm the
> IP, or relay outbound through a smarthost. Inbound MX and the gateway's local
> submission work regardless.

## Install

```bash
cd /opt/agentbbs/deploy/mailu
cp mailu.env.example mailu.env # fill SECRET_KEY, INITIAL_ADMIN_PW, etc.
cp mailu.env.example mailu.env # fill SECRET_KEY, INITIAL_ADMIN_PW, API_TOKEN, DOMAIN=bbs.profullstack.com, HOSTNAMES=mail.profullstack.com
docker compose up -d
# seed the gateway master user + (optionally) backfill member mailboxes:
# add the address domain + the gateway master user:
docker compose exec admin flask mailu domain bbs.profullstack.com
AGENTBBS_MAIL_MASTER_USER=gateway ./provision-mailbox.sh --master "$(openssl rand -hex 16)"
```

Add the Caddy site (setup.sh writes this when `MAIL=1`):

```
mail.profullstack.com {
encode zstd gzip
reverse_proxy 127.0.0.1:8080
}
```

Then install the cert refresher on a timer (setup.sh does this too):

```bash
install -m 0755 deploy/mailu/refresh-certs.sh /usr/local/bin/agentbbs-mailu-certs
# systemd timer runs it every ~12h; first run swaps in the real cert once Caddy issues it.
```
setup.sh writes the Caddy `mail.profullstack.com` site and the cert-refresh
timer when `MAIL=1`, and brings the stack up once `mailu.env` exists.

## agentbbs gateway env

Set these on the agentbbs service so the `Mail` hub entry / `ssh mail@` work:
Set these on the agentbbs service (setup.sh §9e upserts the non-secret ones):

| Var | Value |
|---|---|
| `AGENTBBS_MAIL_ADDR_DOMAIN` | `bbs.profullstack.com` |
| `AGENTBBS_MAIL_DOMAIN` | `mail.profullstack.com` |
| `AGENTBBS_MAIL_IMAP_ADDR` | `mail.profullstack.com:993` |
| `AGENTBBS_MAIL_IMAP_ADDR` | `127.0.0.1:14143` (Dovecot direct, loopback) |
| `AGENTBBS_MAIL_IMAP_PLAINTEXT` | `1` (the loopback path is plaintext) |
| `AGENTBBS_MAIL_SMTP_ADDR` | `127.0.0.1:25` |
| `AGENTBBS_MAIL_ADMIN_URL` | `http://127.0.0.1:8080` |
| `AGENTBBS_MAIL_API_TOKEN` | the Mailu `API_TOKEN` (secret) |
| `AGENTBBS_MAIL_MASTER_USER` | `gateway` |
| `AGENTBBS_MAIL_MASTER_PASS` | the master password set above |
| `AGENTBBS_MAIL_MASTER_PASS` | the master password set above (secret) |
| `AGENTBBS_WEBMAIL_URL` | `https://mail.profullstack.com` (default = mail host) |

Without `AGENTBBS_MAIL_API_TOKEN` auto-provisioning is skipped (the address is
still shown); without `AGENTBBS_MAIL_MASTER_PASS` the gateway can't open
mailboxes.

### Why the gateway talks to Dovecot directly (plaintext loopback)

Mailu's **front** (nginx mail proxy) pre-authenticates every IMAP/SMTP login
against Mailu's user DB before proxying to Dovecot — and it rejects the Dovecot
master-user login form `<addr>*gateway`. So the gateway must reach **Dovecot
directly**, bypassing the front. The `imap` container has no TLS cert (only the
front does), so the bypass is plaintext over loopback — safe because the
connection (and the master password) never leave the host. Wiring:

- Publish Dovecot's IMAP on loopback (docker-compose.override.yml):
`imap.ports: ["127.0.0.1:14143:143"]`.
- The Dovecot master user is defined in `data/overrides/dovecot/dovecot.conf`
(Mailu includes exactly that filename — *not* `*.conf`):

```
auth_master_user_separator = *
passdb { driver = passwd-file; master = yes; args = /overrides/master-users }
```

with `data/overrides/dovecot/master-users` holding `gateway:{SHA512-CRYPT}$6$…`
(the hash of `AGENTBBS_MAIL_MASTER_PASS`). The file must be **world-readable
(644)** — Dovecot reads it as a non-root user, and 640 root:root yields a
`temp_fail`. Do **not** add `result_success = continue` (that would also
require the target user's own password); the target mailbox comes from userdb.
- Point the gateway at it: `AGENTBBS_MAIL_IMAP_ADDR=127.0.0.1:14143` +
`AGENTBBS_MAIL_IMAP_PLAINTEXT=1`.

## Sending mail from the BBS (verify codes + notifications)

The join@ verification code and signup notifications use `internal/mail` (the
`AGENTBBS_SMTP_*` knobs), separate from the per-member mailbox client. Point
them at the local Mailu relay so codes actually send:

```
AGENTBBS_SMTP_HOST=127.0.0.1
AGENTBBS_SMTP_PORT=25
AGENTBBS_SMTP_FROM=bbs@bbs.profullstack.com
# user/pass omitted: the co-located relay accepts local submission unauthenticated
```

## Provisioning member mailboxes

A mailbox must exist before the gateway can open it. Provision when a member
becomes paid (or backfill):
Provisioning is automatic at `join@` verification. To create or backfill by hand:

```bash
deploy/mailu/provision-mailbox.sh alice # creates alice@mail.profullstack.com
deploy/mailu/provision-mailbox.sh alice # creates alice@bbs.profullstack.com
```

The Dovecot **master user** (`gateway`) then authenticates as any member with
the login form `alice*gateway` + the master password — which is exactly what
(Set `MAIL_DOMAIN=bbs.profullstack.com` for the script, since the address domain
differs from the server host.)

The Dovecot **master user** (`gateway`) authenticates as any member with the
login form `alice*gateway` + the master password — exactly what
`internal/mailbox`'s IMAP adapter sends. See
[`deploy/mailu/README.md`](../deploy/mailu/README.md) for the master-user
override and operational details.
[`deploy/mailu/README.md`](../deploy/mailu/README.md) for details.

## Webmail only for members

Expand Down
23 changes: 13 additions & 10 deletions internal/mailbox/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,16 +7,19 @@ import (
"strings"
)

// Identity is the acting member and whether they hold the paid membership.
// Identity is the acting member. AgentMail is a free benefit of membership, so
// having a registered handle is the only requirement; Paid is retained for
// tier-aware features (e.g. quotas) but no longer gates access.
type Identity struct {
Name string // local-part / handle, e.g. "alice"
Paid bool // Founding Lifetime Member; mail is gated on this
Paid bool // Founding Lifetime Member (informational; does not gate mail)
}

// ErrNotPaid is returned to a non-paid member attempting a mail action.
var ErrNotPaid = errors.New("AgentMail is a Founding Lifetime Member feature ($99 one-time) — upgrade: ssh join@bbs.profullstack.com")
// ErrNotMember is returned when a caller without a registered handle attempts a
// mail action. AgentMail is open to every verified member.
var ErrNotMember = errors.New("AgentMail is a member feature — register first: ssh join@bbs.profullstack.com")

// Client is the ergonomic, paid-gated facade the TUI and bot mode use. Every
// Client is the ergonomic, member-gated facade the TUI and bot mode use. Every
// method returns plain structs, so the same calls serve humans and agents.
type Client struct {
t Transport
Expand All @@ -25,21 +28,21 @@ type Client struct {
pageSize int
}

// NewClient builds a paid-gated client. domain is the mail domain (e.g.
// mail.profullstack.com); pageSize defaults to 50 when <= 0.
// NewClient builds a member-gated client. domain is the email address domain
// (e.g. bbs.profullstack.com); pageSize defaults to 50 when <= 0.
func NewClient(t Transport, id Identity, domain string, pageSize int) *Client {
if pageSize <= 0 {
pageSize = 50
}
return &Client{t: t, id: id, domain: domain, pageSize: pageSize}
}

// Address is the member's own mailbox address, e.g. alice@mail.profullstack.com.
// Address is the member's own mailbox address, e.g. alice@bbs.profullstack.com.
func (c *Client) Address() string { return c.id.Name + "@" + c.domain }

func (c *Client) gate() error {
if c.id.Name == "" || !c.id.Paid {
return ErrNotPaid
if c.id.Name == "" {
return ErrNotMember
}
return nil
}
Expand Down
11 changes: 10 additions & 1 deletion internal/mailbox/imap.go
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,11 @@ type IMAPConfig struct {
// SMTPUser/SMTPPass default to Username/Password when empty.
SMTPUser string
SMTPPass string
// Plaintext dials IMAP without TLS. Used only for a co-located backend over
// loopback (the Mailu gateway hitting Dovecot directly on 127.0.0.1, bypassing
// the front's auth proxy so master-user login works) — the password never
// leaves the host. Never enable it for a remote server.
Plaintext bool
}

// imapTransport is a Transport backed by a single authenticated IMAP connection
Expand All @@ -38,7 +43,11 @@ type imapTransport struct {

// NewIMAPTransport dials the IMAP server, logs in, and returns a Transport.
func NewIMAPTransport(cfg IMAPConfig) (Transport, error) {
c, err := imapclient.DialTLS(cfg.IMAPAddr, nil)
dial := imapclient.DialTLS
if cfg.Plaintext {
dial = imapclient.DialInsecure
}
c, err := dial(cfg.IMAPAddr, nil)
if err != nil {
return nil, fmt.Errorf("imap dial %s: %w", cfg.IMAPAddr, err)
}
Expand Down
Loading
Loading