Files

469 lines
47 KiB
JavaScript
Raw Permalink Normal View History

'use strict';
// ============================================================================
// 사이트 메일 시스템 (v1.3.0)
//
// 사용자 사이트의 도메인으로 "아이디@사이트도메인" 메일 주소를 만들어 웹메일(/webmail)로 쓰게 하는 기능의
// 빌더 쪽 관리 계층이다. 구성은 세 단계로 나뉜다.
//
// 1) 운영자(빌더 관리자) : 관리자 콘솔 › 설정 › 메일 시스템 에서 전체 기능을 켜고 끈다(settings.mailSystem).
// 메일 서버 주소(MX 로 가리킬 호스트)와 SPF 에 넣을 include 값도 여기서 정한다.
// 2) 사이트 관리자 : 사이트 관리자 페이지 › 메일 탭에서 이 사이트의 웹메일을 켠다(site.webmail).
// 켤 때 사이트 도메인의 DNS 에 넣을 레코드(MX·SPF·DMARC)를 안내하고, DNS 확인이 끝나야
// 메일 수발신을 허용한다. DNS 가 미완료면 웹메일 안에 "설정 문제로 수발신 제한" 상태를 표시한다.
// 3) 계정 : 사이트 관리자 계정(회원 role=admin + loginId)마다 "메일 사용" 을 켜면 같은 아이디·비밀번호로
// 메일 계정이 생긴다(웹메일 로그인 = 사이트 관리자 로그인, 계정을 공유). 끄면 메일함을 지운다.
//
// v1.3.1: 보내기는 메일 발송 키(Resend, 암호화 보관소 lib/vault.js 'resend')로 한다. 사이트 웹메일을 켜면 그 도메인을
// Resend 에 등록하고 Resend 가 요구하는 DNS 레코드(DKIM·send 하위 도메인 MX/SPF)를 안내 표에 함께 넣는다.
// Resend 가 도메인을 확인(verified)하면 웹메일에서 "아이디@사이트도메인" 으로 보낼 수 있다.
// 받기(SMTP 수신·보관)는 OZ-Group/mail-gateway 가 맡을 부분이다. 1.3.0 개발 시점에 그 저장소를
// 읽을 권한이 없어, 여기서는 백엔드를 어댑터(backend)로 분리해 두고 기본값 "미연결(none)" 으로 동작한다.
// 미연결 상태에서는 계정·DNS·웹메일 화면은 모두 동작하지만 메일 수발신은 막히고 그 이유를 화면에 표시한다.
// mail-gateway 연동 시 backendFor() 에 구현을 추가하면 된다(createMailbox / deleteMailbox / list / read / send).
//
// 저장:
// - settings.mailSystem : { enabled, mxHost, spfInclude, dmarc }
// - site.webmail : { enabled, domain, enabledAt, dns: { checkedAt, ok, records[] } }
// - data/modules/<site>.json members[] : mail(true/false), mailAddress, mailEnabledAt
// - data/mail/<site>/<memberId>.json : 메일함(백엔드가 채움). 메일 사용을 끄면 이 파일을 지운다.
//
// v1.3.2: 메일 시스템 켜기를 "사이트(호스팅 도메인) 단위" 로 바꿨다.
// - 운영자가 관리자 콘솔 › 메일 시스템 탭에서 사이트마다 "메일 시스템 사용" 을 켜고 메일 도메인·메일 서버 주소(MX)·
// SPF include·DMARC 를 입력한다(site.webmail). 전역 settings.mailSystem 은 이 값들의 "기본값" 으로만 쓴다.
// - 입력이 끝나도 DNS 전파·보내기 인증이 확인되기 전에는 사용자(빌더 관리 탭, 사이트 관리자 페이지, 웹메일)에게
// "메일시스템 설정중" 으로 보이고, 확인되면 "사용 가능" 이 된다.
// - 사이트 관리자는 더 이상 메일 시스템을 켜고 끄지 않는다(계정별 메일 사용과 DNS 다시 확인만).
// ============================================================================
const fs = require('fs');
const path = require('path');
const dns = require('dns').promises;
const DATA_ROOT = process.env.DATA_ROOT || path.join(__dirname, '..', 'data');
const MAIL_DIR = path.join(DATA_ROOT, 'mail');
const httpError = (m, s = 400) => Object.assign(new Error(m), { status: s });
const DOMAIN_RE = /^(?=.{1,253}$)([a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z]{2,63}$/i;
/** SPF include 대상: DNS 이름이라 밑줄로 시작하는 라벨(_spf 등)도 허용 (v1.3.17 B11 — 안내 예시 _spf.example.com 이 거부되던 문제) */
const SPF_RE = /^(?=.{1,253}$)([a-z0-9_](?:[a-z0-9_-]{0,61}[a-z0-9])?\.)+[a-z]{2,63}$/i;
const LOCAL_RE = /^[a-z0-9](?:[a-z0-9._-]{0,62}[a-z0-9])?$/; // 메일 주소 @ 앞부분으로 쓸 수 있는 아이디
// ---------- 운영자 설정 ----------
/** 전역 기본값 (v1.3.2: 켜기/끄기는 사이트 단위라 enabled 는 쓰지 않는다). mxHost 가 없으면 사이트 설정에서 꼭 입력해야 한다 */
const DEFAULTS = { mxHost: '', spfInclude: '', dmarc: 'v=DMARC1; p=none' };
/** settings.mailSystem 을 기본값과 합친 값 (settings 모듈은 순환 참조를 피하려고 호출 시점에 읽는다) */
function config() { const s = require('./settings').load(); const c = { ...DEFAULTS, ...(s.mailSystem || {}) }; delete c.enabled; return c; } // enabled: 1.3.1 이전 전역 켜기 값(무시)
/** 관리자 입력 검증 → 저장할 값 */
function normalizeConfig(input, prev) {
const c = { ...DEFAULTS, ...(prev || {}) };
delete c.enabled; // 1.3.0~1.3.1 의 전역 켜기 값은 더 이상 쓰지 않음
if (input.mxHost !== undefined) { const h = String(input.mxHost || '').trim().toLowerCase().replace(/\.$/, ''); if (h && !DOMAIN_RE.test(h)) throw httpError('메일 서버 주소(MX 호스트)는 mail.example.com 같은 도메인 이름이어야 합니다'); c.mxHost = h; }
if (input.spfInclude !== undefined) { const v = String(input.spfInclude || '').trim().toLowerCase(); if (v && !SPF_RE.test(v)) throw httpError('SPF include 값은 도메인 이름이어야 합니다 (예: _spf.example.com)'); c.spfInclude = v; }
if (input.dmarc !== undefined) { const v = String(input.dmarc || '').trim(); if (v && !/^v=DMARC1;/i.test(v)) throw httpError('DMARC 값은 "v=DMARC1;" 로 시작해야 합니다'); c.dmarc = v || DEFAULTS.dmarc; }
return c;
}
/**
* 사이트에 적용되는 메일 설정: 사이트에 입력한 값 > 전역 기본값.
* @returns {{mxHost, spfInclude, dmarc}}
*/
function siteConfig(site) {
const c = config(); const w = (site && site.webmail) || {};
// v1.6.38 (요구 2): 사이트 값도 전역 기본값도 없으면 이 사이트에 고른(또는 메일 도메인이 같은) 메일 서버의 호스트를 MX 로 쓴다 — Arimail 호스트가 SMTP 수신도 맡기 때문
let mx = w.mxHost || c.mxHost; if (!mx) { try { const sv = require('./mailservers').pick(site); if (sv && sv.host) mx = sv.host; } catch (_) {} }
return { mxHost: mx, spfInclude: w.spfInclude != null && w.spfInclude !== '' ? w.spfInclude : c.spfInclude, dmarc: w.dmarc || c.dmarc || DEFAULTS.dmarc };
}
// ---------- 백엔드 ----------
/**
* 메일 백엔드 상태와 동작.
* - 보내기(canSend): 메일 발송 키(Resend)가 있으면 가능. 실제로 보내려면 사이트 도메인이 Resend 에서 확인돼야 한다.
* - 받기(canReceive): mail-gateway 연동 후 가능. 접속 정보(보관소 'mail-gateway')가 등록돼 있어도 연동 코드가 아직 없다.
*/
/**
* v1.5.7 (요구 5): 받는 메일 백엔드는 연동 모듈(lib/connectors/arimail.js)이 맡는다.
* - 설정 파일 mail.provider = arimail 이고 주소·도메인·관리자 계정이 있으면 "연결됨". 사이트의 메일 도메인이 Arimail 의 도메인과
* 같을 때만 그 사이트의 받는 메일·웹메일·메일 계정이 Arimail 로 이어진다(Arimail 한 설치 = 한 도메인).
* - 계정: 메일 사용을 켤 때 Arimail 계정(같은 로그인 ID)을 만들고, 끌 때 지운다. 비밀번호는 켤 때 사용자가 정한다.
* - 웹메일: Arimail 의 웹메일(주소 mail.url)을 쓴다. 이 서비스의 /webmail/<사이트>/ 는 그곳으로 보낸다.
* - 보내기는 그대로 Resend 키(관리자 콘솔 › 설정 › 메일 발송). Arimail 도 같은 Resend 로 보내므로 발신 인증(DKIM) 레코드는 공통이다.
* Arimail 연결 여부는 health 로 60초마다 확인한다(매 요청마다 네트워크를 타지 않게).
* @param {object} [site] 사이트 — 도메인이 맞는지 보려면 필요. 없으면 "서비스 전체" 기준 상태
*/
/**
* v1.6.38 (요구 2): Arimail 이 여러 대(다른 서버) 등록될 수 있다 — 등록부(lib/mailservers.js)에서 이 사이트에 맞는 서버를 고르고,
* 그 서버에 묶인 연동 모듈(arimail.forServer)을 돌려준다. health 는 서버마다 60초 캐시.
* @param {object} [site] 사이트 — 사이트가 고른 서버(site.webmail.mailServer) 또는 메일 도메인이 같은 서버. 없으면 기본 서버
* @returns {{module, server, configured, ok, pending, version, settings, detail}}
*/
const healthCache = new Map(); // 서버 id → { at, ok, detail, version, pending }
function arimailState(site) {
let m = null; try { m = require('./connectors').mail(); } catch (_) {}
if (!m || m.id !== 'arimail') return { module: null, server: null, configured: false, ok: false, detail: '메일 서버 연동 모듈이 꺼져 있습니다 (설정 파일 mail.provider 또는 관리자 콘솔 › 메일 시스템 › 메일 서버 연동에서 서버 등록)' };
let server = null; try { server = require('./mailservers').pick(site); } catch (_) {}
if (!server) return { module: m, server: null, configured: false, ok: false, settings: m.settings(), detail: '등록된 메일 서버(Arimail)가 없습니다 — 관리자 콘솔 › 메일 시스템 › 메일 서버 연동에서 서버(다른 서버에 설치된 Arimail 도 가능)를 등록하세요' };
const bound = m.forServer(server); const s = bound.settings(); const configured = bound.configured();
const who = `${s.nickname || s.host}${s.ip ? ` ${s.ip}` : ''}`;
if (!configured) return { module: bound, server, configured: false, ok: false, settings: s, detail: s.url ? `메일 서버 "${who}" 의 도메인 또는 관리자 비밀번호가 없습니다 (메일 서버 연동에서 수정)` : `메일 서버 "${who}" 의 주소가 없습니다` };
let h = healthCache.get(server.id) || { at: 0, ok: false, detail: '' };
if (Date.now() - h.at > 60000) { h = { at: Date.now(), ok: h.ok, detail: h.detail, version: h.version, pending: true }; healthCache.set(server.id, h); bound.health().then((r) => { healthCache.set(server.id, { at: Date.now(), ok: !!r.ok, detail: r.detail || '', version: r.version || null }); }).catch((e) => { healthCache.set(server.id, { at: Date.now(), ok: false, detail: e.message }); }); }
return { module: bound, server, configured: true, ok: h.ok, pending: !!h.pending, version: h.version || null, settings: s, detail: h.ok ? `Arimail ${h.version || ''} 연결됨 (${who} · ${s.host}, 도메인 ${s.domain})` : (h.detail ? `Arimail(${who}) 응답 없음 — ${h.detail}` : `Arimail(${who}) 상태 확인 중`) };
}
// 기동 직후·설정 변경 직후 첫 화면이 "상태 확인 중" 으로 보이지 않게 미리 한 번 확인해 둔다 (실패해도 무시)
setTimeout(() => { try { arimailState(); } catch (_) {} }, 1500).unref();
function backendFor(site) {
let sendKey = false; try { sendKey = !!require('./mailer').resolveKey().key; } catch (_) {}
const am = arimailState(site); const siteDomain = site && site.webmail && site.webmail.domain ? String(site.webmail.domain).toLowerCase() : null;
const domainMatch = !!(am.configured && siteDomain && am.settings.domain === siteDomain);
const receive = am.configured && am.ok && (site ? domainMatch : true);
let reason;
if (!am.module) reason = '받는 메일: 메일 서버(Arimail) 연동이 꺼져 있어 받을 수 없습니다 (운영자: 초기 설정 페이지 › 메일 연동).';
else if (!am.configured) reason = `받는 메일: ${am.detail} — 운영자가 메일 서버를 등록하면 받을 수 있습니다.`;
else if (!am.ok) reason = `받는 메일: ${am.detail}`;
else if (site && !domainMatch) reason = `받는 메일: 메일 서버 "${am.settings.nickname || am.settings.host}"(${am.settings.host}${am.settings.ip ? ' / ' + am.settings.ip : ''})는 ${am.settings.domain} 도메인만 다루는데 이 사이트의 메일 도메인은 ${siteDomain || '(없음)'} 입니다. ${siteDomain || '그'} 도메인을 다루는 Arimail 을 메일 서버 연동에 등록하고 이 사이트의 메일 설정에서 그 서버를 고르세요.`;
else reason = '';
const mod = am.module;
return {
id: receive ? 'arimail' : (sendKey ? 'resend' : 'none'), canSend: sendKey, canReceive: receive, connected: !!(am.configured && am.ok), gatewayRegistered: am.configured,
arimail: am.configured ? { id: am.settings.id, nickname: am.settings.nickname, ip: am.settings.ip, url: am.settings.url, host: am.settings.host, domain: am.settings.domain, ok: am.ok, version: am.version, detail: am.detail, webmail: mod.webmailUrl(), admin: mod.adminUrl(), client: mod.clientSettings(), domainMatch } : null,
reason: sendKey ? reason : ('메일 발송 키가 없어 보낼 수 없습니다. ' + reason.replace('받는 메일: ', '받는 메일: ')).trim() + (sendKey ? '' : ' 운영자에게 문의하세요.'),
/** 계정 메일 사용 켜기 → Arimail 계정 생성 (비밀번호 필요). Arimail 이 아니면 로컬 메일함만 */
async createMailbox({ login, name, password }) {
if (!receive) return { ok: true, note: sendKey ? '보내기는 도메인 확인 후 바로 가능합니다. 받기는 메일 서버(Arimail) 연결 뒤 메일함이 연결됩니다.' : '메일 서버 연결 뒤 메일함이 연결됩니다.' };
if (!password) throw httpError('메일 비밀번호를 입력하세요. 메일 서버(Arimail)에 이 아이디의 계정이 만들어지며 웹메일·IMAP·POP·SMTP 에 그 비밀번호를 씁니다', 400);
// v1.6.31 (요구 9): 같은 로그인 이름이 다른 사이트에 등록돼 있으면 거절. 이 사이트의 기존 계정(등록부)이면 그 id 로 비밀번호 재설정만
const other = ownerOfLogin(login); if (other && !(other.siteId === arguments[0].siteId && other.memberId === arguments[0].memberId)) throw httpError(`아이디 "${login}" 는 다른 사이트의 메일 계정으로 등록되어 있습니다. 다른 아이디를 쓰세요`, 409);
const mine = accountOf(arguments[0].siteId, arguments[0].memberId);
const r = await mod.createUser({ login, name, password, role: 'user', reuseId: mine && mine.providerId });
setAccount(arguments[0].siteId, arguments[0].memberId, { login, address: r.mailbox, providerId: r.id, provider: 'arimail', state: 'active', lastError: null });
const c = mod.clientSettings();
return { ok: true, address: r.mailbox, existing: r.existing, note: `${r.existing ? '메일 서버에 이미 있던 계정의 비밀번호를 새로 정했습니다' : '메일 서버에 계정을 만들었습니다'}: ${r.mailbox}. 웹메일 ${mod.webmailUrl()} · 메일 프로그램: 받는 서버 ${c.imap.host} (IMAP 993 / POP3 995), 보내는 서버 ${c.smtp.host} (465/587), 사용자 이름 ${login}` };
},
/** 계정 메일 사용 끄기 → Arimail 계정 삭제 (메일은 Arimail 서버 저장소에 남는다 — 관리자 페이지에서 정리) */
/**
* v1.6.31 (요구 9): 삭제는 등록부에 적힌 "이 사이트·회원의 공급자 계정 id" 로만 한다. 등록부가 없거나(1.5.x 이전 계정) 로그인 이름이 다르면 지우지 않고 needs-admin 으로 남긴다.
* 공급자 삭제가 실패하면 로컬 기록을 지우지 않고 deprovision_pending 으로 남겨 운영 복구 화면에서 다시 시도한다(서버에 계정이 남는데 웹메이커에서 먼저 사라지는 일 방지).
*/
async deleteMailbox({ siteId, memberId, login }) {
if (!receive || !login) return { ok: true };
const acc = accountOf(siteId, memberId);
if (!acc || !acc.providerId) { setAccount(siteId, memberId, { login, state: 'needs-admin', lastError: '등록부에 공급자 계정 id 가 없어(이전 버전에서 만든 계정) 자동 삭제하지 않았습니다 — 관리자가 메일 서버에서 소유를 확인한 뒤 처리' }); return { ok: false, needsAdmin: true, warning: '이 메일 계정은 어느 사이트 것인지 자동으로 확인할 수 없어 메일 서버에서 지우지 않았습니다. 관리자에게 확인을 요청했습니다' }; }
try { const pu = await mod.getUserById(acc.providerId); if (pu && String(pu.login).toLowerCase() !== String(acc.login || login).toLowerCase()) { setAccount(siteId, memberId, { state: 'needs-admin', lastError: `공급자 계정(${acc.providerId})의 로그인 이름이 등록부와 다릅니다 (${pu.login})` }); return { ok: false, needsAdmin: true, warning: '메일 서버 계정 정보가 기록과 달라 지우지 않았습니다. 관리자 확인이 필요합니다' }; } if (pu) await mod.deleteUserById(acc.providerId); }
catch (e) { setAccount(siteId, memberId, { state: 'deprovision_pending', lastError: e.message }); return { ok: false, pending: true, warning: `메일 서버에서 계정을 지우지 못했습니다(${e.message}). 기록을 남겨 두었고 관리자 콘솔 › 운영 복구에서 다시 시도할 수 있습니다` }; }
removeAccount(siteId, memberId); return { ok: true };
},
async send({ from, to, subject, text }) { if (!sendKey) throw httpError('메일 발송 키가 없어 보낼 수 없습니다', 503); return require('./mailer').send({ from, to, subject, text }); },
};
}
/** Resend API 호출 (mailer 의 키·주소 사용) */
const resendApi = (p, method, body) => require('./mailer').resend(p, method, body);
/**
* 사이트 도메인을 Resend 에 등록(이미 있으면 재사용)하고 상태·레코드를 돌려준다.
* Resend 레코드 이름은 도메인 기준 상대 이름(예: send, resend._domainkey)이다.
* @returns {{id, status, records, checkedAt, error?}}
*/
async function ensureResendDomain(domain, prev) {
try {
let id = prev && prev.id;
if (!id) { const list = await resendApi('/domains'); const hit = (list.data || []).find((x) => String(x.name).toLowerCase() === domain); id = hit ? hit.id : (await resendApi('/domains', 'POST', { name: domain })).id; }
if (prev && prev.id) { try { await resendApi(`/domains/${id}/verify`, 'POST', {}); } catch (_) {} } // 다시 확인 요청 (Resend 가 비동기로 DNS 조회)
const d = await resendApi(`/domains/${id}`);
return { id, status: d.status || 'unknown', records: (d.records || []).map((r) => ({ record: r.record, name: r.name, type: r.type, value: r.value, priority: r.priority, status: r.status })), checkedAt: new Date().toISOString() };
} catch (e) { return { ...(prev || {}), error: e.message, checkedAt: new Date().toISOString() }; }
}
// ---------- DNS 안내·확인 ----------
/**
* 도메인에 넣어야 할 DNS 레코드 목록.
* - MX : 받는 메일을 메일 서버로 보낸다 (필수)
* - SPF : 이 메일 서버가 이 도메인 이름으로 보내도 된다고 밝힌다 (필수, 없으면 상대 서버가 스팸 처리)
* - DMARC : SPF·DKIM 실패 메일 처리 정책 (권장)
* DKIM 은 메일 게이트웨이가 도메인별 키를 만들어야 하므로 게이트웨이 연결 후 추가된다.
*/
function dnsRecords(domain, resendInfo, cfg) {
const c = cfg || config();
if (!c.mxHost) throw httpError('메일 서버 주소(MX 호스트)가 설정되지 않았습니다. 관리자 콘솔 › 메일 시스템에서 사이트 설정 또는 기본값에 입력하세요', 409);
const spf = `v=spf1 mx${c.spfInclude ? ' include:' + c.spfInclude : ''} ~all`;
return [
{ key: 'mx', type: 'MX', name: domain, host: '@', value: c.mxHost, priority: 10, required: true, why: '받는 메일을 이 서비스의 메일 서버로 보냅니다' },
{ key: 'spf', type: 'TXT', name: domain, host: '@', value: spf, required: true, why: '이 메일 서버가 이 도메인 이름으로 메일을 보낼 수 있다고 알립니다(없으면 보낸 메일이 스팸 처리됩니다). 이미 SPF(v=spf1…) 레코드가 있으면 새로 만들지 말고 mx 와 include 를 기존 값에 추가하세요' },
{ key: 'dmarc', type: 'TXT', name: `_dmarc.${domain}`, host: '_dmarc', value: c.dmarc, required: false, why: '인증에 실패한 위조 메일의 처리 정책입니다(권장)' },
// v1.3.1: 보내기용 Resend 레코드 (DKIM 서명, send 하위 도메인의 반송 처리 MX·SPF). 루트 도메인의 MX·SPF 와 겹치지 않는다
...((resendInfo && resendInfo.records) || []).map((r, i) => ({ key: `resend-${i}`, type: r.type, name: r.name === '@' || !r.name ? domain : `${r.name}.${domain}`, host: r.name || '@', value: r.value, priority: r.priority || null, required: true, purpose: 'send', why: `보내는 메일 인증(${r.record || r.type}) — Resend 가 확인합니다`, providerStatus: r.status || null })),
];
}
/** DNS 조회 (5초 제한). 실패하면 빈 목록 */
async function q(fn, name) { try { return await Promise.race([fn(name), new Promise((_, rej) => setTimeout(() => rej(new Error('timeout')), 5000))]); } catch (_) { return []; } }
/**
* 도메인 DNS 를 실제로 조회해 레코드별 확인 결과를 만든다.
* @returns {{checkedAt, ok, records: Array<{...record, ok, found}>}} ok = 필수 레코드(MX·SPF) 모두 확인됨
*/
async function checkDns(domain, resendInfo, cfg) {
const c = cfg || config(); const want = dnsRecords(domain, resendInfo, c);
const mx = await q(dns.resolveMx, domain); const txt = (await q(dns.resolveTxt, domain)).map((r) => r.join('')); const dm = (await q(dns.resolveTxt, `_dmarc.${domain}`)).map((r) => r.join(''));
const records = want.map((r) => {
if (r.purpose === 'send') return { ...r, found: r.providerStatus || '확인 전', ok: r.providerStatus === 'verified' };
if (r.key === 'mx') { const hosts = mx.map((m) => m.exchange.toLowerCase().replace(/\.$/, '')); return { ...r, found: hosts.join(', ') || '(없음)', ok: hosts.includes(c.mxHost) }; }
if (r.key === 'spf') { const spf = txt.find((t) => /^v=spf1\b/i.test(t)) || ''; const ok = !!spf && (/\smx(\s|$)/i.test(spf) || spf.includes(`a:${c.mxHost}`) || (!!c.spfInclude && spf.includes(`include:${c.spfInclude}`))); return { ...r, found: spf || '(없음)', ok }; }
const v = dm.find((t) => /^v=DMARC1/i.test(t)) || ''; return { ...r, found: v || '(없음)', ok: !!v };
});
// receiveOk: 받기용(MX·SPF) 확인, sendOk: Resend 도메인 확인(verified), ok: 1.3.0 호환(받기용 기준)
const receiveOk = records.filter((r) => r.required && r.purpose !== 'send').every((r) => r.ok);
const sendOk = !!(resendInfo && resendInfo.status === 'verified');
return { checkedAt: new Date().toISOString(), ok: receiveOk, receiveOk, sendOk, records };
}
// ---------- 사이트 웹메일 상태 ----------
/** 사이트에 연결된 도메인 중 메일 도메인으로 쓸 수 있는 것 (www. 는 떼고 중복 제거) */
function domainChoices(site) { return [...new Set((site.domains || []).map((d) => String(d).toLowerCase().replace(/^www\./, '')).filter((d) => DOMAIN_RE.test(d)))]; }
/**
* 사이트 메일 시스템의 현재 상태와 사용자에게 보여 줄 문구 (v1.3.2).
* level : off(이 사이트는 사용 안 함) | setting-up(메일시스템 설정중) | send-only(보내기만 가능) | ready(주고받기 가능)
* label : 사용자 화면에 짧게 보일 상태 이름, message: 자세한 설명
* canMail: 웹메일에서 메일을 쓸 수 있는지(보내기 가능 이상)
*/
function status(site) {
const w = site.webmail || {}; const be = backendFor(site); const dnsInfo = w.dns || null;
const sendReady = !!(be.canSend && dnsInfo && dnsInfo.sendOk); const receiveReady = !!(be.canReceive && dnsInfo && dnsInfo.receiveOk);
// v1.5.7: mailServer — 연결된 메일 서버(Arimail) 정보(웹메일 주소·메일 프로그램 설정값). 화면은 backend === 'arimail' 이면 웹메일 버튼을 그 주소로 연다
const base = { systemEnabled: !!w.enabled, enabled: !!w.enabled, domain: w.domain || null, dns: dnsInfo, settings: w.enabled ? siteConfig(site) : null, configuredAt: w.configuredAt || null, resend: w.resend ? { status: w.resend.status, error: w.resend.error || null } : null, backend: be.id, backendConnected: be.connected, canSend: sendReady, canReceive: receiveReady, domains: domainChoices(site), mailServer: be.arimail ? { provider: 'arimail', id: be.arimail.id, nickname: be.arimail.nickname, ip: be.arimail.ip, host: be.arimail.host, domain: be.arimail.domain, ok: be.arimail.ok, webmail: be.arimail.webmail, client: be.arimail.client, domainMatch: be.arimail.domainMatch } : null };
if (!w.enabled) return { ...base, level: 'off', label: '사용 안 함', canMail: false, message: '이 사이트는 메일 시스템을 사용하지 않습니다. 사용하려면 운영자에게 요청하세요.' };
if (sendReady && receiveReady) return { ...base, level: 'ready', label: '사용 가능', canMail: true, message: '메일을 주고받을 수 있습니다.' };
if (sendReady) return { ...base, level: 'send-only', label: '사용 가능 (보내기)', canMail: true, message: `보내기는 가능합니다. ${dnsInfo.receiveOk ? be.reason : `받는 메일: ${w.domain} 의 MX·SPF 가 아직 확인되지 않아 설정 문제로 수신이 제한되고 있습니다.`}` };
const pending = [];
if (!dnsInfo || !dnsInfo.receiveOk) pending.push('MX·SPF');
if (be.canSend && !(dnsInfo && dnsInfo.sendOk)) pending.push(w.resend && w.resend.error ? `보내기 도메인 등록 실패(${w.resend.error})` : '보내기 인증(DKIM 등)');
const detail = pending.length ? `${w.domain} 의 DNS 설정(${pending.join(', ')})이 아직 전파·확인되지 않아 메일 수발신이 제한되고 있습니다. DNS 를 등록한 뒤 "DNS 다시 확인" 을 누르거나 자동 확인(30분 간격)을 기다리세요.` : be.reason;
return { ...base, level: 'setting-up', label: '메일시스템 설정중', canMail: false, pending, message: `메일시스템 설정중 — ${detail}` };
}
/**
* 운영자가 사이트의 메일 시스템을 켜거나 설정을 바꾼다 (v1.3.2).
* @param {object} site
* @param {{domain, mxHost?, spfInclude?, dmarc?}} input 메일 도메인과 이 사이트 전용 메일 서버 설정(비우면 기본값)
* @param {string} by 설정한 관리자 아이디
* 메일 도메인은 사이트에 연결된 도메인이 기본이지만, 운영자는 다른 도메인(예: 회사 대표 도메인)을 직접 입력할 수 있다.
*/
async function enableSite(site, input = {}, by = '') {
const d = String(input.domain || domainChoices(site)[0] || '').trim().toLowerCase().replace(/^www\./, '').replace(/\.$/, '');
if (!d) throw httpError('메일 도메인을 입력하세요. 사이트에 연결된 도메인이 없으면 "example.com" 처럼 직접 입력합니다. 메일 주소는 "아이디@메일도메인" 이 됩니다', 400);
if (!DOMAIN_RE.test(d)) throw httpError('메일 도메인 형식이 올바르지 않습니다 (예: example.com)');
const own = normalizeConfig({ mxHost: input.mxHost || '', spfInclude: input.spfInclude || '', dmarc: input.dmarc || '' }, {});
const prev = site.webmail || {};
site.webmail = { ...prev, enabled: true, domain: d, mxHost: own.mxHost, spfInclude: own.spfInclude, dmarc: input.dmarc ? own.dmarc : '', configuredAt: new Date().toISOString(), configuredBy: by, enabledAt: prev.enabledAt || new Date().toISOString() };
// v1.6.38 (요구 2): 이 사이트가 쓸 메일 서버(등록부 id). 빈 값이면 자동 — 메일 도메인이 같은 서버, 없으면 기본 서버
if (input.mailServer !== undefined) { const sid = String(input.mailServer || '').trim(); if (sid && !require('./mailservers').get(sid)) { site.webmail = prev; throw httpError('고른 메일 서버가 등록부에 없습니다 — 메일 시스템 탭 › 메일 서버 연동에서 먼저 등록하세요'); } site.webmail.mailServer = sid; }
const cfg = siteConfig(site); if (!cfg.mxHost) { site.webmail.enabled = prev.enabled || false; throw httpError('메일 서버 주소(MX 호스트)를 입력하세요. 비워 두면 기본값을 쓰는데 기본값도 비어 있어 DNS 안내를 만들 수 없습니다'); }
if (prev.domain && prev.domain !== d) { site.webmail.dns = null; }
if (backendFor(site).canSend && (!site.webmail.resend || site.webmail.resendDomain !== d)) { site.webmail.resend = await ensureResendDomain(d, null); site.webmail.resendDomain = d; }
site.webmail.dns = await checkDns(d, site.webmail.resend, cfg);
return site.webmail;
}
/** DNS 다시 확인: Resend 에 재확인 요청 → 우리 레코드 조회 (자동 재확인도 이 함수를 쓴다) */
async function recheck(site) {
const w = site.webmail; if (!w || !w.enabled || !w.domain) throw httpError('이 사이트는 메일 시스템이 켜져 있지 않습니다', 409);
if (backendFor(site).canSend) { w.resend = await ensureResendDomain(w.domain, w.resendDomain === w.domain ? w.resend : null); w.resendDomain = w.domain; }
w.dns = await checkDns(w.domain, w.resend, siteConfig(site)); return w;
}
/**
* 웹메일에서 보내기: 발송 후 보낸 편지함에 사본을 남긴다.
* @param {object} member 사이트 관리자 회원 (mailAddress, name)
*/
async function sendAs(site, member, { to, subject, text }) {
const st = status(site); if (!st.canSend) throw httpError(st.message, 409);
const rcpt = String(to || '').split(/[,;\s]+/).filter(Boolean); if (!rcpt.length || rcpt.some((x) => !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(x))) throw httpError('받는 사람 메일 주소를 확인하세요');
const r = await backendFor(site).send({ from: { address: member.mailAddress, name: member.name || '' }, to: rcpt, subject: String(subject || '(제목 없음)').slice(0, 300), text: String(text || '').slice(0, 100000) });
const f = boxFile(site.id, member.id); const box = loadBox(site.id, member.id);
box.messages.push({ id: (r && r.id) || Date.now().toString(36), folder: 'sent', from: member.mailAddress, to: rcpt.join(', '), subject: String(subject || '(제목 없음)').slice(0, 300), text: String(text || '').slice(0, 100000), at: new Date().toISOString(), read: true });
fs.mkdirSync(path.dirname(f), { recursive: true }); fs.writeFileSync(f, JSON.stringify(box));
return { ok: true, id: r && r.id };
}
/** 사이트 메일 시스템 끄기 — 운영자 전용 (계정의 메일 설정·메일함은 그대로 두며, 다시 켜면 이어서 쓴다) */
function disableSite(site) { site.webmail = { ...(site.webmail || {}), enabled: false, disabledAt: new Date().toISOString() }; return site.webmail; }
// ---------- v1.6.31 (요구 9) 메일 계정 등록부 — 어느 사이트·회원의 계정인지, 공급자 계정 id, 상태 ----------
// data/mail-accounts.json: { "<siteId>:<memberId>": { siteId, memberId, login, address, providerId, provider, createdAt, state, lastError } }
// state: active | deprovision_pending(공급자 삭제 실패 — 다시 시도) | needs-admin(소유가 불명확 — 관리자 확인)
const jsx = require('./jsonstore'); const ACCOUNTS = path.join(DATA_ROOT, 'mail-accounts.json');
function accounts() { const d = jsx.readJson(ACCOUNTS, null, { strict: true }); return d && typeof d === 'object' ? d : {}; }
function saveAccounts(d) { jsx.writeJsonAtomic(ACCOUNTS, d, { mode: 0o600, keepBackup: true }); }
function accountKey(siteId, memberId) { return `${siteId}:${memberId}`; }
function accountOf(siteId, memberId) { return accounts()[accountKey(siteId, memberId)] || null; }
function setAccount(siteId, memberId, patch) { const d = accounts(); const k = accountKey(siteId, memberId); d[k] = { ...(d[k] || { siteId, memberId, createdAt: new Date().toISOString() }), ...patch, updatedAt: new Date().toISOString() }; saveAccounts(d); return d[k]; }
function removeAccount(siteId, memberId) { const d = accounts(); delete d[accountKey(siteId, memberId)]; saveAccounts(d); }
/** 관리자 화면용: 모든 계정 (사이트·회원·상태) */
function listAccounts(filter = {}) { return Object.values(accounts()).filter((a) => (!filter.siteId || a.siteId === filter.siteId) && (!filter.state || a.state === filter.state)); }
/** 공급자 계정 하나가 어느 사이트 것인지 (login 또는 providerId) */
function ownerOfLogin(login) { const l = String(login || '').toLowerCase(); return Object.values(accounts()).find((a) => a.login === l) || null; }
// ---------- 계정 메일함 ----------
function boxFile(siteId, memberId) { return path.join(MAIL_DIR, siteId, `${memberId}.json`); }
/** 메일함 읽기: { messages: [{id, folder, from, to, subject, text, at, read}] } */
function loadBox(siteId, memberId) { try { return JSON.parse(fs.readFileSync(boxFile(siteId, memberId), 'utf8')); } catch (_) { return { messages: [] }; } }
/** 사이트 관리자 계정의 메일 주소 (아이디@메일도메인). 아이디가 메일 주소로 쓸 수 없는 문자면 오류 */
function addressFor(site, member) {
const w = site.webmail || {}; if (!w.enabled || !w.domain) throw httpError('이 사이트는 메일 시스템이 아직 켜지지 않았습니다. 운영자에게 요청하세요', 409);
const local = String(member.loginId || '').toLowerCase(); if (!LOCAL_RE.test(local)) throw httpError(`아이디 "${member.loginId}" 는 메일 주소로 쓸 수 없습니다 (영문 소문자·숫자·. _ - 만)`);
return `${local}@${w.domain}`;
}
/**
* 계정 메일 사용 켜기 → 메일함 생성.
* v1.5.7: Arimail 이 연결된 사이트는 메일 비밀번호(opts.password)로 메일 서버 계정을 만든다 (createMailbox 가 없으면 400 으로 알린다)
* v1.6.39 (요구 3): opts.synced 가 참이면 그 비밀번호가 사이트 관리자 비밀번호와 같다는 뜻 — 등록부에 동기화 기준(관리자 비밀번호 해시)을 적어 둔다.
* 거짓이면 "임시 메일 비밀번호" 로 보고, 관리자가 다음에 사이트 관리자 페이지에 로그인할 때 syncPassword 가 관리자 비밀번호로 맞춘다.
*/
async function enableMember(site, member, opts = {}) {
const addr = addressFor(site, member); const r = await backendFor(site).createMailbox({ siteId: site.id, memberId: member.id, address: addr, login: String(member.loginId || '').toLowerCase(), name: member.name || member.loginId, password: opts.password || '' });
fs.mkdirSync(path.dirname(boxFile(site.id, member.id)), { recursive: true }); if (!fs.existsSync(boxFile(site.id, member.id))) fs.writeFileSync(boxFile(site.id, member.id), JSON.stringify({ address: addr, createdAt: new Date().toISOString(), messages: [] }));
let note = r.note || '';
if (r.address && accountOf(site.id, member.id)) {
const now = new Date().toISOString();
setAccount(site.id, member.id, opts.synced ? { pwSyncHash: member.hash || null, pwSyncAt: now, pwSyncError: null } : { pwSyncHash: null, pwSyncAt: null, pwSyncError: null });
note += opts.synced ? ' 메일 비밀번호는 이 계정의 사이트 관리자 비밀번호와 같습니다(같은 아이디·비밀번호로 웹메일 로그인).' : ' 지금 넣은 비밀번호는 임시입니다 — 이 관리자가 사이트 관리자 페이지에 로그인하면 메일 비밀번호가 그 계정의 비밀번호로 자동으로 맞춰집니다.';
}
return { address: addr, note: note.trim(), synced: !!opts.synced };
}
/**
* v1.6.39 (요구 3): 사이트 관리자 비밀번호 ↔ 메일(Arimail) 계정 비밀번호 동기화.
* 웹메이커는 관리자 비밀번호를 scrypt 해시로만 갖고 있어 Arimail 로 그대로 옮길 수 없다. 그래서 원문을 아는 순간 — 사이트 관리자 페이지·웹메일 로그인 성공 직후 —
* 이 함수를 불러 "마지막으로 Arimail 에 맞춘 해시(pwSyncHash)" 와 지금 해시가 다르면 Arimail 계정 비밀번호를 그 원문으로 바꾼다.
* 분리 설치(웹메이커와 Arimail 이 다른 서버)에서도 한 아이디·비밀번호로 웹메일·IMAP/POP/SMTP 를 쓰게 하는 장치.
* 호출자는 결과를 기다리지 않아도 된다(로그인 응답을 늦추지 않도록 catch 만). 비밀번호 원문은 기록에 남기지 않는다.
* @param {object} site 사이트
* @param {object} member 사이트 관리자 레코드(해시 포함 — modules.getSiteAdmin)
* @param {string} plainPw 지금 확인된 비밀번호 원문
* @param {string} [by] 작업 기록용 주체
* @returns {Promise<{ok?:boolean, skipped?:string, error?:string}>}
*/
async function syncPassword(site, member, plainPw, by = '') {
if (!site || !member || !plainPw || !member.mail) return { skipped: 'mail-off' };
const acc = accountOf(site.id, member.id);
if (!acc || acc.provider !== 'arimail' || !acc.providerId || acc.state !== 'active') return { skipped: 'no-arimail-account' };
if (acc.pwSyncHash && acc.pwSyncHash === member.hash) return { skipped: 'already-synced' };
const am = arimailState(site); if (!am.module || !am.configured) return { skipped: 'server-not-configured', detail: am.detail };
const audit = require('./audit');
try {
await am.module.updateUser(acc.providerId, { password: plainPw });
setAccount(site.id, member.id, { pwSyncHash: member.hash || null, pwSyncAt: new Date().toISOString(), pwSyncError: null });
audit.log({ by: by || member.loginId, action: 'mail.account.sync', targetType: 'site', target: site.id, siteId: site.id, userId: site.ownerId, detail: { member: member.loginId, server: am.settings.nickname || am.settings.host } });
return { ok: true };
} catch (e) {
// 자주 보는 원인: Arimail 비밀번호 정책(weak_password)이 웹메이커 규칙보다 엄격 — 관리자 비밀번호를 정책에 맞게 바꾸면 다음 로그인 때 맞춰진다
setAccount(site.id, member.id, { pwSyncError: e.message, pwSyncErrorAt: new Date().toISOString() });
audit.log({ by: by || member.loginId, action: 'mail.account.sync', targetType: 'site', target: site.id, siteId: site.id, userId: site.ownerId, ok: false, detail: { member: member.loginId, error: e.message } });
return { ok: false, error: e.message };
}
}
/**
* v1.7.3 추가(운영자 요청 2026-10-24): 웹메이커 **서비스 관리자**(role admin, 예: admin) 비밀번호 ↔ Arimail 의 같은 로그인 ID 계정 비밀번호 동기화.
* 배경: webmail.ibit.kr 의 admin 계정과 webmaker.ibit.kr 의 admin 계정이 같은 비밀번호로 열리길 바라는데, 웹메이커는 해시만 가지므로
* "원문을 아는 순간"(관리자 로그인, server.js /api/auth/login)에 등록된 모든 메일 서버에서 같은 ID 의 계정을 찾아 비밀번호를 맞춘다.
* - 같은 ID 의 Arimail 계정이 없는 서버는 건너뛴다(계정을 만들지는 않는다 — 메일함이 생기는 일은 운영자가 정한다).
* - 지문(opts.fingerprint = lib/auth.passwordFingerprint)이 마지막 동기화 때와 같으면 Arimail 을 부르지 않는다(로그인마다 PATCH 하지 않게).
* - 비밀번호 변경만 보내므로 Arimail 의 관리자 보호 규칙(역할·상태 내리기 금지)에 걸리지 않는다. 실패(정책 weak_password 등)는 기록에 남기고 다음 로그인 때 다시 시도한다.
* - 결과는 등록부(adminSync)와 작업 기록(mail.admin.sync)에 남긴다 — 비밀번호 원문·해시는 어디에도 쓰지 않는다. 예외를 던지지 않는다(로그인을 막지 않게).
* @param {{id:string, role:string}} user 로그인한 웹메이커 사용자
* @param {string} plainPw 지금 확인된 비밀번호 원문
* @param {{fingerprint?:string|null, by?:string}} [opts]
* @returns {Promise<Array<{server:string, login:string, ok?:boolean, skipped?:string, error?:string}>>}
*/
async function syncServiceAdmin(user, plainPw, opts = {}) {
const out = []; if (!user || user.role !== 'admin' || !plainPw) return out;
const login = String(user.id || '').toLowerCase(); const fp = opts.fingerprint || null; const audit = require('./audit');
let ms; try { ms = require('./mailservers'); } catch (_) { return out; }
let m = null; try { m = require('./connectors').mail(); } catch (_) {}
if (!m || m.id !== 'arimail') return out;
for (const server of ms.all()) {
const rec = { server: server.nickname || server.id, login };
try {
if (!(server.url && server.domain && server.adminPassword)) { out.push({ ...rec, skipped: 'server-not-configured' }); continue; }
const prev = ms.adminSyncOf(server.id)[login];
if (fp && prev && prev.fp === fp && !prev.error) { out.push({ ...rec, skipped: 'already-synced' }); continue; }
const bound = m.forServer(server);
const u = await bound.findUser(login);
if (!u) { ms.setAdminSync(server.id, login, { skipped: 'no-account', skippedAt: new Date().toISOString() }); out.push({ ...rec, skipped: 'no-account' }); continue; }
await bound.updateUser(u.id, { password: plainPw });
ms.setAdminSync(server.id, login, { fp, at: new Date().toISOString(), error: null, errorAt: null, skipped: null, providerId: u.id });
audit.log({ by: opts.by || login, action: 'mail.admin.sync', targetType: 'setting', target: server.id, detail: { login, server: server.nickname || server.host } });
out.push({ ...rec, ok: true });
} catch (e) {
try { ms.setAdminSync(server.id, login, { error: e.message, errorAt: new Date().toISOString() }); } catch (_) {}
audit.log({ by: opts.by || login, action: 'mail.admin.sync', targetType: 'setting', target: server.id, ok: false, detail: { login, error: e.message } });
out.push({ ...rec, ok: false, error: e.message });
}
}
return out;
}
/**
* v1.6.39 (요구 3): 사이트 관리자 페이지 "메일 계정" 표용 동기화 상태.
* @returns {{state:'synced'|'pending'|'error'|'n/a', at?:string, error?:string}}
*/
function syncState(site, member) {
const acc = accountOf(site.id, member.id);
if (!member.mail || !acc || acc.provider !== 'arimail') return { state: 'n/a' };
if (acc.pwSyncError) return { state: 'error', error: acc.pwSyncError, at: acc.pwSyncErrorAt || null };
if (acc.pwSyncHash && acc.pwSyncHash === member.hash) return { state: 'synced', at: acc.pwSyncAt || null };
return { state: 'pending' };
}
/** 계정 메일 사용 끄기 → 메일함과 보관된 메일을 모두 지운다 (호출 전에 확인·비밀번호 재입력을 받는다) */
async function disableMember(site, member) {
let r = { ok: true }; if (member.mailAddress) r = await backendFor(site).deleteMailbox({ siteId: site.id, memberId: member.id, address: member.mailAddress, login: String(member.loginId || '').toLowerCase() });
if (r && r.ok === false) throw Object.assign(httpError(r.warning || '메일 서버 계정을 지우지 못했습니다', 502), { pending: !!r.pending, needsAdmin: !!r.needsAdmin }); // v1.6.31: 서버 계정이 남아 있으면 로컬도 지우지 않는다
const n = loadBox(site.id, member.id).messages.length; try { fs.unlinkSync(boxFile(site.id, member.id)); } catch (_) {}
return { deletedMessages: n };
}
/**
* v1.6.31 (요구 9·20): 운영 복구 화면에서 관리자가 처리하는 메일 계정 정리.
* mode 'retry' — deprovision_pending: 공급자 삭제를 다시 시도(멱등: 이미 없으면 성공으로 본다) 뒤 등록부·메일함 정리
* mode 'confirmed' — needs-admin: 관리자가 메일 서버에서 소유를 확인했으므로 등록부의 providerId(없으면 로그인 이름)로 삭제
* mode 'forget' — 서버 계정은 그대로 두고 웹메이커 쪽 기록(등록부·메일함)만 정리
*/
async function adminResolveAccount(site, memberId, mode) {
const acc = accountOf(site.id, memberId); if (!acc) throw httpError('등록부에 그 계정이 없습니다', 404);
const am = arimailState(site); let detail = ''; // v1.6.38: 이 사이트가 쓰는 메일 서버
if (mode !== 'forget') {
if (!am.module || !am.configured) throw httpError('메일 서버(Arimail) 연동이 꺼져 있어 삭제할 수 없습니다', 503);
const mod = am.module; let removed = false;
if (acc.providerId) { const pu = await mod.getUserById(acc.providerId); if (pu) { await mod.deleteUserById(acc.providerId); removed = true; } else detail = '공급자에 이미 없음'; }
else if (mode === 'confirmed' && acc.login) { removed = await mod.deleteUserByLogin(acc.login); if (!removed) detail = '공급자에 이미 없음'; }
else throw httpError('등록부에 공급자 계정 id 가 없어 자동 삭제할 수 없습니다 — "소유 확인 후 삭제" 또는 "기록만 정리" 를 쓰세요', 409);
if (removed) detail = `메일 서버 계정 삭제됨(${acc.providerId || acc.login})`;
} else detail = '서버 계정은 유지, 웹메이커 기록만 정리';
const n = loadBox(site.id, memberId).messages.length; try { fs.unlinkSync(boxFile(site.id, memberId)); } catch (_) {}
removeAccount(site.id, memberId);
return { ok: true, detail: `${detail} · 보관 메일 ${n}건 정리` };
}
/** 사이트 폐기 시 메일 데이터 전체 삭제 */
function removeSite(siteId) { try { fs.rmSync(path.join(MAIL_DIR, siteId), { recursive: true, force: true }); } catch (_) {} }
// ---------- 사이트 폴더의 /webmail 진입 파일 ----------
/**
* "사용자사이트/webmail" 로 들어오면 빌더 서비스의 웹메일(<공개주소>/webmail/<사이트ID>/)로 보내는 안내 파일.
* 사이트 폴더에 넣어 두면 어느 배포 서버·도메인에 올라가도 웹서버 설정 변경 없이 동작한다.
* 웹메일이 꺼지면 지운다.
*/
const STUB_MARK = 'data-wm-webmail-stub';
function stubHtml(siteId, base) {
const url = `${base.replace(/\/+$/, '')}/webmail/${siteId}/`;
return `<!doctype html>
<!-- AI Webmaker 웹메일 진입 페이지 (자동 생성 — 수정하지 마세요). 웹메일을 끄면 자동으로 지워집니다.
이 사이트의 /webmail 주소로 들어온 방문자를 빌더 서비스의 웹메일 화면으로 보냅니다. -->
<html lang="ko" ${STUB_MARK}><head><meta charset="utf-8"><meta name="robots" content="noindex"><meta name="viewport" content="width=device-width,initial-scale=1">
<title>웹메일</title><meta http-equiv="refresh" content="0; url=${url}"></head>
<body style="font-family:sans-serif;padding:40px;text-align:center"><p>웹메일로 이동합니다… <a href="${url}">이동하지 않으면 여기를 누르세요</a></p></body></html>
`;
}
/** 사이트 폴더에 진입 파일을 두거나(웹메일 켜짐) 지운다(꺼짐). 사용자가 같은 이름으로 만든 파일은 건드리지 않는다 */
function syncStub(site, siteDir, base) {
// v1.4.0: 메일 접속 주소를 https://사이트도메인/mail 로도 — 같은 안내 파일을 mail/ 에도 둔다 (사용자가 만든 mail/index.html 은 건드리지 않음)
syncStubAt(site, siteDir, base, 'mail');
return syncStubAt(site, siteDir, base, 'webmail');
}
function syncStubAt(site, siteDir, base, folder) {
const dir = path.join(siteDir, folder); const f = path.join(dir, 'index.html');
let cur = null; try { cur = fs.readFileSync(f, 'utf8'); } catch (_) {}
const ours = cur === null || cur.includes(STUB_MARK);
const want = !!(site.webmail && site.webmail.enabled); // v1.3.2: 사이트 단위 켜기
if (want && ours) { fs.mkdirSync(dir, { recursive: true, mode: 0o755 }); fs.writeFileSync(f, stubHtml(site.id, base), { mode: 0o644 }); return 'written'; }
if (!want && cur !== null && cur.includes(STUB_MARK)) { try { fs.unlinkSync(f); fs.rmdirSync(dir); } catch (_) {} return 'removed'; }
return ours ? 'none' : 'user-file';
}
module.exports = { syncServiceAdmin, adminResolveAccount, listAccounts, accountOf, setAccount, removeAccount, ownerOfLogin, siteConfig, recheck, sendAs, ensureResendDomain, DEFAULTS, config, normalizeConfig, backendFor, dnsRecords, checkDns, domainChoices, status, enableSite, disableSite, addressFor, enableMember, disableMember, syncPassword, syncState, loadBox, removeSite, syncStub, stubHtml, LOCAL_RE };