Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions apps/server/src/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
import { AuthFailureThrottle, PeerConnectionLimiter, MAX_MESSAGE_BYTES, createLogSessionReporter, noopAuditSink, noopLogger, rfc5322Date, ulid, type AuditSink, type Logger, type SessionReporter } from "@ionosphere/core";
import { allMigrations, describeDbSpec, migrate, MTA_QUEUE_STATUS, openDatabase, type DbDriver } from "@ionosphere/db";
import {
accountDrainStatus,
authenticate,
createCredential,
DbAuthFailureStore,
Expand Down Expand Up @@ -917,6 +918,14 @@ export class IonosphereApp {
const { rows } = await db.query({ sql: `SELECT COUNT(*) AS n FROM mta_queue WHERE status = ${MTA_QUEUE_STATUS.queued}` });
queueDepth.set(Number(rows[0]?.n ?? 0));
});
// 계정 드레인 — overdue가 0보다 크면 24시간 마감을 넘긴 탈퇴 계정이 있다(경보 기준).
const drainPending = this.metrics.accountDrainPending;
const drainOverdue = this.metrics.accountDrainOverdue;
this.metrics.registry.onCollect(async () => {
const s = await accountDrainStatus(db);
drainPending.set(s.pending);
drainOverdue.set(s.overdue);
});
}

/**
Expand Down Expand Up @@ -1557,6 +1566,8 @@ export class IonosphereApp {
* 기본값(30일·180일·7일)은 `runRetention`이 소유한다 — 여기서 다시 적으면 갈라진다.
*/
retention: { db: this.db },
// 계정 삭제 드레인(SCHEMA §7-7) — 탈퇴한 계정의 메일·자격증명·sieve를 24시간 안에 지운다(2026-10-19 P8).
accountDrain: { db: this.db },
/**
* 대외 리포트 — `rua` 조회와 외부 목적지 승인 확인이 전부 DNS라 리졸버가 필수다.
* `ctx.resolver`는 조립층이 항상 만들어 두므로(미주입이면 NodeDnsResolver) 여기서
Expand Down
35 changes: 34 additions & 1 deletion apps/server/src/reaper.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
* WebhookWorker/MtaWorker와 동형(interval + 재진입 가드 + 수동 tick).
*/
import { noopLogger, type Logger } from "@ionosphere/core";
import { runRetention, type RetentionOptions, type Store } from "@ionosphere/store";
import { drainAccount, listDrainingAccounts, runRetention, type RetentionOptions, type Store } from "@ionosphere/store";
import { runReports, type ReportOptions } from "./reports.ts";
import type { DbDriver } from "@ionosphere/db";

Expand Down Expand Up @@ -39,6 +39,13 @@ export interface MailboxReaperOptions {
* 리퍼 주기(5분)와 무관하게 정확히 한 번이다.
*/
reports?: Omit<ReportOptions, "now">;
/**
* 계정 삭제 드레인(SCHEMA §7-7) — 지정 시 status=2 계정의 메일·자격증명·sieve를 지우고 계정 행까지 없앤다.
*
* ★리퍼에 붙이는 이유: 메일함 툼스톤 수거와 같은 「삭제의 2단계」다. 회원 공통 기준이 24시간 안 드레인이고
* 리퍼 주기가 5분이라 넉넉하다. 마감 초과는 `ionosphere_account_drain_overdue` 지표가 알린다.
*/
accountDrain?: { db: DbDriver; perTick?: number };
}

