# M6.4 — Panel Hostname as Primary Mail Domain

**Objective**: Make `<panel-hostname>` a first-class email domain so `admin@<panel-hostname>` works on every fresh install, the panel's self-signed cert covers `mail.<panel-hostname>`, and `{panel-hostname}/webmail` bounces to the Bulwark webmail at `https://mail.<panel-hostname>/`.

**Scope**: install-time auto-registration of a single `domains` row marked `is_panel_primary=1` (detected by column, NOT by hostname equality — hostnames can change); self-signed panel-cert SAN expansion to include `mail.<panel-hostname>`; a `/webmail` 301 location in the existing default nginx vhost; a read-only "Primary Mail Domain" card in the Settings → Email panel UI.

**Not in scope** (enumerated to head off scope creep):
- Multi-hostname panels. One panel = one primary mail domain.
- Public MX/LE cert for `.local` hostnames — self-signed stays self-signed.
- Admin mailbox auto-creation (`admin@<panel-hostname>`). The operator creates their own mailbox via the existing Mailboxes UI once the domain is up.
- Migrating the phpMyAdmin `/phpmyadmin/` location out of the default vhost.
- Changing how regular customer domains handle email (they keep the M6 path unchanged).

**Branch**: `m6.4/panel-hostname-mail-domain` (integration branch; sub-steps land via `m6.4/panel-hostname-mail-domain/<step-slug>` PRs; umbrella merges to `main` only after Step 6 green on the test VM).

**Anchors** (verified 2026-04-22 against `origin/main @ 1e5b385`):
- Self-zone bootstrap: `install.sh:1827 bootstrap_pdns_self_zone()` inserts SOA/NS/A rows into `jabali_pdns.domains`/`records` for `$JABALI_SRV_HOSTNAME`. Runs once per fresh install; skips if the domain row exists. Does NOT currently insert `mail.<hostname>` A, MX, SPF, DKIM, or DMARC records — those are per-customer-domain, fired by the reconciler on `email_enabled` transition.
- Panel self-signed cert: `install.sh:2112 provision_tls_cert()` runs `openssl req -x509` with `subjectAltName=DNS:<hostname>,DNS:localhost,IP:127.0.0.1,IP:<iface-ip>`. Skips if `/etc/jabali/tls/panel.crt` already exists; no SAN regeneration path today.
- Default nginx vhost: `install.sh:2475` writes `/etc/nginx/sites-available/jabali-default.conf` with the `:443 default_server` block that includes `phpmyadmin.conf`. This is the surface for the `/webmail` redirect — same file, same pattern.
- Per-domain mail vhost template: `install/nginx/jabali-mail-vhost.conf.tmpl` rendered by the agent's `webmail.vhost_apply` command. Writing `mail.<panel-hostname>` through this path requires only that the `domains` row for the panel hostname has `email_enabled=1` — the reconciler + agent do the rest.
- Domain model: `panel-api/internal/models/domain.go:127 type Domain struct` — fields `EmailEnabled` (L215), `DkimSelector`/`DkimPublicKey`/`EmailEnabledAt` (L216-218) already exist from M6. M6.4 adds one new column `is_panel_primary BOOLEAN NOT NULL DEFAULT 0` via migration 000057.
- Latest migration on main: `000056_mailbox_sso`. Next number: `000057`. Audit rule per `feedback_merge_audit_migrations.md`: Step 3's ALTERs must reference tables that already exist on main.
- Reconciler email loop: `panel-api/internal/reconciler/reconciler.go` calls `domain.email_enable` on any `email_enabled=1 && DkimSelector IS NULL` domain (first-time provisioning path — DKIM keypair gen, MX/SPF/DMARC TXT rows, Stalwart domain add, nginx mail vhost apply). The panel-primary row naturally falls into this loop on first tick post-install.
- Delete protection: repository layer at `panel-api/internal/repository/domain_repository.go` — add a guard in `Delete(ctx, id)` that rejects when `is_panel_primary=1`, returning a structured error the handler translates to 403.
- Panel UI Settings: `panel-ui/src/routes/admin/settings/*` (check actual path). New card: "Primary Mail Domain" with hostname + webmail link + DKIM-published status. Read-only — no edit affordance.

**Version pins** (none new; all dependencies already on main):
- Stalwart v0.16.0 (M6)
- Bulwark 1.4.14 (M6)
- pdns-recursor 5.2.8 (M6.3)
- bcrypt (via crypto/bcrypt)

---

## Workflow conventions

1. **Every step is a sub-branch PR into `m6.4/panel-hostname-mail-domain`**. Sub-branches: `m6.4/panel-hostname-mail-domain/<step-slug>`. The umbrella never receives direct commits. Only after Step 6 green + VM smoke does the umbrella fast-forward into `main`.
2. **Impact analysis before editing any symbol**: `mcp__gitnexus__impact({target, direction:"upstream", repo:"jabali2"})`. If HIGH/CRITICAL, stop and report before proceeding.
3. **`mcp__gitnexus__detect_changes()` before committing** — scope must match the step's declared touchpoints.
4. After every Go-touching step: `go build ./...` AND `go test -race ./...` green before PR.
5. After every `install.sh`-touching step: `shellcheck install.sh` clean AND a dry-run on the test VM (192.168.100.150). The logger contract is `_log/_ok/_warn/_err` only — grep first, never invent (per `feedback_install_sh_logger.md`).
6. **Rebase sub-branch onto latest `origin/main` before opening PR; re-run tests post-rebase.** Dispatcher policy (per `feedback_fetch_rebase_before_deploy.md`).
7. Don't push to `main` from sub-branches. Only the umbrella goes to main, only after the human dispatcher approves (no "partial blueprint to main" per `feedback_no_partial_blueprint_to_main.md`).
8. Every step's verification list includes "ran against origin/main's current tip" — catches sneak-through schema/migration conflicts from parallel work.

