generated from ttipo/project-base
docs: 14단계 화면(검색 · 대화 묶기)을 확정해 디자인 문서에 옮긴다 #17
@@ -36,12 +36,12 @@ backend/src/common/errors/error-codes.ts ← 여기만 수정한다
|
||||
| AUTH_003 | 403 | 권한 부족 | "해당 메뉴나 기능에 접근할 수 있는 권한이 없습니다." |
|
||||
| AUTH_004 | 401 | 로그인 자격 증명 불일치 (계정 없음·비밀번호 틀림을 구분하지 않음) | "이메일 또는 비밀번호가 올바르지 않습니다." |
|
||||
| AUTH_005 | 400 | 로그인한 채 본인 확인 실패 — 비밀번호 변경의 지금 비밀번호가 틀림. **401 이 아니다** — 클라이언트는 401 이면 세션을 갱신하고 같은 요청을 다시 보낸다 | "지금 비밀번호가 올바르지 않습니다." |
|
||||
| AUTH_006 | 400 | 2단계 인증 코드 틀림(로그인 코드 단계 · 켜기 · 끄기 · 비밀번호 바꾸기 — 12단계). 401 이 아니다(위와 같은 까닭) | "코드가 올바르지 않습니다. 휴대폰의 날짜와 시간이 「자동」으로 맞춰져 있는지 확인해 주세요." |
|
||||
| AUTH_006 | 400 | 2단계 인증 코드 틀림(로그인 코드 단계 · 켜기 · 끄기 · 비밀번호 바꾸기 — 12단계). 401 이 아니다(위와 같은 까닭) | "코드가 올바르지 않습니다. 휴대폰의 날짜와 시간이 자동으로 맞춰지도록 설정되어 있는지 확인해 주세요." |
|
||||
| AUTH_007 | 400 | 2단계 인증을 켠 직원인데 코드가 없다 — 화면은 코드 칸을 보인다 | "인증 앱의 코드를 입력해 주세요." |
|
||||
| AUTH_008 | 409 | 2단계 등록이 그사이 바뀌었거나 10분이 지났다 · 이미 켜져 있다 — 처음부터(새 키) | "2단계 인증 설정이 그사이 바뀌었거나 시간이 지났습니다. 처음부터 다시 해 주세요." |
|
||||
| AUTH_009 | 409 | 앱 비밀번호 상한(5개) | "5개까지 만들 수 있습니다. 쓰지 않는 앱 비밀번호를 지운 뒤 만들어 주세요." |
|
||||
| AUTH_010 | 403 | 제한된 세션(새 비밀번호 정하기 · 2단계 인증 켜기 전 — 13단계)으로 허용하지 않은 API. 화면은 알림 없이 `me` 를 다시 받아 그 단계로 간다. 403 의 상태 역매핑은 그대로 `AUTH_003` | "먼저 비밀번호를 바꾸거나 2단계 인증을 켜 주세요." |
|
||||
| VAL_001 | 400 / 422 | 입력 검증 실패 | "입력하신 정보를 다시 확인해 주세요. (필수값 누락 또는 형식 오류)" |
|
||||
| VAL_001 | 400 / 422 | 입력 검증 실패(빠진 값 · 형식 · 길이 · 개수 한도) | "입력하신 내용을 다시 확인해 주세요. 빠진 칸이 있거나 형식이나 길이가 맞지 않습니다." |
|
||||
| RES_001 | 404 | 리소스 없음 | "요청하신 정보나 페이지를 찾을 수 없습니다." |
|
||||
| BIZ_001 | 400 | 그 외 비즈니스 규칙 위반 | "요청하신 작업을 완료하지 못했습니다. 다시 시도해 주세요." |
|
||||
|
||||
|
||||
@@ -262,6 +262,7 @@ router.beforeEach((to) => {
|
||||
- [ ] **에러 사전**: `error-codes.generated.ts` 직접 수정 금지 — 백엔드 레지스트리 수정 후 `npm run sync:error-codes`.
|
||||
- [ ] **목록 화면**: 응답은 `{items, pageInfo}` — `shared/types/pagination.ts` 타입 사용.
|
||||
- [ ] **컴포넌트**: `<script setup lang="ts">` + `<style scoped>` + rem 단위. 인라인 스타일·Options API 금지.
|
||||
- [ ] **문구**: 화면 · 알림 · 화면 낭독기 글은 사용자에게 말하듯 — 디자인 README 「화면 문구 — 사용자에게 말하듯」(줄표로 잇지 않기 · 「」 대신 [단추 이름] · 만든 쪽 말(서버 · 엔진 · 판정) 빼기 · 존댓말 "~합니다 · ~해 주세요"). 2026-11-05 사용자 요청으로 모두 고쳤다.
|
||||
- [ ] **타입**: Props/Emits 는 인터페이스로 정의.
|
||||
- [ ] **라우트**: 페이지는 lazy import, 보호 라우트는 `meta.requiresAuth`.
|
||||
- [ ] **auth 가드**: `router/index.ts` 가드는 첫 네비게이션 전에 세션을 복원한 뒤 `isLoggedIn` 으로 판정하고, 미인증은 `{ name: 'login', query: { redirect } }` 로 보낸다. 새 보호 화면은 `meta.requiresAuth` 만 붙인다.
|
||||
|
||||
@@ -56,7 +56,7 @@ const COMMON_ERROR_CODES = {
|
||||
AUTH_006: {
|
||||
status: HttpStatus.BAD_REQUEST,
|
||||
message:
|
||||
'코드가 올바르지 않습니다. 휴대폰의 날짜와 시간이 「자동」으로 맞춰져 있는지 확인해 주세요.',
|
||||
'코드가 올바르지 않습니다. 휴대폰의 날짜와 시간이 자동으로 맞춰지도록 설정되어 있는지 확인해 주세요.',
|
||||
},
|
||||
// 2단계 인증을 켠 직원의 요청에 코드가 없음 — 다른 곳에서 켠 뒤의 화면. 화면은 코드 칸을 보인다 (계약 5절)
|
||||
AUTH_007: {
|
||||
@@ -83,7 +83,8 @@ const COMMON_ERROR_CODES = {
|
||||
},
|
||||
VAL_001: {
|
||||
status: HttpStatus.BAD_REQUEST,
|
||||
message: '입력하신 정보를 다시 확인해 주세요. (필수값 누락 또는 형식 오류)',
|
||||
message:
|
||||
'입력하신 내용을 다시 확인해 주세요. 빠진 칸이 있거나 형식이나 길이가 맞지 않습니다.',
|
||||
},
|
||||
RES_001: {
|
||||
status: HttpStatus.NOT_FOUND,
|
||||
@@ -118,12 +119,14 @@ const DOMAIN_ERROR_CODES = {
|
||||
// 빠진 조각이 있는데 끝내기 · 끝내기가 진행 중 · 살아 있는 링크에 실린 파일 지우기. 아무것도 바꾸지 않았다 — 클라이언트는 GET 으로 상태를 보고 잇는다
|
||||
FILE_003: {
|
||||
status: HttpStatus.CONFLICT,
|
||||
message: '파일 올리기 상태가 맞지 않습니다. 다시 시도해 주세요.',
|
||||
message:
|
||||
'파일 상태가 그사이 바뀌어 처리하지 못했습니다. 다시 시도해 주세요.',
|
||||
},
|
||||
// 조각의 Content-Digest(sha-256)가 받은 바이트와 다르다 — 그 조각을 받지 않았다 (같은 조각을 다시 보낸다)
|
||||
FILE_004: {
|
||||
status: HttpStatus.BAD_REQUEST,
|
||||
message: '파일 조각이 보내는 중에 손상되었습니다. 다시 시도해 주세요.',
|
||||
message:
|
||||
'파일을 올리는 중에 일부가 제대로 전달되지 않았습니다. 다시 시도해 주세요.',
|
||||
},
|
||||
// 직원 보관 한도(보관량 · 보내지 않은 파일 수)를 넘는다 — 만들지 않았다. 문구에 숫자를 넣지 않는다(한도가 env 로 바뀐다)
|
||||
FILE_005: {
|
||||
@@ -151,7 +154,7 @@ const DOMAIN_ERROR_CODES = {
|
||||
// 메일 — 외부 메일 엔진(Stalwart) 장애를 SYS_001(우리 버그)과 구분하기 위해 분리한다
|
||||
MAIL_001: {
|
||||
status: HttpStatus.SERVICE_UNAVAILABLE,
|
||||
message: '메일 서버와 연결할 수 없습니다. 잠시 후 다시 시도해 주세요.',
|
||||
message: '메일 서비스에 연결할 수 없습니다. 잠시 후 다시 시도해 주세요.',
|
||||
},
|
||||
MAIL_002: {
|
||||
status: HttpStatus.BAD_REQUEST,
|
||||
@@ -224,7 +227,7 @@ const DOMAIN_ERROR_CODES = {
|
||||
MAIL_013: {
|
||||
status: HttpStatus.BAD_REQUEST,
|
||||
message:
|
||||
'필터나 차단 목록이 너무 길어 저장할 수 없습니다. 조건을 줄이거나 쓰지 않는 필터 · 차단 주소를 지워 주세요.',
|
||||
'필터나 차단 목록이 너무 길어 저장할 수 없습니다. 조건을 줄이거나, 쓰지 않는 필터나 차단한 주소를 지워 주세요.',
|
||||
},
|
||||
// 붙잡아 둔 발송(보내기 취소 · 예약 — 9단계)을 취소하려 했는데 이미 보냈다(엔진이 풀었음) · 기다리는 발송이 아니다 — 아무것도 바꾸지 않았다.
|
||||
// 화면은 인터셉터 알림으로 보인다 (계약 2절 "보내기 취소 · 예약 발송")
|
||||
@@ -258,7 +261,7 @@ const DOMAIN_ERROR_CODES = {
|
||||
CONTACT_002: {
|
||||
status: HttpStatus.CONFLICT,
|
||||
message:
|
||||
'주소록 한도를 넘어 더 넣을 수 없습니다. (연락처 3,000개 · 그룹 100개 · 그룹 하나에 500명까지)',
|
||||
'주소록 한도를 넘어 더 넣을 수 없습니다. 연락처 3,000개, 그룹 100개, 그룹 하나에 500명까지 넣을 수 있습니다.',
|
||||
},
|
||||
} as const satisfies Record<string, ErrorCodeDefinition>;
|
||||
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
insertHtmlBlock,
|
||||
insertTextBlock,
|
||||
isValidQuoteStart,
|
||||
largeFileTextBlocks,
|
||||
removeHtmlBlock,
|
||||
removeTextBlock,
|
||||
} from './large-file-block';
|
||||
@@ -40,14 +41,14 @@ describe('buildLargeFileBlock — 덩이의 내용 (디자인 README "11단계
|
||||
expect(block.html.endsWith(LARGE_FILES_BLOCK_END)).toBe(true);
|
||||
for (const text of [
|
||||
'대용량 첨부 2개 (합계 1.8 GB)',
|
||||
'2026년 11월 29일(일)까지 내려받을 수 있습니다 (한국 시간)',
|
||||
'한국 시간으로 2026년 11월 29일(일)까지 내려받을 수 있습니다.',
|
||||
'설계도_v3.dwg',
|
||||
'1.2 GB',
|
||||
'현장영상_1029.mp4',
|
||||
'612 MB',
|
||||
`<a href="${URL_}">${URL_}</a>`,
|
||||
'내려받을 때 로그인이나 비밀번호를 묻지 않습니다.',
|
||||
'Large files: download by Nov 29, 2026 (KST) — no login or password needed.',
|
||||
'Large files: download by Nov 29, 2026 (KST). No login or password needed.',
|
||||
]) {
|
||||
expect(block.html).toContain(text);
|
||||
}
|
||||
@@ -57,6 +58,32 @@ describe('buildLargeFileBlock — 덩이의 내용 (디자인 README "11단계
|
||||
|
||||
it('텍스트 — 같은 내용을 대시 줄로 가른 줄로 (파일 "- 이름 (크기)", 주소 "내려받기: 주소")', () => {
|
||||
expect(block.text.split('\n')).toEqual([
|
||||
'-'.repeat(40),
|
||||
'대용량 첨부 2개 (합계 1.8 GB)',
|
||||
'한국 시간으로 2026년 11월 29일(일)까지 내려받을 수 있습니다.',
|
||||
'- 설계도_v3.dwg (1.2 GB)',
|
||||
'- 현장영상_1029.mp4 (612 MB)',
|
||||
`내려받기: ${URL_}`,
|
||||
'내려받을 때 로그인이나 비밀번호를 묻지 않습니다.',
|
||||
'Large files: download by Nov 29, 2026 (KST). No login or password needed.',
|
||||
'-'.repeat(40),
|
||||
]);
|
||||
});
|
||||
|
||||
it('사용자에게 말하는 말투 — 줄표(—)로 잇지 않고 「」 를 쓰지 않는다 (디자인 README "화면 문구")', () => {
|
||||
expect(block.text).not.toMatch(/[—「」]/);
|
||||
expect(block.html).not.toMatch(/[—「」]/);
|
||||
});
|
||||
|
||||
it('떼기용 텍스트 덩이 — 지금 글이 먼저, 그다음 2026-11-05 앞의 글 그대로 (그 전에 붙잡아 둔 발송을 보내기 취소로 되돌릴 때)', () => {
|
||||
const [current, legacy, ...rest] = largeFileTextBlocks({
|
||||
files: FILES,
|
||||
expiresAt: EXPIRES,
|
||||
url: URL_,
|
||||
});
|
||||
expect(current).toBe(block.text);
|
||||
expect(rest).toEqual([]);
|
||||
expect(legacy.split('\n')).toEqual([
|
||||
'-'.repeat(40),
|
||||
'대용량 첨부 2개 (합계 1.8 GB)',
|
||||
'2026년 11월 29일(일)까지 내려받을 수 있습니다 (한국 시간)',
|
||||
|
||||
@@ -11,7 +11,10 @@ import { formatKstDate, formatKstDateEnglish } from '@/shared/utils/kst-date';
|
||||
* 이 표시가 들어갈 수 없다(서버만 넣는다). 엔진은 저장한 메일의 주석을 그대로 둔다(2026-10-30 개발 엔진으로 확인 — 되돌린 초안의 rawHtmlBody 에 남는다)
|
||||
* - **텍스트**: 같은 내용을 줄로 — 대시 줄로 가르고 앞뒤에 빈 줄
|
||||
*
|
||||
* ⚠️ 덩이를 고치면 그 전에 보낸 메일의 텍스트 덩이를 보내기 취소로 되돌릴 때 떼지 못한다(글 그대로일 때만 뗀다) — 표시의 판(v1)을 올리고 옛 판도 알아보게 한다
|
||||
* ⚠️ 덩이를 고치면 그 전에 보낸 메일의 텍스트 덩이를 보내기 취소로 되돌릴 때 떼지 못한다(글 그대로일 때만 뗀다).
|
||||
* - 글만 고칠 때: 옛 글을 `LEGACY_BLOCK_WORDINGS` 에 남겨 떼기가 함께 찾게 한다(largeFileTextBlocks — 2026-11-05 말투를 고칠 때 이렇게 했다).
|
||||
* HTML 덩이는 표시 사이를 떼므로 글과 상관없다
|
||||
* - 줄의 차례 · 표 모양을 바꿀 때: 표시의 판(v1)을 올리고 옛 판도 알아보게 한다
|
||||
*/
|
||||
|
||||
/** 덩이의 앞뒤 표시 (HTML 주석) */
|
||||
@@ -32,6 +35,34 @@ const TEXT_RULE = '-'.repeat(40);
|
||||
const BLOCK_FONT =
|
||||
"'Malgun Gothic', '맑은 고딕', 'Apple SD Gothic Neo', sans-serif";
|
||||
|
||||
/** 덩이의 로그인 안내 줄 (내려받기 화면이 로그인 · 비밀번호를 묻지 않는다는 것 — 묻는 화면이면 남이 꾸민 것이다) */
|
||||
const NO_LOGIN_LINE = '내려받을 때 로그인이나 비밀번호를 묻지 않습니다.';
|
||||
|
||||
/** 덩이의 글 가운데 고쳐 온 줄 — 끝나는 날 · 영어 한 줄 (날짜는 한국 날짜로 받는다) */
|
||||
interface BlockWording {
|
||||
readonly until: (kstDate: string) => string;
|
||||
readonly english: (kstDateEnglish: string) => string;
|
||||
}
|
||||
|
||||
/** 지금 넣는 글 (디자인 README "11단계 › 본문 덩이" — 2026-11-05 사용자에게 말하는 말투로) */
|
||||
const BLOCK_WORDING: BlockWording = {
|
||||
until: (date) => `한국 시간으로 ${date}까지 내려받을 수 있습니다.`,
|
||||
english: (date) =>
|
||||
`Large files: download by ${date} (KST). No login or password needed.`,
|
||||
};
|
||||
|
||||
/**
|
||||
* 예전에 넣던 글 — 보내기 취소로 되돌린 발송의 텍스트 덩이를 뗄 때만 함께 찾는다 (largeFileTextBlocks).
|
||||
* 2026-11-05 앞의 글이다. 그 전에 붙잡아 둔 발송(예약은 엔진 한도 7일)이 모두 나간 뒤에는 지워도 된다
|
||||
*/
|
||||
const LEGACY_BLOCK_WORDINGS: readonly BlockWording[] = [
|
||||
{
|
||||
until: (date) => `${date}까지 내려받을 수 있습니다 (한국 시간)`,
|
||||
english: (date) =>
|
||||
`Large files: download by ${date} (KST) — no login or password needed.`,
|
||||
},
|
||||
];
|
||||
|
||||
/** 덩이 하나의 재료 */
|
||||
export interface LargeFileBlockInput {
|
||||
/** 보낸 차례 */
|
||||
@@ -56,15 +87,48 @@ function escapeHtml(text: string): string {
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
|
||||
/** 덩이의 글 줄 — HTML 덩이와 텍스트 덩이가 같은 글을 쓴다 */
|
||||
interface BlockLines {
|
||||
readonly title: string;
|
||||
readonly until: string;
|
||||
readonly noLogin: string;
|
||||
readonly english: string;
|
||||
}
|
||||
|
||||
function blockLines(
|
||||
input: LargeFileBlockInput,
|
||||
wording: BlockWording,
|
||||
): BlockLines {
|
||||
const total = input.files.reduce((sum, file) => sum + file.size, 0);
|
||||
return {
|
||||
title: `대용량 첨부 ${input.files.length}개 (합계 ${formatFileSize(total)})`,
|
||||
until: wording.until(formatKstDate(input.expiresAt)),
|
||||
noLogin: NO_LOGIN_LINE,
|
||||
english: wording.english(formatKstDateEnglish(input.expiresAt)),
|
||||
};
|
||||
}
|
||||
|
||||
/** 텍스트 덩이 — 대시 줄로 가른 같은 내용 (파일 "- 이름 (크기)", 주소 "내려받기: 주소") */
|
||||
function textBlock(input: LargeFileBlockInput, lines: BlockLines): string {
|
||||
return [
|
||||
TEXT_RULE,
|
||||
lines.title,
|
||||
lines.until,
|
||||
...input.files.map(
|
||||
(file) => `- ${file.name} (${formatFileSize(file.size)})`,
|
||||
),
|
||||
`내려받기: ${input.url}`,
|
||||
lines.noLogin,
|
||||
lines.english,
|
||||
TEXT_RULE,
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
/** 덩이를 만든다 — HTML(앞뒤 표시 포함)과 텍스트에 같은 내용 */
|
||||
export function buildLargeFileBlock(
|
||||
input: LargeFileBlockInput,
|
||||
): LargeFileBlock {
|
||||
const total = input.files.reduce((sum, file) => sum + file.size, 0);
|
||||
const title = `대용량 첨부 ${input.files.length}개 (합계 ${formatFileSize(total)})`;
|
||||
const until = `${formatKstDate(input.expiresAt)}까지 내려받을 수 있습니다 (한국 시간)`;
|
||||
const noLogin = '내려받을 때 로그인이나 비밀번호를 묻지 않습니다.';
|
||||
const english = `Large files: download by ${formatKstDateEnglish(input.expiresAt)} (KST) — no login or password needed.`;
|
||||
const lines = blockLines(input, BLOCK_WORDING);
|
||||
const url = escapeHtml(input.url);
|
||||
|
||||
const cell = (style: string, content: string) =>
|
||||
@@ -81,11 +145,11 @@ export function buildLargeFileBlock(
|
||||
`<table style="border-collapse:collapse;border:1px solid #d0d5dd;margin:16px 0;font-family:${BLOCK_FONT}"><tbody>` +
|
||||
cell(
|
||||
'padding:12px 16px 2px 16px;font-size:14px;font-weight:bold;color:#101828',
|
||||
escapeHtml(title),
|
||||
escapeHtml(lines.title),
|
||||
) +
|
||||
cell(
|
||||
'padding:0 16px 8px 16px;font-size:13px;color:#344054',
|
||||
escapeHtml(until),
|
||||
escapeHtml(lines.until),
|
||||
) +
|
||||
fileRows +
|
||||
cell(
|
||||
@@ -94,28 +158,26 @@ export function buildLargeFileBlock(
|
||||
) +
|
||||
cell(
|
||||
'padding:6px 16px 0 16px;font-size:12px;color:#667085',
|
||||
escapeHtml(noLogin),
|
||||
escapeHtml(lines.noLogin),
|
||||
) +
|
||||
cell(
|
||||
'padding:2px 16px 12px 16px;font-size:12px;color:#667085',
|
||||
escapeHtml(english),
|
||||
escapeHtml(lines.english),
|
||||
) +
|
||||
'</tbody></table>' +
|
||||
LARGE_FILES_BLOCK_END;
|
||||
|
||||
const text = [
|
||||
TEXT_RULE,
|
||||
title,
|
||||
until,
|
||||
...input.files.map(
|
||||
(file) => `- ${file.name} (${formatFileSize(file.size)})`,
|
||||
),
|
||||
`내려받기: ${input.url}`,
|
||||
noLogin,
|
||||
english,
|
||||
TEXT_RULE,
|
||||
].join('\n');
|
||||
return { html, text };
|
||||
return { html, text: textBlock(input, lines) };
|
||||
}
|
||||
|
||||
/**
|
||||
* 보내기 취소로 되돌린 발송에서 뗄 텍스트 덩이 — 지금 글 다음에 예전 글(LEGACY_BLOCK_WORDINGS) 차례.
|
||||
* 텍스트 덩이는 글 그대로일 때만 떼므로(removeTextBlock), 글을 고치기 전에 붙잡아 둔 발송의 덩이도 찾게 한다
|
||||
*/
|
||||
export function largeFileTextBlocks(input: LargeFileBlockInput): string[] {
|
||||
return [BLOCK_WORDING, ...LEGACY_BLOCK_WORDINGS].map((wording) =>
|
||||
textBlock(input, blockLines(input, wording)),
|
||||
);
|
||||
}
|
||||
|
||||
/** 텍스트 덩이를 본문에 넣을 때의 모양 — 앞뒤에 빈 줄 (떼기도 이 모양 그대로 찾는다) */
|
||||
|
||||
@@ -21,6 +21,7 @@ import {
|
||||
buildLargeFileBlock,
|
||||
insertHtmlBlock,
|
||||
insertTextBlock,
|
||||
largeFileTextBlocks,
|
||||
} from './large-file-block';
|
||||
import { MAX_BODY_LENGTH } from './mail.constants';
|
||||
import type { MailEventsService } from './mail-events.service';
|
||||
@@ -538,6 +539,32 @@ describe('MailLargeFilesService — 메일과 대용량 첨부 (11단계)', () =
|
||||
expect(kept.textBody).toBe(edited);
|
||||
});
|
||||
|
||||
it('글을 고치기(2026-11-05) 전에 붙잡아 둔 발송 — 예전 글의 텍스트 덩이도 뗀다 (HTML 은 표시로 뗀다)', async () => {
|
||||
const { service, sharing } = setup();
|
||||
sharing.findBySentEmail.mockResolvedValue(
|
||||
record({ canceledAt: SENT_AT }),
|
||||
);
|
||||
const [, legacyText] = largeFileTextBlocks({
|
||||
files: VIEWS,
|
||||
expiresAt: EXPIRES,
|
||||
url: shareUrl(ORIGIN, TOKEN),
|
||||
});
|
||||
expect(legacyText).not.toBe(sentBlock.text);
|
||||
const originalText = '내 글\n\n----- 원본 메일 -----\n> 인용';
|
||||
const result = await service.draftView(
|
||||
ARA,
|
||||
draft({
|
||||
isCanceledSend: true,
|
||||
textBody: insertTextBlock(
|
||||
originalText,
|
||||
'내 글\n\n'.length,
|
||||
legacyText,
|
||||
),
|
||||
}),
|
||||
);
|
||||
expect(result.textBody).toBe(originalText);
|
||||
});
|
||||
|
||||
it('다른 링크의 덩이(토큰이 다름)는 떼지 않는다', async () => {
|
||||
const { service, sharing } = setup();
|
||||
sharing.findBySentEmail.mockResolvedValue(
|
||||
|
||||
@@ -41,6 +41,7 @@ import {
|
||||
hrefsIn,
|
||||
insertHtmlBlock,
|
||||
insertTextBlock,
|
||||
largeFileTextBlocks,
|
||||
removeHtmlBlock,
|
||||
removeTextBlock,
|
||||
type LargeFileBlock,
|
||||
@@ -565,7 +566,7 @@ export class MailLargeFilesService implements OnModuleInit, OnModuleDestroy {
|
||||
|
||||
/**
|
||||
* 취소해 되돌린 발송의 덩이를 뗀다 — HTML 은 표시 사이(그 링크의 토큰이 있을 때만), 텍스트는 서버가 넣은 글 그대로일 때만.
|
||||
* 토큰은 HTML 덩이의 링크에서(없으면 텍스트의 주소에서) 찾아 링크의 해시와 맞춘다 — 덩이를 다시 만들어 글 그대로 찾는다
|
||||
* 토큰은 HTML 덩이의 링크에서(없으면 텍스트의 주소에서) 찾아 링크의 해시와 맞춘다 — 덩이를 다시 만들어(지금 글 · 예전 글) 글 그대로 찾는다
|
||||
*/
|
||||
private stripBlock(
|
||||
record: ShareLinkRecord,
|
||||
@@ -592,12 +593,19 @@ export class MailLargeFilesService implements OnModuleInit, OnModuleDestroy {
|
||||
|
||||
let text = textBody;
|
||||
if (token !== null && text !== null) {
|
||||
const { text: blockText } = buildLargeFileBlock({
|
||||
// 지금 글 다음에 예전 글 — 글을 고치기 전에 붙잡아 둔 발송의 텍스트 덩이도 뗀다 (large-file-block.ts 의 LEGACY_BLOCK_WORDINGS)
|
||||
const blockTexts = largeFileTextBlocks({
|
||||
files: record.files,
|
||||
expiresAt: record.link.expiresAt,
|
||||
url: shareUrl(origin, token),
|
||||
});
|
||||
text = removeTextBlock(text, blockText) ?? text;
|
||||
for (const blockText of blockTexts) {
|
||||
const removed = removeTextBlock(text, blockText);
|
||||
if (removed !== null) {
|
||||
text = removed;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
return { rawHtmlBody: html, textBody: text };
|
||||
}
|
||||
|
||||
@@ -51,15 +51,15 @@ describe('받은편지함 알림 메일의 글 (13단계 — 디자인 README 13
|
||||
expect(notice.subject).toBe('관리자가 비밀번호를 재설정했습니다');
|
||||
expect(notice.text).toBe(
|
||||
[
|
||||
'김아라 님, 관리자가 이 계정의 비밀번호를 임시 비밀번호로 바꿨습니다.',
|
||||
'김아라 님, 관리자가 비밀번호를 임시 비밀번호로 바꿨습니다.',
|
||||
'',
|
||||
'시각: 2026년 10월 30일 (금) 오후 3:20',
|
||||
'바뀐 것: 비밀번호 — 임시 비밀번호로 바뀌었고, 처음 로그인할 때 새 비밀번호를 정합니다',
|
||||
'로그아웃: 모든 기기 · 브라우저의 웹메일 로그인을 끊었습니다',
|
||||
'앱 비밀번호: 모두 지웠습니다 — 메일 프로그램은 새 앱 비밀번호로 다시 연결합니다',
|
||||
'바뀐 것: 비밀번호가 임시 비밀번호로 바뀌었고, 처음 로그인할 때 새 비밀번호를 정하게 됩니다',
|
||||
'로그아웃: 모든 기기와 브라우저의 웹메일에서 로그아웃되었습니다',
|
||||
'앱 비밀번호: 모두 지워져서 메일 프로그램은 새 앱 비밀번호로 다시 연결해야 합니다',
|
||||
'',
|
||||
'할 일',
|
||||
'아웃룩 · 휴대폰 메일 앱을 쓰면 메일 프로그램마다 앱 비밀번호가 필요합니다. 웹메일 › 설정 › 계정 › 앱 비밀번호에서 새로 만들어 주세요.',
|
||||
'아웃룩이나 휴대폰 메일 앱을 쓰면 메일 프로그램마다 앱 비밀번호가 필요합니다. 웹메일의 [설정] → [앱 비밀번호]에서 새로 만들어 주세요.',
|
||||
'',
|
||||
'직접 요청한 일이 아니면 바로 IT 담당에게 알려 주세요.',
|
||||
CONTACT,
|
||||
@@ -69,7 +69,7 @@ describe('받은편지함 알림 메일의 글 (13단계 — 디자인 README 13
|
||||
].join('\n'),
|
||||
);
|
||||
expect(notice.html).toContain(
|
||||
'<strong>웹메일 › 설정 › 계정 › 앱 비밀번호</strong>에서 새로 만들어 주세요.',
|
||||
'웹메일의 <strong>[설정] → [앱 비밀번호]</strong>에서 새로 만들어 주세요.',
|
||||
);
|
||||
});
|
||||
|
||||
@@ -77,13 +77,13 @@ describe('받은편지함 알림 메일의 글 (13단계 — 디자인 README 13
|
||||
const notice = twoFactorResetNotice(ARA, AT);
|
||||
expect(notice.subject).toBe('관리자가 2단계 인증을 껐습니다');
|
||||
expect(notice.text).toContain(
|
||||
'김아라 님, 휴대폰을 잃어버렸거나 인증 앱을 쓸 수 없다고 알려 주셔서 관리자가 이 계정의 2단계 인증을 껐습니다.',
|
||||
'김아라 님, 휴대폰을 잃어버렸거나 인증 앱을 쓸 수 없다고 알려 주셔서 관리자가 2단계 인증을 껐습니다.',
|
||||
);
|
||||
expect(notice.text).toContain(
|
||||
'그대로인 것: 비밀번호 · 앱 비밀번호 · 로그인된 브라우저',
|
||||
'그대로인 것: 비밀번호, 앱 비밀번호, 로그인된 브라우저',
|
||||
);
|
||||
expect(notice.html).toContain(
|
||||
'<strong>웹메일 › 설정 › 계정 › 2단계 인증</strong>에서 다시 켜 주세요.',
|
||||
'웹메일의 <strong>[설정] → [2단계 인증]</strong>에서 다시 켜 주세요.',
|
||||
);
|
||||
});
|
||||
|
||||
@@ -96,10 +96,10 @@ describe('받은편지함 알림 메일의 글 (13단계 — 디자인 README 13
|
||||
expect(notice.text).toContain('크기: 48 MB');
|
||||
expect(notice.text).toContain('올린 때: 2026년 10월 30일 (금) 오후 3:58');
|
||||
expect(notice.text).toContain(
|
||||
'판정이 잘못되었다고 생각되면 IT 담당에게 알려 주세요.',
|
||||
'검사 결과가 잘못되었다고 생각되면 IT 담당에게 알려 주세요.',
|
||||
);
|
||||
expect(notice.html).toContain(
|
||||
'<strong>웹메일 › 설정 › 메일 › 대용량 첨부</strong>에서 볼 수 있습니다.',
|
||||
'웹메일의 <strong>[설정] → [대용량 첨부]</strong>에서 볼 수 있습니다.',
|
||||
);
|
||||
});
|
||||
|
||||
@@ -114,6 +114,15 @@ describe('받은편지함 알림 메일의 글 (13단계 — 디자인 README 13
|
||||
},
|
||||
);
|
||||
|
||||
it.each(ALL)(
|
||||
'%s — 사용자에게 말하는 말투: 줄표(—)로 잇지 않고 「」 를 쓰지 않는다 (디자인 README "화면 문구")',
|
||||
(_label, notice) => {
|
||||
for (const text of [notice.subject, notice.text]) {
|
||||
expect(text).not.toMatch(/[—「」]/);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
it.each(ALL)(
|
||||
'%s — 보내는 메일 정화(허용 목록)를 거쳐도 지워지는 태그 · 스타일 · 글이 없다',
|
||||
(_label, notice) => {
|
||||
|
||||
@@ -57,7 +57,7 @@ interface NoticeBody {
|
||||
|
||||
/** 맨 아래 한 줄 (세 통 같음) */
|
||||
export const NOTICE_FOOTER =
|
||||
'이 메일은 ARA SYSTEM 이 자동으로 보낸 알림입니다. 회사 보안 알림에는 링크가 없습니다 — 비슷한 메일의 링크는 누르지 마세요.';
|
||||
'이 메일은 ARA SYSTEM이 자동으로 보낸 알림입니다. 회사 보안 알림에는 링크가 없으니, 비슷한 메일에 링크가 있다면 누르지 않도록 주의해 주세요.';
|
||||
|
||||
/** 확인 문장 — 비밀번호 재설정 · 2단계 해제 */
|
||||
const CONFIRM_SELF = '직접 요청한 일이 아니면 바로 IT 담당에게 알려 주세요.';
|
||||
@@ -151,24 +151,24 @@ export function passwordResetNotice(
|
||||
{
|
||||
kind: 'password-reset',
|
||||
subject: '관리자가 비밀번호를 재설정했습니다',
|
||||
greeting: `${recipient.name} 님, 관리자가 이 계정의 비밀번호를 임시 비밀번호로 바꿨습니다.`,
|
||||
greeting: `${recipient.name} 님, 관리자가 비밀번호를 임시 비밀번호로 바꿨습니다.`,
|
||||
rows: [
|
||||
['시각', formatKstDateTime(at)],
|
||||
[
|
||||
'바뀐 것',
|
||||
'비밀번호 — 임시 비밀번호로 바뀌었고, 처음 로그인할 때 새 비밀번호를 정합니다',
|
||||
'비밀번호가 임시 비밀번호로 바뀌었고, 처음 로그인할 때 새 비밀번호를 정하게 됩니다',
|
||||
],
|
||||
['로그아웃', '모든 기기 · 브라우저의 웹메일 로그인을 끊었습니다'],
|
||||
['로그아웃', '모든 기기와 브라우저의 웹메일에서 로그아웃되었습니다'],
|
||||
[
|
||||
'앱 비밀번호',
|
||||
'모두 지웠습니다 — 메일 프로그램은 새 앱 비밀번호로 다시 연결합니다',
|
||||
'모두 지워져서 메일 프로그램은 새 앱 비밀번호로 다시 연결해야 합니다',
|
||||
],
|
||||
],
|
||||
todo: [
|
||||
{
|
||||
text: '아웃룩 · 휴대폰 메일 앱을 쓰면 메일 프로그램마다 앱 비밀번호가 필요합니다. ',
|
||||
text: '아웃룩이나 휴대폰 메일 앱을 쓰면 메일 프로그램마다 앱 비밀번호가 필요합니다. 웹메일의 ',
|
||||
},
|
||||
{ strong: '웹메일 › 설정 › 계정 › 앱 비밀번호' },
|
||||
{ strong: '[설정] → [앱 비밀번호]' },
|
||||
{ text: '에서 새로 만들어 주세요.' },
|
||||
],
|
||||
confirm: CONFIRM_SELF,
|
||||
@@ -186,17 +186,17 @@ export function twoFactorResetNotice(
|
||||
{
|
||||
kind: 'two-factor-reset',
|
||||
subject: '관리자가 2단계 인증을 껐습니다',
|
||||
greeting: `${recipient.name} 님, 휴대폰을 잃어버렸거나 인증 앱을 쓸 수 없다고 알려 주셔서 관리자가 이 계정의 2단계 인증을 껐습니다.`,
|
||||
greeting: `${recipient.name} 님, 휴대폰을 잃어버렸거나 인증 앱을 쓸 수 없다고 알려 주셔서 관리자가 2단계 인증을 껐습니다.`,
|
||||
rows: [
|
||||
['시각', formatKstDateTime(at)],
|
||||
['바뀐 것', '2단계 인증 꺼짐 — 지금은 비밀번호만으로 로그인합니다'],
|
||||
['그대로인 것', '비밀번호 · 앱 비밀번호 · 로그인된 브라우저'],
|
||||
['바뀐 것', '2단계 인증이 꺼져서 지금은 비밀번호만으로 로그인합니다'],
|
||||
['그대로인 것', '비밀번호, 앱 비밀번호, 로그인된 브라우저'],
|
||||
],
|
||||
todo: [
|
||||
{ text: '새 휴대폰에 인증 앱을 설치한 뒤 ' },
|
||||
{ strong: '웹메일 › 설정 › 계정 › 2단계 인증' },
|
||||
{ text: '새 휴대폰에 인증 앱을 설치한 뒤 웹메일의 ' },
|
||||
{ strong: '[설정] → [2단계 인증]' },
|
||||
{
|
||||
text: '에서 다시 켜 주세요. 회사가 2단계 인증을 필수로 정했다면 다음 로그인 때 켜는 화면이 먼저 나옵니다.',
|
||||
text: '에서 다시 켜 주세요. 회사가 2단계 인증을 필수로 정했다면 다음에 로그인할 때 켜는 화면이 먼저 나옵니다.',
|
||||
},
|
||||
],
|
||||
confirm: CONFIRM_SELF,
|
||||
@@ -221,17 +221,17 @@ export function dangerousFileNotice(
|
||||
['올린 때', formatKstDateTime(file.uploadedAt)],
|
||||
[
|
||||
'조치',
|
||||
'이 파일만 막았습니다 — 함께 올린 다른 파일은 그대로 보내고 받을 수 있습니다',
|
||||
'이 파일만 막았고, 함께 올린 다른 파일은 그대로 보내고 받을 수 있습니다',
|
||||
],
|
||||
],
|
||||
todo: [
|
||||
{
|
||||
text: '직접 만든 파일이 아니면 PC 를 백신으로 검사해 주세요. 다른 곳에서 받은 파일이면 보낸 사람에게도 알려 주세요. 막은 파일은 ',
|
||||
text: '직접 만든 파일이 아니면 PC를 백신으로 검사해 주세요. 다른 곳에서 받은 파일이면 보낸 사람에게도 알려 주세요. 막은 파일은 웹메일의 ',
|
||||
},
|
||||
{ strong: '웹메일 › 설정 › 메일 › 대용량 첨부' },
|
||||
{ strong: '[설정] → [대용량 첨부]' },
|
||||
{ text: '에서 볼 수 있습니다.' },
|
||||
],
|
||||
confirm: '판정이 잘못되었다고 생각되면 IT 담당에게 알려 주세요.',
|
||||
confirm: '검사 결과가 잘못되었다고 생각되면 IT 담당에게 알려 주세요.',
|
||||
},
|
||||
recipient,
|
||||
);
|
||||
|
||||
@@ -0,0 +1,351 @@
|
||||
<!doctype html>
|
||||
<html lang="ko">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>목록 · 대화로 묶어 보기</title>
|
||||
<script src="./support.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
<x-dc>
|
||||
<helmet>
|
||||
<style>
|
||||
@font-face{font-family:'Pretendard';src:url('/_blob/16067b9aa5137abf808e3dbef30c42e0') format('woff2-variations'),url('/_blob/16067b9aa5137abf808e3dbef30c42e0') format('woff2');font-weight:45 920;font-style:normal;font-display:swap}
|
||||
body{margin:0}
|
||||
a{color:#0E7C86}a:hover{color:#0A5F67}
|
||||
:focus-visible{outline:2px solid #0E7C86;outline-offset:2px}
|
||||
.row:hover{background:#F8F9FB}
|
||||
</style>
|
||||
</helmet>
|
||||
<div style="min-height: 100vh; display: flex; background: #FFFFFF; color: #171C26; font-family: Pretendard, -apple-system, 'Apple SD Gothic Neo', 'Malgun Gothic', system-ui, sans-serif; font-size: 13px; line-height: 1.5">
|
||||
|
||||
<nav aria-label="사내 시스템" style="flex: 0 0 72px; box-sizing: border-box; display: flex; flex-direction: column; align-items: center; gap: 14px; padding: 10px 0 12px; background: #0B3640">
|
||||
<a href="Main.dc.html" style="display: inline-flex; align-items: center; justify-content: center; width: 60px; min-height: 44px; border-radius: 10px; text-decoration: none">
|
||||
<img src="/_blob/2b9ef4349333debc969432c859eaf1bf" alt="ARA SYSTEM" width="48" height="17" style="display: block; width: 48px; height: auto">
|
||||
</a>
|
||||
<ul style="list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 4px">
|
||||
<li>
|
||||
<a href="Main.dc.html" aria-current="page" style="display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 3px; width: 60px; min-height: 56px; border-radius: 10px; background: #155A64; color: #FFFFFF; font-size: 11px; font-weight: 600; text-decoration: none">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M4 4h16c1.1 0 2 .9 2 2v12c0 1.1-.9 2-2 2H4c-1.1 0-2-.9-2-2V6c0-1.1.9-2 2-2z"></path><polyline points="22,6 12,13 2,6"></polyline></svg>
|
||||
<span>메일</span>
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="#" style="display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 3px; width: 60px; min-height: 56px; border-radius: 10px; color: #A3C2C6; font-size: 11px; font-weight: 500; text-decoration: none">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="3" y="4" width="18" height="18" rx="2" ry="2"></rect><line x1="16" y1="2" x2="16" y2="6"></line><line x1="8" y1="2" x2="8" y2="6"></line><line x1="3" y1="10" x2="21" y2="10"></line></svg>
|
||||
<span>캘린더</span>
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="#" style="display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 3px; width: 60px; min-height: 56px; border-radius: 10px; color: #A3C2C6; font-size: 11px; font-weight: 500; text-decoration: none">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"></path></svg>
|
||||
<span>파일</span>
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="#" style="display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 3px; width: 60px; min-height: 56px; border-radius: 10px; color: #A3C2C6; font-size: 11px; font-weight: 500; text-decoration: none">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"></path><circle cx="9" cy="7" r="4"></circle><path d="M23 21v-2a4 4 0 0 0-3-3.87"></path><path d="M16 3.13a4 4 0 0 1 0 7.75"></path></svg>
|
||||
<span>주소록</span>
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
<a href="Settings.dc.html" style="margin-top: auto; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 3px; width: 60px; min-height: 56px; border-radius: 10px; color: #A3C2C6; font-size: 11px; font-weight: 500; text-decoration: none">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="3"></circle><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z"></path></svg>
|
||||
<span>설정</span>
|
||||
</a>
|
||||
</nav>
|
||||
|
||||
<div style="flex: 1 1 auto; min-width: 0; display: flex; flex-direction: column">
|
||||
|
||||
<header style="display: flex; align-items: center; flex-wrap: wrap; gap: 8px 16px; box-sizing: border-box; min-height: 56px; padding: 6px 12px 6px 20px; border-bottom: 1px solid #E6E8EC">
|
||||
<h1 style="margin: 0; font-size: 16px; font-weight: 700; letter-spacing: -0.01em">메일</h1>
|
||||
<form role="search" aria-label="메일 검색" style="flex: 1 1 280px; max-width: 520px; display: flex; align-items: center; gap: 8px; box-sizing: border-box; height: 40px; margin: 0; padding: 0 12px; border: 1px solid #D0D5DD; border-radius: 8px; background: #FFFFFF">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="#667085" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="11" cy="11" r="8"></circle><line x1="21" y1="21" x2="16.65" y2="16.65"></line></svg>
|
||||
<input type="search" aria-label="메일 검색" placeholder="메일 검색" style="flex: 1 1 auto; min-width: 0; border: 0; outline: none; background: transparent; font: inherit; color: #171C26">
|
||||
</form>
|
||||
<div style="margin-left: auto; display: flex; align-items: center; gap: 10px">
|
||||
<span aria-hidden="true" style="display: inline-flex; align-items: center; justify-content: center; width: 32px; height: 32px; border-radius: 50%; background: #E3F2F3; color: #0A5F67; font-size: 13px; font-weight: 600">김</span>
|
||||
<div style="display: flex; flex-direction: column; line-height: 1.3">
|
||||
<span style="font-weight: 600">김아라</span>
|
||||
<span style="font-size: 12px; color: #667085">ara.kim@arasystem.it.kr</span>
|
||||
</div>
|
||||
<a href="Login.dc.html" style="display: inline-flex; align-items: center; gap: 6px; min-height: 44px; padding: 0 10px; border-radius: 8px; color: #4A5363; font-weight: 500; text-decoration: none">
|
||||
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"></path><polyline points="16 17 21 12 16 7"></polyline><line x1="21" y1="12" x2="9" y2="12"></line></svg>
|
||||
<span>로그아웃</span>
|
||||
</a>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div style="flex: 1 1 auto; display: flex; flex-wrap: wrap; align-items: stretch">
|
||||
|
||||
<nav aria-label="메일함" style="flex: 1 1 208px; box-sizing: border-box; display: flex; flex-direction: column; gap: 14px; padding: 14px 10px; background: #F8F9FB; border-right: 1px solid #E6E8EC">
|
||||
<a href="Compose.dc.html" style="display: flex; align-items: center; justify-content: center; gap: 8px; min-height: 44px; border-radius: 8px; background: #0E7C86; color: #FFFFFF; font-size: 13.5px; font-weight: 600; text-decoration: none">
|
||||
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12 20h9"></path><path d="M16.5 3.5a2.121 2.121 0 0 1 3 3L7 19l-4 1 1-4L16.5 3.5z"></path></svg>
|
||||
<span>메일 쓰기</span>
|
||||
</a>
|
||||
<ul style="list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 1px">
|
||||
<li><a href="Main.dc.html" aria-current="page" style="display: flex; align-items: center; gap: 10px; min-height: 40px; box-sizing: border-box; padding: 0 10px; border-radius: 7px; background: #E3F2F3; color: #0A5F67; font-weight: 600; text-decoration: none"><span style="flex: 1 1 auto">받은편지함</span><span style="font-size: 12px; font-weight: 600; font-variant-numeric: tabular-nums">2</span></a></li>
|
||||
<li><a href="#" style="display: flex; align-items: center; min-height: 40px; box-sizing: border-box; padding: 0 10px; border-radius: 7px; color: #344054; font-weight: 500; text-decoration: none">보낸편지함</a></li>
|
||||
<li><a href="#" style="display: flex; align-items: center; min-height: 40px; box-sizing: border-box; padding: 0 10px; border-radius: 7px; color: #344054; font-weight: 500; text-decoration: none">임시보관함</a></li>
|
||||
<li><a href="#" style="display: flex; align-items: center; min-height: 40px; box-sizing: border-box; padding: 0 10px; border-radius: 7px; color: #344054; font-weight: 500; text-decoration: none">스팸메일함</a></li>
|
||||
<li><a href="#" style="display: flex; align-items: center; min-height: 40px; box-sizing: border-box; padding: 0 10px; border-radius: 7px; color: #344054; font-weight: 500; text-decoration: none">휴지통</a></li>
|
||||
</ul>
|
||||
</nav>
|
||||
|
||||
<main style="flex: 999 1 560px; min-width: 0; display: flex; flex-wrap: wrap; align-items: stretch">
|
||||
|
||||
<section aria-labelledby="list-title" style="flex: 1 1 360px; min-width: 0; box-sizing: border-box; display: flex; flex-direction: column; border-right: 1px solid #E6E8EC">
|
||||
<div style="display: flex; align-items: center; flex-wrap: wrap; gap: 6px 8px; min-height: 52px; box-sizing: border-box; padding: 4px 8px 4px 16px; border-bottom: 1px solid #E6E8EC">
|
||||
<h2 id="list-title" style="margin: 0; font-size: 15px; font-weight: 700; letter-spacing: -0.01em">받은편지함</h2>
|
||||
<span style="font-size: 12px; color: #667085">{{ headCount }}</span>
|
||||
<button type="button" aria-pressed="{{ groupedStr }}" onClick="{{ toggleGroup }}" style="margin-left: auto; display: inline-flex; align-items: center; gap: 6px; min-height: 36px; box-sizing: border-box; padding: 0 10px; border: 1px solid {{ groupBorder }}; border-radius: 8px; background: {{ groupBg }}; color: {{ groupFg }}; font: inherit; font-size: 12.5px; font-weight: 600; cursor: pointer">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"></path></svg>
|
||||
<span>대화로 묶기</span>
|
||||
</button>
|
||||
</div>
|
||||
<ul aria-label="{{ listLabel }}" style="list-style: none; margin: 0; padding: 0">
|
||||
<sc-for list="{{ rows }}" as="r" hint-placeholder-count="6">
|
||||
<li style="border-bottom: 1px solid #EEF0F3; background: {{ r.bg }}">
|
||||
<button type="button" onClick="{{ r.pick }}" style="width: 100%; display: flex; flex-direction: column; justify-content: center; gap: 1px; min-height: 52px; box-sizing: border-box; padding: 6px 16px; border: 0; background: transparent; font: inherit; line-height: 1.4; color: inherit; text-align: left; cursor: pointer">
|
||||
<span style="display: flex; align-items: center; gap: 6px; width: 100%">
|
||||
<span style="min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-weight: {{ r.fw }}">{{ r.who }}</span>
|
||||
<sc-if value="{{ r.hasCount }}" hint-placeholder-val="{{ false }}"><span style="flex: 0 0 auto; font-size: 12px; font-weight: 500; color: #667085; font-variant-numeric: tabular-nums">{{ r.countText }}</span></sc-if>
|
||||
<span style="margin-left: auto; flex: 0 0 auto; font-size: 12px; color: {{ r.timeColor }}; font-weight: {{ r.timeFw }}; font-variant-numeric: tabular-nums">{{ r.time }}</span>
|
||||
</span>
|
||||
<span style="display: flex; align-items: center; gap: 6px; min-width: 0; width: 100%">
|
||||
<span style="min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-weight: {{ r.subFw }}">{{ r.subject }}</span>
|
||||
<sc-if value="{{ r.hasAtt }}" hint-placeholder-val="{{ false }}"><svg role="img" aria-label="첨부 있음" width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="#667085" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="flex: 0 0 auto"><path d="M21.44 11.05l-9.19 9.19a6 6 0 0 1-8.49-8.49l9.19-9.19a4 4 0 0 1 5.66 5.66l-9.2 9.19a2 2 0 0 1-2.83-2.83l8.49-8.48"></path></svg></sc-if>
|
||||
</span>
|
||||
</button>
|
||||
</li>
|
||||
</sc-for>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<div style="flex: 999 1 480px; min-width: 0; box-sizing: border-box; display: flex; flex-direction: column">
|
||||
<article aria-labelledby="mail-subject" style="display: flex; flex-direction: column">
|
||||
<div style="border-bottom: 1px solid #E6E8EC"><div style="display: flex; flex-wrap: wrap; align-items: center; gap: 2px; min-height: 52px; max-width: 800px; box-sizing: border-box; padding: 4px 32px 4px 22px">
|
||||
<a href="Compose.dc.html" style="display: inline-flex; align-items: center; gap: 6px; min-height: 44px; box-sizing: border-box; padding: 0 10px; border-radius: 8px; color: #344054; font-weight: 500; text-decoration: none">
|
||||
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="9 14 4 9 9 4"></polyline><path d="M20 20v-7a4 4 0 0 0-4-4H4"></path></svg>
|
||||
<span>답장</span>
|
||||
</a>
|
||||
<a href="Compose.dc.html" style="display: inline-flex; align-items: center; gap: 6px; min-height: 44px; box-sizing: border-box; padding: 0 10px; border-radius: 8px; color: #344054; font-weight: 500; text-decoration: none">
|
||||
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="7 17 2 12 7 7"></polyline><polyline points="12 17 7 12 12 7"></polyline><path d="M22 18v-2a4 4 0 0 0-4-4H7"></path></svg>
|
||||
<span>전체 답장</span>
|
||||
</a>
|
||||
<a href="Compose.dc.html" style="display: inline-flex; align-items: center; gap: 6px; min-height: 44px; box-sizing: border-box; padding: 0 10px; border-radius: 8px; color: #344054; font-weight: 500; text-decoration: none">
|
||||
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="15 14 20 9 15 4"></polyline><path d="M4 20v-7a4 4 0 0 1 4-4h12"></path></svg>
|
||||
<span>전달</span>
|
||||
</a>
|
||||
</div></div>
|
||||
<div style="display: flex; flex-direction: column; gap: 18px; max-width: 800px; box-sizing: border-box; padding: 20px 32px 36px">
|
||||
<h1 id="mail-subject" style="margin: 4px 0 0; font-size: 20px; line-height: 1.4; font-weight: 650; letter-spacing: -0.015em">{{ m.subject }}</h1>
|
||||
<div style="display: flex; align-items: flex-start; gap: 12px">
|
||||
<span aria-hidden="true" style="flex: 0 0 auto; display: inline-flex; align-items: center; justify-content: center; width: 36px; height: 36px; border-radius: 50%; background: #E3F2F3; color: #0A5F67; font-size: 14px; font-weight: 600">{{ m.initial }}</span>
|
||||
<div style="flex: 1 1 auto; min-width: 0; display: flex; flex-direction: column; gap: 1px">
|
||||
<div style="display: flex; flex-wrap: wrap; align-items: baseline; gap: 2px 8px">
|
||||
<span style="font-size: 13.5px; font-weight: 600">{{ m.fromName }}</span>
|
||||
<span style="font-size: 12.5px; color: #667085">{{ m.fromEmail }}</span>
|
||||
<span style="margin-left: auto; font-size: 12.5px; color: #667085; white-space: nowrap; font-variant-numeric: tabular-nums">{{ m.date }}</span>
|
||||
</div>
|
||||
<span style="font-size: 12.5px; color: #4A5363">받는 사람 김아라 <ara.kim@arasystem.it.kr></span>
|
||||
</div>
|
||||
</div>
|
||||
<div style="white-space: pre-line; font-size: 14px; line-height: 1.75; color: #1F2733; max-width: 680px">{{ m.body }}</div>
|
||||
|
||||
<sc-if value="{{ m.hasConv }}" hint-placeholder-val="{{ true }}">
|
||||
<section aria-labelledby="conv-title" style="display: flex; flex-direction: column; gap: 8px; padding-top: 16px; border-top: 1px solid #EEF0F3">
|
||||
<h2 id="conv-title" style="margin: 0; font-size: 12.5px; font-weight: 600; color: #4A5363">같은 대화 {{ m.convCount }}통</h2>
|
||||
<ol style="list-style: none; margin: 0; padding: 0; border: 1px solid #E6E8EC; border-radius: 8px; overflow: hidden">
|
||||
<sc-for list="{{ m.conv }}" as="c" hint-placeholder-count="4">
|
||||
<li style="border-top: {{ c.borderTop }}">
|
||||
<sc-if value="{{ c.isCurrent }}" hint-placeholder-val="{{ false }}">
|
||||
<div aria-current="true" style="display: flex; align-items: center; gap: 10px; min-height: 48px; box-sizing: border-box; padding: 6px 14px 6px 40px; background: #F2F8F8">
|
||||
<span aria-hidden="true" style="flex: 0 0 auto; display: inline-flex; align-items: center; justify-content: center; width: 28px; height: 28px; border-radius: 50%; background: #E3F2F3; color: #0A5F67; font-size: 12px; font-weight: 600">{{ c.initial }}</span>
|
||||
<span style="flex: 0 0 auto; font-weight: 600">{{ c.who }}</span>
|
||||
<span style="flex: 1 1 auto; min-width: 0; font-size: 12.5px; color: #0A5F67; font-weight: 600">지금 보고 있는 메일</span>
|
||||
<span style="flex: 0 0 auto; font-size: 12px; color: #667085; font-variant-numeric: tabular-nums">{{ c.date }}</span>
|
||||
</div>
|
||||
</sc-if>
|
||||
<sc-if value="{{ c.isOther }}" hint-placeholder-val="{{ true }}">
|
||||
<button type="button" class="row" aria-expanded="{{ c.expandedStr }}" onClick="{{ c.toggle }}" style="width: 100%; display: flex; align-items: center; gap: 10px; min-height: 48px; box-sizing: border-box; padding: 6px 14px; border: 0; background: #FFFFFF; font: inherit; color: inherit; text-align: left; cursor: pointer">
|
||||
<sc-if value="{{ c.expanded }}" hint-placeholder-val="{{ false }}"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="#667085" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" style="flex: 0 0 auto"><polyline points="6 9 12 15 18 9"></polyline></svg></sc-if>
|
||||
<sc-if value="{{ c.collapsed }}" hint-placeholder-val="{{ true }}"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="#667085" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" style="flex: 0 0 auto"><polyline points="9 18 15 12 9 6"></polyline></svg></sc-if>
|
||||
<span aria-hidden="true" style="flex: 0 0 auto; display: inline-flex; align-items: center; justify-content: center; width: 28px; height: 28px; border-radius: 50%; background: {{ c.avatarBg }}; color: {{ c.avatarFg }}; font-size: 12px; font-weight: 600">{{ c.initial }}</span>
|
||||
<span style="flex: 0 0 auto; font-weight: 600">{{ c.who }}</span>
|
||||
<sc-if value="{{ c.isSent }}" hint-placeholder-val="{{ false }}"><span style="flex: 0 0 auto; font-size: 12px; color: #667085">보낸편지함</span></sc-if>
|
||||
<span style="flex: 1 1 auto; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 12.5px; color: #667085">{{ c.preview }}</span>
|
||||
<span style="flex: 0 0 auto; font-size: 12px; color: #667085; font-variant-numeric: tabular-nums">{{ c.date }}</span>
|
||||
</button>
|
||||
<sc-if value="{{ c.expanded }}" hint-placeholder-val="{{ false }}">
|
||||
<div style="display: flex; flex-direction: column; gap: 10px; padding: 2px 16px 14px 78px">
|
||||
<div style="white-space: pre-line; font-size: 13.5px; line-height: 1.7; color: #1F2733">{{ c.body }}</div>
|
||||
<div style="display: flex; flex-wrap: wrap; gap: 4px; margin-left: -10px">
|
||||
<a href="Compose.dc.html" style="display: inline-flex; align-items: center; min-height: 36px; padding: 0 10px; border-radius: 8px; color: #0E7C86; font-weight: 600; text-decoration: none">답장</a>
|
||||
<a href="#" style="display: inline-flex; align-items: center; min-height: 36px; padding: 0 10px; border-radius: 8px; color: #0E7C86; font-weight: 600; text-decoration: none">이 메일만 열기</a>
|
||||
</div>
|
||||
</div>
|
||||
</sc-if>
|
||||
</sc-if>
|
||||
</li>
|
||||
</sc-for>
|
||||
</ol>
|
||||
</section>
|
||||
</sc-if>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</x-dc>
|
||||
<script type="text/x-dc" data-dc-script data-props='{"variant":{"editor":"enum","options":["묶음 켬","묶음 끔"],"default":"묶음 켬"},"$preview":{"width":1440,"height":900}}'>
|
||||
class Component extends DCLogic {
|
||||
state = { grouped: null, sel: 'c1', open: {} };
|
||||
|
||||
convs() {
|
||||
return [
|
||||
{
|
||||
id: 'c1', who: '이도윤, 나', count: 4, time: '오전 10:42', subject: 'Re: 유지보수 계약 갱신 건', unread: true, att: true,
|
||||
fromName: '이도윤', fromEmail: 'doyun.lee@arasystem.it.kr', date: '2026. 11. 5. (목) 오전 10:42',
|
||||
body: '김아라님, 안녕하세요.\n\n말씀하신 조건으로 계약서 초안을 고쳐 첨부했습니다.\n기간은 1년이고, 단가는 작년과 같습니다.\n\n이도윤 드림',
|
||||
conv: [
|
||||
{ id: 'a1', who: '나', initial: '김', sent: true, date: '10월 27일 (화)', preview: '다음 달에 끝나는 유지보수 계약을 1년 더 이어 가려고 합니다.', body: '이도윤님,\n\n다음 달에 끝나는 유지보수 계약을 1년 더 이어 가려고 합니다.\n조건을 정리해서 보내 주시면 검토하겠습니다.\n\n김아라' },
|
||||
{ id: 'a2', who: '이도윤', initial: '이', date: '10월 29일 (목)', preview: '네, 확인했습니다. 단가와 기간을 정리해서 다음 주 초에 보내 드리겠습니다.', body: '네, 확인했습니다.\n단가와 기간을 정리해서 다음 주 초에 보내 드리겠습니다.\n\n이도윤' },
|
||||
{ id: 'a3', who: '나', initial: '김', sent: true, date: '10월 29일 (목)', preview: '감사합니다. 초안은 다음 주 초까지 받으면 됩니다.', body: '감사합니다.\n초안은 다음 주 초까지 받으면 됩니다.\n\n김아라' },
|
||||
{ id: 'a4', who: '이도윤', initial: '이', current: true, date: '오늘 오전 10:42' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'c2', who: '인사팀', count: 1, time: '오전 9:05', subject: '[공지] 계약직 채용 일정 안내', unread: true,
|
||||
fromName: '인사팀', fromEmail: 'hr@arasystem.it.kr', date: '2026. 11. 5. (목) 오전 9:05',
|
||||
body: '구성원 여러분, 인사팀입니다.\n\n11월 계약직 채용은 아래 일정으로 진행합니다.\n서류는 11월 12일(목)까지 받습니다.', conv: [],
|
||||
},
|
||||
{
|
||||
id: 'c3', who: '정하늘, 나', count: 3, time: '어제', subject: 'Re: 신규 입사자 교육 회의록',
|
||||
fromName: '정하늘', fromEmail: 'haneul.jung@arasystem.it.kr', date: '2026. 11. 4. (수) 오후 3:12',
|
||||
body: '아라님,\n\n고친 회의록을 다시 보내 드립니다.\n교육은 다음 주 화요일 오전으로 정했습니다.\n\n정하늘',
|
||||
conv: [
|
||||
{ id: 'b1', who: '정하늘', initial: '정', date: '10월 28일 (수)', preview: '신규 입사자 교육 회의록을 공유합니다.', body: '아라님,\n\n신규 입사자 교육 회의록을 공유합니다.\n\n정하늘' },
|
||||
{ id: 'b2', who: '나', initial: '김', sent: true, date: '11월 2일 (월)', preview: '교육 날짜만 한 번 더 확인해 주실 수 있을까요?', body: '하늘님,\n\n교육 날짜만 한 번 더 확인해 주실 수 있을까요?\n\n김아라' },
|
||||
{ id: 'b3', who: '정하늘', initial: '정', current: true, date: '어제 오후 3:12' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'c4', who: '박준호', count: 1, time: '어제', subject: '11월 인프라 점검 일정 조율',
|
||||
fromName: '박준호', fromEmail: 'junho.park@arasystem.it.kr', date: '2026. 11. 4. (수) 오전 9:05',
|
||||
body: '인프라팀 여러분,\n\n11월 점검 일정을 정하려고 합니다.\n안 되시는 날을 알려 주세요.\n\n박준호', conv: [],
|
||||
},
|
||||
{
|
||||
id: 'c5', who: '최서연', count: 1, time: '10월 30일', subject: '11월 비용 정산 안내',
|
||||
fromName: '최서연', fromEmail: 'seoyeon.choi@arasystem.it.kr', date: '2026. 10. 30. (금) 오후 4:20',
|
||||
body: '안녕하세요, 재무팀 최서연입니다.\n\n10월분 비용 마감은 11월 4일(수)입니다.', conv: [],
|
||||
},
|
||||
{
|
||||
id: 'c6', who: '인사팀, 김민수, 나', count: 6, time: '10월 29일', subject: 'Re: 전사 회의 참석 안내', att: true,
|
||||
fromName: '김민수', fromEmail: 'minsu.kim@arasystem.it.kr', date: '2026. 10. 29. (목) 오후 6:02',
|
||||
body: '모두 고맙습니다.\n자리 배치는 첨부한 표대로 하겠습니다.\n\n김민수',
|
||||
conv: [
|
||||
{ id: 'd1', who: '인사팀', initial: '인', date: '10월 19일 (월)', preview: '10월 30일 오후 4시 전사 회의에 참석해 주세요.', body: '구성원 여러분, 인사팀입니다.\n\n10월 30일(금) 오후 4시 전사 회의에 참석해 주세요.' },
|
||||
{ id: 'd2', who: '나', initial: '김', sent: true, date: '10월 20일 (화)', preview: '운영팀은 모두 참석합니다.', body: '운영팀은 모두 참석합니다.\n\n김아라' },
|
||||
{ id: 'd3', who: '김민수', initial: '김', current: true, date: '10월 29일 오후 6:02' },
|
||||
],
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
messages() {
|
||||
return [
|
||||
{ id: 'c1', who: '이도윤', time: '오전 10:42', subject: 'Re: 유지보수 계약 갱신 건', unread: true, att: true },
|
||||
{ id: 'c2', who: '인사팀', time: '오전 9:05', subject: '[공지] 계약직 채용 일정 안내', unread: true },
|
||||
{ id: 'c3', who: '정하늘', time: '어제', subject: 'Re: 신규 입사자 교육 회의록' },
|
||||
{ id: 'c4', who: '박준호', time: '어제', subject: '11월 인프라 점검 일정 조율' },
|
||||
{ id: 'c5', who: '최서연', time: '10월 30일', subject: '11월 비용 정산 안내' },
|
||||
{ id: 'c1', who: '이도윤', time: '10월 29일', subject: '회신: 유지보수 계약 갱신 건', key: 'm6' },
|
||||
{ id: 'c6', who: '김민수', time: '10월 29일', subject: 'Re: 전사 회의 참석 안내', att: true },
|
||||
{ id: 'c3', who: '정하늘', time: '10월 28일', subject: '신규 입사자 교육 회의록', key: 'm8' },
|
||||
{ id: 'c6', who: '인사팀', time: '10월 19일', subject: '전사 회의 참석 안내', key: 'm9' },
|
||||
];
|
||||
}
|
||||
|
||||
renderVals() {
|
||||
const st = this.state;
|
||||
const variant = this.props.variant ?? '묶음 켬';
|
||||
const grouped = st.grouped ?? (variant === '묶음 켬');
|
||||
const convs = this.convs();
|
||||
const pickConv = (id) => this.setState({ sel: id, open: {} });
|
||||
|
||||
const rows = grouped
|
||||
? convs.map((c) => ({
|
||||
who: c.who,
|
||||
time: c.time,
|
||||
subject: c.subject,
|
||||
hasAtt: !!c.att,
|
||||
hasCount: c.count > 1,
|
||||
countText: c.count + '통',
|
||||
bg: c.id === st.sel ? '#E3F2F3' : '#FFFFFF',
|
||||
fw: c.unread ? '650' : '450',
|
||||
subFw: c.unread ? '600' : '400',
|
||||
timeFw: c.unread ? '600' : '400',
|
||||
timeColor: c.unread ? '#171C26' : '#667085',
|
||||
pick: () => pickConv(c.id),
|
||||
}))
|
||||
: this.messages().map((x) => ({
|
||||
who: x.who,
|
||||
time: x.time,
|
||||
subject: x.subject,
|
||||
hasAtt: !!x.att,
|
||||
hasCount: false,
|
||||
countText: '',
|
||||
bg: x.id === st.sel && !x.key ? '#E3F2F3' : '#FFFFFF',
|
||||
fw: x.unread ? '650' : '450',
|
||||
subFw: x.unread ? '600' : '400',
|
||||
timeFw: x.unread ? '600' : '400',
|
||||
timeColor: x.unread ? '#171C26' : '#667085',
|
||||
pick: () => pickConv(x.id),
|
||||
}));
|
||||
|
||||
const cur = convs.find((c) => c.id === st.sel) || convs[0];
|
||||
const conv = cur.conv.map((c, i) => {
|
||||
const expanded = !!st.open[c.id];
|
||||
return {
|
||||
...c,
|
||||
isCurrent: !!c.current,
|
||||
isOther: !c.current,
|
||||
isSent: !!c.sent,
|
||||
expanded: expanded,
|
||||
collapsed: !expanded,
|
||||
expandedStr: expanded ? 'true' : 'false',
|
||||
borderTop: i === 0 ? '0' : '1px solid #EEF0F3',
|
||||
avatarBg: c.sent ? '#F2F4F7' : '#E3F2F3',
|
||||
avatarFg: c.sent ? '#4A5363' : '#0A5F67',
|
||||
toggle: () => this.setState({ open: { ...st.open, [c.id]: !expanded } }),
|
||||
};
|
||||
});
|
||||
|
||||
return {
|
||||
rows: rows,
|
||||
headCount: grouped ? '대화 6개, 안 읽은 메일 2통' : '메일 9통, 안 읽은 메일 2통',
|
||||
listLabel: grouped ? '받은편지함 대화' : '받은편지함 메일',
|
||||
groupedStr: grouped ? 'true' : 'false',
|
||||
groupBg: grouped ? '#E3F2F3' : '#FFFFFF',
|
||||
groupFg: grouped ? '#0A5F67' : '#344054',
|
||||
groupBorder: grouped ? '#9FD3D7' : '#D0D5DD',
|
||||
toggleGroup: () => this.setState({ grouped: !grouped }),
|
||||
m: {
|
||||
subject: cur.subject,
|
||||
fromName: cur.fromName,
|
||||
fromEmail: cur.fromEmail,
|
||||
date: cur.date,
|
||||
body: cur.body,
|
||||
initial: cur.fromName.slice(0, 1),
|
||||
hasConv: conv.length > 1,
|
||||
convCount: conv.length,
|
||||
conv: conv,
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
+255
-180
@@ -52,6 +52,34 @@
|
||||
분할 보기 읽기·목록 보기 읽기·쓰기 세 곳이 같은 값을 쓰고, 툴바 첫 아이콘도 같은 32px 선에서 시작한다.
|
||||
- 브랜드 컬러 "바다 청록": 바탕 `#0B3640`, 강조 `#0E7C86`. 파생 색은 각 화면 원본의 인라인 스타일을 따른다.
|
||||
|
||||
## 화면 문구 — 사용자에게 말하듯 (2026-11-05 사용자 요청)
|
||||
|
||||
화면 · 알림 · 화면 낭독기(`aria-label` · `title`) · 칸 안내(placeholder)의 글은 설계 문서가 아니라 쓰는 사람에게 하는 말이다.
|
||||
원본은 화면 상수(`frontend/src/shared/constants/`)이고 이 문서는 옮겨 적는다.
|
||||
|
||||
1. **무엇이 되는지 · 무엇을 하면 되는지**를 쓴다. 만든 쪽의 사정(메일 서버 · 엔진 · 계정 · 판정 · 적용 · 동작 · 규칙 · 대상)은 빼거나 쉬운 말로 바꾼다.
|
||||
- ✗ 메일 서버에 계정이 있는 직원입니다 — 이름과 주소만 보입니다. → ○ 회사 메일을 쓰는 직원이라 이름과 메일 주소만 보입니다.
|
||||
2. **줄표(—)로 잇지 않는다.** 두 문장으로 나누거나 "~해서 · ~니 · ~지만"으로 잇는다. 짧은 상태 표시도 줄표 대신 한 구절로(✗ 실패 — 비밀번호 틀림 → ○ 비밀번호가 틀려 실패).
|
||||
결과 칸이 따로 있는 표라면 까닭만 써도 된다.
|
||||
3. **「」 를 쓰지 않는다.** 화면의 단추 · 메뉴 · 칸 이름은 [대괄호](직원 공지와 같다 — [설정] → [2단계 인증]), 사용자가 넣은 값(이름 · 그룹 이름 · 파일 이름)은 지금처럼 ‘ ’.
|
||||
4. **규칙을 정의하듯 쓰지 않는다.**
|
||||
- ✗ 이 주소에서 오는 메일은 스팸메일함으로 갑니다. 부재중 자동 응답도 보내지 않습니다. 「@도메인」은 그 도메인에서 오는 메일 모두입니다.
|
||||
- ○ 차단한 주소에서 온 메일은 스팸메일함으로 옮겨지고, 부재중 자동 응답도 보내지 않습니다. @spam.example 처럼 @로 시작해 넣으면 그 도메인에서 오는 메일을 모두 막습니다.
|
||||
5. **가운뎃점(·) 나열은 쉼표로**, 괄호 속 보충은 문장으로 풀거나 뺀다(꼭 필요한 예시만 괄호). 입력 칸 안내(placeholder)는 짧게.
|
||||
- ✗ 이름 · 주소 · 회사 · 전화번호 — 초성도 됩니다 (ㅂㅈㅎ) → ○ 이름, 주소, 회사, 전화번호, 초성(ㅂㅈㅎ)으로 찾기
|
||||
6. **말끝은 지금처럼 존댓말** "~합니다 · ~해 주세요"(해요체로 바꾸지 않는다). 딱딱한 명령("~하세요" · "~하지 마세요")은 "~해 주세요 · ~하지 않으셔도 됩니다"처럼 부드럽게.
|
||||
단추 · 칸 이름 · 짧은 상태 꼬리표는 명사형 그대로.
|
||||
7. **위험 · 보안 문구는 겁주지 않되 분명하게** — 무엇이 막혔고 무엇을 하면 되는지를 함께.
|
||||
8. **뜻은 그대로** — 한도 · 날짜 · 개수 · 조건 같은 정보를 빼지 않는다. 문장만 바꾼다. 영어 문구(공개 내려받기 화면)는 자연스러운 영어로만 다듬는다.
|
||||
|
||||
적용하며 정한 것(2026-11-05):
|
||||
- 한 줄에 여러 정보를 늘어놓는 짧은 표시의 가운뎃점은 나열이 아니라 칸 구분이라 그대로 둔다 — 파일 줄 "1.2 GB · 3회 받음 · 11월 29일까지", "‘회의’ · 6통",
|
||||
첨부 안내 "파일당 최대 20MB · 합계 25MB · 최대 20개", 탭 제목 "메일 · ARA SYSTEM". 메뉴 · 화면 이름("보내기 · 읽기")도 이름이라 그대로다.
|
||||
- 이 문서의 설명 글은 지금처럼 「」 로 화면 요소를 가리킨다 — 위 규칙은 화면에 보이는 글에만 쓴다.
|
||||
- 디자인 보드(`*.dc.html`)의 문구는 11-05 전의 옛 글이다 — 문구는 이 문서와 화면 상수가 기준이다.
|
||||
- 서버가 만드는 글(에러 코드 사전 · 받은편지함 알림 메일 · 본문의 대용량 첨부 덩이)도 같은 규칙으로 맞췄다(2026-11-05) — 이 문서에 옮겨 적은 그 글(13단계 알림 메일 등)도 새 글이다.
|
||||
알림 메일의 메뉴 경로도 화면 글처럼 "웹메일의 [설정] → [앱 비밀번호]" 꼴로 적는다.
|
||||
|
||||
## 메일 쓰기 (2026-10-27 확정 — `Compose` · `ComposeEditor`)
|
||||
|
||||
위에서부터 머리(제목·닫기) → 받는 사람(→ 참조·숨은참조) → 제목 → 본문 툴바 → 본문 → 첨부 → 보내기 줄이다. 모두 콘텐츠 열 안에 있다.
|
||||
@@ -126,7 +154,7 @@
|
||||
Enter = 적용, Esc = 닫고 본문으로. 주소는 http(s) · mailto 만 받는다.
|
||||
- **표 넣기**(더보기 ›): 8열 × 6행 격자에서 고른다("3행 × 4열"). 표 안의 행·열 추가·삭제는 다음 단계.
|
||||
- **메뉴·팝오버 공통**: 흰 바탕 · 테두리 #E6E8EC · 모서리 10 · 그림자 `--shadow-toast` · 툴바 아래 선 바로 밑에서 연다. Esc 로 닫는다.
|
||||
- **빈 본문**: 안내 문구 "내용을 입력하세요" · #667085(4.9:1). 커서는 문구 앞에 선다.
|
||||
- **빈 본문**: 안내 문구 "내용을 입력해 주세요" · #667085(4.9:1). 커서는 문구 앞에 선다.
|
||||
- **문단**: 문단 사이 여백은 0 — Enter 한 번이 한 줄이다(지금 textarea 와 같다). 줄 간격은 1.75 이고, 보내는 본문도 같은 값이다
|
||||
(감싸는 div 의 `line-height` — 받는 사람이 편집기와 같은 모양으로 본다, 2026-10-27 구현하며 정함).
|
||||
여러 칸 띄어쓰기 · 줄 앞 공백도 받는 쪽에서 그대로 보이게 보낸다. 탭은 다음 8칸 자리까지의 공백이 된다(편집 영역의 탭 간격과 같다, 2026-10-27).
|
||||
@@ -212,7 +240,7 @@
|
||||
그림을 고르면 칸 아래에 크기 줄(작게 96px · 보통 144px · 원본 — 최대 600px · 그림 빼기). 메일 안에 함께 실어 보내므로
|
||||
받는 쪽에서 외부 이미지처럼 막히지 않는다. 지금 있는 흰 로고는 흰 메일 바탕에서 보이지 않으니 서명에는 색이 있는 로고를 쓴다.
|
||||
- 서명을 넣을 메일: 새 메일 · 답장 · 전달(인용한 원문 위) — 둘 다 기본으로 켠다. 미리 보기에서 [새 메일 | 답장]을 바꿔 본다.
|
||||
- [저장] → "저장했습니다. 새로 여는 쓰기 화면부터 들어갑니다."(#067647).
|
||||
- [저장] → "저장했습니다. 다음에 메일을 쓸 때부터 들어갑니다."(#067647).
|
||||
- 쓰기 화면을 열 때 서명을 보통 글로 넣어 둔다 — 새 메일은 본문 끝(빈 줄 하나 뒤), 답장 · 전달은 인용 위. 그 메일에서 고치거나 지울 수 있고
|
||||
임시 저장에도 남는다. 임시 저장본을 이어 쓸 때는 서명을 다시 넣지 않는다. 본문에 붙여 넣은 다른 그림은 지금처럼 첨부로 올린다.
|
||||
- 서명 그림처럼 **본문 안에 그림으로 보인 부분은 첨부로 보이지 않는다** — 읽기 화면의 첨부 목록과 목록의 첨부 클립에서 뺀다
|
||||
@@ -246,12 +274,12 @@ API 와 서버 규칙은 계약 2절 "새 메일 확인" · "검색" · "여러
|
||||
- 결과는 보던 보기 방식(분할 · 목록) 그대로 목록 자리에 보인다.
|
||||
- 머리(52): [←](40×44 · "검색 닫기") · "검색 결과"(15px 굵게) · "‘회의’ · 6통"(12px #667085) · 보기 전환.
|
||||
- 그 아래 범위 줄(48 · 바탕 #FBFBFC): "찾을 곳" + [그 메일함 | 전체 메일함](36 — 설정 화면의 전환과 같은 밀도 예외)
|
||||
+ 전체일 때 "휴지통 · 스팸메일함은 빼고 찾습니다"(12px #667085).
|
||||
+ 전체일 때 "휴지통과 스팸메일함은 빼고 찾습니다"(12px #667085).
|
||||
- 전체 메일함에서 찾으면 행에 메일함을 보인다 — 분할 보기는 첫 줄 시각 앞에 "보낸편지함 ·"(12px #667085),
|
||||
목록 보기 표는 보낸 사람 뒤에 메일함 칸(96). 보낸편지함 · 임시보관함의 메일은 보낸 사람 자리에 "받는 사람: 이름".
|
||||
- 검색 결과에서 연 메일의 읽기 화면은 받는 사람 아래에 "메일함 받은편지함" 한 줄(12.5px #667085)을 더한다.
|
||||
- 결과 없음: 검색 아이콘 · "‘…’에 맞는 메일이 없습니다" · 한계 한 줄 "낱말 단위로 찾습니다. 붙여 쓴 말의 일부(‘서버이전’의 ‘서버’)나
|
||||
제목 맨 앞의 말머리([공지] · Re:)로는 찾지 못합니다." · 한 메일함에서 찾았으면 [전체 메일함에서 찾기].
|
||||
- 결과 없음: 검색 아이콘 · "‘…’에 맞는 메일이 없습니다" · 한계 한 줄 "낱말 단위로 찾습니다. ‘서버이전’의 ‘서버’처럼 붙여 쓴 말의 일부나,
|
||||
제목 맨 앞의 [공지]나 Re: 같은 말머리로는 찾지 못합니다." · 한 메일함에서 찾았으면 [전체 메일함에서 찾기].
|
||||
- 찾는 동안은 지금의 목록 뼈대를 보인다.
|
||||
|
||||
### 여러 통 처리
|
||||
@@ -284,18 +312,18 @@ API 와 서버 규칙은 계약 1절 "`PUT /api/auth/password`", 2절 "메일함
|
||||
### 비밀번호
|
||||
|
||||
- 칸 셋(최대 폭 420): 지금 비밀번호 · 새 비밀번호 · 새 비밀번호 확인. 칸 높이 44, 앞의 두 칸 끝에 보기/숨기기 단추(44 · 눈 아이콘, `aria-pressed`).
|
||||
- 새 비밀번호 아래 안내 "8자 이상 · 지금 비밀번호와 달라야 합니다. 영문 · 숫자 · 기호를 섞으면 더 안전합니다." (12.5px #667085).
|
||||
- 새 비밀번호 아래 안내 "8자 이상이고 지금 비밀번호와 달라야 합니다. 영문, 숫자, 기호를 섞으면 더 안전합니다." (12.5px #667085).
|
||||
- 오류는 그 칸 아래 빨간 글(#B42318, 아이콘 alert-circle) · 칸 테두리도 빨강 — "지금 비밀번호가 올바르지 않습니다." · "새 비밀번호가 서로 다릅니다."
|
||||
- [비밀번호 바꾸기] → "비밀번호를 바꿨습니다. 다른 기기 · 브라우저의 로그인은 끊었습니다."(#067647), 세 칸을 비운다.
|
||||
- [비밀번호 바꾸기] → "비밀번호를 바꿨습니다. 다른 기기와 브라우저에서는 로그아웃했습니다."(#067647), 세 칸을 비운다.
|
||||
- 아래 안내 상자(바탕 #F3F8F8): 다른 기기의 웹메일 로그인이 끊기는 것과 메일 프로그램(IMAP)에 새 비밀번호를 다시 넣어야 하는 것.
|
||||
|
||||
### 부재중 자동 응답
|
||||
|
||||
- [x] 부재중 자동 응답 사용(44). 끄면 아래 칸을 흐리게(쓴 글은 남긴다).
|
||||
- 기간: (•) 끌 때까지 계속 / ( ) 기간 정하기 → 시작 · 끝 날짜 칸(44). "시작일 0시부터 끝나는 날 자정까지 답합니다 (이 브라우저의 시간대 기준)."
|
||||
- 제목(44, 최대 폭 520) · 내용(여러 줄, 글자만 — "글자만 보냅니다 (서식 · 그림 없음). 서명은 붙지 않습니다.").
|
||||
- [저장] → "저장했습니다. 10월 30일(금)부터 11월 3일(화)까지 자동으로 답합니다." / "끌 때까지 자동으로 답합니다." / 끄면 "자동 응답을 껐습니다 — 써 둔 글은 남겨 둡니다."
|
||||
- 미리 보기 "보낸 사람이 받는 답장"(보낸 사람 · 제목 · 내용) + "같은 사람에게 매번 보내지는 않습니다(메일 엔진 규칙)."
|
||||
- 기간: (•) 끌 때까지 계속 / ( ) 기간 정하기 → 시작 · 끝 날짜 칸(44). "시작일 0시부터 끝나는 날 자정까지 답합니다. 시각은 이 브라우저의 시간대를 따릅니다."
|
||||
- 제목(44, 최대 폭 520) · 내용(여러 줄, 글자만 — "서식과 그림 없이 글자만 보내고, 서명은 붙지 않습니다.").
|
||||
- [저장] → "저장했습니다. 10월 30일(금)부터 11월 3일(화)까지 자동으로 답합니다." / "끌 때까지 자동으로 답합니다." / 끄면 "자동 응답을 껐고, 써 둔 글은 그대로 남아 있습니다."
|
||||
- 미리 보기 "보낸 사람이 받는 답장"(보낸 사람 · 제목 · 내용) + "같은 사람에게는 매번 보내지 않습니다."
|
||||
- 켜져 있는 동안 메일 화면의 메일함 패널 아래(사용량 위)에 안내(바탕 #FFF7E6 · #7A5200, 높이 44 이상): "부재중 자동 응답 켜짐" / "11월 3일까지 · 설정" — 누르면 이 설정으로.
|
||||
|
||||
### 메일함 관리 (메일함 패널)
|
||||
@@ -304,7 +332,7 @@ API 와 서버 규칙은 계약 1절 "`PUT /api/auth/password`", 2절 "메일함
|
||||
- 각 메일함 행 오른쪽에 ⋯ 단추(32×40 — 별 칸과 같은 밀도 예외). 마우스를 올리거나 초점이 가면 보이고, 메뉴가 열린 동안은 계속 보인다. 숫자(안 읽음)는 ⋯ 앞에 그대로.
|
||||
- ⋯ 메뉴(항목 40): 모두 읽음으로 표시(안 읽은 메일이 있을 때) · 하위 메일함 만들기(받은편지함 · 내 메일함) · 이름 바꾸기 · 지우기(빨강 — 내 메일함만) ·
|
||||
휴지통 비우기 / 스팸메일함 비우기(빨강 — 그 메일함에서만).
|
||||
- 대화상자(공용 모양): 새 메일함 · "‘프로젝트’ 안에 새 메일함" · 메일함 이름 바꾸기 — 이름 칸(44, 64자) + 안내 "64자까지. 같은 자리에 같은 이름은 쓸 수 없습니다."
|
||||
- 대화상자(공용 모양): 새 메일함 · "‘프로젝트’ 안에 새 메일함" · 메일함 이름 바꾸기 — 이름 칸(44, 64자) + 안내 "64자까지 쓸 수 있고, 같은 자리에 같은 이름은 쓸 수 없습니다."
|
||||
(같은 이름이면 그 자리에 빨강 "같은 이름의 메일함이 이미 있습니다."). 지우기 — "‘거래처’ 메일함을 지울까요?" "안의 메일 12통은 휴지통으로 옮깁니다. 지운 메일함은 되돌릴 수 없습니다." [지우기](빨강 채움) [취소].
|
||||
하위 메일함이 있으면 "‘프로젝트’ 메일함을 지울 수 없습니다" "하위 메일함이 있습니다. 하위 메일함을 먼저 지워 주세요." [확인].
|
||||
- 휴지통 · 스팸메일함 목록 머리 오른쪽에 [휴지통 비우기] / [스팸메일함 비우기](빨강 글자 · 44). 확인 "휴지통의 메일 21통을 모두 영구 삭제할까요?" "지운 메일은 되돌릴 수 없습니다." [영구 삭제] [취소].
|
||||
@@ -316,8 +344,8 @@ API 와 서버 규칙은 계약 1절 "`PUT /api/auth/password`", 2절 "메일함
|
||||
- 여러 통 도구 줄: 이동 ▾ 과 삭제 사이에 「스팸 신고」(스팸메일함에서는 「스팸 아님」).
|
||||
- 오래 걸리는 처리(모두 읽음 · 비우기 · 메일함 지우기): 알림이 "받은편지함의 메일을 읽음으로 표시하는 중 · 600 / 1,234통"(돌아가는 아이콘)으로 남았다가
|
||||
끝나면 "받은편지함의 메일 1,234통을 읽음으로 표시했습니다." 처리하는 동안 다른 일을 해도 된다.
|
||||
- 알림 문구: "메일 2통을 스팸메일함으로 옮겼습니다." [되돌리기] · "메일 1통을 받은편지함으로 옮겼습니다 (스팸 아님)." [되돌리기] ·
|
||||
"‘협력사’ 메일함을 만들었습니다." · "‘거래처’ 메일함을 지웠습니다. 안의 메일 12통은 휴지통에 있습니다." · "휴지통을 비웠습니다 — 메일 21통." (비우기 · 지우기는 되돌리기 없음)
|
||||
- 알림 문구: "메일 2통을 스팸메일함으로 옮겼습니다." [되돌리기] · "메일 1통을 스팸이 아닌 메일로 표시해 받은편지함으로 옮겼습니다." [되돌리기] ·
|
||||
"‘협력사’ 메일함을 만들었습니다." · "‘거래처’ 메일함을 지웠습니다. 안의 메일 12통은 휴지통에 있습니다." · "메일 21통을 지워 휴지통을 비웠습니다." (비우기 · 지우기는 되돌리기 없음)
|
||||
|
||||
## 5단계 — 실시간 새 메일 · 바탕화면 알림 (2026-10-28 확정 — `SettingsNotify` · `MailRealtime`)
|
||||
|
||||
@@ -327,19 +355,19 @@ API 와 서버 규칙은 계약 2절 "실시간 알림".
|
||||
|
||||
- 메뉴의 "메일" 묶음 끝에 「알림」(종 아이콘).
|
||||
- [x] 새 메일이 오면 바탕화면 알림 보내기(44). 켜면 브라우저가 허락을 묻는다. 칸 아래 안내(12.5px):
|
||||
묻기 전 "켜면 브라우저가 알림을 허락할지 한 번 묻습니다 — 「허용」을 눌러 주세요." · 허용됨 "이 브라우저에서 알림이 허용되어 있습니다."(#067647, 체크 아이콘) ·
|
||||
막힘(칸을 끄고 빨강 #B42318) "브라우저가 이 사이트의 알림을 막고 있습니다. 주소창 왼쪽의 사이트 정보(자물쇠) › 알림을 「허용」으로 바꾼 뒤 이 화면을 새로 고쳐 주세요." ·
|
||||
묻기 전 "켜면 브라우저가 알림을 허락할지 한 번 묻습니다. [허용]을 눌러 주세요." · 허용됨 "이 브라우저에서 알림이 허용되어 있습니다."(#067647, 체크 아이콘) ·
|
||||
막힘(칸을 끄고 빨강 #B42318) "브라우저가 이 사이트의 알림을 막고 있습니다. 주소창 왼쪽의 사이트 정보(자물쇠)를 눌러 [알림]을 [허용]으로 바꾼 뒤 이 화면을 새로 고쳐 주세요." ·
|
||||
지원하지 않음 "이 브라우저는 바탕화면 알림을 지원하지 않습니다. 새 메일은 그래도 바로 목록에 나타납니다."
|
||||
- 알림에 보일 것: [x] 메일 제목도 보이기 — "끄면 보낸 사람 이름만 보입니다 — 화면을 다른 사람과 함께 보는 자리라면 꺼 두세요." (알림을 끄면 흐리게)
|
||||
- 알림에 보일 것: [x] 메일 제목도 보이기 — "끄면 보낸 사람 이름만 보입니다. 다른 사람과 화면을 함께 보는 자리라면 꺼 두는 것이 좋습니다." (알림을 끄면 흐리게)
|
||||
- [시험 알림 보내기](보조 단추 44 · 종 아이콘) — 알림 "ARA 웹메일" / "알림이 잘 옵니다. 새 메일이 오면 이렇게 알려 드립니다."
|
||||
- 실시간 연결: ● 초록 "연결되어 있습니다 — 새 메일이 오면 바로 목록에 나타납니다." / 끊겼을 때 "연결할 수 없어 1분마다 확인합니다." (저절로 다시 잇는다)
|
||||
- 실시간 연결: ● 초록 "연결되어 있어 새 메일이 오면 바로 목록에 나타납니다." / 끊겼을 때 "연결할 수 없어 1분마다 확인합니다." (저절로 다시 잇는다)
|
||||
- 아래 안내 상자(바탕 #F3F8F8): 이 브라우저에만 적용 · 웹메일 탭이 하나라도 열려 있어야 · 웹메일 화면을 보고 있을 때는 보내지 않음.
|
||||
|
||||
### 목록 위에서 한 번 권하기
|
||||
|
||||
- 알림을 켜지도 닫지도 않은 사람에게 메일 화면의 목록 머리 아래에 40 줄(바탕 #F3F8F8 — 새 메일 알림 줄과 같은 높이, 밀도 예외):
|
||||
종 아이콘 + "바탕화면 알림을 켜 두면 다른 창을 보는 동안에도 새 메일을 알려 드립니다." [알림 켜기](강조 글자 굵게) [✕ 닫기].
|
||||
- [알림 켜기] → 브라우저가 묻는다 → 허용: 알림 "바탕화면 알림을 켰습니다." / 막힘: 오류 알림 "브라우저가 알림을 막고 있습니다. 푸는 방법은 설정 › 알림에 있습니다." [설정 열기].
|
||||
- [알림 켜기] → 브라우저가 묻는다 → 허용: 알림 "바탕화면 알림을 켰습니다." / 막힘: 오류 알림 "브라우저가 알림을 막고 있습니다. 푸는 방법은 [설정] → [알림]에서 볼 수 있습니다." [설정 열기].
|
||||
- [✕]는 다시 보이지 않는다(브라우저 · 사용자마다). 브라우저가 이미 막았거나 지원하지 않으면 보이지 않는다.
|
||||
|
||||
### 바탕화면 알림
|
||||
@@ -364,14 +392,14 @@ API 와 서버 규칙은 계약 2절 "실시간 알림".
|
||||
- 메뉴는 메일 묶음의 서명 · 부재중 자동 응답 · **필터** · 알림 (깔때기 아이콘). 머리는 "필터" + 설명 한 줄 + 오른쪽 [새 필터](브랜드 색).
|
||||
- 목록은 테두리 상자 하나에 행 60 — 순서 번호 · 켜기 스위치(36×20) · 이름(굵게)과 요약 한 줄 · ▲ ▼(32×40, 밀도 예외 — 메일함 ⋯ 와 같다). 행을 누르면 고친다.
|
||||
요약은 "보낸 사람에 ‘@hanbit-partners.co.kr’ 포함 → 옮기기: 거래처" — 조건은 " 그리고 " / " 또는 ", 할 일은 " · ". 이름이 없으면 조건이 이름 자리에 온다.
|
||||
꺼 둔 규칙은 글자를 흐리게. 옮길 메일함이 지워진 규칙은 빨간 안내 한 줄 "옮길 메일함이 지워져 받은편지함으로 들어옵니다 — 눌러서 고치세요."
|
||||
- 목록 아래 "필터 N개 · 50개까지 만들 수 있습니다." 와 안내 상자(차례로 적용 · 옮기면 멈춤 / 스팸 제외 / 사내 전달만 · 한 곳 · 사본 남김 / 부재중과 함께 동작).
|
||||
꺼 둔 규칙은 글자를 흐리게. 옮길 메일함이 지워진 규칙은 빨간 안내 한 줄 "옮길 메일함이 지워져 메일이 받은편지함으로 들어옵니다. 눌러서 고쳐 주세요."
|
||||
- 목록 아래 "필터 N개를 만들었고, 50개까지 만들 수 있습니다." 와 안내 상자(차례로 적용 · 옮기면 멈춤 / 스팸 제외 / 사내 전달만 · 한 곳 · 사본 남김 / 부재중과 함께 동작).
|
||||
|
||||
### 고치기 창 (모달 620)
|
||||
|
||||
- 이름(선택) → "어떤 메일에"(조건이 모두 · 하나라도 · 모든 메일) → 조건 줄(어디에서 116 · 포함/포함하지 않음 132 · 글 · ✕ 36, 5개까지 [조건 더하기])
|
||||
→ "할 일"(메일함으로 옮기기 + 메일함 고르기 · 읽음으로 표시 · 중요 표시 · 동료에게 전달 + 사내 주소) → 안내 띠 → [이 필터 지우기](고칠 때만, 왼쪽) · [저장] [취소].
|
||||
- 메일함 고르기에는 받은편지함 · 내 메일함 · 스팸메일함 · 휴지통(보낸편지함 · 임시보관함 없음 — 보관함은 구현하며 더했다, 아래 "6단계 구현하며 정한 보완"). 지워진 메일함이면 "(지워진 메일함) — 다른 메일함을 고르세요"가 골라진 채로.
|
||||
- 메일함 고르기에는 받은편지함 · 내 메일함 · 스팸메일함 · 휴지통(보낸편지함 · 임시보관함 없음 — 보관함은 구현하며 더했다, 아래 "6단계 구현하며 정한 보완"). 지워진 메일함이면 "지워진 메일함이니 다른 메일함을 골라 주세요"가 골라진 채로.
|
||||
- 잘못은 [저장]을 누를 때 칸 아래 빨간 글로만 — 빈 조건 · `${` · 바깥 주소 전달 · 할 일 없음 (`MailFilterStates` 의 모음).
|
||||
|
||||
### 상태 · 들어오는 길 (`MailFilterStates`)
|
||||
@@ -387,21 +415,21 @@ API 와 서버 규칙은 계약 2절 "실시간 알림".
|
||||
|
||||
### 고치기 창 — 「받은편지함에 이미 있는 메일에도 적용」
|
||||
|
||||
- 할 일 아래, 6단계의 안내 띠 자리에 회색 상자 하나: 체크 칸(이름 굵게) + 칸 아래 설명(12.5). 상자 아래 한 줄 "스팸으로 판정되어 들어오는 메일에는 적용하지 않습니다."
|
||||
- 할 일 아래, 6단계의 안내 띠 자리에 회색 상자 하나: 체크 칸(이름 굵게) + 칸 아래 설명(12.5). 상자 아래 한 줄 "스팸으로 분류된 메일은 필터를 거치지 않습니다."
|
||||
- 창을 열 때마다 꺼져 있다 — 필터의 설정이 아니라 이번 저장에 한 번 쓴다(읽기 화면에서 만드는 새 필터도 같다). 목록의 행에는 단추를 더하지 않는다 — 있는 필터는 창을 열어 고르고 저장한다.
|
||||
- 설명은 "저장한 뒤 받은편지함에서 조건에 맞는 메일을 찾아 바로 적용합니다." — 전달이 있는 필터면 " 전달은 새로 오는 메일에만 합니다."를 더한다.
|
||||
- 설명은 "저장하면 받은편지함에서 조건에 맞는 메일을 찾아 바로 정리합니다." — 전달이 있는 필터면 " 전달은 새로 오는 메일에만 합니다."를 더한다.
|
||||
- 고를 수 없는 두 경우(칸을 끄고 이름을 흐리게, 설명이 까닭):
|
||||
전달만 하는 필터 "전달만 하는 필터는 이미 받은 메일에 할 일이 없습니다 — 전달은 새로 오는 메일에만 합니다." ·
|
||||
이 탭에서 다른 필터를 적용하는 중 "다른 필터를 받은편지함에 적용하는 중입니다. 끝난 뒤에 고를 수 있습니다."
|
||||
전달만 하는 필터 "전달은 새로 오는 메일에만 해서, 전달만 하는 필터는 이미 받은 메일에 할 일이 없습니다." ·
|
||||
이 탭에서 다른 필터를 적용하는 중 "다른 필터로 받은편지함을 정리하는 중입니다. 끝난 뒤에 고를 수 있습니다."
|
||||
- 목록 머리의 설명과 안내 상자의 "새로 오는 메일에만"을 바꾼다(`MailFilterApply` 보드의 글 — 이미 받은 메일 줄을 더하고, 전달 줄에 "새로 오는 메일만").
|
||||
|
||||
### 저장한 뒤 — 진행 · 결과 알림
|
||||
|
||||
- "필터를 저장했습니다." 대신 4단계의 진행 알림: "‘거래처 메일’ 필터를 받은편지함에 적용하는 중 · 4,200 / 8,412통 확인"(확인한 수 / 받은편지함 메일 수 — 최근 메일부터).
|
||||
끝나면 그 자리에서 "‘거래처 메일’ 필터를 받은편지함의 메일 37통에 적용했습니다." + 옮기는 필터면 [거래처 보기](그 메일함으로) —
|
||||
- "필터를 저장했습니다." 대신 4단계의 진행 알림: "‘거래처 메일’ 필터로 받은편지함을 정리하는 중 · 4,200 / 8,412통 확인"(확인한 수 / 받은편지함 메일 수 — 최근 메일부터).
|
||||
끝나면 그 자리에서 "‘거래처 메일’ 필터로 받은편지함의 메일 37통을 정리했습니다." + 옮기는 필터면 [거래처 보기](그 메일함으로) —
|
||||
읽음 · 중요만 하는 필터는 단추 없음, 맞는 메일이 없으면 "받은편지함에 ‘거래처 메일’ 필터에 맞는 메일이 없습니다."
|
||||
- 멈추면 "‘거래처 메일’ 필터 — 8,412통 가운데 4,200통까지 확인하고 멈췄습니다(12통에 적용). 다시 시도해 주세요." + [다시 시도](남은 메일부터 이어서).
|
||||
처음 요청부터 실패하면 "필터는 저장했지만 받은편지함에 적용하지 못했습니다. 다시 시도해 주세요." + [다시 시도]. 다른 창 · PC 에서 적용 중이면 "다른 창에서 필터를 받은편지함에 적용하는 중입니다. 끝난 뒤 다시 시도해 주세요."
|
||||
- 멈추면 "‘거래처 메일’ 필터로 8,412통 가운데 4,200통까지 확인하고 멈췄습니다. 그동안 12통을 정리했습니다. 다시 시도해 주세요." + [다시 시도](남은 메일부터 이어서).
|
||||
처음 요청부터 실패하면 "필터는 저장했지만 받은편지함은 정리하지 못했습니다. 다시 시도해 주세요." + [다시 시도]. 다른 창 · PC 에서 적용 중이면 "다른 창에서 필터로 받은편지함을 정리하는 중입니다. 끝난 뒤 다시 시도해 주세요."
|
||||
- 그동안 다른 화면으로 가서 메일을 읽고 써도 이어 한다. 탭을 닫으면 멈춘다(이어 하지 않는다). 세션이 바뀌면 멈추고 알림을 닫는다.
|
||||
|
||||
### 저장하지 않고 닫을 때 (6단계 한계를 고친다)
|
||||
@@ -414,7 +442,7 @@ API 와 서버 규칙은 계약 2절 "실시간 알림".
|
||||
### 부재중 자동 응답 화면 — 엔진에서 규칙이 꺼졌을 때 (6단계 한계를 고친다)
|
||||
|
||||
- 자동 응답을 켜 두었는데(저장한 값) 엔진에서 우리 규칙이 꺼져 있으면(`isActive: false`) 머리 아래 노란 줄(필터 화면의 줄과 같은 모양):
|
||||
"켜 둔 자동 응답이 지금은 동작하지 않습니다. 메일 서버의 규칙이 다른 곳에서 바뀌었습니다 — 아래 [저장]을 누르면 다시 켜집니다."
|
||||
"다른 곳에서 메일 설정이 바뀌어 켜 둔 자동 응답이 지금은 나가지 않습니다. 아래 [저장]을 누르면 다시 켜집니다."
|
||||
- 단추는 없다 — 저장 단추는 늘 누를 수 있다. 꺼 두었으면 보이지 않는다. 저장하면 필터도 함께 다시 켜진다.
|
||||
|
||||
## 8단계 — 주소록 (2026-10-29 확정 — `Contacts` · `ContactsEdit` · `ContactsMail`)
|
||||
@@ -425,31 +453,31 @@ API 와 서버 규칙은 계약 2절 "실시간 알림".
|
||||
### 주소록 화면 (`Contacts`)
|
||||
|
||||
- 자리: 레일 · 상단 바 · 모듈 패널(208) · 목록(384) · 상세 — 메일의 분할 보기와 같다. 상단 바의 찾기 칸은 **지금 보는 목록 안에서** 찾는다
|
||||
("이름 · 주소 · 회사 · 전화번호 — 초성도 됩니다 (ㅂㅈㅎ)").
|
||||
("이름, 주소, 회사, 전화번호, 초성(ㅂㅈㅎ)으로 찾기").
|
||||
- 패널: [연락처 추가](채운 단추) → 「내 주소록」 전체 연락처(수) → 「그룹」 그룹마다 한 줄(수 · ⋯ 32×40 — 메일함 ⋯ 와 같은 밀도 예외) + [새 그룹] → 「사내」 직원(수) · 공용 주소(수).
|
||||
- 목록 머리: 제목 · "N명"(공용 주소는 "N개", 찾으면 "찾은 결과 N명"). 그룹이면 오른쪽에 [구성원 넣기] [그룹에 메일 쓰기].
|
||||
사내면 머리 아래 회색 줄 — 직원 "메일 서버에 계정이 있는 직원입니다 — 이름과 주소만 보입니다." · 공용 주소 "여러 사람이 함께 받는 사내 주소입니다."
|
||||
사내면 머리 아래 회색 줄 — 직원 "회사 메일을 쓰는 직원이라 이름과 메일 주소만 보입니다." · 공용 주소 "여러 사람이 함께 받는 사내 주소입니다."
|
||||
- 목록 줄(56): 아바타 32(이름 첫 글자 — 공용 주소는 회색) · 이름(굵게) + 회사 · 부서(작게) / 대표 주소. 고른 줄은 청록 바탕. 이름 차례(한국어), 100명씩 받고 끝에 [더 보기].
|
||||
- 상세 머리 줄: [메일 쓰기] [고치기] … [⋯] — 사내는 [메일 쓰기] [내 주소록에 추가]. 아래로 아바타 48 · 이름(20) · "회사 · 부서 · 직책"(없으면 "소속 없음", 사내는 "사내 직원" · "공용 주소").
|
||||
- 상세 칸(왼쪽 이름 88): 이메일(주소마다 링크 — 누르면 그 주소로 새 메일, 옆에 종류) · 전화(번호 · 종류) · 소속 · 직책 · 그룹(누르면 그 그룹의 목록) · 메모(줄바꿈 그대로). 빈 칸은 줄째 뺀다.
|
||||
사내 상세 아래: "사내 목록은 메일 서버의 계정에서 옵니다 — 이름과 주소만 보이고 여기서 고칠 수 없습니다. 전화번호 · 메모를 적어 두려면 내 주소록에 추가하세요."
|
||||
- 빈 상태: 찾은 것이 없으면 "‘…’에 맞는 연락처가 없습니다"(사내는 "주소가") + "이름 · 주소 · 회사 · 전화번호의 일부나 이름의 초성으로 찾습니다." · 고른 것이 없으면 "연락처를 고르세요".
|
||||
사내 상세 아래: "사내 목록은 이름과 주소만 보이고 여기서 고칠 수 없습니다. 전화번호나 메모를 적어 두려면 내 주소록에 추가해 주세요."
|
||||
- 빈 상태: 찾은 것이 없으면 "‘…’에 맞는 연락처가 없습니다"(사내는 "주소가") + "이름, 주소, 회사, 전화번호의 일부나 이름의 초성으로 찾을 수 있습니다." · 고른 것이 없으면 "연락처를 골라 주세요".
|
||||
|
||||
### 연락처 추가 · 고치기 (`ContactsEdit`)
|
||||
|
||||
- 모달 620 — 필터 고치기 창과 같은 틀이고 나가기 확인도 같다("저장하지 않은 연락처가 있습니다" / [계속 고치기] [닫기]).
|
||||
- 칸: 이름(100자) · 이메일(종류 회사 · 개인 · 기타 + 주소 + ✕, [이메일 더하기] 5개까지 — "받는 사람 칸에는 맨 위 주소가 먼저 나옵니다") ·
|
||||
전화번호(휴대전화 · 회사 · 집 · 기타 + 번호 + ✕, 5개까지) · 회사 · 부서 · 직책(한 줄에 셋) · 메모(1,000자 — 셈 "38 / 1,000") · 그룹(체크 칸).
|
||||
맨 아래 "이름이나 이메일 가운데 하나는 있어야 합니다." · [저장] [취소], 고치기만 왼쪽에 [이 연락처 지우기](빨간 글).
|
||||
맨 아래 "이름이나 이메일 가운데 하나는 넣어 주세요." · [저장] [취소], 고치기만 왼쪽에 [이 연락처 지우기](빨간 글).
|
||||
- 잘못은 [저장]을 누를 때 칸 아래 빨간 글로만(첫 잘못된 칸에 초점, 알림 없음) — "이메일 형식이 올바르지 않습니다." 등.
|
||||
- 같은 주소: 이메일 칸을 떠날 때 내 주소록에 같은 주소(다른 연락처)가 있으면 칸 아래 갈색 글
|
||||
"이미 주소록에 있는 주소입니다 — 강서윤(거래처). 그래도 저장할 수 있습니다. [그 연락처 보기]" — 막지 않는다.
|
||||
"주소록에 있는 강서윤(거래처)의 주소와 같습니다. 그래도 저장할 수 있습니다. [그 연락처 보기]" — 막지 않는다.
|
||||
- 지우기는 확인 없이: 창을 닫고 "‘박지훈’ 연락처를 지웠습니다." + [되돌리기](10초).
|
||||
|
||||
### 그룹 (`ContactsEdit`)
|
||||
|
||||
- 새 그룹 · 이름 바꾸기: 모달 400, 칸 하나(50자) · [만들기] / [저장] [취소]. 같은 이름이면 칸 아래 "같은 이름의 그룹이 있습니다. 다른 이름을 써 주세요."(`CONTACT_001` 사전 문구 — 알림 없이. 보드는 앞 문장만). 그룹은 100개까지.
|
||||
- 그룹 ⋯: 이름 바꾸기 · 그룹에 메일 쓰기 · 그룹 지우기(빨간 글). 지우기는 확인 없이 "‘거래처’ 그룹을 지웠습니다 — 연락처 4명은 그대로 있습니다." + [되돌리기].
|
||||
- 그룹 ⋯: 이름 바꾸기 · 그룹에 메일 쓰기 · 그룹 지우기(빨간 글). 지우기는 확인 없이 "‘거래처’ 그룹을 지웠습니다. 그룹에 있던 연락처 4명은 그대로 남아 있습니다." + [되돌리기].
|
||||
- 연락처 ⋯(상세): 주소 복사 · 연락처 지우기.
|
||||
- 구성원 넣기: 모달 620 — 「내 연락처 | 사내 직원」 전환(36 — `.segmented` 밀도 예외) + 찾기(초성), 체크 줄 48(이름 · 회사 · 주소). 이미 구성원인 연락처는 보이지 않는다.
|
||||
"N명 고름" · [넣기] [취소]. 사내 직원을 고르면 내 주소록에도 이름 · 주소로 추가한다(같은 주소의 연락처가 이미 있으면 그 연락처를 넣는다). 끝나면 "2명을 ‘거래처’ 그룹에 넣었습니다."
|
||||
@@ -458,17 +486,17 @@ API 와 서버 규칙은 계약 2절 "실시간 알림".
|
||||
|
||||
### 상태 (`ContactsEdit`)
|
||||
|
||||
- 처음(연락처 없음): "아직 연락처가 없습니다" · "자주 메일을 주고받는 사람을 넣어 두면 받는 사람 칸에서 바로 찾습니다." · [연락처 추가] ·
|
||||
"메일을 읽다가 ⋯ › 「보낸 사람을 주소록에 추가」로도 넣을 수 있습니다."
|
||||
- 처음(연락처 없음): "아직 연락처가 없습니다" · "자주 메일을 주고받는 사람을 넣어 두면 받는 사람 칸에서 바로 찾을 수 있습니다." · [연락처 추가] ·
|
||||
"메일을 읽다가 [⋯] → [보낸 사람을 주소록에 추가]로도 넣을 수 있습니다."
|
||||
- 사내 목록을 받지 못함: "사내 목록을 불러오지 못했습니다" · "잠시 후 다시 시도해 주세요. 내 주소록은 그대로 쓸 수 있습니다." · [다시 시도].
|
||||
- 한도: 연락처가 3,000개면 [연락처 추가]를 끄고(`aria-disabled`) 아래에 "연락처는 3,000개까지 둘 수 있습니다. 쓰지 않는 연락처를 지운 뒤 추가해 주세요."
|
||||
- 알림: "연락처를 저장했습니다." · "‘…’ 연락처를 지웠습니다." · "‘…’ 그룹을 지웠습니다 — 연락처 N명은 그대로 있습니다." · "N명을 ‘…’ 그룹에 넣었습니다." (단추가 있으면 10초)
|
||||
- 알림: "연락처를 저장했습니다." · "‘…’ 연락처를 지웠습니다." · "‘…’ 그룹을 지웠습니다. 그룹에 있던 연락처 N명은 그대로 남아 있습니다." · "N명을 ‘…’ 그룹에 넣었습니다." (단추가 있으면 10초)
|
||||
|
||||
### 메일과 함께 (`ContactsMail`)
|
||||
|
||||
- **받는 사람 추천**: 묶음은 주소록 → 그룹 → 사내 → 최근 주고받은 주소(모두 합쳐 최대 8개). 주소록은 주소마다 한 줄 — 대표 주소는 "주소 · 회사", 그 밖의 주소는 "주소 · 개인"처럼 종류.
|
||||
사내는 이제 로그인한 적 있는 직원이 아니라 메일 서버에 계정이 있는 직원 · 공용 주소 모두다. 여러 묶음에 있는 주소는 앞 묶음에 한 번만.
|
||||
- **그룹 줄**: 아이콘 + 그룹 이름(맞는 글자 굵게) + "N명 · 고르면 한꺼번에 넣습니다". 고르면 구성원의 대표 주소를 칩으로 넣고 칸 아래 "‘거래처’ 그룹의 4명을 받는 사람에 넣었습니다."
|
||||
- **그룹 줄**: 아이콘 + 그룹 이름(맞는 글자 굵게) + "고르면 N명을 한꺼번에 넣습니다". 고르면 구성원의 대표 주소를 칩으로 넣고 칸 아래 "‘거래처’ 그룹의 4명을 받는 사람에 넣었습니다."
|
||||
이메일이 없는 구성원 · 이미 넣은 주소는 빼고, 뺀 것이 있으면 "주소가 없는 1명은 뺐습니다"를 덧붙인다.
|
||||
- **주소록에서 메일 쓰기**: 연락처 · 그룹 · 사내의 [메일 쓰기]와 주소 링크 → 새 쓰기 화면, 받는 사람을 채워서(그룹은 구성원 모두 — 받는 사람 칸). 쓰다 만 메일은 임시 저장 규칙대로 남는다.
|
||||
- **읽기 화면 ⋯**: 둘째 묶음(「이런 메일로 필터 만들기」 위)에 「보낸 사람을 주소록에 추가」 → 이름 · 주소를 채운 연락처 추가 창. 이미 내 주소록에 있으면 「주소록에서 보기」(그 연락처를 고른 주소록).
|
||||
@@ -485,9 +513,9 @@ API 와 서버 규칙은 계약 2절 "실시간 알림".
|
||||
- **설정에 따라**(설정 › 메일 › 보내기 · 읽기 › 외부 이미지): 늘 묻기(처음 값) · 주소록과 사내 직원은 늘 보기 · 늘 보기. 처음부터 보이면 안내 줄이 없다. **스팸메일함의 메일은 늘 묻는다.**
|
||||
- **⋯ 메뉴**: 「인쇄」(Ctrl+P 표시) → 「원문 내려받기 (.eml)」 → 구분선 → 「보낸 사람을 주소록에 추가」/「주소록에서 보기」 → 「이런 메일로 필터 만들기」 → 「보낸 사람 차단」(빨간 글).
|
||||
이미 차단한 보낸 사람이면 마지막 항목이 「보낸 사람 차단 풀기」. 사내 직원 · 내 주소에는 차단 항목이 없다. 차단한 주소가 500개면 항목을 끄고 이름 아래에 까닭.
|
||||
- **차단 확인 창**(모달 400): 제목 "보낸 사람 차단", 설명 "앞으로 오는 메일을 스팸메일함으로 보냅니다. 부재중 자동 응답도 보내지 않습니다."
|
||||
- **차단 확인 창**(모달 400): 제목 "보낸 사람 차단", 설명 "앞으로 오는 메일은 스팸메일함으로 옮겨지고, 부재중 자동 응답도 보내지 않습니다."
|
||||
고르기 둘 — 「이 주소만」(주소) · 「이 도메인 전체」(@도메인) — 여러 사람이 쓰는 메일 서비스(gmail.com · naver.com 등)는 도메인을 고를 수 없게 끄고 그 아래에 까닭.
|
||||
체크 「이 메일도 스팸메일함으로 옮기기」(처음 켜짐), 안내 "차단한 주소는 설정 › 메일 › 차단한 주소에서 풀 수 있습니다." — [차단](처음 초점 — 되돌릴 수 있는 일) [취소].
|
||||
체크 「이 메일도 스팸메일함으로 옮기기」(처음 켜짐), 안내 "차단은 [설정] → [차단한 주소]에서 풀 수 있습니다." — [차단](처음 초점 — 되돌릴 수 있는 일) [취소].
|
||||
- **차단한 뒤**: 알림 "‘주소’ 을 차단하고 이 메일을 스팸메일함으로 옮겼습니다." + [되돌리기](차단을 풀고 메일을 받은편지함으로). 스팸메일함에서 차단한 보낸 사람의 메일을 열면
|
||||
회색 안내 줄 "차단한 보낸 사람의 메일입니다." + [차단 풀기].
|
||||
|
||||
@@ -506,10 +534,10 @@ API 와 서버 규칙은 계약 2절 "실시간 알림".
|
||||
- **[보내기 ▾]**: 쓰기 화면의 [보내기]를 둘로 나눈다 — 왼쪽은 지금의 [보내기], 오른쪽 ▾(44 — 보드는 40, 밀도 예외를 새로 만들지 않는다)는 메뉴 「예약 발송…」(시계 아이콘). 보내기 단축키는 없다(쓰기 화면에 Ctrl+Enter 묶음이 없다 — 2026-10-30 확인).
|
||||
보내는 중 · 저장 중 · 첨부를 올리는 중에는 두 단추를 함께 끈다(지금의 [보내기]와 같은 규칙).
|
||||
- **예약 창**(모달 400): 제목 "예약 발송". 고르기 넷 — 「내일 오전」 9:00 · 「내일 오후」 1:00 · 「다음 주 월요일」 9:00 · 「직접 고르기」(날짜 칸 + 30분 단위 시각) — 줄마다 오른쪽에 날짜 · 요일 · 시각.
|
||||
오늘 오후가 남았으면 「오늘 오후」 6:00 이 첫 줄. 안내 "이 PC 의 시간(한국 표준시)으로 보냅니다 · 7일 안에서 고를 수 있습니다." [예약] [취소].
|
||||
칸 아래 오류: "지금부터 5분 뒤 이후의 시각을 고르세요." · "7일 안의 시각만 고를 수 있습니다." 받는 사람 확인은 지금처럼 보내는 순간(없는 주소면 받는 사람 칸의 띠).
|
||||
오늘 오후가 남았으면 「오늘 오후」 6:00 이 첫 줄. 안내 "이 PC의 시간(한국 표준시)으로 보내고, 7일 안에서 고를 수 있습니다." [예약] [취소].
|
||||
칸 아래 오류: "5분 뒤보다 이른 시각은 고를 수 없습니다. 더 늦은 시각을 골라 주세요." · "7일 안의 시각만 고를 수 있습니다." 받는 사람 확인은 지금처럼 보내는 순간(없는 주소면 받는 사람 칸의 띠).
|
||||
- **보낸 뒤 알림**(오른쪽 아래): "메일을 보냈습니다." + [보내기 취소] + 아래 남은 시간 막대(설정한 초 — 마우스를 올려도 멈추지 않는다) → 시간이 지나면 저절로 닫힌다.
|
||||
일부가 받지 못하면 빨간 알림 "보냈지만 1명은 받지 못합니다 — 주소 (까닭)" + [보내기 취소](주소를 고쳐 다시 보낼 수 있게).
|
||||
일부가 받지 못하면 빨간 알림 "보냈지만 1명은 받지 못합니다. 주소 (까닭)" + [보내기 취소](주소를 고쳐 다시 보낼 수 있게).
|
||||
[보내기 취소] → "보내기를 취소했습니다. 쓰던 메일로 돌아왔습니다." + 그 메일로 쓰기 화면(받는 사람 · 제목 · 본문 · 첨부 그대로). 늦게 누르면 "이미 보낸 메일이라 취소할 수 없습니다."
|
||||
예약 → "10월 30일 (금) 오전 9:00 에 보냅니다." + [예약됨 보기]. 로그아웃 · 다른 계정 로그인 때 알림은 닫는다 — 메일은 그대로 보내진다.
|
||||
- **「예약됨」**: 메일함 패널의 보낸편지함 아래(예약한 메일이 있을 때만, 시계 아이콘, 오른쪽에 통 수). 목록은 받는 사람 · 제목 · 보낼 시각(청록 글 — "10월 30일 (금) 오전 9:00") 차례,
|
||||
@@ -522,14 +550,14 @@ API 와 서버 규칙은 계약 2절 "실시간 알림".
|
||||
|
||||
- **메뉴**: 메일 묶음에 「보내기 · 읽기」(필터 아래) · 「차단한 주소」를 더한다 — 서명 · 부재중 자동 응답 · 필터 · 보내기 · 읽기 · 차단한 주소 · 알림.
|
||||
- **보내기 · 읽기**: 고르는 대로 저장한다(저장 단추 없음 — 머리 오른쪽에 "저장했습니다"). 서버에 저장되어 어느 PC 에서나 같다.
|
||||
- 「보내기 취소」 — 눌림 전환(36 — `.segmented`) 끔 · 5초 · 10초(처음) · 20초 · 30초 + "[보내기]를 누른 뒤 이 시간 동안 [보내기 취소]로 되돌릴 수 있습니다. 그동안 메일은 메일 서버에서 기다리므로 창을 닫아도 보내집니다."
|
||||
- 「외부 이미지」 — 라디오 셋(늘 묻기 · 주소록 · 사내 직원의 메일은 늘 보기 · 늘 보기, 줄마다 아래 설명) + 회색 상자
|
||||
"외부 이미지는 사내 메일 서버가 대신 받아 보여 줍니다 — 보낸 사람에게 이 PC 의 주소는 드러나지 않습니다. 다만 이미지를 받으면 보낸 사람은 메일을 열어 본 것을 알 수 있습니다."
|
||||
- **차단한 주소**: 머리 "차단한 주소" + "3 / 500" + 설명 "이 주소에서 오는 메일은 스팸메일함으로 갑니다. 부재중 자동 응답도 보내지 않습니다. 「@도메인」은 그 도메인에서 오는 메일 모두입니다."
|
||||
- 「보내기 취소」 — 눌림 전환(36 — `.segmented`) 끔 · 5초 · 10초(처음) · 20초 · 30초 + "[보내기]를 누른 뒤 이 시간 동안 [보내기 취소]로 되돌릴 수 있습니다. 그사이 창을 닫아도 시간이 지나면 메일은 그대로 보내집니다."
|
||||
- 「외부 이미지」 — 라디오 셋(늘 묻기 · 주소록과 사내 직원의 메일은 늘 보기 · 늘 보기, 줄마다 아래 설명) + 회색 상자
|
||||
"외부 이미지는 회사를 거쳐 불러오므로 보낸 사람은 이 PC의 주소를 알 수 없습니다. 다만 이미지를 불러오면 보낸 사람은 메일을 열어 본 것을 알 수 있습니다."
|
||||
- **차단한 주소**: 머리 "차단한 주소" + "3 / 500" + 설명 "차단한 주소에서 온 메일은 스팸메일함으로 옮겨지고, 부재중 자동 응답도 보내지 않습니다. @spam.example 처럼 @로 시작해 넣으면 그 도메인에서 오는 메일을 모두 막습니다."
|
||||
- 더하기 줄: 칸 "차단할 주소"(placeholder "kim@example.com 또는 @example.com") + [차단]. 칸 아래 오류: "메일 주소나 @도메인 형식으로 써 주세요." · "이미 차단한 주소입니다." ·
|
||||
"내 주소 · 사내 도메인은 차단할 수 없습니다." · "gmail.com 처럼 여러 사람이 쓰는 메일 서비스는 도메인으로 막을 수 없습니다 — 주소로 막아 주세요." · "500개까지 차단할 수 있습니다."
|
||||
"내 주소와 사내 도메인은 차단할 수 없습니다." · "gmail.com처럼 여러 사람이 쓰는 메일 서비스는 도메인 전체를 막을 수 없습니다. 주소를 하나씩 넣어 막아 주세요." · "500개까지 차단할 수 있습니다."
|
||||
- 목록: 줄마다 주소(굵게) + 아래 작은 글 "10월 29일 차단"(도메인은 "도메인 전체 · …") + [차단 풀기](36). 누르면 바로 풀고 알림 "‘주소’ 차단을 풀었습니다." + [되돌리기].
|
||||
이미 받은 메일은 옮기지 않는다. 비었으면 "차단한 주소가 없습니다. 메일의 ⋯ › 보낸 사람 차단으로도 막을 수 있습니다."
|
||||
이미 받은 메일은 옮기지 않는다. 비었으면 "차단한 주소가 없습니다. 메일을 읽다가 [⋯] → [보낸 사람 차단]으로도 막을 수 있습니다."
|
||||
|
||||
## 11단계 — 대용량 첨부 (2026-10-30 확정 — `ComposeLargeFiles` · `LargeFileBlock` · `MailReadLargeFiles` · `SharePage` · `SettingsLargeFiles`)
|
||||
|
||||
@@ -546,8 +574,8 @@ API 와 서버 규칙은 계약 2절 "실시간 알림".
|
||||
묻는 창은 없다 — 합계 때문에 바뀌면 칸 아래 ⓘ 한 줄 "‘회의자료_전체.pptx’(9.4 MB)는 메일에 담으면 합계가 25MB 를 넘어 링크로 보냅니다."(role=status). 받는 사람이 사내뿐이어도 같은 규칙이다.
|
||||
메일에 담은 파일도 줄의 링크 단추(40 · 「링크로 보내기」 — 마우스를 올리면 같은 글)로 바꿀 수 있다 — **이 쓰기 화면에서 고른 파일에만** 있다(전달 원본 · 저장본에서 이어 온 첨부에는 없다 — 처음 판).
|
||||
링크 → 메일에 담기 단추는 없다(빼고 다시 고르면 한도 안에서 담긴다 — 아래 "처음 판에서 미룬 것").
|
||||
- **안내문**: "파일당 최대 20MB · 합계 25MB · 최대 20개 · 더 큰 파일은 링크로 보냅니다 (파일당 2GB · 10개까지)".
|
||||
- **두 묶음**: 링크 파일이 있으면 칸을 「메일에 담는 파일 N개」(+ "합계 18.1 MB / 25 MB") · 「링크로 보내는 파일 N개」(+ "받는 사람은 보낸 날부터 30일 동안 내려받습니다 — 오늘 보내면 11월 29일(일)까지")로 나눈다(12.5 굵게 + 12 회색).
|
||||
- **안내문**: "파일당 최대 20MB · 합계 25MB · 최대 20개 · 더 큰 파일은 파일당 2GB, 10개까지 링크로 보냅니다".
|
||||
- **두 묶음**: 링크 파일이 있으면 칸을 「메일에 담는 파일 N개」(+ "합계 18.1 MB / 25 MB") · 「링크로 보내는 파일 N개」(+ "받는 사람은 보낸 날부터 30일 동안 내려받을 수 있습니다. 오늘 보내면 11월 29일(일)까지입니다.")로 나눈다(12.5 굵게 + 12 회색).
|
||||
일반 첨부만 있으면 지금처럼 묶음 이름 없이 — 링크 파일만 있어도 「링크로 보내는 파일 N개」 머리는 둔다(기간 안내 · 보관 경고의 자리, 보드대로). 보관 사용량이 80%를 넘으면 링크 묶음 머리 오른쪽에 "보관 8.4 / 10 GB"(갈색 굵게).
|
||||
- **링크 파일 줄**: 아이콘이 링크(청록 #0A5F67), 두 줄 — 이름 · 단추(40) / 크기 · 상태(12px, 아이콘 선만큼 26px 들여), 올리는 동안 3px 막대. ✕ 는 「빼기」 — 올리던 것을 멈추고 서버의 조각도 지운다.
|
||||
|
||||
@@ -556,34 +584,35 @@ API 와 서버 규칙은 계약 2절 "실시간 알림".
|
||||
| 올리는 중 | "올리는 중 · 420 MB / 1.2 GB" + 오른쪽 "약 3분 남음"(처음 몇 초는 "시간 계산 중") | [멈춤] ✕ · 청록 막대 |
|
||||
| 멈춤 | "멈춤 · 420 MB / 1.2 GB 올림" | [이어 올리기] ✕ · 회색 막대 — 그 파일만 멈춘다 |
|
||||
| 잠깐 끊김 | "연결이 끊겨 다시 잇는 중… · 420 MB / 1.2 GB 올림"(갈색) | 누를 것 없음 — 2 · 5 · 15 · 30초 간격으로 저절로, 받은 조각은 그대로 |
|
||||
| 오래 끊김 | "올리지 못하고 멈췄습니다 — 올린 420 MB 는 남아 있습니다."(빨강) | 빨간 줄 · [다시 시도](40) ✕ — 저절로 다시 잇기를 1분 넘게 못 했을 때 |
|
||||
| 다 올림 · 검사 중 | "612 MB · 보안 검사 중 — 끝나지 않아도 보낼 수 있습니다" | ✕ |
|
||||
| 검사 끝 | 깨끗하면 크기만 "1.2 GB" · 못 했으면 "860 MB · 검사하지 못함 — 암호가 걸린 압축 파일 등"(까닭은 응답에 없다 — 암호 압축 · 한도 밖 · 검사기 없음을 함께 쓴다) | ✕ |
|
||||
| 오래 끊김 | "올리지 못하고 멈췄습니다. 지금까지 올린 420 MB는 남아 있습니다."(빨강) | 빨간 줄 · [다시 시도](40) ✕ — 저절로 다시 잇기를 1분 넘게 못 했을 때 |
|
||||
| 다 올림 · 검사 중 | "612 MB · 보안 검사 중이지만 보낼 수 있습니다" | ✕ |
|
||||
| 검사 끝 | 깨끗하면 크기만 "1.2 GB" · 못 했으면 "860 MB · 암호가 걸린 압축 파일처럼 검사하지 못한 파일"(까닭은 응답에 없다 — 암호 압축 · 한도 밖 · 검사기 없음을 함께 쓴다) | ✕ |
|
||||
| 위험 | "위험한 내용이 발견되어 보낼 수 없습니다. 이 파일을 빼 주세요."(경고 삼각형) | 빨간 줄 · ✕ |
|
||||
|
||||
- **올리기 전 검사**(지금 첨부 사전 검사와 같은 결 — 넘으면 올리지 않고 빨간 줄, 그 파일만 빠지고 보내기는 막지 않는다):
|
||||
"파일 하나는 2GB 까지 보낼 수 있습니다." · "링크로 보내는 파일은 메일 한 통에 10개까지입니다." ·
|
||||
"대용량 보관 한도(10GB)를 넘어 올릴 수 없습니다. 보내지 않은 파일을 지우거나 링크를 끊으면 바로 공간이 생깁니다. [대용량 첨부 보기]" ·
|
||||
"대용량 첨부는 10GB까지 보관할 수 있어 더 올릴 수 없습니다. 보내지 않은 파일을 지우거나 링크를 끊으면 바로 공간이 생깁니다. [대용량 첨부 보기]" ·
|
||||
"지금은 서버에 공간이 모자라 올릴 수 없습니다. 잠시 후 다시 시도해 주세요." · 형식은 `FILE_001` 사전 문구.
|
||||
링크로 보낼 수 있는 형식 = 일반 첨부 목록 + mp4 · mov · dwg · dxf · psd · ai · 7z. 실행 파일(exe · msi · bat · cmd · js · vbs · scr 등)과 디스크 이미지(iso · img · vhd)는 막는다. 파일 고르기 창은 두 목록을 합친 형식만.
|
||||
- **다시 열 때**: 브라우저는 고른 파일을 스스로 다시 읽지 못한다. 새로고침 · 창을 닫은 뒤 저장본을 열면 노란 줄 "올리다 멈춘 파일 — 같은 파일을 다시 고르면 이어서 올립니다 · 420 MB / 1.2 GB 올림" + [파일 고르기](40) ✕.
|
||||
다른 파일을 고르면 그 줄 안에 "고른 파일이 멈춘 파일과 다릅니다(이름 · 크기). 같은 파일을 골라 주세요."(이름 · 크기 · 고친 시각으로 맞춘다 — 서버는 마지막 조각 뒤 24시간 기다린다).
|
||||
다 올린 파일은 저장본에 붙어 "1.2 GB · 이 임시 저장본에 11월 29일까지 남습니다"(올린 날부터 30일), 지나면 빨간 줄 "기간이 지나 지운 파일입니다 — 빼고 다시 올려 주세요."
|
||||
설정 › 대용량 첨부에서 지웠으면 같은 모양에 글만 "설정 › 대용량 첨부에서 지운 파일입니다 — 빼고 다시 올려 주세요."
|
||||
- **다시 열 때**: 브라우저는 고른 파일을 스스로 다시 읽지 못한다. 새로고침 · 창을 닫은 뒤 저장본을 열면 노란 줄 "올리다 멈춤 · 420 MB / 1.2 GB 올림 · 같은 파일을 다시 고르면 이어서 올립니다" + [파일 고르기](40) ✕.
|
||||
다른 파일을 고르면 그 줄 안에 "고른 파일의 이름이나 크기가 멈춘 파일과 다릅니다. 같은 파일을 골라 주세요."(이름 · 크기 · 고친 시각으로 맞춘다 — 서버는 마지막 조각 뒤 24시간 기다린다).
|
||||
다 올린 파일은 저장본에 붙어 "1.2 GB · 이 임시 저장본에 11월 29일까지 남습니다"(올린 날부터 30일), 지나면 빨간 줄 "기간이 지나 지워진 파일입니다. 빼고 다시 올려 주세요."
|
||||
설정 › 대용량 첨부에서 지웠으면 같은 모양에 글만 "[설정] → [대용량 첨부]에서 지운 파일입니다. 빼고 다시 올려 주세요."
|
||||
- **다 올릴 때까지 보내기를 막는다**: [보내기]와 ▾ 를 `disabled` 가 아니라 `aria-disabled` 로 끄고(초점은 받는다) [임시 저장] 오른쪽에 까닭 글(ⓘ 12.5 — 두 단추의 `aria-describedby`).
|
||||
누르면 보내지 않고 까닭을 읽어 준다(role=status — 보내기 단축키는 앱에 없다). 「예약 발송…」도 같다. 임시 저장은 그대로 된다. 까닭 글 —
|
||||
올리는 중 · 다시 잇는 중 "파일 2개를 올리는 중입니다 — 다 올리면 보낼 수 있습니다." ·
|
||||
멈춤 · 오래 끊김 · 다시 열어 멈춘 파일 "다 올리지 못한 파일이 있습니다 — 이어 올리거나 빼 주세요." ·
|
||||
위험 · 지운 파일(기간 지남 · 설정에서 지움) "보낼 수 없는 파일이 있습니다 — 빨간 줄의 파일을 빼 주세요."
|
||||
올리는 중 · 다시 잇는 중 "파일 2개를 올리는 중입니다. 다 올린 뒤에 보낼 수 있습니다." ·
|
||||
멈춤 · 오래 끊김 · 다시 열어 멈춘 파일 "다 올리지 못한 파일이 있습니다. 이어 올리거나 빼 주세요." ·
|
||||
위험 · 지운 파일(기간 지남 · 설정에서 지움) "보낼 수 없는 파일이 있습니다. 빨간 줄의 파일을 빼 주세요."
|
||||
- **나가기 확인**: 올리는 중이면 "올리는 중인 파일이 있습니다" / "이 화면을 나가면 올리기가 멈춥니다. 임시보관함에서 이 메일을 다시 열고 같은 파일을 고르면 이어서 올립니다." [계속 쓰기](처음 초점 · Esc) [나가기].
|
||||
떠나기 전 임시 저장은 지금처럼 먼저 하고, 올리는 중인 파일은 저장본에 "올리다 멈춘 파일"로 남긴다. 다 올린 뒤에는 이 확인이 없다.
|
||||
|
||||
### 본문 덩이 — 바깥 메일 프로그램에서 (`LargeFileBlock`)
|
||||
|
||||
- **자리**: 내 글 · 서명 아래, 인용 위(쓰기 화면이 알려 준 경계). 인용이 없으면 맨 끝. 서버가 정화 · 서명 그림 · 중복 확인을 마친 뒤 넣는다 — 쓰기 화면의 편집기에는 보이지 않는다.
|
||||
- **내용**(위에서부터): "대용량 첨부 2개 (합계 1.8 GB)"(14 굵게) → "2026년 11월 29일(일)까지 내려받을 수 있습니다 (한국 시간)" → 파일마다 이름 · 크기(회색, 오른쪽) →
|
||||
- **내용**(위에서부터): "대용량 첨부 2개 (합계 1.8 GB)"(14 굵게) → "한국 시간으로 2026년 11월 29일(일)까지 내려받을 수 있습니다." → 파일마다 이름 · 크기(회색, 오른쪽) →
|
||||
"내려받기 https://files.arasystem.it.kr/s/…"(주소를 글자 그대로 보이는 링크 — 단축 주소 · 단추 모양 없음. 파일 이름은 링크가 아니다 — 메일 한 통에 링크 하나) →
|
||||
"내려받을 때 로그인이나 비밀번호를 묻지 않습니다."(12 회색) → "Large files: download by Nov 29, 2026 (KST) — no login or password needed."(12 회색).
|
||||
"내려받을 때 로그인이나 비밀번호를 묻지 않습니다."(12 회색) → "Large files: download by Nov 29, 2026 (KST). No login or password needed."(12 회색).
|
||||
(2026-11-05 날짜 줄 · 영어 줄의 말투를 고쳤다 — 그 전에 붙잡아 둔 발송을 보내기 취소로 되돌려도 텍스트 덩이를 떼도록 서버가 예전 글도 함께 찾는다.)
|
||||
- **모양**: 테두리 1px #D0D5DD 표 + 글자, 안 여백 12 · 16, 맑은 고딕 14 · 13 · 12. 보내는 서식의 허용 목록 안에서만(table · td colspan · a, 테두리 · 여백 · 색 · 크기 · 굵기 · 정렬 · 줄바꿈 막기) —
|
||||
둥근 모서리 · 그림 · 아이콘 · 뱃지 없음. 폭은 내용에 맞춘다(주소 줄이 가장 길다 — 좁은 화면에서는 받는 프로그램이 줄을 끊는다).
|
||||
- **텍스트 대체 본문**: 같은 자리에 대시 줄로 가른 같은 내용 — 파일은 "- 이름 (크기)", 주소는 "내려받기: 주소".
|
||||
@@ -597,19 +626,19 @@ API 와 서버 규칙은 계약 2절 "실시간 알림".
|
||||
- **첨부처럼 올리는 규칙**: 주소가 우리 내려받기 주소이고 서버에 있는 링크이며, **링크 주인이 그 메일의 보낸 사람과 같을 때만** 첨부 칸으로 올린다 —
|
||||
바깥 사람 답장의 인용 속 링크 · 전달된 원래 링크는 본문 글이다.
|
||||
- **사내 받는 사람**: 링크 파일을 첨부 칸에 일반 첨부와 함께 보인다 — 이름 아래 "1.2 GB · 11월 29일까지". 아이콘 · [다운로드](44)는 일반 첨부와 같고, 내려받기 화면을 거치지 않고 바로 받는다. 「첨부 파일 N개」는 둘을 합한 수.
|
||||
- **본문의 덩이는 접는다**: 그 자리에 접기 줄(높이 40 · 12.5 · 바탕 #F6F7F9) "본문의 대용량 첨부 안내 (접어 둠) — 파일은 아래 첨부 칸에 있습니다" + "펼치기".
|
||||
- **본문의 덩이는 접는다**: 그 자리에 접기 줄(높이 40 · 12.5 · 바탕 #F6F7F9) "본문의 대용량 첨부 안내" + "파일은 아래 첨부 칸에 있어 접어 두었습니다" + "펼치기".
|
||||
본문 틀 안에서 스크립트 없이 열고 닫는다. 메일을 다시 열면 접혀 있고, 인쇄 화면은 펼친 채 인쇄한다.
|
||||
- **보낸 사람 자신**: 파일마다 "1.2 GB · 3회 받음 · 11월 29일까지"(받지 않았으면 "아직 받지 않음"), 첨부 칸 아래 한 줄
|
||||
"링크로 보낸 파일 2개 — 받는 사람 모두 같은 링크로 받습니다. 끊으면 둘 다 받을 수 없습니다." + [링크 복사] [링크 끊기](36 — 차단한 주소의 [차단 풀기]와 같은 밀도 예외, [링크 끊기]는 빨간 글 테두리 단추).
|
||||
"링크로 보낸 파일 2개는 받는 사람 모두 같은 링크로 받습니다. 끊으면 둘 다 받을 수 없습니다." + [링크 복사] [링크 끊기](36 — 차단한 주소의 [차단 풀기]와 같은 밀도 예외, [링크 끊기]는 빨간 글 테두리 단추).
|
||||
[링크 복사]는 이 메일 본문의 덩이에서 주소를 읽어 복사한다 — "링크를 복사했습니다."(못 하면 "링크를 복사하지 못했습니다."). 설정 목록에는 없다(서버는 링크를 해시로만 안다).
|
||||
받은 수는 처음부터 받기 시작한 수뿐(화면 열기 · 이어 받기 · 내가 받은 것은 빼고)이고, 누가 받았는지는 남기지 않는다.
|
||||
- **링크 끊기 확인**(alertdialog 400): "대용량 첨부 링크를 끊을까요?" / "받는 사람 모두 이 메일의 파일 2개(설계도_v3.dwg · 현장영상_1029.mp4)를 더는 내려받을 수 없습니다. 이미 받은 파일은 되돌릴 수 없고, 끊은 링크는 다시 열 수 없습니다."
|
||||
- **링크 끊기 확인**(alertdialog 400): "대용량 첨부 링크를 끊을까요?" / "받는 사람 모두 이 메일의 파일 2개(설계도_v3.dwg, 현장영상_1029.mp4)를 더는 내려받을 수 없습니다. 이미 받은 파일은 되돌릴 수 없고, 끊은 링크는 다시 열 수 없습니다."
|
||||
[링크 끊기](빨강) [취소](처음 초점 · Esc). 끊으면 "링크를 끊었습니다. 받는 사람은 더 이상 내려받을 수 없습니다." — 되돌리기는 없다. 쓰던 보관 공간은 바로 비워진다.
|
||||
- **기간 지남 · 공유 멈춤**: 흐리게(이름 #667085 · 아이콘 #98A2B3 — 투명도가 아니라 글자색) + "기간 지남 — 11월 29일까지였습니다" · "공유 멈춤 — 보낸 사람이 링크를 끊었습니다", 내려받기 단추 없음.
|
||||
보낸 사람 자신에게는 받은 수가 남고 끊은 날이 보인다 — "1.2 GB · 3회 받음 · 공유 멈춤 — 10월 31일에 끊었습니다"(한국 날짜 · 응답의 `revokedAt`, 없으면 "공유 멈춤 — 링크를 끊었습니다").
|
||||
- **기간 지남 · 공유 멈춤**: 흐리게(이름 #667085 · 아이콘 #98A2B3 — 투명도가 아니라 글자색) + "받을 수 있는 기간이 11월 29일에 끝났습니다" · "보낸 사람이 링크를 끊어 받을 수 없습니다", 내려받기 단추 없음.
|
||||
보낸 사람 자신에게는 받은 수가 남고 끊은 날이 보인다 — "1.2 GB · 3회 받음 · 10월 31일에 링크를 끊었습니다"(한국 날짜 · 응답의 `revokedAt`, 없으면 "링크를 끊었습니다").
|
||||
끝난 뒤 90일이 지나면 첨부 칸에서 빠지고 덩이는 그냥 본문이 된다.
|
||||
- **보안 검사**: 검사 중이면 [다운로드]를 `aria-disabled`(title · 까닭 연결) + "보안 검사 중 — 끝나면 받을 수 있습니다". 위험은 빨간 줄 "위험 — 보안 검사에서 막았습니다. 받을 수 없습니다"(단추 없음). 검사하지 못한 파일은 받을 수 있고 글로만 알린다.
|
||||
- **전달 쓰기 화면**: 원래 메일의 덩이는 인용 속 글(링크)로 그대로 간다 — 첨부 칸에 다시 싣지 않고 칸 아래 ⓘ 한 줄로만 알린다 "원래 메일의 대용량 첨부 2개는 인용 속 링크로 함께 갑니다 — 11월 29일까지".
|
||||
- **보안 검사**: 검사 중이면 [다운로드]를 `aria-disabled`(title · 까닭 연결) + "보안 검사가 끝나면 받을 수 있습니다". 위험은 빨간 줄 "위험한 내용이 있어 보안 검사에서 막았고, 받을 수 없습니다"(단추 없음). 검사하지 못한 파일은 받을 수 있고 글로만 알린다.
|
||||
- **전달 쓰기 화면**: 원래 메일의 덩이는 인용 속 글(링크)로 그대로 간다 — 첨부 칸에 다시 싣지 않고 칸 아래 ⓘ 한 줄로만 알린다 "원래 메일의 대용량 첨부 2개는 인용 속 링크로 함께 갑니다. 11월 29일까지 받을 수 있습니다.".
|
||||
공유가 멈췄거나 기간이 지난 링크면 갈색 한 줄 "인용 속 ‘도면_초안.dwg’ 링크는 공유가 멈춰 받을 수 없습니다."("… 기간이 지나 받을 수 없습니다.", 파일이 여럿이면 "‘설계도_v3.dwg’ 외 1개") — 보내기는 막지 않는다.
|
||||
전달하는 사람이 새 큰 파일을 더하면 그것은 이 메일의 새 링크다(첨부 칸의 「링크로 보내는 파일」 — 전달하는 사람의 보관 한도에 잡힌다).
|
||||
받는 쪽에서 인용 속 원래 링크는 본문 글이고, 내려받기 화면의 보낸 사람 · 끊을 수 있는 사람은 처음 보낸 사람(링크 주인)이다. 답장도 같다.
|
||||
@@ -617,17 +646,17 @@ API 와 서버 규칙은 계약 2절 "실시간 알림".
|
||||
|
||||
### 내려받기 화면 (`SharePage`)
|
||||
|
||||
- **독립 화면**: 앱 레일 · 상단 바 · 로그인 메뉴가 없다. 머리 바(#0B3640, 60)에 흰 ARA SYSTEM 로고(88) + "파일 내려받기", 바탕 #F6F7F9 에 가운데 열 640. 탭 제목 "김아라 님이 보낸 파일 — ARA SYSTEM".
|
||||
- **독립 화면**: 앱 레일 · 상단 바 · 로그인 메뉴가 없다. 머리 바(#0B3640, 60)에 흰 ARA SYSTEM 로고(88) + "파일 내려받기", 바탕 #F6F7F9 에 가운데 열 640. 탭 제목 "김아라 님이 보낸 파일 · ARA SYSTEM".
|
||||
- **정상**: "김아라 님이 보낸 파일 2개 (1.8 GB)"(20) → "보낸 사람 김아라 <ara.kim@arasystem.it.kr> · ARA SYSTEM"(로그인한 계정의 값 — 보낸 사람이 바꿀 수 없다) →
|
||||
"2026년 11월 29일(일)까지 내려받을 수 있습니다 (한국 시간) · 30일 남음" → 파일 목록(줄마다 아이콘 36 · 이름 · "1.2 GB · 보안 검사 · 이상 없음" · [내려받기](채운 단추 44)) →
|
||||
"내려받다 끊기면 브라우저의 다운로드 목록에서 다시 시도하세요 — 받은 곳부터 이어서 받습니다." →
|
||||
"한국 시간으로 2026년 11월 29일(일)까지 내려받을 수 있습니다. 30일 남았습니다."(날짜만 굵게) → 파일 목록(줄마다 아이콘 36 · 이름 · "1.2 GB · 보안 검사 결과 이상 없음" · [내려받기](채운 단추 44)) →
|
||||
"내려받다 끊기면 브라우저의 다운로드 목록에서 다시 시도해 주세요. 받은 곳부터 이어서 받습니다." →
|
||||
청록 상자 "이 화면은 로그인이나 비밀번호를 묻지 않습니다. 묻는 화면이 나오면 닫고 보낸 사람에게 확인해 주세요." →
|
||||
"받을 이유가 없는 파일이면 postmaster@arasystem.it.kr 로 알려 주세요."(운영 설정 — 처음 값 postmaster@메일 도메인) → 영어 한 줄.
|
||||
- 메일 제목 · 본문 · 받는 사람, 내려받은 수 · 남은 수는 보이지 않는다. [내려받기]는 형식과 상관없이 늘 저장이고, 누르기 직전에 상태를 한 번 더 묻는다. noindex · no-referrer.
|
||||
- **상태**(모든 상태에서 머리 · 영어 한 줄은 그대로. 신고 주소 줄은 링크 정보를 받은 상태에서만 — 없는 링크 · 요청이 너무 많음 · 오류 응답에는 그 값이 없다):
|
||||
- 검사 중 — 모두 검사 중이면 제목 "보안 검사 중입니다" / "파일을 검사하고 있습니다. 몇 분 뒤 이 화면을 다시 열어 주세요." + [다시 확인]. 일부면 그 줄만 "보안 검사 중" + [내려받기] `aria-disabled`.
|
||||
- 위험 — "내려받을 수 없는 파일입니다" / "보안 검사에서 위험한 내용이 발견되어 막았습니다. 보낸 사람에게 알렸습니다." 그 줄은 빨강 "위험 — 막았습니다", 단추 없음.
|
||||
- 검사하지 못함 — 제목은 그대로, 줄에 갈색 "검사하지 못함 — 암호가 걸린 압축 파일 등 안을 볼 수 없는 파일입니다. 보낸 사람을 아는 경우에만 여세요." + [내려받기](받을 수 있다).
|
||||
- 위험 — "내려받을 수 없는 파일입니다" / "보안 검사에서 위험한 내용이 발견되어 막았습니다. 보낸 사람에게 알렸습니다." 그 줄은 빨강 "위험한 내용이 있어 막았습니다", 단추 없음.
|
||||
- 검사하지 못함 — 제목은 그대로, 줄에 갈색 "암호가 걸린 압축 파일처럼 안을 볼 수 없어 검사하지 못한 파일입니다. 보낸 사람을 아는 경우에만 열어 주세요." + [내려받기](받을 수 있다).
|
||||
- 횟수 초과(파일마다 운영 설정 — 처음 값 500번, 넘은 파일만) — 그 줄에 "내려받을 수 있는 횟수를 넘었습니다. 보낸 사람에게 다시 보내 달라고 요청해 주세요."(숫자는 쓰지 않는다 — 한도는 운영 설정이고 응답에 없다)
|
||||
- 만료 — "내려받을 수 있는 기간이 지났습니다" / "2026년 11월 29일까지 받을 수 있었던 파일입니다. 파일은 지워져 다시 받을 수 없습니다. 필요하면 보낸 사람(김아라, ara.kim@arasystem.it.kr)에게 다시 보내 달라고 요청해 주세요."
|
||||
- 회수 — "보낸 사람이 공유를 멈췄습니다" / "필요하면 보낸 사람(김아라, ara.kim@arasystem.it.kr)에게 문의해 주세요." 파일 이름도 보이지 않는다.
|
||||
@@ -640,15 +669,15 @@ API 와 서버 규칙은 계약 2절 "실시간 알림".
|
||||
- **메뉴**: 메일 묶음의 차단한 주소 아래 「대용량 첨부」 — 서명 · 부재중 자동 응답 · 필터 · 보내기 · 읽기 · 차단한 주소 · 대용량 첨부 · 알림.
|
||||
- 머리 "대용량 첨부" + "메일에 링크로 보낸 큰 파일입니다. 받는 사람은 보낸 날부터 30일 동안 내려받고, 링크를 끊으면 바로 받을 수 없습니다."
|
||||
- **사용량 상자**: "보관 사용량 6.2 / 10 GB"(메일함 사용량과 같은 표기) + 4px 막대(90% 를 넘으면 빨강 — 메일함 사용량과 같다) +
|
||||
"보낸 링크 4.3 GB · 보내지 않은 파일 1.9 GB(임시 저장 · 올리다 멈춤). 기간이 지나거나, 링크를 끊거나, 보내지 않은 파일을 지우면 바로 줄어듭니다."
|
||||
- **보내지 않은 파일**(사용량 상자 바로 아래 — 공간을 바로 비울 수 있는 곳이라 보낸 링크보다 위): 「보내지 않은 파일 3개」 + "1.9 GB · 임시 저장한 메일에 실렸거나 올리다 멈춘 파일 — 지우면 바로 공간이 생깁니다". 없으면 묶음째 보이지 않는다.
|
||||
"보낸 링크 4.3 GB, 임시 저장했거나 올리다 멈춘 파일 1.9 GB입니다. 기간이 지나거나, 링크를 끊거나, 보내지 않은 파일을 지우면 바로 줄어듭니다."
|
||||
- **보내지 않은 파일**(사용량 상자 바로 아래 — 공간을 바로 비울 수 있는 곳이라 보낸 링크보다 위): 「보내지 않은 파일 3개」 + "1.9 GB · 임시 저장한 메일에 실렸거나 올리다 멈춘 파일이라 지우면 바로 공간이 생깁니다". 없으면 묶음째 보이지 않는다.
|
||||
- 줄(52): 아이콘 · 이름 / (올리다 멈춘 파일은 앞에 갈색 "올리다 멈춤") · 크기(멈춘 파일은 "1.2 GB" / "420 MB 올림") ·
|
||||
실린 저장본의 제목 · 링크는 보이지 않는다(2026-10-30 — 저장본 id 는 저장할 때마다 바뀌고 엔진 밖에서도 지워져 서버가 따라가기 무겁다, 계약 4절 `LargeFile` 에 없음) ·
|
||||
남는 기한 "11월 29일까지 남음" / "올린 뒤 30일"(올리다 멈춘 파일은 이어 올리기 규칙대로 "내일 오후 2:41까지 남음" / "마지막으로 올린 뒤 24시간") · [지우기](36, 빨간 글).
|
||||
- [지우기] → 확인 창(alertdialog 400) "보내지 않은 파일을 지울까요?" / "‘시공도면_v4.dwg’(620 MB) 파일을 지웁니다. 이 파일을 실은 임시 저장본이 있으면 열 때 첨부 칸에 ‘지운 파일’로 보입니다. 지운 파일은 되돌릴 수 없습니다."
|
||||
- [지우기] → 확인 창(alertdialog 400) "보내지 않은 파일을 지울까요?" / "‘시공도면_v4.dwg’(620 MB) 파일을 지웁니다. 이 파일을 실은 임시 저장본을 열면 첨부 칸에 지운 파일로 보입니다. 지운 파일은 되돌릴 수 없습니다."
|
||||
[지우기](빨강) [취소](처음 초점 · Esc) → "‘시공도면_v4.dwg’ 파일을 지웠습니다." — 줄이 사라지고 사용량이 바로 준다.
|
||||
그 저장본을 쓰기 화면에서 열면 첨부 칸에 빨간 줄 "설정 › 대용량 첨부에서 지운 파일입니다 — 빼고 다시 올려 주세요."(쓰기 화면의 "기간이 지나 지운 파일"과 같은 모양 · 다른 글).
|
||||
- **보낸 링크**: 「보낸 링크 6개」 + "메일마다 한 덩이 · 최근에 보낸 차례". 메일(링크)마다 한 덩이 — 링크가 메일 한 통에 하나라 [링크 끊기]는 덩이에 한 번이다(파일 줄마다 두면 같은 메일의 다른 파일까지 끊긴다).
|
||||
그 저장본을 쓰기 화면에서 열면 첨부 칸에 빨간 줄 "[설정] → [대용량 첨부]에서 지운 파일입니다. 빼고 다시 올려 주세요."(쓰기 화면의 "기간이 지나 지워진 파일"과 같은 모양 · 다른 글).
|
||||
- **보낸 링크**: 「보낸 링크 6개」 + "메일마다 하나씩, 최근에 보낸 순서". 메일(링크)마다 한 덩이 — 링크가 메일 한 통에 하나라 [링크 끊기]는 덩이에 한 번이다(파일 줄마다 두면 같은 메일의 다른 파일까지 끊긴다).
|
||||
- 머리: 메일 제목(청록 링크 → 보낸편지함의 그 메일, 메일을 지웠으면 글자만) / "10월 30일 (금) 보냄 · 파일 2개 · 1.8 GB" · 오른쪽 "11월 29일까지" / "30일 남음"(3일 이하면 갈색 굵게) · [링크 끊기](36).
|
||||
- 파일 줄(44 · 바탕 #FBFBFC): 아이콘 · 이름 · 크기 · "3회 받음" / "마지막 오늘 오후 2:41"(받지 않았으면 "아직 받지 않음") · 상태.
|
||||
- 상태 글: 사용 중 · 검사 중(회색) · 위험(빨강 굵게 + "내려받기를 막음", 줄 바탕 #FEF3F2) · 기간 지남 · 공유 멈춤. 검사하지 못한 파일은 상태 아래 갈색 "검사하지 못함". 뱃지 없이 글자만.
|
||||
@@ -686,14 +715,14 @@ API 와 서버 규칙은 계약 2절 "실시간 알림".
|
||||
- 제목 "2단계 인증" / "비밀번호를 확인했습니다. 휴대폰 인증 앱에 표시된 코드를 넣어 주세요."
|
||||
- 주소 + [다른 계정으로 로그인] — 처음 화면으로(주소 칸은 비움), 확인한 비밀번호는 버린다.
|
||||
- 칸 "인증 앱에 표시된 6자리 코드" — 숫자 6자리만, 붙여 넣은 글의 띄어쓰기 · 하이픈은 지운다. `inputmode="numeric"` · `autocomplete="one-time-code"`.
|
||||
- [확인] → "인증 앱을 쓸 수 없나요? 관리자에게 문의해 주세요." → "Outlook · 휴대폰 메일 앱은 이 코드를 묻지 않습니다. 설정 › 앱 비밀번호에서 만든 비밀번호로 연결합니다."
|
||||
- [확인] → "인증 앱을 쓸 수 없나요? 관리자에게 문의해 주세요." → "Outlook이나 휴대폰 메일 앱은 이 코드를 묻지 않습니다. [설정] → [앱 비밀번호]에서 만든 비밀번호로 연결해 주세요."
|
||||
- **상태**
|
||||
- 코드 틀림 — 칸 아래 빨간 글(`AUTH_006` 사전 문구 "코드가 올바르지 않습니다. 휴대폰의 날짜와 시간이 「자동」으로 맞춰져 있는지 확인해 주세요."). 칸을 비우고 초점은 그 칸, 알림은 없다.
|
||||
- 코드 틀림 — 칸 아래 빨간 글(`AUTH_006` 사전 문구 "코드가 올바르지 않습니다. 휴대폰의 날짜와 시간이 자동으로 맞춰지도록 설정되어 있는지 확인해 주세요."). 칸을 비우고 초점은 그 칸, 알림은 없다.
|
||||
- 시도 과다 — 위쪽 빨간 상자(로그인 오류와 같은 자리 · 같은 모양)에 `REQ_001` 사전 문구. 코드 실패는 비밀번호 틀림과 따로 센다(같은 주소 15분 5회 · 하루 20회). 칸 · 단추는 그대로 둔다.
|
||||
- 확인 중 — 「확인하는 중…」(로그인 단추와 같은 모양 · `aria-busy`), 칸은 읽기만. 맞으면 받은편지함으로 — 로그인 전에 가려던 주소가 있으면 그곳으로.
|
||||
- 이 화면에 10분 넘게 머물면 비밀번호를 바로 지우고, [확인] 때 처음 화면으로 가 위쪽 상자에 "코드를 넣는 시간이 지났습니다. 다시 로그인해 주세요."를 보인다. 새로고침도 처음 화면이다(비밀번호는 화면 메모리에만 있다).
|
||||
- 코드 단계에서 비밀번호가 틀리면(그사이 바뀜) 처음 화면의 위쪽 오류 상자에 보인다 — 지금 로그인 화면과 같은 자리. 6자리가 아니면 보내지 않고 칸 아래에 `AUTH_007` 사전 문구.
|
||||
- 로그인 기록에는 코드까지 맞으면 「성공 · 2단계 인증」, 틀리면 「실패 — 코드 틀림」으로 남는다.
|
||||
- 로그인 기록에는 코드까지 맞으면 「2단계 인증으로 성공」, 틀리면 「코드가 틀려 실패」로 남는다.
|
||||
- 성공에 쓴 코드는 2분 동안 다시 받지 않는다 — 30초 안에 두 곳(PC · 휴대폰)에서 연달아 로그인하면 두 번째는 「코드가 올바르지 않습니다…」이고, 앱에 다음 코드가 뜰 때 넣는다.
|
||||
|
||||
### 설정 › 계정 › 2단계 인증 (`SettingsTwoFactor`)
|
||||
@@ -701,43 +730,43 @@ API 와 서버 규칙은 계약 2절 "실시간 알림".
|
||||
- 머리 "2단계 인증" + "로그인할 때 비밀번호와 함께 휴대폰 인증 앱의 코드를 확인합니다. 비밀번호가 새어 나가도 휴대폰이 없으면 로그인할 수 없습니다."
|
||||
- **상태는 글자로만**(뱃지 · 스위치 없음) — 테두리 상자 하나:
|
||||
- 「꺼짐」(회색 굵게) / "지금은 로그인할 때 비밀번호만 확인합니다." + [2단계 인증 켜기]
|
||||
- 「켜짐 — 2026년 10월 31일부터」(초록 #067647 굵게 — 켠 때를 모르면 「켜짐」) / "로그인할 때마다 비밀번호와 인증 앱의 코드를 확인합니다." + [2단계 인증 끄기]
|
||||
- 「2026년 10월 31일부터 켜짐」(초록 #067647 굵게 — 켠 때를 모르면 「켜짐」) / "로그인할 때마다 비밀번호와 인증 앱의 코드를 확인합니다." + [2단계 인증 끄기]
|
||||
- **꺼져 있을 때** 상자 아래:
|
||||
- 갈색 경고 "켜면 메일 프로그램은 앱 비밀번호로 다시 연결해야 합니다" / "Outlook · 썬더버드 · 휴대폰 메일 앱은 웹메일 비밀번호로 새로 연결할 수 없고, 이미 연결해 둔 프로그램도 다음에 다시 연결할 때 막힙니다. 켜기 전에 앱 비밀번호를 만들어 두면 편합니다."(앱 비밀번호 링크)
|
||||
- 「켜는 차례」 1 · 2 · 3(청록 굵은 숫자 — 동그라미 없음): 인증 앱 설치(Google · Microsoft Authenticator 처럼 6자리 코드를 만드는 앱) → [2단계 인증 켜기]를 눌러 QR 찍기 → 코드와 지금 비밀번호를 넣으면 켜진다.
|
||||
- **켜져 있을 때** 상자 아래: "휴대폰을 바꿀 때는 2단계 인증을 끈 뒤 새 휴대폰으로 다시 켜 주세요 — 끌 때 지금 휴대폰의 코드가 필요합니다." · "휴대폰을 잃어버렸거나 인증 앱을 지웠으면 관리자에게 문의해 주세요." · "메일 프로그램은 앱 비밀번호로 연결합니다."
|
||||
- 갈색 경고 "켜면 메일 프로그램은 앱 비밀번호로 다시 연결해야 합니다" / "Outlook, 썬더버드, 휴대폰 메일 앱은 웹메일 비밀번호로 새로 연결할 수 없고, 이미 연결해 둔 프로그램도 다음에 다시 연결할 때 막힙니다. 켜기 전에 앱 비밀번호를 만들어 두면 편합니다."(앱 비밀번호 링크)
|
||||
- 「켜는 방법」 1 · 2 · 3(청록 굵은 숫자 — 동그라미 없음): 인증 앱 설치(Google · Microsoft Authenticator 처럼 6자리 코드를 만드는 앱) → [2단계 인증 켜기]를 눌러 QR 찍기 → 코드와 지금 비밀번호를 넣으면 켜진다.
|
||||
- **켜져 있을 때** 상자 아래: "휴대폰을 바꿀 때는 2단계 인증을 끈 뒤 새 휴대폰으로 다시 켜 주세요. 끌 때 지금 휴대폰의 코드가 필요합니다." · "휴대폰을 잃어버렸거나 인증 앱을 지웠으면 관리자에게 문의해 주세요." · "메일 프로그램은 앱 비밀번호로 연결합니다."
|
||||
- **켜기 창**(모달 620 — `wide`, 필터 고치기 창과 같은 폭 · 처음 초점은 코드 칸)
|
||||
- ① "인증 앱으로 QR 코드를 찍어 주세요" / "인증 앱에서 [+] → 「QR 코드 스캔」을 고르고 이 그림을 찍습니다." + QR 그림(160 — 실제 otpauth 주소는 오류 정정 M 에서 49칸이라 보드의 128 보다 키웠다)
|
||||
- "찍을 수 없으면 이 키를 입력하세요" + 32자 키(4자씩 띄움, 고정폭) + [복사](36 — 「키를 복사했습니다.」) + "앱에는 「ARA SYSTEM (ara.kim@arasystem.it.kr)」로 저장됩니다."
|
||||
- ① "인증 앱으로 QR 코드를 찍어 주세요" / "인증 앱에서 [+] → [QR 코드 스캔]을 고른 뒤 이 그림을 찍어 주세요." + QR 그림(160 — 실제 otpauth 주소는 오류 정정 M 에서 49칸이라 보드의 128 보다 키웠다)
|
||||
- "찍을 수 없으면 이 키를 입력해 주세요" + 32자 키(4자씩 띄움, 고정폭) + [복사](36 — 「키를 복사했습니다.」) + "앱에는 ‘ARA SYSTEM (ara.kim@arasystem.it.kr)’로 저장됩니다."
|
||||
- ② "앱에 표시된 6자리 코드" ③ "지금 비밀번호" → [켜기] [취소]
|
||||
- 칸 아래 오류(알림 없이, 첫 잘못된 칸에 초점): 코드 `AUTH_006` · 지금 비밀번호 `AUTH_005`("지금 비밀번호가 올바르지 않습니다."). 둘 다 틀렸으면 코드부터 — 서버가 코드를 먼저 본다.
|
||||
틀린 코드는 지우고 그 칸에 초점을 둔다. QR · 키는 그대로라 다시 찍지 않아도 된다. 비밀번호 틀림은 로그인 실패와 함께, 코드 틀림은 코드 실패로 따로 센다.
|
||||
- 켜는 동안 「켜는 중…」 · [취소]. 맞으면 창을 닫고 화면이 「켜짐」이 되며, 위에 안내 상자 "2단계 인증을 켰습니다" / "메일 프로그램(Outlook · 썬더버드 · 휴대폰 메일 앱)은 다시 연결할 때 앱 비밀번호가 필요합니다." [앱 비밀번호 만들기](36) — 이 화면을 떠나면 사라진다.
|
||||
- 켜는 동안 「켜는 중…」 · [취소]. 맞으면 창을 닫고 화면이 「켜짐」이 되며, 위에 안내 상자 "2단계 인증을 켰습니다" / "Outlook, 썬더버드, 휴대폰 메일 앱 같은 메일 프로그램은 다시 연결할 때 앱 비밀번호가 필요합니다." [앱 비밀번호 만들기](36) — 이 화면을 떠나면 사라진다.
|
||||
- 키는 창을 열 때마다 새로 만든다(10분 안에 켠다 — 지나면 `AUTH_008` 로 처음부터). 켜지 않고 닫으면 그 키는 버린다 — 앱에 먼저 저장한 항목은 지워 달라고 안내한다. QR · 키는 이 창에만 보이고 로그에 남기지 않는다.
|
||||
- 켜도 다른 곳의 웹메일 로그인은 끊지 않는다 — 끊으려면 로그인 기록에서.
|
||||
- **끄기 창**(alertdialog 400 · 처음 초점 「취소」, Esc 도 같다): "2단계 인증을 끌까요?" / "끄면 비밀번호만으로 로그인합니다. 만들어 둔 앱 비밀번호는 그대로 남습니다." + 지금 비밀번호 · 인증 앱 코드 → [끄기](빨강) [취소].
|
||||
끄면 「2단계 인증을 껐습니다.」 — 화면은 「꺼짐」. 틀리면 켜기 창과 같은 칸 아래 글이다.
|
||||
- **비밀번호 화면**(설정 › 비밀번호)
|
||||
- 2단계 인증을 켠 직원: 지금 양식 맨 아래에 「인증 앱 코드」 칸 + "코드는 30초마다 바뀝니다 — 다른 칸을 다 쓴 뒤 넣어 주세요."
|
||||
- 2단계 인증을 켠 직원: 지금 양식 맨 아래에 「인증 앱 코드」 칸 + "코드는 30초마다 바뀌니 다른 칸을 다 채운 뒤에 넣어 주세요."
|
||||
머리 설명은 "웹메일에 로그인하는 비밀번호입니다. 2단계 인증이 켜져 있어 메일 프로그램은 앱 비밀번호로 연결합니다." 다른 탭에서 켜서 `AUTH_007` 이 오면 2단계 상태를 다시 받아 코드 칸을 보인다.
|
||||
- 모든 직원: 단추 아래 "바꾸면 이 브라우저를 뺀 다른 기기 · 브라우저의 웹메일 로그인이 끊깁니다." "앱 비밀번호도 모두 지워집니다 — 메일 프로그램은 새 앱 비밀번호로 다시 연결해 주세요."
|
||||
- 모든 직원: 단추 아래 "바꾸면 이 브라우저를 빼고 다른 기기와 브라우저의 웹메일에서 로그아웃됩니다." "앱 비밀번호도 모두 지워지니, 메일 프로그램은 새 앱 비밀번호로 다시 연결해 주세요."
|
||||
|
||||
### 설정 › 계정 › 앱 비밀번호 (`SettingsAppPasswords`)
|
||||
|
||||
- 머리 "앱 비밀번호" + "Outlook · 썬더버드 · 휴대폰 메일 앱 같은 메일 프로그램에 넣는 비밀번호입니다. 프로그램마다 따로 만들어 두면, 기기를 잃었을 때 그것만 지울 수 있습니다."
|
||||
- 머리 "앱 비밀번호" + "Outlook, 썬더버드, 휴대폰 메일 앱 같은 메일 프로그램에 넣는 비밀번호입니다. 프로그램마다 따로 만들어 두면 기기를 잃어버렸을 때 그 비밀번호만 지울 수 있습니다."
|
||||
오른쪽 "3 / 5개" + [앱 비밀번호 만들기].
|
||||
- **표**(구현은 실제 table): 이름 · 만든 날 · 마지막 사용 · [지우기](36, 빨간 글). 최근에 만든 것이 위, 긴 이름은 말줄임.
|
||||
마지막 사용은 "오늘 오전 9:12 · IMAP · 211.186.95.252" — 쓰지 않았으면 "아직 쓰지 않음"(회색, 오래 쓰지 않은 줄은 지워도 된다는 신호).
|
||||
- **메일 프로그램 설정값 상자**: 받는 메일(IMAP) `mx1.arasystem.it.kr` · 포트 993 · SSL/TLS / 보내는 메일(SMTP) `mx1.arasystem.it.kr` · 포트 465 · SSL/TLS /
|
||||
사용자 이름 = 메일 주소 전체 / 비밀번호 = 여기서 만든 앱 비밀번호 — 웹메일 비밀번호가 아닙니다. 호스트 · 포트는 화면 상수 하나에 둔다(전 직원 전환 때 다시 본다).
|
||||
- **아래 안내**: "앱 비밀번호는 메일 읽기 · 보내기(IMAP · SMTP)에만 쓰입니다. 웹메일 로그인에는 쓸 수 없습니다." · "2단계 인증을 켜면 메일 프로그램은 앱 비밀번호로만 연결합니다. 켜지 않아도 만들어 쓸 수 있습니다." ·
|
||||
"웹메일 비밀번호를 바꾸면 앱 비밀번호도 모두 지워집니다 — 메일 프로그램마다 새로 만들어 주세요." · "휴대폰이나 PC 를 잃어버렸으면 그 기기의 앱 비밀번호를 바로 지워 주세요."
|
||||
사용자 이름 = 메일 주소 전체 / 비밀번호 = "웹메일 비밀번호가 아닌, 여기서 만든 앱 비밀번호". 호스트 · 포트는 화면 상수 하나에 둔다(전 직원 전환 때 다시 본다).
|
||||
- **아래 안내**: "앱 비밀번호는 메일 프로그램에서 메일을 읽고 보낼 때만 씁니다. 웹메일 로그인에는 쓸 수 없습니다." · "2단계 인증을 켜면 메일 프로그램은 앱 비밀번호로만 연결합니다. 켜지 않아도 만들어 쓸 수 있습니다." ·
|
||||
"웹메일 비밀번호를 바꾸면 앱 비밀번호도 모두 지워지니, 메일 프로그램마다 새로 만들어 주세요." · "휴대폰이나 PC를 잃어버렸다면 그 기기의 앱 비밀번호를 바로 지워 주세요."
|
||||
- **5개를 모두 만들었을 때**: [앱 비밀번호 만들기]는 `aria-disabled` + 머리 아래 한 줄 "5개까지 만들 수 있습니다. 쓰지 않는 앱 비밀번호를 지운 뒤 만들어 주세요." — 누르면 창을 열지 않고 까닭을 읽어 준다(role=status).
|
||||
- **빈 상태**: "0 / 5개" + "만든 앱 비밀번호가 없습니다" / "Outlook · 휴대폰 메일 앱에서 메일을 보려면 앱 비밀번호를 만들어 주세요. 2단계 인증을 켰다면 메일 프로그램은 앱 비밀번호로만 연결합니다."
|
||||
- **빈 상태**: "0 / 5개" + "만든 앱 비밀번호가 없습니다" / "Outlook이나 휴대폰 메일 앱에서 메일을 보려면 앱 비밀번호를 만들어 주세요. 2단계 인증을 켰다면 메일 프로그램은 앱 비밀번호로만 연결할 수 있습니다."
|
||||
설정값 상자 · 아래 안내는 그대로. 목록을 받지 못하면 지금의 목록 오류와 같은 모양(사전 문구 + [다시 시도]) — 그동안 [만들기]는 막는다(개수를 모르므로).
|
||||
- **만들기 창**(400 · 처음 초점 이름 칸): 「이름」 + "어느 프로그램 · 기기에서 쓰는지 알아볼 이름입니다. 40자까지." → [만들기] [취소].
|
||||
- **만들기 창**(400 · 처음 초점 이름 칸): 「이름」 + "어느 프로그램이나 기기에서 쓰는지 알아볼 수 있게 지어 주세요. 40자까지 쓸 수 있습니다." → [만들기] [취소].
|
||||
칸 아래: "이름을 넣어 주세요." · "같은 이름의 앱 비밀번호가 있습니다."(앞뒤 공백을 지운 값으로 화면이 먼저 본다) · 그사이 다른 창에서 5개를 채웠으면 `AUTH_009` 사전 문구.
|
||||
- **만든 결과 창**(620 · 처음 초점 [복사]): "앱 비밀번호를 만들었습니다" / "‘회사 PC 아웃룩’ — 메일 프로그램의 비밀번호 칸에 아래 값을 넣어 주세요."
|
||||
- **만든 결과 창**(620 · 처음 초점 [복사]): "앱 비밀번호를 만들었습니다" / "‘회사 PC 아웃룩’ 앱 비밀번호입니다. 메일 프로그램의 비밀번호 칸에 아래 값을 넣어 주세요."
|
||||
- 값은 고정폭으로 4자씩 떨어뜨려 보이지만 띄어쓰기는 없다 — [복사](44 — 창의 주 동작이라 보드대로)는 40자 그대로. "40자입니다. 보기 좋게 4자씩 띄웠지만 띄어쓰기는 없습니다."
|
||||
- 경고 "이 창을 닫으면 다시 볼 수 없습니다. 잊어버리면 지우고 새로 만들어 주세요." + 설정값 요약 + [닫기].
|
||||
- 복사 「앱 비밀번호를 복사했습니다.」 / 못 하면 「앱 비밀번호를 복사하지 못했습니다. 값을 직접 골라 복사해 주세요.」
|
||||
@@ -750,18 +779,18 @@ API 와 서버 규칙은 계약 2절 "실시간 알림".
|
||||
- 머리 "로그인 기록" + "최근 90일 동안의 로그인입니다. 모르는 기록이 있으면 바로 비밀번호를 바꾸고, 2단계 인증이 꺼져 있으면 켜 주세요."(두 링크).
|
||||
- **로그인된 브라우저**: 「로그인된 브라우저」 + "웹메일 3곳" · 오른쪽 [다른 곳 모두 로그아웃](44).
|
||||
- 줄마다 PC · 휴대폰 아이콘(16) · 브라우저 · 운영체제 / IP / 마지막 활동 / [로그아웃](36). 맨 위는 지금 이 브라우저 — 단추 대신 「지금 이 브라우저」 글자만. 모르면 "알 수 없는 브라우저".
|
||||
- 아래 "웹메일 로그인만 보입니다. 메일 프로그램은 앱 비밀번호를 지워 끊습니다."(링크)
|
||||
- 줄의 [로그아웃]은 묻지 않고 바로 끊는다 → 「‘Edge · Windows’의 로그인을 끊었습니다.」(되돌리기 없음). 끊은 줄은 빠지고, 다른 곳이 없으면 [다른 곳 모두 로그아웃]을 그리지 않는다.
|
||||
- [다른 곳 모두 로그아웃] → 확인(alertdialog 400 · 처음 초점 「취소」) "다른 곳의 로그인을 모두 끊을까요?" /
|
||||
"이 브라우저를 뺀 2곳(Edge · Windows, Safari · iPhone)의 웹메일 로그인을 끊습니다. 끊긴 곳은 다음 동작에서 바로 로그인 화면으로 돌아갑니다." "메일 프로그램은 끊기지 않습니다 — 앱 비밀번호를 지워 끊어 주세요."
|
||||
[모두 로그아웃](청록 주 단추 — 데이터를 지우는 일이 아니다) [취소] → 「다른 곳의 로그인 2곳을 끊었습니다.」
|
||||
- 아래 "웹메일 로그인만 보입니다. 메일 프로그램의 연결을 끊으려면 앱 비밀번호를 지워 주세요."(링크)
|
||||
- 줄의 [로그아웃]은 묻지 않고 바로 끊는다 → 「‘Edge · Windows’에서 로그아웃했습니다.」(되돌리기 없음). 끊은 줄은 빠지고, 다른 곳이 없으면 [다른 곳 모두 로그아웃]을 그리지 않는다.
|
||||
- [다른 곳 모두 로그아웃] → 확인(alertdialog 400 · 처음 초점 「취소」) "다른 곳에서 모두 로그아웃할까요?" /
|
||||
"이 브라우저를 뺀 2곳(Edge · Windows, Safari · iPhone)의 웹메일에서 로그아웃합니다. 그곳에서 다음에 무엇이든 누르면 바로 로그인 화면이 나옵니다." "메일 프로그램은 로그아웃되지 않습니다. 연결을 끊으려면 앱 비밀번호를 지워 주세요."
|
||||
[모두 로그아웃](청록 주 단추 — 데이터를 지우는 일이 아니다) [취소] → 「다른 2곳에서 로그아웃했습니다.」
|
||||
- 끊긴 브라우저는 다음 요청에서 바로 로그인 화면으로 간다(열린 실시간 알림도 30초 안에) — 계약 5절 "로그인된 브라우저".
|
||||
- **최근 로그인**: 「최근 로그인」 + "최근 90일 · 웹메일과 메일 프로그램" · 오른쪽 전환 [전체 · 실패만](`.segmented--outline` — role=group + `aria-pressed`). 표: 시각 · 방식 · IP · 기기 · 결과.
|
||||
- 방식: "웹메일" / "메일 프로그램 · IMAP" · "메일 프로그램 · SMTP" + 앱 비밀번호로 들어왔으면 아래 줄 "앱 비밀번호 ‘회사 PC 아웃룩’"(지운 앱 비밀번호도 그 이름 그대로).
|
||||
- 기기: 웹메일은 "Chrome · Windows", 메일 프로그램은 "—". 나라 · 지역은 보이지 않는다.
|
||||
- 결과: 「성공」 · 「성공 · 2단계 인증」 · 「실패 — 비밀번호 틀림」 · 「실패 — 코드 틀림」 · 「실패 — 앱 비밀번호 필요」(2단계를 켠 계정에 메일 프로그램이 웹메일 비밀번호로 연결하려 함). 실패는 빨간 굵은 글자만 — 뱃지 없음.
|
||||
- 결과: 「성공」 · 「2단계 인증으로 성공」 · 「비밀번호가 틀려 실패」 · 「코드가 틀려 실패」 · 「앱 비밀번호가 필요해 실패」(2단계를 켠 계정에 메일 프로그램이 웹메일 비밀번호로 연결하려 함). 실패는 빨간 굵은 글자만 — 뱃지 없음.
|
||||
- 최근 순서 · 20개씩 [더 보기](44). 「실패만」이면 "최근 90일 · 실패 4번", [더 보기]도 실패만 이어서.
|
||||
- 빈 상태(실패만): "최근 90일 동안 실패한 로그인이 없습니다" / "[전체]를 누르면 성공한 로그인도 봅니다." 기록은 이 기능을 배포한 날부터 쌓인다.
|
||||
- 빈 상태(실패만): "최근 90일 동안 실패한 로그인이 없습니다" / "[전체]를 누르면 성공한 로그인도 볼 수 있습니다." 전체가 비었으면 "이 기능이 생긴 날부터 기록이 쌓입니다."
|
||||
- 목록을 받지 못하면 지금의 목록 오류와 같은 모양(사전 문구 + [다시 시도]).
|
||||
|
||||
### 처음 판에서 미룬 것
|
||||
@@ -789,11 +818,11 @@ API 와 서버 규칙은 계약 2절 "실시간 알림".
|
||||
- 제목 "새 비밀번호를 정해 주세요" / "관리자가 정한 임시 비밀번호로 로그인했습니다. 메일을 쓰려면 먼저 새 비밀번호를 정해 주세요."
|
||||
- 주소 + [다른 계정으로 로그인] — 로그아웃하고 처음 화면으로(주소 칸은 비움).
|
||||
- (임시 비밀번호가 화면 메모리에 없을 때만) 「임시 비밀번호」 + "관리자에게 받은 임시 비밀번호를 다시 넣어 주세요."
|
||||
- 「새 비밀번호」 + "8자 이상 · 임시 비밀번호와 달라야 합니다. 영문 · 숫자 · 기호를 섞으면 더 안전합니다."
|
||||
- 「새 비밀번호」 + "8자 이상이고 임시 비밀번호와 달라야 합니다. 영문, 숫자, 기호를 섞으면 더 안전합니다."
|
||||
- 「새 비밀번호 확인」 — 보기 단추 없이 새 비밀번호의 보기를 따른다(설정 › 비밀번호와 같다).
|
||||
- (2단계 인증을 켠 직원만) 맨 아래 「인증 앱 코드」 + "로그인할 때 넣은 코드는 다시 쓸 수 없습니다 — 앱에 다음 코드가 뜨면 넣어 주세요." — 로그인 코드 칸 모양(18px · 자간), 숫자 6자리 · `one-time-code`.
|
||||
- (2단계 인증을 켠 직원만) 맨 아래 「인증 앱 코드」 + "로그인할 때 넣은 코드는 다시 쓸 수 없으니, 앱에 다음 코드가 뜨면 넣어 주세요." — 로그인 코드 칸 모양(18px · 자간), 숫자 6자리 · `one-time-code`.
|
||||
- [비밀번호 바꾸고 계속] — 보내는 동안 「바꾸는 중…」(`aria-busy`, 칸은 읽기만). 요청 한도 30초(`PASSWORD_CHANGE_TIMEOUT_MS` — 설정 › 비밀번호와 같다).
|
||||
- 맨 아래(위쪽 선 · 12px 회색): "메일 프로그램(아웃룩 등)을 연결해 두었다면 바꾼 뒤 다시 연결해야 합니다."
|
||||
- 맨 아래(위쪽 선 · 12px 회색): "아웃룩 같은 메일 프로그램을 연결해 두었다면 비밀번호를 바꾼 뒤 다시 연결해 주세요."
|
||||
- 칸은 로그인 칸 모양(테두리 #8A94A3 · 글자 14px · 이름 12.5px 굵게)이고, 임시 비밀번호 · 새 비밀번호 칸 끝에 설정 화면의 [보기](44)를 둔다 — 로그인 첫 화면의 비밀번호 칸에는 없다.
|
||||
- **임시 비밀번호**: 로그인 때 넣은 것을 화면 메모리에서 지금 비밀번호로 쓴다 — 묻지 않는다. 10분 뒤 메모리에서 지운다(12단계 코드 단계와 같은 10분).
|
||||
메모리에 없으면(새로고침 · 다른 탭 · 10분 지남) 맨 위에 「임시 비밀번호」 칸을 보인다.
|
||||
@@ -816,17 +845,17 @@ API 와 서버 규칙은 계약 2절 "실시간 알림".
|
||||
- **누가 오나**: 회사가 2단계 인증을 필수로 정한 날(시작일)부터, 아직 켜지 않은 직원(예외 주소 빼고)이 비밀번호를 맞혔을 때(`restriction: twoFactorSetup`).
|
||||
켜기 전에는 메일 · 주소록 · 설정을 열 수 없다(`AUTH_010`). 새 비밀번호 정하기가 함께 걸린 직원(입사)은 그것 먼저.
|
||||
- **틀**(위에서부터):
|
||||
- 제목 "2단계 인증을 켜야 합니다" / "회사 정책에 따라 로그인할 때 휴대폰 인증 앱의 코드도 확인합니다. 지금 켜면 바로 메일로 이어집니다."
|
||||
- 제목 "2단계 인증을 켜 주세요" / "회사 정책에 따라 로그인할 때 휴대폰 인증 앱의 코드도 확인합니다. 지금 켜면 바로 메일로 이어집니다."
|
||||
- 주소 + [다른 계정으로 로그인]
|
||||
- 차례(청록 굵은 숫자 — 동그라미 없음, 숫자 칸 22 · 사이 18 — 12단계 켜기 창과 같다):
|
||||
- ① "인증 앱으로 QR 코드를 찍어 주세요" / "인증 앱에서 [+] → 「QR 코드 스캔」을 고릅니다. 앱이 없으면 Google Authenticator · Microsoft Authenticator 를 먼저 설치해 주세요."
|
||||
QR(148 — 양식 칸 360 안에 키를 옆에 두려고 12단계 켜기 창의 160 보다 작게) 오른쪽에 "찍을 수 없으면 이 키를 입력하세요"(12px 굵게) + 32자 키(4자씩 · 두 줄 · 고정폭 12px) +
|
||||
[복사](36 — "키를 복사했습니다." / 못 하면 "키를 복사하지 못했습니다. 키를 직접 골라 복사해 주세요."), 그 아래 "앱에는 「ARA SYSTEM (주소)」로 저장됩니다."
|
||||
- ① "인증 앱으로 QR 코드를 찍어 주세요" / "인증 앱에서 [+] → [QR 코드 스캔]을 고릅니다. 앱이 없으면 Google Authenticator나 Microsoft Authenticator를 먼저 설치해 주세요."
|
||||
QR(148 — 양식 칸 360 안에 키를 옆에 두려고 12단계 켜기 창의 160 보다 작게) 오른쪽에 "찍을 수 없으면 이 키를 입력해 주세요"(12px 굵게) + 32자 키(4자씩 · 두 줄 · 고정폭 12px) +
|
||||
[복사](36 — "키를 복사했습니다." / 못 하면 "키를 복사하지 못했습니다. 키를 직접 골라 복사해 주세요."), 그 아래 "앱에는 ‘ARA SYSTEM (주소)’로 저장됩니다."
|
||||
- ② 「앱에 표시된 6자리 코드」 — 로그인 코드 칸 모양.
|
||||
- (지금 비밀번호가 화면 메모리에 없을 때만) ③ 「지금 비밀번호」 + "새로고침해서 다시 넣어야 합니다." — [보기] 있음.
|
||||
- (지금 비밀번호가 화면 메모리에 없을 때만) ③ 「지금 비밀번호」 + "새로고침한 뒤에는 지금 비밀번호를 한 번 더 넣어 주세요." — [보기] 있음.
|
||||
- [켜고 계속] — 보내는 동안 「켜는 중…」(`aria-busy`, 칸은 읽기만). 요청 한도 30초(`TWO_FACTOR_CHANGE_TIMEOUT_MS`).
|
||||
- 문의 줄 "인증 앱을 쓸 수 없나요?" + 연락처(아래 "문의 줄").
|
||||
- 맨 아래(위쪽 선 · 12px): "Outlook · 휴대폰 메일 앱은 이 코드를 묻지 않습니다. 켠 뒤에는 설정 › 앱 비밀번호에서 만든 비밀번호로 연결합니다."
|
||||
- 맨 아래(위쪽 선 · 12px): "Outlook이나 휴대폰 메일 앱은 이 코드를 묻지 않습니다. 켠 뒤에는 [설정] → [앱 비밀번호]에서 만든 비밀번호로 연결해 주세요."
|
||||
- **지금 비밀번호**: 로그인 때 넣은 것(방금 새 비밀번호를 정했으면 그것)을 화면 메모리에서 쓴다 — 12단계 켜기 창의 ③ 을 묻지 않는다. 10분 뒤 지우고, 메모리에 없으면 ③ 칸을 보인다.
|
||||
- **키**: 이 화면을 열 때마다 새로 만든다(`POST /api/two-factor/enrollments` — 10분 안에 켠다). 켜지 않고 떠나면(다른 계정 · 새로고침) 그 키는 버린다 —
|
||||
새로고침 뒤에는 QR · 키가 바뀌므로 앱에 먼저 저장한 항목은 지우고 다시 찍는다. QR · 키는 화면 메모리에만 두고 로그에 남기지 않는다(12단계 그대로).
|
||||
@@ -842,14 +871,14 @@ API 와 서버 규칙은 계약 2절 "실시간 알림".
|
||||
(단추가 있는 알림 — 10초, 마우스 · 초점이 있으면 멈춤) → 설정 › 앱 비밀번호. 웹메일 비밀번호로 메일 프로그램을 연결해 둔 직원이 다음 연결 때 막히기 전에 알린다.
|
||||
- **유예 기간**(시작일 전 — 로그인은 그대로, `me` 의 `twoFactorDeadline`):
|
||||
- 메일 모듈 화면(목록 · 읽기 · 쓰기 · 검색)의 상단 바 바로 아래, 모듈 폭 전체에 노란 줄 하나(높이 40 · 바탕 #FFF7E6 · 아래 선 #F0D9A8 · 글 #7A5200 · 방패 아이콘 15):
|
||||
**"11월 30일까지 2단계 인증을 켜 주세요"**(굵게) " — 그 뒤에는 켜야 로그인할 수 있습니다." + [지금 켜기](36 — 읽기 화면 안내 줄의 단추와 같은 `.notice-btn`) → 설정 › 2단계 인증.
|
||||
**"11월 30일까지 2단계 인증을 켜 주세요"**(굵게) ". 그 뒤에는 켜야 로그인할 수 있습니다." + [지금 켜기](36 — 읽기 화면 안내 줄의 단추와 같은 `.notice-btn`) → 설정 › 2단계 인증.
|
||||
- 날짜는 시작일의 전날(KST)이다 — 시작일이 12월 1일이면 「11월 30일까지」. 올해가 아니면 해를 붙인다("2027년 1월 3일까지").
|
||||
- 닫기 단추는 없다 — 켜야 사라진다. 주소록 · 설정에는 없다(설정 › 2단계 인증은 아래 안내).
|
||||
- **설정 › 2단계 인증 — 필수인 직원**(`GET /api/two-factor` 의 `isRequired` · `deadline`):
|
||||
- 유예 중 · 꺼짐 — 머리 아래 갈색 상자(방패): **"11월 30일까지 2단계 인증을 켜 주세요"** / "회사 정책에 따라 그 뒤에는 2단계 인증을 켜야 로그인할 수 있습니다."
|
||||
그 아래 상태 상자 · 경고 · 켜는 차례 · 켜기 창은 12단계 그대로.
|
||||
그 아래 상태 상자 · 경고 · 켜는 방법 · 켜기 창은 12단계 그대로.
|
||||
- 켜짐 — 상태 상자의 [2단계 인증 끄기] 대신 회색 글 "회사 정책에 따라 끌 수 없습니다"(서버도 `VAL_001` 로 거절).
|
||||
아래 안내의 첫 줄은 "휴대폰을 바꿀 때는 관리자에게 2단계 인증 해제를 요청해 주세요 — 다음 로그인 때 새 휴대폰으로 다시 켭니다."(나머지 두 줄은 12단계 그대로).
|
||||
아래 안내의 첫 줄은 "휴대폰을 바꿀 때는 관리자에게 2단계 인증을 풀어 달라고 요청해 주세요. 다음에 로그인할 때 새 휴대폰으로 다시 켜면 됩니다."(나머지 두 줄은 12단계 그대로).
|
||||
- **휴대폰을 바꿀 때**(필수인 직원): 관리자에게 「2단계 해제」를 요청한다(운영 도구 — 받은편지함 알림 메일이 간다) → 다음 로그인 때 이 켜기 단계로 새 휴대폰을 등록한다.
|
||||
직원이 직접 하는 휴대폰 바꾸기는 미뤘다(아래).
|
||||
|
||||
@@ -857,54 +886,54 @@ API 와 서버 규칙은 계약 2절 "실시간 알림".
|
||||
|
||||
- **메뉴**: 계정 묶음의 앱 비밀번호 아래 「메일 프로그램 연결」. API 는 없다 — 화면 상수로만 그린다(설정값은 `MAIL_CLIENT_SETTINGS` — 앱 비밀번호 화면과 같은 값, 차례 글도 상수).
|
||||
2단계 인증 상태만 이미 있는 `GET /api/two-factor`(스토어)로 보고, 받지 못하면 켠 직원의 안내로 둔다(더 안전한 쪽).
|
||||
- 머리 "메일 프로그램 연결" + "아웃룩 · 썬더버드 · 휴대폰 메일 앱에서 회사 메일을 보는 방법입니다."
|
||||
- 머리 "메일 프로그램 연결" + "아웃룩, 썬더버드, 휴대폰 메일 앱에서 회사 메일을 보는 방법입니다."
|
||||
- **설정값 상자**: 「메일 프로그램 설정값」 — 앱 비밀번호 화면과 같은 이름 · 값 표(이름 칸 136).
|
||||
|
||||
| 이름 | 값 |
|
||||
| --- | --- |
|
||||
| 받는 메일 (IMAP) | **mx1.arasystem.it.kr** · 포트 993 · SSL/TLS |
|
||||
| 보내는 메일 (SMTP) | **mx1.arasystem.it.kr** · 포트 465 · SSL/TLS |
|
||||
| 사용자 이름 | **(직원 주소)** — 메일 주소 전체 |
|
||||
| 비밀번호 | 켠 직원 "앱 비밀번호 — 웹메일 비밀번호가 아닙니다" / 켜지 않은 직원 "앱 비밀번호(권장) — 2단계 인증을 켜지 않았으면 웹메일 비밀번호도 됩니다" |
|
||||
| 사용자 이름 | **(직원 주소)** · 메일 주소 전체 |
|
||||
| 비밀번호 | 켠 직원 "웹메일 비밀번호가 아닌 앱 비밀번호" / 켜지 않은 직원 "앱 비밀번호를 권합니다. 2단계 인증을 켜지 않았다면 웹메일 비밀번호로도 연결됩니다." |
|
||||
|
||||
- **상자 바로 아래** — 2단계 인증 상태로 가른다. 둘 다 [앱 비밀번호 만들기](36) → 설정 › 앱 비밀번호.
|
||||
- 켠 직원 — 갈색 상자(열쇠): **"비밀번호 칸에는 꼭 앱 비밀번호를 넣어 주세요"** / "2단계 인증을 켜 두어 웹메일 비밀번호로는 연결되지 않습니다. 프로그램마다 앱 비밀번호를 하나씩 만들어 넣습니다." — 단추 `.notice-btn`.
|
||||
- 켠 직원 — 갈색 상자(열쇠): **"비밀번호 칸에는 꼭 앱 비밀번호를 넣어 주세요"** / "2단계 인증을 켜 두어 웹메일 비밀번호로는 연결되지 않습니다. 프로그램마다 앱 비밀번호를 하나씩 만들어 넣어 주세요." — 단추 `.notice-btn`.
|
||||
- 켜지 않은 직원 — 청록 상자(열쇠): **"앱 비밀번호로 연결하기를 권합니다"** / "지금은 웹메일 비밀번호로도 연결되지만, 2단계 인증을 켜면 다시 연결할 때 막힙니다. 앱 비밀번호는 기기마다 따로 지울 수 있어 잃어버렸을 때도 안전합니다." — 단추 `.line-btn`.
|
||||
- **프로그램별 연결 차례**: 「프로그램별 연결 차례」 + "쓰는 프로그램을 누르면 차례가 펼쳐집니다."
|
||||
- 그 아래 갈색 주의 줄(경고 삼각형): "새 Outlook · 휴대폰 Outlook 앱은 메일을 Microsoft 클라우드를 거쳐 받습니다 — 회사 정책에 따라 쓰지 않는 것이 좋습니다."
|
||||
- **프로그램별 연결 방법**: 「프로그램별 연결 방법」 + "쓰는 프로그램을 누르면 연결 방법이 펼쳐집니다."
|
||||
- 그 아래 갈색 주의 줄(경고 삼각형): "새 Outlook과 휴대폰 Outlook 앱은 메일을 Microsoft 클라우드를 거쳐 받습니다. 회사 정책에 따라 쓰지 않는 것이 좋습니다."
|
||||
권하지 않음으로 정했다(2026-10-31) — 회사가 허용하면 줄을 지운다.
|
||||
- 펼침 목록(테두리 상자 하나 · 줄 사이 선): 줄 52 — 아이콘 18(PC · 휴대폰) · 이름(굵게) / 설명(12px 회색) · 오른쪽 ⌄(펼치면 ⌃). 줄 전체가 버튼(`aria-expanded` · 펼친 것만 `aria-controls`)이고,
|
||||
펼친 차례는 region(이름 = 그 버튼)으로 이름 글과 같은 선(왼쪽 44)에서 번호 줄(청록 굵은 숫자 — 동그라미 없음)로 보인다. 처음에는 모두 접힘, 하나를 펼치면 다른 것은 접힌다.
|
||||
- 표기: 누르는 것 [ ], 고르는 것 「 」, 호스트 이름은 굵게 — 12단계 「켜는 차례」와 같다. 화면 그림은 넣지 않는다. 맥 메일 · 휴대폰 Outlook 앱은 넣지 않는다.
|
||||
- 표기: 누르거나 고르는 것은 [ ], 호스트 이름은 굵게(2026-11-05 — 고르는 것도 「 」 대신 [ ], 위 "화면 문구 — 사용자에게 말하듯"). 12단계 「켜는 방법」과 같다. 화면 그림은 넣지 않는다. 맥 메일 · 휴대폰 Outlook 앱은 넣지 않는다.
|
||||
- 차례 글(아래 그대로 — 구현 때 실제 앱(한국어판)의 메뉴 이름과 한 번 더 맞춘다):
|
||||
- **아웃룩 (Windows)** — 설명 "Microsoft 365 · Outlook 2016 이후의 데스크톱 Outlook"
|
||||
1. Outlook 에서 [파일] → [계정 추가]를 누릅니다.
|
||||
2. 메일 주소를 넣고 [고급 옵션] → 「내 계정을 수동으로 설정」을 고른 뒤 [연결]을 누릅니다.
|
||||
3. 계정 유형에서 「IMAP」을 고릅니다.
|
||||
4. 받는 메일 · 보내는 메일 모두 서버 **mx1.arasystem.it.kr**, 포트는 993 · 465, 암호화 방법은 「SSL/TLS」를 넣고 [다음]을 누릅니다.
|
||||
- **아웃룩 (Windows)** — 설명 "Microsoft 365나 Outlook 2016 이후의 데스크톱 Outlook"
|
||||
1. Outlook에서 [파일] → [계정 추가]를 누릅니다.
|
||||
2. 메일 주소를 넣고 [고급 옵션] → [내 계정을 수동으로 설정]을 고른 뒤 [연결]을 누릅니다.
|
||||
3. 계정 유형에서 [IMAP]을 고릅니다.
|
||||
4. 받는 메일과 보내는 메일 모두 서버는 **mx1.arasystem.it.kr**로 넣습니다. 포트는 받는 메일 993, 보내는 메일 465이고, 암호화 방법은 [SSL/TLS]를 고른 뒤 [다음]을 누릅니다.
|
||||
5. 비밀번호 칸에 앱 비밀번호를 넣고 [연결]을 누릅니다.
|
||||
- **새 Outlook** — 설명 "Windows 의 새 Outlook — 메일이 Microsoft 클라우드를 거칩니다"
|
||||
- **새 Outlook** — 설명 "메일이 Microsoft 클라우드를 거치는 Windows의 새 Outlook"
|
||||
1. [설정](톱니) → [계정] → [메일 계정] → [계정 추가]를 누릅니다.
|
||||
2. 메일 주소를 넣고 [계속] → 공급자에서 「IMAP」을 고릅니다.
|
||||
3. [고급 설정 표시]를 누르고 받는 · 보내는 서버에 위 설정값을, 비밀번호 칸 둘 다에 앱 비밀번호를 넣습니다.
|
||||
4. [계속]을 누르면 메일을 Microsoft 클라우드에 동기화한다는 안내가 나옵니다 — 위 주의를 읽고 정합니다.
|
||||
- **썬더버드** — 설명 "Windows · Mac — 주소와 앱 비밀번호만 넣으면 됩니다"
|
||||
2. 메일 주소를 넣고 [계속]을 누른 뒤 공급자에서 [IMAP]을 고릅니다.
|
||||
3. [고급 설정 표시]를 누르고 받는 서버와 보내는 서버에 위 설정값을, 비밀번호 칸 두 곳에 모두 앱 비밀번호를 넣습니다.
|
||||
4. [계속]을 누르면 메일을 Microsoft 클라우드에 동기화한다는 안내가 나옵니다. 위의 주의 글을 읽고 정해 주세요.
|
||||
- **썬더버드** — 설명 "Windows와 Mac에서 주소와 앱 비밀번호만 넣으면 됩니다"
|
||||
1. 썬더버드에서 [≡] → [새로 만들기] → [기존 메일 계정]을 누릅니다.
|
||||
2. 이름 · 메일 주소 · 비밀번호(앱 비밀번호)를 넣고 [계속]을 누릅니다.
|
||||
3. 메일 주소와 앱 비밀번호만 넣으면 설정을 스스로 찾습니다 — 「IMAP」이 보이면 [완료]를 누릅니다.
|
||||
2. 이름과 메일 주소를 넣고, 비밀번호 칸에 앱 비밀번호를 넣은 뒤 [계속]을 누릅니다.
|
||||
3. 메일 주소와 앱 비밀번호만 넣으면 설정을 스스로 찾습니다. [IMAP]이 보이면 [완료]를 누릅니다.
|
||||
4. 찾지 못하면 [수동 설정]에서 위 설정값을 넣습니다.
|
||||
- **iPhone · iPad** — 설명 "기본 메일 앱"
|
||||
1. [설정] → [메일] → [계정] → [계정 추가] → [기타] → [메일 계정 추가]를 누릅니다. (iOS 18 은 [설정] → [앱] → [메일])
|
||||
2. 이름 · 메일 주소 · 암호(앱 비밀번호) · 설명을 넣고 [다음]을 누릅니다.
|
||||
3. 「IMAP」을 고르고, 받는 · 보내는 메일 서버의 호스트 이름에 **mx1.arasystem.it.kr**, 사용자 이름에 메일 주소 전체, 암호에 앱 비밀번호를 넣습니다 — 보내는 메일 서버도 「선택 사항」이라 보여도 비우지 않습니다.
|
||||
4. [다음] → [저장]을 누릅니다. 포트는 스스로 993 · 465 로 맞춥니다.
|
||||
- **안드로이드** — 설명 "Gmail 앱 — 다른 계정(IMAP) · 삼성 이메일 앱도 같은 값"
|
||||
1. [설정] → [메일] → [계정] → [계정 추가] → [기타] → [메일 계정 추가]를 누릅니다. iOS 18에서는 [설정] → [앱] → [메일]에서 시작합니다.
|
||||
2. 이름, 메일 주소, 설명을 넣고, 암호 칸에 앱 비밀번호를 넣은 뒤 [다음]을 누릅니다.
|
||||
3. [IMAP]을 고르고, 받는 메일 서버와 보내는 메일 서버의 호스트 이름에 **mx1.arasystem.it.kr**, 사용자 이름에 메일 주소 전체, 암호에 앱 비밀번호를 넣습니다. 보내는 메일 서버 칸이 선택 사항으로 보여도 꼭 채워 주세요.
|
||||
4. [다음] → [저장]을 누릅니다. 포트는 받는 메일 993, 보내는 메일 465로 저절로 맞춰집니다.
|
||||
- **안드로이드** — 설명 "Gmail 앱 기준이며, 삼성 이메일 앱 같은 다른 메일 앱도 같은 값을 넣습니다"
|
||||
1. Gmail 앱에서 프로필 사진 → [다른 계정 추가] → [기타]를 누릅니다.
|
||||
2. 메일 주소를 넣고 [수동 설정] → 「개인(IMAP)」을 고릅니다.
|
||||
2. 메일 주소를 넣고 [수동 설정] → [개인(IMAP)]을 고릅니다.
|
||||
3. 비밀번호에 앱 비밀번호를 넣습니다.
|
||||
4. 받는 서버 · 보내는 서버에 **mx1.arasystem.it.kr**, 포트는 993 · 465, 보안 유형은 「SSL/TLS」를 넣고 [다음]을 누릅니다.
|
||||
- **아래 안내 상자**(#F3F8F8 · 가운뎃점 줄): "연결이 안 되면 사용자 이름에 메일 주소 전체를, 비밀번호 칸에 앱 비밀번호(띄어쓰기 없이 40자)를 넣었는지 확인해 주세요." ·
|
||||
"POP3 로는 연결할 수 없습니다 — 계정 유형은 IMAP 을 골라 주세요." · "웹메일 비밀번호를 바꾸면 앱 비밀번호도 모두 지워집니다 — 프로그램마다 새로 만들어 넣어 주세요." · "그래도 안 되면 관리자에게 문의해 주세요."
|
||||
4. 받는 서버와 보내는 서버는 **mx1.arasystem.it.kr**로 넣습니다. 포트는 받는 서버 993, 보내는 서버 465이고, 보안 유형은 [SSL/TLS]를 고른 뒤 [다음]을 누릅니다.
|
||||
- **아래 안내 상자**(#F3F8F8 · 가운뎃점 줄): "연결이 안 되면 사용자 이름에 메일 주소 전체를 넣었는지, 비밀번호 칸에 앱 비밀번호 40자를 띄어쓰기 없이 넣었는지 확인해 주세요." ·
|
||||
"POP3로는 연결할 수 없으니 계정 유형은 IMAP을 골라 주세요." · "웹메일 비밀번호를 바꾸면 앱 비밀번호도 모두 지워지니, 프로그램마다 새로 만들어 넣어 주세요." · "그래도 안 되면 관리자에게 문의해 주세요."
|
||||
- **좁은 폭**: 설정 메뉴가 콘텐츠 위로 올라간다(지금 설정 셸의 줄바꿈). 설정값 상자는 이름(12px 회색) 위 · 값 아래 한 칸으로. 펼침 줄의 이름 · 설명이 줄을 바꿔도 줄 전체가 누르는 곳이다.
|
||||
- **앱 비밀번호 화면(12단계)에 더하는 것**: 설정값 상자 바로 아래 한 줄(12.5px · #4A5363) "프로그램별 연결 방법은 메일 프로그램 연결에서 볼 수 있습니다." — 「메일 프로그램 연결」이 이 화면 링크다. 12단계 보드는 고치지 않았다.
|
||||
- **전 직원 전환 때**: 썬더버드의 "설정을 스스로 찾습니다"와 아웃룩 자동 설정은 autoconfig · autodiscover 주소와 SRV 레코드를 낸 뒤부터 된다(인프라 — `mail/README.md`) —
|
||||
@@ -915,13 +944,13 @@ API 와 서버 규칙은 계약 2절 "실시간 알림".
|
||||
- **알림 메일 — 공통**
|
||||
- 서버가 그 직원의 받은편지함에 바로 넣는다(계약 6절 — 안 읽음 · 받은 시각은 지금 · 실시간 알림과 바탕화면 알림이 간다, 필터 · 스팸 판정은 거치지 않는다). 넣지 못해도 원래 일은 그대로 끝난다.
|
||||
- 보낸 사람 `ARA SYSTEM <postmaster@<MAIL_DOMAIN>>`, 받는 사람 그 직원(`이름 <주소>`). 목록 · 읽기 화면은 다른 메일과 같다 — 보낸 사람 「ARA SYSTEM」, 아바타 "A", 뱃지 · 아이콘으로 따로 가르지 않는다.
|
||||
- **링크를 넣지 않는다** — 직원에게 "회사 보안 알림에는 링크가 없다"는 습관을 주어 링크 달린 피싱 메일이 눈에 띄게 한다. 할 일은 메뉴 경로(웹메일 › 설정 › …)를 굵은 글로 적고, 맨 아래 한 줄로도 알린다.
|
||||
- **링크를 넣지 않는다** — 직원에게 "회사 보안 알림에는 링크가 없다"는 습관을 주어 링크 달린 피싱 메일이 눈에 띄게 한다. 할 일은 메뉴 경로(웹메일의 [설정] → …)를 굵은 글로 적고, 맨 아래 한 줄로도 알린다.
|
||||
웹메일 안의 알림 단추(2단계 인증을 켠 뒤의 [앱 비밀번호 만들기] · 11단계 위험 파일 알림의 [대용량 첨부 보기])는 메일이 아니라 그대로 둔다.
|
||||
- 본문 차례: 인사 한 줄 → 표 → 할 일(메뉴 경로 글) → 문의 줄 → 맨 아래 한 줄. HTML 과 같은 글의 텍스트 본문을 함께 넣는다.
|
||||
- 자리 표시: `{이름}` 직원의 표시 이름(없으면 주소의 @ 앞) · `{시각}` · `{올린 때}` KST "2026년 10월 30일 (금) 오후 3:20" · `{파일 이름}` 올린 이름 그대로 · `{크기}` 11단계 크기 표기("48.2 MB") ·
|
||||
`{연락처}` 문의 연락처 설정값. 이름 · 파일 이름 · 연락처는 HTML 본문에 넣을 때 이스케이프한다.
|
||||
- 문의 줄: `{확인 문장}` 다음 줄에 `{연락처}`. 연락처를 정하지 않았으면 그 줄을(HTML 은 앞의 `<br>` 와 함께) 뺀다.
|
||||
- 맨 아래 한 줄(세 통 같음): "이 메일은 ARA SYSTEM 이 자동으로 보낸 알림입니다. 회사 보안 알림에는 링크가 없습니다 — 비슷한 메일의 링크는 누르지 마세요."
|
||||
- 맨 아래 한 줄(세 통 같음): "이 메일은 ARA SYSTEM이 자동으로 보낸 알림입니다. 회사 보안 알림에는 링크가 없으니, 비슷한 메일에 링크가 있다면 누르지 않도록 주의해 주세요."
|
||||
- HTML 꼴(세 통 같음 — 보내는 메일 정화 `sanitize-outgoing-html.ts` 의 허용 목록 안이고 감싸는 div 는 보내는 메일과 같은 기본 글꼴 · 14px · 1.75, 표의 줄은 칸마다 같은 꼴을 되풀이한다):
|
||||
|
||||
```html
|
||||
@@ -936,9 +965,9 @@ API 와 서버 규칙은 계약 2절 "실시간 알림".
|
||||
</tbody>
|
||||
</table>
|
||||
<p style="margin: 0 0 2px; font-weight: 700">할 일</p>
|
||||
<p style="margin: 0 0 20px">{할 일 — 메뉴 경로는 <strong>웹메일 › 설정 › …</strong>}</p>
|
||||
<p style="margin: 0 0 20px">{할 일 — 메뉴 경로는 웹메일의 <strong>[설정] → …</strong>}</p>
|
||||
<p style="margin: 0; padding-top: 14px; border-top: 1px solid #EEF0F3; font-size: 13px; color: #4A5363">{확인 문장}<br>{연락처}</p>
|
||||
<p style="margin: 6px 0 0; font-size: 12px; color: #667085">이 메일은 ARA SYSTEM 이 자동으로 보낸 알림입니다. 회사 보안 알림에는 링크가 없습니다 — 비슷한 메일의 링크는 누르지 마세요.</p>
|
||||
<p style="margin: 6px 0 0; font-size: 12px; color: #667085">이 메일은 ARA SYSTEM이 자동으로 보낸 알림입니다. 회사 보안 알림에는 링크가 없으니, 비슷한 메일에 링크가 있다면 누르지 않도록 주의해 주세요.</p>
|
||||
</div>
|
||||
```
|
||||
|
||||
@@ -955,28 +984,28 @@ API 와 서버 규칙은 계약 2절 "실시간 알림".
|
||||
{확인 문장}
|
||||
{연락처}
|
||||
|
||||
이 메일은 ARA SYSTEM 이 자동으로 보낸 알림입니다. 회사 보안 알림에는 링크가 없습니다 — 비슷한 메일의 링크는 누르지 마세요.
|
||||
이 메일은 ARA SYSTEM이 자동으로 보낸 알림입니다. 회사 보안 알림에는 링크가 없으니, 비슷한 메일에 링크가 있다면 누르지 않도록 주의해 주세요.
|
||||
```
|
||||
|
||||
- **① 관리자가 비밀번호를 재설정했습니다** — 운영 도구의 「비밀번호 재설정」 뒤.
|
||||
- 제목 "관리자가 비밀번호를 재설정했습니다"
|
||||
- 인사 "{이름} 님, 관리자가 이 계정의 비밀번호를 임시 비밀번호로 바꿨습니다."
|
||||
- 표: 시각 — `{시각}` / 바뀐 것 — "비밀번호 — 임시 비밀번호로 바뀌었고, 처음 로그인할 때 새 비밀번호를 정합니다" / 로그아웃 — "모든 기기 · 브라우저의 웹메일 로그인을 끊었습니다" /
|
||||
앱 비밀번호 — "모두 지웠습니다 — 메일 프로그램은 새 앱 비밀번호로 다시 연결합니다"
|
||||
- 할 일 "아웃룩 · 휴대폰 메일 앱을 쓰면 메일 프로그램마다 앱 비밀번호가 필요합니다. **웹메일 › 설정 › 계정 › 앱 비밀번호**에서 새로 만들어 주세요."
|
||||
- 인사 "{이름} 님, 관리자가 비밀번호를 임시 비밀번호로 바꿨습니다."
|
||||
- 표: 시각 — `{시각}` / 바뀐 것 — "비밀번호가 임시 비밀번호로 바뀌었고, 처음 로그인할 때 새 비밀번호를 정하게 됩니다" / 로그아웃 — "모든 기기와 브라우저의 웹메일에서 로그아웃되었습니다" /
|
||||
앱 비밀번호 — "모두 지워져서 메일 프로그램은 새 앱 비밀번호로 다시 연결해야 합니다"
|
||||
- 할 일 "아웃룩이나 휴대폰 메일 앱을 쓰면 메일 프로그램마다 앱 비밀번호가 필요합니다. 웹메일의 **[설정] → [앱 비밀번호]**에서 새로 만들어 주세요."
|
||||
- 확인 문장 "직접 요청한 일이 아니면 바로 IT 담당에게 알려 주세요."
|
||||
- **② 관리자가 2단계 인증을 껐습니다** — 운영 도구의 「2단계 해제」(휴대폰 분실) 뒤.
|
||||
- 제목 "관리자가 2단계 인증을 껐습니다"
|
||||
- 인사 "{이름} 님, 휴대폰을 잃어버렸거나 인증 앱을 쓸 수 없다고 알려 주셔서 관리자가 이 계정의 2단계 인증을 껐습니다."
|
||||
- 표: 시각 — `{시각}` / 바뀐 것 — "2단계 인증 꺼짐 — 지금은 비밀번호만으로 로그인합니다" / 그대로인 것 — "비밀번호 · 앱 비밀번호 · 로그인된 브라우저"
|
||||
- 할 일 "새 휴대폰에 인증 앱을 설치한 뒤 **웹메일 › 설정 › 계정 › 2단계 인증**에서 다시 켜 주세요. 회사가 2단계 인증을 필수로 정했다면 다음 로그인 때 켜는 화면이 먼저 나옵니다."
|
||||
- 인사 "{이름} 님, 휴대폰을 잃어버렸거나 인증 앱을 쓸 수 없다고 알려 주셔서 관리자가 2단계 인증을 껐습니다."
|
||||
- 표: 시각 — `{시각}` / 바뀐 것 — "2단계 인증이 꺼져서 지금은 비밀번호만으로 로그인합니다" / 그대로인 것 — "비밀번호, 앱 비밀번호, 로그인된 브라우저"
|
||||
- 할 일 "새 휴대폰에 인증 앱을 설치한 뒤 웹메일의 **[설정] → [2단계 인증]**에서 다시 켜 주세요. 회사가 2단계 인증을 필수로 정했다면 다음에 로그인할 때 켜는 화면이 먼저 나옵니다."
|
||||
- 확인 문장 "직접 요청한 일이 아니면 바로 IT 담당에게 알려 주세요."
|
||||
- **③ 올린 대용량 파일에서 위험한 내용이 발견되었습니다** — 악성 검사가 위험으로 판정했을 때, 그 파일을 올린 직원에게(파일마다 한 통).
|
||||
- 제목 "올린 대용량 파일에서 위험한 내용이 발견되었습니다"
|
||||
- 인사 "{이름} 님, 올린 대용량 파일을 보안 검사했더니 위험한 내용이 발견되어 보내거나 내려받을 수 없게 막았습니다."
|
||||
- 표: 파일 — `{파일 이름}` / 크기 — `{크기}` / 올린 때 — `{올린 때}` / 조치 — "이 파일만 막았습니다 — 함께 올린 다른 파일은 그대로 보내고 받을 수 있습니다"
|
||||
- 할 일 "직접 만든 파일이 아니면 PC 를 백신으로 검사해 주세요. 다른 곳에서 받은 파일이면 보낸 사람에게도 알려 주세요. 막은 파일은 **웹메일 › 설정 › 메일 › 대용량 첨부**에서 볼 수 있습니다."
|
||||
- 확인 문장 "판정이 잘못되었다고 생각되면 IT 담당에게 알려 주세요."
|
||||
- 표: 파일 — `{파일 이름}` / 크기 — `{크기}` / 올린 때 — `{올린 때}` / 조치 — "이 파일만 막았고, 함께 올린 다른 파일은 그대로 보내고 받을 수 있습니다"
|
||||
- 할 일 "직접 만든 파일이 아니면 PC를 백신으로 검사해 주세요. 다른 곳에서 받은 파일이면 보낸 사람에게도 알려 주세요. 막은 파일은 웹메일의 **[설정] → [대용량 첨부]**에서 볼 수 있습니다."
|
||||
- 확인 문장 "검사 결과가 잘못되었다고 생각되면 IT 담당에게 알려 주세요."
|
||||
- 웹메일을 열어 둔 동안의 빨간 알림(11단계)은 그대로 둔다 — 이 메일은 그때 열려 있지 않았던 직원도 알게 한다.
|
||||
- **휴지통 · 스팸메일함 — 30일 줄**:
|
||||
- 목록 머리(52) 바로 아래 회색 줄(바탕 #F8F9FB · 아래 선 #EEF0F3 · 위아래 8 · 12.5px #4A5363 · 시계 아이콘 14 #667085):
|
||||
@@ -999,6 +1028,52 @@ API 와 서버 규칙은 계약 2절 "실시간 알림".
|
||||
비밀번호 스스로 재설정(복구 연락처가 필요하다), 새 기기 로그인 알림 메일, 잠긴 계정만의 안내 글, 공용 주소로 보내기 · 공유 메일함.
|
||||
시안에서 뺀 것: 알림 메일의 링크 단추(넣지 않는다 — 위 규칙), 유예 노란 줄의 닫기, 맥 메일 · 휴대폰 Outlook 앱의 연결 차례, 프로그램별 화면 그림.
|
||||
|
||||
## 14단계 — 검색 · 대화 묶기 (2026-11-05 확정 — `Search14` · `Thread14` · `ListThreads14`)
|
||||
|
||||
자체 검색 색인으로 붙여 쓴 말 · 앞부분 · 제목 맨 앞의 말머리까지 찾고(부분 일치), 조건 검색과 검색어 강조를 더한다.
|
||||
같은 대화(답장 · 전달로 이어진 메일)를 읽기 화면에 늘 보이고, 목록은 사용자가 켜면 대화로 묶는다.
|
||||
까닭은 엔진 실측(2026-11-03)이다 — 엔진 스레드는 `회신:` · `답장:` · `전달:` 을 떼지 않아 한국어 Outlook 의 답장이 따로 떨어지고, 엔진 검색은 어절이 통째로 같을 때만 맞는다.
|
||||
데이터 · API 규칙(색인 · 대화 · 조건)은 구현 때 계약 2절에 더한다. 구현 · 배포는 전환 뒤 안정화(1~2주) 다음이다.
|
||||
새 토큰은 없다 — 조건 칸 40 은 필터 조건 칸의 밀도 예외(`--size-filter-control`), [대화로 묶기] 36 은 안내 줄 단추의 밀도 예외(`--size-notice-action`)를 다시 쓴다.
|
||||
문구는 위 「화면 문구 — 사용자에게 말하듯」을 따른다.
|
||||
|
||||
### 검색 · 조건 검색 · 강조 (`Search14`)
|
||||
|
||||
- **찾는 방식**: 서버의 자체 색인(지금 DB — 글자 두 개씩 묶은 색인)에서 부분 일치로 찾는다. "계약"으로 계약서 · 계약직 · 계약은 · 계약을, 제목 맨 앞의 [공지] · Re: 까지 찾는다.
|
||||
여러 낱말은 모두 맞아야 하고 큰따옴표는 구절이다(지금과 같다). 영문은 대소문자를 가리지 않는다. 끝이 바뀐 말('보내' → '보냈습니다')과 첨부 파일 안의 글은 찾지 않는다(첨부 이름은 찾는다).
|
||||
- **[조건]**: 검색 칸 안 오른쪽(✕ 앞)의 단추. 누르면 칸 바로 아래에 「조건으로 찾기」 패널(검색 칸 폭)이 열린다(aria-expanded).
|
||||
- 칸: 보낸 사람 · 받는 사람(참조도) · 제목 · 내용 · 기간(전체 기간 · 최근 1주 · 최근 1달 · 최근 3달 · 최근 1년 · 날짜 고르기 → 시작 ~ 끝) · 첨부(체크 "첨부 파일이 있는 메일만"). 칸 높이 40.
|
||||
- 아래 안내 "검색 칸의 말과 이 조건에 모두 맞는 메일을 찾습니다. 받는 사람에는 참조로 받은 사람도 들어갑니다." 단추 [조건 지우기] · [찾기].
|
||||
- 패널이 열려 있거나 조건이 걸려 있으면 [조건] 단추가 청록 바탕이다. 검색 칸이 비어도 조건만으로 찾을 수 있다.
|
||||
- **조건이 걸린 결과**: 찾을 곳 줄 아래 한 줄 "조건: 이도윤 님이 보낸 메일, 최근 1달, 첨부 있음" + [조건 지우기]. 조건은 쉼표로 잇는다(꼬리표 · 알약을 쓰지 않는다).
|
||||
- **찾을 곳**은 지금과 같다(그 메일함 | 전체 메일함 — 전체는 휴지통과 스팸메일함을 뺀다). 조건도 주소에 싣는다 — 이름은 구현 때 계약에서 정하고, 검색어처럼 로그에 남기지 않는다.
|
||||
- **결과 줄**(높이 64, 세 줄): 보낸 사람 · 시각 / 제목(+ 첨부 표시) / 미리 보기 한 줄(맞은 말 둘레). 제목 · 미리 보기의 맞은 말은 `<mark>`(노랑 `#FCE7A4` · 글자색은 그대로).
|
||||
- **읽기 화면 강조**: 제목 · 본문 · 첨부 이름의 맞은 말. 본문은 서버가 정화한 HTML 의 글 조각에만 `<mark>` 를 넣는다(찾기와 같은 정규화 · 부분 일치). 맞은 곳 사이 이동(다음 · 이전)은 이번에 넣지 않는다.
|
||||
- **검색 준비 중**: 지난 메일의 색인을 만드는 동안 그 직원의 검색은 지금 방식(엔진) 그대로이고, 결과 머리 아래 안내 줄(옅은 청록 바탕 · role=status)
|
||||
"지난 메일을 검색할 수 있게 준비하고 있습니다. 준비가 끝날 때까지는 낱말이 통째로 같은 메일만 찾습니다."
|
||||
- **결과 없음**: "‘계약’에 맞는 메일이 없습니다" / "다른 말로 찾거나 조건을 줄여 보세요." + [전체 메일함에서 찾기](그 메일함에서 찾을 때만). 지금의 한계 안내("낱말 단위로 찾습니다 …")는 색인이 준비된 뒤에는 뺀다.
|
||||
- 검색 결과는 대화로 묶지 않는다(한 통씩).
|
||||
|
||||
### 읽기 화면 — 같은 대화 (`Thread14`)
|
||||
|
||||
- 본문 · 첨부 아래 "같은 대화 N통". 오래된 차례로 테두리 상자 안의 줄(높이 48): ▸ · 머리글자 · 이름(내가 보냈으면 "나" + 회색 "보낸편지함") · 미리 보기 한 줄 · 시각.
|
||||
- 지금 보는 메일의 줄은 옅은 청록 바탕에 "지금 보고 있는 메일"이고 누를 수 없다. 다른 줄을 누르면 그 자리에서 펼쳐 본문과 [답장] · [이 메일만 열기]가 보이고(aria-expanded), 다시 누르면 접는다.
|
||||
- 대화에 드는 메일: 보낸편지함을 포함한 모든 메일함 — 휴지통과 스팸메일함은 뺀다. 한 통뿐이면 이 칸이 없다.
|
||||
- **같은 대화의 기준**(우리 규칙 — 엔진보다 넓다): 답장 관계(References · In-Reply-To)가 있고 말머리를 뗀 제목이 같을 때.
|
||||
떼는 말머리는 Re · RE · Fw · FW · Fwd · AW · WG · **회신 · 답장 · 전달** · 대괄호 꼬리표이고 여러 겹도 뗀다. 제목이 다르면 묶지 않는다(엔진과 같다 — 남의 메일이 잘못 붙지 않게).
|
||||
원본보다 늦게 색인된 메일은 들어온 뒤 합친다. 색인을 다 만들기 전 그 직원은 엔진의 스레드 그대로다.
|
||||
|
||||
### 목록 — 대화로 묶어 보기 (`ListThreads14`)
|
||||
|
||||
- 목록 머리 오른쪽의 [대화로 묶기](눌림 단추 — aria-pressed, 높이 36)와 설정 › 메일 › 보내기 · 읽기의 같은 켬/끔 한 줄. 사용자마다 서버에 저장한다(다른 기기에서도 같다). **기본은 끔**이다 — 전환 직후라 보던 목록을 바꾸지 않는다.
|
||||
- 켜면 한 줄이 그 메일함의 대화 하나다: 참여자(이름을 쉼표로 — 내가 보냈으면 "나") · 통 수("4통" — 회색 글, 뱃지 · 알약 없음, 한 통이면 없음) · 마지막 시각 / 마지막 메일 제목(+ 첨부 표시). 하나라도 안 읽었으면 굵게.
|
||||
- 통 수는 대화 전체(보낸편지함 포함 · 휴지통과 스팸 빼고)이고, 고르기 · 읽음 · 중요 · 이동 · 삭제는 그 메일함 안에 있는 그 대화의 메일 모두에 한다.
|
||||
- 줄을 누르면 그 메일함의 마지막 메일을 열고, 아래에 같은 대화 칸이 보인다. 목록 머리 글은 켰을 때 "대화 6개, 안 읽은 메일 2통", 껐을 때 "메일 9통, 안 읽은 메일 2통" 꼴이다.
|
||||
|
||||
### 처음 판에서 미룬 것
|
||||
|
||||
본문 강조의 다음 · 이전 이동, 검색어 줄임말(from: 같은 꼴), 대화째 끌어다 놓기, 첨부 파일 안의 글 찾기, 끝이 바뀐 말 찾기(형태소 분석).
|
||||
|
||||
## 구현하며 정한 보완 (2026-10-26 — 캔버스에는 아직 없다)
|
||||
|
||||
- 찾지 못한 첨부(보관 시간이 지난 업로드·지워진 원본)는 실패로 표시하고, 전달 원본에는 "빼기"만 둔다(다시 올릴 수 없다).
|
||||
@@ -1084,7 +1159,7 @@ API 와 서버 규칙은 계약 2절 "실시간 알림".
|
||||
- 보던 메일함을 지우면 받은편지함으로 간다. 비우기 · 지우기가 끝나면 초점은 메일함 패널의 지금 메일함(지웠으면 받은편지함)으로 간다 — 누른 단추가 사라지므로.
|
||||
- **오래 걸리는 처리**
|
||||
- 하는 중의 글: "‘거래처’ 메일함을 지우는 중 · 12 / 40통" · "휴지통을 비우는 중 · 600 / 1,234통" (빈 메일함 지우기처럼 셀 메일이 없으면 수를 뺀다).
|
||||
- 도중에 실패하면 "1,234통 가운데 600통까지 처리했습니다 — 다시 시도해 주세요."로 바뀐다(까닭은 오류 알림으로 함께). 하나도 못 했으면 오류 알림만 남는다.
|
||||
- 도중에 실패하면 "1,234통 가운데 600통까지 처리하고 멈췄습니다. 다시 시도해 주세요."로 바뀐다(까닭은 오류 알림으로 함께). 하나도 못 했으면 오류 알림만 남는다.
|
||||
- 다른 탭에서 먼저 끝냈으면 실패로 보이지 않는다 — "받은편지함에 안 읽은 메일이 없습니다." · "휴지통이 이미 비어 있습니다.", 이미 없는 메일함 지우기는 지운 것으로 본다.
|
||||
- 같은 메일함의 처리는 한 번에 하나다(다른 탭에서 같은 메일함을 처리하는 중이면 "요청이 너무 많습니다"). 진행 알림은 로그아웃 · 세션 만료 때 닫힌다.
|
||||
- **휴지통을 비우는 동안은 메일함을 지울 수 없고, 메일함을 지우는 동안은 휴지통을 비울 수 없다** — 비우기는 그동안 휴지통에 들어온 메일도 지우므로,
|
||||
@@ -1101,7 +1176,7 @@ API 와 서버 규칙은 계약 2절 "실시간 알림".
|
||||
- **설정 › 알림**
|
||||
- 허락은 했는데 알림을 꺼 둔 상태: 칸은 비우고 안내는 초록 "이 브라우저에서 알림이 허용되어 있습니다."(보드에 없는 상태).
|
||||
- 실시간을 쓸 수 없는 브라우저(Web Locks · BroadcastChannel · 스트림 읽기 가운데 하나라도 없음)는 바탕화면 알림도 "지원하지 않습니다"로 본다 — 알림은 실시간으로만 온다.
|
||||
- 연결 상태 글: 처음 잇는 중 "연결하는 중입니다…", 끊김 "연결할 수 없어 1분마다 확인합니다." 아래에 "저절로 다시 잇습니다. 회사 방화벽 · 프록시가 오래 열린 연결을 끊으면 이렇게 됩니다.",
|
||||
- 연결 상태 글: 처음 잇는 중 "연결하는 중입니다…", 끊김 "연결할 수 없어 1분마다 확인합니다." 아래에 "저절로 다시 연결합니다. 회사 방화벽이나 프록시가 오래 열린 연결을 끊으면 이렇게 될 수 있습니다.",
|
||||
지원하지 않음 "이 브라우저는 실시간 연결을 지원하지 않습니다."
|
||||
- 주소는 `/settings/mail/notify`, 탭 제목은 "메일 알림"(머리글은 "알림").
|
||||
- 켜기 칸에서 허락을 받은 결과는 칸 아래 안내로만 보인다(화면 알림은 목록 위 권하기 줄에서만 — 설정 화면에서 [설정 열기]는 어색하다).
|
||||
@@ -1123,7 +1198,7 @@ API 와 서버 규칙은 계약 2절 "실시간 알림".
|
||||
### 발송 · 부재중 결함 수정 (6단계 앞, 2026-10-28 — 캔버스에는 없다)
|
||||
|
||||
- **받는 사람 일부가 받지 못했을 때**(없는 주소 · 받는 쪽 거절 · 지금은 받을 수 없음): 보낸 것으로 처리해 쓰기 화면을 닫고, "보냈습니다" 알림 대신 오류 알림 모양(흰 카드)에
|
||||
"보냈지만 N명은 받지 못했습니다 — 주소 (까닭)"와 [보낸편지함 보기](보냈다는 알림과 같은 링크) — 두 명까지 주소 · 까닭을 적고 나머지는 " 외 N명". 까닭 문구 "없는 주소" · "받는 쪽이 거절" · "지금은 받을 수 없음".
|
||||
"보냈지만 N명은 받지 못했습니다. 주소 (까닭)"와 [보낸편지함 보기](보냈다는 알림과 같은 링크) — 두 명까지 주소 · 까닭을 적고 나머지는 " 외 N명". 까닭 문구 "없는 주소" · "받는 쪽이 거절" · "지금은 받을 수 없음".
|
||||
- **모두 받지 못했을 때**: 보내지 않은 것이다 — 없는 주소 · 거절(`MAIL_010`)은 받는 사람 오류(`MAIL_002`)와 같은 안내 띠 · 같은 초점으로, 지금은 받을 수 없음(`MAIL_011`)은 오류 알림으로. 쓰던 메일은 그대로 둔다.
|
||||
- **부재중 자동 응답 길이**: 제목 150자 · 내용 600자(입력칸 한도(maxlength)도 이 값, 켤 때 검사 — 꺼 둔 채로는 4단계 한도까지 저장된다) — 엔진이 바이트로 막아 한글로는 4단계의 200자 · 4,000자가 저장되지 않았다(계약 2절 "부재중 자동 응답").
|
||||
|
||||
@@ -1152,10 +1227,10 @@ API 와 서버 규칙은 계약 2절 "실시간 알림".
|
||||
- **화면 폭**: 공용 설정 틀 그대로 최대 800 · 묶음 사이 22(보드는 880 · 18 — 다른 설정 화면과 맞춘다). 머리의 [새 필터]는 틀의 `actions` 자리. 안내 상자 700, 창 620.
|
||||
- **메일함**: 받아 둔 메일함 목록을 보이며 뒤에서 다시 받는다(다른 탭에서 지웠을 수 있다). 요약에 메일함 이름이 들어가 필터와 메일함을 둘 다 받은 뒤 목록을 그린다.
|
||||
옮길 곳 고르기는 받은편지함 → 내 메일함 → **보관함** → 스팸메일함 → 휴지통(보관함과 그 아래 — 보드에 없었다. 서버가 받고, mailcow 에서 옮겨 온 계정에 있다).
|
||||
지워진 메일함은 "(지워진 메일함)…"이 꺼진 채 골라져 보이고, 그대로 저장해도 막지 않는다(서버가 받는다 — 받은편지함에 들어온다).
|
||||
지워진 메일함은 "지워진 메일함이니 …"가 꺼진 채 골라져 보이고, 그대로 저장해도 막지 않는다(서버가 받는다 — 받은편지함에 들어온다).
|
||||
- **초점**: 조건 더하기 → 새 줄의 "어디에서", 조건 빼기 → 이웃 줄의 찾을 글, 필터 지우기 → 다음 행(마지막이면 앞 행),
|
||||
목록 위 [다시 불러오기] · [다시 켜기] · `?from=` 으로 연 창의 [취소] · 이미 없는 필터 지우기 → 머리의 [새 필터], 창 안 [다시 불러오기] → [저장].
|
||||
- **그 밖**: [다시 켜기]가 성공해도 알림은 없다(줄이 사라진다). 목록 아래 줄은 보드 글 그대로 "… 행을 누르면 고칩니다."까지.
|
||||
- **그 밖**: [다시 켜기]가 성공해도 알림은 없다(줄이 사라진다). 목록 아래 줄 끝은 "… 줄을 누르면 고칠 수 있습니다."(2026-11-05 — 보드의 글은 "… 행을 누르면 고칩니다.").
|
||||
전달 칸은 `type=email` · 254자이고, 보기 글(받는 사람 칸 · 전달 칸)은 로그인한 직원 주소의 도메인으로 만든다.
|
||||
저장하는 사이 화면을 떠나면 칸 안에 보일 거절을 알림으로 띄운다(부재중 화면과 같다). 목록에 `aria-busy` 를 걸지 않는다(칸마다 `aria-disabled`).
|
||||
- **하지 않은 것**: 고치기 창에 쓰다 만 채로 떠날 때 묻기(나가기 확인) — 보드에 없다. 창을 연 채 브라우저 뒤로 가기를 하면 쓴 것을 잃는다(알려진 한계 — 7단계에서 고쳤다, 위 "7단계").
|
||||
@@ -1171,7 +1246,7 @@ API 와 서버 규칙은 계약 2절 "실시간 알림".
|
||||
- **실패 알림**: 요청은 인터셉터 알림을 끄고 보내고, 응답이 온 때 세션이 그대로일 때만 사전 문구로 알린다(세션 만료 대화상자는 그대로) — 그래서 처음 요청부터 실패하면
|
||||
사전 문구 알림과 진행 알림의 결과 "필터는 저장했지만 …"가 함께 보인다(4단계 오래 걸리는 처리와 같다). 다른 창 · PC 에서 적용 중(429)은 결과 문구 하나만.
|
||||
도중의 429 는 이어 할 자리가 있으니 [다시 시도]를 붙인다(처음 요청의 429 는 보드처럼 단추 없음).
|
||||
- **이 탭에서 적용 중에 [다시 시도]**: 시작하지 않고 "다른 필터를 받은편지함에 적용하는 중입니다. 끝난 뒤 다시 시도해 주세요." + 같은 자리 · 같은 수의 [다시 시도].
|
||||
- **이 탭에서 적용 중에 [다시 시도]**: 시작하지 않고 "다른 필터로 받은편지함을 정리하는 중입니다. 끝난 뒤 다시 시도해 주세요." + 같은 자리 · 같은 수의 [다시 시도].
|
||||
- **나아가지 못한 응답**(확인 0 인데 이어 할 자리가 있다)이 세 번 이어지면 멈춘다(`MAIL_FILTER_RUN_MAX_STALLS`) — 같은 요청을 끝없이 보내지 않게.
|
||||
- **맞추기**: 끝났거나 이번에 나아간 뒤 멈췄으면 개수 · 사용량을 다시 받고, 보이는 목록이 받은편지함이나 옮긴 메일함이면 그 목록도 맞춘다(실시간 연결이 없는 탭에서 1분까지 옛 개수가 보이지 않게).
|
||||
- **닫기 확인과 고치기 창**: 브라우저가 Esc 를 거듭 누르면 고치기 창을 막지 못하고 스스로 닫는다 — [계속 고치기]면 창을 다시 띄우고 초점은 닫히기 전에 있던 칸으로 돌려준다.
|
||||
@@ -1240,22 +1315,22 @@ API 와 서버 규칙은 계약 2절 "실시간 알림".
|
||||
이 PC 시계로 이미 지났으면 지금처럼 [보낸편지함 보기] 알림이다. 마감이 있는 알림은 마우스 · 초점으로 멈추지 않고, 알림이 넘쳐도 먼저 닫히지 않는다. 막대는 `<progress>`(aria-hidden)다.
|
||||
- 알림 단추는 한 번만 돈다(닫히는 전환 중에 두 번 눌러도 — `takeAction`). 같은 메일의 취소는 요청 하나로 묶는다. 초점이 든 알림이 닫히면 알림에 들어오기 전 요소로(없으면 본문 영역) 돌려준다.
|
||||
- 쓰기 화면으로 가지 못하면(그 화면의 나가기 확인에서 머묾 등) 그 자리에 두고 "보내기를 취소하고 임시보관함으로 옮겼습니다." + [고치기].
|
||||
- 일부 거절 문장은 붙잡은 발송이면 "받지 못합니다"(현재형)다. 예약에서 일부 거절: "예약했지만 N명은 받지 못합니다 — 주소 (까닭)" + [예약됨 보기].
|
||||
- 일부 거절 문장은 붙잡은 발송이면 "받지 못합니다"(현재형)다. 예약에서 일부 거절: "예약했지만 N명은 받지 못합니다. 주소 (까닭)" + [예약됨 보기].
|
||||
- **[보내기 ▾]**: ▾ 이름 "보내기 방법 더 보기", 메뉴 이름 "보내기 방법". 메뉴는 아래가 모자라면 위로 연다(`usePopupPlacement` — 메일함 ⋯ 과 같은 규칙). 「예약 발송…」은 [보내기]와 같은 사전 검사를 먼저 한다(걸리면 창을 열지 않고 그 칸으로).
|
||||
- **예약 창**: 「오늘 오후」 6:00 은 5분 뒤 이후일 때만, 「다음 주 월요일」은 오늘 다음의 첫 월요일(오늘이 월요일이면 7일 뒤)이다. 예약할 수 없는 시각은 빼고, 같은 시각이 겹치면 앞의 것만 둔다.
|
||||
직접 고르기는 오늘 ~ 7일 뒤 · 30분 단위, 처음 값은 내일 오전 9:00, 검사는 [예약]을 누를 때(role=alert). 시간대 이름을 모르면 "이 PC 의 시간으로 보냅니다 · 7일 안에서 고를 수 있습니다.".
|
||||
직접 고르기는 오늘 ~ 7일 뒤 · 30분 단위, 처음 값은 내일 오전 9:00, 검사는 [예약]을 누를 때(role=alert). 시간대 이름을 모르면 "이 PC의 시간으로 보내고, 7일 안에서 고를 수 있습니다.".
|
||||
창이 열린 동안 보낼 수 없게 되거나 세션이 바뀌면 닫는다. 서버가 `sendAt` 을 `VAL_001` 로 거절하면 창을 다시 열어 그 시각을 「직접 고르기」에 채우고 까닭(5분 · 7일 문구)을 보인다 —
|
||||
이 화면의 검사로는 맞는 시각(PC 시계가 어긋남)이면 가까운 한도로 본다. 쓰기 화면을 이미 떠났으면 알림으로.
|
||||
- **「예약됨」**: 수는 `GET /scheduled-messages?limit=1` 의 total 이다 — 메일 화면에 들어올 때 · 예약하거나 취소한 뒤 · 새 메일 확인이나 실시간 알림이 바뀜을 알릴 때 다시 묻는다.
|
||||
예약됨을 보는 동안은 0통이어도 패널 줄을 남긴다(숫자 없이). 패널 줄은 보낸편지함(과 그 하위) 바로 아래이고 ⋯ 는 없다.
|
||||
- 목록: 분할 보기 2줄(받는 사람 · 시각 / 제목), 목록 보기 한 줄 세 칸(받는 사람 150 · 제목 · 보낼 시각 150). 고르는 칸 · 별 · 여러 통 처리 · 비우기 · 알림 권하기 줄은 없다.
|
||||
빈 상태 "예약한 메일이 없습니다" / "쓰기 화면의 [보내기 ▾] › 예약 발송…으로 보낼 시각을 정할 수 있습니다.".
|
||||
빈 상태 "예약한 메일이 없습니다" / "메일을 쓸 때 [보내기 ▾] → [예약 발송…]으로 보낼 시각을 정할 수 있습니다.".
|
||||
- [고치기] · [예약 취소]는 하는 동안 두 단추를 함께 막고, 성공해 줄이 사라지면 초점을 옮긴다. [고치기] 뒤 "예약을 취소했습니다. 고친 뒤 다시 보내 주세요." —
|
||||
쓰기 화면으로 가지 못하면 "예약을 취소하고 임시보관함으로 옮겼습니다." + [고치기]. [예약 취소] 뒤에는 삭제처럼 다음 메일(분할) · 목록(목록 보기)으로.
|
||||
- **엔진이 발송을 풀 때**: 새 메일 확인 · 실시간 알림의 상태는 바뀌지 않으므로(계약 2절) 보이는 목록 · 열린 메일 · 가장 이른 예약의 시각 + 2초에 셋을 알림 없이 다시 받고,
|
||||
시각이 지난 채 남으면 5초마다 3번 더 본다(`usePendingSendRefresh`). 검색 결과는 기다리는 메일이 보일 때만, 열린 메일은 `pendingSend` 만 고친다. `MAIL_014` 를 받아도 바로 같은 셋을 다시 받는다.
|
||||
- **붙잡힌 메일(보내는 중 · 예약)** — 취소로만 보낸편지함을 떠난다(2026-10-29 리뷰, 계약 2절 `MAIL_015`):
|
||||
- 목록(분할 · 표 · 검색)의 고르기 칸은 꺼지고(`aria-disabled`) title 이 "보내기를 기다리는 메일은 고를 수 없습니다 — 열어서 취소하거나 삭제해 주세요." — 하나 · 범위 · 모두 고르기가 건너뛰고
|
||||
- 목록(분할 · 표 · 검색)의 고르기 칸은 꺼지고(`aria-disabled`) title 이 "보내기를 기다리는 메일은 고를 수 없습니다. 메일을 열어 취소하거나 삭제해 주세요." — 하나 · 범위 · 모두 고르기가 건너뛰고
|
||||
여러 통 처리에도 들지 않는다(고른 뒤 붙잡히면 고른 것에서 뺀다). 보낸편지함 표의 날짜 칸은 title 에도 같은 글.
|
||||
- 읽기 화면은 두 종류 모두 스팸 신고 · 이동 · ⋯ 의 차단 항목이 없다. [삭제]는 확인 "보내지 않은 메일을 삭제할까요?" / "보내지 않고 휴지통으로 옮깁니다." [삭제] [취소](처음 초점 「취소」) →
|
||||
발송 취소 → 휴지통 → "보내지 않고 휴지통으로 옮겼습니다.". 창을 연 채 발송이 풀리면 창을 닫는다(보낸 메일의 영구 삭제 확인으로 바뀌지 않게).
|
||||
@@ -1270,11 +1345,11 @@ API 와 서버 규칙은 계약 2절 "실시간 알림".
|
||||
도메인으로 막힌 보낸 사람의 「차단 풀기」는 그 도메인 차단을 푼다. 풀기가 404 면(이미 풀림) 알림 없이 푼 것으로 본다. 차단 · 풀기 뒤 세션이 바뀌었으면 옮기기 · 알림을 하지 않는다.
|
||||
- 조사는 `objectParticle` 로 — 영문 주소는 "을(를)", 따옴표 뒤에 붙여 쓴다(보드의 "‘주소’ 을" 과 다르다). 문구: 옮기지 않은 차단 "‘주소’을(를) 차단했습니다." ·
|
||||
되돌리기 뒤 "‘주소’ 차단을 풀고 메일을 되돌렸습니다."(옮기지 않았던 차단이면 "‘주소’ 차단을 풀었습니다.") · 풀기의 되돌리기 뒤 "‘주소’을(를) 다시 차단했습니다.".
|
||||
- **설정 › 보내기 · 읽기**: 머리 설명 "고르는 대로 저장합니다. 이 설정은 어느 PC 에서나 같습니다.", "저장했습니다"는 머리 오른쪽(`SettingsPageFrame` 의 `title-aside` 슬롯).
|
||||
- **설정 › 보내기 · 읽기**: 머리 설명 "고르는 대로 바로 저장됩니다. 이 설정은 어느 PC에서나 같습니다.", "저장했습니다"는 머리 오른쪽(`SettingsPageFrame` 의 `title-aside` 슬롯).
|
||||
칸마다 저장은 한 번에 하나 — 보내는 동안 고른 값은 기억했다가 앞 저장이 끝난 뒤 보낸 값 · 서버의 답과 다르면 보낸다. "저장했습니다"는 다 끝나고 마지막이 성공했을 때. 끝내 실패하면 서버가 확인한 값으로 되돌린다.
|
||||
- **설정 › 차단한 주소**: 검사 차례는 형식 → 내 주소 · 사내 도메인 → 메일 서비스 도메인 → 이미 차단 → 500개. 서버 거절(`VAL_001` · `MAIL_013`)은 칸 아래 사전 문구(알림 없음).
|
||||
성공하면 칸을 비우고 칸 아래에 "‘…’을(를) 차단했습니다."(`role=status` — 비어 있어도 영역은 늘 둔다). Enter 는 [차단]과 같다(`isImeComposing`). 줄을 풀면 초점은 다음 줄 → 앞 줄 → 칸.
|
||||
엔진 규칙이 꺼져 있으면(`isActive: false`) 머리 아래 노란 줄(단추 없음) "지금은 차단이 동작하지 않습니다. 메일 서버의 규칙이 다른 곳에서 바뀌었습니다 — 주소를 차단하거나 풀면 다시 켜집니다." —
|
||||
엔진 규칙이 꺼져 있으면(`isActive: false`) 머리 아래 노란 줄(단추 없음) "다른 곳에서 메일 설정이 바뀌어 지금은 차단이 되지 않습니다. 주소를 차단하거나 풀면 다시 켜집니다." —
|
||||
차단 · 풀기가 성공하면 켜진 것으로 본다(서버가 다시 켠다 — 계약 2절).
|
||||
- **새 토큰 · 아이콘**: `--color-on-brand-track` · `--color-on-brand-progress` · `--color-on-brand-divider` · `--color-warning-border`, 아이콘 printer · clock · block(원 + 빗금).
|
||||
|
||||
@@ -1284,8 +1359,8 @@ API 와 서버 규칙은 계약 2절 "실시간 알림".
|
||||
|
||||
- **올리기 차례**: 고르자마자 서버에 올리기를 모두 만든다(임시 저장본에 바로 실려 나가기 확인 글이 그대로 맞다). 조각은 파일 3개까지 함께 보내고, 나머지 줄은 오른쪽에 "차례를 기다리는 중"(처음 몇 초의 "시간 계산 중" 자리).
|
||||
- **끝내기**: 조각을 다 보냈으면 끝내기를 기다리는 동안 오른쪽 글은 "마무리하는 중"이다. 끝내기가 오래 진행 중이면 2 · 5 · 15 · 30초 간격으로 다시 묻고, 1분 넘게 그대로면 "오래 끊김"처럼 멈추고 [다시 시도].
|
||||
- **서버가 올리기를 잊었을 때(404)**: 멈춤 + "서버에서 올리던 파일을 찾을 수 없습니다 — 다시 시도하면 처음부터 올립니다." — [다시 시도]를 누를 때만 처음부터 올린다(사전 `RES_001` 문구로는 [다시 시도]의 뜻을 알 수 없어 화면 글로 둔다).
|
||||
고른 파일이 없는 줄(저장본에서 이어 온 것)이나 보내기의 `MAIL_016` 뒤 다시 물어 404 면 "서버에서 찾을 수 없는 파일입니다 — 빼고 다시 올려 주세요."
|
||||
- **서버가 올리기를 잊었을 때(404)**: 멈춤 + "올리던 파일을 찾을 수 없습니다. [다시 시도]를 누르면 처음부터 올립니다." — [다시 시도]를 누를 때만 처음부터 올린다(사전 `RES_001` 문구로는 [다시 시도]의 뜻을 알 수 없어 화면 글로 둔다).
|
||||
고른 파일이 없는 줄(저장본에서 이어 온 것)이나 보내기의 `MAIL_016` 뒤 다시 물어 404 면 "파일을 찾을 수 없습니다. 빼고 다시 올려 주세요."
|
||||
- **서버 거절 문구**: 서버가 준 코드(`FILE_001` · `FILE_002` · `FILE_005` · `FILE_006` · `VAL_001` · `FILE_003`)는 사전 문구다. 위 "올리기 전 검사"의 화면 글은 서버에 묻기 전(크기 · 개수 · 보관량)에만 쓴다 —
|
||||
"지금은 서버에 공간이 모자라 …"는 쓰지 않고 `FILE_006` 사전 문구.
|
||||
- **「링크로 보내기」**: 대용량 사전 검사(개수 · 보관량)를 먼저 보고 통과할 때만 일반 첨부에서 뺀다. 걸리면 첨부는 그대로 두고 그 글을 오류 알림으로(보관량이면 + [대용량 첨부 보기]).
|
||||
|
||||
@@ -0,0 +1,458 @@
|
||||
<!doctype html>
|
||||
<html lang="ko">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>메일 검색 · 조건 검색 · 강조</title>
|
||||
<script src="./support.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
<x-dc>
|
||||
<helmet>
|
||||
<style>
|
||||
@font-face{font-family:'Pretendard';src:url('/_blob/16067b9aa5137abf808e3dbef30c42e0') format('woff2-variations'),url('/_blob/16067b9aa5137abf808e3dbef30c42e0') format('woff2');font-weight:45 920;font-style:normal;font-display:swap}
|
||||
body{margin:0}
|
||||
a{color:#0E7C86}a:hover{color:#0A5F67}
|
||||
:focus-visible{outline:2px solid #0E7C86;outline-offset:2px}
|
||||
.seg{background:transparent;color:#4A5363}
|
||||
.seg[aria-pressed="true"]{background:#FFFFFF;color:#171C26;box-shadow:0 1px 2px rgba(16,24,28,.14)}
|
||||
.sk{display:block;height:10px;border-radius:5px;background:#EEF0F3}
|
||||
mark{background:#FCE7A4;color:inherit;border-radius:2px;padding:0 1px}
|
||||
.fi{box-sizing:border-box;height:40px;padding:0 10px;border:1px solid #D0D5DD;border-radius:8px;background:#FFFFFF;font:inherit;color:#171C26}
|
||||
.fi:focus{outline:none;border-color:#0E7C86;box-shadow:0 0 0 1px #0E7C86}
|
||||
</style>
|
||||
</helmet>
|
||||
<div style="min-height: 100vh; display: flex; background: #FFFFFF; color: #171C26; font-family: Pretendard, -apple-system, 'Apple SD Gothic Neo', 'Malgun Gothic', system-ui, sans-serif; font-size: 13px; line-height: 1.5">
|
||||
|
||||
<nav aria-label="사내 시스템" style="flex: 0 0 72px; box-sizing: border-box; display: flex; flex-direction: column; align-items: center; gap: 14px; padding: 10px 0 12px; background: #0B3640">
|
||||
<a href="Main.dc.html" style="display: inline-flex; align-items: center; justify-content: center; width: 60px; min-height: 44px; border-radius: 10px; text-decoration: none">
|
||||
<img src="/_blob/2b9ef4349333debc969432c859eaf1bf" alt="ARA SYSTEM" width="48" height="17" style="display: block; width: 48px; height: auto">
|
||||
</a>
|
||||
<ul style="list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 4px">
|
||||
<li>
|
||||
<a href="Main.dc.html" aria-current="page" style="display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 3px; width: 60px; min-height: 56px; border-radius: 10px; background: #155A64; color: #FFFFFF; font-size: 11px; font-weight: 600; text-decoration: none">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M4 4h16c1.1 0 2 .9 2 2v12c0 1.1-.9 2-2 2H4c-1.1 0-2-.9-2-2V6c0-1.1.9-2 2-2z"></path><polyline points="22,6 12,13 2,6"></polyline></svg>
|
||||
<span>메일</span>
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="#" style="display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 3px; width: 60px; min-height: 56px; border-radius: 10px; color: #A3C2C6; font-size: 11px; font-weight: 500; text-decoration: none">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="3" y="4" width="18" height="18" rx="2" ry="2"></rect><line x1="16" y1="2" x2="16" y2="6"></line><line x1="8" y1="2" x2="8" y2="6"></line><line x1="3" y1="10" x2="21" y2="10"></line></svg>
|
||||
<span>캘린더</span>
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="#" style="display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 3px; width: 60px; min-height: 56px; border-radius: 10px; color: #A3C2C6; font-size: 11px; font-weight: 500; text-decoration: none">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"></path></svg>
|
||||
<span>파일</span>
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="#" style="display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 3px; width: 60px; min-height: 56px; border-radius: 10px; color: #A3C2C6; font-size: 11px; font-weight: 500; text-decoration: none">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"></path><circle cx="9" cy="7" r="4"></circle><path d="M23 21v-2a4 4 0 0 0-3-3.87"></path><path d="M16 3.13a4 4 0 0 1 0 7.75"></path></svg>
|
||||
<span>주소록</span>
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
<a href="Settings.dc.html" style="margin-top: auto; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 3px; width: 60px; min-height: 56px; border-radius: 10px; color: #A3C2C6; font-size: 11px; font-weight: 500; text-decoration: none">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="3"></circle><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z"></path></svg>
|
||||
<span>설정</span>
|
||||
</a>
|
||||
</nav>
|
||||
|
||||
<div style="flex: 1 1 auto; min-width: 0; display: flex; flex-direction: column">
|
||||
|
||||
<header style="position: relative; z-index: 5; display: flex; align-items: center; flex-wrap: wrap; gap: 8px 16px; box-sizing: border-box; min-height: 56px; padding: 6px 12px 6px 20px; border-bottom: 1px solid #E6E8EC">
|
||||
<h1 style="margin: 0; font-size: 16px; font-weight: 700; letter-spacing: -0.01em">메일</h1>
|
||||
<form role="search" aria-label="메일 검색" style="position: relative; flex: 1 1 320px; max-width: 560px; display: flex; align-items: center; gap: 6px; box-sizing: border-box; height: 40px; margin: 0; padding: 0 0 0 12px; border: 1px solid #0E7C86; border-radius: 8px; background: #FFFFFF; box-shadow: 0 0 0 1px #0E7C86">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="#667085" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="11" cy="11" r="8"></circle><line x1="21" y1="21" x2="16.65" y2="16.65"></line></svg>
|
||||
<input type="search" aria-label="메일 검색" placeholder="메일 검색" value="계약" style="flex: 1 1 auto; min-width: 0; border: 0; outline: none; background: transparent; font: inherit; color: #171C26">
|
||||
<button type="button" onClick="{{ toggleCond }}" aria-expanded="{{ condOpenStr }}" aria-controls="search-conditions" style="flex: 0 0 auto; display: inline-flex; align-items: center; gap: 5px; height: 32px; padding: 0 9px; border: 0; border-radius: 6px; background: {{ condBtnBg }}; color: {{ condBtnFg }}; font: inherit; font-size: 12.5px; font-weight: 600; cursor: pointer">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><line x1="4" y1="21" x2="4" y2="14"></line><line x1="4" y1="10" x2="4" y2="3"></line><line x1="12" y1="21" x2="12" y2="12"></line><line x1="12" y1="8" x2="12" y2="3"></line><line x1="20" y1="21" x2="20" y2="16"></line><line x1="20" y1="12" x2="20" y2="3"></line><line x1="1" y1="14" x2="7" y2="14"></line><line x1="9" y1="8" x2="15" y2="8"></line><line x1="17" y1="16" x2="23" y2="16"></line></svg>
|
||||
<span>조건</span>
|
||||
</button>
|
||||
<a href="Main.dc.html" aria-label="검색어 지우기" title="검색어 지우기 (Esc)" style="flex: 0 0 auto; width: 40px; height: 38px; display: inline-flex; align-items: center; justify-content: center; border-radius: 0 7px 7px 0; color: #667085; text-decoration: none">
|
||||
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><line x1="18" y1="6" x2="6" y2="18"></line><line x1="6" y1="6" x2="18" y2="18"></line></svg>
|
||||
</a>
|
||||
|
||||
<sc-if value="{{ condOpen }}" hint-placeholder-val="{{ false }}">
|
||||
<div id="search-conditions" role="group" aria-labelledby="cond-title" style="position: absolute; top: 46px; left: -2px; right: -2px; z-index: 20; box-sizing: border-box; display: flex; flex-direction: column; gap: 14px; padding: 16px 18px 14px; border: 1px solid #D0D5DD; border-radius: 10px; background: #FFFFFF; box-shadow: 0 12px 32px rgba(16, 24, 40, 0.16)">
|
||||
<h2 id="cond-title" style="margin: 0; font-size: 13.5px; font-weight: 700">조건으로 찾기</h2>
|
||||
<div style="display: grid; grid-template-columns: 76px minmax(0, 1fr); gap: 10px 12px; align-items: center">
|
||||
<label for="c-from" style="font-size: 12.5px; font-weight: 600; color: #4A5363">보낸 사람</label>
|
||||
<input id="c-from" class="fi" type="text" value="이도윤" placeholder="이름이나 메일 주소">
|
||||
<label for="c-to" style="font-size: 12.5px; font-weight: 600; color: #4A5363">받는 사람</label>
|
||||
<input id="c-to" class="fi" type="text" placeholder="이름이나 메일 주소">
|
||||
<label for="c-subject" style="font-size: 12.5px; font-weight: 600; color: #4A5363">제목</label>
|
||||
<input id="c-subject" class="fi" type="text" placeholder="제목에 든 말">
|
||||
<label for="c-body" style="font-size: 12.5px; font-weight: 600; color: #4A5363">내용</label>
|
||||
<input id="c-body" class="fi" type="text" placeholder="본문에 든 말">
|
||||
<label for="c-period" style="font-size: 12.5px; font-weight: 600; color: #4A5363">기간</label>
|
||||
<div style="display: flex; flex-wrap: wrap; align-items: center; gap: 8px">
|
||||
<select id="c-period" class="fi" value="{{ period }}" onChange="{{ setPeriod }}" style="min-width: 140px; cursor: pointer">
|
||||
<sc-for list="{{ periods }}" as="p" hint-placeholder-count="6"><option value="{{ p.v }}">{{ p.t }}</option></sc-for>
|
||||
</select>
|
||||
<sc-if value="{{ isCustom }}" hint-placeholder-val="{{ false }}">
|
||||
<span style="display: inline-flex; align-items: center; gap: 6px">
|
||||
<input type="date" class="fi" aria-label="시작 날짜" value="2026-10-01">
|
||||
<span aria-hidden="true" style="color: #667085">~</span>
|
||||
<input type="date" class="fi" aria-label="끝 날짜" value="2026-11-05">
|
||||
</span>
|
||||
</sc-if>
|
||||
</div>
|
||||
<span style="font-size: 12.5px; font-weight: 600; color: #4A5363">첨부</span>
|
||||
<label style="display: inline-flex; align-items: center; gap: 8px; min-height: 40px; cursor: pointer">
|
||||
<input type="checkbox" checked="{{ attOnly }}" onChange="{{ toggleAtt }}" style="width: 18px; height: 18px; margin: 0; accent-color: #0E7C86">
|
||||
<span>첨부 파일이 있는 메일만</span>
|
||||
</label>
|
||||
</div>
|
||||
<p style="margin: 0; font-size: 12px; color: #667085">검색 칸의 말과 이 조건에 모두 맞는 메일을 찾습니다. 받는 사람에는 참조로 받은 사람도 들어갑니다.</p>
|
||||
<div style="display: flex; justify-content: flex-end; gap: 8px">
|
||||
<button type="button" onClick="{{ clearCond }}" style="min-height: 40px; padding: 0 14px; border: 1px solid #D0D5DD; border-radius: 8px; background: #FFFFFF; color: #344054; font: inherit; font-weight: 600; cursor: pointer">조건 지우기</button>
|
||||
<button type="button" onClick="{{ applyCond }}" style="min-height: 40px; padding: 0 18px; border: 0; border-radius: 8px; background: #0E7C86; color: #FFFFFF; font: inherit; font-weight: 600; cursor: pointer">찾기</button>
|
||||
</div>
|
||||
</div>
|
||||
</sc-if>
|
||||
</form>
|
||||
<div style="margin-left: auto; display: flex; align-items: center; gap: 10px">
|
||||
<span aria-hidden="true" style="display: inline-flex; align-items: center; justify-content: center; width: 32px; height: 32px; border-radius: 50%; background: #E3F2F3; color: #0A5F67; font-size: 13px; font-weight: 600">김</span>
|
||||
<div style="display: flex; flex-direction: column; line-height: 1.3">
|
||||
<span style="font-weight: 600">김아라</span>
|
||||
<span style="font-size: 12px; color: #667085">ara.kim@arasystem.it.kr</span>
|
||||
</div>
|
||||
<a href="Login.dc.html" style="display: inline-flex; align-items: center; gap: 6px; min-height: 44px; padding: 0 10px; border-radius: 8px; color: #4A5363; font-weight: 500; text-decoration: none">
|
||||
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"></path><polyline points="16 17 21 12 16 7"></polyline><line x1="21" y1="12" x2="9" y2="12"></line></svg>
|
||||
<span>로그아웃</span>
|
||||
</a>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div style="flex: 1 1 auto; display: flex; flex-wrap: wrap; align-items: stretch">
|
||||
|
||||
<nav aria-label="메일함" style="flex: 1 1 208px; box-sizing: border-box; display: flex; flex-direction: column; gap: 14px; padding: 14px 10px; background: #F8F9FB; border-right: 1px solid #E6E8EC">
|
||||
<a href="Compose.dc.html" style="display: flex; align-items: center; justify-content: center; gap: 8px; min-height: 44px; border-radius: 8px; background: #0E7C86; color: #FFFFFF; font-size: 13.5px; font-weight: 600; text-decoration: none">
|
||||
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12 20h9"></path><path d="M16.5 3.5a2.121 2.121 0 0 1 3 3L7 19l-4 1 1-4L16.5 3.5z"></path></svg>
|
||||
<span>메일 쓰기</span>
|
||||
</a>
|
||||
<ul style="list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 1px">
|
||||
<sc-for list="{{ boxes }}" as="b" hint-placeholder-count="5">
|
||||
<li>
|
||||
<a href="Main.dc.html" aria-current="{{ b.current }}" style="display: flex; align-items: center; gap: 10px; min-height: 40px; box-sizing: border-box; padding: 0 10px; border-radius: 7px; background: {{ b.bg }}; color: {{ b.fg }}; font-weight: {{ b.fw }}; text-decoration: none">
|
||||
<span style="flex: 1 1 auto">{{ b.name }}</span>
|
||||
<sc-if value="{{ b.hasUnread }}" hint-placeholder-val="{{ false }}"><span style="font-size: 12px; font-weight: 600; color: {{ b.countColor }}; font-variant-numeric: tabular-nums">{{ b.unread }}</span></sc-if>
|
||||
</a>
|
||||
</li>
|
||||
</sc-for>
|
||||
</ul>
|
||||
</nav>
|
||||
|
||||
<main style="flex: 999 1 560px; min-width: 0; display: flex; flex-wrap: wrap; align-items: stretch">
|
||||
|
||||
<section aria-labelledby="result-title" style="flex: 1 1 384px; min-width: 0; box-sizing: border-box; display: flex; flex-direction: column; border-right: 1px solid #E6E8EC">
|
||||
<div style="display: flex; align-items: center; gap: 6px; min-height: 52px; box-sizing: border-box; padding: 4px; border-bottom: 1px solid #E6E8EC">
|
||||
<a href="Main.dc.html" aria-label="검색 닫기" title="검색을 닫고 받은편지함으로" style="flex: 0 0 auto; width: 40px; height: 44px; display: inline-flex; align-items: center; justify-content: center; border-radius: 8px; color: #4A5363; text-decoration: none">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><line x1="19" y1="12" x2="5" y2="12"></line><polyline points="12 19 5 12 12 5"></polyline></svg>
|
||||
</a>
|
||||
<h2 id="result-title" style="margin: 0; font-size: 15px; font-weight: 700; letter-spacing: -0.01em">검색 결과</h2>
|
||||
<span style="min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 12px; color: #667085; font-variant-numeric: tabular-nums">{{ countText }}</span>
|
||||
</div>
|
||||
|
||||
<div style="display: flex; flex-wrap: wrap; align-items: center; gap: 6px 10px; min-height: 48px; box-sizing: border-box; padding: 4px 12px 4px 16px; border-bottom: 1px solid #EEF0F3; background: #FBFBFC">
|
||||
<span id="scope-label" style="font-size: 12.5px; font-weight: 600; color: #4A5363">찾을 곳</span>
|
||||
<div role="group" aria-labelledby="scope-label" style="display: inline-flex; gap: 2px; padding: 2px; border-radius: 8px; background: #F2F4F7">
|
||||
<button type="button" class="seg" aria-pressed="{{ boxPressed }}" onClick="{{ scopeBox }}" style="min-height: 36px; padding: 0 12px; border: 0; border-radius: 6px; font: inherit; font-weight: 600; cursor: pointer">받은편지함</button>
|
||||
<button type="button" class="seg" aria-pressed="{{ allPressed }}" onClick="{{ scopeAll }}" style="min-height: 36px; padding: 0 12px; border: 0; border-radius: 6px; font: inherit; font-weight: 600; cursor: pointer">전체 메일함</button>
|
||||
</div>
|
||||
<sc-if value="{{ isAll }}" hint-placeholder-val="{{ false }}"><span style="font-size: 12px; color: #667085">휴지통과 스팸메일함은 빼고 찾습니다.</span></sc-if>
|
||||
</div>
|
||||
|
||||
<sc-if value="{{ applied }}" hint-placeholder-val="{{ false }}">
|
||||
<div style="display: flex; flex-wrap: wrap; align-items: center; gap: 2px 8px; min-height: 40px; box-sizing: border-box; padding: 2px 8px 2px 16px; border-bottom: 1px solid #EEF0F3">
|
||||
<span style="font-size: 12.5px; color: #344054">{{ condSummary }}</span>
|
||||
<button type="button" onClick="{{ clearCond }}" style="min-height: 36px; padding: 0 8px; border: 0; border-radius: 6px; background: transparent; color: #0E7C86; font: inherit; font-size: 12.5px; font-weight: 600; cursor: pointer">조건 지우기</button>
|
||||
</div>
|
||||
</sc-if>
|
||||
|
||||
<sc-if value="{{ showIndexing }}" hint-placeholder-val="{{ false }}">
|
||||
<div role="status" style="display: flex; align-items: flex-start; gap: 8px; box-sizing: border-box; padding: 10px 16px; border-bottom: 1px solid #E3EEEF; background: #F2F8F8; font-size: 12.5px; color: #344054">
|
||||
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="#0E7C86" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" style="flex: 0 0 auto; margin-top: 2px"><circle cx="12" cy="12" r="10"></circle><line x1="12" y1="16" x2="12" y2="12"></line><line x1="12" y1="8" x2="12.01" y2="8"></line></svg>
|
||||
<span>지난 메일을 검색할 수 있게 준비하고 있습니다. 준비가 끝날 때까지는 낱말이 통째로 같은 메일만 찾습니다.</span>
|
||||
</div>
|
||||
</sc-if>
|
||||
|
||||
<sc-if value="{{ showList }}" hint-placeholder-val="{{ true }}">
|
||||
<ul aria-label="검색 결과" style="list-style: none; margin: 0; padding: 0">
|
||||
<sc-for list="{{ items }}" as="item" hint-placeholder-count="5">
|
||||
<li style="border-bottom: 1px solid #EEF0F3; background: {{ item.bg }}">
|
||||
<button type="button" onClick="{{ item.pick }}" style="width: 100%; min-height: 64px; box-sizing: border-box; display: flex; flex-direction: column; justify-content: center; gap: 1px; padding: 7px 16px; border: 0; background: transparent; font: inherit; line-height: 1.4; color: inherit; text-align: left; cursor: pointer">
|
||||
<span style="display: flex; align-items: center; gap: 6px">
|
||||
<span style="flex: 1 1 auto; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-weight: {{ item.fw }}; color: {{ item.whoColor }}">{{ item.who }}</span>
|
||||
<sc-if value="{{ item.showBox }}" hint-placeholder-val="{{ false }}"><span style="flex: 0 0 auto; font-size: 12px; color: #667085">{{ item.boxName }}</span></sc-if>
|
||||
<span style="flex: 0 0 auto; font-size: 12px; font-weight: {{ item.timeFw }}; color: {{ item.timeColor }}; font-variant-numeric: tabular-nums">{{ item.time }}</span>
|
||||
</span>
|
||||
<span style="display: flex; align-items: center; gap: 6px; min-width: 0">
|
||||
<span style="min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-weight: {{ item.subFw }}; color: #171C26"><sc-for list="{{ item.subParts }}" as="p" hint-placeholder-count="1"><sc-if value="{{ p.hit }}" hint-placeholder-val="{{ false }}"><mark>{{ p.t }}</mark></sc-if><sc-if value="{{ p.plain }}" hint-placeholder-val="{{ true }}"><span>{{ p.t }}</span></sc-if></sc-for></span>
|
||||
<sc-if value="{{ item.hasAtt }}" hint-placeholder-val="{{ false }}"><svg role="img" aria-label="첨부 있음" width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="#667085" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="flex: 0 0 auto"><path d="M21.44 11.05l-9.19 9.19a6 6 0 0 1-8.49-8.49l9.19-9.19a4 4 0 0 1 5.66 5.66l-9.2 9.19a2 2 0 0 1-2.83-2.83l8.49-8.48"></path></svg></sc-if>
|
||||
</span>
|
||||
<span style="min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 12.5px; color: #667085"><sc-for list="{{ item.preParts }}" as="p" hint-placeholder-count="1"><sc-if value="{{ p.hit }}" hint-placeholder-val="{{ false }}"><mark>{{ p.t }}</mark></sc-if><sc-if value="{{ p.plain }}" hint-placeholder-val="{{ true }}"><span>{{ p.t }}</span></sc-if></sc-for></span>
|
||||
</button>
|
||||
</li>
|
||||
</sc-for>
|
||||
</ul>
|
||||
</sc-if>
|
||||
|
||||
<sc-if value="{{ showEmpty }}" hint-placeholder-val="{{ false }}">
|
||||
<div style="display: flex; flex-direction: column; align-items: center; gap: 6px; padding: 72px 28px; text-align: center">
|
||||
<svg width="36" height="36" viewBox="0 0 24 24" fill="none" stroke="#98A2B3" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="11" cy="11" r="8"></circle><line x1="21" y1="21" x2="16.65" y2="16.65"></line></svg>
|
||||
<p style="margin: 6px 0 0; font-size: 14px; font-weight: 600">‘계약’에 맞는 메일이 없습니다</p>
|
||||
<p style="margin: 0; max-width: 300px; font-size: 12.5px; color: #667085">다른 말로 찾거나 조건을 줄여 보세요.</p>
|
||||
<sc-if value="{{ offerAll }}" hint-placeholder-val="{{ true }}">
|
||||
<button type="button" onClick="{{ scopeAll }}" style="margin-top: 8px; min-height: 44px; padding: 0 16px; border: 0; border-radius: 8px; background: transparent; color: #0E7C86; font: inherit; font-weight: 600; cursor: pointer">전체 메일함에서 찾기</button>
|
||||
</sc-if>
|
||||
</div>
|
||||
</sc-if>
|
||||
</section>
|
||||
|
||||
<div style="flex: 999 1 480px; min-width: 0; box-sizing: border-box; display: flex; flex-direction: column">
|
||||
<sc-if value="{{ hasSel }}" hint-placeholder-val="{{ true }}">
|
||||
<article aria-labelledby="mail-subject" style="display: flex; flex-direction: column">
|
||||
<div style="border-bottom: 1px solid #E6E8EC"><div style="display: flex; flex-wrap: wrap; align-items: center; gap: 2px; min-height: 52px; max-width: 800px; box-sizing: border-box; padding: 4px 32px 4px 22px">
|
||||
<a href="Compose.dc.html" style="display: inline-flex; align-items: center; gap: 6px; min-height: 44px; box-sizing: border-box; padding: 0 10px; border-radius: 8px; color: #344054; font-weight: 500; text-decoration: none">
|
||||
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="9 14 4 9 9 4"></polyline><path d="M20 20v-7a4 4 0 0 0-4-4H4"></path></svg>
|
||||
<span>답장</span>
|
||||
</a>
|
||||
<a href="Compose.dc.html" style="display: inline-flex; align-items: center; gap: 6px; min-height: 44px; box-sizing: border-box; padding: 0 10px; border-radius: 8px; color: #344054; font-weight: 500; text-decoration: none">
|
||||
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="7 17 2 12 7 7"></polyline><polyline points="12 17 7 12 12 7"></polyline><path d="M22 18v-2a4 4 0 0 0-4-4H7"></path></svg>
|
||||
<span>전체 답장</span>
|
||||
</a>
|
||||
<a href="Compose.dc.html" style="display: inline-flex; align-items: center; gap: 6px; min-height: 44px; box-sizing: border-box; padding: 0 10px; border-radius: 8px; color: #344054; font-weight: 500; text-decoration: none">
|
||||
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="15 14 20 9 15 4"></polyline><path d="M4 20v-7a4 4 0 0 1 4-4h12"></path></svg>
|
||||
<span>전달</span>
|
||||
</a>
|
||||
</div></div>
|
||||
<div style="display: flex; flex-direction: column; gap: 18px; max-width: 800px; box-sizing: border-box; padding: 20px 32px 36px">
|
||||
<h1 id="mail-subject" style="margin: 4px 0 0; font-size: 20px; line-height: 1.4; font-weight: 650; letter-spacing: -0.015em"><sc-for list="{{ m.subParts }}" as="p" hint-placeholder-count="1"><sc-if value="{{ p.hit }}" hint-placeholder-val="{{ false }}"><mark>{{ p.t }}</mark></sc-if><sc-if value="{{ p.plain }}" hint-placeholder-val="{{ true }}"><span>{{ p.t }}</span></sc-if></sc-for></h1>
|
||||
<div style="display: flex; align-items: flex-start; gap: 12px">
|
||||
<span aria-hidden="true" style="flex: 0 0 auto; display: inline-flex; align-items: center; justify-content: center; width: 36px; height: 36px; border-radius: 50%; background: #E3F2F3; color: #0A5F67; font-size: 14px; font-weight: 600">{{ m.initial }}</span>
|
||||
<div style="flex: 1 1 auto; min-width: 0; display: flex; flex-direction: column; gap: 1px">
|
||||
<div style="display: flex; flex-wrap: wrap; align-items: baseline; gap: 2px 8px">
|
||||
<span style="font-size: 13.5px; font-weight: 600">{{ m.fromName }}</span>
|
||||
<span style="font-size: 12.5px; color: #667085">{{ m.fromEmail }}</span>
|
||||
<span style="margin-left: auto; font-size: 12.5px; color: #667085; white-space: nowrap; font-variant-numeric: tabular-nums">{{ m.date }}</span>
|
||||
</div>
|
||||
<span style="font-size: 12.5px; color: #4A5363">받는 사람 {{ m.to }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div style="white-space: pre-line; font-size: 14px; line-height: 1.75; color: #1F2733; max-width: 680px"><sc-for list="{{ m.bodyParts }}" as="p" hint-placeholder-count="1"><sc-if value="{{ p.hit }}" hint-placeholder-val="{{ false }}"><mark>{{ p.t }}</mark></sc-if><sc-if value="{{ p.plain }}" hint-placeholder-val="{{ true }}"><span>{{ p.t }}</span></sc-if></sc-for></div>
|
||||
<sc-if value="{{ m.hasAtt }}" hint-placeholder-val="{{ true }}">
|
||||
<section aria-labelledby="att-title" style="display: flex; flex-direction: column; gap: 8px; padding-top: 16px; border-top: 1px solid #EEF0F3">
|
||||
<h2 id="att-title" style="margin: 0; font-size: 12.5px; font-weight: 600; color: #4A5363">첨부 파일 {{ m.attCount }}개</h2>
|
||||
<ul style="list-style: none; margin: 0; padding: 0; display: grid; grid-template-columns: repeat(auto-fill, minmax(min(260px, 100%), 1fr)); gap: 8px">
|
||||
<sc-for list="{{ m.atts }}" as="att" hint-placeholder-count="1">
|
||||
<li style="display: flex; align-items: center; gap: 10px; padding: 4px 2px 4px 8px; border: 1px solid #E6E8EC; border-radius: 8px">
|
||||
<span aria-hidden="true" style="flex: 0 0 auto; display: inline-flex; align-items: center; justify-content: center; width: 32px; height: 32px; border-radius: 6px; background: #F2F4F7; color: #4A5363">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><path d="M13 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V9z"></path><polyline points="13 2 13 9 20 9"></polyline></svg>
|
||||
</span>
|
||||
<div style="flex: 1 1 auto; min-width: 0; display: flex; flex-direction: column; line-height: 1.35">
|
||||
<span style="overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-weight: 500"><sc-for list="{{ att.nameParts }}" as="p" hint-placeholder-count="1"><sc-if value="{{ p.hit }}" hint-placeholder-val="{{ false }}"><mark>{{ p.t }}</mark></sc-if><sc-if value="{{ p.plain }}" hint-placeholder-val="{{ true }}"><span>{{ p.t }}</span></sc-if></sc-for></span>
|
||||
<span style="font-size: 12px; color: #667085">{{ att.size }}</span>
|
||||
</div>
|
||||
<button type="button" aria-label="{{ att.label }}" style="flex: 0 0 auto; width: 44px; height: 44px; display: inline-flex; align-items: center; justify-content: center; border: 0; border-radius: 8px; background: transparent; color: #344054; cursor: pointer">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"></path><polyline points="7 10 12 15 17 10"></polyline><line x1="12" y1="15" x2="12" y2="3"></line></svg>
|
||||
</button>
|
||||
</li>
|
||||
</sc-for>
|
||||
</ul>
|
||||
</section>
|
||||
</sc-if>
|
||||
</div>
|
||||
</article>
|
||||
</sc-if>
|
||||
|
||||
<sc-if value="{{ noSel }}" hint-placeholder-val="{{ false }}">
|
||||
<div style="flex: 1 1 auto; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 6px; min-height: 520px; padding: 24px; text-align: center">
|
||||
<svg width="36" height="36" viewBox="0 0 24 24" fill="none" stroke="#98A2B3" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M4 4h16c1.1 0 2 .9 2 2v12c0 1.1-.9 2-2 2H4c-1.1 0-2-.9-2-2V6c0-1.1.9-2 2-2z"></path><polyline points="22,6 12,13 2,6"></polyline></svg>
|
||||
<p style="margin: 6px 0 0; font-size: 14px; font-weight: 600">메일을 골라 주세요</p>
|
||||
<p style="margin: 0; font-size: 12.5px; color: #667085">검색 결과에서 메일을 고르면 여기에 내용이 보입니다.</p>
|
||||
</div>
|
||||
</sc-if>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</x-dc>
|
||||
<script type="text/x-dc" data-dc-script data-props='{"variant":{"editor":"enum","options":["결과","조건 열림","조건으로 찾음","검색 준비 중","결과 없음"],"default":"결과"},"$preview":{"width":1440,"height":900}}'>
|
||||
class Component extends DCLogic {
|
||||
state = { scope: 'box', sel: 'r1', read: {}, condOpen: null, applied: null, period: 'month', attOnly: true };
|
||||
|
||||
results() {
|
||||
return [
|
||||
{
|
||||
id: 'r1', box: 'inbox', who: '이도윤', fromName: '이도윤', fromEmail: 'doyun.lee@arasystem.it.kr', byDoyun: true,
|
||||
to: '김아라 <ara.kim@arasystem.it.kr>', subject: 'Re: 유지보수 계약 갱신 건', time: '오전 10:42',
|
||||
date: '2026. 11. 5. (목) 오전 10:42', unread: true,
|
||||
preview: '지난번 말씀하신 계약서 초안을 첨부했습니다. 단가는 작년 계약과 같게 두었습니다.',
|
||||
body: '김아라님, 안녕하세요.\n\n지난번 말씀하신 계약서 초안을 첨부했습니다.\n단가는 작년 계약과 같게 두었고, 기간만 1년 늘렸습니다.\n\n검토하시고 고칠 곳을 알려 주시면 바로 반영하겠습니다.\n\n이도윤 드림',
|
||||
atts: [{ name: '유지보수_계약서_초안.docx', size: '48 KB' }],
|
||||
},
|
||||
{
|
||||
id: 'r2', box: 'inbox', who: '인사팀', fromName: '인사팀', fromEmail: 'hr@arasystem.it.kr',
|
||||
to: '전체 구성원 <all@arasystem.it.kr>', subject: '[공지] 계약직 채용 일정 안내', time: '오전 9:05',
|
||||
date: '2026. 11. 5. (목) 오전 9:05', unread: true,
|
||||
preview: '11월 계약직 채용은 아래 일정으로 진행합니다.',
|
||||
body: '구성원 여러분, 인사팀입니다.\n\n11월 계약직 채용은 아래 일정으로 진행합니다.\n서류는 11월 12일(목)까지 받고, 면접은 그다음 주에 봅니다.',
|
||||
atts: [],
|
||||
},
|
||||
{
|
||||
id: 'r3', box: 'sent', who: '받는 사람: 정하늘', fromName: '김아라', fromEmail: 'ara.kim@arasystem.it.kr',
|
||||
to: '정하늘 <haneul.jung@arasystem.it.kr>', subject: '회신: 공급 계약은 이번 주에 마무리합니다', time: '어제',
|
||||
date: '2026. 11. 4. (수) 오후 5:48', unread: false,
|
||||
preview: '말씀하신 공급 계약은 금요일 오전에 서명하기로 했습니다.',
|
||||
body: '하늘님,\n\n말씀하신 공급 계약은 금요일 오전에 서명하기로 했습니다.\n계약을 마치면 사본을 공유 폴더에 올려 두겠습니다.\n\n김아라',
|
||||
atts: [],
|
||||
},
|
||||
{
|
||||
id: 'r4', box: 'inbox', who: '최서연', fromName: '최서연', fromEmail: 'seoyeon.choi@arasystem.it.kr',
|
||||
to: '전체 구성원 <all@arasystem.it.kr>', subject: '11월 비용 정산 안내', time: '10월 30일',
|
||||
date: '2026. 10. 30. (금) 오후 4:20', unread: false,
|
||||
preview: '용역 계약 대금은 다음 달에 한꺼번에 정산합니다.',
|
||||
body: '안녕하세요, 재무팀 최서연입니다.\n\n10월분 비용 마감은 11월 4일(수)입니다.\n용역 계약 대금은 다음 달에 한꺼번에 정산합니다.',
|
||||
atts: [],
|
||||
},
|
||||
{
|
||||
id: 'r5', box: 'inbox', who: '한유진', fromName: '한유진', fromEmail: 'yujin.han@partner.example.com',
|
||||
to: '김아라 <ara.kim@arasystem.it.kr>', subject: '견적서와 계약 조건 정리', time: '10월 28일',
|
||||
date: '2026. 10. 28. (수) 오전 11:15', unread: false,
|
||||
preview: '요청하신 견적서와 계약 조건을 한 장으로 정리했습니다.',
|
||||
body: '김아라 님께,\n\n요청하신 견적서와 계약 조건을 한 장으로 정리했습니다.\n계약 기간과 지급 조건은 2쪽에 있습니다.\n\n한유진 드림',
|
||||
atts: [{ name: '견적서_계약조건.pdf', size: '310 KB' }],
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
parts(text, q) {
|
||||
const out = [];
|
||||
let i = 0;
|
||||
while (i < text.length) {
|
||||
const j = text.indexOf(q, i);
|
||||
if (j < 0) { out.push({ t: text.slice(i), hit: false, plain: true }); break; }
|
||||
if (j > i) out.push({ t: text.slice(i, j), hit: false, plain: true });
|
||||
out.push({ t: q, hit: true, plain: false });
|
||||
i = j + q.length;
|
||||
}
|
||||
return out.length ? out : [{ t: text, hit: false, plain: true }];
|
||||
}
|
||||
|
||||
renderVals() {
|
||||
const st = this.state;
|
||||
const variant = this.props.variant ?? '결과';
|
||||
const q = '계약';
|
||||
const condOpen = st.condOpen ?? (variant === '조건 열림');
|
||||
const applied = st.applied ?? (variant === '조건으로 찾음');
|
||||
const isAll = st.scope === 'all';
|
||||
const BOXNAME = { inbox: '받은편지함', sent: '보낸편지함' };
|
||||
const PERIODS = [
|
||||
{ v: 'all', t: '전체 기간' }, { v: 'week', t: '최근 1주' }, { v: 'month', t: '최근 1달' },
|
||||
{ v: 'quarter', t: '최근 3달' }, { v: 'year', t: '최근 1년' }, { v: 'custom', t: '날짜 고르기' },
|
||||
];
|
||||
const periodLabel = st.period === 'custom' ? '2026. 10. 1.부터 11. 5.까지' : PERIODS.find((p) => p.v === st.period).t;
|
||||
|
||||
let list = this.results().filter((x) => isAll || x.box === 'inbox');
|
||||
if (applied) list = list.filter((x) => x.byDoyun && (!st.attOnly || x.atts.length > 0));
|
||||
if (variant === '결과 없음') list = [];
|
||||
const isUnread = (x) => x.unread && !st.read[x.id];
|
||||
|
||||
const PANEL = [
|
||||
{ id: 'inbox', name: '받은편지함', unread: 2 },
|
||||
{ id: 'sent', name: '보낸편지함', unread: 0 },
|
||||
{ id: 'drafts', name: '임시보관함', unread: 0 },
|
||||
{ id: 'junk', name: '스팸메일함', unread: 0 },
|
||||
{ id: 'trash', name: '휴지통', unread: 0 },
|
||||
];
|
||||
const boxes = PANEL.map((b) => {
|
||||
const active = !isAll && b.id === 'inbox';
|
||||
return {
|
||||
name: b.name,
|
||||
current: active ? 'page' : 'false',
|
||||
bg: active ? '#E3F2F3' : 'transparent',
|
||||
fg: active ? '#0A5F67' : '#344054',
|
||||
fw: active ? '600' : '500',
|
||||
countColor: active ? '#0A5F67' : '#171C26',
|
||||
unread: b.unread,
|
||||
hasUnread: b.unread > 0,
|
||||
};
|
||||
});
|
||||
|
||||
const items = list.map((x) => {
|
||||
const selected = x.id === st.sel;
|
||||
const unread = isUnread(x);
|
||||
return {
|
||||
who: x.who,
|
||||
time: x.time,
|
||||
hasAtt: x.atts.length > 0,
|
||||
showBox: isAll,
|
||||
boxName: BOXNAME[x.box],
|
||||
subParts: this.parts(x.subject, q),
|
||||
preParts: this.parts(x.preview, q),
|
||||
bg: selected ? '#E3F2F3' : '#FFFFFF',
|
||||
fw: unread ? '650' : '450',
|
||||
whoColor: unread ? '#171C26' : '#344054',
|
||||
subFw: unread ? '600' : '400',
|
||||
timeFw: unread ? '600' : '400',
|
||||
timeColor: unread ? '#171C26' : '#667085',
|
||||
pick: () => this.setState({ sel: x.id, read: { ...st.read, [x.id]: true } }),
|
||||
};
|
||||
});
|
||||
|
||||
const cur = list.find((x) => x.id === st.sel) || list[0] || null;
|
||||
const base = cur || { subject: '', fromName: '', fromEmail: '', to: '', date: '', body: '', atts: [] };
|
||||
const m = {
|
||||
subParts: this.parts(base.subject, q),
|
||||
bodyParts: this.parts(base.body, q),
|
||||
fromName: base.fromName,
|
||||
fromEmail: base.fromEmail,
|
||||
to: base.to,
|
||||
date: base.date,
|
||||
initial: base.fromName ? base.fromName.slice(0, 1) : '',
|
||||
atts: base.atts.map((a) => ({ nameParts: this.parts(a.name, q), size: a.size, label: a.name + ' 내려받기' })),
|
||||
attCount: base.atts.length,
|
||||
hasAtt: base.atts.length > 0,
|
||||
};
|
||||
|
||||
return {
|
||||
boxes: boxes,
|
||||
items: items,
|
||||
countText: `‘${q}’ · ${list.length}통`,
|
||||
condOpen: condOpen,
|
||||
condOpenStr: condOpen ? 'true' : 'false',
|
||||
condBtnBg: condOpen || applied ? '#E3F2F3' : 'transparent',
|
||||
condBtnFg: condOpen || applied ? '#0A5F67' : '#4A5363',
|
||||
toggleCond: () => this.setState({ condOpen: !condOpen }),
|
||||
applyCond: () => this.setState({ condOpen: false, applied: true }),
|
||||
clearCond: () => this.setState({ condOpen: false, applied: false, period: 'month', attOnly: true }),
|
||||
period: st.period,
|
||||
periods: PERIODS,
|
||||
setPeriod: (e) => this.setState({ period: e.target.value }),
|
||||
isCustom: st.period === 'custom',
|
||||
attOnly: st.attOnly,
|
||||
toggleAtt: () => this.setState({ attOnly: !st.attOnly }),
|
||||
applied: applied,
|
||||
condSummary: `조건: 이도윤 님이 보낸 메일, ${periodLabel}${st.attOnly ? ', 첨부 있음' : ''}`,
|
||||
showIndexing: variant === '검색 준비 중',
|
||||
isAll: isAll,
|
||||
boxPressed: isAll ? 'false' : 'true',
|
||||
allPressed: isAll ? 'true' : 'false',
|
||||
scopeBox: () => this.setState({ scope: 'box', sel: 'r1' }),
|
||||
scopeAll: () => this.setState({ scope: 'all', sel: 'r1' }),
|
||||
showList: list.length > 0,
|
||||
showEmpty: list.length === 0,
|
||||
offerAll: !isAll,
|
||||
hasSel: !!cur,
|
||||
noSel: !cur,
|
||||
m: m,
|
||||
};
|
||||
}
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,304 @@
|
||||
<!doctype html>
|
||||
<html lang="ko">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>읽기 화면 · 같은 대화</title>
|
||||
<script src="./support.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
<x-dc>
|
||||
<helmet>
|
||||
<style>
|
||||
@font-face{font-family:'Pretendard';src:url('/_blob/16067b9aa5137abf808e3dbef30c42e0') format('woff2-variations'),url('/_blob/16067b9aa5137abf808e3dbef30c42e0') format('woff2');font-weight:45 920;font-style:normal;font-display:swap}
|
||||
body{margin:0}
|
||||
a{color:#0E7C86}a:hover{color:#0A5F67}
|
||||
:focus-visible{outline:2px solid #0E7C86;outline-offset:2px}
|
||||
.row:hover{background:#F8F9FB}
|
||||
</style>
|
||||
</helmet>
|
||||
<div style="min-height: 100vh; display: flex; background: #FFFFFF; color: #171C26; font-family: Pretendard, -apple-system, 'Apple SD Gothic Neo', 'Malgun Gothic', system-ui, sans-serif; font-size: 13px; line-height: 1.5">
|
||||
|
||||
<nav aria-label="사내 시스템" style="flex: 0 0 72px; box-sizing: border-box; display: flex; flex-direction: column; align-items: center; gap: 14px; padding: 10px 0 12px; background: #0B3640">
|
||||
<a href="Main.dc.html" style="display: inline-flex; align-items: center; justify-content: center; width: 60px; min-height: 44px; border-radius: 10px; text-decoration: none">
|
||||
<img src="/_blob/2b9ef4349333debc969432c859eaf1bf" alt="ARA SYSTEM" width="48" height="17" style="display: block; width: 48px; height: auto">
|
||||
</a>
|
||||
<ul style="list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 4px">
|
||||
<li>
|
||||
<a href="Main.dc.html" aria-current="page" style="display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 3px; width: 60px; min-height: 56px; border-radius: 10px; background: #155A64; color: #FFFFFF; font-size: 11px; font-weight: 600; text-decoration: none">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M4 4h16c1.1 0 2 .9 2 2v12c0 1.1-.9 2-2 2H4c-1.1 0-2-.9-2-2V6c0-1.1.9-2 2-2z"></path><polyline points="22,6 12,13 2,6"></polyline></svg>
|
||||
<span>메일</span>
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="#" style="display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 3px; width: 60px; min-height: 56px; border-radius: 10px; color: #A3C2C6; font-size: 11px; font-weight: 500; text-decoration: none">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="3" y="4" width="18" height="18" rx="2" ry="2"></rect><line x1="16" y1="2" x2="16" y2="6"></line><line x1="8" y1="2" x2="8" y2="6"></line><line x1="3" y1="10" x2="21" y2="10"></line></svg>
|
||||
<span>캘린더</span>
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="#" style="display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 3px; width: 60px; min-height: 56px; border-radius: 10px; color: #A3C2C6; font-size: 11px; font-weight: 500; text-decoration: none">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"></path></svg>
|
||||
<span>파일</span>
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="#" style="display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 3px; width: 60px; min-height: 56px; border-radius: 10px; color: #A3C2C6; font-size: 11px; font-weight: 500; text-decoration: none">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"></path><circle cx="9" cy="7" r="4"></circle><path d="M23 21v-2a4 4 0 0 0-3-3.87"></path><path d="M16 3.13a4 4 0 0 1 0 7.75"></path></svg>
|
||||
<span>주소록</span>
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
<a href="Settings.dc.html" style="margin-top: auto; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 3px; width: 60px; min-height: 56px; border-radius: 10px; color: #A3C2C6; font-size: 11px; font-weight: 500; text-decoration: none">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="3"></circle><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z"></path></svg>
|
||||
<span>설정</span>
|
||||
</a>
|
||||
</nav>
|
||||
|
||||
<div style="flex: 1 1 auto; min-width: 0; display: flex; flex-direction: column">
|
||||
|
||||
<header style="display: flex; align-items: center; flex-wrap: wrap; gap: 8px 16px; box-sizing: border-box; min-height: 56px; padding: 6px 12px 6px 20px; border-bottom: 1px solid #E6E8EC">
|
||||
<h1 style="margin: 0; font-size: 16px; font-weight: 700; letter-spacing: -0.01em">메일</h1>
|
||||
<form role="search" aria-label="메일 검색" style="flex: 1 1 280px; max-width: 520px; display: flex; align-items: center; gap: 8px; box-sizing: border-box; height: 40px; margin: 0; padding: 0 12px; border: 1px solid #D0D5DD; border-radius: 8px; background: #FFFFFF">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="#667085" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="11" cy="11" r="8"></circle><line x1="21" y1="21" x2="16.65" y2="16.65"></line></svg>
|
||||
<input type="search" aria-label="메일 검색" placeholder="메일 검색" style="flex: 1 1 auto; min-width: 0; border: 0; outline: none; background: transparent; font: inherit; color: #171C26">
|
||||
</form>
|
||||
<div style="margin-left: auto; display: flex; align-items: center; gap: 10px">
|
||||
<span aria-hidden="true" style="display: inline-flex; align-items: center; justify-content: center; width: 32px; height: 32px; border-radius: 50%; background: #E3F2F3; color: #0A5F67; font-size: 13px; font-weight: 600">김</span>
|
||||
<div style="display: flex; flex-direction: column; line-height: 1.3">
|
||||
<span style="font-weight: 600">김아라</span>
|
||||
<span style="font-size: 12px; color: #667085">ara.kim@arasystem.it.kr</span>
|
||||
</div>
|
||||
<a href="Login.dc.html" style="display: inline-flex; align-items: center; gap: 6px; min-height: 44px; padding: 0 10px; border-radius: 8px; color: #4A5363; font-weight: 500; text-decoration: none">
|
||||
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"></path><polyline points="16 17 21 12 16 7"></polyline><line x1="21" y1="12" x2="9" y2="12"></line></svg>
|
||||
<span>로그아웃</span>
|
||||
</a>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div style="flex: 1 1 auto; display: flex; flex-wrap: wrap; align-items: stretch">
|
||||
|
||||
<nav aria-label="메일함" style="flex: 1 1 208px; box-sizing: border-box; display: flex; flex-direction: column; gap: 14px; padding: 14px 10px; background: #F8F9FB; border-right: 1px solid #E6E8EC">
|
||||
<a href="Compose.dc.html" style="display: flex; align-items: center; justify-content: center; gap: 8px; min-height: 44px; border-radius: 8px; background: #0E7C86; color: #FFFFFF; font-size: 13.5px; font-weight: 600; text-decoration: none">
|
||||
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12 20h9"></path><path d="M16.5 3.5a2.121 2.121 0 0 1 3 3L7 19l-4 1 1-4L16.5 3.5z"></path></svg>
|
||||
<span>메일 쓰기</span>
|
||||
</a>
|
||||
<ul style="list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 1px">
|
||||
<li><a href="Main.dc.html" aria-current="page" style="display: flex; align-items: center; gap: 10px; min-height: 40px; box-sizing: border-box; padding: 0 10px; border-radius: 7px; background: #E3F2F3; color: #0A5F67; font-weight: 600; text-decoration: none"><span style="flex: 1 1 auto">받은편지함</span><span style="font-size: 12px; font-weight: 600; font-variant-numeric: tabular-nums">2</span></a></li>
|
||||
<li><a href="#" style="display: flex; align-items: center; min-height: 40px; box-sizing: border-box; padding: 0 10px; border-radius: 7px; color: #344054; font-weight: 500; text-decoration: none">보낸편지함</a></li>
|
||||
<li><a href="#" style="display: flex; align-items: center; min-height: 40px; box-sizing: border-box; padding: 0 10px; border-radius: 7px; color: #344054; font-weight: 500; text-decoration: none">임시보관함</a></li>
|
||||
<li><a href="#" style="display: flex; align-items: center; min-height: 40px; box-sizing: border-box; padding: 0 10px; border-radius: 7px; color: #344054; font-weight: 500; text-decoration: none">스팸메일함</a></li>
|
||||
<li><a href="#" style="display: flex; align-items: center; min-height: 40px; box-sizing: border-box; padding: 0 10px; border-radius: 7px; color: #344054; font-weight: 500; text-decoration: none">휴지통</a></li>
|
||||
</ul>
|
||||
</nav>
|
||||
|
||||
<main style="flex: 999 1 560px; min-width: 0; display: flex; flex-wrap: wrap; align-items: stretch">
|
||||
|
||||
<section aria-labelledby="list-title" style="flex: 1 1 360px; min-width: 0; box-sizing: border-box; display: flex; flex-direction: column; border-right: 1px solid #E6E8EC">
|
||||
<div style="display: flex; align-items: center; flex-wrap: wrap; gap: 6px 8px; min-height: 52px; box-sizing: border-box; padding: 4px 8px 4px 16px; border-bottom: 1px solid #E6E8EC">
|
||||
<h2 id="list-title" style="margin: 0; font-size: 15px; font-weight: 700; letter-spacing: -0.01em">받은편지함</h2>
|
||||
<span style="font-size: 12px; color: #667085">안 읽은 메일 2통</span>
|
||||
<a href="ListThreads14.dc.html" style="margin-left: auto; display: inline-flex; align-items: center; gap: 6px; min-height: 36px; box-sizing: border-box; padding: 0 10px; border: 1px solid #D0D5DD; border-radius: 8px; background: #FFFFFF; color: #344054; font-size: 12.5px; font-weight: 600; text-decoration: none">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"></path></svg>
|
||||
<span>대화로 묶기</span>
|
||||
</a>
|
||||
</div>
|
||||
<ul aria-label="받은편지함 메일" style="list-style: none; margin: 0; padding: 0">
|
||||
<sc-for list="{{ items }}" as="item" hint-placeholder-count="6">
|
||||
<li style="border-bottom: 1px solid #EEF0F3; background: {{ item.bg }}">
|
||||
<a href="#" style="display: flex; flex-direction: column; justify-content: center; gap: 1px; min-height: 52px; box-sizing: border-box; padding: 6px 16px; color: inherit; text-decoration: none">
|
||||
<span style="display: flex; align-items: center; gap: 6px">
|
||||
<span style="flex: 1 1 auto; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-weight: {{ item.fw }}">{{ item.who }}</span>
|
||||
<span style="flex: 0 0 auto; font-size: 12px; color: {{ item.timeColor }}; font-weight: {{ item.timeFw }}; font-variant-numeric: tabular-nums">{{ item.time }}</span>
|
||||
</span>
|
||||
<span style="display: flex; align-items: center; gap: 6px; min-width: 0">
|
||||
<span style="min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-weight: {{ item.subFw }}">{{ item.subject }}</span>
|
||||
<sc-if value="{{ item.hasAtt }}" hint-placeholder-val="{{ false }}"><svg role="img" aria-label="첨부 있음" width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="#667085" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="flex: 0 0 auto"><path d="M21.44 11.05l-9.19 9.19a6 6 0 0 1-8.49-8.49l9.19-9.19a4 4 0 0 1 5.66 5.66l-9.2 9.19a2 2 0 0 1-2.83-2.83l8.49-8.48"></path></svg></sc-if>
|
||||
</span>
|
||||
</a>
|
||||
</li>
|
||||
</sc-for>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<div style="flex: 999 1 480px; min-width: 0; box-sizing: border-box; display: flex; flex-direction: column">
|
||||
<article aria-labelledby="mail-subject" style="display: flex; flex-direction: column">
|
||||
<div style="border-bottom: 1px solid #E6E8EC"><div style="display: flex; flex-wrap: wrap; align-items: center; gap: 2px; min-height: 52px; max-width: 800px; box-sizing: border-box; padding: 4px 32px 4px 22px">
|
||||
<a href="Compose.dc.html" style="display: inline-flex; align-items: center; gap: 6px; min-height: 44px; box-sizing: border-box; padding: 0 10px; border-radius: 8px; color: #344054; font-weight: 500; text-decoration: none">
|
||||
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="9 14 4 9 9 4"></polyline><path d="M20 20v-7a4 4 0 0 0-4-4H4"></path></svg>
|
||||
<span>답장</span>
|
||||
</a>
|
||||
<a href="Compose.dc.html" style="display: inline-flex; align-items: center; gap: 6px; min-height: 44px; box-sizing: border-box; padding: 0 10px; border-radius: 8px; color: #344054; font-weight: 500; text-decoration: none">
|
||||
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="7 17 2 12 7 7"></polyline><polyline points="12 17 7 12 12 7"></polyline><path d="M22 18v-2a4 4 0 0 0-4-4H7"></path></svg>
|
||||
<span>전체 답장</span>
|
||||
</a>
|
||||
<a href="Compose.dc.html" style="display: inline-flex; align-items: center; gap: 6px; min-height: 44px; box-sizing: border-box; padding: 0 10px; border-radius: 8px; color: #344054; font-weight: 500; text-decoration: none">
|
||||
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="15 14 20 9 15 4"></polyline><path d="M4 20v-7a4 4 0 0 1 4-4h12"></path></svg>
|
||||
<span>전달</span>
|
||||
</a>
|
||||
</div></div>
|
||||
<div style="display: flex; flex-direction: column; gap: 18px; max-width: 800px; box-sizing: border-box; padding: 20px 32px 36px">
|
||||
<h1 id="mail-subject" style="margin: 4px 0 0; font-size: 20px; line-height: 1.4; font-weight: 650; letter-spacing: -0.015em">Re: 유지보수 계약 갱신 건</h1>
|
||||
<div style="display: flex; align-items: flex-start; gap: 12px">
|
||||
<span aria-hidden="true" style="flex: 0 0 auto; display: inline-flex; align-items: center; justify-content: center; width: 36px; height: 36px; border-radius: 50%; background: #E3F2F3; color: #0A5F67; font-size: 14px; font-weight: 600">이</span>
|
||||
<div style="flex: 1 1 auto; min-width: 0; display: flex; flex-direction: column; gap: 1px">
|
||||
<div style="display: flex; flex-wrap: wrap; align-items: baseline; gap: 2px 8px">
|
||||
<span style="font-size: 13.5px; font-weight: 600">이도윤</span>
|
||||
<span style="font-size: 12.5px; color: #667085">doyun.lee@arasystem.it.kr</span>
|
||||
<span style="margin-left: auto; font-size: 12.5px; color: #667085; white-space: nowrap; font-variant-numeric: tabular-nums">2026. 11. 5. (목) 오전 10:42</span>
|
||||
</div>
|
||||
<span style="font-size: 12.5px; color: #4A5363">받는 사람 김아라 <ara.kim@arasystem.it.kr></span>
|
||||
</div>
|
||||
</div>
|
||||
<div style="white-space: pre-line; font-size: 14px; line-height: 1.75; color: #1F2733; max-width: 680px">김아라님, 안녕하세요.
|
||||
|
||||
말씀하신 조건으로 계약서 초안을 고쳐 첨부했습니다.
|
||||
기간은 1년이고, 단가는 작년과 같습니다.
|
||||
|
||||
이번 주 안에 회신 주시면 다음 주 월요일에 서명하겠습니다.
|
||||
|
||||
이도윤 드림</div>
|
||||
<section aria-labelledby="att-title" style="display: flex; flex-direction: column; gap: 8px; padding-top: 16px; border-top: 1px solid #EEF0F3">
|
||||
<h2 id="att-title" style="margin: 0; font-size: 12.5px; font-weight: 600; color: #4A5363">첨부 파일 1개</h2>
|
||||
<div style="display: flex; align-items: center; gap: 10px; max-width: 320px; padding: 4px 2px 4px 8px; border: 1px solid #E6E8EC; border-radius: 8px">
|
||||
<span aria-hidden="true" style="flex: 0 0 auto; display: inline-flex; align-items: center; justify-content: center; width: 32px; height: 32px; border-radius: 6px; background: #F2F4F7; color: #4A5363">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><path d="M13 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V9z"></path><polyline points="13 2 13 9 20 9"></polyline></svg>
|
||||
</span>
|
||||
<div style="flex: 1 1 auto; min-width: 0; display: flex; flex-direction: column; line-height: 1.35">
|
||||
<span style="overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-weight: 500">유지보수_계약서_수정본.docx</span>
|
||||
<span style="font-size: 12px; color: #667085">52 KB</span>
|
||||
</div>
|
||||
<button type="button" aria-label="유지보수_계약서_수정본.docx 내려받기" style="flex: 0 0 auto; width: 44px; height: 44px; display: inline-flex; align-items: center; justify-content: center; border: 0; border-radius: 8px; background: transparent; color: #344054; cursor: pointer">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"></path><polyline points="7 10 12 15 17 10"></polyline><line x1="12" y1="15" x2="12" y2="3"></line></svg>
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<sc-if value="{{ hasConv }}" hint-placeholder-val="{{ true }}">
|
||||
<section aria-labelledby="conv-title" style="display: flex; flex-direction: column; gap: 8px; padding-top: 16px; border-top: 1px solid #EEF0F3">
|
||||
<h2 id="conv-title" style="margin: 0; font-size: 12.5px; font-weight: 600; color: #4A5363">같은 대화 {{ convCount }}통</h2>
|
||||
<ol style="list-style: none; margin: 0; padding: 0; border: 1px solid #E6E8EC; border-radius: 8px; overflow: hidden">
|
||||
<sc-for list="{{ conv }}" as="c" hint-placeholder-count="4">
|
||||
<li style="border-top: {{ c.borderTop }}">
|
||||
<sc-if value="{{ c.isCurrent }}" hint-placeholder-val="{{ false }}">
|
||||
<div aria-current="true" style="display: flex; align-items: center; gap: 10px; min-height: 48px; box-sizing: border-box; padding: 6px 14px 6px 40px; background: #F2F8F8">
|
||||
<span aria-hidden="true" style="flex: 0 0 auto; display: inline-flex; align-items: center; justify-content: center; width: 28px; height: 28px; border-radius: 50%; background: #E3F2F3; color: #0A5F67; font-size: 12px; font-weight: 600">{{ c.initial }}</span>
|
||||
<span style="flex: 0 0 auto; font-weight: 600">{{ c.who }}</span>
|
||||
<span style="flex: 1 1 auto; min-width: 0; font-size: 12.5px; color: #0A5F67; font-weight: 600">지금 보고 있는 메일</span>
|
||||
<span style="flex: 0 0 auto; font-size: 12px; color: #667085; font-variant-numeric: tabular-nums">{{ c.date }}</span>
|
||||
</div>
|
||||
</sc-if>
|
||||
<sc-if value="{{ c.isOther }}" hint-placeholder-val="{{ true }}">
|
||||
<button type="button" class="row" aria-expanded="{{ c.expandedStr }}" onClick="{{ c.toggle }}" style="width: 100%; display: flex; align-items: center; gap: 10px; min-height: 48px; box-sizing: border-box; padding: 6px 14px; border: 0; background: #FFFFFF; font: inherit; color: inherit; text-align: left; cursor: pointer">
|
||||
<sc-if value="{{ c.expanded }}" hint-placeholder-val="{{ false }}"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="#667085" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" style="flex: 0 0 auto"><polyline points="6 9 12 15 18 9"></polyline></svg></sc-if>
|
||||
<sc-if value="{{ c.collapsed }}" hint-placeholder-val="{{ true }}"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="#667085" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" style="flex: 0 0 auto"><polyline points="9 18 15 12 9 6"></polyline></svg></sc-if>
|
||||
<span aria-hidden="true" style="flex: 0 0 auto; display: inline-flex; align-items: center; justify-content: center; width: 28px; height: 28px; border-radius: 50%; background: {{ c.avatarBg }}; color: {{ c.avatarFg }}; font-size: 12px; font-weight: 600">{{ c.initial }}</span>
|
||||
<span style="flex: 0 0 auto; font-weight: 600">{{ c.who }}</span>
|
||||
<sc-if value="{{ c.isSent }}" hint-placeholder-val="{{ false }}"><span style="flex: 0 0 auto; font-size: 12px; color: #667085">보낸편지함</span></sc-if>
|
||||
<span style="flex: 1 1 auto; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 12.5px; color: #667085">{{ c.preview }}</span>
|
||||
<span style="flex: 0 0 auto; font-size: 12px; color: #667085; font-variant-numeric: tabular-nums">{{ c.date }}</span>
|
||||
</button>
|
||||
<sc-if value="{{ c.expanded }}" hint-placeholder-val="{{ false }}">
|
||||
<div style="display: flex; flex-direction: column; gap: 10px; padding: 2px 16px 14px 78px">
|
||||
<span style="font-size: 12.5px; color: #4A5363">받는 사람 {{ c.to }}</span>
|
||||
<span style="font-size: 12.5px; color: #667085">제목 {{ c.subject }}</span>
|
||||
<div style="white-space: pre-line; font-size: 13.5px; line-height: 1.7; color: #1F2733">{{ c.body }}</div>
|
||||
<div style="display: flex; flex-wrap: wrap; gap: 4px; margin-left: -10px">
|
||||
<a href="Compose.dc.html" style="display: inline-flex; align-items: center; min-height: 36px; padding: 0 10px; border-radius: 8px; color: #0E7C86; font-weight: 600; text-decoration: none">답장</a>
|
||||
<a href="#" style="display: inline-flex; align-items: center; min-height: 36px; padding: 0 10px; border-radius: 8px; color: #0E7C86; font-weight: 600; text-decoration: none">이 메일만 열기</a>
|
||||
</div>
|
||||
</div>
|
||||
</sc-if>
|
||||
</sc-if>
|
||||
</li>
|
||||
</sc-for>
|
||||
</ol>
|
||||
</section>
|
||||
</sc-if>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</x-dc>
|
||||
<script type="text/x-dc" data-dc-script data-props='{"variant":{"editor":"enum","options":["접힘","하나 펼침","한 통뿐"],"default":"접힘"},"$preview":{"width":1440,"height":900}}'>
|
||||
class Component extends DCLogic {
|
||||
state = { open: null };
|
||||
|
||||
convItems() {
|
||||
return [
|
||||
{
|
||||
id: 'c1', who: '나', initial: '김', sent: true, date: '10월 27일 (화) 오후 2:10',
|
||||
subject: '유지보수 계약 갱신 건', to: '이도윤 <doyun.lee@arasystem.it.kr>',
|
||||
preview: '다음 달에 끝나는 유지보수 계약을 1년 더 이어 가려고 합니다.',
|
||||
body: '이도윤님,\n\n다음 달에 끝나는 유지보수 계약을 1년 더 이어 가려고 합니다.\n조건을 정리해서 보내 주시면 검토하겠습니다.\n\n김아라',
|
||||
},
|
||||
{
|
||||
id: 'c2', who: '이도윤', initial: '이', sent: false, date: '10월 29일 (목) 오전 9:30',
|
||||
subject: '회신: 유지보수 계약 갱신 건', to: '김아라 <ara.kim@arasystem.it.kr>',
|
||||
preview: '네, 확인했습니다. 단가와 기간을 정리해서 다음 주 초에 보내 드리겠습니다.',
|
||||
body: '네, 확인했습니다.\n단가와 기간을 정리해서 다음 주 초에 보내 드리겠습니다.\n\n이도윤',
|
||||
},
|
||||
{
|
||||
id: 'c3', who: '나', initial: '김', sent: true, date: '10월 29일 (목) 오후 1:05',
|
||||
subject: '회신: 유지보수 계약 갱신 건', to: '이도윤 <doyun.lee@arasystem.it.kr>',
|
||||
preview: '감사합니다. 초안은 다음 주 초까지 받으면 됩니다.',
|
||||
body: '감사합니다.\n초안은 다음 주 초까지 받으면 됩니다.\n\n김아라',
|
||||
},
|
||||
{ id: 'c4', who: '이도윤', initial: '이', current: true, date: '오늘 오전 10:42' },
|
||||
];
|
||||
}
|
||||
|
||||
renderVals() {
|
||||
const st = this.state;
|
||||
const variant = this.props.variant ?? '접힘';
|
||||
const open = st.open ?? (variant === '하나 펼침' ? { c2: true } : {});
|
||||
const conv = this.convItems().map((c, i) => {
|
||||
const expanded = !!open[c.id];
|
||||
return {
|
||||
...c,
|
||||
isCurrent: !!c.current,
|
||||
isOther: !c.current,
|
||||
isSent: !!c.sent,
|
||||
expanded: expanded,
|
||||
collapsed: !expanded,
|
||||
expandedStr: expanded ? 'true' : 'false',
|
||||
borderTop: i === 0 ? '0' : '1px solid #EEF0F3',
|
||||
avatarBg: c.sent ? '#F2F4F7' : '#E3F2F3',
|
||||
avatarFg: c.sent ? '#4A5363' : '#0A5F67',
|
||||
toggle: () => this.setState({ open: { ...open, [c.id]: !expanded } }),
|
||||
};
|
||||
});
|
||||
|
||||
const LIST = [
|
||||
{ who: '이도윤', time: '오전 10:42', subject: 'Re: 유지보수 계약 갱신 건', unread: true, att: true, selected: true },
|
||||
{ who: '인사팀', time: '오전 9:05', subject: '[공지] 계약직 채용 일정 안내', unread: true },
|
||||
{ who: '박준호', time: '어제', subject: '11월 인프라 점검 일정 조율' },
|
||||
{ who: '최서연', time: '10월 30일', subject: '11월 비용 정산 안내' },
|
||||
{ who: '이도윤', time: '10월 29일', subject: '회신: 유지보수 계약 갱신 건' },
|
||||
{ who: '정하늘', time: '10월 28일', subject: '신규 입사자 교육 회의록' },
|
||||
];
|
||||
const items = LIST.map((x) => ({
|
||||
who: x.who,
|
||||
time: x.time,
|
||||
subject: x.subject,
|
||||
hasAtt: !!x.att,
|
||||
bg: x.selected ? '#E3F2F3' : '#FFFFFF',
|
||||
fw: x.unread ? '650' : '450',
|
||||
subFw: x.unread ? '600' : '400',
|
||||
timeFw: x.unread ? '600' : '400',
|
||||
timeColor: x.unread ? '#171C26' : '#667085',
|
||||
}));
|
||||
|
||||
return {
|
||||
items: items,
|
||||
conv: conv,
|
||||
convCount: conv.length,
|
||||
hasConv: variant !== '한 통뿐',
|
||||
};
|
||||
}
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -46,9 +46,12 @@
|
||||
"LoginPasswordChange.dc.html": { "x": 0, "y": 25610, "w": 1440, "h": 2900, "title": "로그인 · 새 비밀번호 정하기" },
|
||||
"LoginTwoFactorSetup.dc.html": { "x": 1520, "y": 25610, "w": 1440, "h": 4290, "title": "로그인 · 2단계 인증 켜기(필수)" },
|
||||
"SettingsMailClients.dc.html": { "x": 3040, "y": 25610, "w": 1440, "h": 3600, "title": "설정 · 메일 프로그램 연결" },
|
||||
"MailNoticesCutover.dc.html": { "x": 4560, "y": 25610, "w": 1440, "h": 3780, "title": "알림 · 안내 모음(13단계)" }
|
||||
"MailNoticesCutover.dc.html": { "x": 4560, "y": 25610, "w": 1440, "h": 3780, "title": "알림 · 안내 모음(13단계)" },
|
||||
"Search14.dc.html": { "x": 0, "y": 30500, "w": 1440, "h": 900, "title": "메일 검색 · 조건 검색 · 강조", "expand": "fill", "is_interactive": true },
|
||||
"Thread14.dc.html": { "x": 1520, "y": 30500, "w": 1440, "h": 900, "title": "읽기 화면 · 같은 대화", "expand": "fill", "is_interactive": true },
|
||||
"ListThreads14.dc.html": { "x": 3040, "y": 30500, "w": 1440, "h": 900, "title": "목록 · 대화로 묶어 보기", "expand": "fill", "is_interactive": true }
|
||||
},
|
||||
"order": ["Main.dc.html", "Login.dc.html", "Compose.dc.html", "States.dc.html", "MainList.dc.html", "MainListRead.dc.html", "ComposeEditor.dc.html", "Settings.dc.html", "ComposeAssist.dc.html", "Search.dc.html", "BulkSelect.dc.html", "ListStates.dc.html", "SettingsAccount.dc.html", "SettingsVacation.dc.html", "MailboxManage.dc.html", "MailActions4.dc.html", "SettingsNotify.dc.html", "MailRealtime.dc.html", "SettingsFilter.dc.html", "MailFilterStates.dc.html", "MailFilterApply.dc.html", "MailFilterApplyStates.dc.html", "Contacts.dc.html", "ContactsEdit.dc.html", "ContactsMail.dc.html", "MailReadTools.dc.html", "MailPrint.dc.html", "MailSendLater.dc.html", "SettingsSendRead.dc.html", "ComposeLargeFiles.dc.html", "LargeFileBlock.dc.html", "MailReadLargeFiles.dc.html", "SharePage.dc.html", "SettingsLargeFiles.dc.html", "LoginTwoFactor.dc.html", "SettingsTwoFactor.dc.html", "SettingsAppPasswords.dc.html", "SettingsLoginHistory.dc.html", "LoginPasswordChange.dc.html", "LoginTwoFactorSetup.dc.html", "SettingsMailClients.dc.html", "MailNoticesCutover.dc.html"],
|
||||
"order": ["Main.dc.html", "Login.dc.html", "Compose.dc.html", "States.dc.html", "MainList.dc.html", "MainListRead.dc.html", "ComposeEditor.dc.html", "Settings.dc.html", "ComposeAssist.dc.html", "Search.dc.html", "BulkSelect.dc.html", "ListStates.dc.html", "SettingsAccount.dc.html", "SettingsVacation.dc.html", "MailboxManage.dc.html", "MailActions4.dc.html", "SettingsNotify.dc.html", "MailRealtime.dc.html", "SettingsFilter.dc.html", "MailFilterStates.dc.html", "MailFilterApply.dc.html", "MailFilterApplyStates.dc.html", "Contacts.dc.html", "ContactsEdit.dc.html", "ContactsMail.dc.html", "MailReadTools.dc.html", "MailPrint.dc.html", "MailSendLater.dc.html", "SettingsSendRead.dc.html", "ComposeLargeFiles.dc.html", "LargeFileBlock.dc.html", "MailReadLargeFiles.dc.html", "SharePage.dc.html", "SettingsLargeFiles.dc.html", "LoginTwoFactor.dc.html", "SettingsTwoFactor.dc.html", "SettingsAppPasswords.dc.html", "SettingsLoginHistory.dc.html", "LoginPasswordChange.dc.html", "LoginTwoFactorSetup.dc.html", "SettingsMailClients.dc.html", "MailNoticesCutover.dc.html", "Search14.dc.html", "Thread14.dc.html", "ListThreads14.dc.html"],
|
||||
"notes": {
|
||||
"flow": { "x": 0, "y": -300, "text": "arasystem 웹메일 — 1단계 화면 (로그인 → 메일함 → 쓰기)", "kind": "title1", "maxW": 4480 },
|
||||
"phase2": { "x": 0, "y": 2180, "text": "2단계 화면 — 임시 저장 · 받는 사람 자동 완성 · 서명 (2026-10-27 확정)", "kind": "title1", "maxW": 2960 },
|
||||
@@ -61,7 +64,8 @@
|
||||
"phase9": { "x": 0, "y": 16520, "text": "9단계 화면 — 보내기 취소 · 예약 발송 · 외부 이미지 보기 · 인쇄 · 보낸 사람 차단 — 2026-10-29 확정", "kind": "title1", "maxW": 6000 },
|
||||
"phase11": { "x": 0, "y": 18620, "text": "11단계 화면 — 대용량 첨부 (쓰기 · 본문 덩이 · 읽기 · 내려받기 화면 · 설정) — 2026-10-30 확정", "kind": "title1", "maxW": 7520 },
|
||||
"phase12": { "x": 0, "y": 21580, "text": "12단계 화면 — 계정 보안(2단계 인증 · 앱 비밀번호 · 로그인 기록) — 2026-10-31 확정", "kind": "title1", "maxW": 6000 },
|
||||
"phase13": { "x": 0, "y": 25310, "text": "13단계 화면 — 전환 준비(새 비밀번호 · 2단계 필수 · 메일 프로그램 연결 · 알림) — 2026-10-31 확정", "kind": "title1", "maxW": 6000 }
|
||||
"phase13": { "x": 0, "y": 25310, "text": "13단계 화면 — 전환 준비(새 비밀번호 · 2단계 필수 · 메일 프로그램 연결 · 알림) — 2026-10-31 확정", "kind": "title1", "maxW": 6000 },
|
||||
"phase14": { "x": 0, "y": 30200, "text": "14단계 화면 — 검색 · 조건 검색 · 강조 · 같은 대화 · 대화로 묶어 보기 — 2026-11-05 확정", "kind": "title1", "maxW": 4480 }
|
||||
},
|
||||
"designSystems": []
|
||||
}
|
||||
|
||||
+17
-15
@@ -397,7 +397,7 @@ interface RejectedRecipient {
|
||||
(조건을 붙이면 엔진이 그 한도를 연결이 아닌 MAIL FROM 단계로 옮겨 바깥 접속을 더는 세지 않는다 — Stalwart 0.16.27 소스로 확인).
|
||||
- **클라이언트**
|
||||
- 일부 거절 — 보낸 것으로 처리한다(쓰기 화면을 닫고 떠난다). 보냈다는 알림 대신 실패 알림(「보낸편지함 보기」 링크 — 보냈다는 알림과 같은 글, 단추 있는 알림과 같은 10초)으로
|
||||
"보냈지만 N명은 받지 못했습니다 — nobody@arasystem.it.kr (없는 주소)" — 두 명까지 주소와 까닭을 적고 더 있으면 " 외 N명".
|
||||
"보냈지만 N명은 받지 못했습니다. nobody@arasystem.it.kr (없는 주소)" — 두 명까지 주소와 까닭을 적고 더 있으면 " 외 N명".
|
||||
까닭 문구는 `not-found` "없는 주소" · `refused` "받는 쪽이 거절" · `temporary` "지금은 받을 수 없음".
|
||||
- `MAIL_010` — 받는 사람 칸의 안내 띠(`MAIL_002` 와 같은 자리 · 같은 초점)로만 알린다(토스트 없음). 쓰던 메일 · 키는 그대로.
|
||||
- `MAIL_011` — 인터셉터 알림(사전 문구). 쓰던 메일 · 키는 그대로 — 잠시 뒤 다시 누르면 같은 키로 간다.
|
||||
@@ -764,7 +764,7 @@ interface MailVacationState extends MailVacation { // GET · PUT 의 응답 (7
|
||||
필터와 한 스크립트라서 부재중을 저장해도 스크립트를 다시 켠다(필터가 꺼져 있었으면 함께 다시 동작한다). 필터 규칙은 건드리지 않는다.
|
||||
- 6단계부터 바뀐 동작: 엔진이 스팸으로 분류한 메일에는 답하지 않는다(필터와 같은 스팸 막기 — "메일 필터"의 "만드는 Sieve").
|
||||
- 우리 스크립트가 엔진에서 꺼져 있으면(`isActive: false`) 부재중도 동작하지 않는다 — `isEnabled` 는 저장한 값이라 그때도 `true` 다.
|
||||
7단계부터 응답의 `isActive` 로 알린다 — 화면은 켜 두었을 때 머리 아래 줄 "켜 둔 자동 응답이 지금은 동작하지 않습니다. 메일 서버의 규칙이 다른 곳에서 바뀌었습니다 —
|
||||
7단계부터 응답의 `isActive` 로 알린다 — 화면은 켜 두었을 때 머리 아래 줄 "다른 곳에서 메일 설정이 바뀌어 켜 둔 자동 응답이 지금은 나가지 않습니다.
|
||||
아래 [저장]을 누르면 다시 켜집니다."(단추 없음 — 저장 단추는 늘 누를 수 있다. 6단계의 알려진 한계를 고쳤다).
|
||||
- 저장해 둔 원본이 깨진 계정은 꺼짐(`isEnabled: false`, 빈 제목 · 내용)으로 온다. 원본이 이 서버보다 새 판이면 저장은 503 `MAIL_001` 이다("메일 필터"의 "저장 자리").
|
||||
- `fromDate` · `toDate` 는 `Z` 로 끝나는 UTC 시각이고, 두 키는 늘 보낸다(값은 null 가능). 엔진에는 초 단위로 저장한다(소수 초는 버린다).
|
||||
@@ -941,7 +941,7 @@ interface MailFilterActions {
|
||||
- `MAIL_012` — 목록 위 줄 "다른 곳에서 필터가 바뀌어 저장하지 않았습니다. 다시 불러온 뒤 고쳐 주세요." + [다시 불러오기]. 고치던 창은 닫지 않는다 —
|
||||
창 안에도 같은 안내와 [다시 불러오기]를 둔다(모달이라 뒤의 줄을 누를 수 없다). 창에서 다시 불러오면 쓴 내용은 그대로이고, 다음 [저장]은 다시 받은 목록에
|
||||
그 필터를 넣는다(같은 id 자리 — 그사이 다른 곳에서 지워졌으면 맨 아래).
|
||||
- `isActive: false` — 목록 위 줄 "지금은 필터와 부재중 자동 응답이 동작하지 않습니다…" + [다시 켜기](지금 목록 그대로 저장).
|
||||
- `isActive: false` — 목록 위 줄 "다른 곳에서 메일 설정이 바뀌어 지금은 필터와 부재중 자동 응답이 되지 않습니다." + [다시 켜기](지금 목록 그대로 저장).
|
||||
- 읽기 화면 ⋯ 「이런 메일로 필터 만들기」 → `/settings/mail/filters?from=<보낸 사람 주소>` — 새 필터 창이 "보낸 사람 · 포함 · 그 주소"로 채워져 열린다(초점은 할 일의 첫 칸).
|
||||
50개를 다 만들었는지 알려고 ⋯ 메뉴를 열 때 `GET` 을 한다(받아 둔 목록이 없거나 50개일 때만 — 알림 없이).
|
||||
- 검사는 서버와 같은 규칙(이름 50 · 값 200 · 조건 5 · 규칙 50 · `${` · 제어 문자 · 할 일 하나 이상 · 전달은 사내 주소이고 자기 주소 아님) — 칸 아래에만 알린다.
|
||||
@@ -1017,11 +1017,12 @@ interface MailFilterRun {
|
||||
(응답 목록의 같은 id — 앞뒤 공백을 지운 값)으로 이 요청을 `cursor` 가 null 이 될 때까지 보낸다.
|
||||
- 칸을 끈다(칸 아래 설명이 까닭을 말한다): 이 탭에서 다른 적용이 진행 중, 그리고 **이미 받은 메일에 할 일이 없는 필터** — 읽음 · 중요 표시가 없고
|
||||
옮기기도 없거나 받은편지함으로 · 지워진 메일함으로 옮기는 것뿐(까닭 문구는 지워진 메일함 → 받은편지함 → 전달만 차례로 고른다). 서버는 그런 규칙도 받는다(맞은 수만 센다).
|
||||
- 진행 알림 "‘{이름}’ 필터를 받은편지함에 적용하는 중 · {확인} / {전체}통 확인"(확인 = scanned 합, 전체 = 확인 + remaining) → 끝나면 그 자리에서
|
||||
"‘{이름}’ 필터를 받은편지함의 메일 N통에 적용했습니다."(N = matched 합) + 옮기는 필터이고 1통 이상이면 [{메일함} 보기], 0통이면 "받은편지함에 ‘{이름}’ 필터에 맞는 메일이 없습니다."
|
||||
- 멈춤: "‘{이름}’ 필터 — {전체}통 가운데 {확인}통까지 확인하고 멈췄습니다(N통에 적용). 다시 시도해 주세요." + [다시 시도](마지막 cursor 로 이어서).
|
||||
처음 요청부터 실패하면 "필터는 저장했지만 받은편지함에 적용하지 못했습니다. 다시 시도해 주세요." + [다시 시도](처음부터).
|
||||
429 는 "다른 창에서 필터를 받은편지함에 적용하는 중입니다. 끝난 뒤 다시 시도해 주세요." — 도중의 429 면 [다시 시도](이어서)도 붙인다.
|
||||
- 진행 알림 "‘{이름}’ 필터로 받은편지함을 정리하는 중 · {확인} / {전체}통 확인"(확인 = scanned 합, 전체 = 확인 + remaining) → 끝나면 그 자리에서
|
||||
"‘{이름}’ 필터로 받은편지함의 메일 N통을 정리했습니다."(N = matched 합) + 옮기는 필터이고 1통 이상이면 [{메일함} 보기], 0통이면 "받은편지함에 ‘{이름}’ 필터에 맞는 메일이 없습니다."
|
||||
- 멈춤: "‘{이름}’ 필터로 {전체}통 가운데 {확인}통까지 확인하고 멈췄습니다. 그동안 N통을 정리했습니다. 다시 시도해 주세요." + [다시 시도](마지막 cursor 로 이어서).
|
||||
처음 요청부터 실패하면 "필터는 저장했지만 받은편지함은 정리하지 못했습니다. 다시 시도해 주세요." + [다시 시도](처음부터).
|
||||
429 는 "다른 창에서 필터로 받은편지함을 정리하는 중입니다. 끝난 뒤 다시 시도해 주세요." — 도중의 429 면 [다시 시도](이어서)도 붙인다.
|
||||
(문구는 2026-11-05 「화면 문구 — 사용자에게 말하듯」으로 고쳤다 — 화면 상수 `MAIL_FILTER_RUN_TEXT` 가 원본.)
|
||||
확인 0 인데 `cursor` 가 있는 응답이 세 번 이어지면 멈춘다(같은 요청을 끝없이 보내지 않게 — 멈춤 문구와 같다).
|
||||
- 그동안 다른 화면으로 가도 이어 한다. 탭을 닫으면 멈춘다(이어 하지 않는다). 세션이 바뀌면(로그아웃 · 만료 · 다른 계정) 다음 요청을 보내지 않고 알림을 닫는다.
|
||||
- **고치기 창의 나가기 확인**(6단계 한계를 고친다): 창을 연 뒤 바꾼 것이 있으면 [취소] · Esc · 다른 화면으로 가기에 확인 — "저장하지 않은 필터가 있습니다" /
|
||||
@@ -1636,7 +1637,7 @@ interface FileUploadCreate {
|
||||
- 검사 상태 다시 묻기: 쓰기 화면 · 읽기 화면은 15 · 30 · 60초 간격 최대 30번, 공개 화면은 20번(공개 경로의 IP 한도 안).
|
||||
- 진행은 받은 바이트로 보인다("420MB / 1.2GB · 약 3분"), 멈춤 · 빼기. 빼면 `DELETE`(알림 없이 — 404 는 지운 것으로 본다). 쓰기 화면을 버리면(휴지통) 지우지 않는다 — 되돌리면 다시 쓴다.
|
||||
- **새로고침 · 창 닫힘 뒤 이어 올리기(처음 판에 넣는다)**: 올리는 중인 파일도 임시 저장본 머리글에 실린다(아래 "임시 저장") — 다시 열면 `uploading` 으로 오고
|
||||
"올리다 멈춘 파일 — 같은 파일을 다시 고르면 이어서 올립니다"(브라우저는 고른 파일을 스스로 다시 읽지 못한다).
|
||||
"올리다 멈춤 · {N} 올림 · 같은 파일을 다시 고르면 이어서 올립니다"(브라우저는 고른 파일을 스스로 다시 읽지 못한다).
|
||||
다시 고른 파일은 **이름 · 크기 · `lastModified` 가 같고 그 파일의 맨 앞 조각 SHA-256 이 `firstChunkSha256` 과 같을 때** 같은 파일로 보고 빠진 조각부터 잇는다.
|
||||
다르면 새로 만든다(앞 올리기는 `DELETE`). 맨 앞 조각을 받기 전(`firstChunkSha256: null`)이면 이을 것이 없다 — 새로 만든다.
|
||||
올리는 중은 마지막 조각 뒤 24시간 남는다 — 그 뒤에 열면 `MissingLargeFile`(`reason: 'expired'`)이라 다시 올린다.
|
||||
@@ -1691,8 +1692,9 @@ interface FileUploadCreate {
|
||||
- 보낼 때만 만든다(임시 저장본에는 넣지 않는다). 한 통에 하나 — 이번 발송의 새 링크(내 파일) 묶음이다.
|
||||
- **자리**: 서식 본문은 첫 경계 표시 자리, 텍스트는 `bodyQuoteStart` — 없으면 맨 끝. 그래서 **내 글 · 서명 아래, 인용 위**다.
|
||||
- **내용** — HTML 과 텍스트에 같은 내용(글 · 모양은 디자인 README "11단계", 구현하며 맞춘다):
|
||||
개수 · 합계 · 끝나는 날("2026년 11월 29일(일)까지 내려받을 수 있습니다 (한국 시간)"), 파일마다 이름 · 크기, 내려받기 주소(**주소 글자가 보이는 링크** — 단축 주소 없음),
|
||||
"내려받을 때 로그인이나 비밀번호를 묻지 않습니다", 영어 한 줄("Large files — download by Nov 29, 2026 (KST)"). 받는 사람 · 제목은 넣지 않는다.
|
||||
개수 · 합계 · 끝나는 날("한국 시간으로 2026년 11월 29일(일)까지 내려받을 수 있습니다."), 파일마다 이름 · 크기, 내려받기 주소(**주소 글자가 보이는 링크** — 단축 주소 없음),
|
||||
"내려받을 때 로그인이나 비밀번호를 묻지 않습니다", 영어 한 줄("Large files: download by Nov 29, 2026 (KST). No login or password needed."). 받는 사람 · 제목은 넣지 않는다.
|
||||
(덩이의 글은 2026-11-05 「화면 문구 — 사용자에게 말하듯」으로 고쳤다 — 그 전에 붙잡아 둔 메일의 옛 글 덩이도 서버가 알아보고 뗀다: `large-file-block.ts` 의 `LEGACY_BLOCK_WORDINGS`.)
|
||||
크기는 화면과 같은 표기(1024 단위 — `1.2 GB` · `612 MB`, 짝: frontend `shared/utils/format.ts` 의 `formatFileSize`), 날짜는 한국 날짜.
|
||||
- **HTML 모양**: 보내는 정화의 허용 목록 안의 태그 · style 값으로만 만든다(2절 "서식 본문" — div · p · span · a · br · table, 색 hex · 여백 px · 글자 8~99px …) —
|
||||
다시 읽을 때 · 답장 · 전달의 인용 · 보내기 취소로 되돌릴 때도 모양이 남게. 그림 · 뱃지 · 알약 없이 테두리 상자와 글자.
|
||||
@@ -1980,8 +1982,8 @@ interface FileUploadCreate {
|
||||
|
||||
| 코드 | HTTP | 뜻 | 쓰는 곳 | 문구 (제안) |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| `FILE_003` | 409 | 올리기 · 파일의 지금 상태와 맞지 않는 요청 — 끝낸 올리기에 조각, 같은 조각을 동시에, 받은 조각과 내용이 다른 조각, 빠진 조각이 있는데 끝내기, 끝내기가 진행 중, 살아 있는 링크에 실린 파일 지우기. 아무것도 바꾸지 않았다 | 조각 · 끝내기 · 지우기 | "파일 올리기 상태가 맞지 않습니다. 다시 시도해 주세요." |
|
||||
| `FILE_004` | 400 | 조각의 `Content-Digest` 가 받은 바이트와 다르다 — 그 조각을 받지 않았다(같은 조각을 다시) | 조각 | "파일 조각이 보내는 중에 손상되었습니다. 다시 시도해 주세요." |
|
||||
| `FILE_003` | 409 | 올리기 · 파일의 지금 상태와 맞지 않는 요청 — 끝낸 올리기에 조각, 같은 조각을 동시에, 받은 조각과 내용이 다른 조각, 빠진 조각이 있는데 끝내기, 끝내기가 진행 중, 살아 있는 링크에 실린 파일 지우기. 아무것도 바꾸지 않았다 | 조각 · 끝내기 · 지우기 | "파일 상태가 그사이 바뀌어 처리하지 못했습니다. 다시 시도해 주세요." |
|
||||
| `FILE_004` | 400 | 조각의 `Content-Digest` 가 받은 바이트와 다르다 — 그 조각을 받지 않았다(같은 조각을 다시) | 조각 | "파일을 올리는 중에 일부가 제대로 전달되지 않았습니다. 다시 시도해 주세요." |
|
||||
| `FILE_005` | 409 | 직원 보관 한도(보관량 · 보내지 않은 파일 수)를 넘는다 — 만들지 않았다 | 만들기 | "대용량 첨부 보관 한도를 넘어 더 올릴 수 없습니다. 보낸 파일의 링크를 끊거나 보내지 않은 파일을 지우면 공간이 생깁니다." |
|
||||
| `FILE_006` | 507 | 서버 저장 공간이 모자란다 — 서버 총량 · 디스크 여유 · 쓰다 `ENOSPC` | 만들기 · 조각 | "서버의 저장 공간이 모자라 지금은 파일을 올릴 수 없습니다. 잠시 뒤 다시 시도해 주세요." |
|
||||
| `FILE_007` | 410 | 더 내려받을 수 없는 파일 — 기간 지남 · 공유 멈춤 · 횟수 초과 · 위험 · 찾을 수 없음 | 공개 받기 · 우리 웹메일 받기 | "이 파일은 더 내려받을 수 없습니다. 필요하면 보낸 사람에게 문의해 주세요." |
|
||||
@@ -2335,7 +2337,7 @@ interface FileUploadCreate {
|
||||
|
||||
| 코드 | HTTP | 상황 | 문구(제안) |
|
||||
| --- | --- | --- | --- |
|
||||
| `AUTH_006` | 400 | 2단계 인증 코드가 틀림 · 2분 안에 다시 씀(로그인 · 비밀번호 바꾸기 · 켜기 · 끄기) | "코드가 올바르지 않습니다. 휴대폰의 날짜와 시간이 「자동」으로 맞춰져 있는지 확인해 주세요." |
|
||||
| `AUTH_006` | 400 | 2단계 인증 코드가 틀림 · 2분 안에 다시 씀(로그인 · 비밀번호 바꾸기 · 켜기 · 끄기) | "코드가 올바르지 않습니다. 휴대폰의 날짜와 시간이 자동으로 맞춰지도록 설정되어 있는지 확인해 주세요." |
|
||||
| `AUTH_007` | 400 | 2단계 인증을 켠 직원의 요청에 코드가 없음(다른 곳에서 켠 뒤의 화면) | "인증 앱의 코드를 입력해 주세요." |
|
||||
| `AUTH_008` | 409 | 등록이 없음 · 시간이 지남 · 그사이 다른 곳에서 바뀜 | "2단계 인증 설정이 그사이 바뀌었거나 시간이 지났습니다. 처음부터 다시 해 주세요." |
|
||||
| `AUTH_009` | 409 | 앱 비밀번호 상한 | "5개까지 만들 수 있습니다. 쓰지 않는 앱 비밀번호를 지운 뒤 만들어 주세요." — 엔진 상한을 바꾸면 이 문구와 화면 상수도 |
|
||||
@@ -2494,7 +2496,7 @@ interface FileUploadCreate {
|
||||
- 위험 파일 판정 — 11단계에서 미룬 것. 그 파일을 올린 직원에게 보낸다.
|
||||
- 넣기에 실패해도 원래 일은 성공으로 둔다(warn 로그).
|
||||
- 본문은 짧은 HTML(보내는 정화 허용 목록 안) + 같은 글의 텍스트다. 제목 · 문구는 디자인 README 13단계를 그대로 쓴다.
|
||||
- **링크를 넣지 않는다** — 할 일은 메뉴 경로를 글로 적는다(「웹메일 › 설정 › 계정 › 앱 비밀번호」 등). 직원에게 "회사 보안 알림에는 링크가 없다"는 습관을 주어 링크 달린 피싱 메일이 눈에 띄게 한다.
|
||||
- **링크를 넣지 않는다** — 할 일은 메뉴 경로를 글로 적는다("웹메일의 [설정] → [앱 비밀번호]" 등 — 화면 글과 같은 꼴). 직원에게 "회사 보안 알림에는 링크가 없다"는 습관을 주어 링크 달린 피싱 메일이 눈에 띄게 한다.
|
||||
- 위험 파일 알림은 파일마다 한 번 — 위험 판정을 처음 기록할 때만 보낸다.
|
||||
|
||||
### 운영 도구 (서버 안 명령)
|
||||
|
||||
@@ -8,23 +8,23 @@ const Map<String, String> kErrorCodeMessages = {
|
||||
'AUTH_003': '해당 메뉴나 기능에 접근할 수 있는 권한이 없습니다.',
|
||||
'AUTH_004': '이메일 또는 비밀번호가 올바르지 않습니다.',
|
||||
'AUTH_005': '지금 비밀번호가 올바르지 않습니다.',
|
||||
'AUTH_006': '코드가 올바르지 않습니다. 휴대폰의 날짜와 시간이 「자동」으로 맞춰져 있는지 확인해 주세요.',
|
||||
'AUTH_006': '코드가 올바르지 않습니다. 휴대폰의 날짜와 시간이 자동으로 맞춰지도록 설정되어 있는지 확인해 주세요.',
|
||||
'AUTH_007': '인증 앱의 코드를 입력해 주세요.',
|
||||
'AUTH_008': '2단계 인증 설정이 그사이 바뀌었거나 시간이 지났습니다. 처음부터 다시 해 주세요.',
|
||||
'AUTH_009': '5개까지 만들 수 있습니다. 쓰지 않는 앱 비밀번호를 지운 뒤 만들어 주세요.',
|
||||
'AUTH_010': '먼저 비밀번호를 바꾸거나 2단계 인증을 켜 주세요.',
|
||||
'VAL_001': '입력하신 정보를 다시 확인해 주세요. (필수값 누락 또는 형식 오류)',
|
||||
'VAL_001': '입력하신 내용을 다시 확인해 주세요. 빠진 칸이 있거나 형식이나 길이가 맞지 않습니다.',
|
||||
'RES_001': '요청하신 정보나 페이지를 찾을 수 없습니다.',
|
||||
'BIZ_001': '요청하신 작업을 완료하지 못했습니다. 다시 시도해 주세요.',
|
||||
'FILE_001': '허용되지 않는 파일 형식입니다. 파일을 확인해 주세요.',
|
||||
'FILE_002': '파일 크기가 허용 범위를 초과했습니다.',
|
||||
'FILE_003': '파일 올리기 상태가 맞지 않습니다. 다시 시도해 주세요.',
|
||||
'FILE_004': '파일 조각이 보내는 중에 손상되었습니다. 다시 시도해 주세요.',
|
||||
'FILE_003': '파일 상태가 그사이 바뀌어 처리하지 못했습니다. 다시 시도해 주세요.',
|
||||
'FILE_004': '파일을 올리는 중에 일부가 제대로 전달되지 않았습니다. 다시 시도해 주세요.',
|
||||
'FILE_005': '대용량 첨부 보관 한도를 넘어 더 올릴 수 없습니다. 보낸 파일의 링크를 끊거나 보내지 않은 파일을 지우면 공간이 생깁니다.',
|
||||
'FILE_006': '서버의 저장 공간이 모자라 지금은 파일을 올릴 수 없습니다. 잠시 뒤 다시 시도해 주세요.',
|
||||
'FILE_007': '이 파일은 더 내려받을 수 없습니다. 필요하면 보낸 사람에게 문의해 주세요.',
|
||||
'FILE_008': '보안 검사가 끝나지 않은 파일입니다. 잠시 뒤 다시 시도해 주세요.',
|
||||
'MAIL_001': '메일 서버와 연결할 수 없습니다. 잠시 후 다시 시도해 주세요.',
|
||||
'MAIL_001': '메일 서비스에 연결할 수 없습니다. 잠시 후 다시 시도해 주세요.',
|
||||
'MAIL_002': '받는 사람 주소를 다시 확인해 주세요.',
|
||||
'MAIL_003': '메일함 용량이 부족합니다. 불필요한 메일을 정리한 뒤 다시 시도해 주세요.',
|
||||
'MAIL_004': '첨부 파일을 찾을 수 없습니다. 해당 첨부를 빼거나 다시 올린 뒤 보내 주세요.',
|
||||
@@ -36,12 +36,12 @@ const Map<String, String> kErrorCodeMessages = {
|
||||
'MAIL_010': '받는 사람 주소로 메일을 보낼 수 없습니다. 주소를 확인해 주세요.',
|
||||
'MAIL_011': '받는 쪽이 지금은 메일을 받을 수 없습니다. 잠시 뒤 다시 보내 주세요.',
|
||||
'MAIL_012': '다른 곳에서 필터가 바뀌어 저장하지 않았습니다. 다시 불러온 뒤 고쳐 주세요.',
|
||||
'MAIL_013': '필터나 차단 목록이 너무 길어 저장할 수 없습니다. 조건을 줄이거나 쓰지 않는 필터 · 차단 주소를 지워 주세요.',
|
||||
'MAIL_013': '필터나 차단 목록이 너무 길어 저장할 수 없습니다. 조건을 줄이거나, 쓰지 않는 필터나 차단한 주소를 지워 주세요.',
|
||||
'MAIL_014': '이미 보낸 메일이라 취소할 수 없습니다.',
|
||||
'MAIL_015': '보내기를 기다리는 메일은 옮기거나 지울 수 없습니다. 먼저 보내기를 취소해 주세요.',
|
||||
'MAIL_016': '대용량 첨부 파일을 보낼 수 없습니다. 해당 파일을 빼거나 다시 올린 뒤 보내 주세요.',
|
||||
'CONTACT_001': '같은 이름의 그룹이 있습니다. 다른 이름을 써 주세요.',
|
||||
'CONTACT_002': '주소록 한도를 넘어 더 넣을 수 없습니다. (연락처 3,000개 · 그룹 100개 · 그룹 하나에 500명까지)',
|
||||
'CONTACT_002': '주소록 한도를 넘어 더 넣을 수 없습니다. 연락처 3,000개, 그룹 100개, 그룹 하나에 500명까지 넣을 수 있습니다.',
|
||||
};
|
||||
|
||||
/// 매핑 실패 시 사용하는 폴백 코드
|
||||
|
||||
@@ -154,8 +154,8 @@ describe('ContactDialog', () => {
|
||||
'집',
|
||||
'기타',
|
||||
])
|
||||
expect(wrapper.text()).toContain('5개까지 · 받는 사람 칸에는 맨 위 주소가 먼저 나옵니다')
|
||||
expect(wrapper.get('#contact-required').text()).toBe('이름이나 이메일 가운데 하나는 있어야 합니다.')
|
||||
expect(wrapper.text()).toContain('5개까지 넣을 수 있고, 받는 사람 칸에는 맨 위 주소가 먼저 나옵니다.')
|
||||
expect(wrapper.get('#contact-required').text()).toBe('이름이나 이메일 가운데 하나는 넣어 주세요.')
|
||||
expect(wrapper.get('[data-contact-note-count]').text()).toBe('0 / 1,000')
|
||||
expect(wrapper.findAll('[data-contact-group]').map((input) => input.attributes('data-contact-group'))).toEqual([
|
||||
'g1',
|
||||
@@ -224,7 +224,7 @@ describe('ContactDialog', () => {
|
||||
expect(email?.attributes('type')).toBe('text')
|
||||
const addEmail = wrapper.findAll('.entry-rows__add-button')[0]
|
||||
expect(wrapper.get(`#${addEmail?.attributes('aria-describedby')}`).text()).toBe(
|
||||
'5개까지 · 받는 사람 칸에는 맨 위 주소가 먼저 나옵니다',
|
||||
'5개까지 넣을 수 있고, 받는 사람 칸에는 맨 위 주소가 먼저 나옵니다.',
|
||||
)
|
||||
})
|
||||
|
||||
@@ -342,7 +342,7 @@ describe('ContactDialog', () => {
|
||||
expect(search?.config.silent).toBe(true)
|
||||
const warning = wrapper.get('[data-contact-duplicate]')
|
||||
expect(warning.text()).toBe(
|
||||
'이미 주소록에 있는 주소입니다 — 강서윤(거래처). 그래도 저장할 수 있습니다. 그 연락처 보기',
|
||||
'주소록에 있는 강서윤(거래처)의 주소와 같습니다. 그래도 저장할 수 있습니다. 그 연락처 보기',
|
||||
)
|
||||
expect(email?.attributes('aria-describedby')).toBe(warning.attributes('id'))
|
||||
expect(email?.attributes('aria-invalid')).toBe('false')
|
||||
@@ -370,7 +370,7 @@ describe('ContactDialog', () => {
|
||||
)
|
||||
|
||||
expect((wrapper.get('#contact-name').element as HTMLInputElement).value).toBe('강서윤')
|
||||
expect(wrapper.get('[data-contact-duplicate]').text()).toContain('이미 주소록에 있는 주소입니다 — 강서윤')
|
||||
expect(wrapper.get('[data-contact-duplicate]').text()).toContain('주소록에 있는 강서윤')
|
||||
await wrapper.get('#contact-cancel').trigger('click')
|
||||
await flushPromises()
|
||||
expect(isOpen(wrapper, '#contact-leave-dialog')).toBe(false)
|
||||
|
||||
@@ -18,7 +18,7 @@ interface Props {
|
||||
tone?: 'neutral' | 'danger'
|
||||
/** 서버 추적 ID — 앞 8자리를 "문의 코드"로 보인다 */
|
||||
traceId?: string | null
|
||||
/** 영역을 세로로 가득 채워 가운데 둔다 (본문 영역의 "메일을 선택하세요") */
|
||||
/** 영역을 세로로 가득 채워 가운데 둔다 (본문 영역의 "메일을 선택해 주세요") */
|
||||
fill?: boolean
|
||||
}
|
||||
|
||||
|
||||
@@ -57,7 +57,7 @@ describe('ComposeScheduleDialog', () => {
|
||||
expect(day.attributes()).toMatchObject({ min: '2026-10-29', max: '2026-11-05' })
|
||||
expect((wrapper.get('[data-schedule-time]').element as HTMLSelectElement).value).toBe(String(9 * 60))
|
||||
expect(wrapper.findAll('[data-schedule-time] option')).toHaveLength(48)
|
||||
expect(wrapper.get('#compose-schedule-note').text()).toMatch(/^이 PC 의 시간(\(.+\))?으로 보냅니다 · 7일 안에서 고를 수 있습니다\.$/)
|
||||
expect(wrapper.get('#compose-schedule-note').text()).toMatch(/^이 PC의 시간(\(.+\))?으로 보내고, 7일 안에서 고를 수 있습니다\.$/)
|
||||
expect(document.activeElement).toBe(wrapper.get('[data-schedule-confirm]').element)
|
||||
})
|
||||
|
||||
@@ -81,7 +81,7 @@ describe('ComposeScheduleDialog', () => {
|
||||
expect(wrapper.emitted('confirm')).toEqual([[local(10, 31, 14, 30)]])
|
||||
})
|
||||
|
||||
it('지금부터 5분 뒤 이전이면 "지금부터 5분 뒤 이후의 시각을 고르세요." — 칸이 그 글을 가리키고 넘기지 않는다', async () => {
|
||||
it('지금부터 5분 뒤 이전이면 "5분 뒤보다 이른 시각은 고를 수 없습니다. 더 늦은 시각을 골라 주세요." — 칸이 그 글을 가리키고 넘기지 않는다', async () => {
|
||||
const { wrapper } = mountAt(local(10, 29, 15))
|
||||
|
||||
await wrapper.get('[data-schedule-day]').setValue('2026-10-29')
|
||||
@@ -89,7 +89,7 @@ describe('ComposeScheduleDialog', () => {
|
||||
await wrapper.get('[data-schedule-confirm]').trigger('click')
|
||||
|
||||
const error = wrapper.get('#compose-schedule-error')
|
||||
expect(error.text()).toBe('지금부터 5분 뒤 이후의 시각을 고르세요.')
|
||||
expect(error.text()).toBe('5분 뒤보다 이른 시각은 고를 수 없습니다. 더 늦은 시각을 골라 주세요.')
|
||||
expect(error.attributes('role')).toBe('alert')
|
||||
expect(wrapper.get('[data-schedule-day]').attributes()).toMatchObject({
|
||||
'aria-invalid': 'true',
|
||||
@@ -120,7 +120,7 @@ describe('ComposeScheduleDialog', () => {
|
||||
clock.advance(6 * 60_000)
|
||||
await wrapper.get('[data-schedule-confirm]').trigger('click')
|
||||
|
||||
expect(wrapper.get('#compose-schedule-error').text()).toBe('지금부터 5분 뒤 이후의 시각을 고르세요.')
|
||||
expect(wrapper.get('#compose-schedule-error').text()).toBe('5분 뒤보다 이른 시각은 고를 수 없습니다. 더 늦은 시각을 골라 주세요.')
|
||||
expect(wrapper.emitted('confirm')).toBeUndefined()
|
||||
})
|
||||
|
||||
|
||||
@@ -138,7 +138,7 @@ describe('MailNotifyOffer (바탕화면 알림 권하기 줄)', () => {
|
||||
expect(useToastStore().toasts).toMatchObject([
|
||||
{
|
||||
kind: 'error',
|
||||
message: '브라우저가 알림을 막고 있습니다. 푸는 방법은 설정 › 알림에 있습니다.',
|
||||
message: '브라우저가 알림을 막고 있습니다. 푸는 방법은 [설정] → [알림]에서 볼 수 있습니다.',
|
||||
action: { label: '설정 열기', to: { name: 'settings-mail-notify' } },
|
||||
},
|
||||
])
|
||||
|
||||
@@ -401,7 +401,7 @@ describe('여러 통 처리 도구 줄', () => {
|
||||
expect(toastTexts()).toContain('메일 2통을 되돌렸습니다.')
|
||||
})
|
||||
|
||||
it('스팸메일함에서는 「스팸 아님」(강조) — 받은편지함으로 · isJunk: false, "(스팸 아님)" 알림 · 되돌리면 스팸메일함으로 · isJunk: true', async () => {
|
||||
it('스팸메일함에서는 「스팸 아님」(강조) — 받은편지함으로 · isJunk: false, "스팸이 아닌 메일로 표시해 …" 알림 · 되돌리면 스팸메일함으로 · isJunk: true', async () => {
|
||||
const junked = [summary('j1', { mailboxIds: ['junk'] }), summary('j2', { mailboxIds: ['junk'] })]
|
||||
const { wrapper, store } = await mountView({ boxId: 'junk', messages: junked })
|
||||
store.toggleCheck('j1')
|
||||
@@ -415,7 +415,7 @@ describe('여러 통 처리 도구 줄', () => {
|
||||
|
||||
expect(bulkPatches()).toEqual([{ ids: ['j1'], mailboxId: 'inbox', isJunk: false }])
|
||||
const [toast] = useToastStore().toasts
|
||||
expect(toast?.message).toBe('메일 1통을 받은편지함으로 옮겼습니다 (스팸 아님).')
|
||||
expect(toast?.message).toBe('메일 1통을 스팸이 아닌 메일로 표시해 받은편지함으로 옮겼습니다.')
|
||||
if (toast?.action && 'run' in toast.action) toast.action.run()
|
||||
await flushPromises()
|
||||
expect(bulkPatches()[1]).toEqual({ ids: ['j1'], mailboxId: 'junk', isJunk: true })
|
||||
|
||||
@@ -111,7 +111,7 @@ describe('목록 머리의 휴지통 · 스팸메일함 비우기', () => {
|
||||
])
|
||||
expect(store.messages).toEqual([])
|
||||
expect(useToastStore().toasts.map((toast) => [toast.message, toast.action])).toEqual([
|
||||
['휴지통을 비웠습니다 — 메일 2통.', null],
|
||||
['메일 2통을 지워 휴지통을 비웠습니다.', null],
|
||||
])
|
||||
// 비었으니 단추도 사라진다
|
||||
expect(wrapper.find('[data-mailbox-empty]').exists()).toBe(false)
|
||||
|
||||
@@ -216,7 +216,7 @@ describe('MailboxPanel — 메일함 관리', () => {
|
||||
const input = wrapper.get('#mailbox-name-input')
|
||||
expect(input.attributes('maxlength')).toBe('64')
|
||||
expect(document.activeElement).toBe(input.element)
|
||||
expect(wrapper.get('#mailbox-name-help').text()).toBe('64자까지. 같은 자리에 같은 이름은 쓸 수 없습니다.')
|
||||
expect(wrapper.get('#mailbox-name-help').text()).toBe('64자까지 쓸 수 있고, 같은 자리에 같은 이름은 쓸 수 없습니다.')
|
||||
// 이름을 비웠으면 누를 수 없다
|
||||
expect(wrapper.get('#mailbox-name-confirm').attributes('disabled')).toBeDefined()
|
||||
|
||||
@@ -262,7 +262,7 @@ describe('MailboxPanel — 메일함 관리', () => {
|
||||
expect(useToastStore().toasts).toEqual([])
|
||||
|
||||
await wrapper.get('#mailbox-name-input').setValue('거래처 2')
|
||||
expect(wrapper.get('#mailbox-name-help').text()).toBe('64자까지. 같은 자리에 같은 이름은 쓸 수 없습니다.')
|
||||
expect(wrapper.get('#mailbox-name-help').text()).toBe('64자까지 쓸 수 있고, 같은 자리에 같은 이름은 쓸 수 없습니다.')
|
||||
})
|
||||
|
||||
it('/ 가 든 이름은 보내지 않고 칸 아래에 사전 문구(VAL_001)', async () => {
|
||||
@@ -403,7 +403,7 @@ describe('MailboxPanel — 메일함 관리', () => {
|
||||
expect(row(wrapper, '받은편지함').find('.mailbox-row__count').exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('휴지통 비우기 — 확인(처음 초점 「취소」) 뒤 DELETE /mailboxes/{id}/messages 를 되풀이 · "휴지통을 비웠습니다 — 메일 21통."', async () => {
|
||||
it('휴지통 비우기 — 확인(처음 초점 「취소」) 뒤 DELETE /mailboxes/{id}/messages 를 되풀이 · "메일 21통을 지워 휴지통을 비웠습니다."', async () => {
|
||||
reply('DELETE /mailboxes/trash/messages', ok({ processed: 20, remaining: 1 }), ok({ processed: 1, remaining: 0 }))
|
||||
const { wrapper } = await mountPanel()
|
||||
|
||||
@@ -421,7 +421,7 @@ describe('MailboxPanel — 메일함 관리', () => {
|
||||
await wrapper.get('#mailbox-empty-confirm').trigger('click')
|
||||
await flushPromises()
|
||||
expect(sent('DELETE', '/mailboxes/trash/messages')).toHaveLength(2)
|
||||
expect(toastTexts()).toEqual(['휴지통을 비웠습니다 — 메일 21통.'])
|
||||
expect(toastTexts()).toEqual(['메일 21통을 지워 휴지통을 비웠습니다.'])
|
||||
// 되돌릴 수 없어 [되돌리기]가 없다
|
||||
expect(useToastStore().toasts[0]?.action).toBeNull()
|
||||
})
|
||||
@@ -433,7 +433,7 @@ describe('MailboxPanel — 메일함 관리', () => {
|
||||
await choose(wrapper, '받은편지함', '모두 읽음으로 표시')
|
||||
// 진행 알림은 그 자리에서 결과로 바뀐다 — 까닭(사전 문구 · 문의 코드)은 인터셉터의 알림
|
||||
expect(useToastStore().toasts.map((toast) => [toast.kind, toast.message])).toEqual([
|
||||
['error', '1,234통 가운데 500통까지 처리했습니다 — 다시 시도해 주세요.'],
|
||||
['error', '1,234통 가운데 500통까지 처리하고 멈췄습니다. 다시 시도해 주세요.'],
|
||||
['error', ERROR_CODE_MESSAGES.MAIL_001],
|
||||
])
|
||||
|
||||
|
||||
@@ -104,7 +104,7 @@ describe('MessageAttachments — 대용량 첨부', () => {
|
||||
expect(blocked.attributes('aria-disabled')).toBe('true')
|
||||
await blocked.trigger('click')
|
||||
await flushPromises()
|
||||
expect(view.get('[role="status"]').text()).toBe('1.2 GB · 보안 검사 중 — 끝나면 받을 수 있습니다')
|
||||
expect(view.get('[role="status"]').text()).toBe('1.2 GB · 보안 검사가 끝나면 받을 수 있습니다')
|
||||
expect(downloads).toEqual([])
|
||||
|
||||
expect(view.get('[data-large-file="f2"]').classes()).toContain('large-item--danger')
|
||||
@@ -127,7 +127,7 @@ describe('MessageAttachments — 대용량 첨부', () => {
|
||||
'612 MB · 아직 받지 않음 · 11월 29일까지',
|
||||
])
|
||||
expect(view.get('.link-bar__note').text()).toBe(
|
||||
'링크로 보낸 파일 2개 — 받는 사람 모두 같은 링크로 받습니다. 끊으면 둘 다 받을 수 없습니다.',
|
||||
'링크로 보낸 파일 2개는 받는 사람 모두 같은 링크로 받습니다. 끊으면 둘 다 받을 수 없습니다.',
|
||||
)
|
||||
|
||||
await view.get('[data-link-copy]').trigger('click')
|
||||
|
||||
@@ -143,7 +143,7 @@ describe('MessageList (분할 보기 목록)', () => {
|
||||
const box = wrapper.findAll('.message-row [role="checkbox"]')[0]
|
||||
|
||||
expect(box?.attributes('aria-disabled')).toBe('true')
|
||||
expect(box?.attributes('title')).toBe('보내기를 기다리는 메일은 고를 수 없습니다 — 열어서 취소하거나 삭제해 주세요.')
|
||||
expect(box?.attributes('title')).toBe('보내기를 기다리는 메일은 고를 수 없습니다. 메일을 열어 취소하거나 삭제해 주세요.')
|
||||
await box?.trigger('click')
|
||||
await wrapper.findAll('.message-row__main')[0]?.trigger('click', { metaKey: true })
|
||||
await flushPromises()
|
||||
@@ -175,6 +175,6 @@ describe('MessageList (분할 보기 목록)', () => {
|
||||
// 머리 — 검색 결과 · ‘회의’ · 2통 · 범위 줄(전체일 때 안내)
|
||||
expect(wrapper.get('#message-list-title').text()).toBe('검색 결과')
|
||||
expect(wrapper.get('.search-header__count').text()).toBe('‘회의’ · 2통')
|
||||
expect(wrapper.get('.search-scope').text()).toContain('휴지통 · 스팸메일함은 빼고 찾습니다')
|
||||
expect(wrapper.get('.search-scope').text()).toContain('휴지통과 스팸메일함은 빼고 찾습니다')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -596,7 +596,7 @@ describe('MessageReader 스팸 신고 · 원문 내려받기 (4단계 — 디자
|
||||
expect(patches().at(-1)).toEqual({ ids: ['m1'], mailboxId: 'inbox', isJunk: false })
|
||||
})
|
||||
|
||||
it('스팸메일함의 메일은 「스팸 아님」(강조) — 받은편지함으로 · isJunk: false, "(스팸 아님)" 알림', async () => {
|
||||
it('스팸메일함의 메일은 「스팸 아님」(강조) — 받은편지함으로 · isJunk: false, "스팸이 아닌 메일로 표시해 …" 알림', async () => {
|
||||
const { wrapper } = await mountWith({ box: 'junk' })
|
||||
const notSpam = wrapper.get('[data-reader-spam]')
|
||||
expect(notSpam.text()).toBe('스팸 아님')
|
||||
@@ -606,7 +606,7 @@ describe('MessageReader 스팸 신고 · 원문 내려받기 (4단계 — 디자
|
||||
await flushPromises()
|
||||
|
||||
expect(patches()).toEqual([{ ids: ['m1'], mailboxId: 'inbox', isJunk: false }])
|
||||
expect(useToastStore().toasts[0]?.message).toBe('메일 1통을 받은편지함으로 옮겼습니다 (스팸 아님).')
|
||||
expect(useToastStore().toasts[0]?.message).toBe('메일 1통을 스팸이 아닌 메일로 표시해 받은편지함으로 옮겼습니다.')
|
||||
})
|
||||
|
||||
it('스팸메일함을 모르면 「스팸 신고」를 그리지 않는다', async () => {
|
||||
|
||||
@@ -281,7 +281,7 @@ onBeforeUnmount(() => {
|
||||
<StateMessage
|
||||
v-else
|
||||
icon="mail"
|
||||
title="메일을 선택하세요"
|
||||
title="메일을 선택해 주세요"
|
||||
:hint="
|
||||
mailStore.search
|
||||
? '검색 결과에서 메일을 고르면 내용이 여기에 표시됩니다.'
|
||||
|
||||
@@ -271,7 +271,7 @@ describe('MessageTable (목록 보기)', () => {
|
||||
|
||||
expect(boxes().map((box) => box.attributes('aria-disabled') ?? null)).toEqual([null, 'true', 'true', null])
|
||||
expect(boxes()[1]?.attributes('title')).toBe(
|
||||
'보내기를 기다리는 메일은 고를 수 없습니다 — 열어서 취소하거나 삭제해 주세요.',
|
||||
'보내기를 기다리는 메일은 고를 수 없습니다. 메일을 열어 취소하거나 삭제해 주세요.',
|
||||
)
|
||||
expect(boxes()[0]?.attributes('title')).toBeUndefined()
|
||||
|
||||
|
||||
@@ -474,7 +474,7 @@ describe('RecipientField', () => {
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('그룹 이름을 치면 그룹 줄 — 아이콘 + 이름(맞는 글자 굵게) + "4명 · 고르면 한꺼번에 넣습니다", 초성도 된다 · 구성원이 없는 그룹은 없다', async () => {
|
||||
it('그룹 이름을 치면 그룹 줄 — 아이콘 + 이름(맞는 글자 굵게) + "고르면 4명을 한꺼번에 넣습니다", 초성도 된다 · 구성원이 없는 그룹은 없다', async () => {
|
||||
const wrapper = mountField([], suggestionsOf([JUNHO], [VENDORS, EMPTY_GROUP]))
|
||||
await typed(wrapper, 'ㄱㄹ')
|
||||
|
||||
@@ -483,7 +483,7 @@ describe('RecipientField', () => {
|
||||
expect(option.attributes('data-suggestion-group')).toBeDefined()
|
||||
expect(option.find('svg').exists()).toBe(true)
|
||||
expect(option.get('.recipient-suggestions__name strong').text()).toBe('거래')
|
||||
expect(option.get('.recipient-suggestions__email').text()).toBe('4명 · 고르면 한꺼번에 넣습니다')
|
||||
expect(option.get('.recipient-suggestions__email').text()).toBe('고르면 4명을 한꺼번에 넣습니다')
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
|
||||
@@ -53,7 +53,7 @@ describe('TwoFactorGraceNotice', () => {
|
||||
const bar = wrapper.get('[data-two-factor-grace-notice]')
|
||||
expect(bar.attributes('role')).toBe('region')
|
||||
expect(bar.attributes('aria-label')).toBe('2단계 인증 안내')
|
||||
expect(bar.text()).toBe('11월 30일까지 2단계 인증을 켜 주세요 — 그 뒤에는 켜야 로그인할 수 있습니다.지금 켜기')
|
||||
expect(bar.text()).toBe('11월 30일까지 2단계 인증을 켜 주세요. 그 뒤에는 켜야 로그인할 수 있습니다.지금 켜기')
|
||||
expect(bar.get('strong').text()).toBe('11월 30일까지 2단계 인증을 켜 주세요')
|
||||
expect(bar.get('[data-two-factor-grace-action]').attributes('href')).toBe('/settings/account/two-factor')
|
||||
expect(bar.findAll('button')).toHaveLength(0)
|
||||
|
||||
@@ -50,7 +50,7 @@ describe('MailEditor (서식 편집기)', () => {
|
||||
expect(body.attributes('role')).toBe('textbox')
|
||||
expect(body.attributes('aria-label')).toBe('본문')
|
||||
expect(body.attributes('aria-multiline')).toBe('true')
|
||||
expect(body.get('p').attributes('data-placeholder')).toBe('내용을 입력하세요')
|
||||
expect(body.get('p').attributes('data-placeholder')).toBe('내용을 입력해 주세요')
|
||||
expect(view.get('[role="toolbar"]').attributes('aria-label')).toBe('본문 서식')
|
||||
expect(view.emitted('ready')).toHaveLength(1)
|
||||
})
|
||||
|
||||
@@ -256,7 +256,7 @@ defineExpose({
|
||||
<EditorToolButton
|
||||
tool="image"
|
||||
label="그림 넣기"
|
||||
title="그림 넣기 — PNG · JPG · GIF"
|
||||
title="그림 넣기 (PNG, JPG, GIF)"
|
||||
:disabled="imageDisabled"
|
||||
:focusable="rovingTool === 'image'"
|
||||
@press="emit('pick-image')"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
// 설정 › 대용량 첨부 › 보내지 않은 파일 (디자인 원본 SettingsLargeFiles — 사용량 상자 바로 아래: 공간을 바로 비울 수 있는 곳이라 보낸 링크보다 위)
|
||||
// 「보내지 않은 파일 3개」 + "1.9 GB · 임시 저장한 메일에 실렸거나 올리다 멈춘 파일 — 지우면 바로 공간이 생깁니다". 없으면 쓰는 쪽이 묶음째 그리지 않는다.
|
||||
// 「보내지 않은 파일 3개」 + "1.9 GB · 임시 저장한 메일에 실렸거나 올리다 멈춘 파일이라 지우면 바로 공간이 생깁니다". 없으면 쓰는 쪽이 묶음째 그리지 않는다.
|
||||
// 줄(52): 아이콘 · 이름 / 상태(올리다 멈춤 — 갈색 · 다 올림 · 검사 상태) · 크기(멈춘 파일은 "1.2 GB" / "420 MB 올림") ·
|
||||
// 남는 기한("11월 29일까지 남음", 멈춘 파일은 "내일 오후 2:41까지 남음" / "마지막으로 올린 뒤 24시간") · [지우기](36, 빨간 글)
|
||||
// [지우기] → 확인 창(처음 초점 「취소」) → DELETE /file-uploads/{id} → "‘…’ 파일을 지웠습니다." — 줄이 사라지고 사용량이 바로 준다(스토어).
|
||||
|
||||
@@ -4,8 +4,8 @@
|
||||
// 표(구현은 실제 table): 시각 · 방식 · IP · 기기 · 결과 — 최근 순서 · 20개씩 [더 보기](44)
|
||||
// - 방식: "웹메일" / "메일 프로그램 · IMAP" · "메일 프로그램 · SMTP" + 앱 비밀번호로 들어왔으면 아래 줄 "앱 비밀번호 ‘회사 PC 아웃룩’"
|
||||
// - 기기: 웹메일은 "Chrome · Windows"(모르면 "알 수 없는 브라우저"), 메일 프로그램은 「—」. 나라 · 지역은 보이지 않는다
|
||||
// - 결과: 「성공」 · 「성공 · 2단계 인증」 · 「실패 — 비밀번호 틀림」 · 「실패 — 코드 틀림」 · 「실패 — 앱 비밀번호 필요」 — 실패는 빨간 굵은 글자만(뱃지 없음)
|
||||
// - 빈 상태(실패만): "최근 90일 동안 실패한 로그인이 없습니다" / "[전체]를 누르면 성공한 로그인도 봅니다."
|
||||
// - 결과: 「성공」 · 「2단계 인증으로 성공」 · 「비밀번호가 틀려 실패」 · 「코드가 틀려 실패」 · 「앱 비밀번호가 필요해 실패」 — 실패는 빨간 굵은 글자만(뱃지 없음)
|
||||
// - 빈 상태(실패만): "최근 90일 동안 실패한 로그인이 없습니다" / "[전체]를 누르면 성공한 로그인도 볼 수 있습니다."
|
||||
// - 보기를 바꾸면 그 보기로 처음부터 받는다 — 받는 동안 지난 목록은 그대로 두고 aria-busy (login-history.store)
|
||||
import { computed } from 'vue'
|
||||
|
||||
|
||||
@@ -118,19 +118,19 @@ describe('useMailFilterRun', () => {
|
||||
mailStore.listStatus = 'ready'
|
||||
}
|
||||
|
||||
it('진행 알림이 남았다가 끝나면 그 자리에서 결과로 — "…메일 37통에 적용했습니다." + [거래처 보기](그 메일함으로)', async () => {
|
||||
it('진행 알림이 남았다가 끝나면 그 자리에서 결과로 — "…메일 37통을 정리했습니다." + [거래처 보기](그 메일함으로)', async () => {
|
||||
const first = deferred()
|
||||
const second = deferred()
|
||||
replies(first.promise, second.promise)
|
||||
|
||||
expect(useMailFilterRun().start(rule())).toBe(true)
|
||||
// 첫 응답 전 — 전체는 받은편지함의 메일 수
|
||||
expect(shown()).toEqual([['progress', '‘거래처 메일’ 필터를 받은편지함에 적용하는 중 · 0 / 8,412통 확인']])
|
||||
expect(shown()).toEqual([['progress', '‘거래처 메일’ 필터로 받은편지함을 정리하는 중 · 0 / 8,412통 확인']])
|
||||
const id = useToastStore().toasts[0]?.id
|
||||
|
||||
first.finish(step(4500, 30, 3912, 'c1'))
|
||||
await flushPromises()
|
||||
expect(shown()).toEqual([['progress', '‘거래처 메일’ 필터를 받은편지함에 적용하는 중 · 4,500 / 8,412통 확인']])
|
||||
expect(shown()).toEqual([['progress', '‘거래처 메일’ 필터로 받은편지함을 정리하는 중 · 4,500 / 8,412통 확인']])
|
||||
second.finish(step(3912, 7, 0, null))
|
||||
await flushPromises()
|
||||
|
||||
@@ -139,16 +139,16 @@ describe('useMailFilterRun', () => {
|
||||
expect(toast).toMatchObject({
|
||||
id,
|
||||
kind: 'success',
|
||||
message: '‘거래처 메일’ 필터를 받은편지함의 메일 37통에 적용했습니다.',
|
||||
message: '‘거래처 메일’ 필터로 받은편지함의 메일 37통을 정리했습니다.',
|
||||
action: { label: '거래처 보기', to: { name: 'mail-mailbox', params: { mailboxId: 'vendor' } } },
|
||||
})
|
||||
expect(cursors()).toEqual([null, 'c1'])
|
||||
})
|
||||
|
||||
it.each([
|
||||
['읽음 · 중요만 하는 필터', rule({ name: '결재 알림', actions: { ...NO_ACTIONS, markAsRead: true, flag: true } }), 12, '‘결재 알림’ 필터를 받은편지함의 메일 12통에 적용했습니다.'],
|
||||
['받은편지함으로 옮기며 읽음 표시하는 필터', rule({ actions: { ...NO_ACTIONS, moveToMailboxId: 'inbox', markAsRead: true } }), 3, '‘거래처 메일’ 필터를 받은편지함의 메일 3통에 적용했습니다.'],
|
||||
['지금 메일함 목록에 없는 메일함으로 옮기며 읽음 표시하는 필터', rule({ actions: { ...NO_ACTIONS, moveToMailboxId: 'gone', markAsRead: true } }), 3, '‘거래처 메일’ 필터를 받은편지함의 메일 3통에 적용했습니다.'],
|
||||
['읽음 · 중요만 하는 필터', rule({ name: '결재 알림', actions: { ...NO_ACTIONS, markAsRead: true, flag: true } }), 12, '‘결재 알림’ 필터로 받은편지함의 메일 12통을 정리했습니다.'],
|
||||
['받은편지함으로 옮기며 읽음 표시하는 필터', rule({ actions: { ...NO_ACTIONS, moveToMailboxId: 'inbox', markAsRead: true } }), 3, '‘거래처 메일’ 필터로 받은편지함의 메일 3통을 정리했습니다.'],
|
||||
['지금 메일함 목록에 없는 메일함으로 옮기며 읽음 표시하는 필터', rule({ actions: { ...NO_ACTIONS, moveToMailboxId: 'gone', markAsRead: true } }), 3, '‘거래처 메일’ 필터로 받은편지함의 메일 3통을 정리했습니다.'],
|
||||
['맞는 메일이 없는 옮기는 필터', rule(), 0, '받은편지함에 ‘거래처 메일’ 필터에 맞는 메일이 없습니다.'],
|
||||
])('%s — 단추 없이 끝난다', async (_label, target, matched, message) => {
|
||||
replies(step(8412, matched, 0, null))
|
||||
@@ -179,7 +179,7 @@ describe('useMailFilterRun', () => {
|
||||
|
||||
useMailFilterRun().start(rule({ actions: { ...NO_ACTIONS, markAsRead: true } }))
|
||||
|
||||
expect(shown()).toEqual([['progress', '‘거래처 메일’ 필터를 받은편지함에 적용하는 중']])
|
||||
expect(shown()).toEqual([['progress', '‘거래처 메일’ 필터로 받은편지함을 정리하는 중']])
|
||||
first.finish(step(10, 0, 0, null))
|
||||
await flushPromises()
|
||||
})
|
||||
@@ -193,7 +193,7 @@ describe('useMailFilterRun', () => {
|
||||
|
||||
// 진행 알림이 그 자리에서 멈춤 문구로 — 까닭은 오류 알림이 함께
|
||||
expect(shown()).toEqual([
|
||||
['error', '‘거래처 메일’ 필터 — 8,412통 가운데 4,200통까지 확인하고 멈췄습니다(12통에 적용). 다시 시도해 주세요.'],
|
||||
['error', '‘거래처 메일’ 필터로 8,412통 가운데 4,200통까지 확인하고 멈췄습니다. 그동안 12통을 정리했습니다. 다시 시도해 주세요.'],
|
||||
['error', ERROR_CODE_MESSAGES.MAIL_001],
|
||||
])
|
||||
const stopped = withAction()
|
||||
@@ -201,11 +201,11 @@ describe('useMailFilterRun', () => {
|
||||
useToastStore().clear()
|
||||
|
||||
press(stopped)
|
||||
expect(shown()).toEqual([['progress', '‘거래처 메일’ 필터를 받은편지함에 적용하는 중 · 4,200 / 8,412통 확인']])
|
||||
expect(shown()).toEqual([['progress', '‘거래처 메일’ 필터로 받은편지함을 정리하는 중 · 4,200 / 8,412통 확인']])
|
||||
await flushPromises()
|
||||
|
||||
expect(cursors()).toEqual([null, 'c1', 'c1'])
|
||||
expect(shown()).toEqual([['success', '‘거래처 메일’ 필터를 받은편지함의 메일 37통에 적용했습니다.']])
|
||||
expect(shown()).toEqual([['success', '‘거래처 메일’ 필터로 받은편지함의 메일 37통을 정리했습니다.']])
|
||||
})
|
||||
|
||||
it('처음 요청부터 실패하면 "필터는 저장했지만 …" + [다시 시도] — 처음부터 다시 한다', async () => {
|
||||
@@ -215,18 +215,18 @@ describe('useMailFilterRun', () => {
|
||||
await flushPromises()
|
||||
|
||||
expect(shown()).toEqual([
|
||||
['error', '필터는 저장했지만 받은편지함에 적용하지 못했습니다. 다시 시도해 주세요.'],
|
||||
['error', '필터는 저장했지만 받은편지함은 정리하지 못했습니다. 다시 시도해 주세요.'],
|
||||
['error', ERROR_CODE_MESSAGES.SYS_001],
|
||||
])
|
||||
const failed = withAction()
|
||||
expect(failed?.action?.label).toBe('다시 시도')
|
||||
useToastStore().clear()
|
||||
press(failed)
|
||||
expect(shown()).toEqual([['progress', '‘거래처 메일’ 필터를 받은편지함에 적용하는 중 · 0 / 8,412통 확인']])
|
||||
expect(shown()).toEqual([['progress', '‘거래처 메일’ 필터로 받은편지함을 정리하는 중 · 0 / 8,412통 확인']])
|
||||
await flushPromises()
|
||||
|
||||
expect(cursors()).toEqual([null, null])
|
||||
expect(shown()).toEqual([['success', '‘거래처 메일’ 필터를 받은편지함의 메일 37통에 적용했습니다.']])
|
||||
expect(shown()).toEqual([['success', '‘거래처 메일’ 필터로 받은편지함의 메일 37통을 정리했습니다.']])
|
||||
})
|
||||
|
||||
it('다른 창 · PC 에서 적용하는 중이면(429) "다른 창에서 …" 알림 하나만 — 처음 요청이면 단추가 없다', async () => {
|
||||
@@ -236,7 +236,7 @@ describe('useMailFilterRun', () => {
|
||||
await flushPromises()
|
||||
|
||||
expect(useToastStore().toasts).toMatchObject([
|
||||
{ kind: 'error', message: '다른 창에서 필터를 받은편지함에 적용하는 중입니다. 끝난 뒤 다시 시도해 주세요.', action: null },
|
||||
{ kind: 'error', message: '다른 창에서 필터로 받은편지함을 정리하는 중입니다. 끝난 뒤 다시 시도해 주세요.', action: null },
|
||||
])
|
||||
})
|
||||
|
||||
@@ -251,7 +251,7 @@ describe('useMailFilterRun', () => {
|
||||
expect(useToastStore().toasts).toHaveLength(1)
|
||||
expect(toast).toMatchObject({
|
||||
kind: 'error',
|
||||
message: '다른 창에서 필터를 받은편지함에 적용하는 중입니다. 끝난 뒤 다시 시도해 주세요.',
|
||||
message: '다른 창에서 필터로 받은편지함을 정리하는 중입니다. 끝난 뒤 다시 시도해 주세요.',
|
||||
action: { label: '다시 시도' },
|
||||
})
|
||||
press(toast)
|
||||
@@ -326,9 +326,9 @@ describe('useMailFilterRun', () => {
|
||||
await flushPromises()
|
||||
|
||||
expect(runs()).toHaveLength(3)
|
||||
const busy = '다른 필터를 받은편지함에 적용하는 중입니다. 끝난 뒤 다시 시도해 주세요.'
|
||||
const busy = '다른 필터로 받은편지함을 정리하는 중입니다. 끝난 뒤 다시 시도해 주세요.'
|
||||
expect(shown()).toEqual([
|
||||
['progress', '‘둘’ 필터를 받은편지함에 적용하는 중 · 0 / 8,412통 확인'],
|
||||
['progress', '‘둘’ 필터로 받은편지함을 정리하는 중 · 0 / 8,412통 확인'],
|
||||
['error', busy],
|
||||
['error', busy],
|
||||
])
|
||||
@@ -342,10 +342,10 @@ describe('useMailFilterRun', () => {
|
||||
|
||||
// 앞 적용이 끝났다 — 바쁨 알림의 [다시 시도]는 멈춘 자리(cursor c1 · 4,200통 · 12통)에서 이어 한다
|
||||
press(resumed)
|
||||
expect(shown()).toEqual([['progress', '‘하나’ 필터를 받은편지함에 적용하는 중 · 4,200 / 8,412통 확인']])
|
||||
expect(shown()).toEqual([['progress', '‘하나’ 필터로 받은편지함을 정리하는 중 · 4,200 / 8,412통 확인']])
|
||||
await flushPromises()
|
||||
expect(cursors()).toEqual([null, 'c1', null, 'c1'])
|
||||
expect(shown()).toEqual([['success', '‘하나’ 필터를 받은편지함의 메일 37통에 적용했습니다.']])
|
||||
expect(shown()).toEqual([['success', '‘하나’ 필터로 받은편지함의 메일 37통을 정리했습니다.']])
|
||||
})
|
||||
|
||||
describe('끝난 뒤 개수 · 목록 맞추기 (4단계 오래 걸리는 처리와 같은 결)', () => {
|
||||
|
||||
@@ -151,9 +151,9 @@ describe('LoginPage — 제한된 세션 (13단계)', () => {
|
||||
expect(wrapper.find('#restricted-password-temporary').exists()).toBe(false)
|
||||
expect(wrapper.find('#restricted-password-code').exists()).toBe(false)
|
||||
expect(wrapper.get('label[for="restricted-password-next"]').text()).toBe('새 비밀번호')
|
||||
expect(wrapper.text()).toContain('8자 이상 · 임시 비밀번호와 달라야 합니다. 영문 · 숫자 · 기호를 섞으면 더 안전합니다.')
|
||||
expect(wrapper.text()).toContain('8자 이상이고 임시 비밀번호와 달라야 합니다. 영문, 숫자, 기호를 섞으면 더 안전합니다.')
|
||||
expect(wrapper.get('[data-restricted-submit]').text()).toBe('비밀번호 바꾸고 계속')
|
||||
expect(wrapper.text()).toContain('메일 프로그램(아웃룩 등)을 연결해 두었다면 바꾼 뒤 다시 연결해야 합니다.')
|
||||
expect(wrapper.text()).toContain('아웃룩 같은 메일 프로그램을 연결해 두었다면 비밀번호를 바꾼 뒤 다시 연결해 주세요.')
|
||||
expect(document.activeElement?.id).toBe('restricted-password-next')
|
||||
// 비밀번호는 칸 · 반응형 상태에 남기지 않는다 — 로그인 칸은 사라졌다
|
||||
expect(wrapper.find('#login-password').exists()).toBe(false)
|
||||
@@ -279,7 +279,7 @@ describe('LoginPage — 제한된 세션 (13단계)', () => {
|
||||
expect(wrapper.get('h1').text()).toBe('새 비밀번호를 정해 주세요')
|
||||
expect(wrapper.find('#restricted-password-temporary').exists()).toBe(true)
|
||||
expect(wrapper.get('label[for="restricted-password-code"]').text()).toBe('인증 앱 코드')
|
||||
expect(wrapper.text()).toContain('로그인할 때 넣은 코드는 다시 쓸 수 없습니다 — 앱에 다음 코드가 뜨면 넣어 주세요.')
|
||||
expect(wrapper.text()).toContain('로그인할 때 넣은 코드는 다시 쓸 수 없으니, 앱에 다음 코드가 뜨면 넣어 주세요.')
|
||||
expect(urls()).toContain('GET /two-factor')
|
||||
})
|
||||
|
||||
@@ -366,16 +366,16 @@ describe('LoginPage — 제한된 세션 (13단계)', () => {
|
||||
it('새 키를 받아 QR · 키를 그린다 — 지금 비밀번호는 묻지 않고(메모리) 문의 줄 · 아래 안내', async () => {
|
||||
const { wrapper } = await atSetupStep({ supportContact: () => ok({ text: CONTACT }) })
|
||||
|
||||
expect(wrapper.get('h1').text()).toBe('2단계 인증을 켜야 합니다')
|
||||
expect(wrapper.get('h1').text()).toBe('2단계 인증을 켜 주세요')
|
||||
expect(urls()).toContain('POST /two-factor/enrollments')
|
||||
expect(wrapper.find('[data-qr-code]').exists()).toBe(true)
|
||||
expect(wrapper.get('[data-setup-key]').text().replace(/\s/g, '')).toBe(enrollment(1).secret)
|
||||
expect(wrapper.text()).toContain(`앱에는 「ARA SYSTEM (${USER.email})」로 저장됩니다.`)
|
||||
expect(wrapper.text()).toContain(`앱에는 ‘ARA SYSTEM (${USER.email})’로 저장됩니다.`)
|
||||
expect(wrapper.get('label[for="restricted-setup-code"]').text()).toBe('앱에 표시된 6자리 코드')
|
||||
expect(wrapper.find('#restricted-setup-password').exists()).toBe(false)
|
||||
expect(wrapper.get('[data-support-line]').text()).toBe(`인증 앱을 쓸 수 없나요?${CONTACT}`)
|
||||
expect(wrapper.get('[data-support-contact]').text()).toBe(CONTACT)
|
||||
expect(wrapper.text()).toContain('Outlook · 휴대폰 메일 앱은 이 코드를 묻지 않습니다.')
|
||||
expect(wrapper.text()).toContain('Outlook이나 휴대폰 메일 앱은 이 코드를 묻지 않습니다.')
|
||||
expect(document.activeElement?.id).toBe('restricted-setup-code')
|
||||
})
|
||||
|
||||
@@ -428,7 +428,7 @@ describe('LoginPage — 제한된 세션 (13단계)', () => {
|
||||
const { wrapper } = await mountLogin({ activate: () => fail(400, 'AUTH_005') })
|
||||
|
||||
expect(wrapper.get('label[for="restricted-setup-password"]').text()).toBe('지금 비밀번호')
|
||||
expect(wrapper.text()).toContain('새로고침해서 다시 넣어야 합니다.')
|
||||
expect(wrapper.text()).toContain('새로고침한 뒤에는 지금 비밀번호를 한 번 더 넣어 주세요.')
|
||||
await wrapper.get('#restricted-setup-code').setValue('482913')
|
||||
await submitStep(wrapper)
|
||||
expect(bodyOf('/two-factor/activation')).toEqual([])
|
||||
@@ -453,7 +453,7 @@ describe('LoginPage — 제한된 세션 (13단계)', () => {
|
||||
await submitStep(wrapper)
|
||||
|
||||
expect(router.currentRoute.value.name).toBe('login')
|
||||
expect(wrapper.get('h1').text()).toBe('2단계 인증을 켜야 합니다')
|
||||
expect(wrapper.get('h1').text()).toBe('2단계 인증을 켜 주세요')
|
||||
expect(useToastStore().toasts.map((toast) => toast.message)).toEqual(['새 비밀번호를 정했습니다.'])
|
||||
expect(wrapper.find('#restricted-setup-password').exists()).toBe(false)
|
||||
expect(document.activeElement?.id).toBe('restricted-setup-code')
|
||||
@@ -491,7 +491,7 @@ describe('LoginPage — 제한된 세션 (13단계)', () => {
|
||||
})
|
||||
|
||||
expect(router.currentRoute.value.name).toBe('login')
|
||||
expect(wrapper.get('h1').text()).toBe('2단계 인증을 켜야 합니다')
|
||||
expect(wrapper.get('h1').text()).toBe('2단계 인증을 켜 주세요')
|
||||
expect(wrapper.find('#restricted-setup-password').exists()).toBe(false)
|
||||
await wrapper.get('#restricted-setup-code').setValue('482913')
|
||||
await submitStep(wrapper)
|
||||
|
||||
@@ -206,7 +206,7 @@ describe('LoginPage — 2단계 인증 코드 단계', () => {
|
||||
expect(input.attributes('maxlength')).toBeUndefined()
|
||||
expect(document.activeElement?.id).toBe('login-code')
|
||||
expect(wrapper.text()).toContain('인증 앱을 쓸 수 없나요? 관리자에게 문의해 주세요.')
|
||||
expect(wrapper.text()).toContain('Outlook · 휴대폰 메일 앱은 이 코드를 묻지 않습니다.')
|
||||
expect(wrapper.text()).toContain('Outlook이나 휴대폰 메일 앱은 이 코드를 묻지 않습니다.')
|
||||
})
|
||||
|
||||
it('문의 연락처가 있으면(13단계) 「인증 앱을 쓸 수 없나요?」 다음 줄에 연락처', async () => {
|
||||
|
||||
@@ -449,7 +449,7 @@ void supportContactStore.ensureLoaded()
|
||||
로그인
|
||||
</h1>
|
||||
<p class="login__subtitle">
|
||||
회사 메일 계정(@{{ COMPANY_MAIL_DOMAIN }})으로 로그인하세요.
|
||||
회사 메일 주소(@{{ COMPANY_MAIL_DOMAIN }})로 로그인해 주세요.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -524,7 +524,7 @@ void supportContactStore.ensureLoaded()
|
||||
:contact="supportContactStore.text"
|
||||
/>
|
||||
<p class="login__note">
|
||||
Outlook이나 휴대폰 기본 메일 앱에서도 같은 계정으로 메일을 쓸 수 있습니다. (IMAP · SMTP 설정)
|
||||
Outlook이나 휴대폰 기본 메일 앱에서도 IMAP, SMTP 설정으로 같은 메일을 쓸 수 있습니다.
|
||||
</p>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
@@ -387,7 +387,7 @@ describe('ContactsPage (주소록 모듈)', () => {
|
||||
const row = app.get('[data-contacts-row="c2"]')
|
||||
expect(row.get('.contacts-row__meta').text()).toBe('(주)한빛상사 · 영업팀')
|
||||
expect(row.get('.contacts-row__email').text()).toBe('jihoon.park@hanbit.example')
|
||||
expect(app.get('[data-contact-none]').text()).toContain('연락처를 고르세요')
|
||||
expect(app.get('[data-contact-none]').text()).toContain('연락처를 골라 주세요')
|
||||
|
||||
await click(row.get('a'))
|
||||
expect(router.currentRoute.value.fullPath).toBe('/contacts/all/c2')
|
||||
@@ -624,11 +624,11 @@ describe('ContactsPage (주소록 모듈)', () => {
|
||||
const { app } = await mountApp('/contacts/directory/people/s5')
|
||||
|
||||
expect(app.get('#contacts-list-title').text()).toBe('직원')
|
||||
expect(app.get('.contacts-list__note').text()).toBe('메일 서버에 계정이 있는 직원입니다 — 이름과 주소만 보입니다.')
|
||||
expect(app.get('.contacts-list__note').text()).toBe('회사 메일을 쓰는 직원이라 이름과 메일 주소만 보입니다.')
|
||||
const detail = app.get('[data-directory-detail="s5"]')
|
||||
expect(detail.get('.contact-detail__subtitle').text()).toBe('사내 직원')
|
||||
expect(detail.findAll('.tool-button').map((button) => button.text())).toEqual(['메일 쓰기', '내 주소록에 추가'])
|
||||
expect(detail.get('.contact-detail__hint').text()).toContain('사내 목록은 메일 서버의 계정에서 옵니다')
|
||||
expect(detail.get('.contact-detail__hint').text()).toContain('사내 목록은 이름과 주소만 보이고 여기서 고칠 수 없습니다')
|
||||
|
||||
await click(detail.get('[data-contact-add-mine]'))
|
||||
expect(isOpen(app, '#contact-dialog')).toBe(true)
|
||||
@@ -693,7 +693,7 @@ describe('ContactsPage (주소록 모듈)', () => {
|
||||
await click(app.get('[data-group-row="g1"] a'))
|
||||
const input = app.get('[data-contacts-search]')
|
||||
expect(input.attributes('aria-label')).toBe('주소록에서 찾기')
|
||||
expect(input.attributes('placeholder')).toBe('이름 · 주소 · 회사 · 전화번호 — 초성도 됩니다 (ㅂㅈㅎ)')
|
||||
expect(input.attributes('placeholder')).toBe('이름, 주소, 회사, 전화번호, 초성(ㅂㅈㅎ)으로 찾기')
|
||||
|
||||
await input.setValue('박')
|
||||
clock.advance(CONTACTS_SEARCH_DELAY_MS - 1)
|
||||
@@ -721,7 +721,7 @@ describe('ContactsPage (주소록 모듈)', () => {
|
||||
await flushPromises()
|
||||
const empty = app.get('[data-contacts-empty="search"]')
|
||||
expect(empty.text()).toContain('‘없는사람’에 맞는 연락처가 없습니다')
|
||||
expect(empty.text()).toContain('이름 · 주소 · 회사 · 전화번호의 일부나 이름의 초성으로 찾습니다.')
|
||||
expect(empty.text()).toContain('이름, 주소, 회사, 전화번호의 일부나 이름의 초성으로 찾을 수 있습니다.')
|
||||
|
||||
// Esc 는 찾을 글을 지운다 — 목록이 다시 그룹 전체로
|
||||
await input.trigger('keydown', { key: 'Escape' })
|
||||
@@ -742,7 +742,7 @@ describe('ContactsPage (주소록 모듈)', () => {
|
||||
await click(app.get('[data-contacts-source="directory:person"]'))
|
||||
expect(router.currentRoute.value.fullPath).toBe('/contacts/directory/people')
|
||||
expect((input.element as HTMLInputElement).value).toBe('')
|
||||
expect(input.attributes('placeholder')).toBe('이름 · 주소 — 초성도 됩니다 (ㅂㅈㅎ)')
|
||||
expect(input.attributes('placeholder')).toBe('이름, 주소, 초성(ㅂㅈㅎ)으로 찾기')
|
||||
})
|
||||
|
||||
it('그룹 ⋯ — 이름 바꾸기 · 그룹에 메일 쓰기 · 그룹 지우기(빨간 글), 구성원이 없으면 메일 쓰기를 끈다 · 지우기는 확인 없이 + [되돌리기]', async () => {
|
||||
@@ -764,7 +764,7 @@ describe('ContactsPage (주소록 모듈)', () => {
|
||||
expect(router.currentRoute.value.fullPath).toBe('/contacts')
|
||||
expect(app.find('[data-group-row="g1"]').exists()).toBe(false)
|
||||
const [toast] = useToastStore().toasts
|
||||
expect(toast?.message).toBe('‘거래처’ 그룹을 지웠습니다 — 연락처 3명은 그대로 있습니다.')
|
||||
expect(toast?.message).toBe('‘거래처’ 그룹을 지웠습니다. 그룹에 있던 연락처 3명은 그대로 남아 있습니다.')
|
||||
expect(toast?.action?.label).toBe('되돌리기')
|
||||
|
||||
if (toast?.action && 'run' in toast.action) toast.action.run()
|
||||
@@ -953,8 +953,8 @@ describe('ContactsPage (주소록 모듈)', () => {
|
||||
const empty = app.get('[data-contacts-empty="first"]')
|
||||
|
||||
expect(empty.text()).toContain('아직 연락처가 없습니다')
|
||||
expect(empty.text()).toContain('자주 메일을 주고받는 사람을 넣어 두면 받는 사람 칸에서 바로 찾습니다.')
|
||||
expect(empty.text()).toContain('메일을 읽다가 ⋯ › 「보낸 사람을 주소록에 추가」로도 넣을 수 있습니다.')
|
||||
expect(empty.text()).toContain('자주 메일을 주고받는 사람을 넣어 두면 받는 사람 칸에서 바로 찾을 수 있습니다.')
|
||||
expect(empty.text()).toContain('메일을 읽다가 [⋯] → [보낸 사람을 주소록에 추가]로도 넣을 수 있습니다.')
|
||||
await click(empty.get('button'))
|
||||
expect(isOpen(app, '#contact-dialog')).toBe(true)
|
||||
expect(app.get('#contact-dialog-title').text()).toBe('연락처 추가')
|
||||
@@ -1001,7 +1001,7 @@ describe('ContactsPage (주소록 모듈)', () => {
|
||||
await click(app.get('[data-group-row="g2"] button[data-mailbox-more]'))
|
||||
await click(app.findAll('[role="menuitem"]')[2] ?? app.get('[role="menuitem"]'))
|
||||
expect(useToastStore().toasts.map((toast) => [toast.message, toast.action ?? null])).toEqual([
|
||||
['‘프로젝트 알파’ 그룹을 지웠습니다 — 연락처 1명은 그대로 있습니다.', null],
|
||||
['‘프로젝트 알파’ 그룹을 지웠습니다. 그룹에 있던 연락처 1명은 그대로 남아 있습니다.', null],
|
||||
])
|
||||
})
|
||||
|
||||
|
||||
@@ -446,7 +446,7 @@ describe('ComposePage — 임시 저장 · 서명 (2단계)', () => {
|
||||
expect(wrapper.get('.compose__send').text()).toBe('보내기')
|
||||
// 꺼진 단추는 aria-disabled (11단계 — 초점은 받는다) · 까닭은 단추 옆 글
|
||||
expect(wrapper.get('.compose__send').attributes('aria-disabled')).toBe('true')
|
||||
expect(wrapper.get('#compose-send-why').text()).toBe('보낼 수 없는 파일이 있습니다 — 빨간 줄의 파일을 빼 주세요.')
|
||||
expect(wrapper.get('#compose-send-why').text()).toBe('보낼 수 없는 파일이 있습니다. 빨간 줄의 파일을 빼 주세요.')
|
||||
expect(wrapper.text()).toContain(ERROR_CODE_MESSAGES.MAIL_004)
|
||||
expect(document.activeElement?.textContent?.trim()).toBe('파일 첨부')
|
||||
expect(useToastStore().toasts).toEqual([])
|
||||
|
||||
@@ -239,9 +239,9 @@ describe('ComposePage — 대용량 첨부', () => {
|
||||
|
||||
expect(phases(wrapper)).toEqual(['ready', 'waiting-file', 'missing'])
|
||||
expect(lineOf(wrapper, 'ready')).toBe('1.2 GB · 이 임시 저장본에 11월 25일까지 남습니다')
|
||||
expect(lineOf(wrapper, 'waiting-file')).toContain('올리다 멈춘 파일 — 같은 파일을 다시 고르면 이어서 올립니다')
|
||||
expect(lineOf(wrapper, 'waiting-file')).toContain('올리다 멈춤 · 160 MB / 612 MB 올림 · 같은 파일을 다시 고르면 이어서 올립니다')
|
||||
expect(wrapper.find('[data-phase="waiting-file"] [data-large-repick]').exists()).toBe(true)
|
||||
expect(lineOf(wrapper, 'missing')).toBe('설정 › 대용량 첨부에서 지운 파일입니다 — 빼고 다시 올려 주세요.')
|
||||
expect(lineOf(wrapper, 'missing')).toBe('[설정] → [대용량 첨부]에서 지운 파일입니다. 빼고 다시 올려 주세요.')
|
||||
|
||||
const send = () => wrapper.get('.compose__send')
|
||||
expect(send().attributes('aria-disabled')).toBe('true')
|
||||
@@ -283,7 +283,7 @@ describe('ComposePage — 대용량 첨부', () => {
|
||||
expect((sent?.data as Record<string, unknown> | undefined)?.largeFileIds).toEqual(['lf-1'])
|
||||
expect(calls(requests)).toContain('GET /file-uploads/lf-1')
|
||||
expect(phases(wrapper)).toEqual(['missing'])
|
||||
expect(lineOf(wrapper, 'missing')).toBe('서버에서 찾을 수 없는 파일입니다 — 빼고 다시 올려 주세요.')
|
||||
expect(lineOf(wrapper, 'missing')).toBe('파일을 찾을 수 없습니다. 빼고 다시 올려 주세요.')
|
||||
expect(wrapper.get('.compose__send').attributes('aria-disabled')).toBe('true')
|
||||
expect(wrapper.get('#compose-send-why').text()).toBe(SEND_BLOCK_TEXT.unsendable)
|
||||
expect(useToastStore().toasts).toEqual([])
|
||||
@@ -308,7 +308,7 @@ describe('ComposePage — 대용량 첨부', () => {
|
||||
const lookup = requests.find((request) => request.url === '/share-link-lookups')
|
||||
expect(lookup?.data).toEqual({ urls: [URL1, URL2] })
|
||||
expect(wrapper.findAll('[data-quoted-link]').map((notice) => notice.text())).toEqual([
|
||||
'원래 메일의 대용량 첨부 2개는 인용 속 링크로 함께 갑니다 — 11월 29일까지',
|
||||
'원래 메일의 대용량 첨부 2개는 인용 속 링크로 함께 갑니다. 11월 29일까지 받을 수 있습니다.',
|
||||
'인용 속 대용량 첨부 링크는 공유가 멈춰 받을 수 없습니다.',
|
||||
])
|
||||
// 원래 메일의 대용량 첨부는 다시 싣지 않는다 — 링크 묶음이 없다
|
||||
@@ -379,7 +379,7 @@ describe('ComposePage — 대용량 첨부', () => {
|
||||
await flushPromises()
|
||||
|
||||
expect(phases(wrapper)).toEqual(['ready'])
|
||||
expect(lineOf(wrapper, 'ready')).toBe('2 MB · 보안 검사 중 — 끝나지 않아도 보낼 수 있습니다')
|
||||
expect(lineOf(wrapper, 'ready')).toBe('2 MB · 보안 검사 중이지만 보낼 수 있습니다')
|
||||
expect(wrapper.find('#compose-send-why').exists()).toBe(false)
|
||||
})
|
||||
|
||||
|
||||
@@ -216,7 +216,7 @@ describe('ComposePage — 보내기 취소 · 예약 발송 (9단계)', () => {
|
||||
expect(toasts().map((toast) => toast.kind)).toEqual(['error'])
|
||||
})
|
||||
|
||||
it('일부가 받지 못하면 빨간 알림 "보냈지만 1명은 받지 못합니다 — …" 에도 [보내기 취소] (같은 마감)', async () => {
|
||||
it('일부가 받지 못하면 빨간 알림 "보냈지만 1명은 받지 못합니다. …" 에도 [보내기 취소] (같은 마감)', async () => {
|
||||
const { wrapper } = await mountCompose(server([undoReply([NOBODY])]))
|
||||
await addRecipient(wrapper, 'doyun.lee@arasystem.it.kr')
|
||||
await addRecipient(wrapper, NOBODY.email)
|
||||
@@ -226,7 +226,7 @@ describe('ComposePage — 보내기 취소 · 예약 발송 (9단계)', () => {
|
||||
expect(toasts()).toEqual([
|
||||
expect.objectContaining({
|
||||
kind: 'error',
|
||||
message: '보냈지만 1명은 받지 못합니다 — nobody@arasystem.it.kr (없는 주소)',
|
||||
message: '보냈지만 1명은 받지 못합니다. nobody@arasystem.it.kr (없는 주소)',
|
||||
action: expect.objectContaining({ label: '보내기 취소' }),
|
||||
expiresAt: NOW + 10_000,
|
||||
}),
|
||||
@@ -369,7 +369,7 @@ describe('ComposePage — 보내기 취소 · 예약 발송 (9단계)', () => {
|
||||
const dialog = wrapper.get('#compose-schedule-title').element.closest('dialog')
|
||||
expect(dialog?.hasAttribute('open')).toBe(true)
|
||||
// 이 PC 의 검사로는 맞는 시각이다(시계가 어긋남) — 가까운 쪽(5분) 한도로 알린다
|
||||
expect(wrapper.get('#compose-schedule-error').text()).toBe('지금부터 5분 뒤 이후의 시각을 고르세요.')
|
||||
expect(wrapper.get('#compose-schedule-error').text()).toBe('5분 뒤보다 이른 시각은 고를 수 없습니다. 더 늦은 시각을 골라 주세요.')
|
||||
expect((wrapper.get('[data-schedule-preset="custom"]').element as HTMLInputElement).checked).toBe(true)
|
||||
const chosen = new Date(preset.at)
|
||||
expect((wrapper.get('[data-schedule-day]').element as HTMLInputElement).value).toBe(formatDate(chosen))
|
||||
|
||||
@@ -319,7 +319,7 @@ describe('ComposePage', () => {
|
||||
expect(useToastStore().toasts).toEqual([
|
||||
expect.objectContaining({
|
||||
kind: 'error',
|
||||
message: '보냈지만 1명은 받지 못했습니다 — nobody@arasystem.it.kr (없는 주소)',
|
||||
message: '보냈지만 1명은 받지 못했습니다. nobody@arasystem.it.kr (없는 주소)',
|
||||
// 성공 알림과 같은 곳 — 링크가 있으니 10초 (toast.store)
|
||||
action: { label: '보낸편지함 보기', to: { name: 'mail-mailbox', params: { mailboxId: 'sent' } } },
|
||||
}),
|
||||
@@ -538,7 +538,7 @@ describe('ComposePage', () => {
|
||||
const { wrapper } = await mountCompose('/mail/compose', replyApi)
|
||||
|
||||
expect(wrapper.get('.compose__attach-hint').text()).toBe(
|
||||
'파일당 최대 10MB · 합계 30MB · 최대 20개 · 더 큰 파일은 링크로 보냅니다 (파일당 2GB · 10개까지)',
|
||||
'파일당 최대 10MB · 합계 30MB · 최대 20개 · 더 큰 파일은 파일당 2GB, 10개까지 링크로 보냅니다',
|
||||
)
|
||||
})
|
||||
|
||||
@@ -547,7 +547,7 @@ describe('ComposePage', () => {
|
||||
|
||||
expect(useMailStore().quota).toBeNull()
|
||||
expect(wrapper.get('.compose__attach-hint').text()).toBe(
|
||||
'파일당 최대 20MB · 합계 25MB · 최대 20개 · 더 큰 파일은 링크로 보냅니다 (파일당 2GB · 10개까지)',
|
||||
'파일당 최대 20MB · 합계 25MB · 최대 20개 · 더 큰 파일은 파일당 2GB, 10개까지 링크로 보냅니다',
|
||||
)
|
||||
})
|
||||
|
||||
@@ -1483,7 +1483,7 @@ describe('ComposePage', () => {
|
||||
expect(dialogOpen(wrapper)).toBe(false)
|
||||
expect(blocksUnload()).toBe(false)
|
||||
expect(useToastStore().toasts.map((toast) => [toast.kind, toast.message])).toEqual([
|
||||
['error', '보냈지만 1명은 받지 못했습니다 — nobody@arasystem.it.kr (받는 쪽이 거절)'],
|
||||
['error', '보냈지만 1명은 받지 못했습니다. nobody@arasystem.it.kr (받는 쪽이 거절)'],
|
||||
])
|
||||
})
|
||||
|
||||
@@ -1567,7 +1567,7 @@ describe('ComposePage', () => {
|
||||
await flushPromises()
|
||||
|
||||
expect(useToastStore().toasts.map((toast) => [toast.kind, toast.message])).toEqual([
|
||||
['error', '보냈지만 1명은 받지 못했습니다 — infra@arasystem.it.kr (지금은 받을 수 없음)'],
|
||||
['error', '보냈지만 1명은 받지 못했습니다. infra@arasystem.it.kr (지금은 받을 수 없음)'],
|
||||
])
|
||||
expect(router.currentRoute.value.fullPath).toBe('/mail/sent')
|
||||
})
|
||||
|
||||
@@ -1256,7 +1256,7 @@ watch(hasMissingLarge, (missing) => {
|
||||
v-model="subject"
|
||||
type="text"
|
||||
class="compose__subject"
|
||||
placeholder="제목을 입력하세요"
|
||||
placeholder="제목을 입력해 주세요"
|
||||
autocomplete="off"
|
||||
:maxlength="MAIL_SUBJECT_MAX_LENGTH"
|
||||
:disabled="preparing"
|
||||
|
||||
@@ -126,12 +126,12 @@ describe('예약됨 · 보낸편지함 (9단계)', () => {
|
||||
}))
|
||||
}
|
||||
|
||||
it('분할 보기 — 예약한 메일을 보낼 시각 차례로: 받는 사람 · 제목 · 보낼 시각(청록) · 머리 "예약됨 · 2통 · 보낼 시각 차례"', async () => {
|
||||
it('분할 보기 — 예약한 메일을 보낼 시각 차례로: 받는 사람 · 제목 · 보낼 시각(청록) · 머리 "예약됨 · 2통 · 보낼 시각이 이른 순서"', async () => {
|
||||
const { wrapper } = await mountAt('/mail/scheduled')
|
||||
|
||||
expect(requests.find((request) => request.url === '/scheduled-messages')?.params).toEqual({ limit: 20 })
|
||||
expect(wrapper.get('#scheduled-list-title').text()).toBe('예약됨')
|
||||
expect(wrapper.get('.list-header__count').text()).toBe('2통 · 보낼 시각 차례')
|
||||
expect(wrapper.get('.list-header__count').text()).toBe('2통 · 보낼 시각이 이른 순서')
|
||||
expect(rows(wrapper)).toEqual([
|
||||
{
|
||||
who: '받는 사람: 이도윤',
|
||||
|
||||
@@ -114,7 +114,7 @@ describe('MailboxPage — 검색 결과', () => {
|
||||
|
||||
expect(wrapper.text()).toContain('‘회의’에 맞는 메일이 없습니다')
|
||||
expect(wrapper.text()).toContain(
|
||||
'낱말 단위로 찾습니다. 붙여 쓴 말의 일부(‘서버이전’의 ‘서버’)나 제목 맨 앞의 말머리([공지] · Re:)로는 찾지 못합니다.',
|
||||
'낱말 단위로 찾습니다. ‘서버이전’의 ‘서버’처럼 붙여 쓴 말의 일부나, 제목 맨 앞의 [공지]나 Re: 같은 말머리로는 찾지 못합니다.',
|
||||
)
|
||||
await wrapper.get('.search-empty__all').trigger('click')
|
||||
await flushPromises()
|
||||
@@ -124,7 +124,7 @@ describe('MailboxPage — 검색 결과', () => {
|
||||
expect(listRequests().at(-1)?.params).toEqual({ q: '회의', limit: 20 })
|
||||
// 전체에서 찾았으면 더 넓힐 곳이 없다 — 안내 줄만
|
||||
expect(wrapper.find('.search-empty__all').exists()).toBe(false)
|
||||
expect(wrapper.get('.search-scope__hint').text()).toBe('휴지통 · 스팸메일함은 빼고 찾습니다')
|
||||
expect(wrapper.get('.search-scope__hint').text()).toBe('휴지통과 스팸메일함은 빼고 찾습니다')
|
||||
})
|
||||
|
||||
it('범위 줄에서 "전체 메일함"을 누르면 기록을 쌓지 않고 전체에서 다시 찾고, "받은편지함"을 누르면 돌아온다', async () => {
|
||||
@@ -167,7 +167,7 @@ describe('MailboxPage — 검색 결과', () => {
|
||||
useMailStore().rememberSearchOrigin({ mailboxId: 'sent', location: '/mail/sent' })
|
||||
const { wrapper, router } = await mountAt(`/mail/search?q=${Q}&in=sent`)
|
||||
|
||||
expect(wrapper.get('.search-header__close').attributes('title')).toBe('검색 닫기 — 보낸편지함으로')
|
||||
expect(wrapper.get('.search-header__close').attributes('title')).toBe('검색을 닫고 보낸편지함으로 돌아가기')
|
||||
await wrapper.get('.search-header__close').trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
|
||||
@@ -147,10 +147,10 @@ describe('AccountAppPasswordsPage', () => {
|
||||
const settings = wrapper.get('[data-mail-client-settings]').text()
|
||||
expect(settings).toContain('mx1.arasystem.it.kr · 포트 993 · SSL/TLS')
|
||||
expect(settings).toContain('mx1.arasystem.it.kr · 포트 465 · SSL/TLS')
|
||||
expect(settings).toContain('ara.kim@arasystem.it.kr — 메일 주소 전체')
|
||||
expect(settings).toContain('여기서 만든 앱 비밀번호 — 웹메일 비밀번호가 아닙니다')
|
||||
expect(settings).toContain('ara.kim@arasystem.it.kr · 메일 주소 전체')
|
||||
expect(settings).toContain('웹메일 비밀번호가 아닌, 여기서 만든 앱 비밀번호')
|
||||
expect(wrapper.get('[data-app-password-notes]').text()).toContain(
|
||||
'웹메일 비밀번호를 바꾸면 앱 비밀번호도 모두 지워집니다 — 메일 프로그램마다 새로 만들어 주세요.',
|
||||
'웹메일 비밀번호를 바꾸면 앱 비밀번호도 모두 지워지니, 메일 프로그램마다 새로 만들어 주세요.',
|
||||
)
|
||||
})
|
||||
|
||||
@@ -214,7 +214,7 @@ describe('AccountAppPasswordsPage', () => {
|
||||
expect(dialogOpen('app-password-create-title')).toBe(true)
|
||||
expect(document.activeElement?.id).toBe('app-password-name')
|
||||
expect(wrapper.get('#app-password-name').attributes('maxlength')).toBe('40')
|
||||
expect(wrapper.get('#app-password-name-help').text()).toBe('어느 프로그램 · 기기에서 쓰는지 알아볼 이름입니다. 40자까지.')
|
||||
expect(wrapper.get('#app-password-name-help').text()).toBe('어느 프로그램이나 기기에서 쓰는지 알아볼 수 있게 지어 주세요. 40자까지 쓸 수 있습니다.')
|
||||
|
||||
await create(wrapper, ' ')
|
||||
expect(wrapper.get('#app-password-name-help').text()).toBe('이름을 넣어 주세요.')
|
||||
@@ -237,7 +237,7 @@ describe('AccountAppPasswordsPage', () => {
|
||||
expect(dialogOpen('app-password-create-title')).toBe(false)
|
||||
expect(dialogOpen('app-password-result-title')).toBe(true)
|
||||
expect(document.querySelector('#app-password-result-message')?.textContent?.trim()).toBe(
|
||||
'‘집 노트북 메일’ — 메일 프로그램의 비밀번호 칸에 아래 값을 넣어 주세요.',
|
||||
'‘집 노트북 메일’ 앱 비밀번호입니다. 메일 프로그램의 비밀번호 칸에 아래 값을 넣어 주세요.',
|
||||
)
|
||||
expect(wrapper.findAll('.result__group').map((group) => group.text())).toEqual([
|
||||
'app_',
|
||||
@@ -370,7 +370,7 @@ describe('AccountAppPasswordsPage', () => {
|
||||
replies[0]?.(fail(400, 'VAL_001'))
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.get('#app-password-name-help').text()).toBe('어느 프로그램 · 기기에서 쓰는지 알아볼 이름입니다. 40자까지.')
|
||||
expect(wrapper.get('#app-password-name-help').text()).toBe('어느 프로그램이나 기기에서 쓰는지 알아볼 수 있게 지어 주세요. 40자까지 쓸 수 있습니다.')
|
||||
expect(toastMessages()).toEqual([])
|
||||
})
|
||||
|
||||
|
||||
@@ -193,7 +193,7 @@ describe('AccountLoginHistoryPage', () => {
|
||||
])
|
||||
expect(wrapper.get('[data-sessions-end-others]').text()).toBe('다른 곳 모두 로그아웃')
|
||||
const note = wrapper.get('[data-sessions-note]')
|
||||
expect(note.text()).toBe('웹메일 로그인만 보입니다. 메일 프로그램은 앱 비밀번호를 지워 끊습니다.')
|
||||
expect(note.text()).toBe('웹메일 로그인만 보입니다. 메일 프로그램의 연결을 끊으려면 앱 비밀번호를 지워 주세요.')
|
||||
expect(note.get('a').attributes('href')).toBe('/settings/account/app-passwords')
|
||||
})
|
||||
|
||||
@@ -204,7 +204,7 @@ describe('AccountLoginHistoryPage', () => {
|
||||
expect(wrapper.get('[data-session="old"] [data-session-device]').text()).toBe('알 수 없는 브라우저')
|
||||
})
|
||||
|
||||
it('줄의 [로그아웃]은 묻지 않고 바로 끊는다 — 「‘Edge · Windows’의 로그인을 끊었습니다.」 · 줄이 빠지고 초점은 다음 줄', async () => {
|
||||
it('줄의 [로그아웃]은 묻지 않고 바로 끊는다 — 「‘Edge · Windows’에서 로그아웃했습니다.」 · 줄이 빠지고 초점은 다음 줄', async () => {
|
||||
const wrapper = await mountPage()
|
||||
const button = wrapper.findAll('[data-session-end]')[0]
|
||||
;(button?.element as HTMLButtonElement).focus()
|
||||
@@ -214,7 +214,7 @@ describe('AccountLoginHistoryPage', () => {
|
||||
expect(requests.filter((request) => request.method === 'DELETE').map((request) => request.url)).toEqual([
|
||||
'/auth/sessions/edge',
|
||||
])
|
||||
expect(toastMessages()).toEqual(['‘Edge · Windows’의 로그인을 끊었습니다.'])
|
||||
expect(toastMessages()).toEqual(['‘Edge · Windows’에서 로그아웃했습니다.'])
|
||||
expect(sessionRows(wrapper).map((row) => row[0])).toEqual(['Chrome · Windows', 'Safari · iOS'])
|
||||
expect(wrapper.get('[data-sessions-count]').text()).toBe('웹메일 2곳')
|
||||
expect(document.activeElement?.getAttribute('aria-label')).toBe('‘Safari · iOS’ 로그아웃')
|
||||
@@ -225,11 +225,11 @@ describe('AccountLoginHistoryPage', () => {
|
||||
await wrapper.findAll('[data-session-end]')[1]?.trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
expect(toastMessages()).toEqual(['‘Safari · iOS’의 로그인을 끊었습니다.'])
|
||||
expect(toastMessages()).toEqual(['‘Safari · iOS’에서 로그아웃했습니다.'])
|
||||
expect(sessionRows(wrapper)).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('[다른 곳 모두 로그아웃] → 확인 창(처음 초점 「취소」 · 끊을 곳의 이름) → 「다른 곳의 로그인 2곳을 끊었습니다.」 — 지금 이 브라우저만 남는다', async () => {
|
||||
it('[다른 곳 모두 로그아웃] → 확인 창(처음 초점 「취소」 · 끊을 곳의 이름) → 「다른 2곳에서 로그아웃했습니다.」 — 지금 이 브라우저만 남는다', async () => {
|
||||
const wrapper = await mountPage()
|
||||
await wrapper.get('[data-sessions-end-others]').trigger('click')
|
||||
await flushPromises()
|
||||
@@ -238,9 +238,9 @@ describe('AccountLoginHistoryPage', () => {
|
||||
expect(dialog?.hasAttribute('open')).toBe(true)
|
||||
expect(dialog?.getAttribute('role')).toBe('alertdialog')
|
||||
expect(document.querySelector('#end-other-sessions-message')?.textContent?.trim()).toBe(
|
||||
'이 브라우저를 뺀 2곳(Edge · Windows, Safari · iOS)의 웹메일 로그인을 끊습니다. 끊긴 곳은 다음 동작에서 바로 로그인 화면으로 돌아갑니다.',
|
||||
'이 브라우저를 뺀 2곳(Edge · Windows, Safari · iOS)의 웹메일에서 로그아웃합니다. 그곳에서 다음에 무엇이든 누르면 바로 로그인 화면이 나옵니다.',
|
||||
)
|
||||
expect(dialog?.textContent).toContain('메일 프로그램은 끊기지 않습니다 — 앱 비밀번호를 지워 끊어 주세요.')
|
||||
expect(dialog?.textContent).toContain('메일 프로그램은 로그아웃되지 않습니다. 연결을 끊으려면 앱 비밀번호를 지워 주세요.')
|
||||
expect(document.activeElement?.hasAttribute('data-end-others-cancel')).toBe(true)
|
||||
// 데이터를 지우는 일이 아니다 — 청록 주 단추
|
||||
expect(wrapper.get('[data-end-others-confirm]').classes()).not.toContain('modal-dialog__action--danger')
|
||||
@@ -249,12 +249,12 @@ describe('AccountLoginHistoryPage', () => {
|
||||
await flushPromises()
|
||||
|
||||
expect(requests.filter((request) => request.method === 'DELETE').map((request) => request.url)).toEqual(['/auth/sessions'])
|
||||
expect(toastMessages()).toEqual(['다른 곳의 로그인 2곳을 끊었습니다.'])
|
||||
expect(toastMessages()).toEqual(['다른 2곳에서 로그아웃했습니다.'])
|
||||
expect(dialog?.hasAttribute('open')).toBe(false)
|
||||
expect(sessionRows(wrapper).map((row) => row[0])).toEqual(['Chrome · Windows'])
|
||||
expect(wrapper.find('[data-sessions-end-others]').exists()).toBe(false)
|
||||
expect(wrapper.get('[data-sessions-note]').text()).toBe(
|
||||
'다른 곳에 로그인된 브라우저가 없습니다. 웹메일 로그인만 보입니다. 메일 프로그램은 앱 비밀번호를 지워 끊습니다.',
|
||||
'다른 곳에 로그인된 브라우저가 없습니다. 웹메일 로그인만 보입니다. 메일 프로그램의 연결을 끊으려면 앱 비밀번호를 지워 주세요.',
|
||||
)
|
||||
expect(document.activeElement?.id).toBe('login-sessions-title')
|
||||
})
|
||||
@@ -273,10 +273,10 @@ describe('AccountLoginHistoryPage', () => {
|
||||
await wrapper.get('[data-sessions-end-others]').trigger('click')
|
||||
await flushPromises()
|
||||
expect(document.querySelector('#end-other-sessions-title')?.textContent?.trim()).toBe(
|
||||
'이 브라우저를 포함해 모든 곳의 로그인을 끊을까요?',
|
||||
'이 브라우저를 포함해 모든 곳에서 로그아웃할까요?',
|
||||
)
|
||||
expect(document.querySelector('#end-other-sessions-message')?.textContent?.trim()).toBe(
|
||||
'이 브라우저를 알아보지 못해 이 브라우저를 포함한 3곳(Chrome · Windows, Edge · Windows, Safari · iOS)의 웹메일 로그인을 끊습니다. 이 브라우저도 로그아웃되어 로그인 화면으로 돌아갑니다.',
|
||||
'이 브라우저를 알아보지 못해서 이 브라우저를 포함한 3곳(Chrome · Windows, Edge · Windows, Safari · iOS)의 웹메일에서 로그아웃합니다. 이 브라우저도 로그아웃되어 로그인 화면으로 돌아갑니다.',
|
||||
)
|
||||
|
||||
await wrapper.get('[data-end-others-confirm]').trigger('click')
|
||||
@@ -288,7 +288,7 @@ describe('AccountLoginHistoryPage', () => {
|
||||
'GET /auth/sessions',
|
||||
'POST /auth/refresh',
|
||||
])
|
||||
expect(toastMessages()).toContain('이 브라우저를 포함한 3곳의 로그인을 끊었습니다.')
|
||||
expect(toastMessages()).toContain('이 브라우저를 포함해 3곳에서 로그아웃했습니다.')
|
||||
// 빈 목록을 그려 두지 않는다 — 다시 받은 요청이 세션 만료로 이어졌다
|
||||
expect(wrapper.find('[data-session]').exists()).toBe(false)
|
||||
expect(useAuthStore().sessionExpired).toBe(true)
|
||||
@@ -329,11 +329,11 @@ describe('AccountLoginHistoryPage', () => {
|
||||
|
||||
expect(wrapper.get('[data-events-hint]').text()).toBe('최근 90일 · 웹메일과 메일 프로그램')
|
||||
expect(eventRows(wrapper)).toEqual([
|
||||
[time(0), '웹메일', '211.186.95.252', 'Chrome · Windows', '성공 · 2단계 인증'],
|
||||
[time(1), '웹메일', '211.186.95.252', 'Chrome · Windows', '실패 — 코드 틀림'],
|
||||
[time(0), '웹메일', '211.186.95.252', 'Chrome · Windows', '2단계 인증으로 성공'],
|
||||
[time(1), '웹메일', '211.186.95.252', 'Chrome · Windows', '코드가 틀려 실패'],
|
||||
[time(2), '메일 프로그램 · IMAP앱 비밀번호 ‘회사 PC 아웃룩’', '211.186.95.252', '—알 수 없음', '성공'],
|
||||
[time(3), '메일 프로그램 · SMTP', '45.133.10.21', '—알 수 없음', '실패 — 앱 비밀번호 필요'],
|
||||
[time(4), '웹메일', '211.186.95.252', '알 수 없는 브라우저', '실패 — 비밀번호 틀림'],
|
||||
[time(3), '메일 프로그램 · SMTP', '45.133.10.21', '—알 수 없음', '앱 비밀번호가 필요해 실패'],
|
||||
[time(4), '웹메일', '211.186.95.252', '알 수 없는 브라우저', '비밀번호가 틀려 실패'],
|
||||
])
|
||||
expect(eventRows(wrapper)[0]?.[0]).toBe('오늘 오전 9:30')
|
||||
expect(wrapper.findAll('[data-event-result]').map((cell) => cell.classes().includes('events__result--failure'))).toEqual([
|
||||
@@ -363,9 +363,9 @@ describe('AccountLoginHistoryPage', () => {
|
||||
expect(all.attributes('aria-pressed')).toBe('false')
|
||||
expect(wrapper.get('[data-events-hint]').text()).toBe('최근 90일 · 실패 4번')
|
||||
expect(eventRows(wrapper).map((row) => row[4])).toEqual([
|
||||
'실패 — 코드 틀림',
|
||||
'실패 — 앱 비밀번호 필요',
|
||||
'실패 — 비밀번호 틀림',
|
||||
'코드가 틀려 실패',
|
||||
'앱 비밀번호가 필요해 실패',
|
||||
'비밀번호가 틀려 실패',
|
||||
])
|
||||
|
||||
// 같은 보기를 다시 누르면 받지 않는다
|
||||
@@ -374,7 +374,7 @@ describe('AccountLoginHistoryPage', () => {
|
||||
expect(eventRequests()).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('빈 상태(실패만) — "최근 90일 동안 실패한 로그인이 없습니다" / "[전체]를 누르면 성공한 로그인도 봅니다."', async () => {
|
||||
it('빈 상태(실패만) — "최근 90일 동안 실패한 로그인이 없습니다" / "[전체]를 누르면 성공한 로그인도 볼 수 있습니다."', async () => {
|
||||
const wrapper = await mountPage({
|
||||
events: (request) => (request.params.isSuccess === false ? ok(pageOf([], null, 0)) : ok(pageOf(EVENTS))),
|
||||
})
|
||||
@@ -383,7 +383,7 @@ describe('AccountLoginHistoryPage', () => {
|
||||
|
||||
expect(wrapper.find('[data-login-event]').exists()).toBe(false)
|
||||
expect(wrapper.get('[data-events-empty]').text()).toContain('최근 90일 동안 실패한 로그인이 없습니다')
|
||||
expect(wrapper.get('[data-events-empty]').text()).toContain('[전체]를 누르면 성공한 로그인도 봅니다.')
|
||||
expect(wrapper.get('[data-events-empty]').text()).toContain('[전체]를 누르면 성공한 로그인도 볼 수 있습니다.')
|
||||
expect(wrapper.get('[data-events-hint]').text()).toBe('최근 90일 · 실패 0번')
|
||||
})
|
||||
|
||||
@@ -398,7 +398,7 @@ describe('AccountLoginHistoryPage', () => {
|
||||
},
|
||||
})
|
||||
expect(wrapper.get('[data-events-more]').text()).toBe('더 보기')
|
||||
expect(wrapper.text()).toContain('최근 순서 · 20개씩. 90일이 지난 기록은 지웁니다.')
|
||||
expect(wrapper.text()).toContain('최근 순서로 20개씩 보입니다. 90일이 지난 기록은 지워집니다.')
|
||||
|
||||
await wrapper.get('[data-events-filter="failures"]').trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
@@ -53,12 +53,12 @@ describe('AccountMailClientsPage', () => {
|
||||
const wrapper = await mountPage()
|
||||
|
||||
expect(wrapper.get('h1').text()).toBe('메일 프로그램 연결')
|
||||
expect(wrapper.text()).toContain('아웃룩 · 썬더버드 · 휴대폰 메일 앱에서 회사 메일을 보는 방법입니다.')
|
||||
expect(wrapper.text()).toContain('아웃룩, 썬더버드, 휴대폰 메일 앱에서 회사 메일을 보는 방법입니다.')
|
||||
expect(settingsRows(wrapper)).toEqual([
|
||||
'mx1.arasystem.it.kr · 포트 993 · SSL/TLS',
|
||||
'mx1.arasystem.it.kr · 포트 465 · SSL/TLS',
|
||||
`${USER.email} — 메일 주소 전체`,
|
||||
'앱 비밀번호 — 웹메일 비밀번호가 아닙니다',
|
||||
`${USER.email} · 메일 주소 전체`,
|
||||
'웹메일 비밀번호가 아닌 앱 비밀번호',
|
||||
])
|
||||
expect(requests.map((request) => request.url)).toEqual(['/two-factor'])
|
||||
})
|
||||
@@ -72,10 +72,10 @@ describe('AccountMailClientsPage', () => {
|
||||
expect(wrapper.get('[data-mail-clients-create]').attributes('href')).toBe('/settings/account/app-passwords')
|
||||
})
|
||||
|
||||
it('켜지 않은 직원 — 비밀번호 줄은 「앱 비밀번호(권장) …」, 청록 상자 「앱 비밀번호로 연결하기를 권합니다」', async () => {
|
||||
it('켜지 않은 직원 — 비밀번호 줄은 「앱 비밀번호를 권합니다. …」, 청록 상자 「앱 비밀번호로 연결하기를 권합니다」', async () => {
|
||||
const wrapper = await mountPage(() => ok(OFF))
|
||||
|
||||
expect(settingsRows(wrapper).at(-1)).toBe('앱 비밀번호(권장) — 2단계 인증을 켜지 않았으면 웹메일 비밀번호도 됩니다')
|
||||
expect(settingsRows(wrapper).at(-1)).toBe('앱 비밀번호를 권합니다. 2단계 인증을 켜지 않았다면 웹메일 비밀번호로도 연결됩니다.')
|
||||
const notice = wrapper.get('[data-mail-clients-notice]')
|
||||
expect(notice.attributes('data-mail-clients-notice')).toBe('recommend')
|
||||
expect(notice.text()).toContain('앱 비밀번호로 연결하기를 권합니다')
|
||||
@@ -92,7 +92,7 @@ describe('AccountMailClientsPage', () => {
|
||||
const wrapper = await mountPage()
|
||||
|
||||
expect(wrapper.get('[data-mail-clients-cloud-warning]').text()).toBe(
|
||||
'새 Outlook · 휴대폰 Outlook 앱은 메일을 Microsoft 클라우드를 거쳐 받습니다 — 회사 정책에 따라 쓰지 않는 것이 좋습니다.',
|
||||
'새 Outlook과 휴대폰 Outlook 앱은 메일을 Microsoft 클라우드를 거쳐 받습니다. 회사 정책에 따라 쓰지 않는 것이 좋습니다.',
|
||||
)
|
||||
const buttons = wrapper.findAll('[data-mail-client] h3 button')
|
||||
expect(buttons.map((button) => button.text())).toEqual([
|
||||
@@ -118,14 +118,14 @@ describe('AccountMailClientsPage', () => {
|
||||
expect(panel.attributes('role')).toBe('region')
|
||||
expect(panel.attributes('aria-labelledby')).toBe('mail-client-outlook-button')
|
||||
expect(panel.findAll('li')).toHaveLength(5)
|
||||
expect(panel.text()).toContain('Outlook 에서 [파일] → [계정 추가]를 누릅니다.')
|
||||
expect(panel.text()).toContain('Outlook에서 [파일] → [계정 추가]를 누릅니다.')
|
||||
// 호스트 이름은 굵게
|
||||
expect(panel.get('strong').text()).toBe('mx1.arasystem.it.kr')
|
||||
|
||||
await wrapper.get('#mail-client-android-button').trigger('click')
|
||||
expect(outlook.attributes('aria-expanded')).toBe('false')
|
||||
expect(wrapper.find('#mail-client-outlook-panel').exists()).toBe(false)
|
||||
expect(wrapper.get('#mail-client-android-panel').text()).toContain('보안 유형은 「SSL/TLS」를 넣고 [다음]을 누릅니다.')
|
||||
expect(wrapper.get('#mail-client-android-panel').text()).toContain('보안 유형은 [SSL/TLS]를 고른 뒤 [다음]을 누릅니다.')
|
||||
|
||||
await wrapper.get('#mail-client-android-button').trigger('click')
|
||||
expect(wrapper.findAll('[role="region"]')).toHaveLength(0)
|
||||
@@ -136,6 +136,6 @@ describe('AccountMailClientsPage', () => {
|
||||
|
||||
const notes = wrapper.get('[data-mail-clients-notes]').findAll('p').map((note) => note.text())
|
||||
expect(notes).toHaveLength(4)
|
||||
expect(notes[1]).toContain('POP3 로는 연결할 수 없습니다 — 계정 유형은 IMAP 을 골라 주세요.')
|
||||
expect(notes[1]).toContain('POP3로는 연결할 수 없으니 계정 유형은 IMAP을 골라 주세요.')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -79,18 +79,18 @@ describe('AccountPasswordPage', () => {
|
||||
expect(wrapper.get('#password-current').attributes('autocomplete')).toBe('current-password')
|
||||
expect(wrapper.get('#password-next').attributes('autocomplete')).toBe('new-password')
|
||||
expect(wrapper.get('#password-next-hint').text()).toBe(
|
||||
'8자 이상 · 지금 비밀번호와 달라야 합니다. 영문 · 숫자 · 기호를 섞으면 더 안전합니다.',
|
||||
'8자 이상이고 지금 비밀번호와 달라야 합니다. 영문, 숫자, 기호를 섞으면 더 안전합니다.',
|
||||
)
|
||||
// 모든 직원 — 다른 곳 로그인이 끊기고 앱 비밀번호도 모두 지워진다 (12단계)
|
||||
expect(wrapper.get('[data-password-note]').text()).toContain(
|
||||
'바꾸면 이 브라우저를 뺀 다른 기기 · 브라우저의 웹메일 로그인이 끊깁니다.',
|
||||
'바꾸면 이 브라우저를 빼고 다른 기기와 브라우저의 웹메일에서 로그아웃됩니다.',
|
||||
)
|
||||
expect(wrapper.get('[data-password-note]').text()).toContain(
|
||||
'앱 비밀번호도 모두 지워집니다 — 메일 프로그램은 새 앱 비밀번호로 다시 연결해 주세요.',
|
||||
'앱 비밀번호도 모두 지워지니, 메일 프로그램은 새 앱 비밀번호로 다시 연결해 주세요.',
|
||||
)
|
||||
// 2단계 인증이 꺼진 직원 — 머리 설명은 그대로, 코드 칸이 없다
|
||||
expect(wrapper.get('.settings-page__description').text()).toBe(
|
||||
'웹메일과 휴대폰 메일 앱(IMAP)이 함께 쓰는 메일 계정의 비밀번호입니다.',
|
||||
'웹메일에 로그인할 때와 휴대폰 메일 앱을 연결할 때 함께 쓰는 비밀번호입니다.',
|
||||
)
|
||||
expect(wrapper.find('#password-code').exists()).toBe(false)
|
||||
|
||||
@@ -148,7 +148,7 @@ describe('AccountPasswordPage', () => {
|
||||
// 서버가 엔진을 두 번 부르고 다른 세션을 끊는다 — 요청 한도는 보통(API_TIMEOUT_MS)보다 긴 30초
|
||||
expect(PASSWORD_CHANGE_TIMEOUT_MS).toBe(30_000)
|
||||
expect(passwordRequests()[0]?.config.timeout).toBe(PASSWORD_CHANGE_TIMEOUT_MS)
|
||||
expect(wrapper.get('#password-status').text()).toBe('비밀번호를 바꿨습니다. 다른 기기 · 브라우저의 로그인은 끊었습니다.')
|
||||
expect(wrapper.get('#password-status').text()).toBe('비밀번호를 바꿨습니다. 다른 기기와 브라우저에서는 로그아웃했습니다.')
|
||||
for (const id of ['password-current', 'password-next', 'password-confirm']) {
|
||||
expect((wrapper.get(`#${id}`).element as HTMLInputElement).value).toBe('')
|
||||
}
|
||||
@@ -234,7 +234,7 @@ describe('AccountPasswordPage', () => {
|
||||
autocomplete: 'one-time-code',
|
||||
'aria-describedby': 'password-code-hint',
|
||||
})
|
||||
expect(wrapper.get('#password-code-hint').text()).toBe('코드는 30초마다 바뀝니다 — 다른 칸을 다 쓴 뒤 넣어 주세요.')
|
||||
expect(wrapper.get('#password-code-hint').text()).toBe('코드는 30초마다 바뀌니 다른 칸을 다 채운 뒤에 넣어 주세요.')
|
||||
})
|
||||
|
||||
it('코드는 숫자만 받는다 — 붙여 넣은 "123 456" · "123-456" 은 다듬고, 6자리가 아니면 보내지 않는다(사전 문구 AUTH_007)', async () => {
|
||||
|
||||
@@ -120,10 +120,10 @@ describe('AccountTwoFactorPage', () => {
|
||||
expect(wrapper.find('[data-two-factor-notes]').exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('켜짐 — 「켜짐 — 2026년 10월 31일부터」 · [2단계 인증 끄기] · 안내 상자 / 켠 때를 모르면 「켜짐」', async () => {
|
||||
it('켜짐 — 「2026년 10월 31일부터 켜짐」 · [2단계 인증 끄기] · 안내 상자 / 켠 때를 모르면 「켜짐」', async () => {
|
||||
const wrapper = await mountPage({ status: () => ok(ON) })
|
||||
|
||||
expect(wrapper.get('[data-two-factor-label]').text()).toBe('켜짐 — 2026년 10월 31일부터')
|
||||
expect(wrapper.get('[data-two-factor-label]').text()).toBe('2026년 10월 31일부터 켜짐')
|
||||
expect(wrapper.get('[data-two-factor-label]').classes()).toContain('two-factor__state-label--on')
|
||||
expect(wrapper.get('[data-two-factor-disable]').text()).toBe('2단계 인증 끄기')
|
||||
expect(wrapper.find('[data-two-factor-warning]').exists()).toBe(false)
|
||||
@@ -176,7 +176,7 @@ describe('AccountTwoFactorPage', () => {
|
||||
['K7QF', '2M4X', 'HB6R', 'TZ3N'],
|
||||
['PW5C', 'YL7D', 'JQ2V', 'MGAS'],
|
||||
])
|
||||
expect(wrapper.text()).toContain('앱에는 「ARA SYSTEM (ara.kim@arasystem.it.kr)」로 저장됩니다.')
|
||||
expect(wrapper.text()).toContain('앱에는 ‘ARA SYSTEM (ara.kim@arasystem.it.kr)’로 저장됩니다.')
|
||||
expect(document.activeElement?.id).toBe('two-factor-enable-code')
|
||||
expect(wrapper.get('#two-factor-enable-code').attributes()).toMatchObject({
|
||||
inputmode: 'numeric',
|
||||
@@ -243,11 +243,11 @@ describe('AccountTwoFactorPage', () => {
|
||||
// 서버가 엔진을 여러 번 부른다 — 한도는 보통(10초)보다 긴 30초
|
||||
expect(calls('/two-factor/activation')[0]?.config.timeout).toBe(TWO_FACTOR_CHANGE_TIMEOUT_MS)
|
||||
expect(enableDialog()?.hasAttribute('open')).toBe(false)
|
||||
expect(wrapper.get('[data-two-factor-label]').text()).toBe('켜짐 — 2026년 10월 31일부터')
|
||||
expect(wrapper.get('[data-two-factor-label]').text()).toBe('2026년 10월 31일부터 켜짐')
|
||||
const done = wrapper.get('[data-two-factor-done]')
|
||||
expect(done.attributes('role')).toBe('status')
|
||||
expect(done.text()).toContain('2단계 인증을 켰습니다')
|
||||
expect(done.text()).toContain('메일 프로그램(Outlook · 썬더버드 · 휴대폰 메일 앱)은 다시 연결할 때 앱 비밀번호가 필요합니다.')
|
||||
expect(done.text()).toContain('Outlook, 썬더버드, 휴대폰 메일 앱 같은 메일 프로그램은 다시 연결할 때 앱 비밀번호가 필요합니다.')
|
||||
expect(done.get('a').text()).toBe('앱 비밀번호 만들기')
|
||||
expect(done.get('a').attributes('href')).toBe('/settings/account/app-passwords')
|
||||
expect(document.activeElement).toBe(done.element)
|
||||
@@ -668,7 +668,7 @@ describe('AccountTwoFactorPage — 필수인 직원 (13단계)', () => {
|
||||
expect(wrapper.get('[data-two-factor-locked]').text()).toBe('회사 정책에 따라 끌 수 없습니다')
|
||||
expect(wrapper.find('[data-two-factor-grace]').exists()).toBe(false)
|
||||
const notes = wrapper.get('[data-two-factor-notes]').text()
|
||||
expect(notes).toContain('휴대폰을 바꿀 때는 관리자에게 2단계 인증 해제를 요청해 주세요 — 다음 로그인 때 새 휴대폰으로 다시 켭니다.')
|
||||
expect(notes).toContain('휴대폰을 바꿀 때는 관리자에게 2단계 인증을 풀어 달라고 요청해 주세요. 다음에 로그인할 때 새 휴대폰으로 다시 켜면 됩니다.')
|
||||
expect(notes).toContain('휴대폰을 잃어버렸거나 인증 앱을 지웠으면 관리자에게 문의해 주세요.')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -119,11 +119,11 @@ describe('MailBlocksPage', () => {
|
||||
expect(empty?.[1]).not.toMatch(/display:\s*none/)
|
||||
})
|
||||
|
||||
it('비었으면 "차단한 주소가 없습니다. 메일의 ⋯ › 보낸 사람 차단으로도 막을 수 있습니다."', async () => {
|
||||
it('비었으면 "차단한 주소가 없습니다. 메일을 읽다가 [⋯] → [보낸 사람 차단]으로도 막을 수 있습니다."', async () => {
|
||||
const wrapper = await mountPage({ blocks: [] })
|
||||
|
||||
expect(wrapper.get('[data-block-empty]').text()).toBe(
|
||||
'차단한 주소가 없습니다. 메일의 ⋯ › 보낸 사람 차단으로도 막을 수 있습니다.',
|
||||
'차단한 주소가 없습니다. 메일을 읽다가 [⋯] → [보낸 사람 차단]으로도 막을 수 있습니다.',
|
||||
)
|
||||
expect(wrapper.get('[data-block-count]').text()).toBe('0 / 500')
|
||||
})
|
||||
@@ -150,12 +150,12 @@ describe('MailBlocksPage', () => {
|
||||
it.each([
|
||||
['형식', 'kim@', '메일 주소나 @도메인 형식으로 써 주세요.'],
|
||||
['이미 차단함', 'NEWS@cloudbrief.example', '이미 차단한 주소입니다.'],
|
||||
['내 주소', 'ara.kim@arasystem.it.kr', '내 주소 · 사내 도메인은 차단할 수 없습니다.'],
|
||||
['사내 도메인', '@arasystem.it.kr', '내 주소 · 사내 도메인은 차단할 수 없습니다.'],
|
||||
['내 주소', 'ara.kim@arasystem.it.kr', '내 주소와 사내 도메인은 차단할 수 없습니다.'],
|
||||
['사내 도메인', '@arasystem.it.kr', '내 주소와 사내 도메인은 차단할 수 없습니다.'],
|
||||
[
|
||||
'여러 사람이 쓰는 메일 서비스',
|
||||
'@gmail.com',
|
||||
'gmail.com 처럼 여러 사람이 쓰는 메일 서비스는 도메인으로 막을 수 없습니다 — 주소로 막아 주세요.',
|
||||
'gmail.com처럼 여러 사람이 쓰는 메일 서비스는 도메인 전체를 막을 수 없습니다. 주소를 하나씩 넣어 막아 주세요.',
|
||||
],
|
||||
])('보내기 전 검사 — %s: 칸 아래 오류(빨강) · 잘못된 입력, 보내지 않는다', async (_label, value, text) => {
|
||||
const wrapper = await mountPage()
|
||||
@@ -247,7 +247,7 @@ describe('MailBlocksPage', () => {
|
||||
})
|
||||
|
||||
describe('엔진에서 우리 규칙이 꺼져 있으면(isActive: false) 머리 아래 노란 줄 — 단추 없음', () => {
|
||||
const INACTIVE = '지금은 차단이 동작하지 않습니다. 메일 서버의 규칙이 다른 곳에서 바뀌었습니다 — 주소를 차단하거나 풀면 다시 켜집니다.'
|
||||
const INACTIVE = '다른 곳에서 메일 설정이 바뀌어 지금은 차단이 되지 않습니다. 주소를 차단하거나 풀면 다시 켜집니다.'
|
||||
|
||||
it('차단한 주소가 있을 때만 보인다(role=status) — 켜져 있거나 비었으면 없다', async () => {
|
||||
const off = await mountPage({ isActive: false })
|
||||
|
||||
@@ -237,24 +237,24 @@ describe('MailFilterPage', () => {
|
||||
expect(wrapper.get('ol').attributes('aria-busy')).toBeUndefined()
|
||||
expect(rows(wrapper).map((item) => item.classes('is-off'))).toEqual([true, false, false, true, false])
|
||||
expect(wrapper.findAll('[data-filter-missing]').map((item) => item.text())).toEqual([
|
||||
'옮길 메일함이 지워져 받은편지함으로 들어옵니다 — 눌러서 고치세요.',
|
||||
'옮길 메일함이 지워져 메일이 받은편지함으로 들어옵니다. 눌러서 고쳐 주세요.',
|
||||
])
|
||||
expect(row(wrapper, 'r5').get('[data-filter-edit]').attributes('aria-label')).toBe('‘알파 프로젝트’ 필터 고치기')
|
||||
// 맨 위의 ▲ · 맨 아래의 ▼ 는 누를 수 없다 (초점은 그대로 받는다)
|
||||
expect(row(wrapper, 'r1').get('[data-filter-up]').attributes('aria-disabled')).toBe('true')
|
||||
expect(row(wrapper, 'r1').get('[data-filter-down]').attributes('aria-disabled')).toBeUndefined()
|
||||
expect(row(wrapper, 'r5').get('[data-filter-down]').attributes('aria-disabled')).toBe('true')
|
||||
expect(wrapper.get('[data-filter-count]').text()).toBe('필터 5개 · 50개까지 만들 수 있습니다. 행을 누르면 고칩니다.')
|
||||
expect(wrapper.get('[data-filter-count]').text()).toBe('필터 5개를 만들었고, 50개까지 만들 수 있습니다. 줄을 누르면 고칠 수 있습니다.')
|
||||
expect(wrapper.get('[data-filter-notes]').findAll('p').map((note) => note.text())).toEqual([
|
||||
'·위에서부터 차례로 적용합니다. 메일함으로 옮기는 필터에 맞으면 그 아래 필터는 적용하지 않습니다 — 순서는 ▲ ▼ 로 바꿉니다.',
|
||||
'·이미 받은 메일에도 적용하려면 필터를 저장할 때 「받은편지함에 이미 있는 메일에도 적용」을 고릅니다. 받은편지함의 메일만 보고, 그 필터 하나만 적용합니다.',
|
||||
'·스팸으로 판정되어 들어오는 메일에는 필터를 적용하지 않습니다 — 그대로 스팸메일함으로 갑니다.',
|
||||
'·전달은 사내 주소로만, 새로 오는 메일만 합니다. 메일 한 통은 한 곳으로만 전달됩니다(위의 필터가 먼저). 전달해도 내 메일함에 그대로 남습니다.',
|
||||
'·부재중 자동 응답은 필터와 함께 동작합니다.',
|
||||
'·위에 있는 필터부터 차례로 메일을 정리합니다. 메일함으로 옮기는 필터에 맞은 메일은 그 아래 필터를 거치지 않습니다. 순서는 ▲ ▼ 로 바꿀 수 있습니다.',
|
||||
'·이미 받은 메일도 정리하려면 필터를 저장할 때 [받은편지함에 이미 있는 메일에도 적용]을 골라 주세요. 받은편지함에 있는 메일만 그 필터 하나로 정리합니다.',
|
||||
'·스팸으로 분류된 메일은 필터를 거치지 않고 그대로 스팸메일함으로 갑니다.',
|
||||
'·전달은 새로 오는 메일만 사내 주소로 합니다. 메일 한 통은 한 곳으로만 전달되고, 전달하는 필터가 여럿이면 위에 있는 필터가 먼저입니다. 전달해도 내 메일함에 그대로 남습니다.',
|
||||
'·필터가 메일을 옮겨도 부재중 자동 응답은 그대로 보냅니다.',
|
||||
])
|
||||
expect(wrapper.get('[data-filter-note-apply] strong').text()).toBe('「받은편지함에 이미 있는 메일에도 적용」')
|
||||
expect(wrapper.get('[data-filter-note-apply] strong').text()).toBe('[받은편지함에 이미 있는 메일에도 적용]')
|
||||
expect(wrapper.get('.settings-page__description').text()).toBe(
|
||||
'받은 메일을 조건에 따라 메일함으로 옮기거나, 읽음 · 중요로 표시하거나, 동료에게 전달합니다. 새로 오는 메일에 적용하고, 고를 때는 받은편지함에 이미 있는 메일에도 적용합니다.',
|
||||
'받은 메일을 조건에 따라 메일함으로 옮기거나, 읽음이나 중요로 표시하거나, 동료에게 전달합니다. 새로 오는 메일을 정리하고, 저장할 때 고르면 받은편지함에 이미 있는 메일도 함께 정리합니다.',
|
||||
)
|
||||
expect(wrapper.find('[data-filter-empty]').exists()).toBe(false)
|
||||
expect(wrapper.find('[data-filter-conflict]').exists()).toBe(false)
|
||||
@@ -403,7 +403,7 @@ describe('MailFilterPage', () => {
|
||||
await save(wrapper)
|
||||
|
||||
expect(wrapper.get('#mail-filter-condition-0-error').text()).toBe(
|
||||
'‘${’ 는 쓸 수 없습니다 — 메일 서버가 다른 뜻으로 읽습니다.',
|
||||
'‘${’는 필터에 쓸 수 없습니다. 이 글자를 빼고 넣어 주세요.',
|
||||
)
|
||||
expect(wrapper.get('#mail-filter-forward-help').text()).toBe('사내 주소(@arasystem.it.kr)로만 전달할 수 있습니다.')
|
||||
expect(document.activeElement).toBe(value.element)
|
||||
@@ -477,7 +477,7 @@ describe('MailFilterPage', () => {
|
||||
const select = wrapper.get('#mail-filter-move-target')
|
||||
const options = select.findAll('option')
|
||||
expect(options.map((option) => option.text())).toEqual([
|
||||
'(지워진 메일함) — 다른 메일함을 고르세요',
|
||||
'지워진 메일함이니 다른 메일함을 골라 주세요',
|
||||
'받은편지함',
|
||||
'거래처',
|
||||
'전체 공지',
|
||||
@@ -721,7 +721,7 @@ describe('MailFilterPage', () => {
|
||||
const { wrapper } = await mountPage({ filters: { ...SET, isActive: false } })
|
||||
const bar = wrapper.get('[data-filter-inactive]')
|
||||
|
||||
expect(bar.text()).toContain('지금은 필터와 부재중 자동 응답이 동작하지 않습니다. 메일 서버의 규칙이 다른 곳에서 바뀌었습니다.')
|
||||
expect(bar.text()).toContain('다른 곳에서 메일 설정이 바뀌어 지금은 필터와 부재중 자동 응답이 되지 않습니다.')
|
||||
await click(bar.get('button'))
|
||||
|
||||
expect(puts()).toEqual([{ rules: RULES, version: 'v1' }])
|
||||
@@ -820,7 +820,7 @@ describe('MailFilterPage', () => {
|
||||
const applyHelp = (wrapper: VueWrapper) => wrapper.get('#mail-filter-apply-help').text()
|
||||
const runs = () =>
|
||||
requests.filter((request) => request.url === '/mail-filter-runs').map((request) => request.data as MailFilterRunRequest)
|
||||
const HELP = '저장한 뒤 받은편지함에서 조건에 맞는 메일을 찾아 바로 적용합니다.'
|
||||
const HELP = '저장하면 받은편지함에서 조건에 맞는 메일을 찾아 바로 정리합니다.'
|
||||
|
||||
it('할 일 아래 — 창을 열 때마다 꺼져 있고 설명은 칸의 aria-describedby, 전달도 하면 설명에 더한다 · 상자 아래 스팸 한 줄', async () => {
|
||||
const { wrapper } = await mountPage()
|
||||
@@ -831,7 +831,7 @@ describe('MailFilterPage', () => {
|
||||
expect(applyBox(wrapper).attributes('aria-describedby')).toBe('mail-filter-apply-help')
|
||||
expect(wrapper.get('[data-filter-apply] label').text()).toBe('받은편지함에 이미 있는 메일에도 적용')
|
||||
expect(applyHelp(wrapper)).toBe(HELP)
|
||||
expect(wrapper.get('[data-filter-apply-spam]').text()).toBe('스팸으로 판정되어 들어오는 메일에는 적용하지 않습니다.')
|
||||
expect(wrapper.get('[data-filter-apply-spam]').text()).toBe('스팸으로 분류된 메일은 필터를 거치지 않습니다.')
|
||||
// 6단계의 안내 띠는 없다
|
||||
expect(wrapper.text()).not.toContain('새로 오는 메일에만 적용됩니다')
|
||||
|
||||
@@ -852,7 +852,7 @@ describe('MailFilterPage', () => {
|
||||
|
||||
expect(applyBox(wrapper).attributes('disabled')).toBeDefined()
|
||||
expect(wrapper.get('[data-filter-apply] label').classes()).toContain('is-disabled')
|
||||
expect(applyHelp(wrapper)).toBe('전달만 하는 필터는 이미 받은 메일에 할 일이 없습니다 — 전달은 새로 오는 메일에만 합니다.')
|
||||
expect(applyHelp(wrapper)).toBe('전달은 새로 오는 메일에만 해서, 전달만 하는 필터는 이미 받은 메일에 할 일이 없습니다.')
|
||||
|
||||
await wrapper.get('#mail-filter-read').setValue(true)
|
||||
expect(applyBox(wrapper).attributes('disabled')).toBeUndefined()
|
||||
@@ -863,7 +863,7 @@ describe('MailFilterPage', () => {
|
||||
const { wrapper } = await mountPage()
|
||||
await openRule(wrapper, 'r5')
|
||||
expect(applyBox(wrapper).attributes('disabled')).toBeDefined()
|
||||
expect(applyHelp(wrapper)).toBe('옮길 메일함이 지워져 이미 받은 메일에 할 일이 없습니다 — 다른 메일함을 고르세요.')
|
||||
expect(applyHelp(wrapper)).toBe('옮길 메일함이 지워져 이미 받은 메일에 할 일이 없습니다. 다른 메일함을 골라 주세요.')
|
||||
await click(wrapper.get('#mail-filter-cancel'))
|
||||
|
||||
await openRule(wrapper, 'r2')
|
||||
@@ -927,7 +927,7 @@ describe('MailFilterPage', () => {
|
||||
{ ...rule, cursor: 'c1' },
|
||||
])
|
||||
expect(useToastStore().toasts.map((toast) => [toast.kind, toast.message])).toEqual([
|
||||
['progress', '‘거래처 메일’ 필터를 받은편지함에 적용하는 중 · 4,500 / 8,412통 확인'],
|
||||
['progress', '‘거래처 메일’ 필터로 받은편지함을 정리하는 중 · 4,500 / 8,412통 확인'],
|
||||
])
|
||||
|
||||
second.finish(step(3912, 7, 0, null))
|
||||
@@ -936,7 +936,7 @@ describe('MailFilterPage', () => {
|
||||
expect(useToastStore().toasts).toHaveLength(1)
|
||||
expect(done).toMatchObject({
|
||||
kind: 'success',
|
||||
message: '‘거래처 메일’ 필터를 받은편지함의 메일 37통에 적용했습니다.',
|
||||
message: '‘거래처 메일’ 필터로 받은편지함의 메일 37통을 정리했습니다.',
|
||||
action: { label: '거래처 보기', to: { name: 'mail-mailbox', params: { mailboxId: 'vendor' } } },
|
||||
})
|
||||
expect(toasts()).not.toContain('필터를 저장했습니다.')
|
||||
@@ -952,7 +952,7 @@ describe('MailFilterPage', () => {
|
||||
|
||||
await openRule(wrapper, 'r2')
|
||||
expect(applyBox(wrapper).attributes('disabled')).toBeDefined()
|
||||
expect(applyHelp(wrapper)).toBe('다른 필터를 받은편지함에 적용하는 중입니다. 끝난 뒤에 고를 수 있습니다.')
|
||||
expect(applyHelp(wrapper)).toBe('다른 필터로 받은편지함을 정리하는 중입니다. 끝난 뒤에 고를 수 있습니다.')
|
||||
await click(wrapper.get('#mail-filter-cancel'))
|
||||
|
||||
// 다른 화면으로 가도 이어 한다
|
||||
@@ -960,7 +960,7 @@ describe('MailFilterPage', () => {
|
||||
wrapper.unmount()
|
||||
first.finish(step(8412, 12, 0, null))
|
||||
await flushPromises()
|
||||
expect(toasts()).toEqual(['‘결재 알림’ 필터를 받은편지함의 메일 12통에 적용했습니다.'])
|
||||
expect(toasts()).toEqual(['‘결재 알림’ 필터로 받은편지함의 메일 12통을 정리했습니다.'])
|
||||
expect(useToastStore().toasts[0]?.action).toBeNull()
|
||||
|
||||
const { wrapper: again } = await mountPage()
|
||||
@@ -981,7 +981,7 @@ describe('MailFilterPage', () => {
|
||||
reply.finish(echo(requests.find((request) => request.method === 'PUT') as MockRequest))
|
||||
await flushPromises()
|
||||
expect(runs().map((request) => request.cursor)).toEqual([null])
|
||||
expect(toasts()).toEqual(['‘거래처 메일’ 필터를 받은편지함의 메일 37통에 적용했습니다.'])
|
||||
expect(toasts()).toEqual(['‘거래처 메일’ 필터로 받은편지함의 메일 37통을 정리했습니다.'])
|
||||
useToastStore().clear()
|
||||
|
||||
const leaving = deferred()
|
||||
@@ -997,7 +997,7 @@ describe('MailFilterPage', () => {
|
||||
leaving.finish(echo(requests.find((request) => request.method === 'PUT') as MockRequest))
|
||||
await flushPromises()
|
||||
expect(runs().map((request) => request.cursor)).toEqual([null])
|
||||
expect(toasts()).toEqual(['‘거래처 메일’ 필터를 받은편지함의 메일 37통에 적용했습니다.'])
|
||||
expect(toasts()).toEqual(['‘거래처 메일’ 필터로 받은편지함의 메일 37통을 정리했습니다.'])
|
||||
})
|
||||
|
||||
it('저장하는 중에 세션이 바뀌면 적용 요청을 보내지 않는다 — 알림도 없다', async () => {
|
||||
|
||||
@@ -158,7 +158,7 @@ describe('MailLargeFilesPage', () => {
|
||||
expect(view.get('[data-large-usage]').text()).toBe('6.2 / 10 GB')
|
||||
expect(view.get('meter').attributes('aria-valuetext')).toBe('62%')
|
||||
expect(view.get('.large-usage__note').text()).toBe(
|
||||
'보낸 링크 4.3 GB · 보내지 않은 파일 1.9 GB(임시 저장 · 올리다 멈춤). 기간이 지나거나, 링크를 끊거나, 보내지 않은 파일을 지우면 바로 줄어듭니다.',
|
||||
'보낸 링크 4.3 GB, 임시 저장했거나 올리다 멈춘 파일 1.9 GB입니다. 기간이 지나거나, 링크를 끊거나, 보내지 않은 파일을 지우면 바로 줄어듭니다.',
|
||||
)
|
||||
})
|
||||
|
||||
@@ -199,7 +199,7 @@ describe('MailLargeFilesPage', () => {
|
||||
await view.get('[data-unsent-delete]').trigger('click')
|
||||
await flushPromises()
|
||||
expect(view.get('#large-delete-message').text()).toBe(
|
||||
'‘시공도면_v4.dwg’(620 MB) 파일을 지웁니다. 이 파일을 실은 임시 저장본이 있으면 열 때 첨부 칸에 ‘지운 파일’로 보입니다. 지운 파일은 되돌릴 수 없습니다.',
|
||||
'‘시공도면_v4.dwg’(620 MB) 파일을 지웁니다. 이 파일을 실은 임시 저장본을 열면 첨부 칸에 지운 파일로 보입니다. 지운 파일은 되돌릴 수 없습니다.',
|
||||
)
|
||||
expect(document.activeElement?.hasAttribute('data-delete-cancel')).toBe(true)
|
||||
|
||||
@@ -283,7 +283,7 @@ describe('MailLargeFilesPage', () => {
|
||||
it('목록을 받지 못하면 사전 문구와 "다시 시도" — 알림은 띄우지 않는다', async () => {
|
||||
const { wrapper: view } = await mountPage({ failLinks: true })
|
||||
|
||||
expect(view.text()).toContain('메일 서버와 연결할 수 없습니다.')
|
||||
expect(view.text()).toContain('메일 서비스에 연결할 수 없습니다.')
|
||||
expect(useToastStore().toasts).toEqual([])
|
||||
expect(useLargeFilesStore().status).toBe('error')
|
||||
})
|
||||
|
||||
@@ -56,13 +56,13 @@ describe('MailNotifyPage (설정 › 메일 › 알림)', () => {
|
||||
expect(wrapper.text()).toContain('새 메일은 1분을 기다리지 않고 바로 목록에 나타납니다.')
|
||||
})
|
||||
|
||||
it('묻기 전 — 칸은 비었고 "켜면 브라우저가 … 「허용」을 눌러 주세요.", 보일 것 · 시험 알림은 흐리게', async () => {
|
||||
it('묻기 전 — 칸은 비었고 "켜면 브라우저가 … [허용]을 눌러 주세요.", 보일 것 · 시험 알림은 흐리게', async () => {
|
||||
const wrapper = await mountPage()
|
||||
|
||||
expect(enableInput(wrapper).checked).toBe(false)
|
||||
expect(enableInput(wrapper).disabled).toBe(false)
|
||||
expect(wrapper.get('#notify-enabled-note').text()).toBe(
|
||||
'켜면 브라우저가 알림을 허락할지 한 번 묻습니다 — 「허용」을 눌러 주세요.',
|
||||
'켜면 브라우저가 알림을 허락할지 한 번 묻습니다. [허용]을 눌러 주세요.',
|
||||
)
|
||||
expect(wrapper.get('fieldset').attributes('disabled')).toBeDefined()
|
||||
expect(wrapper.get('#notify-test').attributes('disabled')).toBeDefined()
|
||||
@@ -101,7 +101,7 @@ describe('MailNotifyPage (설정 › 메일 › 알림)', () => {
|
||||
expect(enableInput(wrapper).disabled).toBe(true)
|
||||
const note = wrapper.get('#notify-enabled-note')
|
||||
expect(note.text()).toBe(
|
||||
'브라우저가 이 사이트의 알림을 막고 있습니다. 주소창 왼쪽의 사이트 정보(자물쇠) › 알림을 「허용」으로 바꾼 뒤 이 화면을 새로 고쳐 주세요.',
|
||||
'브라우저가 이 사이트의 알림을 막고 있습니다. 주소창 왼쪽의 사이트 정보(자물쇠)를 눌러 [알림]을 [허용]으로 바꾼 뒤 이 화면을 새로 고쳐 주세요.',
|
||||
)
|
||||
expect(note.classes()).toContain('notify-form__note--danger')
|
||||
expect(useMailNotifyStore().settings.enabled).toBe(false)
|
||||
@@ -200,7 +200,7 @@ describe('MailNotifyPage (설정 › 메일 › 알림)', () => {
|
||||
|
||||
const subject = wrapper.get('#notify-show-subject')
|
||||
expect((subject.element as HTMLInputElement).checked).toBe(true)
|
||||
expect(wrapper.text()).toContain('끄면 보낸 사람 이름만 보입니다 — 화면을 다른 사람과 함께 보는 자리라면 꺼 두세요.')
|
||||
expect(wrapper.text()).toContain('끄면 보낸 사람 이름만 보입니다. 다른 사람과 화면을 함께 보는 자리라면 꺼 두는 것이 좋습니다.')
|
||||
await subject.setValue(false)
|
||||
|
||||
expect(useMailNotifyStore().settings.showSubject).toBe(false)
|
||||
@@ -213,7 +213,7 @@ describe('MailNotifyPage (설정 › 메일 › 알림)', () => {
|
||||
const status = () => wrapper.get('#notify-realtime-status')
|
||||
|
||||
expect(wrapper.get('h2').text()).toBe('실시간 연결')
|
||||
expect(status().text()).toBe('연결되어 있습니다 — 새 메일이 오면 바로 목록에 나타납니다.')
|
||||
expect(status().text()).toBe('연결되어 있어 새 메일이 오면 바로 목록에 나타납니다.')
|
||||
expect(status().find('.realtime-link__dot').classes()).toContain('is-live')
|
||||
|
||||
expect(wrapper.find('#notify-realtime-notice').exists()).toBe(false)
|
||||
@@ -225,7 +225,7 @@ describe('MailNotifyPage (설정 › 메일 › 알림)', () => {
|
||||
// 끊긴 동안 — 상태 줄 바로 아래, "저절로 다시 잇습니다…" 안내 앞
|
||||
expect(wrapper.get('#notify-realtime-notice').text()).toBe('연결될 때까지 바탕화면 알림은 오지 않습니다.')
|
||||
expect(wrapper.find('[role="status"] #notify-realtime-notice').exists()).toBe(true)
|
||||
expect(wrapper.text()).toContain('저절로 다시 잇습니다.')
|
||||
expect(wrapper.text()).toContain('저절로 다시 연결합니다.')
|
||||
|
||||
realtime.setStatus('connecting')
|
||||
await flushPromises()
|
||||
@@ -238,9 +238,9 @@ describe('MailNotifyPage (설정 › 메일 › 알림)', () => {
|
||||
const notes = wrapper.get('[data-notify-notes]')
|
||||
|
||||
expect(notes.findAll('p').map((note) => note.findAll('span').at(-1)?.text())).toEqual([
|
||||
'알림 설정은 이 브라우저에만 적용됩니다. 다른 PC · 브라우저에서는 그곳에서 따로 켜 주세요.',
|
||||
'알림 설정은 이 브라우저에만 저장됩니다. 다른 PC나 브라우저에서는 그곳에서 따로 켜 주세요.',
|
||||
'웹메일 탭이 하나라도 열려 있어야 알림이 옵니다. 탭을 모두 닫으면 오지 않습니다.',
|
||||
'웹메일 화면을 보고 있을 때는 바탕화면 알림을 보내지 않습니다 — 목록에 바로 보이므로.',
|
||||
'웹메일 화면을 보고 있을 때는 새 메일이 목록에 바로 보이므로 바탕화면 알림을 보내지 않습니다.',
|
||||
])
|
||||
expect(notes.get('strong').text()).toBe('이 브라우저에만')
|
||||
})
|
||||
|
||||
@@ -78,15 +78,15 @@ describe('MailSendReadPage', () => {
|
||||
['30초', 'false'],
|
||||
])
|
||||
expect(wrapper.get('#send-read-undo-hint').text()).toBe(
|
||||
'[보내기]를 누른 뒤 이 시간 동안 [보내기 취소]로 되돌릴 수 있습니다. 그동안 메일은 메일 서버에서 기다리므로 창을 닫아도 보내집니다.',
|
||||
'[보내기]를 누른 뒤 이 시간 동안 [보내기 취소]로 되돌릴 수 있습니다. 그사이 창을 닫아도 시간이 지나면 메일은 그대로 보내집니다.',
|
||||
)
|
||||
expect(wrapper.findAll('.send-read__option-label').map((label) => label.text())).toEqual([
|
||||
'늘 묻기',
|
||||
'주소록 · 사내 직원의 메일은 늘 보기',
|
||||
'주소록과 사내 직원의 메일은 늘 보기',
|
||||
'늘 보기',
|
||||
])
|
||||
expect(checkedImages(wrapper)).toEqual(['trusted'])
|
||||
expect(wrapper.get('[data-images-note]').text()).toContain('사내 메일 서버가 대신 받아 보여 줍니다')
|
||||
expect(wrapper.get('[data-images-note]').text()).toContain('외부 이미지는 회사를 거쳐 불러오므로 보낸 사람은 이 PC의 주소를 알 수 없습니다.')
|
||||
expect(wrapper.get('[data-settings-status]').text()).toBe('')
|
||||
})
|
||||
|
||||
|
||||
@@ -189,7 +189,7 @@ describe('MailSignaturePage', () => {
|
||||
isForNew: true,
|
||||
isForReply: false,
|
||||
})
|
||||
expect(wrapper.get('#signature-save-status').text()).toBe('저장했습니다. 새로 여는 쓰기 화면부터 들어갑니다.')
|
||||
expect(wrapper.get('#signature-save-status').text()).toBe('저장했습니다. 다음에 메일을 쓸 때부터 들어갑니다.')
|
||||
expect(useMailSignatureStore().signature?.isForReply).toBe(false)
|
||||
|
||||
await wrapper.get('#signature-for-new').setValue(false)
|
||||
@@ -466,10 +466,10 @@ describe('MailSignaturePage', () => {
|
||||
|
||||
expect(replyMode.attributes('aria-pressed')).toBe('true')
|
||||
expect(wrapper.find('.signature-preview__quote').exists()).toBe(true)
|
||||
expect(wrapper.get('[data-signature-preview-note]').text()).toBe('답장 · 전달은 인용한 원문 위에 서명을 넣습니다.')
|
||||
expect(wrapper.get('[data-signature-preview-note]').text()).toBe('답장과 전달에는 인용한 원문 위에 서명을 넣습니다.')
|
||||
|
||||
await wrapper.get('#signature-for-reply').setValue(false)
|
||||
expect(wrapper.get('[data-signature-preview]').isVisible()).toBe(false)
|
||||
expect(wrapper.get('[data-signature-preview-note]').text()).toBe('답장 · 전달에는 서명을 넣지 않습니다.')
|
||||
expect(wrapper.get('[data-signature-preview-note]').text()).toBe('답장과 전달에는 서명을 넣지 않습니다.')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -102,7 +102,7 @@ describe('MailVacationPage', () => {
|
||||
// 엔진이 바이트로 막는 한도 안 — 한글로 채워도 저장된다 (계약 2절 "부재중 자동 응답"의 "길이 한도의 근거")
|
||||
expect(wrapper.get('#vacation-subject').attributes('maxlength')).toBe('150')
|
||||
expect(wrapper.get('#vacation-body').attributes('maxlength')).toBe('600')
|
||||
expect(wrapper.get('[data-vacation-preview-note]').text()).toBe('꺼져 있습니다 — 자동으로 답하지 않습니다.')
|
||||
expect(wrapper.get('[data-vacation-preview-note]').text()).toBe('꺼져 있어 자동으로 답하지 않습니다.')
|
||||
})
|
||||
|
||||
it('저장한 값으로 채운다 — 날짜는 브라우저 시간대의 날짜로, 미리 보기는 보낸 사람 · 제목 · 내용', async () => {
|
||||
@@ -112,7 +112,7 @@ describe('MailVacationPage', () => {
|
||||
expect(value(wrapper, '#vacation-from')).toBe('2026-10-30')
|
||||
expect(value(wrapper, '#vacation-to')).toBe('2026-11-03')
|
||||
expect(wrapper.get('#vacation-period-hint').text()).toBe(
|
||||
'시작일 0시부터 끝나는 날 자정까지 답합니다 (이 브라우저의 시간대 기준).',
|
||||
'시작일 0시부터 끝나는 날 자정까지 답합니다. 시각은 이 브라우저의 시간대를 따릅니다.',
|
||||
)
|
||||
expect(value(wrapper, '#vacation-subject')).toBe(SAVED.subject)
|
||||
const preview = wrapper.get('[data-vacation-preview]')
|
||||
@@ -120,7 +120,7 @@ describe('MailVacationPage', () => {
|
||||
expect(preview.get('[data-vacation-preview-subject]').text()).toBe(SAVED.subject)
|
||||
expect(preview.get('[data-vacation-preview-body]').element.textContent).toBe(SAVED.textBody)
|
||||
expect(wrapper.get('[data-vacation-preview-note]').text()).toBe(
|
||||
'받은 메일의 보낸 사람에게 위 글로 답합니다. 같은 사람에게 매번 보내지는 않습니다(메일 엔진 규칙).',
|
||||
'받은 메일의 보낸 사람에게 위 글로 답합니다. 같은 사람에게는 매번 보내지 않습니다.',
|
||||
)
|
||||
})
|
||||
|
||||
@@ -215,7 +215,7 @@ describe('MailVacationPage', () => {
|
||||
|
||||
expect(puts()).toEqual([{ ...SAVED, isEnabled: false, textBody: longBody }])
|
||||
expect(wrapper.find('#vacation-body-error').exists()).toBe(false)
|
||||
expect(wrapper.get('#vacation-save-status').text()).toBe('저장했습니다. 자동 응답을 껐습니다 — 써 둔 글은 남겨 둡니다.')
|
||||
expect(wrapper.get('#vacation-save-status').text()).toBe('저장했습니다. 자동 응답을 껐고, 써 둔 글은 그대로 남아 있습니다.')
|
||||
expect(useMailVacationStore().vacation?.isEnabled).toBe(false)
|
||||
expect(value(wrapper, '#vacation-body')).toBe(longBody)
|
||||
})
|
||||
@@ -227,7 +227,7 @@ describe('MailVacationPage', () => {
|
||||
await save(wrapper)
|
||||
|
||||
expect(puts()).toEqual([{ ...SAVED, isEnabled: false }])
|
||||
expect(wrapper.get('#vacation-save-status').text()).toBe('저장했습니다. 자동 응답을 껐습니다 — 써 둔 글은 남겨 둡니다.')
|
||||
expect(wrapper.get('#vacation-save-status').text()).toBe('저장했습니다. 자동 응답을 껐고, 써 둔 글은 그대로 남아 있습니다.')
|
||||
|
||||
// 설정한 적 없는 계정을 그대로 꺼 둔 채 저장해도 된다
|
||||
const { wrapper: fresh } = await mountPage()
|
||||
@@ -279,7 +279,7 @@ describe('MailVacationPage', () => {
|
||||
|
||||
describe('엔진에서 규칙이 꺼졌을 때 (7단계 — 디자인 원본 MailFilterApplyStates)', () => {
|
||||
const INACTIVE_TEXT =
|
||||
'켜 둔 자동 응답이 지금은 동작하지 않습니다. 메일 서버의 규칙이 다른 곳에서 바뀌었습니다 — 아래 [저장]을 누르면 다시 켜집니다.'
|
||||
'다른 곳에서 메일 설정이 바뀌어 켜 둔 자동 응답이 지금은 나가지 않습니다. 아래 [저장]을 누르면 다시 켜집니다.'
|
||||
|
||||
it('켜 두었는데 isActive: false 면 머리 아래 노란 줄(단추 없음) — 저장하면 isActive 없이 보내고 응답의 값으로 내린다', async () => {
|
||||
const { wrapper } = await mountPage({
|
||||
|
||||
@@ -108,11 +108,11 @@ describe('SharePage', () => {
|
||||
// 이름과 주소 사이 띄어쓰기는 괄호 칸 안에 둔다 — 좁은 화면은 괄호와 함께 감추고 주소를 다음 줄로 (디자인 원본 SharePage "김아라 <ara.kim@…>")
|
||||
expect(view.get('.share__intro').text()).toContain('보낸 사람 김아라 <ara.kim@arasystem.it.kr> · ARA SYSTEM')
|
||||
expect(view.get('.share__intro').text()).toContain(
|
||||
'2026년 11월 29일(일)까지 내려받을 수 있습니다 (한국 시간) · 30일 남음',
|
||||
'한국 시간으로 2026년 11월 29일(일)까지 내려받을 수 있습니다. 30일 남았습니다.',
|
||||
)
|
||||
expect(view.findAll('.share-file__meta').map((meta) => meta.text())).toEqual([
|
||||
'1.2 GB · 보안 검사 · 이상 없음',
|
||||
'612 MB · 보안 검사 · 이상 없음',
|
||||
'1.2 GB · 보안 검사 결과 이상 없음',
|
||||
'612 MB · 보안 검사 결과 이상 없음',
|
||||
])
|
||||
const download = view.get<HTMLAnchorElement>('[data-share-file="f1"] a.share-file__download')
|
||||
expect(download.attributes('href')).toBe(`/api/public-links/${TOKEN}/files/f1`)
|
||||
@@ -122,11 +122,11 @@ describe('SharePage', () => {
|
||||
)
|
||||
expect(view.get('.share__report').text()).toBe('받을 이유가 없는 파일이면 security@arasystem.it.kr 로 알려 주세요.')
|
||||
expect(view.get('.share__english').text()).toBe(
|
||||
'Files from 김아라 (ara.kim@arasystem.it.kr) — download by Nov 29, 2026 (KST). This page never asks for a login or password.',
|
||||
'Files from 김아라 (ara.kim@arasystem.it.kr). Download by Nov 29, 2026 (KST). This page will never ask you to log in or enter a password.',
|
||||
)
|
||||
// 메일 제목 · 본문 · 받는 사람 · 받은 수는 보이지 않는다
|
||||
expect(view.text()).not.toContain('회 받음')
|
||||
expect(document.title).toBe('김아라 님이 보낸 파일 — ARA SYSTEM')
|
||||
expect(document.title).toBe('김아라 님이 보낸 파일 · ARA SYSTEM')
|
||||
})
|
||||
|
||||
it('링크 정보는 쿠키 없이 · 캐시 없이 · 주소를 싣지 않고 묻는다 — 검색 엔진 · referrer 막기 머리도 둔다', async () => {
|
||||
@@ -192,7 +192,7 @@ describe('SharePage', () => {
|
||||
const view = await mountAt(TOKEN, { link: publicLink({ files: [{ ...DESIGN, status: 'scanning' }] }) }, { link: publicLink() })
|
||||
|
||||
expect(view.get('[data-share-view="scanning"] h1').text()).toBe('보안 검사 중입니다')
|
||||
expect(view.get('.share__english').text()).toBe('Security scan in progress — please check again in a few minutes.')
|
||||
expect(view.get('.share__english').text()).toBe('A security scan is in progress. Please check again in a few minutes.')
|
||||
|
||||
await view.get('[data-share-recheck]').trigger('click')
|
||||
await flushPromises()
|
||||
@@ -213,7 +213,7 @@ describe('SharePage', () => {
|
||||
|
||||
const infected = view.get('[data-share-file="f1"]')
|
||||
expect(infected.classes()).toContain('share-file--danger')
|
||||
expect(infected.get('.share-file__meta').text()).toBe('1.2 GB · 위험 — 막았습니다')
|
||||
expect(infected.get('.share-file__meta').text()).toBe('1.2 GB · 위험한 내용이 있어 막았습니다')
|
||||
expect(infected.find('.share-file__download').exists()).toBe(false)
|
||||
const unscannable = view.get('[data-share-file="f2"]')
|
||||
expect(unscannable.classes()).toContain('share-file--warning')
|
||||
@@ -287,7 +287,7 @@ describe('SharePage', () => {
|
||||
|
||||
expect(view.get('h1').text()).toBe('ara.kim@arasystem.it.kr 님이 보낸 파일 1개 (1.2 GB)')
|
||||
expect(view.find('.share__email').exists()).toBe(false)
|
||||
expect(document.title).toBe('ara.kim@arasystem.it.kr 님이 보낸 파일 — ARA SYSTEM')
|
||||
expect(document.title).toBe('ara.kim@arasystem.it.kr 님이 보낸 파일 · ARA SYSTEM')
|
||||
})
|
||||
|
||||
it('불러오는 중 — 자리 그림은 숨기고 "불러오는 중"을 화면 낭독기에 읽어 준다 (리뷰 L9)', async () => {
|
||||
|
||||
@@ -267,7 +267,7 @@ watch(
|
||||
<span class="share__org"> · {{ APP_NAME }}</span>
|
||||
</p>
|
||||
<p class="share__line">
|
||||
<strong>{{ untilText }}</strong>{{ untilTail }}
|
||||
{{ SHARE_PAGE_TEXT.untilBefore }}<strong>{{ untilText }}</strong>{{ untilTail }}
|
||||
</p>
|
||||
</div>
|
||||
<ul class="share__files">
|
||||
|
||||
@@ -57,7 +57,7 @@ export const LOGIN_TWO_FACTOR_TEXT = {
|
||||
codeLabel: '인증 앱에 표시된 6자리 코드',
|
||||
submit: '확인',
|
||||
submitting: '확인하는 중…',
|
||||
note: 'Outlook · 휴대폰 메일 앱은 이 코드를 묻지 않습니다. 설정 › 앱 비밀번호에서 만든 비밀번호로 연결합니다.',
|
||||
note: 'Outlook이나 휴대폰 메일 앱은 이 코드를 묻지 않습니다. [설정] → [앱 비밀번호]에서 만든 비밀번호로 연결해 주세요.',
|
||||
/** 코드 단계에 10분 넘게 머문 뒤 [확인] — 첫 단계의 위쪽 상자 */
|
||||
expired: '코드를 넣는 시간이 지났습니다. 다시 로그인해 주세요.',
|
||||
} as const
|
||||
@@ -70,30 +70,30 @@ export const TWO_FACTOR_TEXT = {
|
||||
off: '꺼짐',
|
||||
offHint: '지금은 로그인할 때 비밀번호만 확인합니다.',
|
||||
on: '켜짐',
|
||||
onSince: (day: string) => `켜짐 — ${day}부터`,
|
||||
onSince: (day: string) => `${day}부터 켜짐`,
|
||||
onHint: '로그인할 때마다 비밀번호와 인증 앱의 코드를 확인합니다.',
|
||||
enable: '2단계 인증 켜기',
|
||||
disable: '2단계 인증 끄기',
|
||||
warningTitle: '켜면 메일 프로그램은 앱 비밀번호로 다시 연결해야 합니다',
|
||||
/** 경고 글 — 가운데 「앱 비밀번호」는 앱 비밀번호 화면 링크 */
|
||||
warningBefore:
|
||||
'Outlook · 썬더버드 · 휴대폰 메일 앱은 웹메일 비밀번호로 새로 연결할 수 없고, 이미 연결해 둔 프로그램도 다음에 다시 연결할 때 막힙니다. 켜기 전에 ',
|
||||
'Outlook, 썬더버드, 휴대폰 메일 앱은 웹메일 비밀번호로 새로 연결할 수 없고, 이미 연결해 둔 프로그램도 다음에 다시 연결할 때 막힙니다. 켜기 전에 ',
|
||||
warningLink: '앱 비밀번호',
|
||||
warningAfter: '를 만들어 두면 편합니다.',
|
||||
stepsTitle: '켜는 차례',
|
||||
stepsTitle: '켜는 방법',
|
||||
steps: [
|
||||
'휴대폰에 인증 앱을 설치합니다 — Google Authenticator, Microsoft Authenticator 처럼 6자리 코드를 만드는(TOTP) 앱이면 됩니다.',
|
||||
'휴대폰에 인증 앱을 설치합니다. Google Authenticator나 Microsoft Authenticator처럼 6자리 코드를 만드는 앱이면 됩니다.',
|
||||
'[2단계 인증 켜기]를 누르고 화면의 QR 코드를 인증 앱으로 찍습니다.',
|
||||
'앱에 표시된 6자리 코드와 지금 비밀번호를 넣으면 켜집니다. 그다음부터 로그인할 때마다 코드를 묻습니다.',
|
||||
],
|
||||
noteChangePhone: '휴대폰을 바꿀 때는 2단계 인증을 끈 뒤 새 휴대폰으로 다시 켜 주세요 — 끌 때 지금 휴대폰의 코드가 필요합니다.',
|
||||
noteChangePhone: '휴대폰을 바꿀 때는 2단계 인증을 끈 뒤 새 휴대폰으로 다시 켜 주세요. 끌 때 지금 휴대폰의 코드가 필요합니다.',
|
||||
noteLostPhone: '휴대폰을 잃어버렸거나 인증 앱을 지웠으면 관리자에게 문의해 주세요.',
|
||||
noteMailClientBefore: '메일 프로그램은 ',
|
||||
noteMailClientLink: '앱 비밀번호',
|
||||
noteMailClientAfter: '로 연결합니다.',
|
||||
/** 켠 직후 머리 아래 안내 상자 — 이 화면을 떠나면 사라진다 */
|
||||
enabledTitle: '2단계 인증을 켰습니다',
|
||||
enabledText: '메일 프로그램(Outlook · 썬더버드 · 휴대폰 메일 앱)은 다시 연결할 때 앱 비밀번호가 필요합니다.',
|
||||
enabledText: 'Outlook, 썬더버드, 휴대폰 메일 앱 같은 메일 프로그램은 다시 연결할 때 앱 비밀번호가 필요합니다.',
|
||||
enabledAction: '앱 비밀번호 만들기',
|
||||
disabled: '2단계 인증을 껐습니다.',
|
||||
} as const
|
||||
@@ -102,17 +102,17 @@ export const TWO_FACTOR_TEXT = {
|
||||
export const TWO_FACTOR_ENABLE_TEXT = {
|
||||
title: '2단계 인증 켜기',
|
||||
scanTitle: '인증 앱으로 QR 코드를 찍어 주세요',
|
||||
scanHint: '인증 앱에서 [+] → 「QR 코드 스캔」을 고르고 이 그림을 찍습니다.',
|
||||
scanHint: '인증 앱에서 [+] → [QR 코드 스캔]을 고른 뒤 이 그림을 찍어 주세요.',
|
||||
qrLabel: '인증 앱에 등록할 QR 코드',
|
||||
keyLabel: '찍을 수 없으면 이 키를 입력하세요',
|
||||
keyLabel: '찍을 수 없으면 이 키를 입력해 주세요',
|
||||
copy: '복사',
|
||||
copyLabel: '키 복사',
|
||||
copied: '키를 복사했습니다.',
|
||||
copyFailed: '키를 복사하지 못했습니다. 키를 직접 골라 복사해 주세요.',
|
||||
/** 앱에 저장될 이름 — 서버의 otpauthUrl 과 같은 이름(계약 5절 "정한 것" 1) */
|
||||
savedAs: (email: string) => `앱에는 「ARA SYSTEM (${email})」로 저장됩니다.`,
|
||||
savedAs: (email: string) => `앱에는 ‘ARA SYSTEM (${email})’로 저장됩니다.`,
|
||||
/** 켜지 않고 닫으면 그 키는 버린다 — 앱에 먼저 저장한 항목은 지워 달라고 안내한다 (README "켜기 창") */
|
||||
discardNote: '켜지 않고 닫으면 이 키는 버립니다 — 앱에 먼저 저장했다면 그 항목을 지워 주세요.',
|
||||
discardNote: '켜지 않고 닫으면 이 키는 더 쓸 수 없습니다. 앱에 먼저 저장했다면 그 항목을 지워 주세요.',
|
||||
loading: 'QR 코드를 만드는 중…',
|
||||
codeLabel: '앱에 표시된 6자리 코드',
|
||||
passwordLabel: '지금 비밀번호',
|
||||
@@ -139,12 +139,12 @@ export const TWO_FACTOR_DISABLE_TEXT = {
|
||||
export const APP_PASSWORD_TEXT = {
|
||||
title: '앱 비밀번호',
|
||||
description:
|
||||
'Outlook · 썬더버드 · 휴대폰 메일 앱 같은 메일 프로그램에 넣는 비밀번호입니다. 프로그램마다 따로 만들어 두면, 기기를 잃었을 때 그것만 지울 수 있습니다.',
|
||||
'Outlook, 썬더버드, 휴대폰 메일 앱 같은 메일 프로그램에 넣는 비밀번호입니다. 프로그램마다 따로 만들어 두면 기기를 잃어버렸을 때 그 비밀번호만 지울 수 있습니다.',
|
||||
count: (count: string, max: string) => `${count} / ${max}개`,
|
||||
create: '앱 비밀번호 만들기',
|
||||
/** 5개를 모두 만들었을 때 머리 아래 한 줄 — [만들기]를 눌러도 이 글을 읽어 준다 */
|
||||
full: (max: string) => `${max}개까지 만들 수 있습니다. 쓰지 않는 앱 비밀번호를 지운 뒤 만들어 주세요.`,
|
||||
listLabel: '앱 비밀번호 — 최근에 만든 차례',
|
||||
listLabel: '앱 비밀번호 목록, 최근에 만든 순서',
|
||||
columnName: '이름',
|
||||
columnCreated: '만든 날',
|
||||
columnLastUsed: '마지막 사용',
|
||||
@@ -154,20 +154,20 @@ export const APP_PASSWORD_TEXT = {
|
||||
deleteLabel: (name: string) => `‘${name}’ 앱 비밀번호 지우기`,
|
||||
emptyTitle: '만든 앱 비밀번호가 없습니다',
|
||||
emptyHint:
|
||||
'Outlook · 휴대폰 메일 앱에서 메일을 보려면 앱 비밀번호를 만들어 주세요. 2단계 인증을 켰다면 메일 프로그램은 앱 비밀번호로만 연결합니다.',
|
||||
'Outlook이나 휴대폰 메일 앱에서 메일을 보려면 앱 비밀번호를 만들어 주세요. 2단계 인증을 켰다면 메일 프로그램은 앱 비밀번호로만 연결할 수 있습니다.',
|
||||
settingsTitle: '메일 프로그램 설정값',
|
||||
incoming: '받는 메일 (IMAP)',
|
||||
outgoing: '보내는 메일 (SMTP)',
|
||||
username: '사용자 이름',
|
||||
usernameNote: ' — 메일 주소 전체',
|
||||
usernameNote: ' · 메일 주소 전체',
|
||||
password: '비밀번호',
|
||||
passwordNote: '여기서 만든 앱 비밀번호 — 웹메일 비밀번호가 아닙니다',
|
||||
passwordNote: '웹메일 비밀번호가 아닌, 여기서 만든 앱 비밀번호',
|
||||
port: (port: number) => `포트 ${port}`,
|
||||
notes: [
|
||||
'앱 비밀번호는 메일 읽기 · 보내기(IMAP · SMTP)에만 쓰입니다. 웹메일 로그인에는 쓸 수 없습니다.',
|
||||
'앱 비밀번호는 메일 프로그램에서 메일을 읽고 보낼 때만 씁니다. 웹메일 로그인에는 쓸 수 없습니다.',
|
||||
'2단계 인증을 켜면 메일 프로그램은 앱 비밀번호로만 연결합니다. 켜지 않아도 만들어 쓸 수 있습니다.',
|
||||
'웹메일 비밀번호를 바꾸면 앱 비밀번호도 모두 지워집니다 — 메일 프로그램마다 새로 만들어 주세요.',
|
||||
'휴대폰이나 PC 를 잃어버렸으면 그 기기의 앱 비밀번호를 바로 지워 주세요.',
|
||||
'웹메일 비밀번호를 바꾸면 앱 비밀번호도 모두 지워지니, 메일 프로그램마다 새로 만들어 주세요.',
|
||||
'휴대폰이나 PC를 잃어버렸다면 그 기기의 앱 비밀번호를 바로 지워 주세요.',
|
||||
],
|
||||
deleted: (name: string) => `‘${name}’ 앱 비밀번호를 지웠습니다.`,
|
||||
} as const
|
||||
@@ -177,7 +177,7 @@ export const APP_PASSWORD_CREATE_TEXT = {
|
||||
title: '앱 비밀번호 만들기',
|
||||
nameLabel: '이름',
|
||||
namePlaceholder: '예: 회사 PC 아웃룩',
|
||||
nameHelp: '어느 프로그램 · 기기에서 쓰는지 알아볼 이름입니다. 40자까지.',
|
||||
nameHelp: '어느 프로그램이나 기기에서 쓰는지 알아볼 수 있게 지어 주세요. 40자까지 쓸 수 있습니다.',
|
||||
empty: '이름을 넣어 주세요.',
|
||||
duplicate: '같은 이름의 앱 비밀번호가 있습니다.',
|
||||
submit: '만들기',
|
||||
@@ -187,7 +187,7 @@ export const APP_PASSWORD_CREATE_TEXT = {
|
||||
/** 만든 결과 창 (모달 620 · 처음 초점 [복사]) — 값은 이 창에서 한 번만 */
|
||||
export const APP_PASSWORD_RESULT_TEXT = {
|
||||
title: '앱 비밀번호를 만들었습니다',
|
||||
message: (name: string) => `‘${name}’ — 메일 프로그램의 비밀번호 칸에 아래 값을 넣어 주세요.`,
|
||||
message: (name: string) => `‘${name}’ 앱 비밀번호입니다. 메일 프로그램의 비밀번호 칸에 아래 값을 넣어 주세요.`,
|
||||
copy: '복사',
|
||||
copyLabel: '앱 비밀번호 복사',
|
||||
lengthNote: (length: string) => `${length}자입니다. 보기 좋게 4자씩 띄웠지만 띄어쓰기는 없습니다.`,
|
||||
@@ -235,13 +235,13 @@ export const LOGIN_HISTORY_TEXT = {
|
||||
unknownBrowser: '알 수 없는 브라우저',
|
||||
unknownValue: '알 수 없음',
|
||||
noOthers: '다른 곳에 로그인된 브라우저가 없습니다. ',
|
||||
sessionsNoteBefore: '웹메일 로그인만 보입니다. 메일 프로그램은 ',
|
||||
sessionsNoteBefore: '웹메일 로그인만 보입니다. 메일 프로그램의 연결을 끊으려면 ',
|
||||
sessionsNoteLink: '앱 비밀번호',
|
||||
sessionsNoteAfter: '를 지워 끊습니다.',
|
||||
sessionEnded: (device: string) => `‘${device}’의 로그인을 끊었습니다.`,
|
||||
othersEnded: (count: string) => `다른 곳의 로그인 ${count}곳을 끊었습니다.`,
|
||||
sessionsNoteAfter: '를 지워 주세요.',
|
||||
sessionEnded: (device: string) => `‘${device}’에서 로그아웃했습니다.`,
|
||||
othersEnded: (count: string) => `다른 ${count}곳에서 로그아웃했습니다.`,
|
||||
/** 서버가 이 브라우저를 알아보지 못했을 때(지금 이 브라우저 줄이 없음) — 이 브라우저까지 끊었다 */
|
||||
allEnded: (count: string) => `이 브라우저를 포함한 ${count}곳의 로그인을 끊었습니다.`,
|
||||
allEnded: (count: string) => `이 브라우저를 포함해 ${count}곳에서 로그아웃했습니다.`,
|
||||
eventsTitle: '최근 로그인',
|
||||
eventsHintAll: '최근 90일 · 웹메일과 메일 프로그램',
|
||||
eventsHintFailures: (count: string) => `최근 90일 · 실패 ${count}번`,
|
||||
@@ -257,34 +257,34 @@ export const LOGIN_HISTORY_TEXT = {
|
||||
methodSmtp: '메일 프로그램 · SMTP',
|
||||
appPassword: (name: string) => `앱 비밀번호 ‘${name}’`,
|
||||
success: '성공',
|
||||
successTwoFactor: '성공 · 2단계 인증',
|
||||
successTwoFactor: '2단계 인증으로 성공',
|
||||
failure: '실패',
|
||||
failureWrongPassword: '실패 — 비밀번호 틀림',
|
||||
failureWrongCode: '실패 — 코드 틀림',
|
||||
failureAppPasswordRequired: '실패 — 앱 비밀번호 필요',
|
||||
failureWrongPassword: '비밀번호가 틀려 실패',
|
||||
failureWrongCode: '코드가 틀려 실패',
|
||||
failureAppPasswordRequired: '앱 비밀번호가 필요해 실패',
|
||||
more: '더 보기',
|
||||
moreHint: '최근 순서 · 20개씩. 90일이 지난 기록은 지웁니다.',
|
||||
moreHint: '최근 순서로 20개씩 보입니다. 90일이 지난 기록은 지워집니다.',
|
||||
emptyFailuresTitle: '최근 90일 동안 실패한 로그인이 없습니다',
|
||||
emptyFailuresHint: '[전체]를 누르면 성공한 로그인도 봅니다.',
|
||||
emptyFailuresHint: '[전체]를 누르면 성공한 로그인도 볼 수 있습니다.',
|
||||
emptyAllTitle: '최근 90일 동안 로그인 기록이 없습니다',
|
||||
emptyAllHint: '기록은 이 기능을 배포한 날부터 쌓입니다.',
|
||||
emptyAllHint: '이 기능이 생긴 날부터 기록이 쌓입니다.',
|
||||
} as const
|
||||
|
||||
/** [다른 곳 모두 로그아웃] 확인 (alertdialog 400 · 처음 초점 「취소」) */
|
||||
export const END_OTHER_SESSIONS_TEXT = {
|
||||
title: '다른 곳의 로그인을 모두 끊을까요?',
|
||||
title: '다른 곳에서 모두 로그아웃할까요?',
|
||||
/** "이 브라우저를 뺀 2곳(Edge · Windows, Safari · iOS)의 …" — 이름은 세 곳까지, 넘으면 "외 n곳" */
|
||||
message: (count: string, names: string) =>
|
||||
`이 브라우저를 뺀 ${count}곳(${names})의 웹메일 로그인을 끊습니다. 끊긴 곳은 다음 동작에서 바로 로그인 화면으로 돌아갑니다.`,
|
||||
`이 브라우저를 뺀 ${count}곳(${names})의 웹메일에서 로그아웃합니다. 그곳에서 다음에 무엇이든 누르면 바로 로그인 화면이 나옵니다.`,
|
||||
/**
|
||||
* 서버가 이 브라우저를 알아보지 못했을 때(지금 이 브라우저 줄이 없음 — 서버는 이 요청의 세션을 몰라 모두 끊는다, 계약 5절) —
|
||||
* 이 브라우저도 로그아웃된다고 알린다
|
||||
*/
|
||||
titleAll: '이 브라우저를 포함해 모든 곳의 로그인을 끊을까요?',
|
||||
titleAll: '이 브라우저를 포함해 모든 곳에서 로그아웃할까요?',
|
||||
messageAll: (count: string, names: string) =>
|
||||
`이 브라우저를 알아보지 못해 이 브라우저를 포함한 ${count}곳(${names})의 웹메일 로그인을 끊습니다. 이 브라우저도 로그아웃되어 로그인 화면으로 돌아갑니다.`,
|
||||
`이 브라우저를 알아보지 못해서 이 브라우저를 포함한 ${count}곳(${names})의 웹메일에서 로그아웃합니다. 이 브라우저도 로그아웃되어 로그인 화면으로 돌아갑니다.`,
|
||||
moreNames: (count: string) => `외 ${count}곳`,
|
||||
note: '메일 프로그램은 끊기지 않습니다 — 앱 비밀번호를 지워 끊어 주세요.',
|
||||
note: '메일 프로그램은 로그아웃되지 않습니다. 연결을 끊으려면 앱 비밀번호를 지워 주세요.',
|
||||
confirm: '모두 로그아웃',
|
||||
cancel: '취소',
|
||||
} as const
|
||||
|
||||
@@ -14,7 +14,7 @@ export const PASSWORD_CHANGE_TIMEOUT_MS = 30 * 1000
|
||||
|
||||
export const PASSWORD_TEXT = {
|
||||
title: '비밀번호',
|
||||
description: '웹메일과 휴대폰 메일 앱(IMAP)이 함께 쓰는 메일 계정의 비밀번호입니다.',
|
||||
description: '웹메일에 로그인할 때와 휴대폰 메일 앱을 연결할 때 함께 쓰는 비밀번호입니다.',
|
||||
/** 2단계 인증을 켠 직원의 머리 설명 (12단계 — 디자인 원본 SettingsTwoFactor "비밀번호 — 2단계 인증을 켠 직원") */
|
||||
descriptionTwoFactor: '웹메일에 로그인하는 비밀번호입니다. 2단계 인증이 켜져 있어 메일 프로그램은 앱 비밀번호로 연결합니다.',
|
||||
current: '지금 비밀번호',
|
||||
@@ -24,14 +24,14 @@ export const PASSWORD_TEXT = {
|
||||
showCurrent: '지금 비밀번호 보기',
|
||||
showNext: '새 비밀번호 보기',
|
||||
/** 새 비밀번호 아래 안내 — 규칙에 맞지 않으면 이 글이 빨간 오류로 바뀐다 */
|
||||
hint: '8자 이상 · 지금 비밀번호와 달라야 합니다. 영문 · 숫자 · 기호를 섞으면 더 안전합니다.',
|
||||
hint: '8자 이상이고 지금 비밀번호와 달라야 합니다. 영문, 숫자, 기호를 섞으면 더 안전합니다.',
|
||||
mismatch: '새 비밀번호가 서로 다릅니다.',
|
||||
/** 2단계 인증을 켠 직원만 — 양식 맨 아래 (코드가 30초마다 바뀌어 먼저 넣으면 다른 칸을 쓰는 동안 지날 수 있다) */
|
||||
code: '인증 앱 코드',
|
||||
codeHint: '코드는 30초마다 바뀝니다 — 다른 칸을 다 쓴 뒤 넣어 주세요.',
|
||||
codeHint: '코드는 30초마다 바뀌니 다른 칸을 다 채운 뒤에 넣어 주세요.',
|
||||
submit: '비밀번호 바꾸기',
|
||||
done: '비밀번호를 바꿨습니다. 다른 기기 · 브라우저의 로그인은 끊었습니다.',
|
||||
noteSessions: '바꾸면 이 브라우저를 뺀 다른 기기 · 브라우저의 웹메일 로그인이 끊깁니다.',
|
||||
done: '비밀번호를 바꿨습니다. 다른 기기와 브라우저에서는 로그아웃했습니다.',
|
||||
noteSessions: '바꾸면 이 브라우저를 빼고 다른 기기와 브라우저의 웹메일에서 로그아웃됩니다.',
|
||||
/** 모든 직원 (12단계) — 비밀번호를 바꾸면 서버가 그 직원의 앱 비밀번호를 모두 지운다 (계약 5절 "비밀번호 바꾸기") */
|
||||
noteAppPasswords: '앱 비밀번호도 모두 지워집니다 — 메일 프로그램은 새 앱 비밀번호로 다시 연결해 주세요.',
|
||||
noteAppPasswords: '앱 비밀번호도 모두 지워지니, 메일 프로그램은 새 앱 비밀번호로 다시 연결해 주세요.',
|
||||
} as const
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// 주소록 상수 — 계약 3절 "주소록 — 8단계" · 디자인 README "8단계 — 주소록" (디자인 원본 Contacts · ContactsEdit · ContactsMail)
|
||||
// 한도는 서버와 같은 값이다 — 서버가 최종 검증한다. 문구는 보드의 글 그대로다(보드에 없던 것은 README "구현하며 정한 보완"으로 옮긴다).
|
||||
// 한도는 서버와 같은 값이다 — 서버가 최종 검증한다. 문구는 README "화면 문구 — 사용자에게 말하듯"(2026-11-05)을 따른다 — 보드의 옛 글과 다를 수 있다.
|
||||
|
||||
import type { ContactEmailLabel, ContactPhoneLabel, DirectoryKind } from '@/shared/types/contacts'
|
||||
|
||||
@@ -114,9 +114,9 @@ export const CONTACT_GROUP_MENU_TEXT = {
|
||||
/** 상단 바의 찾기 칸 — 지금 보는 목록 안에서 찾는다 */
|
||||
export const CONTACTS_SEARCH_TEXT = {
|
||||
label: '주소록에서 찾기',
|
||||
placeholder: '이름 · 주소 · 회사 · 전화번호 — 초성도 됩니다 (ㅂㅈㅎ)',
|
||||
placeholder: '이름, 주소, 회사, 전화번호, 초성(ㅂㅈㅎ)으로 찾기',
|
||||
/** 사내 목록은 이름 · 주소로만 찾는다 (계약 3절 "찾기") */
|
||||
directoryPlaceholder: '이름 · 주소 — 초성도 됩니다 (ㅂㅈㅎ)',
|
||||
directoryPlaceholder: '이름, 주소, 초성(ㅂㅈㅎ)으로 찾기',
|
||||
clear: '찾을 글 지우기',
|
||||
} as const
|
||||
|
||||
@@ -126,7 +126,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: '구성원 넣기',
|
||||
@@ -142,11 +142,11 @@ export const CONTACTS_LIST_TEXT = {
|
||||
/** 목록의 빈 상태 · 못 받음 (디자인 원본 Contacts · ContactsEdit "상태") */
|
||||
export const CONTACTS_EMPTY_TEXT = {
|
||||
searchTitle: (query: string, directory: boolean) => `‘${query}’에 맞는 ${directory ? '주소가' : '연락처가'} 없습니다`,
|
||||
searchHint: '이름 · 주소 · 회사 · 전화번호의 일부나 이름의 초성으로 찾습니다.',
|
||||
directorySearchHint: '이름 · 주소의 일부나 이름의 초성으로 찾습니다.',
|
||||
searchHint: '이름, 주소, 회사, 전화번호의 일부나 이름의 초성으로 찾을 수 있습니다.',
|
||||
directorySearchHint: '이름이나 주소의 일부, 이름의 초성으로 찾을 수 있습니다.',
|
||||
firstTitle: '아직 연락처가 없습니다',
|
||||
firstHint: '자주 메일을 주고받는 사람을 넣어 두면 받는 사람 칸에서 바로 찾습니다.',
|
||||
firstNote: '메일을 읽다가 ⋯ › 「보낸 사람을 주소록에 추가」로도 넣을 수 있습니다.',
|
||||
firstHint: '자주 메일을 주고받는 사람을 넣어 두면 받는 사람 칸에서 바로 찾을 수 있습니다.',
|
||||
firstNote: '메일을 읽다가 [⋯] → [보낸 사람을 주소록에 추가]로도 넣을 수 있습니다.',
|
||||
groupTitle: (name: string) => `‘${name}’ 그룹에 아직 아무도 없습니다`,
|
||||
groupHint: '구성원을 넣으면 그룹 이름으로 한 번에 메일을 보낼 수 있습니다.',
|
||||
directoryTitle: {
|
||||
@@ -183,9 +183,9 @@ export const CONTACT_DETAIL_TEXT = {
|
||||
/** 그룹 단추의 이름 — 누르면 그 그룹의 목록 */
|
||||
openGroup: (name: string) => `‘${name}’ 그룹 보기`,
|
||||
directoryHint:
|
||||
'사내 목록은 메일 서버의 계정에서 옵니다 — 이름과 주소만 보이고 여기서 고칠 수 없습니다. 전화번호 · 메모를 적어 두려면 내 주소록에 추가하세요.',
|
||||
noneTitle: '연락처를 고르세요',
|
||||
noneHint: '목록에서 고르면 주소 · 전화번호가 여기에 보입니다.',
|
||||
'사내 목록은 이름과 주소만 보이고 여기서 고칠 수 없습니다. 전화번호나 메모를 적어 두려면 내 주소록에 추가해 주세요.',
|
||||
noneTitle: '연락처를 골라 주세요',
|
||||
noneHint: '목록에서 고르면 주소와 전화번호가 여기에 보입니다.',
|
||||
leaveToList: '목록으로',
|
||||
loading: '연락처 불러오는 중',
|
||||
} as const
|
||||
@@ -208,13 +208,13 @@ export const CONTACT_DIALOG_TEXT = {
|
||||
emailAddress: (ordinal: string) => `${ordinal} 이메일`,
|
||||
emailRemove: (ordinal: string) => `${ordinal} 이메일 빼기`,
|
||||
addEmail: '이메일 더하기',
|
||||
emailHint: '5개까지 · 받는 사람 칸에는 맨 위 주소가 먼저 나옵니다',
|
||||
emailHint: '5개까지 넣을 수 있고, 받는 사람 칸에는 맨 위 주소가 먼저 나옵니다.',
|
||||
phone: '전화번호',
|
||||
phoneKind: (ordinal: string) => `${ordinal} 전화번호의 종류`,
|
||||
phoneNumber: (ordinal: string) => `${ordinal} 전화번호`,
|
||||
phoneRemove: (ordinal: string) => `${ordinal} 전화번호 빼기`,
|
||||
addPhone: '전화번호 더하기',
|
||||
phoneHint: '5개까지',
|
||||
phoneHint: '5개까지 넣을 수 있습니다.',
|
||||
company: '회사',
|
||||
department: '부서',
|
||||
title: '직책',
|
||||
@@ -228,15 +228,15 @@ export const CONTACT_DIALOG_TEXT = {
|
||||
emailsTooMany: (max: string) => `이메일은 ${max}개까지 넣을 수 있습니다. 몇 개를 빼 주세요.`,
|
||||
phonesTooMany: (max: string) => `전화번호는 ${max}개까지 넣을 수 있습니다. 몇 개를 빼 주세요.`,
|
||||
groups: '그룹',
|
||||
required: '이름이나 이메일 가운데 하나는 있어야 합니다.',
|
||||
required: '이름이나 이메일 가운데 하나는 넣어 주세요.',
|
||||
save: '저장',
|
||||
cancel: '취소',
|
||||
remove: '이 연락처 지우기',
|
||||
emailInvalid: '이메일 형식이 올바르지 않습니다.',
|
||||
emailRepeated: '같은 주소를 두 번 넣었습니다.',
|
||||
phoneInvalid: '전화번호 형식이 올바르지 않습니다. 숫자와 띄어쓰기 · + - ( ) . # * 만 쓸 수 있습니다.',
|
||||
phoneInvalid: '전화번호 형식이 올바르지 않습니다. 숫자와 띄어쓰기, + - ( ) . # * 기호만 쓸 수 있습니다.',
|
||||
/** 같은 주소가 이미 주소록에 있다 — 막지 않는다 (who: "강서윤(거래처)") */
|
||||
duplicate: (who: string) => `이미 주소록에 있는 주소입니다 — ${who}. 그래도 저장할 수 있습니다.`,
|
||||
duplicate: (who: string) => `주소록에 있는 ${who}의 주소와 같습니다. 그래도 저장할 수 있습니다.`,
|
||||
duplicateView: '그 연락처 보기',
|
||||
} as const
|
||||
|
||||
@@ -273,8 +273,8 @@ export const CONTACT_MEMBERS_TEXT = {
|
||||
mine: '내 연락처',
|
||||
staff: '사내 직원',
|
||||
search: '구성원으로 넣을 연락처 찾기',
|
||||
searchPlaceholder: '이름 · 주소 — 초성도 됩니다',
|
||||
note: '이미 이 그룹에 있는 연락처는 보이지 않습니다. 「사내 직원」에서 고르면 그 직원을 내 주소록에도 추가합니다(이름 · 주소).',
|
||||
searchPlaceholder: '이름, 주소, 초성으로 찾기',
|
||||
note: '이미 이 그룹에 있는 연락처는 보이지 않습니다. [사내 직원]에서 고르면 그 직원의 이름과 주소를 내 주소록에도 추가합니다.',
|
||||
picked: (count: string) => `${count}명 고름`,
|
||||
pickMax: '한 번에 100명까지 넣을 수 있습니다.',
|
||||
add: '넣기',
|
||||
@@ -294,7 +294,7 @@ export const CONTACTS_TOAST_TEXT = {
|
||||
groupCreated: (name: string) => `‘${name}’ 그룹을 만들었습니다.`,
|
||||
groupRenamed: '그룹 이름을 바꿨습니다.',
|
||||
groupRemoved: (name: string, count: string | null) =>
|
||||
count === null ? `‘${name}’ 그룹을 지웠습니다.` : `‘${name}’ 그룹을 지웠습니다 — 연락처 ${count}명은 그대로 있습니다.`,
|
||||
count === null ? `‘${name}’ 그룹을 지웠습니다.` : `‘${name}’ 그룹을 지웠습니다. 그룹에 있던 연락처 ${count}명은 그대로 남아 있습니다.`,
|
||||
groupRestored: '그룹을 되돌렸습니다.',
|
||||
/** 응답의 addedCount — 고른 수와 다를 수 있다(이미 구성원 · 그룹에 넣을 수 없는 카드는 서버가 건너뛴다) */
|
||||
membersAdded: (count: string, group: string) => `${count}명을 ‘${group}’ 그룹에 넣었습니다.`,
|
||||
@@ -317,7 +317,7 @@ export const CONTACT_READER_TEXT = {
|
||||
/** 받는 사람 추천의 주소록 · 그룹 (디자인 원본 ContactsMail "받는 사람 추천") */
|
||||
export const RECIPIENT_CONTACT_TEXT = {
|
||||
/** 그룹 줄의 설명 */
|
||||
groupDetail: (count: string) => `${count}명 · 고르면 한꺼번에 넣습니다`,
|
||||
groupDetail: (count: string) => `고르면 ${count}명을 한꺼번에 넣습니다`,
|
||||
groupLoading: (group: string) => `‘${group}’ 그룹의 구성원을 불러오는 중…`,
|
||||
groupAdded: (group: string, count: string, field: string) => `‘${group}’ 그룹의 ${count}명을 ${field}에 넣었습니다.`,
|
||||
groupNone: (group: string, field: string) => `‘${group}’ 그룹에서 ${field}에 더 넣을 주소가 없습니다.`,
|
||||
|
||||
@@ -8,23 +8,23 @@ export const ERROR_CODE_MESSAGES = {
|
||||
AUTH_003: '해당 메뉴나 기능에 접근할 수 있는 권한이 없습니다.',
|
||||
AUTH_004: '이메일 또는 비밀번호가 올바르지 않습니다.',
|
||||
AUTH_005: '지금 비밀번호가 올바르지 않습니다.',
|
||||
AUTH_006: '코드가 올바르지 않습니다. 휴대폰의 날짜와 시간이 「자동」으로 맞춰져 있는지 확인해 주세요.',
|
||||
AUTH_006: '코드가 올바르지 않습니다. 휴대폰의 날짜와 시간이 자동으로 맞춰지도록 설정되어 있는지 확인해 주세요.',
|
||||
AUTH_007: '인증 앱의 코드를 입력해 주세요.',
|
||||
AUTH_008: '2단계 인증 설정이 그사이 바뀌었거나 시간이 지났습니다. 처음부터 다시 해 주세요.',
|
||||
AUTH_009: '5개까지 만들 수 있습니다. 쓰지 않는 앱 비밀번호를 지운 뒤 만들어 주세요.',
|
||||
AUTH_010: '먼저 비밀번호를 바꾸거나 2단계 인증을 켜 주세요.',
|
||||
VAL_001: '입력하신 정보를 다시 확인해 주세요. (필수값 누락 또는 형식 오류)',
|
||||
VAL_001: '입력하신 내용을 다시 확인해 주세요. 빠진 칸이 있거나 형식이나 길이가 맞지 않습니다.',
|
||||
RES_001: '요청하신 정보나 페이지를 찾을 수 없습니다.',
|
||||
BIZ_001: '요청하신 작업을 완료하지 못했습니다. 다시 시도해 주세요.',
|
||||
FILE_001: '허용되지 않는 파일 형식입니다. 파일을 확인해 주세요.',
|
||||
FILE_002: '파일 크기가 허용 범위를 초과했습니다.',
|
||||
FILE_003: '파일 올리기 상태가 맞지 않습니다. 다시 시도해 주세요.',
|
||||
FILE_004: '파일 조각이 보내는 중에 손상되었습니다. 다시 시도해 주세요.',
|
||||
FILE_003: '파일 상태가 그사이 바뀌어 처리하지 못했습니다. 다시 시도해 주세요.',
|
||||
FILE_004: '파일을 올리는 중에 일부가 제대로 전달되지 않았습니다. 다시 시도해 주세요.',
|
||||
FILE_005: '대용량 첨부 보관 한도를 넘어 더 올릴 수 없습니다. 보낸 파일의 링크를 끊거나 보내지 않은 파일을 지우면 공간이 생깁니다.',
|
||||
FILE_006: '서버의 저장 공간이 모자라 지금은 파일을 올릴 수 없습니다. 잠시 뒤 다시 시도해 주세요.',
|
||||
FILE_007: '이 파일은 더 내려받을 수 없습니다. 필요하면 보낸 사람에게 문의해 주세요.',
|
||||
FILE_008: '보안 검사가 끝나지 않은 파일입니다. 잠시 뒤 다시 시도해 주세요.',
|
||||
MAIL_001: '메일 서버와 연결할 수 없습니다. 잠시 후 다시 시도해 주세요.',
|
||||
MAIL_001: '메일 서비스에 연결할 수 없습니다. 잠시 후 다시 시도해 주세요.',
|
||||
MAIL_002: '받는 사람 주소를 다시 확인해 주세요.',
|
||||
MAIL_003: '메일함 용량이 부족합니다. 불필요한 메일을 정리한 뒤 다시 시도해 주세요.',
|
||||
MAIL_004: '첨부 파일을 찾을 수 없습니다. 해당 첨부를 빼거나 다시 올린 뒤 보내 주세요.',
|
||||
@@ -36,12 +36,12 @@ export const ERROR_CODE_MESSAGES = {
|
||||
MAIL_010: '받는 사람 주소로 메일을 보낼 수 없습니다. 주소를 확인해 주세요.',
|
||||
MAIL_011: '받는 쪽이 지금은 메일을 받을 수 없습니다. 잠시 뒤 다시 보내 주세요.',
|
||||
MAIL_012: '다른 곳에서 필터가 바뀌어 저장하지 않았습니다. 다시 불러온 뒤 고쳐 주세요.',
|
||||
MAIL_013: '필터나 차단 목록이 너무 길어 저장할 수 없습니다. 조건을 줄이거나 쓰지 않는 필터 · 차단 주소를 지워 주세요.',
|
||||
MAIL_013: '필터나 차단 목록이 너무 길어 저장할 수 없습니다. 조건을 줄이거나, 쓰지 않는 필터나 차단한 주소를 지워 주세요.',
|
||||
MAIL_014: '이미 보낸 메일이라 취소할 수 없습니다.',
|
||||
MAIL_015: '보내기를 기다리는 메일은 옮기거나 지울 수 없습니다. 먼저 보내기를 취소해 주세요.',
|
||||
MAIL_016: '대용량 첨부 파일을 보낼 수 없습니다. 해당 파일을 빼거나 다시 올린 뒤 보내 주세요.',
|
||||
CONTACT_001: '같은 이름의 그룹이 있습니다. 다른 이름을 써 주세요.',
|
||||
CONTACT_002: '주소록 한도를 넘어 더 넣을 수 없습니다. (연락처 3,000개 · 그룹 100개 · 그룹 하나에 500명까지)',
|
||||
CONTACT_002: '주소록 한도를 넘어 더 넣을 수 없습니다. 연락처 3,000개, 그룹 100개, 그룹 하나에 500명까지 넣을 수 있습니다.',
|
||||
} as const
|
||||
|
||||
export type ErrorCode = keyof typeof ERROR_CODE_MESSAGES
|
||||
|
||||
@@ -81,13 +81,13 @@ export const SHARE_SCAN_POLL_LIMIT = 20
|
||||
|
||||
// ── 화면 글 — 쓰기 화면 (디자인 원본 ComposeLargeFiles) ─────────────
|
||||
export const LARGE_FILE_TEXT = {
|
||||
/** 첨부 안내 — "파일당 최대 20MB · 합계 25MB · 최대 20개 · 더 큰 파일은 링크로 보냅니다 (파일당 2GB · 10개까지)" */
|
||||
hintTail: (maxFile: string, maxCount: string) => `더 큰 파일은 링크로 보냅니다 (파일당 ${maxFile} · ${maxCount}개까지)`,
|
||||
/** 첨부 안내 — "파일당 최대 20MB · 합계 25MB · 최대 20개 · 더 큰 파일은 파일당 2GB, 10개까지 링크로 보냅니다" */
|
||||
hintTail: (maxFile: string, maxCount: string) => `더 큰 파일은 파일당 ${maxFile}, ${maxCount}개까지 링크로 보냅니다`,
|
||||
normalGroup: (count: string) => `메일에 담는 파일 ${count}개`,
|
||||
normalTotal: (used: string, limit: string) => `합계 ${used} / ${limit}`,
|
||||
linkGroup: (count: string) => `링크로 보내는 파일 ${count}개`,
|
||||
linkGroupHint: (days: string, until: string) =>
|
||||
`받는 사람은 보낸 날부터 ${days}일 동안 내려받습니다 — 오늘 보내면 ${until}까지`,
|
||||
`받는 사람은 보낸 날부터 ${days}일 동안 내려받을 수 있습니다. 오늘 보내면 ${until}까지입니다.`,
|
||||
/** 보관 사용량이 80%를 넘을 때 링크 묶음 머리 오른쪽 */
|
||||
quotaWarning: (usage: string) => `보관 ${usage}`,
|
||||
/** 합계 때문에 저절로 링크로 바꿨을 때 — 칸 아래 ⓘ 한 줄 (role=status) */
|
||||
@@ -111,22 +111,22 @@ export const LARGE_FILE_TEXT = {
|
||||
queued: '차례를 기다리는 중',
|
||||
reconnecting: (progress: string) => `연결이 끊겨 다시 잇는 중… · ${progress} 올림`,
|
||||
paused: (progress: string) => `멈춤 · ${progress} 올림`,
|
||||
stalled: (uploaded: string) => `올리지 못하고 멈췄습니다 — 올린 ${uploaded} 는 남아 있습니다.`,
|
||||
stalled: (uploaded: string) => `올리지 못하고 멈췄습니다. 지금까지 올린 ${uploaded}는 남아 있습니다.`,
|
||||
stalledEmpty: '올리지 못하고 멈췄습니다.',
|
||||
waitingFile: (progress: string) =>
|
||||
`올리다 멈춘 파일 — 같은 파일을 다시 고르면 이어서 올립니다 · ${progress} 올림`,
|
||||
repickMismatch: '고른 파일이 멈춘 파일과 다릅니다(이름 · 크기). 같은 파일을 골라 주세요.',
|
||||
scanning: (size: string) => `${size} · 보안 검사 중 — 끝나지 않아도 보낼 수 있습니다`,
|
||||
unscannable: (size: string) => `${size} · 검사하지 못함 — 암호가 걸린 압축 파일 등`,
|
||||
`올리다 멈춤 · ${progress} 올림 · 같은 파일을 다시 고르면 이어서 올립니다`,
|
||||
repickMismatch: '고른 파일의 이름이나 크기가 멈춘 파일과 다릅니다. 같은 파일을 골라 주세요.',
|
||||
scanning: (size: string) => `${size} · 보안 검사 중이지만 보낼 수 있습니다`,
|
||||
unscannable: (size: string) => `${size} · 암호가 걸린 압축 파일처럼 검사하지 못한 파일`,
|
||||
keptInDraft: (size: string, until: string) => `${size} · 이 임시 저장본에 ${until}까지 남습니다`,
|
||||
infected: '위험한 내용이 발견되어 보낼 수 없습니다. 이 파일을 빼 주세요.',
|
||||
missingExpired: '기간이 지나 지운 파일입니다 — 빼고 다시 올려 주세요.',
|
||||
missingDeleted: '설정 › 대용량 첨부에서 지운 파일입니다 — 빼고 다시 올려 주세요.',
|
||||
missingUnknown: '서버에서 찾을 수 없는 파일입니다 — 빼고 다시 올려 주세요.',
|
||||
missingExpired: '기간이 지나 지워진 파일입니다. 빼고 다시 올려 주세요.',
|
||||
missingDeleted: '[설정] → [대용량 첨부]에서 지운 파일입니다. 빼고 다시 올려 주세요.',
|
||||
missingUnknown: '파일을 찾을 수 없습니다. 빼고 다시 올려 주세요.',
|
||||
tooLarge: (max: string) => `파일 하나는 ${max} 까지 보낼 수 있습니다.`,
|
||||
tooMany: (count: string) => `링크로 보내는 파일은 메일 한 통에 ${count}개까지입니다.`,
|
||||
overQuota: (limit: string) =>
|
||||
`대용량 보관 한도(${limit})를 넘어 올릴 수 없습니다. 보내지 않은 파일을 지우거나 링크를 끊으면 바로 공간이 생깁니다.`,
|
||||
`대용량 첨부는 ${limit}까지 보관할 수 있어 더 올릴 수 없습니다. 보내지 않은 파일을 지우거나 링크를 끊으면 바로 공간이 생깁니다.`,
|
||||
quotaLink: '대용량 첨부 보기',
|
||||
unnamed: '(이름 없는 파일)',
|
||||
/** 화면 낭독기 — 10% 마다 */
|
||||
@@ -136,21 +136,21 @@ export const LARGE_FILE_TEXT = {
|
||||
/** [멈춤] 단추 이름 — "설계도_v3.dwg 올리기 멈춤" */
|
||||
pauseLabel: (name: string) => `${name} 올리기 멈춤`,
|
||||
/** 서버가 올리던 파일을 잊었다(404 — 마지막 조각 뒤 24시간 · 설정에서 지움) — 멈춘 줄 글. [다시 시도]는 처음부터 새로 올린다 */
|
||||
restartNeeded: '서버에서 올리던 파일을 찾을 수 없습니다 — 다시 시도하면 처음부터 올립니다.',
|
||||
restartNeeded: '올리던 파일을 찾을 수 없습니다. [다시 시도]를 누르면 처음부터 올립니다.',
|
||||
} as const
|
||||
|
||||
/** [보내기]를 막는 까닭 — 단추 옆 글 (디자인 README "11단계 › 다 올릴 때까지 보내기를 막는다") */
|
||||
export const SEND_BLOCK_TEXT = {
|
||||
uploading: (count: string) => `파일 ${count}개를 올리는 중입니다 — 다 올리면 보낼 수 있습니다.`,
|
||||
incomplete: '다 올리지 못한 파일이 있습니다 — 이어 올리거나 빼 주세요.',
|
||||
unsendable: '보낼 수 없는 파일이 있습니다 — 빨간 줄의 파일을 빼 주세요.',
|
||||
tooMany: (count: string) => `링크로 보내는 파일은 메일 한 통에 ${count}개까지입니다 — 빼 주세요.`,
|
||||
uploading: (count: string) => `파일 ${count}개를 올리는 중입니다. 다 올린 뒤에 보낼 수 있습니다.`,
|
||||
incomplete: '다 올리지 못한 파일이 있습니다. 이어 올리거나 빼 주세요.',
|
||||
unsendable: '보낼 수 없는 파일이 있습니다. 빨간 줄의 파일을 빼 주세요.',
|
||||
tooMany: (count: string) => `링크로 보내는 파일은 메일 한 통에 ${count}개까지입니다. 몇 개를 빼 주세요.`,
|
||||
} as const
|
||||
|
||||
/** 인용 속 원래 링크의 상태 — 첨부 칸 아래 글자로만 (디자인 README "11단계 › 읽기 화면 › 전달 쓰기 화면") */
|
||||
export const QUOTED_LINK_TEXT = {
|
||||
active: (count: string, until: string) => `원래 메일의 대용량 첨부 ${count}개는 인용 속 링크로 함께 갑니다 — ${until}까지`,
|
||||
activeUnknownCount: (until: string) => `인용 속 대용량 첨부 링크는 함께 갑니다 — ${until}까지`,
|
||||
active: (count: string, until: string) => `원래 메일의 대용량 첨부 ${count}개는 인용 속 링크로 함께 갑니다. ${until}까지 받을 수 있습니다.`,
|
||||
activeUnknownCount: (until: string) => `인용 속 대용량 첨부 링크도 함께 갑니다. ${until}까지 받을 수 있습니다.`,
|
||||
revoked: (label: string) => `인용 속 ${label} 링크는 공유가 멈춰 받을 수 없습니다.`,
|
||||
expired: (label: string) => `인용 속 ${label} 링크는 기간이 지나 받을 수 없습니다.`,
|
||||
/** 파일 이름을 모를 때 */
|
||||
@@ -172,18 +172,18 @@ export const MESSAGE_LARGE_FILE_TEXT = {
|
||||
until: (date: string) => `${date}까지`,
|
||||
downloads: (count: string) => `${count}회 받음`,
|
||||
notDownloaded: '아직 받지 않음',
|
||||
scanning: '보안 검사 중 — 끝나면 받을 수 있습니다',
|
||||
infected: '위험 — 보안 검사에서 막았습니다. 받을 수 없습니다',
|
||||
unscannable: '검사하지 못함 — 암호가 걸린 압축 파일 등',
|
||||
expired: (date: string) => `기간 지남 — ${date}까지였습니다`,
|
||||
revoked: '공유 멈춤 — 보낸 사람이 링크를 끊었습니다',
|
||||
scanning: '보안 검사가 끝나면 받을 수 있습니다',
|
||||
infected: '위험한 내용이 있어 보안 검사에서 막았고, 받을 수 없습니다',
|
||||
unscannable: '암호가 걸린 압축 파일처럼 검사하지 못한 파일',
|
||||
expired: (date: string) => `받을 수 있는 기간이 ${date}에 끝났습니다`,
|
||||
revoked: '보낸 사람이 링크를 끊어 받을 수 없습니다',
|
||||
/** 보낸 사람 자신 — 끊은 날을 알면 그 날짜(디자인 원본 MailReadLargeFiles 셋째 줄), 모르면 날짜 없이 */
|
||||
revokedOwn: (date: string | null) => (date ? `공유 멈춤 — ${date}에 끊었습니다` : '공유 멈춤 — 링크를 끊었습니다'),
|
||||
limitReached: '횟수 초과 — 더 내려받을 수 없습니다',
|
||||
missing: '찾을 수 없음 — 더 내려받을 수 없습니다',
|
||||
revokedOwn: (date: string | null) => (date ? `${date}에 링크를 끊었습니다` : '링크를 끊었습니다'),
|
||||
limitReached: '받을 수 있는 횟수를 넘어 더 내려받을 수 없습니다',
|
||||
missing: '파일을 찾을 수 없어 더 내려받을 수 없습니다',
|
||||
download: (name: string) => `${name} 다운로드`,
|
||||
/** 보낸 사람 자신 — 첨부 칸 아래 한 줄 */
|
||||
linkNote: (count: string) => `링크로 보낸 파일 ${count}개 — 받는 사람 모두 같은 링크로 받습니다.`,
|
||||
linkNote: (count: string) => `링크로 보낸 파일 ${count}개는 받는 사람 모두 같은 링크로 받습니다.`,
|
||||
linkNoteCut: (count: number) =>
|
||||
count === 1 ? '끊으면 받을 수 없습니다.' : count === 2 ? '끊으면 둘 다 받을 수 없습니다.' : '끊으면 모두 받을 수 없습니다.',
|
||||
copyLink: '링크 복사',
|
||||
@@ -192,7 +192,7 @@ export const MESSAGE_LARGE_FILE_TEXT = {
|
||||
revoke: '링크 끊기',
|
||||
/** 본문의 덩이를 접는 줄 (본문 틀 안 — 스크립트 없이 details · summary) */
|
||||
foldLabel: '본문의 대용량 첨부 안내',
|
||||
foldHint: '(접어 둠) — 파일은 아래 첨부 칸에 있습니다',
|
||||
foldHint: '파일은 아래 첨부 칸에 있어 접어 두었습니다',
|
||||
foldOpen: '펼치기',
|
||||
foldClose: '접기',
|
||||
} as const
|
||||
@@ -206,7 +206,7 @@ export const LARGE_FILE_REVOKE_TEXT = {
|
||||
/** 설정 — 메일 제목으로 */
|
||||
messageWithSubject: (subject: string, count: string) =>
|
||||
`‘${subject}’의 파일 ${count}개를 받는 사람 모두 더는 내려받을 수 없습니다. 이미 받은 파일은 되돌릴 수 없고, 끊은 링크는 다시 열 수 없습니다.`,
|
||||
nameSeparator: ' · ',
|
||||
nameSeparator: ', ',
|
||||
confirm: '링크 끊기',
|
||||
cancel: '취소',
|
||||
done: '링크를 끊었습니다. 받는 사람은 더 이상 내려받을 수 없습니다.',
|
||||
@@ -224,16 +224,16 @@ export const LARGE_FILE_SETTINGS_TEXT = {
|
||||
description: (days: string) =>
|
||||
`메일에 링크로 보낸 큰 파일입니다. 받는 사람은 보낸 날부터 ${days}일 동안 내려받고, 링크를 끊으면 바로 받을 수 없습니다.`,
|
||||
usageLabel: '보관 사용량',
|
||||
usageSplit: (sent: string, unsent: string) => `보낸 링크 ${sent} · 보내지 않은 파일 ${unsent}(임시 저장 · 올리다 멈춤).`,
|
||||
usageSplit: (sent: string, unsent: string) => `보낸 링크 ${sent}, 임시 저장했거나 올리다 멈춘 파일 ${unsent}입니다.`,
|
||||
usageNote: '기간이 지나거나, 링크를 끊거나, 보내지 않은 파일을 지우면 바로 줄어듭니다.',
|
||||
unsentTitle: (count: string) => `보내지 않은 파일 ${count}개`,
|
||||
unsentHint: (size: string) => `${size} · 임시 저장한 메일에 실렸거나 올리다 멈춘 파일 — 지우면 바로 공간이 생깁니다`,
|
||||
unsentHint: (size: string) => `${size} · 임시 저장한 메일에 실렸거나 올리다 멈춘 파일이라 지우면 바로 공간이 생깁니다`,
|
||||
unsentList: '보내지 않은 파일',
|
||||
unsentUploading: '올리다 멈춤',
|
||||
unsentReady: '다 올림',
|
||||
unsentScanning: '보안 검사 중',
|
||||
unsentUnscannable: '검사하지 못함',
|
||||
unsentInfected: '위험 — 보낼 수 없음',
|
||||
unsentInfected: '위험해서 보낼 수 없음',
|
||||
partUploaded: (bytes: string) => `${bytes} 올림`,
|
||||
remainUntil: (when: string) => `${when}까지 남음`,
|
||||
afterLastChunk: '마지막으로 올린 뒤 24시간',
|
||||
@@ -241,12 +241,12 @@ 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}’ 파일을 지웠습니다.`,
|
||||
linksTitle: (count: string) => `보낸 링크 ${count}개`,
|
||||
linksHint: '메일마다 한 덩이 · 최근에 보낸 차례',
|
||||
linksHint: '메일마다 하나씩, 최근에 보낸 순서',
|
||||
linksList: '링크로 보낸 메일',
|
||||
sentLine: (day: string, count: string, total: string) => `${day} 보냄 · 파일 ${count}개 · ${total}`,
|
||||
until: (date: string) => `${date}까지`,
|
||||
@@ -269,7 +269,7 @@ export const LARGE_FILE_SETTINGS_TEXT = {
|
||||
notDownloaded: '아직 받지 않음',
|
||||
lastDownloaded: (when: string) => `마지막 ${when}`,
|
||||
more: '더 보기',
|
||||
moreHint: '최근에 보낸 차례 · 20개씩. 끝난 링크는 90일 뒤 목록에서 빠집니다.',
|
||||
moreHint: '최근에 보낸 순서로 20개씩 보입니다. 끝난 링크는 90일 뒤 목록에서 빠집니다.',
|
||||
emptyTitle: '링크로 보낸 파일이 없습니다',
|
||||
emptyHint: (max: string, days: string) =>
|
||||
`메일 쓰기에서 ${max} 가 넘는 파일을 고르면 링크로 보냅니다. 받는 사람은 보낸 날부터 ${days}일 동안 내려받을 수 있습니다.`,
|
||||
@@ -279,31 +279,33 @@ export const LARGE_FILE_SETTINGS_TEXT = {
|
||||
// ── 화면 글 — 공개 내려받기 화면 (디자인 원본 SharePage) ─────────────
|
||||
export const SHARE_PAGE_TEXT = {
|
||||
barTitle: '파일 내려받기',
|
||||
/** 탭 제목 — "김아라 님이 보낸 파일 — ARA SYSTEM" */
|
||||
tabTitle: (sender: string, app: string) => `${sender} 님이 보낸 파일 — ${app}`,
|
||||
tabTitleDefault: (app: string) => `파일 내려받기 — ${app}`,
|
||||
/** 탭 제목 — "김아라 님이 보낸 파일 · ARA SYSTEM" (앱의 다른 탭 제목과 같은 가운뎃점) */
|
||||
tabTitle: (sender: string, app: string) => `${sender} 님이 보낸 파일 · ${app}`,
|
||||
tabTitleDefault: (app: string) => `파일 내려받기 · ${app}`,
|
||||
title: (sender: string, count: string, total: string) => `${sender} 님이 보낸 파일 ${count}개 (${total})`,
|
||||
senderLabel: '보낸 사람',
|
||||
/** 기한 줄 — "한국 시간으로 **2026년 11월 29일(일)까지** 내려받을 수 있습니다. 30일 남았습니다." (날짜 조각만 굵게) */
|
||||
untilBefore: '한국 시간으로 ',
|
||||
until: (date: string) => `${date}까지`,
|
||||
untilTail: (days: string) => ` 내려받을 수 있습니다 (한국 시간) · ${days}일 남음`,
|
||||
untilTailLastDay: ' 내려받을 수 있습니다 (한국 시간) · 오늘까지',
|
||||
clean: '보안 검사 · 이상 없음',
|
||||
untilTail: (days: string) => ` 내려받을 수 있습니다. ${days}일 남았습니다.`,
|
||||
untilTailLastDay: ' 내려받을 수 있습니다. 오늘이 마지막 날입니다.',
|
||||
clean: '보안 검사 결과 이상 없음',
|
||||
scanning: '보안 검사 중',
|
||||
unscannable: '검사하지 못함 — 암호가 걸린 압축 파일 등 안을 볼 수 없는 파일입니다. 보낸 사람을 아는 경우에만 여세요.',
|
||||
infected: '위험 — 막았습니다',
|
||||
unscannable: '암호가 걸린 압축 파일처럼 안을 볼 수 없어 검사하지 못한 파일입니다. 보낸 사람을 아는 경우에만 열어 주세요.',
|
||||
infected: '위험한 내용이 있어 막았습니다',
|
||||
limitReached: '내려받을 수 있는 횟수를 넘었습니다. 보낸 사람에게 다시 보내 달라고 요청해 주세요.',
|
||||
missing: '찾을 수 없는 파일입니다 — 더 내려받을 수 없습니다.',
|
||||
missing: '파일을 찾을 수 없어 더 내려받을 수 없습니다.',
|
||||
download: '내려받기',
|
||||
downloadLabel: (name: string) => `${name} 내려받기`,
|
||||
resumeHint: '내려받다 끊기면 브라우저의 다운로드 목록에서 다시 시도하세요 — 받은 곳부터 이어서 받습니다.',
|
||||
resumeHint: '내려받다 끊기면 브라우저의 다운로드 목록에서 다시 시도해 주세요. 받은 곳부터 이어서 받습니다.',
|
||||
noLogin: '이 화면은 로그인이나 비밀번호를 묻지 않습니다. 묻는 화면이 나오면 닫고 보낸 사람에게 확인해 주세요.',
|
||||
reportBefore: '받을 이유가 없는 파일이면 ',
|
||||
reportAfter: ' 로 알려 주세요.',
|
||||
englishActive: (sender: string, email: string, until: string) =>
|
||||
`Files from ${sender} (${email}) — download by ${until} (KST). This page never asks for a login or password.`,
|
||||
`Files from ${sender} (${email}). Download by ${until} (KST). This page will never ask you to log in or enter a password.`,
|
||||
scanningTitle: '보안 검사 중입니다',
|
||||
scanningMessage: '파일을 검사하고 있습니다. 몇 분 뒤 이 화면을 다시 열어 주세요.',
|
||||
scanningEnglish: 'Security scan in progress — please check again in a few minutes.',
|
||||
scanningEnglish: 'A security scan is in progress. Please check again in a few minutes.',
|
||||
recheck: '다시 확인',
|
||||
infectedTitle: '내려받을 수 없는 파일입니다',
|
||||
infectedMessage: '보안 검사에서 위험한 내용이 발견되어 막았습니다. 보낸 사람에게 알렸습니다.',
|
||||
@@ -314,16 +316,16 @@ export const SHARE_PAGE_TEXT = {
|
||||
expiredEnglish: (date: string) => `This link expired on ${date} (KST).`,
|
||||
revokedTitle: '보낸 사람이 공유를 멈췄습니다',
|
||||
revokedMessage: (sender: string) => `필요하면 보낸 사람(${sender})에게 문의해 주세요.`,
|
||||
revokedEnglish: 'The sender stopped sharing these files.',
|
||||
revokedEnglish: 'The sender has stopped sharing these files.',
|
||||
notFoundTitle: '찾을 수 없는 링크입니다',
|
||||
notFoundMessage: '메일의 주소가 끝까지 복사되었는지 확인해 주세요.',
|
||||
notFoundEnglish: 'Link not found — check that the whole address was copied.',
|
||||
notFoundEnglish: 'Link not found. Please check that the full address was copied.',
|
||||
tooManyTitle: '요청이 너무 많습니다',
|
||||
tooManyMessage: '잠시 뒤 다시 시도해 주세요.',
|
||||
tooManyEnglish: 'Too many requests — please try again shortly.',
|
||||
tooManyEnglish: 'Too many requests. Please try again in a moment.',
|
||||
errorTitle: '화면을 불러오지 못했습니다',
|
||||
errorMessage: '잠시 뒤 다시 시도해 주세요.',
|
||||
errorEnglish: 'Something went wrong — please try again shortly.',
|
||||
errorEnglish: 'Something went wrong. Please try again in a moment.',
|
||||
retry: '다시 시도',
|
||||
/** 누르기 직전에 다시 물었더니 받을 수 없게 됐다 (role=status) */
|
||||
unavailableNow: '이 파일은 지금 내려받을 수 없습니다. 아래 안내를 확인해 주세요.',
|
||||
|
||||
@@ -56,7 +56,7 @@ export const MAIL_BLOCK_TEXT = {
|
||||
|
||||
// ── 차단 확인 창 ──
|
||||
dialogTitle: '보낸 사람 차단',
|
||||
dialogMessage: '앞으로 오는 메일을 스팸메일함으로 보냅니다. 부재중 자동 응답도 보내지 않습니다.',
|
||||
dialogMessage: '앞으로 오는 메일은 스팸메일함으로 옮겨지고, 부재중 자동 응답도 보내지 않습니다.',
|
||||
scopeGroup: '차단할 범위',
|
||||
scopeAddress: '이 주소만',
|
||||
scopeDomain: '이 도메인 전체',
|
||||
@@ -65,7 +65,7 @@ export const MAIL_BLOCK_TEXT = {
|
||||
/** 「이 도메인 전체」를 끈 까닭 — 여러 사람이 쓰는 메일 서비스 (보드에 없다) */
|
||||
scopeDomainPublic: '여러 사람이 쓰는 메일 서비스라 도메인으로 막을 수 없습니다.',
|
||||
moveToJunk: '이 메일도 스팸메일함으로 옮기기',
|
||||
settingsHint: '차단한 주소는 설정 › 메일 › 차단한 주소에서 풀 수 있습니다.',
|
||||
settingsHint: '차단은 [설정] → [차단한 주소]에서 풀 수 있습니다.',
|
||||
confirm: '차단',
|
||||
cancel: '취소',
|
||||
|
||||
@@ -90,23 +90,23 @@ export const MAIL_BLOCK_TEXT = {
|
||||
/** "3 / 500" */
|
||||
count: (count: string, max: string) => `${count} / ${max}`,
|
||||
description:
|
||||
'이 주소에서 오는 메일은 스팸메일함으로 갑니다. 부재중 자동 응답도 보내지 않습니다. 「@도메인」은 그 도메인에서 오는 메일 모두입니다.',
|
||||
'차단한 주소에서 온 메일은 스팸메일함으로 옮겨지고, 부재중 자동 응답도 보내지 않습니다. @spam.example 처럼 @로 시작해 넣으면 그 도메인에서 오는 메일을 모두 막습니다.',
|
||||
addLabel: '차단할 주소',
|
||||
addPlaceholder: 'kim@example.com 또는 @example.com',
|
||||
add: '차단',
|
||||
/** 칸 아래 오류 */
|
||||
invalid: '메일 주소나 @도메인 형식으로 써 주세요.',
|
||||
duplicate: '이미 차단한 주소입니다.',
|
||||
own: '내 주소 · 사내 도메인은 차단할 수 없습니다.',
|
||||
publicDomain: 'gmail.com 처럼 여러 사람이 쓰는 메일 서비스는 도메인으로 막을 수 없습니다 — 주소로 막아 주세요.',
|
||||
own: '내 주소와 사내 도메인은 차단할 수 없습니다.',
|
||||
publicDomain: 'gmail.com처럼 여러 사람이 쓰는 메일 서비스는 도메인 전체를 막을 수 없습니다. 주소를 하나씩 넣어 막아 주세요.',
|
||||
full: `${MAIL_BLOCK_MAX}개까지 차단할 수 있습니다.`,
|
||||
/** 목록 줄 아래 작은 글 — "10월 29일 차단" · "도메인 전체 · 10월 21일 차단" */
|
||||
blockedOn: (day: string) => `${day} 차단`,
|
||||
domainBlockedOn: (day: string) => `도메인 전체 · ${day} 차단`,
|
||||
empty: '차단한 주소가 없습니다. 메일의 ⋯ › 보낸 사람 차단으로도 막을 수 있습니다.',
|
||||
empty: '차단한 주소가 없습니다. 메일을 읽다가 [⋯] → [보낸 사람 차단]으로도 막을 수 있습니다.',
|
||||
/**
|
||||
* 머리 아래 노란 줄 — 차단한 주소가 있는데 엔진에서 우리 규칙이 꺼져 있을 때(isActive: false). 단추는 없다 — 차단하거나 풀면 다시 켜진다
|
||||
* (보드에 없다 — 7단계 필터 · 부재중 자동 응답의 노란 줄과 같은 결)
|
||||
*/
|
||||
inactive: '지금은 차단이 동작하지 않습니다. 메일 서버의 규칙이 다른 곳에서 바뀌었습니다 — 주소를 차단하거나 풀면 다시 켜집니다.',
|
||||
inactive: '다른 곳에서 메일 설정이 바뀌어 지금은 차단이 되지 않습니다. 주소를 차단하거나 풀면 다시 켜집니다.',
|
||||
} as const
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// 설정 › 계정 › 메일 프로그램 연결 (13단계 — 계약 6절 "메일 프로그램 연결", 디자인 README "13단계 › 설정 › 계정 › 메일 프로그램 연결")
|
||||
// API 는 없다 — 이 상수로만 그린다. 설정값(호스트 · 포트)은 앱 비밀번호 화면과 같은 MAIL_CLIENT_SETTINGS 하나.
|
||||
// 차례 글은 README 그대로다 — 누르는 것 [ ], 고르는 것 「 」, 호스트 이름은 굵게. 판에 따라 메뉴 이름이 조금 다를 수 있다.
|
||||
// 차례 글은 README 그대로다 — 누르거나 고르는 것은 [ ], 호스트 이름은 굵게(README "화면 문구 — 사용자에게 말하듯"). 판에 따라 메뉴 이름이 조금 다를 수 있다.
|
||||
|
||||
import { MAIL_CLIENT_SETTINGS } from '@/shared/constants/account-security'
|
||||
import type { IconName } from '@/shared/constants/icons'
|
||||
@@ -22,28 +22,28 @@ export interface MailClientGuide {
|
||||
|
||||
export const MAIL_CLIENTS_TEXT = {
|
||||
title: '메일 프로그램 연결',
|
||||
description: '아웃룩 · 썬더버드 · 휴대폰 메일 앱에서 회사 메일을 보는 방법입니다.',
|
||||
description: '아웃룩, 썬더버드, 휴대폰 메일 앱에서 회사 메일을 보는 방법입니다.',
|
||||
settingsTitle: '메일 프로그램 설정값',
|
||||
/** 설정값 상자의 비밀번호 줄 — 2단계 인증을 켠 직원 (상태를 모르면 이쪽 — 더 안전한 쪽) */
|
||||
passwordOn: '앱 비밀번호 — 웹메일 비밀번호가 아닙니다',
|
||||
passwordOn: '웹메일 비밀번호가 아닌 앱 비밀번호',
|
||||
/** 설정값 상자의 비밀번호 줄 — 켜지 않은 직원 */
|
||||
passwordOff: '앱 비밀번호(권장) — 2단계 인증을 켜지 않았으면 웹메일 비밀번호도 됩니다',
|
||||
passwordOff: '앱 비밀번호를 권합니다. 2단계 인증을 켜지 않았다면 웹메일 비밀번호로도 연결됩니다.',
|
||||
requiredTitle: '비밀번호 칸에는 꼭 앱 비밀번호를 넣어 주세요',
|
||||
requiredText: '2단계 인증을 켜 두어 웹메일 비밀번호로는 연결되지 않습니다. 프로그램마다 앱 비밀번호를 하나씩 만들어 넣습니다.',
|
||||
requiredText: '2단계 인증을 켜 두어 웹메일 비밀번호로는 연결되지 않습니다. 프로그램마다 앱 비밀번호를 하나씩 만들어 넣어 주세요.',
|
||||
recommendTitle: '앱 비밀번호로 연결하기를 권합니다',
|
||||
recommendText:
|
||||
'지금은 웹메일 비밀번호로도 연결되지만, 2단계 인증을 켜면 다시 연결할 때 막힙니다. 앱 비밀번호는 기기마다 따로 지울 수 있어 잃어버렸을 때도 안전합니다.',
|
||||
createAppPassword: '앱 비밀번호 만들기',
|
||||
stepsTitle: '프로그램별 연결 차례',
|
||||
stepsHint: '쓰는 프로그램을 누르면 차례가 펼쳐집니다.',
|
||||
stepsTitle: '프로그램별 연결 방법',
|
||||
stepsHint: '쓰는 프로그램을 누르면 연결 방법이 펼쳐집니다.',
|
||||
/** 권하지 않음으로 정했다(2026-10-31) — 회사가 허용하면 이 줄을 지운다 */
|
||||
cloudWarning:
|
||||
'새 Outlook · 휴대폰 Outlook 앱은 메일을 Microsoft 클라우드를 거쳐 받습니다 — 회사 정책에 따라 쓰지 않는 것이 좋습니다.',
|
||||
'새 Outlook과 휴대폰 Outlook 앱은 메일을 Microsoft 클라우드를 거쳐 받습니다. 회사 정책에 따라 쓰지 않는 것이 좋습니다.',
|
||||
listLabel: '메일 프로그램',
|
||||
notes: [
|
||||
'연결이 안 되면 사용자 이름에 메일 주소 전체를, 비밀번호 칸에 앱 비밀번호(띄어쓰기 없이 40자)를 넣었는지 확인해 주세요.',
|
||||
'POP3 로는 연결할 수 없습니다 — 계정 유형은 IMAP 을 골라 주세요.',
|
||||
'웹메일 비밀번호를 바꾸면 앱 비밀번호도 모두 지워집니다 — 프로그램마다 새로 만들어 넣어 주세요.',
|
||||
'연결이 안 되면 사용자 이름에 메일 주소 전체를 넣었는지, 비밀번호 칸에 앱 비밀번호 40자를 띄어쓰기 없이 넣었는지 확인해 주세요.',
|
||||
'POP3로는 연결할 수 없으니 계정 유형은 IMAP을 골라 주세요.',
|
||||
'웹메일 비밀번호를 바꾸면 앱 비밀번호도 모두 지워지니, 프로그램마다 새로 만들어 넣어 주세요.',
|
||||
'그래도 안 되면 관리자에게 문의해 주세요.',
|
||||
],
|
||||
/** 앱 비밀번호 화면의 설정값 상자 아래 한 줄 — 「메일 프로그램 연결」이 이 화면 링크 */
|
||||
@@ -52,23 +52,21 @@ export const MAIL_CLIENTS_TEXT = {
|
||||
shortcutAfter: '에서 볼 수 있습니다.',
|
||||
} as const
|
||||
|
||||
const PORTS = `${imapPort} · ${smtpPort}`
|
||||
|
||||
/** 프로그램별 연결 차례 — 아웃룩 · 새 Outlook · 썬더버드 · iPhone · 안드로이드 (맥 메일 · 휴대폰 Outlook 앱은 넣지 않는다) */
|
||||
export const MAIL_CLIENT_GUIDES: readonly MailClientGuide[] = [
|
||||
{
|
||||
id: 'outlook',
|
||||
name: '아웃룩 (Windows)',
|
||||
description: 'Microsoft 365 · Outlook 2016 이후의 데스크톱 Outlook',
|
||||
description: 'Microsoft 365나 Outlook 2016 이후의 데스크톱 Outlook',
|
||||
icon: 'monitor',
|
||||
steps: [
|
||||
['Outlook 에서 [파일] → [계정 추가]를 누릅니다.'],
|
||||
['메일 주소를 넣고 [고급 옵션] → 「내 계정을 수동으로 설정」을 고른 뒤 [연결]을 누릅니다.'],
|
||||
['계정 유형에서 「IMAP」을 고릅니다.'],
|
||||
['Outlook에서 [파일] → [계정 추가]를 누릅니다.'],
|
||||
['메일 주소를 넣고 [고급 옵션] → [내 계정을 수동으로 설정]을 고른 뒤 [연결]을 누릅니다.'],
|
||||
['계정 유형에서 [IMAP]을 고릅니다.'],
|
||||
[
|
||||
'받는 메일 · 보내는 메일 모두 서버 ',
|
||||
'받는 메일과 보내는 메일 모두 서버는 ',
|
||||
{ strong: host },
|
||||
`, 포트는 ${PORTS}, 암호화 방법은 「${security}」를 넣고 [다음]을 누릅니다.`,
|
||||
`로 넣습니다. 포트는 받는 메일 ${imapPort}, 보내는 메일 ${smtpPort}이고, 암호화 방법은 [${security}]를 고른 뒤 [다음]을 누릅니다.`,
|
||||
],
|
||||
['비밀번호 칸에 앱 비밀번호를 넣고 [연결]을 누릅니다.'],
|
||||
],
|
||||
@@ -76,24 +74,24 @@ export const MAIL_CLIENT_GUIDES: readonly MailClientGuide[] = [
|
||||
{
|
||||
id: 'new-outlook',
|
||||
name: '새 Outlook',
|
||||
description: 'Windows 의 새 Outlook — 메일이 Microsoft 클라우드를 거칩니다',
|
||||
description: '메일이 Microsoft 클라우드를 거치는 Windows의 새 Outlook',
|
||||
icon: 'monitor',
|
||||
steps: [
|
||||
['[설정](톱니) → [계정] → [메일 계정] → [계정 추가]를 누릅니다.'],
|
||||
['메일 주소를 넣고 [계속] → 공급자에서 「IMAP」을 고릅니다.'],
|
||||
['[고급 설정 표시]를 누르고 받는 · 보내는 서버에 위 설정값을, 비밀번호 칸 둘 다에 앱 비밀번호를 넣습니다.'],
|
||||
['[계속]을 누르면 메일을 Microsoft 클라우드에 동기화한다는 안내가 나옵니다 — 위 주의를 읽고 정합니다.'],
|
||||
['메일 주소를 넣고 [계속]을 누른 뒤 공급자에서 [IMAP]을 고릅니다.'],
|
||||
['[고급 설정 표시]를 누르고 받는 서버와 보내는 서버에 위 설정값을, 비밀번호 칸 두 곳에 모두 앱 비밀번호를 넣습니다.'],
|
||||
['[계속]을 누르면 메일을 Microsoft 클라우드에 동기화한다는 안내가 나옵니다. 위의 주의 글을 읽고 정해 주세요.'],
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'thunderbird',
|
||||
name: '썬더버드',
|
||||
description: 'Windows · Mac — 주소와 앱 비밀번호만 넣으면 됩니다',
|
||||
description: 'Windows와 Mac에서 주소와 앱 비밀번호만 넣으면 됩니다',
|
||||
icon: 'monitor',
|
||||
steps: [
|
||||
['썬더버드에서 [≡] → [새로 만들기] → [기존 메일 계정]을 누릅니다.'],
|
||||
['이름 · 메일 주소 · 비밀번호(앱 비밀번호)를 넣고 [계속]을 누릅니다.'],
|
||||
['메일 주소와 앱 비밀번호만 넣으면 설정을 스스로 찾습니다 — 「IMAP」이 보이면 [완료]를 누릅니다.'],
|
||||
['이름과 메일 주소를 넣고, 비밀번호 칸에 앱 비밀번호를 넣은 뒤 [계속]을 누릅니다.'],
|
||||
['메일 주소와 앱 비밀번호만 넣으면 설정을 스스로 찾습니다. [IMAP]이 보이면 [완료]를 누릅니다.'],
|
||||
['찾지 못하면 [수동 설정]에서 위 설정값을 넣습니다.'],
|
||||
],
|
||||
},
|
||||
@@ -103,29 +101,29 @@ export const MAIL_CLIENT_GUIDES: readonly MailClientGuide[] = [
|
||||
description: '기본 메일 앱',
|
||||
icon: 'smartphone',
|
||||
steps: [
|
||||
['[설정] → [메일] → [계정] → [계정 추가] → [기타] → [메일 계정 추가]를 누릅니다. (iOS 18 은 [설정] → [앱] → [메일])'],
|
||||
['이름 · 메일 주소 · 암호(앱 비밀번호) · 설명을 넣고 [다음]을 누릅니다.'],
|
||||
['[설정] → [메일] → [계정] → [계정 추가] → [기타] → [메일 계정 추가]를 누릅니다. iOS 18에서는 [설정] → [앱] → [메일]에서 시작합니다.'],
|
||||
['이름, 메일 주소, 설명을 넣고, 암호 칸에 앱 비밀번호를 넣은 뒤 [다음]을 누릅니다.'],
|
||||
[
|
||||
'「IMAP」을 고르고, 받는 · 보내는 메일 서버의 호스트 이름에 ',
|
||||
'[IMAP]을 고르고, 받는 메일 서버와 보내는 메일 서버의 호스트 이름에 ',
|
||||
{ strong: host },
|
||||
', 사용자 이름에 메일 주소 전체, 암호에 앱 비밀번호를 넣습니다 — 보내는 메일 서버도 「선택 사항」이라 보여도 비우지 않습니다.',
|
||||
', 사용자 이름에 메일 주소 전체, 암호에 앱 비밀번호를 넣습니다. 보내는 메일 서버 칸이 선택 사항으로 보여도 꼭 채워 주세요.',
|
||||
],
|
||||
[`[다음] → [저장]을 누릅니다. 포트는 스스로 ${PORTS} 로 맞춥니다.`],
|
||||
[`[다음] → [저장]을 누릅니다. 포트는 받는 메일 ${imapPort}, 보내는 메일 ${smtpPort}로 저절로 맞춰집니다.`],
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'android',
|
||||
name: '안드로이드',
|
||||
description: 'Gmail 앱 — 다른 계정(IMAP) · 삼성 이메일 앱도 같은 값',
|
||||
description: 'Gmail 앱 기준이며, 삼성 이메일 앱 같은 다른 메일 앱도 같은 값을 넣습니다',
|
||||
icon: 'smartphone',
|
||||
steps: [
|
||||
['Gmail 앱에서 프로필 사진 → [다른 계정 추가] → [기타]를 누릅니다.'],
|
||||
['메일 주소를 넣고 [수동 설정] → 「개인(IMAP)」을 고릅니다.'],
|
||||
['메일 주소를 넣고 [수동 설정] → [개인(IMAP)]을 고릅니다.'],
|
||||
['비밀번호에 앱 비밀번호를 넣습니다.'],
|
||||
[
|
||||
'받는 서버 · 보내는 서버에 ',
|
||||
'받는 서버와 보내는 서버는 ',
|
||||
{ strong: host },
|
||||
`, 포트는 ${PORTS}, 보안 유형은 「${security}」를 넣고 [다음]을 누릅니다.`,
|
||||
`로 넣습니다. 포트는 받는 서버 ${imapPort}, 보내는 서버 ${smtpPort}이고, 보안 유형은 [${security}]를 고른 뒤 [다음]을 누릅니다.`,
|
||||
],
|
||||
],
|
||||
},
|
||||
|
||||
@@ -28,7 +28,7 @@ export const MAIL_BODY_LINE_HEIGHT = 1.75
|
||||
export const MAIL_BODY_HTML_MAX_LENGTH = 500_000
|
||||
|
||||
/** 빈 본문 안내 (디자인 원본 ComposeEditor "빈 본문") */
|
||||
export const EDITOR_PLACEHOLDER = '내용을 입력하세요'
|
||||
export const EDITOR_PLACEHOLDER = '내용을 입력해 주세요'
|
||||
|
||||
// ── 글꼴 · 글자 크기 ─────────────────────────────────────
|
||||
|
||||
|
||||
@@ -84,39 +84,39 @@ export const MAIL_FILTER_SUMMARY_TEXT = {
|
||||
export const MAIL_FILTER_TEXT = {
|
||||
title: '필터',
|
||||
description:
|
||||
'받은 메일을 조건에 따라 메일함으로 옮기거나, 읽음 · 중요로 표시하거나, 동료에게 전달합니다. 새로 오는 메일에 적용하고, 고를 때는 받은편지함에 이미 있는 메일에도 적용합니다.',
|
||||
'받은 메일을 조건에 따라 메일함으로 옮기거나, 읽음이나 중요로 표시하거나, 동료에게 전달합니다. 새로 오는 메일을 정리하고, 저장할 때 고르면 받은편지함에 이미 있는 메일도 함께 정리합니다.',
|
||||
create: '새 필터',
|
||||
/**
|
||||
* 다 만들었을 때 [새 필터] 옆 · 읽기 화면 ⋯ 「이런 메일로 필터 만들기」 아래 ·
|
||||
* 되돌리기 · 다른 곳에서 지워진 규칙의 저장이 한도를 넘을 때(오류 알림 · 고치기 창 안)
|
||||
*/
|
||||
fullNote: `필터는 ${MAIL_FILTER_MAX_RULES}개까지 만들 수 있습니다. 쓰지 않는 필터를 지워 주세요.`,
|
||||
listLabel: '필터 — 위에서부터 차례로 적용',
|
||||
count: (count: string) => `필터 ${count}개 · ${MAIL_FILTER_MAX_RULES}개까지 만들 수 있습니다. 행을 누르면 고칩니다.`,
|
||||
listLabel: '필터 목록, 위에 있는 필터부터 차례로 정리합니다',
|
||||
count: (count: string) => `필터 ${count}개를 만들었고, ${MAIL_FILTER_MAX_RULES}개까지 만들 수 있습니다. 줄을 누르면 고칠 수 있습니다.`,
|
||||
// 단추 이름 · 알림의 quoted 는 따옴표로 묶은 이름(quotedRuleTitle — 길면 자르고, 이름에 작은따옴표가 있으면 큰따옴표로 묶는다)
|
||||
/** 켜기 스위치(role="switch") — 켜짐 · 꺼짐은 aria-checked 가 알린다 (이름은 바꾸지 않는다) */
|
||||
switchLabel: (quoted: string) => `${quoted} 필터 사용`,
|
||||
editLabel: (quoted: string) => `${quoted} 필터 고치기`,
|
||||
moveUp: (quoted: string) => `${quoted} 위로`,
|
||||
moveDown: (quoted: string) => `${quoted} 아래로`,
|
||||
missingMailbox: '옮길 메일함이 지워져 받은편지함으로 들어옵니다 — 눌러서 고치세요.',
|
||||
missingMailbox: '옮길 메일함이 지워져 메일이 받은편지함으로 들어옵니다. 눌러서 고쳐 주세요.',
|
||||
emptyTitle: '아직 필터가 없습니다',
|
||||
emptyText:
|
||||
'보낸 사람 · 받는 사람 · 제목 · 본문의 글로 메일을 골라 메일함으로 옮기거나, 읽음 · 중요로 표시하거나, 동료에게 전달합니다.',
|
||||
'보낸 사람, 받는 사람, 제목, 본문의 글로 메일을 골라 메일함으로 옮기거나, 읽음이나 중요로 표시하거나, 동료에게 전달합니다.',
|
||||
// 목록 위 노란 줄 — 다른 곳에서 바뀜(409 MAIL_012 — 글은 에러 코드 사전) · 동작하지 않음(isActive: false)
|
||||
reload: '다시 불러오기',
|
||||
inactive: '지금은 필터와 부재중 자동 응답이 동작하지 않습니다. 메일 서버의 규칙이 다른 곳에서 바뀌었습니다.',
|
||||
inactive: '다른 곳에서 메일 설정이 바뀌어 지금은 필터와 부재중 자동 응답이 되지 않습니다.',
|
||||
reactivate: '다시 켜기',
|
||||
// 아래 안내 상자 — 첫 줄의 "위에서부터 차례로" · 둘째 줄의 칸 이름은 굵게 (7단계 — 디자인 원본 MailFilterApply 의 글)
|
||||
noteOrderStrong: '위에서부터 차례로',
|
||||
noteOrderAfter: ' 적용합니다. 메일함으로 옮기는 필터에 맞으면 그 아래 필터는 적용하지 않습니다 — 순서는 ▲ ▼ 로 바꿉니다.',
|
||||
noteApplyBefore: '이미 받은 메일에도 적용하려면 필터를 저장할 때 ',
|
||||
noteApplyStrong: '「받은편지함에 이미 있는 메일에도 적용」',
|
||||
noteApplyAfter: '을 고릅니다. 받은편지함의 메일만 보고, 그 필터 하나만 적용합니다.',
|
||||
noteSpam: '스팸으로 판정되어 들어오는 메일에는 필터를 적용하지 않습니다 — 그대로 스팸메일함으로 갑니다.',
|
||||
noteOrderStrong: '위에 있는 필터부터 차례로',
|
||||
noteOrderAfter: ' 메일을 정리합니다. 메일함으로 옮기는 필터에 맞은 메일은 그 아래 필터를 거치지 않습니다. 순서는 ▲ ▼ 로 바꿀 수 있습니다.',
|
||||
noteApplyBefore: '이미 받은 메일도 정리하려면 필터를 저장할 때 ',
|
||||
noteApplyStrong: '[받은편지함에 이미 있는 메일에도 적용]',
|
||||
noteApplyAfter: '을 골라 주세요. 받은편지함에 있는 메일만 그 필터 하나로 정리합니다.',
|
||||
noteSpam: '스팸으로 분류된 메일은 필터를 거치지 않고 그대로 스팸메일함으로 갑니다.',
|
||||
noteForward:
|
||||
'전달은 사내 주소로만, 새로 오는 메일만 합니다. 메일 한 통은 한 곳으로만 전달됩니다(위의 필터가 먼저). 전달해도 내 메일함에 그대로 남습니다.',
|
||||
noteVacation: '부재중 자동 응답은 필터와 함께 동작합니다.',
|
||||
'전달은 새로 오는 메일만 사내 주소로 합니다. 메일 한 통은 한 곳으로만 전달되고, 전달하는 필터가 여럿이면 위에 있는 필터가 먼저입니다. 전달해도 내 메일함에 그대로 남습니다.',
|
||||
noteVacation: '필터가 메일을 옮겨도 부재중 자동 응답은 그대로 보냅니다.',
|
||||
// 알림 — 켜기 · 끄기 · 순서 바꾸기는 목록이 바로 바뀌므로 알리지 않는다 (실패하면 되돌리고 오류 알림)
|
||||
saved: '필터를 저장했습니다.',
|
||||
removed: (quoted: string) => `${quoted} 필터를 지웠습니다.`,
|
||||
@@ -133,18 +133,18 @@ export const MAIL_FILTER_DIALOG_TEXT = {
|
||||
nameOptional: '(선택)',
|
||||
namePlaceholder: '비워 두면 조건이 이름이 됩니다',
|
||||
matchLegend: '어떤 메일에',
|
||||
conditionField: (no: number) => `조건 ${no} — 어디에서`,
|
||||
conditionOperator: (no: number) => `조건 ${no} — 포함 · 포함하지 않음`,
|
||||
conditionValue: (no: number) => `조건 ${no} — 찾을 글`,
|
||||
conditionField: (no: number) => `조건 ${no}의 찾을 곳`,
|
||||
conditionOperator: (no: number) => `조건 ${no}의 포함 여부`,
|
||||
conditionValue: (no: number) => `조건 ${no}의 찾을 글`,
|
||||
conditionRemove: (no: number) => `조건 ${no} 지우기`,
|
||||
addCondition: '조건 더하기',
|
||||
conditionHint: `${MAIL_FILTER_MAX_CONDITIONS}개까지 · 글자는 대소문자를 가리지 않습니다`,
|
||||
conditionHint: `조건은 ${MAIL_FILTER_MAX_CONDITIONS}개까지 넣을 수 있고, 영문 대소문자는 가리지 않습니다`,
|
||||
actionsLegend: '할 일',
|
||||
move: '메일함으로 옮기기',
|
||||
moveTarget: '옮길 메일함',
|
||||
moveHint: '옮기면 이 아래 필터는 적용하지 않습니다.',
|
||||
moveHint: '옮긴 메일은 이 아래 필터를 거치지 않습니다.',
|
||||
/** 옮길 메일함이 지워졌다 — 이 항목이 골라진 채로 보인다 (다시 고를 수는 없다) */
|
||||
missingMailboxOption: '(지워진 메일함) — 다른 메일함을 고르세요',
|
||||
missingMailboxOption: '지워진 메일함이니 다른 메일함을 골라 주세요',
|
||||
markAsRead: '읽음으로 표시',
|
||||
flag: '중요 표시',
|
||||
forward: '동료에게 전달',
|
||||
@@ -157,7 +157,7 @@ export const MAIL_FILTER_DIALOG_TEXT = {
|
||||
cancel: '취소',
|
||||
// 칸 아래 빨간 글 — [저장]을 누를 때 검사한다(알림은 띄우지 않는다). 그 밖의 형식 오류(이름 · 찾을 글의 길이 · 제어 문자)는 에러 코드 사전의 VAL_001
|
||||
conditionEmpty: '비어 있는 조건이 있습니다. 찾을 글을 적거나 그 조건을 지워 주세요.',
|
||||
conditionVariable: '‘${’ 는 쓸 수 없습니다 — 메일 서버가 다른 뜻으로 읽습니다.',
|
||||
conditionVariable: '‘${’는 필터에 쓸 수 없습니다. 이 글자를 빼고 넣어 주세요.',
|
||||
// 전달 주소 — 캔버스에는 바깥 주소만 있다. 빈 칸 · 형식 · 내 주소는 2026-10-28 구현하며 정함 (형식 문구는 서버의 거절 문구와 같다)
|
||||
forwardEmpty: '전달할 주소를 입력해 주세요.',
|
||||
forwardInvalid: '전달할 주소 형식이 올바르지 않습니다.',
|
||||
@@ -173,17 +173,17 @@ export const MAIL_FILTER_DIALOG_TEXT = {
|
||||
*/
|
||||
export const MAIL_FILTER_APPLY_TEXT = {
|
||||
label: '받은편지함에 이미 있는 메일에도 적용',
|
||||
help: '저장한 뒤 받은편지함에서 조건에 맞는 메일을 찾아 바로 적용합니다.',
|
||||
help: '저장하면 받은편지함에서 조건에 맞는 메일을 찾아 바로 정리합니다.',
|
||||
/** 전달도 하는 필터 — 설명 뒤에 더한다 */
|
||||
helpForward: ' 전달은 새로 오는 메일에만 합니다.',
|
||||
forwardOnly: '전달만 하는 필터는 이미 받은 메일에 할 일이 없습니다 — 전달은 새로 오는 메일에만 합니다.',
|
||||
forwardOnly: '전달은 새로 오는 메일에만 해서, 전달만 하는 필터는 이미 받은 메일에 할 일이 없습니다.',
|
||||
/** 옮길 메일함이 지금 메일함 목록에 없다 — 읽음 · 중요 없이 옮기기만 (7단계 리뷰로 더한 경우) */
|
||||
missingMailbox: '옮길 메일함이 지워져 이미 받은 메일에 할 일이 없습니다 — 다른 메일함을 고르세요.',
|
||||
missingMailbox: '옮길 메일함이 지워져 이미 받은 메일에 할 일이 없습니다. 다른 메일함을 골라 주세요.',
|
||||
/** 받은편지함으로 옮기기만 — 이미 받은편지함에 있는 메일이다 (7단계 리뷰로 더한 경우) */
|
||||
inboxOnly: '받은편지함으로 옮기기만 하는 필터는 이미 받은 메일에 할 일이 없습니다.',
|
||||
busy: '다른 필터를 받은편지함에 적용하는 중입니다. 끝난 뒤에 고를 수 있습니다.',
|
||||
busy: '다른 필터로 받은편지함을 정리하는 중입니다. 끝난 뒤에 고를 수 있습니다.',
|
||||
/** 상자 아래 한 줄 */
|
||||
spam: '스팸으로 판정되어 들어오는 메일에는 적용하지 않습니다.',
|
||||
spam: '스팸으로 분류된 메일은 필터를 거치지 않습니다.',
|
||||
} as const
|
||||
|
||||
/**
|
||||
@@ -191,26 +191,26 @@ export const MAIL_FILTER_APPLY_TEXT = {
|
||||
* quoted 는 따옴표로 묶은 이름(quotedRuleTitle), 수는 formatCount 로 만든 글자. 확인 = 지금까지 확인한 수, 전체 = 확인 + 남은 수(어림)
|
||||
*/
|
||||
export const MAIL_FILTER_RUN_TEXT = {
|
||||
progress: (quoted: string) => `${quoted} 필터를 받은편지함에 적용하는 중`,
|
||||
progress: (quoted: string) => `${quoted} 필터로 받은편지함을 정리하는 중`,
|
||||
/** 진행 문구 뒤 — 전체를 아직 모르면(받은편지함 수를 모르고 첫 응답 전) 뺀다 */
|
||||
progressCount: (scanned: string, total: string) => ` · ${scanned} / ${total}통 확인`,
|
||||
done: (quoted: string, matched: string) => `${quoted} 필터를 받은편지함의 메일 ${matched}통에 적용했습니다.`,
|
||||
done: (quoted: string, matched: string) => `${quoted} 필터로 받은편지함의 메일 ${matched}통을 정리했습니다.`,
|
||||
/** 맞는 메일이 없었다 */
|
||||
none: (quoted: string) => `받은편지함에 ${quoted} 필터에 맞는 메일이 없습니다.`,
|
||||
/** 옮기는 필터의 끝 알림 단추 — 그 메일함으로 */
|
||||
viewMailbox: (mailbox: string) => `${mailbox} 보기`,
|
||||
/** 도중에 멈췄다 — [다시 시도]는 남은 메일부터 이어서 */
|
||||
stopped: (quoted: string, total: string, scanned: string, matched: string) =>
|
||||
`${quoted} 필터 — ${total}통 가운데 ${scanned}통까지 확인하고 멈췄습니다(${matched}통에 적용). 다시 시도해 주세요.`,
|
||||
`${quoted} 필터로 ${total}통 가운데 ${scanned}통까지 확인하고 멈췄습니다. 그동안 ${matched}통을 정리했습니다. 다시 시도해 주세요.`,
|
||||
/** 처음 요청부터 실패했다 — 필터는 저장되어 있다(새로 오는 메일에는 적용된다) */
|
||||
notApplied: '필터는 저장했지만 받은편지함에 적용하지 못했습니다. 다시 시도해 주세요.',
|
||||
notApplied: '필터는 저장했지만 받은편지함은 정리하지 못했습니다. 다시 시도해 주세요.',
|
||||
/** 429 REQ_001 — 같은 직원의 적용이 다른 창 · PC 에서 진행 중 */
|
||||
otherWindow: '다른 창에서 필터를 받은편지함에 적용하는 중입니다. 끝난 뒤 다시 시도해 주세요.',
|
||||
otherWindow: '다른 창에서 필터로 받은편지함을 정리하는 중입니다. 끝난 뒤 다시 시도해 주세요.',
|
||||
/**
|
||||
* 이 탭에서 다른 필터를 적용하는 중에 [다시 시도]를 눌렀다 (보드에 없던 경우 — 2026-10-29 구현하며 정함).
|
||||
* 같은 [다시 시도](같은 자리 · 같은 수)를 붙여 앞 적용이 끝난 뒤 다시 누를 수 있게 한다
|
||||
*/
|
||||
busy: '다른 필터를 받은편지함에 적용하는 중입니다. 끝난 뒤 다시 시도해 주세요.',
|
||||
busy: '다른 필터로 받은편지함을 정리하는 중입니다. 끝난 뒤 다시 시도해 주세요.',
|
||||
retry: '다시 시도',
|
||||
} as const
|
||||
|
||||
|
||||
@@ -53,26 +53,26 @@ export const MAIL_NOTIFY_TEXT = {
|
||||
menu: '알림',
|
||||
title: '알림',
|
||||
description:
|
||||
'새 메일은 1분을 기다리지 않고 바로 목록에 나타납니다. 다른 창을 보는 동안에도 알고 싶으면 바탕화면 알림을 켜 두세요.',
|
||||
'새 메일은 1분을 기다리지 않고 바로 목록에 나타납니다. 다른 창을 보는 동안에도 알고 싶으면 바탕화면 알림을 켜 주세요.',
|
||||
enable: '새 메일이 오면 바탕화면 알림 보내기',
|
||||
noteAsk: '켜면 브라우저가 알림을 허락할지 한 번 묻습니다 — 「허용」을 눌러 주세요.',
|
||||
noteAsk: '켜면 브라우저가 알림을 허락할지 한 번 묻습니다. [허용]을 눌러 주세요.',
|
||||
noteGranted: '이 브라우저에서 알림이 허용되어 있습니다.',
|
||||
noteBlocked:
|
||||
'브라우저가 이 사이트의 알림을 막고 있습니다. 주소창 왼쪽의 사이트 정보(자물쇠) › 알림을 「허용」으로 바꾼 뒤 이 화면을 새로 고쳐 주세요.',
|
||||
'브라우저가 이 사이트의 알림을 막고 있습니다. 주소창 왼쪽의 사이트 정보(자물쇠)를 눌러 [알림]을 [허용]으로 바꾼 뒤 이 화면을 새로 고쳐 주세요.',
|
||||
noteUnsupported: '이 브라우저는 바탕화면 알림을 지원하지 않습니다. 새 메일은 그래도 바로 목록에 나타납니다.',
|
||||
contentLegend: '알림에 보일 것',
|
||||
showSubject: '메일 제목도 보이기',
|
||||
showSubjectHint: '끄면 보낸 사람 이름만 보입니다 — 화면을 다른 사람과 함께 보는 자리라면 꺼 두세요.',
|
||||
showSubjectHint: '끄면 보낸 사람 이름만 보입니다. 다른 사람과 화면을 함께 보는 자리라면 꺼 두는 것이 좋습니다.',
|
||||
test: '시험 알림 보내기',
|
||||
testHint: '바탕화면 오른쪽 아래(맥은 오른쪽 위)에 알림이 뜨면 됩니다.',
|
||||
testHint: '바탕화면 오른쪽 아래에 알림이 뜨면 됩니다. 맥에서는 오른쪽 위에 뜹니다.',
|
||||
realtimeTitle: '실시간 연결',
|
||||
realtimeLive: '연결되어 있습니다 — 새 메일이 오면 바로 목록에 나타납니다.',
|
||||
realtimeLive: '연결되어 있어 새 메일이 오면 바로 목록에 나타납니다.',
|
||||
realtimeLiveHint:
|
||||
'연결할 수 없을 때는 "연결할 수 없어 1분마다 확인합니다."로 바뀌고, 저절로 다시 잇습니다. 회사 방화벽 · 프록시가 오래 열린 연결을 끊으면 이렇게 됩니다.',
|
||||
'연결이 끊기면 1분마다 새 메일을 확인하면서 저절로 다시 연결합니다. 회사 방화벽이나 프록시가 오래 열린 연결을 끊으면 이렇게 될 수 있습니다.',
|
||||
realtimeDown: '연결할 수 없어 1분마다 확인합니다.',
|
||||
/** 끊긴 동안 — 상태 줄 바로 아래 (2026-10-28 화면 점검 반영) */
|
||||
realtimeDownNotice: '연결될 때까지 바탕화면 알림은 오지 않습니다.',
|
||||
realtimeDownHint: '저절로 다시 잇습니다. 회사 방화벽 · 프록시가 오래 열린 연결을 끊으면 이렇게 됩니다.',
|
||||
realtimeDownHint: '저절로 다시 연결합니다. 회사 방화벽이나 프록시가 오래 열린 연결을 끊으면 이렇게 될 수 있습니다.',
|
||||
/** 처음 잇는 동안 (캔버스에는 없다 — 2026-10-28 구현하며 정함) */
|
||||
realtimeConnecting: '연결하는 중입니다…',
|
||||
/** Web Locks · BroadcastChannel 이 없는 브라우저 — 1분 확인만 한다 (캔버스에는 없다 — 2026-10-28 구현하며 정함) */
|
||||
@@ -80,9 +80,9 @@ export const MAIL_NOTIFY_TEXT = {
|
||||
/** 아래 안내 상자 — 첫 줄의 "이 브라우저에만"은 굵게 */
|
||||
noteBrowserBefore: '알림 설정은 ',
|
||||
noteBrowserStrong: '이 브라우저에만',
|
||||
noteBrowserAfter: ' 적용됩니다. 다른 PC · 브라우저에서는 그곳에서 따로 켜 주세요.',
|
||||
noteBrowserAfter: ' 저장됩니다. 다른 PC나 브라우저에서는 그곳에서 따로 켜 주세요.',
|
||||
noteOpenTab: '웹메일 탭이 하나라도 열려 있어야 알림이 옵니다. 탭을 모두 닫으면 오지 않습니다.',
|
||||
noteVisible: '웹메일 화면을 보고 있을 때는 바탕화면 알림을 보내지 않습니다 — 목록에 바로 보이므로.',
|
||||
noteVisible: '웹메일 화면을 보고 있을 때는 새 메일이 목록에 바로 보이므로 바탕화면 알림을 보내지 않습니다.',
|
||||
|
||||
// 시험 알림 — 지금 탭에서 바로 보낸다
|
||||
testTitle: 'ARA 웹메일',
|
||||
@@ -106,6 +106,6 @@ export const MAIL_NOTIFY_TEXT = {
|
||||
/** 허락을 받아 켰을 때 (성공 알림) */
|
||||
enabledToast: '바탕화면 알림을 켰습니다.',
|
||||
/** 브라우저가 막았을 때 (오류 알림 + 설정 열기) */
|
||||
blockedToast: '브라우저가 알림을 막고 있습니다. 푸는 방법은 설정 › 알림에 있습니다.',
|
||||
blockedToast: '브라우저가 알림을 막고 있습니다. 푸는 방법은 [설정] → [알림]에서 볼 수 있습니다.',
|
||||
openSettings: '설정 열기',
|
||||
} as const
|
||||
|
||||
@@ -46,10 +46,10 @@ export const SEND_LATER_TEXT = {
|
||||
customDate: '날짜',
|
||||
customTime: '시각',
|
||||
/** zone 은 브라우저가 알려 주는 시간대 이름(Intl — "한국 표준시" 등) */
|
||||
note: (zone: string) => `이 PC 의 시간(${zone})으로 보냅니다 · 7일 안에서 고를 수 있습니다.`,
|
||||
note: (zone: string) => `이 PC의 시간(${zone})으로 보내고, 7일 안에서 고를 수 있습니다.`,
|
||||
/** 시간대 이름을 모를 때 */
|
||||
noteWithoutZone: '이 PC 의 시간으로 보냅니다 · 7일 안에서 고를 수 있습니다.',
|
||||
tooSoon: '지금부터 5분 뒤 이후의 시각을 고르세요.',
|
||||
noteWithoutZone: '이 PC의 시간으로 보내고, 7일 안에서 고를 수 있습니다.',
|
||||
tooSoon: '5분 뒤보다 이른 시각은 고를 수 없습니다. 더 늦은 시각을 골라 주세요.',
|
||||
tooLate: '7일 안의 시각만 고를 수 있습니다.',
|
||||
confirm: '예약',
|
||||
cancel: '취소',
|
||||
@@ -68,10 +68,10 @@ export const SEND_LATER_TEXT = {
|
||||
/** 메일함 패널의 「예약됨」 · 목록 머리 */
|
||||
mailbox: '예약됨',
|
||||
/** 목록 머리의 개수 — "2통 · 보낼 시각 차례" */
|
||||
listCount: (count: string) => `${count}통 · 보낼 시각 차례`,
|
||||
listCount: (count: string) => `${count}통 · 보낼 시각이 이른 순서`,
|
||||
/** 목록이 비었을 때 (보드에 없다 — 보낸 · 취소한 뒤 그 자리에서 비면) */
|
||||
emptyTitle: '예약한 메일이 없습니다',
|
||||
emptyHint: '쓰기 화면의 [보내기 ▾] › 예약 발송…으로 보낼 시각을 정할 수 있습니다.',
|
||||
emptyHint: '메일을 쓸 때 [보내기 ▾] → [예약 발송…]으로 보낼 시각을 정할 수 있습니다.',
|
||||
/** 표 · 목록의 보낼 시각 칸 이름 */
|
||||
sendAtColumn: '보낼 시각',
|
||||
/** 읽기 화면 머리 아래 청록 줄 — "10월 30일 (금) 오전 9:00 에 보낼 메일입니다." */
|
||||
@@ -97,7 +97,7 @@ export const SEND_LATER_TEXT = {
|
||||
/** 보내기 취소를 기다리는 동안 */
|
||||
rowSending: '보내는 중',
|
||||
/** 아직 보내지 않은 메일의 고르기 칸(꺼짐)의 까닭 — 옮기거나 지워도 엔진은 그 시각에 보낸다 (보드에 없다) */
|
||||
heldNotSelectable: '보내기를 기다리는 메일은 고를 수 없습니다 — 열어서 취소하거나 삭제해 주세요.',
|
||||
heldNotSelectable: '보내기를 기다리는 메일은 고를 수 없습니다. 메일을 열어 취소하거나 삭제해 주세요.',
|
||||
} as const
|
||||
|
||||
/** 보낼 시각 뒤 이만큼 기다렸다 보이는 목록 · 열린 메일 · 예약됨 수를 다시 본다 — 엔진이 풀어도 새 메일 확인 · 실시간 알림은 바뀌지 않는다 */
|
||||
|
||||
@@ -11,24 +11,24 @@ export const EXTERNAL_IMAGES_OPTIONS: readonly ExternalImagesSetting[] = ['ask',
|
||||
export const MAIL_SETTINGS_TEXT = {
|
||||
title: '보내기 · 읽기',
|
||||
/** 보드에 없다 — 저장 단추가 없으니 고르는 대로 저장된다는 것을 머리에서 알린다 */
|
||||
description: '고르는 대로 저장합니다. 이 설정은 어느 PC 에서나 같습니다.',
|
||||
description: '고르는 대로 바로 저장됩니다. 이 설정은 어느 PC에서나 같습니다.',
|
||||
saved: '저장했습니다',
|
||||
|
||||
undoTitle: '보내기 취소',
|
||||
undoOff: '끔',
|
||||
undoSeconds: (seconds: number) => `${seconds}초`,
|
||||
undoHint:
|
||||
'[보내기]를 누른 뒤 이 시간 동안 [보내기 취소]로 되돌릴 수 있습니다. 그동안 메일은 메일 서버에서 기다리므로 창을 닫아도 보내집니다.',
|
||||
'[보내기]를 누른 뒤 이 시간 동안 [보내기 취소]로 되돌릴 수 있습니다. 그사이 창을 닫아도 시간이 지나면 메일은 그대로 보내집니다.',
|
||||
|
||||
imagesTitle: '외부 이미지',
|
||||
images: {
|
||||
ask: { label: '늘 묻기', hint: '메일마다 [이미지 보기]를 눌러 봅니다' },
|
||||
trusted: {
|
||||
label: '주소록 · 사내 직원의 메일은 늘 보기',
|
||||
label: '주소록과 사내 직원의 메일은 늘 보기',
|
||||
hint: '보낸 사람이 내 주소록이나 사내 목록에 있으면 바로 보입니다',
|
||||
},
|
||||
always: { label: '늘 보기', hint: '스팸메일함의 메일은 이 설정과 상관없이 묻습니다' },
|
||||
} satisfies Record<ExternalImagesSetting, { label: string; hint: string }>,
|
||||
imagesNote:
|
||||
'외부 이미지는 사내 메일 서버가 대신 받아 보여 줍니다 — 보낸 사람에게 이 PC 의 주소는 드러나지 않습니다. 다만 이미지를 받으면 보낸 사람은 메일을 열어 본 것을 알 수 있습니다.',
|
||||
'외부 이미지는 회사를 거쳐 불러오므로 보낸 사람은 이 PC의 주소를 알 수 없습니다. 다만 이미지를 불러오면 보낸 사람은 메일을 열어 본 것을 알 수 있습니다.',
|
||||
} as const
|
||||
|
||||
@@ -59,15 +59,15 @@ export const SIGNATURE_TEXT = {
|
||||
'보내는 메일 끝에 넣는 글입니다. 쓰기 화면을 열 때 본문에 넣어 두므로, 그 메일에서만 고치거나 지울 수도 있습니다.',
|
||||
fieldLabel: '서명 내용',
|
||||
hint:
|
||||
'굵게 · 기울임 · 밑줄 · 글자 색 · 링크 · 그림을 쓸 수 있습니다. 그림은 PNG · JPG · GIF, 한 장 300KB 이하, 3장까지이고 ' +
|
||||
'붙여 넣거나 끌어다 놓아도 됩니다. 메일 안에 함께 실어 보내므로 받는 쪽에서 외부 이미지처럼 막히지 않습니다.',
|
||||
'굵게, 기울임, 밑줄, 글자 색, 링크, 그림을 쓸 수 있습니다. 그림은 PNG, JPG, GIF 파일을 한 장 300KB 이하로 3장까지 넣을 수 있고 ' +
|
||||
'붙여 넣거나 끌어다 놓아도 됩니다. 그림은 메일에 함께 실어 보내므로 받는 쪽에서 외부 이미지처럼 막히지 않습니다.',
|
||||
usageLegend: '서명을 넣을 메일',
|
||||
forNew: '새 메일',
|
||||
forReply: '답장 · 전달',
|
||||
forReplyNote: '— 인용한 원문 위에 넣습니다',
|
||||
forReply: '답장과 전달',
|
||||
forReplyNote: '인용한 원문 위에 넣습니다',
|
||||
save: '저장',
|
||||
saving: '저장하는 중…',
|
||||
saved: '저장했습니다. 새로 여는 쓰기 화면부터 들어갑니다.',
|
||||
saved: '저장했습니다. 다음에 메일을 쓸 때부터 들어갑니다.',
|
||||
uploading: '그림을 올리는 중…',
|
||||
imageSizeLabel: '그림 크기',
|
||||
imageSizes: { small: '작게', medium: '보통', original: '원본' },
|
||||
@@ -83,8 +83,8 @@ export const SIGNATURE_TEXT = {
|
||||
export const SIGNATURE_PREVIEW_NOTES = {
|
||||
newOn: '새 메일은 본문 끝에 서명을 넣습니다.',
|
||||
newOff: '새 메일에는 서명을 넣지 않습니다.',
|
||||
replyOn: '답장 · 전달은 인용한 원문 위에 서명을 넣습니다.',
|
||||
replyOff: '답장 · 전달에는 서명을 넣지 않습니다.',
|
||||
replyOn: '답장과 전달에는 인용한 원문 위에 서명을 넣습니다.',
|
||||
replyOff: '답장과 전달에는 서명을 넣지 않습니다.',
|
||||
} as const
|
||||
|
||||
/**
|
||||
|
||||
@@ -17,26 +17,26 @@ 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시부터 끝나는 날 자정까지 답합니다. 시각은 이 브라우저의 시간대를 따릅니다.',
|
||||
subject: '제목',
|
||||
body: '내용',
|
||||
bodyHint: '글자만 보냅니다 (서식 · 그림 없음). 서명은 붙지 않습니다.',
|
||||
bodyHint: '서식과 그림 없이 글자만 보내고, 서명은 붙지 않습니다.',
|
||||
save: '저장',
|
||||
saving: '저장하는 중…',
|
||||
previewTitle: '보낸 사람이 받는 답장',
|
||||
previewFrom: '보낸 사람',
|
||||
previewSubject: '제목',
|
||||
previewNoteOn: '받은 메일의 보낸 사람에게 위 글로 답합니다. 같은 사람에게 매번 보내지는 않습니다(메일 엔진 규칙).',
|
||||
previewNoteOff: '꺼져 있습니다 — 자동으로 답하지 않습니다.',
|
||||
previewNoteOn: '받은 메일의 보낸 사람에게 위 글로 답합니다. 같은 사람에게는 매번 보내지 않습니다.',
|
||||
previewNoteOff: '꺼져 있어 자동으로 답하지 않습니다.',
|
||||
// 저장한 뒤의 상태 글 — 날짜는 "10월 30일(금)" (올해가 아니면 앞에 연도)
|
||||
savedOff: '저장했습니다. 자동 응답을 껐습니다 — 써 둔 글은 남겨 둡니다.',
|
||||
savedOff: '저장했습니다. 자동 응답을 껐고, 써 둔 글은 그대로 남아 있습니다.',
|
||||
savedUntilOff: '저장했습니다. 끌 때까지 자동으로 답합니다.',
|
||||
savedRange: (from: string, to: string) => `저장했습니다. ${from}부터 ${to}까지 자동으로 답합니다.`,
|
||||
savedFrom: (from: string) => `저장했습니다. ${from}부터 자동으로 답합니다.`,
|
||||
@@ -52,7 +52,7 @@ export const VACATION_TEXT = {
|
||||
* 머리 아래 노란 줄 (7단계 — 디자인 원본 MailFilterApplyStates) — 저장한 값이 켜짐인데 엔진에서 우리 규칙이 꺼져 있을 때(isActive: false).
|
||||
* 단추는 없다 — 저장 단추는 늘 누를 수 있다. 저장하면 필터도 함께 다시 켜진다
|
||||
*/
|
||||
inactive: '켜 둔 자동 응답이 지금은 동작하지 않습니다. 메일 서버의 규칙이 다른 곳에서 바뀌었습니다 — 아래 [저장]을 누르면 다시 켜집니다.',
|
||||
inactive: '다른 곳에서 메일 설정이 바뀌어 켜 둔 자동 응답이 지금은 나가지 않습니다. 아래 [저장]을 누르면 다시 켜집니다.',
|
||||
} as const
|
||||
|
||||
/** 바꾸고 저장하지 않은 채 떠날 때 (앱 안 이동은 대화상자, 탭 닫기 · 새로고침은 브라우저 확인창) — 서명 설정과 같은 모양 */
|
||||
|
||||
@@ -156,18 +156,18 @@ export const MAIL_SENT_ACTION_LABEL = '보낸편지함 보기'
|
||||
/**
|
||||
* 받는 사람 일부가 받지 못한 채 보냈을 때 — "보냈습니다" 대신 오류 알림 모양(흰 카드) + [보낸편지함 보기]
|
||||
* (계약 2절 "받는 사람별 결과" · 디자인 README "발송 · 부재중 결함 수정" — 캔버스에는 없다).
|
||||
* 문장은 shared/utils/mail-send-result.ts 가 짓는다 — "보냈지만 3명은 받지 못했습니다 — a@… (없는 주소), b@… (받는 쪽이 거절) 외 1명"
|
||||
* 문장은 shared/utils/mail-send-result.ts 가 짓는다 — "보냈지만 3명은 받지 못했습니다. a@… (없는 주소), b@… (받는 쪽이 거절) 외 1명"
|
||||
*/
|
||||
export const MAIL_PARTIAL_SEND_TEXT = {
|
||||
/** count 는 formatCount 로 만든 글자, recipients 는 아래 recipient 를 separator 로 이은 것 (+ others) */
|
||||
message: (count: string, recipients: string) => `보냈지만 ${count}명은 받지 못했습니다 — ${recipients}`,
|
||||
message: (count: string, recipients: string) => `보냈지만 ${count}명은 받지 못했습니다. ${recipients}`,
|
||||
/**
|
||||
* 엔진이 붙잡아 둔 발송(보내기 취소를 기다리는 중 · 9단계)이면 아직 나가지 않았다 — "받지 못합니다" + [보내기 취소]
|
||||
* (디자인 원본 MailSendLater: "보냈지만 1명은 받지 못합니다 — 주소 (까닭)")
|
||||
*/
|
||||
pendingMessage: (count: string, recipients: string) => `보냈지만 ${count}명은 받지 못합니다 — ${recipients}`,
|
||||
pendingMessage: (count: string, recipients: string) => `보냈지만 ${count}명은 받지 못합니다. ${recipients}`,
|
||||
/** 예약 발송이면 — "예약했지만 N명은 받지 못합니다 — …" + [예약됨 보기] (보드에 없다) */
|
||||
scheduledMessage: (count: string, recipients: string) => `예약했지만 ${count}명은 받지 못합니다 — ${recipients}`,
|
||||
scheduledMessage: (count: string, recipients: string) => `예약했지만 ${count}명은 받지 못합니다. ${recipients}`,
|
||||
/** 주소와 까닭 사이를 띄운다 — 긴 주소 뒤에서 줄을 바꿔 까닭 낱말이 쪼개지지 않게 */
|
||||
recipient: (email: string, reason: string) => `${email} (${reason})`,
|
||||
separator: ', ',
|
||||
@@ -266,17 +266,17 @@ export const MAIL_SEARCH_TEXT = {
|
||||
clearTitle: '검색어 지우기 (Esc)',
|
||||
close: '검색 닫기',
|
||||
/** "검색 닫기 — 받은편지함으로" — 돌아갈 메일함 이름과 조사는 부르는 쪽이 붙인다 */
|
||||
closeTitle: (place: string) => `검색 닫기 — ${place}`,
|
||||
closeTitle: (place: string) => `검색을 닫고 ${place} 돌아가기`,
|
||||
title: '검색 결과',
|
||||
quoted: (query: string) => `‘${query}’`,
|
||||
count: (query: string, count: string) => `‘${query}’ · ${count}통`,
|
||||
searching: (query: string) => `‘${query}’ · 찾는 중`,
|
||||
scopeLabel: '찾을 곳',
|
||||
scopeAll: '전체 메일함',
|
||||
scopeAllHint: '휴지통 · 스팸메일함은 빼고 찾습니다',
|
||||
scopeAllHint: '휴지통과 스팸메일함은 빼고 찾습니다',
|
||||
emptyTitle: (query: string) => `‘${query}’에 맞는 메일이 없습니다`,
|
||||
emptyHint:
|
||||
'낱말 단위로 찾습니다. 붙여 쓴 말의 일부(‘서버이전’의 ‘서버’)나 제목 맨 앞의 말머리([공지] · Re:)로는 찾지 못합니다.',
|
||||
'낱말 단위로 찾습니다. ‘서버이전’의 ‘서버’처럼 붙여 쓴 말의 일부나, 제목 맨 앞의 [공지]나 Re: 같은 말머리로는 찾지 못합니다.',
|
||||
searchAll: '전체 메일함에서 찾기',
|
||||
/** 결과 목록의 보이지 않는 이름 (불러오는 동안) */
|
||||
loading: '찾는 중',
|
||||
@@ -327,7 +327,7 @@ export const MAIL_SPAM_TEXT = {
|
||||
/** 스팸메일함 — 받은편지함으로 옮기고 표시를 "스팸 아님"으로 바꾼다 */
|
||||
notSpam: '스팸 아님',
|
||||
/** place 에는 메일함 이름과 조사를 붙여 넘긴다 ("받은편지함으로") */
|
||||
notSpamMoved: (count: string, place: string) => `메일 ${count}통을 ${place} 옮겼습니다 (스팸 아님).`,
|
||||
notSpamMoved: (count: string, place: string) => `메일 ${count}통을 스팸이 아닌 메일로 표시해 ${place} 옮겼습니다.`,
|
||||
} as const
|
||||
|
||||
/** 읽기 화면 도구 줄의 ⋯ (더 보기 40×44) 메뉴 */
|
||||
|
||||
@@ -38,7 +38,7 @@ export const MAILBOX_DIALOG_TEXT = {
|
||||
createChildTitle: (parent: string) => `‘${parent}’ 안에 새 메일함`,
|
||||
renameTitle: '메일함 이름 바꾸기',
|
||||
nameLabel: '이름',
|
||||
nameHelp: '64자까지. 같은 자리에 같은 이름은 쓸 수 없습니다.',
|
||||
nameHelp: '64자까지 쓸 수 있고, 같은 자리에 같은 이름은 쓸 수 없습니다.',
|
||||
create: '만들기',
|
||||
rename: '바꾸기',
|
||||
cancel: '취소',
|
||||
@@ -72,7 +72,7 @@ export const MAILBOX_OPERATION_TEXT = {
|
||||
/** 처리할 것이 없었다 — 다른 탭 · 메일 프로그램이 먼저 했다 */
|
||||
readNone: (mailbox: string) => `${mailbox}에 안 읽은 메일이 없습니다.`,
|
||||
emptyProgress: (object: string) => `${object} 비우는 중`,
|
||||
emptyDone: (object: string, count: string) => `${object} 비웠습니다 — 메일 ${count}통.`,
|
||||
emptyDone: (object: string, count: string) => `메일 ${count}통을 지워 ${object} 비웠습니다.`,
|
||||
/** subject 에는 메일함 이름에 '이/가'까지 붙여 넘긴다 ("휴지통이") */
|
||||
emptyNone: (subject: string) => `${subject} 이미 비어 있습니다.`,
|
||||
deleteProgress: (mailbox: string) => `‘${mailbox}’ 메일함을 지우는 중`,
|
||||
@@ -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
|
||||
|
||||
@@ -27,34 +27,34 @@ export const LOGIN_PASSWORD_CHANGE_TEXT = {
|
||||
next: '새 비밀번호',
|
||||
showNext: '새 비밀번호 보기',
|
||||
/** 규칙(8자 이상 · 임시 비밀번호와 다름)에 맞지 않으면 이 글이 빨간 오류가 된다 */
|
||||
nextHint: '8자 이상 · 임시 비밀번호와 달라야 합니다. 영문 · 숫자 · 기호를 섞으면 더 안전합니다.',
|
||||
nextHint: '8자 이상이고 임시 비밀번호와 달라야 합니다. 영문, 숫자, 기호를 섞으면 더 안전합니다.',
|
||||
confirm: '새 비밀번호 확인',
|
||||
mismatch: '새 비밀번호가 서로 다릅니다.',
|
||||
/** 2단계 인증을 켠 직원만 — 맨 아래 (로그인 때 쓴 코드는 2분 동안 다시 받지 않는다) */
|
||||
code: '인증 앱 코드',
|
||||
codeHint: '로그인할 때 넣은 코드는 다시 쓸 수 없습니다 — 앱에 다음 코드가 뜨면 넣어 주세요.',
|
||||
codeHint: '로그인할 때 넣은 코드는 다시 쓸 수 없으니, 앱에 다음 코드가 뜨면 넣어 주세요.',
|
||||
submit: '비밀번호 바꾸고 계속',
|
||||
submitting: '바꾸는 중…',
|
||||
note: '메일 프로그램(아웃룩 등)을 연결해 두었다면 바꾼 뒤 다시 연결해야 합니다.',
|
||||
note: '아웃룩 같은 메일 프로그램을 연결해 두었다면 비밀번호를 바꾼 뒤 다시 연결해 주세요.',
|
||||
/** 끝난 뒤 알림 */
|
||||
done: '새 비밀번호를 정했습니다.',
|
||||
} as const
|
||||
|
||||
/** 로그인 — 2단계 인증 켜기(필수) (디자인 원본 LoginTwoFactorSetup). QR · 키 · [복사]의 글은 12단계 TWO_FACTOR_ENABLE_TEXT */
|
||||
export const LOGIN_TWO_FACTOR_SETUP_TEXT = {
|
||||
title: '2단계 인증을 켜야 합니다',
|
||||
title: '2단계 인증을 켜 주세요',
|
||||
subtitle: '회사 정책에 따라 로그인할 때 휴대폰 인증 앱의 코드도 확인합니다. 지금 켜면 바로 메일로 이어집니다.',
|
||||
scanTitle: '인증 앱으로 QR 코드를 찍어 주세요',
|
||||
scanHint:
|
||||
'인증 앱에서 [+] → 「QR 코드 스캔」을 고릅니다. 앱이 없으면 Google Authenticator · Microsoft Authenticator 를 먼저 설치해 주세요.',
|
||||
'인증 앱에서 [+] → [QR 코드 스캔]을 고릅니다. 앱이 없으면 Google Authenticator나 Microsoft Authenticator를 먼저 설치해 주세요.',
|
||||
codeLabel: '앱에 표시된 6자리 코드',
|
||||
/** 화면 메모리에 지금 비밀번호가 없을 때만 ③ */
|
||||
passwordLabel: '지금 비밀번호',
|
||||
showPassword: '지금 비밀번호 보기',
|
||||
passwordHint: '새로고침해서 다시 넣어야 합니다.',
|
||||
passwordHint: '새로고침한 뒤에는 지금 비밀번호를 한 번 더 넣어 주세요.',
|
||||
submit: '켜고 계속',
|
||||
submitting: '켜는 중…',
|
||||
note: 'Outlook · 휴대폰 메일 앱은 이 코드를 묻지 않습니다. 켠 뒤에는 설정 › 앱 비밀번호에서 만든 비밀번호로 연결합니다.',
|
||||
note: 'Outlook이나 휴대폰 메일 앱은 이 코드를 묻지 않습니다. 켠 뒤에는 [설정] → [앱 비밀번호]에서 만든 비밀번호로 연결해 주세요.',
|
||||
/** 켠 뒤 알림 — [앱 비밀번호 만들기](단추가 있는 알림 10초) */
|
||||
done: '2단계 인증을 켰습니다. 메일 프로그램은 앱 비밀번호로 연결합니다.',
|
||||
doneAction: '앱 비밀번호 만들기',
|
||||
@@ -78,12 +78,12 @@ export const TWO_FACTOR_GRACE_TEXT = {
|
||||
region: '2단계 인증 안내',
|
||||
/** "11월 30일까지 2단계 인증을 켜 주세요" (굵게) — 날짜는 시작일 전날, 올해가 아니면 해를 붙인다 */
|
||||
deadline: (day: string) => `${day}까지 2단계 인증을 켜 주세요`,
|
||||
barAfter: ' — 그 뒤에는 켜야 로그인할 수 있습니다.',
|
||||
barAfter: '. 그 뒤에는 켜야 로그인할 수 있습니다.',
|
||||
action: '지금 켜기',
|
||||
/** 설정 › 2단계 인증 머리 아래 갈색 상자의 둘째 줄 */
|
||||
settingsText: '회사 정책에 따라 그 뒤에는 2단계 인증을 켜야 로그인할 수 있습니다.',
|
||||
/** 필수인 직원이 켠 뒤 — [2단계 인증 끄기] 대신 */
|
||||
cannotDisable: '회사 정책에 따라 끌 수 없습니다',
|
||||
/** 필수인 직원이 켠 뒤 — 아래 안내의 첫 줄 (12단계 「끈 뒤 새 휴대폰으로 다시 켜 주세요」 대신) */
|
||||
noteChangePhone: '휴대폰을 바꿀 때는 관리자에게 2단계 인증 해제를 요청해 주세요 — 다음 로그인 때 새 휴대폰으로 다시 켭니다.',
|
||||
noteChangePhone: '휴대폰을 바꿀 때는 관리자에게 2단계 인증을 풀어 달라고 요청해 주세요. 다음에 로그인할 때 새 휴대폰으로 다시 켜면 됩니다.',
|
||||
} as const
|
||||
|
||||
@@ -112,7 +112,7 @@ describe('2단계 인증 상태 글', () => {
|
||||
it('꺼짐 · 켜짐 — 웹메일에서 켠 때가 있으면 "2026년 10월 31일부터"', () => {
|
||||
expect(twoFactorStateLabel({ isEnabled: false, enabledAt: null })).toBe('꺼짐')
|
||||
expect(twoFactorStateLabel({ isEnabled: true, enabledAt: null })).toBe('켜짐')
|
||||
expect(twoFactorStateLabel({ isEnabled: true, enabledAt: '2026-10-31T03:00:00Z' })).toBe('켜짐 — 2026년 10월 31일부터')
|
||||
expect(twoFactorStateLabel({ isEnabled: true, enabledAt: '2026-10-31T03:00:00Z' })).toBe('2026년 10월 31일부터 켜짐')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -167,17 +167,17 @@ describe('로그인 기록', () => {
|
||||
|
||||
it('결과 글 다섯 가지 — 실패는 isFailure', () => {
|
||||
expect(loginResultLabel(event({}))).toEqual({ label: '성공', isFailure: false })
|
||||
expect(loginResultLabel(event({ isTwoFactor: true }))).toEqual({ label: '성공 · 2단계 인증', isFailure: false })
|
||||
expect(loginResultLabel(event({ isTwoFactor: true }))).toEqual({ label: '2단계 인증으로 성공', isFailure: false })
|
||||
expect(loginResultLabel(event({ isSuccess: false, failureReason: 'wrongPassword' }))).toEqual({
|
||||
label: '실패 — 비밀번호 틀림',
|
||||
label: '비밀번호가 틀려 실패',
|
||||
isFailure: true,
|
||||
})
|
||||
expect(loginResultLabel(event({ isSuccess: false, failureReason: 'wrongCode' }))).toEqual({
|
||||
label: '실패 — 코드 틀림',
|
||||
label: '코드가 틀려 실패',
|
||||
isFailure: true,
|
||||
})
|
||||
expect(loginResultLabel(event({ isSuccess: false, failureReason: 'appPasswordRequired' }))).toEqual({
|
||||
label: '실패 — 앱 비밀번호 필요',
|
||||
label: '앱 비밀번호가 필요해 실패',
|
||||
isFailure: true,
|
||||
})
|
||||
// 모르는 까닭 — 「실패」만
|
||||
@@ -195,7 +195,7 @@ describe('다른 곳 모두 로그아웃', () => {
|
||||
const others = otherSessions(sessions)
|
||||
expect(others.map((item) => item.id)).toEqual(['a', 'b'])
|
||||
expect(endOtherSessionsMessage(others)).toBe(
|
||||
'이 브라우저를 뺀 2곳(Edge · Windows, Safari · iOS)의 웹메일 로그인을 끊습니다. 끊긴 곳은 다음 동작에서 바로 로그인 화면으로 돌아갑니다.',
|
||||
'이 브라우저를 뺀 2곳(Edge · Windows, Safari · iOS)의 웹메일에서 로그아웃합니다. 그곳에서 다음에 무엇이든 누르면 바로 로그인 화면이 나옵니다.',
|
||||
)
|
||||
|
||||
const many = [
|
||||
@@ -205,7 +205,7 @@ describe('다른 곳 모두 로그아웃', () => {
|
||||
session({ id: 'e', device: null }),
|
||||
]
|
||||
expect(endOtherSessionsMessage(many)).toBe(
|
||||
'이 브라우저를 뺀 5곳(Edge · Windows, Safari · iOS, Firefox · Linux 외 2곳)의 웹메일 로그인을 끊습니다. 끊긴 곳은 다음 동작에서 바로 로그인 화면으로 돌아갑니다.',
|
||||
'이 브라우저를 뺀 5곳(Edge · Windows, Safari · iOS, Firefox · Linux 외 2곳)의 웹메일에서 로그아웃합니다. 그곳에서 다음에 무엇이든 누르면 바로 로그인 화면이 나옵니다.',
|
||||
)
|
||||
})
|
||||
|
||||
@@ -213,7 +213,7 @@ describe('다른 곳 모두 로그아웃', () => {
|
||||
const unknown = [session({ id: 'a', device: { browser: 'Edge', os: 'Windows' } }), session({ id: 'b', device: null })]
|
||||
|
||||
expect(endOtherSessionsMessage(unknown, true)).toBe(
|
||||
'이 브라우저를 알아보지 못해 이 브라우저를 포함한 2곳(Edge · Windows, 알 수 없는 브라우저)의 웹메일 로그인을 끊습니다. 이 브라우저도 로그아웃되어 로그인 화면으로 돌아갑니다.',
|
||||
'이 브라우저를 알아보지 못해서 이 브라우저를 포함한 2곳(Edge · Windows, 알 수 없는 브라우저)의 웹메일에서 로그아웃합니다. 이 브라우저도 로그아웃되어 로그인 화면으로 돌아갑니다.',
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -71,7 +71,7 @@ export function isMobileDevice(device: LoginDevice | null): boolean {
|
||||
return MOBILE_OS.has(device?.os ?? '')
|
||||
}
|
||||
|
||||
/** 2단계 인증 상태 글 — 「꺼짐」 · 「켜짐 — 2026년 10월 31일부터」(켠 때를 모르면 「켜짐」) */
|
||||
/** 2단계 인증 상태 글 — 「꺼짐」 · 「2026년 10월 31일부터 켜짐」(켠 때를 모르면 「켜짐」) */
|
||||
export function twoFactorStateLabel(status: TwoFactorStatus): string {
|
||||
if (!status.isEnabled) return TWO_FACTOR_TEXT.off
|
||||
const day = status.enabledAt ? formatLongDate(status.enabledAt) : ''
|
||||
@@ -127,7 +127,7 @@ export function loginDeviceLabel(event: LoginEvent): string | null {
|
||||
}
|
||||
|
||||
/**
|
||||
* 로그인 기록의 결과 글 — 「성공」 · 「성공 · 2단계 인증」 · 「실패 — 비밀번호 틀림」 · 「실패 — 코드 틀림」 · 「실패 — 앱 비밀번호 필요」.
|
||||
* 로그인 기록의 결과 글 — 「성공」 · 「2단계 인증으로 성공」 · 「비밀번호가 틀려 실패」 · 「코드가 틀려 실패」 · 「앱 비밀번호가 필요해 실패」.
|
||||
* 실패는 화면이 빨간 굵은 글자로만 보인다 (뱃지 없음)
|
||||
*/
|
||||
export function loginResultLabel(event: LoginEvent): { label: string; isFailure: boolean } {
|
||||
|
||||
@@ -32,7 +32,7 @@ describe('읽기 화면의 대용량 첨부 줄 (디자인 원본 MailReadLargeF
|
||||
|
||||
it('검사 중 — 끝나는 날 대신 까닭, 받기는 막는다(aria-disabled) · 검사하지 못함은 받을 수 있다', () => {
|
||||
expect(messageLargeFileView({ ...FILE, status: 'scanning' })).toEqual({
|
||||
meta: '1.2 GB · 보안 검사 중 — 끝나면 받을 수 있습니다',
|
||||
meta: '1.2 GB · 보안 검사가 끝나면 받을 수 있습니다',
|
||||
tone: 'normal',
|
||||
download: 'blocked',
|
||||
})
|
||||
@@ -41,27 +41,27 @@ describe('읽기 화면의 대용량 첨부 줄 (디자인 원본 MailReadLargeF
|
||||
|
||||
it('위험은 빨간 줄 · 끝난 링크와 받을 수 없는 파일은 흐리게 — 받기 단추 없음', () => {
|
||||
expect(messageLargeFileView({ ...FILE, status: 'infected' })).toEqual({
|
||||
meta: '위험 — 보안 검사에서 막았습니다. 받을 수 없습니다',
|
||||
meta: '위험한 내용이 있어 보안 검사에서 막았고, 받을 수 없습니다',
|
||||
tone: 'danger',
|
||||
download: 'none',
|
||||
})
|
||||
expect(messageLargeFileView({ ...FILE, status: 'expired' })).toEqual({
|
||||
meta: '1.2 GB · 기간 지남 — 11월 29일까지였습니다',
|
||||
meta: '1.2 GB · 받을 수 있는 기간이 11월 29일에 끝났습니다',
|
||||
tone: 'dim',
|
||||
download: 'none',
|
||||
})
|
||||
expect(messageLargeFileView({ ...FILE, status: 'revoked' }).meta).toBe('1.2 GB · 공유 멈춤 — 보낸 사람이 링크를 끊었습니다')
|
||||
expect(messageLargeFileView({ ...FILE, status: 'revoked' }).meta).toBe('1.2 GB · 보낸 사람이 링크를 끊어 받을 수 없습니다')
|
||||
expect(messageLargeFileView({ ...FILE, status: 'revoked', downloads: { count: 3, lastDownloadedAt: null } }).meta).toBe(
|
||||
'1.2 GB · 3회 받음 · 공유 멈춤 — 링크를 끊었습니다',
|
||||
'1.2 GB · 3회 받음 · 링크를 끊었습니다',
|
||||
)
|
||||
// 끊은 때(revokedAt)는 보낸 사람 자신에게만 날짜로 보인다(한국 날짜 — 디자인 원본 MailReadLargeFiles 셋째 줄). 받는 사람은 날짜 없이
|
||||
const revokedAt = '2026-10-30T15:30:00Z'
|
||||
expect(messageLargeFileView({ ...FILE, status: 'revoked', revokedAt }).meta).toBe(
|
||||
'1.2 GB · 공유 멈춤 — 보낸 사람이 링크를 끊었습니다',
|
||||
'1.2 GB · 보낸 사람이 링크를 끊어 받을 수 없습니다',
|
||||
)
|
||||
expect(
|
||||
messageLargeFileView({ ...FILE, status: 'revoked', revokedAt, downloads: { count: 3, lastDownloadedAt: null } }).meta,
|
||||
).toBe('1.2 GB · 3회 받음 · 공유 멈춤 — 10월 31일에 끊었습니다')
|
||||
).toBe('1.2 GB · 3회 받음 · 10월 31일에 링크를 끊었습니다')
|
||||
expect(messageLargeFileView({ ...FILE, status: 'limit-reached' }).tone).toBe('dim')
|
||||
expect(messageLargeFileView({ ...FILE, status: 'missing' }).download).toBe('none')
|
||||
})
|
||||
|
||||
@@ -92,7 +92,7 @@ describe('크기 · 진행 글', () => {
|
||||
|
||||
it('첨부 안내 — 일반 한도 뒤에 링크 안내를 붙인다 (디자인 원본 ComposeLargeFiles)', () => {
|
||||
expect(composeAttachmentHint({ ...NORMAL, maxCount: 20 }, LIMITS)).toBe(
|
||||
'파일당 최대 20MB · 합계 25MB · 최대 20개 · 더 큰 파일은 링크로 보냅니다 (파일당 2GB · 10개까지)',
|
||||
'파일당 최대 20MB · 합계 25MB · 최대 20개 · 더 큰 파일은 파일당 2GB, 10개까지 링크로 보냅니다',
|
||||
)
|
||||
})
|
||||
|
||||
@@ -166,7 +166,7 @@ describe('[보내기]를 막는 까닭 (계약 4절 "보내기를 막는 까닭"
|
||||
it('올리는 중 — 일반 첨부와 대용량 파일을 합한 수', () => {
|
||||
expect(sendBlockOf({ ...clear, normalUploading: 1, largeActive: 1 })).toEqual({
|
||||
code: 'large-file-uploading',
|
||||
text: '파일 2개를 올리는 중입니다 — 다 올리면 보낼 수 있습니다.',
|
||||
text: '파일 2개를 올리는 중입니다. 다 올린 뒤에 보낼 수 있습니다.',
|
||||
})
|
||||
expect(sendBlockOf({ ...clear, normalUploading: 1 })?.code).toBe('attachment-uploading')
|
||||
})
|
||||
@@ -175,11 +175,11 @@ describe('[보내기]를 막는 까닭 (계약 4절 "보내기를 막는 까닭"
|
||||
const all = { ...clear, largeActive: 1, largeIncomplete: true, largeInfected: true, largeCount: 11 }
|
||||
expect(sendBlockOf(all)).toEqual({
|
||||
code: 'large-file-infected',
|
||||
text: '보낼 수 없는 파일이 있습니다 — 빨간 줄의 파일을 빼 주세요.',
|
||||
text: '보낼 수 없는 파일이 있습니다. 빨간 줄의 파일을 빼 주세요.',
|
||||
})
|
||||
expect(sendBlockOf({ ...all, largeInfected: false })).toEqual({
|
||||
code: 'large-file-incomplete',
|
||||
text: '다 올리지 못한 파일이 있습니다 — 이어 올리거나 빼 주세요.',
|
||||
text: '다 올리지 못한 파일이 있습니다. 이어 올리거나 빼 주세요.',
|
||||
})
|
||||
expect(sendBlockOf({ ...all, largeInfected: false, largeIncomplete: false })?.code).toBe('large-file-count')
|
||||
expect(sendBlockOf({ ...clear, largeMissing: true })?.code).toBe('large-file-missing')
|
||||
|
||||
@@ -104,7 +104,7 @@ export function formatSizeLimit(bytes: number): string {
|
||||
}
|
||||
|
||||
/**
|
||||
* 첨부 안내 — "파일당 최대 20MB · 합계 25MB · 최대 20개 · 더 큰 파일은 링크로 보냅니다 (파일당 2GB · 10개까지)".
|
||||
* 첨부 안내 — "파일당 최대 20MB · 합계 25MB · 최대 20개 · 더 큰 파일은 파일당 2GB, 10개까지 링크로 보냅니다".
|
||||
* 일반 첨부의 형식 설명(문서, 이미지, 압축 파일)은 링크 안내로 바꾼다 (디자인 원본 ComposeLargeFiles)
|
||||
*/
|
||||
export function composeAttachmentHint(
|
||||
|
||||
@@ -66,10 +66,10 @@ describe('mail-bulk (여러 통 처리 보조)', () => {
|
||||
expect(restoredMessageText(1_234)).toBe('메일 1,234통을 되돌렸습니다.')
|
||||
})
|
||||
|
||||
it('스팸 아님 — "메일 1통을 받은편지함으로 옮겼습니다 (스팸 아님)." · 일부만 처리했으면 그대로 둔 수를 덧붙인다', () => {
|
||||
expect(notSpamMovedText(1, '받은편지함')).toBe('메일 1통을 받은편지함으로 옮겼습니다 (스팸 아님).')
|
||||
it('스팸 아님 — "메일 1통을 스팸이 아닌 메일로 표시해 받은편지함으로 옮겼습니다." · 일부만 처리했으면 그대로 둔 수를 덧붙인다', () => {
|
||||
expect(notSpamMovedText(1, '받은편지함')).toBe('메일 1통을 스팸이 아닌 메일로 표시해 받은편지함으로 옮겼습니다.')
|
||||
expect(notSpamMovedText(2, '받은편지함', 1)).toBe(
|
||||
'메일 2통을 받은편지함으로 옮겼습니다 (스팸 아님). 1통은 그사이 옮겨졌거나 지워져 그대로 두었습니다.',
|
||||
'메일 2통을 스팸이 아닌 메일로 표시해 받은편지함으로 옮겼습니다. 1통은 그사이 옮겨졌거나 지워져 그대로 두었습니다.',
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -119,25 +119,25 @@ describe('알림 문구 (디자인 원본 MailFilterApplyStates)', () => {
|
||||
const copy = mailFilterRunCopy(rule())
|
||||
|
||||
it('하는 중 — "· 확인 / 전체통 확인", 전체를 모르면 수를 뺀다', () => {
|
||||
expect(copy.progress({ scanned: 4200, total: 8412 })).toBe('‘거래처 메일’ 필터를 받은편지함에 적용하는 중 · 4,200 / 8,412통 확인')
|
||||
expect(copy.progress({ scanned: 0, total: 0 })).toBe('‘거래처 메일’ 필터를 받은편지함에 적용하는 중')
|
||||
expect(copy.progress({ scanned: 4200, total: 8412 })).toBe('‘거래처 메일’ 필터로 받은편지함을 정리하는 중 · 4,200 / 8,412통 확인')
|
||||
expect(copy.progress({ scanned: 0, total: 0 })).toBe('‘거래처 메일’ 필터로 받은편지함을 정리하는 중')
|
||||
})
|
||||
|
||||
it('끝 — N통에 적용, 0통이면 맞는 메일이 없다', () => {
|
||||
expect(copy.done(37)).toBe('‘거래처 메일’ 필터를 받은편지함의 메일 37통에 적용했습니다.')
|
||||
expect(copy.done(1234)).toBe('‘거래처 메일’ 필터를 받은편지함의 메일 1,234통에 적용했습니다.')
|
||||
expect(copy.done(37)).toBe('‘거래처 메일’ 필터로 받은편지함의 메일 37통을 정리했습니다.')
|
||||
expect(copy.done(1234)).toBe('‘거래처 메일’ 필터로 받은편지함의 메일 1,234통을 정리했습니다.')
|
||||
expect(copy.done(0)).toBe('받은편지함에 ‘거래처 메일’ 필터에 맞는 메일이 없습니다.')
|
||||
})
|
||||
|
||||
it('멈춤 — 전체 가운데 확인한 데까지 · 적용한 수', () => {
|
||||
expect(copy.stopped({ scanned: 4200, matched: 12, total: 8412 })).toBe(
|
||||
'‘거래처 메일’ 필터 — 8,412통 가운데 4,200통까지 확인하고 멈췄습니다(12통에 적용). 다시 시도해 주세요.',
|
||||
'‘거래처 메일’ 필터로 8,412통 가운데 4,200통까지 확인하고 멈췄습니다. 그동안 12통을 정리했습니다. 다시 시도해 주세요.',
|
||||
)
|
||||
})
|
||||
|
||||
it('이름 없는 필터는 조건 글이 이름 자리에 — 작은따옴표가 겹치지 않게 큰따옴표로 묶는다', () => {
|
||||
expect(mailFilterRunCopy(rule({ name: '' })).done(3)).toBe(
|
||||
'“보낸 사람에 ‘@hanbit-partners.co.kr’ 포함” 필터를 받은편지함의 메일 3통에 적용했습니다.',
|
||||
'“보낸 사람에 ‘@hanbit-partners.co.kr’ 포함” 필터로 받은편지함의 메일 3통을 정리했습니다.',
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -250,7 +250,7 @@ describe('대용량 첨부 덩이 접기 (11단계 — 디자인 원본 MailRead
|
||||
expect(details).not.toBeNull()
|
||||
expect(details?.hasAttribute('open')).toBe(false)
|
||||
expect(details?.querySelector('summary')?.textContent).toContain('본문의 대용량 첨부 안내')
|
||||
expect(details?.querySelector('summary')?.textContent).toContain('(접어 둠) — 파일은 아래 첨부 칸에 있습니다')
|
||||
expect(details?.querySelector('summary')?.textContent).toContain('파일은 아래 첨부 칸에 있어 접어 두었습니다')
|
||||
expect(details?.querySelector('table a')?.getAttribute('href')).toBe('https://files.arasystem.it.kr/s/abc')
|
||||
expect(body.innerHTML).not.toContain('data-ara-large-files')
|
||||
// 덩이는 제자리 — 내 글 아래, 인용 위
|
||||
|
||||
@@ -13,14 +13,14 @@ import {
|
||||
describe('realtimeLinkText (설정 › 알림의 실시간 연결 줄)', () => {
|
||||
it('연결됨은 초록 점과 "연결되어 있습니다", 끊김 · 지원 안 함은 "연결할 수 없어 1분마다 확인합니다."', () => {
|
||||
expect(realtimeLinkText('live')).toMatchObject({
|
||||
text: '연결되어 있습니다 — 새 메일이 오면 바로 목록에 나타납니다.',
|
||||
text: '연결되어 있어 새 메일이 오면 바로 목록에 나타납니다.',
|
||||
notice: null,
|
||||
live: true,
|
||||
})
|
||||
expect(realtimeLinkText('down')).toEqual({
|
||||
text: '연결할 수 없어 1분마다 확인합니다.',
|
||||
notice: '연결될 때까지 바탕화면 알림은 오지 않습니다.',
|
||||
hint: '저절로 다시 잇습니다. 회사 방화벽 · 프록시가 오래 열린 연결을 끊으면 이렇게 됩니다.',
|
||||
hint: '저절로 다시 연결합니다. 회사 방화벽이나 프록시가 오래 열린 연결을 끊으면 이렇게 될 수 있습니다.',
|
||||
live: false,
|
||||
})
|
||||
expect(realtimeLinkText('unsupported')).toEqual({
|
||||
|
||||
@@ -12,8 +12,8 @@ describe('partialSendMessage — 받는 사람 일부가 받지 못한 발송
|
||||
expect(partialSendMessage([])).toBeNull()
|
||||
})
|
||||
|
||||
it('1명 — "보냈지만 1명은 받지 못했습니다 — 주소 (까닭)"', () => {
|
||||
expect(partialSendMessage([NOBODY])).toBe('보냈지만 1명은 받지 못했습니다 — nobody@arasystem.it.kr (없는 주소)')
|
||||
it('1명 — "보냈지만 1명은 받지 못했습니다. 주소 (까닭)"', () => {
|
||||
expect(partialSendMessage([NOBODY])).toBe('보냈지만 1명은 받지 못했습니다. nobody@arasystem.it.kr (없는 주소)')
|
||||
})
|
||||
|
||||
it.each<[RejectedRecipientReason, string]>([
|
||||
@@ -22,29 +22,29 @@ describe('partialSendMessage — 받는 사람 일부가 받지 못한 발송
|
||||
['temporary', '지금은 받을 수 없음'],
|
||||
])('까닭 %s 는 "%s"', (reason, text) => {
|
||||
expect(partialSendMessage([{ email: 'a@arasystem.it.kr', reason }])).toBe(
|
||||
`보냈지만 1명은 받지 못했습니다 — a@arasystem.it.kr (${text})`,
|
||||
`보냈지만 1명은 받지 못했습니다. a@arasystem.it.kr (${text})`,
|
||||
)
|
||||
})
|
||||
|
||||
it('2명 — 두 사람 모두 "주소 (까닭)"를 ", " 로 잇는다', () => {
|
||||
expect(partialSendMessage([NOBODY, REFUSED])).toBe(
|
||||
'보냈지만 2명은 받지 못했습니다 — nobody@arasystem.it.kr (없는 주소), ceo@partner.example (받는 쪽이 거절)',
|
||||
'보냈지만 2명은 받지 못했습니다. nobody@arasystem.it.kr (없는 주소), ceo@partner.example (받는 쪽이 거절)',
|
||||
)
|
||||
})
|
||||
|
||||
it('3명 이상 — 앞의 두 명만 적고 나머지는 " 외 N명"', () => {
|
||||
expect(partialSendMessage([NOBODY, REFUSED, BUSY])).toBe(
|
||||
'보냈지만 3명은 받지 못했습니다 — nobody@arasystem.it.kr (없는 주소), ceo@partner.example (받는 쪽이 거절) 외 1명',
|
||||
'보냈지만 3명은 받지 못했습니다. nobody@arasystem.it.kr (없는 주소), ceo@partner.example (받는 쪽이 거절) 외 1명',
|
||||
)
|
||||
const many = Array.from({ length: 5 }, (_, index) => ({ ...BUSY, email: `user${index + 1}@arasystem.it.kr` }))
|
||||
expect(partialSendMessage(many)).toBe(
|
||||
'보냈지만 5명은 받지 못했습니다 — user1@arasystem.it.kr (지금은 받을 수 없음), user2@arasystem.it.kr (지금은 받을 수 없음) 외 3명',
|
||||
'보냈지만 5명은 받지 못했습니다. user1@arasystem.it.kr (지금은 받을 수 없음), user2@arasystem.it.kr (지금은 받을 수 없음) 외 3명',
|
||||
)
|
||||
})
|
||||
|
||||
it.each(['bounced', 'constructor'])('계약에 없는 까닭(%s)이면 까닭 없이 주소만 적는다', (reason) => {
|
||||
const recipient: RejectedRecipient = { email: NOBODY.email, reason: reason as string as RejectedRecipientReason }
|
||||
expect(partialSendMessage([recipient])).toBe('보냈지만 1명은 받지 못했습니다 — nobody@arasystem.it.kr')
|
||||
expect(partialSendMessage([recipient])).toBe('보냈지만 1명은 받지 못했습니다. nobody@arasystem.it.kr')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -53,19 +53,19 @@ describe('partialSendMessage — 엔진이 붙잡아 둔 발송 (9단계 "보내
|
||||
|
||||
it('보내기 취소를 기다리는 중이면 아직 나가지 않았다 — "받지 못합니다" (디자인 원본 MailSendLater)', () => {
|
||||
expect(partialSendMessage([NOBODY], { pendingKind: 'undo' })).toBe(
|
||||
'보냈지만 1명은 받지 못합니다 — nobody@arasystem.it.kr (없는 주소)',
|
||||
'보냈지만 1명은 받지 못합니다. nobody@arasystem.it.kr (없는 주소)',
|
||||
)
|
||||
})
|
||||
|
||||
it('예약이면 "예약했지만 … 받지 못합니다"', () => {
|
||||
expect(partialSendMessage([NOBODY], { pendingKind: 'scheduled' })).toBe(
|
||||
'예약했지만 1명은 받지 못합니다 — nobody@arasystem.it.kr (없는 주소)',
|
||||
'예약했지만 1명은 받지 못합니다. nobody@arasystem.it.kr (없는 주소)',
|
||||
)
|
||||
})
|
||||
|
||||
it('바로 보냈으면(null) 지금처럼 "받지 못했습니다"', () => {
|
||||
expect(partialSendMessage([NOBODY], { pendingKind: null })).toBe(
|
||||
'보냈지만 1명은 받지 못했습니다 — nobody@arasystem.it.kr (없는 주소)',
|
||||
'보냈지만 1명은 받지 못했습니다. nobody@arasystem.it.kr (없는 주소)',
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -148,7 +148,7 @@ describe('알리는 글', () => {
|
||||
expect(vacationSavedText(ranged(null, null), now)).toBe('저장했습니다. 끌 때까지 자동으로 답합니다.')
|
||||
expect(vacationSavedText(ranged(null, '2026-11-03'), now)).toBe('저장했습니다. 11월 3일(화)까지 자동으로 답합니다.')
|
||||
expect(vacationSavedText(ranged('2026-10-30', null), now)).toBe('저장했습니다. 10월 30일(금)부터 자동으로 답합니다.')
|
||||
expect(vacationSavedText(OFF, now)).toBe('저장했습니다. 자동 응답을 껐습니다 — 써 둔 글은 남겨 둡니다.')
|
||||
expect(vacationSavedText(OFF, now)).toBe('저장했습니다. 자동 응답을 껐고, 써 둔 글은 그대로 남아 있습니다.')
|
||||
})
|
||||
|
||||
it('올해가 아니면 연도를 붙인다', () => {
|
||||
|
||||
@@ -10,15 +10,15 @@ 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('1,234통 가운데 600통까지 처리하고 멈췄습니다. 다시 시도해 주세요.')
|
||||
// 다른 탭이 먼저 했다
|
||||
expect(copy.done(0)).toBe('받은편지함에 안 읽은 메일이 없습니다.')
|
||||
})
|
||||
|
||||
it('비우기 — 메일함 이름의 받침에 맞춰 을/를', () => {
|
||||
expect(mailboxOperationCopy('empty', '휴지통').progress(20, 21)).toBe('휴지통을 비우는 중 · 20 / 21통')
|
||||
expect(mailboxOperationCopy('empty', '휴지통').done(21)).toBe('휴지통을 비웠습니다 — 메일 21통.')
|
||||
expect(mailboxOperationCopy('empty', '스팸메일함').done(5)).toBe('스팸메일함을 비웠습니다 — 메일 5통.')
|
||||
expect(mailboxOperationCopy('empty', '휴지통').done(21)).toBe('메일 21통을 지워 휴지통을 비웠습니다.')
|
||||
expect(mailboxOperationCopy('empty', '스팸메일함').done(5)).toBe('메일 5통을 지워 스팸메일함을 비웠습니다.')
|
||||
// 다른 탭이 먼저 비웠다 — 받침에 맞춰 이/가
|
||||
expect(mailboxOperationCopy('empty', '휴지통').done(0)).toBe('휴지통이 이미 비어 있습니다.')
|
||||
expect(mailboxOperationCopy('empty', 'Spam').done(0)).toBe('Spam이(가) 이미 비어 있습니다.')
|
||||
|
||||
@@ -41,13 +41,13 @@ describe('인용 속 링크의 상태 → 칸 아래 글 (디자인 README "11
|
||||
|
||||
it('받을 수 있는 링크 — 원래 메일의 파일 수와 끝나는 날(한국)', () => {
|
||||
expect(quotedLinkNotices([{ url: URL, status: 'active', expiresAt: '2026-11-29T14:59:59.999Z' }], names)).toEqual([
|
||||
{ key: URL, tone: 'info', text: '원래 메일의 대용량 첨부 2개는 인용 속 링크로 함께 갑니다 — 11월 29일까지' },
|
||||
{ key: URL, tone: 'info', text: '원래 메일의 대용량 첨부 2개는 인용 속 링크로 함께 갑니다. 11월 29일까지 받을 수 있습니다.' },
|
||||
])
|
||||
})
|
||||
|
||||
it('파일 수를 모르면 수 없이', () => {
|
||||
expect(quotedLinkNotices([{ url: OTHER, status: 'active', expiresAt: '2026-11-29T14:59:59.999Z' }], names)[0]?.text).toBe(
|
||||
'인용 속 대용량 첨부 링크는 함께 갑니다 — 11월 29일까지',
|
||||
'인용 속 대용량 첨부 링크도 함께 갑니다. 11월 29일까지 받을 수 있습니다.',
|
||||
)
|
||||
})
|
||||
|
||||
|
||||
@@ -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