const DEFAULT_INTERVAL_MS = 5 * 60 * 1000;
Expand All @@ -51,6 +58,7 @@ export class MailboxReaper {
private readonly maildropLock?: { sweepExpired(now?: number, graceMs?: number): Promise<number> };
private readonly retention?: RetentionOptions & { db: DbDriver };
private readonly reports?: Omit<ReportOptions, "now">;
private readonly accountDrain?: { db: DbDriver; perTick?: number };
private timer: ReturnType<typeof setInterval> | null = null;
private running = false;

Expand All @@ -62,6 +70,7 @@ export class MailboxReaper {
if (opts.maildropLock) this.maildropLock = opts.maildropLock;
if (opts.retention) this.retention = opts.retention;
if (opts.reports) this.reports = opts.reports;
if (opts.accountDrain) this.accountDrain = opts.accountDrain;
}

start(): void {
Expand Down Expand Up @@ -96,6 +105,7 @@ export class MailboxReaper {
this.log.warn("reap failed", { mailboxId: t.id, error: err instanceof Error ? err.message : String(err) });
}
}
if (this.accountDrain) await this.drainAccounts(this.accountDrain);
// 만료 락 정리 — 실패해도 메일함 수거는 이미 끝났으므로 삼킨다(부가 작업).
if (this.maildropLock) {
try {
Expand Down Expand Up @@ -139,4 +149,27 @@ export class MailboxReaper {
this.running = false;
}
}

/**
* 삭제 중인 계정을 오래된 것부터 드레인한다. 계정 하나의 실패가 다른 계정을 막지 않게 따로 잡는다.
* 큰 계정은 한 번에 다 못 지운다(`drainAccount`의 쪽 상한) — 다음 tick이 이어 간다.
*/
private async drainAccounts(opts: { db: DbDriver; perTick?: number }): Promise<void> {
let ids: string[];
try {
ids = await listDrainingAccounts(opts.db, opts.perTick ?? 10);
} catch (err) {
this.log.warn("계정 드레인 목록 실패", { error: err instanceof Error ? err.message : String(err) });
return;
}
for (const accountId of ids) {
try {
const r = await drainAccount(opts.db, accountId);
if (r.done) this.log.info("계정 드레인 완료", { accountId, messages: r.messages });
else if (r.messages > 0) this.log.info("계정 드레인 진행", { accountId, messages: r.messages });
} catch (err) {
this.log.warn("계정 드레인 실패", { accountId, error: err instanceof Error ? err.message : String(err) });
}
}
}
}
17 changes: 16 additions & 1 deletion apps/server/test/reaper.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
/** MailboxReaper 워커 — tick이 툼스톤 메일함을 수거. */
import { describe, expect, test } from "@ionosphere/testkit";
import { allMigrations, migrate, openSqlite } from "@ionosphere/db";
import { ACCOUNT_STATUS, allMigrations, migrate, openSqlite } from "@ionosphere/db";
import { Store } from "@ionosphere/store";
import { MailboxReaper } from "../src/reaper.ts";

Expand Down Expand Up @@ -33,4 +33,19 @@ describe("MailboxReaper.tick", () => {
expect(await reaper.tick()).toBe(0);
await db.close();
});

test("accountDrain을 주면 삭제 중인 계정을 드레인해 계정 행까지 없앤다(SCHEMA §7-7)", async () => {
const { db, store } = await freshStore();
const { tenantId } = await store.createTenant("t");
const { accountId } = await store.createAccount({ tenantId, email: "gone@x.test" });
await db.batch([{ sql: "UPDATE accounts SET status = ?, deleting_since = ? WHERE id = ?", params: [ACCOUNT_STATUS.deleting, Date.now() - 10 * 60_000, accountId] }]);

// 옵션 없이는 돌지 않는다 — 조립층이 켠다
await new MailboxReaper({ store }).tick();
expect((await db.query({ sql: "SELECT 1 FROM accounts WHERE id = ?", params: [accountId] })).rows.length).toBe(1);

await new MailboxReaper({ store, accountDrain: { db } }).tick();
expect((await db.query({ sql: "SELECT 1 FROM accounts WHERE id = ?", params: [accountId] })).rows.length).toBe(0);
await db.close();
});
});
10 changes: 10 additions & 0 deletions docs/OPERATIONS.md
Original file line number Diff line number Diff line change
Expand Up @@ -198,6 +198,16 @@ SQLite(`node:sqlite` 내장) · PostgreSQL(`pg`) · MySQL(`mysql2`) · Cloudflar
- **감사 로그**: `IONOSPHERE_AUDIT`로 켠다. 로컬 보관 + S3 전송(`IONOSPHERE_AUDIT_S3_*`)을 지원한다.
- **구조화 로그**: `IONOSPHERE_LOG_FORMAT=json`.

