feat(email-domains): support creating email domains on optional subdomains

This commit is contained in:
2026-04-12 23:46:31 +00:00
parent 2cdc392a40
commit 59b52d08fa
12 changed files with 65 additions and 16 deletions

View File

@@ -1,5 +1,12 @@
# Changelog # Changelog
## 2026-04-12 - 13.12.0 - feat(email-domains)
support creating email domains on optional subdomains
- Add optional subdomain support to email domain creation, persistence, and API interfaces.
- Update the ops UI to collect and submit a subdomain prefix when creating an email domain.
- Bump @design.estate/dees-catalog from ^3.78.0 to ^3.78.2.
## 2026-04-12 - 13.11.0 - feat(email-domains) ## 2026-04-12 - 13.11.0 - feat(email-domains)
add email domain management with DNS provisioning, validation, and ops dashboard support add email domain management with DNS provisioning, validation, and ops dashboard support

View File

@@ -35,7 +35,7 @@
"@api.global/typedserver": "^8.4.6", "@api.global/typedserver": "^8.4.6",
"@api.global/typedsocket": "^4.1.2", "@api.global/typedsocket": "^4.1.2",
"@apiclient.xyz/cloudflare": "^7.1.0", "@apiclient.xyz/cloudflare": "^7.1.0",
"@design.estate/dees-catalog": "^3.78.0", "@design.estate/dees-catalog": "^3.78.2",
"@design.estate/dees-element": "^2.2.4", "@design.estate/dees-element": "^2.2.4",
"@push.rocks/lik": "^6.4.0", "@push.rocks/lik": "^6.4.0",
"@push.rocks/projectinfo": "^5.1.0", "@push.rocks/projectinfo": "^5.1.0",

43
pnpm-lock.yaml generated
View File

