feat(backend): 이미 받은 메일에 필터 적용 POST /api/mail-filter-runs · 부재중 isActive

본문의 규칙 하나로 받은편지함을 최근 메일부터 판정하고 표시 · 옮기기만 한다(전달 · 지우기 없음).
한 요청은 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 <noreply@anthropic.com>
This commit is contained in:
2026-10-29 08:37:51 +09:00
parent ab4124ba94
commit 6fd0911b86
34 changed files with 6861 additions and 240 deletions
@@ -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');
}
});
});
@@ -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;
}
@@ -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;
}
@@ -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('회<b class="x">의</b>록')).toBe('회의록');
expect(htmlToText('<div>앞</div><div>뒤</div>')).toBe('앞뒤');
expect(htmlToText('<ul><li>앞</li><li>뒤</li></ul>')).toBe('앞뒤');
expect(htmlToText('<td>앞</td><td>뒤</td>앞<hr>뒤')).toBe('앞뒤앞뒤');
expect(htmlToText('앞<p>뒤 앞<p/>뒤 앞<P class="a">뒤')).toBe(
'앞뒤 앞뒤 앞뒤',
);
});
it('</p> (닫는 것만) · <br> (여는 것 · 닫는 것 · <br/> · <br /> · 대문자)은 줄바꿈이다', () => {
expect(htmlToText('<p>앞</p><p>뒤</p>')).toBe('앞\n뒤\n');
expect(htmlToText('앞</P >뒤')).toBe('앞\n뒤');
for (const br of ['<br>', '<br/>', '<br />', '</br>', '<BR>', '< br >']) {
expect(htmlToText(`앞${br}뒤`)).toBe('앞\n뒤');
}
});
it('태그 이름은 앞의 공백 · / 와 끝의 공백 · / 를 뺀 나머지 전부 — 속성이 붙으면 알아보지 않는다', () => {
// 속성이 붙은 br · p 는 줄을 끊지 않는다
expect(htmlToText('앞<br clear="all">뒤')).toBe('앞뒤');
expect(htmlToText('앞</p class="x">뒤')).toBe('앞뒤');
// o:p 는 p 가 아니다
expect(htmlToText('<o:p>앞</o:p>뒤')).toBe('앞뒤');
// 속성이 붙은 style · script · head · template 은 글을 빼지 않는다
expect(htmlToText('<style type="text/css">a{}</style>뒤')).toBe('a{}뒤');
expect(htmlToText('<head lang="ko"><title>제목</title></head>')).toBe(
'제목',
);
expect(htmlToText('<template id="t">글</template>')).toBe('글');
});
it('<style> · <script> · <head> · <template> 은 여는 것부터 같은 이름의 닫는 것까지 글을 뺀다 — 대소문자 없이, 닫는 태그 끝의 공백도 된다', () => {
// 글을 빼는 동안의 `>` 는 그냥 글자다
expect(htmlToText('<style>a>b{}</style>뒤')).toBe('뒤');
expect(htmlToText('<STYLE>숨김</STYLE>보임')).toBe('보임');
expect(htmlToText('<script>var a = 1;</script >보임')).toBe('보임');
expect(htmlToText('<head><title>제목</title></head><p>본문</p>')).toBe(
'본문\n',
);
expect(htmlToText('<TEMPLATE>숨김</TEMPLATE>보임')).toBe('보임');
// 넷을 따로 센다 — 다른 이름의 닫는 태그로는 끝나지 않는다
expect(htmlToText('<head>머리<style>x</style>아직</head>끝')).toBe('끝');
});
it('글을 빼는 동안에도 태그는 본다 — 그 안의 </p> · <br> 도 줄바꿈이다', () => {
expect(htmlToText('앞<script></p></script>뒤')).toBe('앞\n뒤');
expect(htmlToText('앞<style><br></style>뒤')).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('<p><광고> 안내</p>')).toBe('안내\n');
expect(htmlToText('<img alt="a>b">사진')).toBe('b">사진');
expect(htmlToText('<a href="x>링크</a> 회의 안내 <b class="y">z</b>')).toBe(
'링크 회의 안내 z',
);
expect(htmlToText('<>글')).toBe('글');
});
it('태그 안에서 < 가 또 나오면 거기서 태그를 다시 시작한다 — 닫히지 않은 < 는 끝까지 삼킨다', () => {
// `< 1000원할인` 은 버려지고 `</p>` 가 태그가 된다
expect(htmlToText('<p>가격 < 1000원할인</p>뒤')).toBe('가격\n뒤');
expect(htmlToText('앞 < 뒤')).toBe('앞');
expect(htmlToText('앞<b')).toBe('앞');
expect(htmlToText('a<<<br>b')).toBe('a\nb');
});
it('<!-- … --> 는 안의 글까지 뺀다 — 닫히지 않으면 끝까지. 그 밖의 <!…>(DOCTYPE · mso 조건)는 보통 태그다', () => {
expect(htmlToText('<!-- 주석 -->보임')).toBe('보임');
expect(htmlToText('<!--[if mso]>숨김<![endif]-->보임')).toBe('보임');
expect(htmlToText('<![if !mso]>보임<![endif]>')).toBe('보임');
expect(htmlToText('<!DOCTYPE html><p>글</p>')).toBe('글\n');
expect(htmlToText('앞<!-- 열림')).toBe('앞');
});
it('글의 이어진 공백(띄어쓰기 · 탭 · CR · LF)은 띄어쓰기 하나 — 태그를 건너서도 하나. 맨 앞 공백은 넣지 않는다', () => {
expect(htmlToText('<p>프로젝트\r\n알파</p>')).toBe('프로젝트 알파\n');
expect(htmlToText('둘 공백\t\t끝')).toBe('둘 공백 끝');
expect(htmlToText('<p>회의 <b> 안내</b></p>')).toBe('회의 안내\n');
expect(htmlToText('<span>회의</span> <span>일정</span>')).toBe('회의 일정');
expect(htmlToText(' \n 앞')).toBe('앞');
// U+00A0 · U+3000 · FF 는 공백이 아니다 (글자 그대로)
expect(htmlToText('a\fb \f c')).toBe('a\fb \f c');
expect(htmlToText('a  b c')).toBe('a  b c');
});
it('문자 참조는 ; 로 끝나는 것만 — 푼 글자는 줄이지 않고 그대로 넣는다(앞의 글 공백을 먼저)', () => {
expect(htmlToText('회의&#32;&#32;안내')).toBe('회의 안내');
expect(htmlToText('회의 &#32;안내')).toBe('회의 안내');
expect(htmlToText('회의&#10;안내')).toBe('회의\n안내');
expect(htmlToText('회의&nbsp;안내 · 회의&nbsp안내')).toBe(
'회의 안내 · 회의&nbsp안내',
);
expect(htmlToText('&#54620;&#xAE00;&#X1F600;')).toBe('한글😀');
expect(
htmlToText('A&amp;B &lt;x&gt; &quot;q&quot; &apos;s&#39; &copy; &Omega;'),
).toBe('A&B <x> "q" \'s\' © Ω');
});
it('풀 수 없는 참조는 글자 그대로 — 모르는 이름(HTML5 에만 있는 이름 포함) · ; 없음 · 숫자 없음 · 너무 긴 이름. 없는 글자는 대체 문자', () => {
expect(htmlToText('&AMP; &period; &amp &; &#; &#x; AT&T')).toBe(
'&AMP; &period; &amp &; &#; &#x; AT&T',
);
expect(htmlToText(`&${'a'.repeat(40)};`)).toBe(`&${'a'.repeat(40)};`);
expect(htmlToText('&#0;&#xD800;&#x110000;')).toBe('���');
expect(htmlToText('&Auml;&auml;')).toBe('Ää');
});
it('글을 빼는 동안에는 참조 · 공백도 넣지 않는다 — 앞에 모아 둔 공백은 다음 글자 앞에 그대로', () => {
expect(htmlToText('회의 <style> &#32; x </style>안내')).toBe('회의 안내');
});
it('이름 있는 문자 참조 표는 HTML 4 의 252개와 &apos; 다', () => {
expect(NAMED_REFERENCE_COUNT).toBe(253);
expect(
htmlToText('&nbsp;&yuml;&times;&divide;&thetasym;&diams;&lang;'),
).toBe(' ÿ×÷ϑ♦⟨');
});
describe('입력 길이에 비례한다 (CPU 시간)', () => {
it.each<[string, () => string]>([
['깊게 열린 태그', () => '<b>'.repeat(TWO_MEGABYTES / 3)],
['닫히지 않은 < 만', () => '<'.repeat(TWO_MEGABYTES)],
['다시 시작하는 태그', () => `${'<a'.repeat(TWO_MEGABYTES / 2)}>`],
['; 없는 & 만', () => '&'.repeat(TWO_MEGABYTES)],
['이름이 긴 & 만', () => `&${'a'.repeat(30)}`.repeat(TWO_MEGABYTES / 31)],
['닫히지 않은 주석 여럿', () => '<!--'.repeat(TWO_MEGABYTES / 4)],
['줄바꿈 태그 여럿', () => '<br>'.repeat(TWO_MEGABYTES / 4)],
[
'글을 빼는 태그 여럿',
() => '<style><head></style>'.repeat(TWO_MEGABYTES / 21),
],
['낱말 · 공백 여럿', () => 'a \n'.repeat(TWO_MEGABYTES / 3)],
])('%s (2MB)', (_label, build) => {
const html = build();
// repeat 가 만든 이어 붙인 글을 재기 전에 한 줄로 편다 (V8 은 처음 찾을 때 편다)
html.indexOf('\u0000');
const cpuMs = startCpuTimer();
htmlToText(html);
expect(cpuMs()).toBeLessThan(LINEAR_CPU_LIMIT_MS);
});
});
});
+473
View File
@@ -0,0 +1,473 @@
/**
* HTML 의 글자 부분 (순수 함수) — 이미 받은 메일에 필터를 적용할 때 HTML 본문을 견주는 글이다
* (계약 2절 "이미 받은 메일에 적용"의 판정 — 새로 오는 메일은 엔진의 Sieve `body :text` 가 같은 일을 한다. mail-filter-match.ts).
* 엔진(Stalwart 0.16.27)의 글자 뽑기에 맞췄다 — 2026-10-29 인증 발송 75통(HTML 본문)으로 엔진과 같은 판정을 확인한 규칙 (jmap/filter-run-texts.spec.ts 의 W · X · Y · Z 표):
*
* - 태그는 `<` 부터 다음 `>` 까지다 — `<` 뒤가 무엇이든 태그다(`a < b` · `<광고>` 도). 따옴표는 보지 않는다(`alt="a>b"` 는 첫 `>` 에서 끝난다).
* 태그 안에서 `<` 가 또 나오면 거기서 태그를 다시 시작한다. 닫히지 않은 `<` 는 끝까지 삼킨다.
* `<!--` 는 `-->` 까지 주석이다(안의 글까지 뺀다). 그 밖의 `<!…>`(DOCTYPE · `<![if !mso]>`)는 보통 태그다
* - 태그의 이름 = 태그 글에서 앞의 공백 · `/` 와 끝의 공백 · `/` 를 뺀 **나머지 전부** — ASCII 대소문자 없이 견준다.
* 닫는 태그 = 첫 공백 아닌 글자가 `/`. 속성이 붙으면 이름이 아니다 — `<br clear="all">` · `<style type=…>` · `<head lang=…>` 는 알아보지 않고,
* `<br />` · `</style >` · `</p >` 는 알아본다
* - 알아보는 이름: `br`(여는 것 · 닫는 것) → 줄바꿈, `p` 의 닫는 것만 → 줄바꿈(여는 `<p>` · `<p/>` 는 아무것도 아니다),
* `style` · `script` · `head` · `template` → 여는 것부터 같은 이름의 닫는 것까지 글을 뺀다(넷을 따로 센다 — 짝을 맞추지 않는다).
* `title` · `noscript` · `textarea` · `iframe` · `object` · `svg` · `xml` 은 글이 남는다. 글을 빼는 동안에도 태그는 본다(그 안의 `</p>` · `<br>` 도 줄바꿈)
* - 그 밖의 태그는 빈자리 없이 뺀다 — 낱말 중간의 인라인 태그(`회<b>의</b>`) · div · li · 표 칸도 이어 붙는다
* - 글의 이어진 공백(띄어쓰기 · 탭 · CR · LF — 그 밖의 제어 문자 · U+00A0 · U+3000 은 글자다)은 띄어쓰기 하나다 — 태그를 건너서도 하나(`회의 <b> 안내` → "회의 안내")
* - 문자 참조는 `;` 로 끝나는 것만 푼다(`&nbsp` 는 그대로) — 이름(HTML 4 의 이름 · `&apos;`) · 10진 · 16진. 푼 글자는 공백 줄이기에 넣지 않고 그대로 넣는다:
* `&#32;&#32;` → 띄어쓰기 둘, `&#10;` → 줄바꿈, `회의 &#32;안내` → "회의 안내"(앞의 글 공백을 먼저 넣는다), `&nbsp;` → U+00A0.
* HTML5 에만 있는 이름(`&period;` · 대문자 `&AMP;` 등 — 엔진은 푼다)은 글자 그대로 둔다(계약의 알려진 한계)
* - 줄바꿈(태그 · 참조가 만든 것)은 찾을 글에 없는 글자라(제어 문자를 받지 않는다) 그 자리를 건너 맞는 글이 없다 — 엔진과 같다
*
* 입력을 한 번 훑는다 — 길이에 비례한다(태그를 다시 시작해도 앞으로만 간다). 정규식 · 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 COMMENT_CLOSE = '-->';
/** 안의 글을 빼는 태그 — 소문자 (넷을 따로 센다) */
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 의 특수 · 기호 문자 참조와 `&apos;` (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],
];
/** 이름 → 글자 (대소문자를 가린다 — `&Auml;` 과 `&auml;` 은 다르다) */
const NAMED_REFERENCES: ReadonlyMap<string, string> = (() => {
const named = new Map<string, string>();
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);
}
/** `<br>` · `</p>` 의 줄바꿈 — 모아 둔 공백은 버린다 (줄바꿈을 건너 맞는 글은 없다) */
lineBreak(): void {
this.pendingSpace = false;
this.pieces.push(LINE_BREAK);
}
toString(): string {
return this.pieces.join('');
}
}
/** 안의 글을 빼는 태그 가운데 열려 있는 것 — 넷을 따로 둔다(짝 · 깊이를 세지 않는다 — 여는 것은 켜고 닫는 것은 끈다) */
class HidingTags {
private readonly open = new Set<HidingTag>();
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 };
}
/** `&#123;` · `&#x1F600;` — 없는 글자(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);
}
+99
View File
@@ -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();
}
}
@@ -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<string, unknown> | 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 });
});
});
@@ -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<EncodedFilterRunCursor>;
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 };
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,143 @@
import { htmlToText } from '@/modules/mail/html/html-text';
import type { MailFilterTexts } from '@/modules/mail/mail-filter-match';
import type { MailFilterField } from '@/modules/mail/mail.types';
import { decodeHeaderText } from './mime-text';
import type { JmapBodyPart, JmapEmail } from './jmap.types';
/**
* 이미 받은 메일에 필터를 적용할 때 엔진의 메일(JMAP Email)에서 견줄 글을 뽑는다 (순수 함수 — 판정은 mail-filter-match.ts).
* 엔진의 Sieve 가 새로 온 메일에서 보는 글과 같게 뽑는다 (sieve-script.ts 의 FIELD_TESTS — 2026-10-29 실배달 20통과
* 인증 발송 94통(HTML 본문 75 · 머리글 19)으로 확인, filter-run-texts.spec.ts 의 표):
* - 보낸 사람 · 받는 사람: 원래 머리글(`header:From:all` · `header:To:all` · `header:Cc:all` — 같은 이름이 여럿이면 모두)을
* 접힌 줄을 펴고 · 홀로 선 RFC 2047 낱말만 풀고 · 앞뒤 공백을 뺀 것 (mime-text.ts 의 decodeHeaderText). 엔진은 원래 글을 앞에 공백 한 칸 ·
* 접힌 줄(CRLF + 공백) · encoded-word 그대로 준다. 이름에 `:asRaw` 를 붙여 물으면 엔진은 응답의 이름에서 그것을 뗀다 — 그래서 붙이지 않는다
* (원래 글은 머리글 속성의 기본 모양이다 — RFC 8621 §4.1.2)
* - 제목: 엔진이 풀어 준 제목(`subject`)
* - 본문: textBody · htmlBody · attachments 의 text/* 부분(같은 partId 는 한 번) — 파일로 붙인 텍스트 첨부도 본다(엔진이 그렇게 본다).
* HTML(text/html — 본문이든 첨부든)은 엔진의 규칙대로 글자만 (html/html-text.ts). 값은 `fetchAllBodyValues` 로 받는다 (jmap-filter-run.ts)
*/
/** 견줄 곳마다 원래 머리글 이름 — 받는 사람은 To · Cc 둘 다 (Sieve 의 `header :contains ["to", "cc"]`) */
const FROM_HEADERS = ['From'] as const;
const RECIPIENT_HEADERS = ['To', 'Cc'] as const;
/** 엔진에 묻는 원래 머리글 속성 — `:all` 은 같은 이름의 머리글을 모두 배열로 준다 (없으면 빈 배열) */
function rawHeaderProperty(name: string): string {
return `header:${name}:all`;
}
/** 본문 부분마다 읽는 속성 — 같은 부분 가리기 · 글자 부분인지 · 크기(나눠 읽을 어림) */
export const FILTER_RUN_PART_PROPERTIES = ['partId', 'type', 'size'];
/** 본문을 읽을 때의 속성 — 그사이 받은편지함에서 빠졌는지(mailboxIds) · 부분 · 값 */
export const FILTER_RUN_BODY_PROPERTIES = [
'id',
'mailboxIds',
'textBody',
'htmlBody',
'attachments',
'bodyValues',
];
/**
* 한 차례에 메일을 읽을 때의 속성 — 받은편지함에 아직 있는지 · 이미 읽음 · 중요인지(바꿀 것만 바꾼다) · 받은 시각(이어 할 자리) · 규칙이 견주는 머리글.
* 본문을 견주면 부분(값은 빼고)도 — 머리글로 판정이 나지 않는 메일만 값을 따로 읽는다
*/
export function filterRunProperties(
fields: ReadonlySet<MailFilterField>,
): string[] {
return [
'id',
'mailboxIds',
'keywords',
'receivedAt',
...(fields.has('from') ? FROM_HEADERS.map(rawHeaderProperty) : []),
...(fields.has('to') ? RECIPIENT_HEADERS.map(rawHeaderProperty) : []),
...(fields.has('subject') ? ['subject'] : []),
...(fields.has('body') ? ['textBody', 'htmlBody', 'attachments'] : []),
];
}
/**
* 원래 머리글의 값들 — 물은 이름(`header:From:all`)으로 찾되 대소문자 · `:asRaw` 가 달라도 찾는다
* (엔진이 응답의 속성 이름을 바꿔 주더라도 머리글이 없는 것처럼 보이지 않게 — 그러면 "포함하지 않음"이 모두 맞는다)
*/
function rawHeaderValues(email: JmapEmail, name: string): string[] {
const wanted = new Set([
rawHeaderProperty(name).toLowerCase(),
`header:${name}:asRaw:all`.toLowerCase(),
]);
for (const [key, value] of Object.entries(email)) {
if (!wanted.has(key.toLowerCase())) continue;
if (typeof value === 'string') return [value];
return Array.isArray(value)
? value.filter((item): item is string => typeof item === 'string')
: [];
}
return [];
}
function decodedHeaders(email: JmapEmail, names: readonly string[]): string[] {
return names.flatMap((name) =>
rawHeaderValues(email, name).map(decodeHeaderText),
);
}
/** 머리글의 견줄 글 — 보낸 사람 · 받는 사람 · 제목 (본문은 bodyTextsOf) */
export function headerTextsOf(email: JmapEmail): Omit<MailFilterTexts, 'body'> {
const subject = email.subject;
return {
from: decodedHeaders(email, FROM_HEADERS),
to: decodedHeaders(email, RECIPIENT_HEADERS),
subject: typeof subject === 'string' ? [subject] : [],
};
}
function isTextPart(part: JmapBodyPart): boolean {
return (part.type ?? '').toLowerCase().startsWith('text/');
}
/** 글자 부분 — textBody · htmlBody · attachments 차례로, 같은 partId 는 한 번 (한 부분이 textBody 와 htmlBody 에 함께 오기도 한다) */
export function textPartsOf(email: JmapEmail): JmapBodyPart[] {
const seen = new Set<string>();
const parts: JmapBodyPart[] = [];
for (const part of [
...(email.textBody ?? []),
...(email.htmlBody ?? []),
...(email.attachments ?? []),
]) {
const { partId } = part;
if (!partId || seen.has(partId)) continue;
seen.add(partId);
if (isTextPart(part)) parts.push(part);
}
return parts;
}
/** 본문의 견줄 글 — 글자 부분마다(HTML 은 글자만). 값을 받지 못한 부분은 뺀다 */
export function bodyTextsOf(email: JmapEmail): string[] {
return textPartsOf(email).flatMap((part) => {
const value = email.bodyValues?.[part.partId ?? '']?.value;
if (typeof value !== 'string') return [];
return [
(part.type ?? '').toLowerCase() === 'text/html'
? htmlToText(value)
: value,
];
});
}
/** 본문 값을 받을 때 응답 크기의 어림(바이트) — 부분마다 상한까지, 문자셋을 UTF-8 로 바꾸며 늘 수 있어 두 배로 (크기를 모르면 상한) */
export function estimatedBodyBytes(
email: JmapEmail,
maxPartBytes: number,
): number {
return textPartsOf(email).reduce(
(sum, part) =>
sum +
(typeof part.size === 'number' && part.size >= 0
? Math.min(maxPartBytes, part.size * 2)
: maxPartBytes),
0,
);
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,773 @@
import { Injectable, Logger } from '@nestjs/common';
import { BusinessException } from '@/common/errors/business.exception';
import {
filterFields,
judgeFilter,
type MailFilterTexts,
} from '@/modules/mail/mail-filter-match';
import type {
Mailbox,
MailFilterRunContext,
MailFilterRunOutcome,
MailFilterRunRequest,
} from '@/modules/mail/mail.types';
import { BulkRun } from './bulk-run';
import {
MAX_FILTER_RUN_SECONDS,
decodeFilterRunCursor,
encodeFilterRunCursor,
} from './filter-run-cursor';
import {
FILTER_RUN_BODY_PROPERTIES,
FILTER_RUN_PART_PROPERTIES,
bodyTextsOf,
estimatedBodyBytes,
filterRunProperties,
headerTextsOf,
textPartsOf,
} from './filter-run-texts';
import { JmapClient, type JmapCallOptions } from './jmap.client';
import { jmapFailure } from './jmap.errors';
import { toJmapUtcDate } from './jmap.mapper';
import {
expectResult,
findResult,
firstRefusal,
isState,
warnRefusals,
} from './jmap-results';
import type {
JmapEmail,
JmapEmailFilterCondition,
JmapGetResult,
JmapInvocation,
JmapQueryResult,
JmapSetResult,
} from './jmap.types';
/**
* 한 차례에 확인하는 메일 수 — 머리글만 견주면 엔진의 한 번 처리 한도(maxObjectsInGet · maxObjectsInSet 500)까지,
* 본문을 견주면 줄인다(본문 값을 읽는 시간 · 크기 — 차례가 남은 시간 안에 끝나게).
* 측정(개발 엔진, 2026-10-29, 시험 메일 5,000통): 머리글 500통 읽기 0.17~0.19초 · 본문 2,000통 1초 안팎 · 500통 옮기기 0.35초
*/
const HEADER_ROUND_SIZE = 500;
const BODY_ROUND_SIZE = 100;
/**
* 본문을 읽는 상한 (UTF-8 바이트 — 계약의 "한 부분은 앞 256KiB 까지, 한 통은 모두 합쳐 1MiB 까지") — 넘는 뒤쪽은 견주지 않는다.
* 글자 부분이 많아 한 통의 몫을 넘으면 부분마다 같은 몫(1MiB ÷ 부분 수)만큼 앞쪽만 읽는다 — 바깥에서 온 메일 한 통으로 서버가 메모리를 크게 쓰지 않게.
* 텍스트 · HTML 대안 본문이 함께 오는 메일은 텍스트 쪽이 작아 긴 메일의 뒷부분도 대개 이 안에 든다
*/
export const FILTER_RUN_BODY_VALUE_BYTES = 262_144;
export const FILTER_RUN_MESSAGE_BODY_BYTES = 1_048_576;
/** 본문을 한 번에 읽는 양의 어림 상한 — 부분의 크기로 어림해 넘으면 나눠 읽는다 (응답 하나가 수십 MB 가 되지 않게) */
const BODY_FETCH_BUDGET_BYTES = 4_000_000;
/** 엔진의 한 번 읽기 한도 (maxObjectsInGet) */
const MAX_IDS_PER_GET = 500;
/** 받은 시각이 최근인 메일부터 — 같은 받은 시각의 메일은 엔진이 만든 차례로 늘 같게 준다 (2026-10-29 확인) */
const NEWEST_FIRST = [{ property: 'receivedAt', isAscending: false }];
/** JMAP 메서드 오류 — ifInState 가 지금 상태와 다르다 · 지난 차례의 마지막 메일(anchor)이 받은편지함에 없다 (RFC 8620 §5.3 · §5.5) */
const STATE_MISMATCH = 'stateMismatch';
const ANCHOR_NOT_FOUND = 'anchorNotFound';
/** SetError — 그 메일이 없다 (그사이 지워짐) */
const NOT_FOUND = 'notFound';
const MS_PER_SECOND = 1_000;
/**
* 한 통의 본문을 부분마다 읽는 몫 (바이트) — 한 부분은 FILTER_RUN_BODY_VALUE_BYTES 까지, 한 통은 모두 합쳐 FILTER_RUN_MESSAGE_BODY_BYTES 까지.
* 글자 부분이 많으면 같은 몫씩 나눈다 (적어도 1바이트). 엔진의 maxBodyValueBytes 는 요청 단위라 몫이 다른 메일은 따로 읽는다
*/
export function bodyValueQuota(textParts: number): number {
if (textParts <= 0) return FILTER_RUN_BODY_VALUE_BYTES;
return Math.max(
1,
Math.min(
FILTER_RUN_BODY_VALUE_BYTES,
Math.floor(FILTER_RUN_MESSAGE_BODY_BYTES / textParts),
),
);
}
/** 이어 할 자리 (filter-run-cursor.ts 의 FilterRunPosition) — 위 경계는 첫 차례를 읽기 전에는 모른다(null) */
interface RunState {
readonly before: number | null;
readonly anchor: string | null;
readonly anchorTime: number | null;
}
/** 할 일 — 맞은 메일의 패치를 만든다 */
interface RunPlan {
readonly inboxId: string;
/** 옮길 메일함 — 옮기지 않으면 null (규칙에 없음 · 그 메일함이 없음 · 받은편지함) */
readonly moveTo: string | null;
readonly markAsRead: boolean;
readonly flag: boolean;
}
/** 한 차례에 무엇을 읽나 — 속성 · 본문 부분을 읽는지 · 메일 수 */
interface PageRead {
readonly properties: readonly string[];
readonly needsBody: boolean;
readonly limit: number;
}
/** 조회의 시작 — 위 경계(없으면 걸지 않는다)와 anchor 다음, 또는 그 경계의 처음 */
type PageStart = { readonly before: number | null } & (
| { readonly anchor: string }
| { readonly fromTop: true }
);
/** 한 차례에 읽은 받은편지함의 메일 */
interface RoundPage {
/** 첫 메일의 자리 (그 조회의 경계 안에서) */
readonly start: number;
/** 받은 시각 내림차순 */
readonly ids: readonly string[];
/** 그 조회의 경계 안 받은편지함 메일 수 — 엔진이 세어 주지 않으면 어림 */
readonly total: number;
readonly emails: ReadonlyMap<string, JmapEmail>;
/** 이 읽기의 Email 상태 — 옮길 때 ifInState 에 쓴다 (엔진이 알려 주지 않으면 null) */
readonly state: string | null;
/** 이 차례를 읽은 자리 — anchor 가 그사이 빠졌으면 anchor 를 지운 것 (그 받은 시각부터 다시 읽었다) */
readonly from: RunState;
}
/** 한 차례를 마친 결과 */
interface RoundResult {
/** 판정한 메일 수 — 조회와 읽기 사이에 받은편지함에서 빠진 메일은 세지 않는다 */
readonly judged: number;
/** 맞아서 할 일을 한 메일 수 (이미 읽음 · 중요였던 메일 포함) */
readonly matched: number;
/** 받은편지함에 남긴 마지막 메일과 그 받은 시각(초 — 엔진이 주지 않았으면 null) — 남긴 메일이 없으면 null */
readonly lastStayed: {
readonly id: string;
readonly time: number | null;
} | null;
}
/** 맞은 메일에 할 일을 한 결과 */
interface Applied {
readonly updated: ReadonlySet<string>;
/** 그사이 받은편지함에서 빠졌거나 없어진 메일 — 하지 않았다 */
readonly gone: ReadonlySet<string>;
}
/** 본문 값을 한 번에 읽을 메일 — 부분마다의 몫이 같은 메일끼리 */
interface BodyBatch {
readonly quota: number;
readonly ids: readonly string[];
}
/** 옮길 메일함 — 규칙에 없거나 · 받은편지함이거나 · 메일함 목록에 없으면(지워짐) 옮기지 않는다 (계약 — 표시는 한다) */
function runPlan(
request: MailFilterRunRequest,
inboxId: string,
mailboxes: readonly Mailbox[],
): RunPlan {
const target = request.actions.moveToMailboxId;
const moveTo =
target !== null &&
target !== inboxId &&
mailboxes.some((mailbox) => mailbox.id === target)
? target
: null;
return {
inboxId,
moveTo,
markAsRead: request.actions.markAsRead,
flag: request.actions.flag,
};
}
/** 맞은 메일 한 통의 패치 — 이미 있는 표시는 다시 붙이지 않는다. 바꿀 것이 없으면 null */
function emailPatch(
plan: RunPlan,
email: JmapEmail | undefined,
): Record<string, unknown> | null {
const patch: Record<string, unknown> = {};
if (plan.markAsRead && email?.keywords?.$seen !== true) {
patch['keywords/$seen'] = true;
}
if (plan.flag && email?.keywords?.$flagged !== true) {
patch['keywords/$flagged'] = true;
}
if (plan.moveTo !== null) {
patch[`mailboxIds/${plan.inboxId}`] = null;
patch[`mailboxIds/${plan.moveTo}`] = true;
}
return Object.keys(patch).length > 0 ? patch : null;
}
/** 받은 시각(초) — 엔진은 초 단위로 둔다(2026-10-29 확인). 읽을 수 없으면 null */
function receivedSeconds(email: JmapEmail | undefined): number | null {
const time = email?.receivedAt ? Date.parse(email.receivedAt) : Number.NaN;
if (!Number.isFinite(time)) return null;
const seconds = Math.floor(time / MS_PER_SECOND);
return seconds >= 0 && seconds < MAX_FILTER_RUN_SECONDS ? seconds : null;
}
/** 초 → `before` 조건의 UTCDate */
function utcDate(seconds: number): string {
return toJmapUtcDate(new Date(seconds * MS_PER_SECOND).toISOString());
}
/** 차례에서 가장 최근의 받은 시각(초) — 받은편지함 맨 위 메일 (읽은 메일이 없으면 null) */
function newestSecond(page: RoundPage): number | null {
let newest: number | null = null;
for (const email of page.emails.values()) {
const seconds = receivedSeconds(email);
if (seconds !== null && (newest === null || seconds > newest)) {
newest = seconds;
}
}
return newest;
}
/**
* 본문 값을 읽을 묶음 — 부분마다의 몫(bodyValueQuota)이 같은 메일끼리, 어림한 크기가 상한을 넘거나 엔진의 한 번 읽기 한도에 닿으면 나눈다.
* 한 통의 어림은 몫 × 부분 수 이하라(1MiB 안쪽) 한 묶음에 여러 통이 든다
*/
function bodyBatches(emails: readonly JmapEmail[]): BodyBatch[] {
const groups = new Map<number, JmapEmail[]>();
for (const email of emails) {
const quota = bodyValueQuota(textPartsOf(email).length);
const group = groups.get(quota);
if (group) group.push(email);
else groups.set(quota, [email]);
}
const batches: BodyBatch[] = [];
for (const [quota, group] of groups) {
let ids: string[] = [];
let bytes = 0;
for (const email of group) {
const estimate = estimatedBodyBytes(email, quota);
if (
ids.length > 0 &&
(bytes + estimate > BODY_FETCH_BUDGET_BYTES ||
ids.length >= MAX_IDS_PER_GET)
) {
batches.push({ quota, ids });
ids = [];
bytes = 0;
}
ids.push(email.id);
bytes += estimate;
}
if (ids.length > 0) batches.push({ quota, ids });
}
return batches;
}
/**
* 받은편지함에 이미 있는 메일에 필터 규칙 하나를 적용한다 (계약 2절 "이미 받은 메일에 적용" — 7단계).
* 엔진의 Sieve 는 메일을 받을 때만 돌기 때문에, 받은편지함을 차례로 읽어 Sieve 와 같은 규칙으로 판정하고(mail-filter-match.ts) 표시 · 옮기기를 한다.
* 엔진의 검색(`Email/query` 의 글자 조건)은 쓰지 않는다 — 낱말이 통째로 같을 때만 맞고 `header` 조건은 아무것도 찾지 못한다(Stalwart 0.16.27).
*
* - 차례마다: `Email/query`(받은편지함 · 위 경계 미만 · 받은 시각 내림차순 · 지난 차례에 남긴 마지막 메일 다음부터) + `Email/get`(받은편지함에 아직 있는지 ·
* 표시 · 받은 시각 · 견줄 머리글 · 본문 조건이면 부분) → 판정 → 머리글로 판정이 나지 않은 메일만 본문 값을 나눠 읽어 판정 → 맞은 메일에 `Email/set` 한 번
* - 적용을 시작한 때 받은편지함에 있던 메일만 본다 — 첫 차례가 맨 위 메일의 받은 시각 + 1초를 위 경계로 잡는다 (filter-run-cursor.ts)
* - 확인하는 사이 받은편지함에서 빠진 메일은 건너뛰고 세지 않는다. 옮기기는 읽은 상태 그대로일 때만(ifInState) — 그사이 다른 곳으로 옮긴 메일을
* 옮길 메일함에 더 넣지 않게. 어긋나면 맞은 메일의 메일함을 다시 읽어 남은 것만 다시 한다. 표시만 할 때는 조건 없이(되돌릴 수 있는 표시)
* - 영구 삭제는 하지 않는다 — 이 파일에는 Email/set 의 destroy 가 없다(휴지통으로 옮기는 규칙도 옮기기다)
* - 한 요청의 시간 · 멈춤은 BulkRun(bulk-run.ts) — 메일함의 오래 걸리는 처리와 같다. 시간은 서비스에 들어온 때부터(context.startedAt),
* 진행(processed)은 판정한 메일 수다 — 판정한 것이 없는 차례는 멈춘 차례로 센다
* - 이어 할 자리는 cursor — 한 차례를 다 마친 곳까지만 (도중에 멈춘 차례는 다음 요청이 다시 한다 — 할 일은 다시 해도 같다)
* - 받은편지함 · 옮길 메일함은 서비스가 읽은 메일함 목록(context.mailboxes)에서 찾는다 — 다시 읽지 않는다
*/
@Injectable()
export class JmapFilterRunner {
private readonly logger = new Logger(JmapFilterRunner.name);
constructor(private readonly jmap: JmapClient) {}
async run(
user: string,
request: MailFilterRunRequest,
context: MailFilterRunContext,
): Promise<MailFilterRunOutcome> {
// 이어 할 자리는 엔진에 묻기 전에 본다 — 다른 규칙 · 깨진 cursor 면 VAL_001
let position: RunState =
request.cursor === null
? { before: null, anchor: null, anchorTime: null }
: decodeFilterRunCursor(request.cursor, request);
const run = new BulkRun(context.startedAt);
const inbox = context.mailboxes.find((mailbox) => mailbox.role === 'inbox');
// 받은편지함이 없는 계정 — 적용할 메일이 없다
if (!inbox) {
return { scanned: 0, matched: 0, remaining: 0, cursor: null, rounds: 0 };
}
const plan = runPlan(request, inbox.id, context.mailboxes);
const fields = filterFields(request);
const needsBody = fields.has('body');
const read: PageRead = {
properties: filterRunProperties(fields),
needsBody,
limit: needsBody ? BODY_ROUND_SIZE : HEADER_ROUND_SIZE,
};
let scanned = 0;
let matched = 0;
let rounds = 0;
let remaining = inbox.totalCount;
let finished = false;
try {
while (run.canStartRound()) {
rounds += 1;
const page = await this.readPage(user, inbox.id, position, read, run);
position = page.from;
if (page.ids.length === 0) {
finished = true;
break;
}
// 첫 차례 — 맨 위 메일의 받은 시각 + 1초를 위 경계로 (그 뒤에 온 메일은 보지 않는다)
const newest = position.before === null ? newestSecond(page) : null;
if (newest !== null) position = { ...position, before: newest + 1 };
const round = await this.applyRound(user, request, plan, page, run);
// 멈춘다 — 이 차례는 하지 않은 것으로 (다음 요청이 같은 자리부터 다시 한다)
if (round === null) break;
const exhausted = page.start + page.ids.length >= page.total;
remaining = Math.max(0, page.total - (page.start + page.ids.length));
if (round.lastStayed !== null) {
position = {
...position,
anchor: round.lastStayed.id,
anchorTime: round.lastStayed.time,
};
}
if (round.judged > 0) {
scanned += round.judged;
matched += round.matched;
run.progress(round.judged);
}
if (exhausted) {
finished = true;
break;
}
// 읽은 메일이 모두 그사이 빠졌다 — 다시 읽는다 (이어서 그러면 멈춘다)
if (round.judged === 0 && run.stalled('nothing-judged')) break;
}
} catch (error) {
if (!run.cutOffAfterProgress(error)) throw error;
}
if (!finished) run.assertProgress('filter-run');
return {
scanned,
matched,
remaining: finished ? 0 : remaining,
cursor: finished ? null : this.cursorOf(position, request),
rounds,
};
}
/**
* 이어 할 자리 — 위 경계와 남긴 마지막 메일의 받은 시각이 있어야 한다. 없으면 엔진이 받은 시각을 주지 않은 것 — MAIL_001
* (끝까지 했으면 이어 할 자리가 필요 없어 묻지 않는다)
*/
private cursorOf(position: RunState, request: MailFilterRunRequest): string {
if (
position.before === null ||
(position.anchor !== null && position.anchorTime === null)
) {
throw new BusinessException('MAIL_001', {
context: { reason: 'received-at-missing' },
});
}
return encodeFilterRunCursor(
{
before: position.before,
anchor: position.anchor,
anchorTime: position.anchorTime,
},
request,
);
}
/**
* 한 차례의 메일 — 지난 차례에 받은편지함에 남긴 마지막 메일(anchor) 다음부터 위 경계 미만에서. 그 메일이 그사이 빠졌으면(anchorNotFound)
* 그 받은 시각의 메일부터(`before: anchorTime + 1초` 의 처음) 다시 읽는다 — 그 초의 메일을 다시 판정해도 건너뛰지는 않는다.
* anchor 가 없으면(아직 남긴 메일이 없다 · 빠진 anchor 뒤에 남긴 메일이 없다) 그 경계의 처음부터 읽는다 — 앞서 본 메일은 모두 옮겨 나갔다.
* 받은 시각을 모르면(엔진이 주지 않았다) 위 경계의 처음부터 — 다시 판정하는 메일이 늘 뿐 건너뛰지는 않는다
*/
private async readPage(
user: string,
inboxId: string,
from: RunState,
read: PageRead,
run: BulkRun,
): Promise<RoundPage> {
if (from.anchor !== null) {
const responses = await this.queryPage(
user,
inboxId,
{ before: from.before, anchor: from.anchor },
read,
run.callOptions(),
);
const query = findResult(responses, 'q', 'Email/query');
if (query.ok || query.error.type !== ANCHOR_NOT_FOUND) {
return this.toPage(responses, from, read);
}
}
const restart: RunState = { ...from, anchor: null };
const before =
restart.anchorTime !== null ? restart.anchorTime + 1 : restart.before;
const responses = await this.queryPage(
user,
inboxId,
{ before, fromTop: true },
read,
run.callOptions(),
);
return this.toPage(responses, restart, read);
}
private toPage(
responses: JmapInvocation[],
from: RunState,
read: PageRead,
): RoundPage {
const query = expectResult<JmapQueryResult>(responses, 'q', 'Email/query');
const found = expectResult<JmapGetResult<JmapEmail>>(
responses,
'g',
'Email/get',
);
const ids = query.ids ?? [];
const start = query.position ?? 0;
const known = typeof query.total === 'number' ? query.total : null;
return {
start,
ids,
total:
known !== null && known >= start + ids.length
? known
: start + ids.length + (ids.length === read.limit ? 1 : 0),
emails: new Map((found.list ?? []).map((email) => [email.id, email])),
state: isState(found.state) ? found.state : null,
from,
};
}
/**
* 조회 + 읽기를 한 요청으로 — 같은 순간의 받은편지함이다. 위 경계는 `before`(미만)로 건다.
* anchor 는 cursor 에서 왔을 수 있다 — 엔진이 그 모양을 해석하지 못해 요청 전체를 거부하면 깨진 cursor 와 같게 VAL_001
*/
private queryPage(
user: string,
inboxId: string,
start: PageStart,
read: PageRead,
options: JmapCallOptions,
): Promise<JmapInvocation[]> {
const filter: JmapEmailFilterCondition =
start.before === null
? { inMailbox: inboxId }
: { inMailbox: inboxId, before: utcDate(start.before) };
const byAnchor = 'anchor' in start;
return this.jmap.call(
user,
(accountId) => [
[
'Email/query',
{
accountId,
filter,
sort: NEWEST_FIRST,
...(byAnchor
? { anchor: start.anchor, anchorOffset: 1 }
: { position: 0 }),
limit: read.limit,
calculateTotal: true,
},
'q',
],
[
'Email/get',
{
accountId,
'#ids': { resultOf: 'q', name: 'Email/query', path: '/ids' },
properties: [...read.properties],
...(read.needsBody
? { bodyProperties: FILTER_RUN_PART_PROPERTIES }
: {}),
},
'g',
],
],
byAnchor ? { ...options, invalidRequestCode: 'VAL_001' } : options,
);
}
/**
* 한 차례 — 판정하고 맞은 메일에 할 일을 한다. 멈춰야 하면(상태가 계속 어긋남 · 엔진이 모두 거절했는데 앞 차례의 진행이 있음) null
*/
private async applyRound(
user: string,
request: MailFilterRunRequest,
plan: RunPlan,
page: RoundPage,
run: BulkRun,
): Promise<RoundResult | null> {
/** 판정 — 'gone' 은 받은편지함에서 빠진 메일(건너뛰고 세지 않는다) */
const verdicts = new Map<string, boolean | 'gone'>();
const undecided: JmapEmail[] = [];
const headerTexts = new Map<string, Omit<MailFilterTexts, 'body'>>();
for (const id of page.ids) {
const email = page.emails.get(id);
if (email?.mailboxIds?.[plan.inboxId] !== true) {
verdicts.set(id, 'gone');
continue;
}
const texts = headerTextsOf(email);
const verdict = judgeFilter(request, { ...texts, body: null });
if (verdict !== null) {
verdicts.set(id, verdict);
} else if (textPartsOf(email).length === 0) {
// 글자 부분이 없다 — 본문 값을 읽지 않고 빈 본문으로 판정한다
verdicts.set(id, judgeFilter(request, { ...texts, body: [] }) === true);
} else {
undecided.push(email);
headerTexts.set(id, texts);
}
}
// 머리글로 판정이 나지 않은 메일만 본문 값을 읽는다 — 한 통의 몫 · 어림한 크기로 나눠서
for (const batch of bodyBatches(undecided)) {
const bodies = await this.readBodies(
user,
batch.ids,
batch.quota,
run.callOptions(),
);
for (const id of batch.ids) {
const email = bodies.get(id);
const texts = headerTexts.get(id);
if (email?.mailboxIds?.[plan.inboxId] !== true || !texts) {
verdicts.set(id, 'gone');
continue;
}
verdicts.set(
id,
judgeFilter(request, { ...texts, body: bodyTextsOf(email) }) === true,
);
}
}
const patches = new Map<string, Record<string, unknown>>();
let unchanged = 0;
for (const id of page.ids) {
if (verdicts.get(id) !== true) continue;
const patch = emailPatch(plan, page.emails.get(id));
if (patch === null) unchanged += 1;
else patches.set(id, patch);
}
const applied: Applied | null =
patches.size === 0
? { updated: new Set(), gone: new Set() }
: await this.applyPatches(user, plan, page.state, patches, run);
if (applied === null) return null;
let judged = 0;
let lastStayed: string | null = null;
for (const id of page.ids) {
const verdict = verdicts.get(id);
if (verdict === undefined || verdict === 'gone') continue;
judged += 1;
const moved = plan.moveTo !== null && applied.updated.has(id);
if (!moved && !applied.gone.has(id)) lastStayed = id;
}
return {
judged,
matched: unchanged + applied.updated.size,
lastStayed:
lastStayed === null
? null
: {
id: lastStayed,
time: receivedSeconds(page.emails.get(lastStayed)),
},
};
}
/**
* 맞은 메일에 할 일을 한다 — 엔진 요청 한 번(Email/set update). 옮기기가 있으면 읽은 상태 그대로일 때만(ifInState):
* 어긋나면 맞은 메일의 메일함을 다시 읽고(그사이 받은편지함에서 빠진 것은 뺀다) 그 상태로 다시 한다 — 이어서 어긋나면 BulkRun 이 멈춘다.
* 표시만 할 때는 조건 없이 한다 (되돌릴 수 있는 표시 — 메일함 전체 읽음과 같다).
* 엔진이 모두 거절하면 — 앞 차례의 진행이 있으면 멈추고(null), 없으면 그 거절의 코드로 던진다 (클라이언트가 끝없이 다시 보내지 않게)
*/
private async applyPatches(
user: string,
plan: RunPlan,
state: string | null,
patches: ReadonlyMap<string, Record<string, unknown>>,
run: BulkRun,
): Promise<Applied | null> {
const gone = new Set<string>();
let pending = patches;
let ifInState = state;
for (;;) {
// 상태를 모르면 조건 없이 옮기지 않는다
if (plan.moveTo !== null && ifInState === null) {
throw new BusinessException('MAIL_001', {
context: { reason: 'inbox-state-unknown' },
});
}
const set = await this.updateEmails(
user,
pending,
plan.moveTo === null ? null : ifInState,
run.callOptions(),
);
if (set !== null) {
const updated = new Set(
Object.keys(set.updated ?? {}).filter((id) => pending.has(id)),
);
for (const [id, error] of Object.entries(set.notUpdated ?? {})) {
if (error?.type === NOT_FOUND) gone.add(id);
}
warnRefusals(
this.logger,
user,
set.notUpdated,
'이미 받은 메일에 필터를 적용하며 바꾸지',
);
const refusal = firstRefusal(set.notUpdated);
if (updated.size === 0 && refusal) {
if (run.processed > 0) return null;
throw jmapFailure(refusal, { method: 'Email/set' });
}
return { updated, gone };
}
if (run.stalled('state-mismatch')) return null;
const fresh = await this.readInboxMembership(
user,
plan.inboxId,
[...pending.keys()],
run.callOptions(),
);
ifInState = fresh.state;
const still = new Map<string, Record<string, unknown>>();
for (const [id, patch] of pending) {
if (fresh.inInbox.has(id)) still.set(id, patch);
else gone.add(id);
}
pending = still;
if (pending.size === 0) return { updated: new Set(), gone };
}
}
/**
* Email/set 의 고치기만 — 지우기(destroy)는 보내지 않는다. ifInState 가 있으면 어긋났을 때(stateMismatch) null.
* 그 밖의 메서드 오류는 그 코드로 던진다
*/
private async updateEmails(
user: string,
patches: ReadonlyMap<string, Record<string, unknown>>,
ifInState: string | null,
options: JmapCallOptions,
): Promise<JmapSetResult | null> {
const responses = await this.jmap.call(
user,
(accountId) => [
[
'Email/set',
{
accountId,
...(ifInState === null ? {} : { ifInState }),
update: Object.fromEntries(patches),
},
'u',
],
],
options,
);
const result = findResult(responses, 'u', 'Email/set');
if (!result.ok) {
if (ifInState !== null && result.error.type === STATE_MISMATCH) {
return null;
}
throw jmapFailure(result.error, { method: 'Email/set' });
}
return result.args;
}
/** 맞은 메일이 아직 받은편지함에 있는지와 그때의 상태 — 상태가 어긋난 뒤 다시 할 때 */
private async readInboxMembership(
user: string,
inboxId: string,
ids: readonly string[],
options: JmapCallOptions,
): Promise<{ readonly state: string | null; readonly inInbox: Set<string> }> {
const responses = await this.jmap.call(
user,
(accountId) => [
[
'Email/get',
{ accountId, ids: [...ids], properties: ['id', 'mailboxIds'] },
'g',
],
],
options,
);
const found = expectResult<JmapGetResult<JmapEmail>>(
responses,
'g',
'Email/get',
);
return {
state: isState(found.state) ? found.state : null,
inInbox: new Set(
(found.list ?? [])
.filter((email) => email.mailboxIds?.[inboxId] === true)
.map((email) => email.id),
),
};
}
/** 본문 값 — 글자 부분(text/*) 모두, 부분마다 그 몫까지 (fetchAllBodyValues — 텍스트 첨부도 온다) */
private async readBodies(
user: string,
ids: readonly string[],
quota: number,
options: JmapCallOptions,
): Promise<Map<string, JmapEmail>> {
const responses = await this.jmap.call(
user,
(accountId) => [
[
'Email/get',
{
accountId,
ids: [...ids],
properties: FILTER_RUN_BODY_PROPERTIES,
bodyProperties: ['partId', 'type'],
fetchAllBodyValues: true,
maxBodyValueBytes: quota,
},
'b',
],
],
options,
);
const { list = [] } = expectResult<JmapGetResult<JmapEmail>>(
responses,
'b',
'Email/get',
);
return new Map(list.map((email) => [email.id, email]));
}
}
@@ -317,6 +317,7 @@ describe('JmapFilterScriptStore — 필터 · 부재중 자동 응답의 저장
toDate: null,
subject: '4단계 부재중',
textBody: '휴가입니다.',
isActive: true,
});
expect(engine.recorded[0]).toEqual({
calls: [
@@ -336,11 +337,12 @@ describe('JmapFilterScriptStore — 필터 · 부재중 자동 응답의 저장
expect(engine.client.downloadBytes).not.toHaveBeenCalled();
});
it('한 번도 설정하지 않은 계정은 끈 부재중 · 빈 제목과 내용', async () => {
it('한 번도 설정하지 않은 계정은 끈 부재중 · 빈 제목과 내용 · 동작함(막힌 것이 없다)', async () => {
const engine = new FakeSieveEngine();
await expect(engine.store.getVacation(ARA)).resolves.toEqual(
NEVER_CONFIGURED_VACATION,
);
await expect(engine.store.getVacation(ARA)).resolves.toEqual({
...NEVER_CONFIGURED_VACATION,
isActive: true,
});
});
it('VacationResponse 의 null 제목 · 내용은 빈 문자열로 이어받는다 — 처음 저장하면 그 값이 우리 스크립트에 들어간다', async () => {
@@ -362,7 +364,10 @@ describe('JmapFilterScriptStore — 필터 · 부재중 자동 응답의 저장
textBody: '',
};
await expect(engine.store.getVacation(ARA)).resolves.toEqual(imported);
await expect(engine.store.getVacation(ARA)).resolves.toEqual({
...imported,
isActive: true,
});
await engine.store.setFilters(ARA, {
rules: [rule('r1')],
version: filterRulesVersion([]),
@@ -380,7 +385,10 @@ describe('JmapFilterScriptStore — 필터 · 부재중 자동 응답의 저장
version: filterRulesVersion(rules),
isActive: true,
});
await expect(engine.store.getVacation(ARA)).resolves.toEqual(vacationOn);
await expect(engine.store.getVacation(ARA)).resolves.toEqual({
...vacationOn,
isActive: true,
});
expect(engine.client.downloadBytes).toHaveBeenCalledWith(
ARA,
ours.blobId,
@@ -399,7 +407,11 @@ describe('JmapFilterScriptStore — 필터 · 부재중 자동 응답의 저장
version: filterRulesVersion(rules),
isActive: false,
});
await expect(engine.store.getVacation(ARA)).resolves.toEqual(vacationOn);
// 부재중 응답도 같은 isActive 를 알린다 — 켜 둔 값(isEnabled)은 저장한 그대로다 (7단계)
await expect(engine.store.getVacation(ARA)).resolves.toEqual({
...vacationOn,
isActive: false,
});
});
it('원본이 깨졌으면 규칙 없음 · 부재중 꺼짐 · isActive false — 켜져 있어도, 글이 너무 커도', async () => {
@@ -421,9 +433,10 @@ describe('JmapFilterScriptStore — 필터 · 부재중 자동 응답의 저장
version: filterRulesVersion([]),
isActive: false,
});
await expect(engine.store.getVacation(ARA)).resolves.toEqual(
NEVER_CONFIGURED_VACATION,
);
await expect(engine.store.getVacation(ARA)).resolves.toEqual({
...NEVER_CONFIGURED_VACATION,
isActive: false,
});
}
});
@@ -456,9 +469,10 @@ describe('JmapFilterScriptStore — 필터 · 부재중 자동 응답의 저장
version: filterRulesVersion([]),
isActive: false,
});
await expect(engine.store.getVacation(ARA)).resolves.toEqual(
NEVER_CONFIGURED_VACATION,
);
await expect(engine.store.getVacation(ARA)).resolves.toEqual({
...NEVER_CONFIGURED_VACATION,
isActive: false,
});
});
it('필터 저장 · 부재중 저장 모두 쓰지 않는다 — 503 MAIL_001(filter-source-newer), 글은 그대로 남는다', async () => {
@@ -932,13 +946,29 @@ describe('JmapFilterScriptStore — 필터 · 부재중 자동 응답의 저장
engine.addOurs({ rules, vacation: NEVER_CONFIGURED_VACATION });
const before = await engine.store.getFilters(ARA);
await expect(engine.store.setVacation(ARA, vacationOn)).resolves.toEqual(
vacationOn,
);
await expect(engine.store.setVacation(ARA, vacationOn)).resolves.toEqual({
...vacationOn,
isActive: true,
});
expect(engine.ourSource()).toEqual({ rules, vacation: vacationOn });
await expect(engine.store.getFilters(ARA)).resolves.toEqual(before);
});
it('엔진에서 꺼져 있던 우리 규칙은 부재중을 저장하면 다시 켜진다 — 응답의 isActive 도 true (7단계)', async () => {
const engine = new FakeSieveEngine();
engine.addOurs({ rules: [rule('r1')], vacation: vacationOn });
engine.addScript('vacation', 'require ["vacation"];\r\n', true);
await expect(engine.store.getVacation(ARA)).resolves.toMatchObject({
isActive: false,
});
await expect(engine.store.setVacation(ARA, vacationOn)).resolves.toEqual({
...vacationOn,
isActive: true,
});
expect(engine.ours()?.isActive).toBe(true);
});
it('우리 스크립트가 없으면 만든다 — 규칙 없음 + 부재중, 켠다', async () => {
const engine = new FakeSieveEngine();
engine.addScript('vacation', 'require ["vacation"];\r\n', true);
@@ -5,6 +5,7 @@ import type {
MailFilterSet,
MailFilterUpdate,
MailVacation,
MailVacationState,
} from '@/modules/mail/mail.types';
import { traceTag } from '@/shared/logger/request-context';
import { maskEmail } from '@/shared/utils/mask';
@@ -135,6 +136,11 @@ function toFilterSet(view: ScriptView): MailFilterSet {
};
}
/** 부재중 자동 응답의 응답 — 저장한 값 + 필터와 같은 isActive (계약 2절 "부재중 자동 응답"의 MailVacationState) */
function toVacationState(view: ScriptView): MailVacationState {
return { ...view.source.vacation, isActive: view.isActive };
}
/**
* 메일 필터와 부재중 자동 응답의 저장 자리 — 엔진의 Sieve 스크립트 하나(`arasystem`) (계약 2절 "메일 필터"의 저장 자리).
* Stalwart 0.16.27 은 계정마다 켜진 스크립트가 하나뿐이고 엔진의 VacationResponse 도 스크립트(`vacation`)라서, 둘을 따로 두면
@@ -183,21 +189,24 @@ export class JmapFilterScriptStore {
return toFilterSet(saved);
}
/** 부재중 자동 응답 — 우리 스크립트가 없으면 엔진의 VacationResponse, 원본이 깨졌으면 끈 상태 */
async getVacation(user: string): Promise<MailVacation> {
return (await this.snapshot(user)).source.vacation;
/**
* 부재중 자동 응답 — 우리 스크립트가 없으면 엔진의 VacationResponse, 원본이 깨졌으면 끈 상태.
* 동작하는지(isActive)는 필터의 것과 같은 값이다 — 같은 스크립트 하나를 읽어 안다 (7단계)
*/
async getVacation(user: string): Promise<MailVacationState> {
return toVacationState(await this.snapshot(user));
}
/** 부재중 자동 응답만 바꾼다 — 규칙은 다시 읽은 그대로 둔다 (지문을 보지 않는다). 저장한 값을 돌려준다 */
/** 부재중 자동 응답만 바꾼다 — 규칙은 다시 읽은 그대로 둔다 (지문을 보지 않는다). 저장한 값과 저장한 뒤 동작하는지를 돌려준다 */
async setVacation(
user: string,
vacation: MailVacation,
): Promise<MailVacation> {
): Promise<MailVacationState> {
const saved = await this.write(user, (current) => ({
rules: current.rules,
vacation,
}));
return saved.source.vacation;
return toVacationState(saved);
}
/**
@@ -25,6 +25,7 @@ import {
} from './jmap.client';
import type { JmapClient, JmapSession } from './jmap.client';
import { decodeCursor, encodeCursor } from './jmap-cursor';
import { JmapFilterRunner } from './jmap-filter-run';
import { JmapFilterScriptStore } from './jmap-filter-script';
import { WATCH_DEBOUNCE_MS, WATCH_START_TIMEOUT_MS } from './jmap-mail-watch';
import { JmapMailProvider, buildThreadHeaders } from './jmap-mail.provider';
@@ -176,6 +177,7 @@ function fakeClient(
cache,
overrides.settings ?? settings,
new JmapFilterScriptStore(client as unknown as JmapClient),
new JmapFilterRunner(client as unknown as JmapClient),
),
};
}
@@ -5569,7 +5571,11 @@ describe('JmapMailProvider', () => {
it('부재중은 우리 스크립트가 없으면 엔진의 VacationResponse 를 이어받는다 — 스크립트 목록과 한 요청으로 묻는다', async () => {
const { provider, recorded } = fakeClient([noScript]);
await expect(provider.getVacation(ARA)).resolves.toEqual(vacation);
// 저장한 적이 없으면 막힌 것이 없다 — isActive: true (7단계)
await expect(provider.getVacation(ARA)).resolves.toEqual({
...vacation,
isActive: true,
});
expect(recorded[0].calls.map(([method]) => method)).toEqual([
'SieveScript/get',
'VacationResponse/get',
@@ -5601,7 +5607,10 @@ describe('JmapMailProvider', () => {
});
const off = { ...vacation, isEnabled: false };
await expect(provider.setVacation(ARA, off)).resolves.toEqual(off);
await expect(provider.setVacation(ARA, off)).resolves.toEqual({
...off,
isActive: true,
});
expect((client.upload.mock.calls[0] as unknown[])[2]).toBe(
'application/sieve',
);
@@ -23,12 +23,16 @@ import type {
MailboxBulkResult,
MailboxCreate,
MailboxDeletion,
MailFilterRunContext,
MailFilterRunOutcome,
MailFilterRunRequest,
MailFilterSet,
MailFilterUpdate,
MailLiveState,
MailState,
MailUsage,
MailVacation,
MailVacationState,
MailWatch,
MailWatchListener,
MessageDeletionResult,
@@ -69,6 +73,7 @@ import {
type DeliveryResult,
} from './delivery-status';
import { decodeCursor, encodeCursor } from './jmap-cursor';
import { JmapFilterRunner } from './jmap-filter-run';
import { JmapFilterScriptStore } from './jmap-filter-script';
import {
JmapMailWatch,
@@ -79,15 +84,17 @@ import { PUSH_PING_SECONDS, PUSH_TYPES } from './jmap-push';
import {
expectResult,
findResult,
firstRefusal,
firstSetError,
isState,
warnRefusals,
} from './jmap-results';
import {
describeFailure,
isMissingAttachmentBlob,
jmapFailure,
mapJmapErrorType,
} from './jmap.errors';
import { BulkRun, CONDITIONAL_DESTROY_ATTEMPTS } from './bulk-run';
import {
BODY_PART_PROPERTIES,
DETAIL_PROPERTIES,
@@ -172,14 +179,6 @@ const DRAFT_DETAIL_PROPERTIES = [
const SENT_ADDRESS_PROPERTIES = ['to', 'cc', 'bcc', 'receivedAt'];
const RECEIVED_ADDRESS_PROPERTIES = ['from', 'receivedAt'];
/**
* 조건부 영구 삭제(ifInState)에서 상태가 어긋나면 다시 확인하고 지우는 횟수 — 지우기 요청을 모두 합쳐.
* 사용자 초안(교체 저장·보낸 뒤)과 휴지통에서 영구 삭제(DELETE /api/messages/{id} · POST /api/message-deletions)가 함께 쓴다.
* 계정의 메일이 하나라도 바뀌면(새 메일 도착·다른 메일의 읽음 표시 포함) 상태가 바뀌므로 바쁜 메일함에서는 어긋날 수 있다 —
* 그래도 이 횟수 안에 맞지 않으면 지우지 않는다.
*/
const CONDITIONAL_DESTROY_ATTEMPTS = 3;
/** JMAP 메서드 오류 — ifInState 가 지금 상태와 다르다 (RFC 8620 §5.3) */
const STATE_MISMATCH = 'stateMismatch';
@@ -198,13 +197,9 @@ const PARENT_ALLOWED_ROLES: ReadonlySet<string> = new Set(['inbox', 'archive']);
const EMPTYABLE_ROLES: ReadonlySet<string> = new Set(['trash', 'junk']);
/**
* 오래 걸리는 처리(메일함 전체 읽음 · 비우기 · 지우기) 한 요청의 시간 (계약 2절 "메일함 관리") — 실제 상한이다 (BulkRun).
* 이 안에서 끝내고 남은 수(remaining)를 돌려준다. 클라이언트가 남은 것이 없을 때까지 같은 요청을 다시 보낸다.
* 오래 걸리는 처리(메일함 전체 읽음 · 비우기 · 지우기 — 계약 2절 "메일함 관리")가 한 차례에 다루는 메일 수 —
* 엔진의 한 번 처리 한도(maxObjectsInGet · maxObjectsInSet 500). 한 요청의 시간 · 멈춘 차례는 BulkRun (bulk-run.ts)
*/
const BULK_REQUEST_BUDGET_MS = 25_000;
/** 남은 시간이 이보다 적으면 다음 차례를 시작하지 않는다 — 한 차례(엔진 호출 두 번)가 끝까지 시간 안에 들게 */
const BULK_ROUND_RESERVE_MS = 5_000;
/** 한 차례에 다루는 메일 수 — 엔진의 한 번 처리 한도(maxObjectsInGet · maxObjectsInSet 500) */
const BULK_BATCH_SIZE = 500;
/** 원문 내려받기 때 엔진에 요청하는 형식·이름 — 클라이언트에게 주는 이름·헤더는 서비스·컨트롤러가 정한다 */
@@ -455,74 +450,6 @@ function mailboxSetFailure(
return jmapFailure(error, { method });
}
/**
* 오래 걸리는 처리(메일함 전체 읽음 · 비우기 · 지우기)의 한 요청 — 시간 · 진행 · 멈춘 차례 (계약 2절 "메일함 관리").
* - 시간: 메일함 목록을 읽기 **전부터** BULK_REQUEST_BUDGET_MS 가 실제 상한이다. 다음 차례는 남은 시간이 BULK_ROUND_RESERVE_MS 보다
* 많을 때만 시작하고, 엔진 호출마다 제한 시간을 기본 제한 시간(JMAP_REQUEST_TIMEOUT_MS)과 남은 시간 중 짧은 쪽으로 준다 —
* 엔진이 느려도 한 요청이 앞단 nginx 의 시간 제한에 걸리지 않게
* - 멈춘 차례: 아무것도 바뀌지 않은 차례(어긋남 · 바뀐 것도 거절도 없음 · 비운 메일함을 지우는 사이 메일이 들어옴)가
* 이어서 CONDITIONAL_DESTROY_ATTEMPTS 번이면 멈춘다 — 엔진에 끝없이 묻지 않게. 진행한 차례가 있으면 처음부터 다시 센다
* - 일찍 멈췄을 때(멈춘 차례 · 남은 시간 · 남은 시간 때문에 끊긴 호출) 이 요청에서 처리한 것이 있으면 거기까지로 답하고, 없으면 MAIL_001
*/
class BulkRun {
private readonly deadline = Date.now() + BULK_REQUEST_BUDGET_MS;
private stalls = 0;
private lastStall: string | null = null;
/** 이번 요청에서 처리한 메일 수 */
processed = 0;
/** 다음 차례를 시작해도 되는가 */
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();
}
}
/** 조회의 전체 수 — 엔진이 세어 주지 않으면 받은 수로 (가득 찼으면 더 있는 것으로 하나 더) */
function queryTotal(result: JmapQueryResult, received: number): number {
if (typeof result.total === 'number' && result.total >= received) {
@@ -531,15 +458,6 @@ function queryTotal(result: JmapQueryResult, received: number): number {
return received === BULK_BATCH_SIZE ? received + 1 : received;
}
/** 메일마다의 거절 가운데 "그사이 없어짐(notFound)" 이 아닌 첫 오류 — 없으면 undefined */
function firstRefusal(
...errors: (Record<string, JmapError> | undefined)[]
): JmapError | undefined {
return errors
.flatMap((record) => Object.values(record ?? {}))
.find((error) => error?.type !== NOT_FOUND);
}
/**
* 새 메일 확인 값 — 메일함 상태와 메일 상태를 이어 붙인다 (클라이언트에게는 불투명).
* 각각 base64url 로 바꿔 '.' 으로 잇는다 — base64url 에는 '.' 이 없어 서로 다른 두 상태 쌍이 같은 값이 되지 않는다.
@@ -727,6 +645,8 @@ export class JmapMailProvider implements MailProvider {
@Inject(MAIL_SETTINGS) private readonly settings: MailSettings,
/** 필터 · 부재중 자동 응답의 저장 자리 — 엔진의 Sieve 스크립트 하나 (jmap-filter-script.ts) */
private readonly filterScripts: JmapFilterScriptStore,
/** 이미 받은 메일에 필터 적용 (jmap-filter-run.ts — 7단계) */
private readonly filterRuns: JmapFilterRunner,
) {}
verifyCredentials(email: string, password: string): Promise<string | null> {
@@ -1166,12 +1086,15 @@ export class JmapMailProvider implements MailProvider {
* 부재중 자동 응답 — 필터와 같은 엔진 규칙(`arasystem` 스크립트)에 둔다. 그 스크립트가 아직 없으면 엔진의 VacationResponse 값이다
* (계약 2절 "메일 필터"의 저장 자리 — jmap-filter-script.ts)
*/
getVacation(user: string): Promise<MailVacation> {
getVacation(user: string): Promise<MailVacationState> {
return this.filterScripts.getVacation(user);
}
/** 부재중 자동 응답만 바꾼다 — 같은 스크립트의 필터 규칙은 그대로 둔다. 저장한 값을 돌려준다 */
setVacation(user: string, vacation: MailVacation): Promise<MailVacation> {
setVacation(
user: string,
vacation: MailVacation,
): Promise<MailVacationState> {
return this.filterScripts.setVacation(user, vacation);
}
@@ -1188,6 +1111,15 @@ export class JmapMailProvider implements MailProvider {
return this.filterScripts.setFilters(user, update);
}
/** 받은편지함에 이미 있는 메일에 규칙 하나를 적용한다 — 차례 · 판정 · 이어 할 자리는 JmapFilterRunner (계약 2절 "이미 받은 메일에 적용") */
runMailFilter(
user: string,
request: MailFilterRunRequest,
context: MailFilterRunContext,
): Promise<MailFilterRunOutcome> {
return this.filterRuns.run(user, request, context);
}
async getUsage(user: string): Promise<MailUsage> {
const responses = await this.jmap.call(
user,
@@ -2896,27 +2828,13 @@ export class JmapMailProvider implements MailProvider {
);
}
/**
* 여러 통을 처리하다 엔진이 메일마다 거절한 것 — 종류별 개수만 경고로 남긴다 (id 목록은 남기지 않는다).
* 그사이 없어진 것(notFound)은 흔한 경합이라 남기지 않는다.
* @param doing 로그 문장 — '바꾸지' · '휴지통에서 지우지'
*/
/** 여러 통을 처리하다 엔진이 메일마다 거절한 것 — 종류별 개수만 경고로 (jmap-results.ts 의 warnRefusals) */
private warnRefusals(
user: string,
errors: Record<string, JmapError> | undefined,
doing: string,
): void {
const counts = new Map<string, number>();
for (const error of Object.values(errors ?? {})) {
const type = error?.type ?? 'unknown';
if (type === NOT_FOUND) continue;
counts.set(type, (counts.get(type) ?? 0) + 1);
}
for (const [type, count] of counts) {
this.logger.warn(
`[${mapJmapErrorType(type)}]${traceTag()} 엔진이 거절해 ${doing} 못한 메일이 있습니다: user=${maskEmail(user)}, jmapType=${type}, ${count}통`,
);
}
warnRefusals(this.logger, user, errors, doing);
}
/**
+42 -2
View File
@@ -1,12 +1,19 @@
import type { Logger } from '@nestjs/common';
import { BusinessException } from '@/common/errors/business.exception';
import { jmapFailure } from './jmap.errors';
import { traceTag } from '@/shared/logger/request-context';
import { maskEmail } from '@/shared/utils/mask';
import { jmapFailure, mapJmapErrorType } from './jmap.errors';
import type { JmapError, JmapInvocation } from './jmap.types';
/**
* JMAP 응답 읽기 — 어댑터(jmap-mail.provider.ts)와 엔진 규칙 저장(jmap-filter-script.ts)이 같이 쓴다.
* JMAP 응답 읽기 — 어댑터(jmap-mail.provider.ts) · 엔진 규칙 저장(jmap-filter-script.ts) · 필터 적용(jmap-filter-run.ts)이 같이 쓴다.
* 이 모듈의 타입 · 함수는 `modules/mail/jmap/` 밖으로 내보내지 않는다.
*/
/** SetError — 그 대상이 없다 (그사이 지워짐) */
const NOT_FOUND = 'notFound';
export type MethodResult =
| { ok: true; args: Record<string, unknown> }
| { ok: false; error: JmapError };
@@ -49,3 +56,36 @@ export function firstSetError(
export function isState(value: unknown): value is string {
return typeof value === 'string' && value !== '';
}
/** 메일마다의 거절 가운데 "그사이 없어짐(notFound)" 이 아닌 첫 오류 — 없으면 undefined */
export function firstRefusal(
...errors: (Record<string, JmapError> | undefined)[]
): JmapError | undefined {
return errors
.flatMap((record) => Object.values(record ?? {}))
.find((error) => error?.type !== NOT_FOUND);
}
/**
* 여러 통을 처리하다 엔진이 메일마다 거절한 것 — 종류별 개수만 경고로 남긴다 (id 목록은 남기지 않는다).
* 그사이 없어진 것(notFound)은 흔한 경합이라 남기지 않는다.
* @param doing 로그 문장 — '바꾸지' · '휴지통에서 지우지'
*/
export function warnRefusals(
logger: Logger,
user: string,
errors: Record<string, JmapError> | undefined,
doing: string,
): void {
const counts = new Map<string, number>();
for (const error of Object.values(errors ?? {})) {
const type = error?.type ?? 'unknown';
if (type === NOT_FOUND) continue;
counts.set(type, (counts.get(type) ?? 0) + 1);
}
for (const [type, count] of counts) {
logger.warn(
`[${mapJmapErrorType(type)}]${traceTag()} 엔진이 거절해 ${doing} 못한 메일이 있습니다: user=${maskEmail(user)}, jmapType=${type}, ${count}통`,
);
}
}
@@ -189,6 +189,8 @@ export interface JmapEmailFilterCondition {
/** 이 메일함들이 **아닌** 메일함에 하나라도 들어 있는 메일 — 휴지통·스팸메일함에만 있는 메일을 뺀다 */
inMailboxOtherThan?: string[];
notKeyword?: string;
/** 받은 시각이 이보다 앞선(미만) 메일 — UTCDate. 엔진은 받은 시각을 초 단위로 둔다 (2026-10-29 확인) */
before?: string;
/** 보낸 사람·받는 사람·참조·제목·본문 전문 검색 — 큰따옴표로 감싼 값은 구절로 찾는다 */
text?: string;
}
@@ -1,5 +1,7 @@
import { startCpuTimer } from '@/../test/cpu-time';
import {
base64TextBody,
decodeHeaderText,
encodeHeaderWords,
plainTextMimeEntity,
} from './mime-text';
@@ -94,3 +96,205 @@ describe('base64TextBody · plainTextMimeEntity — 부재중 내용', () => {
);
});
});
/**
* 원래 머리글 → 엔진의 Sieve 가 견주는 글 (이미 받은 메일에 필터 적용 — 계약 2절 "이미 받은 메일에 적용"의 판정).
* 원래 글의 모양은 엔진이 주는 그대로다 — 앞에 공백 한 칸, 접힌 줄은 CRLF + 공백, encoded-word 그대로 (2026-10-29 실배달 메일에서 받은 값)
*/
describe('decodeHeaderText — 원래 머리글을 푼다 (RFC 5322 접힌 줄 · RFC 2047)', () => {
it('앞뒤 공백을 빼고 따옴표 · 꺾쇠 · 주소는 그대로 둔다', () => {
expect(decodeHeaderText(' "Kim, Ara" <kimara@example.com>')).toBe(
'"Kim, Ara" <kimara@example.com>',
);
expect(decodeHeaderText(' <seojun.park@arasystem.test> ')).toBe(
'<seojun.park@arasystem.test>',
);
});
it('UTF-8 B 로 인코딩된 표시 이름 · 제목을 푼다 (실배달 메일의 From · Cc · Subject)', () => {
expect(
decodeHeaderText(' =?UTF-8?B?6rmA7JWE6528?= <ara.kim@arasystem.test>'),
).toBe('김아라 <ara.kim@arasystem.test>');
expect(
decodeHeaderText(' =?UTF-8?B?7LC47KGw7J24?= <p7smvs8xqfvcc@example.com>'),
).toBe('참조인 <p7smvs8xqfvcc@example.com>');
expect(
decodeHeaderText(
' =?UTF-8?B?SDQg7KO86rCEIO2ajOydmOulvCBwN3NtdnM4eHFmdg==?=',
),
).toBe('H4 주간 회의를 p7smvs8xqfv');
});
it('EUC-KR 은 그 문자셋으로 푼다 — ks_c_5601-1987 · cp949 이름도 같다 (실배달 메일의 EUC-KR 제목)', () => {
const encoded = 'SDUgsN/A+7ytIL/kw7sgcDdzbXZzOHhxZnY=';
for (const charset of ['euc-kr', 'EUC-KR', 'ks_c_5601-1987', 'cp949']) {
expect(decodeHeaderText(` =?${charset}?B?${encoded}?=`)).toBe(
'H5 견적서 요청 p7smvs8xqfv',
);
}
});
it('Q 인코딩 — _ 는 공백, =XX 는 그 바이트 (대소문자 무시)', () => {
expect(decodeHeaderText('=?utf-8?q?Kim_Ara=2C_=ED=95=9C?=')).toBe(
'Kim Ara, 한',
);
expect(decodeHeaderText('=?ISO-8859-1?Q?J=F6rg?= <j@example.com>')).toBe(
'Jörg <j@example.com>',
);
});
it('접힌 줄(줄바꿈 + 뒤의 공백 · 탭 전부)은 띄어쓰기 하나로 편다 — 접지 않은 탭 · 이어진 공백은 그대로 (엔진 판정 H3 · H4 · G1 ~ G3)', () => {
expect(
decodeHeaderText(
' a1@example.com,\r\n a2@example.com,\r\n\t<b@example.com>',
),
).toBe('a1@example.com, a2@example.com, <b@example.com>');
expect(
decodeHeaderText(
' "가나" <g@example.com>,\r\n "다라" <d@example.com>',
),
).toBe('"가나" <g@example.com>, "다라" <d@example.com>');
expect(decodeHeaderText(' a,\n \t b')).toBe('a, b');
expect(decodeHeaderText(' "가나"\t<g@example.com>')).toBe(
'"가나"\t<g@example.com>',
);
expect(decodeHeaderText(' "가나" <g@example.com>')).toBe(
'"가나" <g@example.com>',
);
});
it('인코딩된 글은 홀로 선 것만 푼다 — 앞이 처음 · 공백 · " · , 이고 뒤가 끝 · 공백 · " 일 때, 앞뒤가 다르다 (엔진 판정 H1 · H5 · H7 · G4 ~ G6 · G8 ~ G10)', () => {
const name = '=?UTF-8?B?6rmA7JWE6528?='; // 김아라
const title = '=?UTF-8?B?67aA7J6l?='; // 부장
expect(decodeHeaderText(` "${name}" <a@example.com>`)).toBe(
'"김아라" <a@example.com>',
);
expect(decodeHeaderText(` "${name} 부장" <a@example.com>`)).toBe(
'"김아라 부장" <a@example.com>',
);
expect(decodeHeaderText(` <g@example.com>,${name} <a@example.com>`)).toBe(
'<g@example.com>,김아라 <a@example.com>',
);
expect(decodeHeaderText(` a@example.com ( ${title} )`)).toBe(
'a@example.com ( 부장 )',
);
// 글자 · ( 뒤, 글자 · < · , 앞이면 풀지 않는다 — 앞의 , 는 풀고(위) 뒤의 , 는 풀지 않는다(G10)
for (const raw of [
`abc${name} <a@example.com>`,
`${name}abc <a@example.com>`,
`a@example.com (${title})`,
`${name}<a@example.com>`,
`<s@example.com>, ${name},"가나" <g@example.com>`,
]) {
expect(decodeHeaderText(` ${raw}`)).toBe(raw);
}
});
it('공백만 사이에 둔 인코딩된 글끼리는 공백을 버리고 붙인다(RFC 2047 §6.2) — 글이 사이에 있으면 공백도 남긴다 (엔진 판정 H8 · H9)', () => {
expect(decodeHeaderText('=?UTF-8?B?7ZWc?= \r\n =?UTF-8?B?6riA?=')).toBe(
'한글',
);
expect(
decodeHeaderText(
' =?UTF-8?B?6rmA?=\r\n =?UTF-8?B?7JWE6528?= <ara.kim@arasystem.test>',
),
).toBe('김아라 <ara.kim@arasystem.test>');
expect(decodeHeaderText('=?UTF-8?B?7ZWc?= 그리고 =?UTF-8?B?6riA?=')).toBe(
'한 그리고 글',
);
// 사이 없이 붙은 둘은 홀로 서지 않았다 — 풀지 않는다
expect(decodeHeaderText('=?UTF-8?B?7ZWc?==?UTF-8?B?6riA?=')).toBe(
'=?UTF-8?B?7ZWc?==?UTF-8?B?6riA?=',
);
// 풀지 않은 낱말이 사이에 있으면 이어 붙이지 않는다 (그 앞뒤 공백도 그대로)
expect(
decodeHeaderText('=?UTF-8?B?7ZWc?= a=?UTF-8?B?6riA?= =?UTF-8?B?6riA?='),
).toBe('한 a=?UTF-8?B?6riA?= 글');
});
it('한 글자를 두 encoded-word 에 나눈 메일도 읽는다 — 같은 문자셋이 붙어 있으면 바이트를 이어 푼다', () => {
// '한'(ED 95 9C) 을 ED · 95 9C 로 나눴다
expect(decodeHeaderText('=?UTF-8?B?7Q==?= =?UTF-8?B?lZw=?=')).toBe('한');
// 문자셋이 다르면 따로 푼다
expect(decodeHeaderText('=?UTF-8?B?7ZWc?= =?euc-kr?B?sN8=?=')).toBe('한견');
});
it('RFC 2231 의 언어 표시(문자셋*언어)는 떼고 푼다', () => {
expect(decodeHeaderText('=?UTF-8*ko?B?7ZWc6riA?=')).toBe('한글');
});
it('모르는 문자셋 · 모양이 틀린 encoded-word 는 풀지 않고 그대로 둔다', () => {
expect(decodeHeaderText('=?x-unknown?B?7ZWc?= <a@example.com>')).toBe(
'=?x-unknown?B?7ZWc?= <a@example.com>',
);
expect(decodeHeaderText('=?UTF-8?X?abc?= =?UTF-8?B?7ZWc')).toBe(
'=?UTF-8?X?abc?= =?UTF-8?B?7ZWc',
);
// 모르는 문자셋 뒤의 낱말과는 공백을 버리지 않는다
expect(decodeHeaderText('=?x-unknown?B?eA==?= =?UTF-8?B?7ZWc?=')).toBe(
'=?x-unknown?B?eA==?= 한',
);
});
it('처음 보는 문자셋 이름은 한 머리글에서 16개까지만 찾아본다 — 그 뒤의 처음 보는 이름은 모르는 문자셋, 이미 아는 이름은 그대로 푼다', () => {
// 모르는 이름을 찾아보는 일은 한 번에 10µs 남짓(TextDecoder 가 던진다) — 이름이 모두 다른 낱말을 잔뜩 넣은 머리글이 서버를 붙잡지 않게
const unknown = (prefix: string, count: number) =>
Array.from(
{ length: count },
(_, index) => `=?${prefix}${index}?B?eA==?=`,
).join(' ');
// 16개를 다 쓴 뒤의 windows-1250(이 테스트에서 처음 쓰는 이름)은 찾아보지 않는다
expect(
decodeHeaderText(`${unknown('budget-a', 16)} =?windows-1250?B?eA==?=`),
).toBe(`${unknown('budget-a', 16)} =?windows-1250?B?eA==?=`);
// 15개 뒤라면 찾아보고 푼다 — 그 뒤로는 기억해 두어 찾아보지 않고 푼다
expect(
decodeHeaderText(`${unknown('budget-b', 15)} =?windows-1250?B?eA==?=`),
).toBe(`${unknown('budget-b', 15)} x`);
expect(
decodeHeaderText(`${unknown('budget-c', 16)} =?windows-1250?B?eA==?=`),
).toBe(`${unknown('budget-c', 16)} x`);
// 이미 아는(앞에서 푼) UTF-8 도 그대로
expect(decodeHeaderText('=?UTF-8?B?7ZWc?=')).toBe('한');
expect(
decodeHeaderText(`${unknown('budget-d', 20)} =?UTF-8?B?7ZWc?=`),
).toBe(`${unknown('budget-d', 20)} 한`);
});
it('찾은 문자셋만 기억한다 — 모르는 이름은 다음 머리글에서 다시 찾아보고(그 머리글의 16개 안에 센다), 기억 칸을 차지하지 않는다', () => {
const unknown = (prefix: string, count: number) =>
Array.from(
{ length: count },
(_, index) => `=?${prefix}${index}?B?eA==?=`,
).join(' ');
// 모르는 이름을 기억했다면 다음 머리글에서는 찾아보지 않아 16개가 남는다 — 기억하지 않으므로 다시 16개를 쓴다
decodeHeaderText(unknown('forget', 16));
expect(
decodeHeaderText(`${unknown('forget', 16)} =?windows-1252?B?eA==?=`),
).toBe(`${unknown('forget', 16)} =?windows-1252?B?eA==?=`);
// 모르는 이름 128개를 찾아본 뒤에도 처음 보는 아는 이름은 기억한다 — 16개를 쓴 머리글에서도 찾아보지 않고 푼다
for (let round = 0; round < 8; round += 1) {
decodeHeaderText(unknown(`fill-${round}-`, 16));
}
expect(decodeHeaderText('=?iso-8859-2?B?eA==?=')).toBe('x');
expect(
decodeHeaderText(`${unknown('fill-x', 16)} =?iso-8859-2?B?eA==?=`),
).toBe(`${unknown('fill-x', 16)} x`);
});
it('입력 길이에 비례한다 (CPU 시간) — 닫히지 않은 encoded-word · =? 만 · 모르는 문자셋 · 이름이 모두 다른 문자셋이 이어진 1MB', () => {
// 한도는 넉넉히 — 혼자 돌면 다섯 개 합계 CPU 0.25초 안쪽이다(무거운 테스트 파일 다음이면 GC 가 끼어든다 — html-text.spec.ts).
// 막으려는 것은 역추적하는 정규식 · 낱말마다 문자셋을 찾다 던지는 경로(10µs 남짓 × 낱말 수)다
const cpuMs = startCpuTimer();
decodeHeaderText(`=?UTF-8?B?${'A'.repeat(1_000_000)}`);
decodeHeaderText('=?'.repeat(500_000));
decodeHeaderText('=?a?B?b?= '.repeat(100_000));
decodeHeaderText(
Array.from({ length: 70_000 }, (_, index) => `=?x${index}?B?eA==?=`).join(
' ',
),
);
decodeHeaderText('=?UTF-8?B?7ZWc?= '.repeat(60_000));
expect(cpuMs()).toBeLessThan(10_000);
});
});
+180 -3
View File
@@ -1,7 +1,9 @@
/**
* 부재중 자동 응답을 엔진 규칙(Sieve)에 넣을 때의 글 모양 (순수 함수) — 제목은 RFC 2047 encoded-word, 내용은 base64 MIME 본문.
* 둘 다 ASCII 로만 나오므로 Sieve 문자열 안에 따옴표 · 역슬래시 · `${` 가 생기지 않는다
* (엔진은 require 와 상관없이 문자열의 `${…}` 를 늘 변수로 펼친다 — 계약 2절 "만드는 Sieve", 2026-10-28 확인).
* 메일 머리글 · 본문의 글 모양 (순수 함수).
* - 쓰기: 부재중 자동 응답을 엔진 규칙(Sieve)에 넣을 때 — 제목은 RFC 2047 encoded-word, 내용은 base64 MIME 본문.
* 둘 다 ASCII 로만 나오므로 Sieve 문자열 안에 따옴표 · 역슬래시 · `${` 가 생기지 않는다
* (엔진은 require 와 상관없이 문자열의 `${…}` 를 늘 변수로 펼친다 — 계약 2절 "만드는 Sieve", 2026-10-28 확인).
* - 읽기: 이미 받은 메일에 필터를 적용할 때 원래 머리글을 엔진의 Sieve 가 보는 글로 푼다 (decodeHeaderText — filter-run-texts.ts)
*/
const CRLF = '\r\n';
@@ -81,3 +83,178 @@ export function plainTextMimeEntity(text: string): string {
'',
].join(CRLF);
}
/**
* encoded-word 하나 — `=?문자셋?B|Q?글?=` (RFC 2047 §2). 문자셋 · 글 칸에는 `?` · 공백이 없어 칸마다 다음 `?` 에서 멈춘다
* (되돌아가 볼 것이 없다 — 길이에 비례). 문자셋 뒤의 `*언어`(RFC 2231 §5)는 풀 때 뗀다
*/
const ENCODED_WORD = /=\?([^?\s]+)\?([BbQq])\?([^?\s]*)\?=/g;
/**
* 접힌 줄 — 줄바꿈과 그 뒤의 공백 · 탭 전부를 띄어쓰기 하나로 바꾼다. 엔진(Sieve 의 `header` 검사)이 그렇게 본다(2026-10-29 확인 — 탭으로 접은 줄 ·
* 공백 넷으로 접은 줄 모두 띄어쓰기 하나). 접지 않은 탭 · 이어진 공백은 그대로 둔다(엔진도 그대로 견준다)
*/
const FOLDED_LINE_BREAK = /\r?\n[ \t]+/g;
/** encoded-word 사이에 공백만 있다 — 그 공백은 버린다 (RFC 2047 §6.2) */
const LINEAR_SPACE_ONLY = /^[ \t]*$/;
/** Q 인코딩의 `=XX` */
const Q_ESCAPE = /^[0-9A-Fa-f]{2}$/;
const Q_UNDERSCORE = 0x5f;
const Q_SPACE = 0x20;
const Q_ESCAPE_MARK = 0x3d;
const ASCII_LIMIT = 0x80;
/**
* 웹 표준(WHATWG Encoding)의 이름표에 없는 한국어 문자셋 이름 — TextDecoder 가 모르면 이것으로 다시 찾는다.
* (ks_c_5601-1987 · windows-949 · euc-kr 은 이름표에 있다 — 모두 euc-kr(UHC 포함)로 읽는다)
*/
const CHARSET_ALIASES: ReadonlyMap<string, string> = new Map([
['cp949', 'euc-kr'],
['ms949', 'euc-kr'],
['uhc', 'euc-kr'],
['x-windows-949', 'euc-kr'],
]);
/**
* 문자셋 이름 → 디코더 — 메일에 쓰이는 문자셋은 몇 되지 않아 이만큼만 기억한다(넘으면 더 기억하지 않는다).
* 찾은 것만 기억한다 — 모르는 이름(null)까지 기억하면 바깥에서 온 메일의 아무 이름이나 자리를 차지해 진짜 문자셋을 밀어낸다.
* 디코더는 스트림 없이(decode 한 번에) 쓰므로 함께 써도 된다
*/
const DECODER_CACHE_LIMIT = 64;
const decoderCache = new Map<string, TextDecoder>();
/**
* 한 머리글에서 기억에 없는 문자셋 이름을 찾아보는 횟수 — 넘으면 모르는 문자셋으로 본다.
* 모르는 이름을 찾아보는 일(TextDecoder 가 던진다)은 한 번에 10µs 남짓이라, 이름이 다른 encoded-word 를 잔뜩 넣은 머리글이
* 서버를 붙잡지 않게 (한 머리글에 문자셋 이름은 많아야 두셋이다). 한 머리글 안에서는 찾아본 이름을 다시 찾지 않는다(모르는 이름 포함)
*/
const MAX_NEW_CHARSETS_PER_HEADER = 16;
/**
* 인코딩된 글이 홀로 섰는지 보는 앞 글자 · 뒤 글자 — 처음 · 끝(NaN)도 경계다. 앞뒤가 다르다(엔진 판정 G6 · G10, 2026-10-29):
* 앞은 공백 · 탭 · CR · LF · `"` · `,`, 뒤는 공백 · 탭 · CR · LF · `"` — 뒤에 `,` 가 붙은 인코딩된 글은 엔진이 풀지 않는다
*/
const WORD_START_BOUNDARY_CODES: ReadonlySet<number> = new Set([
0x20, 0x09, 0x0d, 0x0a, 0x22, 0x2c,
]);
const WORD_END_BOUNDARY_CODES: ReadonlySet<number> = new Set([
0x20, 0x09, 0x0d, 0x0a, 0x22,
]);
/** 이름표(WHATWG) · 한국어 이름으로 디코더를 만든다 — 모르면 null */
function createDecoder(charset: string): TextDecoder | null {
for (const name of [charset, CHARSET_ALIASES.get(charset)]) {
if (name === undefined) continue;
try {
return new TextDecoder(name);
} catch {
// 이름표에 없는 이름 — 다음 이름으로
}
}
return null;
}
/**
* 그 문자셋의 디코더 — 모르는 문자셋이면 null (그 낱말은 풀지 않고 그대로 둔다).
* looked 는 이 머리글에서 찾아본 이름(모르는 이름은 null) — MAX_NEW_CHARSETS_PER_HEADER 개까지 찾아본다
*/
function decoderFor(
label: string,
looked: Map<string, TextDecoder | null>,
): TextDecoder | null {
const charset = label.split('*')[0].trim().toLowerCase();
const cached = decoderCache.get(charset) ?? looked.get(charset);
if (cached !== undefined) return cached;
if (looked.size >= MAX_NEW_CHARSETS_PER_HEADER) return null;
const decoder = createDecoder(charset);
looked.set(charset, decoder);
if (decoder !== null && decoderCache.size < DECODER_CACHE_LIMIT) {
decoderCache.set(charset, decoder);
}
return decoder;
}
/** 그 글자가 인코딩된 글의 경계인가 — 처음 · 끝은 NaN (경계다) */
function isWordBoundary(
code: number,
boundaries: ReadonlySet<number>,
): boolean {
return Number.isNaN(code) || boundaries.has(code);
}
/** encoded-word 의 글 칸 → 바이트 (B: base64 · Q: `_` 는 공백, `=XX` 는 그 바이트) */
function encodedWordBytes(encoding: string, text: string): Buffer {
if (encoding === 'B' || encoding === 'b') return Buffer.from(text, 'base64');
const bytes: number[] = [];
for (let index = 0; index < text.length; index += 1) {
const code = text.charCodeAt(index);
const escape = text.slice(index + 1, index + 3);
if (code === Q_UNDERSCORE) {
bytes.push(Q_SPACE);
} else if (code === Q_ESCAPE_MARK && Q_ESCAPE.test(escape)) {
bytes.push(Number.parseInt(escape, 16));
index += 2;
} else if (code < ASCII_LIMIT) {
bytes.push(code);
} else {
// Q 글 칸에 올 수 없는 글자 — 그 글자의 UTF-8 바이트로 (깨진 메일도 읽을 만큼은 읽는다)
bytes.push(...Buffer.from(text[index], 'utf8'));
}
}
return Buffer.from(bytes);
}
/** 공백만 두고 이어진 같은 문자셋의 encoded-word 들 — 바이트를 이어 붙여 한 번에 푼다 (한 글자를 두 낱말에 나눈 메일도 읽게) */
interface EncodedRun {
readonly decoder: TextDecoder;
readonly bytes: Buffer[];
}
/**
* 원래 머리글(엔진이 주는 그대로 — 앞의 공백 · 접힌 줄 · encoded-word)을 엔진의 Sieve(`header :contains`)가 견주는 글로 푼다 —
* 2026-10-29 인증 발송 19통으로 엔진과 같은 판정을 확인한 규칙 (filter-run-texts.spec.ts 의 H · G 표):
* 1. 접힌 줄(줄바꿈 + 뒤의 공백 · 탭 전부)은 띄어쓰기 하나로 편다. 접지 않은 탭 · 이어진 공백은 그대로 둔다
* 2. 인코딩된 글은 **홀로 선 것만** 그 문자셋으로 푼다 — 앞이 처음 · 공백 · 탭 · CR · LF · `"` · `,` 이고 뒤가 끝 · 공백 · 탭 · CR · LF · `"` 일 때
* (앞뒤가 다르다 — `,=?…?= <a@b>` 는 풀고 `=?…?=,"가나"` 는 풀지 않는다).
* 글자 · `(` 뒤(`abc=?…?=` · `(=?…?=)`), 글자 · `<` · `,` 앞(`=?…?=abc` · `=?…?=<a@b>` · `=?…?=,`)이면 풀지 않고 그대로 둔다.
* 공백만 사이에 둔 인코딩된 글끼리는 공백을 버리고 붙인다(같은 문자셋이면 바이트를 이어 푼다)
* 3. 앞뒤 공백을 뺀다. 따옴표 · 꺾쇠 · 주소는 그대로 둔다 (`"Kim, Ara" <kim@…>` — 계약 2절 "이미 받은 메일에 적용"의 판정)
* 모르는 문자셋의 낱말 · 모양이 틀린 낱말도 풀지 않고 그대로 둔다
*/
export function decodeHeaderText(raw: string): string {
const unfolded = raw.replace(FOLDED_LINE_BREAK, ' ');
const looked = new Map<string, TextDecoder | null>();
let decoded = '';
let copied = 0;
let run: EncodedRun | null = null;
for (const match of unfolded.matchAll(ENCODED_WORD)) {
const [word, charset, encoding, text] = match;
const between = unfolded.slice(copied, match.index);
copied = match.index + word.length;
const standalone =
isWordBoundary(
unfolded.charCodeAt(match.index - 1),
WORD_START_BOUNDARY_CODES,
) &&
isWordBoundary(
unfolded.charCodeAt(match.index + word.length),
WORD_END_BOUNDARY_CODES,
);
const decoder = standalone ? decoderFor(charset, looked) : null;
if (decoder === null) {
if (run !== null) decoded += run.decoder.decode(Buffer.concat(run.bytes));
run = null;
decoded += between + word;
continue;
}
const bytes = encodedWordBytes(encoding, text);
const adjacent = run !== null && LINEAR_SPACE_ONLY.test(between);
if (run !== null && adjacent && run.decoder.encoding === decoder.encoding) {
run.bytes.push(bytes);
continue;
}
if (run !== null) decoded += run.decoder.decode(Buffer.concat(run.bytes));
if (!adjacent) decoded += between;
run = { decoder, bytes: [bytes] };
}
if (run !== null) decoded += run.decoder.decode(Buffer.concat(run.bytes));
return (decoded + unfolded.slice(copied)).trim();
}
@@ -32,6 +32,9 @@ import { encodeHeaderWords, plainTextMimeEntity } from './mime-text';
* - 대소문자는 ASCII 만 무시한다 (엔진 기본 비교)
* - 글은 모두 따옴표 문자열 — `\` 와 `"` 만 이스케이프한다. `${` 는 받지 않는다(요청 검증) — 엔진이 늘 변수로 펼친다
* - 줄바꿈은 CRLF. 규칙 글이 없으면(켠 규칙도 부재중도 없음) 머리 주석만 남는다
*
* 이미 받은 메일에 적용할 때(7단계)는 서버가 같은 판정을 한다 — **FIELD_TESTS 는 mail-filter-match.ts 의 판정과 짝이다**
* (견줄 곳 · 대소문자 · 포함하지 않음의 뜻 · 묶는 방법). 한쪽을 바꾸면 다른 쪽(과 견줄 글 뽑기 filter-run-texts.ts)도 맞춘다
*/
/**
@@ -69,7 +72,10 @@ const INDENT = ' ';
const DESCRIPTION_LINE =
'# ARA SYSTEM 웹메일의 필터 · 부재중 자동 응답 — 웹메일이 아래 원본(JSON)으로 만든다. 여기서 고친 것은 웹메일에서 저장할 때 덮어쓴다.';
/** 견줄 곳 → Sieve 검사 (찾을 글을 받는다) — 표시 이름 · 주소 · 인코딩된 한국어 제목 · 본문 모두 맞는다 (같은 날 확인) */
/**
* 견줄 곳 → Sieve 검사 (찾을 글을 받는다) — 표시 이름 · 주소 · 인코딩된 한국어 제목 · 본문 모두 맞는다 (같은 날 확인).
* 이미 받은 메일의 판정(mail-filter-match.ts · filter-run-texts.ts)과 짝이다 — 여기를 바꾸면 그쪽도 맞춘다
*/
const FIELD_TESTS: Record<MailFilterField, (value: string) => string> = {
from: (value) => `header :contains "from" ${value}`,
to: (value) => `header :contains ["to", "cc"] ${value}`,
@@ -0,0 +1,149 @@
import {
asciiLowerCase,
filterFields,
judgeFilter,
type MailFilterTexts,
} from './mail-filter-match';
import type {
MailFilterCondition,
MailFilterCriteria,
MailFilterField,
} from './mail.types';
/** 견줄 글 — 주지 않은 곳은 빈 목록, 본문은 읽은 것으로(빈 목록) */
const texts = (overrides: Partial<MailFilterTexts> = {}): MailFilterTexts => ({
from: [],
to: [],
subject: [],
body: [],
...overrides,
});
const has = (field: MailFilterField, value: string): MailFilterCondition => ({
field,
operator: 'contains',
value,
});
const lacks = (field: MailFilterField, value: string): MailFilterCondition => ({
field,
operator: 'notContains',
value,
});
const all = (...conditions: MailFilterCondition[]): MailFilterCriteria => ({
match: 'all',
conditions,
});
const any = (...conditions: MailFilterCondition[]): MailFilterCriteria => ({
match: 'any',
conditions,
});
describe('judgeFilter — 엔진의 Sieve 와 같은 판정 (계약 2절 "이미 받은 메일에 적용")', () => {
const mail = texts({
from: ['"Kim, Ara" <kimara@hanbit-partners.co.kr>'],
to: ['seojun.park@arasystem.test', '참조인 <doyun.lee@arasystem.test>'],
subject: ['[견적] 10월 견적서 송부'],
body: ['안녕하세요.\n견적서를 보냅니다.', '첨부 메모'],
});
it('모든 메일(always)은 조건 없이 맞는다 — 견줄 글이 비어 있어도, 본문을 읽지 않았어도', () => {
const always: MailFilterCriteria = { match: 'always', conditions: [] };
expect(judgeFilter(always, texts())).toBe(true);
expect(judgeFilter(always, texts({ body: null }))).toBe(true);
});
it('포함 — 머리글 · 본문 부분 중 하나라도 그 글을 담으면 맞는다 (따옴표 · 꺾쇠까지 원래 글로 견준다)', () => {
expect(judgeFilter(all(has('from', '"Kim')), mail)).toBe(true);
expect(judgeFilter(all(has('from', '@hanbit-partners')), mail)).toBe(true);
expect(judgeFilter(all(has('to', 'doyun.lee@')), mail)).toBe(true);
expect(judgeFilter(all(has('to', '참조인 <doyun')), mail)).toBe(true);
expect(judgeFilter(all(has('subject', '견적서')), mail)).toBe(true);
expect(judgeFilter(all(has('body', '첨부 메모')), mail)).toBe(true);
// 부분을 이어 붙여 견주지 않는다 — 한 부분 안에서 맞아야 한다
expect(judgeFilter(all(has('body', '보냅니다.첨부')), mail)).toBe(false);
// 줄바꿈을 띄어쓰기로 보지 않는다
expect(judgeFilter(all(has('body', '안녕하세요. 견적서')), mail)).toBe(
false,
);
});
it('대소문자는 ASCII 만 무시한다 — 엔진 기본 비교(i;ascii-casemap). 라틴 확장 · 그리스 · 전각 영문은 그대로 견준다', () => {
expect(judgeFilter(all(has('from', 'HANBIT-Partners')), mail)).toBe(true);
const unicode = texts({ subject: ['Ärger σΣ abc'] });
expect(judgeFilter(all(has('subject', 'ärger')), unicode)).toBe(false);
expect(judgeFilter(all(has('subject', 'ÄRGER')), unicode)).toBe(true);
expect(judgeFilter(all(has('subject', 'σσ')), unicode)).toBe(false);
expect(judgeFilter(all(has('subject', 'ABC')), unicode)).toBe(false);
expect(asciiLowerCase('ABC 가나 Ä Σ Z')).toBe('abc 가나 Ä Σ Z');
});
it('포함하지 않음 — 그 글을 담은 머리글 · 부분이 하나도 없으면 맞는다. 머리글 · 본문이 아예 없어도 맞는다 (`not header …`)', () => {
expect(judgeFilter(all(lacks('from', '광고')), mail)).toBe(true);
expect(judgeFilter(all(lacks('to', 'doyun')), mail)).toBe(false);
const bare = texts();
for (const field of ['from', 'to', 'subject', 'body'] as const) {
expect(judgeFilter(all(lacks(field, 'x')), bare)).toBe(true);
expect(judgeFilter(all(has(field, 'x')), bare)).toBe(false);
}
});
it('모두(all) 는 조건이 모두 맞아야, 하나라도(any) 는 하나만 맞으면 된다', () => {
expect(
judgeFilter(all(has('from', 'hanbit'), has('subject', '견적')), mail),
).toBe(true);
expect(
judgeFilter(all(has('from', 'hanbit'), has('subject', '회의')), mail),
).toBe(false);
expect(
judgeFilter(any(has('from', '광고'), has('subject', '견적')), mail),
).toBe(true);
expect(
judgeFilter(any(has('from', '광고'), has('subject', '회의')), mail),
).toBe(false);
});
describe('본문을 아직 읽지 않았을 때 (texts.body 가 null)', () => {
const headersOnly = { ...mail, body: null };
it('본문 조건으로 판정이 갈리면 null — 어댑터가 본문을 읽어 다시 판정한다', () => {
expect(judgeFilter(all(has('body', '견적서')), headersOnly)).toBeNull();
expect(
judgeFilter(
all(has('from', 'hanbit'), has('body', '견적서')),
headersOnly,
),
).toBeNull();
expect(
judgeFilter(
any(has('from', '광고'), lacks('body', '견적서')),
headersOnly,
),
).toBeNull();
});
it('머리글로 판정이 나면 본문 없이 끝낸다 — 모두는 하나가 틀리면 false, 하나라도는 하나가 맞으면 true', () => {
expect(
judgeFilter(
all(has('body', '견적서'), has('from', '광고')),
headersOnly,
),
).toBe(false);
expect(
judgeFilter(
any(has('body', '견적서'), has('from', 'hanbit')),
headersOnly,
),
).toBe(true);
});
});
it('filterFields — 규칙이 견주는 곳만 (모든 메일이면 없음)', () => {
expect([
...filterFields(
all(has('from', 'a'), lacks('body', 'b'), has('from', 'c')),
),
]).toEqual(['from', 'body']);
expect(filterFields({ match: 'always', conditions: [] }).size).toBe(0);
});
});
@@ -0,0 +1,90 @@
import type {
MailFilterCondition,
MailFilterCriteria,
MailFilterField,
} from './mail.types';
/**
* 메일 필터의 판정 — 메일 한 통의 견줄 글이 규칙의 조건에 맞는가 (계약 2절 "이미 받은 메일에 적용"의 판정 — 7단계). 메일 엔진과 무관한 순수 함수.
*
* 새로 오는 메일은 엔진이 Sieve 로 판정한다 — **jmap/sieve-script.ts 의 FIELD_TESTS 와 짝이다** (한쪽을 바꾸면 다른 쪽도 맞춘다).
* 이미 받은 메일도 같은 판정이 되게 Sieve 의 규칙을 그대로 옮겼다 (2026-10-29 실배달 20통으로 엔진과 같은 판정을 확인 — jmap/filter-run-texts.spec.ts):
* - from → `header :contains "from"`: From 머리글마다 — 접힌 줄을 펴고 RFC 2047 을 풀고 앞뒤 공백을 뺀 원래 글(이름 · 따옴표 · 꺾쇠 · 주소)
* - to → `header :contains ["to", "cc"]`: To · Cc 머리글마다 — 하나라도 맞으면 맞다
* - subject → `header :contains "subject"`: 풀어 쓴 제목
* - body → `body :text :contains`: 글자 부분(text/* — 텍스트 첨부 포함)마다. HTML 은 글자만 (html/html-text.ts)
* - 대소문자는 ASCII 만 무시한다 — 엔진 기본 비교(i;ascii-casemap). 한글 · 전각 글자는 그대로 견준다
* - 포함하지 않음은 `not` — 그 머리글 · 본문이 하나도 없으면 참이다
* - 모두(all) = allof · 하나라도(any) = anyof · 모든 메일(always) = true
* 스팸 판정 표시는 보지 않는다 — 받은편지함에 있는 메일은 사용자가 이미 본 메일이다(계약). 견줄 글을 엔진 메일에서 뽑는 일은 어댑터가 한다
* (jmap/filter-run-texts.ts)
*/
/** 메일 한 통의 견줄 글 — 견줄 곳마다 글 목록 (머리글이 여럿이면 여럿) */
export interface MailFilterTexts {
readonly from: readonly string[];
readonly to: readonly string[];
/** 제목 — 없으면 빈 배열 */
readonly subject: readonly string[];
/** 본문의 글자 부분마다 — null 이면 아직 읽지 않았다(본문 조건의 판정을 미룬다 — judgeFilter 가 null) */
readonly body: readonly string[] | null;
}
/** ASCII 대문자만 소문자로 — 엔진의 i;ascii-casemap 과 같다 (한글 · 그리스 문자 · 전각 영문은 그대로) */
const ASCII_UPPERCASE = /[A-Z]+/g;
export function asciiLowerCase(text: string): string {
return text.replace(ASCII_UPPERCASE, (run) => run.toLowerCase());
}
/** 규칙이 견주는 곳 — 어댑터가 엔진에서 읽을 것을 고른다 (모든 메일이면 없음) */
export function filterFields(
criteria: MailFilterCriteria,
): ReadonlySet<MailFilterField> {
return new Set(criteria.conditions.map((condition) => condition.field));
}
/**
* 규칙에 맞는가 — 맞으면 true, 아니면 false. 본문을 아직 읽지 않았고(texts.body 가 null) 본문 조건으로 판정이 갈리면 null
* (모두 — 머리글 조건이 하나라도 틀리면 false · 하나라도 — 머리글 조건이 하나라도 맞으면 true 로 본문을 읽지 않고 끝낸다)
*/
export function judgeFilter(
criteria: MailFilterCriteria,
texts: MailFilterTexts,
): boolean | null {
if (criteria.match === 'always') return true;
const lowered = new Map<MailFilterField, readonly string[]>();
const loweredOf = (field: MailFilterField): readonly string[] | null => {
const known = lowered.get(field);
if (known) return known;
const values = texts[field];
if (values === null) return null;
const result = values.map(asciiLowerCase);
lowered.set(field, result);
return result;
};
let undecided = false;
for (const condition of criteria.conditions) {
const verdict = conditionVerdict(condition, loweredOf(condition.field));
if (verdict === null) {
undecided = true;
continue;
}
if (criteria.match === 'all' && !verdict) return false;
if (criteria.match === 'any' && verdict) return true;
}
if (undecided) return null;
return criteria.match === 'all';
}
/** 조건 하나 — 견줄 글(소문자로 바꾼 것)이 없으면(아직 읽지 않음) null */
function conditionVerdict(
condition: MailFilterCondition,
values: readonly string[] | null,
): boolean | null {
if (values === null) return null;
const needle = asciiLowerCase(condition.value);
const found = values.some((value) => value.includes(needle));
return condition.operator === 'contains' ? found : !found;
}
@@ -1,4 +1,13 @@
import { Body, Controller, Get, Put, UseGuards } from '@nestjs/common';
import {
Body,
Controller,
Get,
HttpCode,
HttpStatus,
Post,
Put,
UseGuards,
} from '@nestjs/common';
import { ApiCookieAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
import {
@@ -12,6 +21,11 @@ import {
import { JwtAuthGuard } from '@/common/guards/jwt-auth.guard';
import { MailFilterSetDto, MailFilterUpdateDto } from './dto/mail-filter.dto';
import {
MailFilterRunDto,
MailFilterRunRequestDto,
} from './dto/mail-filter-run.dto';
import {
MAIL_FILTER_RUNS_ROUTE,
MAIL_FILTERS_ROUTE,
MAX_MAIL_FILTER_CONDITIONS,
MAX_MAIL_FILTER_NAME_LENGTH,
@@ -24,7 +38,8 @@ import { MailService } from './mail.service';
const APPLY_NOTE =
'새로 온 메일을 위의 규칙부터 차례로 견주고, 맞는 규칙의 할 일을 모두 한다(읽음 · 중요는 겹쳐 붙는다). ' +
'메일함으로 옮기는 규칙에 맞으면 거기서 멈춘다. 전달은 한 통에 한 곳(엔진 한도 — 두 번째는 엔진이 버린다)이고 사본을 남긴다. ' +
'스팸으로 판정된 메일에는 아무 규칙도 적용하지 않는다(부재중 응답도 하지 않는다). 꺼 둔 규칙 · 이미 받은 메일에는 적용하지 않는다. ' +
'스팸으로 판정된 메일에는 아무 규칙도 적용하지 않는다(부재중 응답도 하지 않는다). 꺼 둔 규칙에는 적용하지 않는다. ' +
'이미 받은 메일에는 저절로 적용하지 않는다 — 저장한 뒤 POST /api/mail-filter-runs 로 받은편지함에 적용한다. ' +
'필터와 부재중 자동 응답은 메일 엔진의 규칙 하나에 함께 저장된다.';
// 메일 필터 (계약 2절 "메일 필터", 6단계)
@@ -86,3 +101,50 @@ export class MailFilterController {
return this.mail.updateMailFilters(user, dto);
}
}
// 이미 받은 메일에 필터 적용 (계약 2절 "이미 받은 메일에 적용", 7단계) — 받은편지함만, 요청의 규칙 하나로
@ApiTags('Mail')
@ApiCookieAuth()
@UseGuards(JwtAuthGuard)
@ApiErrorResponse('AUTH_001', '로그인 필요 (쿠키 없음·만료)')
@Controller(MAIL_FILTER_RUNS_ROUTE)
export class MailFilterRunController {
constructor(private readonly mail: MailService) {}
@Post()
@HttpCode(HttpStatus.OK)
@ApiOperation({
summary: '이미 받은 메일에 필터 적용 (받은편지함)',
description:
'받은편지함에 이미 있는 메일에 규칙 하나를 적용한다 — 저장해 둔 필터를 읽지 않고 본문의 규칙을 쓴다(방금 저장한 그 규칙을 보낸다). ' +
'받은 시각이 최근인 메일부터, 새로 오는 메일의 엔진 규칙과 같은 판정으로 읽음 · 중요 표시를 붙이고 옮긴다(맞은 메일을 차례마다 엔진 요청 한 번으로). ' +
'확인하는 사이 받은편지함에서 빠진 메일은 건너뛴다. 옮길 메일함이 받은편지함이거나 없는 메일함(지워짐)이면 옮기지 않고 표시만 한다. ' +
'전달은 하지 않는다 · 영구 삭제는 하지 않는다(휴지통으로 옮기는 규칙도 옮기기다) · 스팸 판정 표시는 보지 않는다. ' +
`조건은 필터 규칙과 같은 검사다(all · any 는 1~${MAX_MAIL_FILTER_CONDITIONS}개, always 는 없음 · 찾을 글은 앞뒤 공백을 뺀 1~${MAX_MAIL_FILTER_VALUE_LENGTH}자 · 제어 문자 · "\${" 없음). ` +
'한 요청은 25초 안에 끝내고 cursor 를 돌려준다 — null 이 될 때까지 같은 본문에 받은 cursor 를 넣어 다시 보낸다. ' +
'같은 직원의 적용은 한 번에 하나다 — 진행 중이면 엔진에 묻지 않고 429 REQ_001.',
})
@ApiStandardResponse(MailFilterRunDto, {
description:
'이번 요청에서 확인한 수 · 적용한 수 · 남은 수(어림) · 이어 할 자리(끝이면 null)',
})
@ApiErrorResponse(
'VAL_001',
`형식 · 한도(조건 ${MAX_MAIL_FILTER_CONDITIONS}개 · 찾을 글 ${MAX_MAIL_FILTER_VALUE_LENGTH}자) · 묶는 방법과 조건 수가 맞지 않음 · 할 일 없음 · 제어 문자 · "\${" · ` +
'보낸편지함 · 임시보관함으로 옮김 · 깨진 cursor · 다른 규칙의 cursor · 모르는 필드(actions 의 forwardTo 포함) — 적용하지 않았다',
)
@ApiErrorResponse(
'REQ_001',
'같은 직원의 적용이 진행 중(다른 탭 · PC) — 엔진에 묻지 않았다',
)
@ApiErrorResponse(
'MAIL_001',
'메일 엔진 장애 · 하나도 확인하지 못하고 멈춤(시간 · 확인하는 사이 받은편지함이 계속 바뀜). 도중의 엔진 장애면 일부를 적용했을 수 있다 — 마지막으로 받은 cursor 로 다시 보내면 된다(다시 해도 같다)',
)
run(
@CurrentUser() user: AuthenticatedUser,
@Body() dto: MailFilterRunRequestDto,
) {
return this.mail.runMailFilter(user, dto);
}
}
@@ -4,6 +4,7 @@ import {
} from './mail.constants';
import {
findFilterRulesProblem,
findFilterRunProblem,
hasFilterAction,
hasSieveVariable,
} from './mail-filters';
@@ -183,3 +184,60 @@ describe('hasSieveVariable · hasFilterAction', () => {
).toBe(true);
});
});
describe('findFilterRunProblem — 이미 받은 메일에 적용 (계약 2절 "이미 받은 메일에 적용", 7단계)', () => {
const run = {
match: 'any' as const,
conditions: [
{
field: 'subject' as const,
operator: 'contains' as const,
value: '견적',
},
],
actions: { moveToMailboxId: 'j', markAsRead: false, flag: false },
};
it('규칙 저장과 같은 규칙 — 맞으면 null, 모든 메일은 조건 없이', () => {
expect(findFilterRunProblem(run)).toBeNull();
expect(
findFilterRunProblem({ ...run, match: 'always', conditions: [] }),
).toBeNull();
});
it('묶는 방법과 조건 수가 맞지 않음 · 조건 6개 · 찾을 글 · 할 일 없음(전달이 없어 옮기기 · 읽음 · 중요 가운데 하나) · 옮길 메일함 id', () => {
expect(findFilterRunProblem({ ...run, match: 'always' })).toBe(
'conditions-for-match',
);
expect(findFilterRunProblem({ ...run, conditions: [] })).toBe(
'conditions-for-match',
);
expect(
findFilterRunProblem({
...run,
conditions: Array.from(
{ length: MAX_MAIL_FILTER_CONDITIONS + 1 },
() => run.conditions[0],
),
}),
).toBe('too-many-conditions');
expect(
findFilterRunProblem({
...run,
conditions: [{ ...run.conditions[0], value: '광고${x}' }],
}),
).toBe('condition-value-variable');
expect(
findFilterRunProblem({
...run,
actions: { moveToMailboxId: null, markAsRead: false, flag: false },
}),
).toBe('no-action');
expect(
findFilterRunProblem({
...run,
actions: { ...run.actions, moveToMailboxId: '받은 편지함' },
}),
).toBe('move-mailbox-id');
});
});
+44 -16
View File
@@ -12,16 +12,20 @@ import {
import type {
MailFilterActions,
MailFilterCondition,
MailFilterCriteria,
MailFilterField,
MailFilterMatch,
MailFilterOperator,
MailFilterRule,
MailFilterRunActions,
MailFilterRunRequest,
} from './mail.types';
/**
* 메일 필터 규칙 (계약 2절 "메일 필터"의 타입) — 메일 엔진과 무관한 순수 규칙.
* 요청 검증(dto/mail-filter.dto.ts)이 칸마다 형식을 보고, 서비스가 칸 사이의 규칙을 이것으로 한 번 더 본다.
* 어댑터가 엔진에 저장해 둔 원본을 읽을 때(jmap/filter-source.ts)도 같은 규칙으로 본다 — 어긋나면 깨진 원본이다.
* 이미 받은 메일에 적용하는 요청(dto/mail-filter-run.dto.ts — 7단계)도 조건 · 할 일을 같은 규칙으로 본다 (findFilterRunProblem).
*/
/** 견줄 곳 · 견주는 방법 · 묶는 방법 — 요청 검증과 원본 읽기가 같이 쓴다 */
@@ -58,13 +62,15 @@ export function hasSieveVariable(value: string): boolean {
return value.includes(SIEVE_VARIABLE_MARKER);
}
/** 할 일이 하나라도 있는가 (옮기기 · 읽음 · 중요 · 전달) */
export function hasFilterAction(actions: MailFilterActions): boolean {
/** 할 일이 하나라도 있는가 (옮기기 · 읽음 · 중요 · 전달 — 이미 받은 메일에 적용할 때는 전달이 없다) */
export function hasFilterAction(
actions: MailFilterRunActions & Partial<Pick<MailFilterActions, 'forwardTo'>>,
): boolean {
return (
actions.moveToMailboxId !== null ||
actions.markAsRead ||
actions.flag ||
actions.forwardTo !== null
(actions.forwardTo ?? null) !== null
);
}
@@ -79,10 +85,16 @@ function conditionProblem(condition: MailFilterCondition): string | null {
return null;
}
/** 할 일 — 하나 이상, 옮길 메일함 id 형식, 전달 주소 모양 (사내 주소인지 · 자기 주소가 아닌지는 서비스가 본다) */
function actionsProblem(actions: MailFilterActions): string | null {
/**
* 할 일 — 하나 이상, 옮길 메일함 id 형식, 전달 주소 모양 (사내 주소인지 · 자기 주소가 아닌지는 서비스가 본다).
* 이미 받은 메일에 적용하는 요청에는 전달이 없다
*/
function actionsProblem(
actions: MailFilterRunActions & Partial<Pick<MailFilterActions, 'forwardTo'>>,
): string | null {
if (!hasFilterAction(actions)) return 'no-action';
const { moveToMailboxId, forwardTo } = actions;
const { moveToMailboxId } = actions;
const forwardTo = actions.forwardTo ?? null;
if (moveToMailboxId !== null && !MAIL_ID_PATTERN.test(moveToMailboxId)) {
return 'move-mailbox-id';
}
@@ -99,6 +111,20 @@ function actionsProblem(actions: MailFilterActions): string | null {
return null;
}
/** 묶는 방법에 맞는 조건 수(모든 메일은 0개, 나머지는 1~5개)와 조건마다의 찾을 글 — 어긋난 첫 까닭, 맞으면 null */
function criteriaProblem(criteria: MailFilterCriteria): string | null {
const count = criteria.conditions.length;
if (criteria.match === 'always' ? count !== 0 : count < 1) {
return 'conditions-for-match';
}
if (count > MAX_MAIL_FILTER_CONDITIONS) return 'too-many-conditions';
for (const condition of criteria.conditions) {
const problem = conditionProblem(condition);
if (problem) return problem;
}
return null;
}
/** 규칙 하나 — id · 이름 · 묶는 방법에 맞는 조건 수 · 조건 · 할 일 */
function ruleProblem(rule: MailFilterRule): string | null {
if (!MAIL_FILTER_RULE_ID_PATTERN.test(rule.id)) return 'rule-id';
@@ -108,16 +134,18 @@ function ruleProblem(rule: MailFilterRule): string | null {
) {
return 'rule-name';
}
const count = rule.conditions.length;
if (rule.match === 'always' ? count !== 0 : count < 1) {
return 'conditions-for-match';
}
if (count > MAX_MAIL_FILTER_CONDITIONS) return 'too-many-conditions';
for (const condition of rule.conditions) {
const problem = conditionProblem(condition);
if (problem) return problem;
}
return actionsProblem(rule.actions);
return criteriaProblem(rule) ?? actionsProblem(rule.actions);
}
/**
* 이미 받은 메일에 적용하는 요청이 계약에 맞는지 (계약 2절 "이미 받은 메일에 적용") — 어긋난 첫 까닭, 맞으면 null.
* 규칙 저장과 같은 규칙이다 — 묶는 방법에 맞는 조건 수 · 찾을 글 · 할 일 하나 이상(전달은 없다) · 옮길 메일함 id 형식.
* 옮길 메일함의 역할(보낸편지함 · 임시보관함이 아님)은 서비스가 메일함 목록으로 본다
*/
export function findFilterRunProblem(
request: Pick<MailFilterRunRequest, 'match' | 'conditions' | 'actions'>,
): string | null {
return criteriaProblem(request) ?? actionsProblem(request.actions);
}
/**
@@ -111,6 +111,18 @@ export const MAIL_FILTER_RULE_ID_PATTERN = /^[A-Za-z0-9_-]{1,64}$/;
/** 필터 지문(version) — 서버가 만든 값(base64url)을 그대로 돌려받는다 */
export const MAIL_FILTER_VERSION_PATTERN = /^[A-Za-z0-9_-]{1,128}$/;
/**
* 이미 받은 메일에 필터 적용 라우트 경로 (`/api/mail-filter-runs`) — 계약 2절 "이미 받은 메일에 적용" (7단계)
*/
export const MAIL_FILTER_RUNS_ROUTE = 'mail-filter-runs';
/**
* 이미 받은 메일에 필터 적용의 이어 할 자리(cursor) — 서버가 만든 값(base64url)을 그대로 돌려받는다.
* 서버가 만드는 값은 500자 안쪽이다(메일 id 255자까지 + 자리 + 규칙 지문 — jmap/filter-run-cursor.ts) — 넉넉히 1,024자까지 받는다
*/
export const MAX_MAIL_FILTER_RUN_CURSOR_LENGTH = 1_024;
export const MAIL_FILTER_RUN_CURSOR_PATTERN = /^[A-Za-z0-9_-]+$/;
/**
* 메일 엔진(Stalwart)이 규칙 글 안에서 변수로 펼치는 표시 — require 와 상관없이 늘 펼친다(2026-10-28 확인).
* 펼쳐져 빈 값이 되면 `:contains` 가 모든 메일에 맞으므로 찾을 글 · 전달 주소에 쓰지 않는다 (계약 2절 "만드는 Sieve")
+8 -1
View File
@@ -11,11 +11,15 @@ import { MailSignatureModule } from '@/modules/mail-signature/mail-signature.mod
import { isAllowedAttachment } from './attachment.policy';
import { DraftController } from './draft.controller';
import { JmapClient } from './jmap/jmap.client';
import { JmapFilterRunner } from './jmap/jmap-filter-run';
import { JmapFilterScriptStore } from './jmap/jmap-filter-script';
import { JmapMailProvider } from './jmap/jmap-mail.provider';
import { MailAttachmentController } from './mail-attachment.controller';
import { MailEventsController } from './mail-events.controller';
import { MailFilterController } from './mail-filter.controller';
import {
MailFilterController,
MailFilterRunController,
} from './mail-filter.controller';
import { MailEventsService } from './mail-events.service';
import {
MailUploadTempCleanup,
@@ -111,6 +115,7 @@ function attachmentUploadOptions(config: ConfigService): MulterModuleOptions {
MailEventsController,
MailVacationController,
MailFilterController,
MailFilterRunController,
MessageController,
MessageDeletionController,
MailAttachmentController,
@@ -126,6 +131,8 @@ function attachmentUploadOptions(config: ConfigService): MulterModuleOptions {
JmapClient,
// 필터 · 부재중 자동 응답의 저장 자리(엔진의 Sieve 스크립트 하나) — 어댑터 안에서만 쓴다
JmapFilterScriptStore,
// 이미 받은 메일에 필터 적용(받은편지함을 차례로 판정 · 표시 · 옮기기 — 7단계) — 어댑터 안에서만 쓴다
JmapFilterRunner,
{ provide: MAIL_PROVIDER, useClass: JmapMailProvider },
MailService,
// 실시간 알림 — 직원마다 엔진 연결 하나를 나눠 쓰고, 서버 종료 때 열린 연결을 먼저 닫는다 (beforeApplicationShutdown)
+22 -4
View File
@@ -9,11 +9,15 @@ import type {
MailboxBulkResult,
MailboxCreate,
MailboxDeletion,
MailFilterRunContext,
MailFilterRunOutcome,
MailFilterRunRequest,
MailFilterSet,
MailFilterUpdate,
MailState,
MailUsage,
MailVacation,
MailVacationState,
MailWatch,
MailWatchListener,
MessageDeletionResult,
@@ -106,15 +110,15 @@ export interface MailProvider {
/**
* 부재중 자동 응답 — 한 번도 설정하지 않았으면 끈 상태 · 빈 제목과 내용.
* 저장은 메일 필터와 같은 엔진 규칙에 둔다 (계약 2절 "메일 필터"의 저장 자리)
* 저장은 메일 필터와 같은 엔진 규칙에 둔다 (계약 2절 "메일 필터"의 저장 자리) — 그 규칙이 엔진에서 동작하는지(isActive)를 함께 (7단계)
*/
getVacation(user: string): Promise<MailVacation>;
getVacation(user: string): Promise<MailVacationState>;
/**
* 부재중 자동 응답을 통째로 바꾸고 저장한 값을 돌려준다 — 메일 필터 규칙은 그대로 둔다.
* 부재중 자동 응답을 통째로 바꾸고 저장한 값을 돌려준다 — 메일 필터 규칙은 그대로 둔다. 저장하면 다시 동작한다(isActive).
* 형식은 요청 검증이, 켤 때의 빈 값 · 길이 · 날짜 순서는 서비스가 본다. 함께 만든 엔진 규칙이 엔진 한도를 넘으면 MAIL_013
*/
setVacation(user: string, vacation: MailVacation): Promise<MailVacation>;
setVacation(user: string, vacation: MailVacation): Promise<MailVacationState>;
/** 메일 필터 (계약 2절 "메일 필터") — 규칙(차례대로) · 지금 규칙의 지문(version) · 동작하는지(isActive) */
getMailFilters(user: string): Promise<MailFilterSet>;
@@ -130,6 +134,20 @@ export interface MailProvider {
update: MailFilterUpdate,
): Promise<MailFilterSet>;
/**
* 받은편지함에 이미 있는 메일에 규칙 하나를 적용한다 (계약 2절 "이미 받은 메일에 적용" — 7단계) — 저장해 둔 필터가 아니라 요청의 규칙을 쓴다.
* 받은 시각이 최근인 메일부터, 새로 오는 메일의 엔진 규칙과 같은 판정으로(mail-filter-match.ts) 읽음 · 중요 표시를 붙이고 옮긴다.
* 한 요청의 시간 안에서 차례로 하고 이어 할 자리(cursor — 끝이면 null)를 돌려준다 — 메일함의 오래 걸리는 처리와 같은 상한 · 같은 규칙
* (진행한 것이 없이 멈추면 MAIL_001). 옮길 메일함이 받은편지함이거나 없으면 옮기지 않고 표시만 한다. 영구 삭제는 하지 않는다.
* cursor 가 깨졌거나 다른 규칙의 것이면 엔진에 묻기 전에 VAL_001. 규칙의 형식 · 내용 · 옮길 메일함의 역할은 서비스가 검사한 값이다.
* 받은편지함 · 옮길 메일함은 서비스가 읽은 메일함 목록(context.mailboxes)에서 찾고, 한 요청의 시간은 context.startedAt 부터 센다
*/
runMailFilter(
user: string,
request: MailFilterRunRequest,
context: MailFilterRunContext,
): Promise<MailFilterRunOutcome>;
/** 메일함 사용량과 한도 (한도가 없으면 limitBytes = null) */
getUsage(user: string): Promise<MailUsage>;
+275 -12
View File
@@ -6,6 +6,7 @@ import { BusinessException } from '@/common/errors/business.exception';
import { SIGNATURE_IMAGES_PER_SIGNATURE } from '@/modules/mail-signature/mail-signature.constants';
import type { MailSignatureService } from '@/modules/mail-signature/mail-signature.service';
import type { MailFilterUpdateDto } from './dto/mail-filter.dto';
import type { MailFilterRunRequestDto } from './dto/mail-filter-run.dto';
import type { DraftBodyDto, SendMessageDto } from './dto/send-message.dto';
import { MAX_HTML_PARSE_LENGTH } from './html/html-parse-budget';
import {
@@ -76,6 +77,7 @@ function fakeProvider(): ProviderMocks {
setVacation: mockOf<'setVacation'>(),
getMailFilters: mockOf<'getMailFilters'>(),
setMailFilters: mockOf<'setMailFilters'>(),
runMailFilter: mockOf<'runMailFilter'>(),
openMessageSource: mockOf<'openMessageSource'>(),
getUsage: mockOf<'getUsage'>(),
getAttachmentsTotalLimit:
@@ -648,12 +650,13 @@ describe('MailService', () => {
it('형식 규칙을 지나면 그대로 넘기고 엔진이 저장한 값을 돌려준다 — 로그에는 켬 · 끔만', async () => {
provider.setVacation.mockImplementation((_user, value) =>
Promise.resolve(value),
Promise.resolve({ ...value, isActive: true }),
);
await expect(service.updateVacation(user, vacation)).resolves.toEqual(
vacation,
);
await expect(service.updateVacation(user, vacation)).resolves.toEqual({
...vacation,
isActive: true,
});
expect(provider.setVacation).toHaveBeenCalledWith(user.email, vacation);
const [line] = logSpy.mock.calls[0] as [string];
expect(line).toContain('켬');
@@ -682,10 +685,13 @@ describe('MailService', () => {
subject: '',
textBody: '',
};
provider.setVacation.mockResolvedValue(neverConfigured);
provider.setVacation.mockResolvedValue({
...neverConfigured,
isActive: true,
});
await expect(
service.updateVacation(user, neverConfigured),
).resolves.toEqual(neverConfigured);
).resolves.toEqual({ ...neverConfigured, isActive: true });
});
it('켤 때 앞뒤 공백을 빼고 빈 제목 · 내용도 VAL_001 — 끈 채로는 받고, 저장은 쓴 그대로(공백을 지우지 않는다)', async () => {
@@ -702,7 +708,7 @@ describe('MailService', () => {
expect(provider.setVacation).not.toHaveBeenCalled();
provider.setVacation.mockImplementation((_user, value) =>
Promise.resolve(value),
Promise.resolve({ ...value, isActive: true }),
);
await service.updateVacation(user, {
...vacation,
@@ -758,7 +764,7 @@ describe('MailService', () => {
});
provider.setVacation.mockImplementation((_user, value) =>
Promise.resolve(value),
Promise.resolve({ ...value, isActive: true }),
);
// 한도까지는 켜도 받는다
const full = {
@@ -766,7 +772,10 @@ describe('MailService', () => {
subject: '가'.repeat(MAX_VACATION_SUBJECT_LENGTH),
textBody: '가'.repeat(MAX_VACATION_TEXT_LENGTH),
};
await expect(service.updateVacation(user, full)).resolves.toEqual(full);
await expect(service.updateVacation(user, full)).resolves.toEqual({
...full,
isActive: true,
});
// 꺼 두면 4단계에 저장해 둔 긴 글(영문 내용 1,000자 등)도 그대로 저장한다 — 끄기는 늘 된다
const off = {
...vacation,
@@ -774,7 +783,10 @@ describe('MailService', () => {
subject: 'x'.repeat(MAX_VACATION_SUBJECT_LENGTH + 1),
textBody: 'x'.repeat(1_000),
};
await expect(service.updateVacation(user, off)).resolves.toEqual(off);
await expect(service.updateVacation(user, off)).resolves.toEqual({
...off,
isActive: true,
});
expect(provider.setVacation).toHaveBeenCalledTimes(2);
});
@@ -791,7 +803,7 @@ describe('MailService', () => {
expect(provider.setVacation).not.toHaveBeenCalled();
provider.setVacation.mockImplementation((_user, value) =>
Promise.resolve(value),
Promise.resolve({ ...value, isActive: true }),
);
await service.updateVacation(user, {
...vacation,
@@ -816,7 +828,7 @@ describe('MailService', () => {
expect(provider.setVacation).not.toHaveBeenCalled();
provider.setVacation.mockImplementation((_user, value) =>
Promise.resolve(value),
Promise.resolve({ ...value, isActive: true }),
);
await service.updateVacation(user, { ...vacation, toDate: null });
await service.updateVacation(user, { ...vacation, fromDate: null });
@@ -1133,6 +1145,257 @@ describe('MailService', () => {
});
});
describe('이미 받은 메일에 필터 적용 (7단계)', () => {
const body = (overrides: object = {}) =>
({
match: 'any',
conditions: [
{
field: 'from',
operator: 'contains',
value: '비밀 거래처',
},
],
actions: { moveToMailboxId: 'j', markAsRead: true, flag: false },
cursor: null,
...overrides,
}) as unknown as MailFilterRunRequestDto;
const outcome = {
scanned: 500,
matched: 37,
remaining: 120,
cursor: 'next-cursor',
rounds: 2,
};
const mailbox = (id: string, role: Mailbox['role']): Mailbox => ({
id,
name: id,
role,
parentId: null,
sortOrder: 0,
totalCount: 0,
unreadCount: 0,
});
/** 손으로 끝내는 적용 — 끝낼 때까지 진행 중이다 */
function pending<T>(): {
promise: Promise<T>;
finish: (value: T) => void;
fail: (error: Error) => void;
} {
let finish!: (value: T) => void;
let fail!: (error: Error) => void;
const promise = new Promise<T>((resolve, reject) => {
finish = resolve;
fail = reject;
});
return { promise, finish, fail };
}
const mailboxes = [
mailbox('a', 'inbox'),
mailbox('e', 'sent'),
mailbox('d', 'drafts'),
mailbox('j', null),
];
beforeEach(() => {
provider.listMailboxes.mockResolvedValue(mailboxes);
provider.runMailFilter.mockResolvedValue(outcome);
});
it('검사를 지나면 본문의 규칙(묶는 방법 · 조건 · 할 일 · cursor)과 한 번 읽은 메일함 목록을 넘기고 결과를 돌려준다 — 차례 수는 로그에만', async () => {
await expect(service.runMailFilter(user, body())).resolves.toEqual({
scanned: 500,
matched: 37,
remaining: 120,
cursor: 'next-cursor',
});
expect(provider.listMailboxes).toHaveBeenCalledTimes(1);
expect(provider.runMailFilter).toHaveBeenCalledWith(
user.email,
{
match: 'any',
conditions: [
{ field: 'from', operator: 'contains', value: '비밀 거래처' },
],
actions: { moveToMailboxId: 'j', markAsRead: true, flag: false },
cursor: null,
},
{ mailboxes, startedAt: expect.any(Number) as number },
);
// 로그에는 수 · 차례 · 걸린 시간만 — 규칙의 글 · 주소 · cursor 는 남기지 않는다
const [line] = logSpy.mock.calls.at(-1) as [string];
expect(line).toContain('확인 500통 · 적용 37통 · 남음 120통');
expect(line).toContain('차례 2번');
expect(line).not.toContain('비밀 거래처');
expect(line).not.toContain('next-cursor');
expect(line).not.toContain(user.email);
});
it.each<[string, object, string]>([
[
'모든 메일인데 조건이 있음',
{ match: 'always' },
'conditions-for-match',
],
['하나라도인데 조건이 없음', { conditions: [] }, 'conditions-for-match'],
[
'할 일이 하나도 없음',
{
actions: { moveToMailboxId: null, markAsRead: false, flag: false },
},
'no-action',
],
])(
'칸 사이의 규칙이 어긋나면 엔진에 묻지 않고 VAL_001 — %s',
async (_label, overrides, problem) => {
let failure: unknown;
try {
await service.runMailFilter(user, body(overrides));
} catch (error) {
failure = error;
}
expect(failure).toBeInstanceOf(BusinessException);
expect((failure as BusinessException).context).toEqual({
reason: 'invalid-filter-run',
problem,
});
expect(provider.listMailboxes).not.toHaveBeenCalled();
expect(provider.runMailFilter).not.toHaveBeenCalled();
},
);
it('보낸편지함 · 임시보관함으로는 옮기지 않는다 — VAL_001 (규칙 저장과 같은 판정). 메일함 목록은 늘 한 번 읽어 넘긴다(어댑터가 받은편지함을 찾는다)', async () => {
for (const moveToMailboxId of ['e', 'd']) {
expect(
await failureOf(() =>
service.runMailFilter(
user,
body({
actions: { moveToMailboxId, markAsRead: false, flag: false },
}),
),
),
).toEqual({
code: 'VAL_001',
reason: 'filter-move-target-not-allowed',
});
}
expect(provider.runMailFilter).not.toHaveBeenCalled();
provider.listMailboxes.mockClear();
await service.runMailFilter(
user,
body({
actions: { moveToMailboxId: null, markAsRead: true, flag: true },
}),
);
expect(provider.listMailboxes).toHaveBeenCalledTimes(1);
// 없는 메일함은 받는다 — 어댑터가 옮기지 않고 표시만 한다
await service.runMailFilter(
user,
body({
actions: { moveToMailboxId: 'gone', markAsRead: true, flag: false },
}),
);
expect(provider.runMailFilter).toHaveBeenCalledTimes(2);
});
it('한 요청의 시간은 서비스에 들어온 때부터 — 메일함 목록을 읽는 시간도 그 안이다 (어댑터는 들어온 때를 받는다)', async () => {
let now = 1_000_000;
jest.spyOn(Date, 'now').mockImplementation(() => now);
provider.listMailboxes.mockImplementationOnce(() => {
now += 21_000;
return Promise.resolve(mailboxes);
});
await service.runMailFilter(user, body());
expect(provider.runMailFilter).toHaveBeenCalledWith(
user.email,
expect.any(Object),
{ mailboxes, startedAt: 1_000_000 },
);
});
it('같은 직원의 적용은 하나씩만 — 진행 중이면 엔진에 묻지 않고 바로 REQ_001(대소문자가 다른 주소도 같은 직원), 다른 직원은 동시에 된다, 끝나면(실패여도) 다시 받는다', async () => {
const first = pending<typeof outcome>();
provider.runMailFilter.mockReturnValueOnce(first.promise);
const running = service.runMailFilter(user, body());
// 메일함 확인(엔진)을 지나 어댑터에 들어갈 때까지 기다린다
await new Promise((resolve) => setImmediate(resolve));
expect(provider.runMailFilter).toHaveBeenCalledTimes(1);
provider.listMailboxes.mockClear();
for (const sameUser of [
user,
{ ...user, email: 'ARA.KIM@arasystem.test' },
]) {
expect(
await failureOf(() => service.runMailFilter(sameUser, body())),
).toEqual({ code: 'REQ_001', reason: 'filter-run-in-progress' });
}
expect(provider.listMailboxes).not.toHaveBeenCalled();
expect(provider.runMailFilter).toHaveBeenCalledTimes(1);
// 다른 직원 · 같은 직원의 메일함 처리(비우기 등)는 따로 센다
await expect(
service.runMailFilter(
{ email: 'doyun.lee@arasystem.test', name: '이도윤' },
body(),
),
).resolves.toMatchObject({ scanned: 500 });
provider.emptyMailbox.mockResolvedValueOnce({
processed: 0,
remaining: 0,
});
await expect(service.emptyMailbox(user, 'b')).resolves.toEqual({
processed: 0,
remaining: 0,
});
first.fail(new BusinessException('MAIL_001'));
expect(await failureOf(() => running)).toMatchObject({
code: 'MAIL_001',
});
await expect(service.runMailFilter(user, body())).resolves.toMatchObject({
scanned: 500,
});
});
it('어댑터의 실패는 그대로 — 깨진 cursor VAL_001 · 진행 없음 MAIL_001 (적용 로그 없음)', async () => {
for (const code of ['VAL_001', 'MAIL_001'] as const) {
provider.runMailFilter.mockRejectedValueOnce(
new BusinessException(code),
);
expect(
await codeOf(() =>
service.runMailFilter(user, body({ cursor: 'eyJ2IjoxfQ' })),
),
).toBe(code);
}
expect(logSpy).not.toHaveBeenCalled();
});
});
describe('부재중 자동 응답 읽기 (7단계 — isActive)', () => {
it('어댑터 그대로 — 저장한 값과 엔진에서 동작하는지', async () => {
const state = {
isEnabled: true,
fromDate: null,
toDate: null,
subject: '부재중입니다',
textBody: '휴가입니다.',
isActive: false,
};
provider.getVacation.mockResolvedValue(state);
await expect(service.getVacation(user)).resolves.toBe(state);
expect(provider.getVacation).toHaveBeenCalledWith(user.email);
});
});
describe('메일 상세', () => {
it('HTML 이 없으면 정화 없이 텍스트만 내려준다', async () => {
provider.getMessage.mockResolvedValue(source());
+128 -41
View File
@@ -24,6 +24,7 @@ import type {
UpdateMessagesDto,
} from './dto/bulk-messages.dto';
import type { MailFilterUpdateDto } from './dto/mail-filter.dto';
import type { MailFilterRunRequestDto } from './dto/mail-filter-run.dto';
import type { MailVacationDto } from './dto/mail-vacation.dto';
import type {
CreateMailboxDto,
@@ -62,7 +63,7 @@ import {
MAX_VACATION_SUBJECT_LENGTH,
MAX_VACATION_TEXT_LENGTH,
} from './mail.constants';
import { findFilterRulesProblem } from './mail-filters';
import { findFilterRulesProblem, findFilterRunProblem } from './mail-filters';
import { MAIL_PROVIDER, type MailProvider } from './mail.provider';
import { MAIL_SETTINGS, type MailSettings } from './mail.settings';
import type {
@@ -77,11 +78,15 @@ import type {
MailboxBulkResult,
MailboxDeletion,
MailboxRole,
MailFilterActions,
MailFilterRule,
MailFilterRun,
MailFilterRunRequest,
MailFilterSet,
MailQuota,
MailState,
MailVacation,
MailVacationState,
MessageAttachment,
MessageDeletionResult,
MessageDetail,
@@ -217,6 +222,27 @@ const FILTER_MOVE_FORBIDDEN_ROLES: ReadonlySet<MailboxRole> = new Set([
'drafts',
]);
/**
* 필터가 옮길 메일함 — 보낸편지함 · 임시보관함이면 VAL_001. 메일함 목록에 없는 id 는 받는다
* (지운 메일함을 가리키는 규칙이 남아 있을 수 있다 — 새 메일은 받은편지함에 들어오고, 이미 받은 메일은 옮기지 않는다).
* 규칙 저장(규칙마다의 할 일 — index 는 몇 번째 규칙)과 이미 받은 메일에 적용(할 일 하나)이 같이 쓴다
*/
function assertMoveTargetsAllowed(
mailboxes: readonly Mailbox[],
actions: readonly Pick<MailFilterActions, 'moveToMailboxId'>[],
): void {
const roles = new Map(mailboxes.map((mailbox) => [mailbox.id, mailbox.role]));
for (const [index, { moveToMailboxId }] of actions.entries()) {
if (moveToMailboxId === null) continue;
const role = roles.get(moveToMailboxId);
if (role && FILTER_MOVE_FORBIDDEN_ROLES.has(role)) {
throw new BusinessException('VAL_001', {
context: { reason: 'filter-move-target-not-allowed', role, index },
});
}
}
}
/** 서명 그림의 Content-ID — 같은 그림이면 늘 같은 값이다 (발송 지문이 cid 가 든 서식 본문을 본다) */
function signatureImageCid(imageId: string, sender: string): string {
const domain = sender.slice(sender.lastIndexOf('@') + 1).toLowerCase();
@@ -245,6 +271,13 @@ export class MailService {
*/
private readonly mailboxOperations = new Set<string>();
/**
* 진행 중인 "이미 받은 메일에 필터 적용"의 직원 — 한 직원에 하나씩만 (계약 2절 "이미 받은 메일에 적용" — 다른 탭 · PC 에서 함께 하면 429 REQ_001).
* 두 적용이 같은 받은편지함을 함께 읽으면 서로 옮긴 메일 때문에 자리가 어긋나고 엔진 호출이 그 수만큼 곱절로 는다.
* ⚠️ 프로세스 메모리다. 인스턴스를 늘리면 Redis 잠금(SET NX PX)으로 옮겨야 인스턴스 사이에서도 하나씩이 된다.
*/
private readonly filterRuns = new Set<string>();
constructor(
@Inject(MAIL_PROVIDER) private readonly provider: MailProvider,
@Inject(MAIL_SETTINGS) private readonly settings: MailSettings,
@@ -374,8 +407,8 @@ export class MailService {
return result;
}
/** 부재중 자동 응답 (계약 2절 "부재중 자동 응답") */
getVacation(user: AuthenticatedUser): Promise<MailVacation> {
/** 부재중 자동 응답 (계약 2절 "부재중 자동 응답") — 엔진에서 우리 규칙이 동작하는지(isActive)를 함께 (7단계) */
getVacation(user: AuthenticatedUser): Promise<MailVacationState> {
return this.provider.getVacation(user.email);
}
@@ -392,7 +425,7 @@ export class MailService {
async updateVacation(
user: AuthenticatedUser,
dto: MailVacationDto,
): Promise<MailVacation> {
): Promise<MailVacationState> {
const vacation: MailVacation = {
isEnabled: dto.isEnabled,
fromDate: dto.fromDate === null ? null : toWholeSecondIso(dto.fromDate),
@@ -468,7 +501,10 @@ export class MailService {
});
}
this.assertFilterForwardTargets(user, rules);
await this.assertFilterMoveTargets(user, rules);
await this.assertFilterMoveTargets(
user,
rules.map((rule) => rule.actions),
);
const saved = await this.provider.setMailFilters(user.email, {
rules,
@@ -481,6 +517,59 @@ export class MailService {
return saved;
}
/**
* 이미 받은 메일에 필터 적용 (계약 2절 "이미 받은 메일에 적용" — 7단계) — 받은편지함의 메일을 한 요청의 시간 안에서 차례로 판정하고
* 표시 · 옮기기를 한 뒤 이어 할 자리(cursor)를 돌려준다. 저장해 둔 필터가 아니라 본문의 규칙을 쓴다.
* - 칸 사이의 규칙이 어긋나면 엔진에 묻지 않고 VAL_001 — 규칙 저장과 같다(묶는 방법 ↔ 조건 수 · 할 일 하나 이상 — mail-filters.ts)
* - 같은 직원의 적용은 하나씩만 — 진행 중이면 엔진에 묻지 않고 REQ_001
* - 메일함 목록은 한 번만 읽는다 — 옮길 메일함이 보낸편지함 · 임시보관함이면 VAL_001 (규칙 저장과 같은 판정 — assertMoveTargetsAllowed),
* 없는 메일함은 받는다(옮기지 않는다). 어댑터는 그 목록에서 받은편지함 · 옮길 메일함을 찾는다
* - 한 요청의 시간(25초)은 여기 들어온 때부터 센다 — 메일함 목록을 읽는 것도 그 안이다
* 로그에는 확인 · 적용 · 남은 수와 차례 수 · 걸린 시간만 남긴다 — 규칙의 글(찾을 글)은 남기지 않는다 (필터 저장과 같다)
*/
async runMailFilter(
user: AuthenticatedUser,
dto: MailFilterRunRequestDto,
): Promise<MailFilterRun> {
const startedAt = Date.now();
const request: MailFilterRunRequest = {
match: dto.match,
conditions: dto.conditions,
actions: {
moveToMailboxId: dto.actions.moveToMailboxId,
markAsRead: dto.actions.markAsRead,
flag: dto.actions.flag,
},
cursor: dto.cursor,
};
const problem = findFilterRunProblem(request);
if (problem) {
throw new BusinessException('VAL_001', {
context: { reason: 'invalid-filter-run', problem },
});
}
return this.exclusive(
this.filterRuns,
user.email.toLowerCase(),
'filter-run-in-progress',
user,
async () => {
const mailboxes = await this.provider.listMailboxes(user.email);
assertMoveTargetsAllowed(mailboxes, [request.actions]);
const { rounds, ...result } = await this.provider.runMailFilter(
user.email,
request,
{ mailboxes, startedAt },
);
this.logger.log(
`${traceTag()} 이미 받은 메일에 필터를 적용했습니다: user=${maskEmail(user.email)}, 확인 ${result.scanned}통 · 적용 ${result.matched}통 · 남음 ${result.remaining}통${result.cursor === null ? '(끝)' : ''}, 차례 ${rounds}번, ${Date.now() - startedAt}ms`,
);
return result;
},
);
}
/** 받은 그대로의 메일 원문 — 파일 이름은 제목(없으면 "메일") + .eml (attachment.policy.ts 의 messageSourceFileName) */
async openMessageSource(
user: AuthenticatedUser,
@@ -739,59 +828,57 @@ export class MailService {
}
/**
* 필터가 옮길 메일함 — 보낸편지함 · 임시보관함이면 VAL_001. 메일함 목록에 없는 id 는 받는다
* (지운 메일함을 가리키는 규칙이 남아 있을 수 있다 — 그때는 받은편지함에 들어온다). 옮기는 규칙이 없으면 엔진에 묻지 않는다
* 필터 규칙이 옮길 메일함 — 판정은 assertMoveTargetsAllowed. 옮기는 규칙이 없으면 엔진에 묻지 않는다(메일함 목록을 읽지 않는다)
*/
private async assertFilterMoveTargets(
user: AuthenticatedUser,
rules: readonly MailFilterRule[],
actions: readonly Pick<MailFilterActions, 'moveToMailboxId'>[],
): Promise<void> {
const targets = rules.flatMap((rule, index) =>
rule.actions.moveToMailboxId === null
? []
: [{ index, mailboxId: rule.actions.moveToMailboxId }],
);
if (targets.length === 0) return;
const roles = new Map(
(await this.provider.listMailboxes(user.email)).map((mailbox) => [
mailbox.id,
mailbox.role,
]),
);
for (const { index, mailboxId } of targets) {
const role = roles.get(mailboxId);
if (role && FILTER_MOVE_FORBIDDEN_ROLES.has(role)) {
throw new BusinessException('VAL_001', {
context: { reason: 'filter-move-target-not-allowed', role, index },
});
}
if (actions.every(({ moveToMailboxId }) => moveToMailboxId === null)) {
return;
}
assertMoveTargetsAllowed(
await this.provider.listMailboxes(user.email),
actions,
);
}
/**
* 같은 직원 · 같은 메일함의 오래 걸리는 처리를 하나씩만 한다 — 진행 중이면 엔진에 묻지 않고 바로 REQ_001
* (로그인의 진행 중 시도와 같은 모양). 끝나면 성공 · 실패와 상관없이 푼다. 다른 메일함끼리는 동시에 된다
*/
private async runExclusive<T>(
/** 같은 직원 · 같은 메일함의 오래 걸리는 처리를 하나씩만 한다 — 다른 메일함끼리는 동시에 된다 (exclusive) */
private runExclusive<T>(
user: AuthenticatedUser,
mailboxId: string,
operation: () => Promise<T>,
): Promise<T> {
const key = `${user.email.toLowerCase()}\n${mailboxId}`;
if (this.mailboxOperations.has(key)) {
return this.exclusive(
this.mailboxOperations,
`${user.email.toLowerCase()}\n${mailboxId}`,
'mailbox-operation-in-progress',
user,
operation,
);
}
/**
* 같은 열쇠의 일을 하나씩만 한다 — 진행 중이면 엔진에 묻지 않고 바로 REQ_001 (로그인의 진행 중 시도와 같은 모양).
* 끝나면 성공 · 실패와 상관없이 푼다
*/
private async exclusive<T>(
running: Set<string>,
key: string,
reason: string,
user: AuthenticatedUser,
operation: () => Promise<T>,
): Promise<T> {
if (running.has(key)) {
throw new BusinessException('REQ_001', {
context: {
reason: 'mailbox-operation-in-progress',
user: maskEmail(user.email),
},
context: { reason, user: maskEmail(user.email) },
});
}
this.mailboxOperations.add(key);
running.add(key);
try {
return await operation();
} finally {
this.mailboxOperations.delete(key);
running.delete(key);
}
}
+51
View File
@@ -198,6 +198,15 @@ export interface MailVacation {
readonly textBody: string;
}
/** 부재중 자동 응답의 GET · PUT 응답 (계약 2절 "부재중 자동 응답"의 MailVacationState — 7단계) */
export interface MailVacationState extends MailVacation {
/**
* false = 엔진에서 우리 규칙(필터와 한 스크립트)이 꺼져 있어 켜 둔 자동 응답이 지금은 동작하지 않는다 — 저장하면 다시 켜진다.
* 메일 필터의 isActive 와 같은 값이다 (한 번도 저장하지 않은 계정은 true)
*/
readonly isActive: boolean;
}
/** 필터 조건을 견줄 곳 — 보낸 사람(이름 · 주소) · 받는 사람(받는 사람 · 참조의 이름 · 주소) · 제목 · 본문(글자 부분) */
export type MailFilterField = 'from' | 'to' | 'subject' | 'body';
@@ -253,6 +262,48 @@ export interface MailFilterUpdate {
readonly version: string;
}
/** 규칙의 조건 부분 — 묶는 방법과 조건 (규칙 저장 · 이미 받은 메일에 적용 · 판정이 같이 쓴다) */
export type MailFilterCriteria = Pick<MailFilterRule, 'match' | 'conditions'>;
/** 이미 받은 메일에 할 일 (계약 2절 "이미 받은 메일에 적용") — 규칙의 할 일에서 전달을 뺀 것 (이미 받은 메일은 전달하지 않는다) */
export type MailFilterRunActions = Omit<MailFilterActions, 'forwardTo'>;
/** 받은편지함에 이미 있는 메일에 규칙 하나를 적용하는 요청 (POST /api/mail-filter-runs — 7단계) — 서비스가 검사한 값이다 */
export interface MailFilterRunRequest extends MailFilterCriteria {
/** 하나 이상 — 옮길 메일함이 받은편지함이거나 없으면 옮기지 않는다(표시만 한다) */
readonly actions: MailFilterRunActions;
/** 처음은 null — 이어 할 때는 앞 결과의 cursor 그대로 (어댑터가 만들고 해석한다 — 다른 규칙의 것이면 VAL_001) */
readonly cursor: string | null;
}
/** 이미 받은 메일에 적용한 결과 (계약 2절의 MailFilterRun) — cursor 가 null 이 될 때까지 클라이언트가 같은 본문으로 다시 보낸다 */
export interface MailFilterRun {
/** 이번 요청에서 확인한 받은편지함 메일 수 */
readonly scanned: number;
/** 그중 조건에 맞아 할 일을 한 메일 수 (이미 읽음 · 중요였던 메일도 센다) */
readonly matched: number;
/** 아직 확인하지 않은 메일 수 — 어림 (그사이 들어오고 나간 메일이 있다) */
readonly remaining: number;
/** null = 끝 */
readonly cursor: string | null;
}
/**
* 이미 받은 메일에 적용할 때 서비스가 어댑터에 넘기는 것 — 이 요청에서 읽은 메일함 목록(받은편지함 · 옮길 메일함을 여기서 찾는다 — 다시 읽지 않는다)과
* 요청이 서비스에 들어온 때. 한 요청의 시간(25초)은 그때부터 센다 — 옮길 메일함 확인도 그 안이다 (계약 2절 "이미 받은 메일에 적용")
*/
export interface MailFilterRunContext {
readonly mailboxes: readonly Mailbox[];
/** Date.now() 의 값 (ms) */
readonly startedAt: number;
}
/** 어댑터가 돌려주는 적용 결과 — 응답(MailFilterRun)에 로그만 쓰는 값을 더했다 (서비스가 떼고 답한다) */
export interface MailFilterRunOutcome extends MailFilterRun {
/** 이번 요청에서 엔진에 물은 차례 수 */
readonly rounds: number;
}
/** 받은 그대로의 메일 원문 (GET /api/messages/{id}/source) — 파일 이름은 서비스가 제목으로 정한다 */
export interface MessageSource {
readonly stream: Readable;
+12 -6
View File
@@ -30,7 +30,7 @@ import {
MailQuotaDto,
MailStateDto,
} from './dto/mail-response.dto';
import { MailVacationDto } from './dto/mail-vacation.dto';
import { MailVacationDto, MailVacationStateDto } from './dto/mail-vacation.dto';
import {
CreateMailboxDto,
MailboxMessagesPatchDto,
@@ -202,9 +202,12 @@ export class MailVacationController {
@ApiOperation({
summary: '부재중 자동 응답',
description:
'한 번도 설정하지 않았으면 { isEnabled: false, fromDate: null, toDate: null, subject: "", textBody: "" } 다.',
'한 번도 설정하지 않았으면 { isEnabled: false, fromDate: null, toDate: null, subject: "", textBody: "", isActive: true } 다. ' +
'isActive 는 메일 엔진에서 우리 규칙(메일 필터와 한 규칙)이 동작하는지다 — false 면 켜 두었어도 지금은 답하지 않는다(저장하면 다시 켜진다, 메일 필터의 isActive 와 같은 값).',
})
@ApiStandardResponse(MailVacationStateDto, {
description: '지금 설정 · 동작하는지',
})
@ApiStandardResponse(MailVacationDto, { description: '지금 설정' })
get(@CurrentUser() user: AuthenticatedUser) {
return this.mail.getVacation(user);
}
@@ -218,12 +221,15 @@ export class MailVacationController {
`꺼 둔 채로는 제목 ${MAX_VACATION_SUBJECT_LENGTH_WHEN_OFF}자 · 내용 ${MAX_VACATION_TEXT_LENGTH_WHEN_OFF.toLocaleString('ko-KR')}자까지 받는다 — 전에 저장한 긴 글이 켜져 있어도 끌 수 있게. ` +
'시작 · 끝이 둘 다 있으면 시작이 끝보다 앞이어야 한다 — 날짜는 소수 초를 버린 값으로 저장하고, 순서도 그 값으로 본다. ' +
'제목에는 제어 문자(탭 · 줄바꿈 포함)를, 내용에는 탭 · 줄바꿈 밖의 제어 문자를 쓸 수 없다. ' +
'꺼도 쓴 제목 · 내용은 남겨 둔다. 서식 본문은 쓰지 않는다. 저장한 값을 돌려준다. 메일 필터 규칙은 그대로 둔다.',
'꺼도 쓴 제목 · 내용은 남겨 둔다. 서식 본문은 쓰지 않는다. 저장한 값을 돌려준다. 메일 필터 규칙은 그대로 둔다. ' +
'저장하면 우리 규칙이 다시 켜진다(isActive: true — 꺼져 있던 메일 필터도 함께 다시 동작한다). 본문에 isActive 는 넣지 않는다(모르는 필드 — 400 VAL_001).',
})
@ApiStandardResponse(MailVacationStateDto, {
description: '저장한 설정 · 동작하는지',
})
@ApiStandardResponse(MailVacationDto, { description: '저장한 설정' })
@ApiErrorResponse(
'VAL_001',
`VAL_001 형식 · 길이(제목 ${MAX_VACATION_SUBJECT_LENGTH_WHEN_OFF}자 · 내용 ${MAX_VACATION_TEXT_LENGTH_WHEN_OFF.toLocaleString('ko-KR')}자, 켤 때는 제목 ${MAX_VACATION_SUBJECT_LENGTH}자 · 내용 ${MAX_VACATION_TEXT_LENGTH}자) · 제어 문자 · 켤 때 공백뿐인 제목 · 내용 · 시작이 끝보다 늦거나 같음(소수 초를 버린 값으로) · 모르는 필드 ` +
`VAL_001 형식 · 길이(제목 ${MAX_VACATION_SUBJECT_LENGTH_WHEN_OFF}자 · 내용 ${MAX_VACATION_TEXT_LENGTH_WHEN_OFF.toLocaleString('ko-KR')}자, 켤 때는 제목 ${MAX_VACATION_SUBJECT_LENGTH}자 · 내용 ${MAX_VACATION_TEXT_LENGTH}자) · 제어 문자 · 켤 때 공백뿐인 제목 · 내용 · 시작이 끝보다 늦거나 같음(소수 초를 버린 값으로) · 모르는 필드(isActive 포함) ` +
'/ MAIL_013 메일 필터와 함께 만든 엔진 규칙이 엔진 한도를 넘음 — 모두 400, 저장하지 않았다',
)
@ApiErrorResponse(
+246 -16
View File
@@ -38,9 +38,12 @@ import type {
MailboxCreate,
MailboxDeletion,
MailFilterRule,
MailFilterRunOutcome,
MailFilterRunRequest,
MailFilterSet,
MailFilterUpdate,
MailVacation,
MailVacationState,
MailWatch,
MailWatchListener,
MessageDeletionResult,
@@ -189,6 +192,9 @@ const E2E_MAILBOXES: Mailbox[] = (
unreadCount: 0,
}));
/** 대역 메일 엔진이 "다른 규칙의 cursor"라고 답하는 cursor (400 VAL_001 경로 — 실제 해석은 jmap/filter-run-cursor.ts) */
const OTHER_RULE_CURSOR = 'otherrulecursor';
/** 대역 엔진의 규칙 지문 — 실제 지문은 jmap/filter-source.ts (여기서는 같은 규칙이면 같은 값이면 된다) */
const fakeFilterVersion = (rules: readonly MailFilterRule[]) =>
createHash('sha256').update(JSON.stringify(rules)).digest('base64url');
@@ -369,6 +375,11 @@ describe('응답 계약 (e2e)', () => {
{ rules: readonly MailFilterRule[]; vacation: MailVacation }
>();
const filterSaves: { user: string; update: MailFilterUpdate }[] = [];
/** 7단계 — 엔진에서 우리 규칙이 꺼진 직원(부재중 · 필터가 동작하지 않음 — 저장하면 다시 켜진다) · 받은 "이미 받은 메일에 적용" */
const inactiveScripts = new Set<string>();
const filterRuns: { user: string; request: MailFilterRunRequest }[] = [];
/** 대역 엔진의 적용이 테스트가 문을 열 때까지 끝나지 않는다 — 같은 직원의 진행 중 잠금 확인용. null 이면 바로 답한다 */
let slowFilterRunGate: Promise<void> | null = null;
const singleUpdates: {
user: string;
id: string;
@@ -669,21 +680,23 @@ describe('응답 계약 (e2e)', () => {
if (id === SLOW_MAILBOX && slowMailboxGate) await slowMailboxGate;
return { processed: 2, remaining: 0 };
},
getVacation: (user: string): Promise<MailVacation> =>
Promise.resolve(
mailScripts.get(user)?.vacation ?? NEVER_CONFIGURED_VACATION,
),
getVacation: (user: string): Promise<MailVacationState> =>
Promise.resolve({
...(mailScripts.get(user)?.vacation ?? NEVER_CONFIGURED_VACATION),
isActive: !inactiveScripts.has(user),
}),
// 부재중만 바꾼다 — 같은 엔진 규칙의 필터 규칙은 그대로 (실제 규칙은 jmap-filter-script.spec.ts)
setVacation: (
user: string,
vacation: MailVacation,
): Promise<MailVacation> => {
): Promise<MailVacationState> => {
vacationSaves.push({ user, vacation });
mailScripts.set(user, {
rules: mailScripts.get(user)?.rules ?? [],
vacation,
});
return Promise.resolve(vacation);
inactiveScripts.delete(user);
return Promise.resolve({ ...vacation, isActive: true });
},
listMailboxes: (): Promise<Mailbox[]> => Promise.resolve(E2E_MAILBOXES),
// 6단계 — 엔진 쪽 판단(지문 비교 · 한도)만 흉내 낸다. 만드는 Sieve · 조건부 쓰기는 jmap/*.spec.ts 가 본다
@@ -735,6 +748,34 @@ describe('응답 계약 (e2e)', () => {
isActive: true,
});
},
// 7단계 — 차례 · 판정 · 이어 할 자리는 jmap-filter-run.spec.ts 가 본다. 여기서는 받은 값을 기록하고 cursor 로 이어 하기만 흉내 낸다
runMailFilter: async (
user: string,
request: MailFilterRunRequest,
): Promise<MailFilterRunOutcome> => {
filterRuns.push({ user, request });
if (request.cursor === OTHER_RULE_CURSOR) {
throw new BusinessException('VAL_001', {
context: { reason: 'e2e-filter-run-other-rule' },
});
}
if (slowFilterRunGate) await slowFilterRunGate;
return request.cursor === null
? {
scanned: 500,
matched: 3,
remaining: 120,
cursor: 'e2enextcursor',
rounds: 1,
}
: {
scanned: 120,
matched: 1,
remaining: 0,
cursor: null,
rounds: 1,
};
},
openMessageSource: (
_user: string,
id: string,
@@ -1242,6 +1283,7 @@ describe('응답 계약 (e2e)', () => {
['put', '/api/mail-vacation'],
['get', '/api/mail-filters'],
['put', '/api/mail-filters'],
['post', '/api/mail-filter-runs'],
['get', '/api/messages/eaaaaab/source'],
// 템플릿 업로드 예제도 로그인한 직원만 쓴다
['post', '/api/files'],
@@ -2581,30 +2623,41 @@ describe('응답 계약 (e2e)', () => {
.set('Cookie', cookieOf(ME))
.send(payload);
it('한 번도 설정하지 않았으면 끈 상태 · 빈 제목과 내용 — 받은 그대로 다시 저장해도 200', async () => {
it('한 번도 설정하지 않았으면 끈 상태 · 빈 제목과 내용 · 동작함 — isActive 를 뺀 그대로 다시 저장해도 200, isActive 를 넣으면 400', async () => {
const res = await request(server)
.get('/api/mail-vacation')
.set('Cookie', cookieOf(ME))
.expect(200);
expect(body(res)).toEqual({
success: true,
data: NEVER_CONFIGURED_VACATION,
data: { ...NEVER_CONFIGURED_VACATION, isActive: true },
});
const restored = await put(dataOf<object>(res)).expect(200);
expect(dataOf<unknown>(restored)).toEqual(NEVER_CONFIGURED_VACATION);
const { isActive, ...stored } = dataOf<MailVacationState>(res);
expect(isActive).toBe(true);
const restored = await put(stored).expect(200);
expect(dataOf<unknown>(restored)).toEqual({
...NEVER_CONFIGURED_VACATION,
isActive: true,
});
// 응답 그대로(isActive 포함) 보내면 모르는 필드 — 계약 2절 "부재중 자동 응답"의 MailVacationState
const before = vacationSaves.length;
const rejected = await put(dataOf<object>(res)).expect(400);
expect(body(rejected).error?.code).toBe('VAL_001');
expect(vacationSaves).toHaveLength(before);
});
it('저장하면(전체 교체) 엔진이 저장한 값을 돌려주고, 다음 조회도 그 값이다', async () => {
const res = await put(vacation).expect(200);
expect(dataOf<unknown>(res)).toEqual(vacation);
expect(dataOf<unknown>(res)).toEqual({ ...vacation, isActive: true });
expect(vacationSaves.at(-1)).toEqual({ user: ME, vacation });
const read = await request(server)
.get('/api/mail-vacation')
.set('Cookie', cookieOf(ME))
.expect(200);
expect(dataOf<unknown>(read)).toEqual(vacation);
expect(dataOf<unknown>(read)).toEqual({ ...vacation, isActive: true });
});
it('켤 때 빈(공백뿐인) 제목 · 내용, 제어 문자, 시작이 끝보다 늦음 · 같음(같은 초 포함), 날짜 형식, 빠진 키, 길이 초과(켤 때 150 · 600자, 꺼 둔 채로 200 · 4,000자), 서식 본문은 400 VAL_001 — 저장하지 않는다', async () => {
@@ -2660,7 +2713,7 @@ describe('응답 계약 (e2e)', () => {
const res = await put(full).expect(200);
expect(dataOf<unknown>(res)).toEqual(full);
expect(dataOf<unknown>(res)).toEqual({ ...full, isActive: true });
});
it('꺼 둔 채로는 4단계 한도까지 받는다 — 4단계에 저장해 둔 영문 내용 1,000자도 끌 수 있고(200), 그대로 켜면 400 VAL_001 (줄인 뒤 켠다)', async () => {
@@ -2671,7 +2724,7 @@ describe('응답 계약 (e2e)', () => {
};
const res = await put(longOff).expect(200);
expect(dataOf<unknown>(res)).toEqual(longOff);
expect(dataOf<unknown>(res)).toEqual({ ...longOff, isActive: true });
expect(vacationSaves.at(-1)).toEqual({ user: ME, vacation: longOff });
const before = vacationSaves.length;
@@ -2706,7 +2759,7 @@ describe('응답 계약 (e2e)', () => {
fromDate: '2026-11-02T15:00:00.000Z',
toDate: '2026-11-07T15:00:00.000Z',
};
expect(dataOf<unknown>(res)).toEqual(saved);
expect(dataOf<unknown>(res)).toEqual({ ...saved, isActive: true });
expect(vacationSaves.at(-1)).toEqual({ user: ME, vacation: saved });
});
});
@@ -3222,7 +3275,7 @@ describe('응답 계약 (e2e)', () => {
.get('/api/mail-vacation')
.set('Cookie', cookieOf(ME))
.expect(200);
expect(dataOf<unknown>(read)).toEqual(vacation);
expect(dataOf<unknown>(read)).toEqual({ ...vacation, isActive: true });
await request(server)
.put('/api/mail-vacation')
@@ -3236,6 +3289,183 @@ describe('응답 계약 (e2e)', () => {
});
});
describe('7단계 — 이미 받은 메일에 필터 적용 · 부재중의 isActive', () => {
describe('이미 받은 메일에 적용 (POST /api/mail-filter-runs)', () => {
const ME = 'filter.run@e2e.arasystem.test';
const run = {
match: 'any',
conditions: [
{
field: 'from',
operator: 'contains',
value: '@hanbit-partners.co.kr',
},
],
actions: { moveToMailboxId: 'j', markAsRead: true, flag: false },
cursor: null,
};
const post = (payload: unknown, email = ME) =>
request(server)
.post('/api/mail-filter-runs')
.set('Cookie', cookieOf(email))
.send(payload as object);
it('200(201 이 아니다) — 확인 · 적용 · 남은 수와 cursor, 차례 수는 응답에 없다 · 받은 cursor 로 다시 보내면 이어 한다 (찾을 글은 앞뒤 공백을 지워 넘긴다)', async () => {
const first = await post({
...run,
conditions: [
{ ...run.conditions[0], value: ' @hanbit-partners.co.kr ' },
],
}).expect(200);
expect(body(first)).toEqual({
success: true,
data: {
scanned: 500,
matched: 3,
remaining: 120,
cursor: 'e2enextcursor',
},
});
expect(filterRuns.at(-1)).toEqual({ user: ME, request: run });
const next = await post({ ...run, cursor: 'e2enextcursor' }).expect(
200,
);
expect(dataOf<unknown>(next)).toEqual({
scanned: 120,
matched: 1,
remaining: 0,
cursor: null,
});
expect(filterRuns.at(-1)?.request.cursor).toBe('e2enextcursor');
});
it('형식 · 칸 사이의 규칙이 어긋나면 400 VAL_001 — 엔진에 묻지 않는다 (전달 · 보낸편지함 · 임시보관함 · 할 일 없음 · cursor 빠짐 · 모르는 필드)', async () => {
const before = filterRuns.length;
const withActions = (overrides: object) => ({
...run,
actions: { ...run.actions, ...overrides },
});
const withoutCursor = Object.fromEntries(
Object.entries(run).filter(([key]) => key !== 'cursor'),
);
const payloads = [
withActions({ forwardTo: 'doyun.lee@e2e.arasystem.test' }),
withActions({ moveToMailboxId: 'e' }),
withActions({ moveToMailboxId: 'd' }),
withActions({ moveToMailboxId: null, markAsRead: false }),
{ ...run, match: 'always' },
{ ...run, conditions: [] },
{
...run,
conditions: [{ ...run.conditions[0], value: '광고${x}' }],
},
withoutCursor,
{ ...run, cursor: 'not base64url!' },
{ ...run, cursor: 'a'.repeat(1_025) },
{ ...run, version: 'v' },
{ ...run, id: 'rule-1' },
];
const answers: string[] = [];
for (const payload of payloads) {
const res = await post(payload);
answers.push(`${res.status} ${body(res).error?.code ?? ''}`);
}
expect(answers).toEqual(payloads.map(() => '400 VAL_001'));
expect(filterRuns).toHaveLength(before);
});
it('다른 규칙의 cursor 는 어댑터가 400 VAL_001 로 막는다 · 없는 메일함(지워짐)으로 옮기는 규칙은 받는다', async () => {
const res = await post({ ...run, cursor: OTHER_RULE_CURSOR }).expect(
400,
);
expect(body(res).error?.code).toBe('VAL_001');
await post({
...run,
actions: { ...run.actions, moveToMailboxId: 'deletedbox' },
}).expect(200);
});
it(
'같은 직원의 적용이 진행 중이면 429 REQ_001 — 엔진에 묻지 않는다. 다른 직원은 된다, 끝나면 다시 된다',
async () => {
const gate = createGate();
slowFilterRunGate = gate.opened;
try {
const before = filterRuns.length;
const slow = post(run).then((res) => res);
await waitUntil(() => filterRuns.length > before, GATE_DEADLINE_MS);
expect(filterRuns).toHaveLength(before + 1);
const busy = await post(run).expect(429);
expect(body(busy).error?.code).toBe('REQ_001');
expect(filterRuns).toHaveLength(before + 1);
gate.open();
expect((await slow).status).toBe(200);
} finally {
gate.open();
slowFilterRunGate = null;
}
await post(run).expect(200);
await post(run, 'filter.run.other@e2e.arasystem.test').expect(200);
},
GATED_TEST_TIMEOUT_MS,
);
it('본문의 모양 한도(배열 하나 1,000)를 넘으면 DTO 검사 전에 400 VAL_001 — 로그인 전이어도 401 이 아니다', async () => {
const huge = JSON.stringify({
...run,
conditions: Array.from({ length: 80_000 }, () => ({})),
});
for (const cookie of [cookieOf(ME), '']) {
const res = await request(server)
.post('/api/mail-filter-runs')
.set('Cookie', cookie)
.set('Content-Type', 'application/json')
.send(huge);
expect(res.status).toBe(400);
expect(body(res).error?.code).toBe('VAL_001');
expect(body(res).error?.traceId).toBe(res.headers['x-request-id']);
}
});
});
describe('부재중 자동 응답의 isActive (GET · PUT /api/mail-vacation)', () => {
const ME = 'vacation.inactive@e2e.arasystem.test';
it('엔진에서 우리 규칙이 꺼져 있으면 isActive: false — 켜 둔 값은 그대로, 저장하면 다시 켜진다', async () => {
const vacation = {
isEnabled: true,
fromDate: null,
toDate: null,
subject: '부재중입니다',
textBody: '11월 7일까지 휴가입니다.',
};
await request(server)
.put('/api/mail-vacation')
.set('Cookie', cookieOf(ME))
.send(vacation)
.expect(200);
inactiveScripts.add(ME);
const read = await request(server)
.get('/api/mail-vacation')
.set('Cookie', cookieOf(ME))
.expect(200);
expect(dataOf<unknown>(read)).toEqual({ ...vacation, isActive: false });
const saved = await request(server)
.put('/api/mail-vacation')
.set('Cookie', cookieOf(ME))
.send(vacation)
.expect(200);
expect(dataOf<unknown>(saved)).toEqual({ ...vacation, isActive: true });
});
});
});
/**
* 출처 검사(CSRF) — 상태를 바꾸는 요청은 Sec-Fetch-Site(same-origin·none) 또는 허용 목록의 Origin 이어야 한다.
* 둘 다 없으면 브라우저가 아닌 클라이언트로 보고 통과시킨다 (docs/mail/api-contract.md 머리말).