;
+ expect(draft).not.toHaveProperty('htmlBody');
+ expect(draft.bodyValues).toEqual({
+ body: { value: '도윤님, 확인했습니다.' },
+ });
+ });
+
it('중복 방지 Message-ID 와 내용 지문을 초안에 싣는다 (지문은 사용자 헤더)', async () => {
const { provider, recorded } = fakeClient([prepared, fits, sent]);
diff --git a/backend/src/modules/mail/jmap/jmap-mail.provider.ts b/backend/src/modules/mail/jmap/jmap-mail.provider.ts
index c2cf9ef..ae0ef12 100644
--- a/backend/src/modules/mail/jmap/jmap-mail.provider.ts
+++ b/backend/src/modules/mail/jmap/jmap-mail.provider.ts
@@ -5,6 +5,7 @@ import { openAsBlob } from 'fs';
import { BusinessException } from '@/common/errors/business.exception';
import { resolveAttachmentsTotalLimit } from '@/modules/mail/attachment.policy';
+import { MAX_BODY_VALUE_BYTES } from '@/modules/mail/mail.constants';
import type { MailProvider } from '@/modules/mail/mail.provider';
import { MAIL_SETTINGS, type MailSettings } from '@/modules/mail/mail.settings';
import type {
@@ -87,9 +88,6 @@ const USAGE_SCAN_PAGE_SIZE = 500;
const USAGE_SCAN_MAX_EMAILS = 50_000;
const USAGE_CACHE_TTL_MS = 5 * 60_000;
-/** 상세 조회 때 본문 값을 읽어 오는 상한 — 넘으면 엔진이 잘라서 준다 */
-const MAX_BODY_VALUE_BYTES = 1_000_000;
-
/** References 헤더에 이어 붙이는 Message-ID 개수 상한 (긴 스레드에서 헤더가 끝없이 커지지 않게) */
const MAX_REFERENCES = 20;
@@ -332,6 +330,8 @@ export class JmapMailProvider implements MailProvider {
bodyProperties: BODY_PART_PROPERTIES,
fetchHTMLBodyValues: true,
fetchTextBodyValues: true,
+ // 본문 한 부분을 이 바이트까지 읽는다 — 넘는 부분은 엔진이 잘라서 준다.
+ // 우리가 보낸 서식 본문은 발송 때 정화 결과를 같은 바이트 수로 막았으므로 잘리지 않는다 (mail.constants.ts)
maxBodyValueBytes: MAX_BODY_VALUE_BYTES,
},
'g',
@@ -514,8 +514,15 @@ export class JmapMailProvider implements MailProvider {
...(message.bcc.length ? { bcc: message.bcc.map(toJmapAddress) } : {}),
subject: message.subject,
...(thread ?? {}),
- bodyValues: { body: { value: message.body } },
+ // 서식 본문이 있으면 textBody + htmlBody → 엔진이 multipart/alternative 로 만든다 (RFC 8621 §4.6)
+ bodyValues: {
+ body: { value: message.body },
+ ...(message.htmlBody ? { html: { value: message.htmlBody } } : {}),
+ },
textBody: [{ partId: 'body', type: 'text/plain' }],
+ ...(message.htmlBody
+ ? { htmlBody: [{ partId: 'html', type: 'text/html' }] }
+ : {}),
...(message.attachments.length
? {
attachments: message.attachments.map((attachment) => ({
diff --git a/backend/src/modules/mail/mail.constants.ts b/backend/src/modules/mail/mail.constants.ts
index 14c0b18..9f23a8b 100644
--- a/backend/src/modules/mail/mail.constants.ts
+++ b/backend/src/modules/mail/mail.constants.ts
@@ -14,9 +14,25 @@ export const MAX_RECIPIENTS = 100;
/** 제목 최대 길이 */
export const MAX_SUBJECT_LENGTH = 255;
-/** 본문(일반 텍스트) 최대 길이 */
+/** 본문(일반 텍스트) 최대 길이 — 서식 본문이 있으면 그 대체 본문이다 */
export const MAX_BODY_LENGTH = 100_000;
+/** 서식 본문(bodyHtml) 최대 길이 — 서식 편집기가 만든 HTML (계약 2절) */
+export const MAX_BODY_HTML_LENGTH = 500_000;
+
+/**
+ * 본문 한 부분을 읽는 상한 (UTF-8 바이트) — 상세 조회는 이 크기까지 읽고, 넘는 부분은 엔진이 잘라서 준다.
+ * 보내는 서식 본문은 **정화한 결과**가 이 크기를 넘으면 보내지 않는다(VAL_001) — 우리 웹메일이 다시 읽을 때
+ * (보낸편지함·받는 사람) 표시 없이 잘리지 않게. 500,000자 한도만으로는 부족하다: 정화가 `&` 를 `&` 로 늘리고
+ * 한글·이모지는 글자당 3~4바이트라 같은 글자 수가 2MB 를 넘을 수 있다.
+ */
+export const MAX_BODY_VALUE_BYTES = 2_000_000;
+
+/**
+ * 메일 라우트 경로 (`/api/messages`) — 컨트롤러와, 발송 요청만 본문 한도를 늘리는 파서(app.setup.ts)가 같이 쓴다.
+ */
+export const MESSAGES_ROUTE = 'messages';
+
/** 검색어 최대 길이 */
export const MAX_SEARCH_LENGTH = 200;
diff --git a/backend/src/modules/mail/mail.service.spec.ts b/backend/src/modules/mail/mail.service.spec.ts
index 55de9ab..da84067 100644
--- a/backend/src/modules/mail/mail.service.spec.ts
+++ b/backend/src/modules/mail/mail.service.spec.ts
@@ -2,6 +2,8 @@ import { Logger } from '@nestjs/common';
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 { MailService } from './mail.service';
import type { MailProvider } from './mail.provider';
import type { MailSettings } from './mail.settings';
@@ -108,6 +110,7 @@ describe('MailService', () => {
let provider: ProviderMocks;
let service: MailService;
let logSpy: jest.SpyInstance;
+ let warnSpy: jest.SpyInstance;
beforeEach(() => {
provider = fakeProvider();
@@ -115,7 +118,9 @@ describe('MailService', () => {
logSpy = jest
.spyOn(Logger.prototype, 'log')
.mockImplementation(() => undefined);
- jest.spyOn(Logger.prototype, 'warn').mockImplementation(() => undefined);
+ warnSpy = jest
+ .spyOn(Logger.prototype, 'warn')
+ .mockImplementation(() => undefined);
});
afterEach(() => jest.restoreAllMocks());
@@ -310,6 +315,48 @@ describe('MailService', () => {
htmlBody: '본문
',
});
});
+
+ it('정화 한도를 넘는 HTML(깊은 중첩·이어 붙여 너무 긴 본문)은 정화하지 않고 텍스트 본문만 내려준다 — 내장 이미지도 읽지 않는다', async () => {
+ const logo = {
+ attachmentId: 'blob-logo',
+ cid: 'logo@x',
+ type: 'image/png',
+ size: 3,
+ };
+ const image = '
';
+ for (const rawHtmlBody of [
+ // 깊은 중첩 2MB — 정화하면 3분 가까이 이벤트 루프가 멈춘다
+ image +
+ ''.repeat(Math.floor((MAX_HTML_PARSE_LENGTH - image.length) / 3)),
+ // HTML 부분 여럿을 이어 붙여 길이 한도를 넘은 본문 (2,250,000자 남짓)
+ image + '본문
'.repeat(MAX_HTML_PARSE_LENGTH / 8),
+ ]) {
+ provider.getMessage.mockResolvedValue(
+ source({ rawHtmlBody, textBody: '텍스트 본문', inlineParts: [logo] }),
+ );
+
+ const started = performance.now();
+ const detail = await service.getMessage(user, 'm1');
+
+ expect(detail).toMatchObject({
+ htmlBody: null,
+ textBody: '텍스트 본문',
+ hasBlockedImages: false,
+ });
+ expect(performance.now() - started).toBeLessThan(2_000);
+ }
+ expect(provider.readAttachmentBytes).not.toHaveBeenCalled();
+
+ // 로그에는 사유와 길이만 — 본문 내용은 남기지 않는다
+ const messages = warnSpy.mock.calls.map(([message]) => String(message));
+ expect(messages).toHaveLength(2);
+ expect(messages[0]).toMatch(/사유=too-complex, 길이=\d+자/);
+ expect(messages[1]).toMatch(/사유=too-long/);
+ for (const message of messages) {
+ expect(message).not.toMatch(/||본문<|cid:/);
+ expect(message).not.toContain(user.email);
+ }
+ });
});
it('PATCH 는 하나 이상 지정해야 한다', async () => {
@@ -380,6 +427,78 @@ describe('MailService', () => {
);
});
+ it('서식 본문은 보내는 메일 허용 목록으로 정화해서 넘긴다 (스크립트·이벤트 속성·외부 이미지 제거)', async () => {
+ await service.sendMessage(
+ user,
+ sendDto({
+ body: '확인했습니다.',
+ bodyHtml:
+ '
확인
했습니다.
',
+ }),
+ );
+
+ const [, outgoing] = provider.sendMessage.mock.calls[0];
+ expect(outgoing.htmlBody).toBe(
+ '',
+ );
+ expect(outgoing.body).toBe('확인했습니다.');
+ });
+
+ it('서식 본문이 없거나 정화해서 비면 텍스트 메일로 보낸다', async () => {
+ for (const bodyHtml of [undefined, '', '']) {
+ provider.sendMessage.mockClear();
+ await service.sendMessage(user, sendDto({ bodyHtml }));
+
+ expect(provider.sendMessage.mock.calls[0][1]).not.toHaveProperty(
+ 'htmlBody',
+ );
+ }
+ });
+
+ it('서식 본문이 너무 깊게 중첩되면 정화하지 않고 400 VAL_001 — 엔진·첨부를 찾기 전에 (키가 있어도)', async () => {
+ // 500,000자 안의 '' 16만 개 — 정화하면 9초 가까이 이벤트 루프가 멈춘다
+ const bodyHtml = ''.repeat(166_666);
+ const attachments = [{ id: 'blob-1', name: '체크리스트.xlsx' }];
+
+ 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);
+ }
+ expect(provider.findSentMessage).not.toHaveBeenCalled();
+ expect(provider.sendMessage).not.toHaveBeenCalled();
+ });
+
+ it('정화한 서식 본문이 UTF-8 2,000,000바이트를 넘으면 400 VAL_001 — 다시 읽을 때 잘리지 않게 (넘지 않으면 보낸다)', async () => {
+ // '&' 는 정화하면 '&'(5바이트) — 400,000개면 정확히 한도
+ await service.sendMessage(
+ user,
+ sendDto({ bodyHtml: '&'.repeat(400_000) }),
+ );
+ const [, sent] = provider.sendMessage.mock.calls[0];
+ expect(Buffer.byteLength(sent.htmlBody ?? '', 'utf8')).toBe(
+ 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),
+ ),
+ ).toBe('VAL_001');
+ }
+ expect(provider.sendMessage).toHaveBeenCalledTimes(1);
+ expect(provider.findSentMessage).not.toHaveBeenCalled();
+ });
+
it('첨부 형식은 클라이언트 값이 아니라 이름의 확장자로 정한다', async () => {
await service.sendMessage(
user,
@@ -532,6 +651,30 @@ describe('MailService', () => {
expect(provider.sendMessage).not.toHaveBeenCalled();
});
+ it('지문에는 정화한 서식 본문이 들어간다 — 정화 뒤가 같으면 같은 내용, 서식이 바뀌면 MAIL_005', async () => {
+ const html = '확인했습니다.
';
+ const fingerprint = await fingerprintOf(sendDto({ bodyHtml: html }));
+ const [, first] = provider.sendMessage.mock.calls[0];
+ expect(first.htmlBody).toBe(html);
+ expect(fingerprint).toBe(payloadFingerprint(first, IDEMPOTENCY_KEY));
+
+ provider.sendMessage.mockClear();
+ provider.findSentMessage.mockResolvedValue({ id: 'sent-1', fingerprint });
+
+ // 정화로 지워지는 부분만 다르면 같은 메일이다
+ await expect(
+ send(sendDto({ bodyHtml: `${html}` })),
+ ).resolves.toEqual({ id: 'sent-1' });
+ // 서식이 바뀌거나(굵게 → 기울임) 서식 본문을 뺀 텍스트 메일은 다른 내용이다
+ for (const changed of [
+ sendDto({ bodyHtml: '확인했습니다.
' }),
+ sendDto(),
+ ]) {
+ expect(await codeOf(() => send(changed))).toBe('MAIL_005');
+ }
+ expect(provider.sendMessage).not.toHaveBeenCalled();
+ });
+
it('같은 키인데 내용(받는 사람·제목·본문·첨부)이 다르면 보내지 않고 409 MAIL_005', async () => {
const fingerprint = await fingerprintOf(sendDto());
provider.sendMessage.mockClear();
diff --git a/backend/src/modules/mail/mail.service.ts b/backend/src/modules/mail/mail.service.ts
index 103ea33..3c4323e 100644
--- a/backend/src/modules/mail/mail.service.ts
+++ b/backend/src/modules/mail/mail.service.ts
@@ -24,8 +24,10 @@ import {
selectInlineImages,
toDataUri,
} from './html/inline-images';
+import { checkHtmlParseBudget } from './html/html-parse-budget';
import { sanitizeMailHtml } from './html/sanitize-mail-html';
-import { MAX_RECIPIENTS } from './mail.constants';
+import { sanitizeOutgoingHtml } from './html/sanitize-outgoing-html';
+import { 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 {
@@ -64,6 +66,33 @@ function toAddress(recipient: RecipientDto): MailAddress {
return { name: name || null, email: recipient.email.trim() };
}
+/**
+ * 서식 본문 정화 — 앞뒤로 한도를 본다. 넘으면 400 VAL_001 (엔진·첨부 조회 전이다).
+ * - 정화 전: 깊게 중첩된 HTML 은 정화 자체가 이벤트 루프를 멈춘다 (html/html-parse-budget.ts)
+ * - 정화 뒤: 결과가 본문 읽기 한도(바이트)를 넘으면 보낸편지함·받는 사람 화면에서 표시 없이 잘린다 (mail.constants.ts)
+ */
+function sanitizeBodyHtml(bodyHtml: string): string {
+ const budget = checkHtmlParseBudget(bodyHtml);
+ if (!budget.ok) {
+ throw new BusinessException('VAL_001', {
+ context: {
+ reason: 'body-html-too-complex',
+ detail: budget.reason,
+ length: bodyHtml.length,
+ },
+ });
+ }
+
+ const html = sanitizeOutgoingHtml(bodyHtml);
+ const bytes = Buffer.byteLength(html, 'utf8');
+ if (bytes > MAX_BODY_VALUE_BYTES) {
+ throw new BusinessException('VAL_001', {
+ context: { reason: 'body-html-too-large', bytes },
+ });
+ }
+ return html;
+}
+
/**
* 메일 비즈니스 로직 — 메일 엔진은 `MailProvider` 포트로만 다룬다.
* 엔진과 무관한 규칙(표시 이름·정렬, HTML 정화, 받는 사람 수, 첨부 형식)은 여기서 지킨다.
@@ -133,7 +162,11 @@ export class MailService {
});
}
- /** 상세 — 조회만 한다(읽음 처리 없음). HTML 은 정화한 뒤에만 내보낸다 */
+ /**
+ * 상세 — 조회만 한다(읽음 처리 없음). HTML 은 정화한 뒤에만 내보낸다.
+ * 정화가 이벤트 루프를 오래 붙잡을 HTML(너무 길거나 깊게 중첩된 것)은 정화하지 않고 htmlBody 를 비운다 —
+ * 클라이언트는 textBody 를 보여 준다 (html/html-parse-budget.ts).
+ */
async getMessage(
user: AuthenticatedUser,
id: string,
@@ -145,6 +178,16 @@ export class MailService {
return { ...detail, htmlBody: null, hasBlockedImages: false };
}
+ // 내장 이미지를 읽기 전에 본다 — 보여 주지 않을 본문의 이미지를 엔진에서 받아 오지 않게
+ const budget = checkHtmlParseBudget(rawHtmlBody);
+ if (!budget.ok) {
+ // 본문 내용은 남기지 않는다 (.claude/rules/logging.md)
+ this.logger.warn(
+ `${traceTag()} 받은 메일 HTML 이 정화 한도를 넘어 텍스트 본문만 내려줍니다: user=${maskEmail(user.email)}, id=${id}, 사유=${budget.reason}, 길이=${rawHtmlBody.length}자`,
+ );
+ return { ...detail, htmlBody: null, hasBlockedImages: false };
+ }
+
const inlineImages = await this.loadInlineImages(
user.email,
rawHtmlBody,
@@ -271,7 +314,7 @@ export class MailService {
}
}
- /** 엔진과 무관한 발송 규칙 — 받는 사람 수(MAIL_002), 첨부 형식(FILE_001), 헤더 값 줄바꿈 */
+ /** 엔진과 무관한 발송 규칙 — 받는 사람 수(MAIL_002), 첨부 개수(MAIL_006)·형식(FILE_001), 헤더 값 줄바꿈, 서식 본문 정화(한도 VAL_001) */
private prepareOutgoing(dto: SendMessageDto): OutgoingMessage {
const to = dto.to.map(toAddress);
const cc = (dto.cc ?? []).map(toAddress);
@@ -304,12 +347,16 @@ export class MailService {
return { id: attachment.id, name, type };
});
+ // 서식 본문 — 편집기가 만드는 서식만 남긴다 (보내는 메일 전용 허용 목록). 정화해서 비면 텍스트 메일로 보낸다
+ const htmlBody = dto.bodyHtml ? sanitizeBodyHtml(dto.bodyHtml) : '';
+
return {
to,
cc,
bcc,
subject: singleLine(dto.subject),
body: dto.body,
+ ...(htmlBody ? { htmlBody } : {}),
attachments,
inReplyTo: dto.inReplyTo,
};
@@ -325,7 +372,7 @@ export class MailService {
const recipientCount =
message.to.length + message.cc.length + message.bcc.length;
this.logger.log(
- `메일 발송: from=${maskEmail(user.email)}, 받는 사람 ${recipientCount}명, 첨부 ${message.attachments.length}개${message.inReplyTo ? ', 답장' : ''}${message.messageId ? ', 중복 방지 키' : ''}`,
+ `메일 발송: from=${maskEmail(user.email)}, 받는 사람 ${recipientCount}명, 첨부 ${message.attachments.length}개${message.htmlBody ? ', 서식 본문' : ''}${message.inReplyTo ? ', 답장' : ''}${message.messageId ? ', 중복 방지 키' : ''}`,
);
return result;
}
diff --git a/backend/src/modules/mail/mail.types.ts b/backend/src/modules/mail/mail.types.ts
index e438f6c..aaef575 100644
--- a/backend/src/modules/mail/mail.types.ts
+++ b/backend/src/modules/mail/mail.types.ts
@@ -129,8 +129,13 @@ export interface OutgoingMessage {
readonly cc: MailAddress[];
readonly bcc: MailAddress[];
readonly subject: string;
- /** 일반 텍스트 본문 */
+ /** 일반 텍스트 본문 — 서식 본문이 있으면 그 대체 본문(받는 쪽이 텍스트만 볼 때) */
readonly body: string;
+ /**
+ * 서식 본문 — 서버가 정화한 HTML (html/sanitize-outgoing-html.ts). 없으면 텍스트 메일이다.
+ * 있으면 어댑터는 body 와 함께 multipart/alternative 로 보낸다.
+ */
+ readonly htmlBody?: string;
readonly attachments: OutgoingAttachment[];
/** 답장할 메일의 id — 어댑터가 스레드 헤더(In-Reply-To·References)를 채운다 */
readonly inReplyTo?: string;
diff --git a/backend/src/modules/mail/message.controller.ts b/backend/src/modules/mail/message.controller.ts
index cd7c628..e9a8d7b 100644
--- a/backend/src/modules/mail/message.controller.ts
+++ b/backend/src/modules/mail/message.controller.ts
@@ -44,6 +44,7 @@ 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 { MessageListQueryDto } from './dto/message-list-query.dto';
import { SendMessageDto } from './dto/send-message.dto';
import { UpdateMessageDto } from './dto/update-message.dto';
@@ -61,7 +62,7 @@ const ATTACHMENT_CONTENT_TYPE = 'application/octet-stream';
'AUTH_001',
'로그인 필요 (쿠키 없음·만료) — 클라이언트는 /auth/refresh 를 한 번 시도한다',
)
-@Controller('messages')
+@Controller(MESSAGES_ROUTE)
export class MessageController {
private readonly logger = new Logger(MessageController.name);
@@ -91,7 +92,8 @@ export class MessageController {
@ApiOperation({
summary: '메일 상세 — 조회만으로 읽음 처리하지 않는다',
description:
- 'htmlBody 는 서버가 정화한 HTML 이다. 클라이언트는 격리된 iframe 에만 그린다.',
+ 'htmlBody 는 서버가 정화한 HTML 이다. 클라이언트는 격리된 iframe 에만 그린다. ' +
+ '정화 한도(너무 길거나 깊게 중첩된 HTML)를 넘으면 htmlBody 는 null 이다 — textBody 를 보여 준다.',
})
@ApiStandardResponse(MessageDetailDto, { description: '메일 상세' })
@ApiErrorResponse('RES_001', '없는 메일')
@@ -140,7 +142,10 @@ export class MessageController {
'메일 발송 (답장: inReplyTo · 전달: 원본 첨부 id 를 attachments 에)',
description:
'Idempotency-Key 가 같으면 한 번만 보낸다 — 이미 보냈으면 그 결과(201 { id })를 그대로 돌려준다. ' +
- '같은 키인데 내용이 다르면 409 MAIL_005, 같은 키가 처리 중이면 429 REQ_001.',
+ '같은 키인데 내용이 다르면 409 MAIL_005, 같은 키가 처리 중이면 429 REQ_001. ' +
+ '서식 본문(bodyHtml)이 있으면 정화한 뒤 body(텍스트)와 함께 multipart/alternative 로 보낸다 — ' +
+ `너무 깊게 중첩됐거나(정화 전 검사) 정화한 결과가 UTF-8 ${MAX_BODY_VALUE_BYTES.toLocaleString('ko-KR')}바이트를 넘으면 400 VAL_001 (엔진에 묻기 전). ` +
+ '이 요청만 JSON 본문 4MB 까지 받는다 (다른 API 는 1MB, 넘으면 413 FILE_002).',
})
@ApiHeader({
name: 'Idempotency-Key',
@@ -155,13 +160,16 @@ 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 형식·Idempotency-Key 형식·서식 본문 한도(중첩·정화 뒤 크기) · FILE_001 첨부 형식 (모두 400)`,
)
@ApiErrorResponse(
'MAIL_005',
'같은 Idempotency-Key 로 이미 보낸 메일과 내용이 다르다 — 보내지 않았다 (보낸편지함 확인 후 새 키로)',
)
- @ApiErrorResponse('FILE_002', '첨부 합계 크기 초과')
+ @ApiErrorResponse(
+ 'FILE_002',
+ '첨부 합계 크기 초과 · JSON 본문 4MB 초과 (앞단 nginx 도 이 경로만 4MB — 넘으면 백엔드에 닿기 전에 같은 413 FILE_002)',
+ )
@ApiErrorResponse(
'REQ_001',
'같은 Idempotency-Key 의 발송이 처리 중(또는 엔진 장애 직후 30초) · 메일 엔진 발송 한도',
diff --git a/backend/src/modules/mail/send-idempotency.spec.ts b/backend/src/modules/mail/send-idempotency.spec.ts
index 9d6058e..33256c1 100644
--- a/backend/src/modules/mail/send-idempotency.spec.ts
+++ b/backend/src/modules/mail/send-idempotency.spec.ts
@@ -1,4 +1,4 @@
-import { createHash, createHmac } from 'crypto';
+import { createHash, createHmac, hkdfSync } from 'crypto';
import {
idempotentMessageId,
@@ -108,6 +108,7 @@ describe('payloadFingerprint (v2 — Idempotency-Key 로 만든 HMAC)', () => {
['본문', { body: '고친 본문' }],
['첨부', { attachments: [message.attachments[0]] }],
['답장 대상', { inReplyTo: 'eaaaaab' }],
+ ['서식 본문(없던 것이 생김)', { htmlBody: '확인했습니다.
' }],
];
it.each(changes)('%s 이 바뀌면 지문이 달라진다', (_label, change) => {
@@ -116,6 +117,56 @@ describe('payloadFingerprint (v2 — Idempotency-Key 로 만든 HMAC)', () => {
);
});
+ it('서식 본문이 바뀌면 지문이 달라지고, 줄바꿈 형식만 다르면 같다', () => {
+ const withHtml = {
+ ...message,
+ htmlBody: '확인했습니다.
\n감사합니다.
',
+ };
+
+ expect(
+ payloadFingerprint(
+ { ...withHtml, htmlBody: '확인했습니다.
\n고맙습니다.
' },
+ KEY,
+ ),
+ ).not.toBe(payloadFingerprint(withHtml, KEY));
+ expect(
+ payloadFingerprint(
+ { ...withHtml, htmlBody: '확인했습니다.
\r\n감사합니다.
' },
+ KEY,
+ ),
+ ).toBe(payloadFingerprint(withHtml, KEY));
+ });
+
+ it('서식 본문이 없으면 지문 입력은 서식 본문이 생기기 전과 같다 — 배포 전에 보낸 텍스트 메일의 재시도도 같은 지문', () => {
+ // v2 정의를 그대로 다시 계산한다: HMAC-SHA256(HKDF(키, 용도 라벨), 8항목 JSON)
+ const hmacKey = Buffer.from(
+ hkdfSync(
+ 'sha256',
+ KEY,
+ Buffer.alloc(0),
+ 'arasystem/mail-payload-fingerprint/v2',
+ 32,
+ ),
+ );
+ const canonical = JSON.stringify([
+ 'v2',
+ ['doyun.lee@arasystem.test', 'seojun.park@arasystem.test'],
+ [],
+ ['secret.boss@arasystem.test'],
+ message.subject,
+ message.body,
+ ['blob-a', 'blob-b'],
+ null,
+ ]);
+
+ expect(payloadFingerprint(message, KEY)).toBe(
+ `v2:${createHmac('sha256', hmacKey).update(canonical).digest('hex')}`,
+ );
+ expect(payloadFingerprint({ ...message, htmlBody: '' }, KEY)).toBe(
+ payloadFingerprint(message, KEY),
+ );
+ });
+
it('형식은 v2:', () => {
expect(payloadFingerprint(message, KEY)).toMatch(/^v2:[0-9a-f]{64}$/);
});
diff --git a/backend/src/modules/mail/send-idempotency.ts b/backend/src/modules/mail/send-idempotency.ts
index c8dcec0..2a07861 100644
--- a/backend/src/modules/mail/send-idempotency.ts
+++ b/backend/src/modules/mail/send-idempotency.ts
@@ -63,12 +63,24 @@ export function idempotentMessageId(user: string, key: string): string {
return `ara.${token}@${domain}`;
}
-/** 지문에 넣는 내용 — 받는 사람·참조·숨은 참조·제목·본문·첨부 id·답장 대상 */
+/** 지문에 넣는 내용 — 받는 사람·참조·숨은 참조·제목·본문·서식 본문(정화한 것)·첨부 id·답장 대상 */
export type FingerprintSource = Pick<
OutgoingMessage,
- 'to' | 'cc' | 'bcc' | 'subject' | 'body' | 'attachments' | 'inReplyTo'
+ | 'to'
+ | 'cc'
+ | 'bcc'
+ | 'subject'
+ | 'body'
+ | 'htmlBody'
+ | 'attachments'
+ | 'inReplyTo'
>;
+/** 줄바꿈을 LF 로 맞춘다 — 같은 내용을 다른 줄바꿈 형식으로 다시 보낸 재시도도 같은 지문이 되게 */
+function normalizeNewlines(text: string): string {
+ return text.replace(/\r\n?/g, '\n');
+}
+
function addresses(list: readonly MailAddress[]): string[] {
return list.map((address) => address.email.trim().toLowerCase()).sort();
}
@@ -91,6 +103,8 @@ function fingerprintKey(idempotencyKey: string): Buffer {
* 그냥 해시를 쓰면 받은 사람이 숨은 참조 후보를 넣어 맞춰 볼 수 있다 — 키를 모르면 맞춰 볼 수 없다 (파일 머리말).
*
* 정규화: 주소는 소문자·정렬(순서만 바꾼 것은 같은 메일), 표시 이름은 넣지 않는다, 본문 줄바꿈은 LF 로, 첨부는 id 만 정렬.
+ * 서식 본문(정화한 HTML)은 **있을 때만** 끝에 붙인다 — 텍스트 메일의 지문 입력은 서식 본문이 생기기 전과 같아서,
+ * 배포 전에 보낸 메일을 같은 키로 다시 보내도 MAIL_005 로 오판하지 않는다 (그래서 버전을 올리지 않았다).
*/
export function payloadFingerprint(
message: FingerprintSource,
@@ -102,9 +116,10 @@ export function payloadFingerprint(
addresses(message.cc),
addresses(message.bcc),
message.subject,
- message.body.replace(/\r\n?/g, '\n'),
+ normalizeNewlines(message.body),
message.attachments.map((attachment) => attachment.id).sort(),
message.inReplyTo ?? null,
+ ...(message.htmlBody ? [normalizeNewlines(message.htmlBody)] : []),
]);
const digest = createHmac('sha256', fingerprintKey(idempotencyKey))
.update(canonical)
diff --git a/backend/test/app.e2e-spec.ts b/backend/test/app.e2e-spec.ts
index d16c55e..327d470 100644
--- a/backend/test/app.e2e-spec.ts
+++ b/backend/test/app.e2e-spec.ts
@@ -250,6 +250,44 @@ describe('응답 계약 (e2e)', () => {
expect(body(res).error?.traceId).toBe(res.headers['x-request-id']);
});
+ it('발송(POST /api/messages)만 JSON 본문 4MB 까지 받는다 — 2MB 발송 본문은 파서를 지나 인증 검사에 닿고, 4MB 를 넘으면 413 FILE_002', async () => {
+ const parsed = await request(server)
+ .post('/api/messages')
+ .set('Content-Type', 'application/json')
+ .send(JSON.stringify({ body: '', bodyHtml: 'x'.repeat(2_000_000) }))
+ .expect(401);
+ expect(body(parsed).error?.code).toBe('AUTH_001');
+
+ const tooLarge = await request(server)
+ .post('/api/messages')
+ .set('Content-Type', 'application/json')
+ .send(JSON.stringify({ body: '', bodyHtml: 'x'.repeat(4_300_000) }))
+ .expect(413);
+ expect(body(tooLarge).error?.code).toBe('FILE_002');
+ expect(body(tooLarge).error?.traceId).toBe(
+ tooLarge.headers['x-request-id'],
+ );
+ });
+
+ it('다른 API 는 그대로 1MB — 같은 2MB 본문이 로그인·메일 수정(PATCH /api/messages/:id)에서는 413 FILE_002', async () => {
+ const twoMegabytes = JSON.stringify({
+ email: 'x',
+ password: 'y'.repeat(2_000_000),
+ });
+
+ for (const [method, url] of [
+ ['post', '/api/auth/login'],
+ ['patch', '/api/messages/eaaaaab'],
+ ] as const) {
+ const res = await request(server)
+ [method](url)
+ .set('Content-Type', 'application/json')
+ .send(twoMegabytes)
+ .expect(413);
+ expect(body(res).error?.code).toBe('FILE_002');
+ }
+ });
+
it('형식이 맞는 x-request-id 는 이어받고, 그 밖의 값은 새로 발급한다', async () => {
const kept = await request(server)
.get('/api/users/999999')