메일 서비스(사이트 대표·독립)·메일함·사용량·보안·전환·보존과 AriMail 도입 수정(KST 월 경계, 발신 키 오류, 도메인별 보장량, 100% 제한, 발송 큐·속도, 운영 상태, SSO) (§15~§18·§0.4)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5.1
parent
a014e3cd05
commit
a1cde81332
@@ -17,7 +17,7 @@ echo "▶ 소스 갱신 ($SITE: $MAIL_HOST)"; cd "$REPO"
|
||||
# 번들의 main 이 서버가 아는 origin/main 과 이어지지 않아도(릴리스 역사를 다시 쓴 경우) 받도록 강제(+) 갱신합니다. 어차피 아래에서 origin/main 으로 reset 합니다.
|
||||
if [ -n "${SOURCE_BUNDLE:-}" ]; then git bundle verify "$SOURCE_BUNDLE" >/dev/null 2>&1; git fetch --quiet --force "$SOURCE_BUNDLE" +main:refs/remotes/origin/main; else git fetch --quiet origin; fi
|
||||
git reset --hard --quiet origin/main; echo " $(git log --oneline -1)"
|
||||
echo "▶ 테스트"; for t in gateway imap smtp config provision domains webmaker usage; do "$NODE_BIN" "test/$t.test.mjs"; done
|
||||
echo "▶ 테스트"; for t in gateway imap smtp config provision domains webmaker usage ariweb; do "$NODE_BIN" "test/$t.test.mjs"; done
|
||||
echo "▶ 릴리스 배치"; mkdir -p "$TARGET"
|
||||
for path in server web lang migrations package.json haraka; do cp -r "$REPO/$path" "$TARGET/$path"; done
|
||||
git rev-parse HEAD > "$TARGET/REVISION"
|
||||
|
||||
@@ -97,6 +97,9 @@ exports.deliver = function (next, connection) {
|
||||
.then(({ status, data }) => {
|
||||
if (status >= 200 && status < 300 && data && data.ok) {
|
||||
if (data.skipped === 'quota_exceeded' || data.skipped === 'storage_full') { connection.lognotice(this, `mailbox full: ${rcpt.join(',')}`); return next(DENYSOFT, 'Mailbox full, try again later'); }
|
||||
// [Ariweb Suite 변경] 월간 통수 100%·도메인 이전 중(동결)도 임시 거부(보낸 서버가 재시도). 기존 메일 읽기·삭제는 그대로입니다.
|
||||
if (data.skipped === 'monthly_limit') { connection.lognotice(this, `monthly limit: ${rcpt.join(',')}`); return next(DENYSOFT, 'Monthly message limit reached, try again later'); }
|
||||
if (data.skipped === 'domain_not_active') { connection.lognotice(this, `domain not active: ${rcpt.join(',')}`); return next(DENYSOFT, 'Mail domain temporarily unavailable (migration), try again later'); }
|
||||
connection.loginfo(this, `delivered to gateway${data.imported ? ' id=' + data.id : ' (' + (data.skipped || 'skipped') + ')'}`);
|
||||
return next(OK, 'Message accepted for delivery');
|
||||
}
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
-- [Ariweb Suite 변경] 1.0.11+ariweb: 발송 대기열과 악용 의심 표(Dev-Order §17.2).
|
||||
-- outbound_queue: 발송 모듈(Resend/Haraka/SMTP 중계)에 넘기기 직전부터 끝날 때까지의 기록. 프로세스가 중간에 죽으면 'interrupted' 로 남아 관리자가 다시 보낼 수 있습니다(메일을 잃지 않음).
|
||||
-- state: sending(발송 중) → sent(끝) | failed(발송 모듈 오류, 재시도 가능) | interrupted(재시작으로 끊김)
|
||||
-- abuse_flags: 제출 속도 초과(초당 10건 이상) 등 운영자가 확인할 신호. 메일 내용은 넣지 않습니다.
|
||||
CREATE TABLE outbound_queue (
|
||||
id TEXT PRIMARY KEY,
|
||||
domain TEXT NOT NULL,
|
||||
mailbox TEXT NOT NULL DEFAULT '',
|
||||
send_key TEXT,
|
||||
payload_json TEXT NOT NULL,
|
||||
state TEXT NOT NULL DEFAULT 'sending' CHECK(state IN ('sending','sent','failed','interrupted')),
|
||||
attempts INTEGER NOT NULL DEFAULT 0,
|
||||
provider_id TEXT,
|
||||
error TEXT,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL,
|
||||
sent_at TEXT
|
||||
);
|
||||
CREATE INDEX outbound_queue_state ON outbound_queue(state, created_at);
|
||||
CREATE TABLE abuse_flags (
|
||||
id TEXT PRIMARY KEY,
|
||||
kind TEXT NOT NULL,
|
||||
subject TEXT NOT NULL,
|
||||
detail TEXT,
|
||||
rate REAL,
|
||||
created_at TEXT NOT NULL,
|
||||
reviewed_at TEXT,
|
||||
reviewed_by TEXT
|
||||
);
|
||||
CREATE INDEX abuse_flags_open ON abuse_flags(reviewed_at, created_at);
|
||||
@@ -7,7 +7,7 @@
|
||||
"scripts": {
|
||||
"start": "node server/index.mjs",
|
||||
"migrate": "node server/db-migrate.mjs",
|
||||
"test": "node test/gateway.test.mjs && node test/imap.test.mjs && node test/smtp.test.mjs && node test/config.test.mjs && node test/provision.test.mjs && node test/domains.test.mjs && node test/webmaker.test.mjs && node test/usage.test.mjs"
|
||||
"test": "node test/gateway.test.mjs && node test/imap.test.mjs && node test/smtp.test.mjs && node test/config.test.mjs && node test/provision.test.mjs && node test/domains.test.mjs && node test/webmaker.test.mjs && node test/usage.test.mjs && node test/ariweb.test.mjs"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=22.13"
|
||||
|
||||
@@ -23,6 +23,9 @@ import { safeEqual as timingEqual, address as bareAddress, clean as cleanStr, ui
|
||||
import { knownMailboxes as mailboxList } from './auth.mjs';
|
||||
import { sanitizeHtml } from './sanitize.mjs';
|
||||
import { verifySvix } from './webhook.mjs';
|
||||
import { submit as submitRate, rateSnapshot, queueList, abuseList, reviewAbuse, recoverQueue } from './sendqueue.mjs'; // [Ariweb Suite 변경]
|
||||
import { mailboxUser } from './auth.mjs';
|
||||
import { createHmac, timingSafeEqual } from 'node:crypto';
|
||||
import { setFlag, mailboxOf, ingestReceived, startSync, isSyncing, saveDraft, sendDraft, addAttachment, removeAttachment, copyAttachments } from './store.mjs';
|
||||
import { autoActionsOf, autoReplyActive } from './automation.mjs';
|
||||
import { readLargeFileSettings, writeLargeFileSettings, systemLargeFiles, listLargeFiles, removeLargeFile, largeFileStats } from './files.mjs';
|
||||
@@ -82,6 +85,16 @@ export async function handle(env, request, params) {
|
||||
if (path[0] === 'webhooks' && path[1] === 'resend' && method === 'POST') return await webhook(env, request);
|
||||
// 내장 Haraka 수신 전달(1.0.0): 로컬 Haraka 플러그인이 토큰으로 인증해 부릅니다(수신 방식이 haraka 일 때만 열림).
|
||||
if (path[0] === 'inbound' && path[1] === 'haraka') return await harakaInbound(env, request, path, method);
|
||||
// [Ariweb Suite 변경] 포털 SSO(§16.3): GET /api/auth/sso?ticket=<payload>.<sig> — 포털이 ARIWEB_SSO_SECRET 으로 서명한 티켓(메일함·만료·nonce)을 검증해
|
||||
// 그 메일함 주인의 웹 메일 세션을 만들고 웹 메일 첫 화면으로 보냅니다. 비밀번호를 포털에 알려 주지 않아도 "현재 로그인 상태로 안전하게 연결" 됩니다.
|
||||
if (path[0] === 'auth' && path[1] === 'sso' && method === 'GET') {
|
||||
const v = verifySsoTicket(env, new URL(request.url).searchParams.get('ticket') || '');
|
||||
if (!v.ok) { await audit(env, null, 'sso_denied', null, { ip, reason: v.reason }); return bad(v.reason, 401); }
|
||||
const user = await mailboxUser(env, v.mailbox);
|
||||
if (!user || user.status === 'suspended') { await audit(env, null, 'sso_denied', v.mailbox, { ip, reason: 'no_mailbox' }); return bad('mailbox_not_found', 404); }
|
||||
const token = await createSession(env, user, request, { scope: 'mail' }); await audit(env, user.login, 'login', null, { ip, source: user.source, via: 'ariweb-sso' });
|
||||
return new Response(null, { status: 302, headers: { Location: (prefix || '') + '/', 'Set-Cookie': sessionCookie(token, { scope: 'mail', path: prefix }), 'Cache-Control': 'no-store' } });
|
||||
}
|
||||
if (path[0] === 'login' && method === 'POST') {
|
||||
const body = await request.json().catch(() => null); if (!body) return bad('bad_request');
|
||||
// 로그인 제한(IP 당·계정 당 15분 10회)은 실패만 셉니다. 예전에는 성공도 세어 공유 IP(사무실) 에서 10명이 로그인하면 나머지가 막혔습니다.
|
||||
@@ -124,6 +137,9 @@ export async function handle(env, request, params) {
|
||||
if (path[0] === 'admin') {
|
||||
if (sessionScope !== 'admin') return bad('admin_login_required', 403);
|
||||
if (path[1] === 'status' && method === 'GET') return await adminStatus(env);
|
||||
// [Ariweb Suite 변경] 발송 속도·제출 창·대기열·악용 의심 표(§17.2, 운영자 확인용). 메일 내용은 없습니다.
|
||||
if (path[1] === 'rates' && method === 'GET') return json({ ok: true, rates: rateSnapshot(), queue: await queueList(env), abuse: await abuseList(env) });
|
||||
if (path[1] === 'abuse' && path[2] && method === 'PATCH') { await reviewAbuse(env, path[2], user.login); await audit(env, user.login, 'abuse_reviewed', path[2]); return json({ ok: true }); }
|
||||
if (path[1] === 'sync' && method === 'POST') return json({ ok: true, ...startSync(env, 'manual') });
|
||||
// 비밀번호 정책(0.8.24~): GET 은 현재 정책, PATCH 는 { minLength?, mixedCase?, digit?, special? } 를 저장합니다. 기존 비밀번호에는 소급하지 않습니다.
|
||||
if (path[1] === 'policy' && method === 'GET') return json({ ok: true, policy: await readPasswordPolicy(env) });
|
||||
@@ -293,6 +309,8 @@ export async function handle(env, request, params) {
|
||||
if (method === 'POST' && action === 'send') {
|
||||
// body.largeFiles({ days, downloads }, 0.9.3): 초안에 대용량 파일이 있을 때의 발송 조건. 없으면 sendDraft 가 large_file_terms 로 거부합니다.
|
||||
const b = await request.json().catch(() => ({}));
|
||||
// [Ariweb Suite 변경] 웹 발송도 제출 속도 제한(계정·IP 초당 10건)을 받습니다.
|
||||
{ const th = await submitRate(env, ['account:' + mailbox, ...(ip ? ['ip:' + ip] : [])]); if (th.throttled) return bad('mail_submission_throttled', 429, { until: th.until, rate: th.rate }); }
|
||||
await sendDraft(env, id, { largeFiles: b && typeof b === 'object' ? b.largeFiles : null }); await audit(env, user.login, 'mail_send', id); if (mail.headers_json && JSON.parse(mail.headers_json)['In-Reply-To']) { const parent = await env.DB.prepare('SELECT id FROM messages WHERE message_id=? AND mailbox=?').bind(JSON.parse(mail.headers_json)['In-Reply-To'], mailbox).first(); if (parent) await setFlag(env, parent.id, '\\Answered', true); } return json({ ok: true }); }
|
||||
if (method === 'POST' && action === 'attachments') {
|
||||
// JSON { from, ids }: 다른 메일(전달하는 원본)의 첨부를 이 초안으로 복사합니다(0.8.25~, 웹 "전달" 이 첨부를 함께 보내기 위해). 같은 메일함의 메일만 원본이 될 수 있습니다.
|
||||
@@ -324,10 +342,10 @@ export async function handle(env, request, params) {
|
||||
}
|
||||
return bad('not_found', 404);
|
||||
} catch (e) {
|
||||
const status = e.message === 'not_found' ? 404 : e.message === 'bad_request' ? 400 : ['mail_move_invalid', 'mail_sending', 'mail_locked', 'mail_trash_first', 'login_taken', 'cannot_delete_self', 'cannot_modify_self', 'account_limit_reached'].includes(e.message) ? 409 : e.message === 'forbidden' ? 403 : ['signup_disabled', 'large_files_disabled'].includes(e.message) ? 404 : e.message === 'file_too_large' ? 413 : 422;
|
||||
const status = e.message === 'mail_domain_frozen' ? 423 : e.message === 'not_found' ? 404 : e.message === 'bad_request' ? 400 : ['mail_move_invalid', 'mail_sending', 'mail_locked', 'mail_trash_first', 'login_taken', 'cannot_delete_self', 'cannot_modify_self', 'account_limit_reached'].includes(e.message) ? 409 : e.message === 'forbidden' ? 403 : ['signup_disabled', 'large_files_disabled'].includes(e.message) ? 404 : e.message === 'file_too_large' ? 413 : 422;
|
||||
// weak_password 에는 어긋난 항목(problems)과 정책(policy)을 붙여 화면이 "6자 이상, 숫자 포함" 같은 구체적 안내를 만들 수 있게 합니다.
|
||||
// 제한 오류(too_many_recipients·attachment_too_large·quota_exceeded·storage_full·monthly_limit_exceeded·limit_exceeds_system·account_limit_reached)에는 적용된 상한(limit)과 항목(field)을 붙입니다.
|
||||
return bad(e.message || 'server_error', status, { ...(e.providerMessage ? { detail: e.providerMessage } : {}), ...(e.providerStatus ? { providerStatus: e.providerStatus } : {}), ...(e.problems ? { problems: e.problems, policy: e.policy } : {}), ...(e.limit !== undefined ? { limit: e.limit } : {}), ...(e.field ? { field: e.field } : {}) });
|
||||
return bad(e.message || 'server_error', status, { ...(e.code && e.code !== e.message ? { code: e.code } : {}), ...(e.resetAt ? { resetAt: e.resetAt } : {}), ...(e.providerMessage ? { detail: e.providerMessage } : {}), ...(e.providerStatus ? { providerStatus: e.providerStatus } : {}), ...(e.problems ? { problems: e.problems, policy: e.policy } : {}), ...(e.limit !== undefined ? { limit: e.limit } : {}), ...(e.field ? { field: e.field } : {}) });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -477,3 +495,22 @@ async function adminStatus(env) {
|
||||
stats: await mailStats(env, await knownMailboxes(env), await listUsers(env)), largeFiles: await largeFileStats(env),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* [Ariweb Suite 변경] 포털 SSO 티켓 검증. 티켓 = base64url(JSON{ mailbox, exp(ms), nonce }) + '.' + base64url(HMAC-SHA256(ARIWEB_SSO_SECRET, payload)).
|
||||
* 만료(exp)는 5분 이내, nonce 는 한 번만(메모리, 만료까지 보관). 비밀이 설정되지 않은 도메인은 거부합니다.
|
||||
*/
|
||||
const usedNonces = new Map();
|
||||
export function verifySsoTicket(env, ticket) {
|
||||
const secret = String(env.ARIWEB_SSO_SECRET || env.CONFIG?.ARIWEB_SSO_SECRET || ''); if (!secret) return { ok: false, reason: 'sso_not_configured' };
|
||||
const [payload, sig] = String(ticket || '').split('.'); if (!payload || !sig) return { ok: false, reason: 'sso_bad_ticket' };
|
||||
const expect = createHmac('sha256', secret).update(payload).digest('base64url');
|
||||
const a = Buffer.from(expect), b = Buffer.from(sig); if (a.length !== b.length || !timingSafeEqual(a, b)) return { ok: false, reason: 'sso_bad_signature' };
|
||||
let d; try { d = JSON.parse(Buffer.from(payload, 'base64url').toString('utf8')); } catch { return { ok: false, reason: 'sso_bad_ticket' }; }
|
||||
const t = Date.now(); if (!d || typeof d.mailbox !== 'string' || !Number.isFinite(d.exp) || d.exp < t || d.exp > t + 10 * 60 * 1000 || typeof d.nonce !== 'string') return { ok: false, reason: 'sso_expired' };
|
||||
for (const [k, exp] of usedNonces) if (exp < t) usedNonces.delete(k);
|
||||
if (usedNonces.has(d.nonce)) return { ok: false, reason: 'sso_replayed' };
|
||||
usedNonces.set(d.nonce, d.exp);
|
||||
const mailbox = d.mailbox.trim().toLowerCase(); if (mailbox.split('@')[1] !== mailDomain(env)) return { ok: false, reason: 'sso_wrong_domain' };
|
||||
return { ok: true, mailbox };
|
||||
}
|
||||
|
||||
@@ -77,6 +77,14 @@ export const CONFIG_SCHEMA = [
|
||||
// 사용량 API(1.0.9, 요구사항 1): 외부 관리 시스템(웹메이커)이 /api/usage 를 부를 때 쓰는 비밀 키(기본 도메인 설정에만, 화면의 단추로 생성·교체·삭제)와 접속 허용 IP/CIDR 목록(비우면 제한 없음).
|
||||
{ key: 'USAGE_API_KEY', env: 'USAGE_API_KEY', group: 'usage', type: 'secret', hidden: true },
|
||||
{ key: 'USAGE_API_ALLOW', env: 'USAGE_API_ALLOW', group: 'usage', type: 'text' },
|
||||
// [Ariweb Suite 변경] 도메인별 보장량(Dev-Order §0.4·§13.3): 테넌트(도메인)마다 설정 파일에 두는 상한. 값이 있으면 환경 파일(gateway.env)의 LIMIT_* 보다 우선합니다(limits.systemLimits).
|
||||
// 환경 변수 이름을 두지 않는 이유: 환경 변수에 두면 테넌트가 기본 도메인의 값을 물려받지 못하게 되어(TENANT_OWN_KEYS) 기존 운영 서버의 동작이 바뀝니다.
|
||||
{ key: 'LIMIT_ACCOUNTS', group: 'limits', type: 'number' },
|
||||
{ key: 'LIMIT_TOTAL_BYTES', group: 'limits', type: 'text' },
|
||||
{ key: 'LIMIT_MAILBOX_BYTES', group: 'limits', type: 'text' },
|
||||
{ key: 'LIMIT_MONTHLY_MESSAGES', group: 'limits', type: 'number' },
|
||||
// [Ariweb Suite 변경] 포털 SSO 티켓 서명 비밀(§16.3): 포털 보관소와 같은 값. 화면에는 보이지 않고 설정 API 로만 넣습니다.
|
||||
{ key: 'ARIWEB_SSO_SECRET', group: 'ariweb', type: 'secret', hidden: true },
|
||||
// 초기 설정을 마쳤다는 표시(초기 설정 페이지의 "완료" 단추). 화면 안내용이며 동작에는 영향이 없습니다.
|
||||
{ key: 'SETUP_DONE', group: 'setup', type: 'bool', default: false },
|
||||
];
|
||||
|
||||
@@ -134,8 +134,22 @@ export function normalizeEntry(body, current = null, { keepAuth = false } = {})
|
||||
}
|
||||
const note = String(body.note === undefined ? (current?.note || '') : body.note || '').trim().slice(0, 200);
|
||||
const enabled = body.enabled === undefined ? (current ? current.enabled !== false : true) : !(body.enabled === false || body.enabled === 'false' || body.enabled === '0' || body.enabled === 0);
|
||||
// [Ariweb Suite 변경] Ariweb Suite 연동 정보(§0.4): 사이트 대표 메일(site_bound) / 독립 메일 서비스(standalone) 구분과 포털 식별자. 운영 상태(operating)는 이전 중 두 서버가 함께 처리하지 않게 합니다.
|
||||
const ariweb = normalizeAriweb(body.ariweb === undefined ? current?.ariweb : body.ariweb);
|
||||
const operating = body.operating === undefined ? (current?.operating || 'active') : String(body.operating || 'active');
|
||||
if (!OPERATING_STATES.includes(operating)) throw invalid('operating');
|
||||
const stamp = new Date().toISOString();
|
||||
return { domain, enabled, hosts, mailbox: `${mailboxLocal}@${domain}`, auth: { mode, wwwDbFile: mode === 'www' ? wwwDbFile : '', ownerLogin: mode === 'www' ? ownerLogin : '', webmakerDir: mode === 'webmaker' ? webmakerDir : '', webmakerUser: mode === 'webmaker' ? webmakerUser : '', siteId: mode === 'webmaker' ? siteId : '' }, note, createdAt: current?.createdAt || stamp, updatedAt: stamp };
|
||||
return { domain, enabled, hosts, mailbox: `${mailboxLocal}@${domain}`, auth: { mode, wwwDbFile: mode === 'www' ? wwwDbFile : '', ownerLogin: mode === 'www' ? ownerLogin : '', webmakerDir: mode === 'webmaker' ? webmakerDir : '', webmakerUser: mode === 'webmaker' ? webmakerUser : '', siteId: mode === 'webmaker' ? siteId : '' }, note, ariweb, operating, createdAt: current?.createdAt || stamp, updatedAt: stamp };
|
||||
}
|
||||
/** [Ariweb Suite 변경] 도메인 운영 상태. active 가 아니면 수신 임시 거부·발신 차단(limits.operatingState). */
|
||||
export const OPERATING_STATES = ['active', 'migrating_out', 'migrating_in', 'frozen'];
|
||||
export const ARIWEB_KINDS = ['site_bound', 'standalone'];
|
||||
/** Ariweb 연동 정보 정규화: 없으면 null. kind 는 site_bound|standalone, 나머지는 포털 식별자 문자열(64자). */
|
||||
export function normalizeAriweb(v) {
|
||||
if (!v || typeof v !== 'object') return null;
|
||||
const kind = String(v.kind || ''); if (!ARIWEB_KINDS.includes(kind)) throw invalid('ariweb.kind');
|
||||
const id = (x) => { const s = String(x ?? '').trim().slice(0, 64); if (s && !/^[A-Za-z0-9_.:-]+$/.test(s)) throw invalid('ariweb'); return s; };
|
||||
return { kind, siteId: id(v.siteId), groupId: id(v.groupId), mailServiceId: id(v.mailServiceId) };
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -161,7 +175,8 @@ function primaryEntry(baseEnv, stored = null) {
|
||||
// 로그인 방식은 환경 파일이 정합니다: LOGIN_MODE=webmaker(+WEBMAKER_DIR) → webmaker, WWW_DB_FILE(시험에서는 메모리 WWW_DB) → www, 그 밖에는 local.
|
||||
const mode = String(baseEnv.LOGIN_MODE || '') === 'webmaker' && baseEnv.WEBMAKER_DIR ? 'webmaker' : baseEnv.WWW_DB_FILE || baseEnv.WWW_DB ? 'www' : 'local';
|
||||
const auth = { mode, wwwDbFile: mode === 'www' ? String(baseEnv.WWW_DB_FILE || '') : '', ownerLogin: mode === 'www' ? String(baseEnv.MAILBOX_OWNER_LOGIN || '') : '', webmakerDir: mode === 'webmaker' ? String(baseEnv.WEBMAKER_DIR) : '', webmakerUser: mode === 'webmaker' ? String(baseEnv.WEBMAKER_USER || '') : '', siteId: mode === 'webmaker' ? String(baseEnv.WEBMAKER_SITE || '') : '' };
|
||||
return { domain, enabled: true, primary: true, hosts, mailbox, auth, note: stored?.note || '', createdAt: stored?.createdAt || new Date().toISOString(), updatedAt: stored?.updatedAt || new Date().toISOString() };
|
||||
// [Ariweb Suite 변경] 기본 도메인도 ariweb·operating 은 등록부 값을 그대로 둡니다(환경 파일에는 없는 정보).
|
||||
return { domain, enabled: true, primary: true, hosts, mailbox, auth, note: stored?.note || '', ariweb: stored?.ariweb || null, operating: OPERATING_STATES.includes(stored?.operating) ? stored.operating : 'active', createdAt: stored?.createdAt || new Date().toISOString(), updatedAt: stored?.updatedAt || new Date().toISOString() };
|
||||
}
|
||||
|
||||
/** 등록부를 읽습니다. 없으면 기본 도메인만 담아 만듭니다(1.0.7 로 올린 서버의 첫 시작). 파일을 쓸 수 없으면 메모리에서만 동작합니다. */
|
||||
@@ -270,7 +285,7 @@ export function openTenants(baseEnv, { migrationsDir, onCreate = null, log = con
|
||||
// external(1.0.8): webmaker 연동 도메인의 웹메이커 계정 수(읽지 못하면 오류 문구). 화면이 "계정 N명(+웹메이커 M명)" 으로 보여 줍니다.
|
||||
let external = null;
|
||||
if (e.auth.mode === 'webmaker') { try { const r = await externalUsers(env); external = r.ok ? { count: r.accounts.length, site: r.site } : { count: 0, error: r.error }; } catch (err) { external = { count: 0, error: err.message }; } }
|
||||
out.push({ domain: e.domain, primary: e.domain === registry.primary, enabled: e.enabled !== false, hosts: e.hosts, mailbox: e.mailbox, auth: publicAuth(e.auth), note: e.note || '', createdAt: e.createdAt, updatedAt: e.updatedAt, inbound: env.MAIL_INBOUND || 'resend', outbound: env.MAIL_OUTBOUND || 'resend', setupDone: env.CONFIG?.SETUP_DONE === true, accounts, messages, external, dir: e.domain === registry.primary ? configDir(env) : dirOf(e.domain), initialPassword });
|
||||
out.push({ domain: e.domain, primary: e.domain === registry.primary, enabled: e.enabled !== false, hosts: e.hosts, mailbox: e.mailbox, auth: publicAuth(e.auth), note: e.note || '', ariweb: e.ariweb || null, operating: e.operating || 'active', createdAt: e.createdAt, updatedAt: e.updatedAt, inbound: env.MAIL_INBOUND || 'resend', outbound: env.MAIL_OUTBOUND || 'resend', setupDone: env.CONFIG?.SETUP_DONE === true, accounts, messages, external, dir: e.domain === registry.primary ? configDir(env) : dirOf(e.domain), initialPassword });
|
||||
}
|
||||
return out;
|
||||
},
|
||||
@@ -305,7 +320,8 @@ export function openTenants(baseEnv, { migrationsDir, onCreate = null, log = con
|
||||
update(domain, body) {
|
||||
const t = this.byDomain(domain); if (!t) throw new Error('not_found');
|
||||
const isPrimary = t.entry.domain === registry.primary;
|
||||
const next = isPrimary ? { ...t.entry, ...normalizeEntry({ hosts: body.hosts, note: body.note }, t.entry, { keepAuth: true }), enabled: true, primary: true, mailbox: t.entry.mailbox, auth: t.entry.auth } : normalizeEntry({ ...body, domain: t.entry.domain, mailbox: undefined }, t.entry);
|
||||
// [Ariweb Suite 변경] 기본 도메인도 ariweb(연동 정보)·operating(운영 상태)은 바꿀 수 있습니다.
|
||||
const next = isPrimary ? { ...t.entry, ...normalizeEntry({ hosts: body.hosts, note: body.note, ariweb: body.ariweb, operating: body.operating }, t.entry, { keepAuth: true }), enabled: true, primary: true, mailbox: t.entry.mailbox, auth: t.entry.auth } : normalizeEntry({ ...body, domain: t.entry.domain, mailbox: undefined }, t.entry);
|
||||
// 기본 도메인의 첫 호스트는 환경 파일의 PUBLIC_HOST(+PUBLIC_PATH)로 고정합니다(목록에서 빠지거나 뒤로 가면 앞에 되돌림).
|
||||
if (isPrimary) { const ph = normalizeHost(baseEnv.PUBLIC_HOST || ''), first = ph + (normalizePrefix(baseEnv.PUBLIC_PATH) || ''); next.hosts = [first, ...next.hosts.filter((h) => hostNameOf(h) !== ph)]; }
|
||||
for (const other of tenants.values()) if (other !== t) for (const h of next.hosts) if (other.entry.hosts.some((x) => hostNameOf(x) === hostNameOf(h))) { const e = new Error('host_taken'); e.field = 'hosts'; e.detail = h; throw e; }
|
||||
|
||||
@@ -23,6 +23,13 @@
|
||||
*/
|
||||
import { now } from './util.mjs';
|
||||
import { isWebmaker, webmakerByMailbox } from './webmaker.mjs';
|
||||
// [Ariweb Suite 변경] 이 파일의 변경 요지(Dev-Order §0.4·§13.4·§17):
|
||||
// - systemLimits: 테넌트 설정 파일(config.json)의 LIMIT_* 가 환경 파일 값보다 우선(도메인별 보장량)
|
||||
// - countMail: 발신은 최종 수신자 한 명당 한 통(to+cc+bcc, 기록이 없으면 1통)으로 셉니다. sentMessages 에 통 수(메일 수)도 함께 돌려줍니다.
|
||||
// - mailboxUsage/totalUsage: 원문(.eml, 일반·인라인 첨부 포함) + 대용량 첨부(large_files)를 저장 공간으로 셉니다.
|
||||
// - checkInbound/checkOutbound: 월간 통수 100% 와 도메인 운영 상태(이전 중·동결)를 추가로 검사합니다. 100% 에서 기존 읽기·삭제는 그대로입니다.
|
||||
// - statsNow/periodBounds: 기준 시각을 env.STATS_NOW 로 고정할 수 있어 시험이 월 경계를 결정적으로 검증합니다.
|
||||
// - limitState: 70/80/90/95/100 경고 구간과 제한 사유(사용량 API·관리자 화면용).
|
||||
|
||||
// ------------------------------------------------------------------ 상수
|
||||
/** 어느 층에서도 제한하지 않았을 때 적용되는 내장 상한. 발송 서비스(Resend)의 한도에서 나온 값이라 넘길 수 없습니다. */
|
||||
@@ -49,11 +56,17 @@ const nonNegInt = (v) => { const n = Number(v); return Number.isInteger(n) && n
|
||||
|
||||
/** 시스템 설정(환경 변수)을 읽습니다. 모든 값은 0 이면 제한 없음. */
|
||||
export function systemLimits(env) {
|
||||
// [Ariweb Suite 변경] 도메인별 보장량: 설정 파일 값이 비어 있지 않으면 그 값을, 아니면 환경 파일 값을 씁니다.
|
||||
const cfg = env.CONFIG || {}; const pick = (k) => (cfg[k] !== undefined && cfg[k] !== null && String(cfg[k]) !== '' ? cfg[k] : env[k]);
|
||||
return {
|
||||
accounts: nonNegInt(env.LIMIT_ACCOUNTS), totalBytes: parseSize(env.LIMIT_TOTAL_BYTES), mailboxBytes: parseSize(env.LIMIT_MAILBOX_BYTES),
|
||||
attachmentBytes: parseSize(env.LIMIT_ATTACHMENT_BYTES), recipients: nonNegInt(env.LIMIT_RECIPIENTS), monthlyMessages: nonNegInt(env.LIMIT_MONTHLY_MESSAGES),
|
||||
accounts: nonNegInt(pick('LIMIT_ACCOUNTS')), totalBytes: parseSize(pick('LIMIT_TOTAL_BYTES')), mailboxBytes: parseSize(pick('LIMIT_MAILBOX_BYTES')),
|
||||
attachmentBytes: parseSize(env.LIMIT_ATTACHMENT_BYTES), recipients: nonNegInt(env.LIMIT_RECIPIENTS), monthlyMessages: nonNegInt(pick('LIMIT_MONTHLY_MESSAGES')),
|
||||
};
|
||||
}
|
||||
/** [Ariweb Suite 변경] 통계·제한 판단의 기준 시각. env.STATS_NOW(ISO)가 있으면 그 시각(시험·재현용), 없으면 지금. */
|
||||
export function statsNow(env) { const v = env?.STATS_NOW; if (v) { const d = new Date(v); if (!Number.isNaN(d.getTime())) return d; } return new Date(); }
|
||||
/** [Ariweb Suite 변경] 도메인 운영 상태(등록부 operating). active 가 아니면 수신은 임시 거부, 발신은 차단합니다(이전 중 두 서버가 동시에 처리하지 않게, §0.4). */
|
||||
export function operatingState(env) { const s = String(env?.TENANT?.operating || 'active'); return ['active', 'migrating_out', 'migrating_in', 'frozen'].includes(s) ? s : 'active'; }
|
||||
|
||||
// ------------------------------------------------------------------ settings 표 읽기·쓰기
|
||||
async function readJson(env, key, defaults) {
|
||||
@@ -130,12 +143,15 @@ export async function recipientLimit(env) { const admin = await readAdminLimits(
|
||||
*/
|
||||
export async function mailboxUsage(env, mailbox) {
|
||||
const row = await env.DB.prepare(`SELECT COALESCE(SUM(raw_size),0) + COALESCE((SELECT SUM(a.size) FROM attachments a JOIN messages d ON d.id=a.message_id WHERE d.mailbox=? AND d.raw_status='none'),0) AS bytes FROM messages WHERE mailbox=?`).bind(mailbox, mailbox).first();
|
||||
return Number(row?.bytes || 0);
|
||||
// [Ariweb Suite 변경] 대용량 첨부(별도 디스크 보관, state draft/sent)도 그 메일함의 저장 공간입니다(§0.4 "원문·일반 첨부·Inline 첨부·대용량 첨부 포함").
|
||||
const large = await env.DB.prepare("SELECT COALESCE(SUM(size),0) AS b FROM large_files WHERE mailbox=? AND state IN ('draft','sent')").bind(mailbox).first().catch(() => null);
|
||||
return Number(row?.bytes || 0) + Number(large?.b || 0);
|
||||
}
|
||||
/** 모든 메일함을 합친 사용량(바이트). */
|
||||
/** 모든 메일함을 합친 사용량(바이트). 대용량 첨부 포함([Ariweb Suite 변경]). */
|
||||
export async function totalUsage(env) {
|
||||
const row = await env.DB.prepare(`SELECT COALESCE(SUM(raw_size),0) + COALESCE((SELECT SUM(a.size) FROM attachments a JOIN messages d ON d.id=a.message_id WHERE d.raw_status='none'),0) AS bytes FROM messages`).first();
|
||||
return Number(row?.bytes || 0);
|
||||
const large = await env.DB.prepare("SELECT COALESCE(SUM(size),0) AS b FROM large_files WHERE state IN ('draft','sent')").first().catch(() => null);
|
||||
return Number(row?.bytes || 0) + Number(large?.b || 0);
|
||||
}
|
||||
/** 게이트웨이 계정 수(mail_users 행 수, 정지·승인 대기 포함). 계정 수 제한과 비교합니다. */
|
||||
export async function accountCount(env) { return Number((await env.DB.prepare('SELECT COUNT(*) AS n FROM mail_users').first())?.n || 0); }
|
||||
@@ -175,13 +191,28 @@ export function periodBounds(kind, tz = 'Asia/Seoul', at = new Date()) {
|
||||
*/
|
||||
export async function countMail(env, { start, end }, mailbox = null) {
|
||||
const scope = mailbox ? ' AND mailbox=?' : '', extra = mailbox ? [mailbox] : [];
|
||||
const sent = await env.DB.prepare(`SELECT COUNT(*) AS n FROM messages WHERE direction='outbound' AND state='sent' AND sent_at>=? AND sent_at<?${scope}`).bind(start, end, ...extra).first();
|
||||
// [Ariweb Suite 변경] 발신 통수 = 최종 수신자 수(받는 사람+참조+숨은 참조). 수신자 기록이 없는 옛 행(또는 시험 데이터)은 1통으로 셉니다. sentMessages 는 메일 수.
|
||||
const rcpt = (col) => `COALESCE(json_array_length(CASE WHEN json_valid(${col}) THEN ${col} ELSE '[]' END),0)`;
|
||||
const sent = await env.DB.prepare(`SELECT COUNT(*) AS n, COALESCE(SUM(MAX(1, ${rcpt('recipients_json')} + ${rcpt('cc_json')} + ${rcpt('bcc_json')})),0) AS rcpts FROM messages WHERE direction='outbound' AND state='sent' AND sent_at>=? AND sent_at<?${scope}`).bind(start, end, ...extra).first();
|
||||
const received = await env.DB.prepare(`SELECT COUNT(*) AS n FROM messages WHERE direction='inbound' AND received_at>=? AND received_at<?${scope}`).bind(start, end, ...extra).first();
|
||||
const s = Number(sent?.n || 0), r = Number(received?.n || 0);
|
||||
return { sent: s, received: r, total: s + r, start, end };
|
||||
const s = Number(sent?.rcpts || 0), r = Number(received?.n || 0);
|
||||
return { sent: s, sentMessages: Number(sent?.n || 0), received: r, total: s + r, start, end };
|
||||
}
|
||||
/** 이달의 수발신 통수(월간 제한과 비교). at 을 주면 그 시각이 속한 달(기본은 statsNow). */
|
||||
export async function monthlyCount(env, at = statsNow(env)) { return (await countMail(env, periodBounds('month', statsTimezone(env), at))).total; }
|
||||
/**
|
||||
* [Ariweb Suite 변경] 경고 구간과 제한 상태(§13.4·§17). 저장 공간·월간 통수·계정 수 각각의 사용 비율로 넘은 가장 높은 구간(0/70/80/90/95/100)과,
|
||||
* 지금 적용 중인 제한 사유 목록(storage_full / monthly_limit / accounts_full / domain_not_active)을 돌려줍니다. 다음 KST 월 초기화 시각(monthReset)도 함께.
|
||||
*/
|
||||
export async function limitState(env, { levels = [70, 80, 90, 95] } = {}) {
|
||||
const sys = systemLimits(env), tz = statsTimezone(env), at = statsNow(env);
|
||||
const total = await totalUsage(env), monthly = await monthlyCount(env, at), accounts = await accountCount(env);
|
||||
const level = (used, limit) => { if (!limit || limit <= 0) return 0; const pct = (used / limit) * 100; if (pct >= 100) return 100; let l = 0; for (const x of levels) if (pct >= x) l = x; return l; };
|
||||
const storage = level(total, sys.totalBytes), msgs = level(monthly, sys.monthlyMessages), acc = level(accounts, sys.accounts);
|
||||
const reasons = []; if (storage === 100) reasons.push('storage_full'); if (msgs === 100) reasons.push('monthly_limit'); if (acc === 100) reasons.push('accounts_full');
|
||||
const op = operatingState(env); if (op !== 'active') reasons.push('domain_not_active');
|
||||
return { levels: { storage, monthly: msgs, accounts: acc }, warnLevel: Math.max(storage, msgs, acc), limitedReasons: reasons, operating: op, monthReset: periodBounds('month', tz, at).end, measuredAt: now(), used: { storageBytes: total, monthlyMessages: monthly, accounts } };
|
||||
}
|
||||
/** 이달의 수발신 통수(월간 제한과 비교). */
|
||||
export async function monthlyCount(env) { return (await countMail(env, periodBounds('month', statsTimezone(env)))).total; }
|
||||
|
||||
// ------------------------------------------------------------------ 제한 검사(발송·수신·첨부)
|
||||
/**
|
||||
@@ -202,8 +233,11 @@ export async function checkOutbound(env, { mailbox, recipients = 0, attachmentSi
|
||||
if (maxRcpt > 0 && recipients > maxRcpt) { const e = new Error('too_many_recipients'); e.limit = maxRcpt; throw e; }
|
||||
const maxAttach = await attachmentLimit(env);
|
||||
for (const size of attachmentSizes) if (mimeSize(size) > maxAttach) { const e = new Error('attachment_too_large'); e.limit = maxAttach; throw e; }
|
||||
// [Ariweb Suite 변경] 도메인이 이전 중·동결이면 새 발신을 막습니다(두 서버가 같은 도메인을 동시에 처리하지 않게).
|
||||
if (operatingState(env) !== 'active') { const e = new Error('mail_domain_frozen'); e.code = 'mail_domain_frozen'; e.operating = operatingState(env); throw e; }
|
||||
const sys = systemLimits(env);
|
||||
if (sys.monthlyMessages > 0 && (await monthlyCount(env)) >= sys.monthlyMessages) { const e = new Error('monthly_limit_exceeded'); e.limit = sys.monthlyMessages; throw e; }
|
||||
// 월간 통수 100%: 새 발신 차단. 오류 코드는 예전 이름(monthly_limit_exceeded)을 유지하고 code 에 Ariweb 공통 코드(mail_monthly_limit)를 붙입니다.
|
||||
if (sys.monthlyMessages > 0 && (await monthlyCount(env)) >= sys.monthlyMessages) { const e = new Error('monthly_limit_exceeded'); e.code = 'mail_monthly_limit'; e.limit = sys.monthlyMessages; e.resetAt = periodBounds('month', statsTimezone(env), statsNow(env)).end; throw e; }
|
||||
const quota = await mailboxQuota(env, mailbox);
|
||||
if (quota > 0 && (await mailboxUsage(env, mailbox)) + bytes > quota) { const e = new Error('quota_exceeded'); e.limit = quota; throw e; }
|
||||
if (sys.totalBytes > 0 && (await totalUsage(env)) + bytes > sys.totalBytes) { const e = new Error('storage_full'); e.limit = sys.totalBytes; throw e; }
|
||||
@@ -213,14 +247,18 @@ export async function checkOutbound(env, { mailbox, recipients = 0, attachmentSi
|
||||
* @returns {Promise<null|'quota_exceeded'|'storage_full'>} 저장할 수 없으면 사유. 동기화는 공간이 생기면 같은 메일을 다시 가져옵니다.
|
||||
*/
|
||||
export async function checkInbound(env, mailbox) {
|
||||
// [Ariweb Suite 변경] 운영 상태가 active 가 아니면 임시 거부(보낸 서버가 재시도). 월간 통수 100% 도 새 수신을 임시 거부합니다(§13.4).
|
||||
if (operatingState(env) !== 'active') return 'domain_not_active';
|
||||
const quota = await mailboxQuota(env, mailbox);
|
||||
if (quota > 0 && (await mailboxUsage(env, mailbox)) >= quota) return 'quota_exceeded';
|
||||
const sys = systemLimits(env);
|
||||
if (sys.totalBytes > 0 && (await totalUsage(env)) >= sys.totalBytes) return 'storage_full';
|
||||
// 저장 공간 사유가 우선이고(정리하면 바로 풀림), 그다음 월간 통수(다음 KST 월에 풀림).
|
||||
if (sys.monthlyMessages > 0 && (await monthlyCount(env)) >= sys.monthlyMessages) return 'monthly_limit';
|
||||
return null;
|
||||
}
|
||||
/** 자동 전달·자동 응답처럼 메일 한 통당 발송이 생기는 작업 전에 월간 통수 제한만 확인합니다. 넘었으면 'limit'. */
|
||||
export async function monthlyBlocked(env) { const sys = systemLimits(env); return sys.monthlyMessages > 0 && (await monthlyCount(env)) >= sys.monthlyMessages; }
|
||||
export async function monthlyBlocked(env) { const sys = systemLimits(env); return operatingState(env) !== 'active' || (sys.monthlyMessages > 0 && (await monthlyCount(env)) >= sys.monthlyMessages); }
|
||||
|
||||
// ------------------------------------------------------------------ POP/IMAP 서비스 전체 켜기·끄기, 회원 가입 설정
|
||||
/**
|
||||
@@ -277,7 +315,7 @@ export async function writeSignup(env, patch) {
|
||||
export async function mailStats(env, mailboxes, users = []) {
|
||||
const tz = statsTimezone(env), sys = systemLimits(env), admin = await readAdminLimits(env);
|
||||
const periods = {};
|
||||
for (const kind of ['day', 'week', 'month']) periods[kind] = await countMail(env, periodBounds(kind, tz));
|
||||
for (const kind of ['day', 'week', 'month']) periods[kind] = await countMail(env, periodBounds(kind, tz, statsNow(env)));
|
||||
const boxes = [];
|
||||
for (const mailbox of mailboxes) {
|
||||
const used = await mailboxUsage(env, mailbox), quota = await mailboxQuota(env, mailbox), owner = users.find((u) => u.mailbox === mailbox);
|
||||
@@ -286,7 +324,7 @@ export async function mailStats(env, mailboxes, users = []) {
|
||||
}
|
||||
const total = await totalUsage(env), accounts = await accountCount(env), monthly = periods.month.total;
|
||||
return {
|
||||
timezone: tz, periods, mailboxes: boxes, system: sys, admin,
|
||||
timezone: tz, periods, mailboxes: boxes, system: sys, admin, limitState: await limitState(env),
|
||||
usage: {
|
||||
total: { used: total, limit: sys.totalBytes, percent: percentOf(total, sys.totalBytes) },
|
||||
accounts: { used: accounts, limit: sys.accounts, percent: percentOf(accounts, sys.accounts) },
|
||||
|
||||
@@ -30,6 +30,7 @@ import { uploadLargeFile, largeFileInfo, claimDownload, openLargeFile, finishDow
|
||||
import { escapeHtml } from './util.mjs';
|
||||
import { memoryRateStore, siteName } from './util.mjs';
|
||||
import { systemLimits } from './limits.mjs';
|
||||
import { recoverQueue } from './sendqueue.mjs';
|
||||
import { loadConfig, configFile, setupToken, mailDomain } from './config.mjs';
|
||||
import { createSetupServer, setupPage, writeSetupUrls } from './setup.mjs';
|
||||
import { inboundMode, outboundMode, usesResend, usesHaraka } from './providers/index.mjs';
|
||||
@@ -131,6 +132,8 @@ const prefixOf = (req, resolved) => {
|
||||
|
||||
// 도메인 등록부(1.0.7): 기본 도메인(이 env)과 추가 도메인의 env 를 엽니다. 등록부가 없으면 기본 도메인만으로 만듭니다(데이터 이동 없음).
|
||||
const tenants = openTenants(env, { migrationsDir: join(ROOT, 'migrations'), onCreate: prepareTenant });
|
||||
// [Ariweb Suite 변경] 지난 실행에서 발송 중(sending)으로 남은 대기열 행은 interrupted 로 표시해 관리자가 확인·재발송할 수 있게 합니다(메일을 잃지 않음).
|
||||
for (const t of tenants.envs()) recoverQueue(t).then((n) => { if (n) console.warn(` [${t.DOMAIN}] ⚠️ 끊긴 발송 ${n}건 — 관리자 페이지 › 발송 대기열에서 확인하세요`); }).catch(() => {});
|
||||
console.log(` 도메인 등록부: ${join(env.DATA_DIR, 'domains.json')} — 기본 ${env.DOMAIN} (${env.TENANT.hosts.join(', ')})${tenants.all().length > 1 ? `, 추가 ${tenants.all().length - 1}개` : ''}`);
|
||||
console.log(` 메일 서비스: 수신 ${inboundMode(env)} · 발신 ${outboundMode(env)}`);
|
||||
// 빠진 운영 설정은 경고만 하고 서버는 뜹니다(요구사항 3: 값이 없어도 배포는 성공). 값은 초기 설정 페이지에서 채우면 재시작 없이 적용됩니다.
|
||||
|
||||
@@ -16,7 +16,8 @@
|
||||
import { resend, providerStatus as resendStatus } from './resend.mjs';
|
||||
import { sendViaSmtp } from './smtp.mjs';
|
||||
import { smtpProbe } from './smtp-client.mjs';
|
||||
import { now } from '../util.mjs';
|
||||
import { now, address } from '../util.mjs';
|
||||
import { enqueue, finish, pace } from '../sendqueue.mjs';
|
||||
|
||||
export const OUTBOUND_MODES = ['resend', 'haraka', 'smtp'], INBOUND_MODES = ['resend', 'haraka'];
|
||||
/** 설정된 발신 방식(알 수 없는 값은 resend). env.MAIL_OUTBOUND 는 config.mjs 의 applyConfig 가 채웁니다. */
|
||||
@@ -43,8 +44,15 @@ export function relaySettings(env, mode = outboundMode(env)) {
|
||||
*/
|
||||
export async function sendMail(env, payload, key) {
|
||||
const mode = outboundMode(env);
|
||||
if (mode === 'resend') return resend(env, '/emails', 'POST', payload, key);
|
||||
return sendViaSmtp(env, payload, relaySettings(env, mode));
|
||||
// [Ariweb Suite 변경] 발송 대기열 기록 → 안전 발송 속도(도메인·서버 초당 5건) → 발송 모듈. 같은 멱등 키가 이미 보내졌으면 다시 보내지 않습니다.
|
||||
const domain = String(env.MAILBOX || '').split('@')[1] || 'localhost';
|
||||
const q = await enqueue(env, { domain, mailbox: address(payload.from), key, payload });
|
||||
if (q.alreadySent) return { id: q.providerId, duplicate: true };
|
||||
try {
|
||||
await pace(domain);
|
||||
const r = mode === 'resend' ? await resend(env, '/emails', 'POST', payload, key) : await sendViaSmtp(env, payload, relaySettings(env, mode));
|
||||
await finish(env, q.id, { providerId: r.id }); return r;
|
||||
} catch (e) { await finish(env, q.id, { error: e.message + (e.providerMessage ? ' ' + e.providerMessage : '') }); throw e; }
|
||||
}
|
||||
|
||||
// 상태 캐시는 env(= DB) 마다 따로 둡니다(시험처럼 한 프로세스에 여러 env 가 있을 때 섞이지 않게). 운영은 env 가 하나라 전역 캐시와 같습니다.
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
/**
|
||||
* [Ariweb Suite 변경] 발송 속도 조절·제출 제한·발송 대기열(Dev-Order §17.2).
|
||||
*
|
||||
* 세 가지를 맡습니다.
|
||||
* 1) 안전 발송 속도(pace): 실제 외부 전달은 도메인마다, 그리고 서버 전체로 초당 5건을 넘지 않습니다(토큰 버킷 두 개를 모두 통과해야 발송).
|
||||
* 버킷이 비어 있으면 그 자리에서 기다립니다(최대 WAIT_MAX_MS, 넘으면 mail_rate_limited 로 실패시켜 메일 프로그램이 나중에 다시 보내게 함).
|
||||
* 2) 제출 제한(submission): 계정·접속 IP 가 초당 10건 이상 제출하면 자동 제한(mail_submission_throttled)·경고 기록·악용 확인 표(abuse_flags)에 남깁니다.
|
||||
* 제한 창(THROTTLE_WINDOW_MS)이 지나면 자동으로 풀리고, 관리자 화면(/api/admin/rates)이 현재 창의 제출량과 버킷 상태를 보여 줍니다.
|
||||
* 3) 발송 대기열(outbound_queue): 발송 모듈에 넘기기 직전 행을 넣고(state sending) 끝나면 sent/failed 로 바꿉니다. 재시작 때 sending 으로 남은 행은
|
||||
* interrupted 로 표시해 관리자가 다시 보낼 수 있게 합니다(메일을 잃지 않음). 같은 send_key 가 이미 sent 면 다시 보내지 않습니다(멱등).
|
||||
*
|
||||
* 시계는 clock() 으로 바꿀 수 있어 시험이 실제 시간을 기다리지 않습니다.
|
||||
*/
|
||||
import { now, uid } from './util.mjs';
|
||||
|
||||
export const DELIVERY_RATE = 5; // 초당 외부 전달 상한(도메인·서버 각각)
|
||||
export const SUBMISSION_RATE = 10; // 초당 제출 상한(계정·IP 각각). 이 이상이면 제한·경고·악용 확인
|
||||
export const THROTTLE_WINDOW_MS = 60 * 1000; // 제한이 유지되는 시간
|
||||
export const WAIT_MAX_MS = 15 * 1000; // 버킷을 기다리는 최대 시간
|
||||
|
||||
let clock = () => Date.now();
|
||||
/** 시험용: 시계를 바꿉니다(ms 를 돌려주는 함수). null 이면 실제 시계. */
|
||||
export function setClock(fn) { clock = fn || (() => Date.now()); }
|
||||
/** 시험용: 대기 함수(기본 setTimeout). 가짜 시계와 함께 바꿉니다. */
|
||||
let sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
||||
export function setSleep(fn) { sleep = fn || ((ms) => new Promise((r) => setTimeout(r, ms))); }
|
||||
|
||||
// 토큰 버킷: { tokens, updatedAt }. 초당 rate 만큼 채워지고 최대 rate 개까지만 쌓입니다(순간 폭주 허용 폭 = 1초 분량).
|
||||
const buckets = new Map();
|
||||
function bucket(key, rate) { let b = buckets.get(key); if (!b) { b = { tokens: rate, updatedAt: clock(), rate }; buckets.set(key, b); } return b; }
|
||||
function refill(b) { const t = clock(); const dt = Math.max(0, t - b.updatedAt) / 1000; b.tokens = Math.min(b.rate, b.tokens + dt * b.rate); b.updatedAt = t; }
|
||||
/** 버킷에서 토큰 하나를 빼려고 시도. 성공하면 0, 아니면 다음 토큰까지 기다릴 ms. */
|
||||
function take(key, rate) { const b = bucket(key, rate); refill(b); if (b.tokens >= 1) { b.tokens -= 1; return 0; } return Math.ceil(((1 - b.tokens) / b.rate) * 1000); }
|
||||
|
||||
/**
|
||||
* 외부 전달 전 속도 조절. 도메인 버킷과 서버 버킷을 모두 통과할 때까지 기다립니다.
|
||||
* @param {string} domain 발신 도메인
|
||||
* @throws {Error} mail_rate_limited — WAIT_MAX_MS 안에 차례가 오지 않음
|
||||
*/
|
||||
export async function pace(domain) {
|
||||
const started = clock();
|
||||
for (;;) {
|
||||
const w1 = take('domain:' + domain, DELIVERY_RATE);
|
||||
if (w1 === 0) {
|
||||
const w2 = take('server', DELIVERY_RATE);
|
||||
if (w2 === 0) return;
|
||||
// 서버 버킷이 비었으면 도메인 토큰을 돌려주고(공정성) 기다립니다.
|
||||
bucket('domain:' + domain, DELIVERY_RATE).tokens += 1;
|
||||
if (clock() - started + w2 > WAIT_MAX_MS) { const e = new Error('mail_rate_limited'); e.providerMessage = 'delivery rate limit (server)'; throw e; }
|
||||
await sleep(w2); continue;
|
||||
}
|
||||
if (clock() - started + w1 > WAIT_MAX_MS) { const e = new Error('mail_rate_limited'); e.providerMessage = 'delivery rate limit (domain)'; throw e; }
|
||||
await sleep(w1);
|
||||
}
|
||||
}
|
||||
|
||||
// 제출 창: key → { count, windowStart, throttledUntil }. 1초 창에서 SUBMISSION_RATE 이상이면 제한.
|
||||
const submissions = new Map();
|
||||
/**
|
||||
* 제출 1건을 세고 제한 여부를 돌려줍니다. 제한되면 abuse_flags 에 한 번 기록(같은 키·같은 제한 창에서는 한 번만).
|
||||
* @param {object} env DB 가 있으면 악용 표에 기록
|
||||
* @param {string[]} keys 예: ['account:kim@x.com', 'ip:1.2.3.4']
|
||||
* @returns {Promise<{throttled:boolean, key?:string, until?:string, rate?:number}>}
|
||||
*/
|
||||
export async function submit(env, keys) {
|
||||
const t = clock();
|
||||
for (const key of keys) {
|
||||
let s = submissions.get(key); if (!s) { s = { count: 0, windowStart: t, throttledUntil: 0, flagged: false }; submissions.set(key, s); }
|
||||
if (s.throttledUntil > t) return { throttled: true, key, until: new Date(s.throttledUntil).toISOString(), rate: s.lastRate || 0 };
|
||||
if (t - s.windowStart >= 1000) { s.count = 0; s.windowStart = t; s.flagged = false; }
|
||||
s.count += 1;
|
||||
if (s.count >= SUBMISSION_RATE) {
|
||||
s.throttledUntil = t + THROTTLE_WINDOW_MS; s.lastRate = s.count;
|
||||
if (!s.flagged && env?.DB) { s.flagged = true; try { await env.DB.prepare('INSERT INTO abuse_flags(id,kind,subject,detail,rate,created_at) VALUES(?,?,?,?,?,?)').bind(uid(), 'submission_rate', key, `초당 ${s.count}건 제출 — ${THROTTLE_WINDOW_MS / 1000}초 제한`, s.count, now()).run(); } catch { /* 표가 없는 옛 DB */ } }
|
||||
console.warn(`발송 제출 제한: ${key} 초당 ${s.count}건 (${THROTTLE_WINDOW_MS / 1000}초 동안 거부)`);
|
||||
return { throttled: true, key, until: new Date(s.throttledUntil).toISOString(), rate: s.count };
|
||||
}
|
||||
}
|
||||
return { throttled: false };
|
||||
}
|
||||
/** 관리자 화면용 현재 상태: 버킷(남은 토큰)·제출 창(최근 1초 제출량·제한 해제 시각). 비밀값 없음. */
|
||||
export function rateSnapshot() {
|
||||
const t = clock();
|
||||
const b = [...buckets.entries()].map(([key, x]) => { refill(x); return { key, tokens: Math.round(x.tokens * 100) / 100, rate: x.rate }; });
|
||||
const s = [...submissions.entries()].map(([key, x]) => ({ key, countInWindow: t - x.windowStart >= 1000 ? 0 : x.count, throttledUntil: x.throttledUntil > t ? new Date(x.throttledUntil).toISOString() : null, lastRate: x.lastRate || 0 }));
|
||||
return { deliveryRate: DELIVERY_RATE, submissionRate: SUBMISSION_RATE, throttleWindowMs: THROTTLE_WINDOW_MS, buckets: b, submissions: s, at: new Date(t).toISOString() };
|
||||
}
|
||||
/** 시험·운영자용: 상태 초기화 */
|
||||
export function resetRates() { buckets.clear(); submissions.clear(); }
|
||||
|
||||
// ------------------------------------------------------------------ 발송 대기열
|
||||
/** 발송 직전 기록. 같은 send_key 가 이미 sent 면 null(= 이미 보냄). */
|
||||
export async function enqueue(env, { domain, mailbox = '', key = null, payload }) {
|
||||
if (!env?.DB) return { id: null };
|
||||
try {
|
||||
if (key) { const done = await env.DB.prepare("SELECT id, provider_id FROM outbound_queue WHERE send_key=? AND state='sent'").bind(key).first(); if (done) return { id: done.id, alreadySent: true, providerId: done.provider_id }; }
|
||||
const id = uid(); const stamp = now();
|
||||
// 메일 본문·첨부는 넣지 않습니다(저장소에 이미 있음): 받는 사람 수·제목 길이·첨부 수만 두어 재시도 때 저장소의 원본으로 다시 만듭니다.
|
||||
const slim = { from: payload.from, to: payload.to, cc: payload.cc, bcc: payload.bcc, subject: String(payload.subject || '').slice(0, 200), attachments: (payload.attachments || []).length, messageId: payload.headers?.['Message-ID'] || null, ref: payload.__ref || null };
|
||||
await env.DB.prepare('INSERT INTO outbound_queue(id,domain,mailbox,send_key,payload_json,state,attempts,created_at,updated_at) VALUES(?,?,?,?,?,\'sending\',1,?,?)').bind(id, domain, mailbox, key, JSON.stringify(slim), stamp, stamp).run();
|
||||
return { id };
|
||||
} catch { return { id: null }; } // 0009 마이그레이션 전의 DB 에서도 발송은 계속됩니다
|
||||
}
|
||||
export async function finish(env, id, { providerId = null, error = null } = {}) {
|
||||
if (!id || !env?.DB) return;
|
||||
try { await env.DB.prepare('UPDATE outbound_queue SET state=?, provider_id=?, error=?, sent_at=?, updated_at=? WHERE id=?').bind(error ? 'failed' : 'sent', providerId, error ? String(error).slice(0, 300) : null, error ? null : now(), now(), id).run(); } catch { /* 무시 */ }
|
||||
}
|
||||
/** 시작 때: 지난 실행에서 'sending' 으로 남은 행을 interrupted 로 표시합니다. 돌아온 값은 그 수. */
|
||||
export async function recoverQueue(env) {
|
||||
if (!env?.DB) return 0;
|
||||
try { const r = await env.DB.prepare("UPDATE outbound_queue SET state='interrupted', updated_at=? WHERE state='sending'").bind(now()).run(); return Number(r.meta?.changes || 0); } catch { return 0; }
|
||||
}
|
||||
/** 관리자 화면: 최근 대기열 행(실패·끊김 먼저). */
|
||||
export async function queueList(env, { limit = 50 } = {}) {
|
||||
try { return (await env.DB.prepare("SELECT id, domain, mailbox, state, attempts, provider_id, error, created_at, updated_at, sent_at, payload_json FROM outbound_queue ORDER BY CASE state WHEN 'interrupted' THEN 0 WHEN 'failed' THEN 1 WHEN 'sending' THEN 2 ELSE 3 END, created_at DESC LIMIT ?").bind(limit).all()).results.map((r) => ({ ...r, payload: JSON.parse(r.payload_json || '{}'), payload_json: undefined })); } catch { return []; }
|
||||
}
|
||||
export async function abuseList(env, { open = true, limit = 100 } = {}) {
|
||||
try { return (await env.DB.prepare(`SELECT * FROM abuse_flags ${open ? 'WHERE reviewed_at IS NULL' : ''} ORDER BY created_at DESC LIMIT ?`).bind(limit).all()).results; } catch { return []; }
|
||||
}
|
||||
export async function reviewAbuse(env, id, by) { try { await env.DB.prepare('UPDATE abuse_flags SET reviewed_at=?, reviewed_by=? WHERE id=?').bind(now(), by, id).run(); } catch { /* 무시 */ } }
|
||||
@@ -23,7 +23,8 @@ import net from 'node:net';
|
||||
import tls from 'node:tls';
|
||||
import { createHash } from 'node:crypto';
|
||||
import { verifyUser, audit } from './auth.mjs';
|
||||
import { sendMail } from './providers/index.mjs';
|
||||
import { sendMail, outboundMode } from './providers/index.mjs';
|
||||
import { submit } from './sendqueue.mjs';
|
||||
import { normalizeCrlf } from './mime.mjs';
|
||||
import { parseMime, summarize, header, parseAddresses, decodeWords } from './imap-mime.mjs';
|
||||
import { appendMessage, setFlag } from './store.mjs';
|
||||
@@ -87,7 +88,8 @@ function limitReply(e) {
|
||||
case 'attachment_too_large': return new SmtpError(552, '5.3.4', `Attachment exceeds the size limit (${e.limit} bytes after MIME encoding)`);
|
||||
case 'quota_exceeded': return new SmtpError(452, '4.2.2', 'Mailbox full (quota exceeded), free space and try again');
|
||||
case 'storage_full': return new SmtpError(452, '4.3.1', 'Insufficient system storage');
|
||||
case 'monthly_limit_exceeded': return new SmtpError(550, '5.7.1', `Monthly message limit reached (${e.limit})`);
|
||||
case 'monthly_limit_exceeded': return new SmtpError(550, '5.7.1', `Monthly message limit reached (${e.limit})${e.resetAt ? ', resets at ' + e.resetAt : ''}`);
|
||||
case 'mail_domain_frozen': return new SmtpError(451, '4.3.2', 'Mail domain is being migrated, try again later'); // [Ariweb Suite 변경] 이전 중·동결
|
||||
default: return null;
|
||||
}
|
||||
}
|
||||
@@ -106,7 +108,11 @@ function limitReply(e) {
|
||||
* @throws {SmtpError} 클라이언트에 돌려줄 SMTP 오류
|
||||
*/
|
||||
export async function submitMessage(env, user, { mailFrom = '', rcpt = [], eml, ip = '' }) {
|
||||
if (!env.RESEND_API_KEY) throw new SmtpError(451, '4.3.0', 'Mail service temporarily unavailable, try again later');
|
||||
// [Ariweb Suite 변경] 오류 수정: Resend 키는 발신 방식이 resend 일 때만 필요합니다. 예전에는 Haraka·외부 SMTP 중계를 쓰는 도메인도 키가 없으면 451 로 모든 제출이 거부됐습니다.
|
||||
if (outboundMode(env) === 'resend' && !env.RESEND_API_KEY) throw new SmtpError(451, '4.3.0', 'Mail service temporarily unavailable, try again later');
|
||||
// [Ariweb Suite 변경] 제출 속도 제한(§17.2): 계정·IP 가 초당 10건 이상이면 임시 거부하고 악용 확인 표에 남깁니다.
|
||||
const th = await submit(env, ['account:' + user.mailbox, ...(ip ? ['ip:' + ip] : [])]);
|
||||
if (th.throttled) throw new SmtpError(451, '4.7.0', `Submission rate limit reached, try again after ${th.until}`);
|
||||
const raw = normalizeCrlf(eml);
|
||||
let parsed, info;
|
||||
try { parsed = parseMime(raw); info = summarize(parsed); } catch { throw new SmtpError(550, '5.6.0', 'Malformed message'); }
|
||||
|
||||
@@ -24,7 +24,7 @@
|
||||
* limits : { system:{…}, admin:{…} } — 설치 시 시스템 제한과 관리자 제한(안내용)
|
||||
*/
|
||||
import { json, bad, safeEqual, now } from './util.mjs';
|
||||
import { systemLimits, readAdminLimits, mailboxUsage, mailboxQuota, totalUsage, accountCount, statsTimezone, periodBounds, countMail, percentOf } from './limits.mjs';
|
||||
import { systemLimits, readAdminLimits, mailboxUsage, mailboxQuota, totalUsage, accountCount, statsTimezone, periodBounds, countMail, percentOf, limitState, statsNow } from './limits.mjs';
|
||||
import { listUsers, externalUsers, authMode, audit, mailboxOwner, standalone } from './auth.mjs';
|
||||
import { largeFileStats } from './files.mjs';
|
||||
import { inboundMode, outboundMode } from './providers/index.mjs';
|
||||
@@ -128,7 +128,7 @@ const dateKey = (iso, tz) => { try { return new Intl.DateTimeFormat('en-CA', { t
|
||||
*/
|
||||
async function dailySeries(env, days, tz) {
|
||||
// 시작 = (오늘 0시 - (days-1)일) 이 속한 날의 0시. 24시간 단위로 빼기만 하면 서머타임 경계에서 한 시간이 밀리므로 그 날의 정오를 잡아 다시 0시를 구합니다.
|
||||
const today = periodBounds('day', tz); const approx = Date.parse(today.start) - (days - 1) * DAY_MS;
|
||||
const today = periodBounds('day', tz, statsNow(env)); const approx = Date.parse(today.start) - (days - 1) * DAY_MS;
|
||||
const start = periodBounds('day', tz, new Date(approx + DAY_MS / 2)).start, startMs = Date.parse(start), end = today.end;
|
||||
const rows = (await env.DB.prepare(`SELECT direction, mailbox, raw_size, sent_at, received_at FROM messages WHERE (direction='inbound' AND received_at>=? AND received_at<?) OR (direction='outbound' AND state='sent' AND sent_at>=? AND sent_at<?)`).bind(start, end, start, end).all()).results;
|
||||
const byDate = new Map(); for (let i = 0; i < days; i++) { const d = dateKey(new Date(startMs + i * DAY_MS + DAY_MS / 2).toISOString(), tz); byDate.set(d, { date: d, sent: 0, received: 0, sentBytes: 0, receivedBytes: 0 }); }
|
||||
@@ -156,15 +156,18 @@ export async function domainUsage(env, { days = 30, accounts = true, series = tr
|
||||
const tz = statsTimezone(env), sys = systemLimits(env), admin = await readAdminLimits(env);
|
||||
const counts = await env.DB.prepare("SELECT COUNT(*) total, SUM(CASE WHEN direction='inbound' THEN 1 ELSE 0 END) inbound, SUM(CASE WHEN direction='outbound' AND state='sent' THEN 1 ELSE 0 END) outbound, SUM(CASE WHEN state IN ('draft','sending') THEN 1 ELSE 0 END) drafts, SUM(CASE WHEN state='failed' THEN 1 ELSE 0 END) failed FROM messages").first();
|
||||
const total = await totalUsage(env), local = await accountCount(env), ext = await externalUsers(env);
|
||||
const traffic = {}; for (const kind of ['day', 'week', 'month']) traffic[kind] = await periodTraffic(env, periodBounds(kind, tz));
|
||||
const traffic = {}; for (const kind of ['day', 'week', 'month']) traffic[kind] = await periodTraffic(env, periodBounds(kind, tz, statsNow(env)));
|
||||
const limits = await limitState(env); // [Ariweb Suite 변경] 경고 구간·제한 사유
|
||||
const out = {
|
||||
domain: mailDomain(env), primary: !env.TENANTS || env.TENANTS.primary.env === env, enabled: entry ? entry.enabled !== false : true, hosts: entry?.hosts || [env.PUBLIC_HOST].filter(Boolean),
|
||||
authMode: authMode(env), mailbox: String(env.MAILBOX || ''), inbound: inboundMode(env), outbound: outboundMode(env), timezone: tz, generatedAt: now(),
|
||||
accounts: { local, external: ext.accounts.length, total: local + ext.accounts.length, limit: sys.accounts, percent: percentOf(local, sys.accounts), externalSource: ext.accounts.length || authMode(env) === 'webmaker' ? { kind: authMode(env), ok: ext.ok !== false, error: ext.error || null } : null },
|
||||
storage: { bytes: total, limit: sys.totalBytes, percent: percentOf(total, sys.totalBytes), largeFiles: await largeFileStats(env) },
|
||||
messages: { total: Number(counts?.total || 0), inbound: Number(counts?.inbound || 0), outbound: Number(counts?.outbound || 0), drafts: Number(counts?.drafts || 0), failed: Number(counts?.failed || 0) },
|
||||
traffic: { ...traffic, monthlyLimit: sys.monthlyMessages, monthlyPercent: percentOf(traffic.month.total, sys.monthlyMessages) },
|
||||
traffic: { ...traffic, monthlyLimit: sys.monthlyMessages, monthlyPercent: percentOf(traffic.month.total, sys.monthlyMessages), countingRule: 'per_final_recipient', monthReset: limits.monthReset },
|
||||
limits: { system: sys, admin },
|
||||
// [Ariweb Suite 변경] 포털 표시용: 사이트 대표/독립 구분, 운영 상태, 경고 구간(0/70/80/90/95/100), 제한 사유.
|
||||
ariweb: entry?.ariweb || env.TENANT?.ariweb || null, operating: limits.operating, warnLevel: limits.warnLevel, warnLevels: limits.levels, limitedReasons: limits.limitedReasons, measuredAt: limits.measuredAt,
|
||||
};
|
||||
if (series || accounts) {
|
||||
const d = await dailySeries(env, days, tz);
|
||||
|
||||
@@ -0,0 +1,169 @@
|
||||
/**
|
||||
* [Ariweb Suite 변경] Ariweb Suite 0.1.1 도입 때 고친·더한 동작의 회귀 테스트(Dev-Order §0.4).
|
||||
* 1) KST 월 경계(고정 시각) 2) Haraka/SMTP 중계 발신 때 Resend 키를 요구하지 않음 3) 등록부 ariweb·operating 4) 도메인별 보장량·수신자 기준 통수·대용량 첨부 포함 저장 공간
|
||||
* 5) 100% 동작(저장 공간·월간 통수)과 다음 KST 월 초기화 6) 발송 속도·제출 제한·대기열 7) 운영 상태(동결) 8) 포털 SSO 티켓
|
||||
* 실행: node test/ariweb.test.mjs
|
||||
*/
|
||||
import net from 'node:net';
|
||||
import { mkdtempSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join, dirname } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { createHmac, randomBytes } from 'node:crypto';
|
||||
import { makeEnv, runner, eq, ok, request, params, fakeResend } from './_helpers.mjs';
|
||||
import { handle } from '../server/api.mjs';
|
||||
import { loadConfig, saveConfig, setupToken } from '../server/config.mjs';
|
||||
import { openTenants, normalizeEntry } from '../server/domains.mjs';
|
||||
import { setupApi } from '../server/setup.mjs';
|
||||
import { usageApi } from '../server/usage.mjs';
|
||||
import { periodBounds, monthlyCount, countMail, systemLimits, checkInbound, checkOutbound, totalUsage, limitState, mailboxUsage } from '../server/limits.mjs';
|
||||
import { submitMessage } from '../server/smtp.mjs';
|
||||
import { buildEml } from '../server/mime.mjs';
|
||||
import { ingestRaw, saveDraft } from '../server/store.mjs';
|
||||
import * as sq from '../server/sendqueue.mjs';
|
||||
|
||||
const { t, done } = runner();
|
||||
const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..');
|
||||
const tmp = () => mkdtempSync(join(tmpdir(), 'arimail-ariweb-'));
|
||||
const api = (env, path, cookie, method, body, headers) => handle(env, request(path, cookie, method, body, headers), { ...params(path), prefix: '' });
|
||||
const open = (env) => openTenants(env, { migrationsDir: join(ROOT, 'migrations'), log: { log() {}, error() {} } });
|
||||
const setup = (env, path, method = 'GET', body, tenants = null) => setupApi(env, new Request('https://mail.arihakdang.com/api/setup/' + path, { method, headers: { 'Content-Type': 'application/json' }, body: body === undefined ? undefined : JSON.stringify(body) }), path.split('/'), { ip: '127.0.0.1', tenants });
|
||||
async function base(over = {}) { const dir = tmp(); const env = await makeEnv({ DB_FILE: join(dir, 'mail-gateway.sqlite'), PUBLIC_HOST: 'mail.arihakdang.com', ...over }); loadConfig(env); return { env, dir }; }
|
||||
/** 발신 메일 행(수신자 n명) */
|
||||
async function sentRow(env, { at, recipients = ['a@x.test'], cc = [], bcc = [], mailbox = 'admin@arihakdang.com', size = 1000 }) {
|
||||
const id = randomBytes(8).toString('hex');
|
||||
await env.DB.prepare("INSERT INTO messages(id,direction,folder,sender,recipients_json,cc_json,bcc_json,subject,text,source,state,received_at,created_at,updated_at,sent_at,raw_status,raw_size,mailbox) VALUES(?,'outbound','sent','a',?,?,?,'s','b','web','sent',?,?,?,?,'stored',?,?)").bind(id, JSON.stringify(recipients), JSON.stringify(cc), JSON.stringify(bcc), at, at, at, at, size, mailbox).run();
|
||||
return id;
|
||||
}
|
||||
async function inboundRow(env, { at, mailbox = 'admin@arihakdang.com', size = 1000 }) {
|
||||
const id = randomBytes(8).toString('hex');
|
||||
await env.DB.prepare("INSERT INTO messages(id,direction,folder,sender,recipients_json,subject,text,source,state,received_at,created_at,updated_at,raw_status,raw_size,mailbox) VALUES(?,'inbound','inbox','x@y.test','[]','s','b','sync','received',?,?,?,'stored',?,?)").bind(id, at, at, at, size, mailbox).run();
|
||||
return id;
|
||||
}
|
||||
|
||||
await t('1) KST 월 경계는 고정 시각으로 결정적이다: 10-31 23:59:59 KST 는 10월, 11-01 00:00:00 KST 는 11월; monthlyCount 는 STATS_NOW 를 따른다', async () => {
|
||||
const b1 = periodBounds('month', 'Asia/Seoul', new Date('2026-10-31T14:59:59Z')); eq(b1.start, '2026-09-30T15:00:00.000Z', '10월 시작'); eq(b1.end, '2026-10-31T15:00:00.000Z', '10월 끝');
|
||||
const b2 = periodBounds('month', 'Asia/Seoul', new Date('2026-10-31T15:00:00Z')); eq(b2.start, '2026-10-31T15:00:00.000Z', '11월 시작'); eq(b2.end, '2026-11-30T15:00:00.000Z', '11월 끝');
|
||||
const { env } = await base();
|
||||
await sentRow(env, { at: '2026-10-31T14:30:00Z' }); await sentRow(env, { at: '2026-10-31T15:30:00Z' }); await inboundRow(env, { at: '2026-10-31T15:10:00Z' });
|
||||
env.STATS_NOW = '2026-10-31T14:59:59Z'; eq(await monthlyCount(env), 1, '10월: 발신 1');
|
||||
env.STATS_NOW = '2026-10-31T15:00:00Z'; eq(await monthlyCount(env), 2, '11월: 발신 1 + 수신 1');
|
||||
eq((await limitState(env)).monthReset, '2026-11-30T15:00:00.000Z', '다음 KST 월 초기화 시각');
|
||||
});
|
||||
|
||||
await t('2) 발신 방식이 smtp/haraka 면 RESEND_API_KEY 없이도 SMTP 제출이 발송된다(예전에는 451)', async () => {
|
||||
// 가짜 중계 서버: EHLO/MAIL/RCPT/DATA/QUIT 에 성공 응답
|
||||
const got = []; const relay = net.createServer((s) => { s.write('220 relay.test ESMTP\r\n'); let data = false, buf = ''; s.on('data', (c) => { buf += c.toString('latin1'); if (data) { if (buf.includes('\r\n.\r\n')) { data = false; buf = ''; s.write('250 2.0.0 queued as relay-1\r\n'); } return; } let i; while ((i = buf.indexOf('\r\n')) >= 0) { const line = buf.slice(0, i); buf = buf.slice(i + 2); got.push(line); if (/^EHLO/i.test(line)) s.write('250-relay.test\r\n250 8BITMIME\r\n'); else if (/^(MAIL|RCPT)/i.test(line)) s.write('250 OK\r\n'); else if (/^DATA/i.test(line)) { data = true; s.write('354 go\r\n'); } else if (/^QUIT/i.test(line)) { s.write('221 bye\r\n'); s.end(); } else s.write('250 OK\r\n'); } }); });
|
||||
await new Promise((r) => relay.listen(0, '127.0.0.1', r)); const port = relay.address().port;
|
||||
try {
|
||||
const env = await makeEnv({ RESEND_API_KEY: '', MAIL_OUTBOUND: 'smtp', SMTP_RELAY_HOST: '127.0.0.1', SMTP_RELAY_PORT: String(port), SMTP_RELAY_SECURITY: 'none' });
|
||||
const user = { id: 'www:admin-1', login: 'arihakdang', name: '관리자', role: 'admin', mailbox: 'admin@arihakdang.com', source: 'www' };
|
||||
const eml = buildEml({ from: 'admin@arihakdang.com', to: ['friend@example.test'], subject: 'relay', text: 'hi', messageId: '<r1@arihakdang.com>' });
|
||||
const r = await submitMessage(env, user, { mailFrom: 'admin@arihakdang.com', rcpt: ['friend@example.test'], eml });
|
||||
ok(r.id && r.providerId, '발송·저장: ' + JSON.stringify(r)); ok(got.some((l) => /^RCPT TO:<friend@example.test>/i.test(l)), '중계 서버가 받음');
|
||||
// resend 방식이면 여전히 키가 필요(기존 동작 유지)
|
||||
let threw = null; try { await submitMessage({ ...env, MAIL_OUTBOUND: 'resend' }, user, { mailFrom: 'admin@arihakdang.com', rcpt: ['friend@example.test'], eml }); } catch (e) { threw = e; } eq(threw?.code, 451, 'resend 방식은 키 없으면 451');
|
||||
} finally { relay.close(); }
|
||||
});
|
||||
|
||||
await t('3) 등록부 ariweb(사이트 대표/독립)·operating 을 설정 API 로 정하고 목록·사용량 API 에 보인다', async () => {
|
||||
const { env } = await base(); const tenants = open(env); setupToken(env);
|
||||
const e = normalizeEntry({ domain: 'x.test', ariweb: { kind: 'standalone', mailServiceId: 'ms_1', groupId: 'g_1' }, operating: 'migrating_in' }); eq(e.ariweb.kind, 'standalone', '정규화'); eq(e.operating, 'migrating_in', '운영 상태');
|
||||
let threw = null; try { normalizeEntry({ domain: 'x.test', ariweb: { kind: 'nope' } }); } catch (x) { threw = x; } eq(threw?.field, 'ariweb.kind', '잘못된 종류 거부');
|
||||
threw = null; try { normalizeEntry({ domain: 'x.test', operating: 'gone' }); } catch (x) { threw = x; } eq(threw?.field, 'operating', '잘못된 운영 상태 거부');
|
||||
await tenants.add({ domain: 'second.test', adminPassword: 'secondpw1' });
|
||||
let r = await (await setup(env, 'domains/second.test', 'PATCH', { ariweb: { kind: 'site_bound', siteId: 's_abc', groupId: 'g_1', mailServiceId: 'ms_2' } }, tenants)).json(); eq(r.domain.ariweb.siteId, 's_abc', 'PATCH 반영');
|
||||
r = await (await setup(env, 'domains/arihakdang.com', 'PATCH', { ariweb: { kind: 'standalone', mailServiceId: 'ms_0' }, operating: 'frozen' }, tenants)).json(); eq(r.domain.ariweb.kind, 'standalone', '기본 도메인도 가능'); eq(r.domain.operating, 'frozen', '기본 도메인 운영 상태');
|
||||
const list = (await (await setup(env, 'domains', 'GET', undefined, tenants)).json()).domains; eq(list.find((d) => d.domain === 'second.test').ariweb.kind, 'site_bound', '목록'); eq(list[0].operating, 'frozen', '목록 운영 상태');
|
||||
const { key } = await (await setup(env, 'usage-key', 'POST', { action: 'generate' }, tenants)).json();
|
||||
const u = await (await usageApi(tenants, new Request('https://x/api/usage/second.test', { headers: { Authorization: 'Bearer ' + key } }), { path: ['second.test'], ip: '1.1.1.1', query: new URLSearchParams() })).json();
|
||||
eq(u.ariweb.kind, 'site_bound', '사용량 API 에 ariweb'); eq(u.operating, 'active', '사용량 API 에 운영 상태'); eq(u.warnLevel, 0, '경고 구간'); eq(u.limitedReasons.length, 0, '제한 없음'); eq(u.traffic.countingRule, 'per_final_recipient', '통수 규칙');
|
||||
tenants.close();
|
||||
});
|
||||
|
||||
await t('4) 도메인별 보장량은 설정 파일이 환경보다 우선, 발신 통수는 최종 수신자 기준, 저장 공간은 대용량 첨부 포함', async () => {
|
||||
const { env } = await base({ LIMIT_MONTHLY_MESSAGES: '100', LIMIT_TOTAL_BYTES: '1000000', LIMIT_ACCOUNTS: '5' });
|
||||
eq(systemLimits(env).monthlyMessages, 100, '환경 값');
|
||||
saveConfig(env, { LIMIT_MONTHLY_MESSAGES: 10, LIMIT_TOTAL_BYTES: '5000', LIMIT_ACCOUNTS: 2 });
|
||||
const l = systemLimits(env); eq(l.monthlyMessages, 10, '설정 파일 우선'); eq(l.totalBytes, 5000, '크기 접미사 없는 값'); eq(l.accounts, 2, '계정 수');
|
||||
saveConfig(env, { LIMIT_MONTHLY_MESSAGES: '' }); eq(systemLimits(env).monthlyMessages, 100, '비우면 환경 값으로');
|
||||
env.STATS_NOW = '2026-10-15T03:00:00Z';
|
||||
await sentRow(env, { at: '2026-10-10T03:00:00Z', recipients: ['a@x.test', 'b@x.test'], cc: ['c@x.test'], bcc: ['d@x.test'] }); await sentRow(env, { at: '2026-10-11T03:00:00Z', recipients: [] }); await inboundRow(env, { at: '2026-10-12T03:00:00Z' });
|
||||
const c = await countMail(env, periodBounds('month', 'Asia/Seoul', new Date(env.STATS_NOW))); eq(c.sent, 5, '수신자 4 + 기록 없는 메일 1'); eq(c.sentMessages, 2, '메일 수'); eq(c.received, 1, '수신'); eq(c.total, 6, '합계');
|
||||
const before = await totalUsage(env);
|
||||
await env.DB.prepare("INSERT INTO large_files(id,message_id,mailbox,filename,size,state,created_at) VALUES('lf1',NULL,'admin@arihakdang.com','big.bin',4000,'sent',?)").bind(new Date().toISOString()).run();
|
||||
eq(await totalUsage(env), before + 4000, '대용량 첨부 포함'); eq(await mailboxUsage(env, 'admin@arihakdang.com'), before + 4000, '메일함 사용량에도 포함');
|
||||
});
|
||||
|
||||
await t('5) 100%: 저장 공간이 차면 새 수신 임시 거부(읽기·삭제 유지), 월간 통수가 차면 발신 차단(mail_monthly_limit)·수신 임시 거부, 다음 KST 월에 풀린다', async () => {
|
||||
const { env } = await base({ LIMIT_MONTHLY_MESSAGES: '2', RESEND_FETCH: fakeResend({}) });
|
||||
env.STATS_NOW = '2026-10-20T03:00:00Z';
|
||||
await sentRow(env, { at: '2026-10-10T03:00:00Z' }); await sentRow(env, { at: '2026-10-11T03:00:00Z' });
|
||||
eq(await checkInbound(env, 'admin@arihakdang.com'), 'monthly_limit', '수신 임시 거부');
|
||||
let threw = null; try { await checkOutbound(env, { mailbox: 'admin@arihakdang.com', recipients: 1 }); } catch (e) { threw = e; } eq(threw?.message, 'monthly_limit_exceeded', '발신 차단'); eq(threw?.code, 'mail_monthly_limit', '공통 코드'); eq(threw?.resetAt, '2026-10-31T15:00:00.000Z', '해제 시각');
|
||||
const st = await limitState(env); eq(st.warnLevel, 100, '구간 100'); eq(st.limitedReasons.join(','), 'monthly_limit', '사유');
|
||||
// Haraka 수신 전달도 건너뜀(플러그인이 DENYSOFT)
|
||||
const eml = buildEml({ from: 'x@example.test', to: ['admin@arihakdang.com'], subject: 'in', text: 'hi', messageId: '<in1@example.test>' });
|
||||
eq((await ingestRaw(env, eml, { id: 'tx1', rcpt: ['admin@arihakdang.com'], from: 'x@example.test' })).skipped, 'monthly_limit', 'Haraka 수신 건너뜀');
|
||||
// 기존 메일 읽기·삭제는 그대로(API 목록 조회 가능)
|
||||
const cookie = (await api(env, 'login', '', 'POST', { login: 'arihakdang', password: 'admin-secret-12' })).headers.get('set-cookie').split(';')[0];
|
||||
eq((await api(env, 'mail?folder=sent', cookie)).status, 200, '읽기 유지');
|
||||
const d = await saveDraft(env, { to: 'a@example.test', subject: 's', html: '<p>x</p>' }); const r = await (await api(env, 'mail/' + d + '/send', cookie, 'POST', {})).json(); eq(r.error, 'monthly_limit_exceeded', '웹 발송 차단'); eq(r.code, 'mail_monthly_limit', '응답에 공통 코드'); eq(r.resetAt, '2026-10-31T15:00:00.000Z', '응답에 해제 시각');
|
||||
env.STATS_NOW = '2026-11-01T03:00:00Z'; eq(await checkInbound(env, 'admin@arihakdang.com'), null, '다음 달엔 수신 가능'); eq((await limitState(env)).warnLevel, 0, '구간 초기화');
|
||||
// 저장 공간 100%
|
||||
const env2 = (await base({ LIMIT_TOTAL_BYTES: '1500' })).env; await inboundRow(env2, { at: '2026-10-01T00:00:00Z', size: 1500 });
|
||||
eq(await checkInbound(env2, 'admin@arihakdang.com'), 'storage_full', '저장 공간 100% 수신 거부'); eq((await limitState(env2)).limitedReasons.join(','), 'storage_full', '사유');
|
||||
const env3 = (await base({ LIMIT_TOTAL_BYTES: '2000' })).env; await inboundRow(env3, { at: '2026-10-01T00:00:00Z', size: 1900 }); eq((await limitState(env3)).warnLevel, 95, '95% 경고');
|
||||
});
|
||||
|
||||
await t('6) 발송 속도(도메인·서버 초당 5건)·제출 제한(초당 10건 → 60초 제한·악용 표)·대기열 기록·재시작 복구', async () => {
|
||||
let nowMs = 1_000_000; sq.setClock(() => nowMs); const slept = []; sq.setSleep(async (ms) => { slept.push(ms); nowMs += ms; }); sq.resetRates();
|
||||
try {
|
||||
for (let i = 0; i < 5; i++) await sq.pace('a.test'); eq(slept.length, 0, '처음 5건은 즉시');
|
||||
await sq.pace('a.test'); ok(slept.length >= 1 && slept[0] >= 150 && slept[0] <= 200, '6번째는 ~200ms 대기: ' + slept[0]);
|
||||
// 다른 도메인이라도 서버 버킷(초당 5)을 함께 씁니다
|
||||
slept.length = 0; nowMs += 2000; for (let i = 0; i < 5; i++) await sq.pace('b.test'); await sq.pace('c.test'); ok(slept.length >= 1, '서버 전체 버킷 적용');
|
||||
// 제출 제한
|
||||
const { env } = await base(); let th = null;
|
||||
for (let i = 0; i < 12; i++) { th = await sq.submit(env, ['account:k@a.test', 'ip:1.2.3.4']); if (th.throttled) break; }
|
||||
ok(th.throttled, '초당 10건에서 제한'); eq(th.rate, 10, '제출량'); ok(th.until, '해제 시각');
|
||||
const flags = await sq.abuseList(env); eq(flags.length, 1, '악용 확인 표 1건'); eq(flags[0].subject, 'account:k@a.test', '대상');
|
||||
const snap = sq.rateSnapshot(); ok(snap.submissions.find((s) => s.key === 'account:k@a.test')?.throttledUntil, '운영자 화면: 제한 해제 시각'); eq(snap.deliveryRate, 5, '전달 상한');
|
||||
nowMs += sq.THROTTLE_WINDOW_MS + 1; eq((await sq.submit(env, ['account:k@a.test'])).throttled, false, '창이 지나면 정상 발송 재개');
|
||||
// 대기열: 발송 성공 → sent, 같은 키 재전송은 발송 모듈 호출 없음, 실패 → failed, 재시작 → interrupted
|
||||
const calls = []; const env2 = await makeEnv({ RESEND_FETCH: fakeResend({ calls }) }); const { sendMail } = await import('../server/providers/index.mjs');
|
||||
const r1 = await sendMail(env2, { from: 'admin@arihakdang.com', to: ['a@x.test'], subject: 's', text: 'b' }, 'key-1'); ok(r1.id, '발송');
|
||||
const r2 = await sendMail(env2, { from: 'admin@arihakdang.com', to: ['a@x.test'], subject: 's', text: 'b' }, 'key-1'); eq(r2.duplicate, true, '같은 키는 중복 발송 안 함'); eq(calls.filter((c) => c.path === '/emails' && c.method === 'POST').length, 1, '발송 모듈 1회');
|
||||
env2.RESEND_FETCH = fakeResend({ failStatus: 500 }); let err = null; try { await sendMail(env2, { from: 'admin@arihakdang.com', to: ['b@x.test'], subject: 's', text: 'b' }, 'key-2'); } catch (e) { err = e; } ok(err, '실패');
|
||||
const rows = (await env2.DB.prepare('SELECT send_key, state FROM outbound_queue ORDER BY created_at').all()).results; eq(rows.map((r) => r.state).join(','), 'sent,failed', '대기열 상태');
|
||||
await env2.DB.prepare("UPDATE outbound_queue SET state='sending' WHERE send_key='key-2'").run(); eq(await sq.recoverQueue(env2), 1, '재시작 복구: interrupted'); eq((await sq.queueList(env2))[0].state, 'interrupted', '목록 맨 앞');
|
||||
ok(!JSON.stringify(await sq.queueList(env2)).includes('"text"'), '대기열에 본문 없음');
|
||||
} finally { sq.setClock(null); sq.setSleep(null); sq.resetRates(); }
|
||||
});
|
||||
|
||||
await t('7) 운영 상태가 active 가 아니면 수신 임시 거부(domain_not_active)·발신 차단(mail_domain_frozen)', async () => {
|
||||
const { env } = await base(); const tenants = open(env); setupToken(env);
|
||||
await setup(env, 'domains/arihakdang.com', 'PATCH', { operating: 'migrating_out' }, tenants);
|
||||
eq(await checkInbound(env, 'admin@arihakdang.com'), 'domain_not_active', '수신 거부');
|
||||
let threw = null; try { await checkOutbound(env, { mailbox: 'admin@arihakdang.com', recipients: 1 }); } catch (e) { threw = e; } eq(threw?.message, 'mail_domain_frozen', '발신 차단');
|
||||
await setup(env, 'domains/arihakdang.com', 'PATCH', { operating: 'active' }, tenants); eq(await checkInbound(env, 'admin@arihakdang.com'), null, '복귀');
|
||||
tenants.close();
|
||||
});
|
||||
|
||||
await t('8) 포털 SSO 티켓: 서명·만료·재사용·도메인을 검사하고 메일함 세션 쿠키로 웹 메일에 들어간다', async () => {
|
||||
const { env } = await base(); const secret = randomBytes(24).toString('base64url');
|
||||
const ticket = (payload, sec = secret) => { const p = Buffer.from(JSON.stringify(payload)).toString('base64url'); return p + '.' + createHmac('sha256', sec).update(p).digest('base64url'); };
|
||||
eq((await api(env, 'auth/sso?ticket=' + ticket({ mailbox: 'admin@arihakdang.com', exp: Date.now() + 60000, nonce: 'n0' }))).status, 401, '비밀 미설정이면 거부');
|
||||
saveConfig(env, { ARIWEB_SSO_SECRET: secret }, { allowHidden: true });
|
||||
const good = ticket({ mailbox: 'admin@arihakdang.com', exp: Date.now() + 60000, nonce: 'n1' });
|
||||
const r = await api(env, 'auth/sso?ticket=' + encodeURIComponent(good)); eq(r.status, 302, '세션 발급·이동'); ok(/mg_session=/.test(r.headers.get('set-cookie') || ''), '세션 쿠키'); eq(r.headers.get('location'), '/', '웹 메일로');
|
||||
const cookie = r.headers.get('set-cookie').split(';')[0]; eq((await (await api(env, 'session', cookie)).json()).user.mailbox, 'admin@arihakdang.com', '세션의 메일함');
|
||||
eq((await (await api(env, 'auth/sso?ticket=' + encodeURIComponent(good))).json()).error, 'sso_replayed', '재사용 거부');
|
||||
eq((await (await api(env, 'auth/sso?ticket=' + encodeURIComponent(ticket({ mailbox: 'admin@arihakdang.com', exp: Date.now() + 60000, nonce: 'n2' }, 'other')))).json()).error, 'sso_bad_signature', '서명 불일치');
|
||||
eq((await (await api(env, 'auth/sso?ticket=' + encodeURIComponent(ticket({ mailbox: 'admin@arihakdang.com', exp: Date.now() - 1, nonce: 'n3' })))).json()).error, 'sso_expired', '만료');
|
||||
eq((await (await api(env, 'auth/sso?ticket=' + encodeURIComponent(ticket({ mailbox: 'admin@other.test', exp: Date.now() + 60000, nonce: 'n4' })))).json()).error, 'sso_wrong_domain', '다른 도메인');
|
||||
eq((await api(env, 'auth/sso?ticket=' + encodeURIComponent(ticket({ mailbox: 'nobody@arihakdang.com', exp: Date.now() + 60000, nonce: 'n5' })))).status, 404, '없는 메일함');
|
||||
ok(!JSON.stringify(await (await api(env, 'admin/rates', cookie, 'GET', undefined, { 'X-Mail-Scope': 'admin' })).json()).includes(secret), '관리자 응답에 비밀 없음');
|
||||
});
|
||||
|
||||
done();
|
||||
@@ -119,7 +119,7 @@ await t('0004 마이그레이션은 기존 만료일을 비우고 Resend 형식
|
||||
for (const f of readdirSync(all).filter((x) => x < '0004')) copyFileSync(join(all, f), join(old, f));
|
||||
const db = openDb(':memory:'); migrate(db, old, { quiet: true });
|
||||
db.prepare("INSERT INTO messages(id,direction,folder,received_at,sent_at,created_at,updated_at,expires_at) VALUES('m1','outbound','sent','2026-09-28 06:20:26.514000+00','2026-09-28 06:20:26.514000+00','x','x','2026-10-28T06:20:26.514Z'),('m2','inbound','inbox','2026-09-28T07:00:00.000Z',NULL,'x','x','2026-10-28T07:00:00.000Z')").run();
|
||||
eq(migrate(db, all, { quiet: true }), 5, '0004·0005·0006·0007·0008 적용');
|
||||
eq(migrate(db, all, { quiet: true }), 6, '0004·0005·0006·0007·0008·0009 적용'); // [Ariweb Suite 변경] 0009 발송 대기열
|
||||
const rows = db.prepare('SELECT id,received_at,sent_at,expires_at FROM messages ORDER BY received_at DESC').all();
|
||||
eq(rows.map((r) => r.id).join(','), 'm2,m1', '정렬이 실제 시각 순서');
|
||||
eq(rows[1].received_at, '2026-09-28T06:20:26.514Z', 'received_at ISO'); eq(rows[1].sent_at, '2026-09-28T06:20:26.514Z', 'sent_at ISO');
|
||||
@@ -813,7 +813,8 @@ await t('0.9.0 제한: 시스템(환경)·관리자·계정별 메일함 크기,
|
||||
const d4 = await saveDraft(env, { to: 'a@example.test', subject: '월간', html: '<p>x</p>' });
|
||||
r = await api(env, 'mail/' + d4 + '/send', cookie, 'POST', {}); const e4 = await r.json(); eq(e4.error, 'monthly_limit_exceeded', '월간 초과'); eq(e4.limit, 5, '상한');
|
||||
await writePrefs(env, { id: 'www:admin-1', mailbox: 'admin@arihakdang.com' }, { forward: { enabled: true, to: 'fw@example.test' } });
|
||||
env.RESEND_FETCH = fakeResend({ received: [sample('q3')] }); const q3 = await ingestReceived(env, 'q3'); eq(q3.auto?.forward?.detail, 'limit', '자동 전달 건너뜀(월간 한도)');
|
||||
// [Ariweb Suite 변경] 월간 통수 100% 에서는 새 수신도 임시 거부(skipped='monthly_limit', 보낸 서버가 재시도)합니다(Dev-Order §13.4). 예전에는 받되 자동 전달만 건너뛰었습니다.
|
||||
env.RESEND_FETCH = fakeResend({ received: [sample('q3')] }); const q3 = await ingestReceived(env, 'q3'); eq(q3.skipped, 'monthly_limit', '월간 한도에서 새 수신 임시 거부');
|
||||
// 현황
|
||||
const st = await mailStats(env, await knownMailboxes(env), await listUsers(env));
|
||||
eq(st.timezone, 'Asia/Seoul', '시간대'); ok(st.periods.month.total >= 5, '월간 합계'); ok(st.periods.day.received >= 1, '일일 수신');
|
||||
|
||||
@@ -191,7 +191,9 @@ await t('0.8.25 오류 수정: 같은 DATA 를 받는 사람만 바꿔 따로
|
||||
const c2 = client(await new Promise((res) => { const s = net.connect(port, '127.0.0.1', () => res(s)); }));
|
||||
await c2.reply(); await c2.cmd('EHLO x'); await c2.cmd('AUTH PLAIN ' + Buffer.from('\0arihakdang\0admin-secret-12').toString('base64'));
|
||||
await c2.cmd('MAIL FROM:<admin@arihakdang.com>'); await c2.cmd('RCPT TO:<alice@example.test>'); await c2.cmd('DATA'); c2.raw(data); await c2.reply(); await c2.cmd('QUIT');
|
||||
const again = calls.filter((x) => x.path === '/emails' && x.method === 'POST'); eq(again[2].key, posts[0].key, '재전송은 같은 키');
|
||||
// [Ariweb Suite 변경] 같은 키의 재전송은 발송 대기열(outbound_queue)이 "이미 보냄" 으로 알아 발송 모듈을 다시 부르지 않고 같은 발송 id 로 250 을 돌려줍니다(두 번 발송 없음).
|
||||
const again = calls.filter((x) => x.path === '/emails' && x.method === 'POST'); eq(again.length, 2, '재전송은 발송 모듈을 다시 부르지 않음');
|
||||
const q = (await env.DB.prepare("SELECT state, send_key FROM outbound_queue ORDER BY created_at").all()).results; eq(q.length, 2, '대기열 두 건'); ok(q.every((r) => r.state === 'sent'), '모두 sent');
|
||||
} finally { smtp.close(); }
|
||||
});
|
||||
|
||||
|
||||
@@ -39,7 +39,9 @@ async function putMail(env, { direction, at, size = 1000, mailbox, state }) {
|
||||
.bind(id, direction, outbound ? 'sent' : 'inbox', 'a@b.test', '["x@y.test"]', 's', 'body', outbound ? 'web' : 'sync', state || (outbound ? 'sent' : 'received'), at, at, at, outbound ? at : null, 'stored', size, mailbox).run();
|
||||
return id;
|
||||
}
|
||||
const daysAgo = (n, hour = 12) => { const d = new Date(); d.setUTCDate(d.getUTCDate() - n); d.setUTCHours(hour, 0, 0, 0); return d.toISOString(); };
|
||||
// [Ariweb Suite 변경] 결정적 시각: 예전에는 UTC 12시 기준이라 UTC 15:00~24:00(= KST 다음 날 0~9시)에 실행하면 "오늘" 이 KST 어제가 되어 오늘 칸이 비었습니다.
|
||||
// 이제 통계 시간대(Asia/Seoul)의 오늘 0시를 periodBounds 로 구하고 그 날 정오(KST)에서 n 일을 뺍니다 — 어느 시각에 실행해도 같은 칸에 들어갑니다.
|
||||
const daysAgo = (n) => new Date(Date.parse(periodBounds('day', 'Asia/Seoul').start) + 12 * 3600 * 1000 - n * 86400000).toISOString();
|
||||
|
||||
await t('IP 허용 목록: 빈 목록은 모두 허용, IPv4 정확히·CIDR, IPv6 정확히·접두사, ::ffff: 로 감싼 IPv4, 잘못된 주소는 거부', () => {
|
||||
eq(parseAllowList(' 1.2.3.4, 10.0.0.0/8 ,2001:db8::1 ').join('|'), '1.2.3.4|10.0.0.0/8|2001:db8::1', '파싱');
|
||||
|
||||
@@ -99,7 +99,7 @@ function domainsCard() {
|
||||
const cur = d.domain === DOMAIN;
|
||||
const btns = [`<button type="button" class="small ${cur ? '' : 'primary'}" data-domain-select="${esc(d.domain)}" ${cur ? 'disabled' : ''}>${cur ? '보는 중' : '선택'}</button>`, `<button type="button" class="small" data-domain-edit="${esc(d.domain)}">수정</button>`];
|
||||
if (!d.primary) btns.push(`<button type="button" class="small" data-domain-toggle="${esc(d.domain)}" data-enabled="${d.enabled ? '1' : '0'}">${d.enabled ? '중지' : '사용'}</button>`, `<button type="button" class="small ghost" data-domain-remove="${esc(d.domain)}">삭제</button>`);
|
||||
return `<tr class="${cur ? 'current' : ''} ${d.enabled ? '' : 'off'}"><td><code>${esc(d.domain)}</code>${d.primary ? ' <span class="tag">기본</span>' : ''}${d.note ? `<div class="meta muted">${esc(d.note)}</div>` : ''}</td><td class="mono">${d.hosts.map(esc).join('<br>')}</td><td>${d.enabled ? tag(true, '사용', '') : tag(false, '', '중지')}</td><td>${esc(AUTH_LABEL[d.auth.mode] || d.auth.mode)}${d.auth.mode === 'www' ? `<div class="meta muted">${esc(d.auth.wwwDbFile)}</div>` : ''}${d.auth.mode === 'webmaker' ? `<div class="meta muted">${esc(d.auth.webmakerDir)}${d.auth.webmakerUser ? ` (sudo -u ${esc(d.auth.webmakerUser)})` : ''}</div>${d.external && d.external.error ? `<div class="meta warn">읽기 실패: ${esc(d.external.error)}</div>` : ''}` : ''}</td><td>${esc(MODES.inbound[d.inbound] ? MODES.inbound[d.inbound][0] : d.inbound)} / ${esc(MODES.outbound[d.outbound] ? MODES.outbound[d.outbound][0] : d.outbound)}</td><td>${d.accounts}명${d.external && !d.external.error ? ` + 웹메이커 ${d.external.count}명` : ''} · ${d.messages}통${d.initialPassword ? `<div class="meta warn">초기 관리자 ${esc(d.mailbox.split('@')[0])} / ${esc(d.initialPassword)}</div>` : ''}</td><td class="actions-cell">${btns.join('')}</td></tr>`;
|
||||
return `<tr class="${cur ? 'current' : ''} ${d.enabled ? '' : 'off'}"><td><code>${esc(d.domain)}</code>${d.primary ? ' <span class="tag">기본</span>' : ''}${d.note ? `<div class="meta muted">${esc(d.note)}</div>` : ''}</td><td class="mono">${d.hosts.map(esc).join('<br>')}</td><td>${d.enabled ? tag(true, '사용', '') : tag(false, '', '중지')}${d.operating && d.operating !== 'active' ? `<div class="meta warn">운영 상태: ${esc({ migrating_out: '이전 중(보내는 쪽)', migrating_in: '이전 중(받는 쪽)', frozen: '동결' }[d.operating] || d.operating)}</div>` : ''}${d.ariweb ? `<div class="meta muted">Ariweb: ${esc(d.ariweb.kind === 'site_bound' ? '사이트 대표 메일' : '독립 메일 서비스')}${d.ariweb.siteId ? ' · 사이트 ' + esc(d.ariweb.siteId) : ''}</div>` : ''}</td><td>${esc(AUTH_LABEL[d.auth.mode] || d.auth.mode)}${d.auth.mode === 'www' ? `<div class="meta muted">${esc(d.auth.wwwDbFile)}</div>` : ''}${d.auth.mode === 'webmaker' ? `<div class="meta muted">${esc(d.auth.webmakerDir)}${d.auth.webmakerUser ? ` (sudo -u ${esc(d.auth.webmakerUser)})` : ''}</div>${d.external && d.external.error ? `<div class="meta warn">읽기 실패: ${esc(d.external.error)}</div>` : ''}` : ''}</td><td>${esc(MODES.inbound[d.inbound] ? MODES.inbound[d.inbound][0] : d.inbound)} / ${esc(MODES.outbound[d.outbound] ? MODES.outbound[d.outbound][0] : d.outbound)}</td><td>${d.accounts}명${d.external && !d.external.error ? ` + 웹메이커 ${d.external.count}명` : ''} · ${d.messages}통${d.initialPassword ? `<div class="meta warn">초기 관리자 ${esc(d.mailbox.split('@')[0])} / ${esc(d.initialPassword)}</div>` : ''}</td><td class="actions-cell">${btns.join('')}</td></tr>`;
|
||||
}).join('');
|
||||
const e = EDIT, editing = !!e, isPrimaryEdit = editing && e.primary;
|
||||
const form = `<form id="f-domain" class="domain-form ${editing ? 'editing' : ''}"><h3>${editing ? `도메인 수정 — ${esc(e.domain)}` : '도메인 추가'}</h3><div class="grid">
|
||||
|
||||
Reference in New Issue
Block a user