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:
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user