Files
AriWeb-Suite/modules/arimail/server/config.mjs
T

283 lines
21 KiB
JavaScript

/**
* 설정 파일(config.json) 모듈 — 1.0.0 에서 환경 파일(gateway.env)과 분리한 "운영 설정".
*
* 왜 분리했나
* 예전에는 Resend API 키·웹훅 비밀키·발신 표시 이름 같은 값이 모두 systemd 환경 파일(gateway.env)에 있었습니다.
* 그 값들은 처음 배포할 때는 알 수 없거나(키를 아직 만들지 않음) 배포 자체에는 필요하지 않은데, 비어 있으면 운영이 안 되고
* 채우려면 root 로 파일을 고치고 서비스를 재시작해야 했습니다. 이제 "배포에 꼭 필요한 값"(DB 경로·포트·메일함·로그인 방식·시스템 제한)만
* 환경 파일에 남기고, 나머지는 데이터 디렉터리의 config.json 에 두어 설치가 끝난 뒤 웹(초기 설정 페이지 / 관리자 페이지)에서 채웁니다.
*
* 우선순위와 호환(요구사항 5: 기존 운영 유지)
* - 설정 파일에 있는 값이 환경 변수보다 우선합니다. 설정 파일이 없으면(처음 1.0.0 으로 올린 서버) 환경 변수에 있던 값을 그대로
* 읽어 설정 파일을 만듭니다(가져오기). 그래서 두 운영 서버의 환경 파일을 바꾸지 않아도 동작이 그대로이고, 값은 설정 파일에 보관됩니다.
* - 가져오기 뒤에는 설정 파일이 기준입니다(파일에 빈 값으로 저장하면 환경 변수 값이 되살아나지 않음). 파일에 아예 없는 키만 환경 변수를 봅니다.
* - 코드의 나머지 부분은 예전처럼 env.RESEND_API_KEY 같은 환경 변수 이름으로 값을 읽습니다. applyConfig() 가 설정 파일 값을 env 객체에
* 덮어쓰므로 모듈들이 바뀌지 않았고, 저장 즉시(재시작 없이) 새 값이 쓰입니다.
*
* 파일 위치: CONFIG_FILE 환경 변수, 없으면 DB 파일 옆의 config.json(예: /srv/<사이트>/data/config.json). 소유자만 읽습니다(0600, 비밀키 포함).
* 초기 설정 페이지의 접속 토큰도 같은 디렉터리의 setup-token 파일입니다.
*/
import { readFileSync, writeFileSync, existsSync, mkdirSync, renameSync, chmodSync } from 'node:fs';
import { dirname, join } from 'node:path';
import { generateKeyPairSync } from 'node:crypto';
import { secret } from './util.mjs';
/** 설정 파일 형식 버전. 구조가 바뀌면 올리고 loadConfig 에서 변환합니다. */
export const CONFIG_FORMAT = 1;
/**
* 설정 항목 정의.
* key : config.json 의 키이자(env 가 있으면) 코드가 읽는 환경 변수 이름
* env : 이 항목이 대신하는 환경 변수(가져오기와 applyConfig 대상). 없으면 설정 파일 전용 항목
* group : 화면의 묶음 — mail(수·발신 방식) resend smtp haraka site setup
* type : text | secret(화면에 가린 채 보임) | select | number | bool
* hidden : 화면 폼에 직접 보이지 않는 항목(DKIM 키·내부 토큰처럼 단추로 만들거나 자동 생성)
* default: 값이 없을 때의 기본값(applyConfig 는 기본값을 env 에 쓰지 않고, 읽는 쪽 코드의 기본값에 맡깁니다)
*/
export const CONFIG_SCHEMA = [
// 수·발신 방식(요구사항 1·2): inbound = 받는 길, outbound = 보내는 길. 조합은 자유입니다(예: 수신 Haraka + 발신 Resend).
{ key: 'inbound', group: 'mail', type: 'select', options: ['resend', 'haraka'], default: 'resend' },
{ key: 'outbound', group: 'mail', type: 'select', options: ['resend', 'haraka', 'smtp'], default: 'resend' },
// Resend
{ key: 'RESEND_API_KEY', env: 'RESEND_API_KEY', group: 'resend', type: 'secret' },
{ key: 'RESEND_WEBHOOK_SECRET', env: 'RESEND_WEBHOOK_SECRET', group: 'resend', type: 'secret' },
// Resend 도메인 자동 설정(1.0.5): 발송 지역(수신 MX 값도 지역에 따라 다름). RESEND_DOMAIN_ID / RESEND_WEBHOOK_ID 는 자동 설정이 찾거나 만든 id(화면에는 숨김).
{ key: 'RESEND_REGION', env: 'RESEND_REGION', group: 'resend', type: 'select', options: ['ap-northeast-1', 'us-east-1', 'eu-west-1', 'sa-east-1'], default: 'ap-northeast-1' },
{ key: 'RESEND_DOMAIN_ID', env: 'RESEND_DOMAIN_ID', group: 'resend', type: 'text', hidden: true },
{ key: 'RESEND_WEBHOOK_ID', env: 'RESEND_WEBHOOK_ID', group: 'resend', type: 'text', hidden: true },
// DNS 모듈(1.0.5, 요구사항 1): none 이면 레코드를 보여 주기만 하고, google / route53 이면 자동으로 적용합니다. 자격 증명은 비밀값.
{ key: 'DNS_PROVIDER', env: 'DNS_PROVIDER', group: 'dns', type: 'select', options: ['none', 'google', 'route53'], default: 'none' },
{ key: 'DNS_GOOGLE_CREDENTIALS', env: 'DNS_GOOGLE_CREDENTIALS', group: 'dns', type: 'secret', multiline: true },
{ key: 'DNS_GOOGLE_PROJECT', env: 'DNS_GOOGLE_PROJECT', group: 'dns', type: 'text' },
{ key: 'DNS_GOOGLE_ZONE', env: 'DNS_GOOGLE_ZONE', group: 'dns', type: 'text' },
{ key: 'DNS_AWS_ACCESS_KEY_ID', env: 'DNS_AWS_ACCESS_KEY_ID', group: 'dns', type: 'text' },
{ key: 'DNS_AWS_SECRET_ACCESS_KEY', env: 'DNS_AWS_SECRET_ACCESS_KEY', group: 'dns', type: 'secret' },
{ key: 'DNS_AWS_HOSTED_ZONE_ID', env: 'DNS_AWS_HOSTED_ZONE_ID', group: 'dns', type: 'text' },
// 외부 SMTP 중계 서버(outbound=smtp)
{ key: 'SMTP_RELAY_HOST', env: 'SMTP_RELAY_HOST', group: 'smtp', type: 'text' },
{ key: 'SMTP_RELAY_PORT', env: 'SMTP_RELAY_PORT', group: 'smtp', type: 'number', default: 587 },
{ key: 'SMTP_RELAY_SECURITY', env: 'SMTP_RELAY_SECURITY', group: 'smtp', type: 'select', options: ['starttls', 'tls', 'none'], default: 'starttls' },
{ key: 'SMTP_RELAY_USER', env: 'SMTP_RELAY_USER', group: 'smtp', type: 'text' },
{ key: 'SMTP_RELAY_PASS', env: 'SMTP_RELAY_PASS', group: 'smtp', type: 'secret' },
// 내장 Haraka(inbound=haraka 또는 outbound=haraka 일 때 haraka/run.mjs 가 이 값으로 Haraka 설정을 만듭니다)
{ key: 'HARAKA_PORT', env: 'HARAKA_PORT', group: 'haraka', type: 'number', default: 25 },
{ key: 'HARAKA_RELAY_PORT', env: 'HARAKA_RELAY_PORT', group: 'haraka', type: 'number', default: 2525 },
{ key: 'HARAKA_HOSTNAME', env: 'HARAKA_HOSTNAME', group: 'haraka', type: 'text' },
{ key: 'HARAKA_DKIM_SELECTOR', env: 'HARAKA_DKIM_SELECTOR', group: 'haraka', type: 'text', default: 'arimail' },
{ key: 'HARAKA_DKIM_PRIVATE', env: 'HARAKA_DKIM_PRIVATE', group: 'haraka', type: 'secret', hidden: true },
{ key: 'HARAKA_DKIM_PUBLIC', env: 'HARAKA_DKIM_PUBLIC', group: 'haraka', type: 'text', hidden: true },
// Haraka 플러그인 ↔ 게이트웨이 사이의 공유 비밀(자동 생성). 로컬 HTTP 로 넘어오는 수신 메일이 정말 Haraka 에서 온 것인지 확인합니다.
{ key: 'HARAKA_TOKEN', env: 'HARAKA_TOKEN', group: 'haraka', type: 'secret', hidden: true },
// 사이트 정보(배포에는 필요 없고 운영 중 바꿀 수 있는 값)
{ key: 'MAIL_FROM_NAME', env: 'MAIL_FROM_NAME', group: 'site', type: 'text' },
{ key: 'SITE_TITLE', env: 'SITE_TITLE', group: 'site', type: 'text' },
{ key: 'MAILBOX_OWNER_LOGIN', env: 'MAILBOX_OWNER_LOGIN', group: 'site', type: 'text' },
{ key: 'STATS_TIMEZONE', env: 'STATS_TIMEZONE', group: 'site', type: 'text', default: 'Asia/Seoul' },
{ key: 'SYNC_INTERVAL_MS', env: 'SYNC_INTERVAL_MS', group: 'site', type: 'number', default: 3600000 },
// 사용량 API(1.0.9, 요구사항 1): 외부 관리 시스템(웹메이커)이 /api/usage 를 부를 때 쓰는 비밀 키(기본 도메인 설정에만, 화면의 단추로 생성·교체·삭제)와 접속 허용 IP/CIDR 목록(비우면 제한 없음).
{ key: 'USAGE_API_KEY', env: 'USAGE_API_KEY', group: 'usage', type: 'secret', hidden: true },
{ key: 'USAGE_API_ALLOW', env: 'USAGE_API_ALLOW', group: 'usage', type: 'text' },
// [Ariweb Suite 변경] 도메인별 보장량(Dev-Order §0.4·§13.3): 테넌트(도메인)마다 설정 파일에 두는 상한. 값이 있으면 환경 파일(gateway.env)의 LIMIT_* 보다 우선합니다(limits.systemLimits).
// 환경 변수 이름을 두지 않는 이유: 환경 변수에 두면 테넌트가 기본 도메인의 값을 물려받지 못하게 되어(TENANT_OWN_KEYS) 기존 운영 서버의 동작이 바뀝니다.
{ key: 'LIMIT_ACCOUNTS', group: 'limits', type: 'number' },
{ key: 'LIMIT_TOTAL_BYTES', group: 'limits', type: 'text' },
{ key: 'LIMIT_MAILBOX_BYTES', group: 'limits', type: 'text' },
{ key: 'LIMIT_MONTHLY_MESSAGES', group: 'limits', type: 'number' },
// [Ariweb Suite 변경] 포털 SSO 티켓 서명 비밀(§16.3): 포털 보관소와 같은 값. 화면에는 보이지 않고 설정 API 로만 넣습니다.
{ key: 'ARIWEB_SSO_SECRET', group: 'ariweb', type: 'secret', hidden: true },
// 초기 설정을 마쳤다는 표시(초기 설정 페이지의 "완료" 단추). 화면 안내용이며 동작에는 영향이 없습니다.
{ key: 'SETUP_DONE', group: 'setup', type: 'bool', default: false },
];
const BY_KEY = new Map(CONFIG_SCHEMA.map((item) => [item.key, item]));
/** 설정 파일 경로: CONFIG_FILE, 없으면 DB 파일 옆의 config.json. */
export function configFile(env) {
return env.CONFIG_FILE || join(dirname(env.DB_FILE || join(process.cwd(), 'data', 'mail-gateway.sqlite')), 'config.json');
}
/** 설정 파일과 같은 디렉터리의 보조 파일(setup-token, haraka-status.json). */
export const configDir = (env) => dirname(configFile(env));
function readJsonFile(file) {
try { return JSON.parse(readFileSync(file, 'utf8')); } catch (e) { if (e.code === 'ENOENT') return null; throw e; }
}
/** 임시 파일에 쓰고 이름을 바꿔 중간에 끊겨도 반쪽 파일이 남지 않게 합니다. 비밀키가 들어 있으므로 0600. */
function writeJsonFile(file, data) {
mkdirSync(dirname(file), { recursive: true });
const tmp = file + '.tmp';
writeFileSync(tmp, JSON.stringify(data, null, 2) + '\n', { mode: 0o600 });
try { chmodSync(tmp, 0o600); } catch { /* 권한을 못 바꾸는 파일 시스템이면 그대로 둡니다 */ }
renameSync(tmp, file);
}
/**
* 값 하나를 항목 형식에 맞게 다듬습니다. 잘못된 값은 Error('validation') 를 던지며 field 에 키를 넣습니다.
* number 는 0 이상의 정수, select 는 목록 안의 값, bool 은 true/false/'1'/'0'. 빈 문자열은 "지움" 으로 보고 '' 로 둡니다.
*/
export function coerceValue(item, value) {
const fail = () => { const e = new Error('validation'); e.field = item.key; throw e; };
if (value === null || value === undefined) return '';
if (item.type === 'bool') { if (typeof value === 'boolean') return value; if (['1', 'true', 'on'].includes(String(value))) return true; if (['', '0', 'false', 'off'].includes(String(value))) return false; fail(); }
const s = String(value).trim();
if (s === '') return '';
if (item.type === 'number') { if (!/^\d{1,12}$/.test(s)) fail(); return Number(s); }
if (item.type === 'select') { if (!item.options.includes(s)) fail(); return s; }
if (s.length > 20000 || /[\0]/.test(s)) fail();
return s;
}
/**
* 환경 변수에서 설정 파일을 처음 만듭니다(1.0.0 으로 올린 기존 서버). env 에 있는 항목은 모두 그대로 복사하고,
* 수·발신 방식은 예전 동작과 같은 resend 로 둡니다. 가져온 사실은 _meta 에 남겨 화면이 "환경 파일에서 가져옴" 을 보여 줄 수 있습니다.
*/
function importFromEnv(env) {
const cfg = { _meta: { format: CONFIG_FORMAT, createdAt: new Date().toISOString(), importedFromEnv: [] } };
for (const item of CONFIG_SCHEMA) {
if (!item.env || env[item.env] === undefined || env[item.env] === '') continue;
try { cfg[item.key] = coerceValue(item, env[item.env]); cfg._meta.importedFromEnv.push(item.key); } catch { /* 형식이 안 맞는 옛 값은 건너뜁니다 */ }
}
cfg.inbound = 'resend'; cfg.outbound = 'resend';
return cfg;
}
/**
* 설정 파일을 읽어(없으면 환경 변수에서 만들어) env 에 적용합니다. 서버 시작 때 한 번 부릅니다.
* 내부 토큰(HARAKA_TOKEN)이 없으면 만들어 저장합니다. 파일을 쓸 수 없는 환경(읽기 전용)이면 경고만 남기고 메모리에서만 동작합니다.
* @returns {object} 적용된 설정(env.CONFIG 와 같은 객체)
*/
export function loadConfig(env) {
const file = configFile(env);
let cfg = readJsonFile(file), created = false;
if (!cfg || typeof cfg !== 'object') { cfg = importFromEnv(env); created = true; }
if (!cfg._meta) cfg._meta = { format: CONFIG_FORMAT };
if (!cfg.HARAKA_TOKEN) { cfg.HARAKA_TOKEN = secret(32); created = true; }
if (created) { try { writeJsonFile(file, cfg); } catch (e) { console.warn(` ⚠️ 설정 파일을 쓸 수 없습니다(${file}): ${e.message} — 환경 변수 값으로 동작합니다`); } }
applyConfig(env, cfg);
env.CONFIG_CREATED = created;
return cfg;
}
/**
* 설정 값을 env 에 적용합니다. env 를 쓰는 다른 모듈(Resend 호출·발신 표시 이름·시간대 등)은 바뀌지 않았으므로
* 저장 즉시 새 값이 쓰입니다. 파일에 있는 키만 덮어쓰고(빈 값 포함), 파일에 없는 키는 환경 변수 값을 그대로 둡니다.
* MAIL_INBOUND / MAIL_OUTBOUND 는 providers/index.mjs 가 읽는 수·발신 방식입니다.
*/
export function applyConfig(env, cfg) {
for (const item of CONFIG_SCHEMA) {
if (!item.env || !(item.key in cfg)) continue;
const v = cfg[item.key];
env[item.env] = v === null || v === undefined ? '' : String(v);
}
env.MAIL_INBOUND = BY_KEY.get('inbound').options.includes(cfg.inbound) ? cfg.inbound : 'resend';
env.MAIL_OUTBOUND = BY_KEY.get('outbound').options.includes(cfg.outbound) ? cfg.outbound : 'resend';
env.CONFIG = cfg;
env.CONFIG_VERSION = (env.CONFIG_VERSION || 0) + 1;
}
/**
* 설정 일부를 바꿔 저장하고 바로 적용합니다(초기 설정 페이지·관리자 페이지의 저장 단추).
* @param {object} patch { 키: 값 } — 알 수 없는 키는 무시, hidden 항목은 allowHidden 일 때만(내부 동작용)
* @returns {object} publicConfig(env)
*/
export function saveConfig(env, patch, { allowHidden = false } = {}) {
const cfg = { ...(env.CONFIG || readJsonFile(configFile(env)) || {}) };
for (const [key, value] of Object.entries(patch || {})) {
const item = BY_KEY.get(key); if (!item || (item.hidden && !allowHidden)) continue;
cfg[key] = coerceValue(item, value);
}
cfg._meta = { ...(cfg._meta || {}), format: CONFIG_FORMAT, updatedAt: new Date().toISOString() };
writeJsonFile(configFile(env), cfg);
applyConfig(env, cfg);
if (typeof env.ON_CONFIG_CHANGE === 'function') { try { env.ON_CONFIG_CHANGE(cfg); } catch (e) { console.error('설정 적용 알림 실패', e.message); } }
return publicConfig(env);
}
/** 비밀값은 앞뒤 일부만 보여 줍니다(있는지·어느 키인지만 확인할 수 있게). 짝수 길이 보장 없이 짧은 값은 전부 가립니다. */
export function maskSecret(value) {
const s = String(value || ''); if (!s) return '';
if (s.length <= 8) return '••••••';
return s.slice(0, 3) + '••••' + s.slice(-4);
}
/**
* 화면에 주는 설정: 비밀값은 가리고, 각 키의 출처(config = 설정 파일, env = 환경 변수 폴백, default = 기본값)를 함께 줍니다.
* hidden 항목은 값 대신 있음/없음만 알립니다.
*/
export function publicConfig(env) {
const cfg = env.CONFIG || {}, values = {}, sources = {}, present = {};
for (const item of CONFIG_SCHEMA) {
const inFile = item.key in cfg && cfg[item.key] !== '' && cfg[item.key] !== undefined && cfg[item.key] !== null;
const raw = inFile ? cfg[item.key] : item.env && env[item.env] ? env[item.env] : item.default ?? '';
sources[item.key] = inFile ? 'config' : item.env && env[item.env] ? 'env' : 'default';
if (item.hidden) { present[item.key] = !!raw; continue; }
values[item.key] = item.type === 'secret' ? maskSecret(raw) : raw;
}
return {
values, sources, present, inbound: env.MAIL_INBOUND || 'resend', outbound: env.MAIL_OUTBOUND || 'resend',
file: configFile(env), importedFromEnv: cfg._meta?.importedFromEnv || [], updatedAt: cfg._meta?.updatedAt || cfg._meta?.createdAt || null,
schema: CONFIG_SCHEMA.filter((i) => !i.hidden).map(({ key, group, type, options, default: d, multiline }) => ({ key, group, type, ...(options ? { options } : {}), ...(d !== undefined ? { default: d } : {}), ...(multiline ? { multiline: true } : {}) })),
};
}
/**
* 초기 설정 페이지 접속 토큰. 설정 파일 옆 setup-token 에 두고 없으면 만듭니다(0600).
* 배포 스크립트가 이 파일을 읽어 접속 주소(…/?token=…)를 출력합니다.
*/
export function setupToken(env) {
const file = join(configDir(env), 'setup-token');
try { const t = readFileSync(file, 'utf8').trim(); if (t) return t; } catch { /* 없으면 만듭니다 */ }
const token = secret(24);
try { mkdirSync(dirname(file), { recursive: true }); writeFileSync(file, token + '\n', { mode: 0o600 }); } catch (e) { console.warn(' ⚠️ setup-token 을 쓸 수 없습니다:', e.message); }
return token;
}
/**
* Haraka 설치 디렉터리(node_modules·config·plugins·queue): HARAKA_DIR, 없으면 데이터 디렉터리 옆의 haraka/. haraka/run.mjs 와 같은 규칙.
* 1.0.7: 추가 도메인의 env 는 DATA_DIR(기본 도메인의 데이터 디렉터리)을 가지므로 그 옆을 봅니다 — Haraka 는 서버에 하나뿐이고 모든 도메인이 함께 씁니다.
*/
export const harakaDir = (env) => env.HARAKA_DIR || join(env.DATA_DIR || configDir(env), '..', 'haraka');
/**
* Haraka 상태: haraka/run.mjs(별도 systemd 서비스)가 데이터 디렉터리의 haraka-status.json 에 주기적으로 쓰는 값 + 설치 여부.
* 서비스가 아예 없으면(파일 없음) status 는 null 이고 화면은 "서비스 미설치(deploy/haraka-apply.sh)" 를 안내합니다. 1.0.5 에서 setup.mjs 에서 이곳으로 옮김(provision.mjs 도 씀).
*/
export function harakaStatus(env) {
const dir = harakaDir(env); let status = null;
// 상태 파일은 기본 도메인의 데이터 디렉터리(DATA_DIR)에 하나만 있습니다(1.0.7: 추가 도메인도 같은 파일을 봄).
try { status = JSON.parse(readFileSync(join(env.DATA_DIR || configDir(env), 'haraka-status.json'), 'utf8')); } catch { status = null; }
const stale = status && Date.now() - Date.parse(status.updatedAt || 0) > 90000; // 30초마다 쓰므로 90초 넘게 조용하면 서비스가 멈춘 것
const wanted = env.MAIL_INBOUND === 'haraka' || env.MAIL_OUTBOUND === 'haraka';
return { dir, installed: existsSync(join(dir, 'node_modules', 'Haraka', 'package.json')), status, stale: !!stale, wanted };
}
/** 메일 도메인(MAILBOX 의 @ 뒤)과 Haraka 호스트 이름(HARAKA_HOSTNAME → PUBLIC_HOST). */
export const mailDomain = (env) => String(env.MAILBOX || 'admin@arihakdang.com').split('@')[1] || '';
export const harakaHostname = (env) => String(env.HARAKA_HOSTNAME || env.PUBLIC_HOST || 'mail.' + mailDomain(env)).trim();
/**
* DKIM 키 쌍을 만들어 설정에 저장합니다(RSA 2048). 공개키는 DNS TXT 레코드에 넣고 비밀키는 Haraka 가 서명에 씁니다.
* 이미 키가 있으면 force 일 때만 바꿉니다(바꾸면 DNS 도 다시 올려야 하므로).
*/
export function generateDkim(env, { force = false } = {}) {
if (env.CONFIG?.HARAKA_DKIM_PRIVATE && !force) return dnsRecords(env);
const { privateKey, publicKey } = generateKeyPairSync('rsa', { modulusLength: 2048, publicKeyEncoding: { type: 'spki', format: 'der' }, privateKeyEncoding: { type: 'pkcs1', format: 'pem' } });
saveConfig(env, { HARAKA_DKIM_PRIVATE: privateKey, HARAKA_DKIM_PUBLIC: Buffer.from(publicKey).toString('base64') }, { allowHidden: true });
return dnsRecords(env);
}
/**
* Haraka 로 직접 수·발신할 때 도메인 DNS 에 넣어야 하는 레코드(화면 안내용).
* MX : 받는 메일이 이 서버로 오게(수신 Haraka)
* SPF : 이 서버가 보낸 메일을 받는 쪽이 신뢰하게(발신 Haraka). Resend 를 함께 쓰면 include 를 더합니다.
* DKIM : 서명 공개키(키를 만든 뒤)
* DMARC: 권장 정책(선택)
*/
export function dnsRecords(env) {
const domain = mailDomain(env), host = harakaHostname(env), selector = env.HARAKA_DKIM_SELECTOR || 'arimail', pub = env.HARAKA_DKIM_PUBLIC || '';
const includeResend = env.MAIL_OUTBOUND === 'resend' ? ' include:amazonses.com' : '';
const records = [
{ kind: 'MX', name: domain, value: `10 ${host}.`, for: 'inbound' },
{ kind: 'TXT', name: domain, value: `v=spf1 mx a:${host}${includeResend} ~all`, for: 'outbound' },
...(pub ? [{ kind: 'TXT', name: `${selector}._domainkey.${domain}`, value: `v=DKIM1; k=rsa; p=${pub}`, for: 'outbound' }] : []),
{ kind: 'TXT', name: `_dmarc.${domain}`, value: 'v=DMARC1; p=none; adkim=r; aspf=r', for: 'outbound', optional: true },
];
return { domain, host, selector, hasDkim: !!pub, records };
}