feat: 메일 15단계 — 읽기 화면 이동 · 끌어다 놓기 · 메일함 옮기기 · 차단할 때 받은편지함 정리 · 예약 메일 지금 보내기 #23

Merged
ttipo merged 6 commits from feat/mail-handling into main 2026-11-09 22:35:44 +09:00
17 changed files with 271 additions and 37 deletions
Showing only changes of commit 071fe9a60f - Show all commits
+2 -2
View File
@@ -29,7 +29,7 @@ description: Vue 3 프론트엔드(frontend/) 개발 규칙 — Composition API
| 인증 스토어 | `src/stores/auth.store.ts` | 로그인 사용자(email·name) · 세션 복원(`restore`) · 만료 상태(`expireSession`) · 로그아웃 상태(`clearAuth()` 가 켜는 `signedOut` — 다시 로그인할 때까지 늦게 온 갱신·복원 결과를 쓰지 않는다) |
| 공통 컴포넌트 예제 | `src/components/BaseButton.vue` | 재사용 컴포넌트 기준 패턴(타입 Props/Emits·scoped·rem·a11y) |
| 라우터 | `src/router/index.ts` · 라우트 표 `src/router/routes.ts`(라우터와 라우트 테스트가 함께 쓴다) | lazy import + 첫 네비게이션 전 세션 복원 + `requiresAuth` 가드(미인증 → `{ name: 'login', query: { redirect } }`) |
| 전역 셸 | `src/components/shell/AppShell.vue` | 앱 레일 + 상단 바 + 모듈 영역. 레일 모듈 목록은 `src/shared/constants/app-modules.ts` 하나. 상단 바(`AppTopBar.vue` — 15단계): 머리글자 · [로그아웃]은 줄지 않고(nowrap) 이름 · 주소 칸만 줄어 "…"(title "이름 (주소)"), 화면 폭 480(30rem) 이하에서 주소를 감춘다 — 줄 수 · 차례는 그대로 |
| 전역 셸 | `src/components/shell/AppShell.vue` | 앱 레일 + 상단 바 + 모듈 영역. 레일 모듈 목록은 `src/shared/constants/app-modules.ts` 하나. 상단 바(`AppTopBar.vue` — 15단계): 머리글자 · [로그아웃]은 줄지 않고(nowrap) 이름 · 주소 칸만 줄어 "…"(title "이름 (주소)"), 화면 폭 480(30rem) 이하에서 주소를 감춘다 — 줄 수 · 차례는 그대로이고 검색 칸(search 슬롯 · 통합 검색)도 `min-width: 0` 으로 바 안쪽에 맞게 준다(360 에서 넘쳤다). 앱 틀은 잘못 놓은 파일을 무시한다(15단계 — `composables/useWindowFileDrop.ts` 의 `useIgnoredWindowFileDrop`: 받는 화면(`useWindowFileDrop` — 쓰기 화면 · 서명 설정)이 떠 있으면 그 화면이 받는다) |
| 디자인 토큰·글꼴 | `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단계 필터 적용의 [{메일함} 보기] · [다시 시도], 시간 · 세션 묶기는 다른 단추와 같다). 진행 알림도 세션이 바뀌면 닫는다 |
@@ -71,7 +71,7 @@ description: Vue 3 프론트엔드(frontend/) 개발 규칙 — Composition API
| 전환 준비 (13단계) | 제한된 세션 `stores/auth.store.ts`(`restriction` · `twoFactorDeadline` · `restrictionChecks` · `canUseApp`) · `shared/utils/auth-session.ts` · `router/index.ts` 가드 + `composables/useSessionRestriction.ts`(App) · `useApi.ts` 의 `recheckRestriction` · 로그인 틀 단계 `components/auth/LoginPasswordChangeStep.vue` · `LoginTwoFactorSetupStep.vue` · `LoginTwoFactorScan.vue`(QR 148 — `QrCodeImage` 의 `compact`) · `LoginAccountRow.vue` · `LoginSupportLine.vue` · `composables/useLoginSecret.ts` · `shared/constants/session-restriction.ts` · 문의 연락처 `stores/support-contact.store.ts` · `shared/utils/support-contact.ts` · 유예 줄 `components/mail/TwoFactorGraceNotice.vue`(AppShell 의 `notice` 슬롯 — MailLayout 에만) · 메일 프로그램 연결 `pages/settings/AccountMailClientsPage.vue` + `components/settings/MailClientGuideList.vue` · `shared/constants/mail-clients.ts` · 30일 줄 `MailListFrame`(`MAILBOX_RETENTION_NOTICES` · `MAILBOX_EMPTY_HINTS`) | **제한된 세션**(계약 6절): login · me · refresh 의 `restriction` 이 있으면 보호 화면 대신 로그인 틀의 그 단계(새 비밀번호 → 2단계 켜기)로 — 403 `AUTH_010` 은 알림 없이 me 를 다시 받고(제한 없음이면 refresh 후 원래 요청 한 번만), 서버가 제한을 확인할 때마다(`restrictionChecks`) 다시 옮긴다. 제한된 동안은 `canUseApp` 이 false 라 나가기 확인을 묻지 않고(세션이 끝난 것처럼 — 2026-11-02 리뷰: 확인에서 이동을 취소하면 메일 화면에 갇혔다) 실시간 알림 · 새 메일 확인을 열지 않는다. 모르는 제한 이름은 `unknown` — 갱신 · 다시 보내기 없이 사전 문구 + [다른 계정으로 로그인]만. **비밀**: 로그인 때 넣은 비밀번호는 반응형이 아닌 클로저에 10분(서버가 마지막으로 확인한 때부터) — 성공 · 다른 계정 · 떠남 · `isLoggedIn` false 때 지운다, 거절(`AUTH_005`)되면 지우고 칸을 보인다. 성공한 로그인 코드도 지운다(`resetCodeStep`). 단계를 마친 뒤 refresh 가 장애면 me 를 먼저 묻는다(제한이 있으면 그 단계 · 없으면 이어 감 · me 도 실패면 위쪽 상자 + [다시 시도]). 2단계를 켠 직원의 새 비밀번호 단계는 다음 코드를 받는다(로그인 코드는 2분 다시 못 쓴다). **유예 줄**: 높이 40 · [지금 켜기] 36(안내 줄 밀도 예외 재사용), 날짜는 `twoFactorDeadline` 의 1ms 앞(올해가 아니면 해), 닫기 없음, 마감이 있으면 `GET /two-factor` 를 한 번 물어 이미 켠 직원에게는 숨긴다. 설정 › 2단계 인증은 `isRequired` · `deadline` — 필수 · 켜짐이면 끄기 대신 글. **메일 프로그램 연결**: 펼침 줄 52(`--size-header-min` 재사용 — 예외 아님) · 한 번에 하나 · 처음 모두 접힘(h3 > button · `aria-expanded` · region), 글은 상수가 원본(디자인 README 13단계), 앱 비밀번호 화면에 바로가기 줄, 아이콘 `monitor-smartphone`. `PasswordField` · `OtpCodeField` 의 `variant="login"`(로그인 틀 칸 모양), `PasswordField` 의 `readonly`. 12단계 코드 단계의 도움말은 문의 줄로 바뀌었다(`LOGIN_TWO_FACTOR_TEXT.help` 삭제) — 계약 6절, 디자인 README "13단계" |
| 휴대폰 앱 받기 (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`)가 바뀌면 늦은 응답을 버리고 값을 지운다. [앱 내려받기]는 첨부처럼 `<a href download>` + 이동 직전 `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`(MailLayout) · 시험 도구 `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`) — 분할 보기 570 · 휴지통 595 · 목록 보기 850 · 875 보다 좁으면 이름을 숨긴다(목록 보기는 단추 줄 최대 폭을 푼다, 화면 폭 기준은 tokens.css 머리). 접히는 단추는 `collapsible`(aria-label · title · `.tool-button__label`) — 계약 2절, 디자인 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단계" |
| 시계 주입 | `src/composables/useClock.ts` · `src/test-utils/manual-clock.ts` | 타이머를 쓰는 로직(자동 저장 등)은 시계를 주입받는다 — 테스트는 손으로 돌리는 시계로 시간을 넘긴다(고정 대기 없이, [[testing]]) |
| 나가기 전 확인 | `src/composables/useLeaveConfirm.ts` · `src/shared/utils/compose-changes.ts` | 화면을 연 뒤(또는 마지막으로 저장한 뒤) 바꾼 것이 있으면 앱 안 이동(라우트 떠남·바뀜)은 확인 대화상자, 탭 닫기·새로고침은 `beforeunload`. 쓰기 화면은 떠나기 전에 먼저 저장하고 저장하지 못한 것이 있을 때만 묻는다(서명 설정은 저장하지 않은 서명이 있으면 묻는다). 가드가 도는 중은 `guarding`. 바꿈 여부는 연 직후(저장한 뒤에는 마지막으로 저장한 판)의 스냅숏과 비교한다(편집기는 `isChanged()`). 떠나기 전 저장은 5초까지만 기다린다. 보낸 뒤·보내는 중·세션이 끝났을 때는 묻지 않는다 — 보내는 중에 떠나면 결과는 토스트로만 알리고 떠난 양식·새 양식을 건드리지 않는다(양식 세대로 늦게 온 결과를 버린다, 디자인 README "나가기 전 확인"). **고치던 모달 창**(필터 고치기 창 — 7단계)은 `composables/useDialogLeaveConfirm.ts` — 화면의 확인에 "창 닫기" 길을 더한다: [취소] · Esc 는 `asking: 'close'`([계속 고치기]면 창을 그대로 — 브라우저가 닫았으면 `reshow()`), 다른 화면은 `'route'`, 탭 닫기 · 새로고침은 `beforeunload`. 보내는 중 · 세션이 끝났을 때는 묻지 않는다 |
| 읽기 화면 본문 안내 | `src/shared/utils/mail-body-notices.ts` · `shared/constants/mail.ts` 의 `MAIL_BODY_NOTICES` | 서버의 `isHtmlBodyOmitted`·`isBodyTruncated`(계약 2절 "읽기 한도")를 외부 이미지 차단과 같은 안내 줄로 보인다(`.reader__notice[data-notice]`) |
+14 -6
View File
@@ -1183,7 +1183,7 @@ PC 에서는 설정 › 계정 › 휴대폰 앱(A)이 QR 코드로 휴대폰에
- **알림의 메일함 이름**: 같은 이름의 메일함이 다른 곳에도 있을 때만 위 메일함 이름을 붙인다("거래처 › 2025로" — 정하는 규칙은 아래 "15단계 구현하며 정한 보완"의 메일함 나무 도우미).
목록의 [이동 ▾] · 끌어다 놓기 · 되돌리기 알림도 같다.
- **메뉴 자리**: 화면(보이는 영역) 밖으로 넘치면 반대 끝에 맞춘다 — 단추 왼쪽 끝에 맞춘 메뉴가 오른쪽 밖으로 넘치면 메뉴 오른쪽 끝을 단추 오른쪽 끝에. 위아래만 바꾸던 메뉴 자리 정하기에 좌우를 더해 같은 부품의 모든 메뉴에 걸린다.
- **좁은 읽기 칸**: 도구 줄의 이름은 화면 폭이 아니라 읽기 칸 폭(컨테이너 쿼리)으로 숨긴다 — 이름을 한 줄에 다 보일 폭 570(도구 줄 좌우 여백 포함 · 휴지통은 [영구 삭제] 때문에 595)보다 좁으면 아이콘만 40×44([이동 ▾]는 ▾ 까지).
- **좁은 읽기 칸**: 도구 줄의 이름은 화면 폭이 아니라 읽기 칸 폭(컨테이너 쿼리)으로 숨긴다 — 이름을 한 줄에 다 보일 폭에 여유를 둔 580(도구 줄 좌우 여백 포함 · 휴지통은 [영구 삭제] 때문에 605)보다 좁으면 아이콘만 40×44([이동 ▾]는 ▾ 까지).
이름은 마우스를 올리면 뜨는 글(title)과 화면 낭독(aria-label)으로 읽힌다. 분할 보기 1144~1233px 에서 두 줄로 꺾이던 일이 없고, 360 에서도 두 줄로 끝난다.
- **끌어다 놓기**(목록 → 메일함 패널): 고른 줄을 끌면 고른 것 모두, 고르지 않은 줄을 끌면 그 줄만(고른 것은 그대로). 대화로 묶은 목록의 줄은 그 대화에서 지금 메일함에 든 메일(보내기를 기다리는 메일은 빼고 — [이동 ▾]와 같다),
보내기를 기다리는 메일의 줄 · 그런 메일뿐인 대화 줄은 끌 수 없다.
@@ -1193,9 +1193,9 @@ PC 에서는 설정 › 계정 › 휴대폰 앱(A)이 QR 코드로 휴대폰에
- 마우스로만 한다 — 키보드 · 터치는 [이동 ▾]. 메일함 패널은 쓰기 화면에도 보이므로 파일 끌기는 받지 않는다(첨부 놓기와 겹치지 않게). 줄의 링크가 끌어 가던 메일 주소(URL)는 싣지 않는다 — 다른 창 · 바깥 프로그램에 놓아도 아무 일 없다.
- **스팸메일함으로 옮기는 세 길 같게**: [스팸 신고] · [이동 ▾]에서 스팸메일함 · 스팸메일함에 끌어다 놓기는 모두 스팸 표시를 붙여 옮긴다. 스팸메일함에서 휴지통이 아닌 메일함으로 옮기면(메뉴 · 끌기)
[스팸 아님]과 같이 스팸 아님 표시를 붙인다 — "메일 1통을 스팸이 아닌 메일로 표시해 거래처로 옮겼습니다." 휴지통으로는 표시를 바꾸지 않는다. [되돌리기]는 표시도 되돌린다.
- **메일 화면에 파일을 놓으면**: 지금은 브라우저가 그 파일을 열어 웹메일을 떠났다 — 메일 화면에서는 무시한다(놓는 동안 금지 커서). 쓰기 화면은 지금처럼 어디에 놓아도 첨부로 받는다.
- **화면에 파일을 잘못 놓으면**(메일 · 주소록 · 설정 — 로그인 뒤 모든 모듈 화면): 지금은 브라우저가 그 파일을 열어 웹메일을 떠났다 — 무시한다(놓는 동안 금지 커서). 쓰기 화면은 지금처럼 어디에 놓아도 첨부로, 서명 설정은 그림으로 받는다.
- **상단 바 좁은 폭**(360 — 바 안쪽 256): [로그아웃]은 줄지도 꺾이지도 않는다. 이름 · 주소는 넘치면 끝을 "…"로 줄이고 마우스를 올리면 다 보인다(title "김아라 (ara.kim@arasystem.it.kr)").
화면 폭 480 이하에서는 주소를 감추고 이름만 둔다. 줄 수와 차례(메일 · 검색 칸 · 계정)는 지금과 같다 — 계정 줄을 첫 줄로 올리지 않는다(Tab 차례).
화면 폭 480 이하에서는 주소를 감추고 이름만 둔다. 줄 수와 차례(메일 · 검색 칸 · 계정)는 지금과 같다 — 계정 줄을 첫 줄로 올리지 않는다(Tab 차례). 검색 칸은 바 안쪽 폭에 맞게 준다.
- **같은 대화 [모두 펼치기]**: 칸 머리 오른쪽의 글자 단추(36 — 펼친 줄의 [답장]과 같은 크기, 안내 줄 단추 `--size-notice-action` 재사용). 불러온 줄(지금 보는 메일 빼고)을 모두 펼치고 [모두 접기]로 바뀐다 —
줄을 하나씩 다 펼쳐도 [모두 접기]이고, 하나라도 접혀 있으면 [모두 펼치기]. [더 보기]로 더 받은 줄은 접힌 채로 온다.
본문은 한꺼번에 받지 않는다 — 화면에 들어오는 줄부터 하나씩(요청 수 한도와 화면 무게), 아직 화면 밖인 줄은 받는 중과 같은 자리만 둔다.
@@ -1700,7 +1700,9 @@ PC 에서는 설정 › 계정 › 휴대폰 앱(A)이 QR 코드로 휴대폰에
- **하위까지 지우기**: 지우는 동안 나무의 모든 메일함(하위 포함)을 "지우는 중"으로 둔다 — 그 메뉴들이 꺼지고, 나무 안의 메일함을 따로 지우기 시작하지 않는다.
하위 메일함을 보고 있었어도 끝나면 받은편지함으로 간다. 하위가 없는 메일함은 지금처럼 `withChildren` 없이 보낸다.
- **차단할 때 받은 메일 옮기기**
- 「이 메일도 …」가 켜져 있으면 그 메일을 먼저 옮긴 뒤(지금 길 그대로 — 읽기 화면은 삭제처럼 바로 다음 메일로) 받은편지함 정리를 시작한다. 그 메일이 받은편지함에 있었으면 옮긴 수에 넣는다.
- 「이 메일도 …」가 켜져 있으면 그 메일을 먼저 옮긴 뒤 받은편지함 정리를 시작한다. 그 메일이 받은편지함에 있었으면 옮긴 수에 넣는다.
옮긴 뒤 읽기 화면은 다음 메일을 열지 않고 목록으로 간다 — 분할 보기는 읽기 칸이 비고 목록 보기는 목록이다(2026-11-09 실사용 시험: 다음 메일이 같은 보낸 사람의 것이면
열자마자 읽음이 되고, 곧 정리가 스팸메일함으로 옮겨 받은편지함 주소에 [스팸 아님] · 차단 안내가 뜬 채 남았다). 목록은 정리가 끝나면 다시 맞춘다. 새 칸을 켜지 않은 차단은 지금처럼 삭제와 같이 다음 메일이다.
- 이 탭에서 받은편지함을 정리하는 중에 [다시 시도]를 누르면 "‘…’을(를) 차단했습니다. 받은편지함을 정리하는 중입니다. 끝난 뒤 다시 시도해 주세요." + 같은 [다시 시도](보드에 없다 — 필터 적용의 busy 와 같은 결).
- 그사이 차단을 풀었으면(404 `RES_001`) 진행 알림을 닫고 사전 문구만 보인다.
- 끝났거나 나아간 뒤 멈췄으면 개수를 다시 받고, 받은편지함 · 스팸메일함 목록을 보고 있으면 그 목록도 맞춘다(필터 적용과 같다).
@@ -1723,11 +1725,14 @@ PC 에서는 설정 › 계정 › 휴대폰 앱(A)이 QR 코드로 휴대폰에
받는 사람이 모두 받지 않았거나(400 `MAIL_010` · 503 `MAIL_011`) 예약이 그사이 사라졌으면(404) 사전 문구 알림만 보이고 그 자리에 두며, 예약 표시 · 예약됨 수 · 보이는 목록을 서버 기준으로 다시 맞춘다.
- 보내기 취소 알림의 마감은 쓰기 화면과 같은 규칙이다(설정한 초를 알면 "지금 + 초 − 1초", 모르면 보낼 시각까지 · 30초를 넘지 않게 — `undoToastDeadline`).
- 안내 줄이 좁으면(글이 12rem 보다 좁아질 때) 단추 셋을 묶음째 다음 줄 오른쪽으로 보낸다 — 단추가 셋이 되며 360 폭에서 글이 한 글자 폭으로 눌리지 않게(보드는 넓은 폭만 그렸다).
- **모달 창의 제목 · 글**(모든 창 — `ModalDialog`): 낱말 가운데서 줄을 바꾸지 않는다(`word-break: keep-all` — "지울까/요?"가 나왔다, 15단계 전부터).
띄어쓰기 없는 긴 글(긴 주소 · 파일 이름)은 창 밖으로 넘치지 않게 아무 데서나 끊는다(`overflow-wrap: anywhere`). 슬롯에 넣은 칸 · 목록은 쓰는 쪽이 정한다(2026-11-09 실사용 시험).
**읽기 화면 · 목록 · 상단 바**
- **좁은 읽기 칸의 기준(보드에 없는 것)**: 컨테이너는 도구 줄 띠(`.reader__toolbar-bar` — 칸 전체 폭)다. 목록 보기는 앞에 [목록으로] · 이전/다음이 함께 놓여 이름이 다 보이는 줄이 800(콘텐츠 열)보다 조금 넓다 —
그래서 목록 보기만 단추 줄의 최대 폭을 풀고 접는 폭을 850 · 휴지통 875 로 두었다(570 실측에 [목록으로] · 이전/다음 · 위치 글을 더한 어림 — 브라우저에서 다시 잰다). 화면 폭 기준은 tokens.css 머리에 한 줄씩 적었다.
그래서 목록 보기만 단추 줄의 최대 폭을 풀고 접는 폭을 850 · 휴지통 875 로 두었다(분할 보기의 필요 폭에 [목록으로] · 이전/다음 · 위치 글을 더한 어림 — 실사용 시험에서 넉넉했다). 화면 폭 기준은 tokens.css 머리에 한 줄씩 적었다.
분할 보기는 보드의 570 · 595 가 아니라 580 · 605 다 — 실측한 필요 폭(받은편지함 569.19 · 휴지통 594.83)에 여유가 0.81 · 0.17px 뿐이라 글자 · 여백이 1px 만 늘어도 좁은 구간에서 두 줄로 꺾인다(15단계 실사용 시험).
- **메뉴 폭**: 옮길 메일함 메뉴는 이름 길이만큼(200 부터 320 까지 — 좁은 화면은 화면 폭 − 80 까지) 넓어지고 그보다 긴 이름은 줄을 바꾼다(보드는 줄을 바꾸지 않는 짧은 이름만 그렸다).
- **좌우 바꾸기**: 오른쪽 끝에 맞춘 메뉴(⋯)가 왼쪽 밖으로 넘칠 때도 같은 규칙으로 단추 왼쪽 끝에 맞춘다(360 에서 ⋯ 가 둘째 줄 맨 앞으로 내려간다). 양쪽 다 넘치면 덜 넘치는 쪽이다(위아래와 같은 결).
- **흐린 줄 더**: 하위 메일함이 있는 휴지통 · 임시보관함(다른 메일 프로그램이 지운 폴더를 휴지통 아래에 둔다)도 까닭 글 없이 흐린 줄로 남겨 그 하위 메일함을 고를 수 있게 한다.
@@ -1739,8 +1744,11 @@ PC 에서는 설정 › 계정 › 휴대폰 앱(A)이 QR 코드로 휴대폰에
- 이 탭에서 시작한 끌기만 받는다(다른 탭 · 바깥에서 끌어 온 같은 꼴의 자료는 놓을 수 없다).
- 열어 둔 메일을 끌어 옮겼으면 목록 도구 줄로 옮길 때처럼 목록 주소로 바꿔 끼운다.
- 패널의 메일함이 많아 아래가 가려져 있으면 패널 끝에서 저절로 스크롤되는지는 브라우저가 한다 — 브라우저에서 확인한다.
- **메일 화면 파일 놓기 무시**: 쓰기 화면도 메일 화면 안이라 두 창 처리기가 함께 걸린다 — 파일을 받는 화면(쓰기 화면 · 서명 설정)이 떠 있으면 무시하는 쪽이 아무것도 하지 않는다(어느 쪽이 먼저 걸렸는지와 상관없이 쓰기 화면이 받는다).
- **파일 놓기 무시는 앱 틀(AppShell)에**: 처음에는 메일 화면에만 걸어 주소록 · 설정에 놓은 파일은 여전히 브라우저가 열었다(15단계 실사용 시험) — 로그인 뒤 모든 모듈 화면이 쓰는 앱 틀로 옮겼다.
쓰기 화면 · 서명 설정도 앱 틀 안이라 두 창 처리기가 함께 걸린다 — 파일을 받는 화면이 떠 있으면 무시하는 쪽이 아무것도 하지 않는다(어느 쪽이 먼저 걸렸는지와 상관없이 받는 화면이 받는다).
앱 틀 밖의 화면(로그인 · 인쇄 · 휴대폰 앱 받기 · 공개 내려받기)은 그대로다.
- **상단 바**: 줄어든 이름 · 주소의 title 은 이름 · 주소 칸 전체에 둔다(넓은 폭에서 주소가 줄어도 같은 글).
검색 칸(메일 검색 · 비활성 통합 검색)은 최소 폭을 0 으로 둔다 — 입력 칸의 기본 폭에 묶여 360 에서 278 로 바 안쪽(256)을 넘고 화면이 옆으로 10px 밀렸다(14단계부터 — 15단계 실사용 시험).
- **같은 대화 [모두 펼치기]**
- 손으로 펼친 줄은 지금처럼 누른 그 자리에서 본문을 받고 받는 대로 읽음이다. [모두 펼치기]로 펼친 줄만 화면에 들어올 때 차례로 받는다 — 차례가 오기 전에 화면 밖으로 나간 줄은 건너뛰고 다시 들어오면 다시 줄을 선다.
- 받는 사이 화면 밖으로 나갔으면 읽음으로 바꾸지 않고, 다시 들어올 때 읽음이다(읽음이 실패해도 드나들 때마다 다시 묻지 않는다).
@@ -11,6 +11,7 @@
// - 닫기는 열기보다 먼저 한다 — 한 대화상자가 닫히고 다른 대화상자가 열리는 때(세션 만료) 둘이 겹치지 않게
// 단추는 actions 슬롯에 둔다: class="modal-dialog__action" (보조 단추는 modal-dialog__action--secondary, 되돌릴 수 없는 일은 --danger 를 더한다)
// 글(message) 대신 · 글 아래에 입력 칸을 둘 때는 기본 슬롯에 넣는다 — 글이 없으면 describedBy 로 칸의 안내를 가리킨다
// 제목 · 글은 낱말 단위로 줄을 바꾸고(keep-all) 띄어쓰기 없는 긴 글만 아무 데서나 끊는다(15단계 실사용 시험) — 슬롯 안의 글은 쓰는 쪽이 정한다
import { computed, onMounted, ref, watch } from 'vue'
interface Props {
@@ -190,6 +191,16 @@ onMounted(() => {
background: var(--color-backdrop);
}
/*
* 제목 · 글은 낱말 가운데서 줄을 바꾸지 않는다("지울까/요?" — 2026-11-09 실사용 시험). 띄어쓰기 없는 긴 글(긴 주소 · 파일 이름)은
* 창 밖으로 넘치지 않게 아무 데서나 끊는다 — 둘을 함께 둔다
*/
.modal-dialog__title,
.modal-dialog__message {
word-break: keep-all;
overflow-wrap: anywhere;
}
.modal-dialog__title {
margin: 0;
font-size: var(--font-size-title-sm);
@@ -8,6 +8,7 @@
// 패널이 열린 동안 검색 칸의 Enter 는 [찾기]와 같고, 검색 칸의 Esc 는 패널만 닫는다(초점은 검색 칸에 남는다).
// 검색 칸이 비어도 조건만으로 찾는다. 패널이 닫힌 채 누른 Enter 는 걸려 있는 조건을 그대로 둔다.
// 패널의 [조건 지우기]는 패널의 칸만 비운다(MailSearchConditions) — 걸린 조건은 [찾기]를 누르거나 결과의 조건 줄 [조건 지우기]로 뺀다
// - 좁은 폭(15단계 실사용 시험 — 360)에서는 상단 바 안쪽 폭에 맞게 칸이 준다(min-width 0 — 입력 칸의 최소 폭에 묶여 바 밖으로 넘치지 않게)
import { computed, nextTick, ref, watch } from 'vue'
import AppIcon from '@/components/AppIcon.vue'
@@ -152,6 +153,8 @@ watch(
.mail-search {
position: relative;
flex: 1 1 17.5rem;
/* 바 안쪽이 280 보다 좁으면(폭 360 — 256) 그만큼 준다. 없으면 입력 칸의 최소 폭에 묶여 바 밖으로 넘치고 화면이 옆으로 밀린다 */
min-width: 0;
max-width: 32.5rem;
display: flex;
align-items: center;
@@ -16,7 +16,8 @@
// 메뉴를 열 때 필터를 아직 받지 않았으면 받아 본다(알리지 않는다 — 모르는 동안은 켜 두고, 설정 화면이 다시 본다)
// 차단: 사내 직원(사내 도메인) · 내 주소에는 항목이 없다. 이미 막았으면(주소 또는 도메인) 「보낸 사람 차단 풀기」(바로 푼다 + [되돌리기]),
// 아니면 「보낸 사람 차단」 → 확인 창(MessageBlockDialog). 500개면 끄고 까닭을 아래에. 차단 목록은 메일 화면에 들어올 때 받아 두고,
// 메뉴를 열 때 못 받았으면 다시 받아 본다(알리지 않는다). 막으며 이 메일을 스팸메일함으로 옮겼으면 삭제처럼 다음 메일 · 목록으로 간다
// 메뉴를 열 때 못 받았으면 다시 받아 본다(알리지 않는다). 막으며 이 메일을 스팸메일함으로 옮겼으면 삭제처럼 다음 메일 · 목록으로 간다 —
// 받은편지함의 그 보낸 사람 메일도 옮기기로 했으면(15단계 새 칸) 다음 메일을 열지 않고 목록으로(분할 보기는 읽기 칸이 빈다)
import { computed, ref, watch } from 'vue'
import { useRouter } from 'vue-router'
@@ -50,7 +51,7 @@ import { useMailStore } from '@/stores/mail.store'
interface Props {
message: MessageDetail
/** split: 차단하며 옮기면 다음 메일을 연다 · list: 목록으로 */
/** split: 차단하며 옮기면 다음 메일을 연다(받은편지함 정리를 켰으면 목록으로) · list: 목록으로 */
layout?: 'split' | 'list'
}
@@ -215,6 +216,8 @@ function focusButton() {
/**
* 차단 확인 창의 [차단] — 창을 닫고 막는다. 이 메일을 스팸메일함으로 옮겼으면 삭제처럼 다음 메일 · 목록으로.
* 받은편지함의 그 보낸 사람 메일도 옮기기로 했으면(cleanInbox — 15단계) 정리는 알림이 이어 보인다(기다리지 않는다).
* 그때는 다음 메일을 열지 않고 목록으로 간다 — 다음 메일이 같은 보낸 사람의 것이면 열자마자 읽음이 되고, 곧 정리가 스팸메일함으로 옮겨
* 받은편지함 주소에 그 메일이 남는다(2026-11-09 실사용 시험). 목록은 정리가 끝나면 다시 맞춘다(useSenderBlockRun).
* 기다리는 사이 다른 목록 · 다른 메일로 옮겨 갔으면(stale · leaveRemoved 의 stillShowing) 움직이지 않는다
*/
async function onBlock(target: string, moveToJunk: boolean, cleanInbox: boolean) {
@@ -225,7 +228,7 @@ async function onBlock(target: string, moveToJunk: boolean, cleanInbox: boolean)
const outcome = await senderBlock.block(message, target, moveToJunk, cleanInbox)
if (!outcome.moved || outcome.stale) return
const removed = !mailStore.isListedMessage(message.id)
await removal.leaveRemoved({ removed, nextId: removed ? nextId : null }, source, props.layout, message.id)
await removal.leaveRemoved({ removed, nextId: removed && !cleanInbox ? nextId : null }, source, props.layout, message.id)
}
// 다른 메일로 바뀌면 지난 보낸 사람의 결과를 버린다 (확인 창도 닫는다 — 다른 메일의 보낸 사람을 막지 않게)
@@ -542,7 +542,7 @@ describe('MessageReader — 15단계 지금 보내기 · 차단할 때 받은
return wrapper.get('[data-block-dialog]')
}
it('새 칸을 켜고 [차단] — 이 메일을 먼저 옮기고 받은편지함 정리를 시작한다 · 차단 알림 대신 알림 하나가 결과(이 메일까지 센 수) + [스팸메일함 보기]', async () => {
it('새 칸을 켜고 [차단] — 이 메일을 먼저 옮기고 받은편지함 정리를 시작한다 · 차단 알림 대신 알림 하나가 결과(이 메일까지 센 수) + [스팸메일함 보기] · 다음 메일 대신 목록으로', async () => {
const { wrapper, router } = await mountWith({
extra: (request) =>
request.url === '/mail-block-runs' ? ok({ scanned: 120, matched: 3, remaining: 0, cursor: null }) : undefined,
@@ -571,8 +571,25 @@ describe('MessageReader — 15단계 지금 보내기 · 차단할 때 받은
action: { label: '스팸메일함 보기' },
},
])
// 옮겼으니 삭제처럼 다음 메일을 연다
expect(router.currentRoute.value.fullPath).toBe('/mail/inbox/m2')
// 다음 메일을 열지 않고 목록으로 — 다음 메일이 같은 보낸 사람의 것이면 읽음이 된 채 곧 정리가 옮긴다 (분할 보기는 읽기 칸이 빈다)
expect(router.currentRoute.value.fullPath).toBe('/mail/inbox')
expect(requests.some((request) => request.method === 'GET' && request.url === '/messages/m2')).toBe(false)
})
it('목록 보기에서도 새 칸을 켠 차단은 목록으로 돌아간다 (새 칸을 켜지 않으면 지금처럼 다음 메일 — stage9 시험)', async () => {
const { wrapper, router } = await mountWith({
layout: 'list',
extra: (request) =>
request.url === '/mail-block-runs' ? ok({ scanned: 120, matched: 0, remaining: 0, cursor: null }) : undefined,
})
const dialog = await openBlockDialog(wrapper)
await dialog.get('[data-block-clean]').setValue(true)
await wrapper.get('[data-block-confirm]').trigger('click')
await flushPromises()
expect(requests.filter((request) => request.url === '/mail-block-runs')).toHaveLength(1)
expect(router.currentRoute.value.fullPath).toBe('/mail/inbox')
})
it('이 탭에서 필터를 적용하는 중이면 새 칸이 꺼져 있다 — 까닭 글', async () => {
@@ -10,6 +10,7 @@ import { useToastStore } from '@/stores/toast.store'
import { flushPromises, installMockApi, ok, restoreApiAdapter, type MockRequest } from '@/test-utils/mock-api'
import { createTestRouter } from '@/test-utils/router'
import MessageReader from './MessageReader.vue'
import readerSource from './MessageReader.vue?raw'
// 15단계 — 읽기 화면의 [이동 ▾] · 좁은 읽기 칸의 아이콘만 도구 줄 (디자인 원본 Reader15 "1 · 2 · 3")
@@ -306,4 +307,17 @@ describe('MessageReader — 좁은 읽기 칸의 아이콘만 도구 줄 (15단
expect(list.get('.reader__toolbar').classes()).toContain('reader__toolbar--list')
list.unmount()
})
it('접는 폭 — 분할 보기 580 · 「영구 삭제」 605(실측 필요 폭 569.19 · 594.83 에 여유를 둔다), 목록 보기 850 · 875 (15단계 실사용 시험)', () => {
// jsdom 은 컨테이너 쿼리를 계산하지 않는다 — 원본의 기준을 읽는다 (px = rem × 16)
const queries = [...readerSource.matchAll(/@container reader-tools \(width < ([\d.]+)rem\) \{\s*([^\s{]+)/g)].map(
([, rem = '', selector = '']) => [Number(rem) * 16, selector],
)
expect(queries).toEqual([
[580, '.reader__toolbar'],
[605, '.reader__toolbar--long'],
[850, '.reader__toolbar--list'],
[875, '.reader__toolbar--list.reader__toolbar--long'],
])
})
})
@@ -8,8 +8,9 @@
// (디자인 README "4단계 › 스팸 · 원문" · "6단계 › 상태 · 들어오는 길" · "9단계 › 읽기 화면" · "15단계").
// 아직 보내지 않은 메일(9단계 — 보내는 중 · 예약)은 이동 · 스팸 신고가 없다 — 옮기거나 지워도 엔진은 그 시각에 보낸다. 「삭제」는 확인 창 뒤 발송을 취소하고 휴지통으로.
// 읽기 칸이 좁으면 도구 줄의 이름을 숨기고 아이콘만 둔다(15단계 — 디자인 원본 Reader15 "2"). 기준은 화면 폭이 아니라 읽기 칸 폭(컨테이너 쿼리 —
// 도구 줄 띠가 그 칸이다): 이름을 한 줄에 다 보일 폭 570(도구 줄 좌우 여백 포함), 「영구 삭제」가 있으면 595. 목록 보기는 앞에 [목록으로] · 이전/다음이
// 함께 놓여 850 · 875 이고, 그 폭에서 한 줄에 들도록 단추 줄의 최대 폭(콘텐츠 열 800)을 풀어 둔다. 이름은 aria-label · title 로 읽힌다 (MailToolButton 의 collapsible)
// 도구 줄 띠가 그 칸이다): 이름을 한 줄에 다 보일 폭(도구 줄 좌우 여백 포함 — 실측 569.19, 「영구 삭제」가 있으면 594.83)에 여유를 두어 580 · 605.
// 목록 보기는 앞에 [목록으로] · 이전/다음이 함께 놓여 850 · 875 이고, 그 폭에서 한 줄에 들도록 단추 줄의 최대 폭(콘텐츠 열 800)을 풀어 둔다.
// 이름은 aria-label · title 로 읽힌다 (MailToolButton 의 collapsible)
// 예약한 메일은 답장 · 전체 답장 · 전달도 없고, 머리 아래 청록 안내 줄([고치기] [예약 취소])이 있다.
// 「삭제」 · 「영구 삭제」는 MessageDeleteButton — 휴지통인지는 그 메일의 메일함으로 정한다. 「스팸 신고」는 MessageSpamButton, ⋯ 는 MessageMoreMenu.
// 머리 아래 안내 줄(예약 · 차단한 보낸 사람 · 본문 한도 · 외부 이미지 [이미지 보기])은 MessageReaderNotices.
@@ -378,9 +379,10 @@ onBeforeUnmount(() => {
/*
* 읽기 칸이 단추 이름을 한 줄에 다 보일 폭보다 좁으면 — 이름을 숨기고 아이콘만 40×44 ([이동 ▾]는 ▾ 까지 남긴다).
* 폭(15단계 — 디자인 원본 Reader15 "2" 실측, 도구 줄 좌우 여백 포함): 분할 보기 570 · 「영구 삭제」 595, 목록 보기는 앞의 [목록으로] · 이전/다음을 더해 850 · 875
* 폭(15단계 — 디자인 원본 Reader15 "2", 도구 줄 좌우 여백 포함): 분할 보기 580 · 「영구 삭제」 605 — 보드의 570 · 595 는 실측 필요 폭(569.19 · 594.83)에
* 여유가 0.81 · 0.17px 뿐이라 글자 · 여백이 1px 만 늘어도 좁은 구간에서 두 줄로 꺾인다(15단계 실사용 시험). 목록 보기는 앞의 [목록으로] · 이전/다음을 더해 850 · 875
*/
@container reader-tools (width < 35.625rem) {
@container reader-tools (width < 36.25rem) {
.reader__toolbar :deep(.tool-button--collapsible) {
justify-content: center;
min-width: var(--size-icon-button);
@@ -395,7 +397,7 @@ onBeforeUnmount(() => {
}
}
@container reader-tools (width < 37.1875rem) {
@container reader-tools (width < 37.8125rem) {
.reader__toolbar--long :deep(.tool-button--collapsible) {
justify-content: center;
min-width: var(--size-icon-button);
@@ -0,0 +1,128 @@
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'
import { mount, type VueWrapper } from '@vue/test-utils'
import { createPinia, setActivePinia, type Pinia } from 'pinia'
import { defineComponent, h } from 'vue'
import MailSignaturePage from '@/pages/settings/MailSignaturePage.vue'
import SettingsLayout from '@/pages/settings/SettingsLayout.vue'
import type { MailSignature, SignatureImage } from '@/shared/types/mail'
import { useAuthStore } from '@/stores/auth.store'
import { useToastStore } from '@/stores/toast.store'
import { dragEvent, fakeDataTransfer } from '@/test-utils/drag'
import { flushPromises, installMockApi, ok, restoreApiAdapter, type MockHandler } from '@/test-utils/mock-api'
import { installProseMirrorDom } from '@/test-utils/prosemirror-dom'
import { createTestRouter } from '@/test-utils/router'
// 앱 틀 — 잘못 놓은 파일 무시 (15단계 실사용 시험: 메일 화면(MailLayout)에서만 막아서 주소록 · 설정에 놓은 파일은 브라우저가 열어 웹메일을 떠났다).
// 로그인 뒤 모든 모듈 화면이 이 틀을 쓴다 — 설정 화면(SettingsLayout)으로 본다. 파일을 받는 화면(서명 설정 — useWindowFileDrop)은 지금처럼 받는다
const USER = { email: 'ara.kim@arasystem.it.kr', name: '김아라' }
/** 서명 칸(지연 로딩한 TipTap 청크)을 기다리는 시간 — 서명 설정 테스트와 같은 값 */
const EDITOR_LOAD_TIMEOUT_MS = 15_000
const SIGNATURE: MailSignature = {
bodyHtml: '<div style="font-size: 14px"><p style="margin: 0">김아라 · 인프라팀</p></div>',
body: '김아라 · 인프라팀',
isForNew: true,
isForReply: true,
images: [],
updatedAt: '2026-10-27T05:00:00Z',
}
const UPLOADED: SignatureImage = {
id: '00000000-0000-4000-8000-000000000001',
type: 'image/png',
size: 1000,
width: 480,
height: 60,
}
/**
* 파일을 받는 곳이 없는 설정 화면(비밀번호 자리). 시험 라우터의 함수형 빈 화면은 인스턴스가 없어
* RouterView 가 떠날 때(vnode.component) 다루지 못한다 — 화면을 옮겨 가는 이 시험에서는 보통 컴포넌트로 둔다
*/
const PasswordScreen = defineComponent({ name: 'PasswordScreen', render: () => h('h1', '비밀번호') })
const server: MockHandler = ({ method, url }) => {
if (method === 'GET' && url === '/mail-signature') return ok(SIGNATURE)
if (method === 'POST' && url === '/mail-signature/images') return ok(UPLOADED, 201)
return ok(null)
}
describe('AppShell — 잘못 놓은 파일 (15단계)', () => {
let pinia: Pinia
const mounted: VueWrapper[] = []
beforeAll(installProseMirrorDom)
beforeAll(async () => {
await import('@/components/mail/editor/signature/SignatureForm.vue')
}, EDITOR_LOAD_TIMEOUT_MS)
beforeEach(() => {
pinia = createPinia()
setActivePinia(pinia)
useAuthStore().markAuthenticated(USER)
})
afterEach(() => {
for (const wrapper of mounted.splice(0)) wrapper.unmount()
document.body.innerHTML = ''
useToastStore().clear()
restoreApiAdapter()
})
it('설정 화면에 놓은 파일은 막고(놓을 수 없는 모양 · 브라우저가 열지 않는다), 서명 설정 화면은 지금처럼 그림을 받아 올린다', async () => {
const requests = installMockApi(server)
const router = createTestRouter()
// 같은 이름의 빈 화면을 바꿔 끼운다 — 서명 설정은 진짜 화면 (메뉴의 다른 설정 화면은 빈 화면 그대로)
router.addRoute({
path: '/settings/account/password',
name: 'settings-account-password',
component: PasswordScreen,
meta: { requiresAuth: true },
})
router.addRoute({
path: '/settings/mail/signature',
name: 'settings-mail-signature',
component: MailSignaturePage,
meta: { requiresAuth: true },
})
await router.push('/settings/account/password')
await router.isReady()
const wrapper = mount(SettingsLayout, { attachTo: document.body, global: { plugins: [pinia, router] } })
mounted.push(wrapper)
await flushPromises()
expect(wrapper.get('h1').text()).toBe('비밀번호')
// 설정 › 비밀번호 — 파일을 받는 곳이 없다: 놓을 수 없는 모양이고, 놓아도 기본 동작(브라우저가 파일을 연다)을 막는다
const report = fakeDataTransfer([new File(['x'], '보고서.pdf')])
report.dropEffect = 'copy'
const over = dragEvent('dragover', report)
document.body.dispatchEvent(over)
expect(over.defaultPrevented).toBe(true)
expect(report.dropEffect).toBe('none')
const drop = dragEvent('drop', report)
document.body.dispatchEvent(drop)
expect(drop.defaultPrevented).toBe(true)
// 설정 › 서명 — 같은 앱 틀 안의 받는 화면이 화면 어디에 놓은 그림이든 받아 올린다
await router.push('/settings/mail/signature')
await vi.waitFor(
() => {
if (!wrapper.find('#signature-body').exists()) throw new Error('서명 칸을 불러오는 중')
},
{ timeout: EDITOR_LOAD_TIMEOUT_MS },
)
await flushPromises()
const logo = fakeDataTransfer([new File([new Uint8Array(1000)], '로고.png', { type: 'image/png' })])
document.body.dispatchEvent(dragEvent('dragover', logo))
expect(logo.dropEffect).toBe('copy')
document.body.dispatchEvent(dragEvent('drop', logo))
await flushPromises()
// 올린 것은 서명 설정에 놓은 그림 하나뿐 — 비밀번호 화면에 놓은 파일은 어디로도 가지 않았다
expect(requests.filter((request) => request.method === 'POST').map((request) => request.url)).toEqual([
'/mail-signature/images',
])
})
})
@@ -3,10 +3,13 @@
// 모든 모듈 레이아웃은 이 셸로 감싼다: <AppShell module-id="mail"> … </AppShell>
// 모듈의 검색 칸은 search 슬롯으로 상단 바에 넣는다: <template #search><MailSearchBox /></template> (없으면 비활성 통합 검색)
// 상단 바 바로 아래 모듈 폭 전체의 안내 줄은 notice 슬롯 (13단계 — 메일 화면의 2단계 인증 유예 줄)
// 잘못 놓은 파일은 무시한다(15단계 — 로그인 뒤 모든 모듈 화면): 받지 않는 곳에 놓으면 브라우저가 그 파일을 열어 웹메일을 떠난다.
// 파일을 받는 화면(쓰기 화면 · 서명 설정 — useWindowFileDrop)이 떠 있으면 지금처럼 그 화면이 받는다 (useIgnoredWindowFileDrop)
import { computed } from 'vue'
import AppRail from './AppRail.vue'
import AppTopBar from './AppTopBar.vue'
import { useIgnoredWindowFileDrop } from '@/composables/useWindowFileDrop'
import { findAppModule, type AppModuleId } from '@/shared/constants/app-modules'
interface Props {
@@ -16,6 +19,9 @@ interface Props {
const props = defineProps<Props>()
const moduleLabel = computed(() => findAppModule(props.moduleId).label)
// 메일 · 주소록 · 설정 어디에 파일을 잘못 놓아도 아무 일 없게 (받는 화면이 떠 있으면 그 화면에 맡긴다)
useIgnoredWindowFileDrop()
</script>
<template>
@@ -2,13 +2,29 @@ import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import { mount } from '@vue/test-utils'
import { createPinia, setActivePinia, type Pinia } from 'pinia'
import MailSearchBox from '@/components/mail/MailSearchBox.vue'
import mailSearchBoxSource from '@/components/mail/MailSearchBox.vue?raw'
import { useAuthStore } from '@/stores/auth.store'
import { restoreApiAdapter } from '@/test-utils/mock-api'
import { createTestRouter } from '@/test-utils/router'
import AppTopBar from './AppTopBar.vue'
import topBarSource from './AppTopBar.vue?raw'
// 상단 바 — 15단계 좁은 폭(디자인 원본 ListDrag15 "4. 상단 바 · 휴대폰 폭 360"): 이름 · 주소 칸만 줄고 넘치면 "…", 전체는 마우스를 올리면 보인다.
// 줄 · 숨김(480 이하에서 주소 감춤)은 CSS 라 여기서는 차례 · 이름 · title 만 본다
// 줄 · 숨김(480 이하에서 주소 감춤)은 CSS 라 여기서는 차례 · 이름 · title 만 본다.
// 검색 칸도 바 안쪽(폭 360 — 256)에 맞게 준다(15단계 실사용 시험 — 278 로 넘쳐 화면이 옆으로 밀렸다) — 그 규칙은 원본의 style 에서 읽는다
/** 컴포넌트 원본에서 한 규칙의 본문 — jsdom 은 컴포넌트의 style 을 적용하지 않는다 (주석은 뺀다 — MailboxPage.search.spec 과 같은 읽기) */
function ruleBody(source: string, selector: string): string {
const rules = source
.slice(source.indexOf('<style'))
.replace(/\/\*[\s\S]*?\*\//g, '')
.matchAll(/([^{}]+)\{([^}]*)\}/g)
for (const [, found = '', body = ''] of rules) {
if (found.replace('<style scoped>', '').trim() === selector) return body
}
return ''
}
describe('AppTopBar — 계정 칸', () => {
let pinia: Pinia
@@ -48,4 +64,31 @@ describe('AppTopBar — 계정 칸', () => {
expect(who.get('.top-bar__name').text()).toBe('김아라')
expect(who.get('.top-bar__email').text()).toBe('ara.kim@arasystem.it.kr')
})
it('폭 360 에서도 검색 칸이 바 밖으로 넘치지 않는다 — 메일 검색 칸은 바의 줄 칸이고, 칸 · 입력 칸 모두 최소 폭 0 이라 바 안쪽만큼 준다', async () => {
const router = createTestRouter()
await router.push('/mail/inbox')
await router.isReady()
const wrapper = mount(AppTopBar, {
props: { title: '메일' },
slots: { search: MailSearchBox },
global: { plugins: [pinia, router] },
})
// 감싸는 칸 없이 바로 바의 줄 칸이다 — 감싸는 칸이 있으면 그 칸의 최소 폭(입력 칸의 기본 폭)에 다시 묶인다
const children = [...wrapper.get('.top-bar').element.children].map((child) => child.className)
expect(children).toEqual(['top-bar__title', 'mail-search', 'top-bar__account'])
// 280 에서 시작해 줄 수 있고(flex 1 1 17.5rem), 입력 칸의 기본 폭에 묶이지 않는다(min-width 0) — 비활성 통합 검색 칸도 같다
expect(ruleBody(mailSearchBoxSource, '.mail-search')).toMatch(/flex:\s*1 1 17\.5rem;/)
const shrinking = [
[mailSearchBoxSource, '.mail-search'],
[mailSearchBoxSource, '.mail-search__input'],
[topBarSource, '.top-bar__search'],
[topBarSource, '.top-bar__search-input'],
] as const
for (const [source, selector] of shrinking) {
expect(ruleBody(source, selector), selector).toMatch(/min-width:\s*0;/)
}
})
})
+4 -1
View File
@@ -3,7 +3,8 @@
// 검색 칸은 모듈이 search 슬롯으로 넣는다(메일: "메일 검색" — MailSearchBox). 넣지 않은 모듈은 비활성 "통합 검색"(준비 중)이다.
// 좁은 폭(15단계 — 디자인 원본 ListDrag15 "4. 상단 바 · 휴대폰 폭 360"): [로그아웃] · 머리글자는 줄지도 꺾이지도 않고 이름 · 주소 칸만 준다 —
// 넘치면 끝을 "…"로 줄이고 전체는 마우스를 올리면 보인다(title). 화면 폭 480 이하에서는 주소를 감추고 이름만 둔다.
// 줄 수와 차례(모듈 이름 · 검색 칸 · 계정)는 그대로다 — 계정 칸을 첫 줄로 올리지 않는다(Tab 차례)
// 줄 수와 차례(모듈 이름 · 검색 칸 · 계정)는 그대로다 — 계정 칸을 첫 줄로 올리지 않는다(Tab 차례).
// 검색 칸(모듈의 search 슬롯 · 비활성 통합 검색)도 바 안쪽보다 넓어지지 않는다 — 폭 360 에서 바 안쪽 256 에 맞게 준다(min-width 0)
import { computed, ref } from 'vue'
import { useRouter } from 'vue-router'
@@ -119,6 +120,8 @@ async function onLogout() {
.top-bar__search {
flex: 1 1 17.5rem;
/* 좁은 폭에서는 바 안쪽에 맞게 준다 (메일 검색 칸 MailSearchBox 와 같다) */
min-width: 0;
max-width: 32.5rem;
display: flex;
align-items: center;
+1 -1
View File
@@ -9,7 +9,7 @@ import { useAuthStore } from '@/stores/auth.store'
import { useMailBlockStore } from '@/stores/mail-block.store'
import { useMailStore } from '@/stores/mail.store'
/** 차단의 결과 — 메일을 스팸메일함으로 옮겼으면 moved (읽기 화면이 삭제처럼 다음 메일로 넘어간다) */
/** 차단의 결과 — 메일을 스팸메일함으로 옮겼으면 moved (읽기 화면이 삭제처럼 다음 메일로 넘어간다 — 받은편지함 정리를 켰으면 목록으로) */
export interface SenderBlockOutcome {
blocked: boolean
moved: boolean
@@ -5,10 +5,10 @@ import { defineComponent, h, type PropType } from 'vue'
import { dragEvent, fakeDataTransfer } from '@/test-utils/drag'
import { useIgnoredWindowFileDrop, useWindowFileDrop } from './useWindowFileDrop'
// 15단계 — 메일 화면에 파일을 잘못 놓으면 브라우저가 그 파일을 열어 웹메일을 떠났다. 메일 화면(MailLayout)은 파일 놓기를 무시하고,
// 쓰기 화면(useWindowFileDrop — 같은 메일 화면 안)이 떠 있으면 그 화면이 지금처럼 첨부로 받는다
// 15단계 — 화면에 파일을 잘못 놓으면 브라우저가 그 파일을 열어 웹메일을 떠났다. 앱 틀(AppShell — 메일 · 주소록 · 설정)은 파일 놓기를 무시하고,
// 받는 화면(useWindowFileDrop — 같은 앱 틀 안의 쓰기 화면 · 서명 설정)이 떠 있으면 그 화면이 지금처럼 받는다 (앱 틀 그대로는 AppShell.spec)
/** 메일 화면(파일 놓기 무시) — receive 를 주면 쓰기 화면(파일을 첨부로 받는다) */
/** 앱 틀(파일 놓기 무시) — receive 를 주면 받는 화면(쓰기 화면처럼 파일을 받는다) */
const Screen = defineComponent({
props: {
receive: { type: Function as PropType<((files: File[]) => void) | null>, default: null },
@@ -20,7 +20,7 @@ const Screen = defineComponent({
},
})
describe('useIgnoredWindowFileDrop — 메일 화면의 파일 놓기 무시', () => {
describe('useIgnoredWindowFileDrop — 앱 틀의 파일 놓기 무시', () => {
const mounted: VueWrapper[] = []
afterEach(() => {
@@ -4,8 +4,8 @@ import { isFileDrag } from '@/shared/utils/editor-files'
/**
* 지금 떠 있는 파일 받는 곳의 수 (쓰기 화면 · 서명 설정 — useWindowFileDrop).
* 메일 화면의 "파일 놓기 무시"(useIgnoredWindowFileDrop)는 받는 곳이 없을 때만 막는다 — 쓰기 화면도 메일 화면 안이라
* 둘의 창 처리기가 함께 걸린다. 어느 쪽이 먼저 걸렸는지와 상관없이 쓰기 화면의 첨부 놓기가 이기게 (15단계)
* 앱 틀의 "파일 놓기 무시"(useIgnoredWindowFileDrop — AppShell)는 받는 곳이 없을 때만 막는다 — 쓰기 화면 · 서명 설정도 앱 틀 안이라
* 둘의 창 처리기가 함께 걸린다. 어느 쪽이 먼저 걸렸는지와 상관없이 받는 화면이 이기게 (15단계)
*/
let activeReceivers = 0
@@ -67,10 +67,10 @@ export function useWindowFileDrop(onFiles: (files: File[]) => void) {
}
/**
* 이 화면이 떠 있는 동안 창에 놓은 파일을 받지 않고 버린다 (15단계 — 메일 화면 · MailLayout).
* 받지 않은 곳에 놓으면 브라우저가 그 파일을 열어 웹메일을 떠난다 — 메일 목록 · 읽기 화면에 파일을 잘못 놓아도 아무 일 없게.
* 이 화면이 떠 있는 동안 창에 놓은 파일을 받지 않고 버린다 (15단계 — 앱 틀 AppShell: 메일 · 주소록 · 설정 등 로그인 뒤 모든 모듈 화면).
* 받지 않은 곳에 놓으면 브라우저가 그 파일을 열어 웹메일을 떠난다 — 목록 · 읽기 화면 · 주소록 · 설정에 파일을 잘못 놓아도 아무 일 없게.
* - 놓는 동안 마우스 커서는 놓을 수 없는 모양이다(dropEffect none — 메일함 패널도 파일은 받지 않는다)
* - 파일을 받는 화면(쓰기 화면 — useWindowFileDrop)이 떠 있으면 아무것도 하지 않는다(그 화면의 첨부 놓기에 맡긴다)
* - 파일을 받는 화면(쓰기 화면 · 서명 설정 — useWindowFileDrop)이 떠 있으면 아무것도 하지 않는다(그 화면에 맡긴다)
* - 글 · 메일 끌기(목록 줄을 메일함 패널로 — useMailDrag)는 건드리지 않는다
*/
export function useIgnoredWindowFileDrop() {
+1 -5
View File
@@ -11,8 +11,7 @@
// (usePendingSendRefresh). 들어올 때 보내기 · 읽기 설정도 받아 둔다 — 쓰기 화면의 [보내기 취소] 알림이 설정한 초로 마감을 센다(못 받으면 알리지 않는다).
// 탭이 다시 보일 때 마지막으로 받은 지 60초가 지났으면 설정을 조용히 다시 받는다 — 다른 탭에서 바꾼 묶기가 남지 않게 (useMailSettingsRefresh, 14단계)
// 회사가 2단계 인증을 필수로 정한 유예 기간이면 상단 바 바로 아래에 노란 줄 (13단계 — TwoFactorGraceNotice)
// 메일 화면에 파일을 잘못 놓아도 아무 일 없게 한다(15단계 — 브라우저가 파일을 열어 웹메일을 떠나지 않게). 쓰기 화면은 지금처럼 어디에 놓아도 첨부로 받는다
// (useIgnoredWindowFileDrop — 받는 화면이 떠 있으면 그쪽에 맡긴다)
// 잘못 놓은 파일은 앱 틀(AppShell)이 무시한다 — 쓰기 화면은 지금처럼 어디에 놓아도 첨부로 받는다 (15단계 — useWindowFileDrop)
import { provide } from 'vue'
import AppShell from '@/components/shell/AppShell.vue'
@@ -27,7 +26,6 @@ import { useMailSettingsRefresh } from '@/composables/useMailSettingsRefresh'
import { useMailStatePolling } from '@/composables/useMailStatePolling'
import { useMailTabTitle } from '@/composables/useMailTabTitle'
import { usePendingSendRefresh } from '@/composables/usePendingSendRefresh'
import { useIgnoredWindowFileDrop } from '@/composables/useWindowFileDrop'
import { useMailBlockStore } from '@/stores/mail-block.store'
import { useMailSettingsStore } from '@/stores/mail-settings.store'
import { useMailSignatureStore } from '@/stores/mail-signature.store'
@@ -72,8 +70,6 @@ useMailStatePolling({
useMailTabTitle()
// 보낼 시각이 지난 붙잡은 발송 — 보이는 목록 · 열린 메일 · 예약됨 수를 다시 본다
usePendingSendRefresh({ clock })
// 목록 · 읽기 화면에 놓은 파일은 받지 않는다 (쓰기 화면이 떠 있으면 그 화면이 첨부로 받는다)
useIgnoredWindowFileDrop()
</script>
<template>
+1 -1
View File
@@ -8,7 +8,7 @@
* 그보다 좁으면 칸이 아래로 접히고 화면 전체가 스크롤된다.
* 목록 보기 표(MessageTable) — 68.6875rem(1099px) 이하에서 크기 칸 숨김, 37.4375rem(599px) 이하에서 칸 축소.
* 상단 바(AppTopBar) — 30rem(480px) 이하에서 계정 칸의 주소를 감추고 이름만 둔다 (15단계).
* 읽기 화면 도구 줄(MessageReader)은 화면 폭이 아니라 읽기 칸 폭(컨테이너 쿼리) — 35.625rem(570px, 휴지통은 37.1875rem 595px)보다 좁으면 아이콘만 (15단계).
* 읽기 화면 도구 줄(MessageReader)은 화면 폭이 아니라 읽기 칸 폭(컨테이너 쿼리) — 36.25rem(580px, 휴지통은 37.8125rem 605px)보다 좁으면 아이콘만 (15단계).
*/
:root {
/* ── 글꼴 ─────────────────────────────── */