diff --git a/backend/src/common/errors/error-codes.ts b/backend/src/common/errors/error-codes.ts index 8fc346b..814f837 100644 --- a/backend/src/common/errors/error-codes.ts +++ b/backend/src/common/errors/error-codes.ts @@ -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; diff --git a/backend/src/modules/mail/large-file-block.spec.ts b/backend/src/modules/mail/large-file-block.spec.ts index caa06a1..12f901e 100644 --- a/backend/src/modules/mail/large-file-block.spec.ts +++ b/backend/src/modules/mail/large-file-block.spec.ts @@ -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', `${URL_}`, '내려받을 때 로그인이나 비밀번호를 묻지 않습니다.', - '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일(일)까지 내려받을 수 있습니다 (한국 시간)', diff --git a/backend/src/modules/mail/large-file-block.ts b/backend/src/modules/mail/large-file-block.ts index 6a98c60..1447dc7 100644 --- a/backend/src/modules/mail/large-file-block.ts +++ b/backend/src/modules/mail/large-file-block.ts @@ -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( `` + 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), ) + '
' + 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)), + ); } /** 텍스트 덩이를 본문에 넣을 때의 모양 — 앞뒤에 빈 줄 (떼기도 이 모양 그대로 찾는다) */ diff --git a/backend/src/modules/mail/mail-large-files.service.spec.ts b/backend/src/modules/mail/mail-large-files.service.spec.ts index 00249c4..4ecabf8 100644 --- a/backend/src/modules/mail/mail-large-files.service.spec.ts +++ b/backend/src/modules/mail/mail-large-files.service.spec.ts @@ -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( diff --git a/backend/src/modules/mail/mail-large-files.service.ts b/backend/src/modules/mail/mail-large-files.service.ts index 6f4d9a6..d046515 100644 --- a/backend/src/modules/mail/mail-large-files.service.ts +++ b/backend/src/modules/mail/mail-large-files.service.ts @@ -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 }; } diff --git a/backend/src/modules/system-mail/system-notices.spec.ts b/backend/src/modules/system-mail/system-notices.spec.ts index 6b068fa..618d809 100644 --- a/backend/src/modules/system-mail/system-notices.spec.ts +++ b/backend/src/modules/system-mail/system-notices.spec.ts @@ -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( - '웹메일 › 설정 › 계정 › 앱 비밀번호에서 새로 만들어 주세요.', + '웹메일의 [설정] → [앱 비밀번호]에서 새로 만들어 주세요.', ); }); @@ -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( - '웹메일 › 설정 › 계정 › 2단계 인증에서 다시 켜 주세요.', + '웹메일의 [설정] → [2단계 인증]에서 다시 켜 주세요.', ); }); @@ -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( - '웹메일 › 설정 › 메일 › 대용량 첨부에서 볼 수 있습니다.', + '웹메일의 [설정] → [대용량 첨부]에서 볼 수 있습니다.', ); }); @@ -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) => { diff --git a/backend/src/modules/system-mail/system-notices.ts b/backend/src/modules/system-mail/system-notices.ts index 9037ca0..958ad91 100644 --- a/backend/src/modules/system-mail/system-notices.ts +++ b/backend/src/modules/system-mail/system-notices.ts @@ -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, ); diff --git a/flutter/lib/core/api/error_lexicon.g.dart b/flutter/lib/core/api/error_lexicon.g.dart index d8894c4..d1bf2e2 100644 --- a/flutter/lib/core/api/error_lexicon.g.dart +++ b/flutter/lib/core/api/error_lexicon.g.dart @@ -8,23 +8,23 @@ const Map 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 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명까지 넣을 수 있습니다.', }; /// 매핑 실패 시 사용하는 폴백 코드 diff --git a/frontend/src/pages/settings/MailLargeFilesPage.spec.ts b/frontend/src/pages/settings/MailLargeFilesPage.spec.ts index 5060935..92393c3 100644 --- a/frontend/src/pages/settings/MailLargeFilesPage.spec.ts +++ b/frontend/src/pages/settings/MailLargeFilesPage.spec.ts @@ -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') }) diff --git a/frontend/src/shared/constants/error-codes.generated.ts b/frontend/src/shared/constants/error-codes.generated.ts index fd92520..779e9d1 100644 --- a/frontend/src/shared/constants/error-codes.generated.ts +++ b/frontend/src/shared/constants/error-codes.generated.ts @@ -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