docs: 14단계 화면(검색 · 대화 묶기)을 확정해 디자인 문서에 옮긴다 #17

Merged
ttipo merged 6 commits from docs/phase14-design into main 2026-11-05 18:24:54 +09:00
91 changed files with 2112 additions and 781 deletions
+2 -2
View File
@@ -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 | 그 외 비즈니스 규칙 위반 | "요청하신 작업을 완료하지 못했습니다. 다시 시도해 주세요." |
+1
View File
@@ -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` 만 붙인다.
+10 -7
View File
@@ -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일(일)까지 내려받을 수 있습니다 (한국 시간)',
+85 -23
View File
@@ -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, '&#39;');
}
/** 덩이의 글 줄 — 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,
);
+351
View File
@@ -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">받는 사람 김아라 &lt;ara.kim@arasystem.it.kr&gt;</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
View File
@@ -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` 사전 문구.
- **「링크로 보내기」**: 대용량 사전 검사(개수 · 보관량)를 먼저 보고 통과할 때만 일반 첨부에서 뺀다. 걸리면 첨부는 그대로 두고 그 글을 오류 알림으로(보관량이면 + [대용량 첨부 보기]).
+458
View File
@@ -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>
+304
View File
@@ -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">받는 사람 김아라 &lt;ara.kim@arasystem.it.kr&gt;</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>
+7 -3
View File
@@ -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
View File
@@ -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단계를 그대로 쓴다.
- **링크를 넣지 않는다** — 할 일은 메뉴 경로를 글로 적는다(「웹메일 › 설정 › 계정 › 앱 비밀번호」 등). 직원에게 "회사 보안 알림에는 링크가 없다"는 습관을 주어 링크 달린 피싱 메일이 눈에 띄게 한다.
- **링크를 넣지 않는다** — 할 일은 메뉴 경로를 글로 적는다("웹메일의 [설정] → [앱 비밀번호]" 등 — 화면 글과 같은 꼴). 직원에게 "회사 보안 알림에는 링크가 없다"는 습관을 주어 링크 달린 피싱 메일이 눈에 띄게 한다.
- 위험 파일 알림은 파일마다 한 번 — 위험 판정을 처음 기록할 때만 보낸다.
### 운영 도구 (서버 안 명령)
+7 -7
View File
@@ -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)
+1 -1
View File
@@ -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 () => {
+2 -2
View File
@@ -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))
+5 -5
View File
@@ -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')
})
+1 -1
View File
@@ -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({
+8 -8
View File
@@ -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 () => {
+1 -1
View File
@@ -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
+6 -6
View File
@@ -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
+20 -20
View File
@@ -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
+54 -52
View File
@@ -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: '이 파일은 지금 내려받을 수 없습니다. 아래 안내를 확인해 주세요.',
+7 -7
View File
@@ -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
+33 -35
View File
@@ -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}]를 고른 뒤 [다음]을 누릅니다.`,
],
],
},
+1 -1
View File
@@ -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 = '내용을 입력해 주세요'
// ── 글꼴 · 글자 크기 ─────────────────────────────────────
+32 -32
View File
@@ -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
+11 -11
View File
@@ -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
/** 바꾸고 저장하지 않은 채 떠날 때 (앱 안 이동은 대화상자, 탭 닫기 · 새로고침은 브라우저 확인창) — 서명 설정과 같은 모양 */
+8 -8
View File
@@ -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')
})
+4 -4
View File
@@ -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')
+1 -1
View File
@@ -104,7 +104,7 @@ export function formatSizeLimit(bytes: number): string {
}
/**
* 첨부 안내 — "파일당 최대 20MB · 합계 25MB · 최대 20개 · 더 큰 파일은 링크로 보냅니다 (파일당 2GB · 10개까지)".
* 첨부 안내 — "파일당 최대 20MB · 합계 25MB · 최대 20개 · 더 큰 파일은 파일당 2GB, 10개까지 링크로 보냅니다".
* 일반 첨부의 형식 설명(문서, 이미지, 압축 파일)은 링크 안내로 바꾼다 (디자인 원본 ComposeLargeFiles)
*/
export function composeAttachmentHint(
+3 -3
View File
@@ -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통을 정리했습니다.',
)
})
})
+1 -1
View File
@@ -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일까지 받을 수 있습니다.',
)
})
+2 -2
View File
@@ -228,8 +228,8 @@ describe('toast.store', () => {
const failed = toast.progress('비우는 중')
store.dismiss(store.toasts[1]?.id ?? -1)
failed.fail('21통 가운데 10통까지 처리했습니다 — 다시 시도해 주세요.')
expect(store.toasts.at(-1)).toMatchObject({ kind: 'error', message: '21통 가운데 10통까지 처리했습니다 — 다시 시도해 주세요.' })
failed.fail('21통 가운데 10통까지 처리하고 멈췄습니다. 다시 시도해 주세요.')
expect(store.toasts.at(-1)).toMatchObject({ kind: 'error', message: '21통 가운데 10통까지 처리하고 멈췄습니다. 다시 시도해 주세요.' })
})
})