Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
126 lines
12 KiB
JavaScript
126 lines
12 KiB
JavaScript
'use strict';
|
|
// ============================================================================
|
|
// 메일 사용량 연동 — v1.7.3 (요구 2)
|
|
//
|
|
// Arimail 1.0.9 의 "사용량 정보 제공 API"(AriMail docs/USAGE-API.md) 를 읽어 메일함 디스크 사용량·메일 수·발송/수신량을
|
|
// 사이트 정보 페이지(디스크 사용량의 "메일 데이터")·메일 화면(한눈에 보기)·관리자 콘솔(메일 서버 연동 표)에 보여 준다.
|
|
//
|
|
// - 호출: lib/connectors/arimail.forServer(server).usage(domain, {days}) — 키는 등록부(lib/mailservers.js)가 보관소에서 꺼낸 server.usageKey.
|
|
// GET /api/usage(모든 도메인 요약) · GET /api/usage/<도메인>(계정 목록·일별 추이 포함).
|
|
// - 캐시: Arimail 은 IP 마다 1분 120회 제한이 있고 화면마다 부르면 금방 찬다 — 서버·도메인·기간별로 5분(성공) / 1분(실패) 캐시한다.
|
|
// 관리자가 "새로 고침" 을 누르면(force) 캐시를 건너뛴다. 키를 바꾸거나 서버를 지우면 invalidate 로 비운다.
|
|
// - 사이트 화면(forSite): 사이트의 메일 도메인(site.webmail.domain)을 다루는 서버에서 그 도메인 보고서를 받고, 계정 목록은
|
|
// "이 사이트가 만든 메일 계정"(lib/mailsys 등록부의 login/address)만 남긴다 — 같은 도메인을 쓰는 다른 사이트·게이트웨이 계정의 이름은 보이지 않게.
|
|
// - 응답에는 수(통·바이트)·제한·계정 이름만 있고 비밀값은 없다(Arimail 쪽도 같다). 오류는 ok:false + code + message 로 돌려 화면이 그대로 보여 준다.
|
|
// (예외를 던지지 않는다 — 통계 화면이 메일 서버 장애로 깨지면 안 된다.)
|
|
// ============================================================================
|
|
const TTL_OK = 5 * 60 * 1000; // 성공 응답 캐시
|
|
const TTL_ERR = 60 * 1000; // 실패 응답 캐시(연결 실패·키 오류를 매번 다시 두드리지 않게)
|
|
const cache = new Map(); // 키 "<서버 id>|<도메인>|<days>" → { at, ttl, data?, error? }
|
|
|
|
const num = (v) => (Number.isFinite(Number(v)) ? Number(v) : 0);
|
|
/** 서버 식별 — id 문자열이면 등록부에서 찾는다 */
|
|
function serverOf(idOrServer) { if (idOrServer && typeof idOrServer === 'object') return idOrServer; try { return require('./mailservers').get(idOrServer); } catch (_) { return null; } }
|
|
/** 화면에 내려보낼 서버 요약(비밀값 없음) */
|
|
function serverLite(s) { return s ? { id: s.id, nickname: s.nickname || s.host || s.id, host: s.host || '', ip: s.ip || '', domain: s.domain || '' } : null; }
|
|
/** 캐시 비우기 — 서버 id 를 주면 그 서버만, 없으면 전부 */
|
|
function invalidate(serverId) { if (!serverId) { cache.clear(); return; } for (const k of [...cache.keys()]) if (k.startsWith(`${serverId}|`)) cache.delete(k); }
|
|
|
|
/**
|
|
* Arimail 사용량 API 를 캐시를 거쳐 부른다.
|
|
* @returns {Promise<{ok:boolean, at:string, cached:boolean, data?:object, error?:{code:string|null, message:string, status:number}}>}
|
|
*/
|
|
async function fetchUsage(server, domain, days, force) {
|
|
const key = `${server.id}|${String(domain || '').toLowerCase()}|${days}`; const hit = cache.get(key);
|
|
if (!force && hit && Date.now() - hit.at < hit.ttl) return { ...hit.res, cached: true };
|
|
let res;
|
|
try { const data = await require('./connectors/arimail').forServer(server).usage(domain, { days }); res = { ok: true, at: new Date().toISOString(), data }; cache.set(key, { at: Date.now(), ttl: TTL_OK, res }); }
|
|
catch (e) { res = { ok: false, at: new Date().toISOString(), error: { code: e.code || null, message: e.message, status: e.status || 500 } }; cache.set(key, { at: Date.now(), ttl: TTL_ERR, res }); }
|
|
return { ...res, cached: false };
|
|
}
|
|
|
|
/** traffic 의 한 기간({sent, received, total, sentBytes, receivedBytes, start, end}) 정리 */
|
|
function period(p) { const x = p || {}; return { sent: num(x.sent), received: num(x.received), total: num(x.total) || num(x.sent) + num(x.received), sentBytes: num(x.sentBytes), receivedBytes: num(x.receivedBytes), start: x.start || null, end: x.end || null }; }
|
|
/** 계정 한 줄 — 문서의 accountList 항목에서 화면에 쓰는 값만 (비밀값 없음) */
|
|
function account(a) { return { login: a.login || '', name: a.name || '', mailbox: a.mailbox || '', role: a.role || '', source: a.source || '', status: a.status || '', messages: num(a.messages), bytes: num(a.bytes), limit: num(a.limit), percent: a.percent == null ? null : num(a.percent), lastLoginAt: a.lastLoginAt || null, sent: num(a.sent), received: num(a.received), sentBytes: num(a.sentBytes), receivedBytes: num(a.receivedBytes) }; }
|
|
/**
|
|
* 도메인 보고서 정리 — Arimail 응답(domains[] 항목 또는 /api/usage/<도메인>)을 화면이 쓰는 모양으로. 없는 값은 0/null.
|
|
* @param {object} rep Arimail 도메인 보고서
|
|
* @param {{withAccounts?:boolean}} [o] 계정 목록 포함 여부(요약 표에서는 뺀다)
|
|
*/
|
|
function domainReport(rep, o = {}) {
|
|
const r = rep || {}; const st = r.storage || {}; const ac = r.accounts || {}; const ms = r.messages || {}; const tr = r.traffic || {}; const lf = st.largeFiles || {};
|
|
const out = {
|
|
domain: r.domain || '', primary: !!r.primary, enabled: r.enabled !== false, error: r.error || null, hosts: Array.isArray(r.hosts) ? r.hosts : [], authMode: r.authMode || null, timezone: r.timezone || null, generatedAt: r.generatedAt || null,
|
|
accounts: { local: num(ac.local), external: num(ac.external), total: num(ac.total) || num(ac.local) + num(ac.external), limit: num(ac.limit), percent: ac.percent == null ? null : num(ac.percent) },
|
|
storage: { bytes: num(st.bytes), limit: num(st.limit), percent: st.percent == null ? null : num(st.percent), largeFiles: { count: num(lf.count), bytes: num(lf.bytes), sent: num(lf.sent) } },
|
|
messages: { total: num(ms.total), inbound: num(ms.inbound), outbound: num(ms.outbound), drafts: num(ms.drafts), failed: num(ms.failed) },
|
|
traffic: { day: period(tr.day), week: period(tr.week), month: period(tr.month), monthlyLimit: num(tr.monthlyLimit), monthlyPercent: tr.monthlyPercent == null ? null : num(tr.monthlyPercent) },
|
|
period: r.period || null,
|
|
daily: Array.isArray(r.daily) ? r.daily.map((d) => ({ date: d.date, sent: num(d.sent), received: num(d.received), sentBytes: num(d.sentBytes), receivedBytes: num(d.receivedBytes) })) : [],
|
|
};
|
|
if (o.withAccounts) out.accountList = Array.isArray(r.accountList) ? r.accountList.map(account) : [];
|
|
return out;
|
|
}
|
|
|
|
/**
|
|
* 서버 전체 요약 (관리자 콘솔 › 메일 서버 연동 › "사용량"): 모든 도메인의 합계와 도메인별 보고서(계정 목록 없음).
|
|
* @param {string|object} idOrServer 서버 id 또는 등록부 서버 객체
|
|
* @param {{force?:boolean, days?:number}} [o]
|
|
*/
|
|
async function forServer(idOrServer, o = {}) {
|
|
const s = serverOf(idOrServer); if (!s) return { ok: false, server: null, at: new Date().toISOString(), error: { code: 'no_server', message: '그 메일 서버가 없습니다', status: 404 } };
|
|
const days = Math.min(366, Math.max(1, Number(o.days) || 30));
|
|
const r = await fetchUsage(s, '', days, !!o.force);
|
|
if (!r.ok) return { ok: false, server: serverLite(s), at: r.at, cached: r.cached, hasKey: !!s.usageKey, error: r.error };
|
|
const d = r.data || {}; const t = d.totals || {};
|
|
return {
|
|
ok: true, server: serverLite(s), at: r.at, cached: r.cached, hasKey: true, version: d.version || null, primary: d.primary || null, generatedAt: d.generatedAt || null,
|
|
totals: { domains: num(t.domains), accounts: num(t.accounts), storageBytes: num(t.storageBytes), largeFileBytes: num(t.largeFileBytes), messages: num(t.messages), sentMonth: num(t.sentMonth), receivedMonth: num(t.receivedMonth) },
|
|
domains: (Array.isArray(d.domains) ? d.domains : []).map((x) => domainReport(x)),
|
|
};
|
|
}
|
|
/**
|
|
* 도메인 하나의 상세(계정 목록·일별 추이 포함).
|
|
* @param {string|object} idOrServer
|
|
* @param {string} domain
|
|
* @param {{force?:boolean, days?:number}} [o]
|
|
*/
|
|
async function forDomain(idOrServer, domain, o = {}) {
|
|
const s = serverOf(idOrServer); if (!s) return { ok: false, server: null, at: new Date().toISOString(), error: { code: 'no_server', message: '그 메일 서버가 없습니다', status: 404 } };
|
|
const dom = String(domain || '').toLowerCase().replace(/^www\./, ''); if (!dom) return { ok: false, server: serverLite(s), at: new Date().toISOString(), error: { code: 'no_domain', message: '메일 도메인이 없습니다', status: 400 } };
|
|
const days = Math.min(366, Math.max(1, Number(o.days) || 30));
|
|
const r = await fetchUsage(s, dom, days, !!o.force);
|
|
if (!r.ok) return { ok: false, server: serverLite(s), domain: dom, at: r.at, cached: r.cached, hasKey: !!s.usageKey, error: r.error };
|
|
return { ok: true, server: serverLite(s), domain: dom, at: r.at, cached: r.cached, hasKey: true, version: (r.data && r.data.version) || null, report: domainReport(r.data, { withAccounts: true }) };
|
|
}
|
|
/**
|
|
* 사이트 화면용 — 이 사이트의 메일 도메인 보고서. 계정 목록은 이 사이트가 만든 메일 계정(lib/mailsys 등록부)만 남기고 `mine:true` 로 표시한다.
|
|
* 메일 시스템이 꺼져 있거나 서버·키가 없으면 ok:false 와 이유(code/message) — 화면은 그 문구를 그대로 보여 준다.
|
|
* @param {object} site 사이트 기록(webmail.enabled / webmail.domain / webmail.mailServer)
|
|
* @param {{force?:boolean, days?:number}} [o]
|
|
*/
|
|
async function forSite(site, o = {}) {
|
|
const w = (site && site.webmail) || {};
|
|
if (!w.enabled) return { ok: false, code: 'mail_off', message: '이 사이트는 메일 시스템이 켜져 있지 않습니다', at: new Date().toISOString() };
|
|
if (!w.domain) return { ok: false, code: 'no_domain', message: '이 사이트의 메일 도메인이 없습니다', at: new Date().toISOString() };
|
|
let s = null; try { s = require('./mailservers').pick(site); } catch (_) {}
|
|
if (!s) return { ok: false, code: 'no_server', message: '등록된 메일 서버(Arimail)가 없습니다 — 운영자가 메일 서버를 등록하면 사용량이 보입니다', domain: w.domain, at: new Date().toISOString() };
|
|
if (s.domain && s.domain !== String(w.domain).toLowerCase()) return { ok: false, code: 'domain_mismatch', message: `메일 서버 "${s.nickname || s.host}" 는 ${s.domain} 도메인을 다루는데 이 사이트의 메일 도메인은 ${w.domain} 입니다 — 운영자가 이 도메인을 다루는 메일 서버를 등록·선택해야 합니다`, domain: w.domain, server: serverLite(s), at: new Date().toISOString() };
|
|
const r = await forDomain(s, w.domain, o);
|
|
if (!r.ok) return { ok: false, code: (r.error && r.error.code) || 'error', message: (r.error && r.error.message) || '사용량을 받지 못했습니다', domain: w.domain, server: r.server, at: r.at, hasKey: r.hasKey };
|
|
// 이 사이트의 계정만 — 등록부(login/address)와 맞춰 본다
|
|
let mine = []; try { mine = require('./mailsys').listAccounts({ siteId: site.id }); } catch (_) {}
|
|
const logins = new Set(mine.map((a) => String(a.login || '').toLowerCase()).filter(Boolean)); const boxes = new Set(mine.map((a) => String(a.address || '').toLowerCase()).filter(Boolean));
|
|
const rep = r.report; const all = rep.accountList || [];
|
|
const accountList = all.filter((a) => logins.has(String(a.login || '').toLowerCase()) || boxes.has(String(a.mailbox || '').toLowerCase())).map((a) => ({ ...a, mine: true }));
|
|
const myBytes = accountList.reduce((n, a) => n + a.bytes, 0);
|
|
return {
|
|
ok: true, domain: rep.domain || w.domain, server: r.server, at: r.at, cached: r.cached, version: r.version, generatedAt: rep.generatedAt,
|
|
storage: rep.storage, accounts: { ...rep.accounts, mine: accountList.length }, messages: rep.messages, traffic: rep.traffic, period: rep.period, daily: rep.daily,
|
|
accountList, myBytes, // myBytes: 이 사이트 계정들의 메일함 합계 (도메인 전체 storage.bytes 와 구분)
|
|
};
|
|
}
|
|
|
|
module.exports = { forServer, forDomain, forSite, invalidate, domainReport, TTL_OK, TTL_ERR, _cache: cache };
|