'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/ {name?,password?,role?,status?} DELETE /api/admin/users/ // 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 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} 바꾼 계정의 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} 버전 문자열 */ 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(), };