Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
204 lines
29 KiB
JavaScript
204 lines
29 KiB
JavaScript
/**
|
|
* portal/mail/arimail.mjs — AriMail 연동 어댑터(docs/ARCHITECTURE.md §7).
|
|
*
|
|
* 포털은 AriMail 의 기존 기능(웹메일·IMAP·POP3·SMTP·다중 도메인·사용량 API·전체 설정 API·관리자 API)을 다시 만들지 않고 이 어댑터로 부른다(§0.3·§38).
|
|
* - 전체 설정 API(/api/setup/*, 설정 토큰): 도메인(테넌트) 추가·변경·삭제, 도메인별 설정(보장량·SSO 비밀·DNS 레코드·자동 설정 계획), 발신 상태 확인.
|
|
* - 관리자 API(/api/admin/*, 관리자 세션): 메일함(계정) 목록·생성·수정·삭제, POP/IMAP 스위치, 서버 상태, 발송 속도·대기열.
|
|
* - 사용량 API(/api/usage, 비밀 키): 도메인·계정별 저장 공간·통수·경고 구간·제한 사유.
|
|
* - SSO(/api/auth/sso): 포털이 서명한 티켓으로 웹메일 세션(§16.3).
|
|
* - 송수신 확인: SMTP 제출(modules/arimail/server/providers/smtp-client.mjs 재사용)과 웹 API 수신함 조회로 실제 외부 왕복을 확인.
|
|
* 서버 등록은 mail_servers 표, 비밀은 보관소: arimail:<서버id>:setup-token · :usage-key · :sso-secret · :admin:<도메인>(관리자 로그인 JSON). 응답에 비밀값을 넣지 않는다.
|
|
* 네트워크 오류는 code='service_unreachable' 로 던져 화면이 '정보 확인 지연' 을 보이게 한다. createFake() 는 같은 인터페이스의 메모리 구현(다른 영역의 테스트용).
|
|
*/
|
|
import { randomBytes, createHmac } from 'node:crypto';
|
|
import { newId } from '../../lib/crypto.mjs';
|
|
import { nowIso, parseJson, toJson } from '../../lib/db.mjs';
|
|
|
|
/** 공통 fetch: JSON 응답, 네트워크 오류는 service_unreachable, AriMail 의 { ok:false, error } 는 code=error 로 던진다. */
|
|
async function call(fetchImpl, url, { method = 'GET', headers = {}, body, host = null, timeoutMs = 15000 } = {}) {
|
|
let r;
|
|
try { r = await fetchImpl(url, { method, headers: { 'Content-Type': 'application/json', ...(host ? { Host: host, 'X-Forwarded-Host': host } : {}), ...headers }, body: body === undefined ? undefined : JSON.stringify(body), signal: AbortSignal.timeout(timeoutMs), redirect: 'manual' }); }
|
|
catch (e) { const err = new Error(`AriMail 에 연결할 수 없습니다 (${new URL(url).host})`); err.code = 'service_unreachable'; err.cause = e; throw err; }
|
|
let json = null; try { json = await r.json(); } catch (_) {}
|
|
if (!r.ok || (json && json.ok === false)) { const err = new Error((json && json.error) || `AriMail 오류 ${r.status}`); err.code = (json && json.error) || 'arimail_' + r.status; err.status = r.status; err.detail = json; throw err; }
|
|
return { json, headers: r.headers, status: r.status };
|
|
}
|
|
|
|
export function create(deps) {
|
|
const { store, vault } = deps; const fetchImpl = deps.fetchImpl || fetch;
|
|
const secretName = (serverId, kind) => `arimail:${serverId}:${kind}`;
|
|
const serverRow = (id) => { const r = id ? store.get('SELECT * FROM mail_servers WHERE id=?', id) : store.get('SELECT * FROM mail_servers WHERE is_default=1') || store.get('SELECT * FROM mail_servers ORDER BY created_at LIMIT 1'); if (!r) { const e = new Error('등록된 AriMail 서버가 없습니다. 운영자 콘솔 › 메일 서비스 › 서버 등록에서 먼저 등록하세요'); e.code = 'mail_server_missing'; throw e; } return r; };
|
|
const internalOf = (s) => (s.internal_url || s.base_url).replace(/\/$/, '');
|
|
const setupOf = (s) => (s.setup_url || s.base_url.replace(/\/$/, '') + ':8791').replace(/\/$/, '');
|
|
const hostOf = (s, domain) => (domain ? (domain.host || `mail.${domain}`) : new URL(s.base_url).host);
|
|
/** 도메인 → 그 도메인의 웹 메일 호스트(등록부 hosts[0]). 캐시(60초). */
|
|
const hostCache = new Map();
|
|
async function domainHost(s, domain) {
|
|
const c = hostCache.get(s.id + '|' + domain); if (c && Date.now() - c.at < 60000) return c.host;
|
|
const list = await api.domains(s.id); const d = list.find((x) => x.domain === domain); const host = d ? String(d.hosts[0]).split('/')[0] : `mail.${domain}`;
|
|
hostCache.set(s.id + '|' + domain, { host, at: Date.now() }); return host;
|
|
}
|
|
/** 전체 설정 API 호출. AriMail 은 설정 API 를 기본 도메인 호스트(req.headers.host)에서만 응답하고 Node fetch 는 Host 헤더를 바꿀 수 없으므로 setup_url 자체가 기본 도메인 이름이어야 한다(등록 화면 안내, 실제 환경 확인). */
|
|
const setupCall = (s, path, opts = {}) => call(fetchImpl, setupOf(s) + path, { ...opts, headers: { ...setupHeaders(s), ...(opts.headers || {}) } });
|
|
const setupHeaders = (s) => { const tok = vault.get(secretName(s.id, 'setup-token')); if (!tok) { const e = new Error('AriMail 전체 설정 토큰이 보관소에 없습니다'); e.code = 'mail_server_token_missing'; throw e; } return { 'X-Setup-Token': tok }; };
|
|
/** 관리자 세션 쿠키(도메인별). 보관소의 관리자 로그인으로 로그인해 10분 캐시. */
|
|
const adminCache = new Map();
|
|
async function adminCookie(s, domain) {
|
|
const k = s.id + '|' + domain; const c = adminCache.get(k); if (c && Date.now() - c.at < 10 * 60 * 1000) return c.cookie;
|
|
const cred = parseJson(vault.get(secretName(s.id, 'admin:' + domain)), null); if (!cred || !cred.login) { const e = new Error(`AriMail 관리자 로그인 정보가 없습니다(${domain})`); e.code = 'mail_admin_missing'; throw e; }
|
|
const host = await domainHost(s, domain);
|
|
const r = await call(fetchImpl, internalOf(s) + '/api/login', { method: 'POST', host, headers: { 'X-Mail-Scope': 'admin' }, body: { login: cred.login, password: cred.password } });
|
|
const cookie = String(r.headers.get('set-cookie') || '').split(';')[0]; adminCache.set(k, { cookie, at: Date.now() }); return cookie;
|
|
}
|
|
const admin = async (s, domain, path, opts = {}) => call(fetchImpl, internalOf(s) + '/api/' + path, { ...opts, host: await domainHost(s, domain), headers: { 'X-Mail-Scope': 'admin', Cookie: await adminCookie(s, domain), ...(opts.headers || {}) } });
|
|
|
|
const api = {
|
|
kind: 'arimail',
|
|
// ---- 서버 등록부(운영자)
|
|
listServers() { return store.all('SELECT * FROM mail_servers ORDER BY is_default DESC, created_at').map((s) => ({ ...s, last_health: parseJson(s.last_health_json, null), secrets: { setupToken: vault.has(secretName(s.id, 'setup-token')), usageKey: vault.has(secretName(s.id, 'usage-key')), ssoSecret: vault.has(secretName(s.id, 'sso-secret')) } })); },
|
|
defaultServer() { return serverRow(null); },
|
|
getServer: (id) => serverRow(id),
|
|
/** 서버 등록·수정. secrets 는 보관소에만 저장하고 응답에 돌려주지 않는다. */
|
|
saveServer({ id = null, name, baseUrl, internalUrl = null, setupUrl = null, mailHost = null, isDefault = false, secrets = {} }) {
|
|
const now = nowIso(); const sid = id || newId('ms_');
|
|
if (!/^https?:\/\//.test(String(baseUrl || ''))) throw Object.assign(new Error('공개 주소는 http(s):// 로 시작해야 합니다'), { code: 'validation', status: 422 });
|
|
if (isDefault) store.run('UPDATE mail_servers SET is_default=0');
|
|
if (id) store.run('UPDATE mail_servers SET name=?, base_url=?, internal_url=?, setup_url=?, mail_host=?, is_default=?, updated_at=? WHERE id=?', name, baseUrl, internalUrl, setupUrl, mailHost, isDefault ? 1 : 0, now, id);
|
|
else store.run('INSERT INTO mail_servers(id, name, base_url, internal_url, setup_url, mail_host, is_default, created_at, updated_at) VALUES(?,?,?,?,?,?,?,?,?)', sid, name, baseUrl, internalUrl, setupUrl, mailHost, isDefault || !store.get('SELECT 1 FROM mail_servers') ? 1 : 0, now, now);
|
|
for (const [k, v] of Object.entries(secrets)) if (v) vault.set(secretName(sid, k), typeof v === 'string' ? v : JSON.stringify(v), { kind: 'arimail' });
|
|
return api.listServers().find((s) => s.id === sid);
|
|
},
|
|
/** 도메인 관리자 로그인 보관(도메인 추가 때 초기 관리자 비밀번호를 받아 저장) */
|
|
storeAdmin(serverId, domain, login, password) { vault.set(secretName(serverId, 'admin:' + domain), JSON.stringify({ login, password }), { kind: 'arimail-admin' }); adminCache.delete(serverId + '|' + domain); },
|
|
async health(serverId) {
|
|
const s = serverRow(serverId); const t0 = Date.now();
|
|
try { const r = await call(fetchImpl, internalOf(s) + '/api/health'); const h = { ok: true, version: r.json.version, domains: r.json.domains, ms: Date.now() - t0, at: nowIso() }; store.run('UPDATE mail_servers SET last_health_at=?, last_health_json=? WHERE id=?', h.at, toJson(h), s.id); return h; }
|
|
catch (e) { const h = { ok: false, error: e.code || e.message, at: nowIso() }; store.run('UPDATE mail_servers SET last_health_at=?, last_health_json=? WHERE id=?', h.at, toJson(h), s.id); return h; }
|
|
},
|
|
// ---- 사용량 API
|
|
async usage(serverId, domain = '', { days = 30 } = {}) {
|
|
const s = serverRow(serverId); const key = vault.get(secretName(s.id, 'usage-key')); if (!key) { const e = new Error('AriMail 사용량 API 키가 보관소에 없습니다'); e.code = 'mail_usage_key_missing'; throw e; }
|
|
const r = await call(fetchImpl, `${internalOf(s)}/api/usage${domain ? '/' + encodeURIComponent(domain) : ''}?days=${days}`, { headers: { Authorization: 'Bearer ' + key } });
|
|
return r.json;
|
|
},
|
|
// ---- 전체 설정 API(도메인 등록부)
|
|
async domains(serverId) { const s = serverRow(serverId); return (await setupCall(s, '/api/setup/domains', { headers: setupHeaders(s) })).json.domains; },
|
|
async addDomain(serverId, { domain, hosts = null, mailbox = 'admin', adminPassword, ariweb = null, operating = 'active', note = '' }) {
|
|
const s = serverRow(serverId); const pw = adminPassword || randomBytes(12).toString('base64url');
|
|
const r = await setupCall(s, '/api/setup/domains', { method: 'POST', headers: setupHeaders(s), body: { domain, hosts: hosts || [`mail.${domain}`], mailbox, adminPassword: pw, ariweb, operating, note, copyFromPrimary: true } });
|
|
hostCache.delete(s.id + '|' + domain);
|
|
if (r.json.initialAdmin) api.storeAdmin(s.id, domain, r.json.initialAdmin.login, pw);
|
|
return { entry: r.json.domain, initialAdmin: r.json.initialAdmin ? { login: r.json.initialAdmin.login } : null };
|
|
},
|
|
async updateDomain(serverId, domain, patch) { const s = serverRow(serverId); hostCache.delete(s.id + '|' + domain); return (await setupCall(s, '/api/setup/domains/' + encodeURIComponent(domain), { method: 'PATCH', headers: setupHeaders(s), body: patch })).json.domain; },
|
|
async removeDomain(serverId, domain) { const s = serverRow(serverId); hostCache.delete(s.id + '|' + domain); return (await setupCall(s, '/api/setup/domains/' + encodeURIComponent(domain), { method: 'DELETE', headers: setupHeaders(s) })).json.removed; },
|
|
/** 도메인 설정(비밀 가림)·메타(DNS 레코드·발신 상태·호스트). */
|
|
async domainConfig(serverId, domain) { const s = serverRow(serverId); return (await setupCall(s, `/api/setup/config?domain=${encodeURIComponent(domain)}`, { headers: setupHeaders(s) })).json; },
|
|
async setDomainConfig(serverId, domain, values) { const s = serverRow(serverId); return (await setupCall(s, `/api/setup/config?domain=${encodeURIComponent(domain)}`, { method: 'PUT', headers: setupHeaders(s), body: { values } })).json; },
|
|
/** 도메인별 보장량(§13.3): 메일함 수·저장 공간·월간 통수를 AriMail 설정으로 */
|
|
setDomainLimits(serverId, domain, { mailboxes, storageBytes, monthlyMessages }) { return api.setDomainConfig(serverId, domain, { LIMIT_ACCOUNTS: mailboxes ?? '', LIMIT_TOTAL_BYTES: storageBytes != null ? String(storageBytes) : '', LIMIT_MONTHLY_MESSAGES: monthlyMessages ?? '' }); },
|
|
async plan(serverId, domain) { const s = serverRow(serverId); return (await setupCall(s, `/api/setup/plan?domain=${encodeURIComponent(domain)}`, { headers: setupHeaders(s) })).json.plan; },
|
|
async runPlan(serverId, domain, body = {}) { const s = serverRow(serverId); return (await setupCall(s, `/api/setup/plan/run?domain=${encodeURIComponent(domain)}`, { method: 'POST', headers: setupHeaders(s), body })).json; },
|
|
async dnsProbe(serverId, domain) { const s = serverRow(serverId); return (await setupCall(s, `/api/setup/dns/probe?domain=${encodeURIComponent(domain)}`, { method: 'POST', headers: setupHeaders(s), body: {} })).json.probe; },
|
|
async outboundCheck(serverId, domain) { const s = serverRow(serverId); return (await setupCall(s, `/api/setup/check?domain=${encodeURIComponent(domain)}`, { method: 'POST', headers: setupHeaders(s), body: { what: 'outbound' } })).json.outboundStatus; },
|
|
// ---- 관리자 API(메일함)
|
|
async listUsers(serverId, domain) { const s = serverRow(serverId); return (await admin(s, domain, 'admin/users')).json; },
|
|
async createUser(serverId, domain, { login, name = '', password, quotaBytes = undefined, services = undefined, role = 'user' }) { const s = serverRow(serverId); return (await admin(s, domain, 'admin/users', { method: 'POST', body: { login, name, password, role, ...(quotaBytes !== undefined ? { quotaBytes } : {}), ...(services ? { services } : {}) } })).json; },
|
|
async updateUser(serverId, domain, id, patch) { const s = serverRow(serverId); return (await admin(s, domain, 'admin/users/' + encodeURIComponent(id), { method: 'PATCH', body: patch })).json; },
|
|
async deleteUser(serverId, domain, id) { const s = serverRow(serverId); return (await admin(s, domain, 'admin/users/' + encodeURIComponent(id), { method: 'DELETE' })).json; },
|
|
async setServices(serverId, domain, services) { const s = serverRow(serverId); return (await admin(s, domain, 'admin/config', { method: 'PATCH', body: { services } })).json; },
|
|
async adminConfig(serverId, domain) { const s = serverRow(serverId); return (await admin(s, domain, 'admin/config')).json; },
|
|
async setLargeFiles(serverId, domain, largeFiles) { const s = serverRow(serverId); return (await admin(s, domain, 'admin/config', { method: 'PATCH', body: { largeFiles } })).json; },
|
|
async adminStatus(serverId, domain) { const s = serverRow(serverId); return (await admin(s, domain, 'admin/status')).json; },
|
|
async rates(serverId, domain) { const s = serverRow(serverId); return (await admin(s, domain, 'admin/rates')).json; },
|
|
/** 메일함의 모든 세션 종료: AriMail 은 비밀번호 재설정이 세션을 무효화하므로 임시 비밀번호 재설정으로 구현(사용자는 다시 정해야 함). */
|
|
async endSessions(serverId, domain, id) { const pw = randomBytes(12).toString('base64url'); await api.updateUser(serverId, domain, id, { password: pw }); return { ok: true, passwordReset: true }; },
|
|
// ---- SSO·접속 정보
|
|
/** 웹메일 SSO 주소(§16.3). AriMail 도메인 설정의 ARIWEB_SSO_SECRET 과 같은 비밀로 서명. 5분 유효, 1회용. */
|
|
async ssoUrl(serverId, domain, mailbox) {
|
|
const s = serverRow(serverId); const secret = vault.get(secretName(s.id, 'sso-secret')); if (!secret) return null;
|
|
const payload = Buffer.from(JSON.stringify({ mailbox, exp: Date.now() + 5 * 60 * 1000, nonce: randomBytes(12).toString('base64url') })).toString('base64url');
|
|
const host = await domainHost(s, domain);
|
|
return `https://${host}/api/auth/sso?ticket=${encodeURIComponent(payload + '.' + createHmac('sha256', secret).update(payload).digest('base64url'))}`;
|
|
},
|
|
/** SSO 비밀을 만들어 보관소와 AriMail 도메인 설정 양쪽에 둔다(값은 돌려주지 않음). */
|
|
async ensureSso(serverId, domain) { const s = serverRow(serverId); let secret = vault.get(secretName(s.id, 'sso-secret')); if (!secret) { secret = randomBytes(32).toString('base64url'); vault.set(secretName(s.id, 'sso-secret'), secret, { kind: 'arimail' }); } await api.setDomainConfig(s.id, domain, { ARIWEB_SSO_SECRET: secret }); return true; },
|
|
/**
|
|
* 실제 외부 왕복 확인(§15.3 8단계): 검증 메일함(ariweb-check@도메인, 비밀번호는 보관소)이 SMTP 제출(587 STARTTLS, AriMail smtp-client 재사용)로 외부 검증 주소에 보내고,
|
|
* 그 주소의 자동 회신이 검증 메일함 수신함에 들어오는지 웹 API 로 기다린다. 메일 내용은 표식(marker)뿐이며 결과에 본문을 넣지 않는다.
|
|
*/
|
|
async roundTrip(serverId, domain, { to, timeoutMs = 90000 } = {}) {
|
|
const s = serverRow(serverId); const host = await domainHost(s, domain); const login = 'ariweb-check'; const from = `${login}@${domain}`;
|
|
let pw = vault.get(secretName(s.id, 'check:' + domain));
|
|
if (!pw) { pw = randomBytes(12).toString('base64url'); try { await api.createUser(s.id, domain, { login, name: 'Ariweb 검증', password: pw }); } catch (e) { if (e.code !== 'login_taken') throw e; const list = await api.listUsers(s.id, domain); const u = (list.users || []).find((x) => x.login === login); if (u) await api.updateUser(s.id, domain, u.id, { password: pw }); } vault.set(secretName(s.id, 'check:' + domain), pw, { kind: 'arimail-check' }); }
|
|
const marker = 'AWCHK-' + randomBytes(6).toString('hex'); const mailHost = s.mail_host || new URL(s.base_url).host;
|
|
const eml = Buffer.from(`From: ${from}\r\nTo: ${to}\r\nSubject: ${marker}\r\nDate: ${new Date().toUTCString()}\r\nMessage-ID: <${marker}@${domain}>\r\n\r\nAriweb Suite mail verification ${marker}. Please reply to this message.\r\n`);
|
|
let sent = false, sendDetail = '';
|
|
try { const { smtpSend } = await import('../../modules/arimail/server/providers/smtp-client.mjs'); await smtpSend({ host: mailHost, port: Number(deps.config?.values?.mailSubmitPort || 587), security: deps.config?.values?.mailSubmitSecurity || 'starttls', user: from, pass: pw, hostname: 'ariweb-portal', from, to: [to], eml, rejectUnauthorized: deps.config?.values?.mailVerifierInsecureTls ? false : true }); sent = true; }
|
|
catch (e) { sendDetail = `SMTP 제출 실패: ${e.message}${e.providerMessage ? ' ' + e.providerMessage : ''}`; }
|
|
let received = false, replied = false; const deadline = Date.now() + timeoutMs;
|
|
if (sent) {
|
|
const lg = await call(fetchImpl, internalOf(s) + '/api/login', { method: 'POST', host, body: { login, password: pw } }); const cookie = String(lg.headers.get('set-cookie') || '').split(';')[0];
|
|
while (Date.now() < deadline) { const r = await call(fetchImpl, internalOf(s) + '/api/mail?folder=inbox', { host, headers: { Cookie: cookie } }).catch(() => null); const msgs = r?.json?.messages || []; if (msgs.some((m) => String(m.subject || '').includes(marker))) { received = true; replied = true; break; } await new Promise((res) => setTimeout(res, deps.verifyPollMs || 5000)); }
|
|
}
|
|
return { sent, sendDetail, received, replied, marker, to, receiveDetail: sent && !received ? '제한 시간 안에 회신이 수신함에 들어오지 않았습니다(외부 검증 주소의 자동 회신·DNS·MX 확인)' : '', replyDetail: '' };
|
|
},
|
|
/** 메일 프로그램 설정값(비밀번호 없음) */
|
|
programSettings(serverId, domain, address) { const s = serverRow(serverId); const host = s.mail_host || new URL(s.base_url).host; return { username: address, imap: { host, ports: { tls: 993, starttls: 143 } }, pop3: { host, ports: { tls: 995, starttls: 110 } }, smtp: { host, ports: { tls: 465, starttls: 587 } }, webmail: `https://${host}` }; },
|
|
/** 서버 상태 화면용: 등록부·건강·버전(비밀 없음) */
|
|
async overview() { const out = []; for (const s of api.listServers()) { let domains = null, health = await api.health(s.id); try { domains = await api.domains(s.id); } catch (e) { domains = { error: e.code || e.message }; } out.push({ ...s, health, domains }); } return out; },
|
|
secretName,
|
|
};
|
|
return api;
|
|
}
|
|
|
|
/**
|
|
* 메모리 구현(다른 영역의 테스트용). 같은 메서드 이름·모양을 가지며 상태를 this.state 로 들여다볼 수 있다.
|
|
* failWith 를 두면 모든 호출이 그 code 로 실패한다(연결 불가 시나리오).
|
|
*/
|
|
export function createFake(opts = {}) {
|
|
const state = { servers: [{ id: 'fake', name: '가짜 AriMail', base_url: 'https://mail.example.test', internal_url: 'http://127.0.0.1:1', setup_url: 'http://127.0.0.1:1', mail_host: 'mail.example.test', is_default: 1, status: 'active' }], domains: new Map(), users: new Map(), configs: new Map(), usage: new Map(), services: new Map(), failWith: opts.failWith || null, calls: [] };
|
|
const guard = (name) => { state.calls.push(name); if (state.failWith) { const e = new Error('fake failure'); e.code = state.failWith; throw e; } };
|
|
const usersOf = (d) => { if (!state.users.has(d)) state.users.set(d, []); return state.users.get(d); };
|
|
const fake = {
|
|
kind: 'arimail-fake', state,
|
|
listServers: () => state.servers.map((s) => ({ ...s, secrets: { setupToken: true, usageKey: true, ssoSecret: true } })), defaultServer: () => state.servers[0], getServer: () => state.servers[0], saveServer: (s) => ({ ...state.servers[0], ...s }), storeAdmin() {},
|
|
async health() { guard('health'); return { ok: true, version: '1.0.11-fake', domains: state.domains.size, ms: 1, at: nowIso() }; },
|
|
async usage(_s, domain = '', o = {}) {
|
|
guard('usage'); const one = (d) => { const u = state.usage.get(d) || {}; const users = usersOf(d); const cfg = state.configs.get(d) || {}; const lim = { accounts: Number(cfg.LIMIT_ACCOUNTS || 0), totalBytes: Number(cfg.LIMIT_TOTAL_BYTES || 0), monthlyMessages: Number(cfg.LIMIT_MONTHLY_MESSAGES || 0) };
|
|
const storage = u.storageBytes || 0, month = u.monthly || 0; const lvl = (x, l) => (!l ? 0 : x / l >= 1 ? 100 : x / l >= 0.95 ? 95 : x / l >= 0.9 ? 90 : x / l >= 0.8 ? 80 : x / l >= 0.7 ? 70 : 0);
|
|
const levels = { storage: lvl(storage, lim.totalBytes), monthly: lvl(month, lim.monthlyMessages), accounts: lvl(users.length, lim.accounts) }; const reasons = []; if (levels.storage === 100) reasons.push('storage_full'); if (levels.monthly === 100) reasons.push('monthly_limit'); if (levels.accounts === 100) reasons.push('accounts_full');
|
|
return { domain: d, enabled: true, hosts: [`mail.${d}`], authMode: 'local', inbound: 'haraka', outbound: 'haraka', timezone: 'Asia/Seoul', accounts: { local: users.length, external: 0, total: users.length, limit: lim.accounts }, storage: { bytes: storage, limit: lim.totalBytes, largeFiles: { count: 0, bytes: u.largeBytes || 0, sent: 0 } }, messages: { total: u.messages || 0 }, traffic: { month: { sent: u.sent || 0, received: u.received || 0, total: month }, monthlyLimit: lim.monthlyMessages, countingRule: 'per_final_recipient', monthReset: u.monthReset || '2026-10-31T15:00:00.000Z' }, ariweb: (state.domains.get(d) || {}).ariweb || null, operating: (state.domains.get(d) || {}).operating || 'active', warnLevel: Math.max(levels.storage, levels.monthly, levels.accounts), warnLevels: levels, limitedReasons: reasons, measuredAt: nowIso(), accountList: users.map((x) => ({ login: x.login, name: x.name, mailbox: x.mailbox, role: x.role, source: 'local', status: x.status, messages: x.messages || 0, bytes: x.bytes || 0, limit: x.quotaBytes || 0, lastLoginAt: x.lastLoginAt || null, sent: x.sent || 0, received: x.received || 0 })), limits: { system: lim, admin: {} } }; };
|
|
if (domain) { if (!state.domains.has(domain)) { const e = new Error('domain_not_found'); e.code = 'domain_not_found'; e.status = 404; throw e; } return { ok: true, version: '1.0.11-fake', ...one(domain) }; }
|
|
return { ok: true, version: '1.0.11-fake', primary: [...state.domains.keys()][0] || null, totals: {}, domains: [...state.domains.keys()].map(one) };
|
|
},
|
|
async domains() { guard('domains'); return [...state.domains.values()]; },
|
|
async addDomain(_s, { domain, hosts, mailbox = 'admin', ariweb = null, operating = 'active' }) { guard('addDomain'); if (state.domains.has(domain)) { const e = new Error('domain_exists'); e.code = 'domain_exists'; e.status = 409; throw e; } const entry = { domain, enabled: true, hosts: hosts || [`mail.${domain}`], mailbox: `${mailbox}@${domain}`, auth: { mode: 'local' }, ariweb, operating, accounts: 0, messages: 0 }; state.domains.set(domain, entry); usersOf(domain).push({ id: 'u_admin_' + domain, login: mailbox, name: '관리자', mailbox: `${mailbox}@${domain}`, role: 'admin', status: 'active' }); return { entry, initialAdmin: { login: mailbox } }; },
|
|
async updateDomain(_s, domain, patch) { guard('updateDomain'); const e = state.domains.get(domain); if (!e) { const x = new Error('not_found'); x.code = 'not_found'; x.status = 404; throw x; } Object.assign(e, patch); return e; },
|
|
async removeDomain(_s, domain) { guard('removeDomain'); state.domains.delete(domain); return { domain }; },
|
|
async domainConfig(_s, domain) { guard('domainConfig'); const cfg = state.configs.get(domain) || {}; return { ok: true, values: { ...cfg, ARIWEB_SSO_SECRET: undefined }, meta: { host: `mail.${domain}`, domain, dns: { records: [{ type: 'MX', name: domain, value: `10 mail.${domain}` }, { type: 'TXT', name: domain, value: 'v=spf1 a mx -all' }, { type: 'TXT', name: `arimail._domainkey.${domain}`, value: 'v=DKIM1; k=rsa; p=FAKEPUBLICKEY' }, { type: 'TXT', name: `_dmarc.${domain}`, value: 'v=DMARC1; p=quarantine' }] }, outboundStatus: { mode: 'haraka', provider: 'ok' }, outbound: 'haraka', inbound: 'haraka' } }; },
|
|
async setDomainConfig(_s, domain, values) { guard('setDomainConfig'); state.configs.set(domain, { ...(state.configs.get(domain) || {}), ...values }); return { ok: true }; },
|
|
setDomainLimits(s, d, { mailboxes, storageBytes, monthlyMessages }) { return fake.setDomainConfig(s, d, { LIMIT_ACCOUNTS: mailboxes ?? '', LIMIT_TOTAL_BYTES: storageBytes != null ? String(storageBytes) : '', LIMIT_MONTHLY_MESSAGES: monthlyMessages ?? '' }); },
|
|
async plan() { guard('plan'); return { steps: [] }; }, async runPlan() { guard('runPlan'); return { ok: true, log: [] }; },
|
|
async dnsProbe(_s, domain) { guard('dnsProbe'); return state.dnsProbe || { mx: [`mail.${domain}`], spf: true, dkim: true, dmarc: true }; },
|
|
async outboundCheck() { guard('outboundCheck'); return { mode: 'haraka', provider: 'ok' }; },
|
|
async listUsers(_s, domain) { guard('listUsers'); const cfg = state.configs.get(domain) || {}; return { ok: true, users: usersOf(domain).filter((u) => u.role !== 'admin' || true), domain, accounts: { used: usersOf(domain).length, limit: Number(cfg.LIMIT_ACCOUNTS || 0) }, services: state.services.get(domain) || { pop: true, imap: true } }; },
|
|
async createUser(_s, domain, { login, name, password, quotaBytes, services }) { guard('createUser'); const list = usersOf(domain); if (list.some((u) => u.login === login)) { const e = new Error('login_taken'); e.code = 'login_taken'; e.status = 409; throw e; } const cfg = state.configs.get(domain) || {}; if (Number(cfg.LIMIT_ACCOUNTS || 0) && list.length >= Number(cfg.LIMIT_ACCOUNTS)) { const e = new Error('account_limit_reached'); e.code = 'account_limit_reached'; e.status = 409; throw e; } if (!password || password.length < 6) { const e = new Error('weak_password'); e.code = 'weak_password'; e.status = 422; throw e; } const id = 'u_' + randomBytes(4).toString('hex'); list.push({ id, login, name, mailbox: `${login}@${domain}`, role: 'user', status: 'active', quotaBytes: quotaBytes || 0, services: services || { pop: true, imap: true } }); return { ok: true, id }; },
|
|
async updateUser(_s, domain, id, patch) { guard('updateUser'); const u = usersOf(domain).find((x) => x.id === id); if (!u) { const e = new Error('not_found'); e.code = 'not_found'; e.status = 404; throw e; } const { password, ...rest } = patch; Object.assign(u, rest); if (password) u.passwordChanged = (u.passwordChanged || 0) + 1; return { ok: true }; },
|
|
async deleteUser(_s, domain, id) { guard('deleteUser'); const list = usersOf(domain); const i = list.findIndex((x) => x.id === id); if (i >= 0) list.splice(i, 1); return { ok: true }; },
|
|
async setServices(_s, domain, services) { guard('setServices'); state.services.set(domain, { ...(state.services.get(domain) || { pop: true, imap: true }), ...services }); return { ok: true, services: state.services.get(domain) }; },
|
|
async adminConfig(_s, domain) { guard('adminConfig'); return { ok: true, services: state.services.get(domain) || { pop: true, imap: true }, largeFiles: state.largeFiles || { enabled: false, maxBytes: 0, maxDays: 0, maxDownloads: 0 }, systemLargeFiles: { bytes: 1024 ** 3, days: 30, downloads: 20 } }; },
|
|
async setLargeFiles(_s, _d, lf) { guard('setLargeFiles'); state.largeFiles = { ...(state.largeFiles || {}), ...lf }; return { ok: true }; },
|
|
async adminStatus(_s, domain) { guard('adminStatus'); return { ok: true, host: `mail.${domain}`, tls: true, ports: { pop3s: 995, pop3: 110, imaps: 993, imap: 143, smtps: 465, submission: 587 }, services: state.services.get(domain) || { pop: true, imap: true }, mail: { inbound: 'haraka', outbound: 'haraka', status: { provider: 'ok' } } }; },
|
|
async rates() { guard('rates'); return { ok: true, rates: { deliveryRate: 5, submissionRate: 10, buckets: [], submissions: [] }, queue: [], abuse: [] }; },
|
|
async endSessions() { guard('endSessions'); return { ok: true, passwordReset: true }; },
|
|
async roundTrip(_s, domain, { to }) { guard('roundTrip'); return state.roundTrip || { sent: true, received: true, replied: true, marker: 'AWCHK-fake', to }; },
|
|
async ssoUrl(_s, domain, mailbox) { guard('ssoUrl'); return `https://mail.${domain}/api/auth/sso?ticket=fake-${encodeURIComponent(mailbox)}`; },
|
|
async ensureSso() { guard('ensureSso'); return true; },
|
|
programSettings(_s, domain, address) { return { username: address, imap: { host: `mail.${domain}`, ports: { tls: 993, starttls: 143 } }, pop3: { host: `mail.${domain}`, ports: { tls: 995, starttls: 110 } }, smtp: { host: `mail.${domain}`, ports: { tls: 465, starttls: 587 } }, webmail: `https://mail.${domain}` }; },
|
|
async overview() { return state.servers.map((s) => ({ ...s, health: { ok: true }, domains: [...state.domains.values()] })); },
|
|
secretName: (id, k) => `arimail:${id}:${k}`,
|
|
};
|
|
return fake;
|
|
}
|