Compare commits

..

11 Commits

Author SHA1 Message Date
ttipo b8a5114af9 docs: 로드맵에 휴대폰 앱 1.0.1 을 운영에 올린 것을 적는다
CI / Backend (lint · type-check · test · e2e · build) (pull_request) Successful in 12m54s
CI / Frontend (lint · type-check · test · build) (pull_request) Successful in 13m37s
CI / Infra (compose · Dockerfile · nginx · 운영 스크립트) (pull_request) Successful in 2m1s
CI / 에러 코드 SSOT 동기화 검사 (pull_request) Successful in 11m4s
CI / Backend (lint · type-check · test · e2e · build) (push) Successful in 14m2s
CI / Frontend (lint · type-check · test · build) (push) Successful in 13m3s
CI / Infra (compose · Dockerfile · nginx · 운영 스크립트) (push) Successful in 2m25s
CI / 에러 코드 SSOT 동기화 검사 (push) Successful in 11m19s
배포 뒤 고친 앱 1.0.1 을 11월 12일 올리고 실기기에서 확인했다. 「고치는 중」 줄을 운영 줄로 옮기고 앱 판을 1.0.1 로 고친다.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-12 12:17:25 +09:00
ttipo 198ac8f4f2 chore(flutter): 앱 판을 1.0.1 로 올린다
CI (Flutter) / Flutter (analyze · test) (pull_request) Successful in 22m14s
CI / Backend (lint · type-check · test · e2e · build) (pull_request) Successful in 13m2s
CI / Frontend (lint · type-check · test · build) (pull_request) Successful in 13m25s
CI / Infra (compose · Dockerfile · nginx · 운영 스크립트) (pull_request) Successful in 2m6s
CI / 에러 코드 SSOT 동기화 검사 (pull_request) Successful in 11m25s
CI (Flutter) / Flutter (analyze · test) (push) Successful in 22m26s
CI / Backend (lint · type-check · test · e2e · build) (push) Successful in 13m44s
CI / Frontend (lint · type-check · test · build) (push) Successful in 12m40s
CI / Infra (compose · Dockerfile · nginx · 운영 스크립트) (push) Successful in 2m11s
CI / 에러 코드 SSOT 동기화 검사 (push) Successful in 11m3s
읽기 화면의 빈 본문과 쓰기 · 검색 입력칸 고침을 직원 휴대폰에 내보낼 판이다.
최소 판(app-config 1.0.0)은 그대로라 1.0.0 을 쓰는 휴대폰도 막지 않는다.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-12 08:11:02 +09:00
ttipo b644e8b104 docs: 앱 읽기 화면의 빈 본문과 쓰기 화면 입력칸 규칙을 앱 디자인 README · flutter 스킬에 적는다
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-12 08:10:34 +09:00
ttipo bd916ccbed fix(flutter): 쓰기 · 검색 입력칸에 테마의 둥근 테두리가 그려지던 것을 고치고 칸 줄을 보드에 맞춘다
InputDecoration.collapsed 는 enabledBorder · focusedBorder 를 비워 두어 TextField 가
앱 테마의 둥근 테두리로 채운다. 그래서 받는 사람 · 제목 · 본문 · 검색 칸에 글자를
감싼 테두리가 그려지고 초점이면 칸 줄의 아래 선과 청록 테두리가 겹쳤다
(11-12 배포 뒤 사용자 보고). 여섯 테두리를 모두 없앤 borderlessInputDecoration 으로
바꾸고, lib 에 InputDecoration.collapsed 가 다시 들어오면 시험이 실패하게 한다.

함께 칸 줄을 보드 App1Compose 에 맞췄다: 칸 이름 15 · 간격 6 · 오른쪽 여백 4,
칩이 여러 줄이면 칸 이름과 [참조 펼치기]를 첫 칩 줄에, 안내 글 색 · 칩 ✕ 색,
본문 칸 최소 높이(원본 메일이 있으면 150 · 없으면 서명이 있어도 240).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-12 08:10:34 +09:00
ttipo 94ec659b78 fix(flutter): 본문 없이 첨부만 있는 메일에서 읽기 화면 아래가 비고 첨부가 잘리던 것을 고친다
읽기 화면은 머리(제목 · 보낸 사람 · 안내 · 첨부)가 남은 높이의 절반까지만 쓰고
나머지는 본문 WebView 였다. 본문이 비면 아래 절반이 빈 칸으로 남고 첨부가 많으면
위 절반 안에서 잘렸다(11-12 배포 뒤 사용자 보고).

정화한 본문에 보이는 것(글자 · 주소가 남은 그림 · hr · li · details · q)이 없으면
상태 bodyEmpty 로 본문 칸을 두지 않고 머리가 화면 전체를 한 스크롤로 쓴다.
판정은 정화와 같은 길에서 내고(큰 본문은 isolate), 아래 막대가 없는 예약 메일은
머리 스크롤에 시스템 아래 여백을 더한다. 본문이 있는 메일은 그대로다.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-12 08:10:34 +09:00
ttipo 487760bd9b docs: 휴대폰 앱 APK 크기와 apksigner 의 Java 를 바로 적는다
첫 release APK(1.0.0)는 기기 종류 셋(ABI)이 한 파일에 들어 64.4MB 였다 —
문서의 "판마다 30MB 남짓"을 고친다. 개발 PC 에 java 가 PATH 에 없으면
apksigner 가 결과 없이 끝나 서명 확인을 놓치므로 Android Studio 의 Java 를
먼저 잡는 줄을 flutter/README 사내 배포에 더한다.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-12 00:28:55 +09:00
ttipo 37ffcfca2c docs: 로드맵에 11월 12일 운영 배포를 적는다
14 · 15 · 16단계 · 앱 서버 · 업데이트 내역을 11-12 0시에 운영에 올렸고
휴대폰 앱 1.0.0 을 나눠 주기 시작했다. 배포 뒤 받은 의견(앱 읽기 화면 ·
쓰기 화면 입력칸)은 앱 1.0.1 로 고치는 중이다.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-12 00:28:54 +09:00
ttipo f4a85fda27 docs: 쓰기 화면 스크롤과 목록 고르기 칸 고침을 디자인 README · 스킬에 적는다
CI (Flutter) / Flutter (analyze · test) (pull_request) Successful in 23m1s
CI / Backend (lint · type-check · test · e2e · build) (pull_request) Successful in 14m22s
CI / Frontend (lint · type-check · test · build) (pull_request) Successful in 15m10s
CI / Infra (compose · Dockerfile · nginx · 운영 스크립트) (pull_request) Successful in 3m50s
CI / 에러 코드 SSOT 동기화 검사 (pull_request) Successful in 11m41s
CI (Flutter) / Flutter (analyze · test) (push) Successful in 22m57s
CI / Backend (lint · type-check · test · e2e · build) (push) Successful in 13m47s
CI / Frontend (lint · type-check · test · build) (push) Successful in 13m15s
CI / Infra (compose · Dockerfile · nginx · 운영 스크립트) (push) Successful in 1m51s
CI / 에러 코드 SSOT 동기화 검사 (push) Successful in 11m19s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-10 22:14:52 +09:00
ttipo 0ff1ec46af fix(frontend): 목록 보기에서 고르기 상자를 누르면 메일까지 열리던 것을 고친다
목록 보기의 줄은 줄 전체의 click 에서 event.target.closest('button') 로 단추를 가렸다. 상자 가운데(그림 rect)를 누르면 단추 처리기가 고르기를 바꾼 뒤
처리기 사이에 Vue 가 다시 그려 그 그림이 문서에서 떨어지고, 줄 처리기의 closest 가 비어 메일까지 열렸다(사용자 보고).

- 무엇을 눌렀는지는 사건이 지나간 길(composedPath — eventPathHas)로 가르고, 상자 · 별 그림은 pointer-events: none 이다
- 고르기 칸의 빈자리를 눌러도 고른다(Shift 는 범위 · Ctrl 은 하나씩), 별 칸의 빈자리는 아무 일도 없다. 대화로 묶은 목록의 줄도 같다

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-10 22:14:52 +09:00
ttipo f3586abcd7 fix(frontend): 받는 사람이 많으면 쓰기 화면의 [보내기]가 화면 밖으로 숨던 것을 고친다
받는 사람 · 참조 · 숨은참조가 여러 줄이 되면 편집 영역(최소 300)이 줄지 않아 쓰기 칸 전체가 화면을 넘쳤다. 바깥 스크롤과 본문 스크롤이 겹치고,
흐름 끝의 [보내기] 줄이 화면 밖으로 밀렸다(사용자 보고 — 전 직원을 숨은참조에 넣었을 때).

