From d6b804ba837ecb86f8b65af957160369554e1954 Mon Sep 17 00:00:00 2001 From: ttipo Date: Tue, 10 Nov 2026 13:30:08 +0900 Subject: [PATCH] =?UTF-8?q?fix(frontend):=2016=EB=8B=A8=EA=B3=84=20?= =?UTF-8?q?=EC=8B=A4=EC=82=AC=EC=9A=A9=20=EC=8B=9C=ED=97=98=EC=97=90?= =?UTF-8?q?=EC=84=9C=20=EB=82=98=EC=98=A8=20=EB=91=98=EC=9D=84=20=EA=B3=A0?= =?UTF-8?q?=EC=B9=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 주소록 가져오기 창의 미리 보기가 1366×768 · 1440×900 에서 잘리고 창도 스크롤하지 않았다 — 창()은 flex 열이라 overflow 를 건 세 묶음 상자가 내용보다 작게 눌렸다. 상자에 flex-shrink: 0 을 두고 가져오기 · 내보내기 창은 창 안이 스크롤한다 - 서명 위 빈 줄에 한글을 조합해 쓰면 띄어쓰기를 칠 때까지 빈 줄이 돌아오지 않았다 — 조합이 끝나면(compositionend 뒤 ProseMirror 가 조합을 마무리한 다음 차례) 편집기가 스스로 빈 줄을 넣는다. 조합 중에 임시 저장한 뒤 편집기가 넣은 빈 줄은 바꿈으로 세지 않는다 - 디자인 README · vue3 스킬에 두 가지를 적는다(모달 창의 flex 열 — 창 바로 아래 칸에 overflow: hidden 을 걸면 flex-shrink: 0 을 함께) Co-Authored-By: Claude Opus 5.5 --- .claude/skills/vue3/SKILL.md | 6 +- docs/design/webmail/README.md | 5 +- .../contacts/ContactExportDialog.vue | 6 ++ .../contacts/ContactImportDialog.vue | 9 ++ .../contacts/ContactImportSummary.vue | 6 ++ .../ContactTransferDialogs.layout.spec.ts | 64 +++++++++++++ .../components/mail/editor/MailEditor.spec.ts | 36 ++++++- .../src/components/mail/editor/MailEditor.vue | 5 +- .../mail/editor/mail-signature-block.spec.ts | 93 ++++++++++++++++++- .../mail/editor/mail-signature-block.ts | 60 ++++++++++-- 10 files changed, 274 insertions(+), 16 deletions(-) create mode 100644 frontend/src/components/contacts/ContactTransferDialogs.layout.spec.ts diff --git a/.claude/skills/vue3/SKILL.md b/.claude/skills/vue3/SKILL.md index 4a4fda1..a98d40c 100644 --- a/.claude/skills/vue3/SKILL.md +++ b/.claude/skills/vue3/SKILL.md @@ -33,7 +33,7 @@ description: Vue 3 프론트엔드(frontend/) 개발 규칙 — Composition API | 디자인 토큰·글꼴 | `src/styles/tokens.css` · `src/styles/base.css` | 색·간격·글자 크기 변수. 글꼴은 npm `pretendard` 자체 호스팅(`main.ts` 에서 import — CSP `font-src 'self'`) | | 브랜드 자산 | `src/assets/brand/` · `src/shared/constants/brand.ts` | 흰 로고(전체·ARA 마크). 원본은 `docs/brand/`. 탭 · 홈 화면 아이콘(`favicon-16.png` · `favicon-32.png` · `apple-touch-icon.png`)은 `index.html` 이 `/src/assets/brand/…` 로 걸어 빌드가 `/assets/` 의 해시 파일로 바꾼다 — 배포 직후 새 아이콘이 보이고, 공개 내려받기 호스트도 `/assets/` 는 연다. `public/favicon.ico` 는 주소를 직접 묻는 클라이언트용. 모양 · 크기 규칙은 디자인 README 머리 | | 토스트 | `src/stores/toast.store.ts` · `src/components/feedback/ToastHost.vue` | 오류는 `useApi.ts` 의 `showErrorUI()`, 성공 안내는 `composables/useToast.ts`(API 가 아닌 화면 쪽 실패 — 예: 브라우저가 알림을 막음 · 보냈지만 받는 사람 일부가 받지 못함(+ [보낸편지함 보기], 문장은 `shared/utils/mail-send-result.ts`) — 는 `useToast().error()`). 알림 안의 단추는 `{ label, run }`(예: 임시 저장본 [되돌리기]), 이동 링크는 지금처럼. 단추가 있는 알림은 10초(`TOAST_ACTION_DURATION_MS`), 마우스 · 초점이 있는 동안은 `hold`·`release` 로 멈춘다. **단추는 띄운 세션에서만 움직인다** — `useToast` 가 `authStore.generation` 에 묶고, 로그아웃 · 세션 만료 · 다른 계정 로그인 때 단추가 있는 알림을 닫는다(메일 id 는 계정마다 겹칠 수 있다). 오래 걸리는 처리는 **진행 알림** `useToast().progress(글)` — 저절로 닫히지 않고(넘쳐도 다른 알림부터 닫는다) 글을 고치다가(`update`) 끝나면 그 자리에서 결과가 된다(`succeed` · `fail` — `toast.store` 의 `update`). 결과에도 단추를 붙일 수 있다(`succeed(글, action)` · `fail(글, action)` — 7단계 필터 적용의 [{메일함} 보기] · [다시 시도], 시간 · 세션 묶기는 다른 단추와 같다). 진행 알림도 세션이 바뀌면 닫는다. **✕로 닫을 때까지 남는 알림**(`sticky` — `useToast().success/error(…, { sticky: true })` · 진행 알림의 `succeed(글, action, { sticky: true })` · `fail(…)`)은 저절로 닫히지 않고 넘쳐도 다른 알림부터 닫는다(진행 · 마감 알림과 같다). ToastHost 가 성공 알림에도 ✕ 를 단다(`toast__close--on-brand`). 지금은 주소록 가져오기의 결과 · 멈춤 알림만 쓴다(16단계) — 여러 명을 한꺼번에 되돌리는 길이 그 알림뿐이라서. 세션이 바뀌면 다른 단추 알림처럼 닫는다 | -| 모달 대화상자 | `src/components/feedback/ModalDialog.vue` | 네이티브 ``(초점·Esc·닫힘 처리)와 모양. 세션 만료(`SessionExpiredDialog`)·나가기 확인(공용 `components/feedback/LeaveConfirmDialog.vue` — 쓰기 화면 `ComposeLeaveDialog.vue`·서명 설정 · 부재중 설정 · 필터 고치기 창 위(7단계)가 쓴다) · 메일함 대화상자(`components/mail/Mailbox*Dialog.vue` — 기본 슬롯에 이름 칸) · 필터 고치기 창(`components/settings/MailFilterDialog.vue` — 칸이 많은 창은 `wide`: 폭 620) · 주소록의 연락처 · 그룹 창(`medium`: 폭 400 — 8단계) · 서명 지우기 확인(`components/mail/editor/signature/SignatureDeleteDialog.vue` — 폭 360 · 처음 초점 「취소」) · 저장하지 않은 서명이 있을 때 다른 서명 고르기(`LeaveConfirmDialog` 의 `signature-switch` — 둘째 단추 [바꾼 내용 버리기])이 쓴다 — 새 대화상자도 이것으로 만든다. 되돌릴 수 없는 일의 확인은 처음 초점을 「취소」에 둔다. 초점을 지키려고 `aria-disabled` 로 막은 단추에는 마우스를 올린 모양이 걸리지 않는다. Esc 는 `cancel` 로 알리기만 한다 — 브라우저가 막지 못하고 스스로 닫았는데(Esc 를 거듭 누름) 쓰는 쪽이 닫지 않기로 했으면 `reshow()`(ref 로 부른다)로 다시 띄운다. 초점은 열린 동안 `focusin` 으로 기억한 창 안의 마지막 요소로 돌려준다(브라우저는 `close` 전에 초점을 창 밖으로 옮긴다) | +| 모달 대화상자 | `src/components/feedback/ModalDialog.vue` | 네이티브 ``(초점·Esc·닫힘 처리)와 모양. 세션 만료(`SessionExpiredDialog`)·나가기 확인(공용 `components/feedback/LeaveConfirmDialog.vue` — 쓰기 화면 `ComposeLeaveDialog.vue`·서명 설정 · 부재중 설정 · 필터 고치기 창 위(7단계)가 쓴다) · 메일함 대화상자(`components/mail/Mailbox*Dialog.vue` — 기본 슬롯에 이름 칸) · 필터 고치기 창(`components/settings/MailFilterDialog.vue` — 칸이 많은 창은 `wide`: 폭 620) · 주소록의 연락처 · 그룹 창(`medium`: 폭 400 — 8단계) · 서명 지우기 확인(`components/mail/editor/signature/SignatureDeleteDialog.vue` — 폭 360 · 처음 초점 「취소」) · 저장하지 않은 서명이 있을 때 다른 서명 고르기(`LeaveConfirmDialog` 의 `signature-switch` — 둘째 단추 [바꾼 내용 버리기])이 쓴다 — 새 대화상자도 이것으로 만든다. 되돌릴 수 없는 일의 확인은 처음 초점을 「취소」에 둔다. 초점을 지키려고 `aria-disabled` 로 막은 단추에는 마우스를 올린 모양이 걸리지 않는다. Esc 는 `cancel` 로 알리기만 한다 — 브라우저가 막지 못하고 스스로 닫았는데(Esc 를 거듭 누름) 쓰는 쪽이 닫지 않기로 했으면 `reshow()`(ref 로 부른다)로 다시 띄운다. 초점은 열린 동안 `focusin` 으로 기억한 창 안의 마지막 요소로 돌려준다(브라우저는 `close` 전에 초점을 창 밖으로 옮긴다). 창(`` — 열린 동안 flex 열)이 화면보다 길면 창 안이 스크롤한다(단추 줄 고정 없음). 창 바로 아래 칸에 `overflow: hidden` 을 걸면 `flex-shrink: 0` 을 함께 둔다 — 아니면 그 칸이 창을 넘기는 대신 눌려 잘린다(16단계 가져오기 창의 세 묶음 상자 — 2026-11-10 실사용 시험). 칸 안을 스크롤하는 목록(`overflow-y: auto`)은 줄어도 된다 | | 외부 HTML 격리 렌더링 | `src/components/mail/MailBodyFrame.vue` · `src/shared/utils/mail-html.ts` | 메일 본문 등 외부 HTML 은 이 컴포넌트로만 그린다 (sandbox·내부 CSP 근거는 파일 머리말). 보여 줄 HTML 고르기는 `displayedHtmlBody()` 하나다 — null 이거나 공백뿐이면 textBody (읽기 화면·인용 공용, 서버의 `isBodyTruncated` 와 같은 규칙) | | 메일 모듈 | `src/pages/mail/*` · `src/components/mail/*` | API 계약 `docs/mail/api-contract.md`, 디자인 원본 `docs/design/webmail/` | | 콘텐츠 열 토큰 | `src/styles/tokens.css` 의 `--content-*` · `--content-toolbar-start` | 읽기·쓰기 화면이 같은 최대 폭·여백·32px 시작선을 쓴다 (새 화면도 이 토큰으로) | @@ -42,7 +42,7 @@ description: Vue 3 프론트엔드(frontend/) 개발 규칙 — Composition API | 화면 이동 규칙 | `src/composables/useMailNavigation.ts` | 이전/다음은 replace, "목록으로"는 뒤로 가기 또는 replace (기록이 쌓이지 않게) | | 발송 키 규칙 | `src/shared/utils/idempotency-key.ts` 의 `shouldRenewSendKey()` | `Idempotency-Key` 는 성공과 `MAIL_005` 때만 바꾼다 — 그 밖의 실패 뒤 다시 보내기는 같은 키 (계약 2절 "발송 본문"). UUID 만들기는 `shared/utils/uuid.ts` 하나(필터 id 와 공용 — `crypto.randomUUID` 가 없으면 대체 경로) | | 첨부 사전 검사 | `src/shared/utils/mail-attachment.ts` 의 `precheckAttachment()` | 형식·파일당·합계 한도(`mail.store` 의 `attachmentMaxBytes`·`attachmentTotalMaxBytes`, 서버 값이 없으면 대체값). 넘는 파일은 올리지 않는다 | -| 메일 본문 편집기 | `src/components/mail/editor/` — `MailEditor.vue` (쓰기 화면이 `defineAsyncComponent` 로 불러온다) | TipTap core (MIT 무료 패키지만, `@tiptap/*` 는 모두 같은 버전으로 고정). 툴바 배치·좁은 폭에서 접는 순서·단축키는 `docs/design/webmail/README.md` "메일 쓰기". 보낼 모양은 `mail-serialize.ts` — 서식 본문 `bodyHtml`(기본 글꼴 div, 인라인 style) + 텍스트 대체 본문 `body` (계약 2절 "서식 본문"). 답장·전달 초안 한도는 `fit-draft.ts`(처음 맞출 때 내 글 몫 5%를 남기고, 보내기 직전 `getContentForSend()` 가 다시 재서 인용을 더 줄인다 — 내 글만으로 넘으면 보내지 않고 칸 아래에 알린다), 붙여 넣거나 끌어다 놓은 파일은 첨부로(`shared/utils/editor-files.ts`, 화면 어디에 놓아도 — `composables/useWindowFileDrop.ts`). **읽어 들이는 style 값은 서버 정화와 같은 형식만** 받는다 — 규칙 `shared/utils/outgoing-html-rules.ts`(짝: backend `sanitize-outgoing-html.ts`, 같은 표본 표 `test-utils/outgoing-style-samples.ts` — 한쪽을 고치면 둘 다), 적용 `editor/mail-format-extensions.ts`. 편집기 청크를 못 받으면 `ComposeEditorLoadError.vue`("다시 시도"), 단축키 표기는 플랫폼별(`shared/utils/keyboard-shortcut.ts`). **그림은 서명 그림만** 받는다 — 노드 `editor/signature-image.ts`(쓰기 본문·서명 칸 공용, 규칙은 `outgoing-html-rules.ts` 의 그림 부분 — 짝: backend `html/signature-images.ts`·`keepSignatureImage`, 표본 `IMAGE_SAMPLES`). 메뉴(`editor/EditorMenu.vue` + `EditorPopup.vue` — 이동 ▾ · 메일함 ⋯ · 읽기 화면 ⋯ 도 같은 부품)의 꺼진 항목은 `disabled` 가 아니라 `aria-disabled` — 화살표로 가서 초점은 받되 고를 수 없다(WAI-ARIA 메뉴). 누를 수 없는 까닭은 항목의 `note`(이름 아래 작은 글, `aria-describedby`). 메뉴 · 팝오버의 좌우는 바라는 끝(`align`)으로 그린 뒤 보이는 영역 밖으로 넘치면 덜 넘치는 반대 끝에 맞춘다(15단계 — `EditorPopup` 이 그린 직후 · 초점을 주기 전에 스스로 잰다: `composables/usePopupPlacement.ts` 의 `measurePopupAlign` · 판단 `shared/utils/popup-placement.ts` 의 `popupAlignFor`). 항목은 들여쓰기(`depth` — 10 · 28 · 44 · 56)와 따로 읽힐 이름(`ariaLabel`)을 받는다. **서명 자리**(16단계 — `editor/mail-signature-block.ts`의 `mailSignature` 블록 노드): 보낼 때 · 저장할 때 빈 문단 뒤의 **속성 없는 `
`**(앱과 같은 경계 — 계약 2절 "앱이 만드는 모양")로 나가고, 다시 열 때 "맨 위 단계에서 빈 문단 바로 뒤의 속성 없는 div 이고 글이나 그림이 있다"로 되찾는다(원문 인용 안 · 꾸민 · 빈 div 는 아니다). 서명 자리는 하나 — 붙여 넣기로 하나 더 생기면 처음 것만 두고 푼다. **서명 자리 앞에는 늘 빈 줄** — 그 빈 줄에 쓰거나 지우면 다시 넣는다(같은 appendTransaction — 한글 조합 중(`composition` 표시)에는 미룬다, 화면에 붙인 편집기에서만 돈다 · 시험은 `editor.mount`), 보내고 저장하는 모양은 `withSignatureBoundary` 로 한 번 더(`MailEditor` 의 `contentOf`) — 리뷰 M3. | +| 메일 본문 편집기 | `src/components/mail/editor/` — `MailEditor.vue` (쓰기 화면이 `defineAsyncComponent` 로 불러온다) | TipTap core (MIT 무료 패키지만, `@tiptap/*` 는 모두 같은 버전으로 고정). 툴바 배치·좁은 폭에서 접는 순서·단축키는 `docs/design/webmail/README.md` "메일 쓰기". 보낼 모양은 `mail-serialize.ts` — 서식 본문 `bodyHtml`(기본 글꼴 div, 인라인 style) + 텍스트 대체 본문 `body` (계약 2절 "서식 본문"). 답장·전달 초안 한도는 `fit-draft.ts`(처음 맞출 때 내 글 몫 5%를 남기고, 보내기 직전 `getContentForSend()` 가 다시 재서 인용을 더 줄인다 — 내 글만으로 넘으면 보내지 않고 칸 아래에 알린다), 붙여 넣거나 끌어다 놓은 파일은 첨부로(`shared/utils/editor-files.ts`, 화면 어디에 놓아도 — `composables/useWindowFileDrop.ts`). **읽어 들이는 style 값은 서버 정화와 같은 형식만** 받는다 — 규칙 `shared/utils/outgoing-html-rules.ts`(짝: backend `sanitize-outgoing-html.ts`, 같은 표본 표 `test-utils/outgoing-style-samples.ts` — 한쪽을 고치면 둘 다), 적용 `editor/mail-format-extensions.ts`. 편집기 청크를 못 받으면 `ComposeEditorLoadError.vue`("다시 시도"), 단축키 표기는 플랫폼별(`shared/utils/keyboard-shortcut.ts`). **그림은 서명 그림만** 받는다 — 노드 `editor/signature-image.ts`(쓰기 본문·서명 칸 공용, 규칙은 `outgoing-html-rules.ts` 의 그림 부분 — 짝: backend `html/signature-images.ts`·`keepSignatureImage`, 표본 `IMAGE_SAMPLES`). 메뉴(`editor/EditorMenu.vue` + `EditorPopup.vue` — 이동 ▾ · 메일함 ⋯ · 읽기 화면 ⋯ 도 같은 부품)의 꺼진 항목은 `disabled` 가 아니라 `aria-disabled` — 화살표로 가서 초점은 받되 고를 수 없다(WAI-ARIA 메뉴). 누를 수 없는 까닭은 항목의 `note`(이름 아래 작은 글, `aria-describedby`). 메뉴 · 팝오버의 좌우는 바라는 끝(`align`)으로 그린 뒤 보이는 영역 밖으로 넘치면 덜 넘치는 반대 끝에 맞춘다(15단계 — `EditorPopup` 이 그린 직후 · 초점을 주기 전에 스스로 잰다: `composables/usePopupPlacement.ts` 의 `measurePopupAlign` · 판단 `shared/utils/popup-placement.ts` 의 `popupAlignFor`). 항목은 들여쓰기(`depth` — 10 · 28 · 44 · 56)와 따로 읽힐 이름(`ariaLabel`)을 받는다. **서명 자리**(16단계 — `editor/mail-signature-block.ts`의 `mailSignature` 블록 노드): 보낼 때 · 저장할 때 빈 문단 뒤의 **속성 없는 `
`**(앱과 같은 경계 — 계약 2절 "앱이 만드는 모양")로 나가고, 다시 열 때 "맨 위 단계에서 빈 문단 바로 뒤의 속성 없는 div 이고 글이나 그림이 있다"로 되찾는다(원문 인용 안 · 꾸민 · 빈 div 는 아니다). 서명 자리는 하나 — 붙여 넣기로 하나 더 생기면 처음 것만 두고 푼다. **서명 자리 앞에는 늘 빈 줄** — 그 빈 줄에 쓰거나 지우면 다시 넣는다(같은 appendTransaction — 한글 조합 중(`composition` 표시)에는 미루고, 조합이 끝나면 플러그인 view 가 `compositionend` 뒤 ProseMirror 가 조합을 마무리한 다음 차례(50ms)에 표시만 단 빈 거래로 깨운다 — 그사이 새 조합이 시작됐으면 그 조합이 끝날 때. 화면에 붙인 편집기에서만 돈다 · 시험은 `editor.mount` + `CompositionEvent` + 가짜 setTimeout), 보내고 저장하는 모양은 `withSignatureBoundary` 로 한 번 더(`MailEditor` 의 `contentOf` · 저장한 뒤 견줄 `markSaved` 의 settled) — 리뷰 M3. | | 임시 저장 | `src/composables/useDraftSaving.ts` · `components/mail/ComposeDraftStatus.vue` · `composables/useComposeDraftLink.ts`(주소 맞추기 · 옛 id 기록 · 보내지 못한 메일을 저장본으로) · `composables/useDraftTrash.ts`(휴지통 · 되돌리기) | 3초 멈춤 · 쉬지 않고 쓰면 30초 · 10초 간격 · 한 번에 하나(계약 2절 "임시 저장", 상수 `shared/constants/mail.ts` 의 `DRAFT_*`). `MAIL_008`·PUT 404 는 막지 않고 POST 로 새로. 찾지 못한 첨부가 남았거나 본문이 한도를 넘는 동안은 저장하지 않고, 실을 수 없는 첨부가 든 남의 저장본은 첫 저장을 POST 로(원본 보존). 보내기·버리기 전에는 `suspend`+`settle` — 보내기는 settle 뒤 첨부 상태를 다시 본다. 저장할 때마다 주소를 `?draft=<새 id>` 로 맞추고(떠나기 전 저장 제외), 옛 id 로 돌아오면 `mail.store` 의 `latestDraftId()` 로 마지막 저장본을 연다. 쓰기 화면 휴지통은 DELETE 가 아니라 PATCH(휴지통으로 이동) — 이미 휴지통인 메일은 DELETE 가 영구 삭제한다 | | 받는 사람 자동 완성 | `src/composables/useRecipientSuggestions.ts` · `shared/utils/recipient-match.ts` · `components/mail/RecipientField.vue` · `RecipientSuggestionList.vue` | 검색어는 input · compositionupdate 의 `target.value`(한글 조합 중 글자 포함)로 따로 두고, 칩 확정은 v-model. Enter 등의 조합 판정은 `shared/utils/keyboard-shortcut.ts` 의 `isImeComposing()`(`isComposing` 또는 keyCode 229 — Safari · macOS) — 새 입력 칸의 Enter 처리도 이것을 쓴다. 쓰기 화면을 열 때 `limit=100` 한 번, 모자랄 때만 250ms 뒤 `q` 로(8단계부터 초성 · 조합 중 글자도 묻는다 — 서버의 q 가 같은 맞추기다: 짝 backend `modules/mail/recipient-match.ts`, 같은 표본 표 `test-utils/recipient-match-samples.ts` — 한쪽을 고치면 둘 다. 늦은 응답은 버린다). 초성 검색·맞은 글자 강조는 `recipient-match.ts`(강조는 `` 조각 — v-html 없음). combobox 접근성(aria-activedescendant · listbox · 조합 중 Enter 무시), 완전한 주소면 미리 고르지 않는다 | | 메일 서명 (16단계 — 여러 서명) | `src/stores/mail-signature.store.ts` · API `composables/useMailSignatures.ts` · 타입 `shared/types/mail-signature.ts` · `components/mail/editor/signature/`(`SignatureSettings` 화면 · `SignatureList` · `SignatureDefaultsField` · `SignatureForm` · `SignatureEmpty` · `SignatureDeleteDialog` · 공용 편집기 `SignatureEditor` + `SignatureToolbar`) · `shared/utils/mail-signature.ts` · `shared/constants/mail-signature.ts` · `pages/settings/MailSignaturePage.vue` · 쓰기 화면 [서명 ▾] `components/mail/ComposeSignatureMenu.vue`(ComposeFooter 의 `signature` 슬롯) · 서명 자리 `components/mail/editor/mail-signature-block.ts` | 서명은 직원마다 10개(만든 차례)와 기본 둘(새 메일 · 답장과 전달 — `GET · PUT /mail-signature-defaults`, null = 넣지 않음) — 계약 2절 "서명". 스토어는 목록과 기본 둘을 함께 받는다(`ensureLoaded` — 메일 화면에 들어올 때 · 쓰기 화면이 열 때, 실패는 알리지 않고 서명 없이). 만들기 · 고치기 · 지우기는 받아 둔 목록을 그 자리에서 고치고(첫 서명은 기본 둘 — 서버와 같게, 지운 서명의 기본은 null), 기본 고르기(`chooseDefault`)는 화면을 먼저 바꾸고 **저장은 한 번에 하나**(보내기 · 읽기 설정과 같은 결 — 끝내 실패하면 서버가 확인한 값으로). 저장 거절(`VAL_001`)은 `refresh()`(조용히 — 그사이 바꿨으면 버림)로 다른 창의 같은 이름을 가른다. 설정 화면은 들어올 때마다 `load` 하고 TipTap 청크(`SignatureSettings`)를 지연 로딩한다 — 목록 208 · 고치는 칸, 칸이 640 보다 좁으면 목록을 위로(컨테이너 쿼리 `signature-settings`). 고른 서명마다 `SignatureForm` 을 key 로 새로 만들되 새 서명을 처음 저장한 것은 같은 칸이다. 저장하지 않은 채 다른 서명 · [새 서명] · [취소]는 `LeaveConfirmDialog`(`signature-switch`), 다른 화면은 `useLeaveConfirm`(`signature-leave`). 이름 검사 `signatureNameProblem`(앞뒤 공백 · 영문 대소문자 무시 — 서버와 같다). 미리 보기는 편집기 스키마의 `DOMSerializer` 로 고른 서명을 늘 그린다. 그림 3장은 서명 칸 안의 그림 수로(한 사람 보관 20장은 서버가 센다 — 올리기 `VAL_001` 은 알림). **쓰기 화면**: 새 메일은 새 메일 기본을 끝에, 답장 · 전달은 답장과 전달 기본을 인용 위에 서명 자리(`signatureBlockHtml` — `data-mail-signature` 표시 div)로 넣고, 이어 쓰기는 다시 넣지 않는다. [서명 ▾](임시 저장 다음 · 44 · 편집기 메뉴 220 · `menuitemradio` ✓ · 키보드로 열면 첫 항목 — `EditorMenu` 의 `initialFocus`)는 `MailEditorHandle` 의 `replaceSignature`(그 자리만 · 실행 취소 됨 · 넣지 않음은 앞 빈 줄까지 · 없으면 처음 넣는 자리) · `signaturePlace` · `signatureFingerprint`(글 · 그림으로 견줘 ✓ — 고쳤으면 없음, 단추 이름 "서명: 대외" · "서명")를 쓰고 바꾼 뒤 role=status 에만 "서명을 ‘사내’로 바꿨습니다."(조사 `directionParticle`). 서명 관리는 설정 › 메일 › 서명(나가기 규칙대로) — 디자인 README "16단계" | @@ -72,7 +72,7 @@ description: Vue 3 프론트엔드(frontend/) 개발 규칙 — Composition API | 휴대폰 앱 받기 (2026-11-08) | 설정 › 계정 › 휴대폰 앱 `src/pages/settings/AccountMobileAppPage.vue` · 앱 틀 밖 받기 화면 `pages/mobile-app/MobileAppPage.vue`(`/app` — 인쇄 화면처럼 `meta.requiresAuth` 만) · `components/mobile-app/`(`MobileAppDownloadLink` · `MobileAppIcon` · `MobileAppSteps` · `MobileAppIphoneNote`) · `stores/app-release.store.ts` · API `composables/useAppReleases.ts` · `shared/utils/app-release.ts` · `shared/types/app-release.ts` · 글 `shared/constants/mobile-app.ts`(`MOBILE_APP_PATH` · `MOBILE_APP_ROUTE` — 글은 이 상수가 원본) | 안드로이드 APK 를 로그인한 직원만 받는다(스토어 없음). 판 정보 `GET /api/app-releases/android/latest` 는 들어올 때마다 `silent` 로 받는다 — 404 `RES_001` 은 "받을 앱 없음"(알림 없음 · 안내와 문의 줄만), 그 밖의 실패는 칸 자리에 `MailLoadError`(사전 문구 + [다시 시도]), 세션 세대(`authStore.generation`)가 바뀌면 늦은 응답을 버리고 값을 지운다. [앱 내려받기]는 첨부처럼 `` + 이동 직전 `confirmSession()`(`…/latest/file`). QR(`QrCodeImage`)은 `location.origin + '/app'` 만 담는다(비밀 아님) — `/app` 은 앱의 [새 버전 받기]도 여는 약속이라 경로를 바꾸면 앱도 함께. 문의 줄은 `LoginSupportLine`(`/app` 은 `size="base"` 13). 올린 날은 브라우저 시간대(`formatLongDate`). 설정은 좁은 폭(480 이하)에서 QR 줄을 빼고 [앱 내려받기]를 전체 폭으로, `/app` 은 휴대폰 폭이 먼저 · 480 초과는 띠 전체 폭 + 가운데 칸 480 · [앱 내려받기] 48(밀도 예외 아님). 글자 17 토큰 `--font-size-title-md` — 디자인 README "휴대폰 앱 받기" | | 메일함 옮기기 · 하위까지 지우기 · 차단 정리 · 지금 보내기 (15단계) | 메일함 나무 도우미 `src/shared/utils/mailbox.ts`(`mailboxTree` · `mailboxSpokenName` · `mailboxDisplayName` · `mailboxMoveTargets` · `canHoldChildMailbox` · `sameMailboxName` · `descendantMailboxes`) · 옮기기 창 `components/mail/MailboxMoveDialog.vue`(+ `MailboxDialogs.vue` · `mailbox-dialog.ts` 의 `move`) · 지우기 확인 `MailboxDeleteDialog.vue`(`childCount`) · `composables/useMailboxActions.ts`(`move` · `remove(…, { withChildren })`) · `stores/mail/mailbox-admin.ts`(`moveMailbox` · `runMailboxOperation` 의 `withChildren`) · 글 `shared/utils/mailbox-operation.ts`(`mailboxMoveText`) · `shared/constants/mailbox-manage.ts`(`MAILBOX_MOVE_TEXT` · `MAILBOX_PATH_TEXT`) · 차단 창 `MessageBlockDialog.vue`(새 칸) · `MessageMoreMenu.vue` · `composables/useSenderBlock.ts`(`cleanInbox`) · `useSenderBlockRun.ts`(진행 · 결과 알림) · `stores/mail-block-run.store.ts`(`inboxBusy` · `startBlock`) · 되풀이 `stores/inbox-cleanup-run.ts`(필터 적용 · 차단 정리 공용) · 글 `shared/utils/mail-block.ts` 의 `mailBlockRunCopy` · `shared/constants/mail-filter.ts` 의 `INBOX_CLEANUP_TEXT` · 지금 보내기 `MessageReaderNotices.vue`([지금 보내기]) · `composables/useScheduledMessageActions.ts`(`sendNow`) · `stores/mail/scheduled.ts`(`releasePendingSend`) · API `composables/useMail.ts`(`moveMailbox` · `deleteMailbox` 의 `withChildren` · `runSenderBlock` · `releaseScheduledMessage`) | 계약 2절 "메일함 관리" · "보낸 사람 차단" · "보내기 취소 · 예약 발송". **나무 도우미는 한 곳** — [이동 ▾] 들여쓰기 · 끌어다 놓기 알림 · 옮기기 창이 함께 쓴다(알림 이름은 같은 이름이 다른 곳에도 있을 때만 "거래처 › 2025", 같은 자리 겹침은 대소문자 무시 — 엔진과 같다). 옮기기는 `PATCH /mailboxes/{id}` 의 `parentId`(null 은 맨 위) — 옮길 곳은 「하위 메일함 만들기」가 있는 곳과 같고, 자기 · 하위 · 지금 있는 곳 · 같은 이름이 있는 곳은 처음부터 고를 수 없다(radiogroup — 꺼진 줄에도 초점은 간다). `MAIL_009` 만 창 안, 결과 알림 + [되돌리기](원래 상위로). 하위까지 지우기는 확인 창이 물은 뒤에만 `?withChildren=true` — 나무 전체를 "지우는 중"으로 두고 수도 나무 전체로 센다, 끝나면 나무째 뺀다. 차단 창의 새 칸(처음 꺼짐)을 켜면 받은편지함의 이 메일은 첫 칸을 켠 채 잠그고(aria-disabled) 먼저 옮긴 뒤 `POST /mail-block-runs` 를 cursor 가 null 이 될 때까지 — 차단 알림 대신 진행 알림 하나가 결과로 바뀐다([스팸메일함 보기] · [다시 시도] — 다른 창에서 하는 중이어도 단추, [되돌리기] 없음). **받은편지함 정리는 한 직원에 한 번에 하나** — 이 탭의 필터 적용 · 다른 차단 정리가 도는 동안(`inboxBusy`)과 이 탭에서 스팸메일함을 비우는 동안 새 칸을 끈다(`startBlock` — 까닭 글이 다르다, 필터 적용은 스팸메일함 비우기를 보지 않는다), 다른 창은 서버가 429 `REQ_001`(차단 정리는 "다른 창에서 받은편지함이나 스팸메일함을 정리하는 중입니다. …" — 필터 적용은 "…받은편지함을 정리하는 중입니다. …"). [지금 보내기]는 `POST /scheduled-messages/{id}/release` — 응답의 `pendingSend` 가 undo 면 쓰기 화면과 같은 마감 알림 + [보내기 취소](`undoToastDeadline`), null 이면 "메일을 보냈습니다." + [보낸편지함 보기], 예약됨에서 빠진 뒤의 이동은 [예약 취소]와 같다. 503 `MAIL_001` · 결과를 모르는 실패(`SYS_001` — 앞단 502 · 504 · 시간 초과 · 연결 끊김)는 그 메일을 다시 받아 가른다(`shared/utils/pending-send.ts` 의 `unsentReleaseState`, 스토어의 `ReleaseOutcome`): 임시보관함 [임시보관함 보기] · 예약 그대로(확인함) "지금 보내지 못했습니다. …" + [다시 시도](안내 줄 그대로) · 예약만 풀림 · 다시 받지 못함은 이미 나갔을 수 있어 못 보냈다고 단정하지 않는다 — "메일을 보냈는지 확인하지 못했습니다. …" + [다시 시도](예약만 풀림은 안내 줄을 내리고, 다시 받지 못함은 마지막으로 안 그대로. [다시 시도]는 이미 보낸 메일이면 서버가 200) · 다시 낸 발송이 기다림은 보낸 것과 같다. 차단 정리와 이 탭의 스팸메일함 비우기는 서로 막는다(`isMailboxOperationBlocked` · `startBlock` — 다른 창끼리는 서버 잠금이 요청 하나씩이라 엇갈릴 수 있다, 알려진 한계) — 디자인 README "15단계" | | 메일 옮기기 · 끌어다 놓기 · 좁은 읽기 칸 (15단계) | 읽기 화면 [이동 ▾] `src/components/mail/MessageMoveButton.vue` · 목록 [이동 ▾] `MailBulkMoveButton.vue` · 메뉴 항목 `components/mail/mail-move-menu.ts` · 옮길 곳 규칙 `shared/utils/mail-move-targets.ts`(`mailMoveTargets` · `hasMoveTarget` · `canDropMailOn` · `hereMailboxIds` · `junkMarkForMove`) · 끌기 `composables/useMailDrag.ts`(`useMailRowDrag` · `activeMailDrag` · `takeMailDrop` · `mailDragging` — 목록 줄 넷이 쓴다) · 끄는 표 `components/mail/MailDragChip.vue`(MailLayout) · 놓기 `MailboxPanelRow.vue`(모양 · 받을지) → `MailboxTree.vue` → `useBulkMailActions` 의 `moveDragged` · 알림 이름 `useMailMoveFeedback` 의 `placeName` · 좁은 읽기 칸 `MessageReader.vue`(컨테이너 쿼리) + `MailToolButton.vue`(`collapsible` · `menu`) · 파일 놓기 무시 `composables/useWindowFileDrop.ts` 의 `useIgnoredWindowFileDrop`(앱 틀 `AppShell`) · 시험 도구 `src/test-utils/drag.ts` | 옮길 곳은 메일함 패널 차례 · 깊이(위 「메일함 옮기기 …」 행의 나무 도우미)이고 메일이 든 메일함 · 임시보관함 · 휴지통은 빼되 하위가 있으면 고를 수 없는 줄(읽기 "이 메일이 있는 메일함" · 목록 "고른 메일이 있는 메일함")로 남긴다. 들여 쓴 항목은 `EditorMenuEntry` 의 `depth` · `ariaLabel`. **스팸메일함으로 옮기는 세 길 같게**: 메뉴 · 끌기로 스팸메일함에 넣으면 `isJunk: true`, 스팸메일함에서 휴지통 밖으로 꺼내면 `isJunk: false`(`junkMarkForMove` — [스팸 신고] · [스팸 아님]과 같은 알림 · 되돌리기). 알림의 메일함 이름은 `placeName`(나무 도우미의 `mailboxDisplayName` — 옮김 · 되돌림 · 대화 목록의 "{메일함}에 있는 …" 모두). 끌기는 우리 자료(`MAIL_DRAG_TYPE` + 이 끌기의 표)만 싣고 링크의 주소는 지운다(`clearData`) — 끄는 동안의 자료는 탭 메모리(dragover 에서는 값을 읽지 못한다), 놓을 때 표가 같아야 받는다. 줄은 `:draggable="!held"` · 끌 수 없는 줄에서 시작한 링크 끌기는 dragstart 에서 막는다. 놓을 수 있는 줄만 dragover 를 받는다(나머지는 브라우저 금지 커서) · 들어온 요소 수로 점선을 켜고 끄며 끌기가 끝나면(`mailDragging`) 끈다. 파일 끌기는 받지 않는다. 앱 틀(메일 · 주소록 · 설정)에 놓은 파일은 무시하되 받는 화면(`useWindowFileDrop` — 쓰기 화면 · 서명 설정)이 떠 있으면 그 화면에 맡긴다. **좁은 읽기 칸**: 도구 줄 띠가 컨테이너(`container: reader-tools / inline-size`) — 분할 보기 580 · 휴지통 605(실측 569.19 · 594.83 에 여유) · 목록 보기 850 · 875 보다 좁으면 이름을 숨긴다(목록 보기는 단추 줄 최대 폭을 푼다, 화면 폭 기준은 tokens.css 머리). 접히는 단추는 `collapsible`(aria-label · title · `.tool-button__label`) — 계약 2절, 디자인 README "15단계" | -| 주소록 가져오기 · 내보내기 (16단계) | 머리 단추 `src/components/contacts/ContactsList.vue`(「전체 연락처」 머리 [가져오기] · [내보내기] · 빈 주소록의 테두리 [가져오기]) · 창 `ContactImportDialog.vue`(+ 파일 고르기 `ContactImportPicker` · 세 묶음 `ContactImportSummary` · 그룹 `ContactImportGroupFields` · 메모 체크 칸 `ContactImportInfoField`) · `ContactExportDialog.vue` · 창 자리 `ContactsDialogs.vue` + `composables/useContactsDialogs.ts`(`openImport` · `openExport`) · 읽기 · 넣기 · 되돌리기 `composables/useContactImport.ts` · 차례 `stores/contact-import.store.ts`(`busy` · `enqueue`) · 묶어 다시 받기 `contacts.store` 의 `afterBulkChange` · API `composables/useContacts.ts`(`checkContactImport` · `importContacts` · `deleteContacts` · `downloadContactExport` · `contactExportUrl`) · 파일 읽기(순수 함수 · 새 의존성 없음) `shared/utils/contact-import/`(글자 `text.ts` · `csv.ts` · `vcard.ts` · `zip.ts` · `xlsx.ts` · 머리 줄 → 칸 `table.ts` · 값 다듬기 `values.ts` · 넣을 · 이미 있는 · 넣지 못하는 · 계획 `records.ts` · 요청 묶음 `batches.ts` · 형식 가르기 `read-file.ts` · 타입 `types.ts`) · 글 `shared/constants/contacts.ts`(`CONTACTS_TRANSFER_TEXT` · `CONTACT_IMPORT_*` · `CONTACT_EXPORT_TEXT` — 글은 보드 Contacts16 그대로) · 시험 도구 `test-utils/zip.ts`(작은 zip · xlsx) · `euc-kr.ts` | 계약 3절 "가져오기 · 내보내기". **파일은 서버로 보내지 않는다** — 브라우저가 읽어(`readContactFile` — 20MB 넘으면 읽지 않음, 이름 끝 + 앞 바이트로 형식: zip → .xlsx(xl/workbook.xml 이 없으면 넣을 수 없음), CFB → 암호를 건 .xlsx · .xls · 그 밖, 글은 BOM → UTF-8(fatal) → `TextDecoder('euc-kr')` — **'cp949' 라벨은 RangeError 라 쓰지 않는다**) 넣을 수 있는 연락처만 요청 묶음(아래)으로 `POST /contact-import-checks`(남은 자리를 다 채우면 더 묻지 않는다)에 이미 있는지 묻는다. 엑셀은 zip 가운데 목록 → `DecompressionStream('deflate-raw')` → `DOMParser`(공유 문자열 · 첫 시트(workbook 의 첫 `` → 관계 파일) · 칸 안 문자열), **푼 크기는 읽는 동안 센다**(항목 64MB · 모두 96MB — 목록의 크기는 꾸밀 수 있다), DOCTYPE 이 든 XML 은 읽지 않는다, 열은 XFD(16,384번째)까지 · 늘어놓는 칸 수의 합은 `CONTACT_IMPORT_XLSX_CELLS_MAX`(400만 — 넘으면 열지 않는다). vCard 2.1 QP 는 "=" 접힘을 바이트로 이은 뒤 CHARSET 대로. **읽기는 입력 길이 · 줄 수에 비례하게**(2026-11-10 리뷰): 줄 잇기는 조각을 배열에 모아 한 번에(이은 글을 물리 줄마다 다시 보면 줄 수의 제곱 — 사진 든 vCard 2만 줄에 16.8초), PHOTO · LOGO · SOUND · KEY 는 이어지는 줄째 건너뛴다, 큰 배열을 `push(...)` 로 펼쳐 넘기지 않는다(인자 수 한도 — 20만 개면 RangeError), 구분자 둘레 빈칸은 정규식(`\s*:::\s*` — 긴 빈칸에서 제곱)이 아니라 나눈 뒤 trim. 시험은 벽시계 대신 "20만 줄을 넣어도 끝나고 맞는가"로 본다. 머리 줄로 꼴(네이버 · Google 새 · 옛 · Outlook · 우리 내보내기)을 알아보고 모르면 열 이름 낱말로(`table.ts` 의 `classifyHeader` — 차례가 중요: 회사번호는 전화 · 회사주소는 주소 · 회사소속명은 회사, "번호" 한 낱말은 전화가 아니다). 검사는 연락처 창과 같거나 더 좁게(`isStrictEmail` · `isValidContactPhone` · 100 · 1,000자) — 화면이 넘긴 줄은 서버도 받는다(묶음 하나라도 걸리면 400 이라서). 가져온 정보 값은 `cleanText` 로 한 줄 글로, 붙인 메모는 `contactNoteText` 를 한 번 더(서버는 메모의 줄바꿈 밖 제어 문자를 받지 않는다 — vCard 별명 · 형식이 틀린 전화에 남은 탭 · \r · C0 로 묶음 전체가 400 이었다). 파일 안 겹침은 서버와 같은 규칙(이메일 trim + 소문자, 없으면 이름 + 전화 숫자 모음 — **짝: backend `modules/contacts/contact-import.ts` 의 `phoneDigitsKey` · `namePhonesKey`**, 숫자만 · 숫자 없는 번호 빼기 · 겹침 없이 · 차례대로, 한쪽을 고치면 둘 다). 확인 · 넣기 · 되돌리기 묶음은 100명 · JSON 값 8,000개 · 700KB 가운데 먼저 닿는 데서 끊는다(`batches.ts` 의 `requestBatchSize` — 값 수는 서버 `json-shape.ts` 와 같은 셈, 상한 `CONTACT_IMPORT_BATCH_MAX_VALUES` · `_MAX_BYTES`). uid 는 읽을 때 한 번 만들어(`urn:uuid:` + 소문자 UUID) 확인 · 넣기 · [다시 시도]가 같은 값을 쓴다. **넣기는 창을 닫고 진행 알림** — 새 그룹 먼저(`CONTACT_001` 이면 그 그룹 · `CONTACT_002` 면 그 그룹 없이 — 만들기 응답만 잃은(응답 없음 · 5xx — `unsureGroupKeys`) 그룹이 [다시 시도]에서 `CONTACT_001` 이면 이번에 만든 그룹으로 센다, 그 밖의 `CONTACT_001` 그룹은 되돌리기가 지우지 않는다) → 묶음마다 `POST /contact-imports`(409 `CONTACT_002` 면 남은 자리를 다시 물어 줄이고, 404 `RES_001` 이면 그룹을 다시 받아 한 번 더) → 결과 알림은 **✕로 닫을 때까지 남는다**(`sticky`) + [되돌리기](`POST /contact-deletions` 묶음마다 → 이번에 만든 그룹 `DELETE`), 멈춘 오류 알림도 sticky + [다시 시도](남은 것부터). 가져오기 · 되돌리기는 한 탭에서 하나씩 — `enqueue` 가 차례를 지키고 넣은 때의 세션이 바뀌었으면 건너뛴다, 도는 동안 [가져오기]는 `aria-busy`. 끝나거나 멈추면 `afterBulkChange`(수 · 그룹 · 첫 쪽 — 지운 id 는 `goneIds`). 창은 주소록 틀에 둔다(세션이 바뀌면 닫힌다 — 읽는 중이면 `AbortController` 로 끊는다). 읽는 동안의 상태 영역(`role=status` · `data-import-status`)은 창을 열 때부터 두고 글만 바꾼다, 읽다가 뜻밖에 멈추면(`read` 의 catch) "열지 못했습니다"로 돌아온다. 끌어다 놓기는 창 루트(`` — ModalDialog 의 fallthrough 리스너)에서 받고 `preventDefault` 로 앱 틀의 "파일 놓기 무시"보다 앞선다. 내보내기는 `` + 이동 직전 `confirmSession()`(`downloadContactExport` — 첨부 받기와 같다), 파일 이름의 날짜는 이 PC 의 날짜(서버는 KST). 시험: Node(ICU)의 euc-kr 은 CP949 확장 글자를 모른다 — KS X 1001 글자만 — 디자인 README "16단계 › 주소록 가져오기 · 내보내기" | +| 주소록 가져오기 · 내보내기 (16단계) | 머리 단추 `src/components/contacts/ContactsList.vue`(「전체 연락처」 머리 [가져오기] · [내보내기] · 빈 주소록의 테두리 [가져오기]) · 창 `ContactImportDialog.vue`(+ 파일 고르기 `ContactImportPicker` · 세 묶음 `ContactImportSummary` · 그룹 `ContactImportGroupFields` · 메모 체크 칸 `ContactImportInfoField`) · `ContactExportDialog.vue` · 창 자리 `ContactsDialogs.vue` + `composables/useContactsDialogs.ts`(`openImport` · `openExport`) · 읽기 · 넣기 · 되돌리기 `composables/useContactImport.ts` · 차례 `stores/contact-import.store.ts`(`busy` · `enqueue`) · 묶어 다시 받기 `contacts.store` 의 `afterBulkChange` · API `composables/useContacts.ts`(`checkContactImport` · `importContacts` · `deleteContacts` · `downloadContactExport` · `contactExportUrl`) · 파일 읽기(순수 함수 · 새 의존성 없음) `shared/utils/contact-import/`(글자 `text.ts` · `csv.ts` · `vcard.ts` · `zip.ts` · `xlsx.ts` · 머리 줄 → 칸 `table.ts` · 값 다듬기 `values.ts` · 넣을 · 이미 있는 · 넣지 못하는 · 계획 `records.ts` · 요청 묶음 `batches.ts` · 형식 가르기 `read-file.ts` · 타입 `types.ts`) · 글 `shared/constants/contacts.ts`(`CONTACTS_TRANSFER_TEXT` · `CONTACT_IMPORT_*` · `CONTACT_EXPORT_TEXT` — 글은 보드 Contacts16 그대로) · 시험 도구 `test-utils/zip.ts`(작은 zip · xlsx) · `euc-kr.ts` | 계약 3절 "가져오기 · 내보내기". **파일은 서버로 보내지 않는다** — 브라우저가 읽어(`readContactFile` — 20MB 넘으면 읽지 않음, 이름 끝 + 앞 바이트로 형식: zip → .xlsx(xl/workbook.xml 이 없으면 넣을 수 없음), CFB → 암호를 건 .xlsx · .xls · 그 밖, 글은 BOM → UTF-8(fatal) → `TextDecoder('euc-kr')` — **'cp949' 라벨은 RangeError 라 쓰지 않는다**) 넣을 수 있는 연락처만 요청 묶음(아래)으로 `POST /contact-import-checks`(남은 자리를 다 채우면 더 묻지 않는다)에 이미 있는지 묻는다. 엑셀은 zip 가운데 목록 → `DecompressionStream('deflate-raw')` → `DOMParser`(공유 문자열 · 첫 시트(workbook 의 첫 `` → 관계 파일) · 칸 안 문자열), **푼 크기는 읽는 동안 센다**(항목 64MB · 모두 96MB — 목록의 크기는 꾸밀 수 있다), DOCTYPE 이 든 XML 은 읽지 않는다, 열은 XFD(16,384번째)까지 · 늘어놓는 칸 수의 합은 `CONTACT_IMPORT_XLSX_CELLS_MAX`(400만 — 넘으면 열지 않는다). vCard 2.1 QP 는 "=" 접힘을 바이트로 이은 뒤 CHARSET 대로. **읽기는 입력 길이 · 줄 수에 비례하게**(2026-11-10 리뷰): 줄 잇기는 조각을 배열에 모아 한 번에(이은 글을 물리 줄마다 다시 보면 줄 수의 제곱 — 사진 든 vCard 2만 줄에 16.8초), PHOTO · LOGO · SOUND · KEY 는 이어지는 줄째 건너뛴다, 큰 배열을 `push(...)` 로 펼쳐 넘기지 않는다(인자 수 한도 — 20만 개면 RangeError), 구분자 둘레 빈칸은 정규식(`\s*:::\s*` — 긴 빈칸에서 제곱)이 아니라 나눈 뒤 trim. 시험은 벽시계 대신 "20만 줄을 넣어도 끝나고 맞는가"로 본다. 머리 줄로 꼴(네이버 · Google 새 · 옛 · Outlook · 우리 내보내기)을 알아보고 모르면 열 이름 낱말로(`table.ts` 의 `classifyHeader` — 차례가 중요: 회사번호는 전화 · 회사주소는 주소 · 회사소속명은 회사, "번호" 한 낱말은 전화가 아니다). 검사는 연락처 창과 같거나 더 좁게(`isStrictEmail` · `isValidContactPhone` · 100 · 1,000자) — 화면이 넘긴 줄은 서버도 받는다(묶음 하나라도 걸리면 400 이라서). 가져온 정보 값은 `cleanText` 로 한 줄 글로, 붙인 메모는 `contactNoteText` 를 한 번 더(서버는 메모의 줄바꿈 밖 제어 문자를 받지 않는다 — vCard 별명 · 형식이 틀린 전화에 남은 탭 · \r · C0 로 묶음 전체가 400 이었다). 파일 안 겹침은 서버와 같은 규칙(이메일 trim + 소문자, 없으면 이름 + 전화 숫자 모음 — **짝: backend `modules/contacts/contact-import.ts` 의 `phoneDigitsKey` · `namePhonesKey`**, 숫자만 · 숫자 없는 번호 빼기 · 겹침 없이 · 차례대로, 한쪽을 고치면 둘 다). 확인 · 넣기 · 되돌리기 묶음은 100명 · JSON 값 8,000개 · 700KB 가운데 먼저 닿는 데서 끊는다(`batches.ts` 의 `requestBatchSize` — 값 수는 서버 `json-shape.ts` 와 같은 셈, 상한 `CONTACT_IMPORT_BATCH_MAX_VALUES` · `_MAX_BYTES`). uid 는 읽을 때 한 번 만들어(`urn:uuid:` + 소문자 UUID) 확인 · 넣기 · [다시 시도]가 같은 값을 쓴다. **넣기는 창을 닫고 진행 알림** — 새 그룹 먼저(`CONTACT_001` 이면 그 그룹 · `CONTACT_002` 면 그 그룹 없이 — 만들기 응답만 잃은(응답 없음 · 5xx — `unsureGroupKeys`) 그룹이 [다시 시도]에서 `CONTACT_001` 이면 이번에 만든 그룹으로 센다, 그 밖의 `CONTACT_001` 그룹은 되돌리기가 지우지 않는다) → 묶음마다 `POST /contact-imports`(409 `CONTACT_002` 면 남은 자리를 다시 물어 줄이고, 404 `RES_001` 이면 그룹을 다시 받아 한 번 더) → 결과 알림은 **✕로 닫을 때까지 남는다**(`sticky`) + [되돌리기](`POST /contact-deletions` 묶음마다 → 이번에 만든 그룹 `DELETE`), 멈춘 오류 알림도 sticky + [다시 시도](남은 것부터). 가져오기 · 되돌리기는 한 탭에서 하나씩 — `enqueue` 가 차례를 지키고 넣은 때의 세션이 바뀌었으면 건너뛴다, 도는 동안 [가져오기]는 `aria-busy`. 끝나거나 멈추면 `afterBulkChange`(수 · 그룹 · 첫 쪽 — 지운 id 는 `goneIds`). 창은 주소록 틀에 둔다(세션이 바뀌면 닫힌다 — 읽는 중이면 `AbortController` 로 끊는다). 창이 화면보다 길면 창 안이 스크롤한다(`.import-dialog` · `.export-dialog` 의 `overflow-y: auto` — ModalDialog 에 class 로 넘긴다, 단추 줄 고정 없음 — 필터 창과 같다). **창(`` — flex 열) 바로 아래 칸에 overflow 를 걸면 `flex-shrink: 0` 도** — 아니면 최소 높이가 0 이 되어 창이 넘치는 대신 그 칸이 눌려 잘린다(2026-11-10 실사용 시험 — 세 묶음 상자, 시험 `ContactTransferDialogs.layout.spec.ts` 가 원본 style 을 읽어 본다). 읽는 동안의 상태 영역(`role=status` · `data-import-status`)은 창을 열 때부터 두고 글만 바꾼다, 읽다가 뜻밖에 멈추면(`read` 의 catch) "열지 못했습니다"로 돌아온다. 끌어다 놓기는 창 루트(`` — ModalDialog 의 fallthrough 리스너)에서 받고 `preventDefault` 로 앱 틀의 "파일 놓기 무시"보다 앞선다. 내보내기는 `` + 이동 직전 `confirmSession()`(`downloadContactExport` — 첨부 받기와 같다), 파일 이름의 날짜는 이 PC 의 날짜(서버는 KST). 시험: Node(ICU)의 euc-kr 은 CP949 확장 글자를 모른다 — KS X 1001 글자만 — 디자인 README "16단계 › 주소록 가져오기 · 내보내기" | | 공용 편집기 (16단계) | `components/mail/editor/signature/SignatureEditor.vue` · `SignatureToolbar.vue` · `signature-extensions.ts` | 서명 칸 · 부재중 자동 응답 내용 칸이 함께 쓴다(TipTap 청크 — 쓰는 화면이 지연 로딩). prop `bodyId` · `toolbarLabel` · `allowImages`(false 면 그림 단추 · 그림 노드 없음 · 파일 무시) · `invalid` · `disabled`, 최소 높이 CSS 변수 `--signature-editor-min-height`, 보이는 글 `getText()` · 보낼 모양 `getContent()`(쓰기 본문과 같은 `MailEditorContent`). 기본값은 서명 칸 그대로 | | 부재중 서식 본문 · 회사 밖 응답 (16단계) | `src/components/settings/VacationForm.vue`(설정 화면 `MailVacationPage` 이 `defineAsyncComponent` 로 양식째 지연 로딩 — `ComposeEditorLoadError` · `MAIL_EDITOR_RETRY`) · `VacationMessageFields.vue`(제목 + 내용 편집기 — `idPrefix` 로 사내 `vacation` · 회사 밖 `vacation-external`) · `VacationExternalFields.vue`(회사 밖에서 온 메일 — 고르기 셋 + 따로 쓰기 칸 슬롯) · `VacationPreview.vue`([사내 \| 회사 밖]) · `shared/utils/mail-vacation.ts`(`vacationExternalOf` · `vacationProblems` · `firstVacationProblem` · `vacationPayloadOf` · `vacationPreviewNote` · `vacationPreviewSendsNothing` · `vacationPreviewUsesExternal` · `emailDomainOf`) · `shared/constants/mail-vacation.ts`(`VACATION_EXTERNAL_MODES` · `VACATION_HTML_MAX_LENGTH`) · 타입 `shared/types/mail.ts` 의 `MailVacationExternal` | 계약 2절 "부재중 자동 응답"(16단계). **내용 칸은 서명 칸과 같은 편집기**(`SignatureEditor` — `allowImages: false` · `bodyId` · `toolbarLabel` · `invalid` · `disabled`, 높이 150 은 `--signature-editor-min-height`) — TipTap 청크라 양식째 지연 로딩한다(양식의 부품은 화면에서 바로 import 하지 않는다). 글자 수(600)는 편집기의 `getText().length`(서버가 받는 `textBody` 와 같다 — 링크는 "글 (주소)"), 켜 둔 동안 넘으면 빨갛게. **서식 본문은 서식이 하나라도 있을 때만**(`VacationMessageFields.getContent` — 편집 중인 문서에 표시(marks: 굵게 · 기울임 · 밑줄 · 글자 색 · 링크)가 있는지 `hasVacationFormatting`, 빈 글자 스타일은 빼고) `getContent().html`, 글만이면 null — 사내 · 회사 밖 따로, 끄면서 하는 저장도(서버가 새 칸을 쓴 직원만 원본 판 3 으로 저장한다 — 되돌리기 계획의 전제, 16단계 리뷰). 서식 본문 10,000자 넘으면 `VAL_001`. 저장 거절 `MAIL_013` 은 알림을 끄고 [저장] 옆에 `VACATION_TEXT.saveTooLong`(서식이 두 번 들어가 필터가 없어도 넘을 수 있다 — 화면을 떠났으면 그 글로 알림). 16단계 전에 저장한 글만 있는 응답은 `textToParagraphHtml` 로 문단을 만들어 연다. **회사 밖 응답**: 고르기 차례 사내와 같은 응답(처음 값) · 보내지 않기 · 따로 쓰기(열리는 칸이 맨 끝) — 따로 쓰기의 두 칸은 `v-show` 로 닫아 다른 고르기로 바꿔도 편집기째 남기고, 저장은 고르기와 상관없이 따로 쓴 제목 · 내용을 함께 보낸다(쓴 것이 없으면 null). 검사는 켤 때만 · 회사 밖 두 칸은 따로 쓰기일 때만, 보는 차례 `VACATION_PROBLEM_ORDER`(기간 · 제목 · 내용 · 회사 밖 제목 · 회사 밖 내용), 600자 넘음은 전용 글(`bodyTooLong`)이고 그 밖은 사전 문구. 바꿈은 칸(`sameVacationForm` — 고르기 · 회사 밖 제목 포함) + 두 편집기의 `isChanged()`(저장하면 `captureSavePoint` → `markSaved`). 미리 보기는 편집 중인 문서를 편집기 스키마로 그린다(`DOMSerializer` — 서명 미리 보기와 같다, v-html 없음). **`useMail().saveVacation` 은 일곱 칸을 늘 보낸다**(`htmlBody` · `external` — 주지 않으면 글만 · 사내와 같은 응답), 응답에 새 칸이 없거나 모양이 틀리면(16단계 전 서버) `vacationStateOf` 가 글만 · 사내와 같은 응답으로 맞춘다 — 타입의 두 칸이 optional 인 까닭(메일함 패널 안내는 4단계 칸만 본다) — 디자인 README "16단계 › 부재중" | | 2단계 인증 휴대폰 바꾸기 (16단계) | `src/components/settings/TwoFactorReplaceDialog.vue`(창 — 두 걸음) · `TwoFactorReplaceSteps.vue`(차례 "새 휴대폰 등록 › 지금 휴대폰으로 확인") · QR · 키는 `TwoFactorEnrollmentScan`(켜기 창과 공용 — `title` · `discardNote` · `keyLabelId` 를 받는다) · 문의 줄 `LoginSupportLine` · API `composables/useAccountSecurity.ts`(`verifyTwoFactorEnrollment` · `replaceTwoFactor`) · 타입 `shared/types/account-security.ts`(`TwoFactorVerificationPayload` · `TwoFactorReplacementPayload`) · 글 `shared/constants/account-security.ts` 의 `TWO_FACTOR_REPLACE_TEXT` · 자리 `pages/settings/AccountTwoFactorPage.vue`([2단계 인증 끄기] · 끌 수 없다는 글 앞) | 계약 5절 "2단계 인증 — 휴대폰 바꾸기". ① `POST /two-factor/enrollments`(켜져 있어도 — 열 때마다 새 키) → `…/enrollments/{id}/verification`(새 휴대폰의 코드) ② `POST /two-factor/replacements`(지금 코드 · 지금 비밀번호 — 한도 30초 `TWO_FACTOR_CHANGE_TIMEOUT_MS`). **요청은 창이 보낸다**(Pinia 액션 금지 — 12단계 규칙), 키 · 코드 · 비밀번호는 창 메모리에만 · 닫을 때와 세션 세대(`authStore.generation`)가 바뀔 때 지운다, `openSeq` 로 닫았다 다시 연 창에 앞 요청의 결과를 넣지 않는다. 칸 아래 거절: ① `AUTH_006` / ② `AUTH_006` · `AUTH_005`(silent). `AUTH_008` 은 상태를 다시 받아(`twoFactorStore.refresh`) 꺼졌으면 창을 닫고 사전 문구 알림, 아니면 ①로 돌아와 창 위 상자 + 새 키. **결과를 모르면**(`MAIL_001` · `SYS_001` — silent) 단정하지 않는 글(`unconfirmed` — 창 위 상자, ② 그대로 · 닫힌 뒤면 `unconfirmedClosed` 알림). 바꾸면 스토어에 `apply` + 알림 `done`(창이 띄운다 — 닫았거나 화면을 떠난 뒤 왔어도), 화면은 초점을 [휴대폰 바꾸기]로. [이전]은 같은 키로 ①(새 코드 칸을 비우고 다시 확인 — 지금 비밀번호는 창을 닫을 때까지 남긴다). 끌 수 없는 직원에게도 같은 단추 — 디자인 README "16단계 › 휴대폰 바꾸기" | diff --git a/docs/design/webmail/README.md b/docs/design/webmail/README.md index 3d035d6..d2b808a 100644 --- a/docs/design/webmail/README.md +++ b/docs/design/webmail/README.md @@ -1316,6 +1316,8 @@ PC 에서는 설정 › 계정 › 휴대폰 앱(A)이 QR 코드로 휴대폰에 - 남은 자리보다 많으면 노란 상자 "연락처는 3,000개까지 둘 수 있어 앞에서부터 188명만 넣습니다. 나머지 142명은 넣지 않습니다." — 단추의 수도 그만큼. - [연락처 330명 넣기](처음 초점) [취소]. 넣을 연락처가 없으면 [연락처 넣기]를 끄고(`aria-disabled`) 단추 왼쪽에 "넣을 연락처가 없습니다." - Esc · [취소]는 묻지 않고 닫는다(아직 아무것도 넣지 않았다). 창이 화면보다 길면 창 안이 스크롤하고 단추 줄은 고정하지 않는다(필터 창과 같다). + 창 안의 칸은 줄어들지 않는다 — 세 묶음 상자(둥근 모서리 때문에 `overflow: hidden`)는 `flex-shrink: 0`. 창(``)이 flex 열이라 overflow 를 건 칸은 내용보다 작게 눌릴 수 있다 + (2026-11-10 실사용 시험 — 1366×768 · 1440×900 에서 상자가 눌려 [이미 있는 연락처는 건너뜁니다] · "그 밖에 N명"이 안 보이고 창도 스크롤하지 않았다). 내보내기 창도 같다. - **넣는 동안 · 끝난 뒤**(알림 — 필터 적용 · 차단 정리와 같은 결): [연락처 N명 넣기]를 누르면 창을 닫고 진행 알림 "연락처를 넣는 중 · 120 / 330명"(넣은 수 / 넣을 수 — 아래 「요청 묶음」 하나가 끝날 때마다 고친다). - 끝: 같은 자리에서 "연락처 330명을 넣었습니다. 이미 있던 8명은 건너뛰었습니다." + [되돌리기] + ✕ — **✕로 닫을 때까지 남는다**(저절로 닫히지 않는다). 건너뛴 수 · 한도에 걸린 수는 한 문장씩 덧붙인다 — "연락처는 3,000개까지 둘 수 있어 나머지 142명은 넣지 못했습니다." · "그룹은 100개, 그룹 하나에는 500명까지 둘 수 있어 24명은 그룹에 넣지 못했습니다." @@ -1973,7 +1975,8 @@ PC 에서는 설정 › 계정 › 휴대폰 앱(A)이 QR 코드로 휴대폰에 쓰기 화면이 처음 넣는 서명은 표시(`data-mail-signature` — 서버 정화가 지운다)로 안다. - **서명 앞의 빈 줄은 늘 둔다**(2026-11-10 리뷰 M3 — 빈 줄에 글을 쓰거나 지운 채 저장하면 다시 열 때 서명 자리를 잃었다): 그 빈 줄에 글을 쓰면 쓴 글 아래 · 서명 위에 새 빈 줄이 생기고, Backspace · Delete 로 빈 줄을 지우면 다시 생긴다(커서는 쓰던 자리 · 윗줄 끝, 실행 취소는 그 입력과 함께 — 쓴 글은 지우지 않는다). - 한글을 조합하는 중에는 미뤘다가 다음 입력에서 넣고, 보내고 저장하는 모양도 빠진 빈 줄을 한 번 더 넣는다. + 한글을 조합하는 중에는 미뤘다가 조합이 끝나면(화살표 · 다른 칸 누르기 등 — 다른 입력이 없어도) 넣고(2026-11-10 실사용 시험 — 띄어쓰기를 쳐야 돌아왔다), + 보내고 저장하는 모양도 빠진 빈 줄을 한 번 더 넣는다. 조합 중에 저장한 뒤 편집기가 넣은 빈 줄은 바꿈으로 세지 않는다(저장한 본문과 같다). 알려진 한계: 16단계 앞에 저장한 본문(서명이 문단으로만 들어 있다)은 서명 자리를 찾지 못한다 — 그 서명은 글로 남고, [서명 ▾]는 ✓ 없이 열리며 고르면 처음 넣는 자리에 새로 넣는다. - **서명 자리는 하나**: 서명 안에서 복사해 붙여 넣으면 편집기가 서명 자리째 붙이는데, 그때 처음부터 있던 것만 두고 나머지는 글로 푼다(쓰기 화면의 편집기에서만 — 붙여 넣기 · 끌어 놓기 모두). - **어느 서명이 들었나**: 서명 자리의 글(공백 · nbsp 를 하나로 줄여)과 그림 주소를 저장한 서명(편집기로 읽은 모양)과 견준다 — 만든 차례로 먼저 맞는 서명에 ✓. 본문이 바뀔 때마다 다시 잰다(서명 안을 고치면 ✓ 가 빠진다). diff --git a/frontend/src/components/contacts/ContactExportDialog.vue b/frontend/src/components/contacts/ContactExportDialog.vue index 9533678..8c926e9 100644 --- a/frontend/src/components/contacts/ContactExportDialog.vue +++ b/frontend/src/components/contacts/ContactExportDialog.vue @@ -97,6 +97,7 @@ watch(