diff --git a/backend/src/common/errors/error-codes.ts b/backend/src/common/errors/error-codes.ts index b037087..a7fbfd5 100644 --- a/backend/src/common/errors/error-codes.ts +++ b/backend/src/common/errors/error-codes.ts @@ -112,6 +112,13 @@ const DOMAIN_ERROR_CODES = { status: HttpStatus.BAD_REQUEST, message: '첨부는 한 메일에 최대 20개까지 보낼 수 있습니다.', }, + // 본문이 너무 길다 — 텍스트 100,000자·서식 본문 500,000자 초과, 서식 본문이 정화 전 비용 한도나 정화 뒤 2,000,000바이트를 넘음. + // 메일은 나가지 않는다. 클라이언트는 보내기 전에 같은 한도로 미리 막고 본문 아래에 이 문구를 보인다 (계약 2절) + MAIL_007: { + status: HttpStatus.BAD_REQUEST, + message: + '본문이 너무 길어 보낼 수 없습니다. 내용을 줄인 뒤 다시 보내 주세요.', + }, } as const satisfies Record; export const ERROR_CODES = { diff --git a/backend/src/modules/mail/dto/mail-response.dto.ts b/backend/src/modules/mail/dto/mail-response.dto.ts index 784e769..aab606d 100644 --- a/backend/src/modules/mail/dto/mail-response.dto.ts +++ b/backend/src/modules/mail/dto/mail-response.dto.ts @@ -232,7 +232,7 @@ export class MessageDetailDto @ApiProperty({ description: '서버가 정화한 HTML — 스크립트·스타일 태그·이벤트 핸들러·폼·iframe·object·embed·svg 제거, 외부 이미지 제거. 클라이언트는 격리된 iframe 에만 그린다. ' + - `HTML 이 없거나, 정화 한도(HTML 부분을 이어 붙여 ${MAX_BODY_VALUE_BYTES.toLocaleString('ko-KR')}자 초과 · 너무 깊은 중첩)를 넘으면 null — 그때는 textBody 를 보여 준다`, + `HTML 이 없거나, 정화 한도(HTML 부분을 이어 붙여 ${MAX_BODY_VALUE_BYTES.toLocaleString('ko-KR')}자 초과 · 너무 깊은 중첩)를 넘으면 null — 그때는 textBody 를 보여 준다 (한도를 넘은 경우는 isHtmlBodyOmitted 가 true)`, nullable: true, type: String, }) @@ -251,6 +251,22 @@ export class MessageDetailDto }) hasBlockedImages!: boolean; + @ApiProperty({ + description: + 'HTML 이 있었지만 정화 한도(너무 길거나 깊게 중첩됨)를 넘어 htmlBody 를 비웠으면 true → 서식 없이 textBody 를 보여 주고 안내한다 (textBody 도 없으면 표시할 수 없음). ' + + 'HTML 이 없는 메일은 false', + example: false, + }) + isHtmlBodyOmitted!: boolean; + + @ApiProperty({ + description: + `보여 줄 본문(htmlBody — null 이거나 공백뿐이면 textBody)이 읽기 한도(본문 한 부분 UTF-8 ${MAX_BODY_VALUE_BYTES.toLocaleString('ko-KR')}바이트)에서 잘렸으면 true → "앞부분만 표시" 안내, ` + + '답장·전달 인용 끝에 생략 표시', + example: false, + }) + isBodyTruncated!: boolean; + @ApiProperty({ description: '첨부 (본문 내장 이미지 포함)', type: [MailAttachmentDto], diff --git a/backend/src/modules/mail/dto/send-message.dto.spec.ts b/backend/src/modules/mail/dto/send-message.dto.spec.ts index 617ce4a..7f12617 100644 --- a/backend/src/modules/mail/dto/send-message.dto.spec.ts +++ b/backend/src/modules/mail/dto/send-message.dto.spec.ts @@ -5,7 +5,11 @@ import { MAX_ATTACHMENT_LIST_LENGTH, MAX_ATTACHMENTS_PER_MESSAGE, } from '@/modules/mail/attachment.policy'; -import { MAX_BODY_HTML_LENGTH } from '@/modules/mail/mail.constants'; +import { + MAX_BODY_HTML_LENGTH, + MAX_BODY_LENGTH, + MAX_SUBJECT_LENGTH, +} from '@/modules/mail/mail.constants'; import { SendMessageDto } from './send-message.dto'; /** 전역 ValidationPipe 와 같은 옵션으로 검증한다 (app.setup.ts) */ @@ -28,26 +32,48 @@ const withAttachments = (count: number) => ({ })), }); -describe('SendMessageDto 서식 본문 (bodyHtml)', () => { - const withHtml = (bodyHtml: unknown) => ({ +describe('SendMessageDto 본문 (body · bodyHtml)', () => { + const message = (overrides: object) => ({ to: [{ name: null, email: 'doyun.lee@arasystem.test' }], subject: '제목', body: '본문', - bodyHtml, + ...overrides, }); - it('선택 항목이고 500,000자까지 받는다', async () => { - expect(await constraintsOf({ ...withHtml(undefined) })).toEqual([]); + it('서식 본문은 선택 항목이다', async () => { + expect(await constraintsOf(message({ bodyHtml: undefined }))).toEqual([]); + expect(await constraintsOf(message({ bodyHtml: '

x

' }))).toEqual([]); + }); + + it('길이는 형식 검증에서 막지 않는다 — 한도는 서비스가 JS 문자열 길이로 세어 MAIL_007 로 답한다', async () => { expect( - await constraintsOf(withHtml('가'.repeat(MAX_BODY_HTML_LENGTH))), + await constraintsOf( + message({ + body: '가'.repeat(MAX_BODY_LENGTH + 1), + bodyHtml: '가'.repeat(MAX_BODY_HTML_LENGTH + 1), + }), + ), ).toEqual([]); }); - it('500,000자를 넘거나 문자열이 아니면 형식 검증에서 막는다 (VAL_001)', async () => { + it('문자열이 아니면 형식 검증에서 막는다 (VAL_001)', async () => { + expect(await constraintsOf(message({ body: 1 }))).toContain('isString'); + expect(await constraintsOf(message({ bodyHtml: ['

x

'] }))).toContain( + 'isString', + ); + }); + + it('제목은 그대로 형식 검증에서 255자로 막는다 (VAL_001)', async () => { expect( - await constraintsOf(withHtml('가'.repeat(MAX_BODY_HTML_LENGTH + 1))), + await constraintsOf( + message({ subject: '가'.repeat(MAX_SUBJECT_LENGTH) }), + ), + ).toEqual([]); + expect( + await constraintsOf( + message({ subject: '가'.repeat(MAX_SUBJECT_LENGTH + 1) }), + ), ).toContain('maxLength'); - expect(await constraintsOf(withHtml(['

x

']))).toContain('isString'); }); }); diff --git a/backend/src/modules/mail/dto/send-message.dto.ts b/backend/src/modules/mail/dto/send-message.dto.ts index ab9ff84..b66bf92 100644 --- a/backend/src/modules/mail/dto/send-message.dto.ts +++ b/backend/src/modules/mail/dto/send-message.dto.ts @@ -21,6 +21,7 @@ import { MAIL_ID_PATTERN, MAX_BODY_HTML_LENGTH, MAX_BODY_LENGTH, + MAX_BODY_VALUE_BYTES, MAX_DISPLAY_NAME_LENGTH, MAX_EMAIL_LENGTH, MAX_FILE_NAME_LENGTH, @@ -123,29 +124,29 @@ export class SendMessageDto { }) subject!: string; + // 본문 두 개의 길이는 여기서 막지 않는다 — 서비스가 JS 문자열 길이(UTF-16 코드 단위)로 세어 400 MAIL_007 로 답한다 (mail.service.ts 의 prepareBody). + // MaxLength(validator.js)는 서로게이트 쌍·이모지 표시 선택자를 덜 세어 클라이언트가 센 길이와 어긋나고, 실패 코드도 VAL_001 이 된다. + @ApiProperty({ - description: `일반 텍스트 본문 (${MAX_BODY_LENGTH.toLocaleString('ko-KR')}자 이하, 빈 문자열 가능) — 서식 본문을 보낼 때는 같은 내용의 대체 본문 (목록은 "- "·"1. ", 인용은 "> ")`, + description: `일반 텍스트 본문 (${MAX_BODY_LENGTH.toLocaleString('ko-KR')}자 이하 — JS 문자열 길이, 넘으면 400 MAIL_007. 빈 문자열 가능) — 서식 본문을 보낼 때는 같은 내용의 대체 본문 (목록은 "- "·"1. ", 인용은 "> ")`, example: '도윤님, 확인했습니다.', + maxLength: MAX_BODY_LENGTH, }) @IsString({ message: '본문 형식이 올바르지 않습니다.' }) - @MaxLength(MAX_BODY_LENGTH, { - message: `본문은 ${MAX_BODY_LENGTH.toLocaleString('ko-KR')}자 이하로 입력해 주세요.`, - }) body!: string; @ApiPropertyOptional({ description: - `서식 본문 HTML (${MAX_BODY_HTML_LENGTH.toLocaleString('ko-KR')}자 이하) — 서식 편집기가 만든 HTML. ` + + `서식 본문 HTML (${MAX_BODY_HTML_LENGTH.toLocaleString('ko-KR')}자 이하 — JS 문자열 길이, 넘으면 400 MAIL_007) — 서식 편집기가 만든 HTML. ` + '서버가 허용 목록(문단·글자 서식·span 의 색·글꼴·크기·정렬·들여쓰기·목록·인용·가로줄·표·http/https/mailto 링크)으로 정화한 뒤 ' + - 'body 와 함께 multipart/alternative 로 보낸다. 없으면 텍스트 메일', + 'body 와 함께 multipart/alternative 로 보낸다. 없으면 텍스트 메일. ' + + `정화 전 비용 한도(깊은 중첩)를 넘거나 정화한 결과가 UTF-8 ${MAX_BODY_VALUE_BYTES.toLocaleString('ko-KR')}바이트를 넘어도 400 MAIL_007`, example: '

