diff --git a/.claude/rules/error-codes.md b/.claude/rules/error-codes.md index c09aafb..fe93dfb 100644 --- a/.claude/rules/error-codes.md +++ b/.claude/rules/error-codes.md @@ -36,12 +36,12 @@ backend/src/common/errors/error-codes.ts ← 여기만 수정한다 | 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 | 입력 검증 실패 | "입력하신 정보를 다시 확인해 주세요. (필수값 누락 또는 형식 오류)" | +| VAL_001 | 400 / 422 | 입력 검증 실패(빠진 값 · 형식 · 길이 · 개수 한도) | "입력하신 내용을 다시 확인해 주세요. 빠진 칸이 있거나 형식이나 길이가 맞지 않습니다." | | RES_001 | 404 | 리소스 없음 | "요청하신 정보나 페이지를 찾을 수 없습니다." | | BIZ_001 | 400 | 그 외 비즈니스 규칙 위반 | "요청하신 작업을 완료하지 못했습니다. 다시 시도해 주세요." | diff --git a/.claude/skills/vue3/SKILL.md b/.claude/skills/vue3/SKILL.md index f95d652..b7b6feb 100644 --- a/.claude/skills/vue3/SKILL.md +++ b/.claude/skills/vue3/SKILL.md @@ -262,6 +262,7 @@ router.beforeEach((to) => { - [ ] **에러 사전**: `error-codes.generated.ts` 직접 수정 금지 — 백엔드 레지스트리 수정 후 `npm run sync:error-codes`. - [ ] **목록 화면**: 응답은 `{items, pageInfo}` — `shared/types/pagination.ts` 타입 사용. - [ ] **컴포넌트**: ` + + + + + + +
+ + + +
+ +
+

메일

+
+ + +
+
+ +
+ 김아라 + ara.kim@arasystem.it.kr +
+ + + 로그아웃 + +
+
+ +
+ + + +
+ +
+
+

받은편지함

+ {{ headCount }} + +
+
    + +
  • + +
  • +
    +
+
+ +
+
+ +
+

{{ m.subject }}

+
+ +
+
+ {{ m.fromName }} + {{ m.fromEmail }} + {{ m.date }} +
+ 받는 사람 김아라 <ara.kim@arasystem.it.kr> +
+
+
{{ m.body }}
+ + +
+

같은 대화 {{ m.convCount }}통

+
    + +
  1. + +
    + + {{ c.who }} + 지금 보고 있는 메일 + {{ c.date }} +
    +
    + + + +
    +
    {{ c.body }}
    + +
    +
    +
    +
  2. +
    +
+
+
+
+
+
+
+
+
+
+
+ + + diff --git a/docs/design/webmail/README.md b/docs/design/webmail/README.md index 4fe9b46..142db1f 100644 --- a/docs/design/webmail/README.md +++ b/docs/design/webmail/README.md @@ -52,6 +52,34 @@ 분할 보기 읽기·목록 보기 읽기·쓰기 세 곳이 같은 값을 쓰고, 툴바 첫 아이콘도 같은 32px 선에서 시작한다. - 브랜드 컬러 "바다 청록": 바탕 `#0B3640`, 강조 `#0E7C86`. 파생 색은 각 화면 원본의 인라인 스타일을 따른다. +## 화면 문구 — 사용자에게 말하듯 (2026-11-05 사용자 요청) + +화면 · 알림 · 화면 낭독기(`aria-label` · `title`) · 칸 안내(placeholder)의 글은 설계 문서가 아니라 쓰는 사람에게 하는 말이다. +원본은 화면 상수(`frontend/src/shared/constants/`)이고 이 문서는 옮겨 적는다. + +1. **무엇이 되는지 · 무엇을 하면 되는지**를 쓴다. 만든 쪽의 사정(메일 서버 · 엔진 · 계정 · 판정 · 적용 · 동작 · 규칙 · 대상)은 빼거나 쉬운 말로 바꾼다. + - ✗ 메일 서버에 계정이 있는 직원입니다 — 이름과 주소만 보입니다. → ○ 회사 메일을 쓰는 직원이라 이름과 메일 주소만 보입니다. +2. **줄표(—)로 잇지 않는다.** 두 문장으로 나누거나 "~해서 · ~니 · ~지만"으로 잇는다. 짧은 상태 표시도 줄표 대신 한 구절로(✗ 실패 — 비밀번호 틀림 → ○ 비밀번호가 틀려 실패). + 결과 칸이 따로 있는 표라면 까닭만 써도 된다. +3. **「」 를 쓰지 않는다.** 화면의 단추 · 메뉴 · 칸 이름은 [대괄호](직원 공지와 같다 — [설정] → [2단계 인증]), 사용자가 넣은 값(이름 · 그룹 이름 · 파일 이름)은 지금처럼 ‘ ’. +4. **규칙을 정의하듯 쓰지 않는다.** + - ✗ 이 주소에서 오는 메일은 스팸메일함으로 갑니다. 부재중 자동 응답도 보내지 않습니다. 「@도메인」은 그 도메인에서 오는 메일 모두입니다. + - ○ 차단한 주소에서 온 메일은 스팸메일함으로 옮겨지고, 부재중 자동 응답도 보내지 않습니다. @spam.example 처럼 @로 시작해 넣으면 그 도메인에서 오는 메일을 모두 막습니다. +5. **가운뎃점(·) 나열은 쉼표로**, 괄호 속 보충은 문장으로 풀거나 뺀다(꼭 필요한 예시만 괄호). 입력 칸 안내(placeholder)는 짧게. + - ✗ 이름 · 주소 · 회사 · 전화번호 — 초성도 됩니다 (ㅂㅈㅎ) → ○ 이름, 주소, 회사, 전화번호, 초성(ㅂㅈㅎ)으로 찾기 +6. **말끝은 지금처럼 존댓말** "~합니다 · ~해 주세요"(해요체로 바꾸지 않는다). 딱딱한 명령("~하세요" · "~하지 마세요")은 "~해 주세요 · ~하지 않으셔도 됩니다"처럼 부드럽게. + 단추 · 칸 이름 · 짧은 상태 꼬리표는 명사형 그대로. +7. **위험 · 보안 문구는 겁주지 않되 분명하게** — 무엇이 막혔고 무엇을 하면 되는지를 함께. +8. **뜻은 그대로** — 한도 · 날짜 · 개수 · 조건 같은 정보를 빼지 않는다. 문장만 바꾼다. 영어 문구(공개 내려받기 화면)는 자연스러운 영어로만 다듬는다. + +적용하며 정한 것(2026-11-05): +- 한 줄에 여러 정보를 늘어놓는 짧은 표시의 가운뎃점은 나열이 아니라 칸 구분이라 그대로 둔다 — 파일 줄 "1.2 GB · 3회 받음 · 11월 29일까지", "‘회의’ · 6통", + 첨부 안내 "파일당 최대 20MB · 합계 25MB · 최대 20개", 탭 제목 "메일 · ARA SYSTEM". 메뉴 · 화면 이름("보내기 · 읽기")도 이름이라 그대로다. +- 이 문서의 설명 글은 지금처럼 「」 로 화면 요소를 가리킨다 — 위 규칙은 화면에 보이는 글에만 쓴다. +- 디자인 보드(`*.dc.html`)의 문구는 11-05 전의 옛 글이다 — 문구는 이 문서와 화면 상수가 기준이다. +- 서버가 만드는 글(에러 코드 사전 · 받은편지함 알림 메일 · 본문의 대용량 첨부 덩이)도 같은 규칙으로 맞췄다(2026-11-05) — 이 문서에 옮겨 적은 그 글(13단계 알림 메일 등)도 새 글이다. + 알림 메일의 메뉴 경로도 화면 글처럼 "웹메일의 [설정] → [앱 비밀번호]" 꼴로 적는다. + ## 메일 쓰기 (2026-10-27 확정 — `Compose` · `ComposeEditor`) 위에서부터 머리(제목·닫기) → 받는 사람(→ 참조·숨은참조) → 제목 → 본문 툴바 → 본문 → 첨부 → 보내기 줄이다. 모두 콘텐츠 열 안에 있다. @@ -126,7 +154,7 @@ Enter = 적용, Esc = 닫고 본문으로. 주소는 http(s) · mailto 만 받는다. - **표 넣기**(더보기 ›): 8열 × 6행 격자에서 고른다("3행 × 4열"). 표 안의 행·열 추가·삭제는 다음 단계. - **메뉴·팝오버 공통**: 흰 바탕 · 테두리 #E6E8EC · 모서리 10 · 그림자 `--shadow-toast` · 툴바 아래 선 바로 밑에서 연다. Esc 로 닫는다. -- **빈 본문**: 안내 문구 "내용을 입력하세요" · #667085(4.9:1). 커서는 문구 앞에 선다. +- **빈 본문**: 안내 문구 "내용을 입력해 주세요" · #667085(4.9:1). 커서는 문구 앞에 선다. - **문단**: 문단 사이 여백은 0 — Enter 한 번이 한 줄이다(지금 textarea 와 같다). 줄 간격은 1.75 이고, 보내는 본문도 같은 값이다 (감싸는 div 의 `line-height` — 받는 사람이 편집기와 같은 모양으로 본다, 2026-10-27 구현하며 정함). 여러 칸 띄어쓰기 · 줄 앞 공백도 받는 쪽에서 그대로 보이게 보낸다. 탭은 다음 8칸 자리까지의 공백이 된다(편집 영역의 탭 간격과 같다, 2026-10-27). @@ -212,7 +240,7 @@ 그림을 고르면 칸 아래에 크기 줄(작게 96px · 보통 144px · 원본 — 최대 600px · 그림 빼기). 메일 안에 함께 실어 보내므로 받는 쪽에서 외부 이미지처럼 막히지 않는다. 지금 있는 흰 로고는 흰 메일 바탕에서 보이지 않으니 서명에는 색이 있는 로고를 쓴다. - 서명을 넣을 메일: 새 메일 · 답장 · 전달(인용한 원문 위) — 둘 다 기본으로 켠다. 미리 보기에서 [새 메일 | 답장]을 바꿔 본다. -- [저장] → "저장했습니다. 새로 여는 쓰기 화면부터 들어갑니다."(#067647). +- [저장] → "저장했습니다. 다음에 메일을 쓸 때부터 들어갑니다."(#067647). - 쓰기 화면을 열 때 서명을 보통 글로 넣어 둔다 — 새 메일은 본문 끝(빈 줄 하나 뒤), 답장 · 전달은 인용 위. 그 메일에서 고치거나 지울 수 있고 임시 저장에도 남는다. 임시 저장본을 이어 쓸 때는 서명을 다시 넣지 않는다. 본문에 붙여 넣은 다른 그림은 지금처럼 첨부로 올린다. - 서명 그림처럼 **본문 안에 그림으로 보인 부분은 첨부로 보이지 않는다** — 읽기 화면의 첨부 목록과 목록의 첨부 클립에서 뺀다 @@ -246,12 +274,12 @@ API 와 서버 규칙은 계약 2절 "새 메일 확인" · "검색" · "여러 - 결과는 보던 보기 방식(분할 · 목록) 그대로 목록 자리에 보인다. - 머리(52): [←](40×44 · "검색 닫기") · "검색 결과"(15px 굵게) · "‘회의’ · 6통"(12px #667085) · 보기 전환. - 그 아래 범위 줄(48 · 바탕 #FBFBFC): "찾을 곳" + [그 메일함 | 전체 메일함](36 — 설정 화면의 전환과 같은 밀도 예외) - + 전체일 때 "휴지통 · 스팸메일함은 빼고 찾습니다"(12px #667085). + + 전체일 때 "휴지통과 스팸메일함은 빼고 찾습니다"(12px #667085). - 전체 메일함에서 찾으면 행에 메일함을 보인다 — 분할 보기는 첫 줄 시각 앞에 "보낸편지함 ·"(12px #667085), 목록 보기 표는 보낸 사람 뒤에 메일함 칸(96). 보낸편지함 · 임시보관함의 메일은 보낸 사람 자리에 "받는 사람: 이름". - 검색 결과에서 연 메일의 읽기 화면은 받는 사람 아래에 "메일함 받은편지함" 한 줄(12.5px #667085)을 더한다. -- 결과 없음: 검색 아이콘 · "‘…’에 맞는 메일이 없습니다" · 한계 한 줄 "낱말 단위로 찾습니다. 붙여 쓴 말의 일부(‘서버이전’의 ‘서버’)나 - 제목 맨 앞의 말머리([공지] · Re:)로는 찾지 못합니다." · 한 메일함에서 찾았으면 [전체 메일함에서 찾기]. +- 결과 없음: 검색 아이콘 · "‘…’에 맞는 메일이 없습니다" · 한계 한 줄 "낱말 단위로 찾습니다. ‘서버이전’의 ‘서버’처럼 붙여 쓴 말의 일부나, + 제목 맨 앞의 [공지]나 Re: 같은 말머리로는 찾지 못합니다." · 한 메일함에서 찾았으면 [전체 메일함에서 찾기]. - 찾는 동안은 지금의 목록 뼈대를 보인다. ### 여러 통 처리 @@ -284,18 +312,18 @@ API 와 서버 규칙은 계약 1절 "`PUT /api/auth/password`", 2절 "메일함 ### 비밀번호 - 칸 셋(최대 폭 420): 지금 비밀번호 · 새 비밀번호 · 새 비밀번호 확인. 칸 높이 44, 앞의 두 칸 끝에 보기/숨기기 단추(44 · 눈 아이콘, `aria-pressed`). -- 새 비밀번호 아래 안내 "8자 이상 · 지금 비밀번호와 달라야 합니다. 영문 · 숫자 · 기호를 섞으면 더 안전합니다." (12.5px #667085). +- 새 비밀번호 아래 안내 "8자 이상이고 지금 비밀번호와 달라야 합니다. 영문, 숫자, 기호를 섞으면 더 안전합니다." (12.5px #667085). - 오류는 그 칸 아래 빨간 글(#B42318, 아이콘 alert-circle) · 칸 테두리도 빨강 — "지금 비밀번호가 올바르지 않습니다." · "새 비밀번호가 서로 다릅니다." -- [비밀번호 바꾸기] → "비밀번호를 바꿨습니다. 다른 기기 · 브라우저의 로그인은 끊었습니다."(#067647), 세 칸을 비운다. +- [비밀번호 바꾸기] → "비밀번호를 바꿨습니다. 다른 기기와 브라우저에서는 로그아웃했습니다."(#067647), 세 칸을 비운다. - 아래 안내 상자(바탕 #F3F8F8): 다른 기기의 웹메일 로그인이 끊기는 것과 메일 프로그램(IMAP)에 새 비밀번호를 다시 넣어야 하는 것. ### 부재중 자동 응답 - [x] 부재중 자동 응답 사용(44). 끄면 아래 칸을 흐리게(쓴 글은 남긴다). -- 기간: (•) 끌 때까지 계속 / ( ) 기간 정하기 → 시작 · 끝 날짜 칸(44). "시작일 0시부터 끝나는 날 자정까지 답합니다 (이 브라우저의 시간대 기준)." -- 제목(44, 최대 폭 520) · 내용(여러 줄, 글자만 — "글자만 보냅니다 (서식 · 그림 없음). 서명은 붙지 않습니다."). -- [저장] → "저장했습니다. 10월 30일(금)부터 11월 3일(화)까지 자동으로 답합니다." / "끌 때까지 자동으로 답합니다." / 끄면 "자동 응답을 껐습니다 — 써 둔 글은 남겨 둡니다." -- 미리 보기 "보낸 사람이 받는 답장"(보낸 사람 · 제목 · 내용) + "같은 사람에게 매번 보내지는 않습니다(메일 엔진 규칙)." +- 기간: (•) 끌 때까지 계속 / ( ) 기간 정하기 → 시작 · 끝 날짜 칸(44). "시작일 0시부터 끝나는 날 자정까지 답합니다. 시각은 이 브라우저의 시간대를 따릅니다." +- 제목(44, 최대 폭 520) · 내용(여러 줄, 글자만 — "서식과 그림 없이 글자만 보내고, 서명은 붙지 않습니다."). +- [저장] → "저장했습니다. 10월 30일(금)부터 11월 3일(화)까지 자동으로 답합니다." / "끌 때까지 자동으로 답합니다." / 끄면 "자동 응답을 껐고, 써 둔 글은 그대로 남아 있습니다." +- 미리 보기 "보낸 사람이 받는 답장"(보낸 사람 · 제목 · 내용) + "같은 사람에게는 매번 보내지 않습니다." - 켜져 있는 동안 메일 화면의 메일함 패널 아래(사용량 위)에 안내(바탕 #FFF7E6 · #7A5200, 높이 44 이상): "부재중 자동 응답 켜짐" / "11월 3일까지 · 설정" — 누르면 이 설정으로. ### 메일함 관리 (메일함 패널) @@ -304,7 +332,7 @@ API 와 서버 규칙은 계약 1절 "`PUT /api/auth/password`", 2절 "메일함 - 각 메일함 행 오른쪽에 ⋯ 단추(32×40 — 별 칸과 같은 밀도 예외). 마우스를 올리거나 초점이 가면 보이고, 메뉴가 열린 동안은 계속 보인다. 숫자(안 읽음)는 ⋯ 앞에 그대로. - ⋯ 메뉴(항목 40): 모두 읽음으로 표시(안 읽은 메일이 있을 때) · 하위 메일함 만들기(받은편지함 · 내 메일함) · 이름 바꾸기 · 지우기(빨강 — 내 메일함만) · 휴지통 비우기 / 스팸메일함 비우기(빨강 — 그 메일함에서만). -- 대화상자(공용 모양): 새 메일함 · "‘프로젝트’ 안에 새 메일함" · 메일함 이름 바꾸기 — 이름 칸(44, 64자) + 안내 "64자까지. 같은 자리에 같은 이름은 쓸 수 없습니다." +- 대화상자(공용 모양): 새 메일함 · "‘프로젝트’ 안에 새 메일함" · 메일함 이름 바꾸기 — 이름 칸(44, 64자) + 안내 "64자까지 쓸 수 있고, 같은 자리에 같은 이름은 쓸 수 없습니다." (같은 이름이면 그 자리에 빨강 "같은 이름의 메일함이 이미 있습니다."). 지우기 — "‘거래처’ 메일함을 지울까요?" "안의 메일 12통은 휴지통으로 옮깁니다. 지운 메일함은 되돌릴 수 없습니다." [지우기](빨강 채움) [취소]. 하위 메일함이 있으면 "‘프로젝트’ 메일함을 지울 수 없습니다" "하위 메일함이 있습니다. 하위 메일함을 먼저 지워 주세요." [확인]. - 휴지통 · 스팸메일함 목록 머리 오른쪽에 [휴지통 비우기] / [스팸메일함 비우기](빨강 글자 · 44). 확인 "휴지통의 메일 21통을 모두 영구 삭제할까요?" "지운 메일은 되돌릴 수 없습니다." [영구 삭제] [취소]. @@ -316,8 +344,8 @@ API 와 서버 규칙은 계약 1절 "`PUT /api/auth/password`", 2절 "메일함 - 여러 통 도구 줄: 이동 ▾ 과 삭제 사이에 「스팸 신고」(스팸메일함에서는 「스팸 아님」). - 오래 걸리는 처리(모두 읽음 · 비우기 · 메일함 지우기): 알림이 "받은편지함의 메일을 읽음으로 표시하는 중 · 600 / 1,234통"(돌아가는 아이콘)으로 남았다가 끝나면 "받은편지함의 메일 1,234통을 읽음으로 표시했습니다." 처리하는 동안 다른 일을 해도 된다. -- 알림 문구: "메일 2통을 스팸메일함으로 옮겼습니다." [되돌리기] · "메일 1통을 받은편지함으로 옮겼습니다 (스팸 아님)." [되돌리기] · - "‘협력사’ 메일함을 만들었습니다." · "‘거래처’ 메일함을 지웠습니다. 안의 메일 12통은 휴지통에 있습니다." · "휴지통을 비웠습니다 — 메일 21통." (비우기 · 지우기는 되돌리기 없음) +- 알림 문구: "메일 2통을 스팸메일함으로 옮겼습니다." [되돌리기] · "메일 1통을 스팸이 아닌 메일로 표시해 받은편지함으로 옮겼습니다." [되돌리기] · + "‘협력사’ 메일함을 만들었습니다." · "‘거래처’ 메일함을 지웠습니다. 안의 메일 12통은 휴지통에 있습니다." · "메일 21통을 지워 휴지통을 비웠습니다." (비우기 · 지우기는 되돌리기 없음) ## 5단계 — 실시간 새 메일 · 바탕화면 알림 (2026-10-28 확정 — `SettingsNotify` · `MailRealtime`) @@ -327,19 +355,19 @@ API 와 서버 규칙은 계약 2절 "실시간 알림". - 메뉴의 "메일" 묶음 끝에 「알림」(종 아이콘). - [x] 새 메일이 오면 바탕화면 알림 보내기(44). 켜면 브라우저가 허락을 묻는다. 칸 아래 안내(12.5px): - 묻기 전 "켜면 브라우저가 알림을 허락할지 한 번 묻습니다 — 「허용」을 눌러 주세요." · 허용됨 "이 브라우저에서 알림이 허용되어 있습니다."(#067647, 체크 아이콘) · - 막힘(칸을 끄고 빨강 #B42318) "브라우저가 이 사이트의 알림을 막고 있습니다. 주소창 왼쪽의 사이트 정보(자물쇠) › 알림을 「허용」으로 바꾼 뒤 이 화면을 새로 고쳐 주세요." · + 묻기 전 "켜면 브라우저가 알림을 허락할지 한 번 묻습니다. [허용]을 눌러 주세요." · 허용됨 "이 브라우저에서 알림이 허용되어 있습니다."(#067647, 체크 아이콘) · + 막힘(칸을 끄고 빨강 #B42318) "브라우저가 이 사이트의 알림을 막고 있습니다. 주소창 왼쪽의 사이트 정보(자물쇠)를 눌러 [알림]을 [허용]으로 바꾼 뒤 이 화면을 새로 고쳐 주세요." · 지원하지 않음 "이 브라우저는 바탕화면 알림을 지원하지 않습니다. 새 메일은 그래도 바로 목록에 나타납니다." -- 알림에 보일 것: [x] 메일 제목도 보이기 — "끄면 보낸 사람 이름만 보입니다 — 화면을 다른 사람과 함께 보는 자리라면 꺼 두세요." (알림을 끄면 흐리게) +- 알림에 보일 것: [x] 메일 제목도 보이기 — "끄면 보낸 사람 이름만 보입니다. 다른 사람과 화면을 함께 보는 자리라면 꺼 두는 것이 좋습니다." (알림을 끄면 흐리게) - [시험 알림 보내기](보조 단추 44 · 종 아이콘) — 알림 "ARA 웹메일" / "알림이 잘 옵니다. 새 메일이 오면 이렇게 알려 드립니다." -- 실시간 연결: ● 초록 "연결되어 있습니다 — 새 메일이 오면 바로 목록에 나타납니다." / 끊겼을 때 "연결할 수 없어 1분마다 확인합니다." (저절로 다시 잇는다) +- 실시간 연결: ● 초록 "연결되어 있어 새 메일이 오면 바로 목록에 나타납니다." / 끊겼을 때 "연결할 수 없어 1분마다 확인합니다." (저절로 다시 잇는다) - 아래 안내 상자(바탕 #F3F8F8): 이 브라우저에만 적용 · 웹메일 탭이 하나라도 열려 있어야 · 웹메일 화면을 보고 있을 때는 보내지 않음. ### 목록 위에서 한 번 권하기 - 알림을 켜지도 닫지도 않은 사람에게 메일 화면의 목록 머리 아래에 40 줄(바탕 #F3F8F8 — 새 메일 알림 줄과 같은 높이, 밀도 예외): 종 아이콘 + "바탕화면 알림을 켜 두면 다른 창을 보는 동안에도 새 메일을 알려 드립니다." [알림 켜기](강조 글자 굵게) [✕ 닫기]. -- [알림 켜기] → 브라우저가 묻는다 → 허용: 알림 "바탕화면 알림을 켰습니다." / 막힘: 오류 알림 "브라우저가 알림을 막고 있습니다. 푸는 방법은 설정 › 알림에 있습니다." [설정 열기]. +- [알림 켜기] → 브라우저가 묻는다 → 허용: 알림 "바탕화면 알림을 켰습니다." / 막힘: 오류 알림 "브라우저가 알림을 막고 있습니다. 푸는 방법은 [설정] → [알림]에서 볼 수 있습니다." [설정 열기]. - [✕]는 다시 보이지 않는다(브라우저 · 사용자마다). 브라우저가 이미 막았거나 지원하지 않으면 보이지 않는다. ### 바탕화면 알림 @@ -364,14 +392,14 @@ API 와 서버 규칙은 계약 2절 "실시간 알림". - 메뉴는 메일 묶음의 서명 · 부재중 자동 응답 · **필터** · 알림 (깔때기 아이콘). 머리는 "필터" + 설명 한 줄 + 오른쪽 [새 필터](브랜드 색). - 목록은 테두리 상자 하나에 행 60 — 순서 번호 · 켜기 스위치(36×20) · 이름(굵게)과 요약 한 줄 · ▲ ▼(32×40, 밀도 예외 — 메일함 ⋯ 와 같다). 행을 누르면 고친다. 요약은 "보낸 사람에 ‘@hanbit-partners.co.kr’ 포함 → 옮기기: 거래처" — 조건은 " 그리고 " / " 또는 ", 할 일은 " · ". 이름이 없으면 조건이 이름 자리에 온다. - 꺼 둔 규칙은 글자를 흐리게. 옮길 메일함이 지워진 규칙은 빨간 안내 한 줄 "옮길 메일함이 지워져 받은편지함으로 들어옵니다 — 눌러서 고치세요." -- 목록 아래 "필터 N개 · 50개까지 만들 수 있습니다." 와 안내 상자(차례로 적용 · 옮기면 멈춤 / 스팸 제외 / 사내 전달만 · 한 곳 · 사본 남김 / 부재중과 함께 동작). + 꺼 둔 규칙은 글자를 흐리게. 옮길 메일함이 지워진 규칙은 빨간 안내 한 줄 "옮길 메일함이 지워져 메일이 받은편지함으로 들어옵니다. 눌러서 고쳐 주세요." +- 목록 아래 "필터 N개를 만들었고, 50개까지 만들 수 있습니다." 와 안내 상자(차례로 적용 · 옮기면 멈춤 / 스팸 제외 / 사내 전달만 · 한 곳 · 사본 남김 / 부재중과 함께 동작). ### 고치기 창 (모달 620) - 이름(선택) → "어떤 메일에"(조건이 모두 · 하나라도 · 모든 메일) → 조건 줄(어디에서 116 · 포함/포함하지 않음 132 · 글 · ✕ 36, 5개까지 [조건 더하기]) → "할 일"(메일함으로 옮기기 + 메일함 고르기 · 읽음으로 표시 · 중요 표시 · 동료에게 전달 + 사내 주소) → 안내 띠 → [이 필터 지우기](고칠 때만, 왼쪽) · [저장] [취소]. -- 메일함 고르기에는 받은편지함 · 내 메일함 · 스팸메일함 · 휴지통(보낸편지함 · 임시보관함 없음 — 보관함은 구현하며 더했다, 아래 "6단계 구현하며 정한 보완"). 지워진 메일함이면 "(지워진 메일함) — 다른 메일함을 고르세요"가 골라진 채로. +- 메일함 고르기에는 받은편지함 · 내 메일함 · 스팸메일함 · 휴지통(보낸편지함 · 임시보관함 없음 — 보관함은 구현하며 더했다, 아래 "6단계 구현하며 정한 보완"). 지워진 메일함이면 "지워진 메일함이니 다른 메일함을 골라 주세요"가 골라진 채로. - 잘못은 [저장]을 누를 때 칸 아래 빨간 글로만 — 빈 조건 · `${` · 바깥 주소 전달 · 할 일 없음 (`MailFilterStates` 의 모음). ### 상태 · 들어오는 길 (`MailFilterStates`) @@ -387,21 +415,21 @@ API 와 서버 규칙은 계약 2절 "실시간 알림". ### 고치기 창 — 「받은편지함에 이미 있는 메일에도 적용」 -- 할 일 아래, 6단계의 안내 띠 자리에 회색 상자 하나: 체크 칸(이름 굵게) + 칸 아래 설명(12.5). 상자 아래 한 줄 "스팸으로 판정되어 들어오는 메일에는 적용하지 않습니다." +- 할 일 아래, 6단계의 안내 띠 자리에 회색 상자 하나: 체크 칸(이름 굵게) + 칸 아래 설명(12.5). 상자 아래 한 줄 "스팸으로 분류된 메일은 필터를 거치지 않습니다." - 창을 열 때마다 꺼져 있다 — 필터의 설정이 아니라 이번 저장에 한 번 쓴다(읽기 화면에서 만드는 새 필터도 같다). 목록의 행에는 단추를 더하지 않는다 — 있는 필터는 창을 열어 고르고 저장한다. -- 설명은 "저장한 뒤 받은편지함에서 조건에 맞는 메일을 찾아 바로 적용합니다." — 전달이 있는 필터면 " 전달은 새로 오는 메일에만 합니다."를 더한다. +- 설명은 "저장하면 받은편지함에서 조건에 맞는 메일을 찾아 바로 정리합니다." — 전달이 있는 필터면 " 전달은 새로 오는 메일에만 합니다."를 더한다. - 고를 수 없는 두 경우(칸을 끄고 이름을 흐리게, 설명이 까닭): - 전달만 하는 필터 "전달만 하는 필터는 이미 받은 메일에 할 일이 없습니다 — 전달은 새로 오는 메일에만 합니다." · - 이 탭에서 다른 필터를 적용하는 중 "다른 필터를 받은편지함에 적용하는 중입니다. 끝난 뒤에 고를 수 있습니다." + 전달만 하는 필터 "전달은 새로 오는 메일에만 해서, 전달만 하는 필터는 이미 받은 메일에 할 일이 없습니다." · + 이 탭에서 다른 필터를 적용하는 중 "다른 필터로 받은편지함을 정리하는 중입니다. 끝난 뒤에 고를 수 있습니다." - 목록 머리의 설명과 안내 상자의 "새로 오는 메일에만"을 바꾼다(`MailFilterApply` 보드의 글 — 이미 받은 메일 줄을 더하고, 전달 줄에 "새로 오는 메일만"). ### 저장한 뒤 — 진행 · 결과 알림 -- "필터를 저장했습니다." 대신 4단계의 진행 알림: "‘거래처 메일’ 필터를 받은편지함에 적용하는 중 · 4,200 / 8,412통 확인"(확인한 수 / 받은편지함 메일 수 — 최근 메일부터). - 끝나면 그 자리에서 "‘거래처 메일’ 필터를 받은편지함의 메일 37통에 적용했습니다." + 옮기는 필터면 [거래처 보기](그 메일함으로) — +- "필터를 저장했습니다." 대신 4단계의 진행 알림: "‘거래처 메일’ 필터로 받은편지함을 정리하는 중 · 4,200 / 8,412통 확인"(확인한 수 / 받은편지함 메일 수 — 최근 메일부터). + 끝나면 그 자리에서 "‘거래처 메일’ 필터로 받은편지함의 메일 37통을 정리했습니다." + 옮기는 필터면 [거래처 보기](그 메일함으로) — 읽음 · 중요만 하는 필터는 단추 없음, 맞는 메일이 없으면 "받은편지함에 ‘거래처 메일’ 필터에 맞는 메일이 없습니다." -- 멈추면 "‘거래처 메일’ 필터 — 8,412통 가운데 4,200통까지 확인하고 멈췄습니다(12통에 적용). 다시 시도해 주세요." + [다시 시도](남은 메일부터 이어서). - 처음 요청부터 실패하면 "필터는 저장했지만 받은편지함에 적용하지 못했습니다. 다시 시도해 주세요." + [다시 시도]. 다른 창 · PC 에서 적용 중이면 "다른 창에서 필터를 받은편지함에 적용하는 중입니다. 끝난 뒤 다시 시도해 주세요." +- 멈추면 "‘거래처 메일’ 필터로 8,412통 가운데 4,200통까지 확인하고 멈췄습니다. 그동안 12통을 정리했습니다. 다시 시도해 주세요." + [다시 시도](남은 메일부터 이어서). + 처음 요청부터 실패하면 "필터는 저장했지만 받은편지함은 정리하지 못했습니다. 다시 시도해 주세요." + [다시 시도]. 다른 창 · PC 에서 적용 중이면 "다른 창에서 필터로 받은편지함을 정리하는 중입니다. 끝난 뒤 다시 시도해 주세요." - 그동안 다른 화면으로 가서 메일을 읽고 써도 이어 한다. 탭을 닫으면 멈춘다(이어 하지 않는다). 세션이 바뀌면 멈추고 알림을 닫는다. ### 저장하지 않고 닫을 때 (6단계 한계를 고친다) @@ -414,7 +442,7 @@ API 와 서버 규칙은 계약 2절 "실시간 알림". ### 부재중 자동 응답 화면 — 엔진에서 규칙이 꺼졌을 때 (6단계 한계를 고친다) - 자동 응답을 켜 두었는데(저장한 값) 엔진에서 우리 규칙이 꺼져 있으면(`isActive: false`) 머리 아래 노란 줄(필터 화면의 줄과 같은 모양): - "켜 둔 자동 응답이 지금은 동작하지 않습니다. 메일 서버의 규칙이 다른 곳에서 바뀌었습니다 — 아래 [저장]을 누르면 다시 켜집니다." + "다른 곳에서 메일 설정이 바뀌어 켜 둔 자동 응답이 지금은 나가지 않습니다. 아래 [저장]을 누르면 다시 켜집니다." - 단추는 없다 — 저장 단추는 늘 누를 수 있다. 꺼 두었으면 보이지 않는다. 저장하면 필터도 함께 다시 켜진다. ## 8단계 — 주소록 (2026-10-29 확정 — `Contacts` · `ContactsEdit` · `ContactsMail`) @@ -425,31 +453,31 @@ API 와 서버 규칙은 계약 2절 "실시간 알림". ### 주소록 화면 (`Contacts`) - 자리: 레일 · 상단 바 · 모듈 패널(208) · 목록(384) · 상세 — 메일의 분할 보기와 같다. 상단 바의 찾기 칸은 **지금 보는 목록 안에서** 찾는다 - ("이름 · 주소 · 회사 · 전화번호 — 초성도 됩니다 (ㅂㅈㅎ)"). + ("이름, 주소, 회사, 전화번호, 초성(ㅂㅈㅎ)으로 찾기"). - 패널: [연락처 추가](채운 단추) → 「내 주소록」 전체 연락처(수) → 「그룹」 그룹마다 한 줄(수 · ⋯ 32×40 — 메일함 ⋯ 와 같은 밀도 예외) + [새 그룹] → 「사내」 직원(수) · 공용 주소(수). - 목록 머리: 제목 · "N명"(공용 주소는 "N개", 찾으면 "찾은 결과 N명"). 그룹이면 오른쪽에 [구성원 넣기] [그룹에 메일 쓰기]. - 사내면 머리 아래 회색 줄 — 직원 "메일 서버에 계정이 있는 직원입니다 — 이름과 주소만 보입니다." · 공용 주소 "여러 사람이 함께 받는 사내 주소입니다." + 사내면 머리 아래 회색 줄 — 직원 "회사 메일을 쓰는 직원이라 이름과 메일 주소만 보입니다." · 공용 주소 "여러 사람이 함께 받는 사내 주소입니다." - 목록 줄(56): 아바타 32(이름 첫 글자 — 공용 주소는 회색) · 이름(굵게) + 회사 · 부서(작게) / 대표 주소. 고른 줄은 청록 바탕. 이름 차례(한국어), 100명씩 받고 끝에 [더 보기]. - 상세 머리 줄: [메일 쓰기] [고치기] … [⋯] — 사내는 [메일 쓰기] [내 주소록에 추가]. 아래로 아바타 48 · 이름(20) · "회사 · 부서 · 직책"(없으면 "소속 없음", 사내는 "사내 직원" · "공용 주소"). - 상세 칸(왼쪽 이름 88): 이메일(주소마다 링크 — 누르면 그 주소로 새 메일, 옆에 종류) · 전화(번호 · 종류) · 소속 · 직책 · 그룹(누르면 그 그룹의 목록) · 메모(줄바꿈 그대로). 빈 칸은 줄째 뺀다. - 사내 상세 아래: "사내 목록은 메일 서버의 계정에서 옵니다 — 이름과 주소만 보이고 여기서 고칠 수 없습니다. 전화번호 · 메모를 적어 두려면 내 주소록에 추가하세요." -- 빈 상태: 찾은 것이 없으면 "‘…’에 맞는 연락처가 없습니다"(사내는 "주소가") + "이름 · 주소 · 회사 · 전화번호의 일부나 이름의 초성으로 찾습니다." · 고른 것이 없으면 "연락처를 고르세요". + 사내 상세 아래: "사내 목록은 이름과 주소만 보이고 여기서 고칠 수 없습니다. 전화번호나 메모를 적어 두려면 내 주소록에 추가해 주세요." +- 빈 상태: 찾은 것이 없으면 "‘…’에 맞는 연락처가 없습니다"(사내는 "주소가") + "이름, 주소, 회사, 전화번호의 일부나 이름의 초성으로 찾을 수 있습니다." · 고른 것이 없으면 "연락처를 골라 주세요". ### 연락처 추가 · 고치기 (`ContactsEdit`) - 모달 620 — 필터 고치기 창과 같은 틀이고 나가기 확인도 같다("저장하지 않은 연락처가 있습니다" / [계속 고치기] [닫기]). - 칸: 이름(100자) · 이메일(종류 회사 · 개인 · 기타 + 주소 + ✕, [이메일 더하기] 5개까지 — "받는 사람 칸에는 맨 위 주소가 먼저 나옵니다") · 전화번호(휴대전화 · 회사 · 집 · 기타 + 번호 + ✕, 5개까지) · 회사 · 부서 · 직책(한 줄에 셋) · 메모(1,000자 — 셈 "38 / 1,000") · 그룹(체크 칸). - 맨 아래 "이름이나 이메일 가운데 하나는 있어야 합니다." · [저장] [취소], 고치기만 왼쪽에 [이 연락처 지우기](빨간 글). + 맨 아래 "이름이나 이메일 가운데 하나는 넣어 주세요." · [저장] [취소], 고치기만 왼쪽에 [이 연락처 지우기](빨간 글). - 잘못은 [저장]을 누를 때 칸 아래 빨간 글로만(첫 잘못된 칸에 초점, 알림 없음) — "이메일 형식이 올바르지 않습니다." 등. - 같은 주소: 이메일 칸을 떠날 때 내 주소록에 같은 주소(다른 연락처)가 있으면 칸 아래 갈색 글 - "이미 주소록에 있는 주소입니다 — 강서윤(거래처). 그래도 저장할 수 있습니다. [그 연락처 보기]" — 막지 않는다. + "주소록에 있는 강서윤(거래처)의 주소와 같습니다. 그래도 저장할 수 있습니다. [그 연락처 보기]" — 막지 않는다. - 지우기는 확인 없이: 창을 닫고 "‘박지훈’ 연락처를 지웠습니다." + [되돌리기](10초). ### 그룹 (`ContactsEdit`) - 새 그룹 · 이름 바꾸기: 모달 400, 칸 하나(50자) · [만들기] / [저장] [취소]. 같은 이름이면 칸 아래 "같은 이름의 그룹이 있습니다. 다른 이름을 써 주세요."(`CONTACT_001` 사전 문구 — 알림 없이. 보드는 앞 문장만). 그룹은 100개까지. -- 그룹 ⋯: 이름 바꾸기 · 그룹에 메일 쓰기 · 그룹 지우기(빨간 글). 지우기는 확인 없이 "‘거래처’ 그룹을 지웠습니다 — 연락처 4명은 그대로 있습니다." + [되돌리기]. +- 그룹 ⋯: 이름 바꾸기 · 그룹에 메일 쓰기 · 그룹 지우기(빨간 글). 지우기는 확인 없이 "‘거래처’ 그룹을 지웠습니다. 그룹에 있던 연락처 4명은 그대로 남아 있습니다." + [되돌리기]. - 연락처 ⋯(상세): 주소 복사 · 연락처 지우기. - 구성원 넣기: 모달 620 — 「내 연락처 | 사내 직원」 전환(36 — `.segmented` 밀도 예외) + 찾기(초성), 체크 줄 48(이름 · 회사 · 주소). 이미 구성원인 연락처는 보이지 않는다. "N명 고름" · [넣기] [취소]. 사내 직원을 고르면 내 주소록에도 이름 · 주소로 추가한다(같은 주소의 연락처가 이미 있으면 그 연락처를 넣는다). 끝나면 "2명을 ‘거래처’ 그룹에 넣었습니다." @@ -458,17 +486,17 @@ API 와 서버 규칙은 계약 2절 "실시간 알림". ### 상태 (`ContactsEdit`) -- 처음(연락처 없음): "아직 연락처가 없습니다" · "자주 메일을 주고받는 사람을 넣어 두면 받는 사람 칸에서 바로 찾습니다." · [연락처 추가] · - "메일을 읽다가 ⋯ › 「보낸 사람을 주소록에 추가」로도 넣을 수 있습니다." +- 처음(연락처 없음): "아직 연락처가 없습니다" · "자주 메일을 주고받는 사람을 넣어 두면 받는 사람 칸에서 바로 찾을 수 있습니다." · [연락처 추가] · + "메일을 읽다가 [⋯] → [보낸 사람을 주소록에 추가]로도 넣을 수 있습니다." - 사내 목록을 받지 못함: "사내 목록을 불러오지 못했습니다" · "잠시 후 다시 시도해 주세요. 내 주소록은 그대로 쓸 수 있습니다." · [다시 시도]. - 한도: 연락처가 3,000개면 [연락처 추가]를 끄고(`aria-disabled`) 아래에 "연락처는 3,000개까지 둘 수 있습니다. 쓰지 않는 연락처를 지운 뒤 추가해 주세요." -- 알림: "연락처를 저장했습니다." · "‘…’ 연락처를 지웠습니다." · "‘…’ 그룹을 지웠습니다 — 연락처 N명은 그대로 있습니다." · "N명을 ‘…’ 그룹에 넣었습니다." (단추가 있으면 10초) +- 알림: "연락처를 저장했습니다." · "‘…’ 연락처를 지웠습니다." · "‘…’ 그룹을 지웠습니다. 그룹에 있던 연락처 N명은 그대로 남아 있습니다." · "N명을 ‘…’ 그룹에 넣었습니다." (단추가 있으면 10초) ### 메일과 함께 (`ContactsMail`) - **받는 사람 추천**: 묶음은 주소록 → 그룹 → 사내 → 최근 주고받은 주소(모두 합쳐 최대 8개). 주소록은 주소마다 한 줄 — 대표 주소는 "주소 · 회사", 그 밖의 주소는 "주소 · 개인"처럼 종류. 사내는 이제 로그인한 적 있는 직원이 아니라 메일 서버에 계정이 있는 직원 · 공용 주소 모두다. 여러 묶음에 있는 주소는 앞 묶음에 한 번만. -- **그룹 줄**: 아이콘 + 그룹 이름(맞는 글자 굵게) + "N명 · 고르면 한꺼번에 넣습니다". 고르면 구성원의 대표 주소를 칩으로 넣고 칸 아래 "‘거래처’ 그룹의 4명을 받는 사람에 넣었습니다." +- **그룹 줄**: 아이콘 + 그룹 이름(맞는 글자 굵게) + "고르면 N명을 한꺼번에 넣습니다". 고르면 구성원의 대표 주소를 칩으로 넣고 칸 아래 "‘거래처’ 그룹의 4명을 받는 사람에 넣었습니다." 이메일이 없는 구성원 · 이미 넣은 주소는 빼고, 뺀 것이 있으면 "주소가 없는 1명은 뺐습니다"를 덧붙인다. - **주소록에서 메일 쓰기**: 연락처 · 그룹 · 사내의 [메일 쓰기]와 주소 링크 → 새 쓰기 화면, 받는 사람을 채워서(그룹은 구성원 모두 — 받는 사람 칸). 쓰다 만 메일은 임시 저장 규칙대로 남는다. - **읽기 화면 ⋯**: 둘째 묶음(「이런 메일로 필터 만들기」 위)에 「보낸 사람을 주소록에 추가」 → 이름 · 주소를 채운 연락처 추가 창. 이미 내 주소록에 있으면 「주소록에서 보기」(그 연락처를 고른 주소록). @@ -485,9 +513,9 @@ API 와 서버 규칙은 계약 2절 "실시간 알림". - **설정에 따라**(설정 › 메일 › 보내기 · 읽기 › 외부 이미지): 늘 묻기(처음 값) · 주소록과 사내 직원은 늘 보기 · 늘 보기. 처음부터 보이면 안내 줄이 없다. **스팸메일함의 메일은 늘 묻는다.** - **⋯ 메뉴**: 「인쇄」(Ctrl+P 표시) → 「원문 내려받기 (.eml)」 → 구분선 → 「보낸 사람을 주소록에 추가」/「주소록에서 보기」 → 「이런 메일로 필터 만들기」 → 「보낸 사람 차단」(빨간 글). 이미 차단한 보낸 사람이면 마지막 항목이 「보낸 사람 차단 풀기」. 사내 직원 · 내 주소에는 차단 항목이 없다. 차단한 주소가 500개면 항목을 끄고 이름 아래에 까닭. -- **차단 확인 창**(모달 400): 제목 "보낸 사람 차단", 설명 "앞으로 오는 메일을 스팸메일함으로 보냅니다. 부재중 자동 응답도 보내지 않습니다." +- **차단 확인 창**(모달 400): 제목 "보낸 사람 차단", 설명 "앞으로 오는 메일은 스팸메일함으로 옮겨지고, 부재중 자동 응답도 보내지 않습니다." 고르기 둘 — 「이 주소만」(주소) · 「이 도메인 전체」(@도메인) — 여러 사람이 쓰는 메일 서비스(gmail.com · naver.com 등)는 도메인을 고를 수 없게 끄고 그 아래에 까닭. - 체크 「이 메일도 스팸메일함으로 옮기기」(처음 켜짐), 안내 "차단한 주소는 설정 › 메일 › 차단한 주소에서 풀 수 있습니다." — [차단](처음 초점 — 되돌릴 수 있는 일) [취소]. + 체크 「이 메일도 스팸메일함으로 옮기기」(처음 켜짐), 안내 "차단은 [설정] → [차단한 주소]에서 풀 수 있습니다." — [차단](처음 초점 — 되돌릴 수 있는 일) [취소]. - **차단한 뒤**: 알림 "‘주소’ 을 차단하고 이 메일을 스팸메일함으로 옮겼습니다." + [되돌리기](차단을 풀고 메일을 받은편지함으로). 스팸메일함에서 차단한 보낸 사람의 메일을 열면 회색 안내 줄 "차단한 보낸 사람의 메일입니다." + [차단 풀기]. @@ -506,10 +534,10 @@ API 와 서버 규칙은 계약 2절 "실시간 알림". - **[보내기 ▾]**: 쓰기 화면의 [보내기]를 둘로 나눈다 — 왼쪽은 지금의 [보내기], 오른쪽 ▾(44 — 보드는 40, 밀도 예외를 새로 만들지 않는다)는 메뉴 「예약 발송…」(시계 아이콘). 보내기 단축키는 없다(쓰기 화면에 Ctrl+Enter 묶음이 없다 — 2026-10-30 확인). 보내는 중 · 저장 중 · 첨부를 올리는 중에는 두 단추를 함께 끈다(지금의 [보내기]와 같은 규칙). - **예약 창**(모달 400): 제목 "예약 발송". 고르기 넷 — 「내일 오전」 9:00 · 「내일 오후」 1:00 · 「다음 주 월요일」 9:00 · 「직접 고르기」(날짜 칸 + 30분 단위 시각) — 줄마다 오른쪽에 날짜 · 요일 · 시각. - 오늘 오후가 남았으면 「오늘 오후」 6:00 이 첫 줄. 안내 "이 PC 의 시간(한국 표준시)으로 보냅니다 · 7일 안에서 고를 수 있습니다." [예약] [취소]. - 칸 아래 오류: "지금부터 5분 뒤 이후의 시각을 고르세요." · "7일 안의 시각만 고를 수 있습니다." 받는 사람 확인은 지금처럼 보내는 순간(없는 주소면 받는 사람 칸의 띠). + 오늘 오후가 남았으면 「오늘 오후」 6:00 이 첫 줄. 안내 "이 PC의 시간(한국 표준시)으로 보내고, 7일 안에서 고를 수 있습니다." [예약] [취소]. + 칸 아래 오류: "5분 뒤보다 이른 시각은 고를 수 없습니다. 더 늦은 시각을 골라 주세요." · "7일 안의 시각만 고를 수 있습니다." 받는 사람 확인은 지금처럼 보내는 순간(없는 주소면 받는 사람 칸의 띠). - **보낸 뒤 알림**(오른쪽 아래): "메일을 보냈습니다." + [보내기 취소] + 아래 남은 시간 막대(설정한 초 — 마우스를 올려도 멈추지 않는다) → 시간이 지나면 저절로 닫힌다. - 일부가 받지 못하면 빨간 알림 "보냈지만 1명은 받지 못합니다 — 주소 (까닭)" + [보내기 취소](주소를 고쳐 다시 보낼 수 있게). + 일부가 받지 못하면 빨간 알림 "보냈지만 1명은 받지 못합니다. 주소 (까닭)" + [보내기 취소](주소를 고쳐 다시 보낼 수 있게). [보내기 취소] → "보내기를 취소했습니다. 쓰던 메일로 돌아왔습니다." + 그 메일로 쓰기 화면(받는 사람 · 제목 · 본문 · 첨부 그대로). 늦게 누르면 "이미 보낸 메일이라 취소할 수 없습니다." 예약 → "10월 30일 (금) 오전 9:00 에 보냅니다." + [예약됨 보기]. 로그아웃 · 다른 계정 로그인 때 알림은 닫는다 — 메일은 그대로 보내진다. - **「예약됨」**: 메일함 패널의 보낸편지함 아래(예약한 메일이 있을 때만, 시계 아이콘, 오른쪽에 통 수). 목록은 받는 사람 · 제목 · 보낼 시각(청록 글 — "10월 30일 (금) 오전 9:00") 차례, @@ -522,14 +550,14 @@ API 와 서버 규칙은 계약 2절 "실시간 알림". - **메뉴**: 메일 묶음에 「보내기 · 읽기」(필터 아래) · 「차단한 주소」를 더한다 — 서명 · 부재중 자동 응답 · 필터 · 보내기 · 읽기 · 차단한 주소 · 알림. - **보내기 · 읽기**: 고르는 대로 저장한다(저장 단추 없음 — 머리 오른쪽에 "저장했습니다"). 서버에 저장되어 어느 PC 에서나 같다. - - 「보내기 취소」 — 눌림 전환(36 — `.segmented`) 끔 · 5초 · 10초(처음) · 20초 · 30초 + "[보내기]를 누른 뒤 이 시간 동안 [보내기 취소]로 되돌릴 수 있습니다. 그동안 메일은 메일 서버에서 기다리므로 창을 닫아도 보내집니다." - - 「외부 이미지」 — 라디오 셋(늘 묻기 · 주소록 · 사내 직원의 메일은 늘 보기 · 늘 보기, 줄마다 아래 설명) + 회색 상자 - "외부 이미지는 사내 메일 서버가 대신 받아 보여 줍니다 — 보낸 사람에게 이 PC 의 주소는 드러나지 않습니다. 다만 이미지를 받으면 보낸 사람은 메일을 열어 본 것을 알 수 있습니다." -- **차단한 주소**: 머리 "차단한 주소" + "3 / 500" + 설명 "이 주소에서 오는 메일은 스팸메일함으로 갑니다. 부재중 자동 응답도 보내지 않습니다. 「@도메인」은 그 도메인에서 오는 메일 모두입니다." + - 「보내기 취소」 — 눌림 전환(36 — `.segmented`) 끔 · 5초 · 10초(처음) · 20초 · 30초 + "[보내기]를 누른 뒤 이 시간 동안 [보내기 취소]로 되돌릴 수 있습니다. 그사이 창을 닫아도 시간이 지나면 메일은 그대로 보내집니다." + - 「외부 이미지」 — 라디오 셋(늘 묻기 · 주소록과 사내 직원의 메일은 늘 보기 · 늘 보기, 줄마다 아래 설명) + 회색 상자 + "외부 이미지는 회사를 거쳐 불러오므로 보낸 사람은 이 PC의 주소를 알 수 없습니다. 다만 이미지를 불러오면 보낸 사람은 메일을 열어 본 것을 알 수 있습니다." +- **차단한 주소**: 머리 "차단한 주소" + "3 / 500" + 설명 "차단한 주소에서 온 메일은 스팸메일함으로 옮겨지고, 부재중 자동 응답도 보내지 않습니다. @spam.example 처럼 @로 시작해 넣으면 그 도메인에서 오는 메일을 모두 막습니다." - 더하기 줄: 칸 "차단할 주소"(placeholder "kim@example.com 또는 @example.com") + [차단]. 칸 아래 오류: "메일 주소나 @도메인 형식으로 써 주세요." · "이미 차단한 주소입니다." · - "내 주소 · 사내 도메인은 차단할 수 없습니다." · "gmail.com 처럼 여러 사람이 쓰는 메일 서비스는 도메인으로 막을 수 없습니다 — 주소로 막아 주세요." · "500개까지 차단할 수 있습니다." + "내 주소와 사내 도메인은 차단할 수 없습니다." · "gmail.com처럼 여러 사람이 쓰는 메일 서비스는 도메인 전체를 막을 수 없습니다. 주소를 하나씩 넣어 막아 주세요." · "500개까지 차단할 수 있습니다." - 목록: 줄마다 주소(굵게) + 아래 작은 글 "10월 29일 차단"(도메인은 "도메인 전체 · …") + [차단 풀기](36). 누르면 바로 풀고 알림 "‘주소’ 차단을 풀었습니다." + [되돌리기]. - 이미 받은 메일은 옮기지 않는다. 비었으면 "차단한 주소가 없습니다. 메일의 ⋯ › 보낸 사람 차단으로도 막을 수 있습니다." + 이미 받은 메일은 옮기지 않는다. 비었으면 "차단한 주소가 없습니다. 메일을 읽다가 [⋯] → [보낸 사람 차단]으로도 막을 수 있습니다." ## 11단계 — 대용량 첨부 (2026-10-30 확정 — `ComposeLargeFiles` · `LargeFileBlock` · `MailReadLargeFiles` · `SharePage` · `SettingsLargeFiles`) @@ -546,8 +574,8 @@ API 와 서버 규칙은 계약 2절 "실시간 알림". 묻는 창은 없다 — 합계 때문에 바뀌면 칸 아래 ⓘ 한 줄 "‘회의자료_전체.pptx’(9.4 MB)는 메일에 담으면 합계가 25MB 를 넘어 링크로 보냅니다."(role=status). 받는 사람이 사내뿐이어도 같은 규칙이다. 메일에 담은 파일도 줄의 링크 단추(40 · 「링크로 보내기」 — 마우스를 올리면 같은 글)로 바꿀 수 있다 — **이 쓰기 화면에서 고른 파일에만** 있다(전달 원본 · 저장본에서 이어 온 첨부에는 없다 — 처음 판). 링크 → 메일에 담기 단추는 없다(빼고 다시 고르면 한도 안에서 담긴다 — 아래 "처음 판에서 미룬 것"). -- **안내문**: "파일당 최대 20MB · 합계 25MB · 최대 20개 · 더 큰 파일은 링크로 보냅니다 (파일당 2GB · 10개까지)". -- **두 묶음**: 링크 파일이 있으면 칸을 「메일에 담는 파일 N개」(+ "합계 18.1 MB / 25 MB") · 「링크로 보내는 파일 N개」(+ "받는 사람은 보낸 날부터 30일 동안 내려받습니다 — 오늘 보내면 11월 29일(일)까지")로 나눈다(12.5 굵게 + 12 회색). +- **안내문**: "파일당 최대 20MB · 합계 25MB · 최대 20개 · 더 큰 파일은 파일당 2GB, 10개까지 링크로 보냅니다". +- **두 묶음**: 링크 파일이 있으면 칸을 「메일에 담는 파일 N개」(+ "합계 18.1 MB / 25 MB") · 「링크로 보내는 파일 N개」(+ "받는 사람은 보낸 날부터 30일 동안 내려받을 수 있습니다. 오늘 보내면 11월 29일(일)까지입니다.")로 나눈다(12.5 굵게 + 12 회색). 일반 첨부만 있으면 지금처럼 묶음 이름 없이 — 링크 파일만 있어도 「링크로 보내는 파일 N개」 머리는 둔다(기간 안내 · 보관 경고의 자리, 보드대로). 보관 사용량이 80%를 넘으면 링크 묶음 머리 오른쪽에 "보관 8.4 / 10 GB"(갈색 굵게). - **링크 파일 줄**: 아이콘이 링크(청록 #0A5F67), 두 줄 — 이름 · 단추(40) / 크기 · 상태(12px, 아이콘 선만큼 26px 들여), 올리는 동안 3px 막대. ✕ 는 「빼기」 — 올리던 것을 멈추고 서버의 조각도 지운다. @@ -556,34 +584,35 @@ API 와 서버 규칙은 계약 2절 "실시간 알림". | 올리는 중 | "올리는 중 · 420 MB / 1.2 GB" + 오른쪽 "약 3분 남음"(처음 몇 초는 "시간 계산 중") | [멈춤] ✕ · 청록 막대 | | 멈춤 | "멈춤 · 420 MB / 1.2 GB 올림" | [이어 올리기] ✕ · 회색 막대 — 그 파일만 멈춘다 | | 잠깐 끊김 | "연결이 끊겨 다시 잇는 중… · 420 MB / 1.2 GB 올림"(갈색) | 누를 것 없음 — 2 · 5 · 15 · 30초 간격으로 저절로, 받은 조각은 그대로 | - | 오래 끊김 | "올리지 못하고 멈췄습니다 — 올린 420 MB 는 남아 있습니다."(빨강) | 빨간 줄 · [다시 시도](40) ✕ — 저절로 다시 잇기를 1분 넘게 못 했을 때 | - | 다 올림 · 검사 중 | "612 MB · 보안 검사 중 — 끝나지 않아도 보낼 수 있습니다" | ✕ | - | 검사 끝 | 깨끗하면 크기만 "1.2 GB" · 못 했으면 "860 MB · 검사하지 못함 — 암호가 걸린 압축 파일 등"(까닭은 응답에 없다 — 암호 압축 · 한도 밖 · 검사기 없음을 함께 쓴다) | ✕ | + | 오래 끊김 | "올리지 못하고 멈췄습니다. 지금까지 올린 420 MB는 남아 있습니다."(빨강) | 빨간 줄 · [다시 시도](40) ✕ — 저절로 다시 잇기를 1분 넘게 못 했을 때 | + | 다 올림 · 검사 중 | "612 MB · 보안 검사 중이지만 보낼 수 있습니다" | ✕ | + | 검사 끝 | 깨끗하면 크기만 "1.2 GB" · 못 했으면 "860 MB · 암호가 걸린 압축 파일처럼 검사하지 못한 파일"(까닭은 응답에 없다 — 암호 압축 · 한도 밖 · 검사기 없음을 함께 쓴다) | ✕ | | 위험 | "위험한 내용이 발견되어 보낼 수 없습니다. 이 파일을 빼 주세요."(경고 삼각형) | 빨간 줄 · ✕ | - **올리기 전 검사**(지금 첨부 사전 검사와 같은 결 — 넘으면 올리지 않고 빨간 줄, 그 파일만 빠지고 보내기는 막지 않는다): "파일 하나는 2GB 까지 보낼 수 있습니다." · "링크로 보내는 파일은 메일 한 통에 10개까지입니다." · - "대용량 보관 한도(10GB)를 넘어 올릴 수 없습니다. 보내지 않은 파일을 지우거나 링크를 끊으면 바로 공간이 생깁니다. [대용량 첨부 보기]" · + "대용량 첨부는 10GB까지 보관할 수 있어 더 올릴 수 없습니다. 보내지 않은 파일을 지우거나 링크를 끊으면 바로 공간이 생깁니다. [대용량 첨부 보기]" · "지금은 서버에 공간이 모자라 올릴 수 없습니다. 잠시 후 다시 시도해 주세요." · 형식은 `FILE_001` 사전 문구. 링크로 보낼 수 있는 형식 = 일반 첨부 목록 + mp4 · mov · dwg · dxf · psd · ai · 7z. 실행 파일(exe · msi · bat · cmd · js · vbs · scr 등)과 디스크 이미지(iso · img · vhd)는 막는다. 파일 고르기 창은 두 목록을 합친 형식만. -- **다시 열 때**: 브라우저는 고른 파일을 스스로 다시 읽지 못한다. 새로고침 · 창을 닫은 뒤 저장본을 열면 노란 줄 "올리다 멈춘 파일 — 같은 파일을 다시 고르면 이어서 올립니다 · 420 MB / 1.2 GB 올림" + [파일 고르기](40) ✕. - 다른 파일을 고르면 그 줄 안에 "고른 파일이 멈춘 파일과 다릅니다(이름 · 크기). 같은 파일을 골라 주세요."(이름 · 크기 · 고친 시각으로 맞춘다 — 서버는 마지막 조각 뒤 24시간 기다린다). - 다 올린 파일은 저장본에 붙어 "1.2 GB · 이 임시 저장본에 11월 29일까지 남습니다"(올린 날부터 30일), 지나면 빨간 줄 "기간이 지나 지운 파일입니다 — 빼고 다시 올려 주세요." - 설정 › 대용량 첨부에서 지웠으면 같은 모양에 글만 "설정 › 대용량 첨부에서 지운 파일입니다 — 빼고 다시 올려 주세요." +- **다시 열 때**: 브라우저는 고른 파일을 스스로 다시 읽지 못한다. 새로고침 · 창을 닫은 뒤 저장본을 열면 노란 줄 "올리다 멈춤 · 420 MB / 1.2 GB 올림 · 같은 파일을 다시 고르면 이어서 올립니다" + [파일 고르기](40) ✕. + 다른 파일을 고르면 그 줄 안에 "고른 파일의 이름이나 크기가 멈춘 파일과 다릅니다. 같은 파일을 골라 주세요."(이름 · 크기 · 고친 시각으로 맞춘다 — 서버는 마지막 조각 뒤 24시간 기다린다). + 다 올린 파일은 저장본에 붙어 "1.2 GB · 이 임시 저장본에 11월 29일까지 남습니다"(올린 날부터 30일), 지나면 빨간 줄 "기간이 지나 지워진 파일입니다. 빼고 다시 올려 주세요." + 설정 › 대용량 첨부에서 지웠으면 같은 모양에 글만 "[설정] → [대용량 첨부]에서 지운 파일입니다. 빼고 다시 올려 주세요." - **다 올릴 때까지 보내기를 막는다**: [보내기]와 ▾ 를 `disabled` 가 아니라 `aria-disabled` 로 끄고(초점은 받는다) [임시 저장] 오른쪽에 까닭 글(ⓘ 12.5 — 두 단추의 `aria-describedby`). 누르면 보내지 않고 까닭을 읽어 준다(role=status — 보내기 단축키는 앱에 없다). 「예약 발송…」도 같다. 임시 저장은 그대로 된다. 까닭 글 — - 올리는 중 · 다시 잇는 중 "파일 2개를 올리는 중입니다 — 다 올리면 보낼 수 있습니다." · - 멈춤 · 오래 끊김 · 다시 열어 멈춘 파일 "다 올리지 못한 파일이 있습니다 — 이어 올리거나 빼 주세요." · - 위험 · 지운 파일(기간 지남 · 설정에서 지움) "보낼 수 없는 파일이 있습니다 — 빨간 줄의 파일을 빼 주세요." + 올리는 중 · 다시 잇는 중 "파일 2개를 올리는 중입니다. 다 올린 뒤에 보낼 수 있습니다." · + 멈춤 · 오래 끊김 · 다시 열어 멈춘 파일 "다 올리지 못한 파일이 있습니다. 이어 올리거나 빼 주세요." · + 위험 · 지운 파일(기간 지남 · 설정에서 지움) "보낼 수 없는 파일이 있습니다. 빨간 줄의 파일을 빼 주세요." - **나가기 확인**: 올리는 중이면 "올리는 중인 파일이 있습니다" / "이 화면을 나가면 올리기가 멈춥니다. 임시보관함에서 이 메일을 다시 열고 같은 파일을 고르면 이어서 올립니다." [계속 쓰기](처음 초점 · Esc) [나가기]. 떠나기 전 임시 저장은 지금처럼 먼저 하고, 올리는 중인 파일은 저장본에 "올리다 멈춘 파일"로 남긴다. 다 올린 뒤에는 이 확인이 없다. ### 본문 덩이 — 바깥 메일 프로그램에서 (`LargeFileBlock`) - **자리**: 내 글 · 서명 아래, 인용 위(쓰기 화면이 알려 준 경계). 인용이 없으면 맨 끝. 서버가 정화 · 서명 그림 · 중복 확인을 마친 뒤 넣는다 — 쓰기 화면의 편집기에는 보이지 않는다. -- **내용**(위에서부터): "대용량 첨부 2개 (합계 1.8 GB)"(14 굵게) → "2026년 11월 29일(일)까지 내려받을 수 있습니다 (한국 시간)" → 파일마다 이름 · 크기(회색, 오른쪽) → +- **내용**(위에서부터): "대용량 첨부 2개 (합계 1.8 GB)"(14 굵게) → "한국 시간으로 2026년 11월 29일(일)까지 내려받을 수 있습니다." → 파일마다 이름 · 크기(회색, 오른쪽) → "내려받기 https://files.arasystem.it.kr/s/…"(주소를 글자 그대로 보이는 링크 — 단축 주소 · 단추 모양 없음. 파일 이름은 링크가 아니다 — 메일 한 통에 링크 하나) → - "내려받을 때 로그인이나 비밀번호를 묻지 않습니다."(12 회색) → "Large files: download by Nov 29, 2026 (KST) — no login or password needed."(12 회색). + "내려받을 때 로그인이나 비밀번호를 묻지 않습니다."(12 회색) → "Large files: download by Nov 29, 2026 (KST). No login or password needed."(12 회색). + (2026-11-05 날짜 줄 · 영어 줄의 말투를 고쳤다 — 그 전에 붙잡아 둔 발송을 보내기 취소로 되돌려도 텍스트 덩이를 떼도록 서버가 예전 글도 함께 찾는다.) - **모양**: 테두리 1px #D0D5DD 표 + 글자, 안 여백 12 · 16, 맑은 고딕 14 · 13 · 12. 보내는 서식의 허용 목록 안에서만(table · td colspan · a, 테두리 · 여백 · 색 · 크기 · 굵기 · 정렬 · 줄바꿈 막기) — 둥근 모서리 · 그림 · 아이콘 · 뱃지 없음. 폭은 내용에 맞춘다(주소 줄이 가장 길다 — 좁은 화면에서는 받는 프로그램이 줄을 끊는다). - **텍스트 대체 본문**: 같은 자리에 대시 줄로 가른 같은 내용 — 파일은 "- 이름 (크기)", 주소는 "내려받기: 주소". @@ -597,19 +626,19 @@ API 와 서버 규칙은 계약 2절 "실시간 알림". - **첨부처럼 올리는 규칙**: 주소가 우리 내려받기 주소이고 서버에 있는 링크이며, **링크 주인이 그 메일의 보낸 사람과 같을 때만** 첨부 칸으로 올린다 — 바깥 사람 답장의 인용 속 링크 · 전달된 원래 링크는 본문 글이다. - **사내 받는 사람**: 링크 파일을 첨부 칸에 일반 첨부와 함께 보인다 — 이름 아래 "1.2 GB · 11월 29일까지". 아이콘 · [다운로드](44)는 일반 첨부와 같고, 내려받기 화면을 거치지 않고 바로 받는다. 「첨부 파일 N개」는 둘을 합한 수. -- **본문의 덩이는 접는다**: 그 자리에 접기 줄(높이 40 · 12.5 · 바탕 #F6F7F9) "본문의 대용량 첨부 안내 (접어 둠) — 파일은 아래 첨부 칸에 있습니다" + "펼치기". +- **본문의 덩이는 접는다**: 그 자리에 접기 줄(높이 40 · 12.5 · 바탕 #F6F7F9) "본문의 대용량 첨부 안내" + "파일은 아래 첨부 칸에 있어 접어 두었습니다" + "펼치기". 본문 틀 안에서 스크립트 없이 열고 닫는다. 메일을 다시 열면 접혀 있고, 인쇄 화면은 펼친 채 인쇄한다. - **보낸 사람 자신**: 파일마다 "1.2 GB · 3회 받음 · 11월 29일까지"(받지 않았으면 "아직 받지 않음"), 첨부 칸 아래 한 줄 - "링크로 보낸 파일 2개 — 받는 사람 모두 같은 링크로 받습니다. 끊으면 둘 다 받을 수 없습니다." + [링크 복사] [링크 끊기](36 — 차단한 주소의 [차단 풀기]와 같은 밀도 예외, [링크 끊기]는 빨간 글 테두리 단추). + "링크로 보낸 파일 2개는 받는 사람 모두 같은 링크로 받습니다. 끊으면 둘 다 받을 수 없습니다." + [링크 복사] [링크 끊기](36 — 차단한 주소의 [차단 풀기]와 같은 밀도 예외, [링크 끊기]는 빨간 글 테두리 단추). [링크 복사]는 이 메일 본문의 덩이에서 주소를 읽어 복사한다 — "링크를 복사했습니다."(못 하면 "링크를 복사하지 못했습니다."). 설정 목록에는 없다(서버는 링크를 해시로만 안다). 받은 수는 처음부터 받기 시작한 수뿐(화면 열기 · 이어 받기 · 내가 받은 것은 빼고)이고, 누가 받았는지는 남기지 않는다. -- **링크 끊기 확인**(alertdialog 400): "대용량 첨부 링크를 끊을까요?" / "받는 사람 모두 이 메일의 파일 2개(설계도_v3.dwg · 현장영상_1029.mp4)를 더는 내려받을 수 없습니다. 이미 받은 파일은 되돌릴 수 없고, 끊은 링크는 다시 열 수 없습니다." +- **링크 끊기 확인**(alertdialog 400): "대용량 첨부 링크를 끊을까요?" / "받는 사람 모두 이 메일의 파일 2개(설계도_v3.dwg, 현장영상_1029.mp4)를 더는 내려받을 수 없습니다. 이미 받은 파일은 되돌릴 수 없고, 끊은 링크는 다시 열 수 없습니다." [링크 끊기](빨강) [취소](처음 초점 · Esc). 끊으면 "링크를 끊었습니다. 받는 사람은 더 이상 내려받을 수 없습니다." — 되돌리기는 없다. 쓰던 보관 공간은 바로 비워진다. -- **기간 지남 · 공유 멈춤**: 흐리게(이름 #667085 · 아이콘 #98A2B3 — 투명도가 아니라 글자색) + "기간 지남 — 11월 29일까지였습니다" · "공유 멈춤 — 보낸 사람이 링크를 끊었습니다", 내려받기 단추 없음. - 보낸 사람 자신에게는 받은 수가 남고 끊은 날이 보인다 — "1.2 GB · 3회 받음 · 공유 멈춤 — 10월 31일에 끊었습니다"(한국 날짜 · 응답의 `revokedAt`, 없으면 "공유 멈춤 — 링크를 끊었습니다"). +- **기간 지남 · 공유 멈춤**: 흐리게(이름 #667085 · 아이콘 #98A2B3 — 투명도가 아니라 글자색) + "받을 수 있는 기간이 11월 29일에 끝났습니다" · "보낸 사람이 링크를 끊어 받을 수 없습니다", 내려받기 단추 없음. + 보낸 사람 자신에게는 받은 수가 남고 끊은 날이 보인다 — "1.2 GB · 3회 받음 · 10월 31일에 링크를 끊었습니다"(한국 날짜 · 응답의 `revokedAt`, 없으면 "링크를 끊었습니다"). 끝난 뒤 90일이 지나면 첨부 칸에서 빠지고 덩이는 그냥 본문이 된다. -- **보안 검사**: 검사 중이면 [다운로드]를 `aria-disabled`(title · 까닭 연결) + "보안 검사 중 — 끝나면 받을 수 있습니다". 위험은 빨간 줄 "위험 — 보안 검사에서 막았습니다. 받을 수 없습니다"(단추 없음). 검사하지 못한 파일은 받을 수 있고 글로만 알린다. -- **전달 쓰기 화면**: 원래 메일의 덩이는 인용 속 글(링크)로 그대로 간다 — 첨부 칸에 다시 싣지 않고 칸 아래 ⓘ 한 줄로만 알린다 "원래 메일의 대용량 첨부 2개는 인용 속 링크로 함께 갑니다 — 11월 29일까지". +- **보안 검사**: 검사 중이면 [다운로드]를 `aria-disabled`(title · 까닭 연결) + "보안 검사가 끝나면 받을 수 있습니다". 위험은 빨간 줄 "위험한 내용이 있어 보안 검사에서 막았고, 받을 수 없습니다"(단추 없음). 검사하지 못한 파일은 받을 수 있고 글로만 알린다. +- **전달 쓰기 화면**: 원래 메일의 덩이는 인용 속 글(링크)로 그대로 간다 — 첨부 칸에 다시 싣지 않고 칸 아래 ⓘ 한 줄로만 알린다 "원래 메일의 대용량 첨부 2개는 인용 속 링크로 함께 갑니다. 11월 29일까지 받을 수 있습니다.". 공유가 멈췄거나 기간이 지난 링크면 갈색 한 줄 "인용 속 ‘도면_초안.dwg’ 링크는 공유가 멈춰 받을 수 없습니다."("… 기간이 지나 받을 수 없습니다.", 파일이 여럿이면 "‘설계도_v3.dwg’ 외 1개") — 보내기는 막지 않는다. 전달하는 사람이 새 큰 파일을 더하면 그것은 이 메일의 새 링크다(첨부 칸의 「링크로 보내는 파일」 — 전달하는 사람의 보관 한도에 잡힌다). 받는 쪽에서 인용 속 원래 링크는 본문 글이고, 내려받기 화면의 보낸 사람 · 끊을 수 있는 사람은 처음 보낸 사람(링크 주인)이다. 답장도 같다. @@ -617,17 +646,17 @@ API 와 서버 규칙은 계약 2절 "실시간 알림". ### 내려받기 화면 (`SharePage`) -- **독립 화면**: 앱 레일 · 상단 바 · 로그인 메뉴가 없다. 머리 바(#0B3640, 60)에 흰 ARA SYSTEM 로고(88) + "파일 내려받기", 바탕 #F6F7F9 에 가운데 열 640. 탭 제목 "김아라 님이 보낸 파일 — ARA SYSTEM". +- **독립 화면**: 앱 레일 · 상단 바 · 로그인 메뉴가 없다. 머리 바(#0B3640, 60)에 흰 ARA SYSTEM 로고(88) + "파일 내려받기", 바탕 #F6F7F9 에 가운데 열 640. 탭 제목 "김아라 님이 보낸 파일 · ARA SYSTEM". - **정상**: "김아라 님이 보낸 파일 2개 (1.8 GB)"(20) → "보낸 사람 김아라 · ARA SYSTEM"(로그인한 계정의 값 — 보낸 사람이 바꿀 수 없다) → - "2026년 11월 29일(일)까지 내려받을 수 있습니다 (한국 시간) · 30일 남음" → 파일 목록(줄마다 아이콘 36 · 이름 · "1.2 GB · 보안 검사 · 이상 없음" · [내려받기](채운 단추 44)) → - "내려받다 끊기면 브라우저의 다운로드 목록에서 다시 시도하세요 — 받은 곳부터 이어서 받습니다." → + "한국 시간으로 2026년 11월 29일(일)까지 내려받을 수 있습니다. 30일 남았습니다."(날짜만 굵게) → 파일 목록(줄마다 아이콘 36 · 이름 · "1.2 GB · 보안 검사 결과 이상 없음" · [내려받기](채운 단추 44)) → + "내려받다 끊기면 브라우저의 다운로드 목록에서 다시 시도해 주세요. 받은 곳부터 이어서 받습니다." → 청록 상자 "이 화면은 로그인이나 비밀번호를 묻지 않습니다. 묻는 화면이 나오면 닫고 보낸 사람에게 확인해 주세요." → "받을 이유가 없는 파일이면 postmaster@arasystem.it.kr 로 알려 주세요."(운영 설정 — 처음 값 postmaster@메일 도메인) → 영어 한 줄. - 메일 제목 · 본문 · 받는 사람, 내려받은 수 · 남은 수는 보이지 않는다. [내려받기]는 형식과 상관없이 늘 저장이고, 누르기 직전에 상태를 한 번 더 묻는다. noindex · no-referrer. - **상태**(모든 상태에서 머리 · 영어 한 줄은 그대로. 신고 주소 줄은 링크 정보를 받은 상태에서만 — 없는 링크 · 요청이 너무 많음 · 오류 응답에는 그 값이 없다): - 검사 중 — 모두 검사 중이면 제목 "보안 검사 중입니다" / "파일을 검사하고 있습니다. 몇 분 뒤 이 화면을 다시 열어 주세요." + [다시 확인]. 일부면 그 줄만 "보안 검사 중" + [내려받기] `aria-disabled`. - - 위험 — "내려받을 수 없는 파일입니다" / "보안 검사에서 위험한 내용이 발견되어 막았습니다. 보낸 사람에게 알렸습니다." 그 줄은 빨강 "위험 — 막았습니다", 단추 없음. - - 검사하지 못함 — 제목은 그대로, 줄에 갈색 "검사하지 못함 — 암호가 걸린 압축 파일 등 안을 볼 수 없는 파일입니다. 보낸 사람을 아는 경우에만 여세요." + [내려받기](받을 수 있다). + - 위험 — "내려받을 수 없는 파일입니다" / "보안 검사에서 위험한 내용이 발견되어 막았습니다. 보낸 사람에게 알렸습니다." 그 줄은 빨강 "위험한 내용이 있어 막았습니다", 단추 없음. + - 검사하지 못함 — 제목은 그대로, 줄에 갈색 "암호가 걸린 압축 파일처럼 안을 볼 수 없어 검사하지 못한 파일입니다. 보낸 사람을 아는 경우에만 열어 주세요." + [내려받기](받을 수 있다). - 횟수 초과(파일마다 운영 설정 — 처음 값 500번, 넘은 파일만) — 그 줄에 "내려받을 수 있는 횟수를 넘었습니다. 보낸 사람에게 다시 보내 달라고 요청해 주세요."(숫자는 쓰지 않는다 — 한도는 운영 설정이고 응답에 없다) - 만료 — "내려받을 수 있는 기간이 지났습니다" / "2026년 11월 29일까지 받을 수 있었던 파일입니다. 파일은 지워져 다시 받을 수 없습니다. 필요하면 보낸 사람(김아라, ara.kim@arasystem.it.kr)에게 다시 보내 달라고 요청해 주세요." - 회수 — "보낸 사람이 공유를 멈췄습니다" / "필요하면 보낸 사람(김아라, ara.kim@arasystem.it.kr)에게 문의해 주세요." 파일 이름도 보이지 않는다. @@ -640,15 +669,15 @@ API 와 서버 규칙은 계약 2절 "실시간 알림". - **메뉴**: 메일 묶음의 차단한 주소 아래 「대용량 첨부」 — 서명 · 부재중 자동 응답 · 필터 · 보내기 · 읽기 · 차단한 주소 · 대용량 첨부 · 알림. - 머리 "대용량 첨부" + "메일에 링크로 보낸 큰 파일입니다. 받는 사람은 보낸 날부터 30일 동안 내려받고, 링크를 끊으면 바로 받을 수 없습니다." - **사용량 상자**: "보관 사용량 6.2 / 10 GB"(메일함 사용량과 같은 표기) + 4px 막대(90% 를 넘으면 빨강 — 메일함 사용량과 같다) + - "보낸 링크 4.3 GB · 보내지 않은 파일 1.9 GB(임시 저장 · 올리다 멈춤). 기간이 지나거나, 링크를 끊거나, 보내지 않은 파일을 지우면 바로 줄어듭니다." -- **보내지 않은 파일**(사용량 상자 바로 아래 — 공간을 바로 비울 수 있는 곳이라 보낸 링크보다 위): 「보내지 않은 파일 3개」 + "1.9 GB · 임시 저장한 메일에 실렸거나 올리다 멈춘 파일 — 지우면 바로 공간이 생깁니다". 없으면 묶음째 보이지 않는다. + "보낸 링크 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개」 + "메일마다 한 덩이 · 최근에 보낸 차례". 메일(링크)마다 한 덩이 — 링크가 메일 한 통에 하나라 [링크 끊기]는 덩이에 한 번이다(파일 줄마다 두면 같은 메일의 다른 파일까지 끊긴다). + 그 저장본을 쓰기 화면에서 열면 첨부 칸에 빨간 줄 "[설정] → [대용량 첨부]에서 지운 파일입니다. 빼고 다시 올려 주세요."(쓰기 화면의 "기간이 지나 지워진 파일"과 같은 모양 · 다른 글). +- **보낸 링크**: 「보낸 링크 6개」 + "메일마다 하나씩, 최근에 보낸 순서". 메일(링크)마다 한 덩이 — 링크가 메일 한 통에 하나라 [링크 끊기]는 덩이에 한 번이다(파일 줄마다 두면 같은 메일의 다른 파일까지 끊긴다). - 머리: 메일 제목(청록 링크 → 보낸편지함의 그 메일, 메일을 지웠으면 글자만) / "10월 30일 (금) 보냄 · 파일 2개 · 1.8 GB" · 오른쪽 "11월 29일까지" / "30일 남음"(3일 이하면 갈색 굵게) · [링크 끊기](36). - 파일 줄(44 · 바탕 #FBFBFC): 아이콘 · 이름 · 크기 · "3회 받음" / "마지막 오늘 오후 2:41"(받지 않았으면 "아직 받지 않음") · 상태. - 상태 글: 사용 중 · 검사 중(회색) · 위험(빨강 굵게 + "내려받기를 막음", 줄 바탕 #FEF3F2) · 기간 지남 · 공유 멈춤. 검사하지 못한 파일은 상태 아래 갈색 "검사하지 못함". 뱃지 없이 글자만. @@ -686,14 +715,14 @@ API 와 서버 규칙은 계약 2절 "실시간 알림". - 제목 "2단계 인증" / "비밀번호를 확인했습니다. 휴대폰 인증 앱에 표시된 코드를 넣어 주세요." - 주소 + [다른 계정으로 로그인] — 처음 화면으로(주소 칸은 비움), 확인한 비밀번호는 버린다. - 칸 "인증 앱에 표시된 6자리 코드" — 숫자 6자리만, 붙여 넣은 글의 띄어쓰기 · 하이픈은 지운다. `inputmode="numeric"` · `autocomplete="one-time-code"`. - - [확인] → "인증 앱을 쓸 수 없나요? 관리자에게 문의해 주세요." → "Outlook · 휴대폰 메일 앱은 이 코드를 묻지 않습니다. 설정 › 앱 비밀번호에서 만든 비밀번호로 연결합니다." + - [확인] → "인증 앱을 쓸 수 없나요? 관리자에게 문의해 주세요." → "Outlook이나 휴대폰 메일 앱은 이 코드를 묻지 않습니다. [설정] → [앱 비밀번호]에서 만든 비밀번호로 연결해 주세요." - **상태** - - 코드 틀림 — 칸 아래 빨간 글(`AUTH_006` 사전 문구 "코드가 올바르지 않습니다. 휴대폰의 날짜와 시간이 「자동」으로 맞춰져 있는지 확인해 주세요."). 칸을 비우고 초점은 그 칸, 알림은 없다. + - 코드 틀림 — 칸 아래 빨간 글(`AUTH_006` 사전 문구 "코드가 올바르지 않습니다. 휴대폰의 날짜와 시간이 자동으로 맞춰지도록 설정되어 있는지 확인해 주세요."). 칸을 비우고 초점은 그 칸, 알림은 없다. - 시도 과다 — 위쪽 빨간 상자(로그인 오류와 같은 자리 · 같은 모양)에 `REQ_001` 사전 문구. 코드 실패는 비밀번호 틀림과 따로 센다(같은 주소 15분 5회 · 하루 20회). 칸 · 단추는 그대로 둔다. - 확인 중 — 「확인하는 중…」(로그인 단추와 같은 모양 · `aria-busy`), 칸은 읽기만. 맞으면 받은편지함으로 — 로그인 전에 가려던 주소가 있으면 그곳으로. - 이 화면에 10분 넘게 머물면 비밀번호를 바로 지우고, [확인] 때 처음 화면으로 가 위쪽 상자에 "코드를 넣는 시간이 지났습니다. 다시 로그인해 주세요."를 보인다. 새로고침도 처음 화면이다(비밀번호는 화면 메모리에만 있다). - 코드 단계에서 비밀번호가 틀리면(그사이 바뀜) 처음 화면의 위쪽 오류 상자에 보인다 — 지금 로그인 화면과 같은 자리. 6자리가 아니면 보내지 않고 칸 아래에 `AUTH_007` 사전 문구. -- 로그인 기록에는 코드까지 맞으면 「성공 · 2단계 인증」, 틀리면 「실패 — 코드 틀림」으로 남는다. +- 로그인 기록에는 코드까지 맞으면 「2단계 인증으로 성공」, 틀리면 「코드가 틀려 실패」로 남는다. - 성공에 쓴 코드는 2분 동안 다시 받지 않는다 — 30초 안에 두 곳(PC · 휴대폰)에서 연달아 로그인하면 두 번째는 「코드가 올바르지 않습니다…」이고, 앱에 다음 코드가 뜰 때 넣는다. ### 설정 › 계정 › 2단계 인증 (`SettingsTwoFactor`) @@ -701,43 +730,43 @@ API 와 서버 규칙은 계약 2절 "실시간 알림". - 머리 "2단계 인증" + "로그인할 때 비밀번호와 함께 휴대폰 인증 앱의 코드를 확인합니다. 비밀번호가 새어 나가도 휴대폰이 없으면 로그인할 수 없습니다." - **상태는 글자로만**(뱃지 · 스위치 없음) — 테두리 상자 하나: - 「꺼짐」(회색 굵게) / "지금은 로그인할 때 비밀번호만 확인합니다." + [2단계 인증 켜기] - - 「켜짐 — 2026년 10월 31일부터」(초록 #067647 굵게 — 켠 때를 모르면 「켜짐」) / "로그인할 때마다 비밀번호와 인증 앱의 코드를 확인합니다." + [2단계 인증 끄기] + - 「2026년 10월 31일부터 켜짐」(초록 #067647 굵게 — 켠 때를 모르면 「켜짐」) / "로그인할 때마다 비밀번호와 인증 앱의 코드를 확인합니다." + [2단계 인증 끄기] - **꺼져 있을 때** 상자 아래: - - 갈색 경고 "켜면 메일 프로그램은 앱 비밀번호로 다시 연결해야 합니다" / "Outlook · 썬더버드 · 휴대폰 메일 앱은 웹메일 비밀번호로 새로 연결할 수 없고, 이미 연결해 둔 프로그램도 다음에 다시 연결할 때 막힙니다. 켜기 전에 앱 비밀번호를 만들어 두면 편합니다."(앱 비밀번호 링크) - - 「켜는 차례」 1 · 2 · 3(청록 굵은 숫자 — 동그라미 없음): 인증 앱 설치(Google · Microsoft Authenticator 처럼 6자리 코드를 만드는 앱) → [2단계 인증 켜기]를 눌러 QR 찍기 → 코드와 지금 비밀번호를 넣으면 켜진다. -- **켜져 있을 때** 상자 아래: "휴대폰을 바꿀 때는 2단계 인증을 끈 뒤 새 휴대폰으로 다시 켜 주세요 — 끌 때 지금 휴대폰의 코드가 필요합니다." · "휴대폰을 잃어버렸거나 인증 앱을 지웠으면 관리자에게 문의해 주세요." · "메일 프로그램은 앱 비밀번호로 연결합니다." + - 갈색 경고 "켜면 메일 프로그램은 앱 비밀번호로 다시 연결해야 합니다" / "Outlook, 썬더버드, 휴대폰 메일 앱은 웹메일 비밀번호로 새로 연결할 수 없고, 이미 연결해 둔 프로그램도 다음에 다시 연결할 때 막힙니다. 켜기 전에 앱 비밀번호를 만들어 두면 편합니다."(앱 비밀번호 링크) + - 「켜는 방법」 1 · 2 · 3(청록 굵은 숫자 — 동그라미 없음): 인증 앱 설치(Google · Microsoft Authenticator 처럼 6자리 코드를 만드는 앱) → [2단계 인증 켜기]를 눌러 QR 찍기 → 코드와 지금 비밀번호를 넣으면 켜진다. +- **켜져 있을 때** 상자 아래: "휴대폰을 바꿀 때는 2단계 인증을 끈 뒤 새 휴대폰으로 다시 켜 주세요. 끌 때 지금 휴대폰의 코드가 필요합니다." · "휴대폰을 잃어버렸거나 인증 앱을 지웠으면 관리자에게 문의해 주세요." · "메일 프로그램은 앱 비밀번호로 연결합니다." - **켜기 창**(모달 620 — `wide`, 필터 고치기 창과 같은 폭 · 처음 초점은 코드 칸) - - ① "인증 앱으로 QR 코드를 찍어 주세요" / "인증 앱에서 [+] → 「QR 코드 스캔」을 고르고 이 그림을 찍습니다." + QR 그림(160 — 실제 otpauth 주소는 오류 정정 M 에서 49칸이라 보드의 128 보다 키웠다) - - "찍을 수 없으면 이 키를 입력하세요" + 32자 키(4자씩 띄움, 고정폭) + [복사](36 — 「키를 복사했습니다.」) + "앱에는 「ARA SYSTEM (ara.kim@arasystem.it.kr)」로 저장됩니다." + - ① "인증 앱으로 QR 코드를 찍어 주세요" / "인증 앱에서 [+] → [QR 코드 스캔]을 고른 뒤 이 그림을 찍어 주세요." + QR 그림(160 — 실제 otpauth 주소는 오류 정정 M 에서 49칸이라 보드의 128 보다 키웠다) + - "찍을 수 없으면 이 키를 입력해 주세요" + 32자 키(4자씩 띄움, 고정폭) + [복사](36 — 「키를 복사했습니다.」) + "앱에는 ‘ARA SYSTEM (ara.kim@arasystem.it.kr)’로 저장됩니다." - ② "앱에 표시된 6자리 코드" ③ "지금 비밀번호" → [켜기] [취소] - 칸 아래 오류(알림 없이, 첫 잘못된 칸에 초점): 코드 `AUTH_006` · 지금 비밀번호 `AUTH_005`("지금 비밀번호가 올바르지 않습니다."). 둘 다 틀렸으면 코드부터 — 서버가 코드를 먼저 본다. 틀린 코드는 지우고 그 칸에 초점을 둔다. QR · 키는 그대로라 다시 찍지 않아도 된다. 비밀번호 틀림은 로그인 실패와 함께, 코드 틀림은 코드 실패로 따로 센다. - - 켜는 동안 「켜는 중…」 · [취소]. 맞으면 창을 닫고 화면이 「켜짐」이 되며, 위에 안내 상자 "2단계 인증을 켰습니다" / "메일 프로그램(Outlook · 썬더버드 · 휴대폰 메일 앱)은 다시 연결할 때 앱 비밀번호가 필요합니다." [앱 비밀번호 만들기](36) — 이 화면을 떠나면 사라진다. + - 켜는 동안 「켜는 중…」 · [취소]. 맞으면 창을 닫고 화면이 「켜짐」이 되며, 위에 안내 상자 "2단계 인증을 켰습니다" / "Outlook, 썬더버드, 휴대폰 메일 앱 같은 메일 프로그램은 다시 연결할 때 앱 비밀번호가 필요합니다." [앱 비밀번호 만들기](36) — 이 화면을 떠나면 사라진다. - 키는 창을 열 때마다 새로 만든다(10분 안에 켠다 — 지나면 `AUTH_008` 로 처음부터). 켜지 않고 닫으면 그 키는 버린다 — 앱에 먼저 저장한 항목은 지워 달라고 안내한다. QR · 키는 이 창에만 보이고 로그에 남기지 않는다. - 켜도 다른 곳의 웹메일 로그인은 끊지 않는다 — 끊으려면 로그인 기록에서. - **끄기 창**(alertdialog 400 · 처음 초점 「취소」, Esc 도 같다): "2단계 인증을 끌까요?" / "끄면 비밀번호만으로 로그인합니다. 만들어 둔 앱 비밀번호는 그대로 남습니다." + 지금 비밀번호 · 인증 앱 코드 → [끄기](빨강) [취소]. 끄면 「2단계 인증을 껐습니다.」 — 화면은 「꺼짐」. 틀리면 켜기 창과 같은 칸 아래 글이다. - **비밀번호 화면**(설정 › 비밀번호) - - 2단계 인증을 켠 직원: 지금 양식 맨 아래에 「인증 앱 코드」 칸 + "코드는 30초마다 바뀝니다 — 다른 칸을 다 쓴 뒤 넣어 주세요." + - 2단계 인증을 켠 직원: 지금 양식 맨 아래에 「인증 앱 코드」 칸 + "코드는 30초마다 바뀌니 다른 칸을 다 채운 뒤에 넣어 주세요." 머리 설명은 "웹메일에 로그인하는 비밀번호입니다. 2단계 인증이 켜져 있어 메일 프로그램은 앱 비밀번호로 연결합니다." 다른 탭에서 켜서 `AUTH_007` 이 오면 2단계 상태를 다시 받아 코드 칸을 보인다. - - 모든 직원: 단추 아래 "바꾸면 이 브라우저를 뺀 다른 기기 · 브라우저의 웹메일 로그인이 끊깁니다." "앱 비밀번호도 모두 지워집니다 — 메일 프로그램은 새 앱 비밀번호로 다시 연결해 주세요." + - 모든 직원: 단추 아래 "바꾸면 이 브라우저를 빼고 다른 기기와 브라우저의 웹메일에서 로그아웃됩니다." "앱 비밀번호도 모두 지워지니, 메일 프로그램은 새 앱 비밀번호로 다시 연결해 주세요." ### 설정 › 계정 › 앱 비밀번호 (`SettingsAppPasswords`) -- 머리 "앱 비밀번호" + "Outlook · 썬더버드 · 휴대폰 메일 앱 같은 메일 프로그램에 넣는 비밀번호입니다. 프로그램마다 따로 만들어 두면, 기기를 잃었을 때 그것만 지울 수 있습니다." +- 머리 "앱 비밀번호" + "Outlook, 썬더버드, 휴대폰 메일 앱 같은 메일 프로그램에 넣는 비밀번호입니다. 프로그램마다 따로 만들어 두면 기기를 잃어버렸을 때 그 비밀번호만 지울 수 있습니다." 오른쪽 "3 / 5개" + [앱 비밀번호 만들기]. - **표**(구현은 실제 table): 이름 · 만든 날 · 마지막 사용 · [지우기](36, 빨간 글). 최근에 만든 것이 위, 긴 이름은 말줄임. 마지막 사용은 "오늘 오전 9:12 · IMAP · 211.186.95.252" — 쓰지 않았으면 "아직 쓰지 않음"(회색, 오래 쓰지 않은 줄은 지워도 된다는 신호). - **메일 프로그램 설정값 상자**: 받는 메일(IMAP) `mx1.arasystem.it.kr` · 포트 993 · SSL/TLS / 보내는 메일(SMTP) `mx1.arasystem.it.kr` · 포트 465 · SSL/TLS / - 사용자 이름 = 메일 주소 전체 / 비밀번호 = 여기서 만든 앱 비밀번호 — 웹메일 비밀번호가 아닙니다. 호스트 · 포트는 화면 상수 하나에 둔다(전 직원 전환 때 다시 본다). -- **아래 안내**: "앱 비밀번호는 메일 읽기 · 보내기(IMAP · SMTP)에만 쓰입니다. 웹메일 로그인에는 쓸 수 없습니다." · "2단계 인증을 켜면 메일 프로그램은 앱 비밀번호로만 연결합니다. 켜지 않아도 만들어 쓸 수 있습니다." · - "웹메일 비밀번호를 바꾸면 앱 비밀번호도 모두 지워집니다 — 메일 프로그램마다 새로 만들어 주세요." · "휴대폰이나 PC 를 잃어버렸으면 그 기기의 앱 비밀번호를 바로 지워 주세요." + 사용자 이름 = 메일 주소 전체 / 비밀번호 = "웹메일 비밀번호가 아닌, 여기서 만든 앱 비밀번호". 호스트 · 포트는 화면 상수 하나에 둔다(전 직원 전환 때 다시 본다). +- **아래 안내**: "앱 비밀번호는 메일 프로그램에서 메일을 읽고 보낼 때만 씁니다. 웹메일 로그인에는 쓸 수 없습니다." · "2단계 인증을 켜면 메일 프로그램은 앱 비밀번호로만 연결합니다. 켜지 않아도 만들어 쓸 수 있습니다." · + "웹메일 비밀번호를 바꾸면 앱 비밀번호도 모두 지워지니, 메일 프로그램마다 새로 만들어 주세요." · "휴대폰이나 PC를 잃어버렸다면 그 기기의 앱 비밀번호를 바로 지워 주세요." - **5개를 모두 만들었을 때**: [앱 비밀번호 만들기]는 `aria-disabled` + 머리 아래 한 줄 "5개까지 만들 수 있습니다. 쓰지 않는 앱 비밀번호를 지운 뒤 만들어 주세요." — 누르면 창을 열지 않고 까닭을 읽어 준다(role=status). -- **빈 상태**: "0 / 5개" + "만든 앱 비밀번호가 없습니다" / "Outlook · 휴대폰 메일 앱에서 메일을 보려면 앱 비밀번호를 만들어 주세요. 2단계 인증을 켰다면 메일 프로그램은 앱 비밀번호로만 연결합니다." +- **빈 상태**: "0 / 5개" + "만든 앱 비밀번호가 없습니다" / "Outlook이나 휴대폰 메일 앱에서 메일을 보려면 앱 비밀번호를 만들어 주세요. 2단계 인증을 켰다면 메일 프로그램은 앱 비밀번호로만 연결할 수 있습니다." 설정값 상자 · 아래 안내는 그대로. 목록을 받지 못하면 지금의 목록 오류와 같은 모양(사전 문구 + [다시 시도]) — 그동안 [만들기]는 막는다(개수를 모르므로). -- **만들기 창**(400 · 처음 초점 이름 칸): 「이름」 + "어느 프로그램 · 기기에서 쓰는지 알아볼 이름입니다. 40자까지." → [만들기] [취소]. +- **만들기 창**(400 · 처음 초점 이름 칸): 「이름」 + "어느 프로그램이나 기기에서 쓰는지 알아볼 수 있게 지어 주세요. 40자까지 쓸 수 있습니다." → [만들기] [취소]. 칸 아래: "이름을 넣어 주세요." · "같은 이름의 앱 비밀번호가 있습니다."(앞뒤 공백을 지운 값으로 화면이 먼저 본다) · 그사이 다른 창에서 5개를 채웠으면 `AUTH_009` 사전 문구. -- **만든 결과 창**(620 · 처음 초점 [복사]): "앱 비밀번호를 만들었습니다" / "‘회사 PC 아웃룩’ — 메일 프로그램의 비밀번호 칸에 아래 값을 넣어 주세요." +- **만든 결과 창**(620 · 처음 초점 [복사]): "앱 비밀번호를 만들었습니다" / "‘회사 PC 아웃룩’ 앱 비밀번호입니다. 메일 프로그램의 비밀번호 칸에 아래 값을 넣어 주세요." - 값은 고정폭으로 4자씩 떨어뜨려 보이지만 띄어쓰기는 없다 — [복사](44 — 창의 주 동작이라 보드대로)는 40자 그대로. "40자입니다. 보기 좋게 4자씩 띄웠지만 띄어쓰기는 없습니다." - 경고 "이 창을 닫으면 다시 볼 수 없습니다. 잊어버리면 지우고 새로 만들어 주세요." + 설정값 요약 + [닫기]. - 복사 「앱 비밀번호를 복사했습니다.」 / 못 하면 「앱 비밀번호를 복사하지 못했습니다. 값을 직접 골라 복사해 주세요.」 @@ -750,18 +779,18 @@ API 와 서버 규칙은 계약 2절 "실시간 알림". - 머리 "로그인 기록" + "최근 90일 동안의 로그인입니다. 모르는 기록이 있으면 바로 비밀번호를 바꾸고, 2단계 인증이 꺼져 있으면 켜 주세요."(두 링크). - **로그인된 브라우저**: 「로그인된 브라우저」 + "웹메일 3곳" · 오른쪽 [다른 곳 모두 로그아웃](44). - 줄마다 PC · 휴대폰 아이콘(16) · 브라우저 · 운영체제 / IP / 마지막 활동 / [로그아웃](36). 맨 위는 지금 이 브라우저 — 단추 대신 「지금 이 브라우저」 글자만. 모르면 "알 수 없는 브라우저". - - 아래 "웹메일 로그인만 보입니다. 메일 프로그램은 앱 비밀번호를 지워 끊습니다."(링크) - - 줄의 [로그아웃]은 묻지 않고 바로 끊는다 → 「‘Edge · Windows’의 로그인을 끊었습니다.」(되돌리기 없음). 끊은 줄은 빠지고, 다른 곳이 없으면 [다른 곳 모두 로그아웃]을 그리지 않는다. - - [다른 곳 모두 로그아웃] → 확인(alertdialog 400 · 처음 초점 「취소」) "다른 곳의 로그인을 모두 끊을까요?" / - "이 브라우저를 뺀 2곳(Edge · Windows, Safari · iPhone)의 웹메일 로그인을 끊습니다. 끊긴 곳은 다음 동작에서 바로 로그인 화면으로 돌아갑니다." "메일 프로그램은 끊기지 않습니다 — 앱 비밀번호를 지워 끊어 주세요." - [모두 로그아웃](청록 주 단추 — 데이터를 지우는 일이 아니다) [취소] → 「다른 곳의 로그인 2곳을 끊었습니다.」 + - 아래 "웹메일 로그인만 보입니다. 메일 프로그램의 연결을 끊으려면 앱 비밀번호를 지워 주세요."(링크) + - 줄의 [로그아웃]은 묻지 않고 바로 끊는다 → 「‘Edge · Windows’에서 로그아웃했습니다.」(되돌리기 없음). 끊은 줄은 빠지고, 다른 곳이 없으면 [다른 곳 모두 로그아웃]을 그리지 않는다. + - [다른 곳 모두 로그아웃] → 확인(alertdialog 400 · 처음 초점 「취소」) "다른 곳에서 모두 로그아웃할까요?" / + "이 브라우저를 뺀 2곳(Edge · Windows, Safari · iPhone)의 웹메일에서 로그아웃합니다. 그곳에서 다음에 무엇이든 누르면 바로 로그인 화면이 나옵니다." "메일 프로그램은 로그아웃되지 않습니다. 연결을 끊으려면 앱 비밀번호를 지워 주세요." + [모두 로그아웃](청록 주 단추 — 데이터를 지우는 일이 아니다) [취소] → 「다른 2곳에서 로그아웃했습니다.」 - 끊긴 브라우저는 다음 요청에서 바로 로그인 화면으로 간다(열린 실시간 알림도 30초 안에) — 계약 5절 "로그인된 브라우저". - **최근 로그인**: 「최근 로그인」 + "최근 90일 · 웹메일과 메일 프로그램" · 오른쪽 전환 [전체 · 실패만](`.segmented--outline` — role=group + `aria-pressed`). 표: 시각 · 방식 · IP · 기기 · 결과. - 방식: "웹메일" / "메일 프로그램 · IMAP" · "메일 프로그램 · SMTP" + 앱 비밀번호로 들어왔으면 아래 줄 "앱 비밀번호 ‘회사 PC 아웃룩’"(지운 앱 비밀번호도 그 이름 그대로). - 기기: 웹메일은 "Chrome · Windows", 메일 프로그램은 "—". 나라 · 지역은 보이지 않는다. - - 결과: 「성공」 · 「성공 · 2단계 인증」 · 「실패 — 비밀번호 틀림」 · 「실패 — 코드 틀림」 · 「실패 — 앱 비밀번호 필요」(2단계를 켠 계정에 메일 프로그램이 웹메일 비밀번호로 연결하려 함). 실패는 빨간 굵은 글자만 — 뱃지 없음. + - 결과: 「성공」 · 「2단계 인증으로 성공」 · 「비밀번호가 틀려 실패」 · 「코드가 틀려 실패」 · 「앱 비밀번호가 필요해 실패」(2단계를 켠 계정에 메일 프로그램이 웹메일 비밀번호로 연결하려 함). 실패는 빨간 굵은 글자만 — 뱃지 없음. - 최근 순서 · 20개씩 [더 보기](44). 「실패만」이면 "최근 90일 · 실패 4번", [더 보기]도 실패만 이어서. - - 빈 상태(실패만): "최근 90일 동안 실패한 로그인이 없습니다" / "[전체]를 누르면 성공한 로그인도 봅니다." 기록은 이 기능을 배포한 날부터 쌓인다. + - 빈 상태(실패만): "최근 90일 동안 실패한 로그인이 없습니다" / "[전체]를 누르면 성공한 로그인도 볼 수 있습니다." 전체가 비었으면 "이 기능이 생긴 날부터 기록이 쌓입니다." - 목록을 받지 못하면 지금의 목록 오류와 같은 모양(사전 문구 + [다시 시도]). ### 처음 판에서 미룬 것 @@ -789,11 +818,11 @@ API 와 서버 규칙은 계약 2절 "실시간 알림". - 제목 "새 비밀번호를 정해 주세요" / "관리자가 정한 임시 비밀번호로 로그인했습니다. 메일을 쓰려면 먼저 새 비밀번호를 정해 주세요." - 주소 + [다른 계정으로 로그인] — 로그아웃하고 처음 화면으로(주소 칸은 비움). - (임시 비밀번호가 화면 메모리에 없을 때만) 「임시 비밀번호」 + "관리자에게 받은 임시 비밀번호를 다시 넣어 주세요." - - 「새 비밀번호」 + "8자 이상 · 임시 비밀번호와 달라야 합니다. 영문 · 숫자 · 기호를 섞으면 더 안전합니다." + - 「새 비밀번호」 + "8자 이상이고 임시 비밀번호와 달라야 합니다. 영문, 숫자, 기호를 섞으면 더 안전합니다." - 「새 비밀번호 확인」 — 보기 단추 없이 새 비밀번호의 보기를 따른다(설정 › 비밀번호와 같다). - - (2단계 인증을 켠 직원만) 맨 아래 「인증 앱 코드」 + "로그인할 때 넣은 코드는 다시 쓸 수 없습니다 — 앱에 다음 코드가 뜨면 넣어 주세요." — 로그인 코드 칸 모양(18px · 자간), 숫자 6자리 · `one-time-code`. + - (2단계 인증을 켠 직원만) 맨 아래 「인증 앱 코드」 + "로그인할 때 넣은 코드는 다시 쓸 수 없으니, 앱에 다음 코드가 뜨면 넣어 주세요." — 로그인 코드 칸 모양(18px · 자간), 숫자 6자리 · `one-time-code`. - [비밀번호 바꾸고 계속] — 보내는 동안 「바꾸는 중…」(`aria-busy`, 칸은 읽기만). 요청 한도 30초(`PASSWORD_CHANGE_TIMEOUT_MS` — 설정 › 비밀번호와 같다). - - 맨 아래(위쪽 선 · 12px 회색): "메일 프로그램(아웃룩 등)을 연결해 두었다면 바꾼 뒤 다시 연결해야 합니다." + - 맨 아래(위쪽 선 · 12px 회색): "아웃룩 같은 메일 프로그램을 연결해 두었다면 비밀번호를 바꾼 뒤 다시 연결해 주세요." - 칸은 로그인 칸 모양(테두리 #8A94A3 · 글자 14px · 이름 12.5px 굵게)이고, 임시 비밀번호 · 새 비밀번호 칸 끝에 설정 화면의 [보기](44)를 둔다 — 로그인 첫 화면의 비밀번호 칸에는 없다. - **임시 비밀번호**: 로그인 때 넣은 것을 화면 메모리에서 지금 비밀번호로 쓴다 — 묻지 않는다. 10분 뒤 메모리에서 지운다(12단계 코드 단계와 같은 10분). 메모리에 없으면(새로고침 · 다른 탭 · 10분 지남) 맨 위에 「임시 비밀번호」 칸을 보인다. @@ -816,17 +845,17 @@ API 와 서버 규칙은 계약 2절 "실시간 알림". - **누가 오나**: 회사가 2단계 인증을 필수로 정한 날(시작일)부터, 아직 켜지 않은 직원(예외 주소 빼고)이 비밀번호를 맞혔을 때(`restriction: twoFactorSetup`). 켜기 전에는 메일 · 주소록 · 설정을 열 수 없다(`AUTH_010`). 새 비밀번호 정하기가 함께 걸린 직원(입사)은 그것 먼저. - **틀**(위에서부터): - - 제목 "2단계 인증을 켜야 합니다" / "회사 정책에 따라 로그인할 때 휴대폰 인증 앱의 코드도 확인합니다. 지금 켜면 바로 메일로 이어집니다." + - 제목 "2단계 인증을 켜 주세요" / "회사 정책에 따라 로그인할 때 휴대폰 인증 앱의 코드도 확인합니다. 지금 켜면 바로 메일로 이어집니다." - 주소 + [다른 계정으로 로그인] - 차례(청록 굵은 숫자 — 동그라미 없음, 숫자 칸 22 · 사이 18 — 12단계 켜기 창과 같다): - - ① "인증 앱으로 QR 코드를 찍어 주세요" / "인증 앱에서 [+] → 「QR 코드 스캔」을 고릅니다. 앱이 없으면 Google Authenticator · Microsoft Authenticator 를 먼저 설치해 주세요." - QR(148 — 양식 칸 360 안에 키를 옆에 두려고 12단계 켜기 창의 160 보다 작게) 오른쪽에 "찍을 수 없으면 이 키를 입력하세요"(12px 굵게) + 32자 키(4자씩 · 두 줄 · 고정폭 12px) + - [복사](36 — "키를 복사했습니다." / 못 하면 "키를 복사하지 못했습니다. 키를 직접 골라 복사해 주세요."), 그 아래 "앱에는 「ARA SYSTEM (주소)」로 저장됩니다." + - ① "인증 앱으로 QR 코드를 찍어 주세요" / "인증 앱에서 [+] → [QR 코드 스캔]을 고릅니다. 앱이 없으면 Google Authenticator나 Microsoft Authenticator를 먼저 설치해 주세요." + QR(148 — 양식 칸 360 안에 키를 옆에 두려고 12단계 켜기 창의 160 보다 작게) 오른쪽에 "찍을 수 없으면 이 키를 입력해 주세요"(12px 굵게) + 32자 키(4자씩 · 두 줄 · 고정폭 12px) + + [복사](36 — "키를 복사했습니다." / 못 하면 "키를 복사하지 못했습니다. 키를 직접 골라 복사해 주세요."), 그 아래 "앱에는 ‘ARA SYSTEM (주소)’로 저장됩니다." - ② 「앱에 표시된 6자리 코드」 — 로그인 코드 칸 모양. - - (지금 비밀번호가 화면 메모리에 없을 때만) ③ 「지금 비밀번호」 + "새로고침해서 다시 넣어야 합니다." — [보기] 있음. + - (지금 비밀번호가 화면 메모리에 없을 때만) ③ 「지금 비밀번호」 + "새로고침한 뒤에는 지금 비밀번호를 한 번 더 넣어 주세요." — [보기] 있음. - [켜고 계속] — 보내는 동안 「켜는 중…」(`aria-busy`, 칸은 읽기만). 요청 한도 30초(`TWO_FACTOR_CHANGE_TIMEOUT_MS`). - 문의 줄 "인증 앱을 쓸 수 없나요?" + 연락처(아래 "문의 줄"). - - 맨 아래(위쪽 선 · 12px): "Outlook · 휴대폰 메일 앱은 이 코드를 묻지 않습니다. 켠 뒤에는 설정 › 앱 비밀번호에서 만든 비밀번호로 연결합니다." + - 맨 아래(위쪽 선 · 12px): "Outlook이나 휴대폰 메일 앱은 이 코드를 묻지 않습니다. 켠 뒤에는 [설정] → [앱 비밀번호]에서 만든 비밀번호로 연결해 주세요." - **지금 비밀번호**: 로그인 때 넣은 것(방금 새 비밀번호를 정했으면 그것)을 화면 메모리에서 쓴다 — 12단계 켜기 창의 ③ 을 묻지 않는다. 10분 뒤 지우고, 메모리에 없으면 ③ 칸을 보인다. - **키**: 이 화면을 열 때마다 새로 만든다(`POST /api/two-factor/enrollments` — 10분 안에 켠다). 켜지 않고 떠나면(다른 계정 · 새로고침) 그 키는 버린다 — 새로고침 뒤에는 QR · 키가 바뀌므로 앱에 먼저 저장한 항목은 지우고 다시 찍는다. QR · 키는 화면 메모리에만 두고 로그에 남기지 않는다(12단계 그대로). @@ -842,14 +871,14 @@ API 와 서버 규칙은 계약 2절 "실시간 알림". (단추가 있는 알림 — 10초, 마우스 · 초점이 있으면 멈춤) → 설정 › 앱 비밀번호. 웹메일 비밀번호로 메일 프로그램을 연결해 둔 직원이 다음 연결 때 막히기 전에 알린다. - **유예 기간**(시작일 전 — 로그인은 그대로, `me` 의 `twoFactorDeadline`): - 메일 모듈 화면(목록 · 읽기 · 쓰기 · 검색)의 상단 바 바로 아래, 모듈 폭 전체에 노란 줄 하나(높이 40 · 바탕 #FFF7E6 · 아래 선 #F0D9A8 · 글 #7A5200 · 방패 아이콘 15): - **"11월 30일까지 2단계 인증을 켜 주세요"**(굵게) " — 그 뒤에는 켜야 로그인할 수 있습니다." + [지금 켜기](36 — 읽기 화면 안내 줄의 단추와 같은 `.notice-btn`) → 설정 › 2단계 인증. + **"11월 30일까지 2단계 인증을 켜 주세요"**(굵게) ". 그 뒤에는 켜야 로그인할 수 있습니다." + [지금 켜기](36 — 읽기 화면 안내 줄의 단추와 같은 `.notice-btn`) → 설정 › 2단계 인증. - 날짜는 시작일의 전날(KST)이다 — 시작일이 12월 1일이면 「11월 30일까지」. 올해가 아니면 해를 붙인다("2027년 1월 3일까지"). - 닫기 단추는 없다 — 켜야 사라진다. 주소록 · 설정에는 없다(설정 › 2단계 인증은 아래 안내). - **설정 › 2단계 인증 — 필수인 직원**(`GET /api/two-factor` 의 `isRequired` · `deadline`): - 유예 중 · 꺼짐 — 머리 아래 갈색 상자(방패): **"11월 30일까지 2단계 인증을 켜 주세요"** / "회사 정책에 따라 그 뒤에는 2단계 인증을 켜야 로그인할 수 있습니다." - 그 아래 상태 상자 · 경고 · 켜는 차례 · 켜기 창은 12단계 그대로. + 그 아래 상태 상자 · 경고 · 켜는 방법 · 켜기 창은 12단계 그대로. - 켜짐 — 상태 상자의 [2단계 인증 끄기] 대신 회색 글 "회사 정책에 따라 끌 수 없습니다"(서버도 `VAL_001` 로 거절). - 아래 안내의 첫 줄은 "휴대폰을 바꿀 때는 관리자에게 2단계 인증 해제를 요청해 주세요 — 다음 로그인 때 새 휴대폰으로 다시 켭니다."(나머지 두 줄은 12단계 그대로). + 아래 안내의 첫 줄은 "휴대폰을 바꿀 때는 관리자에게 2단계 인증을 풀어 달라고 요청해 주세요. 다음에 로그인할 때 새 휴대폰으로 다시 켜면 됩니다."(나머지 두 줄은 12단계 그대로). - **휴대폰을 바꿀 때**(필수인 직원): 관리자에게 「2단계 해제」를 요청한다(운영 도구 — 받은편지함 알림 메일이 간다) → 다음 로그인 때 이 켜기 단계로 새 휴대폰을 등록한다. 직원이 직접 하는 휴대폰 바꾸기는 미뤘다(아래). @@ -857,54 +886,54 @@ API 와 서버 규칙은 계약 2절 "실시간 알림". - **메뉴**: 계정 묶음의 앱 비밀번호 아래 「메일 프로그램 연결」. API 는 없다 — 화면 상수로만 그린다(설정값은 `MAIL_CLIENT_SETTINGS` — 앱 비밀번호 화면과 같은 값, 차례 글도 상수). 2단계 인증 상태만 이미 있는 `GET /api/two-factor`(스토어)로 보고, 받지 못하면 켠 직원의 안내로 둔다(더 안전한 쪽). -- 머리 "메일 프로그램 연결" + "아웃룩 · 썬더버드 · 휴대폰 메일 앱에서 회사 메일을 보는 방법입니다." +- 머리 "메일 프로그램 연결" + "아웃룩, 썬더버드, 휴대폰 메일 앱에서 회사 메일을 보는 방법입니다." - **설정값 상자**: 「메일 프로그램 설정값」 — 앱 비밀번호 화면과 같은 이름 · 값 표(이름 칸 136). | 이름 | 값 | | --- | --- | | 받는 메일 (IMAP) | **mx1.arasystem.it.kr** · 포트 993 · SSL/TLS | | 보내는 메일 (SMTP) | **mx1.arasystem.it.kr** · 포트 465 · SSL/TLS | - | 사용자 이름 | **(직원 주소)** — 메일 주소 전체 | - | 비밀번호 | 켠 직원 "앱 비밀번호 — 웹메일 비밀번호가 아닙니다" / 켜지 않은 직원 "앱 비밀번호(권장) — 2단계 인증을 켜지 않았으면 웹메일 비밀번호도 됩니다" | + | 사용자 이름 | **(직원 주소)** · 메일 주소 전체 | + | 비밀번호 | 켠 직원 "웹메일 비밀번호가 아닌 앱 비밀번호" / 켜지 않은 직원 "앱 비밀번호를 권합니다. 2단계 인증을 켜지 않았다면 웹메일 비밀번호로도 연결됩니다." | - **상자 바로 아래** — 2단계 인증 상태로 가른다. 둘 다 [앱 비밀번호 만들기](36) → 설정 › 앱 비밀번호. - - 켠 직원 — 갈색 상자(열쇠): **"비밀번호 칸에는 꼭 앱 비밀번호를 넣어 주세요"** / "2단계 인증을 켜 두어 웹메일 비밀번호로는 연결되지 않습니다. 프로그램마다 앱 비밀번호를 하나씩 만들어 넣습니다." — 단추 `.notice-btn`. + - 켠 직원 — 갈색 상자(열쇠): **"비밀번호 칸에는 꼭 앱 비밀번호를 넣어 주세요"** / "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)에서 번호 줄(청록 굵은 숫자 — 동그라미 없음)로 보인다. 처음에는 모두 접힘, 하나를 펼치면 다른 것은 접힌다. - - 표기: 누르는 것 [ ], 고르는 것 「 」, 호스트 이름은 굵게 — 12단계 「켜는 차례」와 같다. 화면 그림은 넣지 않는다. 맥 메일 · 휴대폰 Outlook 앱은 넣지 않는다. + - 표기: 누르거나 고르는 것은 [ ], 호스트 이름은 굵게(2026-11-05 — 고르는 것도 「 」 대신 [ ], 위 "화면 문구 — 사용자에게 말하듯"). 12단계 「켜는 방법」과 같다. 화면 그림은 넣지 않는다. 맥 메일 · 휴대폰 Outlook 앱은 넣지 않는다. - 차례 글(아래 그대로 — 구현 때 실제 앱(한국어판)의 메뉴 이름과 한 번 더 맞춘다): - - **아웃룩 (Windows)** — 설명 "Microsoft 365 · Outlook 2016 이후의 데스크톱 Outlook" - 1. Outlook 에서 [파일] → [계정 추가]를 누릅니다. - 2. 메일 주소를 넣고 [고급 옵션] → 「내 계정을 수동으로 설정」을 고른 뒤 [연결]을 누릅니다. - 3. 계정 유형에서 「IMAP」을 고릅니다. - 4. 받는 메일 · 보내는 메일 모두 서버 **mx1.arasystem.it.kr**, 포트는 993 · 465, 암호화 방법은 「SSL/TLS」를 넣고 [다음]을 누릅니다. + - **아웃룩 (Windows)** — 설명 "Microsoft 365나 Outlook 2016 이후의 데스크톱 Outlook" + 1. Outlook에서 [파일] → [계정 추가]를 누릅니다. + 2. 메일 주소를 넣고 [고급 옵션] → [내 계정을 수동으로 설정]을 고른 뒤 [연결]을 누릅니다. + 3. 계정 유형에서 [IMAP]을 고릅니다. + 4. 받는 메일과 보내는 메일 모두 서버는 **mx1.arasystem.it.kr**로 넣습니다. 포트는 받는 메일 993, 보내는 메일 465이고, 암호화 방법은 [SSL/TLS]를 고른 뒤 [다음]을 누릅니다. 5. 비밀번호 칸에 앱 비밀번호를 넣고 [연결]을 누릅니다. - - **새 Outlook** — 설명 "Windows 의 새 Outlook — 메일이 Microsoft 클라우드를 거칩니다" + - **새 Outlook** — 설명 "메일이 Microsoft 클라우드를 거치는 Windows의 새 Outlook" 1. [설정](톱니) → [계정] → [메일 계정] → [계정 추가]를 누릅니다. - 2. 메일 주소를 넣고 [계속] → 공급자에서 「IMAP」을 고릅니다. - 3. [고급 설정 표시]를 누르고 받는 · 보내는 서버에 위 설정값을, 비밀번호 칸 둘 다에 앱 비밀번호를 넣습니다. - 4. [계속]을 누르면 메일을 Microsoft 클라우드에 동기화한다는 안내가 나옵니다 — 위 주의를 읽고 정합니다. - - **썬더버드** — 설명 "Windows · Mac — 주소와 앱 비밀번호만 넣으면 됩니다" + 2. 메일 주소를 넣고 [계속]을 누른 뒤 공급자에서 [IMAP]을 고릅니다. + 3. [고급 설정 표시]를 누르고 받는 서버와 보내는 서버에 위 설정값을, 비밀번호 칸 두 곳에 모두 앱 비밀번호를 넣습니다. + 4. [계속]을 누르면 메일을 Microsoft 클라우드에 동기화한다는 안내가 나옵니다. 위의 주의 글을 읽고 정해 주세요. + - **썬더버드** — 설명 "Windows와 Mac에서 주소와 앱 비밀번호만 넣으면 됩니다" 1. 썬더버드에서 [≡] → [새로 만들기] → [기존 메일 계정]을 누릅니다. - 2. 이름 · 메일 주소 · 비밀번호(앱 비밀번호)를 넣고 [계속]을 누릅니다. - 3. 메일 주소와 앱 비밀번호만 넣으면 설정을 스스로 찾습니다 — 「IMAP」이 보이면 [완료]를 누릅니다. + 2. 이름과 메일 주소를 넣고, 비밀번호 칸에 앱 비밀번호를 넣은 뒤 [계속]을 누릅니다. + 3. 메일 주소와 앱 비밀번호만 넣으면 설정을 스스로 찾습니다. [IMAP]이 보이면 [완료]를 누릅니다. 4. 찾지 못하면 [수동 설정]에서 위 설정값을 넣습니다. - **iPhone · iPad** — 설명 "기본 메일 앱" - 1. [설정] → [메일] → [계정] → [계정 추가] → [기타] → [메일 계정 추가]를 누릅니다. (iOS 18 은 [설정] → [앱] → [메일]) - 2. 이름 · 메일 주소 · 암호(앱 비밀번호) · 설명을 넣고 [다음]을 누릅니다. - 3. 「IMAP」을 고르고, 받는 · 보내는 메일 서버의 호스트 이름에 **mx1.arasystem.it.kr**, 사용자 이름에 메일 주소 전체, 암호에 앱 비밀번호를 넣습니다 — 보내는 메일 서버도 「선택 사항」이라 보여도 비우지 않습니다. - 4. [다음] → [저장]을 누릅니다. 포트는 스스로 993 · 465 로 맞춥니다. - - **안드로이드** — 설명 "Gmail 앱 — 다른 계정(IMAP) · 삼성 이메일 앱도 같은 값" + 1. [설정] → [메일] → [계정] → [계정 추가] → [기타] → [메일 계정 추가]를 누릅니다. iOS 18에서는 [설정] → [앱] → [메일]에서 시작합니다. + 2. 이름, 메일 주소, 설명을 넣고, 암호 칸에 앱 비밀번호를 넣은 뒤 [다음]을 누릅니다. + 3. [IMAP]을 고르고, 받는 메일 서버와 보내는 메일 서버의 호스트 이름에 **mx1.arasystem.it.kr**, 사용자 이름에 메일 주소 전체, 암호에 앱 비밀번호를 넣습니다. 보내는 메일 서버 칸이 선택 사항으로 보여도 꼭 채워 주세요. + 4. [다음] → [저장]을 누릅니다. 포트는 받는 메일 993, 보내는 메일 465로 저절로 맞춰집니다. + - **안드로이드** — 설명 "Gmail 앱 기준이며, 삼성 이메일 앱 같은 다른 메일 앱도 같은 값을 넣습니다" 1. Gmail 앱에서 프로필 사진 → [다른 계정 추가] → [기타]를 누릅니다. - 2. 메일 주소를 넣고 [수동 설정] → 「개인(IMAP)」을 고릅니다. + 2. 메일 주소를 넣고 [수동 설정] → [개인(IMAP)]을 고릅니다. 3. 비밀번호에 앱 비밀번호를 넣습니다. - 4. 받는 서버 · 보내는 서버에 **mx1.arasystem.it.kr**, 포트는 993 · 465, 보안 유형은 「SSL/TLS」를 넣고 [다음]을 누릅니다. -- **아래 안내 상자**(#F3F8F8 · 가운뎃점 줄): "연결이 안 되면 사용자 이름에 메일 주소 전체를, 비밀번호 칸에 앱 비밀번호(띄어쓰기 없이 40자)를 넣었는지 확인해 주세요." · - "POP3 로는 연결할 수 없습니다 — 계정 유형은 IMAP 을 골라 주세요." · "웹메일 비밀번호를 바꾸면 앱 비밀번호도 모두 지워집니다 — 프로그램마다 새로 만들어 넣어 주세요." · "그래도 안 되면 관리자에게 문의해 주세요." + 4. 받는 서버와 보내는 서버는 **mx1.arasystem.it.kr**로 넣습니다. 포트는 받는 서버 993, 보내는 서버 465이고, 보안 유형은 [SSL/TLS]를 고른 뒤 [다음]을 누릅니다. +- **아래 안내 상자**(#F3F8F8 · 가운뎃점 줄): "연결이 안 되면 사용자 이름에 메일 주소 전체를 넣었는지, 비밀번호 칸에 앱 비밀번호 40자를 띄어쓰기 없이 넣었는지 확인해 주세요." · + "POP3로는 연결할 수 없으니 계정 유형은 IMAP을 골라 주세요." · "웹메일 비밀번호를 바꾸면 앱 비밀번호도 모두 지워지니, 프로그램마다 새로 만들어 넣어 주세요." · "그래도 안 되면 관리자에게 문의해 주세요." - **좁은 폭**: 설정 메뉴가 콘텐츠 위로 올라간다(지금 설정 셸의 줄바꿈). 설정값 상자는 이름(12px 회색) 위 · 값 아래 한 칸으로. 펼침 줄의 이름 · 설명이 줄을 바꿔도 줄 전체가 누르는 곳이다. - **앱 비밀번호 화면(12단계)에 더하는 것**: 설정값 상자 바로 아래 한 줄(12.5px · #4A5363) "프로그램별 연결 방법은 메일 프로그램 연결에서 볼 수 있습니다." — 「메일 프로그램 연결」이 이 화면 링크다. 12단계 보드는 고치지 않았다. - **전 직원 전환 때**: 썬더버드의 "설정을 스스로 찾습니다"와 아웃룩 자동 설정은 autoconfig · autodiscover 주소와 SRV 레코드를 낸 뒤부터 된다(인프라 — `mail/README.md`) — @@ -915,13 +944,13 @@ API 와 서버 규칙은 계약 2절 "실시간 알림". - **알림 메일 — 공통** - 서버가 그 직원의 받은편지함에 바로 넣는다(계약 6절 — 안 읽음 · 받은 시각은 지금 · 실시간 알림과 바탕화면 알림이 간다, 필터 · 스팸 판정은 거치지 않는다). 넣지 못해도 원래 일은 그대로 끝난다. - 보낸 사람 `ARA SYSTEM >`, 받는 사람 그 직원(`이름 <주소>`). 목록 · 읽기 화면은 다른 메일과 같다 — 보낸 사람 「ARA SYSTEM」, 아바타 "A", 뱃지 · 아이콘으로 따로 가르지 않는다. - - **링크를 넣지 않는다** — 직원에게 "회사 보안 알림에는 링크가 없다"는 습관을 주어 링크 달린 피싱 메일이 눈에 띄게 한다. 할 일은 메뉴 경로(웹메일 › 설정 › …)를 굵은 글로 적고, 맨 아래 한 줄로도 알린다. + - **링크를 넣지 않는다** — 직원에게 "회사 보안 알림에는 링크가 없다"는 습관을 주어 링크 달린 피싱 메일이 눈에 띄게 한다. 할 일은 메뉴 경로(웹메일의 [설정] → …)를 굵은 글로 적고, 맨 아래 한 줄로도 알린다. 웹메일 안의 알림 단추(2단계 인증을 켠 뒤의 [앱 비밀번호 만들기] · 11단계 위험 파일 알림의 [대용량 첨부 보기])는 메일이 아니라 그대로 둔다. - 본문 차례: 인사 한 줄 → 표 → 할 일(메뉴 경로 글) → 문의 줄 → 맨 아래 한 줄. HTML 과 같은 글의 텍스트 본문을 함께 넣는다. - 자리 표시: `{이름}` 직원의 표시 이름(없으면 주소의 @ 앞) · `{시각}` · `{올린 때}` KST "2026년 10월 30일 (금) 오후 3:20" · `{파일 이름}` 올린 이름 그대로 · `{크기}` 11단계 크기 표기("48.2 MB") · `{연락처}` 문의 연락처 설정값. 이름 · 파일 이름 · 연락처는 HTML 본문에 넣을 때 이스케이프한다. - 문의 줄: `{확인 문장}` 다음 줄에 `{연락처}`. 연락처를 정하지 않았으면 그 줄을(HTML 은 앞의 `
` 와 함께) 뺀다. - - 맨 아래 한 줄(세 통 같음): "이 메일은 ARA SYSTEM 이 자동으로 보낸 알림입니다. 회사 보안 알림에는 링크가 없습니다 — 비슷한 메일의 링크는 누르지 마세요." + - 맨 아래 한 줄(세 통 같음): "이 메일은 ARA SYSTEM이 자동으로 보낸 알림입니다. 회사 보안 알림에는 링크가 없으니, 비슷한 메일에 링크가 있다면 누르지 않도록 주의해 주세요." - HTML 꼴(세 통 같음 — 보내는 메일 정화 `sanitize-outgoing-html.ts` 의 허용 목록 안이고 감싸는 div 는 보내는 메일과 같은 기본 글꼴 · 14px · 1.75, 표의 줄은 칸마다 같은 꼴을 되풀이한다): ```html @@ -936,9 +965,9 @@ API 와 서버 규칙은 계약 2절 "실시간 알림".

할 일

-

{할 일 — 메뉴 경로는 웹메일 › 설정 › …}

+

{할 일 — 메뉴 경로는 웹메일의 [설정] → …}

{확인 문장}
{연락처}

-

이 메일은 ARA SYSTEM 이 자동으로 보낸 알림입니다. 회사 보안 알림에는 링크가 없습니다 — 비슷한 메일의 링크는 누르지 마세요.

+

이 메일은 ARA SYSTEM이 자동으로 보낸 알림입니다. 회사 보안 알림에는 링크가 없으니, 비슷한 메일에 링크가 있다면 누르지 않도록 주의해 주세요.

``` @@ -955,28 +984,28 @@ API 와 서버 규칙은 계약 2절 "실시간 알림". {확인 문장} {연락처} - 이 메일은 ARA SYSTEM 이 자동으로 보낸 알림입니다. 회사 보안 알림에는 링크가 없습니다 — 비슷한 메일의 링크는 누르지 마세요. + 이 메일은 ARA SYSTEM이 자동으로 보낸 알림입니다. 회사 보안 알림에는 링크가 없으니, 비슷한 메일에 링크가 있다면 누르지 않도록 주의해 주세요. ``` - **① 관리자가 비밀번호를 재설정했습니다** — 운영 도구의 「비밀번호 재설정」 뒤. - 제목 "관리자가 비밀번호를 재설정했습니다" - - 인사 "{이름} 님, 관리자가 이 계정의 비밀번호를 임시 비밀번호로 바꿨습니다." - - 표: 시각 — `{시각}` / 바뀐 것 — "비밀번호 — 임시 비밀번호로 바뀌었고, 처음 로그인할 때 새 비밀번호를 정합니다" / 로그아웃 — "모든 기기 · 브라우저의 웹메일 로그인을 끊었습니다" / - 앱 비밀번호 — "모두 지웠습니다 — 메일 프로그램은 새 앱 비밀번호로 다시 연결합니다" - - 할 일 "아웃룩 · 휴대폰 메일 앱을 쓰면 메일 프로그램마다 앱 비밀번호가 필요합니다. **웹메일 › 설정 › 계정 › 앱 비밀번호**에서 새로 만들어 주세요." + - 인사 "{이름} 님, 관리자가 비밀번호를 임시 비밀번호로 바꿨습니다." + - 표: 시각 — `{시각}` / 바뀐 것 — "비밀번호가 임시 비밀번호로 바뀌었고, 처음 로그인할 때 새 비밀번호를 정하게 됩니다" / 로그아웃 — "모든 기기와 브라우저의 웹메일에서 로그아웃되었습니다" / + 앱 비밀번호 — "모두 지워져서 메일 프로그램은 새 앱 비밀번호로 다시 연결해야 합니다" + - 할 일 "아웃룩이나 휴대폰 메일 앱을 쓰면 메일 프로그램마다 앱 비밀번호가 필요합니다. 웹메일의 **[설정] → [앱 비밀번호]**에서 새로 만들어 주세요." - 확인 문장 "직접 요청한 일이 아니면 바로 IT 담당에게 알려 주세요." - **② 관리자가 2단계 인증을 껐습니다** — 운영 도구의 「2단계 해제」(휴대폰 분실) 뒤. - 제목 "관리자가 2단계 인증을 껐습니다" - - 인사 "{이름} 님, 휴대폰을 잃어버렸거나 인증 앱을 쓸 수 없다고 알려 주셔서 관리자가 이 계정의 2단계 인증을 껐습니다." - - 표: 시각 — `{시각}` / 바뀐 것 — "2단계 인증 꺼짐 — 지금은 비밀번호만으로 로그인합니다" / 그대로인 것 — "비밀번호 · 앱 비밀번호 · 로그인된 브라우저" - - 할 일 "새 휴대폰에 인증 앱을 설치한 뒤 **웹메일 › 설정 › 계정 › 2단계 인증**에서 다시 켜 주세요. 회사가 2단계 인증을 필수로 정했다면 다음 로그인 때 켜는 화면이 먼저 나옵니다." + - 인사 "{이름} 님, 휴대폰을 잃어버렸거나 인증 앱을 쓸 수 없다고 알려 주셔서 관리자가 2단계 인증을 껐습니다." + - 표: 시각 — `{시각}` / 바뀐 것 — "2단계 인증이 꺼져서 지금은 비밀번호만으로 로그인합니다" / 그대로인 것 — "비밀번호, 앱 비밀번호, 로그인된 브라우저" + - 할 일 "새 휴대폰에 인증 앱을 설치한 뒤 웹메일의 **[설정] → [2단계 인증]**에서 다시 켜 주세요. 회사가 2단계 인증을 필수로 정했다면 다음에 로그인할 때 켜는 화면이 먼저 나옵니다." - 확인 문장 "직접 요청한 일이 아니면 바로 IT 담당에게 알려 주세요." - **③ 올린 대용량 파일에서 위험한 내용이 발견되었습니다** — 악성 검사가 위험으로 판정했을 때, 그 파일을 올린 직원에게(파일마다 한 통). - 제목 "올린 대용량 파일에서 위험한 내용이 발견되었습니다" - 인사 "{이름} 님, 올린 대용량 파일을 보안 검사했더니 위험한 내용이 발견되어 보내거나 내려받을 수 없게 막았습니다." - - 표: 파일 — `{파일 이름}` / 크기 — `{크기}` / 올린 때 — `{올린 때}` / 조치 — "이 파일만 막았습니다 — 함께 올린 다른 파일은 그대로 보내고 받을 수 있습니다" - - 할 일 "직접 만든 파일이 아니면 PC 를 백신으로 검사해 주세요. 다른 곳에서 받은 파일이면 보낸 사람에게도 알려 주세요. 막은 파일은 **웹메일 › 설정 › 메일 › 대용량 첨부**에서 볼 수 있습니다." - - 확인 문장 "판정이 잘못되었다고 생각되면 IT 담당에게 알려 주세요." + - 표: 파일 — `{파일 이름}` / 크기 — `{크기}` / 올린 때 — `{올린 때}` / 조치 — "이 파일만 막았고, 함께 올린 다른 파일은 그대로 보내고 받을 수 있습니다" + - 할 일 "직접 만든 파일이 아니면 PC를 백신으로 검사해 주세요. 다른 곳에서 받은 파일이면 보낸 사람에게도 알려 주세요. 막은 파일은 웹메일의 **[설정] → [대용량 첨부]**에서 볼 수 있습니다." + - 확인 문장 "검사 결과가 잘못되었다고 생각되면 IT 담당에게 알려 주세요." - 웹메일을 열어 둔 동안의 빨간 알림(11단계)은 그대로 둔다 — 이 메일은 그때 열려 있지 않았던 직원도 알게 한다. - **휴지통 · 스팸메일함 — 30일 줄**: - 목록 머리(52) 바로 아래 회색 줄(바탕 #F8F9FB · 아래 선 #EEF0F3 · 위아래 8 · 12.5px #4A5363 · 시계 아이콘 14 #667085): @@ -999,6 +1028,52 @@ API 와 서버 규칙은 계약 2절 "실시간 알림". 비밀번호 스스로 재설정(복구 연락처가 필요하다), 새 기기 로그인 알림 메일, 잠긴 계정만의 안내 글, 공용 주소로 보내기 · 공유 메일함. 시안에서 뺀 것: 알림 메일의 링크 단추(넣지 않는다 — 위 규칙), 유예 노란 줄의 닫기, 맥 메일 · 휴대폰 Outlook 앱의 연결 차례, 프로그램별 화면 그림. +## 14단계 — 검색 · 대화 묶기 (2026-11-05 확정 — `Search14` · `Thread14` · `ListThreads14`) + +자체 검색 색인으로 붙여 쓴 말 · 앞부분 · 제목 맨 앞의 말머리까지 찾고(부분 일치), 조건 검색과 검색어 강조를 더한다. +같은 대화(답장 · 전달로 이어진 메일)를 읽기 화면에 늘 보이고, 목록은 사용자가 켜면 대화로 묶는다. +까닭은 엔진 실측(2026-11-03)이다 — 엔진 스레드는 `회신:` · `답장:` · `전달:` 을 떼지 않아 한국어 Outlook 의 답장이 따로 떨어지고, 엔진 검색은 어절이 통째로 같을 때만 맞는다. +데이터 · API 규칙(색인 · 대화 · 조건)은 구현 때 계약 2절에 더한다. 구현 · 배포는 전환 뒤 안정화(1~2주) 다음이다. +새 토큰은 없다 — 조건 칸 40 은 필터 조건 칸의 밀도 예외(`--size-filter-control`), [대화로 묶기] 36 은 안내 줄 단추의 밀도 예외(`--size-notice-action`)를 다시 쓴다. +문구는 위 「화면 문구 — 사용자에게 말하듯」을 따른다. + +### 검색 · 조건 검색 · 강조 (`Search14`) + +- **찾는 방식**: 서버의 자체 색인(지금 DB — 글자 두 개씩 묶은 색인)에서 부분 일치로 찾는다. "계약"으로 계약서 · 계약직 · 계약은 · 계약을, 제목 맨 앞의 [공지] · Re: 까지 찾는다. + 여러 낱말은 모두 맞아야 하고 큰따옴표는 구절이다(지금과 같다). 영문은 대소문자를 가리지 않는다. 끝이 바뀐 말('보내' → '보냈습니다')과 첨부 파일 안의 글은 찾지 않는다(첨부 이름은 찾는다). +- **[조건]**: 검색 칸 안 오른쪽(✕ 앞)의 단추. 누르면 칸 바로 아래에 「조건으로 찾기」 패널(검색 칸 폭)이 열린다(aria-expanded). + - 칸: 보낸 사람 · 받는 사람(참조도) · 제목 · 내용 · 기간(전체 기간 · 최근 1주 · 최근 1달 · 최근 3달 · 최근 1년 · 날짜 고르기 → 시작 ~ 끝) · 첨부(체크 "첨부 파일이 있는 메일만"). 칸 높이 40. + - 아래 안내 "검색 칸의 말과 이 조건에 모두 맞는 메일을 찾습니다. 받는 사람에는 참조로 받은 사람도 들어갑니다." 단추 [조건 지우기] · [찾기]. + - 패널이 열려 있거나 조건이 걸려 있으면 [조건] 단추가 청록 바탕이다. 검색 칸이 비어도 조건만으로 찾을 수 있다. +- **조건이 걸린 결과**: 찾을 곳 줄 아래 한 줄 "조건: 이도윤 님이 보낸 메일, 최근 1달, 첨부 있음" + [조건 지우기]. 조건은 쉼표로 잇는다(꼬리표 · 알약을 쓰지 않는다). +- **찾을 곳**은 지금과 같다(그 메일함 | 전체 메일함 — 전체는 휴지통과 스팸메일함을 뺀다). 조건도 주소에 싣는다 — 이름은 구현 때 계약에서 정하고, 검색어처럼 로그에 남기지 않는다. +- **결과 줄**(높이 64, 세 줄): 보낸 사람 · 시각 / 제목(+ 첨부 표시) / 미리 보기 한 줄(맞은 말 둘레). 제목 · 미리 보기의 맞은 말은 ``(노랑 `#FCE7A4` · 글자색은 그대로). +- **읽기 화면 강조**: 제목 · 본문 · 첨부 이름의 맞은 말. 본문은 서버가 정화한 HTML 의 글 조각에만 `` 를 넣는다(찾기와 같은 정규화 · 부분 일치). 맞은 곳 사이 이동(다음 · 이전)은 이번에 넣지 않는다. +- **검색 준비 중**: 지난 메일의 색인을 만드는 동안 그 직원의 검색은 지금 방식(엔진) 그대로이고, 결과 머리 아래 안내 줄(옅은 청록 바탕 · role=status) + "지난 메일을 검색할 수 있게 준비하고 있습니다. 준비가 끝날 때까지는 낱말이 통째로 같은 메일만 찾습니다." +- **결과 없음**: "‘계약’에 맞는 메일이 없습니다" / "다른 말로 찾거나 조건을 줄여 보세요." + [전체 메일함에서 찾기](그 메일함에서 찾을 때만). 지금의 한계 안내("낱말 단위로 찾습니다 …")는 색인이 준비된 뒤에는 뺀다. +- 검색 결과는 대화로 묶지 않는다(한 통씩). + +### 읽기 화면 — 같은 대화 (`Thread14`) + +- 본문 · 첨부 아래 "같은 대화 N통". 오래된 차례로 테두리 상자 안의 줄(높이 48): ▸ · 머리글자 · 이름(내가 보냈으면 "나" + 회색 "보낸편지함") · 미리 보기 한 줄 · 시각. +- 지금 보는 메일의 줄은 옅은 청록 바탕에 "지금 보고 있는 메일"이고 누를 수 없다. 다른 줄을 누르면 그 자리에서 펼쳐 본문과 [답장] · [이 메일만 열기]가 보이고(aria-expanded), 다시 누르면 접는다. +- 대화에 드는 메일: 보낸편지함을 포함한 모든 메일함 — 휴지통과 스팸메일함은 뺀다. 한 통뿐이면 이 칸이 없다. +- **같은 대화의 기준**(우리 규칙 — 엔진보다 넓다): 답장 관계(References · In-Reply-To)가 있고 말머리를 뗀 제목이 같을 때. + 떼는 말머리는 Re · RE · Fw · FW · Fwd · AW · WG · **회신 · 답장 · 전달** · 대괄호 꼬리표이고 여러 겹도 뗀다. 제목이 다르면 묶지 않는다(엔진과 같다 — 남의 메일이 잘못 붙지 않게). + 원본보다 늦게 색인된 메일은 들어온 뒤 합친다. 색인을 다 만들기 전 그 직원은 엔진의 스레드 그대로다. + +### 목록 — 대화로 묶어 보기 (`ListThreads14`) + +- 목록 머리 오른쪽의 [대화로 묶기](눌림 단추 — aria-pressed, 높이 36)와 설정 › 메일 › 보내기 · 읽기의 같은 켬/끔 한 줄. 사용자마다 서버에 저장한다(다른 기기에서도 같다). **기본은 끔**이다 — 전환 직후라 보던 목록을 바꾸지 않는다. +- 켜면 한 줄이 그 메일함의 대화 하나다: 참여자(이름을 쉼표로 — 내가 보냈으면 "나") · 통 수("4통" — 회색 글, 뱃지 · 알약 없음, 한 통이면 없음) · 마지막 시각 / 마지막 메일 제목(+ 첨부 표시). 하나라도 안 읽었으면 굵게. +- 통 수는 대화 전체(보낸편지함 포함 · 휴지통과 스팸 빼고)이고, 고르기 · 읽음 · 중요 · 이동 · 삭제는 그 메일함 안에 있는 그 대화의 메일 모두에 한다. +- 줄을 누르면 그 메일함의 마지막 메일을 열고, 아래에 같은 대화 칸이 보인다. 목록 머리 글은 켰을 때 "대화 6개, 안 읽은 메일 2통", 껐을 때 "메일 9통, 안 읽은 메일 2통" 꼴이다. + +### 처음 판에서 미룬 것 + +본문 강조의 다음 · 이전 이동, 검색어 줄임말(from: 같은 꼴), 대화째 끌어다 놓기, 첨부 파일 안의 글 찾기, 끝이 바뀐 말 찾기(형태소 분석). + ## 구현하며 정한 보완 (2026-10-26 — 캔버스에는 아직 없다) - 찾지 못한 첨부(보관 시간이 지난 업로드·지워진 원본)는 실패로 표시하고, 전달 원본에는 "빼기"만 둔다(다시 올릴 수 없다). @@ -1084,7 +1159,7 @@ API 와 서버 규칙은 계약 2절 "실시간 알림". - 보던 메일함을 지우면 받은편지함으로 간다. 비우기 · 지우기가 끝나면 초점은 메일함 패널의 지금 메일함(지웠으면 받은편지함)으로 간다 — 누른 단추가 사라지므로. - **오래 걸리는 처리** - 하는 중의 글: "‘거래처’ 메일함을 지우는 중 · 12 / 40통" · "휴지통을 비우는 중 · 600 / 1,234통" (빈 메일함 지우기처럼 셀 메일이 없으면 수를 뺀다). - - 도중에 실패하면 "1,234통 가운데 600통까지 처리했습니다 — 다시 시도해 주세요."로 바뀐다(까닭은 오류 알림으로 함께). 하나도 못 했으면 오류 알림만 남는다. + - 도중에 실패하면 "1,234통 가운데 600통까지 처리하고 멈췄습니다. 다시 시도해 주세요."로 바뀐다(까닭은 오류 알림으로 함께). 하나도 못 했으면 오류 알림만 남는다. - 다른 탭에서 먼저 끝냈으면 실패로 보이지 않는다 — "받은편지함에 안 읽은 메일이 없습니다." · "휴지통이 이미 비어 있습니다.", 이미 없는 메일함 지우기는 지운 것으로 본다. - 같은 메일함의 처리는 한 번에 하나다(다른 탭에서 같은 메일함을 처리하는 중이면 "요청이 너무 많습니다"). 진행 알림은 로그아웃 · 세션 만료 때 닫힌다. - **휴지통을 비우는 동안은 메일함을 지울 수 없고, 메일함을 지우는 동안은 휴지통을 비울 수 없다** — 비우기는 그동안 휴지통에 들어온 메일도 지우므로, @@ -1101,7 +1176,7 @@ API 와 서버 규칙은 계약 2절 "실시간 알림". - **설정 › 알림** - 허락은 했는데 알림을 꺼 둔 상태: 칸은 비우고 안내는 초록 "이 브라우저에서 알림이 허용되어 있습니다."(보드에 없는 상태). - 실시간을 쓸 수 없는 브라우저(Web Locks · BroadcastChannel · 스트림 읽기 가운데 하나라도 없음)는 바탕화면 알림도 "지원하지 않습니다"로 본다 — 알림은 실시간으로만 온다. - - 연결 상태 글: 처음 잇는 중 "연결하는 중입니다…", 끊김 "연결할 수 없어 1분마다 확인합니다." 아래에 "저절로 다시 잇습니다. 회사 방화벽 · 프록시가 오래 열린 연결을 끊으면 이렇게 됩니다.", + - 연결 상태 글: 처음 잇는 중 "연결하는 중입니다…", 끊김 "연결할 수 없어 1분마다 확인합니다." 아래에 "저절로 다시 연결합니다. 회사 방화벽이나 프록시가 오래 열린 연결을 끊으면 이렇게 될 수 있습니다.", 지원하지 않음 "이 브라우저는 실시간 연결을 지원하지 않습니다." - 주소는 `/settings/mail/notify`, 탭 제목은 "메일 알림"(머리글은 "알림"). - 켜기 칸에서 허락을 받은 결과는 칸 아래 안내로만 보인다(화면 알림은 목록 위 권하기 줄에서만 — 설정 화면에서 [설정 열기]는 어색하다). @@ -1123,7 +1198,7 @@ API 와 서버 규칙은 계약 2절 "실시간 알림". ### 발송 · 부재중 결함 수정 (6단계 앞, 2026-10-28 — 캔버스에는 없다) - **받는 사람 일부가 받지 못했을 때**(없는 주소 · 받는 쪽 거절 · 지금은 받을 수 없음): 보낸 것으로 처리해 쓰기 화면을 닫고, "보냈습니다" 알림 대신 오류 알림 모양(흰 카드)에 - "보냈지만 N명은 받지 못했습니다 — 주소 (까닭)"와 [보낸편지함 보기](보냈다는 알림과 같은 링크) — 두 명까지 주소 · 까닭을 적고 나머지는 " 외 N명". 까닭 문구 "없는 주소" · "받는 쪽이 거절" · "지금은 받을 수 없음". + "보냈지만 N명은 받지 못했습니다. 주소 (까닭)"와 [보낸편지함 보기](보냈다는 알림과 같은 링크) — 두 명까지 주소 · 까닭을 적고 나머지는 " 외 N명". 까닭 문구 "없는 주소" · "받는 쪽이 거절" · "지금은 받을 수 없음". - **모두 받지 못했을 때**: 보내지 않은 것이다 — 없는 주소 · 거절(`MAIL_010`)은 받는 사람 오류(`MAIL_002`)와 같은 안내 띠 · 같은 초점으로, 지금은 받을 수 없음(`MAIL_011`)은 오류 알림으로. 쓰던 메일은 그대로 둔다. - **부재중 자동 응답 길이**: 제목 150자 · 내용 600자(입력칸 한도(maxlength)도 이 값, 켤 때 검사 — 꺼 둔 채로는 4단계 한도까지 저장된다) — 엔진이 바이트로 막아 한글로는 4단계의 200자 · 4,000자가 저장되지 않았다(계약 2절 "부재중 자동 응답"). @@ -1152,10 +1227,10 @@ API 와 서버 규칙은 계약 2절 "실시간 알림". - **화면 폭**: 공용 설정 틀 그대로 최대 800 · 묶음 사이 22(보드는 880 · 18 — 다른 설정 화면과 맞춘다). 머리의 [새 필터]는 틀의 `actions` 자리. 안내 상자 700, 창 620. - **메일함**: 받아 둔 메일함 목록을 보이며 뒤에서 다시 받는다(다른 탭에서 지웠을 수 있다). 요약에 메일함 이름이 들어가 필터와 메일함을 둘 다 받은 뒤 목록을 그린다. 옮길 곳 고르기는 받은편지함 → 내 메일함 → **보관함** → 스팸메일함 → 휴지통(보관함과 그 아래 — 보드에 없었다. 서버가 받고, mailcow 에서 옮겨 온 계정에 있다). - 지워진 메일함은 "(지워진 메일함)…"이 꺼진 채 골라져 보이고, 그대로 저장해도 막지 않는다(서버가 받는다 — 받은편지함에 들어온다). + 지워진 메일함은 "지워진 메일함이니 …"가 꺼진 채 골라져 보이고, 그대로 저장해도 막지 않는다(서버가 받는다 — 받은편지함에 들어온다). - **초점**: 조건 더하기 → 새 줄의 "어디에서", 조건 빼기 → 이웃 줄의 찾을 글, 필터 지우기 → 다음 행(마지막이면 앞 행), 목록 위 [다시 불러오기] · [다시 켜기] · `?from=` 으로 연 창의 [취소] · 이미 없는 필터 지우기 → 머리의 [새 필터], 창 안 [다시 불러오기] → [저장]. -- **그 밖**: [다시 켜기]가 성공해도 알림은 없다(줄이 사라진다). 목록 아래 줄은 보드 글 그대로 "… 행을 누르면 고칩니다."까지. +- **그 밖**: [다시 켜기]가 성공해도 알림은 없다(줄이 사라진다). 목록 아래 줄 끝은 "… 줄을 누르면 고칠 수 있습니다."(2026-11-05 — 보드의 글은 "… 행을 누르면 고칩니다."). 전달 칸은 `type=email` · 254자이고, 보기 글(받는 사람 칸 · 전달 칸)은 로그인한 직원 주소의 도메인으로 만든다. 저장하는 사이 화면을 떠나면 칸 안에 보일 거절을 알림으로 띄운다(부재중 화면과 같다). 목록에 `aria-busy` 를 걸지 않는다(칸마다 `aria-disabled`). - **하지 않은 것**: 고치기 창에 쓰다 만 채로 떠날 때 묻기(나가기 확인) — 보드에 없다. 창을 연 채 브라우저 뒤로 가기를 하면 쓴 것을 잃는다(알려진 한계 — 7단계에서 고쳤다, 위 "7단계"). @@ -1171,7 +1246,7 @@ API 와 서버 규칙은 계약 2절 "실시간 알림". - **실패 알림**: 요청은 인터셉터 알림을 끄고 보내고, 응답이 온 때 세션이 그대로일 때만 사전 문구로 알린다(세션 만료 대화상자는 그대로) — 그래서 처음 요청부터 실패하면 사전 문구 알림과 진행 알림의 결과 "필터는 저장했지만 …"가 함께 보인다(4단계 오래 걸리는 처리와 같다). 다른 창 · PC 에서 적용 중(429)은 결과 문구 하나만. 도중의 429 는 이어 할 자리가 있으니 [다시 시도]를 붙인다(처음 요청의 429 는 보드처럼 단추 없음). -- **이 탭에서 적용 중에 [다시 시도]**: 시작하지 않고 "다른 필터를 받은편지함에 적용하는 중입니다. 끝난 뒤 다시 시도해 주세요." + 같은 자리 · 같은 수의 [다시 시도]. +- **이 탭에서 적용 중에 [다시 시도]**: 시작하지 않고 "다른 필터로 받은편지함을 정리하는 중입니다. 끝난 뒤 다시 시도해 주세요." + 같은 자리 · 같은 수의 [다시 시도]. - **나아가지 못한 응답**(확인 0 인데 이어 할 자리가 있다)이 세 번 이어지면 멈춘다(`MAIL_FILTER_RUN_MAX_STALLS`) — 같은 요청을 끝없이 보내지 않게. - **맞추기**: 끝났거나 이번에 나아간 뒤 멈췄으면 개수 · 사용량을 다시 받고, 보이는 목록이 받은편지함이나 옮긴 메일함이면 그 목록도 맞춘다(실시간 연결이 없는 탭에서 1분까지 옛 개수가 보이지 않게). - **닫기 확인과 고치기 창**: 브라우저가 Esc 를 거듭 누르면 고치기 창을 막지 못하고 스스로 닫는다 — [계속 고치기]면 창을 다시 띄우고 초점은 닫히기 전에 있던 칸으로 돌려준다. @@ -1240,22 +1315,22 @@ API 와 서버 규칙은 계약 2절 "실시간 알림". 이 PC 시계로 이미 지났으면 지금처럼 [보낸편지함 보기] 알림이다. 마감이 있는 알림은 마우스 · 초점으로 멈추지 않고, 알림이 넘쳐도 먼저 닫히지 않는다. 막대는 ``(aria-hidden)다. - 알림 단추는 한 번만 돈다(닫히는 전환 중에 두 번 눌러도 — `takeAction`). 같은 메일의 취소는 요청 하나로 묶는다. 초점이 든 알림이 닫히면 알림에 들어오기 전 요소로(없으면 본문 영역) 돌려준다. - 쓰기 화면으로 가지 못하면(그 화면의 나가기 확인에서 머묾 등) 그 자리에 두고 "보내기를 취소하고 임시보관함으로 옮겼습니다." + [고치기]. - - 일부 거절 문장은 붙잡은 발송이면 "받지 못합니다"(현재형)다. 예약에서 일부 거절: "예약했지만 N명은 받지 못합니다 — 주소 (까닭)" + [예약됨 보기]. + - 일부 거절 문장은 붙잡은 발송이면 "받지 못합니다"(현재형)다. 예약에서 일부 거절: "예약했지만 N명은 받지 못합니다. 주소 (까닭)" + [예약됨 보기]. - **[보내기 ▾]**: ▾ 이름 "보내기 방법 더 보기", 메뉴 이름 "보내기 방법". 메뉴는 아래가 모자라면 위로 연다(`usePopupPlacement` — 메일함 ⋯ 과 같은 규칙). 「예약 발송…」은 [보내기]와 같은 사전 검사를 먼저 한다(걸리면 창을 열지 않고 그 칸으로). - **예약 창**: 「오늘 오후」 6:00 은 5분 뒤 이후일 때만, 「다음 주 월요일」은 오늘 다음의 첫 월요일(오늘이 월요일이면 7일 뒤)이다. 예약할 수 없는 시각은 빼고, 같은 시각이 겹치면 앞의 것만 둔다. - 직접 고르기는 오늘 ~ 7일 뒤 · 30분 단위, 처음 값은 내일 오전 9:00, 검사는 [예약]을 누를 때(role=alert). 시간대 이름을 모르면 "이 PC 의 시간으로 보냅니다 · 7일 안에서 고를 수 있습니다.". + 직접 고르기는 오늘 ~ 7일 뒤 · 30분 단위, 처음 값은 내일 오전 9:00, 검사는 [예약]을 누를 때(role=alert). 시간대 이름을 모르면 "이 PC의 시간으로 보내고, 7일 안에서 고를 수 있습니다.". 창이 열린 동안 보낼 수 없게 되거나 세션이 바뀌면 닫는다. 서버가 `sendAt` 을 `VAL_001` 로 거절하면 창을 다시 열어 그 시각을 「직접 고르기」에 채우고 까닭(5분 · 7일 문구)을 보인다 — 이 화면의 검사로는 맞는 시각(PC 시계가 어긋남)이면 가까운 한도로 본다. 쓰기 화면을 이미 떠났으면 알림으로. - **「예약됨」**: 수는 `GET /scheduled-messages?limit=1` 의 total 이다 — 메일 화면에 들어올 때 · 예약하거나 취소한 뒤 · 새 메일 확인이나 실시간 알림이 바뀜을 알릴 때 다시 묻는다. 예약됨을 보는 동안은 0통이어도 패널 줄을 남긴다(숫자 없이). 패널 줄은 보낸편지함(과 그 하위) 바로 아래이고 ⋯ 는 없다. - 목록: 분할 보기 2줄(받는 사람 · 시각 / 제목), 목록 보기 한 줄 세 칸(받는 사람 150 · 제목 · 보낼 시각 150). 고르는 칸 · 별 · 여러 통 처리 · 비우기 · 알림 권하기 줄은 없다. - 빈 상태 "예약한 메일이 없습니다" / "쓰기 화면의 [보내기 ▾] › 예약 발송…으로 보낼 시각을 정할 수 있습니다.". + 빈 상태 "예약한 메일이 없습니다" / "메일을 쓸 때 [보내기 ▾] → [예약 발송…]으로 보낼 시각을 정할 수 있습니다.". - [고치기] · [예약 취소]는 하는 동안 두 단추를 함께 막고, 성공해 줄이 사라지면 초점을 옮긴다. [고치기] 뒤 "예약을 취소했습니다. 고친 뒤 다시 보내 주세요." — 쓰기 화면으로 가지 못하면 "예약을 취소하고 임시보관함으로 옮겼습니다." + [고치기]. [예약 취소] 뒤에는 삭제처럼 다음 메일(분할) · 목록(목록 보기)으로. - **엔진이 발송을 풀 때**: 새 메일 확인 · 실시간 알림의 상태는 바뀌지 않으므로(계약 2절) 보이는 목록 · 열린 메일 · 가장 이른 예약의 시각 + 2초에 셋을 알림 없이 다시 받고, 시각이 지난 채 남으면 5초마다 3번 더 본다(`usePendingSendRefresh`). 검색 결과는 기다리는 메일이 보일 때만, 열린 메일은 `pendingSend` 만 고친다. `MAIL_014` 를 받아도 바로 같은 셋을 다시 받는다. - **붙잡힌 메일(보내는 중 · 예약)** — 취소로만 보낸편지함을 떠난다(2026-10-29 리뷰, 계약 2절 `MAIL_015`): - - 목록(분할 · 표 · 검색)의 고르기 칸은 꺼지고(`aria-disabled`) title 이 "보내기를 기다리는 메일은 고를 수 없습니다 — 열어서 취소하거나 삭제해 주세요." — 하나 · 범위 · 모두 고르기가 건너뛰고 + - 목록(분할 · 표 · 검색)의 고르기 칸은 꺼지고(`aria-disabled`) title 이 "보내기를 기다리는 메일은 고를 수 없습니다. 메일을 열어 취소하거나 삭제해 주세요." — 하나 · 범위 · 모두 고르기가 건너뛰고 여러 통 처리에도 들지 않는다(고른 뒤 붙잡히면 고른 것에서 뺀다). 보낸편지함 표의 날짜 칸은 title 에도 같은 글. - 읽기 화면은 두 종류 모두 스팸 신고 · 이동 · ⋯ 의 차단 항목이 없다. [삭제]는 확인 "보내지 않은 메일을 삭제할까요?" / "보내지 않고 휴지통으로 옮깁니다." [삭제] [취소](처음 초점 「취소」) → 발송 취소 → 휴지통 → "보내지 않고 휴지통으로 옮겼습니다.". 창을 연 채 발송이 풀리면 창을 닫는다(보낸 메일의 영구 삭제 확인으로 바뀌지 않게). @@ -1270,11 +1345,11 @@ API 와 서버 규칙은 계약 2절 "실시간 알림". 도메인으로 막힌 보낸 사람의 「차단 풀기」는 그 도메인 차단을 푼다. 풀기가 404 면(이미 풀림) 알림 없이 푼 것으로 본다. 차단 · 풀기 뒤 세션이 바뀌었으면 옮기기 · 알림을 하지 않는다. - 조사는 `objectParticle` 로 — 영문 주소는 "을(를)", 따옴표 뒤에 붙여 쓴다(보드의 "‘주소’ 을" 과 다르다). 문구: 옮기지 않은 차단 "‘주소’을(를) 차단했습니다." · 되돌리기 뒤 "‘주소’ 차단을 풀고 메일을 되돌렸습니다."(옮기지 않았던 차단이면 "‘주소’ 차단을 풀었습니다.") · 풀기의 되돌리기 뒤 "‘주소’을(를) 다시 차단했습니다.". -- **설정 › 보내기 · 읽기**: 머리 설명 "고르는 대로 저장합니다. 이 설정은 어느 PC 에서나 같습니다.", "저장했습니다"는 머리 오른쪽(`SettingsPageFrame` 의 `title-aside` 슬롯). +- **설정 › 보내기 · 읽기**: 머리 설명 "고르는 대로 바로 저장됩니다. 이 설정은 어느 PC에서나 같습니다.", "저장했습니다"는 머리 오른쪽(`SettingsPageFrame` 의 `title-aside` 슬롯). 칸마다 저장은 한 번에 하나 — 보내는 동안 고른 값은 기억했다가 앞 저장이 끝난 뒤 보낸 값 · 서버의 답과 다르면 보낸다. "저장했습니다"는 다 끝나고 마지막이 성공했을 때. 끝내 실패하면 서버가 확인한 값으로 되돌린다. - **설정 › 차단한 주소**: 검사 차례는 형식 → 내 주소 · 사내 도메인 → 메일 서비스 도메인 → 이미 차단 → 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(원 + 빗금). @@ -1284,8 +1359,8 @@ API 와 서버 규칙은 계약 2절 "실시간 알림". - **올리기 차례**: 고르자마자 서버에 올리기를 모두 만든다(임시 저장본에 바로 실려 나가기 확인 글이 그대로 맞다). 조각은 파일 3개까지 함께 보내고, 나머지 줄은 오른쪽에 "차례를 기다리는 중"(처음 몇 초의 "시간 계산 중" 자리). - **끝내기**: 조각을 다 보냈으면 끝내기를 기다리는 동안 오른쪽 글은 "마무리하는 중"이다. 끝내기가 오래 진행 중이면 2 · 5 · 15 · 30초 간격으로 다시 묻고, 1분 넘게 그대로면 "오래 끊김"처럼 멈추고 [다시 시도]. -- **서버가 올리기를 잊었을 때(404)**: 멈춤 + "서버에서 올리던 파일을 찾을 수 없습니다 — 다시 시도하면 처음부터 올립니다." — [다시 시도]를 누를 때만 처음부터 올린다(사전 `RES_001` 문구로는 [다시 시도]의 뜻을 알 수 없어 화면 글로 둔다). - 고른 파일이 없는 줄(저장본에서 이어 온 것)이나 보내기의 `MAIL_016` 뒤 다시 물어 404 면 "서버에서 찾을 수 없는 파일입니다 — 빼고 다시 올려 주세요." +- **서버가 올리기를 잊었을 때(404)**: 멈춤 + "올리던 파일을 찾을 수 없습니다. [다시 시도]를 누르면 처음부터 올립니다." — [다시 시도]를 누를 때만 처음부터 올린다(사전 `RES_001` 문구로는 [다시 시도]의 뜻을 알 수 없어 화면 글로 둔다). + 고른 파일이 없는 줄(저장본에서 이어 온 것)이나 보내기의 `MAIL_016` 뒤 다시 물어 404 면 "파일을 찾을 수 없습니다. 빼고 다시 올려 주세요." - **서버 거절 문구**: 서버가 준 코드(`FILE_001` · `FILE_002` · `FILE_005` · `FILE_006` · `VAL_001` · `FILE_003`)는 사전 문구다. 위 "올리기 전 검사"의 화면 글은 서버에 묻기 전(크기 · 개수 · 보관량)에만 쓴다 — "지금은 서버에 공간이 모자라 …"는 쓰지 않고 `FILE_006` 사전 문구. - **「링크로 보내기」**: 대용량 사전 검사(개수 · 보관량)를 먼저 보고 통과할 때만 일반 첨부에서 뺀다. 걸리면 첨부는 그대로 두고 그 글을 오류 알림으로(보관량이면 + [대용량 첨부 보기]). diff --git a/docs/design/webmail/Search14.dc.html b/docs/design/webmail/Search14.dc.html new file mode 100644 index 0000000..56386d9 --- /dev/null +++ b/docs/design/webmail/Search14.dc.html @@ -0,0 +1,458 @@ + + + + +메일 검색 · 조건 검색 · 강조 + + + + + + + +
+ + + +
+ +
+

메일

+
+ + + + + + + + +
+

조건으로 찾기

+
+ + + + + + + + + +
+ + + + + + + + +
+ 첨부 + +
+

검색 칸의 말과 이 조건에 모두 맞는 메일을 찾습니다. 받는 사람에는 참조로 받은 사람도 들어갑니다.

+
+ + +
+
+
+
+
+ +
+ 김아라 + ara.kim@arasystem.it.kr +
+ + + 로그아웃 + +
+
+ +
+ + + +
+ +
+
+ + + +

검색 결과

+ {{ countText }} +
+ +
+ 찾을 곳 +
+ + +
+ 휴지통과 스팸메일함은 빼고 찾습니다. +
+ + +
+ {{ condSummary }} + +
+
+ + +
+ + 지난 메일을 검색할 수 있게 준비하고 있습니다. 준비가 끝날 때까지는 낱말이 통째로 같은 메일만 찾습니다. +
+
+ + +
    + +
  • + +
  • +
    +
+
+ + +
+ +

‘계약’에 맞는 메일이 없습니다

+

다른 말로 찾거나 조건을 줄여 보세요.

+ + + +
+
+
+ +
+ +
+ +
+

{{ p.t }}{{ p.t }}

+
+ +
+
+ {{ m.fromName }} + {{ m.fromEmail }} + {{ m.date }} +
+ 받는 사람 {{ m.to }} +
+
+
{{ p.t }}{{ p.t }}
+ +
+

첨부 파일 {{ m.attCount }}개

+
    + +
  • + +
    + {{ p.t }}{{ p.t }} + {{ att.size }} +
    + +
  • +
    +
+
+
+
+
+
+ + +
+ +

메일을 골라 주세요

+

검색 결과에서 메일을 고르면 여기에 내용이 보입니다.

+
+
+
+
+
+
+
+
+ + + diff --git a/docs/design/webmail/Thread14.dc.html b/docs/design/webmail/Thread14.dc.html new file mode 100644 index 0000000..f5630fe --- /dev/null +++ b/docs/design/webmail/Thread14.dc.html @@ -0,0 +1,304 @@ + + + + +읽기 화면 · 같은 대화 + + + + + + + +
+ + + +
+ +
+

메일

+
+ + +
+
+ +
+ 김아라 + ara.kim@arasystem.it.kr +
+ + + 로그아웃 + +
+
+ +
+ + + +
+ +
+
+

받은편지함

+ 안 읽은 메일 2통 + + + 대화로 묶기 + +
+ +
+ +
+
+ +
+

Re: 유지보수 계약 갱신 건

+
+ +
+
+ 이도윤 + doyun.lee@arasystem.it.kr + 2026. 11. 5. (목) 오전 10:42 +
+ 받는 사람 김아라 <ara.kim@arasystem.it.kr> +
+
+
김아라님, 안녕하세요. + +말씀하신 조건으로 계약서 초안을 고쳐 첨부했습니다. +기간은 1년이고, 단가는 작년과 같습니다. + +이번 주 안에 회신 주시면 다음 주 월요일에 서명하겠습니다. + +이도윤 드림
+
+

첨부 파일 1개

+
+ +
+ 유지보수_계약서_수정본.docx + 52 KB +
+ +
+
+ + +
+

같은 대화 {{ convCount }}통

+
    + +
  1. + +
    + + {{ c.who }} + 지금 보고 있는 메일 + {{ c.date }} +
    +
    + + + +
    + 받는 사람 {{ c.to }} + 제목 {{ c.subject }} +
    {{ c.body }}
    + +
    +
    +
    +
  2. +
    +
+
+
+
+
+
+
+
+
+
+
+ + + diff --git a/docs/design/webmail/canvas.json b/docs/design/webmail/canvas.json index 666046b..f136f0e 100644 --- a/docs/design/webmail/canvas.json +++ b/docs/design/webmail/canvas.json @@ -46,9 +46,12 @@ "LoginPasswordChange.dc.html": { "x": 0, "y": 25610, "w": 1440, "h": 2900, "title": "로그인 · 새 비밀번호 정하기" }, "LoginTwoFactorSetup.dc.html": { "x": 1520, "y": 25610, "w": 1440, "h": 4290, "title": "로그인 · 2단계 인증 켜기(필수)" }, "SettingsMailClients.dc.html": { "x": 3040, "y": 25610, "w": 1440, "h": 3600, "title": "설정 · 메일 프로그램 연결" }, - "MailNoticesCutover.dc.html": { "x": 4560, "y": 25610, "w": 1440, "h": 3780, "title": "알림 · 안내 모음(13단계)" } + "MailNoticesCutover.dc.html": { "x": 4560, "y": 25610, "w": 1440, "h": 3780, "title": "알림 · 안내 모음(13단계)" }, + "Search14.dc.html": { "x": 0, "y": 30500, "w": 1440, "h": 900, "title": "메일 검색 · 조건 검색 · 강조", "expand": "fill", "is_interactive": true }, + "Thread14.dc.html": { "x": 1520, "y": 30500, "w": 1440, "h": 900, "title": "읽기 화면 · 같은 대화", "expand": "fill", "is_interactive": true }, + "ListThreads14.dc.html": { "x": 3040, "y": 30500, "w": 1440, "h": 900, "title": "목록 · 대화로 묶어 보기", "expand": "fill", "is_interactive": true } }, - "order": ["Main.dc.html", "Login.dc.html", "Compose.dc.html", "States.dc.html", "MainList.dc.html", "MainListRead.dc.html", "ComposeEditor.dc.html", "Settings.dc.html", "ComposeAssist.dc.html", "Search.dc.html", "BulkSelect.dc.html", "ListStates.dc.html", "SettingsAccount.dc.html", "SettingsVacation.dc.html", "MailboxManage.dc.html", "MailActions4.dc.html", "SettingsNotify.dc.html", "MailRealtime.dc.html", "SettingsFilter.dc.html", "MailFilterStates.dc.html", "MailFilterApply.dc.html", "MailFilterApplyStates.dc.html", "Contacts.dc.html", "ContactsEdit.dc.html", "ContactsMail.dc.html", "MailReadTools.dc.html", "MailPrint.dc.html", "MailSendLater.dc.html", "SettingsSendRead.dc.html", "ComposeLargeFiles.dc.html", "LargeFileBlock.dc.html", "MailReadLargeFiles.dc.html", "SharePage.dc.html", "SettingsLargeFiles.dc.html", "LoginTwoFactor.dc.html", "SettingsTwoFactor.dc.html", "SettingsAppPasswords.dc.html", "SettingsLoginHistory.dc.html", "LoginPasswordChange.dc.html", "LoginTwoFactorSetup.dc.html", "SettingsMailClients.dc.html", "MailNoticesCutover.dc.html"], + "order": ["Main.dc.html", "Login.dc.html", "Compose.dc.html", "States.dc.html", "MainList.dc.html", "MainListRead.dc.html", "ComposeEditor.dc.html", "Settings.dc.html", "ComposeAssist.dc.html", "Search.dc.html", "BulkSelect.dc.html", "ListStates.dc.html", "SettingsAccount.dc.html", "SettingsVacation.dc.html", "MailboxManage.dc.html", "MailActions4.dc.html", "SettingsNotify.dc.html", "MailRealtime.dc.html", "SettingsFilter.dc.html", "MailFilterStates.dc.html", "MailFilterApply.dc.html", "MailFilterApplyStates.dc.html", "Contacts.dc.html", "ContactsEdit.dc.html", "ContactsMail.dc.html", "MailReadTools.dc.html", "MailPrint.dc.html", "MailSendLater.dc.html", "SettingsSendRead.dc.html", "ComposeLargeFiles.dc.html", "LargeFileBlock.dc.html", "MailReadLargeFiles.dc.html", "SharePage.dc.html", "SettingsLargeFiles.dc.html", "LoginTwoFactor.dc.html", "SettingsTwoFactor.dc.html", "SettingsAppPasswords.dc.html", "SettingsLoginHistory.dc.html", "LoginPasswordChange.dc.html", "LoginTwoFactorSetup.dc.html", "SettingsMailClients.dc.html", "MailNoticesCutover.dc.html", "Search14.dc.html", "Thread14.dc.html", "ListThreads14.dc.html"], "notes": { "flow": { "x": 0, "y": -300, "text": "arasystem 웹메일 — 1단계 화면 (로그인 → 메일함 → 쓰기)", "kind": "title1", "maxW": 4480 }, "phase2": { "x": 0, "y": 2180, "text": "2단계 화면 — 임시 저장 · 받는 사람 자동 완성 · 서명 (2026-10-27 확정)", "kind": "title1", "maxW": 2960 }, @@ -61,7 +64,8 @@ "phase9": { "x": 0, "y": 16520, "text": "9단계 화면 — 보내기 취소 · 예약 발송 · 외부 이미지 보기 · 인쇄 · 보낸 사람 차단 — 2026-10-29 확정", "kind": "title1", "maxW": 6000 }, "phase11": { "x": 0, "y": 18620, "text": "11단계 화면 — 대용량 첨부 (쓰기 · 본문 덩이 · 읽기 · 내려받기 화면 · 설정) — 2026-10-30 확정", "kind": "title1", "maxW": 7520 }, "phase12": { "x": 0, "y": 21580, "text": "12단계 화면 — 계정 보안(2단계 인증 · 앱 비밀번호 · 로그인 기록) — 2026-10-31 확정", "kind": "title1", "maxW": 6000 }, - "phase13": { "x": 0, "y": 25310, "text": "13단계 화면 — 전환 준비(새 비밀번호 · 2단계 필수 · 메일 프로그램 연결 · 알림) — 2026-10-31 확정", "kind": "title1", "maxW": 6000 } + "phase13": { "x": 0, "y": 25310, "text": "13단계 화면 — 전환 준비(새 비밀번호 · 2단계 필수 · 메일 프로그램 연결 · 알림) — 2026-10-31 확정", "kind": "title1", "maxW": 6000 }, + "phase14": { "x": 0, "y": 30200, "text": "14단계 화면 — 검색 · 조건 검색 · 강조 · 같은 대화 · 대화로 묶어 보기 — 2026-11-05 확정", "kind": "title1", "maxW": 4480 } }, "designSystems": [] } diff --git a/docs/mail/api-contract.md b/docs/mail/api-contract.md index fad4e35..8a3681a 100644 --- a/docs/mail/api-contract.md +++ b/docs/mail/api-contract.md @@ -397,7 +397,7 @@ interface RejectedRecipient { (조건을 붙이면 엔진이 그 한도를 연결이 아닌 MAIL FROM 단계로 옮겨 바깥 접속을 더는 세지 않는다 — Stalwart 0.16.27 소스로 확인). - **클라이언트** - 일부 거절 — 보낸 것으로 처리한다(쓰기 화면을 닫고 떠난다). 보냈다는 알림 대신 실패 알림(「보낸편지함 보기」 링크 — 보냈다는 알림과 같은 글, 단추 있는 알림과 같은 10초)으로 - "보냈지만 N명은 받지 못했습니다 — nobody@arasystem.it.kr (없는 주소)" — 두 명까지 주소와 까닭을 적고 더 있으면 " 외 N명". + "보냈지만 N명은 받지 못했습니다. nobody@arasystem.it.kr (없는 주소)" — 두 명까지 주소와 까닭을 적고 더 있으면 " 외 N명". 까닭 문구는 `not-found` "없는 주소" · `refused` "받는 쪽이 거절" · `temporary` "지금은 받을 수 없음". - `MAIL_010` — 받는 사람 칸의 안내 띠(`MAIL_002` 와 같은 자리 · 같은 초점)로만 알린다(토스트 없음). 쓰던 메일 · 키는 그대로. - `MAIL_011` — 인터셉터 알림(사전 문구). 쓰던 메일 · 키는 그대로 — 잠시 뒤 다시 누르면 같은 키로 간다. @@ -764,7 +764,7 @@ interface MailVacationState extends MailVacation { // GET · PUT 의 응답 (7 필터와 한 스크립트라서 부재중을 저장해도 스크립트를 다시 켠다(필터가 꺼져 있었으면 함께 다시 동작한다). 필터 규칙은 건드리지 않는다. - 6단계부터 바뀐 동작: 엔진이 스팸으로 분류한 메일에는 답하지 않는다(필터와 같은 스팸 막기 — "메일 필터"의 "만드는 Sieve"). - 우리 스크립트가 엔진에서 꺼져 있으면(`isActive: false`) 부재중도 동작하지 않는다 — `isEnabled` 는 저장한 값이라 그때도 `true` 다. - 7단계부터 응답의 `isActive` 로 알린다 — 화면은 켜 두었을 때 머리 아래 줄 "켜 둔 자동 응답이 지금은 동작하지 않습니다. 메일 서버의 규칙이 다른 곳에서 바뀌었습니다 — + 7단계부터 응답의 `isActive` 로 알린다 — 화면은 켜 두었을 때 머리 아래 줄 "다른 곳에서 메일 설정이 바뀌어 켜 둔 자동 응답이 지금은 나가지 않습니다. 아래 [저장]을 누르면 다시 켜집니다."(단추 없음 — 저장 단추는 늘 누를 수 있다. 6단계의 알려진 한계를 고쳤다). - 저장해 둔 원본이 깨진 계정은 꺼짐(`isEnabled: false`, 빈 제목 · 내용)으로 온다. 원본이 이 서버보다 새 판이면 저장은 503 `MAIL_001` 이다("메일 필터"의 "저장 자리"). - `fromDate` · `toDate` 는 `Z` 로 끝나는 UTC 시각이고, 두 키는 늘 보낸다(값은 null 가능). 엔진에는 초 단위로 저장한다(소수 초는 버린다). @@ -941,7 +941,7 @@ interface MailFilterActions { - `MAIL_012` — 목록 위 줄 "다른 곳에서 필터가 바뀌어 저장하지 않았습니다. 다시 불러온 뒤 고쳐 주세요." + [다시 불러오기]. 고치던 창은 닫지 않는다 — 창 안에도 같은 안내와 [다시 불러오기]를 둔다(모달이라 뒤의 줄을 누를 수 없다). 창에서 다시 불러오면 쓴 내용은 그대로이고, 다음 [저장]은 다시 받은 목록에 그 필터를 넣는다(같은 id 자리 — 그사이 다른 곳에서 지워졌으면 맨 아래). - - `isActive: false` — 목록 위 줄 "지금은 필터와 부재중 자동 응답이 동작하지 않습니다…" + [다시 켜기](지금 목록 그대로 저장). + - `isActive: false` — 목록 위 줄 "다른 곳에서 메일 설정이 바뀌어 지금은 필터와 부재중 자동 응답이 되지 않습니다." + [다시 켜기](지금 목록 그대로 저장). - 읽기 화면 ⋯ 「이런 메일로 필터 만들기」 → `/settings/mail/filters?from=<보낸 사람 주소>` — 새 필터 창이 "보낸 사람 · 포함 · 그 주소"로 채워져 열린다(초점은 할 일의 첫 칸). 50개를 다 만들었는지 알려고 ⋯ 메뉴를 열 때 `GET` 을 한다(받아 둔 목록이 없거나 50개일 때만 — 알림 없이). - 검사는 서버와 같은 규칙(이름 50 · 값 200 · 조건 5 · 규칙 50 · `${` · 제어 문자 · 할 일 하나 이상 · 전달은 사내 주소이고 자기 주소 아님) — 칸 아래에만 알린다. @@ -1017,11 +1017,12 @@ interface MailFilterRun { (응답 목록의 같은 id — 앞뒤 공백을 지운 값)으로 이 요청을 `cursor` 가 null 이 될 때까지 보낸다. - 칸을 끈다(칸 아래 설명이 까닭을 말한다): 이 탭에서 다른 적용이 진행 중, 그리고 **이미 받은 메일에 할 일이 없는 필터** — 읽음 · 중요 표시가 없고 옮기기도 없거나 받은편지함으로 · 지워진 메일함으로 옮기는 것뿐(까닭 문구는 지워진 메일함 → 받은편지함 → 전달만 차례로 고른다). 서버는 그런 규칙도 받는다(맞은 수만 센다). - - 진행 알림 "‘{이름}’ 필터를 받은편지함에 적용하는 중 · {확인} / {전체}통 확인"(확인 = scanned 합, 전체 = 확인 + remaining) → 끝나면 그 자리에서 - "‘{이름}’ 필터를 받은편지함의 메일 N통에 적용했습니다."(N = matched 합) + 옮기는 필터이고 1통 이상이면 [{메일함} 보기], 0통이면 "받은편지함에 ‘{이름}’ 필터에 맞는 메일이 없습니다." - - 멈춤: "‘{이름}’ 필터 — {전체}통 가운데 {확인}통까지 확인하고 멈췄습니다(N통에 적용). 다시 시도해 주세요." + [다시 시도](마지막 cursor 로 이어서). - 처음 요청부터 실패하면 "필터는 저장했지만 받은편지함에 적용하지 못했습니다. 다시 시도해 주세요." + [다시 시도](처음부터). - 429 는 "다른 창에서 필터를 받은편지함에 적용하는 중입니다. 끝난 뒤 다시 시도해 주세요." — 도중의 429 면 [다시 시도](이어서)도 붙인다. + - 진행 알림 "‘{이름}’ 필터로 받은편지함을 정리하는 중 · {확인} / {전체}통 확인"(확인 = scanned 합, 전체 = 확인 + remaining) → 끝나면 그 자리에서 + "‘{이름}’ 필터로 받은편지함의 메일 N통을 정리했습니다."(N = matched 합) + 옮기는 필터이고 1통 이상이면 [{메일함} 보기], 0통이면 "받은편지함에 ‘{이름}’ 필터에 맞는 메일이 없습니다." + - 멈춤: "‘{이름}’ 필터로 {전체}통 가운데 {확인}통까지 확인하고 멈췄습니다. 그동안 N통을 정리했습니다. 다시 시도해 주세요." + [다시 시도](마지막 cursor 로 이어서). + 처음 요청부터 실패하면 "필터는 저장했지만 받은편지함은 정리하지 못했습니다. 다시 시도해 주세요." + [다시 시도](처음부터). + 429 는 "다른 창에서 필터로 받은편지함을 정리하는 중입니다. 끝난 뒤 다시 시도해 주세요." — 도중의 429 면 [다시 시도](이어서)도 붙인다. + (문구는 2026-11-05 「화면 문구 — 사용자에게 말하듯」으로 고쳤다 — 화면 상수 `MAIL_FILTER_RUN_TEXT` 가 원본.) 확인 0 인데 `cursor` 가 있는 응답이 세 번 이어지면 멈춘다(같은 요청을 끝없이 보내지 않게 — 멈춤 문구와 같다). - 그동안 다른 화면으로 가도 이어 한다. 탭을 닫으면 멈춘다(이어 하지 않는다). 세션이 바뀌면(로그아웃 · 만료 · 다른 계정) 다음 요청을 보내지 않고 알림을 닫는다. - **고치기 창의 나가기 확인**(6단계 한계를 고친다): 창을 연 뒤 바꾼 것이 있으면 [취소] · Esc · 다른 화면으로 가기에 확인 — "저장하지 않은 필터가 있습니다" / @@ -1636,7 +1637,7 @@ interface FileUploadCreate { - 검사 상태 다시 묻기: 쓰기 화면 · 읽기 화면은 15 · 30 · 60초 간격 최대 30번, 공개 화면은 20번(공개 경로의 IP 한도 안). - 진행은 받은 바이트로 보인다("420MB / 1.2GB · 약 3분"), 멈춤 · 빼기. 빼면 `DELETE`(알림 없이 — 404 는 지운 것으로 본다). 쓰기 화면을 버리면(휴지통) 지우지 않는다 — 되돌리면 다시 쓴다. - **새로고침 · 창 닫힘 뒤 이어 올리기(처음 판에 넣는다)**: 올리는 중인 파일도 임시 저장본 머리글에 실린다(아래 "임시 저장") — 다시 열면 `uploading` 으로 오고 - "올리다 멈춘 파일 — 같은 파일을 다시 고르면 이어서 올립니다"(브라우저는 고른 파일을 스스로 다시 읽지 못한다). + "올리다 멈춤 · {N} 올림 · 같은 파일을 다시 고르면 이어서 올립니다"(브라우저는 고른 파일을 스스로 다시 읽지 못한다). 다시 고른 파일은 **이름 · 크기 · `lastModified` 가 같고 그 파일의 맨 앞 조각 SHA-256 이 `firstChunkSha256` 과 같을 때** 같은 파일로 보고 빠진 조각부터 잇는다. 다르면 새로 만든다(앞 올리기는 `DELETE`). 맨 앞 조각을 받기 전(`firstChunkSha256: null`)이면 이을 것이 없다 — 새로 만든다. 올리는 중은 마지막 조각 뒤 24시간 남는다 — 그 뒤에 열면 `MissingLargeFile`(`reason: 'expired'`)이라 다시 올린다. @@ -1691,8 +1692,9 @@ interface FileUploadCreate { - 보낼 때만 만든다(임시 저장본에는 넣지 않는다). 한 통에 하나 — 이번 발송의 새 링크(내 파일) 묶음이다. - **자리**: 서식 본문은 첫 경계 표시 자리, 텍스트는 `bodyQuoteStart` — 없으면 맨 끝. 그래서 **내 글 · 서명 아래, 인용 위**다. - **내용** — HTML 과 텍스트에 같은 내용(글 · 모양은 디자인 README "11단계", 구현하며 맞춘다): - 개수 · 합계 · 끝나는 날("2026년 11월 29일(일)까지 내려받을 수 있습니다 (한국 시간)"), 파일마다 이름 · 크기, 내려받기 주소(**주소 글자가 보이는 링크** — 단축 주소 없음), - "내려받을 때 로그인이나 비밀번호를 묻지 않습니다", 영어 한 줄("Large files — download by Nov 29, 2026 (KST)"). 받는 사람 · 제목은 넣지 않는다. + 개수 · 합계 · 끝나는 날("한국 시간으로 2026년 11월 29일(일)까지 내려받을 수 있습니다."), 파일마다 이름 · 크기, 내려받기 주소(**주소 글자가 보이는 링크** — 단축 주소 없음), + "내려받을 때 로그인이나 비밀번호를 묻지 않습니다", 영어 한 줄("Large files: download by Nov 29, 2026 (KST). No login or password needed."). 받는 사람 · 제목은 넣지 않는다. + (덩이의 글은 2026-11-05 「화면 문구 — 사용자에게 말하듯」으로 고쳤다 — 그 전에 붙잡아 둔 메일의 옛 글 덩이도 서버가 알아보고 뗀다: `large-file-block.ts` 의 `LEGACY_BLOCK_WORDINGS`.) 크기는 화면과 같은 표기(1024 단위 — `1.2 GB` · `612 MB`, 짝: frontend `shared/utils/format.ts` 의 `formatFileSize`), 날짜는 한국 날짜. - **HTML 모양**: 보내는 정화의 허용 목록 안의 태그 · style 값으로만 만든다(2절 "서식 본문" — div · p · span · a · br · table, 색 hex · 여백 px · 글자 8~99px …) — 다시 읽을 때 · 답장 · 전달의 인용 · 보내기 취소로 되돌릴 때도 모양이 남게. 그림 · 뱃지 · 알약 없이 테두리 상자와 글자. @@ -1980,8 +1982,8 @@ 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_007` | 410 | 더 내려받을 수 없는 파일 — 기간 지남 · 공유 멈춤 · 횟수 초과 · 위험 · 찾을 수 없음 | 공개 받기 · 우리 웹메일 받기 | "이 파일은 더 내려받을 수 없습니다. 필요하면 보낸 사람에게 문의해 주세요." | @@ -2335,7 +2337,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개까지 만들 수 있습니다. 쓰지 않는 앱 비밀번호를 지운 뒤 만들어 주세요." — 엔진 상한을 바꾸면 이 문구와 화면 상수도 | @@ -2494,7 +2496,7 @@ interface FileUploadCreate { - 위험 파일 판정 — 11단계에서 미룬 것. 그 파일을 올린 직원에게 보낸다. - 넣기에 실패해도 원래 일은 성공으로 둔다(warn 로그). - 본문은 짧은 HTML(보내는 정화 허용 목록 안) + 같은 글의 텍스트다. 제목 · 문구는 디자인 README 13단계를 그대로 쓴다. -- **링크를 넣지 않는다** — 할 일은 메뉴 경로를 글로 적는다(「웹메일 › 설정 › 계정 › 앱 비밀번호」 등). 직원에게 "회사 보안 알림에는 링크가 없다"는 습관을 주어 링크 달린 피싱 메일이 눈에 띄게 한다. +- **링크를 넣지 않는다** — 할 일은 메뉴 경로를 글로 적는다("웹메일의 [설정] → [앱 비밀번호]" 등 — 화면 글과 같은 꼴). 직원에게 "회사 보안 알림에는 링크가 없다"는 습관을 주어 링크 달린 피싱 메일이 눈에 띄게 한다. - 위험 파일 알림은 파일마다 한 번 — 위험 판정을 처음 기록할 때만 보낸다. ### 운영 도구 (서버 안 명령) diff --git a/flutter/lib/core/api/error_lexicon.g.dart b/flutter/lib/core/api/error_lexicon.g.dart index d8894c4..d1bf2e2 100644 --- a/flutter/lib/core/api/error_lexicon.g.dart +++ b/flutter/lib/core/api/error_lexicon.g.dart @@ -8,23 +8,23 @@ const Map kErrorCodeMessages = { 'AUTH_003': '해당 메뉴나 기능에 접근할 수 있는 권한이 없습니다.', 'AUTH_004': '이메일 또는 비밀번호가 올바르지 않습니다.', 'AUTH_005': '지금 비밀번호가 올바르지 않습니다.', - 'AUTH_006': '코드가 올바르지 않습니다. 휴대폰의 날짜와 시간이 「자동」으로 맞춰져 있는지 확인해 주세요.', + 'AUTH_006': '코드가 올바르지 않습니다. 휴대폰의 날짜와 시간이 자동으로 맞춰지도록 설정되어 있는지 확인해 주세요.', 'AUTH_007': '인증 앱의 코드를 입력해 주세요.', 'AUTH_008': '2단계 인증 설정이 그사이 바뀌었거나 시간이 지났습니다. 처음부터 다시 해 주세요.', 'AUTH_009': '5개까지 만들 수 있습니다. 쓰지 않는 앱 비밀번호를 지운 뒤 만들어 주세요.', 'AUTH_010': '먼저 비밀번호를 바꾸거나 2단계 인증을 켜 주세요.', - 'VAL_001': '입력하신 정보를 다시 확인해 주세요. (필수값 누락 또는 형식 오류)', + 'VAL_001': '입력하신 내용을 다시 확인해 주세요. 빠진 칸이 있거나 형식이나 길이가 맞지 않습니다.', 'RES_001': '요청하신 정보나 페이지를 찾을 수 없습니다.', 'BIZ_001': '요청하신 작업을 완료하지 못했습니다. 다시 시도해 주세요.', 'FILE_001': '허용되지 않는 파일 형식입니다. 파일을 확인해 주세요.', 'FILE_002': '파일 크기가 허용 범위를 초과했습니다.', - 'FILE_003': '파일 올리기 상태가 맞지 않습니다. 다시 시도해 주세요.', - 'FILE_004': '파일 조각이 보내는 중에 손상되었습니다. 다시 시도해 주세요.', + 'FILE_003': '파일 상태가 그사이 바뀌어 처리하지 못했습니다. 다시 시도해 주세요.', + 'FILE_004': '파일을 올리는 중에 일부가 제대로 전달되지 않았습니다. 다시 시도해 주세요.', 'FILE_005': '대용량 첨부 보관 한도를 넘어 더 올릴 수 없습니다. 보낸 파일의 링크를 끊거나 보내지 않은 파일을 지우면 공간이 생깁니다.', 'FILE_006': '서버의 저장 공간이 모자라 지금은 파일을 올릴 수 없습니다. 잠시 뒤 다시 시도해 주세요.', 'FILE_007': '이 파일은 더 내려받을 수 없습니다. 필요하면 보낸 사람에게 문의해 주세요.', 'FILE_008': '보안 검사가 끝나지 않은 파일입니다. 잠시 뒤 다시 시도해 주세요.', - 'MAIL_001': '메일 서버와 연결할 수 없습니다. 잠시 후 다시 시도해 주세요.', + 'MAIL_001': '메일 서비스에 연결할 수 없습니다. 잠시 후 다시 시도해 주세요.', 'MAIL_002': '받는 사람 주소를 다시 확인해 주세요.', 'MAIL_003': '메일함 용량이 부족합니다. 불필요한 메일을 정리한 뒤 다시 시도해 주세요.', 'MAIL_004': '첨부 파일을 찾을 수 없습니다. 해당 첨부를 빼거나 다시 올린 뒤 보내 주세요.', @@ -36,12 +36,12 @@ const Map kErrorCodeMessages = { 'MAIL_010': '받는 사람 주소로 메일을 보낼 수 없습니다. 주소를 확인해 주세요.', 'MAIL_011': '받는 쪽이 지금은 메일을 받을 수 없습니다. 잠시 뒤 다시 보내 주세요.', 'MAIL_012': '다른 곳에서 필터가 바뀌어 저장하지 않았습니다. 다시 불러온 뒤 고쳐 주세요.', - 'MAIL_013': '필터나 차단 목록이 너무 길어 저장할 수 없습니다. 조건을 줄이거나 쓰지 않는 필터 · 차단 주소를 지워 주세요.', + 'MAIL_013': '필터나 차단 목록이 너무 길어 저장할 수 없습니다. 조건을 줄이거나, 쓰지 않는 필터나 차단한 주소를 지워 주세요.', 'MAIL_014': '이미 보낸 메일이라 취소할 수 없습니다.', 'MAIL_015': '보내기를 기다리는 메일은 옮기거나 지울 수 없습니다. 먼저 보내기를 취소해 주세요.', 'MAIL_016': '대용량 첨부 파일을 보낼 수 없습니다. 해당 파일을 빼거나 다시 올린 뒤 보내 주세요.', 'CONTACT_001': '같은 이름의 그룹이 있습니다. 다른 이름을 써 주세요.', - 'CONTACT_002': '주소록 한도를 넘어 더 넣을 수 없습니다. (연락처 3,000개 · 그룹 100개 · 그룹 하나에 500명까지)', + 'CONTACT_002': '주소록 한도를 넘어 더 넣을 수 없습니다. 연락처 3,000개, 그룹 100개, 그룹 하나에 500명까지 넣을 수 있습니다.', }; /// 매핑 실패 시 사용하는 폴백 코드 diff --git a/frontend/src/components/contacts/ContactDialog.spec.ts b/frontend/src/components/contacts/ContactDialog.spec.ts index b4bffaf..40ca319 100644 --- a/frontend/src/components/contacts/ContactDialog.spec.ts +++ b/frontend/src/components/contacts/ContactDialog.spec.ts @@ -154,8 +154,8 @@ describe('ContactDialog', () => { '집', '기타', ]) - expect(wrapper.text()).toContain('5개까지 · 받는 사람 칸에는 맨 위 주소가 먼저 나옵니다') - expect(wrapper.get('#contact-required').text()).toBe('이름이나 이메일 가운데 하나는 있어야 합니다.') + expect(wrapper.text()).toContain('5개까지 넣을 수 있고, 받는 사람 칸에는 맨 위 주소가 먼저 나옵니다.') + expect(wrapper.get('#contact-required').text()).toBe('이름이나 이메일 가운데 하나는 넣어 주세요.') expect(wrapper.get('[data-contact-note-count]').text()).toBe('0 / 1,000') expect(wrapper.findAll('[data-contact-group]').map((input) => input.attributes('data-contact-group'))).toEqual([ 'g1', @@ -224,7 +224,7 @@ describe('ContactDialog', () => { expect(email?.attributes('type')).toBe('text') const addEmail = wrapper.findAll('.entry-rows__add-button')[0] expect(wrapper.get(`#${addEmail?.attributes('aria-describedby')}`).text()).toBe( - '5개까지 · 받는 사람 칸에는 맨 위 주소가 먼저 나옵니다', + '5개까지 넣을 수 있고, 받는 사람 칸에는 맨 위 주소가 먼저 나옵니다.', ) }) @@ -342,7 +342,7 @@ describe('ContactDialog', () => { expect(search?.config.silent).toBe(true) const warning = wrapper.get('[data-contact-duplicate]') expect(warning.text()).toBe( - '이미 주소록에 있는 주소입니다 — 강서윤(거래처). 그래도 저장할 수 있습니다. 그 연락처 보기', + '주소록에 있는 강서윤(거래처)의 주소와 같습니다. 그래도 저장할 수 있습니다. 그 연락처 보기', ) expect(email?.attributes('aria-describedby')).toBe(warning.attributes('id')) expect(email?.attributes('aria-invalid')).toBe('false') @@ -370,7 +370,7 @@ describe('ContactDialog', () => { ) expect((wrapper.get('#contact-name').element as HTMLInputElement).value).toBe('강서윤') - expect(wrapper.get('[data-contact-duplicate]').text()).toContain('이미 주소록에 있는 주소입니다 — 강서윤') + expect(wrapper.get('[data-contact-duplicate]').text()).toContain('주소록에 있는 강서윤') await wrapper.get('#contact-cancel').trigger('click') await flushPromises() expect(isOpen(wrapper, '#contact-leave-dialog')).toBe(false) diff --git a/frontend/src/components/feedback/StateMessage.vue b/frontend/src/components/feedback/StateMessage.vue index 67965a1..a2da0de 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/ComposeScheduleDialog.spec.ts b/frontend/src/components/mail/ComposeScheduleDialog.spec.ts index 77e2b8b..ebf8bc5 100644 --- a/frontend/src/components/mail/ComposeScheduleDialog.spec.ts +++ b/frontend/src/components/mail/ComposeScheduleDialog.spec.ts @@ -57,7 +57,7 @@ describe('ComposeScheduleDialog', () => { expect(day.attributes()).toMatchObject({ min: '2026-10-29', max: '2026-11-05' }) expect((wrapper.get('[data-schedule-time]').element as HTMLSelectElement).value).toBe(String(9 * 60)) expect(wrapper.findAll('[data-schedule-time] option')).toHaveLength(48) - expect(wrapper.get('#compose-schedule-note').text()).toMatch(/^이 PC 의 시간(\(.+\))?으로 보냅니다 · 7일 안에서 고를 수 있습니다\.$/) + expect(wrapper.get('#compose-schedule-note').text()).toMatch(/^이 PC의 시간(\(.+\))?으로 보내고, 7일 안에서 고를 수 있습니다\.$/) expect(document.activeElement).toBe(wrapper.get('[data-schedule-confirm]').element) }) @@ -81,7 +81,7 @@ describe('ComposeScheduleDialog', () => { expect(wrapper.emitted('confirm')).toEqual([[local(10, 31, 14, 30)]]) }) - it('지금부터 5분 뒤 이전이면 "지금부터 5분 뒤 이후의 시각을 고르세요." — 칸이 그 글을 가리키고 넘기지 않는다', async () => { + it('지금부터 5분 뒤 이전이면 "5분 뒤보다 이른 시각은 고를 수 없습니다. 더 늦은 시각을 골라 주세요." — 칸이 그 글을 가리키고 넘기지 않는다', async () => { const { wrapper } = mountAt(local(10, 29, 15)) await wrapper.get('[data-schedule-day]').setValue('2026-10-29') @@ -89,7 +89,7 @@ describe('ComposeScheduleDialog', () => { await wrapper.get('[data-schedule-confirm]').trigger('click') const error = wrapper.get('#compose-schedule-error') - expect(error.text()).toBe('지금부터 5분 뒤 이후의 시각을 고르세요.') + expect(error.text()).toBe('5분 뒤보다 이른 시각은 고를 수 없습니다. 더 늦은 시각을 골라 주세요.') expect(error.attributes('role')).toBe('alert') expect(wrapper.get('[data-schedule-day]').attributes()).toMatchObject({ 'aria-invalid': 'true', @@ -120,7 +120,7 @@ describe('ComposeScheduleDialog', () => { clock.advance(6 * 60_000) await wrapper.get('[data-schedule-confirm]').trigger('click') - expect(wrapper.get('#compose-schedule-error').text()).toBe('지금부터 5분 뒤 이후의 시각을 고르세요.') + expect(wrapper.get('#compose-schedule-error').text()).toBe('5분 뒤보다 이른 시각은 고를 수 없습니다. 더 늦은 시각을 골라 주세요.') expect(wrapper.emitted('confirm')).toBeUndefined() }) diff --git a/frontend/src/components/mail/MailNotifyOffer.spec.ts b/frontend/src/components/mail/MailNotifyOffer.spec.ts index 19889b6..fc4379d 100644 --- a/frontend/src/components/mail/MailNotifyOffer.spec.ts +++ b/frontend/src/components/mail/MailNotifyOffer.spec.ts @@ -138,7 +138,7 @@ describe('MailNotifyOffer (바탕화면 알림 권하기 줄)', () => { expect(useToastStore().toasts).toMatchObject([ { kind: 'error', - message: '브라우저가 알림을 막고 있습니다. 푸는 방법은 설정 › 알림에 있습니다.', + message: '브라우저가 알림을 막고 있습니다. 푸는 방법은 [설정] → [알림]에서 볼 수 있습니다.', action: { label: '설정 열기', to: { name: 'settings-mail-notify' } }, }, ]) diff --git a/frontend/src/components/mail/MailSelectionToolbar.spec.ts b/frontend/src/components/mail/MailSelectionToolbar.spec.ts index 4cdfc21..ebb859b 100644 --- a/frontend/src/components/mail/MailSelectionToolbar.spec.ts +++ b/frontend/src/components/mail/MailSelectionToolbar.spec.ts @@ -401,7 +401,7 @@ describe('여러 통 처리 도구 줄', () => { expect(toastTexts()).toContain('메일 2통을 되돌렸습니다.') }) - it('스팸메일함에서는 「스팸 아님」(강조) — 받은편지함으로 · isJunk: false, "(스팸 아님)" 알림 · 되돌리면 스팸메일함으로 · isJunk: true', async () => { + it('스팸메일함에서는 「스팸 아님」(강조) — 받은편지함으로 · isJunk: false, "스팸이 아닌 메일로 표시해 …" 알림 · 되돌리면 스팸메일함으로 · isJunk: true', async () => { const junked = [summary('j1', { mailboxIds: ['junk'] }), summary('j2', { mailboxIds: ['junk'] })] const { wrapper, store } = await mountView({ boxId: 'junk', messages: junked }) store.toggleCheck('j1') @@ -415,7 +415,7 @@ describe('여러 통 처리 도구 줄', () => { expect(bulkPatches()).toEqual([{ ids: ['j1'], mailboxId: 'inbox', isJunk: false }]) const [toast] = useToastStore().toasts - expect(toast?.message).toBe('메일 1통을 받은편지함으로 옮겼습니다 (스팸 아님).') + expect(toast?.message).toBe('메일 1통을 스팸이 아닌 메일로 표시해 받은편지함으로 옮겼습니다.') if (toast?.action && 'run' in toast.action) toast.action.run() await flushPromises() expect(bulkPatches()[1]).toEqual({ ids: ['j1'], mailboxId: 'junk', isJunk: true }) diff --git a/frontend/src/components/mail/MailboxEmptyButton.spec.ts b/frontend/src/components/mail/MailboxEmptyButton.spec.ts index a84aeaf..76d79b1 100644 --- a/frontend/src/components/mail/MailboxEmptyButton.spec.ts +++ b/frontend/src/components/mail/MailboxEmptyButton.spec.ts @@ -111,7 +111,7 @@ describe('목록 머리의 휴지통 · 스팸메일함 비우기', () => { ]) expect(store.messages).toEqual([]) expect(useToastStore().toasts.map((toast) => [toast.message, toast.action])).toEqual([ - ['휴지통을 비웠습니다 — 메일 2통.', null], + ['메일 2통을 지워 휴지통을 비웠습니다.', null], ]) // 비었으니 단추도 사라진다 expect(wrapper.find('[data-mailbox-empty]').exists()).toBe(false) diff --git a/frontend/src/components/mail/MailboxPanel.spec.ts b/frontend/src/components/mail/MailboxPanel.spec.ts index cf28ea1..d00175c 100644 --- a/frontend/src/components/mail/MailboxPanel.spec.ts +++ b/frontend/src/components/mail/MailboxPanel.spec.ts @@ -216,7 +216,7 @@ describe('MailboxPanel — 메일함 관리', () => { const input = wrapper.get('#mailbox-name-input') expect(input.attributes('maxlength')).toBe('64') expect(document.activeElement).toBe(input.element) - expect(wrapper.get('#mailbox-name-help').text()).toBe('64자까지. 같은 자리에 같은 이름은 쓸 수 없습니다.') + expect(wrapper.get('#mailbox-name-help').text()).toBe('64자까지 쓸 수 있고, 같은 자리에 같은 이름은 쓸 수 없습니다.') // 이름을 비웠으면 누를 수 없다 expect(wrapper.get('#mailbox-name-confirm').attributes('disabled')).toBeDefined() @@ -262,7 +262,7 @@ describe('MailboxPanel — 메일함 관리', () => { expect(useToastStore().toasts).toEqual([]) await wrapper.get('#mailbox-name-input').setValue('거래처 2') - expect(wrapper.get('#mailbox-name-help').text()).toBe('64자까지. 같은 자리에 같은 이름은 쓸 수 없습니다.') + expect(wrapper.get('#mailbox-name-help').text()).toBe('64자까지 쓸 수 있고, 같은 자리에 같은 이름은 쓸 수 없습니다.') }) it('/ 가 든 이름은 보내지 않고 칸 아래에 사전 문구(VAL_001)', async () => { @@ -403,7 +403,7 @@ describe('MailboxPanel — 메일함 관리', () => { expect(row(wrapper, '받은편지함').find('.mailbox-row__count').exists()).toBe(false) }) - it('휴지통 비우기 — 확인(처음 초점 「취소」) 뒤 DELETE /mailboxes/{id}/messages 를 되풀이 · "휴지통을 비웠습니다 — 메일 21통."', async () => { + it('휴지통 비우기 — 확인(처음 초점 「취소」) 뒤 DELETE /mailboxes/{id}/messages 를 되풀이 · "메일 21통을 지워 휴지통을 비웠습니다."', async () => { reply('DELETE /mailboxes/trash/messages', ok({ processed: 20, remaining: 1 }), ok({ processed: 1, remaining: 0 })) const { wrapper } = await mountPanel() @@ -421,7 +421,7 @@ describe('MailboxPanel — 메일함 관리', () => { await wrapper.get('#mailbox-empty-confirm').trigger('click') await flushPromises() expect(sent('DELETE', '/mailboxes/trash/messages')).toHaveLength(2) - expect(toastTexts()).toEqual(['휴지통을 비웠습니다 — 메일 21통.']) + expect(toastTexts()).toEqual(['메일 21통을 지워 휴지통을 비웠습니다.']) // 되돌릴 수 없어 [되돌리기]가 없다 expect(useToastStore().toasts[0]?.action).toBeNull() }) @@ -433,7 +433,7 @@ describe('MailboxPanel — 메일함 관리', () => { await choose(wrapper, '받은편지함', '모두 읽음으로 표시') // 진행 알림은 그 자리에서 결과로 바뀐다 — 까닭(사전 문구 · 문의 코드)은 인터셉터의 알림 expect(useToastStore().toasts.map((toast) => [toast.kind, toast.message])).toEqual([ - ['error', '1,234통 가운데 500통까지 처리했습니다 — 다시 시도해 주세요.'], + ['error', '1,234통 가운데 500통까지 처리하고 멈췄습니다. 다시 시도해 주세요.'], ['error', ERROR_CODE_MESSAGES.MAIL_001], ]) diff --git a/frontend/src/components/mail/MessageAttachments.large-files.spec.ts b/frontend/src/components/mail/MessageAttachments.large-files.spec.ts index 99bd60f..c1e85ce 100644 --- a/frontend/src/components/mail/MessageAttachments.large-files.spec.ts +++ b/frontend/src/components/mail/MessageAttachments.large-files.spec.ts @@ -104,7 +104,7 @@ describe('MessageAttachments — 대용량 첨부', () => { expect(blocked.attributes('aria-disabled')).toBe('true') await blocked.trigger('click') await flushPromises() - expect(view.get('[role="status"]').text()).toBe('1.2 GB · 보안 검사 중 — 끝나면 받을 수 있습니다') + expect(view.get('[role="status"]').text()).toBe('1.2 GB · 보안 검사가 끝나면 받을 수 있습니다') expect(downloads).toEqual([]) expect(view.get('[data-large-file="f2"]').classes()).toContain('large-item--danger') @@ -127,7 +127,7 @@ describe('MessageAttachments — 대용량 첨부', () => { '612 MB · 아직 받지 않음 · 11월 29일까지', ]) expect(view.get('.link-bar__note').text()).toBe( - '링크로 보낸 파일 2개 — 받는 사람 모두 같은 링크로 받습니다. 끊으면 둘 다 받을 수 없습니다.', + '링크로 보낸 파일 2개는 받는 사람 모두 같은 링크로 받습니다. 끊으면 둘 다 받을 수 없습니다.', ) await view.get('[data-link-copy]').trigger('click') diff --git a/frontend/src/components/mail/MessageList.spec.ts b/frontend/src/components/mail/MessageList.spec.ts index e6dcc86..51e5d17 100644 --- a/frontend/src/components/mail/MessageList.spec.ts +++ b/frontend/src/components/mail/MessageList.spec.ts @@ -143,7 +143,7 @@ describe('MessageList (분할 보기 목록)', () => { const box = wrapper.findAll('.message-row [role="checkbox"]')[0] expect(box?.attributes('aria-disabled')).toBe('true') - expect(box?.attributes('title')).toBe('보내기를 기다리는 메일은 고를 수 없습니다 — 열어서 취소하거나 삭제해 주세요.') + expect(box?.attributes('title')).toBe('보내기를 기다리는 메일은 고를 수 없습니다. 메일을 열어 취소하거나 삭제해 주세요.') await box?.trigger('click') await wrapper.findAll('.message-row__main')[0]?.trigger('click', { metaKey: true }) await flushPromises() @@ -175,6 +175,6 @@ describe('MessageList (분할 보기 목록)', () => { // 머리 — 검색 결과 · ‘회의’ · 2통 · 범위 줄(전체일 때 안내) expect(wrapper.get('#message-list-title').text()).toBe('검색 결과') expect(wrapper.get('.search-header__count').text()).toBe('‘회의’ · 2통') - expect(wrapper.get('.search-scope').text()).toContain('휴지통 · 스팸메일함은 빼고 찾습니다') + expect(wrapper.get('.search-scope').text()).toContain('휴지통과 스팸메일함은 빼고 찾습니다') }) }) diff --git a/frontend/src/components/mail/MessageReader.spec.ts b/frontend/src/components/mail/MessageReader.spec.ts index 307918e..dd84415 100644 --- a/frontend/src/components/mail/MessageReader.spec.ts +++ b/frontend/src/components/mail/MessageReader.spec.ts @@ -596,7 +596,7 @@ describe('MessageReader 스팸 신고 · 원문 내려받기 (4단계 — 디자 expect(patches().at(-1)).toEqual({ ids: ['m1'], mailboxId: 'inbox', isJunk: false }) }) - it('스팸메일함의 메일은 「스팸 아님」(강조) — 받은편지함으로 · isJunk: false, "(스팸 아님)" 알림', async () => { + it('스팸메일함의 메일은 「스팸 아님」(강조) — 받은편지함으로 · isJunk: false, "스팸이 아닌 메일로 표시해 …" 알림', async () => { const { wrapper } = await mountWith({ box: 'junk' }) const notSpam = wrapper.get('[data-reader-spam]') expect(notSpam.text()).toBe('스팸 아님') @@ -606,7 +606,7 @@ describe('MessageReader 스팸 신고 · 원문 내려받기 (4단계 — 디자 await flushPromises() expect(patches()).toEqual([{ ids: ['m1'], mailboxId: 'inbox', isJunk: false }]) - expect(useToastStore().toasts[0]?.message).toBe('메일 1통을 받은편지함으로 옮겼습니다 (스팸 아님).') + expect(useToastStore().toasts[0]?.message).toBe('메일 1통을 스팸이 아닌 메일로 표시해 받은편지함으로 옮겼습니다.') }) it('스팸메일함을 모르면 「스팸 신고」를 그리지 않는다', async () => { diff --git a/frontend/src/components/mail/MessageReader.vue b/frontend/src/components/mail/MessageReader.vue index 88b22c8..5a6efb3 100644 --- a/frontend/src/components/mail/MessageReader.vue +++ b/frontend/src/components/mail/MessageReader.vue @@ -281,7 +281,7 @@ onBeforeUnmount(() => { { + it('그룹 이름을 치면 그룹 줄 — 아이콘 + 이름(맞는 글자 굵게) + "고르면 4명을 한꺼번에 넣습니다", 초성도 된다 · 구성원이 없는 그룹은 없다', async () => { const wrapper = mountField([], suggestionsOf([JUNHO], [VENDORS, EMPTY_GROUP])) await typed(wrapper, 'ㄱㄹ') @@ -483,7 +483,7 @@ describe('RecipientField', () => { expect(option.attributes('data-suggestion-group')).toBeDefined() expect(option.find('svg').exists()).toBe(true) expect(option.get('.recipient-suggestions__name strong').text()).toBe('거래') - expect(option.get('.recipient-suggestions__email').text()).toBe('4명 · 고르면 한꺼번에 넣습니다') + expect(option.get('.recipient-suggestions__email').text()).toBe('고르면 4명을 한꺼번에 넣습니다') wrapper.unmount() }) diff --git a/frontend/src/components/mail/TwoFactorGraceNotice.spec.ts b/frontend/src/components/mail/TwoFactorGraceNotice.spec.ts index bc9375d..95460e1 100644 --- a/frontend/src/components/mail/TwoFactorGraceNotice.spec.ts +++ b/frontend/src/components/mail/TwoFactorGraceNotice.spec.ts @@ -53,7 +53,7 @@ describe('TwoFactorGraceNotice', () => { const bar = wrapper.get('[data-two-factor-grace-notice]') expect(bar.attributes('role')).toBe('region') expect(bar.attributes('aria-label')).toBe('2단계 인증 안내') - expect(bar.text()).toBe('11월 30일까지 2단계 인증을 켜 주세요 — 그 뒤에는 켜야 로그인할 수 있습니다.지금 켜기') + expect(bar.text()).toBe('11월 30일까지 2단계 인증을 켜 주세요. 그 뒤에는 켜야 로그인할 수 있습니다.지금 켜기') expect(bar.get('strong').text()).toBe('11월 30일까지 2단계 인증을 켜 주세요') expect(bar.get('[data-two-factor-grace-action]').attributes('href')).toBe('/settings/account/two-factor') expect(bar.findAll('button')).toHaveLength(0) diff --git a/frontend/src/components/mail/editor/MailEditor.spec.ts b/frontend/src/components/mail/editor/MailEditor.spec.ts index 38b0cc9..3177442 100644 --- a/frontend/src/components/mail/editor/MailEditor.spec.ts +++ b/frontend/src/components/mail/editor/MailEditor.spec.ts @@ -50,7 +50,7 @@ describe('MailEditor (서식 편집기)', () => { expect(body.attributes('role')).toBe('textbox') expect(body.attributes('aria-label')).toBe('본문') expect(body.attributes('aria-multiline')).toBe('true') - expect(body.get('p').attributes('data-placeholder')).toBe('내용을 입력하세요') + expect(body.get('p').attributes('data-placeholder')).toBe('내용을 입력해 주세요') expect(view.get('[role="toolbar"]').attributes('aria-label')).toBe('본문 서식') expect(view.emitted('ready')).toHaveLength(1) }) diff --git a/frontend/src/components/mail/editor/signature/SignatureToolbar.vue b/frontend/src/components/mail/editor/signature/SignatureToolbar.vue index 50c82a7..7ba6263 100644 --- a/frontend/src/components/mail/editor/signature/SignatureToolbar.vue +++ b/frontend/src/components/mail/editor/signature/SignatureToolbar.vue @@ -256,7 +256,7 @@ defineExpose({ // 설정 › 대용량 첨부 › 보내지 않은 파일 (디자인 원본 SettingsLargeFiles — 사용량 상자 바로 아래: 공간을 바로 비울 수 있는 곳이라 보낸 링크보다 위) -// 「보내지 않은 파일 3개」 + "1.9 GB · 임시 저장한 메일에 실렸거나 올리다 멈춘 파일 — 지우면 바로 공간이 생깁니다". 없으면 쓰는 쪽이 묶음째 그리지 않는다. +// 「보내지 않은 파일 3개」 + "1.9 GB · 임시 저장한 메일에 실렸거나 올리다 멈춘 파일이라 지우면 바로 공간이 생깁니다". 없으면 쓰는 쪽이 묶음째 그리지 않는다. // 줄(52): 아이콘 · 이름 / 상태(올리다 멈춤 — 갈색 · 다 올림 · 검사 상태) · 크기(멈춘 파일은 "1.2 GB" / "420 MB 올림") · // 남는 기한("11월 29일까지 남음", 멈춘 파일은 "내일 오후 2:41까지 남음" / "마지막으로 올린 뒤 24시간") · [지우기](36, 빨간 글) // [지우기] → 확인 창(처음 초점 「취소」) → DELETE /file-uploads/{id} → "‘…’ 파일을 지웠습니다." — 줄이 사라지고 사용량이 바로 준다(스토어). diff --git a/frontend/src/components/settings/LoginEventTable.vue b/frontend/src/components/settings/LoginEventTable.vue index 70a4a4b..2105844 100644 --- a/frontend/src/components/settings/LoginEventTable.vue +++ b/frontend/src/components/settings/LoginEventTable.vue @@ -4,8 +4,8 @@ // 표(구현은 실제 table): 시각 · 방식 · IP · 기기 · 결과 — 최근 순서 · 20개씩 [더 보기](44) // - 방식: "웹메일" / "메일 프로그램 · IMAP" · "메일 프로그램 · SMTP" + 앱 비밀번호로 들어왔으면 아래 줄 "앱 비밀번호 ‘회사 PC 아웃룩’" // - 기기: 웹메일은 "Chrome · Windows"(모르면 "알 수 없는 브라우저"), 메일 프로그램은 「—」. 나라 · 지역은 보이지 않는다 -// - 결과: 「성공」 · 「성공 · 2단계 인증」 · 「실패 — 비밀번호 틀림」 · 「실패 — 코드 틀림」 · 「실패 — 앱 비밀번호 필요」 — 실패는 빨간 굵은 글자만(뱃지 없음) -// - 빈 상태(실패만): "최근 90일 동안 실패한 로그인이 없습니다" / "[전체]를 누르면 성공한 로그인도 봅니다." +// - 결과: 「성공」 · 「2단계 인증으로 성공」 · 「비밀번호가 틀려 실패」 · 「코드가 틀려 실패」 · 「앱 비밀번호가 필요해 실패」 — 실패는 빨간 굵은 글자만(뱃지 없음) +// - 빈 상태(실패만): "최근 90일 동안 실패한 로그인이 없습니다" / "[전체]를 누르면 성공한 로그인도 볼 수 있습니다." // - 보기를 바꾸면 그 보기로 처음부터 받는다 — 받는 동안 지난 목록은 그대로 두고 aria-busy (login-history.store) import { computed } from 'vue' diff --git a/frontend/src/composables/useMailFilterRun.spec.ts b/frontend/src/composables/useMailFilterRun.spec.ts index 7f5d403..df39300 100644 --- a/frontend/src/composables/useMailFilterRun.spec.ts +++ b/frontend/src/composables/useMailFilterRun.spec.ts @@ -118,19 +118,19 @@ describe('useMailFilterRun', () => { mailStore.listStatus = 'ready' } - it('진행 알림이 남았다가 끝나면 그 자리에서 결과로 — "…메일 37통에 적용했습니다." + [거래처 보기](그 메일함으로)', async () => { + it('진행 알림이 남았다가 끝나면 그 자리에서 결과로 — "…메일 37통을 정리했습니다." + [거래처 보기](그 메일함으로)', async () => { const first = deferred() const second = deferred() replies(first.promise, second.promise) expect(useMailFilterRun().start(rule())).toBe(true) // 첫 응답 전 — 전체는 받은편지함의 메일 수 - expect(shown()).toEqual([['progress', '‘거래처 메일’ 필터를 받은편지함에 적용하는 중 · 0 / 8,412통 확인']]) + expect(shown()).toEqual([['progress', '‘거래처 메일’ 필터로 받은편지함을 정리하는 중 · 0 / 8,412통 확인']]) const id = useToastStore().toasts[0]?.id first.finish(step(4500, 30, 3912, 'c1')) await flushPromises() - expect(shown()).toEqual([['progress', '‘거래처 메일’ 필터를 받은편지함에 적용하는 중 · 4,500 / 8,412통 확인']]) + expect(shown()).toEqual([['progress', '‘거래처 메일’ 필터로 받은편지함을 정리하는 중 · 4,500 / 8,412통 확인']]) second.finish(step(3912, 7, 0, null)) await flushPromises() @@ -139,16 +139,16 @@ describe('useMailFilterRun', () => { expect(toast).toMatchObject({ id, kind: 'success', - message: '‘거래처 메일’ 필터를 받은편지함의 메일 37통에 적용했습니다.', + message: '‘거래처 메일’ 필터로 받은편지함의 메일 37통을 정리했습니다.', action: { label: '거래처 보기', to: { name: 'mail-mailbox', params: { mailboxId: 'vendor' } } }, }) expect(cursors()).toEqual([null, 'c1']) }) it.each([ - ['읽음 · 중요만 하는 필터', rule({ name: '결재 알림', actions: { ...NO_ACTIONS, markAsRead: true, flag: true } }), 12, '‘결재 알림’ 필터를 받은편지함의 메일 12통에 적용했습니다.'], - ['받은편지함으로 옮기며 읽음 표시하는 필터', rule({ actions: { ...NO_ACTIONS, moveToMailboxId: 'inbox', markAsRead: true } }), 3, '‘거래처 메일’ 필터를 받은편지함의 메일 3통에 적용했습니다.'], - ['지금 메일함 목록에 없는 메일함으로 옮기며 읽음 표시하는 필터', rule({ actions: { ...NO_ACTIONS, moveToMailboxId: 'gone', markAsRead: true } }), 3, '‘거래처 메일’ 필터를 받은편지함의 메일 3통에 적용했습니다.'], + ['읽음 · 중요만 하는 필터', rule({ name: '결재 알림', actions: { ...NO_ACTIONS, markAsRead: true, flag: true } }), 12, '‘결재 알림’ 필터로 받은편지함의 메일 12통을 정리했습니다.'], + ['받은편지함으로 옮기며 읽음 표시하는 필터', rule({ actions: { ...NO_ACTIONS, moveToMailboxId: 'inbox', markAsRead: true } }), 3, '‘거래처 메일’ 필터로 받은편지함의 메일 3통을 정리했습니다.'], + ['지금 메일함 목록에 없는 메일함으로 옮기며 읽음 표시하는 필터', rule({ actions: { ...NO_ACTIONS, moveToMailboxId: 'gone', markAsRead: true } }), 3, '‘거래처 메일’ 필터로 받은편지함의 메일 3통을 정리했습니다.'], ['맞는 메일이 없는 옮기는 필터', rule(), 0, '받은편지함에 ‘거래처 메일’ 필터에 맞는 메일이 없습니다.'], ])('%s — 단추 없이 끝난다', async (_label, target, matched, message) => { replies(step(8412, matched, 0, null)) @@ -179,7 +179,7 @@ describe('useMailFilterRun', () => { useMailFilterRun().start(rule({ actions: { ...NO_ACTIONS, markAsRead: true } })) - expect(shown()).toEqual([['progress', '‘거래처 메일’ 필터를 받은편지함에 적용하는 중']]) + expect(shown()).toEqual([['progress', '‘거래처 메일’ 필터로 받은편지함을 정리하는 중']]) first.finish(step(10, 0, 0, null)) await flushPromises() }) @@ -193,7 +193,7 @@ describe('useMailFilterRun', () => { // 진행 알림이 그 자리에서 멈춤 문구로 — 까닭은 오류 알림이 함께 expect(shown()).toEqual([ - ['error', '‘거래처 메일’ 필터 — 8,412통 가운데 4,200통까지 확인하고 멈췄습니다(12통에 적용). 다시 시도해 주세요.'], + ['error', '‘거래처 메일’ 필터로 8,412통 가운데 4,200통까지 확인하고 멈췄습니다. 그동안 12통을 정리했습니다. 다시 시도해 주세요.'], ['error', ERROR_CODE_MESSAGES.MAIL_001], ]) const stopped = withAction() @@ -201,11 +201,11 @@ describe('useMailFilterRun', () => { useToastStore().clear() press(stopped) - expect(shown()).toEqual([['progress', '‘거래처 메일’ 필터를 받은편지함에 적용하는 중 · 4,200 / 8,412통 확인']]) + expect(shown()).toEqual([['progress', '‘거래처 메일’ 필터로 받은편지함을 정리하는 중 · 4,200 / 8,412통 확인']]) await flushPromises() expect(cursors()).toEqual([null, 'c1', 'c1']) - expect(shown()).toEqual([['success', '‘거래처 메일’ 필터를 받은편지함의 메일 37통에 적용했습니다.']]) + expect(shown()).toEqual([['success', '‘거래처 메일’ 필터로 받은편지함의 메일 37통을 정리했습니다.']]) }) it('처음 요청부터 실패하면 "필터는 저장했지만 …" + [다시 시도] — 처음부터 다시 한다', async () => { @@ -215,18 +215,18 @@ describe('useMailFilterRun', () => { await flushPromises() expect(shown()).toEqual([ - ['error', '필터는 저장했지만 받은편지함에 적용하지 못했습니다. 다시 시도해 주세요.'], + ['error', '필터는 저장했지만 받은편지함은 정리하지 못했습니다. 다시 시도해 주세요.'], ['error', ERROR_CODE_MESSAGES.SYS_001], ]) const failed = withAction() expect(failed?.action?.label).toBe('다시 시도') useToastStore().clear() press(failed) - expect(shown()).toEqual([['progress', '‘거래처 메일’ 필터를 받은편지함에 적용하는 중 · 0 / 8,412통 확인']]) + expect(shown()).toEqual([['progress', '‘거래처 메일’ 필터로 받은편지함을 정리하는 중 · 0 / 8,412통 확인']]) await flushPromises() expect(cursors()).toEqual([null, null]) - expect(shown()).toEqual([['success', '‘거래처 메일’ 필터를 받은편지함의 메일 37통에 적용했습니다.']]) + expect(shown()).toEqual([['success', '‘거래처 메일’ 필터로 받은편지함의 메일 37통을 정리했습니다.']]) }) it('다른 창 · PC 에서 적용하는 중이면(429) "다른 창에서 …" 알림 하나만 — 처음 요청이면 단추가 없다', async () => { @@ -236,7 +236,7 @@ describe('useMailFilterRun', () => { await flushPromises() expect(useToastStore().toasts).toMatchObject([ - { kind: 'error', message: '다른 창에서 필터를 받은편지함에 적용하는 중입니다. 끝난 뒤 다시 시도해 주세요.', action: null }, + { kind: 'error', message: '다른 창에서 필터로 받은편지함을 정리하는 중입니다. 끝난 뒤 다시 시도해 주세요.', action: null }, ]) }) @@ -251,7 +251,7 @@ describe('useMailFilterRun', () => { expect(useToastStore().toasts).toHaveLength(1) expect(toast).toMatchObject({ kind: 'error', - message: '다른 창에서 필터를 받은편지함에 적용하는 중입니다. 끝난 뒤 다시 시도해 주세요.', + message: '다른 창에서 필터로 받은편지함을 정리하는 중입니다. 끝난 뒤 다시 시도해 주세요.', action: { label: '다시 시도' }, }) press(toast) @@ -326,9 +326,9 @@ describe('useMailFilterRun', () => { await flushPromises() expect(runs()).toHaveLength(3) - const busy = '다른 필터를 받은편지함에 적용하는 중입니다. 끝난 뒤 다시 시도해 주세요.' + const busy = '다른 필터로 받은편지함을 정리하는 중입니다. 끝난 뒤 다시 시도해 주세요.' expect(shown()).toEqual([ - ['progress', '‘둘’ 필터를 받은편지함에 적용하는 중 · 0 / 8,412통 확인'], + ['progress', '‘둘’ 필터로 받은편지함을 정리하는 중 · 0 / 8,412통 확인'], ['error', busy], ['error', busy], ]) @@ -342,10 +342,10 @@ describe('useMailFilterRun', () => { // 앞 적용이 끝났다 — 바쁨 알림의 [다시 시도]는 멈춘 자리(cursor c1 · 4,200통 · 12통)에서 이어 한다 press(resumed) - expect(shown()).toEqual([['progress', '‘하나’ 필터를 받은편지함에 적용하는 중 · 4,200 / 8,412통 확인']]) + expect(shown()).toEqual([['progress', '‘하나’ 필터로 받은편지함을 정리하는 중 · 4,200 / 8,412통 확인']]) await flushPromises() expect(cursors()).toEqual([null, 'c1', null, 'c1']) - expect(shown()).toEqual([['success', '‘하나’ 필터를 받은편지함의 메일 37통에 적용했습니다.']]) + expect(shown()).toEqual([['success', '‘하나’ 필터로 받은편지함의 메일 37통을 정리했습니다.']]) }) describe('끝난 뒤 개수 · 목록 맞추기 (4단계 오래 걸리는 처리와 같은 결)', () => { diff --git a/frontend/src/pages/LoginPage.restricted.spec.ts b/frontend/src/pages/LoginPage.restricted.spec.ts index 0bfc2c9..6be4a49 100644 --- a/frontend/src/pages/LoginPage.restricted.spec.ts +++ b/frontend/src/pages/LoginPage.restricted.spec.ts @@ -151,9 +151,9 @@ describe('LoginPage — 제한된 세션 (13단계)', () => { expect(wrapper.find('#restricted-password-temporary').exists()).toBe(false) expect(wrapper.find('#restricted-password-code').exists()).toBe(false) expect(wrapper.get('label[for="restricted-password-next"]').text()).toBe('새 비밀번호') - expect(wrapper.text()).toContain('8자 이상 · 임시 비밀번호와 달라야 합니다. 영문 · 숫자 · 기호를 섞으면 더 안전합니다.') + expect(wrapper.text()).toContain('8자 이상이고 임시 비밀번호와 달라야 합니다. 영문, 숫자, 기호를 섞으면 더 안전합니다.') expect(wrapper.get('[data-restricted-submit]').text()).toBe('비밀번호 바꾸고 계속') - expect(wrapper.text()).toContain('메일 프로그램(아웃룩 등)을 연결해 두었다면 바꾼 뒤 다시 연결해야 합니다.') + expect(wrapper.text()).toContain('아웃룩 같은 메일 프로그램을 연결해 두었다면 비밀번호를 바꾼 뒤 다시 연결해 주세요.') expect(document.activeElement?.id).toBe('restricted-password-next') // 비밀번호는 칸 · 반응형 상태에 남기지 않는다 — 로그인 칸은 사라졌다 expect(wrapper.find('#login-password').exists()).toBe(false) @@ -279,7 +279,7 @@ describe('LoginPage — 제한된 세션 (13단계)', () => { expect(wrapper.get('h1').text()).toBe('새 비밀번호를 정해 주세요') expect(wrapper.find('#restricted-password-temporary').exists()).toBe(true) expect(wrapper.get('label[for="restricted-password-code"]').text()).toBe('인증 앱 코드') - expect(wrapper.text()).toContain('로그인할 때 넣은 코드는 다시 쓸 수 없습니다 — 앱에 다음 코드가 뜨면 넣어 주세요.') + expect(wrapper.text()).toContain('로그인할 때 넣은 코드는 다시 쓸 수 없으니, 앱에 다음 코드가 뜨면 넣어 주세요.') expect(urls()).toContain('GET /two-factor') }) @@ -366,16 +366,16 @@ describe('LoginPage — 제한된 세션 (13단계)', () => { it('새 키를 받아 QR · 키를 그린다 — 지금 비밀번호는 묻지 않고(메모리) 문의 줄 · 아래 안내', async () => { const { wrapper } = await atSetupStep({ supportContact: () => ok({ text: CONTACT }) }) - expect(wrapper.get('h1').text()).toBe('2단계 인증을 켜야 합니다') + expect(wrapper.get('h1').text()).toBe('2단계 인증을 켜 주세요') expect(urls()).toContain('POST /two-factor/enrollments') expect(wrapper.find('[data-qr-code]').exists()).toBe(true) expect(wrapper.get('[data-setup-key]').text().replace(/\s/g, '')).toBe(enrollment(1).secret) - expect(wrapper.text()).toContain(`앱에는 「ARA SYSTEM (${USER.email})」로 저장됩니다.`) + expect(wrapper.text()).toContain(`앱에는 ‘ARA SYSTEM (${USER.email})’로 저장됩니다.`) expect(wrapper.get('label[for="restricted-setup-code"]').text()).toBe('앱에 표시된 6자리 코드') expect(wrapper.find('#restricted-setup-password').exists()).toBe(false) expect(wrapper.get('[data-support-line]').text()).toBe(`인증 앱을 쓸 수 없나요?${CONTACT}`) expect(wrapper.get('[data-support-contact]').text()).toBe(CONTACT) - expect(wrapper.text()).toContain('Outlook · 휴대폰 메일 앱은 이 코드를 묻지 않습니다.') + expect(wrapper.text()).toContain('Outlook이나 휴대폰 메일 앱은 이 코드를 묻지 않습니다.') expect(document.activeElement?.id).toBe('restricted-setup-code') }) @@ -428,7 +428,7 @@ describe('LoginPage — 제한된 세션 (13단계)', () => { const { wrapper } = await mountLogin({ activate: () => fail(400, 'AUTH_005') }) expect(wrapper.get('label[for="restricted-setup-password"]').text()).toBe('지금 비밀번호') - expect(wrapper.text()).toContain('새로고침해서 다시 넣어야 합니다.') + expect(wrapper.text()).toContain('새로고침한 뒤에는 지금 비밀번호를 한 번 더 넣어 주세요.') await wrapper.get('#restricted-setup-code').setValue('482913') await submitStep(wrapper) expect(bodyOf('/two-factor/activation')).toEqual([]) @@ -453,7 +453,7 @@ describe('LoginPage — 제한된 세션 (13단계)', () => { await submitStep(wrapper) expect(router.currentRoute.value.name).toBe('login') - expect(wrapper.get('h1').text()).toBe('2단계 인증을 켜야 합니다') + expect(wrapper.get('h1').text()).toBe('2단계 인증을 켜 주세요') expect(useToastStore().toasts.map((toast) => toast.message)).toEqual(['새 비밀번호를 정했습니다.']) expect(wrapper.find('#restricted-setup-password').exists()).toBe(false) expect(document.activeElement?.id).toBe('restricted-setup-code') @@ -491,7 +491,7 @@ describe('LoginPage — 제한된 세션 (13단계)', () => { }) expect(router.currentRoute.value.name).toBe('login') - expect(wrapper.get('h1').text()).toBe('2단계 인증을 켜야 합니다') + expect(wrapper.get('h1').text()).toBe('2단계 인증을 켜 주세요') expect(wrapper.find('#restricted-setup-password').exists()).toBe(false) await wrapper.get('#restricted-setup-code').setValue('482913') await submitStep(wrapper) diff --git a/frontend/src/pages/LoginPage.spec.ts b/frontend/src/pages/LoginPage.spec.ts index a9e1061..41e401a 100644 --- a/frontend/src/pages/LoginPage.spec.ts +++ b/frontend/src/pages/LoginPage.spec.ts @@ -206,7 +206,7 @@ describe('LoginPage — 2단계 인증 코드 단계', () => { expect(input.attributes('maxlength')).toBeUndefined() expect(document.activeElement?.id).toBe('login-code') expect(wrapper.text()).toContain('인증 앱을 쓸 수 없나요? 관리자에게 문의해 주세요.') - expect(wrapper.text()).toContain('Outlook · 휴대폰 메일 앱은 이 코드를 묻지 않습니다.') + expect(wrapper.text()).toContain('Outlook이나 휴대폰 메일 앱은 이 코드를 묻지 않습니다.') }) it('문의 연락처가 있으면(13단계) 「인증 앱을 쓸 수 없나요?」 다음 줄에 연락처', async () => { diff --git a/frontend/src/pages/LoginPage.vue b/frontend/src/pages/LoginPage.vue index b78ff78..727d19d 100644 --- a/frontend/src/pages/LoginPage.vue +++ b/frontend/src/pages/LoginPage.vue @@ -449,7 +449,7 @@ void supportContactStore.ensureLoaded() 로그인 @@ -524,7 +524,7 @@ void supportContactStore.ensureLoaded() :contact="supportContactStore.text" /> diff --git a/frontend/src/pages/contacts/ContactsPage.spec.ts b/frontend/src/pages/contacts/ContactsPage.spec.ts index 9c04d86..45ef922 100644 --- a/frontend/src/pages/contacts/ContactsPage.spec.ts +++ b/frontend/src/pages/contacts/ContactsPage.spec.ts @@ -387,7 +387,7 @@ describe('ContactsPage (주소록 모듈)', () => { const row = app.get('[data-contacts-row="c2"]') expect(row.get('.contacts-row__meta').text()).toBe('(주)한빛상사 · 영업팀') expect(row.get('.contacts-row__email').text()).toBe('jihoon.park@hanbit.example') - expect(app.get('[data-contact-none]').text()).toContain('연락처를 고르세요') + expect(app.get('[data-contact-none]').text()).toContain('연락처를 골라 주세요') await click(row.get('a')) expect(router.currentRoute.value.fullPath).toBe('/contacts/all/c2') @@ -624,11 +624,11 @@ describe('ContactsPage (주소록 모듈)', () => { const { app } = await mountApp('/contacts/directory/people/s5') expect(app.get('#contacts-list-title').text()).toBe('직원') - expect(app.get('.contacts-list__note').text()).toBe('메일 서버에 계정이 있는 직원입니다 — 이름과 주소만 보입니다.') + expect(app.get('.contacts-list__note').text()).toBe('회사 메일을 쓰는 직원이라 이름과 메일 주소만 보입니다.') const detail = app.get('[data-directory-detail="s5"]') expect(detail.get('.contact-detail__subtitle').text()).toBe('사내 직원') expect(detail.findAll('.tool-button').map((button) => button.text())).toEqual(['메일 쓰기', '내 주소록에 추가']) - expect(detail.get('.contact-detail__hint').text()).toContain('사내 목록은 메일 서버의 계정에서 옵니다') + expect(detail.get('.contact-detail__hint').text()).toContain('사내 목록은 이름과 주소만 보이고 여기서 고칠 수 없습니다') await click(detail.get('[data-contact-add-mine]')) expect(isOpen(app, '#contact-dialog')).toBe(true) @@ -693,7 +693,7 @@ describe('ContactsPage (주소록 모듈)', () => { await click(app.get('[data-group-row="g1"] a')) const input = app.get('[data-contacts-search]') expect(input.attributes('aria-label')).toBe('주소록에서 찾기') - expect(input.attributes('placeholder')).toBe('이름 · 주소 · 회사 · 전화번호 — 초성도 됩니다 (ㅂㅈㅎ)') + expect(input.attributes('placeholder')).toBe('이름, 주소, 회사, 전화번호, 초성(ㅂㅈㅎ)으로 찾기') await input.setValue('박') clock.advance(CONTACTS_SEARCH_DELAY_MS - 1) @@ -721,7 +721,7 @@ describe('ContactsPage (주소록 모듈)', () => { await flushPromises() const empty = app.get('[data-contacts-empty="search"]') expect(empty.text()).toContain('‘없는사람’에 맞는 연락처가 없습니다') - expect(empty.text()).toContain('이름 · 주소 · 회사 · 전화번호의 일부나 이름의 초성으로 찾습니다.') + expect(empty.text()).toContain('이름, 주소, 회사, 전화번호의 일부나 이름의 초성으로 찾을 수 있습니다.') // Esc 는 찾을 글을 지운다 — 목록이 다시 그룹 전체로 await input.trigger('keydown', { key: 'Escape' }) @@ -742,7 +742,7 @@ describe('ContactsPage (주소록 모듈)', () => { await click(app.get('[data-contacts-source="directory:person"]')) expect(router.currentRoute.value.fullPath).toBe('/contacts/directory/people') expect((input.element as HTMLInputElement).value).toBe('') - expect(input.attributes('placeholder')).toBe('이름 · 주소 — 초성도 됩니다 (ㅂㅈㅎ)') + expect(input.attributes('placeholder')).toBe('이름, 주소, 초성(ㅂㅈㅎ)으로 찾기') }) it('그룹 ⋯ — 이름 바꾸기 · 그룹에 메일 쓰기 · 그룹 지우기(빨간 글), 구성원이 없으면 메일 쓰기를 끈다 · 지우기는 확인 없이 + [되돌리기]', async () => { @@ -764,7 +764,7 @@ describe('ContactsPage (주소록 모듈)', () => { expect(router.currentRoute.value.fullPath).toBe('/contacts') expect(app.find('[data-group-row="g1"]').exists()).toBe(false) const [toast] = useToastStore().toasts - expect(toast?.message).toBe('‘거래처’ 그룹을 지웠습니다 — 연락처 3명은 그대로 있습니다.') + expect(toast?.message).toBe('‘거래처’ 그룹을 지웠습니다. 그룹에 있던 연락처 3명은 그대로 남아 있습니다.') expect(toast?.action?.label).toBe('되돌리기') if (toast?.action && 'run' in toast.action) toast.action.run() @@ -953,8 +953,8 @@ describe('ContactsPage (주소록 모듈)', () => { const empty = app.get('[data-contacts-empty="first"]') expect(empty.text()).toContain('아직 연락처가 없습니다') - expect(empty.text()).toContain('자주 메일을 주고받는 사람을 넣어 두면 받는 사람 칸에서 바로 찾습니다.') - expect(empty.text()).toContain('메일을 읽다가 ⋯ › 「보낸 사람을 주소록에 추가」로도 넣을 수 있습니다.') + expect(empty.text()).toContain('자주 메일을 주고받는 사람을 넣어 두면 받는 사람 칸에서 바로 찾을 수 있습니다.') + expect(empty.text()).toContain('메일을 읽다가 [⋯] → [보낸 사람을 주소록에 추가]로도 넣을 수 있습니다.') await click(empty.get('button')) expect(isOpen(app, '#contact-dialog')).toBe(true) expect(app.get('#contact-dialog-title').text()).toBe('연락처 추가') @@ -1001,7 +1001,7 @@ describe('ContactsPage (주소록 모듈)', () => { await click(app.get('[data-group-row="g2"] button[data-mailbox-more]')) await click(app.findAll('[role="menuitem"]')[2] ?? app.get('[role="menuitem"]')) expect(useToastStore().toasts.map((toast) => [toast.message, toast.action ?? null])).toEqual([ - ['‘프로젝트 알파’ 그룹을 지웠습니다 — 연락처 1명은 그대로 있습니다.', null], + ['‘프로젝트 알파’ 그룹을 지웠습니다. 그룹에 있던 연락처 1명은 그대로 남아 있습니다.', null], ]) }) diff --git a/frontend/src/pages/mail/ComposePage.drafts.spec.ts b/frontend/src/pages/mail/ComposePage.drafts.spec.ts index 93e13fd..32c894e 100644 --- a/frontend/src/pages/mail/ComposePage.drafts.spec.ts +++ b/frontend/src/pages/mail/ComposePage.drafts.spec.ts @@ -446,7 +446,7 @@ describe('ComposePage — 임시 저장 · 서명 (2단계)', () => { expect(wrapper.get('.compose__send').text()).toBe('보내기') // 꺼진 단추는 aria-disabled (11단계 — 초점은 받는다) · 까닭은 단추 옆 글 expect(wrapper.get('.compose__send').attributes('aria-disabled')).toBe('true') - expect(wrapper.get('#compose-send-why').text()).toBe('보낼 수 없는 파일이 있습니다 — 빨간 줄의 파일을 빼 주세요.') + expect(wrapper.get('#compose-send-why').text()).toBe('보낼 수 없는 파일이 있습니다. 빨간 줄의 파일을 빼 주세요.') expect(wrapper.text()).toContain(ERROR_CODE_MESSAGES.MAIL_004) expect(document.activeElement?.textContent?.trim()).toBe('파일 첨부') expect(useToastStore().toasts).toEqual([]) diff --git a/frontend/src/pages/mail/ComposePage.large-files.spec.ts b/frontend/src/pages/mail/ComposePage.large-files.spec.ts index cdced1f..0ca15e0 100644 --- a/frontend/src/pages/mail/ComposePage.large-files.spec.ts +++ b/frontend/src/pages/mail/ComposePage.large-files.spec.ts @@ -239,9 +239,9 @@ describe('ComposePage — 대용량 첨부', () => { expect(phases(wrapper)).toEqual(['ready', 'waiting-file', 'missing']) expect(lineOf(wrapper, 'ready')).toBe('1.2 GB · 이 임시 저장본에 11월 25일까지 남습니다') - expect(lineOf(wrapper, 'waiting-file')).toContain('올리다 멈춘 파일 — 같은 파일을 다시 고르면 이어서 올립니다') + expect(lineOf(wrapper, 'waiting-file')).toContain('올리다 멈춤 · 160 MB / 612 MB 올림 · 같은 파일을 다시 고르면 이어서 올립니다') expect(wrapper.find('[data-phase="waiting-file"] [data-large-repick]').exists()).toBe(true) - expect(lineOf(wrapper, 'missing')).toBe('설정 › 대용량 첨부에서 지운 파일입니다 — 빼고 다시 올려 주세요.') + expect(lineOf(wrapper, 'missing')).toBe('[설정] → [대용량 첨부]에서 지운 파일입니다. 빼고 다시 올려 주세요.') const send = () => wrapper.get('.compose__send') expect(send().attributes('aria-disabled')).toBe('true') @@ -283,7 +283,7 @@ describe('ComposePage — 대용량 첨부', () => { expect((sent?.data as Record | undefined)?.largeFileIds).toEqual(['lf-1']) expect(calls(requests)).toContain('GET /file-uploads/lf-1') expect(phases(wrapper)).toEqual(['missing']) - expect(lineOf(wrapper, 'missing')).toBe('서버에서 찾을 수 없는 파일입니다 — 빼고 다시 올려 주세요.') + expect(lineOf(wrapper, 'missing')).toBe('파일을 찾을 수 없습니다. 빼고 다시 올려 주세요.') expect(wrapper.get('.compose__send').attributes('aria-disabled')).toBe('true') expect(wrapper.get('#compose-send-why').text()).toBe(SEND_BLOCK_TEXT.unsendable) expect(useToastStore().toasts).toEqual([]) @@ -308,7 +308,7 @@ describe('ComposePage — 대용량 첨부', () => { const lookup = requests.find((request) => request.url === '/share-link-lookups') expect(lookup?.data).toEqual({ urls: [URL1, URL2] }) expect(wrapper.findAll('[data-quoted-link]').map((notice) => notice.text())).toEqual([ - '원래 메일의 대용량 첨부 2개는 인용 속 링크로 함께 갑니다 — 11월 29일까지', + '원래 메일의 대용량 첨부 2개는 인용 속 링크로 함께 갑니다. 11월 29일까지 받을 수 있습니다.', '인용 속 대용량 첨부 링크는 공유가 멈춰 받을 수 없습니다.', ]) // 원래 메일의 대용량 첨부는 다시 싣지 않는다 — 링크 묶음이 없다 @@ -379,7 +379,7 @@ describe('ComposePage — 대용량 첨부', () => { await flushPromises() expect(phases(wrapper)).toEqual(['ready']) - expect(lineOf(wrapper, 'ready')).toBe('2 MB · 보안 검사 중 — 끝나지 않아도 보낼 수 있습니다') + expect(lineOf(wrapper, 'ready')).toBe('2 MB · 보안 검사 중이지만 보낼 수 있습니다') expect(wrapper.find('#compose-send-why').exists()).toBe(false) }) diff --git a/frontend/src/pages/mail/ComposePage.sendlater.spec.ts b/frontend/src/pages/mail/ComposePage.sendlater.spec.ts index a5f89ff..9c69327 100644 --- a/frontend/src/pages/mail/ComposePage.sendlater.spec.ts +++ b/frontend/src/pages/mail/ComposePage.sendlater.spec.ts @@ -216,7 +216,7 @@ describe('ComposePage — 보내기 취소 · 예약 발송 (9단계)', () => { expect(toasts().map((toast) => toast.kind)).toEqual(['error']) }) - it('일부가 받지 못하면 빨간 알림 "보냈지만 1명은 받지 못합니다 — …" 에도 [보내기 취소] (같은 마감)', async () => { + it('일부가 받지 못하면 빨간 알림 "보냈지만 1명은 받지 못합니다. …" 에도 [보내기 취소] (같은 마감)', async () => { const { wrapper } = await mountCompose(server([undoReply([NOBODY])])) await addRecipient(wrapper, 'doyun.lee@arasystem.it.kr') await addRecipient(wrapper, NOBODY.email) @@ -226,7 +226,7 @@ describe('ComposePage — 보내기 취소 · 예약 발송 (9단계)', () => { expect(toasts()).toEqual([ expect.objectContaining({ kind: 'error', - message: '보냈지만 1명은 받지 못합니다 — nobody@arasystem.it.kr (없는 주소)', + message: '보냈지만 1명은 받지 못합니다. nobody@arasystem.it.kr (없는 주소)', action: expect.objectContaining({ label: '보내기 취소' }), expiresAt: NOW + 10_000, }), @@ -369,7 +369,7 @@ describe('ComposePage — 보내기 취소 · 예약 발송 (9단계)', () => { const dialog = wrapper.get('#compose-schedule-title').element.closest('dialog') expect(dialog?.hasAttribute('open')).toBe(true) // 이 PC 의 검사로는 맞는 시각이다(시계가 어긋남) — 가까운 쪽(5분) 한도로 알린다 - expect(wrapper.get('#compose-schedule-error').text()).toBe('지금부터 5분 뒤 이후의 시각을 고르세요.') + expect(wrapper.get('#compose-schedule-error').text()).toBe('5분 뒤보다 이른 시각은 고를 수 없습니다. 더 늦은 시각을 골라 주세요.') expect((wrapper.get('[data-schedule-preset="custom"]').element as HTMLInputElement).checked).toBe(true) const chosen = new Date(preset.at) expect((wrapper.get('[data-schedule-day]').element as HTMLInputElement).value).toBe(formatDate(chosen)) diff --git a/frontend/src/pages/mail/ComposePage.spec.ts b/frontend/src/pages/mail/ComposePage.spec.ts index 273840b..a64c16e 100644 --- a/frontend/src/pages/mail/ComposePage.spec.ts +++ b/frontend/src/pages/mail/ComposePage.spec.ts @@ -319,7 +319,7 @@ describe('ComposePage', () => { expect(useToastStore().toasts).toEqual([ expect.objectContaining({ kind: 'error', - message: '보냈지만 1명은 받지 못했습니다 — nobody@arasystem.it.kr (없는 주소)', + message: '보냈지만 1명은 받지 못했습니다. nobody@arasystem.it.kr (없는 주소)', // 성공 알림과 같은 곳 — 링크가 있으니 10초 (toast.store) action: { label: '보낸편지함 보기', to: { name: 'mail-mailbox', params: { mailboxId: 'sent' } } }, }), @@ -538,7 +538,7 @@ describe('ComposePage', () => { const { wrapper } = await mountCompose('/mail/compose', replyApi) expect(wrapper.get('.compose__attach-hint').text()).toBe( - '파일당 최대 10MB · 합계 30MB · 최대 20개 · 더 큰 파일은 링크로 보냅니다 (파일당 2GB · 10개까지)', + '파일당 최대 10MB · 합계 30MB · 최대 20개 · 더 큰 파일은 파일당 2GB, 10개까지 링크로 보냅니다', ) }) @@ -547,7 +547,7 @@ describe('ComposePage', () => { expect(useMailStore().quota).toBeNull() expect(wrapper.get('.compose__attach-hint').text()).toBe( - '파일당 최대 20MB · 합계 25MB · 최대 20개 · 더 큰 파일은 링크로 보냅니다 (파일당 2GB · 10개까지)', + '파일당 최대 20MB · 합계 25MB · 최대 20개 · 더 큰 파일은 파일당 2GB, 10개까지 링크로 보냅니다', ) }) @@ -1483,7 +1483,7 @@ describe('ComposePage', () => { expect(dialogOpen(wrapper)).toBe(false) expect(blocksUnload()).toBe(false) expect(useToastStore().toasts.map((toast) => [toast.kind, toast.message])).toEqual([ - ['error', '보냈지만 1명은 받지 못했습니다 — nobody@arasystem.it.kr (받는 쪽이 거절)'], + ['error', '보냈지만 1명은 받지 못했습니다. nobody@arasystem.it.kr (받는 쪽이 거절)'], ]) }) @@ -1567,7 +1567,7 @@ describe('ComposePage', () => { await flushPromises() expect(useToastStore().toasts.map((toast) => [toast.kind, toast.message])).toEqual([ - ['error', '보냈지만 1명은 받지 못했습니다 — infra@arasystem.it.kr (지금은 받을 수 없음)'], + ['error', '보냈지만 1명은 받지 못했습니다. infra@arasystem.it.kr (지금은 받을 수 없음)'], ]) expect(router.currentRoute.value.fullPath).toBe('/mail/sent') }) diff --git a/frontend/src/pages/mail/ComposePage.vue b/frontend/src/pages/mail/ComposePage.vue index 6cfb108..f923fc8 100644 --- a/frontend/src/pages/mail/ComposePage.vue +++ b/frontend/src/pages/mail/ComposePage.vue @@ -1256,7 +1256,7 @@ watch(hasMissingLarge, (missing) => { v-model="subject" type="text" class="compose__subject" - placeholder="제목을 입력하세요" + placeholder="제목을 입력해 주세요" autocomplete="off" :maxlength="MAIL_SUBJECT_MAX_LENGTH" :disabled="preparing" diff --git a/frontend/src/pages/mail/MailboxPage.scheduled.spec.ts b/frontend/src/pages/mail/MailboxPage.scheduled.spec.ts index 292e89d..7a68de9 100644 --- a/frontend/src/pages/mail/MailboxPage.scheduled.spec.ts +++ b/frontend/src/pages/mail/MailboxPage.scheduled.spec.ts @@ -126,12 +126,12 @@ describe('예약됨 · 보낸편지함 (9단계)', () => { })) } - it('분할 보기 — 예약한 메일을 보낼 시각 차례로: 받는 사람 · 제목 · 보낼 시각(청록) · 머리 "예약됨 · 2통 · 보낼 시각 차례"', async () => { + it('분할 보기 — 예약한 메일을 보낼 시각 차례로: 받는 사람 · 제목 · 보낼 시각(청록) · 머리 "예약됨 · 2통 · 보낼 시각이 이른 순서"', async () => { const { wrapper } = await mountAt('/mail/scheduled') expect(requests.find((request) => request.url === '/scheduled-messages')?.params).toEqual({ limit: 20 }) expect(wrapper.get('#scheduled-list-title').text()).toBe('예약됨') - expect(wrapper.get('.list-header__count').text()).toBe('2통 · 보낼 시각 차례') + expect(wrapper.get('.list-header__count').text()).toBe('2통 · 보낼 시각이 이른 순서') expect(rows(wrapper)).toEqual([ { who: '받는 사람: 이도윤', diff --git a/frontend/src/pages/mail/MailboxPage.search.spec.ts b/frontend/src/pages/mail/MailboxPage.search.spec.ts index 2cd43f4..c521b6c 100644 --- a/frontend/src/pages/mail/MailboxPage.search.spec.ts +++ b/frontend/src/pages/mail/MailboxPage.search.spec.ts @@ -114,7 +114,7 @@ describe('MailboxPage — 검색 결과', () => { expect(wrapper.text()).toContain('‘회의’에 맞는 메일이 없습니다') expect(wrapper.text()).toContain( - '낱말 단위로 찾습니다. 붙여 쓴 말의 일부(‘서버이전’의 ‘서버’)나 제목 맨 앞의 말머리([공지] · Re:)로는 찾지 못합니다.', + '낱말 단위로 찾습니다. ‘서버이전’의 ‘서버’처럼 붙여 쓴 말의 일부나, 제목 맨 앞의 [공지]나 Re: 같은 말머리로는 찾지 못합니다.', ) await wrapper.get('.search-empty__all').trigger('click') await flushPromises() @@ -124,7 +124,7 @@ describe('MailboxPage — 검색 결과', () => { expect(listRequests().at(-1)?.params).toEqual({ q: '회의', limit: 20 }) // 전체에서 찾았으면 더 넓힐 곳이 없다 — 안내 줄만 expect(wrapper.find('.search-empty__all').exists()).toBe(false) - expect(wrapper.get('.search-scope__hint').text()).toBe('휴지통 · 스팸메일함은 빼고 찾습니다') + expect(wrapper.get('.search-scope__hint').text()).toBe('휴지통과 스팸메일함은 빼고 찾습니다') }) it('범위 줄에서 "전체 메일함"을 누르면 기록을 쌓지 않고 전체에서 다시 찾고, "받은편지함"을 누르면 돌아온다', async () => { @@ -167,7 +167,7 @@ describe('MailboxPage — 검색 결과', () => { useMailStore().rememberSearchOrigin({ mailboxId: 'sent', location: '/mail/sent' }) const { wrapper, router } = await mountAt(`/mail/search?q=${Q}&in=sent`) - expect(wrapper.get('.search-header__close').attributes('title')).toBe('검색 닫기 — 보낸편지함으로') + expect(wrapper.get('.search-header__close').attributes('title')).toBe('검색을 닫고 보낸편지함으로 돌아가기') await wrapper.get('.search-header__close').trigger('click') await flushPromises() diff --git a/frontend/src/pages/settings/AccountAppPasswordsPage.spec.ts b/frontend/src/pages/settings/AccountAppPasswordsPage.spec.ts index 31ef97e..7c47c8a 100644 --- a/frontend/src/pages/settings/AccountAppPasswordsPage.spec.ts +++ b/frontend/src/pages/settings/AccountAppPasswordsPage.spec.ts @@ -147,10 +147,10 @@ describe('AccountAppPasswordsPage', () => { const settings = wrapper.get('[data-mail-client-settings]').text() expect(settings).toContain('mx1.arasystem.it.kr · 포트 993 · SSL/TLS') expect(settings).toContain('mx1.arasystem.it.kr · 포트 465 · SSL/TLS') - expect(settings).toContain('ara.kim@arasystem.it.kr — 메일 주소 전체') - expect(settings).toContain('여기서 만든 앱 비밀번호 — 웹메일 비밀번호가 아닙니다') + expect(settings).toContain('ara.kim@arasystem.it.kr · 메일 주소 전체') + expect(settings).toContain('웹메일 비밀번호가 아닌, 여기서 만든 앱 비밀번호') expect(wrapper.get('[data-app-password-notes]').text()).toContain( - '웹메일 비밀번호를 바꾸면 앱 비밀번호도 모두 지워집니다 — 메일 프로그램마다 새로 만들어 주세요.', + '웹메일 비밀번호를 바꾸면 앱 비밀번호도 모두 지워지니, 메일 프로그램마다 새로 만들어 주세요.', ) }) @@ -214,7 +214,7 @@ describe('AccountAppPasswordsPage', () => { expect(dialogOpen('app-password-create-title')).toBe(true) expect(document.activeElement?.id).toBe('app-password-name') expect(wrapper.get('#app-password-name').attributes('maxlength')).toBe('40') - expect(wrapper.get('#app-password-name-help').text()).toBe('어느 프로그램 · 기기에서 쓰는지 알아볼 이름입니다. 40자까지.') + expect(wrapper.get('#app-password-name-help').text()).toBe('어느 프로그램이나 기기에서 쓰는지 알아볼 수 있게 지어 주세요. 40자까지 쓸 수 있습니다.') await create(wrapper, ' ') expect(wrapper.get('#app-password-name-help').text()).toBe('이름을 넣어 주세요.') @@ -237,7 +237,7 @@ describe('AccountAppPasswordsPage', () => { expect(dialogOpen('app-password-create-title')).toBe(false) expect(dialogOpen('app-password-result-title')).toBe(true) expect(document.querySelector('#app-password-result-message')?.textContent?.trim()).toBe( - '‘집 노트북 메일’ — 메일 프로그램의 비밀번호 칸에 아래 값을 넣어 주세요.', + '‘집 노트북 메일’ 앱 비밀번호입니다. 메일 프로그램의 비밀번호 칸에 아래 값을 넣어 주세요.', ) expect(wrapper.findAll('.result__group').map((group) => group.text())).toEqual([ 'app_', @@ -370,7 +370,7 @@ describe('AccountAppPasswordsPage', () => { replies[0]?.(fail(400, 'VAL_001')) await flushPromises() - expect(wrapper.get('#app-password-name-help').text()).toBe('어느 프로그램 · 기기에서 쓰는지 알아볼 이름입니다. 40자까지.') + expect(wrapper.get('#app-password-name-help').text()).toBe('어느 프로그램이나 기기에서 쓰는지 알아볼 수 있게 지어 주세요. 40자까지 쓸 수 있습니다.') expect(toastMessages()).toEqual([]) }) diff --git a/frontend/src/pages/settings/AccountLoginHistoryPage.spec.ts b/frontend/src/pages/settings/AccountLoginHistoryPage.spec.ts index faf6048..f91c269 100644 --- a/frontend/src/pages/settings/AccountLoginHistoryPage.spec.ts +++ b/frontend/src/pages/settings/AccountLoginHistoryPage.spec.ts @@ -193,7 +193,7 @@ describe('AccountLoginHistoryPage', () => { ]) expect(wrapper.get('[data-sessions-end-others]').text()).toBe('다른 곳 모두 로그아웃') const note = wrapper.get('[data-sessions-note]') - expect(note.text()).toBe('웹메일 로그인만 보입니다. 메일 프로그램은 앱 비밀번호를 지워 끊습니다.') + expect(note.text()).toBe('웹메일 로그인만 보입니다. 메일 프로그램의 연결을 끊으려면 앱 비밀번호를 지워 주세요.') expect(note.get('a').attributes('href')).toBe('/settings/account/app-passwords') }) @@ -204,7 +204,7 @@ describe('AccountLoginHistoryPage', () => { expect(wrapper.get('[data-session="old"] [data-session-device]').text()).toBe('알 수 없는 브라우저') }) - it('줄의 [로그아웃]은 묻지 않고 바로 끊는다 — 「‘Edge · Windows’의 로그인을 끊었습니다.」 · 줄이 빠지고 초점은 다음 줄', async () => { + it('줄의 [로그아웃]은 묻지 않고 바로 끊는다 — 「‘Edge · Windows’에서 로그아웃했습니다.」 · 줄이 빠지고 초점은 다음 줄', async () => { const wrapper = await mountPage() const button = wrapper.findAll('[data-session-end]')[0] ;(button?.element as HTMLButtonElement).focus() @@ -214,7 +214,7 @@ describe('AccountLoginHistoryPage', () => { expect(requests.filter((request) => request.method === 'DELETE').map((request) => request.url)).toEqual([ '/auth/sessions/edge', ]) - expect(toastMessages()).toEqual(['‘Edge · Windows’의 로그인을 끊었습니다.']) + expect(toastMessages()).toEqual(['‘Edge · Windows’에서 로그아웃했습니다.']) expect(sessionRows(wrapper).map((row) => row[0])).toEqual(['Chrome · Windows', 'Safari · iOS']) expect(wrapper.get('[data-sessions-count]').text()).toBe('웹메일 2곳') expect(document.activeElement?.getAttribute('aria-label')).toBe('‘Safari · iOS’ 로그아웃') @@ -225,11 +225,11 @@ describe('AccountLoginHistoryPage', () => { await wrapper.findAll('[data-session-end]')[1]?.trigger('click') await flushPromises() - expect(toastMessages()).toEqual(['‘Safari · iOS’의 로그인을 끊었습니다.']) + expect(toastMessages()).toEqual(['‘Safari · iOS’에서 로그아웃했습니다.']) expect(sessionRows(wrapper)).toHaveLength(2) }) - it('[다른 곳 모두 로그아웃] → 확인 창(처음 초점 「취소」 · 끊을 곳의 이름) → 「다른 곳의 로그인 2곳을 끊었습니다.」 — 지금 이 브라우저만 남는다', async () => { + it('[다른 곳 모두 로그아웃] → 확인 창(처음 초점 「취소」 · 끊을 곳의 이름) → 「다른 2곳에서 로그아웃했습니다.」 — 지금 이 브라우저만 남는다', async () => { const wrapper = await mountPage() await wrapper.get('[data-sessions-end-others]').trigger('click') await flushPromises() @@ -238,9 +238,9 @@ describe('AccountLoginHistoryPage', () => { expect(dialog?.hasAttribute('open')).toBe(true) expect(dialog?.getAttribute('role')).toBe('alertdialog') expect(document.querySelector('#end-other-sessions-message')?.textContent?.trim()).toBe( - '이 브라우저를 뺀 2곳(Edge · Windows, Safari · iOS)의 웹메일 로그인을 끊습니다. 끊긴 곳은 다음 동작에서 바로 로그인 화면으로 돌아갑니다.', + '이 브라우저를 뺀 2곳(Edge · Windows, Safari · iOS)의 웹메일에서 로그아웃합니다. 그곳에서 다음에 무엇이든 누르면 바로 로그인 화면이 나옵니다.', ) - expect(dialog?.textContent).toContain('메일 프로그램은 끊기지 않습니다 — 앱 비밀번호를 지워 끊어 주세요.') + expect(dialog?.textContent).toContain('메일 프로그램은 로그아웃되지 않습니다. 연결을 끊으려면 앱 비밀번호를 지워 주세요.') expect(document.activeElement?.hasAttribute('data-end-others-cancel')).toBe(true) // 데이터를 지우는 일이 아니다 — 청록 주 단추 expect(wrapper.get('[data-end-others-confirm]').classes()).not.toContain('modal-dialog__action--danger') @@ -249,12 +249,12 @@ describe('AccountLoginHistoryPage', () => { await flushPromises() expect(requests.filter((request) => request.method === 'DELETE').map((request) => request.url)).toEqual(['/auth/sessions']) - expect(toastMessages()).toEqual(['다른 곳의 로그인 2곳을 끊었습니다.']) + expect(toastMessages()).toEqual(['다른 2곳에서 로그아웃했습니다.']) expect(dialog?.hasAttribute('open')).toBe(false) expect(sessionRows(wrapper).map((row) => row[0])).toEqual(['Chrome · Windows']) expect(wrapper.find('[data-sessions-end-others]').exists()).toBe(false) expect(wrapper.get('[data-sessions-note]').text()).toBe( - '다른 곳에 로그인된 브라우저가 없습니다. 웹메일 로그인만 보입니다. 메일 프로그램은 앱 비밀번호를 지워 끊습니다.', + '다른 곳에 로그인된 브라우저가 없습니다. 웹메일 로그인만 보입니다. 메일 프로그램의 연결을 끊으려면 앱 비밀번호를 지워 주세요.', ) expect(document.activeElement?.id).toBe('login-sessions-title') }) @@ -273,10 +273,10 @@ describe('AccountLoginHistoryPage', () => { await wrapper.get('[data-sessions-end-others]').trigger('click') await flushPromises() expect(document.querySelector('#end-other-sessions-title')?.textContent?.trim()).toBe( - '이 브라우저를 포함해 모든 곳의 로그인을 끊을까요?', + '이 브라우저를 포함해 모든 곳에서 로그아웃할까요?', ) expect(document.querySelector('#end-other-sessions-message')?.textContent?.trim()).toBe( - '이 브라우저를 알아보지 못해 이 브라우저를 포함한 3곳(Chrome · Windows, Edge · Windows, Safari · iOS)의 웹메일 로그인을 끊습니다. 이 브라우저도 로그아웃되어 로그인 화면으로 돌아갑니다.', + '이 브라우저를 알아보지 못해서 이 브라우저를 포함한 3곳(Chrome · Windows, Edge · Windows, Safari · iOS)의 웹메일에서 로그아웃합니다. 이 브라우저도 로그아웃되어 로그인 화면으로 돌아갑니다.', ) await wrapper.get('[data-end-others-confirm]').trigger('click') @@ -288,7 +288,7 @@ describe('AccountLoginHistoryPage', () => { 'GET /auth/sessions', 'POST /auth/refresh', ]) - expect(toastMessages()).toContain('이 브라우저를 포함한 3곳의 로그인을 끊었습니다.') + expect(toastMessages()).toContain('이 브라우저를 포함해 3곳에서 로그아웃했습니다.') // 빈 목록을 그려 두지 않는다 — 다시 받은 요청이 세션 만료로 이어졌다 expect(wrapper.find('[data-session]').exists()).toBe(false) expect(useAuthStore().sessionExpired).toBe(true) @@ -329,11 +329,11 @@ describe('AccountLoginHistoryPage', () => { expect(wrapper.get('[data-events-hint]').text()).toBe('최근 90일 · 웹메일과 메일 프로그램') expect(eventRows(wrapper)).toEqual([ - [time(0), '웹메일', '211.186.95.252', 'Chrome · Windows', '성공 · 2단계 인증'], - [time(1), '웹메일', '211.186.95.252', 'Chrome · Windows', '실패 — 코드 틀림'], + [time(0), '웹메일', '211.186.95.252', 'Chrome · Windows', '2단계 인증으로 성공'], + [time(1), '웹메일', '211.186.95.252', 'Chrome · Windows', '코드가 틀려 실패'], [time(2), '메일 프로그램 · IMAP앱 비밀번호 ‘회사 PC 아웃룩’', '211.186.95.252', '—알 수 없음', '성공'], - [time(3), '메일 프로그램 · SMTP', '45.133.10.21', '—알 수 없음', '실패 — 앱 비밀번호 필요'], - [time(4), '웹메일', '211.186.95.252', '알 수 없는 브라우저', '실패 — 비밀번호 틀림'], + [time(3), '메일 프로그램 · SMTP', '45.133.10.21', '—알 수 없음', '앱 비밀번호가 필요해 실패'], + [time(4), '웹메일', '211.186.95.252', '알 수 없는 브라우저', '비밀번호가 틀려 실패'], ]) expect(eventRows(wrapper)[0]?.[0]).toBe('오늘 오전 9:30') expect(wrapper.findAll('[data-event-result]').map((cell) => cell.classes().includes('events__result--failure'))).toEqual([ @@ -363,9 +363,9 @@ describe('AccountLoginHistoryPage', () => { expect(all.attributes('aria-pressed')).toBe('false') expect(wrapper.get('[data-events-hint]').text()).toBe('최근 90일 · 실패 4번') expect(eventRows(wrapper).map((row) => row[4])).toEqual([ - '실패 — 코드 틀림', - '실패 — 앱 비밀번호 필요', - '실패 — 비밀번호 틀림', + '코드가 틀려 실패', + '앱 비밀번호가 필요해 실패', + '비밀번호가 틀려 실패', ]) // 같은 보기를 다시 누르면 받지 않는다 @@ -374,7 +374,7 @@ describe('AccountLoginHistoryPage', () => { expect(eventRequests()).toHaveLength(2) }) - it('빈 상태(실패만) — "최근 90일 동안 실패한 로그인이 없습니다" / "[전체]를 누르면 성공한 로그인도 봅니다."', async () => { + it('빈 상태(실패만) — "최근 90일 동안 실패한 로그인이 없습니다" / "[전체]를 누르면 성공한 로그인도 볼 수 있습니다."', async () => { const wrapper = await mountPage({ events: (request) => (request.params.isSuccess === false ? ok(pageOf([], null, 0)) : ok(pageOf(EVENTS))), }) @@ -383,7 +383,7 @@ describe('AccountLoginHistoryPage', () => { expect(wrapper.find('[data-login-event]').exists()).toBe(false) expect(wrapper.get('[data-events-empty]').text()).toContain('최근 90일 동안 실패한 로그인이 없습니다') - expect(wrapper.get('[data-events-empty]').text()).toContain('[전체]를 누르면 성공한 로그인도 봅니다.') + expect(wrapper.get('[data-events-empty]').text()).toContain('[전체]를 누르면 성공한 로그인도 볼 수 있습니다.') expect(wrapper.get('[data-events-hint]').text()).toBe('최근 90일 · 실패 0번') }) @@ -398,7 +398,7 @@ describe('AccountLoginHistoryPage', () => { }, }) expect(wrapper.get('[data-events-more]').text()).toBe('더 보기') - expect(wrapper.text()).toContain('최근 순서 · 20개씩. 90일이 지난 기록은 지웁니다.') + expect(wrapper.text()).toContain('최근 순서로 20개씩 보입니다. 90일이 지난 기록은 지워집니다.') await wrapper.get('[data-events-filter="failures"]').trigger('click') await flushPromises() diff --git a/frontend/src/pages/settings/AccountMailClientsPage.spec.ts b/frontend/src/pages/settings/AccountMailClientsPage.spec.ts index 75985a6..0f42313 100644 --- a/frontend/src/pages/settings/AccountMailClientsPage.spec.ts +++ b/frontend/src/pages/settings/AccountMailClientsPage.spec.ts @@ -53,12 +53,12 @@ describe('AccountMailClientsPage', () => { const wrapper = await mountPage() expect(wrapper.get('h1').text()).toBe('메일 프로그램 연결') - expect(wrapper.text()).toContain('아웃룩 · 썬더버드 · 휴대폰 메일 앱에서 회사 메일을 보는 방법입니다.') + expect(wrapper.text()).toContain('아웃룩, 썬더버드, 휴대폰 메일 앱에서 회사 메일을 보는 방법입니다.') expect(settingsRows(wrapper)).toEqual([ 'mx1.arasystem.it.kr · 포트 993 · SSL/TLS', 'mx1.arasystem.it.kr · 포트 465 · SSL/TLS', - `${USER.email} — 메일 주소 전체`, - '앱 비밀번호 — 웹메일 비밀번호가 아닙니다', + `${USER.email} · 메일 주소 전체`, + '웹메일 비밀번호가 아닌 앱 비밀번호', ]) expect(requests.map((request) => request.url)).toEqual(['/two-factor']) }) @@ -72,10 +72,10 @@ describe('AccountMailClientsPage', () => { expect(wrapper.get('[data-mail-clients-create]').attributes('href')).toBe('/settings/account/app-passwords') }) - it('켜지 않은 직원 — 비밀번호 줄은 「앱 비밀번호(권장) …」, 청록 상자 「앱 비밀번호로 연결하기를 권합니다」', async () => { + it('켜지 않은 직원 — 비밀번호 줄은 「앱 비밀번호를 권합니다. …」, 청록 상자 「앱 비밀번호로 연결하기를 권합니다」', async () => { const wrapper = await mountPage(() => ok(OFF)) - expect(settingsRows(wrapper).at(-1)).toBe('앱 비밀번호(권장) — 2단계 인증을 켜지 않았으면 웹메일 비밀번호도 됩니다') + expect(settingsRows(wrapper).at(-1)).toBe('앱 비밀번호를 권합니다. 2단계 인증을 켜지 않았다면 웹메일 비밀번호로도 연결됩니다.') const notice = wrapper.get('[data-mail-clients-notice]') expect(notice.attributes('data-mail-clients-notice')).toBe('recommend') expect(notice.text()).toContain('앱 비밀번호로 연결하기를 권합니다') @@ -92,7 +92,7 @@ describe('AccountMailClientsPage', () => { const wrapper = await mountPage() expect(wrapper.get('[data-mail-clients-cloud-warning]').text()).toBe( - '새 Outlook · 휴대폰 Outlook 앱은 메일을 Microsoft 클라우드를 거쳐 받습니다 — 회사 정책에 따라 쓰지 않는 것이 좋습니다.', + '새 Outlook과 휴대폰 Outlook 앱은 메일을 Microsoft 클라우드를 거쳐 받습니다. 회사 정책에 따라 쓰지 않는 것이 좋습니다.', ) const buttons = wrapper.findAll('[data-mail-client] h3 button') expect(buttons.map((button) => button.text())).toEqual([ @@ -118,14 +118,14 @@ describe('AccountMailClientsPage', () => { expect(panel.attributes('role')).toBe('region') expect(panel.attributes('aria-labelledby')).toBe('mail-client-outlook-button') expect(panel.findAll('li')).toHaveLength(5) - expect(panel.text()).toContain('Outlook 에서 [파일] → [계정 추가]를 누릅니다.') + expect(panel.text()).toContain('Outlook에서 [파일] → [계정 추가]를 누릅니다.') // 호스트 이름은 굵게 expect(panel.get('strong').text()).toBe('mx1.arasystem.it.kr') await wrapper.get('#mail-client-android-button').trigger('click') expect(outlook.attributes('aria-expanded')).toBe('false') expect(wrapper.find('#mail-client-outlook-panel').exists()).toBe(false) - expect(wrapper.get('#mail-client-android-panel').text()).toContain('보안 유형은 「SSL/TLS」를 넣고 [다음]을 누릅니다.') + expect(wrapper.get('#mail-client-android-panel').text()).toContain('보안 유형은 [SSL/TLS]를 고른 뒤 [다음]을 누릅니다.') await wrapper.get('#mail-client-android-button').trigger('click') expect(wrapper.findAll('[role="region"]')).toHaveLength(0) @@ -136,6 +136,6 @@ describe('AccountMailClientsPage', () => { const notes = wrapper.get('[data-mail-clients-notes]').findAll('p').map((note) => note.text()) expect(notes).toHaveLength(4) - expect(notes[1]).toContain('POP3 로는 연결할 수 없습니다 — 계정 유형은 IMAP 을 골라 주세요.') + expect(notes[1]).toContain('POP3로는 연결할 수 없으니 계정 유형은 IMAP을 골라 주세요.') }) }) diff --git a/frontend/src/pages/settings/AccountPasswordPage.spec.ts b/frontend/src/pages/settings/AccountPasswordPage.spec.ts index 0646d50..ed14167 100644 --- a/frontend/src/pages/settings/AccountPasswordPage.spec.ts +++ b/frontend/src/pages/settings/AccountPasswordPage.spec.ts @@ -79,18 +79,18 @@ describe('AccountPasswordPage', () => { expect(wrapper.get('#password-current').attributes('autocomplete')).toBe('current-password') expect(wrapper.get('#password-next').attributes('autocomplete')).toBe('new-password') expect(wrapper.get('#password-next-hint').text()).toBe( - '8자 이상 · 지금 비밀번호와 달라야 합니다. 영문 · 숫자 · 기호를 섞으면 더 안전합니다.', + '8자 이상이고 지금 비밀번호와 달라야 합니다. 영문, 숫자, 기호를 섞으면 더 안전합니다.', ) // 모든 직원 — 다른 곳 로그인이 끊기고 앱 비밀번호도 모두 지워진다 (12단계) expect(wrapper.get('[data-password-note]').text()).toContain( - '바꾸면 이 브라우저를 뺀 다른 기기 · 브라우저의 웹메일 로그인이 끊깁니다.', + '바꾸면 이 브라우저를 빼고 다른 기기와 브라우저의 웹메일에서 로그아웃됩니다.', ) expect(wrapper.get('[data-password-note]').text()).toContain( - '앱 비밀번호도 모두 지워집니다 — 메일 프로그램은 새 앱 비밀번호로 다시 연결해 주세요.', + '앱 비밀번호도 모두 지워지니, 메일 프로그램은 새 앱 비밀번호로 다시 연결해 주세요.', ) // 2단계 인증이 꺼진 직원 — 머리 설명은 그대로, 코드 칸이 없다 expect(wrapper.get('.settings-page__description').text()).toBe( - '웹메일과 휴대폰 메일 앱(IMAP)이 함께 쓰는 메일 계정의 비밀번호입니다.', + '웹메일에 로그인할 때와 휴대폰 메일 앱을 연결할 때 함께 쓰는 비밀번호입니다.', ) expect(wrapper.find('#password-code').exists()).toBe(false) @@ -148,7 +148,7 @@ describe('AccountPasswordPage', () => { // 서버가 엔진을 두 번 부르고 다른 세션을 끊는다 — 요청 한도는 보통(API_TIMEOUT_MS)보다 긴 30초 expect(PASSWORD_CHANGE_TIMEOUT_MS).toBe(30_000) expect(passwordRequests()[0]?.config.timeout).toBe(PASSWORD_CHANGE_TIMEOUT_MS) - expect(wrapper.get('#password-status').text()).toBe('비밀번호를 바꿨습니다. 다른 기기 · 브라우저의 로그인은 끊었습니다.') + expect(wrapper.get('#password-status').text()).toBe('비밀번호를 바꿨습니다. 다른 기기와 브라우저에서는 로그아웃했습니다.') for (const id of ['password-current', 'password-next', 'password-confirm']) { expect((wrapper.get(`#${id}`).element as HTMLInputElement).value).toBe('') } @@ -234,7 +234,7 @@ describe('AccountPasswordPage', () => { autocomplete: 'one-time-code', 'aria-describedby': 'password-code-hint', }) - expect(wrapper.get('#password-code-hint').text()).toBe('코드는 30초마다 바뀝니다 — 다른 칸을 다 쓴 뒤 넣어 주세요.') + expect(wrapper.get('#password-code-hint').text()).toBe('코드는 30초마다 바뀌니 다른 칸을 다 채운 뒤에 넣어 주세요.') }) it('코드는 숫자만 받는다 — 붙여 넣은 "123 456" · "123-456" 은 다듬고, 6자리가 아니면 보내지 않는다(사전 문구 AUTH_007)', async () => { diff --git a/frontend/src/pages/settings/AccountTwoFactorPage.spec.ts b/frontend/src/pages/settings/AccountTwoFactorPage.spec.ts index 89faa38..7bce367 100644 --- a/frontend/src/pages/settings/AccountTwoFactorPage.spec.ts +++ b/frontend/src/pages/settings/AccountTwoFactorPage.spec.ts @@ -120,10 +120,10 @@ describe('AccountTwoFactorPage', () => { expect(wrapper.find('[data-two-factor-notes]').exists()).toBe(false) }) - it('켜짐 — 「켜짐 — 2026년 10월 31일부터」 · [2단계 인증 끄기] · 안내 상자 / 켠 때를 모르면 「켜짐」', async () => { + it('켜짐 — 「2026년 10월 31일부터 켜짐」 · [2단계 인증 끄기] · 안내 상자 / 켠 때를 모르면 「켜짐」', async () => { const wrapper = await mountPage({ status: () => ok(ON) }) - expect(wrapper.get('[data-two-factor-label]').text()).toBe('켜짐 — 2026년 10월 31일부터') + expect(wrapper.get('[data-two-factor-label]').text()).toBe('2026년 10월 31일부터 켜짐') expect(wrapper.get('[data-two-factor-label]').classes()).toContain('two-factor__state-label--on') expect(wrapper.get('[data-two-factor-disable]').text()).toBe('2단계 인증 끄기') expect(wrapper.find('[data-two-factor-warning]').exists()).toBe(false) @@ -176,7 +176,7 @@ describe('AccountTwoFactorPage', () => { ['K7QF', '2M4X', 'HB6R', 'TZ3N'], ['PW5C', 'YL7D', 'JQ2V', 'MGAS'], ]) - expect(wrapper.text()).toContain('앱에는 「ARA SYSTEM (ara.kim@arasystem.it.kr)」로 저장됩니다.') + expect(wrapper.text()).toContain('앱에는 ‘ARA SYSTEM (ara.kim@arasystem.it.kr)’로 저장됩니다.') expect(document.activeElement?.id).toBe('two-factor-enable-code') expect(wrapper.get('#two-factor-enable-code').attributes()).toMatchObject({ inputmode: 'numeric', @@ -243,11 +243,11 @@ describe('AccountTwoFactorPage', () => { // 서버가 엔진을 여러 번 부른다 — 한도는 보통(10초)보다 긴 30초 expect(calls('/two-factor/activation')[0]?.config.timeout).toBe(TWO_FACTOR_CHANGE_TIMEOUT_MS) expect(enableDialog()?.hasAttribute('open')).toBe(false) - expect(wrapper.get('[data-two-factor-label]').text()).toBe('켜짐 — 2026년 10월 31일부터') + expect(wrapper.get('[data-two-factor-label]').text()).toBe('2026년 10월 31일부터 켜짐') const done = wrapper.get('[data-two-factor-done]') expect(done.attributes('role')).toBe('status') expect(done.text()).toContain('2단계 인증을 켰습니다') - expect(done.text()).toContain('메일 프로그램(Outlook · 썬더버드 · 휴대폰 메일 앱)은 다시 연결할 때 앱 비밀번호가 필요합니다.') + expect(done.text()).toContain('Outlook, 썬더버드, 휴대폰 메일 앱 같은 메일 프로그램은 다시 연결할 때 앱 비밀번호가 필요합니다.') expect(done.get('a').text()).toBe('앱 비밀번호 만들기') expect(done.get('a').attributes('href')).toBe('/settings/account/app-passwords') expect(document.activeElement).toBe(done.element) @@ -668,7 +668,7 @@ describe('AccountTwoFactorPage — 필수인 직원 (13단계)', () => { expect(wrapper.get('[data-two-factor-locked]').text()).toBe('회사 정책에 따라 끌 수 없습니다') expect(wrapper.find('[data-two-factor-grace]').exists()).toBe(false) const notes = wrapper.get('[data-two-factor-notes]').text() - expect(notes).toContain('휴대폰을 바꿀 때는 관리자에게 2단계 인증 해제를 요청해 주세요 — 다음 로그인 때 새 휴대폰으로 다시 켭니다.') + expect(notes).toContain('휴대폰을 바꿀 때는 관리자에게 2단계 인증을 풀어 달라고 요청해 주세요. 다음에 로그인할 때 새 휴대폰으로 다시 켜면 됩니다.') expect(notes).toContain('휴대폰을 잃어버렸거나 인증 앱을 지웠으면 관리자에게 문의해 주세요.') }) }) diff --git a/frontend/src/pages/settings/MailBlocksPage.spec.ts b/frontend/src/pages/settings/MailBlocksPage.spec.ts index 99a51e1..e2f729f 100644 --- a/frontend/src/pages/settings/MailBlocksPage.spec.ts +++ b/frontend/src/pages/settings/MailBlocksPage.spec.ts @@ -119,11 +119,11 @@ describe('MailBlocksPage', () => { expect(empty?.[1]).not.toMatch(/display:\s*none/) }) - it('비었으면 "차단한 주소가 없습니다. 메일의 ⋯ › 보낸 사람 차단으로도 막을 수 있습니다."', async () => { + it('비었으면 "차단한 주소가 없습니다. 메일을 읽다가 [⋯] → [보낸 사람 차단]으로도 막을 수 있습니다."', async () => { const wrapper = await mountPage({ blocks: [] }) expect(wrapper.get('[data-block-empty]').text()).toBe( - '차단한 주소가 없습니다. 메일의 ⋯ › 보낸 사람 차단으로도 막을 수 있습니다.', + '차단한 주소가 없습니다. 메일을 읽다가 [⋯] → [보낸 사람 차단]으로도 막을 수 있습니다.', ) expect(wrapper.get('[data-block-count]').text()).toBe('0 / 500') }) @@ -150,12 +150,12 @@ describe('MailBlocksPage', () => { it.each([ ['형식', 'kim@', '메일 주소나 @도메인 형식으로 써 주세요.'], ['이미 차단함', 'NEWS@cloudbrief.example', '이미 차단한 주소입니다.'], - ['내 주소', 'ara.kim@arasystem.it.kr', '내 주소 · 사내 도메인은 차단할 수 없습니다.'], - ['사내 도메인', '@arasystem.it.kr', '내 주소 · 사내 도메인은 차단할 수 없습니다.'], + ['내 주소', 'ara.kim@arasystem.it.kr', '내 주소와 사내 도메인은 차단할 수 없습니다.'], + ['사내 도메인', '@arasystem.it.kr', '내 주소와 사내 도메인은 차단할 수 없습니다.'], [ '여러 사람이 쓰는 메일 서비스', '@gmail.com', - 'gmail.com 처럼 여러 사람이 쓰는 메일 서비스는 도메인으로 막을 수 없습니다 — 주소로 막아 주세요.', + 'gmail.com처럼 여러 사람이 쓰는 메일 서비스는 도메인 전체를 막을 수 없습니다. 주소를 하나씩 넣어 막아 주세요.', ], ])('보내기 전 검사 — %s: 칸 아래 오류(빨강) · 잘못된 입력, 보내지 않는다', async (_label, value, text) => { const wrapper = await mountPage() @@ -247,7 +247,7 @@ describe('MailBlocksPage', () => { }) describe('엔진에서 우리 규칙이 꺼져 있으면(isActive: false) 머리 아래 노란 줄 — 단추 없음', () => { - const INACTIVE = '지금은 차단이 동작하지 않습니다. 메일 서버의 규칙이 다른 곳에서 바뀌었습니다 — 주소를 차단하거나 풀면 다시 켜집니다.' + const INACTIVE = '다른 곳에서 메일 설정이 바뀌어 지금은 차단이 되지 않습니다. 주소를 차단하거나 풀면 다시 켜집니다.' it('차단한 주소가 있을 때만 보인다(role=status) — 켜져 있거나 비었으면 없다', async () => { const off = await mountPage({ isActive: false }) diff --git a/frontend/src/pages/settings/MailFilterPage.spec.ts b/frontend/src/pages/settings/MailFilterPage.spec.ts index 5a4b971..bc6b779 100644 --- a/frontend/src/pages/settings/MailFilterPage.spec.ts +++ b/frontend/src/pages/settings/MailFilterPage.spec.ts @@ -237,24 +237,24 @@ describe('MailFilterPage', () => { expect(wrapper.get('ol').attributes('aria-busy')).toBeUndefined() expect(rows(wrapper).map((item) => item.classes('is-off'))).toEqual([true, false, false, true, false]) expect(wrapper.findAll('[data-filter-missing]').map((item) => item.text())).toEqual([ - '옮길 메일함이 지워져 받은편지함으로 들어옵니다 — 눌러서 고치세요.', + '옮길 메일함이 지워져 메일이 받은편지함으로 들어옵니다. 눌러서 고쳐 주세요.', ]) expect(row(wrapper, 'r5').get('[data-filter-edit]').attributes('aria-label')).toBe('‘알파 프로젝트’ 필터 고치기') // 맨 위의 ▲ · 맨 아래의 ▼ 는 누를 수 없다 (초점은 그대로 받는다) expect(row(wrapper, 'r1').get('[data-filter-up]').attributes('aria-disabled')).toBe('true') expect(row(wrapper, 'r1').get('[data-filter-down]').attributes('aria-disabled')).toBeUndefined() expect(row(wrapper, 'r5').get('[data-filter-down]').attributes('aria-disabled')).toBe('true') - expect(wrapper.get('[data-filter-count]').text()).toBe('필터 5개 · 50개까지 만들 수 있습니다. 행을 누르면 고칩니다.') + expect(wrapper.get('[data-filter-count]').text()).toBe('필터 5개를 만들었고, 50개까지 만들 수 있습니다. 줄을 누르면 고칠 수 있습니다.') expect(wrapper.get('[data-filter-notes]').findAll('p').map((note) => note.text())).toEqual([ - '·위에서부터 차례로 적용합니다. 메일함으로 옮기는 필터에 맞으면 그 아래 필터는 적용하지 않습니다 — 순서는 ▲ ▼ 로 바꿉니다.', - '·이미 받은 메일에도 적용하려면 필터를 저장할 때 「받은편지함에 이미 있는 메일에도 적용」을 고릅니다. 받은편지함의 메일만 보고, 그 필터 하나만 적용합니다.', - '·스팸으로 판정되어 들어오는 메일에는 필터를 적용하지 않습니다 — 그대로 스팸메일함으로 갑니다.', - '·전달은 사내 주소로만, 새로 오는 메일만 합니다. 메일 한 통은 한 곳으로만 전달됩니다(위의 필터가 먼저). 전달해도 내 메일함에 그대로 남습니다.', - '·부재중 자동 응답은 필터와 함께 동작합니다.', + '·위에 있는 필터부터 차례로 메일을 정리합니다. 메일함으로 옮기는 필터에 맞은 메일은 그 아래 필터를 거치지 않습니다. 순서는 ▲ ▼ 로 바꿀 수 있습니다.', + '·이미 받은 메일도 정리하려면 필터를 저장할 때 [받은편지함에 이미 있는 메일에도 적용]을 골라 주세요. 받은편지함에 있는 메일만 그 필터 하나로 정리합니다.', + '·스팸으로 분류된 메일은 필터를 거치지 않고 그대로 스팸메일함으로 갑니다.', + '·전달은 새로 오는 메일만 사내 주소로 합니다. 메일 한 통은 한 곳으로만 전달되고, 전달하는 필터가 여럿이면 위에 있는 필터가 먼저입니다. 전달해도 내 메일함에 그대로 남습니다.', + '·필터가 메일을 옮겨도 부재중 자동 응답은 그대로 보냅니다.', ]) - expect(wrapper.get('[data-filter-note-apply] strong').text()).toBe('「받은편지함에 이미 있는 메일에도 적용」') + expect(wrapper.get('[data-filter-note-apply] strong').text()).toBe('[받은편지함에 이미 있는 메일에도 적용]') expect(wrapper.get('.settings-page__description').text()).toBe( - '받은 메일을 조건에 따라 메일함으로 옮기거나, 읽음 · 중요로 표시하거나, 동료에게 전달합니다. 새로 오는 메일에 적용하고, 고를 때는 받은편지함에 이미 있는 메일에도 적용합니다.', + '받은 메일을 조건에 따라 메일함으로 옮기거나, 읽음이나 중요로 표시하거나, 동료에게 전달합니다. 새로 오는 메일을 정리하고, 저장할 때 고르면 받은편지함에 이미 있는 메일도 함께 정리합니다.', ) expect(wrapper.find('[data-filter-empty]').exists()).toBe(false) expect(wrapper.find('[data-filter-conflict]').exists()).toBe(false) @@ -403,7 +403,7 @@ describe('MailFilterPage', () => { await save(wrapper) expect(wrapper.get('#mail-filter-condition-0-error').text()).toBe( - '‘${’ 는 쓸 수 없습니다 — 메일 서버가 다른 뜻으로 읽습니다.', + '‘${’는 필터에 쓸 수 없습니다. 이 글자를 빼고 넣어 주세요.', ) expect(wrapper.get('#mail-filter-forward-help').text()).toBe('사내 주소(@arasystem.it.kr)로만 전달할 수 있습니다.') expect(document.activeElement).toBe(value.element) @@ -477,7 +477,7 @@ describe('MailFilterPage', () => { const select = wrapper.get('#mail-filter-move-target') const options = select.findAll('option') expect(options.map((option) => option.text())).toEqual([ - '(지워진 메일함) — 다른 메일함을 고르세요', + '지워진 메일함이니 다른 메일함을 골라 주세요', '받은편지함', '거래처', '전체 공지', @@ -721,7 +721,7 @@ describe('MailFilterPage', () => { const { wrapper } = await mountPage({ filters: { ...SET, isActive: false } }) const bar = wrapper.get('[data-filter-inactive]') - expect(bar.text()).toContain('지금은 필터와 부재중 자동 응답이 동작하지 않습니다. 메일 서버의 규칙이 다른 곳에서 바뀌었습니다.') + expect(bar.text()).toContain('다른 곳에서 메일 설정이 바뀌어 지금은 필터와 부재중 자동 응답이 되지 않습니다.') await click(bar.get('button')) expect(puts()).toEqual([{ rules: RULES, version: 'v1' }]) @@ -820,7 +820,7 @@ describe('MailFilterPage', () => { const applyHelp = (wrapper: VueWrapper) => wrapper.get('#mail-filter-apply-help').text() const runs = () => requests.filter((request) => request.url === '/mail-filter-runs').map((request) => request.data as MailFilterRunRequest) - const HELP = '저장한 뒤 받은편지함에서 조건에 맞는 메일을 찾아 바로 적용합니다.' + const HELP = '저장하면 받은편지함에서 조건에 맞는 메일을 찾아 바로 정리합니다.' it('할 일 아래 — 창을 열 때마다 꺼져 있고 설명은 칸의 aria-describedby, 전달도 하면 설명에 더한다 · 상자 아래 스팸 한 줄', async () => { const { wrapper } = await mountPage() @@ -831,7 +831,7 @@ describe('MailFilterPage', () => { expect(applyBox(wrapper).attributes('aria-describedby')).toBe('mail-filter-apply-help') expect(wrapper.get('[data-filter-apply] label').text()).toBe('받은편지함에 이미 있는 메일에도 적용') expect(applyHelp(wrapper)).toBe(HELP) - expect(wrapper.get('[data-filter-apply-spam]').text()).toBe('스팸으로 판정되어 들어오는 메일에는 적용하지 않습니다.') + expect(wrapper.get('[data-filter-apply-spam]').text()).toBe('스팸으로 분류된 메일은 필터를 거치지 않습니다.') // 6단계의 안내 띠는 없다 expect(wrapper.text()).not.toContain('새로 오는 메일에만 적용됩니다') @@ -852,7 +852,7 @@ describe('MailFilterPage', () => { expect(applyBox(wrapper).attributes('disabled')).toBeDefined() expect(wrapper.get('[data-filter-apply] label').classes()).toContain('is-disabled') - expect(applyHelp(wrapper)).toBe('전달만 하는 필터는 이미 받은 메일에 할 일이 없습니다 — 전달은 새로 오는 메일에만 합니다.') + expect(applyHelp(wrapper)).toBe('전달은 새로 오는 메일에만 해서, 전달만 하는 필터는 이미 받은 메일에 할 일이 없습니다.') await wrapper.get('#mail-filter-read').setValue(true) expect(applyBox(wrapper).attributes('disabled')).toBeUndefined() @@ -863,7 +863,7 @@ describe('MailFilterPage', () => { const { wrapper } = await mountPage() await openRule(wrapper, 'r5') expect(applyBox(wrapper).attributes('disabled')).toBeDefined() - expect(applyHelp(wrapper)).toBe('옮길 메일함이 지워져 이미 받은 메일에 할 일이 없습니다 — 다른 메일함을 고르세요.') + expect(applyHelp(wrapper)).toBe('옮길 메일함이 지워져 이미 받은 메일에 할 일이 없습니다. 다른 메일함을 골라 주세요.') await click(wrapper.get('#mail-filter-cancel')) await openRule(wrapper, 'r2') @@ -927,7 +927,7 @@ describe('MailFilterPage', () => { { ...rule, cursor: 'c1' }, ]) expect(useToastStore().toasts.map((toast) => [toast.kind, toast.message])).toEqual([ - ['progress', '‘거래처 메일’ 필터를 받은편지함에 적용하는 중 · 4,500 / 8,412통 확인'], + ['progress', '‘거래처 메일’ 필터로 받은편지함을 정리하는 중 · 4,500 / 8,412통 확인'], ]) second.finish(step(3912, 7, 0, null)) @@ -936,7 +936,7 @@ describe('MailFilterPage', () => { expect(useToastStore().toasts).toHaveLength(1) expect(done).toMatchObject({ kind: 'success', - message: '‘거래처 메일’ 필터를 받은편지함의 메일 37통에 적용했습니다.', + message: '‘거래처 메일’ 필터로 받은편지함의 메일 37통을 정리했습니다.', action: { label: '거래처 보기', to: { name: 'mail-mailbox', params: { mailboxId: 'vendor' } } }, }) expect(toasts()).not.toContain('필터를 저장했습니다.') @@ -952,7 +952,7 @@ describe('MailFilterPage', () => { await openRule(wrapper, 'r2') expect(applyBox(wrapper).attributes('disabled')).toBeDefined() - expect(applyHelp(wrapper)).toBe('다른 필터를 받은편지함에 적용하는 중입니다. 끝난 뒤에 고를 수 있습니다.') + expect(applyHelp(wrapper)).toBe('다른 필터로 받은편지함을 정리하는 중입니다. 끝난 뒤에 고를 수 있습니다.') await click(wrapper.get('#mail-filter-cancel')) // 다른 화면으로 가도 이어 한다 @@ -960,7 +960,7 @@ describe('MailFilterPage', () => { wrapper.unmount() first.finish(step(8412, 12, 0, null)) await flushPromises() - expect(toasts()).toEqual(['‘결재 알림’ 필터를 받은편지함의 메일 12통에 적용했습니다.']) + expect(toasts()).toEqual(['‘결재 알림’ 필터로 받은편지함의 메일 12통을 정리했습니다.']) expect(useToastStore().toasts[0]?.action).toBeNull() const { wrapper: again } = await mountPage() @@ -981,7 +981,7 @@ describe('MailFilterPage', () => { reply.finish(echo(requests.find((request) => request.method === 'PUT') as MockRequest)) await flushPromises() expect(runs().map((request) => request.cursor)).toEqual([null]) - expect(toasts()).toEqual(['‘거래처 메일’ 필터를 받은편지함의 메일 37통에 적용했습니다.']) + expect(toasts()).toEqual(['‘거래처 메일’ 필터로 받은편지함의 메일 37통을 정리했습니다.']) useToastStore().clear() const leaving = deferred() @@ -997,7 +997,7 @@ describe('MailFilterPage', () => { leaving.finish(echo(requests.find((request) => request.method === 'PUT') as MockRequest)) await flushPromises() expect(runs().map((request) => request.cursor)).toEqual([null]) - expect(toasts()).toEqual(['‘거래처 메일’ 필터를 받은편지함의 메일 37통에 적용했습니다.']) + expect(toasts()).toEqual(['‘거래처 메일’ 필터로 받은편지함의 메일 37통을 정리했습니다.']) }) it('저장하는 중에 세션이 바뀌면 적용 요청을 보내지 않는다 — 알림도 없다', async () => { diff --git a/frontend/src/pages/settings/MailLargeFilesPage.spec.ts b/frontend/src/pages/settings/MailLargeFilesPage.spec.ts index a545dd6..92393c3 100644 --- a/frontend/src/pages/settings/MailLargeFilesPage.spec.ts +++ b/frontend/src/pages/settings/MailLargeFilesPage.spec.ts @@ -158,7 +158,7 @@ describe('MailLargeFilesPage', () => { expect(view.get('[data-large-usage]').text()).toBe('6.2 / 10 GB') expect(view.get('meter').attributes('aria-valuetext')).toBe('62%') expect(view.get('.large-usage__note').text()).toBe( - '보낸 링크 4.3 GB · 보내지 않은 파일 1.9 GB(임시 저장 · 올리다 멈춤). 기간이 지나거나, 링크를 끊거나, 보내지 않은 파일을 지우면 바로 줄어듭니다.', + '보낸 링크 4.3 GB, 임시 저장했거나 올리다 멈춘 파일 1.9 GB입니다. 기간이 지나거나, 링크를 끊거나, 보내지 않은 파일을 지우면 바로 줄어듭니다.', ) }) @@ -199,7 +199,7 @@ describe('MailLargeFilesPage', () => { await view.get('[data-unsent-delete]').trigger('click') await flushPromises() expect(view.get('#large-delete-message').text()).toBe( - '‘시공도면_v4.dwg’(620 MB) 파일을 지웁니다. 이 파일을 실은 임시 저장본이 있으면 열 때 첨부 칸에 ‘지운 파일’로 보입니다. 지운 파일은 되돌릴 수 없습니다.', + '‘시공도면_v4.dwg’(620 MB) 파일을 지웁니다. 이 파일을 실은 임시 저장본을 열면 첨부 칸에 지운 파일로 보입니다. 지운 파일은 되돌릴 수 없습니다.', ) expect(document.activeElement?.hasAttribute('data-delete-cancel')).toBe(true) @@ -283,7 +283,7 @@ describe('MailLargeFilesPage', () => { it('목록을 받지 못하면 사전 문구와 "다시 시도" — 알림은 띄우지 않는다', async () => { const { wrapper: view } = await mountPage({ failLinks: true }) - expect(view.text()).toContain('메일 서버와 연결할 수 없습니다.') + expect(view.text()).toContain('메일 서비스에 연결할 수 없습니다.') expect(useToastStore().toasts).toEqual([]) expect(useLargeFilesStore().status).toBe('error') }) diff --git a/frontend/src/pages/settings/MailNotifyPage.spec.ts b/frontend/src/pages/settings/MailNotifyPage.spec.ts index c19981c..d185add 100644 --- a/frontend/src/pages/settings/MailNotifyPage.spec.ts +++ b/frontend/src/pages/settings/MailNotifyPage.spec.ts @@ -56,13 +56,13 @@ describe('MailNotifyPage (설정 › 메일 › 알림)', () => { expect(wrapper.text()).toContain('새 메일은 1분을 기다리지 않고 바로 목록에 나타납니다.') }) - it('묻기 전 — 칸은 비었고 "켜면 브라우저가 … 「허용」을 눌러 주세요.", 보일 것 · 시험 알림은 흐리게', async () => { + it('묻기 전 — 칸은 비었고 "켜면 브라우저가 … [허용]을 눌러 주세요.", 보일 것 · 시험 알림은 흐리게', async () => { const wrapper = await mountPage() expect(enableInput(wrapper).checked).toBe(false) expect(enableInput(wrapper).disabled).toBe(false) expect(wrapper.get('#notify-enabled-note').text()).toBe( - '켜면 브라우저가 알림을 허락할지 한 번 묻습니다 — 「허용」을 눌러 주세요.', + '켜면 브라우저가 알림을 허락할지 한 번 묻습니다. [허용]을 눌러 주세요.', ) expect(wrapper.get('fieldset').attributes('disabled')).toBeDefined() expect(wrapper.get('#notify-test').attributes('disabled')).toBeDefined() @@ -101,7 +101,7 @@ describe('MailNotifyPage (설정 › 메일 › 알림)', () => { expect(enableInput(wrapper).disabled).toBe(true) const note = wrapper.get('#notify-enabled-note') expect(note.text()).toBe( - '브라우저가 이 사이트의 알림을 막고 있습니다. 주소창 왼쪽의 사이트 정보(자물쇠) › 알림을 「허용」으로 바꾼 뒤 이 화면을 새로 고쳐 주세요.', + '브라우저가 이 사이트의 알림을 막고 있습니다. 주소창 왼쪽의 사이트 정보(자물쇠)를 눌러 [알림]을 [허용]으로 바꾼 뒤 이 화면을 새로 고쳐 주세요.', ) expect(note.classes()).toContain('notify-form__note--danger') expect(useMailNotifyStore().settings.enabled).toBe(false) @@ -200,7 +200,7 @@ describe('MailNotifyPage (설정 › 메일 › 알림)', () => { const subject = wrapper.get('#notify-show-subject') expect((subject.element as HTMLInputElement).checked).toBe(true) - expect(wrapper.text()).toContain('끄면 보낸 사람 이름만 보입니다 — 화면을 다른 사람과 함께 보는 자리라면 꺼 두세요.') + expect(wrapper.text()).toContain('끄면 보낸 사람 이름만 보입니다. 다른 사람과 화면을 함께 보는 자리라면 꺼 두는 것이 좋습니다.') await subject.setValue(false) expect(useMailNotifyStore().settings.showSubject).toBe(false) @@ -213,7 +213,7 @@ describe('MailNotifyPage (설정 › 메일 › 알림)', () => { const status = () => wrapper.get('#notify-realtime-status') expect(wrapper.get('h2').text()).toBe('실시간 연결') - expect(status().text()).toBe('연결되어 있습니다 — 새 메일이 오면 바로 목록에 나타납니다.') + expect(status().text()).toBe('연결되어 있어 새 메일이 오면 바로 목록에 나타납니다.') expect(status().find('.realtime-link__dot').classes()).toContain('is-live') expect(wrapper.find('#notify-realtime-notice').exists()).toBe(false) @@ -225,7 +225,7 @@ describe('MailNotifyPage (설정 › 메일 › 알림)', () => { // 끊긴 동안 — 상태 줄 바로 아래, "저절로 다시 잇습니다…" 안내 앞 expect(wrapper.get('#notify-realtime-notice').text()).toBe('연결될 때까지 바탕화면 알림은 오지 않습니다.') expect(wrapper.find('[role="status"] #notify-realtime-notice').exists()).toBe(true) - expect(wrapper.text()).toContain('저절로 다시 잇습니다.') + expect(wrapper.text()).toContain('저절로 다시 연결합니다.') realtime.setStatus('connecting') await flushPromises() @@ -238,9 +238,9 @@ describe('MailNotifyPage (설정 › 메일 › 알림)', () => { const notes = wrapper.get('[data-notify-notes]') expect(notes.findAll('p').map((note) => note.findAll('span').at(-1)?.text())).toEqual([ - '알림 설정은 이 브라우저에만 적용됩니다. 다른 PC · 브라우저에서는 그곳에서 따로 켜 주세요.', + '알림 설정은 이 브라우저에만 저장됩니다. 다른 PC나 브라우저에서는 그곳에서 따로 켜 주세요.', '웹메일 탭이 하나라도 열려 있어야 알림이 옵니다. 탭을 모두 닫으면 오지 않습니다.', - '웹메일 화면을 보고 있을 때는 바탕화면 알림을 보내지 않습니다 — 목록에 바로 보이므로.', + '웹메일 화면을 보고 있을 때는 새 메일이 목록에 바로 보이므로 바탕화면 알림을 보내지 않습니다.', ]) expect(notes.get('strong').text()).toBe('이 브라우저에만') }) diff --git a/frontend/src/pages/settings/MailSendReadPage.spec.ts b/frontend/src/pages/settings/MailSendReadPage.spec.ts index 5293109..69006b8 100644 --- a/frontend/src/pages/settings/MailSendReadPage.spec.ts +++ b/frontend/src/pages/settings/MailSendReadPage.spec.ts @@ -78,15 +78,15 @@ describe('MailSendReadPage', () => { ['30초', 'false'], ]) expect(wrapper.get('#send-read-undo-hint').text()).toBe( - '[보내기]를 누른 뒤 이 시간 동안 [보내기 취소]로 되돌릴 수 있습니다. 그동안 메일은 메일 서버에서 기다리므로 창을 닫아도 보내집니다.', + '[보내기]를 누른 뒤 이 시간 동안 [보내기 취소]로 되돌릴 수 있습니다. 그사이 창을 닫아도 시간이 지나면 메일은 그대로 보내집니다.', ) expect(wrapper.findAll('.send-read__option-label').map((label) => label.text())).toEqual([ '늘 묻기', - '주소록 · 사내 직원의 메일은 늘 보기', + '주소록과 사내 직원의 메일은 늘 보기', '늘 보기', ]) expect(checkedImages(wrapper)).toEqual(['trusted']) - expect(wrapper.get('[data-images-note]').text()).toContain('사내 메일 서버가 대신 받아 보여 줍니다') + expect(wrapper.get('[data-images-note]').text()).toContain('외부 이미지는 회사를 거쳐 불러오므로 보낸 사람은 이 PC의 주소를 알 수 없습니다.') expect(wrapper.get('[data-settings-status]').text()).toBe('') }) diff --git a/frontend/src/pages/settings/MailSignaturePage.spec.ts b/frontend/src/pages/settings/MailSignaturePage.spec.ts index dbbf545..cc5de8e 100644 --- a/frontend/src/pages/settings/MailSignaturePage.spec.ts +++ b/frontend/src/pages/settings/MailSignaturePage.spec.ts @@ -189,7 +189,7 @@ describe('MailSignaturePage', () => { isForNew: true, isForReply: false, }) - expect(wrapper.get('#signature-save-status').text()).toBe('저장했습니다. 새로 여는 쓰기 화면부터 들어갑니다.') + expect(wrapper.get('#signature-save-status').text()).toBe('저장했습니다. 다음에 메일을 쓸 때부터 들어갑니다.') expect(useMailSignatureStore().signature?.isForReply).toBe(false) await wrapper.get('#signature-for-new').setValue(false) @@ -466,10 +466,10 @@ describe('MailSignaturePage', () => { expect(replyMode.attributes('aria-pressed')).toBe('true') expect(wrapper.find('.signature-preview__quote').exists()).toBe(true) - expect(wrapper.get('[data-signature-preview-note]').text()).toBe('답장 · 전달은 인용한 원문 위에 서명을 넣습니다.') + expect(wrapper.get('[data-signature-preview-note]').text()).toBe('답장과 전달에는 인용한 원문 위에 서명을 넣습니다.') await wrapper.get('#signature-for-reply').setValue(false) expect(wrapper.get('[data-signature-preview]').isVisible()).toBe(false) - expect(wrapper.get('[data-signature-preview-note]').text()).toBe('답장 · 전달에는 서명을 넣지 않습니다.') + expect(wrapper.get('[data-signature-preview-note]').text()).toBe('답장과 전달에는 서명을 넣지 않습니다.') }) }) diff --git a/frontend/src/pages/settings/MailVacationPage.spec.ts b/frontend/src/pages/settings/MailVacationPage.spec.ts index b36f711..ae76a7e 100644 --- a/frontend/src/pages/settings/MailVacationPage.spec.ts +++ b/frontend/src/pages/settings/MailVacationPage.spec.ts @@ -102,7 +102,7 @@ describe('MailVacationPage', () => { // 엔진이 바이트로 막는 한도 안 — 한글로 채워도 저장된다 (계약 2절 "부재중 자동 응답"의 "길이 한도의 근거") expect(wrapper.get('#vacation-subject').attributes('maxlength')).toBe('150') expect(wrapper.get('#vacation-body').attributes('maxlength')).toBe('600') - expect(wrapper.get('[data-vacation-preview-note]').text()).toBe('꺼져 있습니다 — 자동으로 답하지 않습니다.') + expect(wrapper.get('[data-vacation-preview-note]').text()).toBe('꺼져 있어 자동으로 답하지 않습니다.') }) it('저장한 값으로 채운다 — 날짜는 브라우저 시간대의 날짜로, 미리 보기는 보낸 사람 · 제목 · 내용', async () => { @@ -112,7 +112,7 @@ describe('MailVacationPage', () => { expect(value(wrapper, '#vacation-from')).toBe('2026-10-30') expect(value(wrapper, '#vacation-to')).toBe('2026-11-03') expect(wrapper.get('#vacation-period-hint').text()).toBe( - '시작일 0시부터 끝나는 날 자정까지 답합니다 (이 브라우저의 시간대 기준).', + '시작일 0시부터 끝나는 날 자정까지 답합니다. 시각은 이 브라우저의 시간대를 따릅니다.', ) expect(value(wrapper, '#vacation-subject')).toBe(SAVED.subject) const preview = wrapper.get('[data-vacation-preview]') @@ -120,7 +120,7 @@ describe('MailVacationPage', () => { expect(preview.get('[data-vacation-preview-subject]').text()).toBe(SAVED.subject) expect(preview.get('[data-vacation-preview-body]').element.textContent).toBe(SAVED.textBody) expect(wrapper.get('[data-vacation-preview-note]').text()).toBe( - '받은 메일의 보낸 사람에게 위 글로 답합니다. 같은 사람에게 매번 보내지는 않습니다(메일 엔진 규칙).', + '받은 메일의 보낸 사람에게 위 글로 답합니다. 같은 사람에게는 매번 보내지 않습니다.', ) }) @@ -215,7 +215,7 @@ describe('MailVacationPage', () => { expect(puts()).toEqual([{ ...SAVED, isEnabled: false, textBody: longBody }]) expect(wrapper.find('#vacation-body-error').exists()).toBe(false) - expect(wrapper.get('#vacation-save-status').text()).toBe('저장했습니다. 자동 응답을 껐습니다 — 써 둔 글은 남겨 둡니다.') + expect(wrapper.get('#vacation-save-status').text()).toBe('저장했습니다. 자동 응답을 껐고, 써 둔 글은 그대로 남아 있습니다.') expect(useMailVacationStore().vacation?.isEnabled).toBe(false) expect(value(wrapper, '#vacation-body')).toBe(longBody) }) @@ -227,7 +227,7 @@ describe('MailVacationPage', () => { await save(wrapper) expect(puts()).toEqual([{ ...SAVED, isEnabled: false }]) - expect(wrapper.get('#vacation-save-status').text()).toBe('저장했습니다. 자동 응답을 껐습니다 — 써 둔 글은 남겨 둡니다.') + expect(wrapper.get('#vacation-save-status').text()).toBe('저장했습니다. 자동 응답을 껐고, 써 둔 글은 그대로 남아 있습니다.') // 설정한 적 없는 계정을 그대로 꺼 둔 채 저장해도 된다 const { wrapper: fresh } = await mountPage() @@ -279,7 +279,7 @@ describe('MailVacationPage', () => { describe('엔진에서 규칙이 꺼졌을 때 (7단계 — 디자인 원본 MailFilterApplyStates)', () => { const INACTIVE_TEXT = - '켜 둔 자동 응답이 지금은 동작하지 않습니다. 메일 서버의 규칙이 다른 곳에서 바뀌었습니다 — 아래 [저장]을 누르면 다시 켜집니다.' + '다른 곳에서 메일 설정이 바뀌어 켜 둔 자동 응답이 지금은 나가지 않습니다. 아래 [저장]을 누르면 다시 켜집니다.' it('켜 두었는데 isActive: false 면 머리 아래 노란 줄(단추 없음) — 저장하면 isActive 없이 보내고 응답의 값으로 내린다', async () => { const { wrapper } = await mountPage({ diff --git a/frontend/src/pages/share/SharePage.spec.ts b/frontend/src/pages/share/SharePage.spec.ts index b3b0180..6a24a10 100644 --- a/frontend/src/pages/share/SharePage.spec.ts +++ b/frontend/src/pages/share/SharePage.spec.ts @@ -108,11 +108,11 @@ describe('SharePage', () => { // 이름과 주소 사이 띄어쓰기는 괄호 칸 안에 둔다 — 좁은 화면은 괄호와 함께 감추고 주소를 다음 줄로 (디자인 원본 SharePage "김아라 ") expect(view.get('.share__intro').text()).toContain('보낸 사람 김아라 · ARA SYSTEM') expect(view.get('.share__intro').text()).toContain( - '2026년 11월 29일(일)까지 내려받을 수 있습니다 (한국 시간) · 30일 남음', + '한국 시간으로 2026년 11월 29일(일)까지 내려받을 수 있습니다. 30일 남았습니다.', ) expect(view.findAll('.share-file__meta').map((meta) => meta.text())).toEqual([ - '1.2 GB · 보안 검사 · 이상 없음', - '612 MB · 보안 검사 · 이상 없음', + '1.2 GB · 보안 검사 결과 이상 없음', + '612 MB · 보안 검사 결과 이상 없음', ]) const download = view.get('[data-share-file="f1"] a.share-file__download') expect(download.attributes('href')).toBe(`/api/public-links/${TOKEN}/files/f1`) @@ -122,11 +122,11 @@ describe('SharePage', () => { ) expect(view.get('.share__report').text()).toBe('받을 이유가 없는 파일이면 security@arasystem.it.kr 로 알려 주세요.') expect(view.get('.share__english').text()).toBe( - 'Files from 김아라 (ara.kim@arasystem.it.kr) — download by Nov 29, 2026 (KST). This page never asks for a login or password.', + 'Files from 김아라 (ara.kim@arasystem.it.kr). Download by Nov 29, 2026 (KST). This page will never ask you to log in or enter a password.', ) // 메일 제목 · 본문 · 받는 사람 · 받은 수는 보이지 않는다 expect(view.text()).not.toContain('회 받음') - expect(document.title).toBe('김아라 님이 보낸 파일 — ARA SYSTEM') + expect(document.title).toBe('김아라 님이 보낸 파일 · ARA SYSTEM') }) it('링크 정보는 쿠키 없이 · 캐시 없이 · 주소를 싣지 않고 묻는다 — 검색 엔진 · referrer 막기 머리도 둔다', async () => { @@ -192,7 +192,7 @@ describe('SharePage', () => { const view = await mountAt(TOKEN, { link: publicLink({ files: [{ ...DESIGN, status: 'scanning' }] }) }, { link: publicLink() }) expect(view.get('[data-share-view="scanning"] h1').text()).toBe('보안 검사 중입니다') - expect(view.get('.share__english').text()).toBe('Security scan in progress — please check again in a few minutes.') + expect(view.get('.share__english').text()).toBe('A security scan is in progress. Please check again in a few minutes.') await view.get('[data-share-recheck]').trigger('click') await flushPromises() @@ -213,7 +213,7 @@ describe('SharePage', () => { const infected = view.get('[data-share-file="f1"]') expect(infected.classes()).toContain('share-file--danger') - expect(infected.get('.share-file__meta').text()).toBe('1.2 GB · 위험 — 막았습니다') + expect(infected.get('.share-file__meta').text()).toBe('1.2 GB · 위험한 내용이 있어 막았습니다') expect(infected.find('.share-file__download').exists()).toBe(false) const unscannable = view.get('[data-share-file="f2"]') expect(unscannable.classes()).toContain('share-file--warning') @@ -287,7 +287,7 @@ describe('SharePage', () => { expect(view.get('h1').text()).toBe('ara.kim@arasystem.it.kr 님이 보낸 파일 1개 (1.2 GB)') expect(view.find('.share__email').exists()).toBe(false) - expect(document.title).toBe('ara.kim@arasystem.it.kr 님이 보낸 파일 — ARA SYSTEM') + expect(document.title).toBe('ara.kim@arasystem.it.kr 님이 보낸 파일 · ARA SYSTEM') }) it('불러오는 중 — 자리 그림은 숨기고 "불러오는 중"을 화면 낭독기에 읽어 준다 (리뷰 L9)', async () => { diff --git a/frontend/src/pages/share/SharePage.vue b/frontend/src/pages/share/SharePage.vue index 038ecaf..692cc60 100644 --- a/frontend/src/pages/share/SharePage.vue +++ b/frontend/src/pages/share/SharePage.vue @@ -267,7 +267,7 @@ watch(