- **계정 삭제 드레인**: `account-delete` 뒤 리퍼(5분 주기)가 메일·자격증명·sieve를 지우고 계정 행까지 없앤다
(SCHEMA §7-7). 회원 공통 기준은 **24시간 안**이다. `ionosphere_account_drain_overdue`가 0보다 크면 경보를 걸 것 —
마감을 넘긴 탈퇴 계정이 있다는 뜻이다. `ionosphere_account_drain_pending`은 진행 중인 수다.
두 값은 공유 DB에서 세므로 **서버마다 같은 값**을 낸다 — 경보는 `sum`이 아니라 `max`로 걸 것.
삭제 뒤 2분은 기다렸다 시작한다(열린 JMAP 세션의 늦은 쓰기가 계정 행이 사라진 뒤 고아로 남지 않게).
처리량은 서버당 tick마다 계정 10개 × 1,960통이다. 그보다 훨씬 큰 계정은 24시간을 넘길 수 있다 — 그게 overdue 경보다.
⚠ 블롭 파일은 블롭 GC가 회수한다. `IONOSPHERE_BLOB_GC=off`면 본문 파일이 저장소에 남는다(DB 참조는 지워진다).
- **백업 보관**: `scripts/backup.sh`가 `IONOSPHERE_BACKUP_RETAIN_DAYS`(기본 14일)보다 오래된 묶음을 지운다.
원격 백업 저장소에 수명 규칙을 따로 두지 않았다면 **이 삭제만이 보관 상한**이다 — 그 작업이 멈추면 백업이 계속 쌓인다.

관측에서 반복되는 함정 하나: **"돌았는데 할 일이 없었다"와 "아예 안 돌았다"가 겉으로 같다**
(블롭 GC 같은 주기 작업). 주기 작업을 믿기 전에 실행 자체를 세는 지표가 있는지 볼 것.

Expand Down
2 changes: 1 addition & 1 deletion docs/PROTOCOLS.md
Original file line number Diff line number Diff line change
Expand Up @@ -352,7 +352,7 @@ SPF+DKIM pass · From 도메인 정렬(SPF 또는 DKIM) · DMARC p=none 이상
| 표면 제한 | `credentials.scopes` — 인증 함수 안의 단일 관문, 모르는 이름은 fail closed | `credentialAllowsSurface` |
| 시도 제한 | 실패만 셈, IP 프리픽스·계정 두 축(모든 메일 프로토콜·웹), 5회 뒤 1초부터 두 배·상한 15분·15분 뒤 초기화(공통 수치). 성공하면 계정 축만 지움. 서버들이 공유 DB로 함께 센다(DB 장애 시 프로세스 카운터) | `core/auth-throttle.ts`, `store/auth-failures.ts` |
| 정지 | `account-suspend` — 새 인증·제출·수신 차단, 데이터 보존, 되돌릴 수 있음 | — |
| 삭제 | `account-delete` — 되돌릴 수 없는 드레인, 끝날 때까지 주소 재등록 차단 | SCHEMA §7-7 |
| 삭제 | `account-delete` — 즉시 비노출, 리퍼가 24시간 안에 메일·자격증명·sieve를 지우고 계정 행까지 없앤다(`ionosphere_account_drain_overdue`). 끝날 때까지 주소 재등록 차단 | SCHEMA §7-7 |
| 반영 지연 | 새 인증은 즉시. **JMAP은 인증 캐시로 최대 30초**(외부 토큰은 exp를 넘지 않는다). 제출(SMTP)은 메시지마다 상태를 다시 본다. 열린 IMAP·POP3·ManageSieve 연결은 **다음 명령에서** 계정 상태를 다시 본다(G2). 외부 토큰으로 연 연결은 토큰 exp에 닫힌다(E4) | `jmap-server.ts`, `backend.ts`, `proto-*/server.ts`, `core/auth-expiry.ts` |
| 디렉터리 로그인 | 기본 꺼짐. 로컬 실패 뒤 폴백, 관리자가 연결한 불변 키만. provider별 허용 표면(`surfaces`, 생략하면 전부) — 밖이면 bind 전에 거절 | `shared-mailbox-runtime.ts`, `directory-config.ts` |
| 관리 주체 | 사람: `admin_users`(메일 계정과 별개, TOTP 필수, 서버 세션). 서비스: `api_keys`(read/write/admin). 정적 `rootToken`은 부트스트랩 전용(활성 owner가 생기면 경고) | `store/admin-auth.ts`, `api/server.ts` |
Expand Down
19 changes: 18 additions & 1 deletion docs/SCHEMA.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

