docs: 메일 16단계(개인 설정)를 계약 · 디자인 README · 스킬 · 로드맵에 적는다
CI / Backend (lint · type-check · test · e2e · build) (pull_request) Successful in 15m17s
CI / Frontend (lint · type-check · test · build) (pull_request) Successful in 14m10s
CI / Infra (compose · Dockerfile · nginx · 운영 스크립트) (pull_request) Successful in 2m13s
CI / 에러 코드 SSOT 동기화 검사 (pull_request) Successful in 11m54s
CI / Backend (lint · type-check · test · e2e · build) (push) Successful in 14m55s
CI / Frontend (lint · type-check · test · build) (push) Successful in 13m43s
CI / Infra (compose · Dockerfile · nginx · 운영 스크립트) (push) Successful in 2m2s
CI / 에러 코드 SSOT 동기화 검사 (push) Successful in 11m39s

계약 2절(서명 · 부재중 — 원본 판 3 과 되돌리기 계획) · 3절(가져오기 · 내보내기) · 5절(휴대폰 바꾸기),
디자인 README 16단계 절과 구현하며 정한 보완, nestjs · vue3 스킬 표, 로드맵의 추후 후보(16단계에서 미룬 것).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit was merged in pull request #24.
This commit is contained in:
2026-11-10 11:44:43 +09:00
parent 907287021f
commit 19dbb2babb
5 changed files with 490 additions and 42 deletions
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+248 -3
View File
@@ -337,7 +337,7 @@ API 와 서버 규칙은 계약 1절 "`PUT /api/auth/password`", 2절 "메일함
- [x] 부재중 자동 응답 사용(44). 끄면 아래 칸을 흐리게(쓴 글은 남긴다).
- 기간: (•) 끌 때까지 계속 / ( ) 기간 정하기 → 시작 · 끝 날짜 칸(44). "시작하는 날 0시부터 끝나는 날 밤 12시까지 답합니다. 시각은 이 PC의 시간으로 셉니다."
- 제목(44, 최대 폭 520) · 내용(여러 줄, 글자만 — "서식과 그림 없이 글자만 보내고, 서명은 붙지 않습니다.").
- 제목(44, 최대 폭 520) · 내용(여러 줄, 글자만 — "서식과 그림 없이 글자만 보내고, 서명은 붙지 않습니다."). — **16단계부터 내용 칸은 서식 편집기**(아래 16단계 절).
- [저장] → "저장했습니다. 10월 30일(금)부터 11월 3일(화)까지 자동으로 답합니다." / "끌 때까지 자동으로 답합니다." / 끄면 "자동 응답을 껐고, 써 둔 글은 그대로 남아 있습니다."
- 미리 보기 "보낸 사람이 받는 답장"(보낸 사람 · 제목 · 내용) + "같은 사람에게는 매번 보내지 않습니다."
- 켜져 있는 동안 메일 화면의 메일함 패널 아래(사용량 위)에 안내(바탕 #FFF7E6 · #7A5200, 높이 44 이상): "부재중 자동 응답 켜짐" / "11월 3일까지 · 설정" — 누르면 이 설정으로.
@@ -750,7 +750,7 @@ API 와 서버 규칙은 계약 2절 "실시간 알림".
- **꺼져 있을 때** 상자 아래:
- 갈색 경고 "켜면 메일 프로그램은 앱 비밀번호로 다시 연결해야 합니다" / "Outlook, 썬더버드, 휴대폰 메일 앱은 웹메일 비밀번호로 새로 연결할 수 없고, 이미 연결해 둔 프로그램도 다음에 다시 연결할 때 막힙니다. 켜기 전에 앱 비밀번호를 만들어 두면 편합니다."(앱 비밀번호 링크)
- 「켜는 방법」 1 · 2 · 3(청록 굵은 숫자 — 동그라미 없음): 인증 앱 설치(Google · Microsoft Authenticator 처럼 6자리 코드를 만드는 앱) → [2단계 인증 켜기]를 눌러 QR 찍기 → 코드와 지금 비밀번호를 넣으면 켜진다.
- **켜져 있을 때** 상자 아래: "휴대폰을 바꿀 때는 2단계 인증을 끈 뒤 새 휴대폰으로 다시 켜 주세요. 끌 때 지금 휴대폰의 코드가 필요합니다." · "휴대폰을 잃어버렸거나 인증 앱을 지웠으면 관리자에게 문의해 주세요." · "메일 프로그램은 앱 비밀번호로 연결합니다."
- **켜져 있을 때** 상자 아래: "휴대폰을 바꿀 때는 2단계 인증을 끈 뒤 새 휴대폰으로 다시 켜 주세요. 끌 때 지금 휴대폰의 코드가 필요합니다."(**16단계부터 [휴대폰 바꾸기] — 첫 줄은 16단계 절의 글**) · "휴대폰을 잃어버렸거나 인증 앱을 지웠으면 관리자에게 문의해 주세요." · "메일 프로그램은 앱 비밀번호로 연결합니다."
- **켜기 창**(모달 620 — `wide`, 필터 고치기 창과 같은 폭 · 처음 초점은 코드 칸)
- ① "인증 앱으로 QR 코드를 찍어 주세요" / "인증 앱에서 [+] → [QR 코드 스캔]을 고른 뒤 이 그림을 찍어 주세요." + QR 그림(160 — 실제 otpauth 주소는 오류 정정 M 에서 49칸이라 보드의 128 보다 키웠다)
- "찍을 수 없으면 이 키를 입력해 주세요" + 32자 키(4자씩 띄움, 고정폭) + [복사](36 — 「키를 복사했습니다.」) + "앱에는 ‘ARA SYSTEM (ara.kim@arasystem.it.kr)’로 저장됩니다."
@@ -894,7 +894,7 @@ API 와 서버 규칙은 계약 2절 "실시간 알림".
- 유예 중 · 꺼짐 — 머리 아래 갈색 상자(방패): **"11월 30일까지 2단계 인증을 켜 주세요"** / "회사 정책에 따라 그 뒤에는 2단계 인증을 켜야 로그인할 수 있습니다."
그 아래 상태 상자 · 경고 · 켜는 방법 · 켜기 창은 12단계 그대로.
- 켜짐 — 상태 상자의 [2단계 인증 끄기] 대신 회색 글 "회사 정책에 따라 끌 수 없습니다"(서버도 `VAL_001` 로 거절).
아래 안내의 첫 줄은 "휴대폰을 바꿀 때는 관리자에게 2단계 인증을 풀어 달라고 요청해 주세요. 다음에 로그인할 때 새 휴대폰으로 다시 켜면 됩니다."(나머지 두 줄은 12단계 그대로).
아래 안내의 첫 줄은 "휴대폰을 바꿀 때는 관리자에게 2단계 인증을 풀어 달라고 요청해 주세요. 다음에 로그인할 때 새 휴대폰으로 다시 켜면 됩니다."(**16단계부터 이 직원도 [휴대폰 바꾸기] — 첫 줄은 16단계 절의 글**)(나머지 두 줄은 12단계 그대로).
- **휴대폰을 바꿀 때**(필수인 직원): 관리자에게 「2단계 해제」를 요청한다(운영 도구 — 받은편지함 알림 메일이 간다) → 다음 로그인 때 이 켜기 단계로 새 휴대폰을 등록한다.
직원이 직접 하는 휴대폰 바꾸기는 미뤘다(아래).
@@ -1272,6 +1272,168 @@ PC 에서는 설정 › 계정 › 휴대폰 앱(A)이 QR 코드로 휴대폰에
메일함을 끌어 옮기기 · 순서 바꾸기(옮기기 창으로), 차단할 때 옮긴 메일의 한꺼번에 되돌리기, 예약됨 목록 줄의 단추, 옮기기 단축키, 휴대폰 앱의 [지금 보내기].
## 16단계 — 개인 설정 (2026-11-10 확정 — `Contacts16` · `Signature16` · `Settings16`)
직원이 혼자 할 수 있는 설정을 넓힌다 — 주소록 가져오기 · 내보내기, 여러 서명과 쓰기 화면 [서명 ▾], 부재중 응답의 서식 본문과 회사 밖 응답, 2단계 인증 휴대폰 바꾸기.
서버 약속은 계약 2절("서명" · "부재중 자동 응답") · 3절(주소록) · 5절("2단계 인증")이다. 글의 원본은 화면 상수(`shared/constants/contacts.ts` · `mail-signature.ts` · `mail-vacation.ts` · `account-security.ts`)이고 아래는 옮겨 적은 것이다.
새 크기 토큰 · 밀도 예외는 없다 — [사내 | 회사 밖] 미리 보기 전환 36 은 서명 미리 보기의 `.segmented`(`--size-segment-item-lg`)를 다시 쓴다. 새 아이콘은 upload 하나다. 문구는 위 「화면 문구 — 사용자에게 말하듯」을 따른다.
### 주소록 가져오기 · 내보내기 (`Contacts16`)
다른 메일이나 휴대폰에서 내보낸 연락처 파일을 주소록에 넣고, 내 주소록을 파일로 내려받는다. 서버 약속은 계약 3절("가져오기 · 내보내기").
새 크기 토큰 · 밀도 예외는 없다(머리 52 · 단추 44 그대로). 새 아이콘은 올리기(`upload`) 하나다.
- **자리**: 「전체 연락처」 목록 머리 오른쪽에 [가져오기] · [내보내기](도구 단추 — 그룹 머리의 [구성원 넣기]와 같은 꼴). 그룹 · 사내 목록의 머리는 그대로다(그룹 하나만 내보낼 때는 내보내기 창에서 고른다).
- 연락처가 하나도 없으면 [내보내기]를 끄고(`aria-disabled`) 까닭 "내보낼 연락처가 없습니다."(마우스를 올리면 뜨는 글 · 화면 낭독).
- 연락처가 3,000개면 [가져오기]도 끄고 패널의 3,000개 안내를 까닭으로 쓴다. 가져오는 동안(되돌리기 포함)은 [가져오기]가 바쁜 표시(`aria-busy`)이고 눌러도 아무 일이 없다.
- 빈 주소록 안내: [연락처 추가] 옆에 테두리 단추 [가져오기], 그 아래 "다른 메일이나 휴대폰에서 내보낸 연락처 파일이 있으면 [가져오기]로 한 번에 넣을 수 있습니다." + 지금 줄("메일을 읽다가 …").
- **가져오기 창 ① 파일 고르기**(넓은 창 620): 제목 "연락처 가져오기" · "네이버, Google, Outlook 같은 메일이나 휴대폰에서 내보낸 연락처 파일을 그대로 넣을 수 있습니다."
- 놓는 자리(168 · 회색 점선): 올리기 아이콘 · [파일 고르기](처음 초점 — .vcf · .csv · .xlsx 만 보이게 걸러 둔 파일 고르는 창) · "파일을 여기로 끌어다 놓아도 됩니다."
- 창 어디에 끌어다 놓아도 받는다 — 끄는 동안 자리가 2px 청록 점선 · 옅은 청록 바탕, "놓으면 이 파일에서 연락처를 찾습니다". 창 밖 주소록 화면에 놓은 파일은 지금처럼 무시한다.
- 읽는 동안: 도는 아이콘 · "파일을 읽는 중…" · 파일 이름. 그사이 [취소]는 읽기를 멈추고 창을 닫는다.
화면 낭독기에는 창을 열 때부터 있는 상태 영역(`role=status` — 글만 바꾼다)이 같은 글을 읽는다(글과 함께 생기는 영역은 읽지 않기도 한다).
읽다가 뜻밖에 멈추면(깨진 파일 등) "읽는 중"에 머물지 않고 "열지 못했습니다" 글로 돌아온다.
- 아래 "vCard(.vcf), CSV(.csv), 엑셀(.xlsx) 파일을 넣을 수 있습니다. 파일 하나는 20MB까지입니다." · [취소].
- 읽지 못하면 자리 아래 빨간 글로만(알림 없음) — 다른 파일을 고르거나 놓으면 지운다:
- 넣을 수 없는 형식 "넣을 수 없는 파일입니다. vCard(.vcf), CSV(.csv), 엑셀(.xlsx) 파일을 골라 주세요."
- 옛 엑셀 "옛 엑셀 파일(.xls)은 넣을 수 없습니다. 엑셀에서 [다른 이름으로 저장]을 눌러 .xlsx나 CSV 파일로 저장한 뒤 넣어 주세요."
- 20MB 넘음 "파일이 너무 큽니다. 파일 하나는 20MB까지 넣을 수 있습니다."(읽지 않고 바로)
- 연락처를 찾지 못함 "이 파일에서 연락처를 찾지 못했습니다. 이름이나 이메일이 든 열이 있는지 확인해 주세요."(vCard 는 앞 문장만)
- 열지 못함 "이 파일을 열지 못했습니다. 암호를 건 엑셀 파일이면 암호를 푼 뒤 넣어 주세요."
- 여러 파일 "파일은 한 번에 하나만 넣을 수 있습니다."
- 형식은 이름 끝과 파일 앞부분을 함께 본다 — 이름이 .csv 여도 안이 엑셀이면 엑셀로, 안이 옛 엑셀(.xls)이면 .xls 글.
- **가져오기 창 ② 미리 보기**: 파일 이름 + [다른 파일 고르기](파일 고르는 창을 바로 연다 — 고른 그룹과 체크 칸은 그대로) · "연락처 340명을 찾았습니다."(넣지 못하는 줄까지 센 수).
- 세 묶음(테두리 상자 — 글과 숫자로만, 뱃지 없음):
- "넣을 연락처 · 330명" — 앞 6명의 표(이름 64 · 이메일 · 전화 96 · 회사 · 그룹 76) + "그 밖에 324명".
- "이미 있는 연락처는 건너뜁니다 · 8명" — 눌러 펼친다(`aria-expanded`). 줄마다 "4번째 줄 · 박지훈 jihoon.park@example.com · 주소록에 있음"(또는 "2번째 줄과 같음").
- "넣지 못하는 줄 · 2개" — 눌러 펼친다. "57번째 줄 · 010-9034-6621 · 이름과 이메일이 모두 없습니다."(까닭은 갈색).
- 펼친 목록은 6줄 높이(186)까지 늘고 넘으면 목록 안에서 넘긴다. 줄 번호는 엑셀에서 보이는 행 번호(머리 줄이 1번째 줄), vCard 는 "12번째 연락처"이고 묶음 이름이 "넣지 못하는 연락처"다.
- 그룹: "파일에 적힌 그룹 4개 가운데 거래처, 세무는 이미 있는 그룹에 넣고 동호회, 학교는 새로 만듭니다."(이름이 많으면 앞 셋과 "외 N개", 없으면 "파일에 적힌 그룹이 없습니다.").
- 「가져온 연락처를 모두 넣을 그룹」: 넣지 않음(처음) · 이미 있는 그룹(고르기 칸 280×44 — "거래처 · 4명") · 새 그룹(이름 칸 280×44 · 50자) + "파일에 적힌 그룹에 더해 넣습니다."
새 그룹 이름이 이미 있는 그룹과 같으면 그 그룹에 넣는다. 이름을 비운 채 넣으려 하면 칸 아래 "그룹 이름을 넣어 주세요." 그룹이 100개면 「새 그룹」을 끄고 "그룹은 100개까지 만들 수 있습니다."
- 체크 칸 「주소, 생일, 홈페이지, 별명처럼 주소록에 칸이 없는 정보는 메모 끝에 붙이기」(처음 켜짐) + "메모가 1,000자를 넘게 되면 붙이지 않습니다." —
켜 두면 견본 "예: 강민지의 메모"(원래 메모는 흐리게 · 빈 줄 · "가져온 정보" · "회사 주소: 서울 중구 세종대로 110" · "생일: 1988년 3월 14일 (음력)" · "홈페이지: …" · "별명: …").
- 남은 자리보다 많으면 노란 상자 "연락처는 3,000개까지 둘 수 있어 앞에서부터 188명만 넣습니다. 나머지 142명은 넣지 않습니다." — 단추의 수도 그만큼.
- [연락처 330명 넣기](처음 초점) [취소]. 넣을 연락처가 없으면 [연락처 넣기]를 끄고(`aria-disabled`) 단추 왼쪽에 "넣을 연락처가 없습니다."
- Esc · [취소]는 묻지 않고 닫는다(아직 아무것도 넣지 않았다). 창이 화면보다 길면 창 안이 스크롤하고 단추 줄은 고정하지 않는다(필터 창과 같다).
- **넣는 동안 · 끝난 뒤**(알림 — 필터 적용 · 차단 정리와 같은 결): [연락처 N명 넣기]를 누르면 창을 닫고 진행 알림 "연락처를 넣는 중 · 120 / 330명"(넣은 수 / 넣을 수 — 아래 「요청 묶음」 하나가 끝날 때마다 고친다).
- 끝: 같은 자리에서 "연락처 330명을 넣었습니다. 이미 있던 8명은 건너뛰었습니다." + [되돌리기] + ✕ — **✕로 닫을 때까지 남는다**(저절로 닫히지 않는다).
건너뛴 수 · 한도에 걸린 수는 한 문장씩 덧붙인다 — "연락처는 3,000개까지 둘 수 있어 나머지 142명은 넣지 못했습니다." · "그룹은 100개, 그룹 하나에는 500명까지 둘 수 있어 24명은 그룹에 넣지 못했습니다."
- [되돌리기]: "가져온 연락처를 지우는 중 · 100 / 330명" → "가져온 연락처 330명과 새로 만든 그룹 2개를 지웠습니다."(새 그룹이 없으면 "가져온 연락처 330명을 지웠습니다.").
이번에 넣은 연락처와 이번에 새로 만든 그룹만 지운다 — 원래 있던 그룹에 넣은 것은 연락처를 지우며 함께 빠진다. 되돌리다 멈추면 "가져온 연락처 330명 가운데 100명을 지우고 멈췄습니다. 다시 시도해 주세요." + [다시 시도].
- 멈춤: 흰 오류 알림 "연락처 330명 가운데 120명을 넣고 멈췄습니다. 다시 시도해 주세요." + [다시 시도](남은 연락처부터 — 줄마다 화면이 붙인 표시(uid)가 있어 두 번 들어가지 않는다),
처음부터 넣지 못했으면 "연락처를 넣지 못했습니다. 다시 시도해 주세요." 멈춘 알림에는 [되돌리기]가 없다 — 다시 시도해 끝낸 뒤의 [되돌리기]가 이번에 넣은 연락처를 모두 지운다.
알려진 한계: 멈춘 알림을 다시 시도하지 않고 닫으면 그때까지 넣은 연락처는 남는다(같은 파일을 다시 넣으면 이미 있는 연락처로 건너뛴다).
- 넣는 동안 다른 화면으로 가도 이어 넣는다. 탭을 닫거나 로그아웃하면 멈추고 알림을 닫는다. 끝나거나 멈추면 연락처 수 · 그룹 목록 · 보고 있는 목록을 다시 받는다.
실패 까닭(연결 · 서버)은 지금처럼 오류 사전의 글로 알리고 진행 알림은 결과로만 바뀐다.
- **내보내기 창**(400 — 새 그룹 창과 같은 폭): 제목 "연락처 내보내기".
- 「내보낼 연락처」 내 주소록 전체("연락처 342명" — 처음) · 그룹 하나("고른 그룹의 구성원만" — 고르면 그룹 고르기 칸 44, 첫 그룹이 골라진다, "거래처 · 24명") + "내 주소록의 연락처만 내보내고 사내 목록은 넣지 않습니다."
- 「파일 형식」 vCard (.vcf) "다른 메일이나 휴대폰으로 옮길 때"(처음) · CSV (.csv) "엑셀에서 열어 볼 때". 고른 상자는 청록 테두리 · 옅은 청록 바탕.
- "파일 이름 주소록-2026-11-10.vcf"(그룹이면 "주소록-거래처-2026-11-10.csv" — 날짜는 이 PC 의 날짜, 파일 이름에 쓸 수 없는 글자는 _).
- 구성원이 없는 그룹을 고르면 [내보내기]를 끄고(`aria-disabled`) 그룹 칸 아래 "‘협력사 담당’ 그룹에는 아직 아무도 없어 내보낼 연락처가 없습니다." — 다른 그룹을 고르면 바로 돌아온다.
- [내보내기] [취소] — 누르면 창을 닫고 바로 내려받는다(첨부 받기처럼 이동 직전에 로그인을 확인, 파일은 서버가 만든다). 따로 알림은 띄우지 않는다.
- **받는 파일 · 칸 맞추기**: vCard 2.1 · 3.0 · 4.0(한 파일에 여러 명), CSV(네이버 · Google 새 · 옛 머리 · Outlook · 우리가 내보낸 꼴을 머리 줄로 알아보고, 모르는 꼴은 열 이름으로 짐작), 엑셀 .xlsx(첫 시트 · 첫 줄이 머리).
CSV 글자는 BOM, UTF-8, 아니면 EUC-KR(한국어 엑셀의 CSV) 차례. 성 + 이름은 이름 하나로(한글끼리는 붙여 쓴다 — 강 + 민지 = 강민지), 이메일과 전화는 종류대로(팩스와 직접 입력한 번호는 기타),
전화는 하이픈을 넣는다(010-1234-5678). 이미 있는 연락처는 같은 이메일(앞뒤 공백 · 대소문자 무시), 이메일이 없으면 이름과 전화번호가 모두 같을 때다.
### 설정 › 메일 › 서명 — 여러 서명 (`Signature16` 1~5)
서버 약속은 계약 2절 "서명"(16단계 — `/api/mail-signatures` · `/api/mail-signature-defaults`)이다. 글의 원본은 화면 상수(`shared/constants/mail-signature.ts`)이고 아래는 옮겨 적은 것이다.
새 크기 토큰 · 밀도 예외는 없다 — 목록 줄 52 는 `--size-header-min`, 칸 · 단추 44 는 보통 크기다.
- **배치**: 머리(제목 "서명" · 설명) → 「메일에 넣을 서명」(서명이 있을 때만) → 왼쪽 「내 서명」 목록(208) · 오른쪽 고른 서명 고치기(이름 · 서명 칸 · 안내 · [저장] … [이 서명 지우기] · 가는 선 · 미리 보기). 콘텐츠 열은 다른 설정 화면과 같은 800.
목록과 고치는 칸을 담은 칸이 640보다 좁으면 목록을 위로 올려 칸 폭을 쓰게 하고 고치는 칸을 그 아래에 둔다 — 창 폭이 아니라 그 칸의 폭을 본다(컨테이너 쿼리).
설명은 "보내는 메일 끝에 넣는 글입니다. 여러 개 만들어 두면 메일을 쓸 때 [서명]에서 골라 바꿀 수 있고, 그 메일에서만 고치거나 지울 수도 있습니다."
- **목록**: 만든 차례 · 줄 52 · 고른 줄은 메일함 패널의 지금 메일함처럼 청록 바탕 · 굵은 글자(aria-current). 이름 아래 회색 12 는 그 서명을 기본으로 넣는 메일("새 메일" · "답장과 전달" · "새 메일, 답장과 전달" — 뱃지 없음).
목록 아래 [새 서명](글자 단추 44) · "서명 3개를 만들었고, 10개까지 만들 수 있습니다." — 10개면 [새 서명]은 누를 수 없고(aria-disabled — 초점은 받는다) 글이
"서명은 10개까지 만들 수 있습니다. 쓰지 않는 서명을 지워 주세요."로 바뀐다(단추가 aria-describedby 로 가리킨다). 화면 낭독기는 목록을 「내 서명」으로, 고른 줄을 지금 항목으로 읽는다.
- **처음 고른 서명**: 새 메일에 넣는 서명, 새 메일이 넣지 않음이면 첫 서명.
- **메일에 넣을 서명**(지금의 두 체크를 대신한다): 칸 이름 84 · 고르기 240(서명 이름들 · 넣지 않음) · 아래 "메일을 쓰기 시작할 때 본문에 넣어 둡니다. 고르는 대로 바로 저장됩니다."
고르는 대로 저장하고, 끝나면 고른 칸 오른쪽에 초록 ✓ "저장했습니다"(role=status) — 다시 고르면 내린다. 저장은 한 번에 하나(보내기 · 읽기 설정과 같은 결 — 보내는 동안 고른 것은 끝난 뒤 다르면 보낸다),
끝내 저장하지 못하면 고르기 전 값으로 되돌리고 오류 알림. 고른 것은 나가기 확인의 까닭이 아니다.
- **이름**: 칸 320(좁으면 칸 폭) · 30자에서 더 들어가지 않는다 · 아래 "메일을 쓸 때 [서명]에서 고르는 이름입니다. 30자까지 쓸 수 있습니다."
[저장]할 때 비었으면(앞뒤 공백을 뺀다) "이름을 넣어 주세요.", 다른 서명과 같으면(영문 대소문자 무시) "같은 이름의 서명이 있습니다." — 안내 대신 빨간 글 · 칸 빨간 테두리(aria-invalid) · 이름 칸에 초점, 알림은 없다.
이름을 고치면 안내로 돌아간다. 저장하면 이름은 앞뒤 공백을 뺀 글로 바뀐다.
- **[저장] 뒤 글**: 기본으로 넣는 서명이면 지금 글 "저장했습니다. 다음에 메일을 쓸 때부터 들어갑니다.", 아니면 "저장했습니다." — 무엇이든 다시 바꾸면 내린다.
- **편집기 · 그림 · 미리 보기**는 지금 서명 화면 그대로(그림 서명마다 3장 · 크기 줄 · 화면 어디에 놓은 그림은 지금 고른 서명에). 미리 보기는 고른 서명이 [새 메일 | 답장]에 들어갈 자리를 늘 보인다 —
아래 글 "새 메일은 본문 끝에 서명을 넣습니다." · "답장과 전달에는 인용한 원문 위에 서명을 넣습니다."(그 메일에 넣을지는 「메일에 넣을 서명」이 정한다).
- **서명이 없을 때**(2): 점선 상자 안 펜 아이콘 36 · "아직 서명이 없습니다" · "보내는 메일 끝에 넣을 이름, 소속, 연락처를 만들어 두면 메일을 쓸 때마다 본문에 넣어 둡니다. 사내용과 대외용처럼 여러 개를 만들어 골라 쓸 수도 있습니다."(최대 400) ·
[새 서명](채운 단추). 「메일에 넣을 서명」 칸과 목록은 없다(고를 서명이 없다). 지금 서명을 쓰던 직원은 서버가 그 서명을 「기본 서명」으로 옮겨 두어 1의 모습으로 시작한다.
- **새 서명**(3): 목록 끝에 "새 서명 / 저장하지 않음" 줄 · 빈 이름 칸(안 글 "예: 사내, 대외, 영문")에 초점 · 빈 서명 칸 · [저장] [취소]. 처음 [저장]에 만들고 그 뒤로는 같은 칸에서 고친다(쓰던 자리 · 실행 취소 기록이 남는다).
첫 서명이면 두 곳의 기본이 그 서명이 된다(서버가 정한다). 그 밖의 새 서명은 기본을 바꾸지 않는다. [취소]는 새 줄을 빼고 전에 고른 서명을 연다(서명이 없었으면 2의 화면). 새 서명을 연 채 [새 서명]을 누르면 이름 칸으로 간다.
- **지우기**(4): [이 서명 지우기](빨간 글자 단추 · [저장]과 떨어뜨려 오른쪽 끝) → 확인 창(폭 360 · 처음 초점 [취소] · Esc 도 [취소]) "‘대외’ 서명을 지울까요?" +
기본이던 곳마다 다른 글(새 메일 · 답장과 전달 · 둘 다 · 기본 아님 "지운 서명은 되돌릴 수 없습니다.") · [지우기](빨강 채움) [취소].
지우면 알림 "‘영문’ 서명을 지웠습니다." · 목록의 첫 서명을 연다(초점은 그 줄) — 마지막 서명이면 2의 화면(초점은 [새 서명]). 그 서명을 기본으로 넣던 곳은 넣지 않음이 되고 다른 서명이 저절로 기본이 되지 않는다.
- **저장하지 않은 것이 있을 때**(5): 다른 서명을 고르면 "다른 서명을 고르면 ‘대외’ 서명에서 바꾼 내용이 사라집니다.", [새 서명]이면 "새 서명을 만들면 ‘대외’ 서명에서 바꾼 내용이 사라집니다."
— 제목 "저장하지 않은 서명이 있습니다" · [계속 고치기](처음 초점 · Esc) [바꾼 내용 버리기]. 바꾼 것이 없으면 묻지 않는다.
다른 화면으로 갈 때는 지금 나가기 확인 그대로("이 화면을 나가면 바꾼 서명이 사라집니다." · [계속 고치기] [나가기] — 저장하는 중 · 세션이 끝났을 때는 묻지 않고, 탭을 닫으면 브라우저가 묻는다).
### 쓰기 화면 [서명 ▾] (`Signature16` 6)
- **자리**: 아래 단추 줄 [보내기 ▾] [임시 저장] **[서명 ▾]** — 서명은 서식이 아니라 메일 전체에 붙는 것이라 편집기 툴바(좁은 폭에서 접는 차례)에 넣지 않았다. [임시 저장]과 같은 글자 단추(44)에 ▾ 12.
화면 낭독은 툴바의 글꼴 단추처럼 지금 서명까지 — "서명: 대외" · "서명: 넣지 않음"(본문의 서명 자리가 어느 서명과도 같지 않으면 "서명"만). 편집기를 불러오는 중 · 보내는 중에는 누를 수 없다.
- **메뉴**(편집기 메뉴 — 항목 40 · 폭 220, 긴 이름은 끝을 …): 서명(만든 차례 · 지금 본문에 든 서명에 ✓ · "기본" 표시 없음) · 넣지 않음 · 가는 선 · 서명 관리. 서명이 없으면 넣지 않음과 서명 관리만.
키보드는 [보내기 ▾]와 같다 — Enter · Space · 아래 화살표로 열면 첫 항목에 초점(마우스로 열면 메뉴 자체), Esc 는 메뉴만 닫고 [서명 ▾]로. 아래 자리가 모자라면 위로 연다(아래 줄에서는 보통 위).
- **고르면** 본문의 서명 자리만 그 서명으로 바뀐다 — 쓴 글 · 인용 · 첨부는 그대로이고, 서명 안을 고쳤다면 고친 것도 새 서명으로 바뀐다. 실행 취소(Ctrl+Z)로 되돌린다. 바꾸면 화면 낭독기에 "서명을 ‘사내’로 바꿨습니다."(알림 없음).
넣지 않음은 서명 자리와 그 앞의 빈 줄을 뺀다. 서명 자리를 지운 뒤 서명을 고르면 처음 넣는 자리(새 메일은 쓴 글 끝, 답장과 전달은 인용 위)에 빈 줄 하나 뒤로 넣는다.
- **처음 넣는 서명**: 새 메일은 「새 메일」 기본, 답장 · 전달은 「답장과 전달」 기본(넣지 않음이면 넣지 않는다). 이어 쓰는 임시 저장본에는 다시 넣지 않는다 — 서명 자리의 글이 저장한 서명과 같으면 그 서명에 ✓, 다르면 ✓ 없이.
- **서명 관리**: 설정 › 메일 › 서명으로 — 지금 나가기 규칙대로 먼저 임시 저장하고 저장하지 못했을 때만 묻는다.
- 받는 사람이 회사 밖이어도 저절로 바꾸지 않는다(처음 판 — 직원이 고르는 대로).
### 설정 › 메일 › 부재중 자동 응답 — 서식 본문 · 회사 밖 응답 (`Settings16` 1~4)
- 사용 · 기간 · 제목 칸 · [저장] · 저장한 뒤의 글은 4단계 그대로다.
- **내용 칸은 서명 칸과 같은 편집기**(지연 로딩 — 못 받으면 서명 설정과 같은 사전 문구 + [다시 시도]): 툴바 52 · 굵게 · 기울임 · 밑줄 | 글자 색 · 링크(단추 40×44 — 그림 단추 없음, 붙여 넣은 그림 · 파일은 받지 않는다) ·
본문 맑은 고딕 14 · 줄 간격 1.75 · 높이 150. 툴바의 화면 낭독 이름 "자동 응답 서식". 칸 아래 안내 "굵게, 기울임, 밑줄, 글자 색, 링크를 쓸 수 있습니다. 그림은 넣을 수 없고, 서명은 붙지 않습니다."
- 글자 수는 칸 이름 오른쪽 "132 / 600"(12 · 회색 — 주소록 메모와 같은 자리). 서식을 뺀 보이는 글로 센다(받는 쪽의 글자 본문과 같은 글 — 링크는 "글 (주소)"). 켜 둔 채 600자를 넘으면 굵게 빨갛게.
- 서식(굵게 · 기울임 · 밑줄 · 글자 색 · 링크)을 쓴 칸만 받는 사람에게 서식 본문과 글자 본문을 함께 보내고, 글만 쓴 칸은 글자 본문만 보낸다(서식 본문 null — 사내 · 회사 밖 칸 따로, 끄면서 하는 저장도 같다).
서버는 서식 본문 · 회사 밖 응답을 쓴 직원만 새 원본 판으로 저장한다 — 글만 쓰는 직원은 옛 판 그대로라 되돌리기 계획의 전제가 지켜진다(16단계 리뷰).
16단계 전에 저장한 글만 있는 응답은 줄마다 문단으로 열린다(빈 줄은 빈 문단).
- **회사 밖에서 온 메일**(내용 칸과 [저장] 사이 · fieldset): "보낸 사람 주소가 회사 메일 주소(@arasystem.it.kr)가 아닌 메일입니다."(도메인은 내 주소의 것) + 고르기 셋 —
보내기 · 읽기 설정의 외부 이미지 칸과 같은 모양(라디오 18 · 이름 굵게 · 아래 설명 12.5 · 줄 44):
사내와 같은 응답("회사 안팎 모두 위의 제목과 내용으로 답합니다." — 처음 값) · 보내지 않기("회사 밖에서 온 메일에는 자동으로 답하지 않습니다.") · 따로 쓰기("회사 밖에서 온 메일에 보낼 제목과 내용을 따로 씁니다.").
칸이 열리는 따로 쓰기가 맨 끝이다.
- 따로 쓰기를 고르면 바로 아래(고르기 글자 선 28 에 맞춰 들여 씀 · 사이 18)에 "회사 밖에 보낼 제목"(최대 폭 520 · 150자) · "회사 밖에 보낼 내용"(같은 편집기 · 툴바 이름 "회사 밖 자동 응답 서식" · 같은 글자 수 · 아래 안내 없음).
위의 제목 · 내용은 이때부터 사내에서 온 메일에만 간다.
- 다른 고르기로 바꾸면 두 칸을 닫지만 써 둔 글은 남겨 두고 함께 저장한다 — 다시 따로 쓰기를 고르면 그대로 나온다.
- 자동 응답을 끄면 이 묶음도 흐려진다(고르기 글 회색 · 칸은 다른 칸처럼).
- **미리 보기**: 머리 오른쪽 전환 [사내 | 회사 밖](36 · 화면 낭독 이름 "미리 볼 답장" — 처음은 사내 쪽, 사내와 같은 응답일 때도 둔다). 칸을 고친 값을 받는 사람이 보는 서식 그대로 바로 그린다.
아래 글: 사내와 같은 응답 — 사내 쪽 "받은 메일의 보낸 사람에게 위 글로 답합니다. 같은 사람에게는 매번 보내지 않습니다."(지금 글) · 회사 밖 쪽 "회사 밖에서 온 메일에도 같은 글로 답합니다. …" /
보내지 않기 · 따로 쓰기 — 사내 쪽 "사내에서 온 메일에 위 글로 답합니다. …" · 따로 쓰기의 회사 밖 쪽 "회사 밖에서 온 메일에 위 글로 답합니다. …"(제목 · 내용은 회사 밖에 보낼 것) /
보내지 않기의 회사 밖 쪽은 답장 대신 회색 상자(최소 높이 120) "회사 밖에서 온 메일에는 자동으로 답하지 않습니다." · 아래 글 없음 / 꺼 두었으면 두 쪽 모두 "꺼져 있어 자동으로 답하지 않습니다."
- **켤 때 검사**(켠 채 [저장]일 때만 — 꺼 둔 채로는 빈 칸 · 긴 글도 저장한다): 보는 차례 기간 · 제목 · 내용 · 회사 밖에 보낼 제목 · 회사 밖에 보낼 내용, 첫 칸으로 초점. 알림은 띄우지 않는다.
- 빈 칸은 사전 문구(`VAL_001`). 내용이 600자를 넘으면 칸 아래 "내용은 600자까지 쓸 수 있습니다." + 빨간 글자 수(회사 밖에 보낼 내용 칸도 같은 글).
- 글은 600자 안인데 서식이 많아 서식 본문이 10,000자를 넘으면 사전 문구(`VAL_001` — 서명과 같은 한도).
- 편집기 칸은 빨간 테두리와 aria-invalid(설명은 오류 → 안내 차례). 그 칸을 고치기 시작하면 아래 글이 사라진다.
- 회사 밖 두 칸은 따로 쓰기일 때만 본다 — 사내와 같은 응답 · 보내지 않기면 비어 있어도 저장한다. 서버가 거절하면 지금처럼 [저장] 옆에 사전 문구.
- 서버가 `MAIL_013`(필터 · 차단 · 부재중을 담는 메일 규칙이 한도를 넘음)으로 거절하면 같은 자리에 "자동 응답이 너무 깁니다. 서식이나 내용을 줄여 주세요."(알림 없음) —
부재중은 서식 본문이 두 번 들어가 필터가 없어도 넘을 수 있어 사전 문구("필터나 차단 목록이 …") 대신 이 글이다(16단계 리뷰). 저장하는 사이 화면을 떠났으면 같은 글을 알림으로.
### 설정 › 계정 › 2단계 인증 — 휴대폰 바꾸기 (`Settings16` 5~8)
- **켜져 있을 때** 상태 상자의 [2단계 인증 끄기] 바로 앞에 [휴대폰 바꾸기](테두리 44 · 아이콘 없음). 회사 정책으로 끌 수 없는 직원도 "회사 정책에 따라 끌 수 없습니다" 앞 같은 자리에 있다. 꺼져 있을 때는 없다(켜기 창으로).
아래 안내의 첫 줄은 두 직원 모두 "휴대폰을 바꿀 때는 [휴대폰 바꾸기]를 눌러 주세요. 새 휴대폰과 지금 휴대폰의 코드가 모두 필요합니다."(둘째 · 셋째 줄은 그대로).
- **바꾸기 창**(모달 620 — 켜기 창과 같은 폭) "2단계 인증 휴대폰 바꾸기" · 제목 아래 차례 "새 휴대폰 등록 › 지금 휴대폰으로 확인"(12.5 · 글로만 — 지금 걸음 청록 굵게 · 마친 걸음 앞 초록 체크 · 남은 걸음 회색, 뱃지 · 동그라미 숫자 없음).
- **① 새 휴대폰 등록**: "새 휴대폰을 등록하고 지금 휴대폰으로 확인하면 바뀝니다. 바꾸기를 마칠 때까지는 지금 휴대폰의 코드로 로그인합니다." →
1 "새 휴대폰의 인증 앱으로 QR 코드를 찍어 주세요"(QR 160 · 키 4자씩 두 줄 · [복사] 36 — 켜기 창과 같은 부품) + "바꾸지 않고 닫으면 이 키는 더 쓸 수 없습니다. 새 휴대폰의 인증 앱에 먼저 추가했다면 앱에서 그 계정을 지워 주세요." /
2 "새 휴대폰의 앱에 표시된 6자리 코드" → [다음] [취소]. 처음 초점은 코드 칸. 키는 창을 열 때마다 새로 만들고 QR · 키는 이 창 안에만 두었다가 닫으면 지운다.
[다음]은 새 휴대폰의 코드부터 확인한다(확인하는 동안 "확인하는 중…") — 맞으면 ②. 그동안 2단계 인증은 지금 휴대폰으로 그대로 켜져 있다.
- **② 지금 휴대폰으로 확인**: "새 휴대폰을 확인했습니다. 지금 휴대폰의 코드와 비밀번호를 넣으면 바뀌고, 그 뒤로는 새 휴대폰의 코드로만 로그인합니다." →
"지금 휴대폰의 앱에 표시된 6자리 코드"(처음 초점) · "지금 비밀번호"(최대 320 · 보기 단추) → 위에 선을 둔 문의 줄 "지금 휴대폰을 쓸 수 없나요? 관리자에게 2단계 인증을 풀어 달라고 요청한 뒤, 새 휴대폰으로 다시 켜 주세요."
(로그인 화면의 문의 줄과 같은 꼴 — 연락처가 있으면 다음 줄에) → [이전](왼쪽 · ‹) · [바꾸기] [취소]. 바꾸는 동안 "바꾸는 중…" · [바꾸기] · [이전] aria-disabled.
[이전]은 ①로(QR · 키는 그대로 · 새 휴대폰의 코드 칸은 비운다 — 같은 코드는 2분 동안 다시 받지 않으니 앱의 다음 코드를 넣는다).
- **틀렸을 때**(알림 없이 그 칸 아래 사전 문구 · 그 칸에 초점 · 틀린 코드는 지운다): ① 새 휴대폰의 코드 `AUTH_006` / ② 지금 비밀번호 `AUTH_005` · 지금 휴대폰의 코드 `AUTH_006`(서버가 비밀번호를 먼저 보고 한 칸만 알린다).
보내기 전에 6자리가 아니면 `AUTH_007` 문구, 비밀번호가 비었으면 `VAL_001` 문구(칸 차례 — 코드 → 비밀번호). 시도가 너무 많으면 `REQ_001` 알림.
- **10분이 지남 · 더 새 등록**(`AUTH_008` — ① · ② 어느 쪽이든): ①로 돌아와 창 위 빨간 상자에 사전 문구, 새 QR · 키로 다시 그리고 코드 칸을 비운다. 그사이 다른 곳에서 2단계 인증이 꺼졌으면 창을 닫고 사전 문구 알림(화면은 「꺼짐」).
- 세션이 바뀌면(로그아웃 · 다른 기기의 [다른 곳 모두 로그아웃]) 키 · 칸을 지우고 창을 닫는다(켜기 창과 같다).
- **바꾼 뒤**: 창을 닫고 단추 없는 알림 "2단계 인증 휴대폰을 바꿨습니다. 이제 새 휴대폰의 코드로 로그인합니다." · 초점은 [휴대폰 바꾸기]. 화면 · 켠 때(「2026년 10월 31일부터 켜짐」)는 그대로다.
[취소] · Esc 로 닫아도 서버가 이미 바꿨으면 화면을 맞추고 같은 알림을 띄운다. 로그인된 기기는 끊지 않고 알림 메일도 보내지 않는다(켜기 · 끄기와 같다).
### 처음 판에서 미룬 것
주소록: 칸 늘리기(주소 · 생일 · 홈페이지 · 별명 · 사진), 겹친 연락처 합치기, 모르는 CSV 의 열을 손으로 맞추기, 옛 엑셀(.xls), 여러 연락처 고르기 · 한꺼번에 지우기, 사내 목록 내보내기.
서명: 받는 사람에 따라 저절로 바꾸기, 휴대폰 앱에서 서명 고르기. 부재중: 「내 연락처에게만」, 그림 넣기. 2단계 인증: 복구 코드, 바꿀 때 알림 메일 · 계정 변경 기록.
## 구현하며 정한 보완 (2026-10-26 — 캔버스에는 아직 없다)
- 찾지 못한 첨부(보관 시간이 지난 업로드·지워진 원본)는 실패로 표시하고, 전달 원본에는 "빼기"만 둔다(다시 올릴 수 없다).
@@ -1754,3 +1916,86 @@ PC 에서는 설정 › 계정 › 휴대폰 앱(A)이 QR 코드로 휴대폰에
- 받는 사이 화면 밖으로 나갔으면 읽음으로 바꾸지 않고, 다시 들어올 때 읽음이다(읽음이 실패해도 드나들 때마다 다시 묻지 않는다).
- 화면에 들어왔는지 알 수 없는 브라우저(IntersectionObserver 없음)는 펼친 줄이 생기면 들어온 것으로 본다 — 그래도 하나씩 받는다.
- 펼칠 줄이 없으면(지금 메일만 받고 나머지를 받는 중) 단추가 없다. 그사이 지워진 메일(본문이 404)의 줄에는 머리 [답장]도 없다(아래 [답장]과 같다).
### 16단계 구현하며 정한 보완 — 주소록 (2026-11-10 — 캔버스에는 아직 없다)
- **파일은 이 브라우저에서 읽는다**(`frontend/src/shared/utils/contact-import/` — 순수 함수 · 새 의존성 없음): 글자 `text.ts` · CSV `csv.ts` · vCard `vcard.ts` · zip `zip.ts` · 엑셀 `xlsx.ts` · 머리 줄 → 칸 `table.ts` ·
값 다듬기 `values.ts` · 넣을 · 이미 있는 · 넣지 못하는 `records.ts` · 요청 묶음 `batches.ts` · 형식 가르기 `read-file.ts`.
모두 입력 길이 · 줄 수에 비례하게 읽는다(2026-11-10 리뷰 — 조각은 배열에 모아 한 번에 잇고, 큰 배열을 `push(...)` 로 펼쳐 넘기지 않고, 구분자 둘레 빈칸은 정규식이 아니라 나눈 뒤 trim).
- 엑셀은 zip 가운데 목록 → `DecompressionStream('deflate-raw')` → `DOMParser`(공유 문자열 · 첫 시트 · 칸 안 문자열). 첫 시트는 workbook 의 첫 `<sheet>`(관계 파일로 시트 파일을 찾는다).
푼 크기가 항목 하나 64MB · 모두 96MB 를 넘는 순간 멈춘다(압축 폭탄 — 목록에 적힌 크기는 믿지 않는다). DOCTYPE · ENTITY 가 든 XML · 암호를 건 항목 · ZIP64 는 "열지 못했습니다".
열은 XFD(16,384번째)까지만 읽고 넘는 칸은 버린다. 늘어놓는 칸 수(사이의 빈 칸 포함)의 합이 400만을 넘으면 "열지 못했습니다" — 먼 열에 칸 하나씩 둔 줄이 많으면 줄마다 빈 칸 16,384개로 부풀기 때문이다.
- 옛 오피스 파일(CFB)은 안의 목록 이름으로 가른다 — 암호를 건 .xlsx(EncryptedPackage)는 "열지 못했습니다", Workbook · Book 이 있으면 .xls 글, 그 밖은 "넣을 수 없는 파일".
- 2.1 QUOTED-PRINTABLE 은 줄 끝 "=" 로 접힌 줄을 먼저 바이트로 이은 뒤 CHARSET 대로 푼다(삼성 안드로이드는 한글 한 글자를 두 줄에 나눈다). 아이폰의 묶음 라벨(`item1.X-ABLabel`)로 종류를 정한다.
사진 · 로고 · 소리 · 열쇠(PHOTO · LOGO · SOUND · KEY)는 이어지는 줄까지 모으지 않고 건너뛴다(사진 든 vCard 는 이어지는 줄이 수만 개다).
- 'cp949' 라벨은 쓰지 않는다(RangeError) — EUC-KR 라벨이 브라우저에서 CP949 전체다. Node(ICU)의 euc-kr 은 CP949 확장 글자(똠 등)를 모르므로 시험은 KS X 1001 글자만 쓴다.
- **머리 줄 알아보기**: 머리 이름은 공백 · 기호를 빼고 소문자로 견준다("E-mail 1 - Value" → email1value, "회사·소속명" → 회사소속명).
네이버 열 이름의 띄어쓰기 · 괄호가 조금 달라도 낱말(휴대폰 · 회사 · 집 · Fax · 직접입력 · 생일 · 양력음력 · 기념일 · 우편번호 · 주소 · 메신저 …)로 알아본다.
"번호" 한 낱말 열은 전화로 보지 않는다(일련번호 열이 흔하다 — 네이버의 회사번호 · 집번호 · 기타번호는 따로 안다). Outlook 의 "Title"은 경칭(Mr. · Ms.)이라 쓰지 않고 "Job Title"이 직책이다.
네이버 CSV 처럼 값 대부분이 앞에 '를 달았으면 모두 떼고, 그 밖은 엑셀 수식 막기로 붙인 '(뒤가 = + - @)만 뗀다.
- **이름**: 성이 한글 · 한자 · 가나면 성 다음 이름(한글끼리는 붙여 쓴다), 그 밖은 이름 · 중간 이름 · 성 차례로 띄운다(영문 "John Smith").
- **종류**: CSV 의 종류를 모르는 이메일 칸은 회사(연락처 추가 창의 처음 값과 같다), vCard 의 종류가 없는 EMAIL 은 기타(우리 내보내기가 기타를 INTERNET 만으로 쓰므로 다시 가져와도 같다).
종류를 모르는 전화 칸(전화번호 · 연락처 · Google 의 빈 라벨)은 번호로 짐작한다 — 국내 휴대전화(01x)면 휴대전화, 아니면 기타. 엑셀이 숫자로 바꿔 앞의 0 이 빠진 휴대전화(1012345678)는 0 을 되살린다.
- **넣지 못하는 줄의 까닭** 차례는 이메일 형식 → 이름과 이메일이 모두 없음 → 한 줄 칸 100자 → 메모 1,000자. 한 줄 칸은 어느 칸인지를 앞에 붙인다("이름은 100자까지 쓸 수 있습니다." —
연락처 창은 칸 아래라 칸 이름이 없지만 목록에서는 가를 수 없어서). 이메일은 연락처 창과 같이 서버보다 좁게 본다. 한 연락처 안의 같은 이메일은 하나만, 이메일 · 전화는 20개까지.
- **형식에 맞지 않는 전화번호**(내선 · 글자가 든 번호 — "02-555-0147 내선 3")는 버리지 않고 가져온 정보의 "전화: …" 줄로 둔다(체크 칸을 끄면 넣지 않는다). 전각 숫자 · 여러 가지 줄표는 보통 글자로 고친다.
- **가져온 정보 줄**: "회사 주소 · 집 주소 · 기타 주소 · 주소(네이버 주소직접입력은 그 이름을 앞에) · 생일 · 기념일 · 홈페이지 · 별명 · 메신저 · 전화". 날짜는 "1988년 3월 14일"(해를 모르면 "3월 14일", 음력이면 뒤에 " (음력)"),
한글 주소는 큰 곳부터 띄어 쓰고 영문은 작은 곳부터 쉼표로, 우편번호는 뒤에 괄호로, 우리나라 이름은 쓰지 않는다.
값은 한 줄 글로 다듬는다(탭 · 줄바꿈 · 제어 문자는 띄어쓰기 — vCard 별명 · 형식이 틀린 전화 등), 붙인 메모는 연락처 창과 같은 메모 정리를 한 번 더 지난다 —
서버는 메모의 줄바꿈 밖 제어 문자를 받지 않아 하나라도 남으면 그 묶음 전체가 400 이다.
- **미리 보기**: 빈 묶음(이미 있는 연락처 0 · 넣지 못하는 줄 0)은 두지 않는다. 넣을 연락처가 없으면 표 · 그룹 · 메모 칸도 두지 않는다. 칸이 없는 정보가 든 연락처가 없으면 메모 체크 칸을 두지 않는다.
그룹 글은 이미 있는 그룹만이면 "파일에 적힌 그룹 거래처, 세무는 이미 있는 그룹에 넣습니다.", 새로 만들 그룹만이면 "파일에 적힌 그룹 동호회, 학교는 새로 만듭니다."(보드는 섞인 꼴만).
vCard 의 넣지 못하는 수는 "N명". 그룹이 하나도 없으면 「이미 있는 그룹」을 끈다(`aria-disabled`).
- **겹침 열쇠는 서버와 같다**(짝: backend `modules/contacts/contact-import.ts` 의 `phoneDigitsKey` · `namePhonesKey`): 이메일이 없으면 이름(앞뒤 공백만 뺌) + 전화 숫자 모음 —
번호마다 숫자만 남기고, 숫자가 없는 번호는 빼고, 겹침을 없앤 뒤 차례로 잇는다(같은 번호를 두 번 적은 줄도 한 번 적은 줄과 같은 사람이다).
- **요청 묶음**: 확인 · 넣기 · 되돌리기 본문은 100명 · JSON 값 8,000개 · 700KB 가운데 먼저 닿는 데서 끊는다(`batches.ts` — 값은 서버 `json-shape.ts` 와 같은 셈, 바이트는 UTF-8 길이).
칸을 한도까지 채운 연락처(이메일 · 전화 20개씩) 100명은 값 13,000개 남짓이라 61명 · 39명으로 나뉜다 — 서버 본문 한도(값 10,000 · 1MB)를 넘지 않게.
- **이미 있는지 묻기**: 넣을 수 있는 연락처만 위 묶음으로 서버에 묻는다(`POST /contact-import-checks`). 남은 자리를 다 채우면 더 묻지 않는다(요청 수 한도를 아끼려고) —
그래서 한도에 걸린 파일이면 묻지 않은 연락처 가운데 이미 있는 것도 "넣을 연락처"와 "나머지 N명"에 세진다(넣지 않으니 결과는 같다). 묻기가 실패하면 사전 문구 알림 뒤 파일 고르기로 돌아간다.
- **넣기**: 새 그룹을 먼저 만든다 — 그사이 같은 이름이 생겼으면(`CONTACT_001`) 그 그룹을 쓰고, 100개면(`CONTACT_002`) 그 그룹 없이 넣는다(결과의 "그룹에 넣지 못했습니다"에 센다).
만들기의 응답만 잃은 그룹(응답 없음 · 5xx — 서버에서는 만들어졌을 수 있다)이 [다시 시도]에서 `CONTACT_001` 이면 이번에 만든 그룹으로 센다([되돌리기]가 지운다).
응답을 잃은 적 없이 `CONTACT_001` 인 그룹(다른 창 · 기기가 만든 그룹)은 넣기만 하고 [되돌리기]는 지우지 않는다.
넣는 사이 주소록이 찼으면(409 `CONTACT_002`) 남은 자리를 다시 물어 그만큼만 넣고 나머지는 "넣지 못했습니다", 넣을 그룹이 그사이 지워졌으면(404 `RES_001`) 그룹을 다시 받아 그 그룹을 빼고 한 번 더 보낸다.
메일 서버가 일부만 받으면 결과에 "N명은 넣지 못했습니다."를 덧붙인다(보드에 없는 글).
- **멈춘 알림도 ✕까지 남는다**: 보드는 결과([되돌리기]) 알림만 적었지만, 멈춘 오류 알림의 [다시 시도]가 남은 연락처를 넣고 [되돌리기]를 받는 유일한 길이라 같이 남긴다(되돌리다 멈춘 알림도).
알림은 넘쳐도 다른 알림부터 닫는다. 결과 알림 위에 다른 결과가 쌓이면 셋까지 보인다.
- **차례**: 한 탭에서 가져오기 · 되돌리기는 하나씩 돈다. 도는 동안 누른 [되돌리기] · [다시 시도]는 버리지 않고 끝난 뒤에 한다 — 누른 뒤 세션이 바뀌었으면 하지 않는다(다른 직원의 주소록을 건드리지 않게).
- **내보내기**: 처음 초점은 [내보내기](내 주소록 전체 · vCard 를 바로 받게), 그룹이 하나도 없으면 「그룹 하나」를 고를 수 없다(`disabled`). 화면의 파일 이름 날짜는 이 PC 의 날짜이고
서버가 붙이는 이름은 한국 시간이다 — 자정 무렵 바깥 시간대의 PC 에서는 하루 다를 수 있다(알려진 한계).
- **알림 부품**: ✕로 닫을 때까지 남는 알림은 `toast.store` 의 `sticky`(진행 알림의 `succeed` · `fail` 옵션 — `useToast`). 성공 알림에도 ✕ 를 단다(브랜드 바탕용 흐린 청록, 마우스를 올리면 흰 글자).
지금 쓰는 알림의 시간 · 모양은 그대로다.
### 16단계 구현하며 정한 보완 — 서명 (2026-11-10 — 캔버스에는 아직 없다)
- **서명 자리**(`components/mail/editor/mail-signature-block.ts`): 편집기 안에서는 블록 노드 하나(`mailSignature` — 안은 서명의 문단)이고, 보낼 때 · 저장할 때 빈 문단 뒤의 **속성 없는 `<div>`** 로 나간다 —
앱과 같은 경계(계약 2절 "앱이 만드는 모양")라 웹 · 앱 어느 쪽 저장본을 다시 열어도 그 자리를 찾는다. 안의 문단은 다른 문단처럼 `margin: 0` 이 붙는다.
다시 열 때는 "맨 위 단계(문서 · 하나뿐인 감싸는 글꼴 div 바로 아래)에서 빈 문단 바로 뒤의 속성 없는 div 이고 글이나 그림이 있다"로 찾는다 — 원문 인용 안 · 꾸민 div · 빈 div(인용 경계 표시가 남은 것)는 서명 자리가 아니다.
쓰기 화면이 처음 넣는 서명은 표시(`data-mail-signature` — 서버 정화가 지운다)로 안다.
- **서명 앞의 빈 줄은 늘 둔다**(2026-11-10 리뷰 M3 — 빈 줄에 글을 쓰거나 지운 채 저장하면 다시 열 때 서명 자리를 잃었다): 그 빈 줄에 글을 쓰면 쓴 글 아래 · 서명 위에 새 빈 줄이 생기고,
Backspace · Delete 로 빈 줄을 지우면 다시 생긴다(커서는 쓰던 자리 · 윗줄 끝, 실행 취소는 그 입력과 함께 — 쓴 글은 지우지 않는다).
한글을 조합하는 중에는 미뤘다가 다음 입력에서 넣고, 보내고 저장하는 모양도 빠진 빈 줄을 한 번 더 넣는다.
알려진 한계: 16단계 앞에 저장한 본문(서명이 문단으로만 들어 있다)은 서명 자리를 찾지 못한다 — 그 서명은 글로 남고, [서명 ▾]는 ✓ 없이 열리며 고르면 처음 넣는 자리에 새로 넣는다.
- **서명 자리는 하나**: 서명 안에서 복사해 붙여 넣으면 편집기가 서명 자리째 붙이는데, 그때 처음부터 있던 것만 두고 나머지는 글로 푼다(쓰기 화면의 편집기에서만 — 붙여 넣기 · 끌어 놓기 모두).
- **어느 서명이 들었나**: 서명 자리의 글(공백 · nbsp 를 하나로 줄여)과 그림 주소를 저장한 서명(편집기로 읽은 모양)과 견준다 — 만든 차례로 먼저 맞는 서명에 ✓. 본문이 바뀔 때마다 다시 잰다(서명 안을 고치면 ✓ 가 빠진다).
- **글(보드에 없어 지었다)**: 넣지 않음을 고른 뒤 낭독은 같은 틀 "서명을 ‘넣지 않음’으로 바꿨습니다." — 방향 조사는 이름 끝소리로 고른다("‘영문’으로" · 영문 이름은 "(으)로").
새 서명(저장하지 않은 줄)을 고치다 다른 서명을 고르면 "다른 서명을 고르면 새 서명에 쓴 내용이 사라집니다.", 쓴 것이 있을 때의 [취소]는 "취소하면 새 서명에 쓴 내용이 사라집니다."("‘새 서명’ 서명에서"가 되지 않게).
- **서버 거절**: 저장이 400 `VAL_001` 이면 목록을 조용히 다시 받아 그사이 다른 창에서 같은 이름을 만들었으면 이름 칸의 빨간 글, 아니면 서명 칸 아래 사전 문구(본문 길이 · 그림 3장 등 — 지금과 같다).
지우려던 서명이 이미 없으면(404) 지운 것으로 본다. 고치던 서명이 다시 받은 목록에서 사라졌으면(다른 창에서 지움) 쓰던 것은 두고 새 서명으로 본다([저장]하면 새로 만든다).
한 사람이 보관하는 그림 20장을 넘는 올리기 거절(`VAL_001`)은 지금처럼 알림이다.
- **쓰기 화면이 서명을 못 받았으면** 서명 없이 열고 알리지 않는다(지금과 같다). [서명 ▾]를 열 때 다시 받아 본다.
- **공용 편집기**(`SignatureEditor` — 부재중 자동 응답의 내용 칸이 함께 쓴다): prop `bodyId`(편집 영역 id · 툴바 aria-controls) · `toolbarLabel`(툴바 이름) · `allowImages`(false 면 그림 단추 · 그림 노드가 없고 붙여 넣거나 끌어 놓은 파일은 받지 않는다) ·
`invalid`(aria-invalid · 빨간 테두리) · `disabled`(읽기만 · 툴바 끔 · 흐리게). 편집 영역 최소 높이는 CSS 변수 `--signature-editor-min-height`(기본 132), 보이는 글은 `getText()`. 기본값은 서명 칸 그대로다.
### 16단계 구현하며 정한 보완 — 부재중 · 휴대폰 바꾸기 (2026-11-10 — 캔버스에는 아직 없다)
- **휴대폰을 바꿨는지 모를 때**(서버가 503 `MAIL_001` — 엔진이 새 비밀을 가려 다시 읽어도 가를 수 없다 · 시간 초과 · 연결 끊김 · 앞단 5xx 의 `SYS_001`): 바꿨다고도 못 바꿨다고도 하지 않는다(보드에 없는 새 글).
사전 문구 알림은 끄고 창은 ② 그대로 · 지금 휴대폰의 코드 칸만 비운 채 창 위 빨간 상자에
"휴대폰을 바꿨는지 확인하지 못했습니다. 지금 휴대폰에 다음 코드가 뜨면 넣고 [바꾸기]를 다시 눌러 주세요. 코드가 맞지 않다고 나오면 이미 바뀌었을 수 있으니, 다음에 로그인할 때 새 휴대폰의 코드를 넣어 주세요." —
이미 바뀌었으면 지금 휴대폰의 코드는 막혀 [바꾸기]가 `AUTH_006` 으로 거절하므로, 그 상자를 남겨 둔 채 칸 아래 글이 함께 보인다.
창을 닫은 뒤에 왔으면 알림 "휴대폰을 바꿨는지 확인하지 못했습니다. 다음에 로그인할 때 새 휴대폰의 코드가 맞지 않으면 지금 휴대폰의 코드를 넣어 주세요."
- 닫은 창에 늦게 온 거절(지금 비밀번호 · 지금 코드)은 칸 대신 사전 문구 알림, 다시 연 창에는 넣지 않는다(켜기 · 끄기 창과 같다). ①의 늦은 확인 결과는 버린다(엔진을 바꾸지 않는다).
- 켜기 창과 바꾸기 창이 한 화면에 함께 있어 키 이름표 id 가 겹치지 않게 QR · 키 부품(`TwoFactorEnrollmentScan`)이 머리 글 · 닫을 때의 안내 · 키 이름표 id 를 받는다.
- 부재중 내용의 글자 수는 켜 둔 동안 600을 넘는 순간부터 빨갛게 보인다(보드는 [저장]을 누른 모습만 — 칸 아래 글은 지금처럼 [저장]을 눌렀을 때만).
꺼 둔 채로는 넘어도 회색이다(켤 때만 센다).
- 부재중 양식은 편집기와 함께 지연 로딩한다 — 청크를 받는 동안은 칸이 비어 있고, 못 받으면 서명 설정과 같은 사전 문구 + [다시 시도].
- 회사 밖 칸을 쓴 적 없으면 제목 · 내용을 null 로 보낸다(빈 글을 남기지 않는다 — 서버도 빈 글을 null 로 저장한다).
+224 -27
View File
@@ -415,8 +415,8 @@ HTML 은 정화하기 **전에** 길이(HTML 부분이 여럿이면 이어 붙
| `DELETE /api/mailboxes/{id}` | `withChildren=true`(선택 — 15단계 "하위 메일함까지 지우기") | 200 `MailboxDeletion` — 안의 메일을 휴지통으로 옮긴 뒤 지운다(하위까지면 나무 전체) | 400 `VAL_001`(기본 메일함 · 하위 메일함이 있는데 `withChildren=true` 가 아님 · 하위에 기본 메일함 · 다른 값), 404 `RES_001`, 409 `MAIL_015`(남은 메일이 붙잡힌 메일뿐 — 9단계 "보내기 취소 · 예약 발송"), 429 `REQ_001`(같은 메일함(하위까지면 나무의 메일함)의 처리가 진행 중), 503 `MAIL_001` |
| `PATCH /api/mailboxes/{id}/messages` | `{ isRead: true }` — 그 메일함 전체를 읽음으로 | 200 `MailboxBulkResult` | 400 `VAL_001`, 404 `RES_001`, 429 `REQ_001`(같은 메일함의 처리가 진행 중), 503 `MAIL_001` |
| `DELETE /api/mailboxes/{id}/messages` | — 휴지통 · 스팸메일함 비우기(영구 삭제) | 200 `MailboxBulkResult` | 400 `VAL_001`(휴지통 · 스팸메일함이 아님), 404 `RES_001`, 409 `MAIL_015`(남은 메일이 붙잡힌 메일뿐 — 9단계), 429 `REQ_001`(같은 메일함의 처리가 진행 중), 503 `MAIL_001` |
| `GET /api/mail-vacation` | — | 200 `MailVacationState` (아래 "부재중 자동 응답" — `MailVacation` + 7단계의 `isActive`) | 503 `MAIL_001` |
| `PUT /api/mail-vacation` | `MailVacation` (전체 교체 — `isActive` 는 보내지 않는다) | 200 `MailVacationState`(저장한 값) | 400 `VAL_001` · `MAIL_013`(필터와 함께 만든 엔진 규칙이 한도를 넘음 — 6단계), 503 `MAIL_001` |
| `GET /api/mail-vacation` | — | 200 `MailVacationState` (아래 "부재중 자동 응답" — `MailVacation` + 7단계의 `isActive`. 16단계의 `htmlBody` · `external` 은 늘 담는다) | 503 `MAIL_001` |
| `PUT /api/mail-vacation` | `MailVacation` (전체 교체 — `isActive` 는 보내지 않는다. 16단계의 `htmlBody` · `external` 은 보내지 않아도 된다 — 글만 · 사내와 같은 응답) | 200 `MailVacationState`(저장한 값) | 400 `VAL_001` · `MAIL_013`(필터와 함께 만든 엔진 규칙이 한도를 넘음 — 6단계), 503 `MAIL_001` |
| `GET /api/mail-filters` | — | 200 `MailFilterSet` (아래 "메일 필터", 6단계) | 503 `MAIL_001` |
| `PUT /api/mail-filters` | `{ rules, version }` (전체 교체) | 200 `MailFilterSet` | 400 `VAL_001` · `MAIL_013`(만든 규칙이 엔진 한도를 넘음), 409 `MAIL_012`(그사이 다른 곳에서 필터가 바뀜 — 저장 안 함), 503 `MAIL_001` |
| `POST /api/mail-filter-runs` | `MailFilterRunRequest` — 받은편지함에 이미 있는 메일에 규칙 하나를 적용한다(아래 "이미 받은 메일에 적용", 7단계) | 200 `MailFilterRun` — 붙잡힌 메일(9단계)은 옮기지 않고 표시만 | 400 `VAL_001`(규칙 검사 · 할 일 없음 · 깨진 cursor), 429 `REQ_001`(같은 직원의 적용이 진행 중), 503 `MAIL_001` |
@@ -440,9 +440,15 @@ HTML 은 정화하기 **전에** 길이(HTML 부분이 여럿이면 이어 붙
| `GET /api/drafts/{id}` | — | 200 `Draft` (아래 "임시 저장") | 404 `RES_001`(없음·초안이 아님), 503 `MAIL_001` |
| `POST /api/drafts` | 초안 본문 | 201 `DraftSaved` | 400 `VAL_001`·`MAIL_002`·`MAIL_003`·`MAIL_004`·`MAIL_006`·`MAIL_007`·`MAIL_016`(대용량 첨부 — 11단계, 4절)·`FILE_001`, 413 `FILE_002`, 503 `MAIL_001` |
| `PUT /api/drafts/{id}` | 초안 본문 (전체 교체) | 200 `DraftSaved` — **새 id** | 위와 같음 + 404 `RES_001`(초안이 아닌 id — 지우지 않음) + 409 `MAIL_008`(다른 곳에서 이미 바뀜 — 저장 안 함) |
| `GET /api/mail-signature` | — | 200 `MailSignature` (아래 "서명") | — |
| `PUT /api/mail-signature` | `{ bodyHtml, body, isForNew, isForReply }` | 200 `MailSignature` | 400 `VAL_001` |
| `POST /api/mail-signature/images` | multipart `file` 1개 | 201 `SignatureImage` | 400 `FILE_001`·`VAL_001`(올려 둔 그림 10장 초과), 413 `FILE_002`(300KB 초과) |
| `GET /api/mail-signatures` | — | 200 `{ items: MailSignature[], pageInfo }` — 만든 차례, 10개까지 한 번에(hasNext=false · `total`) (아래 "서명", 16단계) | — |
| `POST /api/mail-signatures` | `{ name, bodyHtml, body }` | 201 `MailSignature` — 서명이 하나도 없던 직원이면 「새 메일」 · 「답장 · 전달」 두 곳의 기본이 된다 | 400 `VAL_001`(이름 · 같은 이름 · 본문 한도 · 11번째) |
| `PUT /api/mail-signatures/{id}` | `{ name, bodyHtml, body }` (전체 교체 — 기본 표시는 그대로) | 200 `MailSignature` | 400 `VAL_001`, 404 `RES_001`(없는 · 남의 · 형식이 틀린 id) |
| `DELETE /api/mail-signatures/{id}` | — | 204 — 이 서명을 기본으로 쓰던 곳은 넣지 않음, 이 서명에만 쓰던 그림은 함께 지운다 | 404 `RES_001` |
| `GET /api/mail-signature-defaults` | — | 200 `MailSignatureDefaults` (「메일에 넣을 서명」 둘 — 16단계) | — |
| `PUT /api/mail-signature-defaults` | `{ newMessageId, replyId }` (두 키 모두 — null 은 넣지 않음) | 200 `MailSignatureDefaults` | 400 `VAL_001`, 404 `RES_001`(내 서명이 아닌 id — 바꾸지 않음) |
| `GET /api/mail-signature` | — | 200 `LegacyMailSignature` — 「새 메일」 기본 서명을 15단계까지의 모양으로 (옛 경로 — 앱 첫 판이 읽는다) | — |
| `PUT /api/mail-signature` | `{ bodyHtml, body, isForNew, isForReply }` | 200 `LegacyMailSignature` (옛 경로 — 16단계 전의 웹 탭) | 400 `VAL_001` |
| `POST /api/mail-signature/images` | multipart `file` 1개 | 201 `SignatureImage` | 400 `FILE_001`·`VAL_001`(올려 둔 그림 20장 초과 — 16단계부터, 전에는 10장), 413 `FILE_002`(300KB 초과) |
| `GET /api/mail-signature/images/{id}` | — | 그림 파일 (표준 래퍼 없음, 본인 그림만) | 404 `RES_001` |
| `GET /api/recipient-suggestions` | `q`(선택, 1~100자) · `limit`(기본 20, 최대 100) · `cursor` | 200 `{ items: RecipientSuggestion[], pageInfo }` (아래 "받는 사람 추천") | 400 `VAL_001`, 503 `MAIL_001` |
| `GET /api/scheduled-messages` | `cursor` · `limit`(기본 20, 최대 100) | 200 `{ items: MessageSummary[], pageInfo }` — 예약한 메일, 보낼 시각 차례, `total` 포함 (아래 "보내기 취소 · 예약 발송", 9단계) | 400 `VAL_001`, 503 `MAIL_001` |
@@ -726,16 +732,30 @@ interface DraftSaved { id: string; attachments: Attachment[]; updatedAt: string
- 저장 응답을 받지 못하면(시간 초과 등) 서버는 이미 새 저장본을 만들었을 수 있다 — 다음 PUT 은 `MAIL_008` 이 되어 클라이언트가 새로 남기므로
임시보관함에 한 통이 더 남을 수 있다 (잃는 내용은 없다).
### 서명 (`/api/mail-signature`)
### 서명 (`/api/mail-signatures` · `/api/mail-signature-defaults` · 옛 경로 `/api/mail-signature`) — 16단계부터 여러 개
```ts
interface MailSignature {
interface MailSignature { // 16단계 — 한 사람에 10개까지
id: string // 서버가 만든 UUID
name: string // 앞뒤 공백을 뺀 1~30자(JS 문자열 길이) — 제어 문자 없음, 내 다른 서명과 겹치지 않는다(영문 대소문자 무시)
bodyHtml: string | null // 서명 서식 — 서식 본문 규칙 + 서명 그림. 정화 뒤 10,000자 이하
body: string | null // 텍스트 서명 (텍스트 대체 본문에 넣는다) — 2,000자 이하
isForNew: boolean // 새 메일에 넣는다 (기본 true)
isForReply: boolean // 답장·전달에 넣는다 — 인용 위 (기본 true)
images: SignatureImage[] // 서명이 쓰는 그림
updatedAt: string | null // 저장한 적 없으면 null
images: SignatureImage[] // 서명이 쓰는 그림 (HTML 에 나온 순서)
updatedAt: string // 마지막으로 저장한 시각
}
interface MailSignatureDefaults { // 「메일에 넣을 서명」 — null 은 넣지 않음 (15단계까지의 두 체크를 대신한다)
newMessageId: string | null // 새 메일 (본문 끝)
replyId: string | null // 답장 · 전달 (인용 위)
}
interface LegacyMailSignature { // 옛 경로 GET · PUT /api/mail-signature — 15단계까지의 MailSignature 모양 (앱 첫 판이 읽는다)
bodyHtml: string | null // 「새 메일」 기본 서명의 것 — 없으면 「답장 · 전달」 기본 서명의 것
body: string | null
isForNew: boolean // 「새 메일」 기본 서명이 있다
isForReply: boolean // 「답장 · 전달」 기본이 이 서명이다
images: SignatureImage[]
updatedAt: string | null // 기본 서명이 둘 다 없으면 null
}
interface SignatureImage {
@@ -747,19 +767,42 @@ interface SignatureImage {
}
```
- 사용자마다 하나다. 저장한 적 없으면 기본값(`bodyHtml: null`·`body: null`·둘 다 `true`·`images: []`)을 돌려준다. 서버 DB 에 둔다(메일 엔진에 두지 않는다).
- `PUT` 의 한도 — 넘으면 400 `VAL_001`: 텍스트 서명 2,000자, 서식 서명은 정화 전에 서식 본문과 같은 한도(500,000자·파서 비용)와 정화 뒤 10,000자,
서명 그림 3장(서로 다른 그림 기준).
- 서버 DB 에 둔다(메일 엔진에 두지 않는다). 목록은 **만든 차례**이고 10개까지 한 번에 준다(`hasNext: false` · `total` = 서명 수).
- 만들기 · 고치기(`POST` · `PUT /api/mail-signatures/{id}` — 전체 교체)의 한도 — 넘으면 400 `VAL_001`(아무것도 저장하지 않는다):
이름(위 — 같은 이름은 그사이 다른 창에서 만든 것도 서버가 막는다), 11번째 만들기, 텍스트 서명 2,000자,
서식 서명은 정화 전에 서식 본문과 같은 한도(500,000자·파서 비용)와 정화 뒤 10,000자, 서명 그림 3장(서로 다른 그림 기준).
없는 서명 · 남의 서명 · 형식이 틀린 id 는 404 `RES_001`. 같은 직원의 서명 저장 · 지우기 · 기본 바꾸기는 서버가 차례로 한다(사용자 잠금).
- **「메일에 넣을 서명」**: 서명이 하나도 없던 직원의 첫 서명(`POST`)은 「새 메일」 · 「답장 · 전달」 두 곳의 기본이 된다(15단계까지 두 체크가 처음에 켜져 있던 것과 같다).
그 밖의 만들기 · 고치기는 기본을 바꾸지 않는다. 지우면(`DELETE` → 204) 그 서명을 기본으로 쓰던 곳은 넣지 않음(null)이 된다 — 다른 서명이 저절로 기본이 되지 않는다.
`PUT /api/mail-signature-defaults` 는 두 키를 모두 보내고(null = 넣지 않음, 두 곳이 같은 서명이어도 된다) 바꾼 뒤의 값을 돌려준다 —
내 서명이 아닌 id 는 404 `RES_001`(아무것도 바꾸지 않는다). 기본은 한 곳에 하나다(표의 부분 유일 인덱스).
- 이미 보냈거나 임시 저장한 메일에 들어간 서명 글은 서명을 고치거나 지워도 그대로다.
- **옛 경로**(앱 첫 판 · 16단계 전의 웹 탭 — 그대로 둔다):
- `GET /api/mail-signature` — 「새 메일」 기본 서명을 위 `LegacyMailSignature` 로. 「새 메일」 기본이 없으면 「답장 · 전달」 기본 서명을 `isForNew: false` · `isForReply: true` 로
(옛 두 체크가 새 메일 끔 · 답장 켬이던 직원 — 옮기면 답장 기본만 있다 — 이 앱 첫 판의 답장에서 서명을 잃지 않게).
둘 다 없으면 `{ bodyHtml: null, body: null, isForNew: false, isForReply: false, images: [], updatedAt: null }`
(15단계까지는 저장한 적 없으면 두 표시가 `true` 였다 — 본문이 없으니 앱이 넣는 것은 같다). 「새 메일」 기본이 있을 때 `isForReply` 는 「답장 · 전달」 기본이 같은 서명일 때만 `true` —
두 곳이 서로 다른 서명이면 앱 첫 판은 답장에 서명을 넣지 않는다(알려진 한계 — 앱에서 서명 고르기는 앱 다음 판 후보).
- `PUT /api/mail-signature` — 옛 GET 이 보이는 서명(「새 메일」 기본, 없으면 「답장 · 전달」 기본, 둘 다 없으면 "기본 서명"이라는 이름의 서명 — 그것도 없으면 만든다)의 본문을 바꾸고 두 표시를 기본 둘로 옮긴다:
`isForNew` → 「새 메일」 기본을 이 서명으로(false 면 넣지 않음), `isForReply` → 「답장 · 전달」 기본을 이 서명으로(false 면 이 서명이 기본이었을 때만 넣지 않음 — 다른 서명이 기본이면 그대로).
한도 · 정화는 위와 같다(새로 만들어야 하는데 이미 10개면 400 `VAL_001`).
- **옮기기**(마이그레이션 `MultipleMailSignatures` — 표 `mail_signatures` 를 서명마다 한 줄로): 지금 서명은 "기본 서명"이라는 이름의 서명 하나가 되고,
두 체크(`isForNew` · `isForReply`)에서 켠 곳의 기본이 된다(끈 곳은 넣지 않음). **내용이 비어 있던 서명(서식 · 텍스트 모두 없거나 공백뿐)은 옮기지 않는다.**
되돌리기(down)는 한 사람에 한 줄만 남긴다 — 「새 메일」 기본 → 「답장 · 전달」 기본 → 먼저 만든 서명 차례로 하나, 나머지 서명은 지운다(남긴 서명의 두 표시가 옛 두 체크가 된다).
- **쓰기 화면의 서명 자리**: 앱과 같은 경계 — 빈 문단 하나 뒤의 **속성 없는 `<div>` 하나**(위 "서식 본문"의 "앱이 만드는 모양"). 보내는 정화 · 임시 저장본을 다시 열 때의 정화 ·
받은 메일 정화를 지나도 그대로 남는다(서버 시험 — `sanitize-outgoing-html.spec.ts`). 다만 속성이 모두 지워지는 div(허용하지 않는 style 뿐 · class · data-*)도
속성 없는 div 가 되므로, 경계는 자리(빈 문단 바로 뒤)로 알아본다.
- **서명 그림**: `POST /api/mail-signature/images` (multipart `file`) — PNG·JPEG·GIF 만(확장자·MIME·파일 앞부분 모두 검사, WebP·SVG 는 받지 않는다),
한 장 300KB 이하(넘으면 413 `FILE_002` — 받은 메일의 본문 그림 한도와 같아서, 보낸 메일을 우리 웹메일로 다시 읽어도 그림이 보인다),
가로·세로 각각 4096px 이하(넘으면 400 `FILE_001` — 작은 파일 안의 아주 큰 그림이 받는 쪽을 무겁게 하지 않게).
서명에는 3장까지 넣는다(편집기가 먼저 막는다). 발송 본문·임시 저장본도 서로 다른 서명 그림 3장까지다(넘으면 400 `VAL_001`).
올려 두기만 한 그림을 포함해 한 사람 10장까지 둘 수 있다(넘으면 올리기가 400 `VAL_001`).
올려 두기만 한 그림을 포함해 한 사람 20장까지 둘 수 있다(넘으면 올리기가 400 `VAL_001` — 여러 서명이 함께 쓰는 칸이라 16단계에 10 → 20).
서명 HTML 은 그림을 `<img src="/api/mail-signature/images/{id}" width="144" alt="">` 로 가리킨다(`width` 1~600 — 작게 96 · 보통 144 · 원본은 원래 폭, 600 이하).
- `GET /api/mail-signature/images/{id}` 는 그 사람의 그림만 내준다(남의 것은 404 `RES_001`) — 편집기·미리 보기용이고, 받는 사람은 이 주소를 쓰지 않는다.
저장할 때 확인한 형식 그대로, `Content-Disposition: inline` · `X-Content-Type-Options: nosniff` · `Cache-Control: private, max-age=86400`
(그림 id 의 내용은 바뀌지 않는다 — 새로 올리면 새 id).
- `PUT` 은 서명 HTML 이 더는 가리키지 않는 그림을 지운다 — 다만 한 번도 저장하지 않은 그림 가운데 10분 안에 올린 것은 남긴다
- 서명을 저장 · 지울 때(옛 경로 `PUT` 포함) **어느 서명의 HTML 도 더는 가리키지 않는 그림**(모든 서명의 합집합 밖)을 지운다 — 다른 서명이 쓰는 그림은 남고,
지운 서명에만 쓰던 그림은 함께 지워진다. 다만 한 번도 저장하지 않은 그림 가운데 10분 안에 올린 것은 남긴다
(그림을 넣자마자 저장하면 올리기와 저장이 엇갈릴 수 있다). 올리고 저장하지 않은 그림은 하루 뒤 지운다.
- **보낼 때**: 쓰기 화면이 서명을 본문에 보통 글로 넣어 둔 그대로 보낸다 — 서명 그림은 위 "서식 본문"의 그림 예외로 메일 안에 함께 실린다
(`multipart/related` 의 그림 부분 — `Content-Disposition: inline`, `Content-ID: <sig-{그림 id}@{보내는 사람 도메인}>`, 이름 `signature-1.png` …,
@@ -1144,7 +1187,7 @@ interface MailboxBulkResult {
다른 탭에서 먼저 끝냈으면 실패로 보이지 않는다 — 모두 읽음 · 비우기의 0통은 "…에 안 읽은 메일이 없습니다" · "…이 이미 비어 있습니다", 지우기의 404 `RES_001` 은 지운 것으로 본다.
메일함 지우기의 진행은 `processed` 로 센다(`movedToTrash` 는 휴지통으로 간 것만이라 다른 메일함에도 든 메일만 남으면 늘지 않는다).
### 부재중 자동 응답 (`/api/mail-vacation`) — 4단계
### 부재중 자동 응답 (`/api/mail-vacation`) — 4단계 · 16단계(서식 본문 · 회사 밖 응답)
```ts
interface MailVacation {
@@ -1152,7 +1195,15 @@ interface MailVacation {
fromDate: string | null // ISO 8601 UTC — null 이면 켠 때부터
toDate: string | null // null 이면 끌 때까지
subject: string // 켤 때 150자까지(꺼 둔 채로는 200자까지 — 아래 "길이 한도의 근거") — 켤 때(isEnabled true)는 공백이 아닌 글자 1자 이상. 제어 문자(메일함 이름과 같은 범위 — 탭 · 줄바꿈 포함)는 쓰지 않는다(한 줄짜리 머리글)
textBody: string // 켤 때 600자까지(꺼 둔 채로는 4,000자까지) — 켤 때는 공백이 아닌 글자 1자 이상. 글자만(서식 없음) — 제어 문자는 탭 · LF · CR 만. 서명은 붙지 않는다
textBody: string // 보이는 글(서식을 뺀 글) — 켤 때 600자까지(꺼 둔 채로는 4,000자까지), 켤 때는 공백이 아닌 글자 1자 이상. 제어 문자는 탭 · LF · CR 만. 서명은 붙지 않는다
htmlBody: string | null // 16단계 — 서식 본문(그림 없음). 정화해 저장한다 — 켤 때 정화 뒤 10,000자까지. null = 글만. PUT 에 없으면 null
external: MailVacationExternal // 16단계 — 회사 밖에서 온 메일에 하는 응답. PUT 에 없거나 null 이면 사내와 같은 응답(글 칸 null)
}
interface MailVacationExternal { // 16단계 — PUT 에 담으면 네 키를 모두 보낸다(글 칸은 null 가능)
mode: 'same' | 'none' | 'separate' // 사내와 같은 응답(처음 값) · 보내지 않기 · 따로 쓰기
subject: string | null // 따로 쓰기의 제목 — 켜고 따로 쓰기면 1~150자(그 밖에는 200자까지 · 제어 문자 없음). 빈 글은 null 로 저장
textBody: string | null // 따로 쓰기의 보이는 글 — 켜고 따로 쓰기면 1~600자(그 밖에는 4,000자까지 · 탭 · LF · CR 밖의 제어 문자 없음). 빈 글은 null
htmlBody: string | null // 따로 쓰기의 서식 본문(그림 없음) — 정화해 저장, 켜고 따로 쓰기면 정화 뒤 10,000자까지
}
interface MailVacationState extends MailVacation { // GET · PUT 의 응답 (7단계)
isActive: boolean // false = 엔진에서 우리 규칙(필터와 한 스크립트)이 꺼져 있어 켜 둔 자동 응답이 지금은 동작하지 않는다 — 저장하면 다시 켜진다.
@@ -1166,11 +1217,12 @@ interface MailVacationState extends MailVacation { // GET · PUT 의 응답 (7
이 한도는 **켤 때만** 본다 — 꺼 둔 채로는 4단계 한도(200자 · 4,000자)까지 받는다. 4단계에 엔진 한도 안으로 저장해 둔 긴 글(영문 내용 1,000자 등)이 켜져 있으면
화면은 꺼 둘 때 칸이 흐려 고칠 수 없는데, 끄기는 늘 저장되어야 하기 때문이다. 다시 켤 때 150 · 600 에 걸리면 줄인 뒤 켠다.
- 한 번도 설정하지 않은 계정은 `{ isEnabled: false, fromDate: null, toDate: null, subject: "", textBody: "" }` 로 온다(엔진의 null 은 빈 문자열로). 끈 채로는 빈 제목 · 내용도 저장된다.
- 한 번도 설정하지 않은 계정은 `{ isEnabled: false, fromDate: null, toDate: null, subject: "", textBody: "", htmlBody: null, external: { mode: "same", subject: null, textBody: null, htmlBody: null } }` 로 온다
(엔진의 null 은 빈 문자열로 — 엔진 VacationResponse 의 서식 본문은 가져오지 않는다). 끈 채로는 빈 제목 · 내용도 저장된다.
제목 · 내용은 쓴 그대로 저장한다 — "공백이 아닌 글자" 검사만 앞뒤 공백을 뺀 값(JS `trim()` — NBSP · U+3000 포함)으로 한다. 제어 문자 검사는 꺼 둔 채로도 한다.
- 저장 자리 — 6단계부터 메일 필터와 같은 엔진 규칙(Sieve 스크립트 `arasystem`)에 둔다(아래 "메일 필터"의 "저장 자리"). 4단계에는 엔진의 JMAP `VacationResponse`(singleton)였다.
서식 본문은 없다(글자만). 저장은 전체 교체이고 응답은 저장한 값이다. 꺼도 쓴 제목 · 내용은 남겨 둔다.
15단계까지 서식 본문은 없었다(글자만 — 16단계는 아래 "서식 본문 · 회사 밖 응답"). 저장은 전체 교체이고 응답은 저장한 값이다. 꺼도 쓴 제목 · 내용은 남겨 둔다.
필터와 한 스크립트라서 부재중을 저장해도 스크립트를 다시 켠다(필터가 꺼져 있었으면 함께 다시 동작한다). 필터 규칙은 건드리지 않는다.
- 6단계부터 바뀐 동작: 엔진이 스팸으로 분류한 메일에는 답하지 않는다(필터와 같은 스팸 막기 — "메일 필터"의 "만드는 Sieve").
- 우리 스크립트가 엔진에서 꺼져 있으면(`isActive: false`) 부재중도 동작하지 않는다 — `isEnabled` 는 저장한 값이라 그때도 `true` 다.
@@ -1181,6 +1233,45 @@ interface MailVacationState extends MailVacation { // GET · PUT 의 응답 (7
둘 다 있으면 버린 값으로 `fromDate < toDate`(아니면 400 `VAL_001`). 화면은 날짜만 고르고, 브라우저 시간대의 시작일 0시 · 끝나는 날 다음 날 0시를 UTC 로 보낸다.
- 같은 사람에게 매번 답하지는 않는다 — JMAP 규격(RFC 8621 8절)이 "보낸 사람마다 적당한 기간에 한 번" 을 요구하고 엔진이 따른다.
6단계부터는 Sieve `vacation`(RFC 5230)이 같은 일을 한다 — 간격은 엔진 기본값이다.
- **서식 본문 · 회사 밖 응답 (16단계)**:
- **서식 본문**(`htmlBody`)은 서명과 같은 정화(쓰기 화면의 서식 본문 허용 목록 — **그림은 없다**)를 거쳐 저장한다. 정화 전 길이(500,000자) · 파서 비용은 켬 · 끔과 상관없이 보고,
정화 뒤 10,000자(JS 문자열 길이)는 켤 때만 본다(회사 밖 서식은 따로 쓰기이고 켤 때만) — 넘으면 400 `VAL_001`. 정화해서 남는 것이 없으면 null(글만).
보이는 글(`textBody`)은 화면이 서식을 뺀 글로 함께 보낸다 — 켤 때의 600자 한도는 이 글로 본다(지금 그대로).
- 받는 사람에게는 서식이 있으면 **글 + 서식 두 벌**(`multipart/alternative` — text/plain · text/html, 각 base64)로 간다. 서식을 읽지 못하는 메일 프로그램은 글만 보인다.
서식이 없으면 지금처럼 글만(text/plain)이다.
- **회사 밖 응답**(`external`) — 사내 · 회사 밖은 **봉투 보낸 사람(return-path)의 도메인**으로 가른다: `MAIL_DOMAIN` 과 같으면 사내(하위 도메인은 회사 밖).
엔진은 답장을 봉투 주소로만 보내므로 머리글 From 을 사내로 꾸며도 사내용 글은 그 봉투 주소(회사 밖)로 가지 않는다(2026-11-10 개발 엔진 — 실제 배달로 확인).
`same` = 지금처럼 모두에게 같은 응답(처음 값), `none` = 회사 밖에는 답하지 않는다, `separate` = 회사 밖에는 따로 쓴 제목 · 내용으로.
다른 고르기로 바꿔도 따로 써 둔 회사 밖 글은 그대로 저장한다(빈 글은 null). 회사 밖 칸은 따로 쓰기이고 켤 때만 사내와 같은 규칙(1자 이상 · 150 · 600자 · 서식 10,000자)으로 보고,
사내와 같은 응답 · 보내지 않기면 비어 있어도 저장한다.
- 같은 사람에게 다시 보내는 간격은 엔진 기본 그대로이고, 사내 · 회사 밖은 따로 센다 — 회사 밖 응답에 `:handle` 을 따로 둔다(응답 내용의 지문 — 글을 바꾸면 다시 보낸다,
사내 응답은 15단계까지와 같은 모양이라 고르기를 바꿔도 같은 사람에게 다시 보내지 않는다).
- 옛 요청(16단계 칸 없음)은 글만 · 사내와 같은 응답으로 저장한다 — 옛 화면이 보는 그대로. 응답(GET · PUT)에는 두 칸을 늘 담는다.
- **만드는 Sieve**(아래 "메일 필터"의 "만드는 Sieve"에 더하는 것):
```
if not header :matches "X-Spam-Score" "spam*" {
if currentdate … { ← 기간이 있으면 (지금 그대로)
if envelope :domain :is "from" "<MAIL_DOMAIN>" { ← 회사 밖 응답이 사내와 같지 않을 때만
vacation :mime :subject "<사내 제목>" "<MIME 개체>";
} else { ← 따로 쓰기만 (보내지 않기면 else 가 없다)
vacation :mime :handle "ara-external-<지문>" :subject "<회사 밖 제목>" "<MIME 개체>";
}
}
필터 규칙 …
}
```
require 에 `envelope`(가를 때) · `variables`(조각이 있을 때)를 더한다. MIME 개체가 Sieve 문자열 한 개 한도(4,096바이트)를 넘으면 `variables` 로 3,000바이트 이하 조각에 나눠 담고
(`set "part0" "…";` — 조각은 줄 끝(CRLF) 뒤에서만 끊는다) `vacation :mime "${part0}${part1}…"` 로 잇는다 — 2026-11-10 개발 엔진: 이은 답장의 글 · 서식이 원래와 바이트까지 같다.
사용자 글 · 서식은 base64 · encoded-word 안에만 있어 Sieve 글에 `${` 가 생기지 않는다(지금처럼). 스크립트 90,000바이트 한도(400 `MAIL_013`)는 그대로다 —
서식 · 회사 밖 글 · 차단 목록이 함께 크면 걸릴 수 있다(알려진 한계).
- **원본 판 3** — 머리 주석의 원본(JSON)은 **새 칸을 쓴 직원만** 판 3 으로 쓴다(서식 본문이 있거나 · 회사 밖 응답이 사내와 같지 않거나 · 회사 밖 글을 써 둔 경우 — 판 3 은 차단 목록 칸을 늘 담는다).
새 칸을 쓰지 않은 직원은 판 1 · 2 그대로다. 판 3 의 서식 본문은 읽을 때 다시 정화한다(다른 메일 프로그램이 고친 원본의 HTML 이 화면에 그대로 가지 않게 — 정화할 수 없으면 깨진 원본).
**전제 — 서식이 없는 글이면 화면은 `htmlBody: null` 을 보낸다**(웹 설정 화면 — 글만 쓴 직원은 판 1 · 2 에 머문다). 서버는 서식이 있는지 가리지 않고
정화한 `htmlBody` 가 남으면(null · 빈 값이 아니면) 판 3 으로 쓴다 — 그래서 판 3 은 서식을 쓰거나 회사 밖 응답 칸을 쓴 직원뿐이고, 아래 되돌리기 계획이 막는 직원도 그들뿐이다.
- **되돌리기 계획**: 판 3 을 모르는 서버(15단계까지)로 되돌리면 **새 칸을 쓴 직원만** 필터 · 부재중 · 차단 저장이 503 `MAIL_001` 이고 부재중 화면은 꺼짐으로 보인다 —
엔진의 응답(Sieve)은 그대로 돈다(옛 서버가 새 판을 지우지 않게 — 6단계의 규칙). 그 직원들이 저장해야 하면 16단계 서버로 다시 올린다
(판 3 을 읽기만 하는 서버를 먼저 배포할 수 있게 둔다 — 9단계 판 2 와 같은 길).
- 로그: 켬 · 끔 · 서식 있음 · 회사 밖 고르기만 남긴다(제목 · 글 · 서식은 남기지 않는다).
- **클라이언트**: 설정 › 메일 › 부재중 자동 응답. 켜져 있으면 메일 화면의 메일함 패널 아래(사용량 위)에 "부재중 자동 응답 켜짐 · 11월 3일까지"(누르면 설정)를 보인다 —
메일 화면에 들어올 때 한 번 받고, 시간이 지나면 다시 계산한다(끝나는 시각이 지나면 숨기고, 시작 전이면 "10월 30일 ~ 11월 3일 · 설정").
@@ -1319,6 +1410,7 @@ interface MailFilterActions {
- 4단계 `/api/mail-vacation` 은 계약 그대로 두고 저장 자리만 이 스크립트로 옮긴다 — 엔진의 VacationResponse 는 더 쓰지 않는다.
스크립트가 아직 없는 계정은 처음 읽을 때 VacationResponse 의 값을 가져온다(개발 데이터 이어받기). 엔진의 `vacation` 스크립트는 고칠 수 없다(엔진이 `forbidden`).
- 스크립트 머리의 주석에 규칙 · 부재중 원본(JSON — 판 번호 `v` 를 함께 둔다)을 두고, 읽을 때는 그것만 믿는다(엔진의 Sieve 를 다시 해석하지 않는다).
판은 쓰는 칸에 따라 가장 낮은 것으로 쓴다 — 1: 규칙 · 부재중, 2: + 차단 목록(9단계 — 아래 "보낸 사람 차단"), 3: + 부재중의 서식 본문 · 회사 밖 응답(16단계 — 위 "부재중 자동 응답").
- 스크립트가 아직 없으면 빈 규칙 + `isActive: true`, 부재중은 엔진의 VacationResponse 값(위 이어받기).
- 스크립트는 있는데 원본이 없거나 깨졌으면 빈 규칙 + `isActive: false`, 부재중은 꺼짐 — 저장하면(필터든 부재중이든) 덮어쓴다.
- 원본의 판이 이 서버가 아는 것보다 새로우면(새 서버를 배포했다 되돌린 경우) 읽기는 위와 같지만 **쓰기는 거절한다**(503 `MAIL_001`) — 옛 서버가 새 판의 규칙을 지우지 않게.
@@ -1341,6 +1433,7 @@ interface MailFilterActions {
(그 메일은 엔진 판정대로 들어간다). 엔진의 스팸 필터는 바깥에서 오는(인증 없는) 메일에만 돌아 사내 메일에는 이 머리글이 없다 — 규칙이 그대로 적용된다.
- 부재중: 엔진이 하던 대로 `vacation :mime`(본문 base64) — 제목은 RFC 2047 로 인코딩해 넣는다. Stalwart 는 require 와 상관없이 문자열의 `${…}` 를 늘 변수로 펼쳐서
그대로 넣으면 지워진다(같은 날 확인). 기간은 `currentdate :zone "+0000"`(엔진 시간대에 기대지 않게 — 같은 날 확인). 같은 사람에게 다시 답하는 간격은 엔진 기본값(지금과 같다).
16단계의 서식 본문(글 + 서식 두 벌) · 회사 밖 응답(봉투 도메인으로 가름) · 긴 개체의 `variables` 조각은 위 "부재중 자동 응답"의 "만드는 Sieve".
- 규칙 값에 `${` 는 받지 않는다(400 `VAL_001`) — 펼쳐져 빈 값이 되면 `:contains` 가 모든 메일에 맞는다. 문자열은 `"` · `\` 를 이스케이프한다.
`%{…}`(Stalwart 의 식 모양)는 펼치지 않아 막지 않는다 — `%{"a"}` · `%{}` · `%{1+1}` · `%{subject}` 모두 글자 그대로 견주었다(같은 날 확인).
- 크기: 엔진 한도는 스크립트 102,400바이트 · 문자열 4,096바이트다. 만든 스크립트가 90,000바이트를 넘으면 저장하지 않고 400 `MAIL_013`.
@@ -1684,11 +1777,12 @@ interface MailSettings {
### 이 단계에서 하지 않는 것
탭을 모두 닫아도 오는 알림(웹 푸시 — 엔진은 `webpush-vapid` 를 지원한다), 알림 소리(운영체제 설정을 따른다), 휴대폰 푸시, 본문 안 이미지(인라인 cid — 붙여 넣은 이미지는 첨부로, 서명 그림만 예외), 메일 안의 배경 그림 보이기 · 보낸 사람마다 고르는 "이미지 늘 보기" 목록(9단계는 설정 세 가지로),
여러 개의 서명(자체 검색 색인 · 조건 검색 · 검색어 강조는 14단계에서 했다 — 위 "검색 — 14단계"),
받는 사람에 따라 서명을 저절로 바꾸기 · 앱에서 서명 고르기(16단계 서명은 기본 둘과 쓰기 화면의 [서명 ▾]로 — 자체 검색 색인 · 조건 검색 · 검색어 강조는 14단계에서 했다),
메일함을 끌어 옮기기 · 메일함 순서 바꾸기(15단계는 ⋯ 메뉴 「옮기기」 창으로 — 메일을 끌어다 메일함에 놓기는 15단계에서 했다, 서버는 지금 `PATCH /api/messages` 그대로), 옮기기 단축키,
부재중 응답의 서식 본문 · 사내/외부 구분(앱 비밀번호 · 2단계 인증은 12단계에서 했다), 7일 넘는 예약(엔진 한도), 예약됨 목록 줄의 [지금 보내기](15단계는 읽기 화면의 예약 안내 줄에만),
부재중 응답의 「내 연락처에게만」(엔진 Sieve 는 주소록을 보지 못한다) · 부재중 응답의 그림(앱 비밀번호 · 2단계 인증은 12단계에서 했다), 7일 넘는 예약(엔진 한도), 예약됨 목록 줄의 [지금 보내기](15단계는 읽기 화면의 예약 안내 줄에만),
차단 정리의 되돌리기 · 받은편지함 밖 메일함의 정리(15단계는 받은편지함만 — 위 "보낸 사람 차단"의 "받은편지함 정리").
15단계에 한 것: 메일함 옮기기 · 하위 메일함까지 지우기(위 "메일함 관리"), 차단할 때 받은편지함 정리(위 "보낸 사람 차단"), 예약한 메일 지금 보내기(위 "보내기 취소 · 예약 발송").
16단계에 한 것: 여러 서명 · 「메일에 넣을 서명」 둘(위 "서명"), 부재중 응답의 서식 본문 · 회사 밖 응답(위 "부재중 자동 응답").
---
@@ -1790,6 +1884,10 @@ interface DirectoryEntry {
| `POST /api/contact-groups/{id}/members` | `{ contactIds?, directoryIds? }` — 합쳐 1~100개 | 200 `ContactGroupMembersAdded` | 400 `VAL_001`, 404 `RES_001`(없는 그룹 · 연락처 · 사내 목록 id), 409 `CONTACT_002`, 503 `MAIL_001` |
| `GET /api/directory-entries` | `q`(선택, 1~254자) · `kind`(선택 — `person` · `shared`) · `excludeGroupId`(선택 — 그 그룹 구성원의 주소를 뺀다. 구성원 넣기 창) · `cursor` · `limit`(기본 20, 최대 100) | 200 `{ items: DirectoryEntry[], pageInfo }` — 이름 차례, `total` 포함 | 400 `VAL_001`, 404 `RES_001`(없는 그룹), 503 `MAIL_001`(사내 목록을 읽지 못함 — 아래 "사내 목록") |
| `GET /api/directory-entries/{id}` | — | 200 `DirectoryEntry` | 404 `RES_001`(없거나 빼는 계정), 503 `MAIL_001` |
| `POST /api/contact-import-checks` — 16단계 | `{ contacts: ContactImportItem[] }` — 1~100명 | 200 `ContactImportCheck` — 사람마다 `new` · `exists`, 남은 자리(아래 "가져오기 · 내보내기") | 400 `VAL_001`, 503 `MAIL_001` |
| `POST /api/contact-imports` — 16단계 | `{ contacts: ContactImportItem[] }` — 1~100명 | 201 `ContactImportResult` — 넣은 연락처(같은 uid 로 이미 있던 것 포함), 보낸 차례 | 400 `VAL_001`, 404 `RES_001`(없는 groupId), 409 `CONTACT_002`(연락처 3,000개 · 크기 — 묶음 전체로, 하나도 만들지 않음), 503 `MAIL_001` |
| `POST /api/contact-deletions` — 16단계 | `{ ids }` — 1~500개, 겹치지 않게 | 200 `ContactsDeleted` — 영구 삭제(되돌리기 표 없음) | 400 `VAL_001`, 503 `MAIL_001` |
| `GET /api/contact-exports` — 16단계 | `format`(`vcard` · `csv`) · `groupId`(선택 — 그 그룹만) | 200 파일 — `text/vcard` · `text/csv`(표준 래퍼 없음, `attachment`) | 400 `VAL_001`, 404 `RES_001`(없는 그룹), 503 `MAIL_001` |
- **에러 코드(8단계에 더함)**: `CONTACT_001` 409 — 같은 이름의 그룹이 있다(만들기 · 이름 바꾸기 — 화면은 칸 아래에, 알림 없이).
`CONTACT_002` 409 — 주소록 한도(연락처 3,000개 · 그룹 100개 · 그룹 하나에 구성원 500명 · 아래 "크기")를 넘는다 — 아무것도 바꾸지 않는다
@@ -1875,6 +1973,62 @@ interface DirectoryEntry {
- **읽기 화면 ⋯**: 보낸 사람 주소가 내 주소록에 없으면 「보낸 사람을 주소록에 추가」(이름 · 주소를 채운 연락처 추가 창), 있으면 「주소록에서 보기」.
판단은 메뉴를 열 때 `GET /api/contacts?q=<보낸 사람 주소>` 로 알림 없이 하고, 주소가 같은(대소문자 무시) 연락처가 있는지 본다.
### 가져오기 · 내보내기 — 16단계 (2026-11-10)
화면은 디자인 원본 `Contacts16`(디자인 README "16단계"). **파일은 화면(브라우저)이 읽는다** — vCard(2.1 · 3.0 · 4.0, 여러 명) · CSV(네이버 · Google · Outlook · 우리가 내보낸 꼴) · 엑셀 .xlsx 를
화면이 풀어 미리 보기까지 하고, 넣기로 한 연락처만 100명씩 서버로 보낸다(파일은 서버로 가지 않는다 — 개인 정보 · 새 서버 파서 없음). 서버는 연락처 추가와 같은 검사 · 같은 한도로 한 요청에 엔진 요청 하나로 만든다.
```ts
interface ContactImportItem extends ContactInput { // 연락처 추가 본문(ContactInput)의 칸 · 검사 그대로 +
uid: string // 'urn:uuid:' + 소문자 UUID — 화면이 사람마다 만든다(엔진 카드의 uid 가 된다)
}
interface ContactImportCheck { // POST /api/contact-import-checks → 200
items: { uid: string; status: 'new' | 'exists' }[] // 보낸 차례
remaining: number // 3,000 − 지금 연락처 수 (0 아래로 내려가지 않는다)
}
interface ContactImportResult { // POST /api/contact-imports → 201
items: { uid: string; id: string; skippedGroupIds: string[] }[] // 넣은 연락처만, 보낸 차례 — 빠진 uid 는 넣지 못한 것
}
interface ContactsDeleted { deletedIds: string[]; notFoundIds: string[] } // POST /api/contact-deletions → 200 (보낸 차례)
```
- **확인**(`POST /api/contact-import-checks`): 만들지 않는다. `groupIds` 는 보지 않는다(새 그룹은 넣기 직전에 만든다). 사람마다 아래 가운데 하나면 `exists`, 아니면 `new`:
- 이메일이 있는 사람 — 그 이메일 가운데 하나라도(앞뒤 공백을 빼고 소문자로 견준다) 내 주소록의 어느 연락처에 있다.
- 이메일이 없는 사람 — 이름(앞뒤 공백만 뺀 글 — 가운데 공백 · 대소문자는 그대로)이 같고 **전화번호의 숫자 모음**이 같은 연락처가 있다.
숫자 모음은 번호마다 숫자만 남긴 글의 모음이다(겹침 · 차례는 보지 않는다, 숫자가 없는 번호는 뺀다 — 번호 하나만 같으면 다른 사람이다). 주소록 쪽 연락처는 이메일이 있어도 된다.
둘 다 전화가 없으면 이름만 같아도 `exists`.
- 같은 uid 의 연락처가 이미 있다(다시 보낸 묶음).
**같은 파일 안의 겹침은 화면이 같은 규칙으로 본다**(파일 앞쪽에 같은 사람이 있으면 건너뛴다) — 짝: backend `modules/contacts/contact-import.ts` · frontend `shared/utils/contact-import`(한쪽을 고치면 둘 다).
- **넣기**(`POST /api/contact-imports`): 엔진 `ContactCard/set` 한 번(300명에 90ms — 2026-11-10 확인).
- 사람마다 보낸 uid 로 만든다. 같은 uid 의 연락처가 이미 있으면(응답을 잃고 다시 보낸 묶음) 새로 만들지 않고 그 id 를 돌려준다 — 빠진 그룹만 마저 넣는다. 두 번 들어가지 않는다.
- 연락처 3,000개 · 크기(800만 — 위 "크기") 한도는 **묶음 전체로** 미리 본다 — 넘으면 409 `CONTACT_002`, 하나도 만들지 않는다(화면은 `remaining` 으로 미리 줄인다).
- 그룹 구성원 500명을 넘기는 넣기는 그 그룹만 빼고 만들고 `skippedGroupIds` 에 적는다 — 실패가 아니다(보낸 차례대로 넣다가 찬 그룹부터 뺀다). 없는 그룹은 404 `RES_001`(연락처 추가와 같다).
- 검사(연락처 추가와 같은 칸 규칙 · 이름이나 이메일 · 같은 연락처 안의 같은 주소 · uid 모양 · 묶음 안의 같은 uid)에 걸리는 사람이 하나라도 있으면 400 `VAL_001` — 묶음 전체다(화면이 같은 규칙으로 미리 거른다). 겹침은 보지 않는다(확인의 몫).
- 메일 서버가 일부만 받으면 받은 것만 `items` 에 — **보낸 uid 가 빠져 있으면 넣지 못한 것이다**(화면은 "넣지 못함"으로). 던지지 않는다. 그룹 카드가 이미 쓰는 uid 도 만들지 않는다(엔진이 같은 주소록의 같은 uid 를 받지 않는다).
- **새 그룹**은 화면이 넣기 전에 지금 `POST /api/contact-groups` 로 만든다(같은 이름 409 `CONTACT_001` 이면 목록에서 그 그룹을 쓰고, 100개 한도 409 `CONTACT_002` 면 그 그룹 없이). 되돌릴 때 그 그룹은 `DELETE /api/contact-groups/{id}`.
- **한꺼번에 지우기**(`POST /api/contact-deletions`): 가져오기 [되돌리기] 몫이다. 영구 삭제이고 되돌리기 표는 주지 않는다. 지운 연락처를 가리키는 그룹 구성원도 함께 뺀다(같은 uid 의 다른 카드가 남으면 그대로).
내 주소록에 없는 id(이미 지움 · 그룹 id)는 `notFoundIds` — 실패가 아니다. 엔진 한 요청에 담는 카드 수(지울 연락처 + 고칠 그룹)가 엔진 상한(`maxObjectsInSet` 500)을 넘으면 서버가 앞에서부터 나눠 지운다 —
중간에 503 이면 앞 몫은 지워진 채다(같은 목록을 다시 보내면 그 id 는 `notFoundIds`). 지울 카드는 id 만 읽는다(사진이 든 카드를 통째로 읽지 않는다).
- **요청 수 한도**는 지금 기본(경로 · IP 1분 100회) — 100명씩이면 3,000명도 30번이다. 사무실이 IP 하나로 나가면 그동안 다른 직원의 같은 경로 요청과 나눠 쓴다.
- **본문 한도**는 앱 공통(JSON 1MB · 값 10,000개). 보통 100명이 50KB 남짓이고, 칸이 많은 연락처(네이버 꼴 — 전화 20개 · 이메일 4개 · 그룹 4개)로 채워도 100명이 값 8,600개 남짓이다.
칸을 한도까지 채운 연락처가 몰려 400 `VAL_001`(본문 모양) · 413 `FILE_002` 가 나면 화면은 묶음을 줄여 보낸다(권장).
- **내보내기**(`GET /api/contact-exports`): 파일 응답(표준 래퍼 없음 — 실패는 표준 실패 JSON). 화면은 첨부 받기처럼 이동 직전에 세션을 확인하고 `<a href download>` 로 받는다.
- 내 주소록 전체 또는 그룹 하나(`groupId`), 이름 차례. 사내 목록은 넣지 않는다(회사 직원 명단을 파일로 밖에 내보내는 길을 만들지 않는다). 우리 칸만 — 엔진 카드의 다른 칸(주소 · 생일 · 사진 등)은 넣지 않는다.
- 머리글: `Content-Disposition: attachment` — 이름 `주소록-YYYY-MM-DD.vcf` · `주소록-<그룹 이름>-YYYY-MM-DD.csv`(날짜는 한국 날짜, 그룹 이름의 `\ / : * ? " < > |` 는 `_`, 앞뒤 공백 · 점은 떼고 50글자까지) ·
`Cache-Control: private, no-store` · `X-Content-Type-Options: nosniff` · `Content-Security-Policy: default-src 'none'; sandbox`.
- **vCard 3.0**(`text/vcard; charset=utf-8`): 줄 끝 CRLF · 한 줄 75바이트에서 접는다(UTF-8 글자를 가르지 않는다 — 이어지는 줄은 공백 하나로 시작) · 글 값의 `\` `;` `,` 줄바꿈은 이스케이프.
`UID`(카드에 있으면) · `FN`(이름 — 없으면 첫 이메일, 그것도 없으면 첫 전화번호 — vCard 3.0 은 FN 을 꼭 담는다) · `N`(이름 전체를 성 칸에 — `N:김아라;;;;`, 이름이 없으면 `N:;;;;` — 다시 가져올 때 주소를 이름으로 읽지 않게) · `EMAIL;TYPE=INTERNET,WORK`(회사) / `INTERNET,HOME`(개인) / `INTERNET`(기타) ·
`TEL;TYPE=CELL`(휴대전화) / `WORK` / `HOME` / `VOICE`(기타) · `ORG:회사;부서` · `TITLE` · `NOTE` · `CATEGORIES`(그룹 이름, 쉼표로). 한 파일에 여러 명이다.
- **CSV**(`text/csv; charset=utf-8`): UTF-8 BOM · CRLF · 머리 `이름,이메일,휴대폰,회사 전화,집 전화,기타 전화,회사,부서,직함,메모,그룹` — 이메일은 종류 없이 모두, 같은 칸에 여럿이면 `; ` 로 잇는다.
칸이 `= + - @` · 탭 · CR 로 시작하면 앞에 `'` 를 붙인다(엑셀 수식 막기 — OWASP CSV Injection, 다시 가져올 때 떼어 읽는다, 네이버 CSV 와 같은 꼴). 쉼표 · 따옴표 · 줄바꿈이 든 칸은 따옴표로 감싼다(RFC 4180 — 메모의 줄바꿈은 LF 그대로).
우리 가져오기가 이 머리 줄을 알아본다 — 머리 이름 · 차례를 바꾸면 frontend `shared/utils/contact-import` 도 함께.
- 없는 그룹 404 `RES_001`, 모르는 `format`(없음 포함) · 모르는 쿼리 400 `VAL_001`.
- **로그**: 개수 · 형식 · 그룹 id 만 — 연락처 내용 · 파일 내용 · uid 는 남기지 않는다.
- **알려진 한계**: 한 파일에 여러 명이 든 vCard 를 Outlook 클래식은 첫 사람만 읽는다. 그룹 이름에 `;` 가 있으면 CSV 의 그룹 칸이 둘로 읽힌다. 넣기의 일부를 엔진이 받지 않으면 그 사람의 uid 가 그룹 구성원에 남는다
(그룹 구성원 수는 주소록에 있는 연락처만 세므로 보이지 않는다 — 같은 uid 로 다시 넣으면 그 그룹에 들어간다).
- **이 단계에서 하지 않는 것**: 주소록 칸 늘리기(주소 · 생일 · 홈페이지 · 별명 · 사진 — 화면이 메모 끝 "가져온 정보" 줄로 붙인다), 겹친 연락처 합치기, 모르는 CSV 의 열을 손으로 맞추기, 옛 .xls,
서버에서 파일 읽기, 사내 목록 내보내기, 여러 연락처를 골라 지우는 화면(서버 길은 한꺼번에 지우기로 있다), 앱의 주소록.
---
## 4. 대용량 첨부 — 11단계 (2026-10-30 확정)
@@ -2650,7 +2804,7 @@ interface FileUploadCreate {
// GET /api/two-factor → 200
{ "isEnabled": true, "enabledAt": "2026-10-31T00:12:00.000Z" } // enabledAt — 웹메일에서 켠 때. 엔진 관리 화면에서 켰으면 null
// POST /api/two-factor/enrollments → 201 (꺼져 있을 때만, Cache-Control: no-store)
// POST /api/two-factor/enrollments → 201 (꺼져 있으면 켜기용 · 16단계부터 켜져 있으면 휴대폰 바꾸기용 — 아래 "휴대폰 바꾸기", Cache-Control: no-store)
{ "enrollmentId": "…", "secret": "JBSWY3DPEHPK3PXP…(32자)",
"otpauthUrl": "otpauth://totp/ARA%20SYSTEM:ara.kim%40arasystem.it.kr?secret=…&issuer=ARA%20SYSTEM&algorithm=SHA1&digits=6&period=30",
"expiresAt": "2026-10-31T00:22:00.000Z" }
@@ -2672,20 +2826,62 @@ interface FileUploadCreate {
3. 대리 접근으로 `currentSecret` + `otpAuth.otpUrl` 을 보낸다. 켠 코드는 2분 기억하고 등록의 비밀은 바로 지운다(`enabledAt` 기록이 실패해도 성공 — 엔진이 기준).
- 끄기: 비밀번호를 본인 Basic 으로 먼저 확인(402) → 다시 쓴 코드인지 확인 → 대리 접근으로 `currentSecret` + `otpAuth: { otpCode, otpUrl: null }`. 이미 꺼져 있으면 200 이다(같은 결과). 엔진에서 꺼진 뒤 `enabledAt` 기록 지우기가 실패해도 200 이다(error 로그 — 엔진이 기준).
- 켜고 끄는 일은 웹메일 세션을 끊지 않는다 — 로그인 기록 화면에 [다른 곳 모두 로그아웃]이 따로 있다. 이미 열린 IMAP 연결은 다시 연결할 때까지 남는다(알려진 한계).
- 같은 직원의 2단계 인증 바꾸기(등록 · 켜기 · 끄기 · 2단계를 켠 직원의 비밀번호 바꾸기)는 한 번에 하나다 — 처리 중이면 429 `REQ_001`.
- 휴대폰 바꾸기: 끈 뒤 다시 켠다(켜진 채 다시 등록하기는 처음 판에 없다).
- 같은 직원의 2단계 인증 바꾸기(등록 · 켜기 · 끄기 · 2단계를 켠 직원의 비밀번호 바꾸기 · 16단계 휴대폰 바꾸기의 확인 · 바꾸기)는 한 번에 하나다 — 처리 중이면 429 `REQ_001`.
- 휴대폰 바꾸기: 16단계부터 켠 채 바꾼다 — 아래 "휴대폰 바꾸기 (16단계)". 등록은 켜져 있어도 받는다(엔진에 묻지 않는다) — 켜진 채 받은 등록으로 켜기를 보내면 비밀번호 확인이 402 라 409 `AUTH_008` 이다.
- 휴대폰을 잃었으면 운영자가 엔진 관리 화면에서 그 직원의 OTP 를 지운다(직원 비밀번호 없이 된다 — 절차는 mail/README).
| 실패 | 코드 |
| --- | --- |
| 코드 틀림 · 다시 쓴 코드 | 400 `AUTH_006` |
| 지금 비밀번호 틀림 · 앱 비밀번호 모양 | 400 `AUTH_005` |
| 등록이 없음 · 10분 지남 · 더 새 등록으로 바뀜 · 그사이 다른 곳에서 켜짐(등록 · 켜기) | 409 `AUTH_008` — 화면은 처음부터 다시 |
| 등록이 없음 · 10분 지남 · 더 새 등록으로 바뀜 · 그사이 다른 곳에서 켜짐(켜기 — 16단계부터 등록은 켜져 있어도 201) | 409 `AUTH_008` — 화면은 처음부터 다시 |
| 형식 오류 | 400 `VAL_001` |
| 코드 · 비밀번호 실패 한도, 처리 중 | 429 `REQ_001` |
| 웹메일로 다루지 않는 계정 | 401 `AUTH_001` |
| 엔진 장애 · 결과를 모르는 실패 | 503 `MAIL_001` — 켜기 · 끄기의 결과를 모르면 엔진을 다시 읽어 맞춘다(바뀌었으면 성공으로) |
### 휴대폰 바꾸기 (16단계 — 2026-11-10)
2단계 인증을 **켠 채** 새 휴대폰으로 옮긴다 — 2단계가 꺼지는 틈이 없어 필수 정책인 직원도 바꾼다(화면은 [끄기]를 숨긴 직원에게도 [휴대폰 바꾸기]를 보인다). 화면은 디자인 원본 `Settings16`(창 하나 · 두 걸음).
```jsonc
// 0) 등록 — 켜져 있어도 받는다 (응답은 켜기와 같은 꼴, Cache-Control: no-store)
// POST /api/two-factor/enrollments → 201 { "enrollmentId": "…", "secret": "…", "otpauthUrl": "otpauth://…", "expiresAt": "…" }
// ① 새 휴대폰의 코드 — POST /api/two-factor/enrollments/{enrollmentId}/verification → 200
{ "otpCode": "123456" }
// → { "verified": true }
// ② 지금 휴대폰의 코드 + 지금 비밀번호 — POST /api/two-factor/replacements → 200 (GET /api/two-factor 와 같은 모양)
{ "enrollmentId": "…", "currentOtpCode": "654321", "currentPassword": "…" }
```
- **①** 새 휴대폰의 코드를 등록의 비밀로 확인하고(앞뒤 한 단계) 등록에 "확인됨"을 단다 — 엔진은 등록 때 코드를 보지 않으므로 서버가 먼저 본다. 엔진에는 아직 아무것도 바꾸지 않는다.
코드 확인은 켜기 · 끄기와 같은 코드 가드다(코드 실패 한도 · 받은 코드는 2분 동안 다시 받지 않는다). 확인은 등록에 남는다(10분) — [이전]으로 돌아와도 다시 확인하지 않아도 된다(같은 코드로 다시 확인하면 2분 안에는 `AUTH_006`).
- **②** 서버의 차례
1. 등록이 "확인됨"이 아니면 409 `AUTH_008` — 그 등록은 버린다(처음부터).
2. 앱 비밀번호 모양의 지금 비밀번호는 엔진에 묻지 않고 400 `AUTH_005`(세지 않는다). 지금 비밀번호를 본인 Basic 으로 본다 — 402(켜짐 · 비밀번호 맞음)여야 한다.
401 이면 400 `AUTH_005`(로그인 실패 한도로 센다), 200 이면 그사이 다른 곳에서 꺼진 것이라 409 `AUTH_008`(등록을 버린다 — 화면은 상태를 다시 받는다).
엔진은 비밀번호 틀림과 지금 코드 틀림을 가르지 않으므로(둘 다 `forbidden` "Current secret is incorrect.") 비밀번호를 먼저 본다.
3. 다시 쓴 코드인지 보고 코드 실패 한 번을 차지한다(끄기와 같은 결) → 대리 접근 `x:AccountPassword/set` 에 `currentSecret` + `otpAuth: { otpCode: <지금 코드>, otpUrl: <새 otpauth 주소> }` 한 요청
(2026-11-10 개발 엔진 — `updated`, 옛 비밀은 바로 죽고 거듭 바꿀 수 있다).
4. `forbidden` 이면 지금 코드 틀림 400 `AUTH_006`(코드 실패로 센다 — 등록은 남아 맞는 코드로 다시 보낸다). 결과를 모르면(시간 초과 · 연결 끊김 · 5xx) 503 `MAIL_001` —
엔진이 `otpUrl` 을 가려(`****`) 다시 읽어도 바뀌었는지 가를 수 없다. 그 코드는 쓰지 않은 것으로 두고 등록도 남긴다.
5. 바뀌면 등록의 비밀을 지운다(새 비밀은 엔진에만). 두 코드는 2분 동안 다시 받지 않는다. 켠 때(`enabledAt`)는 그대로 — 로그인된 기기는 끊지 않고 알림 메일도 보내지 않는다(켜기 · 끄기와 같다).
- 제한된 세션은 ① · ② 모두 403 `AUTH_010`(등록은 지금처럼 제한된 세션도 부른다). 같은 직원의 2단계 바꾸기와 함께 돌지 않는다(처리 중 429 `REQ_001`).
- 지금 휴대폰을 잃었으면 지금처럼 운영자의 「2단계 해제」 뒤 다시 켠다(필수 정책이면 다음 로그인의 켜기 단계 — 화면은 창 ②에 문의 줄).
- 로그 · 오류 context 에 코드 · 비밀번호 · otpauth 주소 · 엔진 설명 문장은 없다(가린 주소 · 사유만). 바꾸면 info "2단계 인증 휴대폰을 바꿨습니다: a***@…".
| 실패 | 코드 |
| --- | --- |
| 코드 · 등록 id 형식(경로의 id 가 UUID 가 아님 포함) | 400 `VAL_001` |
| ① 새 휴대폰의 코드 틀림 · ② 지금 휴대폰의 코드 틀림 · 2분 안에 다시 쓴 코드 | 400 `AUTH_006` |
| ② 지금 비밀번호 틀림 · 앱 비밀번호 모양 | 400 `AUTH_005` |
| 등록이 없음 · 10분 지남 · 더 새 등록 · ②에서 확인(①)하지 않은 등록 · 그사이 다른 곳에서 꺼짐 | 409 `AUTH_008` — 화면은 처음부터(새 등록) |
| 제한된 세션 | 403 `AUTH_010` |
| 코드 · 비밀번호 실패 한도, 처리 중 | 429 `REQ_001` |
| 엔진 장애 · ②의 결과를 모름 | 503 `MAIL_001` |
### 앱 비밀번호 (`/api/app-passwords`)
```jsonc
@@ -2859,10 +3055,11 @@ interface FileUploadCreate {
- 끊은 세션 `DELETE /api/auth/sessions/{id}` 의 모양이 틀린 id 는 404 `RES_001`(없는 것과 같다).
- 엔진 관리 화면에서 켠 2단계 인증은 `enabledAt` 이 없다.
- 복구 코드가 없다 — 휴대폰을 잃으면 운영자에게 문의한다.
- 휴대폰 바꾸기(16단계)의 ②가 503 `MAIL_001`(결과를 모름)로 끝난 뒤 같은 등록으로 다시 보내면, 그사이 바뀌었다면 옛 휴대폰의 코드가 죽어 `AUTH_006` 이다 — 새 휴대폰의 코드로 로그인되는지로 안다.
### 이 단계에서 하지 않는 것
복구 코드, 이 브라우저 기억, 문자 · 메일 코드, 보안 키(WebAuthn) · 패스키, 관리자가 2단계를 강제하기(13단계), 켜진 채 휴대폰 바꾸기,
복구 코드, 이 브라우저 기억, 문자 · 메일 코드, 보안 키(WebAuthn) · 패스키, 관리자가 2단계를 강제하기(13단계), 켜진 채 휴대폰 바꾸기(16단계에서 했다 — 위 "휴대폰 바꾸기"),
앱 비밀번호 이름 바꾸기 · 만료 · 허용 IP, 새 기기 로그인 알림 메일, 계정 변경 기록(비밀번호 바꾸기 · 2단계 켜기 등), 메일 프로그램의 OAuth 로그인, 모바일 앱.
### 정한 것 (2026-10-31 추천대로)
+3 -1
View File
@@ -60,9 +60,11 @@
7일 넘는 예약(메일 엔진 한도라 어렵다).
- **검색**(14단계에서 미룸): 강조한 곳 사이 다음 · 이전 이동, 검색어 줄임말(`from:` 꼴), 첨부 파일 안의 글 찾기, 끝이 바뀐 말 찾기(형태소 분석).
- **메일함 정리**(15단계에서 미룸): 메일함을 끌어 옮기기 · 순서 바꾸기, 옮기기 단축키, 예약됨 목록 줄의 [지금 보내기], 차단할 때 옮긴 메일 한꺼번에 되돌리기 · 받은편지함 밖 메일함 정리.
- **개인 설정**(16단계에서 미룸): 주소록 칸 늘리기(주소 · 생일 · 홈페이지 · 별명 · 사진), 겹친 연락처 합치기, 모르는 CSV 의 열을 손으로 맞추기, 옛 엑셀(.xls), 여러 연락처 고르기 · 한꺼번에 지우기,
받는 사람에 따라 서명 저절로 바꾸기, 앱에서 서명 고르기, 부재중 응답을 내 연락처에게만 · 그림 넣기.
- **대용량 첨부**(11단계에서 미룸 — 파일 저장소 때 함께): 보관 기간 고르기 · 연장, 다시 보내기, 받는 사람 확인 코드 · 비밀번호, 사내 직원만 받기, 받는 사람별 기록,
처음 받음 · 만료 전 알림, 미리 보기, 모두 받기(zip), 폴더 올리기, 링크 → 메일에 담기 되돌리기.
- **계정 · 보안**(12 · 13단계에서 미룸 — 공통 기반 때 함께): 복구 코드, 이 브라우저 기억, 패스키, 새 기기 로그인 알림 메일, 계정 변경 기록, 로그인 기록의 나라 · 지역,
- **계정 · 보안**(12 · 13 · 16단계에서 미룸 — 공통 기반 때 함께): 복구 코드, 이 브라우저 기억, 패스키, 새 기기 로그인 알림 메일, 2단계 휴대폰을 바꿀 때 알림 메일, 계정 변경 기록, 로그인 기록의 나라 · 지역,
앱 비밀번호 이름 바꾸기 · 만료, 비밀번호 스스로 재설정, 공용 주소로 보내기 · 공유 메일함, 웹메일 관리자 화면.
- **앱**: 앱 안에서 받아 바로 설치, 최소 판이 아닌 새 판 알림, 아이폰, Google 개발자 인증(2027년 전 세계 — 그 전에 등록할지 정한다).