2026-10-26 11:02:58 +00:00
/**
* 설정 파일(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' },
2026-10-26 11:47:45 +00:00
// [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 },
2026-10-26 11:02:58 +00:00
// 초기 설정을 마쳤다는 표시(초기 설정 페이지의 "완료" 단추). 화면 안내용이며 동작에는 영향이 없습니다.
{ 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 };
}