도윤님, 확인했습니다.

', + maxLength: MAX_BODY_HTML_LENGTH, }) @IsOptional() @IsString({ message: '서식 본문 형식이 올바르지 않습니다.' }) - @MaxLength(MAX_BODY_HTML_LENGTH, { - message: `서식 본문은 ${MAX_BODY_HTML_LENGTH.toLocaleString('ko-KR')}자 이하여야 합니다.`, - }) bodyHtml?: string; @ApiPropertyOptional({ diff --git a/backend/src/modules/mail/html/html-parse-budget.ts b/backend/src/modules/mail/html/html-parse-budget.ts index 8b95c95..71f62c7 100644 --- a/backend/src/modules/mail/html/html-parse-budget.ts +++ b/backend/src/modules/mail/html/html-parse-budget.ts @@ -441,7 +441,7 @@ export type HtmlParseBudgetCheck = /** * 이 HTML 을 정화해도 되는가 — 길이와 스택 비용이 한도 안인지 본다 (선형 시간). - * 넘으면 정화하지 않는다: 받은 메일은 htmlBody 없이(텍스트 본문으로), 보내는 서식 본문은 VAL_001 (mail.service.ts). + * 넘으면 정화하지 않는다: 받은 메일은 htmlBody 없이(텍스트 본문으로, isHtmlBodyOmitted), 보내는 서식 본문은 MAIL_007 (mail.service.ts). */ export function checkHtmlParseBudget(html: string): HtmlParseBudgetCheck { if (html.length > MAX_HTML_PARSE_LENGTH) { diff --git a/backend/src/modules/mail/html/sanitize-outgoing-html.spec.ts b/backend/src/modules/mail/html/sanitize-outgoing-html.spec.ts index 7eb899d..8380464 100644 --- a/backend/src/modules/mail/html/sanitize-outgoing-html.spec.ts +++ b/backend/src/modules/mail/html/sanitize-outgoing-html.spec.ts @@ -223,6 +223,39 @@ describe('sanitizeOutgoingHtml (보내는 서식 본문)', () => { 'a', ); }); + + it('보이지 않게 숨긴 글은 그 서식만 지운다 — 8px 보다 작은 글자, 알파 0.3 미만의 글자 색 (글은 기본 크기·색으로 남는다)', () => { + for (const style of [ + 'font-size: 1px', + 'font-size: 7.99px', + 'color: rgba(255, 255, 255, 0)', + 'color: rgba(0, 0, 0, .29)', + // 알파를 붙인 rgb() 도 같은 규칙이다 + 'color: rgb(0, 0, 0, 0.1)', + ]) { + expect(clean(`

숨은 글

