generated from ttipo/project-base
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:
@@ -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('회의  안내')).toBe('회의 안내');
|
||||
expect(htmlToText('회의  안내')).toBe('회의 안내');
|
||||
expect(htmlToText('회의 안내')).toBe('회의\n안내');
|
||||
expect(htmlToText('회의 안내 · 회의 안내')).toBe(
|
||||
'회의 안내 · 회의 안내',
|
||||
);
|
||||
expect(htmlToText('한글😀')).toBe('한글😀');
|
||||
expect(
|
||||
htmlToText('A&B <x> "q" 's' © Ω'),
|
||||
).toBe('A&B <x> "q" \'s\' © Ω');
|
||||
});
|
||||
|
||||
it('풀 수 없는 참조는 글자 그대로 — 모르는 이름(HTML5 에만 있는 이름 포함) · ; 없음 · 숫자 없음 · 너무 긴 이름. 없는 글자는 대체 문자', () => {
|
||||
expect(htmlToText('& . & &; &#; &#x; AT&T')).toBe(
|
||||
'& . & &; &#; &#x; AT&T',
|
||||
);
|
||||
expect(htmlToText(`&${'a'.repeat(40)};`)).toBe(`&${'a'.repeat(40)};`);
|
||||
expect(htmlToText('���')).toBe('���');
|
||||
expect(htmlToText('Ää')).toBe('Ää');
|
||||
});
|
||||
|
||||
it('글을 빼는 동안에는 참조 · 공백도 넣지 않는다 — 앞에 모아 둔 공백은 다음 글자 앞에 그대로', () => {
|
||||
expect(htmlToText('회의 <style>   x </style>안내')).toBe('회의 안내');
|
||||
});
|
||||
|
||||
it('이름 있는 문자 참조 표는 HTML 4 의 252개와 ' 다', () => {
|
||||
expect(NAMED_REFERENCE_COUNT).toBe(253);
|
||||
expect(
|
||||
htmlToText(' ÿ×÷ϑ♦⟨'),
|
||||
).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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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> 안내` → "회의 안내")
|
||||
* - 문자 참조는 `;` 로 끝나는 것만 푼다(` ` 는 그대로) — 이름(HTML 4 의 이름 · `'`) · 10진 · 16진. 푼 글자는 공백 줄이기에 넣지 않고 그대로 넣는다:
|
||||
* `  ` → 띄어쓰기 둘, ` ` → 줄바꿈, `회의  안내` → "회의 안내"(앞의 글 공백을 먼저 넣는다), ` ` → U+00A0.
|
||||
* HTML5 에만 있는 이름(`.` · 대문자 `&` 등 — 엔진은 푼다)은 글자 그대로 둔다(계약의 알려진 한계)
|
||||
* - 줄바꿈(태그 · 참조가 만든 것)은 찾을 글에 없는 글자라(제어 문자를 받지 않는다) 그 자리를 건너 맞는 글이 없다 — 엔진과 같다
|
||||
*
|
||||
* 입력을 한 번 훑는다 — 길이에 비례한다(태그를 다시 시작해도 앞으로만 간다). 정규식 · htmlparser2(sanitize-html)는 쓰지 않는다 —
|
||||
* htmlparser2 는 중첩 깊이의 제곱으로 느려진다 (html-parse-budget.ts). 태그의 짝도 보지 않는다(깊이를 세지 않는다).
|
||||
*/
|
||||
|
||||
const LESS_THAN = 0x3c;
|
||||
const GREATER_THAN = 0x3e;
|
||||
const SLASH = 0x2f;
|
||||
const AMPERSAND = 0x26;
|
||||
const HASH = 0x23;
|
||||
const SEMICOLON = 0x3b;
|
||||
|
||||
/** 태그 · 참조가 만든 줄바꿈 — 공백 줄이기에 들어가지 않는다 */
|
||||
const LINE_BREAK = '\n';
|
||||
|
||||
/** 주석의 처음 · 끝 */
|
||||
const COMMENT_OPEN = '<!--';
|
||||
const 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 의 특수 · 기호 문자 참조와 `'` (HTML 5 · XML) */
|
||||
const OTHER_REFERENCES: readonly (readonly [string, number])[] = [
|
||||
['quot', 0x22],
|
||||
['amp', 0x26],
|
||||
['apos', 0x27],
|
||||
['lt', 0x3c],
|
||||
['gt', 0x3e],
|
||||
['OElig', 0x152],
|
||||
['oelig', 0x153],
|
||||
['Scaron', 0x160],
|
||||
['scaron', 0x161],
|
||||
['Yuml', 0x178],
|
||||
['fnof', 0x192],
|
||||
['circ', 0x2c6],
|
||||
['tilde', 0x2dc],
|
||||
['thetasym', 0x3d1],
|
||||
['upsih', 0x3d2],
|
||||
['piv', 0x3d6],
|
||||
['ensp', 0x2002],
|
||||
['emsp', 0x2003],
|
||||
['thinsp', 0x2009],
|
||||
['zwnj', 0x200c],
|
||||
['zwj', 0x200d],
|
||||
['lrm', 0x200e],
|
||||
['rlm', 0x200f],
|
||||
['ndash', 0x2013],
|
||||
['mdash', 0x2014],
|
||||
['lsquo', 0x2018],
|
||||
['rsquo', 0x2019],
|
||||
['sbquo', 0x201a],
|
||||
['ldquo', 0x201c],
|
||||
['rdquo', 0x201d],
|
||||
['bdquo', 0x201e],
|
||||
['dagger', 0x2020],
|
||||
['Dagger', 0x2021],
|
||||
['bull', 0x2022],
|
||||
['hellip', 0x2026],
|
||||
['permil', 0x2030],
|
||||
['prime', 0x2032],
|
||||
['Prime', 0x2033],
|
||||
['lsaquo', 0x2039],
|
||||
['rsaquo', 0x203a],
|
||||
['oline', 0x203e],
|
||||
['frasl', 0x2044],
|
||||
['euro', 0x20ac],
|
||||
['image', 0x2111],
|
||||
['weierp', 0x2118],
|
||||
['real', 0x211c],
|
||||
['trade', 0x2122],
|
||||
['alefsym', 0x2135],
|
||||
['larr', 0x2190],
|
||||
['uarr', 0x2191],
|
||||
['rarr', 0x2192],
|
||||
['darr', 0x2193],
|
||||
['harr', 0x2194],
|
||||
['crarr', 0x21b5],
|
||||
['lArr', 0x21d0],
|
||||
['uArr', 0x21d1],
|
||||
['rArr', 0x21d2],
|
||||
['dArr', 0x21d3],
|
||||
['hArr', 0x21d4],
|
||||
['forall', 0x2200],
|
||||
['part', 0x2202],
|
||||
['exist', 0x2203],
|
||||
['empty', 0x2205],
|
||||
['nabla', 0x2207],
|
||||
['isin', 0x2208],
|
||||
['notin', 0x2209],
|
||||
['ni', 0x220b],
|
||||
['prod', 0x220f],
|
||||
['sum', 0x2211],
|
||||
['minus', 0x2212],
|
||||
['lowast', 0x2217],
|
||||
['radic', 0x221a],
|
||||
['prop', 0x221d],
|
||||
['infin', 0x221e],
|
||||
['ang', 0x2220],
|
||||
['and', 0x2227],
|
||||
['or', 0x2228],
|
||||
['cap', 0x2229],
|
||||
['cup', 0x222a],
|
||||
['int', 0x222b],
|
||||
['there4', 0x2234],
|
||||
['sim', 0x223c],
|
||||
['cong', 0x2245],
|
||||
['asymp', 0x2248],
|
||||
['ne', 0x2260],
|
||||
['equiv', 0x2261],
|
||||
['le', 0x2264],
|
||||
['ge', 0x2265],
|
||||
['sub', 0x2282],
|
||||
['sup', 0x2283],
|
||||
['nsub', 0x2284],
|
||||
['sube', 0x2286],
|
||||
['supe', 0x2287],
|
||||
['oplus', 0x2295],
|
||||
['otimes', 0x2297],
|
||||
['perp', 0x22a5],
|
||||
['sdot', 0x22c5],
|
||||
['lceil', 0x2308],
|
||||
['rceil', 0x2309],
|
||||
['lfloor', 0x230a],
|
||||
['rfloor', 0x230b],
|
||||
// HTML 5 는 꺾쇠를 U+27E8 · U+27E9 로 바꿨다 (HTML 4 는 U+2329 · U+232A)
|
||||
['lang', 0x27e8],
|
||||
['rang', 0x27e9],
|
||||
['loz', 0x25ca],
|
||||
['spades', 0x2660],
|
||||
['clubs', 0x2663],
|
||||
['hearts', 0x2665],
|
||||
['diams', 0x2666],
|
||||
];
|
||||
|
||||
/** 이름 → 글자 (대소문자를 가린다 — `Ä` 과 `ä` 은 다르다) */
|
||||
const NAMED_REFERENCES: ReadonlyMap<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 };
|
||||
}
|
||||
|
||||
/** `{` · `😀` — 없는 글자(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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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>;
|
||||
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user