chore(docker): 메일 엔진·네트워크 분리·같은 주소 배포와 Node 24 고정 #1

Merged
ttipo merged 52 commits from feat/mail-module into main 2026-10-30 12:50:48 +09:00
18 changed files with 654 additions and 91 deletions
Showing only changes of commit fbfad8d0ca - Show all commits
+7
View File
@@ -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<string, ErrorCodeDefinition>;
export const ERROR_CODES = {
@@ -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],
@@ -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: '<p>x</p>' }))).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: ['<p>x</p>'] }))).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(['<p>x</p>']))).toContain('isString');
});
});
@@ -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:
'<div style="font-family:\'맑은 고딕\', sans-serif;font-size:14px"><p>도윤님, 확인했습니다.</p></div>',
maxLength: MAX_BODY_HTML_LENGTH,
})
@IsOptional()
@IsString({ message: '서식 본문 형식이 올바르지 않습니다.' })
@MaxLength(MAX_BODY_HTML_LENGTH, {
message: `서식 본문은 ${MAX_BODY_HTML_LENGTH.toLocaleString('ko-KR')}자 이하여야 합니다.`,
})
bodyHtml?: string;
@ApiPropertyOptional({
@@ -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) {
@@ -223,6 +223,39 @@ describe('sanitizeOutgoingHtml (보내는 서식 본문)', () => {
'<span style="color:#abc;background-color:rgba(0, 0, 0, 0.5)">a</span>',
);
});
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(`<p><span style="${style}">숨은 글</span></p>`)).toBe(
'<p><span>숨은 글</span></p>',
);
}
// 하한 위의 값과, 투명해도 글을 숨기지 않는 배경 색·테두리 색은 남긴다
expect(
clean(
'<p><span style="font-size: 8px; color: rgba(0, 0, 0, .3); background-color: rgba(0, 0, 0, 0)">글</span></p>' +
'<table style="border: 1px solid rgba(0, 0, 0, 0)"><tbody><tr><td style="border-color: rgba(0, 0, 0, 0)">칸</td></tr></tbody></table>',
),
).toBe(
'<p><span style="font-size:8px;color:rgba(0, 0, 0, .3);background-color:rgba(0, 0, 0, 0)">글</span></p>' +
'<table style="border:1px solid rgba(0, 0, 0, 0)"><tbody><tr><td style="border-color:rgba(0, 0, 0, 0)">칸</td></tr></tbody></table>',
);
});
it('받은 메일 정화에는 이 하한이 없다 — 받은 뉴스레터의 작은 글자·투명한 글자 색은 그대로 둔다', () => {
const received =
'<p><span style="font-size:1px;color:rgba(0, 0, 0, 0)">작은 글</span></p>';
expect(sanitizeMailHtml(received).html).toBe(received);
expect(clean(received)).toBe('<p><span>작은 글</span></p>');
});
});
/**
@@ -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],
@@ -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<string, string[]> = {
// 모두 처음부터 끝까지(^…$) 맞아야 한다. 값에 쓸 수 있는 글자가 정해져 있어 함수·주석·이스케이프가 끼어들 수 없다.
// 형식(단위·글꼴 목록·색 표기)은 엄격하지만 값의 범위는 쓰기 화면의 메뉴(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<string, RegExp[]> = {
color: [COLOR_VALUE],
color: [TEXT_COLOR_VALUE],
'background-color': [COLOR_VALUE],
'font-family': [FONT_FAMILY_VALUE],
'font-size': [FONT_SIZE_VALUE],
@@ -608,7 +608,9 @@ describe('JmapMailProvider', () => {
messageId: 'abc@arasystem.test',
references: [],
rawHtmlBody: '<p>HTML 본문</p><img src="cid:logo@x">',
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: '<p>HTML 앞부분', isTruncated: true },
},
html: true,
text: false,
},
{
name: '텍스트 부분이 잘렸다',
bodyValues: {
'1': { value: '텍스트 앞부분', isTruncated: true },
'2': { value: '<p>HTML 본문</p>', isTruncated: false },
},
html: false,
text: true,
},
{
name: '잘린 부분이 없다 (isTruncated 가 없는 값 포함)',
bodyValues: {
'1': { value: '텍스트 본문' },
'2': { value: '<p>HTML 본문</p>', 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: '<p>첫 부분</p>', isTruncated: false },
'5': { value: '<p>둘째 부분', 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: '<p>둘째 부분</p>', 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('<p>첫 부분</p>\n<p>둘째 부분');
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']],
@@ -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,
},
+20 -5
View File
@@ -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,
};
+8 -5
View File
@@ -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자 한도만으로는 부족하다: 정화가 `&` 를 `&amp;` 로 늘리고
* 본문 한 부분을 읽는 상한 (UTF-8 바이트) — 상세 조회는 이 크기까지 읽고, 넘는 부분은 엔진이 잘라서 준다(그때 isBodyTruncated).
* 보내는 서식 본문은 **정화한 결과**가 이 크기를 넘으면 보내지 않는다(MAIL_007) — 우리 웹메일이 다시 읽을 때
* (보낸편지함·받는 사람) 잘리지 않게. 500,000자 한도만으로는 부족하다: 정화가 `&` 를 `&amp;` 로 늘리고
* 한글·이모지는 글자당 3~4바이트라 같은 글자 수가 2MB 를 넘을 수 있다.
*/
export const MAX_BODY_VALUE_BYTES = 2_000_000;
+271 -28
View File
@@ -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<unknown>,
): 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> = {},
): MessageDetailSource => ({
@@ -93,12 +112,17 @@ const source = (
messageId: 'abc@x',
references: [],
rawHtmlBody: null,
isRawHtmlBodyTruncated: false,
textBody: '본문',
isTextBodyTruncated: false,
attachments: [],
inlineParts: [],
...overrides,
});
/** 정화 비용 한도를 넘는 HTML — 500,000자 안의 '<b>' 16만 개 (정화하면 9초 가까이 이벤트 루프가 멈춘다) */
const TOO_DEEP_HTML = '<b>'.repeat(166_666);
const sendDto = (overrides: Partial<SendMessageDto> = {}): 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: '<p>본문</p>',
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: '<p>앞부분',
html: true,
text: false,
truncated: true,
},
{
name: 'HTML 을 정화 한도로 비웠고 텍스트가 잘렸다',
rawHtmlBody: TOO_DEEP_HTML,
html: false,
text: true,
truncated: true,
},
{
name: 'HTML 을 보여 주고 텍스트만 잘렸다',
rawHtmlBody: '<p>본문</p>',
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: '<script>x()</script>',
html: false,
text: true,
truncated: true,
},
{
name: '정화한 HTML 이 비었고 HTML 만 잘렸다',
rawHtmlBody: '<script>x()</script>',
html: true,
text: false,
truncated: false,
},
{
name: '잘린 부분이 없다',
rawHtmlBody: '<p>본문</p>',
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자 안의 '<b>' 16만 개 — 정화하면 9초 가까이 이벤트 루프가 멈춘다
const bodyHtml = '<b>'.repeat(166_666);
it('본문이 너무 길면 400 MAIL_007 — 엔진·첨부·중복 방지 잠금 전에 (키가 있어도), 로그 사유는 경우마다 다르다', async () => {
const attachments = [{ id: 'blob-1', name: '체크리스트.xlsx' }];
const cases: [Partial<SendMessageDto>, 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'],
// '&' 는 정화하면 '&amp;'(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 () => {
// '&' 는 정화하면 '&amp;'(5바이트) — 400,000개면 정확히 한도
it('깊게 중첩된 서식 본문은 정화하지 않고 바로 막는다 (정화하면 9초 가까이 걸린다)', async () => {
// CPU 시간 (test/cpu-time.ts) — 지금 수십 ms (비용 검사가 멈춘다). 정화했다면 '<b>' 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: '<b>'.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 + '&amp;' 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' });
+60 -16
View File
@@ -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<MessageDetail> {
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,
+17 -1
View File
@@ -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[];
}
+18 -5
View File
@@ -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',
+38
View File
@@ -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');
});
});
/**
@@ -18,6 +18,7 @@ const Map<String, String> kErrorCodeMessages = {
'MAIL_004': '첨부 파일을 찾을 수 없습니다. 해당 첨부를 빼거나 다시 올린 뒤 보내 주세요.',
'MAIL_005': '이미 보낸 메일과 내용이 달라 보내지 않았습니다. 보낸편지함을 확인한 뒤 다시 보내 주세요.',
'MAIL_006': '첨부는 한 메일에 최대 20개까지 보낼 수 있습니다.',
'MAIL_007': '본문이 너무 길어 보낼 수 없습니다. 내용을 줄인 뒤 다시 보내 주세요.',
};
/// 매핑 실패 시 사용하는 폴백 코드
@@ -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