`)).toBe( + '

숨은 글

', + ); + } + // 하한 위의 값과, 투명해도 글을 숨기지 않는 배경 색·테두리 색은 남긴다 + expect( + clean( + '

글

' + + '
칸
', + ), + ).toBe( + '

글

' + + '
칸
', + ); + }); + + it('받은 메일 정화에는 이 하한이 없다 — 받은 뉴스레터의 작은 글자·투명한 글자 색은 그대로 둔다', () => { + const received = + '

작은 글

'; + + expect(sanitizeMailHtml(received).html).toBe(received); + expect(clean(received)).toBe('

작은 글

'); + }); }); /** @@ -244,6 +277,9 @@ describe('sanitizeOutgoingHtml (보내는 서식 본문)', () => { ['color', 'rgb(23, 92, 211)', true], ['color', 'RGB(1,2,3)', true], ['color', 'rgba(0, 0, 0, 0.5)', true], + ['color', 'rgba(0, 0, 0, 0.3)', true], + ['color', 'rgba(0, 0, 0, 0.29)', false], + ['color', 'rgba(0, 0, 0, 0)', false], ['color', 'red', false], ['color', 'transparent', false], ['color', 'currentcolor', false], @@ -257,11 +293,14 @@ describe('sanitizeOutgoingHtml (보내는 서식 본문)', () => { ['background-color', '#FFF1A8', true], ['background-color', 'yellow', false], ['background-color', 'transparent', false], - // 글자 크기 — px 만, 1~99.99px + ['background-color', 'rgba(0, 0, 0, 0)', true], + // 글자 크기 — px 만, 8~99.99px (8px 미만은 보이지 않게 숨긴 글로 보고 지운다) ['font-size', '14px', true], ['font-size', '12.5px', true], ['font-size', '13.33px', true], - ['font-size', '1px', true], + ['font-size', '1px', false], + ['font-size', '8px', true], + ['font-size', '7.99px', false], ['font-size', '99.99px', true], ['font-size', '0', false], ['font-size', '0px', false], diff --git a/backend/src/modules/mail/html/sanitize-outgoing-html.ts b/backend/src/modules/mail/html/sanitize-outgoing-html.ts index 13146d2..bf9d9fa 100644 --- a/backend/src/modules/mail/html/sanitize-outgoing-html.ts +++ b/backend/src/modules/mail/html/sanitize-outgoing-html.ts @@ -13,6 +13,9 @@ import { NON_TEXT_TAGS, SAFE_LINK } from './sanitize-mail-html'; * - 태그: 문단·줄바꿈, 굵게·기울임·밑줄·취소선, span(글자 서식), 링크, 목록, 인용, 가로줄, 표, 감싸는 div * - style: **속성마다 값의 형식까지** 검사한다 (색·길이·글꼴 이름·정렬 낱말). 괄호는 rgb()·rgba() 색에만 쓸 수 있어 * url()·expression()·CSS 이스케이프가 들어갈 자리가 없다 + * - 보이지 않을 만큼 작거나 투명한 서식은 지운다: 8px 보다 작은 글자 크기, 알파 0.3 미만의 글자 색 — 붙여 넣거나 인용한 글에 + * 그렇게 숨긴 글이 보이지 않는 채로 실려 나가지 않게. 바탕과 같은 색(흰 바탕 위 흰 글자 등)은 바탕을 알 수 없어 막지 않는다 + * (받은 메일 정화에는 이 하한이 없다 — 받은 뉴스레터의 모양은 그대로 둔다) * - 링크: http·https·mailto 만. 그 밖의 링크는 글자만 남긴다 * - 이미지·폼·스크립트·이벤트 속성·id·class·data-* 는 남기지 않는다 (본문 이미지는 이번 단계에서 넣지 않는다 — 첨부로) * @@ -21,7 +24,7 @@ import { NON_TEXT_TAGS, SAFE_LINK } from './sanitize-mail-html'; * (sanitize-outgoing-html.spec.ts 의 "다시 읽기" 테스트가 지킨다). * * ⚠️ 부르기 전에 html-parse-budget.ts 의 `checkHtmlParseBudget` 으로 중첩 비용을 보고, 부른 뒤에는 결과의 바이트 수를 본다 - * (mail.service.ts 의 sanitizeBodyHtml — 둘 다 넘으면 VAL_001). + * (mail.service.ts 의 sanitizeBodyHtml — 둘 다 넘으면 MAIL_007). * * ⚠️ 프론트에 짝이 있다 — frontend/src/shared/utils/outgoing-html-rules.ts 가 아래 STYLE_RULES · OUTGOING_FONT_NAMES · * SMALL_NUMBER 와 **같은 값 형식**을 둔다. 쓰기 화면의 편집기가 붙여 넣은 글·원문 인용의 HTML 을 읽어 들일 때 그 형식에 맞는 @@ -75,21 +78,31 @@ const ALLOWED_ATTRIBUTES: Record = { // 모두 처음부터 끝까지(^…$) 맞아야 한다. 값에 쓸 수 있는 글자가 정해져 있어 함수·주석·이스케이프가 끼어들 수 없다. // 형식(단위·글꼴 목록·색 표기)은 엄격하지만 값의 범위는 쓰기 화면의 메뉴(docs/design/webmail/README.md "메일 쓰기")보다 // **넓다** — 붙여 넣거나 답장·전달로 인용한 글의 모양도 지키기 위해서다: -// 글자 크기 1~99.99px(메뉴는 12~32px), 여백·들여쓰기·테두리 두께 0~999.99px(음수 없음), -// 색 #rgb·#rrggbb·rgb()·rgba()(알파 0 포함 — 브라우저가 색을 되읽으면 rgb()/rgba() 로 바꾼다), 글꼴은 아래 목록의 이름만. +// 글자 크기 8~99.99px(메뉴는 12~32px), 여백·들여쓰기·테두리 두께 0~999.99px(음수 없음), +// 색 #rgb·#rrggbb·rgb()·rgba()(브라우저가 색을 되읽으면 rgb()/rgba() 로 바꾼다 — 배경 색·테두리 색은 알파 0~1, +// 글자 색은 알파 0.3~1), 글꼴은 아래 목록의 이름만. +// 글자 크기·글자 색의 하한은 보이지 않는 글을 막는 선이다 — 더 작거나 더 투명한 값은 그 선언만 지워 기본 크기·색으로 보인다. // 쓰기 화면의 편집기도 같은 규칙에 맞는 값만 남긴다 (편집기에 보이는 모양 = 보내는 모양) — 한쪽을 바꾸면 다른 쪽도 함께 바꾼다. /** px 길이 — 0 또는 0~999.99px. 음수는 받지 않는다 (음수 여백으로 다른 내용을 덮는 화면 위장 방지) */ const PX = String.raw`(?:0|\d{1,3}(?:\.\d{1,2})?px)`; /** 칸 폭 — px · % · auto */ const WIDTH = String.raw`(?:${PX}|\d{1,3}(?:\.\d{1,2})?%|auto)`; -/** 색 — #rgb · #rrggbb · rgb()/rgba() (괄호 안은 숫자만) */ +/** 색 — #rgb · #rrggbb · rgb()/rgba() (괄호 안은 숫자만, 알파 0~1) — 배경 색·테두리 색 */ const COLOR = String.raw`(?:#[0-9a-f]{3}|#[0-9a-f]{6}|rgba?\(\s*\d{1,3}\s*,\s*\d{1,3}\s*,\s*\d{1,3}\s*(?:,\s*(?:0|1|0?\.\d{1,3})\s*)?\))`; +/** + * 글자 색 — COLOR 와 같되 rgb()/rgba() 에 알파가 있으면 0.3~1 (투명에 가까운 글자로 숨긴 글을 보내지 않게). + * hex 는 알파가 없다(8자리 hex 는 COLOR 에서도 받지 않는다). + */ +const TEXT_COLOR = String.raw`(?:#[0-9a-f]{3}|#[0-9a-f]{6}|rgba?\(\s*\d{1,3}\s*,\s*\d{1,3}\s*,\s*\d{1,3}\s*(?:,\s*(?:1|0?\.[3-9]\d{0,2})\s*)?\))`; const BORDER_STYLE = String.raw`(?:none|hidden|dotted|dashed|solid|double)`; const BORDER_WIDTH = String.raw`(?:${PX}|thin|medium|thick)`; const BORDER_PART = String.raw`(?:${BORDER_WIDTH}|${BORDER_STYLE}|${COLOR})`; -/** 글자 크기 — px 만, 1~99.99px (메뉴는 12·13·14·16·18·20·24·32, 인용 머리 12.5 — 붙여 넣은 글의 크기도 남긴다) */ -const FONT_SIZE = String.raw`(?:[1-9]\d?(?:\.\d{1,2})?px)`; +/** + * 글자 크기 — px 만, 8~99.99px (메뉴는 12·13·14·16·18·20·24·32, 인용 머리 12.5 — 붙여 넣은 글의 크기도 남긴다). + * 8px 아래는 보이지 않게 숨긴 글로 보고 지운다. + */ +const FONT_SIZE = String.raw`(?:(?:[89]|[1-9]\d)(?:\.\d{1,2})?px)`; /** 줄 간격 — 단위 없는 숫자 1~3, 소수 둘째 자리까지 (편집기 기본 1.75). px·%·em·normal 은 받지 않는다 */ const LINE_HEIGHT = String.raw`(?:[12](?:\.\d{1,2})?|3(?:\.0{1,2})?)`; @@ -140,6 +153,7 @@ const exactly = (pattern: string, flags = 'i') => new RegExp(`^${pattern}$`, flags); const COLOR_VALUE = exactly(COLOR); +const TEXT_COLOR_VALUE = exactly(TEXT_COLOR); const PX_VALUE = exactly(PX); const WIDTH_VALUE = exactly(WIDTH); /** margin·padding 줄임형 — px 길이 1~4개 */ @@ -161,9 +175,10 @@ const LINE_HEIGHT_VALUE = exactly(LINE_HEIGHT); * - 인용 막대: border-left·padding-left·margin·color * - 표(원본 메일 머리 표 포함): 테두리·칸 합치기·칸 폭·칸 여백·세로 정렬·줄바꿈 막기 * 어느 태그에 오든 같은 규칙이다 (편집기가 서식을 문단·칸·목록 어디에 붙이든 남도록). + * 글자 색(color)만 투명도 하한이 있다 — 배경 색·테두리 색은 투명해도 글이 숨지 않는다. */ const STYLE_RULES: Record = { - color: [COLOR_VALUE], + color: [TEXT_COLOR_VALUE], 'background-color': [COLOR_VALUE], 'font-family': [FONT_FAMILY_VALUE], 'font-size': [FONT_SIZE_VALUE], diff --git a/backend/src/modules/mail/jmap/jmap-mail.provider.spec.ts b/backend/src/modules/mail/jmap/jmap-mail.provider.spec.ts index 598b755..5c1251f 100644 --- a/backend/src/modules/mail/jmap/jmap-mail.provider.spec.ts +++ b/backend/src/modules/mail/jmap/jmap-mail.provider.spec.ts @@ -608,7 +608,9 @@ describe('JmapMailProvider', () => { messageId: 'abc@arasystem.test', references: [], rawHtmlBody: '

HTML 본문

', + isRawHtmlBodyTruncated: false, textBody: '텍스트 본문', + isTextBodyTruncated: false, }); expect(detail.attachments).toEqual([ { @@ -647,9 +649,92 @@ describe('JmapMailProvider', () => { const detail = await provider.getMessage(ARA, 'm1'); expect(detail.rawHtmlBody).toBeNull(); + expect(detail.isRawHtmlBodyTruncated).toBe(false); expect(detail.textBody).toBe('텍스트 본문'); }); + /** 엔진이 본문 읽기 한도(maxBodyValueBytes)에서 자른 부분은 isTruncated 가 켜져 온다 (RFC 8621 EmailBodyValue) */ + it.each([ + { + name: 'HTML 부분이 잘렸다', + bodyValues: { + '1': { value: '텍스트 본문', isTruncated: false }, + '2': { value: '

HTML 앞부분', isTruncated: true }, + }, + html: true, + text: false, + }, + { + name: '텍스트 부분이 잘렸다', + bodyValues: { + '1': { value: '텍스트 앞부분', isTruncated: true }, + '2': { value: '

HTML 본문

', isTruncated: false }, + }, + html: false, + text: true, + }, + { + name: '잘린 부분이 없다 (isTruncated 가 없는 값 포함)', + bodyValues: { + '1': { value: '텍스트 본문' }, + '2': { value: '

HTML 본문

', isTruncated: false }, + }, + html: false, + text: false, + }, + ])( + '본문 잘림은 HTML·텍스트 따로 넘긴다 — $name', + async ({ bodyValues, html, text }) => { + const { provider } = fakeClient([ + [['Email/get', { list: [{ ...detailEmail, bodyValues }] }, 'g']], + ]); + + const detail = await provider.getMessage(ARA, 'm1'); + + expect(detail.isRawHtmlBodyTruncated).toBe(html); + expect(detail.isTextBodyTruncated).toBe(text); + // 잘린 값도 그대로 넘긴다 — 무엇을 보여 줄지는 서비스가 정한다 + expect(detail.rawHtmlBody).toBe(bodyValues['2'].value); + expect(detail.textBody).toBe(bodyValues['1'].value); + }, + ); + + it('HTML 부분이 여럿이면 하나라도 잘렸으면 잘린 본문이다 — 다른 형식의 부분은 세지 않는다', async () => { + const multipart = { + ...detailEmail, + bodyValues: { + '1': { value: '텍스트 앞부분', isTruncated: true }, + '2': { value: '

첫 부분

', isTruncated: false }, + '5': { value: '

둘째 부분', isTruncated: true }, + }, + // JMAP htmlBody 에는 HTML 이 없는 구간의 text/plain 부분도 들어 있을 수 있다 — 그 잘림은 HTML 의 것이 아니다 + htmlBody: [ + { partId: '2', type: 'text/html' }, + { partId: '1', type: 'text/plain' }, + { partId: '5', type: 'text/html' }, + ], + }; + const onlyTextTruncated = { + ...multipart, + bodyValues: { + ...multipart.bodyValues, + '5': { value: '

둘째 부분

', isTruncated: false }, + }, + }; + const { provider } = fakeClient([ + [['Email/get', { list: [multipart] }, 'g']], + [['Email/get', { list: [onlyTextTruncated] }, 'g']], + ]); + + const first = await provider.getMessage(ARA, 'm1'); + expect(first.rawHtmlBody).toBe('

첫 부분

\n

둘째 부분'); + expect(first.isRawHtmlBodyTruncated).toBe(true); + + const second = await provider.getMessage(ARA, 'm1'); + expect(second.isRawHtmlBodyTruncated).toBe(false); + expect(second.isTextBodyTruncated).toBe(true); + }); + it('없는 메일은 RES_001 이다', async () => { const { provider } = fakeClient([ [['Email/get', { list: [], notFound: ['nope'] }, 'g']], diff --git a/backend/src/modules/mail/jmap/jmap-mail.provider.ts b/backend/src/modules/mail/jmap/jmap-mail.provider.ts index ae0ef12..cc5c9aa 100644 --- a/backend/src/modules/mail/jmap/jmap-mail.provider.ts +++ b/backend/src/modules/mail/jmap/jmap-mail.provider.ts @@ -330,7 +330,7 @@ export class JmapMailProvider implements MailProvider { bodyProperties: BODY_PART_PROPERTIES, fetchHTMLBodyValues: true, fetchTextBodyValues: true, - // 본문 한 부분을 이 바이트까지 읽는다 — 넘는 부분은 엔진이 잘라서 준다. + // 본문 한 부분을 이 바이트까지 읽는다 — 넘는 부분은 엔진이 잘라서 주고 그 부분에 isTruncated 를 켠다 (매퍼가 읽는다). // 우리가 보낸 서식 본문은 발송 때 정화 결과를 같은 바이트 수로 막았으므로 잘리지 않는다 (mail.constants.ts) maxBodyValueBytes: MAX_BODY_VALUE_BYTES, }, diff --git a/backend/src/modules/mail/jmap/jmap.mapper.ts b/backend/src/modules/mail/jmap/jmap.mapper.ts index 80e37c9..44bb194 100644 --- a/backend/src/modules/mail/jmap/jmap.mapper.ts +++ b/backend/src/modules/mail/jmap/jmap.mapper.ts @@ -204,20 +204,31 @@ function toAttachment( }; } +/** 이어 붙인 본문 — 엔진이 그중 한 부분이라도 잘라서 줬으면 isTruncated */ +interface JoinedBody { + readonly value: string; + readonly isTruncated: boolean; +} + /** * 지정한 형식의 본문 부분 값을 이어 붙인다. 그 형식 부분이 없으면 null. * (JMAP 의 htmlBody 는 HTML 이 없으면 text/plain 부분으로 채워지므로 형식을 확인해야 한다) + * 잘림은 부분마다 온다(RFC 8621 EmailBodyValue.isTruncated — maxBodyValueBytes 를 넘은 부분) — 하나라도 잘렸으면 잘린 본문이다. */ function joinBodyValues( email: JmapEmail, parts: JmapBodyPart[] | undefined, type: string, -): string | null { +): JoinedBody | null { const values = (parts ?? []) .filter((part) => partType(part) === type && part.partId) - .map((part) => email.bodyValues?.[part.partId as string]?.value ?? ''); + .map((part) => email.bodyValues?.[part.partId as string]); + if (values.length === 0) return null; - return values.length > 0 ? values.join('\n') : null; + return { + value: values.map((bodyValue) => bodyValue?.value ?? '').join('\n'), + isTruncated: values.some((bodyValue) => bodyValue?.isTruncated === true), + }; } export function toDetailSource(email: JmapEmail): MessageDetailSource { @@ -230,6 +241,8 @@ export function toDetailSource(email: JmapEmail): MessageDetailSource { type: partType(part), size: part.size ?? 0, })); + const html = joinBodyValues(email, email.htmlBody, 'text/html'); + const text = joinBodyValues(email, email.textBody, 'text/plain'); return { ...toSummary(email), @@ -239,8 +252,10 @@ export function toDetailSource(email: JmapEmail): MessageDetailSource { sentAt: toUtcIso(email.sentAt), messageId: email.messageId?.[0] ?? null, references: email.references ?? [], - rawHtmlBody: joinBodyValues(email, email.htmlBody, 'text/html'), - textBody: joinBodyValues(email, email.textBody, 'text/plain'), + rawHtmlBody: html?.value ?? null, + isRawHtmlBodyTruncated: html?.isTruncated ?? false, + textBody: text?.value ?? null, + isTextBodyTruncated: text?.isTruncated ?? false, attachments: attachments.map(toAttachment), inlineParts, }; diff --git a/backend/src/modules/mail/mail.constants.ts b/backend/src/modules/mail/mail.constants.ts index 9f23a8b..6be6145 100644 --- a/backend/src/modules/mail/mail.constants.ts +++ b/backend/src/modules/mail/mail.constants.ts @@ -14,16 +14,19 @@ export const MAX_RECIPIENTS = 100; /** 제목 최대 길이 */ export const MAX_SUBJECT_LENGTH = 255; -/** 본문(일반 텍스트) 최대 길이 — 서식 본문이 있으면 그 대체 본문이다 */ +/** + * 본문(일반 텍스트) 최대 길이 — 서식 본문이 있으면 그 대체 본문이다. + * JS 문자열 길이(UTF-16 코드 단위)로 세고, 넘으면 400 MAIL_007 (mail.service.ts — 웹 클라이언트도 같은 방법으로 센다) + */ export const MAX_BODY_LENGTH = 100_000; -/** 서식 본문(bodyHtml) 최대 길이 — 서식 편집기가 만든 HTML (계약 2절) */ +/** 서식 본문(bodyHtml) 최대 길이 — 서식 편집기가 만든 HTML (계약 2절). 세는 방법·실패 코드는 MAX_BODY_LENGTH 와 같다 */ export const MAX_BODY_HTML_LENGTH = 500_000; /** - * 본문 한 부분을 읽는 상한 (UTF-8 바이트) — 상세 조회는 이 크기까지 읽고, 넘는 부분은 엔진이 잘라서 준다. - * 보내는 서식 본문은 **정화한 결과**가 이 크기를 넘으면 보내지 않는다(VAL_001) — 우리 웹메일이 다시 읽을 때 - * (보낸편지함·받는 사람) 표시 없이 잘리지 않게. 500,000자 한도만으로는 부족하다: 정화가 `&` 를 `&` 로 늘리고 + * 본문 한 부분을 읽는 상한 (UTF-8 바이트) — 상세 조회는 이 크기까지 읽고, 넘는 부분은 엔진이 잘라서 준다(그때 isBodyTruncated). + * 보내는 서식 본문은 **정화한 결과**가 이 크기를 넘으면 보내지 않는다(MAIL_007) — 우리 웹메일이 다시 읽을 때 + * (보낸편지함·받는 사람) 잘리지 않게. 500,000자 한도만으로는 부족하다: 정화가 `&` 를 `&` 로 늘리고 * 한글·이모지는 글자당 3~4바이트라 같은 글자 수가 2MB 를 넘을 수 있다. */ export const MAX_BODY_VALUE_BYTES = 2_000_000; diff --git a/backend/src/modules/mail/mail.service.spec.ts b/backend/src/modules/mail/mail.service.spec.ts index da84067..7e60d62 100644 --- a/backend/src/modules/mail/mail.service.spec.ts +++ b/backend/src/modules/mail/mail.service.spec.ts @@ -1,9 +1,14 @@ import { Logger } from '@nestjs/common'; +import { startCpuTimer } from '@/../test/cpu-time'; import { BusinessException } from '@/common/errors/business.exception'; import type { SendMessageDto } from './dto/send-message.dto'; import { MAX_HTML_PARSE_LENGTH } from './html/html-parse-budget'; -import { MAX_BODY_VALUE_BYTES } from './mail.constants'; +import { + MAX_BODY_HTML_LENGTH, + MAX_BODY_LENGTH, + MAX_BODY_VALUE_BYTES, +} from './mail.constants'; import { MailService } from './mail.service'; import type { MailProvider } from './mail.provider'; import type { MailSettings } from './mail.settings'; @@ -71,6 +76,20 @@ async function codeOf( return undefined; } +/** 실패의 코드와 로그에 남는 사유(context.reason) */ +async function failureOf( + run: () => Promise, +): Promise<{ code?: string; reason?: unknown }> { + try { + await run(); + } catch (error) { + return error instanceof BusinessException + ? { code: error.code, reason: error.context?.reason } + : { code: 'NOT_BUSINESS' }; + } + return {}; +} + const source = ( overrides: Partial = {}, ): MessageDetailSource => ({ @@ -93,12 +112,17 @@ const source = ( messageId: 'abc@x', references: [], rawHtmlBody: null, + isRawHtmlBodyTruncated: false, textBody: '본문', + isTextBodyTruncated: false, attachments: [], inlineParts: [], ...overrides, }); +/** 정화 비용 한도를 넘는 HTML — 500,000자 안의 '' 16만 개 (정화하면 9초 가까이 이벤트 루프가 멈춘다) */ +const TOO_DEEP_HTML = ''.repeat(166_666); + const sendDto = (overrides: Partial = {}): SendMessageDto => ({ to: [{ name: '이도윤', email: 'doyun.lee@arasystem.test' }], subject: '안녕하세요', @@ -235,9 +259,18 @@ describe('MailService', () => { htmlBody: null, textBody: '본문', hasBlockedImages: false, + isHtmlBodyOmitted: false, + isBodyTruncated: false, }); - expect(detail).not.toHaveProperty('rawHtmlBody'); - expect(detail).not.toHaveProperty('inlineParts'); + // 어댑터의 정화 전 값·내부 표시는 내보내지 않는다 + for (const internal of [ + 'rawHtmlBody', + 'inlineParts', + 'isRawHtmlBodyTruncated', + 'isTextBodyTruncated', + ]) { + expect(detail).not.toHaveProperty(internal); + } }); it('HTML 은 정화해서 내려준다 (원본을 그대로 내보내지 않는다)', async () => { @@ -335,15 +368,17 @@ describe('MailService', () => { source({ rawHtmlBody, textBody: '텍스트 본문', inlineParts: [logo] }), ); - const started = performance.now(); + const cpuMs = startCpuTimer(); const detail = await service.getMessage(user, 'm1'); expect(detail).toMatchObject({ htmlBody: null, textBody: '텍스트 본문', hasBlockedImages: false, + isHtmlBodyOmitted: true, }); - expect(performance.now() - started).toBeLessThan(2_000); + // CPU 시간 (test/cpu-time.ts) — 지금 0.1초 안쪽 (검사만 하고 정화하지 않는다). 정화했다면 2MB 중첩에 172초 + expect(cpuMs()).toBeLessThan(2_000); } expect(provider.readAttachmentBytes).not.toHaveBeenCalled(); @@ -357,6 +392,121 @@ describe('MailService', () => { expect(message).not.toContain(user.email); } }); + + it.each([ + { + name: 'HTML 이 없다', + rawHtmlBody: null, + textBody: '본문', + omitted: false, + }, + { + name: 'HTML 을 정화해서 보여 준다', + rawHtmlBody: '

본문

', + textBody: '본문', + omitted: false, + }, + { + name: 'HTML 이 정화 한도를 넘었다', + rawHtmlBody: TOO_DEEP_HTML, + textBody: '본문', + omitted: true, + }, + { + name: 'HTML 만 있는 메일이 정화 한도를 넘었다 (보여 줄 본문이 없다)', + rawHtmlBody: TOO_DEEP_HTML, + textBody: null, + omitted: true, + }, + ])( + 'isHtmlBodyOmitted 는 HTML 이 있었지만 정화 한도로 비웠을 때만 true — $name', + async ({ rawHtmlBody, textBody, omitted }) => { + provider.getMessage.mockResolvedValue( + source({ rawHtmlBody, textBody }), + ); + + const detail = await service.getMessage(user, 'm1'); + + expect(detail.isHtmlBodyOmitted).toBe(omitted); + expect(detail.textBody).toBe(textBody); + if (omitted) expect(detail.htmlBody).toBeNull(); + }, + ); + + it.each([ + { + name: 'HTML 이 잘렸고 HTML 을 보여 준다', + rawHtmlBody: '

앞부분', + html: true, + text: false, + truncated: true, + }, + { + name: 'HTML 을 정화 한도로 비웠고 텍스트가 잘렸다', + rawHtmlBody: TOO_DEEP_HTML, + html: false, + text: true, + truncated: true, + }, + { + name: 'HTML 을 보여 주고 텍스트만 잘렸다', + rawHtmlBody: '

본문

', + html: false, + text: true, + truncated: false, + }, + { + name: 'HTML 이 없고 텍스트가 잘렸다', + rawHtmlBody: null, + html: false, + text: true, + truncated: true, + }, + { + name: 'HTML 을 정화 한도로 비웠고 HTML 만 잘렸다', + rawHtmlBody: TOO_DEEP_HTML, + html: true, + text: false, + truncated: false, + }, + // 정화해서 빈 htmlBody 는 클라이언트가 그리지 않고 textBody 를 보여 준다 (웹 buildMailDocument) + { + name: '정화한 HTML 이 비었고 텍스트가 잘렸다', + rawHtmlBody: '', + html: false, + text: true, + truncated: true, + }, + { + name: '정화한 HTML 이 비었고 HTML 만 잘렸다', + rawHtmlBody: '', + html: true, + text: false, + truncated: false, + }, + { + name: '잘린 부분이 없다', + rawHtmlBody: '

본문

', + html: false, + text: false, + truncated: false, + }, + ])( + 'isBodyTruncated 는 보여 줄 본문의 잘림을 따른다 — $name', + async ({ rawHtmlBody, html, text, truncated }) => { + provider.getMessage.mockResolvedValue( + source({ + rawHtmlBody, + isRawHtmlBodyTruncated: html, + isTextBodyTruncated: text, + }), + ); + + await expect(service.getMessage(user, 'm1')).resolves.toMatchObject({ + isBodyTruncated: truncated, + }); + }, + ); }); it('PATCH 는 하나 이상 지정해야 한다', async () => { @@ -455,26 +605,108 @@ describe('MailService', () => { } }); - it('서식 본문이 너무 깊게 중첩되면 정화하지 않고 400 VAL_001 — 엔진·첨부를 찾기 전에 (키가 있어도)', async () => { - // 500,000자 안의 '' 16만 개 — 정화하면 9초 가까이 이벤트 루프가 멈춘다 - const bodyHtml = ''.repeat(166_666); + it('본문이 너무 길면 400 MAIL_007 — 엔진·첨부·중복 방지 잠금 전에 (키가 있어도), 로그 사유는 경우마다 다르다', async () => { const attachments = [{ id: 'blob-1', name: '체크리스트.xlsx' }]; + const cases: [Partial, string][] = [ + [{ body: 'x'.repeat(MAX_BODY_LENGTH + 1) }, 'body-too-long'], + [ + { bodyHtml: 'x'.repeat(MAX_BODY_HTML_LENGTH + 1) }, + 'body-html-too-long', + ], + [{ bodyHtml: TOO_DEEP_HTML }, 'body-html-too-complex'], + // '&' 는 정화하면 '&'(5바이트) — 400,001개면 한도를 5바이트 넘는다 + [{ bodyHtml: '&'.repeat(400_001) }, 'body-html-too-large'], + ]; - for (const key of [undefined, IDEMPOTENCY_KEY]) { - const started = performance.now(); - expect( - await codeOf(() => - service.sendMessage(user, sendDto({ bodyHtml, attachments }), key), - ), - ).toBe('VAL_001'); - expect(performance.now() - started).toBeLessThan(2_000); + for (const [overrides, reason] of cases) { + for (const key of [undefined, IDEMPOTENCY_KEY]) { + expect( + await failureOf(() => + service.sendMessage( + user, + sendDto({ ...overrides, attachments }), + key, + ), + ), + ).toEqual({ code: 'MAIL_007', reason }); + } } expect(provider.findSentMessage).not.toHaveBeenCalled(); expect(provider.sendMessage).not.toHaveBeenCalled(); }); - it('정화한 서식 본문이 UTF-8 2,000,000바이트를 넘으면 400 VAL_001 — 다시 읽을 때 잘리지 않게 (넘지 않으면 보낸다)', async () => { - // '&' 는 정화하면 '&'(5바이트) — 400,000개면 정확히 한도 + it('깊게 중첩된 서식 본문은 정화하지 않고 바로 막는다 (정화하면 9초 가까이 걸린다)', async () => { + // CPU 시간 (test/cpu-time.ts) — 지금 수십 ms (비용 검사가 멈춘다). 정화했다면 '' 16만 개에 8.8초. + // 정화까지 하는 다른 한도(정화 뒤 바이트)는 시간을 보지 않는다 — 막는지만 본다 (위 테스트) + const cpuMs = startCpuTimer(); + expect( + await failureOf(() => + service.sendMessage(user, sendDto({ bodyHtml: TOO_DEEP_HTML })), + ), + ).toEqual({ code: 'MAIL_007', reason: 'body-html-too-complex' }); + expect(cpuMs()).toBeLessThan(2_000); + }); + + it('길이는 JS 문자열 길이(UTF-16 코드 단위)로 센다 — 웹 클라이언트와 같은 수 (이모지·표시 선택자가 붙은 글자는 2자)', async () => { + // 한도에 딱 맞으면 보낸다 + await service.sendMessage( + user, + sendDto({ body: '😀'.repeat(MAX_BODY_LENGTH / 2) }), + ); + await service.sendMessage( + user, + sendDto({ bodyHtml: '❤️'.repeat(MAX_BODY_HTML_LENGTH / 2) }), + ); + expect(provider.sendMessage).toHaveBeenCalledTimes(2); + + // 한 자만 넘어도 막는다 — 형식 검증(class-validator MaxLength)은 이것을 절반으로 세어 통과시켰다 + expect( + await failureOf(() => + service.sendMessage( + user, + sendDto({ body: `${'😀'.repeat(MAX_BODY_LENGTH / 2)}x` }), + ), + ), + ).toEqual({ code: 'MAIL_007', reason: 'body-too-long' }); + expect( + await failureOf(() => + service.sendMessage( + user, + sendDto({ bodyHtml: `${'❤️'.repeat(MAX_BODY_HTML_LENGTH / 2)}x` }), + ), + ), + ).toEqual({ code: 'MAIL_007', reason: 'body-html-too-long' }); + expect(provider.sendMessage).toHaveBeenCalledTimes(2); + }); + + it('본문 한도는 텍스트 길이를 먼저 보고, 서식 본문은 길이를 정화 전 비용보다 먼저 본다 (비용은 정화 전 — 위 시간 검사)', async () => { + // 둘 다 넘으면 텍스트 길이가 먼저 + expect( + await failureOf(() => + service.sendMessage( + user, + sendDto({ + body: 'x'.repeat(MAX_BODY_LENGTH + 1), + bodyHtml: 'x'.repeat(MAX_BODY_HTML_LENGTH + 1), + }), + ), + ), + ).toEqual({ code: 'MAIL_007', reason: 'body-too-long' }); + // 길이를 넘는 깊은 중첩은 비용을 재기 전에 길이로 막는다 + expect( + await failureOf(() => + service.sendMessage( + user, + sendDto({ + bodyHtml: ''.repeat(Math.ceil(MAX_BODY_HTML_LENGTH / 3)), + }), + ), + ), + ).toEqual({ code: 'MAIL_007', reason: 'body-html-too-long' }); + }); + + it('정화한 서식 본문이 UTF-8 2,000,000바이트 이하면 보낸다 — 500,000자 안이어도 한글과 & 가 섞이면 넘는다', async () => { + // '&' 400,000개는 정화하면 정확히 2,000,000바이트 await service.sendMessage( user, sendDto({ bodyHtml: '&'.repeat(400_000) }), @@ -484,17 +716,16 @@ describe('MailService', () => { MAX_BODY_VALUE_BYTES, ); - for (const bodyHtml of [ - '&'.repeat(400_001), - // 글자 + 이모지 표시 선택자(VS16) — 형식 검증은 1자로 세지만(500,000자 안) UTF-8 로 6바이트씩 3MB - '❤️'.repeat(500_000), - ]) { - expect( - await codeOf(() => - service.sendMessage(user, sendDto({ bodyHtml }), IDEMPOTENCY_KEY), + // '가&&' — 3자가 정화하면 13바이트 (한글 3 + '&' 5 × 2): 499,998자로 2,166,658바이트 + expect( + await failureOf(() => + service.sendMessage( + user, + sendDto({ bodyHtml: '가&&'.repeat(166_666) }), + IDEMPOTENCY_KEY, ), - ).toBe('VAL_001'); - } + ), + ).toEqual({ code: 'MAIL_007', reason: 'body-html-too-large' }); expect(provider.sendMessage).toHaveBeenCalledTimes(1); expect(provider.findSentMessage).not.toHaveBeenCalled(); }); @@ -615,6 +846,18 @@ describe('MailService', () => { expect(provider.sendMessage).not.toHaveBeenCalled(); }); + it('본문이 너무 길어 막힌 뒤(MAIL_007)에는 고친 본문을 같은 키로 곧바로 보낼 수 있다 — 키를 잠그거나 기록하지 않는다', async () => { + expect( + await codeOf(() => + send(sendDto({ body: 'x'.repeat(MAX_BODY_LENGTH + 1) })), + ), + ).toBe('MAIL_007'); + + await expect(send()).resolves.toEqual({ id: 'sent-1' }); + expect(provider.findSentMessage).toHaveBeenCalledTimes(1); + expect(provider.sendMessage).toHaveBeenCalledTimes(1); + }); + it('처음 보내는 키면 (사용자, 키)로 정한 Message-ID 와 내용 지문을 실어 보낸다', async () => { await expect(send()).resolves.toEqual({ id: 'sent-1' }); diff --git a/backend/src/modules/mail/mail.service.ts b/backend/src/modules/mail/mail.service.ts index 3c4323e..972db96 100644 --- a/backend/src/modules/mail/mail.service.ts +++ b/backend/src/modules/mail/mail.service.ts @@ -27,7 +27,12 @@ import { import { checkHtmlParseBudget } from './html/html-parse-budget'; import { sanitizeMailHtml } from './html/sanitize-mail-html'; import { sanitizeOutgoingHtml } from './html/sanitize-outgoing-html'; -import { MAX_BODY_VALUE_BYTES, MAX_RECIPIENTS } from './mail.constants'; +import { + MAX_BODY_HTML_LENGTH, + MAX_BODY_LENGTH, + MAX_BODY_VALUE_BYTES, + MAX_RECIPIENTS, +} from './mail.constants'; import { MAIL_PROVIDER, type MailProvider } from './mail.provider'; import { MAIL_SETTINGS, type MailSettings } from './mail.settings'; import type { @@ -67,14 +72,36 @@ function toAddress(recipient: RecipientDto): MailAddress { } /** - * 서식 본문 정화 — 앞뒤로 한도를 본다. 넘으면 400 VAL_001 (엔진·첨부 조회 전이다). - * - 정화 전: 깊게 중첩된 HTML 은 정화 자체가 이벤트 루프를 멈춘다 (html/html-parse-budget.ts) - * - 정화 뒤: 결과가 본문 읽기 한도(바이트)를 넘으면 보낸편지함·받는 사람 화면에서 표시 없이 잘린다 (mail.constants.ts) + * 발송 본문의 한도 — 넘으면 400 MAIL_007 (계약 2절). 엔진·첨부 조회와 중복 방지 잠금 **전**이라 메일은 나가지 않는다. + * 검사 순서: 텍스트 길이 → 서식 본문 길이 → 정화 전 비용 → 정화 → 정화 뒤 바이트. + * 길이는 JS 문자열 길이(UTF-16 코드 단위)로 센다 — 웹 클라이언트가 보내기 전에 세는 방법과 같다 + * (class-validator 의 MaxLength 는 서로게이트 쌍·이모지 표시 선택자를 다르게 세서 클라이언트와 어긋났다). + * @returns 정화한 서식 본문 — 서식 본문이 없거나 정화해서 비면 빈 문자열(텍스트 메일) + */ +function prepareBody(body: string, bodyHtml: string | undefined): string { + if (body.length > MAX_BODY_LENGTH) { + throw new BusinessException('MAIL_007', { + context: { reason: 'body-too-long', length: body.length }, + }); + } + return bodyHtml ? sanitizeBodyHtml(bodyHtml) : ''; +} + +/** + * 서식 본문 정화 — 앞뒤로 한도를 본다 (넘으면 MAIL_007). + * - 정화 전: 길이, 그리고 깊게 중첩된 HTML 은 정화 자체가 이벤트 루프를 멈춘다 (html/html-parse-budget.ts) + * - 정화 뒤: 결과가 본문 읽기 한도(바이트)를 넘으면 보낸편지함·받는 사람 화면에서 잘린다 (mail.constants.ts) */ function sanitizeBodyHtml(bodyHtml: string): string { + if (bodyHtml.length > MAX_BODY_HTML_LENGTH) { + throw new BusinessException('MAIL_007', { + context: { reason: 'body-html-too-long', length: bodyHtml.length }, + }); + } + const budget = checkHtmlParseBudget(bodyHtml); if (!budget.ok) { - throw new BusinessException('VAL_001', { + throw new BusinessException('MAIL_007', { context: { reason: 'body-html-too-complex', detail: budget.reason, @@ -86,7 +113,7 @@ function sanitizeBodyHtml(bodyHtml: string): string { const html = sanitizeOutgoingHtml(bodyHtml); const bytes = Buffer.byteLength(html, 'utf8'); if (bytes > MAX_BODY_VALUE_BYTES) { - throw new BusinessException('VAL_001', { + throw new BusinessException('MAIL_007', { context: { reason: 'body-html-too-large', bytes }, }); } @@ -165,18 +192,32 @@ export class MailService { /** * 상세 — 조회만 한다(읽음 처리 없음). HTML 은 정화한 뒤에만 내보낸다. * 정화가 이벤트 루프를 오래 붙잡을 HTML(너무 길거나 깊게 중첩된 것)은 정화하지 않고 htmlBody 를 비운다 — - * 클라이언트는 textBody 를 보여 준다 (html/html-parse-budget.ts). + * 그때 isHtmlBodyOmitted 를 켜고, 클라이언트는 textBody 를 보여 준다 (html/html-parse-budget.ts). + * isBodyTruncated 는 클라이언트가 보여 줄 본문의 잘림을 따른다 — htmlBody 가 null 이거나 공백뿐이면 텍스트, 아니면 HTML + * (웹의 buildMailDocument 가 htmlBody 를 trim 해 비면 textBody 를 그린다). */ async getMessage( user: AuthenticatedUser, id: string, ): Promise { - const { rawHtmlBody, inlineParts, ...detail } = - await this.provider.getMessage(user.email, id); + const { + rawHtmlBody, + isRawHtmlBodyTruncated, + isTextBodyTruncated, + inlineParts, + ...detail + } = await this.provider.getMessage(user.email, id); - if (rawHtmlBody === null) { - return { ...detail, htmlBody: null, hasBlockedImages: false }; - } + /** HTML 없이 텍스트 본문을 보여 줄 때 (HTML 이 없거나, 있었지만 정화 한도를 넘어 비웠을 때) */ + const withoutHtml = (isHtmlBodyOmitted: boolean): MessageDetail => ({ + ...detail, + htmlBody: null, + hasBlockedImages: false, + isHtmlBodyOmitted, + isBodyTruncated: isTextBodyTruncated, + }); + + if (rawHtmlBody === null) return withoutHtml(false); // 내장 이미지를 읽기 전에 본다 — 보여 주지 않을 본문의 이미지를 엔진에서 받아 오지 않게 const budget = checkHtmlParseBudget(rawHtmlBody); @@ -185,7 +226,7 @@ export class MailService { this.logger.warn( `${traceTag()} 받은 메일 HTML 이 정화 한도를 넘어 텍스트 본문만 내려줍니다: user=${maskEmail(user.email)}, id=${id}, 사유=${budget.reason}, 길이=${rawHtmlBody.length}자`, ); - return { ...detail, htmlBody: null, hasBlockedImages: false }; + return withoutHtml(true); } const inlineImages = await this.loadInlineImages( @@ -194,10 +235,13 @@ export class MailService { inlineParts, ); const sanitized = sanitizeMailHtml(rawHtmlBody, inlineImages); + const showsHtml = sanitized.html.trim() !== ''; return { ...detail, htmlBody: sanitized.html, hasBlockedImages: sanitized.hasBlockedImages, + isHtmlBodyOmitted: false, + isBodyTruncated: showsHtml ? isRawHtmlBodyTruncated : isTextBodyTruncated, }; } @@ -314,7 +358,7 @@ export class MailService { } } - /** 엔진과 무관한 발송 규칙 — 받는 사람 수(MAIL_002), 첨부 개수(MAIL_006)·형식(FILE_001), 헤더 값 줄바꿈, 서식 본문 정화(한도 VAL_001) */ + /** 엔진과 무관한 발송 규칙 — 받는 사람 수(MAIL_002), 첨부 개수(MAIL_006)·형식(FILE_001), 헤더 값 줄바꿈, 본문 한도(MAIL_007)·서식 본문 정화 */ private prepareOutgoing(dto: SendMessageDto): OutgoingMessage { const to = dto.to.map(toAddress); const cc = (dto.cc ?? []).map(toAddress); @@ -347,8 +391,8 @@ export class MailService { return { id: attachment.id, name, type }; }); - // 서식 본문 — 편집기가 만드는 서식만 남긴다 (보내는 메일 전용 허용 목록). 정화해서 비면 텍스트 메일로 보낸다 - const htmlBody = dto.bodyHtml ? sanitizeBodyHtml(dto.bodyHtml) : ''; + // 본문 한도를 보고, 서식 본문은 편집기가 만드는 서식만 남긴다 (보내는 메일 전용 허용 목록). 정화해서 비면 텍스트 메일로 보낸다 + const htmlBody = prepareBody(dto.body, dto.bodyHtml); return { to, diff --git a/backend/src/modules/mail/mail.types.ts b/backend/src/modules/mail/mail.types.ts index aaef575..1aad3da 100644 --- a/backend/src/modules/mail/mail.types.ts +++ b/backend/src/modules/mail/mail.types.ts @@ -68,6 +68,13 @@ export interface MessageDetail extends MessageSummary { readonly textBody: string | null; /** 외부 이미지를 지웠으면 true */ readonly hasBlockedImages: boolean; + /** HTML 이 있었지만 정화 전 사전 검사(html/html-parse-budget.ts)를 넘어 htmlBody 를 비웠으면 true */ + readonly isHtmlBodyOmitted: boolean; + /** + * 보여 줄 본문이 본문 읽기 한도(MAX_BODY_VALUE_BYTES)에서 잘렸으면 true. + * 보여 줄 본문 = htmlBody, 그것이 null 이거나 공백뿐이면 textBody (웹 클라이언트의 buildMailDocument 와 같은 규칙) + */ + readonly isBodyTruncated: boolean; readonly attachments: MailAttachment[]; } @@ -83,13 +90,22 @@ export interface InlinePart { /** * 어댑터가 돌려주는 메일 상세 원본. * HTML 은 **정화 전** 값이다 — 서비스 계층이 정화한 뒤에만 MessageDetail 로 내보낸다. + * 잘림 표시는 HTML·텍스트 따로 준다 — 어느 쪽을 보여 줄지는 정화 결과에 달려 있어 서비스 계층이 정한다. */ export interface MessageDetailSource extends Omit< MessageDetail, - 'htmlBody' | 'textBody' | 'hasBlockedImages' + | 'htmlBody' + | 'textBody' + | 'hasBlockedImages' + | 'isHtmlBodyOmitted' + | 'isBodyTruncated' > { readonly rawHtmlBody: string | null; + /** HTML 부분 중 하나라도 엔진이 본문 읽기 한도에서 잘랐으면 true (HTML 이 없으면 false) */ + readonly isRawHtmlBodyTruncated: boolean; readonly textBody: string | null; + /** 텍스트 부분 중 하나라도 엔진이 본문 읽기 한도에서 잘랐으면 true (텍스트가 없으면 false) */ + readonly isTextBodyTruncated: boolean; readonly inlineParts: InlinePart[]; } diff --git a/backend/src/modules/mail/message.controller.ts b/backend/src/modules/mail/message.controller.ts index e9a8d7b..0df0bdf 100644 --- a/backend/src/modules/mail/message.controller.ts +++ b/backend/src/modules/mail/message.controller.ts @@ -44,7 +44,13 @@ import { SentMessageDto, } from './dto/mail-response.dto'; import { MAX_ATTACHMENTS_PER_MESSAGE } from './attachment.policy'; -import { MAX_BODY_VALUE_BYTES, MESSAGES_ROUTE } from './mail.constants'; +import { + MAX_BODY_HTML_LENGTH, + MAX_BODY_LENGTH, + MAX_BODY_VALUE_BYTES, + MAX_SUBJECT_LENGTH, + MESSAGES_ROUTE, +} from './mail.constants'; import { MessageListQueryDto } from './dto/message-list-query.dto'; import { SendMessageDto } from './dto/send-message.dto'; import { UpdateMessageDto } from './dto/update-message.dto'; @@ -93,7 +99,8 @@ export class MessageController { summary: '메일 상세 — 조회만으로 읽음 처리하지 않는다', description: 'htmlBody 는 서버가 정화한 HTML 이다. 클라이언트는 격리된 iframe 에만 그린다. ' + - '정화 한도(너무 길거나 깊게 중첩된 HTML)를 넘으면 htmlBody 는 null 이다 — textBody 를 보여 준다.', + '정화 한도(너무 길거나 깊게 중첩된 HTML)를 넘으면 htmlBody 는 null, isHtmlBodyOmitted 는 true 다 — textBody 를 보여 준다. ' + + `보여 줄 본문(htmlBody — null 이거나 공백뿐이면 textBody)이 읽기 한도(본문 한 부분 UTF-8 ${MAX_BODY_VALUE_BYTES.toLocaleString('ko-KR')}바이트)에서 잘렸으면 isBodyTruncated 가 true 다.`, }) @ApiStandardResponse(MessageDetailDto, { description: '메일 상세' }) @ApiErrorResponse('RES_001', '없는 메일') @@ -143,8 +150,10 @@ export class MessageController { description: 'Idempotency-Key 가 같으면 한 번만 보낸다 — 이미 보냈으면 그 결과(201 { id })를 그대로 돌려준다. ' + '같은 키인데 내용이 다르면 409 MAIL_005, 같은 키가 처리 중이면 429 REQ_001. ' + - '서식 본문(bodyHtml)이 있으면 정화한 뒤 body(텍스트)와 함께 multipart/alternative 로 보낸다 — ' + - `너무 깊게 중첩됐거나(정화 전 검사) 정화한 결과가 UTF-8 ${MAX_BODY_VALUE_BYTES.toLocaleString('ko-KR')}바이트를 넘으면 400 VAL_001 (엔진에 묻기 전). ` + + '서식 본문(bodyHtml)이 있으면 정화한 뒤 body(텍스트)와 함께 multipart/alternative 로 보낸다. ' + + `본문이 너무 길면 400 MAIL_007 — body ${MAX_BODY_LENGTH.toLocaleString('ko-KR')}자·bodyHtml ${MAX_BODY_HTML_LENGTH.toLocaleString('ko-KR')}자 초과(JS 문자열 길이, UTF-16 단위), ` + + `bodyHtml 이 너무 깊게 중첩됨(정화 전 검사)·정화한 결과가 UTF-8 ${MAX_BODY_VALUE_BYTES.toLocaleString('ko-KR')}바이트 초과. ` + + '엔진에 묻기 전이라 메일은 나가지 않는다 (고친 뒤 같은 키로 다시 보내면 된다). ' + '이 요청만 JSON 본문 4MB 까지 받는다 (다른 API 는 1MB, 넘으면 413 FILE_002).', }) @ApiHeader({ @@ -160,7 +169,11 @@ export class MessageController { }) @ApiErrorResponse( 'MAIL_002', - `MAIL_002 받는 사람 주소 · MAIL_003 메일함 용량 · MAIL_004 첨부를 찾을 수 없음(만료된 업로드는 다시 올리고, 지워진 원본 첨부는 뺀다) · MAIL_006 첨부 ${MAX_ATTACHMENTS_PER_MESSAGE}개 초과 · VAL_001 형식·Idempotency-Key 형식·서식 본문 한도(중첩·정화 뒤 크기) · FILE_001 첨부 형식 (모두 400)`, + `MAIL_002 받는 사람 주소 · MAIL_003 메일함 용량 · MAIL_004 첨부를 찾을 수 없음(만료된 업로드는 다시 올리고, 지워진 원본 첨부는 뺀다) · MAIL_006 첨부 ${MAX_ATTACHMENTS_PER_MESSAGE}개 초과 · VAL_001 형식(제목 ${MAX_SUBJECT_LENGTH}자 초과 포함)·Idempotency-Key 형식 · FILE_001 첨부 형식 (모두 400)`, + ) + @ApiErrorResponse( + 'MAIL_007', + `MAIL_007 본문이 너무 김 (400) — body ${MAX_BODY_LENGTH.toLocaleString('ko-KR')}자·bodyHtml ${MAX_BODY_HTML_LENGTH.toLocaleString('ko-KR')}자 초과(JS 문자열 길이), bodyHtml 의 정화 전 비용·정화 뒤 UTF-8 ${MAX_BODY_VALUE_BYTES.toLocaleString('ko-KR')}바이트 초과. 메일은 나가지 않았다`, ) @ApiErrorResponse( 'MAIL_005', diff --git a/backend/test/app.e2e-spec.ts b/backend/test/app.e2e-spec.ts index 327d470..de24a1d 100644 --- a/backend/test/app.e2e-spec.ts +++ b/backend/test/app.e2e-spec.ts @@ -12,6 +12,11 @@ import { DataSource } from 'typeorm'; import { AppModule } from '@/app.module'; import { configureApp } from '@/app.setup'; import { AUTH_SETTINGS, type AuthSettings } from '@/modules/auth/auth.settings'; +import { + MAX_BODY_HTML_LENGTH, + MAX_BODY_LENGTH, + MAX_SUBJECT_LENGTH, +} from '@/modules/mail/mail.constants'; import { MAIL_PROVIDER } from '@/modules/mail/mail.provider'; import { MAIL_SETTINGS, type MailSettings } from '@/modules/mail/mail.settings'; import { UPLOAD_DIR } from '@/modules/upload/upload.config'; @@ -537,6 +542,39 @@ describe('응답 계약 (e2e)', () => { const stored = join(UPLOAD_DIR, basename(saved?.fileName ?? '')); if (saved?.fileName && existsSync(stored)) unlinkSync(stored); }); + + it('발송 본문이 너무 길면 400 MAIL_007 — 형식 검증이 아니라 서비스가 JS 문자열 길이(UTF-16)로 센다, 제목은 그대로 VAL_001', async () => { + const accessToken = sign( + { sub: EMPLOYEE.email, typ: 'access', name: EMPLOYEE.name }, + 600, + ); + // 대역 엔진에는 발송이 없다 — 엔진에 닿으면 500 이 된다 (한도는 엔진에 묻기 전에 본다) + const send = (overrides: object) => + request(server) + .post('/api/messages') + .set('Cookie', `access_token=${accessToken}`) + .send({ + to: [{ name: null, email: 'doyun.lee@e2e.arasystem.test' }], + subject: '제목', + body: '본문', + ...overrides, + }); + + for (const overrides of [ + // 이모지 50,001개 = 100,002자 — 예전 형식 검증(MaxLength)은 50,001자로 세어 통과시켰다 + { body: '😀'.repeat(MAX_BODY_LENGTH / 2 + 1) }, + { bodyHtml: 'x'.repeat(MAX_BODY_HTML_LENGTH + 1) }, + ]) { + const res = await send(overrides).expect(400); + expect(body(res).error?.code).toBe('MAIL_007'); + expect(body(res).error?.traceId).toBe(res.headers['x-request-id']); + } + + const longSubject = await send({ + subject: '가'.repeat(MAX_SUBJECT_LENGTH + 1), + }).expect(400); + expect(body(longSubject).error?.code).toBe('VAL_001'); + }); }); /** diff --git a/flutter/lib/core/api/error_lexicon.g.dart b/flutter/lib/core/api/error_lexicon.g.dart index 8681ecb..655ab08 100644 --- a/flutter/lib/core/api/error_lexicon.g.dart +++ b/flutter/lib/core/api/error_lexicon.g.dart @@ -18,6 +18,7 @@ const Map kErrorCodeMessages = { 'MAIL_004': '첨부 파일을 찾을 수 없습니다. 해당 첨부를 빼거나 다시 올린 뒤 보내 주세요.', 'MAIL_005': '이미 보낸 메일과 내용이 달라 보내지 않았습니다. 보낸편지함을 확인한 뒤 다시 보내 주세요.', 'MAIL_006': '첨부는 한 메일에 최대 20개까지 보낼 수 있습니다.', + 'MAIL_007': '본문이 너무 길어 보낼 수 없습니다. 내용을 줄인 뒤 다시 보내 주세요.', }; /// 매핑 실패 시 사용하는 폴백 코드 diff --git a/frontend/src/shared/constants/error-codes.generated.ts b/frontend/src/shared/constants/error-codes.generated.ts index 3fc9156..f614c41 100644 --- a/frontend/src/shared/constants/error-codes.generated.ts +++ b/frontend/src/shared/constants/error-codes.generated.ts @@ -18,6 +18,7 @@ export const ERROR_CODE_MESSAGES = { MAIL_004: '첨부 파일을 찾을 수 없습니다. 해당 첨부를 빼거나 다시 올린 뒤 보내 주세요.', MAIL_005: '이미 보낸 메일과 내용이 달라 보내지 않았습니다. 보낸편지함을 확인한 뒤 다시 보내 주세요.', MAIL_006: '첨부는 한 메일에 최대 20개까지 보낼 수 있습니다.', + MAIL_007: '본문이 너무 길어 보낼 수 없습니다. 내용을 줄인 뒤 다시 보내 주세요.', } as const export type ErrorCode = keyof typeof ERROR_CODE_MESSAGES