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,131 @@
import { Injectable, Logger } from '@nestjs/common'
import { Resolver } from 'node:dns/promises'
import type { DomainRecord, RecordStatus } from '../schemas/domain.schema.js'
export interface CheckResult {
observed?: string
status: RecordStatus
}
// Verifies a domain's DNS records against what Stalwart expects. Uses a
// dedicated resolver pointed at public DNS (Cloudflare / Google) rather than the
// container's system resolver, so results reflect real-world propagation and
// aren't skewed by Docker's internal resolver cache. The instance is private —
// we never touch the global `dns.setServers`, which would affect the rest of
// the app (Authentik/OCIS/Stripe fetches).
//
// Tone semantics mirror the customer-admin DNS_FIX copy on the Domains page:
// ok — record present and correct
// warn — present but weak/secondary (e.g. SPF ~all, DMARC p=none)
// bad — missing or wrong
@Injectable()
export class DnsVerifierService {
private readonly logger = new Logger(DnsVerifierService.name)
private readonly resolver: Resolver
constructor() {
this.resolver = new Resolver({ timeout: 5000, tries: 2 })
this.resolver.setServers(['1.1.1.1', '8.8.8.8'])
}
// Dispatch a single expected record to the right check. The DomainsService
// calls this for every record on a (re)check and writes back observed+status.
async check(record: DomainRecord, domain: string): Promise<CheckResult> {
switch (record.kind) {
case 'ownership':
return this.checkOwnership(record.fqdn, record.expected)
case 'mx':
return this.checkMx(domain, record.expected)
case 'spf':
return this.checkSpf(domain, record.expected)
case 'dkim':
return this.checkDkim(record.fqdn, record.expected)
case 'dmarc':
return this.checkDmarc(domain)
default:
return { status: 'pending' }
}
}
// Resolve TXT, joining each record's character-strings into one value.
private async txt(fqdn: string): Promise<string[]> {
try {
const recs = await this.resolver.resolveTxt(fqdn)
return recs.map((chunks) => chunks.join(''))
} catch {
// NXDOMAIN / NODATA / SERVFAIL → treat as "no record".
return []
}
}
// Ownership: the `_dezky-verify.<domain>` TXT must contain our token verbatim.
private async checkOwnership(fqdn: string, token: string): Promise<CheckResult> {
const records = await this.txt(fqdn)
const hit = records.find((v) => v.includes(token))
return hit ? { observed: hit, status: 'ok' } : { status: 'bad' }
}
// MX: at least one exchange must point at the expected host. A record that
// exists but doesn't match is a secondary/foreign MX → warn (allowed for
// failover, but the customer should know).
private async checkMx(domain: string, expectedHost: string): Promise<CheckResult> {
let mx: { exchange: string; priority: number }[]
try {
mx = await this.resolver.resolveMx(domain)
} catch {
return { status: 'bad' }
}
if (!mx.length) return { status: 'bad' }
const norm = (h: string) => h.replace(/\.$/, '').toLowerCase()
const want = norm(expectedHost)
const observed = mx
.sort((a, b) => a.priority - b.priority)
.map((r) => `${r.priority} ${norm(r.exchange)}`)
.join(', ')
const match = mx.some((r) => norm(r.exchange) === want)
return { observed, status: match ? 'ok' : 'warn' }
}
// SPF: find the v=spf1 record at the apex. Correct = authorises our sender
// (the expected mechanism — `mx` or an `include:` — is present) AND hardfails
// with `-all`. A softfail (`~all`/`?all`) or a missing mechanism is warn;
// no SPF record at all is bad.
private async checkSpf(domain: string, expected: string): Promise<CheckResult> {
const records = await this.txt(domain)
const spf = records.find((v) => /^v=spf1\b/i.test(v.trim()))
if (!spf) return { status: 'bad' }
const mechanism = expected.match(/include:\S+/i)?.[0] ?? 'mx'
const hasMechanism = new RegExp(`(^|\\s)${escapeRegex(mechanism)}(\\s|$)`, 'i').test(spf)
const hardFail = /[\s]-all\b/.test(spf) || /(^|\s)-all$/.test(spf.trim())
if (hasMechanism && hardFail) return { observed: spf, status: 'ok' }
return { observed: spf, status: 'warn' }
}
// DKIM: the selector TXT must carry the same public key Stalwart generated.
// We compare the `p=` value. Present-but-different = warn; absent = bad.
private async checkDkim(fqdn: string, expected: string): Promise<CheckResult> {
const records = await this.txt(fqdn)
const dkim = records.find((v) => /(^|;)\s*v=DKIM1/i.test(v) || /k=(rsa|ed25519)/i.test(v))
if (!dkim) return { status: 'bad' }
const expectedKey = expected.match(/p=([A-Za-z0-9+/=]+)/)?.[1]
const observedKey = dkim.match(/p=([A-Za-z0-9+/=]+)/)?.[1]
if (expectedKey && observedKey && expectedKey === observedKey) {
return { observed: dkim, status: 'ok' }
}
return { observed: dkim, status: 'warn' }
}
// DMARC: a `_dmarc` TXT must exist. p=none is monitor-only (warn); any
// stronger policy (quarantine/reject) is ok; no record is bad.
private async checkDmarc(domain: string): Promise<CheckResult> {
const records = await this.txt(`_dmarc.${domain}`)
const dmarc = records.find((v) => /^v=DMARC1\b/i.test(v.trim()))
if (!dmarc) return { status: 'bad' }
const policy = dmarc.match(/(^|;)\s*p=(\w+)/i)?.[2]?.toLowerCase()
return { observed: dmarc, status: policy === 'none' ? 'warn' : 'ok' }
}
}
function escapeRegex(s: string): string {
return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
}
@@ -0,0 +1,118 @@
import {
Body,
Controller,
Delete,
ForbiddenException,
Get,
HttpCode,
Param,
Patch,
Post,
Req,
UseGuards,
} from '@nestjs/common'
import { ActorService } from '../auth/actor.service.js'
import { clientIp } from '../auth/client-ip.js'
import { CurrentUser } from '../auth/current-user.decorator.js'
import { JwtAuthGuard } from '../auth/jwt-auth.guard.js'
import type { AuthentikJwtPayload } from '../auth/jwt-payload.interface.js'
import type { AuditActor } from '../audit/audit.service.js'
import { TenantsService } from '../tenants/tenants.service.js'
import { AddDomainDto } from './dto/add-domain.dto.js'
import { SetDmarcPolicyDto } from './dto/set-dmarc-policy.dto.js'
import { DomainsService, type TenantRef } from './domains.service.js'
function auditActor(
user: { _id: unknown; email: string },
req: Parameters<typeof clientIp>[0],
): AuditActor {
return { userId: String(user._id), email: user.email, ip: clientIp(req) }
}
// Customer-admin domain management, mounted under the tenant. Same membership
// gate as the other tenant-scoped portal resources (GET :slug/users etc.): any
// member of the tenant — or a platform admin — may manage its domains.
@Controller('tenants/:slug/domains')
@UseGuards(JwtAuthGuard)
export class DomainsController {
constructor(
private readonly domains: DomainsService,
private readonly tenants: TenantsService,
private readonly actor: ActorService,
) {}
// Resolve the tenant and assert the caller belongs to it (or is a platform
// admin). Returns the lightweight ref the service works with.
private async gate(slug: string, jwt: AuthentikJwtPayload): Promise<TenantRef> {
const actor = await this.actor.resolve(jwt)
const tenant = await this.tenants.findOneBySlug(slug)
if (!actor.platformAdmin && !actor.tenantIds.some((id) => id.equals(tenant._id))) {
throw new ForbiddenException(`No access to tenant "${slug}"`)
}
return { _id: tenant._id, slug: tenant.slug }
}
@Get()
async list(@Param('slug') slug: string, @CurrentUser() jwt: AuthentikJwtPayload) {
const tenant = await this.gate(slug, jwt)
return this.domains.list(tenant)
}
@Post()
async add(
@Param('slug') slug: string,
@Body() dto: AddDomainDto,
@CurrentUser() jwt: AuthentikJwtPayload,
@Req() req: Parameters<typeof clientIp>[0],
) {
const tenant = await this.gate(slug, jwt)
const user = await this.actor.resolve(jwt)
return this.domains.add(tenant, dto.domain, auditActor(user, req))
}
@Get(':domain')
async getOne(
@Param('slug') slug: string,
@Param('domain') domain: string,
@CurrentUser() jwt: AuthentikJwtPayload,
) {
const tenant = await this.gate(slug, jwt)
return this.domains.getOne(tenant, domain)
}
@Post(':domain/recheck')
async recheck(
@Param('slug') slug: string,
@Param('domain') domain: string,
@CurrentUser() jwt: AuthentikJwtPayload,
) {
const tenant = await this.gate(slug, jwt)
return this.domains.recheck(tenant, domain)
}
@Patch(':domain/dmarc')
async setDmarc(
@Param('slug') slug: string,
@Param('domain') domain: string,
@Body() dto: SetDmarcPolicyDto,
@CurrentUser() jwt: AuthentikJwtPayload,
@Req() req: Parameters<typeof clientIp>[0],
) {
const tenant = await this.gate(slug, jwt)
const user = await this.actor.resolve(jwt)
return this.domains.setDmarcPolicy(tenant, domain, dto.dmarcPolicy, auditActor(user, req))
}
@Delete(':domain')
@HttpCode(204)
async remove(
@Param('slug') slug: string,
@Param('domain') domain: string,
@CurrentUser() jwt: AuthentikJwtPayload,
@Req() req: Parameters<typeof clientIp>[0],
) {
const tenant = await this.gate(slug, jwt)
const user = await this.actor.resolve(jwt)
await this.domains.remove(tenant, domain, auditActor(user, req))
}
}
@@ -0,0 +1,30 @@
import { Module } from '@nestjs/common'
import { MongooseModule } from '@nestjs/mongoose'
import { AuditModule } from '../audit/audit.module.js'
import { AuthModule } from '../auth/auth.module.js'
import { IntegrationsModule } from '../integrations/integrations.module.js'
import { Domain, DomainSchema } from '../schemas/domain.schema.js'
import { Tenant, TenantSchema } from '../schemas/tenant.schema.js'
import { User, UserSchema } from '../schemas/user.schema.js'
import { TenantsModule } from '../tenants/tenants.module.js'
import { DnsVerifierService } from './dns-verifier.service.js'
import { DomainsController } from './domains.controller.js'
import { DomainsService } from './domains.service.js'
@Module({
imports: [
MongooseModule.forFeature([
{ name: Domain.name, schema: DomainSchema },
{ name: Tenant.name, schema: TenantSchema },
{ name: User.name, schema: UserSchema },
]),
AuthModule,
AuditModule,
IntegrationsModule,
TenantsModule, // TenantsService — resolve tenant by slug for the membership gate
],
controllers: [DomainsController],
providers: [DomainsService, DnsVerifierService],
exports: [DomainsService],
})
export class DomainsModule {}
@@ -0,0 +1,463 @@
import {
BadRequestException,
ConflictException,
Injectable,
Logger,
NotFoundException,
} from '@nestjs/common'
import { InjectModel } from '@nestjs/mongoose'
import { randomBytes } from 'node:crypto'
import { Model, Types } from 'mongoose'
import { AuditService, type AuditActor } from '../audit/audit.service.js'
import {
DomainInUseError,
StalwartClient,
type StalwartLinkedObject,
type StalwartZoneRecord,
} from '../integrations/stalwart.client.js'
import {
Domain,
DomainDocument,
DomainRecord,
DmarcPolicy,
DomainStatus,
RecordKind,
RecordStatus,
} from '../schemas/domain.schema.js'
import { Tenant, TenantDocument } from '../schemas/tenant.schema.js'
import { User, UserDocument } from '../schemas/user.schema.js'
import { DnsVerifierService } from './dns-verifier.service.js'
// The four status slots the customer-admin Domains page renders, plus ownership.
const CHECK_KINDS: RecordKind[] = ['ownership', 'mx', 'spf', 'dkim', 'dmarc']
// Minimal tenant identity the service needs — the controller resolves the full
// doc for its membership gate and hands us this.
export interface TenantRef {
_id: Types.ObjectId
slug: string
}
@Injectable()
export class DomainsService {
private readonly logger = new Logger(DomainsService.name)
constructor(
@InjectModel(Domain.name) private readonly domainModel: Model<DomainDocument>,
@InjectModel(Tenant.name) private readonly tenantModel: Model<TenantDocument>,
@InjectModel(User.name) private readonly userModel: Model<UserDocument>,
private readonly stalwart: StalwartClient,
private readonly dns: DnsVerifierService,
private readonly audit: AuditService,
) {}
async list(tenant: TenantRef): Promise<DomainView[]> {
const docs = await this.domainModel.find({ tenantId: tenant._id }).sort({ createdAt: 1 }).exec()
return Promise.all(docs.map((d) => this.toView(d, tenant)))
}
async getOne(tenant: TenantRef, domain: string): Promise<DomainView> {
const doc = await this.findOrThrow(tenant, domain)
return this.toView(doc, tenant)
}
// Add a domain: provision it in Stalwart (which auto-generates DKIM), seed the
// expected records from Stalwart's zone file + our ownership token, then run an
// immediate DNS check so the page shows live status right away.
async add(tenant: TenantRef, rawDomain: string, actor: AuditActor): Promise<DomainView> {
const domain = rawDomain.trim().toLowerCase()
const existing = await this.domainModel.findOne({ tenantId: tenant._id, domain }).exec()
if (existing) throw new ConflictException(`Domain "${domain}" is already added`)
const isPrimary = (await this.domainModel.countDocuments({ tenantId: tenant._id })) === 0
const verificationToken = `dezky-verify=${randomBytes(16).toString('hex')}`
let stalwartId: string | undefined
let provisioned = false
let records: DomainRecord[] = [ownershipRecord(domain, verificationToken)]
// Provision in Stalwart BEFORE persisting. A rejected domain (bad name,
// Stalwart outage) then fails the add cleanly with the real reason, rather
// than leaving an orphaned error-domain on the customer's list. On a partial
// failure (created but couldn't read the zone) we roll back the Stalwart side.
if (this.stalwart.configured) {
try {
const created = await this.stalwart.ensureDomain(domain, `Dezky tenant ${tenant.slug}`)
stalwartId = created.id
// Stalwart generates the DKIM keypair asynchronously after the domain is
// created, so the zone file omits the DKIM records for a moment. Poll
// briefly so the very first add response already carries them.
const zone = await this.zoneWithDkim(domain)
records = this.buildRecords(domain, zone, verificationToken, 'quarantine')
provisioned = true
} catch (err) {
const msg = (err as Error).message
this.logger.error(`Stalwart provisioning failed for "${domain}": ${msg}`)
await this.stalwart.deleteDomain(domain).catch(() => {})
throw new BadRequestException(
`Could not provision "${domain}" in the mail server: ${cleanStalwartError(msg)}`,
)
}
}
const doc = await this.domainModel.create({
tenantId: tenant._id,
domain,
isPrimary,
verificationToken,
dmarcPolicy: 'quarantine',
status: 'pending',
stalwartId,
stalwartProvisioned: provisioned,
records,
})
await this.tenantModel.updateOne({ _id: tenant._id }, { $addToSet: { domains: domain } }).exec()
await this.audit.record(
{
action: 'domain.added',
resourceType: 'domain',
resourceId: domain,
resourceName: domain,
tenantSlug: tenant.slug,
metadata: { stalwartProvisioned: doc.stalwartProvisioned, isPrimary },
},
actor,
)
// Immediate check so the customer sees real status without a manual refresh.
await this.runChecks(doc)
return this.toView(doc, tenant)
}
// Re-run the DNS checks for a domain on demand (the "Re-check" buttons).
async recheck(tenant: TenantRef, domain: string): Promise<DomainView> {
const doc = await this.findOrThrow(tenant, domain)
// (Re)seed expected records if they're incomplete — Stalwart was unreachable
// at add time, or the async DKIM keys hadn't landed yet (no dkim record).
const hasMail = doc.records.some((r) => r.kind === 'mx')
const hasDkim = doc.records.some((r) => r.kind === 'dkim')
if (this.stalwart.configured && (!hasMail || !hasDkim)) {
try {
const { id } = await this.stalwart.ensureDomain(domain, `Dezky tenant ${tenant.slug}`)
const zone = await this.zoneWithDkim(domain)
doc.stalwartId = id
doc.stalwartProvisioned = true
doc.stalwartError = undefined
doc.records = this.buildRecords(domain, zone, doc.verificationToken, doc.dmarcPolicy)
} catch (err) {
doc.stalwartError = (err as Error).message
}
}
await this.runChecks(doc)
return this.toView(doc, tenant)
}
// Read the domain's zone records, polling briefly until the asynchronously
// generated DKIM records have all appeared (Stalwart creates the keypair just
// after the domain — and the ed25519 + RSA selectors land a beat apart). We
// wait until the DKIM count is non-zero AND stable across two reads, so we
// don't capture just the first selector. Budget-capped (~5s) either way.
private async zoneWithDkim(domain: string): Promise<StalwartZoneRecord[]> {
const dkimCount = (z: StalwartZoneRecord[]) =>
z.filter((r) => r.fqdn.includes('._domainkey.')).length
let zone = await this.stalwart.getZoneRecords(domain)
let prev = -1
for (let i = 0; i < 8; i++) {
const count = dkimCount(zone)
if (count > 0 && count === prev) break // stable — all selectors present
prev = count
await new Promise((resolve) => setTimeout(resolve, 600))
zone = await this.stalwart.getZoneRecords(domain)
}
return zone
}
// Set the DMARC enforcement level (wizard step 5) and re-verify.
async setDmarcPolicy(
tenant: TenantRef,
domain: string,
policy: DmarcPolicy,
actor: AuditActor,
): Promise<DomainView> {
const doc = await this.findOrThrow(tenant, domain)
doc.dmarcPolicy = policy
const dmarc = doc.records.find((r) => r.kind === 'dmarc')
if (dmarc) dmarc.expected = dmarcExpected(domain, policy)
await this.audit.record(
{
action: 'domain.dmarc_policy_set',
resourceType: 'domain',
resourceId: domain,
resourceName: domain,
tenantSlug: tenant.slug,
metadata: { dmarcPolicy: policy },
},
actor,
)
await this.runChecks(doc)
return this.toView(doc, tenant)
}
// Remove a domain: delete it from Stalwart (best-effort) and drop our records.
// Guarded — a domain that still has mailboxes can't be removed, or those users
// would lose their email identity. This is enforced here (not just in the UI)
// so the rule holds even for direct API callers.
async remove(tenant: TenantRef, domain: string, actor: AuditActor): Promise<void> {
const doc = await this.findOrThrow(tenant, domain)
const mailboxes = await this.mailboxCount(tenant, doc.domain)
if (mailboxes > 0) {
throw new ConflictException(
`Cannot remove "${doc.domain}" — ${mailboxes} mailbox${mailboxes === 1 ? '' : 'es'} still use${
mailboxes === 1 ? 's' : ''
} it. Remove or reassign those users first.`,
)
}
if (this.stalwart.configured) {
try {
await this.stalwart.deleteDomain(doc.domain)
} catch (err) {
// Accounts / aliases / mailing lists still on the domain — block the
// removal with a clear, actionable message rather than orphaning them.
if (err instanceof DomainInUseError) {
throw new ConflictException(
`Cannot remove "${doc.domain}" — it still has ${summarizeLinks(err.linkedObjects)} in the mail server. Remove ${err.linkedObjects.length === 1 ? 'it' : 'them'} first.`,
)
}
// Any other Stalwart failure: don't drop our record, so the two sides
// stay consistent and the customer can retry.
this.logger.error(`Stalwart delete failed for "${doc.domain}": ${(err as Error).message}`)
throw err
}
}
await this.domainModel.deleteOne({ _id: doc._id }).exec()
await this.tenantModel.updateOne({ _id: tenant._id }, { $pull: { domains: domain } }).exec()
await this.audit.record(
{
action: 'domain.removed',
resourceType: 'domain',
resourceId: domain,
resourceName: domain,
tenantSlug: tenant.slug,
},
actor,
)
}
// ── internals ──────────────────────────────────────────────────────────────
private async findOrThrow(tenant: TenantRef, domain: string): Promise<DomainDocument> {
const doc = await this.domainModel
.findOne({ tenantId: tenant._id, domain: domain.trim().toLowerCase() })
.exec()
if (!doc) throw new NotFoundException(`Domain "${domain}" not found`)
return doc
}
// Check every record against DNS, write back observed+status, recompute the
// domain's overall status, and persist. Records out to public DNS in parallel.
private async runChecks(doc: DomainDocument): Promise<void> {
const wasVerified = doc.ownershipVerified
await Promise.all(
doc.records.map(async (rec) => {
const { observed, status } = await this.dns.check(rec, doc.domain)
rec.observed = observed
rec.status = status
rec.checkedAt = new Date()
}),
)
const tone = (kind: RecordKind): RecordStatus => aggregateTone(doc.records, kind)
doc.ownershipVerified = tone('ownership') === 'ok'
if (doc.ownershipVerified && !wasVerified) doc.verifiedAt = new Date()
if (doc.stalwartError) {
doc.status = 'error'
} else if (!doc.ownershipVerified) {
doc.status = 'pending'
} else {
const mail: RecordKind[] = ['mx', 'spf', 'dkim', 'dmarc']
doc.status = mail.every((k) => tone(k) === 'ok') ? 'active' : 'verifying'
}
doc.lastCheckedAt = new Date()
doc.markModified('records')
await doc.save()
}
// Map Stalwart's parsed zone records → our verifiable record set. Always
// includes the ownership TXT (which Stalwart doesn't know about). DMARC is
// overridden to reflect the customer's chosen policy.
private buildRecords(
domain: string,
zone: StalwartZoneRecord[],
token: string,
policy: DmarcPolicy,
): DomainRecord[] {
const records: DomainRecord[] = [ownershipRecord(domain, token)]
for (const z of zone) {
const kind = classify(z, domain)
if (!kind) continue
records.push({
kind,
type: z.type,
host: relativeHost(z.fqdn, domain),
fqdn: z.fqdn,
expected: kind === 'dmarc' ? dmarcExpected(domain, policy) : z.value,
priority: z.priority,
status: 'pending',
})
}
return records
}
// Count mailboxes on this domain — tenant users whose email is @domain.
private async mailboxCount(tenant: TenantRef, domain: string): Promise<number> {
return this.userModel
.countDocuments({
tenantIds: tenant._id,
email: { $regex: `@${escapeRegex(domain)}$`, $options: 'i' },
})
.exec()
}
private async toView(doc: DomainDocument, tenant: TenantRef): Promise<DomainView> {
const mailboxes = await this.mailboxCount(tenant, doc.domain)
return {
id: String(doc._id),
domain: doc.domain,
isPrimary: doc.isPrimary,
status: doc.status,
ownershipVerified: doc.ownershipVerified,
verificationToken: doc.verificationToken,
dmarcPolicy: doc.dmarcPolicy,
stalwartProvisioned: doc.stalwartProvisioned,
stalwartError: doc.stalwartError,
mailboxes,
checks: {
ownership: aggregateTone(doc.records, 'ownership'),
mx: aggregateTone(doc.records, 'mx'),
spf: aggregateTone(doc.records, 'spf'),
dkim: aggregateTone(doc.records, 'dkim'),
dmarc: aggregateTone(doc.records, 'dmarc'),
},
records: doc.records.map((r) => ({
kind: r.kind,
type: r.type,
host: r.host,
fqdn: r.fqdn,
expected: r.expected,
priority: r.priority,
observed: r.observed,
status: r.status,
})),
lastCheckedAt: doc.lastCheckedAt?.toISOString(),
}
}
}
// ── view types ─────────────────────────────────────────────────────────────
export interface DomainRecordView {
kind: RecordKind
type: string
host: string
fqdn: string
expected: string
priority?: number
observed?: string
status: RecordStatus
}
export interface DomainView {
id: string
domain: string
isPrimary: boolean
status: DomainStatus
ownershipVerified: boolean
verificationToken: string
dmarcPolicy: DmarcPolicy
stalwartProvisioned: boolean
stalwartError?: string
mailboxes: number
checks: Record<'ownership' | 'mx' | 'spf' | 'dkim' | 'dmarc', RecordStatus>
records: DomainRecordView[]
lastCheckedAt?: string
}
// ── pure helpers ─────────────────────────────────────────────────────────────
function ownershipRecord(domain: string, token: string): DomainRecord {
return {
kind: 'ownership',
type: 'TXT',
host: '_dezky-verify',
fqdn: `_dezky-verify.${domain}`,
expected: token,
status: 'pending',
}
}
// Classify a Stalwart zone record into one of our verifiable kinds, or null for
// the records we don't surface as status slots (SRV/MTA-STS/autoconfig/…).
function classify(z: StalwartZoneRecord, domain: string): RecordKind | null {
if (z.type === 'MX' && z.fqdn === domain) return 'mx'
if (z.type === 'TXT' && z.fqdn === domain && /^v=spf1\b/i.test(z.value)) return 'spf'
if (z.type === 'TXT' && z.fqdn.endsWith(`._domainkey.${domain}`)) return 'dkim'
if (z.type === 'TXT' && z.fqdn === `_dmarc.${domain}` && /^v=DMARC1\b/i.test(z.value)) return 'dmarc'
return null
}
// Relative host for display ('@' at apex, otherwise the label prefix).
function relativeHost(fqdn: string, domain: string): string {
if (fqdn === domain) return '@'
return fqdn.endsWith(`.${domain}`) ? fqdn.slice(0, -(domain.length + 1)) : fqdn
}
function dmarcExpected(domain: string, policy: DmarcPolicy): string {
return `v=DMARC1; p=${policy}; rua=mailto:postmaster@${domain}`
}
// Aggregate per-kind tone: a kind is only 'ok' if ALL its records are ok (DKIM
// has two — ed25519 + RSA — both must be published). Worst tone wins, with a
// kind that has no records yet reading as 'pending'.
function aggregateTone(records: DomainRecord[], kind: RecordKind): RecordStatus {
const mine = records.filter((r) => r.kind === kind)
if (!mine.length) return 'pending'
const order: RecordStatus[] = ['bad', 'warn', 'pending', 'ok']
return mine
.map((r) => r.status)
.sort((a, b) => order.indexOf(a) - order.indexOf(b))[0]
}
function escapeRegex(s: string): string {
return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
}
// Human summary of what still links to a domain, e.g. "2 mailboxes and 1
// mailing list". Groups Stalwart's linked-object types and pluralises.
function summarizeLinks(links: StalwartLinkedObject[]): string {
const label: Record<string, string> = {
MailingList: 'mailing list',
Account: 'mailbox',
Principal: 'mailbox',
Individual: 'mailbox',
Group: 'group',
List: 'list',
}
const counts = new Map<string, number>()
for (const l of links) {
const name = label[l.object] ?? l.object.toLowerCase()
counts.set(name, (counts.get(name) ?? 0) + 1)
}
return [...counts.entries()]
.map(([name, n]) => `${n} ${name}${n === 1 ? '' : 's'}`)
.join(' and ')
}
// Stalwart JMAP errors arrive as a JSON blob embedded in the message, e.g.
// `…: {"type":"invalidPatch","description":"Invalid domain name",…}`. Surface
// just the human description when present, otherwise the raw message.
function cleanStalwartError(msg: string): string {
return msg.match(/"description":"([^"]+)"/)?.[1] ?? msg
}
@@ -0,0 +1,13 @@
import { IsString, Matches, MaxLength } from 'class-validator'
// Add a custom email domain to a tenant. The domain is lowercased + trimmed by
// the schema; we validate it's a plausible hostname here (no protocol, no path,
// at least one dot). Punycode/IDN is accepted as already-encoded ascii.
export class AddDomainDto {
@IsString()
@MaxLength(253)
@Matches(/^(?!-)[a-z0-9-]{1,63}(?<!-)(\.(?!-)[a-z0-9-]{1,63}(?<!-))+$/i, {
message: 'domain must be a valid hostname like acme.dk',
})
domain!: string
}
@@ -0,0 +1,9 @@
import { IsIn } from 'class-validator'
import type { DmarcPolicy } from '../../schemas/domain.schema.js'
// Set the DMARC enforcement level chosen in the add-domain wizard (step 5).
// Drives the expected `_dmarc` TXT value we verify against.
export class SetDmarcPolicyDto {
@IsIn(['none', 'quarantine', 'reject'])
dmarcPolicy!: DmarcPolicy
}