- [보내기] 줄은 스크롤하는 칸의 아래에 붙는다(sticky). 겹침 차례는 편집기 툴바 2 < [보내기] 줄 3 < 받는 사람 추천 목록 · 메뉴를 연 툴바 4
- 칩 · 입력 칸은 96px 을 넘으면 그 칸 안에서 스크롤한다(초점 · 칩 추가 · 입력 때 맨 아래로). 넓은 화면은 편집 영역이 200 까지 먼저 줄어 보통 높이에서 바깥 스크롤이 생기지 않는다
- 넓은 화면에서 숨긴 낭독 글이 앱 틀 밖에 놓여 문서까지 스크롤하던 것을 고치고(.compose position: relative), [보내기] 줄에 가린 칸으로 옮긴 초점은 가린 만큼 올린다(useFocusAboveFooter)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-10 22:14:52 +09:00
ttipo 6eba013416 docs(backend): 업데이트 내역 첫 글의 날짜를 배포 날(11월 12일)로 정한다
배포 일정이 11월 12일(목) 오전 1시로 정해져 파일 이름을 2026-11-12.md 로 바꾸고 날짜 메모 줄을 지웠다.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-10 20:35:24 +09:00
45 changed files with 2371 additions and 101 deletions
+2 -2
View File
@@ -106,7 +106,7 @@ sudo docker compose --env-file .env.production logs -f backend
`nginx-api-errors.conf`(이름 있는 오류 location — `@api_not_found` 404 `RES_001` 포함). 공개 화면 `/s/` 는 `no-referrer`(주소에 토큰) · `noindex` · `no-cache`.
- 받기 `GET /api/public-links/{token}/files/…` · `GET /api/messages/{id}/large-files/…` 는 SSE 처럼 버퍼링 · 압축을 끄고 `X-Accel-Buffering` 을 넘긴다
(기본값이면 받는 사람마다 1GB 까지 임시 파일에 받아 둔다). Range · If-Range 는 그대로 — 206 · 416 은 백엔드가 만든다.
**휴대폰 앱(APK) 받기 `GET /api/app-releases/android/latest/file`**(2026-11-08 — 계약 1절 "휴대폰 앱 받기", 포털 · 로그인한 직원, 판마다 30MB 남짓)도 같은 줄들이다 —
**휴대폰 앱(APK) 받기 `GET /api/app-releases/android/latest/file`**(2026-11-08 — 계약 1절 "휴대폰 앱 받기", 포털 · 로그인한 직원, 판마다 65MB 안팎 — 기기 종류 셋(ABI)이 한 파일에 든다)도 같은 줄들이다 —
머리글을 대용량 받기와 같은 도우미(`backend/src/shared/utils/download-headers.ts` — `X-Accel-Buffering: no`)가 만든다. 정보 `GET /api/app-releases/android/latest`(JSON)는 받기 location 에 들지 않고,
공개 내려받기 server 에서는 그 밖의 `/api` 처럼 404 `RES_001` 이다.
머리글 버퍼는 `proxy_buffer_size 16k` + `proxy_busy_buffers_size 16k`(둘이 함께 — 하나만이면 `nginx -t` 가 실패) — 한글 255자 이름이면 머리글이 3.5KB 라 기본 4KB 에 아슬아슬하다.
@@ -223,7 +223,7 @@ sudo docker compose --env-file .env.production logs -f backend
- 업로드 중 임시 디스크: 백엔드는 첨부를 `/tmp/arasystem-mail-uploads`(컨테이너 쓰기 계층)에, nginx 는 요청 본문을 자기 임시 폴더에 받는다.
필요한 공간은 동시 업로드 수 × 20MB 정도. backend 를 `read_only` 로 바꾸면 그 경로에 **tmpfs 가 아닌 볼륨**을 붙인다 (tmpfs 는 메모리 한도에 잡힌다).
- **업로드 파일은 named volume `<PROJECT_NAME>_uploads` 에 쌓인다** (`/api/files`, 메일 서명 그림은 그 안의 `mail-signatures/` — 서명 표와 함께 백업한다,
휴대폰 앱 APK 는 그 안의 `app-releases/` — 판마다 30MB 남짓 · 지우는 명령이 없어 올린 판이 모두 남는다 · 매일 백업에 든다). 호스트 디렉터리(`./data/uploads`)를 쓰지 않는 이유 —
휴대폰 앱 APK 는 그 안의 `app-releases/` — 판마다 65MB 안팎 · 지우는 명령이 없어 올린 판이 모두 남는다 · 매일 백업에 든다). 호스트 디렉터리(`./data/uploads`)를 쓰지 않는 이유 —
`sudo docker compose` 가 없는 디렉터리를 root 소유로 만들어 backend(node, UID 1000)가 쓰지 못한다. 새 named volume 은 이미지의
`/app/uploads`(node 소유) 소유자를 물려받는다 (2026-10-27 운영 이미지로 확인). 개발만 `./data/uploads` 바인드 마운트다.
백업은 매일 백업(`ops/` — 아래 "백업 · 감시")에 든다.
+7 -3
View File
@@ -25,9 +25,9 @@ description: Flutter 모바일 앱(flutter/) 개발 규칙 — Dart Sound Null S
| 앱 로그인 저장소 | `lib/features/auth/data/` | 계약 1절 "앱 로그인"(`/auth/app/login` · `refresh` · `logout`(리프레시 토큰 + Bearer) + Bearer `me`, 인터셉터 줄 밖의 세션 확인 `readSessionDirect`). 실패는 늘 `DioException`(`error` 에 `ApiException` — 응답 모양이 틀리면 `SYS_001`, `apiFailure()`). 모델은 손으로 쓴다(`AppLoginResult` · `AuthSessionInfo` · `SessionRestriction` — 모르는 제한은 `unknown`). 로그인 화면이 칸에 보이는 실패(`kLoginInlineCodes`)는 알리지 않는다 |
| **로그인 화면 (네 단계 — `App1Login`)** | `lib/features/auth/presentation/pages/`(login · login_code · password_change · two_factor_setup · restricted_login) · `widgets/`(`login_frame` · `login_fields` · `login_parts`) · 글 `core/constants/login_texts.dart`(제목은 `screen_titles.dart`) · `data/`(`two_factor_repository` · `support_contact_repository` · `authenticator_launcher`) | 노티파이어의 제한 단계 API(`attempt` · `passedCodeStep` · `heldLoginListenable` · `changePassword` · `enableTwoFactor` · `finishRestrictedStep` → `RestrictedStepResult { done, needsPassword, stale }`). 로그인 비밀번호는 두 제한 단계에서만 10분 쥔다(모르는 제한 · 제한이 풀리면 지움). 칸 · 상자에 보이는 코드만 알림을 끈다(`kLoginInlineCodes` · `kLoginCodeInlineCodes` · `kPasswordChangeInlineCodes` · `kTwoFactorSetupInlineCodes` — 앱 디자인 README 의 표), `AUTH_002` 는 스낵바 없이 로그인 화면 상자만. 2단계 키 · otpauth 주소는 그 화면 State 에만. **url_launcher**: `canLaunchUrl` 은 쓰지 않는다(debug 판이 주소째 로그를 남긴다) — `launchUrl(externalNonBrowserApplication)` 만, 실패 예외에 주소가 들어 있어 삼킨다, `<queries>` 는 필요 없다. 한글 줄바꿈은 `shared/widgets/keep_all_text.dart`(시험은 `findCopy`). 보안 설정 요청 한도 `kSecuritySettingTimeout` 30초 |
| **메일 목록 (`App1MailList`)** · 앱 틀 | `lib/features/mail/` — data(models · `mail_repository.dart`) · domain(`mailbox_tree` · `mail_list_merge` · `mail_bulk` — 순수 함수) · presentation(`pages/mail_list_page.dart` · providers · widgets), 틀 `core/router/app_shell.dart` · `shell_bar.dart`, `core/utils/`(`app_foreground` · `accessible_navigation` · `korean_format`), `shared/widgets/confirm_dialog.dart`, 시험 도구 `test/support/mail_fakes.dart` · `mail_harness.dart` | **틀은 Scaffold 가 아니다**(Column + 탭 막대 — 탭 안 화면마다 자기 Scaffold 를 둔다, 없으면 스낵바 · Material 조상이 없다). 탭 막대는 `MediaQuery.removePadding(removeTop)` 으로 감싸고, 화면이 숨기려면 `ShellBarHidden`(모듈마다 센다 — 보이는지는 TickerMode). Scaffold 는 넓은 단추 자리에 안전 영역을 넘기지 않으니 Scaffold 밖에서 읽어 넘긴다. 목록 상태는 메일함마다(`mailListProvider(String?)` autoDispose), 세션이 바뀌면 `mailAccountProvider` 로 처음부터. 알림은 두 갈래 — 단추 있는 `mailToastProvider`(한 번에 하나 · 화면 낭독기 켠 동안 닫지 않음) · 단추 없는 `mailNoticeSinkProvider`(전역 스낵바). 생명주기 안에서는 프로바이더를 바꾸지 않는다(그린 뒤 · 마이크로태스크), 다른 노티파이어 인스턴스를 든 노티파이어는 세션이 바뀐 뒤 다시 읽는다. 가짜 서버는 응답이 한 프레임 안에 오므로 "받는 중이 끝나는 순간"을 기다리는 위젯을 만들지 않는다 |
| **메일 읽기 (`App1Reader`)** | `lib/features/mail/` — domain(`mail_html`(두 번째 정화 · 중첩 비용 · CSP 문서 · 중계 그림 바꿔 넣기) · `mail_images` · `mail_links` · `compose_request` · `mail_body_notices`) · `data/`(`models/message_detail.dart` · `mail_link_launcher.dart`) · presentation(`pages/message_reader_page.dart` · `providers/message_reader_controller.dart` · `attachment_downloads.dart` · widgets `mail_body_view` · `reader_*`), 시험 도구 `test/support/fake_webview.dart`(`WebViewPlatform.instance`) · `file_fakes.dart` | **WebView 에는 `buildMailDocument` 의 문서만 넣는다**(CSP 를 맨 앞 · JS 끔 · 처음 문서 말고는 탐색을 막음 · 안드로이드는 파일 · content 접근 · 위치 · 섞인 내용 끔, 아래 틀(iframe)의 탐색은 판정을 거치지 않으니 막는 것은 정화(내용째 버림)와 CSP 다). 받은 HTML 은 서버가 정화했어도 `sanitizeMailHtml` 을 한 번 더 지난다 — package:html 은 `parse().body`(`parseFragment` 는 맨 위 형제가 많으면 제곱), 그 전에 `htmlNestingCost`(선형 · 파서보다 깊게 센다)로 한도를 보고, 바로 할지 isolate 로 보낼지는 `mailBodyBuildPlaceOf` 하나로 고른다. 중계 그림은 Dio(Bearer)로 받아 `data:` 로 넣고 토큰은 WebView 에 가지 않는다 — 문서에 싣는 양은 **나온 횟수까지** 센다(`resolveProxiedImages`). 응답은 그 요청이 바꾼 칸만 지금 요약에 덮는다(`_applyResponse`). 메일 id 마다 autoDispose 프로바이더(라우트 `ValueKey(id)`) · 받기 세대 · 본문 세대 · 세션 세대로 늦은 응답을 버린다. 목록에는 `MailChange(updated, removedIds)` 로 알린다. 쓰기 화면을 여는 값은 웹과 같은 쿼리(`replyTo` · `all` · `forward` · `draft` — `composeIntentOf`), mailto 로 채울 값은 go_router `extra` 의 `ComposePrefill` |
| **메일 읽기 (`App1Reader`)** | `lib/features/mail/` — domain(`mail_html`(두 번째 정화 · 중첩 비용 · CSP 문서 · 중계 그림 바꿔 넣기) · `mail_images` · `mail_links` · `compose_request` · `mail_body_notices`) · `data/`(`models/message_detail.dart` · `mail_link_launcher.dart`) · presentation(`pages/message_reader_page.dart` · `providers/message_reader_controller.dart` · `attachment_downloads.dart` · widgets `mail_body_view` · `reader_*`), 시험 도구 `test/support/fake_webview.dart`(`WebViewPlatform.instance`) · `file_fakes.dart` | **WebView 에는 `buildMailDocument` 의 문서만 넣는다**(CSP 를 맨 앞 · JS 끔 · 처음 문서 말고는 탐색을 막음 · 안드로이드는 파일 · content 접근 · 위치 · 섞인 내용 끔, 아래 틀(iframe)의 탐색은 판정을 거치지 않으니 막는 것은 정화(내용째 버림)와 CSP 다). **보이는 것이 없는 본문이면 본문 칸(WebView)을 두지 않는다**(2026-11-12 — 첨부만 있는 메일에서 아래 절반이 빈 칸으로 남고 첨부가 위 절반에서 잘렸다): 판정은 정화 길이 함께 낸다(`SanitizedMailBody.hasVisibleContent` — 남긴 글 가운데 `core/utils/visible_text.dart` 의 `hasVisibleText`(공백 · 제어 문자 · Default_Ignorable(제로폭 · 방향 표시 · 한글 채움 등) · 점자 빈칸은 보이지 않음)인 글, 주소가 남은 그림(`_attributesOf` 가 src 를 맨 앞에 쓰는 모양에 기댄다 — 외부 그림은 받기 전에도), 글 없이 그려지는 `hr` · `li` · `details` · `q`. 꾸밈(style · bgcolor · border)만은 치지 않는다. 고르는 본문(`displayedHtmlBody`)은 그대로) → 상태 `bodyEmpty` → 머리의 최대 높이가 화면 전체(`kReaderHeadMaxFraction` 대신 — 그림 받는 진행 막대 자리만 뺀다 · 0 아래로 내려가지 않게), 아래 막대가 없는 메일(예약)은 머리 스크롤에 시스템 아래 여백을 더한다. 문서를 만들기 전(`document == null`)은 지금처럼 본문 자리의 진행 줄. 받은 HTML 은 서버가 정화했어도 `sanitizeMailHtml` 을 한 번 더 지난다 — package:html 은 `parse().body`(`parseFragment` 는 맨 위 형제가 많으면 제곱), 그 전에 `htmlNestingCost`(선형 · 파서보다 깊게 센다)로 한도를 보고, 바로 할지 isolate 로 보낼지는 `mailBodyBuildPlaceOf` 하나로 고른다. 중계 그림은 Dio(Bearer)로 받아 `data:` 로 넣고 토큰은 WebView 에 가지 않는다 — 문서에 싣는 양은 **나온 횟수까지** 센다(`resolveProxiedImages`). 응답은 그 요청이 바꾼 칸만 지금 요약에 덮는다(`_applyResponse`). 메일 id 마다 autoDispose 프로바이더(라우트 `ValueKey(id)`) · 받기 세대 · 본문 세대 · 세션 세대로 늦은 응답을 버린다. 목록에는 `MailChange(updated, removedIds)` 로 알린다. 쓰기 화면을 여는 값은 웹과 같은 쿼리(`replyTo` · `all` · `forward` · `draft` — `composeIntentOf`), mailto 로 채울 값은 go_router `extra` 의 `ComposePrefill` |
| 받은 파일 · 고른 파일 · 형식 | `lib/core/files/` — `downloaded_files.dart`(`DownloadedFileStore` · `CacheDirFileStore` · `FileOpener`) · `picked_files.dart`(`PickedFileStore` · `PlatformPickedFileStore` — 채널의 `pick`) · `file_names.dart`(`safeFileName` · `displayFileName`) · `file_types.dart`(`fileOpenTypeOf` · `isBlockedFileOpen`), 안드로이드 `MainActivity.kt`(채널 `kr.it.arasystem.app/files`) · `res/xml/downloaded_file_paths.xml` · 매니페스트의 FileProvider `${applicationId}.downloads` | 모듈 공통(메일 첨부 · 나중의 파일 모듈). 자리는 `cacheDir/downloads/<무작위 16자>/<정리한 이름>` 뿐 — **짝 셋**: Dart `kDownloadedFilesFolder` · `downloaded_file_paths.xml` 의 cache-path · `MainActivity.kt` 의 `DOWNLOADS_DIR`(채널 이름도 짝). Kotlin 은 canonical 경로로 그 폴더 안인지 다시 보고 읽기 권한을 한 번만 준다. 파일 여는 플러그인(open_filex 등)은 쓰지 않는다 — 저장소 권한 · 기기 전체 경로를 더한다. 열 형식은 확장자 먼저(`package:mime`), 보낸 쪽 형식은 확장자를 모를 때만 — 형식 글 모양 검사는 짝: Kotlin `MIME_PATTERN` · `GENERIC_TYPE`. 막는 형식(웹 문서 · svg · 설치 파일 · 인증서)은 받지도 않는다. **고른 파일**(메일 쓰기 2차): 고르는 창(`ACTION_GET_CONTENT` — 권한 없음 · `*/*` · 여러 개)으로 고른 파일을 `cacheDir/uploads/<무작위 16자>/<정리한 이름>` 에 복사본으로 둔다 — **짝 둘**: Dart `kPickedFilesFolder` · `MainActivity.kt` 의 `UPLOADS_DIR`(다른 앱에 건네지 않아 FileProvider xml 에는 없다). 채널 인자 · 답의 칸 · 값도 짝이다 — Dart `PlatformPickedFileStore.pick` · `PickResult.parse` · `PickedFile.fromChannel` ↔ Kotlin `ARG_*` · `KEY_*` · `OUTCOME_*` · `STATUS_*`. Kotlin 은 `content://` 만 읽고, Dart 사전 검사와 같은 차례(개수 → 형식 → 파일당 → 합계 — `copyPicked` ↔ `precheckPickedFile`)로 넣을 파일만 복사한다 — 미리 거르기일 뿐 판정은 Dart 가 다시 한다. 확장자를 떼는 규칙도 짝(Kotlin `displayFileName` · `extensionOf` · `isDartWhitespace` ↔ Dart `displayFileName` · `extensionOf` · `trim()`). 복사본 이름은 Kotlin `safeFileName`(Dart 와 같은 규칙), 보이고 보내는 이름은 `displayFileName` 이다. 복사 스레드는 Throwable 까지 잡아 한 번만 답한다. 고르기와 모두 지우기는 차례로 돌고, 지우기는 기호 링크를 푼 경로로 복사본 폴더 바로 아래 한 칸만 지운다. `pick` 은 그 차례 안에서 `kPickTimeout`(10분) 시한을 둔다 — 넘으면 `timedOut`(Dart 에만 있는 결과)으로 끝내고 차례를 넘긴다(늦게 온 복사본은 다음 정리에). 파일 고르는 플러그인은 쓰지 않는다(권한 · 기기 전체 경로) |
| **메일 쓰기 (`App1Compose` — 1차 · 2차)** | `lib/core/people/`(사람 고르기 — 주소 칩 · 추천 맞추기 · 그룹, 모듈 공통. 표본 표 `test/core/people/recipient_match_samples.dart` 는 웹 · 백엔드와 **세 쪽 짝**) · `features/mail/domain/`(`compose_body` 직렬화 · `compose_fit`(`buildInitialQuote`) · `mail_quote` · `compose_draft_body`(`buildDraftBody` · `draftBodyPlaceOf`) · `compose_reply` · `compose_send` · `compose_attachments`(2차 — 형식 표 · 한도 · 사전 검사) · `compose_signature` · `html_nodes`) · `data/`(`compose_models` · `compose_payload` · `repositories/compose_repository.dart`) · presentation(`pages/message_compose_page.dart` · `providers/compose_controller.dart`(화면마다 하나 — `ComposeArgs.instance`, autoDispose) · `draft_autosave.dart` · `attachment_uploads.dart`(`AttachmentUploadQueue` — 둘씩 · 취소 · 다시 올리기) · `signature_image_provider.dart` · widgets `compose_*`) · `core/utils/utf16_length.dart` · 시험 도구 `test/support/compose_fakes.dart` | 본문 모양은 계약 2절 "앱이 만드는 모양". 웹 짝(`mail-address.ts` · `recipient-match.ts` · `MAIL_HTML_STYLES` · `docToText` · `fit-draft` · `DRAFT_*` · `shouldRenewSendKey`)은 각 파일 머리말에 적었다 — 한쪽을 고치면 둘 다. **package:html 의 `text` · `querySelectorAll` · `innerHtml` · `outerHtml` 은 깊이만큼 되풀이한다** — 쓰기 화면은 `html_nodes.dart`(되풀이 없는 글 모으기 · 찾기 · 직렬화)를 쓰고, 저장본 · 원문은 읽기 화면과 같은 한도 · 문턱(`mailBodyBuildPlaceOf`)으로 그 자리 · isolate · 읽지 않음을 고른다. **화면을 떠나도 끝까지 가야 하는 일(보내기)은 기다림을 시작하기 전에 `ref.keepAlive()` 를 잡고 try/finally 로 놓는다**(2026-11-07 리뷰 — 저장을 기다리는 사이 내려가 메일이 나가지 않았다). 임시 저장은 Timer 만 쓴다(시험은 `tester.pump` 로 시간을 넘긴다). 보내기 결과는 `mailToastProvider` 의 마감 알림(목록 · 읽기 화면이 그린다). 서버와 같은 한도를 거는 칸은 `Utf16LengthLimitFormatter`(UTF-16 단위). **첨부(2차)**: 형식 표 `kMailAttachmentTypes` 는 **짝: 백엔드 `attachment.policy.ts` 의 `ATTACHMENT_TYPES`** — 시험이 그 파일을 읽어 표와 `MAX_ATTACHMENTS_PER_MESSAGE` 를 맞춰 본다 (앱 CI 는 flutter/ 가 바뀐 커밋에서만 돈다 — 백엔드 표만 고친 커밋에서는 이 시험이 돌지 않으니 함께 고친다). 사전 검사는 웹 `precheckAttachment` 의 차례, 보내기 막기는 웹 `sendBlockOf` 의 차례(가져오는 중은 올리는 중 다음). 저장소 `uploadAttachment`(multipart `file` · 확장자의 정식 형식 · 보내기 5분 · 받기 60초 · 알림 끔 — 401 뒤에는 FormData 를 복제해 복사본을 처음부터 다시 읽는다) · `getAttachmentLimits`(`GET /api/mail-quota`). 다 올리면 서버의 이름 · 크기로 싣는다(이름 255자 — 웹과 같다, 2026-11-07 리뷰). `MAIL_004` 뒤 다시 올릴 수 없는 고른 파일은 `problem: missing` 으로 막는다(웹 `markMissing`). 시험 도구 `FakePickedFiles`(`test/support/file_fakes.dart`) · `FakeComposeBackend` 의 `uploads` · `uploadGates` · `quota` · `quotaGate` · `FakeServer.readRequestBodies` |
| **메일 쓰기 (`App1Compose` — 1차 · 2차)** | `lib/core/people/`(사람 고르기 — 주소 칩 · 추천 맞추기 · 그룹, 모듈 공통. 표본 표 `test/core/people/recipient_match_samples.dart` 는 웹 · 백엔드와 **세 쪽 짝**) · `features/mail/domain/`(`compose_body` 직렬화 · `compose_fit`(`buildInitialQuote`) · `mail_quote` · `compose_draft_body`(`buildDraftBody` · `draftBodyPlaceOf`) · `compose_reply` · `compose_send` · `compose_attachments`(2차 — 형식 표 · 한도 · 사전 검사) · `compose_signature` · `html_nodes`) · `data/`(`compose_models` · `compose_payload` · `repositories/compose_repository.dart`) · presentation(`pages/message_compose_page.dart` · `providers/compose_controller.dart`(화면마다 하나 — `ComposeArgs.instance`, autoDispose) · `draft_autosave.dart` · `attachment_uploads.dart`(`AttachmentUploadQueue` — 둘씩 · 취소 · 다시 올리기) · `signature_image_provider.dart` · widgets `compose_*`) · `core/utils/utf16_length.dart` · 시험 도구 `test/support/compose_fakes.dart` | 본문 모양은 계약 2절 "앱이 만드는 모양". 웹 짝(`mail-address.ts` · `recipient-match.ts` · `MAIL_HTML_STYLES` · `docToText` · `fit-draft` · `DRAFT_*` · `shouldRenewSendKey`)은 각 파일 머리말에 적었다 — 한쪽을 고치면 둘 다. **package:html 의 `text` · `querySelectorAll` · `innerHtml` · `outerHtml` 은 깊이만큼 되풀이한다** — 쓰기 화면은 `html_nodes.dart`(되풀이 없는 글 모으기 · 찾기 · 직렬화)를 쓰고, 저장본 · 원문은 읽기 화면과 같은 한도 · 문턱(`mailBodyBuildPlaceOf`)으로 그 자리 · isolate · 읽지 않음을 고른다. **화면을 떠나도 끝까지 가야 하는 일(보내기)은 기다림을 시작하기 전에 `ref.keepAlive()` 를 잡고 try/finally 로 놓는다**(2026-11-07 리뷰 — 저장을 기다리는 사이 내려가 메일이 나가지 않았다). 임시 저장은 Timer 만 쓴다(시험은 `tester.pump` 로 시간을 넘긴다). 보내기 결과는 `mailToastProvider` 의 마감 알림(목록 · 읽기 화면이 그린다). 서버와 같은 한도를 거는 칸은 `Utf16LengthLimitFormatter`(UTF-16 단위). **첨부(2차)**: 형식 표 `kMailAttachmentTypes` 는 **짝: 백엔드 `attachment.policy.ts` 의 `ATTACHMENT_TYPES`** — 시험이 그 파일을 읽어 표와 `MAX_ATTACHMENTS_PER_MESSAGE` 를 맞춰 본다 (앱 CI 는 flutter/ 가 바뀐 커밋에서만 돈다 — 백엔드 표만 고친 커밋에서는 이 시험이 돌지 않으니 함께 고친다). 사전 검사는 웹 `precheckAttachment` 의 차례, 보내기 막기는 웹 `sendBlockOf` 의 차례(가져오는 중은 올리는 중 다음). 저장소 `uploadAttachment`(multipart `file` · 확장자의 정식 형식 · 보내기 5분 · 받기 60초 · 알림 끔 — 401 뒤에는 FormData 를 복제해 복사본을 처음부터 다시 읽는다) · `getAttachmentLimits`(`GET /api/mail-quota`). 다 올리면 서버의 이름 · 크기로 싣는다(이름 255자 — 웹과 같다, 2026-11-07 리뷰). `MAIL_004` 뒤 다시 올릴 수 없는 고른 파일은 `problem: missing` 으로 막는다(웹 `markMissing`). 시험 도구 `FakePickedFiles`(`test/support/file_fakes.dart`) · `FakeComposeBackend` 의 `uploads` · `uploadGates` · `quota` · `quotaGate` · `FakeServer.readRequestBodies`. **칸 줄**은 `ComposeFieldRow`(`widgets/compose_recipient_field.dart`) 하나 — 칸 이름 72 · 글자 15 · 간격 6 · 여백 4 4 4 16, 칸 이름 · 오른쪽 단추는 첫 줄(`ComposeFieldRow.lineHeight` 48)의 가운데(받는 사람은 `firstLineHeight` 로 칩 줄 40 을 넘겨 칩이 여러 줄이어도 첫 칩 줄에 맞춘다), 아래 선은 `foregroundDecoration` 이라 초점으로 굵어져도 칸이 움직이지 않는다. 입력칸은 `borderlessInputDecoration`(2026-11-12 — 아래 함정). 본문 칸 최소 높이는 `composeBodyMinHeightOf`(원본 메일이 있으면 150 · 없으면 240 — 서명과 상관없다, 보드 App1Compose) |
| **메일 검색 (`App1Search` — 1차 · `App1SearchConditions` — 2차)** | domain `search_text`(**짝: 웹 `search-match.ts` · `mail-search.ts` · 백엔드 `search-text.ts`** — 표본 표는 시험이 `backend/test/search-text-samples.ts` 의 FOLD · GRAM 을 직접 읽는다 · `test/support/ts_table.dart`) · `search_highlight`(짝: 웹 `search-highlight.ts`) · `search_conditions`(2차 — **짝: 웹 `mail-search.ts` · `mail-vacation.ts`** — 조건 다듬기 · 합친 낱말 수 · 기간 · after/before(기기 시간대 0시 → UTC, 끝 날짜는 다음 날 0시) · 강조 쿼리 · "나") · `data/models/mail_search.dart` · `mail_repository`(`searchMessages` — 응답의 `Date` 로 서버 시각 · `searchIndexStatus` · `getMessage(highlight)`) · presentation(`pages/mail_search_page` · `providers/mail_search_controller`(화면마다 — `MailSearchArgs.instance`, autoDispose) · `mail_search_state`(`waitsForMore`) · `mail_search_view` · widgets `mail_search_bar`(+ 공용 `LineBreaksToSpacesFormatter`) · `mail_search_states` · `highlighted_text` · `mail_search_conditions_sheet`(2차)) · `core/utils/js_case.dart` | 탭 안 `/mail/search`(시작한 메일함은 `extra` 의 `MailSearchStart`, 검색어는 주소에 싣지 않는다). 이어 찾기는 한 차례 `kMailSearchFillMaxRequests`(5) · 20통, 모자라면 [더 찾기]로 멈춘다. 검색 세대 + `CancelToken`(새 검색 · 화면 닫기에 끊는다), 준비 상태는 따로 세대. 결과는 `MailChange` 와 붙잡힌 줄 다시 받기(보낼 시각 + 2초)만 반영한다. **강조 위치는 그 응답의 글에만**. 읽기 키는 `MessageReaderArgs(id, highlight)`, 결과에서 연 메일은 `MailSearchOpen`. 조건(2차)은 화면 상태 `MailSearchState.conditions` 로 이어 찾기 · 새로고침 · 다시 시도 · 찾을 곳 바꾸기에 이어지고, "나"는 `mailSelfProvider`. 시트 가림막은 테마의 바닥 시트 가림막(`AppColors.backdrop` — 옮기기 시트도), 날짜 창은 `showDatePicker(barrierColor:)` · 머리 22(`datePickerTheme`). 노랑 `AppColors.searchMark` 는 **짝: 웹 `--color-search-mark` · `mail_html.dart` 의 mark**. 시험 도구는 `FakeMailBackend` 의 `defaultSearch`(받은 시각 내림차순 · `searchCursorAfter` 는 그 줄 다음부터) · `move` · `searchResponder` · `searchGate` · `searchHighlights` · `highlightDetails` · `searchIndex` |
| HTTP 클라이언트 | `lib/core/api/dio_client.dart` · `dio_provider.dart` | Dio 둘: 화면용(인터셉터 줄 `ResponseInterceptor` → `AuthInterceptor` → `ErrorNotifyInterceptor`)과 갱신 · 다시 보내기 · 로그인 없는 앱 설정용(언래핑만 — `publicDioProvider`. 인증 인터셉터는 요청을 한 줄로 세워 앞 요청의 미리 갱신을 기다리게 하므로, 앱을 띄우자마자 묻는 앱 설정은 줄 밖으로 보낸다). baseUrl=dotenv `API_URL`(`api_config.dart`), User-Agent `ARASYSTEM-App/<판> (Android <판>; <기종>)`(`user_agent.dart`). `LogInterceptor` 는 넣지 않는다(비밀번호 · 토큰). 시험은 `httpClientAdapterProvider` 로 가짜 서버를 끼운다 |
| 언래핑 · 코드 정규화 | `lib/core/api/response_interceptor.dart` · `api_error_mapper.dart` | `{success,data}→data`(스트림 · 바이트는 그대로 — 그 **실패** 본문은 64KB 까지 읽어 `error.code` 를 꺼낸다(`decodeRawErrorBody`). 스트림을 받는 도중의 실패는 인터셉터를 거치지 않아 부르는 쪽이 `SYS_001` 로 알린다(`DownloadInterrupted`). 비동기 처리기는 무슨 일이 있어도 `handler.next` 로 끝낸다 — 던지면 요청이 매달린다), 200 + `success:false` 도 실패, 실패는 모두 `DioException.error` 에 `ApiException`. 상태 → 코드 폴백(웹 `resolveCodeByStatus` 와 같다) |
@@ -36,7 +36,7 @@ description: Flutter 모바일 앱(flutter/) 개발 규칙 — Dart Sound Null S
| **인증 인터셉터** | `lib/core/api/auth_interceptor.dart` · `token_refresher.dart` | `QueuedInterceptor`. Bearer 붙이기 · 액세스 토큰이 없거나 30초 안에 끝나면 미리 갱신 · 401 이면 갱신 한 번 → 원래 요청 한 번 다시(로그인 · 갱신 · 로그아웃 제외 — `ApiPaths.noRefresh`). 갱신은 `TokenRefresher` 가 **한 번에 하나**(동시 401 은 토큰 세대를 보고 묶는다). 갱신 401 · 유예 응답(`tokens: null`) → 토큰 지움 + `AUTH_002` + 세션 끝, 5xx · 연결 끊김은 세션을 두고 그 코드로 알림, 갱신 중 로그아웃이면 받은 토큰을 버리고 서버 세션도 끝낸다. 갱신의 일시 장애는 같은 토큰 세대에서 5초 동안 다시 쓴다(`kRefreshFailureReuse` — 연결이 끊긴 동안 줄 선 요청마다 갱신하지 않게). `AUTH_010` 은 세션 노티파이어에 다시 맞춰 달라고 하고(`requestRecheck` — 안전 한도 65초) renewed 면 한 번 다시 보낸다 · restricted · stale 은 알리지 않는다 · unknown 은 알린다(웹 `recheckRestriction` 과 같은 결). 같은 토큰으로 줄 선 `AUTH_010` 은 한 번만 묻는다(그사이 새 토큰이면 묻지 않고 다시 보내고, restricted 는 그 세대 동안 · unknown 은 5초 다시 쓴다). **다시 보낸 요청의 실패도 첫 요청과 같은 규칙**(401 · `AUTH_010` 마다 한 번씩 — `kAuthRetriedExtraKey` · `kRestrictionRetriedExtraKey`). **요청을 보낸 세션이 끝났으면**(세션 세대 `kSessionGenerationExtraKey` 가 다르다 — 로그아웃 · 세션 끝 · 그 뒤 다른 직원 로그인) 어떤 실패든 조용히 끝낸다 — 401 은 사용자가 로그아웃했으면 `AUTH_001` 그대로, 세션이 끝났으면 `AUTH_002`. 실패를 내보내기 전에 Bearer · 비밀 본문을 지운다(`withoutSecrets`). 처리기는 뜻밖의 예외에도 꼭 부른다(`SYS_001`). 이 인터셉터의 오류 처리 안에서 보내는 요청(갱신 · 세션 확인 · 다시 보내기)은 모두 안쪽 Dio — 같은 줄로 보내면 서로 기다린다 |
| 오류 알림 | `lib/core/api/error_notifier.dart` | 전역 `ScaffoldMessenger` 키 하나로 스낵바(웹 `showErrorUI` 자리). 같은 코드는 3초 안에 다시 띄우지 않는다. 요청마다 끄기: `Options(extra: {kSilentExtraKey: true})` 또는 코드 목록(`ApiOptions.silentFor`) — `request_flags.dart`. **비밀이 든 요청**(비밀번호 · 코드 · 리프레시 토큰이 본문에)은 `ApiOptions.secret(...)` 으로 보낸다 — 실패 예외의 본문을 비워 던져 화면이 `AsyncValue` 로 상태에 두어도 된다 |
| 토큰 보관 | `lib/core/storage/` — `token_store.dart` · `secure_store.dart` · `first_run_marker.dart` | 리프레시 토큰만 `flutter_secure_storage`(키 상수 `kRefreshTokenKey`), 액세스 토큰은 메모리. 새 토큰은 받자마자 저장(로그인 저장이 실패하면 그 세션을 끝내고 `SYS_001`, 갱신 저장이 실패하면 이번 실행은 메모리의 새 토큰으로 이어 가고 다음 읽기 · 저장 때 다시 쓴다). 보안 저장소 일은 부른 차례대로 하나씩(로그인 저장과 로그아웃 지우기가 엇갈리지 않게). 토큰 세대 `generation` · 세션 세대 `sessionGeneration`(지울 때만 오른다), 로그아웃은 `signOut()`(메모리를 곧바로 비우고 서버 logout 에 쓸 토큰을 돌려준다). 만료는 기기 시계로 보지 않는다 — 응답의 `Date`(서버 시각, `core/api/server_time.dart`)로 남은 시간을 재고 받은 때부터 센다. 첫 실행 표시(`shared_preferences` — 참 · 거짓 하나)가 없으면 보안 저장소를 모두 지운다(iOS Keychain 은 앱을 지워도 남는다). Android 는 백업을 껐다(`AndroidManifest` · `res/xml/data_extraction_rules.xml`) |
| 테마 | `lib/core/theme/` | 색은 웹 `tokens.css` 를 `ColorScheme` 에 손으로 대응(`app_colors.dart` · `app_theme.dart` — 시드 생성 쓰지 않음, Material 에 없는 색은 `AppExtraColors`). 간격 · 모서리 `AppSpacing` · `AppRadius`, 누르는 곳 최소 48dp(`AppSize.touchMin`). 글자는 `app_typography.dart` 한 곳 — 화면 글 15 · 입력 칸 16 · 위 막대 제목 18 · 메일 본문 15/1.75 는 확정(`docs/design/app/README.md`), 그 밖의 역할은 임시 값 |
| 테마 | `lib/core/theme/` | 색은 웹 `tokens.css` 를 `ColorScheme` 에 손으로 대응(`app_colors.dart` · `app_theme.dart` — 시드 생성 쓰지 않음, Material 에 없는 색은 `AppExtraColors`). 간격 · 모서리 `AppSpacing` · `AppRadius`, 누르는 곳 최소 48dp(`AppSize.touchMin`). 글자는 `app_typography.dart` 한 곳 — 화면 글 15 · 입력 칸 16 · 위 막대 제목 18 · 메일 본문 15/1.75 는 확정(`docs/design/app/README.md`), 그 밖의 역할은 임시 값. 테두리 없는 입력칸(칸 줄이 아래 선 · 초점을 그리는 쓰기 화면 · 검색 막대)은 `borderlessInputDecoration` — 테마의 입력칸 테두리를 물려받지 않는다 |
| 글꼴 | `assets/fonts/` · `assets/licenses/pretendard-ofl.txt` · `core/theme/app_fonts.dart` | Pretendard 1.3.9 정적 OTF 넷(400 · 500 · 600 · 700 — 웹 npm `pretendard` 와 같은 파일). OFL 1.1 글을 `LicenseRegistry` 에 올린다(설정 › 앱 정보 › 오픈소스 라이선스) |
| **새 메일 알림 (`App1Push`)** | `lib/core/push/` — 포트 `push_messaging.dart`(FCM 은 `firebase_push_messaging.dart` — 띄우지 못하면 `UnavailablePushMessaging`) · `notification_system.dart`(채널 · 알림 지우기 · 휴대폰 설정 열기 — 안드로이드 `NewMailNotifications.kt`, 채널 `kr.it.arasystem.app/notifications`) · `push_preferences.dart`(이 휴대폰의 값 — `shared_preferences`, 참 · 거짓과 FID 하나뿐) · `push_state.dart`(상태 · 설정 줄 모습 `pushRowStatusOf` · 권하기 줄 `showsPushOffer`) · `push_controller.dart`(앱에 하나 — `PushRegistrar` 를 구현) · `push_registration.dart`(서버 저장소 · `pushRegistrarProvider`) · `push_events.dart`(앞에서 받음 · 누름 — 라우터 `core/router/push_navigation.dart` 가 누른 알림을 늘 메일 받은편지함으로) · `push_target.dart` · 글 `push_texts.dart`. 화면 `features/mail/presentation/widgets/push_offer_line.dart` · `features/settings/presentation/widgets/settings_notification_row.dart` | **보낼 조건** = 받을 수 있는 휴대폰 ∧ 서버가 보낼 수 있음(app-config `pushAvailable` — 모르면 보낼 수 있는 것으로) ∧ 메일을 쓸 수 있는 세션(세션 노티파이어의 `register` · 제한 단계는 `pause`) ∧ 이 휴대폰에서 켬 ∧ 권한 ∧ 새 메일 채널. 맞추기 `_reconcile` 하나가 PUT · DELETE 를 고른다. **PUT** 은 세션이 열릴 때 한 번 · FID 가 바뀔 때 · 켤 때만(앞으로 올 때마다가 아니다), **DELETE** 는 끌 때와 권한 · 채널이 꺼진 것을 알았을 때만 — 로그아웃 · 세션 끝은 FCM `unregister()` 만(서버 줄은 서버가 세션과 함께 지운다, 풀지 못하면 `pendingUnregister` 로 다음 실행 · 앞으로 옴에 다시). **봤음** `DELETE …/unseen` 은 앞으로 올 때 · 앞에서 받은 새 메일 알림 때, 30초 간격 + 끝 한 번 — 앱이 앞에 있을 때만 보내고, 기다리는 동안 뒤로 가면 그 자리에서 보낸다(끝까지 기다리면 그사이 온 메일까지 본 것으로 센다). 앞에서 받은 알림 뒤의 봤음은 받은 때 + 1.5초(`kPushSeenAfterMessageDelay`)보다 일찍 보내지 않는다 — 서버는 seen_at 의 초부터 같은 초를 세고 받은 시각은 초 단위라, 곧바로 보내면 열지 않은 그 메일이 다음 알림에 다시 세어진다(2026-11-08 실기기). 켜기 · 끄기는 서버가 답한 뒤에 바꾼다(실패는 그 코드의 사전 문구 — 등록 소식이 없으면 `SYS_001`). **켜기가 실패하면 이 휴대폰의 값을 끔으로 둔다**(처음 값 켬에서 허락하고 켰어도 — 스위치는 꺼진 채, 저절로 다시 하지 않는다). 서버 일은 멈춘(`pause`) 바로 그 차례에도 보내지 않는다(`_canReachServer` 가 컨트롤러의 세션 값도 본다). FCM `register` · `unregister` 는 우리가 한 줄로 세운다(플러그인이 스레드 풀에서 돈다), `onRegistered` 는 방송이라 `register` 전에 듣는다. **풀기는 기다리기 전에 등록 세대를 올리고 등록을 내려 둔다** — 풀기가 실패하거나 오래 걸려도 같은 실행의 다음 로그인이 그 뒤에 다시 등록하고(같은 FID 의 PUT 이 서버의 앞 직원 줄을 지운다), 풀기 앞의 등록은 끝나도 마친 것으로 치지 않으며 "풀지 못한 등록" 표시도 지우지 않는다. 풀기가 줄에 있는 동안 온 등록 소식은 쓰지 않는다. 앱이 앞에 있으면 FCM 이 알림을 그리지 않는다 — 앞에서 받은 새 메일 알림은 메일 목록이 바로 새 메일을 확인한다(`mail_state_watcher`). 알림 데이터는 **모듈 이름과 종류**(`{ module: 'mail', kind: 'new-mail' }` · `session-ended`). FID · 주소 · 토큰을 로그 · 디버그 출력에 남기지 않는다. 시험 도구 `test/support/push_fakes.dart`(가짜 FCM · 알림창 · 값 · 서버) · `push_screen_harness.dart`(앱 전체 + 진짜 컨트롤러 — `ProviderHarness.recordPush = false`). 다른 시험대는 기기 등록을 기록하는 가짜(`RecordingPushRegistrar`)이고 컨트롤러는 FCM 이 없어 조용하다 |
| **앱 설정 · 업데이트 화면** | `lib/core/app_config/`(`app_config.dart` — 판 비교 `AppVersion` · 모양, `app_config_repository.dart`, `app_config_controller.dart` — `appUpdateRequiredProvider` · `pushAvailableProvider`) · `core/router/session_redirect.dart`(`updateRequired` 가 세션보다 먼저) · `features/app_update/`(모듈이 아니다 — 화면 · 글) · `core/app_download/`([새 버전 받기] — 받기 화면 주소 `app_download_link.dart` · 런처 `AppDownloadLauncher` · 글, 업데이트 화면과 설정 › 앱 정보가 함께 쓴다) | `GET /api/app-config` 는 띄울 때 한 번 · 앞으로 올 때 마지막으로 받은 지 1시간이 지났으면(실패는 1분 간격으로 다시) — 알리지 않고 막지 않는다. 404 `RES_001` 은 옛 서버(최소 판 없음 · 알림 쓸 수 없음). 판은 숫자 1~3칸 · 칸마다 4자리까지(서버의 판 칸 `APP_VERSION_PATTERN` 과 같다 — `-` · `+` 뒤는 버린다), 이 앱의 판을 모르면 막지 않는다. 업데이트 화면으로 옮길 때 쓰기 화면은 마저 저장한다(`ComposeController.detach(saveRemaining: true)` — 화면이 밖에서 닫힌 때도). 앱은 스토어에 올리지 않고 사내에 직접 나눠 준다(2026-11-08 — 빌드 · 서명 · 올리기는 `flutter/README.md` "사내 배포"). [새 버전 받기]는 포털의 휴대폰 앱 받기 화면 `<API_URL 의 출처>/app`(`appDownloadPageUrlOf` — `/api` 를 뗀 출처 + 경로, 짝: 웹 `mobile-app.ts` 의 `MOBILE_APP_PATH`)을 바깥 브라우저로(`LaunchMode.externalApplication` — `canLaunchUrl` 은 쓰지 않는다) 연다. 열지 못하면 스킴 없는 주소를 담아 알린다(`openAppDownloadPage` — 두 화면이 같은 알림). 앱 설정 계약은 받는 곳 주소를 싣지 않는다 |
@@ -313,6 +313,10 @@ await tokenStore.clear(byUser: true); // 로그아웃 (세션 끝은 byUser
InkWell 에 길게 누르기 처리가 없으면 길게 눌렀다 뗄 때 onTap 이 돈다 — 열지 않을 줄은 길게 누르기를 받아 두고 아무것도 하지 않는다(검색 결과 줄).
두 줄 TextField 에도 Scrollable 이 있다 — 목록 스크롤은 ListView 아래에서 찾는다(`find.descendant`).
go_router 17 의 `context.pushReplacement` · `replace` 는 void 다(`push` 만 Future) — 바꿔 끼울 화면은 `pushReplacement`(새 page key — 앞 화면의 PageStorage 스크롤 자리를 물려받지 않는다, 업데이트 내역의 다른 글 줄).
짜임을 둘로 가르는 화면(읽기 화면의 본문 칸 있음 · 없음)은 두 짜임에 같은 자리의 같은 위젯을 두고 값(`ConstrainedBox` 의 maxHeight)만 바꾼다 — `Flexible` · `Expanded` 로 바꿔 끼우면 그 아래 State(펼친 받는 사람 등)가 새로 만들어진다. 시험은 위치로 본다(머리 스크롤 칸의 아래 끝 = 아래 막대의 위 끝).
`InputDecoration.collapsed` 는 테두리 없는 칸이 아니다 — `border` 만 없애고 `enabledBorder` · `focusedBorder` 등을 비워 두면 TextField 가 그 칸을 테마의 `inputDecorationTheme`(둥근 테두리 · 초점은 청록 2)로 채우고, InputDecorator 는 `border` 보다 그 칸들을 먼저 쓴다(2026-11-12 — 쓰기 화면의 칸마다 글자를 감싼 테두리). 칸 줄이 선을 따로 그리는 칸은 `borderlessInputDecoration`(`core/theme/app_theme.dart` — 여섯 테두리 모두 없음 · 여백 0)을 쓴다. `lib/` 에 `InputDecoration.collapsed(` 가 들어오면 시험(`test/core/theme/borderless_input_decoration_test.dart`)이 실패한다. 칸의 테두리는 `test/support/input_borders.dart` 의 `expectBorderlessInput`(평소 · 초점 · 꺼짐마다 InputDecorator 가 받은 꾸밈)으로 본다.
화면을 그림으로 견줄 때(임시 시험 — 저장소에 남기지 않는다): `setUpAll` 에서 `FontLoader('Pretendard')` 에 `assets/fonts/Pretendard-*.otf` 넷을, `FontLoader('MaterialIcons')` 에 SDK 의 `bin/cache/artifacts/material_fonts/materialicons-regular.otf` 를 올리고(그러지 않으면 글자가 네모), 맨 위 repaint boundary(RenderView)의 `OffsetLayer.toImage(paintBounds)` 를 `tester.runAsync` 안에서 PNG 로 쓴다. 화면 크기는 `MailAppHarness.pump(size:)`.
화면 아래 끝까지 닿는 스크롤(읽기 화면의 빈 본문 + 아래 막대 없는 예약 메일 등)은 `MediaQuery.paddingOf(context).bottom` 을 스크롤 끝 여백으로 더한다 — Scaffold 는 bottomNavigationBar 가 있을 때만 몸통의 아래 padding 을 0 으로 만든다. 시험은 `tester.view.padding = FakeViewPadding(bottom: 48 * 3)`(물리 픽셀 — 시험대는 3배) + `addTearDown(tester.view.resetPadding)`.
- [ ] **린트로 강제되는 항목**: `dynamic` 호출·`print`·`const` 누락·await 누락·작은따옴표·import 정렬 (`analysis_options.yaml`).
- [ ] **env**: `flutter/.env.example` → `.env` 복사 후 `API_URL` 설정 (Android 에뮬레이터는 `10.0.2.2`).
`.env` 는 `pubspec.yaml` 의 asset 이라 **없으면 `flutter analyze` 가 경고**한다.
+7 -4
View File
@@ -42,9 +42,10 @@ 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` 블록 노드): 보낼 때 · 저장할 때 빈 문단 뒤의 **속성 없는 `<div>`**(앱과 같은 경계 — 계약 2절 "앱이 만드는 모양")로 나가고, 다시 열 때 "맨 위 단계에서 빈 문단 바로 뒤의 속성 없는 div 이고 글이나 그림이 있다"로 되찾는다(원문 인용 안 · 꾸민 · 빈 div 는 아니다). 서명 자리는 하나 — 붙여 넣기로 하나 더 생기면 처음 것만 두고 푼다. **서명 자리 앞에는 늘 빈 줄** — 그 빈 줄에 쓰거나 지우면 다시 넣는다(같은 appendTransaction — 한글 조합 중(`composition` 표시)에는 미루고, 조합이 끝나면 플러그인 view 가 `compositionend` 뒤 ProseMirror 가 조합을 마무리한 다음 차례(50ms)에 표시만 단 빈 거래로 깨운다 — 그사이 새 조합이 시작됐으면 그 조합이 끝날 때. 화면에 붙인 편집기에서만 돈다 · 시험은 `editor.mount` + `CompositionEvent` + 가짜 setTimeout), 보내고 저장하는 모양은 `withSignatureBoundary` 로 한 번 더(`MailEditor` 의 `contentOf` · 저장한 뒤 견줄 `markSaved` 의 settled) — 리뷰 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` 블록 노드): 보낼 때 · 저장할 때 빈 문단 뒤의 **속성 없는 `<div>`**(앱과 같은 경계 — 계약 2절 "앱이 만드는 모양")로 나가고, 다시 열 때 "맨 위 단계에서 빈 문단 바로 뒤의 속성 없는 div 이고 글이나 그림이 있다"로 되찾는다(원문 인용 안 · 꾸민 · 빈 div 는 아니다). 서명 자리는 하나 — 붙여 넣기로 하나 더 생기면 처음 것만 두고 푼다. **서명 자리 앞에는 늘 빈 줄** — 그 빈 줄에 쓰거나 지우면 다시 넣는다(같은 appendTransaction — 한글 조합 중(`composition` 표시)에는 미루고, 조합이 끝나면 플러그인 view 가 `compositionend` 뒤 ProseMirror 가 조합을 마무리한 다음 차례(50ms)에 표시만 단 빈 거래로 깨운다 — 그사이 새 조합이 시작됐으면 그 조합이 끝날 때. 화면에 붙인 편집기에서만 돈다 · 시험은 `editor.mount` + `CompositionEvent` + 가짜 setTimeout), 보내고 저장하는 모양은 `withSignatureBoundary` 로 한 번 더(`MailEditor` 의 `contentOf` · 저장한 뒤 견줄 `markSaved` 의 settled) — 리뷰 M3. 툴바는 z-index 2 이고 메뉴 · 팝오버를 연 동안만 4(`editor-toolbar--popup-open` — 쓰기 화면 아래에 붙은 [보내기] 줄(3) 위로). 넓은 화면 편집 영역 최소 200 은 「쓰기 화면 스크롤」 행 |
| 임시 저장 | `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`(강조는 `<strong>` 조각 — v-html 없음). combobox 접근성(aria-activedescendant · listbox · 조합 중 Enter 무시), 완전한 주소면 미리 고르지 않는다 |
| 쓰기 화면 스크롤 (2026-11-10 사용자 보고) | `src/components/mail/ComposeFooter.vue`(아래에 붙는 [보내기] 줄) · `RecipientField.vue`(칩 · 입력 칸 안 스크롤) · `pages/mail/ComposePage.vue`(넓은 화면 편집 영역 · 위치) · `components/mail/editor/MailEditor.vue` · `EditorToolbar.vue`(`editor-toolbar--popup-open`) · `composables/useFocusAboveFooter.ts` · 토큰 `--size-recipient-chips-max`(96) · `--size-editor-body-min-fit`(200) · 시험 `pages/mail/ComposePage.scroll.spec.ts`(원본 style 읽기 + 초점) | 받는 사람이 많으면 바깥 · 본문 이중 스크롤에 [보내기] 줄이 화면 밖으로 숨었다. **[보내기] 줄은 스크롤하는 칸(넓은 화면 `.compose` · 좁은 화면 문서)의 아래에 붙는다**(`position: sticky; bottom: 0` + 흰 바탕 — 모양 그대로). **겹침 차례: 툴바 2 < [보내기] 줄 3 < 받는 사람 추천 목록 4 · 메뉴를 연 툴바 4**(`.editor-toolbar--popup-open` — 툴바는 줄 밑으로 지나가고 툴바의 메뉴는 줄 위에 뜬다). 줄의 메뉴([보내기 ▾] · [서명 ▾])는 위로 연다(`usePopupPlacement`). **받는 사람 · 참조 · 숨은참조의 칩 · 입력 칸(`.recipient-field__control`)은 96(+ 위 여백 4)을 넘으면 그 칸 안에서 스크롤** — 맨 위 칩 세 줄 · 맨 아래 칩 두 줄 + 입력 줄이 줄 사이에서 끊긴다(반쯤 잘린 칩 없음). 위 · 좌우 안쪽 여백 4 를 바깥 여백으로 당겨 자리 · 줄바꿈은 그대로 두고 칩 ✕ 의 초점 고리가 잘리지 않게(아래는 당기지 않는다 — 줄의 초점 선을 덮지 않게). 초점이 오거나 · 칩이 늘거나(`flush: 'post'`) · 글을 치면 칸을 맨 아래로, 칩 ✕ 는 `focus({ preventScroll: true })` 로 자리를 그대로(가운데 칩을 이어 지운다). 추천 목록은 그 칸 밖(`.recipient-field` 기준)에 뜬다. **넓은 화면(70rem 이상)은 편집 영역이 300 에서 200 까지 먼저 줄어** 보통 높이(1366×768 · 1440×900)에서는 쓰기 칸 전체가 화면 안에 든다(좁은 화면은 300 그대로). **`.compose` 는 `position: relative`** — 안의 `.visually-hidden`(파일 고르기 칸 · 단추 줄의 낭독 글)이 화면 전체를 기준으로 놓여 넓은 화면에서 문서까지 스크롤했다(168px). **[보내기] 줄에 가린 칸으로 온 초점은 올린다**(`useFocusAboveFooter` — form 의 `@focusin` · `@pointerdown.capture`: 다음 그림에 재어 가린 만큼(+8) 스크롤 상자를 올린다, 쓰기 칸의 줄은 `.compose-field` 전체, 누른 칸(1초 안 · 그 줄 안)은 그대로 — 누른 채 움직이면 글이 골라진다, WCAG 2.4.11). 시험: vitest 는 CSS 파일을 비운다(`tokens.css?raw` 도 빈 글) — 토큰 값은 브라우저로 보고 시험은 컴포넌트가 토큰을 쓰는지만 — 디자인 README "메일 쓰기 › 받는 사람이 많을 때 · 낮은 화면" |
| 받는 사람 자동 완성 | `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`(강조는 `<strong>` 조각 — 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단계" |
| 메일 스토어 구성 | `src/stores/mail.store.ts`(조립 · 공개 API) + `src/stores/mail/` — `mailboxes.ts` · `list.ts` · `selection.ts` · `message.ts` · `bulk.ts` · `mailbox-admin.ts`(4단계 메일함 관리) · `conversation.ts`(14단계 같은 대화 칸) | Pinia 스토어는 `useMailStore` 하나이고 기능별 setup 모듈을 조립한다(스토어를 여러 개로 나누지 않는다 — 서로 부르는 고리가 생긴다). 목록 출처는 `shared/utils/mail-list-source.ts`(메일함 · 검색 · 대화로 묶은 메일함 `conversations` — 14단계). **목록에 든 메일인지 볼 때는 `messages` 대신 줄 기준 도우미**(`rowCount` · `rowIndexOfMessage` · `rowMessageIdAt` · `isListedMessage` · `listedSummaries`)를 쓴다 — 대화 목록에서는 `messages` 가 비어 있다. 목록 요청은 출처가 바뀌면 `listAbort`(AbortController)로 끊는다(끊긴 요청은 알리지 않는다). **늦게 온 응답 버리기**: 목록 맞추기는 이 탭의 목록 변경 세대(`localGeneration` — 읽음 · 중요 · 빼기 · 옮기기마다 오른다)가 그사이 바뀌었으면 응답을 버리고 한 번 더 맞춘다. 여러 통 처리 · 한 통 삭제는 시작할 때의 출처 순번(`sourceSeq`)과 다르면 목록을 건드리지 않고 개수만 맞춘다 |
| 새 메일 확인 · 탭 제목 | `src/composables/useMailStatePolling.ts`(메일 화면 `MailLayout` 이 시작) · `useListArrivalNotice.ts` + `components/mail/MailNewNotice.vue` · `useMailTabTitle.ts` + `shared/utils/document-title.ts` | 화면이 보이는 동안 60초마다 `GET /mail-state`, 탭으로 돌아오면 10초 지났을 때 바로, 한 번에 하나, 실패는 `silent`. 바뀌면 `syncCounts()` + 보이는 목록 맞추기(불러온 만큼 · 최대 100통, 검색 결과는 건너뜀). 목록을 내려 본 채면 "새 메일 N통 · 맨 위로" 줄. 받은편지함 안 읽음이 있으면 탭 제목 앞 `(N) ` (메일 라우트에서만) — 계약 2절 "새 메일 확인" |
@@ -55,7 +56,7 @@ description: Vue 3 프론트엔드(frontend/) 개발 규칙 — Composition API
| 메일 검색 — 14단계 (조건 · 강조 · 준비 중) | `src/components/mail/MailSearchConditions.vue`(조건 패널 — 칸 `MailSearchConditionFields.vue` · 기간 `MailSearchPeriodField.vue` · [조건] `MailSearchConditionsToggle.vue`, 고치는 값 `composables/useSearchConditionsDraft.ts` · 열고 닫기 `useSearchConditionsPopup.ts`) · `MailSearchConditionLine.vue`(조건 줄) · `MailSearchNotices.vue`(준비 중 · 한 글자 안내) · `MailSearchMore.vue`([더 찾기]) · `HighlightedText.vue` · `shared/utils/mail-search.ts`(조건 ↔ 주소 ↔ API · 기간 · 조건 줄 글) · `search-highlight.ts` · `search-match.ts` · `shared/constants/mail-search.ts` | 조건 이름 · 화면 주소(`since` · `until` 은 브라우저 시간대 날짜 · 양 끝 포함, 첨부 `attachment=1`) · 이어 찾기(`MAIL_SEARCH_FILL_MAX_REQUESTS` 5번)는 계약 2절 "검색 — 14단계"의 클라이언트 줄. **`search-match.ts` 는 backend `shared/utils/search-query.ts` · `search-text.ts` · `subject-base.ts` 와 짝** — 같은 표본 표 `test-utils/search-text-samples.ts`(backend `test/search-text-samples.ts` 와 행 · 차례까지 같다, 보이지 않는 글자는 `\u` 로 적는다 — 한쪽을 고치면 둘 다). 맞은 말은 `HighlightedText` 가 글 조각 + `<mark>` 로 그린다(v-html 없음 — 위치가 글 밖 · 거꾸로 · 정수가 아니면 버리고, 서로게이트 쌍을 가르지 않는다). **강조가 든 응답은 보이기만 한다** — `openMessage(id, highlight)` 는 강조 쿼리가 다르면 다시 받고, 답장 · 전달 · 인쇄 · 예약 · 대용량 첨부 다시 받기는 강조 쿼리 없이 받는다. 손으로 고친 주소도 패널과 같은 검사(8낱말 넘으면 묻지 않고 "찾을 말이 너무 많습니다"). 강조 노랑은 `--color-search-mark` — 본문 틀(`mail-html.ts`)의 같은 값과 짝. 미리 보기 줄(회색 글) 안의 강조만 글자를 `--color-text-secondary` 로 진하게(노랑 위 대비 — `MessageListItem.vue`). 패널의 [조건 지우기]는 칸만 비운다 — 패널은 열린 채 · 찾지 않음, 걸린 조건은 [찾기] · 조건 줄의 [조건 지우기]가 뺀다(2026-11-07) |
| 같은 대화 (14단계) | `src/components/mail/MessageConversation.vue`(읽기 화면 첨부 아래 칸) · `ConversationMessageRow.vue`(줄) · `ConversationMessagePanel.vue`(펼친 안) · `stores/mail/conversation.ts` · `composables/useConversationSync.ts`(언제 받을지 — 시계 주입) · `shared/utils/mail-conversation.ts`(이름 자리 · 쪽 맞추기 · 갈 곳) · `shared/constants/mail-conversation.ts` · `shared/utils/format.ts` 의 `formatConversationTime` | 받기 · 다시 받기는 계약 2절 "대화 — 14단계"의 클라이언트 줄(100통 · 이어 묻기 5번 · [더 보기], 실패는 칸 안 안내 — 404 면 칸 없음). **다시 받기 판정은 `mail.store` 의 `mailboxTotalsKey`**(메일함마다 `id:totalCount` — 바뀔 때만 `requestConversationRefresh()`, MailLayout 의 바뀜 소식에서는 부르지 않는다 — 읽음 · 중요 표시만 바뀐 소식으로 대화를 다시 받지 않게 — 칸의 안 읽은 줄은 목록처럼 굵게 그리고, 이 탭이 바꾼 읽음은 `list.ts` 의 `watchLocalReads` 로 바로 적는다: 받는 동안 바꾼 것은 `conversation.ts` 의 `withLocalReads` 가 받은 값보다 앞세우고, 다른 탭의 읽음은 다음 받기 때 — 2026-11-07), 간격은 받기가 끝난 때(`conversationBusy` 가 꺼질 때)부터 10초. 펼친 본문은 `MailBodyFrame` · 강조 쿼리 없이, 안 읽은 메일은 `markRead`(`Promise<boolean>` — 같은 목록의 `pendingFlags` 에 같은 읽음이 있으면 건너뛴다)로. `closeMessage` 가 칸도 비운다. 초점은 누른 단추가 사라지면 줄의 펼침 단추 → 새 [다시 시도] → 칸 h3(`tabindex="-1"`) → 칸이 없어지면 읽기 화면에 `settled`. 상태 영역은 늘 두고 글만 바꾼다(다시 실패해도 읽게). [이 메일만 열기]는 분할 보기 push · 목록 보기 replace(layout prop). 줄 차례는 `orderHeldLast`(붙잡힌 메일은 끝 — 보낼 시각 차례), 미리 보기는 `previewWithoutOwnQuote`(우리 원문 인용 머리부터 잘라 냄 — `mail-conversation.ts`), 펼친 줄의 첨부 수 안내는 `ConversationMessagePanel`. 15단계: 칸 머리 오른쪽 [모두 펼치기] · [모두 접기](36 — `MAIL_CONVERSATION_TEXT.expandAll` · `collapseAll`, 판정 `conversationAllExpanded` — 하나씩 다 펼쳐도 [모두 접기]). 모두 펼친 줄의 본문은 화면에 들어온 줄부터 하나씩(`composables/useConversationPanelVisibility.ts` — IntersectionObserver 하나로 `[data-conversation-panel]` 을 보고 스토어의 `noteConversationPanelVisible` 에 알린다 · 차례 `detailQueue`, 차례 전에 화면 밖으로 나간 줄은 건너뛴다), 읽음은 본문이 화면에 보였을 때만(`lazyRows` — 손으로 펼친 줄은 지금처럼 받는 대로). 펼친 줄 머리 [답장](`data-conversation-head-reply` — 머리 틀 `.conversation-row__head` 안에 펼침 단추와 나란히, 임시 저장본 · 예약 · 지워진 메일엔 없다) |
| 대화로 묶어 보기 (14단계) | `src/components/mail/MailConversationToggle.vue`(목록 머리 [대화로 묶기]) · `ConversationListItem.vue`(분할 보기 줄) · `ConversationTableRow.vue`(목록 보기 표 줄) · `composables/useConversationRows.ts` · `shared/utils/mail-conversation-list.ts`(응답 맞추기 · 할 일 id · 참여자 · 머리 글 · 표시 적용 · 맞추기 병합 · 삭제 뒤 갈 곳) · `stores/mail/list.ts`(출처 `conversations`) · 설정 `stores/mail-settings.store.ts` 의 `groupByConversation`(+ 설정 › 보내기 · 읽기 한 줄) · 글 `shared/constants/mail-conversation.ts` 의 `MAIL_CONVERSATION_LIST_TEXT` | 받기 · 맞추기 · 줄에 하는 일은 계약 2절 "대화 — 14단계"의 클라이언트 줄(20줄 · 맞추기는 불러온 줄 수만큼 최대 100줄 `silent` · 할 일 = `messageIds` − 붙잡힌 메일 · 줄 전체 표시는 모두 바꿨을 때만 · 옮기기 · 삭제 뒤 한 번 다시 받기). **첫 진입 짐작**은 localStorage `groupByConversationHintKey(주소)` — 설정을 받기 전 · 받지 못했으면 이 값, 받으면 서버 값, 읽기 · 쓰기가 던져도 끔. 로그아웃 · 세션 만료에 설정 스토어를 비워도 화면이 보는 값은 그대로 둔다(뒤집히면 watcher 가 떠나는 화면의 목록을 다시 부른다 — 2026-11-06 리뷰). **목록을 부르는 watcher 는 `authStore.canUseApp` 이 false 면 부르지 않는다**(소스에도 넣는다). 붙잡힌 메일이 든 줄은 고르기 칸 · 별이 `aria-disabled` + 까닭(`SEND_LATER_TEXT.heldNotSelectable`). 목록 줄의 화면 낭독 글("안 읽음, " · "중요 표시" · "첨부 있음")은 `shared/constants/mail.ts` 의 `MAIL_LIST_ROW_TEXT` — 메일 줄은 아직 직접 적은 글이라 함께 고친다. 읽기 화면 [이전] · [다음] · 위치는 대화 단위(`ListReadNav.vue`), 한 통 삭제 뒤 갈 곳은 `conversationNeighborAfterRemoval`. 분할 보기 머리의 줄인 개수 글 `listHeadShortCountText`(전체 글은 title · 숨긴 글) · 도구 줄 `selectedShort`(숨긴 글 "대화 N개 선택됨") · 표 머리 "참여자"(`participantsColumn`) · 줄 알림 `announceMove(…, { from })` → `MAIL_BULK_TEXT.movedFrom`("{메일함}에 있는 메일 N통을 …"). 영구 삭제는 `MailPurgeDialog` 의 `from` + `announcePurge(…, { from })` → `purgeTitleFrom` · `purgedFrom`(값은 `useBulkMailActions` 의 `conversationFrom` — 묶지 않은 목록 · 읽기 화면은 null). **설정 다시 받기** `composables/useMailSettingsRefresh.ts`(MailLayout — 탭이 보일 때 60초 `MAIL_SETTINGS_REFRESH_AFTER_MS` · `canUseApp`) + 스토어 `refresh()`(silent · 받는 동안 loading 으로 바꾸지 않음 · 저장 중이면 하지 않음 · 받는 사이 고른 것(`choiceSeq`) · 다시 받기(`loadSeq`) · 세션이 바뀌면 버림) · `fetchSeq` — 디자인 README "14단계 구현하며 정한 보완 — 목록 대화로 묶어 보기" |
| 여러 통 처리 · 삭제 | `src/components/mail/MailCheckbox.vue` · `MailSelectionToolbar.vue`(+ `MailBulkButton` · `MailBulkMoveButton`) · `MailPurgeDialog.vue` · `MessageDeleteButton.vue` · `composables/useBulkMailActions.ts` · `useMessageRemoval.ts` · `useMailMoveFeedback.ts` · `shared/utils/mail-selection.ts` · `mail-bulk.ts` | 100통씩 차례로. 읽음 · 중요는 먼저 바꾸고 `failedIds` 만 되돌리고, 이동 · 삭제는 응답의 id 만 뺀다. **웹은 `DELETE /api/messages/{id}` 를 쓰지 않는다** — 휴지통 밖 삭제는 `PATCH` 이동(+ 되돌리기), 휴지통에서는 확인 대화상자(처음 초점 「취소」) 뒤 `POST /message-deletions`. 읽기 화면도 그 메일의 `mailboxIds` 로 같은 규칙을 탄다. 도구 줄은 role="toolbar" 로빙 tabindex(바쁜 · 꺼진 단추는 건너뛴다). [되돌리기] 뒤 알림은 한 메일함이면 그 이름(`restoredMessageText(…, mailboxName)` — "메일 N통을 받은편지함으로 되돌렸습니다.") — 계약 2절 "여러 통 한꺼번에". [이동 ▾]의 옮길 곳 · 스팸 표시 · 끌어다 놓기는 아래 「메일 옮기기 · 끌어다 놓기 · 좁은 읽기 칸 (15단계)」 행 |
| 여러 통 처리 · 삭제 | `src/components/mail/MailCheckbox.vue` · `MailSelectionToolbar.vue`(+ `MailBulkButton` · `MailBulkMoveButton`) · `MailPurgeDialog.vue` · `MessageDeleteButton.vue` · `composables/useBulkMailActions.ts` · `useMessageRemoval.ts` · `useMailMoveFeedback.ts` · `shared/utils/mail-selection.ts` · `mail-bulk.ts` | 100통씩 차례로. 읽음 · 중요는 먼저 바꾸고 `failedIds` 만 되돌리고, 이동 · 삭제는 응답의 id 만 뺀다. **웹은 `DELETE /api/messages/{id}` 를 쓰지 않는다** — 휴지통 밖 삭제는 `PATCH` 이동(+ 되돌리기), 휴지통에서는 확인 대화상자(처음 초점 「취소」) 뒤 `POST /message-deletions`. 읽기 화면도 그 메일의 `mailboxIds` 로 같은 규칙을 탄다. 도구 줄은 role="toolbar" 로빙 tabindex(바쁜 · 꺼진 단추는 건너뛴다). [되돌리기] 뒤 알림은 한 메일함이면 그 이름(`restoredMessageText(…, mailboxName)` — "메일 N통을 받은편지함으로 되돌렸습니다.") — 계약 2절 "여러 통 한꺼번에". [이동 ▾]의 옮길 곳 · 스팸 표시 · 끌어다 놓기는 아래 「메일 옮기기 · 끌어다 놓기 · 좁은 읽기 칸 (15단계)」 행 목록 보기의 줄(`MessageTableRow` · `ConversationTableRow`)은 줄 전체에 click 을 걸고 단추 · 링크 · 고르기 칸 · 별 칸이면 열지 않는다 — **무엇을 눌렀는지는 `shared/utils/event-path.ts` 의 `eventPathHas`(사건이 지나간 길 — dispatch 때 정해진다)로 가르고 `event.target.closest` 로 가르지 않는다**: 고르기 상자 가운데를 누르면 target 은 빈 상자 그림(`<rect>`)인데, 단추의 처리기가 고르기를 바꾸면 브라우저가 처리기 사이에 microtask 를 돌려 Vue 가 다시 그리고 그 그림이 문서에서 떨어져 줄의 처리기에서 closest('button') 이 비었다 — 고르면서 메일까지 열렸다(2026-11-10 사용자 보고). 상자 그림(`MailCheckbox` 의 svg) · 표의 별 그림은 `pointer-events: none` — 누른 것이 늘 단추다. 고르기 칸의 빈자리(단추 밖 · td 안)는 고르기 상자를 누른 것과 같다(Shift 는 범위 · 고를 수 없는 줄이면 아무 일도 없다), 별 칸의 빈자리는 아무 일도 없다. Ctrl(⌘) · Shift + 클릭은 줄의 다른 곳에서 지금처럼 고른다. 분할 보기는 고르기 칸(36)이 링크 밖이라 탈이 없다. 시험 `components/mail/MailListRows.check.spec.ts` · `shared/utils/event-path.spec.ts` — 스크립트의 dispatchEvent 는 처리기 사이에 microtask 를 돌리지 않아 칸의 처리기에서 그림을 떼어 내 흉내 낸다(실제 다시 그리기는 브라우저로 확인) |
| 설정 › 비밀번호 · 부재중 (4단계) | `src/pages/settings/AccountPasswordPage.vue` · `MailVacationPage.vue` · `components/settings/`(`SettingsPageFrame` 공용 틀 — 머리 오른쪽 단추는 `actions` 슬롯 · `PasswordChangeForm` · `PasswordField` · `Vacation*`) · `stores/mail-vacation.store.ts` · `shared/utils/password-change.ts` · `mail-vacation.ts` · 메뉴 `shared/constants/settings-menu.ts` | 메뉴는 묶음(계정 › 비밀번호 / 메일 › 서명 · 부재중 자동 응답 · 필터(6단계) · 알림(5단계) / 정보 › 업데이트 내역), 라우트 이름은 `SETTINGS_ROUTES` 하나. 비밀번호는 서버와 같은 규칙을 먼저 보고, 서버 거절(`AUTH_005` · `VAL_001`)은 칸 아래에만(알림 없음) — 성공하면 세 칸을 비운다. 부재중은 날짜만 고르고 브라우저 시간대의 시작일 0시 · 끝나는 날 다음 날 0시를 UTC 로 보낸다(변환은 `mail-vacation.ts` 한 곳 — 되읽을 때는 1ms 앞 날짜). 켤 때만 검사하고 꺼 둔 채로는 빈 값도 저장한다(길이 150 · 600 도 켤 때만 — 꺼 둔 채로는 4단계 한도까지, 끄기는 늘 저장되게 — 16단계부터 내용은 편집기, 아래 「부재중 서식 본문 · 회사 밖 응답 (16단계)」 행). 부재중 상태는 메일 화면에 들어올 때 받고, 패널 안내 `components/mail/MailVacationNotice.vue` 가 1분마다 다시 계산한다(`useClock` · `VACATION_NOTICE_REFRESH_MS`). 저장 응답은 세션 세대(`authStore.generation`)가 바뀌었으면 쓰지 않는다. 저장한 값이 켜짐인데 엔진에서 규칙이 꺼져 있으면(응답의 `isActive: false` — 7단계) 머리 아래 노란 줄(`MailFilterNotice` 를 단추 없이 — `actionLabel` 을 주지 않는다). 칸을 고친 값이 아니라 저장한 값으로 보고, `isActive` 는 PUT 본문에 넣지 않는다(`useMail().saveVacation` 이 일곱 칸만 — 16단계의 `htmlBody` · `external` 까지) — 계약 1절 · 2절 "부재중 자동 응답" |
| 메일함 관리 · 오래 걸리는 처리 (4단계) | `src/components/mail/MailboxTree.vue` · `MailboxPanelRow.vue` · `MailboxMoreButton.vue`(⋯ 메뉴) · `MailboxDialogs.vue`(+ `Mailbox*Dialog.vue`) · `MailboxEmptyButton.vue`(목록 머리 「비우기」) · `composables/useMailboxActions.ts` · `stores/mail/mailbox-admin.ts` · `shared/utils/mailbox-operation.ts` · 상수 `shared/constants/mailbox-manage.ts` | 만들기 · 이름 바꾸기는 칸 안 실패(`MAIL_009` · `VAL_001`)를 알림 없이 칸 아래에. 모두 읽음 · 비우기 · 지우기는 남은 수가 0(지우기는 `deleted: true`)이 될 때까지 다시 보내며 진행 알림을 고친다 — 진행은 응답의 `processed`, 세션이 바뀌면 멈춘다(`epoch` — 늦게 온 만들기 · 이름 바꾸기 응답도 버린다), 같은 메일함의 같은 처리는 하나, **휴지통 비우기와 메일함 지우기는 함께 돌리지 않는다**(비우기는 그사이 휴지통에 들어온 메일도 지운다 — 판정은 스토어의 `isMailboxOperationBlocked` 하나, 메뉴 · 단추 · 대화상자 확인이 모두 이것을 본다). 끝난 뒤 누른 단추가 사라져 초점을 잃었으면 패널의 지금 메일함 항목으로 옮긴다. ⋯ 메뉴는 보이는 영역 안에 다 들게 연다(`shared/utils/popup-placement.ts` — 아래가 모자라면 위로 · 좌우가 넘치면 반대 끝으로(15단계 — EditorPopup)). 되돌릴 수 없는 일은 확인 대화상자(처음 초점 「취소」) 뒤에, 확인한 그 메일함만 — 계약 2절 "메일함 관리" |
| 스팸 · 원문 (4단계) | `src/components/mail/MessageSpamButton.vue`(읽기 화면) · 도구 줄의 스팸 단추(`MailSelectionToolbar.vue`) · `MessageMoreMenu.vue`(⋯ › 원문 내려받기) | 스팸 신고 · 스팸 아님은 이동과 `isJunk` 를 한 요청에 보내고(`mailStore.moveMessages(…, { isJunk })`), [되돌리기]는 원래 메일함으로 반대 값을 보낸다. 원문(.eml)은 `useMail().downloadMessageSource()` — 첨부처럼 `confirmSession()` 을 먼저 거친다 — 계약 2절 "스팸 · 원문" |
@@ -63,7 +64,7 @@ description: Vue 3 프론트엔드(frontend/) 개발 규칙 — Composition API
| 이미 받은 메일에 필터 적용 (7단계) | `src/components/settings/MailFilterApplyField.vue`(고치기 창의 「받은편지함에 이미 있는 메일도 정리하기」 — 창을 열 때마다 꺼짐) · `composables/useMailFilterRun.ts`(진행 · 결과 알림) · `stores/mail-filter-run.store.ts`(cursor 를 이어 보내기 — 탭마다 하나 `running`) · `shared/utils/mail-filter-run.ts`(할 일 판정 · 보낼 규칙 · 끝 알림의 메일함 · 문구) | 저장이 성공한 뒤에만 `POST /api/mail-filter-runs` 를 `cursor` 가 null 이 될 때까지 다시 보내며 진행 알림을 고친다(4단계 오래 걸리는 처리와 같은 결 — 전체는 확인 + `remaining`, 첫 응답 전에는 받은편지함의 메일 수). 전달은 보내지 않는다. 이미 받은 메일에 할 일이 없는 필터(옮길 메일함이 지워짐 · 받은편지함으로 옮기기만 · 전달만)는 칸을 끄고 시작하지도 않는다 — 판정은 `filterRunIdleReason` 하나(칸 `filterApplyBlock` · 시작 `filterRunRuleOf` 가 함께 쓴다), 이 탭에서 적용이 도는 중에도 칸을 끈다. 요청은 `silent` 로 보내고 실패는 응답이 온 때 세션이 그대로일 때만 스토어가 알린다(`REQ_001` — 다른 창 · PC 에서 적용 중 — 은 화면이 "다른 창에서 …"로). 멈추면 [다시 시도](받은 `cursor` 부터 — 수도 이어 센다), 나아가지 못한 응답이 거듭되면(`MAIL_FILTER_RUN_MAX_STALLS`) 멈춘다. 다른 화면으로 가도 이어 하고, 세션이 바뀌면 다음 요청을 보내지 않고 알림을 닫는다. 끝났거나 나아간 뒤 멈췄으면 개수 · 목록을 맞춘다 — 계약 2절 "이미 받은 메일에 적용", 디자인 README "7단계" |
| 주소록 (8단계) | `src/pages/contacts/`(`ContactsLayout.vue` — 패널 · 창 묶음 `ContactsDialogs` 의 자리 · `ContactsPage.vue`) · `components/contacts/`(패널 `ContactsPanel` · 그룹 줄 `ContactsGroupRow` · 목록 `ContactsList` · `ContactsListItem` · 상세 `ContactDetail` · 찾기 `ContactsSearchBox` · 연락처 창 `ContactDialog` + 줄 `ContactEntryRows` · 그룹 이름 창 · 구성원 넣기 창 `ContactGroupMembersDialog` · ⋯ `ContactMoreMenu` · `ContactAvatar` · `contacts-ui.ts`) · `stores/contacts.store.ts` · `composables/useContacts.ts`(API) · `useContactActions.ts`(지우기 · [되돌리기] · 그룹에 메일 쓰기 · 고치기 열기) · `useContactsDialogs.ts`(창 상태) · `useContactDuplicateCheck.ts`(같은 주소 경고) · `shared/utils/contacts.ts`(차례 `compareContacts` — **backend `contact-search.ts` 와 짝** · 칸 정리 · 바꾼 칸 `contactPatchOf` · 검사) · `contacts-route.ts` · `shared/constants/contacts.ts`(라우트 `CONTACTS_ROUTES` · 한도) · `shared/utils/clipboard.ts` | 레일의 주소록 모듈(`app-modules.ts`). 고치기는 창을 열 때 그 연락처를 새로 받고 **바꾼 칸만 `PATCH`** 한다(검사도 바꾼 칸만 — 손대지 않은 남의 값은 그대로, 바꾼 것이 없으면 보내지 않는다). 지우기는 확인 없이 하고 [되돌리기]는 지우기 응답의 `restoreToken` 으로 `POST /contact-restorations`(표가 없으면 단추 없이 알린다). 바꾼 연락처는 응답을 서버의 차례로 받은 쪽 안의 자리에 넣는다(스토어의 `placeContact` — 자리를 모를 때만 첫 쪽을 한 번 다시 받는다, 바꿀 때마다 불러온 쪽을 모두 다시 받지 않는다). 구성원 넣기 창은 `excludeGroupId` 로 받고, 주소로 연 사내 항목은 `GET /directory-entries/{id}` 로. 창은 주소록 틀(`ContactsLayout`)에 둔다 — 주소록 안에서 옮겨 다녀도 남고, 떠날 때는 나가기 확인(`useDialogLeaveConfirm`), 세션이 바뀌면 닫는다. 저장 중에 창을 닫았으면 창 안에 보일 거절도 `notifyApiError` 로. 이메일은 서버보다 좁게 본다(`mail-address.ts` 의 `isStrictEmail` — 필터의 전달 주소와 공용), 이메일 칸은 `type="text"` + `inputmode="email"`(브라우저가 한글 도메인을 punycode 로 바꾸지 않게). 전환은 `.segmented--outline`(base.css), 도구 단추 `components/mail/MailToolButton.vue` 의 `unavailable`(aria-disabled — 클릭 무시) · `busy`(aria-busy) · `describedby` — 계약 3절, 디자인 README "8단계" |
| 메일과 함께 (8단계) | `shared/utils/compose-prefill.ts` · `components/mail/MailContactDialogHost.vue` + `composables/useMailContactDialog.ts` · `MessageMoreMenu.vue`(⋯ 「보낸 사람을 주소록에 추가」 · 「주소록에서 보기」) · 받는 사람 칸의 그룹(`useRecipientSuggestions` 의 `optionsFor` · `expandGroup`, `recipient-match.ts` 의 `matchContactGroups` · `arrangeRecipientOptions`) | 쓰기 화면을 받는 사람을 채워 열 때는 라우터 상태(`composeTo` — 주소는 URL 에 싣지 않는다)에 연 직원의 주소를 함께 싣고, 지금 직원과 다르면 쓰지 않는다(같은 탭에서 다른 직원이 로그인한 뒤 뒤로 가기). 읽기 화면의 연락처 추가 창은 메일 레이아웃(`MailLayout` 이 provide)에 둔다 — 메일을 바꾸거나 목록으로 돌아가도 남는다(읽기 화면 안 `v-if="message"` 에 두면 사라진다). 받는 사람 추천은 주소록 → 그룹 → 사내 → 최근. 그룹은 쓰기 화면을 열 때 받아 둔 목록에서 이름으로 찾고, 고르면 구성원을 받아 대표 주소를 넣는다 — 그룹마다 따로 기다려 이어 골라도 앞 그룹이 빠지지 않는다 — 계약 3절 "메일과 함께" |
| 보내기 취소 · 예약 발송 (9단계) | `src/components/mail/ComposeFooter.vue`([보내기 ▾] — 메뉴 자리는 `composables/usePopupPlacement.ts`, 메일함 ⋯ 과 공용) · `ComposeScheduleDialog.vue` · `shared/utils/schedule-send.ts`(고르기 · 한도 · `rejectedScheduleProblem`) · `shared/utils/pending-send.ts`(`isHeldMessage` · `isScheduledMessage` — 응답의 `pendingSend` 는 이것으로만 읽는다) · `stores/mail/scheduled.ts` · `composables/useScheduledMessageActions.ts` · `usePendingSendRefresh.ts` · `components/mail/MailboxScheduledRow.vue` · `ScheduledMessageList.vue` · `ScheduledMessageItem.vue` · `MessageReaderNotices.vue` · 마감 알림 `toast.store` 의 `expiresAt` + `components/feedback/ToastCountdown.vue` | 기다리는 곳은 엔진이다(계약 2절 "보내기 취소 · 예약 발송"). 보내기 취소 알림의 마감은 설정한 초를 알면 "지금 + 초 − 1초"(설정 스토어의 `knownUndoSendSeconds` — 메일 화면에 들어올 때 `ensureLoaded`), 모르면 min(sendAt, 지금 + 30초). 마감 알림은 멈추지 않고 넘쳐도 먼저 닫히지 않는다. 알림 단추는 `toastStore.takeAction(id)` 로 한 번만, 같은 메일의 취소는 스토어가 요청 하나로 묶는다. 초점이 든 알림이 닫히면 들어오기 전 요소로(없으면 `<main>` — `data-focus-fallback`). **붙잡힌 메일은 취소로만 보낸편지함을 떠난다** — 고르기 칸 `MailCheckbox` 의 `unavailable` · `note`(aria-disabled + 까닭), 고르기 · 범위 · 모두 고르기 · `checkedMessages` 가 건너뛰고, 읽기 화면 [삭제]는 확인(처음 초점 「취소」) → 취소 → 휴지통(서버도 `MAIL_015` 로 막는다). 엔진이 풀어도 상태가 바뀌지 않아 `usePendingSendRefresh` 가 가장 이른 시각 + 2초(남으면 5초 × 3)에 목록 · 열린 메일 · 「예약됨」 수를 다시 받는다. 응답을 기다린 뒤 다음 메일로 옮기는 일(`useMessageRemoval` 의 `leaveRemoved(…, messageId)`)은 같은 목록 · 같은 메일을 보고 있을 때만 — 디자인 README "9단계" |
| 보내기 취소 · 예약 발송 (9단계) | `src/components/mail/ComposeFooter.vue`([보내기 ▾] — 메뉴 자리는 `composables/usePopupPlacement.ts`, 메일함 ⋯ 과 공용) · `ComposeScheduleDialog.vue` · `shared/utils/schedule-send.ts`(고르기 · 한도 · `rejectedScheduleProblem`) · `shared/utils/pending-send.ts`(`isHeldMessage` · `isScheduledMessage` — 응답의 `pendingSend` 는 이것으로만 읽는다) · `stores/mail/scheduled.ts` · `composables/useScheduledMessageActions.ts` · `usePendingSendRefresh.ts` · `components/mail/MailboxScheduledRow.vue` · `ScheduledMessageList.vue` · `ScheduledMessageItem.vue` · `MessageReaderNotices.vue` · 마감 알림 `toast.store` 의 `expiresAt` + `components/feedback/ToastCountdown.vue` | 기다리는 곳은 엔진이다(계약 2절 "보내기 취소 · 예약 발송"). 보내기 취소 알림의 마감은 설정한 초를 알면 "지금 + 초 − 1초"(설정 스토어의 `knownUndoSendSeconds` — 메일 화면에 들어올 때 `ensureLoaded`), 모르면 min(sendAt, 지금 + 30초). 마감 알림은 멈추지 않고 넘쳐도 먼저 닫히지 않는다. 알림 단추는 `toastStore.takeAction(id)` 로 한 번만, 같은 메일의 취소는 스토어가 요청 하나로 묶는다. 초점이 든 알림이 닫히면 들어오기 전 요소로(없으면 `<main>` — `data-focus-fallback`). **붙잡힌 메일은 취소로만 보낸편지함을 떠난다** — 고르기 칸 `MailCheckbox` 의 `unavailable` · `note`(aria-disabled + 까닭), 고르기 · 범위 · 모두 고르기 · `checkedMessages` 가 건너뛰고, 읽기 화면 [삭제]는 확인(처음 초점 「취소」) → 취소 → 휴지통(서버도 `MAIL_015` 로 막는다). 엔진이 풀어도 상태가 바뀌지 않아 `usePendingSendRefresh` 가 가장 이른 시각 + 2초(남으면 5초 × 3)에 목록 · 열린 메일 · 「예약됨」 수를 다시 받는다. 응답을 기다린 뒤 다음 메일로 옮기는 일(`useMessageRemoval` 의 `leaveRemoved(…, messageId)`)은 같은 목록 · 같은 메일을 보고 있을 때만 — 디자인 README "9단계" 줄은 스크롤하는 칸의 아래에 붙는다(sticky · 2026-11-10) |
| 외부 이미지 · 인쇄 (9단계) | `src/components/mail/MailBodyFrame.vue`(`imagesShown`) · `shared/utils/mail-html.ts`(`mailFrameCspImagesShown` · `PROXIED_IMAGE_PATTERN`) · `mailStore.showImages()` · 인쇄 `pages/mail/MailPrintPage.vue` + `composables/useMailPrint.ts` · `shared/constants/mail-print.ts` | [이미지 보기]는 `GET /messages/{id}?images=show` 로 그 메일의 본문 칸만 다시 받는다. 본문 틀의 CSP 는 보일 때만 그림에 **우리 출처의 중계 경로**(`<origin>/api/mail-images/`)를 연다 — `'self'` 로 열면 정화를 빠져나간 CSS `url()` 이 다른 API 를 부를 수 있다(2026-10-29 리뷰). 중계 주소 모양은 짝: backend `images/mail-image-token.ts` 의 `TOKEN_PATTERN`. 인쇄는 새 탭(`window.open` 은 누름 처리 안에서 바로), A4 폭(210mm — @page 와 같은 값, 함께 고친다)에서 본문 높이를 다시 잰 뒤 `window.print()` 한 번. Ctrl+P 판정은 `keyboard-shortcut.ts`(조합 중 · 다른 수식키는 브라우저 인쇄, `code === 'KeyP'` 도) |
| 보낸 사람 차단 · 보내기 · 읽기 설정 (9단계) | `src/composables/useSenderBlock.ts` · `components/mail/MessageBlockDialog.vue` · `stores/mail-block.store.ts` · `shared/utils/mail-block.ts` · `shared/constants/mail-block.ts`(`PUBLIC_MAIL_DOMAINS`) · `pages/settings/MailBlocksPage.vue` · `MailSendReadPage.vue` · `stores/mail-settings.store.ts` · `composables/useMailSettings.ts` · 설정 틀의 `title-aside` 슬롯(`SettingsPageFrame`) | 차단은 엔진의 Sieve(필터 · 부재중과 같은 스크립트 — 계약 2절 "보낸 사람 차단"). 여러 사람이 쓰는 메일 서비스 목록은 **짝: backend `mail.constants.ts`**(30개 · 같은 차례 — 한쪽을 고치면 둘 다). 차단 · 풀기가 성공하면 엔진 규칙이 켜진 것으로 본다(`isActive`). [되돌리기]는 원래 메일함으로. 설정은 고르는 대로 `PATCH` — **칸마다 저장은 한 번에 하나**(보내는 동안 고른 값은 끝난 뒤 다르면 보낸다, 끝내 실패하면 서버가 확인한 값으로 — 6단계 필터의 `saving` 과 같은 결) |
| 대용량 첨부 (11단계) | 올리기 엔진 `src/composables/useLargeFileUploads.ts` · 쓰기 화면 잇기 `useComposeLargeFiles.ts` · 첨부 칸 `components/mail/ComposeAttachmentGroups.vue` · `ComposeLargeFileItem.vue` · 읽기 `MessageAttachments.vue`(+ `MessageLargeFileItem` · `MessageLargeFileLinkBar` · `LargeFileRevokeDialog`) · 검사 다시 묻기 `useLargeFileScanRefresh.ts` · 위험 알림 `useLargeFileAlerts.ts`(실시간 `large-file`) · 설정 `pages/settings/MailLargeFilesPage.vue` + `components/settings/LargeFile*.vue` · `stores/large-files.store.ts` · API `useLargeFiles.ts` · 공개 화면 `pages/share/SharePage.vue` + `usePublicShare.ts` · `shared/utils/large-file.ts` · `large-file-status.ts` · `kst-date.ts` · `share-links.ts` · `shared/constants/large-file.ts` | 20MB 넘는 파일 · 합계 25MB 를 넘기는 파일 · 메일에 못 담는 형식은 저절로 링크로(묻는 창 없음). **올리기**: 고르자마자 서버에 모두 만들고(임시 저장본에 바로 실림) 조각(16MiB · `Content-Digest`)은 파일 3개까지만 함께, 끊기면 2 · 5 · 15 · 30초로 다시, 1분 넘으면 멈춤 + [다시 시도], 404 는 저절로 다시 올리지 않는다, 새로고침 뒤 같은 파일(이름 · 크기 · 고친 시각 + 맨 앞 조각 SHA-256)을 다시 고르면 잇는다. 다 올릴 때까지 [보내기] · ▾ 는 `aria-disabled` + 까닭(누르면 role=status). **본문**: 대용량 파일이 있을 때만 첫 원문 인용 앞에 `<div data-ara-quote-start="">` 와 `bodyQuoteStart`(텍스트 인용 줄의 처음)를 보낸다 — 서버가 그 자리에 덩이를 넣는다. 다시 연 저장본의 원문 인용은 `mail-extensions.ts` 의 `isMailQuoteBlock`("원본 메일" 머리 문단 + 머리 표)으로 알아본다. **읽기**: 첨부 칸의 대용량 항목은 서버의 `largeFiles` 에서만 오고, 본문 덩이는 서버만 붙일 수 있는 `data-ara-large-files` 요소만 `<template>` 안에서 접는다(`mail-html.ts`). **공개 화면** `/s/:token` 은 `meta.public`(세션 복원을 건너뛴다 — 이 라우트 하나뿐), 요청은 `credentials: 'omit'` · `no-store` · `no-referrer`, v-html 없음. 서버 거절은 사전 문구(`getErrorMessage`), 화면 상수 글은 서버에 묻기 전 검사뿐 — 계약 4절, 디자인 README "11단계" |
@@ -284,6 +285,8 @@ router.beforeEach((to) => {
편집기로 읽어 들이는 서식은 **서버가 남기는 형식만** 받는다 (`outgoing-html-rules.ts`) — 서버만 지우면 편집기에서 숨은 글(`font-size:0` 등)이
받는 사람에게 보이는 식으로 화면과 보낸 메일이 달라진다 (2026-10-27 리뷰).
이어 쓰는 임시 저장본의 `bodyHtml`·서명 HTML 도 같은 경로로 편집기에 옮긴다 (다른 메일 프로그램이 만든 초안도 있다).
- [ ] **아래에 붙는 줄(sticky)을 새로 만들 때**: 겹침 차례(z-index — 그 줄 위로 뜰 메뉴 · 목록)와 그 줄에 가리는 초점(키보드 · 코드로 옮긴 초점 — 쓰기 화면은 `useFocusAboveFooter`)을 함께 본다
- [ ] **줄 · 칸 전체에 click 을 걸 때**: 안의 단추 · 링크를 가르는 것은 `event.target.closest` 가 아니라 `eventPathHas(event, 선택자)`(`shared/utils/event-path.ts`) — 앞선 처리기가 상태를 바꾸면 처리기 사이에 Vue 가 다시 그려 target(아이콘 그림 등)이 문서에서 떨어진다. 단추 안의 그림은 `pointer-events: none`
- [ ] **lint/test**: 커밋 전 `npm run lint` (`--max-warnings 0`) + `npm run type-check` + `npm run test:unit` 통과. 자동 수정은 `npm run lint:fix`.
테스트 시간 한도는 전역 20초(`vitest.config.ts` — jsdom·편집기 테스트가 부하에서 5~13초)이고, 긴 초안을 맞추는 테스트는 `src/test-utils/timeouts.ts` 의 한도를 쓴다.
"몇 ms 안에 끝난다"를 검사하는 성능 단언은 벽시계 대신 계산량(CPU 시간·비용)으로 잰다 — 벽시계는 다른 작업이 많으면 몇 배로 늘어
@@ -1,5 +1,4 @@
# 휴대폰 앱이 나왔고, 메일 검색과 여러 설정이 좋아졌습니다
<!-- 배포 날짜가 정해지면 파일 이름을 그날로 바꾸고 이 줄을 지웁니다 -->
- 11월 5일 새 웹메일로 옮긴 뒤 새로 생기거나 바뀐 것을 모았습니다. 앞으로 바뀌는 것도 이곳에서 알려 드리겠습니다.
+9
View File
@@ -82,6 +82,8 @@
- 짜임: 머리(제목 · 보낸 사람 · 받는 사람 · 시각 · 안내 줄 · 첨부)는 앱 화면으로 본문 위에 두고 남은 높이의 절반까지만 쓴다(넘으면 머리 안에서 스크롤).
본문 WebView 는 나머지를 다 쓰고 그 안에서 스크롤한다 — 스크립트를 꺼서 내용 높이를 잴 수 없다.
**첨부 목록은 본문 위**다(보드는 아래) — 본문이 WebView 안에서 스크롤하므로 아래에 두면 늘 화면 아래를 차지한다.
**본문에 보이는 것이 없으면 본문 칸을 두지 않고** 머리 전체(안내 줄 · 첨부)를 화면 전체에서 한 스크롤로 보인다 — 빈 본문이라는 글은 따로 두지 않는다(2026-11-12 — 첨부만 있는 메일에서 아래 절반이 빈 칸으로 남고 첨부 목록이 위 절반 안에서 잘렸다).
보이는 것은 공백 · 보이지 않는 글자(nbsp · 제로폭 · 방향 표시 · 한글 채움 문자 등)가 아닌 글자, 주소가 남은 그림(외부 그림은 받기 전에도), 가로줄 · 목록 표시 · 펼침 · 따옴표다 — 꾸밈(style · bgcolor · border)만으로 그린 상자 · 선은 치지 않는다. 보일 본문을 고르는 규칙은 그대로다(서식 본문이 비어 보여도 글자 본문으로 바꾸지 않는다 — 웹과 같은 본문).
- 본문 글꼴은 기기 글꼴이다(CSP · 파일 접근 끄기로 WebView 가 Pretendard 를 쓰지 못한다). 크기 15 · 줄 간격 1.75 · 여백 16 / 20 / 24 는 보드대로, 기기 글자 크기 설정을 따르고, 앱이 어두운 테마여도 본문 바탕은 흰색이다.
- 앱이 한 번 더 정화한다(서버 정화 뒤 — 허용 목록으로 다시 쓴다). 길이 2,000,000 · 중첩 비용 8,000,000 을 넘으면 정화하지 않고 글자 본문과 서버의 생략 안내와 같은 글을 보인다.
서식 본문은 6,144자보다 짧고 중첩 비용이 5,000 이하일 때만 화면에서 바로, 그 밖은 다른 isolate 에서 정화한다(개발 PC 에서 바로 하는 것이 2ms 안쪽 — 휴대폰은 몇 배 느리다).
@@ -113,6 +115,7 @@
- 진행 막대의 화면 낭독 값은 숫자만 된다(Flutter 3.41) — 이름("… 받는 중")만 싣고 "받는 중 64%"는 화면 글로만 보인다.
- iOS 를 붙일 때: 파일 열기 채널(지금은 "파일을 열지 못했습니다."), WKWebView 의 링크 길게 누르기 미리 보기 끄기(`setAllowsLinkPreview(false)` — 미리 보기는 판정을 거치지 않고 그 주소를 연다).
- 실기기에서 볼 것: 넓은 표 뉴스레터(가로 스크롤 · 확대), 기기 글자 크기, 링크 · 길게 누르기(WebView 안에서 이동하지 않는지), 같은 그림을 여러 번 쓴 뉴스레터, 2MB 본문을 정화하는 시간,
첨부만 있는 메일(본문 칸 없이 마지막 첨부까지 한 스크롤 · 그 메일에서 [이미지 보기]를 누르면 본문 칸이 생기는지),
첨부(PDF · 오피스 · hwp 가 한컴 뷰어에서 열리는지 · 막는 형식의 알림 · eml 을 어느 앱이 여는지 — 크롬이 MHTML 로 열면 스크립트가 꺼져 있는지), 파일 · content 접근이 꺼졌는지, 폴더 밖 경로는 열지 않는지,
TalkBack 읽는 차례(위 막대 → 머리 → 안내 → 첨부 → 본문 → 아래 막대), 뒤로 가기 몸짓, 로그아웃 뒤 받은 파일이 지워지는지.
@@ -175,11 +178,17 @@
- 전달 원본 · 저장본의 첨부와 대용량 파일은 싣고 빼기만 한다. 문제가 있으면 줄 테두리가 빨갛고 줄 아래에 까닭을 적는다(함께 보낼 수 없음 · 찾지 못함 `MAIL_004` · 올리다 멈춤 · 지워짐 · 위험한 파일).
- 복사본은 뺄 때 · 화면이 끝날 때 · 세션이 끝날 때 · 앱을 띄울 때 · 메일을 보는 직원이 바뀔 때 지운다.
- 글자 크기는 테마 크기로 맞췄다(15.5 → 16 · 14와 14.5 → 15 · 13 → 13 · 12.5 → 12).
- 입력칸(받는 사람 · 제목 · 본문)은 테두리 · 바탕 없이 글만 그리고, 선은 칸 줄의 아래 선(초점이면 청록 2)뿐이다 — 보드대로. 검색 막대의 칸도 테두리 없이 글만 그린다.
앱 테마의 입력칸 테두리를 물려받지 않게 `borderlessInputDecoration`(`core/theme/app_theme.dart`)을 쓴다(2026-11-12 — `InputDecoration.collapsed` 가 테마의 둥근 테두리를 물려받아 칸마다 글자를 바짝 감싼 테두리가 그려지고, 초점이면 칸 줄의 아래 선과 청록 테두리가 겹쳤다).
- 칸 줄(2026-11-12 — 보드에 맞춤): 최소 높이 56 · 여백 위 4 · 오른쪽 4 · 아래 4 · 왼쪽 16, 칸 이름 폭 72 · 글자 15(보드 14) · 칸 이름과 칸 사이 6. 아래 선은 자리를 차지하지 않아 초점으로 굵어져도 칸이 움직이지 않는다.
**받는 사람 칩이 여러 줄이면 칸 이름과 [참조 펼치기]는 첫 칩 줄에 맞춘다**(보드는 한 줄만 그렸다 — 줄 전체의 가운데로 내려가지 않게). 칩 ✕ 는 #4A5363, 제목 · 본문의 안내 글은 보드의 placeholder 색 #8A94A3(검색 칸과 같다).
본문 칸 최소 높이는 원본 메일이 아래에 있으면(답장 · 전달) 150, 없으면(새 메일 — 서명이 있어도) 240 이다(보드의 「새 메일 · 받는 사람 추천」 240 · 「답장 · 참조 펼침 · 첨부 · 원본 접힘」 150).
- **알려진 한계**: 앱이 안 보이게 될 때 칸에 쓰다 만 주소(칩이 되지 않은 글)는 저장에 들지 않고, 나가기 확인 창이 떠 있는 동안은 저장하지 않는다. 앱이 앞에 있는 채로 정리되면 마지막 몇 초를 잃는다.
고르는 동안 앱이 정리되면 고른 파일은 버린다(쓰던 내용은 고르는 창을 열 때 저장된다). 고르는 창에 10분 넘게 머물면 그 고르기는 시한으로 끝난다(알림이 창 뒤에서 뜰 수 있다 — 다시 고른다).
**알림 · 업데이트 화면으로 내려갈 때**(2026-11-07): 새 메일 알림을 누르거나 업데이트 화면으로 옮겨(다른 화면으로 `go`) 쓰기 화면이 내려가면, 쓰다 만 주소까지 임시보관함에 마저 저장하고
"임시보관함에 저장했습니다."(저장하지 못하면 그 코드의 사전 문구)로 알린다. 로그아웃 · 세션 끝 · 제한 단계로 옮겨 내려갈 때는 알리지 않는다(그 세션의 쓰기가 끝났다).
- **실기기에서 볼 것**: 삼성 · Gboard 자판의 칩 만들기(쉼표 · [다음] · 한글 조합 중 확정 · 여러 줄 붙여 넣기), 추천 목록이 자판에 가리는지, 예측 뒤로 가기, 홈 · 다른 앱으로 갔을 때 저장, 긴 원문 펼침 스크롤,
기기 글자 크기를 키웠을 때 칸 이름(숨은참조 — 폭 72 에서 낱말 가운데 꺾임),
서명 그림 폭, TalkBack 차례(칩 · 추천 · 원본 펼침 · 띠), 제스처 내비게이션에서 읽기 화면 알림 자리, 웹 ↔ 앱으로 저장본을 서로 다시 열기, 앱 로그인 서버(feat/app-auth)와 함께 실제 보내기 · 저장 · 서명 그림.
첨부(2차): 고르는 창의 출처마다(내 파일 · Google 드라이브 · 사진 · 다운로드) 이름 · 크기 · 확장자를 주는지, hwp · hwpx 가 창에 보이고 올라가는지 · HEIC 는 `FILE_001` 인지,
20MB 넘는 파일 · 크기를 알려 주지 않는 클라우드 파일이 한도에서 멈추는지, 합계에 걸리는 큰 파일을 내려받지 않고 바로 알리는지, 받는 동안의 진행 막대, 받다 망이 끊길 때(비행기 모드) 가져오기가 끝나는지, 여러 개 · 남은 자리보다 많이 고르기,
+23
View File
@@ -229,6 +229,24 @@
받지 않는 곳에 놓으면 브라우저가 그 파일을 열어 쓰던 메일을 잃기 때문이다 (2026-10-27 구현하며 정함).
- 이미지를 첨부로 돌렸을 때만 성공 알림 "이미지는 본문 대신 첨부 파일로 올렸습니다."를 띄운다(성공 알림과 같은 모양 · 5초).
### 받는 사람이 많을 때 · 낮은 화면 — 스크롤 (2026-11-10 사용자 보고 — 캔버스에는 없다)
전 직원 공지를 숨은참조로 쓰자 칸이 늘어 쓰기 칸이 넘쳤고, 바깥 스크롤 + 본문 스크롤의 이중 스크롤 속에 맨 아래 [보내기] 줄이 화면 밖으로 숨었다
(본문 위에서 휠을 돌리면 본문만 움직였다). 모양 · 크기는 그대로 두고 아래처럼 고쳤다.
- **[보내기] 줄은 늘 보인다** — 스크롤하는 칸(넓은 화면은 쓰기 칸, 좁은 화면은 문서)의 아래에 붙는다. 위 선 · 흰 바탕 · 여백은 그대로이고 넘칠 때만 내용 위에 뜬다.
[보내기 ▾] · [서명 ▾] 메뉴는 위로 연다. 겹침은 툴바 < [보내기] 줄 < 받는 사람 추천 목록 · 툴바의 메뉴(연 동안)다.
- **받는 사람 · 참조 · 숨은참조는 칩이 세 줄을 넘으면 그 칸 안에서 스크롤한다** — 보이는 높이 96: 맨 위에서는 칩 세 줄, 맨 아래(입력 자리)에서는
칩 두 줄 + 입력 줄로 줄 사이에서 끊겨 반쯤 잘린 칩이 보이지 않는다. 초점이 오거나 · 칩이 늘거나 · 글을 치면 맨 아래로 간다.
칩의 ✕ 로 지우면 초점은 입력칸으로 가되 칸의 자리는 그대로다(가운데 칩을 이어 지운다). 추천 목록은 칸 밖(줄 아래)에 뜬다.
- **넓은 화면(1120 이상)은 본문 편집 영역이 300 에서 200 까지 먼저 줄어든다** — 보통 높이(1366×768 · 1440×900)에서는 숨은참조 30명이어도
쓰기 칸 전체가 화면 안에 들고 본문만 스크롤한다(1366×768 에서 편집 영역 226). 그래도 넘치면(첨부가 많음 · 아주 낮은 창) 쓰기 칸이 스크롤하고
[보내기] 줄은 아래에 붙어 있다. 좁은 화면은 300 그대로이고 문서가 스크롤한다.
- [보내기] 줄에 가린 칸으로 키보드 · 코드의 초점이 오면 가린 만큼 올린다(WCAG 2.4.11 — 좁은 화면에서 Tab 으로 간 [파일 첨부]가 가렸다).
마우스 · 손가락으로 누른 칸은 그대로다 — 누른 채 내용이 움직이면 글이 골라진다.
- 넓은 화면에서 문서까지 168px 스크롤하던 것(화면 낭독 전용 글이 앱 틀 밖에 놓였다)도 고쳤다 — 쓰기 칸이 위치를 잡는다.
- 알려진 한계: 휴대폰 폭(360)에서는 [보내기] 줄이 세 줄(보내기 · 임시 저장 / 서명 · 저장 상태 / 보내는 사람 — 161px)로 화면의 1/5 을 덮는다 — 나중에 다듬는다.
## 2단계 — 임시 저장 · 서명 · 받는 사람 자동 완성 (2026-10-27 확정 — `Settings` · `ComposeAssist`)
### 임시 저장
@@ -302,6 +320,11 @@ API 와 서버 규칙은 계약 2절 "새 메일 확인" · "검색" · "여러
- **목록 보기 표**: 맨 앞에 고르기 칸(32 — 별 칸과 같은 밀도 예외). 상자 16px — 비었을 때 테두리 #8A94A3 1.5px, 고르면 #0E7C86 채움에 흰 체크.
머리의 상자는 불러온 메일 모두 고르기 · 풀기(일부만 골랐으면 – 표시). 고른 행 바탕 #E8F4F5(마우스를 올리면 #DDEFF0).
칸 = 고르기 32 · 별 32 · 보낸 사람 168 · 제목(나머지) · 날짜 84 · 크기 64 (칸 사이 12). 좁아질 때는 지금 규칙 그대로(1100 미만 크기 칸 숨김 · 600 미만 보낸 사람 112 · 날짜 72).
행 어디를 눌러도 메일을 열되 **고르기 칸 · 별 칸은 어디를 눌러도 열지 않는다** — 고르기 칸은 빈자리(단추 밖)를 눌러도 상자를 누른 것과 같이 고르고
(Shift 는 범위 · 고를 수 없는 줄이면 아무 일도 없다), 별 칸은 별 단추만 — 별 칸의 빈자리는 아무 일도 없다
(2026-11-10 사용자 보고 — 고르기 상자 가운데를 누르면 고르면서 메일까지 열렸다: 고르자 상자 그림이 다시 그려져 문서에서 떨어진 탓.
줄은 사건이 지나간 길로 가르고 상자 · 별 그림은 누름을 받지 않는다 — 대화로 묶은 표도 같다). Ctrl(⌘) · Shift + 클릭은 칸의 빈자리에서도 지금처럼 고른다.
분할 보기는 고르기 칸(36)이 줄의 링크 밖이라 이 탈이 없었다(같은 시험으로 확인).
- **분할 보기**: 행에 마우스를 올리거나 초점이 가면 왼쪽 36px 에 고르기 상자가 나타나고, 하나라도 고르면 모든 행에 보인다.
행을 누르면 지금처럼 메일을 연다. Ctrl(⌘)+클릭은 하나씩, Shift+클릭은 범위, Esc 는 선택 풀기 (두 보기 공통).
여러 통을 고르는 동안 본문 칸은 열어 둔 메일을 그대로 보인다.
+9 -9
View File
@@ -3,13 +3,13 @@
그룹웨어 전체의 작업 차례다. 단계 번호는 모듈마다 따로 센다 — 메일의 "N단계"는 API 계약([`docs/mail/api-contract.md`](./mail/api-contract.md))과
화면 디자인([`docs/design/webmail/README.md`](./design/webmail/README.md))의 "N단계"와 같다. 단계마다 **조사 → 캔버스(화면 보드) → 확정 → 구현 → 배포** 차례로 한다.
마지막 정리: 2026-11-10
마지막 정리: 2026-11-12
## 지금
- **운영**: 메일 1~13단계. 2026-11-05 옛 메일 서버(mailcow)에서 전 직원이 옮겨 왔다(메일만 옮겼다 — 주소록 · 달력 · 필터 · 부재중 · 서명은 직원이 새로).
- **배포를 기다림**: 메일 14 · 15 · 16단계 · 앱 서버(앱 로그인 · 새 메일 알림) · 휴대폰 앱 받기 — main 에 합쳤고, 전환 뒤 안정화 1~2주를 지나 11월 셋째 주에 운영에 올린다.
- **개발 중**: 업데이트 내역 — 웹메일 · 휴대폰 앱의 설정에서 무엇이 새로 생기거나 바뀌었는지 읽는 게시판(글은 저장소의 글 파일, 셋째 주 배포에 함께).
- **운영**: 메일 1~16단계 · 휴대폰 앱 첫 판(안드로이드 1.0.1) · 업데이트 내역. 2026-11-05 옛 메일 서버(mailcow)에서 전 직원이 옮겨 왔고(메일만 옮겼다 — 주소록 · 달력 · 필터 · 부재중 · 서명은 직원이 새로),
2026-11-12 0시에 14 · 15 · 16단계 · 앱 서버(앱 로그인 · 새 메일 알림 · 휴대폰 앱 받기) · 업데이트 내역을 운영에 올리고 휴대폰 앱(1.0.0)을 나눠 주기 시작했다.
같은 날 배포 뒤 받은 의견(본문 없이 첨부만 있는 메일의 앱 읽기 화면 · 앱 쓰기 화면 입력칸)을 고친 앱 1.0.1 을 올렸다. 1.0.0 도 그대로 쓸 수 있다.
- **다음**: 아래 "추후 후보" 가운데 직원 요청이 실제로 있는 것을 고른다. 그다음은 공통 기반(조직 · 권한).
## 1부 — 메일
@@ -29,9 +29,9 @@
| 11 | 대용량 첨부 — 링크로 보내기 · 조각 올리기 · 30일 보관 · 로그인 없는 내려받기 화면 · 악성 검사 | 운영 |
| 12 | 계정 보안 — 2단계 인증 · 앱 비밀번호 · 로그인 기록 · 로그인된 기기 | 운영 |
| 13 | 전환 준비 — 새 비밀번호 · 2단계 필수 · 메일 프로그램 연결 안내 · 운영 도구 · 백업 · 감시 · 휴지통 · 스팸 30일 | 운영 |
| 14 | 자체 검색 색인 · 조건 검색 · 검색어 강조 · 같은 대화 · 대화로 묶어 보기 | main 에 합침 — 배포 기다림 |
| 15 | 메일 · 메일함 다루기 — 읽기 화면 [이동] · 끌어다 놓기 · 메일함 옮기기 · 하위까지 지우기 · 차단할 때 받은편지함 정리 · 예약 메일 [지금 보내기] · 같은 대화 [모두 펼치기] | main 에 합침 — 배포 기다림 |
| 16 | 개인 설정 — **주소록 가져오기 · 내보내기**(vCard · CSV · 엑셀) · 여러 서명(사내 · 대외 · 영문) · 부재중 응답의 서식 본문과 사내/외부 구분 · 2단계 인증 휴대폰 직접 바꾸기 | main 에 합침 — 배포 기다림 |
| 14 | 자체 검색 색인 · 조건 검색 · 검색어 강조 · 같은 대화 · 대화로 묶어 보기 | 운영(2026-11-12) |
| 15 | 메일 · 메일함 다루기 — 읽기 화면 [이동] · 끌어다 놓기 · 메일함 옮기기 · 하위까지 지우기 · 차단할 때 받은편지함 정리 · 예약 메일 [지금 보내기] · 같은 대화 [모두 펼치기] | 운영(2026-11-12) |
| 16 | 개인 설정 — **주소록 가져오기 · 내보내기**(vCard · CSV · 엑셀) · 여러 서명(사내 · 대외 · 영문) · 부재중 응답의 서식 본문과 사내/외부 구분 · 2단계 인증 휴대폰 직접 바꾸기 | 운영(2026-11-12) |
- 15단계부터는 직원이 쓰며 바라는 것을 먼저 본다 — 아래 "추후 후보" 가운데 요청이 실제로 있는 것을 골라 다음 단계로 넣는다.
- 2026-11-09 계획에서 뺀 것: 수신 확인 · 사내 발송 취소(사내 받는 사람에게만 되고, 흔한 실수는 보내기 취소가 막는다 — 아래 후보로).
@@ -48,8 +48,8 @@
| 판 | 내용 | 상태 |
| --- | --- | --- |
| 앱 1단계 | 로그인(코드 · 새 비밀번호 · 2단계 켜기) · 메일(목록 · 읽기 · 쓰기 · 첨부 · 검색 · 이동 · 삭제) · 새 메일 알림(안드로이드) | main 에 합침 — 서버 배포 뒤 직원 배포 |
| 사내 배포 | 스토어 없이 우리 키로 서명한 APK 를 포털의 휴대폰 앱 받기 화면에서 | main 에 합침 — 서명 키 준비됨 |
| 앱 1단계 | 로그인(코드 · 새 비밀번호 · 2단계 켜기) · 메일(목록 · 읽기 · 쓰기 · 첨부 · 검색 · 이동 · 삭제) · 새 메일 알림(안드로이드) | 운영 — 1.0.1(2026-11-12) |
| 사내 배포 | 스토어 없이 우리 키로 서명한 APK 를 포털의 휴대폰 앱 받기 화면에서 | 운영 — 1.0.1 을 올림(2026-11-12) |
| 다음 판 후보 | 주소록 · 대용량 첨부 · 필터 · 서식 편집 · 예약 메일 [지금 보내기] | 첫 판 반응을 보고 고른다 |
## 추후 후보 (필요해지면)
+3 -2
View File
@@ -137,8 +137,9 @@ flutter build apk --release
Copy-Item build\app\outputs\flutter-apk\app-release.apk "$env:USERPROFILE\Desktop\ARA-SYSTEM-1.0.1.apk" # 이름은 ARA-SYSTEM-<버전>.apk
```
- 나오는 파일은 `build/app/outputs/flutter-apk/app-release.apk` 하나다(모든 ABI — arm64-v8a · armeabi-v7a · x86_64). 저장소 밖으로 옮기며 이름을 `ARA-SYSTEM-<버전>.apk` 로 바꾼다(받기 화면이 주는 파일 이름과 같은 꼴).
- 올리기 전에 본다:
- 나오는 파일은 `build/app/outputs/flutter-apk/app-release.apk` 하나다(모든 ABI — arm64-v8a · armeabi-v7a · x86_64 — 그래서 1.0.0 이 64.4MB 다). 저장소 밖으로 옮기며 이름을 `ARA-SYSTEM-<버전>.apk` 로 바꾼다(받기 화면이 주는 파일 이름과 같은 꼴).
- 올리기 전에 본다 — `apksigner` 는 Java 로 돈다. PATH 에 `java` 가 없으면 결과 없이 끝나니(2026-11-11 개발 PC) 먼저 Android Studio 의 Java 를 잡는다:
`$env:JAVA_HOME = "C:\Program Files\Android\Android Studio\jbr"; $env:Path = "$env:JAVA_HOME\bin;$env:Path"`
- 우리 키로 서명했는지 — `& "$env:LOCALAPPDATA\Android\Sdk\build-tools\36.1.0\apksigner.bat" verify --print-certs <파일>` 의 `certificate DN` 이 keytool 에 넣은 이름이다(`CN=Android Debug` 면 debug 키다).
- 파일 안의 판이 파일 이름과 같은지 — `& "$env:LOCALAPPDATA\Android\Sdk\build-tools\36.1.0\aapt2.exe" dump badging <파일> | Select-String "^package:"` 의
`versionName` 이 파일 이름의 버전, `versionCode` 가 `pubspec.yaml` 의 `+` 뒤 숫자다(예 `versionCode='2' versionName='1.0.1'`).
+2 -2
View File
@@ -171,10 +171,10 @@ abstract final class AppSize {
/// 쓰기 화면의 첨부 줄 파일 아이콘 칸
static const double composeFileIconBox = 36;
/// 본문 칸의 최소 높이 — 새 메일(원본 메일이 아래에 없다)
/// 본문 칸의 최소 높이 — 원본 메일이 아래에 없는 쓰기(새 메일 등 — 서명은 있어도)
static const double composeBodyMinHeight = 240;
/// 본문 칸의 최소 높이 — 답장 · 전달(아래에 서명 · 원본 메일)
/// 본문 칸의 최소 높이 — 답장 · 전달(아래에 원본 메일)
static const double composeBodyMinHeightWithQuote = 150;
/// 원본 메일 접힌 줄의 최소 높이
+28
View File
@@ -231,6 +231,34 @@ abstract final class AppTheme {
);
}
/// 테두리 · 바탕 · 안쪽 여백이 없는 입력칸 꾸밈 — 칸을 담은 줄이 아래 선 · 초점 표시를 따로 그리는 칸
/// (쓰기 화면의 받는 사람 · 제목 · 본문 — `ComposeFieldRow`, 메일 검색 막대).
///
/// `InputDecoration.collapsed` 는 쓰지 않는다 — collapsed 는 `border` 만 없애고 `enabledBorder` · `focusedBorder` 등을 비워 두는데,
/// TextField 가 그 빈 칸을 테마의 입력칸 테두리(위 `inputDecorationTheme` — 둥근 테두리 · 초점은 청록 2)로 채우고, InputDecorator 는
/// `border` 보다 이 칸들을 먼저 쓴다. 그래서 여섯 테두리를 모두 [InputBorder.none] 으로 적는다
/// (2026-11-12 — 쓰기 화면의 칸마다 글자를 바짝 감싼 둥근 테두리가 그려지고, 초점이 오면 칸 줄의 아래 선과 청록 테두리가 겹쳤다).
/// 여백 · 높이는 collapsed 와 같다(안쪽 여백 0 · `isCollapsed`). [hint] 와 [hintText] 는 하나만 준다
InputDecoration borderlessInputDecoration({
String? hintText,
TextStyle? hintStyle,
Widget? hint,
}) => InputDecoration(
hintText: hintText,
hintStyle: hintStyle,
hint: hint,
isCollapsed: true,
isDense: false,
contentPadding: EdgeInsets.zero,
filled: false,
border: InputBorder.none,
enabledBorder: InputBorder.none,
focusedBorder: InputBorder.none,
disabledBorder: InputBorder.none,
errorBorder: InputBorder.none,
focusedErrorBorder: InputBorder.none,
);
/// Material 색 역할에 없는 웹 토큰 — `Theme.of(context).extension<AppExtraColors>()!` 로 쓴다
@immutable
class AppExtraColors extends ThemeExtension<AppExtraColors> {
+56
View File
@@ -0,0 +1,56 @@
/// 글에 보이는 글자가 있는가 — 순수 함수 (모듈 공통 — 메일 본문이 비었는지 등).
///
/// 아래 글자만 있는 글은 보이는 것이 없다(빈 글이다):
/// - 공백: Dart `trim()` 이 지우는 공백 전부 — 빈칸 · 탭 · 줄바꿈 · nbsp · 전각 빈칸 · 줄 · 문단 구분 · BOM 등
/// - 제어 문자: C0 · DEL · C1
/// - 보이지 않는 글자: 유니코드의 Default_Ignorable_Code_Point — 제로폭 빈칸 · 이음(ZWNJ · ZWJ · WORD JOINER) · 방향 표시(LRM · RLM · 끼워 넣기 · 격리) ·
/// soft hyphen · 이체 선택자 · 한글 채움 문자(U+115F · U+1160 · U+3164 · U+FFA0) · 태그 글자 등
/// - 점자 빈칸(U+2800) — 글자이지만 빈칸으로 그려진다
library;
/// [text] 에 보이는 글자가 하나라도 있는가 — 앞에서부터 보고 처음 보이는 글자에서 멈춘다(길이에 비례)
bool hasVisibleText(String text) {
for (final int rune in text.runes) {
if (!_isInvisible(rune)) return true;
}
return false;
}
/// 보이지 않는 글자인가 — 위 머리말의 목록
bool _isInvisible(int rune) {
// C0 제어 문자 · 빈칸
if (rune <= 0x20) return true;
// 그 밖의 ASCII 는 모두 보인다
if (rune < 0x7F) return false;
// DEL · C1 제어 문자(NEL 포함) · nbsp
if (rune <= 0xA0) return true;
for (final (int first, int last) in _invisibleRanges) {
if (rune < first) return false;
if (rune <= last) return true;
}
return false;
}
/// 보이지 않는 글자의 범위 — (처음, 끝) 끝 포함 · 작은 차례대로(U+00A0 까지는 위에서 따로 본다)
const List<(int, int)> _invisibleRanges = <(int, int)>[
(0x00AD, 0x00AD), // soft hyphen
(0x034F, 0x034F), // 글자 묶음 이음(CGJ)
(0x061C, 0x061C), // 아랍 글자 방향 표시
(0x115F, 0x1160), // 한글 초성 · 중성 채움 문자
(0x1680, 0x1680), // 오검 빈칸
(0x17B4, 0x17B5), // 크메르의 소리 없는 모음
(0x180B, 0x180F), // 몽골 이체 선택자 · 모음 구분
(0x2000, 0x200F), // 너비가 다른 빈칸 · 제로폭 빈칸 · 이음 · 방향 표시
(0x2028, 0x202F), // 줄 · 문단 구분 · 방향 끼워 넣기 · 좁은 nbsp
(0x205F, 0x206F), // 수학 빈칸 · WORD JOINER · 보이지 않는 연산자 · 방향 격리
(0x2800, 0x2800), // 점자 빈칸
(0x3000, 0x3000), // 전각 빈칸
(0x3164, 0x3164), // 한글 채움 문자
(0xFE00, 0xFE0F), // 이체 선택자
(0xFEFF, 0xFEFF), // BOM(제로폭 nbsp)
(0xFFA0, 0xFFA0), // 반각 한글 채움 문자
(0xFFF0, 0xFFF8), // 보이지 않게 정해 둔 빈 자리
(0x1BCA0, 0x1BCA3), // 속기 서식 제어
(0x1D173, 0x1D17A), // 악보 서식 제어
(0xE0000, 0xE0FFF), // 태그 글자 · 이체 선택자 보충
];
+51 -18
View File
@@ -11,8 +11,11 @@
///
/// 외부 이미지를 보일 때(서버가 중계 주소 `/api/mail-images/<표>` 로 바꿔 넣은 본문 — imagesShown)도 WebView 는 바깥에 나가지 않는다 —
/// 앱이 Dio(Bearer)로 그림을 받아 `data:` 로 바꿔 넣는다([resolveProxiedImages]). 토큰이 WebView 에 가지 않는다.
///
/// 정화하며 본문에 보이는 것이 있는지도 함께 본다([SanitizedMailBody.hasVisibleContent]) — 없으면 읽기 화면이 본문 칸을 그리지 않는다.
library;
import 'package:arasystem_app/core/utils/visible_text.dart';
import 'package:arasystem_app/features/mail/domain/mail_images.dart';
import 'package:arasystem_app/features/mail/domain/search_highlight.dart';
import 'package:html/dom.dart' as dom;
@@ -67,6 +70,11 @@ const Set<String> _droppedWithContent = <String>{
/// 내용이 없는 태그 — 닫는 태그를 쓰지 않는다
const Set<String> _voidTags = <String>{'br', 'col', 'hr', 'img', 'wbr'};
/// 남기는 태그 가운데 글 없이도 그려지는 것 — 가로줄 · 목록 표시(• · 1.) · 펼침 표시(▶ 와 기본 이름) · 따옴표.
/// 그림(`img`)은 주소가 남았을 때만 보인다(주소를 지운 그림은 문서가 숨긴다 — [buildMailDocument]) — 따로 본다.
/// 모양 속성(style · bgcolor · border)만으로 그린 상자 · 선 · 바탕과 빈 인용의 왼쪽 막대는 보이는 것으로 치지 않는다 — 글도 그림도 없는 꾸밈뿐이다
const Set<String> _drawnWithoutText = <String>{'hr', 'li', 'details', 'q'};
/// 어느 태그에나 남기는 속성 — 모양(style) · 표 · 글꼴의 옛 속성. 이벤트 처리기 · data-* · id · target · 외부 자원 속성은 없다
const Set<String> _globalAttributes = <String>{
'style', 'class', 'title', 'dir', 'lang', 'align', 'valign', 'width', //
@@ -109,6 +117,7 @@ class SanitizedMailBody {
const SanitizedMailBody({
required this.fragment,
required this.proxiedTokens,
required this.hasVisibleContent,
this.htmlOmitted = false,
});
@@ -118,6 +127,12 @@ class SanitizedMailBody {
/// 받아 넣을 중계 그림의 표 — 문서 차례 · 겹치지 않게 · [kMaxProxiedImages] 까지(외부 이미지를 보일 때만)
final List<String> proxiedTokens;
/// 본문에 보이는 것이 있다 — 공백 · 보이지 않는 글자가 아닌 글자([hasVisibleText]), 주소가 남은 그림, 글 없이도 그려지는 태그
/// (가로줄 · 목록 표시 · 펼침 표시 · 따옴표). 거짓이면 읽기 화면이 본문 칸(WebView)을 그리지 않는다.
///
/// 중계 그림은 받기 전에도 보이는 것으로 친다 — 하나도 받지 못하면 빈 본문 칸이 남는다(본문을 다시 보지 않는다)
final bool hasVisibleContent;
/// 서식 본문이 앱의 정화 한도(길이 · 중첩)를 넘어 글자 본문을 보인다 — 화면은 서버의 생략과 같은 안내를 보인다
final bool htmlOmitted;
}
@@ -130,7 +145,10 @@ String? displayedHtmlBody(String? htmlBody) =>
/// [imagesShown] 이면 서버의 중계 주소 그림을 남긴다(그 밖의 바깥 주소 · 상대 경로 그림은 어느 때나 지운다).
/// 서식 본문이 길이 · 중첩 한도([kMaxSanitizedHtmlLength] · [kMaxHtmlNestingCost])를 넘으면 정화하지 않고 글자 본문을 보인다.
/// [textHighlights] 는 글 본문의 검색에서 맞은 곳(검색 결과에서 연 메일 — 14단계) — 글 본문을 보일 때만 `<mark>` 로 넣는다
/// (서식 본문은 서버가 정화한 뒤 맞은 글 조각에 `<mark>` 를 넣어 보냈고, 앱의 정화가 그대로 남긴다)
/// (서식 본문은 서버가 정화한 뒤 맞은 글 조각에 `<mark>` 를 넣어 보냈고, 앱의 정화가 그대로 남긴다).
///
/// 고른 본문에 보이는 것이 있는지([SanitizedMailBody.hasVisibleContent])도 함께 낸다 — 고르는 규칙은 그대로다(서식 본문이 비어 보여도
/// 글자 본문으로 바꾸지 않는다 — 웹 `displayedHtmlBody` 와 같은 본문을 보인다)
SanitizedMailBody mailBodyFragment({
required String? htmlBody,
required String? textBody,
@@ -162,6 +180,7 @@ SanitizedMailBody _textFragment(
fragment:
'<div class="mail-text">${highlightedTextHtml(textBody ?? '', highlights)}</div>',
proxiedTokens: const <String>[],
hasVisibleContent: hasVisibleText(textBody ?? ''),
htmlOmitted: htmlOmitted,
);
@@ -316,15 +335,22 @@ const Set<String> _htmlVoidTags = <String>{
/// 허용 목록 밖의 태그는 태그만 지우고 글은 남긴다(`<o:p>` · `<form>` · `<button>` 등), [_droppedWithContent] 는 내용째 버린다.
/// HTML 이 아닌 이름공간(SVG · MathML)은 내용째 버린다. 주석은 지운다. 깊은 중첩에도 스택이 넘치지 않게 되풀이로 돈다.
/// 문서 전체로 읽어 `<body>` 아래만 쓴다 — 조각으로 읽으면(`parseFragment`) 맨 위 요소가 많을 때 파서가 옮기며 제곱으로 느려진다
/// (2026-11-06 측정: 맨 위 span 4만 개 10초 → 문서로 0.3초). 머리에 들어가는 태그(meta · title · style 등)는 어차피 버린다
/// (2026-11-06 측정: 맨 위 span 4만 개 10초 → 문서로 0.3초). 머리에 들어가는 태그(meta · title · style 등)는 어차피 버린다.
/// 쓰는 동안 보이는 것이 있는지([SanitizedMailBody.hasVisibleContent])도 본다 — 남긴 글 · 주소가 남은 그림 · [_drawnWithoutText] 만
/// (내용째 버린 것 · 주석은 보이지 않는다). 처음 찾으면 더 보지 않는다
SanitizedMailBody sanitizeMailHtml(String html, {required bool imagesShown}) {
final dom.Element? body = html_parser.parse(html).body;
final StringBuffer out = StringBuffer();
final List<String> tokens = <String>[];
final Set<String> seen = <String>{};
if (body == null) {
return const SanitizedMailBody(fragment: '', proxiedTokens: <String>[]);
return const SanitizedMailBody(
fragment: '',
proxiedTokens: <String>[],
hasVisibleContent: false,
);
}
bool visible = false;
// 되풀이 깊이 우선 — 닫는 태그는 글로 쌓는다
final List<Object> stack = <Object>[..._reversed(body.nodes)];
@@ -337,6 +363,7 @@ SanitizedMailBody sanitizeMailHtml(String html, {required bool imagesShown}) {
final dom.Node node = item as dom.Node;
if (node is dom.Text) {
out.write(escapeHtmlText(node.data));
if (!visible) visible = hasVisibleText(node.data);
continue;
}
if (node is! dom.Element) continue;
@@ -348,24 +375,29 @@ SanitizedMailBody sanitizeMailHtml(String html, {required bool imagesShown}) {
stack.addAll(_reversed(node.nodes));
continue;
}
final String attributes = _attributesOf(
node,
name,
imagesShown: imagesShown,
onProxied: (String token) {
if (seen.contains(token)) return true;
if (tokens.length >= kMaxProxiedImages) return false;
seen.add(token);
tokens.add(token);
return true;
},
);
out
..write('<')
..write(name)
..write(
_attributesOf(
node,
name,
imagesShown: imagesShown,
onProxied: (String token) {
if (seen.contains(token)) return true;
if (tokens.length >= kMaxProxiedImages) return false;
seen.add(token);
tokens.add(token);
return true;
},
),
)
..write(attributes)
..write('>');
// 그림은 주소가 남았을 때만 보인다 — 남긴 주소는 늘 맨 앞에 쓴다([_attributesOf])
if (!visible &&
(_drawnWithoutText.contains(name) ||
(name == 'img' && attributes.startsWith(' src=')))) {
visible = true;
}
if (_voidTags.contains(name)) continue;
stack
..add('</$name>')
@@ -374,6 +406,7 @@ SanitizedMailBody sanitizeMailHtml(String html, {required bool imagesShown}) {
return SanitizedMailBody(
fragment: out.toString(),
proxiedTokens: List<String>.unmodifiable(tokens),
hasVisibleContent: visible,
);
}
@@ -381,7 +414,7 @@ const String _htmlNamespace = 'http://www.w3.org/1999/xhtml';
Iterable<dom.Node> _reversed(List<dom.Node> nodes) => nodes.reversed;
/// 남길 속성을 ` 이름="값"` 꼴로 — 그림은 `src` 를, 링크는 `href` 를 맨 앞에 쓴다.
/// 남길 속성을 ` 이름="값"` 꼴로 — 그림은 `src` 를, 링크는 `href` 를 맨 앞에 쓴다(보이는 그림인지 가르기 · [resolveProxiedImages] 가 이 모양에 기댄다).
/// [onProxied] 는 남길 중계 그림이면 참(한 통의 한도를 넘으면 거짓 — 그 그림은 주소를 지운다)
String _attributesOf(
dom.Element element,
@@ -489,9 +489,7 @@ class _MessageComposePageState extends ConsumerState<MessageComposePage> {
focusNode: _bodyFocus,
enabled: enabled,
error: lengthError == ComposeLengthError.body,
minHeight: quote != null || signature != null
? AppSize.composeBodyMinHeightWithQuote
: AppSize.composeBodyMinHeight,
minHeight: composeBodyMinHeightOf(hasQuote: quote != null),
),
if (lengthError == ComposeLengthError.body)
ComposeAlert(
@@ -556,6 +554,12 @@ class _MessageComposePageState extends ConsumerState<MessageComposePage> {
}
}
/// 본문 칸의 최소 높이 — 원본 메일(인용)이 아래에 있으면(답장 · 전달) 낮게(150), 없으면(새 메일 등) 서명이 있어도 높게(240).
/// 보드 `App1Compose` 의 「새 메일 · 받는 사람 추천」(서명 있음 — 240)과 「답장 · 참조 펼침 · 첨부 · 원본 접힘」(서명 · 원본 메일 — 150)
double composeBodyMinHeightOf({required bool hasQuote}) => hasQuote
? AppSize.composeBodyMinHeightWithQuote
: AppSize.composeBodyMinHeight;
/// 올리지 못한 고른 파일의 수
int failedUploadsOf(ComposeState state) => state.attachments
.where(
@@ -627,7 +631,6 @@ class _SubjectRow extends StatelessWidget {
@override
Widget build(BuildContext context) {
final ThemeData theme = Theme.of(context);
final AppExtraColors extra = theme.extension<AppExtraColors>()!;
final TextStyle? style = theme.textTheme.bodyLarge?.copyWith(
fontWeight: AppTypography.semiBold,
);
@@ -653,11 +656,13 @@ class _SubjectRow extends StatelessWidget {
Utf16LengthLimitFormatter(kComposeMaxSubject),
],
style: style,
decoration: InputDecoration.collapsed(
// 테두리는 칸 줄(ComposeFieldRow)의 아래 선뿐이다 — 테마의 입력칸 테두리를 물려받지 않게
// 안내 글은 보드의 placeholder 색(#8A94A3 — 테마의 outline, 검색 칸 안내 글과 같다)
decoration: borderlessInputDecoration(
hintText: MailComposeTexts.subjectHint,
hintStyle: style?.copyWith(
fontWeight: AppTypography.regular,
color: extra.textMuted,
color: theme.colorScheme.outline,
),
),
onSubmitted: (String _) => onSubmitted(),
@@ -669,7 +674,7 @@ class _SubjectRow extends StatelessWidget {
}
/// 본문 칸 — 글만(서식 편집은 다음 판). 비어 있는 아래쪽을 눌러도 칸에 초점이 간다.
/// 아래에 서명 · 원본 메일이 있으면 낮게(150), 없으면 높게(240) 둔다(보드)
/// 아래에 원본 메일이 있으면 낮게(150), 없으면 서명이 있어도 높게(240) 둔다(보드 — [composeBodyMinHeightOf])
class _BodyField extends StatelessWidget {
const _BodyField({
required this.controller,
@@ -718,9 +723,9 @@ class _BodyField extends StatelessWidget {
textInputAction: TextInputAction.newline,
maxLines: null,
style: style,
decoration: InputDecoration.collapsed(
decoration: borderlessInputDecoration(
hintText: MailComposeTexts.bodyHint,
hintStyle: style?.copyWith(color: extra.textMuted),
hintStyle: style?.copyWith(color: theme.colorScheme.outline),
),
),
),
@@ -1,4 +1,5 @@
import 'dart:async';
import 'dart:math' as math;
import 'package:arasystem_app/core/api/api_exception.dart';
import 'package:arasystem_app/core/api/dio_provider.dart';
@@ -33,7 +34,8 @@ import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
/// 머리(제목 · 보낸 사람 · 안내 줄 · 첨부)가 차지할 수 있는 최대 높이 — 남은 자리의 이만큼. 넘으면 머리 안에서 스크롤한다
/// 머리(제목 · 보낸 사람 · 안내 줄 · 첨부)가 차지할 수 있는 최대 높이 — 남은 자리의 이만큼. 넘으면 머리 안에서 스크롤한다.
/// 본문에 보이는 것이 없으면(본문 칸이 없다) 이 제한 없이 화면 전체를 쓴다
const double kReaderHeadMaxFraction = 0.5;
/// 메일 읽기 — 앱 보드 `App1Reader` (앱 디자인 README 「읽기」). 탭 막대 없는 전체 화면이다.
@@ -42,6 +44,8 @@ const double kReaderHeadMaxFraction = 0.5;
/// 붙잡힌 메일(보내기 취소를 기다림 · 예약)에는 이동 · 스팸이 없고 [삭제]는 확인 뒤 발송을 취소하고 휴지통으로.
/// - 머리는 앱 화면, 본문은 WebView(`MailBodyView`) — 머리 아래 남은 자리를 다 쓰고 본문은 그 안에서 스크롤한다.
/// 머리가 길면(펼친 받는 사람 · 첨부 여럿) 남은 자리의 절반까지만 쓰고 머리 안에서 스크롤한다.
/// **본문에 보이는 것이 없으면**(첨부만 있는 메일 등 — 상태의 `bodyEmpty`) 본문 칸을 두지 않고 머리가 화면 전체를 한 스크롤로 쓴다
/// (2026-11-12 — 아래 절반이 빈 칸으로 남고 첨부 목록이 위 절반 안에서 잘렸다).
/// - **첨부 목록은 머리 안(안내 줄 아래 · 본문 위)이다** — 보드는 본문 아래지만, 본문이 WebView 안에서 스크롤하므로 아래에 두면
/// 늘 화면 아래 자리를 차지한다(쓰기 화면처럼 위에 둔다 — 앱에서 정함).
/// - 아래 행동 막대: 답장 · 전체 답장 · 전달 — 쓰기 화면으로 그 메일 id 를 넘긴다(`MailRoutes.composeReply` 등). 예약한 메일에는 없다(웹과 같다).
@@ -349,7 +353,8 @@ List<ReaderMenuItem> readerMenuOf(
];
}
/// 받은 메일 — 머리(남은 자리의 절반까지 · 넘으면 그 안에서 스크롤) + 본문 WebView(남은 자리 전부)
/// 받은 메일 — 머리(남은 자리의 절반까지 · 넘으면 그 안에서 스크롤) + 본문 WebView(남은 자리 전부).
/// 본문에 보이는 것이 없으면 본문 칸 없이 머리만(화면 전체까지 · 넘으면 그 안에서 스크롤)
class _ReaderContent extends ConsumerWidget {
const _ReaderContent({
required this.messageId,
@@ -383,16 +388,31 @@ class _ReaderContent extends ConsumerWidget {
final String? myEmail = ref.watch(mailAccountProvider);
final List<MessageAttachment> attachments = detail.listedAttachments;
final String? document = reader.document;
// 본문 칸 — 보이는 것이 없는 본문이면 두지 않는다. 문서를 만드는 동안(document 가 null)은 아직 모르므로 지금처럼 진행 줄을 둔다
final bool bodyShown = !reader.bodyEmpty;
return LayoutBuilder(
builder: (BuildContext context, BoxConstraints constraints) {
// 본문 칸이 없으면 머리가 화면 전체를 쓴다 — 그림 받는 진행 막대가 머리 아래에 들 자리만 남긴다
final double headMaxHeight = bodyShown
? constraints.maxHeight * kReaderHeadMaxFraction
: math.max(
0,
constraints.maxHeight -
(reader.imagesLoading ? AppSize.progressBarHeight : 0),
);
// 머리가 화면 아래 끝까지 닿으면(본문 칸이 없다) 시스템 내비게이션 막대 몫을 머리 끝에 더한다 — 마지막 첨부가 그 밑에 남지 않게.
// 아래 행동 막대가 있으면 Scaffold 가 이 값을 이미 0 으로 만든다(예약 메일처럼 막대가 없을 때만 남는다)
final double headBottomInset = bodyShown
? 0
: MediaQuery.paddingOf(context).bottom;
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: <Widget>[
// 머리는 두 짜임에서 같은 자리의 같은 위젯이다 — 빈 본문에서 [이미지 보기]로 본문 칸이 생겨도 펼친 받는 사람이 그대로 남는다
ConstrainedBox(
constraints: BoxConstraints(
maxHeight: constraints.maxHeight * kReaderHeadMaxFraction,
),
constraints: BoxConstraints(maxHeight: headMaxHeight),
child: SingleChildScrollView(
padding: EdgeInsets.only(bottom: headBottomInset),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: <Widget>[
@@ -438,14 +458,15 @@ class _ReaderContent extends ConsumerWidget {
minHeight: AppSize.progressBarHeight,
),
),
Expanded(
child: document == null
? const _BodyLoading()
: MailBodyView(
document: document,
onNavigation: onNavigation,
),
),
if (bodyShown)
Expanded(
child: document == null
? const _BodyLoading()
: MailBodyView(
document: document,
onNavigation: onNavigation,
),
),
],
);
},
@@ -101,6 +101,7 @@ messageReaderProvider = NotifierProvider.autoDispose
///
/// - 열면 안 읽은 메일을 읽음으로 바꾼다(화면 · 메일함 개수를 먼저 — 실패하면 알리지 않고 되돌린다).
/// - 본문은 두 번째 정화 뒤 CSP 를 건 문서로 만든다(`mail_html.dart` — 짧고 얕은 본문만 그 자리에서, 길거나 깊은 본문은 다른 isolate 에서).
/// 정화가 함께 낸 "보이는 것이 없다"([MessageReaderState.bodyEmpty] — 첨부만 있는 메일 등)도 같은 차례에 싣는다 — 화면은 본문 칸을 두지 않는다.
/// 외부 이미지를 보이는 본문이면(설정 · [이미지 보기]) 중계 그림을 Dio 로 받아(한 번에 [kMailImageConcurrency] 개 · 한도 `mail_images.dart`)
/// `data:` 로 넣고 문서를 한 번 더 만든다. 그림 받기는 화면이 사라지거나 본문이 바뀌면 끊는다(끊긴 그림은 숨기고 알리지 않는다).
/// - 옮기기 · 스팸 · 휴지통은 응답을 받은 뒤 목록으로 돌아가고(부르는 쪽이), 알림에 [되돌리기](원래 메일함 — 스팸은 표시도 반대로).
@@ -279,6 +280,8 @@ class MessageReaderController extends Notifier<MessageReaderState> {
state = state.copyWith(
document: _document(),
bodyOmitted: body.htmlOmitted,
// 보이는 것이 없는 본문이면 화면이 본문 칸을 두지 않는다 — 판정은 정화가 함께 냈다
bodyEmpty: !body.hasVisibleContent,
imagesLoading: hasImages,
);
if (!hasImages) return;
@@ -38,6 +38,7 @@ class MessageReaderState {
this.error,
this.document,
this.bodyOmitted = false,
this.bodyEmpty = false,
this.imagesLoading = false,
this.busy,
this.subjectHighlight,
@@ -52,6 +53,10 @@ class MessageReaderState {
/// WebView 에 넣을 문서(정화 · CSP 를 마쳤다) — 만들기 전이면 null
final String? document;
/// 보여 줄 본문에 보이는 것이 없다(첨부만 있는 메일 등 — 정화가 함께 낸 판정 `SanitizedMailBody.hasVisibleContent`).
/// 화면은 본문 칸(WebView)을 두지 않고 머리(제목 · 안내 줄 · 첨부)를 화면 전체에서 한 스크롤로 보인다. 문서를 만들기 전에는 거짓
final bool bodyEmpty;
/// 서식 본문이 앱의 정화 한도를 넘어 글자 본문을 보인다 — 서버의 생략(`isHtmlBodyOmitted`)과 같은 안내를 보인다
final bool bodyOmitted;
@@ -75,6 +80,7 @@ class MessageReaderState {
MessageDetail? detail,
String? document,
bool? bodyOmitted,
bool? bodyEmpty,
bool? imagesLoading,
MessageReaderBusy? busy,
bool clearBusy = false,
@@ -85,6 +91,7 @@ class MessageReaderState {
error: error,
document: document ?? this.document,
bodyOmitted: bodyOmitted ?? this.bodyOmitted,
bodyEmpty: bodyEmpty ?? this.bodyEmpty,
imagesLoading: imagesLoading ?? this.imagesLoading,
busy: clearBusy ? null : (busy ?? this.busy),
subjectHighlight: identical(subjectHighlight, _unset)
@@ -1,3 +1,5 @@
import 'dart:math' as math;
import 'package:arasystem_app/core/people/address_input.dart';
import 'package:arasystem_app/core/people/people_texts.dart';
import 'package:arasystem_app/core/people/recipient_match.dart';
@@ -192,6 +194,10 @@ class _ComposeRecipientFieldState extends State<ComposeRecipientField> {
error: invalid,
onTap: widget.enabled ? widget.focusNode.requestFocus : null,
trailing: widget.trailing,
// 칩이 있으면 첫 줄은 칩 줄 — 여러 줄이어도 칸 이름 · [참조 펼치기]가 첫 칩 줄에 맞는다
firstLineHeight: widget.tokens.isEmpty
? null
: AppSize.recipientChipHeight,
child: LayoutBuilder(
builder: (BuildContext context, BoxConstraints constraints) {
return Wrap(
@@ -263,7 +269,8 @@ class _ComposeRecipientFieldState extends State<ComposeRecipientField> {
smartQuotesType: SmartQuotesType.disabled,
inputFormatters: const <TextInputFormatter>[_LineBreaksToCommas()],
style: theme.textTheme.bodyLarge,
decoration: const InputDecoration.collapsed(hintText: ''),
// 테두리는 칸 줄(ComposeFieldRow)의 아래 선뿐이다 — 테마의 입력칸 테두리를 물려받지 않게
decoration: borderlessInputDecoration(hintText: ''),
onChanged: widget.onChanged,
onEditingComplete: _onNext,
),
@@ -342,7 +349,12 @@ class _LineBreaksToCommas extends TextInputFormatter {
}
}
/// 쓰기 화면의 칸 줄 — 왼쪽 칸 이름(72) · 가운데 칸 · 오른쪽 단추, 아래 선(초점이 있으면 청록 2) (보드 `App1Compose`)
/// 쓰기 화면의 칸 줄 — 왼쪽 칸 이름(72 · 글자 15) · 간격 6 · 가운데 칸 · 오른쪽 단추, 아래 선(초점이 있으면 청록 2)
/// (보드 `App1Compose` — 최소 높이 56 · 여백 위 4 · 오른쪽 4 · 아래 4 · 왼쪽 16).
///
/// 칸 이름과 오른쪽 단추는 **첫 줄**([lineHeight] — 최소 높이에서 위아래 여백을 뺀 48)의 세로 가운데에 둔다 —
/// 받는 사람 칩이 여러 줄이어도 첫 줄에 남는다(줄 전체의 가운데로 내려가지 않게 — 2026-11-12). 칸이 한 줄이면 줄의 가운데 그대로다.
/// 아래 선은 자리를 차지하지 않게 그린다 — 초점 · 오류로 굵어져도 칸이 움직이지 않는다(보드의 inset 그림자와 같다)
class ComposeFieldRow extends StatelessWidget {
const ComposeFieldRow({
required this.label,
@@ -351,9 +363,14 @@ class ComposeFieldRow extends StatelessWidget {
this.error = false,
this.trailing,
this.onTap,
this.firstLineHeight,
super.key,
});
/// 첫 줄의 높이 — 줄의 최소 높이(56)에서 위아래 여백(4)을 뺀 자리
static const double lineHeight =
AppSize.composeRowMinHeight - AppSpacing.s4 * 2;
final String label;
final Widget child;
final bool focused;
@@ -363,12 +380,20 @@ class ComposeFieldRow extends StatelessWidget {
final Widget? trailing;
final VoidCallback? onTap;
/// 칸 안 첫 줄의 높이(받는 사람 칩 40) — 주면 칸을 위아래로 ([lineHeight] − 이 값) / 2 씩 띄워,
/// 칩이 여러 줄이어도 첫 칩 줄이 칸 이름 · 오른쪽 단추와 같은 높이에 온다(한 줄이면 줄을 꼭 채워 지금처럼 가운데)
final double? firstLineHeight;
@override
Widget build(BuildContext context) {
final ThemeData theme = Theme.of(context);
final ColorScheme scheme = theme.colorScheme;
final AppExtraColors extra = theme.extension<AppExtraColors>()!;
final Widget? action = trailing;
final double? inner = firstLineHeight;
final double inset = inner == null
? 0
: math.max(0, (lineHeight - inner) / 2);
return GestureDetector(
behavior: HitTestBehavior.translucent,
onTap: onTap,
@@ -376,13 +401,13 @@ class ComposeFieldRow extends StatelessWidget {
constraints: const BoxConstraints(
minHeight: AppSize.composeRowMinHeight,
),
padding: EdgeInsets.fromLTRB(
padding: const EdgeInsets.fromLTRB(
AppSpacing.s16,
AppSpacing.s4,
action == null ? AppSpacing.s16 : AppSpacing.s4,
AppSpacing.s4,
AppSpacing.s4,
),
decoration: BoxDecoration(
foregroundDecoration: BoxDecoration(
border: Border(
bottom: BorderSide(
color: error
@@ -395,20 +420,32 @@ class ComposeFieldRow extends StatelessWidget {
),
),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
SizedBox(
width: AppSize.composeLabelWidth,
child: ExcludeSemantics(
child: Text(
label,
style: theme.textTheme.bodySmall?.copyWith(
color: extra.textMuted,
_FirstLine(
child: SizedBox(
width: AppSize.composeLabelWidth,
child: ExcludeSemantics(
child: Text(
label,
style: theme.textTheme.bodyMedium?.copyWith(
color: extra.textMuted,
),
),
),
),
),
Expanded(child: child),
?action,
const SizedBox(width: AppSpacing.s6),
Expanded(
child: _FirstLine(
stretch: true,
child: Padding(
padding: EdgeInsets.symmetric(vertical: inset),
child: child,
),
),
),
if (action != null) _FirstLine(child: action),
],
),
),
@@ -416,6 +453,31 @@ class ComposeFieldRow extends StatelessWidget {
}
}
/// 칸 줄의 첫 줄 자리 — [ComposeFieldRow.lineHeight] 안의 세로 가운데에 둔다. 더 높으면(칩이 여러 줄 · 큰 글자) 위에서부터
class _FirstLine extends StatelessWidget {
const _FirstLine({required this.child, this.stretch = false});
final Widget child;
/// 폭을 다 쓴다(가운데 칸)
final bool stretch;
@override
Widget build(BuildContext context) {
return ConstrainedBox(
constraints: const BoxConstraints(minHeight: ComposeFieldRow.lineHeight),
child: Column(
mainAxisSize: MainAxisSize.min,
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: stretch
? CrossAxisAlignment.stretch
: CrossAxisAlignment.start,
children: <Widget>[child],
),
);
}
}
/// 받는 사람 칩 — 이름(없으면 주소) · ✕. 형식이 틀린 주소는 빨갛게
class _RecipientChip extends StatelessWidget {
const _RecipientChip({required this.token, required this.onRemove});
@@ -427,7 +489,6 @@ class _RecipientChip extends StatelessWidget {
Widget build(BuildContext context) {
final ThemeData theme = Theme.of(context);
final ColorScheme scheme = theme.colorScheme;
final AppExtraColors extra = theme.extension<AppExtraColors>()!;
final String? name = token.address.name?.trim();
final String shown = !token.valid
? token.label
@@ -470,7 +531,8 @@ class _RecipientChip extends StatelessWidget {
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
),
iconSize: AppSize.iconTiny,
color: token.valid ? extra.textStrong : scheme.error,
// 보드의 ✕ #4A5363 — 보조 글자색(onSurfaceVariant)
color: token.valid ? scheme.onSurfaceVariant : scheme.error,
tooltip: MailComposeTexts.remove(shown),
onPressed: onRemove,
icon: const Icon(Icons.close),
@@ -18,6 +18,7 @@ import 'package:webview_flutter_android/webview_flutter_android.dart';
/// - 토큰은 WebView 에 가지 않는다(Bearer 는 Dio 만 — 그림 · 첨부는 앱이 받는다).
///
/// 높이: 머리(앱 화면) 아래 남은 자리를 다 쓰고 본문은 이 안에서 스크롤한다(앱 디자인 결정 — 스크립트가 꺼져 내용 높이를 잴 수 없다).
/// 그래서 보이는 것이 없는 본문(첨부만 있는 메일 등)이면 읽기 화면이 이 칸을 아예 두지 않는다(`SanitizedMailBody.hasVisibleContent`).
class MailBodyView extends StatefulWidget {
const MailBodyView({
required this.document,
@@ -94,8 +94,8 @@ class MailSearchBar extends StatelessWidget implements PreferredSizeWidget {
Utf16LengthLimitFormatter(kMailSearchMaxLength),
],
style: style,
decoration: InputDecoration.collapsed(
hintText: null,
// 위 막대 안의 칸 — 테마의 입력칸 테두리를 물려받지 않게
decoration: borderlessInputDecoration(
hint: ExcludeSemantics(
child: Text(
MailSearchTexts.box,
+1 -1
View File
@@ -6,7 +6,7 @@ publish_to: 'none'
# 앱 판 — build-name(1.0.0)은 Android versionName · iOS CFBundleShortVersionString,
# build-number(+1)는 Android versionCode · iOS CFBundleVersion 이 된다.
# 서버에 보내는 User-Agent 의 판(ARASYSTEM-App/<판>)도 build-name 이다 (core/api/user_agent.dart).
version: 1.0.0+1
version: 1.0.1+2
environment:
# Flutter 3.41.9 의 Dart — 판 고정은 .claude/skills/flutter/SKILL.md "Flutter 판"
@@ -0,0 +1,131 @@
import 'dart:io';
import 'package:arasystem_app/core/theme/app_theme.dart';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import '../../support/input_borders.dart';
/// 테두리 없는 입력칸 꾸밈 — 칸 줄이 아래 선 · 초점을 따로 그리는 칸(쓰기 화면 · 검색 막대)이 앱 테마의 둥근 테두리를 물려받지 않는다
void main() {
final ThemeData theme = AppTheme.light();
test(
'앱 테마를 입혀도 여섯 테두리가 모두 없고 바탕 · 안쪽 여백도 없다 — collapsed 는 테마의 둥근 테두리를 물려받는다',
() {
final InputDecoration bare = borderlessInputDecoration(
hintText: '제목을 입력해 주세요',
).applyDefaults(theme.inputDecorationTheme);
for (final InputBorder? border in <InputBorder?>[
bare.border,
bare.enabledBorder,
bare.focusedBorder,
bare.disabledBorder,
bare.errorBorder,
bare.focusedErrorBorder,
]) {
expect(border, InputBorder.none);
}
expect(bare.filled, isFalse);
expect(bare.contentPadding, EdgeInsets.zero);
expect(bare.isCollapsed, isTrue);
expect(bare.hintText, '제목을 입력해 주세요');
// 까닭 — collapsed 는 border 만 없애고 비워 둔 칸을 TextField 가 테마의 둥근 테두리로 채운다
final InputDecoration collapsed = const InputDecoration.collapsed(
hintText: '제목을 입력해 주세요',
).applyDefaults(theme.inputDecorationTheme);
expect(collapsed.border, InputBorder.none);
expect(collapsed.enabledBorder, isA<OutlineInputBorder>());
expect(collapsed.focusedBorder, isA<OutlineInputBorder>());
},
);
/// 앱 테마로 칸 하나를 그린다 — [enabled] 가 거짓이면 꺼진 칸
Future<FocusNode> pumpField(
WidgetTester tester,
InputDecoration decoration, {
bool enabled = true,
}) async {
final FocusNode focusNode = FocusNode();
addTearDown(focusNode.dispose);
await tester.pumpWidget(
MaterialApp(
theme: theme,
home: Scaffold(
body: TextField(
key: const ValueKey<String>('field'),
focusNode: focusNode,
enabled: enabled,
decoration: decoration,
),
),
),
);
return focusNode;
}
final Finder field = find.byKey(const ValueKey<String>('field'));
testWidgets('앱 테마로 그린 칸 — 평소 · 초점 · 꺼짐 어느 때나 InputDecorator 가 받는 테두리가 없다', (
WidgetTester tester,
) async {
final FocusNode focusNode = await pumpField(
tester,
borderlessInputDecoration(hintText: '제목'),
);
InputDecorator decorator = expectBorderlessInput(
tester,
field,
reason: '평소',
);
expect(decorator.isFocused, isFalse);
expect(decorator.decoration.enabled, isTrue);
focusNode.requestFocus();
await tester.pump();
decorator = expectBorderlessInput(tester, field, reason: '초점');
expect(decorator.isFocused, isTrue);
await pumpField(tester, borderlessInputDecoration(), enabled: false);
decorator = expectBorderlessInput(tester, field, reason: '꺼짐');
expect(decorator.decoration.enabled, isFalse);
});
testWidgets('견줌 — collapsed 로 그린 칸은 평소에 회색, 초점에 청록 둥근 테두리를 그린다(이 시험이 잡는 것)', (
WidgetTester tester,
) async {
final FocusNode focusNode = await pumpField(
tester,
const InputDecoration.collapsed(hintText: '제목'),
);
InputDecorator decorator() => tester.widget<InputDecorator>(
find.descendant(of: field, matching: find.byType(InputDecorator)),
);
expect(isDrawnInputBorder(shownInputBorder(decorator())), isTrue);
focusNode.requestFocus();
await tester.pump();
final InputBorder? focused = shownInputBorder(decorator());
expect(isDrawnInputBorder(focused), isTrue);
expect(focused!.borderSide.color, theme.colorScheme.primary);
});
test(
'lib 의 입력칸은 InputDecoration.collapsed 를 쓰지 않는다 — 테두리 없는 칸은 borderlessInputDecoration',
() {
final List<String> found = <String>[];
for (final FileSystemEntity entity in Directory(
'lib',
).listSync(recursive: true)) {
if (entity is! File || !entity.path.endsWith('.dart')) continue;
final List<String> lines = entity.readAsLinesSync();
for (int index = 0; index < lines.length; index++) {
if (lines[index].contains('InputDecoration.collapsed(')) {
found.add('${entity.path}:${index + 1}');
}
}
}
expect(found, isEmpty);
},
);
}
@@ -0,0 +1,111 @@
import 'package:arasystem_app/core/utils/visible_text.dart';
import 'package:flutter_test/flutter_test.dart';
/// 보이는 글자가 있는가 — 공백 · 제어 문자 · 보이지 않는 글자만 있으면 빈 글이다
void main() {
/// 글자 코드로 만든 글 — 소스에 보이지 않는 글자를 두지 않는다
String chars(List<int> codes) => String.fromCharCodes(codes);
test('Dart trim() 이 지우는 공백은 모두 보이지 않는다', () {
int blanks = 0;
for (int code = 0; code <= 0xFFFF; code++) {
// 홀로 선 서로게이트는 글자가 아니다
if (code >= 0xD800 && code <= 0xDFFF) continue;
final String char = String.fromCharCode(code);
if (char.trim().isNotEmpty) continue;
blanks++;
expect(
hasVisibleText(char),
isFalse,
reason: 'U+${code.toRadixString(16).toUpperCase()}',
);
}
// 유니코드 White_Space 25자 + BOM — 빈칸 · 탭 · 줄바꿈 · nbsp · 너비가 다른 빈칸 11자 · 줄 · 문단 구분 · 전각 빈칸 등
expect(blanks, 26);
});
test('빈 글 · 공백(nbsp · 전각 빈칸 · 줄 · 문단 구분 · BOM 등)뿐인 글은 보이는 것이 없다', () {
expect(hasVisibleText(''), isFalse);
expect(hasVisibleText(' \t\r\n'), isFalse);
for (final int code in <int>[
0x00A0, // nbsp
0x0085, // NEL
0x1680,
0x2002,
0x200A,
0x2028,
0x2029,
0x202F,
0x205F,
0x3000, // 전각 빈칸
0xFEFF, // BOM
]) {
expect(hasVisibleText(chars(<int>[0x20, code, 0x0A])), isFalse);
}
});
test(
'제어 문자 · 보이지 않는 글자(제로폭 · 이음 · 방향 표시 · 이체 선택자 · 한글 채움 · 태그 · 점자 빈칸)도 보이지 않는다',
() {
for (final int code in <int>[
0x0000,
0x001F,
0x007F,
0x009F,
0x00AD, // soft hyphen
0x034F, // CGJ
0x061C,
0x115F, // 한글 초성 채움
0x1160, // 한글 중성 채움
0x180E,
0x200B, // 제로폭 빈칸
0x200C, // ZWNJ
0x200D, // ZWJ
0x200E, // LRM
0x200F, // RLM
0x202A,
0x202E,
0x2060, // WORD JOINER
0x2066,
0x2069,
0x2800, // 점자 빈칸
0x3164, // 한글 채움 문자
0xFE0F, // 이체 선택자
0xFFA0, // 반각 한글 채움 문자
0x1BCA0,
0x1D173,
0xE0001, // 태그 글자
0xE0041,
0xE01EF, // 이체 선택자 보충
]) {
expect(
hasVisibleText(chars(<int>[code, code])),
isFalse,
reason: 'U+${code.toRadixString(16).toUpperCase()}',
);
}
},
);
test('글자 · 기호 · 숫자 · 이모지가 하나라도 있으면 보인다', () {
expect(hasVisibleText('가'), isTrue);
expect(hasVisibleText('a'), isTrue);
expect(hasVisibleText('.'), isTrue);
expect(hasVisibleText('~'), isTrue);
expect(hasVisibleText('0'), isTrue);
expect(hasVisibleText(chars(<int>[0x1F600])), isTrue);
// 이음(ZWJ)으로 묶은 이모지 — 이음 말고 그림 글자가 보인다
expect(hasVisibleText(chars(<int>[0x1F468, 0x200D, 0x1F469])), isTrue);
// 보이지 않는 글자들 뒤에 글자 하나
expect(
hasVisibleText(chars(<int>[0x00A0, 0x200B, 0xFEFF, 0x3164, 0x41])),
isTrue,
);
// 보이지 않는 글자의 범위 바로 옆 글자는 보인다
expect(hasVisibleText(chars(<int>[0x00A1])), isTrue); // ¡
expect(hasVisibleText(chars(<int>[0x2010])), isTrue); // 붙임표
expect(hasVisibleText(chars(<int>[0x2801])), isTrue); // 점자 점 하나
expect(hasVisibleText(chars(<int>[0x3163])), isTrue); // ㅣ
expect(hasVisibleText(chars(<int>[0x20000])), isTrue); // 한자 확장 B
});
}
@@ -372,6 +372,141 @@ void main() {
});
});
group('본문에 보이는 것이 있는가 (없으면 읽기 화면에 본문 칸이 없다)', () {
bool visibleHtml(String html, {String? text, bool imagesShown = false}) =>
mailBodyFragment(
htmlBody: html,
textBody: text,
imagesShown: imagesShown,
).hasVisibleContent;
bool visibleText(String? text) => mailBodyFragment(
htmlBody: null,
textBody: text,
imagesShown: false,
).hasVisibleContent;
test('빈 줄 · nbsp · 제로폭 글자뿐인 서식 본문은 비었다', () {
expect(visibleHtml('<div><br></div>'), isFalse);
expect(visibleHtml('<p>&nbsp;</p>'), isFalse);
// Gmail · Outlook 이 본문 없이 보낸 꼴 — 모르는 태그(o:p)는 지우고 안의 nbsp 만 남는다
expect(visibleHtml('<div dir="ltr"><br></div><div><br></div>'), isFalse);
expect(
visibleHtml('<p class="MsoNormal"><o:p>&nbsp;</o:p></p>'),
isFalse,
);
expect(
visibleHtml(
'<div style="font-family:Pretendard;color:#1F2733"><p></p></div>',
),
isFalse,
);
// 제로폭 빈칸 · BOM · WORD JOINER · 한글 채움 문자 · 점자 빈칸
expect(
visibleHtml('<p>&#x200B;&#xFEFF;&#x2060;&#x3164;&#x2800;</p>'),
isFalse,
);
});
test('내용째 버리는 것 · 주석 · 주소를 지운 그림은 보이지 않는다', () {
expect(
visibleHtml(
'<style>p{color:red}</style><!-- 주석 --><script>alert(1)</script>'
'<title>제목</title>',
),
isFalse,
);
expect(
visibleHtml(
'<p><svg><text>그림 글</text></svg><template><b>숨은 글</b></template></p>',
),
isFalse,
);
// 막은 바깥 그림 · 외부 이미지를 보이지 않을 때의 중계 주소 — 주소를 지워 문서가 숨긴다(alt 도 보이지 않는다)
expect(
visibleHtml('<img src="https://t.example/p.png" width="600">'),
isFalse,
);
expect(visibleHtml('<img src="/api/mail-images/t1" alt="배너">'), isFalse);
});
test(
'꾸밈(style · bgcolor · border)만으로 그린 상자 · 선과 빈 인용의 막대는 보이는 것으로 치지 않는다',
() {
expect(
visibleHtml(
'<table border="1" bgcolor="#EEEEEE"><tr>'
'<td style="height:20px;background:#0E7C86;border:1px solid #000000"></td>'
'</tr></table>',
),
isFalse,
);
expect(visibleHtml('<blockquote><br></blockquote>'), isFalse);
},
);
test('글 · 그림 · 가로줄 · 목록 표시 · 펼침 · 따옴표가 하나라도 있으면 보인다', () {
expect(visibleHtml('<p>안녕하세요</p>'), isTrue);
expect(visibleHtml('<o:p>모르는 태그 안의 글</o:p>'), isTrue);
expect(visibleHtml('<p>&nbsp;</p><p>.</p>'), isTrue);
expect(
visibleHtml(
'<p><br></p><img src="data:image/png;base64,iVBORw0KGgo=">',
),
isTrue,
);
// 외부 이미지를 보일 때의 중계 그림 — 받기 전에도 보이는 것으로 친다
expect(
visibleHtml('<img src="/api/mail-images/t1">', imagesShown: true),
isTrue,
);
expect(visibleHtml('<hr>'), isTrue);
expect(visibleHtml('<ul><li></li></ul>'), isTrue);
expect(visibleHtml('<details></details>'), isTrue);
expect(visibleHtml('<q></q>'), isTrue);
});
test('글자 본문 — 없거나 공백 · 보이지 않는 글자뿐이면 비었다', () {
expect(visibleText(null), isFalse);
expect(visibleText(''), isFalse);
expect(visibleText(' \n\t\n'), isFalse);
expect(
visibleText(String.fromCharCodes(<int>[0x00A0, 0x200B, 0x0A, 0xFEFF])),
isFalse,
);
expect(visibleText('본문'), isTrue);
});
test(
'고른 본문으로 본다 — 공백뿐인 서식 본문 · 한도를 넘은 서식 본문은 글자 본문, 비어 보이는 서식 본문은 그대로',
() {
expect(visibleHtml(' \n ', text: ''), isFalse);
expect(visibleHtml(' \n ', text: '글자 본문'), isTrue);
// 서식 본문이 비어 보여도 글자 본문으로 바꾸지 않는다(웹 displayedHtmlBody 와 같은 본문을 보인다)
expect(visibleHtml('<div><br></div>', text: '글자 본문'), isFalse);
const int depth = 20000;
final String deep = '${'<div>' * depth}${'</div>' * depth}';
final SanitizedMailBody omitted = mailBodyFragment(
htmlBody: deep,
textBody: '',
imagesShown: false,
);
expect(omitted.htmlOmitted, isTrue);
expect(omitted.hasVisibleContent, isFalse);
expect(visibleHtml(deep, text: '글자 본문'), isTrue);
},
);
test('깊거나 큰 본문도 되풀이 없이 정화하며 판정한다', () {
const int depth = 1500;
expect(visibleHtml('${'<div>' * depth}<br>${'</div>' * depth}'), isFalse);
expect(visibleHtml('${'<div>' * depth}끝${'</div>' * depth}'), isTrue);
// 맨 끝에만 글이 있는 본문
expect(visibleHtml('${'<p>&nbsp;</p>' * 20000}<p>끝</p>'), isTrue);
});
});
group('본문 조각 · 문서', () {
test('HTML 이 없거나 공백뿐이면 글자 본문을 이스케이프해 보인다', () {
final SanitizedMailBody text = mailBodyFragment(
@@ -28,6 +28,7 @@ import 'package:flutter_test/flutter_test.dart';
import 'package:go_router/go_router.dart';
import '../../support/fakes.dart';
import '../../support/input_borders.dart';
import '../../support/login_fakes.dart';
import '../../support/mail_fakes.dart';
import '../../support/mail_harness.dart';
@@ -187,6 +188,24 @@ void main() {
expect(h.mail.searchQueries, isEmpty);
});
testWidgets(
'검색 칸은 테두리를 그리지 않는다 — 초점이 있을 때 · 놓은 뒤(앱 테마의 둥근 테두리를 물려받지 않는다)',
(WidgetTester tester) async {
await openSearch(tester);
expect(
expectBorderlessInput(tester, field(), reason: '초점').isFocused,
isTrue,
);
FocusManager.instance.primaryFocus?.unfocus();
await tester.pumpAndSettle();
expect(
expectBorderlessInput(tester, field(), reason: '평소').isFocused,
isFalse,
);
},
);
testWidgets(
'[검색] → 뼈대("찾는 중") → 결과 — 개수 줄 · 제목 · 미리 보기의 맞은 말이 노랑, 자판을 내린다',
(WidgetTester tester) async {
@@ -32,6 +32,7 @@ import 'package:flutter_test/flutter_test.dart';
import '../../support/compose_fakes.dart';
import '../../support/fakes.dart';
import '../../support/input_borders.dart';
import '../../support/login_fakes.dart';
import '../../support/mail_fakes.dart';
import '../../support/mail_harness.dart';
@@ -500,6 +501,241 @@ void main() {
});
});
group('입력칸 모양', () {
testWidgets(
'칸마다 테두리를 그리지 않는다 — 평소 · 초점 · 꺼짐(보내는 중) 모두 칸 줄의 아래 선뿐(앱 테마의 둥근 테두리를 물려받지 않는다)',
(WidgetTester tester) async {
await start(tester);
await open(tester, MailRoutes.compose);
await tapTooltip(tester, MailComposeTexts.expandCopies);
await addRecipient(tester, 'doyun@x.kr');
await tester.enterText(subjectField, '계약');
await tester.enterText(bodyField, '안녕하세요');
final List<(String, Finder)> fields = <(String, Finder)>[
('받는 사람', field(RecipientFieldKind.to)),
('참조', field(RecipientFieldKind.cc)),
('숨은참조', field(RecipientFieldKind.bcc)),
('제목', subjectField),
('본문', bodyField),
];
FocusManager.instance.primaryFocus?.unfocus();
await tester.pumpAndSettle();
for (final (String name, Finder finder) in fields) {
final InputDecorator decorator = expectBorderlessInput(
tester,
finder,
reason: '$name 평소',
);
expect(decorator.isFocused, isFalse);
expect(decorator.decoration.enabled, isTrue);
}
for (final (String name, Finder finder) in fields) {
tester.widget<TextField>(finder).focusNode!.requestFocus();
await tester.pumpAndSettle();
final InputDecorator decorator = expectBorderlessInput(
tester,
finder,
reason: '$name 초점',
);
expect(decorator.isFocused, isTrue);
}
// 보내는 동안은 칸을 막는다
final Completer<void> gate = Completer<void>();
c.sendGate = gate;
await tester.tap(find.byTooltip(MailComposeTexts.send));
await tester.pump();
for (final (String name, Finder finder) in fields) {
final InputDecorator decorator = expectBorderlessInput(
tester,
finder,
reason: '$name 꺼짐',
);
expect(decorator.decoration.enabled, isFalse);
}
gate.complete();
await tester.pumpAndSettle();
expect(c.sends, hasLength(1));
},
);
/// 받는 사람 칸 줄
Finder toRow() => find.descendant(
of: find.byKey(const ValueKey<RecipientFieldKind>(RecipientFieldKind.to)),
matching: find.byType(ComposeFieldRow),
);
Finder removeChip(String name) =>
find.byTooltip(MailComposeTexts.remove(name));
double centerY(WidgetTester tester, Finder finder) =>
tester.getCenter(finder).dy;
testWidgets(
'칸 줄 — 칸 이름 15 · 칸 이름과 칸 사이 6 · 오른쪽 여백 4 · 안내 글은 보드의 placeholder 색 · 칩 ✕ 는 #4A5363 (보드 App1Compose)',
(WidgetTester tester) async {
await start(tester);
await open(
tester,
MailRoutes.compose,
extra: const ComposePrefill(to: <String>['이도윤 <doyun@x.kr>']),
);
await tapTooltip(tester, MailComposeTexts.expandCopies);
for (final String label in <String>[
fieldLabelOf(RecipientFieldKind.to),
fieldLabelOf(RecipientFieldKind.cc),
fieldLabelOf(RecipientFieldKind.bcc),
MailComposeTexts.subject,
]) {
// 보드 14 → 앱의 크기 대응 15 (앱 디자인 README 「쓰기」)
expect(
tester.widget<Text>(find.text(label)).style?.fontSize,
15,
reason: label,
);
}
final Rect label = tester.getRect(find.text(MailComposeTexts.subject));
final Rect subject = tester.getRect(subjectField);
final Rect row = tester.getRect(
find.ancestor(
of: subjectField,
matching: find.byType(ComposeFieldRow),
),
);
expect(subject.left, label.left + 72 + 6);
expect(subject.right, row.right - 4);
expect(row.height, 56);
// 안내 글 — 보드의 placeholder #8A94A3(검색 칸 안내 글과 같은 색)
final InputDecoration? subjectHint = tester
.widget<TextField>(subjectField)
.decoration;
final InputDecoration? bodyHint = tester
.widget<TextField>(bodyField)
.decoration;
expect(subjectHint?.hintStyle?.color, AppColors.borderInput);
expect(bodyHint?.hintStyle?.color, AppColors.borderInput);
expect(AppColors.borderInput, const Color(0xFF8A94A3));
final IconButton remove = tester.widget<IconButton>(
find.ancestor(
of: removeChip('이도윤'),
matching: find.byType(IconButton),
),
);
expect(remove.color, AppColors.textSecondary);
expect(AppColors.textSecondary, const Color(0xFF4A5363));
},
);
testWidgets(
'받는 사람 칩이 여러 줄이면 칸 이름 · [참조 펼치기]는 첫 칩 줄에 맞춘다 — 한 줄이면 줄의 가운데 그대로',
(WidgetTester tester) async {
await start(tester);
await open(
tester,
MailRoutes.compose,
extra: const ComposePrefill(
to: <String>[
'이도윤 <doyun@x.kr>',
'박준호 <junho@x.kr>',
'최서연 <seoyeon@x.kr>',
],
),
);
FocusManager.instance.primaryFocus?.unfocus();
await tester.pumpAndSettle();
final Finder label = find.text(fieldLabelOf(RecipientFieldKind.to));
final Finder toggle = find.byTooltip(MailComposeTexts.expandCopies);
// 두 줄 — 셋째 칩은 아래 줄. 칸 이름 · 단추는 첫 칩 줄의 가운데(줄 전체의 가운데가 아니다)
final double firstLine = centerY(tester, removeChip('이도윤'));
expect(centerY(tester, removeChip('최서연')), greaterThan(firstLine + 40));
expect(
centerY(tester, label),
moreOrLessEquals(firstLine, epsilon: 0.5),
);
expect(
centerY(tester, toggle),
moreOrLessEquals(firstLine, epsilon: 0.5),
);
expect(
tester.getRect(toRow()).top + 4 + 24,
moreOrLessEquals(firstLine, epsilon: 0.5),
);
// 한 줄 — 칩 · 칸 이름 · 단추가 줄(56)의 가운데
await tester.tap(removeChip('최서연'));
await tester.pumpAndSettle();
await tester.tap(removeChip('박준호'));
await tester.pumpAndSettle();
FocusManager.instance.primaryFocus?.unfocus();
await tester.pumpAndSettle();
final Rect row = tester.getRect(toRow());
expect(row.height, 56);
for (final Finder finder in <Finder>[
label,
toggle,
removeChip('이도윤'),
]) {
expect(
centerY(tester, finder),
moreOrLessEquals(row.center.dy, epsilon: 0.5),
);
}
},
);
/// 본문 칸의 최소 높이 — 본문 칸을 감싼 ConstrainedBox(가장 가까운 것)
Finder bodyBox() => find
.ancestor(of: bodyField, matching: find.byType(ConstrainedBox))
.first;
testWidgets('본문 칸 최소 높이 — 새 메일은 서명이 있어도 240, 원본 메일이 있는 답장은 150 (보드)', (
WidgetTester tester,
) async {
expect(composeBodyMinHeightOf(hasQuote: false), 240);
expect(composeBodyMinHeightOf(hasQuote: true), 150);
c.signature = <String, Object?>{
'bodyHtml': null,
'body': '김아라\n경영지원팀',
'isForNew': true,
'isForReply': true,
};
h.mail.put(
kClientsId,
messageJson(
'r1',
mailboxId: kClientsId,
subject: '유지보수 계약',
isRead: true,
),
detail: <String, Object?>{'textBody': '원문 첫 줄'},
);
await start(tester);
await open(tester, MailRoutes.compose);
expect(find.text('김아라'), findsOneWidget);
expect(
tester.widget<ConstrainedBox>(bodyBox()).constraints.minHeight,
240,
);
expect(tester.getSize(bodyBox()).height, 240);
await tapTooltip(tester, MailComposeTexts.close);
expect(find.byType(MessageComposePage), findsNothing);
await open(tester, MailRoutes.composeReply('r1'));
expect(find.text('김아라'), findsOneWidget);
expect(find.textContaining('이도윤 · '), findsOneWidget);
expect(
tester.widget<ConstrainedBox>(bodyBox()).constraints.minHeight,
150,
);
});
});
group('받는 사람', () {
testWidgets('쉼표 · [다음]에 칩 — 붙여 넣은 여러 줄도 나누고, 틀린 주소는 빨간 칩, ✕ 로 뺀다', (
WidgetTester tester,
@@ -21,6 +21,7 @@ import 'package:arasystem_app/features/mail/presentation/providers/message_reade
import 'package:arasystem_app/features/mail/presentation/widgets/mail_body_view.dart';
import 'package:arasystem_app/features/mail/presentation/widgets/mail_compose_area.dart';
import 'package:arasystem_app/features/mail/presentation/widgets/message_row.dart';
import 'package:arasystem_app/features/mail/presentation/widgets/reader_bars.dart';
import 'package:dio/dio.dart';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
@@ -246,6 +247,232 @@ void main() {
});
});
group('빈 본문 (첨부만 있는 메일)', () {
const int fileCount = 12;
/// 첨부 [fileCount] 개 — 본문 칸은 [body] 로 덮는다(없으면 비었다)
Map<String, Object?> manyFiles([
Map<String, Object?> body = const <String, Object?>{},
]) => <String, Object?>{
'htmlBody': null,
'textBody': '',
...body,
'attachments': <Object?>[
for (int n = 1; n <= fileCount; n++)
attachmentJson('a$n', name: 'file-$n.pdf', size: 2048),
],
};
/// 머리(제목 · 안내 줄 · 첨부)의 스크롤 칸
Finder head() => find.descendant(
of: find.byType(MessageReaderPage),
matching: find.byType(SingleChildScrollView),
);
Rect actionBar(WidgetTester tester) =>
tester.getRect(find.byType(ReaderActionBar));
testWidgets('보이는 것이 없으면 본문 칸 없이 머리가 화면 전체를 한 스크롤로 — 마지막 첨부까지 닿는다', (
WidgetTester tester,
) async {
// Gmail 이 본문 없이 보낸 서식 본문 — 빈 줄뿐이다
put(
'm1',
isRead: true,
detail: manyFiles(<String, Object?>{
'htmlBody': '<div dir="ltr"><br></div>',
}),
);
await open(tester, 'm1');
expect(find.byType(MailBodyView), findsNothing);
expect(h.webView.controllers, isEmpty);
expect(
find.text(MailReaderTexts.attachmentsTitle(fileCount)),
findsOneWidget,
);
// 머리가 아래 막대 위까지 다 쓴다(본문이 있으면 절반까지)
final Rect scroll = tester.getRect(head());
expect(scroll.bottom, moreOrLessEquals(actionBar(tester).top));
// 마지막 첨부는 처음에는 화면 밖 — 머리를 스크롤해 닿는다
final Finder last = find.text('file-$fileCount.pdf');
expect(last.hitTestable(), findsNothing);
await tester.drag(head(), const Offset(0, -2000));
await tester.pumpAndSettle();
final Rect row = tester.getRect(last);
expect(row.top, greaterThanOrEqualTo(scroll.top));
expect(row.bottom, lessThanOrEqualTo(scroll.bottom));
// 본문 칸이 있었다면 머리가 닿지 못하는 아래 절반이다
expect(
row.center.dy,
greaterThan(scroll.top + scroll.height * kReaderHeadMaxFraction),
);
// 닿은 첨부를 받아 연다
h.mail.attachmentFiles['a$fileCount'] = () =>
FakeResponse.bytes(<int>[1, 2], contentType: 'application/pdf');
await tapTooltip(tester, MailReaderTexts.download('file-$fileCount.pdf'));
expect(h.opener.opened.single.path, endsWith('/file-$fileCount.pdf'));
});
testWidgets('본문이 있으면 지금처럼 — 머리는 남은 자리의 절반까지(그 안에서 스크롤), 나머지는 본문 칸', (
WidgetTester tester,
) async {
put(
'm1',
isRead: true,
detail: manyFiles(<String, Object?>{'textBody': '본문'}),
);
await open(tester, 'm1');
expect(find.byType(MailBodyView), findsOneWidget);
final Rect scroll = tester.getRect(head());
final Rect body = tester.getRect(find.byType(MailBodyView));
final Rect bar = actionBar(tester);
expect(body.top, moreOrLessEquals(scroll.bottom));
expect(body.bottom, moreOrLessEquals(bar.top));
expect(
scroll.height,
moreOrLessEquals((bar.top - scroll.top) * kReaderHeadMaxFraction),
);
// 마지막 첨부도 머리 안에서 스크롤해 닿는다 — 위 절반 안이다
await tester.drag(head(), const Offset(0, -2000));
await tester.pumpAndSettle();
final Rect row = tester.getRect(find.text('file-$fileCount.pdf'));
expect(row.bottom, lessThanOrEqualTo(scroll.bottom));
expect(find.byType(MailBodyView), findsOneWidget);
});
testWidgets('본문을 만드는 동안은 지금처럼 본문 자리에 진행 줄 — 다 만들어 비었으면 본문 칸이 없어진다', (
WidgetTester tester,
) async {
put('m1', isRead: true, detail: manyFiles());
final Completer<SanitizedMailBody> building =
Completer<SanitizedMailBody>();
h.overrides.add(
mailBodyBuilderProvider.overrideWithValue(
({
required String? htmlBody,
required String? textBody,
required bool imagesShown,
required List<SearchRange> textHighlights,
}) => building.future,
),
);
await h.pump(tester);
unawaited(h.router.push<void>(MailRoutes.message('m1')));
await tester.pump();
await tester.pump(const Duration(seconds: 1));
expect(
find.bySemanticsLabel(MailReaderTexts.bodyLoading),
findsOneWidget,
);
final Rect loading = tester.getRect(head());
expect(
loading.height,
moreOrLessEquals(
(actionBar(tester).top - loading.top) * kReaderHeadMaxFraction,
),
);
building.complete(
mailBodyFragment(htmlBody: null, textBody: '', imagesShown: false),
);
await tester.pumpAndSettle();
expect(find.bySemanticsLabel(MailReaderTexts.bodyLoading), findsNothing);
expect(find.byType(MailBodyView), findsNothing);
expect(
tester.getRect(head()).bottom,
moreOrLessEquals(actionBar(tester).top),
);
});
testWidgets('빈 본문에서 [이미지 보기] — 그림이 생기면 본문 칸이 생기고 펼친 받는 사람은 그대로 남는다', (
WidgetTester tester,
) async {
// 서버가 막은 바깥 그림을 지워 공백만 남은 서식 본문 — 글자 본문(빈 글)을 보인다
put(
'm1',
isRead: true,
to: <Map<String, Object?>>[addressJson('김아라', me)],
detail: <String, Object?>{
'htmlBody': '\n',
'textBody': '',
'hasBlockedImages': true,
},
);
h.mail.shownImageDetails['m1'] = <String, Object?>{
'htmlBody': '<img src="/api/mail-images/t1">',
'hasBlockedImages': false,
'imagesShown': true,
};
h.mail.mailImages['t1'] = FakeResponse.bytes(
kTinyPng,
contentType: 'image/png',
);
await open(tester, 'm1');
expect(find.byType(MailBodyView), findsNothing);
expect(findCopy(MailReaderTexts.imagesBlocked), findsOneWidget);
await tester.tap(
find.text(MailReaderTexts.recipients(MailReaderTexts.me, 0)),
);
await tester.pumpAndSettle();
expect(find.text(MailReaderTexts.fromLabel), findsOneWidget);
await tester.ensureVisible(find.text(MailReaderTexts.showImages));
await tester.pumpAndSettle();
await tester.tap(find.text(MailReaderTexts.showImages));
await tester.pumpAndSettle();
expect(find.byType(MailBodyView), findsOneWidget);
expect(lastDocument(), contains('data:image/png;base64,'));
expect(findCopy(MailReaderTexts.imagesBlocked), findsNothing);
// 머리는 같은 자리의 같은 위젯이라 펼친 칸이 남는다
expect(find.text(MailReaderTexts.fromLabel), findsOneWidget);
expect(
tester.getRect(head()).bottom,
moreOrLessEquals(tester.getRect(find.byType(MailBodyView)).top),
);
});
testWidgets('아래 막대가 없는 예약 메일 — 머리 끝에 시스템 내비게이션 막대 몫을 더해 마지막 첨부가 그 위에 온다', (
WidgetTester tester,
) async {
put(
's1',
mailboxId: kSentId,
isRead: true,
pendingSend: pendingJson(
kTestNow.add(const Duration(days: 1)),
kind: 'scheduled',
),
detail: manyFiles(),
);
// 3버튼 내비게이션 막대(48) — 물리 픽셀(시험대는 3배)
tester.view.padding = const FakeViewPadding(bottom: 48 * 3);
addTearDown(tester.view.resetPadding);
await open(tester, 's1', location: MailRoutes.mailbox(kSentId));
expect(find.byType(ReaderActionBar), findsNothing);
expect(find.byType(MailBodyView), findsNothing);
// 머리가 화면 아래 끝까지 닿는다 — 그 밑의 48 은 내비게이션 막대 자리
expect(tester.getRect(head()).bottom, kPhoneSize.height);
await tester.drag(head(), const Offset(0, -3000));
await tester.pumpAndSettle();
// 마지막 첨부 줄 전체가 내비게이션 막대 위에 온다
final Rect row = tester.getRect(
find.byKey(const ValueKey<String>('attachment-a$fileCount')),
);
expect(row.bottom, lessThanOrEqualTo(kPhoneSize.height - 48));
});
});
group('읽음 처리', () {
testWidgets('열면 안 읽은 메일을 읽음으로 — 메일함 수를 먼저 줄이고 목록 줄은 바로 읽음', (
WidgetTester tester,
+52
View File
@@ -0,0 +1,52 @@
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
/// InputDecorator 가 지금 상태(꺼짐 · 초점 · 평소, 오류)에서 그릴 테두리 — SDK `input_decorator.dart` 가 고르는 차례와 같다
/// (상태의 테두리가 없으면 `border`)
InputBorder? shownInputBorder(InputDecorator decorator) {
final InputDecoration decoration = decorator.decoration;
final bool error = decoration.errorText != null || decoration.error != null;
final InputBorder? border = !decoration.enabled
? (error ? decoration.errorBorder : decoration.disabledBorder)
: decorator.isFocused
? (error ? decoration.focusedErrorBorder : decoration.focusedBorder)
: (error ? decoration.errorBorder : decoration.enabledBorder);
return border ?? decoration.border;
}
/// 선이 보이는 테두리인가 — 없음([InputBorder.none]) · 두께 0 은 보이지 않는다
bool isDrawnInputBorder(InputBorder? border) =>
border != null &&
border.borderSide.style != BorderStyle.none &&
border.borderSide.width > 0;
/// [field](TextField 를 찾는 Finder) 의 InputDecorator 가 받은 꾸밈에 보이는 테두리 · 바탕이 없다 — 여섯 테두리와 지금 상태의 테두리를 본다
/// (앱 테마의 입력칸 테두리를 물려받지 않았는지). 상태(초점 · 꺼짐)를 더 볼 수 있게 그 InputDecorator 를 돌려준다
InputDecorator expectBorderlessInput(
WidgetTester tester,
Finder field, {
String? reason,
}) {
final InputDecorator decorator = tester.widget<InputDecorator>(
find.descendant(of: field, matching: find.byType(InputDecorator)),
);
final InputDecoration decoration = decorator.decoration;
final Map<String, InputBorder?> borders = <String, InputBorder?>{
'border': decoration.border,
'enabledBorder': decoration.enabledBorder,
'focusedBorder': decoration.focusedBorder,
'disabledBorder': decoration.disabledBorder,
'errorBorder': decoration.errorBorder,
'focusedErrorBorder': decoration.focusedErrorBorder,
'지금 상태': shownInputBorder(decorator),
};
borders.forEach((String name, InputBorder? border) {
expect(
isDrawnInputBorder(border),
isFalse,
reason: '${reason ?? ''} $name: $border',
);
});
expect(decoration.filled, isFalse, reason: reason);
return decorator;
}
+13 -1
View File
@@ -4,8 +4,10 @@
// [보내기 ▾](9단계): 단추를 둘로 나눈다 — 왼쪽은 지금의 [보내기], 오른쪽 ▾(44 — 보드는 40, 밀도 예외를 새로 만들지 않는다)는 메뉴 「예약 발송…」(시계).
// 메뉴는 편집기 메뉴(EditorMenu)를 쓴다 — 키보드로 열면(Enter · Space · 아래 화살표) 첫 항목에, 마우스로 열면 메뉴 자체에 초점, Esc 는 ▾ 로 돌아간다.
// 두 단추는 같은 규칙으로 함께 꺼진다(보내는 중 · 편집기를 불러오는 중 등 — canSend · 첨부의 까닭 — blockedReason)
// 메뉴는 단추 아래로 열되 보이는 영역 아래가 모자라면 위로 연다(usePopupPlacement) — 단추 줄은 편집기가 늘어나 화면 맨 아래에 붙으므로
// 메뉴는 단추 아래로 열되 보이는 영역 아래가 모자라면 위로 연다(usePopupPlacement) — 단추 줄은 화면 맨 아래에 붙으므로
// 보통은 위로 열린다 (아래로 열면 화면 밖에 생기고, 초점이 들어갈 때 쓰기 화면이 튀어 올랐다 — 2026-10-29 리뷰)
// 줄은 스크롤하는 칸(넓은 화면은 쓰기 칸, 좁은 화면은 문서)의 아래에 붙는다(position: sticky) — 받는 사람이 많거나 화면이 낮아 쓰기 칸이 넘쳐도
// [보내기]가 늘 보인다 (2026-11-10 사용자 보고 — 전 직원을 숨은참조에 넣자 이 줄이 화면 밖으로 숨었다). 겹침 차례는 아래 style 의 주석
// 꺼진 [보내기] · ▾ 는 disabled 가 아니라 aria-disabled 다(11단계 — 초점은 받는다). 첨부 때문에 막혔으면(올리는 중 · 다 올리지 못함 · 빼야 하는 파일)
// [임시 저장] 오른쪽에 까닭 글(ⓘ 12.5 — 두 단추의 aria-describedby #compose-send-why)을 두고, 누르면 보내지 않고 까닭을 화면 낭독기에 읽어 준다(role=status).
// 「예약 발송…」도 같다. 임시 저장은 그대로 된다 (디자인 README "11단계 › 다 올릴 때까지 보내기를 막는다")
@@ -227,13 +229,23 @@ watch(unavailable, (off) => {
</template>
<style scoped>
/*
* 스크롤하는 칸의 아래에 붙는다 — 모양(위 선 · 흰 바탕 · 여백)은 그대로이고 넘치는 동안만 내용 위에 떠 있다 (2026-11-10 사용자 보고).
* 겹침 차례: 편집기 툴바(2) < 이 줄(3) < 받는 사람 추천 목록 · 메뉴를 연 툴바(4) — 툴바는 이 줄 밑으로 지나가고,
* 추천 목록과 툴바의 메뉴는 이 줄 위에 뜬다. 이 줄의 메뉴([보내기 ▾] · [서명 ▾])는 이 줄 안에서 위로 열려 툴바 위에 온다.
* 위치를 잡은 줄이라 안의 화면 낭독 글(.visually-hidden)도 이 줄 안에 든다 — 넓은 화면에서 문서가 스크롤되지 않게
*/
.compose__footer {
position: sticky;
bottom: 0;
z-index: 3;
display: flex;
flex-wrap: wrap;
align-items: center;
gap: var(--space-8);
padding: var(--space-10) var(--content-inline);
border-top: var(--border-width) solid var(--color-border);
background: var(--color-surface);
}
/* [보내기 ▾] — 둘을 한 덩어리로 (디자인 원본 MailSendLater: 모서리 8 · 사이에 옅은 흰 선). 메뉴는 이 덩어리 아래(모자라면 위)에서 연다 */
@@ -3,6 +3,7 @@
// - 칸은 메일 표(MessageTableRow)와 같은 자리 · 폭이다. 크기 칸은 없다 — 대화 하나의 크기는 마지막 메일의 크기가 아니다
// - 참여자 뒤에 회색 "4통"(한 통이면 없다) — 참여자가 길면 이름이 먼저 줄고 통 수는 남는다
// - 행 어디를 눌러도 그 메일함의 마지막 메일이 열리고(open), 제목은 키보드로 갈 수 있는 링크다. Ctrl(⌘) · Shift + 클릭은 고른다 (메일 표와 같다)
// 고르기 · 별 칸은 어디를 눌러도 열지 않는다 — 고르기 칸은 빈자리도 고르기 상자를 누른 것과 같고, 별 칸의 빈자리는 아무 일도 없다 (2026-11-10 사용자 보고)
// - 줄의 메일이 모두 보내기를 기다리는 메일이면 고르기 · 별을 쓸 수 없다(aria-disabled + 까닭은 title)
// - 임시보관함의 제목 없는 저장본은 메일 표처럼 흐린 "(제목 없음)" (리뷰 L5)
// 칸 폭 · 칸 공통 여백은 표(MessageTable)가 정한다.
@@ -18,6 +19,7 @@ import { MAIL_BULK_TEXT, MAIL_LIST_ROW_TEXT, MAIL_NO_SUBJECT } from '@/shared/co
import { MAIL_CONVERSATION_LIST_TEXT } from '@/shared/constants/mail-conversation'
import { SEND_LATER_TEXT } from '@/shared/constants/mail-send-later'
import type { ConversationSummary } from '@/shared/types/mail'
import { eventPathHas } from '@/shared/utils/event-path'
import { formatMailTableDate } from '@/shared/utils/format'
import { conversationCountText } from '@/shared/utils/mail-conversation-list'
import { pendingSendLabel, type RowWho } from '@/shared/utils/mail-list-row'
@@ -50,6 +52,13 @@ const props = withDefaults(defineProps<Props>(), {
const emit = defineEmits<Emits>()
/** 스스로 처리하는 것 — 고르기 · 별 단추 (메일 표와 같다) */
const ROW_CONTROLS = 'button, input'
/** 고르기 칸 — 단추 밖의 빈자리를 눌러도 고르기 상자를 누른 것과 같다 */
const CHECK_CELL = '.mail-table__check'
/** 눌러도 줄을 열지 않는 곳 — 제목 링크 · 별 칸(단추 밖의 빈자리 — 아무 일도 없다) */
const ROW_NO_OPEN = 'a, .mail-table__star'
const drag = useMailRowDrag()
const subject = computed(() => props.row.latest.subject || MAIL_NO_SUBJECT)
@@ -57,16 +66,23 @@ const countText = computed(() => conversationCountText(props.row))
/** 마지막 메일이 붙잡아 둔 발송이면 "예약 · …" · "보내는 중" (9단계) */
const pendingLabel = computed(() => pendingSendLabel(props.row.latest))
/** 행 어디를 눌러도 연다 — 버튼 · 제목 링크(스스로 연다) · 글자를 고르는 중이면 그대로 둔다 (메일 표와 같다) */
/**
* 행 어디를 눌러도 연다 — 단추 · 제목 링크(스스로 연다) · 별 칸의 빈자리 · 글자를 고르는 중이면 그대로 둔다 (메일 표와 같다).
* 고르기 칸의 빈자리(단추 밖)는 고르기 상자를 누른 것과 같다 — 고르고(Shift 는 범위) 열지 않는다, 고를 수 없는 줄이면 아무 일도 없다.
* 무엇을 눌렀는지는 사건이 지나간 길로 가른다 — 고르기 상자 그림은 고르면 다시 그려져 target 이 문서에서 떨어진다 (2026-11-10 사용자 보고)
*/
function onRowClick(event: MouseEvent) {
const target = event.target instanceof Element ? event.target : null
if (target?.closest('button')) return
if (eventPathHas(event, ROW_CONTROLS)) return
if (eventPathHas(event, CHECK_CELL)) {
if (!props.held) emit('check', props.row.id, event.shiftKey)
return
}
if (event.ctrlKey || event.metaKey || event.shiftKey) {
event.preventDefault()
if (!props.held) emit('check', props.row.id, event.shiftKey)
return
}
if (target?.closest('a')) return
if (eventPathHas(event, ROW_NO_OPEN)) return
if (window.getSelection()?.toString()) return
emit('open', props.to)
}
@@ -215,6 +231,11 @@ function onStar() {
color: var(--color-star);
}
/* 별 그림은 누름을 받지 않는다 — 누른 것(target)이 늘 단추다 (메일 표와 같다) */
.thread-table-row__star .app-icon {
pointer-events: none;
}
.thread-table-row__star[aria-disabled='true'] {
cursor: not-allowed;
}
@@ -94,10 +94,15 @@ function onClick(event: MouseEvent) {
background: transparent;
}
/*
* 상자 그림은 누름을 받지 않는다 — 누른 것(target)이 늘 단추다. 고르면 그림이 바뀌며(빈 상자 → 채운 상자) 누른 <rect> 가 문서에서 떨어져,
* 줄 전체에 click 을 건 목록 보기가 단추를 누른 줄 모르고 메일까지 열었다 (2026-11-10 사용자 보고 — 줄은 사건의 길로도 가른다, event-path.ts)
*/
.mail-checkbox__box {
flex: 0 0 auto;
width: var(--size-checkbox);
height: var(--size-checkbox);
pointer-events: none;
}
.mail-checkbox__empty {
@@ -0,0 +1,225 @@
import { beforeEach, describe, expect, it } from 'vitest'
import { mount, type VueWrapper } from '@vue/test-utils'
import { createPinia, setActivePinia, type Pinia } from 'pinia'
import type { Component } from 'vue'
import type { ConversationSummary, MessageSummary } from '@/shared/types/mail'
import { flushPromises } from '@/test-utils/mock-api'
import { createTestRouter } from '@/test-utils/router'
import ConversationTableRow from './ConversationTableRow.vue'
import MessageListItem from './MessageListItem.vue'
import MessageTableRow from './MessageTableRow.vue'
// 고르기 칸을 누르면 메일까지 열렸다 (2026-11-10 사용자 보고 — 목록 보기에서 고르기 상자 가운데를 눌렀다).
// 목록 보기의 줄(tr)은 줄 전체에 click 을 걸고 "단추 · 링크를 눌렀는가"를 target.closest 로 갈랐다. 상자 가운데를 누르면 target 은 빈 상자 그림(<rect>)이고,
// 단추의 처리기가 먼저 돌아 고르기를 바꾸면 브라우저는 처리기 사이에 microtask 를 돌려 Vue 가 다시 그린다 — 빈 상자 그림이 빠져 target 이 문서에서 떨어지고,
// 줄의 처리기에서 closest('button') 이 비어 줄을 누른 것으로 보고 열었다. 고르기 칸 · 별 칸의 빈자리(단추 밖)를 눌러도 열었다.
// 이제 줄은 사건이 지나간 길(event-path.ts — dispatch 때 정해진다)로 가르고, 상자 · 별 그림은 누름을 받지 않는다(pointer-events: none — 브라우저 시험).
// 고르기 칸의 빈자리는 고르기 상자를 누른 것과 같고(고를 수 없는 줄이면 아무 일도 없다), 별 칸의 빈자리는 아무 일도 없다.
// 스크립트의 dispatchEvent 는 처리기 사이에 microtask 를 돌리지 않는다 — 단추와 줄 사이(칸의 처리기)에서 그림을 떼어 내 브라우저의 다시 그리기를 흉내 낸다
function summary(id: string, partial: Partial<MessageSummary> = {}): MessageSummary {
return {
id,
threadId: `t-${id}`,
mailboxIds: ['inbox'],
from: { name: '이도윤', email: 'doyun.lee@arasystem.test' },
to: [{ name: '김아라', email: 'ara.kim@arasystem.test' }],
subject: `제목 ${id}`,
preview: '',
receivedAt: '2026-11-10T01:00:00Z',
isRead: true,
isFlagged: false,
hasAttachment: false,
size: 2048,
...partial,
}
}
function conversation(id: string): ConversationSummary {
const latest = summary(`${id}-latest`)
return {
id,
latest,
participants: [{ name: '이도윤', email: 'doyun.lee@arasystem.test' }],
messageCount: 3,
messageIds: [latest.id, `${id}-2`, `${id}-3`],
pendingSendIds: [],
isRead: true,
isFlagged: false,
hasAttachment: false,
}
}
interface RowCase {
name: string
component: Component
props: Record<string, unknown>
/** 고를 수 없는 줄로 바꾸는 props — 보내기를 기다리는 메일(9단계) */
heldProps: Record<string, unknown>
/** 고르기 · 별 사건에 실리는 줄 id */
rowId: string
}
const ROWS: RowCase[] = [
{
name: '메일 줄 (MessageTableRow)',
component: MessageTableRow,
props: { message: summary('m1'), to: '/mail/inbox/m1', role: 'inbox' },
heldProps: { message: summary('m1', { pendingSend: { kind: 'scheduled', sendAt: '2026-11-11T00:00:00Z' } }) },
rowId: 'm1',
},
{
name: '대화 줄 (ConversationTableRow — 대화로 묶기)',
component: ConversationTableRow,
props: {
row: conversation('c1'),
who: { text: '이도윤', placeholder: false },
to: '/mail/inbox/c1-latest',
},
heldProps: { held: true },
rowId: 'c1',
},
]
describe('목록 보기의 줄 — 고르기 칸 · 별 칸을 눌러도 메일을 열지 않는다 (2026-11-10 사용자 보고)', () => {
let pinia: Pinia
beforeEach(() => {
pinia = createPinia()
setActivePinia(pinia)
})
async function mountRow(row: RowCase): Promise<VueWrapper> {
const router = createTestRouter()
await router.push('/mail/inbox')
await router.isReady()
return mount(row.component, { props: row.props, global: { plugins: [pinia, router] } })
}
/** 누른 그림을 단추와 줄 사이(칸의 처리기)에서 떼어 낸다 — 브라우저에서 단추의 처리기 뒤 Vue 가 다시 그린 것처럼 */
function detachBeforeRow(wrapper: VueWrapper, cellSelector: string, picture: Element) {
wrapper.get(cellSelector).element.addEventListener('click', () => picture.remove())
}
function click(element: Element, init: MouseEventInit = {}) {
element.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true, ...init }))
}
describe.each(ROWS)('$name', (row) => {
it('고르기 상자 그림(빈 상자)을 누르면 고르기만 한다 — 고르며 다시 그려 그림이 문서에서 떨어져도 열지 않는다', async () => {
const wrapper = await mountRow(row)
const picture = wrapper.get('td.mail-table__check .mail-checkbox__empty').element
detachBeforeRow(wrapper, 'td.mail-table__check', picture)
click(picture)
await flushPromises()
expect(picture.isConnected).toBe(false)
expect(wrapper.emitted('check')).toEqual([[row.rowId, false]])
expect(wrapper.emitted('open')).toBeUndefined()
})
it('Shift + 상자 그림은 범위로 고른다 — 열지 않는다', async () => {
const wrapper = await mountRow(row)
const picture = wrapper.get('td.mail-table__check .mail-checkbox__empty').element
detachBeforeRow(wrapper, 'td.mail-table__check', picture)
click(picture, { shiftKey: true })
expect(wrapper.emitted('check')).toEqual([[row.rowId, true]])
expect(wrapper.emitted('open')).toBeUndefined()
})
it('고르기 칸의 빈자리(단추 밖)는 고르기 상자를 누른 것과 같다 — 고르고 · Shift 는 범위 · Ctrl(⌘)은 하나씩, 열지 않는다', async () => {
const wrapper = await mountRow(row)
const cell = wrapper.get('td.mail-table__check').element
click(cell)
click(cell, { shiftKey: true })
click(cell, { ctrlKey: true })
click(cell, { metaKey: true })
expect(wrapper.emitted('check')).toEqual([
[row.rowId, false],
[row.rowId, true],
[row.rowId, false],
[row.rowId, false],
])
expect(wrapper.emitted('open')).toBeUndefined()
})
it('고를 수 없는 줄(보내기를 기다리는 메일)이면 고르기 칸의 빈자리를 눌러도 아무 일도 없다', async () => {
const wrapper = await mountRow({ ...row, props: { ...row.props, ...row.heldProps } })
click(wrapper.get('td.mail-table__check').element)
click(wrapper.get('td.mail-table__check').element, { shiftKey: true })
expect(wrapper.emitted('check')).toBeUndefined()
expect(wrapper.emitted('open')).toBeUndefined()
})
it('별 칸의 빈자리(단추 밖)는 아무 일도 없다 — 열지도 · 별을 바꾸지도 · 고르지도 않는다', async () => {
const wrapper = await mountRow(row)
click(wrapper.get('td.mail-table__star').element)
expect(wrapper.emitted('open')).toBeUndefined()
expect(wrapper.emitted('check')).toBeUndefined()
expect(wrapper.emitted('toggle-flag')).toBeUndefined()
})
it('별 그림을 누르면 중요 표시만 한다 — 그림이 떨어져도 열지 않는다', async () => {
const wrapper = await mountRow(row)
const picture = wrapper.get('td.mail-table__star svg').element
detachBeforeRow(wrapper, 'td.mail-table__star', picture)
click(picture)
expect(wrapper.emitted('toggle-flag')).toEqual([[row.rowId]])
expect(wrapper.emitted('open')).toBeUndefined()
})
it('줄의 다른 곳(사람 · 날짜 칸)을 누르면 지금처럼 연다 · Ctrl(⌘) · Shift + 클릭은 열지 않고 고른다', async () => {
const wrapper = await mountRow(row)
const cells = wrapper.findAll('td')
click(cells[2]!.element)
expect(wrapper.emitted('open')).toHaveLength(1)
click(wrapper.get('td.mail-table__date').element, { ctrlKey: true })
click(cells[2]!.element, { shiftKey: true })
expect(wrapper.emitted('check')).toEqual([
[row.rowId, false],
[row.rowId, true],
])
expect(wrapper.emitted('open')).toHaveLength(1)
})
})
})
describe('분할 보기의 줄 — 고르기 칸(36)은 링크 밖이라 열지 않는다 (지금처럼)', () => {
it('상자 그림을 눌러도 고르기만 하고 주소는 그대로다 · 줄(링크)을 누르면 연다', async () => {
setActivePinia(createPinia())
const router = createTestRouter()
await router.push('/mail/inbox')
await router.isReady()
const wrapper = mount(MessageListItem, {
props: { message: summary('m1'), to: '/mail/inbox/m1', role: 'inbox' },
global: { plugins: [router] },
})
click(wrapper.get('.message-row__check .mail-checkbox__empty').element)
await flushPromises()
expect(wrapper.emitted('check')).toEqual([['m1', false]])
expect(router.currentRoute.value.fullPath).toBe('/mail/inbox')
await wrapper.get('.message-row__main').trigger('click')
await flushPromises()
expect(router.currentRoute.value.fullPath).toBe('/mail/inbox/m1')
})
function click(element: Element) {
element.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true }))
}
})
@@ -1,6 +1,8 @@
<script setup lang="ts">
// 목록 보기 표의 한 행(38) — 고르기 · 별 · 보낸 사람 · [메일함] · 제목(링크 + 첨부 클립) · 날짜 · 크기 (디자인 원본 MainList · BulkSelect)
// - 행 어디를 눌러도 메일이 열리고(open), 제목은 키보드로 갈 수 있는 링크다. 고르기 · 별 버튼은 따로 동작한다
// - 행 어디를 눌러도 메일이 열리고(open), 제목은 키보드로 갈 수 있는 링크다. 고르기 · 별 버튼은 따로 동작한다 —
// 고르기 · 별 칸은 어디를 눌러도(상자 · 별 그림 위 · 단추 밖의 빈자리) 열지 않는다 — 고르기 칸은 빈자리도 고르기 상자를 누른 것과 같고,
// 별 칸의 빈자리는 아무 일도 없다 (2026-11-10 사용자 보고 — 고르기 상자 가운데를 누르면 메일까지 열렸다)
// - Ctrl(⌘)+클릭은 하나씩, Shift+클릭은 범위로 고른다 — 메일을 열지 않고, 제목 링크를 눌러도 새 탭 · 새 창으로 열지 않는다
// - 보낸 사람 자리 · 읽음 표시 · 누르면 갈 곳은 메일함 규칙을 따른다 (mail-list-row.ts)
// - 아직 보내지 않은 내 메일(9단계)은 날짜 칸이 "예약 · 10월 30일 오전 9:00" · "보내는 중"(청록) — 칸(84)보다 길면 줄이고 전체는 title 로.
@@ -19,6 +21,7 @@ import { useMailRowDrag } from '@/composables/useMailDrag'
import { MAIL_BULK_TEXT, MAIL_NO_SUBJECT } from '@/shared/constants/mail'
import { SEND_LATER_TEXT } from '@/shared/constants/mail-send-later'
import type { MailboxRole, MessageListItem } from '@/shared/types/mail'
import { eventPathHas } from '@/shared/utils/event-path'
import { formatFileSize, formatMailTableDate } from '@/shared/utils/format'
import { isDraftRole, isRowUnread, pendingSendLabel, rowWho } from '@/shared/utils/mail-list-row'
import { isHeldMessage } from '@/shared/utils/pending-send'
@@ -48,6 +51,13 @@ const props = withDefaults(defineProps<Props>(), {
const emit = defineEmits<Emits>()
/** 스스로 처리하는 것 — 고르기 · 별 단추 (Ctrl · Shift + 클릭으로도 고르지 않는다) */
const ROW_CONTROLS = 'button, input'
/** 고르기 칸 — 단추 밖의 빈자리를 눌러도 고르기 상자를 누른 것과 같다 */
const CHECK_CELL = '.mail-table__check'
/** 눌러도 줄을 열지 않는 곳 — 제목 링크(스스로 연다) · 별 칸(단추 밖의 빈자리 — 아무 일도 없다) */
const ROW_NO_OPEN = 'a, .mail-table__star'
const drag = useMailRowDrag()
const who = computed(() => rowWho(props.message, { role: props.role, mixed: props.mixed, view: 'table' }))
@@ -61,16 +71,24 @@ const held = computed(() => isHeldMessage(props.message))
/** 검색 강조 (14단계) — "(제목 없음)"에는 없다 */
const subjectRanges = computed(() => (props.message.subject ? (props.message.searchHighlight?.subject ?? null) : null))
/** 행 어디를 눌러도 연다 — 버튼을 누른 경우 · 제목 링크(스스로 연다) · 글자를 고르는 중이면 그대로 둔다 */
/**
* 행 어디를 눌러도 연다 — 단추(고르기 · 별)를 누른 경우 · 제목 링크(스스로 연다) · 별 칸의 빈자리 · 글자를 고르는 중이면 그대로 둔다.
* 고르기 칸의 빈자리(단추 밖)는 고르기 상자를 누른 것과 같다 — 고르고(Shift 는 범위) 열지 않는다, 고를 수 없는 줄이면 아무 일도 없다.
* 무엇을 눌렀는지는 사건이 지나간 길(eventPathHas)로 가른다 — 고르기 상자 가운데를 누르면 target 은 상자 그림인데 단추의 처리기가
* 고르기를 바꾸며 다시 그려 문서에서 떨어진다(target.closest 로 보다가 메일까지 열렸다 — 2026-11-10 사용자 보고)
*/
function onRowClick(event: MouseEvent) {
const target = event.target instanceof Element ? event.target : null
if (target?.closest('button')) return
if (eventPathHas(event, ROW_CONTROLS)) return
if (eventPathHas(event, CHECK_CELL)) {
if (!held.value) emit('check', props.message.id, event.shiftKey)
return
}
if (event.ctrlKey || event.metaKey || event.shiftKey) {
event.preventDefault()
if (!held.value) emit('check', props.message.id, event.shiftKey)
return
}
if (target?.closest('a')) return
if (eventPathHas(event, ROW_NO_OPEN)) return
if (window.getSelection()?.toString()) return
emit('open', props.to)
}
@@ -216,6 +234,11 @@ function onMouseDown(event: MouseEvent) {
color: var(--color-star);
}
/* 별 그림은 누름을 받지 않는다 — 누른 것(target)이 늘 단추다 (고르기 상자 그림과 같다 — MailCheckbox) */
.mail-table__star-button .app-icon {
pointer-events: none;
}
.mail-table__who {
font-weight: 450;
color: var(--color-text-strong);
@@ -1,4 +1,4 @@
import { describe, expect, it } from 'vitest'
import { describe, expect, it, vi } from 'vitest'
import { mount, type VueWrapper } from '@vue/test-utils'
import { shallowRef } from 'vue'
@@ -626,3 +626,104 @@ describe('RecipientField', () => {
})
})
})
describe('RecipientField — 칩이 세 줄을 넘으면 칸 안에서 스크롤 (2026-11-10 사용자 보고)', () => {
// 전 직원을 숨은참조에 넣자 칸이 늘어 쓰기 화면이 넘치고 [보내기]가 화면 밖으로 숨었다 — 칩 · 입력 칸(.recipient-field__control)은
// 높이 한도(--size-recipient-chips-max)에서 그 칸 안이 스크롤한다(값은 ComposePage.scroll.spec 이 원본 style 에서 본다).
// jsdom 은 배치를 하지 않으므로 칸의 높이를 정해 주고 scrollTop 을 붙잡는다
const MANY: RecipientToken[] = Array.from({ length: 30 }, (_, i) =>
tokenFromAddress({ name: '', email: `notice-staff${i + 1}@example.com` }),
)
/** 칩 · 입력 칸의 scrollTop — 칸의 내용 높이는 400 */
function scrollOf(wrapper: VueWrapper) {
const control = wrapper.get('.recipient-field__control').element
Object.defineProperty(control, 'scrollHeight', { configurable: true, value: 400 })
const box = { top: 0 }
Object.defineProperty(control, 'scrollTop', {
configurable: true,
get: () => box.top,
set: (value: number) => {
box.top = value
},
})
return box
}
it('초점이 오면 칸을 맨 아래로 — 입력칸은 늘 칸의 끝에 있어 입력 자리가 보인다', async () => {
const wrapper = mountField(MANY)
const scroll = scrollOf(wrapper)
await wrapper.get('input').trigger('focus')
expect(scroll.top).toBe(400)
})
it('칩이 늘면(확정) 그린 뒤 맨 아래로 · 줄면(빈 입력칸 Backspace) 칸의 자리를 그대로 둔다', async () => {
const wrapper = mountField(MANY)
const scroll = scrollOf(wrapper)
const input = wrapper.get('input')
await input.setValue('new.one@example.com')
scroll.top = 0
await input.trigger('keydown', { key: 'Enter' })
await flushPromises()
expect(tokensOf(wrapper)).toHaveLength(31)
expect(scroll.top).toBe(400)
scroll.top = 120
await input.trigger('keydown', { key: 'Backspace' })
await flushPromises()
expect(tokensOf(wrapper)).toHaveLength(30)
expect(scroll.top).toBe(120)
})
it('글을 치면 맨 아래로 — 칸을 위로 올려 본 채 쳐도 치는 자리가 보인다 (한글 조합 중 글자도)', async () => {
const wrapper = mountField(MANY)
const scroll = scrollOf(wrapper)
const input = wrapper.get('input')
await input.setValue('kim')
expect(scroll.top).toBe(400)
scroll.top = 0
await input.trigger('compositionupdate')
expect(scroll.top).toBe(400)
})
it('가운데 칩의 ✕ 는 그 칩만 지우고 초점은 지금처럼 입력칸으로 — 칸의 스크롤은 그대로 둔다(이어 지우게)', async () => {
const wrapper = mountField(MANY, suggestionsOf([]))
const scroll = scrollOf(wrapper)
const input = wrapper.get('input').element as HTMLInputElement
const focusSpy = vi.spyOn(input, 'focus')
scroll.top = 120
await wrapper.get('button[aria-label="notice-staff5@example.com 삭제"]').trigger('click')
expect(tokensOf(wrapper)).toHaveLength(29)
expect(tokensOf(wrapper).some((token) => token.address.email === 'notice-staff5@example.com')).toBe(false)
expect(focusSpy).toHaveBeenCalledWith({ preventScroll: true })
expect(document.activeElement).toBe(input)
expect(scroll.top).toBe(120)
// 다시 들어오면(Tab · 누름) 맨 아래로
input.blur()
input.focus()
expect(scroll.top).toBe(400)
wrapper.unmount()
})
it('추천 목록은 칩 · 입력 칸 밖(줄 아래)에 뜬다 — 칸이 스크롤해도 목록이 잘리지 않게', async () => {
const suggestions = suggestionsOf([{ name: '이도윤', email: 'doyun.lee@arasystem.it.kr', source: 'directory' }])
const wrapper = mountField(MANY, suggestions)
const input = wrapper.get('input')
;(input.element as HTMLInputElement).focus()
await input.trigger('focus')
await input.setValue('도윤')
const list = wrapper.get('.recipient-suggestions')
expect(list.element.closest('.recipient-field')).toBe(wrapper.get('.recipient-field').element)
expect(list.element.closest('.recipient-field__control')).toBeNull()
wrapper.unmount()
})
})
@@ -16,6 +16,10 @@
// input · compositionupdate 의 값을 따로 둔다(query). 주소 토큰 확정은 v-model(draft) 그대로다
// - 한글 조합 중의 Enter · Tab 은 글자 확정이다 — 토큰으로도 추천으로도 넣지 않는다 (isImeComposing — Safari 의 keyCode 229 포함)
// 목록은 RecipientSuggestionList 가 그린다 — 고르기 · 넣기 키는 이 입력칸이 받는다(초점은 늘 입력칸).
// 칩이 세 줄을 넘으면 칩 · 입력 칸(.recipient-field__control) 안에서 스크롤한다 (--size-recipient-chips-max — 2026-11-10 사용자 보고:
// 전 직원을 숨은참조에 넣자 칸이 늘어 쓰기 화면이 넘쳤다). 추천 목록은 그 칸 밖(줄 아래)에 뜬다.
// 초점이 오거나 · 칩이 늘거나 · 글을 치면 칸을 맨 아래로(입력 자리가 보이게). 칩의 ✕ 로 지우면 초점은 지금처럼 입력칸으로 가되
// 칸의 스크롤은 그대로 둔다 — 가운데 칩을 이어 지울 때 자리가 맨 아래로 튀지 않게
// 고정 id — 목록 #{inputId}-suggestions · 추천 #{inputId}-option-{n} · 안내 #{inputId}-suggestions-hint (recipient-suggestion-ids.ts)
// 사용 예: <RecipientField v-model="to" label="받는 사람" input-id="compose-to" error-id="compose-recipient-error" />
import { computed, inject, nextTick, onBeforeUnmount, ref, watch } from 'vue'
@@ -79,6 +83,10 @@ const draft = ref<string>('')
/** 추천 검색어 — 입력칸에 보이는 글 그대로(한글 조합 중인 글자 포함). 조합이 끝나면 draft 와 같아진다 */
const query = ref<string>('')
const inputRef = ref<HTMLInputElement | null>(null)
/** 칩 · 입력 칸 — 칩이 세 줄을 넘으면 이 칸 안에서 스크롤한다 */
const controlRef = ref<HTMLElement | null>(null)
/** 칩을 지우며 입력칸에 초점을 줄 때 — 칸의 스크롤을 그대로 둔다 (onFocus 가 맨 아래로 옮기지 않게) */
let keepScrollOnFocus = false
/** 입력칸에 초점이 있다 — 그동안만 추천 목록을 연다 */
const focused = ref<boolean>(false)
/** Esc 로 닫았다 — 다시 치면 연다 */
@@ -260,13 +268,24 @@ function onPaste(event: ClipboardEvent) {
commit(`${draft.value}${text}`)
}
/** 입력칸에 보이는 글 — 한글 조합 중인 글자도 (v-model 은 조합 중의 input 을 건너뛴다) */
/**
* 칩 · 입력 칸을 맨 아래로 — 입력칸은 늘 칸의 끝에 있으므로 칩이 세 줄을 넘어 칸 안에서 스크롤할 때 입력 자리가 보인다.
* 넘치지 않으면 아무 일도 없다
*/
function revealInput() {
const control = controlRef.value
if (control) control.scrollTop = control.scrollHeight
}
/** 입력칸에 보이는 글 — 한글 조합 중인 글자도 (v-model 은 조합 중의 input 을 건너뛴다). 치는 자리가 보이게 칸을 맨 아래로 */
function onTextInput(event: Event) {
query.value = (event.target as HTMLInputElement).value
revealInput()
}
function onFocus() {
focused.value = true
if (!keepScrollOnFocus) revealInput()
// 미리 받기가 실패했으면 다시 받아 본다 (받았거나 받는 중이면 아무것도 하지 않는다)
suggestions?.preload()
}
@@ -282,7 +301,10 @@ function remove(token: RecipientToken) {
'update:modelValue',
props.modelValue.filter((item) => recipientTokenKey(item) !== key),
)
inputRef.value?.focus()
// 초점은 지금처럼 입력칸으로 — 칩이 많아 칸 안에서 스크롤하는 중이면 그 자리를 그대로 둔다 (가운데 칩을 이어 지우게)
keepScrollOnFocus = true
inputRef.value?.focus({ preventScroll: true })
keepScrollOnFocus = false
}
function focus() {
@@ -321,6 +343,15 @@ watch(query, (text) => {
suggestions?.search(text)
})
// 칩이 늘면(확정 · 추천 · 그룹 · 붙여 넣기 · 저장본) 그린 뒤 칸을 맨 아래로 — 입력 자리가 보이게. 줄 때(지우기)는 그대로 둔다
watch(
() => props.modelValue.length,
(count, previous) => {
if (count > previous) revealInput()
},
{ flush: 'post' },
)
// 추천이 늘거나 줄면(서버 결과 · 이미 넣은 주소) 고른 자리를 목록 안에 둔다
watch(
() => options.value.length,
@@ -344,7 +375,10 @@ defineExpose({ commit, focus, clearDraft, hasDraft })
:for="inputId"
class="recipient-field__label"
>{{ label }}</label>
<div class="recipient-field__control">
<div
ref="controlRef"
class="recipient-field__control"
>
<span
v-for="token in modelValue"
:key="recipientTokenKey(token)"
@@ -444,13 +478,23 @@ defineExpose({ commit, focus, clearDraft, hasDraft })
color: var(--color-text-secondary);
}
/*
* 칩 · 입력 — 칩이 세 줄을 넘으면 이 칸 안에서 스크롤한다 (--size-recipient-chips-max 96 + 위 여백 4 = 100 · 2026-11-10 사용자 보고).
* 위 · 좌우에 안쪽 여백 4 를 두고 바깥 여백을 같은 만큼 당긴다 — 자리 · 폭 · 줄바꿈은 그대로이고, 칸 가장자리 칩의 ✕ 초점 고리(바깥 4)가
* 잘리지 않는다. 아래는 당기지 않는다 — 입력이 든 마지막 줄(34)은 칩(28)보다 높아 고리가 들고, 줄의 아래 선(초점 선)을 덮지 않게
*/
.recipient-field__control {
flex: 1 1 20rem;
flex: 1 1 calc(20rem + var(--space-4) * 2);
min-width: 0;
max-height: calc(var(--size-recipient-chips-max) + var(--space-4));
display: flex;
flex-wrap: wrap;
align-items: center;
gap: var(--space-4) var(--space-6);
margin: calc(var(--space-4) * -1) calc(var(--space-4) * -1) 0;
padding: var(--space-4) var(--space-4) 0;
overflow-x: hidden;
overflow-y: auto;
}
.recipient-field__input {
@@ -222,6 +222,22 @@ describe('EditorToolbar (본문 서식 툴바)', () => {
expect(document.activeElement?.getAttribute('data-tool')).toBe('more')
})
it('메뉴 · 팝오버가 열린 동안만 툴바를 쓰기 화면 아래에 붙은 [보내기] 줄(z-index 3) 위로 올린다 — editor-toolbar--popup-open (2026-11-10)', async () => {
const view = mountEditor()
const toolbar = view.get('.editor-toolbar')
expect(toolbar.classes()).not.toContain('editor-toolbar--popup-open')
await tool(view, 'more').trigger('click')
expect(toolbar.classes()).toContain('editor-toolbar--popup-open')
await view.get('[role="menu"][aria-label="서식 더보기"]').trigger('keydown', { key: 'Escape' })
await nextTick()
expect(toolbar.classes()).not.toContain('editor-toolbar--popup-open')
await tool(view, 'textColor').trigger('click')
expect(toolbar.classes()).toContain('editor-toolbar--popup-open')
})
it('마우스로 열면 메뉴 자체에 초점(고른 항목에 키보드 고리가 뜨지 않게) — 아래 화살표로 항목에 들어간다. 키보드로 열면 켜진 항목에', async () => {
const view = mountEditor()
editorOf(view.element).commands.focus()
@@ -341,7 +341,7 @@ defineExpose({
<template>
<div
ref="rootRef"
class="editor-toolbar"
:class="['editor-toolbar', { 'editor-toolbar--popup-open': popup !== null }]"
role="toolbar"
aria-label="본문 서식"
:aria-controls="MAIL_EDITOR_BODY_ID"
@@ -676,6 +676,14 @@ defineExpose({
background: var(--color-surface);
}
/*
* 메뉴 · 팝오버를 연 동안은 쓰기 화면 아래에 붙은 단추 줄(z-index 3 — ComposeFooter) 위에 — 툴바 아래로 연 메뉴가 그 줄에 가리지 않게.
* 닫으면 2 로 돌아가 툴바가 그 줄 밑으로 지나간다 (2026-11-10)
*/
.editor-toolbar--popup-open {
z-index: 4;
}
.editor-toolbar__group {
flex: 0 0 auto;
display: flex;
@@ -367,6 +367,16 @@ defineExpose({
overflow-y: auto;
}
/*
* 넓은 화면(70rem 이상)에서는 쓰기 화면이 화면 높이에 묶여 따로 스크롤한다 — 자리가 모자라면 편집 영역이 200 까지 먼저 줄어
* 쓰기 칸 전체가 화면 안에 든다(바깥 스크롤 + 본문 스크롤의 이중 스크롤을 막는다). 자리가 넉넉하면 지금처럼 늘어난다 (ComposePage · 2026-11-10)
*/
@media (min-width: 70rem) {
.mail-editor__frame {
min-height: var(--size-editor-body-min-fit);
}
}
/* 끌어다 놓는 동안의 덮개 — 2px 점선(포커스 선은 실선)이라 모양이 다르다 */
.mail-editor__drop {
position: absolute;
@@ -0,0 +1,216 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { createManualClock, type ManualClock } from '@/test-utils/manual-clock'
import { useFocusAboveFooter } from './useFocusAboveFooter'
// 아래에 붙은 [보내기] 줄 밑에 가린 칸으로 초점이 오면 가린 만큼 올린다 (2026-11-10 — 좁은 화면에서 Tab 으로 간 [파일 첨부]가 그 줄에 가렸다).
// jsdom 은 배치를 하지 않는다 — 상자 크기를 정해 주고, 스크롤 상자의 scrollTop 을 잡아 본다
function rectOf(top: number, bottom: number): DOMRect {
return { top, bottom, height: bottom - top, left: 0, right: 0, width: 0, x: 0, y: top, toJSON: () => ({}) } as DOMRect
}
/** scrollTop 을 담는 칸 — jsdom 은 스크롤하지 않으므로 값만 붙잡는다 */
function trackScroll(element: Element, start = 0): { value: number } {
const box = { value: start }
Object.defineProperty(element, 'scrollTop', {
configurable: true,
get: () => box.value,
set: (value: number) => {
box.value = value
},
})
return box
}
describe('useFocusAboveFooter — [보내기] 줄 밑에 초점이 가리지 않게', () => {
let clock: ManualClock
let frames: (() => void)[]
let scroller: HTMLElement
let row: HTMLElement
let input: HTMLInputElement
let attach: HTMLButtonElement
let footer: HTMLElement
let send: HTMLButtonElement
let rects: Map<Element, DOMRect>
beforeEach(() => {
clock = createManualClock(10_000)
frames = []
// 넓은 화면 — 쓰기 칸이 스크롤 상자다 (overflow-y: auto · 넘친다)
scroller = document.createElement('main')
scroller.style.overflowY = 'auto'
Object.defineProperty(scroller, 'scrollHeight', { configurable: true, value: 1200 })
Object.defineProperty(scroller, 'clientHeight', { configurable: true, value: 600 })
row = document.createElement('div')
row.className = 'compose-field'
input = document.createElement('input')
row.append(input)
attach = document.createElement('button')
footer = document.createElement('div')
send = document.createElement('button')
footer.append(send)
scroller.append(row, attach, footer)
document.body.append(scroller)
rects = new Map<Element, DOMRect>([
[scroller, rectOf(56, 656)],
[footer, rectOf(591, 656)],
[row, rectOf(560, 604)],
[input, rectOf(565, 599)],
[attach, rectOf(600, 644)],
[send, rectOf(601, 645)],
])
vi.spyOn(Element.prototype, 'getBoundingClientRect').mockImplementation(function (this: Element) {
return rects.get(this) ?? rectOf(0, 0)
})
})
afterEach(() => {
vi.restoreAllMocks()
document.body.innerHTML = ''
})
function setup() {
return useFocusAboveFooter({ footer: () => footer, clock, nextFrame: (callback) => frames.push(callback) })
}
/** 초점을 옮기고 그 focusin 을 넘긴다 (재는 것은 다음 그림 — frames) */
function focusIn(handler: ReturnType<typeof setup>, element: HTMLElement) {
element.focus()
const event = new FocusEvent('focusin', { bubbles: true })
Object.defineProperty(event, 'target', { value: element })
handler.onFocusIn(event)
}
function pointerDown(handler: ReturnType<typeof setup>, element: Element) {
const event = new Event('pointerdown', { bubbles: true }) as PointerEvent
Object.defineProperty(event, 'target', { value: element })
handler.onPointerDown(event)
}
function runFrames() {
for (const frame of frames.splice(0)) frame()
}
it('키보드로 간 칸이 그 줄에 가렸으면 가린 만큼(+ 8) 쓰기 칸을 올린다 — 줄 전체(.compose-field)를 드러낸다', () => {
const scroll = trackScroll(scroller, 100)
const handler = setup()
focusIn(handler, input)
// 그 자리에서 재지 않는다 — 브라우저가 초점에 맞춰 스크롤한 다음 그림에
expect(scroll.value).toBe(100)
runFrames()
// 줄 아래 604 + 8 − 그 줄의 위 591 = 21
expect(scroll.value).toBe(121)
})
it('줄 밖의 요소([파일 첨부])는 그 요소를 드러낸다', () => {
const scroll = trackScroll(scroller, 0)
const handler = setup()
focusIn(handler, attach)
runFrames()
expect(scroll.value).toBe(644 + 8 - 591)
})
it('가리지 않았으면 · 아래 줄 안의 단추면 그대로 둔다', () => {
const scroll = trackScroll(scroller, 0)
const handler = setup()
rects.set(attach, rectOf(500, 544))
focusIn(handler, attach)
focusIn(handler, send)
runFrames()
expect(scroll.value).toBe(0)
})
it('마우스 · 손가락으로 누른 칸은 그대로 둔다 — 누른 채 내용이 움직이면 글이 골라진다. 1초가 지난 뒤의 키보드 초점은 올린다', () => {
const scroll = trackScroll(scroller, 0)
const handler = setup()
pointerDown(handler, input)
focusIn(handler, input)
runFrames()
expect(scroll.value).toBe(0)
clock.advance(1000)
attach.focus()
focusIn(handler, input)
runFrames()
expect(scroll.value).toBe(21)
})
it('다른 곳을 누른 뒤 코드가 옮긴 초점(보내기 전 검사 → 받는 사람 칸 등)은 올린다', () => {
const scroll = trackScroll(scroller, 0)
const handler = setup()
pointerDown(handler, send)
focusIn(handler, input)
runFrames()
expect(scroll.value).toBe(21)
})
it('칸이 보이는 자리보다 크면 칸의 위를 맞춘다 — 위가 화면 밖으로 나가지 않게', () => {
const scroll = trackScroll(scroller, 0)
const handler = setup()
// 쓰기 칸 위 56 · 그 줄 위 591 — 보이는 자리는 535. 칸 300..900
rects.set(row, rectOf(300, 900))
focusIn(handler, input)
runFrames()
// 가린 만큼은 900 + 8 − 591 = 317 이지만 칸의 위가 56 + 8 까지만 올라간다 — 300 − 56 − 8 = 236
expect(scroll.value).toBe(236)
})
it('그린 차례 전에 초점이 다른 곳으로 갔으면 올리지 않는다', () => {
const scroll = trackScroll(scroller, 0)
const handler = setup()
focusIn(handler, input)
send.focus()
runFrames()
expect(scroll.value).toBe(0)
})
it('쓰기 칸이 스크롤하지 않으면(좁은 화면) 문서를 올린다', () => {
Object.defineProperty(scroller, 'scrollHeight', { configurable: true, value: 600 })
const documentScroller = document.scrollingElement ?? document.documentElement
const scroll = trackScroll(documentScroller, 40)
const handler = setup()
try {
focusIn(handler, attach)
runFrames()
expect(scroll.value).toBe(40 + 644 + 8 - 591)
} finally {
// 문서의 scrollTop 은 다른 시험에 남기지 않는다
Reflect.deleteProperty(documentScroller, 'scrollTop')
}
})
it('쓰기 칸에 단 처리기는 안에서 올라온 focusin · pointerdown 을 받는다 (사건의 target 으로 가른다)', () => {
const scroll = trackScroll(scroller, 0)
const handler = setup()
scroller.addEventListener('focusin', (event) => handler.onFocusIn(event))
scroller.addEventListener('pointerdown', (event) => handler.onPointerDown(event as PointerEvent), { capture: true })
// 누른 [파일 첨부]는 그대로 (focus 가 focusin 을 올려 보낸다)
attach.dispatchEvent(new Event('pointerdown', { bubbles: true }))
attach.focus()
runFrames()
expect(scroll.value).toBe(0)
// 1초 뒤 Tab 으로 온 초점은 올린다 — 그사이 지나간 칸(input)은 초점이 떠나 올리지 않는다
clock.advance(1000)
input.focus()
attach.focus()
runFrames()
expect(scroll.value).toBe(644 + 8 - 591)
})
})
@@ -0,0 +1,79 @@
import type { Clock } from '@/composables/useClock'
// 아래에 붙은 줄(position: sticky — 쓰기 화면의 [보내기] 줄) 밑에 초점이 가리지 않게 한다 (WCAG 2.4.11 — 2026-11-10 사용자 보고의 고침과 함께).
// 브라우저는 초점을 준 요소가 스크롤 상자 안에 있으면 스크롤하지 않는다 — 그 줄 밑에 들어간 요소(좁은 화면에서 Tab 으로 간 [파일 첨부] 등)는
// 가린 채 남는다. 키보드 · 코드(오류 칸으로 옮기는 초점 등)로 초점이 들어오면, 브라우저가 스스로 스크롤한 다음 그림에 재어 가린 만큼 올린다.
// 마우스 · 손가락으로 누른 요소는 보이는 곳을 누른 것이므로 그대로 둔다 — 누른 채 내용이 움직이면 글이 골라진다.
// 드러내는 것은 쓰기 칸의 줄 전체(.compose-field — 받는 사람 줄 · 제목 줄 · 본문 편집 영역)이고, 줄이 보이는 자리보다 크면 줄의 위를 맞춘다.
// 쓰는 곳: 쓰기 화면의 form 에 @focusin · @pointerdown.capture (ComposePage)
/** 드러낸 요소와 아래 줄 사이 · 화면 위와의 틈 (--space-8) */
const FOCUS_GAP_PX = 8
/** 누른 뒤 이 안에 누른 줄로 온 초점은 누른 것으로 본다 — 손가락은 뗄 때 초점이 온다 */
const POINTER_FOCUS_WINDOW_MS = 1000
export interface FocusAboveFooterOptions {
/** 아래에 붙은 줄 */
footer: () => HTMLElement | null
/** 누른 때를 잰다 — 시험은 손으로 돌리는 시계 */
clock: Clock
/** 다음 그림 차례에 부른다 — 기본은 requestAnimationFrame (브라우저가 초점에 맞춰 스크롤한 뒤) */
nextFrame?: (callback: () => void) => void
}
/** 드러낼 칸 — 쓰기 칸의 줄(.compose-field)이면 줄 전체, 아니면 그 요소 */
function boxOf(target: Element): Element {
return target.closest('.compose-field') ?? target
}
/** 아래 줄이 붙어 있는 스크롤 상자 — 넓은 화면은 쓰기 칸, 좁은 화면은 문서 */
function scrollerOf(element: HTMLElement): Element {
for (let node = element.parentElement; node; node = node.parentElement) {
const { overflowY } = getComputedStyle(node)
if ((overflowY === 'auto' || overflowY === 'scroll') && node.scrollHeight > node.clientHeight) return node
}
return document.scrollingElement ?? document.documentElement
}
export function useFocusAboveFooter(options: FocusAboveFooterOptions) {
const nextFrame = options.nextFrame ?? ((callback: () => void) => void requestAnimationFrame(() => callback()))
/** 마지막으로 누른 곳과 때 */
let pressed: { target: Node; at: number } | null = null
function onPointerDown(event: PointerEvent) {
if (event.target instanceof Node) pressed = { target: event.target, at: options.clock.now() }
}
/** 방금 이 칸 안을 눌렀다 — 그 누름으로 온 초점이다 */
function pressedInside(box: Element): boolean {
return (
pressed !== null && options.clock.now() - pressed.at < POINTER_FOCUS_WINDOW_MS && box.contains(pressed.target)
)
}
/** 가린 만큼 올린다 — 칸의 위가 보이는 자리 위로 나가지 않을 만큼까지 */
function reveal(target: Element, box: Element, footer: HTMLElement) {
// 그사이 초점이 다른 곳으로 갔거나 화면을 떠났다
if (document.activeElement !== target || !footer.isConnected) return
const rect = box.getBoundingClientRect()
const hidden = rect.bottom + FOCUS_GAP_PX - footer.getBoundingClientRect().top
if (hidden <= 0) return
const scroller = scrollerOf(footer)
const viewTop = scroller === document.scrollingElement ? 0 : scroller.getBoundingClientRect().top
const delta = Math.min(hidden, rect.top - viewTop - FOCUS_GAP_PX)
if (delta > 0) scroller.scrollTop += delta
}
function onFocusIn(event: FocusEvent) {
const target = event.target
if (!(target instanceof Element)) return
const footer = options.footer()
// 아래 줄 안의 단추는 늘 보인다
if (!footer || footer.contains(target)) return
const box = boxOf(target)
if (pressedInside(box)) return
nextFrame(() => reveal(target, box, footer))
}
return { onFocusIn, onPointerDown }
}
@@ -0,0 +1,224 @@
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 footerSource from '@/components/mail/ComposeFooter.vue?raw'
import recipientSource from '@/components/mail/RecipientField.vue?raw'
import suggestionListSource from '@/components/mail/RecipientSuggestionList.vue?raw'
import editorSource from '@/components/mail/editor/MailEditor.vue?raw'
import toolbarSource from '@/components/mail/editor/EditorToolbar.vue?raw'
import { CLOCK } from '@/composables/useClock'
import { useAuthStore } from '@/stores/auth.store'
import { createManualClock, type ManualClock } from '@/test-utils/manual-clock'
import { flushPromises, installMockApi, ok, restoreApiAdapter } from '@/test-utils/mock-api'
import { installProseMirrorDom } from '@/test-utils/prosemirror-dom'
import { createTestRouter, routeViewProvide } from '@/test-utils/router'
import ComposePage from './ComposePage.vue'
import composeSource from './ComposePage.vue?raw'
// 쓰기 화면 스크롤 (2026-11-10 사용자 보고 — 전 직원을 숨은참조에 넣자 받는 사람 칸이 늘어 쓰기 칸이 넘쳤고, 바깥 스크롤 + 본문 스크롤의
// 이중 스크롤 속에 맨 아래 [보내기] 줄이 화면 밖으로 숨었다. 본문 위에서 휠을 돌리면 본문만 움직였다).
// - [보내기] 줄은 스크롤하는 칸의 아래에 붙는다(sticky) · 받는 사람 칸은 칩 세 줄을 넘으면 칸 안이 스크롤 · 넓은 화면은 본문 편집 영역이 200 까지 줄어든다
// - 겹침 차례: 툴바 2 < [보내기] 줄 3 < 추천 목록 · 메뉴를 연 툴바 4
// jsdom 은 크기를 재지 않는다 — 배치 값은 원본의 style 을 읽고(ContactTransferDialogs.layout.spec 과 같은 읽기), 초점 올리기는 상자 크기를 정해 준다.
// 토큰 값(tokens.css — 받는 사람 칸 96 · 넓은 화면 편집 영역 200)은 시험에서 읽지 못한다(vitest 는 CSS 파일을 비운다 — ?raw 도) — 컴포넌트가 그 토큰을
// 쓰는지만 보고, 실제 폭 · 높이(1440×900 · 1366×768 · 1000×600 · 360×800)는 브라우저로 확인했다 (디자인 README "메일 쓰기 › 받는 사람이 많을 때")
/** 원본 style 의 CSS — 주석은 뺀다 */
function cssOf(source: string): string {
const start = source.indexOf('<style')
const css = start < 0 ? source : source.slice(source.indexOf('>', start) + 1, source.lastIndexOf('</style>'))
return css.replace(/\/\*[\s\S]*?\*\//g, '')
}
/** { 로 여는 블록의 안쪽 — open 은 그 { 의 자리 */
function blockAt(css: string, open: number): { inner: string; end: number } {
let depth = 0
for (let i = open; i < css.length; i += 1) {
if (css[i] === '{') depth += 1
else if (css[i] === '}') {
depth -= 1
if (depth === 0) return { inner: css.slice(open + 1, i), end: i }
}
}
return { inner: '', end: css.length }
}
/** 미디어 쿼리 블록을 뺀 맨 위 단계의 CSS */
function topLevel(css: string): string {
let out = ''
let from = 0
for (let at = css.indexOf('@media', from); at >= 0; at = css.indexOf('@media', from)) {
out += css.slice(from, at)
from = blockAt(css, css.indexOf('{', at)).end + 1
}
return out + css.slice(from)
}
/** 한 규칙의 본문 — media 를 주면 그 미디어 쿼리 안에서, 주지 않으면 맨 위 단계에서 */
function ruleBody(source: string, selector: string, media?: string): string {
const css = cssOf(source)
let scope = topLevel(css)
if (media) {
const at = css.indexOf(`@media ${media}`)
scope = at < 0 ? '' : blockAt(css, css.indexOf('{', at)).inner
}
for (const [, found = '', body = ''] of scope.matchAll(/([^{}]+)\{([^}]*)\}/g)) {
if (found.trim() === selector) return body
}
return ''
}
const WIDE = '(min-width: 70rem)'
describe('쓰기 화면 스크롤 — 원본 style (2026-11-10 사용자 보고)', () => {
it('[보내기] 줄은 스크롤하는 칸의 아래에 붙는다 — sticky · bottom 0 · 흰 바탕, 위 선 · 여백은 그대로', () => {
const footer = ruleBody(footerSource, '.compose__footer')
expect(footer).toMatch(/position:\s*sticky;/)
expect(footer).toMatch(/bottom:\s*0;/)
expect(footer).toMatch(/z-index:\s*3;/)
expect(footer).toMatch(/background:\s*var\(--color-surface\);/)
expect(footer).toMatch(/border-top:\s*var\(--border-width\) solid var\(--color-border\);/)
expect(footer).toMatch(/padding:\s*var\(--space-10\) var\(--content-inline\);/)
})
it('겹침 차례 — 툴바 2 < [보내기] 줄 3 < 추천 목록 · 메뉴를 연 툴바 4 (툴바는 줄 밑으로 지나가고, 툴바의 메뉴와 추천 목록은 줄 위에 뜬다)', () => {
const z = (source: string, selector: string) => Number(ruleBody(source, selector).match(/z-index:\s*(\d+);/)?.[1])
expect(z(toolbarSource, '.editor-toolbar')).toBe(2)
expect(z(footerSource, '.compose__footer')).toBe(3)
expect(z(toolbarSource, '.editor-toolbar--popup-open')).toBe(4)
expect(z(suggestionListSource, '.recipient-suggestions')).toBe(4)
})
it('받는 사람 · 참조 · 숨은참조 — 칩 · 입력 칸은 96(+ 위 여백 4)을 넘으면 그 칸 안에서 스크롤한다', () => {
const control = ruleBody(recipientSource, '.recipient-field__control')
expect(control).toMatch(/max-height:\s*calc\(var\(--size-recipient-chips-max\) \+ var\(--space-4\)\);/)
expect(control).toMatch(/overflow-y:\s*auto;/)
expect(control).toMatch(/overflow-x:\s*hidden;/)
// 안쪽 여백 4(위 · 좌우)를 바깥 여백으로 당긴다 — 자리 · 폭 · 줄바꿈은 그대로
expect(control).toMatch(/margin:\s*calc\(var\(--space-4\) \* -1\) calc\(var\(--space-4\) \* -1\) 0;/)
expect(control).toMatch(/padding:\s*var\(--space-4\) var\(--space-4\) 0;/)
expect(control).toMatch(/flex:\s*1 1 calc\(20rem \+ var\(--space-4\) \* 2\);/)
})
it('넓은 화면은 본문 편집 영역이 300 에서 200 까지 먼저 줄어 쓰기 칸 전체가 화면 안에 든다 — 좁은 화면은 300 그대로', () => {
expect(ruleBody(editorSource, '.mail-editor__frame')).toMatch(/min-height:\s*var\(--size-editor-body-min\);/)
expect(ruleBody(editorSource, '.mail-editor__frame', WIDE)).toMatch(/min-height:\s*var\(--size-editor-body-min-fit\);/)
expect(ruleBody(composeSource, '.compose__editor')).toMatch(
/min-height:\s*calc\(var\(--size-editor-toolbar\) \+ var\(--size-editor-body-min\) \+ var\(--border-width\)\);/,
)
expect(ruleBody(composeSource, '.compose__editor', WIDE)).toMatch(
/min-height:\s*calc\(var\(--size-editor-toolbar\) \+ var\(--size-editor-body-min-fit\) \+ var\(--border-width\)\);/,
)
expect(ruleBody(composeSource, '.compose', WIDE)).toMatch(/overflow-y:\s*auto;/)
})
it('쓰기 칸은 위치를 잡는다 — 안의 화면 낭독 전용 요소(.visually-hidden)가 앱 틀을 넘겨 문서까지 스크롤하지 않게', () => {
expect(ruleBody(composeSource, '.compose')).toMatch(/position:\s*relative;/)
})
})
describe('쓰기 화면 — [보내기] 줄 밑에 가린 칸으로 온 초점은 올린다 (WCAG 2.4.11)', () => {
const USER = { email: 'ara.kim@arasystem.it.kr', name: '김아라' }
/** 지연 로딩한 편집기를 기다리는 시간 — ComposePage.spec 과 같다 (테스트 한도 20초보다 짧게) */
const EDITOR_LOAD_TIMEOUT_MS = 15_000
let wrapper: VueWrapper | null = null
let pinia: Pinia
let clock: ManualClock
let frames: FrameRequestCallback[]
beforeAll(installProseMirrorDom)
beforeAll(async () => {
await import('@/components/mail/editor/MailEditor.vue')
}, EDITOR_LOAD_TIMEOUT_MS)
beforeEach(() => {
pinia = createPinia()
setActivePinia(pinia)
useAuthStore().markAuthenticated(USER)
clock = createManualClock(1_000_000)
frames = []
vi.spyOn(window, 'requestAnimationFrame').mockImplementation((callback) => {
frames.push(callback)
return frames.length
})
})
afterEach(() => {
wrapper?.unmount()
wrapper = null
vi.restoreAllMocks()
restoreApiAdapter()
Reflect.deleteProperty(document.scrollingElement ?? document.documentElement, 'scrollTop')
document.body.innerHTML = ''
})
async function mountCompose() {
installMockApi(() => ok({ items: [], pageInfo: { hasNext: false, nextCursor: null, total: 0 } }))
const router = createTestRouter()
await router.push('/mail/compose')
await router.isReady()
wrapper = mount(ComposePage, {
attachTo: document.body,
global: { plugins: [pinia, router], provide: { ...routeViewProvide(router), [CLOCK as symbol]: clock } },
})
await flushPromises()
await vi.waitFor(
() => {
if (!wrapper?.find('.ProseMirror').exists()) throw new Error('편집기를 불러오는 중')
},
{ timeout: EDITOR_LOAD_TIMEOUT_MS },
)
await flushPromises()
return wrapper
}
function rectOf(top: number, bottom: number): DOMRect {
return { top, bottom, height: bottom - top, left: 0, right: 0, width: 0, x: 0, y: top, toJSON: () => ({}) } as DOMRect
}
function runFrames() {
for (const frame of frames.splice(0)) frame(0)
}
it('좁은 화면(문서가 스크롤) — Tab 으로 간 제목 줄이 [보내기] 줄에 가렸으면 가린 만큼(+ 8) 문서를 올리고, 누른 칸은 그대로 둔다', async () => {
const page = await mountCompose()
const subject = page.get('#compose-subject').element as HTMLInputElement
const row = subject.closest('.compose-field')
const footer = page.get('.compose__footer').element
// [보내기] 줄 위 500 · 제목 줄 470~514 — 줄 아래 14px 이 가렸다
vi.spyOn(Element.prototype, 'getBoundingClientRect').mockImplementation(function (this: Element) {
if (this === footer) return rectOf(500, 565)
if (this === row) return rectOf(470, 514)
return rectOf(0, 0)
})
const documentScroller = document.scrollingElement ?? document.documentElement
const scroll = { top: 300 }
Object.defineProperty(documentScroller, 'scrollTop', {
configurable: true,
get: () => scroll.top,
set: (value: number) => {
scroll.top = value
},
})
runFrames()
// 누른 칸은 그대로 — 보이는 곳을 누른 것이다 (누른 채 내용이 움직이면 글이 골라진다)
subject.dispatchEvent(new Event('pointerdown', { bubbles: true }))
subject.focus()
runFrames()
expect(scroll.top).toBe(300)
// 1초 뒤 키보드로 다시 오면 올린다 — 514 + 8 − 500 = 22
clock.advance(1000)
subject.blur()
subject.focus()
runFrames()
expect(scroll.top).toBe(322)
// [보내기] 줄 안의 단추는 늘 보인다 — 올리지 않는다
;(page.get('.compose__send').element as HTMLButtonElement).focus()
runFrames()
expect(scroll.top).toBe(322)
})
})
+38 -2
View File
@@ -23,6 +23,11 @@
// 그렇게 맞추지 못한 옛 주소(방문 기록의 ?draft=<옛 id>)로 돌아오면 이 탭이 마지막으로 저장한 저장본을 연다 (useComposeDraftLink).
// - 휴지통 · 되돌리기는 useDraftTrash, 머리 · 아래 단추 줄 · 칸 안내 띠는 ComposeHeader · ComposeFooter · ComposeAlert.
// 받는 사람 자동 완성: 열 때 추천을 한 번 받아 두고 세 칸이 함께 쓴다 (useRecipientSuggestions → RecipientField).
// 스크롤 (2026-11-10 사용자 보고 — 받는 사람이 많으면 바깥 · 본문 이중 스크롤에 [보내기] 줄이 화면 밖으로 숨었다):
// - [보내기] 줄은 스크롤하는 칸(넓은 화면은 이 쓰기 칸, 좁은 화면은 문서)의 아래에 붙어 늘 보인다 (ComposeFooter — sticky)
// - 받는 사람 · 참조 · 숨은참조는 칩이 세 줄을 넘으면 그 칸 안에서 스크롤한다 (RecipientField)
// - 넓은 화면은 본문 편집 영역이 300 에서 200 까지 먼저 줄어 보통 높이의 화면에서는 쓰기 칸 전체가 화면 안에 든다 (아래 style · MailEditor)
// - 그 줄에 가린 칸으로 키보드 · 코드의 초점이 오면 가린 만큼 올린다 (useFocusAboveFooter)
// 본문은 서식 편집기(TipTap) — 이 화면에서만 불러온다(지연 로딩). 보낼 때 서식 본문(bodyHtml)과 같은 내용의 텍스트(body)를 함께 보낸다.
// 편집기 청크를 못 받으면 본문 자리에 사전 문구(SYS_001)와 "다시 시도"를 보인다 (ComposeEditorLoadError).
// 본문에 붙여 넣거나 끌어다 놓은 파일(이미지 포함)은 첨부로 올린다 — 본문의 그림은 서명 그림뿐이다 (사용자 확정).
@@ -91,6 +96,7 @@ import { useComposeDraftLink } from '@/composables/useComposeDraftLink'
import { useComposeLargeFiles } from '@/composables/useComposeLargeFiles'
import { useDraftSaving, type DraftSaveRejected, type DraftSaveRequest } from '@/composables/useDraftSaving'
import { useDraftTrash } from '@/composables/useDraftTrash'
import { useFocusAboveFooter } from '@/composables/useFocusAboveFooter'
import { useLeaveConfirm } from '@/composables/useLeaveConfirm'
import { useMail } from '@/composables/useMail'
import { RECIPIENT_SUGGESTIONS, useRecipientSuggestions } from '@/composables/useRecipientSuggestions'
@@ -325,6 +331,19 @@ const subjectInputRef = ref<HTMLInputElement | null>(null)
const fileInputRef = ref<HTMLInputElement | null>(null)
const attachButtonRef = ref<HTMLButtonElement | null>(null)
const attachmentListRef = ref<InstanceType<typeof ComposeAttachmentGroups> | null>(null)
const footerRef = ref<InstanceType<typeof ComposeFooter> | null>(null)
/**
* 아래에 붙은 [보내기] 줄 밑에 초점이 가리지 않게 (2026-11-10) — 키보드 · 코드로 초점이 들어온 칸이 그 줄에 가렸으면 가린 만큼 올린다.
* 줄은 넘쳐도 늘 보이도록 아래에 붙어 있다 (ComposeFooter · useFocusAboveFooter)
*/
const { onFocusIn: revealFocused, onPointerDown: notePointerDown } = useFocusAboveFooter({
footer: () => {
const element: unknown = footerRef.value?.$el
return element instanceof HTMLElement ? element : null
},
clock,
})
function queryId(value: unknown): string | null {
return typeof value === 'string' && value !== '' ? value : null
@@ -1265,6 +1284,8 @@ watch(
aria-labelledby="compose-title"
novalidate
@submit.prevent="onSubmit"
@focusin="revealFocused"
@pointerdown.capture="notePointerDown"
>
<ComposeHeader
:title="title"
@@ -1423,6 +1444,7 @@ watch(
</div>
<ComposeFooter
ref="footerRef"
:can-send="canSend"
:blocked-reason="sendBlock?.text ?? null"
:sending="sending"
@@ -1466,7 +1488,12 @@ watch(
</template>
<style scoped>
/*
* 위치를 잡은 칸 — 안의 화면 낭독 전용 요소(.visually-hidden — 파일 고르기 칸 등)가 이 칸 안에 든다.
* 잡지 않으면 화면 전체가 기준이 되어 넓은 화면에서 앱 틀(100vh)이 넘치고 문서까지 스크롤했다 (2026-11-10 — 이중 스크롤의 한 갈래)
*/
.compose {
position: relative;
flex: 999 1 35rem;
min-width: 0;
display: flex;
@@ -1525,7 +1552,7 @@ watch(
}
/*
* 본문 서식 편집기 자리 — 툴바(52) + 편집 영역(300) + 아래 선.
* 본문 서식 편집기 자리 — 툴바(52) + 편집 영역(300 — 넓은 화면에서 자리가 모자라면 200 까지, 아래 미디어 쿼리) + 아래 선.
* 편집기를 내려받는 동안에도 같은 높이를 둔다. 포커스는 편집 영역의 아래 선이다 (.compose-field, styles/base.css).
*/
.compose__editor {
@@ -1574,10 +1601,19 @@ watch(
color: var(--color-text-muted);
}
/* 넓은 화면에서는 쓰기 칸만 따로 스크롤한다 (tokens.css 화면 폭 기준) */
/*
* 넓은 화면에서는 쓰기 칸만 따로 스크롤한다 (tokens.css 화면 폭 기준).
* 쓰기 칸이 화면보다 길어지려 하면 본문 편집 영역이 300 에서 200(--size-editor-body-min-fit)까지 먼저 줄어든다 — 받는 사람이 많아도
* 보통 높이의 화면에서는 쓰기 칸 전체가 화면 안에 들고 본문만 스크롤한다(편집 영역의 같은 값은 MailEditor). 그래도 넘치면 쓰기 칸이 스크롤하고
* [보내기] 줄은 아래에 붙어 있다 (ComposeFooter — 2026-11-10 사용자 보고)
*/
@media (min-width: 70rem) {
.compose {
overflow-y: auto;
}
.compose__editor {
min-height: calc(var(--size-editor-toolbar) + var(--size-editor-body-min-fit) + var(--border-width));
}
}
</style>
@@ -0,0 +1,58 @@
import { afterEach, describe, expect, it } from 'vitest'
import { eventPathHas } from './event-path'
// 사건이 지나간 길 — 앞선 처리기가 target 을 문서에서 떼어 내도 dispatch 때의 길로 가른다 (2026-11-10 — 목록 보기의 고르기 상자)
describe('eventPathHas', () => {
afterEach(() => {
document.body.innerHTML = ''
})
/** <a-wrap> 안의 줄(tr 대신 div) — 줄 > 칸 > 단추 > 그림 */
function build() {
document.body.innerHTML =
'<a id="outer"><div id="row"><div id="cell" class="cell"><button id="button"><svg><rect id="picture" /></svg></button></div><span id="text">글</span></div></a>'
const get = (id: string) => document.getElementById(id) as Element
return { outer: get('outer'), row: get('row'), cell: get('cell'), button: get('button'), picture: get('picture'), text: get('text') }
}
/** 줄에 단 처리기에서 본 값 */
function seenByRow(row: Element, target: Element, selector: string, before?: () => void): { path: boolean; closest: boolean } {
let seen = { path: false, closest: false }
const listener = (event: Event) => {
seen = {
path: eventPathHas(event, selector),
closest: event.target instanceof Element && event.target.closest(selector) !== null,
}
}
row.addEventListener('click', listener)
if (before) target.parentElement?.closest('.cell')?.addEventListener('click', before)
target.dispatchEvent(new MouseEvent('click', { bubbles: true }))
row.removeEventListener('click', listener)
return seen
}
it('그림을 누른 click — 단추의 처리기 뒤 그림이 문서에서 떨어져도 길에는 단추가 있다 (closest 는 비었다)', () => {
const { row, picture } = build()
const seen = seenByRow(row, picture, 'button', () => picture.remove())
expect(picture.isConnected).toBe(false)
expect(seen).toEqual({ path: true, closest: false })
})
it('칸의 빈자리 · 칸 밖은 그 선택자가 길에 없다', () => {
const { row, cell, text } = build()
expect(seenByRow(row, cell, 'button').path).toBe(false)
expect(seenByRow(row, cell, '.cell').path).toBe(true)
expect(seenByRow(row, text, 'button, .cell').path).toBe(false)
})
it('사건을 받은 요소(currentTarget)까지만 본다 — 줄을 감싼 바깥 링크는 세지 않는다', () => {
const { row, text } = build()
expect(seenByRow(row, text, 'a').path).toBe(false)
})
})
+14
View File
@@ -0,0 +1,14 @@
// 사건이 지나간 길 — dispatch 할 때 정해져, 앞선 처리기가 화면을 다시 그려 target 이 문서에서 떨어져도 그대로다.
// 줄 전체에 click 을 걸고 "단추 · 링크를 눌렀는가"를 가르는 곳(목록 보기의 줄 — MessageTableRow · ConversationTableRow)은
// target.closest 대신 이것으로 본다 (2026-11-10 사용자 보고 — 목록 보기에서 고르기 상자 가운데를 누르면 target 은 상자 그림(<rect>)인데,
// 단추의 처리기가 고르기를 바꾸자 처리기 사이(microtask)에 Vue 가 다시 그려 그 그림을 떼어 냈다. 줄의 처리기에서 closest('button') 이
// 비어 줄을 누른 것으로 보고 메일까지 열었다)
/** 사건이 지나간 길에서 그 사건을 받은 요소(currentTarget)까지 — 선택자에 맞는 요소가 있었는가 */
export function eventPathHas(event: Event, selector: string): boolean {
for (const node of event.composedPath()) {
if (node instanceof Element && node.matches(selector)) return true
if (node === event.currentTarget) return false
}
return false
}
+12
View File
@@ -252,6 +252,18 @@
--size-editor-size-button: 3.5rem; /* 56px — 글자 크기 단추 */
--size-editor-toolbar: 3.25rem; /* 52px — 툴바 높이 (위아래 4) */
--size-editor-body-min: 18.75rem; /* 300px — 본문 편집 영역의 최소 높이 (편집기를 불러오는 동안의 자리도 같다) */
/*
* 200px — 넓은 화면(70rem 이상 — 쓰기 칸이 화면 높이에 묶여 따로 스크롤한다)에서 쓰기 칸이 화면보다 길어지려 할 때 본문 편집 영역이 줄어드는 한계.
* 자리가 넉넉하면 지금처럼 300 이상으로 늘어나고, 받는 사람이 많은 낮은 화면에서만 이 값까지 줄어 쓰기 칸 전체가 화면 안에 든다 —
* 그 밑으로는 쓰기 칸이 스크롤한다 (2026-11-10 사용자 보고 — 바깥 스크롤 + 본문 스크롤의 이중 스크롤)
*/
--size-editor-body-min-fit: 12.5rem;
/*
* 96px — 받는 사람 · 참조 · 숨은참조 칸(칩 + 입력)이 보이는 최대 높이: 칩 28 · 입력 34 · 줄 사이 4 로 맨 위에서는 칩 세 줄,
* 맨 아래(입력 자리)에서는 칩 두 줄 + 입력 줄이 줄 사이에서 끊긴다 — 반쯤 잘린 칩이 보이지 않게 (칸의 위 여백 4 는 RecipientField 가 더한다).
* 넘으면 그 칸 안에서 스크롤한다 (2026-11-10 사용자 보고 — 전 직원을 숨은참조에 넣자 칸이 늘어 [보내기]가 화면 밖으로 숨었다)
*/
--size-recipient-chips-max: 6rem;
--editor-toolbar-inline: 1.25rem; /* 20px — 툴바 좌우 여백. 버튼 안 12 를 더하면 첫 아이콘이 32px 선 */
--color-editor-pressed-hover: #d5ebed; /* 켜진 버튼에 마우스를 올렸을 때 */
--color-quote-bar: #d0d5dd; /* 원문 인용 막대 — 포커스 선(브랜드색)과 겹치지 않게 회색 */
+1 -1
View File
@@ -1809,7 +1809,7 @@ mailcow 를 흉내 낸 Dovecot(`dovecot/dovecot:2.3.21.1@sha256:547d28f9a893c93a
| --- | --- | --- |
| 메일 엔진 `<PROJECT_NAME>_mail-config` · `_mail-data` | 볼륨 (읽기 전용으로 붙인다) | RocksDB — 메일 · 계정 · 설정 · DKIM 키 · 인증서 전체. `config.json` 이 없으면 bootstrap 모드로 뜬다 |
| DB | `pg_dump -Fc` (멈추지 않는다) | 로그인 세션 · 서명 · 설정 · 대용량 첨부 기록 등. 덤프를 `pg_restore -l` 로 읽어 본 뒤 올린다 |
| 업로드 `<PROJECT_NAME>_uploads` | 볼륨 | 서명 그림(`mail-signatures/`) · `/api/files` · 휴대폰 앱 APK(`app-releases/` — 판마다 30MB 남짓, 지우는 명령이 없어 올린 판이 모두 남는다) |
| 업로드 `<PROJECT_NAME>_uploads` | 볼륨 | 서명 그림(`mail-signatures/`) · `/api/files` · 휴대폰 앱 APK(`app-releases/` — 판마다 65MB 안팎(1.0.0 이 64.4MB — 기기 종류 셋(ABI)이 한 파일에 든다), 지우는 명령이 없어 올린 판이 모두 남는다) |
| env 파일 | `.env.production` · `backend/.env.production` | 비밀이 든 파일 — 저장소가 restic 비밀번호로 암호화된다. 새 서버를 되살릴 때 필요하다 |
| 받지 않는 것 | — | 대용량 첨부 `_files`(30일짜리 전송물 — 기록은 DB 에 있어 복원하면 바이트가 없는 파일은 "찾을 수 없음"), 악성 검사 서명 `_clamav-db`(다시 받는다), 리졸버 · frontend(상태 없음), 로그 |