Compare commits

..

3 Commits

Author SHA1 Message Date
ttipo 9c232b575d docs(rules): 메일 이전 부품 — 스킬 표 · 이전용 비밀번호 보안 · imapsync 이미지 고정
CI / Backend (lint · type-check · test · e2e · build) (pull_request) Successful in 13m8s
CI / Frontend (lint · type-check · test · build) (pull_request) Successful in 12m30s
CI / Infra (compose · Dockerfile · nginx · 운영 스크립트) (pull_request) Successful in 1m50s
CI / 에러 코드 SSOT 동기화 검사 (pull_request) Successful in 11m9s
CI / Frontend (lint · type-check · test · build) (push) Has been cancelled
CI / Infra (compose · Dockerfile · nginx · 운영 스크립트) (push) Has been cancelled
CI / 에러 코드 SSOT 동기화 검사 (push) Has been cancelled
CI / Backend (lint · type-check · test · e2e · build) (push) Has been cancelled
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-02 22:51:17 +09:00
ttipo 24179edaaa docs: 메일 이전 — 계약 6절 · mail/README imapsync 차례 · 전환 절차
imapsync 절을 새 차례(계정 → 1차 → MX → 마지막 imapsync → 5분 뒤 이전 마치기 → 임시 비밀번호 나눠 주기)로 다시 썼다.
mailcow 고정 마스터 계정(사무실 PC 의 mailcow.conf), 서버 B 의 명령(고정 이미지 · 인증서 확인 · 비밀번호 파일),
개발에서 확인한 것(폴더 맞춤 · 수 · 날짜 · 표시 · 한글 폴더 · 차이분 · 만료 뒤 실패)을 적었다.
엔진 사실: 만료가 미래면 그때까지 IMAP 정상, 기본 만료의 찌꺼기는 끈 뒤에도 남는다.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-02 22:51:16 +09:00
ttipo c8287c4819 feat(backend): 운영 도구 메일 이전 — 이전용 비밀번호 · 「메일 이전」 · 이전 중 판정
전 직원 전환의 imapsync 는 새 엔진에 IMAP 으로 들어가야 하는데, 입사 · 재설정의 임시 비밀번호는
만료라 IMAP 이 끊긴다. 직원이 비밀번호를 바꾸면 마지막 차이분을 옮길 길도 없었다.
- 「입사」의 옮겨 올 메일 질문 → 이전용 비밀번호(엔진 만료 30일 뒤 · 실행 한 번 안에서 같은 값 · 메모리에만)
- 메뉴 「11. 메일 이전」 — 이전 현황 · 이전용 비밀번호 다시 만들기(세션을 먼저 끊는다) · 이전 마치기
  (앱 비밀번호 → 세션 → imapsync 로그인 기록 지우기 → 임시 비밀번호 + 만료 → 세션, 알림 메일 없음, 끝에 표 한 번)
- 이전 중 = 엔진 만료가 미래(31일 안) + 실행 기록의 마지막 판정 기록이 이전용(hire-migration · migration-password)
  — 엔진 기본 만료의 찌꺼기를 이전 중으로 읽지 않게
