generated from ttipo/project-base
fix: 화면 · 오류 문구를 사람이 말하듯 다듬고 엔진 검색의 기간 시작 경계를 고친다 #19
@@ -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 (클라이언트 전용)
|
||||
|
||||
|
||||
@@ -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단계" |
|
||||
|
||||
@@ -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[],
|
||||
|
||||
@@ -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 + 클릭으로도 고르지 않는다.
|
||||
|
||||
@@ -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개까지 만들 수 있습니다. 쓰지 않는 앱 비밀번호를 지운 뒤 만들어 주세요." — 엔진 상한을 바꾸면 이 문구와 화면 상수도 |
|
||||
|
||||
@@ -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명까지 넣을 수 있습니다.',
|
||||
};
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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],
|
||||
)
|
||||
})
|
||||
|
||||
|
||||
@@ -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))
|
||||
|
||||
|
||||
@@ -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([])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -376,7 +376,7 @@ void supportContactStore.ensureLoaded()
|
||||
사내 업무를<br>한 계정으로
|
||||
</p>
|
||||
<p class="login__lead">
|
||||
메일부터 시작해 캘린더와 파일 저장소까지 같은 계정으로 이어집니다.
|
||||
메일부터 시작해 캘린더와 파일 저장소까지 같은 계정으로 쓸 수 있게 됩니다.
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -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 는 찾을 글을 지운다 — 목록이 다시 그룹 전체로
|
||||
|
||||
@@ -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일 동안 로그인 기록이 없습니다',
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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: '잠시 뒤 다시 시도해 주세요.',
|
||||
|
||||
@@ -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 앱에서 프로필 사진 → [다른 계정 추가] → [기타]를 누릅니다.'],
|
||||
|
||||
@@ -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} 보기`,
|
||||
/** 도중에 멈췄다 — [다시 시도]는 남은 메일부터 이어서 */
|
||||
|
||||
@@ -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 웹메일',
|
||||
|
||||
@@ -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: '서식과 그림 없이 글자만 보내고, 서명은 붙지 않습니다.',
|
||||
|
||||
@@ -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: '인증 앱을 쓸 수 없나요? 관리자에게 문의해 주세요.',
|
||||
|
||||
@@ -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('(으)로')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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('멈춤 — 전체 가운데 확인한 데까지 · 적용한 수', () => {
|
||||
|
||||
@@ -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('받은편지함에 안 읽은 메일이 없습니다.')
|
||||
})
|
||||
|
||||
@@ -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('프로젝트가 비어 있습니다')
|
||||
})
|
||||
|
||||
@@ -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통까지 마치고 멈췄습니다. 다시 시도해 주세요.' })
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
Reference in New Issue
Block a user