Compare commits

..

6 Commits

Author SHA1 Message Date
ttipo c1d0808764 chore: 14단계 검색 브랜치(main 을 합친 판)를 문구 다듬기 브랜치에 합친다
CI / Frontend (lint · type-check · test · build) (pull_request) Successful in 12m25s
CI / Infra (compose · Dockerfile · nginx · 운영 스크립트) (pull_request) Successful in 1m42s
CI / 에러 코드 SSOT 동기화 검사 (pull_request) Successful in 10m58s
CI (Flutter) / Flutter (analyze · test) (pull_request) Successful in 20m34s
CI / Backend (lint · type-check · test · e2e · build) (pull_request) Successful in 14m34s
CI (Flutter) / Flutter (analyze · test) (push) Has been cancelled
CI / Backend (lint · type-check · test · e2e · build) (push) Has been cancelled
CI / Frontend (lint · type-check · test · build) (push) Has been cancelled
CI / Infra (compose · Dockerfile · nginx · 운영 스크립트) (push) Has been cancelled
CI / 에러 코드 SSOT 동기화 검사 (push) Has been cancelled
14단계 검색 브랜치에 main(#16 — 백엔드 의존성 넷 · 유예 표)을 합친 판을 받는다.
이 PR 이 14단계 PR 뒤에 빨리 감기로 합쳐지고 CI 가 합친 모습 그대로를 시험하게 한다. 충돌 없음.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-08 12:22:47 +09:00
ttipo d4ab06183c fix(backend): 엔진 검색의 기간 시작이 그 초에 받은 메일도 찾게 한다
Stalwart 0.16.27 의 Email/query after 는 그 초를 넣지 않는다(2026-11-08 개발 엔진에서 확인 — RFC 8621 은 "같거나 뒤").
그래서 색인이 준비되기 전에 쓰는 엔진 길의 조건 검색이 계약의 "after 이상"보다 1초 좁았다(시작 날 0시 정각에 받은 메일이 빠짐).
시각을 초로 올린 뒤 한 초 앞을 넘겨 색인 길(>=)과 같은 경계로 맞춘다(toJmapAfterInclusive).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-08 01:22:59 +09:00
ttipo e0036000e9 docs: 화면 문구 규칙 9 · 10 과 고친 글을 README · 계약 · 스킬 · 규칙 표에 적는다
디자인 README 「화면 문구」에 규칙 9(화면이 하는 일을 설명하지 않는다) · 10(번역투 ·
설계 말을 쓰지 않는다)과 11-07 적용 줄을 더하고, 옮겨 적은 화면 글 · 오류 사전 글을
새 글로 맞춘다(계약 · vue3 스킬 · 에러 코드 규칙 표 포함). 디자인 보드의 글은
그대로다.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-07 14:37:57 +09:00
ttipo 1bd4b3d626 fix(frontend): 화면 문구 75곳을 사람이 말하듯 다듬는다
2026-11-07 사용자가 "자연스러운 문구가 아니라 시스템, 설계 문구 형식"이라고 짚은
글을 고친다(규칙 9 · 10). "여기에 표시됩니다 · 보입니다 · 나타납니다"처럼 화면을
주어로 한 설명과 "맞는 메일 · 분류된 · 지원하지 않습니다 · 처리" 같은 번역투 · 설계
말을, 내 메일이 어떻게 되는지와 무엇을 하면 되는지로 바꾼다. 정할 것 여섯은 추천안
(읽기 칸이 비었을 때 "고른 메일이 없습니다", 필터 칸 이름 [받은편지함에 이미 있는
메일도 정리하기], 본문 안내에 원문 내려받기까지, 로그인 소개 글)이다. 같은 대화
칸의 펼친 줄은 본문 안내의 앞 문장만 둔다(그 칸의 ⋯ 는 위에 연 메일의 것이다).
사전 글을 견주던 시험은 사전을 참조하게 바꿨다. 시험 2738 → 2762.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-07 14:37:57 +09:00
ttipo c9e6858b2a feat(frontend): 조사 도우미가 호환 자모 · 숫자의 받침도 고른다
검색 결과 없음 · 주소록 찾기 글에 이/가 · 으로/로 조사를 붙이게 되면서, 초성
검색어(‘ㄱㅁ’)나 숫자로 끝나는 말이 "(으)로" · "이(가)" 꼴로 나왔다. 호환 자모
자음은 이름(기역 · 리을 …)대로 받침 있음(ㄹ 은 "로"), 모음은 받침 없음, 숫자는 읽는
소리(영 · 일 · 이 …)로 고른다. 라틴 글자 · 기호로 끝나면 지금처럼 괄호 꼴이다.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-07 14:37:56 +09:00
ttipo 76cbdcf74c fix: 오류 사전 문구 18곳을 사람이 말하듯 다듬는다
2026-11-07 사용자가 "자연스러운 문구가 아니라 시스템, 설계 문구 형식"이라고 짚은
글을 고친다(규칙 9 · 10 — 화면 설명 · 번역투 빼기). "일시적인 시스템 오류가
발생했습니다" · "요청하신 정보나 페이지를 찾을 수 없습니다" · "허용 범위를 초과했습니다"
같은 안내문 말투를 무엇이 안 됐고 무엇을 하면 되는지로 바꾼다.
적용하며 셋을 더 다듬었다: REQ_001 은 가장 자주 보는 로그인 · 코드 실패 한도와 진행
중인 일에 함께 맞게, MAIL_003 은 용량이 휴지통도 세므로 휴지통 비우기까지, FILE_001
은 전달하는 원본 첨부에도 맞게. 코드 · 상태 · 뜻은 그대로이고, 웹 · 앱 사전은 sync 로
다시 만들었다.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-07 14:09:51 +09:00
82 changed files with 613 additions and 356 deletions
+9 -9
View File
@@ -29,21 +29,21 @@ backend/src/common/errors/error-codes.ts ← 여기만 수정한다
| 코드 | HTTP | 상황 | 메시지 |
| --- | --- | --- | --- |
| SYS_001 | 500 | 미정의 예외·네트워크 장애 (최종 폴백) | "일시적인 시스템 오류가 발생했습니다. 잠시 후 다시 시도해 주세요." |
| REQ_001 | 429 | Rate Limit 초과 | "요청이 너무 많습니다. 잠시 후 다시 시도해 주세요." |
| AUTH_001 | 401 | 미인증 상태로 보호 자원 접근 | "로그인이 필요한 서비스입니다. 로그인 후 이용해 주세요." |
| AUTH_002 | — | **클라이언트 전용** · 세션 만료 | "안전을 위해 로그아웃 되었습니다. 다시 로그인해 주세요." |
| AUTH_003 | 403 | 권한 부족 | "해당 메뉴나 기능에 접근할 수 있는 권한이 없습니다." |
| AUTH_004 | 401 | 로그인 자격 증명 불일치 (계정 없음·비밀번호 틀림을 구분하지 않음) | "이메일 또는 비밀번호가 올바르지 않습니다." |
| SYS_001 | 500 | 미정의 예외·네트워크 장애 (최종 폴백) | "문제가 생겨 하던 일을 마치지 못했습니다. 잠시 뒤 다시 시도해 주세요." |
| REQ_001 | 429 | Rate Limit 초과 · 실패 횟수 한도 · 같은 일이 진행 중 | "너무 여러 번 시도했거나 같은 일이 이미 진행 중입니다. 잠시 뒤 다시 시도해 주세요." |
| AUTH_001 | 401 | 미인증 상태로 보호 자원 접근 | "로그인이 필요합니다. 로그인한 뒤 다시 시도해 주세요." |
| AUTH_002 | — | **클라이언트 전용** · 세션 만료 | "안전을 위해 로그아웃했습니다. 다시 로그인해 주세요." |
| AUTH_003 | 403 | 권한 부족 | "이 기능을 쓸 수 있는 권한이 없습니다." |
| AUTH_004 | 401 | 로그인 자격 증명 불일치 (계정 없음·비밀번호 틀림을 구분하지 않음) | "메일 주소나 비밀번호가 맞지 않습니다. 다시 확인해 주세요." |
| AUTH_005 | 400 | 로그인한 채 본인 확인 실패 — 비밀번호 변경의 지금 비밀번호가 틀림. **401 이 아니다** — 클라이언트는 401 이면 세션을 갱신하고 같은 요청을 다시 보낸다 | "지금 비밀번호가 올바르지 않습니다." |
| AUTH_006 | 400 | 2단계 인증 코드 틀림(로그인 코드 단계 · 켜기 · 끄기 · 비밀번호 바꾸기 — 12단계). 401 이 아니다(위와 같은 까닭) | "코드가 올바르지 않습니다. 휴대폰의 날짜와 시간이 자동으로 맞춰지도록 설정되어 있는지 확인해 주세요." |
| AUTH_006 | 400 | 2단계 인증 코드 틀림(로그인 코드 단계 · 켜기 · 끄기 · 비밀번호 바꾸기 — 12단계). 401 이 아니다(위와 같은 까닭) | "코드가 맞지 않습니다. 휴대폰 설정에서 날짜와 시간을 자동으로 맞추게 해 두었는지 확인해 주세요." |
| AUTH_007 | 400 | 2단계 인증을 켠 직원인데 코드가 없다 — 화면은 코드 칸을 보인다 | "인증 앱의 코드를 입력해 주세요." |
| AUTH_008 | 409 | 2단계 등록이 그사이 바뀌었거나 10분이 지났다 · 이미 켜져 있다 — 처음부터(새 키) | "2단계 인증 설정이 그사이 바뀌었거나 시간이 지났습니다. 처음부터 다시 해 주세요." |
| AUTH_009 | 409 | 앱 비밀번호 상한(5개) | "5개까지 만들 수 있습니다. 쓰지 않는 앱 비밀번호를 지운 뒤 만들어 주세요." |
| AUTH_010 | 403 | 제한된 세션(새 비밀번호 정하기 · 2단계 인증 켜기 전 — 13단계)으로 허용하지 않은 API. 화면은 알림 없이 `me` 를 다시 받아 그 단계로 간다. 403 의 상태 역매핑은 그대로 `AUTH_003` | "먼저 비밀번호를 바꾸거나 2단계 인증을 켜 주세요." |
| VAL_001 | 400 / 422 | 입력 검증 실패(빠진 값 · 형식 · 길이 · 개수 한도) | "입력하신 내용을 다시 확인해 주세요. 빠진 칸이 있거나 형식이나 길이가 맞지 않습니다." |
| RES_001 | 404 | 리소스 없음 | "요청하신 정보나 페이지를 찾을 수 없습니다." |
| BIZ_001 | 400 | 그 외 비즈니스 규칙 위반 | "요청하신 작업을 완료하지 못했습니다. 다시 시도해 주세요." |
| RES_001 | 404 | 리소스 없음 | "찾을 수 없습니다. 이미 지워졌거나 옮겨졌을 수 있습니다." |
| BIZ_001 | 400 | 그 외 비즈니스 규칙 위반 | "하려던 일을 마치지 못했습니다. 다시 시도해 주세요." |
### AUTH_002 (클라이언트 전용)
+1 -1
View File
@@ -60,7 +60,7 @@ description: Vue 3 프론트엔드(frontend/) 개발 규칙 — Composition API
| 메일함 관리 · 오래 걸리는 처리 (4단계) | `src/components/mail/MailboxTree.vue` · `MailboxPanelRow.vue` · `MailboxMoreButton.vue`(⋯ 메뉴) · `MailboxDialogs.vue`(+ `Mailbox*Dialog.vue`) · `MailboxEmptyButton.vue`(목록 머리 「비우기」) · `composables/useMailboxActions.ts` · `stores/mail/mailbox-admin.ts` · `shared/utils/mailbox-operation.ts` · 상수 `shared/constants/mailbox-manage.ts` | 만들기 · 이름 바꾸기는 칸 안 실패(`MAIL_009` · `VAL_001`)를 알림 없이 칸 아래에. 모두 읽음 · 비우기 · 지우기는 남은 수가 0(지우기는 `deleted: true`)이 될 때까지 다시 보내며 진행 알림을 고친다 — 진행은 응답의 `processed`, 세션이 바뀌면 멈춘다(`epoch` — 늦게 온 만들기 · 이름 바꾸기 응답도 버린다), 같은 메일함의 같은 처리는 하나, **휴지통 비우기와 메일함 지우기는 함께 돌리지 않는다**(비우기는 그사이 휴지통에 들어온 메일도 지운다 — 판정은 스토어의 `isMailboxOperationBlocked` 하나, 메뉴 · 단추 · 대화상자 확인이 모두 이것을 본다). 끝난 뒤 누른 단추가 사라져 초점을 잃었으면 패널의 지금 메일함 항목으로 옮긴다. ⋯ 메뉴는 보이는 영역 안에 다 들게 연다(`shared/utils/popup-placement.ts` — 아래가 모자라면 위로). 되돌릴 수 없는 일은 확인 대화상자(처음 초점 「취소」) 뒤에, 확인한 그 메일함만 — 계약 2절 "메일함 관리" |
| 스팸 · 원문 (4단계) | `src/components/mail/MessageSpamButton.vue`(읽기 화면) · 도구 줄의 스팸 단추(`MailSelectionToolbar.vue`) · `MessageMoreMenu.vue`(⋯ › 원문 내려받기) | 스팸 신고 · 스팸 아님은 이동과 `isJunk` 를 한 요청에 보내고(`mailStore.moveMessages(…, { isJunk })`), [되돌리기]는 원래 메일함으로 반대 값을 보낸다. 원문(.eml)은 `useMail().downloadMessageSource()` — 첨부처럼 `confirmSession()` 을 먼저 거친다 — 계약 2절 "스팸 · 원문" |
| 메일 필터 (6단계) | `src/pages/settings/MailFilterPage.vue` · `components/settings/MailFilter*.vue`(목록 · 행 · 목록 위 줄 `MailFilterNotice` · 빈 상태 · 안내 상자 `MailFilterNotes` · 고치기 창 `MailFilterDialog` + 조건 `MailFilterConditionFields` · `MailFilterConditionRow` · 할 일 `MailFilterActionFields`) · `mail-filter-ui.ts` · `stores/mail-filter.store.ts` · `composables/useMailFilterActions.ts`(켜기 · 순서 · 지우기 · 되돌리기 알림) · `useMailFilterDialog.ts`(창 상태) · `shared/utils/mail-filter.ts`(검사 · 요약 · 옮길 곳 · 되돌릴 자리) · `shared/constants/mail-filter.ts` · 읽기 화면 ⋯ 「이런 메일로 필터 만들기」(`MessageMoreMenu.vue` → `/settings/mail/filters?from=`) | 바꾸는 일은 모두 `PUT /api/mail-filters` 전체 교체(받은 `version`)이고 **저장은 한 번에 하나**(`saving`). 켜기 · 순서는 화면을 먼저 바꾸고 실패하면(그사이 목록이 안 바뀌었으면) 되돌린다. 창의 저장은 그 순간 목록의 켜짐 값을 쓴다. [되돌리기]는 지운 필터만 앞 · 뒤 필터 기준 자리에 다시 넣고, 누른 차례대로 앞 저장이 끝난 뒤 보낸다(`restoreQueue` — 기다림과 `send()` 사이에 await 를 두지 않는다 — 2026-10-28 리뷰의 경합). `MAIL_012` 는 목록 위 줄 + 창 안 안내와 [다시 불러오기](쓴 내용을 지키고 다시 받은 목록 위에 저장), 늦게 온 409 는 보낼 때의 순번(seq)이 그대로일 때만 줄을 켠다. 검사는 서버와 같은 규칙(전달 주소의 @ 앞은 dot-atom · 64자로 서버보다 좁게)이고 거절은 칸 아래(`VAL_001` · `MAIL_013` 은 알림을 끈다). 필터 화면이 떠 있지 않을 때 실패한 되돌리기는 알림으로(`attachPage` · `pageVisible`). 세션이 바뀌면 늦은 응답을 버린다(`authStore.generation`) — 계약 2절 "메일 필터", 디자인 README "6단계" |
| 이미 받은 메일에 필터 적용 (7단계) | `src/components/settings/MailFilterApplyField.vue`(고치기 창의 「받은편지함에 이미 있는 메일에도 적용」 — 창을 열 때마다 꺼짐) · `composables/useMailFilterRun.ts`(진행 · 결과 알림) · `stores/mail-filter-run.store.ts`(cursor 를 이어 보내기 — 탭마다 하나 `running`) · `shared/utils/mail-filter-run.ts`(할 일 판정 · 보낼 규칙 · 끝 알림의 메일함 · 문구) | 저장이 성공한 뒤에만 `POST /api/mail-filter-runs` 를 `cursor` 가 null 이 될 때까지 다시 보내며 진행 알림을 고친다(4단계 오래 걸리는 처리와 같은 결 — 전체는 확인 + `remaining`, 첫 응답 전에는 받은편지함의 메일 수). 전달은 보내지 않는다. 이미 받은 메일에 할 일이 없는 필터(옮길 메일함이 지워짐 · 받은편지함으로 옮기기만 · 전달만)는 칸을 끄고 시작하지도 않는다 — 판정은 `filterRunIdleReason` 하나(칸 `filterApplyBlock` · 시작 `filterRunRuleOf` 가 함께 쓴다), 이 탭에서 적용이 도는 중에도 칸을 끈다. 요청은 `silent` 로 보내고 실패는 응답이 온 때 세션이 그대로일 때만 스토어가 알린다(`REQ_001` — 다른 창 · PC 에서 적용 중 — 은 화면이 "다른 창에서 …"로). 멈추면 [다시 시도](받은 `cursor` 부터 — 수도 이어 센다), 나아가지 못한 응답이 거듭되면(`MAIL_FILTER_RUN_MAX_STALLS`) 멈춘다. 다른 화면으로 가도 이어 하고, 세션이 바뀌면 다음 요청을 보내지 않고 알림을 닫는다. 끝났거나 나아간 뒤 멈췄으면 개수 · 목록을 맞춘다 — 계약 2절 "이미 받은 메일에 적용", 디자인 README "7단계" |
| 이미 받은 메일에 필터 적용 (7단계) | `src/components/settings/MailFilterApplyField.vue`(고치기 창의 「받은편지함에 이미 있는 메일도 정리하기」 — 창을 열 때마다 꺼짐) · `composables/useMailFilterRun.ts`(진행 · 결과 알림) · `stores/mail-filter-run.store.ts`(cursor 를 이어 보내기 — 탭마다 하나 `running`) · `shared/utils/mail-filter-run.ts`(할 일 판정 · 보낼 규칙 · 끝 알림의 메일함 · 문구) | 저장이 성공한 뒤에만 `POST /api/mail-filter-runs` 를 `cursor` 가 null 이 될 때까지 다시 보내며 진행 알림을 고친다(4단계 오래 걸리는 처리와 같은 결 — 전체는 확인 + `remaining`, 첫 응답 전에는 받은편지함의 메일 수). 전달은 보내지 않는다. 이미 받은 메일에 할 일이 없는 필터(옮길 메일함이 지워짐 · 받은편지함으로 옮기기만 · 전달만)는 칸을 끄고 시작하지도 않는다 — 판정은 `filterRunIdleReason` 하나(칸 `filterApplyBlock` · 시작 `filterRunRuleOf` 가 함께 쓴다), 이 탭에서 적용이 도는 중에도 칸을 끈다. 요청은 `silent` 로 보내고 실패는 응답이 온 때 세션이 그대로일 때만 스토어가 알린다(`REQ_001` — 다른 창 · PC 에서 적용 중 — 은 화면이 "다른 창에서 …"로). 멈추면 [다시 시도](받은 `cursor` 부터 — 수도 이어 센다), 나아가지 못한 응답이 거듭되면(`MAIL_FILTER_RUN_MAX_STALLS`) 멈춘다. 다른 화면으로 가도 이어 하고, 세션이 바뀌면 다음 요청을 보내지 않고 알림을 닫는다. 끝났거나 나아간 뒤 멈췄으면 개수 · 목록을 맞춘다 — 계약 2절 "이미 받은 메일에 적용", 디자인 README "7단계" |
| 주소록 (8단계) | `src/pages/contacts/`(`ContactsLayout.vue` — 패널 · 창 묶음 `ContactsDialogs` 의 자리 · `ContactsPage.vue`) · `components/contacts/`(패널 `ContactsPanel` · 그룹 줄 `ContactsGroupRow` · 목록 `ContactsList` · `ContactsListItem` · 상세 `ContactDetail` · 찾기 `ContactsSearchBox` · 연락처 창 `ContactDialog` + 줄 `ContactEntryRows` · 그룹 이름 창 · 구성원 넣기 창 `ContactGroupMembersDialog` · ⋯ `ContactMoreMenu` · `ContactAvatar` · `contacts-ui.ts`) · `stores/contacts.store.ts` · `composables/useContacts.ts`(API) · `useContactActions.ts`(지우기 · [되돌리기] · 그룹에 메일 쓰기 · 고치기 열기) · `useContactsDialogs.ts`(창 상태) · `useContactDuplicateCheck.ts`(같은 주소 경고) · `shared/utils/contacts.ts`(차례 `compareContacts` — **backend `contact-search.ts` 와 짝** · 칸 정리 · 바꾼 칸 `contactPatchOf` · 검사) · `contacts-route.ts` · `shared/constants/contacts.ts`(라우트 `CONTACTS_ROUTES` · 한도) · `shared/utils/clipboard.ts` | 레일의 주소록 모듈(`app-modules.ts`). 고치기는 창을 열 때 그 연락처를 새로 받고 **바꾼 칸만 `PATCH`** 한다(검사도 바꾼 칸만 — 손대지 않은 남의 값은 그대로, 바꾼 것이 없으면 보내지 않는다). 지우기는 확인 없이 하고 [되돌리기]는 지우기 응답의 `restoreToken` 으로 `POST /contact-restorations`(표가 없으면 단추 없이 알린다). 바꾼 연락처는 응답을 서버의 차례로 받은 쪽 안의 자리에 넣는다(스토어의 `placeContact` — 자리를 모를 때만 첫 쪽을 한 번 다시 받는다, 바꿀 때마다 불러온 쪽을 모두 다시 받지 않는다). 구성원 넣기 창은 `excludeGroupId` 로 받고, 주소로 연 사내 항목은 `GET /directory-entries/{id}` 로. 창은 주소록 틀(`ContactsLayout`)에 둔다 — 주소록 안에서 옮겨 다녀도 남고, 떠날 때는 나가기 확인(`useDialogLeaveConfirm`), 세션이 바뀌면 닫는다. 저장 중에 창을 닫았으면 창 안에 보일 거절도 `notifyApiError` 로. 이메일은 서버보다 좁게 본다(`mail-address.ts` 의 `isStrictEmail` — 필터의 전달 주소와 공용), 이메일 칸은 `type="text"` + `inputmode="email"`(브라우저가 한글 도메인을 punycode 로 바꾸지 않게). 전환은 `.segmented--outline`(base.css), 도구 단추 `components/mail/MailToolButton.vue` 의 `unavailable`(aria-disabled — 클릭 무시) · `busy`(aria-busy) · `describedby` — 계약 3절, 디자인 README "8단계" |
| 메일과 함께 (8단계) | `shared/utils/compose-prefill.ts` · `components/mail/MailContactDialogHost.vue` + `composables/useMailContactDialog.ts` · `MessageMoreMenu.vue`(⋯ 「보낸 사람을 주소록에 추가」 · 「주소록에서 보기」) · 받는 사람 칸의 그룹(`useRecipientSuggestions` 의 `optionsFor` · `expandGroup`, `recipient-match.ts` 의 `matchContactGroups` · `arrangeRecipientOptions`) | 쓰기 화면을 받는 사람을 채워 열 때는 라우터 상태(`composeTo` — 주소는 URL 에 싣지 않는다)에 연 직원의 주소를 함께 싣고, 지금 직원과 다르면 쓰지 않는다(같은 탭에서 다른 직원이 로그인한 뒤 뒤로 가기). 읽기 화면의 연락처 추가 창은 메일 레이아웃(`MailLayout` 이 provide)에 둔다 — 메일을 바꾸거나 목록으로 돌아가도 남는다(읽기 화면 안 `v-if="message"` 에 두면 사라진다). 받는 사람 추천은 주소록 → 그룹 → 사내 → 최근. 그룹은 쓰기 화면을 열 때 받아 둔 목록에서 이름으로 찾고, 고르면 구성원을 받아 대표 주소를 넣는다 — 그룹마다 따로 기다려 이어 골라도 앞 그룹이 빠지지 않는다 — 계약 3절 "메일과 함께" |
| 보내기 취소 · 예약 발송 (9단계) | `src/components/mail/ComposeFooter.vue`([보내기 ▾] — 메뉴 자리는 `composables/usePopupPlacement.ts`, 메일함 ⋯ 과 공용) · `ComposeScheduleDialog.vue` · `shared/utils/schedule-send.ts`(고르기 · 한도 · `rejectedScheduleProblem`) · `shared/utils/pending-send.ts`(`isHeldMessage` · `isScheduledMessage` — 응답의 `pendingSend` 는 이것으로만 읽는다) · `stores/mail/scheduled.ts` · `composables/useScheduledMessageActions.ts` · `usePendingSendRefresh.ts` · `components/mail/MailboxScheduledRow.vue` · `ScheduledMessageList.vue` · `ScheduledMessageItem.vue` · `MessageReaderNotices.vue` · 마감 알림 `toast.store` 의 `expiresAt` + `components/feedback/ToastCountdown.vue` | 기다리는 곳은 엔진이다(계약 2절 "보내기 취소 · 예약 발송"). 보내기 취소 알림의 마감은 설정한 초를 알면 "지금 + 초 − 1초"(설정 스토어의 `knownUndoSendSeconds` — 메일 화면에 들어올 때 `ensureLoaded`), 모르면 min(sendAt, 지금 + 30초). 마감 알림은 멈추지 않고 넘쳐도 먼저 닫히지 않는다. 알림 단추는 `toastStore.takeAction(id)` 로 한 번만, 같은 메일의 취소는 스토어가 요청 하나로 묶는다. 초점이 든 알림이 닫히면 들어오기 전 요소로(없으면 `<main>` — `data-focus-fallback`). **붙잡힌 메일은 취소로만 보낸편지함을 떠난다** — 고르기 칸 `MailCheckbox` 의 `unavailable` · `note`(aria-disabled + 까닭), 고르기 · 범위 · 모두 고르기 · `checkedMessages` 가 건너뛰고, 읽기 화면 [삭제]는 확인(처음 초점 「취소」) → 취소 → 휴지통(서버도 `MAIL_015` 로 막는다). 엔진이 풀어도 상태가 바뀌지 않아 `usePendingSendRefresh` 가 가장 이른 시각 + 2초(남으면 5초 × 3)에 목록 · 열린 메일 · 「예약됨」 수를 다시 받는다. 응답을 기다린 뒤 다음 메일로 옮기는 일(`useMessageRemoval` 의 `leaveRemoved(…, messageId)`)은 같은 목록 · 같은 메일을 보고 있을 때만 — 디자인 README "9단계" |
+21 -19
View File
@@ -20,30 +20,32 @@ export interface ErrorCodeDefinition {
const COMMON_ERROR_CODES = {
SYS_001: {
status: HttpStatus.INTERNAL_SERVER_ERROR,
message: '일시적인 시스템 오류가 발생했습니다. 잠시 후 다시 시도해 주세요.',
message:
'문제가 생겨 하던 일을 마치지 못했습니다. 잠시 뒤 다시 시도해 주세요.',
},
REQ_001: {
status: HttpStatus.TOO_MANY_REQUESTS,
message: '요청이 너무 많습니다. 잠시 후 다시 시도해 주세요.',
message:
'너무 여러 번 시도했거나 같은 일이 이미 진행 중입니다. 잠시 뒤 다시 시도해 주세요.',
},
AUTH_001: {
status: HttpStatus.UNAUTHORIZED,
message: '로그인이 필요한 서비스입니다. 로그인 후 이용해 주세요.',
message: '로그인이 필요합니다. 로그인한 뒤 다시 시도해 주세요.',
},
AUTH_002: {
status: HttpStatus.UNAUTHORIZED,
message: '안전을 위해 로그아웃 되었습니다. 다시 로그인해 주세요.',
message: '안전을 위해 로그아웃했습니다. 다시 로그인해 주세요.',
// 세션 만료는 클라이언트가 "인증된 세션이 있었는데 401 을 받은" 상황에서 생성한다.
clientOnly: true,
},
AUTH_003: {
status: HttpStatus.FORBIDDEN,
message: '해당 메뉴나 기능에 접근할 수 있는 권한이 없습니다.',
message: '이 기능을 쓸 수 있는 권한이 없습니다.',
},
// 로그인 자격 증명 불일치 — 계정 없음/비밀번호 틀림을 구분하지 않는다 (.claude/rules/security.md)
AUTH_004: {
status: HttpStatus.UNAUTHORIZED,
message: '이메일 또는 비밀번호가 올바르지 않습니다.',
message: '메일 주소나 비밀번호가 맞지 않습니다. 다시 확인해 주세요.',
},
// 비밀번호 바꾸기(PUT /api/auth/password)의 지금 비밀번호가 틀림 — 세션은 그대로다.
// 401 이 아니라 400 인 까닭: 웹 클라이언트는 401 이면 세션을 갱신하고 같은 요청을 다시 보낸다 (계약 1절)
@@ -56,7 +58,7 @@ const COMMON_ERROR_CODES = {
AUTH_006: {
status: HttpStatus.BAD_REQUEST,
message:
'코드가 올바르지 않습니다. 휴대폰의 날짜와 시간이 자동으로 맞춰지도록 설정되어 있는지 확인해 주세요.',
'코드가 맞지 않습니다. 휴대폰 설정에서 날짜와 시간을 자동으로 맞추게 해 두었는지 확인해 주세요.',
},
// 2단계 인증을 켠 직원의 요청에 코드가 없음 — 다른 곳에서 켠 뒤의 화면. 화면은 코드 칸을 보인다 (계약 5절)
AUTH_007: {
@@ -88,11 +90,11 @@ const COMMON_ERROR_CODES = {
},
RES_001: {
status: HttpStatus.NOT_FOUND,
message: '요청하신 정보나 페이지를 찾을 수 없습니다.',
message: '찾을 수 없습니다. 이미 지워졌거나 옮겨졌을 수 있습니다.',
},
BIZ_001: {
status: HttpStatus.BAD_REQUEST,
message: '요청하신 작업을 완료하지 못했습니다. 다시 시도해 주세요.',
message: '하려던 일을 마치지 못했습니다. 다시 시도해 주세요.',
},
} as const satisfies Record<string, ErrorCodeDefinition>;
@@ -109,24 +111,24 @@ const COMMON_ERROR_CODES = {
const DOMAIN_ERROR_CODES = {
FILE_001: {
status: HttpStatus.BAD_REQUEST,
message: '허용되지 않는 파일 형식입니다. 파일을 확인해 주세요.',
message:
'쓸 수 없는 형식의 파일입니다. 그 파일을 빼거나 다른 파일로 바꿔 주세요.',
},
FILE_002: {
status: HttpStatus.PAYLOAD_TOO_LARGE,
message: '파일 크기가 허용 범위를 초과했습니다.',
message: '파일이 너무 커서 올릴 수 없습니다.',
},
// 대용량 첨부(11단계 — 계약 4절) — 올리기 · 파일의 지금 상태와 맞지 않는 요청: 끝낸 올리기에 조각 · 같은 조각을 동시에 · 받은 조각과 내용이 다른 조각 ·
// 빠진 조각이 있는데 끝내기 · 끝내기가 진행 중 · 살아 있는 링크에 실린 파일 지우기. 아무것도 바꾸지 않았다 — 클라이언트는 GET 으로 상태를 보고 잇는다
FILE_003: {
status: HttpStatus.CONFLICT,
message:
'파일 상태가 그사이 바뀌어 처리하지 못했습니다. 다시 시도해 주세요.',
'그사이 파일이 바뀌어 하던 일을 마치지 못했습니다. 다시 시도해 주세요.',
},
// 조각의 Content-Digest(sha-256)가 받은 바이트와 다르다 — 그 조각을 받지 않았다 (같은 조각을 다시 보낸다)
FILE_004: {
status: HttpStatus.BAD_REQUEST,
message:
'파일을 올리는 중에 일부가 제대로 전달되지 않았습니다. 다시 시도해 주세요.',
message: '파일 일부가 제대로 올라가지 않았습니다. 다시 시도해 주세요.',
},
// 직원 보관 한도(보관량 · 보내지 않은 파일 수)를 넘는다 — 만들지 않았다. 문구에 숫자를 넣지 않는다(한도가 env 로 바뀐다)
FILE_005: {
@@ -138,7 +140,7 @@ const DOMAIN_ERROR_CODES = {
FILE_006: {
status: HttpStatus.INSUFFICIENT_STORAGE,
message:
'서버의 저장 공간이 모자라 지금은 파일을 올릴 수 없습니다. 잠시 뒤 다시 시도해 주세요.',
'회사 저장 공간이 모자라 지금은 파일을 올릴 수 없습니다. 잠시 뒤 다시 시도해 주세요.',
},
// 더 내려받을 수 없는 파일 — 기간 지남 · 공유 멈춤 · 횟수 초과 · 위험 · 찾을 수 없음 (공개 받기 · 우리 웹메일 받기)
FILE_007: {
@@ -163,14 +165,14 @@ const DOMAIN_ERROR_CODES = {
MAIL_003: {
status: HttpStatus.BAD_REQUEST,
message:
'메일함 용량이 부족합니다. 불필요한 메일을 정리한 뒤 다시 시도해 주세요.',
'메일함이 가득 찼습니다. 필요 없는 메일을 지우고 휴지통을 비운 뒤 다시 시도해 주세요.',
},
// 발송 때 엔진에서 첨부(blob)를 찾지 못함 — 올려 두고 오래 지나 만료된 업로드·그사이 지워진 원본 메일의 첨부(전달)·잘못된 첨부 id.
// 문구는 원인을 단정하지 않는다 — 올린 파일은 다시 올리고, 전달하는 원본 첨부는 빼야 한다 (계약 2절)
MAIL_004: {
status: HttpStatus.BAD_REQUEST,
message:
'첨부 파일을 찾을 수 없습니다. 해당 첨부를 빼거나 다시 올린 뒤 보내 주세요.',
'첨부 파일을 찾을 수 없습니다. 그 파일을 빼거나 다시 올린 뒤 보내 주세요.',
},
// 같은 Idempotency-Key 로 이미 보낸 메일과 내용(받는 사람·제목·본문·첨부)이 다르다 — 보내지 않는다.
// 첫 메일이 실제로 나갔을 수 있으므로 클라이언트는 보낸편지함을 확인하게 하고 새 키로 다시 보내게 한다
@@ -183,7 +185,7 @@ const DOMAIN_ERROR_CODES = {
// 문구의 숫자는 attachment.policy.ts 의 MAX_ATTACHMENTS_PER_MESSAGE 와 같아야 한다 (attachment.policy.spec.ts 가 확인한다)
MAIL_006: {
status: HttpStatus.BAD_REQUEST,
message: '첨부는 한 메일에 최대 20개까지 보낼 수 있습니다.',
message: '첨부 파일은 메일 한 통에 20개까지 보낼 수 있습니다.',
},
// 본문이 너무 길다 — 텍스트 100,000자·서식 본문 500,000자 초과, 서식 본문이 정화 전 비용 한도나 정화 뒤 2,000,000바이트를 넘음.
// 메일은 나가지 않는다. 클라이언트는 보내기 전에 같은 한도로 미리 막고 본문 아래에 이 문구를 보인다 (계약 2절)
@@ -248,7 +250,7 @@ const DOMAIN_ERROR_CODES = {
MAIL_016: {
status: HttpStatus.BAD_REQUEST,
message:
'대용량 첨부 파일을 보낼 수 없습니다. 해당 파일을 빼거나 다시 올린 뒤 보내 주세요.',
'보낼 수 없는 대용량 첨부 파일이 있습니다. 그 파일을 빼거나 다시 올린 뒤 보내 주세요.',
},
// 주소록(8단계) — 그룹 만들기 · 이름 바꾸기 때 이 주소록에 같은 이름의 그룹이 있다(앞뒤 공백을 빼고 ASCII 대소문자를 무시하고 견준다).
// 화면은 이름 칸 아래에 알림 없이 보인다 (계약 3절)
@@ -150,7 +150,7 @@ describe('엔진 웹훅 본문 파서 (createMailEngineEventsBodyParser)', () =>
success: false,
error: {
code: 'AUTH_001',
message: '로그인이 필요한 서비스입니다. 로그인 후 이용해 주세요.',
message: '로그인이 필요합니다. 로그인한 뒤 다시 시도해 주세요.',
traceId: 'trace-e',
},
};
@@ -898,7 +898,7 @@ describe('JmapMailProvider', () => {
],
];
it('조건은 칸마다 엔진 필터로 — 받는 사람은 to · cc 가운데 하나, 기간은 받은 시각(after 이상 · before 미만), 첨부는 hasAttachment', async () => {
it('조건은 칸마다 엔진 필터로 — 받는 사람은 to · cc 가운데 하나, 기간은 받은 시각(after 이상 — 엔진의 after 는 그 초를 넣지 않아 한 초 앞 · before 미만), 첨부는 hasAttachment', async () => {
const { provider, recorded } = fakeClient([
mailboxRoles,
searchResponses(['m1'], 1),
@@ -942,13 +942,37 @@ describe('JmapMailProvider', () => {
operator: 'OR',
conditions: particleForms('견적').map((body) => ({ body })),
},
{ after: '2026-10-31T15:00:00Z' },
{ after: '2026-10-31T14:59:59Z' },
{ before: '2026-11-30T15:00:00Z' },
{ hasAttachment: true },
]);
expect(page.items.map((item) => item.id)).toEqual(['m1']);
});
it('기간의 after 는 색인 길(>=)과 같은 경계로 — 정수 초는 한 초 앞(달 · 날이 바뀌는 0시도), 소수 초는 초로 올린 값의 한 초 앞(= 그 초)', async () => {
const cases: [string, string][] = [
['2026-11-01T00:00:00.000Z', '2026-10-31T23:59:59Z'],
['2026-10-31T15:00:00Z', '2026-10-31T14:59:59Z'],
['2026-10-31T15:00:00.500Z', '2026-10-31T15:00:00Z'],
['2026-10-31T15:00:00.001Z', '2026-10-31T15:00:00Z'],
['2026-10-31T15:00:00.999Z', '2026-10-31T15:00:00Z'],
];
for (const [after, engineAfter] of cases) {
const { provider, recorded } = fakeClient([
mailboxRoles,
searchResponses([], 0),
]);
await provider.listMessages(ARA, { limit: 20, conditions: { after } });
const { filter } = argsOf(recorded[1]) as {
filter: { conditions: unknown[] };
};
expect({ after, conditions: filter.conditions.slice(1) }).toEqual({
after,
conditions: [{ after: engineAfter }],
});
}
});
it('검색어와 조건은 함께 AND — 커서 지문에 조건이 들어가 조건이 다른 검색의 커서는 엔진에 묻기 전에 VAL_001', async () => {
const conditions = { from: '이도윤', hasAttachment: true as const };
const first = fakeClient([
@@ -134,6 +134,7 @@ import {
selectAccountQuota,
toAddresses,
toDetailSource,
toJmapAfterInclusive,
toJmapUtcDate,
toMailbox,
toSummary,
@@ -310,6 +311,7 @@ function conditionsKey(
/**
* 조건 → 엔진 필터 조건들 (AND 로 묶어 쓴다) — 글 칸은 검색어와 같은 낱말 · 구절 · 조사 형태, 기간은 받은 시각(after 이상 · before 미만), 첨부는 엔진의 판정.
* after 는 엔진이 그 초를 넣지 않아 한 초 앞을 넘긴다(toJmapAfterInclusive — 색인 길의 `>=` 와 같은 경계)
* 글을 준 칸에 찾을 낱말이 하나도 없으면 null — 아무것도 맞지 않는다(묻지 않고 빈 결과)
*/
function conditionFilters(
@@ -325,7 +327,7 @@ function conditionFilters(
filters.push(...found);
}
if (conditions.after)
filters.push({ after: toJmapUtcDate(conditions.after) });
filters.push({ after: toJmapAfterInclusive(conditions.after) });
if (conditions.before) {
filters.push({ before: toJmapUtcDate(conditions.before) });
}
@@ -378,6 +378,25 @@ export function toJmapUtcDate(iso: string): string {
return new Date(Date.parse(iso)).toISOString().replace(/\.\d+Z$/, 'Z');
}
/** JMAP UTCDate 의 단위 — 초 (엔진은 받은 시각을 초로 둔다) */
const JMAP_DATE_RESOLUTION_MS = 1_000;
/**
* "받은 시각이 그 시각 이상"을 엔진의 Email/query `after` 값으로 — 엔진의 after 는 **그 초를 넣지 않는다**
* ("그 초보다 뒤" — RFC 8621 은 "같거나 뒤"라 적지만 Stalwart 0.16.27 은 아니다, 2026-11-08 개발 엔진에서 확인).
* 받은 시각은 초라 "그 시각 이상"은 "그 시각을 초로 올린 값 이상"이고, 엔진에는 그 한 초 앞을 넘긴다 — 색인 길(mail-search/search-sql.ts 의 `>=`)과
* 같은 경계다. 화면이 보내는 0시(정수 초)면 한 초 앞, 소수 초면 그 초 그대로.
* ⚠️ 엔진이 RFC 대로 같은 초를 넣게 바뀌면 한 초 앞의 메일까지 맞는다 — 엔진 판을 올릴 때 다시 본다
*/
export function toJmapAfterInclusive(iso: string): string {
const ceiled =
Math.ceil(Date.parse(iso) / JMAP_DATE_RESOLUTION_MS) *
JMAP_DATE_RESOLUTION_MS;
return toJmapUtcDate(
new Date(ceiled - JMAP_DATE_RESOLUTION_MS).toISOString(),
);
}
/** 발신 ID — 직원 주소와 같은 것을 고르고, 없으면 첫 번째 */
export function pickIdentity(
identities: JmapIdentity[],
+58 -43
View File
@@ -52,13 +52,13 @@
분할 보기 읽기·목록 보기 읽기·쓰기 세 곳이 같은 값을 쓰고, 툴바 첫 아이콘도 같은 32px 선에서 시작한다.
- 브랜드 컬러 "바다 청록": 바탕 `#0B3640`, 강조 `#0E7C86`. 파생 색은 각 화면 원본의 인라인 스타일을 따른다.
## 화면 문구 — 사용자에게 말하듯 (2026-11-05 사용자 요청)
## 화면 문구 — 사용자에게 말하듯 (2026-11-05 · 11-07 사용자 요청)
화면 · 알림 · 화면 낭독기(`aria-label` · `title`) · 칸 안내(placeholder)의 글은 설계 문서가 아니라 쓰는 사람에게 하는 말이다.
원본은 화면 상수(`frontend/src/shared/constants/`)이고 이 문서는 옮겨 적는다.
1. **무엇이 되는지 · 무엇을 하면 되는지**를 쓴다. 만든 쪽의 사정(메일 서버 · 엔진 · 계정 · 판정 · 적용 · 동작 · 규칙 · 대상)은 빼거나 쉬운 말로 바꾼다.
- ✗ 메일 서버에 계정이 있는 직원입니다 — 이름과 주소만 보입니다. → ○ 회사 메일을 쓰는 직원이라 이름과 메일 주소만 보입니다.
- ✗ 메일 서버에 계정이 있는 직원입니다 — 이름과 주소만 보입니다. → ○ 회사 메일을 쓰는 직원입니다. 사내 목록에는 이름과 메일 주소만 있습니다.
2. **줄표(—)로 잇지 않는다.** 두 문장으로 나누거나 "~해서 · ~니 · ~지만"으로 잇는다. 짧은 상태 표시도 줄표 대신 한 구절로(✗ 실패 — 비밀번호 틀림 → ○ 비밀번호가 틀려 실패).
결과 칸이 따로 있는 표라면 까닭만 써도 된다.
3. **「」 를 쓰지 않는다.** 화면의 단추 · 메뉴 · 칸 이름은 [대괄호](직원 공지와 같다 — [설정] → [2단계 인증]), 사용자가 넣은 값(이름 · 그룹 이름 · 파일 이름)은 지금처럼 ‘ ’.
@@ -71,6 +71,12 @@
단추 · 칸 이름 · 짧은 상태 꼬리표는 명사형 그대로.
7. **위험 · 보안 문구는 겁주지 않되 분명하게** — 무엇이 막혔고 무엇을 하면 되는지를 함께.
8. **뜻은 그대로** — 한도 · 날짜 · 개수 · 조건 같은 정보를 빼지 않는다. 문장만 바꾼다. 영어 문구(공개 내려받기 화면)는 자연스러운 영어로만 다듬는다.
9. **화면이 하는 일을 설명하지 않는다**(2026-11-07) — "여기에 표시됩니다 · 보입니다 · 나타납니다 · 펼쳐집니다 · 이어집니다"처럼 화면을 주어로 한 설명 대신, 내 메일이 어떻게 되는지 · 무엇을 하면 되는지를 쓴다.
- ✗ 삭제한 메일이 여기에 표시됩니다. → ○ 지운 메일은 여기에 30일 동안 남아 있다가 저절로 지워집니다.
- ✗ 목록에서 메일을 고르면 내용이 여기에 표시됩니다. → ○ 읽을 메일을 목록에서 골라 주세요.
10. **번역투 · 설계 말을 쓰지 않는다**(2026-11-07) — 목적어 없는 "맞는", "해당 · 대상 · 항목 · 처리 · 적용 · 분류 · 기준 · ~에 대한 · ~를 통해 · ~하는 것이 좋습니다 · 지원하지 않습니다", 명사 나열 대신 평소에 쓰는 말로.
- ✗ 아직 맞는 메일을 찾지 못했습니다 → ○ 아직 찾은 메일이 없습니다
- ✗ 일시적인 시스템 오류가 발생했습니다. → ○ 문제가 생겨 하던 일을 마치지 못했습니다.
적용하며 정한 것(2026-11-05):
- 한 줄에 여러 정보를 늘어놓는 짧은 표시의 가운뎃점은 나열이 아니라 칸 구분이라 그대로 둔다 — 파일 줄 "1.2 GB · 3회 받음 · 11월 29일까지", "‘회의’ · 6통",
@@ -80,6 +86,15 @@
- 서버가 만드는 글(에러 코드 사전 · 받은편지함 알림 메일 · 본문의 대용량 첨부 덩이)도 같은 규칙으로 맞췄다(2026-11-05) — 이 문서에 옮겨 적은 그 글(13단계 알림 메일 등)도 새 글이다.
알림 메일의 메뉴 경로도 화면 글처럼 "웹메일의 [설정] → [앱 비밀번호]" 꼴로 적는다.
적용하며 정한 것(2026-11-07 — 규칙 9 · 10, 사용자가 "자연스러운 문구가 아니라 시스템, 설계 문구 형식"이라고 짚은 글):
- 웹 75곳 · 오류 사전 18곳 · 앱 18곳(+ 앱 검색 계획 6곳)을 고쳤다. 정할 것 여섯은 모두 추천안이다 — 읽기 칸이 비었을 때 제목 "고른 메일이 없습니다" + 안내 "읽을 메일을 목록에서 골라 주세요."(주소록의 빈 칸도 같은 꼴),
`REQ_001` "너무 여러 번 시도했거나 같은 일이 이미 진행 중입니다."(추천안 "요청이 한꺼번에 몰렸습니다"는 가장 자주 보는 로그인 · 코드 실패 한도와 맞지 않아 바꿨다 — `MAIL_003` 은 휴지통도 비우라고, `FILE_001` 은 전달 원본 첨부에도 맞게 함께 다듬었다), 필터 창의 칸 이름 [받은편지함에 이미 있는 메일도 정리하기], 본문을 다 보이지 못할 때 할 일(원문 내려받기)까지, 로그인 소개 글 "…같은 계정으로 쓸 수 있게 됩니다.", 이 규칙 9 · 10.
- 그대로 둔 것: 내 메일 · 계정에 생기는 일을 말하는 "지워집니다 · 저장됩니다 · 로그아웃됩니다", "조건에 맞는 메일"처럼 평소에 쓰는 말, 다른 프로그램에서도 쓰는 단추 이름("실행 취소" · "원문 내려받기 (.eml)"),
받은편지함 알림 메일 · 본문의 대용량 첨부 덩이(이미 사람 말에 가깝다), 운영 도구의 터미널 글(운영자용).
- 디자인 보드(`*.dc.html`)의 글은 이번에도 고치지 않는다(위 11-05 줄과 같다 — 기준은 화면 상수와 이 문서).
- 조사는 받침에 맞게 고른다(`frontend/src/shared/utils/korean.ts` — 짝: 앱 `core/utils/korean_text.dart`). 한글 음절에 더해 호환 자모(초성 검색 — 자음은 받침 있음, ㄹ 은 "로")와 숫자(영 · 일 · 이 … 로 읽는다 — "2026"은 "으로")도 고르고, 라틴 글자 · 기호로 끝나면 "이(가)" · "(으)로" 꼴이다.
- 본문을 다 보이지 못한다는 안내는 읽기 화면만 할 일(원문 내려받기)까지 적고, 같은 대화 칸의 펼친 줄은 앞 문장만 둔다(`MAIL_BODY_NOTICES` + `MAIL_BODY_NOTICE_ACTIONS`) — 그 칸의 ⋯ 는 위에 연 메일의 것이라 다른 메일을 받게 된다. 원문은 [이 메일만 열기]로 연 읽기 화면에서 받는다.
## 메일 쓰기 (2026-10-27 확정 — `Compose` · `ComposeEditor`)
위에서부터 머리(제목·닫기) → 받는 사람(→ 참조·숨은참조) → 제목 → 본문 툴바 → 본문 → 첨부 → 보내기 줄이다. 모두 콘텐츠 열 안에 있다.
@@ -251,7 +266,7 @@
- 받는 사람 · 참조 · 숨은참조에서 한 글자 이상 치면 칸 바로 아래에 추천 목록(최대 8개) — "사내"(웹메일에 로그인한 적 있는 직원) ·
"최근 주고받은 주소" 두 묶음(묶음 이름 12px #667085 굵게). 줄은 높이 40 · 이름(맞는 글자 굵게) + 주소(12px #4A5363), 고른 줄은 #E3F2F3.
- 이미 넣은 주소는 빼고, 초성으로도 찾는다(ㄷㅇ → 도윤). 목록 아래 안내 "↑↓ 고르기 · Enter · Tab 넣기 · Esc 닫기".
- 친 글자가 완전한 메일 주소면 추천을 미리 고르지 않는다 — Enter 는 친 주소를 그대로 넣고("Enter 는 친 주소를 그대로 넣습니다"),
- 친 글자가 완전한 메일 주소면 추천을 미리 고르지 않는다 — Enter 는 친 주소를 그대로 넣고("Enter를 누르면 친 주소를 그대로 넣습니다"),
화살표로 고른 뒤에만 추천이 들어간다. 엉뚱한 사람이 받는 사람에 들어가지 않게 하기 위해서다.
- 쓰기 화면을 열 때 추천 목록을 한 번 받아 두고 치는 대로 바로 거른다 — 모자랄 때만 서버에 다시 묻는다.
@@ -278,7 +293,7 @@ API 와 서버 규칙은 계약 2절 "새 메일 확인" · "검색" · "여러
- 전체 메일함에서 찾으면 행에 메일함을 보인다 — 분할 보기는 첫 줄 시각 앞에 "보낸편지함 ·"(12px #667085),
목록 보기 표는 보낸 사람 뒤에 메일함 칸(96). 보낸편지함 · 임시보관함의 메일은 보낸 사람 자리에 "받는 사람: 이름".
- 검색 결과에서 연 메일의 읽기 화면은 받는 사람 아래에 "메일함 받은편지함" 한 줄(12.5px #667085)을 더한다.
- 결과 없음: 검색 아이콘 · "‘…’에 맞는 메일이 없습니다" · 한계 한 줄 "낱말 단위로 찾습니다. ‘서버이전’의 ‘서버’처럼 붙여 쓴 말의 일부나,
- 결과 없음: 검색 아이콘 · "‘…’이/가 들어간 메일이 없습니다" · 한계 한 줄 "낱말 단위로 찾습니다. ‘서버이전’의 ‘서버’처럼 붙여 쓴 말의 일부나,
제목 맨 앞의 [공지]나 Re: 같은 말머리로는 찾지 못합니다." · 한 메일함에서 찾았으면 [전체 메일함에서 찾기].
- 찾는 동안은 지금의 목록 뼈대를 보인다.
@@ -321,7 +336,7 @@ API 와 서버 규칙은 계약 1절 "`PUT /api/auth/password`", 2절 "메일함
### 부재중 자동 응답
- [x] 부재중 자동 응답 사용(44). 끄면 아래 칸을 흐리게(쓴 글은 남긴다).
- 기간: (•) 끌 때까지 계속 / ( ) 기간 정하기 → 시작 · 끝 날짜 칸(44). "시작일 0시부터 끝나는 날 자정까지 답합니다. 시각은 이 브라우저의 시간대를 따릅니다."
- 기간: (•) 끌 때까지 계속 / ( ) 기간 정하기 → 시작 · 끝 날짜 칸(44). "시작하는 날 0시부터 끝나는 날 밤 12시까지 답합니다. 시각은 이 PC의 시간으로 셉니다."
- 제목(44, 최대 폭 520) · 내용(여러 줄, 글자만 — "서식과 그림 없이 글자만 보내고, 서명은 붙지 않습니다.").
- [저장] → "저장했습니다. 10월 30일(금)부터 11월 3일(화)까지 자동으로 답합니다." / "끌 때까지 자동으로 답합니다." / 끄면 "자동 응답을 껐고, 써 둔 글은 그대로 남아 있습니다."
- 미리 보기 "보낸 사람이 받는 답장"(보낸 사람 · 제목 · 내용) + "같은 사람에게는 매번 보내지 않습니다."
@@ -358,10 +373,10 @@ API 와 서버 규칙은 계약 2절 "실시간 알림".
- [x] 새 메일이 오면 바탕화면 알림 보내기(44). 켜면 브라우저가 허락을 묻는다. 칸 아래 안내(12.5px):
묻기 전 "켜면 브라우저가 알림을 허락할지 한 번 묻습니다. [허용]을 눌러 주세요." · 허용됨 "이 브라우저에서 알림이 허용되어 있습니다."(#067647, 체크 아이콘) ·
막힘(칸을 끄고 빨강 #B42318) "브라우저가 이 사이트의 알림을 막고 있습니다. 주소창 왼쪽의 사이트 정보(자물쇠)를 눌러 [알림]을 [허용]으로 바꾼 뒤 이 화면을 새로 고쳐 주세요." ·
지원하지 않음 "이 브라우저는 바탕화면 알림을 지원하지 않습니다. 새 메일은 그래도 바로 목록에 나타납니다."
- 알림에 보일 것: [x] 메일 제목도 보이기 — "끄면 보낸 사람 이름만 보입니다. 다른 사람과 화면을 함께 보는 자리라면 꺼 두는 것이 좋습니다." (알림을 끄면 흐리게)
지원하지 않음 "이 브라우저에서는 바탕화면 알림을 쓸 수 없습니다. 새 메일은 그래도 목록에서 바로 볼 수 있습니다."
- 알림 내용: [x] 메일 제목도 보이기 — "끄면 알림에 보낸 사람 이름만 나옵니다. 다른 사람과 화면을 함께 보는 자리라면 꺼 두시기를 권합니다." (알림을 끄면 흐리게)
- [시험 알림 보내기](보조 단추 44 · 종 아이콘) — 알림 "ARA 웹메일" / "알림이 잘 옵니다. 새 메일이 오면 이렇게 알려 드립니다."
- 실시간 연결: ● 초록 "연결되어 있어 새 메일이 오면 바로 목록에 나타납니다." / 끊겼을 때 "연결할 수 없어 1분마다 확인합니다." (저절로 다시 잇는다)
- 실시간 연결: ● 초록 "연결되어 있어 새 메일이 오면 바로 알 수 있습니다." / 끊겼을 때 "연결할 수 없어 1분마다 확인합니다." (저절로 다시 잇는다)
- 아래 안내 상자(바탕 #F3F8F8): 이 브라우저에만 적용 · 웹메일 탭이 하나라도 열려 있어야 · 웹메일 화면을 보고 있을 때는 보내지 않음.
### 목록 위에서 한 번 권하기
@@ -414,9 +429,9 @@ API 와 서버 규칙은 계약 2절 "실시간 알림".
필터를 저장할 때 받은편지함에 이미 있는 메일에도 그 필터 하나를 적용한다. 6단계의 두 한계(고치기 창의 나가기 확인 없음 · 부재중 화면이 꺼진 규칙을 모름)도 함께 고친다.
규칙은 계약 2절 "이미 받은 메일에 적용".
### 고치기 창 — 「받은편지함에 이미 있는 메일에도 적용」
### 고치기 창 — 「받은편지함에 이미 있는 메일도 정리하기」
- 할 일 아래, 6단계의 안내 띠 자리에 회색 상자 하나: 체크 칸(이름 굵게) + 칸 아래 설명(12.5). 상자 아래 한 줄 "스팸으로 분류된 메일은 필터를 거치지 않습니다."
- 할 일 아래, 6단계의 안내 띠 자리에 회색 상자 하나: 체크 칸(이름 굵게) + 칸 아래 설명(12.5). 상자 아래 한 줄 "스팸 메일은 필터를 거치지 않습니다."
- 창을 열 때마다 꺼져 있다 — 필터의 설정이 아니라 이번 저장에 한 번 쓴다(읽기 화면에서 만드는 새 필터도 같다). 목록의 행에는 단추를 더하지 않는다 — 있는 필터는 창을 열어 고르고 저장한다.
- 설명은 "저장하면 받은편지함에서 조건에 맞는 메일을 찾아 바로 정리합니다." — 전달이 있는 필터면 " 전달은 새로 오는 메일에만 합니다."를 더한다.
- 고를 수 없는 두 경우(칸을 끄고 이름을 흐리게, 설명이 까닭):
@@ -428,7 +443,7 @@ API 와 서버 규칙은 계약 2절 "실시간 알림".
- "필터를 저장했습니다." 대신 4단계의 진행 알림: "‘거래처 메일’ 필터로 받은편지함을 정리하는 중 · 4,200 / 8,412통 확인"(확인한 수 / 받은편지함 메일 수 — 최근 메일부터).
끝나면 그 자리에서 "‘거래처 메일’ 필터로 받은편지함의 메일 37통을 정리했습니다." + 옮기는 필터면 [거래처 보기](그 메일함으로) —
읽음 · 중요만 하는 필터는 단추 없음, 맞는 메일이 없으면 "받은편지함에 ‘거래처 메일’ 필터에 맞는 메일이 없습니다."
읽음 · 중요만 하는 필터는 단추 없음, 맞는 메일이 없으면 "받은편지함에는 ‘거래처 메일’ 필터에 걸리는 메일이 없습니다."
- 멈추면 "‘거래처 메일’ 필터로 8,412통 가운데 4,200통까지 확인하고 멈췄습니다. 그동안 12통을 정리했습니다. 다시 시도해 주세요." + [다시 시도](남은 메일부터 이어서).
처음 요청부터 실패하면 "필터는 저장했지만 받은편지함은 정리하지 못했습니다. 다시 시도해 주세요." + [다시 시도]. 다른 창 · PC 에서 적용 중이면 "다른 창에서 필터로 받은편지함을 정리하는 중입니다. 끝난 뒤 다시 시도해 주세요."
- 그동안 다른 화면으로 가서 메일을 읽고 써도 이어 한다. 탭을 닫으면 멈춘다(이어 하지 않는다). 세션이 바뀌면 멈추고 알림을 닫는다.
@@ -457,12 +472,12 @@ API 와 서버 규칙은 계약 2절 "실시간 알림".
("이름, 주소, 회사, 전화번호, 초성(ㅂㅈㅎ)으로 찾기").
- 패널: [연락처 추가](채운 단추) → 「내 주소록」 전체 연락처(수) → 「그룹」 그룹마다 한 줄(수 · ⋯ 32×40 — 메일함 ⋯ 와 같은 밀도 예외) + [새 그룹] → 「사내」 직원(수) · 공용 주소(수).
- 목록 머리: 제목 · "N명"(공용 주소는 "N개", 찾으면 "찾은 결과 N명"). 그룹이면 오른쪽에 [구성원 넣기] [그룹에 메일 쓰기].
사내면 머리 아래 회색 줄 — 직원 "회사 메일을 쓰는 직원이라 이름과 메일 주소만 보입니다." · 공용 주소 "여러 사람이 함께 받는 사내 주소입니다."
사내면 머리 아래 회색 줄 — 직원 "회사 메일을 쓰는 직원입니다. 사내 목록에는 이름과 메일 주소만 있습니다." · 공용 주소 "여러 사람이 함께 받는 사내 주소입니다."
- 목록 줄(56): 아바타 32(이름 첫 글자 — 공용 주소는 회색) · 이름(굵게) + 회사 · 부서(작게) / 대표 주소. 고른 줄은 청록 바탕. 이름 차례(한국어), 100명씩 받고 끝에 [더 보기].
- 상세 머리 줄: [메일 쓰기] [고치기] … [⋯] — 사내는 [메일 쓰기] [내 주소록에 추가]. 아래로 아바타 48 · 이름(20) · "회사 · 부서 · 직책"(없으면 "소속 없음", 사내는 "사내 직원" · "공용 주소").
- 상세 칸(왼쪽 이름 88): 이메일(주소마다 링크 — 누르면 그 주소로 새 메일, 옆에 종류) · 전화(번호 · 종류) · 소속 · 직책 · 그룹(누르면 그 그룹의 목록) · 메모(줄바꿈 그대로). 빈 칸은 줄째 뺀다.
사내 상세 아래: "사내 목록은 이름과 주소만 보이고 여기서 고칠 수 없습니다. 전화번호나 메모를 적어 두려면 내 주소록에 추가해 주세요."
- 빈 상태: 찾은 것이 없으면 "‘…’에 맞는 연락처가 없습니다"(사내는 "주소가") + "이름, 주소, 회사, 전화번호의 일부나 이름의 초성으로 찾을 수 있습니다." · 고른 것이 없으면 "연락처를 골라 주세요".
사내 상세 아래: "사내 목록에는 이름과 주소만 있고, 여기서는 고칠 수 없습니다. 전화번호나 메모를 적어 두려면 내 주소록에 추가해 주세요."
- 빈 상태: 찾은 것이 없으면 "‘…’에 맞는 연락처가 없습니다"(사내는 "주소가") + "이름, 주소, 회사, 전화번호의 일부나 이름의 초성으로 찾을 수 있습니다." · 고른 것이 없으면 "고른 연락처가 없습니다".
### 연락처 추가 · 고치기 (`ContactsEdit`)
@@ -553,7 +568,7 @@ API 와 서버 규칙은 계약 2절 "실시간 알림".
- **보내기 · 읽기**: 고르는 대로 저장한다(저장 단추 없음 — 머리 오른쪽에 "저장했습니다"). 서버에 저장되어 어느 PC 에서나 같다.
- 「보내기 취소」 — 눌림 전환(36 — `.segmented`) 끔 · 5초 · 10초(처음) · 20초 · 30초 + "[보내기]를 누른 뒤 이 시간 동안 [보내기 취소]로 되돌릴 수 있습니다. 그사이 창을 닫아도 시간이 지나면 메일은 그대로 보내집니다."
- 「외부 이미지」 — 라디오 셋(늘 묻기 · 주소록과 사내 직원의 메일은 늘 보기 · 늘 보기, 줄마다 아래 설명) + 회색 상자
"외부 이미지는 회사를 거쳐 불러오므로 보낸 사람은 이 PC의 주소를 알 수 없습니다. 다만 이미지를 불러오면 보낸 사람은 메일을 열어 본 것을 알 수 있습니다."
"외부 이미지는 회사 쪽에서 대신 받아 오기 때문에 보낸 사람은 이 PC의 주소를 알 수 없습니다. 다만 이미지를 불러오면 보낸 사람이 내가 메일을 열어 본 것을 알 수 있습니다."
- **차단한 주소**: 머리 "차단한 주소" + "3 / 500" + 설명 "차단한 주소에서 온 메일은 스팸메일함으로 옮겨지고, 부재중 자동 응답도 보내지 않습니다. @spam.example 처럼 @로 시작해 넣으면 그 도메인에서 오는 메일을 모두 막습니다."
- 더하기 줄: 칸 "차단할 주소"(placeholder "kim@example.com 또는 @example.com") + [차단]. 칸 아래 오류: "메일 주소나 @도메인 형식으로 써 주세요." · "이미 차단한 주소입니다." ·
"내 주소와 사내 도메인은 차단할 수 없습니다." · "gmail.com처럼 여러 사람이 쓰는 메일 서비스는 도메인 전체를 막을 수 없습니다. 주소를 하나씩 넣어 막아 주세요." · "500개까지 차단할 수 있습니다."
@@ -638,7 +653,7 @@ API 와 서버 규칙은 계약 2절 "실시간 알림".
- **기간 지남 · 공유 멈춤**: 흐리게(이름 #667085 · 아이콘 #98A2B3 — 투명도가 아니라 글자색) + "받을 수 있는 기간이 11월 29일에 끝났습니다" · "보낸 사람이 링크를 끊어 받을 수 없습니다", 내려받기 단추 없음.
보낸 사람 자신에게는 받은 수가 남고 끊은 날이 보인다 — "1.2 GB · 3회 받음 · 10월 31일에 링크를 끊었습니다"(한국 날짜 · 응답의 `revokedAt`, 없으면 "링크를 끊었습니다").
끝난 뒤 90일이 지나면 첨부 칸에서 빠지고 덩이는 그냥 본문이 된다.
- **보안 검사**: 검사 중이면 [다운로드]를 `aria-disabled`(title · 까닭 연결) + "보안 검사가 끝나면 받을 수 있습니다". 위험은 빨간 줄 "위험한 내용이 있어 보안 검사에서 막았고, 받을 수 없습니다"(단추 없음). 검사하지 못한 파일은 받을 수 있고 글로만 알린다.
- **보안 검사**: 검사 중이면 [다운로드]를 `aria-disabled`(title · 까닭 연결) + "보안 검사가 끝나면 받을 수 있습니다". 위험은 빨간 줄 "보안 검사에서 위험한 내용이 나와 받을 수 없습니다"(단추 없음). 검사하지 못한 파일은 받을 수 있고 글로만 알린다.
- **전달 쓰기 화면**: 원래 메일의 덩이는 인용 속 글(링크)로 그대로 간다 — 첨부 칸에 다시 싣지 않고 칸 아래 ⓘ 한 줄로만 알린다 "원래 메일의 대용량 첨부 2개는 인용 속 링크로 함께 갑니다. 11월 29일까지 받을 수 있습니다.".
공유가 멈췄거나 기간이 지난 링크면 갈색 한 줄 "인용 속 ‘도면_초안.dwg’ 링크는 공유가 멈춰 받을 수 없습니다."("… 기간이 지나 받을 수 없습니다.", 파일이 여럿이면 "‘설계도_v3.dwg’ 외 1개") — 보내기는 막지 않는다.
전달하는 사람이 새 큰 파일을 더하면 그것은 이 메일의 새 링크다(첨부 칸의 「링크로 보내는 파일」 — 전달하는 사람의 보관 한도에 잡힌다).
@@ -661,21 +676,21 @@ API 와 서버 규칙은 계약 2절 "실시간 알림".
- 횟수 초과(파일마다 운영 설정 — 처음 값 500번, 넘은 파일만) — 그 줄에 "내려받을 수 있는 횟수를 넘었습니다. 보낸 사람에게 다시 보내 달라고 요청해 주세요."(숫자는 쓰지 않는다 — 한도는 운영 설정이고 응답에 없다)
- 만료 — "내려받을 수 있는 기간이 지났습니다" / "2026년 11월 29일까지 받을 수 있었던 파일입니다. 파일은 지워져 다시 받을 수 없습니다. 필요하면 보낸 사람(김아라, ara.kim@arasystem.it.kr)에게 다시 보내 달라고 요청해 주세요."
- 회수 — "보낸 사람이 공유를 멈췄습니다" / "필요하면 보낸 사람(김아라, ara.kim@arasystem.it.kr)에게 문의해 주세요." 파일 이름도 보이지 않는다.
- 없는 링크(모르는 주소 · 끝난 지 90일이 지나 기록을 지운 링크) — "찾을 수 없는 링크입니다" / "메일의 주소가 끝까지 복사되었는지 확인해 주세요." 보낸 사람도 보이지 않는다.
- 없는 링크(모르는 주소 · 끝난 지 90일이 지나 기록을 지운 링크) — "찾을 수 없는 링크입니다" / "메일에 있던 주소를 끝까지 다 복사했는지 확인해 주세요." 보낸 사람도 보이지 않는다.
- 잠시 막힘(같은 IP 의 요청이 너무 많음) — "요청이 너무 많습니다" / "잠시 뒤 다시 시도해 주세요." + [다시 시도].
- **좁은 폭(휴대폰 360)**: 머리 52 · 로고 76, 보낸 사람 주소는 이름 아래 줄로, 줄마다 [내려받기]가 이름 아래 전체 폭(44).
### 설정 › 메일 › 대용량 첨부 (`SettingsLargeFiles`)
- **메뉴**: 메일 묶음의 차단한 주소 아래 「대용량 첨부」 — 서명 · 부재중 자동 응답 · 필터 · 보내기 · 읽기 · 차단한 주소 · 대용량 첨부 · 알림.
- 머리 "대용량 첨부" + "메일에 링크로 보낸 큰 파일입니다. 받는 사람은 보낸 날부터 30일 동안 내려받고, 링크를 끊으면 바로 받을 수 없습니다."
- 머리 "대용량 첨부" + "메일에 링크로 보낸 큰 파일입니다. 받는 사람은 보낸 날부터 30일 동안 내려받을 수 있고, 링크를 끊으면 그때부터 받을 수 없습니다."
- **사용량 상자**: "보관 사용량 6.2 / 10 GB"(메일함 사용량과 같은 표기) + 4px 막대(90% 를 넘으면 빨강 — 메일함 사용량과 같다) +
"보낸 링크 4.3 GB, 임시 저장했거나 올리다 멈춘 파일 1.9 GB입니다. 기간이 지나거나, 링크를 끊거나, 보내지 않은 파일을 지우면 바로 줄어듭니다."
- **보내지 않은 파일**(사용량 상자 바로 아래 — 공간을 바로 비울 수 있는 곳이라 보낸 링크보다 위): 「보내지 않은 파일 3개」 + "1.9 GB · 임시 저장한 메일에 실렸거나 올리다 멈춘 파일이라 지우면 바로 공간이 생깁니다". 없으면 묶음째 보이지 않는다.
- 줄(52): 아이콘 · 이름 / (올리다 멈춘 파일은 앞에 갈색 "올리다 멈춤") · 크기(멈춘 파일은 "1.2 GB" / "420 MB 올림") ·
실린 저장본의 제목 · 링크는 보이지 않는다(2026-10-30 — 저장본 id 는 저장할 때마다 바뀌고 엔진 밖에서도 지워져 서버가 따라가기 무겁다, 계약 4절 `LargeFile` 에 없음) ·
남는 기한 "11월 29일까지 남음" / "올린 뒤 30일"(올리다 멈춘 파일은 이어 올리기 규칙대로 "내일 오후 2:41까지 남음" / "마지막으로 올린 뒤 24시간") · [지우기](36, 빨간 글).
- [지우기] → 확인 창(alertdialog 400) "보내지 않은 파일을 지울까요?" / "‘시공도면_v4.dwg’(620 MB) 파일을 지웁니다. 이 파일을 실은 임시 저장본을 열면 첨부 칸에 지운 파일로 보입니다. 지운 파일은 되돌릴 수 없습니다."
- [지우기] → 확인 창(alertdialog 400) "보내지 않은 파일을 지울까요?" / "‘시공도면_v4.dwg’(620 MB) 파일을 지웁니다. 이 파일을 넣어 둔 임시 저장본이 있으면 그 메일에서 파일을 빼고 다시 올려야 합니다. 지운 파일은 되돌릴 수 없습니다."
[지우기](빨강) [취소](처음 초점 · Esc) → "‘시공도면_v4.dwg’ 파일을 지웠습니다." — 줄이 사라지고 사용량이 바로 준다.
그 저장본을 쓰기 화면에서 열면 첨부 칸에 빨간 줄 "[설정] → [대용량 첨부]에서 지운 파일입니다. 빼고 다시 올려 주세요."(쓰기 화면의 "기간이 지나 지워진 파일"과 같은 모양 · 다른 글).
- **보낸 링크**: 「보낸 링크 6개」 + "메일마다 하나씩, 최근에 보낸 순서". 메일(링크)마다 한 덩이 — 링크가 메일 한 통에 하나라 [링크 끊기]는 덩이에 한 번이다(파일 줄마다 두면 같은 메일의 다른 파일까지 끊긴다).
@@ -711,14 +726,14 @@ API 와 서버 규칙은 계약 2절 "실시간 알림".
### 로그인 — 코드 단계 (`LoginTwoFactor`)
- **누가 오나**: 비밀번호가 맞고 2단계 인증을 켠 직원만. 켜지 않은 직원은 지금처럼 바로 메일함으로 간다.
비밀번호가 틀리면 지금처럼 「이메일 또는 비밀번호가 올바르지 않습니다.」 — 2단계를 켰는지는 비밀번호가 맞은 뒤에야 드러난다.
비밀번호가 틀리면 지금처럼 「메일 주소나 비밀번호가 맞지 않습니다. 다시 확인해 주세요.」 — 2단계를 켰는지는 비밀번호가 맞은 뒤에야 드러난다.
- **틀**은 로그인 화면과 같다. 위에서부터:
- 제목 "2단계 인증" / "비밀번호를 확인했습니다. 휴대폰 인증 앱에 표시된 코드를 넣어 주세요."
- 주소 + [다른 계정으로 로그인] — 처음 화면으로(주소 칸은 비움), 확인한 비밀번호는 버린다.
- 칸 "인증 앱에 표시된 6자리 코드" — 숫자 6자리만, 붙여 넣은 글의 띄어쓰기 · 하이픈은 지운다. `inputmode="numeric"` · `autocomplete="one-time-code"`.
- [확인] → "인증 앱을 쓸 수 없나요? 관리자에게 문의해 주세요." → "Outlook이나 휴대폰 메일 앱은 이 코드를 묻지 않습니다. [설정] → [앱 비밀번호]에서 만든 비밀번호로 연결해 주세요."
- **상태**
- 코드 틀림 — 칸 아래 빨간 글(`AUTH_006` 사전 문구 "코드가 올바르지 않습니다. 휴대폰의 날짜와 시간이 자동으로 맞춰지도록 설정되어 있는지 확인해 주세요."). 칸을 비우고 초점은 그 칸, 알림은 없다.
- 코드 틀림 — 칸 아래 빨간 글(`AUTH_006` 사전 문구 "코드가 맞지 않습니다. 휴대폰 설정에서 날짜와 시간을 자동으로 맞추게 해 두었는지 확인해 주세요."). 칸을 비우고 초점은 그 칸, 알림은 없다.
- 시도 과다 — 위쪽 빨간 상자(로그인 오류와 같은 자리 · 같은 모양)에 `REQ_001` 사전 문구. 코드 실패는 비밀번호 틀림과 따로 센다(같은 주소 15분 5회 · 하루 20회). 칸 · 단추는 그대로 둔다.
- 확인 중 — 「확인하는 중…」(로그인 단추와 같은 모양 · `aria-busy`), 칸은 읽기만. 맞으면 받은편지함으로 — 로그인 전에 가려던 주소가 있으면 그곳으로.
- 이 화면에 10분 넘게 머물면 비밀번호를 바로 지우고, [확인] 때 처음 화면으로 가 위쪽 상자에 "코드를 넣는 시간이 지났습니다. 다시 로그인해 주세요."를 보인다. 새로고침도 처음 화면이다(비밀번호는 화면 메모리에만 있다).
@@ -768,7 +783,7 @@ API 와 서버 규칙은 계약 2절 "실시간 알림".
- **만들기 창**(400 · 처음 초점 이름 칸): 「이름」 + "어느 프로그램이나 기기에서 쓰는지 알아볼 수 있게 지어 주세요. 40자까지 쓸 수 있습니다." → [만들기] [취소].
칸 아래: "이름을 넣어 주세요." · "같은 이름의 앱 비밀번호가 있습니다."(앞뒤 공백을 지운 값으로 화면이 먼저 본다) · 그사이 다른 창에서 5개를 채웠으면 `AUTH_009` 사전 문구.
- **만든 결과 창**(620 · 처음 초점 [복사]): "앱 비밀번호를 만들었습니다" / "‘회사 PC 아웃룩’ 앱 비밀번호입니다. 메일 프로그램의 비밀번호 칸에 아래 값을 넣어 주세요."
- 값은 고정폭으로 4자씩 떨어뜨려 보이지만 띄어쓰기는 없다 — [복사](44 — 창의 주 동작이라 보드대로)는 40자 그대로. "40자입니다. 보기 좋게 4자씩 띄웠지만 띄어쓰기는 없습니다."
- 값은 고정폭으로 4자씩 떨어뜨려 보이지만 띄어쓰기는 없다 — [복사](44 — 창의 주 동작이라 보드대로)는 40자 그대로. "40자입니다. 보기 편하게 4자씩 띄어 놓았지만, 실제로는 띄어쓰기 없이 넣으면 됩니다."
- 경고 "이 창을 닫으면 다시 볼 수 없습니다. 잊어버리면 지우고 새로 만들어 주세요." + 설정값 요약 + [닫기].
- 복사 「앱 비밀번호를 복사했습니다.」 / 못 하면 「앱 비밀번호를 복사하지 못했습니다. 값을 직접 골라 복사해 주세요.」
- [닫기] · Esc 로 닫으면 목록 맨 위에 새 줄이 생기고 값은 화면 메모리에서도 지운다. 복사하지 않고 닫아도 다시 묻지 않는다.
@@ -780,7 +795,7 @@ API 와 서버 규칙은 계약 2절 "실시간 알림".
- 머리 "로그인 기록" + "최근 90일 동안의 로그인입니다. 모르는 기록이 있으면 바로 비밀번호를 바꾸고, 2단계 인증이 꺼져 있으면 켜 주세요."(두 링크).
- **로그인된 브라우저**: 「로그인된 브라우저」 + "웹메일 3곳" · 오른쪽 [다른 곳 모두 로그아웃](44).
- 줄마다 PC · 휴대폰 아이콘(16) · 브라우저 · 운영체제 / IP / 마지막 활동 / [로그아웃](36). 맨 위는 지금 이 브라우저 — 단추 대신 「지금 이 브라우저」 글자만. 모르면 "알 수 없는 브라우저".
- 아래 "웹메일 로그인만 보입니다. 메일 프로그램의 연결을 끊으려면 앱 비밀번호를 지워 주세요."(링크)
- 아래 "메일 프로그램은 이 목록에 없습니다. 연결을 끊으려면 앱 비밀번호를 지워 주세요."(링크)
- 줄의 [로그아웃]은 묻지 않고 바로 끊는다 → 「‘Edge · Windows’에서 로그아웃했습니다.」(되돌리기 없음). 끊은 줄은 빠지고, 다른 곳이 없으면 [다른 곳 모두 로그아웃]을 그리지 않는다.
- [다른 곳 모두 로그아웃] → 확인(alertdialog 400 · 처음 초점 「취소」) "다른 곳에서 모두 로그아웃할까요?" /
"이 브라우저를 뺀 2곳(Edge · Windows, Safari · iPhone)의 웹메일에서 로그아웃합니다. 그곳에서 다음에 무엇이든 누르면 바로 로그인 화면이 나옵니다." "메일 프로그램은 로그아웃되지 않습니다. 연결을 끊으려면 앱 비밀번호를 지워 주세요."
@@ -846,7 +861,7 @@ API 와 서버 규칙은 계약 2절 "실시간 알림".
- **누가 오나**: 회사가 2단계 인증을 필수로 정한 날(시작일)부터, 아직 켜지 않은 직원(예외 주소 빼고)이 비밀번호를 맞혔을 때(`restriction: twoFactorSetup`).
켜기 전에는 메일 · 주소록 · 설정을 열 수 없다(`AUTH_010`). 새 비밀번호 정하기가 함께 걸린 직원(입사)은 그것 먼저.
- **틀**(위에서부터):
- 제목 "2단계 인증을 켜 주세요" / "회사 정책에 따라 로그인할 때 휴대폰 인증 앱의 코드도 확인합니다. 지금 켜면 바로 메일로 이어집니다."
- 제목 "2단계 인증을 켜 주세요" / "회사 정책에 따라 로그인할 때 휴대폰 인증 앱의 코드도 확인합니다. 지금 켜면 바로 메일을 쓸 수 있습니다."
- 주소 + [다른 계정으로 로그인]
- 차례(청록 굵은 숫자 — 동그라미 없음, 숫자 칸 22 · 사이 18 — 12단계 켜기 창과 같다):
- ① "인증 앱으로 QR 코드를 찍어 주세요" / "인증 앱에서 [+] → [QR 코드 스캔]을 고릅니다. 앱이 없으면 Google Authenticator나 Microsoft Authenticator를 먼저 설치해 주세요."
@@ -900,8 +915,8 @@ API 와 서버 규칙은 계약 2절 "실시간 알림".
- **상자 바로 아래** — 2단계 인증 상태로 가른다. 둘 다 [앱 비밀번호 만들기](36) → 설정 › 앱 비밀번호.
- 켠 직원 — 갈색 상자(열쇠): **"비밀번호 칸에는 꼭 앱 비밀번호를 넣어 주세요"** / "2단계 인증을 켜 두어 웹메일 비밀번호로는 연결되지 않습니다. 프로그램마다 앱 비밀번호를 하나씩 만들어 넣어 주세요." — 단추 `.notice-btn`.
- 켜지 않은 직원 — 청록 상자(열쇠): **"앱 비밀번호로 연결하기를 권합니다"** / "지금은 웹메일 비밀번호로도 연결되지만, 2단계 인증을 켜면 다시 연결할 때 막힙니다. 앱 비밀번호는 기기마다 따로 지울 수 있어 잃어버렸을 때도 안전합니다." — 단추 `.line-btn`.
- **프로그램별 연결 방법**: 「프로그램별 연결 방법」 + "쓰는 프로그램을 누르면 연결 방법이 펼쳐집니다."
- 그 아래 갈색 주의 줄(경고 삼각형): "새 Outlook과 휴대폰 Outlook 앱은 메일을 Microsoft 클라우드를 거쳐 받습니다. 회사 정책에 따라 쓰지 않는 것이 좋습니다."
- **프로그램별 연결 방법**: 「프로그램별 연결 방법」 + "쓰는 프로그램을 누르면 연결 방법을 볼 수 있습니다."
- 그 아래 갈색 주의 줄(경고 삼각형): "새 Outlook과 휴대폰 Outlook 앱은 메일을 Microsoft 클라우드를 거쳐 받습니다. 회사 정책에 따라 되도록 다른 메일 프로그램을 써 주세요."
권하지 않음으로 정했다(2026-10-31) — 회사가 허용하면 줄을 지운다.
- 펼침 목록(테두리 상자 하나 · 줄 사이 선): 줄 52 — 아이콘 18(PC · 휴대폰) · 이름(굵게) / 설명(12px 회색) · 오른쪽 ⌄(펼치면 ⌃). 줄 전체가 버튼(`aria-expanded` · 펼친 것만 `aria-controls`)이고,
펼친 차례는 region(이름 = 그 버튼)으로 이름 글과 같은 선(왼쪽 44)에서 번호 줄(청록 굵은 숫자 — 동그라미 없음)로 보인다. 처음에는 모두 접힘, 하나를 펼치면 다른 것은 접힌다.
@@ -928,7 +943,7 @@ API 와 서버 규칙은 계약 2절 "실시간 알림".
2. 이름, 메일 주소, 설명을 넣고, 암호 칸에 앱 비밀번호를 넣은 뒤 [다음]을 누릅니다.
3. [IMAP]을 고르고, 받는 메일 서버와 보내는 메일 서버의 호스트 이름에 **mx1.arasystem.it.kr**, 사용자 이름에 메일 주소 전체, 암호에 앱 비밀번호를 넣습니다. 보내는 메일 서버 칸이 선택 사항으로 보여도 꼭 채워 주세요.
4. [다음] → [저장]을 누릅니다. 포트는 받는 메일 993, 보내는 메일 465로 저절로 맞춰집니다.
- **안드로이드** — 설명 "Gmail 앱 기준이며, 삼성 이메일 앱 같은 다른 메일 앱도 같은 값을 넣습니다"
- **안드로이드** — 설명 "Gmail 앱으로 설명하며, 삼성 이메일 앱 같은 다른 메일 앱도 같은 값을 넣으면 됩니다"
1. Gmail 앱에서 프로필 사진 → [다른 계정 추가] → [기타]를 누릅니다.
2. 메일 주소를 넣고 [수동 설정] → [개인(IMAP)]을 고릅니다.
3. 비밀번호에 앱 비밀번호를 넣습니다.
@@ -1012,16 +1027,16 @@ API 와 서버 규칙은 계약 2절 "실시간 알림".
- 목록 머리(52) 바로 아래 회색 줄(바탕 #F8F9FB · 아래 선 #EEF0F3 · 위아래 8 · 12.5px #4A5363 · 시계 아이콘 14 #667085):
휴지통 "휴지통에 들어간 메일은 30일 뒤 자동으로 지워집니다." / 스팸메일함 "스팸메일함에 들어간 메일은 30일 뒤 자동으로 지워집니다."
- 늘 보인다(닫기 없음). 목록 보기(표)에서는 표 머리 위 — 같은 줄. 검색 결과 · 다른 메일함에는 없다. 머리의 [휴지통 비우기] · [스팸메일함 비우기]는 지금 그대로다.
- 메일이 없으면 줄을 빼고 빈 상태 안내 글에 붙인다: "휴지통이 비어 있습니다" / "삭제한 메일이 여기에 표시됩니다. 들어간 지 30일이 지난 메일은 자동으로 지워집니다." ·
"스팸메일함이 비어 있습니다" / "스팸으로 분류된 메일이 여기에 표시됩니다. 들어간 지 30일이 지난 메일은 자동으로 지워집니다."
- 메일이 없으면 줄을 빼고 빈 상태 안내 글에 붙인다: "휴지통이 비어 있습니다" / "지운 메일은 여기에 30일 동안 남아 있다가 저절로 지워집니다." ·
"스팸메일함이 비어 있습니다" / "스팸 메일은 여기에 30일 동안 남아 있다가 저절로 지워집니다."
- 지우는 것은 메일 서버다 — 그 메일함에 들어간 날부터 30일, 매일 새벽 3시(KST). API 는 없다(계약 6절).
- **문의 줄**(로그인 · 2단계 코드 단계 · 2단계 켜기):
- 연락처는 `GET /api/support-contact` 의 `text`(운영 도구로 넣는 설정값 · 200자 · 한 줄 — 로그인 없이 받는다). 보드의 "IT 담당 홍길동 · 02-000-0000 · 사내 메신저 @it" 는 자리 표시다. 글자로만 보인다(링크 · HTML 없음).
- 꼴: 물음(12.5px · #667085) 다음 줄에 연락처(#344054 · 500) — 연락처가 길어도 중간에서 끊기지 않게 줄을 나눈다.
- 로그인 — 있을 때 "비밀번호를 잊으셨나요?" + 연락처 / 없을 때 지금 글 "비밀번호를 잊으셨다면 시스템 관리자에게 문의해 주세요."
- 로그인 — 있을 때 "비밀번호를 잊으셨나요?" + 연락처 / 없을 때 지금 글 "비밀번호를 잊으셨다면 관리자에게 문의해 주세요."
- 코드 단계(`LoginTwoFactor`) · 2단계 켜기 — 있을 때 "인증 앱을 쓸 수 없나요?" + 연락처 / 없을 때 지금 글 "인증 앱을 쓸 수 없나요? 관리자에게 문의해 주세요."
- 받지 못하면(오류 · `null`) 없을 때와 같다.
- 잠긴 계정(퇴사 · 털림)도 비밀번호 틀림과 같은 글 "이메일 또는 비밀번호가 올바르지 않습니다."다(원인을 가리지 않는다) — 그 아래 문의 줄로 안내한다.
- 잠긴 계정(퇴사 · 털림)도 비밀번호 틀림과 같은 글 "메일 주소나 비밀번호가 맞지 않습니다. 다시 확인해 주세요."다(원인을 가리지 않는다) — 그 아래 문의 줄로 안내한다.
### 처음 판에서 미룬 것
@@ -1044,15 +1059,15 @@ API 와 서버 규칙은 계약 2절 "실시간 알림".
여러 낱말은 모두 맞아야 하고 큰따옴표는 구절이다(지금과 같다). 영문은 대소문자를 가리지 않는다. 끝이 바뀐 말('보내' → '보냈습니다')과 첨부 파일 안의 글은 찾지 않는다(첨부 이름은 찾는다).
- **[조건]**: 검색 칸 안 오른쪽(✕ 앞)의 단추. 누르면 칸 바로 아래에 「조건으로 찾기」 패널(검색 칸 폭)이 열린다(aria-expanded).
- 칸: 보낸 사람 · 받는 사람(참조도) · 제목 · 내용 · 기간(전체 기간 · 최근 1주 · 최근 1달 · 최근 3달 · 최근 1년 · 날짜 고르기 → 시작 ~ 끝) · 첨부(체크 "첨부 파일이 있는 메일만"). 칸 높이 40.
- 아래 안내 "검색 칸의 말과 이 조건에 모두 맞는 메일을 찾습니다. 받는 사람에는 참조로 받은 사람도 들어갑니다." 단추 [조건 지우기] · [찾기].
- 아래 안내 "검색 칸에 쓴 말이 들어 있고 이 조건에도 맞는 메일을 찾습니다. 받는 사람으로 찾을 때는 참조로 받은 사람까지 찾습니다." 단추 [조건 지우기] · [찾기].
- 패널이 열려 있거나 조건이 걸려 있으면 [조건] 단추가 청록 바탕이다. 검색 칸이 비어도 조건만으로 찾을 수 있다.
- **조건이 걸린 결과**: 찾을 곳 줄 아래 한 줄 "조건: 이도윤 님이 보낸 메일, 최근 1달, 첨부 있음" + [조건 지우기]. 조건은 쉼표로 잇는다(꼬리표 · 알약을 쓰지 않는다).
- **찾을 곳**은 지금과 같다(그 메일함 | 전체 메일함 — 전체는 휴지통과 스팸메일함을 뺀다). 조건도 주소에 싣는다 — 이름은 구현 때 계약에서 정하고, 검색어처럼 로그에 남기지 않는다.
- **결과 줄**(높이 64, 세 줄): 보낸 사람 · 시각 / 제목(+ 첨부 표시) / 미리 보기 한 줄(맞은 말 둘레). 제목 · 미리 보기의 맞은 말은 `<mark>`(노랑 `#FCE7A4` · 글자색은 그대로).
- **읽기 화면 강조**: 제목 · 본문 · 첨부 이름의 맞은 말. 본문은 서버가 정화한 HTML 의 글 조각에만 `<mark>` 를 넣는다(찾기와 같은 정규화 · 부분 일치). 맞은 곳 사이 이동(다음 · 이전)은 이번에 넣지 않는다.
- **검색 준비 중**: 지난 메일의 색인을 만드는 동안 그 직원의 검색은 지금 방식(엔진) 그대로이고, 결과 머리 아래 안내 줄(옅은 청록 바탕 · role=status)
"지난 메일을 검색할 수 있게 밤사이에 준비하고 있습니다. 메일이 많으면 며칠 걸릴 수 있고, 그동안은 낱말이 통째로 같은 메일만 찾습니다."(2026-11-07 사용자 관점 점검 — 색인은 밤에만 채워, 낮 내내 같은 글이면 멈춘 줄 안다)
- **결과 없음**: "‘계약’에 맞는 메일이 없습니다" / "다른 말로 찾거나 조건을 줄여 보세요." + [전체 메일함에서 찾기](그 메일함에서 찾을 때만). 지금의 한계 안내는 색인이 준비된 뒤에는 뺀다 — 준비 중에는 위 안내 줄과 겹치지 않게 "준비가 끝나기 전에는 ‘서버이전’의 ‘서버’처럼 붙여 쓴 말의 일부나, 제목 맨 앞의 [공지]나 Re: 같은 말머리로는 찾지 못합니다."(2026-11-07).
"지난 메일까지 낱말의 일부로도 찾을 수 있게 밤사이에 준비하고 있습니다. 메일이 많으면 며칠 걸릴 수 있고, 그동안은 낱말을 끝까지 다 써야 찾을 수 있습니다."(2026-11-07 사용자 관점 점검 — 색인은 밤에만 채워, 낮 내내 같은 글이면 멈춘 줄 안다)
- **결과 없음**: "‘계약’이 들어간 메일이 없습니다" / "다른 말로 찾거나 조건을 줄여 보세요." + [전체 메일함에서 찾기](그 메일함에서 찾을 때만). 지금의 한계 안내는 색인이 준비된 뒤에는 뺀다 — 준비 중에는 위 안내 줄과 겹치지 않게 "준비가 끝나기 전에는 ‘서버이전’의 ‘서버’처럼 붙여 쓴 말의 일부나, 제목 맨 앞의 [공지]나 Re: 같은 말머리로는 찾지 못합니다."(2026-11-07).
- 검색 결과는 대화로 묶지 않는다(한 통씩).
### 읽기 화면 — 같은 대화 (`Thread14`)
@@ -1083,9 +1098,9 @@ API 와 서버 규칙은 계약 2절 "실시간 알림".
"다시 시도"는 메일 서버 장애처럼 다시 해 보면 나아질 수 있는 오류에만 쓴다.
- 읽기 화면의 본문 안내(2026-10-27 사용자 확정) — "외부 이미지를 차단했습니다"와 같은 모양(아이콘 alert-circle)의 안내 줄을 본문 위에 둔다.
순서는 생략 → 잘림 → 외부 이미지 차단이다. "표시할 수 없음"을 보일 때는 잘림 안내를 숨기고, 공백뿐인 글자 본문은 없는 것으로 본다:
- 서버가 HTML 을 정화 한도 때문에 비웠을 때(`isHtmlBodyOmitted`): 글자 본문이 있으면 "본문이 복잡해 서식 없이 글자만 표시합니다.",
없으면 "본문이 너무 크거나 복잡해 표시할 수 없습니다."
- 보여 주는 본문이 읽기 한도(2MB)에서 잘렸을 때(`isBodyTruncated`): "본문이 길어 앞부분만 표시합니다."
- 서버가 HTML 을 정화 한도 때문에 비웠을 때(`isHtmlBodyOmitted`): 글자 본문이 있으면 "본문이 복잡해 서식 없이 글자만 보여 드립니다.",
없으면 "본문이 너무 크거나 복잡해 여기서는 열 수 없습니다. [⋯] → [원문 내려받기 (.eml)]로 받아 볼 수 있습니다."
- 보여 주는 본문이 읽기 한도(2MB)에서 잘렸을 때(`isBodyTruncated`): "본문이 길어 앞부분만 보여 드립니다. 전체는 [⋯] → [원문 내려받기 (.eml)]로 받아 볼 수 있습니다."
### 2단계 구현하며 정한 보완 (2026-10-27 — 캔버스에는 아직 없다)
@@ -1161,7 +1176,7 @@ API 와 서버 규칙은 계약 2절 "실시간 알림".
- 보던 메일함을 지우면 받은편지함으로 간다. 비우기 · 지우기가 끝나면 초점은 메일함 패널의 지금 메일함(지웠으면 받은편지함)으로 간다 — 누른 단추가 사라지므로.
- **오래 걸리는 처리**
- 하는 중의 글: "‘거래처’ 메일함을 지우는 중 · 12 / 40통" · "휴지통을 비우는 중 · 600 / 1,234통" (빈 메일함 지우기처럼 셀 메일이 없으면 수를 뺀다).
- 도중에 실패하면 "1,234통 가운데 600통까지 처리하고 멈췄습니다. 다시 시도해 주세요."로 바뀐다(까닭은 오류 알림으로 함께). 하나도 못 했으면 오류 알림만 남는다.
- 도중에 실패하면 "1,234통 가운데 600통까지 마치고 멈췄습니다. 다시 시도해 주세요."로 바뀐다(까닭은 오류 알림으로 함께). 하나도 못 했으면 오류 알림만 남는다.
- 다른 탭에서 먼저 끝냈으면 실패로 보이지 않는다 — "받은편지함에 안 읽은 메일이 없습니다." · "휴지통이 이미 비어 있습니다.", 이미 없는 메일함 지우기는 지운 것으로 본다.
- 같은 메일함의 처리는 한 번에 하나다(다른 탭에서 같은 메일함을 처리하는 중이면 "요청이 너무 많습니다"). 진행 알림은 로그아웃 · 세션 만료 때 닫힌다.
- **휴지통을 비우는 동안은 메일함을 지울 수 없고, 메일함을 지우는 동안은 휴지통을 비울 수 없다** — 비우기는 그동안 휴지통에 들어온 메일도 지우므로,
@@ -1179,7 +1194,7 @@ API 와 서버 규칙은 계약 2절 "실시간 알림".
- 허락은 했는데 알림을 꺼 둔 상태: 칸은 비우고 안내는 초록 "이 브라우저에서 알림이 허용되어 있습니다."(보드에 없는 상태).
- 실시간을 쓸 수 없는 브라우저(Web Locks · BroadcastChannel · 스트림 읽기 가운데 하나라도 없음)는 바탕화면 알림도 "지원하지 않습니다"로 본다 — 알림은 실시간으로만 온다.
- 연결 상태 글: 처음 잇는 중 "연결하는 중입니다…", 끊김 "연결할 수 없어 1분마다 확인합니다." 아래에 "저절로 다시 연결합니다. 회사 방화벽이나 프록시가 오래 열린 연결을 끊으면 이렇게 될 수 있습니다.",
지원하지 않음 "이 브라우저는 실시간 연결을 지원하지 않습니다."
지원하지 않음 "이 브라우저에서는 실시간 연결을 쓸 수 없습니다."
- 주소는 `/settings/mail/notify`, 탭 제목은 "메일 알림"(머리글은 "알림").
- 켜기 칸에서 허락을 받은 결과는 칸 아래 안내로만 보인다(화면 알림은 목록 위 권하기 줄에서만 — 설정 화면에서 [설정 열기]는 어색하다).
- 연결이 끊긴 동안은 "연결할 수 없어 1분마다 확인합니다." 아래에 "연결될 때까지 바탕화면 알림은 오지 않습니다."를 더한다.
@@ -1351,7 +1366,7 @@ API 와 서버 규칙은 계약 2절 "실시간 알림".
칸마다 저장은 한 번에 하나 — 보내는 동안 고른 값은 기억했다가 앞 저장이 끝난 뒤 보낸 값 · 서버의 답과 다르면 보낸다. "저장했습니다"는 다 끝나고 마지막이 성공했을 때. 끝내 실패하면 서버가 확인한 값으로 되돌린다.
- **설정 › 차단한 주소**: 검사 차례는 형식 → 내 주소 · 사내 도메인 → 메일 서비스 도메인 → 이미 차단 → 500개. 서버 거절(`VAL_001` · `MAIL_013`)은 칸 아래 사전 문구(알림 없음).
성공하면 칸을 비우고 칸 아래에 "‘…’을(를) 차단했습니다."(`role=status` — 비어 있어도 영역은 늘 둔다). Enter 는 [차단]과 같다(`isImeComposing`). 줄을 풀면 초점은 다음 줄 → 앞 줄 → 칸.
엔진 규칙이 꺼져 있으면(`isActive: false`) 머리 아래 노란 줄(단추 없음) "다른 곳에서 메일 설정이 바뀌어 지금은 차단이 되지 않습니다. 주소를 차단하거나 풀면 다시 켜집니다." —
엔진 규칙이 꺼져 있으면(`isActive: false`) 머리 아래 노란 줄(단추 없음) "다른 곳에서 메일 설정이 바뀌어 지금은 차단이 꺼져 있습니다. 주소를 차단하거나 풀면 차단이 다시 켜집니다." —
차단 · 풀기가 성공하면 켜진 것으로 본다(서버가 다시 켠다 — 계약 2절).
- **새 토큰 · 아이콘**: `--color-on-brand-track` · `--color-on-brand-progress` · `--color-on-brand-divider` · `--color-warning-border`, 아이콘 printer · clock · block(원 + 빗금).
@@ -1400,9 +1415,9 @@ API 와 서버 규칙은 계약 2절 "실시간 알림".
- 옛 한계 안내("낱말 단위로 찾습니다 …")는 색인을 채우는 동안(`building`)만 결과 없음 아래에 더한다.
- 한 글자 안내: "한 글자로만 찾을 때는 본문은 빼고 제목, 보낸 사람과 받는 사람, 첨부 파일 이름에서 찾습니다. 본문까지 찾으려면 두 글자 이상으로 찾아 주세요." — 색인이 준비됐고 본문을 찾을 말(검색어나 내용 조건)이 있을 때만.
- 준비 상태를 받지 못하면(요청 실패) 안내 줄 · 옛 한계 안내 · 한 글자 안내를 모두 보이지 않는다.
- **[더 찾기]**: 한 번에 다 찾지 못하면 "아직 맞는 메일을 찾지 못했습니다" / "메일이 많아 한 번에 다 찾지 못했습니다. [더 찾기]를 누르면 이어서 찾습니다." + [더 찾기](찾는 동안 "찾는 중…").
- **[더 찾기]**: 한 번에 다 찾지 못하면 "아직 찾은 메일이 없습니다" / "메일이 많아 한 번에 다 찾지 못했습니다. [더 찾기]를 누르면 이어서 찾습니다." + [더 찾기](찾는 동안 "찾는 중…").
하나도 못 찾았으면 "찾는 중"을 이어 보이고, 하나라도 찾으면 먼저 보이면서 뒤에서 잇는다. 목록 보기의 [다음]은 이어 받는 동안 기다렸다가 넘기고, 그동안 꺼짐 · `aria-busy` 다.
- **낱말이 너무 많은 주소**(손으로 고친 주소): "찾을 말이 너무 많습니다" / "검색어와 조건에 넣은 낱말은 모두 합쳐 8개까지 찾을 수 있습니다. 검색 칸이나 [조건]에서 낱말을 줄여 주세요." — [전체 메일함에서 찾기] · 알림 없이, 조건 줄의 [조건 지우기]는 남긴다.
- **낱말이 너무 많은 주소**(손으로 고친 주소): "찾을 말이 너무 많습니다" / "검색 칸과 [조건]에 넣은 낱말은 모두 합쳐 8개까지입니다. 낱말을 몇 개 줄여 주세요." — [전체 메일함에서 찾기] · 알림 없이, 조건 줄의 [조건 지우기]는 남긴다.
- **강조 색**: 노랑 `#FCE7A4` 는 색 토큰 `--color-search-mark` 하나로 두고, 본문 틀 안 CSS 는 같은 값을 직접 적는다(짝). 보낸 사람이 본문에 쓴 `<mark>` 도 같은 노랑으로 보인다(드물어 가르지 않는다).
미리 보기 줄(회색 `--color-text-muted`)의 맞은 말만 글자를 `--color-text-secondary`(#4A5363)로 진하게 한다 — 회색 그대로면 노랑 위 대비가 4.05:1 로 4.5:1 에 못 미친다(2026-11-07 사용자 확정 — 보드와 다르다, 앱도 같다). 제목 · 읽기 화면 · 본문 틀의 강조는 진한 글 위라 글자색 그대로다.
- 긴 안내 글(결과 없음 · [더 찾기] · 안내 줄)은 낱말 가운데서 줄을 바꾸지 않는다(`word-break: keep-all`).
@@ -1448,7 +1463,7 @@ API 와 서버 규칙은 계약 2절 "실시간 알림".
분할 보기에 이름까지 넣으면(99) 1440 에서 개수 글 자리가 25px 남짓이라 아이콘으로 두고, 설정 설명이 말풍선 모양이라고 알린다(2026-11-07 잼).
- 켠 모양은 옅은 청록 바탕 + 진한 청록 글자이고 테두리는 그대로다(보드의 #9FD3D7 테두리는 새 색 토큰이라 두지 않았다).
- 설정 › 메일 › 보내기 · 읽기의 한 줄은 외부 이미지 아래 가는 선 뒤 — 끔 · 켬 전환(`.segmented--outline` — 보내기 취소와 같은 모양)과
"답장과 전달로 이어진 메일을 메일함 목록에서 한 줄로 묶어 보여 줍니다. 메일함 목록 위의 말풍선 모양 [대화로 묶기] 단추로도 켜고 끌 수 있습니다." — 줄 이름은 단추와 같은 "대화로 묶기"(2026-11-07).
"켜면 답장과 전달로 이어진 메일을 메일함 목록에서 한 줄로 묶습니다. 메일함 목록 위의 말풍선 모양 [대화로 묶기] 단추로도 켜고 끌 수 있습니다." — 줄 이름은 단추와 같은 "대화로 묶기"(2026-11-07).
- **줄**
- 고르기 칸 · 별을 남겼다(보드의 줄에는 없다 — 검색 결과 줄과 같은 판단, 3단계부터 목록에서 여러 통을 고른다).
메일이 모두 보내기를 기다리는 줄은 고르기 칸 · 별이 꺼짐(aria-disabled) + 까닭이고, 목록 보기에서 Ctrl · Shift + 클릭으로도 고르지 않는다.
+9 -9
View File
@@ -1110,7 +1110,7 @@ interface MailFilterActions {
- `MAIL_012` — 목록 위 줄 "다른 곳에서 필터가 바뀌어 저장하지 않았습니다. 다시 불러온 뒤 고쳐 주세요." + [다시 불러오기]. 고치던 창은 닫지 않는다 —
창 안에도 같은 안내와 [다시 불러오기]를 둔다(모달이라 뒤의 줄을 누를 수 없다). 창에서 다시 불러오면 쓴 내용은 그대로이고, 다음 [저장]은 다시 받은 목록에
그 필터를 넣는다(같은 id 자리 — 그사이 다른 곳에서 지워졌으면 맨 아래).
- `isActive: false` — 목록 위 줄 "다른 곳에서 메일 설정이 바뀌어 지금은 필터와 부재중 자동 응답이 되지 않습니다." + [다시 켜기](지금 목록 그대로 저장).
- `isActive: false` — 목록 위 줄 "다른 곳에서 메일 설정이 바뀌어 지금은 필터와 부재중 자동 응답이 꺼져 있습니다." + [다시 켜기](지금 목록 그대로 저장).
- 읽기 화면 ⋯ 「이런 메일로 필터 만들기」 → `/settings/mail/filters?from=<보낸 사람 주소>` — 새 필터 창이 "보낸 사람 · 포함 · 그 주소"로 채워져 열린다(초점은 할 일의 첫 칸).
50개를 다 만들었는지 알려고 ⋯ 메뉴를 열 때 `GET` 을 한다(받아 둔 목록이 없거나 50개일 때만 — 알림 없이).
- 검사는 서버와 같은 규칙(이름 50 · 값 200 · 조건 5 · 규칙 50 · `${` · 제어 문자 · 할 일 하나 이상 · 전달은 사내 주소이고 자기 주소 아님) — 칸 아래에만 알린다.
@@ -1125,7 +1125,7 @@ interface MailFilterActions {
### 이미 받은 메일에 적용 (`/api/mail-filter-runs`) — 7단계
필터를 저장할 때 고르면(고치기 창 「받은편지함에 이미 있는 메일에도 적용」) 받은편지함에 이미 있는 메일에도 그 규칙 하나를 적용한다.
필터를 저장할 때 고르면(고치기 창 「받은편지함에 이미 있는 메일도 정리하기」) 받은편지함에 이미 있는 메일에도 그 규칙 하나를 적용한다.
엔진의 Sieve 는 메일을 받을 때만 돌기 때문에 서버가 받은편지함을 읽어 같은 규칙으로 판정하고 표시 · 옮기기를 한다.
```ts
@@ -1182,12 +1182,12 @@ interface MailFilterRun {
- 엔진 한도: `Email/query` 는 한 번에 5,000통까지(`queryMaxResults`)라 차례마다 나눠 읽는다. 측정(개발 엔진, 2026-10-29, 시험 메일 5,000통): 보낸 사람 조건으로 4,500통을 확인하고
450통을 옮기는 데 1.3초, 본문 2,000통 읽기 1초 안팎 — 받은편지함 수만 통도 머리글 조건이면 한 요청, 본문 조건이면 몇 요청이다.
- 로그 — 규칙의 글은 남기지 않는다(필터와 같다). 확인 · 적용 수 · 차례 수 · 걸린 시간만.
- **클라이언트**: 고치기 창의 「받은편지함에 이미 있는 메일에도 적용」 — 창을 열 때마다 꺼져 있다(필터의 설정이 아니다). `PUT /api/mail-filters` 가 성공한 뒤 저장한 그 규칙
- **클라이언트**: 고치기 창의 「받은편지함에 이미 있는 메일도 정리하기」 — 창을 열 때마다 꺼져 있다(필터의 설정이 아니다). `PUT /api/mail-filters` 가 성공한 뒤 저장한 그 규칙
(응답 목록의 같은 id — 앞뒤 공백을 지운 값)으로 이 요청을 `cursor` 가 null 이 될 때까지 보낸다.
- 칸을 끈다(칸 아래 설명이 까닭을 말한다): 이 탭에서 다른 적용이 진행 중, 그리고 **이미 받은 메일에 할 일이 없는 필터** — 읽음 · 중요 표시가 없고
옮기기도 없거나 받은편지함으로 · 지워진 메일함으로 옮기는 것뿐(까닭 문구는 지워진 메일함 → 받은편지함 → 전달만 차례로 고른다). 서버는 그런 규칙도 받는다(맞은 수만 센다).
- 진행 알림 "‘{이름}’ 필터로 받은편지함을 정리하는 중 · {확인} / {전체}통 확인"(확인 = scanned 합, 전체 = 확인 + remaining) → 끝나면 그 자리에서
"‘{이름}’ 필터로 받은편지함의 메일 N통을 정리했습니다."(N = matched 합) + 옮기는 필터이고 1통 이상이면 [{메일함} 보기], 0통이면 "받은편지함에 ‘{이름}’ 필터에 맞는 메일이 없습니다."
"‘{이름}’ 필터로 받은편지함의 메일 N통을 정리했습니다."(N = matched 합) + 옮기는 필터이고 1통 이상이면 [{메일함} 보기], 0통이면 "받은편지함에는 ‘{이름}’ 필터에 걸리는 메일이 없습니다."
- 멈춤: "‘{이름}’ 필터로 {전체}통 가운데 {확인}통까지 확인하고 멈췄습니다. 그동안 N통을 정리했습니다. 다시 시도해 주세요." + [다시 시도](마지막 cursor 로 이어서).
처음 요청부터 실패하면 "필터는 저장했지만 받은편지함은 정리하지 못했습니다. 다시 시도해 주세요." + [다시 시도](처음부터).
429 는 "다른 창에서 필터로 받은편지함을 정리하는 중입니다. 끝난 뒤 다시 시도해 주세요." — 도중의 429 면 [다시 시도](이어서)도 붙인다.
@@ -2155,13 +2155,13 @@ interface FileUploadCreate {
| 코드 | HTTP | 뜻 | 쓰는 곳 | 문구 (제안) |
| --- | --- | --- | --- | --- |
| `FILE_003` | 409 | 올리기 · 파일의 지금 상태와 맞지 않는 요청 — 끝낸 올리기에 조각, 같은 조각을 동시에, 받은 조각과 내용이 다른 조각, 빠진 조각이 있는데 끝내기, 끝내기가 진행 중, 살아 있는 링크에 실린 파일 지우기. 아무것도 바꾸지 않았다 | 조각 · 끝내기 · 지우기 | "파일 상태가 그사이 바뀌어 처리하지 못했습니다. 다시 시도해 주세요." |
| `FILE_004` | 400 | 조각의 `Content-Digest` 가 받은 바이트와 다르다 — 그 조각을 받지 않았다(같은 조각을 다시) | 조각 | "파일을 올리는 중에 일부가 제대로 전달되지 않았습니다. 다시 시도해 주세요." |
| `FILE_003` | 409 | 올리기 · 파일의 지금 상태와 맞지 않는 요청 — 끝낸 올리기에 조각, 같은 조각을 동시에, 받은 조각과 내용이 다른 조각, 빠진 조각이 있는데 끝내기, 끝내기가 진행 중, 살아 있는 링크에 실린 파일 지우기. 아무것도 바꾸지 않았다 | 조각 · 끝내기 · 지우기 | "그사이 파일이 바뀌어 하던 일을 마치지 못했습니다. 다시 시도해 주세요." |
| `FILE_004` | 400 | 조각의 `Content-Digest` 가 받은 바이트와 다르다 — 그 조각을 받지 않았다(같은 조각을 다시) | 조각 | "파일 일부가 제대로 올라가지 않았습니다. 다시 시도해 주세요." |
| `FILE_005` | 409 | 직원 보관 한도(보관량 · 보내지 않은 파일 수)를 넘는다 — 만들지 않았다 | 만들기 | "대용량 첨부 보관 한도를 넘어 더 올릴 수 없습니다. 보낸 파일의 링크를 끊거나 보내지 않은 파일을 지우면 공간이 생깁니다." |
| `FILE_006` | 507 | 서버 저장 공간이 모자란다 — 서버 총량 · 디스크 여유 · 쓰다 `ENOSPC` | 만들기 · 조각 | "서버의 저장 공간이 모자라 지금은 파일을 올릴 수 없습니다. 잠시 뒤 다시 시도해 주세요." |
| `FILE_006` | 507 | 서버 저장 공간이 모자란다 — 서버 총량 · 디스크 여유 · 쓰다 `ENOSPC` | 만들기 · 조각 | "회사 저장 공간이 모자라 지금은 파일을 올릴 수 없습니다. 잠시 뒤 다시 시도해 주세요." |
| `FILE_007` | 410 | 더 내려받을 수 없는 파일 — 기간 지남 · 공유 멈춤 · 횟수 초과 · 위험 · 찾을 수 없음 | 공개 받기 · 우리 웹메일 받기 | "이 파일은 더 내려받을 수 없습니다. 필요하면 보낸 사람에게 문의해 주세요." |
| `FILE_008` | 409 | 아직 보안 검사 중인 파일 — 끝나면 받는다 | 공개 받기 · 우리 웹메일 받기 | "보안 검사가 끝나지 않은 파일입니다. 잠시 뒤 다시 시도해 주세요." |
| `MAIL_016` | 400 | 보내거나 저장할 대용량 파일을 쓸 수 없다 — 없음 · 다른 직원 것 · 지움 · 기간 지남, (보낼 때) 올리는 중 · 위험. 메일은 나가지 않았다 | 발송 · 임시 저장 | "대용량 첨부 파일을 보낼 수 없습니다. 해당 파일을 빼거나 다시 올린 뒤 보내 주세요." |
| `MAIL_016` | 400 | 보내거나 저장할 대용량 파일을 쓸 수 없다 — 없음 · 다른 직원 것 · 지움 · 기간 지남, (보낼 때) 올리는 중 · 위험. 메일은 나가지 않았다 | 발송 · 임시 저장 | "보낼 수 없는 대용량 첨부 파일이 있습니다. 그 파일을 빼거나 다시 올린 뒤 보내 주세요." |
- 문구에 숫자를 넣지 않는다 — 한도가 env 로 바뀐다(`MAIL_006` · `CONTACT_002` 처럼 상수와 견주는 테스트를 둘 수 없다).
- 그 밖은 지금 코드를 쓴다 — 형식 400 `FILE_001`, 한 파일 · 조각 본문 413 `FILE_002`, 개수 · 모양 400 `VAL_001`, 없음 404 `RES_001`, 요청 수 · 동시 수 429 `REQ_001`, 엔진 503 `MAIL_001`.
@@ -2510,7 +2510,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개까지 만들 수 있습니다. 쓰지 않는 앱 비밀번호를 지운 뒤 만들어 주세요." — 엔진 상한을 바꾸면 이 문구와 화면 상수도 |
+18 -18
View File
@@ -1,35 +1,35 @@
// backend/src/common/errors/error-codes.ts 에서 자동 생성된 파일입니다. 직접 수정하지 마세요.
const Map<String, String> kErrorCodeMessages = {
'SYS_001': '일시적인 시스템 오류가 발생했습니다. 잠시 후 다시 시도해 주세요.',
'REQ_001': '요청이 너무 많습니다. 잠시 후 다시 시도해 주세요.',
'AUTH_001': '로그인이 필요한 서비스입니다. 로그인 후 이용해 주세요.',
'AUTH_002': '안전을 위해 로그아웃 되었습니다. 다시 로그인해 주세요.',
'AUTH_003': '해당 메뉴나 기능에 접근할 수 있는 권한이 없습니다.',
'AUTH_004': '이메일 또는 비밀번호가 올바르지 않습니다.',
'SYS_001': '문제가 생겨 하던 일을 마치지 못했습니다. 잠시 뒤 다시 시도해 주세요.',
'REQ_001': '너무 여러 번 시도했거나 같은 일이 이미 진행 중입니다. 잠시 뒤 다시 시도해 주세요.',
'AUTH_001': '로그인이 필요합니다. 로그인한 뒤 다시 시도해 주세요.',
'AUTH_002': '안전을 위해 로그아웃했습니다. 다시 로그인해 주세요.',
'AUTH_003': '이 기능을 쓸 수 있는 권한이 없습니다.',
'AUTH_004': '메일 주소나 비밀번호가 맞지 않습니다. 다시 확인해 주세요.',
'AUTH_005': '지금 비밀번호가 올바르지 않습니다.',
'AUTH_006': '코드가 올바르지 않습니다. 휴대폰의 날짜와 시간이 자동으로 맞춰지도록 설정되어 있는지 확인해 주세요.',
'AUTH_006': '코드가 맞지 않습니다. 휴대폰 설정에서 날짜와 시간을 자동으로 맞추게 해 두었는지 확인해 주세요.',
'AUTH_007': '인증 앱의 코드를 입력해 주세요.',
'AUTH_008': '2단계 인증 설정이 그사이 바뀌었거나 시간이 지났습니다. 처음부터 다시 해 주세요.',
'AUTH_009': '5개까지 만들 수 있습니다. 쓰지 않는 앱 비밀번호를 지운 뒤 만들어 주세요.',
'AUTH_010': '먼저 비밀번호를 바꾸거나 2단계 인증을 켜 주세요.',
'VAL_001': '입력하신 내용을 다시 확인해 주세요. 빠진 칸이 있거나 형식이나 길이가 맞지 않습니다.',
'RES_001': '요청하신 정보나 페이지를 찾을 수 없습니다.',
'BIZ_001': '요청하신 작업을 완료하지 못했습니다. 다시 시도해 주세요.',
'FILE_001': '허용되지 않는 파일 형식입니다. 파일을 확인해 주세요.',
'FILE_002': '파일 크기가 허용 범위를 초과했습니다.',
'FILE_003': '파일 상태가 그사이 바뀌어 처리하지 못했습니다. 다시 시도해 주세요.',
'FILE_004': '파일을 올리는 중에 일부가 제대로 전달되지 않았습니다. 다시 시도해 주세요.',
'RES_001': '찾을 수 없습니다. 이미 지워졌거나 옮겨졌을 수 있습니다.',
'BIZ_001': '하려던 일을 마치지 못했습니다. 다시 시도해 주세요.',
'FILE_001': '쓸 수 없는 형식의 파일입니다. 그 파일을 빼거나 다른 파일로 바꿔 주세요.',
'FILE_002': '파일이 너무 커서 올릴 수 없습니다.',
'FILE_003': '그사이 파일이 바뀌어 하던 일을 마치지 못했습니다. 다시 시도해 주세요.',
'FILE_004': '파일 일부가 제대로 올라가지 않았습니다. 다시 시도해 주세요.',
'FILE_005': '대용량 첨부 보관 한도를 넘어 더 올릴 수 없습니다. 보낸 파일의 링크를 끊거나 보내지 않은 파일을 지우면 공간이 생깁니다.',
'FILE_006': '서버의 저장 공간이 모자라 지금은 파일을 올릴 수 없습니다. 잠시 뒤 다시 시도해 주세요.',
'FILE_006': '회사 저장 공간이 모자라 지금은 파일을 올릴 수 없습니다. 잠시 뒤 다시 시도해 주세요.',
'FILE_007': '이 파일은 더 내려받을 수 없습니다. 필요하면 보낸 사람에게 문의해 주세요.',
'FILE_008': '보안 검사가 끝나지 않은 파일입니다. 잠시 뒤 다시 시도해 주세요.',
'MAIL_001': '메일 서비스에 연결할 수 없습니다. 잠시 후 다시 시도해 주세요.',
'MAIL_002': '받는 사람 주소를 다시 확인해 주세요.',
'MAIL_003': '메일함 용량이 부족합니다. 불필요한 메일을 정리한 뒤 다시 시도해 주세요.',
'MAIL_004': '첨부 파일을 찾을 수 없습니다. 해당 첨부를 빼거나 다시 올린 뒤 보내 주세요.',
'MAIL_003': '메일함이 가득 찼습니다. 필요 없는 메일을 지우고 휴지통을 비운 뒤 다시 시도해 주세요.',
'MAIL_004': '첨부 파일을 찾을 수 없습니다. 그 파일을 빼거나 다시 올린 뒤 보내 주세요.',
'MAIL_005': '이미 보낸 메일과 내용이 달라 보내지 않았습니다. 보낸편지함을 확인한 뒤 다시 보내 주세요.',
'MAIL_006': '첨부는 한 메일에 최대 20개까지 보낼 수 있습니다.',
'MAIL_006': '첨부 파일은 메일 한 통에 20개까지 보낼 수 있습니다.',
'MAIL_007': '본문이 너무 길어 보낼 수 없습니다. 내용을 줄인 뒤 다시 보내 주세요.',
'MAIL_008': '임시 저장한 메일이 다른 곳에서 바뀌었거나 지워졌습니다. 지금 내용을 새 임시 저장본으로 남겨 주세요.',
'MAIL_009': '같은 이름의 메일함이 이미 있습니다.',
@@ -39,7 +39,7 @@ const Map<String, String> kErrorCodeMessages = {
'MAIL_013': '필터나 차단 목록이 너무 길어 저장할 수 없습니다. 조건을 줄이거나, 쓰지 않는 필터나 차단한 주소를 지워 주세요.',
'MAIL_014': '이미 보낸 메일이라 취소할 수 없습니다.',
'MAIL_015': '보내기를 기다리는 메일은 옮기거나 지울 수 없습니다. 먼저 보내기를 취소해 주세요.',
'MAIL_016': '대용량 첨부 파일을 보낼 수 없습니다. 해당 파일을 빼거나 다시 올린 뒤 보내 주세요.',
'MAIL_016': '보낼 수 없는 대용량 첨부 파일이 있습니다. 그 파일을 빼거나 다시 올린 뒤 보내 주세요.',
'CONTACT_001': '같은 이름의 그룹이 있습니다. 다른 이름을 써 주세요.',
'CONTACT_002': '주소록 한도를 넘어 더 넣을 수 없습니다. 연락처 3,000개, 그룹 100개, 그룹 하나에 500명까지 넣을 수 있습니다.',
};
+1 -1
View File
@@ -7,7 +7,7 @@ void main() {
test('등록된 코드는 해당 한국어 메시지를 반환한다', () {
expect(
ErrorLexicon.getMessage('AUTH_001'),
'로그인이 필요한 서비스입니다. 로그인 후 이용해 주세요.',
'로그인이 필요합니다. 로그인한 뒤 다시 시도해 주세요.',
);
});
@@ -18,7 +18,7 @@ interface Props {
tone?: 'neutral' | 'danger'
/** 서버 추적 ID — 앞 8자리를 "문의 코드"로 보인다 */
traceId?: string | null
/** 영역을 세로로 가득 채워 가운데 둔다 (본문 영역의 "메일을 선택해 주세요") */
/** 영역을 세로로 가득 채워 가운데 둔다 (본문 영역의 "고른 메일이 없습니다") */
fill?: boolean
}
@@ -2,6 +2,7 @@
// "같은 대화" 칸에서 펼친 줄의 안 (14단계 — 디자인 원본 Thread14 "하나 펼침")
// 받는 사람 · 제목 한 줄씩, 본문(MailBodyFrame — 검색 쿼리 없이 받은 본문이라 강조가 없다), [답장] · [이 메일만 열기](36 — 밀도 예외 --size-notice-action 재사용).
// - 본문이 다 보이지 않으면(서버의 isHtmlBodyOmitted · isBodyTruncated) 읽기 화면과 같은 안내 줄(MAIL_BODY_NOTICES)을 본문 위에 — 읽기 화면도 같은 본문이다.
// 앞 문장만 보이고 원문 내려받기(할 일)는 붙이지 않는다 — 이 화면의 ⋯ 는 위에 연 메일의 것이라, 원문은 [이 메일만 열기]로 연 읽기 화면에서 받는다.
// 외부 이미지 안내 · 첨부 목록은 두지 않는다 — [이 메일만 열기]로 연 읽기 화면에 있다. 첨부가 있으면 본문 아래 · [답장] 위에
// "첨부 파일 N개는 [이 메일만 열기]를 누르면 볼 수 있습니다." (수는 읽기 화면의 "첨부 파일 N개"와 같다 — 본문 그림 빼고 · 대용량 첨부 더해)
// - 본문을 받는 동안은 뼈대(aria-busy), 못 받으면 사전 문구 + [다시 시도]. 그사이 지워진 메일(RES_001)이면 [다시 시도] · [답장] · [이 메일만 열기] 없이 —
@@ -49,7 +50,7 @@ const ready = computed(() => (props.detail?.status === 'ready' ? props.detail.de
const failure = computed(() => (props.detail?.status === 'error' ? props.detail.error : null))
/** 그사이 지워진 메일 — 다시 해도 같고 그 메일로 갈 곳도 없다 */
const missing = computed(() => failure.value?.code === 'RES_001')
const bodyNotices = computed(() => (ready.value ? mailBodyNotices(ready.value) : []))
const bodyNotices = computed(() => (ready.value ? mailBodyNotices(ready.value, { withAction: false }) : []))
/** 읽기 화면에서 볼 수 있는 첨부 수 — 본문 그림(isInline)은 빼고 대용량 첨부는 더한다 (MessageReader 의 첨부 칸과 같은 수) */
const attachmentCount = computed(() =>
ready.value ? listedAttachments(ready.value.attachments).length + (ready.value.largeFiles?.length ?? 0) : 0,
@@ -2,6 +2,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { mount } from '@vue/test-utils'
import { createPinia, setActivePinia, type Pinia } from 'pinia'
import { MAILBOX_EMPTY_HINTS } from '@/shared/constants/mail'
import type { Mailbox, MessageSummary } from '@/shared/types/mail'
import { useAuthStore } from '@/stores/auth.store'
import { useMailStore } from '@/stores/mail.store'
@@ -239,7 +240,9 @@ describe('MailListFrame — 휴지통 · 스팸메일함의 30일 줄 (13단계)
expect(wrapper.find('[data-retention-notice]').exists()).toBe(false)
expect(wrapper.text()).toContain('휴지통이 비어 있습니다')
expect(wrapper.text()).toContain('삭제한 메일이 여기에 표시됩니다. 들어간 지 30일이 지난 메일은 자동으로 지워집니다.')
expect(wrapper.text()).toContain(MAILBOX_EMPTY_HINTS.trash)
// 줄 대신 이 글이 30일을 알린다
expect(MAILBOX_EMPTY_HINTS.trash).toContain('30일')
})
it('다른 메일함 · 검색 결과에는 없다', async () => {
@@ -4,6 +4,7 @@ import { createPinia, setActivePinia, type Pinia } from 'pinia'
import { CLOCK } from '@/composables/useClock'
import { ERROR_CODE_MESSAGES } from '@/shared/constants/error-codes.generated'
import { SEARCH_CONDITIONS_TEXT } from '@/shared/constants/mail-search'
import type { Mailbox } from '@/shared/types/mail'
import { useAuthStore } from '@/stores/auth.store'
import { useMailStore } from '@/stores/mail.store'
@@ -245,9 +246,7 @@ describe('MailSearchBox — [조건] · 「조건으로 찾기」 (14단계 디
'최근 1년',
'날짜 고르기',
])
expect(panel.text()).toContain(
'검색 칸의 말과 이 조건에 모두 맞는 메일을 찾습니다. 받는 사람에는 참조로 받은 사람도 들어갑니다.',
)
expect(panel.text()).toContain(SEARCH_CONDITIONS_TEXT.hint)
// 칸 안내 — 내용 칸은 칸 이름과 같은 말로 (2026-11-06 실사용 시험)
expect(['from', 'to', 'subject', 'body'].map((name) => field(wrapper, name).attributes('placeholder'))).toEqual([
'이름이나 메일 주소',
@@ -1,6 +1,6 @@
<script setup lang="ts">
// 검색 결과 없음 (디자인 원본 Search · Search14 "결과 없음") — 더 찾을 것이 없을 때(hasNext false)만 보인다(이어 찾을 것이 남았으면 MailSearchMore)
// 검색 아이콘 · "‘계약’에 맞는 메일이 없습니다"(조건만으로 찾았으면 "조건에 맞는 메일이 없습니다") · 도움말 —
// 검색 아이콘 · "‘계약’이 들어간 메일이 없습니다"(조건만으로 찾았으면 "조건에 맞는 메일이 없습니다") · 도움말 —
// 검색어와 조건이 함께면 "다른 말로 찾거나 조건을 줄여 보세요.", 조건만이면 "조건을 바꾸거나 줄여 보세요.", 걸린 조건이 없으면 "다른 말로 찾아 보세요."
// 지난 메일을 검색할 수 있게 준비하는 동안(엔진 방식 — 낱말 단위)은 그 한계를 한 줄 더한다 — 준비가 끝나면 뺀다 (14단계)
// 한 메일함에서 찾았으면 [전체 메일함에서 찾기]
@@ -4,6 +4,7 @@ import { createPinia, setActivePinia, type Pinia } from 'pinia'
import { nextTick } from 'vue'
import { ERROR_CODE_MESSAGES } from '@/shared/constants/error-codes.generated'
import { MAILBOX_OPERATION_TEXT } from '@/shared/constants/mailbox-manage'
import type { MailVacationState, Mailbox } from '@/shared/types/mail'
import { endOfDayIso } from '@/shared/utils/mail-vacation'
import { useAuthStore } from '@/stores/auth.store'
@@ -433,7 +434,7 @@ describe('MailboxPanel — 메일함 관리', () => {
await choose(wrapper, '받은편지함', '모두 읽음으로 표시')
// 진행 알림은 그 자리에서 결과로 바뀐다 — 까닭(사전 문구 · 문의 코드)은 인터셉터의 알림
expect(useToastStore().toasts.map((toast) => [toast.kind, toast.message])).toEqual([
['error', '1,234통 가운데 500통까지 처리하고 멈췄습니다. 다시 시도해 주세요.'],
['error', MAILBOX_OPERATION_TEXT.failed('500', '1,234')],
['error', ERROR_CODE_MESSAGES.MAIL_001],
])
@@ -4,7 +4,7 @@ import { createPinia, setActivePinia, type Pinia } from 'pinia'
import { CLOCK, type Clock } from '@/composables/useClock'
import { ERROR_CODE_MESSAGES } from '@/shared/constants/error-codes.generated'
import { MAIL_BODY_NOTICES } from '@/shared/constants/mail'
import { MAIL_BODY_NOTICE_ACTIONS, MAIL_BODY_NOTICES } from '@/shared/constants/mail'
import { MAIL_CONVERSATION_PAGE_SIZE, MAIL_CONVERSATION_REFRESH_GAP_MS } from '@/shared/constants/mail-conversation'
import type { Mailbox, MessageDetail, MessageSummary } from '@/shared/types/mail'
import type { MailListSource } from '@/shared/utils/mail-list-source'
@@ -444,7 +444,7 @@ describe('MessageConversation — 읽기 화면의 같은 대화', () => {
await rowOf(wrapper, 'm2').get('button').trigger('click')
await flushPromises()
const error = rowOf(wrapper, 'm2').get('[data-conversation-detail-error]')
expect(error.text()).toContain('메일 서비스에 연결할 수 없습니다.')
expect(error.text()).toContain(ERROR_CODE_MESSAGES.MAIL_001)
expect(useToastStore().toasts).toEqual([])
// 읽지 못했으니 읽음으로 바꾸지 않는다
expect(requests.some((request) => request.method === 'PATCH')).toBe(false)
@@ -857,7 +857,7 @@ describe('MessageConversation — 읽기 화면의 같은 대화', () => {
})
describe('펼친 줄의 본문 안내 · 그사이 지워진 메일', () => {
it('서식을 뺐거나 잘린 본문이면 읽기 화면과 같은 안내 줄을 본문 위에 둔다', async () => {
it('서식을 뺐거나 잘린 본문이면 읽기 화면과 같은 안내 줄을 본문 위에 둔다 — 앞 문장만(원문은 [이 메일만 열기]로 연 읽기 화면에서)', async () => {
serve({ detail: () => ok(detail(REPLY, { isHtmlBodyOmitted: true, isBodyTruncated: true, textBody: '글자 본문' })) })
const { wrapper } = await mountReader()
@@ -869,6 +869,8 @@ describe('MessageConversation — 읽기 화면의 같은 대화', () => {
['htmlOmitted', MAIL_BODY_NOTICES.htmlOmitted],
['truncated', MAIL_BODY_NOTICES.truncated],
])
// 이 화면의 ⋯ 는 위에 연 메일의 것이라 할 일(⋯ → 원문 내려받기)은 붙이지 않는다
expect(row.text()).not.toContain(MAIL_BODY_NOTICE_ACTIONS.truncated)
expect(row.find('iframe').exists()).toBe(true)
})
@@ -7,7 +7,7 @@ import MailContactDialogHost from '@/components/mail/MailContactDialogHost.vue'
import { createMailContactDialog, MAIL_CONTACT_DIALOG } from '@/composables/useMailContactDialog'
import { ERROR_CODE_MESSAGES } from '@/shared/constants/error-codes.generated'
import { MAIL_BODY_NOTICES } from '@/shared/constants/mail'
import { MAIL_BODY_NOTICE_ACTIONS, MAIL_BODY_NOTICES } from '@/shared/constants/mail'
import type { Contact } from '@/shared/types/contacts'
import type { Mailbox, MailFilterRule, MailFilterSet, MessageDetail, MessagesPatch, MessageSummary } from '@/shared/types/mail'
import type { PaginatedResult } from '@/shared/types/pagination'
@@ -94,12 +94,12 @@ describe('MessageReader 도구 모음', () => {
expect(linkHref(wrapper, '전달')).toBe('/mail/compose?forward=m1')
})
it('목록 보기에서는 메일을 못 불러와도 앞쪽 이동 도구(목록으로 등)가 남고, 메일 동작 버튼은 숨긴다', async () => {
it('목록 보기에서는 메일을 못 불러와도 앞쪽 이동 도구(목록으로 등)가 남고, 이 메일로 할 일 단추는 숨긴다', async () => {
const store = useMailStore()
store.selected = null
store.selectedId = 'm1'
store.selectedStatus = 'error'
store.selectedError = { code: 'RES_001', message: '요청하신 정보나 페이지를 찾을 수 없습니다.' }
store.selectedError = { code: 'RES_001', message: ERROR_CODE_MESSAGES.RES_001 }
const router = createTestRouter()
await router.push('/mail/inbox/m1')
await router.isReady()
@@ -110,7 +110,7 @@ describe('MessageReader 도구 모음', () => {
})
expect(wrapper.find('.test-nav').exists()).toBe(true)
expect(wrapper.text()).toContain('요청하신 정보나 페이지를 찾을 수 없습니다.')
expect(wrapper.text()).toContain(ERROR_CODE_MESSAGES.RES_001)
expect(wrapper.findAll('.tool-button')).toHaveLength(0)
})
@@ -159,7 +159,10 @@ describe('MessageReader 본문 안내 (계약 2절 "읽기 한도")', () => {
expect(notices(withText)).toEqual([{ kind: 'htmlOmitted', text: MAIL_BODY_NOTICES.htmlOmitted }])
const withoutText = await mountWith({ ...DETAIL, isHtmlBodyOmitted: true, textBody: null })
expect(notices(withoutText)).toEqual([{ kind: 'unavailable', text: MAIL_BODY_NOTICES.unavailable }])
// 읽기 화면은 앞 문장 뒤에 할 일(⋯ → 원문 내려받기)까지
expect(notices(withoutText)).toEqual([
{ kind: 'unavailable', text: `${MAIL_BODY_NOTICES.unavailable} ${MAIL_BODY_NOTICE_ACTIONS.unavailable}` },
])
})
it('생략 → 잘림 순서로, 외부 이미지 차단 안내 앞에 같은 모양으로 둔다', async () => {
@@ -172,7 +175,7 @@ describe('MessageReader 본문 안내 (계약 2절 "읽기 한도")', () => {
})
expect(notices(wrapper).map((notice) => notice.kind)).toEqual(['truncated', 'blockedImages'])
expect(notices(wrapper)[0]?.text).toBe(MAIL_BODY_NOTICES.truncated)
expect(notices(wrapper)[0]?.text).toBe(`${MAIL_BODY_NOTICES.truncated} ${MAIL_BODY_NOTICE_ACTIONS.truncated}`)
const both = await mountWith({ ...DETAIL, isHtmlBodyOmitted: true, isBodyTruncated: true, textBody: '긴 글' })
expect(notices(both).map((notice) => notice.kind)).toEqual(['htmlOmitted', 'truncated'])
@@ -2,6 +2,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { mount, type VueWrapper } from '@vue/test-utils'
import { createPinia, setActivePinia, type Pinia } from 'pinia'
import { MAIL_IMAGES_TEXT } from '@/shared/constants/mail'
import type { BlockedSender, Mailbox, MessageDetail, MessagesPatch, MessageSummary } from '@/shared/types/mail'
import { formatScheduleTime } from '@/shared/utils/format'
import type { MailListSource } from '@/shared/utils/mail-list-source'
@@ -179,7 +180,7 @@ describe('MessageReader — 9단계', () => {
extra: (request) => (request.url === '/messages/m1' ? ok(SHOWN) : undefined),
})
const notice = wrapper.get('[data-notice="blockedImages"]')
expect(notice.text()).toContain('외부 이미지를 차단했습니다.')
expect(notice.text()).toContain(MAIL_IMAGES_TEXT.blocked)
const button = notice.get('[data-show-images]')
expect(button.text()).toBe('이미지 보기')
@@ -130,7 +130,7 @@ onBeforeUnmount(() => {
<div
class="reader__toolbar"
role="group"
aria-label="메일 동작"
aria-label="이 메일로 할 일"
>
<slot name="toolbar-start" />
<template v-if="message">
@@ -301,11 +301,11 @@ onBeforeUnmount(() => {
<StateMessage
v-else
icon="mail"
title="메일을 선택해 주세요"
title="고른 메일이 없습니다"
:hint="
mailStore.search
? '검색 결과에서 메일을 고르면 내용이 여기에 표시됩니다.'
: '목록에서 메일을 고르면 내용이 여기에 표시됩니다.'
? '읽을 메일을 검색 결과에서 골라 주세요.'
: '읽을 메일을 목록에서 골라 주세요.'
"
fill
/>
@@ -54,7 +54,8 @@ const senderBlockEntry = computed(() => {
if (!junkId || !props.message.mailboxIds.includes(junkId)) return null
return blockStore.blockOf(props.message.from?.email)
})
const bodyNotices = computed(() => mailBodyNotices(props.message))
/** 읽기 화면은 할 일까지 — "… [⋯] → [원문 내려받기 (.eml)]로 받아 볼 수 있습니다." (이 화면의 ⋯ 는 이 메일의 것이다) */
const bodyNotices = computed(() => mailBodyNotices(props.message, { withAction: true }))
const imagesLoading = computed(() => mailStore.imagesLoadingId === props.message.id)
async function showImages() {
@@ -286,7 +286,7 @@ describe('RecipientField', () => {
expect(options(wrapper)).toEqual(['이도윤 doyun.lee@arasystem.it.kr'])
expect(activeOption(wrapper)).toBeNull()
expect(input.attributes('aria-activedescendant')).toBeUndefined()
expect(wrapper.get('#compose-to-suggestions-hint').text()).toBe('Enter 는 친 주소를 그대로 넣습니다')
expect(wrapper.get('#compose-to-suggestions-hint').text()).toBe('Enter를 누르면 친 주소를 그대로 넣습니다')
await input.trigger('keydown', { key: 'Enter' })
expect(tokensOf(wrapper).map((token) => token.label)).toEqual(['doyun.lee@partner.example'])
@@ -72,7 +72,7 @@ const emit = defineEmits<Emits>()
const suggestions = inject(RECIPIENT_SUGGESTIONS, null)
const HINT_DEFAULT = '↑↓ 고르기 · Enter · Tab 넣기 · Esc 닫기'
const HINT_TYPED_ADDRESS = 'Enter 는 친 주소를 그대로 넣습니다'
const HINT_TYPED_ADDRESS = 'Enter를 누르면 친 주소를 그대로 넣습니다'
/** 아직 토큰이 되지 않은 입력 — v-model (한글 조합이 끝나야 바뀐다) */
const draft = ref<string>('')
@@ -354,7 +354,7 @@ defineExpose({ commit, focus, clearDraft, hasDraft })
<span
v-if="!token.valid"
class="visually-hidden"
>(주소 형식 오류)</span>
>(주소가 올바르지 않음)</span>
<button
type="button"
class="recipient-chip__remove"
@@ -117,7 +117,7 @@ function onAddressInput() {
v-model="text"
type="text"
class="link-form__input"
placeholder="비우면 주소를 그대로 보여 줍니다"
placeholder="비워 두면 주소를 그대로 씁니다"
autocomplete="off"
@keydown.enter="onEnter"
>
@@ -2,7 +2,7 @@
// 앱 비밀번호를 만든 결과 창 — 모달 620(wide) · 처음 초점 [복사] (디자인 원본 SettingsAppPasswords "만든 결과 창" · 계약 5절 "앱 비밀번호")
// "앱 비밀번호를 만들었습니다" / "‘회사 PC 아웃룩’ — 메일 프로그램의 비밀번호 칸에 아래 값을 넣어 주세요."
// - 값은 고정폭으로 4자씩 떨어뜨려 보이지만 띄어쓰기는 없다(조각 사이를 여백으로 — 골라 복사해도 값 그대로) — [복사]는 값 그대로 복사한다.
// "40자입니다. 보기 좋게 4자씩 띄웠지만 띄어쓰기는 없습니다." — 글자 수는 받은 값의 길이로 쓴다.
// "40자입니다. 보기 편하게 4자씩 띄어 놓았지만, 실제로는 띄어쓰기 없이 넣으면 됩니다." — 글자 수는 받은 값의 길이로 쓴다.
// 묶음은 줄바꿈 없는 inline-block 덩어리 — 넓으면 한 줄, 좁으면 묶음 사이에서만 줄이 바뀐다(묶음이 둘로 갈리지 않는다 — 2026-10-31 브라우저 점검)
// - 경고 "이 창을 닫으면 다시 볼 수 없습니다. …" + 설정값 요약 + [닫기]
// - 복사 「앱 비밀번호를 복사했습니다.」 / 못 하면 「앱 비밀번호를 복사하지 못했습니다. 값을 직접 골라 복사해 주세요.」
@@ -3,7 +3,7 @@
// 「로그인된 브라우저」 + "웹메일 3곳" · 오른쪽 [다른 곳 모두 로그아웃](44 — 다른 곳이 없으면 그리지 않는다)
// 표(구현은 실제 table): PC · 휴대폰 아이콘(16) · 브라우저 · 운영체제 / IP / 마지막 활동 / [로그아웃](36 — 밀도 예외).
// 맨 위는 지금 이 브라우저 — 단추 대신 「지금 이 브라우저」 글자만. 모르는 브라우저는 "알 수 없는 브라우저", 모르는 IP 는 「—」.
// 아래 "웹메일 로그인만 보입니다. 메일 프로그램은 앱 비밀번호를 지워 끊습니다."(링크 — 다른 곳이 없으면 앞에 "다른 곳에 로그인된 브라우저가 없습니다.")
// 아래 "메일 프로그램은 이 목록에 없습니다. 연결을 끊으려면 앱 비밀번호를 지워 주세요."(앱 비밀번호는 링크 — 다른 곳이 없으면 앞에 "다른 곳에 로그인된 브라우저가 없습니다.")
// - 줄의 [로그아웃]은 묻지 않고 바로 끊는다 → 「‘Edge · Windows’의 로그인을 끊었습니다.」(되돌리기 없음). 누른 줄이 사라지면 초점은 다음 줄(없으면 앞 줄 · 묶음 제목)
// - [다른 곳 모두 로그아웃] → 확인 창(처음 초점 「취소」) → 「다른 곳의 로그인 2곳을 끊었습니다.」 — 지금 이 브라우저만 남고 초점은 묶음 제목으로
// 지금 이 브라우저 줄이 없으면(서버가 이 세션을 알아보지 못함 — 서버는 모두 끊는다) 확인 창이 이 브라우저도 로그아웃된다고 알리고,
@@ -1,5 +1,5 @@
<script setup lang="ts">
// 고치기 창의 「받은편지함에 이미 있는 메일에도 적용」 (7단계 — 디자인 원본 MailFilterApply · MailFilterApplyStates) — 할 일 아래, 6단계의 안내 띠 자리
// 고치기 창의 「받은편지함에 이미 있는 메일도 정리하기」 (7단계 — 디자인 원본 MailFilterApply · MailFilterApplyStates) — 할 일 아래, 6단계의 안내 띠 자리
// 회색 상자(바탕 #F8F9FB) 하나: 체크 칸(이름 굵게) + 칸 아래 설명(12.5 — 칸의 aria-describedby) → 상자 아래 한 줄 "스팸으로 판정되어 …"
// - 켤지는 부모가 정한다(창을 열 때마다 꺼짐 — 필터의 설정이 아니라 이번 저장에 한 번 쓴다)
// - 고를 수 없으면(block — mail-filter-run.ts 의 filterApplyBlock) 칸을 disabled · 이름을 흐리게, 설명이 까닭을 말한다:
@@ -1,7 +1,7 @@
<script setup lang="ts">
// 필터 고치기 창 (디자인 원본 SettingsFilter "새 필터" · "필터 고치기" — 모달 620, 공용 모양 ModalDialog 의 넓은 창)
// 이름(선택, 50자) → "어떤 메일에"(MailFilterConditionFields — 모두 · 하나라도 · 모든 메일 + 조건 줄 5개까지) →
// 할 일(MailFilterActionFields) → 「받은편지함에 이미 있는 메일에도 적용」(MailFilterApplyField — 7단계, 6단계 안내 띠 자리) →
// 할 일(MailFilterActionFields) → 「받은편지함에 이미 있는 메일도 정리하기」(MailFilterApplyField — 7단계, 6단계 안내 띠 자리) →
// [이 필터 지우기](고칠 때만, 왼쪽) · [저장] [취소].
// - 잘못은 [저장]을 누를 때 칸 아래 빨간 글로만 — 빈 조건 · `${` · 할 일 없음 · 전달 주소(빈 칸 · 형식 · 사내 주소가 아님 · 내 주소) ·
// 이름 · 찾을 글의 길이 · 제어 문자(사전의 VAL_001).
@@ -11,7 +11,7 @@
// - 열 때마다 칸을 새로 채운다 — 새 필터는 빈 조건 한 줄(읽기 화면에서 왔으면 "보낸 사람 · 포함 · 그 주소"), 고치기는 그 규칙.
// 처음 초점은 이름 칸, 읽기 화면에서 만든 새 필터는 할 일의 첫 칸 (디자인 원본 MailFilterStates)
// - 보내는 · 다시 받는 동안(pending)과 다른 저장을 기다리는 동안(busy)은 [저장] · [이 필터 지우기]를 aria-disabled 로 막는다 — 누른 단추가 초점을 잃지 않게.
// - 「받은편지함에 이미 있는 메일에도 적용」은 열 때마다 꺼져 있다(필터에 저장하지 않는다). 이 탭에서 다른 적용이 진행 중(applyBusy)이거나
// - 「받은편지함에 이미 있는 메일도 정리하기」는 열 때마다 꺼져 있다(필터에 저장하지 않는다). 이 탭에서 다른 적용이 진행 중(applyBusy)이거나
// 이미 받은 메일에 할 일이 없으면(옮길 메일함이 지워짐 · 받은편지함으로 옮기기만 · 전달만 — filterApplyBlock) 고를 수 없다.
// 고른 채 [저장]하면 save 의 apply 가 true — 저장한 뒤의 적용 · 알림은 부모가 한다(useMailFilterActions)
// - 나가기 확인(7단계 — 6단계 한계를 고친다, useDialogLeaveConfirm): 창을 연 때의 값(새 필터는 빈 값 · 읽기 화면에서 채운 값)과 다르면
@@ -84,7 +84,7 @@ interface Props {
}
interface Emits {
/** apply — 「받은편지함에 이미 있는 메일에도 적용」을 골랐다 (저장한 뒤 이번에 한 번) */
/** apply — 「받은편지함에 이미 있는 메일도 정리하기」를 골랐다 (저장한 뒤 이번에 한 번) */
(e: 'save', rule: MailFilterRule, apply: boolean): void
(e: 'remove'): void
(e: 'cancel'): void
@@ -124,7 +124,7 @@ const problems = ref<MailFilterProblems>(noFilterProblems(draft.conditions.lengt
let base = baseOf()
/** 창을 연 때의 칸 값 — 이것과 다르면 바꾼 것이다 (나가기 확인) */
const opened = shallowRef<MailFilterDraft>(copyFilterDraft(draft))
/** 「받은편지함에 이미 있는 메일에도 적용」을 골랐다 — 열 때마다 꺼진다 */
/** 「받은편지함에 이미 있는 메일도 정리하기」를 골랐다 — 열 때마다 꺼진다 */
const applyChosen = ref<boolean>(false)
const applyBlock = computed(() => filterApplyBlock(draft, props.mailboxes, props.applyBusy))
@@ -1,10 +1,10 @@
<script setup lang="ts">
// 바탕화면 알림 켜기 · 알림에 보일 것 · 시험 알림 (디자인 원본 SettingsNotify · MailRealtime "막혔을 때 · 지원하지 않을 때")
// 바탕화면 알림 켜기 · 알림 내용 · 시험 알림 (디자인 원본 SettingsNotify · MailRealtime "막혔을 때 · 지원하지 않을 때")
// - [x] 새 메일이 오면 바탕화면 알림 보내기(44) — 켜면 브라우저가 허락을 묻는다(누른 뒤에만). 칸 아래 안내(12.5px):
// 묻기 전(회색) · 허용됨(초록 + 체크) · 막힘(칸을 끄고 빨강 + 경고) · 지원하지 않음(칸을 끄고 회색) — notifyEnableState
// 허락하지 않았으면(막음 · 그냥 닫음) 칸은 비운 채 돌아간다. 묻는 동안은 칸을 끄지 않고 aria-disabled 로만 막는다(초점이 그대로 남게).
// 막혀서(또는 지원하지 않아서) 칸이 꺼지면 초점을 안내 글로 옮긴다 — 왜 꺼졌는지 바로 읽게
// - 알림에 보일 것: [x] 메일 제목도 보이기 — 알림을 끄면 흐리게(누를 수 없다)
// - 알림 내용: [x] 메일 제목도 보이기 — 알림을 끄면 흐리게(누를 수 없다)
// - [시험 알림 보내기](보조 단추 44 · 종 아이콘) — 지금 탭에서 바로 "ARA 웹메일" / "알림이 잘 옵니다. …" (알림을 끄면 누를 수 없다).
// 못 띄우면 조용히 넘어가지 않는다 — 만들 수 없는 브라우저(휴대폰 Chrome 등)면 "지원하지 않음", 허락이 사라졌으면 그 안내로 바꾸고 초점을 옮긴다
// 고정 id — #notify-enabled · #notify-enabled-note · #notify-show-subject · #notify-test
@@ -1,6 +1,6 @@
<script setup lang="ts">
// 설정 › 알림의 "실시간 연결" (디자인 원본 SettingsNotify) — 이 탭이 보는 연결 상태 (stores/mail-realtime.store.ts)
// ● 초록 "연결되어 있습니다 — 새 메일이 오면 바로 목록에 나타납니다." / 끊겼을 때 "연결할 수 없어 1분마다 확인합니다." (저절로 다시 잇는다)
// ● 초록 "연결되어 있어 새 메일이 오면 바로 알 수 있습니다." / 끊겼을 때 "연결할 수 없어 1분마다 확인합니다." (저절로 다시 잇는다)
// 끊긴 동안은 상태 줄 아래에 "연결될 때까지 바탕화면 알림은 오지 않습니다." (2026-10-28 화면 점검 반영)
// 메일 화면에서는 끊겨도 알리지 않는다 — 여기서만 볼 수 있다 (디자인 README "5단계 › 실시간 반영")
import { computed } from 'vue'
@@ -1,6 +1,6 @@
<script setup lang="ts">
// 부재중 자동 응답의 기간 (디자인 원본 SettingsVacation) — (•) 끌 때까지 계속 / ( ) 기간 정하기 → 시작 ~ 끝 날짜 칸(44)
// "시작일 0시부터 끝나는 날 자정까지 답합니다 (이 브라우저의 시간대 기준)." 끈 동안은 묶음째 흐리게(쓴 값은 남긴다).
// "시작하는 날 0시부터 끝나는 날 밤 12시까지 답합니다. 시각은 이 PC의 시간으로 셉니다." 끈 동안은 묶음째 흐리게(쓴 값은 남긴다).
// 날짜는 <input type="date"> 값(YYYY-MM-DD) 그대로 주고받는다 — 시각으로 바꾸는 일은 mail-vacation.ts
// 고정 id — #vacation-period-until-off · #vacation-period-range · #vacation-from · #vacation-to · #vacation-period-error
import { ref } from 'vue'
+4 -2
View File
@@ -26,8 +26,10 @@ import api, {
describe('에러 코드 사전', () => {
it('등록된 코드는 해당 한국어 메시지를 반환한다', () => {
expect(getErrorMessage('AUTH_001')).toBe(
'로그인이 필요한 서비스입니다. 로그인 후 이용해 주세요.',
// 문구는 사전에서만 온다 — 글을 적지 않고 사전을 본다 (문구를 고칠 때마다 이 시험을 고치지 않게)
expect(getErrorMessage('AUTH_001')).toBe(ERROR_CODE_MESSAGES.AUTH_001)
expect(getErrorMessage('AUTH_001')).not.toBe(
ERROR_CODE_MESSAGES[FALLBACK_ERROR_CODE],
)
})
+1 -1
View File
@@ -202,7 +202,7 @@ function handle(
const code = toAuthCode(isErrorCode(rawCode) ? rawCode : FALLBACK_ERROR_CODE, sentWithSession)
const message = getErrorMessage(code)
// 로그아웃한 뒤에 돌아온, 로그인 중에 보낸 요청의 401 · 403 AUTH_010 — 사용자가 끝낸 세션이라 알리지 않는다
// (로그인 화면에 "로그인이 필요한 서비스입니다" 가 뜨지 않게)
// (로그인 화면에 "로그인이 필요합니다" 가 뜨지 않게)
const endedBySignOut = (code === 'AUTH_001' || code === RESTRICTED_CODE) && sentWithSession && authStore.signedOut
if (notify && !endedBySignOut && !isSilenced(code, config?.silent)) showErrorUI(code, message, traceId)
@@ -339,7 +339,7 @@ describe('useLargeFileUploads', () => {
['too-big.zip', 'rejected', '파일 하나는 0MB까지 보낼 수 있습니다.'],
['first.zip', 'ready', null],
['second.zip', 'rejected', '링크로 보내는 파일은 메일 한 통에 1개까지입니다.'],
['setup.exe', 'rejected', '허용되지 않는 파일 형식입니다. 파일을 확인해 주세요.'],
['setup.exe', 'rejected', ERROR_CODE_MESSAGES.FILE_001],
])
expect(lines().filter((line) => line === 'POST /file-uploads')).toHaveLength(1)
expect(uploads.uploadIds.value).toEqual(['u1'])
@@ -11,7 +11,7 @@ import {
} from '@/stores/mail-filter.store'
// 메일 필터 설정 화면의 저장 · 지우기 알림 (디자인 README "6단계 › 상태 · 들어오는 길" · 디자인 원본 MailFilterStates "알림 문구")
// - 고치기 창의 [저장]이 끝나면 "필터를 저장했습니다." — 「받은편지함에 이미 있는 메일에도 적용」을 골랐으면(7단계) 그 대신
// - 고치기 창의 [저장]이 끝나면 "필터를 저장했습니다." — 「받은편지함에 이미 있는 메일도 정리하기」를 골랐으면(7단계) 그 대신
// 저장한 그 규칙(응답 목록의 같은 id — 서버가 앞뒤 공백을 지운 값)으로 받은편지함에 적용하는 진행 알림 (useMailFilterRun)
// - 지우기는 확인 없이 하고 "‘거래처 메일’ 필터를 지웠습니다." + [되돌리기](10초 — 단추가 있는 알림, 띄운 세션에서만 움직인다 — useToast).
// 되돌리기는 지운 규칙을 지운 자리(앞 · 뒤 규칙 기준)에 다시 저장한다 → "필터를 되돌렸습니다." 알림 여럿의 [되돌리기]는 누른 차례대로 보낸다(스토어).
@@ -20,7 +20,7 @@ import {
// - 켜기 · 끄기 · 순서 바꾸기는 알리지 않는다 — 목록이 바로 바뀐다(실패하면 스토어가 되돌리고 인터셉터가 알린다)
export interface MailFilterSaveOptions {
/** 「받은편지함에 이미 있는 메일에도 적용」 — 저장한 뒤 이번에 한 번 (필터의 설정이 아니다) */
/** 「받은편지함에 이미 있는 메일도 정리하기」 — 저장한 뒤 이번에 한 번 (필터의 설정이 아니다) */
apply?: boolean
}
@@ -14,7 +14,7 @@ import { useMailFilterStore } from '@/stores/mail-filter.store'
// - 그사이 바뀜이면 창 안에 [다시 불러오기]를 둔다(conflict — 창이 모달이라 목록 위 줄을 누를 수 없다). 다시 받으면 창 · 쓴 내용은 그대로 두고 안내를 내린다 —
// 다음 [저장]은 다시 받은 목록 · version 위에 넣는다(스토어의 saveRule). 칸을 고쳐도 이 안내는 남는다(다시 받아야 풀린다)
// - 끝나면 초점을 줄 규칙을 돌려준다 — 저장은 그 규칙, 지우기는 이웃 행(다음, 마지막이었으면 앞). 초점은 화면이 옮긴다
// - [저장]이 「받은편지함에 이미 있는 메일에도 적용」을 골랐으면(7단계 — options.apply) 저장한 뒤 적용한다(useMailFilterActions) —
// - [저장]이 「받은편지함에 이미 있는 메일도 정리하기」를 골랐으면(7단계 — options.apply) 저장한 뒤 적용한다(useMailFilterActions) —
// 창을 그사이 닫았어도 저장했으면 적용한다(고른 사람이 저장을 보냈다)
export type MailFilterDialogPending = 'save' | 'remove' | 'reload' | null
@@ -4,6 +4,7 @@ import { nextTick } from 'vue'
import { ERROR_CODE_MESSAGES } from '@/shared/constants/error-codes.generated'
import { SEARCH_SCOPE_ALL } from '@/shared/constants/mail'
import { MAIL_FILTER_RUN_TEXT } from '@/shared/constants/mail-filter'
import type { Mailbox, MailFilterRule, MailFilterRun, MailFilterRunRequest } from '@/shared/types/mail'
import { useAuthStore } from '@/stores/auth.store'
import { useMailFilterRunStore } from '@/stores/mail-filter-run.store'
@@ -149,7 +150,7 @@ describe('useMailFilterRun', () => {
['읽음 · 중요만 하는 필터', 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, '받은편지함에 ‘거래처 메일’ 필터에 맞는 메일이 없습니다.'],
['걸리는 메일이 없는 옮기는 필터', rule(), 0, MAIL_FILTER_RUN_TEXT.none('‘거래처 메일’')],
])('%s — 단추 없이 끝난다', async (_label, target, matched, message) => {
replies(step(8412, matched, 0, null))
+2 -2
View File
@@ -15,11 +15,11 @@ import { useMailStore } from '@/stores/mail.store'
import type { ToastAction } from '@/stores/toast.store'
// 이미 받은 메일에 필터 적용 — 진행 · 결과 알림 (디자인 README "7단계 › 저장한 뒤 — 진행 · 결과 알림" · 디자인 원본 MailFilterApply · MailFilterApplyStates)
// - 고치기 창의 [저장]이 성공했고 「받은편지함에 이미 있는 메일에도 적용」을 골랐으면 "필터를 저장했습니다." 대신 진행 알림
// - 고치기 창의 [저장]이 성공했고 「받은편지함에 이미 있는 메일도 정리하기」를 골랐으면 "필터를 저장했습니다." 대신 진행 알림
// "‘거래처 메일’ 필터를 받은편지함에 적용하는 중 · 4,200 / 8,412통 확인"(첫 응답 전의 전체는 받은편지함의 메일 수 — 모르면 수를 뺀다)이
// 남았다가 끝나면 그 자리에서 결과로 바뀐다 (4단계 오래 걸리는 처리 — useMailboxActions 의 runOperation 과 같은 결)
// - 끝: "‘…’ 필터를 받은편지함의 메일 N통에 적용했습니다." + 옮기는 필터이고 1통 이상이면 [{메일함} 보기](filterRunTarget — 받은편지함 ·
// 지금 메일함 목록에 없는 메일함이면 단추 없음). 맞는 메일이 없으면 "받은편지함에 ‘…’ 필터에 맞는 메일이 없습니다."
// 지금 메일함 목록에 없는 메일함이면 단추 없음). 걸리는 메일이 없으면 "받은편지함에는 ‘…’ 필터에 걸리는 메일이 없습니다."
// - 멈춤(이어 할 자리가 있다): "‘…’ 필터 — {전체}통 가운데 {확인}통까지 확인하고 멈췄습니다(N통에 적용). 다시 시도해 주세요." +
// [다시 시도](그 cursor 로 이어서 — 수는 이어 센다)
// - 처음 요청부터 실패: "필터는 저장했지만 받은편지함에 적용하지 못했습니다. 다시 시도해 주세요." + [다시 시도](처음부터)
@@ -5,7 +5,7 @@ import { createPinia, setActivePinia, type Pinia } from 'pinia'
import { CLOCK } from '@/composables/useClock'
import { PASSWORD_CHANGE_TIMEOUT_MS } from '@/shared/constants/account'
import { ERROR_CODE_MESSAGES } from '@/shared/constants/error-codes.generated'
import { RESTRICTED_SECRET_WINDOW_MS } from '@/shared/constants/session-restriction'
import { RESTRICTED_SECRET_WINDOW_MS, SUPPORT_CONTACT_TEXT } from '@/shared/constants/session-restriction'
import type { TwoFactorEnrollment, TwoFactorStatus } from '@/shared/types/account-security'
import type { SessionRestriction } from '@/shared/types/auth'
import { useAuthStore } from '@/stores/auth.store'
@@ -602,7 +602,7 @@ describe('LoginPage — 제한된 세션 (13단계)', () => {
it('정하지 않았거나 받지 못하면 지금 글 그대로 — 알리지 않는다', async () => {
const { wrapper } = await mountLogin({ supportContact: () => fail(503, 'SYS_001') })
expect(wrapper.get('[data-support-line]').text()).toBe('비밀번호를 잊으셨다면 시스템 관리자에게 문의해 주세요.')
expect(wrapper.get('[data-support-line]').text()).toBe(SUPPORT_CONTACT_TEXT.passwordFallback)
expect(useToastStore().toasts).toEqual([])
})
})
+1 -1
View File
@@ -376,7 +376,7 @@ void supportContactStore.ensureLoaded()
사내 업무를<br>한 계정으로
</p>
<p class="login__lead">
메일부터 시작해 캘린더와 파일 저장소까지 같은 계정으로 이어집니다.
메일부터 시작해 캘린더와 파일 저장소까지 같은 계정으로 쓸 수 있게 됩니다.
</p>
</div>
</section>
+1 -1
View File
@@ -8,7 +8,7 @@
role="alert"
>
<h1>404</h1>
<p>요청하신 정보나 페이지를 찾을 수 없습니다.</p>
<p>찾는 페이지가 없습니다. 주소가 맞는지 확인해 주세요.</p>
<RouterLink to="/">
홈으로 돌아가기
</RouterLink>
@@ -5,7 +5,12 @@ import { h, type FunctionalComponent } from 'vue'
import { createMemoryHistory, createRouter, RouterView, type Router } from 'vue-router'
import { CLOCK } from '@/composables/useClock'
import { CONTACTS_SEARCH_DELAY_MS } from '@/shared/constants/contacts'
import {
CONTACT_DETAIL_TEXT,
CONTACTS_EMPTY_TEXT,
CONTACTS_LIST_TEXT,
CONTACTS_SEARCH_DELAY_MS,
} from '@/shared/constants/contacts'
import { ERROR_CODE_MESSAGES } from '@/shared/constants/error-codes.generated'
import type {
Contact,
@@ -387,7 +392,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(CONTACT_DETAIL_TEXT.noneTitle)
await click(row.get('a'))
expect(router.currentRoute.value.fullPath).toBe('/contacts/all/c2')
@@ -624,11 +629,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(CONTACTS_LIST_TEXT.directoryNote.person)
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(CONTACT_DETAIL_TEXT.directoryHint)
await click(detail.get('[data-contact-add-mine]'))
expect(isOpen(app, '#contact-dialog')).toBe(true)
@@ -720,7 +725,7 @@ describe('ContactsPage (주소록 모듈)', () => {
clock.advance(CONTACTS_SEARCH_DELAY_MS)
await flushPromises()
const empty = app.get('[data-contacts-empty="search"]')
expect(empty.text()).toContain('‘없는사람’에 맞는 연락처가 없습니다')
expect(empty.text()).toContain(CONTACTS_EMPTY_TEXT.searchTitle('없는사람', false))
expect(empty.text()).toContain('이름, 주소, 회사, 전화번호의 일부나 이름의 초성으로 찾을 수 있습니다.')
// Esc 는 찾을 글을 지운다 — 목록이 다시 그룹 전체로
+1 -1
View File
@@ -1195,7 +1195,7 @@ watch(hasMissingLarge, (missing) => {
v-model="to"
label="받는 사람"
input-id="compose-to"
placeholder="메일 주소 입력 후 Enter"
placeholder="메일 주소를 쓰고 Enter를 눌러 주세요"
:error-id="RECIPIENT_ERROR_ID"
:rejected="rejectedByServer !== null"
:disabled="preparing"
@@ -3,6 +3,8 @@ import { mount, type VueWrapper } from '@vue/test-utils'
import { createPinia, setActivePinia, type Pinia } from 'pinia'
import { CLOCK } from '@/composables/useClock'
import { MAIL_SEARCH_TEXT } from '@/shared/constants/mail'
import { SEARCH_NOTICE_TEXT } from '@/shared/constants/mail-search'
import type { Mailbox, MessageDetail, MessageListItem, MessageSummary } from '@/shared/types/mail'
import { useAuthStore } from '@/stores/auth.store'
import { useMailStore } from '@/stores/mail.store'
@@ -128,7 +130,7 @@ describe('MailboxPage — 검색 결과', () => {
const { wrapper, router } = await mountAt(`/mail/search?q=${Q}&in=inbox`)
const push = vi.spyOn(router, 'push')
expect(wrapper.text()).toContain('‘회의’에 맞는 메일이 없습니다')
expect(wrapper.text()).toContain(MAIL_SEARCH_TEXT.emptyTitle('회의'))
// 걸린 조건이 없으면 조건을 줄이라고 하지 않는다 (2026-11-06 실사용 시험)
expect(wrapper.text()).toContain('다른 말로 찾아 보세요.')
expect(wrapper.text()).not.toContain('조건을 줄여')
@@ -335,7 +337,7 @@ describe('MailboxPage — 14단계 검색 (조건 · 강조 · 이어 찾기 ·
url === '/search-index' ? indexState('ready') : ok({ items: [], pageInfo: { hasNext: false, nextCursor: null, total: 0 } })
const { wrapper } = await mountAt(`/mail/search?q=${Q}&in=inbox&${CONDITIONS_QUERY}`)
expect(wrapper.text()).toContain('‘회의’에 맞는 메일이 없습니다')
expect(wrapper.text()).toContain(MAIL_SEARCH_TEXT.emptyTitle('회의'))
expect(wrapper.text()).toContain('다른 말로 찾거나 조건을 줄여 보세요.')
})
@@ -402,9 +404,7 @@ describe('MailboxPage — 14단계 검색 (조건 · 강조 · 이어 찾기 ·
expect(row.get('[data-row-preview]').text()).toBe('지난번 말씀하신 계약서 초안을 첨부했습니다.')
const notice = wrapper.get('[data-search-notice="indexing"]')
// 색인은 밤에만 채운다 — 낮 내내 같은 글이어도 멈춘 줄 알지 않게 (2026-11-06 실사용 시험)
expect(notice.text()).toBe(
'지난 메일을 검색할 수 있게 밤사이에 준비하고 있습니다. 메일이 많으면 며칠 걸릴 수 있고, 그동안은 낱말이 통째로 같은 메일만 찾습니다.',
)
expect(notice.text()).toBe(SEARCH_NOTICE_TEXT.indexing)
expect(notice.element.closest('[role="status"]')).not.toBeNull()
})
@@ -416,7 +416,7 @@ describe('MailboxPage — 14단계 검색 (조건 · 강조 · 이어 찾기 ·
expect(wrapper.get('[data-search-word-limit]').text()).toBe(
'준비가 끝나기 전에는 ‘서버이전’의 ‘서버’처럼 붙여 쓴 말의 일부나, 제목 맨 앞의 [공지]나 Re: 같은 말머리로는 찾지 못합니다.',
)
expect(wrapper.get('[data-search-word-limit]').text()).not.toContain('통째로')
expect(wrapper.get('[data-search-word-limit]').text()).not.toContain('끝까지 다 써야')
})
it('색인 상태를 못 받으면 안내 줄 · 한계 안내 없이 지금처럼 — 알리지 않는다', async () => {
@@ -514,9 +514,7 @@ describe('MailboxPage — 14단계 검색 (조건 · 강조 · 이어 찾기 ·
expect(listRequests()).toEqual([])
const empty = wrapper.get('[data-search-too-many-words]')
expect(empty.text()).toContain('찾을 말이 너무 많습니다')
expect(empty.text()).toContain(
'검색어와 조건에 넣은 낱말은 모두 합쳐 8개까지 찾을 수 있습니다. 검색 칸이나 [조건]에서 낱말을 줄여 주세요.',
)
expect(empty.text()).toContain(SEARCH_NOTICE_TEXT.tooManyWordsHint(8))
// 다른 메일함에서 찾아도 같다 — [전체 메일함에서 찾기]를 권하지 않는다. 조건은 조건 줄에서 지울 수 있다
expect(wrapper.find('.search-empty__all').exists()).toBe(false)
expect(wrapper.find('[data-search-condition-line-clear]').exists()).toBe(true)
@@ -561,9 +559,9 @@ describe('MailboxPage — 14단계 검색 (조건 · 강조 · 이어 찾기 ·
}
const { wrapper } = await mountAt(`/mail/search?q=${Q}&in=all`)
expect(wrapper.text()).toContain('아직 맞는 메일을 찾지 못했습니다')
expect(wrapper.text()).toContain(SEARCH_NOTICE_TEXT.partialTitle)
expect(wrapper.text()).toContain('메일이 많아 한 번에 다 찾지 못했습니다. [더 찾기]를 누르면 이어서 찾습니다.')
expect(wrapper.text()).not.toContain('에 맞는 메일이 없습니다')
expect(wrapper.text()).not.toContain(MAIL_SEARCH_TEXT.emptyTitle('회의'))
const sent = listRequests().length
exhausted = true
@@ -572,7 +570,7 @@ describe('MailboxPage — 14단계 검색 (조건 · 강조 · 이어 찾기 ·
expect(listRequests().length).toBe(sent + 1)
expect(wrapper.find('[data-search-more]').exists()).toBe(false)
expect(wrapper.text()).toContain('‘회의’에 맞는 메일이 없습니다')
expect(wrapper.text()).toContain(MAIL_SEARCH_TEXT.emptyTitle('회의'))
})
it('결과에서 연 메일은 검색어와 글 조건 넷만 넘겨 강조를 받는다 — 제목 · 첨부 이름 · 글 본문의 맞은 말', async () => {
@@ -3,6 +3,7 @@ import { mount, type VueWrapper } from '@vue/test-utils'
import { createPinia, setActivePinia, type Pinia } from 'pinia'
import { CLOCK } from '@/composables/useClock'
import { APP_PASSWORD_RESULT_TEXT } from '@/shared/constants/account-security'
import { ERROR_CODE_MESSAGES } from '@/shared/constants/error-codes.generated'
import type { AppPassword, CreatedAppPassword } from '@/shared/types/account-security'
import { formatBlockedDay, formatRelativeDayTime } from '@/shared/utils/format'
@@ -252,7 +253,7 @@ describe('AccountAppPasswordsPage', () => {
'uk0n',
])
expect(wrapper.get('[data-app-password-value]').text()).toBe(VALUE)
expect(wrapper.get('.result__length').text()).toBe('40자입니다. 보기 좋게 4자씩 띄웠지만 띄어쓰기는 없습니다.')
expect(wrapper.get('.result__length').text()).toBe(APP_PASSWORD_RESULT_TEXT.lengthNote('40'))
expect(document.activeElement?.hasAttribute('data-app-password-copy')).toBe(true)
expect(wrapper.get('.result__warning').text()).toContain('이 창을 닫으면 다시 볼 수 없습니다.')
@@ -3,6 +3,7 @@ import { mount, type VueWrapper } from '@vue/test-utils'
import { createPinia, setActivePinia, type Pinia } from 'pinia'
import { CLOCK } from '@/composables/useClock'
import { LOGIN_HISTORY_TEXT } from '@/shared/constants/account-security'
import { ERROR_CODE_MESSAGES } from '@/shared/constants/error-codes.generated'
import type { AuthSession, LoginEvent } from '@/shared/types/account-security'
import { formatRelativeDayTime } from '@/shared/utils/format'
@@ -16,6 +17,8 @@ import AccountLoginHistoryPage from './AccountLoginHistoryPage.vue'
// 설정 › 계정 › 로그인 기록 (12단계 — 디자인 원본 SettingsLoginHistory · 계약 5절 "로그인된 브라우저" · "로그인 기록")
const USER = { email: 'ara.kim@arasystem.it.kr', name: '김아라' }
/** 로그인된 브라우저 아래 안내 — 가운데 [앱 비밀번호]가 링크다 */
const SESSIONS_NOTE = `${LOGIN_HISTORY_TEXT.sessionsNoteBefore}${LOGIN_HISTORY_TEXT.sessionsNoteLink}${LOGIN_HISTORY_TEXT.sessionsNoteAfter}`
/**
* 2026년 그 시각을 이 PC 의 시간대(지역 시각)로 — 화면은 시각을 브라우저 시간대로 보인다.
* "오늘 오전 9:05" 같은 글을 그대로 보려면 시각을 지역 시각으로 적어야 어느 시간대에서도 같다
@@ -193,7 +196,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(SESSIONS_NOTE)
expect(note.get('a').attributes('href')).toBe('/settings/account/app-passwords')
})
@@ -253,9 +256,7 @@ describe('AccountLoginHistoryPage', () => {
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(wrapper.get('[data-sessions-note]').text()).toBe(`${LOGIN_HISTORY_TEXT.noOthers}${SESSIONS_NOTE}`)
expect(document.activeElement?.id).toBe('login-sessions-title')
})
@@ -398,7 +399,7 @@ describe('AccountLoginHistoryPage', () => {
},
})
expect(wrapper.get('[data-events-more]').text()).toBe('더 보기')
expect(wrapper.text()).toContain('최근 순서로 20개씩 보입니다. 90일이 지난 기록은 지워집니다.')
expect(wrapper.text()).toContain(LOGIN_HISTORY_TEXT.moreHint)
await wrapper.get('[data-events-filter="failures"]').trigger('click')
await flushPromises()
@@ -2,6 +2,7 @@ import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import { mount, type VueWrapper } from '@vue/test-utils'
import { createPinia, setActivePinia, type Pinia } from 'pinia'
import { MAIL_CLIENTS_TEXT } from '@/shared/constants/mail-clients'
import type { TwoFactorStatus } from '@/shared/types/account-security'
import { useAuthStore } from '@/stores/auth.store'
import { useToastStore } from '@/stores/toast.store'
@@ -91,9 +92,7 @@ describe('AccountMailClientsPage', () => {
it('프로그램별 차례 — 새 Outlook 주의 줄 · 다섯 프로그램, 처음에는 모두 접힘', async () => {
const wrapper = await mountPage()
expect(wrapper.get('[data-mail-clients-cloud-warning]').text()).toBe(
'새 Outlook과 휴대폰 Outlook 앱은 메일을 Microsoft 클라우드를 거쳐 받습니다. 회사 정책에 따라 쓰지 않는 것이 좋습니다.',
)
expect(wrapper.get('[data-mail-clients-cloud-warning]').text()).toBe(MAIL_CLIENTS_TEXT.cloudWarning)
const buttons = wrapper.findAll('[data-mail-client] h3 button')
expect(buttons.map((button) => button.text())).toEqual([
expect.stringContaining('아웃룩 (Windows)'),
@@ -3,6 +3,7 @@ import { mount, type VueWrapper } from '@vue/test-utils'
import { createPinia, setActivePinia, type Pinia } from 'pinia'
import { ERROR_CODE_MESSAGES } from '@/shared/constants/error-codes.generated'
import { MAIL_BLOCK_TEXT } from '@/shared/constants/mail-block'
import type { BlockedSender } from '@/shared/types/mail'
import { formatBlockedDay } from '@/shared/utils/format'
import { useAuthStore } from '@/stores/auth.store'
@@ -247,7 +248,7 @@ describe('MailBlocksPage', () => {
})
describe('엔진에서 우리 규칙이 꺼져 있으면(isActive: false) 머리 아래 노란 줄 — 단추 없음', () => {
const INACTIVE = '다른 곳에서 메일 설정이 바뀌어 지금은 차단이 되지 않습니다. 주소를 차단하거나 풀면 다시 켜집니다.'
const INACTIVE = MAIL_BLOCK_TEXT.inactive
it('차단한 주소가 있을 때만 보인다(role=status) — 켜져 있거나 비었으면 없다', async () => {
const off = await mountPage({ isActive: false })
@@ -6,6 +6,7 @@ import { RouterView } from 'vue-router'
import MessageReader from '@/components/mail/MessageReader.vue'
import { ERROR_CODE_MESSAGES } from '@/shared/constants/error-codes.generated'
import { MAIL_FILTER_APPLY_TEXT, MAIL_FILTER_TEXT } from '@/shared/constants/mail-filter'
import type {
Mailbox,
MailFilterRule,
@@ -246,16 +247,16 @@ describe('MailFilterPage', () => {
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-notes]').findAll('p').map((note) => note.text())).toEqual([
'·위에 있는 필터부터 차례로 메일을 정리합니다. 메일함으로 옮기는 필터에 맞은 메일은 그 아래 필터를 거치지 않습니다. 순서는 ▲ ▼ 로 바꿀 수 있습니다.',
'·이미 받은 메일도 정리하려면 필터를 저장할 때 [받은편지함에 이미 있는 메일에도 적용]을 골라 주세요. 받은편지함에 있는 메일만 그 필터 하나로 정리합니다.',
'·스팸으로 분류된 메일은 필터를 거치지 않고 그대로 스팸메일함으로 갑니다.',
'·전달은 새로 오는 메일만 사내 주소로 합니다. 메일 한 통은 한 곳으로만 전달되고, 전달하는 필터가 여럿이면 위에 있는 필터가 먼저입니다. 전달해도 내 메일함에 그대로 남습니다.',
'·필터가 메일을 옮겨도 부재중 자동 응답은 그대로 보냅니다.',
`·${MAIL_FILTER_TEXT.noteOrderStrong}${MAIL_FILTER_TEXT.noteOrderAfter}`,
`·${MAIL_FILTER_TEXT.noteApplyBefore}${MAIL_FILTER_TEXT.noteApplyStrong}${MAIL_FILTER_TEXT.noteApplyAfter}`,
`·${MAIL_FILTER_TEXT.noteSpam}`,
`·${MAIL_FILTER_TEXT.noteForward}`,
`·${MAIL_FILTER_TEXT.noteVacation}`,
])
expect(wrapper.get('[data-filter-note-apply] strong').text()).toBe('[받은편지함에 이미 있는 메일에도 적용]')
expect(wrapper.get('.settings-page__description').text()).toBe(
'받은 메일을 조건에 따라 메일함으로 옮기거나, 읽음이나 중요로 표시하거나, 동료에게 전달합니다. 새로 오는 메일을 정리하고, 저장할 때 고르면 받은편지함에 이미 있는 메일도 함께 정리합니다.',
)
// 안내 상자의 [ ]는 고치기 창의 칸 이름 그대로다 — 칸 이름을 바꾸면 함께 바뀐다
expect(wrapper.get('[data-filter-note-apply] strong').text()).toBe(`[${MAIL_FILTER_APPLY_TEXT.label}]`)
expect(MAIL_FILTER_TEXT.noteApplyStrong).toBe(`[${MAIL_FILTER_APPLY_TEXT.label}]`)
expect(wrapper.get('.settings-page__description').text()).toBe(MAIL_FILTER_TEXT.description)
expect(wrapper.find('[data-filter-empty]').exists()).toBe(false)
expect(wrapper.find('[data-filter-conflict]').exists()).toBe(false)
expect(wrapper.find('[data-filter-inactive]').exists()).toBe(false)
@@ -542,7 +543,7 @@ describe('MailFilterPage', () => {
expect(wrapper.get('#mail-filter-error').text()).toBe(ERROR_CODE_MESSAGES.MAIL_012)
const bar = wrapper.get('[data-filter-conflict]')
expect(bar.attributes('role')).toBe('alert')
expect(bar.text()).toContain('다른 곳에서 필터가 바뀌어 저장하지 않았습니다. 다시 불러온 뒤 고쳐 주세요.')
expect(bar.text()).toContain(ERROR_CODE_MESSAGES.MAIL_012)
expect(toasts()).toEqual([])
filters = { rules: [RULES[2] as MailFilterRule], version: 'v9', isActive: true }
@@ -721,7 +722,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(MAIL_FILTER_TEXT.inactive)
await click(bar.get('button'))
expect(puts()).toEqual([{ rules: RULES, version: 'v1' }])
@@ -814,7 +815,7 @@ describe('MailFilterPage', () => {
expect(router.currentRoute.value.fullPath).toBe('/settings/mail/filters')
})
describe('받은편지함에 이미 있는 메일에도 적용 (7단계 — 디자인 원본 MailFilterApply · MailFilterApplyStates)', () => {
describe('받은편지함에 이미 있는 메일도 정리하기 (7단계 — 디자인 원본 MailFilterApply · MailFilterApplyStates)', () => {
const applyBox = (wrapper: VueWrapper) => wrapper.get('#mail-filter-apply')
const applyInput = (wrapper: VueWrapper) => applyBox(wrapper).element as HTMLInputElement
const applyHelp = (wrapper: VueWrapper) => wrapper.get('#mail-filter-apply-help').text()
@@ -829,9 +830,9 @@ describe('MailFilterPage', () => {
expect(applyInput(wrapper).checked).toBe(false)
expect(applyBox(wrapper).attributes('disabled')).toBeUndefined()
expect(applyBox(wrapper).attributes('aria-describedby')).toBe('mail-filter-apply-help')
expect(wrapper.get('[data-filter-apply] label').text()).toBe('받은편지함에 이미 있는 메일에도 적용')
expect(wrapper.get('[data-filter-apply] label').text()).toBe(MAIL_FILTER_APPLY_TEXT.label)
expect(applyHelp(wrapper)).toBe(HELP)
expect(wrapper.get('[data-filter-apply-spam]').text()).toBe('스팸으로 분류된 메일은 필터를 거치지 않습니다.')
expect(wrapper.get('[data-filter-apply-spam]').text()).toBe(MAIL_FILTER_APPLY_TEXT.spam)
// 6단계의 안내 띠는 없다
expect(wrapper.text()).not.toContain('새로 오는 메일에만 적용됩니다')
@@ -12,7 +12,7 @@
// - 읽기 화면 ⋯ 「이런 메일로 필터 만들기」는 ?from=<보낸 사람 주소> 로 온다 — 받은 뒤 새 필터 창을 그 주소로 채워 열고(50개면 열지 않는다)
// 주소에서 지운다(replace — 새로 고침에 또 열리지 않게)
// - 누른 단추가 사라져 초점을 잃으면(지운 행 · 사라진 줄 · 빈 상태의 [새 필터]) 가까운 행이나 머리의 [새 필터]로 옮긴다
// - 창의 「받은편지함에 이미 있는 메일에도 적용」(7단계)을 고른 채 저장하면 저장한 뒤 받은편지함에 적용한다(useMailFilterActions → useMailFilterRun).
// - 창의 「받은편지함에 이미 있는 메일도 정리하기」(7단계)를 고른 채 저장하면 저장한 뒤 받은편지함에 적용한다(useMailFilterActions → useMailFilterRun).
// 이 탭에서 적용하는 중이면 창의 그 칸을 끈다(mail-filter-run.store 의 running — 화면을 떠나도 적용은 이어진다)
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
@@ -4,6 +4,7 @@ import { createPinia, setActivePinia, type Pinia } from 'pinia'
import { CLOCK } from '@/composables/useClock'
import { ERROR_CODE_MESSAGES } from '@/shared/constants/error-codes.generated'
import { LARGE_FILE_SETTINGS_TEXT } from '@/shared/constants/large-file'
import type { FileQuota, LargeFile, ShareLink } from '@/shared/types/large-file'
import { useAuthStore } from '@/stores/auth.store'
import { useLargeFilesStore } from '@/stores/large-files.store'
@@ -198,9 +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) 파일을 지웁니다. 이 파일을 실은 임시 저장본을 열면 첨부 칸에 지운 파일로 보입니다. 지운 파일은 되돌릴 수 없습니다.',
)
expect(view.get('#large-delete-message').text()).toBe(LARGE_FILE_SETTINGS_TEXT.deleteMessage('시공도면_v4.dwg', '620 MB'))
expect(document.activeElement?.hasAttribute('data-delete-cancel')).toBe(true)
await view.get('[data-delete-confirm]').trigger('click')
@@ -283,7 +282,7 @@ describe('MailLargeFilesPage', () => {
it('목록을 받지 못하면 사전 문구와 "다시 시도" — 알림은 띄우지 않는다', async () => {
const { wrapper: view } = await mountPage({ failLinks: true })
expect(view.text()).toContain('메일 서비스에 연결할 수 없습니다.')
expect(view.text()).toContain(ERROR_CODE_MESSAGES.MAIL_001)
expect(useToastStore().toasts).toEqual([])
expect(useLargeFilesStore().status).toBe('error')
})
@@ -2,6 +2,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { flushPromises, mount } from '@vue/test-utils'
import { createPinia, setActivePinia, type Pinia } from 'pinia'
import { MAIL_NOTIFY_TEXT } from '@/shared/constants/mail-notify'
import { useAuthStore } from '@/stores/auth.store'
import { useMailNotifyStore } from '@/stores/mail-notify.store'
import { useMailRealtimeStore } from '@/stores/mail-realtime.store'
@@ -48,15 +49,15 @@ describe('MailNotifyPage (설정 › 메일 › 알림)', () => {
return wrapper.get('#notify-enabled').element as HTMLInputElement
}
it('제목 · 설명 — 새 메일은 1분을 기다리지 않고 바로 목록에 나타난다', async () => {
it('제목 · 설명 — 새 메일이 오면 바로 목록에서 볼 수 있다', async () => {
const wrapper = await mountPage()
expect(wrapper.get('h1').text()).toBe('알림')
expect(wrapper.get('main').attributes('aria-labelledby')).toBe('notify-page-title')
expect(wrapper.text()).toContain('새 메일은 1분을 기다리지 않고 바로 목록에 나타납니다.')
expect(wrapper.text()).toContain(MAIL_NOTIFY_TEXT.description)
})
it('묻기 전 — 칸은 비었고 "켜면 브라우저가 … [허용]을 눌러 주세요.", 보일 것 · 시험 알림은 흐리게', async () => {
it('묻기 전 — 칸은 비었고 "켜면 브라우저가 … [허용]을 눌러 주세요.", 알림 내용 · 시험 알림은 흐리게', async () => {
const wrapper = await mountPage()
expect(enableInput(wrapper).checked).toBe(false)
@@ -132,7 +133,7 @@ describe('MailNotifyPage (설정 › 메일 › 알림)', () => {
expect(document.activeElement).toBe(enableInput(wrapper))
})
it('시험 알림을 만들 수 없는 브라우저(휴대폰 Chrome 등)면 조용히 넘어가지 않고 "지원하지 않습니다"로 바꾼다 — 초점은 안내 글로', async () => {
it('시험 알림을 만들 수 없는 브라우저(휴대폰 Chrome 등)면 조용히 넘어가지 않고 "쓸 수 없습니다"로 바꾼다 — 초점은 안내 글로', async () => {
FakeNotification.reset('granted')
const wrapper = await mountPage()
await wrapper.get('#notify-enabled').setValue(true)
@@ -143,7 +144,7 @@ describe('MailNotifyPage (설정 › 메일 › 알림)', () => {
await flushPromises()
const note = wrapper.get('#notify-enabled-note')
expect(note.text()).toBe('이 브라우저는 바탕화면 알림을 지원하지 않습니다. 새 메일은 그래도 바로 목록에 나타납니다.')
expect(note.text()).toBe(MAIL_NOTIFY_TEXT.noteUnsupported)
expect(enableInput(wrapper).disabled).toBe(true)
expect(wrapper.get('#notify-test').attributes('disabled')).toBeDefined()
expect(document.activeElement).toBe(note.element)
@@ -166,7 +167,7 @@ describe('MailNotifyPage (설정 › 메일 › 알림)', () => {
expect(enableInput(wrapper).checked).toBe(true)
})
it('끄기 — 체크를 풀면 꺼지고 보일 것 · 시험 알림이 흐려진다 (허락은 그대로)', async () => {
it('끄기 — 체크를 풀면 꺼지고 알림 내용 · 시험 알림이 흐려진다 (허락은 그대로)', async () => {
FakeNotification.reset('granted')
const wrapper = await mountPage()
await wrapper.get('#notify-enabled').setValue(true)
@@ -182,17 +183,15 @@ describe('MailNotifyPage (설정 › 메일 › 알림)', () => {
expect(wrapper.get('#notify-enabled-note').text()).toBe('이 브라우저에서 알림이 허용되어 있습니다.')
})
it('지원하지 않는 브라우저 — 칸을 끄고 "지원하지 않습니다. 새 메일은 그래도 바로 목록에"', async () => {
it('지원하지 않는 브라우저 — 칸을 끄고 "쓸 수 없습니다. 새 메일은 그래도 목록에서 바로"', async () => {
vi.stubGlobal('Notification', undefined)
const wrapper = await mountPage()
expect(enableInput(wrapper).disabled).toBe(true)
expect(wrapper.get('#notify-enabled-note').text()).toBe(
'이 브라우저는 바탕화면 알림을 지원하지 않습니다. 새 메일은 그래도 바로 목록에 나타납니다.',
)
expect(wrapper.get('#notify-enabled-note').text()).toBe(MAIL_NOTIFY_TEXT.noteUnsupported)
})
it('메일 제목도 보이기 — 끄면 저장한다 ("끄면 보낸 사람 이름만 보입니다")', async () => {
it('메일 제목도 보이기 — 끄면 저장한다 ("끄면 알림에 보낸 사람 이름만 나옵니다")', async () => {
FakeNotification.reset('granted')
const wrapper = await mountPage()
await wrapper.get('#notify-enabled').setValue(true)
@@ -200,7 +199,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(MAIL_NOTIFY_TEXT.showSubjectHint)
await subject.setValue(false)
expect(useMailNotifyStore().settings.showSubject).toBe(false)
@@ -213,7 +212,7 @@ describe('MailNotifyPage (설정 › 메일 › 알림)', () => {
const status = () => wrapper.get('#notify-realtime-status')
expect(wrapper.get('h2').text()).toBe('실시간 연결')
expect(status().text()).toBe('연결되어 있어 새 메일이 오면 바로 목록에 나타납니다.')
expect(status().text()).toBe(MAIL_NOTIFY_TEXT.realtimeLive)
expect(status().find('.realtime-link__dot').classes()).toContain('is-live')
expect(wrapper.find('#notify-realtime-notice').exists()).toBe(false)
@@ -240,7 +239,7 @@ describe('MailNotifyPage (설정 › 메일 › 알림)', () => {
expect(notes.findAll('p').map((note) => note.findAll('span').at(-1)?.text())).toEqual([
'알림 설정은 이 브라우저에만 저장됩니다. 다른 PC나 브라우저에서는 그곳에서 따로 켜 주세요.',
'웹메일 탭이 하나라도 열려 있어야 알림이 옵니다. 탭을 모두 닫으면 오지 않습니다.',
'웹메일 화면을 보고 있을 때는 새 메일이 목록에 바로 보이므로 바탕화면 알림을 보내지 않습니다.',
MAIL_NOTIFY_TEXT.noteVisible,
])
expect(notes.get('strong').text()).toBe('이 브라우저에만')
})
@@ -1,6 +1,6 @@
<script setup lang="ts">
// 설정 › 메일 › 알림 (디자인 원본 SettingsNotify · README "5단계 › 설정 › 메일 › 알림") — /settings/mail/notify
// 위에서부터 바탕화면 알림 켜기 · 알림에 보일 것 · 시험 알림(NotifySettingsForm) — 가로줄 — 실시간 연결(RealtimeLinkStatus) — 안내 상자(바탕 #F3F8F8):
// 위에서부터 바탕화면 알림 켜기 · 알림 내용 · 시험 알림(NotifySettingsForm) — 가로줄 — 실시간 연결(RealtimeLinkStatus) — 안내 상자(바탕 #F3F8F8):
// 이 브라우저에만 적용 · 웹메일 탭이 하나라도 열려 있어야 · 웹메일 화면을 보고 있을 때는 보내지 않음.
// 설정은 이 브라우저 · 사용자마다 저장한다(mail-notify.store). 들어올 때 · 탭으로 돌아올 때 브라우저의 허락을 다시 읽는다 — 사이트 설정에서 바꿨을 수 있다.
import { onBeforeUnmount, onMounted } from 'vue'
@@ -3,6 +3,7 @@ import { mount, type VueWrapper } from '@vue/test-utils'
import { createPinia, setActivePinia, type Pinia } from 'pinia'
import { ERROR_CODE_MESSAGES } from '@/shared/constants/error-codes.generated'
import { MAIL_SETTINGS_TEXT } from '@/shared/constants/mail-settings'
import type { MailSettings } from '@/shared/types/mail'
import { useAuthStore } from '@/stores/auth.store'
import { useToastStore } from '@/stores/toast.store'
@@ -86,7 +87,7 @@ describe('MailSendReadPage', () => {
'늘 보기',
])
expect(checkedImages(wrapper)).toEqual(['trusted'])
expect(wrapper.get('[data-images-note]').text()).toContain('외부 이미지는 회사를 거쳐 불러오므로 보낸 사람은 이 PC의 주소를 알 수 없습니다.')
expect(wrapper.get('[data-images-note]').text()).toContain(MAIL_SETTINGS_TEXT.imagesNote)
expect(wrapper.get('[data-settings-status]').text()).toBe('')
})
@@ -128,9 +129,7 @@ describe('MailSendReadPage', () => {
['끔', 'true'],
['켬', 'false'],
])
expect(wrapper.get('#send-read-group-hint').text()).toBe(
'답장과 전달로 이어진 메일을 메일함 목록에서 한 줄로 묶어 보여 줍니다. 메일함 목록 위의 말풍선 모양 [대화로 묶기] 단추로도 켜고 끌 수 있습니다.',
)
expect(wrapper.get('#send-read-group-hint').text()).toBe(MAIL_SETTINGS_TEXT.groupHint)
await wrapper.get('[data-group-by-conversation="true"]').trigger('click')
await flushPromises()
@@ -3,6 +3,7 @@ import { mount, type VueWrapper } from '@vue/test-utils'
import { createPinia, setActivePinia, type Pinia } from 'pinia'
import { ERROR_CODE_MESSAGES } from '@/shared/constants/error-codes.generated'
import { VACATION_TEXT } from '@/shared/constants/mail-vacation'
import type { MailVacation, MailVacationState } from '@/shared/types/mail'
import { formatDate } from '@/shared/utils/format'
import { endOfDayIso, startOfDayIso } from '@/shared/utils/mail-vacation'
@@ -111,9 +112,7 @@ describe('MailVacationPage', () => {
expect((wrapper.get('#vacation-period-range').element as HTMLInputElement).checked).toBe(true)
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시부터 끝나는 날 자정까지 답합니다. 시각은 이 브라우저의 시간대를 따릅니다.',
)
expect(wrapper.get('#vacation-period-hint').text()).toBe(VACATION_TEXT.rangeHint)
expect(value(wrapper, '#vacation-subject')).toBe(SAVED.subject)
const preview = wrapper.get('[data-vacation-preview]')
expect(preview.text()).toContain('김아라 <ara.kim@arasystem.it.kr>')
@@ -112,7 +112,7 @@ export const TWO_FACTOR_ENABLE_TEXT = {
/** 앱에 저장될 이름 — 서버의 otpauthUrl 과 같은 이름(계약 5절 "정한 것" 1) */
savedAs: (email: string) => `앱에는 ‘ARA SYSTEM (${email})’로 저장됩니다.`,
/** 켜지 않고 닫으면 그 키는 버린다 — 앱에 먼저 저장한 항목은 지워 달라고 안내한다 (README "켜기 창") */
discardNote: '켜지 않고 닫으면 이 키는 더 쓸 수 없습니다. 앱에 먼저 저장했다면 그 항목을 지워 주세요.',
discardNote: '켜지 않고 닫으면 이 키는 더 쓸 수 없습니다. 인증 앱에 먼저 추가했다면 앱에서 그 계정을 지워 주세요.',
loading: 'QR 코드를 만드는 중…',
codeLabel: '앱에 표시된 6자리 코드',
passwordLabel: '지금 비밀번호',
@@ -190,7 +190,7 @@ export const APP_PASSWORD_RESULT_TEXT = {
message: (name: string) => `‘${name}’ 앱 비밀번호입니다. 메일 프로그램의 비밀번호 칸에 아래 값을 넣어 주세요.`,
copy: '복사',
copyLabel: '앱 비밀번호 복사',
lengthNote: (length: string) => `${length}자입니다. 보기 좋게 4자씩 띄웠지만 띄어쓰기는 없습니다.`,
lengthNote: (length: string) => `${length}자입니다. 보기 편하게 4자씩 띄어 놓았지만, 실제로는 띄어쓰기 없이 넣으면 됩니다.`,
warningStrong: '이 창을 닫으면 다시 볼 수 없습니다.',
warningAfter: ' 잊어버리면 지우고 새로 만들어 주세요.',
copied: '앱 비밀번호를 복사했습니다.',
@@ -235,7 +235,7 @@ export const LOGIN_HISTORY_TEXT = {
unknownBrowser: '알 수 없는 브라우저',
unknownValue: '알 수 없음',
noOthers: '다른 곳에 로그인된 브라우저가 없습니다. ',
sessionsNoteBefore: '웹메일 로그인만 보입니다. 메일 프로그램의 연결을 끊으려면 ',
sessionsNoteBefore: '메일 프로그램은 이 목록에 없습니다. 연결을 끊으려면 ',
sessionsNoteLink: '앱 비밀번호',
sessionsNoteAfter: '를 지워 주세요.',
sessionEnded: (device: string) => `‘${device}’에서 로그아웃했습니다.`,
@@ -246,7 +246,7 @@ export const LOGIN_HISTORY_TEXT = {
eventsHintAll: '최근 90일 · 웹메일과 메일 프로그램',
eventsHintFailures: (count: string) => `최근 90일 · 실패 ${count}번`,
eventsHintFailuresUnknown: '최근 90일 · 실패만',
filterLabel: '보일 기록',
filterLabel: '볼 기록',
filterAll: '전체',
filterFailures: '실패만',
columnTime: '시각',
@@ -263,7 +263,7 @@ export const LOGIN_HISTORY_TEXT = {
failureWrongCode: '코드가 틀려 실패',
failureAppPasswordRequired: '앱 비밀번호가 필요해 실패',
more: '더 보기',
moreHint: '최근 순서로 20개씩 보입니다. 90일이 지난 기록은 지워집니다.',
moreHint: '최근 기록부터 20개씩 볼 수 있습니다. 90일이 지난 기록은 지워집니다.',
emptyFailuresTitle: '최근 90일 동안 실패한 로그인이 없습니다',
emptyFailuresHint: '[전체]를 누르면 성공한 로그인도 볼 수 있습니다.',
emptyAllTitle: '최근 90일 동안 로그인 기록이 없습니다',
+10 -8
View File
@@ -2,6 +2,7 @@
// 한도는 서버와 같은 값이다 — 서버가 최종 검증한다. 문구는 README "화면 문구 — 사용자에게 말하듯"(2026-11-05)을 따른다 — 보드의 옛 글과 다를 수 있다.
import type { ContactEmailLabel, ContactPhoneLabel, DirectoryKind } from '@/shared/types/contacts'
import { directionParticle } from '@/shared/utils/korean'
/**
* 주소록 라우트 이름 — 라우트 표(router/routes.ts) · 레일(app-modules.ts) · 화면 안의 링크 · 읽기 화면 ⋯ 「주소록에서 보기」가 함께 쓴다.
@@ -126,7 +127,7 @@ export const CONTACTS_LIST_TEXT = {
count: (count: string, shared: boolean) => `${count}${shared ? '개' : '명'}`,
found: (count: string, shared: boolean) => `찾은 결과 ${count}${shared ? '개' : '명'}`,
directoryNote: {
person: '회사 메일을 쓰는 직원이라 이름과 메일 주소만 보입니다.',
person: '회사 메일을 쓰는 직원입니다. 사내 목록에는 이름과 메일 주소만 있습니다.',
shared: '여러 사람이 함께 받는 사내 주소입니다.',
} satisfies Record<DirectoryKind, string>,
addMembers: '구성원 넣기',
@@ -141,7 +142,8 @@ export const CONTACTS_LIST_TEXT = {
/** 목록의 빈 상태 · 못 받음 (디자인 원본 Contacts · ContactsEdit "상태") */
export const CONTACTS_EMPTY_TEXT = {
searchTitle: (query: string, directory: boolean) => `‘${query}’에 맞는 ${directory ? '주소가' : '연락처가'} 없습니다`,
searchTitle: (query: string, directory: boolean) =>
`‘${query}’${directionParticle(query)} 찾은 ${directory ? '주소가' : '연락처가'} 없습니다`,
searchHint: '이름, 주소, 회사, 전화번호의 일부나 이름의 초성으로 찾을 수 있습니다.',
directorySearchHint: '이름이나 주소의 일부, 이름의 초성으로 찾을 수 있습니다.',
firstTitle: '아직 연락처가 없습니다',
@@ -164,7 +166,7 @@ export const CONTACT_DETAIL_TEXT = {
more: '더 보기',
menu: '연락처 메뉴',
/** 상세 머리 줄(단추 묶음)의 이름 */
actions: '연락처 동작',
actions: '이 연락처로 할 일',
addToMine: '내 주소록에 추가',
noOrg: '소속 없음',
noName: '(이름 없음)',
@@ -183,9 +185,9 @@ export const CONTACT_DETAIL_TEXT = {
/** 그룹 단추의 이름 — 누르면 그 그룹의 목록 */
openGroup: (name: string) => `‘${name}’ 그룹 보기`,
directoryHint:
'사내 목록은 이름과 주소만 보이고 여기서 고칠 수 없습니다. 전화번호나 메모를 적어 두려면 내 주소록에 추가해 주세요.',
noneTitle: '연락처를 골라 주세요',
noneHint: '목록에서 고르면 주소와 전화번호가 여기에 보입니다.',
'사내 목록에는 이름과 주소만 있고, 여기서는 고칠 수 없습니다. 전화번호나 메모를 적어 두려면 내 주소록에 추가해 주세요.',
noneTitle: '고른 연락처가 없습니다',
noneHint: '자세히 볼 연락처를 목록에서 골라 주세요.',
leaveToList: '목록으로',
loading: '연락처 불러오는 중',
} as const
@@ -274,7 +276,7 @@ export const CONTACT_MEMBERS_TEXT = {
staff: '사내 직원',
search: '구성원으로 넣을 연락처 찾기',
searchPlaceholder: '이름, 주소, 초성으로 찾기',
note: '이미 이 그룹에 있는 연락처는 보이지 않습니다. [사내 직원]에서 고르면 그 직원의 이름과 주소를 내 주소록에도 추가합니다.',
note: '이 그룹에 이미 있는 사람은 목록에 없습니다. [사내 직원]에서 고른 사람은 이름과 주소가 내 주소록에도 함께 들어갑니다.',
picked: (count: string) => `${count}명 고름`,
pickMax: '한 번에 100명까지 넣을 수 있습니다.',
add: '넣기',
@@ -283,7 +285,7 @@ export const CONTACT_MEMBERS_TEXT = {
loading: '불러오는 중',
emptyMine: '넣을 수 있는 연락처가 없습니다',
emptyStaff: '넣을 수 있는 직원이 없습니다',
searchEmpty: (query: string) => `‘${query}’에 맞는 사람이 없습니다`,
searchEmpty: (query: string) => `‘${query}’${directionParticle(query)} 찾은 사람이 없습니다`,
} as const
/** 알림 (디자인 원본 ContactsEdit "알림" — 단추가 있으면 10초) */
@@ -1,35 +1,35 @@
// backend/src/common/errors/error-codes.ts 에서 자동 생성된 파일입니다. 직접 수정하지 마세요.
export const ERROR_CODE_MESSAGES = {
SYS_001: '일시적인 시스템 오류가 발생했습니다. 잠시 후 다시 시도해 주세요.',
REQ_001: '요청이 너무 많습니다. 잠시 후 다시 시도해 주세요.',
AUTH_001: '로그인이 필요한 서비스입니다. 로그인 후 이용해 주세요.',
AUTH_002: '안전을 위해 로그아웃 되었습니다. 다시 로그인해 주세요.',
AUTH_003: '해당 메뉴나 기능에 접근할 수 있는 권한이 없습니다.',
AUTH_004: '이메일 또는 비밀번호가 올바르지 않습니다.',
SYS_001: '문제가 생겨 하던 일을 마치지 못했습니다. 잠시 뒤 다시 시도해 주세요.',
REQ_001: '너무 여러 번 시도했거나 같은 일이 이미 진행 중입니다. 잠시 뒤 다시 시도해 주세요.',
AUTH_001: '로그인이 필요합니다. 로그인한 뒤 다시 시도해 주세요.',
AUTH_002: '안전을 위해 로그아웃했습니다. 다시 로그인해 주세요.',
AUTH_003: '이 기능을 쓸 수 있는 권한이 없습니다.',
AUTH_004: '메일 주소나 비밀번호가 맞지 않습니다. 다시 확인해 주세요.',
AUTH_005: '지금 비밀번호가 올바르지 않습니다.',
AUTH_006: '코드가 올바르지 않습니다. 휴대폰의 날짜와 시간이 자동으로 맞춰지도록 설정되어 있는지 확인해 주세요.',
AUTH_006: '코드가 맞지 않습니다. 휴대폰 설정에서 날짜와 시간을 자동으로 맞추게 해 두었는지 확인해 주세요.',
AUTH_007: '인증 앱의 코드를 입력해 주세요.',
AUTH_008: '2단계 인증 설정이 그사이 바뀌었거나 시간이 지났습니다. 처음부터 다시 해 주세요.',
AUTH_009: '5개까지 만들 수 있습니다. 쓰지 않는 앱 비밀번호를 지운 뒤 만들어 주세요.',
AUTH_010: '먼저 비밀번호를 바꾸거나 2단계 인증을 켜 주세요.',
VAL_001: '입력하신 내용을 다시 확인해 주세요. 빠진 칸이 있거나 형식이나 길이가 맞지 않습니다.',
RES_001: '요청하신 정보나 페이지를 찾을 수 없습니다.',
BIZ_001: '요청하신 작업을 완료하지 못했습니다. 다시 시도해 주세요.',
FILE_001: '허용되지 않는 파일 형식입니다. 파일을 확인해 주세요.',
FILE_002: '파일 크기가 허용 범위를 초과했습니다.',
FILE_003: '파일 상태가 그사이 바뀌어 처리하지 못했습니다. 다시 시도해 주세요.',
FILE_004: '파일을 올리는 중에 일부가 제대로 전달되지 않았습니다. 다시 시도해 주세요.',
RES_001: '찾을 수 없습니다. 이미 지워졌거나 옮겨졌을 수 있습니다.',
BIZ_001: '하려던 일을 마치지 못했습니다. 다시 시도해 주세요.',
FILE_001: '쓸 수 없는 형식의 파일입니다. 그 파일을 빼거나 다른 파일로 바꿔 주세요.',
FILE_002: '파일이 너무 커서 올릴 수 없습니다.',
FILE_003: '그사이 파일이 바뀌어 하던 일을 마치지 못했습니다. 다시 시도해 주세요.',
FILE_004: '파일 일부가 제대로 올라가지 않았습니다. 다시 시도해 주세요.',
FILE_005: '대용량 첨부 보관 한도를 넘어 더 올릴 수 없습니다. 보낸 파일의 링크를 끊거나 보내지 않은 파일을 지우면 공간이 생깁니다.',
FILE_006: '서버의 저장 공간이 모자라 지금은 파일을 올릴 수 없습니다. 잠시 뒤 다시 시도해 주세요.',
FILE_006: '회사 저장 공간이 모자라 지금은 파일을 올릴 수 없습니다. 잠시 뒤 다시 시도해 주세요.',
FILE_007: '이 파일은 더 내려받을 수 없습니다. 필요하면 보낸 사람에게 문의해 주세요.',
FILE_008: '보안 검사가 끝나지 않은 파일입니다. 잠시 뒤 다시 시도해 주세요.',
MAIL_001: '메일 서비스에 연결할 수 없습니다. 잠시 후 다시 시도해 주세요.',
MAIL_002: '받는 사람 주소를 다시 확인해 주세요.',
MAIL_003: '메일함 용량이 부족합니다. 불필요한 메일을 정리한 뒤 다시 시도해 주세요.',
MAIL_004: '첨부 파일을 찾을 수 없습니다. 해당 첨부를 빼거나 다시 올린 뒤 보내 주세요.',
MAIL_003: '메일함이 가득 찼습니다. 필요 없는 메일을 지우고 휴지통을 비운 뒤 다시 시도해 주세요.',
MAIL_004: '첨부 파일을 찾을 수 없습니다. 그 파일을 빼거나 다시 올린 뒤 보내 주세요.',
MAIL_005: '이미 보낸 메일과 내용이 달라 보내지 않았습니다. 보낸편지함을 확인한 뒤 다시 보내 주세요.',
MAIL_006: '첨부는 한 메일에 최대 20개까지 보낼 수 있습니다.',
MAIL_006: '첨부 파일은 메일 한 통에 20개까지 보낼 수 있습니다.',
MAIL_007: '본문이 너무 길어 보낼 수 없습니다. 내용을 줄인 뒤 다시 보내 주세요.',
MAIL_008: '임시 저장한 메일이 다른 곳에서 바뀌었거나 지워졌습니다. 지금 내용을 새 임시 저장본으로 남겨 주세요.',
MAIL_009: '같은 이름의 메일함이 이미 있습니다.',
@@ -39,7 +39,7 @@ export const ERROR_CODE_MESSAGES = {
MAIL_013: '필터나 차단 목록이 너무 길어 저장할 수 없습니다. 조건을 줄이거나, 쓰지 않는 필터나 차단한 주소를 지워 주세요.',
MAIL_014: '이미 보낸 메일이라 취소할 수 없습니다.',
MAIL_015: '보내기를 기다리는 메일은 옮기거나 지울 수 없습니다. 먼저 보내기를 취소해 주세요.',
MAIL_016: '대용량 첨부 파일을 보낼 수 없습니다. 해당 파일을 빼거나 다시 올린 뒤 보내 주세요.',
MAIL_016: '보낼 수 없는 대용량 첨부 파일이 있습니다. 그 파일을 빼거나 다시 올린 뒤 보내 주세요.',
CONTACT_001: '같은 이름의 그룹이 있습니다. 다른 이름을 써 주세요.',
CONTACT_002: '주소록 한도를 넘어 더 넣을 수 없습니다. 연락처 3,000개, 그룹 100개, 그룹 하나에 500명까지 넣을 수 있습니다.',
} as const
+5 -5
View File
@@ -176,7 +176,7 @@ export const MESSAGE_LARGE_FILE_TEXT = {
downloads: (count: string) => `${count}회 받음`,
notDownloaded: '아직 받지 않음',
scanning: '보안 검사가 끝나면 받을 수 있습니다',
infected: '위험한 내용이 있어 보안 검사에서 막았고, 받을 수 없습니다',
infected: '보안 검사에서 위험한 내용이 나와 받을 수 없습니다',
unscannable: '암호가 걸린 압축 파일처럼 검사하지 못한 파일',
expired: (date: string) => `받을 수 있는 기간이 ${date}에 끝났습니다`,
revoked: '보낸 사람이 링크를 끊어 받을 수 없습니다',
@@ -225,7 +225,7 @@ export const LARGE_FILE_ALERT_TEXT = {
export const LARGE_FILE_SETTINGS_TEXT = {
title: '대용량 첨부',
description: (days: string) =>
`메일에 링크로 보낸 큰 파일입니다. 받는 사람은 보낸 날부터 ${days}일 동안 내려받고, 링크를 끊으면 바로 받을 수 없습니다.`,
`메일에 링크로 보낸 큰 파일입니다. 받는 사람은 보낸 날부터 ${days}일 동안 내려받을 수 있고, 링크를 끊으면 그때부터 받을 수 없습니다.`,
usageLabel: '보관 사용량',
usageSplit: (sent: string, unsent: string) => `보낸 링크 ${sent}, 임시 저장했거나 올리다 멈춘 파일 ${unsent}입니다.`,
usageNote: '기간이 지나거나, 링크를 끊거나, 보내지 않은 파일을 지우면 바로 줄어듭니다.',
@@ -244,7 +244,7 @@ export const LARGE_FILE_SETTINGS_TEXT = {
deleteLabel: (name: string) => `${name} 지우기`,
deleteTitle: '보내지 않은 파일을 지울까요?',
deleteMessage: (name: string, size: string) =>
`‘${name}’(${size}) 파일을 지웁니다. 이 파일을 실은 임시 저장본을 열면 첨부 칸에 지운 파일로 보입니다. 지운 파일은 되돌릴 수 없습니다.`,
`‘${name}’(${size}) 파일을 지웁니다. 이 파일을 넣어 둔 임시 저장본이 있으면 그 메일에서 파일을 빼고 다시 올려야 합니다. 지운 파일은 되돌릴 수 없습니다.`,
deleteConfirm: '지우기',
deleteCancel: '취소',
deleted: (name: string) => `‘${name}’ 파일을 지웠습니다.`,
@@ -272,7 +272,7 @@ export const LARGE_FILE_SETTINGS_TEXT = {
notDownloaded: '아직 받지 않음',
lastDownloaded: (when: string) => `마지막 ${when}`,
more: '더 보기',
moreHint: '최근에 보낸 순서로 20개씩 보입니다. 끝난 링크는 90일 뒤 목록에서 빠집니다.',
moreHint: '최근에 보낸 것부터 20개씩 볼 수 있습니다. 끝난 링크는 90일 뒤 목록에서 빠집니다.',
emptyTitle: '링크로 보낸 파일이 없습니다',
/** max 는 늘 "20MB" 꼴(formatMegabytes — 받침이 없다)이라 조사를 '가'로 붙여 쓴다 */
emptyHint: (max: string, days: string) =>
@@ -326,7 +326,7 @@ export const SHARE_PAGE_TEXT = {
revokedMessage: (sender: string) => `필요하면 보낸 사람(${sender})에게 문의해 주세요.`,
revokedEnglish: 'The sender has stopped sharing these files.',
notFoundTitle: '찾을 수 없는 링크입니다',
notFoundMessage: '메일의 주소가 끝까지 복사되었는지 확인해 주세요.',
notFoundMessage: '메일에 있던 주소를 끝까지 다 복사했는지 확인해 주세요.',
notFoundEnglish: 'Link not found. Please check that the full address was copied.',
tooManyTitle: '요청이 너무 많습니다',
tooManyMessage: '잠시 뒤 다시 시도해 주세요.',
+1 -1
View File
@@ -108,5 +108,5 @@ export const MAIL_BLOCK_TEXT = {
* 머리 아래 노란 줄 — 차단한 주소가 있는데 엔진에서 우리 규칙이 꺼져 있을 때(isActive: false). 단추는 없다 — 차단하거나 풀면 다시 켜진다
* (보드에 없다 — 7단계 필터 · 부재중 자동 응답의 노란 줄과 같은 결)
*/
inactive: '다른 곳에서 메일 설정이 바뀌어 지금은 차단이 되지 않습니다. 주소를 차단하거나 풀면 다시 켜집니다.',
inactive: '다른 곳에서 메일 설정이 바뀌어 지금은 차단이 꺼져 있습니다. 주소를 차단하거나 풀면 차단이 다시 켜집니다.',
} as const
@@ -35,10 +35,10 @@ export const MAIL_CLIENTS_TEXT = {
'지금은 웹메일 비밀번호로도 연결되지만, 2단계 인증을 켜면 다시 연결할 때 막힙니다. 앱 비밀번호는 기기마다 따로 지울 수 있어 잃어버렸을 때도 안전합니다.',
createAppPassword: '앱 비밀번호 만들기',
stepsTitle: '프로그램별 연결 방법',
stepsHint: '쓰는 프로그램을 누르면 연결 방법이 펼쳐집니다.',
stepsHint: '쓰는 프로그램을 누르면 연결 방법을 볼 수 있습니다.',
/** 권하지 않음으로 정했다(2026-10-31) — 회사가 허용하면 이 줄을 지운다 */
cloudWarning:
'새 Outlook과 휴대폰 Outlook 앱은 메일을 Microsoft 클라우드를 거쳐 받습니다. 회사 정책에 따라 쓰지 않는 것이 좋습니다.',
'새 Outlook과 휴대폰 Outlook 앱은 메일을 Microsoft 클라우드를 거쳐 받습니다. 회사 정책에 따라 되도록 다른 메일 프로그램을 써 주세요.',
listLabel: '메일 프로그램',
notes: [
'연결이 안 되면 사용자 이름에 메일 주소 전체를 넣었는지, 비밀번호 칸에 앱 비밀번호 40자를 띄어쓰기 없이 넣었는지 확인해 주세요.',
@@ -114,7 +114,7 @@ export const MAIL_CLIENT_GUIDES: readonly MailClientGuide[] = [
{
id: 'android',
name: '안드로이드',
description: 'Gmail 앱 기준이며, 삼성 이메일 앱 같은 다른 메일 앱도 같은 값을 넣습니다',
description: 'Gmail 앱으로 설명하며, 삼성 이메일 앱 같은 다른 메일 앱도 같은 값을 넣으면 됩니다',
icon: 'smartphone',
steps: [
['Gmail 앱에서 프로필 사진 → [다른 계정 추가] → [기타]를 누릅니다.'],
+11 -11
View File
@@ -84,7 +84,7 @@ export const MAIL_FILTER_SUMMARY_TEXT = {
export const MAIL_FILTER_TEXT = {
title: '필터',
description:
'받은 메일을 조건에 따라 메일함으로 옮기거나, 읽음이나 중요로 표시하거나, 동료에게 전달합니다. 새로 오는 메일을 정리하고, 저장할 때 고르면 받은편지함에 이미 있는 메일도 함께 정리합니다.',
'새로 오는 메일을 조건에 따라 메일함으로 옮기거나, 읽음이나 중요로 표시하거나, 동료에게 전달합니다. 저장할 때 고르면 받은편지함에 이미 있는 메일도 함께 정리할 수 있습니다.',
create: '새 필터',
/**
* 다 만들었을 때 [새 필터] 옆 · 읽기 화면 ⋯ 「이런 메일로 필터 만들기」 아래 ·
@@ -105,17 +105,17 @@ export const MAIL_FILTER_TEXT = {
'보낸 사람, 받는 사람, 제목, 본문의 글로 메일을 골라 메일함으로 옮기거나, 읽음이나 중요로 표시하거나, 동료에게 전달합니다.',
// 목록 위 노란 줄 — 다른 곳에서 바뀜(409 MAIL_012 — 글은 에러 코드 사전) · 동작하지 않음(isActive: false)
reload: '다시 불러오기',
inactive: '다른 곳에서 메일 설정이 바뀌어 지금은 필터와 부재중 자동 응답이 되지 않습니다.',
inactive: '다른 곳에서 메일 설정이 바뀌어 지금은 필터와 부재중 자동 응답이 꺼져 있습니다.',
reactivate: '다시 켜기',
// 아래 안내 상자 — 첫 줄의 "위에서부터 차례로" · 둘째 줄의 칸 이름은 굵게 (7단계 — 디자인 원본 MailFilterApply 의 글)
noteOrderStrong: '위에 있는 필터부터 차례로',
noteOrderAfter: ' 메일을 정리합니다. 메일함으로 옮기는 필터에 맞은 메일은 그 아래 필터를 거치지 않습니다. 순서는 ▲ ▼ 로 바꿀 수 있습니다.',
noteOrderAfter: ' 메일을 정리합니다. 메일함으로 옮기는 필터에 걸린 메일은 그 아래 필터를 거치지 않습니다. 순서는 ▲ ▼로 바꿀 수 있습니다.',
noteApplyBefore: '이미 받은 메일도 정리하려면 필터를 저장할 때 ',
noteApplyStrong: '[받은편지함에 이미 있는 메일에도 적용]',
noteApplyAfter: '을 골라 주세요. 받은편지함에 있는 메일만 그 필터 하나로 정리합니다.',
noteSpam: '스팸으로 분류된 메일은 필터를 거치지 않고 그대로 스팸메일함으로 갑니다.',
noteApplyStrong: '[받은편지함에 이미 있는 메일도 정리하기]',
noteApplyAfter: '를 골라 주세요. 받은편지함에 있는 메일만 그 필터 하나로 정리합니다.',
noteSpam: '스팸 메일은 필터를 거치지 않고 바로 스팸메일함으로 갑니다.',
noteForward:
'전달은 새로 오는 메일만 사내 주소로 합니다. 메일 한 통은 한 곳으로만 전달되고, 전달하는 필터가 여럿이면 위에 있는 필터가 먼저입니다. 전달해도 내 메일함에 그대로 남습니다.',
'전달은 새로 오는 메일만 사내 주소로 보냅니다. 메일 한 통은 한 곳으로만 전달할 수 있어서, 전달하는 필터가 여럿이면 가장 위에 있는 필터의 주소로 갑니다. 전달한 메일도 내 메일함에 그대로 남습니다.',
noteVacation: '필터가 메일을 옮겨도 부재중 자동 응답은 그대로 보냅니다.',
// 알림 — 켜기 · 끄기 · 순서 바꾸기는 목록이 바로 바뀌므로 알리지 않는다 (실패하면 되돌리고 오류 알림)
saved: '필터를 저장했습니다.',
@@ -167,12 +167,12 @@ export const MAIL_FILTER_DIALOG_TEXT = {
} as const
/**
* 고치기 창의 「받은편지함에 이미 있는 메일에도 적용」 (7단계 — 디자인 원본 MailFilterApply · MailFilterApplyStates).
* 고치기 창의 「받은편지함에 이미 있는 메일도 정리하기」 (7단계 — 디자인 원본 MailFilterApply · MailFilterApplyStates).
* 6단계의 안내 띠("새로 오는 메일에만 적용됩니다 …") 자리에 둔다. 고를 수 없으면 설명이 그 까닭이다 —
* 이 탭에서 다른 필터를 적용하는 중 → 옮길 메일함이 지워짐 → 받은편지함으로 옮기기만 → 전달만 (mail-filter-run.ts 의 filterApplyBlock 차례)
*/
export const MAIL_FILTER_APPLY_TEXT = {
label: '받은편지함에 이미 있는 메일에도 적용',
label: '받은편지함에 이미 있는 메일도 정리하기',
help: '저장하면 받은편지함에서 조건에 맞는 메일을 찾아 바로 정리합니다.',
/** 전달도 하는 필터 — 설명 뒤에 더한다 */
helpForward: ' 전달은 새로 오는 메일에만 합니다.',
@@ -183,7 +183,7 @@ export const MAIL_FILTER_APPLY_TEXT = {
inboxOnly: '받은편지함으로 옮기기만 하는 필터는 이미 받은 메일에 할 일이 없습니다.',
busy: '다른 필터로 받은편지함을 정리하는 중입니다. 끝난 뒤에 고를 수 있습니다.',
/** 상자 아래 한 줄 */
spam: '스팸으로 분류된 메일은 필터를 거치지 않습니다.',
spam: '스팸 메일은 필터를 거치지 않습니다.',
} as const
/**
@@ -196,7 +196,7 @@ export const MAIL_FILTER_RUN_TEXT = {
progressCount: (scanned: string, total: string) => ` · ${scanned} / ${total}통 확인`,
done: (quoted: string, matched: string) => `${quoted} 필터로 받은편지함의 메일 ${matched}통을 정리했습니다.`,
/** 맞는 메일이 없었다 */
none: (quoted: string) => `받은편지함에 ${quoted} 필터에 맞는 메일이 없습니다.`,
none: (quoted: string) => `받은편지함에는 ${quoted} 필터에 걸리는 메일이 없습니다.`,
/** 옮기는 필터의 끝 알림 단추 — 그 메일함으로 */
viewMailbox: (mailbox: string) => `${mailbox} 보기`,
/** 도중에 멈췄다 — [다시 시도]는 남은 메일부터 이어서 */
+7 -7
View File
@@ -53,20 +53,20 @@ export const MAIL_NOTIFY_TEXT = {
menu: '알림',
title: '알림',
description:
'새 메일은 1분을 기다리지 않고 바로 목록에 나타납니다. 다른 창을 보는 동안에도 알고 싶으면 바탕화면 알림을 켜 주세요.',
'새 메일이 오면 바로 목록에서 볼 수 있습니다. 다른 창을 보는 동안에도 알고 싶으면 바탕화면 알림을 켜 주세요.',
enable: '새 메일이 오면 바탕화면 알림 보내기',
noteAsk: '켜면 브라우저가 알림을 허락할지 한 번 묻습니다. [허용]을 눌러 주세요.',
noteGranted: '이 브라우저에서 알림이 허용되어 있습니다.',
noteBlocked:
'브라우저가 이 사이트의 알림을 막고 있습니다. 주소창 왼쪽의 사이트 정보(자물쇠)를 눌러 [알림]을 [허용]으로 바꾼 뒤 이 화면을 새로 고쳐 주세요.',
noteUnsupported: '이 브라우저는 바탕화면 알림을 지원하지 않습니다. 새 메일은 그래도 바로 목록에 나타납니다.',
contentLegend: '알림에 보일 것',
noteUnsupported: '이 브라우저에서는 바탕화면 알림을 쓸 수 없습니다. 새 메일은 그래도 목록에서 바로 볼 수 있습니다.',
contentLegend: '알림 내용',
showSubject: '메일 제목도 보이기',
showSubjectHint: '끄면 보낸 사람 이름만 보입니다. 다른 사람과 화면을 함께 보는 자리라면 꺼 두는 것이 좋습니다.',
showSubjectHint: '끄면 알림에 보낸 사람 이름만 나옵니다. 다른 사람과 화면을 함께 보는 자리라면 꺼 두시기를 권합니다.',
test: '시험 알림 보내기',
testHint: '바탕화면 오른쪽 아래에 알림이 뜨면 됩니다. 맥에서는 오른쪽 위에 뜹니다.',
realtimeTitle: '실시간 연결',
realtimeLive: '연결되어 있어 새 메일이 오면 바로 목록에 나타납니다.',
realtimeLive: '연결되어 있어 새 메일이 오면 바로 알 수 있습니다.',
realtimeLiveHint:
'연결이 끊기면 1분마다 새 메일을 확인하면서 저절로 다시 연결합니다. 회사 방화벽이나 프록시가 오래 열린 연결을 끊으면 이렇게 될 수 있습니다.',
realtimeDown: '연결할 수 없어 1분마다 확인합니다.',
@@ -76,13 +76,13 @@ export const MAIL_NOTIFY_TEXT = {
/** 처음 잇는 동안 (캔버스에는 없다 — 2026-10-28 구현하며 정함) */
realtimeConnecting: '연결하는 중입니다…',
/** Web Locks · BroadcastChannel 이 없는 브라우저 — 1분 확인만 한다 (캔버스에는 없다 — 2026-10-28 구현하며 정함) */
realtimeUnsupportedHint: '이 브라우저는 실시간 연결을 지원하지 않습니다.',
realtimeUnsupportedHint: '이 브라우저에서는 실시간 연결을 쓸 수 없습니다.',
/** 아래 안내 상자 — 첫 줄의 "이 브라우저에만"은 굵게 */
noteBrowserBefore: '알림 설정은 ',
noteBrowserStrong: '이 브라우저에만',
noteBrowserAfter: ' 저장됩니다. 다른 PC나 브라우저에서는 그곳에서 따로 켜 주세요.',
noteOpenTab: '웹메일 탭이 하나라도 열려 있어야 알림이 옵니다. 탭을 모두 닫으면 오지 않습니다.',
noteVisible: '웹메일 화면을 보고 있을 때는 새 메일이 목록에 바로 보이므로 바탕화면 알림을 보내지 않습니다.',
noteVisible: '웹메일 화면을 보고 있을 때는 목록에서 새 메일을 바로 볼 수 있어 바탕화면 알림을 보내지 않습니다.',
// 시험 알림 — 지금 탭에서 바로 보낸다
testTitle: 'ARA 웹메일',
+4 -4
View File
@@ -86,7 +86,7 @@ export const SEARCH_CONDITIONS_TEXT = {
since: '시작 날짜',
until: '끝 날짜',
attachmentOnly: '첨부 파일이 있는 메일만',
hint: '검색 칸의 말과 이 조건에 모두 맞는 메일을 찾습니다. 받는 사람에는 참조로 받은 사람도 들어갑니다.',
hint: '검색 칸에 쓴 말이 들어 있고 이 조건에도 맞는 메일을 찾습니다. 받는 사람으로 찾을 때는 참조로 받은 사람까지 찾습니다.',
periodOrder: '끝 날짜를 시작 날짜와 같거나 그 뒤로 골라 주세요.',
periodRange: '날짜는 1970년 1월 1일부터 9999년 12월 30일 사이에서 골라 주세요.',
clear: '조건 지우기',
@@ -121,17 +121,17 @@ export const SEARCH_NOTICE_TEXT = {
* 낮 내내 같은 글이면 멈춘 줄 안다 (2026-11-06 실사용 시험)
*/
indexing:
'지난 메일을 검색할 수 있게 밤사이에 준비하고 있습니다. 메일이 많으면 며칠 걸릴 수 있고, 그동안은 낱말이 통째로 같은 메일만 찾습니다.',
'지난 메일까지 낱말의 일부로도 찾을 수 있게 밤사이에 준비하고 있습니다. 메일이 많으면 며칠 걸릴 수 있고, 그동안은 낱말을 끝까지 다 써야 찾을 수 있습니다.',
/** 한 글자 낱말뿐이라 본문은 찾지 않았을 때 (서버와 같은 낱말 나누기 — search-match.ts) */
singleChar:
'한 글자로만 찾을 때는 본문은 빼고 제목, 보낸 사람과 받는 사람, 첨부 파일 이름에서 찾습니다. 본문까지 찾으려면 두 글자 이상으로 찾아 주세요.',
/** 이어 찾을 것이 남았는데 아직 하나도 찾지 못했을 때 */
partialTitle: '아직 맞는 메일을 찾지 못했습니다',
partialTitle: '아직 찾은 메일이 없습니다',
partialHint: '메일이 많아 한 번에 다 찾지 못했습니다. [더 찾기]를 누르면 이어서 찾습니다.',
more: '더 찾기',
moreBusy: '찾는 중…',
/** 검색어와 조건을 합친 낱말이 한도를 넘는 주소로 들어왔을 때 — 찾지 않고 이 안내만 (조건이 있으면 조건 줄의 [조건 지우기]) */
tooManyWordsTitle: '찾을 말이 너무 많습니다',
tooManyWordsHint: (max: number) =>
`검색어와 조건에 넣은 낱말은 모두 합쳐 ${max}개까지 찾을 수 있습니다. 검색 칸이나 [조건]에서 낱말을 줄여 주세요.`,
`검색 칸과 [조건]에 넣은 낱말은 모두 합쳐 ${max}개까지입니다. 낱말을 몇 개 줄여 주세요.`,
} as const
@@ -34,12 +34,12 @@ export const MAIL_SETTINGS_TEXT = {
ask: { label: '늘 묻기', hint: '메일마다 [이미지 보기]를 눌러 봅니다' },
trusted: {
label: '주소록과 사내 직원의 메일은 늘 보기',
hint: '보낸 사람이 내 주소록이나 사내 목록에 있으면 바로 보입니다',
hint: '보낸 사람이 내 주소록이나 사내 목록에 있으면 묻지 않고 보여 드립니다',
},
always: { label: '늘 보기', hint: '스팸메일함의 메일은 이 설정과 상관없이 묻습니다' },
} satisfies Record<ExternalImagesSetting, { label: string; hint: string }>,
imagesNote:
'외부 이미지는 회사를 거쳐 불러오므로 보낸 사람은 이 PC의 주소를 알 수 없습니다. 다만 이미지를 불러오면 보낸 사람은 메일을 열어 본 것을 알 수 있습니다.',
'외부 이미지는 회사 쪽에서 대신 받아 오기 때문에 보낸 사람은 이 PC의 주소를 알 수 없습니다. 다만 이미지를 불러오면 보낸 사람이 내가 메일을 열어 본 것을 알 수 있습니다.',
/**
* 대화로 묶기 (14단계 — 디자인 README "목록 — 대화로 묶어 보기 (ListThreads14)" 의 켬/끔 한 줄. 보드가 없어 글은 정했다).
@@ -50,5 +50,5 @@ export const MAIL_SETTINGS_TEXT = {
groupOff: '끔',
groupOn: '켬',
groupHint:
'답장과 전달로 이어진 메일을 메일함 목록에서 한 줄로 묶어 보여 줍니다. 메일함 목록 위의 말풍선 모양 [대화로 묶기] 단추로도 켜고 끌 수 있습니다.',
'켜면 답장과 전달로 이어진 메일을 메일함 목록에서 한 줄로 묶습니다. 메일함 목록 위의 말풍선 모양 [대화로 묶기] 단추로도 켜고 끌 수 있습니다.',
} as const
@@ -56,11 +56,11 @@ export const SIGNATURE_LEAVE_DIALOG = {
export const SIGNATURE_TEXT = {
title: '서명',
description:
'보내는 메일 끝에 넣는 글입니다. 쓰기 화면을 열 때 본문에 넣어 두므로, 그 메일에서만 고치거나 지울 수도 있습니다.',
'보내는 메일 끝에 넣는 글입니다. 메일을 쓰기 시작할 때 본문에 미리 넣어 두니, 그 메일에서만 고치거나 지울 수도 있습니다.',
fieldLabel: '서명 내용',
hint:
'굵게, 기울임, 밑줄, 글자 색, 링크, 그림을 쓸 수 있습니다. 그림은 PNG, JPG, GIF 파일을 한 장 300KB 이하로 3장까지 넣을 수 있고 ' +
'붙여 넣거나 끌어다 놓아도 됩니다. 그림은 메일에 함께 실어 보내므로 받는 쪽에서 외부 이미지처럼 막히지 않습니다.',
'붙여 넣거나 끌어다 놓아도 됩니다. 그림은 메일에 함께 실어 보내서 받는 사람 쪽에서 외부 이미지처럼 막히지 않습니다.',
usageLegend: '서명을 넣을 메일',
forNew: '새 메일',
forReply: '답장과 전달',
@@ -17,14 +17,14 @@ export const VACATION_NOTICE_REFRESH_MS = 60_000
export const VACATION_TEXT = {
title: '부재중 자동 응답',
description: '자리를 비운 동안 받은 메일에 정해 둔 글로 자동으로 답합니다. 켜 둔 동안에는 메일 화면 왼쪽의 메일함 목록 아래에 안내가 보입니다.',
description: '자리를 비운 동안 받은 메일에 정해 둔 글로 자동으로 답합니다. 켜 둔 동안에는 메일 화면 왼쪽, 메일함 목록 아래에서 켜져 있는지 확인할 수 있습니다.',
enable: '부재중 자동 응답 사용',
periodLegend: '기간',
untilOff: '끌 때까지 계속',
range: '기간 정하기',
from: '시작',
to: '끝',
rangeHint: '시작일 0시부터 끝나는 날 자정까지 답합니다. 시각은 이 브라우저의 시간대를 따릅니다.',
rangeHint: '시작하는 날 0시부터 끝나는 날 밤 12시까지 답합니다. 시각은 이 PC의 시간으로 셉니다.',
subject: '제목',
body: '내용',
bodyHint: '서식과 그림 없이 글자만 보내고, 서명은 붙지 않습니다.',
+26 -14
View File
@@ -3,6 +3,7 @@
import type { ErrorCode } from '@/shared/constants/error-codes.generated'
import type { IconName } from '@/shared/constants/icons'
import type { MailboxRole, RejectedRecipientReason } from '@/shared/types/mail'
import { subjectParticle } from '@/shared/utils/korean'
/**
* 메일함 정렬 순서 — 계약 2절 "메일함 정렬(확정 디자인 기준)".
@@ -33,14 +34,15 @@ export const MAILBOX_ICONS: Readonly<Record<MailboxKind, IconName>> = {
/** 빈 메일함 안내 (디자인 원본 States · Main) */
export const MAILBOX_EMPTY_HINTS: Readonly<Record<MailboxKind, string>> = {
inbox: '새 메일이 오면 여기에 표시됩니다.',
drafts: '임시 저장한 메일이 여기에 표시됩니다.',
sent: '보낸 메일이 여기에 표시됩니다.',
// 휴지통 · 스팸메일함은 메일 서버가 30일 뒤 지운다 — 메일이 없을 때는 목록 위 줄 대신 이 글에 붙인다 (13단계 — 디자인 README "휴지통 · 스팸메일함 — 30일 줄")
junk: '스팸으로 분류된 메일이 여기에 표시됩니다. 들어간 지 30일이 지난 메일은 자동으로 지워집니다.',
trash: '삭제한 메일이 여기에 표시됩니다. 들어간 지 30일이 지난 메일은 자동으로 지워집니다.',
archive: '보관한 메일이 여기에 표시됩니다.',
custom: '이 메일함으로 옮긴 메일이 여기에 표시됩니다.',
inbox: '새 메일이 오면 여기로 들어옵니다.',
drafts: '쓰다가 임시 저장한 메일은 여기에 남습니다.',
sent: '보낸 메일은 여기에 남습니다.',
// 휴지통 · 스팸메일함은 메일 서버가 30일 뒤 지운다 — 메일이 없을 때는 목록 위 줄 대신 이 글이 30일을 알린다 (13단계 — 디자인 README "휴지통 · 스팸메일함 — 30일 줄")
junk: '스팸 메일은 여기에 30일 동안 남아 있다가 저절로 지워집니다.',
trash: '지운 메일은 여기에 30일 동안 남아 있다가 저절로 지워집니다.',
archive: '오래 두고 볼 메일을 여기로 옮겨 보관할 수 있습니다.',
// 빈 메일함에서 보는 글이라 어디서 고르는지를 밝힌다 — [이동]은 고른 메일 도구 줄의 단추(MAIL_BULK_TEXT.move)
custom: '다른 메일함에서 메일을 고른 뒤 [이동]을 누르면 이 메일함으로 옮길 수 있습니다.',
}
/**
@@ -291,7 +293,8 @@ export const MAIL_SEARCH_TEXT = {
scopeLabel: '찾을 곳',
scopeAll: '전체 메일함',
scopeAllHint: '휴지통과 스팸메일함은 빼고 찾습니다',
emptyTitle: (query: string) => (query ? `‘${query}’에 맞는 메일이 없습니다` : '조건에 맞는 메일이 없습니다'),
emptyTitle: (query: string) =>
query ? `‘${query}’${subjectParticle(query)} 들어간 메일이 없습니다` : '조건에 맞는 메일이 없습니다',
/**
* 결과 없음의 도움말 (14단계 디자인 원본 Search14) — 검색어와 조건으로 찾았으면 emptyHint, 조건만으로 찾았으면 emptyConditionsHint,
* 검색어만으로 찾았으면(걸린 조건이 없다) emptyWordsHint — 없는 조건을 줄이라고 하지 않는다 (2026-11-06 실사용 시험)
@@ -392,19 +395,28 @@ export const MAIL_IMAGES_SHOW = 'show'
export const MAIL_IMAGE_PROXY_PATH = '/api/mail-images/'
/** 읽기 화면의 노란 안내 줄 + [이미지 보기](36 — 밀도 예외 --size-notice-action) */
export const MAIL_IMAGES_TEXT = {
blocked: '외부 이미지를 차단했습니다. 개인정보 보호를 위해 보낸 사람 서버의 이미지는 불러오지 않습니다.',
blocked: '외부 이미지는 막아 두었습니다. 이미지를 불러오면 보낸 사람이 내가 메일을 열어 본 것을 알 수 있습니다.',
show: '이미지 보기',
} as const
// ── 읽기 화면의 본문 안내 (디자인 README "읽기 화면의 본문 안내" · 계약 2절 "읽기 한도") ─────────
/**
* 서버가 본문을 다 주지 못했을 때 본문 위에 두는 안내 — "외부 이미지를 차단했습니다"와 같은 모양.
* 서버가 본문을 다 주지 못했을 때 본문 위에 두는 안내의 앞 문장 — "외부 이미지는 막아 두었습니다"와 같은 모양.
* - htmlOmitted · unavailable: 서식 본문을 정화 한도 때문에 비웠다(isHtmlBodyOmitted) — 글자 본문이 있을 때 · 없을 때
* - truncated: 보여 주는 본문이 읽기 한도(2MB)에서 잘렸다(isBodyTruncated)
* 읽기 화면은 뒤에 할 일(MAIL_BODY_NOTICE_ACTIONS)을 한 칸 띄워 잇고, 같은 대화 칸의 펼친 줄은 이 앞 문장만 보인다 (mail-body-notices.ts)
*/
export const MAIL_BODY_NOTICES = {
htmlOmitted: '본문이 복잡해 서식 없이 글자만 표시합니다.',
unavailable: '본문이 너무 크거나 복잡해 표시할 수 없습니다.',
truncated: '본문이 길어 앞부분만 표시합니다.',
htmlOmitted: '본문이 복잡해 서식 없이 글자만 보여 드립니다.',
unavailable: '본문이 너무 크거나 복잡해 여기서는 열 수 없습니다.',
truncated: '본문이 길어 앞부분만 보여 드립니다.',
} as const
export type MailBodyNoticeKind = keyof typeof MAIL_BODY_NOTICES
/**
* 본문 안내 뒤의 할 일 — 읽기 화면에서만. 읽기 화면 ⋯ 메뉴(MAIL_READER_MORE_TEXT)의 원문(.eml)에는 본문 전체가 있다.
* 같은 대화 칸의 펼친 줄에는 붙이지 않는다 — 그 화면의 ⋯ 는 위에 연 메일의 것이라, 원문은 그 줄의 [이 메일만 열기]로 연 읽기 화면에서 받는다
*/
export const MAIL_BODY_NOTICE_ACTIONS = {
unavailable: '[⋯] → [원문 내려받기 (.eml)]로 받아 볼 수 있습니다.',
truncated: '전체는 [⋯] → [원문 내려받기 (.eml)]로 받아 볼 수 있습니다.',
} as const satisfies Partial<Record<MailBodyNoticeKind, string>>
@@ -81,5 +81,5 @@ export const MAILBOX_OPERATION_TEXT = {
deleteDoneMoved: (mailbox: string, count: string) =>
`‘${mailbox}’ 메일함을 지웠습니다. 안의 메일 ${count}통은 휴지통에 있습니다.`,
/** 도중에 실패 — 거기까지 처리한 수. 다시 하면 남은 것만 처리한다 */
failed: (done: string, total: string) => `${total}통 가운데 ${done}통까지 처리하고 멈췄습니다. 다시 시도해 주세요.`,
failed: (done: string, total: string) => `${total}통 가운데 ${done}통까지 마치고 멈췄습니다. 다시 시도해 주세요.`,
} as const
@@ -43,7 +43,7 @@ export const LOGIN_PASSWORD_CHANGE_TEXT = {
/** 로그인 — 2단계 인증 켜기(필수) (디자인 원본 LoginTwoFactorSetup). QR · 키 · [복사]의 글은 12단계 TWO_FACTOR_ENABLE_TEXT */
export const LOGIN_TWO_FACTOR_SETUP_TEXT = {
title: '2단계 인증을 켜 주세요',
subtitle: '회사 정책에 따라 로그인할 때 휴대폰 인증 앱의 코드도 확인합니다. 지금 켜면 바로 메일로 이어집니다.',
subtitle: '회사 정책에 따라 로그인할 때 휴대폰 인증 앱의 코드도 확인합니다. 지금 켜면 바로 메일을 쓸 수 있습니다.',
scanTitle: '인증 앱으로 QR 코드를 찍어 주세요',
scanHint:
'인증 앱에서 [+] → [QR 코드 스캔]을 고릅니다. 앱이 없으면 Google Authenticator나 Microsoft Authenticator를 먼저 설치해 주세요.',
@@ -67,7 +67,7 @@ export const LOGIN_TWO_FACTOR_SETUP_TEXT = {
export const SUPPORT_CONTACT_TEXT = {
/** 로그인 첫 화면 */
passwordQuestion: '비밀번호를 잊으셨나요?',
passwordFallback: '비밀번호를 잊으셨다면 시스템 관리자에게 문의해 주세요.',
passwordFallback: '비밀번호를 잊으셨다면 관리자에게 문의해 주세요.',
/** 로그인 코드 단계 · 2단계 인증 켜기 */
authenticatorQuestion: '인증 앱을 쓸 수 없나요?',
authenticatorFallback: '인증 앱을 쓸 수 없나요? 관리자에게 문의해 주세요.',
+82 -2
View File
@@ -2,6 +2,8 @@ import { describe, expect, it } from 'vitest'
import { directionParticle, objectParticle, subjectParticle } from './korean'
// 규칙은 korean.ts 머리말 — 짝: 앱 `flutter/lib/core/utils/korean_text.dart` (한쪽을 고치면 둘 다)
describe('subjectParticle (주격 조사 이/가)', () => {
it.each([
['받은편지함', '이'],
@@ -27,9 +29,10 @@ describe('directionParticle (방향 조사 으로/로)', () => {
// ㄹ 받침 → 로
['서울', '로'],
['메일', '로'],
// 한글로 끝나지 않으면 발음을 알 수 없다
// 숫자는 읽는 소리로 — 2026 은 "육"
['2026', '으로'],
// 라틴 글자로 끝나면 소리를 알 수 없다
['Work', '(으)로'],
['2026', '(으)로'],
['', '(으)로'],
])('%s → %s', (word, particle) => {
expect(directionParticle(word)).toBe(particle)
@@ -49,3 +52,80 @@ describe('objectParticle (목적격 조사 을/를 — "휴지통을 비웠습
expect(objectParticle(word)).toBe(particle)
})
})
describe('한글 호환 자모 (U+3131~U+3163 — 초성으로 찾은 말)', () => {
// 자음은 이름(기역 · 리을 · 미음 …)이 모두 받침으로 끝난다 — ㄹ(리을)만 ㄹ 받침이라 "로". 쌍자음 · 겹자음도 받침 있음(ㄹ 받침이 아니다)
it.each([
['ㄱ', '기역', '이', '을', '으로'],
['ㄹ', '리을', '이', '을', '로'],
['ㅁ', '미음', '이', '을', '으로'],
['ㅎ', '히읗', '이', '을', '으로'],
['ㄲ', '쌍기역', '이', '을', '으로'],
['ㄳ', '기역시옷', '이', '을', '으로'],
['ㄺ', '리을기역', '이', '을', '으로'],
['ㅏ', '아', '가', '를', '로'],
['ㅣ', '이', '가', '를', '로'],
])('%s(%s) → %s · %s · %s', (jamo, reading, subject, object, direction) => {
expect([subjectParticle(jamo), objectParticle(jamo), directionParticle(jamo)]).toEqual([subject, object, direction])
// 읽는 소리에 붙인 조사와 같다
expect([subjectParticle(reading), objectParticle(reading), directionParticle(reading)]).toEqual([
subject,
object,
direction,
])
})
it('초성 검색어는 마지막 자모로 고른다 — 범위 밖의 옛 자모는 소리를 알 수 없는 꼴', () => {
expect(directionParticle('ㄱㅁ')).toBe('으로')
expect(directionParticle('박ㅈ')).toBe('으로')
expect(subjectParticle('김ㅇㄹ')).toBe('이')
expect(directionParticle('김ㅇㄹ')).toBe('로')
// ㆍ(아래아 U+318D) — 호환 자모지만 U+3163 밖
expect(subjectParticle('ㆍ')).toBe('이(가)')
})
})
describe('숫자 0~9 — 읽는 소리(영 · 일 · 이 · 삼 · 사 · 오 · 육 · 칠 · 팔 · 구)의 받침으로', () => {
it.each([
['0', '영', '이', '을', '으로'],
['1', '일', '이', '을', '로'],
['2', '이', '가', '를', '로'],
['3', '삼', '이', '을', '으로'],
['4', '사', '가', '를', '로'],
['5', '오', '가', '를', '로'],
['6', '육', '이', '을', '으로'],
['7', '칠', '이', '을', '로'],
['8', '팔', '이', '을', '로'],
['9', '구', '가', '를', '로'],
])('%s(%s) → %s · %s · %s', (digit, reading, subject, object, direction) => {
expect([subjectParticle(digit), objectParticle(digit), directionParticle(digit)]).toEqual([subject, object, direction])
// 읽는 소리에 붙인 조사와 같다
expect([subjectParticle(reading), objectParticle(reading), directionParticle(reading)]).toEqual([
subject,
object,
direction,
])
})
it('여러 자리 수 · 글자 뒤의 숫자도 마지막 숫자로 본다', () => {
expect(subjectParticle('2026')).toBe('이')
expect(subjectParticle('프로젝트2')).toBe('가')
expect(objectParticle('3분기 보고서 v10')).toBe('을')
expect(directionParticle('회의실 7')).toBe('로')
})
})
describe('끝의 공백 · 닫는 따옴표 · 괄호 (지금 규칙 그대로)', () => {
it('앞뒤 공백은 지우고 본다 — 자모 · 숫자도 같다', () => {
expect(directionParticle('스팸메일함 ')).toBe('으로')
expect(directionParticle('ㄹ ')).toBe('로')
expect(subjectParticle(' 7 ')).toBe('이')
})
it('닫는 따옴표 · 괄호는 건너뛰지 않는다 — 기호로 끝나면 소리를 알 수 없는 꼴 (부르는 쪽이 따옴표 안의 말을 넘긴다)', () => {
expect(subjectParticle('보고서’')).toBe('이(가)')
expect(subjectParticle('"서버 이전"')).toBe('이(가)')
expect(objectParticle('휴지통)')).toBe('을(를)')
expect(directionParticle('회의(2)')).toBe('(으)로')
})
})
+64 -21
View File
@@ -1,4 +1,17 @@
// 한국어 문구 보조 — 순수 함수만 둔다
//
// 조사는 앞말의 마지막 글자를 읽는 소리의 받침으로 고른다. 앞뒤 공백은 지우고 보며, 닫는 따옴표 · 괄호 같은 기호는 건너뛰지 않는다
// (기호로 끝나면 소리를 알 수 없는 것으로 본다 — 부르는 쪽이 따옴표 안의 말을 넘긴다).
// - 한글 음절: 그 글자의 받침
// - 한글 호환 자모(U+3131~U+3163 — 초성으로 찾을 때 흔하다): 자음 ㄱ~ㅎ은 이름(기역 · 니은 · 디귿 · 리을 · 미음 …)이 모두
// 받침으로 끝나 받침 있음이고, ㄹ(리을)만 ㄹ 받침이다. 쌍자음 · 겹자음(ㄲ · ㄳ · ㄺ …)도 받침 있음(ㄹ 받침이 아니다). 모음 ㅏ~ㅣ는 받침 없음
// - 숫자 0~9: 읽는 소리(영 · 일 · 이 · 삼 · 사 · 오 · 육 · 칠 · 팔 · 구)의 받침 — 받침 있음 0 · 1 · 3 · 6 · 7 · 8(그 가운데 ㄹ 받침 1 · 7 · 8),
// 받침 없음 2 · 4 · 5 · 9. 여러 자리 수도 마지막 숫자로 본다("2026" → 육)
// - 그 밖(라틴 글자 · 기호 · 빈 글자)은 소리를 알 수 없어 두 꼴을 함께 쓴다 — "이(가)" · "을(를)" · "(으)로"
// 짝: flutter `lib/core/utils/korean_text.dart` — 규칙을 바꾸면 둘 다
/** 마지막 소리의 받침 — 없음 · ㄹ 받침 · 그 밖의 받침 */
type FinalSound = 'none' | 'rieul' | 'other'
const HANGUL_SYLLABLE_START = 0xac00
const HANGUL_SYLLABLE_END = 0xd7a3
@@ -7,43 +20,73 @@ const JONGSEONG_COUNT = 28
/** 받침 번호 — 0 은 받침 없음, 8 은 ㄹ */
const NO_JONGSEONG = 0
const JONGSEONG_RIEUL = 8
/** 한글 호환 자모 — 자음 ㄱ(U+3131)~ㅎ(U+314E), 그 가운데 ㄹ(U+3139), 모음 ㅏ(U+314F)~ㅣ(U+3163) */
const JAMO_CONSONANT_START = 0x3131
const JAMO_CONSONANT_END = 0x314e
const JAMO_RIEUL = 0x3139
const JAMO_VOWEL_START = 0x314f
const JAMO_VOWEL_END = 0x3163
/** 숫자 0~9 */
const DIGIT_ZERO = 0x30
const DIGIT_NINE = 0x39
/** 숫자 0~9 를 읽는 소리의 받침 — 영 · 일 · 이 · 삼 · 사 · 오 · 육 · 칠 · 팔 · 구 */
const DIGIT_FINAL_SOUNDS: readonly FinalSound[] = [
'other', // 0 영
'rieul', // 1 일
'none', // 2 이
'other', // 3 삼
'none', // 4 사
'none', // 5 오
'other', // 6 육
'rieul', // 7 칠
'rieul', // 8 팔
'none', // 9 구
]
/** 마지막 글자의 받침 번호 — 한글 음절로 끝나지 않으면(영문 · 숫자 · 빈 글자) null */
function lastJongseong(word: string): number | null {
/** 마지막 글자를 읽는 소리의 받침 — 소리를 알 수 없으면(라틴 글자 · 기호 · 빈 글자) null */
function finalSound(word: string): FinalSound | null {
const last = Array.from(word.trim()).pop()
if (!last) return null
const code = last?.codePointAt(0)
if (code === undefined) return null
const code = last.charCodeAt(0)
if (code < HANGUL_SYLLABLE_START || code > HANGUL_SYLLABLE_END) return null
return (code - HANGUL_SYLLABLE_START) % JONGSEONG_COUNT
if (code >= HANGUL_SYLLABLE_START && code <= HANGUL_SYLLABLE_END) {
const jongseong = (code - HANGUL_SYLLABLE_START) % JONGSEONG_COUNT
if (jongseong === NO_JONGSEONG) return 'none'
return jongseong === JONGSEONG_RIEUL ? 'rieul' : 'other'
}
if (code >= JAMO_CONSONANT_START && code <= JAMO_CONSONANT_END) return code === JAMO_RIEUL ? 'rieul' : 'other'
if (code >= JAMO_VOWEL_START && code <= JAMO_VOWEL_END) return 'none'
if (code >= DIGIT_ZERO && code <= DIGIT_NINE) return DIGIT_FINAL_SOUNDS[code - DIGIT_ZERO] ?? null
return null
}
/**
* 주격 조사 "이/가" — 마지막 글자에 받침이 있으면 "이", 없으면 "가".
* 한글로 끝나지 않는 이름(영문·숫자)은 발음을 알 수 없어 "이(가)" 를 쓴다.
* 주격 조사 "이/가" — 마지막 소리에 받침이 있으면 "이", 없으면 "가" (휴지통이 · 프로젝트가 · ㄱ이 · 2가).
* 소리를 알 수 없는 이름(라틴 글자 · 기호로 끝남)은 "이(가)" 를 쓴다.
*/
export function subjectParticle(word: string): '이' | '가' | '이(가)' {
const jongseong = lastJongseong(word)
if (jongseong === null) return '이(가)'
return jongseong === NO_JONGSEONG ? '가' : '이'
const sound = finalSound(word)
if (sound === null) return '이(가)'
return sound === 'none' ? '가' : '이'
}
/**
* 목적격 조사 "을/를" — 받침이 있으면 "을", 없으면 "를" (휴지통을 · 스팸메일함을 · 보관함을 · 메일 서버를).
* 한글로 끝나지 않는 이름(영문·숫자)은 발음을 알 수 없어 "을(를)" 을 쓴다.
* 목적격 조사 "을/를" — 받침이 있으면 "을", 없으면 "를" (휴지통을 · 스팸메일함을 · 보관함을 · 메일 서버를 · 3을 · ㅏ를).
* 소리를 알 수 없는 이름(라틴 글자 · 기호로 끝남)은 "을(를)" 을 쓴다.
*/
export function objectParticle(word: string): '을' | '를' | '을(를)' {
const jongseong = lastJongseong(word)
if (jongseong === null) return '을(를)'
return jongseong === NO_JONGSEONG ? '를' : '을'
const sound = finalSound(word)
if (sound === null) return '을(를)'
return sound === 'none' ? '를' : '을'
}
/**
* 방향 조사 "으로/로" — 받침이 있으면 "으로", 받침이 없거나 ㄹ 받침이면 "로" (보낸편지함으로 · 휴지통으로 · 보관함으로 · 서울로).
* 한글로 끝나지 않는 이름(영문·숫자)은 발음을 알 수 없어 "(으)로" 를 쓴다.
* 방향 조사 "으로/로" — 받침이 있으면 "으로", 받침이 없거나 ㄹ 받침이면 "로"
* (보낸편지함으로 · 휴지통으로 · 보관함으로 · 서울로 · ㄱ으로 · ㄹ로 · 6으로 · 7로).
* 소리를 알 수 없는 이름(라틴 글자 · 기호로 끝남)은 "(으)로" 를 쓴다.
*/
export function directionParticle(word: string): '으로' | '로' | '(으)로' {
const jongseong = lastJongseong(word)
if (jongseong === null) return '(으)로'
return jongseong === NO_JONGSEONG || jongseong === JONGSEONG_RIEUL ? '로' : '으로'
const sound = finalSound(word)
if (sound === null) return '(으)로'
return sound === 'other' ? '으로' : '로'
}
@@ -1,5 +1,6 @@
import { describe, expect, it } from 'vitest'
import { MESSAGE_LARGE_FILE_TEXT } from '@/shared/constants/large-file'
import type { MessageLargeFile } from '@/shared/types/large-file'
import { isDownloadable, isEndedLinkStatus, messageLargeFileView, sharedFileStatusView } from './large-file-status'
@@ -41,7 +42,7 @@ describe('읽기 화면의 대용량 첨부 줄 (디자인 원본 MailReadLargeF
it('위험은 빨간 줄 · 끝난 링크와 받을 수 없는 파일은 흐리게 — 받기 단추 없음', () => {
expect(messageLargeFileView({ ...FILE, status: 'infected' })).toEqual({
meta: '위험한 내용이 있어 보안 검사에서 막았고, 받을 수 없습니다',
meta: MESSAGE_LARGE_FILE_TEXT.infected,
tone: 'danger',
download: 'none',
})
@@ -1,14 +1,21 @@
import { describe, expect, it } from 'vitest'
import { MAIL_BODY_NOTICES } from '@/shared/constants/mail'
import { mailBodyNotices } from './mail-body-notices'
import { MAIL_BODY_NOTICE_ACTIONS, MAIL_BODY_NOTICES, MAIL_READER_MORE_TEXT } from '@/shared/constants/mail'
import { mailBodyNotices, type MailBodyNoticeOptions } from './mail-body-notices'
const texts = (source: Parameters<typeof mailBodyNotices>[0]) => mailBodyNotices(source).map((item) => item.text)
/** 읽기 화면 — 앞 문장 뒤에 할 일까지 */
const READER: MailBodyNoticeOptions = { withAction: true }
/** 같은 대화 칸의 펼친 줄 — 앞 문장만 */
const CONVERSATION: MailBodyNoticeOptions = { withAction: false }
const texts = (source: Parameters<typeof mailBodyNotices>[0], options: MailBodyNoticeOptions = CONVERSATION) =>
mailBodyNotices(source, options).map((item) => item.text)
describe('mailBodyNotices — 읽기 화면의 본문 안내 (계약 2절 "읽기 한도")', () => {
it('서버가 다 준 본문이면 안내가 없다 — 표시 값이 없는(이전 서버) 메일도 같다', () => {
expect(texts({ textBody: '본문', isHtmlBodyOmitted: false, isBodyTruncated: false })).toEqual([])
expect(texts({ textBody: '본문' })).toEqual([])
expect(texts({ textBody: '본문' }, READER)).toEqual([])
})
it('서식 본문을 비웠는데 글자 본문이 있으면 "서식 없이 글자만", 없으면(공백뿐 포함) "표시할 수 없음"', () => {
@@ -18,7 +25,7 @@ describe('mailBodyNotices — 읽기 화면의 본문 안내 (계약 2절 "읽
})
it('보여 주는 본문이 잘렸으면 "앞부분만 표시"', () => {
expect(mailBodyNotices({ textBody: null, isBodyTruncated: true })).toEqual([
expect(mailBodyNotices({ textBody: null, isBodyTruncated: true }, CONVERSATION)).toEqual([
{ kind: 'truncated', text: MAIL_BODY_NOTICES.truncated },
])
})
@@ -32,4 +39,21 @@ describe('mailBodyNotices — 읽기 화면의 본문 안내 (계약 2절 "읽
MAIL_BODY_NOTICES.unavailable,
])
})
it('읽기 화면은 앞 문장 뒤에 할 일(원문 내려받기)을 한 칸 띄워 잇는다 — 할 일이 없는 "서식 없이 글자만"은 앞 문장 그대로', () => {
expect(texts({ textBody: null, isHtmlBodyOmitted: true }, READER)).toEqual([
`${MAIL_BODY_NOTICES.unavailable} ${MAIL_BODY_NOTICE_ACTIONS.unavailable}`,
])
expect(texts({ textBody: '긴 글자 본문', isHtmlBodyOmitted: true, isBodyTruncated: true }, READER)).toEqual([
MAIL_BODY_NOTICES.htmlOmitted,
`${MAIL_BODY_NOTICES.truncated} ${MAIL_BODY_NOTICE_ACTIONS.truncated}`,
])
// 할 일은 읽기 화면 ⋯ 메뉴의 원문 내려받기 — 펼친 줄(앞 문장만)에는 없다
expect(MAIL_BODY_NOTICE_ACTIONS.unavailable).toContain(MAIL_READER_MORE_TEXT.downloadSource)
expect(MAIL_BODY_NOTICE_ACTIONS.truncated).toContain(MAIL_READER_MORE_TEXT.downloadSource)
for (const text of texts({ textBody: null, isHtmlBodyOmitted: true, isBodyTruncated: true })) {
expect(text).not.toContain(MAIL_READER_MORE_TEXT.downloadSource)
}
expect(texts({ textBody: null, isBodyTruncated: true })).toEqual([MAIL_BODY_NOTICES.truncated])
})
})
+19 -6
View File
@@ -1,7 +1,7 @@
// 읽기 화면의 본문 안내 — 서버가 본문을 다 주지 못했을 때 (계약 2절 MessageDetail · "읽기 한도") — 순수 함수만 둔다
// 순서는 생략(서식 없이 · 표시할 수 없음) → 잘림 (디자인 README "읽기 화면의 본문 안내").
import { MAIL_BODY_NOTICES, type MailBodyNoticeKind } from '@/shared/constants/mail'
import { MAIL_BODY_NOTICE_ACTIONS, MAIL_BODY_NOTICES, type MailBodyNoticeKind } from '@/shared/constants/mail'
import type { MessageDetail } from '@/shared/types/mail'
export interface MailBodyNotice {
@@ -9,22 +9,35 @@ export interface MailBodyNotice {
text: string
}
export interface MailBodyNoticeOptions {
/**
* 앞 문장 뒤에 할 일(원문 내려받기 — MAIL_BODY_NOTICE_ACTIONS)까지 잇는다 — 읽기 화면만 true.
* 같은 대화 칸의 펼친 줄은 false(앞 문장만): 그 화면의 ⋯ 는 위에 연 메일의 것이라,
* 원문은 그 줄의 [이 메일만 열기]로 연 읽기 화면에서 받는다
*/
withAction: boolean
}
type NoticeSource = Pick<MessageDetail, 'textBody' | 'isHtmlBodyOmitted' | 'isBodyTruncated'>
function notice(kind: MailBodyNoticeKind): MailBodyNotice {
return { kind, text: MAIL_BODY_NOTICES[kind] }
/** 안내마다의 할 일 — 할 일이 없는 안내(서식 없이 글자만)는 앞 문장 그대로다 */
const ACTIONS: Readonly<Partial<Record<MailBodyNoticeKind, string>>> = MAIL_BODY_NOTICE_ACTIONS
function notice(kind: MailBodyNoticeKind, withAction: boolean): MailBodyNotice {
const action = withAction ? ACTIONS[kind] : undefined
return { kind, text: action ? `${MAIL_BODY_NOTICES[kind]} ${action}` : MAIL_BODY_NOTICES[kind] }
}
/**
* 본문 위에 둘 안내 — 없으면 빈 목록. 표시 값이 없는(이전 서버) 메일은 안내하지 않는다.
* 글자 본문은 공백뿐이면 없는 것으로 본다 — 화면(MailBodyFrame)에 보이는 것이 없다.
*/
export function mailBodyNotices(message: NoticeSource): MailBodyNotice[] {
export function mailBodyNotices(message: NoticeSource, options: MailBodyNoticeOptions): MailBodyNotice[] {
const notices: MailBodyNotice[] = []
const omitted = message.isHtmlBodyOmitted === true
const hasText = (message.textBody ?? '').trim() !== ''
if (omitted) notices.push(notice(hasText ? 'htmlOmitted' : 'unavailable'))
if (omitted) notices.push(notice(hasText ? 'htmlOmitted' : 'unavailable', options.withAction))
// 보여 줄 본문이 없으면("표시할 수 없음") 앞부분만 보인다는 안내는 맞지 않는다
if (message.isBodyTruncated === true && !(omitted && !hasText)) notices.push(notice('truncated'))
if (message.isBodyTruncated === true && !(omitted && !hasText)) notices.push(notice('truncated', options.withAction))
return notices
}
@@ -1,5 +1,6 @@
import { describe, expect, it } from 'vitest'
import { MAIL_FILTER_RUN_TEXT } from '@/shared/constants/mail-filter'
import type { Mailbox, MailFilterRule } from '@/shared/types/mail'
import { newFilterDraft, type MailFilterDraft } from './mail-filter'
import {
@@ -55,7 +56,7 @@ describe('이미 받은 메일에 할 일 — 칸과 시작이 함께 쓰는 판
})
})
describe('「받은편지함에 이미 있는 메일에도 적용」을 고를 수 없는 까닭 (고치기 창의 칸 값)', () => {
describe('「받은편지함에 이미 있는 메일도 정리하기」를 고를 수 없는 까닭 (고치기 창의 칸 값)', () => {
it('이 탭에서 적용하는 중이 먼저 — 그다음 지워진 메일함 · 받은편지함으로만 · 전달만', () => {
expect(filterApplyBlock(draft({ move: true }), MAILBOXES, true)).toBe('busy')
expect(filterApplyBlock(draft({ move: true, moveTarget: 'gone', forward: true }), MAILBOXES, false)).toBe('missingMailbox')
@@ -123,10 +124,10 @@ describe('알림 문구 (디자인 원본 MailFilterApplyStates)', () => {
expect(copy.progress({ scanned: 0, total: 0 })).toBe('‘거래처 메일’ 필터로 받은편지함을 정리하는 중')
})
it('끝 — N통에 적용, 0통이면 맞는 메일이 없다', () => {
it('끝 — N통에 적용, 0통이면 걸리는 메일이 없다', () => {
expect(copy.done(37)).toBe('‘거래처 메일’ 필터로 받은편지함의 메일 37통을 정리했습니다.')
expect(copy.done(1234)).toBe('‘거래처 메일’ 필터로 받은편지함의 메일 1,234통을 정리했습니다.')
expect(copy.done(0)).toBe('받은편지함에 ‘거래처 메일’ 필터에 맞는 메일이 없습니다.')
expect(copy.done(0)).toBe(MAIL_FILTER_RUN_TEXT.none('‘거래처 메일’'))
})
it('멈춤 — 전체 가운데 확인한 데까지 · 적용한 수', () => {
+2 -2
View File
@@ -39,7 +39,7 @@ export function filterRunIdleReason(
}
/**
* 「받은편지함에 이미 있는 메일에도 적용」을 고를 수 없는 까닭 (디자인 원본 MailFilterApplyStates — 7단계 리뷰로 넓혔다)
* 「받은편지함에 이미 있는 메일도 정리하기」를 고를 수 없는 까닭 (디자인 원본 MailFilterApplyStates — 7단계 리뷰로 넓혔다)
* busy: 이 탭에서 다른 필터를 적용하는 중(가장 먼저) · missingMailbox · inboxOnly · forwardOnly: 전달만 하는 필터
*/
export type MailFilterApplyBlock = 'busy' | 'missingMailbox' | 'inboxOnly' | 'forwardOnly'
@@ -99,7 +99,7 @@ export function filterRunTarget(
export interface MailFilterRunCopy {
/** 하는 중 — "‘…’ 필터를 받은편지함에 적용하는 중 · 4,200 / 8,412통 확인" (전체를 모르면 수를 뺀다) */
progress: (tally: Pick<MailFilterRunTally, 'scanned' | 'total'>) => string
/** 끝남 — 할 일을 한 수. 0 이면 "받은편지함에 ‘…’ 필터에 맞는 메일이 없습니다." */
/** 끝남 — 할 일을 한 수. 0 이면 "받은편지함에는 ‘…’ 필터에 걸리는 메일이 없습니다." */
done: (matched: number) => string
/** 도중에 멈춤 — 거기까지의 수 */
stopped: (tally: MailFilterRunTally) => string
@@ -1,5 +1,6 @@
import { describe, expect, it } from 'vitest'
import { MAIL_NOTIFY_TEXT } from '@/shared/constants/mail-notify'
import {
mailEventsLockName,
packRealtimeMessage,
@@ -13,7 +14,7 @@ import {
describe('realtimeLinkText (설정 › 알림의 실시간 연결 줄)', () => {
it('연결됨은 초록 점과 "연결되어 있습니다", 끊김 · 지원 안 함은 "연결할 수 없어 1분마다 확인합니다."', () => {
expect(realtimeLinkText('live')).toMatchObject({
text: '연결되어 있어 새 메일이 오면 바로 목록에 나타납니다.',
text: MAIL_NOTIFY_TEXT.realtimeLive,
notice: null,
live: true,
})
@@ -26,7 +27,7 @@ describe('realtimeLinkText (설정 › 알림의 실시간 연결 줄)', () => {
expect(realtimeLinkText('unsupported')).toEqual({
text: '연결할 수 없어 1분마다 확인합니다.',
notice: null,
hint: '이 브라우저는 실시간 연결을 지원하지 않습니다.',
hint: MAIL_NOTIFY_TEXT.realtimeUnsupportedHint,
live: false,
})
expect(realtimeLinkText('connecting')).toMatchObject({ text: '연결하는 중입니다…', notice: null, live: false })
@@ -1,5 +1,6 @@
import { describe, expect, it } from 'vitest'
import { MAILBOX_OPERATION_TEXT } from '@/shared/constants/mailbox-manage'
import { initialOperationTotal, mailboxOperationCopy } from './mailbox-operation'
// 오래 걸리는 메일함 처리의 알림 문구 (디자인 원본 MailActions4 "오래 걸리는 처리" · "알림 문구")
@@ -10,7 +11,7 @@ describe('mailboxOperationCopy', () => {
expect(copy.progress(600, 1234)).toBe('받은편지함의 메일을 읽음으로 표시하는 중 · 600 / 1,234통')
expect(copy.done(1234)).toBe('받은편지함의 메일 1,234통을 읽음으로 표시했습니다.')
expect(copy.failed(600, 1234)).toBe('1,234통 가운데 600통까지 처리하고 멈췄습니다. 다시 시도해 주세요.')
expect(copy.failed(600, 1234)).toBe(MAILBOX_OPERATION_TEXT.failed('600', '1,234'))
// 다른 탭이 먼저 했다
expect(copy.done(0)).toBe('받은편지함에 안 읽은 메일이 없습니다.')
})
+2 -1
View File
@@ -1,5 +1,6 @@
import { describe, expect, it } from 'vitest'
import { MAILBOX_EMPTY_HINTS } from '@/shared/constants/mail'
import type { Mailbox } from '@/shared/types/mail'
import {
emptyMailboxText,
@@ -84,7 +85,7 @@ describe('emptyMailboxText', () => {
it('메일함 이름과 받침에 맞는 조사로 안내한다', () => {
expect(emptyMailboxText({ name: '임시보관함', role: 'drafts' })).toEqual({
title: '임시보관함이 비어 있습니다',
hint: '임시 저장한 메일이 여기에 표시됩니다.',
hint: MAILBOX_EMPTY_HINTS.drafts,
})
expect(emptyMailboxText({ name: '프로젝트', role: null }).title).toBe('프로젝트가 비어 있습니다')
})
+2 -2
View File
@@ -228,8 +228,8 @@ describe('toast.store', () => {
const failed = toast.progress('비우는 중')
store.dismiss(store.toasts[1]?.id ?? -1)
failed.fail('21통 가운데 10통까지 처리하고 멈췄습니다. 다시 시도해 주세요.')
expect(store.toasts.at(-1)).toMatchObject({ kind: 'error', message: '21통 가운데 10통까지 처리하고 멈췄습니다. 다시 시도해 주세요.' })
failed.fail('21통 가운데 10통까지 마치고 멈췄습니다. 다시 시도해 주세요.')
expect(store.toasts.at(-1)).toMatchObject({ kind: 'error', message: '21통 가운데 10통까지 마치고 멈췄습니다. 다시 시도해 주세요.' })
})
})