@@ -24,8 +24,8 @@ importers:
specifier: ^7.1.0 specifier: ^7.1.0
version: 7.1.0 version: 7.1.0
'@design.estate/dees-catalog': '@design.estate/dees-catalog':
specifier: ^3.78.0 specifier: ^3.78.2
version: 3.78.0(@tiptap/pm@2.27.2) version: 3.78.2(@tiptap/pm@2.27.2)
'@design.estate/dees-element': '@design.estate/dees-element':
specifier: ^2.2.4 specifier: ^2.2.4
version: 2.2.4 version: 2.2.4
@@ -353,8 +353,8 @@ packages:
'@configvault.io/interfaces@1.0.17': '@configvault.io/interfaces@1.0.17':
resolution: {integrity: sha512-bEcCUR2VBDJsTin8HQh8Uw/mlYl2v8A3jMIaQ+MTB9Hrqd6CZL2dL7iJdWyFl/3EIX+LDxWFR+Oq7liIq7w+1Q==} resolution: {integrity: sha512-bEcCUR2VBDJsTin8HQh8Uw/mlYl2v8A3jMIaQ+MTB9Hrqd6CZL2dL7iJdWyFl/3EIX+LDxWFR+Oq7liIq7w+1Q==}
'@design.estate/dees-catalog@3.78.0': '@design.estate/dees-catalog@3.78.2':
resolution: {integrity: sha512-doc9eYGsFV47Ui7k5FuLXpt3ytC/Q+g+yX+qGU/V4fZpc5KUXpL04/FRzO0AU1wF9Xl9GMmL39CcE2vKj88QAQ==} resolution: {integrity: sha512-9MKKCvx+vxoIp6UpqVQklreokdg7ZSSODz4FlKyNFqjfZiDDme6pjwxWoMSA+Tn4bkboYyCBosUrVfc0nxa1HA==}
'@design.estate/dees-comms@1.0.30': '@design.estate/dees-comms@1.0.30':
resolution: {integrity: sha512-KchMlklJfKAjQiJiR0xmofXtQ27VgZtBIxcMwPE9d+h3jJRv+lPZxzBQVOM0eyM0uS44S5vJMZ11IeV4uDXSHg==} resolution: {integrity: sha512-KchMlklJfKAjQiJiR0xmofXtQ27VgZtBIxcMwPE9d+h3jJRv+lPZxzBQVOM0eyM0uS44S5vJMZ11IeV4uDXSHg==}
@@ -368,8 +368,8 @@ packages:
'@design.estate/dees-wcctools@3.8.0': '@design.estate/dees-wcctools@3.8.0':
resolution: {integrity: sha512-CC14iVKUrguzD9jIrdPBd9fZ4egVJEZMxl5y8iy0l7WLumeoYvGsoXj5INVkRPLRVLqziIdi4Je1hXqHt2NU+g==} resolution: {integrity: sha512-CC14iVKUrguzD9jIrdPBd9fZ4egVJEZMxl5y8iy0l7WLumeoYvGsoXj5INVkRPLRVLqziIdi4Je1hXqHt2NU+g==}
'@design.estate/dees-wcctools@3.8.4': '@design.estate/dees-wcctools@3.9.0':
resolution: {integrity: sha512-KpFK/azK+a/Xpq33pXKcho+tdFKVHhKZM5ArvHqo9QMwTczgp5DZZgowTDUuqAofjZwnuVfCPHK/Pw9e64N46A==} resolution: {integrity: sha512-0vZBaGBEGIbl8hx+8BezIIea3U5T7iSHHF9VqlJZGf+nOFIW4zBAxcCljH8YzZ1Yayp6BEjxp/pQXjHN2YB3Jg==}
'@emnapi/core@1.9.2': '@emnapi/core@1.9.2':
resolution: {integrity: sha512-UC+ZhH3XtczQYfOlu3lNEkdW/p4dsJ1r/bP7H8+rhao3TTTMO1ATq/4DdIi23XuGoFY+Cz0JmCbdVl0hz9jZcA==} resolution: {integrity: sha512-UC+ZhH3XtczQYfOlu3lNEkdW/p4dsJ1r/bP7H8+rhao3TTTMO1ATq/4DdIi23XuGoFY+Cz0JmCbdVl0hz9jZcA==}
@@ -1997,6 +1997,12 @@ packages:
'@types/debug@4.1.13': '@types/debug@4.1.13':
resolution: {integrity: sha512-KSVgmQmzMwPlmtljOomayoR89W4FynCAi3E8PPs7vmDVPe84hT+vGPKkJfThkmXs0x0jAaa9U8uW8bbfyS2fWw==} resolution: {integrity: sha512-KSVgmQmzMwPlmtljOomayoR89W4FynCAi3E8PPs7vmDVPe84hT+vGPKkJfThkmXs0x0jAaa9U8uW8bbfyS2fWw==}
'@types/dom-mediacapture-transform@0.1.11':
resolution: {integrity: sha512-Y2p+nGf1bF2XMttBnsVPHUWzRRZzqUoJAKmiP10b5umnO6DDrWI0BrGDJy1pOHoOULVmGSfFNkQrAlC5dcj6nQ==}
'@types/dom-webcodecs@0.1.13':
resolution: {integrity: sha512-O5hkiFIcjjszPIYyUSyvScyvrBoV3NOEEZx/pMlsu44TKzWNkLVBBxnxJz42in5n3QIolYOcBYFCPZZ0h8SkwQ==}
'@types/fs-extra@11.0.4': '@types/fs-extra@11.0.4':
resolution: {integrity: sha512-yTbItCNreRooED33qjunPthRcSjERP1r4MqCZc7wv0u2sUkzTFp45tgUfS5+r7FrZPdmCCNflLhVSP/o+SemsQ==} resolution: {integrity: sha512-yTbItCNreRooED33qjunPthRcSjERP1r4MqCZc7wv0u2sUkzTFp45tgUfS5+r7FrZPdmCCNflLhVSP/o+SemsQ==}
@@ -3166,6 +3172,9 @@ packages:
mdurl@2.0.0: mdurl@2.0.0:
resolution: {integrity: sha512-Lf+9+2r+Tdp5wXDXC4PcIBjTDtq4UKjCPMQhKIuzpJNW0b96kVqSwW0bT7FhRSfmAiFYgP+SCRvdrDozfh0U5w==} resolution: {integrity: sha512-Lf+9+2r+Tdp5wXDXC4PcIBjTDtq4UKjCPMQhKIuzpJNW0b96kVqSwW0bT7FhRSfmAiFYgP+SCRvdrDozfh0U5w==}
mediabunny@1.40.1:
resolution: {integrity: sha512-HU/stGzAkdWaJIly6ypbUVgAUvT9kt39DIg0IaErR7/1fwtTmgUYs4i8uEPYcgcjPjbB9gtBmUXOLnXi6J2LDw==}
memory-pager@1.5.0: memory-pager@1.5.0:
resolution: {integrity: sha512-ZS4Bp4r/Zoeq6+NLJpP+0Zzm0pR8whtGPf1XExKLJBAczGMnSi3It14OiNCStjQjM6NU1okjQGSxgEZN8eBYKg==} resolution: {integrity: sha512-ZS4Bp4r/Zoeq6+NLJpP+0Zzm0pR8whtGPf1XExKLJBAczGMnSi3It14OiNCStjQjM6NU1okjQGSxgEZN8eBYKg==}
@@ -4318,7 +4327,7 @@ snapshots:
'@api.global/typedrequest-interfaces': 3.0.19 '@api.global/typedrequest-interfaces': 3.0.19
'@api.global/typedsocket': 4.1.2(@push.rocks/smartserve@2.0.3) '@api.global/typedsocket': 4.1.2(@push.rocks/smartserve@2.0.3)
'@cloudflare/workers-types': 4.20260405.1 '@cloudflare/workers-types': 4.20260405.1
'@design.estate/dees-catalog': 3.78.0(@tiptap/pm@2.27.2) '@design.estate/dees-catalog': 3.78.2(@tiptap/pm@2.27.2)
'@design.estate/dees-comms': 1.0.30 '@design.estate/dees-comms': 1.0.30
'@push.rocks/lik': 6.4.0 '@push.rocks/lik': 6.4.0
'@push.rocks/smartdelay': 3.0.5 '@push.rocks/smartdelay': 3.0.5
@@ -4847,11 +4856,11 @@ snapshots:
dependencies: dependencies:
'@api.global/typedrequest-interfaces': 3.0.19 '@api.global/typedrequest-interfaces': 3.0.19
'@design.estate/dees-catalog@3.78.0(@tiptap/pm@2.27.2)': '@design.estate/dees-catalog@3.78.2(@tiptap/pm@2.27.2)':
dependencies: dependencies:
'@design.estate/dees-domtools': 2.5.4 '@design.estate/dees-domtools': 2.5.4
'@design.estate/dees-element': 2.2.4 '@design.estate/dees-element': 2.2.4
'@design.estate/dees-wcctools': 3.8.4 '@design.estate/dees-wcctools': 3.9.0
'@fortawesome/fontawesome-svg-core': 7.2.0 '@fortawesome/fontawesome-svg-core': 7.2.0
'@fortawesome/free-brands-svg-icons': 7.2.0 '@fortawesome/free-brands-svg-icons': 7.2.0
'@fortawesome/free-regular-svg-icons': 7.2.0 '@fortawesome/free-regular-svg-icons': 7.2.0
@@ -4940,12 +4949,13 @@ snapshots:
- supports-color - supports-color
- vue - vue
'@design.estate/dees-wcctools@3.8.4': '@design.estate/dees-wcctools@3.9.0':
dependencies: dependencies:
'@design.estate/dees-domtools': 2.5.4 '@design.estate/dees-domtools': 2.5.4
'@design.estate/dees-element': 2.2.4 '@design.estate/dees-element': 2.2.4
'@push.rocks/smartdelay': 3.0.5 '@push.rocks/smartdelay': 3.0.5
lit: 3.3.2 lit: 3.3.2
mediabunny: 1.40.1
transitivePeerDependencies: transitivePeerDependencies:
- '@nuxt/kit' - '@nuxt/kit'
- react - react
@@ -6915,7 +6925,7 @@ snapshots:
'@serve.zone/catalog@2.12.3(@tiptap/pm@2.27.2)': '@serve.zone/catalog@2.12.3(@tiptap/pm@2.27.2)':
dependencies: dependencies:
'@design.estate/dees-catalog': 3.78.0(@tiptap/pm@2.27.2) '@design.estate/dees-catalog': 3.78.2(@tiptap/pm@2.27.2)
'@design.estate/dees-domtools': 2.5.4 '@design.estate/dees-domtools': 2.5.4
'@design.estate/dees-element': 2.2.4 '@design.estate/dees-element': 2.2.4
'@design.estate/dees-wcctools': 3.8.0 '@design.estate/dees-wcctools': 3.8.0
@@ -7464,6 +7474,12 @@ snapshots:
dependencies: dependencies:
'@types/ms': 2.1.0 '@types/ms': 2.1.0
'@types/dom-mediacapture-transform@0.1.11':
dependencies:
'@types/dom-webcodecs': 0.1.13
'@types/dom-webcodecs@0.1.13': {}
'@types/fs-extra@11.0.4': '@types/fs-extra@11.0.4':
dependencies: dependencies:
'@types/jsonfile': 6.1.4 '@types/jsonfile': 6.1.4
@@ -8819,6 +8835,11 @@ snapshots:
mdurl@2.0.0: {} mdurl@2.0.0: {}
mediabunny@1.40.1:
dependencies:
'@types/dom-mediacapture-transform': 0.1.11
'@types/dom-webcodecs': 0.1.13
memory-pager@1.5.0: {} memory-pager@1.5.0: {}
micromark-core-commonmark@2.0.3: micromark-core-commonmark@2.0.3:

