diff --git a/apps/server/src/app.ts b/apps/server/src/app.ts index d1c0f19..7d5a2a7 100644 --- a/apps/server/src/app.ts +++ b/apps/server/src/app.ts @@ -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, @@ -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); + }); } /** @@ -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) 여기서 diff --git a/apps/server/src/reaper.ts b/apps/server/src/reaper.ts index 146ba9b..2d54f2e 100644 --- a/apps/server/src/reaper.ts +++ b/apps/server/src/reaper.ts @@ -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"; @@ -39,6 +39,13 @@ export interface MailboxReaperOptions { * 리퍼 주기(5분)와 무관하게 정확히 한 번이다. */ reports?: Omit; + /** + * 계정 삭제 드레인(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; @@ -51,6 +58,7 @@ export class MailboxReaper { private readonly maildropLock?: { sweepExpired(now?: number, graceMs?: number): Promise }; private readonly retention?: RetentionOptions & { db: DbDriver }; private readonly reports?: Omit; + private readonly accountDrain?: { db: DbDriver; perTick?: number }; private timer: ReturnType | null = null; private running = false; @@ -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 { @@ -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 { @@ -139,4 +149,27 @@ export class MailboxReaper { this.running = false; } } + + /** + * 삭제 중인 계정을 오래된 것부터 드레인한다. 계정 하나의 실패가 다른 계정을 막지 않게 따로 잡는다. + * 큰 계정은 한 번에 다 못 지운다(`drainAccount`의 쪽 상한) — 다음 tick이 이어 간다. + */ + private async drainAccounts(opts: { db: DbDriver; perTick?: number }): Promise { + 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) }); + } + } + } } diff --git a/apps/server/test/reaper.test.ts b/apps/server/test/reaper.test.ts index 88f18f7..3863833 100644 --- a/apps/server/test/reaper.test.ts +++ b/apps/server/test/reaper.test.ts @@ -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"; @@ -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(); + }); }); diff --git a/docs/OPERATIONS.md b/docs/OPERATIONS.md index 3adad36..c7e0ebd 100644 --- a/docs/OPERATIONS.md +++ b/docs/OPERATIONS.md @@ -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 같은 주기 작업). 주기 작업을 믿기 전에 실행 자체를 세는 지표가 있는지 볼 것. diff --git a/docs/PROTOCOLS.md b/docs/PROTOCOLS.md index 25dc8a2..4c9255a 100644 --- a/docs/PROTOCOLS.md +++ b/docs/PROTOCOLS.md @@ -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` | diff --git a/docs/SCHEMA.md b/docs/SCHEMA.md index 988c99b..ca5a4a4 100644 --- a/docs/SCHEMA.md +++ b/docs/SCHEMA.md @@ -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이고, 그 뒤는 아래 절에 따로 적었다: > > | # | 이름 | 어디에 | @@ -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이 한동안 코드에만 > 있고 이 문서에 없었다 — "동결 스키마"를 자처하는 문서가 실제 테이블을 빠뜨리면, 그것을 읽고 @@ -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 동일. diff --git a/packages/admin-cmd/src/accounts.ts b/packages/admin-cmd/src/accounts.ts index ebc0772..09c2b2e 100644 --- a/packages/admin-cmd/src/accounts.ts +++ b/packages/admin-cmd/src/accounts.ts @@ -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로 해제)` }; }, }, @@ -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}` }; }, }, @@ -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 diff --git a/packages/db/src/index.ts b/packages/db/src/index.ts index 8ba0081..6a29ddd 100644 --- a/packages/db/src/index.ts +++ b/packages/db/src/index.ts @@ -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"; @@ -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]; diff --git a/packages/db/src/migrations/030_account_deleting_since.ts b/packages/db/src/migrations/030_account_deleting_since.ts new file mode 100644 index 0000000..c940d41 --- /dev/null +++ b/packages/db/src/migrations/030_account_deleting_since.ts @@ -0,0 +1,20 @@ +import type { Migration } from "../migrate.ts"; + +/** + * 계정 삭제 시작 시각 — 계정 드레인의 마감(24시간) 판정용 (2026-10-19 P8 결정). + * + * 탈퇴를 "즉시 비노출 + 비동기 드레인"으로 처리하려면 드레인이 **언제 시작됐는지**를 알아야 + * 「24시간 안에 끝났나」를 잴 수 있다. `status=2`만으로는 그 시각이 없다. + * + * 이미 `status=2`인 계정은 시작 시각을 모르므로 `created_at`으로 채운다 — 실제보다 **이르게** + * 잡혀 곧바로 마감 초과로 보인다. 늦게 잡혀 경보가 안 울리는 쪽보다 낫다(fail closed). + */ +export const m030AccountDeletingSince: Migration = { + version: 30, + name: "account deleting_since", + statements: [ + "ALTER TABLE accounts ADD COLUMN deleting_since BIGINT", + "UPDATE accounts SET deleting_since = created_at WHERE status = 2 AND deleting_since IS NULL", + "CREATE INDEX IF NOT EXISTS ix_accounts_status ON accounts(status, deleting_since)", + ], +}; diff --git a/packages/metrics/src/ionosphere-metrics.ts b/packages/metrics/src/ionosphere-metrics.ts index 78b96da..026e9e0 100644 --- a/packages/metrics/src/ionosphere-metrics.ts +++ b/packages/metrics/src/ionosphere-metrics.ts @@ -69,6 +69,10 @@ export interface IonosphereMetrics { * (2026-10-01). `result="bad"`와 command가 함께 있어야 "어떤 명령이 BAD로 도는가"에 답한다. */ imapCommands: Counter; + /** 삭제 드레인 중인 계정 수. */ + accountDrainPending: Gauge; + /** 24시간 마감을 넘긴 드레인 계정 수(경보 기준). */ + accountDrainOverdue: Gauge; } export function createIonosphereMetrics(registry: Registry = new Registry()): IonosphereMetrics { @@ -89,6 +93,8 @@ export function createIonosphereMetrics(registry: Registry = new Registry()): Io auditShipPending: registry.gauge("ionosphere_audit_ship_pending", "아직 이관되지 않은 지난 날짜 감사 로그 파일 수"), sessions: registry.counter("ionosphere_sessions_total", "닫힌 연결 세션 수(surface, reason=closed|idle|preauth)"), peerLimitRejections: registry.counter("ionosphere_peer_limit_rejections_total", "IP 프리픽스 동시 연결 상한으로 거절된 연결 수"), + accountDrainPending: registry.gauge("ionosphere_account_drain_pending", "삭제 드레인 중인 계정 수(status=2)"), + accountDrainOverdue: registry.gauge("ionosphere_account_drain_overdue", "삭제 드레인이 24시간 마감을 넘긴 계정 수 — 0보다 크면 경보"), imapCommands: registry.counter("ionosphere_imap_commands_total", "IMAP 명령 완료 수(command, result=ok|no|bad)"), }; } diff --git a/packages/store/src/account-drain.ts b/packages/store/src/account-drain.ts new file mode 100644 index 0000000..aa19e68 --- /dev/null +++ b/packages/store/src/account-drain.ts @@ -0,0 +1,210 @@ +/** + * 계정 삭제 드레인 — SCHEMA §7-7 「계정 삭제 = accounts.status=2 + 전 메일함 + credentials/sieve/… 정리」의 구현. + * + * ★왜 생겼나(2026-10-19 P8 확인): `account-delete`는 `status=2`로 바꾸기만 했고, 그 뒤를 맡을 드레인이 + * **어디에도 없었다.** 리퍼는 지운 메일함(mailboxes.status=2)만 수거했다. 그래서 탈퇴한 계정의 메일·본문 + * 색인·자격증명·sieve가 영구히 남았다. 회원 공통 기준은 「즉시 비노출 + 24시간 안 드레인」이다. + * + * 즉시 비노출은 이미 성립한다 — 로그인·열린 연결·웹 세션·SSO·수신 라우팅이 모두 `status=1`만 받는다. + * 여기는 그 뒤에 **실제로 지우는** 일만 한다. + * + * 설계: + * - 메시지는 쪽(page) 단위로 지운다. 한 쪽 = 한 배치라 쪽 안에서는 원자적이고, 중간에 서버가 죽어도 + * 다음 tick이 남은 것부터 이어 간다(모든 문장이 멱등한 DELETE다). + * - change_log·modseq를 남기지 않는다. 계정이 이미 어떤 클라이언트에도 보이지 않으므로 변경을 알릴 + * 상대가 없다. 그래서 `Store.reapMailbox`(활성 계정 전제, `mustGetAccount`가 막는다)를 쓰지 않는다. + * - accounts 행은 **마지막에** 지운다. 그 전까지 이메일 유니크 키가 주소 재등록을 막는다(§7-7 — 드레인 + * 중 새 계정이 옛 메일을 받는 사고 방지). + * - 서버 세 대의 리퍼가 같은 계정을 함께 지울 수 있다. 문장이 전부 멱등해 결과는 같다. + * + * 지우지 않는 것: + * - `identity_claims`(SSO sub의 최초 주인) — 같은 sub를 다른 계정에 다시 잇지 못하게 하는 기록이라 + * 계정이 사라져도 남겨야 한다(마이그레이션 029). 값은 IdP의 불투명 식별자다. + * - 블롭 파일 — `blob_refs`를 지우면 블롭 GC가 참조 0으로 보고 회수한다. GC가 `off`면 파일은 남는다. + */ +import { ACCOUNT_STATUS, MTA_QUEUE_STATUS, REF_KIND, type DbDriver, type Statement } from "@ionosphere/db"; + +/** 계정 드레인 마감 — 회원 공통 기준(2026-10-19 P8). 넘기면 지표가 overdue로 센다. */ +export const ACCOUNT_DRAIN_DEADLINE_MS = 24 * 60 * 60 * 1000; + +/** + * 한 쪽에 담는 메시지 수. 가장 긴 문장(search_index·blob_refs)이 계정 id 2개 + 메시지 id를 받으므로 + * 문장당 파라미터 상한(`chunk.ts` MAX_PARAMS_PER_STATEMENT = 100) 안에 들게 98이다. + */ +const PAGE_MESSAGES = 98; +/** + * 삭제 뒤 드레인을 시작하기까지의 유예. JMAP 인증 캐시(30초) 동안은 이미 열린 세션이 vacation·push·sieve를 + * 쓸 수 있다 — store의 그 쓰기들은 계정 상태를 보지 않는다. 드레인이 계정 행까지 지운 **뒤에** 그런 쓰기가 + * 들어오면 다시 찾을 계정 행이 없어 영구 고아가 된다. 캐시보다 넉넉히 기다린다. 24시간 마감에는 영향이 없다. + */ +export const ACCOUNT_DRAIN_GRACE_MS = 2 * 60 * 1000; +/** tick 한 번에 계정 하나당 지우는 쪽 수 — 리퍼 한 주기가 한 계정에 묶여 다른 일을 굶기지 않게. */ +const DEFAULT_MAX_PAGES = 20; + +export interface AccountDrainResult { + /** 이번 호출로 지운 메시지 수. */ + messages: number; + /** accounts 행까지 지워 드레인이 끝났으면 true. */ + done: boolean; +} + +export interface AccountDrainStatus { + /** 드레인 중인 계정 수(status=2). */ + pending: number; + /** 마감(24시간)을 넘긴 계정 수. */ + overdue: number; +} + +/** 드레인할 계정 — 오래된 것부터. 삭제한 지 유예(`ACCOUNT_DRAIN_GRACE_MS`)가 지난 것만. */ +export async function listDrainingAccounts(db: DbDriver, limit = 10, now = Date.now()): Promise { + const { rows } = await db.query({ + sql: `SELECT id FROM accounts WHERE status = ${ACCOUNT_STATUS.deleting} AND (deleting_since IS NULL OR deleting_since <= ?) + ORDER BY deleting_since, id LIMIT ?`, + params: [now - ACCOUNT_DRAIN_GRACE_MS, limit], + }); + return rows.map((r) => String(r.id)); +} + +/** 지표용 — 드레인 적체와 마감 초과 수. */ +export async function accountDrainStatus(db: DbDriver, now = Date.now()): Promise { + const { rows } = await db.query({ + sql: `SELECT COUNT(*) AS pending, + SUM(CASE WHEN deleting_since IS NULL OR deleting_since < ? THEN 1 ELSE 0 END) AS overdue + FROM accounts WHERE status = ${ACCOUNT_STATUS.deleting}`, + params: [now - ACCOUNT_DRAIN_DEADLINE_MS], + }); + return { pending: Number(rows[0]?.pending ?? 0), overdue: Number(rows[0]?.overdue ?? 0) }; +} + +/** + * 계정 하나를 드레인한다. 메시지가 많으면 `maxPages`쪽까지만 하고 돌아온다(`done: false`) — + * 다음 호출이 이어 간다. 계정이 없거나 `status=2`가 아니면 아무것도 지우지 않는다. + */ +export async function drainAccount(db: DbDriver, accountId: string, opts: { maxPages?: number; now?: number } = {}): Promise { + if (!(await isDraining(db, accountId))) return { messages: 0, done: false }; + const maxPages = opts.maxPages ?? DEFAULT_MAX_PAGES; + let messages = 0; + for (let page = 0; page < maxPages; page++) { + const { rows } = await db.query({ sql: "SELECT id FROM messages WHERE account_id = ? LIMIT ?", params: [accountId, PAGE_MESSAGES] }); + if (rows.length === 0) break; + const ids = rows.map((r) => String(r.id)); + await db.batch(messagePageStatements(accountId, ids)); + messages += ids.length; + } + // 아직 메시지가 남았으면(쪽 상한) 계정 단위 정리는 다음 호출로 미룬다 — 메시지 쪽이 그 정리보다 먼저다. + const { rows: left } = await db.query({ sql: "SELECT 1 AS x FROM messages WHERE account_id = ? LIMIT 1", params: [accountId] }); + if (left.length > 0) return { messages, done: false }; + + /** + * 발송 중(inFlight) 큐 항목은 지우지 않고 기다린다. 워커가 리스를 쥐고 SMTP 대화 중인 행을 지우면 + * 배달 결과를 쓸 곳이 사라진다. 리스는 짧아서 다음 tick이면 풀린다. 남아 있으면 accounts 행을 지우지 않는다. + * 리스가 **만료된** 발송 중 행은 지운다 — 그대로 두면 워커가 회수해 탈퇴한 계정의 메일을 다시 보내려 한다. + */ + const results = await db.batch([...accountStatements(accountId, opts.now ?? Date.now()), finalDeleteStatement(accountId)]); + return { messages, done: (results[results.length - 1]?.changes ?? 0) > 0 }; +} + +async function isDraining(db: DbDriver, accountId: string): Promise { + const { rows } = await db.query({ sql: `SELECT 1 AS x FROM accounts WHERE id = ? AND status = ${ACCOUNT_STATUS.deleting}`, params: [accountId] }); + return rows.length > 0; +} + +/** 메시지 한 쪽 — 메시지에 매달린 행을 모두 지운다. `account_id` 조건은 남의 메시지를 지우지 않게 하는 이중 잠금이다. */ +function messagePageStatements(accountId: string, ids: readonly string[]): Statement[] { + const ph = ids.map(() => "?").join(", "); + const owned = `(SELECT id FROM messages WHERE account_id = ? AND id IN (${ph}))`; + const byMessage = (table: string, column = "message_id"): Statement => ({ + sql: `DELETE FROM ${table} WHERE ${column} IN ${owned}`, + params: [accountId, ...ids], + }); + return [ + byMessage("message_mailbox"), + byMessage("message_keywords"), + byMessage("message_addresses"), + byMessage("message_header_projection"), + // ★본문 텍스트와 검색 색인 — 남으면 지운 메일의 본문이 DB와 백업에 영구히 남는다(reapMailbox와 같은 이유). + byMessage("message_text"), + byMessage("message_auth"), + /** + * ★search_index·blob_refs는 계정 id를 **앞에** 건다. 두 테이블 모두 message_id만으로 찾는 인덱스가 없어 + * (PK가 각각 (account_id, token, …)·(blob_id, …)) 계정 조건이 없으면 쪽마다 모든 테넌트의 행을 훑는다 — + * MySQL에서는 훑은 행에 잠금이 걸려 다른 계정의 수신이 막힌다(2026-10-19 리뷰). + */ + { sql: `DELETE FROM search_index WHERE account_id = ? AND message_id IN ${owned}`, params: [accountId, accountId, ...ids] }, + { sql: `DELETE FROM blob_refs WHERE account_id = ? AND ref_kind = ${REF_KIND.message} AND ref_id IN ${owned}`, params: [accountId, accountId, ...ids] }, + // messages 행은 맨 끝 — 위 문장들이 owned 하위 질의로 이 행을 본다. + { sql: `DELETE FROM messages WHERE account_id = ? AND id IN (${ph})`, params: [accountId, ...ids] }, + ]; +} + +/** 계정 단위 정리 — 메시지가 다 지워진 뒤에 돈다. */ +function accountStatements(accountId: string, now: number): Statement[] { + const mailboxes = "(SELECT id FROM mailboxes WHERE account_id = ?)"; + const principals = "(SELECT id FROM principals WHERE account_id = ?)"; + const byAccount = (table: string, column = "account_id"): Statement => ({ sql: `DELETE FROM ${table} WHERE ${column} = ?`, params: [accountId] }); + return [ + // 메일함과 거기 매달린 것. 메시지 쪽에서 못 지운 membership(다른 계정 메시지를 가리키는 일은 없다)도 여기서 마저. + { sql: `DELETE FROM message_mailbox WHERE mailbox_id IN ${mailboxes}`, params: [accountId] }, + { sql: `DELETE FROM expunged WHERE mailbox_id IN ${mailboxes}`, params: [accountId] }, + // ACL — 이 계정 메일함에 걸린 권한과, 이 계정이 남의 메일함에 받은 권한 둘 다. + { sql: `DELETE FROM mailbox_acl WHERE mailbox_id IN ${mailboxes}`, params: [accountId] }, + { sql: `DELETE FROM mailbox_acl WHERE principal_id IN ${principals}`, params: [accountId] }, + byAccount("mailboxes"), + byAccount("search_index"), + // 메시지 행 없이 남은 옛 고아(키워드·주소 — 주소에는 개인정보가 있다)까지 계정 id로 마저 지운다. + byAccount("message_keywords"), + byAccount("message_addresses"), + byAccount("thread_refs"), + byAccount("change_log"), + byAccount("modseq_claims"), + // 자격증명 — 기본 비밀번호·앱 비밀번호·OAuth 토큰. 웹 세션과 MFA 비밀도 함께. + byAccount("credentials"), + byAccount("account_sessions"), + byAccount("account_mfa"), + byAccount("account_recovery_codes"), + byAccount("push_subscriptions", "subject_id"), + byAccount("sieve_scripts"), + byAccount("vacation_response"), + byAccount("vacation_sent"), + byAccount("identities"), + byAccount("email_submissions"), + { + sql: `DELETE FROM mta_queue WHERE account_id = ? AND (status != ${MTA_QUEUE_STATUS.inFlight} OR lease_until IS NULL OR lease_until < ?)`, + params: [accountId, now], + }, + /** + * 큐·업로드 블롭 참조 — 메시지 참조는 쪽에서 이미 지웠다. 바로 위에서 남겨 둔 발송 중 큐 행의 참조는 지우지 않는다 — + * 지우면 워커가 본문을 다시 읽기 전에 블롭 GC가 그 파일을 회수 대상으로 찍는다. + */ + { + sql: `DELETE FROM blob_refs WHERE account_id = ? + AND NOT (ref_kind = ${REF_KIND.queue} AND ref_id IN (SELECT id FROM mta_queue WHERE account_id = ?))`, + params: [accountId, accountId], + }, + byAccount("dedup_tracking"), + byAccount("webhook_deliveries"), + byAccount("webhook_endpoints"), + byAccount("maildrop_locks"), + byAccount("bayes_tokens"), + byAccount("bayes_totals"), + byAccount("account_memberships"), + { sql: `DELETE FROM account_memberships WHERE principal_id IN ${principals}`, params: [accountId] }, + byAccount("principals"), + byAccount("directory_identities"), + // 주소→계정 연결. 주소 행(addresses) 자체는 계정을 모른다(마이그레이션 006) — 알리아스 소유라 지우지 않는다. + byAccount("address_targets"), + ]; +} + +/** + * accounts 행 — 메시지와 발송 중 큐가 정말 비었을 때만. 쪽을 다 지운 뒤에도 그 사이 끼어든 배달이 + * 있을 수 있어(상태 확인과 저장 사이) 같은 문장 안에서 다시 본다. + */ +function finalDeleteStatement(accountId: string): Statement { + return { + sql: `DELETE FROM accounts WHERE id = ? AND status = ${ACCOUNT_STATUS.deleting} + AND NOT EXISTS (SELECT 1 FROM messages WHERE account_id = ?) + AND NOT EXISTS (SELECT 1 FROM mta_queue WHERE account_id = ?)`, + params: [accountId, accountId, accountId], + }; +} diff --git a/packages/store/src/index.ts b/packages/store/src/index.ts index d3c03db..8ee2aa4 100644 --- a/packages/store/src/index.ts +++ b/packages/store/src/index.ts @@ -105,6 +105,7 @@ export { getOrLoadListing, listingCacheKey, ListingCache, LISTING_CACHE_LIMITS, export { queryInChunks } from "./chunk.ts"; export { runRetention, type RetentionOptions, type RetentionResult } from "./retention.ts"; +export { ACCOUNT_DRAIN_DEADLINE_MS, ACCOUNT_DRAIN_GRACE_MS, accountDrainStatus, drainAccount, listDrainingAccounts, type AccountDrainResult, type AccountDrainStatus } from "./account-drain.ts"; export { claimVacationReply, getVacationResponse, diff --git a/packages/store/src/store.ts b/packages/store/src/store.ts index 44aa963..63bfc89 100644 --- a/packages/store/src/store.ts +++ b/packages/store/src/store.ts @@ -465,7 +465,9 @@ export class Store { /** 삭제(툼스톤 status=2) 대기 메일함 목록 — 2단계 리퍼가 배치로 수거. */ async listReapableMailboxes(limit = 50): Promise<{ id: string; accountId: string }[]> { const { rows } = await this.db.query({ - sql: "SELECT id, account_id FROM mailboxes WHERE status = 2 ORDER BY highestmodseq LIMIT ?", + // 활성 계정의 툼스톤만 — 삭제 중인 계정의 메일함은 계정 드레인이 지운다. 섞이면 reapMailbox가 + // mustGetAccount에서 매번 실패하며 LIMIT 자리를 차지해 다른 계정의 수거를 굶긴다. + sql: "SELECT m.id AS id, m.account_id AS account_id FROM mailboxes m JOIN accounts a ON a.id = m.account_id WHERE m.status = 2 AND a.status = 1 ORDER BY m.highestmodseq LIMIT ?", params: [limit], }); return rows.map((r) => ({ id: String(r.id), accountId: String(r.account_id) })); diff --git a/packages/store/test/account-drain.test.ts b/packages/store/test/account-drain.test.ts new file mode 100644 index 0000000..10a1e3a --- /dev/null +++ b/packages/store/test/account-drain.test.ts @@ -0,0 +1,219 @@ +/** + * 계정 삭제 드레인(SCHEMA §7-7) — 탈퇴한 계정의 메일·자격증명·sieve가 실제로 지워지는가 (2026-10-19 P8). + * + * 예전엔 `account-delete`가 status=2로 바꾸기만 하고 지우는 쪽이 없어 데이터가 영구히 남았다. + * CI의 PG·MySQL(IONOSPHERE_TEST_PG_URL / IONOSPHERE_TEST_MYSQL_URL)에서도 돈다. + */ +import { afterEach, describe, expect, test } from "@ionosphere/testkit"; +import { ACCOUNT_STATUS, allMigrations, migrate, openMysql, openPostgres, openSqlite, type DbDriver } from "@ionosphere/db"; +import { ACCOUNT_DRAIN_DEADLINE_MS, ACCOUNT_DRAIN_GRACE_MS, accountDrainStatus, createAppPassword, createCredential, drainAccount, listDrainingAccounts, Store } from "../src/index.ts"; +import { makeAppendInput } from "./helpers.ts"; + +const SCHEMA = "ionosphere_test_account_drain"; + +const opened: DbDriver[] = []; +afterEach(async () => { + while (opened.length > 0) { + try { + await opened.pop()!.close(); + } catch { + /* 이미 닫혔다 */ + } + } +}); +function track(db: DbDriver): DbDriver { + opened.push(db); + return db; +} + +async function freshPostgres(base: string): Promise { + const admin = track(await openPostgres(base)); + await admin.query({ sql: `DROP SCHEMA IF EXISTS ${SCHEMA} CASCADE` }); + await admin.query({ sql: `CREATE SCHEMA ${SCHEMA}` }); + const url = new URL(base); + url.searchParams.set("options", `-c search_path=${SCHEMA}`); + const db = track(await openPostgres(url.toString())); + await migrate(db, allMigrations); + return db; +} + +async function freshMysql(base: string): Promise { + const admin = track(await openMysql(base)); + await admin.query({ sql: `DROP DATABASE IF EXISTS \`${SCHEMA}\`` }); + await admin.query({ sql: `CREATE DATABASE \`${SCHEMA}\` CHARACTER SET utf8mb4` }); + const url = new URL(base); + url.pathname = `/${SCHEMA}`; + const db = track(await openMysql(url.toString())); + await migrate(db, allMigrations); + return db; +} + +async function freshSqlite(): Promise { + const db = track(await openSqlite()); + await migrate(db, allMigrations); + return db; +} + +const dialects: { name: string; open: (() => Promise) | null }[] = [ + { name: "sqlite", open: freshSqlite }, + { name: "postgres", open: process.env.IONOSPHERE_TEST_PG_URL ? () => freshPostgres(process.env.IONOSPHERE_TEST_PG_URL!) : null }, + { name: "mysql", open: process.env.IONOSPHERE_TEST_MYSQL_URL ? () => freshMysql(process.env.IONOSPHERE_TEST_MYSQL_URL!) : null }, +]; + +/** 계정 하나에 매달린 행 수 — 드레인 뒤 전부 0이어야 한다. */ +const OWNED: readonly [string, string][] = [ + ["accounts", "id"], + ["messages", "account_id"], + ["mailboxes", "account_id"], + ["credentials", "account_id"], + ["sieve_scripts", "account_id"], + ["principals", "account_id"], + ["change_log", "account_id"], + ["modseq_claims", "account_id"], + ["blob_refs", "account_id"], + ["message_keywords", "account_id"], + ["message_addresses", "account_id"], + ["search_index", "account_id"], + ["thread_refs", "account_id"], + ["message_keywords", "account_id"], + ["message_addresses", "account_id"], + ["mta_queue", "account_id"], +]; + +async function ownedCounts(db: DbDriver, accountId: string): Promise> { + const out: Record = {}; + for (const [table, col] of OWNED) { + const { rows } = await db.query({ sql: `SELECT COUNT(*) AS n FROM ${table} WHERE ${col} = ?`, params: [accountId] }); + out[table] = Number(rows[0]!.n); + } + return out; +} + +async function messageSideRows(db: DbDriver, messageIds: readonly string[]): Promise { + let n = 0; + for (const id of messageIds) { + for (const table of ["message_mailbox", "message_text", "message_header_projection"]) { + const { rows } = await db.query({ sql: `SELECT COUNT(*) AS n FROM ${table} WHERE message_id = ?`, params: [id] }); + n += Number(rows[0]!.n); + } + } + return n; +} + +async function markDeleting(db: DbDriver, accountId: string, since: number): Promise { + await db.batch([{ sql: "UPDATE accounts SET status = ?, deleting_since = ? WHERE id = ?", params: [ACCOUNT_STATUS.deleting, since, accountId] }]); +} + +/** 메일·앱 비밀번호·sieve·추가 메일함을 가진 계정. */ +async function populatedAccount(db: DbDriver, store: Store, tenantId: string, email: string) { + const { accountId, mailboxId: inboxId } = await store.createAccount({ tenantId, email }); + await createCredential(db, { accountId, password: "Correct-Horse-9" }); + await createAppPassword(db, accountId, "phone"); + await store.putSieveScript(accountId, "main", 'require "fileinto"; fileinto "Work";'); + const { mailboxId: work } = await store.createMailbox({ accountId, name: "Work" }); + const messageIds: string[] = []; + for (let i = 0; i < 3; i++) { + const { messageId } = await store.appendMessage(makeAppendInput({ accountId, mailboxIds: i === 0 ? [inboxId, work] : [inboxId], sizeBytes: 100 + i })); + messageIds.push(messageId); + } + return { accountId, messageIds }; +} + +for (const d of dialects) { + describe.skipIf(d.open === null)(`계정 삭제 드레인 — ${d.name}`, () => { + test("탈퇴한 계정의 메일·자격증명·sieve·메일함을 지우고 계정 행까지 없앤다. 다른 계정은 건드리지 않는다", async () => { + const db = await d.open!(); + const store = new Store(db); + const { tenantId } = await store.createTenant("t"); + const gone = await populatedAccount(db, store, tenantId, "gone@ionosphere.test"); + const kept = await populatedAccount(db, store, tenantId, "kept@ionosphere.test"); + const keptBefore = await ownedCounts(db, kept.accountId); + + await markDeleting(db, gone.accountId, Date.now()); + const r = await drainAccount(db, gone.accountId); + expect(r).toEqual({ messages: 3, done: true }); + + const after = await ownedCounts(db, gone.accountId); + expect(Object.entries(after).filter(([, n]) => n > 0)).toEqual([]); + expect(await messageSideRows(db, gone.messageIds)).toBe(0); + // 이웃 계정은 그대로 + expect(await ownedCounts(db, kept.accountId)).toEqual(keptBefore); + // 같은 주소로 다시 가입할 수 있다(계정 행이 사라져 유니크 키가 풀렸다) + await store.createAccount({ tenantId, email: "gone@ionosphere.test" }); + }); + + test("활성·정지 계정은 드레인하지 않는다(status=2만)", async () => { + const db = await d.open!(); + const store = new Store(db); + const { tenantId } = await store.createTenant("t"); + const active = await populatedAccount(db, store, tenantId, "active@ionosphere.test"); + const suspended = await populatedAccount(db, store, tenantId, "suspended@ionosphere.test"); + await db.batch([{ sql: "UPDATE accounts SET status = ? WHERE id = ?", params: [ACCOUNT_STATUS.suspended, suspended.accountId] }]); + const before = [await ownedCounts(db, active.accountId), await ownedCounts(db, suspended.accountId)]; + + expect(await drainAccount(db, active.accountId)).toEqual({ messages: 0, done: false }); + expect(await drainAccount(db, suspended.accountId)).toEqual({ messages: 0, done: false }); + expect(await listDrainingAccounts(db)).toEqual([]); + expect([await ownedCounts(db, active.accountId), await ownedCounts(db, suspended.accountId)]).toEqual(before); + }); + + test("큰 계정은 쪽 상한만큼 지우고 돌아오고, 다음 호출이 이어서 끝낸다", async () => { + const db = await d.open!(); + const store = new Store(db); + const { tenantId } = await store.createTenant("t"); + const { accountId, mailboxId } = await store.createAccount({ tenantId, email: "big@ionosphere.test" }); + for (let i = 0; i < 120; i++) await store.appendMessage(makeAppendInput({ accountId, mailboxIds: [mailboxId] })); + await markDeleting(db, accountId, Date.now()); + + const first = await drainAccount(db, accountId, { maxPages: 1 }); + expect(first.done).toBe(false); + expect(first.messages).toBe(98); + const second = await drainAccount(db, accountId, { maxPages: 1 }); + expect(second).toEqual({ messages: 22, done: true }); + expect(Object.values(await ownedCounts(db, accountId)).every((n) => n === 0)).toBe(true); + }); + + test("발송 중(리스 유효) 큐 항목이 있으면 계정 행을 남겨 두고, 리스가 풀리면 끝낸다", async () => { + const db = await d.open!(); + const store = new Store(db); + const { tenantId } = await store.createTenant("t"); + const { accountId } = await store.createAccount({ tenantId, email: "sending@ionosphere.test" }); + const now = Date.now(); + await db.batch([ + { + sql: `INSERT INTO mta_queue (id, tenant_id, account_id, blob_id, env_from, rcpt, rcpt_domain, status, attempts, next_attempt, lease_until, created_at) + VALUES (?, ?, ?, ?, ?, ?, ?, 1, 1, ?, ?, ?)`, + params: ["01QUEUEINFLIGHT00000000000", tenantId, accountId, "c".repeat(64), "sending@ionosphere.test", "x@example.com", "example.com", now, now + 60_000, now], + }, + ]); + await markDeleting(db, accountId, now); + + expect((await drainAccount(db, accountId, { now })).done).toBe(false); + const { rows } = await db.query({ sql: "SELECT COUNT(*) AS n FROM accounts WHERE id = ?", params: [accountId] }); + expect(Number(rows[0]!.n)).toBe(1); + // 리스 만료 뒤 — 워커가 회수해 다시 보내기 전에 지운다 + expect((await drainAccount(db, accountId, { now: now + 120_000 })).done).toBe(true); + }); + + test("지표: 24시간 마감을 넘긴 계정을 overdue로 센다", async () => { + const db = await d.open!(); + const store = new Store(db); + const { tenantId } = await store.createTenant("t"); + const now = Date.now(); + const { accountId: fresh } = await store.createAccount({ tenantId, email: "fresh@ionosphere.test" }); + const { accountId: stale } = await store.createAccount({ tenantId, email: "stale@ionosphere.test" }); + const { accountId: justNow } = await store.createAccount({ tenantId, email: "justnow@ionosphere.test" }); + await markDeleting(db, fresh, now - ACCOUNT_DRAIN_GRACE_MS - 60_000); + await markDeleting(db, stale, now - ACCOUNT_DRAIN_DEADLINE_MS - 1); + await markDeleting(db, justNow, now - 1_000); + + expect(await accountDrainStatus(db, now)).toEqual({ pending: 3, overdue: 1 }); + // 오래된 것부터, 유예(열린 JMAP 세션의 늦은 쓰기를 기다린다)가 지난 것만 드레인한다 + expect(await listDrainingAccounts(db, 10, now)).toEqual([stale, fresh]); + await drainAccount(db, justNow); + await drainAccount(db, stale); + await drainAccount(db, fresh); + expect(await accountDrainStatus(db, now)).toEqual({ pending: 0, overdue: 0 }); + }); + }); +} diff --git a/packages/store/test/app-password-random.test.ts b/packages/store/test/app-password-random.test.ts index 74633a0..86e057d 100644 --- a/packages/store/test/app-password-random.test.ts +++ b/packages/store/test/app-password-random.test.ts @@ -34,7 +34,9 @@ describe("앱 비밀번호 — 128비트 이상", () => { expect(await authenticate(db, "u@x.test", password, "imap")).toMatchObject({ accountId, credKind: "appPassword", credentialId: id }); const sloppy = password.replace(/-/g, " ").toUpperCase(); expect(await authenticate(db, "u@x.test", sloppy, "imap")).toMatchObject({ credentialId: id }); - expect(await authenticate(db, "u@x.test", password.slice(0, -1) + "a", "imap")).toBeNull(); + // 마지막 글자를 **다른** 글자로 바꾼다. 늘 "a"로 바꾸면 원래 끝이 "a"일 때(1/26) 같은 비밀번호가 되어 CI가 가끔 깨졌다. + const last = password.slice(-1); + expect(await authenticate(db, "u@x.test", password.slice(0, -1) + (last === "a" ? "b" : "a"), "imap")).toBeNull(); }); test("표면 제한은 빠른 경로에서도 지킨다", async () => {