> 상태: **동결** (v1 → 이중 리뷰 → v2 → 이중 재검증 → v2.1) · 전제: [PLAN.md](../PLAN.md) + [PROTOCOLS.md](PROTOCOLS.md) §0
>
> **적용된 마이그레이션(2026-10-19 기준 029까지)** — 정본은 `packages/db/src/migrations/`다.
> **적용된 마이그레이션(2026-10-19 기준 030까지)** — 정본은 `packages/db/src/migrations/`다.
> 코어 DDL은 001이고, 그 뒤는 아래 절에 따로 적었다:
>
> | # | 이름 | 어디에 |
Expand Down Expand Up @@ -36,6 +36,7 @@
> | 027 | oidc | **§4-3**(외부 OIDC 연결·인가 상태) |
> | 028 | auth_failures | **§4-4** |
> | 029 | identity_claims | **§4-5** |
> | 030 | account_deleting_since | **§7-7**(계정 드레인 마감) |
>
> ⚠ 새 마이그레이션을 넣으면 **이 표와 해당 절을 같이 갱신할 것.** 009·010이 한동안 코드에만
> 있고 이 문서에 없었다 — "동결 스키마"를 자처하는 문서가 실제 테이블을 빠뜨리면, 그것을 읽고
Expand Down Expand Up @@ -727,6 +728,22 @@ SELECT 후 INSERT는 두 MRA 사이에서 항상 진다. `owner` 가드가 없
(드레인 중 신규 계정이 구 메일 수신하는 사고 방지)
```

**계정 드레인 구현**(2026-10-19, `store/account-drain.ts`). 위 약속이 오래 코드에 없었다 — 리퍼는 메일함 툼스톤만
수거했고 status=2 계정의 데이터는 영구히 남았다. 지금은 리퍼가 tick마다 삭제 중인 계정을 오래된 것부터 지운다:

- 삭제 뒤 2분 유예(`ACCOUNT_DRAIN_GRACE_MS`)가 지난 계정만 고른다 — JMAP 인증 캐시(30초) 동안 열린 세션의 vacation·push·sieve 쓰기가
계정 행이 사라진 뒤 들어와 고아가 되지 않게.
- 메시지는 98통씩 한 배치(쪽)로 지운다. 메시지에 매달린 행(membership·키워드·주소·헤더 투영·본문 텍스트·검색 색인·
message_auth·blob_refs)을 함께. 쪽이 멱등한 DELETE라 중간에 죽어도 다음 tick이 이어 간다.
- 메시지가 비면 계정 단위 정리 한 배치: 메일함·ACL·change_log·자격증명(기본·앱 비밀번호·OAuth)·웹 세션·MFA·sieve·
vacation·identity·제출·큐·webhook·bayes·principal·디렉터리 연결·주소 연결, **마지막에 accounts 행**.
- change_log를 남기지 않는다. 계정이 이미 보이지 않아 알릴 상대가 없다(그래서 `reapMailbox`를 쓰지 않는다).
- 발송 중(리스 유효) 큐 행이 있으면 accounts 행과 그 행의 블롭 참조를 남기고 다음 tick에 다시 본다. 리스가 만료된 행은 지운다.
- `account-suspend`·`account-activate`는 status=2를 덮어쓰지 않는다(조건부 UPDATE) — 반쯤 지운 계정이 되살아나지 않게.
- 남기는 것: `identity_claims`(§4-5), `addresses` 행(알리아스 소유 — 계정 연결은 `address_targets`), 블롭 파일(GC 소관).
- 마감 24시간은 `accounts.deleting_since`(030)부터 잰다. `account-delete`가 처음 한 번만 쓴다(COALESCE).
030 이전에 이미 status=2였던 계정은 `created_at`으로 채워 곧바로 마감 초과로 보인다(늦게 잡히는 것보다 낫다).

**읽기 가시성 계약**: 모든 읽기 경로(IMAP LIST/SELECT/STATUS, JMAP Mailbox/get·Email/query,
POP3)는 `status=1`만 노출. status=2는 즉시 notFound/부재 — 드레인은 관측 불가여야 함.
accounts.status=2 동일.
Expand Down
31 changes: 26 additions & 5 deletions packages/admin-cmd/src/accounts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -193,9 +193,16 @@ export const accountCommands: readonly Command[] = [
// 삭제 드레인은 편도다 — 정지로 되돌린 것처럼 보이게 두면 운영자가 복구됐다고 오해한다.
throw new CommandError("conflict", `삭제 중인 계정은 정지할 수 없습니다: ${acc.email}`, "삭제는 되돌릴 수 없습니다");
}
await ctx.db.batch([
{ sql: "UPDATE accounts SET status = ? WHERE id = ? AND tenant_id = ?", params: [ACCOUNT_STATUS.suspended, acc.id, tenantId] },
const updated = await ctx.db.batch([
/**
* ★삭제 중(2)이 아닐 때만 — 위의 확인과 이 쓰기 사이에 account-delete가 끼면 status=2를 덮어써,
* 드레인이 반쯤 지운(자격증명·메일함이 없는) 계정이 "활성"으로 되살아난다(2026-10-19 리뷰).
*/
{ sql: `UPDATE accounts SET status = ? WHERE id = ? AND tenant_id = ? AND status != ${ACCOUNT_STATUS.deleting}`, params: [ACCOUNT_STATUS.suspended, acc.id, tenantId] },
]);
if (updated[0]!.changes === 0) {
throw new CommandError("conflict", `계정 상태가 바뀌어 정지하지 못했습니다: ${acc.email}`, "삭제가 시작됐을 수 있습니다 — account-list로 확인하십시오");
}
return { data: { accountId: acc.id, status: ACCOUNT_STATUS.suspended }, message: `정지됨: ${acc.email} (account-activate로 해제)` };
},
},
Expand All @@ -214,9 +221,16 @@ export const accountCommands: readonly Command[] = [
if (acc.status === ACCOUNT_STATUS.deleting) {
throw new CommandError("conflict", `삭제 중인 계정은 되살릴 수 없습니다: ${acc.email}`, "리퍼가 이미 메일함·자격증명을 드레인했습니다");
}
await ctx.db.batch([
{ sql: "UPDATE accounts SET status = ? WHERE id = ? AND tenant_id = ?", params: [ACCOUNT_STATUS.active, acc.id, tenantId] },
const updated = await ctx.db.batch([
/**
* ★삭제 중(2)이 아닐 때만 — 위의 확인과 이 쓰기 사이에 account-delete가 끼면 status=2를 덮어써,
* 드레인이 반쯤 지운(자격증명·메일함이 없는) 계정이 "활성"으로 되살아난다(2026-10-19 리뷰).
*/
{ sql: `UPDATE accounts SET status = ? WHERE id = ? AND tenant_id = ? AND status != ${ACCOUNT_STATUS.deleting}`, params: [ACCOUNT_STATUS.active, acc.id, tenantId] },
]);
if (updated[0]!.changes === 0) {
throw new CommandError("conflict", `계정 상태가 바뀌어 재활성하지 못했습니다: ${acc.email}`, "삭제가 시작됐을 수 있습니다 — account-list로 확인하십시오");
}
return { data: { accountId: acc.id, status: ACCOUNT_STATUS.active }, message: `활성화됨: ${acc.email}` };
},
},
Expand Down Expand Up @@ -319,7 +333,14 @@ export const accountCommands: readonly Command[] = [
* FK 미사용 정책(SCHEMA §1-4)이라 DB가 대신 지워 주지 않는다. 한 배치라 중간 상태가 안 보인다.
*/
const results = await ctx.db.batch([
{ sql: "UPDATE accounts SET status = ? WHERE id = ? AND tenant_id = ?", params: [ACCOUNT_STATUS.deleting, acc.id, tenantId] },
/**
* deleting_since — 리퍼의 계정 드레인이 24시간 마감을 이 시각부터 잰다(마이그레이션 030).
* 이미 삭제 중이면 덮어쓰지 않는다(COALESCE) — 다시 눌러 마감을 늦추지 못하게.
*/
{
sql: "UPDATE accounts SET status = ?, deleting_since = COALESCE(deleting_since, ?) WHERE id = ? AND tenant_id = ?",
params: [ACCOUNT_STATUS.deleting, Date.now(), acc.id, tenantId],
},
{
// 테넌트 사슬을 타 남의 계정 목적지를 지우지 않게 한다(accountId만으로 지우면 인가가 샌다).
sql: `DELETE FROM address_targets WHERE account_id IN
Expand Down
4 changes: 3 additions & 1 deletion packages/db/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,7 @@ export { m026AccountWeb } from "./migrations/026_account_web.ts";
export { m027Oidc } from "./migrations/027_oidc.ts";
export { m028AuthFailures } from "./migrations/028_auth_failures.ts";
export { m029IdentityClaims } from "./migrations/029_identity_claims.ts";
export { m030AccountDeletingSince } from "./migrations/030_account_deleting_since.ts";

