generated from ttipo/project-base
fix(backend): 서버가 만드는 글을 사용자에게 말하는 말투로 고친다
화면 문구(ddfb2d1)와 같은 기준으로 서버가 짓는 글을 고친다.
- 오류 문구 41개 가운데 7개: AUTH_006(「자동」) · VAL_001(괄호 꼬리표 — 길이 한도에도 나와 "형식이나 길이"로) ·
FILE_003 · FILE_004 · MAIL_001(메일 서버 → 메일 서비스) · MAIL_013 · CONTACT_002. 코드 · 상태는 그대로이고
sync:error-codes 로 frontend · flutter 사전을 다시 만들었다.
- 받은편지함 알림 메일 세 통: 줄표 · "이 계정의" · "판정"을 빼고, 메뉴 경로를 화면처럼 [설정] → [앱 비밀번호] 꼴로.
링크를 넣지 않는 규칙 · 이스케이프 · 정화는 그대로다.
- 본문에 넣는 대용량 첨부 덩이의 기한 줄 · 영어 줄. 표시 · 표 모양은 그대로이고, 배포 전에 붙잡아 둔 발송의
텍스트 덩이(옛 글)도 보내기 취소 때 떼도록 LEGACY_BLOCK_WORDINGS 를 둔다(7일 뒤 지워도 된다).
임시 복사본 Linux 컨테이너에서 lint · jest 3,209 · build, frontend lint · type-check · 단위 2,354,
flutter error_lexicon 테스트 · analyze 통과.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -56,7 +56,7 @@ const COMMON_ERROR_CODES = {
|
||||
AUTH_006: {
|
||||
status: HttpStatus.BAD_REQUEST,
|
||||
message:
|
||||
'코드가 올바르지 않습니다. 휴대폰의 날짜와 시간이 「자동」으로 맞춰져 있는지 확인해 주세요.',
|
||||
'코드가 올바르지 않습니다. 휴대폰의 날짜와 시간이 자동으로 맞춰지도록 설정되어 있는지 확인해 주세요.',
|
||||
},
|
||||
// 2단계 인증을 켠 직원의 요청에 코드가 없음 — 다른 곳에서 켠 뒤의 화면. 화면은 코드 칸을 보인다 (계약 5절)
|
||||
AUTH_007: {
|
||||
@@ -83,7 +83,8 @@ const COMMON_ERROR_CODES = {
|
||||
},
|
||||
VAL_001: {
|
||||
status: HttpStatus.BAD_REQUEST,
|
||||
message: '입력하신 정보를 다시 확인해 주세요. (필수값 누락 또는 형식 오류)',
|
||||
message:
|
||||
'입력하신 내용을 다시 확인해 주세요. 빠진 칸이 있거나 형식이나 길이가 맞지 않습니다.',
|
||||
},
|
||||
RES_001: {
|
||||
status: HttpStatus.NOT_FOUND,
|
||||
@@ -118,12 +119,14 @@ const DOMAIN_ERROR_CODES = {
|
||||
// 빠진 조각이 있는데 끝내기 · 끝내기가 진행 중 · 살아 있는 링크에 실린 파일 지우기. 아무것도 바꾸지 않았다 — 클라이언트는 GET 으로 상태를 보고 잇는다
|
||||
FILE_003: {
|
||||
status: HttpStatus.CONFLICT,
|
||||
message: '파일 올리기 상태가 맞지 않습니다. 다시 시도해 주세요.',
|
||||
message:
|
||||
'파일 상태가 그사이 바뀌어 처리하지 못했습니다. 다시 시도해 주세요.',
|
||||
},
|
||||
// 조각의 Content-Digest(sha-256)가 받은 바이트와 다르다 — 그 조각을 받지 않았다 (같은 조각을 다시 보낸다)
|
||||
FILE_004: {
|
||||
status: HttpStatus.BAD_REQUEST,
|
||||
message: '파일 조각이 보내는 중에 손상되었습니다. 다시 시도해 주세요.',
|
||||
message:
|
||||
'파일을 올리는 중에 일부가 제대로 전달되지 않았습니다. 다시 시도해 주세요.',
|
||||
},
|
||||
// 직원 보관 한도(보관량 · 보내지 않은 파일 수)를 넘는다 — 만들지 않았다. 문구에 숫자를 넣지 않는다(한도가 env 로 바뀐다)
|
||||
FILE_005: {
|
||||
@@ -151,7 +154,7 @@ const DOMAIN_ERROR_CODES = {
|
||||
// 메일 — 외부 메일 엔진(Stalwart) 장애를 SYS_001(우리 버그)과 구분하기 위해 분리한다
|
||||
MAIL_001: {
|
||||
status: HttpStatus.SERVICE_UNAVAILABLE,
|
||||
message: '메일 서버와 연결할 수 없습니다. 잠시 후 다시 시도해 주세요.',
|
||||
message: '메일 서비스에 연결할 수 없습니다. 잠시 후 다시 시도해 주세요.',
|
||||
},
|
||||
MAIL_002: {
|
||||
status: HttpStatus.BAD_REQUEST,
|
||||
@@ -224,7 +227,7 @@ const DOMAIN_ERROR_CODES = {
|
||||
MAIL_013: {
|
||||
status: HttpStatus.BAD_REQUEST,
|
||||
message:
|
||||
'필터나 차단 목록이 너무 길어 저장할 수 없습니다. 조건을 줄이거나 쓰지 않는 필터 · 차단 주소를 지워 주세요.',
|
||||
'필터나 차단 목록이 너무 길어 저장할 수 없습니다. 조건을 줄이거나, 쓰지 않는 필터나 차단한 주소를 지워 주세요.',
|
||||
},
|
||||
// 붙잡아 둔 발송(보내기 취소 · 예약 — 9단계)을 취소하려 했는데 이미 보냈다(엔진이 풀었음) · 기다리는 발송이 아니다 — 아무것도 바꾸지 않았다.
|
||||
// 화면은 인터셉터 알림으로 보인다 (계약 2절 "보내기 취소 · 예약 발송")
|
||||
@@ -258,7 +261,7 @@ const DOMAIN_ERROR_CODES = {
|
||||
CONTACT_002: {
|
||||
status: HttpStatus.CONFLICT,
|
||||
message:
|
||||
'주소록 한도를 넘어 더 넣을 수 없습니다. (연락처 3,000개 · 그룹 100개 · 그룹 하나에 500명까지)',
|
||||
'주소록 한도를 넘어 더 넣을 수 없습니다. 연락처 3,000개, 그룹 100개, 그룹 하나에 500명까지 넣을 수 있습니다.',
|
||||
},
|
||||
} as const satisfies Record<string, ErrorCodeDefinition>;
|
||||
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
insertHtmlBlock,
|
||||
insertTextBlock,
|
||||
isValidQuoteStart,
|
||||
largeFileTextBlocks,
|
||||
removeHtmlBlock,
|
||||
removeTextBlock,
|
||||
} from './large-file-block';
|
||||
@@ -40,14 +41,14 @@ describe('buildLargeFileBlock — 덩이의 내용 (디자인 README "11단계
|
||||
expect(block.html.endsWith(LARGE_FILES_BLOCK_END)).toBe(true);
|
||||
for (const text of [
|
||||
'대용량 첨부 2개 (합계 1.8 GB)',
|
||||
'2026년 11월 29일(일)까지 내려받을 수 있습니다 (한국 시간)',
|
||||
'한국 시간으로 2026년 11월 29일(일)까지 내려받을 수 있습니다.',
|
||||
'설계도_v3.dwg',
|
||||
'1.2 GB',
|
||||
'현장영상_1029.mp4',
|
||||
'612 MB',
|
||||
`<a href="${URL_}">${URL_}</a>`,
|
||||
'내려받을 때 로그인이나 비밀번호를 묻지 않습니다.',
|
||||
'Large files: download by Nov 29, 2026 (KST) — no login or password needed.',
|
||||
'Large files: download by Nov 29, 2026 (KST). No login or password needed.',
|
||||
]) {
|
||||
expect(block.html).toContain(text);
|
||||
}
|
||||
@@ -57,6 +58,32 @@ describe('buildLargeFileBlock — 덩이의 내용 (디자인 README "11단계
|
||||
|
||||
it('텍스트 — 같은 내용을 대시 줄로 가른 줄로 (파일 "- 이름 (크기)", 주소 "내려받기: 주소")', () => {
|
||||
expect(block.text.split('\n')).toEqual([
|
||||
'-'.repeat(40),
|
||||
'대용량 첨부 2개 (합계 1.8 GB)',
|
||||
'한국 시간으로 2026년 11월 29일(일)까지 내려받을 수 있습니다.',
|
||||
'- 설계도_v3.dwg (1.2 GB)',
|
||||
'- 현장영상_1029.mp4 (612 MB)',
|
||||
`내려받기: ${URL_}`,
|
||||
'내려받을 때 로그인이나 비밀번호를 묻지 않습니다.',
|
||||
'Large files: download by Nov 29, 2026 (KST). No login or password needed.',
|
||||
'-'.repeat(40),
|
||||
]);
|
||||
});
|
||||
|
||||
it('사용자에게 말하는 말투 — 줄표(—)로 잇지 않고 「」 를 쓰지 않는다 (디자인 README "화면 문구")', () => {
|
||||
expect(block.text).not.toMatch(/[—「」]/);
|
||||
expect(block.html).not.toMatch(/[—「」]/);
|
||||
});
|
||||
|
||||
it('떼기용 텍스트 덩이 — 지금 글이 먼저, 그다음 2026-11-05 앞의 글 그대로 (그 전에 붙잡아 둔 발송을 보내기 취소로 되돌릴 때)', () => {
|
||||
const [current, legacy, ...rest] = largeFileTextBlocks({
|
||||
files: FILES,
|
||||
expiresAt: EXPIRES,
|
||||
url: URL_,
|
||||
});
|
||||
expect(current).toBe(block.text);
|
||||
expect(rest).toEqual([]);
|
||||
expect(legacy.split('\n')).toEqual([
|
||||
'-'.repeat(40),
|
||||
'대용량 첨부 2개 (합계 1.8 GB)',
|
||||
'2026년 11월 29일(일)까지 내려받을 수 있습니다 (한국 시간)',
|
||||
|
||||
@@ -11,7 +11,10 @@ import { formatKstDate, formatKstDateEnglish } from '@/shared/utils/kst-date';
|
||||
* 이 표시가 들어갈 수 없다(서버만 넣는다). 엔진은 저장한 메일의 주석을 그대로 둔다(2026-10-30 개발 엔진으로 확인 — 되돌린 초안의 rawHtmlBody 에 남는다)
|
||||
* - **텍스트**: 같은 내용을 줄로 — 대시 줄로 가르고 앞뒤에 빈 줄
|
||||
*
|
||||
* ⚠️ 덩이를 고치면 그 전에 보낸 메일의 텍스트 덩이를 보내기 취소로 되돌릴 때 떼지 못한다(글 그대로일 때만 뗀다) — 표시의 판(v1)을 올리고 옛 판도 알아보게 한다
|
||||
* ⚠️ 덩이를 고치면 그 전에 보낸 메일의 텍스트 덩이를 보내기 취소로 되돌릴 때 떼지 못한다(글 그대로일 때만 뗀다).
|
||||
* - 글만 고칠 때: 옛 글을 `LEGACY_BLOCK_WORDINGS` 에 남겨 떼기가 함께 찾게 한다(largeFileTextBlocks — 2026-11-05 말투를 고칠 때 이렇게 했다).
|
||||
* HTML 덩이는 표시 사이를 떼므로 글과 상관없다
|
||||
* - 줄의 차례 · 표 모양을 바꿀 때: 표시의 판(v1)을 올리고 옛 판도 알아보게 한다
|
||||
*/
|
||||
|
||||
/** 덩이의 앞뒤 표시 (HTML 주석) */
|
||||
@@ -32,6 +35,34 @@ const TEXT_RULE = '-'.repeat(40);
|
||||
const BLOCK_FONT =
|
||||
"'Malgun Gothic', '맑은 고딕', 'Apple SD Gothic Neo', sans-serif";
|
||||
|
||||
/** 덩이의 로그인 안내 줄 (내려받기 화면이 로그인 · 비밀번호를 묻지 않는다는 것 — 묻는 화면이면 남이 꾸민 것이다) */
|
||||
const NO_LOGIN_LINE = '내려받을 때 로그인이나 비밀번호를 묻지 않습니다.';
|
||||
|
||||
/** 덩이의 글 가운데 고쳐 온 줄 — 끝나는 날 · 영어 한 줄 (날짜는 한국 날짜로 받는다) */
|
||||
interface BlockWording {
|
||||
readonly until: (kstDate: string) => string;
|
||||
readonly english: (kstDateEnglish: string) => string;
|
||||
}
|
||||
|
||||
/** 지금 넣는 글 (디자인 README "11단계 › 본문 덩이" — 2026-11-05 사용자에게 말하는 말투로) */
|
||||
const BLOCK_WORDING: BlockWording = {
|
||||
until: (date) => `한국 시간으로 ${date}까지 내려받을 수 있습니다.`,
|
||||
english: (date) =>
|
||||
`Large files: download by ${date} (KST). No login or password needed.`,
|
||||
};
|
||||
|
||||
/**
|
||||
* 예전에 넣던 글 — 보내기 취소로 되돌린 발송의 텍스트 덩이를 뗄 때만 함께 찾는다 (largeFileTextBlocks).
|
||||
* 2026-11-05 앞의 글이다. 그 전에 붙잡아 둔 발송(예약은 엔진 한도 7일)이 모두 나간 뒤에는 지워도 된다
|
||||
*/
|
||||
const LEGACY_BLOCK_WORDINGS: readonly BlockWording[] = [
|
||||
{
|
||||
until: (date) => `${date}까지 내려받을 수 있습니다 (한국 시간)`,
|
||||
english: (date) =>
|
||||
`Large files: download by ${date} (KST) — no login or password needed.`,
|
||||
},
|
||||
];
|
||||
|
||||
/** 덩이 하나의 재료 */
|
||||
export interface LargeFileBlockInput {
|
||||
/** 보낸 차례 */
|
||||
@@ -56,15 +87,48 @@ function escapeHtml(text: string): string {
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
|
||||
/** 덩이의 글 줄 — HTML 덩이와 텍스트 덩이가 같은 글을 쓴다 */
|
||||
interface BlockLines {
|
||||
readonly title: string;
|
||||
readonly until: string;
|
||||
readonly noLogin: string;
|
||||
readonly english: string;
|
||||
}
|
||||
|
||||
function blockLines(
|
||||
input: LargeFileBlockInput,
|
||||
wording: BlockWording,
|
||||
): BlockLines {
|
||||
const total = input.files.reduce((sum, file) => sum + file.size, 0);
|
||||
return {
|
||||
title: `대용량 첨부 ${input.files.length}개 (합계 ${formatFileSize(total)})`,
|
||||
until: wording.until(formatKstDate(input.expiresAt)),
|
||||
noLogin: NO_LOGIN_LINE,
|
||||
english: wording.english(formatKstDateEnglish(input.expiresAt)),
|
||||
};
|
||||
}
|
||||
|
||||
/** 텍스트 덩이 — 대시 줄로 가른 같은 내용 (파일 "- 이름 (크기)", 주소 "내려받기: 주소") */
|
||||
function textBlock(input: LargeFileBlockInput, lines: BlockLines): string {
|
||||
return [
|
||||
TEXT_RULE,
|
||||
lines.title,
|
||||
lines.until,
|
||||
...input.files.map(
|
||||
(file) => `- ${file.name} (${formatFileSize(file.size)})`,
|
||||
),
|
||||
`내려받기: ${input.url}`,
|
||||
lines.noLogin,
|
||||
lines.english,
|
||||
TEXT_RULE,
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
/** 덩이를 만든다 — HTML(앞뒤 표시 포함)과 텍스트에 같은 내용 */
|
||||
export function buildLargeFileBlock(
|
||||
input: LargeFileBlockInput,
|
||||
): LargeFileBlock {
|
||||
const total = input.files.reduce((sum, file) => sum + file.size, 0);
|
||||
const title = `대용량 첨부 ${input.files.length}개 (합계 ${formatFileSize(total)})`;
|
||||
const until = `${formatKstDate(input.expiresAt)}까지 내려받을 수 있습니다 (한국 시간)`;
|
||||
const noLogin = '내려받을 때 로그인이나 비밀번호를 묻지 않습니다.';
|
||||
const english = `Large files: download by ${formatKstDateEnglish(input.expiresAt)} (KST) — no login or password needed.`;
|
||||
const lines = blockLines(input, BLOCK_WORDING);
|
||||
const url = escapeHtml(input.url);
|
||||
|
||||
const cell = (style: string, content: string) =>
|
||||
@@ -81,11 +145,11 @@ export function buildLargeFileBlock(
|
||||
`<table style="border-collapse:collapse;border:1px solid #d0d5dd;margin:16px 0;font-family:${BLOCK_FONT}"><tbody>` +
|
||||
cell(
|
||||
'padding:12px 16px 2px 16px;font-size:14px;font-weight:bold;color:#101828',
|
||||
escapeHtml(title),
|
||||
escapeHtml(lines.title),
|
||||
) +
|
||||
cell(
|
||||
'padding:0 16px 8px 16px;font-size:13px;color:#344054',
|
||||
escapeHtml(until),
|
||||
escapeHtml(lines.until),
|
||||
) +
|
||||
fileRows +
|
||||
cell(
|
||||
@@ -94,28 +158,26 @@ export function buildLargeFileBlock(
|
||||
) +
|
||||
cell(
|
||||
'padding:6px 16px 0 16px;font-size:12px;color:#667085',
|
||||
escapeHtml(noLogin),
|
||||
escapeHtml(lines.noLogin),
|
||||
) +
|
||||
cell(
|
||||
'padding:2px 16px 12px 16px;font-size:12px;color:#667085',
|
||||
escapeHtml(english),
|
||||
escapeHtml(lines.english),
|
||||
) +
|
||||
'</tbody></table>' +
|
||||
LARGE_FILES_BLOCK_END;
|
||||
|
||||
const text = [
|
||||
TEXT_RULE,
|
||||
title,
|
||||
until,
|
||||
...input.files.map(
|
||||
(file) => `- ${file.name} (${formatFileSize(file.size)})`,
|
||||
),
|
||||
`내려받기: ${input.url}`,
|
||||
noLogin,
|
||||
english,
|
||||
TEXT_RULE,
|
||||
].join('\n');
|
||||
return { html, text };
|
||||
return { html, text: textBlock(input, lines) };
|
||||
}
|
||||
|
||||
/**
|
||||
* 보내기 취소로 되돌린 발송에서 뗄 텍스트 덩이 — 지금 글 다음에 예전 글(LEGACY_BLOCK_WORDINGS) 차례.
|
||||
* 텍스트 덩이는 글 그대로일 때만 떼므로(removeTextBlock), 글을 고치기 전에 붙잡아 둔 발송의 덩이도 찾게 한다
|
||||
*/
|
||||
export function largeFileTextBlocks(input: LargeFileBlockInput): string[] {
|
||||
return [BLOCK_WORDING, ...LEGACY_BLOCK_WORDINGS].map((wording) =>
|
||||
textBlock(input, blockLines(input, wording)),
|
||||
);
|
||||
}
|
||||
|
||||
/** 텍스트 덩이를 본문에 넣을 때의 모양 — 앞뒤에 빈 줄 (떼기도 이 모양 그대로 찾는다) */
|
||||
|
||||
@@ -21,6 +21,7 @@ import {
|
||||
buildLargeFileBlock,
|
||||
insertHtmlBlock,
|
||||
insertTextBlock,
|
||||
largeFileTextBlocks,
|
||||
} from './large-file-block';
|
||||
import { MAX_BODY_LENGTH } from './mail.constants';
|
||||
import type { MailEventsService } from './mail-events.service';
|
||||
@@ -538,6 +539,32 @@ describe('MailLargeFilesService — 메일과 대용량 첨부 (11단계)', () =
|
||||
expect(kept.textBody).toBe(edited);
|
||||
});
|
||||
|
||||
it('글을 고치기(2026-11-05) 전에 붙잡아 둔 발송 — 예전 글의 텍스트 덩이도 뗀다 (HTML 은 표시로 뗀다)', async () => {
|
||||
const { service, sharing } = setup();
|
||||
sharing.findBySentEmail.mockResolvedValue(
|
||||
record({ canceledAt: SENT_AT }),
|
||||
);
|
||||
const [, legacyText] = largeFileTextBlocks({
|
||||
files: VIEWS,
|
||||
expiresAt: EXPIRES,
|
||||
url: shareUrl(ORIGIN, TOKEN),
|
||||
});
|
||||
expect(legacyText).not.toBe(sentBlock.text);
|
||||
const originalText = '내 글\n\n----- 원본 메일 -----\n> 인용';
|
||||
const result = await service.draftView(
|
||||
ARA,
|
||||
draft({
|
||||
isCanceledSend: true,
|
||||
textBody: insertTextBlock(
|
||||
originalText,
|
||||
'내 글\n\n'.length,
|
||||
legacyText,
|
||||
),
|
||||
}),
|
||||
);
|
||||
expect(result.textBody).toBe(originalText);
|
||||
});
|
||||
|
||||
it('다른 링크의 덩이(토큰이 다름)는 떼지 않는다', async () => {
|
||||
const { service, sharing } = setup();
|
||||
sharing.findBySentEmail.mockResolvedValue(
|
||||
|
||||
@@ -41,6 +41,7 @@ import {
|
||||
hrefsIn,
|
||||
insertHtmlBlock,
|
||||
insertTextBlock,
|
||||
largeFileTextBlocks,
|
||||
removeHtmlBlock,
|
||||
removeTextBlock,
|
||||
type LargeFileBlock,
|
||||
@@ -565,7 +566,7 @@ export class MailLargeFilesService implements OnModuleInit, OnModuleDestroy {
|
||||
|
||||
/**
|
||||
* 취소해 되돌린 발송의 덩이를 뗀다 — HTML 은 표시 사이(그 링크의 토큰이 있을 때만), 텍스트는 서버가 넣은 글 그대로일 때만.
|
||||
* 토큰은 HTML 덩이의 링크에서(없으면 텍스트의 주소에서) 찾아 링크의 해시와 맞춘다 — 덩이를 다시 만들어 글 그대로 찾는다
|
||||
* 토큰은 HTML 덩이의 링크에서(없으면 텍스트의 주소에서) 찾아 링크의 해시와 맞춘다 — 덩이를 다시 만들어(지금 글 · 예전 글) 글 그대로 찾는다
|
||||
*/
|
||||
private stripBlock(
|
||||
record: ShareLinkRecord,
|
||||
@@ -592,12 +593,19 @@ export class MailLargeFilesService implements OnModuleInit, OnModuleDestroy {
|
||||
|
||||
let text = textBody;
|
||||
if (token !== null && text !== null) {
|
||||
const { text: blockText } = buildLargeFileBlock({
|
||||
// 지금 글 다음에 예전 글 — 글을 고치기 전에 붙잡아 둔 발송의 텍스트 덩이도 뗀다 (large-file-block.ts 의 LEGACY_BLOCK_WORDINGS)
|
||||
const blockTexts = largeFileTextBlocks({
|
||||
files: record.files,
|
||||
expiresAt: record.link.expiresAt,
|
||||
url: shareUrl(origin, token),
|
||||
});
|
||||
text = removeTextBlock(text, blockText) ?? text;
|
||||
for (const blockText of blockTexts) {
|
||||
const removed = removeTextBlock(text, blockText);
|
||||
if (removed !== null) {
|
||||
text = removed;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
return { rawHtmlBody: html, textBody: text };
|
||||
}
|
||||
|
||||
@@ -51,15 +51,15 @@ describe('받은편지함 알림 메일의 글 (13단계 — 디자인 README 13
|
||||
expect(notice.subject).toBe('관리자가 비밀번호를 재설정했습니다');
|
||||
expect(notice.text).toBe(
|
||||
[
|
||||
'김아라 님, 관리자가 이 계정의 비밀번호를 임시 비밀번호로 바꿨습니다.',
|
||||
'김아라 님, 관리자가 비밀번호를 임시 비밀번호로 바꿨습니다.',
|
||||
'',
|
||||
'시각: 2026년 10월 30일 (금) 오후 3:20',
|
||||
'바뀐 것: 비밀번호 — 임시 비밀번호로 바뀌었고, 처음 로그인할 때 새 비밀번호를 정합니다',
|
||||
'로그아웃: 모든 기기 · 브라우저의 웹메일 로그인을 끊었습니다',
|
||||
'앱 비밀번호: 모두 지웠습니다 — 메일 프로그램은 새 앱 비밀번호로 다시 연결합니다',
|
||||
'바뀐 것: 비밀번호가 임시 비밀번호로 바뀌었고, 처음 로그인할 때 새 비밀번호를 정하게 됩니다',
|
||||
'로그아웃: 모든 기기와 브라우저의 웹메일에서 로그아웃되었습니다',
|
||||
'앱 비밀번호: 모두 지워져서 메일 프로그램은 새 앱 비밀번호로 다시 연결해야 합니다',
|
||||
'',
|
||||
'할 일',
|
||||
'아웃룩 · 휴대폰 메일 앱을 쓰면 메일 프로그램마다 앱 비밀번호가 필요합니다. 웹메일 › 설정 › 계정 › 앱 비밀번호에서 새로 만들어 주세요.',
|
||||
'아웃룩이나 휴대폰 메일 앱을 쓰면 메일 프로그램마다 앱 비밀번호가 필요합니다. 웹메일의 [설정] → [앱 비밀번호]에서 새로 만들어 주세요.',
|
||||
'',
|
||||
'직접 요청한 일이 아니면 바로 IT 담당에게 알려 주세요.',
|
||||
CONTACT,
|
||||
@@ -69,7 +69,7 @@ describe('받은편지함 알림 메일의 글 (13단계 — 디자인 README 13
|
||||
].join('\n'),
|
||||
);
|
||||
expect(notice.html).toContain(
|
||||
'<strong>웹메일 › 설정 › 계정 › 앱 비밀번호</strong>에서 새로 만들어 주세요.',
|
||||
'웹메일의 <strong>[설정] → [앱 비밀번호]</strong>에서 새로 만들어 주세요.',
|
||||
);
|
||||
});
|
||||
|
||||
@@ -77,13 +77,13 @@ describe('받은편지함 알림 메일의 글 (13단계 — 디자인 README 13
|
||||
const notice = twoFactorResetNotice(ARA, AT);
|
||||
expect(notice.subject).toBe('관리자가 2단계 인증을 껐습니다');
|
||||
expect(notice.text).toContain(
|
||||
'김아라 님, 휴대폰을 잃어버렸거나 인증 앱을 쓸 수 없다고 알려 주셔서 관리자가 이 계정의 2단계 인증을 껐습니다.',
|
||||
'김아라 님, 휴대폰을 잃어버렸거나 인증 앱을 쓸 수 없다고 알려 주셔서 관리자가 2단계 인증을 껐습니다.',
|
||||
);
|
||||
expect(notice.text).toContain(
|
||||
'그대로인 것: 비밀번호 · 앱 비밀번호 · 로그인된 브라우저',
|
||||
'그대로인 것: 비밀번호, 앱 비밀번호, 로그인된 브라우저',
|
||||
);
|
||||
expect(notice.html).toContain(
|
||||
'<strong>웹메일 › 설정 › 계정 › 2단계 인증</strong>에서 다시 켜 주세요.',
|
||||
'웹메일의 <strong>[설정] → [2단계 인증]</strong>에서 다시 켜 주세요.',
|
||||
);
|
||||
});
|
||||
|
||||
@@ -96,10 +96,10 @@ describe('받은편지함 알림 메일의 글 (13단계 — 디자인 README 13
|
||||
expect(notice.text).toContain('크기: 48 MB');
|
||||
expect(notice.text).toContain('올린 때: 2026년 10월 30일 (금) 오후 3:58');
|
||||
expect(notice.text).toContain(
|
||||
'판정이 잘못되었다고 생각되면 IT 담당에게 알려 주세요.',
|
||||
'검사 결과가 잘못되었다고 생각되면 IT 담당에게 알려 주세요.',
|
||||
);
|
||||
expect(notice.html).toContain(
|
||||
'<strong>웹메일 › 설정 › 메일 › 대용량 첨부</strong>에서 볼 수 있습니다.',
|
||||
'웹메일의 <strong>[설정] → [대용량 첨부]</strong>에서 볼 수 있습니다.',
|
||||
);
|
||||
});
|
||||
|
||||
@@ -114,6 +114,15 @@ describe('받은편지함 알림 메일의 글 (13단계 — 디자인 README 13
|
||||
},
|
||||
);
|
||||
|
||||
it.each(ALL)(
|
||||
'%s — 사용자에게 말하는 말투: 줄표(—)로 잇지 않고 「」 를 쓰지 않는다 (디자인 README "화면 문구")',
|
||||
(_label, notice) => {
|
||||
for (const text of [notice.subject, notice.text]) {
|
||||
expect(text).not.toMatch(/[—「」]/);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
it.each(ALL)(
|
||||
'%s — 보내는 메일 정화(허용 목록)를 거쳐도 지워지는 태그 · 스타일 · 글이 없다',
|
||||
(_label, notice) => {
|
||||
|
||||
@@ -57,7 +57,7 @@ interface NoticeBody {
|
||||
|
||||
/** 맨 아래 한 줄 (세 통 같음) */
|
||||
export const NOTICE_FOOTER =
|
||||
'이 메일은 ARA SYSTEM 이 자동으로 보낸 알림입니다. 회사 보안 알림에는 링크가 없습니다 — 비슷한 메일의 링크는 누르지 마세요.';
|
||||
'이 메일은 ARA SYSTEM이 자동으로 보낸 알림입니다. 회사 보안 알림에는 링크가 없으니, 비슷한 메일에 링크가 있다면 누르지 않도록 주의해 주세요.';
|
||||
|
||||
/** 확인 문장 — 비밀번호 재설정 · 2단계 해제 */
|
||||
const CONFIRM_SELF = '직접 요청한 일이 아니면 바로 IT 담당에게 알려 주세요.';
|
||||
@@ -151,24 +151,24 @@ export function passwordResetNotice(
|
||||
{
|
||||
kind: 'password-reset',
|
||||
subject: '관리자가 비밀번호를 재설정했습니다',
|
||||
greeting: `${recipient.name} 님, 관리자가 이 계정의 비밀번호를 임시 비밀번호로 바꿨습니다.`,
|
||||
greeting: `${recipient.name} 님, 관리자가 비밀번호를 임시 비밀번호로 바꿨습니다.`,
|
||||
rows: [
|
||||
['시각', formatKstDateTime(at)],
|
||||
[
|
||||
'바뀐 것',
|
||||
'비밀번호 — 임시 비밀번호로 바뀌었고, 처음 로그인할 때 새 비밀번호를 정합니다',
|
||||
'비밀번호가 임시 비밀번호로 바뀌었고, 처음 로그인할 때 새 비밀번호를 정하게 됩니다',
|
||||
],
|
||||
['로그아웃', '모든 기기 · 브라우저의 웹메일 로그인을 끊었습니다'],
|
||||
['로그아웃', '모든 기기와 브라우저의 웹메일에서 로그아웃되었습니다'],
|
||||
[
|
||||
'앱 비밀번호',
|
||||
'모두 지웠습니다 — 메일 프로그램은 새 앱 비밀번호로 다시 연결합니다',
|
||||
'모두 지워져서 메일 프로그램은 새 앱 비밀번호로 다시 연결해야 합니다',
|
||||
],
|
||||
],
|
||||
todo: [
|
||||
{
|
||||
text: '아웃룩 · 휴대폰 메일 앱을 쓰면 메일 프로그램마다 앱 비밀번호가 필요합니다. ',
|
||||
text: '아웃룩이나 휴대폰 메일 앱을 쓰면 메일 프로그램마다 앱 비밀번호가 필요합니다. 웹메일의 ',
|
||||
},
|
||||
{ strong: '웹메일 › 설정 › 계정 › 앱 비밀번호' },
|
||||
{ strong: '[설정] → [앱 비밀번호]' },
|
||||
{ text: '에서 새로 만들어 주세요.' },
|
||||
],
|
||||
confirm: CONFIRM_SELF,
|
||||
@@ -186,17 +186,17 @@ export function twoFactorResetNotice(
|
||||
{
|
||||
kind: 'two-factor-reset',
|
||||
subject: '관리자가 2단계 인증을 껐습니다',
|
||||
greeting: `${recipient.name} 님, 휴대폰을 잃어버렸거나 인증 앱을 쓸 수 없다고 알려 주셔서 관리자가 이 계정의 2단계 인증을 껐습니다.`,
|
||||
greeting: `${recipient.name} 님, 휴대폰을 잃어버렸거나 인증 앱을 쓸 수 없다고 알려 주셔서 관리자가 2단계 인증을 껐습니다.`,
|
||||
rows: [
|
||||
['시각', formatKstDateTime(at)],
|
||||
['바뀐 것', '2단계 인증 꺼짐 — 지금은 비밀번호만으로 로그인합니다'],
|
||||
['그대로인 것', '비밀번호 · 앱 비밀번호 · 로그인된 브라우저'],
|
||||
['바뀐 것', '2단계 인증이 꺼져서 지금은 비밀번호만으로 로그인합니다'],
|
||||
['그대로인 것', '비밀번호, 앱 비밀번호, 로그인된 브라우저'],
|
||||
],
|
||||
todo: [
|
||||
{ text: '새 휴대폰에 인증 앱을 설치한 뒤 ' },
|
||||
{ strong: '웹메일 › 설정 › 계정 › 2단계 인증' },
|
||||
{ text: '새 휴대폰에 인증 앱을 설치한 뒤 웹메일의 ' },
|
||||
{ strong: '[설정] → [2단계 인증]' },
|
||||
{
|
||||
text: '에서 다시 켜 주세요. 회사가 2단계 인증을 필수로 정했다면 다음 로그인 때 켜는 화면이 먼저 나옵니다.',
|
||||
text: '에서 다시 켜 주세요. 회사가 2단계 인증을 필수로 정했다면 다음에 로그인할 때 켜는 화면이 먼저 나옵니다.',
|
||||
},
|
||||
],
|
||||
confirm: CONFIRM_SELF,
|
||||
@@ -221,17 +221,17 @@ export function dangerousFileNotice(
|
||||
['올린 때', formatKstDateTime(file.uploadedAt)],
|
||||
[
|
||||
'조치',
|
||||
'이 파일만 막았습니다 — 함께 올린 다른 파일은 그대로 보내고 받을 수 있습니다',
|
||||
'이 파일만 막았고, 함께 올린 다른 파일은 그대로 보내고 받을 수 있습니다',
|
||||
],
|
||||
],
|
||||
todo: [
|
||||
{
|
||||
text: '직접 만든 파일이 아니면 PC 를 백신으로 검사해 주세요. 다른 곳에서 받은 파일이면 보낸 사람에게도 알려 주세요. 막은 파일은 ',
|
||||
text: '직접 만든 파일이 아니면 PC를 백신으로 검사해 주세요. 다른 곳에서 받은 파일이면 보낸 사람에게도 알려 주세요. 막은 파일은 웹메일의 ',
|
||||
},
|
||||
{ strong: '웹메일 › 설정 › 메일 › 대용량 첨부' },
|
||||
{ strong: '[설정] → [대용량 첨부]' },
|
||||
{ text: '에서 볼 수 있습니다.' },
|
||||
],
|
||||
confirm: '판정이 잘못되었다고 생각되면 IT 담당에게 알려 주세요.',
|
||||
confirm: '검사 결과가 잘못되었다고 생각되면 IT 담당에게 알려 주세요.',
|
||||
},
|
||||
recipient,
|
||||
);
|
||||
|
||||
@@ -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명까지 넣을 수 있습니다.',
|
||||
};
|
||||
|
||||
/// 매핑 실패 시 사용하는 폴백 코드
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user