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