Ariweb Suite 0.1.1 기반: 공통 라이브러리(db·http·세션·작업 엔진·할당/예약·알림·감사·보관소), 포털 뼈대, Webmaker/AriMail 도입 소스와 manifest

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
zzabisagent-lab
2026-10-26 11:02:58 +00:00
co-authored by Claude Fable 5.1
parent 9ca4cee341
commit c7aea8d419
295 changed files with 90146 additions and 0 deletions
+81
View File
@@ -0,0 +1,81 @@
-- 메일 게이트웨이 저장소. www 사이트의 DB와 분리하며 인증만 www DB(users)를 읽습니다.
CREATE TABLE messages (
id TEXT PRIMARY KEY,
provider_id TEXT UNIQUE,
message_id TEXT,
direction TEXT NOT NULL CHECK(direction IN ('inbound','outbound')),
folder TEXT NOT NULL CHECK(folder IN ('inbox','sent','drafts','trash')),
previous_folder TEXT,
sender TEXT NOT NULL DEFAULT '',
recipients_json TEXT NOT NULL DEFAULT '[]',
cc_json TEXT NOT NULL DEFAULT '[]',
bcc_json TEXT NOT NULL DEFAULT '[]',
reply_to_json TEXT NOT NULL DEFAULT '[]',
subject TEXT NOT NULL DEFAULT '',
html TEXT,
text TEXT NOT NULL DEFAULT '',
original_html TEXT,
headers_json TEXT NOT NULL DEFAULT '{}',
source TEXT NOT NULL DEFAULT 'sync' CHECK(source IN ('webhook','sync','web')),
state TEXT NOT NULL DEFAULT 'received' CHECK(state IN ('draft','sending','sent','received','failed')),
send_key TEXT,
send_started_at TEXT,
read_at TEXT,
received_at TEXT NOT NULL,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
sent_at TEXT,
raw_status TEXT NOT NULL DEFAULT 'none' CHECK(raw_status IN ('none','stored','built','unavailable')),
raw_size INTEGER NOT NULL DEFAULT 0,
expires_at TEXT
);
CREATE INDEX messages_folder_date ON messages(folder, received_at DESC);
CREATE INDEX messages_expires ON messages(expires_at);
CREATE INDEX messages_pop ON messages(direction, folder, received_at);
-- 원본(RFC 5322 .eml)은 그대로 보관합니다. POP 은 이 바이트를 그대로 전달합니다.
CREATE TABLE message_raw (
message_id TEXT PRIMARY KEY REFERENCES messages(id) ON DELETE CASCADE,
eml BLOB NOT NULL,
stored_at TEXT NOT NULL
);
CREATE TABLE attachments (
id TEXT PRIMARY KEY,
message_id TEXT NOT NULL REFERENCES messages(id) ON DELETE CASCADE,
provider_id TEXT,
filename TEXT NOT NULL DEFAULT '',
content_type TEXT NOT NULL DEFAULT 'application/octet-stream',
content_id TEXT,
disposition TEXT NOT NULL DEFAULT 'attachment',
size INTEGER NOT NULL DEFAULT 0,
bytes BLOB,
created_at TEXT NOT NULL
);
CREATE INDEX attachments_message ON attachments(message_id);
-- 영구 삭제하거나 보관 기간이 지난 메일을 동기화가 다시 가져오지 않게 합니다.
CREATE TABLE tombstones (provider_id TEXT PRIMARY KEY, deleted_at TEXT NOT NULL, reason TEXT NOT NULL DEFAULT 'deleted');
CREATE TABLE sessions (
id TEXT PRIMARY KEY, user_id TEXT NOT NULL, login TEXT NOT NULL,
expires_at TEXT NOT NULL, created_at TEXT NOT NULL, ip TEXT, ua TEXT
);
-- POP 으로 한 번 전달한 메일은 같은 계정에 다시 내려주지 않습니다.
CREATE TABLE pop_deliveries (
user_id TEXT NOT NULL,
message_id TEXT NOT NULL REFERENCES messages(id) ON DELETE CASCADE,
delivered_at TEXT NOT NULL,
via TEXT NOT NULL DEFAULT 'retr' CHECK(via IN ('retr','dele')),
PRIMARY KEY(user_id, message_id)
);
CREATE TABLE webhook_events (
id TEXT PRIMARY KEY, type TEXT NOT NULL, provider_id TEXT,
received_at TEXT NOT NULL, status TEXT NOT NULL, detail TEXT
);
CREATE INDEX webhook_events_date ON webhook_events(received_at DESC);
CREATE TABLE sync_runs (
id INTEGER PRIMARY KEY AUTOINCREMENT, trigger TEXT NOT NULL DEFAULT 'timer',
started_at TEXT NOT NULL, finished_at TEXT, imported INTEGER NOT NULL DEFAULT 0,
status TEXT NOT NULL DEFAULT 'running', detail TEXT
);
CREATE TABLE audit_log (
id TEXT PRIMARY KEY, actor TEXT, action TEXT NOT NULL, target TEXT, detail TEXT, created_at TEXT NOT NULL
);
CREATE TABLE settings (key TEXT PRIMARY KEY, value TEXT NOT NULL, updated_at TEXT NOT NULL);
+16
View File
@@ -0,0 +1,16 @@
-- IMAP: 메일함별 UID(전역 단조 증가)와 영구 플래그.
CREATE TABLE imap_uids (
id INTEGER PRIMARY KEY AUTOINCREMENT,
message_id TEXT NOT NULL REFERENCES messages(id) ON DELETE CASCADE,
folder TEXT NOT NULL,
UNIQUE(message_id, folder)
);
CREATE TABLE imap_flags (
message_id TEXT PRIMARY KEY REFERENCES messages(id) ON DELETE CASCADE,
flags TEXT NOT NULL DEFAULT '[]',
updated_at TEXT NOT NULL
);
INSERT INTO settings(key,value,updated_at) VALUES('imap_uidvalidity', CAST(strftime('%s','now') AS TEXT), strftime('%Y-%m-%dT%H:%M:%SZ','now'));
INSERT INTO imap_uids(message_id, folder) SELECT id, folder FROM messages ORDER BY received_at, id;
-- 보낸 메일·초안은 읽음 상태로 둡니다. IMAP 의 \Seen 은 read_at 으로 표현합니다.
UPDATE messages SET read_at = COALESCE(read_at, sent_at, received_at) WHERE direction = 'outbound';
@@ -0,0 +1,19 @@
-- 게이트웨이 사용자 계정과 메일함 분리.
-- www 관리자 계정은 계속 www DB 로 인증하며(별칭 admin 포함), 여기 계정은 관리자가 만드는 추가 사용자입니다.
CREATE TABLE mail_users (
id TEXT PRIMARY KEY,
login TEXT NOT NULL UNIQUE,
name TEXT NOT NULL DEFAULT '',
mailbox TEXT NOT NULL,
role TEXT NOT NULL DEFAULT 'user' CHECK(role IN ('admin','user')),
status TEXT NOT NULL DEFAULT 'active' CHECK(status IN ('active','suspended')),
password_hash TEXT NOT NULL,
created_by TEXT,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
last_login_at TEXT
);
CREATE INDEX mail_users_mailbox ON mail_users(mailbox);
-- 메시지가 속한 메일함 주소. 기존 메시지는 기본 메일함(admin@arihakdang.com)입니다.
ALTER TABLE messages ADD COLUMN mailbox TEXT NOT NULL DEFAULT 'admin@arihakdang.com';
CREATE INDEX messages_mailbox_folder ON messages(mailbox, folder, received_at DESC);
@@ -0,0 +1,20 @@
-- 0.8.0: 메일 보관 기간(30일) 제한 삭제와 시각 형식 통일.
--
-- 1) 보관 기간 제한 삭제
-- 예전 릴리스는 메일마다 expires_at(수신 후 30일)을 두고 매시간 지난 메일을 원본·첨부와 함께 지웠습니다.
-- 이제 메일은 사용자가 영구 삭제할 때까지 보관합니다. 열 자체는 지우지 않고 값만 비웁니다.
-- 열을 남기는 이유: 배포 실패로 이전 릴리스로 되돌아가도 그 코드가 넣는 INSERT 가 깨지지 않게 하기 위해서입니다.
-- 값이 NULL 이면 이전 릴리스의 정리 작업(expires_at IS NOT NULL 조건)도 이 메일들을 지우지 않습니다.
UPDATE messages SET expires_at = NULL WHERE expires_at IS NOT NULL;
-- 만료 정리에만 쓰던 색인은 필요 없습니다.
DROP INDEX IF EXISTS messages_expires;
-- 2) 시각 형식 통일
-- Resend 보낸 메일 목록은 "2026-09-28 06:20:26.514000+00" 형식(공백 구분, 분 없는 시간대)을 돌려주고,
-- 0.8.0 이전에는 이 값을 그대로 received_at·sent_at 에 저장했습니다. 다른 메일은 ISO 형식(…T…Z)이라
-- 같은 날짜에서 문자열 정렬이 시각과 무관하게 뒤섞였습니다. "+00" 뒤에 ":00" 을 붙여 SQLite 가 해석할 수 있게 한 뒤
-- ISO 8601 UTC(밀리초 3자리, Z)로 바꿉니다. 이미 ISO 형식인 값은 GLOB 조건에 맞지 않아 그대로 둡니다.
UPDATE messages SET received_at = strftime('%Y-%m-%dT%H:%M:%fZ', received_at || ':00')
WHERE received_at GLOB '[0-9][0-9][0-9][0-9]-[0-9][0-9]-[0-9][0-9] [0-9][0-9]:[0-9][0-9]:[0-9][0-9]*[+-][0-9][0-9]';
UPDATE messages SET sent_at = strftime('%Y-%m-%dT%H:%M:%fZ', sent_at || ':00')
WHERE sent_at GLOB '[0-9][0-9][0-9][0-9]-[0-9][0-9]-[0-9][0-9] [0-9][0-9]:[0-9][0-9]:[0-9][0-9]*[+-][0-9][0-9]';
@@ -0,0 +1,16 @@
-- 0.8.0: 메일이 이전에 있던 IMAP 메일함으로 돌아올 때 새 UID 를 받게 합니다.
--
-- 문제: imap_uids 는 (message_id, folder) 마다 UID 한 개를 두고 INSERT OR IGNORE 로 채웁니다.
-- 메일이 다른 폴더로 나갔다가(휴지통 이동·IMAP MOVE·웹 "이동") 돌아오면 예전 행이 남아 있어 옛 UID 를 다시 썼습니다.
-- RFC 3501 은 메일함에 새로 들어온 메일의 UID 가 그때까지의 모든 UID 보다 커야 한다고 정합니다. 옛 UID 로 돌아온
-- 메일은 "UIDNEXT 이후만" 받아 가는 클라이언트(Thunderbird·Outlook 등)에서 보이지 않았습니다.
-- 해결: 폴더가 바뀌는 순간 떠난 폴더의 UID 행을 지웁니다. 돌아오면 다음 동기화(SELECT·NOOP·IDLE)나 MOVE/APPEND 가
-- INSERT OR IGNORE 로 새(더 큰) UID 를 줍니다. 웹·IMAP 등 어떤 경로로 옮기든 같은 규칙이 적용되도록 트리거로 둡니다.
CREATE TRIGGER IF NOT EXISTS messages_folder_leaves_imap_uid
AFTER UPDATE OF folder ON messages
WHEN OLD.folder <> NEW.folder
BEGIN
DELETE FROM imap_uids WHERE message_id = OLD.id AND folder = OLD.folder;
END;
-- 이미 남아 있는 "지금은 그 폴더에 없는 메일"의 UID 행을 정리합니다. 현재 폴더의 UID 는 그대로 두므로 클라이언트 캐시는 유지됩니다.
DELETE FROM imap_uids WHERE folder <> (SELECT m.folder FROM messages m WHERE m.id = imap_uids.message_id);
@@ -0,0 +1,6 @@
-- 0.8.4: 관리자 페이지(/admin) 전용 세션.
-- 세션마다 범위(scope)를 둡니다. 'mail' 은 웹 메일(/), 'admin' 은 관리자 페이지(/admin)입니다.
-- 관리자 페이지는 별도의 로그인을 거치고 다른 이름의 쿠키(mg_admin)를 쓰므로, 웹 메일에 로그인돼 있어도
-- 관리자 페이지를 열려면 다시 로그인해야 하고, 관리자 세션 쿠키로는 메일 API 를 쓸 수 없습니다.
-- 기존 세션은 모두 웹 메일 세션입니다.
ALTER TABLE sessions ADD COLUMN scope TEXT NOT NULL DEFAULT 'mail';
@@ -0,0 +1,17 @@
-- 0.8.16: 수신 메일 자동 처리 기록(메일 전달·자동 응답).
-- 설정 화면의 "메일 전달"(지정 주소로 받은 메일 전부 전달)과 "자동 응답"(지정 기간 동안 정해진 메시지로 회신)이
-- 메일 한 통에 무엇을 했는지 남깁니다. 같은 메일에 같은 작업을 두 번 하지 않게 (message_id, kind) 를 기본 키로 둡니다
-- (웹훅과 동기화가 같은 메일을 거의 동시에 저장하려 할 때, 또는 원본 없이 저장했던 메일을 다시 채울 때 중복 발송 방지).
CREATE TABLE auto_actions (
message_id TEXT NOT NULL REFERENCES messages(id) ON DELETE CASCADE,
kind TEXT NOT NULL CHECK(kind IN ('forward','reply')),
mailbox TEXT NOT NULL, -- 작업을 설정한 메일함(받은 메일의 메일함)
target TEXT NOT NULL DEFAULT '', -- 전달: 전달한 주소 목록(쉼표 구분) / 자동 응답: 회신한 상대 주소
status TEXT NOT NULL CHECK(status IN ('pending','sent','skipped','failed')), -- pending: 처리 중(먼저 행을 넣어 중복 처리를 막음)
detail TEXT, -- 건너뛴 이유(loop·stale·recent·bulk …) 또는 오류 메시지, 보냈으면 발송 id
sent_message_id TEXT, -- 자동 응답을 보낸 메일함(sent)에 저장한 메시지 id
created_at TEXT NOT NULL,
PRIMARY KEY(message_id, kind)
);
-- 자동 응답 "같은 상대에게는 하루 한 번" 검사: 메일함·종류·상대 주소로 최근 기록을 찾습니다.
CREATE INDEX auto_actions_target ON auto_actions(mailbox, kind, target, created_at DESC);
@@ -0,0 +1,26 @@
-- 0.9.3: 대용량 첨부파일(다운로드 링크 발송).
-- 큰 파일은 메일에 첨부하지 않고 서버 디스크(FILES_DIR, 기본 <DB 디렉터리>/files/<id>)에 두고 메일에는 다운로드 링크만 넣습니다.
-- 파일 바이트는 DB 에 넣지 않습니다(1GB 급 BLOB 은 SQLite 와 백업에 부담). 이 표는 파일의 메타데이터와 링크 상태만 가집니다.
-- message_id : 파일이 딸린 초안/보낸 메일. 메일이 영구 삭제돼도 보낸 링크는 기한까지 살아 있어야 하므로 NULL 로 풀립니다(초안이면 정리 작업이 파일을 지움).
-- token : 다운로드 링크(/dl/<token>)의 비밀 값. 발송할 때 만들어지며 그 전(초안)에는 NULL.
-- state : draft(초안에 올려 둠) → sent(링크 발송됨) → expired(기한·횟수 만료 또는 보낸 사람이 링크를 끊음, 파일은 지워짐)
-- max_downloads: 허용 다운로드 횟수, downloads: 지금까지 받은 횟수(랜딩 페이지의 다운로드 단추를 누를 때마다 1), expires_at: 보관 기한(ISO)
-- 발송 전에 기한과 횟수를 반드시 정하므로 sent 상태의 행은 둘 다 NULL 이 아닙니다(기한·횟수 제한 없는 발송 불가).
CREATE TABLE large_files (
id TEXT PRIMARY KEY,
message_id TEXT REFERENCES messages(id) ON DELETE SET NULL,
mailbox TEXT NOT NULL,
filename TEXT NOT NULL DEFAULT 'file',
content_type TEXT NOT NULL DEFAULT 'application/octet-stream',
size INTEGER NOT NULL DEFAULT 0,
token TEXT UNIQUE,
state TEXT NOT NULL DEFAULT 'draft' CHECK(state IN ('draft','sent','expired')),
max_downloads INTEGER,
downloads INTEGER NOT NULL DEFAULT 0,
expires_at TEXT,
created_at TEXT NOT NULL,
sent_at TEXT,
last_download_at TEXT
);
CREATE INDEX large_files_message ON large_files(message_id);
CREATE INDEX large_files_state ON large_files(state, expires_at);