---

## Dependency graph

```
Step 1 (ADR-0048 + BLUEPRINT)  ──┐
                                 ├─► Step 5 (nginx /webmail + UI card) ──┐
Step 2 (migration + model + repo)┤                                       │
                                 ├─► Step 3 (install.sh: cert SAN +      │
                                 │          self-domain insert) ─────────┤
                                 │                                       │
                                 └─► Step 4 (API: delete-protect +       │
                                            settings endpoint) ──────────┼─► Step 6 (runbook + merge)
                                                                         │
```

- **Parallel batch A** (dispatchable immediately against origin/main):
  - Step 1 — doc-only, no code touch.
- **Parallel batch B** (after Step 1 merges; Step 1 locks the marker-column design):
  - Step 2 — `domains.is_panel_primary` migration + `Domain.IsPanelPrimary bool` field + repository methods `FindPanelPrimary` / `MarkPanelPrimary` / `Delete` guard.
- **Parallel batch C** (after Step 2 merges):
  - Step 3 — install.sh: `provision_tls_cert` SAN regeneration + new `install_panel_primary_domain` that INSERTs the domain row with `email_enabled=1, is_panel_primary=1`.
  - Step 4 — panel-api: settings endpoint `GET /api/admin/settings/email` returning `{primary_domain_name, webmail_url, dkim_published}`; reject deletes of the panel-primary row at the repo layer with a typed error.
  - Step 3 and Step 4 touch disjoint files (bash vs Go) — **true parallel**.
- **Serial gate**:
  - Step 5 — `install.sh` (`jabali-default.conf` `/webmail` location) + `panel-ui` Settings → Email card. Depends on Step 3 (SAN/domain row) AND Step 4 (API endpoint).
  - Step 6 — runbook, integration smoke on 192.168.100.150, merge to `main`. Depends on 2, 3, 4, 5 all green.

---

## Risk register

| # | Risk | Likelihood | Impact | Mitigation |
|---|------|------------|--------|------------|
| 1 | `provision_tls_cert`'s existing-cert skip-path prevents SAN regeneration on upgrade VMs | **HIGH** | Panel cert lacks `mail.<hostname>` SAN → Firefox TLS error on `/webmail` redirect target | Step 3 explicitly reads cert SANs (`openssl x509 -noout -ext subjectAltName`) and regenerates if `mail.<hostname>` is absent; warn operator via `_warn` that panel service needs a restart |
| 2 | `install_panel_primary_domain` runs before `users` table has an admin user row (bootstrap ordering) | MED | FK violation on `domains.user_id` insert | Order: `install_panel_primary_domain` runs AFTER bootstrap-admin step; if admin users absent, skip + log a structured message; reconciler converges next tick |
| 3 | Hostname change (operator edits `JABALI_SRV_HOSTNAME` in env and re-runs install) | MED | Stale panel-primary row points to old hostname; new install creates second row | Step 3 updates the existing panel-primary row's `name` in-place if one exists, per ADR-0048 decision ("update in place, never orphan") |
| 4 | Reconciler email-enable path requires DNS self-zone to have an `@` + `ns1` + `ns2` — panel-primary row's DNS TXT rows assume zone exists | LOW | MX/SPF/DMARC INSERTs fail | Self-zone already exists by `bootstrap_pdns_self_zone` which runs before the reconciler ticks; Step 3 asserts the pdns domain row exists before inserting the panel domain row |
| 5 | `/webmail` 301 when primary domain absent (reconciler not yet converged) | LOW | Browser hits `mail.<hostname>` with no cert/vhost → TLS error | Step 5's nginx location tests the primary domain's status via a small Go HTTP helper OR defaults to a graceful 503 error page. Chosen: graceful 503 (simpler, no inter-service dep) |
| 6 | Panel UI surfaces `is_panel_primary=1` row like any tenant domain (edit/delete buttons) | MED | Admin accidentally tries to delete, sees confusing 403 | Step 4's repo delete-guard returns a typed error; Step 5 hides Delete button on the domain-list page when `is_panel_primary=true` and shows a "System" tag instead |
| 7 | Migration 000057 races with another M6.x migration landing in parallel | LOW | Duplicate number | `git fetch origin main && ls panel-api/internal/db/migrations/ \| tail` at PR-open time; auto-increment if 000057 is taken (per `feedback_merge_audit_migrations.md`) |
| 8 | Self-signed cert regen mid-install leaves nginx with stale-cached cert in memory | LOW | `/webmail` serves old cert | Step 3 issues `systemctl reload nginx` (not restart) after regen; reload rereads cert files on next connection |

---

## Anti-patterns to avoid (learned from prior M6.x)

- **Do not** dispatch agents for well-specified steps (per `feedback_no_agents_for_well_specified_steps.md`). Every step below is scoped tight enough for inline execution by the dispatcher with full context. Only Step 1 (ADR) warrants a dedicated architect review pass.
- **Do not** promote partial blueprint steps to main before the umbrella is green (per `feedback_no_partial_blueprint_to_main.md`). Step 3's DNS insert + Step 4's API endpoint without Step 5's vhost = broken `/webmail`.
- **Do not** verify DNS by "something answered". M6.3's probe bug shipped because `LookupNS` accepted a public fallthrough. Step 3/Step 6's verification commands must compare against the self-zone's authoritative answer (see M6.3's post-incident probe pattern).
- **Do not** invent install.sh logger helpers. Only `_log/_ok/_warn/_err` exist (per `feedback_install_sh_logger.md`).
- **Do not** use find-and-replace to rename `EmailEnabled` → new fields. Use `mcp__gitnexus__rename` (per project CLAUDE.md sub-agent mandate).
- **Do not** add backwards-compat shims for the pre-M6.4 "no is_panel_primary column" world. The column is additive; old rows get `FALSE` by the `DEFAULT`. No code needs to behave differently for pre-migration data.