View File

@@ -3,6 +3,6 @@
*/ */
export const commitinfo = { export const commitinfo = {
name: '@serve.zone/dcrouter', name: '@serve.zone/dcrouter',
version: '13.11.0', version: '13.12.0',
description: 'A multifaceted routing service handling mail and SMS delivery functions.' description: 'A multifaceted routing service handling mail and SMS delivery functions.'
} }

View File

@@ -20,6 +20,9 @@ export class EmailDomainDoc extends plugins.smartdata.SmartDataDbDoc<EmailDomain
@plugins.smartdata.svDb() @plugins.smartdata.svDb()
public linkedDomainId: string = ''; public linkedDomainId: string = '';
@plugins.smartdata.svDb()
public subdomain?: string;
@plugins.smartdata.svDb() @plugins.smartdata.svDb()
public dkim!: IEmailDomainDkim; public dkim!: IEmailDomainDkim;

View File

@@ -48,6 +48,7 @@ export class EmailDomainManager {
public async createEmailDomain(opts: { public async createEmailDomain(opts: {
linkedDomainId: string; linkedDomainId: string;
subdomain?: string;
dkimSelector?: string; dkimSelector?: string;
dkimKeySize?: number; dkimKeySize?: number;
rotateKeys?: boolean; rotateKeys?: boolean;
@@ -58,7 +59,9 @@ export class EmailDomainManager {
if (!domainDoc) { if (!domainDoc) {
throw new Error(`DNS domain not found: ${opts.linkedDomainId}`); throw new Error(`DNS domain not found: ${opts.linkedDomainId}`);
} }
const domainName = domainDoc.name; const baseDomain = domainDoc.name;
const subdomain = opts.subdomain?.trim() || undefined;
const domainName = subdomain ? `${subdomain}.${baseDomain}` : baseDomain;
// Check for duplicates // Check for duplicates
const existing = await EmailDomainDoc.findByDomain(domainName); const existing = await EmailDomainDoc.findByDomain(domainName);
@@ -90,6 +93,7 @@ export class EmailDomainManager {
doc.id = plugins.smartunique.shortId(); doc.id = plugins.smartunique.shortId();
doc.domain = domainName.toLowerCase(); doc.domain = domainName.toLowerCase();
doc.linkedDomainId = opts.linkedDomainId; doc.linkedDomainId = opts.linkedDomainId;
doc.subdomain = subdomain;
doc.dkim = { doc.dkim = {
selector, selector,
keySize, keySize,
@@ -306,6 +310,7 @@ export class EmailDomainManager {
id: doc.id, id: doc.id,
domain: doc.domain, domain: doc.domain,
linkedDomainId: doc.linkedDomainId, linkedDomainId: doc.linkedDomainId,
subdomain: doc.subdomain,
dkim: doc.dkim, dkim: doc.dkim,
rateLimits: doc.rateLimits, rateLimits: doc.rateLimits,
dnsStatus: doc.dnsStatus, dnsStatus: doc.dnsStatus,

View File

@@ -85,6 +85,7 @@ export class EmailDomainHandler {
try { try {
const domain = await this.manager.createEmailDomain({ const domain = await this.manager.createEmailDomain({
linkedDomainId: dataArg.linkedDomainId, linkedDomainId: dataArg.linkedDomainId,
subdomain: dataArg.subdomain,
dkimSelector: dataArg.dkimSelector, dkimSelector: dataArg.dkimSelector,
dkimKeySize: dataArg.dkimKeySize, dkimKeySize: dataArg.dkimKeySize,
rotateKeys: dataArg.rotateKeys, rotateKeys: dataArg.rotateKeys,

View File

@@ -12,10 +12,12 @@ export type TDnsRecordStatus = 'valid' | 'missing' | 'invalid' | 'unchecked';
*/ */
export interface IEmailDomain { export interface IEmailDomain {
id: string; id: string;
/** Fully qualified domain name (e.g. 'example.com'). */ /** Fully qualified email domain name (e.g. 'example.com' or 'mail.example.com'). */
domain: string; domain: string;
/** ID of the linked dcrouter DNS domain — determines how DNS records are managed. */ /** ID of the linked dcrouter DNS domain — determines how DNS records are managed. */
linkedDomainId: string; linkedDomainId: string;
/** Optional subdomain prefix (e.g. 'mail' for mail.example.com). Empty/undefined = bare domain. */
subdomain?: string;
/** DKIM configuration and key state. */ /** DKIM configuration and key state. */
dkim: IEmailDomainDkim; dkim: IEmailDomainDkim;
/** Optional per-domain rate limits. */ /** Optional per-domain rate limits. */

View File

@@ -55,6 +55,8 @@ export interface IReq_CreateEmailDomain extends plugins.typedrequestInterfaces.i
apiToken?: string; apiToken?: string;
/** ID of the existing dcrouter DNS domain to link to. */ /** ID of the existing dcrouter DNS domain to link to. */
linkedDomainId: string; linkedDomainId: string;
/** Optional subdomain prefix (e.g. 'mail' for mail.example.com). Leave empty for bare domain. */
subdomain?: string;
/** DKIM selector (default: 'default'). */ /** DKIM selector (default: 'default'). */
dkimSelector?: string; dkimSelector?: string;
/** RSA key size (default: 2048). */ /** RSA key size (default: 2048). */

View File

@@ -3,6 +3,6 @@
*/ */
export const commitinfo = { export const commitinfo = {
name: '@serve.zone/dcrouter', name: '@serve.zone/dcrouter',
version: '13.11.0', version: '13.12.0',
description: 'A multifaceted routing service handling mail and SMS delivery functions.' description: 'A multifaceted routing service handling mail and SMS delivery functions.'
} }

View File

@@ -2422,6 +2422,7 @@ export const fetchEmailDomainsAction = emailDomainsStatePart.createAction(
export const createEmailDomainAction = emailDomainsStatePart.createAction<{ export const createEmailDomainAction = emailDomainsStatePart.createAction<{
linkedDomainId: string; linkedDomainId: string;
subdomain?: string;
dkimSelector?: string; dkimSelector?: string;
dkimKeySize?: number; dkimKeySize?: number;
rotateKeys?: boolean; rotateKeys?: boolean;

View File

@@ -276,6 +276,11 @@ export class OpsViewEmailDomains extends DeesElement {
.options=${domainOptions} .options=${domainOptions}
.required=${true} .required=${true}
></dees-input-dropdown> ></dees-input-dropdown>
<dees-input-text
.key=${'subdomain'}
.label=${'Subdomain'}
.description=${'Leave empty for bare domain, e.g. "mail" for mail.example.com'}
></dees-input-text>
<dees-input-text <dees-input-text
.key=${'dkimSelector'} .key=${'dkimSelector'}
.label=${'DKIM Selector'} .label=${'DKIM Selector'}
@@ -316,10 +321,12 @@ export class OpsViewEmailDomains extends DeesElement {
? parseInt(data.dkimKeySize.key, 10) ? parseInt(data.dkimKeySize.key, 10)
: parseInt(data.dkimKeySize || '2048', 10); : parseInt(data.dkimKeySize || '2048', 10);
const subdomain = data.subdomain?.trim() || undefined;
await appstate.emailDomainsStatePart.dispatchAction( await appstate.emailDomainsStatePart.dispatchAction(
appstate.createEmailDomainAction, appstate.createEmailDomainAction,
{ {
linkedDomainId, linkedDomainId,
subdomain,
dkimSelector: data.dkimSelector || 'default', dkimSelector: data.dkimSelector || 'default',
dkimKeySize: keySize, dkimKeySize: keySize,
rotateKeys: Boolean(data.rotateKeys), rotateKeys: Boolean(data.rotateKeys),