Files
AriWeb-Suite/modules/webmaker/lib/connectors/arimail.js

260 lines
26 KiB
JavaScript

'use strict';
// ============================================================================
// 메일 서버 연동 모듈 — Arimail (v1.5.7, 요구 5 · v1.6.38 요구 2: 여러 서버)
//
// Arimail 은 OZ-Group 의 메일 게이트웨이(mail-gateway, 0.9.x 에서 AriMail 로 이름이 바뀜)다. Resend 로 받은 메일을 보관하고
// 웹메일(/), 관리자 페이지(/admin), IMAP(993/143)·POP3(995/110)·SMTP 제출(465/587) 서버를 연다. 한 설치는 한 메일 도메인
// (환경 파일 MAILBOX=admin@도메인)의 계정 "로그인ID@도메인" 을 다룬다.
//
// 이 모듈이 하는 일 — 웹메이커 쪽 "사이트 메일 시스템"(lib/mailsys.js)의 받는 메일 백엔드
// - 관리자 API 로 계정을 만들고 지운다: 사이트 관리자 계정의 "메일 사용" 을 켤 때 Arimail 계정(같은 로그인 ID)을 만들고,
// 끌 때 지운다. 비밀번호는 Arimail 이 PBKDF2 로 따로 보관하므로 켤 때 사용자가 메일 비밀번호를 정한다
// (웹메이커는 사이트 관리자 비밀번호의 원문을 갖고 있지 않다 — scrypt 해시만).
// - 상태: /api/health(버전) 와 관리자 목록(계정 수·제한)으로 연결 확인.
// - 주소: 웹메일 주소(사이트의 /webmail 진입 파일과 "웹메일 열기" 버튼이 이곳으로), 메일 프로그램 설정값(IMAP/POP/SMTP 호스트·포트).
//
// Arimail 관리자 API (server/api.mjs)
// POST /api/login {login,password} 헤더 X-Mail-Scope: admin → Set-Cookie: mg_admin=<토큰> (관리자 역할 계정만, 15분 10회 제한)
// GET /api/admin/users → { users:[{id,login,name,mailbox,role,status,…}], domain, accounts:{used,limit}, policy, … }
// POST /api/admin/users {login,name,password,role?,status?} → { id } (오류 코드: login_taken, weak_password, account_limit_reached, invalid_login)
// PATCH /api/admin/users/<id> {name?,password?,role?,status?} DELETE /api/admin/users/<id>
// GET /api/admin/status 서버 상태(포트·TLS·동기화)
// 오류 응답은 { ok:false, error:'<코드>' } 와 HTTP 상태. 세션 쿠키는 Secure 속성이 있지만 서버 간 호출에서는 우리가 직접 붙이므로 http 로도 쓸 수 있다.
//
// v1.6.38 (요구 2): Arimail 이 웹메이커와 다른 서버에, 그것도 여러 대 있을 수 있다(webmaker.ibit.kr 121.152.149.45 ↔ webmail.ibit.kr 115.68.194.192).
// 접속 정보는 등록부(lib/mailservers.js)가 준다 — 설정 파일 mail.* 의 "기본 서버" 와 관리자 콘솔에서 추가한 서버들.
// 모든 동작은 forServer(server) 로 서버를 골라 쓴다. 모듈 최상위 함수들은 "기본 서버" 에 묶인 것이라 1.5.7 호출 코드가 그대로 돈다.
// 관리자 세션 쿠키는 서버(주소+계정)마다 따로 20분 보관한다.
// v1.6.39 (요구 2·3·4):
// - 로그인 실패에 오류 코드를 붙이고, rate_limited(15분 10회 제한) 이면 등록부(lib/mailservers.js)에 잠금 해제 시각을 적는다.
// - 사이트 관리자 비밀번호 ↔ 메일 비밀번호 동기화(setPassword): 웹메이커는 해시만 가지므로 "원문을 아는 순간"(사이트 관리자 로그인·메일 사용 켜기)에
// lib/mailsys.syncPassword 가 Arimail 계정 비밀번호를 같은 값으로 맞춘다 — 분리 설치(다른 서버)에서도 웹메이커 계정으로 웹메일에 들어간다.
// - 관리자 계정 보호(protectAdmin): 등록된 연동 관리자·관리자 역할 계정은 웹메이커에서 지우거나 역할을 내릴 수 없다.
// ============================================================================
const id = 'arimail';
const label = 'Arimail (메일 게이트웨이)';
const kind = 'mail';
const httpError = (m, s = 400) => Object.assign(new Error(m), { status: s });
/** Arimail 오류 코드 → 사용자 문구 */
const ERRORS = {
invalid_credentials: 'Arimail 관리자 로그인에 실패했습니다 (아이디·비밀번호 확인 — 관리자 콘솔 › 메일 시스템 › 메일 서버 연동, 기본 서버는 설정 파일 mail.adminLogin / 보관소 arimail)',
admin_only: 'Arimail 에 등록한 계정이 관리자 역할이 아닙니다',
admin_login_required: 'Arimail 관리자 세션이 없습니다',
rate_limited: 'Arimail 로그인 시도가 너무 많아 잠시 막혔습니다 (15분 뒤 다시)',
login_taken: '같은 로그인 ID 의 Arimail 계정이 이미 있습니다',
weak_password: '메일 비밀번호가 Arimail 의 비밀번호 정책에 맞지 않습니다 (관리자 페이지 › 비밀번호 정책)',
account_limit_reached: 'Arimail 의 계정 수 제한(LIMIT_ACCOUNTS)에 이르렀습니다',
invalid_login: '로그인 ID 를 Arimail 계정으로 쓸 수 없습니다 (영문 소문자·숫자·. _ - 2~64자)',
not_found: 'Arimail 에 그 계정이 없습니다',
validation: 'Arimail 이 입력값을 거부했습니다',
cannot_delete_self: 'Arimail 관리자 자신의 계정은 지울 수 없습니다',
pending_approval: '가입 승인을 기다리는 계정입니다',
// v1.7.3 (요구 2): 사용량 API(1.0.9) 와 키 발급(POST /api/setup/usage-key) 오류
usage_api_disabled: 'Arimail 의 사용량 API 가 꺼져 있습니다(키를 만들지 않았거나 지움) — 관리자 콘솔 › 메일 시스템 › 메일 서버 연동의 "사용량 API 키 발급" 을 누르거나 Arimail 전체 설정 › 사용량 API 카드에서 키를 만들어 넣으세요',
unauthenticated: '사용량 API 키가 Arimail 의 키와 다릅니다 — Arimail 에서 키를 다시 만들었다면 새 키를 넣거나 "사용량 API 키 발급" 으로 다시 받으세요',
forbidden_ip: '이 웹메이커 서버의 IP 가 Arimail 사용량 API 의 접속 허용 IP 목록에 없습니다 — Arimail 전체 설정 › 사용량 API › 접속 허용 IP 에 이 서버의 공인 IP(같은 서버면 127.0.0.1 도)를 더하거나 목록을 비우세요',
primary_only: '사용량 API 키는 Arimail 기본 도메인에서만 만들 수 있습니다 — 이 서버 등록의 API 주소를 기본 도메인 호스트로 바꾸거나, Arimail 전체 설정 화면에서 만든 키를 직접 넣으세요',
bad_request: 'Arimail 이 요청을 이해하지 못했습니다 (Arimail 버전이 낮을 수 있음 — 사용량 API 는 1.0.9 부터)',
};
/** v1.7.3: 사용량 API 가 들어간 Arimail 최소 버전 */
const USAGE_MIN_VERSION = '1.0.9';
/** 'x.y.z(-접미)' 두 버전 비교 — a<b 면 음수. 숫자가 아니면 0(비교 불가 → 막지 않음) */
function cmpVersion(a, b) { const pa = String(a || '').match(/^(\d+)\.(\d+)\.(\d+)/), pb = String(b || '').match(/^(\d+)\.(\d+)\.(\d+)/); if (!pa || !pb) return 0; for (let i = 1; i <= 3; i++) { const d = Number(pa[i]) - Number(pb[i]); if (d) return d; } return 0; }
const explain = (code, status) => ERRORS[code] || `Arimail 오류 ${code || status}`;
/** 서버 객체(등록부) → 접속 정보. 서버가 없으면 모두 빈 값 */
function toSettings(server) {
const s = server || {};
const url = String(s.url || '').replace(/\/+$/, '');
let host = s.host || ''; if (!host) { try { host = new URL(url).hostname; } catch (_) {} }
return { id: s.id || '', nickname: s.nickname || '', ip: s.ip || '', url, apiUrl: String(s.apiUrl || '').replace(/\/+$/, '') || url, host, domain: String(s.domain || '').trim().toLowerCase(), adminLogin: String(s.adminLogin || 'admin').trim(), adminPassword: s.adminPassword || '', usageKey: s.usageKey || '' /* v1.7.3: 사용량 API 키(보관소) */ };
}
/** 등록부의 기본 서버 (없으면 null) — 설정 파일 mail.* 또는 관리자 콘솔에서 기본으로 지정한 서버 */
function defaultServer() { try { return require('../mailservers').defaultServer(); } catch (_) { return null; } }
// ---------- 관리자 세션 (서버마다) ----------
/** 로그인 결과 쿠키를 잠시 재사용한다 (같은 주소·계정, 20분). 401 이 나면 다시 로그인한다. 키 = 주소|계정 */
const sessions = new Map();
// 키에 비밀번호의 짧은 해시를 넣어 관리자 콘솔에서 비밀번호를 바꾸면 이전 세션을 버리고 다시 로그인하게 한다 (비밀번호 원문은 키에 넣지 않음)
const sessionKey = (s) => `${s.apiUrl}|${s.adminLogin}|${require('crypto').createHash('sha256').update(String(s.adminPassword || '')).digest('hex').slice(0, 12)}`;
/** 서버 이름표(오류 문구용) — 닉네임이 있으면 닉네임, 없으면 호스트 */
const nameOf = (s) => (s.nickname ? `${s.nickname}${s.ip ? ' ' + s.ip : ''}` : s.host || s.apiUrl);
/**
* 한 서버에 묶인 동작 묶음. mailsys 는 사이트마다 고른 서버로 이것을 만들어 쓴다.
* @param {object|null} server 등록부 서버 객체(비밀번호 포함). null 이면 기본 서버
*/
function forServer(server) {
const resolve = () => toSettings(server || defaultServer());
const settings = () => resolve();
const configured = () => { const s = resolve(); return !!(s.url && s.domain && s.adminLogin && s.adminPassword); };
/** 웹메일·관리자 페이지 주소 */
const webmailUrl = () => { const s = resolve(); return s.url ? s.url + '/' : null; };
const adminUrl = () => { const s = resolve(); return s.url ? s.url + '/admin' : null; };
/** 메일 프로그램 설정값 (사용자 안내용). Arimail 은 웹·IMAP·POP·SMTP 가 같은 아이디·비밀번호다 */
const clientSettings = () => { const s = resolve(); if (!s.host) return null; return { host: s.host, imap: { host: s.host, ports: [993, 143], note: '993 SSL/TLS 또는 143 STARTTLS' }, pop: { host: s.host, ports: [995, 110], note: '995 SSL/TLS 또는 110 STARTTLS' }, smtp: { host: s.host, ports: [465, 587], note: '465 SSL/TLS 또는 587 STARTTLS, 인증: 메일 비밀번호' }, username: '로그인 ID 또는 로그인ID@' + s.domain }; };
/** 로그인 ID → 메일 주소 */
const mailboxFor = (login) => `${String(login).toLowerCase()}@${resolve().domain}`;
async function login() {
const s = resolve(); if (!s.url) throw httpError('Arimail 주소가 없습니다 (관리자 콘솔 › 메일 시스템 › 메일 서버 연동)', 503); if (!s.adminPassword) throw httpError(`Arimail 관리자 비밀번호가 보관소에 없습니다 (${nameOf(s)} — 메일 서버 연동에서 비밀번호를 다시 넣으세요)`, 503);
let r; try { r = await fetch(`${s.apiUrl}/api/login`, { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-Mail-Scope': 'admin' }, body: JSON.stringify({ login: s.adminLogin, password: s.adminPassword }), signal: AbortSignal.timeout(15000) }); }
catch (e) { throw httpError(`Arimail 연결 실패 (${nameOf(s)}, ${s.apiUrl}): ${e.cause ? e.cause.code || e.message : e.message}`, 502); }
const data = await r.json().catch(() => ({}));
if (!r.ok) {
// v1.6.39 (요구 2): 오류 코드를 예외에 붙여 호출자가 구분할 수 있게 하고, 로그인 잠금(rate_limited / 429)이면 등록부에 해제 시각을 적어
// 관리자 콘솔의 "연결 확인" 이 잠금이 풀릴 때까지 다시 시도하지 않게 한다 (시도할수록 잠금이 길어진다)
const code = data.error || (r.status === 429 ? 'rate_limited' : null);
const err = Object.assign(httpError(explain(code, r.status), 502), { code });
if (code === 'rate_limited') { try { err.lockedUntil = require('../mailservers').noteLocked(s.id); } catch (_) {} }
throw err;
}
// Set-Cookie: mg_admin=<토큰>; Path=/; HttpOnly; Secure; … — 토큰 부분만 보관한다
const raw = (typeof r.headers.getSetCookie === 'function' ? r.headers.getSetCookie() : [r.headers.get('set-cookie')].filter(Boolean)).find((c) => /^mg_admin=/.test(c)) || '';
const m = raw.match(/^(mg_admin=[^;]*)/); if (!m) throw httpError('Arimail 로그인 응답에 세션 쿠키가 없습니다', 502);
sessions.set(sessionKey(s), { cookie: m[1], at: Date.now() });
return m[1];
}
async function cookie() { const s = resolve(); const c = sessions.get(sessionKey(s)); if (c && Date.now() - c.at < 20 * 60 * 1000) return c.cookie; return login(); }
/**
* 관리자 API 호출. 401/403(세션 없음)이면 한 번 다시 로그인해서 재시도한다.
* @returns 응답 JSON (ok:false 면 예외)
*/
async function call(method, p, body, retried = false) {
const s = resolve(); const ck = await cookie();
let r; try { r = await fetch(`${s.apiUrl}/api${p}`, { method, headers: { 'Content-Type': 'application/json', 'X-Mail-Scope': 'admin', Cookie: ck }, body: body === undefined ? undefined : JSON.stringify(body), signal: AbortSignal.timeout(20000) }); }
catch (e) { throw httpError(`Arimail 연결 실패 (${nameOf(s)}, ${s.apiUrl}): ${e.cause ? e.cause.code || e.message : e.message}`, 502); }
const data = await r.json().catch(() => ({}));
if ((r.status === 401 || (r.status === 403 && data.error === 'admin_login_required')) && !retried) { sessions.delete(sessionKey(s)); return call(method, p, body, true); }
if (!r.ok) throw Object.assign(httpError(explain(data.error, r.status), r.status >= 500 ? 502 : 400), { code: data.error || null });
return data;
}
// ---------- 계정 ----------
async function health() {
const s = resolve(); if (!s.url) return { ok: false, detail: 'Arimail 주소가 없습니다' };
try { const r = await fetch(`${s.apiUrl}/api/health`, { signal: AbortSignal.timeout(8000) }); const d = await r.json().catch(() => ({})); return { ok: r.ok && d.ok !== false, version: d.version || null, status: r.status, detail: r.ok ? `Arimail ${d.version || ''} 응답` : `응답 ${r.status}` }; }
catch (e) { return { ok: false, detail: `연결 실패 (${s.apiUrl}): ${e.cause ? e.cause.code || e.message : e.message}` }; }
}
/** 계정 목록 + 도메인·제한 */
async function listUsers() { const d = await call('GET', '/admin/users'); return { users: d.users || [], domain: d.domain || resolve().domain, accounts: d.accounts || null, policy: d.policy || null, limits: d.limits || null }; }
async function findUser(login) { const l = String(login || '').toLowerCase(); const { users } = await listUsers(); return users.find((u) => String(u.login).toLowerCase() === l) || null; }
/**
* 계정 만들기. 같은 로그인 ID 가 이미 있으면 그 계정을 돌려준다(existing:true) — 비밀번호가 주어졌으면 그 비밀번호로 재설정.
* @param {{login:string, name?:string, password:string, role?:'admin'|'user', reuseId?:string}} o
*/
async function createUser(o) {
const login = String(o.login || '').toLowerCase(); if (!login) throw httpError('로그인 ID 가 없습니다');
if (!o.password) throw httpError('메일 비밀번호를 입력하세요 (Arimail 계정의 웹메일·IMAP·POP·SMTP 비밀번호)');
try { const r = await call('POST', '/admin/users', { login, name: o.name || login, password: o.password, role: o.role === 'admin' ? 'admin' : 'user' }); return { id: r.id, login, mailbox: mailboxFor(login), existing: false }; }
catch (e) {
if (e.code !== 'login_taken') throw e;
const u = await findUser(login); if (!u) throw e;
// v1.6.31 (요구 9, 검토 S3): 이미 있는 계정은 이 사이트가 소유한 것(o.reuseId === u.id)일 때만 비밀번호를 새로 정한다. 아니면 다른 사이트의 계정일 수 있으므로 건드리지 않고 알린다
if (!(o.reuseId && String(o.reuseId) === String(u.id))) throw Object.assign(httpError(`메일 서버에 같은 아이디(${login})의 계정이 이미 있습니다. 다른 사이트의 계정일 수 있어 그대로 두었습니다 — 다른 아이디를 쓰거나 관리자가 소유를 확인한 뒤 연결하세요`, 409), { code: 'login_conflict', providerId: u.id });
if (o.password) await call('PATCH', `/admin/users/${encodeURIComponent(u.id)}`, { password: o.password, status: 'active' });
return { id: u.id, login, mailbox: u.mailbox || mailboxFor(login), existing: true };
}
}
/**
* v1.6.39 (요구 4): 관리자 계정 보호 — 등록된 연동 관리자 계정(adminLogin)과 Arimail 의 관리자 역할 계정은 웹메이커가 지우거나 역할·상태를 내리지 않는다.
* 웹메이커가 다루는 것은 사이트 메일 계정(user 역할)뿐이다. 서버 기본 관리자를 잃으면 Arimail 자체에 들어갈 길이 없어진다.
*/
function protectAdmin(u, what) {
const s = resolve(); if (!u) return;
if (String(u.login || '').toLowerCase() === String(s.adminLogin || '').toLowerCase() || u.role === 'admin') throw Object.assign(httpError(`Arimail 관리자 계정 "${u.login}" 은(는) 웹메이커에서 ${what} 수 없습니다 — 서버 기본 관리자·연동 관리자 계정은 보호됩니다 (필요하면 Arimail 관리자 페이지 ${adminUrl() || ''} 에서 직접)`, 403), { code: 'protected_admin' });
}
async function updateUser(userId, fields) {
// 역할을 user 로 내리거나 상태를 비활성으로 바꾸는 요청은 관리자 계정이면 거절 (비밀번호·이름 변경, 역할을 admin 으로 올리기는 허용)
if ((fields && fields.role && fields.role !== 'admin') || (fields && fields.status && fields.status !== 'active')) protectAdmin(await getUserById(userId), '역할·상태를 바꿀');
await call('PATCH', `/admin/users/${encodeURIComponent(userId)}`, fields); return true;
}
/** v1.6.31 (요구 9): 공급자 계정 id 로 조회·삭제 — 로그인 이름이 같은 다른 사이트의 계정을 건드리지 않는다 */
async function getUserById(uid) { const { users } = await listUsers(); return users.find((u) => String(u.id) === String(uid)) || null; }
async function deleteUserById(uid) { protectAdmin(await getUserById(uid), '지울'); await call('DELETE', `/admin/users/${encodeURIComponent(uid)}`); return true; }
/** 로그인 ID 로 계정을 찾아 지운다 (없으면 false) */
async function deleteUserByLogin(login) { const u = await findUser(login); if (!u) return false; protectAdmin(u, '지울'); await call('DELETE', `/admin/users/${encodeURIComponent(u.id)}`); return true; }
/**
* v1.6.39 (요구 3): 로그인 ID 의 계정 비밀번호를 바꾼다 (사이트 관리자 비밀번호 ↔ 메일 비밀번호 동기화용). 계정이 없으면 null
* @returns {Promise<string|null>} 바꾼 계정의 id
*/
async function setPassword(login, password) { if (!password) throw httpError('비밀번호가 비어 있습니다'); const u = await findUser(login); if (!u) return null; await call('PATCH', `/admin/users/${encodeURIComponent(u.id)}`, { password }); return u.id; }
async function adminStatus() { return call('GET', '/admin/status'); }
// ---------- v1.7.3 (요구 2): 사용량 API (docs/USAGE-API.md, Arimail 1.0.9) ----------
/**
* 사용량 API 를 쓸 수 있는 버전인지 health 로 확인. 낮은 버전이면 안내 예외(1.0.8 은 /api/usage 가 없어 401/404 만 돌려주므로 "키가 틀렸다" 로 오해하기 전에 막는다)
* @returns {Promise<string|null>} 버전 문자열
*/
async function requireUsageVersion() {
const s = resolve(); const h = await health(); if (!h.ok) throw httpError(`Arimail(${nameOf(s)})에 연결할 수 없습니다 — ${h.detail}`, 502);
if (h.version && cmpVersion(h.version, USAGE_MIN_VERSION) < 0) throw Object.assign(httpError(`Arimail ${h.version} 에는 사용량 API 가 없습니다 — ${nameOf(s)} 의 Arimail 을 ${USAGE_MIN_VERSION} 이상으로 업데이트하면 메일 사용량이 보입니다`, 409), { code: 'usage_unsupported', version: h.version });
return h.version || null;
}
/**
* 사용량 조회 — GET /api/usage(모든 도메인 요약) 또는 /api/usage/<도메인>(계정 목록·일별 추이 포함). 키는 등록부 서버 객체의 usageKey(보관소).
* 응답은 Arimail 문서 그대로(totals/domains[] 또는 storage/traffic/accountList…). 실패는 코드(code)를 붙인 예외 — 호출자는 문구를 그대로 보여 주면 된다.
* @param {string} [domain] 비우면 전체 요약
* @param {{days?:number}} [o] 일별 추이·계정별 기간 합계의 기간(1~366, 기본 30)
*/
async function usage(domain = '', o = {}) {
const s = resolve(); const key = String(s.usageKey || '');
if (!s.apiUrl) throw httpError('Arimail 주소가 없습니다 (관리자 콘솔 › 메일 시스템 › 메일 서버 연동)', 503);
if (!key) throw Object.assign(httpError(`메일 서버 "${nameOf(s)}" 의 사용량 API 키가 없습니다 — 관리자 콘솔 › 메일 시스템 › 메일 서버 연동에서 "사용량 API 키 발급" 을 누르거나 Arimail 전체 설정 › 사용량 API 카드에서 만든 키를 넣으세요`, 409), { code: 'no_usage_key' });
const days = Math.min(366, Math.max(1, Number(o.days) || 30));
let r; try { r = await fetch(`${s.apiUrl}/api/usage${domain ? '/' + encodeURIComponent(String(domain).toLowerCase()) : ''}?days=${days}`, { headers: { Authorization: 'Bearer ' + key, Accept: 'application/json' }, signal: AbortSignal.timeout(20000) }); }
catch (e) { throw httpError(`Arimail 연결 실패 (${nameOf(s)}, ${s.apiUrl}): ${e.cause ? e.cause.code || e.message : e.message}`, 502); }
const data = await r.json().catch(() => ({}));
if (!r.ok) {
// 1.0.8 이하는 /api/usage 자체가 없어 401(세션 없음)·404 로 답한다 — 버전을 확인해 "키가 틀렸다" 대신 업데이트 안내를 낸다
if (r.status === 401 || r.status === 404) await requireUsageVersion();
const code = data.error || (r.status === 404 ? 'usage_api_disabled' : r.status === 401 ? 'unauthenticated' : r.status === 403 ? 'forbidden_ip' : r.status === 429 ? 'rate_limited' : `http_${r.status}`);
throw Object.assign(httpError(ERRORS[code] || `Arimail 사용량 API 오류 ${code}`, r.status >= 500 ? 502 : (r.status === 429 ? 429 : 409)), { code, httpStatus: r.status });
}
return data;
}
/**
* 사용량 API 키 발급 — 관리자 세션으로 POST /api/setup/usage-key {action:'generate'} (기본 도메인 관리자만; 기존 키가 있으면 교체되어 이전 키는 즉시 무효).
* 돌려받은 키는 호출자(lib/mailservers.issueUsageKey)가 보관소에 저장한다. 키는 기록·로그에 남기지 않는다.
* @returns {Promise<{key:string, endpoint:string|null}>}
*/
async function issueUsageKey() {
await requireUsageVersion();
const d = await call('POST', '/setup/usage-key', { action: 'generate' });
if (!d || !d.key) throw httpError('Arimail 이 사용량 API 키를 돌려주지 않았습니다 (응답에 key 없음)', 502);
return { key: String(d.key), endpoint: d.endpoint || null };
}
/** Arimail 쪽 사용량 API 키 삭제(API 끄기) — 관리자 세션 필요 */
async function revokeUsageKey() { await call('POST', '/setup/usage-key', { action: 'revoke' }); return true; }
/** 연결 상태 (설정 페이지·관리자 콘솔 "연결 확인") */
async function status() {
const s = resolve(); const base = { enabled: true, provider: id, serverId: s.id || null, nickname: s.nickname || null, ip: s.ip || null, url: s.url || null, domain: s.domain || null, adminLogin: s.adminLogin, webmail: webmailUrl(), admin: adminUrl(), client: clientSettings(), configured: configured() };
if (!s.url) return { ...base, ok: false, detail: 'Arimail 주소가 없습니다. 관리자 콘솔 › 메일 시스템 › 메일 서버 연동에서 서버를 등록하거나(다른 서버의 Arimail 도 가능) 이 서버에서 deploy/install-arimail.sh 를 실행하세요' };
const h = await health(); if (!h.ok) return { ...base, ok: false, health: h, detail: `Arimail(${nameOf(s)})에 연결할 수 없습니다 — ${h.detail}` };
if (!s.domain) return { ...base, ok: false, health: h, detail: `Arimail ${h.version || ''} 응답 — 메일 도메인을 넣으세요` };
if (!s.adminPassword) return { ...base, ok: false, health: h, detail: `Arimail ${h.version || ''} 응답 — 관리자 비밀번호가 없어 계정을 관리할 수 없습니다 (보기 전용)` };
try {
const l = await listUsers();
const mismatch = l.domain && l.domain !== s.domain ? ` · 주의: Arimail 의 도메인은 ${l.domain} 인데 등록한 메일 도메인은 ${s.domain}` : '';
return { ...base, ok: !mismatch, health: h, version: h.version, serverDomain: l.domain, accounts: l.accounts, users: l.users.length, detail: `연결 성공 — Arimail ${h.version || ''}${s.nickname ? ` · ${nameOf(s)}` : ''} · 도메인 ${l.domain} · 계정 ${l.users.length}개${l.accounts && l.accounts.limit ? ` / 최대 ${l.accounts.limit}` : ''}${mismatch}` };
} catch (e) {
// v1.6.39: code(rate_limited 등)와 잠금 해제 시각을 함께 돌려 관리자 콘솔이 버튼을 막고 안내할 수 있게 한다
return { ...base, ok: false, health: h, version: h.version, code: e.code || null, lockedUntil: e.lockedUntil || null, detail: `Arimail ${h.version || ''} 응답 — 관리자 로그인 실패: ${e.message}` };
}
}
return { server: server || null, settings, configured, webmailUrl, adminUrl, clientSettings, mailboxFor, health, login, call, listUsers, findUser, createUser, updateUser, setPassword, getUserById, deleteUserById, deleteUserByLogin, adminStatus, status, usage, issueUsageKey, revokeUsageKey, requireUsageVersion /* v1.7.3 */ };
}
// 모듈 최상위 = 기본 서버에 묶인 동작 (1.5.7 호출 코드 호환: connectors.mail().status() 등). 호출할 때마다 기본 서버를 다시 고른다
const base = forServer(null);
module.exports = {
id, label, kind, ERRORS, forServer, toSettings, cmpVersion, USAGE_MIN_VERSION,
settings: () => base.settings(), configured: () => base.configured(), webmailUrl: () => base.webmailUrl(), adminUrl: () => base.adminUrl(), clientSettings: () => base.clientSettings(), mailboxFor: (l) => base.mailboxFor(l),
health: () => base.health(), login: () => base.login(), call: (...a) => base.call(...a), listUsers: () => base.listUsers(), findUser: (l) => base.findUser(l), createUser: (o) => base.createUser(o), updateUser: (u, f) => base.updateUser(u, f),
setPassword: (l, p) => base.setPassword(l, p),
getUserById: (u) => base.getUserById(u), deleteUserById: (u) => base.deleteUserById(u), deleteUserByLogin: (l) => base.deleteUserByLogin(l), adminStatus: () => base.adminStatus(), status: () => base.status(),
};