import { m001Init } from "./migrations/001_init.ts";
import { m002Webhooks } from "./migrations/002_webhooks.ts";
Expand Down Expand Up @@ -133,7 +134,8 @@ import { m026AccountWeb } from "./migrations/026_account_web.ts";
import { m027Oidc } from "./migrations/027_oidc.ts";
import { m028AuthFailures } from "./migrations/028_auth_failures.ts";
import { m029IdentityClaims } from "./migrations/029_identity_claims.ts";
import { m030AccountDeletingSince } from "./migrations/030_account_deleting_since.ts";
import type { Migration } from "./migrate.ts";

/** 전체 마이그레이션 목록 (버전 순). */
export const allMigrations: readonly Migration[] = [m001Init, m002Webhooks, m003Forwarding, m004BlobGc, m005MaildropLock, m006AddressFanout, m007Smarthosts, m008SuppressionExpiry, m009Complaints, m010BayesTokens, m011QueueIndexes, m012DsnDelayNotice, m013Vacation, m014ExpungedFloor, m015VacationResponse, m016DsnParams, m017Reporting, m018PushSubscriptions, m019IdentityState, m020MailboxAcl, m021DirectoryIdentity, m022HeaderProjection, m023ListingIndexes, m024DirectoryIdentityAccountUnique, m025AdminUsers, m026AccountWeb, m027Oidc, m028AuthFailures, m029IdentityClaims];
export const allMigrations: readonly Migration[] = [m001Init, m002Webhooks, m003Forwarding, m004BlobGc, m005MaildropLock, m006AddressFanout, m007Smarthosts, m008SuppressionExpiry, m009Complaints, m010BayesTokens, m011QueueIndexes, m012DsnDelayNotice, m013Vacation, m014ExpungedFloor, m015VacationResponse, m016DsnParams, m017Reporting, m018PushSubscriptions, m019IdentityState, m020MailboxAcl, m021DirectoryIdentity, m022HeaderProjection, m023ListingIndexes, m024DirectoryIdentityAccountUnique, m025AdminUsers, m026AccountWeb, m027Oidc, m028AuthFailures, m029IdentityClaims, m030AccountDeletingSince];
Loading
Loading