- 재설정 · 잠금 풀기 가드, 현황의 이전 중 표시, 엔진 기본 만료는 첫 화면 알림만

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-02 22:51:16 +09:00
24 changed files with 3823 additions and 129 deletions
+2 -1
View File
@@ -33,7 +33,8 @@
제한을 푸는 요청은 **그 요청의 세션만** 풀고, 다음 제한이 있으면 그 제한으로 바꾼다. 2단계를 켜면 그 직원의 다른 세션 가운데 2단계를 거친 적 없는(`twoFactorSetup`) 세션은 끊는다 —
비밀번호만 아는 다른 사람의 세션이 함께 열리지 않게(2026-11-02 리뷰). 엔진은 2단계를 강제하지 못하고, 비밀번호가 만료되면 웹메일(대리 접근)은 메일 오류만 보이므로 서버가 알아채 그 단계로 보낸다
- **운영 도구**(서버 안 CLI — 13단계)의 관리자 비밀번호는 실행할 때 터미널에서만 받는다 — 화면에 보이지 않게 · 저장하지 않음 · 실행 기록 · 로그 · 예외 문장 · 프로세스 인자에 없음 · 입력 기록(↑) 0(`historySize: 0` — 2026-11-02 리뷰).
표준 입력이 TTY 가 아니면 묻기 전에 멈춘다(자동 시험만 `OPS_ALLOW_NO_TTY=1`). 임시 비밀번호는 crypto 무작위 16자 · 한 번만 보여 주고 · 엔진 만료(첫 로그인 때 바꾸기)와 함께 건다
표준 입력이 TTY 가 아니면 묻기 전에 멈춘다(자동 시험만 `OPS_ALLOW_NO_TTY=1`). 임시 비밀번호는 crypto 무작위 16자 · 한 번만 보여 주고 · 엔진 만료(첫 로그인 때 바꾸기)와 함께 건다.
이전용 비밀번호(메일 이전 — 2026-11-02)도 같은 무작위 · 한 번만이고 엔진 만료를 30일 뒤로 건다(운영 도구 한 번 실행 안에서는 같은 값 — 프로세스 메모리에만) — imapsync 의 비밀번호 파일(`--passfile` — 0600, 끝나면 지움)에만 넣고 명령줄에 싣거나 직원에게 주지 않는다. 마지막 imapsync 뒤 「이전 마치기」로 직원용 임시 비밀번호로 바꾼다
- **메일 엔진 관리자 계정**(13단계 setup): 대리 접근 권한(`impersonate`)이 없고 허용 IP 는 앱 네트워크다 — 관리자 비밀번호가 새도 바깥 993 · JMAP 에서 직원 메일함에 들어가지 못한다.
되돌리는 길은 복구 관리자뿐이라 운영에서 허용 IP 를 처음 걸 때는 복구 관리자가 있어야 한다(없으면 걸지 않고 멈춘다). 게이트웨이 비밀번호는 여전히 관리자급이다(위 줄)
- **받은편지함 알림 메일**(서버가 넣는 시스템 메일)에는 링크를 넣지 않는다 — 할 일은 메뉴 경로 글로. "회사 보안 알림에는 링크가 없다"는 습관이 링크 달린 피싱을 드러낸다
+2 -1
View File
@@ -420,7 +420,7 @@ node --env-file=.env.development mail/scripts/setup.mjs
영구 관리자 비밀번호의 허용 IP 를 `APP_NETWORK_SUBNET` 으로 — 관리 API 주소가 앱 네트워크 안일 때만 걸고, 건 뒤 다시 로그인해 보고, 막히면 복구 관리자로 되돌린다. **운영에서 처음 걸 때는 복구 관리자가 있어야 한다**(없으면 걸지 않고 종료 1).
운영자가 대역 안으로 좁혀 둔 값은 넓히지 않는다. 관리 화면 SSH 터널은 mail 컨테이너의 **앱 네트워크 주소**로 연다(다른 망 주소로 열면 403 — 엔진이 보는 출발 주소가 그 망의 게이트웨이).
휴지통 · 스팸메일함 30일(`x:DataRetention.expungeTrashAfter`)과 정리 시각 KST 03:00 · 04:00 · 04:30(컨테이너 UTC 차이를 읽어)도 맞춘다 — 그 메일함에 들어간 때부터 세므로 imapsync 로 옮긴 메일도 옮긴 때부터다.
자동 비우기는 특수 메일함("Deleted Items" · "Junk Mail")에만 걸린다 — imapsync 는 `--f1f2` 로 폴더를 맞춘다(mail/README imapsync 절). 이미 운영 중인 서버는 mail/README "13단계 전부터 운영 중인 서버".
자동 비우기는 특수 메일함("Deleted Items" · "Junk Mail")에만 걸린다 — imapsync 의 `--automap` 이 mailcow 의 Trash · Junk 를 이 둘로 맞춘다(2026-11-02 개발 확인 — 따로 생긴 폴더가 있을 때만 `--f1f2`, mail/README imapsync 절). 이미 운영 중인 서버는 mail/README "13단계 전부터 운영 중인 서버".
- 운영의 복구 관리자(`MAIL_RECOVERY_ADMIN`)는 `.env.production` 에 두지 않고 셸 일회성 override + `setup.mjs --recovery` 로만 쓴다
(파일에 있거나, 끝난 뒤 mail 컨테이너에 남아 있으면 setup 이 실패한다 — 절차는 mail/README.md "운영 배포").
- 영구 관리자 아이디는 `MAIL_ADMIN_LOCAL_PART`(기본 `admin`) — setup 과 백엔드가 함께 읽는다 (compose 가 backend 에 넘긴다).
@@ -502,6 +502,7 @@ node --env-file=.env.development mail/scripts/setup.mjs
| PostgreSQL (db — 13단계부터 고정) | `postgres:15.19-alpine3.24@sha256:54e0dd8e…` | 루트 예시 env 둘의 `DB_IMAGE`(각자의 로컬 env 도), CI 의 `DB_IMAGE` 정규식, 이 문서. `docker-compose.remote-proxy.yml` 의 `frontend-bind-check` 가 같은 이미지를 빌려 쓴다. 15.19 는 2026-08-13 보안 릴리스, 이 digest 는 2026-10-21 alpine 재빌드라 운영 반영은 11-04 뒤. **메이저(15)를 올리려면 덤프 · 복원** — 데이터 형식이 다르다. db · redis 의 watchtower 표시는 뺐다(판은 사람이 올린다) |
| restic (백업 — 13단계) | `restic/restic:0.19.1@sha256:136600b6…` | `ops/backup/arasystem-backup.sh` · `arasystem-restore.sh` 의 `RESTIC_IMAGE` 두 곳, 이 문서 |
| shellcheck (CI — 13단계) | `koalaman/shellcheck:v0.11.0@sha256:61862eba…` | `.github/workflows/ci.yml` |
| imapsync (전환 때 서버 B 호스트에서 — 2026-11-02) | `gilleslamiral/imapsync:2.326@sha256:ece980cb…` | `mail/README.md` 「imapsync」 절의 명령. 저자 공식 이미지 · amd64 만 · 비-root(`nobody`). 인증서를 기본으로 확인하지 않는다(`SSL_verify_mode=0`) — 운영 명령은 `--sslargs1/2 SSL_verify_mode=1` 꼴(README) |
| Redis (선택 — 기본 꺼짐) | `redis:7-alpine`(아직 떠 있는 태그 — 기존 드리프트) | 켤 때 판 · digest 로 고정하고 이 표에 줄을 둔다 |
| CI 러너 | `gitea/act_runner:0.6.1` | `ci/docker-compose.runner.yml`. 잡 이미지(`RUNNER_LABELS` 의 `gitea/runner-images:ubuntu-latest`)는 각자의 `ci/.env` 값이라 아직 떠 있는 태그다 |
+1 -1
View File
@@ -42,7 +42,7 @@ description: NestJS 백엔드(backend/) 개발 규칙 — 컨트롤러/서비스
| 파일 저장 · 조각 올리기 (11단계) | `modules/file-storage` | `/api/file-uploads`(만들기 · 16MiB 조각 `PUT` · 끝내기 · 상태 · 지우기) · `/api/file-quota`. 조각은 `Content-Digest`(SHA-256)를 보고 `datasync` 뒤에 적는다, 같은 조각은 `ChunkGate`(프로세스 메모리)가 하나만 들인다, 조각 번호는 `file-id.pipe.ts`(매개변수 타입 `unknown` — 아래 점검 "경로 숫자"). 저장 포트 `FILE_BLOB_STORE` — 지금은 로컬 디스크 어댑터(named volume `/app/files`, 키는 서버가 만든 UUID 만 — `assertKey`). 보관량(직원 · 서버 총량 · 디스크 여유)은 만들기를 `pg_advisory_xact_lock` 안에서 센다(인스턴스를 늘려도 맞다). 끝내기는 `completion_started_at` · 상태 전이 결과를 본다. 악성 검사 `clamav-client.ts`(INSTREAM 직접 구현 — 판정 글은 `CLAMAV_REPLY` · `classifyClamavReply` 한 곳: 암호 · 압축 안 한도 → 검사하지 못함, 스트림 크기 한도 → 설정 오류로 검사 중 그대로 + error) · 검사 차례 `file-scan.service.ts`(DB pending · 동시 2) · 정리 cron `file-storage.cleanup.ts`. 확장자 허용 목록만 본다 — [[security]] "파일 업로드"의 예외. 계약 4절 |
| 공개 링크 · 내려받기 (11단계) | `modules/file-sharing` · `modules/mail/mail-large-files.service.ts` · `large-file-block.ts` · `share-link.controller.ts` | 메일 한 통에 링크 하나(토큰 256비트 — DB 에는 SHA-256 만). 보낼 때 서버가 정화 · 지문 · 중복 확인 뒤 본문에 덩이를 넣는다(`large-file-block.ts` — 표시 `<!--ara-large-files:v1-->` · `data-ara-large-files` 는 서버만 넣고 두 정화기가 사용자 HTML 에서 지운다, 경계 `bodyQuoteStart`). 링크는 "확실한 실패"(`SendNotStartedException` — 형식으로 가른다)에서만 거둔다. 임시 저장은 머리글 `X-Ara-Large-Files`(보내는 메일에는 없다). 공개 경로 `/api/public-links/{token}`(로그인 없음) — 공개 호스트 미들웨어 `public-host.ts`(공개 호스트에서는 이 경로만 · 포털에서는 닫음, Host 를 스스로 정규화. 경로는 원문 · 라우터의 `req.path` · WHATWG URL · 퍼센트를 푼 원문 네 모양을 모두 본다 — 공개 호스트는 모두 공개 경로일 때만, 포털은 하나라도 공개 경로면 닫고, 읽지 못한 모양이 있으면 둘 다 닫는다. 라우터가 읽는 모양을 빼면 절대 주소 꼴 · `\` 요청이 막기를 지난다 — 2026-10-30), 파일 응답 `serveFile`(octet-stream · attachment · nosniff · CSP sandbox · no-store · Range `shared/utils/http-range.ts`), 받은 수는 조건부 UPDATE 한 문장, 종료 때 진행 중 응답을 `BeforeApplicationShutdown` 에서 끊는다. 읽기 화면의 `largeFiles` 는 표시 · 우리 출처 토큰 · 링크 주인 == From 이 모두 맞을 때만. 기간은 `shared/utils/kst-date.ts`(보낸 날부터 30일째 23:59:59 KST). 계약 4절 |
| 계정 보안 (12단계) | `modules/account-security` · `modules/auth` 의 세션 부분 · `modules/mail/jmap/jmap-account-security.provider.ts` | 2단계 인증(엔진 TOTP — 계약 5절): 등록 `two-factor-enrollments.ts`(비밀 160비트 · 메모리 10분 · 직원마다 하나) · 켜기 · 끄기 `two-factor.service.ts`(엔진이 기준 — `enabledAt` 만 표 `two_factor_settings`) · **코드 가드 `two-factor-code-guard.ts`** — 실패 차지 · 다시 쓴 코드 확인 · (주소, 코드) "확인 중" 차지를 await 없이 한 번에 하고(같은 코드의 동시 요청이 둘 다 지나가지 않게), 받으면 그 자리에서 2분 기억한다. 결과는 받음 · 거절 · 쓰지 않음(`unused` — 새 비밀번호 거절처럼 코드와 상관없는 실패는 세지도 기억하지도 않는다). 코드 실패 카운터는 `login-attempt.service.ts`(같은 주소 15분 5 · 24시간 20 + IP 의 로그인 실패 한도). TOTP · base32 는 `shared/utils/totp.ts`(RFC 6238 — 새 의존성 없음, ±1 단계 상수 시간 비교). 잠금 `two-factor-locks.ts`(같은 직원의 2단계 바꾸기는 한 번에 하나). 로그인 2단계는 `auth.service.ts` — 앱 비밀번호 모양 거절 → 실패 차지 → 본인 Basic → **402 면 대리 접근 프로필로 정식 주소**(막는 계정의 별칭은 `AUTH_004`) → 코드는 엔진 `POST /api/auth`(mfaToken). 앱 비밀번호 `app-passwords.*`(엔진 `x:AppPassword` — 권한 `Replace` 셋, 직원마다 만들기는 한 번에 하나, 마지막 사용은 표 `app_password_usages` — 번호 · createdAt 이 같을 때만 잇는다, 비밀번호를 바꾸면 모두 지운다). 로그인된 브라우저 `modules/auth/auth-sessions.*`(경로 `/api/auth/sessions` — 리프레시 쿠키로 지금 세션을 안다) · **끊은 세션 기억 `revoked-sessions.ts`**(액세스 토큰의 `sid` — 1시간, 기동 때 DB 에서 채운다, 끊는 모든 길이 `revokeWhere` 한 곳을 지난다 — 가드가 막고 실시간 알림은 다음 ping 때 닫는다). User-Agent → 짧은 이름 `shared/utils/user-agent.ts`. 로그인 기록 `login-events.*`(표 `login_events` — 90일 정리 cron `login-events.cleanup.ts`, 위치 커서 `login-event-cursor.ts`). 엔진 웹훅 `mail-engine-events.*` — 본문을 받기 전에 소켓 주소(`APP_NETWORK_SUBNET` — `shared/utils/cidr.ts`)와 키 유무를 보고, 원문 HMAC 을 상수 시간으로, 이 경로만 JSON 한도를 따로(4MB · 배열 10,000 · 값 200,000), 겹침은 내용 지문(`dedupe_key` 유일 인덱스 — 엔진은 다시 보낼 때 id 를 새로 붙인다), 1,000행씩 `ON CONFLICT DO NOTHING`, DB 실패는 5xx(엔진이 다시 보낸다), 빈 묶음 200(setup 의 받는 곳 확인). 마이그레이션 `AddAccountSecurity` |
| 전환 준비 (13단계) | `common/decorators/allow-restricted-session.decorator.ts` · `JwtAuthGuard` · `modules/account-security/two-factor-policy.service.ts` · `modules/system-settings` · `modules/system-mail` · `src/cli/ops.ts` + `modules/ops` · `modules/mail/jmap/jmap-ops-engine.ts` · `jmap-system-mailbox.ts` | **제한된 세션**(계약 6절): 세션 표의 `restriction`(`passwordChange` · `twoFactorSetup`)을 액세스 토큰(`rst`)에도 싣고, `JwtAuthGuard`(Reflector)가 `@AllowRestrictedSession()` 을 단 핸들러(`/api/auth/*` · 2단계 켜기 셋)만 지나게 한다 — 그 밖은 403 `AUTH_010`. 제한이 실린 토큰만 요청마다 세션 표의 지금 값을 읽는다(PK 한 번). 푸는 요청은 그 세션(`sid`)만 다음 제한으로 바꾸고(`replaceRestriction` — 조건부 UPDATE), 2단계를 켜면 그 직원의 다른 `twoFactorSetup` 세션은 끊는다(`revokeRestrictedOf` — 사유 `two-factor-not-verified`, 갱신에서도). **비밀번호 만료 알아채기**: 게이트웨이 대리 접근 세션에 메일 계정이 없고 `/api/account` 권한이 다섯 안이면 `PasswordExpiredException`(`jmap.client.ts` 의 `isPasswordExpiredPermissions` — 로그인 · 갱신 때 엔진 기준, 풀렸으면 지움). **2단계 필수 정책 · 문의 연락처**: 표 `system_settings`(한 줄 — 프로세스마다 10초 기억), `GET /api/support-contact`(로그인 없음 · `{ text }`), `twoFactorDeadline` 은 유예 중이고 아직 안 켠 직원만. **받은편지함 알림 메일**: 대리 접근 `Email/import`(안 읽음 · 필터 · 스팸 안 거침), 보낸 사람 `ARA SYSTEM <postmaster@도메인>`, 글은 디자인 README 13단계(링크 없음 · 이름 · 파일 이름 · 연락처 이스케이프 · 보내는 정화 통과), 실패는 warn(원래 일은 성공), 위험 파일은 처음 판정 때 한 번(`markInfected`). **운영 도구**(`sudo docker exec -it <PROJECT_NAME>-backend node dist/cli/ops.js`): Nest 앱 컨텍스트(ScheduleModule 없음 · `FILE_SCANNER` null · `migrationsRun` false), 한국어 메뉴 → 바뀔 것 → y/N → 실행 → `ops_audit_log`. 관리자 비밀번호는 TTY 에서만 · 입력 기록 0(`historySize: 0`) — 자동 시험만 `OPS_ALLOW_NO_TTY=1`. 관리자 Basic 은 `x:Account` · `x:MailingList` 에만, 직원 메일함 · Sieve · 앱 비밀번호는 게이트웨이 대리 접근(관리자에 impersonate 가 없다). 명령 열 개와 차례는 계약 6절(재설정: 앱 비밀번호 → 세션 끊기 → 알림 → 만료 → 세션 한 번 더 / 잠금: Sieve → authenticate 빼기 → 세션 / 퇴사 마무리: 엔진 계정 → 주소 키 표 한 트랜잭션(세션 행은 남김) · 예외 목록 · MailingList 구성원 → 디스크). 끊은 세션 기억은 15초마다 DB 에서 채운다(`revoked-sessions.ts` — 운영 도구는 다른 프로세스). 마이그레이션 `AddCutoverPrep`(`auth_sessions.restriction` · `system_settings` · `ops_audit_log`). Swagger 는 가드를 건 컨트롤러에 `@ApiSessionErrors()`(AUTH_001 + AUTH_010) |
| 전환 준비 (13단계) | `common/decorators/allow-restricted-session.decorator.ts` · `JwtAuthGuard` · `modules/account-security/two-factor-policy.service.ts` · `modules/system-settings` · `modules/system-mail` · `src/cli/ops.ts` + `modules/ops` · `modules/mail/jmap/jmap-ops-engine.ts` · `jmap-system-mailbox.ts` | **제한된 세션**(계약 6절): 세션 표의 `restriction`(`passwordChange` · `twoFactorSetup`)을 액세스 토큰(`rst`)에도 싣고, `JwtAuthGuard`(Reflector)가 `@AllowRestrictedSession()` 을 단 핸들러(`/api/auth/*` · 2단계 켜기 셋)만 지나게 한다 — 그 밖은 403 `AUTH_010`. 제한이 실린 토큰만 요청마다 세션 표의 지금 값을 읽는다(PK 한 번). 푸는 요청은 그 세션(`sid`)만 다음 제한으로 바꾸고(`replaceRestriction` — 조건부 UPDATE), 2단계를 켜면 그 직원의 다른 `twoFactorSetup` 세션은 끊는다(`revokeRestrictedOf` — 사유 `two-factor-not-verified`, 갱신에서도). **비밀번호 만료 알아채기**: 게이트웨이 대리 접근 세션에 메일 계정이 없고 `/api/account` 권한이 다섯 안이면 `PasswordExpiredException`(`jmap.client.ts` 의 `isPasswordExpiredPermissions` — 로그인 · 갱신 때 엔진 기준, 풀렸으면 지움). **2단계 필수 정책 · 문의 연락처**: 표 `system_settings`(한 줄 — 프로세스마다 10초 기억), `GET /api/support-contact`(로그인 없음 · `{ text }`), `twoFactorDeadline` 은 유예 중이고 아직 안 켠 직원만. **받은편지함 알림 메일**: 대리 접근 `Email/import`(안 읽음 · 필터 · 스팸 안 거침), 보낸 사람 `ARA SYSTEM <postmaster@도메인>`, 글은 디자인 README 13단계(링크 없음 · 이름 · 파일 이름 · 연락처 이스케이프 · 보내는 정화 통과), 실패는 warn(원래 일은 성공), 위험 파일은 처음 판정 때 한 번(`markInfected`). **운영 도구**(`sudo docker exec -it <PROJECT_NAME>-backend node dist/cli/ops.js`): Nest 앱 컨텍스트(ScheduleModule 없음 · `FILE_SCANNER` null · `migrationsRun` false), 한국어 메뉴 → 바뀔 것 → y/N → 실행 → `ops_audit_log`. **메일 이전**(2026-11-02 — 전환의 imapsync): 「입사」의 이전용 비밀번호는 엔진 비밀번호 만료를 30일 뒤로 건다(그때까지 IMAP 정상 — 임시 비밀번호의 지난 만료는 IMAP 이 끊긴다), 이전 중 = 만료가 미래(31일 안)이고 실행 기록(`ops_audit_log`)의 마지막 판정 기록이 이전용(`hire-migration` · `migration-password` — 이전용 명령은 failure 도 센다) — 판정은 `modules/ops/migration-state.ts` 하나(엔진 만료만으로는 기본 만료의 찌꺼기와 가를 수 없다), 「메일 이전」(`modules/ops/migration-commands.ts`)의 이전 마치기가 직원용 임시 비밀번호로 바꾼다. 이전용 비밀번호는 실행 한 번 안에서 같은 값 — `migration-password.ts` 의 `MigrationPasswordSession`(싱글턴 · 메모리에만 · `OpsCli.run()` 의 finally 에서 지움), 약함 거절의 다시 만들기는 `temp-password.ts` 의 `applyFreshPassword`. 관리자 비밀번호는 TTY 에서만 · 입력 기록 0(`historySize: 0`) — 자동 시험만 `OPS_ALLOW_NO_TTY=1`. 관리자 Basic 은 `x:Account` · `x:MailingList` 에만, 직원 메일함 · Sieve · 앱 비밀번호는 게이트웨이 대리 접근(관리자에 impersonate 가 없다). 명령 열한 개와 차례는 계약 6절(재설정: 앱 비밀번호 → 세션 끊기 → 알림 → 만료 → 세션 한 번 더 / 잠금: Sieve → authenticate 빼기 → 세션 / 이전 마치기: 앱 비밀번호 → 세션 → 로그인 기록 → 임시 비밀번호 + 만료 → 세션 한 번 더(알림 없음) / 퇴사 마무리: 엔진 계정 → 주소 키 표 한 트랜잭션(세션 행은 남김) · 예외 목록 · MailingList 구성원 → 디스크). 끊은 세션 기억은 15초마다 DB 에서 채운다(`revoked-sessions.ts` — 운영 도구는 다른 프로세스). 마이그레이션 `AddCutoverPrep`(`auth_sessions.restriction` · `system_settings` · `ops_audit_log`). Swagger 는 가드를 건 컨트롤러에 `@ApiSessionErrors()`(AUTH_001 + AUTH_010) |
| Rate Limiting | `app.module.ts` 의 `ClientIpThrottlerGuard`(APP_GUARD, `common/guards/client-ip-throttler.guard.ts`) | 초과 시 429 → `REQ_001`. 루트 `.env` 의 `THROTTLE_*` 로 조정. `ThrottlerGuard` 를 상속해 tracker 만 바꿨다 — IP 는 `shared/utils/client-ip.ts` 의 `clientIpKey()`(IPv6 는 /64 로 묶음, 로그인 실패 카운터와 같은 함수) |
| 로깅 | `shared/logger/winston.config.ts` | `console.log` 대신 사용 ([[logging]]) |
| env 검증 | `shared/config/env.validation.ts` | 잘못된 설정이면 **기동 실패**(fail-fast) ([[env-structure]]) |
@@ -1,3 +1,5 @@
import { Logger } from '@nestjs/common';
import type { MailSettings } from '@/modules/mail/mail.settings';
import type {
MailFilterSet,
@@ -199,6 +201,7 @@ describe('JmapOpsEngine — 관리 API · 대리 접근 (13단계)', () => {
locked: false,
customPermissions: false,
passwordExpired: false,
passwordExpiresAt: null,
twoFactorEnabled: true,
appPasswords: 1,
usedBytes: 2048,
@@ -257,9 +260,14 @@ describe('JmapOpsEngine — 관리 API · 대리 접근 (13단계)', () => {
localPart: 'hong',
name: '홍길동',
quotaBytes: 5,
tempPassword: 'Temp1234Temp1234',
password: 'Temp1234Temp1234',
migrationUntil: null,
});
expect(created).toMatchObject({
id: 'acc9',
passwordExpired: true,
passwordExpiresAt: null,
});
expect(created).toMatchObject({ id: 'acc9', passwordExpired: true });
const create = bodyAt(3).methodCalls[0][1].create as Record<
string,
Record<string, unknown>
@@ -307,7 +315,8 @@ describe('JmapOpsEngine — 관리 API · 대리 접근 (13단계)', () => {
localPart: 'hong',
name: '홍',
quotaBytes: null,
tempPassword: 'weak',
password: 'weak',
migrationUntil: null,
}),
).rejects.toMatchObject({ kind: 'weak-password' });
@@ -327,11 +336,325 @@ describe('JmapOpsEngine — 관리 API · 대리 접근 (13단계)', () => {
localPart: 'hong',
name: '홍',
quotaBytes: null,
tempPassword: 'x',
password: 'x',
migrationUntil: null,
}),
).rejects.toMatchObject({ kind: 'exists' });
});
describe('메일 이전 — 이전용 비밀번호(만료가 미래)', () => {
const DAY_MS = 24 * 60 * 60 * 1000;
/** 엔진의 시각 칸 — 초 단위 */
const engineTime = (at: Date) => at.toISOString().replace(/\.\d+Z$/, 'Z');
it('입사 — 이전용 비밀번호와 받은 미래 시각(초 단위)을 만료로 한 번에 만든다 · 읽은 계정은 이전 중', async () => {
await signedIn();
const until = new Date(Date.now() + 30 * DAY_MS);
fetchMock
.mockResolvedValueOnce(
json({
methodResponses: [
['x:Domain/query', { ids: ['d1'] }, 'q'],
['x:Domain/get', { list: [{ id: 'd1', name: DOMAIN }] }, 'g'],
],
}),
)
.mockResolvedValueOnce(
json({
methodResponses: [
['x:Account/set', { created: { a: { id: 'acc9' } } }, 's'],
],
}),
)
.mockResolvedValueOnce(
json({
methodResponses: [
[
'x:Account/get',
{
list: [
{
...araAccount,
id: 'acc9',
emailAddress: `hong@${DOMAIN}`,
credentials: {
'0': {
'@type': 'Password',
expiresAt: engineTime(until),
},
},
},
],
},
'g',
],
],
}),
);
const created = await engine.createEmployee({
localPart: 'hong',
name: '홍길동',
quotaBytes: null,
password: 'Migr1234Migr1234',
migrationUntil: until,
});
expect(created).toMatchObject({
id: 'acc9',
passwordExpired: false,
passwordExpiresAt: new Date(engineTime(until)).toISOString(),
});
const create = bodyAt(3).methodCalls[0][1].create as Record<
string,
Record<string, unknown>
>;
expect(
(create.a.credentials as Record<string, Record<string, unknown>>)['0'],
).toEqual({
'@type': 'Password',
secret: 'Migr1234Migr1234',
expiresAt: engineTime(until),
});
expect(create.a).not.toHaveProperty('quotas');
});
it('setMigrationPassword — 비밀번호 자격의 secret 과 만료(미래 · 초 단위)만 패치한다(2단계 인증은 칸에 넣지 않는다)', async () => {
await signedIn();
const until = new Date(Date.now() + 30 * DAY_MS);
fetchMock
.mockResolvedValueOnce(
json({
methodResponses: [['x:Account/get', { list: [araAccount] }, 'g']],
}),
)
.mockResolvedValueOnce(
json({
methodResponses: [
['x:Account/set', { updated: { acc1: null } }, 's'],
],
}),
);
await engine.setMigrationPassword('acc1', 'Migr1234Migr1234', until);
expect(
(bodyAt(3).methodCalls[0][1].update as Record<string, unknown>).acc1,
).toEqual({
'credentials/0/secret': 'Migr1234Migr1234',
'credentials/0/expiresAt': engineTime(until),
});
expect(Date.parse(engineTime(until))).toBeGreaterThan(Date.now());
});
it('passwordExpiresAt — 만료가 미래면 그 시각(ISO), 지났으면 null(만료), 없거나 읽지 못하면 null (이전 중 판정은 운영 도구가 실행 기록과 함께)', async () => {
await signedIn();
const future = engineTime(new Date(Date.now() + 10 * DAY_MS));
const accountWith = (name: string, expiresAt: unknown) => ({
...araAccount,
id: `acc-${name}`,
name,
emailAddress: `${name}@${DOMAIN}`,
credentials: {
'0': { '@type': 'Password', secret: '****', expiresAt },
},
});
fetchMock.mockResolvedValueOnce(
json({
methodResponses: [
['x:Account/query', { ids: ['a', 'b', 'c', 'd'] }, 'q'],
[
'x:Account/get',
{
list: [
accountWith('future', future),
accountWith('past', '2020-01-01T00:00:00Z'),
accountWith('none', null),
accountWith('garbage', 'not-a-date'),
],
},
'g',
],
],
}),
);
const states = Object.fromEntries(
(await engine.listEmployees()).map((employee) => [
employee.email.slice(0, employee.email.indexOf('@')),
[employee.passwordExpiresAt, employee.passwordExpired],
]),
);
expect(states).toEqual({
future: [new Date(future).toISOString(), false],
past: [null, true],
none: [null, false],
garbage: [null, false],
});
});
describe('passwordDefaultExpiry — x:Authentication 싱글톤의 비밀번호 기본 만료 (알림만 — 재리뷰 N1)', () => {
const authentication = (settings: Record<string, unknown> | null) =>
json({
methodResponses: [
[
'x:Authentication/get',
{
list:
settings === null ? [] : [{ id: 'singleton', ...settings }],
},
'a',
],
],
});
it('싱글톤을 한 번 읽고 기억한다 — 칸이 null 이거나 없으면 꺼짐', async () => {
await signedIn();
fetchMock.mockResolvedValueOnce(
authentication({ passwordDefaultExpiry: null }),
);
await expect(engine.passwordDefaultExpiry()).resolves.toBe('off');
expect(bodyAt(2).methodCalls[0]).toEqual([
'x:Authentication/get',
{
accountId: 'adm',
ids: ['singleton'],
properties: ['passwordDefaultExpiry'],
},
'a',
]);
// 이 실행 동안 기억한다 — 다시 묻지 않는다
await expect(engine.passwordDefaultExpiry()).resolves.toBe('off');
expect(fetchMock).toHaveBeenCalledTimes(3);
const other = new JmapOpsEngine(
settings,
ADMIN,
accountSecurity as unknown as JmapAccountSecurityProvider,
filterScripts as unknown as JmapFilterScriptStore,
);
fetchMock
.mockResolvedValueOnce(json(adminSession))
.mockResolvedValueOnce(
json({ methodResponses: [['x:Account/get', { list: [] }, 'p']] }),
)
.mockResolvedValueOnce(authentication({}));
await other.signIn('admin-secret');
await expect(other.passwordDefaultExpiry()).resolves.toBe('off');
});
it('값이 있으면 켜짐 — 그래도 계정의 미래 만료는 그대로 준다(가리지 않는다 — 판정은 운영 도구가 실행 기록과 함께)', async () => {
await signedIn();
fetchMock.mockResolvedValueOnce(
authentication({ passwordDefaultExpiry: '90d' }),
);
await expect(engine.passwordDefaultExpiry()).resolves.toBe('on');
const future = engineTime(new Date(Date.now() + 10 * DAY_MS));
fetchMock.mockResolvedValueOnce(
json({
methodResponses: [
['x:Account/query', { ids: ['acc1'] }, 'q'],
[
'x:Account/get',
{
list: [
{
...araAccount,
credentials: {
'0': { '@type': 'Password', expiresAt: future },
},
},
],
},
'g',
],
],
}),
);
const [ara] = await engine.listEmployees();
expect(ara).toMatchObject({
email: ARA,
passwordExpiresAt: new Date(future).toISOString(),
passwordExpired: false,
});
});
it('읽지 못하면 unknown — 메서드 거절 · 싱글톤 없음, 던지지 않고 경고 로그만([MAIL_001] 머리 · 종류만)', async () => {
const warn = jest
.spyOn(Logger.prototype, 'warn')
.mockImplementation(() => undefined);
await signedIn();
fetchMock.mockResolvedValueOnce(
json({
methodResponses: [['error', { type: 'forbidden' }, 'a']],
}),
);
await expect(engine.passwordDefaultExpiry()).resolves.toBe('unknown');
// 기억한다 — 다시 묻지 않는다
await expect(engine.passwordDefaultExpiry()).resolves.toBe('unknown');
expect(fetchMock).toHaveBeenCalledTimes(3);
// 같은 모듈의 다른 경고처럼 코드 머리를 달고, 종류만 — 관리자 비밀번호 · 엔진의 설명 문장은 없다 (재리뷰 N4)
const message = String(warn.mock.calls[0]?.[0]);
expect(message.startsWith('[MAIL_001]')).toBe(true);
expect(message).toContain('reason=refused/forbidden');
expect(message).not.toContain('admin-secret');
const empty = new JmapOpsEngine(
settings,
ADMIN,
accountSecurity as unknown as JmapAccountSecurityProvider,
filterScripts as unknown as JmapFilterScriptStore,
);
fetchMock
.mockResolvedValueOnce(json(adminSession))
.mockResolvedValueOnce(
json({ methodResponses: [['x:Account/get', { list: [] }, 'p']] }),
)
.mockResolvedValueOnce(authentication(null));
await empty.signIn('admin-secret');
await expect(empty.passwordDefaultExpiry()).resolves.toBe('unknown');
// 계정 읽기는 그대로 — 미래 만료를 준다
const future = engineTime(new Date(Date.now() + 10 * DAY_MS));
fetchMock
.mockResolvedValueOnce(
json({
methodResponses: [['x:Account/query', { ids: ['acc1'] }, 'q']],
}),
)
.mockResolvedValueOnce(
json({
methodResponses: [
[
'x:Account/get',
{
list: [
{
...araAccount,
credentials: {
'0': { '@type': 'Password', expiresAt: future },
},
},
],
},
'g',
],
],
}),
);
await expect(empty.findEmployee(ARA)).resolves.toMatchObject({
passwordExpiresAt: new Date(future).toISOString(),
});
warn.mockRestore();
});
it('로그인 전에는 묻지도 기억하지도 않는다', async () => {
await expect(engine.passwordDefaultExpiry()).resolves.toBe('unknown');
expect(fetchMock).not.toHaveBeenCalled();
await signedIn();
fetchMock.mockResolvedValueOnce(
authentication({ passwordDefaultExpiry: null }),
);
await expect(engine.passwordDefaultExpiry()).resolves.toBe('off');
});
});
});
it('재설정 · 2단계 해제 · 잠금은 비밀번호 자격의 칸 · 권한 칸을 패치한다', async () => {
await signedIn();
const account = json({
@@ -1,4 +1,4 @@
import { Inject, Injectable } from '@nestjs/common';
import { Inject, Injectable, Logger } from '@nestjs/common';
import { randomUUID } from 'crypto';
import { BusinessException } from '@/common/errors/business.exception';
@@ -20,7 +20,9 @@ import {
type LockRulesState,
type OpsEngine,
type OpsSignInResult,
type PasswordDefaultExpiry,
} from '@/modules/ops/ops-engine.port';
import { traceTag } from '@/shared/logger/request-context';
import { JmapAccountSecurityProvider } from './jmap-account-security.provider';
import { JmapFilterScriptStore } from './jmap-filter-script';
import { basicAuthorization, toRelativeReference } from './jmap.client';
@@ -54,6 +56,8 @@ const ACCOUNT_PROPERTIES = [
];
/** 만료를 지난 날로 — "다음 로그인 때 바꾸기" (엔진이 그 계정의 권한을 비밀번호 바꾸기만으로 줄인다 — 2026-11-02 확인) */
const EXPIRED_AT_OFFSET_MS = 60_000;
/** 엔진 전역 설정(싱글톤 객체)의 id — `x:Authentication` 등 */
const SINGLETON_ID = 'singleton';
const HTTP_UNAUTHORIZED = 401;
const HTTP_PAYMENT_REQUIRED = 402;
@@ -125,6 +129,11 @@ function asNumber(value: unknown): number | null {
return typeof value === 'number' && Number.isFinite(value) ? value : null;
}
/** 엔진의 시각 칸 — ISO 8601 UTC, 초 단위(밀리초를 뗀다) */
function engineTime(at: Date): string {
return at.toISOString().replace(/\.\d+Z$/, 'Z');
}
/** 엔진의 SetError → 도구의 까닭 (종류만 — 설명 문장은 넣지 않는다) */
function refusal(error: JmapError | undefined): OpsEngineError {
const type = error?.type ?? 'unknown';
@@ -206,12 +215,16 @@ export function permissionsWithLogin(
* - 게이트웨이 대리 접근: 앱 비밀번호(JmapAccountSecurityProvider) · 잠금 Sieve(JmapFilterScriptStore — 직원의 필터 · 부재중과 한 스크립트.
* 엔진은 계정마다 스크립트 하나만 켠다 — 따로 스크립트를 켜면 직원의 필터 · 부재중 · 차단이 꺼진다, 2026-11-02 다시 확인)
* 엔진 동작(2026-11-02 개발 엔진 확인): 만들 때 만료를 함께 받는다 · 관리자의 비밀번호 바꾸기는 OTP 를 남긴다 ·
* OTP 지우기는 비밀번호 자격의 otpAuth 를 null 로 · 잠금은 계정 권한의 Merge disabledPermissions.authenticate
* OTP 지우기는 비밀번호 자격의 otpAuth 를 null 로 · 잠금은 계정 권한의 Merge disabledPermissions.authenticate ·
* 만료를 미래로 두면 그 시각까지 IMAP · SMTP · JMAP 이 그대로고 지나면 스스로 만료로 다룬다(메일 이전의 이전용 비밀번호 — 넣은 값을 그대로 돌려준다)
*/
@Injectable()
export class JmapOpsEngine implements OpsEngine {
private readonly logger = new Logger(JmapOpsEngine.name);
private admin: AdminSession | null = null;
private domainId: string | null = null;
/** 엔진의 비밀번호 기본 만료 — 관리자 로그인 뒤 한 번 읽고 기억한다(도구가 첫 화면에 알리기만 한다) */
private defaultExpiry: PasswordDefaultExpiry | null = null;
constructor(
@Inject(MAIL_SETTINGS) private readonly settings: MailSettings,
@@ -264,6 +277,51 @@ export class JmapOpsEngine implements OpsEngine {
return 'ok';
}
async passwordDefaultExpiry(): Promise<PasswordDefaultExpiry> {
if (this.defaultExpiry !== null) return this.defaultExpiry;
// 로그인 전이면 기억하지 않는다 — 로그인 뒤에 다시 읽는다
if (!this.admin) return 'unknown';
let state: PasswordDefaultExpiry;
try {
const [get] = await this.call([
[
'x:Authentication/get',
{
accountId: this.session().accountId,
ids: [SINGLETON_ID],
properties: ['passwordDefaultExpiry'],
},
'a',
],
]);
const settings = this.expect<
GetResult<{ readonly passwordDefaultExpiry?: unknown }>
>(get, 'x:Authentication/get').list?.[0];
// 칸이 없거나 null 이면 꺼짐 — 값이 있으면(기간) 켜짐. 싱글톤을 돌려주지 않으면 읽지 못한 것으로
state =
settings === undefined
? 'unknown'
: settings.passwordDefaultExpiry === undefined ||
settings.passwordDefaultExpiry === null
? 'off'
: 'on';
} catch (error) {
// 종류만 남긴다 — 엔진의 설명 문장 · 비밀은 넣지 않는다
const kind =
error instanceof OpsEngineError
? `${error.kind}${error.detail ? `/${error.detail}` : ''}`
: error instanceof Error
? error.name
: 'unknown';
this.logger.warn(
`[MAIL_001]${traceTag()} 엔진의 비밀번호 기본 만료(x:Authentication)를 읽지 못했습니다 — 운영 도구는 그대로 씁니다(메일 이전 중 판정은 실행 기록으로 가른다): reason=${kind}`,
);
state = 'unknown';
}
this.defaultExpiry = state;
return state;
}
isEmployeeAddress(email: string): boolean {
return this.isEmployee(email.trim().toLowerCase());
}
@@ -298,7 +356,8 @@ export class JmapOpsEngine implements OpsEngine {
readonly localPart: string;
readonly name: string;
readonly quotaBytes: number | null;
readonly tempPassword: string;
readonly password: string;
readonly migrationUntil: Date | null;
}): Promise<EngineEmployee> {
const domainId = await this.requireDomainId();
const result = await this.set('Account', {
@@ -312,8 +371,12 @@ export class JmapOpsEngine implements OpsEngine {
credentials: {
0: {
'@type': 'Password',
secret: input.tempPassword,
expiresAt: this.expiredAt(),
secret: input.password,
// 임시 비밀번호는 지난 만료(첫 로그인 때 바꾸기), 이전용은 받은 미래 시각(그때까지 IMAP 등이 그대로 — 지나면 엔진이 스스로 막는다)
expiresAt:
input.migrationUntil === null
? this.expiredAt()
: engineTime(input.migrationUntil),
},
},
...(input.quotaBytes !== null
@@ -340,6 +403,19 @@ export class JmapOpsEngine implements OpsEngine {
});
}
async setMigrationPassword(
id: string,
password: string,
until: Date,
): Promise<void> {
const credentialId = await this.requirePasswordCredential(id);
// 미래의 만료 — 그 시각까지 IMAP · SMTP · 웹메일이 그대로다(2026-11-02 확인). 2단계 인증(otpAuth)은 칸에 넣지 않는다(재설정과 같다)
await this.update('Account', id, {
[`credentials/${credentialId}/secret`]: password,
[`credentials/${credentialId}/expiresAt`]: engineTime(until),
});
}
async removeTwoFactor(id: string): Promise<void> {
const credentialId = await this.requirePasswordCredential(id);
await this.update('Account', id, {
@@ -494,9 +570,7 @@ export class JmapOpsEngine implements OpsEngine {
}
private expiredAt(): string {
return new Date(Date.now() - EXPIRED_AT_OFFSET_MS)
.toISOString()
.replace(/\.\d+Z$/, 'Z');
return engineTime(new Date(Date.now() - EXPIRED_AT_OFFSET_MS));
}
private toEmployee(account: JmapAccount): EngineEmployee | null {
@@ -511,6 +585,10 @@ export class JmapOpsEngine implements OpsEngine {
(credential) => credential?.['@type'] === 'Password',
);
const expiresAt = asString(password?.expiresAt);
// 만료 시각 — 지났으면 만료(임시 비밀번호 · 30일이 지난 이전용), 미래면 그 시각. 읽지 못하면 둘 다 아니다.
// 미래의 만료가 곧 "메일 이전 중"은 아니다(기본 만료로 저장된 것일 수 있다) — 판정은 운영 도구가 실행 기록과 함께 한다
const expiresMs = expiresAt === null ? Number.NaN : Date.parse(expiresAt);
const now = Date.now();
const description = asString(account.description)?.trim();
return {
id,
@@ -518,8 +596,9 @@ export class JmapOpsEngine implements OpsEngine {
name: description || fallbackDisplayName(email),
locked: isLocked(account.permissions),
customPermissions: account.permissions?.['@type'] === 'Replace',
passwordExpired:
expiresAt !== null && Date.parse(expiresAt) <= Date.now(),
passwordExpired: expiresMs <= now,
passwordExpiresAt:
expiresMs > now ? new Date(expiresMs).toISOString() : null,
twoFactorEnabled:
password?.otpAuth !== undefined && password.otpAuth !== null,
appPasswords: credentials.filter(
+105 -41
View File
@@ -4,20 +4,28 @@ import { MAIL_SETTINGS, type MailSettings } from '@/modules/mail/mail.settings';
import { SystemMailService } from '@/modules/system-mail/system-mail.service';
import { SystemSettingsService } from '@/modules/system-settings/system-settings.service';
import { twoFactorRequirementFor } from '@/modules/system-settings/system-settings.types';
import {
MIGRATION_PASSWORD_DAYS,
MigrationPasswordSession,
migrationAuditText,
migrationUntilFrom,
} from './migration-password';
import { resetGuard } from './migration-state';
import { OpsAuditService } from './ops-audit.service';
import { OpsDataService, totalRows } from './ops-data.service';
import {
OPS_ENGINE,
OpsEngineError,
type EngineEmployee,
type OpsEngine,
} from './ops-engine.port';
import type { OpsIo } from './ops-io';
import {
MIGRATION_PASSWORD_REUSED,
askAddress,
askEmployee,
confirmPlan,
failureLine,
showMigrationPassword,
showTempPassword,
step,
} from './ops-prompts';
@@ -25,17 +33,30 @@ import {
bytesText,
failureDetail,
isUnknownOutcome,
timeText,
toDisplayName,
toQuotaBytes,
} from './ops-text';
import { generateTempPassword } from './temp-password';
import { applyFreshPassword } from './temp-password';
/** 임시 비밀번호를 엔진이 약하다고 받지 않을 때 새로 만들어 다시 하는 횟수 */
const WEAK_PASSWORD_RETRIES = 2;
/** 입사의 바뀔 것 — 이전용 비밀번호 줄 (이번 실행의 값이 이미 있으면 다시 보이지 않는다) */
function migrationPasswordPlan(
until: Date,
sessionHasPassword: boolean,
): string[] {
return [
sessionHasPassword
? '비밀번호 이전용 비밀번호 — 이번 실행의 값(이번 실행에서 마지막으로 보인 값)을 겁니다. 다시 보이지 않습니다'
: '비밀번호 이전용 비밀번호(무작위) — 만든 뒤 한 번만 보입니다. 이번 실행에서 옮겨 올 메일이 있는 계정은 모두 이 값을 씁니다',
' imapsync 에만 씁니다 — 직원에게 주지 않습니다',
` ${timeText(until)} 까지(${MIGRATION_PASSWORD_DAYS}일) — 지나면 엔진이 막습니다`,
' 마지막 imapsync 뒤 「메일 이전 › 이전 마치기」로 직원용 임시 비밀번호를 만듭니다',
];
}
/**
* 운영 도구 — 계정 명령 (13단계 — 계약 6절 "운영 도구"): 입사 · 비밀번호 재설정 · 2단계 해제.
* 묻고 → 바뀔 것을 보이고 → y/N → 차례대로 하고 → 실행 기록(ops_audit_log)을 남긴다. 임시 비밀번호는 한 번만 보이고 어디에도 남기지 않는다
* 묻고 → 바뀔 것을 보이고 → y/N → 차례대로 하고 → 실행 기록(ops_audit_log)을 남긴다. 임시 · 이전용 비밀번호는 한 번만 보이고 어디에도 남기지 않는다
*/
@Injectable()
export class AccountCommands {
@@ -46,13 +67,19 @@ export class AccountCommands {
private readonly audit: OpsAuditService,
private readonly systemMail: SystemMailService,
private readonly settings: SystemSettingsService,
private readonly migrationPassword: MigrationPasswordSession,
) {}
private get domain(): string {
return this.mailSettings.domain ?? '';
}
/** 입사 — 엔진 계정(직원 역할) + 임시 비밀번호(무작위 · 한 번만) + 만료(첫 로그인 때 바꾸기) + 메일함 용량 */
/**
* 입사 — 엔진 계정(직원 역할) + 메일함 용량 + 처음 비밀번호:
* - 보통: 임시 비밀번호(무작위 · 한 번만) + 만료(첫 로그인 때 바꾸기)
* - mailcow 에서 옮겨 올 메일이 있으면: 이전용 비밀번호(이번 실행 공통 — 처음 만들 때 한 번만 보인다) + 만든 때부터 30일의 만료.
* 그때까지 IMAP 이 그대로라 imapsync 가 들어간다. 직원용 임시 비밀번호는 「메일 이전 › 이전 마치기」가 만든다
*/
async hire(io: OpsIo, operator: string): Promise<void> {
const email = await askAddress(io, this.domain, '새 직원 주소');
if (email === null) return;
@@ -83,6 +110,9 @@ export class AccountCommands {
io.print('용량은 0 보다 큰 숫자(GB, 소수 한 자리까지)로 넣어 주세요.');
return;
}
// 메일 이전 — 옮겨 올 메일이 있으면 이전용 비밀번호로 만든다(imapsync 가 새 엔진에 IMAP 으로 들어간다. 임시 비밀번호의 지난 만료는 IMAP 을 막는다).
// 실행 기록은 hire-migration 으로 남긴다 — 메일 이전 중 판정이 이 기록을 본다(migration-state.ts)
const migrating = await io.confirm('mailcow 에서 옮겨 올 메일이 있습니까?');
// 같은 주소를 쓰던 옛 직원의 기록이 남았으면 먼저 지운다 — 새 사람에게 옛 로그인 기록 · 서명 · 2단계 기록 · 2단계 필수 예외가 넘어가지 않게
const leftoverRows = totalRows(await this.data.countRowsOf(email));
@@ -99,12 +129,18 @@ export class AccountCommands {
purgeLeftover = await io.confirm('입사 전에 지울까요? (권함)');
}
// 이전용 비밀번호의 끝 — 그 계정을 만든 때부터 30일(바뀔 것에 보인 시각 그대로 건다)
const until = migrating ? migrationUntilFrom(new Date()) : null;
const lines = [
`주소 ${email}`,
`이름 ${name}`,
`메일함 ${quota === null ? '제한 없음' : bytesText(quota)}`,
'비밀번호 임시 비밀번호(무작위) — 만든 뒤 한 번만 보입니다',
' 첫 로그인 때 새 비밀번호를 정합니다(엔진의 비밀번호 만료)',
...(until === null
? [
'비밀번호 임시 비밀번호(무작위) — 만든 뒤 한 번만 보입니다',
' 첫 로그인 때 새 비밀번호를 정합니다(엔진의 비밀번호 만료)',
]
: migrationPasswordPlan(until, this.migrationPassword.exists)),
...(purgeLeftover
? [`옛 기록 지우기 — ${leftoverParts.join(' · ')}`]
: []),
@@ -117,37 +153,66 @@ export class AccountCommands {
step(io, true, `옛 기록을 지웠습니다 — ${leftoverParts.join(' · ')}`);
}
const localPart = email.slice(0, email.indexOf('@'));
let password = '';
for (let attempt = 0; ; attempt += 1) {
password = generateTempPassword();
try {
await this.engine.createEmployee({
localPart,
name,
quotaBytes: quota,
tempPassword: password,
});
break;
} catch (error) {
const weak =
error instanceof OpsEngineError && error.kind === 'weak-password';
if (!weak || attempt >= WEAK_PASSWORD_RETRIES) throw error;
const create = (password: string) =>
this.engine.createEmployee({
localPart,
name,
quotaBytes: quota,
password,
migrationUntil: until,
});
// 실행 기록의 이전용 비밀번호 — 끝나는 날만(비밀 없음)
let migrationNote: string | null = null;
if (until === null) {
const password = await applyFreshPassword(create);
step(io, true, '엔진 계정을 만들었습니다.');
showTempPassword(io, email, password);
} else {
const migration = await this.migrationPassword.apply(create);
step(io, true, '엔진 계정을 만들었습니다.');
if (migration.kind === 'reused') {
step(io, true, MIGRATION_PASSWORD_REUSED);
} else {
showMigrationPassword(
io,
email,
migration.password,
until,
migration.kind === 'replaced',
);
}
migrationNote = migrationAuditText(until, migration);
}
step(io, true, '엔진 계정을 만들었습니다.');
showTempPassword(io, email, password);
await this.audit.record({
operator,
command: 'hire',
command: until === null ? 'hire' : 'hire-migration',
target: email,
result: 'success',
detail: `용량 ${quota === null ? '제한 없음' : bytesText(quota)}${purgeLeftover ? ` · 옛 기록 지움(${leftoverParts.join(' · ')})` : ''}`,
detail: [
`용량 ${quota === null ? '제한 없음' : bytesText(quota)}`,
...(migrationNote !== null ? [migrationNote] : []),
...(purgeLeftover
? [`옛 기록 지움(${leftoverParts.join(' · ')})`]
: []),
].join(' · '),
});
} catch (error) {
step(io, false, failureLine(error));
const line = failureLine(
error,
until === null ? '임시 비밀번호' : '이전용 비밀번호',
);
// 결과를 모르면 계정이 만들어졌을 수 있다 — 다시 입사하면 "이미 쓰고 있습니다"에서 멈추므로 그때 할 일을 함께 알린다.
// 이전용은 이 실패 기록도 판정에 들어(countsForMigration) 만들어졌으면 이전 중으로 보인다 — 다시 만들기(그 주소)로 바로 되살린다
step(
io,
false,
isUnknownOutcome(error)
? `${line} 다시 할 때 "이미 쓰고 있습니다"가 나오면 계정은 만들어진 것입니다 — 그때는 ${until === null ? '「비밀번호 재설정」을' : '「메일 이전 › 이전용 비밀번호 다시 만들기」(그 주소)를'} 하세요.`
: line,
);
await this.audit.record({
operator,
command: 'hire',
command: until === null ? 'hire' : 'hire-migration',
target: email,
result: 'failure',
detail: failureDetail(error),
@@ -159,6 +224,13 @@ export class AccountCommands {
async resetPassword(io: OpsIo, operator: string): Promise<void> {
const employee = await askEmployee(io, this.engine, this.domain);
if (employee === null) return;
// 메일 이전 중 — 재설정은 이전용 비밀번호를 막아 imapsync 를 끊고, 이전 중 로그인 기록이 직원에게 남는다(「메일 이전」으로 한다).
// 실행 기록을 읽지 못했으면 엔진의 만료가 미래인 계정은 보수적으로 막는다
const guarded = await resetGuard(this.data, employee);
if (guarded !== null) {
io.print(guarded);
return;
}
if (employee.locked) {
io.print(
'잠긴 계정입니다 — 먼저 「잠금 풀기」를 하세요(잠금 풀기에서 비밀번호 재설정을 함께 할 수 있습니다).',
@@ -282,19 +354,11 @@ export class AccountCommands {
}
// 4. 엔진 — 임시 비밀번호 + 만료
let password = '';
let password: string;
try {
for (let attempt = 0; ; attempt += 1) {
password = generateTempPassword();
try {
await this.engine.resetPassword(employee.id, password);
break;
} catch (error) {
const weak =
error instanceof OpsEngineError && error.kind === 'weak-password';
if (!weak || attempt >= WEAK_PASSWORD_RETRIES) throw error;
}
}
password = await applyFreshPassword((candidate) =>
this.engine.resetPassword(employee.id, candidate),
);
step(io, true, '임시 비밀번호와 만료를 걸었습니다.');
} catch (error) {
notes.push(`세션 ${ended}개 끊음`);
+20 -9
View File
@@ -8,11 +8,15 @@ import {
type SystemSettings,
} from '@/modules/system-settings/system-settings.types';
import { kstDateString, kstStartOfDate } from '@/shared/utils/kst-date';
import {
notJudged,
withMigrationState,
type OpsEmployee,
} from './migration-state';
import { OpsAuditService } from './ops-audit.service';
import { OpsDataService } from './ops-data.service';
import {
OPS_ENGINE,
type EngineEmployee,
type EngineMailingList,
type OpsEngine,
} from './ops-engine.port';
@@ -31,6 +35,7 @@ import {
timeText,
toAddress,
toDisplayName,
usageText,
} from './ops-text';
/** 공용 주소 하나의 구성원 상한 — 오타로 아주 긴 목록을 만들지 않게 */
@@ -45,25 +50,24 @@ export function policyText(settings: SystemSettings): string {
return `켜짐 · 시작일 ${start}${exempt > 0 ? ` · 예외 ${exempt}명` : ''}`;
}
/** 현황의 한 줄 */
/** 현황의 한 줄 — 메일 이전 중이면 이전용 비밀번호가 끝나는 한국 날짜(MM-DD)를 함께 */
export function employeeLine(
employee: EngineEmployee,
employee: OpsEmployee,
lastLogin: Date | undefined,
): string {
const usage =
employee.quotaBytes === null
? bytesText(employee.usedBytes)
: `${bytesText(employee.usedBytes)} / ${bytesText(employee.quotaBytes)}`;
const flags = [
employee.locked ? '잠김' : null,
employee.passwordExpired ? '임시 비밀번호' : null,
employee.migrationUntil !== null
? `이전 중(~${kstDateString(new Date(employee.migrationUntil)).slice(5)})`
: null,
]
.filter(Boolean)
.join(' · ');
return [
`${employee.email} (${employee.name})`,
` 상태 ${flags || '정상'} · 2단계 ${employee.twoFactorEnabled ? '켜짐' : '꺼짐'} · 앱 비밀번호 ${employee.appPasswords}개`,
` 마지막 웹메일 로그인 ${timeText(lastLogin)} · 메일함 ${usage}`,
` 마지막 웹메일 로그인 ${timeText(lastLogin)} · 메일함 ${usageText(employee.usedBytes, employee.quotaBytes)}`,
].join('\n');
}
@@ -335,13 +339,20 @@ export class AdminCommands {
const lastLogins = await this.data.lastWebmailLogins(
employees.map((employee) => employee.email),
);
// 메일 이전 중 판정(실행 기록과 함께) — 읽지 못하면 이전 중 표시 없이 보이고 알린다
const judged = await withMigrationState(this.data, employees);
io.print('');
io.print(RULE);
io.print(` 직원 ${employees.length}명`);
io.print(RULE);
for (const employee of employees) {
for (const employee of judged ?? employees.map(notJudged)) {
io.print(employeeLine(employee, lastLogins.get(employee.email)));
}
if (judged === null) {
io.print(
' 이전 중 표시를 하지 못했습니다 — 실행 기록을 읽지 못했습니다(오류 로그 참고).',
);
}
io.print(RULE);
io.print(` 2단계 인증 필수 정책 ${policyText(settings)}`);
io.print(
+17 -2
View File
@@ -7,6 +7,7 @@ import {
} from '@/modules/mail/mail.constants';
import { MAIL_SETTINGS, type MailSettings } from '@/modules/mail/mail.settings';
import { AccountCommands } from './account-commands';
import { resetGuard } from './migration-state';
import { OpsAuditService } from './ops-audit.service';
import {
OpsDataService,
@@ -118,7 +119,7 @@ function footprintLines(footprint: AddressFootprint, first: number): string[] {
* 운영 도구 — 잠금 명령 (13단계 — 계약 6절 "운영 도구"): 잠금 · 잠금 풀기 · 퇴사 마무리.
* - 잠금: 받는 메일 넘기기 · 자동 응답(대리 접근 Sieve)을 먼저 → 로그인 권한(authenticate) 빼기 → 웹메일 세션 끊기 → (묻고) 공개 링크 끊기.
* 뺀 뒤에는 대리 접근이 막혀 Sieve 를 새로 걸 수 없다(이미 건 Sieve 는 계속 돈다). 메일은 계속 받는다
* - 잠금 풀기: 로그인 권한 되돌리기 → (묻고) 넘기기 · 자동 응답 떼기 → 비밀번호 재설정을 함께 권한다
* - 잠금 풀기: 로그인 권한 되돌리기 → (묻고) 넘기기 · 자동 응답 떼기 → 비밀번호 재설정을 함께 권한다(메일 이전 중이면 권하지 않는다 — 재설정의 가드 resetGuard)
* - 퇴사 마무리: 잠긴 계정만 — 주소를 다시 쳐서 확인한 뒤 엔진 계정 지우기(메일 영구 삭제) → 우리 DB 의 그 주소 줄 정리
*/
@Injectable()
@@ -393,11 +394,15 @@ export class LockCommands {
);
return;
}
// 비밀번호 재설정의 가드 — 메일 이전 중(또는 실행 기록을 읽지 못했는데 만료가 미래)이면 재설정을 권하지 않는다
const resetRefusal = await resetGuard(this.data, employee);
const lines = [
`대상 ${employee.name} <${employee.email}>`,
'1. 로그인 권한 되돌리기 — 지금 비밀번호 · 앱 비밀번호로 다시 들어올 수 있다',
' 잠글 때 건 넘기기 · 자동 응답은 푼 뒤에 뗄지 묻는다',
' 털린 계정이면 비밀번호 재설정을 함께 하기를 권한다(다음에 묻는다)',
resetRefusal !== null
? ' 비밀번호 재설정은 권하지 않는다 — 메일 이전 중이거나 그런지 확인하지 못했다(푼 뒤에 까닭을 보인다)'
: ' 털린 계정이면 비밀번호 재설정을 함께 하기를 권한다(다음에 묻는다)',
];
if (!(await confirmPlan(io, '잠금 풀기', lines))) return;
@@ -449,6 +454,11 @@ export class LockCommands {
detail: notes.join(' · ') || null,
});
if (resetRefusal !== null) {
// 비밀번호 재설정을 권하지 않는다 — 「비밀번호 재설정」의 가드와 같다
io.print(resetRefusal);
return;
}
if (
await io.confirm('비밀번호 재설정을 함께 할까요? (털린 계정이면 권함)')
) {
@@ -464,6 +474,11 @@ export class LockCommands {
);
return;
}
const refusedNow = await resetGuard(this.data, refreshed);
if (refusedNow !== null) {
io.print(refusedNow);
return;
}
if (
await confirmPlan(
io,
@@ -0,0 +1,864 @@
import { Inject, Injectable } from '@nestjs/common';
import { MAIL_SETTINGS, type MailSettings } from '@/modules/mail/mail.settings';
import {
MIGRATION_PASSWORD_DAYS,
MigrationPasswordSession,
migrationAuditText,
migrationUntilFrom,
type MigrationPasswordUse,
} from './migration-password';
import {
judgeOne,
withMigrationState,
type OpsEmployee,
} from './migration-state';
import type { OpsAuditResult } from './ops-audit-log.entity';
import { OpsAuditService } from './ops-audit.service';
import { OpsDataService } from './ops-data.service';
import {
OPS_ENGINE,
type EngineEmployee,
type OpsEngine,
} from './ops-engine.port';
import type { OpsIo } from './ops-io';
import {
MIGRATION_PASSWORD_LOST,
MIGRATION_PASSWORD_REUSED,
MIGRATION_RECORDS_UNREADABLE,
RULE,
confirmPlan,
failureLine,
lookUpEmployee,
showMigrationPassword,
step,
} from './ops-prompts';
import {
failureDetail,
isUnknownOutcome,
timeText,
toAddress,
usageText,
} from './ops-text';
import { applyFreshPassword } from './temp-password';
/**
* 비밀번호의 처지 — 이전 중(migration-state.ts 의 판정) · 만료(임시 비밀번호 · 30일이 지난 이전용 — 둘을 가를 수 없다) ·
* 직원이 정함(그 밖 — 만료가 없거나, 미래의 만료가 우리 이전용이 아니다)
*/
type PasswordState = 'migrating' | 'expired' | 'set';
function passwordStateOf(employee: OpsEmployee): PasswordState {
if (employee.migrationUntil !== null) return 'migrating';
return employee.passwordExpired ? 'expired' : 'set';
}
function isMigrating(employee: OpsEmployee): boolean {
return employee.migrationUntil !== null;
}
/** 이전용 비밀번호가 끝나는 때 (한국 시각) */
function untilText(employee: OpsEmployee): string {
return employee.migrationUntil === null
? '—'
: timeText(new Date(employee.migrationUntil));
}
/**
* 이전용 비밀번호를 걸지 않는 까닭 — 걸어도 되면 null.
* 이전 중 · 비밀번호가 만료된 계정만 받는다 — 직원이 정한 비밀번호를 덮으면 직원이 로그인하지 못한다.
* 잠긴 계정 · 2단계 인증이 켜진 계정(비밀번호로는 IMAP 에 들어가지 못한다)도 막는다
*/
export function renewRefusal(employee: OpsEmployee): string | null {
if (passwordStateOf(employee) === 'set') {
return '직원이 이미 비밀번호를 정한 계정입니다 — 이전용 비밀번호를 걸면 직원이 로그인하지 못합니다.';
}
if (employee.locked) {
return '잠긴 계정입니다 — 이전용 비밀번호로도 들어가지 못합니다. 먼저 「잠금 풀기」를 하세요.';
}
if (employee.twoFactorEnabled) {
return '2단계 인증이 켜진 계정입니다 — 비밀번호로는 IMAP 에 들어가지 못합니다. 먼저 「2단계 해제」를 하세요.';
}
return null;
}
/** 이전을 마치지 않는 까닭 — 마쳐도 되면 null (이전 중인 계정만 · 잠긴 계정은 막는다) */
export function finishRefusal(employee: OpsEmployee): string | null {
const state = passwordStateOf(employee);
if (state === 'expired') {
return '이전 중인 계정이 아닙니다 — 비밀번호가 만료된 계정입니다(30일이 지난 이전 계정이거나, 직원이 아직 바꾸지 않은 임시 비밀번호). 30일이 지난 이전 계정이면 「이전용 비밀번호 다시 만들기」 뒤에 마치고, 아니면 「비밀번호 재설정」을 하세요.';
}
if (state === 'set') {
return '이전 중인 계정이 아닙니다 — 직원이 이미 비밀번호를 정했습니다.';
}
if (employee.locked) {
return '잠긴 계정입니다 — 먼저 「잠금 풀기」를 하세요.';
}
return null;
}
/** 다시 읽은 결과 — 그대로면 지금의 계정, 아니면 하지 않는 까닭 */
type Recheck =
| { readonly ok: true; readonly employee: OpsEmployee }
| { readonly ok: false; readonly reason: string };
/**
* 바뀔 것을 보인 뒤 다시 읽은 계정이 그때와 같은 처지인가 — 아니면 까닭(하지 않는다).
* 그사이 다른 운영자가 마쳤거나 · 30일이 지났거나 · 잠겼을 수 있다 — 직원에게 넘긴 임시 비밀번호를 덮지 않게
*/
export function recheck(
planned: OpsEmployee,
current: OpsEmployee | null,
refusal: (employee: OpsEmployee) => string | null,
): Recheck {
if (current === null || current.id !== planned.id) {
return { ok: false, reason: '그사이 엔진에서 그 계정을 찾지 못했습니다.' };
}
if (passwordStateOf(current) !== passwordStateOf(planned)) {
return {
ok: false,
reason:
'그사이 비밀번호의 처지가 바뀌었습니다(다른 곳에서 마쳤거나 · 30일이 지났거나 · 직원이 바꿨습니다) — 「이전 현황」을 다시 보세요.',
};
}
const reason = refusal(current);
return reason === null
? { ok: true, employee: current }
: { ok: false, reason };
}
/** 이전 현황의 한 줄 — 주소 · 이름 · 끝나는 때 · 메일함(imapsync 뒤 mailcow 와 견준다) · 막힌 까닭 */
export function migrationLine(employee: OpsEmployee): string {
const flags = [
employee.locked ? '잠김' : null,
employee.twoFactorEnabled
? '2단계 켜짐(비밀번호로 IMAP 에 들어가지 못함)'
: null,
].filter(Boolean);
return [
`${employee.email} (${employee.name})`,
` 이전용 ~${untilText(employee)} · 메일함 ${usageText(employee.usedBytes, employee.quotaBytes)}${flags.length > 0 ? ` · ${flags.join(' · ')}` : ''}`,
].join('\n');
}
/** 명령의 대상 — 한 사람(주소) 또는 이전 중인 계정 모두(빈 값). 모두에서 막는 계정은 건너뛰고 바뀔 것에 까닭과 함께 보인다 */
interface Targets {
readonly all: boolean;
readonly accounts: readonly OpsEmployee[];
readonly skipped: readonly {
readonly employee: OpsEmployee;
readonly reason: string;
}[];
}
/** 바뀔 것의 대상 줄 — 모두면 계정마다 한 줄(`more` 를 덧붙인다), 한 사람이면 `single` */
function targetLines(
targets: Targets,
single: (employee: OpsEmployee) => string,
more: (employee: OpsEmployee) => string = () => '',
): string[] {
const lines = targets.all
? [
`대상 이전 중인 계정 ${targets.accounts.length}명 — 계정마다 차례로 한다`,
...targets.accounts.map(
(employee) =>
` ${employee.email} (${employee.name}) — 이전용 ~${untilText(employee)}${more(employee)}`,
),
]
: targets.accounts.map((employee) => `대상 ${single(employee)}`);
return [
...lines,
...targets.skipped.map(
({ employee, reason }) => ` 건너뜀: ${employee.email} — ${reason}`,
),
];
}
/** 이전용 비밀번호 다시 만들기의 바뀔 것 */
function renewPlan(
targets: Targets,
until: Date,
sessionHasPassword: boolean,
): string[] {
const expired = !targets.all && targets.accounts[0].passwordExpired;
return [
...targetLines(targets, (employee) =>
employee.migrationUntil !== null
? `${employee.name} <${employee.email}> — 지금 이전용 ~${untilText(employee)}`
: `${employee.name} <${employee.email}> — 비밀번호가 만료된 계정`,
),
'1. 웹메일 로그인 모두 끊기 — 지금 비밀번호로 들어온 로그인이 이전용 비밀번호로 이어지지 않게(건 뒤 그사이 새로 들어온 것도 끊는다)',
sessionHasPassword
? '2. 이번 실행의 이전용 비밀번호(이번 실행에서 마지막으로 보인 값)를 건다 — 다시 보이지 않는다'
: '2. 이전용 비밀번호를 무작위로 만들어 건다 — 만든 뒤 한 번만 보인다. 이번 실행에서는 모두 이 값을 쓴다',
...(sessionHasPassword ? [` ${MIGRATION_PASSWORD_LOST}`] : []),
' imapsync 에만 쓴다 — 직원에게 주지 않는다. 다른 실행에서 건 값은 더 쓰지 못한다(imapsync 의 비밀번호 파일을 바꾼다)',
`3. 쓸 수 있는 때 — ${timeText(until)} 까지(지금부터 ${MIGRATION_PASSWORD_DAYS}일). 지나면 엔진이 막는다`,
...(expired
? [' 주의: 지금 비밀번호(임시 · 지난 이전용)는 더 쓰지 못합니다']
: []),
];
}
/** 이전 마치기의 바뀔 것 — `loginEvents` 는 계정마다 지울 로그인 기록 수 */
function finishPlan(
targets: Targets,
loginEvents: ReadonlyMap<string, number>,
): string[] {
const single = targets.all ? null : targets.accounts[0];
const events = (employee: OpsEmployee) =>
loginEvents.get(employee.email) ?? 0;
const twoFactor = targets.accounts.filter(
(employee) => employee.twoFactorEnabled,
);
return [
...targetLines(
targets,
(employee) =>
`${employee.name} <${employee.email}> — 이전용 ~${untilText(employee)}`,
(employee) => ` · 로그인 기록 ${events(employee)}줄`,
),
'먼저 마지막 imapsync 가 끝났는지 확인한다 — 끝나고 5분쯤 뒤(엔진 웹훅의 다시 보내기)에 한다. 이 뒤로는 이전용 비밀번호로 들어가지 못한다',
single === null
? '1. 앱 비밀번호가 있으면 지우기'
: single.appPasswords > 0
? `1. 앱 비밀번호 ${single.appPasswords}개 지우기`
: '1. 앱 비밀번호 — 없다',
'2. 웹메일 로그인 모두 끊기 (확인하려 들어간 로그인 포함)',
single === null
? '3. 이전 중 로그인 기록 지우기(위의 줄 수 — 웹메일 · IMAP · SMTP 모두) — imapsync 의 IMAP 로그인이 직원의 로그인 기록에 보이지 않게'
: `3. 이전 중 로그인 기록 ${events(single)}줄 지우기(웹메일 · IMAP · SMTP 모두) — imapsync 의 IMAP 로그인이 직원의 로그인 기록에 보이지 않게`,
'4. 직원용 임시 비밀번호 + 만료 — 첫 로그인 때 새 비밀번호를 정한다',
'5. 그사이 새로 로그인한 웹메일도 끊기',
' 받은편지함 알림 메일은 넣지 않는다',
...(twoFactor.length > 0
? [
` 2단계 인증이 켜진 계정 — 직원이 로그인하려면 「2단계 해제」가 먼저다: ${twoFactor.map((employee) => employee.email).join(', ')}`,
]
: []),
' 끝에 직원용 임시 비밀번호 표를 한 번만 보인다 — 직원에게 직접 전한다',
];
}
/** 다시 만들기의 한 계정 결과 */
interface RenewOutcome {
/** 이전용 비밀번호를 걸었다 */
readonly applied: boolean;
/** 못 한 까닭 · 덧붙일 말 */
readonly problem: string | null;
/** 새 값을 상자로 보였다 */
readonly shown: boolean;
}
/** 이전 마치기의 한 계정 — 끝의 표에 한 줄. 임시 비밀번호는 엔진에 건 뒤에만 채운다 */
interface FinishRow {
readonly email: string;
readonly name: string;
password: string | null;
/** 못 한 까닭 · 덧붙일 말 */
problem: string | null;
}
/** 이전 마치기의 끝 — 직원용 임시 비밀번호(한 번만) · 못 한 까닭. 건 것이 없으면 전하라는 줄은 보이지 않는다 */
function showFinishTable(io: OpsIo, rows: readonly FinishRow[]): void {
if (rows.length === 0) return;
const done = rows.filter((row) => row.password !== null).length;
const failed = rows.length - done;
io.print('');
io.print(RULE);
io.print(` 이전 마치기 — 직원용 임시 비밀번호 ${done}명 · 못 함 ${failed}명`);
if (done > 0) {
io.print(
' 이 화면을 닫으면 다시 볼 수 없습니다 — 직원에게 직접(말 · 쪽지) 전하세요.',
);
}
io.print(RULE);
for (const row of rows) {
io.print(` ${row.email} (${row.name})`);
if (row.password !== null) {
io.print(` 임시 비밀번호 ${row.password}`);
if (row.problem !== null) io.print(` 참고: ${row.problem}`);
} else {
io.print(
` [못 함] ${row.problem ?? '까닭을 모릅니다 — 바로 위의 줄을 보세요.'}`,
);
}
}
io.print(RULE);
if (done > 0) io.print(' 직원은 첫 로그인 때 새 비밀번호를 정합니다.');
if (failed > 0) {
io.print(' 못 한 계정은 까닭을 고친 뒤 「이전 마치기」를 다시 하세요.');
}
io.print(RULE);
}
/**
* 운영 도구 — 메일 이전 (mailcow → 새 엔진, imapsync): 이전 현황 · 이전용 비밀번호 다시 만들기 · 이전 마치기.
* "이전 중" 판정은 migration-state.ts 하나 — 엔진의 미래 만료(31일 안) + 우리 실행 기록의 마지막 비밀번호 걸기가 이전용.
* 실행 기록을 읽지 못하면 판정을 할 수 없어 이 메뉴는 멈춘다(MIGRATION_RECORDS_UNREADABLE).
* 이전용 비밀번호는 이번 실행 공통(MigrationPasswordSession)이고, 직원용 임시 비밀번호는 「이전 마치기」가 계정마다 따로 만든다.
* 비밀번호는 화면에 한 번만 보이고 실행 기록 · 로그에 남기지 않는다
*/
@Injectable()
export class MigrationCommands {
constructor(
@Inject(OPS_ENGINE) private readonly engine: OpsEngine,
@Inject(MAIL_SETTINGS) private readonly mailSettings: MailSettings,
private readonly data: OpsDataService,
private readonly audit: OpsAuditService,
private readonly migrationPassword: MigrationPasswordSession,
) {}
private get domain(): string {
return this.mailSettings.domain ?? '';
}
/** 메일 이전 — 하위 메뉴 */
async menu(io: OpsIo, operator: string): Promise<void> {
io.print(
'메일 이전 — 1. 이전 현황 2. 이전용 비밀번호 다시 만들기 3. 이전 마치기 0. 돌아가기',
);
const choice = await io.ask('고르세요: ');
if (choice === '1') await this.status(io);
else if (choice === '2') await this.renewPasswords(io, operator);
else if (choice === '3') await this.finish(io, operator);
}
/**
* 직원 모두를 읽고 판정을 붙인다 — 엔진을 읽지 못하면 그 까닭을, 실행 기록을 읽지 못하면 판정을 할 수 없다고 알리고 null
*/
private async judgedEmployees(io: OpsIo): Promise<OpsEmployee[] | null> {
let employees: EngineEmployee[];
try {
employees = await this.engine.listEmployees();
} catch (error) {
io.print(failureLine(error));
return null;
}
const judged = await withMigrationState(this.data, employees);
if (judged === null) io.print(MIGRATION_RECORDS_UNREADABLE);
return judged;
}
/** 이전 현황 — 이전 중인 계정: 주소 · 이름 · 끝나는 때 · 메일함 사용량 (읽기만 — 기록하지 않는다) */
async status(io: OpsIo): Promise<void> {
const employees = await this.judgedEmployees(io);
if (employees === null) return;
const migrating = employees.filter(isMigrating);
const expired = employees.filter(
(employee) => passwordStateOf(employee) === 'expired',
).length;
// 30일이 지난 이전 계정은 이 목록에서 빠진다 — 임시 비밀번호 계정과 가를 수 없어 수만 알린다
const expiredNote =
expired > 0
? ` 비밀번호가 만료된 계정 ${expired}명은 여기에 없습니다 — 직원이 아직 바꾸지 않은 임시 비밀번호이거나 30일이 지난 이전 계정입니다(이전을 이어 하려면 「이전용 비밀번호 다시 만들기」에 그 주소).`
: null;
if (migrating.length === 0) {
io.print('이전 중인 계정이 없습니다.');
if (expiredNote !== null) io.print(expiredNote);
return;
}
io.print('');
io.print(RULE);
io.print(' 메일 이전 중 — 이전용 비밀번호가 끝나는 때(한국 시각) · 메일함');
io.print(RULE);
for (const employee of migrating) io.print(migrationLine(employee));
io.print(RULE);
io.print(` 이전 중 ${migrating.length}명`);
if (expiredNote !== null) io.print(expiredNote);
io.print(RULE);
}
/**
* 이전용 비밀번호 다시 만들기 — 웹메일 세션 끊기 → 이번 실행의 이전용 비밀번호(없으면 만들어 한 번 보인다) + 지금부터 30일 → 세션 한 번 더 끊기.
* 잃었거나 30일이 다가올 때. 주소를 넣으면 그 한 사람(이전 중 · 비밀번호가 만료된 계정), 빈 값이면 이전 중인 계정 모두
* (만료된 계정은 넣지 않는다 — 직원에게 넘긴 임시 비밀번호와 가를 수 없다)
*/
async renewPasswords(io: OpsIo, operator: string): Promise<void> {
const targets = await this.askTargets(io, renewRefusal);
if (targets === null) return;
const until = migrationUntilFrom(new Date());
if (
!(await confirmPlan(
io,
'이전용 비밀번호 다시 만들기',
renewPlan(targets, until, this.migrationPassword.exists),
))
) {
return;
}
const outcomes: {
readonly employee: OpsEmployee;
readonly outcome: RenewOutcome;
}[] = [];
for (const employee of targets.accounts) {
let outcome: RenewOutcome;
try {
outcome = await this.renewOne(io, operator, employee, until);
} catch (error) {
// 모르는 오류 — 그 계정만 못 한 것으로 두고 이어 간다(새로 만든 값은 걸자마자 상자로 보였다)
outcome = {
applied: false,
problem: failureLine(error, '이전용 비밀번호'),
shown: false,
};
}
outcomes.push({ employee, outcome });
}
if (!targets.all) return;
const applied = outcomes.filter(({ outcome }) => outcome.applied).length;
io.print('');
io.print(RULE);
io.print(
` 이전용 비밀번호 다시 만들기 — 걸었음 ${applied}명 · 못 함 ${outcomes.length - applied}명`,
);
for (const { employee, outcome } of outcomes) {
if (outcome.problem === null) continue;
io.print(
` ${employee.email} (${employee.name}) — ${outcome.applied ? '참고: ' : '[못 함] '}${outcome.problem}`,
);
}
if (outcomes.some(({ outcome }) => outcome.shown)) {
io.print(' 새 이전용 비밀번호는 위 상자에 한 번만 보였습니다.');
}
if (applied < outcomes.length) {
io.print(
' 못 한 계정은 까닭을 고친 뒤 같은 실행에서 「다시 만들기」(빈 값)를 한 번 더 — 모두 이번 값이 됩니다.',
);
}
io.print(RULE);
}
/**
* y/N 뒤 그 계정을 엔진에서 다시 읽고 판정한다 — 바뀔 것을 보일 때와 처지가 같아야 한다(직원에게 넘긴 임시 비밀번호를 덮지 않게).
* 아니면 하지 않는 까닭과 실행 기록에 남길 말
*/
private async reread(
planned: OpsEmployee,
refusal: (employee: OpsEmployee) => string | null,
): Promise<
| { readonly ok: true; readonly employee: OpsEmployee }
| { readonly ok: false; readonly reason: string; readonly detail: string }
> {
let current: EngineEmployee | null;
try {
current = await this.engine.findEmployee(planned.email);
} catch (error) {
return {
ok: false,
reason: `계정을 다시 읽지 못했습니다 — ${failureLine(error)}`,
detail: `다시 읽기 실패 · ${failureDetail(error)}`,
};
}
let judged: OpsEmployee | null = null;
if (current !== null) {
judged = await judgeOne(this.data, current);
if (judged === null) {
return {
ok: false,
reason:
'이전 중인지 다시 확인하지 못했습니다 — 실행 기록을 읽지 못했습니다(오류 로그 참고).',
detail: '다시 읽기 실패 · 실행 기록',
};
}
}
const checked = recheck(planned, judged, refusal);
return checked.ok
? checked
: {
ok: false,
reason: checked.reason,
detail: `하지 않음 · ${checked.reason}`,
};
}
/** 한 계정 — 다시 읽기 → 웹메일 세션 끊기 → 이번 실행의 이전용 비밀번호 + until → 세션 한 번 더 끊기 */
private async renewOne(
io: OpsIo,
operator: string,
planned: OpsEmployee,
until: Date,
): Promise<RenewOutcome> {
io.print('');
io.print(`── ${planned.name} <${planned.email}>`);
const record = (result: OpsAuditResult, detail: string) =>
this.audit.record({
operator,
command: 'migration-password',
target: planned.email,
result,
detail,
});
const notDone = async (
problem: string,
detail: string,
): Promise<RenewOutcome> => {
await record('failure', detail);
return { applied: false, problem, shown: false };
};
// 0. 바뀔 것을 보인 뒤 그사이 바뀌었으면 하지 않는다 (직원에게 넘긴 임시 비밀번호를 덮지 않게)
const reread = await this.reread(planned, renewRefusal);
if (!reread.ok) {
step(io, false, `${reread.reason} 바꾼 것이 없습니다.`);
return notDone(reread.reason, reread.detail);
}
const { employee } = reread;
const from =
employee.migrationUntil !== null
? '이전 중이던 계정'
: '비밀번호가 만료된 계정';
// 1. 웹메일 세션 — 임시 비밀번호(만료)로 들어와 비밀번호 바꾸기로 묶인 세션은 만료가 미래가 되면 갱신 때 제한이 풀린다(이전 중 메일함이 열린다).
// 끊지 못하면 비밀번호를 바꾸지 않고 멈춘다
let ended: number;
try {
ended = await this.data.revokeSessions(employee.email);
step(
io,
true,
`웹메일 로그인 ${ended}개를 끊었습니다(15초 안에 막힙니다).`,
);
} catch (error) {
const line = failureLine(error);
step(
io,
false,
`웹메일 로그인을 끊지 못해 멈춥니다 — 비밀번호는 그대로입니다. ${line}`,
);
return notDone(
`웹메일 로그인을 끊지 못했습니다(비밀번호 그대로) — ${line}`,
`${from} · 세션 끊기 실패 · ${failureDetail(error)}`,
);
}
// 2. 엔진 — 이번 실행의 이전용 비밀번호 + until
let use: MigrationPasswordUse;
try {
use = await this.migrationPassword.apply((password) =>
this.engine.setMigrationPassword(employee.id, password, until),
);
} catch (error) {
const line = failureLine(error, '이전용 비밀번호');
const unknown = isUnknownOutcome(error);
// 결과를 모르면 "같은 명령을 다시 하세요"(failureLine) — 이 실패 기록도 판정에 들어(countsForMigration) 걸렸으면 이전 중으로 보여 다시 된다.
// 같은 실행이면 같은 값을 다시 건다 — imapsync 의 비밀번호 파일은 그대로 둔다
const problem =
unknown && this.migrationPassword.exists
? `${line} 같은 실행에서 다시 하면 이번 실행의 값을 다시 겁니다.`
: line;
step(
io,
false,
unknown
? `이전용 비밀번호를 걸었는지 모릅니다 — ${problem}`
: `이전용 비밀번호를 걸지 못했습니다 — 지금 비밀번호는 그대로입니다. ${line}`,
);
return notDone(
problem,
`${from} · 세션 ${ended}개 끊음 · ${unknown ? '결과 모름' : '실패'} · ${failureDetail(error)}`,
);
}
// 새로 만든 값은 곧바로 보인다(한 번만) — 뒤에서 무엇이 실패해도 이미 보였다
if (use.kind === 'reused') {
step(io, true, MIGRATION_PASSWORD_REUSED);
} else {
step(io, true, '이전용 비밀번호를 새로 만들어 걸었습니다.');
showMigrationPassword(
io,
employee.email,
use.password,
until,
use.kind === 'replaced',
);
}
// 3. 그사이(1과 2 사이) 지금 비밀번호로 새로 들어온 웹메일
let result: 'success' | 'partial' = 'success';
let problem: string | null = null;
const notes: string[] = [];
try {
const late = await this.data.revokeSessions(employee.email);
ended += late;
if (late > 0) {
step(io, true, `그사이 새로 로그인한 웹메일 ${late}개도 끊었습니다.`);
}
} catch (error) {
result = 'partial';
const line = failureLine(error);
step(
io,
false,
`그사이 새로 로그인한 웹메일을 확인하지 못했습니다. ${line}`,
);
notes.push(`세션 다시 끊기 실패 · ${failureDetail(error)}`);
problem = `그사이 새로 로그인한 웹메일을 확인하지 못했습니다 — ${line}`;
}
await record(
result,
[
migrationAuditText(until, use),
from,
`세션 ${ended}개 끊음`,
...notes,
].join(' · '),
);
return { applied: true, problem, shown: use.kind !== 'reused' };
}
/**
* 이전 마치기 — 한 사람 또는 이전 중인 계정 모두. 계정마다 차례로(재설정과 같은 까닭 — 만료 뒤에는 대리 접근이 막힌다):
* ① 앱 비밀번호가 있으면 지우기 → ② 웹메일 세션 끊기 → ③ 이전 중 로그인 기록 지우기 → ④ 직원용 임시 비밀번호 + 지난 만료 → ⑤ 그사이 생긴 세션 한 번 더 끊기.
* 받은편지함 알림 메일은 넣지 않는다(재설정 알림은 "관리자가 비밀번호를 바꿈"이라 새 직원이 놀란다).
* 한 계정이 실패해도 나머지는 이어 하고, 끝에 임시 비밀번호 표를 한 번 보인다 — 예외가 나도 앞에서 건 것은 보인다(finally)
*/
async finish(io: OpsIo, operator: string): Promise<void> {
const targets = await this.askTargets(io, finishRefusal);
if (targets === null) return;
// 바뀔 것에 보일 지울 로그인 기록 수 — 읽지 못하면 하지 않는다
const loginEvents = new Map<string, number>();
try {
for (const employee of targets.accounts) {
loginEvents.set(
employee.email,
await this.data.countLoginEventsOf(employee.email),
);
}
} catch (error) {
io.print(failureLine(error));
return;
}
if (
!(await confirmPlan(io, '이전 마치기', finishPlan(targets, loginEvents)))
) {
return;
}
const rows: FinishRow[] = [];
try {
for (const planned of targets.accounts) {
const row: FinishRow = {
email: planned.email,
name: planned.name,
password: null,
problem: null,
};
rows.push(row);
try {
await this.finishOne(io, operator, planned, row);
} catch (error) {
// 모르는 오류 — 그 계정만 못 한 것으로 두고 이어 간다(건 임시 비밀번호는 표에 남는다)
const line = failureLine(error);
if (row.problem === null) row.problem = line;
}
}
} finally {
showFinishTable(io, rows);
}
}
/** 이전 마치기 — 한 계정의 차례. 임시 비밀번호를 걸면 곧바로 row 에 둔다(뒤에서 무엇이 실패해도 표에 보이게) */
private async finishOne(
io: OpsIo,
operator: string,
planned: OpsEmployee,
row: FinishRow,
): Promise<void> {
io.print('');
io.print(`── ${planned.name} <${planned.email}>`);
const notes: string[] = [];
const fail = async (problem: string, detail: string): Promise<void> => {
row.problem = problem;
await this.audit.record({
operator,
command: 'migration-finish',
target: planned.email,
result: 'failure',
detail: [...notes, detail].join(' · '),
});
};
// 0. 바뀔 것을 보인 뒤 그사이 바뀌었으면 하지 않는다 — 다른 곳에서 마쳐 직원에게 넘긴 임시 비밀번호를 덮지 않게
const reread = await this.reread(planned, finishRefusal);
if (!reread.ok) {
step(io, false, `${reread.reason} 바꾼 것이 없습니다.`);
return fail(reread.reason, reread.detail);
}
const { employee } = reread;
// ① 앱 비밀번호 — 있으면 지운다. 지우지 못하면 아무것도 바꾸지 않고 멈춘다(재설정과 같다 — 이전 중에 만든 것이 남지 않게)
if (employee.appPasswords > 0) {
try {
const removed = await this.engine.deleteAppPasswords(employee.email);
step(io, true, `앱 비밀번호 ${removed}개를 지웠습니다.`);
notes.push(`앱 비밀번호 ${removed}개 지움`);
} catch (error) {
const line = failureLine(error);
step(
io,
false,
`앱 비밀번호를 지우지 못해 멈춥니다 — 아무것도 바꾸지 않았습니다. ${line}`,
);
return fail(
`앱 비밀번호를 지우지 못했습니다(바꾼 것 없음) — ${line}`,
`앱 비밀번호 지우기 실패 · ${failureDetail(error)}`,
);
}
}
// ② 웹메일 세션 — 운영자가 확인하려 들어갔을 수 있다. 끊지 못하면 멈춘다(이전용 비밀번호 그대로 — 다시 하면 된다)
let ended: number;
try {
ended = await this.data.revokeSessions(employee.email);
step(
io,
true,
`웹메일 로그인 ${ended}개를 끊었습니다(15초 안에 막힙니다).`,
);
} catch (error) {
const line = failureLine(error);
step(
io,
false,
`웹메일 로그인을 끊지 못해 멈춥니다 — 비밀번호는 그대로입니다. ${line}`,
);
return fail(
`웹메일 로그인을 끊지 못했습니다(이전용 비밀번호 그대로) — ${line}`,
`세션 끊기 실패 · ${failureDetail(error)}`,
);
}
// ③ 이전 중 로그인 기록 — 지우지 못하면 멈춘다(넘긴 뒤에는 지울 명령이 없다 — 이전 중인 동안 다시 하면 된다)
try {
const removed = await this.data.deleteLoginEventsOf(employee.email);
step(io, true, `이전 중 로그인 기록 ${removed}줄을 지웠습니다.`);
notes.push(`로그인 기록 ${removed}줄 지움`);
} catch (error) {
notes.push(`세션 ${ended}개 끊음`);
const line = failureLine(error);
step(
io,
false,
`이전 중 로그인 기록을 지우지 못해 멈춥니다 — 비밀번호는 그대로입니다. ${line}`,
);
return fail(
`이전 중 로그인 기록을 지우지 못했습니다(이전용 비밀번호 그대로) — ${line}`,
`로그인 기록 지우기 실패 · ${failureDetail(error)}`,
);
}
// ④ 엔진 — 직원용 임시 비밀번호 + 지난 만료(재설정과 같다). 이 뒤로는 이전용 비밀번호가 막힌다
let password: string;
try {
password = await applyFreshPassword((candidate) =>
this.engine.resetPassword(employee.id, candidate),
);
} catch (error) {
notes.push(`세션 ${ended}개 끊음`);
const line = failureLine(error);
if (isUnknownOutcome(error)) {
// 엔진이 바꿨을 수 있다 — 바뀌었으면 다시 할 때 "이전 중이 아님"이 나온다
step(io, false, `임시 비밀번호를 걸었는지 모릅니다 — ${line}`);
return fail(
`${line} 다시 할 때 이전 중이 아니라고 나오면 이미 바뀐 것입니다 — 「비밀번호 재설정」으로 새 임시 비밀번호를 만드세요.`,
`비밀번호 바꾸기 결과 모름 · ${failureDetail(error)}`,
);
}
step(
io,
false,
`임시 비밀번호를 걸지 못했습니다 — 이전용 비밀번호는 그대로입니다. ${line}`,
);
return fail(
`임시 비밀번호를 걸지 못했습니다(이전용 비밀번호 그대로) — ${line}`,
`비밀번호 바꾸기 실패 · ${failureDetail(error)}`,
);
}
row.password = password;
step(
io,
true,
'직원용 임시 비밀번호와 만료를 걸었습니다 — 이전용 비밀번호는 더 쓰지 못합니다.',
);
// ⑤ 그사이(②와 ④ 사이) 이전용 비밀번호로 새로 로그인한 웹메일
let result: 'success' | 'partial' = 'success';
try {
const late = await this.data.revokeSessions(employee.email);
ended += late;
if (late > 0) {
step(io, true, `그사이 새로 로그인한 웹메일 ${late}개도 끊었습니다.`);
}
} catch (error) {
result = 'partial';
const line = failureLine(error);
step(
io,
false,
`그사이 새로 로그인한 웹메일을 확인하지 못했습니다. ${line}`,
);
notes.push(`세션 다시 끊기 실패 · ${failureDetail(error)}`);
row.problem = `그사이 새로 로그인한 웹메일을 확인하지 못했습니다 — ${line}`;
}
notes.push(`세션 ${ended}개 끊음`);
await this.audit.record({
operator,
command: 'migration-finish',
target: employee.email,
result,
detail: notes.join(' · '),
});
}
/**
* 대상을 묻는다 — 주소면 그 한 사람(막는 까닭이 있으면 알리고 null), 빈 값이면 이전 중인 계정 모두(막는 계정은 건너뛴다).
* 모두에는 비밀번호가 만료된 계정을 넣지 않는다 — 30일이 지난 이전 계정과 직원에게 넘긴 임시 비밀번호를 가를 수 없다
*/
private async askTargets(
io: OpsIo,
refusal: (employee: OpsEmployee) => string | null,
): Promise<Targets | null> {
const input = await io.ask('주소 (빈 값이면 이전 중인 계정 모두): ');
if (input === '') {
const employees = await this.judgedEmployees(io);
if (employees === null) return null;
const migrating = employees.filter(isMigrating);
if (migrating.length === 0) {
io.print('이전 중인 계정이 없습니다.');
return null;
}
const accounts: OpsEmployee[] = [];
const skipped: { employee: OpsEmployee; reason: string }[] = [];
for (const employee of migrating) {
const reason = refusal(employee);
if (reason === null) accounts.push(employee);
else skipped.push({ employee, reason });
}
if (accounts.length === 0) {
io.print('이전 중인 계정에 할 수 있는 것이 없습니다:');
for (const { employee, reason } of skipped) {
io.print(` ${employee.email} — ${reason}`);
}
return null;
}
return { all: true, accounts, skipped };
}
const email = toAddress(input, this.domain);
if (email === null) {
io.print(`주소 모양이 맞지 않거나 @${this.domain} 주소가 아닙니다.`);
return null;
}
const found = await lookUpEmployee(io, this.engine, email);
if (found === null) return null;
const employee = await judgeOne(this.data, found);
if (employee === null) {
io.print(MIGRATION_RECORDS_UNREADABLE);
return null;
}
const reason = refusal(employee);
if (reason !== null) {
io.print(reason);
return null;
}
return { all: false, accounts: [employee], skipped: [] };
}
}
@@ -0,0 +1,74 @@
import { Injectable } from '@nestjs/common';
import { kstDateString } from '@/shared/utils/kst-date';
import { applyFreshPassword, isWeakPasswordRefusal } from './temp-password';
/** 이전용 비밀번호를 쓸 수 있는 날 수 — 입사(옮겨 올 메일 있음) · 「메일 이전 › 이전용 비밀번호 다시 만들기」가 지금부터 이만큼 건다 */
export const MIGRATION_PASSWORD_DAYS = 30;
const DAY_MS = 24 * 60 * 60 * 1000;
/** 이전용 비밀번호의 끝 — `now` 부터 30일 */
export function migrationUntilFrom(now: Date): Date {
return new Date(now.getTime() + MIGRATION_PASSWORD_DAYS * DAY_MS);
}
/** 이번 실행의 이전용 비밀번호를 건 결과 — 새로 만들었을 때만 값을 돌려준다(그때 한 번만 보인다) */
export type MigrationPasswordUse =
/** 이번 실행의 값을 다시 걸었다 — 보이지 않는다(처음 만들 때 보였다) */
| { readonly kind: 'reused' }
/** 이번 실행에 처음 만들었다 */
| { readonly kind: 'created'; readonly password: string }
/** 엔진이 이번 실행의 값을 약하다고 받지 않아 새로 만들어 바꿨다 — 앞에서 건 계정들은 옛 값 그대로다 */
| { readonly kind: 'replaced'; readonly password: string };
/** 실행 기록(ops_audit_log)의 이전용 비밀번호 — 끝나는 한국 날짜만(비밀 없음). 예) 이전용(이번 실행 공통 · ~2026-12-02) */
export function migrationAuditText(
until: Date,
use: MigrationPasswordUse,
): string {
return `이전용(이번 실행 공통${use.kind === 'replaced' ? ' · 값 바꿈' : ''} · ~${kstDateString(until)})`;
}
/**
* 이번 실행의 이전용 비밀번호 (메일 이전 — 운영 도구 한 번 실행 안에서 같은 값).
* 운영자가 직원 수십 명의 비밀번호를 파일 수십 개에 옮겨 적지 않고 imapsync 비밀번호 파일 하나로 모두 옮기게 한다.
* 값은 이 프로세스의 메모리에만 둔다(관리자 비밀번호처럼) — 실행 기록 · 로그 · DB 에 남기지 않고, 도구를 끝낼 때 지운다(OpsCli)
*/
@Injectable()
export class MigrationPasswordSession {
private password: string | null = null;
/** 이번 실행에 이미 만들었는가 */
get exists(): boolean {
return this.password !== null;
}
/**
* 이번 실행의 값으로 `apply`(엔진에 걸기)를 한다 — 없으면 새로 만들어(약하면 다시) 걸고 이번 실행의 값으로 둔다.
* 엔진이 이번 실행의 값을 약하다고 받지 않으면(이미 받은 값이라 드물다) 새로 만들어 바꾼다. 그 밖의 실패는 그대로 던진다(값은 그대로)
*/
async apply(
apply: (password: string) => Promise<unknown>,
): Promise<MigrationPasswordUse> {
const current = this.password;
if (current !== null) {
try {
await apply(current);
return { kind: 'reused' };
} catch (error) {
if (!isWeakPasswordRefusal(error)) throw error;
}
}
const fresh = await applyFreshPassword(apply);
this.password = fresh;
return current === null
? { kind: 'created', password: fresh }
: { kind: 'replaced', password: fresh };
}
/** 지운다 — 도구를 끝낼 때 */
clear(): void {
this.password = null;
}
}
+103
View File
@@ -0,0 +1,103 @@
import { MIGRATION_PASSWORD_DAYS } from './migration-password';
import type { OpsDataService } from './ops-data.service';
import type { EngineEmployee } from './ops-engine.port';
import {
logUnknownFailure,
migratingNotice,
unverifiedMigrationNotice,
} from './ops-prompts';
/**
* 메일 이전 중 판정 — 운영 도구에 하나뿐이다. 현황 · 비밀번호 재설정 · 잠금 풀기 · 「메일 이전」(현황 · 다시 만들기 · 마치기)이 모두 이것을 쓴다.
* 이전 중 = 엔진의 비밀번호 만료가 미래(지금 + 31일 안)이고, 우리 실행 기록에서 그 주소의 판정 기록(비밀번호를 건 성공 · 이전용 명령의 실패 —
* ops-data.service.ts 의 countsForMigration) 가운데 마지막이 이전용(이전용 입사 · 다시 만들기)이다. 만료 조건이 안전망이다 —
* 이전용 명령이 실제로는 거절됐으면 계정이 없거나 옛 만료 그대로다.
* 엔진의 만료만으로는 가를 수 없다 — 엔진은 비밀번호 기본 만료가 켜져 있던 동안 직원이 정한 비밀번호에도 미래의 만료를 저장하고, 끈 뒤에도 남긴다(0.16.27)
*/
const DAY_MS = 24 * 60 * 60 * 1000;
/** 이전용 비밀번호의 끝이 될 수 있는 가장 먼 때 — 걸 때 지금부터 30일, 하루 여유 */
export const MIGRATION_WINDOW_DAYS = MIGRATION_PASSWORD_DAYS + 1;
/** 운영 도구가 본 직원 — 엔진 계정 + 메일 이전 중 판정 */
export interface OpsEmployee extends EngineEmployee {
/** 메일 이전 중이면 이전용 비밀번호의 끝(ISO 8601 UTC), 아니면 null — migrationUntilOf 로만 정한다 */
readonly migrationUntil: string | null;
}
/** 판정 — 이전 중이면 이전용 비밀번호의 끝, 아니면 null */
export function migrationUntilOf(
employee: EngineEmployee,
lastSetIsMigration: boolean,
now: Date,
): string | null {
if (!lastSetIsMigration || employee.passwordExpiresAt === null) return null;
const until = Date.parse(employee.passwordExpiresAt);
const nowMs = now.getTime();
if (!(until > nowMs) || until > nowMs + MIGRATION_WINDOW_DAYS * DAY_MS) {
return null;
}
return employee.passwordExpiresAt;
}
/**
* 엔진 계정들에 판정을 붙인다 — 실행 기록은 쿼리 하나로 읽는다.
* 읽지 못하면 null(오류 로그를 남긴다) — 「메일 이전」은 멈추고, 재설정 · 잠금 풀기는 엔진만 보는 보수적인 가드(resetGuard)로 간다
*/
export async function withMigrationState(
data: Pick<OpsDataService, 'lastPasswordSetIsMigration'>,
employees: readonly EngineEmployee[],
now: Date = new Date(),
): Promise<OpsEmployee[] | null> {
let migrating: ReadonlySet<string>;
try {
migrating = await data.lastPasswordSetIsMigration(
employees.map((employee) => employee.email),
);
} catch (error) {
logUnknownFailure(error);
return null;
}
return employees.map((employee) => ({
...employee,
migrationUntil: migrationUntilOf(
employee,
migrating.has(employee.email),
now,
),
}));
}
/** 한 사람 — 읽지 못하면 null */
export async function judgeOne(
data: Pick<OpsDataService, 'lastPasswordSetIsMigration'>,
employee: EngineEmployee,
): Promise<OpsEmployee | null> {
const [judged] = (await withMigrationState(data, [employee])) ?? [];
return judged ?? null;
}
/** 실행 기록을 읽지 못해 판정이 없을 때 쓰는 모양 — 이전 중이 아니다(현황 등 보이기만 하는 곳) */
export function notJudged(employee: EngineEmployee): OpsEmployee {
return { ...employee, migrationUntil: null };
}
/**
* 비밀번호 재설정(잠금 풀기가 권하는 것 포함)을 하지 않는 까닭 — 해도 되면 null.
* 이전 중이면 「메일 이전」으로 안내한다. 실행 기록을 읽지 못했으면 엔진만 본다 — 만료가 미래면 이전 중일 수 있어 보수적으로 막는다(이전용 비밀번호를 잘못 덮지 않게)
*/
export async function resetGuard(
data: Pick<OpsDataService, 'lastPasswordSetIsMigration'>,
employee: EngineEmployee,
): Promise<string | null> {
const judged = await judgeOne(data, employee);
if (judged === null) {
return employee.passwordExpiresAt === null
? null
: unverifiedMigrationNotice(employee.passwordExpiresAt);
}
return judged.migrationUntil === null
? null
: migratingNotice(judged.migrationUntil);
}
@@ -14,6 +14,49 @@ export const OPS_DETAIL_MAX_LENGTH = 500;
/** 실행 결과 */
export type OpsAuditResult = 'success' | 'failure' | 'partial';
/** 실행 기록의 명령 이름 — 기록을 남기는 곳은 모두 이 이름만 쓴다(타입이 막는다) */
export type OpsCommand =
| 'hire'
/** 입사 — 옮겨 올 메일이 있어 이전용 비밀번호로 만들었다 */
| 'hire-migration'
| 'password-reset'
| 'two-factor-reset'
| 'lock'
| 'unlock'
| 'offboard'
| 'link-revoke'
| 'mailing-list'
| 'policy'
| 'support-contact'
/** 메일 이전 › 이전용 비밀번호 다시 만들기 */
| 'migration-password'
/** 메일 이전 › 이전 마치기 — 직원용 임시 비밀번호 */
| 'migration-finish';
/**
* 비밀번호를 거는 명령 — 메일 이전 중 판정이 그 주소의 마지막 기록(성공 · 이전용 명령의 실패 — ops-data.service.ts 의 countsForMigration)을 본다.
* 잠금 풀기가 함께 하는 재설정도 password-reset 으로 남는다. 비밀번호를 거는 명령을 새로 만들면 여기에 더한다(빠지면 판정이 틀린다)
*/
export const PASSWORD_SETTING_COMMANDS: readonly OpsCommand[] = [
'hire',
'hire-migration',
'password-reset',
'migration-password',
'migration-finish',
];
/** 그 가운데 이전용 비밀번호를 거는 것 */
export const MIGRATION_PASSWORD_COMMANDS: readonly OpsCommand[] = [
'hire-migration',
'migration-password',
];
/** 비밀번호를 걸었다고 보는 결과 — partial 은 비밀번호는 걸고 뒷정리 하나를 못 한 것이다 */
export const PASSWORD_SET_RESULTS: readonly OpsAuditResult[] = [
'success',
'partial',
];
/**
* 운영 도구의 실행 기록 (13단계 — 계약 6절 "운영 도구") — 언제 · 누가 · 명령 · 대상 · 결과.
* 비밀(관리자 비밀번호 · 임시 비밀번호 · 코드)은 남기지 않는다. 퇴사 마무리도 이 표는 지우지 않는다(DB 백업에 든다)
@@ -32,7 +75,7 @@ export class OpsAuditLogEntity {
@Column({ type: 'varchar', length: OPS_OPERATOR_MAX_LENGTH })
operator!: string;
/** 명령 — hire · password-reset · two-factor-reset · lock · unlock · offboard · link-revoke · mailing-list · policy · support-contact */
/** 명령 — OpsCommand (hire · hire-migration · password-reset · … · migration-password · migration-finish) */
@Column({ type: 'varchar', length: COMMAND_COLUMN_LENGTH })
command!: string;
+2 -1
View File
@@ -8,12 +8,13 @@ import {
OPS_TARGET_MAX_LENGTH,
OpsAuditLogEntity,
type OpsAuditResult,
type OpsCommand,
} from './ops-audit-log.entity';
/** 실행 기록 한 줄 — 비밀(비밀번호 · 코드)은 넣지 않는다 */
export interface OpsAuditEntry {
readonly operator: string;
readonly command: string;
readonly command: OpsCommand;
readonly target: string | null;
readonly result: OpsAuditResult;
readonly detail?: string | null;
+28 -3
View File
@@ -3,10 +3,16 @@ import { Inject, Injectable } from '@nestjs/common';
import { AccountCommands } from './account-commands';
import { AdminCommands } from './admin-commands';
import { LockCommands } from './lock-commands';
import { MigrationCommands } from './migration-commands';
import { MigrationPasswordSession } from './migration-password';
import { OPS_OPERATOR_MAX_LENGTH } from './ops-audit-log.entity';
import { OPS_ENGINE, OpsEngineError, type OpsEngine } from './ops-engine.port';
import { OpsInputClosed, type OpsIo } from './ops-io';
import { RULE, failureLine } from './ops-prompts';
import {
PASSWORD_DEFAULT_EXPIRY_NOTICE,
RULE,
failureLine,
} from './ops-prompts';
/** 운영자 이름에 받지 않는 제어 문자 */
// eslint-disable-next-line no-control-regex
@@ -15,7 +21,7 @@ const OPERATOR_CONTROL = /[\u0000-\u001f]/;
/** 관리자 비밀번호를 다시 묻는 횟수 */
const SIGN_IN_ATTEMPTS = 3;
/** 메뉴 — 번호 · 이름 (계약 6절 "운영 도구"의 명령 열 개와 차례 그대로) */
/** 메뉴 — 번호 · 이름 (계약 6절 "운영 도구"의 명령 열한 개와 차례 그대로) */
export const OPS_MENU = [
['1', '입사'],
['2', '비밀번호 재설정'],
@@ -27,12 +33,14 @@ export const OPS_MENU = [
['8', '공용 주소'],
['9', '현황'],
['10', '정책 · 설정'],
['11', '메일 이전'],
['0', '끝내기'],
] as const;
/**
* 운영 도구의 차례 (13단계 — 계약 6절 "운영 도구") — 운영자 이름 · 관리자 비밀번호(화면에 보이지 않게 · 저장하지 않음)를 묻고 메뉴를 돈다.
* 명령마다 묻고 → 바뀔 것을 보이고 → y/N → 실행 → 실행 기록. 입력이 끝나면(Ctrl+D · Ctrl+C) 조용히 끝낸다
* 명령마다 묻고 → 바뀔 것을 보이고 → y/N → 실행 → 실행 기록. 입력이 끝나면(Ctrl+D · Ctrl+C) 조용히 끝낸다.
* 끝날 때 이번 실행의 이전용 비밀번호(메모리에만 있다)를 지운다
*/
@Injectable()
export class OpsCli {
@@ -41,10 +49,21 @@ export class OpsCli {
private readonly accounts: AccountCommands,
private readonly locks: LockCommands,
private readonly admin: AdminCommands,
private readonly migration: MigrationCommands,
private readonly migrationPassword: MigrationPasswordSession,
) {}
/** 끝나면 종료 코드 — 0 정상, 1 들어가지 못함 */
async run(io: OpsIo): Promise<number> {
try {
return await this.session(io);
} finally {
// 이번 실행의 이전용 비밀번호 — 관리자 비밀번호처럼 이 프로세스의 메모리에만 두었다
this.migrationPassword.clear();
}
}
private async session(io: OpsIo): Promise<number> {
try {
io.print(RULE);
io.print(' ARA SYSTEM 운영 도구');
@@ -73,6 +92,10 @@ export class OpsCli {
const operator = await this.askOperator(io);
if (operator === null) return 1;
if (!(await this.signIn(io))) return 1;
// 엔진의 비밀번호 기본 만료 — 로그인 직후 한 번 읽는다. 켜져 있으면 알리기만 한다(이전 중 판정은 실행 기록으로 가른다 · 읽지 못하면 경고 로그만)
if ((await this.engine.passwordDefaultExpiry()) === 'on') {
io.print(PASSWORD_DEFAULT_EXPIRY_NOTICE);
}
await this.menu(io, operator);
return 0;
} catch (error) {
@@ -182,6 +205,8 @@ export class OpsCli {
return this.admin.status(io);
case '10':
return this.admin.policy(io, operator);
case '11':
return this.migration.menu(io, operator);
default:
io.print('메뉴의 번호를 넣어 주세요.');
}
File diff suppressed because it is too large Load Diff
+105 -1
View File
@@ -21,6 +21,14 @@ import {
SYSTEM_SETTINGS_ROW_ID,
SystemSettingsEntity,
} from '@/modules/system-settings/system-settings.entity';
import {
MIGRATION_PASSWORD_COMMANDS,
OpsAuditLogEntity,
PASSWORD_SET_RESULTS,
PASSWORD_SETTING_COMMANDS,
type OpsAuditResult,
type OpsCommand,
} from './ops-audit-log.entity';
/**
* 그 주소로 남은 우리 DB 의 줄 수 — 표마다 (퇴사 마무리 · 입사 때 겹침 확인).
@@ -58,6 +66,47 @@ function errorMessage(error: unknown): string {
return error instanceof Error ? error.message : String(error);
}
/** 실행 기록 한 줄의 판정에 쓰는 칸 */
interface PasswordRecord {
readonly target: string | null;
readonly command: string;
readonly result: string;
}
/**
* 메일 이전 중 판정에 쓰는 기록인가 — 비밀번호를 거는 명령의 성공(success · partial), 또는 이전용 명령(이전용 입사 · 다시 만들기)의 실패.
* 이전용 명령의 실패는 결과를 모르는 실패일 수 있어(엔진에 걸렸을 수 있다) 센다 — 엔진이 거절했으면 계정이 없거나 옛 만료 그대로라
* 만료 조건(migration-state.ts)이 가른다. 이전용이 아닌 명령의 실패는 뺀다 — 상태를 바꾸지 않았거나, 바꿨다면 만료가 지나 있다
*/
export function countsForMigration(command: string, result: string): boolean {
if (!PASSWORD_SETTING_COMMANDS.includes(command as OpsCommand)) return false;
if (PASSWORD_SET_RESULTS.includes(result as OpsAuditResult)) return true;
return (
result === 'failure' &&
MIGRATION_PASSWORD_COMMANDS.includes(command as OpsCommand)
);
}
/**
* 실행 기록(새것부터) → 판정에 쓰는 마지막 기록(countsForMigration)이 이전용 명령인 주소.
* 주소마다 처음 만난(가장 새) 판정 기록이 정한다 — 그 밖의 기록은 건너뛴다
*/
export function migrationAddressesOf(
rows: Iterable<PasswordRecord>,
): Set<string> {
const decided = new Set<string>();
const migrating = new Set<string>();
for (const row of rows) {
if (row.target === null || decided.has(row.target)) continue;
if (!countsForMigration(row.command, row.result)) continue;
decided.add(row.target);
if (MIGRATION_PASSWORD_COMMANDS.includes(row.command as OpsCommand)) {
migrating.add(row.target);
}
}
return migrating;
}
/** 예외 주소 목록에서 그 주소를 뺀 것 (대소문자 · 앞뒤 공백 무시 — 서버 설정 읽기와 같은 규칙) */
export function exemptWithout(
exempt: unknown,
@@ -73,7 +122,8 @@ export function exemptWithout(
}
/**
* 운영 도구의 DB 일 (13단계 — 계약 6절 "운영 도구") — 세션 끊기 · 2단계 기록 지우기 · 현황의 마지막 웹메일 로그인 · 퇴사 마무리의 주소 정리.
* 운영 도구의 DB 일 (13단계 — 계약 6절 "운영 도구") — 세션 끊기 · 2단계 기록 지우기 · 현황의 마지막 웹메일 로그인 · 퇴사 마무리의 주소 정리 ·
* 메일 이전 마치기의 이전 중 로그인 기록 지우기.
* 우리 DB 는 주소로 직원을 가린다 — 지운 주소를 새 사람이 다시 쓰면 옛 로그인 기록 · 서명 · 설정 · 2단계 기록 · 2단계 필수 예외가 그대로 남으므로
* 퇴사 마무리가 그 주소의 줄을 지운다. 세션 행은 끊기만 한다(정리 cron 이 지운다). 실행 기록(ops_audit_log)은 지우지 않는다.
*/
@@ -127,6 +177,60 @@ export class OpsDataService {
);
}
/**
* 그 주소의 로그인 기록을 지운다 → 지운 줄 수 (메일 이전 마치기 — imapsync 의 IMAP 로그인이 엔진 웹훅으로 쌓여 있다.
* 직원이 첫날 모르는 로그인 수십 줄을 보지 않게). 로그인 기록만이다 — purgeAddress 는 2단계 필수 예외 · 서명 등도 지운다
*/
async deleteLoginEventsOf(email: string): Promise<number> {
const result = await this.dataSource.manager.delete(LoginEventEntity, {
userEmail: email,
});
return result.affected ?? 0;
}
/** 그 주소의 로그인 기록 수 — 이전 마치기의 바뀔 것에 보인다(지우는 범위와 같다 — 방식 상관없이 그 주소 전부) */
countLoginEventsOf(email: string): Promise<number> {
return this.dataSource.manager.countBy(LoginEventEntity, {
userEmail: email,
});
}
/**
* 주소들 가운데 우리 실행 기록에서 판정에 쓰는 마지막 기록(countsForMigration)이 이전용(이전용 입사 · 다시 만들기)인 주소 — 쿼리 하나.
* 메일 이전 중 판정의 한쪽이다(다른 쪽은 엔진의 미래 만료 — migration-state.ts). 엔진의 만료만으로는 가를 수 없다:
* 엔진은 비밀번호 기본 만료가 켜져 있던 동안 직원이 정한 비밀번호에도 미래의 만료를 저장하고, 끈 뒤에도 남긴다(0.16.27)
*/
async lastPasswordSetIsMigration(
emails: readonly string[],
): Promise<Set<string>> {
if (emails.length === 0) return new Set();
const targets = In([...emails]);
const rows = await this.dataSource.manager.find(OpsAuditLogEntity, {
select: {
id: true,
occurredAt: true,
target: true,
command: true,
result: true,
},
// 두 조건의 OR — 비밀번호를 거는 명령의 성공(success · partial) · 이전용 명령의 실패
where: [
{
target: targets,
command: In([...PASSWORD_SETTING_COMMANDS]),
result: In([...PASSWORD_SET_RESULTS]),
},
{
target: targets,
command: In([...MIGRATION_PASSWORD_COMMANDS]),
result: 'failure',
},
],
order: { occurredAt: 'DESC', id: 'DESC' },
});
return migrationAddressesOf(rows);
}
/** 그 주소로 남은 줄 수 */
async countRowsOf(email: string): Promise<AddressRows> {
return this.rowsOf(this.dataSource.manager, email, 'count');
+30 -4
View File
@@ -10,6 +10,9 @@ export const OPS_ENGINE = Symbol('OPS_ENGINE');
/** 관리자 로그인 결과 */
export type OpsSignInResult = 'ok' | 'rejected' | 'two-factor' | 'not-admin';
/** 엔진의 비밀번호 기본 만료(`passwordDefaultExpiry`) — 꺼짐 · 켜짐 · 읽지 못함 */
export type PasswordDefaultExpiry = 'off' | 'on' | 'unknown';
/** 엔진의 직원 계정 하나 (현황 · 명령의 대상) */
export interface EngineEmployee {
/** 엔진의 계정 id — 불투명 */
@@ -22,8 +25,15 @@ export interface EngineEmployee {
readonly locked: boolean;
/** 계정 권한이 직접 정해진(Replace) 계정 — 잠그고 푸는 것을 이 도구가 하지 않는다 */
readonly customPermissions: boolean;
/** 비밀번호가 만료됐다(임시 비밀번호 — 첫 로그인 때 바꾼다) */
/** 비밀번호가 만료됐다(임시 비밀번호 — 첫 로그인 때 바꾼다 · 30일이 지난 이전용 비밀번호) */
readonly passwordExpired: boolean;
/**
* 비밀번호 자격의 만료 시각이 지금보다 뒤면 그 시각(ISO 8601 UTC), 아니면 null(지났으면 passwordExpired).
* 이전용 비밀번호는 그 시각까지 IMAP · SMTP · 웹메일이 그대로고(imapsync) 지나면 엔진이 스스로 만료로 다룬다.
* 이것만으로 "메일 이전 중"을 가르지 않는다 — 엔진은 비밀번호 기본 만료가 켜져 있던 동안 직원이 정한 비밀번호에도 미래의 만료를 저장한다.
* 판정은 운영 도구가 우리 실행 기록과 함께 한다(migration-state.ts)
*/
readonly passwordExpiresAt: string | null;
/** 2단계 인증(OTP)이 켜져 있다 */
readonly twoFactorEnabled: boolean;
/** 앱 비밀번호 수 */
@@ -63,6 +73,11 @@ export interface LockRulesState {
export interface OpsEngine {
/** 관리자 로그인 — 맞으면 이 프로세스가 끝날 때까지 메모리에만 둔다 */
signIn(adminPassword: string): Promise<OpsSignInResult>;
/**
* 엔진의 비밀번호 기본 만료가 켜져 있는가 — 관리자 로그인 직후 한 번 읽고 이 실행 동안 기억한다(던지지 않는다 — 읽지 못하면 'unknown', 경고 로그).
* 아무것도 막지 않는다 — 켜져 있으면 도구가 첫 화면에 알리기만 한다(메일 이전 중 판정은 실행 기록으로 가르므로 영향이 없다)
*/
passwordDefaultExpiry(): Promise<PasswordDefaultExpiry>;
/**
* 직원으로 다룰 수 있는 주소인가 — 사내 도메인이고 관리자 · 게이트웨이 · 사내 목록 · 막은 계정이 아니다 (엔진에 묻지 않는다).
@@ -76,15 +91,26 @@ export interface OpsEngine {
/** 그 주소를 쓰는 계정 · 목록이 엔진에 있는가 (직원이 아닌 것 포함 — 입사 때 겹침 확인) */
addressInUse(email: string): Promise<boolean>;
/** 입사 — 직원 역할 · 임시 비밀번호 · 만료(첫 로그인 때 바꾸기) · 메일함 용량(없으면 무제한) */
/**
* 입사 — 직원 역할 · 처음 비밀번호 · 메일함 용량(없으면 무제한).
* `migrationUntil` 이 null 이면 임시 비밀번호 + 지난 만료(첫 로그인 때 바꾸기),
* 시각이면 이전용 비밀번호 + 그 시각의 만료(그때까지 IMAP 등이 그대로 — imapsync, 지나면 엔진이 막는다)
*/
createEmployee(input: {
readonly localPart: string;
readonly name: string;
readonly quotaBytes: number | null;
readonly tempPassword: string;
readonly password: string;
readonly migrationUntil: Date | null;
}): Promise<EngineEmployee>;
/** 임시 비밀번호 + 만료 — 2단계 인증 · 앱 비밀번호는 그대로 둔다 */
resetPassword(id: string, tempPassword: string): Promise<void>;
/** 이전용 비밀번호 + 만료 시각 `until`(미래 — 그때까지 IMAP 등이 그대로) — 2단계 인증 · 앱 비밀번호는 그대로 둔다 */
setMigrationPassword(
id: string,
password: string,
until: Date,
): Promise<void>;
/** 2단계 인증(OTP)을 지운다 — 비밀번호는 그대로 */
removeTwoFactor(id: string): Promise<void>;
/** 로그인 권한(authenticate) 빼기 · 되돌리기 — 메일은 계속 받는다 */
@@ -122,7 +148,7 @@ export const LOCK_FORWARD_RULE_NAME = '잠금 — 받는 메일 넘기기 (운
/** 엔진이 받지 않은 까닭 — 명령이 한국어로 알린다 */
export type OpsEngineErrorKind =
/** 엔진이 임시 비밀번호를 약하다고 받지 않음 — 새로 만들어 다시 한다 */
/** 엔진이 임시 · 이전용 비밀번호를 약하다고 받지 않음 — 새로 만들어 다시 한다 */
| 'weak-password'
/** 같은 이름(주소)이 이미 있다 */
| 'exists'
+236 -2
View File
@@ -1,7 +1,23 @@
import { Logger } from '@nestjs/common';
import { PassThrough } from 'stream';
import { BusinessException } from '@/common/errors/business.exception';
import { OpsEngineError } from './ops-engine.port';
import {
MigrationPasswordSession,
migrationAuditText,
migrationUntilFrom,
} from './migration-password';
import {
MIGRATION_WINDOW_DAYS,
migrationUntilOf,
withMigrationState,
} from './migration-state';
import {
MIGRATION_PASSWORD_COMMANDS,
PASSWORD_SETTING_COMMANDS,
} from './ops-audit-log.entity';
import { countsForMigration, migrationAddressesOf } from './ops-data.service';
import { OpsEngineError, type EngineEmployee } from './ops-engine.port';
import { OpsInputClosed, TerminalIo, isYes } from './ops-io';
import {
failureDetail,
@@ -10,7 +26,11 @@ import {
toDisplayName,
toQuotaBytes,
} from './ops-text';
import { TEMP_PASSWORD_LENGTH, generateTempPassword } from './temp-password';
import {
TEMP_PASSWORD_LENGTH,
applyFreshPassword,
generateTempPassword,
} from './temp-password';
describe('임시 비밀번호 (13단계)', () => {
it('16자 · 헷갈리는 글자 없음 · 대문자 · 소문자 · 숫자가 하나씩은 있다', () => {
@@ -35,6 +55,220 @@ describe('임시 비밀번호 (13단계)', () => {
});
});
describe('새 비밀번호 걸기 · 이번 실행의 이전용 비밀번호 (메일 이전)', () => {
const weak = () => Promise.reject(new OpsEngineError('weak-password'));
it('applyFreshPassword — 엔진이 약하다고 받지 않으면 새로 만들어 두 번까지 다시, 그 밖의 실패는 다시 하지 않는다', async () => {
const tried: string[] = [];
let refusals = 2;
const password = await applyFreshPassword((candidate) => {
tried.push(candidate);
return refusals-- > 0 ? weak() : Promise.resolve();
});
expect(tried).toHaveLength(3);
expect(new Set(tried).size).toBe(3);
expect(password).toBe(tried[2]);
await expect(applyFreshPassword(weak)).rejects.toMatchObject({
kind: 'weak-password',
});
const unavailable = jest
.fn()
.mockRejectedValue(new OpsEngineError('unavailable', 'timeout'));
await expect(applyFreshPassword(unavailable)).rejects.toMatchObject({
kind: 'unavailable',
});
expect(unavailable).toHaveBeenCalledTimes(1);
});
it('이번 실행의 값 — 처음엔 만들고(보인다) 다음엔 같은 값(보이지 않는다), 지우면(새 실행) 새 값', async () => {
const session = new MigrationPasswordSession();
const applied: string[] = [];
const apply = (password: string) => {
applied.push(password);
return Promise.resolve();
};
expect(session.exists).toBe(false);
const first = await session.apply(apply);
expect(first).toEqual({ kind: 'created', password: applied[0] });
expect(session.exists).toBe(true);
await expect(session.apply(apply)).resolves.toEqual({ kind: 'reused' });
expect(applied[1]).toBe(applied[0]);
session.clear();
expect(session.exists).toBe(false);
const again = await session.apply(apply);
expect(again.kind).toBe('created');
expect(applied[2]).not.toBe(applied[0]);
});
it('엔진이 이번 실행의 값을 약하다고 받지 않으면 새로 만들어 바꾼다(replaced) · 다른 실패면 값은 그대로', async () => {
const session = new MigrationPasswordSession();
const applied: string[] = [];
await session.apply((password) => {
applied.push(password);
return Promise.resolve();
});
let refuseOld = true;
const replaced = await session.apply((password) => {
applied.push(password);
if (refuseOld) {
refuseOld = false;
return weak();
}
return Promise.resolve();
});
expect(applied[1]).toBe(applied[0]);
expect(replaced).toEqual({ kind: 'replaced', password: applied[2] });
expect(applied[2]).not.toBe(applied[0]);
// 다른 실패 — 던지고 이번 실행의 값은 바뀌지 않는다
await expect(
session.apply(() =>
Promise.reject(new OpsEngineError('unavailable', 'timeout')),
),
).rejects.toMatchObject({ kind: 'unavailable' });
const reused: string[] = [];
await expect(
session.apply((password) => {
reused.push(password);
return Promise.resolve();
}),
).resolves.toEqual({ kind: 'reused' });
expect(reused).toEqual([applied[2]]);
});
it('이전용 비밀번호의 끝은 지금부터 30일 · 실행 기록 글은 끝나는 한국 날짜만(비밀 없음)', () => {
// 한국 2026-11-03 01:30 — 날짜가 UTC 와 다른 때
const until = migrationUntilFrom(new Date('2026-11-02T16:30:00Z'));
expect(until.toISOString()).toBe('2026-12-02T16:30:00.000Z');
expect(migrationAuditText(until, { kind: 'reused' })).toBe(
'이전용(이번 실행 공통 · ~2026-12-03)',
);
expect(
migrationAuditText(until, { kind: 'replaced', password: 'Secret12' }),
).toBe('이전용(이번 실행 공통 · 값 바꿈 · ~2026-12-03)');
});
});
describe('메일 이전 중 판정 (재리뷰 N1)', () => {
const NOW = new Date('2026-11-02T06:00:00.000Z');
const DAY_MS = 24 * 60 * 60 * 1000;
const HONG = 'hong@arasystem.test';
const KIM = 'kim@arasystem.test';
const LEE = 'lee@arasystem.test';
const at = (days: number) =>
new Date(NOW.getTime() + days * DAY_MS).toISOString();
const account = (passwordExpiresAt: string | null): EngineEmployee => ({
id: 'acc-hong',
email: HONG,
name: '홍길동',
locked: false,
customPermissions: false,
passwordExpired: false,
passwordExpiresAt,
twoFactorEnabled: false,
appPasswords: 0,
usedBytes: null,
quotaBytes: null,
});
it('비밀번호를 거는 명령은 모두 판정에 든다 — 이전용은 이전용 입사 · 다시 만들기 둘', () => {
expect([...PASSWORD_SETTING_COMMANDS].sort()).toEqual(
[
'hire',
'hire-migration',
'migration-finish',
'migration-password',
'password-reset',
].sort(),
);
expect([...MIGRATION_PASSWORD_COMMANDS].sort()).toEqual(
['hire-migration', 'migration-password'].sort(),
);
expect(MIGRATION_WINDOW_DAYS).toBe(31);
});
it('판정에 쓰는 기록 — 비밀번호를 거는 명령의 성공(success · partial), 이전용 명령의 실패. 이전용이 아닌 명령의 실패 · 다른 명령은 아니다', () => {
for (const command of PASSWORD_SETTING_COMMANDS) {
expect(countsForMigration(command, 'success')).toBe(true);
expect(countsForMigration(command, 'partial')).toBe(true);
}
expect(countsForMigration('hire-migration', 'failure')).toBe(true);
expect(countsForMigration('migration-password', 'failure')).toBe(true);
expect(countsForMigration('hire', 'failure')).toBe(false);
expect(countsForMigration('password-reset', 'failure')).toBe(false);
expect(countsForMigration('migration-finish', 'failure')).toBe(false);
expect(countsForMigration('lock', 'success')).toBe(false);
});
it('실행 기록(새것부터) — 주소마다 판정에 쓰는 마지막 기록이 이전용 명령인 주소만', () => {
const rows = [
// 새것부터 — HONG: 재설정 실패 · 잠금은 건너뛰고 partial 다시 만들기가 마지막 → 이전 중
{ target: HONG, command: 'password-reset', result: 'failure' },
{ target: HONG, command: 'lock', result: 'success' },
{ target: HONG, command: 'migration-password', result: 'partial' },
{ target: HONG, command: 'migration-finish', result: 'success' },
// KIM: 마치기 실패는 건너뛰고 재설정 성공이 마지막 → 아니다(그 앞의 이전용 입사는 보지 않는다)
{ target: KIM, command: 'migration-finish', result: 'failure' },
{ target: KIM, command: 'password-reset', result: 'success' },
{ target: KIM, command: 'hire-migration', result: 'success' },
// LEE: 결과를 모르는 이전용 입사(실패)가 마지막 → 이전 중으로 센다(엔진에 걸렸을 수 있다 — 만료 조건이 가른다)
{ target: LEE, command: 'hire-migration', result: 'failure' },
{ target: LEE, command: 'hire', result: 'success' },
{ target: null, command: 'support-contact', result: 'success' },
];
expect(migrationAddressesOf(rows)).toEqual(new Set([HONG, LEE]));
expect(
migrationAddressesOf([
{ target: KIM, command: 'hire', result: 'success' },
]),
).toEqual(new Set());
});
it('이전 중 = 기록이 이전용 + 엔진의 만료가 미래(지금 + 31일 안)', () => {
expect(migrationUntilOf(account(at(30)), true, NOW)).toBe(at(30));
expect(migrationUntilOf(account(at(31)), true, NOW)).toBe(at(31));
// 기록이 이전용이 아니면 · 31일보다 멀면 · 지났거나 없으면 아니다
expect(migrationUntilOf(account(at(30)), false, NOW)).toBeNull();
expect(migrationUntilOf(account(at(31.01)), true, NOW)).toBeNull();
expect(migrationUntilOf(account(at(-1)), true, NOW)).toBeNull();
expect(migrationUntilOf(account(null), true, NOW)).toBeNull();
});
it('여러 계정에 쿼리 하나로 판정을 붙이고, 실행 기록을 읽지 못하면 null', async () => {
const lookup = jest.fn().mockResolvedValue(new Set([HONG]));
const judged = await withMigrationState(
{ lastPasswordSetIsMigration: lookup },
[account(at(10)), { ...account(at(10)), id: 'acc-kim', email: KIM }],
NOW,
);
expect(lookup).toHaveBeenCalledTimes(1);
expect(lookup).toHaveBeenCalledWith([HONG, KIM]);
expect(judged?.map((employee) => employee.migrationUntil)).toEqual([
at(10),
null,
]);
const error = jest
.spyOn(Logger.prototype, 'error')
.mockImplementation(() => undefined);
await expect(
withMigrationState(
{
lastPasswordSetIsMigration: jest
.fn()
.mockRejectedValue(new Error('connection lost')),
},
[account(at(10))],
NOW,
),
).resolves.toBeNull();
expect(error).toHaveBeenCalled();
error.mockRestore();
});
});
describe('운영 도구의 값 읽기 · 글', () => {
it('주소 — @도메인 을 붙이고 소문자로, 다른 도메인 · 모양이 틀린 것은 null', () => {
expect(toAddress('Hong.Gildong', 'arasystem.test')).toBe(
+85 -3
View File
@@ -7,7 +7,13 @@ import {
type OpsEngine,
} from './ops-engine.port';
import type { OpsIo } from './ops-io';
import { failureDetail, failureText, toAddress } from './ops-text';
import {
failureDetail,
failureText,
timeText,
toAddress,
type PasswordName,
} from './ops-text';
/** 운영 도구의 공통 묻기 · 보이기 — 명령들이 함께 쓴다 */
@@ -28,9 +34,12 @@ export function logUnknownFailure(error: unknown): void {
}
/** 실패 → 운영자에게 보일 글 — 모르는 오류는 오류 로그에도 남긴다 (명령은 failureText 대신 이것을 쓴다) */
export function failureLine(error: unknown): string {
export function failureLine(
error: unknown,
passwordName?: PasswordName,
): string {
logUnknownFailure(error);
return failureText(error);
return failureText(error, passwordName);
}
/** 구분선 */
@@ -70,6 +79,15 @@ export async function askEmployee(
): Promise<EngineEmployee | null> {
const email = await askAddress(io, domain, label);
if (email === null) return null;
return lookUpEmployee(io, engine, email);
}
/** 정식 주소로 직원 계정을 찾는다 — 직원 주소가 아님 · 직원 계정이 아님 · 엔진에 없음을 갈라 알리고 null (askEmployee 의 뒤쪽) */
export async function lookUpEmployee(
io: OpsIo,
engine: OpsEngine,
email: string,
): Promise<EngineEmployee | null> {
if (!engine.isEmployeeAddress(email)) {
io.print(NOT_EMPLOYEE_ADDRESS);
return null;
@@ -122,6 +140,70 @@ export function showTempPassword(
io.print(RULE);
}
/**
* 이번 실행의 이전용 비밀번호를 한 번만 보인다 — imapsync 에만 쓴다(직원에게 주지 않는다). 어디에도 남기지 않는다.
* `replaced` 면 엔진이 앞 값을 받지 않아 새로 만든 것이다 — 앞에서 건 계정들은 옛 값 그대로라고 알린다
*/
export function showMigrationPassword(
io: OpsIo,
email: string,
password: string,
until: Date,
replaced = false,
): void {
io.print('');
io.print(RULE);
io.print(
' 이전용 비밀번호 — 지금 한 번만 보입니다. imapsync 의 비밀번호 파일(--passfile2)에만 넣고 직원에게 주지 마세요.',
);
io.print(` 주소 ${email}`);
io.print(` 이전용 비밀번호 ${password}`);
io.print(` 쓸 수 있는 때 ${timeText(until)} 까지 (한국 시각)`);
if (replaced) {
io.print(
' 엔진이 이번 실행의 앞 값을 받지 않아 새로 만들었습니다 — 앞에서 이전용 비밀번호를 건 계정은 옛 값 그대로입니다. 같은 실행에서 「다시 만들기」(빈 값)를 한 번 하면 모두 새 값으로 맞춰집니다.',
);
}
io.print(
' 이번 실행에서 옮겨 올 메일이 있는 계정을 더 만들거나 다시 만들면 모두 이 비밀번호를 씁니다 — imapsync 비밀번호 파일 하나로 모두 옮길 수 있습니다.',
);
io.print(
' 마지막 imapsync 뒤 「메일 이전 › 이전 마치기」로 직원용 임시 비밀번호를 만드세요.',
);
io.print(RULE);
}
/** 이번 실행의 이전용 비밀번호를 다시 걸었을 때 — 값은 다시 보이지 않는다 (엔진이 앞 값을 받지 않아 바꿨으면 바꾼 값) */
export const MIGRATION_PASSWORD_REUSED =
'이번 실행의 이전용 비밀번호(이번 실행에서 마지막으로 보인 값)를 걸었습니다.';
/** 같은 실행 안에서는 이전용 비밀번호를 다시 보이지 않는다 — 잃었으면 새 실행에서 새 값으로 */
export const MIGRATION_PASSWORD_LOST =
'잃었으면 이 도구를 끝내고 다시 시작한 뒤 「다시 만들기」(빈 값 = 모두)';
/**
* 메일 이전 중인 계정의 안내 — 비밀번호 재설정(잠금 풀기가 권하는 것 포함)은 하지 않는다:
* 이전용 비밀번호가 막혀 imapsync 가 끊기고, 이전 중 로그인 기록이 직원에게 남는다
*/
export function migratingNotice(until: string): string {
return `메일 이전 중인 계정입니다(이전용 비밀번호 ~${timeText(new Date(until))}). 직원에게 비밀번호를 줄 때는 「메일 이전 › 이전 마치기」, 이전용 비밀번호를 잃었으면 이 도구를 끝내고 다시 시작한 뒤 「메일 이전 › 이전용 비밀번호 다시 만들기」(빈 값 = 모두)를 하세요.`;
}
/**
* 실행 기록을 읽지 못해 이전 중인지 가르지 못했고, 엔진의 비밀번호 만료가 미래다 — 이전 중일 수 있어 비밀번호 재설정을 하지 않는다(보수적으로)
*/
export function unverifiedMigrationNotice(expiresAt: string): string {
return `메일 이전 중인지 확인하지 못했습니다 — 실행 기록을 읽지 못했고 비밀번호 만료가 미래(~${timeText(new Date(expiresAt))})라 이전 중일 수 있어 하지 않습니다. 잠시 뒤 다시 하세요(오류 로그 참고).`;
}
/** 실행 기록을 읽지 못해 이전 중인 계정을 가를 수 없다 — 「메일 이전」은 멈춘다 */
export const MIGRATION_RECORDS_UNREADABLE =
'실행 기록을 읽지 못해 메일 이전 중인 계정을 가를 수 없습니다 — 잠시 뒤 다시 하세요(오류 로그 참고).';
/** 엔진의 비밀번호 기본 만료가 켜져 있을 때 첫 화면의 알림 — 막지는 않는다(이전 중 판정은 실행 기록으로 가른다) */
export const PASSWORD_DEFAULT_EXPIRY_NOTICE =
'주의: 엔진에 비밀번호 기본 만료가 켜져 있습니다 — 직원 비밀번호가 주기적으로 만료됩니다. 운영 도구의 이전 중 판정은 실행 기록으로 가르므로 영향이 없습니다.';
/** 한 단계의 결과 줄 */
export function step(io: OpsIo, ok: boolean, text: string): void {
io.print(` ${ok ? '[완료]' : '[못 함]'} ${text}`);
+22 -3
View File
@@ -57,6 +57,16 @@ export function bytesText(bytes: number | null): string {
return bytes === null ? '—' : formatFileSize(bytes);
}
/** 메일함 사용량 — 한도가 있으면 "사용 / 한도" */
export function usageText(
usedBytes: number | null,
quotaBytes: number | null,
): string {
return quotaBytes === null
? bytesText(usedBytes)
: `${bytesText(usedBytes)} / ${bytesText(quotaBytes)}`;
}
/** 한국 시각 — 없으면 "—" */
export function timeText(at: Date | null | undefined): string {
return at ? formatKstDateTime(at) : '—';
@@ -81,15 +91,24 @@ export function isUnknownOutcome(error: unknown): boolean {
return UNKNOWN_OUTCOME_DETAILS.has(detail) || /^http-5\d\d$/.test(detail);
}
/** 실패 → 운영자에게 보일 글 (엔진의 설명 문장 · 비밀은 넣지 않는다 — 종류 · 코드만) */
export function failureText(error: unknown): string {
/** 엔진에 거는 새 비밀번호의 이름 — 약하다는 거절 글에 쓴다 */
export type PasswordName = '임시 비밀번호' | '이전용 비밀번호';
/**
* 실패 → 운영자에게 보일 글 (엔진의 설명 문장 · 비밀은 넣지 않는다 — 종류 · 코드만).
* `passwordName` 은 그 명령이 거는 비밀번호 — 약하다는 거절 글에만 쓴다(메일 이전은 '이전용 비밀번호')
*/
export function failureText(
error: unknown,
passwordName: PasswordName = '임시 비밀번호',
): string {
if (isUnknownOutcome(error)) {
return `메일 엔진의 답을 받지 못해 결과를 모릅니다 (${(error as OpsEngineError).detail ?? '까닭 모름'}) — 같은 명령을 다시 하세요.`;
}
if (error instanceof OpsEngineError) {
switch (error.kind) {
case 'weak-password':
return '엔진이 임시 비밀번호를 받지 않았습니다(강도). 다시 해 주세요.';
return `엔진이 ${passwordName}를 받지 않았습니다(강도). 다시 해 주세요.`;
case 'exists':
return '같은 이름(주소)이 엔진에 이미 있습니다.';
case 'not-found':
+5
View File
@@ -40,6 +40,8 @@ import { validateEnv } from '@/shared/config/env.validation';
import { AccountCommands } from './account-commands';
import { AdminCommands } from './admin-commands';
import { LockCommands } from './lock-commands';
import { MigrationCommands } from './migration-commands';
import { MigrationPasswordSession } from './migration-password';
import { OpsAuditLogEntity } from './ops-audit-log.entity';
import { OpsAuditService } from './ops-audit.service';
import { OpsCli } from './ops-cli';
@@ -132,9 +134,12 @@ const OPS_DB_POOL = {
},
OpsDataService,
OpsAuditService,
// 이번 실행의 이전용 비밀번호 — 이 프로세스의 메모리에만(한 실행 = 한 프로세스), 끝낼 때 OpsCli 가 지운다
MigrationPasswordSession,
AccountCommands,
LockCommands,
AdminCommands,
MigrationCommands,
OpsCli,
],
})
+32 -1
View File
@@ -1,5 +1,7 @@
import { randomInt } from 'crypto';
import { OpsEngineError } from './ops-engine.port';
/**
* 임시 비밀번호의 글자 — 헷갈리는 글자(0 · O · o · 1 · l · I)를 뺀 영문 대 · 소문자와 숫자.
* 직원이 화면에서 옮겨 적는다. 영문 · 숫자만이라 셸 · 메신저에서 깨지지 않는다
@@ -9,9 +11,13 @@ const ALPHABET = 'ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnpqrstuvwxyz23456789';
/** 길이 — 16자(약 93비트). 엔진의 강도 검사(passwordMinStrength)를 넉넉히 넘는다 (2026-11-02 개발 엔진에서 확인) */
export const TEMP_PASSWORD_LENGTH = 16;
/** 엔진이 새 비밀번호를 약하다고 받지 않을 때 새로 만들어 다시 하는 횟수 */
const WEAK_PASSWORD_RETRIES = 2;
/**
* 임시 비밀번호 (13단계 — 계약 6절 "운영 도구": 무작위 · 한 번만 보여 준다) — crypto 의 고른 난수.
* 대문자 · 소문자 · 숫자가 하나씩은 들어간다(옮겨 적을 때 섞인 것을 알아보기 쉽게). 저장하지 않고 로그에 남기지 않는다
* 대문자 · 소문자 · 숫자가 하나씩은 들어간다(옮겨 적을 때 섞인 것을 알아보기 쉽게). 저장하지 않고 로그에 남기지 않는다.
* 메일 이전의 이전용 비밀번호도 이것으로 만든다
*/
export function generateTempPassword(
pick: (max: number) => number = randomInt,
@@ -30,3 +36,28 @@ export function generateTempPassword(
}
}
}
/** 엔진이 비밀번호를 약하다고 받지 않았는가 */
export function isWeakPasswordRefusal(error: unknown): boolean {
return error instanceof OpsEngineError && error.kind === 'weak-password';
}
/**
* 새 무작위 비밀번호로 `apply`(엔진에 걸기)를 한다 → 건 비밀번호.
* 엔진이 약하다고 받지 않으면 새로 만들어 `WEAK_PASSWORD_RETRIES` 번까지 다시 한다 — 그 밖의 실패는 그대로 던진다
*/
export async function applyFreshPassword(
apply: (password: string) => Promise<unknown>,
): Promise<string> {
for (let attempt = 0; ; attempt += 1) {
const password = generateTempPassword();
try {
await apply(password);
return password;
} catch (error) {
if (!isWeakPasswordRefusal(error) || attempt >= WEAK_PASSWORD_RETRIES) {
throw error;
}
}
}
}
+36 -4
View File
@@ -2405,6 +2405,11 @@ interface FileUploadCreate {
그 밖의 메일 API 가 만료를 만나면(운영 도구가 재설정한 직후 등) 401 `AUTH_001` 이다 — 화면이 바로 갱신해 그 단계로(세션이 끊겼으면 로그인 화면으로) 간다.
- 만료된 동안은 대리 접근으로 메일을 넣거나 앱 비밀번호를 지우는 일도 막힌다(개발 엔진 확인 — 세션에 메일 계정이 없어 `Mailbox/get` · `Email/import` · `SieveScript` 를 부를 수 없고 `x:AppPassword` 는 `forbidden`,
되는 것은 blob 올리기 · `x:AccountPassword/get·set` · `/api/account`) — 그래서 운영 도구는 그 일을 만료를 걸기 전에 한다(아래 "운영 도구").
- 만료된 비밀번호로는 IMAP 로그인이 응답 없이 끊기고 SMTP 인증은 550 이다 — 메일 옮기기(imapsync)가 새 엔진에 들어가지 못한다.
- 만료 시각을 **미래**로 두면 그때까지 IMAP · SMTP · JMAP 이 모두 정상이고, 그 시각이 지나면 엔진이 스스로 만료로 다룬다. `null` 은 만료 없음이다(2026-11-02 개발 엔진 확인 — 20초 뒤로 걸고 지나기 전 · 뒤를 보았다).
운영 도구의 "이전 중"은 이것과 실행 기록으로 가른다(아래 "메일 이전").
- 엔진의 비밀번호 기본 만료(`x:Authentication` 의 `passwordDefaultExpiry`)가 켜져 있으면 직원이 비밀번호를 바꿀 때 엔진이 "지금 + 기본 만료"를 **저장**하고, 끈 뒤에도 그 만료가 지날 때까지 남긴다(0.16.27 소스 `registry/mapping/account.rs`).
그래서 미래 만료만으로는 이전 중을 가를 수 없다. 기본 만료는 켜지 않는다(켜면 직원 비밀번호가 주기적으로 만료된다).
- 엔진에서 비밀번호를 바꾸거나 막아도 웹메일 세션은 끊기지 않는다. 갱신이 게이트웨이로 계정이 있는지만 보고, 엔진에는 바뀐 것을 알리는 칸이 없다.
- 그래서 운영 도구가 세션을 직접 끊는다.
- 엔진에는 2단계 인증을 강제하는 설정이 없다 — 서버 정책으로 둔다.
@@ -2507,16 +2512,17 @@ interface FileUploadCreate {
| 명령 | 하는 일 |
| --- | --- |
| 입사 | 주소 · 이름 · 메일함 용량 → 엔진 계정(직원 역할) + 임시 비밀번호(무작위 — 한 번만 보여 줌) + 만료(첫 로그인 때 바꾸기) |
| 비밀번호 재설정 | 임시 비밀번호 + 만료, 웹메일 세션 모두 끊기, 앱 비밀번호 지우기, 받은편지함 알림. 2단계는 그대로다(따로 해제) |
| 입사 | 주소 · 이름 · 메일함 용량 → 엔진 계정(직원 역할) + 임시 비밀번호(무작위 — 한 번만 보여 줌) + 만료(첫 로그인 때 바꾸기). "mailcow 에서 옮겨 올 메일이 있습니까?"에 y 면 임시 비밀번호 대신 **이전용 비밀번호**(만료 30일 뒤 — 아래 "메일 이전") |
| 비밀번호 재설정 | 임시 비밀번호 + 만료, 웹메일 세션 모두 끊기, 앱 비밀번호 지우기, 받은편지함 알림. 2단계는 그대로다(따로 해제). 이전 중인 계정은 하지 않고 「메일 이전」으로 안내한다 |
| 2단계 해제 | 휴대폰 분실 — 엔진 OTP 지우기 + `enabledAt` 정리 + 받은편지함 알림 |
| 잠금 | 퇴사 · 털림 — 엔진 `authenticate` 빼기, 웹메일 세션 모두 끊기. 묻고: 그 직원의 공개 링크 끊기, 받는 메일 넘기기(Sieve `redirect :copy`), 자동 응답. 메일은 계속 받는다 |
| 잠금 풀기 | `authenticate` 되돌리기. 비밀번호 재설정을 함께 권한다 |
| 잠금 풀기 | `authenticate` 되돌리기. 비밀번호 재설정을 함께 권한다(이전 중인 계정이면 권하지 않는다) |
| 퇴사 마무리 | 잠긴 계정만. 엔진 계정 지우기(메일 영구 삭제 — 주소를 다시 쳐서 확인) + 우리 DB 의 그 주소 행 정리 — 같은 주소를 다시 쓸 때 섞이지 않게 |
| 링크 끊기 | 신고된 공개 링크 주소로 → `revoked_by = operator` |
| 공용 주소 | MailingList 만들기 · 구성원 넣기 / 빼기 · 목록(구성원 받은편지함으로 복사) |
| 현황 | 직원마다 잠금 · 2단계 · 앱 비밀번호 수 · 마지막 웹메일 로그인 · 메일함 사용량 |
| 현황 | 직원마다 잠금 · 2단계 · 앱 비밀번호 수 · 마지막 웹메일 로그인 · 메일함 사용량 · 이전 중(끝나는 날) |
| 정책 · 설정 | 2단계 필수(켜기 · 시작일 · 예외), 문의 연락처 |
| 메일 이전 (2026-11-02 더함) | 이전 현황(이전 중인 계정 · 끝나는 때 · 메일함 사용량) · 이전용 비밀번호 다시 만들기(한 사람 또는 이전 중인 계정 모두 — 잃었거나 30일을 늘릴 때) · 이전 마치기(한 사람 또는 이전 중인 계정 모두 → 직원용 임시 비밀번호) |
- 차례가 있는 명령
- 비밀번호 재설정: 앱 비밀번호 지우기(대리 접근) → 웹메일 세션 끊기(DB) → 알림 메일 넣기(대리 접근) → 엔진에 임시 비밀번호 + 만료 → 세션 한 번 더 끊기.
@@ -2534,6 +2540,32 @@ interface FileUploadCreate {
- 잠금 풀기는 `authenticate` 빼기만 되돌리고 남은 것이 없으면 상속(Inherit)으로 돌린다 — 직접 정한 권한(Replace)인 계정은 바꾸지 않는다.
- 엔진의 답을 받지 못해 결과를 모르는 실패(시간 초과 · 네트워크 · 5xx)는 "결과를 모릅니다 — 같은 명령을 다시 하세요"로 알린다 — 재설정은 넣은 알림을 지우지 않고, 퇴사 마무리는 DB 를 건드리지 않는다(다시 하면 남은 것만).
퇴사 마무리에서 그 직원이 공용 주소의 마지막 구성원이면 뺄지 먼저 묻는다(빼면 받는 사람이 없다). 2단계 필수 예외 주소 넣기 · 빼기는 y/N 뒤 퇴사 마무리와 같은 행 잠금 안에서 읽고 쓴다.
- **메일 이전**(2026-11-02 더함 — 전 직원 전환의 imapsync, 차례 · 명령은 mail/README "imapsync"):
- 왜: imapsync 는 새 엔진에 IMAP 으로 들어가야 하는데 임시 비밀번호는 만료라 끊긴다(위 "엔진 사실"). 직원이 첫 로그인에서 비밀번호를 바꾸면 우리는 모르고 앱 비밀번호도 지워져 마지막 차이분을 옮길 길이 없다.
그래서 비밀번호를 나눠 주기 전에 옮기기를 모두 끝낸다 — 계정 만들기(이전용) → imapsync 1차 → MX 바꾸기 → 마지막 imapsync → 이전 마치기 → 임시 비밀번호 나눠 주기.
- **이전 중** = 엔진 비밀번호 만료가 미래(지금 + 31일 안)이고, 실행 기록(`ops_audit_log`)에서 그 주소의 판정 기록 가운데 마지막이 이전용(`hire-migration` · `migration-password`)인 계정이다.
판정 기록 = 비밀번호를 거는 명령(`hire` · `hire-migration` · `password-reset` · `migration-password` · `migration-finish`)의 success · partial, 그리고 **이전용 명령의 failure** —
결과를 모르는 실패는 엔진에 걸렸을 수 있어서다(걸렸으면 「다시 만들기」(그 주소)로 바로 되살린다. 실제로 거절됐으면 계정이 없거나 옛 만료 그대로라 만료 조건이 가른다). 이전용이 아닌 명령의 failure 는 건너뛴다.
판정 함수는 하나다(`modules/ops/migration-state.ts`) — 현황 · 재설정 가드 · 잠금 풀기 · 「메일 이전」이 모두 쓴다.
새 표는 두지 않는다 — 엔진의 만료만 보면 기본 만료의 찌꺼기(위 "엔진 사실")를 이전 중으로 읽어 직원의 비밀번호 · 앱 비밀번호를 지울 수 있다(2026-11-02 재리뷰).
실행 기록을 읽지 못하면 「메일 이전」은 멈추고, 재설정 · 잠금 풀기는 엔진 만료만 보고 보수적으로 막는다(잘못 덮지 않게).
이전용 비밀번호는 만든 때부터 30일이고, 잊어도 그때 엔진이 막는다(그 뒤는 첫 로그인 때 바꾸기와 같은 상태).
- 이전용 비밀번호는 imapsync 에만 쓰고 직원에게 주지 않는다. 한 번만 보이고 실행 기록 · 로그에 없다(임시 비밀번호와 같다).
**운영 도구 한 번 실행 안에서는 같은 값**이다 — 처음 만들 때 한 번 보이고, 그 실행의 다음 입사 · 다시 만들기는 그 값을 건다(다시 보이지 않는다). imapsync 의 대상 비밀번호 파일 하나로 모든 계정을 옮기게 하려는 것이다.
값은 그 프로세스 메모리에만 있다(관리자 비밀번호처럼). 만료는 바뀔 것을 보일 때 정한 시각부터 30일이다(모두면 모두 같은 시각).
같은 실행 안에서 값을 잃었으면 도구를 끝내고 다시 시작한 뒤 「다시 만들기」(빈 값 = 모두)를 한다 — 같은 실행의 다시 만들기는 그 실행의 값을 다시 걸 뿐 새 값을 보이지 않는다.
- 도구는 관리자로 들어온 직후 엔진의 비밀번호 기본 만료를 한 번 읽어, 켜져 있으면 첫 화면에 알리기만 한다(이전 중 판정은 실행 기록으로 가르므로 막지 않는다). 읽지 못하면 경고 로그만 남긴다.
- 다시 만들기: 한 사람, 또는 주소를 비우면 이전 중인 계정 모두(잃어버렸거나 30일이 다가올 때 — 그 실행의 값으로 맞춘다).
비밀번호를 바꾸기 전에 그 주소의 웹메일 세션을 끊는다 — 임시 비밀번호(만료)로 들어와 새 비밀번호 단계에 묶인 세션이, 만료가 미래로 바뀌면 다음 갱신에서 제한이 풀리기 때문이다(끊지 못하면 바꾸지 않는다).
한 사람일 때는 이전 중이거나 비밀번호가 만료된 계정(30일이 지난 이전 계정 · 아직 바꾸지 않은 임시 비밀번호 — 엔진에서는 가를 수 없다 — 바뀔 것에 "지금 비밀번호는 더 쓰지 못합니다")만 받는다. 만료된 계정은 "모두"에 넣지 않는다.
직원이 이미 비밀번호를 정한 계정(만료 없음)은 막는다 — 걸면 직원이 로그인하지 못한다. 잠긴 계정 · 2단계 인증을 켠 계정(비밀번호로는 IMAP 에 들어가지 못한다)도 막는다.
- 이전 마치기의 차례(계정마다): 앱 비밀번호가 있으면 지우기 → 웹메일 세션 끊기 → **이전 중 로그인 기록 지우기**(우리 DB `login_events` — imapsync 의 IMAP 로그인이 엔진 웹훅으로 쌓인다. 직원이 첫날 모르는 IMAP 로그인 수십 줄을 보지 않게)
→ 엔진에 직원용 임시 비밀번호 + 만료 → 세션 한 번 더 끊기. **받은편지함 알림 메일은 넣지 않는다**(재설정 알림은 "관리자가 비밀번호를 바꿈"이라 새 직원이 놀란다).
한 계정이 실패해도 나머지를 이어 하고, 끝에 주소 · 임시 비밀번호 표를 한 번 보인다(앞에서 만든 것은 실패가 나도 반드시 보인다). 로그인 기록을 지우지 못한 계정은 비밀번호를 바꾸지 않고 멈춘다(넘긴 뒤에는 지울 명령이 없다).
잠긴 계정은 마치지 않는다. y/N 뒤 계정마다 다시 읽어 그사이 비밀번호 처지가 바뀌었으면(다른 운영자가 먼저 마침 등) 하지 않는다. 세션 끊는 사유는 운영 도구의 `ended-by-operator` 그대로다.
실행 기록은 계정마다 — command `migration-finish`(이전 마치기) · `migration-password`(다시 만들기), 이전용 입사는 `hire-migration`(임시 입사는 `hire` 그대로), detail 에 `이전용(이번 실행 공통 · ~날짜)`.
마지막 imapsync 가 끝나고 5분쯤 뒤에 마친다 — 엔진 웹훅이 실패한 묶음을 5분까지 다시 보내므로, 바로 마치면 늦게 온 IMAP 로그인 몇 줄이 직원의 로그인 기록에 남을 수 있다.
끝의 표를 잃었으면(터미널이 끊김 등) 실행 기록에서 `migration-finish` 가 success 인 주소를 찾아 「비밀번호 재설정」으로 새 임시 비밀번호를 만든다(이미 마친 계정은 이전 중이 아니라 재설정이 받는다).
- 관리자 계정에서 대리 접근 권한(`impersonate`)을 빼므로(인프라 — mail/README) 도구가 직원의 메일함 · Sieve · 앱 비밀번호를 다룰 때는 웹메일과 같은 게이트웨이 대리 접근을 쓴다. 관리자 Basic 은 계정 관리 API(`x:Account` · `x:MailingList`)에만 쓴다.
- **도구로 끊은 세션도 15초 안에 막힌다.** 세션 끊기는 DB 로 하고, 서버는 끊은 세션 기억을 15초마다 DB 에서 채운다(5절의 기억에 더한다).
- 잠금은 도구로 한다. 엔진 관리 화면에서 `authenticate` 만 빼면 웹메일 세션이 남는다 — 갱신이 503 `MAIL_001` 을 내며 세션을 그대로 둔다(엔진의 대리 접근 403 을 설정 문제로 본다).
+184 -25
View File
@@ -387,7 +387,8 @@ openssl s_client -quiet -connect mx1.arasystem.it.kr:993
# 메일 호스트에서
sudo docker logs --since 5m "${PROJECT_NAME}-mail" 2>&1 | grep 'auth.success' | grep 'imaps' | tail -3
```
- 실제 직원 계정은 관리 화면이나 관리 API(`x:Account/set`)로 만든다. 관리 화면은 공개 443 에서 막혀 있으므로 SSH 터널로 mail 컨테이너의 **앱 네트워크 주소**:8080 에 붙는다
- 실제 직원 계정은 운영 도구 「입사」로 만든다(13단계 — 계약 6절 "운영 도구". mailcow 에서 메일을 옮겨 올 사람은 [imapsync](#imapsync) 의 이전용 비밀번호로).
관리 화면이나 관리 API(`x:Account/set`)로도 만들 수 있다. 관리 화면은 공개 443 에서 막혀 있으므로 SSH 터널로 mail 컨테이너의 **앱 네트워크 주소**:8080 에 붙는다
(13단계부터 관리자는 앱 네트워크 안에서만 로그인된다 — 명령은 [관리 화면 — SSH 터널](#관리-화면--ssh-터널)). setup 은 운영에서 직원 계정을 만들지 않는다.
⚠️ **공용 주소(Group 계정)는 일반 관리자로 만들 수 없다** — Group 역할에 관리자 역할에는 없는 Principal 권한 13개가 있어
`You are not authorized to grant permissions: jmapPrincipalCreate, …` 로 거절된다(2026-10-29 확인, 직원 계정 · 그룹에 구성원 넣기는 관리자로 된다).
@@ -1050,8 +1051,8 @@ setup 이 엔진의 `x:DataRetention` 을 맞춘다 — 휴지통 · 스팸메
- **옮겨 온 메일(imapsync)도 옮긴 때부터 센다** — 엔진은 휴지통 · 스팸메일함에 든 순간의 시각(`now()`)을 그 칸에 쓴다(0.16.27 `email/src/message/index/mod.rs` — 내부 날짜가 아니다).
시험(2026-11-02 임시 엔진, 기간 70초): 내부 날짜 2025-01-01 로 휴지통 · 스팸메일함에 IMAP APPEND → 바로 정리해도 남고, 70초 뒤 정리에 지워졌다(받은편지함의 같은 날짜 메일은 그대로).
그래서 이전 다음 날 30일 넘은 옛 휴지통 메일이 한꺼번에 지워지지 않는다 — 이전한 날부터 30일 뒤에 지워진다(직원에게 알릴 거리).
- 자동 비우기는 **엔진의 휴지통 · 스팸메일함(특수 메일함 — "Deleted Items" · "Junk Mail") 둘에만** 걸린다. 이름이 같은 다른 폴더(imapsync 가 "Trash" 를 따로 만든 경우)는 지워지지 않는다 —
[imapsync](#imapsync) 의 메일함 맞추기를 본다.
- 자동 비우기는 **엔진의 휴지통 · 스팸메일함(특수 메일함 — "Deleted Items" · "Junk Mail") 둘에만** 걸린다. mailcow 의 Trash · Junk 는 imapsync `--automap` 이 이 둘로 옮긴다(2026-11-02 개발 시험 — 따로 생긴 폴더 없음).
따로 생긴 "Trash" · "Junk" 같은 폴더는 지워지지 않는다 — [imapsync](#imapsync) 의 "사람마다 확인할 것".
- 엔진 기본값도 30일이지만 기대지 않고 맞추고, 관리 화면에서 바꾼 값은 되돌린다(`[변경]`). 휴지통에서 지운 메일도 [백업](#백업)의 보존 기간(최대 6개월)까지는 백업에 남는다.
- **정리 시각을 새벽으로 옮긴다** — 엔진의 정리 작업 셋은 메일 컨테이너의 현지 시각(UTC — compose 가 TZ 를 주지 않는다)으로 돌아 기본값(00:00 · 02:00 · 04:00)이 한국 시각 09:00 · 11:00 · 13:00 이었다.
@@ -1329,7 +1330,7 @@ alias dcx='docker compose exec postfix-mailcow'
SPF · DKIM · DMARC 가운데 하나라도 실패하면 mailcow 가 위조로 보고(+50점) **거절**한다 — DMARC 가 `p=none` 이어도 막아 주지 않는다.
DKIM 은 키가 있는데 서명이 맞지 않을 때(`R_DKIM_REJECT`)다 — selector 의 키를 찾지 못하는 것(`R_DKIM_PERMFAIL`)은 0점이라 SPF 정렬로 DMARC 가 통과하면 받는다.
없는 주소(`colleaguee@`)로 보내면 mailcow 가 550 → 새 서버가 owner 에게 반송한다(루프 없음).
5. **imapsync 1차** — mailcow → 새 서버, owner 만 ([imapsync](#imapsync)).
5. **imapsync 1차** — mailcow → 새 서버, owner 만 ([imapsync](#imapsync) — owner 계정은 운영 도구 「입사」에서 옮겨 올 메일 y 로 만들어 이전용 비밀번호로 옮긴다).
6. **Transport Map 추가** — System → Configuration → Routing → Transport Maps → "Add transport":
| 칸 | 값 |
@@ -1348,7 +1349,7 @@ alias dcx='docker compose exec postfix-mailcow'
# 실제 발송 — 바깥(Gmail) · SOGo 동료 · owner 가 든 별칭 · owner+test@ 에서 각 한 통
docker compose logs --since 15m postfix-mailcow | grep -E 'to=<owner(\+[^@]*)?@arasystem\.it\.kr>' # relay=mx1.arasystem.it.kr[<MX1_IP>]:25 … status=sent
```
7. **마지막 imapsync**(차이분).
7. **마지막 imapsync**(차이분) → 운영 도구 「메일 이전 › 이전 마치기」(owner) → owner 에게 임시 비밀번호(첫 로그인 때 새 비밀번호를 정한다 — 그 뒤 8).
8. **owner 의 메일 프로그램을 mx1 로** — IMAP `mx1.arasystem.it.kr:993` · SMTP `:465`, 손으로 설정한다(autodiscover 는 mailcow 를 가리킨다). 휴대폰 ActiveSync 등 옛 프로필은 지운다.
9. **mailcow 의 옛 사서함 접근 줄이기** — 사서함 편집의 Allowed protocols(IMAP · POP3 · SMTP · Sieve · ActiveSync · CalDAV/CardDAV)와 SOGo 접근을 줄이고 앱 비밀번호를 지운 뒤,
마지막 동기화가 끝나면 Active 를 **"Disallow login (incoming mail is still accepted)"** 로. **지우거나 "Inactive" 로 두지 않는다** — 그러면 owner 앞 메일이 모두 550 이고,
@@ -1394,7 +1395,7 @@ mailcow 를 거친 메일은 SPF · DMARC 결과가 없고(`Authentication-Resul
```
`allowRelaying`(꺼진 것을 다시 읽어 확인) → 발송 전략 → RCPT 가드 · 위조 막기 · 검사 예외 · 한도 예외 · 자동 차단 예외 차례로 지우고 리로드한 뒤 `legacy` 경로를 지운다. 다음 실행부터는 `--end-pilot` 없이 `[유지] 시범 공존 — 꺼짐` 이다.
- 계정을 만든 순간부터 그 주소 앞 사내 메일은 새 서버의 로컬 배달이다 — 계정 만들기 → imapsync → MX 바꾸기를 짧게 이어서 한다.
- 계정을 만든 순간부터 그 주소 앞 사내 메일은 새 서버의 로컬 배달이다 — 「입사」 → imapsync 1차 → MX 바꾸기를 짧게 이어서 한다([imapsync](#imapsync) 의 차례).
- 위조 막기(인증 없는 사내 도메인 MAIL FROM 은 mailcow IP 에서만)도 함께 빠진다. 전환 뒤에도 남길지는 정할 일이다(원래 봉투를 그대로 넘기는 바깥 전달 서비스가 막힌다).
### 주의
@@ -1448,15 +1449,20 @@ mailcow 를 거친 메일은 SPF · DMARC 결과가 없고(`Authentication-Resul
| --- | --- | --- |
| 0. TTL 낮추기 (D-3 이상) | MX·SPF TXT·`mx1` A 의 TTL 을 300초로. **기존 TTL 만큼** 기다린 뒤 다음 단계 | `dig +noall +answer MX arasystem.it.kr` 의 TTL |
| 1. 새 호스트 | `mx1.arasystem.it.kr` A → 새 IP, PTR → `mx1.arasystem.it.kr`, `.env.production` 에 `MAIL_HOSTNAME`·`MAIL_PUBLIC_URL` = mx1, [운영 배포](#운영-배포) | 컨테이너 로그에 인증서 발급, `openssl s_client -connect mx1.arasystem.it.kr:993` |
| 2. 같은 주소 생성 | 모든 직원 주소·별칭·공용 주소 + **`dmarc@`**(현재 DMARC 리포트 수신처) + `postmaster@`(Stalwart 기본 리포트 수신처). **시범 공존 중이면 전면 전환 때 한다** — 만든 순간부터 그 주소 앞 사내 메일이 새 서버에 쌓인다([파일럿 공존](#파일럿-공존-mailcow-와-함께--한-사람-먼저) "주의") | 관리 화면 계정 목록 |
| 2. 같은 주소 생성 | 직원 메일함은 운영 도구 「입사」를 한 사람씩 — 옮겨 올 메일이 있으면 y(이전용 비밀번호 — [imapsync](#imapsync)). 별칭은 없다(2026-11-02). 그리고 **`dmarc@`**(현재 DMARC 리포트 수신처) · `postmaster@`(Stalwart 기본 리포트 수신처). **시범 공존 중이면 전면 전환 때 — 5 · 6 과 짧게 잇는다** — 만든 순간부터 그 주소 앞 사내 메일이 새 서버에 쌓인다([파일럿 공존](#파일럿-공존-mailcow-와-함께--한-사람-먼저) "주의") | 운영 도구 「메일 이전 › 이전 현황」 · 관리 화면 계정 목록 |
| 3. DKIM selector 추가 | setup 이 출력한 `v1-ed25519-YYYYMMDD._domainkey`·`v1-rsa-YYYYMMDD._domainkey` TXT 를 **추가**. mailcow 의 `dkim._domainkey` 는 그대로 둔다 (selector 가 달라 공존) | `dig TXT v1-rsa-YYYYMMDD._domainkey.arasystem.it.kr` |
| 4. SPF 갱신 | `v=spf1 mx ip4:46.250.248.43 ip4:<새 IP> -all` (+ 권장: `mx1.arasystem.it.kr TXT "v=spf1 a -all"`) — **MX 변경·외부 발송 전에** | 새 서버에서 외부(본인 Gmail 등)로 1통 → 헤더의 `spf=pass dkim=pass` |
| 5. 메일 이전 1차 | `imapsync` 로 mailcow → Stalwart 전체 동기화 (아래) | 폴더별 메일 수 비교 |
| 6. MX 전환 | `arasystem.it.kr MX 10 mx1.arasystem.it.kr` 로 교체. mailcow 는 **계속 켜 둔다** (전파 중에는 mailcow 로도 들어온다) | 외부에서 보낸 메일이 새 엔진 로그에 수신 |
| 7. 증분 동기화 | MX 전환 직후 · TTL 경과 후 · 정리 직전에 `imapsync` 를 다시 (이미 옮긴 메일은 건너뛴다) | mailcow 로그에 새 수신 없음 |
| 8. 병행 1~2주 후 정리 | SPF 에서 `ip4:46.250.248.43` 제거, `dkim._domainkey` 삭제, mailcow 중지. 필요하면 `mail.arasystem.it.kr` 을 새 호스트로 옮기고(인증서 포함) `MAIL_PUBLIC_URL` 변경 | — |
| 5. 메일 이전 1차 | [imapsync](#imapsync) — 한 사람으로 먼저(`--justlogin` · 폴더 맞춤), 그다음 주소 목록 전부 | 사람마다 `Detected 0 errors` · 폴더별 수 · 「이전 현황」의 메일함 사용량 |
| 6. MX 전환 | `arasystem.it.kr MX 10 mx1.arasystem.it.kr` 로 교체. mailcow 는 **계속 켜 둔다** (전파 중에는 mailcow 로도 들어온다). **이때부터 9 까지 직원은 새 서버에 들어오지 못한다** — 짧게(예: 금요일 저녁 ~ 월요일 아침) | 외부에서 보낸 메일이 새 엔진 로그에 수신 |
| 7. 마지막 imapsync | TTL 이 지나 mailcow 에 새 수신이 없어진 뒤 같은 명령을 한 번 더(차이분 — 이미 옮긴 메일은 건너뛴다). 그 전에 직원에게 mailcow 를 그만 쓰게 알린다 | mailcow 로그에 새 수신 없음 · 사람마다 `Detected 0 errors` |
| 8. 이전 마치기 | 마지막 imapsync 뒤 5분쯤 지나 운영 도구 「메일 이전 › 이전 마치기」(주소 빈 값 — 이전 중인 계정 모두) → 주소 · 임시 비밀번호 표가 **한 번** 보인다. mailcow 메일함은 `Disallow login`, 마스터 계정 두 줄 · 서버 B 의 비밀번호 파일 지우기([imapsync](#imapsync) 단계 8) | 「이전 현황」이 비었다 |
| 9. 임시 비밀번호 나눠 주기 | 직원에게 직접(말 · 쪽지). 직원은 첫 로그인 때 새 비밀번호를 정하고, 회사 정책이면 2단계 인증을 켠다 | 직원이 웹메일에 들어온다 |
| 10. 병행 1~2주 후 정리 | SPF 에서 `ip4:46.250.248.43` 제거, `dkim._domainkey` 삭제, mailcow 중지. 필요하면 `mail.arasystem.it.kr` 을 새 호스트로 옮기고(인증서 포함) `MAIL_PUBLIC_URL` 변경 | — |
롤백: MX 를 `10 mail.arasystem.it.kr` 로 되돌린다. 그 사이 새 엔진에 들어온 메일은 반대 방향 `imapsync` 로 옮긴다.
롤백: MX 를 `10 mail.arasystem.it.kr` 로 되돌린다. 그 사이 새 엔진에 들어온 메일은 반대 방향 `imapsync` 로 옮긴다(원본 · 대상과 그 자격을 서로 바꿔 — 새 서버가 `--host1`).
- 되돌릴 때 — 임시 비밀번호를 **나눠 주기 전**(9 전)이면 새 서버 쪽 자격은 이전용 비밀번호다(이전 마치기를 했으면 「이전용 비밀번호 다시 만들기」에 주소마다 — 만료된 계정도 받는다, 빈 값에는 빠진다) · mailcow 메일함은 `Disallow login` 을 했으면 Active 로 되살린다.
**나눠 준 뒤**면 직원이 정한 비밀번호라 운영자에게 새 서버 쪽 자격이 없고(운영 도구도 그 계정에는 이전용 비밀번호를 걸지 않는다) 직원이 새 서버에서 보낸 · 정리한 메일까지 옮겨야 한다 — 직원마다 앱 비밀번호로 하는 길은 확인하지 않았다.
주의할 레코드:
@@ -1477,22 +1483,175 @@ mailcow 를 거친 메일은 SPF · DMARC 결과가 없고(`Authentication-Resul
### imapsync
전 직원 전환 때 mailcow 의 **메일만** 새 엔진으로 옮긴다 — 메일함 15개 남짓, 별칭 없음(모두 `@arasystem.it.kr` 메일함 — 2026-11-02 결정).
원본(mailcow)은 **Dovecot 마스터 계정**으로 직원 비밀번호 없이 읽고, 대상(새 엔진)은 운영 도구가 만든 **이전용 비밀번호**로 IMAP 에 로그인한다.
- **옮기지 않는 것** — SOGo 주소록 · 달력, 메일 필터 · 부재중 · 전달 설정, 서명. 직원이 웹메일에서 새로 만든다. 필요하면 mailcow 를 해지하기 전에 SOGo 의 주소록 · 달력을 내보내 둔다.
- **이전용 비밀번호** — 운영 도구 「입사」에서 용량 다음의 `mailcow 에서 옮겨 올 메일이 있습니까?` 에 y 로 답하면 직원용 임시 비밀번호 대신 만든다(한 번만 보인다).
만료가 만든 때 + 30일이라 그때까지 IMAP 이 되고, 지나면 엔진이 저절로 막는다. imapsync 에만 쓰고 직원에게 주지 않는다.
**운영 도구를 한 번 띄운 동안의 「입사」는 같은 값**이다 — 모두를 한 실행에서 만들면 비밀번호 파일이 하나이고, 사람마다 다른 것은 `--user1` · `--user2` 뿐이다(원본도 마스터 비밀번호 파일 하나).
이전 중인 계정은 운영 도구 메뉴 `11. 메일 이전` 이 다룬다 — `1. 이전 현황` · `2. 이전용 비밀번호 다시 만들기` · `3. 이전 마치기`(2 · 3 은 `주소 (빈 값이면 이전 중인 계정 모두): `).
"이전 중"은 엔진 비밀번호 만료가 미래(30일 안)이면서 운영 도구의 실행 기록에서 마지막으로 건 비밀번호가 이전용인 계정이다(계약 6절 "메일 이전").
엔진의 **비밀번호 기본 만료**(`x:Authentication` 의 `passwordDefaultExpiry` — 관리 화면의 인증 설정)는 켜지 않는다 — 켜면 직원 비밀번호가 주기적으로 만료되고, 끈 뒤에도 그동안 바꾼 비밀번호의 만료가 남는다. 운영 도구는 켜져 있으면 첫 화면에 알린다.
- 근거는 아래 [개발에서 확인한 것](#개발에서-확인한-것-imapsync--2026-11-02) — mailcow 를 흉내 낸 Dovecot → 개발 엔진. **운영 mailcow · 서버 B 에서는 아직 돌려 보지 않았다** — 단계 5 의 첫 사람으로 확인한다.
#### 차례 (단계 번호는 [전환 절차](#arasystemitkr-전환-절차-mailcow--stalwart)의 것)
| 단계 | 할 일 | 비고 |
| --- | --- | --- |
| 준비 | mailcow 마스터 계정 고정(아래 "원본") · mailcow Fail2ban 예외에 새 서버 IP · 서버 B 에 마스터 비밀번호 파일 · 주소 목록(아래 "돌리는 곳") | Fail2ban 예외는 [파일럿 공존](#파일럿-공존-mailcow-와-함께--한-사람-먼저)의 mailcow 절차 2 와 같다 — imapsync 로그인 실패가 쌓여 그 IP 가 막히면 새 서버 → mailcow 의 SMTP 도 끊긴다 |
| 2 | 계정 만들기 — 운영 도구 「입사」를 한 사람씩(모두를 한 실행에서), 옮겨 올 메일 y. 처음 보이는 이전용 비밀번호를 **그 자리에서** 서버 B 의 비밀번호 파일로(아래 — 다시 볼 수 없다) | 「메일 이전 › 이전 현황」에 이전 중인 계정 · 끝나는 때 · 메일함 사용량이 보인다 |
| 5 | imapsync 1차 — **한 사람으로 먼저**(`--justlogin` · 엔진이 본 접속 주소 · 1차 · 아래 "사람마다 확인할 것"), 그다음 주소 목록 전부 | 메일이 많으면 MX 전날에 — 단계 7 의 차이분이 짧아진다 |
| 6 | MX 바꾸기 | 이때부터 단계 9 까지 직원은 새 서버에 들어오지 못한다(비밀번호를 아직 받지 않았다) |
| 7 | **마지막 imapsync** — TTL 이 지나 mailcow 에 새 수신이 없어진 뒤 같은 명령 | 그사이 mailcow 에 온 메일 · 바뀐 읽음 표시만 옮긴다. 직원에게는 그 전에 mailcow 를 그만 쓰게 알린다 — 이 뒤에 mailcow 에서 한 일은 옮겨지지 않는다 |
| 8 | **마지막 imapsync 가 끝나고 5분쯤 지난 뒤** 운영 도구 「메일 이전 › 이전 마치기」 — 주소 빈 값(이전 중인 계정 모두). 그다음 mailcow 정리 — 메일함을 `Disallow login (incoming mail is still accepted)` 로, 마스터 계정 두 줄 지우기, 서버 B 의 비밀번호 파일 지우기 | 사람마다 직원용 임시 비밀번호(만료 — 첫 로그인 때 바꾸기), imapsync 의 로그인 기록 지우기. 끝에 주소 · 임시 비밀번호 표가 **한 번** 보인다. 5분을 기다리는 까닭 — 엔진 웹훅은 1초씩 묶어 보내고 실패하면 5분까지 다시 보낸다, 그 전에 하면 늦게 온 IMAP 로그인 몇 줄이 직원의 로그인 기록에 남는다. mailcow 메일함은 지우거나 Inactive 로 두지 않는다(파일럿 mailcow 절차 9 와 같다) |
| 9 | 임시 비밀번호 나눠 주기 — 직원에게 직접(말 · 쪽지) | 직원은 첫 로그인 때 새 비밀번호를 정하고, 회사 정책이면 2단계 인증을 켠다(계약 6절 "제한된 세션") |
- **단계 6 ~ 9 는 짧게 잡는다** — 예: 금요일 저녁 MX → 토요일 마지막 imapsync · 이전 마치기 → 월요일 아침 나눠 주기. 그동안 바깥 메일은 새 서버에 쌓이고, 직원은 단계 7 전까지 mailcow 를 그대로 쓸 수 있다.
- 단계 2 ~ 6 도 짧게 잇는다 — 계정을 만든 순간부터 그 주소 앞 사내 메일(새 서버 안에서 보낸 것 — 시범 사용자 등)은 새 서버로 배달된다([시범 끝내기](#시범-끝내기--전면-전환)).
- 이전용 비밀번호를 잃었거나, 「입사」를 여러 실행에 나눠 해 값이 다르거나, 30일이 다가오면 — 「메일 이전 › 이전용 비밀번호 다시 만들기」를 주소 빈 값(이전 중인 계정 모두)으로 한 번 해서
한 값으로 맞춘다(지금부터 30일). 서버 B 의 비밀번호 파일도 그 값으로 다시 만든다.
- 이전 중인 계정에는 「비밀번호 재설정」을 쓰지 않는다 — 운영 도구가 막고 위 메뉴로 안내한다.
- 휴지통 · 스팸메일함으로 옮긴 메일의 30일은 **옮긴 날부터** 센다(엔진은 그 메일함에 들어온 때로 센다 — [자동 비우기](#휴지통--스팸메일함-자동-비우기)) — 이전 다음 날 옛 휴지통이 한꺼번에 비지 않고, 옮긴 날 + 30일에 지워진다. 직원에게 알린다.
#### 원본 — mailcow 마스터 계정
mailcow 는 dovecot-mailcow 를 다시 띄울 때마다 **무작위** 마스터 계정을 만든다(평소에는 그대로 둔다). 이전하는 동안만 고정한다 —
mailcow 문서 "Static master user"(<https://docs.mailcow.email/manual-guides/Dovecot/u_e-dovecot-static_master/>, 몇 해 된 글):
```bash
imapsync \
--host1 mail.arasystem.it.kr --ssl1 --user1 '<직원 주소><구분자><마스터 계정>' --password1 '<마스터 비밀번호>' \
--host2 mx1.arasystem.it.kr --ssl2 --user2 '<직원 주소>' --password2 '<새 엔진 초기 비밀번호>' \
--automap --syncinternaldates
# mailcow 호스트 — 설치 폴더(/opt/mailcow-dockerized)의 mailcow.conf 에 두 줄. 둘 다 값이 있어야 한다(하나라도 비면 무작위로 만든다)
DOVECOT_MASTER_USER=<이름> # 로그인 이름에는 @mailcow.local 이 저절로 붙는다
DOVECOT_MASTER_PASS=<비밀번호> # 영문 · 숫자로 길게
docker compose up -d # dovecot-mailcow 가 다시 만들어진다
```
- 원본(mailcow)은 **Dovecot 마스터 계정**으로 직원 비밀번호 없이 읽는다. mailcow 의 마스터 계정 설정 방법과
로그인 형식(Dovecot 기본 구분자는 `*`)은 **mailcow 문서를 확인해야 한다** (여기서는 검증하지 않았다).
- **휴지통 · 스팸메일함은 엔진의 특수 메일함으로 맞춘다** — 엔진의 이름은 "Deleted Items" · "Junk Mail"(IMAP SPECIAL-USE `\Trash` · `\Junk`)이고 mailcow 는 "Trash" · "Junk" 다.
`--automap` 이 특수 용도로 짝짓지만, 한 사람으로 먼저 돌려 새 엔진에 "Trash" · "Junk" 폴더가 따로 생기지 않았는지 본다 — 생겼으면 `--f1f2 'Trash=Deleted Items' --f1f2 'Junk=Junk Mail'` 로 다시.
따로 생긴 폴더는 자동 비우기(30일 — [휴지통 · 스팸메일함 자동 비우기](#휴지통--스팸메일함-자동-비우기))가 걸리지 않는다. 옮긴 휴지통 메일의 30일은 옮긴 날부터 센다(같은 절 — 시험함)
- 대상(Stalwart)은 계정을 만들 때 정한 초기 비밀번호로 로그인한다. 게이트웨이 대리 접근(`%`)은 HTTP 에서만 검증했고 IMAP 에서는 확인하지 않았다.
게이트웨이는 신뢰 대역에서만 로그인되므로 외부에서 쓸 수도 없다.
- 직원 비밀번호는 정책 결정이 필요하다 — 새 초기 비밀번호를 배포하고 첫 로그인 때 바꾸게 하거나,
mailcow 의 비밀번호 해시를 그대로 옮길 수 있는지 검토한다 (미검증).
- IMAP 로그인 이름은 `<직원 주소>*<이름>@mailcow.local`(예 `hong@arasystem.it.kr*migrator@mailcow.local` — `@` 가 둘), 비밀번호는 `DOVECOT_MASTER_PASS` 다.
**IMAP 전용** — SOGo(mailcow 웹메일) 로그인에는 쓰지 못한다.
- 끝나면(단계 8) 두 줄을 지우고 `docker compose up -d` — 무작위로 돌아간다. 마스터 비밀번호는 모든 메일함을 연다.
- `mailcow.conf` 는 **mailcow 호스트** — mailcow 컨테이너가 도는 사무실 PC(바깥 `mail.arasystem.it.kr` 은 Contabo 앞단을 거쳐 이 PC 로 온다)에 있다. 운영자가 그 PC 에 들어가 고친다
(2026-11-02 사용자 확인 — 파일럿 때 적은 "관리 화면만"([파일럿 공존](#파일럿-공존-mailcow-와-함께--한-사람-먼저)의 "SSH 가 없을 때")은 원격으로 들어갈 길 이야기다).
설치 폴더는 compose 가 기록한 작업 폴더로 찾는다 — 값(비밀번호)은 출력하지 않고 이름만 본다:
```bash
# mailcow PC 에서 (리눅스 셸 · docker 를 쓸 수 있는 사용자)
sudo docker inspect "$(sudo docker ps -qf name=dovecot-mailcow)" --format '{{ index .Config.Labels "com.docker.compose.project.working_dir" }}'
cd <위 폴더> && grep -o '^#\?DOVECOT_MASTER_[A-Z]*' mailcow.conf # 줄이 있는지 · 주석인지만
git log -1 --format='%cd %h' --date=short # mailcow 판(커밋 날짜)
```
- **확인하지 않은 것** — imapsync 가 붙는 `mail.arasystem.it.kr:993` 이 Contabo 앞단을 어떻게 지나 사무실 PC 의 mailcow 에 닿는지 —
mailcow 가 보는 접속 주소가 서버 B 의 IP 가 아니라 앞단의 주소라면 Fail2ban 예외도 그 주소여야 하고, 그 주소가 막히면 모든 직원의 메일 프로그램이 함께 막힌다(첫 사람의 `--justlogin` 뒤 mailcow 관리 화면의 로그인 기록에서 그 주소를 본다).
운영 mailcow 판에서 이 설정이 문서대로인지(같은 꼴로 인증에 실패했다는 커뮤니티 글이 있다 — 해결 기록 없음. 그래서 한 사람의 `--justlogin` 으로 먼저 본다),
메일함 편집의 **Allowed protocols** 에서 IMAP 이 꺼진 메일함이 마스터로도 막히는지(그래서 mailcow 접근 줄이기는 마지막 imapsync 뒤 — 단계 8).
- 개발 흉내(Dovecot 2.3 에 같은 꼴 — 구분자 `*` · 마스터 이름 `migrator@mailcow.local`)에서는 imapsync 가 그 이름을 그대로 넘겨 Dovecot 이 `Master user logging in as kim@legacy.test` 로 받았다.
mailcow 에 없는 메일함은 마스터로도 `NO [AUTHORIZATIONFAILED] Authorization failed` 다.
#### 돌리는 곳 · 명령 (서버 B 호스트)
이미지는 imapsync 저자의 공식 이미지를 판 · digest 까지 고정한다 — `gilleslamiral/imapsync:2.326@sha256:ece980cb0fd2806369975d2989a3e8d9fbf28d632cdd45a05961a1e511612ed5`
(2026-10-02 · amd64 만 · 받는 양 181MB · Debian 13 · 비-root `nobody`). 올릴 때는 [docker 스킬](../.claude/skills/docker/SKILL.md) "이미지 버전 고정"의 갱신 규칙대로.
```bash
# 준비 — 비밀번호 파일은 root 폴더(0700) 안, imapsync 컨테이너 사용자(nobody 65534)만 읽는다(0400).
# read 로 받아 화면 · 셸 기록 · ps 에 남기지 않는다. 줄 끝은 LF 여야 한다 — CRLF(Windows 에서 만든 파일)면 imapsync 가 \r 까지 비밀번호로 읽어 로그인에 실패한다
sudo install -d -m 0700 /root/imapsync /root/imapsync/log
read -rsp 'mailcow 마스터 비밀번호: ' P; echo; printf '%s\n' "$P" | sudo install -m 0400 -o 65534 -g 65534 /dev/stdin /root/imapsync/mailcow-master.pass; unset P
# 단계 2 — 「입사」가 이전용 비밀번호를 보여 줄 때 (다시 만들기로 바꾼 뒤에도 같은 줄로 덮어쓴다)
read -rsp '이전용 비밀번호: ' P; echo; printf '%s\n' "$P" | sudo install -m 0400 -o 65534 -g 65534 /dev/stdin /root/imapsync/migration.pass; unset P
sudo tee /root/imapsync/addresses.txt >/dev/null # 주소 목록 — mailcow 관리 화면의 메일함 목록에서 한 줄에 하나, 붙여 넣고 Ctrl-D
B_IP=<서버 B 의 사설 IP> # NPM 이 :8080 으로 붙는 그 주소 (ip -4 addr)
MASTER='<DOVECOT_MASTER_USER>@mailcow.local'
IMAPSYNC_IMAGE='gilleslamiral/imapsync:2.326@sha256:ece980cb0fd2806369975d2989a3e8d9fbf28d632cdd45a05961a1e511612ed5'
imapsync_one() { # $1 = 직원 주소, 그 뒤는 덧붙일 옵션
sudo docker run --rm --network host --add-host "mx1.arasystem.it.kr:${B_IP}" \
--user 65534:65534 --cap-drop ALL --security-opt no-new-privileges --read-only --tmpfs /tmp --tmpfs /var/tmp \
--mount type=bind,src=/root/imapsync/mailcow-master.pass,dst=/run/secrets/master,readonly \
--mount type=bind,src=/root/imapsync/migration.pass,dst=/run/secrets/migration,readonly \
"$IMAPSYNC_IMAGE" imapsync \
--host1 mail.arasystem.it.kr --port1 993 --ssl1 --sslargs1 SSL_verify_mode=1 \
--user1 "$1*$MASTER" --passfile1 /run/secrets/master \
--host2 mx1.arasystem.it.kr --port2 993 --ssl2 --sslargs2 SSL_verify_mode=1 \
--user2 "$1" --passfile2 /run/secrets/migration \
--automap --syncinternaldates --addheader --nolog --noreleasecheck "${@:2}" </dev/null
}
# 단계 5 — 한 사람으로 먼저: 두 서버에 로그인만 → 엔진이 본 접속 주소(remoteIp)가 B_IP 인지 → 1차
imapsync_one hong@arasystem.it.kr --justlogin
sudo docker logs --since 5m "${PROJECT_NAME}-mail" 2>&1 | grep auth.success | grep imaps | tail -1
imapsync_one hong@arasystem.it.kr | sudo tee /root/imapsync/log/hong-1.log | tail -45
# 단계 5 · 7 — 모두. 사람마다 로그 하나(R=1 · 단계 7 은 R=2), 화면에는 끝의 판정 줄만.
# /root 는 이 셸이 읽지 못한다 — 목록은 sudo cat 으로, 지울 파일은 이름으로 준다(< /root/… · /root/*.pass 는 셸이 풀지 못한다)
R=1; sudo cat /root/imapsync/addresses.txt | while read -r A; do
imapsync_one "$A" | sudo tee "/root/imapsync/log/${A%@*}-$R.log" | grep -E '^(Detected|The sync looks good|There is no unidentified|Exiting with)'
done
# 단계 8 — 비밀번호 파일을 지운다(로그에는 비밀번호가 없다 — 파일 경로만)
sudo rm -f /root/imapsync/mailcow-master.pass /root/imapsync/migration.pass
```
- **`--host2` 는 이름 `mx1.arasystem.it.kr` 그대로, 길은 `--add-host` 로 서버 B 의 사설 IP** — `SSL_verify_mode=1` 이 엔진의 Let's Encrypt 인증서를 그 이름으로 확인하고(주소로 붙으면 `hostname verification failed` — 개발 시험),
연결은 서버 안에서 끝난다. 게시 포트 규칙(iptables)을 거치므로 엔진은 접속 주소를 서버 B 의 사설 IP 로 본다(신뢰 대역 밖 — 바깥 메일 프로그램과 같은 대접) — **원리에 따른 판단이다**, 첫 사람의 `--justlogin` 뒤 `remoteIp` 로 확인한다.
- 쓰지 않는 길: 공인 주소(이름을 그대로 풀면) — 서버가 자기 공인 IP 로 되돌아오는 길을 NCP 가 주는지 확인하지 않았다(setup 의 공개 443 확인도 이 길을 경고로만 다룬다 — [운영 배포](#운영-배포) 끝).
`127.0.0.1` · mail 컨테이너 주소 · 앱 네트워크 — 엔진이 앱 네트워크의 게이트웨이(`172.16.71.1` — 신뢰 대역, 자동 차단 예외)로 본다. 앱 네트워크에 다른 컨테이너를 붙이는 것은 setup 이 운영에서 막는다.
- `--network host` — 기본 브리지에서 사설 IP 로 가면 도커 규칙에 따라 주소가 바뀌거나(MASQUERADE) 네트워크 사이에서 막힐 수 있어 호스트 네트워크로 한다(확인하지 않았다). 컨테이너는 비-root · 권한 없음 · 읽기 전용이다.
- **인증서 확인을 켠다** — imapsync 는 기본으로 확인하지 않는다(2.326 로그 `SSL default mode is like --sslargs1 "SSL_verify_mode=0"`). `SSL_verify_mode=1` 은 이미지의 CA 묶음(Debian — Let's Encrypt 루트가 든다)으로 CA 와 이름을 모두 본다.
mailcow(`mail.arasystem.it.kr`)도 공인 인증서다. mx1 이 아직 자체 서명이면(엔진의 ACME 가 실패 — "운영 배포"의 TLS 인증서 줄) `certificate verify failed` 로 멈춘다 — 확인을 끄지 말고 인증서를 고친다.
- `--automap` — 특수 폴더를 SPECIAL-USE 로 짝짓는다. `--syncinternaldates` — 받은 때(내부 날짜)를 그대로(기본으로도 켜져 있다).
`--addheader` — Message-ID 가 없는 메일(일부 프로그램의 초안 등)은 이것 없이는 옮기지 않는다(엔진 쪽 사본에 머리글 하나를 더한다).
`--noreleasecheck` — 새 판 확인으로 imapsync 저자의 서버에 묻지 않게. `--nolog` — 로그는 컨테이너(읽기 전용) 대신 `tee` 로 호스트에 남긴다.
- 원본에서 지운 메일은 새 서버에서 지우지 않는다 — 차이분은 더하기 · 읽음 표시 맞추기뿐이다(`--delete2` 는 쓰지 않는다).
- 한 사람씩 차례로 돌린다. 메일 14통에 5초 안팎(개발) — 운영의 양(수 GB)은 mailcow ↔ 서버 B 사이 대역폭이 정한다.
#### 사람마다 확인할 것
- imapsync 끝의 판정 — `Detected 0 errors`, `The sync looks good, all N identified messages in host1 are on host2.`, `There is no unidentified message on host1.`
(단계 7 뒤에는 `The sync is not strict, there are N … in host2 that are not on host1` 이 나올 수 있다 — 원본에서 지운 메일 · 그사이 새 서버로 바로 온 메일이라 정상이다.)
- 폴더 맞춤 — `Folders mapping from --automap feature` 아래 `[Drafts] -> [Drafts]` · `[Junk] -> [Junk Mail]` · `[Sent] -> [Sent Items]` · `[Trash] -> [Deleted Items]`.
엔진에 "Trash" · "Junk" 같은 폴더가 **따로 생겼으면**(그 폴더는 [자동 비우기](#휴지통--스팸메일함-자동-비우기)가 걸리지 않는다) 그 사람만 `--f1f2 'Trash=Deleted Items' --f1f2 'Junk=Junk Mail'` 을 덧붙여 다시 돌리고, 따로 생긴 폴더는 웹메일에서 지운다.
mailcow 의 Archive(늘 있는 보관함)는 엔진에 같은 특수 메일함이 없어 보통 폴더 "Archive" 로 온다(비어 있어도 생긴다).
- 폴더별 수 — `Folders sizes after the synchronization` 의 Host1 · Host2 `Messages:` 가 폴더마다 같다(`--addheader` 로 머리글이 붙은 메일은 크기가 수십 바이트 다르다).
- 메일함 사용량 — 운영 도구 「메일 이전 › 이전 현황」이 mailcow 관리 화면의 그 메일함 사용량과 비슷하다.
#### 실패할 때
| imapsync 출력 (종료 코드) | 까닭 | 할 일 |
| --- | --- | --- |
| `Host2 failure: Error login on [mx1.arasystem.it.kr] with user [<주소>] auth [LOGIN]: 2 NO [AUTHENTICATIONFAILED] Authentication failed` (162) | 새 서버 쪽 비밀번호가 다르다 — 파일 줄 끝이 CRLF, 다른 실행에서 만든 이전용 비밀번호, 「다시 만들기」로 바뀐 값, 「이전 마치기」를 이미 했다 | 파일을 위처럼 다시 만들거나 「메일 이전 › 이전용 비밀번호 다시 만들기」 |
| `Host2 failure: Error login … socket closed while reading data from server` (162) | 비밀번호는 맞지만 만료 — 30일이 지났다(엔진이 답 없이 끊는다) | 「이전용 비밀번호 다시 만들기」에 **그 주소**로(지금부터 30일 — 빈 값은 아직 이전 중인 계정만이라 30일이 지난 계정은 빠진다) |
| `Host1 failure: Error login on [mail.arasystem.it.kr] with user [<주소>*…@mailcow.local] … NO [AUTHENTICATIONFAILED]` (161) | mailcow 마스터 — 비밀번호 파일, `DOVECOT_MASTER_*` 가 먹지 않음, 이름 꼴(`*` · `@mailcow.local`) | 위 "원본" |
| `Host1 failure: … NO [AUTHORIZATIONFAILED] Authorization failed` (161) | 마스터는 맞지만 mailcow 에 그 메일함이 없다(주소 오타) | 주소 목록 |
| `… SSL connect attempt failed … certificate verify failed` · `hostname verification failed` (10) | 인증서 — 자체 서명이거나 이름이 다르다(`--add-host` 없이 주소로 붙음) | 위 `--host2` · 인증서 |
- 같은 실패를 모두에게 되풀이하지 않는다 — 엔진은 IP 마다 인증 실패 하루 100회면 그 IP 를 막는다(자동 차단 — 위 "setup 스크립트가 하는 일" 4. 서버 B 의 사설 IP 는 신뢰 대역 밖이다).
한 사람에서 실패하면 멈추고 고친다. mailcow 쪽은 Fail2ban 이다(준비의 예외).
- 한쪽 로그인이 실패해도 imapsync 는 다른 쪽 로그인을 해 본 뒤 끝난다(`--justlogin` 에서 봤다) — 출력의 `Err 1/2` 처럼 실패가 둘로 보이면 먼저 나온 것부터 본다.
#### 개발에서 확인한 것 (imapsync — 2026-11-02)
mailcow 를 흉내 낸 Dovecot(`dovecot/dovecot:2.3.21.1@sha256:547d28f9a893c93a1d2f6dc164880cd7bfa74009106dfa46b36a6a2ee636d921` — mailcow 와 같은 2.3 줄기, 비-root 로 띄움, 시험 CA 의 인증서)에
시험 직원 둘 — mailcow 와 같은 특수 폴더 이름(Sent · Drafts · Trash · Junk · Archive), 한글 하위 폴더, 2025년 날짜의 메일, 읽음 · 안 읽음 · 답장함 · 깃발, 첨부 하나, Message-ID 없는 초안 —
을 넣고, 개발 엔진(0.16.27)에 이전용 비밀번호(만료 30일 뒤 · 두 계정 한 값)로 만든 계정 둘로 위와 같은 옵션의 imapsync 를 돌렸다.
| 무엇 | 결과 |
| --- | --- |
| 원본 로그인 | `<주소>*migrator@mailcow.local` 을 imapsync 가 그대로 넘겼다. 원본에 없는 메일함은 마스터로도 `NO [AUTHORIZATIONFAILED]` |
| 폴더 맞춤 | INBOX → Inbox(`inbox`) · Sent → Sent Items(`sent`) · Drafts → Drafts(`drafts`) · Trash → Deleted Items(`trash`) · Junk → Junk Mail(`junk`) — **따로 생긴 폴더 없음, `--f1f2` 가 필요 없었다**. Archive 는 보통 폴더 "Archive"(role 없음) |
| 폴더마다 메일 수 | 원본과 같다 (메일 14 · 5통, 폴더 8 · 7개) |
| 받은 때 | 모두 같다 — 2025년 내부 날짜가 초까지 그대로(JMAP `receivedAt`) |
| 표시 | 읽음 · 답장함 · 깃발 · 초안 모두 같다 |
| 한글 폴더 | `프로젝트/2026 계약` 이 `프로젝트` 아래로, `&` 가 든 `거래처 & 협력` 도 그대로 |
| 첨부 | 한글 이름 PDF 48KB — 이름 · 크기 · SHA-256 이 같다 |
| Message-ID 없는 메일 | `--addheader` 없이는 옮기지 않는다(`Messages void (noheader) on host1 : 1 … add option --addheader`). 넣으면 옮기고, 다시 돌려도 겹치지 않는다 |
| 다시 돌리기 | 바뀐 것이 없으면 `Messages transferred : 0` (건너뜀 14 · 5) |
| 차이분 | 원본에 새로 온 1통만 옮기고, 원본에서 읽음으로 바꾼 것도 맞췄다(`--resyncflags` — 기본). 원본에서 지운 메일은 엔진에 남았다 |
| 만료 뒤 | 이전용 비밀번호의 만료를 지난 시각으로 바꾸자 `Host2 failure: Error login … socket closed while reading data from server` (162) |
| 틀린 비밀번호 | CRLF 비밀번호 파일 → `Host2 failure … NO [AUTHENTICATIONFAILED]` (162). 틀린 마스터 비밀번호 → 같은 꼴의 `Host1 failure` (161) |
| 인증서 | 기본은 확인하지 않는다. `SSL_verify_mode=1` 이면 자체 서명 `certificate verify failed`, 인증서에 없는 이름 `hostname verification failed` (10), 맞는 CA · 이름이면 통과 |
| 로그인 기록 | imapsync 의 IMAP 로그인도 웹훅으로 로그인 기록에 쌓인다(실행마다 한 줄 · 틀린 비밀번호의 실패도) — 「이전 마치기」가 지우는 까닭 |
| 메모리 | imapsync 170MB 남짓 |
- **개발과 운영이 다른 점** — 대상 인증서가 자체 서명이라 `--sslargs2 SSL_verify_mode=0` 으로 돌렸고, imapsync 를 잠시 앱 네트워크에 붙여 `mail:993` 으로 갔다(엔진은 신뢰 대역 주소로 본다 — 운영에서는 하지 않는다).
원본은 시험 CA 로 `SSL_verify_mode=1` 이었다.
- **확인하지 않은 것** — 운영 mailcow 의 마스터 계정 · Allowed protocols, 서버 B 의 길(`--network host` + `--add-host` 와 엔진이 보는 주소), 운영의 양 · 시간, 직원이 만든 실제 폴더 이름.
---