diff --git a/.claude/rules/error-codes.md b/.claude/rules/error-codes.md index 72b3e2c..f12f1fd 100644 --- a/.claude/rules/error-codes.md +++ b/.claude/rules/error-codes.md @@ -29,21 +29,21 @@ backend/src/common/errors/error-codes.ts ← 여기만 수정한다 | 코드 | HTTP | 상황 | 메시지 | | --- | --- | --- | --- | -| SYS_001 | 500 | 미정의 예외·네트워크 장애 (최종 폴백) | "일시적인 시스템 오류가 발생했습니다. 잠시 후 다시 시도해 주세요." | -| REQ_001 | 429 | Rate Limit 초과 | "요청이 너무 많습니다. 잠시 후 다시 시도해 주세요." | -| AUTH_001 | 401 | 미인증 상태로 보호 자원 접근 | "로그인이 필요한 서비스입니다. 로그인 후 이용해 주세요." | -| AUTH_002 | — | **클라이언트 전용** · 세션 만료 | "안전을 위해 로그아웃 되었습니다. 다시 로그인해 주세요." | -| AUTH_003 | 403 | 권한 부족 | "해당 메뉴나 기능에 접근할 수 있는 권한이 없습니다." | -| AUTH_004 | 401 | 로그인 자격 증명 불일치 (계정 없음·비밀번호 틀림을 구분하지 않음) | "이메일 또는 비밀번호가 올바르지 않습니다." | +| SYS_001 | 500 | 미정의 예외·네트워크 장애 (최종 폴백) | "문제가 생겨 하던 일을 마치지 못했습니다. 잠시 뒤 다시 시도해 주세요." | +| REQ_001 | 429 | Rate Limit 초과 · 실패 횟수 한도 · 같은 일이 진행 중 | "너무 여러 번 시도했거나 같은 일이 이미 진행 중입니다. 잠시 뒤 다시 시도해 주세요." | +| AUTH_001 | 401 | 미인증 상태로 보호 자원 접근 | "로그인이 필요합니다. 로그인한 뒤 다시 시도해 주세요." | +| AUTH_002 | — | **클라이언트 전용** · 세션 만료 | "안전을 위해 로그아웃했습니다. 다시 로그인해 주세요." | +| AUTH_003 | 403 | 권한 부족 | "이 기능을 쓸 수 있는 권한이 없습니다." | +| AUTH_004 | 401 | 로그인 자격 증명 불일치 (계정 없음·비밀번호 틀림을 구분하지 않음) | "메일 주소나 비밀번호가 맞지 않습니다. 다시 확인해 주세요." | | AUTH_005 | 400 | 로그인한 채 본인 확인 실패 — 비밀번호 변경의 지금 비밀번호가 틀림. **401 이 아니다** — 클라이언트는 401 이면 세션을 갱신하고 같은 요청을 다시 보낸다 | "지금 비밀번호가 올바르지 않습니다." | -| AUTH_006 | 400 | 2단계 인증 코드 틀림(로그인 코드 단계 · 켜기 · 끄기 · 비밀번호 바꾸기 — 12단계). 401 이 아니다(위와 같은 까닭) | "코드가 올바르지 않습니다. 휴대폰의 날짜와 시간이 자동으로 맞춰지도록 설정되어 있는지 확인해 주세요." | +| AUTH_006 | 400 | 2단계 인증 코드 틀림(로그인 코드 단계 · 켜기 · 끄기 · 비밀번호 바꾸기 — 12단계). 401 이 아니다(위와 같은 까닭) | "코드가 맞지 않습니다. 휴대폰 설정에서 날짜와 시간을 자동으로 맞추게 해 두었는지 확인해 주세요." | | AUTH_007 | 400 | 2단계 인증을 켠 직원인데 코드가 없다 — 화면은 코드 칸을 보인다 | "인증 앱의 코드를 입력해 주세요." | | AUTH_008 | 409 | 2단계 등록이 그사이 바뀌었거나 10분이 지났다 · 이미 켜져 있다 — 처음부터(새 키) | "2단계 인증 설정이 그사이 바뀌었거나 시간이 지났습니다. 처음부터 다시 해 주세요." | | AUTH_009 | 409 | 앱 비밀번호 상한(5개) | "5개까지 만들 수 있습니다. 쓰지 않는 앱 비밀번호를 지운 뒤 만들어 주세요." | | AUTH_010 | 403 | 제한된 세션(새 비밀번호 정하기 · 2단계 인증 켜기 전 — 13단계)으로 허용하지 않은 API. 화면은 알림 없이 `me` 를 다시 받아 그 단계로 간다. 403 의 상태 역매핑은 그대로 `AUTH_003` | "먼저 비밀번호를 바꾸거나 2단계 인증을 켜 주세요." | | VAL_001 | 400 / 422 | 입력 검증 실패(빠진 값 · 형식 · 길이 · 개수 한도) | "입력하신 내용을 다시 확인해 주세요. 빠진 칸이 있거나 형식이나 길이가 맞지 않습니다." | -| RES_001 | 404 | 리소스 없음 | "요청하신 정보나 페이지를 찾을 수 없습니다." | -| BIZ_001 | 400 | 그 외 비즈니스 규칙 위반 | "요청하신 작업을 완료하지 못했습니다. 다시 시도해 주세요." | +| RES_001 | 404 | 리소스 없음 | "찾을 수 없습니다. 이미 지워졌거나 옮겨졌을 수 있습니다." | +| BIZ_001 | 400 | 그 외 비즈니스 규칙 위반 | "하려던 일을 마치지 못했습니다. 다시 시도해 주세요." | ### AUTH_002 (클라이언트 전용) diff --git a/.claude/rules/security.md b/.claude/rules/security.md index ef7916c..b14ac9b 100644 --- a/.claude/rules/security.md +++ b/.claude/rules/security.md @@ -169,32 +169,33 @@ 2. **수정이 다른 것을 깨뜨리는가** — 생태계 버전이 어긋나면 더 큰 위험 3. 미룬다면 **언제 다시 볼지** 조건을 적어둔다 -> **현재 유예 중인 항목** (2026-10-27 점검): +> **현재 유예 중인 항목** (frontend 2026-10-27 · backend 2026-11-05 점검): > - frontend `eslint-plugin-vue@9` → `postcss-selector-parser` (moderate, 선택자 파싱 CPU 과다 사용) > - 도달 경로: 개발 시 린트에서만 돈다. 입력은 우리 소스 코드뿐이라 외부 공격 경로가 없다. 운영 번들·이미지에 포함되지 않는다. > - 미루는 이유: 수정판이 `eslint-plugin-vue@10`(메이저)이라 린트 규칙이 바뀌고 설정 이관이 필요하다. > - 다시 볼 시점: ESLint 스택을 올릴 때, 늦어도 2027-01. -> - backend (2026-10-27 기준 7건 — high 3·moderate 4, 운영 의존성만 보면 6건 — 아래 5종): +> - backend (2026-11-05 기준 23건 — moderate 21 · low 2 · high 0, 운영 의존성만 보면 5건 — 아래 셋): > > | 항목 | 도달 경로 | 미루는 이유 | 다시 볼 시점 | > | --- | --- | --- | --- | -> | `body-parser` 2.3.0 (high, 압축 본문 DoS) | 없음 — Nest 기본 파서를 끄고 JSON 파서만 `inflate:false` 로 둔다. 압축 본문은 해제 전에 415 (실서버 확인) | 수정판 2.4.0 이 2026-10-22 배포 (2주 미만) | 2026-11-05 에 lock 갱신 (express 5.3.0 이 `^2.3.0` 이라 범위 안) | -> | `multer` 2.4.0 (high, 비동기 fileFilter DoS) | 없음 — 우리 fileFilter(upload·mail)는 모두 동기 | 2.5.0 이 2026-10-22 배포 (2주 미만). `@nestjs/platform-express` 최신 12.2.0 도 2.4.0 고정이라 override 를 올릴 근거가 아직 없음 | 2026-11-05 이후 override `^2.5.0` 여부 결정 | -> | `js-yaml` 5.3.0 (moderate, `@nestjs/swagger` 하위) | 없음 — swagger 는 `dump` 만 쓰고 우리 코드는 YAML 을 파싱하지 않는다 | swagger 11.x 가 5.3.0 으로 정확히 고정 (12.x 는 5.5.0 이지만 메이저) | swagger 11.x 가 ≥5.4.1 을 선언한 릴리스 + 2주. 또는 override `^5.4.3` 검토 (swagger 12 가 5.x 를 쓰는 것이 근거) | -> | `fast-uri` 3.1.8 (moderate, 개발 전용) | 없음 — Nest CLI 의 ajv 에서만 쓰이고 운영 이미지에 없다 | 3.1.9 가 2026-10-19 배포 (2주 미만) | 2026-11-02 | -> | `sanitize-html` 2.18.0 (moderate, 버린 iframe 의 대체 내용 재귀 — GHSA-c9rx) | 없음 — 우리 설정은 iframe 을 내용째 버려 대체 내용을 다시 정화하지 않는다 (5만 단계 중첩으로 확인, 2만 단계 회귀 테스트로 고정) | 수정판 2.18.1 이 2026-10-14 배포 (2주 미만) | 2026-10-28 이후 2.18.1 | +> | `js-yaml` 5.3.0 (moderate, `@nestjs/swagger` 하위) | 없음 — swagger 는 `dump` 만 쓰고 우리 코드는 YAML 을 파싱하지 않는다 | swagger 11.4.8(2026-11-04)이 js-yaml 5.5.0 을 선언하지만 배포 2주 미만 | 2026-11-18 이후 `npm update @nestjs/swagger`(`^11` 범위 — lock 만, 컨테이너 안에서) | +> | `glob` 10.5.0 · `minimatch` 9.0.9 (moderate, `typeorm` 0.3.31 하위 — 11-05 새 권고) | 없음 — typeorm 은 기동 때 `database.config.ts` 의 고정 패턴(entities · migrations)만 푼다. 외부 입력이 닿지 않고 우리 코드는 둘을 직접 쓰지 않는다 | 수정판이 메이저(glob 14 · minimatch 11, 2026-10-27)라 override 로 건너뛰지 않는다(아래 규칙 — 같은 메이저만) | typeorm 0.3.x 가 고친 glob 을 선언한 판 + 2주, 또는 typeorm 1.x 로 옮길 때 | +> | 개발 전용 18건 — jest · `@nestjs/cli` · `@swc/cli` · typescript-eslint 아래의 glob · minimatch, `deepmerge`(low) | 없음 — 운영 이미지에 없다 | 상위 스택의 갱신 판이 2주 미만이거나 메이저 | 그 스택을 올릴 때. ⚠️ `npm audit fix` 를 통째로 돌리지 않는다 — 2026-11-05 dry-run 이 `@unrs/resolver-binding-*` 를 1.13 으로 바꾸고 2주 미만 판을 섞었다 | +> +> 2026-11-05 에 올린 것(모두 배포 2주 지난 판 — 컨테이너 안 `npm install … --before=<2주 전 날짜>` 로 골랐다): body-parser 2.3.0 → 2.4.0(lock 만 — express 5.2.1 의 범위 안, qs 6.16.1 · iconv-lite 0.7.3 이 따라옴), +> multer override `^2.4.0` → `^2.5.0`, sanitize-html 2.18.0 → 2.18.1, fast-uri 3.1.8 → 3.1.9. 29건 → 23건(운영 11 → 5, high 3 → 0). > > 2026-10-27 Node 24 전환과 함께 sanitize-html 고정(2.17.5)을 풀어 이전 권고 4건을 해결했다. > 2.18 이 쓰는 htmlparser2 계열은 ESM 전용이라 Jest 설정에서 CJS 로 변환한다 ([[nestjs]] "의존성 추가/제거"). > -> 참고: `@nestjs/platform-express` 11.2.7(2026-09-30) 이상은 multer 2.4.0 을 직접 선언한다 — 올리면 아래 multer override 를 지울 수 있다. +> 참고: `@nestjs/platform-express` 11.x 최신(11.2.8)도 multer 2.4.0 을 정확히 고정하고, 2.5.0 은 12.3.0 부터 선언한다 — multer override 는 platform-express 를 v12.3 이상으로 올릴 때 지운다. > > 같은 점검에서 정리한 것: frontend 22건 → 2건 (axios 1.20.0 · vue 3.5.43 · vite 8.3.4 · oxfmt 0.72.0 으로 갱신, lock 은 Linux 컨테이너에서 생성), > backend 12건 → 7건 (critical 0 — proxy-addr 2.0.8 등). > 새 버전은 배포 후 2주 이상 지난 패치 버전을 골랐다 (공급망 위험 — 갓 나온 버전은 피한다). > **예외: 우리에게 닿는 취약점을 고치는 보안 릴리스**는 2주를 기다리지 않고 CI 가 통과하는 대로 올린다 — 알려진 취약점을 2주 더 두는 위험이 더 크다 > (예: Node 24.22.1 — 2026-10-20 보안 릴리스를 6일 만에 고정, 고정 위치는 [[docker]]). -> 도달 경로가 없다고 확인한 취약점이면 평소처럼 2주를 기다린다 (예: 위 표의 sanitize-html 2.18.1). +> 도달 경로가 없다고 확인한 취약점이면 평소처럼 2주를 기다린다 (예: 2026-11-05 에 올린 sanitize-html 2.18.1 · multer 2.5.0 · body-parser 2.4.0). ### 전이 의존성 취약점은 overrides 로 끊는다 @@ -204,13 +205,14 @@ ```jsonc // backend/package.json "overrides": { - "multer": "^2.4.0" + "multer": "^2.5.0" } ``` > 실제 사례: `@nestjs/platform-express@11` 은 `multer` 를 `2.2.0` 으로 고정하는데 이 버전에 DoS 취약점(high)이 있었다. > `@nestjs/platform-express@12` 는 `multer@2.4.0` 을 쓰지만, v12 로 올리면 나머지 Nest 생태계(v11)와 호환이 깨진다. > **v12 가 하는 일이 결국 multer 를 올리는 것**이므로, override 로 같은 결과를 생태계 업그레이드 없이 얻었다. +> 2026-11-05 multer 2.5.0(다음 DoS 수정)도 같은 길이다 — platform-express 12.3.0 이 2.5.0 을 선언하는 것이 근거, 2.4.0 과 의존성(busboy · type-is · append-field)도 같다. 규칙: diff --git a/.claude/skills/nestjs/SKILL.md b/.claude/skills/nestjs/SKILL.md index 92e71ee..9e1e3cb 100644 --- a/.claude/skills/nestjs/SKILL.md +++ b/.claude/skills/nestjs/SKILL.md @@ -374,7 +374,7 @@ dc exec backend npm uninstall <패키지> (`package.json` 의 `jest` · `test/jest-e2e.json`)의 `transform`·`transformIgnorePatterns` 목록에 그 패키지를 더한다 (이미 있는 ts-jest 로 CJS 변환 — 지금은 htmlparser2 계열 6개). - 전이 의존성의 취약점이 `npm audit fix` 로 안 풀리면 `package.json` 의 `overrides` 를 쓴다 ([[security]]). - 현재 `multer: ^2.4.0` 이 걸려 있다 — `@nestjs/platform-express` 를 v12 로 올릴 때 제거한다. + 현재 `multer: ^2.5.0` 이 걸려 있다(2026-11-05 — platform-express 11.x 최신 11.2.8 도 2.4.0 고정) — `@nestjs/platform-express` 를 v12.3 이상으로 올릴 때 제거한다. --- diff --git a/.claude/skills/vue3/SKILL.md b/.claude/skills/vue3/SKILL.md index 6435519..a71c1cf 100644 --- a/.claude/skills/vue3/SKILL.md +++ b/.claude/skills/vue3/SKILL.md @@ -60,7 +60,7 @@ description: Vue 3 프론트엔드(frontend/) 개발 규칙 — Composition API | 메일함 관리 · 오래 걸리는 처리 (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` — 아래가 모자라면 위로). 되돌릴 수 없는 일은 확인 대화상자(처음 초점 「취소」) 뒤에, 확인한 그 메일함만 — 계약 2절 "메일함 관리" | | 스팸 · 원문 (4단계) | `src/components/mail/MessageSpamButton.vue`(읽기 화면) · 도구 줄의 스팸 단추(`MailSelectionToolbar.vue`) · `MessageMoreMenu.vue`(⋯ › 원문 내려받기) | 스팸 신고 · 스팸 아님은 이동과 `isJunk` 를 한 요청에 보내고(`mailStore.moveMessages(…, { isJunk })`), [되돌리기]는 원래 메일함으로 반대 값을 보낸다. 원문(.eml)은 `useMail().downloadMessageSource()` — 첨부처럼 `confirmSession()` 을 먼저 거친다 — 계약 2절 "스팸 · 원문" | | 메일 필터 (6단계) | `src/pages/settings/MailFilterPage.vue` · `components/settings/MailFilter*.vue`(목록 · 행 · 목록 위 줄 `MailFilterNotice` · 빈 상태 · 안내 상자 `MailFilterNotes` · 고치기 창 `MailFilterDialog` + 조건 `MailFilterConditionFields` · `MailFilterConditionRow` · 할 일 `MailFilterActionFields`) · `mail-filter-ui.ts` · `stores/mail-filter.store.ts` · `composables/useMailFilterActions.ts`(켜기 · 순서 · 지우기 · 되돌리기 알림) · `useMailFilterDialog.ts`(창 상태) · `shared/utils/mail-filter.ts`(검사 · 요약 · 옮길 곳 · 되돌릴 자리) · `shared/constants/mail-filter.ts` · 읽기 화면 ⋯ 「이런 메일로 필터 만들기」(`MessageMoreMenu.vue` → `/settings/mail/filters?from=`) | 바꾸는 일은 모두 `PUT /api/mail-filters` 전체 교체(받은 `version`)이고 **저장은 한 번에 하나**(`saving`). 켜기 · 순서는 화면을 먼저 바꾸고 실패하면(그사이 목록이 안 바뀌었으면) 되돌린다. 창의 저장은 그 순간 목록의 켜짐 값을 쓴다. [되돌리기]는 지운 필터만 앞 · 뒤 필터 기준 자리에 다시 넣고, 누른 차례대로 앞 저장이 끝난 뒤 보낸다(`restoreQueue` — 기다림과 `send()` 사이에 await 를 두지 않는다 — 2026-10-28 리뷰의 경합). `MAIL_012` 는 목록 위 줄 + 창 안 안내와 [다시 불러오기](쓴 내용을 지키고 다시 받은 목록 위에 저장), 늦게 온 409 는 보낼 때의 순번(seq)이 그대로일 때만 줄을 켠다. 검사는 서버와 같은 규칙(전달 주소의 @ 앞은 dot-atom · 64자로 서버보다 좁게)이고 거절은 칸 아래(`VAL_001` · `MAIL_013` 은 알림을 끈다). 필터 화면이 떠 있지 않을 때 실패한 되돌리기는 알림으로(`attachPage` · `pageVisible`). 세션이 바뀌면 늦은 응답을 버린다(`authStore.generation`) — 계약 2절 "메일 필터", 디자인 README "6단계" | -| 이미 받은 메일에 필터 적용 (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단계" | +| 이미 받은 메일에 필터 적용 (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)` 로 한 번만, 같은 메일의 취소는 스토어가 요청 하나로 묶는다. 초점이 든 알림이 닫히면 들어오기 전 요소로(없으면 `
` — `data-focus-fallback`). **붙잡힌 메일은 취소로만 보낸편지함을 떠난다** — 고르기 칸 `MailCheckbox` 의 `unavailable` · `note`(aria-disabled + 까닭), 고르기 · 범위 · 모두 고르기 · `checkedMessages` 가 건너뛰고, 읽기 화면 [삭제]는 확인(처음 초점 「취소」) → 취소 → 휴지통(서버도 `MAIL_015` 로 막는다). 엔진이 풀어도 상태가 바뀌지 않아 `usePendingSendRefresh` 가 가장 이른 시각 + 2초(남으면 5초 × 3)에 목록 · 열린 메일 · 「예약됨」 수를 다시 받는다. 응답을 기다린 뒤 다음 메일로 옮기는 일(`useMessageRemoval` 의 `leaveRemoved(…, messageId)`)은 같은 목록 · 같은 메일을 보고 있을 때만 — 디자인 README "9단계" | diff --git a/backend/package-lock.json b/backend/package-lock.json index 61ff3f5..bd9c850 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -33,7 +33,7 @@ "pg": "^8.20.0", "reflect-metadata": "^0.2.2", "rxjs": "^7.8.1", - "sanitize-html": "^2.18.0", + "sanitize-html": "^2.18.1", "typeorm": "^0.3.28", "winston": "^3.17.0" }, @@ -5764,18 +5764,18 @@ } }, "node_modules/body-parser": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.3.0.tgz", - "integrity": "sha512-2cGmJupaNgg+QUwVLAucDuWuoMZ6EX9iHDRswZ5lsNYEmwPaRknMPCLZz07yTzVq/83p4o/wzbDZbBrTvGGTIw==", + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.4.0.tgz", + "integrity": "sha512-Ifk1KcLrxaGt1occQagQNFNNOqRt1LFuehEnHHzB8Z6cRgIxRU1TBaiIsUiCdA+gy5PL7Y1IX31DNKkRmxtXjQ==", "license": "MIT", "dependencies": { "bytes": "^3.1.2", - "content-type": "^2.0.0", + "content-type": "^2.1.0", "debug": "^4.4.3", "http-errors": "^2.0.1", - "iconv-lite": "^0.7.2", + "iconv-lite": "^0.7.3", "on-finished": "^2.4.1", - "qs": "^6.15.2", + "qs": "^6.16.1", "raw-body": "^3.0.2", "type-is": "^2.1.0" }, @@ -5788,9 +5788,9 @@ } }, "node_modules/body-parser/node_modules/content-type": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.0.0.tgz", - "integrity": "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ==", + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.1.0.tgz", + "integrity": "sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag==", "license": "MIT", "engines": { "node": ">=18" @@ -6715,6 +6715,7 @@ "version": "4.3.1", "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz", "integrity": "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==", + "dev": true, "license": "MIT", "engines": { "node": ">=0.10.0" @@ -7524,9 +7525,9 @@ "license": "MIT" }, "node_modules/fast-uri": { - "version": "3.1.8", - "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.8.tgz", - "integrity": "sha512-GZMtZUTNRpOVIECoXwLNZS5xUGE+mVNbTB8h/7Rwh2TFWcBQiPzTgyZi05BF9UMZKkLJv8XBRJTlU7zg8+ZfMg==", + "version": "3.1.9", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.9.tgz", + "integrity": "sha512-hS/0Wt3Ya043oKXs0sIxrMpKQITXhMLIrVAKKXi4gozGC9v7YRWVnXIZzjNPTO02M9BdxLCWqK1yRXgNziVyJQ==", "dev": true, "funding": [ { @@ -8413,9 +8414,9 @@ } }, "node_modules/iconv-lite": { - "version": "0.7.2", - "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.2.tgz", - "integrity": "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==", + "version": "0.7.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.3.tgz", + "integrity": "sha512-IKXpvIzjnC9XTAUbVBcMfGS0EPaIXtW6v+zr+RRp+hqULEpo0owZax6wyRwPOJbWbzjYspQwusTsfVr0ifh4uQ==", "license": "MIT", "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" @@ -10305,9 +10306,9 @@ "license": "MIT" }, "node_modules/multer": { - "version": "2.4.0", - "resolved": "https://registry.npmjs.org/multer/-/multer-2.4.0.tgz", - "integrity": "sha512-7dqa0ZcFfzbefdTuIkzOSMvZWC0J7FLqBOjJUZvDCXShIURWKxAyTT1wHhnE5q19c7jOJf43IYYKjBmZVZmvhg==", + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/multer/-/multer-2.5.0.tgz", + "integrity": "sha512-BsqOzYGVq49JcW2IzoSAxeKYRevb+dGGPPWleOR8Lrlk4igwxu1/9pwSXlByhCb7MVZDrNJFC0IBP5/O5nLYRw==", "license": "MIT", "dependencies": { "append-field": "^1.0.0", @@ -11340,9 +11341,9 @@ "license": "MIT" }, "node_modules/qs": { - "version": "6.16.0", - "resolved": "https://registry.npmjs.org/qs/-/qs-6.16.0.tgz", - "integrity": "sha512-h6fhOIaRrID2CbEY2fqs+7t+UXZo+MLAnU5gRIq85uFtdiUPCdsApMlHhXogKVM4HM2DVbIjGNTTYH2OcmP1vA==", + "version": "6.16.1", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.16.1.tgz", + "integrity": "sha512-5Px6J7rfHBAGUhNQ3es4YzcOdBBw0SDcVtThJml4RdnKw4tKs2epA0kPZ+K8X+WiHktT93RHVcQ/3ym5aTCgtg==", "license": "BSD-3-Clause", "dependencies": { "es-define-property": "^1.0.1", @@ -11601,12 +11602,11 @@ "license": "MIT" }, "node_modules/sanitize-html": { - "version": "2.18.0", - "resolved": "https://registry.npmjs.org/sanitize-html/-/sanitize-html-2.18.0.tgz", - "integrity": "sha512-CvY+PV+NBhxe3BnjFI5f//vEDKLULm9OlZArso7gdgVsWvanBuq9PNDx+/2llfL+8XFYa/UNBxvqRBg3TWU2Ug==", + "version": "2.18.1", + "resolved": "https://registry.npmjs.org/sanitize-html/-/sanitize-html-2.18.1.tgz", + "integrity": "sha512-2X5lC8fYla8YaT8g73Nj+HcLWXan5dLyCWLgVvaOmfw7JI78IRsyNtIDytTHdn7hBQbIkCah7Zbmd5jabzeDig==", "license": "MIT", "dependencies": { - "deepmerge": "^4.3.1", "escape-string-regexp": "^4.0.0", "htmlparser2": "^12.0.0", "is-plain-object": "^5.1.0", diff --git a/backend/package.json b/backend/package.json index 3741abb..964d1a7 100644 --- a/backend/package.json +++ b/backend/package.json @@ -53,7 +53,7 @@ "pg": "^8.20.0", "reflect-metadata": "^0.2.2", "rxjs": "^7.8.1", - "sanitize-html": "^2.18.0", + "sanitize-html": "^2.18.1", "typeorm": "^0.3.28", "winston": "^3.17.0" }, @@ -90,7 +90,7 @@ "typescript-eslint": "^8.20.0" }, "overrides": { - "multer": "^2.4.0" + "multer": "^2.5.0" }, "jest": { "moduleFileExtensions": [ diff --git a/backend/src/common/errors/error-codes.ts b/backend/src/common/errors/error-codes.ts index 814f837..d179cc1 100644 --- a/backend/src/common/errors/error-codes.ts +++ b/backend/src/common/errors/error-codes.ts @@ -20,30 +20,32 @@ export interface ErrorCodeDefinition { const COMMON_ERROR_CODES = { SYS_001: { status: HttpStatus.INTERNAL_SERVER_ERROR, - message: '일시적인 시스템 오류가 발생했습니다. 잠시 후 다시 시도해 주세요.', + message: + '문제가 생겨 하던 일을 마치지 못했습니다. 잠시 뒤 다시 시도해 주세요.', }, REQ_001: { status: HttpStatus.TOO_MANY_REQUESTS, - message: '요청이 너무 많습니다. 잠시 후 다시 시도해 주세요.', + message: + '너무 여러 번 시도했거나 같은 일이 이미 진행 중입니다. 잠시 뒤 다시 시도해 주세요.', }, AUTH_001: { status: HttpStatus.UNAUTHORIZED, - message: '로그인이 필요한 서비스입니다. 로그인 후 이용해 주세요.', + message: '로그인이 필요합니다. 로그인한 뒤 다시 시도해 주세요.', }, AUTH_002: { status: HttpStatus.UNAUTHORIZED, - message: '안전을 위해 로그아웃 되었습니다. 다시 로그인해 주세요.', + message: '안전을 위해 로그아웃했습니다. 다시 로그인해 주세요.', // 세션 만료는 클라이언트가 "인증된 세션이 있었는데 401 을 받은" 상황에서 생성한다. clientOnly: true, }, AUTH_003: { status: HttpStatus.FORBIDDEN, - message: '해당 메뉴나 기능에 접근할 수 있는 권한이 없습니다.', + message: '이 기능을 쓸 수 있는 권한이 없습니다.', }, // 로그인 자격 증명 불일치 — 계정 없음/비밀번호 틀림을 구분하지 않는다 (.claude/rules/security.md) AUTH_004: { status: HttpStatus.UNAUTHORIZED, - message: '이메일 또는 비밀번호가 올바르지 않습니다.', + message: '메일 주소나 비밀번호가 맞지 않습니다. 다시 확인해 주세요.', }, // 비밀번호 바꾸기(PUT /api/auth/password)의 지금 비밀번호가 틀림 — 세션은 그대로다. // 401 이 아니라 400 인 까닭: 웹 클라이언트는 401 이면 세션을 갱신하고 같은 요청을 다시 보낸다 (계약 1절) @@ -56,7 +58,7 @@ const COMMON_ERROR_CODES = { AUTH_006: { status: HttpStatus.BAD_REQUEST, message: - '코드가 올바르지 않습니다. 휴대폰의 날짜와 시간이 자동으로 맞춰지도록 설정되어 있는지 확인해 주세요.', + '코드가 맞지 않습니다. 휴대폰 설정에서 날짜와 시간을 자동으로 맞추게 해 두었는지 확인해 주세요.', }, // 2단계 인증을 켠 직원의 요청에 코드가 없음 — 다른 곳에서 켠 뒤의 화면. 화면은 코드 칸을 보인다 (계약 5절) AUTH_007: { @@ -88,11 +90,11 @@ const COMMON_ERROR_CODES = { }, RES_001: { status: HttpStatus.NOT_FOUND, - message: '요청하신 정보나 페이지를 찾을 수 없습니다.', + message: '찾을 수 없습니다. 이미 지워졌거나 옮겨졌을 수 있습니다.', }, BIZ_001: { status: HttpStatus.BAD_REQUEST, - message: '요청하신 작업을 완료하지 못했습니다. 다시 시도해 주세요.', + message: '하려던 일을 마치지 못했습니다. 다시 시도해 주세요.', }, } as const satisfies Record; @@ -109,24 +111,24 @@ const COMMON_ERROR_CODES = { const DOMAIN_ERROR_CODES = { FILE_001: { status: HttpStatus.BAD_REQUEST, - message: '허용되지 않는 파일 형식입니다. 파일을 확인해 주세요.', + message: + '쓸 수 없는 형식의 파일입니다. 그 파일을 빼거나 다른 파일로 바꿔 주세요.', }, FILE_002: { status: HttpStatus.PAYLOAD_TOO_LARGE, - message: '파일 크기가 허용 범위를 초과했습니다.', + message: '파일이 너무 커서 올릴 수 없습니다.', }, // 대용량 첨부(11단계 — 계약 4절) — 올리기 · 파일의 지금 상태와 맞지 않는 요청: 끝낸 올리기에 조각 · 같은 조각을 동시에 · 받은 조각과 내용이 다른 조각 · // 빠진 조각이 있는데 끝내기 · 끝내기가 진행 중 · 살아 있는 링크에 실린 파일 지우기. 아무것도 바꾸지 않았다 — 클라이언트는 GET 으로 상태를 보고 잇는다 FILE_003: { status: HttpStatus.CONFLICT, message: - '파일 상태가 그사이 바뀌어 처리하지 못했습니다. 다시 시도해 주세요.', + '그사이 파일이 바뀌어 하던 일을 마치지 못했습니다. 다시 시도해 주세요.', }, // 조각의 Content-Digest(sha-256)가 받은 바이트와 다르다 — 그 조각을 받지 않았다 (같은 조각을 다시 보낸다) FILE_004: { status: HttpStatus.BAD_REQUEST, - message: - '파일을 올리는 중에 일부가 제대로 전달되지 않았습니다. 다시 시도해 주세요.', + message: '파일 일부가 제대로 올라가지 않았습니다. 다시 시도해 주세요.', }, // 직원 보관 한도(보관량 · 보내지 않은 파일 수)를 넘는다 — 만들지 않았다. 문구에 숫자를 넣지 않는다(한도가 env 로 바뀐다) FILE_005: { @@ -138,7 +140,7 @@ const DOMAIN_ERROR_CODES = { FILE_006: { status: HttpStatus.INSUFFICIENT_STORAGE, message: - '서버의 저장 공간이 모자라 지금은 파일을 올릴 수 없습니다. 잠시 뒤 다시 시도해 주세요.', + '회사 저장 공간이 모자라 지금은 파일을 올릴 수 없습니다. 잠시 뒤 다시 시도해 주세요.', }, // 더 내려받을 수 없는 파일 — 기간 지남 · 공유 멈춤 · 횟수 초과 · 위험 · 찾을 수 없음 (공개 받기 · 우리 웹메일 받기) FILE_007: { @@ -163,14 +165,14 @@ const DOMAIN_ERROR_CODES = { MAIL_003: { status: HttpStatus.BAD_REQUEST, message: - '메일함 용량이 부족합니다. 불필요한 메일을 정리한 뒤 다시 시도해 주세요.', + '메일함이 가득 찼습니다. 필요 없는 메일을 지우고 휴지통을 비운 뒤 다시 시도해 주세요.', }, // 발송 때 엔진에서 첨부(blob)를 찾지 못함 — 올려 두고 오래 지나 만료된 업로드·그사이 지워진 원본 메일의 첨부(전달)·잘못된 첨부 id. // 문구는 원인을 단정하지 않는다 — 올린 파일은 다시 올리고, 전달하는 원본 첨부는 빼야 한다 (계약 2절) MAIL_004: { status: HttpStatus.BAD_REQUEST, message: - '첨부 파일을 찾을 수 없습니다. 해당 첨부를 빼거나 다시 올린 뒤 보내 주세요.', + '첨부 파일을 찾을 수 없습니다. 그 파일을 빼거나 다시 올린 뒤 보내 주세요.', }, // 같은 Idempotency-Key 로 이미 보낸 메일과 내용(받는 사람·제목·본문·첨부)이 다르다 — 보내지 않는다. // 첫 메일이 실제로 나갔을 수 있으므로 클라이언트는 보낸편지함을 확인하게 하고 새 키로 다시 보내게 한다 @@ -183,7 +185,7 @@ const DOMAIN_ERROR_CODES = { // 문구의 숫자는 attachment.policy.ts 의 MAX_ATTACHMENTS_PER_MESSAGE 와 같아야 한다 (attachment.policy.spec.ts 가 확인한다) MAIL_006: { status: HttpStatus.BAD_REQUEST, - message: '첨부는 한 메일에 최대 20개까지 보낼 수 있습니다.', + message: '첨부 파일은 메일 한 통에 20개까지 보낼 수 있습니다.', }, // 본문이 너무 길다 — 텍스트 100,000자·서식 본문 500,000자 초과, 서식 본문이 정화 전 비용 한도나 정화 뒤 2,000,000바이트를 넘음. // 메일은 나가지 않는다. 클라이언트는 보내기 전에 같은 한도로 미리 막고 본문 아래에 이 문구를 보인다 (계약 2절) @@ -248,7 +250,7 @@ const DOMAIN_ERROR_CODES = { MAIL_016: { status: HttpStatus.BAD_REQUEST, message: - '대용량 첨부 파일을 보낼 수 없습니다. 해당 파일을 빼거나 다시 올린 뒤 보내 주세요.', + '보낼 수 없는 대용량 첨부 파일이 있습니다. 그 파일을 빼거나 다시 올린 뒤 보내 주세요.', }, // 주소록(8단계) — 그룹 만들기 · 이름 바꾸기 때 이 주소록에 같은 이름의 그룹이 있다(앞뒤 공백을 빼고 ASCII 대소문자를 무시하고 견준다). // 화면은 이름 칸 아래에 알림 없이 보인다 (계약 3절) diff --git a/backend/src/modules/account-security/mail-engine-events.parser.spec.ts b/backend/src/modules/account-security/mail-engine-events.parser.spec.ts index 793f912..9489d82 100644 --- a/backend/src/modules/account-security/mail-engine-events.parser.spec.ts +++ b/backend/src/modules/account-security/mail-engine-events.parser.spec.ts @@ -150,7 +150,7 @@ describe('엔진 웹훅 본문 파서 (createMailEngineEventsBodyParser)', () => success: false, error: { code: 'AUTH_001', - message: '로그인이 필요한 서비스입니다. 로그인 후 이용해 주세요.', + message: '로그인이 필요합니다. 로그인한 뒤 다시 시도해 주세요.', traceId: 'trace-e', }, }; diff --git a/backend/src/modules/mail/jmap/jmap-mail.provider.spec.ts b/backend/src/modules/mail/jmap/jmap-mail.provider.spec.ts index c48ce26..59fc8cf 100644 --- a/backend/src/modules/mail/jmap/jmap-mail.provider.spec.ts +++ b/backend/src/modules/mail/jmap/jmap-mail.provider.spec.ts @@ -898,7 +898,7 @@ describe('JmapMailProvider', () => { ], ]; - it('조건은 칸마다 엔진 필터로 — 받는 사람은 to · cc 가운데 하나, 기간은 받은 시각(after 이상 · before 미만), 첨부는 hasAttachment', async () => { + it('조건은 칸마다 엔진 필터로 — 받는 사람은 to · cc 가운데 하나, 기간은 받은 시각(after 이상 — 엔진의 after 는 그 초를 넣지 않아 한 초 앞 · before 미만), 첨부는 hasAttachment', async () => { const { provider, recorded } = fakeClient([ mailboxRoles, searchResponses(['m1'], 1), @@ -942,13 +942,37 @@ describe('JmapMailProvider', () => { operator: 'OR', conditions: particleForms('견적').map((body) => ({ body })), }, - { after: '2026-10-31T15:00:00Z' }, + { after: '2026-10-31T14:59:59Z' }, { before: '2026-11-30T15:00:00Z' }, { hasAttachment: true }, ]); expect(page.items.map((item) => item.id)).toEqual(['m1']); }); + it('기간의 after 는 색인 길(>=)과 같은 경계로 — 정수 초는 한 초 앞(달 · 날이 바뀌는 0시도), 소수 초는 초로 올린 값의 한 초 앞(= 그 초)', async () => { + const cases: [string, string][] = [ + ['2026-11-01T00:00:00.000Z', '2026-10-31T23:59:59Z'], + ['2026-10-31T15:00:00Z', '2026-10-31T14:59:59Z'], + ['2026-10-31T15:00:00.500Z', '2026-10-31T15:00:00Z'], + ['2026-10-31T15:00:00.001Z', '2026-10-31T15:00:00Z'], + ['2026-10-31T15:00:00.999Z', '2026-10-31T15:00:00Z'], + ]; + for (const [after, engineAfter] of cases) { + const { provider, recorded } = fakeClient([ + mailboxRoles, + searchResponses([], 0), + ]); + await provider.listMessages(ARA, { limit: 20, conditions: { after } }); + const { filter } = argsOf(recorded[1]) as { + filter: { conditions: unknown[] }; + }; + expect({ after, conditions: filter.conditions.slice(1) }).toEqual({ + after, + conditions: [{ after: engineAfter }], + }); + } + }); + it('검색어와 조건은 함께 AND — 커서 지문에 조건이 들어가 조건이 다른 검색의 커서는 엔진에 묻기 전에 VAL_001', async () => { const conditions = { from: '이도윤', hasAttachment: true as const }; const first = fakeClient([ diff --git a/backend/src/modules/mail/jmap/jmap-mail.provider.ts b/backend/src/modules/mail/jmap/jmap-mail.provider.ts index c32e2e3..cc10059 100644 --- a/backend/src/modules/mail/jmap/jmap-mail.provider.ts +++ b/backend/src/modules/mail/jmap/jmap-mail.provider.ts @@ -134,6 +134,7 @@ import { selectAccountQuota, toAddresses, toDetailSource, + toJmapAfterInclusive, toJmapUtcDate, toMailbox, toSummary, @@ -310,6 +311,7 @@ function conditionsKey( /** * 조건 → 엔진 필터 조건들 (AND 로 묶어 쓴다) — 글 칸은 검색어와 같은 낱말 · 구절 · 조사 형태, 기간은 받은 시각(after 이상 · before 미만), 첨부는 엔진의 판정. + * after 는 엔진이 그 초를 넣지 않아 한 초 앞을 넘긴다(toJmapAfterInclusive — 색인 길의 `>=` 와 같은 경계) * 글을 준 칸에 찾을 낱말이 하나도 없으면 null — 아무것도 맞지 않는다(묻지 않고 빈 결과) */ function conditionFilters( @@ -325,7 +327,7 @@ function conditionFilters( filters.push(...found); } if (conditions.after) - filters.push({ after: toJmapUtcDate(conditions.after) }); + filters.push({ after: toJmapAfterInclusive(conditions.after) }); if (conditions.before) { filters.push({ before: toJmapUtcDate(conditions.before) }); } diff --git a/backend/src/modules/mail/jmap/jmap.mapper.ts b/backend/src/modules/mail/jmap/jmap.mapper.ts index 8bc77e3..96db141 100644 --- a/backend/src/modules/mail/jmap/jmap.mapper.ts +++ b/backend/src/modules/mail/jmap/jmap.mapper.ts @@ -378,6 +378,25 @@ export function toJmapUtcDate(iso: string): string { return new Date(Date.parse(iso)).toISOString().replace(/\.\d+Z$/, 'Z'); } +/** JMAP UTCDate 의 단위 — 초 (엔진은 받은 시각을 초로 둔다) */ +const JMAP_DATE_RESOLUTION_MS = 1_000; + +/** + * "받은 시각이 그 시각 이상"을 엔진의 Email/query `after` 값으로 — 엔진의 after 는 **그 초를 넣지 않는다** + * ("그 초보다 뒤" — RFC 8621 은 "같거나 뒤"라 적지만 Stalwart 0.16.27 은 아니다, 2026-11-08 개발 엔진에서 확인). + * 받은 시각은 초라 "그 시각 이상"은 "그 시각을 초로 올린 값 이상"이고, 엔진에는 그 한 초 앞을 넘긴다 — 색인 길(mail-search/search-sql.ts 의 `>=`)과 + * 같은 경계다. 화면이 보내는 0시(정수 초)면 한 초 앞, 소수 초면 그 초 그대로. + * ⚠️ 엔진이 RFC 대로 같은 초를 넣게 바뀌면 한 초 앞의 메일까지 맞는다 — 엔진 판을 올릴 때 다시 본다 + */ +export function toJmapAfterInclusive(iso: string): string { + const ceiled = + Math.ceil(Date.parse(iso) / JMAP_DATE_RESOLUTION_MS) * + JMAP_DATE_RESOLUTION_MS; + return toJmapUtcDate( + new Date(ceiled - JMAP_DATE_RESOLUTION_MS).toISOString(), + ); +} + /** 발신 ID — 직원 주소와 같은 것을 고르고, 없으면 첫 번째 */ export function pickIdentity( identities: JmapIdentity[], diff --git a/docs/design/webmail/README.md b/docs/design/webmail/README.md index 31be2fd..d90c544 100644 --- a/docs/design/webmail/README.md +++ b/docs/design/webmail/README.md @@ -52,13 +52,13 @@ 분할 보기 읽기·목록 보기 읽기·쓰기 세 곳이 같은 값을 쓰고, 툴바 첫 아이콘도 같은 32px 선에서 시작한다. - 브랜드 컬러 "바다 청록": 바탕 `#0B3640`, 강조 `#0E7C86`. 파생 색은 각 화면 원본의 인라인 스타일을 따른다. -## 화면 문구 — 사용자에게 말하듯 (2026-11-05 사용자 요청) +## 화면 문구 — 사용자에게 말하듯 (2026-11-05 · 11-07 사용자 요청) 화면 · 알림 · 화면 낭독기(`aria-label` · `title`) · 칸 안내(placeholder)의 글은 설계 문서가 아니라 쓰는 사람에게 하는 말이다. 원본은 화면 상수(`frontend/src/shared/constants/`)이고 이 문서는 옮겨 적는다. 1. **무엇이 되는지 · 무엇을 하면 되는지**를 쓴다. 만든 쪽의 사정(메일 서버 · 엔진 · 계정 · 판정 · 적용 · 동작 · 규칙 · 대상)은 빼거나 쉬운 말로 바꾼다. - - ✗ 메일 서버에 계정이 있는 직원입니다 — 이름과 주소만 보입니다. → ○ 회사 메일을 쓰는 직원이라 이름과 메일 주소만 보입니다. + - ✗ 메일 서버에 계정이 있는 직원입니다 — 이름과 주소만 보입니다. → ○ 회사 메일을 쓰는 직원입니다. 사내 목록에는 이름과 메일 주소만 있습니다. 2. **줄표(—)로 잇지 않는다.** 두 문장으로 나누거나 "~해서 · ~니 · ~지만"으로 잇는다. 짧은 상태 표시도 줄표 대신 한 구절로(✗ 실패 — 비밀번호 틀림 → ○ 비밀번호가 틀려 실패). 결과 칸이 따로 있는 표라면 까닭만 써도 된다. 3. **「」 를 쓰지 않는다.** 화면의 단추 · 메뉴 · 칸 이름은 [대괄호](직원 공지와 같다 — [설정] → [2단계 인증]), 사용자가 넣은 값(이름 · 그룹 이름 · 파일 이름)은 지금처럼 ‘ ’. @@ -71,6 +71,12 @@ 단추 · 칸 이름 · 짧은 상태 꼬리표는 명사형 그대로. 7. **위험 · 보안 문구는 겁주지 않되 분명하게** — 무엇이 막혔고 무엇을 하면 되는지를 함께. 8. **뜻은 그대로** — 한도 · 날짜 · 개수 · 조건 같은 정보를 빼지 않는다. 문장만 바꾼다. 영어 문구(공개 내려받기 화면)는 자연스러운 영어로만 다듬는다. +9. **화면이 하는 일을 설명하지 않는다**(2026-11-07) — "여기에 표시됩니다 · 보입니다 · 나타납니다 · 펼쳐집니다 · 이어집니다"처럼 화면을 주어로 한 설명 대신, 내 메일이 어떻게 되는지 · 무엇을 하면 되는지를 쓴다. + - ✗ 삭제한 메일이 여기에 표시됩니다. → ○ 지운 메일은 여기에 30일 동안 남아 있다가 저절로 지워집니다. + - ✗ 목록에서 메일을 고르면 내용이 여기에 표시됩니다. → ○ 읽을 메일을 목록에서 골라 주세요. +10. **번역투 · 설계 말을 쓰지 않는다**(2026-11-07) — 목적어 없는 "맞는", "해당 · 대상 · 항목 · 처리 · 적용 · 분류 · 기준 · ~에 대한 · ~를 통해 · ~하는 것이 좋습니다 · 지원하지 않습니다", 명사 나열 대신 평소에 쓰는 말로. + - ✗ 아직 맞는 메일을 찾지 못했습니다 → ○ 아직 찾은 메일이 없습니다 + - ✗ 일시적인 시스템 오류가 발생했습니다. → ○ 문제가 생겨 하던 일을 마치지 못했습니다. 적용하며 정한 것(2026-11-05): - 한 줄에 여러 정보를 늘어놓는 짧은 표시의 가운뎃점은 나열이 아니라 칸 구분이라 그대로 둔다 — 파일 줄 "1.2 GB · 3회 받음 · 11월 29일까지", "‘회의’ · 6통", @@ -80,6 +86,15 @@ - 서버가 만드는 글(에러 코드 사전 · 받은편지함 알림 메일 · 본문의 대용량 첨부 덩이)도 같은 규칙으로 맞췄다(2026-11-05) — 이 문서에 옮겨 적은 그 글(13단계 알림 메일 등)도 새 글이다. 알림 메일의 메뉴 경로도 화면 글처럼 "웹메일의 [설정] → [앱 비밀번호]" 꼴로 적는다. +적용하며 정한 것(2026-11-07 — 규칙 9 · 10, 사용자가 "자연스러운 문구가 아니라 시스템, 설계 문구 형식"이라고 짚은 글): +- 웹 75곳 · 오류 사전 18곳 · 앱 18곳(+ 앱 검색 계획 6곳)을 고쳤다. 정할 것 여섯은 모두 추천안이다 — 읽기 칸이 비었을 때 제목 "고른 메일이 없습니다" + 안내 "읽을 메일을 목록에서 골라 주세요."(주소록의 빈 칸도 같은 꼴), + `REQ_001` "너무 여러 번 시도했거나 같은 일이 이미 진행 중입니다."(추천안 "요청이 한꺼번에 몰렸습니다"는 가장 자주 보는 로그인 · 코드 실패 한도와 맞지 않아 바꿨다 — `MAIL_003` 은 휴지통도 비우라고, `FILE_001` 은 전달 원본 첨부에도 맞게 함께 다듬었다), 필터 창의 칸 이름 [받은편지함에 이미 있는 메일도 정리하기], 본문을 다 보이지 못할 때 할 일(원문 내려받기)까지, 로그인 소개 글 "…같은 계정으로 쓸 수 있게 됩니다.", 이 규칙 9 · 10. +- 그대로 둔 것: 내 메일 · 계정에 생기는 일을 말하는 "지워집니다 · 저장됩니다 · 로그아웃됩니다", "조건에 맞는 메일"처럼 평소에 쓰는 말, 다른 프로그램에서도 쓰는 단추 이름("실행 취소" · "원문 내려받기 (.eml)"), + 받은편지함 알림 메일 · 본문의 대용량 첨부 덩이(이미 사람 말에 가깝다), 운영 도구의 터미널 글(운영자용). +- 디자인 보드(`*.dc.html`)의 글은 이번에도 고치지 않는다(위 11-05 줄과 같다 — 기준은 화면 상수와 이 문서). +- 조사는 받침에 맞게 고른다(`frontend/src/shared/utils/korean.ts` — 짝: 앱 `core/utils/korean_text.dart`). 한글 음절에 더해 호환 자모(초성 검색 — 자음은 받침 있음, ㄹ 은 "로")와 숫자(영 · 일 · 이 … 로 읽는다 — "2026"은 "으로")도 고르고, 라틴 글자 · 기호로 끝나면 "이(가)" · "(으)로" 꼴이다. +- 본문을 다 보이지 못한다는 안내는 읽기 화면만 할 일(원문 내려받기)까지 적고, 같은 대화 칸의 펼친 줄은 앞 문장만 둔다(`MAIL_BODY_NOTICES` + `MAIL_BODY_NOTICE_ACTIONS`) — 그 칸의 ⋯ 는 위에 연 메일의 것이라 다른 메일을 받게 된다. 원문은 [이 메일만 열기]로 연 읽기 화면에서 받는다. + ## 메일 쓰기 (2026-10-27 확정 — `Compose` · `ComposeEditor`) 위에서부터 머리(제목·닫기) → 받는 사람(→ 참조·숨은참조) → 제목 → 본문 툴바 → 본문 → 첨부 → 보내기 줄이다. 모두 콘텐츠 열 안에 있다. @@ -251,7 +266,7 @@ - 받는 사람 · 참조 · 숨은참조에서 한 글자 이상 치면 칸 바로 아래에 추천 목록(최대 8개) — "사내"(웹메일에 로그인한 적 있는 직원) · "최근 주고받은 주소" 두 묶음(묶음 이름 12px #667085 굵게). 줄은 높이 40 · 이름(맞는 글자 굵게) + 주소(12px #4A5363), 고른 줄은 #E3F2F3. - 이미 넣은 주소는 빼고, 초성으로도 찾는다(ㄷㅇ → 도윤). 목록 아래 안내 "↑↓ 고르기 · Enter · Tab 넣기 · Esc 닫기". -- 친 글자가 완전한 메일 주소면 추천을 미리 고르지 않는다 — Enter 는 친 주소를 그대로 넣고("Enter 는 친 주소를 그대로 넣습니다"), +- 친 글자가 완전한 메일 주소면 추천을 미리 고르지 않는다 — Enter 는 친 주소를 그대로 넣고("Enter를 누르면 친 주소를 그대로 넣습니다"), 화살표로 고른 뒤에만 추천이 들어간다. 엉뚱한 사람이 받는 사람에 들어가지 않게 하기 위해서다. - 쓰기 화면을 열 때 추천 목록을 한 번 받아 두고 치는 대로 바로 거른다 — 모자랄 때만 서버에 다시 묻는다. @@ -278,7 +293,7 @@ API 와 서버 규칙은 계약 2절 "새 메일 확인" · "검색" · "여러 - 전체 메일함에서 찾으면 행에 메일함을 보인다 — 분할 보기는 첫 줄 시각 앞에 "보낸편지함 ·"(12px #667085), 목록 보기 표는 보낸 사람 뒤에 메일함 칸(96). 보낸편지함 · 임시보관함의 메일은 보낸 사람 자리에 "받는 사람: 이름". - 검색 결과에서 연 메일의 읽기 화면은 받는 사람 아래에 "메일함 받은편지함" 한 줄(12.5px #667085)을 더한다. -- 결과 없음: 검색 아이콘 · "‘…’에 맞는 메일이 없습니다" · 한계 한 줄 "낱말 단위로 찾습니다. ‘서버이전’의 ‘서버’처럼 붙여 쓴 말의 일부나, +- 결과 없음: 검색 아이콘 · "‘…’이/가 들어간 메일이 없습니다" · 한계 한 줄 "낱말 단위로 찾습니다. ‘서버이전’의 ‘서버’처럼 붙여 쓴 말의 일부나, 제목 맨 앞의 [공지]나 Re: 같은 말머리로는 찾지 못합니다." · 한 메일함에서 찾았으면 [전체 메일함에서 찾기]. - 찾는 동안은 지금의 목록 뼈대를 보인다. @@ -321,7 +336,7 @@ API 와 서버 규칙은 계약 1절 "`PUT /api/auth/password`", 2절 "메일함 ### 부재중 자동 응답 - [x] 부재중 자동 응답 사용(44). 끄면 아래 칸을 흐리게(쓴 글은 남긴다). -- 기간: (•) 끌 때까지 계속 / ( ) 기간 정하기 → 시작 · 끝 날짜 칸(44). "시작일 0시부터 끝나는 날 자정까지 답합니다. 시각은 이 브라우저의 시간대를 따릅니다." +- 기간: (•) 끌 때까지 계속 / ( ) 기간 정하기 → 시작 · 끝 날짜 칸(44). "시작하는 날 0시부터 끝나는 날 밤 12시까지 답합니다. 시각은 이 PC의 시간으로 셉니다." - 제목(44, 최대 폭 520) · 내용(여러 줄, 글자만 — "서식과 그림 없이 글자만 보내고, 서명은 붙지 않습니다."). - [저장] → "저장했습니다. 10월 30일(금)부터 11월 3일(화)까지 자동으로 답합니다." / "끌 때까지 자동으로 답합니다." / 끄면 "자동 응답을 껐고, 써 둔 글은 그대로 남아 있습니다." - 미리 보기 "보낸 사람이 받는 답장"(보낸 사람 · 제목 · 내용) + "같은 사람에게는 매번 보내지 않습니다." @@ -358,10 +373,10 @@ API 와 서버 규칙은 계약 2절 "실시간 알림". - [x] 새 메일이 오면 바탕화면 알림 보내기(44). 켜면 브라우저가 허락을 묻는다. 칸 아래 안내(12.5px): 묻기 전 "켜면 브라우저가 알림을 허락할지 한 번 묻습니다. [허용]을 눌러 주세요." · 허용됨 "이 브라우저에서 알림이 허용되어 있습니다."(#067647, 체크 아이콘) · 막힘(칸을 끄고 빨강 #B42318) "브라우저가 이 사이트의 알림을 막고 있습니다. 주소창 왼쪽의 사이트 정보(자물쇠)를 눌러 [알림]을 [허용]으로 바꾼 뒤 이 화면을 새로 고쳐 주세요." · - 지원하지 않음 "이 브라우저는 바탕화면 알림을 지원하지 않습니다. 새 메일은 그래도 바로 목록에 나타납니다." -- 알림에 보일 것: [x] 메일 제목도 보이기 — "끄면 보낸 사람 이름만 보입니다. 다른 사람과 화면을 함께 보는 자리라면 꺼 두는 것이 좋습니다." (알림을 끄면 흐리게) + 지원하지 않음 "이 브라우저에서는 바탕화면 알림을 쓸 수 없습니다. 새 메일은 그래도 목록에서 바로 볼 수 있습니다." +- 알림 내용: [x] 메일 제목도 보이기 — "끄면 알림에 보낸 사람 이름만 나옵니다. 다른 사람과 화면을 함께 보는 자리라면 꺼 두시기를 권합니다." (알림을 끄면 흐리게) - [시험 알림 보내기](보조 단추 44 · 종 아이콘) — 알림 "ARA 웹메일" / "알림이 잘 옵니다. 새 메일이 오면 이렇게 알려 드립니다." -- 실시간 연결: ● 초록 "연결되어 있어 새 메일이 오면 바로 목록에 나타납니다." / 끊겼을 때 "연결할 수 없어 1분마다 확인합니다." (저절로 다시 잇는다) +- 실시간 연결: ● 초록 "연결되어 있어 새 메일이 오면 바로 알 수 있습니다." / 끊겼을 때 "연결할 수 없어 1분마다 확인합니다." (저절로 다시 잇는다) - 아래 안내 상자(바탕 #F3F8F8): 이 브라우저에만 적용 · 웹메일 탭이 하나라도 열려 있어야 · 웹메일 화면을 보고 있을 때는 보내지 않음. ### 목록 위에서 한 번 권하기 @@ -414,9 +429,9 @@ API 와 서버 규칙은 계약 2절 "실시간 알림". 필터를 저장할 때 받은편지함에 이미 있는 메일에도 그 필터 하나를 적용한다. 6단계의 두 한계(고치기 창의 나가기 확인 없음 · 부재중 화면이 꺼진 규칙을 모름)도 함께 고친다. 규칙은 계약 2절 "이미 받은 메일에 적용". -### 고치기 창 — 「받은편지함에 이미 있는 메일에도 적용」 +### 고치기 창 — 「받은편지함에 이미 있는 메일도 정리하기」 -- 할 일 아래, 6단계의 안내 띠 자리에 회색 상자 하나: 체크 칸(이름 굵게) + 칸 아래 설명(12.5). 상자 아래 한 줄 "스팸으로 분류된 메일은 필터를 거치지 않습니다." +- 할 일 아래, 6단계의 안내 띠 자리에 회색 상자 하나: 체크 칸(이름 굵게) + 칸 아래 설명(12.5). 상자 아래 한 줄 "스팸 메일은 필터를 거치지 않습니다." - 창을 열 때마다 꺼져 있다 — 필터의 설정이 아니라 이번 저장에 한 번 쓴다(읽기 화면에서 만드는 새 필터도 같다). 목록의 행에는 단추를 더하지 않는다 — 있는 필터는 창을 열어 고르고 저장한다. - 설명은 "저장하면 받은편지함에서 조건에 맞는 메일을 찾아 바로 정리합니다." — 전달이 있는 필터면 " 전달은 새로 오는 메일에만 합니다."를 더한다. - 고를 수 없는 두 경우(칸을 끄고 이름을 흐리게, 설명이 까닭): @@ -428,7 +443,7 @@ API 와 서버 규칙은 계약 2절 "실시간 알림". - "필터를 저장했습니다." 대신 4단계의 진행 알림: "‘거래처 메일’ 필터로 받은편지함을 정리하는 중 · 4,200 / 8,412통 확인"(확인한 수 / 받은편지함 메일 수 — 최근 메일부터). 끝나면 그 자리에서 "‘거래처 메일’ 필터로 받은편지함의 메일 37통을 정리했습니다." + 옮기는 필터면 [거래처 보기](그 메일함으로) — - 읽음 · 중요만 하는 필터는 단추 없음, 맞는 메일이 없으면 "받은편지함에 ‘거래처 메일’ 필터에 맞는 메일이 없습니다." + 읽음 · 중요만 하는 필터는 단추 없음, 맞는 메일이 없으면 "받은편지함에는 ‘거래처 메일’ 필터에 걸리는 메일이 없습니다." - 멈추면 "‘거래처 메일’ 필터로 8,412통 가운데 4,200통까지 확인하고 멈췄습니다. 그동안 12통을 정리했습니다. 다시 시도해 주세요." + [다시 시도](남은 메일부터 이어서). 처음 요청부터 실패하면 "필터는 저장했지만 받은편지함은 정리하지 못했습니다. 다시 시도해 주세요." + [다시 시도]. 다른 창 · PC 에서 적용 중이면 "다른 창에서 필터로 받은편지함을 정리하는 중입니다. 끝난 뒤 다시 시도해 주세요." - 그동안 다른 화면으로 가서 메일을 읽고 써도 이어 한다. 탭을 닫으면 멈춘다(이어 하지 않는다). 세션이 바뀌면 멈추고 알림을 닫는다. @@ -457,12 +472,12 @@ API 와 서버 규칙은 계약 2절 "실시간 알림". ("이름, 주소, 회사, 전화번호, 초성(ㅂㅈㅎ)으로 찾기"). - 패널: [연락처 추가](채운 단추) → 「내 주소록」 전체 연락처(수) → 「그룹」 그룹마다 한 줄(수 · ⋯ 32×40 — 메일함 ⋯ 와 같은 밀도 예외) + [새 그룹] → 「사내」 직원(수) · 공용 주소(수). - 목록 머리: 제목 · "N명"(공용 주소는 "N개", 찾으면 "찾은 결과 N명"). 그룹이면 오른쪽에 [구성원 넣기] [그룹에 메일 쓰기]. - 사내면 머리 아래 회색 줄 — 직원 "회사 메일을 쓰는 직원이라 이름과 메일 주소만 보입니다." · 공용 주소 "여러 사람이 함께 받는 사내 주소입니다." + 사내면 머리 아래 회색 줄 — 직원 "회사 메일을 쓰는 직원입니다. 사내 목록에는 이름과 메일 주소만 있습니다." · 공용 주소 "여러 사람이 함께 받는 사내 주소입니다." - 목록 줄(56): 아바타 32(이름 첫 글자 — 공용 주소는 회색) · 이름(굵게) + 회사 · 부서(작게) / 대표 주소. 고른 줄은 청록 바탕. 이름 차례(한국어), 100명씩 받고 끝에 [더 보기]. - 상세 머리 줄: [메일 쓰기] [고치기] … [⋯] — 사내는 [메일 쓰기] [내 주소록에 추가]. 아래로 아바타 48 · 이름(20) · "회사 · 부서 · 직책"(없으면 "소속 없음", 사내는 "사내 직원" · "공용 주소"). - 상세 칸(왼쪽 이름 88): 이메일(주소마다 링크 — 누르면 그 주소로 새 메일, 옆에 종류) · 전화(번호 · 종류) · 소속 · 직책 · 그룹(누르면 그 그룹의 목록) · 메모(줄바꿈 그대로). 빈 칸은 줄째 뺀다. - 사내 상세 아래: "사내 목록은 이름과 주소만 보이고 여기서 고칠 수 없습니다. 전화번호나 메모를 적어 두려면 내 주소록에 추가해 주세요." -- 빈 상태: 찾은 것이 없으면 "‘…’에 맞는 연락처가 없습니다"(사내는 "주소가") + "이름, 주소, 회사, 전화번호의 일부나 이름의 초성으로 찾을 수 있습니다." · 고른 것이 없으면 "연락처를 골라 주세요". + 사내 상세 아래: "사내 목록에는 이름과 주소만 있고, 여기서는 고칠 수 없습니다. 전화번호나 메모를 적어 두려면 내 주소록에 추가해 주세요." +- 빈 상태: 찾은 것이 없으면 "‘…’에 맞는 연락처가 없습니다"(사내는 "주소가") + "이름, 주소, 회사, 전화번호의 일부나 이름의 초성으로 찾을 수 있습니다." · 고른 것이 없으면 "고른 연락처가 없습니다". ### 연락처 추가 · 고치기 (`ContactsEdit`) @@ -553,7 +568,7 @@ API 와 서버 규칙은 계약 2절 "실시간 알림". - **보내기 · 읽기**: 고르는 대로 저장한다(저장 단추 없음 — 머리 오른쪽에 "저장했습니다"). 서버에 저장되어 어느 PC 에서나 같다. - 「보내기 취소」 — 눌림 전환(36 — `.segmented`) 끔 · 5초 · 10초(처음) · 20초 · 30초 + "[보내기]를 누른 뒤 이 시간 동안 [보내기 취소]로 되돌릴 수 있습니다. 그사이 창을 닫아도 시간이 지나면 메일은 그대로 보내집니다." - 「외부 이미지」 — 라디오 셋(늘 묻기 · 주소록과 사내 직원의 메일은 늘 보기 · 늘 보기, 줄마다 아래 설명) + 회색 상자 - "외부 이미지는 회사를 거쳐 불러오므로 보낸 사람은 이 PC의 주소를 알 수 없습니다. 다만 이미지를 불러오면 보낸 사람은 메일을 열어 본 것을 알 수 있습니다." + "외부 이미지는 회사 쪽에서 대신 받아 오기 때문에 보낸 사람은 이 PC의 주소를 알 수 없습니다. 다만 이미지를 불러오면 보낸 사람이 내가 메일을 열어 본 것을 알 수 있습니다." - **차단한 주소**: 머리 "차단한 주소" + "3 / 500" + 설명 "차단한 주소에서 온 메일은 스팸메일함으로 옮겨지고, 부재중 자동 응답도 보내지 않습니다. @spam.example 처럼 @로 시작해 넣으면 그 도메인에서 오는 메일을 모두 막습니다." - 더하기 줄: 칸 "차단할 주소"(placeholder "kim@example.com 또는 @example.com") + [차단]. 칸 아래 오류: "메일 주소나 @도메인 형식으로 써 주세요." · "이미 차단한 주소입니다." · "내 주소와 사내 도메인은 차단할 수 없습니다." · "gmail.com처럼 여러 사람이 쓰는 메일 서비스는 도메인 전체를 막을 수 없습니다. 주소를 하나씩 넣어 막아 주세요." · "500개까지 차단할 수 있습니다." @@ -638,7 +653,7 @@ API 와 서버 규칙은 계약 2절 "실시간 알림". - **기간 지남 · 공유 멈춤**: 흐리게(이름 #667085 · 아이콘 #98A2B3 — 투명도가 아니라 글자색) + "받을 수 있는 기간이 11월 29일에 끝났습니다" · "보낸 사람이 링크를 끊어 받을 수 없습니다", 내려받기 단추 없음. 보낸 사람 자신에게는 받은 수가 남고 끊은 날이 보인다 — "1.2 GB · 3회 받음 · 10월 31일에 링크를 끊었습니다"(한국 날짜 · 응답의 `revokedAt`, 없으면 "링크를 끊었습니다"). 끝난 뒤 90일이 지나면 첨부 칸에서 빠지고 덩이는 그냥 본문이 된다. -- **보안 검사**: 검사 중이면 [다운로드]를 `aria-disabled`(title · 까닭 연결) + "보안 검사가 끝나면 받을 수 있습니다". 위험은 빨간 줄 "위험한 내용이 있어 보안 검사에서 막았고, 받을 수 없습니다"(단추 없음). 검사하지 못한 파일은 받을 수 있고 글로만 알린다. +- **보안 검사**: 검사 중이면 [다운로드]를 `aria-disabled`(title · 까닭 연결) + "보안 검사가 끝나면 받을 수 있습니다". 위험은 빨간 줄 "보안 검사에서 위험한 내용이 나와 받을 수 없습니다"(단추 없음). 검사하지 못한 파일은 받을 수 있고 글로만 알린다. - **전달 쓰기 화면**: 원래 메일의 덩이는 인용 속 글(링크)로 그대로 간다 — 첨부 칸에 다시 싣지 않고 칸 아래 ⓘ 한 줄로만 알린다 "원래 메일의 대용량 첨부 2개는 인용 속 링크로 함께 갑니다. 11월 29일까지 받을 수 있습니다.". 공유가 멈췄거나 기간이 지난 링크면 갈색 한 줄 "인용 속 ‘도면_초안.dwg’ 링크는 공유가 멈춰 받을 수 없습니다."("… 기간이 지나 받을 수 없습니다.", 파일이 여럿이면 "‘설계도_v3.dwg’ 외 1개") — 보내기는 막지 않는다. 전달하는 사람이 새 큰 파일을 더하면 그것은 이 메일의 새 링크다(첨부 칸의 「링크로 보내는 파일」 — 전달하는 사람의 보관 한도에 잡힌다). @@ -661,21 +676,21 @@ API 와 서버 규칙은 계약 2절 "실시간 알림". - 횟수 초과(파일마다 운영 설정 — 처음 값 500번, 넘은 파일만) — 그 줄에 "내려받을 수 있는 횟수를 넘었습니다. 보낸 사람에게 다시 보내 달라고 요청해 주세요."(숫자는 쓰지 않는다 — 한도는 운영 설정이고 응답에 없다) - 만료 — "내려받을 수 있는 기간이 지났습니다" / "2026년 11월 29일까지 받을 수 있었던 파일입니다. 파일은 지워져 다시 받을 수 없습니다. 필요하면 보낸 사람(김아라, ara.kim@arasystem.it.kr)에게 다시 보내 달라고 요청해 주세요." - 회수 — "보낸 사람이 공유를 멈췄습니다" / "필요하면 보낸 사람(김아라, ara.kim@arasystem.it.kr)에게 문의해 주세요." 파일 이름도 보이지 않는다. - - 없는 링크(모르는 주소 · 끝난 지 90일이 지나 기록을 지운 링크) — "찾을 수 없는 링크입니다" / "메일의 주소가 끝까지 복사되었는지 확인해 주세요." 보낸 사람도 보이지 않는다. + - 없는 링크(모르는 주소 · 끝난 지 90일이 지나 기록을 지운 링크) — "찾을 수 없는 링크입니다" / "메일에 있던 주소를 끝까지 다 복사했는지 확인해 주세요." 보낸 사람도 보이지 않는다. - 잠시 막힘(같은 IP 의 요청이 너무 많음) — "요청이 너무 많습니다" / "잠시 뒤 다시 시도해 주세요." + [다시 시도]. - **좁은 폭(휴대폰 360)**: 머리 52 · 로고 76, 보낸 사람 주소는 이름 아래 줄로, 줄마다 [내려받기]가 이름 아래 전체 폭(44). ### 설정 › 메일 › 대용량 첨부 (`SettingsLargeFiles`) - **메뉴**: 메일 묶음의 차단한 주소 아래 「대용량 첨부」 — 서명 · 부재중 자동 응답 · 필터 · 보내기 · 읽기 · 차단한 주소 · 대용량 첨부 · 알림. -- 머리 "대용량 첨부" + "메일에 링크로 보낸 큰 파일입니다. 받는 사람은 보낸 날부터 30일 동안 내려받고, 링크를 끊으면 바로 받을 수 없습니다." +- 머리 "대용량 첨부" + "메일에 링크로 보낸 큰 파일입니다. 받는 사람은 보낸 날부터 30일 동안 내려받을 수 있고, 링크를 끊으면 그때부터 받을 수 없습니다." - **사용량 상자**: "보관 사용량 6.2 / 10 GB"(메일함 사용량과 같은 표기) + 4px 막대(90% 를 넘으면 빨강 — 메일함 사용량과 같다) + "보낸 링크 4.3 GB, 임시 저장했거나 올리다 멈춘 파일 1.9 GB입니다. 기간이 지나거나, 링크를 끊거나, 보내지 않은 파일을 지우면 바로 줄어듭니다." - **보내지 않은 파일**(사용량 상자 바로 아래 — 공간을 바로 비울 수 있는 곳이라 보낸 링크보다 위): 「보내지 않은 파일 3개」 + "1.9 GB · 임시 저장한 메일에 실렸거나 올리다 멈춘 파일이라 지우면 바로 공간이 생깁니다". 없으면 묶음째 보이지 않는다. - 줄(52): 아이콘 · 이름 / (올리다 멈춘 파일은 앞에 갈색 "올리다 멈춤") · 크기(멈춘 파일은 "1.2 GB" / "420 MB 올림") · 실린 저장본의 제목 · 링크는 보이지 않는다(2026-10-30 — 저장본 id 는 저장할 때마다 바뀌고 엔진 밖에서도 지워져 서버가 따라가기 무겁다, 계약 4절 `LargeFile` 에 없음) · 남는 기한 "11월 29일까지 남음" / "올린 뒤 30일"(올리다 멈춘 파일은 이어 올리기 규칙대로 "내일 오후 2:41까지 남음" / "마지막으로 올린 뒤 24시간") · [지우기](36, 빨간 글). - - [지우기] → 확인 창(alertdialog 400) "보내지 않은 파일을 지울까요?" / "‘시공도면_v4.dwg’(620 MB) 파일을 지웁니다. 이 파일을 실은 임시 저장본을 열면 첨부 칸에 지운 파일로 보입니다. 지운 파일은 되돌릴 수 없습니다." + - [지우기] → 확인 창(alertdialog 400) "보내지 않은 파일을 지울까요?" / "‘시공도면_v4.dwg’(620 MB) 파일을 지웁니다. 이 파일을 넣어 둔 임시 저장본이 있으면 그 메일에서 파일을 빼고 다시 올려야 합니다. 지운 파일은 되돌릴 수 없습니다." [지우기](빨강) [취소](처음 초점 · Esc) → "‘시공도면_v4.dwg’ 파일을 지웠습니다." — 줄이 사라지고 사용량이 바로 준다. 그 저장본을 쓰기 화면에서 열면 첨부 칸에 빨간 줄 "[설정] → [대용량 첨부]에서 지운 파일입니다. 빼고 다시 올려 주세요."(쓰기 화면의 "기간이 지나 지워진 파일"과 같은 모양 · 다른 글). - **보낸 링크**: 「보낸 링크 6개」 + "메일마다 하나씩, 최근에 보낸 순서". 메일(링크)마다 한 덩이 — 링크가 메일 한 통에 하나라 [링크 끊기]는 덩이에 한 번이다(파일 줄마다 두면 같은 메일의 다른 파일까지 끊긴다). @@ -711,14 +726,14 @@ API 와 서버 규칙은 계약 2절 "실시간 알림". ### 로그인 — 코드 단계 (`LoginTwoFactor`) - **누가 오나**: 비밀번호가 맞고 2단계 인증을 켠 직원만. 켜지 않은 직원은 지금처럼 바로 메일함으로 간다. - 비밀번호가 틀리면 지금처럼 「이메일 또는 비밀번호가 올바르지 않습니다.」 — 2단계를 켰는지는 비밀번호가 맞은 뒤에야 드러난다. + 비밀번호가 틀리면 지금처럼 「메일 주소나 비밀번호가 맞지 않습니다. 다시 확인해 주세요.」 — 2단계를 켰는지는 비밀번호가 맞은 뒤에야 드러난다. - **틀**은 로그인 화면과 같다. 위에서부터: - 제목 "2단계 인증" / "비밀번호를 확인했습니다. 휴대폰 인증 앱에 표시된 코드를 넣어 주세요." - 주소 + [다른 계정으로 로그인] — 처음 화면으로(주소 칸은 비움), 확인한 비밀번호는 버린다. - 칸 "인증 앱에 표시된 6자리 코드" — 숫자 6자리만, 붙여 넣은 글의 띄어쓰기 · 하이픈은 지운다. `inputmode="numeric"` · `autocomplete="one-time-code"`. - [확인] → "인증 앱을 쓸 수 없나요? 관리자에게 문의해 주세요." → "Outlook이나 휴대폰 메일 앱은 이 코드를 묻지 않습니다. [설정] → [앱 비밀번호]에서 만든 비밀번호로 연결해 주세요." - **상태** - - 코드 틀림 — 칸 아래 빨간 글(`AUTH_006` 사전 문구 "코드가 올바르지 않습니다. 휴대폰의 날짜와 시간이 자동으로 맞춰지도록 설정되어 있는지 확인해 주세요."). 칸을 비우고 초점은 그 칸, 알림은 없다. + - 코드 틀림 — 칸 아래 빨간 글(`AUTH_006` 사전 문구 "코드가 맞지 않습니다. 휴대폰 설정에서 날짜와 시간을 자동으로 맞추게 해 두었는지 확인해 주세요."). 칸을 비우고 초점은 그 칸, 알림은 없다. - 시도 과다 — 위쪽 빨간 상자(로그인 오류와 같은 자리 · 같은 모양)에 `REQ_001` 사전 문구. 코드 실패는 비밀번호 틀림과 따로 센다(같은 주소 15분 5회 · 하루 20회). 칸 · 단추는 그대로 둔다. - 확인 중 — 「확인하는 중…」(로그인 단추와 같은 모양 · `aria-busy`), 칸은 읽기만. 맞으면 받은편지함으로 — 로그인 전에 가려던 주소가 있으면 그곳으로. - 이 화면에 10분 넘게 머물면 비밀번호를 바로 지우고, [확인] 때 처음 화면으로 가 위쪽 상자에 "코드를 넣는 시간이 지났습니다. 다시 로그인해 주세요."를 보인다. 새로고침도 처음 화면이다(비밀번호는 화면 메모리에만 있다). @@ -768,7 +783,7 @@ API 와 서버 규칙은 계약 2절 "실시간 알림". - **만들기 창**(400 · 처음 초점 이름 칸): 「이름」 + "어느 프로그램이나 기기에서 쓰는지 알아볼 수 있게 지어 주세요. 40자까지 쓸 수 있습니다." → [만들기] [취소]. 칸 아래: "이름을 넣어 주세요." · "같은 이름의 앱 비밀번호가 있습니다."(앞뒤 공백을 지운 값으로 화면이 먼저 본다) · 그사이 다른 창에서 5개를 채웠으면 `AUTH_009` 사전 문구. - **만든 결과 창**(620 · 처음 초점 [복사]): "앱 비밀번호를 만들었습니다" / "‘회사 PC 아웃룩’ 앱 비밀번호입니다. 메일 프로그램의 비밀번호 칸에 아래 값을 넣어 주세요." - - 값은 고정폭으로 4자씩 떨어뜨려 보이지만 띄어쓰기는 없다 — [복사](44 — 창의 주 동작이라 보드대로)는 40자 그대로. "40자입니다. 보기 좋게 4자씩 띄웠지만 띄어쓰기는 없습니다." + - 값은 고정폭으로 4자씩 떨어뜨려 보이지만 띄어쓰기는 없다 — [복사](44 — 창의 주 동작이라 보드대로)는 40자 그대로. "40자입니다. 보기 편하게 4자씩 띄어 놓았지만, 실제로는 띄어쓰기 없이 넣으면 됩니다." - 경고 "이 창을 닫으면 다시 볼 수 없습니다. 잊어버리면 지우고 새로 만들어 주세요." + 설정값 요약 + [닫기]. - 복사 「앱 비밀번호를 복사했습니다.」 / 못 하면 「앱 비밀번호를 복사하지 못했습니다. 값을 직접 골라 복사해 주세요.」 - [닫기] · Esc 로 닫으면 목록 맨 위에 새 줄이 생기고 값은 화면 메모리에서도 지운다. 복사하지 않고 닫아도 다시 묻지 않는다. @@ -780,7 +795,7 @@ API 와 서버 규칙은 계약 2절 "실시간 알림". - 머리 "로그인 기록" + "최근 90일 동안의 로그인입니다. 모르는 기록이 있으면 바로 비밀번호를 바꾸고, 2단계 인증이 꺼져 있으면 켜 주세요."(두 링크). - **로그인된 기기**(2026-11-06 — 앱 세션이 함께 들어가 「로그인된 브라우저」에서 바꿨다): 「로그인된 기기」 + "웹메일 3곳"(앱 세션이 있으면 "웹메일 2곳 · ARA SYSTEM 앱 1곳" — 세션의 `client` 로 세고 0곳인 쪽은 쓰지 않는다. "앱 N곳"으로 줄이지 않는다 — 앱 비밀번호를 쓰는 "휴대폰 메일 앱"과 헷갈리지 않게) · 오른쪽 [다른 곳 모두 로그아웃](44). - 줄마다 PC · 휴대폰 아이콘(16) · 기기(칸 머리 "기기" — 아이콘 칸의 화면 낭독 이름은 "종류") / IP / 마지막 활동 / [로그아웃](36). 앱 세션은 "ARA SYSTEM 앱 · Android"(운영체제를 모르면 "ARA SYSTEM 앱")이고 아이콘은 늘 휴대폰이다 — 앱인지는 `client` 로만 가린다(앱 꼴 User-Agent 의 웹 세션은 웹메일로 센다). 맨 위는 지금 이 브라우저 — 단추 대신 「지금 이 브라우저」 글자만. 모르면 "알 수 없는 브라우저". - - 아래 "메일 프로그램은 여기에 보이지 않습니다. 연결을 끊으려면 [앱 비밀번호]를 지워 주세요."(링크 — 2026-11-06 앱 세션이 함께 보여 고침) + - 아래 "메일 프로그램은 이 목록에 없습니다. 연결을 끊으려면 [앱 비밀번호]를 지워 주세요."(링크 — 2026-11-06 앱 세션이 함께 보여 고침, 11-07 문구 다듬기) - 줄의 [로그아웃]은 묻지 않고 바로 끊는다 → 「‘Edge · Windows’에서 로그아웃했습니다.」(되돌리기 없음). 끊은 줄은 빠지고, 다른 곳이 없으면 [다른 곳 모두 로그아웃]을 그리지 않는다. - [다른 곳 모두 로그아웃] → 확인(alertdialog 400 · 처음 초점 「취소」) "다른 곳에서 모두 로그아웃할까요?" / "이 브라우저를 뺀 2곳(Edge · Windows, ARA SYSTEM 앱 · Android)에서 로그아웃합니다. 그곳에서 다음에 무엇이든 누르면 바로 로그인 화면이 나옵니다." "메일 프로그램은 로그아웃되지 않습니다. 연결을 끊으려면 앱 비밀번호를 지워 주세요." @@ -846,7 +861,7 @@ API 와 서버 규칙은 계약 2절 "실시간 알림". - **누가 오나**: 회사가 2단계 인증을 필수로 정한 날(시작일)부터, 아직 켜지 않은 직원(예외 주소 빼고)이 비밀번호를 맞혔을 때(`restriction: twoFactorSetup`). 켜기 전에는 메일 · 주소록 · 설정을 열 수 없다(`AUTH_010`). 새 비밀번호 정하기가 함께 걸린 직원(입사)은 그것 먼저. - **틀**(위에서부터): - - 제목 "2단계 인증을 켜 주세요" / "회사 정책에 따라 로그인할 때 휴대폰 인증 앱의 코드도 확인합니다. 지금 켜면 바로 메일로 이어집니다." + - 제목 "2단계 인증을 켜 주세요" / "회사 정책에 따라 로그인할 때 휴대폰 인증 앱의 코드도 확인합니다. 지금 켜면 바로 메일을 쓸 수 있습니다." - 주소 + [다른 계정으로 로그인] - 차례(청록 굵은 숫자 — 동그라미 없음, 숫자 칸 22 · 사이 18 — 12단계 켜기 창과 같다): - ① "인증 앱으로 QR 코드를 찍어 주세요" / "인증 앱에서 [+] → [QR 코드 스캔]을 고릅니다. 앱이 없으면 Google Authenticator나 Microsoft Authenticator를 먼저 설치해 주세요." @@ -900,8 +915,8 @@ API 와 서버 규칙은 계약 2절 "실시간 알림". - **상자 바로 아래** — 2단계 인증 상태로 가른다. 둘 다 [앱 비밀번호 만들기](36) → 설정 › 앱 비밀번호. - 켠 직원 — 갈색 상자(열쇠): **"비밀번호 칸에는 꼭 앱 비밀번호를 넣어 주세요"** / "2단계 인증을 켜 두어 웹메일 비밀번호로는 연결되지 않습니다. 프로그램마다 앱 비밀번호를 하나씩 만들어 넣어 주세요." — 단추 `.notice-btn`. - 켜지 않은 직원 — 청록 상자(열쇠): **"앱 비밀번호로 연결하기를 권합니다"** / "지금은 웹메일 비밀번호로도 연결되지만, 2단계 인증을 켜면 다시 연결할 때 막힙니다. 앱 비밀번호는 기기마다 따로 지울 수 있어 잃어버렸을 때도 안전합니다." — 단추 `.line-btn`. -- **프로그램별 연결 방법**: 「프로그램별 연결 방법」 + "쓰는 프로그램을 누르면 연결 방법이 펼쳐집니다." - - 그 아래 갈색 주의 줄(경고 삼각형): "새 Outlook과 휴대폰 Outlook 앱은 메일을 Microsoft 클라우드를 거쳐 받습니다. 회사 정책에 따라 쓰지 않는 것이 좋습니다." +- **프로그램별 연결 방법**: 「프로그램별 연결 방법」 + "쓰는 프로그램을 누르면 연결 방법을 볼 수 있습니다." + - 그 아래 갈색 주의 줄(경고 삼각형): "새 Outlook과 휴대폰 Outlook 앱은 메일을 Microsoft 클라우드를 거쳐 받습니다. 회사 정책에 따라 되도록 다른 메일 프로그램을 써 주세요." 권하지 않음으로 정했다(2026-10-31) — 회사가 허용하면 줄을 지운다. - 펼침 목록(테두리 상자 하나 · 줄 사이 선): 줄 52 — 아이콘 18(PC · 휴대폰) · 이름(굵게) / 설명(12px 회색) · 오른쪽 ⌄(펼치면 ⌃). 줄 전체가 버튼(`aria-expanded` · 펼친 것만 `aria-controls`)이고, 펼친 차례는 region(이름 = 그 버튼)으로 이름 글과 같은 선(왼쪽 44)에서 번호 줄(청록 굵은 숫자 — 동그라미 없음)로 보인다. 처음에는 모두 접힘, 하나를 펼치면 다른 것은 접힌다. @@ -928,7 +943,7 @@ API 와 서버 규칙은 계약 2절 "실시간 알림". 2. 이름, 메일 주소, 설명을 넣고, 암호 칸에 앱 비밀번호를 넣은 뒤 [다음]을 누릅니다. 3. [IMAP]을 고르고, 받는 메일 서버와 보내는 메일 서버의 호스트 이름에 **mx1.arasystem.it.kr**, 사용자 이름에 메일 주소 전체, 암호에 앱 비밀번호를 넣습니다. 보내는 메일 서버 칸이 선택 사항으로 보여도 꼭 채워 주세요. 4. [다음] → [저장]을 누릅니다. 포트는 받는 메일 993, 보내는 메일 465로 저절로 맞춰집니다. - - **안드로이드** — 설명 "Gmail 앱 기준이며, 삼성 이메일 앱 같은 다른 메일 앱도 같은 값을 넣습니다" + - **안드로이드** — 설명 "Gmail 앱으로 설명하며, 삼성 이메일 앱 같은 다른 메일 앱도 같은 값을 넣으면 됩니다" 1. Gmail 앱에서 프로필 사진 → [다른 계정 추가] → [기타]를 누릅니다. 2. 메일 주소를 넣고 [수동 설정] → [개인(IMAP)]을 고릅니다. 3. 비밀번호에 앱 비밀번호를 넣습니다. @@ -1012,16 +1027,16 @@ API 와 서버 규칙은 계약 2절 "실시간 알림". - 목록 머리(52) 바로 아래 회색 줄(바탕 #F8F9FB · 아래 선 #EEF0F3 · 위아래 8 · 12.5px #4A5363 · 시계 아이콘 14 #667085): 휴지통 "휴지통에 들어간 메일은 30일 뒤 자동으로 지워집니다." / 스팸메일함 "스팸메일함에 들어간 메일은 30일 뒤 자동으로 지워집니다." - 늘 보인다(닫기 없음). 목록 보기(표)에서는 표 머리 위 — 같은 줄. 검색 결과 · 다른 메일함에는 없다. 머리의 [휴지통 비우기] · [스팸메일함 비우기]는 지금 그대로다. - - 메일이 없으면 줄을 빼고 빈 상태 안내 글에 붙인다: "휴지통이 비어 있습니다" / "삭제한 메일이 여기에 표시됩니다. 들어간 지 30일이 지난 메일은 자동으로 지워집니다." · - "스팸메일함이 비어 있습니다" / "스팸으로 분류된 메일이 여기에 표시됩니다. 들어간 지 30일이 지난 메일은 자동으로 지워집니다." + - 메일이 없으면 줄을 빼고 빈 상태 안내 글에 붙인다: "휴지통이 비어 있습니다" / "지운 메일은 여기에 30일 동안 남아 있다가 저절로 지워집니다." · + "스팸메일함이 비어 있습니다" / "스팸 메일은 여기에 30일 동안 남아 있다가 저절로 지워집니다." - 지우는 것은 메일 서버다 — 그 메일함에 들어간 날부터 30일, 매일 새벽 3시(KST). API 는 없다(계약 6절). - **문의 줄**(로그인 · 2단계 코드 단계 · 2단계 켜기): - 연락처는 `GET /api/support-contact` 의 `text`(운영 도구로 넣는 설정값 · 200자 · 한 줄 — 로그인 없이 받는다). 보드의 "IT 담당 홍길동 · 02-000-0000 · 사내 메신저 @it" 는 자리 표시다. 글자로만 보인다(링크 · HTML 없음). - 꼴: 물음(12.5px · #667085) 다음 줄에 연락처(#344054 · 500) — 연락처가 길어도 중간에서 끊기지 않게 줄을 나눈다. - - 로그인 — 있을 때 "비밀번호를 잊으셨나요?" + 연락처 / 없을 때 지금 글 "비밀번호를 잊으셨다면 시스템 관리자에게 문의해 주세요." + - 로그인 — 있을 때 "비밀번호를 잊으셨나요?" + 연락처 / 없을 때 지금 글 "비밀번호를 잊으셨다면 관리자에게 문의해 주세요." - 코드 단계(`LoginTwoFactor`) · 2단계 켜기 — 있을 때 "인증 앱을 쓸 수 없나요?" + 연락처 / 없을 때 지금 글 "인증 앱을 쓸 수 없나요? 관리자에게 문의해 주세요." - 받지 못하면(오류 · `null`) 없을 때와 같다. - - 잠긴 계정(퇴사 · 털림)도 비밀번호 틀림과 같은 글 "이메일 또는 비밀번호가 올바르지 않습니다."다(원인을 가리지 않는다) — 그 아래 문의 줄로 안내한다. + - 잠긴 계정(퇴사 · 털림)도 비밀번호 틀림과 같은 글 "메일 주소나 비밀번호가 맞지 않습니다. 다시 확인해 주세요."다(원인을 가리지 않는다) — 그 아래 문의 줄로 안내한다. ### 처음 판에서 미룬 것 @@ -1044,15 +1059,15 @@ API 와 서버 규칙은 계약 2절 "실시간 알림". 여러 낱말은 모두 맞아야 하고 큰따옴표는 구절이다(지금과 같다). 영문은 대소문자를 가리지 않는다. 끝이 바뀐 말('보내' → '보냈습니다')과 첨부 파일 안의 글은 찾지 않는다(첨부 이름은 찾는다). - **[조건]**: 검색 칸 안 오른쪽(✕ 앞)의 단추. 누르면 칸 바로 아래에 「조건으로 찾기」 패널(검색 칸 폭)이 열린다(aria-expanded). - 칸: 보낸 사람 · 받는 사람(참조도) · 제목 · 내용 · 기간(전체 기간 · 최근 1주 · 최근 1달 · 최근 3달 · 최근 1년 · 날짜 고르기 → 시작 ~ 끝) · 첨부(체크 "첨부 파일이 있는 메일만"). 칸 높이 40. - - 아래 안내 "검색 칸의 말과 이 조건에 모두 맞는 메일을 찾습니다. 받는 사람에는 참조로 받은 사람도 들어갑니다." 단추 [조건 지우기] · [찾기]. + - 아래 안내 "검색 칸에 쓴 말이 들어 있고 이 조건에도 맞는 메일을 찾습니다. 받는 사람으로 찾을 때는 참조로 받은 사람까지 찾습니다." 단추 [조건 지우기] · [찾기]. - 패널이 열려 있거나 조건이 걸려 있으면 [조건] 단추가 청록 바탕이다. 검색 칸이 비어도 조건만으로 찾을 수 있다. - **조건이 걸린 결과**: 찾을 곳 줄 아래 한 줄 "조건: 이도윤 님이 보낸 메일, 최근 1달, 첨부 있음" + [조건 지우기]. 조건은 쉼표로 잇는다(꼬리표 · 알약을 쓰지 않는다). - **찾을 곳**은 지금과 같다(그 메일함 | 전체 메일함 — 전체는 휴지통과 스팸메일함을 뺀다). 조건도 주소에 싣는다 — 이름은 구현 때 계약에서 정하고, 검색어처럼 로그에 남기지 않는다. - **결과 줄**(높이 64, 세 줄): 보낸 사람 · 시각 / 제목(+ 첨부 표시) / 미리 보기 한 줄(맞은 말 둘레). 제목 · 미리 보기의 맞은 말은 ``(노랑 `#FCE7A4` · 글자색은 그대로). - **읽기 화면 강조**: 제목 · 본문 · 첨부 이름의 맞은 말. 본문은 서버가 정화한 HTML 의 글 조각에만 `` 를 넣는다(찾기와 같은 정규화 · 부분 일치). 맞은 곳 사이 이동(다음 · 이전)은 이번에 넣지 않는다. - **검색 준비 중**: 지난 메일의 색인을 만드는 동안 그 직원의 검색은 지금 방식(엔진) 그대로이고, 결과 머리 아래 안내 줄(옅은 청록 바탕 · role=status) - "지난 메일을 검색할 수 있게 밤사이에 준비하고 있습니다. 메일이 많으면 며칠 걸릴 수 있고, 그동안은 낱말이 통째로 같은 메일만 찾습니다."(2026-11-07 사용자 관점 점검 — 색인은 밤에만 채워, 낮 내내 같은 글이면 멈춘 줄 안다) -- **결과 없음**: "‘계약’에 맞는 메일이 없습니다" / "다른 말로 찾거나 조건을 줄여 보세요." + [전체 메일함에서 찾기](그 메일함에서 찾을 때만). 지금의 한계 안내는 색인이 준비된 뒤에는 뺀다 — 준비 중에는 위 안내 줄과 겹치지 않게 "준비가 끝나기 전에는 ‘서버이전’의 ‘서버’처럼 붙여 쓴 말의 일부나, 제목 맨 앞의 [공지]나 Re: 같은 말머리로는 찾지 못합니다."(2026-11-07). + "지난 메일까지 낱말의 일부로도 찾을 수 있게 밤사이에 준비하고 있습니다. 메일이 많으면 며칠 걸릴 수 있고, 그동안은 낱말을 끝까지 다 써야 찾을 수 있습니다."(2026-11-07 사용자 관점 점검 — 색인은 밤에만 채워, 낮 내내 같은 글이면 멈춘 줄 안다) +- **결과 없음**: "‘계약’이 들어간 메일이 없습니다" / "다른 말로 찾거나 조건을 줄여 보세요." + [전체 메일함에서 찾기](그 메일함에서 찾을 때만). 지금의 한계 안내는 색인이 준비된 뒤에는 뺀다 — 준비 중에는 위 안내 줄과 겹치지 않게 "준비가 끝나기 전에는 ‘서버이전’의 ‘서버’처럼 붙여 쓴 말의 일부나, 제목 맨 앞의 [공지]나 Re: 같은 말머리로는 찾지 못합니다."(2026-11-07). - 검색 결과는 대화로 묶지 않는다(한 통씩). ### 읽기 화면 — 같은 대화 (`Thread14`) @@ -1083,9 +1098,9 @@ API 와 서버 규칙은 계약 2절 "실시간 알림". "다시 시도"는 메일 서버 장애처럼 다시 해 보면 나아질 수 있는 오류에만 쓴다. - 읽기 화면의 본문 안내(2026-10-27 사용자 확정) — "외부 이미지를 차단했습니다"와 같은 모양(아이콘 alert-circle)의 안내 줄을 본문 위에 둔다. 순서는 생략 → 잘림 → 외부 이미지 차단이다. "표시할 수 없음"을 보일 때는 잘림 안내를 숨기고, 공백뿐인 글자 본문은 없는 것으로 본다: - - 서버가 HTML 을 정화 한도 때문에 비웠을 때(`isHtmlBodyOmitted`): 글자 본문이 있으면 "본문이 복잡해 서식 없이 글자만 표시합니다.", - 없으면 "본문이 너무 크거나 복잡해 표시할 수 없습니다." - - 보여 주는 본문이 읽기 한도(2MB)에서 잘렸을 때(`isBodyTruncated`): "본문이 길어 앞부분만 표시합니다." + - 서버가 HTML 을 정화 한도 때문에 비웠을 때(`isHtmlBodyOmitted`): 글자 본문이 있으면 "본문이 복잡해 서식 없이 글자만 보여 드립니다.", + 없으면 "본문이 너무 크거나 복잡해 여기서는 열 수 없습니다. [⋯] → [원문 내려받기 (.eml)]로 받아 볼 수 있습니다." + - 보여 주는 본문이 읽기 한도(2MB)에서 잘렸을 때(`isBodyTruncated`): "본문이 길어 앞부분만 보여 드립니다. 전체는 [⋯] → [원문 내려받기 (.eml)]로 받아 볼 수 있습니다." ### 2단계 구현하며 정한 보완 (2026-10-27 — 캔버스에는 아직 없다) @@ -1161,7 +1176,7 @@ API 와 서버 규칙은 계약 2절 "실시간 알림". - 보던 메일함을 지우면 받은편지함으로 간다. 비우기 · 지우기가 끝나면 초점은 메일함 패널의 지금 메일함(지웠으면 받은편지함)으로 간다 — 누른 단추가 사라지므로. - **오래 걸리는 처리** - 하는 중의 글: "‘거래처’ 메일함을 지우는 중 · 12 / 40통" · "휴지통을 비우는 중 · 600 / 1,234통" (빈 메일함 지우기처럼 셀 메일이 없으면 수를 뺀다). - - 도중에 실패하면 "1,234통 가운데 600통까지 처리하고 멈췄습니다. 다시 시도해 주세요."로 바뀐다(까닭은 오류 알림으로 함께). 하나도 못 했으면 오류 알림만 남는다. + - 도중에 실패하면 "1,234통 가운데 600통까지 마치고 멈췄습니다. 다시 시도해 주세요."로 바뀐다(까닭은 오류 알림으로 함께). 하나도 못 했으면 오류 알림만 남는다. - 다른 탭에서 먼저 끝냈으면 실패로 보이지 않는다 — "받은편지함에 안 읽은 메일이 없습니다." · "휴지통이 이미 비어 있습니다.", 이미 없는 메일함 지우기는 지운 것으로 본다. - 같은 메일함의 처리는 한 번에 하나다(다른 탭에서 같은 메일함을 처리하는 중이면 "요청이 너무 많습니다"). 진행 알림은 로그아웃 · 세션 만료 때 닫힌다. - **휴지통을 비우는 동안은 메일함을 지울 수 없고, 메일함을 지우는 동안은 휴지통을 비울 수 없다** — 비우기는 그동안 휴지통에 들어온 메일도 지우므로, @@ -1179,7 +1194,7 @@ API 와 서버 규칙은 계약 2절 "실시간 알림". - 허락은 했는데 알림을 꺼 둔 상태: 칸은 비우고 안내는 초록 "이 브라우저에서 알림이 허용되어 있습니다."(보드에 없는 상태). - 실시간을 쓸 수 없는 브라우저(Web Locks · BroadcastChannel · 스트림 읽기 가운데 하나라도 없음)는 바탕화면 알림도 "지원하지 않습니다"로 본다 — 알림은 실시간으로만 온다. - 연결 상태 글: 처음 잇는 중 "연결하는 중입니다…", 끊김 "연결할 수 없어 1분마다 확인합니다." 아래에 "저절로 다시 연결합니다. 회사 방화벽이나 프록시가 오래 열린 연결을 끊으면 이렇게 될 수 있습니다.", - 지원하지 않음 "이 브라우저는 실시간 연결을 지원하지 않습니다." + 지원하지 않음 "이 브라우저에서는 실시간 연결을 쓸 수 없습니다." - 주소는 `/settings/mail/notify`, 탭 제목은 "메일 알림"(머리글은 "알림"). - 켜기 칸에서 허락을 받은 결과는 칸 아래 안내로만 보인다(화면 알림은 목록 위 권하기 줄에서만 — 설정 화면에서 [설정 열기]는 어색하다). - 연결이 끊긴 동안은 "연결할 수 없어 1분마다 확인합니다." 아래에 "연결될 때까지 바탕화면 알림은 오지 않습니다."를 더한다. @@ -1351,7 +1366,7 @@ API 와 서버 규칙은 계약 2절 "실시간 알림". 칸마다 저장은 한 번에 하나 — 보내는 동안 고른 값은 기억했다가 앞 저장이 끝난 뒤 보낸 값 · 서버의 답과 다르면 보낸다. "저장했습니다"는 다 끝나고 마지막이 성공했을 때. 끝내 실패하면 서버가 확인한 값으로 되돌린다. - **설정 › 차단한 주소**: 검사 차례는 형식 → 내 주소 · 사내 도메인 → 메일 서비스 도메인 → 이미 차단 → 500개. 서버 거절(`VAL_001` · `MAIL_013`)은 칸 아래 사전 문구(알림 없음). 성공하면 칸을 비우고 칸 아래에 "‘…’을(를) 차단했습니다."(`role=status` — 비어 있어도 영역은 늘 둔다). Enter 는 [차단]과 같다(`isImeComposing`). 줄을 풀면 초점은 다음 줄 → 앞 줄 → 칸. - 엔진 규칙이 꺼져 있으면(`isActive: false`) 머리 아래 노란 줄(단추 없음) "다른 곳에서 메일 설정이 바뀌어 지금은 차단이 되지 않습니다. 주소를 차단하거나 풀면 다시 켜집니다." — + 엔진 규칙이 꺼져 있으면(`isActive: false`) 머리 아래 노란 줄(단추 없음) "다른 곳에서 메일 설정이 바뀌어 지금은 차단이 꺼져 있습니다. 주소를 차단하거나 풀면 차단이 다시 켜집니다." — 차단 · 풀기가 성공하면 켜진 것으로 본다(서버가 다시 켠다 — 계약 2절). - **새 토큰 · 아이콘**: `--color-on-brand-track` · `--color-on-brand-progress` · `--color-on-brand-divider` · `--color-warning-border`, 아이콘 printer · clock · block(원 + 빗금). @@ -1400,9 +1415,9 @@ API 와 서버 규칙은 계약 2절 "실시간 알림". - 옛 한계 안내("낱말 단위로 찾습니다 …")는 색인을 채우는 동안(`building`)만 결과 없음 아래에 더한다. - 한 글자 안내: "한 글자로만 찾을 때는 본문은 빼고 제목, 보낸 사람과 받는 사람, 첨부 파일 이름에서 찾습니다. 본문까지 찾으려면 두 글자 이상으로 찾아 주세요." — 색인이 준비됐고 본문을 찾을 말(검색어나 내용 조건)이 있을 때만. - 준비 상태를 받지 못하면(요청 실패) 안내 줄 · 옛 한계 안내 · 한 글자 안내를 모두 보이지 않는다. -- **[더 찾기]**: 한 번에 다 찾지 못하면 "아직 맞는 메일을 찾지 못했습니다" / "메일이 많아 한 번에 다 찾지 못했습니다. [더 찾기]를 누르면 이어서 찾습니다." + [더 찾기](찾는 동안 "찾는 중…"). +- **[더 찾기]**: 한 번에 다 찾지 못하면 "아직 찾은 메일이 없습니다" / "메일이 많아 한 번에 다 찾지 못했습니다. [더 찾기]를 누르면 이어서 찾습니다." + [더 찾기](찾는 동안 "찾는 중…"). 하나도 못 찾았으면 "찾는 중"을 이어 보이고, 하나라도 찾으면 먼저 보이면서 뒤에서 잇는다. 목록 보기의 [다음]은 이어 받는 동안 기다렸다가 넘기고, 그동안 꺼짐 · `aria-busy` 다. -- **낱말이 너무 많은 주소**(손으로 고친 주소): "찾을 말이 너무 많습니다" / "검색어와 조건에 넣은 낱말은 모두 합쳐 8개까지 찾을 수 있습니다. 검색 칸이나 [조건]에서 낱말을 줄여 주세요." — [전체 메일함에서 찾기] · 알림 없이, 조건 줄의 [조건 지우기]는 남긴다. +- **낱말이 너무 많은 주소**(손으로 고친 주소): "찾을 말이 너무 많습니다" / "검색 칸과 [조건]에 넣은 낱말은 모두 합쳐 8개까지입니다. 낱말을 몇 개 줄여 주세요." — [전체 메일함에서 찾기] · 알림 없이, 조건 줄의 [조건 지우기]는 남긴다. - **강조 색**: 노랑 `#FCE7A4` 는 색 토큰 `--color-search-mark` 하나로 두고, 본문 틀 안 CSS 는 같은 값을 직접 적는다(짝). 보낸 사람이 본문에 쓴 `` 도 같은 노랑으로 보인다(드물어 가르지 않는다). 미리 보기 줄(회색 `--color-text-muted`)의 맞은 말만 글자를 `--color-text-secondary`(#4A5363)로 진하게 한다 — 회색 그대로면 노랑 위 대비가 4.05:1 로 4.5:1 에 못 미친다(2026-11-07 사용자 확정 — 보드와 다르다, 앱도 같다). 제목 · 읽기 화면 · 본문 틀의 강조는 진한 글 위라 글자색 그대로다. - 긴 안내 글(결과 없음 · [더 찾기] · 안내 줄)은 낱말 가운데서 줄을 바꾸지 않는다(`word-break: keep-all`). @@ -1448,7 +1463,7 @@ API 와 서버 규칙은 계약 2절 "실시간 알림". 분할 보기에 이름까지 넣으면(99) 1440 에서 개수 글 자리가 25px 남짓이라 아이콘으로 두고, 설정 설명이 말풍선 모양이라고 알린다(2026-11-07 잼). - 켠 모양은 옅은 청록 바탕 + 진한 청록 글자이고 테두리는 그대로다(보드의 #9FD3D7 테두리는 새 색 토큰이라 두지 않았다). - 설정 › 메일 › 보내기 · 읽기의 한 줄은 외부 이미지 아래 가는 선 뒤 — 끔 · 켬 전환(`.segmented--outline` — 보내기 취소와 같은 모양)과 - "답장과 전달로 이어진 메일을 메일함 목록에서 한 줄로 묶어 보여 줍니다. 메일함 목록 위의 말풍선 모양 [대화로 묶기] 단추로도 켜고 끌 수 있습니다." — 줄 이름은 단추와 같은 "대화로 묶기"(2026-11-07). + "켜면 답장과 전달로 이어진 메일을 메일함 목록에서 한 줄로 묶습니다. 메일함 목록 위의 말풍선 모양 [대화로 묶기] 단추로도 켜고 끌 수 있습니다." — 줄 이름은 단추와 같은 "대화로 묶기"(2026-11-07). - **줄** - 고르기 칸 · 별을 남겼다(보드의 줄에는 없다 — 검색 결과 줄과 같은 판단, 3단계부터 목록에서 여러 통을 고른다). 메일이 모두 보내기를 기다리는 줄은 고르기 칸 · 별이 꺼짐(aria-disabled) + 까닭이고, 목록 보기에서 Ctrl · Shift + 클릭으로도 고르지 않는다. diff --git a/docs/mail/api-contract.md b/docs/mail/api-contract.md index 37b8df6..9a7f2e7 100644 --- a/docs/mail/api-contract.md +++ b/docs/mail/api-contract.md @@ -1270,7 +1270,7 @@ interface MailFilterActions { - `MAIL_012` — 목록 위 줄 "다른 곳에서 필터가 바뀌어 저장하지 않았습니다. 다시 불러온 뒤 고쳐 주세요." + [다시 불러오기]. 고치던 창은 닫지 않는다 — 창 안에도 같은 안내와 [다시 불러오기]를 둔다(모달이라 뒤의 줄을 누를 수 없다). 창에서 다시 불러오면 쓴 내용은 그대로이고, 다음 [저장]은 다시 받은 목록에 그 필터를 넣는다(같은 id 자리 — 그사이 다른 곳에서 지워졌으면 맨 아래). - - `isActive: false` — 목록 위 줄 "다른 곳에서 메일 설정이 바뀌어 지금은 필터와 부재중 자동 응답이 되지 않습니다." + [다시 켜기](지금 목록 그대로 저장). + - `isActive: false` — 목록 위 줄 "다른 곳에서 메일 설정이 바뀌어 지금은 필터와 부재중 자동 응답이 꺼져 있습니다." + [다시 켜기](지금 목록 그대로 저장). - 읽기 화면 ⋯ 「이런 메일로 필터 만들기」 → `/settings/mail/filters?from=<보낸 사람 주소>` — 새 필터 창이 "보낸 사람 · 포함 · 그 주소"로 채워져 열린다(초점은 할 일의 첫 칸). 50개를 다 만들었는지 알려고 ⋯ 메뉴를 열 때 `GET` 을 한다(받아 둔 목록이 없거나 50개일 때만 — 알림 없이). - 검사는 서버와 같은 규칙(이름 50 · 값 200 · 조건 5 · 규칙 50 · `${` · 제어 문자 · 할 일 하나 이상 · 전달은 사내 주소이고 자기 주소 아님) — 칸 아래에만 알린다. @@ -1285,7 +1285,7 @@ interface MailFilterActions { ### 이미 받은 메일에 적용 (`/api/mail-filter-runs`) — 7단계 -필터를 저장할 때 고르면(고치기 창 「받은편지함에 이미 있는 메일에도 적용」) 받은편지함에 이미 있는 메일에도 그 규칙 하나를 적용한다. +필터를 저장할 때 고르면(고치기 창 「받은편지함에 이미 있는 메일도 정리하기」) 받은편지함에 이미 있는 메일에도 그 규칙 하나를 적용한다. 엔진의 Sieve 는 메일을 받을 때만 돌기 때문에 서버가 받은편지함을 읽어 같은 규칙으로 판정하고 표시 · 옮기기를 한다. ```ts @@ -1342,12 +1342,12 @@ interface MailFilterRun { - 엔진 한도: `Email/query` 는 한 번에 5,000통까지(`queryMaxResults`)라 차례마다 나눠 읽는다. 측정(개발 엔진, 2026-10-29, 시험 메일 5,000통): 보낸 사람 조건으로 4,500통을 확인하고 450통을 옮기는 데 1.3초, 본문 2,000통 읽기 1초 안팎 — 받은편지함 수만 통도 머리글 조건이면 한 요청, 본문 조건이면 몇 요청이다. - 로그 — 규칙의 글은 남기지 않는다(필터와 같다). 확인 · 적용 수 · 차례 수 · 걸린 시간만. -- **클라이언트**: 고치기 창의 「받은편지함에 이미 있는 메일에도 적용」 — 창을 열 때마다 꺼져 있다(필터의 설정이 아니다). `PUT /api/mail-filters` 가 성공한 뒤 저장한 그 규칙 +- **클라이언트**: 고치기 창의 「받은편지함에 이미 있는 메일도 정리하기」 — 창을 열 때마다 꺼져 있다(필터의 설정이 아니다). `PUT /api/mail-filters` 가 성공한 뒤 저장한 그 규칙 (응답 목록의 같은 id — 앞뒤 공백을 지운 값)으로 이 요청을 `cursor` 가 null 이 될 때까지 보낸다. - 칸을 끈다(칸 아래 설명이 까닭을 말한다): 이 탭에서 다른 적용이 진행 중, 그리고 **이미 받은 메일에 할 일이 없는 필터** — 읽음 · 중요 표시가 없고 옮기기도 없거나 받은편지함으로 · 지워진 메일함으로 옮기는 것뿐(까닭 문구는 지워진 메일함 → 받은편지함 → 전달만 차례로 고른다). 서버는 그런 규칙도 받는다(맞은 수만 센다). - 진행 알림 "‘{이름}’ 필터로 받은편지함을 정리하는 중 · {확인} / {전체}통 확인"(확인 = scanned 합, 전체 = 확인 + remaining) → 끝나면 그 자리에서 - "‘{이름}’ 필터로 받은편지함의 메일 N통을 정리했습니다."(N = matched 합) + 옮기는 필터이고 1통 이상이면 [{메일함} 보기], 0통이면 "받은편지함에 ‘{이름}’ 필터에 맞는 메일이 없습니다." + "‘{이름}’ 필터로 받은편지함의 메일 N통을 정리했습니다."(N = matched 합) + 옮기는 필터이고 1통 이상이면 [{메일함} 보기], 0통이면 "받은편지함에는 ‘{이름}’ 필터에 걸리는 메일이 없습니다." - 멈춤: "‘{이름}’ 필터로 {전체}통 가운데 {확인}통까지 확인하고 멈췄습니다. 그동안 N통을 정리했습니다. 다시 시도해 주세요." + [다시 시도](마지막 cursor 로 이어서). 처음 요청부터 실패하면 "필터는 저장했지만 받은편지함은 정리하지 못했습니다. 다시 시도해 주세요." + [다시 시도](처음부터). 429 는 "다른 창에서 필터로 받은편지함을 정리하는 중입니다. 끝난 뒤 다시 시도해 주세요." — 도중의 429 면 [다시 시도](이어서)도 붙인다. @@ -2315,13 +2315,13 @@ interface FileUploadCreate { | 코드 | HTTP | 뜻 | 쓰는 곳 | 문구 (제안) | | --- | --- | --- | --- | --- | -| `FILE_003` | 409 | 올리기 · 파일의 지금 상태와 맞지 않는 요청 — 끝낸 올리기에 조각, 같은 조각을 동시에, 받은 조각과 내용이 다른 조각, 빠진 조각이 있는데 끝내기, 끝내기가 진행 중, 살아 있는 링크에 실린 파일 지우기. 아무것도 바꾸지 않았다 | 조각 · 끝내기 · 지우기 | "파일 상태가 그사이 바뀌어 처리하지 못했습니다. 다시 시도해 주세요." | -| `FILE_004` | 400 | 조각의 `Content-Digest` 가 받은 바이트와 다르다 — 그 조각을 받지 않았다(같은 조각을 다시) | 조각 | "파일을 올리는 중에 일부가 제대로 전달되지 않았습니다. 다시 시도해 주세요." | +| `FILE_003` | 409 | 올리기 · 파일의 지금 상태와 맞지 않는 요청 — 끝낸 올리기에 조각, 같은 조각을 동시에, 받은 조각과 내용이 다른 조각, 빠진 조각이 있는데 끝내기, 끝내기가 진행 중, 살아 있는 링크에 실린 파일 지우기. 아무것도 바꾸지 않았다 | 조각 · 끝내기 · 지우기 | "그사이 파일이 바뀌어 하던 일을 마치지 못했습니다. 다시 시도해 주세요." | +| `FILE_004` | 400 | 조각의 `Content-Digest` 가 받은 바이트와 다르다 — 그 조각을 받지 않았다(같은 조각을 다시) | 조각 | "파일 일부가 제대로 올라가지 않았습니다. 다시 시도해 주세요." | | `FILE_005` | 409 | 직원 보관 한도(보관량 · 보내지 않은 파일 수)를 넘는다 — 만들지 않았다 | 만들기 | "대용량 첨부 보관 한도를 넘어 더 올릴 수 없습니다. 보낸 파일의 링크를 끊거나 보내지 않은 파일을 지우면 공간이 생깁니다." | -| `FILE_006` | 507 | 서버 저장 공간이 모자란다 — 서버 총량 · 디스크 여유 · 쓰다 `ENOSPC` | 만들기 · 조각 | "서버의 저장 공간이 모자라 지금은 파일을 올릴 수 없습니다. 잠시 뒤 다시 시도해 주세요." | +| `FILE_006` | 507 | 서버 저장 공간이 모자란다 — 서버 총량 · 디스크 여유 · 쓰다 `ENOSPC` | 만들기 · 조각 | "회사 저장 공간이 모자라 지금은 파일을 올릴 수 없습니다. 잠시 뒤 다시 시도해 주세요." | | `FILE_007` | 410 | 더 내려받을 수 없는 파일 — 기간 지남 · 공유 멈춤 · 횟수 초과 · 위험 · 찾을 수 없음 | 공개 받기 · 우리 웹메일 받기 | "이 파일은 더 내려받을 수 없습니다. 필요하면 보낸 사람에게 문의해 주세요." | | `FILE_008` | 409 | 아직 보안 검사 중인 파일 — 끝나면 받는다 | 공개 받기 · 우리 웹메일 받기 | "보안 검사가 끝나지 않은 파일입니다. 잠시 뒤 다시 시도해 주세요." | -| `MAIL_016` | 400 | 보내거나 저장할 대용량 파일을 쓸 수 없다 — 없음 · 다른 직원 것 · 지움 · 기간 지남, (보낼 때) 올리는 중 · 위험. 메일은 나가지 않았다 | 발송 · 임시 저장 | "대용량 첨부 파일을 보낼 수 없습니다. 해당 파일을 빼거나 다시 올린 뒤 보내 주세요." | +| `MAIL_016` | 400 | 보내거나 저장할 대용량 파일을 쓸 수 없다 — 없음 · 다른 직원 것 · 지움 · 기간 지남, (보낼 때) 올리는 중 · 위험. 메일은 나가지 않았다 | 발송 · 임시 저장 | "보낼 수 없는 대용량 첨부 파일이 있습니다. 그 파일을 빼거나 다시 올린 뒤 보내 주세요." | - 문구에 숫자를 넣지 않는다 — 한도가 env 로 바뀐다(`MAIL_006` · `CONTACT_002` 처럼 상수와 견주는 테스트를 둘 수 없다). - 그 밖은 지금 코드를 쓴다 — 형식 400 `FILE_001`, 한 파일 · 조각 본문 413 `FILE_002`, 개수 · 모양 400 `VAL_001`, 없음 404 `RES_001`, 요청 수 · 동시 수 429 `REQ_001`, 엔진 503 `MAIL_001`. @@ -2675,7 +2675,7 @@ interface FileUploadCreate { | 코드 | HTTP | 상황 | 문구(제안) | | --- | --- | --- | --- | -| `AUTH_006` | 400 | 2단계 인증 코드가 틀림 · 2분 안에 다시 씀(로그인 · 비밀번호 바꾸기 · 켜기 · 끄기) | "코드가 올바르지 않습니다. 휴대폰의 날짜와 시간이 자동으로 맞춰지도록 설정되어 있는지 확인해 주세요." | +| `AUTH_006` | 400 | 2단계 인증 코드가 틀림 · 2분 안에 다시 씀(로그인 · 비밀번호 바꾸기 · 켜기 · 끄기) | "코드가 맞지 않습니다. 휴대폰 설정에서 날짜와 시간을 자동으로 맞추게 해 두었는지 확인해 주세요." | | `AUTH_007` | 400 | 2단계 인증을 켠 직원의 요청에 코드가 없음(다른 곳에서 켠 뒤의 화면) | "인증 앱의 코드를 입력해 주세요." | | `AUTH_008` | 409 | 등록이 없음 · 시간이 지남 · 그사이 다른 곳에서 바뀜 | "2단계 인증 설정이 그사이 바뀌었거나 시간이 지났습니다. 처음부터 다시 해 주세요." | | `AUTH_009` | 409 | 앱 비밀번호 상한 | "5개까지 만들 수 있습니다. 쓰지 않는 앱 비밀번호를 지운 뒤 만들어 주세요." — 엔진 상한을 바꾸면 이 문구와 화면 상수도 | diff --git a/flutter/lib/core/api/error_lexicon.g.dart b/flutter/lib/core/api/error_lexicon.g.dart index d1bf2e2..f378976 100644 --- a/flutter/lib/core/api/error_lexicon.g.dart +++ b/flutter/lib/core/api/error_lexicon.g.dart @@ -1,35 +1,35 @@ // backend/src/common/errors/error-codes.ts 에서 자동 생성된 파일입니다. 직접 수정하지 마세요. const Map kErrorCodeMessages = { - 'SYS_001': '일시적인 시스템 오류가 발생했습니다. 잠시 후 다시 시도해 주세요.', - 'REQ_001': '요청이 너무 많습니다. 잠시 후 다시 시도해 주세요.', - 'AUTH_001': '로그인이 필요한 서비스입니다. 로그인 후 이용해 주세요.', - 'AUTH_002': '안전을 위해 로그아웃 되었습니다. 다시 로그인해 주세요.', - 'AUTH_003': '해당 메뉴나 기능에 접근할 수 있는 권한이 없습니다.', - 'AUTH_004': '이메일 또는 비밀번호가 올바르지 않습니다.', + 'SYS_001': '문제가 생겨 하던 일을 마치지 못했습니다. 잠시 뒤 다시 시도해 주세요.', + 'REQ_001': '너무 여러 번 시도했거나 같은 일이 이미 진행 중입니다. 잠시 뒤 다시 시도해 주세요.', + 'AUTH_001': '로그인이 필요합니다. 로그인한 뒤 다시 시도해 주세요.', + 'AUTH_002': '안전을 위해 로그아웃했습니다. 다시 로그인해 주세요.', + 'AUTH_003': '이 기능을 쓸 수 있는 권한이 없습니다.', + 'AUTH_004': '메일 주소나 비밀번호가 맞지 않습니다. 다시 확인해 주세요.', 'AUTH_005': '지금 비밀번호가 올바르지 않습니다.', - 'AUTH_006': '코드가 올바르지 않습니다. 휴대폰의 날짜와 시간이 자동으로 맞춰지도록 설정되어 있는지 확인해 주세요.', + 'AUTH_006': '코드가 맞지 않습니다. 휴대폰 설정에서 날짜와 시간을 자동으로 맞추게 해 두었는지 확인해 주세요.', 'AUTH_007': '인증 앱의 코드를 입력해 주세요.', 'AUTH_008': '2단계 인증 설정이 그사이 바뀌었거나 시간이 지났습니다. 처음부터 다시 해 주세요.', 'AUTH_009': '5개까지 만들 수 있습니다. 쓰지 않는 앱 비밀번호를 지운 뒤 만들어 주세요.', 'AUTH_010': '먼저 비밀번호를 바꾸거나 2단계 인증을 켜 주세요.', 'VAL_001': '입력하신 내용을 다시 확인해 주세요. 빠진 칸이 있거나 형식이나 길이가 맞지 않습니다.', - 'RES_001': '요청하신 정보나 페이지를 찾을 수 없습니다.', - 'BIZ_001': '요청하신 작업을 완료하지 못했습니다. 다시 시도해 주세요.', - 'FILE_001': '허용되지 않는 파일 형식입니다. 파일을 확인해 주세요.', - 'FILE_002': '파일 크기가 허용 범위를 초과했습니다.', - 'FILE_003': '파일 상태가 그사이 바뀌어 처리하지 못했습니다. 다시 시도해 주세요.', - 'FILE_004': '파일을 올리는 중에 일부가 제대로 전달되지 않았습니다. 다시 시도해 주세요.', + 'RES_001': '찾을 수 없습니다. 이미 지워졌거나 옮겨졌을 수 있습니다.', + 'BIZ_001': '하려던 일을 마치지 못했습니다. 다시 시도해 주세요.', + 'FILE_001': '쓸 수 없는 형식의 파일입니다. 그 파일을 빼거나 다른 파일로 바꿔 주세요.', + 'FILE_002': '파일이 너무 커서 올릴 수 없습니다.', + 'FILE_003': '그사이 파일이 바뀌어 하던 일을 마치지 못했습니다. 다시 시도해 주세요.', + 'FILE_004': '파일 일부가 제대로 올라가지 않았습니다. 다시 시도해 주세요.', 'FILE_005': '대용량 첨부 보관 한도를 넘어 더 올릴 수 없습니다. 보낸 파일의 링크를 끊거나 보내지 않은 파일을 지우면 공간이 생깁니다.', - 'FILE_006': '서버의 저장 공간이 모자라 지금은 파일을 올릴 수 없습니다. 잠시 뒤 다시 시도해 주세요.', + 'FILE_006': '회사 저장 공간이 모자라 지금은 파일을 올릴 수 없습니다. 잠시 뒤 다시 시도해 주세요.', 'FILE_007': '이 파일은 더 내려받을 수 없습니다. 필요하면 보낸 사람에게 문의해 주세요.', 'FILE_008': '보안 검사가 끝나지 않은 파일입니다. 잠시 뒤 다시 시도해 주세요.', 'MAIL_001': '메일 서비스에 연결할 수 없습니다. 잠시 후 다시 시도해 주세요.', 'MAIL_002': '받는 사람 주소를 다시 확인해 주세요.', - 'MAIL_003': '메일함 용량이 부족합니다. 불필요한 메일을 정리한 뒤 다시 시도해 주세요.', - 'MAIL_004': '첨부 파일을 찾을 수 없습니다. 해당 첨부를 빼거나 다시 올린 뒤 보내 주세요.', + 'MAIL_003': '메일함이 가득 찼습니다. 필요 없는 메일을 지우고 휴지통을 비운 뒤 다시 시도해 주세요.', + 'MAIL_004': '첨부 파일을 찾을 수 없습니다. 그 파일을 빼거나 다시 올린 뒤 보내 주세요.', 'MAIL_005': '이미 보낸 메일과 내용이 달라 보내지 않았습니다. 보낸편지함을 확인한 뒤 다시 보내 주세요.', - 'MAIL_006': '첨부는 한 메일에 최대 20개까지 보낼 수 있습니다.', + 'MAIL_006': '첨부 파일은 메일 한 통에 20개까지 보낼 수 있습니다.', 'MAIL_007': '본문이 너무 길어 보낼 수 없습니다. 내용을 줄인 뒤 다시 보내 주세요.', 'MAIL_008': '임시 저장한 메일이 다른 곳에서 바뀌었거나 지워졌습니다. 지금 내용을 새 임시 저장본으로 남겨 주세요.', 'MAIL_009': '같은 이름의 메일함이 이미 있습니다.', @@ -39,7 +39,7 @@ const Map kErrorCodeMessages = { 'MAIL_013': '필터나 차단 목록이 너무 길어 저장할 수 없습니다. 조건을 줄이거나, 쓰지 않는 필터나 차단한 주소를 지워 주세요.', 'MAIL_014': '이미 보낸 메일이라 취소할 수 없습니다.', 'MAIL_015': '보내기를 기다리는 메일은 옮기거나 지울 수 없습니다. 먼저 보내기를 취소해 주세요.', - 'MAIL_016': '대용량 첨부 파일을 보낼 수 없습니다. 해당 파일을 빼거나 다시 올린 뒤 보내 주세요.', + 'MAIL_016': '보낼 수 없는 대용량 첨부 파일이 있습니다. 그 파일을 빼거나 다시 올린 뒤 보내 주세요.', 'CONTACT_001': '같은 이름의 그룹이 있습니다. 다른 이름을 써 주세요.', 'CONTACT_002': '주소록 한도를 넘어 더 넣을 수 없습니다. 연락처 3,000개, 그룹 100개, 그룹 하나에 500명까지 넣을 수 있습니다.', }; diff --git a/flutter/test/error_lexicon_test.dart b/flutter/test/error_lexicon_test.dart index f455b57..8a9c319 100644 --- a/flutter/test/error_lexicon_test.dart +++ b/flutter/test/error_lexicon_test.dart @@ -7,7 +7,7 @@ void main() { test('등록된 코드는 해당 한국어 메시지를 반환한다', () { expect( ErrorLexicon.getMessage('AUTH_001'), - '로그인이 필요한 서비스입니다. 로그인 후 이용해 주세요.', + '로그인이 필요합니다. 로그인한 뒤 다시 시도해 주세요.', ); }); diff --git a/frontend/src/components/feedback/StateMessage.vue b/frontend/src/components/feedback/StateMessage.vue index a2da0de..5ab9f57 100644 --- a/frontend/src/components/feedback/StateMessage.vue +++ b/frontend/src/components/feedback/StateMessage.vue @@ -18,7 +18,7 @@ interface Props { tone?: 'neutral' | 'danger' /** 서버 추적 ID — 앞 8자리를 "문의 코드"로 보인다 */ traceId?: string | null - /** 영역을 세로로 가득 채워 가운데 둔다 (본문 영역의 "메일을 선택해 주세요") */ + /** 영역을 세로로 가득 채워 가운데 둔다 (본문 영역의 "고른 메일이 없습니다") */ fill?: boolean } diff --git a/frontend/src/components/mail/ConversationMessagePanel.vue b/frontend/src/components/mail/ConversationMessagePanel.vue index 3fce3e9..0823ed2 100644 --- a/frontend/src/components/mail/ConversationMessagePanel.vue +++ b/frontend/src/components/mail/ConversationMessagePanel.vue @@ -2,6 +2,7 @@ // "같은 대화" 칸에서 펼친 줄의 안 (14단계 — 디자인 원본 Thread14 "하나 펼침") // 받는 사람 · 제목 한 줄씩, 본문(MailBodyFrame — 검색 쿼리 없이 받은 본문이라 강조가 없다), [답장] · [이 메일만 열기](36 — 밀도 예외 --size-notice-action 재사용). // - 본문이 다 보이지 않으면(서버의 isHtmlBodyOmitted · isBodyTruncated) 읽기 화면과 같은 안내 줄(MAIL_BODY_NOTICES)을 본문 위에 — 읽기 화면도 같은 본문이다. +// 앞 문장만 보이고 원문 내려받기(할 일)는 붙이지 않는다 — 이 화면의 ⋯ 는 위에 연 메일의 것이라, 원문은 [이 메일만 열기]로 연 읽기 화면에서 받는다. // 외부 이미지 안내 · 첨부 목록은 두지 않는다 — [이 메일만 열기]로 연 읽기 화면에 있다. 첨부가 있으면 본문 아래 · [답장] 위에 // "첨부 파일 N개는 [이 메일만 열기]를 누르면 볼 수 있습니다." (수는 읽기 화면의 "첨부 파일 N개"와 같다 — 본문 그림 빼고 · 대용량 첨부 더해) // - 본문을 받는 동안은 뼈대(aria-busy), 못 받으면 사전 문구 + [다시 시도]. 그사이 지워진 메일(RES_001)이면 [다시 시도] · [답장] · [이 메일만 열기] 없이 — @@ -49,7 +50,7 @@ const ready = computed(() => (props.detail?.status === 'ready' ? props.detail.de const failure = computed(() => (props.detail?.status === 'error' ? props.detail.error : null)) /** 그사이 지워진 메일 — 다시 해도 같고 그 메일로 갈 곳도 없다 */ const missing = computed(() => failure.value?.code === 'RES_001') -const bodyNotices = computed(() => (ready.value ? mailBodyNotices(ready.value) : [])) +const bodyNotices = computed(() => (ready.value ? mailBodyNotices(ready.value, { withAction: false }) : [])) /** 읽기 화면에서 볼 수 있는 첨부 수 — 본문 그림(isInline)은 빼고 대용량 첨부는 더한다 (MessageReader 의 첨부 칸과 같은 수) */ const attachmentCount = computed(() => ready.value ? listedAttachments(ready.value.attachments).length + (ready.value.largeFiles?.length ?? 0) : 0, diff --git a/frontend/src/components/mail/MailListFrame.spec.ts b/frontend/src/components/mail/MailListFrame.spec.ts index 7f841e9..2f76a64 100644 --- a/frontend/src/components/mail/MailListFrame.spec.ts +++ b/frontend/src/components/mail/MailListFrame.spec.ts @@ -2,6 +2,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { mount } from '@vue/test-utils' import { createPinia, setActivePinia, type Pinia } from 'pinia' +import { MAILBOX_EMPTY_HINTS } from '@/shared/constants/mail' import type { Mailbox, MessageSummary } from '@/shared/types/mail' import { useAuthStore } from '@/stores/auth.store' import { useMailStore } from '@/stores/mail.store' @@ -239,7 +240,9 @@ describe('MailListFrame — 휴지통 · 스팸메일함의 30일 줄 (13단계) expect(wrapper.find('[data-retention-notice]').exists()).toBe(false) expect(wrapper.text()).toContain('휴지통이 비어 있습니다') - expect(wrapper.text()).toContain('삭제한 메일이 여기에 표시됩니다. 들어간 지 30일이 지난 메일은 자동으로 지워집니다.') + expect(wrapper.text()).toContain(MAILBOX_EMPTY_HINTS.trash) + // 줄 대신 이 글이 30일을 알린다 + expect(MAILBOX_EMPTY_HINTS.trash).toContain('30일') }) it('다른 메일함 · 검색 결과에는 없다', async () => { diff --git a/frontend/src/components/mail/MailSearchBox.spec.ts b/frontend/src/components/mail/MailSearchBox.spec.ts index b6791f8..851b59e 100644 --- a/frontend/src/components/mail/MailSearchBox.spec.ts +++ b/frontend/src/components/mail/MailSearchBox.spec.ts @@ -4,6 +4,7 @@ import { createPinia, setActivePinia, type Pinia } from 'pinia' import { CLOCK } from '@/composables/useClock' import { ERROR_CODE_MESSAGES } from '@/shared/constants/error-codes.generated' +import { SEARCH_CONDITIONS_TEXT } from '@/shared/constants/mail-search' import type { Mailbox } from '@/shared/types/mail' import { useAuthStore } from '@/stores/auth.store' import { useMailStore } from '@/stores/mail.store' @@ -245,9 +246,7 @@ describe('MailSearchBox — [조건] · 「조건으로 찾기」 (14단계 디 '최근 1년', '날짜 고르기', ]) - expect(panel.text()).toContain( - '검색 칸의 말과 이 조건에 모두 맞는 메일을 찾습니다. 받는 사람에는 참조로 받은 사람도 들어갑니다.', - ) + expect(panel.text()).toContain(SEARCH_CONDITIONS_TEXT.hint) // 칸 안내 — 내용 칸은 칸 이름과 같은 말로 (2026-11-06 실사용 시험) expect(['from', 'to', 'subject', 'body'].map((name) => field(wrapper, name).attributes('placeholder'))).toEqual([ '이름이나 메일 주소', diff --git a/frontend/src/components/mail/MailSearchEmpty.vue b/frontend/src/components/mail/MailSearchEmpty.vue index e59248b..0620f3b 100644 --- a/frontend/src/components/mail/MailSearchEmpty.vue +++ b/frontend/src/components/mail/MailSearchEmpty.vue @@ -1,6 +1,6 @@