---

## Step 1 — ADR-0048 + BLUEPRINT entry

**Branch**: `m6.4/panel-hostname-mail-domain/01-adr`
**Model**: strongest (architecture decisions have downstream blast radius for Steps 2-5)
**Depends on**: nothing (doc-only)
**Enables**: Steps 2-6 (ADR locks the marker-column shape + hostname-change behavior + /webmail fallback mode)

### Context brief (cold-start readable)

M6.4 turns the panel hostname into a first-class email domain so webmail works on a fresh install without operator intervention. Four design decisions need to be locked before any code lands:

1. **Auto-register vs require-operator-register the panel-primary domain row.** Auto-register: operator flips the switch by clicking "Enable email" on the hostname domain (which already exists). Require-register: operator must create the hostname as a tenant domain, then enable email — same path as any customer domain. Decision: **auto-register**, because (a) the UX fail of "/webmail is broken on fresh install" erodes trust in the product, (b) phpMyAdmin's "batteries included" precedent argues for the same ergonomic on mail, (c) the marker column makes this row trivially distinguishable so no future operator surprise.

2. **Marker shape.** Options: (a) `is_panel_primary BOOLEAN NOT NULL DEFAULT 0`, (b) `domain_type VARCHAR(16)` enum ('tenant'|'panel_primary'|'system'), (c) external file pointer `/etc/jabali-panel/primary-domain.id`. Decision: **(a) boolean column** — minimal, additive, sortable/filterable, no migration pain if future categories (e.g. "shared hosting plan domain") need more granularity — add another column then.

3. **Hostname-change handling.** Decision: **update-in-place**. If `JABALI_SRV_HOSTNAME` changes and install.sh re-runs, the existing `is_panel_primary=1` row has its `name` updated and the reconciler re-runs email-enable (new DKIM key, new MX/SPF/DMARC TXT, new Stalwart domain, new nginx mail vhost). Old records in the old self-zone are orphaned; install.sh documents the cleanup as a manual operator task.

4. **/webmail fallback when primary domain absent.** Two options considered:
   - **(a) Graceful HTTP 503 static page** — nginx location serves a tiny HTML "Webmail is initializing, retry in a minute" via `error_page` + a sentinel file (`/run/jabali/webmail-ready`) the reconciler touches on first converge.
   - **(b) No fallback — just 301 and let the downstream TLS handshake fail** if reconciler hasn't converged.
   - **Decision: (b)**. Reasoning: the convergence window is ~30s on a fresh install (reconciler ticks every ~15s; first tick enables email + issues agent commands; second tick confirms). Option (a) needs a sentinel file + install.sh wiring + runbook-documented cleanup state + a static HTML asset. The 30-second rough-edge for a one-time-per-install scenario doesn't justify that. **Option (a) is not discarded** — docketed as M6.4.4 follow-up; a first operator complaint flips the switch. No inter-service HTTP check is considered — nginx polling the panel API is an anti-pattern in this codebase.

### Task list

- [ ] Write `docs/adr/0048-panel-hostname-primary-mail-domain.md` following the ADR-0041 template (Context / Decision / Alternatives / Consequences / Security posture / Failure modes).
- [ ] Record the four sub-decisions above, each with a pros/cons block for the rejected alternatives.
- [ ] Add a "Relationship to M6.3" note: the panel-primary domain's DNS records land in the same self-zone that M6.3 made resolvable locally; without M6.3, auto-register would work but `/webmail` would resolve to the public internet answer (or NXDOMAIN).
- [ ] Insert an M6.4 section into `docs/BLUEPRINT.md` right after the M6.3 section. Pattern: "What ships / Driving evidence / Status: IN PROGRESS".

### Verification

- [ ] File `docs/adr/0048-panel-hostname-primary-mail-domain.md` exists.
- [ ] Running `grep -l "ADR-0048" docs/BLUEPRINT.md` returns a match.
- [ ] ADR references the four specific decisions in order; every rejected alternative has a "Why not" block.

### Exit criteria

- PR merged to umbrella branch.
- Architect review (opus-tier sub-agent) signs off — the four decisions are defensible as stated; no missing alternative or unexplored risk.

---

## Step 2 — Migration + domain model + repo methods

**Branch**: `m6.4/panel-hostname-mail-domain/02-schema`
**Model**: default
**Depends on**: Step 1 (ADR locks `is_panel_primary boolean` marker shape)
**Enables**: Steps 3, 4, 5 (every downstream touchpoint reads/writes this column or calls the repo methods)

### Context brief

The `domains` table needs one new column + two new repository methods + one guard. Nothing else. No refactor of existing M6 columns; the panel-primary row uses the same `email_enabled`, `dkim_selector`, `dkim_public_key`, `email_enabled_at` plumbing as any tenant domain — the only difference is the new marker bit and the delete-protection.

### Task list

- [ ] Before editing any symbol: `mcp__gitnexus__impact({target:"Domain", direction:"upstream", repo:"jabali2"})`. Record the blast radius (callers of Domain struct, repository methods, serializers) in the PR description. Expect MEDIUM risk — Domain is widely imported.
- [ ] Write migration `panel-api/internal/db/migrations/000057_domains_is_panel_primary.up.sql` — see the full SQL (with inline comment) in the next checkbox. **No UNIQUE KEY** on `is_panel_primary` — MariaDB's UNIQUE on TINYINT with `DEFAULT 0` would block multiple 0-valued rows (MariaDB treats every 0 as a distinct value in a unique index), and a partial index "UNIQUE WHERE is_panel_primary=1" isn't supported. At-most-one is enforced in the Go repository layer (`MarkPanelPrimary` transaction) per ADR-0048 Decision 2.
- [ ] Add an inline SQL comment to `.up.sql` explaining the missing UNIQUE constraint (because a future maintainer WILL wonder):
  ```sql
  -- M6.4 / ADR-0048: is_panel_primary marks the single auto-registered
  -- panel-hostname domain row. "At most one" is enforced in the Go
  -- repository layer (MarkPanelPrimary transaction), NOT via a UNIQUE
  -- KEY — MariaDB's UNIQUE on TINYINT treats every 0 as a distinct
  -- value, which would only block duplicate 1s if wrapped in a
  -- partial-index expression that MariaDB doesn't support. Repo-layer
  -- enforcement is simpler and keeps the schema uniform.
  ALTER TABLE domains
    ADD COLUMN is_panel_primary TINYINT(1) NOT NULL DEFAULT 0;
  ```
