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:
co-authored by
Claude Fable 5.1
parent
9ca4cee341
commit
c7aea8d419
@@ -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);
|
||||
@@ -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);
|
||||
Reference in New Issue
Block a user