feat(platform): real email domains, mailboxes & member lifecycle

Wire the mail/identity stack to real Stalwart/Authentik/OCIS provisioning,
replacing the mocked Domains and Users pages.

Domains (customer-admin):
- StalwartClient: real JMAP management (v0.16 dropped REST) — create/list/delete
  email domains via x:Domain at the internal http://stalwart:8080 listener;
  DKIM auto-generated; the records to publish are read from the domain's
  dnsZoneFile. Gated by STALWART_PROVISIONING_ENABLED.
- New Domain collection + DomainsModule: add/list/recheck/set-DMARC/remove,
  tenant-membership-gated and audited.
- DnsVerifierService: verifies MX/SPF/DKIM/DMARC/ownership against a public
  resolver (1.1.1.1/8.8.8.8) and diffs them against the expected records.
- Remove is guarded: refuses while accounts/aliases/mailing lists still use the
  domain (via Stalwart referential integrity).
- Domains page + add wizard on real data; sidebar badge counts domains needing
  attention.

Users & groups (customer-admin):
- Create a member provisioned across Authentik SSO, a Stalwart mailbox on the
  tenant's primary domain, and OCIS — returning a one-time password.
- Lifecycle: suspend/resume (Authentik is_active + freeze the mailbox via
  account permissions, original password preserved), force-logout (terminate
  sessions, filtered client-side so it can never end other users' sessions),
  reset password (new one-time password on SSO + mailbox), and remove (tear down
  mailbox + SSO identity + OCIS + doc; mailbox-in-use aware for multi-tenant
  users). Self-suspend / self-force-logout are blocked.

Infra: point platform-api at the internal Stalwart listener; document the new
STALWART_/provisioning vars in .env.example.
This commit is contained in:
Ronni Baslund
2026-06-01 21:19:42 +02:00
parent 2a43a7bbf3
commit 47eb9502f8
40 changed files with 3235 additions and 554 deletions
@@ -0,0 +1,114 @@
import { Prop, Schema, SchemaFactory } from '@nestjs/mongoose'
import { HydratedDocument, Types } from 'mongoose'
export type DomainDocument = HydratedDocument<Domain>
// Overall lifecycle of a customer email domain:
// pending — created in Stalwart, ownership TXT not yet seen
// verifying — ownership confirmed, mail records still propagating / failing
// active — every required record (MX/SPF/DKIM/DMARC) resolves correctly
// error — Stalwart provisioning failed (see stalwart.error)
export type DomainStatus = 'pending' | 'verifying' | 'active' | 'error'
// Per-record verification tone — mirrors the frontend DNS_FIX semantics exactly
// (ok / warn / bad), plus `pending` for "not checked yet".
export type RecordStatus = 'ok' | 'warn' | 'bad' | 'pending'
// Which DNS concern a record belongs to. `ownership` is the one-time TXT proving
// the customer controls the domain; the other four map to the UI's status slots.
export type RecordKind = 'ownership' | 'mx' | 'spf' | 'dkim' | 'dmarc'
export type DmarcPolicy = 'none' | 'quarantine' | 'reject'
// A single expected DNS record + the last observed result. `expected` is the
// authoritative value (from Stalwart's dnsZoneFile, except ownership which we
// mint). `host` is the relative name to paste at a DNS provider ('@' for apex);
// `fqdn` is the full name we actually query.
@Schema({ _id: false })
export class DomainRecord {
@Prop({ required: true, enum: ['ownership', 'mx', 'spf', 'dkim', 'dmarc'] })
kind!: RecordKind
@Prop({ required: true })
type!: string // DNS record type: 'TXT' | 'MX' | 'CNAME'
@Prop({ required: true })
host!: string // relative host, e.g. '@', '_dmarc', 'sel._domainkey'
@Prop({ required: true })
fqdn!: string // full name queried, e.g. '_dmarc.acme.dk'
@Prop({ required: true })
expected!: string
@Prop({ type: Number })
priority?: number // MX only
@Prop()
observed?: string
@Prop({ enum: ['ok', 'warn', 'bad', 'pending'], default: 'pending' })
status!: RecordStatus
@Prop()
checkedAt?: Date
}
export const DomainRecordSchema = SchemaFactory.createForClass(DomainRecord)
// A customer-owned email domain. Distinct from the loose `tenant.domains`
// string[] (kept as a denormalised primary-host convenience) — this collection
// holds the per-domain provisioning + DNS-verification state behind the
// customer-admin Domains page.
@Schema({ collection: 'domains', timestamps: true })
export class Domain {
@Prop({ type: Types.ObjectId, ref: 'Tenant', required: true, index: true })
tenantId!: Types.ObjectId
@Prop({ required: true, lowercase: true, trim: true, index: true })
domain!: string
// First domain added for a tenant is primary. Display-only for now.
@Prop({ default: false })
isPrimary!: boolean
// Random token published as a `_dezky-verify.<domain>` TXT to prove ownership.
@Prop({ required: true })
verificationToken!: string
@Prop({ default: false })
ownershipVerified!: boolean
@Prop()
verifiedAt?: Date
// Customer's chosen DMARC enforcement level (wizard step 5). Drives the
// expected `_dmarc` value we verify against. Stalwart defaults to reject.
@Prop({ enum: ['none', 'quarantine', 'reject'], default: 'quarantine' })
dmarcPolicy!: DmarcPolicy
// Stalwart x:Domain handle + last provisioning error.
@Prop({ index: true, sparse: true })
stalwartId?: string
@Prop({ default: false })
stalwartProvisioned!: boolean
@Prop()
stalwartError?: string
// Snapshot of the last expected-vs-observed diff. Replaced wholesale on each
// recheck. Empty until the first check runs.
@Prop({ type: [DomainRecordSchema], default: [] })
records!: DomainRecord[]
@Prop({ enum: ['pending', 'verifying', 'active', 'error'], default: 'pending', index: true })
status!: DomainStatus
@Prop()
lastCheckedAt?: Date
}
export const DomainSchema = SchemaFactory.createForClass(Domain)
// A tenant can only register a given domain once.
DomainSchema.index({ tenantId: 1, domain: 1 }, { unique: true })