- [ ] Write matching `.down.sql`:
  ```sql
  ALTER TABLE domains
    DROP COLUMN is_panel_primary;
  ```
- [ ] Add field to `panel-api/internal/models/domain.go`:
  ```go
  // IsPanelPrimary marks the single domain row auto-registered for the
  // panel hostname. Protected from delete; surfaced in Settings → Email.
  // See ADR-0048.
  IsPanelPrimary bool `gorm:"type:tinyint(1);not null;default:0;index:ix_domains_panel_primary" json:"is_panel_primary"`
  ```
- [ ] Add repository methods in `panel-api/internal/repository/domain_repository.go`:
  - `FindPanelPrimary(ctx) (*Domain, error)` — `WHERE is_panel_primary = 1 LIMIT 1`; returns typed `ErrPanelPrimaryNotFound` when absent (not `gorm.ErrRecordNotFound` — callers should distinguish).
  - `MarkPanelPrimary(ctx, domainID) error` — wraps a transaction: clear any other `is_panel_primary=1` row to 0, then set target to 1. Idempotent.
- [ ] Add guard in `Delete(ctx, id)`: load domain first, reject with typed `ErrCannotDeletePanelPrimary` if the row is panel-primary.
- [ ] Add typed errors in `panel-api/internal/repository/errors.go` (or wherever existing repo errors live — check existing pattern first, don't invent).
- [ ] Unit tests (`_test.go` adjacent to new code):
  - `TestFindPanelPrimary_Absent_ReturnsTypedErr`
  - `TestMarkPanelPrimary_ClearsOthers_ThenSetsTarget` (setup: two rows with `is_panel_primary=1`, mark a third — verify exactly one `=1` after)
  - `TestDelete_RefusesPanelPrimary`
  - `TestDelete_AllowsRegularDomain`
- [ ] `mcp__gitnexus__detect_changes()` before commit: verify affected scope matches (`Domain` struct, one repository file, one migration pair, one test file). Flag any stray hits.
- [ ] **Pre-PR migration-number recheck**: immediately before opening the PR, `git fetch origin main && ls panel-api/internal/db/migrations/ | tail -5` on a throwaway clone of origin/main. If another branch has landed 000057 in parallel, rebase + renumber to the next free slot (per `feedback_merge_audit_migrations.md`). Re-run the recheck at PR-open, NOT just at branch-create.

### Verification

- [ ] `go build ./...` clean.
- [ ] `go test -race -count=1 ./panel-api/internal/repository/... ./panel-api/internal/models/...` green.
- [ ] `ls panel-api/internal/db/migrations/ | tail -5` — confirm 000057 is the next number (no conflict from parallel work since branching).
- [ ] Migrate on a throwaway MariaDB DB: `mysql ... < 000057_domains_is_panel_primary.up.sql` → `DESCRIBE domains;` shows new column; `.down.sql` cleanly reverts.

### Exit criteria

- PR merged to umbrella.
- Two repository methods + one guard + typed errors in place.
- Migration reversible, idempotent (re-run safe).

### Rollback

- Apply `000057_domains_is_panel_primary.down.sql`. Revert the Go code. No data loss (column was additive).

---

## Step 3 — install.sh: panel cert SAN + panel-primary domain row

**Branch**: `m6.4/panel-hostname-mail-domain/03-install`
**Model**: default
**Depends on**: Step 2 (need `is_panel_primary` column + `MarkPanelPrimary` method)
**Enables**: Step 5 (the /webmail location has something to redirect to; the cert covers the target hostname)

### Context brief

Two discrete install.sh changes, no runtime services touched:

1. **Cert SAN regeneration.** `provision_tls_cert` today generates once and skips. We extend it to detect when `mail.<hostname>` is missing from the cert's SAN list (happens on every upgrade from a pre-M6.4 install) and regenerate + reload nginx. Self-signed cert only — real-FQDN Let's Encrypt flow is separate (M6.1 owns that, already ships `mail.<domain>` SAN for tenant domains via `--expand`).

2. **Panel-primary domain row insert.** New function `install_panel_primary_domain`:
   - If a `is_panel_primary=1` row exists and `name = $JABALI_SRV_HOSTNAME` → no-op.
   - If a `is_panel_primary=1` row exists and `name != $JABALI_SRV_HOSTNAME` → UPDATE the `name` in-place (hostname change case; ADR-0048 §Decision 3).
   - Else → INSERT row with `name=$JABALI_SRV_HOSTNAME, email_enabled=1, is_panel_primary=1, user_id=<first-admin-user-id>, is_enabled=1, ssl_enabled=1, index_priority='html_first'`, ULID generated via the existing `_ulid` helper (grep install.sh first; don't invent). Note: docroot=`''` by default (panel-primary domain has no public_html — it's mail-only).
   - If no admin user exists yet (fresh install, admin-bootstrap step not yet run): `_log` the deferral and return 0. The reconciler will NOT converge email for this row until it has an owner; install.sh's main() calls `install_panel_primary_domain` AFTER bootstrap_admin.

3. **Self-zone records for mail subdomain.** The reconciler's `domain.email_enable` path (fired because `email_enabled=1` is set at row-insert time) inserts ALL of: `mail.<hostname>` A, `<hostname>` MX, `<hostname>` SPF TXT, `<selector>._domainkey.<hostname>` DKIM TXT, `_dmarc.<hostname>` DMARC TXT. `bootstrap_pdns_self_zone` inserts only the apex/ns1/ns2 A records — the `mail` A is NOT one of them. Do NOT duplicate-insert from install.sh; let the reconciler converge. Step 3's verification list probes dig to confirm the reconciler has done its job.

### Task list

- [ ] Impact analysis: `mcp__gitnexus__impact({target:"provision_tls_cert", direction:"upstream", repo:"jabali2"})` (if indexed; function may not be in gitnexus — grep fallback). Expected LOW risk — called once from `main()`.
- [ ] Extend `provision_tls_cert`:
  - At the TOP of the function (before the existing cert-exists early-return), detect hostname drift. The cert's embedded CN tells us what hostname it was issued for; if it no longer matches `$JABALI_SRV_HOSTNAME`, we're in a hostname-change case and must regenerate regardless of what the SAN block says:
    ```bash
    local cert_cn=""
    if [[ -f "$cert_file" ]]; then
      cert_cn="$(openssl x509 -in "$cert_file" -noout -subject 2>/dev/null | sed -n 's/.*CN *= *\([^,]*\).*/\1/p' | tr -d ' ')"
    fi
    if [[ -n "$cert_cn" && "$cert_cn" != "$cn" ]]; then
      _warn "panel cert CN=$cert_cn != current hostname $cn — hostname drift, regenerating"
      rm -f "$cert_file" "$key_file"   # force the generate block below to run
    fi
    ```
  - THEN the existing "cert exists" path: if cert still exists (no drift, possible SAN drift only), add the SAN check:
    ```bash
    local have_mail_san=0
    if openssl x509 -in "$cert_file" -noout -ext subjectAltName 2>/dev/null | grep -qE "DNS:mail\.${cn}(,|$)"; then
      have_mail_san=1
    fi
    if [[ "$have_mail_san" -eq 0 ]]; then
      _log "panel cert missing mail.${cn} SAN — regenerating"
      rm -f "$cert_file" "$key_file"
    else
      _ok "TLS cert exists with mail.${cn} SAN: $cert_file"
      return 0
    fi
    ```
  - In the generate block, append `,DNS:mail.${cn}` to the `san=` string.
  - After regenerate, issue `systemctl reload nginx 2>/dev/null || _warn "nginx reload failed; check journalctl -u nginx"`.
  - Panel service (panel-api) is a Go HTTP server — it caches the cert in memory and will NOT re-read on SIGHUP (reload is a no-op for Go's `http.Server`). Full restart required:
    ```bash
    systemctl restart jabali-panel-api 2>/dev/null \
      || _warn "jabali-panel-api restart failed; check 'systemctl status jabali-panel-api'"
    ```
    Accept the ~100ms TLS downtime as the cost of cert rotation (happens only on install / upgrade). Document this in Step 6's runbook.
- [ ] New function `install_panel_primary_domain()` (anchored near `bootstrap_pdns_self_zone`, ~line 1827):
  - Guard: `$JABALI_SRV_HOSTNAME` must be set; else `_die "JABALI_SRV_HOSTNAME not set — cannot configure panel primary mail domain"`. An unset hostname is a fatal install error (empty string → cert CN empty → self-zone missing → cascading breakage); NOT a recoverable skip.
  - Self-zone FK assertion: `install_panel_primary_domain` inserts rows into `jabali_panel.domains`, and the reconciler will later insert DNS records into `jabali_pdns` that reference a zone keyed by hostname. If `bootstrap_pdns_self_zone` hasn't run, those later inserts fail with FK violations at reconciler tick time. Catch it here, not there:
    ```bash
    local pdns_zone_id
    pdns_zone_id="$(mariadb -uroot -Ns jabali_pdns -e "SELECT id FROM domains WHERE name = '$(_sql_escape "$JABALI_SRV_HOSTNAME")';" 2>/dev/null || true)"
    if [[ -z "$pdns_zone_id" ]]; then
      _die "pdns self-zone '$JABALI_SRV_HOSTNAME' not found — bootstrap_pdns_self_zone must run before install_panel_primary_domain; check main() ordering"
    fi
    ```
  - Read first admin user: `user_id="$(mariadb -uroot -Ns jabali_panel -e "SELECT id FROM users WHERE role='admin' ORDER BY created_at LIMIT 1")"`. If empty, `_log "no admin user yet; deferring panel-primary domain creation to next install.sh run"; return 0`.
  - Idempotency: check `SELECT id, name FROM domains WHERE is_panel_primary=1 LIMIT 1`.
  - Branch on result (no-op / UPDATE name / INSERT new) per the decision table above.
  - On INSERT: use `_ulid` for the domain id (grep install.sh for the helper name — it exists; if not, the SQL can use `CONCAT(...)` from a shell-generated ULID).
  - `_ok "panel-primary domain row: $JABALI_SRV_HOSTNAME (id=$domain_id)"` on success.
- [ ] Wire `install_panel_primary_domain` into `main()` AFTER `bootstrap_admin_user` and BEFORE reconciler-first-tick (which happens implicitly on service start). Order matters — test on a fresh install where users table is empty pre-bootstrap.
- [ ] Add the new function + call site to the install.sh "sections" comment block at the top (grep for the existing table of contents; update it).
- [ ] No shell-escape shortcuts: `_sql_escape` every variable before SQL interpolation (matches `bootstrap_pdns_self_zone` style).

### Verification

- [ ] `shellcheck install.sh` — clean (or at worst, same baseline warnings as main; no new ones).
- [ ] Syntax: `bash -n install.sh`.
- [ ] On test VM 192.168.100.150 (already has M6.3): `scp install.sh` + run via `sudo bash install.sh` (idempotent re-run). Expect:
  - `provision_tls_cert`: regenerates cert with `mail.jabali-panel.local` SAN; nginx reload; panel service reload.
  - `install_panel_primary_domain`: INSERTs the row since none exists.
  - `openssl x509 -in /etc/jabali/tls/panel.crt -noout -ext subjectAltName` — output includes `DNS:mail.jabali-panel.local`.
  - `mysql jabali_panel -e "SELECT id,name,is_panel_primary,email_enabled FROM domains WHERE is_panel_primary=1"` — one row, name=jabali-panel.local, email_enabled=1.
- [ ] Wait 1 reconciler tick (~30s): reconciler email-enables the domain → DKIM keypair appears at `/etc/jabali-panel/dkim/jabali-panel.local.key`, nginx mail vhost written to `/etc/nginx/sites-available/jabali-panel.local-mail.conf`, Stalwart adds the domain.
- [ ] `dig @127.0.0.1 mail.jabali-panel.local A +short` returns 192.168.100.150 (reconciler wrote it to the self-zone via `domain.email_enable` → `dns_record.upsert` path).
- [ ] `dig @127.0.0.1 jabali-panel.local TXT +short` returns SPF record.
- [ ] Re-run install.sh: cert-check now passes (SAN present, early-return); panel-primary insert is no-op (idempotency); no changes.

### Exit criteria

- PR merged to umbrella.
- Test VM has: cert with `mail.<hostname>` SAN, panel-primary row, reconciler-converged email domain.

### Rollback

- Revert install.sh changes. Manually: `UPDATE domains SET email_enabled=0 WHERE is_panel_primary=1` (reconciler will tear down Stalwart domain + nginx mail vhost on next tick). Cert is self-signed; regenerate without `mail.` SAN if desired (cosmetic; no security impact).

---

## Step 4 — Panel API: settings endpoint + delete-protect surfacing

**Branch**: `m6.4/panel-hostname-mail-domain/04-api`
**Model**: default
**Depends on**: Step 2 (uses `FindPanelPrimary`)
**Enables**: Step 5 (UI card consumes this endpoint)
**Parallel with**: Step 3 (disjoint files: bash vs Go)

### Context brief

Two discrete API changes:

1. **New endpoint** `GET /api/admin/settings/email` with two distinct wire shapes:
   - **200 OK (row exists)** — all four fields present:
     ```json
     {
       "primary_domain_name": "jabali-panel.local",
       "webmail_url": "https://mail.jabali-panel.local/",
       "dkim_published": true,
       "email_enabled_at": "2026-04-22T18:00:00Z"
     }
     ```
   - **202 Accepted (row absent — rare, install still converging OR operator manually deleted via SQL bypass)** — minimal shape, only two fields:
     ```json
     {
       "primary_domain_name": null,
       "status": "initializing"
     }
     ```
   - Field derivations (200 path): `primary_domain_name` = `FindPanelPrimary().Name`. `webmail_url` = `"https://mail." + name + "/"`. `dkim_published` = `DkimPublicKey != nil && *DkimPublicKey != ""`. `email_enabled_at` = RFC3339 string or null.
   - Wire-contract rule: the TS client in Step 5 discriminates on **HTTP status code** (200 vs 202), NOT on presence/absence of fields. The Go handler MUST return distinct response bodies per status — do NOT serialize the 200 struct with all-null fields as the 202 body. Use two separate response structs; tag `json:",omitempty"` is NOT sufficient because `primary_domain_name: null` is explicit, not absent.
   - Absent-for-pathological-reason (operator SQL bypass): the UI will show "Initializing" indefinitely. This is the designed behavior — the delete-protect guard means the row should never be absent post-install. Runbook documents the diagnosis path (Step 6).
2. **Delete-protect surface**: Step 2 already added the repo guard. Step 4 adds the HTTP-layer translation:
   - `DELETE /api/admin/domains/{id}` → when repo returns `ErrCannotDeletePanelPrimary`, respond 403 with body `{"error":{"code":"panel_primary_protected", "message":"Cannot delete the panel's primary mail domain."}}`.
   - Existing delete handler needs one switch arm; no new handler.

### Task list

- [ ] Impact analysis on `domains.Delete` handler: `mcp__gitnexus__impact({target:"DeleteDomain", direction:"upstream", repo:"jabali2"})`. Expected LOW.
- [ ] Add handler `GET /api/admin/settings/email` in `panel-api/internal/api/settings_email.go` (or wherever settings endpoints live — grep for existing settings handler placement).
- [ ] Route wiring in `panel-api/internal/app/routes.go` (or equivalent; match the existing pattern for admin-scoped endpoints).
- [ ] Read `panel-api/internal/api/errors.go` (or grep for the actual file — e.g. `grep -rn "type ErrorResponse\|type APIError" panel-api/internal/api/`). Record the exact struct + JSON shape at the top of the handler file as a comment, so the TS client in Step 5 has an authoritative contract to read against (per `feedback_verify_wire_contract.md`).
- [ ] Update `DeleteDomain` handler to translate `ErrCannotDeletePanelPrimary` → 403 using the EXACT envelope from the previous step. The error `code` string is `panel_primary_protected`; the `message` is `"Cannot delete the panel's primary mail domain."`. Do NOT introduce a new nested wrapper shape if the envelope doesn't already use one.
- [ ] Unit tests:
  - `TestSettingsEmail_ReturnsPrimaryDomain` (setup: panel-primary row exists with DKIM).
  - `TestSettingsEmail_Initializing_When_DKIM_Absent` (panel-primary row exists, `DkimPublicKey` nil) — still returns 200 with `dkim_published=false`.
  - `TestSettingsEmail_Absent_Returns202` (no panel-primary row).
  - `TestDeleteDomain_PanelPrimary_Returns403`.
- [ ] Wire-contract verification per `feedback_verify_wire_contract.md`: the TypeScript client in Step 5 reads THIS handler's shape; document the exact shape at the top of the handler file.
- [ ] `mcp__gitnexus__detect_changes()` pre-commit — scope should show only `panel-api/internal/api/`, one route file, and tests.

### Verification

- [ ] `go build ./...` clean.
- [ ] `go test -race -count=1 ./panel-api/internal/api/...` green.
- [ ] Manual probe on test VM (after Step 3 applied): `curl -sk https://localhost:8443/api/admin/settings/email -H "Cookie: <admin session>"` returns the expected JSON shape.
- [ ] `curl -sk -X DELETE https://localhost:8443/api/admin/domains/<panel-primary-id> -H "Cookie: <admin session>"` returns 403 with `panel_primary_protected` code.

### Exit criteria

- PR merged to umbrella.
- Settings endpoint live; delete-protect surfaces to HTTP 403.

### Rollback

- Revert handler + route wiring. Endpoint disappears; delete-protect falls back to repo-layer typed error (still enforced, just less pretty at the HTTP layer).

---

## Step 5 — nginx /webmail redirect + panel UI card

**Branch**: `m6.4/panel-hostname-mail-domain/05-vhost-ui`
**Model**: default
**Depends on**: Step 3 (cert SAN + panel-primary row), Step 4 (settings endpoint)
**Enables**: Step 6 (full end-to-end smoke)

### Context brief

Two discrete changes, different surfaces:

1. **`install.sh` default vhost gets `/webmail` redirect**. Anchored at `install.sh:2517` in the `location /` catch-all block. New block BEFORE that catch-all:
   ```nginx
   # Bounce /webmail to the panel-primary domain's Bulwark instance.
   # Target is mail.<panel-hostname> — reconciler provisions the vhost
   # + SAN + DNS records once the panel-primary domain row exists and
   # reconciler has converged. See M6.4 / ADR-0048.
   location = /webmail {
       return 301 https://mail.${JABALI_SRV_HOSTNAME}/;
   }
   location = /webmail/ {
       return 301 https://mail.${JABALI_SRV_HOSTNAME}/;
   }
   ```
   - `${JABALI_SRV_HOSTNAME}` is resolved at install.sh-render time via heredoc interpolation (matches how `${tls_cert}` is handled at `install.sh:2500`). **Hostname drift is safe**: the default vhost file is written unconditionally on every install.sh run (line 2475's `cat > "${default_vhost_file}" << VHOSTEOF` is not guarded by an exists-check), so a hostname change + re-run re-renders the `/webmail` target automatically. No extra regen call needed.
   - No fallback handling at the nginx layer — if the reconciler hasn't converged yet, the 301 still works, but the target's TLS handshake fails. Acceptable per ADR-0048 §Decision 4 (operator waits out convergence). The ADR's "graceful 503" option is de-scoped here because it requires either a second nginx location checking the panel API, or a sentinel file on disk — neither justifies the complexity for a 30-second window on install.
2. **Panel UI: Settings → Email card**. Read-only card in the existing admin Settings panel.
   - Card title: "Primary Mail Domain"
   - Displays: `primary_domain_name`, `webmail_url` (as a clickable link), `dkim_published` (badge: green "Published" / amber "Initializing"), `email_enabled_at` (relative time).
   - Absent state (202 response): show a skeleton card with "Webmail is initializing. Retry in ~30 seconds." + a Refresh button.
   - Uses TanStack Query (per M21 conventions) — new hook `useSettingsEmail()` in `panel-ui/src/hooks/admin/useSettingsEmail.ts`.
   - Card component: `panel-ui/src/routes/admin/settings/EmailCard.tsx`.
   - Integrate into existing Settings route: grep for `panel-ui/src/routes/admin/settings` and find the card-grid container; add `<EmailCard />` near other system-status cards.
3. **Panel UI: hide Delete button on panel-primary domain rows.** On the admin Domains list: when a row has `is_panel_primary=true`, replace the Delete action with a disabled button that shows a tooltip "System domain — cannot be deleted". Add a small "System" tag in the Name column for the same row.

### Task list

- [ ] Impact analysis on `jabali-default.conf` writing block: `grep -n "jabali-default.conf" install.sh`. Only the one writer.
- [ ] Insert the `location = /webmail` + `location = /webmail/` blocks into the default vhost heredoc in install.sh. Verify heredoc interpolation treats `$JABALI_SRV_HOSTNAME` correctly (should be expanded at write time, not left as a literal).
- [ ] `shellcheck install.sh` + `nginx -t` on test VM after re-running install.sh.
- [ ] Panel UI: create hook `useSettingsEmail` (stale time 30s; retries on 202 with exponential backoff capped at 30s — signals "still converging"). Wire contract matches Step 4 handler shape exactly (verify by reading the Go file, per `feedback_verify_wire_contract.md`).
- [ ] Panel UI: create `EmailCard.tsx` (AntD `Card` + `Descriptions` per M23 responsive conventions; `scroll={{x: 'max-content'}}` if the card lives inside a table).
- [ ] Panel UI: modify admin Domains list to hide/disable Delete on `is_panel_primary=true` rows + add "System" tag.
- [ ] E2E test (`panel-ui/e2e/admin-settings-email.spec.ts`) — two variants:
  - **Variant A (converged state)**: runs AFTER the global E2E setup has waited for reconciler convergence (the existing suite's `globalSetup` already handles this for other M6 tests; reuse). Assert card renders with `dkim_published=true`, domain name = panel hostname, webmail link points to `https://mail.<hostname>/`, and `email_enabled_at` is within the last 10 minutes. Navigate to admin Domains list → panel-primary row has "System" tag, no Delete button (clicking area where Delete would be shows a tooltip, not a dialog).
  - **Variant B (initializing state)**: mocks the `/api/admin/settings/email` response to 202 via MSW or Playwright `route.fulfill`. Assert card shows "Initializing" badge + Refresh button.
  - Webmail link-click behavior: the test asserts the href only (`https://mail.<hostname>/`). Do NOT navigate — Bulwark's self-signed cert will fail in headless Chromium; that's M6's responsibility, not M6.4's.
- [ ] Vitest: `useSettingsEmail.test.ts` — covers 200 / 202 / error paths.

### Verification

- [ ] `shellcheck install.sh` clean.
- [ ] `nginx -t` on test VM post-install.
- [ ] `npm run test` in panel-ui → vitest green.
- [ ] `npm run test:e2e -- admin-settings-email` green.
- [ ] Manual: open `https://jabali-panel.local:443/webmail` → 301 → `https://mail.jabali-panel.local/` → Bulwark login page.
- [ ] Manual: admin Settings → Email card shows green "Published" badge (DKIM converged).

### Exit criteria

- PR merged to umbrella.
- `/webmail` redirect works on test VM.
- Settings → Email card live.
- Delete-guard visible in admin Domains list.

### Rollback

- Revert the install.sh block (nginx default vhost regresses to pre-M6.4 state; `/webmail` returns 444 via the catch-all).
- Revert panel-ui additions. Card disappears; Delete button re-exposed (still guarded at the API layer — no data-loss risk).

---

## Step 6 — Runbook, integration smoke, merge to main

**Branch**: `m6.4/panel-hostname-mail-domain/06-runbook-merge`
**Model**: default
**Depends on**: Steps 2, 3, 4, 5 (all green on umbrella)
**Enables**: nothing (terminal step)

### Context brief

Final gate before main. Three deliverables:

1. **Runbook** `plans/m6.4-panel-hostname-mail-domain-runbook.md` — operator-facing: ASCII architecture, verification checklist, common ops (cert rotation, hostname change, reconciler re-converge), troubleshooting for the Step 3 install.sh hard-fails, package upgrade notes.
2. **Integration smoke** on 192.168.100.150 from a clean state: `install.sh --uninstall` (the new one from `da8092b`) → `install.sh` fresh → wait for reconciler → browser test `/webmail` → manually create a mailbox `admin@jabali-panel.local` → send a test mail from Roundcube/Bulwark to itself → verify receipt.
3. **Merge to main** via fast-forward from the umbrella. Only after all above green.

### Task list

- [ ] Write runbook following the M6.3 runbook pattern (`plans/m6.3-pdns-recursor-runbook.md`).
- [ ] Run integration smoke: `scp install.sh root@192.168.100.150:/tmp/` → `ssh ... 'bash /tmp/install.sh --uninstall && bash /tmp/install.sh'` → wait 60s → test every verification step from Steps 3, 5.
- [ ] **DKIM publication probe** (added per review H6): before declaring smoke green, verify DKIM is actually in DNS — a silent unsigned-outbound mail bug would otherwise slip past the roundtrip test:
  ```bash
  ssh -p 2222 root@192.168.100.150 'selector=$(mariadb -Ns jabali_panel -e "SELECT dkim_selector FROM domains WHERE is_panel_primary=1"); \
    dig @127.0.0.1 "${selector}._domainkey.jabali-panel.local" TXT +short'
  ```
  Must return a non-empty string containing `p=`. Fail smoke if empty.
- [ ] Record the smoke-test output (including DKIM probe result) in the PR description (copy-paste, not "trust me").
- [ ] Rebase umbrella onto `origin/main` (latest). Re-run test suite.
- [ ] Request explicit human dispatcher approval before pushing to main (per `feedback_no_partial_blueprint_to_main.md` and every user preference in memory).
- [ ] Push via fast-forward merge to main. Tag as `m6.4-shipped-<YYYY-MM-DD>`.
- [ ] Update `MEMORY.md` index with `project_m6_4_panel_hostname_mail.md` SHIPPED entry.

### Verification

- [ ] Runbook has every section from the M6.3 pattern (Architecture, Health probes, Common ops, Troubleshooting, Package upgrade notes).
- [ ] Smoke-test output shows: cert has `mail.<hostname>` SAN, domains table has panel-primary row, DKIM published, `/webmail` 301 redirect works, mail roundtrip through Bulwark UI succeeds.
- [ ] Post-merge: `git log --oneline origin/main -1` shows the umbrella merge commit; test VM `/webmail` still works.

### Exit criteria

- `origin/main` contains M6.4.
- Memory updated.
- Test VM green and documented.

### Rollback

- `git revert` the umbrella merge commit on main; data survives (migration is additive, panel-primary row stays, reconciler keeps converging). Revert install.sh changes on the VM via `git checkout origin/main~1 -- install.sh && bash install.sh`. The only operator-visible regression: `/webmail` stops working; manual URL `https://mail.<hostname>/` still does.

---

## Post-merge follow-ups (not blocking M6.4 ship)

- M6.4.1: Admin mailbox auto-creation (`admin@<panel-hostname>` at install time, reveal-once password). Requires deciding where the credential lands (ops-visible? encrypted-at-rest under SSO key?).
- M6.4.2: Hostname-change UX. Today: operator edits env, re-runs install.sh, old self-zone orphans. Future: panel Settings UI flow with a "Change panel hostname" button + guided migration.
- M6.4.3: Public-FQDN Let's Encrypt for the panel cert (currently self-signed). Reuse M6.1 `--expand` logic; blocked on picking which M-number owns "panel cert becomes LE cert".
- M6.4.4: `/webmail` graceful 503 during reconciler initialization (ADR-0048 §Decision 4 alternative). Complexity vs. a 30-second-per-install user-visible rough edge — defer until an operator actually files a complaint.
