f6bac10ff3
ci / changes (push) Successful in 4s
ci / tc_booking (push) Has been skipped
ci / tc_operator (push) Has been skipped
ci / tc_website (push) Has been skipped
ci / tc_platform_api (push) Successful in 20s
ci / tc_portal (push) Failing after 27s
ci / build_booking (push) Has been skipped
ci / build_operator (push) Has been skipped
ci / build_portal (push) Has been skipped
ci / test_platform_api (push) Successful in 33s
ci / build_platform_api (push) Successful in 15s
ci / deploy (push) Failing after 3m5s
Mail clients could never autoconfigure: Stalwart's zone file contains the _imaps/_submissions/_pop3s SRV records but classify() dropped everything except mx/spf/dkim/dmarc, so customers never saw them and every client needed manual server entry. New 'autodiscovery' record kind: classified from the zone (only the services actually reachable in prod — the _jmap/_caldavs SRVs target :443 which Traefik owns, deferred to the webmail story), verified via resolveSrv (missing=bad, wrong target=warn), shown as an OPTIONAL slot on the portal Domains page that never gates the domain status or the records-to-fix nag. Also fixed on the live server via management JMAP (x:SystemSettings): hostname was the machine name node1.dezky.eu from the v0.16 auto-bootstrap — MX/SRV targets and the SMTP banner now say mail.dezky.eu, and the LE x:Certificate is set as defaultCertificateId.
118 lines
4.2 KiB
TypeScript
118 lines
4.2 KiB
TypeScript
import { Prop, Schema, SchemaFactory } from '@nestjs/mongoose'
|
|
import { HydratedDocument, Types } from 'mongoose'
|
|
|
|
export type DomainDocument = HydratedDocument<Domain>
|
|
|
|
// Overall lifecycle of a customer email domain:
|
|
// pending — created in Stalwart, ownership TXT not yet seen
|
|
// verifying — ownership confirmed, mail records still propagating / failing
|
|
// active — every required record (MX/SPF/DKIM/DMARC) resolves correctly
|
|
// error — Stalwart provisioning failed (see stalwart.error)
|
|
export type DomainStatus = 'pending' | 'verifying' | 'active' | 'error'
|
|
|
|
// Per-record verification tone — mirrors the frontend DNS_FIX semantics exactly
|
|
// (ok / warn / bad), plus `pending` for "not checked yet".
|
|
export type RecordStatus = 'ok' | 'warn' | 'bad' | 'pending'
|
|
|
|
// Which DNS concern a record belongs to. `ownership` is the one-time TXT proving
|
|
// the customer controls the domain; mx/spf/dkim/dmarc map to the UI's required
|
|
// status slots. `autodiscovery` carries the optional SRV records (RFC 6186)
|
|
// that let mail clients configure themselves from just an email address — it
|
|
// never gates the domain's overall status.
|
|
export type RecordKind = 'ownership' | 'mx' | 'spf' | 'dkim' | 'dmarc' | 'autodiscovery'
|
|
|
|
export type DmarcPolicy = 'none' | 'quarantine' | 'reject'
|
|
|
|
// A single expected DNS record + the last observed result. `expected` is the
|
|
// authoritative value (from Stalwart's dnsZoneFile, except ownership which we
|
|
// mint). `host` is the relative name to paste at a DNS provider ('@' for apex);
|
|
// `fqdn` is the full name we actually query.
|
|
@Schema({ _id: false })
|
|
export class DomainRecord {
|
|
@Prop({ required: true, enum: ['ownership', 'mx', 'spf', 'dkim', 'dmarc'] })
|
|
kind!: RecordKind
|
|
|
|
@Prop({ required: true })
|
|
type!: string // DNS record type: 'TXT' | 'MX' | 'CNAME'
|
|
|
|
@Prop({ required: true })
|
|
host!: string // relative host, e.g. '@', '_dmarc', 'sel._domainkey'
|
|
|
|
@Prop({ required: true })
|
|
fqdn!: string // full name queried, e.g. '_dmarc.acme.dk'
|
|
|
|
@Prop({ required: true })
|
|
expected!: string
|
|
|
|
@Prop({ type: Number })
|
|
priority?: number // MX only
|
|
|
|
@Prop()
|
|
observed?: string
|
|
|
|
@Prop({ enum: ['ok', 'warn', 'bad', 'pending'], default: 'pending' })
|
|
status!: RecordStatus
|
|
|
|
@Prop()
|
|
checkedAt?: Date
|
|
}
|
|
export const DomainRecordSchema = SchemaFactory.createForClass(DomainRecord)
|
|
|
|
// A customer-owned email domain. Distinct from the loose `tenant.domains`
|
|
// string[] (kept as a denormalised primary-host convenience) — this collection
|
|
// holds the per-domain provisioning + DNS-verification state behind the
|
|
// customer-admin Domains page.
|
|
@Schema({ collection: 'domains', timestamps: true })
|
|
export class Domain {
|
|
@Prop({ type: Types.ObjectId, ref: 'Tenant', required: true, index: true })
|
|
tenantId!: Types.ObjectId
|
|
|
|
@Prop({ required: true, lowercase: true, trim: true, index: true })
|
|
domain!: string
|
|
|
|
// First domain added for a tenant is primary. Display-only for now.
|
|
@Prop({ default: false })
|
|
isPrimary!: boolean
|
|
|
|
// Random token published as a `_dezky-verify.<domain>` TXT to prove ownership.
|
|
@Prop({ required: true })
|
|
verificationToken!: string
|
|
|
|
@Prop({ default: false })
|
|
ownershipVerified!: boolean
|
|
|
|
@Prop()
|
|
verifiedAt?: Date
|
|
|
|
// Customer's chosen DMARC enforcement level (wizard step 5). Drives the
|
|
// expected `_dmarc` value we verify against. Stalwart defaults to reject.
|
|
@Prop({ enum: ['none', 'quarantine', 'reject'], default: 'quarantine' })
|
|
dmarcPolicy!: DmarcPolicy
|
|
|
|
// Stalwart x:Domain handle + last provisioning error.
|
|
@Prop({ index: true, sparse: true })
|
|
stalwartId?: string
|
|
|
|
@Prop({ default: false })
|
|
stalwartProvisioned!: boolean
|
|
|
|
@Prop()
|
|
stalwartError?: string
|
|
|
|
// Snapshot of the last expected-vs-observed diff. Replaced wholesale on each
|
|
// recheck. Empty until the first check runs.
|
|
@Prop({ type: [DomainRecordSchema], default: [] })
|
|
records!: DomainRecord[]
|
|
|
|
@Prop({ enum: ['pending', 'verifying', 'active', 'error'], default: 'pending', index: true })
|
|
status!: DomainStatus
|
|
|
|
@Prop()
|
|
lastCheckedAt?: Date
|
|
}
|
|
|
|
export const DomainSchema = SchemaFactory.createForClass(Domain)
|
|
|
|
// A tenant can only register a given domain once.
|
|
DomainSchema.index({ tenantId: 1, domain: 1 }, { unique: true })
|