From 6fd0911b86b64210303787dff5a0ea47039e0000 Mon Sep 17 00:00:00 2001
From: ttipo
Date: Thu, 29 Oct 2026 08:37:51 +0900
Subject: [PATCH] =?UTF-8?q?feat(backend):=20=EC=9D=B4=EB=AF=B8=20=EB=B0=9B?=
=?UTF-8?q?=EC=9D=80=20=EB=A9=94=EC=9D=BC=EC=97=90=20=ED=95=84=ED=84=B0=20?=
=?UTF-8?q?=EC=A0=81=EC=9A=A9=20POST=20/api/mail-filter-runs=20=C2=B7=20?=
=?UTF-8?q?=EB=B6=80=EC=9E=AC=EC=A4=91=20isActive?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
본문의 규칙 하나로 받은편지함을 최근 메일부터 판정하고 표시 · 옮기기만 한다(전달 · 지우기 없음).
한 요청은 25초 안에서 하고 cursor 로 이어 하며, 같은 직원은 한 번에 하나다(진행 중이면 429 REQ_001).
- 판정(mail-filter-match.ts)은 엔진 Sieve(sieve-script.ts 의 FIELD_TESTS)를 그대로 옮겼다. 견줄 글은
원래 머리글(mime-text.ts 의 decodeHeaderText)과 텍스트 · HTML · 텍스트 첨부(html/html-text.ts)에서 뽑는다.
실엔진 배달 94통(HTML 75 · 머리글 19)을 표 테스트로 고정했다 — HTML5 에만 있는 이름 참조는 알려진 한계
- 이어 할 자리(cursor 2판)는 위 경계 · 남긴 마지막 메일과 그 받은 시각이다. 시작한 뒤 온 메일은 보지 않고,
그 메일이 빠지면 그 초부터 다시 읽는다 — 리뷰가 가짜 엔진으로 재현한 건너뜀을 고쳤다
- 옮기기는 읽은 상태 그대로일 때만(ifInState), 본문 값은 부분마다 256KiB · 한 통 1MiB 까지
- 오래 걸리는 처리의 시간 규칙(BulkRun)을 jmap/bulk-run.ts 로 옮겨 메일함 처리와 함께 쓴다
- 부재중 GET · PUT 응답에 isActive — 엔진에서 우리 Sieve 스크립트가 꺼져 있으면 false(본문으로는 받지 않는다)
Co-Authored-By: Claude Opus 5.5
---
.../mail/dto/mail-filter-run.dto.spec.ts | 150 ++
.../modules/mail/dto/mail-filter-run.dto.ts | 123 ++
.../src/modules/mail/dto/mail-vacation.dto.ts | 24 +-
.../src/modules/mail/html/html-text.spec.ts | 175 ++
backend/src/modules/mail/html/html-text.ts | 473 ++++++
backend/src/modules/mail/jmap/bulk-run.ts | 99 ++
.../mail/jmap/filter-run-cursor.spec.ts | 174 ++
.../modules/mail/jmap/filter-run-cursor.ts | 133 ++
.../mail/jmap/filter-run-texts.spec.ts | 1412 ++++++++++++++++
.../src/modules/mail/jmap/filter-run-texts.ts | 143 ++
.../modules/mail/jmap/jmap-filter-run.spec.ts | 1490 +++++++++++++++++
.../src/modules/mail/jmap/jmap-filter-run.ts | 773 +++++++++
.../mail/jmap/jmap-filter-script.spec.ts | 62 +-
.../modules/mail/jmap/jmap-filter-script.ts | 21 +-
.../mail/jmap/jmap-mail.provider.spec.ts | 13 +-
.../modules/mail/jmap/jmap-mail.provider.ts | 138 +-
backend/src/modules/mail/jmap/jmap-results.ts | 44 +-
backend/src/modules/mail/jmap/jmap.types.ts | 2 +
.../src/modules/mail/jmap/mime-text.spec.ts | 204 +++
backend/src/modules/mail/jmap/mime-text.ts | 183 +-
backend/src/modules/mail/jmap/sieve-script.ts | 8 +-
.../modules/mail/mail-filter-match.spec.ts | 149 ++
backend/src/modules/mail/mail-filter-match.ts | 90 +
.../modules/mail/mail-filter.controller.ts | 66 +-
backend/src/modules/mail/mail-filters.spec.ts | 58 +
backend/src/modules/mail/mail-filters.ts | 60 +-
backend/src/modules/mail/mail.constants.ts | 12 +
backend/src/modules/mail/mail.module.ts | 9 +-
backend/src/modules/mail/mail.provider.ts | 26 +-
backend/src/modules/mail/mail.service.spec.ts | 287 +++-
backend/src/modules/mail/mail.service.ts | 169 +-
backend/src/modules/mail/mail.types.ts | 51 +
.../src/modules/mail/mailbox.controller.ts | 18 +-
backend/test/app.e2e-spec.ts | 262 ++-
34 files changed, 6861 insertions(+), 240 deletions(-)
create mode 100644 backend/src/modules/mail/dto/mail-filter-run.dto.spec.ts
create mode 100644 backend/src/modules/mail/dto/mail-filter-run.dto.ts
create mode 100644 backend/src/modules/mail/html/html-text.spec.ts
create mode 100644 backend/src/modules/mail/html/html-text.ts
create mode 100644 backend/src/modules/mail/jmap/bulk-run.ts
create mode 100644 backend/src/modules/mail/jmap/filter-run-cursor.spec.ts
create mode 100644 backend/src/modules/mail/jmap/filter-run-cursor.ts
create mode 100644 backend/src/modules/mail/jmap/filter-run-texts.spec.ts
create mode 100644 backend/src/modules/mail/jmap/filter-run-texts.ts
create mode 100644 backend/src/modules/mail/jmap/jmap-filter-run.spec.ts
create mode 100644 backend/src/modules/mail/jmap/jmap-filter-run.ts
create mode 100644 backend/src/modules/mail/mail-filter-match.spec.ts
create mode 100644 backend/src/modules/mail/mail-filter-match.ts
diff --git a/backend/src/modules/mail/dto/mail-filter-run.dto.spec.ts b/backend/src/modules/mail/dto/mail-filter-run.dto.spec.ts
new file mode 100644
index 0000000..fe53d62
--- /dev/null
+++ b/backend/src/modules/mail/dto/mail-filter-run.dto.spec.ts
@@ -0,0 +1,150 @@
+import { plainToInstance } from 'class-transformer';
+import { validate, type ValidationError } from 'class-validator';
+
+import {
+ MAX_MAIL_FILTER_CONDITIONS,
+ MAX_MAIL_FILTER_RUN_CURSOR_LENGTH,
+ MAX_MAIL_FILTER_VALUE_LENGTH,
+} from '@/modules/mail/mail.constants';
+import { MailFilterRunRequestDto } from './mail-filter-run.dto';
+
+/** 걸린 규칙 이름 — 중첩된 칸(conditions.0.value · actions.flag 등)까지 */
+function constraintNames(errors: ValidationError[]): string[] {
+ return errors.flatMap((error) => [
+ ...Object.keys(error.constraints ?? {}),
+ ...constraintNames(error.children ?? []),
+ ]);
+}
+
+/** 전역 ValidationPipe 와 같은 옵션으로 변환 · 검증한다 (app.setup.ts — stopAtFirstError 만 빼고 — 걸린 규칙을 모두 본다, 통과 · 실패는 같다) */
+async function check(
+ body: object,
+): Promise<{ names: string[]; dto: MailFilterRunRequestDto }> {
+ const dto = plainToInstance(MailFilterRunRequestDto, body);
+ const errors = await validate(dto, {
+ whitelist: true,
+ forbidNonWhitelisted: true,
+ });
+ return { names: constraintNames(errors), dto };
+}
+
+const namesOf = async (body: object) => (await check(body)).names;
+
+const condition = {
+ field: 'from',
+ operator: 'contains',
+ value: '@hanbit-partners.co.kr',
+};
+const actions = { moveToMailboxId: 'j', markAsRead: true, flag: false };
+const body = (overrides: object = {}) => ({
+ match: 'any',
+ conditions: [condition],
+ actions,
+ cursor: null,
+ ...overrides,
+});
+const withCondition = (overrides: object) =>
+ body({ conditions: [{ ...condition, ...overrides }] });
+const withActions = (overrides: object) =>
+ body({ actions: { ...actions, ...overrides } });
+
+describe('이미 받은 메일에 필터 적용 본문 (POST /api/mail-filter-runs)', () => {
+ it('맞는 본문 — 처음(cursor null) · 이어 하기(cursor 글) · 모든 메일 · 옮기지 않는 할 일', async () => {
+ expect(await namesOf(body())).toEqual([]);
+ expect(await namesOf(body({ cursor: 'eyJ2IjoxfQ' }))).toEqual([]);
+ expect(
+ await namesOf(
+ body({
+ match: 'always',
+ conditions: [],
+ actions: { moveToMailboxId: null, markAsRead: false, flag: true },
+ }),
+ ),
+ ).toEqual([]);
+ });
+
+ it('찾을 글은 필터 규칙과 같은 검사 — 앞뒤 공백을 지운 값으로 검사하고 그 값을 쓴다', async () => {
+ const { names, dto } = await check(
+ withCondition({ value: ' @hanbit-partners.co.kr ' }),
+ );
+ expect(names).toEqual([]);
+ expect(dto.conditions[0].value).toBe('@hanbit-partners.co.kr');
+
+ for (const value of [
+ '',
+ ' ',
+ 'x'.repeat(MAX_MAIL_FILTER_VALUE_LENGTH + 1),
+ '광고${x}',
+ '광고\n줄바꿈',
+ 42,
+ ]) {
+ expect(await namesOf(withCondition({ value }))).not.toEqual([]);
+ }
+ expect(await namesOf(withCondition({ field: 'cc' }))).toContain('isIn');
+ expect(await namesOf(withCondition({ operator: 'equals' }))).toContain(
+ 'isIn',
+ );
+ });
+
+ it(`묶는 방법 · 조건 수 — 모르는 방법 · 조건 ${MAX_MAIL_FILTER_CONDITIONS}개 초과 · 배열이 아님은 400 (방법과 조건 수의 짝은 서비스가 본다)`, async () => {
+ expect(await namesOf(body({ match: 'none' }))).toContain('isIn');
+ expect(
+ await namesOf(
+ body({
+ conditions: Array.from(
+ { length: MAX_MAIL_FILTER_CONDITIONS + 1 },
+ () => condition,
+ ),
+ }),
+ ),
+ ).toContain('arrayMaxSize');
+ expect(await namesOf(body({ conditions: condition }))).toContain('isArray');
+ });
+
+ it('할 일 — 전달(forwardTo) 등 다른 키는 모르는 필드 · 옮길 메일함 id 형식 · 표시는 참거짓 · 객체가 아님', async () => {
+ expect(
+ await namesOf(withActions({ forwardTo: 'doyun.lee@arasystem.test' })),
+ ).toContain('whitelistValidation');
+ expect(await namesOf(withActions({ forwardTo: null }))).toContain(
+ 'whitelistValidation',
+ );
+ expect(await namesOf(withActions({ moveToMailboxId: '메일함' }))).toContain(
+ 'matches',
+ );
+ expect(await namesOf(withActions({ flag: 'yes' }))).toContain('isBoolean');
+ expect(await namesOf(body({ actions: [] }))).not.toEqual([]);
+ // 할 일이 하나도 없는 것은 형식이 아니라 칸 사이의 규칙이다 — 서비스가 VAL_001 (findFilterRunProblem)
+ expect(
+ await namesOf(
+ withActions({ moveToMailboxId: null, markAsRead: false, flag: false }),
+ ),
+ ).toEqual([]);
+ });
+
+ it(`cursor — 늘 보낸다(빠지면 400) · 글이면 ${MAX_MAIL_FILTER_RUN_CURSOR_LENGTH}자까지 base64url 글자만`, async () => {
+ const withoutCursor = Object.fromEntries(
+ Object.entries(body()).filter(([key]) => key !== 'cursor'),
+ );
+ expect(await namesOf(withoutCursor)).toContain('isString');
+ expect(
+ await namesOf(
+ body({ cursor: 'a'.repeat(MAX_MAIL_FILTER_RUN_CURSOR_LENGTH + 1) }),
+ ),
+ ).toContain('maxLength');
+ expect(await namesOf(body({ cursor: 'abc=' }))).toContain('matches');
+ expect(await namesOf(body({ cursor: '' }))).toContain('matches');
+ expect(await namesOf(body({ cursor: 7 }))).toContain('isString');
+ });
+
+ it('모르는 필드는 400 — 규칙의 id · 이름 · 켜짐 · 규칙 목록 · version 은 이 요청에 없다', async () => {
+ for (const extra of [
+ { id: 'r1' },
+ { name: '거래처' },
+ { isEnabled: true },
+ { rules: [] },
+ { version: 'v' },
+ ]) {
+ expect(await namesOf(body(extra))).toContain('whitelistValidation');
+ }
+ });
+});
diff --git a/backend/src/modules/mail/dto/mail-filter-run.dto.ts b/backend/src/modules/mail/dto/mail-filter-run.dto.ts
new file mode 100644
index 0000000..b898ae0
--- /dev/null
+++ b/backend/src/modules/mail/dto/mail-filter-run.dto.ts
@@ -0,0 +1,123 @@
+import { ApiProperty, PickType } from '@nestjs/swagger';
+import { Type } from 'class-transformer';
+import {
+ IsObject,
+ IsString,
+ Matches,
+ MaxLength,
+ ValidateIf,
+ ValidateNested,
+} from 'class-validator';
+
+import {
+ MailFilterActionsDto,
+ MailFilterRuleDto,
+} from '@/modules/mail/dto/mail-filter.dto';
+import {
+ MAIL_FILTER_RUN_CURSOR_PATTERN,
+ MAX_MAIL_FILTER_RUN_CURSOR_LENGTH,
+} from '@/modules/mail/mail.constants';
+import type {
+ MailFilterRun,
+ MailFilterRunActions,
+ MailFilterRunRequest,
+} from '@/modules/mail/mail.types';
+
+/**
+ * 이미 받은 메일에 필터 적용 — POST /api/mail-filter-runs 의 본문과 응답 (계약 2절 "이미 받은 메일에 적용" — 7단계).
+ * 묶는 방법 · 조건 · 할 일은 필터 규칙(dto/mail-filter.dto.ts)의 칸을 그대로 가져와 같은 검사를 한다(앞뒤 공백 · 길이 · `${` · 제어 문자).
+ * 칸 사이의 규칙(묶는 방법 ↔ 조건 수 · 할 일 하나 이상)과 옮길 메일함(보낸편지함 · 임시보관함 아님)은 서비스가 본다 (모두 400 VAL_001).
+ * 전달은 없다 — actions 에 forwardTo 같은 다른 키를 보내면 모르는 필드로 400 VAL_001
+ */
+
+/** null 은 "처음부터"로 받는다 — 키가 없으면(undefined) 형식 오류다 (늘 보낸다) */
+const unlessNull = (_object: object, value: unknown) => value !== null;
+
+/** 할 일 — 필터 규칙의 할 일에서 전달을 뺀 세 칸 (같은 검사) */
+export class MailFilterRunActionsDto
+ extends PickType(MailFilterActionsDto, [
+ 'moveToMailboxId',
+ 'markAsRead',
+ 'flag',
+ ] as const)
+ implements MailFilterRunActions
+{
+ @ApiProperty({
+ description:
+ '옮길 메일함 id (없으면 null) — 필터 규칙과 같은 메일함만(보낸편지함 · 임시보관함은 400 VAL_001). ' +
+ '받은편지함이거나 없는 메일함(지워짐)이면 옮기지 않고 표시만 한다',
+ nullable: true,
+ type: String,
+ example: 'j',
+ })
+ moveToMailboxId!: string | null;
+}
+
+/** POST /api/mail-filter-runs — 규칙 하나(방금 저장한 그 규칙)와 이어 할 자리 */
+export class MailFilterRunRequestDto
+ extends PickType(MailFilterRuleDto, ['match', 'conditions'] as const)
+ implements MailFilterRunRequest
+{
+ @ApiProperty({
+ description:
+ '할 일 — 하나 이상(셋 다 없으면 400 VAL_001). 읽음 · 중요 표시를 붙이고(이미 있으면 그대로) 받은편지함에서 옮길 메일함으로 옮긴다. ' +
+ '전달은 없다 — 이미 받은 메일은 전달하지 않는다',
+ type: MailFilterRunActionsDto,
+ })
+ @IsObject({ message: '할 일 형식이 올바르지 않습니다.' })
+ @ValidateNested()
+ @Type(() => MailFilterRunActionsDto)
+ actions!: MailFilterRunActionsDto;
+
+ @ApiProperty({
+ description:
+ '이어 할 자리 — 처음은 null, 이어 할 때는 앞 응답의 cursor 그대로(해석하지 않는다). 늘 보낸다. ' +
+ '다른 규칙의 cursor · 깨진 cursor 는 400 VAL_001',
+ nullable: true,
+ type: String,
+ maxLength: MAX_MAIL_FILTER_RUN_CURSOR_LENGTH,
+ example: null,
+ })
+ @ValidateIf(unlessNull)
+ @IsString({ message: '이어 할 자리를 다시 받아 주세요.' })
+ @MaxLength(MAX_MAIL_FILTER_RUN_CURSOR_LENGTH, {
+ message: '이어 할 자리를 다시 받아 주세요.',
+ })
+ @Matches(MAIL_FILTER_RUN_CURSOR_PATTERN, {
+ message: '이어 할 자리를 다시 받아 주세요.',
+ })
+ cursor!: string | null;
+}
+
+/** POST /api/mail-filter-runs 의 응답 (계약 2절의 MailFilterRun) */
+export class MailFilterRunDto implements MailFilterRun {
+ @ApiProperty({
+ description: '이번 요청에서 확인한 받은편지함 메일 수',
+ example: 500,
+ })
+ scanned!: number;
+
+ @ApiProperty({
+ description:
+ '그중 조건에 맞아 할 일을 한 메일 수 — 이미 읽음 · 중요였던 메일도 센다',
+ example: 37,
+ })
+ matched!: number;
+
+ @ApiProperty({
+ description:
+ '아직 확인하지 않은 메일 수 — 어림(그사이 들어오고 나간 메일이 있다). 진행의 전체는 지금까지 확인한 수 + 이 값',
+ example: 7_912,
+ })
+ remaining!: number;
+
+ @ApiProperty({
+ description:
+ 'null = 끝. 아니면 같은 본문에 이 cursor 를 넣어 다시 보낸다 (해석하지 않는다)',
+ nullable: true,
+ type: String,
+ example:
+ 'eyJ2IjoxLCJhIjoic2VhYWFhZXIiLCJwIjo1MDAsInIiOiJkMlZ3dHo3TjJnQlVUcmdpIn0',
+ })
+ cursor!: string | null;
+}
diff --git a/backend/src/modules/mail/dto/mail-vacation.dto.ts b/backend/src/modules/mail/dto/mail-vacation.dto.ts
index 540d57d..4143f2a 100644
--- a/backend/src/modules/mail/dto/mail-vacation.dto.ts
+++ b/backend/src/modules/mail/dto/mail-vacation.dto.ts
@@ -17,7 +17,10 @@ import {
MULTI_LINE_TEXT_PATTERN,
SINGLE_LINE_TEXT_PATTERN,
} from '@/modules/mail/mail.constants';
-import type { MailVacation } from '@/modules/mail/mail.types';
+import type {
+ MailVacation,
+ MailVacationState,
+} from '@/modules/mail/mail.types';
/** ISO 8601 UTC 시각 — 'Z' 로 끝나고 소수 초는 3자리까지 (JS toISOString 의 모양, 계약 머리말 "날짜·시각") */
const UTC_DATE_TIME = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d{1,3})?Z$/;
@@ -46,7 +49,7 @@ function IsUtcDateTime(options: ValidationOptions) {
const unlessNull = (_object: object, value: unknown) => value !== null;
/**
- * 부재중 자동 응답 — GET 응답과 PUT 본문(전체 교체)이 같은 모양이다 (계약 2절 "부재중 자동 응답").
+ * 부재중 자동 응답 — PUT 본문(전체 교체). GET · PUT 응답은 이것에 isActive 를 더한 MailVacationStateDto 다 (계약 2절 "부재중 자동 응답", 7단계).
* 켤 때 제목 · 내용이 비었는지 · 켤 때의 길이(150자 · 600자), 시작이 끝보다 앞인지는 서비스가 본다 (400 VAL_001).
* 여기서는 켬 · 끔과 상관없는 한도(4단계의 200자 · 4,000자)만 본다 — 같은 속성에 ValidateIf 를 걸면 그 속성의 검사가 모두 빠져
* 켤 때만의 한도를 함께 걸 수 없다. 서식 본문은 받지 않는다.
@@ -112,3 +115,20 @@ export class MailVacationDto implements MailVacation {
})
textBody!: string;
}
+
+/**
+ * GET · PUT /api/mail-vacation 의 응답 (계약 2절의 MailVacationState — 7단계) — 저장한 값 + 지금 동작하는지.
+ * PUT 본문에는 isActive 를 넣지 않는다 (넣으면 모르는 필드 — 400 VAL_001). 응답 문서용이라 검사 데코레이터를 더하지 않는다
+ */
+export class MailVacationStateDto
+ extends MailVacationDto
+ implements MailVacationState
+{
+ @ApiProperty({
+ description:
+ 'false = 메일 엔진에서 우리 규칙(메일 필터와 한 규칙)이 꺼져 있어 켜 둔 자동 응답이 지금은 동작하지 않는다 — 저장하면 다시 켜진다. ' +
+ '메일 필터의 isActive 와 같은 값이다(한 번도 저장하지 않은 계정은 true). PUT 본문에는 넣지 않는다',
+ example: true,
+ })
+ isActive!: boolean;
+}
diff --git a/backend/src/modules/mail/html/html-text.spec.ts b/backend/src/modules/mail/html/html-text.spec.ts
new file mode 100644
index 0000000..2e13dd6
--- /dev/null
+++ b/backend/src/modules/mail/html/html-text.spec.ts
@@ -0,0 +1,175 @@
+import { startCpuTimer } from '@/../test/cpu-time';
+import { NAMED_REFERENCE_COUNT, htmlToText } from './html-text';
+
+/**
+ * CPU 시간 한도 (벽시계가 아니다 — test/cpu-time.ts). 지금 함수는 아래 2MB 입력을 하나에 CPU 0.15초 안쪽으로 읽는다(혼자 돌 때).
+ * 한도는 넉넉히 둔다 — 무거운 테스트 파일(html-parse-budget.spec.ts 등) 다음에 같은 일꾼에서 돌면 앞 파일이 남긴 큰 힙의 GC 가
+ * 재는 사이에 들어와 2초 가까이 재졌다(2026-10-29, 전체 테스트). 막으려는 것은 입력 길이의 제곱이 되는 경로(`<` 마다 `>` 를 끝까지 다시 찾기,
+ * `&` 마다 끝까지 다시 찾기, 역추적하는 정규식) — 2MB 에서 수천 초가 걸려 이 한도를 확실히 넘는다. 입력은 재기 전에 만들어 둔다(만들기 · 펴기는 재지 않는다)
+ */
+const LINEAR_CPU_LIMIT_MS = 10_000;
+const TWO_MEGABYTES = 2_000_000;
+
+/**
+ * 엔진(Stalwart 0.16.27 Sieve `body :text`)의 글자 뽑기 규칙 — 엔진과 같은 판정인지는 jmap/filter-run-texts.spec.ts 의 W · X · Y · Z 표(인증 발송 75통)가 본다.
+ * 여기서는 규칙 하나하나가 만드는 글을 본다 (줄바꿈 = 태그 · 참조가 만든 것)
+ */
+describe('htmlToText — HTML 의 글자 부분 (계약 2절 "이미 받은 메일에 적용"의 본문 판정)', () => {
+ it('태그는 빈자리 없이 뺀다 — 인라인 태그 · div · li · 표 칸 · hr · 여는 p 도 이어 붙는다', () => {
+ expect(htmlToText('회의록')).toBe('회의록');
+ expect(htmlToText('앞
뒤
')).toBe('앞뒤');
+ expect(htmlToText('')).toBe('앞뒤');
+ expect(htmlToText('앞 | 뒤 | 앞
뒤')).toBe('앞뒤앞뒤');
+ expect(htmlToText('앞뒤 앞
뒤 앞뒤')).toBe(
+ '앞뒤 앞뒤 앞뒤',
+ );
+ });
+
+ it('
(닫는 것만) ·
(여는 것 · 닫는 것 ·
·
· 대문자)은 줄바꿈이다', () => {
+ expect(htmlToText('앞
뒤
')).toBe('앞\n뒤\n');
+ expect(htmlToText('앞
뒤')).toBe('앞\n뒤');
+ for (const br of ['
', '
', '
', '', '
', '< br >']) {
+ expect(htmlToText(`앞${br}뒤`)).toBe('앞\n뒤');
+ }
+ });
+
+ it('태그 이름은 앞의 공백 · / 와 끝의 공백 · / 를 뺀 나머지 전부 — 속성이 붙으면 알아보지 않는다', () => {
+ // 속성이 붙은 br · p 는 줄을 끊지 않는다
+ expect(htmlToText('앞
뒤')).toBe('앞뒤');
+ expect(htmlToText('앞뒤')).toBe('앞뒤');
+ // o:p 는 p 가 아니다
+ expect(htmlToText('앞뒤')).toBe('앞뒤');
+ // 속성이 붙은 style · script · head · template 은 글을 빼지 않는다
+ expect(htmlToText('뒤')).toBe('a{}뒤');
+ expect(htmlToText('제목')).toBe(
+ '제목',
+ );
+ expect(htmlToText('글')).toBe('글');
+ });
+
+ it('뒤')).toBe('뒤');
+ expect(htmlToText('보임')).toBe('보임');
+ expect(htmlToText('보임')).toBe('보임');
+ expect(htmlToText('제목본문
')).toBe(
+ '본문\n',
+ );
+ expect(htmlToText('숨김보임')).toBe('보임');
+ // 넷을 따로 센다 — 다른 이름의 닫는 태그로는 끝나지 않는다
+ expect(htmlToText('머리아직끝')).toBe('끝');
+ });
+
+ it('글을 빼는 동안에도 태그는 본다 — 그 안의 ·
도 줄바꿈이다', () => {
+ expect(htmlToText('앞뒤')).toBe('앞\n뒤');
+ expect(htmlToText('앞뒤')).toBe('앞\n뒤');
+ });
+
+ it('title · noscript · textarea · iframe · object · svg · xml 은 글이 남는다', () => {
+ for (const name of [
+ 'title',
+ 'noscript',
+ 'textarea',
+ 'iframe',
+ 'object',
+ 'svg',
+ 'xml',
+ ]) {
+ expect(htmlToText(`<${name}>글${name}>`)).toBe('글');
+ }
+ });
+
+ it('태그는 < 부터 다음 > 까지 — < 뒤가 무엇이든 태그이고, 따옴표는 보지 않는다', () => {
+ // `<광고>` 는 태그로 빠지고, 맨 앞이 된 공백은 넣지 않는다
+ expect(htmlToText('<광고> 안내
')).toBe('안내\n');
+ expect(htmlToText('
사진')).toBe('b">사진');
+ expect(htmlToText('z')).toBe(
+ '링크 회의 안내 z',
+ );
+ expect(htmlToText('<>글')).toBe('글');
+ });
+
+ it('태그 안에서 < 가 또 나오면 거기서 태그를 다시 시작한다 — 닫히지 않은 < 는 끝까지 삼킨다', () => {
+ // `< 1000원할인` 은 버려지고 `` 가 태그가 된다
+ expect(htmlToText('가격 < 1000원할인
뒤')).toBe('가격\n뒤');
+ expect(htmlToText('앞 < 뒤')).toBe('앞');
+ expect(htmlToText('앞b')).toBe('a\nb');
+ });
+
+ it(' 는 안의 글까지 뺀다 — 닫히지 않으면 끝까지. 그 밖의 (DOCTYPE · mso 조건)는 보통 태그다', () => {
+ expect(htmlToText('보임')).toBe('보임');
+ expect(htmlToText('보임')).toBe('보임');
+ expect(htmlToText('보임')).toBe('보임');
+ expect(htmlToText('글
')).toBe('글\n');
+ expect(htmlToText('앞` 까지 주석이다(안의 글까지 뺀다). 그 밖의 ``(DOCTYPE · ``)는 보통 태그다
+ * - 태그의 이름 = 태그 글에서 앞의 공백 · `/` 와 끝의 공백 · `/` 를 뺀 **나머지 전부** — ASCII 대소문자 없이 견준다.
+ * 닫는 태그 = 첫 공백 아닌 글자가 `/`. 속성이 붙으면 이름이 아니다 — `
` · `` · `` 는 알아본다
+ * - 알아보는 이름: `br`(여는 것 · 닫는 것) → 줄바꿈, `p` 의 닫는 것만 → 줄바꿈(여는 `` · `
` 는 아무것도 아니다),
+ * `style` · `script` · `head` · `template` → 여는 것부터 같은 이름의 닫는 것까지 글을 뺀다(넷을 따로 센다 — 짝을 맞추지 않는다).
+ * `title` · `noscript` · `textarea` · `iframe` · `object` · `svg` · `xml` 은 글이 남는다. 글을 빼는 동안에도 태그는 본다(그 안의 `` · `
` 도 줄바꿈)
+ * - 그 밖의 태그는 빈자리 없이 뺀다 — 낱말 중간의 인라인 태그(`회의`) · div · li · 표 칸도 이어 붙는다
+ * - 글의 이어진 공백(띄어쓰기 · 탭 · CR · LF — 그 밖의 제어 문자 · U+00A0 · U+3000 은 글자다)은 띄어쓰기 하나다 — 태그를 건너서도 하나(`회의 안내` → "회의 안내")
+ * - 문자 참조는 `;` 로 끝나는 것만 푼다(` ` 는 그대로) — 이름(HTML 4 의 이름 · `'`) · 10진 · 16진. 푼 글자는 공백 줄이기에 넣지 않고 그대로 넣는다:
+ * ` ` → 띄어쓰기 둘, `
` → 줄바꿈, `회의 안내` → "회의 안내"(앞의 글 공백을 먼저 넣는다), ` ` → U+00A0.
+ * HTML5 에만 있는 이름(`.` · 대문자 `&` 등 — 엔진은 푼다)은 글자 그대로 둔다(계약의 알려진 한계)
+ * - 줄바꿈(태그 · 참조가 만든 것)은 찾을 글에 없는 글자라(제어 문자를 받지 않는다) 그 자리를 건너 맞는 글이 없다 — 엔진과 같다
+ *
+ * 입력을 한 번 훑는다 — 길이에 비례한다(태그를 다시 시작해도 앞으로만 간다). 정규식 · htmlparser2(sanitize-html)는 쓰지 않는다 —
+ * htmlparser2 는 중첩 깊이의 제곱으로 느려진다 (html-parse-budget.ts). 태그의 짝도 보지 않는다(깊이를 세지 않는다).
+ */
+
+const LESS_THAN = 0x3c;
+const GREATER_THAN = 0x3e;
+const SLASH = 0x2f;
+const AMPERSAND = 0x26;
+const HASH = 0x23;
+const SEMICOLON = 0x3b;
+
+/** 태그 · 참조가 만든 줄바꿈 — 공백 줄이기에 들어가지 않는다 */
+const LINE_BREAK = '\n';
+
+/** 주석의 처음 · 끝 */
+const COMMENT_OPEN = '';
+
+/** 안의 글을 빼는 태그 — 소문자 (넷을 따로 센다) */
+const HIDING_TAGS = ['style', 'script', 'head', 'template'] as const;
+type HidingTag = (typeof HIDING_TAGS)[number];
+
+/** 알아보는 이름 가운데 가장 긴 것(template)의 길이 — 이보다 긴 태그 글은 이름을 견주지 않는다 */
+const LONGEST_KNOWN_TAG = 8;
+
+/** 문자 참조의 이름 · 숫자를 읽는 최대 길이 — 넘으면 참조가 아니라 글자로 본다 (`&` 마다 앞으로 조금만 본다 — 길이에 비례) */
+const MAX_REFERENCE_NAME_LENGTH = 32;
+const MAX_REFERENCE_DIGITS = 8;
+
+/** 유니코드의 끝 · 대체 문자 */
+const MAX_CODE_POINT = 0x10ffff;
+const REPLACEMENT_CHARACTER = '�';
+
+/** U+00A0 ~ U+00FF 의 이름 — HTML 4 의 Latin-1 문자 참조, 차례대로 */
+const LATIN1_REFERENCE_NAMES = [
+ 'nbsp iexcl cent pound curren yen brvbar sect uml copy ordf laquo not shy reg macr',
+ 'deg plusmn sup2 sup3 acute micro para middot cedil sup1 ordm raquo frac14 frac12 frac34 iquest',
+ 'Agrave Aacute Acirc Atilde Auml Aring AElig Ccedil Egrave Eacute Ecirc Euml Igrave Iacute Icirc Iuml',
+ 'ETH Ntilde Ograve Oacute Ocirc Otilde Ouml times Oslash Ugrave Uacute Ucirc Uuml Yacute THORN szlig',
+ 'agrave aacute acirc atilde auml aring aelig ccedil egrave eacute ecirc euml igrave iacute icirc iuml',
+ 'eth ntilde ograve oacute ocirc otilde ouml divide oslash ugrave uacute ucirc uuml yacute thorn yuml',
+]
+ .join(' ')
+ .split(' ');
+const LATIN1_REFERENCE_START = 0xa0;
+
+/** 그리스 문자 — 대문자 U+0391 ~ U+03A9(U+03A2 는 없다), 소문자 U+03B1 ~ U+03C9 (HTML 4) */
+const GREEK_UPPER_NAMES = [
+ 'Alpha Beta Gamma Delta Epsilon Zeta Eta Theta Iota Kappa Lambda Mu Nu Xi Omicron Pi Rho',
+ '- Sigma Tau Upsilon Phi Chi Psi Omega',
+]
+ .join(' ')
+ .split(' ');
+const GREEK_LOWER_NAMES = [
+ 'alpha beta gamma delta epsilon zeta eta theta iota kappa lambda mu nu xi omicron pi rho',
+ 'sigmaf sigma tau upsilon phi chi psi omega',
+]
+ .join(' ')
+ .split(' ');
+const GREEK_UPPER_START = 0x391;
+const GREEK_LOWER_START = 0x3b1;
+
+/** 그 밖의 이름 — HTML 4 의 특수 · 기호 문자 참조와 `'` (HTML 5 · XML) */
+const OTHER_REFERENCES: readonly (readonly [string, number])[] = [
+ ['quot', 0x22],
+ ['amp', 0x26],
+ ['apos', 0x27],
+ ['lt', 0x3c],
+ ['gt', 0x3e],
+ ['OElig', 0x152],
+ ['oelig', 0x153],
+ ['Scaron', 0x160],
+ ['scaron', 0x161],
+ ['Yuml', 0x178],
+ ['fnof', 0x192],
+ ['circ', 0x2c6],
+ ['tilde', 0x2dc],
+ ['thetasym', 0x3d1],
+ ['upsih', 0x3d2],
+ ['piv', 0x3d6],
+ ['ensp', 0x2002],
+ ['emsp', 0x2003],
+ ['thinsp', 0x2009],
+ ['zwnj', 0x200c],
+ ['zwj', 0x200d],
+ ['lrm', 0x200e],
+ ['rlm', 0x200f],
+ ['ndash', 0x2013],
+ ['mdash', 0x2014],
+ ['lsquo', 0x2018],
+ ['rsquo', 0x2019],
+ ['sbquo', 0x201a],
+ ['ldquo', 0x201c],
+ ['rdquo', 0x201d],
+ ['bdquo', 0x201e],
+ ['dagger', 0x2020],
+ ['Dagger', 0x2021],
+ ['bull', 0x2022],
+ ['hellip', 0x2026],
+ ['permil', 0x2030],
+ ['prime', 0x2032],
+ ['Prime', 0x2033],
+ ['lsaquo', 0x2039],
+ ['rsaquo', 0x203a],
+ ['oline', 0x203e],
+ ['frasl', 0x2044],
+ ['euro', 0x20ac],
+ ['image', 0x2111],
+ ['weierp', 0x2118],
+ ['real', 0x211c],
+ ['trade', 0x2122],
+ ['alefsym', 0x2135],
+ ['larr', 0x2190],
+ ['uarr', 0x2191],
+ ['rarr', 0x2192],
+ ['darr', 0x2193],
+ ['harr', 0x2194],
+ ['crarr', 0x21b5],
+ ['lArr', 0x21d0],
+ ['uArr', 0x21d1],
+ ['rArr', 0x21d2],
+ ['dArr', 0x21d3],
+ ['hArr', 0x21d4],
+ ['forall', 0x2200],
+ ['part', 0x2202],
+ ['exist', 0x2203],
+ ['empty', 0x2205],
+ ['nabla', 0x2207],
+ ['isin', 0x2208],
+ ['notin', 0x2209],
+ ['ni', 0x220b],
+ ['prod', 0x220f],
+ ['sum', 0x2211],
+ ['minus', 0x2212],
+ ['lowast', 0x2217],
+ ['radic', 0x221a],
+ ['prop', 0x221d],
+ ['infin', 0x221e],
+ ['ang', 0x2220],
+ ['and', 0x2227],
+ ['or', 0x2228],
+ ['cap', 0x2229],
+ ['cup', 0x222a],
+ ['int', 0x222b],
+ ['there4', 0x2234],
+ ['sim', 0x223c],
+ ['cong', 0x2245],
+ ['asymp', 0x2248],
+ ['ne', 0x2260],
+ ['equiv', 0x2261],
+ ['le', 0x2264],
+ ['ge', 0x2265],
+ ['sub', 0x2282],
+ ['sup', 0x2283],
+ ['nsub', 0x2284],
+ ['sube', 0x2286],
+ ['supe', 0x2287],
+ ['oplus', 0x2295],
+ ['otimes', 0x2297],
+ ['perp', 0x22a5],
+ ['sdot', 0x22c5],
+ ['lceil', 0x2308],
+ ['rceil', 0x2309],
+ ['lfloor', 0x230a],
+ ['rfloor', 0x230b],
+ // HTML 5 는 꺾쇠를 U+27E8 · U+27E9 로 바꿨다 (HTML 4 는 U+2329 · U+232A)
+ ['lang', 0x27e8],
+ ['rang', 0x27e9],
+ ['loz', 0x25ca],
+ ['spades', 0x2660],
+ ['clubs', 0x2663],
+ ['hearts', 0x2665],
+ ['diams', 0x2666],
+];
+
+/** 이름 → 글자 (대소문자를 가린다 — `Ä` 과 `ä` 은 다르다) */
+const NAMED_REFERENCES: ReadonlyMap = (() => {
+ const named = new Map();
+ const addRun = (names: readonly string[], start: number) =>
+ names.forEach((name, offset) => {
+ // '-' 는 빈 자리 (그 번호에는 이름이 없다)
+ if (name !== '-') named.set(name, String.fromCodePoint(start + offset));
+ });
+ addRun(LATIN1_REFERENCE_NAMES, LATIN1_REFERENCE_START);
+ addRun(GREEK_UPPER_NAMES, GREEK_UPPER_START);
+ addRun(GREEK_LOWER_NAMES, GREEK_LOWER_START);
+ for (const [name, codePoint] of OTHER_REFERENCES) {
+ named.set(name, String.fromCodePoint(codePoint));
+ }
+ return named;
+})();
+
+/** 이름 있는 문자 참조의 수 — 테스트가 표를 빠뜨리지 않았는지 본다 (HTML 4 의 252개 + apos) */
+export const NAMED_REFERENCE_COUNT = NAMED_REFERENCES.size;
+
+/**
+ * 뽑은 글 — 글의 이어진 공백은 하나로 모았다가 다음 글자 앞에 띄어쓰기 하나로 넣고(맨 앞이면 넣지 않는다),
+ * 푼 참조 · 태그가 만든 줄바꿈은 줄이지 않고 그대로 넣는다
+ */
+class TextSink {
+ private readonly pieces: string[] = [];
+ /** 글의 공백을 만났다 — 다음 글자 앞에 띄어쓰기 하나 */
+ private pendingSpace = false;
+
+ space(): void {
+ this.pendingSpace = true;
+ }
+
+ /** 글자 · 푼 참조 — 모아 둔 공백을 먼저 넣는다 */
+ text(value: string): void {
+ if (this.pendingSpace && this.pieces.length > 0) this.pieces.push(' ');
+ this.pendingSpace = false;
+ this.pieces.push(value);
+ }
+
+ /** `
` · `` 의 줄바꿈 — 모아 둔 공백은 버린다 (줄바꿈을 건너 맞는 글은 없다) */
+ lineBreak(): void {
+ this.pendingSpace = false;
+ this.pieces.push(LINE_BREAK);
+ }
+
+ toString(): string {
+ return this.pieces.join('');
+ }
+}
+
+/** 안의 글을 빼는 태그 가운데 열려 있는 것 — 넷을 따로 둔다(짝 · 깊이를 세지 않는다 — 여는 것은 켜고 닫는 것은 끈다) */
+class HidingTags {
+ private readonly open = new Set();
+
+ get active(): boolean {
+ return this.open.size > 0;
+ }
+
+ toggle(tag: HidingTag, opening: boolean): void {
+ if (opening) this.open.add(tag);
+ else this.open.delete(tag);
+ }
+}
+
+/** HTML 의 글자 부분 — 위 머리말의 규칙 */
+export function htmlToText(html: string): string {
+ const sink = new TextSink();
+ const hiding = new HidingTags();
+ let index = 0;
+ while (index < html.length) {
+ const code = html.charCodeAt(index);
+ if (code === LESS_THAN) {
+ index = readMarkup(html, index, sink, hiding);
+ continue;
+ }
+ if (isHtmlSpace(code)) {
+ if (!hiding.active) sink.space();
+ index += 1;
+ continue;
+ }
+ if (code === AMPERSAND) {
+ const reference = readReference(html, index);
+ if (reference !== null) {
+ if (!hiding.active) sink.text(reference.value);
+ index = reference.end;
+ continue;
+ }
+ }
+ // 글자 — 다음 `<` · `&` · 공백 앞까지 한 번에 (풀지 못한 `&` 는 글자다)
+ const end = textRunEnd(html, index + 1);
+ if (!hiding.active) sink.text(html.slice(index, end));
+ index = end;
+ }
+ return sink.toString();
+}
+
+/** 글자가 이어지는 끝 — `<` · `&` · 공백 앞 */
+function textRunEnd(html: string, from: number): number {
+ let index = from;
+ while (index < html.length) {
+ const code = html.charCodeAt(index);
+ if (code === LESS_THAN || code === AMPERSAND || isHtmlSpace(code)) break;
+ index += 1;
+ }
+ return index;
+}
+
+/**
+ * `<` 에서 시작하는 표시를 읽고 그 다음 자리를 돌려준다 — 주석이면 `-->` 까지, 아니면 다음 `>` 까지의 태그.
+ * 태그 안에서 `<` 가 또 나오면 거기서 다시 시작한다(앞의 것은 버린다). 닫히지 않으면 html.length — 나머지를 모두 삼킨다
+ */
+function readMarkup(
+ html: string,
+ open: number,
+ sink: TextSink,
+ hiding: HidingTags,
+): number {
+ let start = open;
+ for (;;) {
+ if (html.startsWith(COMMENT_OPEN, start)) {
+ const close = html.indexOf(COMMENT_CLOSE, start + COMMENT_OPEN.length);
+ return close === -1 ? html.length : close + COMMENT_CLOSE.length;
+ }
+ let index = start + 1;
+ while (index < html.length) {
+ const code = html.charCodeAt(index);
+ if (code === GREATER_THAN || code === LESS_THAN) break;
+ index += 1;
+ }
+ if (index >= html.length) return html.length;
+ if (html.charCodeAt(index) === LESS_THAN) {
+ start = index;
+ continue;
+ }
+ applyTag(html, start + 1, index, sink, hiding);
+ return index + 1;
+ }
+}
+
+/**
+ * 태그 글(`<` 와 `>` 사이)의 할 일 — 이름은 앞의 공백 · `/` 와 끝의 공백 · `/` 를 뺀 나머지 전부다(속성이 붙으면 알아보지 않는다).
+ * 닫는 태그는 첫 공백 아닌 글자가 `/` 인 것. br → 줄바꿈 · 닫는 p → 줄바꿈 · style · script · head · template → 안의 글을 빼기 시작 · 끝
+ */
+function applyTag(
+ html: string,
+ from: number,
+ to: number,
+ sink: TextSink,
+ hiding: HidingTags,
+): void {
+ let start = from;
+ while (start < to && isHtmlSpace(html.charCodeAt(start))) start += 1;
+ const closing = start < to && html.charCodeAt(start) === SLASH;
+ while (start < to && isNameEdge(html.charCodeAt(start))) start += 1;
+ let end = to;
+ while (end > start && isNameEdge(html.charCodeAt(end - 1))) end -= 1;
+ if (end - start > LONGEST_KNOWN_TAG) return;
+
+ if (isTagName(html, start, end, 'br')) {
+ sink.lineBreak();
+ return;
+ }
+ if (isTagName(html, start, end, 'p')) {
+ if (closing) sink.lineBreak();
+ return;
+ }
+ const tag = HIDING_TAGS.find((name) => isTagName(html, start, end, name));
+ if (tag) hiding.toggle(tag, !closing);
+}
+
+/** 태그 이름 자리가 그 이름(소문자)과 같은가 — ASCII 대소문자만 무시한다 */
+function isTagName(
+ html: string,
+ start: number,
+ end: number,
+ name: string,
+): boolean {
+ return end - start === name.length && matchesAsciiLower(html, start, name);
+}
+
+/** 이름 앞뒤에서 빼는 글자 — 공백 · `/` */
+function isNameEdge(code: number): boolean {
+ return isHtmlSpace(code) || code === SLASH;
+}
+
+/** html 의 그 자리부터가 소문자 낱말(영문)과 같은가 — ASCII 대소문자만 무시한다 */
+function matchesAsciiLower(html: string, at: number, word: string): boolean {
+ if (at + word.length > html.length) return false;
+ for (let offset = 0; offset < word.length; offset += 1) {
+ let code = html.charCodeAt(at + offset);
+ if (code >= 0x41 && code <= 0x5a) code += 0x20;
+ if (code !== word.charCodeAt(offset)) return false;
+ }
+ return true;
+}
+
+/** `&` 자리에서 시작하는 문자 참조 — `;` 로 끝나는 것만, 풀 수 없으면 null (글자 그대로 둔다) */
+function readReference(
+ text: string,
+ at: number,
+): { readonly value: string; readonly end: number } | null {
+ if (text.charCodeAt(at + 1) === HASH) return readNumericReference(text, at);
+ const nameStart = at + 1;
+ const limit = Math.min(text.length, nameStart + MAX_REFERENCE_NAME_LENGTH);
+ let index = nameStart;
+ while (index < limit && isAsciiAlphanumeric(text.charCodeAt(index))) {
+ index += 1;
+ }
+ if (index === nameStart || text.charCodeAt(index) !== SEMICOLON) return null;
+ const value = NAMED_REFERENCES.get(text.slice(nameStart, index));
+ return value === undefined ? null : { value, end: index + 1 };
+}
+
+/** `{` · `😀` — 없는 글자(0 · 대리 · 유니코드 밖)는 대체 문자 (HTML 표준) */
+function readNumericReference(
+ text: string,
+ at: number,
+): { readonly value: string; readonly end: number } | null {
+ const marker = text.charCodeAt(at + 2);
+ const hex = marker === 0x78 || marker === 0x58;
+ const digitsStart = hex ? at + 3 : at + 2;
+ const limit = Math.min(text.length, digitsStart + MAX_REFERENCE_DIGITS);
+ let index = digitsStart;
+ while (
+ index < limit &&
+ (hex
+ ? isHexDigit(text.charCodeAt(index))
+ : isDecimalDigit(text.charCodeAt(index)))
+ ) {
+ index += 1;
+ }
+ if (index === digitsStart || text.charCodeAt(index) !== SEMICOLON) {
+ return null;
+ }
+ const codePoint = Number.parseInt(
+ text.slice(digitsStart, index),
+ hex ? 16 : 10,
+ );
+ return { value: characterOf(codePoint), end: index + 1 };
+}
+
+function characterOf(codePoint: number): string {
+ if (
+ codePoint === 0 ||
+ codePoint > MAX_CODE_POINT ||
+ (codePoint >= 0xd800 && codePoint <= 0xdfff)
+ ) {
+ return REPLACEMENT_CHARACTER;
+ }
+ return String.fromCodePoint(codePoint);
+}
+
+/** 글의 공백 — 띄어쓰기 · 탭 · LF · CR (엔진과 같다 — FF · U+00A0 · U+3000 은 아니다) */
+function isHtmlSpace(code: number): boolean {
+ return code === 0x20 || code === 0x09 || code === 0x0a || code === 0x0d;
+}
+
+function isAsciiLetter(code: number): boolean {
+ return (code >= 0x41 && code <= 0x5a) || (code >= 0x61 && code <= 0x7a);
+}
+
+function isDecimalDigit(code: number): boolean {
+ return code >= 0x30 && code <= 0x39;
+}
+
+function isHexDigit(code: number): boolean {
+ return (
+ isDecimalDigit(code) ||
+ (code >= 0x41 && code <= 0x46) ||
+ (code >= 0x61 && code <= 0x66)
+ );
+}
+
+function isAsciiAlphanumeric(code: number): boolean {
+ return isAsciiLetter(code) || isDecimalDigit(code);
+}
diff --git a/backend/src/modules/mail/jmap/bulk-run.ts b/backend/src/modules/mail/jmap/bulk-run.ts
new file mode 100644
index 0000000..6107668
--- /dev/null
+++ b/backend/src/modules/mail/jmap/bulk-run.ts
@@ -0,0 +1,99 @@
+import { BusinessException } from '@/common/errors/business.exception';
+import { JMAP_REQUEST_TIMEOUT_MS, type JmapCallOptions } from './jmap.client';
+
+/**
+ * 오래 걸리는 처리 한 요청의 시간 · 진행 · 멈춘 차례 — 메일함 전체 읽음 · 비우기 · 지우기(jmap-mail.provider.ts, 계약 2절 "메일함 관리")와
+ * 이미 받은 메일에 필터 적용(jmap-filter-run.ts, 계약 2절 "이미 받은 메일에 적용")이 같은 규칙을 쓴다.
+ * 이 모듈은 `modules/mail/jmap/` 밖으로 내보내지 않는다.
+ */
+
+/**
+ * 한 요청의 시간 — 실제 상한이다 (BulkRun). 이 안에서 끝내고 남은 수(또는 이어 할 자리)를 돌려준다.
+ * 클라이언트가 남은 것이 없을 때까지 같은 요청을 다시 보낸다.
+ */
+export const BULK_REQUEST_BUDGET_MS = 25_000;
+/** 남은 시간이 이보다 적으면 다음 차례를 시작하지 않는다 — 한 차례(엔진 호출 두어 번)가 끝까지 시간 안에 들게 */
+export const BULK_ROUND_RESERVE_MS = 5_000;
+
+/**
+ * 상태가 어긋나면(ifInState — stateMismatch) 다시 확인하는 횟수 — 조건부로 바꾸는 요청을 모두 합쳐.
+ * 사용자 초안(교체 저장·보낸 뒤)과 휴지통에서 영구 삭제(DELETE /api/messages/{id} · POST /api/message-deletions)가 함께 쓰고,
+ * 오래 걸리는 처리(BulkRun)는 아무것도 바뀌지 않은 차례가 이어서 이 횟수면 멈춘다.
+ * 계정의 메일이 하나라도 바뀌면(새 메일 도착·다른 메일의 읽음 표시 포함) 상태가 바뀌므로 바쁜 메일함에서는 어긋날 수 있다 —
+ * 그래도 이 횟수 안에 맞지 않으면 지우지(옮기지) 않는다.
+ */
+export const CONDITIONAL_DESTROY_ATTEMPTS = 3;
+
+/**
+ * 오래 걸리는 처리의 한 요청 — 시간 · 진행 · 멈춘 차례.
+ * - 시간: 처음 엔진 호출(메일함 목록) **전부터** BULK_REQUEST_BUDGET_MS 가 실제 상한이다. 다음 차례는 남은 시간이 BULK_ROUND_RESERVE_MS 보다
+ * 많을 때만 시작하고, 엔진 호출마다 제한 시간을 기본 제한 시간(JMAP_REQUEST_TIMEOUT_MS)과 남은 시간 중 짧은 쪽으로 준다 —
+ * 엔진이 느려도 한 요청이 앞단 nginx 의 시간 제한에 걸리지 않게
+ * - 멈춘 차례: 아무것도 바뀌지 않은 차례(어긋남 · 바뀐 것도 거절도 없음 · 비운 메일함을 지우는 사이 메일이 들어옴)가
+ * 이어서 CONDITIONAL_DESTROY_ATTEMPTS 번이면 멈춘다 — 엔진에 끝없이 묻지 않게. 진행한 차례가 있으면 처음부터 다시 센다
+ * - 일찍 멈췄을 때(멈춘 차례 · 남은 시간 · 남은 시간 때문에 끊긴 호출) 이 요청에서 처리한 것이 있으면 거기까지로 답하고, 없으면 MAIL_001
+ * 처리한 것(processed)은 쓰는 쪽이 정한다 — 메일함 처리는 바꾼 메일 수, 필터 적용은 판정한 메일 수.
+ * 시계는 만들 때부터 간다 — 앞서 시작한 때(startedAt)를 주면 그때부터 센다(필터 적용은 서비스에 들어온 때 — 옮길 메일함 확인을 포함한다)
+ */
+export class BulkRun {
+ private readonly deadline: number;
+ private stalls = 0;
+ private lastStall: string | null = null;
+ /** 이번 요청에서 처리한 메일 수 */
+ processed = 0;
+
+ constructor(startedAt: number = Date.now()) {
+ this.deadline = startedAt + BULK_REQUEST_BUDGET_MS;
+ }
+
+ /** 다음 차례를 시작해도 되는가 */
+ canStartRound(): boolean {
+ return this.left() > BULK_ROUND_RESERVE_MS;
+ }
+
+ /** 이번 엔진 호출의 제한 시간 — 기본 제한 시간과 남은 시간 중 짧은 쪽 */
+ callOptions(): JmapCallOptions {
+ return {
+ timeoutMs: Math.max(1, Math.min(JMAP_REQUEST_TIMEOUT_MS, this.left())),
+ };
+ }
+
+ /** 처리했다 — 멈춘 차례를 처음부터 다시 센다 */
+ progress(count: number): void {
+ if (count <= 0) return;
+ this.processed += count;
+ this.stalls = 0;
+ }
+
+ /** 아무것도 바뀌지 않은 차례 — 이어서 CONDITIONAL_DESTROY_ATTEMPTS 번째면 true (멈춘다) */
+ stalled(why: string): boolean {
+ this.stalls += 1;
+ this.lastStall = why;
+ return this.stalls >= CONDITIONAL_DESTROY_ATTEMPTS;
+ }
+
+ /**
+ * 한 요청의 시간을 다 써서 엔진 호출이 끊겼고, 이 요청에서 처리한 것이 있는가 — 그러면 거기까지로 답한다.
+ * 끊긴 Email/set 은 엔진이 적용했을 수 있다 — 남은 수는 다음 요청이 새로 읽어 맞춘다 (조건부 규칙 그대로)
+ */
+ cutOffAfterProgress(error: unknown): boolean {
+ return (
+ this.processed > 0 &&
+ this.left() <= 0 &&
+ error instanceof BusinessException &&
+ error.code === 'MAIL_001'
+ );
+ }
+
+ /** 끝까지 하지 못하고 멈췄는데 이 요청에서 처리한 것이 없으면 MAIL_001 — 클라이언트가 같은 요청을 끝없이 다시 보내지 않게 */
+ assertProgress(operation: string): void {
+ if (this.processed > 0) return;
+ throw new BusinessException('MAIL_001', {
+ context: { reason: `${operation}-no-progress`, stalled: this.lastStall },
+ });
+ }
+
+ private left(): number {
+ return this.deadline - Date.now();
+ }
+}
diff --git a/backend/src/modules/mail/jmap/filter-run-cursor.spec.ts b/backend/src/modules/mail/jmap/filter-run-cursor.spec.ts
new file mode 100644
index 0000000..8fdaba7
--- /dev/null
+++ b/backend/src/modules/mail/jmap/filter-run-cursor.spec.ts
@@ -0,0 +1,174 @@
+import { BusinessException } from '@/common/errors/business.exception';
+import type { MailFilterRunRequest } from '@/modules/mail/mail.types';
+import {
+ decodeFilterRunCursor,
+ encodeFilterRunCursor,
+ MAX_FILTER_RUN_SECONDS,
+ type FilterRunPosition,
+ type FilterRunRule,
+} from './filter-run-cursor';
+
+const rule: FilterRunRule = {
+ match: 'any',
+ conditions: [
+ { field: 'from', operator: 'contains', value: '@hanbit-partners.co.kr' },
+ ],
+ actions: { moveToMailboxId: 'j', markAsRead: true, flag: false },
+};
+
+/** 2026-10-29T09:00:00Z (초) */
+const NINE = 1_793_264_400;
+
+function failureOf(run: () => unknown): Record | undefined {
+ try {
+ run();
+ } catch (error) {
+ if (!(error instanceof BusinessException)) throw error;
+ return { code: error.code, reason: error.context?.reason };
+ }
+ return undefined;
+}
+
+const forged = (payload: unknown) =>
+ Buffer.from(JSON.stringify(payload)).toString('base64url');
+
+/** 진짜 cursor 의 규칙 지문 — 손으로 만든 cursor 에 붙인다 */
+function hashOf(cursor: string): string {
+ return (
+ JSON.parse(Buffer.from(cursor, 'base64url').toString('utf8')) as {
+ r: string;
+ }
+ ).r;
+}
+
+describe('이미 받은 메일에 필터 적용의 cursor (계약 2절 "이미 받은 메일에 적용")', () => {
+ it('만든 자리를 그대로 되돌린다 — 위 경계 · 남긴 마지막 메일 · 그 받은 시각 (남긴 메일이 아직 없으면 anchor 는 null, 받은 시각만 있을 수 있다)', () => {
+ const positions: FilterRunPosition[] = [
+ { before: NINE + 1, anchor: 'seaaaaer', anchorTime: NINE - 60 },
+ { before: NINE + 1, anchor: null, anchorTime: NINE - 60 },
+ { before: NINE + 1, anchor: null, anchorTime: null },
+ { before: MAX_FILTER_RUN_SECONDS, anchor: 'm1', anchorTime: 0 },
+ ];
+ for (const position of positions) {
+ expect(
+ decodeFilterRunCursor(encodeFilterRunCursor(position, rule), rule),
+ ).toEqual(position);
+ }
+ });
+
+ it('URL 에 그대로 넣을 수 있는 글자만 쓰고 짧다 (요청 검증의 1,024자 안 — 가장 긴 메일 id · 가장 큰 시각이어도)', () => {
+ const cursor = encodeFilterRunCursor(
+ {
+ before: MAX_FILTER_RUN_SECONDS,
+ anchor: 'a'.repeat(255),
+ anchorTime: MAX_FILTER_RUN_SECONDS - 1,
+ },
+ rule,
+ );
+ expect(cursor).toMatch(/^[A-Za-z0-9_-]+$/);
+ expect(cursor.length).toBeLessThan(1_024);
+ });
+
+ it('다른 규칙의 cursor 는 VAL_001 — 묶는 방법 · 조건 · 할 일 어느 것이 달라도', () => {
+ const cursor = encodeFilterRunCursor(
+ { before: NINE, anchor: 'm1', anchorTime: NINE - 1 },
+ rule,
+ );
+ const others: FilterRunRule[] = [
+ { ...rule, match: 'all' },
+ {
+ ...rule,
+ conditions: [{ ...rule.conditions[0], value: '@hanbit-partners' }],
+ },
+ {
+ ...rule,
+ conditions: [{ ...rule.conditions[0], operator: 'notContains' }],
+ },
+ { ...rule, actions: { ...rule.actions, moveToMailboxId: 'k' } },
+ { ...rule, actions: { ...rule.actions, flag: true } },
+ ];
+ for (const other of others) {
+ expect(failureOf(() => decodeFilterRunCursor(cursor, other))).toEqual({
+ code: 'VAL_001',
+ reason: 'invalid-filter-run-cursor:other-rule',
+ });
+ }
+ });
+
+ it('규칙 객체의 키 순서 · 다른 속성(cursor 등)과는 상관없다 — 같은 규칙이면 같은 지문', () => {
+ const position = { before: NINE, anchor: 'm1', anchorTime: NINE - 1 };
+ const cursor = encodeFilterRunCursor(position, rule);
+ const reordered: MailFilterRunRequest = {
+ cursor: 'anything',
+ actions: { flag: false, markAsRead: true, moveToMailboxId: 'j' },
+ conditions: [
+ {
+ value: '@hanbit-partners.co.kr',
+ operator: 'contains',
+ field: 'from',
+ },
+ ],
+ match: 'any',
+ };
+ expect(decodeFilterRunCursor(cursor, reordered)).toEqual(position);
+ });
+
+ it('깨진 cursor 는 VAL_001 — base64url · JSON 이 아님 · 판 · 모양(시각의 형식 · 범위 · 차례, 메일 id 형식) · 너무 김', () => {
+ const r = hashOf(
+ encodeFilterRunCursor(
+ { before: NINE, anchor: 'm1', anchorTime: NINE - 1 },
+ rule,
+ ),
+ );
+ const good = { v: 2, b: NINE, a: 'm1', t: NINE - 1, r };
+ const cases: [string, string][] = [
+ ['%%%', 'not-json'],
+ [Buffer.from('not json').toString('base64url'), 'not-json'],
+ // 1판(자리 수로 이어 하던 것)은 받지 않는다
+ [forged({ v: 1, a: 'm1', p: 1, r }), 'shape'],
+ [forged({ ...good, v: 3 }), 'shape'],
+ // 위 경계 — 없음 · 0 · 음수 · 소수 · 글 · 범위 밖
+ [forged({ ...good, b: undefined }), 'shape'],
+ [forged({ ...good, b: 0, t: null, a: null }), 'shape'],
+ [forged({ ...good, b: -5 }), 'shape'],
+ [forged({ ...good, b: NINE + 0.5 }), 'shape'],
+ [forged({ ...good, b: String(NINE) }), 'shape'],
+ [forged({ ...good, b: MAX_FILTER_RUN_SECONDS + 1 }), 'shape'],
+ // 남긴 메일의 받은 시각 — 위 경계와 같거나 뒤 · 음수 · 소수 · 글 · 없음(anchor 가 있는데)
+ [forged({ ...good, t: NINE }), 'shape'],
+ [forged({ ...good, t: NINE + 10 }), 'shape'],
+ [forged({ ...good, t: -1 }), 'shape'],
+ [forged({ ...good, t: NINE - 0.5 }), 'shape'],
+ [forged({ ...good, t: String(NINE - 1) }), 'shape'],
+ [forged({ ...good, t: null }), 'shape'],
+ [forged({ ...good, t: undefined }), 'shape'],
+ // 메일 id 형식
+ [forged({ ...good, a: 'm/1' }), 'shape'],
+ [forged({ ...good, a: '' }), 'shape'],
+ [forged({ ...good, a: 7 }), 'shape'],
+ [forged({ ...good, a: undefined }), 'shape'],
+ // 지문 없음 · 글이 아님
+ [forged({ ...good, r: undefined }), 'shape'],
+ [forged({ ...good, r: 1 }), 'shape'],
+ [forged(null), 'shape'],
+ [forged([2, NINE]), 'shape'],
+ [forged('cursor'), 'shape'],
+ ['a'.repeat(1_025), 'too-long'],
+ ];
+ for (const [cursor, reason] of cases) {
+ expect(failureOf(() => decodeFilterRunCursor(cursor, rule))).toEqual({
+ code: 'VAL_001',
+ reason: `invalid-filter-run-cursor:${reason}`,
+ });
+ }
+ // 손으로 만들어도 모양 · 지문이 맞으면 받는다 (자리는 엔진에 묻는 시작점일 뿐 — 위 경계 아래의 받은편지함만 읽는다)
+ expect(decodeFilterRunCursor(forged(good), rule)).toEqual({
+ before: NINE,
+ anchor: 'm1',
+ anchorTime: NINE - 1,
+ });
+ expect(
+ decodeFilterRunCursor(forged({ ...good, a: null, t: null }), rule),
+ ).toEqual({ before: NINE, anchor: null, anchorTime: null });
+ });
+});
diff --git a/backend/src/modules/mail/jmap/filter-run-cursor.ts b/backend/src/modules/mail/jmap/filter-run-cursor.ts
new file mode 100644
index 0000000..057c3e1
--- /dev/null
+++ b/backend/src/modules/mail/jmap/filter-run-cursor.ts
@@ -0,0 +1,133 @@
+import { createHash } from 'crypto';
+
+import { BusinessException } from '@/common/errors/business.exception';
+import {
+ MAIL_ID_PATTERN,
+ MAX_MAIL_FILTER_RUN_CURSOR_LENGTH,
+} from '@/modules/mail/mail.constants';
+import type { MailFilterRunRequest } from '@/modules/mail/mail.types';
+
+/**
+ * 이미 받은 메일에 필터 적용의 이어 할 자리 (계약 2절 "이미 받은 메일에 적용"의 cursor — 클라이언트에게는 불투명 문자열, base64url JSON).
+ * 엔진의 조회 규칙(2026-10-29 확인)에 기대어 자리를 받은 시각으로 잡는다 — 받은 시각은 초 단위이고, `before` 는 미만(<)이며,
+ * 받은 시각이 같은 메일은 만든 차례로 나오고 표시를 바꾸거나 옮겼다 되돌려도 차례가 그대로다:
+ * - before(위 경계): 첫 요청이 본 받은편지함 맨 위 메일의 받은 시각 + 1초 — 모든 조회에 `before` 로 건다. 적용을 시작한 뒤 온 메일은 보지 않는다
+ * (새로 온 메일은 Sieve 가 규칙 차례대로 이미 판정했다 — 한 규칙으로 다시 판정하면 위 규칙의 "멈춤"을 건너뛴다)
+ * - anchor: 지금까지 확인하고 받은편지함에 **남긴**(옮기지 않은) 마지막 메일 — 다음 차례는 그 다음부터(`anchor` + 1) 읽는다
+ * - anchorTime: 그 메일의 받은 시각(초) — 그 메일이 그사이 받은편지함에서 빠졌으면(`anchorNotFound`) `before: anchorTime + 1초` 의 처음부터 읽는다
+ * (그 초의 메일을 다시 판정한다 — 건너뛰지 않는다). 그렇게 읽은 차례에 남긴 메일이 없으면 anchor 는 없고 anchorTime 만 남는다
+ * - 규칙의 지문: 묶는 방법 · 조건 · 할 일 — 다른 규칙의 cursor 를 섞어 쓰지 못하게 (충돌보다는 "다른 규칙" 감지가 목적이라 짧게 둔다)
+ * 시각은 UTC 의 초(epoch)다 — 해석할 때 범위(0 ~ 9999-12-31)를 본다. 깨졌거나 다른 규칙의 것이면 400 VAL_001 — 엔진에 묻기 전에 본다
+ */
+const CURSOR_VERSION = 2;
+
+/** 규칙 지문의 길이 */
+const RULE_HASH_LENGTH = 16;
+
+/** cursor 에 담는 시각의 끝 — JMAP UTCDate 의 끝(9999-12-31T23:59:59Z, 초) */
+export const MAX_FILTER_RUN_SECONDS = 253_402_300_799;
+
+/** 이어 할 자리 */
+export interface FilterRunPosition {
+ /** 위 경계(초) — 받은 시각이 이보다 앞선 메일만 본다 */
+ readonly before: number;
+ /** 받은편지함에 남긴 마지막 메일 — 없으면 null */
+ readonly anchor: string | null;
+ /** 그 메일(또는 빠진 그 메일)의 받은 시각(초) — 아직 없으면 null. anchor 가 있으면 늘 있다 */
+ readonly anchorTime: number | null;
+}
+
+/** 이 cursor 를 만든 규칙 — 묶는 방법 · 조건 · 할 일 */
+export type FilterRunRule = Pick<
+ MailFilterRunRequest,
+ 'match' | 'conditions' | 'actions'
+>;
+
+interface EncodedFilterRunCursor {
+ v: number;
+ b: number;
+ a: string | null;
+ t: number | null;
+ r: string;
+}
+
+/** 규칙의 지문 — 정해진 차례의 JSON 을 SHA-256 으로 (들어온 객체의 키 순서 · 다른 속성과 상관없다) */
+function ruleHash(rule: FilterRunRule): string {
+ const canonical = JSON.stringify([
+ rule.match,
+ rule.conditions.map(({ field, operator, value }) => [
+ field,
+ operator,
+ value,
+ ]),
+ [rule.actions.moveToMailboxId, rule.actions.markAsRead, rule.actions.flag],
+ ]);
+ return createHash('sha256')
+ .update(canonical, 'utf8')
+ .digest('base64url')
+ .slice(0, RULE_HASH_LENGTH);
+}
+
+function invalidCursor(reason: string): BusinessException {
+ return new BusinessException('VAL_001', {
+ context: { reason: `invalid-filter-run-cursor:${reason}` },
+ });
+}
+
+/** cursor 에 담을 수 있는 시각(초)인가 */
+function isSeconds(value: unknown): value is number {
+ return (
+ typeof value === 'number' &&
+ Number.isSafeInteger(value) &&
+ value >= 0 &&
+ value <= MAX_FILTER_RUN_SECONDS
+ );
+}
+
+export function encodeFilterRunCursor(
+ position: FilterRunPosition,
+ rule: FilterRunRule,
+): string {
+ const payload: EncodedFilterRunCursor = {
+ v: CURSOR_VERSION,
+ b: position.before,
+ a: position.anchor,
+ t: position.anchorTime,
+ r: ruleHash(rule),
+ };
+ return Buffer.from(JSON.stringify(payload)).toString('base64url');
+}
+
+/** cursor 를 해석한다 — 형식 · 범위가 틀리거나 다른 규칙의 것이면 VAL_001 */
+export function decodeFilterRunCursor(
+ cursor: string,
+ rule: FilterRunRule,
+): FilterRunPosition {
+ if (cursor.length > MAX_MAIL_FILTER_RUN_CURSOR_LENGTH) {
+ throw invalidCursor('too-long');
+ }
+
+ let parsed: unknown;
+ try {
+ parsed = JSON.parse(Buffer.from(cursor, 'base64url').toString('utf8'));
+ } catch {
+ throw invalidCursor('not-json');
+ }
+
+ const { v, b, a, t, r } = (parsed ?? {}) as Partial;
+ if (v !== CURSOR_VERSION || !isSeconds(b) || b === 0) {
+ throw invalidCursor('shape');
+ }
+ if (!(a === null || (typeof a === 'string' && MAIL_ID_PATTERN.test(a)))) {
+ throw invalidCursor('shape');
+ }
+ // anchor 가 있으면 그 받은 시각도 있다 — 위 경계보다 앞선다
+ if (!(t === null || (isSeconds(t) && t < b)) || (a !== null && t === null)) {
+ throw invalidCursor('shape');
+ }
+ if (typeof r !== 'string') throw invalidCursor('shape');
+
+ if (r !== ruleHash(rule)) throw invalidCursor('other-rule');
+
+ return { before: b, anchor: a, anchorTime: t };
+}
diff --git a/backend/src/modules/mail/jmap/filter-run-texts.spec.ts b/backend/src/modules/mail/jmap/filter-run-texts.spec.ts
new file mode 100644
index 0000000..1b9bce0
--- /dev/null
+++ b/backend/src/modules/mail/jmap/filter-run-texts.spec.ts
@@ -0,0 +1,1412 @@
+import { judgeFilter } from '@/modules/mail/mail-filter-match';
+import type { MailFilterField } from '@/modules/mail/mail.types';
+import {
+ bodyTextsOf,
+ estimatedBodyBytes,
+ filterRunProperties,
+ headerTextsOf,
+ textPartsOf,
+} from './filter-run-texts';
+import type { JmapEmail } from './jmap.types';
+
+/** 엔진 메일 — 원래 머리글 속성(`header:From:all` 등)이 더 붙는다 */
+type DeliveredEmail = JmapEmail & Record;
+
+/** 실배달 한 통과 규칙 하나 — 엔진(Sieve)이 새로 받은 그 메일에 내린 판정 */
+interface DeliveredCase {
+ readonly key: string;
+ readonly note: string;
+ readonly field: MailFilterField;
+ readonly value: string;
+ /** 엔진의 판정 — 규칙의 옮기기가 일어났는가. null 은 판정 불가(엔진이 스팸으로 분류해 규칙을 돌리지 않았다) */
+ readonly engine: boolean | null;
+ readonly email: DeliveredEmail;
+}
+
+/**
+ * 2026-10-29 개발 엔진(Stalwart 0.16.27)에 실제로 배달한 시험 메일 20통 — 규칙(견줄 곳 · 찾을 글)마다 엔진이 Sieve 로 판정한 결과와,
+ * 같은 메일을 JMAP 으로 읽은 응답(원래 머리글 · 부분 · 본문 값). 원래 머리글은 엔진이 준 그대로다(앞의 공백 · encoded-word).
+ * 받는 쪽 주소는 시험 계정(arasystem.test), 보낸 쪽은 example.com. p7smvs8xqfv 는 시험마다 붙인 표지다
+ */
+const DELIVERED: readonly DeliveredCase[] = [
+ {
+ key: 'B1',
+ note: 'HTML 만 — 보이는 글',
+ field: 'body',
+ value: 'VISp7smvs8xqfv',
+ engine: true,
+ email: {
+ id: 'seaaaaer',
+ mailboxIds: { a: true },
+ subject: 'B1 p7smvs8xqfv',
+ 'header:From:all': [' Probe '],
+ 'header:To:all': [' '],
+ 'header:Cc:all': [],
+ textBody: [{ partId: '0', type: 'text/html' }],
+ htmlBody: [{ partId: '0', type: 'text/html' }],
+ attachments: [],
+ bodyValues: { '0': { value: '보이는 VISp7smvs8xqfv
' } },
+ },
+ },
+ {
+ key: 'B2',
+ note: 'HTML 속성 값은 글이 아니다',
+ field: 'body',
+ value: 'ATTRp7smvs8xqfv',
+ engine: false,
+ email: {
+ id: 'siaaaaes',
+ mailboxIds: { a: true },
+ subject: 'B2 p7smvs8xqfv',
+ 'header:From:all': [' Probe '],
+ 'header:To:all': [' '],
+ 'header:Cc:all': [],
+ textBody: [{ partId: '0', type: 'text/html' }],
+ htmlBody: [{ partId: '0', type: 'text/html' }],
+ attachments: [],
+ bodyValues: {
+ '0': { value: 'x
y' },
+ },
+ },
+ },
+ {
+ key: 'B3',
+ note: 'x