diff --git a/docs/design/webmail/README.md b/docs/design/webmail/README.md index fcc7c3b..4ffb152 100644 --- a/docs/design/webmail/README.md +++ b/docs/design/webmail/README.md @@ -770,6 +770,7 @@ API 와 서버 규칙은 계약 2절 "실시간 알림". 설정 메뉴의 계정 묶음: 비밀번호 · 2단계 인증 · 앱 비밀번호 · **메일 프로그램 연결**(새 — 아이콘 PC · 휴대폰, lucide `monitor-smartphone`) · 로그인 기록. 새 토큰은 없다 — 유예 노란 줄 40 · 줄 안 단추 36 은 안내 줄 밀도 예외(`--size-notice-bar` · `--size-notice-action`)를 다시 쓴다. 메일 프로그램 연결의 펼침 줄은 52(이름 · 설명 두 줄)라 예외가 아니다. 로그인 틀(브랜드 패널 + 양식 칸 360)과 칸 · 단추 · 위쪽 오류 상자는 `Login` · `LoginTwoFactor` 와 같다. 각 보드 위쪽은 실제 크기 화면, 아래는 상태 카드다. +화면이 모르는 제한 이름이 오면(서버가 새 제한을 더했을 때) 로그인 틀에 `AUTH_010` 사전 문구와 [다른 계정으로 로그인]만 보인다 — 새로 받기 · 다시 보내기를 되풀이하지 않는다. ### 로그인 — 새 비밀번호 정하기 (`LoginPasswordChange`) @@ -793,9 +794,12 @@ API 와 서버 규칙은 계약 2절 "실시간 알림". - **상태**(칸 아래 오류는 알림 없이 · 처음 걸린 칸에 초점): - 규칙에 맞지 않음(8자 미만 · 임시 비밀번호와 같음) — 보내지 않고 새 비밀번호의 안내 글이 그대로 빨간 오류가 된다. 엔진이 규칙으로 거절하면 같은 자리에 `VAL_001` 사전 문구. - 확인 칸이 다름 — 확인 칸 아래 "새 비밀번호가 서로 다릅니다." - - 임시 비밀번호 틀림 — 그 칸 아래 `AUTH_005` 사전 문구("지금 비밀번호가 올바르지 않습니다."). + - 임시 비밀번호 칸이 빔 — 보내지 않고 그 칸의 안내 글이 그대로 빨간 오류가 된다. + - 임시 비밀번호 틀림 — 그 칸 아래 `AUTH_005` 사전 문구("지금 비밀번호가 올바르지 않습니다."). 화면 메모리에서 쓴 비밀번호가 거절되면 기억을 지우고 「임시 비밀번호」 칸을 이 오류와 함께 보인다. - 코드 틀림 — 코드 칸 아래 `AUTH_006` 사전 문구, 칸을 비운다. - 시도 과다 — 위쪽 빨간 상자(로그인 오류와 같은 자리 · 같은 모양)에 `REQ_001` 사전 문구. 임시 비밀번호 틀림은 로그인 실패 한도, 코드 틀림은 코드 실패 한도로 센다. 칸 · 단추는 그대로. + - 바꾼 뒤 넘기지 못함(새 토큰 받기 `/auth/refresh` 가 장애) — 먼저 `me`(세션 표의 지금 값)를 묻는다. 제한이 남아 있으면 그 단계로, 없으면 가려던 주소로. + `me` 도 받지 못하면 이 단계에 머물며 위쪽 빨간 상자에 사전 문구 + [다시 시도](36) — 칸 · 단추는 막고 [다른 계정으로 로그인]만 누를 수 있다. 2단계 켜기 단계도 같다. - **끝나면**: 서버가 제한을 지우고 화면은 `/auth/refresh` 로 새 토큰을 받아 받은편지함으로 간다(로그인 전에 가려던 주소가 있으면 그곳) — 알림 "새 비밀번호를 정했습니다." 2단계 인증이 필수인데 켜지 않았으면 받은편지함 대신 「2단계 인증 켜기」 단계로. 다른 곳의 웹메일 로그인 끊기 · 앱 비밀번호 지우기는 설정 › 비밀번호와 같다(계약 5절) — 맨 아래 안내 줄이 그 까닭이다. @@ -822,8 +826,10 @@ API 와 서버 규칙은 계약 2절 "실시간 알림". - 코드 틀림 — 칸 아래 `AUTH_006` 사전 문구, 칸을 비우고 초점. QR · 키는 그대로라 다시 찍지 않는다. - 시간이 지남 · 그사이 바뀜(`AUTH_008`) — 위쪽 빨간 상자에 사전 문구("2단계 인증 설정이 그사이 바뀌었거나 시간이 지났습니다. 처음부터 다시 해 주세요.") + 새 QR · 키로 다시 그린다(코드 칸은 비움). 다른 곳에서 이미 켰으면 이 단계를 건너뛰고 받은편지함으로. - - 지금 비밀번호 틀림(③ 이 있을 때) — 칸 아래 `AUTH_005`. 둘 다 틀렸으면 코드부터(서버가 코드를 먼저 본다 — 12단계). + - ③ 지금 비밀번호가 빔 — 보내지 않고 그 칸의 안내 글이 그대로 빨간 오류가 된다(새 비밀번호 단계의 임시 비밀번호와 같다). + - 지금 비밀번호 틀림(③ 이 있을 때) — 칸 아래 `AUTH_005`. 둘 다 틀렸으면 코드부터(서버가 코드를 먼저 본다 — 12단계). 화면 메모리에서 쓴 비밀번호가 거절되면 기억을 지우고 ③ 칸을 이 오류와 함께 보인다. - 시도 과다 — 위쪽 상자에 `REQ_001`(코드 실패는 같은 주소 15분 5회 · 하루 20회 — 12단계 그대로). + - 켠 뒤 넘기지 못함 — 새 비밀번호 단계의 같은 상태와 같다(`me` 를 먼저 묻고, 그것도 실패하면 위쪽 상자 + [다시 시도]). - **켠 뒤**: 서버가 제한을 지우고 받은편지함으로 간다(가려던 주소가 있으면 그곳) — 알림 "2단계 인증을 켰습니다. 메일 프로그램은 앱 비밀번호로 연결합니다." + [앱 비밀번호 만들기] (단추가 있는 알림 — 10초, 마우스 · 초점이 있으면 멈춤) → 설정 › 앱 비밀번호. 웹메일 비밀번호로 메일 프로그램을 연결해 둔 직원이 다음 연결 때 막히기 전에 알린다. - **유예 기간**(시작일 전 — 로그인은 그대로, `me` 의 `twoFactorDeadline`): diff --git a/docs/mail/api-contract.md b/docs/mail/api-contract.md index 6f0d2f3..57d6d25 100644 --- a/docs/mail/api-contract.md +++ b/docs/mail/api-contract.md @@ -2387,7 +2387,9 @@ interface FileUploadCreate { ### 공유 계약을 바꾸는 곳 (한데 모음) - `POST /api/auth/login`(`signedIn`) · `GET /api/auth/me` · `POST /api/auth/refresh` 응답에 `restriction`(`null` · `passwordChange` · `twoFactorSetup`)과 - `twoFactorDeadline`(2단계 필수의 유예 중이면 시작일 0시(KST)의 시각 — ISO 8601 UTC, 아니면 `null`)을 더한다. + `twoFactorDeadline`(2단계 필수의 유예 중이고 이 직원이 아직 2단계를 켜지 않았으면 시작일 0시(KST)의 시각 — ISO 8601 UTC, 아니면 `null`)을 더한다. + - 두 칸은 늘 싣는다(refresh 의 유예 응답 — 쿠키 없는 200 — 도). 칸이 없으면 클라이언트는 제한 없음으로 읽는다. + - me · refresh 의 `restriction` 은 액세스 토큰이 아니라 세션 표의 지금 값이다 — 제한을 푼 직후 옛 토큰으로 새로고침해도 마친 단계가 다시 보이지 않게. - 제한된 세션으로 다른 API 를 부르면 403 `AUTH_010` 이다. - `GET /api/two-factor` 응답에 `isRequired`(이 직원에게 필수인가) · `deadline`. - 새 공개 API `GET /api/support-contact`(로그인 없음). @@ -2399,11 +2401,16 @@ interface FileUploadCreate { - 엔진 계정에는 "끄기" 칸이 없다. 로그인 권한(`authenticate`)을 빼면 비밀번호 · 앱 비밀번호 · 대리 접근(웹메일)이 모두 막히고 메일은 계속 받는다. 되돌리기 쉽다. - 비밀번호 자격의 만료(`expiresAt` 을 지난 날로)는 그 계정의 권한을 비밀번호 바꾸기만으로 줄인다: 본인 Basic 은 200 이지만 권한이 다섯 (`authenticate` · `authenticateWithAlias` · `sysAccountPasswordGet` · `sysAccountPasswordUpdate` · `emailReceive`)뿐이고, 본인이 바꾸면 엔진이 만료를 스스로 푼다. 앱 비밀번호는 영향이 없다(자체 만료). - - 웹메일(대리 접근)도 세션은 열리지만 메일 메서드가 `forbidden` 이다 — 그대로면 로그인 뒤 메일 오류만 보인다. 그래서 서버가 로그인 때 알아채 새 비밀번호 단계로 보낸다(아래 "비밀번호 바꾸기 강제"). - - 만료된 동안은 대리 접근으로 메일을 넣거나 앱 비밀번호를 지우는 일도 막힐 것이다(구현 때 확인) — 그래서 운영 도구는 그 일을 만료를 걸기 전에 한다(아래 "운영 도구"). + - 웹메일(대리 접근)도 세션은 열리지만 메일 메서드가 `forbidden` 이다 — 그대로면 로그인 뒤 메일 오류만 보인다. 그래서 서버가 로그인 · 갱신 때 알아채 새 비밀번호 단계로 보낸다(아래 "비밀번호 바꾸기 강제"). + 그 밖의 메일 API 가 만료를 만나면(운영 도구가 재설정한 직후 등) 401 `AUTH_001` 이다 — 화면이 바로 갱신해 그 단계로(세션이 끊겼으면 로그인 화면으로) 간다. + - 만료된 동안은 대리 접근으로 메일을 넣거나 앱 비밀번호를 지우는 일도 막힌다(개발 엔진 확인 — 세션에 메일 계정이 없어 `Mailbox/get` · `Email/import` · `SieveScript` 를 부를 수 없고 `x:AppPassword` 는 `forbidden`, + 되는 것은 blob 올리기 · `x:AccountPassword/get·set` · `/api/account`) — 그래서 운영 도구는 그 일을 만료를 걸기 전에 한다(아래 "운영 도구"). - 엔진에서 비밀번호를 바꾸거나 막아도 웹메일 세션은 끊기지 않는다. 갱신이 게이트웨이로 계정이 있는지만 보고, 엔진에는 바뀐 것을 알리는 칸이 없다. - 그래서 운영 도구가 세션을 직접 끊는다. - 엔진에는 2단계 인증을 강제하는 설정이 없다 — 서버 정책으로 둔다. +- 엔진은 잠긴 계정(`authenticate` 뺌)에도 2단계를 먼저 본다 — 2단계를 켠 잠긴 계정의 맞는 비밀번호는 402 이고, 그 뒤 대리 접근이 403 이다. + 서버는 그 403 을 게이트웨이 자신의 권한(`/api/account` 에 `impersonate` — 1분 기억)으로 가른다: 게이트웨이에 권한이 있으면 그 계정이 잠긴 것 → 로그인은 401 `AUTH_004`(실패 한도에 센다 — 틀린 비밀번호와 같게, 잠겼는지 · 비밀번호가 맞는지 드러내지 않는다), + 없거나 읽지 못하면 게이트웨이 설정 문제 → 503 `MAIL_001`(갱신 · 메일 API 는 지금처럼 503 · 세션 유지). - 대리 접근으로 직원 받은편지함에 메일을 넣을 수 있다(`Email/import`). 안 읽음 · 실시간 알림이 가고, 필터 · 스팸 판정은 거치지 않는다. ### 제한된 세션 @@ -2415,9 +2422,12 @@ interface FileUploadCreate { - `/api/support-contact` - 그 밖은 403 `AUTH_010` 이다. - 차례: `passwordChange` 가 먼저, 그다음 `twoFactorSetup`. -- 푸는 요청(비밀번호 바꾸기 · 2단계 켜기)이 성공하면 서버가 세션의 제한을 지운다. 클라이언트는 곧바로 `/auth/refresh` 로 새 토큰을 받는다. - - 다음 제한이 남아 있으면 그 제한이 붙어 온다. +- 푸는 요청(비밀번호 바꾸기 · 2단계 켜기)이 성공하면 서버가 **그 요청의 세션**(액세스 토큰의 `sid`)의 제한을 지운다. 클라이언트는 곧바로 `/auth/refresh` 로 새 토큰을 받는다. + - 다음 제한이 남아 있으면 지우지 않고 그 제한으로 바꾼다(`passwordChange` → `twoFactorSetup`) — 옛 토큰으로도 다음 단계 밖은 막힌다. + - 2단계를 켜면 그 직원의 **다른 세션 가운데 `twoFactorSetup` 제한이 붙은 것은 끊는다**(2단계를 거친 적이 없는 세션 — 비밀번호만 아는 다른 사람의 세션일 수 있다). + 갱신에서도 세션 표가 `twoFactorSetup` 인데 계정은 이미 켜짐이면 제한을 지우지 않고 끊는다. 비밀번호 바꾸기는 5절 규칙대로 다른 세션을 모두 끊는다. - 새로고침 · 다른 탭에서도 같은 제한이다(서버가 막는다). 화면은 `me` 의 `restriction` 을 보고 로그인 틀의 그 단계로 간다. +- 가드는 제한이 실린 액세스 토큰만 요청마다 세션 표의 지금 값을 읽는다(제한된 동안만 DB 한 번). 제한이 없는 토큰은 묻지 않는다 — 그래서 시작일이 지난 뒤의 2단계 필수는 다음 갱신 때 붙는다. - 클라이언트는 403 `AUTH_010` 을 받으면 알림 없이 `me` 를 다시 받아 그 제한의 단계로 간다. 제한된 동안은 실시간 알림(`/api/mail-events`)도 열지 않는다 — 제한이 풀린 뒤 연다. ```jsonc @@ -2430,8 +2440,9 @@ interface FileUploadCreate { ### 비밀번호 바꾸기 강제 (`restriction: passwordChange`) - 언제: 엔진의 비밀번호가 만료됐을 때다. 운영 도구의 입사 · 비밀번호 재설정이 건다. -- 알아채는 법: 로그인 때 본인 자격으로 엔진에 묻는다(권한이 비밀번호 바꾸기뿐인가 — 방법은 구현 때 정한다). - - 알아챈 제한은 세션에 남는다 — 갱신은 게이트웨이라 다시 알아채지 못하므로. +- 알아채는 법: 로그인 · 갱신 때 게이트웨이 대리 접근 세션을 본다 — 만료된 계정은 세션에 메일 계정이 없고 권한(`/api/account`)이 위 다섯 안이다(본인 Basic 도 200 — 402 가 아니다). + - 만료를 건 직후 0.5초쯤은 엔진 세션 캐시가 옛 모양이라, 메일 메서드가 `forbidden` 이면 권한으로 다시 본다. + - 갱신 때마다 엔진을 기준으로 다시 본다 — 만료면 `passwordChange` 를 붙이고, 다른 곳에서 바꿔 풀렸으면 지운다. 세션 표에도 남긴다. - 푸는 법: `PUT /api/auth/password` — 지금 비밀번호는 임시 비밀번호, 새 비밀번호, 2단계를 켰으면 `otpCode`. - 성공하면 엔진의 만료가 풀리고 세션 제한을 지운다. - 다른 세션 끊기 · 앱 비밀번호 지우기는 5절 규칙 그대로다. @@ -2439,7 +2450,8 @@ interface FileUploadCreate { 10분이 지났거나 새로고침 · 다른 탭이면 「임시 비밀번호」 칸을 보인다. - 2단계를 켠 직원은 비밀번호 → 코드 → 이 단계이고, 이 단계에서 코드를 한 번 더 받는다(`otpCode` — 엔진이 비밀번호를 바꿀 때 코드를 요구한다). 로그인 때 쓴 코드는 2분 동안 다시 받지 않으므로(5절) 앱의 다음 코드를 넣는다. 서버가 로그인 코드를 기억해 다시 쓰지 않는다 — 새 비밀번호를 치는 사이 코드의 유효 시간(앞뒤 30초)을 지날 수 있다. -- 엔진이 만료와 2단계 중 무엇을 먼저 보는지는 구현 때 확인한다. +- 엔진은 2단계를 먼저 본다(개발 엔진 확인) — 만료 + OTP 계정의 Basic 은 402, 코드 로그인 뒤 대리 접근이 만료 모양이라 흐름은 비밀번호 → 코드 → 이 단계다. + 비밀번호 바꾸기는 대리 접근의 `x:AccountPassword/set` + `otpCode` 로 되고 만료도 풀린다. 관리자의 재설정(secret + expiresAt)은 OTP 를 지우지 않는다. ### 2단계 인증 필수 (`restriction: twoFactorSetup`) @@ -2449,7 +2461,9 @@ interface FileUploadCreate { - 로그인은 엔진의 답(402 면 켜짐)으로, 갱신은 정책과 우리 기록(`two_factor_settings`)으로 다시 본다 — 시작일이 지나면 이미 로그인한 세션도 다음 갱신(최대 1시간) 때 제한이 붙는다. - 켜기는 12단계 API(`enrollments` · `activation`) 그대로다. 지금 비밀번호는 위 "클라이언트"와 같은 규칙이다. - 켜면 세션 제한을 지운다. 화면은 받은편지함으로 가며 [앱 비밀번호 만들기] 알림을 띄운다(디자인 README 13단계). -- `GET /api/two-factor` 의 `isRequired` · `deadline` 으로 설정 화면도 같은 안내를 보인다. 필수인 직원은 끄기 단추가 없다 — 서버도 400 `VAL_001` 로 거절한다. +- `GET /api/two-factor` 의 `isRequired` · `deadline` 으로 설정 화면도 같은 안내를 보인다(`deadline` 은 `twoFactorDeadline` 과 같은 규칙 — 켜기 · 끄기 응답에도 싣는다). + 필수인 직원은 끄기 단추가 없다 — 서버도 400 `VAL_001` 로 거절한다. +- 정책을 꺼도 시작일 값은 DB 에 남는다 — 꺼짐이면 쓰지 않고, 다시 켤 때 운영 도구가 시작일을 다시 묻는다. - 필수인 직원이 휴대폰을 바꿀 때는 관리자가 운영 도구의 「2단계 해제」를 하고, 다음 로그인 때 새 휴대폰으로 켠다(직원이 켜진 채 새 휴대폰을 등록하는 길은 이 단계에 없다). - 메일 프로그램은 앱 비밀번호로 연결한다(12단계 그대로). @@ -2487,6 +2501,8 @@ interface FileUploadCreate { - 운영자 이름 — 실행 기록에 남긴다. - 관리자 비밀번호 — 저장하지 않는다. 화면에 보이지 않게 넣는다. - 실행 기록 표(`ops_audit_log`): 언제 · 누가 · 명령 · 대상 · 결과. 비밀은 남기지 않는다. +- 표준 입력이 터미널(TTY)이 아니면 운영자 이름 · 비밀번호를 묻기 전에 멈춘다(`docker exec -it` 로 실행). 입력 기록(↑)에 비밀번호를 남기지 않는다. + 자동 시험만 `OPS_ALLOW_NO_TTY=1` 로 파이프 입력을 받는다 — 사람은 쓰지 않는다. - 명령 | 명령 | 하는 일 | @@ -2503,9 +2519,21 @@ interface FileUploadCreate { | 정책 · 설정 | 2단계 필수(켜기 · 시작일 · 예외), 문의 연락처 | - 차례가 있는 명령 - - 비밀번호 재설정: 앱 비밀번호 지우기 · 알림 메일 넣기(대리 접근) → 엔진에 임시 비밀번호 + 만료 → 웹메일 세션 끊기. 만료 뒤에는 대리 접근 메서드가 막히기 때문이다. 엔진 바꾸기가 실패하면 넣은 알림 메일을 지운다. + - 비밀번호 재설정: 앱 비밀번호 지우기(대리 접근) → 웹메일 세션 끊기(DB) → 알림 메일 넣기(대리 접근) → 엔진에 임시 비밀번호 + 만료 → 세션 한 번 더 끊기. + 만료 뒤에는 대리 접근 메서드가 막히고, 알림이 말하는 일(로그아웃 · 앱 비밀번호 지움)은 넣기 전에 끝나 있어야 하기 때문이다. 첫 세션 끊기가 실패하면 알림을 넣기 전에 멈추고, + 엔진 바꾸기가 실패하면 넣은 알림 메일을 지운다. 마지막 끊기는 그사이 옛 비밀번호로 새로 로그인한 세션을 위한 것이다. - 잠금: 받는 메일 넘기기 · 자동 응답(대리 접근으로 Sieve)을 먼저 걸고 → `authenticate` 빼기 → 세션 끊기. 뺀 뒤에는 대리 접근이 막혀 Sieve 를 새로 걸 수 없다(이미 건 Sieve 는 계속 돈다). - - 퇴사 마무리: 엔진 계정을 지운 뒤 우리 DB 에서 그 주소의 행을 지운다 — 로그인 기록 · 앱 비밀번호 사용 · 2단계 설정 · 서명 · 설정 · 세션 · 사내 목록 기록 · 대용량 파일 · 링크. 실행 기록(`ops_audit_log`)은 남긴다. + - 퇴사 마무리: 엔진 계정을 지운 뒤 우리 DB 에서 그 주소의 행을 한 트랜잭션으로 지운다 — 로그인 기록 · 앱 비밀번호 사용 · 2단계 설정 · 서명 · 설정 · 사내 목록 기록 · 대용량 파일 · 링크, + 그리고 2단계 필수의 예외 주소 목록과 공용 주소(MailingList)의 구성원에서도 뺀다(실행 전 보여 주는 바뀔 것에 든다). + 세션 행은 바로 지우지 않는다 — 끊은 기록을 서버가 15초 동기화로 읽어야 하므로 끝난 세션 정리 cron 에 맡긴다(로그인된 브라우저 목록은 살아 있는 세션만 보인다). + 디스크 파일(대용량 · 서명 그림)은 그 뒤에 지우고 못 지운 것은 정리 cron 에 맡긴다. 실행 기록(`ops_audit_log`)은 남기고, 읽기만 하는 현황은 기록하지 않는다. + - 비밀번호 재설정은 잠긴 계정을 거절한다(대리 접근이 막혀 있다 — 잠금 풀기가 재설정을 함께 권한다). 이미 만료된 계정을 다시 재설정하면 앱 비밀번호 지우기 · 알림 넣기를 건너뛴다 + (엔진이 막는다 — 엔진 관리 화면에서 만료를 걸어 앱 비밀번호가 남은 드문 경우는 `[못 함]` 으로 알리고 이어 간다). + - 잠금의 넘기기 · 자동 응답은 그 직원의 Sieve 스크립트 `arasystem`(필터 · 부재중과 같은 스크립트 — 엔진은 계정마다 스크립트 하나만 켠다)에 넣는다: + 넘기기는 맨 앞 필터 규칙 `redirect :copy`(이름 "잠금 — 받는 메일 넘기기 (운영 도구)"), 자동 응답은 부재중 칸. 잠금 풀기는 걸려 있는 것을 뗄지 묻는다(부재중은 직원이 건 것일 수 있다). + - 잠금 풀기는 `authenticate` 빼기만 되돌리고 남은 것이 없으면 상속(Inherit)으로 돌린다 — 직접 정한 권한(Replace)인 계정은 바꾸지 않는다. +- 엔진의 답을 받지 못해 결과를 모르는 실패(시간 초과 · 네트워크 · 5xx)는 "결과를 모릅니다 — 같은 명령을 다시 하세요"로 알린다 — 재설정은 넣은 알림을 지우지 않고, 퇴사 마무리는 DB 를 건드리지 않는다(다시 하면 남은 것만). + 퇴사 마무리에서 그 직원이 공용 주소의 마지막 구성원이면 뺄지 먼저 묻는다(빼면 받는 사람이 없다). 2단계 필수 예외 주소 넣기 · 빼기는 y/N 뒤 퇴사 마무리와 같은 행 잠금 안에서 읽고 쓴다. - 관리자 계정에서 대리 접근 권한(`impersonate`)을 빼므로(인프라 — mail/README) 도구가 직원의 메일함 · Sieve · 앱 비밀번호를 다룰 때는 웹메일과 같은 게이트웨이 대리 접근을 쓴다. 관리자 Basic 은 계정 관리 API(`x:Account` · `x:MailingList`)에만 쓴다. - **도구로 끊은 세션도 15초 안에 막힌다.** 세션 끊기는 DB 로 하고, 서버는 끊은 세션 기억을 15초마다 DB 에서 채운다(5절의 기억에 더한다). - 잠금은 도구로 한다. 엔진 관리 화면에서 `authenticate` 만 빼면 웹메일 세션이 남는다 — 갱신이 503 `MAIL_001` 을 내며 세션을 그대로 둔다(엔진의 대리 접근 403 을 설정 문제로 본다).