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
@@ -70,6 +70,52 @@ export class AuthentikClient {
return created
}
// Fully delete a user from Authentik (used when a member is removed from their
// last tenant). 404 is tolerated so a re-run after a partial removal is safe.
async deleteUser(userPk: number): Promise<void> {
const res = await fetch(`${this.base}/core/users/${userPk}/`, {
method: 'DELETE',
headers: { Authorization: `Bearer ${this.token}` },
})
if (!res.ok && res.status !== 404) {
const body = await res.text().catch(() => '')
throw new Error(`Authentik DELETE user ${userPk}${res.status}: ${body.slice(0, 200)}`)
}
this.logger.log(`Deleted Authentik user ${userPk}`)
}
// Enable / disable a user. is_active=false blocks all sign-in (portal, SSO,
// and OCIS-via-SSO) without deleting anything — the basis of suspend/resume.
async setUserActive(userPk: number, active: boolean): Promise<void> {
await this.request(`/core/users/${userPk}/`, {
method: 'PATCH',
body: JSON.stringify({ is_active: active }),
})
this.logger.log(`Set Authentik user ${userPk} is_active=${active}`)
}
// Force-logout: terminate the user's active sessions so they must sign in
// again. Returns how many were terminated. We pass the `?user=` filter AND
// re-filter client-side on the session's `user` pk — Authentik's endpoint
// silently ignores an unknown query filter, which would otherwise return (and
// delete) EVERY user's session. The client-side filter makes that impossible.
async terminateSessions(userPk: number): Promise<number> {
const res = await this.request<{ results: Array<{ uuid: string; user: number }> }>(
`/core/authenticated_sessions/?user=${userPk}`,
)
const sessions = (res.results ?? []).filter((s) => s.user === userPk)
await Promise.all(
sessions.map((s) =>
fetch(`${this.base}/core/authenticated_sessions/${s.uuid}/`, {
method: 'DELETE',
headers: { Authorization: `Bearer ${this.token}` },
}).catch(() => {}),
),
)
this.logger.log(`Terminated ${sessions.length} Authentik session(s) for user ${userPk}`)
return sessions.length
}
async deleteGroup(groupId: string): Promise<void> {
const res = await fetch(`${this.base}/core/groups/${groupId}/`, {
method: 'DELETE',
@@ -165,6 +165,34 @@ export class OcisClient {
return (await res.json()) as T
}
// Write-capable variant of request() for POST/DELETE libregraph calls.
private async mutate<T>(
method: 'POST' | 'DELETE',
path: string,
body?: unknown,
): Promise<T | undefined> {
const token = await this.getToken()
const res = await fetch(`${this.base}${path}`, {
method,
headers: {
Authorization: `Bearer ${token}`,
Accept: 'application/json',
...(body ? { 'Content-Type': 'application/json' } : {}),
},
body: body ? JSON.stringify(body) : undefined,
})
if (!res.ok) {
if (res.status === 401) {
this.accessToken = undefined
this.accessExpiresAt = 0
}
const text = await res.text().catch(() => '')
throw new Error(`OCIS ${method} ${path}${res.status}: ${text.slice(0, 200)}`)
}
if (res.status === 204 || res.headers.get('content-length') === '0') return undefined
return (await res.json()) as T
}
// List all drives, optionally filtered with an OData $filter expression
// (e.g. `driveType eq 'personal'`). Requires the OCIS admin role. libregraph
// caps the page at 100 items; a tenant's personal drives stay well under that.
@@ -183,6 +211,43 @@ export class OcisClient {
return body.value ?? []
}
// Proactively create the OCIS account so the user shows up immediately. OCIS
// runs with PROXY_AUTOPROVISION_ACCOUNTS, so it ALSO creates the account (and
// personal drive) on the user's first SSO login — this just does it up front.
// `username` must match the OIDC claim OCIS keys on (preferred_username, which
// is the Authentik username = the user's email here). Returns the libregraph
// user id, or { deferred: true } if creation isn't available (external-IdP
// setups can reject graph user-create) so the caller falls back to autoprovision.
async ensureUser(input: {
username: string
displayName: string
mail: string
}): Promise<{ id?: string; deferred: boolean }> {
if (!this.configured) return { deferred: true }
try {
const user = await this.mutate<{ id?: string }>('POST', '/graph/v1.0/users', {
onPremisesSamAccountName: input.username,
displayName: input.displayName,
mail: input.mail,
accountEnabled: true,
})
this.logger.log(`Created OCIS user ${input.mail} (id=${user?.id})`)
return { id: user?.id, deferred: false }
} catch (err) {
this.logger.warn(
`OCIS user pre-create unavailable for ${input.mail} (${(err as Error).message.slice(0, 120)}) — will auto-provision on first sign-in`,
)
return { deferred: true }
}
}
async deleteUser(id: string): Promise<void> {
if (!this.configured) return
await this.mutate('DELETE', `/graph/v1.0/users/${id}`).catch((err) => {
this.logger.error(`OCIS user delete failed (id=${id}): ${(err as Error).message}`)
})
}
// ── Provisioning (stubbed) ────────────────────────────────────────────────
// Real implementation needs POST /graph/v1.0/drives { name, driveType:
// 'project' } to create a space and assign it to the tenant's group / users.
@@ -1,30 +1,351 @@
import { Injectable, Logger } from '@nestjs/common'
import { ConfigService } from '@nestjs/config'
// Stalwart v0.16 removed the REST management API — all admin operations now go
// through the JMAP /jmap endpoint with Principal/set, Domain/set, etc. method
// calls. Implementing a JMAP client is meaningful work and out of scope for
// Phase 4. Stubbed for now; the orchestration code records this as 'skipped'.
// Stalwart v0.16 removed the REST management API (`/api/principal`, `/api/dkim`,
// `/api/dns/records` all 404). All admin operations now go through the JMAP
// endpoint at `${base}/jmap` using the Stalwart-specific `urn:stalwart:jmap`
// capability. Domains are `x:Domain` objects (NOT JMAP principals — `type:
// "domain"` is rejected); DKIM signatures are `x:DkimSignature` objects.
//
// TODO (follow-up): Build a minimal JMAP client that wraps Principal/set + the
// DKIM key generation method. See https://stalw.art/docs/api/management/overview
// Auth is HTTP Basic with the fallback admin from config.toml
// (admin / STALWART_ADMIN_PASSWORD). Calls go to the internal docker hostname
// `http://stalwart:8080` — NOT the public `https://mail.dezky.local`, which is
// Traefik + a mkcert cert that Node's fetch rejects.
//
// Creating a domain auto-generates its DKIM keys (dkimManagement defaults to
// "Automatic") and leaves DNS as "Manual" (the customer publishes the records).
// The full set of records to publish comes back on the domain's server-set
// `dnsZoneFile` field as BIND zone text — we parse it rather than computing the
// records ourselves, so the DKIM public keys etc. are always authoritative.
const JMAP_USING = ['urn:ietf:params:jmap:core', 'urn:stalwart:jmap']
// A single DNS record extracted from a domain's `dnsZoneFile`. `fqdn` carries
// the trailing-dot-stripped name (e.g. `_dmarc.acme.dk`); `value` is the
// unquoted record data (TXT strings joined). `priority` is set for MX only.
export interface StalwartZoneRecord {
fqdn: string
type: string // 'MX' | 'TXT' | 'CNAME' | 'SRV' | …
value: string
priority?: number
}
// What the WebAdmin's x:Domain/get returns (only the fields we read).
interface StalwartDomain {
id: string
name: string
dnsZoneFile?: string
}
type JmapMethodCall = [string, Record<string, unknown>, string]
type JmapMethodResponse = [string, Record<string, any>, string]
@Injectable()
export class StalwartClient {
private readonly logger = new Logger(StalwartClient.name)
private readonly base: string
private readonly authHeader: string
// Live provisioning is gated like billing's `stripeLive`: off by default so
// dev without a reachable Stalwart (or without the flag) records 'skipped'
// instead of erroring. Requires the flag AND an admin password.
readonly enabled: boolean
constructor(config: ConfigService) {
this.base = config.getOrThrow<string>('STALWART_API_URL')
const user = config.get<string>('STALWART_ADMIN_USER') || 'admin'
const password = config.get<string>('STALWART_ADMIN_PASSWORD') || ''
this.authHeader = `Basic ${Buffer.from(`${user}:${password}`).toString('base64')}`
this.enabled =
config.get<string>('STALWART_PROVISIONING_ENABLED') === 'true' && !!password
if (!this.enabled) {
this.logger.warn(
'Stalwart provisioning disabled (STALWART_PROVISIONING_ENABLED != true or no admin password) — domain steps record as skipped.',
)
}
}
async ensureDomain(domain: string, _description?: string): Promise<{ name: string }> {
this.logger.warn(
`Stalwart domain provisioning is stubbed — would create "${domain}" via JMAP at ${this.base}/jmap`,
// Static config present and provisioning turned on. DomainsService checks this
// to decide between a real call and the honest "skipped" state.
get configured(): boolean {
return this.enabled
}
// Run one or more JMAP method calls. Returns the methodResponses array. A
// request-level error (e.g. malformed envelope) comes back as a flat
// {type,status,detail} object with no methodResponses — surfaced as a throw.
private async jmap(methodCalls: JmapMethodCall[]): Promise<JmapMethodResponse[]> {
const res = await fetch(`${this.base}/jmap`, {
method: 'POST',
headers: {
Authorization: this.authHeader,
'Content-Type': 'application/json',
Accept: 'application/json',
},
body: JSON.stringify({ using: JMAP_USING, methodCalls }),
})
const text = await res.text()
if (!res.ok) {
throw new Error(`Stalwart JMAP → ${res.status}: ${text.slice(0, 300)}`)
}
let json: { methodResponses?: JmapMethodResponse[] }
try {
json = JSON.parse(text)
} catch {
throw new Error(`Stalwart JMAP returned non-JSON: ${text.slice(0, 200)}`)
}
if (!json.methodResponses) {
throw new Error(`Stalwart JMAP error: ${text.slice(0, 300)}`)
}
return json.methodResponses
}
// The Stalwart domain id for a name, or undefined if it doesn't exist.
private async findDomainId(name: string): Promise<string | undefined> {
const resp = await this.jmap([['x:Domain/query', { filter: { name } }, '0']])
const ids = resp[0]?.[1]?.ids as string[] | undefined
return ids?.[0]
}
// Idempotent: returns the existing domain id if present, otherwise creates the
// domain (which auto-generates its DKIM keys) and returns the new id.
async ensureDomain(name: string, _description?: string): Promise<{ id: string }> {
const existing = await this.findDomainId(name)
if (existing) {
this.logger.log(`Stalwart domain "${name}" already exists (id=${existing})`)
return { id: existing }
}
const resp = await this.jmap([['x:Domain/set', { create: { d1: { name } } }, '0']])
const result = resp[0][1]
const created = result.created?.d1
if (!created?.id) {
const err = result.notCreated?.d1
throw new Error(`Stalwart domain create failed for "${name}": ${JSON.stringify(err)}`)
}
this.logger.log(`Created Stalwart domain "${name}" (id=${created.id})`)
return { id: created.id }
}
// Fetch the domain's authoritative DNS records (parsed from its dnsZoneFile).
// Returns [] if the domain isn't found. Used to populate the expected records
// the customer must publish, including the live DKIM public keys.
async getZoneRecords(name: string): Promise<StalwartZoneRecord[]> {
const resp = await this.jmap([
['x:Domain/query', { filter: { name } }, '0'],
[
'x:Domain/get',
{ '#ids': { resultOf: '0', name: 'x:Domain/query', path: '/ids' } },
'1',
],
])
const domain = (resp[1]?.[1]?.list as StalwartDomain[] | undefined)?.[0]
if (!domain?.dnsZoneFile) return []
return parseZoneFile(domain.dnsZoneFile)
}
// Delete a domain. Stalwart enforces referential integrity: a domain can't be
// destroyed while anything links to it, reported as notDestroyed/objectIsLinked
// with the linked object ids. The auto-generated DKIM signatures always link,
// so we remove those and retry — but any OTHER link (accounts, aliases, mailing
// lists) is real user data, so we refuse with a DomainInUseError and let the
// caller surface it. 404-equivalent (no such domain) is a silent no-op.
async deleteDomain(name: string): Promise<void> {
const id = await this.findDomainId(name)
if (!id) return
const resp = await this.jmap([['x:Domain/set', { destroy: [id] }, '0']])
const result = resp[0][1]
if ((result.destroyed as string[] | undefined)?.includes(id)) {
this.logger.log(`Deleted Stalwart domain "${name}" (id=${id})`)
return
}
const notDestroyed = result.notDestroyed?.[id]
if (notDestroyed?.type === 'objectIsLinked') {
const links: StalwartLinkedObject[] = notDestroyed.linkedObjects ?? []
const blockers = links.filter((o) => o.object !== 'DkimSignature')
if (blockers.length) {
// The (failed) destroy above mutated nothing, so the domain is untouched.
throw new DomainInUseError(name, blockers)
}
const dkimIds = links.filter((o) => o.object === 'DkimSignature').map((o) => o.id)
await this.jmap([
['x:DkimSignature/set', { destroy: dkimIds }, '0'],
['x:Domain/set', { destroy: [id] }, '1'],
])
this.logger.log(
`Deleted Stalwart domain "${name}" (id=${id}) after removing ${dkimIds.length} DKIM signature(s)`,
)
return
}
throw new Error(
`Stalwart domain delete failed for "${name}": ${JSON.stringify(notDestroyed)}`,
)
return { name: domain }
}
async deleteDomain(domain: string): Promise<void> {
this.logger.warn(`Stalwart domain delete is stubbed — would delete "${domain}"`)
// Create a user mailbox on a domain. The account's address is name@domain
// (Stalwart forms it from the domain), and the password lets the user sign in
// to webmail / IMAP / SMTP. `credentials` is an index-keyed MAP (not an array)
// — a quirk of Stalwart's patch format. Returns the new account id.
async createMailbox(input: {
domainId: string
localPart: string
fullName: string
password: string
}): Promise<{ id: string }> {
const resp = await this.jmap([
[
'x:Account/set',
{
create: {
u1: {
'@type': 'User',
name: input.localPart,
domainId: input.domainId,
description: input.fullName,
credentials: { '0': { '@type': 'Password', secret: input.password } },
},
},
},
'0',
],
])
const result = resp[0][1]
const created = result.created?.u1
if (!created?.id) {
const err = result.notCreated?.u1
throw new Error(`Stalwart mailbox create failed for "${input.localPart}": ${JSON.stringify(err)}`)
}
this.logger.log(`Created Stalwart mailbox "${input.localPart}" (id=${created.id})`)
return { id: created.id }
}
// Freeze / unfreeze a mailbox. Suspending disables the authenticate / send /
// receive permissions (so they can't sign in, send, or receive), while keeping
// the account + password intact — resuming restores the inherited defaults, so
// the user's original credential works again.
async setMailboxSuspended(accountId: string, suspended: boolean): Promise<void> {
const permissions = suspended
? {
'@type': 'Merge',
disabledPermissions: { authenticate: true, emailReceive: true, emailSend: true },
}
: { '@type': 'Inherit' }
const resp = await this.jmap([
['x:Account/set', { update: { [accountId]: { permissions } } }, '0'],
])
const notUpdated = resp[0][1].notUpdated?.[accountId]
if (notUpdated) {
throw new Error(
`Stalwart mailbox ${suspended ? 'suspend' : 'resume'} failed (id=${accountId}): ${JSON.stringify(notUpdated)}`,
)
}
}
// Set a new mailbox password (replaces the primary credential).
async setMailboxPassword(accountId: string, password: string): Promise<void> {
const resp = await this.jmap([
[
'x:Account/set',
{ update: { [accountId]: { credentials: { '0': { '@type': 'Password', secret: password } } } } },
'0',
],
])
const notUpdated = resp[0][1].notUpdated?.[accountId]
if (notUpdated) {
throw new Error(`Stalwart mailbox password update failed (id=${accountId}): ${JSON.stringify(notUpdated)}`)
}
}
// Delete a mailbox by account id. Missing id is a silent no-op.
async deleteMailbox(accountId: string): Promise<void> {
const resp = await this.jmap([['x:Account/set', { destroy: [accountId] }, '0']])
const result = resp[0][1]
if ((result.destroyed as string[] | undefined)?.includes(accountId)) {
this.logger.log(`Deleted Stalwart mailbox (id=${accountId})`)
return
}
const notDestroyed = result.notDestroyed?.[accountId]
if (notDestroyed && notDestroyed.type !== 'notFound') {
throw new Error(`Stalwart mailbox delete failed (id=${accountId}): ${JSON.stringify(notDestroyed)}`)
}
}
}
export interface StalwartLinkedObject {
object: string // 'DkimSignature' | 'MailingList' | 'Account' | …
id: string
}
// Thrown when a domain still has accounts, aliases or mailing lists in Stalwart
// and therefore can't be removed. `linkedObjects` excludes the auto-generated
// DKIM signatures (which we remove automatically).
export class DomainInUseError extends Error {
constructor(
public readonly domain: string,
public readonly linkedObjects: StalwartLinkedObject[],
) {
super(`Domain "${domain}" is still in use by ${linkedObjects.length} mail object(s)`)
this.name = 'DomainInUseError'
}
}
// ── BIND zone-file parsing ───────────────────────────────────────────────────
// Stalwart's dnsZoneFile is BIND zone text. RSA DKIM records span multiple lines
// with parenthesised, concatenated quoted strings, e.g.:
// sel._domainkey.acme.dk. IN TXT (
// "v=DKIM1; k=rsa; ... "
// "...more base64..."
// )
// We first fold parenthesised groups onto one logical line, then tokenise each
// line as `<name> IN <type> <rest>`. Exported for unit testing.
export function parseZoneFile(zone: string): StalwartZoneRecord[] {
const records: StalwartZoneRecord[] = []
for (const line of foldZoneLines(zone)) {
const trimmed = line.trim()
if (!trimmed || trimmed.startsWith(';')) continue
// <name> IN <TYPE> <rest…>
const m = trimmed.match(/^(\S+)\s+IN\s+(\S+)\s+(.+)$/)
if (!m) continue
const [, rawName, type, rest] = m
const fqdn = rawName.replace(/\.$/, '')
if (type === 'TXT') {
records.push({ fqdn, type, value: unquoteTxt(rest) })
} else if (type === 'MX') {
const mx = rest.match(/^(\d+)\s+(\S+)$/)
if (mx) {
records.push({ fqdn, type, priority: Number(mx[1]), value: mx[2].replace(/\.$/, '') })
}
} else {
records.push({ fqdn, type, value: rest.trim().replace(/\.$/, '') })
}
}
return records
}
// Collapse parenthesised multi-line records into single logical lines.
function foldZoneLines(zone: string): string[] {
const out: string[] = []
let buffer = ''
let depth = 0
for (const raw of zone.split('\n')) {
const line = raw
for (const ch of line) {
if (ch === '(') depth++
else if (ch === ')') depth = Math.max(0, depth - 1)
}
buffer += (buffer ? ' ' : '') + line.replace(/[()]/g, ' ').trim()
if (depth === 0) {
out.push(buffer)
buffer = ''
}
}
if (buffer) out.push(buffer)
return out
}
// Join the quoted character-strings of a TXT record into its logical value.
// `"v=DKIM1; ..." "more"` → `v=DKIM1; ...more`.
function unquoteTxt(rest: string): string {
const parts = rest.match(/"((?:[^"\\]|\\.)*)"/g)
if (!parts) return rest.trim()
return parts.map((p) => p.slice(1, -1)).join('')
}