diff --git a/backend/src/modules/mail/dto/mail-filter-run.dto.spec.ts b/backend/src/modules/mail/dto/mail-filter-run.dto.spec.ts new file mode 100644 index 0000000..fe53d62 --- /dev/null +++ b/backend/src/modules/mail/dto/mail-filter-run.dto.spec.ts @@ -0,0 +1,150 @@ +import { plainToInstance } from 'class-transformer'; +import { validate, type ValidationError } from 'class-validator'; + +import { + MAX_MAIL_FILTER_CONDITIONS, + MAX_MAIL_FILTER_RUN_CURSOR_LENGTH, + MAX_MAIL_FILTER_VALUE_LENGTH, +} from '@/modules/mail/mail.constants'; +import { MailFilterRunRequestDto } from './mail-filter-run.dto'; + +/** 걸린 규칙 이름 — 중첩된 칸(conditions.0.value · actions.flag 등)까지 */ +function constraintNames(errors: ValidationError[]): string[] { + return errors.flatMap((error) => [ + ...Object.keys(error.constraints ?? {}), + ...constraintNames(error.children ?? []), + ]); +} + +/** 전역 ValidationPipe 와 같은 옵션으로 변환 · 검증한다 (app.setup.ts — stopAtFirstError 만 빼고 — 걸린 규칙을 모두 본다, 통과 · 실패는 같다) */ +async function check( + body: object, +): Promise<{ names: string[]; dto: MailFilterRunRequestDto }> { + const dto = plainToInstance(MailFilterRunRequestDto, body); + const errors = await validate(dto, { + whitelist: true, + forbidNonWhitelisted: true, + }); + return { names: constraintNames(errors), dto }; +} + +const namesOf = async (body: object) => (await check(body)).names; + +const condition = { + field: 'from', + operator: 'contains', + value: '@hanbit-partners.co.kr', +}; +const actions = { moveToMailboxId: 'j', markAsRead: true, flag: false }; +const body = (overrides: object = {}) => ({ + match: 'any', + conditions: [condition], + actions, + cursor: null, + ...overrides, +}); +const withCondition = (overrides: object) => + body({ conditions: [{ ...condition, ...overrides }] }); +const withActions = (overrides: object) => + body({ actions: { ...actions, ...overrides } }); + +describe('이미 받은 메일에 필터 적용 본문 (POST /api/mail-filter-runs)', () => { + it('맞는 본문 — 처음(cursor null) · 이어 하기(cursor 글) · 모든 메일 · 옮기지 않는 할 일', async () => { + expect(await namesOf(body())).toEqual([]); + expect(await namesOf(body({ cursor: 'eyJ2IjoxfQ' }))).toEqual([]); + expect( + await namesOf( + body({ + match: 'always', + conditions: [], + actions: { moveToMailboxId: null, markAsRead: false, flag: true }, + }), + ), + ).toEqual([]); + }); + + it('찾을 글은 필터 규칙과 같은 검사 — 앞뒤 공백을 지운 값으로 검사하고 그 값을 쓴다', async () => { + const { names, dto } = await check( + withCondition({ value: ' @hanbit-partners.co.kr ' }), + ); + expect(names).toEqual([]); + expect(dto.conditions[0].value).toBe('@hanbit-partners.co.kr'); + + for (const value of [ + '', + ' ', + 'x'.repeat(MAX_MAIL_FILTER_VALUE_LENGTH + 1), + '광고${x}', + '광고\n줄바꿈', + 42, + ]) { + expect(await namesOf(withCondition({ value }))).not.toEqual([]); + } + expect(await namesOf(withCondition({ field: 'cc' }))).toContain('isIn'); + expect(await namesOf(withCondition({ operator: 'equals' }))).toContain( + 'isIn', + ); + }); + + it(`묶는 방법 · 조건 수 — 모르는 방법 · 조건 ${MAX_MAIL_FILTER_CONDITIONS}개 초과 · 배열이 아님은 400 (방법과 조건 수의 짝은 서비스가 본다)`, async () => { + expect(await namesOf(body({ match: 'none' }))).toContain('isIn'); + expect( + await namesOf( + body({ + conditions: Array.from( + { length: MAX_MAIL_FILTER_CONDITIONS + 1 }, + () => condition, + ), + }), + ), + ).toContain('arrayMaxSize'); + expect(await namesOf(body({ conditions: condition }))).toContain('isArray'); + }); + + it('할 일 — 전달(forwardTo) 등 다른 키는 모르는 필드 · 옮길 메일함 id 형식 · 표시는 참거짓 · 객체가 아님', async () => { + expect( + await namesOf(withActions({ forwardTo: 'doyun.lee@arasystem.test' })), + ).toContain('whitelistValidation'); + expect(await namesOf(withActions({ forwardTo: null }))).toContain( + 'whitelistValidation', + ); + expect(await namesOf(withActions({ moveToMailboxId: '메일함' }))).toContain( + 'matches', + ); + expect(await namesOf(withActions({ flag: 'yes' }))).toContain('isBoolean'); + expect(await namesOf(body({ actions: [] }))).not.toEqual([]); + // 할 일이 하나도 없는 것은 형식이 아니라 칸 사이의 규칙이다 — 서비스가 VAL_001 (findFilterRunProblem) + expect( + await namesOf( + withActions({ moveToMailboxId: null, markAsRead: false, flag: false }), + ), + ).toEqual([]); + }); + + it(`cursor — 늘 보낸다(빠지면 400) · 글이면 ${MAX_MAIL_FILTER_RUN_CURSOR_LENGTH}자까지 base64url 글자만`, async () => { + const withoutCursor = Object.fromEntries( + Object.entries(body()).filter(([key]) => key !== 'cursor'), + ); + expect(await namesOf(withoutCursor)).toContain('isString'); + expect( + await namesOf( + body({ cursor: 'a'.repeat(MAX_MAIL_FILTER_RUN_CURSOR_LENGTH + 1) }), + ), + ).toContain('maxLength'); + expect(await namesOf(body({ cursor: 'abc=' }))).toContain('matches'); + expect(await namesOf(body({ cursor: '' }))).toContain('matches'); + expect(await namesOf(body({ cursor: 7 }))).toContain('isString'); + }); + + it('모르는 필드는 400 — 규칙의 id · 이름 · 켜짐 · 규칙 목록 · version 은 이 요청에 없다', async () => { + for (const extra of [ + { id: 'r1' }, + { name: '거래처' }, + { isEnabled: true }, + { rules: [] }, + { version: 'v' }, + ]) { + expect(await namesOf(body(extra))).toContain('whitelistValidation'); + } + }); +}); diff --git a/backend/src/modules/mail/dto/mail-filter-run.dto.ts b/backend/src/modules/mail/dto/mail-filter-run.dto.ts new file mode 100644 index 0000000..b898ae0 --- /dev/null +++ b/backend/src/modules/mail/dto/mail-filter-run.dto.ts @@ -0,0 +1,123 @@ +import { ApiProperty, PickType } from '@nestjs/swagger'; +import { Type } from 'class-transformer'; +import { + IsObject, + IsString, + Matches, + MaxLength, + ValidateIf, + ValidateNested, +} from 'class-validator'; + +import { + MailFilterActionsDto, + MailFilterRuleDto, +} from '@/modules/mail/dto/mail-filter.dto'; +import { + MAIL_FILTER_RUN_CURSOR_PATTERN, + MAX_MAIL_FILTER_RUN_CURSOR_LENGTH, +} from '@/modules/mail/mail.constants'; +import type { + MailFilterRun, + MailFilterRunActions, + MailFilterRunRequest, +} from '@/modules/mail/mail.types'; + +/** + * 이미 받은 메일에 필터 적용 — POST /api/mail-filter-runs 의 본문과 응답 (계약 2절 "이미 받은 메일에 적용" — 7단계). + * 묶는 방법 · 조건 · 할 일은 필터 규칙(dto/mail-filter.dto.ts)의 칸을 그대로 가져와 같은 검사를 한다(앞뒤 공백 · 길이 · `${` · 제어 문자). + * 칸 사이의 규칙(묶는 방법 ↔ 조건 수 · 할 일 하나 이상)과 옮길 메일함(보낸편지함 · 임시보관함 아님)은 서비스가 본다 (모두 400 VAL_001). + * 전달은 없다 — actions 에 forwardTo 같은 다른 키를 보내면 모르는 필드로 400 VAL_001 + */ + +/** null 은 "처음부터"로 받는다 — 키가 없으면(undefined) 형식 오류다 (늘 보낸다) */ +const unlessNull = (_object: object, value: unknown) => value !== null; + +/** 할 일 — 필터 규칙의 할 일에서 전달을 뺀 세 칸 (같은 검사) */ +export class MailFilterRunActionsDto + extends PickType(MailFilterActionsDto, [ + 'moveToMailboxId', + 'markAsRead', + 'flag', + ] as const) + implements MailFilterRunActions +{ + @ApiProperty({ + description: + '옮길 메일함 id (없으면 null) — 필터 규칙과 같은 메일함만(보낸편지함 · 임시보관함은 400 VAL_001). ' + + '받은편지함이거나 없는 메일함(지워짐)이면 옮기지 않고 표시만 한다', + nullable: true, + type: String, + example: 'j', + }) + moveToMailboxId!: string | null; +} + +/** POST /api/mail-filter-runs — 규칙 하나(방금 저장한 그 규칙)와 이어 할 자리 */ +export class MailFilterRunRequestDto + extends PickType(MailFilterRuleDto, ['match', 'conditions'] as const) + implements MailFilterRunRequest +{ + @ApiProperty({ + description: + '할 일 — 하나 이상(셋 다 없으면 400 VAL_001). 읽음 · 중요 표시를 붙이고(이미 있으면 그대로) 받은편지함에서 옮길 메일함으로 옮긴다. ' + + '전달은 없다 — 이미 받은 메일은 전달하지 않는다', + type: MailFilterRunActionsDto, + }) + @IsObject({ message: '할 일 형식이 올바르지 않습니다.' }) + @ValidateNested() + @Type(() => MailFilterRunActionsDto) + actions!: MailFilterRunActionsDto; + + @ApiProperty({ + description: + '이어 할 자리 — 처음은 null, 이어 할 때는 앞 응답의 cursor 그대로(해석하지 않는다). 늘 보낸다. ' + + '다른 규칙의 cursor · 깨진 cursor 는 400 VAL_001', + nullable: true, + type: String, + maxLength: MAX_MAIL_FILTER_RUN_CURSOR_LENGTH, + example: null, + }) + @ValidateIf(unlessNull) + @IsString({ message: '이어 할 자리를 다시 받아 주세요.' }) + @MaxLength(MAX_MAIL_FILTER_RUN_CURSOR_LENGTH, { + message: '이어 할 자리를 다시 받아 주세요.', + }) + @Matches(MAIL_FILTER_RUN_CURSOR_PATTERN, { + message: '이어 할 자리를 다시 받아 주세요.', + }) + cursor!: string | null; +} + +/** POST /api/mail-filter-runs 의 응답 (계약 2절의 MailFilterRun) */ +export class MailFilterRunDto implements MailFilterRun { + @ApiProperty({ + description: '이번 요청에서 확인한 받은편지함 메일 수', + example: 500, + }) + scanned!: number; + + @ApiProperty({ + description: + '그중 조건에 맞아 할 일을 한 메일 수 — 이미 읽음 · 중요였던 메일도 센다', + example: 37, + }) + matched!: number; + + @ApiProperty({ + description: + '아직 확인하지 않은 메일 수 — 어림(그사이 들어오고 나간 메일이 있다). 진행의 전체는 지금까지 확인한 수 + 이 값', + example: 7_912, + }) + remaining!: number; + + @ApiProperty({ + description: + 'null = 끝. 아니면 같은 본문에 이 cursor 를 넣어 다시 보낸다 (해석하지 않는다)', + nullable: true, + type: String, + example: + 'eyJ2IjoxLCJhIjoic2VhYWFhZXIiLCJwIjo1MDAsInIiOiJkMlZ3dHo3TjJnQlVUcmdpIn0', + }) + cursor!: string | null; +} diff --git a/backend/src/modules/mail/dto/mail-vacation.dto.ts b/backend/src/modules/mail/dto/mail-vacation.dto.ts index 540d57d..4143f2a 100644 --- a/backend/src/modules/mail/dto/mail-vacation.dto.ts +++ b/backend/src/modules/mail/dto/mail-vacation.dto.ts @@ -17,7 +17,10 @@ import { MULTI_LINE_TEXT_PATTERN, SINGLE_LINE_TEXT_PATTERN, } from '@/modules/mail/mail.constants'; -import type { MailVacation } from '@/modules/mail/mail.types'; +import type { + MailVacation, + MailVacationState, +} from '@/modules/mail/mail.types'; /** ISO 8601 UTC 시각 — 'Z' 로 끝나고 소수 초는 3자리까지 (JS toISOString 의 모양, 계약 머리말 "날짜·시각") */ const UTC_DATE_TIME = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d{1,3})?Z$/; @@ -46,7 +49,7 @@ function IsUtcDateTime(options: ValidationOptions) { const unlessNull = (_object: object, value: unknown) => value !== null; /** - * 부재중 자동 응답 — GET 응답과 PUT 본문(전체 교체)이 같은 모양이다 (계약 2절 "부재중 자동 응답"). + * 부재중 자동 응답 — PUT 본문(전체 교체). GET · PUT 응답은 이것에 isActive 를 더한 MailVacationStateDto 다 (계약 2절 "부재중 자동 응답", 7단계). * 켤 때 제목 · 내용이 비었는지 · 켤 때의 길이(150자 · 600자), 시작이 끝보다 앞인지는 서비스가 본다 (400 VAL_001). * 여기서는 켬 · 끔과 상관없는 한도(4단계의 200자 · 4,000자)만 본다 — 같은 속성에 ValidateIf 를 걸면 그 속성의 검사가 모두 빠져 * 켤 때만의 한도를 함께 걸 수 없다. 서식 본문은 받지 않는다. @@ -112,3 +115,20 @@ export class MailVacationDto implements MailVacation { }) textBody!: string; } + +/** + * GET · PUT /api/mail-vacation 의 응답 (계약 2절의 MailVacationState — 7단계) — 저장한 값 + 지금 동작하는지. + * PUT 본문에는 isActive 를 넣지 않는다 (넣으면 모르는 필드 — 400 VAL_001). 응답 문서용이라 검사 데코레이터를 더하지 않는다 + */ +export class MailVacationStateDto + extends MailVacationDto + implements MailVacationState +{ + @ApiProperty({ + description: + 'false = 메일 엔진에서 우리 규칙(메일 필터와 한 규칙)이 꺼져 있어 켜 둔 자동 응답이 지금은 동작하지 않는다 — 저장하면 다시 켜진다. ' + + '메일 필터의 isActive 와 같은 값이다(한 번도 저장하지 않은 계정은 true). PUT 본문에는 넣지 않는다', + example: true, + }) + isActive!: boolean; +} diff --git a/backend/src/modules/mail/html/html-text.spec.ts b/backend/src/modules/mail/html/html-text.spec.ts new file mode 100644 index 0000000..2e13dd6 --- /dev/null +++ b/backend/src/modules/mail/html/html-text.spec.ts @@ -0,0 +1,175 @@ +import { startCpuTimer } from '@/../test/cpu-time'; +import { NAMED_REFERENCE_COUNT, htmlToText } from './html-text'; + +/** + * CPU 시간 한도 (벽시계가 아니다 — test/cpu-time.ts). 지금 함수는 아래 2MB 입력을 하나에 CPU 0.15초 안쪽으로 읽는다(혼자 돌 때). + * 한도는 넉넉히 둔다 — 무거운 테스트 파일(html-parse-budget.spec.ts 등) 다음에 같은 일꾼에서 돌면 앞 파일이 남긴 큰 힙의 GC 가 + * 재는 사이에 들어와 2초 가까이 재졌다(2026-10-29, 전체 테스트). 막으려는 것은 입력 길이의 제곱이 되는 경로(`<` 마다 `>` 를 끝까지 다시 찾기, + * `&` 마다 끝까지 다시 찾기, 역추적하는 정규식) — 2MB 에서 수천 초가 걸려 이 한도를 확실히 넘는다. 입력은 재기 전에 만들어 둔다(만들기 · 펴기는 재지 않는다) + */ +const LINEAR_CPU_LIMIT_MS = 10_000; +const TWO_MEGABYTES = 2_000_000; + +/** + * 엔진(Stalwart 0.16.27 Sieve `body :text`)의 글자 뽑기 규칙 — 엔진과 같은 판정인지는 jmap/filter-run-texts.spec.ts 의 W · X · Y · Z 표(인증 발송 75통)가 본다. + * 여기서는 규칙 하나하나가 만드는 글을 본다 (줄바꿈 = 태그 · 참조가 만든 것) + */ +describe('htmlToText — HTML 의 글자 부분 (계약 2절 "이미 받은 메일에 적용"의 본문 판정)', () => { + it('태그는 빈자리 없이 뺀다 — 인라인 태그 · div · li · 표 칸 · hr · 여는 p 도 이어 붙는다', () => { + expect(htmlToText('회의록')).toBe('회의록'); + expect(htmlToText('
앞
뒤
')).toBe('앞뒤'); + expect(htmlToText('')).toBe('앞뒤'); + expect(htmlToText('앞뒤앞
뒤')).toBe('앞뒤앞뒤'); + expect(htmlToText('앞

뒤 앞

뒤 앞

뒤')).toBe( + '앞뒤 앞뒤 앞뒤', + ); + }); + + it('

(닫는 것만) ·
(여는 것 · 닫는 것 ·
·
· 대문자)은 줄바꿈이다', () => { + expect(htmlToText('

앞

뒤

')).toBe('앞\n뒤\n'); + expect(htmlToText('앞

뒤')).toBe('앞\n뒤'); + for (const br of ['
', '
', '
', '
', '
', '< br >']) { + expect(htmlToText(`앞${br}뒤`)).toBe('앞\n뒤'); + } + }); + + it('태그 이름은 앞의 공백 · / 와 끝의 공백 · / 를 뺀 나머지 전부 — 속성이 붙으면 알아보지 않는다', () => { + // 속성이 붙은 br · p 는 줄을 끊지 않는다 + expect(htmlToText('앞
뒤')).toBe('앞뒤'); + expect(htmlToText('앞

뒤')).toBe('앞뒤'); + // o:p 는 p 가 아니다 + expect(htmlToText('앞뒤')).toBe('앞뒤'); + // 속성이 붙은 style · script · head · template 은 글을 빼지 않는다 + expect(htmlToText('뒤')).toBe('a{}뒤'); + expect(htmlToText('제목')).toBe( + '제목', + ); + expect(htmlToText('')).toBe('글'); + }); + + it('뒤')).toBe('뒤'); + expect(htmlToText('보임')).toBe('보임'); + expect(htmlToText('보임')).toBe('보임'); + expect(htmlToText('제목

본문

')).toBe( + '본문\n', + ); + expect(htmlToText('보임')).toBe('보임'); + // 넷을 따로 센다 — 다른 이름의 닫는 태그로는 끝나지 않는다 + expect(htmlToText('머리아직끝')).toBe('끝'); + }); + + it('글을 빼는 동안에도 태그는 본다 — 그 안의

·
도 줄바꿈이다', () => { + expect(htmlToText('앞뒤')).toBe('앞\n뒤'); + expect(htmlToText('앞뒤')).toBe('앞\n뒤'); + }); + + it('title · noscript · textarea · iframe · object · svg · xml 은 글이 남는다', () => { + for (const name of [ + 'title', + 'noscript', + 'textarea', + 'iframe', + 'object', + 'svg', + 'xml', + ]) { + expect(htmlToText(`<${name}>글`)).toBe('글'); + } + }); + + it('태그는 < 부터 다음 > 까지 — < 뒤가 무엇이든 태그이고, 따옴표는 보지 않는다', () => { + // `<광고>` 는 태그로 빠지고, 맨 앞이 된 공백은 넣지 않는다 + expect(htmlToText('

<광고> 안내

')).toBe('안내\n'); + expect(htmlToText('a>b사진')).toBe('b">사진'); + expect(htmlToText('z')).toBe( + '링크 회의 안내 z', + ); + expect(htmlToText('<>글')).toBe('글'); + }); + + it('태그 안에서 < 가 또 나오면 거기서 태그를 다시 시작한다 — 닫히지 않은 < 는 끝까지 삼킨다', () => { + // `< 1000원할인` 은 버려지고 `

` 가 태그가 된다 + expect(htmlToText('

가격 < 1000원할인

뒤')).toBe('가격\n뒤'); + expect(htmlToText('앞 < 뒤')).toBe('앞'); + expect(htmlToText('앞b')).toBe('a\nb'); + }); + + it(' 는 안의 글까지 뺀다 — 닫히지 않으면 끝까지. 그 밖의 (DOCTYPE · mso 조건)는 보통 태그다', () => { + expect(htmlToText('보임')).toBe('보임'); + expect(htmlToText('보임')).toBe('보임'); + expect(htmlToText('보임')).toBe('보임'); + expect(htmlToText('

글

')).toBe('글\n'); + expect(htmlToText('앞` 까지 주석이다(안의 글까지 뺀다). 그 밖의 ``(DOCTYPE · ``)는 보통 태그다 + * - 태그의 이름 = 태그 글에서 앞의 공백 · `/` 와 끝의 공백 · `/` 를 뺀 **나머지 전부** — ASCII 대소문자 없이 견준다. + * 닫는 태그 = 첫 공백 아닌 글자가 `/`. 속성이 붙으면 이름이 아니다 — `
` · `` · `

` 는 알아본다 + * - 알아보는 이름: `br`(여는 것 · 닫는 것) → 줄바꿈, `p` 의 닫는 것만 → 줄바꿈(여는 `

` · `

` 는 아무것도 아니다), + * `style` · `script` · `head` · `template` → 여는 것부터 같은 이름의 닫는 것까지 글을 뺀다(넷을 따로 센다 — 짝을 맞추지 않는다). + * `title` · `noscript` · `textarea` · `iframe` · `object` · `svg` · `xml` 은 글이 남는다. 글을 빼는 동안에도 태그는 본다(그 안의 `

` · `
` 도 줄바꿈) + * - 그 밖의 태그는 빈자리 없이 뺀다 — 낱말 중간의 인라인 태그(`회의`) · div · li · 표 칸도 이어 붙는다 + * - 글의 이어진 공백(띄어쓰기 · 탭 · CR · LF — 그 밖의 제어 문자 · U+00A0 · U+3000 은 글자다)은 띄어쓰기 하나다 — 태그를 건너서도 하나(`회의 안내` → "회의 안내") + * - 문자 참조는 `;` 로 끝나는 것만 푼다(` ` 는 그대로) — 이름(HTML 4 의 이름 · `'`) · 10진 · 16진. 푼 글자는 공백 줄이기에 넣지 않고 그대로 넣는다: + * ` ` → 띄어쓰기 둘, ` ` → 줄바꿈, `회의 안내` → "회의 안내"(앞의 글 공백을 먼저 넣는다), ` ` → U+00A0. + * HTML5 에만 있는 이름(`.` · 대문자 `&` 등 — 엔진은 푼다)은 글자 그대로 둔다(계약의 알려진 한계) + * - 줄바꿈(태그 · 참조가 만든 것)은 찾을 글에 없는 글자라(제어 문자를 받지 않는다) 그 자리를 건너 맞는 글이 없다 — 엔진과 같다 + * + * 입력을 한 번 훑는다 — 길이에 비례한다(태그를 다시 시작해도 앞으로만 간다). 정규식 · htmlparser2(sanitize-html)는 쓰지 않는다 — + * htmlparser2 는 중첩 깊이의 제곱으로 느려진다 (html-parse-budget.ts). 태그의 짝도 보지 않는다(깊이를 세지 않는다). + */ + +const LESS_THAN = 0x3c; +const GREATER_THAN = 0x3e; +const SLASH = 0x2f; +const AMPERSAND = 0x26; +const HASH = 0x23; +const SEMICOLON = 0x3b; + +/** 태그 · 참조가 만든 줄바꿈 — 공백 줄이기에 들어가지 않는다 */ +const LINE_BREAK = '\n'; + +/** 주석의 처음 · 끝 */ +const COMMENT_OPEN = ''; + +/** 안의 글을 빼는 태그 — 소문자 (넷을 따로 센다) */ +const HIDING_TAGS = ['style', 'script', 'head', 'template'] as const; +type HidingTag = (typeof HIDING_TAGS)[number]; + +/** 알아보는 이름 가운데 가장 긴 것(template)의 길이 — 이보다 긴 태그 글은 이름을 견주지 않는다 */ +const LONGEST_KNOWN_TAG = 8; + +/** 문자 참조의 이름 · 숫자를 읽는 최대 길이 — 넘으면 참조가 아니라 글자로 본다 (`&` 마다 앞으로 조금만 본다 — 길이에 비례) */ +const MAX_REFERENCE_NAME_LENGTH = 32; +const MAX_REFERENCE_DIGITS = 8; + +/** 유니코드의 끝 · 대체 문자 */ +const MAX_CODE_POINT = 0x10ffff; +const REPLACEMENT_CHARACTER = '�'; + +/** U+00A0 ~ U+00FF 의 이름 — HTML 4 의 Latin-1 문자 참조, 차례대로 */ +const LATIN1_REFERENCE_NAMES = [ + 'nbsp iexcl cent pound curren yen brvbar sect uml copy ordf laquo not shy reg macr', + 'deg plusmn sup2 sup3 acute micro para middot cedil sup1 ordm raquo frac14 frac12 frac34 iquest', + 'Agrave Aacute Acirc Atilde Auml Aring AElig Ccedil Egrave Eacute Ecirc Euml Igrave Iacute Icirc Iuml', + 'ETH Ntilde Ograve Oacute Ocirc Otilde Ouml times Oslash Ugrave Uacute Ucirc Uuml Yacute THORN szlig', + 'agrave aacute acirc atilde auml aring aelig ccedil egrave eacute ecirc euml igrave iacute icirc iuml', + 'eth ntilde ograve oacute ocirc otilde ouml divide oslash ugrave uacute ucirc uuml yacute thorn yuml', +] + .join(' ') + .split(' '); +const LATIN1_REFERENCE_START = 0xa0; + +/** 그리스 문자 — 대문자 U+0391 ~ U+03A9(U+03A2 는 없다), 소문자 U+03B1 ~ U+03C9 (HTML 4) */ +const GREEK_UPPER_NAMES = [ + 'Alpha Beta Gamma Delta Epsilon Zeta Eta Theta Iota Kappa Lambda Mu Nu Xi Omicron Pi Rho', + '- Sigma Tau Upsilon Phi Chi Psi Omega', +] + .join(' ') + .split(' '); +const GREEK_LOWER_NAMES = [ + 'alpha beta gamma delta epsilon zeta eta theta iota kappa lambda mu nu xi omicron pi rho', + 'sigmaf sigma tau upsilon phi chi psi omega', +] + .join(' ') + .split(' '); +const GREEK_UPPER_START = 0x391; +const GREEK_LOWER_START = 0x3b1; + +/** 그 밖의 이름 — HTML 4 의 특수 · 기호 문자 참조와 `'` (HTML 5 · XML) */ +const OTHER_REFERENCES: readonly (readonly [string, number])[] = [ + ['quot', 0x22], + ['amp', 0x26], + ['apos', 0x27], + ['lt', 0x3c], + ['gt', 0x3e], + ['OElig', 0x152], + ['oelig', 0x153], + ['Scaron', 0x160], + ['scaron', 0x161], + ['Yuml', 0x178], + ['fnof', 0x192], + ['circ', 0x2c6], + ['tilde', 0x2dc], + ['thetasym', 0x3d1], + ['upsih', 0x3d2], + ['piv', 0x3d6], + ['ensp', 0x2002], + ['emsp', 0x2003], + ['thinsp', 0x2009], + ['zwnj', 0x200c], + ['zwj', 0x200d], + ['lrm', 0x200e], + ['rlm', 0x200f], + ['ndash', 0x2013], + ['mdash', 0x2014], + ['lsquo', 0x2018], + ['rsquo', 0x2019], + ['sbquo', 0x201a], + ['ldquo', 0x201c], + ['rdquo', 0x201d], + ['bdquo', 0x201e], + ['dagger', 0x2020], + ['Dagger', 0x2021], + ['bull', 0x2022], + ['hellip', 0x2026], + ['permil', 0x2030], + ['prime', 0x2032], + ['Prime', 0x2033], + ['lsaquo', 0x2039], + ['rsaquo', 0x203a], + ['oline', 0x203e], + ['frasl', 0x2044], + ['euro', 0x20ac], + ['image', 0x2111], + ['weierp', 0x2118], + ['real', 0x211c], + ['trade', 0x2122], + ['alefsym', 0x2135], + ['larr', 0x2190], + ['uarr', 0x2191], + ['rarr', 0x2192], + ['darr', 0x2193], + ['harr', 0x2194], + ['crarr', 0x21b5], + ['lArr', 0x21d0], + ['uArr', 0x21d1], + ['rArr', 0x21d2], + ['dArr', 0x21d3], + ['hArr', 0x21d4], + ['forall', 0x2200], + ['part', 0x2202], + ['exist', 0x2203], + ['empty', 0x2205], + ['nabla', 0x2207], + ['isin', 0x2208], + ['notin', 0x2209], + ['ni', 0x220b], + ['prod', 0x220f], + ['sum', 0x2211], + ['minus', 0x2212], + ['lowast', 0x2217], + ['radic', 0x221a], + ['prop', 0x221d], + ['infin', 0x221e], + ['ang', 0x2220], + ['and', 0x2227], + ['or', 0x2228], + ['cap', 0x2229], + ['cup', 0x222a], + ['int', 0x222b], + ['there4', 0x2234], + ['sim', 0x223c], + ['cong', 0x2245], + ['asymp', 0x2248], + ['ne', 0x2260], + ['equiv', 0x2261], + ['le', 0x2264], + ['ge', 0x2265], + ['sub', 0x2282], + ['sup', 0x2283], + ['nsub', 0x2284], + ['sube', 0x2286], + ['supe', 0x2287], + ['oplus', 0x2295], + ['otimes', 0x2297], + ['perp', 0x22a5], + ['sdot', 0x22c5], + ['lceil', 0x2308], + ['rceil', 0x2309], + ['lfloor', 0x230a], + ['rfloor', 0x230b], + // HTML 5 는 꺾쇠를 U+27E8 · U+27E9 로 바꿨다 (HTML 4 는 U+2329 · U+232A) + ['lang', 0x27e8], + ['rang', 0x27e9], + ['loz', 0x25ca], + ['spades', 0x2660], + ['clubs', 0x2663], + ['hearts', 0x2665], + ['diams', 0x2666], +]; + +/** 이름 → 글자 (대소문자를 가린다 — `Ä` 과 `ä` 은 다르다) */ +const NAMED_REFERENCES: ReadonlyMap = (() => { + const named = new Map(); + const addRun = (names: readonly string[], start: number) => + names.forEach((name, offset) => { + // '-' 는 빈 자리 (그 번호에는 이름이 없다) + if (name !== '-') named.set(name, String.fromCodePoint(start + offset)); + }); + addRun(LATIN1_REFERENCE_NAMES, LATIN1_REFERENCE_START); + addRun(GREEK_UPPER_NAMES, GREEK_UPPER_START); + addRun(GREEK_LOWER_NAMES, GREEK_LOWER_START); + for (const [name, codePoint] of OTHER_REFERENCES) { + named.set(name, String.fromCodePoint(codePoint)); + } + return named; +})(); + +/** 이름 있는 문자 참조의 수 — 테스트가 표를 빠뜨리지 않았는지 본다 (HTML 4 의 252개 + apos) */ +export const NAMED_REFERENCE_COUNT = NAMED_REFERENCES.size; + +/** + * 뽑은 글 — 글의 이어진 공백은 하나로 모았다가 다음 글자 앞에 띄어쓰기 하나로 넣고(맨 앞이면 넣지 않는다), + * 푼 참조 · 태그가 만든 줄바꿈은 줄이지 않고 그대로 넣는다 + */ +class TextSink { + private readonly pieces: string[] = []; + /** 글의 공백을 만났다 — 다음 글자 앞에 띄어쓰기 하나 */ + private pendingSpace = false; + + space(): void { + this.pendingSpace = true; + } + + /** 글자 · 푼 참조 — 모아 둔 공백을 먼저 넣는다 */ + text(value: string): void { + if (this.pendingSpace && this.pieces.length > 0) this.pieces.push(' '); + this.pendingSpace = false; + this.pieces.push(value); + } + + /** `
` · `

` 의 줄바꿈 — 모아 둔 공백은 버린다 (줄바꿈을 건너 맞는 글은 없다) */ + lineBreak(): void { + this.pendingSpace = false; + this.pieces.push(LINE_BREAK); + } + + toString(): string { + return this.pieces.join(''); + } +} + +/** 안의 글을 빼는 태그 가운데 열려 있는 것 — 넷을 따로 둔다(짝 · 깊이를 세지 않는다 — 여는 것은 켜고 닫는 것은 끈다) */ +class HidingTags { + private readonly open = new Set(); + + get active(): boolean { + return this.open.size > 0; + } + + toggle(tag: HidingTag, opening: boolean): void { + if (opening) this.open.add(tag); + else this.open.delete(tag); + } +} + +/** HTML 의 글자 부분 — 위 머리말의 규칙 */ +export function htmlToText(html: string): string { + const sink = new TextSink(); + const hiding = new HidingTags(); + let index = 0; + while (index < html.length) { + const code = html.charCodeAt(index); + if (code === LESS_THAN) { + index = readMarkup(html, index, sink, hiding); + continue; + } + if (isHtmlSpace(code)) { + if (!hiding.active) sink.space(); + index += 1; + continue; + } + if (code === AMPERSAND) { + const reference = readReference(html, index); + if (reference !== null) { + if (!hiding.active) sink.text(reference.value); + index = reference.end; + continue; + } + } + // 글자 — 다음 `<` · `&` · 공백 앞까지 한 번에 (풀지 못한 `&` 는 글자다) + const end = textRunEnd(html, index + 1); + if (!hiding.active) sink.text(html.slice(index, end)); + index = end; + } + return sink.toString(); +} + +/** 글자가 이어지는 끝 — `<` · `&` · 공백 앞 */ +function textRunEnd(html: string, from: number): number { + let index = from; + while (index < html.length) { + const code = html.charCodeAt(index); + if (code === LESS_THAN || code === AMPERSAND || isHtmlSpace(code)) break; + index += 1; + } + return index; +} + +/** + * `<` 에서 시작하는 표시를 읽고 그 다음 자리를 돌려준다 — 주석이면 `-->` 까지, 아니면 다음 `>` 까지의 태그. + * 태그 안에서 `<` 가 또 나오면 거기서 다시 시작한다(앞의 것은 버린다). 닫히지 않으면 html.length — 나머지를 모두 삼킨다 + */ +function readMarkup( + html: string, + open: number, + sink: TextSink, + hiding: HidingTags, +): number { + let start = open; + for (;;) { + if (html.startsWith(COMMENT_OPEN, start)) { + const close = html.indexOf(COMMENT_CLOSE, start + COMMENT_OPEN.length); + return close === -1 ? html.length : close + COMMENT_CLOSE.length; + } + let index = start + 1; + while (index < html.length) { + const code = html.charCodeAt(index); + if (code === GREATER_THAN || code === LESS_THAN) break; + index += 1; + } + if (index >= html.length) return html.length; + if (html.charCodeAt(index) === LESS_THAN) { + start = index; + continue; + } + applyTag(html, start + 1, index, sink, hiding); + return index + 1; + } +} + +/** + * 태그 글(`<` 와 `>` 사이)의 할 일 — 이름은 앞의 공백 · `/` 와 끝의 공백 · `/` 를 뺀 나머지 전부다(속성이 붙으면 알아보지 않는다). + * 닫는 태그는 첫 공백 아닌 글자가 `/` 인 것. br → 줄바꿈 · 닫는 p → 줄바꿈 · style · script · head · template → 안의 글을 빼기 시작 · 끝 + */ +function applyTag( + html: string, + from: number, + to: number, + sink: TextSink, + hiding: HidingTags, +): void { + let start = from; + while (start < to && isHtmlSpace(html.charCodeAt(start))) start += 1; + const closing = start < to && html.charCodeAt(start) === SLASH; + while (start < to && isNameEdge(html.charCodeAt(start))) start += 1; + let end = to; + while (end > start && isNameEdge(html.charCodeAt(end - 1))) end -= 1; + if (end - start > LONGEST_KNOWN_TAG) return; + + if (isTagName(html, start, end, 'br')) { + sink.lineBreak(); + return; + } + if (isTagName(html, start, end, 'p')) { + if (closing) sink.lineBreak(); + return; + } + const tag = HIDING_TAGS.find((name) => isTagName(html, start, end, name)); + if (tag) hiding.toggle(tag, !closing); +} + +/** 태그 이름 자리가 그 이름(소문자)과 같은가 — ASCII 대소문자만 무시한다 */ +function isTagName( + html: string, + start: number, + end: number, + name: string, +): boolean { + return end - start === name.length && matchesAsciiLower(html, start, name); +} + +/** 이름 앞뒤에서 빼는 글자 — 공백 · `/` */ +function isNameEdge(code: number): boolean { + return isHtmlSpace(code) || code === SLASH; +} + +/** html 의 그 자리부터가 소문자 낱말(영문)과 같은가 — ASCII 대소문자만 무시한다 */ +function matchesAsciiLower(html: string, at: number, word: string): boolean { + if (at + word.length > html.length) return false; + for (let offset = 0; offset < word.length; offset += 1) { + let code = html.charCodeAt(at + offset); + if (code >= 0x41 && code <= 0x5a) code += 0x20; + if (code !== word.charCodeAt(offset)) return false; + } + return true; +} + +/** `&` 자리에서 시작하는 문자 참조 — `;` 로 끝나는 것만, 풀 수 없으면 null (글자 그대로 둔다) */ +function readReference( + text: string, + at: number, +): { readonly value: string; readonly end: number } | null { + if (text.charCodeAt(at + 1) === HASH) return readNumericReference(text, at); + const nameStart = at + 1; + const limit = Math.min(text.length, nameStart + MAX_REFERENCE_NAME_LENGTH); + let index = nameStart; + while (index < limit && isAsciiAlphanumeric(text.charCodeAt(index))) { + index += 1; + } + if (index === nameStart || text.charCodeAt(index) !== SEMICOLON) return null; + const value = NAMED_REFERENCES.get(text.slice(nameStart, index)); + return value === undefined ? null : { value, end: index + 1 }; +} + +/** `{` · `😀` — 없는 글자(0 · 대리 · 유니코드 밖)는 대체 문자 (HTML 표준) */ +function readNumericReference( + text: string, + at: number, +): { readonly value: string; readonly end: number } | null { + const marker = text.charCodeAt(at + 2); + const hex = marker === 0x78 || marker === 0x58; + const digitsStart = hex ? at + 3 : at + 2; + const limit = Math.min(text.length, digitsStart + MAX_REFERENCE_DIGITS); + let index = digitsStart; + while ( + index < limit && + (hex + ? isHexDigit(text.charCodeAt(index)) + : isDecimalDigit(text.charCodeAt(index))) + ) { + index += 1; + } + if (index === digitsStart || text.charCodeAt(index) !== SEMICOLON) { + return null; + } + const codePoint = Number.parseInt( + text.slice(digitsStart, index), + hex ? 16 : 10, + ); + return { value: characterOf(codePoint), end: index + 1 }; +} + +function characterOf(codePoint: number): string { + if ( + codePoint === 0 || + codePoint > MAX_CODE_POINT || + (codePoint >= 0xd800 && codePoint <= 0xdfff) + ) { + return REPLACEMENT_CHARACTER; + } + return String.fromCodePoint(codePoint); +} + +/** 글의 공백 — 띄어쓰기 · 탭 · LF · CR (엔진과 같다 — FF · U+00A0 · U+3000 은 아니다) */ +function isHtmlSpace(code: number): boolean { + return code === 0x20 || code === 0x09 || code === 0x0a || code === 0x0d; +} + +function isAsciiLetter(code: number): boolean { + return (code >= 0x41 && code <= 0x5a) || (code >= 0x61 && code <= 0x7a); +} + +function isDecimalDigit(code: number): boolean { + return code >= 0x30 && code <= 0x39; +} + +function isHexDigit(code: number): boolean { + return ( + isDecimalDigit(code) || + (code >= 0x41 && code <= 0x46) || + (code >= 0x61 && code <= 0x66) + ); +} + +function isAsciiAlphanumeric(code: number): boolean { + return isAsciiLetter(code) || isDecimalDigit(code); +} diff --git a/backend/src/modules/mail/jmap/bulk-run.ts b/backend/src/modules/mail/jmap/bulk-run.ts new file mode 100644 index 0000000..6107668 --- /dev/null +++ b/backend/src/modules/mail/jmap/bulk-run.ts @@ -0,0 +1,99 @@ +import { BusinessException } from '@/common/errors/business.exception'; +import { JMAP_REQUEST_TIMEOUT_MS, type JmapCallOptions } from './jmap.client'; + +/** + * 오래 걸리는 처리 한 요청의 시간 · 진행 · 멈춘 차례 — 메일함 전체 읽음 · 비우기 · 지우기(jmap-mail.provider.ts, 계약 2절 "메일함 관리")와 + * 이미 받은 메일에 필터 적용(jmap-filter-run.ts, 계약 2절 "이미 받은 메일에 적용")이 같은 규칙을 쓴다. + * 이 모듈은 `modules/mail/jmap/` 밖으로 내보내지 않는다. + */ + +/** + * 한 요청의 시간 — 실제 상한이다 (BulkRun). 이 안에서 끝내고 남은 수(또는 이어 할 자리)를 돌려준다. + * 클라이언트가 남은 것이 없을 때까지 같은 요청을 다시 보낸다. + */ +export const BULK_REQUEST_BUDGET_MS = 25_000; +/** 남은 시간이 이보다 적으면 다음 차례를 시작하지 않는다 — 한 차례(엔진 호출 두어 번)가 끝까지 시간 안에 들게 */ +export const BULK_ROUND_RESERVE_MS = 5_000; + +/** + * 상태가 어긋나면(ifInState — stateMismatch) 다시 확인하는 횟수 — 조건부로 바꾸는 요청을 모두 합쳐. + * 사용자 초안(교체 저장·보낸 뒤)과 휴지통에서 영구 삭제(DELETE /api/messages/{id} · POST /api/message-deletions)가 함께 쓰고, + * 오래 걸리는 처리(BulkRun)는 아무것도 바뀌지 않은 차례가 이어서 이 횟수면 멈춘다. + * 계정의 메일이 하나라도 바뀌면(새 메일 도착·다른 메일의 읽음 표시 포함) 상태가 바뀌므로 바쁜 메일함에서는 어긋날 수 있다 — + * 그래도 이 횟수 안에 맞지 않으면 지우지(옮기지) 않는다. + */ +export const CONDITIONAL_DESTROY_ATTEMPTS = 3; + +/** + * 오래 걸리는 처리의 한 요청 — 시간 · 진행 · 멈춘 차례. + * - 시간: 처음 엔진 호출(메일함 목록) **전부터** BULK_REQUEST_BUDGET_MS 가 실제 상한이다. 다음 차례는 남은 시간이 BULK_ROUND_RESERVE_MS 보다 + * 많을 때만 시작하고, 엔진 호출마다 제한 시간을 기본 제한 시간(JMAP_REQUEST_TIMEOUT_MS)과 남은 시간 중 짧은 쪽으로 준다 — + * 엔진이 느려도 한 요청이 앞단 nginx 의 시간 제한에 걸리지 않게 + * - 멈춘 차례: 아무것도 바뀌지 않은 차례(어긋남 · 바뀐 것도 거절도 없음 · 비운 메일함을 지우는 사이 메일이 들어옴)가 + * 이어서 CONDITIONAL_DESTROY_ATTEMPTS 번이면 멈춘다 — 엔진에 끝없이 묻지 않게. 진행한 차례가 있으면 처음부터 다시 센다 + * - 일찍 멈췄을 때(멈춘 차례 · 남은 시간 · 남은 시간 때문에 끊긴 호출) 이 요청에서 처리한 것이 있으면 거기까지로 답하고, 없으면 MAIL_001 + * 처리한 것(processed)은 쓰는 쪽이 정한다 — 메일함 처리는 바꾼 메일 수, 필터 적용은 판정한 메일 수. + * 시계는 만들 때부터 간다 — 앞서 시작한 때(startedAt)를 주면 그때부터 센다(필터 적용은 서비스에 들어온 때 — 옮길 메일함 확인을 포함한다) + */ +export class BulkRun { + private readonly deadline: number; + private stalls = 0; + private lastStall: string | null = null; + /** 이번 요청에서 처리한 메일 수 */ + processed = 0; + + constructor(startedAt: number = Date.now()) { + this.deadline = startedAt + BULK_REQUEST_BUDGET_MS; + } + + /** 다음 차례를 시작해도 되는가 */ + canStartRound(): boolean { + return this.left() > BULK_ROUND_RESERVE_MS; + } + + /** 이번 엔진 호출의 제한 시간 — 기본 제한 시간과 남은 시간 중 짧은 쪽 */ + callOptions(): JmapCallOptions { + return { + timeoutMs: Math.max(1, Math.min(JMAP_REQUEST_TIMEOUT_MS, this.left())), + }; + } + + /** 처리했다 — 멈춘 차례를 처음부터 다시 센다 */ + progress(count: number): void { + if (count <= 0) return; + this.processed += count; + this.stalls = 0; + } + + /** 아무것도 바뀌지 않은 차례 — 이어서 CONDITIONAL_DESTROY_ATTEMPTS 번째면 true (멈춘다) */ + stalled(why: string): boolean { + this.stalls += 1; + this.lastStall = why; + return this.stalls >= CONDITIONAL_DESTROY_ATTEMPTS; + } + + /** + * 한 요청의 시간을 다 써서 엔진 호출이 끊겼고, 이 요청에서 처리한 것이 있는가 — 그러면 거기까지로 답한다. + * 끊긴 Email/set 은 엔진이 적용했을 수 있다 — 남은 수는 다음 요청이 새로 읽어 맞춘다 (조건부 규칙 그대로) + */ + cutOffAfterProgress(error: unknown): boolean { + return ( + this.processed > 0 && + this.left() <= 0 && + error instanceof BusinessException && + error.code === 'MAIL_001' + ); + } + + /** 끝까지 하지 못하고 멈췄는데 이 요청에서 처리한 것이 없으면 MAIL_001 — 클라이언트가 같은 요청을 끝없이 다시 보내지 않게 */ + assertProgress(operation: string): void { + if (this.processed > 0) return; + throw new BusinessException('MAIL_001', { + context: { reason: `${operation}-no-progress`, stalled: this.lastStall }, + }); + } + + private left(): number { + return this.deadline - Date.now(); + } +} diff --git a/backend/src/modules/mail/jmap/filter-run-cursor.spec.ts b/backend/src/modules/mail/jmap/filter-run-cursor.spec.ts new file mode 100644 index 0000000..8fdaba7 --- /dev/null +++ b/backend/src/modules/mail/jmap/filter-run-cursor.spec.ts @@ -0,0 +1,174 @@ +import { BusinessException } from '@/common/errors/business.exception'; +import type { MailFilterRunRequest } from '@/modules/mail/mail.types'; +import { + decodeFilterRunCursor, + encodeFilterRunCursor, + MAX_FILTER_RUN_SECONDS, + type FilterRunPosition, + type FilterRunRule, +} from './filter-run-cursor'; + +const rule: FilterRunRule = { + match: 'any', + conditions: [ + { field: 'from', operator: 'contains', value: '@hanbit-partners.co.kr' }, + ], + actions: { moveToMailboxId: 'j', markAsRead: true, flag: false }, +}; + +/** 2026-10-29T09:00:00Z (초) */ +const NINE = 1_793_264_400; + +function failureOf(run: () => unknown): Record | undefined { + try { + run(); + } catch (error) { + if (!(error instanceof BusinessException)) throw error; + return { code: error.code, reason: error.context?.reason }; + } + return undefined; +} + +const forged = (payload: unknown) => + Buffer.from(JSON.stringify(payload)).toString('base64url'); + +/** 진짜 cursor 의 규칙 지문 — 손으로 만든 cursor 에 붙인다 */ +function hashOf(cursor: string): string { + return ( + JSON.parse(Buffer.from(cursor, 'base64url').toString('utf8')) as { + r: string; + } + ).r; +} + +describe('이미 받은 메일에 필터 적용의 cursor (계약 2절 "이미 받은 메일에 적용")', () => { + it('만든 자리를 그대로 되돌린다 — 위 경계 · 남긴 마지막 메일 · 그 받은 시각 (남긴 메일이 아직 없으면 anchor 는 null, 받은 시각만 있을 수 있다)', () => { + const positions: FilterRunPosition[] = [ + { before: NINE + 1, anchor: 'seaaaaer', anchorTime: NINE - 60 }, + { before: NINE + 1, anchor: null, anchorTime: NINE - 60 }, + { before: NINE + 1, anchor: null, anchorTime: null }, + { before: MAX_FILTER_RUN_SECONDS, anchor: 'm1', anchorTime: 0 }, + ]; + for (const position of positions) { + expect( + decodeFilterRunCursor(encodeFilterRunCursor(position, rule), rule), + ).toEqual(position); + } + }); + + it('URL 에 그대로 넣을 수 있는 글자만 쓰고 짧다 (요청 검증의 1,024자 안 — 가장 긴 메일 id · 가장 큰 시각이어도)', () => { + const cursor = encodeFilterRunCursor( + { + before: MAX_FILTER_RUN_SECONDS, + anchor: 'a'.repeat(255), + anchorTime: MAX_FILTER_RUN_SECONDS - 1, + }, + rule, + ); + expect(cursor).toMatch(/^[A-Za-z0-9_-]+$/); + expect(cursor.length).toBeLessThan(1_024); + }); + + it('다른 규칙의 cursor 는 VAL_001 — 묶는 방법 · 조건 · 할 일 어느 것이 달라도', () => { + const cursor = encodeFilterRunCursor( + { before: NINE, anchor: 'm1', anchorTime: NINE - 1 }, + rule, + ); + const others: FilterRunRule[] = [ + { ...rule, match: 'all' }, + { + ...rule, + conditions: [{ ...rule.conditions[0], value: '@hanbit-partners' }], + }, + { + ...rule, + conditions: [{ ...rule.conditions[0], operator: 'notContains' }], + }, + { ...rule, actions: { ...rule.actions, moveToMailboxId: 'k' } }, + { ...rule, actions: { ...rule.actions, flag: true } }, + ]; + for (const other of others) { + expect(failureOf(() => decodeFilterRunCursor(cursor, other))).toEqual({ + code: 'VAL_001', + reason: 'invalid-filter-run-cursor:other-rule', + }); + } + }); + + it('규칙 객체의 키 순서 · 다른 속성(cursor 등)과는 상관없다 — 같은 규칙이면 같은 지문', () => { + const position = { before: NINE, anchor: 'm1', anchorTime: NINE - 1 }; + const cursor = encodeFilterRunCursor(position, rule); + const reordered: MailFilterRunRequest = { + cursor: 'anything', + actions: { flag: false, markAsRead: true, moveToMailboxId: 'j' }, + conditions: [ + { + value: '@hanbit-partners.co.kr', + operator: 'contains', + field: 'from', + }, + ], + match: 'any', + }; + expect(decodeFilterRunCursor(cursor, reordered)).toEqual(position); + }); + + it('깨진 cursor 는 VAL_001 — base64url · JSON 이 아님 · 판 · 모양(시각의 형식 · 범위 · 차례, 메일 id 형식) · 너무 김', () => { + const r = hashOf( + encodeFilterRunCursor( + { before: NINE, anchor: 'm1', anchorTime: NINE - 1 }, + rule, + ), + ); + const good = { v: 2, b: NINE, a: 'm1', t: NINE - 1, r }; + const cases: [string, string][] = [ + ['%%%', 'not-json'], + [Buffer.from('not json').toString('base64url'), 'not-json'], + // 1판(자리 수로 이어 하던 것)은 받지 않는다 + [forged({ v: 1, a: 'm1', p: 1, r }), 'shape'], + [forged({ ...good, v: 3 }), 'shape'], + // 위 경계 — 없음 · 0 · 음수 · 소수 · 글 · 범위 밖 + [forged({ ...good, b: undefined }), 'shape'], + [forged({ ...good, b: 0, t: null, a: null }), 'shape'], + [forged({ ...good, b: -5 }), 'shape'], + [forged({ ...good, b: NINE + 0.5 }), 'shape'], + [forged({ ...good, b: String(NINE) }), 'shape'], + [forged({ ...good, b: MAX_FILTER_RUN_SECONDS + 1 }), 'shape'], + // 남긴 메일의 받은 시각 — 위 경계와 같거나 뒤 · 음수 · 소수 · 글 · 없음(anchor 가 있는데) + [forged({ ...good, t: NINE }), 'shape'], + [forged({ ...good, t: NINE + 10 }), 'shape'], + [forged({ ...good, t: -1 }), 'shape'], + [forged({ ...good, t: NINE - 0.5 }), 'shape'], + [forged({ ...good, t: String(NINE - 1) }), 'shape'], + [forged({ ...good, t: null }), 'shape'], + [forged({ ...good, t: undefined }), 'shape'], + // 메일 id 형식 + [forged({ ...good, a: 'm/1' }), 'shape'], + [forged({ ...good, a: '' }), 'shape'], + [forged({ ...good, a: 7 }), 'shape'], + [forged({ ...good, a: undefined }), 'shape'], + // 지문 없음 · 글이 아님 + [forged({ ...good, r: undefined }), 'shape'], + [forged({ ...good, r: 1 }), 'shape'], + [forged(null), 'shape'], + [forged([2, NINE]), 'shape'], + [forged('cursor'), 'shape'], + ['a'.repeat(1_025), 'too-long'], + ]; + for (const [cursor, reason] of cases) { + expect(failureOf(() => decodeFilterRunCursor(cursor, rule))).toEqual({ + code: 'VAL_001', + reason: `invalid-filter-run-cursor:${reason}`, + }); + } + // 손으로 만들어도 모양 · 지문이 맞으면 받는다 (자리는 엔진에 묻는 시작점일 뿐 — 위 경계 아래의 받은편지함만 읽는다) + expect(decodeFilterRunCursor(forged(good), rule)).toEqual({ + before: NINE, + anchor: 'm1', + anchorTime: NINE - 1, + }); + expect( + decodeFilterRunCursor(forged({ ...good, a: null, t: null }), rule), + ).toEqual({ before: NINE, anchor: null, anchorTime: null }); + }); +}); diff --git a/backend/src/modules/mail/jmap/filter-run-cursor.ts b/backend/src/modules/mail/jmap/filter-run-cursor.ts new file mode 100644 index 0000000..057c3e1 --- /dev/null +++ b/backend/src/modules/mail/jmap/filter-run-cursor.ts @@ -0,0 +1,133 @@ +import { createHash } from 'crypto'; + +import { BusinessException } from '@/common/errors/business.exception'; +import { + MAIL_ID_PATTERN, + MAX_MAIL_FILTER_RUN_CURSOR_LENGTH, +} from '@/modules/mail/mail.constants'; +import type { MailFilterRunRequest } from '@/modules/mail/mail.types'; + +/** + * 이미 받은 메일에 필터 적용의 이어 할 자리 (계약 2절 "이미 받은 메일에 적용"의 cursor — 클라이언트에게는 불투명 문자열, base64url JSON). + * 엔진의 조회 규칙(2026-10-29 확인)에 기대어 자리를 받은 시각으로 잡는다 — 받은 시각은 초 단위이고, `before` 는 미만(<)이며, + * 받은 시각이 같은 메일은 만든 차례로 나오고 표시를 바꾸거나 옮겼다 되돌려도 차례가 그대로다: + * - before(위 경계): 첫 요청이 본 받은편지함 맨 위 메일의 받은 시각 + 1초 — 모든 조회에 `before` 로 건다. 적용을 시작한 뒤 온 메일은 보지 않는다 + * (새로 온 메일은 Sieve 가 규칙 차례대로 이미 판정했다 — 한 규칙으로 다시 판정하면 위 규칙의 "멈춤"을 건너뛴다) + * - anchor: 지금까지 확인하고 받은편지함에 **남긴**(옮기지 않은) 마지막 메일 — 다음 차례는 그 다음부터(`anchor` + 1) 읽는다 + * - anchorTime: 그 메일의 받은 시각(초) — 그 메일이 그사이 받은편지함에서 빠졌으면(`anchorNotFound`) `before: anchorTime + 1초` 의 처음부터 읽는다 + * (그 초의 메일을 다시 판정한다 — 건너뛰지 않는다). 그렇게 읽은 차례에 남긴 메일이 없으면 anchor 는 없고 anchorTime 만 남는다 + * - 규칙의 지문: 묶는 방법 · 조건 · 할 일 — 다른 규칙의 cursor 를 섞어 쓰지 못하게 (충돌보다는 "다른 규칙" 감지가 목적이라 짧게 둔다) + * 시각은 UTC 의 초(epoch)다 — 해석할 때 범위(0 ~ 9999-12-31)를 본다. 깨졌거나 다른 규칙의 것이면 400 VAL_001 — 엔진에 묻기 전에 본다 + */ +const CURSOR_VERSION = 2; + +/** 규칙 지문의 길이 */ +const RULE_HASH_LENGTH = 16; + +/** cursor 에 담는 시각의 끝 — JMAP UTCDate 의 끝(9999-12-31T23:59:59Z, 초) */ +export const MAX_FILTER_RUN_SECONDS = 253_402_300_799; + +/** 이어 할 자리 */ +export interface FilterRunPosition { + /** 위 경계(초) — 받은 시각이 이보다 앞선 메일만 본다 */ + readonly before: number; + /** 받은편지함에 남긴 마지막 메일 — 없으면 null */ + readonly anchor: string | null; + /** 그 메일(또는 빠진 그 메일)의 받은 시각(초) — 아직 없으면 null. anchor 가 있으면 늘 있다 */ + readonly anchorTime: number | null; +} + +/** 이 cursor 를 만든 규칙 — 묶는 방법 · 조건 · 할 일 */ +export type FilterRunRule = Pick< + MailFilterRunRequest, + 'match' | 'conditions' | 'actions' +>; + +interface EncodedFilterRunCursor { + v: number; + b: number; + a: string | null; + t: number | null; + r: string; +} + +/** 규칙의 지문 — 정해진 차례의 JSON 을 SHA-256 으로 (들어온 객체의 키 순서 · 다른 속성과 상관없다) */ +function ruleHash(rule: FilterRunRule): string { + const canonical = JSON.stringify([ + rule.match, + rule.conditions.map(({ field, operator, value }) => [ + field, + operator, + value, + ]), + [rule.actions.moveToMailboxId, rule.actions.markAsRead, rule.actions.flag], + ]); + return createHash('sha256') + .update(canonical, 'utf8') + .digest('base64url') + .slice(0, RULE_HASH_LENGTH); +} + +function invalidCursor(reason: string): BusinessException { + return new BusinessException('VAL_001', { + context: { reason: `invalid-filter-run-cursor:${reason}` }, + }); +} + +/** cursor 에 담을 수 있는 시각(초)인가 */ +function isSeconds(value: unknown): value is number { + return ( + typeof value === 'number' && + Number.isSafeInteger(value) && + value >= 0 && + value <= MAX_FILTER_RUN_SECONDS + ); +} + +export function encodeFilterRunCursor( + position: FilterRunPosition, + rule: FilterRunRule, +): string { + const payload: EncodedFilterRunCursor = { + v: CURSOR_VERSION, + b: position.before, + a: position.anchor, + t: position.anchorTime, + r: ruleHash(rule), + }; + return Buffer.from(JSON.stringify(payload)).toString('base64url'); +} + +/** cursor 를 해석한다 — 형식 · 범위가 틀리거나 다른 규칙의 것이면 VAL_001 */ +export function decodeFilterRunCursor( + cursor: string, + rule: FilterRunRule, +): FilterRunPosition { + if (cursor.length > MAX_MAIL_FILTER_RUN_CURSOR_LENGTH) { + throw invalidCursor('too-long'); + } + + let parsed: unknown; + try { + parsed = JSON.parse(Buffer.from(cursor, 'base64url').toString('utf8')); + } catch { + throw invalidCursor('not-json'); + } + + const { v, b, a, t, r } = (parsed ?? {}) as Partial; + if (v !== CURSOR_VERSION || !isSeconds(b) || b === 0) { + throw invalidCursor('shape'); + } + if (!(a === null || (typeof a === 'string' && MAIL_ID_PATTERN.test(a)))) { + throw invalidCursor('shape'); + } + // anchor 가 있으면 그 받은 시각도 있다 — 위 경계보다 앞선다 + if (!(t === null || (isSeconds(t) && t < b)) || (a !== null && t === null)) { + throw invalidCursor('shape'); + } + if (typeof r !== 'string') throw invalidCursor('shape'); + + if (r !== ruleHash(rule)) throw invalidCursor('other-rule'); + + return { before: b, anchor: a, anchorTime: t }; +} diff --git a/backend/src/modules/mail/jmap/filter-run-texts.spec.ts b/backend/src/modules/mail/jmap/filter-run-texts.spec.ts new file mode 100644 index 0000000..1b9bce0 --- /dev/null +++ b/backend/src/modules/mail/jmap/filter-run-texts.spec.ts @@ -0,0 +1,1412 @@ +import { judgeFilter } from '@/modules/mail/mail-filter-match'; +import type { MailFilterField } from '@/modules/mail/mail.types'; +import { + bodyTextsOf, + estimatedBodyBytes, + filterRunProperties, + headerTextsOf, + textPartsOf, +} from './filter-run-texts'; +import type { JmapEmail } from './jmap.types'; + +/** 엔진 메일 — 원래 머리글 속성(`header:From:all` 등)이 더 붙는다 */ +type DeliveredEmail = JmapEmail & Record; + +/** 실배달 한 통과 규칙 하나 — 엔진(Sieve)이 새로 받은 그 메일에 내린 판정 */ +interface DeliveredCase { + readonly key: string; + readonly note: string; + readonly field: MailFilterField; + readonly value: string; + /** 엔진의 판정 — 규칙의 옮기기가 일어났는가. null 은 판정 불가(엔진이 스팸으로 분류해 규칙을 돌리지 않았다) */ + readonly engine: boolean | null; + readonly email: DeliveredEmail; +} + +/** + * 2026-10-29 개발 엔진(Stalwart 0.16.27)에 실제로 배달한 시험 메일 20통 — 규칙(견줄 곳 · 찾을 글)마다 엔진이 Sieve 로 판정한 결과와, + * 같은 메일을 JMAP 으로 읽은 응답(원래 머리글 · 부분 · 본문 값). 원래 머리글은 엔진이 준 그대로다(앞의 공백 · encoded-word). + * 받는 쪽 주소는 시험 계정(arasystem.test), 보낸 쪽은 example.com. p7smvs8xqfv 는 시험마다 붙인 표지다 + */ +const DELIVERED: readonly DeliveredCase[] = [ + { + key: 'B1', + note: 'HTML 만 — 보이는 글', + field: 'body', + value: 'VISp7smvs8xqfv', + engine: true, + email: { + id: 'seaaaaer', + mailboxIds: { a: true }, + subject: 'B1 p7smvs8xqfv', + 'header:From:all': [' Probe '], + 'header:To:all': [' '], + 'header:Cc:all': [], + textBody: [{ partId: '0', type: 'text/html' }], + htmlBody: [{ partId: '0', type: 'text/html' }], + attachments: [], + bodyValues: { '0': { value: '

보이는 VISp7smvs8xqfv

' } }, + }, + }, + { + key: 'B2', + note: 'HTML 속성 값은 글이 아니다', + field: 'body', + value: 'ATTRp7smvs8xqfv', + engine: false, + email: { + id: 'siaaaaes', + mailboxIds: { a: true }, + subject: 'B2 p7smvs8xqfv', + 'header:From:all': [' Probe '], + 'header:To:all': [' '], + 'header:Cc:all': [], + textBody: [{ partId: '0', type: 'text/html' }], + htmlBody: [{ partId: '0', type: 'text/html' }], + attachments: [], + bodyValues: { + '0': { value: '

x

y' }, + }, + }, + }, + { + key: 'B3', + note: '

x

', + }, + }, + }, + }, + { + key: 'B4', + note: '문자 참조(&)를 푼다', + field: 'body', + value: 'A&Bp7smvs8xqfv', + engine: true, + email: { + id: 'sqaaaaeu', + mailboxIds: { a: true }, + subject: 'B4 p7smvs8xqfv', + 'header:From:all': [' Probe '], + 'header:To:all': [' '], + 'header:Cc:all': [], + textBody: [{ partId: '0', type: 'text/html' }], + htmlBody: [{ partId: '0', type: 'text/html' }], + attachments: [], + bodyValues: { '0': { value: '

A&Bp7smvs8xqfv

' } }, + }, + }, + { + key: 'B5', + note: '대안 본문 — HTML 쪽에만 있음', + field: 'body', + value: 'HALTp7smvs8xqfv', + engine: true, + email: { + id: 'suaaaaev', + mailboxIds: { a: true }, + subject: 'B5 p7smvs8xqfv', + 'header:From:all': [' Probe '], + 'header:To:all': [' '], + 'header:Cc:all': [], + textBody: [{ partId: '1', type: 'text/plain' }], + htmlBody: [{ partId: '2', type: 'text/html' }], + attachments: [], + bodyValues: { + '1': { value: '평문만' }, + '2': { value: '

HALTp7smvs8xqfv

' }, + }, + }, + }, + { + key: 'B6', + note: '대안 본문 — 텍스트 쪽에만 있음', + field: 'body', + value: 'PALTp7smvs8xqfv', + engine: true, + email: { + id: 'syaaaaew', + mailboxIds: { a: true }, + subject: 'B6 p7smvs8xqfv', + 'header:From:all': [' Probe '], + 'header:To:all': [' '], + 'header:Cc:all': [], + textBody: [{ partId: '1', type: 'text/plain' }], + htmlBody: [{ partId: '2', type: 'text/html' }], + attachments: [], + bodyValues: { + '1': { value: 'PALTp7smvs8xqfv' }, + '2': { value: '

서식만

' }, + }, + }, + }, + { + key: 'B7', + note: '텍스트 첨부(a.txt)도 본다', + field: 'body', + value: 'ATTACHp7smvs8xqfv', + engine: true, + email: { + id: 's2aaaaex', + mailboxIds: { a: true }, + subject: 'B7 p7smvs8xqfv', + 'header:From:all': [' Probe '], + 'header:To:all': [' '], + 'header:Cc:all': [], + textBody: [{ partId: '1', type: 'text/plain' }], + htmlBody: [{ partId: '1', type: 'text/plain' }], + attachments: [{ partId: '2', type: 'text/plain', name: 'a.txt' }], + bodyValues: { + '1': { value: '본문' }, + '2': { value: 'ATTACHp7smvs8xqfv' }, + }, + }, + }, + { + key: 'B8', + note: '주석 안은 뺀다', + field: 'body', + value: 'CMTp7smvs8xqfv', + engine: false, + email: { + id: 'taaaaaey', + mailboxIds: { a: true }, + subject: 'B8 p7smvs8xqfv', + 'header:From:all': [' Probe '], + 'header:To:all': [' '], + 'header:Cc:all': [], + textBody: [{ partId: '0', type: 'text/html' }], + htmlBody: [{ partId: '0', type: 'text/html' }], + attachments: [], + bodyValues: { '0': { value: '

x

' } }, + }, + }, + { + key: 'B9', + note: '

x

" }, + }, + }, + }, + { + key: 'B10', + note: '인라인 텍스트 부분(disposition inline)', + field: 'body', + value: 'INLINEp7smvs8xqfv', + engine: true, + email: { + id: 'tiaaaae7', + mailboxIds: { a: true }, + subject: 'B10 p7smvs8xqfv', + 'header:From:all': [' Probe '], + 'header:To:all': [' '], + 'header:Cc:all': [], + textBody: [ + { partId: '1', type: 'text/plain' }, + { partId: '2', type: 'text/plain' }, + ], + htmlBody: [ + { partId: '1', type: 'text/plain' }, + { partId: '2', type: 'text/plain' }, + ], + attachments: [], + bodyValues: { + '1': { value: '본문' }, + '2': { value: 'INLINEp7smvs8xqfv' }, + }, + }, + }, + { + key: 'B11', + note: 'QP 로 보낸 텍스트 — 엔진이 풀어 준 값', + field: 'body', + value: 'ENCp7smvs8xqfv', + engine: true, + email: { + id: 'tmaaaae9', + mailboxIds: { a: true }, + subject: 'B11 p7smvs8xqfv', + 'header:From:all': [' Probe '], + 'header:To:all': [' '], + 'header:Cc:all': [], + textBody: [{ partId: '0', type: 'text/plain' }], + htmlBody: [{ partId: '0', type: 'text/plain' }], + attachments: [], + bodyValues: { '0': { value: '앞 ENCp7smvs8xqfv 뒤\n\n' } }, + }, + }, + { + key: 'B12', + note: 'EUC-KR 텍스트 — 엔진이 풀어 준 값', + field: 'body', + value: '한글표시 KRp7smvs8xqfv', + engine: true, + email: { + id: 'tqaaaae2', + mailboxIds: { a: true }, + subject: 'B12 p7smvs8xqfv', + 'header:From:all': [' Probe '], + 'header:To:all': [' '], + 'header:Cc:all': [], + textBody: [{ partId: '0', type: 'text/plain' }], + htmlBody: [{ partId: '0', type: 'text/plain' }], + attachments: [], + bodyValues: { '0': { value: '한글표시 KRp7smvs8xqfv' } }, + }, + }, + { + key: 'B13', + note: '  는 U+00A0 — 띄어쓰기로 견주면 맞지 않는다', + field: 'body', + value: '줄 간격p7smvs8xqfv', + engine: false, + email: { + id: 'drmaaaa2l', + mailboxIds: { a: true }, + subject: 'B13 p7smvs8xqfv', + 'header:From:all': [' Probe '], + 'header:To:all': [' '], + 'header:Cc:all': [], + textBody: [{ partId: '0', type: 'text/html' }], + htmlBody: [{ partId: '0', type: 'text/html' }], + attachments: [], + bodyValues: { '0': { value: '

줄 간격p7smvs8xqfv

' } }, + }, + }, + { + key: 'B14', + note: '블록 사이에 띄어쓰기가 생기지 않는다', + field: 'body', + value: '앞 뒤p7smvs8xqfv', + engine: false, + email: { + id: 'xcqaaafyu', + mailboxIds: { a: true }, + subject: 'B14 p7smvs8xqfv', + 'header:From:all': [' Probe '], + 'header:To:all': [' '], + 'header:Cc:all': [], + textBody: [{ partId: '0', type: 'text/html' }], + htmlBody: [{ partId: '0', type: 'text/html' }], + attachments: [], + bodyValues: { '0': { value: '
앞
뒤p7smvs8xqfv
' } }, + }, + }, + { + key: 'H1', + note: '인코딩된 표시 이름 — 엔진은 스팸으로 분류해 규칙을 돌리지 않았다(판정 불가)', + field: 'from', + value: '김아라 '], + 'header:To:all': [' '], + 'header:Cc:all': [], + textBody: [{ partId: '0', type: 'text/plain' }], + htmlBody: [{ partId: '0', type: 'text/plain' }], + attachments: [], + bodyValues: { '0': { value: 'x' } }, + }, + }, + { + key: 'H2', + note: '따옴표 표시 이름 — 따옴표까지 견준다', + field: 'from', + value: '"Kim', + engine: true, + email: { + id: 'xcyaaafyw', + mailboxIds: { a: true }, + subject: 'H2 p7smvs8xqfv', + 'header:From:all': [' "Kim, Ara" '], + 'header:To:all': [' '], + 'header:Cc:all': [], + textBody: [{ partId: '0', type: 'text/plain' }], + htmlBody: [{ partId: '0', type: 'text/plain' }], + attachments: [], + bodyValues: { '0': { value: 'x' } }, + }, + }, + { + key: 'H3', + note: '받는 사람 여럿 — 원래 글 그대로', + field: 'to', + value: 'a1@example.com, a2@', + engine: true, + email: { + id: 'xc2aaafyx', + mailboxIds: { a: true }, + subject: 'H3 p7smvs8xqfv', + 'header:From:all': [' Probe '], + 'header:To:all': [ + ' a1@example.com, a2@example.com, ', + ], + 'header:Cc:all': [], + textBody: [{ partId: '0', type: 'text/plain' }], + htmlBody: [{ partId: '0', type: 'text/plain' }], + attachments: [], + bodyValues: { '0': { value: 'x' } }, + }, + }, + { + key: 'H4', + note: 'UTF-8 로 인코딩된 제목 — 조사가 붙은 낱말', + field: 'subject', + value: '회의', + engine: true, + email: { + id: 'xdaaaafyy', + mailboxIds: { a: true }, + subject: 'H4 주간 회의를 p7smvs8xqfv', + 'header:From:all': [' Probe '], + 'header:To:all': [' '], + 'header:Cc:all': [], + textBody: [{ partId: '0', type: 'text/plain' }], + htmlBody: [{ partId: '0', type: 'text/plain' }], + attachments: [], + bodyValues: { '0': { value: 'x' } }, + }, + }, + { + key: 'H5', + note: 'EUC-KR 로 인코딩된 제목', + field: 'subject', + value: '견적서', + engine: true, + email: { + id: 'xdeaaafyz', + mailboxIds: { a: true }, + subject: 'H5 견적서 요청 p7smvs8xqfv', + 'header:From:all': [' Probe '], + 'header:To:all': [' '], + 'header:Cc:all': [], + textBody: [{ partId: '0', type: 'text/plain' }], + htmlBody: [{ partId: '0', type: 'text/plain' }], + attachments: [], + bodyValues: { '0': { value: 'x' } }, + }, + }, + { + key: 'H6', + note: '인코딩된 이름의 참조(Cc)', + field: 'to', + value: 'p7smvs8xqfvcc@', + engine: true, + email: { + id: 'xdiaaafy7', + mailboxIds: { a: true }, + subject: 'H6 p7smvs8xqfv', + 'header:From:all': [' Probe '], + 'header:To:all': [' '], + 'header:Cc:all': [ + ' =?UTF-8?B?7LC47KGw7J24?= ', + ], + textBody: [{ partId: '0', type: 'text/plain' }], + htmlBody: [{ partId: '0', type: 'text/plain' }], + attachments: [], + bodyValues: { '0': { value: 'x' } }, + }, + }, +]; + +const judge = ( + { field, value }: Pick, + email: JmapEmail, + operator: 'contains' | 'notContains' = 'contains', +) => + judgeFilter( + { match: 'all', conditions: [{ field, operator, value }] }, + { ...headerTextsOf(email), body: bodyTextsOf(email) }, + ); + +describe('이미 받은 메일의 견줄 글 — 엔진(Sieve)과 같은 판정 (계약 2절 "이미 받은 메일에 적용", 실배달 20통)', () => { + const judged = DELIVERED.filter((item) => item.engine !== null); + + it('판정할 수 있는 19통이 모두 있다 (본문 14 · 머리글 5)', () => { + expect(judged).toHaveLength(19); + expect(judged.filter((item) => item.field === 'body')).toHaveLength(14); + }); + + it.each(judged.map((item) => [item.key, item.note, item] as const))( + '%s %s', + (_key, _note, item) => { + expect(judge(item, item.email)).toBe(item.engine); + // 포함하지 않음은 같은 검사의 반대다 (`not header …` · `not body …`) + expect(judge(item, item.email, 'notContains')).toBe(!item.engine); + }, + ); + + it('H1 — 엔진은 스팸으로 분류해 규칙을 돌리지 않았다(판정 불가). 서버는 스팸 표시를 보지 않는다 — 인코딩된 표시 이름을 풀어 맞춘다', () => { + const spam = DELIVERED.find((item) => item.key === 'H1'); + expect(spam?.engine).toBeNull(); + if (!spam) return; + expect(judge(spam, spam.email)).toBe(true); + expect(headerTextsOf(spam.email).from).toEqual([ + '김아라 ', + ]); + }); +}); + +/** HTML 본문 사례 — 본문이 HTML 한 부분뿐인 메일, 규칙은 "본문 포함 " */ +interface HtmlCase { + readonly key: string; + readonly label: string; + readonly html: string; + readonly value: string; + /** 엔진의 판정 — 규칙의 옮기기가 일어났는가 */ + readonly engine: boolean; + /** 엔진과 다른 것을 알고 둔 사례 — 서버는 엔진의 반대로 판정한다 (계약 2절의 알려진 한계) */ + readonly limitation?: true; +} + +/** 머리글 사례 — 보낸 사람 · 받는 사람은 엔진이 주는 원래 머리글(raw), 제목은 엔진이 풀어 준 subject */ +interface HeaderCase { + readonly key: string; + readonly label: string; + readonly field: Exclude; + readonly value: string; + readonly engine: boolean; + readonly raw?: string; + readonly subject?: string; +} + +/** + * HTML 본문 — 2026-10-29 인증 발송(ara → seojun, 스팸 판정 없음) 75통에 엔진이 배달 때 내린 판정(규칙 "본문 포함 <찾을 글>"). + * 사례는 W(10) · X(15) · Y(23) · Z(27) 네 묶음 — 찾을 글은 끝에 키 + 표지(p7mark)가 붙는다. engine 이 기대값이다. + * limitation: 엔진과 다른 것을 알고 둔 사례(계약의 알려진 한계 — HTML5 에만 있는 이름 참조) + */ +const HTML_CASES: readonly HtmlCase[] = [ + { + key: 'W1', + label: '낱말 안 인라인 태그', + html: '

회의록W1p7mark

', + value: '회의록W1p7mark', + engine: true, + }, + { + key: 'W2', + label: '원문 줄바꿈', + html: '

프로젝트\r\n알파W2p7mark

', + value: '프로젝트 알파W2p7mark', + engine: true, + }, + { + key: 'W3', + label: '공백 셋', + html: '

둘 공백W3p7mark

', + value: '둘 공백W3p7mark', + engine: true, + }, + { + key: 'W4', + label: '블록 사이(붙여)', + html: '

앞

뒤W4p7mark

', + value: '앞뒤W4p7mark', + engine: false, + }, + { + key: 'W5', + label: '블록 사이(띄어)', + html: '

앞

뒤W5p7mark

', + value: '앞 뒤W5p7mark', + engine: false, + }, + { + key: 'W6', + label: 'br(붙여)', + html: '
앞
뒤W6p7mark
', + value: '앞뒤W6p7mark', + engine: false, + }, + { + key: 'W7', + label: '표 칸(붙여)', + html: '
앞뒤W7p7mark
', + value: '앞뒤W7p7mark', + engine: true, + }, + { + key: 'W8', + label: '숫자 참조', + html: '

한글W8p7mark

', + value: '한글W8p7mark', + engine: true, + }, + { + key: 'W9', + label: 'head 의 title', + html: '제목글W9p7mark

x

', + value: '제목글W9p7mark', + engine: false, + }, + { + key: 'W10', + label: '인라인 사이 띄어쓰기', + html: '회의 일정W10p7mark', + value: '회의 일정W10p7mark', + engine: true, + }, + { + key: 'X1', + label: '목록 항목 사이', + html: '
  • 앞
  • 뒤X1p7mark
', + value: '앞뒤X1p7mark', + engine: true, + }, + { + key: 'X2', + label: '제목 뒤', + html: '

앞

뒤X2p7mark', + value: '앞뒤X2p7mark', + engine: true, + }, + { + key: 'X3', + label: '표 줄 사이', + html: '
앞
뒤X3p7mark
', + value: '앞뒤X3p7mark', + engine: true, + }, + { + key: 'X4', + label: '인용 뒤', + html: '
앞
뒤X4p7mark', + value: '앞뒤X4p7mark', + engine: true, + }, + { + key: 'X5', + label: 'hr', + html: '앞
뒤X5p7mark', + value: '앞뒤X5p7mark', + engine: true, + }, + { + key: 'X6', + label: 'div 안 div', + html: '
앞
뒤X6p7mark
', + value: '앞뒤X6p7mark', + engine: true, + }, + { + key: 'X7', + label: '닫는 p 만', + html: '앞

뒤X7p7mark', + value: '앞뒤X7p7mark', + engine: false, + }, + { + key: 'X8', + label: '표 칸 사이 띄어', + html: '
앞 뒤X8p7mark
', + value: '앞 뒤X8p7mark', + engine: true, + }, + { + key: 'X9', + label: 'span 사이', + html: '앞뒤X9p7mark', + value: '앞뒤X9p7mark', + engine: true, + }, + { + key: 'X10', + label: 'br/ 닫힌 꼴', + html: '앞
뒤X10p7mark', + value: '앞뒤X10p7mark', + engine: false, + }, + { + key: 'X11', + label: '대문자 P', + html: '

앞

뒤X11p7mark

', + value: '앞뒤X11p7mark', + engine: false, + }, + { + key: 'X12', + label: 'head meta 뒤 body', + html: '본문X12p7mark', + value: '본문X12p7mark', + engine: true, + }, + { + key: 'X13', + label: 'a 링크 글', + html: '
', + value: '링크글X13p7mark', + engine: true, + }, + { + key: 'X14', + label: 'p 뒤 줄바꿈 하나', + html: '

앞

\r\n

뒤X14p7mark

', + value: '앞 뒤X14p7mark', + engine: false, + }, + { + key: 'X15', + label: 'head 밖 title 없음 · body 안 글', + html: '

앞

뒤X15p7mark', + value: '앞뒤X15p7mark', + engine: false, + }, + { + key: 'Y1', + label: '여는 p 만', + html: '앞

뒤Y1p7mark', + value: '앞뒤Y1p7mark', + engine: true, + }, + { + key: 'Y2', + label: '속성 있는 p', + html: '

앞

뒤Y2p7mark

', + value: '앞뒤Y2p7mark', + engine: false, + }, + { + key: 'Y3', + label: 'br 공백 슬래시', + html: '
앞
뒤Y3p7mark
', + value: '앞뒤Y3p7mark', + engine: false, + }, + { + key: 'Y4', + label: 'br 속성', + html: '
앞
뒤Y4p7mark
', + value: '앞뒤Y4p7mark', + engine: true, + }, + { + key: 'Y5', + label: 'style 속성', + html: '

x

', + value: '숨김Y5p7mark', + engine: true, + }, + { + key: 'Y6', + label: 'script 속성', + html: '

x

', + value: '숨김Y6p7mark', + engine: true, + }, + { + key: 'Y7', + label: 'title 속성', + html: '제목Y7p7mark

x

', + value: '제목Y7p7mark', + engine: true, + }, + { + key: 'Y8', + label: '글자 < 뒤 공백', + html: '

가격 < 1000원할인Y8p7mark

', + value: '가격 < 1000원할인Y8p7mark', + engine: false, + }, + { + key: 'Y9', + label: '< 뒤 한글', + html: '

<광고> 안내Y9p7mark

', + value: '<광고> 안내Y9p7mark', + engine: false, + }, + { + key: 'Y10', + label: '따옴표 안 > (뒤 글)', + html: '

a>b사진Y10p7mark

', + value: '사진Y10p7mark', + engine: true, + }, + { + key: 'Y11', + label: '따옴표 안 > (잘린 글)', + html: '

a>b사진Y11p7mark

', + value: 'b">사진Y11p7mark', + engine: true, + }, + { + key: 'Y12', + label: '닫히지 않은 따옴표', + html: '

z

', + value: '회의 안내Y12p7mark', + engine: true, + }, + { + key: 'Y13', + label: '참조 공백 둘(한 칸으로)', + html: '

회의 안내Y13p7mark

', + value: '회의 안내Y13p7mark', + engine: false, + }, + { + key: 'Y14', + label: '참조 공백 둘(두 칸)', + html: '

회의 안내Y14p7mark

', + value: '회의 안내Y14p7mark', + engine: true, + }, + { + key: 'Y15', + label: '참조 줄바꿈', + html: '

회의 안내Y15p7mark

', + value: '회의 안내Y15p7mark', + engine: false, + }, + { + key: 'Y16', + label: '; 없는   (풀림)', + html: '

회의 안내Y16p7mark

', + value: '회의 안내Y16p7mark', + engine: false, + }, + { + key: 'Y17', + label: '; 없는   (그대로)', + html: '

회의 안내Y17p7mark

', + value: '회의 안내Y17p7mark', + engine: true, + }, + { + key: 'Y18', + label: '& 대문자', + html: '

A&B안내Y18p7mark

', + value: 'A&B안내Y18p7mark', + engine: true, + limitation: true, + }, + { + key: 'Y19', + label: 'HTML5 이름 .', + html: '

점.안내Y19p7mark

', + value: '점.안내Y19p7mark', + engine: true, + limitation: true, + }, + { + key: 'Y20', + label: 'pre 는 끊는가', + html: '
앞
뒤Y20p7mark
', + value: '앞뒤Y20p7mark', + engine: true, + }, + { + key: 'Y21', + label: 'noscript 안 글', + html: '

x

', + value: '숨김Y21p7mark', + engine: true, + }, + { + key: 'Y22', + label: 'template 안 글', + html: '

x

', + value: '숨김Y22p7mark', + engine: false, + }, + { + key: 'Y23', + label: 'textarea 안 글', + html: '

x

', + value: '숨김Y23p7mark', + engine: true, + }, + { + key: 'Z1', + label: 'title 만(속성 없음)', + html: '제목Z1p7mark

x

', + value: '제목Z1p7mark', + engine: true, + }, + { + key: 'Z2', + label: 'head 안 글', + html: '머리Z2p7mark

x

', + value: '머리Z2p7mark', + engine: false, + }, + { + key: 'Z3', + label: 'head + title 속성', + html: '제목Z3p7mark

x

', + value: '제목Z3p7mark', + engine: false, + }, + { + key: 'Z4', + label: 'head 속성 + title', + html: '제목Z4p7mark

x

', + value: '제목Z4p7mark', + engine: true, + }, + { + key: 'Z5', + label: 'TEMPLATE 대문자', + html: '

x

', + value: '숨김Z5p7mark', + engine: false, + }, + { + key: 'Z6', + label: 'template 속성', + html: '

x

', + value: '숨김Z6p7mark', + engine: true, + }, + { + key: 'Z7', + label: 'iframe 안 글', + html: '

x

', + value: '숨김Z7p7mark', + engine: true, + }, + { + key: 'Z8', + label: 'object 안 글', + html: '숨김Z8p7mark

x

', + value: '숨김Z8p7mark', + engine: true, + }, + { + key: 'Z9', + label: 'svg 안 글', + html: '숨김Z9p7mark

x

', + value: '숨김Z9p7mark', + engine: true, + }, + { + key: 'Z10', + label: 'xml 안 글', + html: '숨김Z10p7mark

x

', + value: '숨김Z10p7mark', + engine: true, + }, + { + key: 'Z11', + label: 'o:p 닫기', + html: '앞뒤Z11p7mark', + value: '앞뒤Z11p7mark', + engine: true, + }, + { + key: 'Z12', + label: 'style 속성 없음', + html: '

x

', + value: '숨김Z12p7mark', + engine: false, + }, + { + key: 'Z13', + label: 'STYLE 대문자', + html: '

x

', + value: '숨김Z13p7mark', + engine: false, + }, + { + key: 'Z14', + label: ' 로 닫기', + html: '뒤Z14p7mark', + value: '뒤Z14p7mark', + engine: true, + }, + { + key: 'Z15', + label: 'BR 대문자', + html: '
앞
뒤Z15p7mark
', + value: '앞뒤Z15p7mark', + engine: false, + }, + { + key: 'Z16', + label: '
', + html: '
앞
뒤Z16p7mark
', + value: '앞뒤Z16p7mark', + engine: false, + }, + { + key: 'Z17', + label: '

공백', + html: '앞

뒤Z17p7mark', + value: '앞뒤Z17p7mark', + engine: false, + }, + { + key: 'Z18', + label: '

', + html: '앞

뒤Z18p7mark', + value: '앞뒤Z18p7mark', + engine: true, + }, + { + key: 'Z19', + label: 'mso 보이는 조건', + html: '보임Z19p7mark', + value: '보임Z19p7mark', + engine: true, + }, + { + key: 'Z20', + label: 'mso 주석 조건', + html: '', + value: '숨김Z20p7mark', + engine: false, + }, + { + key: 'Z21', + label: 'title 닫힌 뒤 글', + html: 't뒤Z21p7mark', + value: '뒤Z21p7mark', + engine: true, + }, + { + key: 'Z22', + label: 'head 닫힌 뒤 글', + html: '본문Z22p7mark', + value: '본문Z22p7mark', + engine: true, + }, + { + key: 'Z23', + label: 'style 안 >', + html: '뒤Z23p7mark', + value: '뒤Z23p7mark', + engine: true, + }, + { + key: 'Z24', + label: 'script 안

', + html: '앞뒤Z24p7mark', + value: '앞뒤Z24p7mark', + engine: false, + }, + { + key: 'Z25', + label: '공백 · 태그 · 공백', + html: '

회의 안내Z25p7mark

', + value: '회의 안내Z25p7mark', + engine: true, + }, + { + key: 'Z26', + label: '글 공백 + 참조 공백(두 칸)', + html: '

회의 안내Z26p7mark

', + value: '회의 안내Z26p7mark', + engine: true, + }, + { + key: 'Z27', + label: '글 공백 + 참조 공백(한 칸)', + html: '

회의 안내Z27p7mark

', + value: '회의 안내Z27p7mark', + engine: false, + }, +]; + +/** + * 머리글 — 같은 날 원래 글(raw MIME)로 보낸 19통(H · G)에 엔진이 내린 판정. raw 는 엔진이 JMAP 으로 주는 원래 글 그대로(앞에 공백 한 칸 · 접힌 줄). + * 제목(subject)은 엔진이 풀어 준 JMAP subject 를 쓴다 — 서버의 판정과 엔진의 판정이 같았다(H9 · G7) + */ +const HEADER_CASES: readonly HeaderCase[] = [ + { + key: 'H1', + label: '따옴표 안 인코딩 이름', + field: 'from', + value: '김아라', + engine: true, + raw: ' "=?UTF-8?B?6rmA7JWE6528?=" ', + }, + { + key: 'H2', + label: '따옴표 안 인코딩 이름(원래 글)', + field: 'from', + value: '=?UTF-8?B?', + engine: false, + raw: ' "=?UTF-8?B?6rmA7JWE6528?=" ', + }, + { + key: 'H3', + label: '탭으로 접힌 받는 사람', + field: 'to', + value: ', "다라"', + engine: true, + raw: ' "가나" ,\r\n\t"다라" ', + }, + { + key: 'H4', + label: '받는 사람 이어진 공백', + field: 'to', + value: '"가나" <', + engine: false, + raw: ' "가나" ', + }, + { + key: 'H5', + label: '글자에 붙은 인코딩', + field: 'from', + value: 'abc김아라', + engine: false, + raw: ' abc=?UTF-8?B?6rmA7JWE6528?= ', + }, + { + key: 'H6', + label: '주석', + field: 'from', + value: '김아라 부장', + engine: true, + raw: ' ara.kim@arasystem.test (김아라 부장)', + }, + { + key: 'H7', + label: '주석 안 인코딩', + field: 'from', + value: '(부장)', + engine: false, + raw: ' ara.kim@arasystem.test (=?UTF-8?B?67aA7J6l?=)', + }, + { + key: 'H8', + label: '이어진 인코딩 두 낱말', + field: 'from', + value: '김아라', + engine: true, + raw: ' =?UTF-8?B?6rmA?=\r\n =?UTF-8?B?7JWE6528?= ', + }, + { + key: 'H9', + label: '제목의 이어진 인코딩', + field: 'subject', + value: '회의안내', + engine: true, + subject: 'H9 회의안내', + }, + { + key: 'G1', + label: '탭(접지 않음)', + field: 'to', + value: '"가나" <', + engine: false, + raw: ' "가나"\t', + }, + { + key: 'G2', + label: '접힌 줄 공백 넷', + field: 'to', + value: ', "다라"', + engine: true, + raw: ' "가나" ,\r\n "다라" ', + }, + { + key: 'G3', + label: '접힌 줄 공백 하나', + field: 'to', + value: ', "다라"', + engine: true, + raw: ' "가나" ,\r\n "다라" ', + }, + { + key: 'G4', + label: '인코딩 뒤에 붙은 글', + field: 'from', + value: '김아라abc', + engine: false, + raw: ' =?UTF-8?B?6rmA7JWE6528?=abc ', + }, + { + key: 'G5', + label: '괄호 안 띄운 인코딩', + field: 'from', + value: '( 부장 )', + engine: true, + raw: ' ara.kim@arasystem.test ( =?UTF-8?B?67aA7J6l?= )', + }, + { + key: 'G6', + label: '쉼표에 붙은 인코딩', + field: 'to', + value: '다라', + engine: true, + raw: ' "가나" ,=?UTF-8?B?64uk6528?= ', + }, + { + key: 'G7', + label: '제목 글자에 붙은 인코딩', + field: 'subject', + value: 'abc회의', + engine: false, + subject: 'G7 abc=?UTF-8?B?7ZqM7J2Y?=', + }, + { + key: 'G8', + label: '따옴표 안 인코딩 + 글', + field: 'from', + value: '김아라 부장', + engine: true, + raw: ' "=?UTF-8?B?6rmA7JWE6528?= 부장" ', + }, + { + key: 'G9', + label: '꺾쇠에 붙은 인코딩', + field: 'from', + value: '김아라<', + engine: false, + raw: ' =?UTF-8?B?6rmA7JWE6528?=', + }, + { + key: 'G10', + label: '뒤에 쉼표가 붙은 인코딩', + field: 'to', + value: '다라,', + engine: false, + raw: ' , =?UTF-8?B?64uk6528?=,"가나" ', + }, +]; + +/** HTML 한 부분뿐인 메일 — 배달한 그대로 (textBody · htmlBody 가 같은 부분) */ +function htmlOnlyEmail(html: string): JmapEmail { + return { + id: 'm1', + textBody: [{ partId: '1', type: 'text/html' }], + htmlBody: [{ partId: '1', type: 'text/html' }], + attachments: [], + bodyValues: { '1': { value: html } }, + }; +} + +/** 머리글 사례의 메일 — 견주는 머리글 하나만 (받는 사람은 To) */ +function headerEmail({ field, raw, subject }: HeaderCase): DeliveredEmail { + return { + id: 'm1', + 'header:From:all': field === 'from' && raw !== undefined ? [raw] : [], + 'header:To:all': field === 'to' && raw !== undefined ? [raw] : [], + 'header:Cc:all': [], + subject: subject ?? null, + }; +} + +describe('엔진과 같은 판정 — HTML 본문 75통 (W · X · Y · Z, 2026-10-29 인증 발송)', () => { + const known = HTML_CASES.filter((item) => item.limitation !== true); + const limitations = HTML_CASES.filter((item) => item.limitation === true); + + it('네 묶음이 모두 있다 (W 10 · X 15 · Y 23 · Z 27) — 엔진과 다른 것은 HTML5 에만 있는 이름 참조 둘뿐', () => { + const count = (prefix: string) => + HTML_CASES.filter((item) => item.key.startsWith(prefix)).length; + expect(['W', 'X', 'Y', 'Z'].map(count)).toEqual([10, 15, 23, 27]); + expect(limitations.map((item) => item.key)).toEqual(['Y18', 'Y19']); + }); + + it.each(known.map((item) => [item.key, item.label, item] as const))( + '%s %s', + (_key, _label, item) => { + const email = htmlOnlyEmail(item.html); + expect(judge({ field: 'body', value: item.value }, email)).toBe( + item.engine, + ); + expect( + judge({ field: 'body', value: item.value }, email, 'notContains'), + ).toBe(!item.engine); + }, + ); + + it.each(limitations.map((item) => [item.key, item.label, item] as const))( + '%s %s — 알려진 한계: 엔진은 HTML5 의 이름도 풀지만 서버는 HTML 4 표 + ' 만 푼다', + (_key, _label, item) => { + expect( + judge({ field: 'body', value: item.value }, htmlOnlyEmail(item.html)), + ).toBe(!item.engine); + }, + ); +}); + +describe('엔진과 같은 판정 — 머리글 19통 (H · G, 원래 글로 보낸 것)', () => { + it.each(HEADER_CASES.map((item) => [item.key, item.label, item] as const))( + '%s %s', + (_key, _label, item) => { + const email = headerEmail(item); + expect(judge(item, email)).toBe(item.engine); + expect(judge(item, email, 'notContains')).toBe(!item.engine); + }, + ); +}); + +describe('headerTextsOf — 원래 머리글을 엔진이 보는 글로', () => { + const email = (extra: Record): DeliveredEmail => ({ + id: 'm1', + ...extra, + }); + + it('받는 사람은 To · Cc 머리글마다, 같은 이름의 머리글이 여럿이면 모두 — 제목은 엔진이 풀어 준 subject', () => { + expect( + headerTextsOf( + email({ + 'header:From:all': [' =?UTF-8?B?6rmA7JWE6528?= '], + 'header:To:all': [' a@example.com', ' b@example.com'], + 'header:Cc:all': [' =?UTF-8?B?7LC47KGw7J24?= '], + subject: '[견적] 10월', + }), + ), + ).toEqual({ + from: ['김아라 '], + to: ['a@example.com', 'b@example.com', '참조인 '], + subject: ['[견적] 10월'], + }); + }); + + it('머리글이 없으면(빈 배열 · null · 속성 없음) 빈 목록 — 포함하지 않음이 맞는다', () => { + const bare = headerTextsOf( + email({ 'header:From:all': [], 'header:To:all': null, subject: null }), + ); + expect(bare).toEqual({ from: [], to: [], subject: [] }); + expect( + judge( + { field: 'from', value: 'x' }, + email({ 'header:From:all': [] }), + 'notContains', + ), + ).toBe(true); + }); + + it('엔진이 속성 이름의 대소문자 · `:asRaw` 를 바꿔 주어도 찾는다 · 배열이 아니라 글 하나로 주어도 받는다', () => { + expect( + headerTextsOf( + email({ + 'header:from:asRaw:all': [' "Kim" '], + 'HEADER:TO:ALL': ' t@example.com', + }), + ), + ).toEqual({ + from: ['"Kim" '], + to: ['t@example.com'], + subject: [], + }); + }); +}); + +describe('bodyTextsOf · textPartsOf — 글자 부분(text/*)', () => { + const email: JmapEmail = { + id: 'm1', + textBody: [ + { partId: '1', type: 'text/plain' }, + { partId: '3', type: 'image/png' }, + ], + htmlBody: [{ partId: '2', type: 'TEXT/HTML' }], + attachments: [ + { partId: '1', type: 'text/plain' }, + { partId: '4', type: 'text/csv', size: 10 }, + { partId: '5', type: 'application/pdf' }, + { partId: '6', type: 'text/plain' }, + { type: 'text/plain' }, + ], + bodyValues: { + '1': { value: '본문' }, + '2': { value: '

서식 본문

' }, + '4': { value: 'a,b' }, + }, + }; + + it('textBody · htmlBody · attachments 차례로 같은 partId 는 한 번 — 그림 · PDF 는 빼고, HTML 은 글자만, 값이 없는 부분은 뺀다', () => { + expect(textPartsOf(email).map((part) => part.partId)).toEqual([ + '1', + '2', + '4', + '6', + ]); + expect(bodyTextsOf(email)).toEqual(['본문', '서식\u00a0본문\n', 'a,b']); + }); + + it('파일로 붙인 HTML(text/html 첨부)도 본문의 HTML 과 같은 규칙으로 글자만 본다', () => { + expect( + bodyTextsOf({ + id: 'm1', + textBody: [{ partId: '1', type: 'text/plain' }], + htmlBody: [{ partId: '1', type: 'text/plain' }], + attachments: [{ partId: '2', type: 'Text/HTML', name: 'a.html' }], + bodyValues: { + '1': { value: '본문' }, + '2': { + value: + '

회의록

y', + }, + }, + }), + ).toEqual(['본문', '회의록\ny']); + }); + + it('본문 값의 크기 어림 — 글자 부분마다 크기의 두 배를 상한까지, 크기를 모르면 상한', () => { + expect( + estimatedBodyBytes( + { + id: 'm1', + textBody: [{ partId: '1', type: 'text/plain', size: 100 }], + htmlBody: [{ partId: '2', type: 'text/html', size: 900 }], + attachments: [ + { partId: '3', type: 'text/plain' }, + { partId: '4', type: 'image/png', size: 5_000 }, + ], + }, + 1_000, + ), + ).toBe(200 + 1_000 + 1_000); + }); +}); + +describe('filterRunProperties — 규칙이 견주는 것만 읽는다', () => { + it('늘 id · 받은편지함에 있는지 · 표시 · 받은 시각(이어 할 자리), 그리고 견줄 곳마다 원래 머리글(:all) · 제목 · 본문 부분', () => { + expect(filterRunProperties(new Set())).toEqual([ + 'id', + 'mailboxIds', + 'keywords', + 'receivedAt', + ]); + expect( + filterRunProperties(new Set(['body', 'subject', 'to', 'from'] as const)), + ).toEqual([ + 'id', + 'mailboxIds', + 'keywords', + 'receivedAt', + 'header:From:all', + 'header:To:all', + 'header:Cc:all', + 'subject', + 'textBody', + 'htmlBody', + 'attachments', + ]); + }); +}); diff --git a/backend/src/modules/mail/jmap/filter-run-texts.ts b/backend/src/modules/mail/jmap/filter-run-texts.ts new file mode 100644 index 0000000..acf2db8 --- /dev/null +++ b/backend/src/modules/mail/jmap/filter-run-texts.ts @@ -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, +): 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 { + 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(); + 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, + ); +} diff --git a/backend/src/modules/mail/jmap/jmap-filter-run.spec.ts b/backend/src/modules/mail/jmap/jmap-filter-run.spec.ts new file mode 100644 index 0000000..8dfa1d3 --- /dev/null +++ b/backend/src/modules/mail/jmap/jmap-filter-run.spec.ts @@ -0,0 +1,1490 @@ +import { Logger } from '@nestjs/common'; + +import { BusinessException } from '@/common/errors/business.exception'; +import type { + Mailbox, + MailboxRole, + MailFilterCondition, + MailFilterRunActions, + MailFilterRunContext, + MailFilterRunRequest, +} from '@/modules/mail/mail.types'; +import { + decodeFilterRunCursor, + encodeFilterRunCursor, +} from './filter-run-cursor'; +import type { JmapClient } from './jmap.client'; +import { + FILTER_RUN_BODY_VALUE_BYTES, + FILTER_RUN_MESSAGE_BODY_BYTES, + JmapFilterRunner, + bodyValueQuota, +} from './jmap-filter-run'; +import type { JmapInvocation } from './jmap.types'; + +const ARA = 'ara.kim@arasystem.test'; +const INBOX = 'a'; +const TARGET = 'j'; +const TRASH = 't'; + +/** 2026-10-29T09:00:00Z (초) — 받은 시각의 기준 */ +const T0 = 1_793_264_400; + +/** 초 → 엔진이 주는 받은 시각 (UTCDate — 엔진은 초 단위로 둔다) */ +const utc = (seconds: number) => + new Date(seconds * 1_000).toISOString().replace('.000Z', 'Z'); + +const mailbox = ( + id: string, + role: MailboxRole | null, + totalCount = 0, +): Mailbox => ({ + id, + name: id, + role, + parentId: null, + sortOrder: 0, + totalCount, + unreadCount: 0, +}); + +/** 서비스가 읽어 넘긴 메일함 목록 — 받은편지함(a) · 내 메일함(j) · 휴지통(t) */ +const MAILBOXES: readonly Mailbox[] = [ + mailbox(INBOX, 'inbox', 3), + mailbox(TARGET, null), + mailbox(TRASH, 'trash'), +]; + +/** 서비스가 넘기는 것 — 메일함 목록과 들어온 때. 시계를 바꾸는 테스트는 바꾼 뒤에 부른다 */ +const context = ( + overrides: Partial = {}, +): MailFilterRunContext => ({ + mailboxes: MAILBOXES, + startedAt: Date.now(), + ...overrides, +}); + +const fromHanbit: MailFilterCondition = { + field: 'from', + operator: 'contains', + value: '@hanbit-partners.co.kr', +}; +const moveAndRead: MailFilterRunActions = { + moveToMailboxId: TARGET, + markAsRead: true, + flag: false, +}; +const request = ( + overrides: Partial = {}, +): MailFilterRunRequest => ({ + match: 'any', + conditions: [fromHanbit], + actions: moveAndRead, + cursor: null, + ...overrides, +}); + +async function failureOf( + promise: Promise, +): Promise | undefined> { + try { + await promise; + } catch (error) { + if (!(error instanceof BusinessException)) throw error; + return { code: error.code, ...error.context }; + } + return undefined; +} + +describe('JmapFilterRunner — 이미 받은 메일에 필터 적용 (계약 2절 "이미 받은 메일에 적용")', () => { + let warnSpy: jest.SpyInstance; + + beforeEach(() => { + warnSpy = jest + .spyOn(Logger.prototype, 'warn') + .mockImplementation(() => undefined); + }); + // 시각(Date.now)을 바꾸는 테스트가 있다 — 다음 테스트로 넘기지 않는다 + afterEach(() => jest.restoreAllMocks()); + + /** 엔진 요청을 준비한 응답으로 답하는 대역 — 요청의 모양을 본다 */ + describe('엔진에 묻는 모양', () => { + interface CallRecord { + readonly calls: JmapInvocation[]; + readonly options: unknown; + } + + /** JmapClient 대역 — call() 은 넘겨받은 build 로 호출 목록을 만들어 기록하고, 준비한 응답을 차례로 돌려준다 */ + function fakeRunner(responses: JmapInvocation[][]) { + const recorded: CallRecord[] = []; + const queue = [...responses]; + const client = { + call: jest.fn( + ( + _user: string, + build: (accountId: string) => JmapInvocation[], + options?: unknown, + ) => { + recorded.push({ calls: build('d'), options }); + const next = queue.shift(); + if (!next) throw new Error('준비한 응답이 없습니다'); + return Promise.resolve(next); + }, + ), + }; + return { + client, + recorded, + runner: new JmapFilterRunner(client as unknown as JmapClient), + }; + } + + /** n 번째 엔진 호출이 끝난 뒤 시계를 넘긴다 — 한 요청의 시간(25초) 확인용 */ + function advanceClockAfterCall( + fake: ReturnType, + callNumber: number, + ms: number, + ): void { + let now = 1_000_000; + jest.spyOn(Date, 'now').mockImplementation(() => now); + const call = fake.client.call.getMockImplementation(); + if (!call) throw new Error('call 구현이 없다'); + fake.client.call.mockImplementation((...args) => { + const answer = call(...args); + if (fake.recorded.length === callNumber) now += ms; + return answer; + }); + } + + /** n 번째 엔진 호출이 시계를 ms 만큼 넘긴 뒤 error 로 끝난다 — 남은 시간 때문에 끊긴 호출 확인용 */ + function failCallWith( + fake: ReturnType, + callNumber: number, + ms: number, + error: Error, + ): void { + let now = 1_000_000; + jest.spyOn(Date, 'now').mockImplementation(() => now); + const call = fake.client.call.getMockImplementation(); + if (!call) throw new Error('call 구현이 없다'); + let count = 0; + fake.client.call.mockImplementation((...args) => { + count += 1; + if (count === callNumber) { + now += ms; + return Promise.reject(error); + } + return call(...args); + }); + } + + const argsOf = (record: CallRecord, index = 0) => record.calls[index][1]; + const methodsOf = (records: readonly CallRecord[]) => + records.flatMap((record) => record.calls.map(([method]) => method)); + + /** 받은편지함의 메일 한 통 — 보낸 사람(원래 머리글), 읽지 않음, 받은 시각 T0 */ + const mail = ( + id: string, + from = ' Probe ', + extra: Record = {}, + ) => ({ + id, + mailboxIds: { [INBOX]: true }, + keywords: {}, + receivedAt: utc(T0), + 'header:From:all': [from], + ...extra, + }); + const HANBIT = ' "한빛" '; + + /** 한 차례 — 조회(자리 · 전체 수) + 읽기(그때의 상태) */ + const page = ( + ids: string[], + list: Record[], + { + state = 's1', + total = ids.length, + position = 0, + }: { state?: string; total?: number; position?: number } = {}, + ): JmapInvocation[] => [ + ['Email/query', { ids, position, total }, 'q'], + ['Email/get', { state, list, notFound: [] }, 'g'], + ]; + const updated = ( + ids: string[], + notUpdated: Record = {}, + ): JmapInvocation[] => [ + [ + 'Email/set', + { + updated: Object.fromEntries(ids.map((id) => [id, null])), + notUpdated, + }, + 'u', + ], + ]; + const mismatch: JmapInvocation[] = [ + ['error', { type: 'stateMismatch' }, 'u'], + ]; + + const ids = (prefix: string, from: number, count: number) => + Array.from({ length: count }, (_, index) => `${prefix}${from + index}`); + + it('받은편지함을 받은 시각 내림차순으로 읽어 판정하고, 맞은 메일만 한 번에 — 옮기기는 읽은 상태 그대로일 때만(ifInState). 메일함 목록은 서비스가 준 것을 쓴다', async () => { + const { runner, recorded } = fakeRunner([ + page( + ['m1', 'm2', 'm3'], + [ + mail('m1', HANBIT), + mail('m2'), + mail('m3', HANBIT, { keywords: { $seen: true } }), + ], + ), + updated(['m1', 'm3']), + ]); + + await expect(runner.run(ARA, request(), context())).resolves.toEqual({ + scanned: 3, + matched: 2, + remaining: 0, + cursor: null, + rounds: 1, + }); + + // 첫 요청의 첫 조회 — 위 경계를 아직 모른다 (그 차례의 맨 위 메일로 정한다) + expect(argsOf(recorded[0], 0)).toEqual({ + accountId: 'd', + filter: { inMailbox: INBOX }, + sort: [{ property: 'receivedAt', isAscending: false }], + position: 0, + limit: 500, + calculateTotal: true, + }); + // 견주는 머리글만 원래 글로 · 받은 시각(이어 할 자리) — 본문 조건이 없어 부분은 읽지 않는다 + expect(argsOf(recorded[0], 1)).toEqual({ + accountId: 'd', + '#ids': { resultOf: 'q', name: 'Email/query', path: '/ids' }, + properties: [ + 'id', + 'mailboxIds', + 'keywords', + 'receivedAt', + 'header:From:all', + ], + }); + // 이미 읽은 m3 에는 읽음을 다시 붙이지 않는다 — 옮기기만 + expect(argsOf(recorded[1])).toEqual({ + accountId: 'd', + ifInState: 's1', + update: { + m1: { + 'keywords/$seen': true, + 'mailboxIds/a': null, + 'mailboxIds/j': true, + }, + m3: { 'mailboxIds/a': null, 'mailboxIds/j': true }, + }, + }); + // 메일함 목록은 다시 읽지 않는다 · 영구 삭제는 보내지 않는다 · 엔진 호출마다 남은 시간 안의 제한 시간 + expect(methodsOf(recorded)).toEqual([ + 'Email/query', + 'Email/get', + 'Email/set', + ]); + for (const record of recorded) { + for (const [, args] of record.calls) { + expect(args).not.toHaveProperty('destroy'); + } + expect(record.options).toEqual({ + timeoutMs: expect.any(Number) as number, + }); + } + }); + + it('맞는 메일이 없으면 Email/set 을 보내지 않는다 · 빈 받은편지함은 바로 끝 · 받은편지함이 없는 계정은 엔진에 묻지 않는다', async () => { + const none = fakeRunner([page(['m1', 'm2'], [mail('m1'), mail('m2')])]); + await expect(none.runner.run(ARA, request(), context())).resolves.toEqual( + { + scanned: 2, + matched: 0, + remaining: 0, + cursor: null, + rounds: 1, + }, + ); + expect(none.recorded).toHaveLength(1); + + const empty = fakeRunner([page([], [])]); + await expect( + empty.runner.run(ARA, request(), context()), + ).resolves.toEqual({ + scanned: 0, + matched: 0, + remaining: 0, + cursor: null, + rounds: 1, + }); + + const noInbox = fakeRunner([]); + await expect( + noInbox.runner.run( + ARA, + request(), + context({ mailboxes: [mailbox(TARGET, null)] }), + ), + ).resolves.toEqual({ + scanned: 0, + matched: 0, + remaining: 0, + cursor: null, + rounds: 0, + }); + expect(noInbox.recorded).toHaveLength(0); + }); + + it('옮길 메일함이 목록에 없거나(지워짐) 받은편지함이면 옮기지 않고 표시만 — 조건 없이(되돌릴 수 있는 표시), 이미 있는 표시는 다시 붙이지 않는다', async () => { + const flagged = { + moveToMailboxId: 'gonebox', + markAsRead: false, + flag: true, + }; + const gone = fakeRunner([ + page( + ['m1', 'm2'], + [mail('m1'), mail('m2', undefined, { keywords: { $flagged: true } })], + ), + updated(['m1']), + ]); + await expect( + gone.runner.run( + ARA, + request({ match: 'always', conditions: [], actions: flagged }), + context(), + ), + ).resolves.toMatchObject({ scanned: 2, matched: 2, cursor: null }); + expect(argsOf(gone.recorded[1])).toEqual({ + accountId: 'd', + update: { m1: { 'keywords/$flagged': true } }, + }); + + // 받은편지함으로 옮기는 규칙 + 읽음 — 모두 이미 읽었으면 바꿀 것이 없다 (맞은 수는 센다) + const inbox = fakeRunner([ + page( + ['m1', 'm2'], + [ + mail('m1', HANBIT, { keywords: { $seen: true } }), + mail('m2', HANBIT, { keywords: { $seen: true } }), + ], + ), + ]); + await expect( + inbox.runner.run( + ARA, + request({ actions: { ...moveAndRead, moveToMailboxId: INBOX } }), + context(), + ), + ).resolves.toMatchObject({ scanned: 2, matched: 2, cursor: null }); + expect(inbox.recorded).toHaveLength(1); + }); + + it('조회와 읽기 사이에 받은편지함에서 빠진 메일(다른 메일함으로 · 지워짐)은 판정 · 할 일 없이 건너뛰고 scanned 로 세지 않는다', async () => { + const fake = fakeRunner([ + page( + ['m1', 'm2', 'm3', 'm4'], + [ + mail('m1', HANBIT), + mail('m2', HANBIT, { mailboxIds: { b: true } }), + mail('m4', undefined, { receivedAt: utc(T0 - 10) }), + ], + { total: 10 }, + ), + updated(['m1']), + ]); + // 첫 차례를 마친 뒤 21초가 지났다 — 다음 차례를 시작하지 않는다 + advanceClockAfterCall(fake, 2, 21_000); + + const result = await fake.runner.run(ARA, request(), context()); + expect(result).toMatchObject({ scanned: 2, matched: 1, remaining: 6 }); + expect(Object.keys(argsOf(fake.recorded[1]).update as object)).toEqual([ + 'm1', + ]); + // 남긴 마지막 메일은 m4 — 옮긴 m1 · 빠진 m2 · m3 은 자리가 되지 않는다 + expect(decodeFilterRunCursor(result.cursor ?? '', request())).toEqual({ + before: T0 + 1, + anchor: 'm4', + anchorTime: T0 - 10, + }); + }); + + describe('이어 하기 (cursor)', () => { + it('시간을 다 쓰면 마친 차례까지 — cursor 는 위 경계(맨 위 메일의 받은 시각 + 1초) · 남긴 마지막 메일 · 그 받은 시각. 다음 요청은 위 경계 아래에서 그 메일 다음부터(anchor + 1)', async () => { + const first = ids('m', 0, 500); + const matchedIds = first.slice(0, 10); + const fake = fakeRunner([ + page( + first, + first.map((id, index) => + mail(id, matchedIds.includes(id) ? HANBIT : undefined, { + receivedAt: utc(T0 - index), + }), + ), + { total: 600 }, + ), + updated(matchedIds), + ]); + advanceClockAfterCall(fake, 2, 21_000); + + const stopped = await fake.runner.run(ARA, request(), context()); + expect(stopped).toMatchObject({ + scanned: 500, + matched: 10, + remaining: 100, + rounds: 1, + }); + expect(decodeFilterRunCursor(stopped.cursor ?? '', request())).toEqual({ + before: T0 + 1, + anchor: 'm499', + anchorTime: T0 - 499, + }); + + jest.restoreAllMocks(); + jest + .spyOn(Logger.prototype, 'warn') + .mockImplementation(() => undefined); + const rest = ids('m', 500, 100); + const next = fakeRunner([ + page( + rest, + rest.map((id, index) => + mail(id, undefined, { receivedAt: utc(T0 - 500 - index) }), + ), + { total: 590, position: 490 }, + ), + ]); + await expect( + next.runner.run(ARA, request({ cursor: stopped.cursor }), context()), + ).resolves.toEqual({ + scanned: 100, + matched: 0, + remaining: 0, + cursor: null, + rounds: 1, + }); + expect(argsOf(next.recorded[0], 0)).toEqual({ + accountId: 'd', + filter: { inMailbox: INBOX, before: utc(T0 + 1) }, + sort: [{ property: 'receivedAt', isAscending: false }], + anchor: 'm499', + anchorOffset: 1, + limit: 500, + calculateTotal: true, + }); + // anchor 는 cursor 에서 왔다 — 엔진이 요청을 거부하면 깨진 cursor 와 같게 VAL_001 + expect(next.recorded[0].options).toMatchObject({ + invalidRequestCode: 'VAL_001', + }); + }); + + it('같은 요청의 다음 차례도 첫 차례가 정한 위 경계를 건다', async () => { + const first = ids('m', 0, 500); + const rest = ids('m', 500, 200); + const fake = fakeRunner([ + page( + first, + first.map((id, index) => + mail(id, undefined, { receivedAt: utc(T0 - index) }), + ), + { total: 700 }, + ), + page( + rest, + rest.map((id) => + mail(id, undefined, { receivedAt: utc(T0 - 600) }), + ), + { total: 700, position: 500 }, + ), + ]); + await expect( + fake.runner.run(ARA, request(), context()), + ).resolves.toMatchObject({ scanned: 700, cursor: null, rounds: 2 }); + expect(argsOf(fake.recorded[1], 0)).toMatchObject({ + filter: { inMailbox: INBOX, before: utc(T0 + 1) }, + anchor: 'm499', + anchorOffset: 1, + }); + }); + + it('남긴 마지막 메일이 그사이 받은편지함에서 빠졌으면(anchorNotFound) 그 받은 시각 + 1초 미만의 처음(자리 0)부터 다시 읽는다 — 그 초의 메일을 다시 판정해도 건너뛰지 않는다', async () => { + const cursor = encodeFilterRunCursor( + { before: T0 + 1, anchor: 'm499', anchorTime: T0 - 499 }, + request(), + ); + const rest = ids('m', 500, 3); + const fake = fakeRunner([ + [ + ['error', { type: 'anchorNotFound' }, 'q'], + ['error', { type: 'invalidResultReference' }, 'g'], + ], + page( + rest, + rest.map((id) => + mail(id, undefined, { receivedAt: utc(T0 - 499) }), + ), + ), + ]); + + await expect( + fake.runner.run(ARA, request({ cursor }), context()), + ).resolves.toMatchObject({ scanned: 3, cursor: null }); + expect(argsOf(fake.recorded[0], 0)).toMatchObject({ + filter: { inMailbox: INBOX, before: utc(T0 + 1) }, + anchor: 'm499', + }); + expect(argsOf(fake.recorded[1], 0)).toEqual({ + accountId: 'd', + filter: { inMailbox: INBOX, before: utc(T0 - 498) }, + sort: [{ property: 'receivedAt', isAscending: false }], + position: 0, + limit: 500, + calculateTotal: true, + }); + expect(fake.recorded[1].options).toEqual({ + timeoutMs: expect.any(Number) as number, + }); + }); + + it('남긴 메일이 아직 없는 cursor — 받은 시각이 남았으면 그 시각 + 1초 미만의, 없으면 위 경계 아래의 처음부터 읽는다 (앞서 본 메일은 모두 옮겨 나갔다)', async () => { + for (const [anchorTime, before] of [ + [T0 - 100, T0 - 99], + [null, T0 + 1], + ] as const) { + const cursor = encodeFilterRunCursor( + { before: T0 + 1, anchor: null, anchorTime }, + request(), + ); + const fake = fakeRunner([page([], [])]); + await expect( + fake.runner.run(ARA, request({ cursor }), context()), + ).resolves.toMatchObject({ scanned: 0, cursor: null }); + expect(argsOf(fake.recorded[0], 0)).toMatchObject({ + filter: { inMailbox: INBOX, before: utc(before) }, + position: 0, + }); + expect(argsOf(fake.recorded[0], 0)).not.toHaveProperty('anchor'); + } + }); + + it('다른 규칙의 cursor · 깨진 cursor 는 엔진에 묻지 않고 VAL_001', async () => { + const cursor = encodeFilterRunCursor( + { before: T0 + 1, anchor: 'm1', anchorTime: T0 }, + request(), + ); + for (const bad of [ + request({ cursor, actions: { ...moveAndRead, flag: true } }), + request({ cursor: 'eyJ2IjoxfQ' }), + ]) { + const fake = fakeRunner([]); + expect( + await failureOf(fake.runner.run(ARA, bad, context())), + ).toMatchObject({ code: 'VAL_001' }); + expect(fake.recorded).toHaveLength(0); + } + }); + + it('엔진이 받은 시각을 주지 않으면 이어 할 자리를 정할 수 없다 — MAIL_001 (끝까지 했으면 cursor 가 필요 없어 그대로 답한다)', async () => { + const noTime = (id: string) => + mail(id, undefined, { receivedAt: null }); + const stopped = fakeRunner([ + page(['m1', 'm2'], [noTime('m1'), noTime('m2')], { total: 5 }), + ]); + advanceClockAfterCall(stopped, 1, 21_000); + expect( + await failureOf(stopped.runner.run(ARA, request(), context())), + ).toMatchObject({ code: 'MAIL_001', reason: 'received-at-missing' }); + + jest.restoreAllMocks(); + const whole = fakeRunner([ + page(['m1', 'm2'], [noTime('m1'), noTime('m2')]), + ]); + await expect( + whole.runner.run(ARA, request(), context()), + ).resolves.toMatchObject({ scanned: 2, cursor: null }); + }); + }); + + describe('본문 조건', () => { + const quoteMail = ( + id: string, + from: string, + size = 40, + ): Record => + mail(id, from, { + textBody: [{ partId: '1', type: 'text/plain', size }], + htmlBody: [{ partId: '2', type: 'text/html', size }], + attachments: [], + }); + const bodies = (list: Record[]): JmapInvocation[] => [ + ['Email/get', { state: 's1', list }, 'b'], + ]; + const bodyOf = (id: string, html: string) => ({ + id, + mailboxIds: { [INBOX]: true }, + textBody: [{ partId: '1', type: 'text/plain' }], + htmlBody: [{ partId: '2', type: 'text/html' }], + attachments: [], + bodyValues: { '1': { value: '안녕하세요' }, '2': { value: html } }, + }); + const hasQuote: MailFilterCondition = { + field: 'body', + operator: 'contains', + value: '견적서', + }; + const textParts = (count: number, size: number) => + Array.from({ length: count }, (_, index) => ({ + partId: `${index + 1}`, + type: 'text/plain', + size, + })); + + it('한 부분은 256KiB · 한 통은 모두 합쳐 1MiB 까지 — 글자 부분이 많으면 부분마다 같은 몫(1MiB ÷ 부분 수)', () => { + expect(FILTER_RUN_BODY_VALUE_BYTES).toBe(262_144); + expect(FILTER_RUN_MESSAGE_BODY_BYTES).toBe(1_048_576); + expect( + [0, 1, 4, 5, 6, 300, 1_048_576, 2_000_000].map(bodyValueQuota), + ).toEqual([262_144, 262_144, 262_144, 209_715, 174_762, 3_495, 1, 1]); + }); + + it('한 차례 100통 · 부분을 함께 읽고, 머리글로 판정이 나지 않은 메일만 본문 값을 읽는다 (fetchAllBodyValues · 한 부분 256KiB)', async () => { + const fake = fakeRunner([ + page( + ['m1', 'm2', 'm3'], + [ + quoteMail('m1', HANBIT), + quoteMail('m2', ' x@example.com'), + quoteMail('m3', HANBIT), + ], + ), + bodies([ + bodyOf('m1', '

10월 견적서를 보냅니다

'), + bodyOf('m3', '

회의 안내

'), + ]), + updated(['m1']), + ]); + + await expect( + fake.runner.run( + ARA, + request({ match: 'all', conditions: [fromHanbit, hasQuote] }), + context(), + ), + ).resolves.toMatchObject({ scanned: 3, matched: 1, cursor: null }); + + expect(argsOf(fake.recorded[0], 0)).toMatchObject({ limit: 100 }); + expect(argsOf(fake.recorded[0], 1)).toMatchObject({ + properties: [ + 'id', + 'mailboxIds', + 'keywords', + 'receivedAt', + 'header:From:all', + 'textBody', + 'htmlBody', + 'attachments', + ], + bodyProperties: ['partId', 'type', 'size'], + }); + // m2 는 보낸 사람에서 이미 틀렸다 — 본문을 읽지 않는다 + expect(argsOf(fake.recorded[1])).toEqual({ + accountId: 'd', + ids: ['m1', 'm3'], + properties: [ + 'id', + 'mailboxIds', + 'textBody', + 'htmlBody', + 'attachments', + 'bodyValues', + ], + bodyProperties: ['partId', 'type'], + fetchAllBodyValues: true, + maxBodyValueBytes: FILTER_RUN_BODY_VALUE_BYTES, + }); + expect(Object.keys(argsOf(fake.recorded[2]).update as object)).toEqual([ + 'm1', + ]); + }); + + it('하나라도(any) — 머리글이 맞은 메일은 본문을 읽지 않는다 · 본문을 읽는 사이 받은편지함에서 빠진 메일은 건너뛰고 세지 않는다', async () => { + const fake = fakeRunner([ + page( + ['m1', 'm2', 'm3'], + [ + quoteMail('m1', HANBIT), + quoteMail('m2', ' x@example.com'), + quoteMail('m3', ' y@example.com'), + ], + ), + bodies([ + bodyOf('m2', '

견적서 송부

'), + { ...bodyOf('m3', '

견적서

'), mailboxIds: { b: true } }, + ]), + updated(['m1', 'm2']), + ]); + + await expect( + fake.runner.run( + ARA, + request({ conditions: [fromHanbit, hasQuote] }), + context(), + ), + ).resolves.toMatchObject({ scanned: 2, matched: 2 }); + expect(argsOf(fake.recorded[1])).toMatchObject({ ids: ['m2', 'm3'] }); + expect(Object.keys(argsOf(fake.recorded[2]).update as object)).toEqual([ + 'm1', + 'm2', + ]); + }); + + it('글자 부분 300개짜리 한 통 — 부분마다 1MiB ÷ 300 = 3,495바이트로 읽어 판정하고, 몫이 다른 메일은 따로 읽는다', async () => { + const parts = textParts(300, 10_000); + const fake = fakeRunner([ + page( + ['m1', 'm2'], + [ + mail('m1', HANBIT, { + textBody: [], + htmlBody: [], + attachments: parts, + }), + quoteMail('m2', HANBIT), + ], + ), + bodies([ + { + id: 'm1', + mailboxIds: { [INBOX]: true }, + textBody: [], + htmlBody: [], + attachments: parts.map(({ partId, type }) => ({ partId, type })), + // 찾을 글은 마지막 부분에만 있다 — 300개 모두 견준다 + bodyValues: Object.fromEntries( + parts.map(({ partId }) => [ + partId, + { value: partId === '300' ? '10월 견적서' : '안내' }, + ]), + ), + }, + ]), + bodies([bodyOf('m2', '

회의 안내

')]), + updated(['m1']), + ]); + + await expect( + fake.runner.run( + ARA, + request({ match: 'all', conditions: [hasQuote] }), + context(), + ), + ).resolves.toMatchObject({ scanned: 2, matched: 1 }); + expect(argsOf(fake.recorded[1])).toMatchObject({ + ids: ['m1'], + fetchAllBodyValues: true, + maxBodyValueBytes: 3_495, + }); + expect(Math.floor(FILTER_RUN_MESSAGE_BODY_BYTES / 300)).toBe(3_495); + expect(argsOf(fake.recorded[2])).toMatchObject({ + ids: ['m2'], + maxBodyValueBytes: FILTER_RUN_BODY_VALUE_BYTES, + }); + expect(Object.keys(argsOf(fake.recorded[3]).update as object)).toEqual([ + 'm1', + ]); + }); + + it('본문 값은 어림한 크기(부분마다 크기의 두 배를 몫까지)로 나눠 읽는다 — 한 번에 4MB 안쪽', async () => { + // 1MB 부분 6개 — 몫은 1MiB ÷ 6 = 174,762바이트, 한 통의 어림은 1,048,572바이트 → 한 번에 세 통 + const big = (id: string) => + mail(id, HANBIT, { + textBody: [], + htmlBody: [], + attachments: textParts(6, 1_000_000), + }); + const list = ['m1', 'm2', 'm3', 'm4', 'm5']; + const fake = fakeRunner([ + page(list, list.map(big)), + bodies(['m1', 'm2', 'm3'].map((id) => bodyOf(id, ''))), + bodies([bodyOf('m4', ''), bodyOf('m5', '

견적서

')]), + updated(['m5']), + ]); + + await expect( + fake.runner.run( + ARA, + request({ match: 'all', conditions: [hasQuote] }), + context(), + ), + ).resolves.toMatchObject({ scanned: 5, matched: 1 }); + expect(argsOf(fake.recorded[1])).toMatchObject({ + ids: ['m1', 'm2', 'm3'], + maxBodyValueBytes: 174_762, + }); + expect(argsOf(fake.recorded[2])).toMatchObject({ + ids: ['m4', 'm5'], + maxBodyValueBytes: 174_762, + }); + }); + }); + + describe('상태 · 거절 · 시간 (BulkRun — 메일함의 오래 걸리는 처리와 같은 규칙)', () => { + it('옮기는 사이 상태가 어긋나면 맞은 메일의 메일함을 다시 읽고, 아직 받은편지함에 있는 것만 그 상태로 다시 옮긴다', async () => { + const fake = fakeRunner([ + page(['m1', 'm2'], [mail('m1', HANBIT), mail('m2', HANBIT)]), + mismatch, + [ + [ + 'Email/get', + { + state: 's2', + list: [ + { id: 'm1', mailboxIds: { [INBOX]: true } }, + { id: 'm2', mailboxIds: { b: true } }, + ], + }, + 'g', + ], + ], + updated(['m1']), + ]); + + await expect( + fake.runner.run(ARA, request(), context()), + ).resolves.toMatchObject({ scanned: 2, matched: 1, cursor: null }); + expect(argsOf(fake.recorded[2])).toEqual({ + accountId: 'd', + ids: ['m1', 'm2'], + properties: ['id', 'mailboxIds'], + }); + expect(argsOf(fake.recorded[3])).toMatchObject({ + ifInState: 's2', + update: { m1: expect.any(Object) as object }, + }); + expect(Object.keys(argsOf(fake.recorded[3]).update as object)).toEqual([ + 'm1', + ]); + }); + + it('이어서 세 번 어긋나면 멈춘다 — 확인한 것이 없으면 MAIL_001 (옮기지 않았다)', async () => { + const reread: JmapInvocation[] = [ + [ + 'Email/get', + { + state: 's2', + list: [{ id: 'm1', mailboxIds: { [INBOX]: true } }], + }, + 'g', + ], + ]; + const fake = fakeRunner([ + page(['m1'], [mail('m1', HANBIT)]), + mismatch, + reread, + mismatch, + reread, + mismatch, + ]); + + expect( + await failureOf(fake.runner.run(ARA, request(), context())), + ).toMatchObject({ + code: 'MAIL_001', + reason: 'filter-run-no-progress', + stalled: 'state-mismatch', + }); + expect(fake.recorded).toHaveLength(6); + }); + + it('한 요청에서 판정한 것이 하나도 없으면(읽을 때마다 모두 그사이 빠짐) 진행 없음 — 세 차례 뒤 MAIL_001(503)', async () => { + const vanished = () => + page(['m1'], [mail('m1', HANBIT, { mailboxIds: { b: true } })], { + total: 5, + }); + const fake = fakeRunner([vanished(), vanished(), vanished()]); + expect( + await failureOf(fake.runner.run(ARA, request(), context())), + ).toMatchObject({ + code: 'MAIL_001', + reason: 'filter-run-no-progress', + stalled: 'nothing-judged', + }); + expect(fake.recorded).toHaveLength(3); + expect(methodsOf(fake.recorded)).not.toContain('Email/set'); + }); + + it('상태를 모르면 조건 없이 옮기지 않는다 (MAIL_001)', async () => { + const fake = fakeRunner([ + [ + ['Email/query', { ids: ['m1'], position: 0, total: 1 }, 'q'], + ['Email/get', { list: [mail('m1', HANBIT)] }, 'g'], + ], + ]); + expect( + await failureOf(fake.runner.run(ARA, request(), context())), + ).toMatchObject({ code: 'MAIL_001', reason: 'inbox-state-unknown' }); + }); + + it('엔진이 차례의 할 일을 모두 거절하면 — 확인한 것이 없으면 그 거절의 코드, 그사이 지워진 메일(notFound)은 거절로 보지 않는다', async () => { + const refused = fakeRunner([ + page(['m1'], [mail('m1', HANBIT)]), + updated([], { m1: { type: 'forbidden' } }), + ]); + expect( + await failureOf(refused.runner.run(ARA, request(), context())), + ).toMatchObject({ code: 'AUTH_003' }); + expect(warnSpy).toHaveBeenCalledWith( + expect.stringContaining('jmapType=forbidden'), + ); + + const vanished = fakeRunner([ + page(['m1'], [mail('m1', HANBIT)]), + updated([], { m1: { type: 'notFound' } }), + ]); + await expect( + vanished.runner.run(ARA, request(), context()), + ).resolves.toMatchObject({ scanned: 1, matched: 0, cursor: null }); + }); + + it('시간은 서비스에 들어온 때(context.startedAt — 옮길 메일함 확인 포함)부터 센다 — 21초 전에 들어왔으면 차례를 시작하지 않고 MAIL_001 (엔진에 묻지 않았다)', async () => { + jest.spyOn(Date, 'now').mockReturnValue(1_000_000); + const fake = fakeRunner([]); + expect( + await failureOf( + fake.runner.run( + ARA, + request(), + context({ startedAt: 1_000_000 - 21_000 }), + ), + ), + ).toMatchObject({ code: 'MAIL_001', reason: 'filter-run-no-progress' }); + expect(fake.recorded).toHaveLength(0); + }); + + it('남은 시간 때문에 끊긴 엔진 호출 — 확인한 차례가 있으면 거기까지로 답하고(끊긴 차례는 다음 요청이 다시 한다), 없으면 MAIL_001 · 시간이 남았는데 끊긴 것은 엔진 장애 그대로', async () => { + const timeout = new BusinessException('MAIL_001', { + context: { reason: 'timeout' }, + }); + const firstRound = (): JmapInvocation[][] => [ + page( + ['m1', 'm2'], + [ + mail('m1', HANBIT), + mail('m2', undefined, { receivedAt: utc(T0 - 1) }), + ], + { total: 5 }, + ), + updated(['m1']), + ]; + + const cut = fakeRunner(firstRound()); + failCallWith(cut, 3, 30_000, timeout); + const result = await cut.runner.run(ARA, request(), context()); + expect(result).toMatchObject({ scanned: 2, matched: 1, remaining: 3 }); + expect(decodeFilterRunCursor(result.cursor ?? '', request())).toEqual({ + before: T0 + 1, + anchor: 'm2', + anchorTime: T0 - 1, + }); + + jest.restoreAllMocks(); + const early = fakeRunner([]); + failCallWith(early, 1, 30_000, timeout); + expect( + await failureOf(early.runner.run(ARA, request(), context())), + ).toMatchObject({ code: 'MAIL_001' }); + + jest.restoreAllMocks(); + const slow = fakeRunner(firstRound()); + failCallWith(slow, 3, 0, timeout); + expect( + await failureOf(slow.runner.run(ARA, request(), context())), + ).toMatchObject({ code: 'MAIL_001', reason: 'timeout' }); + }); + }); + }); + + /** + * 상태가 있는 가짜 엔진으로 여러 요청을 끝까지 — 요청 사이 · 요청 도중에 받은편지함이 바뀌어도 맞는 메일을 남기지 않는지, 새 메일을 건드리지 않는지 본다. + * 가짜 엔진의 조회 규칙은 2026-10-29 개발 엔진에서 확인한 것이다(phase7 tie-order): 받은 시각 내림차순 · 같은 시각은 만든 차례 + * (표시를 바꾸거나 옮겼다 되돌려도 그대로) · `before` 는 미만 · 받은 시각은 초 · anchor 가 없으면 anchorNotFound · ifInState 가 다르면 stateMismatch + */ + describe('여러 요청에 걸쳐 (상태가 있는 가짜 엔진)', () => { + const HANBIT_FROM = '"한빛" '; + const OTHER_FROM = 'Probe '; + + /** 가짜 엔진의 메일 — 받은 시각은 초, doc 은 만든 차례 */ + interface SimEmail { + readonly id: string; + readonly doc: number; + readonly receivedAt: number; + readonly from: string; + readonly mailboxes: Set; + readonly keywords: Set; + } + + interface SimQuery { + readonly filter: { readonly inMailbox: string; readonly before?: string }; + readonly anchor?: string; + readonly anchorOffset?: number; + readonly position?: number; + readonly limit: number; + } + + class FakeEngine { + readonly emails = new Map(); + /** 받은 조회 — 위 경계가 걸렸는지 본다 */ + readonly queries: SimQuery[] = []; + /** 엔진 요청 하나를 처리하기 전에 — 요청 도중에 일어나는 일(새 메일 · 다른 탭의 옮기기) */ + readonly beforeCall: ((methods: readonly string[]) => void)[] = []; + private state = 1; + private nextDoc = 0; + + add(receivedAt: number, from: string): SimEmail { + const doc = this.nextDoc; + this.nextDoc += 1; + const email: SimEmail = { + id: `e${doc}`, + doc, + receivedAt, + from, + mailboxes: new Set([INBOX]), + keywords: new Set(), + }; + this.emails.set(email.id, email); + this.state += 1; + return email; + } + + /** 사용자가 옮긴다 (다른 탭 · 다른 메일 프로그램) */ + move(id: string, from: string, to: string): void { + const email = this.emails.get(id); + if (!email) return; + email.mailboxes.delete(from); + email.mailboxes.add(to); + this.state += 1; + } + + /** 그 메일함의 메일 — 받은 시각 내림차순, 같은 시각은 만든 차례 (before 는 미만) */ + sorted(mailboxId: string, before = Number.POSITIVE_INFINITY): SimEmail[] { + return [...this.emails.values()] + .filter( + (email) => + email.mailboxes.has(mailboxId) && email.receivedAt < before, + ) + .sort((x, y) => y.receivedAt - x.receivedAt || x.doc - y.doc); + } + + handle(calls: readonly JmapInvocation[]): JmapInvocation[] { + for (const hook of this.beforeCall) hook(calls.map(([name]) => name)); + const responses: JmapInvocation[] = []; + const queried = new Map(); + for (const [method, args, callId] of calls) { + if (method === 'Email/query') { + const query = args as unknown as SimQuery; + this.queries.push(query); + const before = + query.filter.before === undefined + ? Number.POSITIVE_INFINITY + : Date.parse(query.filter.before) / 1_000; + const list = this.sorted(query.filter.inMailbox, before); + let start = query.position ?? 0; + if (query.anchor !== undefined) { + const index = list.findIndex(({ id }) => id === query.anchor); + if (index === -1) { + responses.push(['error', { type: 'anchorNotFound' }, callId]); + continue; + } + start = Math.max(0, index + (query.anchorOffset ?? 0)); + } + const ids = list + .slice(start, start + query.limit) + .map(({ id }) => id); + queried.set(callId, ids); + responses.push([ + 'Email/query', + { ids, position: start, total: list.length }, + callId, + ]); + } else if (method === 'Email/get') { + const reference = args['#ids'] as { resultOf: string } | undefined; + const ids = reference + ? queried.get(reference.resultOf) + : (args.ids as string[]); + if (!ids) { + responses.push([ + 'error', + { type: 'invalidResultReference' }, + callId, + ]); + continue; + } + responses.push([ + 'Email/get', + { + state: `s${this.state}`, + list: ids.flatMap((id) => { + const email = this.emails.get(id); + return email ? [this.view(email)] : []; + }), + notFound: ids.filter((id) => !this.emails.has(id)), + }, + callId, + ]); + } else if (method === 'Email/set') { + expect(args).not.toHaveProperty('destroy'); + if ( + args.ifInState !== undefined && + args.ifInState !== `s${this.state}` + ) { + responses.push(['error', { type: 'stateMismatch' }, callId]); + continue; + } + responses.push([ + 'Email/set', + this.update( + args.update as Record>, + ), + callId, + ]); + } else { + throw new Error(`가짜 엔진이 모르는 메서드: ${method}`); + } + } + return responses; + } + + private view(email: SimEmail): Record { + return { + id: email.id, + mailboxIds: Object.fromEntries( + [...email.mailboxes].map((id) => [id, true]), + ), + keywords: Object.fromEntries( + [...email.keywords].map((keyword) => [keyword, true]), + ), + receivedAt: utc(email.receivedAt), + 'header:From:all': [` ${email.from}`], + }; + } + + private update( + patches: Record>, + ): Record { + const done: Record = {}; + const notUpdated: Record = {}; + for (const [id, patch] of Object.entries(patches)) { + const email = this.emails.get(id); + if (!email) { + notUpdated[id] = { type: 'notFound' }; + continue; + } + for (const [path, value] of Object.entries(patch)) { + const [kind, key] = path.split('/'); + const target = + kind === 'keywords' ? email.keywords : email.mailboxes; + if (value === true) target.add(key); + else target.delete(key); + } + done[id] = null; + } + if (Object.keys(done).length > 0) this.state += 1; + return { updated: done, notUpdated }; + } + } + + interface RunOptions { + /** 한 요청에 하는 차례 수 — 조회마다 시계를 그만큼 넘긴다 */ + readonly roundsPerRequest?: number; + /** 요청 사이에 — 사용자의 옮기기 · 새 메일 */ + readonly between?: (cursor: string) => void; + } + + /** cursor 가 null 이 될 때까지 같은 본문으로 다시 보낸다 (클라이언트처럼) — 요청마다 서비스처럼 메일함 목록 · 들어온 때를 넘긴다 */ + async function runAll( + engine: FakeEngine, + rule: MailFilterRunRequest, + { roundsPerRequest = 1, between }: RunOptions = {}, + ) { + let now = 1_000_000; + jest.spyOn(Date, 'now').mockImplementation(() => now); + const client = { + call: jest.fn( + (_user: string, build: (accountId: string) => JmapInvocation[]) => { + const calls = build('d'); + if (calls[0][0] === 'Email/query') { + now += Math.floor(21_000 / roundsPerRequest); + } + return Promise.resolve(engine.handle(calls)); + }, + ), + }; + const runner = new JmapFilterRunner(client as unknown as JmapClient); + let cursor: string | null = null; + let requests = 0; + let scanned = 0; + let matched = 0; + do { + if (cursor !== null) between?.(cursor); + const mailboxes = [ + mailbox(INBOX, 'inbox', engine.sorted(INBOX).length), + mailbox(TARGET, null), + mailbox(TRASH, 'trash'), + ]; + const result = await runner.run( + ARA, + { ...rule, cursor }, + { mailboxes, startedAt: now }, + ); + scanned += result.scanned; + matched += result.matched; + cursor = result.cursor; + requests += 1; + now += 100_000; + } while (cursor !== null && requests < 100); + return { requests, scanned, matched, done: cursor === null }; + } + + const moveRule = request(); + const flagRule = request({ + actions: { moveToMailboxId: null, markAsRead: false, flag: true }, + }); + + /** 받은편지함에 메일 count 통 — 7통에 한 통이 맞는 메일(한빛), 받은 시각은 timeOf(차례) */ + function fill( + engine: FakeEngine, + count: number, + timeOf: (index: number) => number = (index) => T0 + index, + ): void { + for (let index = 0; index < count; index += 1) { + engine.add(timeOf(index), index % 7 === 0 ? HANBIT_FROM : OTHER_FROM); + } + } + + /** 할 일을 하지 않은 채 받은편지함에 남은 맞는 메일 — 옮기기면 받은편지함에 남은 것, 표시만이면 중요 표시가 없는 것 */ + function missed( + engine: FakeEngine, + rule: MailFilterRunRequest, + among: ReadonlySet = new Set(engine.emails.keys()), + ): string[] { + return engine + .sorted(INBOX) + .filter( + (email) => + among.has(email.id) && + email.from === HANBIT_FROM && + (rule.actions.moveToMailboxId !== null || + !email.keywords.has('$flagged')), + ) + .map(({ id }) => id); + } + + it('옮기기 · 1,234통 · 요청마다 한 차례 — 맞는 메일만 모두 옮기고 모든 메일을 한 번씩 확인한다. 첫 조회 말고는 모두 위 경계를 건다', async () => { + const engine = new FakeEngine(); + fill(engine, 1_234); + + const result = await runAll(engine, moveRule); + expect(result).toMatchObject({ + done: true, + requests: 3, + scanned: 1_234, + matched: 177, + }); + expect(missed(engine, moveRule)).toEqual([]); + expect(engine.sorted(TARGET)).toHaveLength(177); + // 맞지 않은 메일은 그대로 + for (const email of engine.sorted(INBOX)) { + expect(email.from).toBe(OTHER_FROM); + expect(email.keywords.size).toBe(0); + } + expect(engine.queries[0].filter).toEqual({ inMailbox: INBOX }); + for (const query of engine.queries.slice(1)) { + expect(query.filter).toEqual({ + inMailbox: INBOX, + before: utc(T0 + 1_234), + }); + } + }); + + it('표시만 · 1,234통이 모두 같은 받은 시각 · 요청마다 두 차례 — 같은 초 안에서도 anchor 로 이어 읽어 한 번씩 확인한다', async () => { + const engine = new FakeEngine(); + fill(engine, 1_234, () => T0); + + const result = await runAll(engine, flagRule, { roundsPerRequest: 2 }); + expect(result).toMatchObject({ + done: true, + requests: 2, + scanned: 1_234, + matched: 177, + }); + expect(missed(engine, flagRule)).toEqual([]); + }); + + it.each([1, 5, 20, 100])( + '요청 사이에 사용자가 anchor 와 받은편지함 맨 위 %i통을 휴지통으로 옮겨도 맞는 메일이 남지 않는다 (옮기기 · 표시만)', + async (count) => { + for (const rule of [moveRule, flagRule]) { + const engine = new FakeEngine(); + fill(engine, 1_400); + let moved = false; + const result = await runAll(engine, rule, { + between: (cursor) => { + if (moved) return; + const { anchor } = decodeFilterRunCursor(cursor, rule); + if (anchor === null) return; + moved = true; + engine.move(anchor, INBOX, TRASH); + for (const email of engine.sorted(INBOX).slice(0, count)) { + engine.move(email.id, INBOX, TRASH); + } + }, + }); + expect(moved).toBe(true); + expect(result.done).toBe(true); + expect(missed(engine, rule)).toEqual([]); + } + }, + ); + + it('anchor 의 초에 같은 시각 메일이 여럿 — anchor 가 빠지면 그 초의 메일을 처음부터 다시 판정하고(그 뒤의 같은 초 메일을 건너뛰지 않는다), 다시 보는 것은 그 초뿐이다', async () => { + for (const rule of [moveRule, flagRule]) { + const engine = new FakeEngine(); + // 한 초에 37통 — 한 차례(500통)가 초의 가운데에서 끝난다 + fill(engine, 1_400, (index) => T0 + Math.floor(index / 37)); + /** 요청 사이에 빠진 anchor — 처음 한 번만 */ + const removed: { id: string; time: number | null }[] = []; + const result = await runAll(engine, rule, { + between: (cursor) => { + if (removed.length > 0) return; + const { anchor, anchorTime } = decodeFilterRunCursor(cursor, rule); + if (anchor === null) return; + removed.push({ id: anchor, time: anchorTime }); + engine.move(anchor, INBOX, TRASH); + }, + }); + + expect(result.done).toBe(true); + expect(missed(engine, rule)).toEqual([]); + // 빠진 anchor 의 초에는 그 뒤에도 같은 시각의 메일 · 맞는 메일이 있었다 + expect(removed).toHaveLength(1); + const [{ id: anchorId, time: anchorTime }] = removed; + expect(anchorTime).not.toBeNull(); + const sameSecond = [...engine.emails.values()].filter( + (email) => email.receivedAt === anchorTime, + ); + expect(sameSecond.length).toBe(37); + const anchorDoc = engine.emails.get(anchorId)?.doc ?? -1; + expect( + sameSecond.some( + (email) => email.doc > anchorDoc && email.from === HANBIT_FROM, + ), + ).toBe(true); + // 다시 판정하는 것은 그 초의 앞쪽(많아야 36통)뿐이다 + expect(result.scanned).toBeGreaterThanOrEqual(1_400); + expect(result.scanned).toBeLessThanOrEqual(1_400 + 36); + } + }); + + it('도중에 온 새 메일은 건드리지 않는다 — 요청 사이 · 요청 도중에 맞는 메일이 와도(새 메일은 Sieve 가 이미 판정했다), anchor 가 빠져 다시 읽어도', async () => { + for (const rule of [moveRule, flagRule]) { + const engine = new FakeEngine(); + fill(engine, 1_400); + const original = new Set(engine.emails.keys()); + let arrivals = 0; + const arrive = () => { + arrivals += 1; + engine.add(T0 + 10_000 + arrivals, HANBIT_FROM); + }; + // 요청 도중 — 첫 조회 뒤로는 조회마다 한 통 + let queries = 0; + engine.beforeCall.push((methods) => { + if (methods[0] !== 'Email/query') return; + queries += 1; + if (queries > 1) arrive(); + }); + let anchorRemoved = false; + const result = await runAll(engine, rule, { + between: (cursor) => { + // 요청 사이 — 세 통, 처음에는 anchor 도 빠진다(처음부터 다시 읽는다) + for (let index = 0; index < 3; index += 1) arrive(); + const { anchor } = decodeFilterRunCursor(cursor, rule); + if (!anchorRemoved && anchor !== null) { + anchorRemoved = true; + engine.move(anchor, INBOX, TRASH); + } + }, + }); + + expect(result.done).toBe(true); + expect(anchorRemoved).toBe(true); + expect(missed(engine, rule, original)).toEqual([]); + const fresh = [...engine.emails.values()].filter( + ({ id }) => !original.has(id), + ); + expect(fresh.length).toBeGreaterThan(5); + for (const email of fresh) { + expect([...email.mailboxes]).toEqual([INBOX]); + expect(email.keywords.size).toBe(0); + } + } + }); + + it('옮기기 직전에 사용자가 맞은 메일 하나를 휴지통으로 — 읽은 상태가 아니면 옮기지 않고(ifInState) 다시 읽어 남은 것만 옮긴다 (휴지통과 옮길 메일함에 함께 들지 않는다)', async () => { + const engine = new FakeEngine(); + fill(engine, 30); + let trashed: string | null = null; + engine.beforeCall.push((methods) => { + if (trashed !== null || methods[0] !== 'Email/set') return; + trashed = + engine.sorted(INBOX).find((email) => email.from === HANBIT_FROM) + ?.id ?? null; + if (trashed !== null) engine.move(trashed, INBOX, TRASH); + }); + + const result = await runAll(engine, moveRule); + expect(trashed).not.toBeNull(); + expect(result).toMatchObject({ done: true, scanned: 30, matched: 4 }); + expect( + [...engine.emails.values()].filter( + (email) => email.mailboxes.has(TRASH) && email.mailboxes.has(TARGET), + ), + ).toEqual([]); + expect(missed(engine, moveRule)).toEqual([]); + }); + + it('모두 맞는 규칙(always)으로 차례마다 모두 옮겨 남긴 메일이 없어도 이어 간다 — 위 경계 아래의 처음부터. 요청 사이에 온 새 메일은 그대로', async () => { + const engine = new FakeEngine(); + fill(engine, 1_200); + const always = request({ + match: 'always', + conditions: [], + actions: { moveToMailboxId: TARGET, markAsRead: false, flag: false }, + }); + + const result = await runAll(engine, always, { + between: () => { + for (let index = 0; index < 3; index += 1) { + engine.add(T0 + 20_000 + engine.emails.size, OTHER_FROM); + } + }, + }); + expect(result).toMatchObject({ + done: true, + requests: 3, + scanned: 1_200, + matched: 1_200, + }); + // 받은편지함에 남은 것은 도중에 온 새 메일 6통뿐 + const left = engine.sorted(INBOX); + expect(left).toHaveLength(6); + expect(left.every((email) => email.receivedAt >= T0 + 20_000)).toBe(true); + }); + }); +}); diff --git a/backend/src/modules/mail/jmap/jmap-filter-run.ts b/backend/src/modules/mail/jmap/jmap-filter-run.ts new file mode 100644 index 0000000..f636005 --- /dev/null +++ b/backend/src/modules/mail/jmap/jmap-filter-run.ts @@ -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; + /** 이 읽기의 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; + /** 그사이 받은편지함에서 빠졌거나 없어진 메일 — 하지 않았다 */ + readonly gone: ReadonlySet; +} + +/** 본문 값을 한 번에 읽을 메일 — 부분마다의 몫이 같은 메일끼리 */ +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 | null { + const patch: Record = {}; + 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(); + 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 { + // 이어 할 자리는 엔진에 묻기 전에 본다 — 다른 규칙 · 깨진 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 { + 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(responses, 'q', 'Email/query'); + const found = expectResult>( + 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 { + 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 { + /** 판정 — 'gone' 은 받은편지함에서 빠진 메일(건너뛰고 세지 않는다) */ + const verdicts = new Map(); + const undecided: JmapEmail[] = []; + const headerTexts = new Map>(); + 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>(); + 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>, + run: BulkRun, + ): Promise { + const gone = new Set(); + 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>(); + 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>, + ifInState: string | null, + options: JmapCallOptions, + ): Promise { + 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 }> { + const responses = await this.jmap.call( + user, + (accountId) => [ + [ + 'Email/get', + { accountId, ids: [...ids], properties: ['id', 'mailboxIds'] }, + 'g', + ], + ], + options, + ); + const found = expectResult>( + 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> { + 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>( + responses, + 'b', + 'Email/get', + ); + return new Map(list.map((email) => [email.id, email])); + } +} diff --git a/backend/src/modules/mail/jmap/jmap-filter-script.spec.ts b/backend/src/modules/mail/jmap/jmap-filter-script.spec.ts index 661f035..0573765 100644 --- a/backend/src/modules/mail/jmap/jmap-filter-script.spec.ts +++ b/backend/src/modules/mail/jmap/jmap-filter-script.spec.ts @@ -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); diff --git a/backend/src/modules/mail/jmap/jmap-filter-script.ts b/backend/src/modules/mail/jmap/jmap-filter-script.ts index e17c151..22a8391 100644 --- a/backend/src/modules/mail/jmap/jmap-filter-script.ts +++ b/backend/src/modules/mail/jmap/jmap-filter-script.ts @@ -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 { - return (await this.snapshot(user)).source.vacation; + /** + * 부재중 자동 응답 — 우리 스크립트가 없으면 엔진의 VacationResponse, 원본이 깨졌으면 끈 상태. + * 동작하는지(isActive)는 필터의 것과 같은 값이다 — 같은 스크립트 하나를 읽어 안다 (7단계) + */ + async getVacation(user: string): Promise { + return toVacationState(await this.snapshot(user)); } - /** 부재중 자동 응답만 바꾼다 — 규칙은 다시 읽은 그대로 둔다 (지문을 보지 않는다). 저장한 값을 돌려준다 */ + /** 부재중 자동 응답만 바꾼다 — 규칙은 다시 읽은 그대로 둔다 (지문을 보지 않는다). 저장한 값과 저장한 뒤 동작하는지를 돌려준다 */ async setVacation( user: string, vacation: MailVacation, - ): Promise { + ): Promise { const saved = await this.write(user, (current) => ({ rules: current.rules, vacation, })); - return saved.source.vacation; + return toVacationState(saved); } /** diff --git a/backend/src/modules/mail/jmap/jmap-mail.provider.spec.ts b/backend/src/modules/mail/jmap/jmap-mail.provider.spec.ts index 9d523ba..0fa6fa9 100644 --- a/backend/src/modules/mail/jmap/jmap-mail.provider.spec.ts +++ b/backend/src/modules/mail/jmap/jmap-mail.provider.spec.ts @@ -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', ); diff --git a/backend/src/modules/mail/jmap/jmap-mail.provider.ts b/backend/src/modules/mail/jmap/jmap-mail.provider.ts index 31ad39c..bec65c3 100644 --- a/backend/src/modules/mail/jmap/jmap-mail.provider.ts +++ b/backend/src/modules/mail/jmap/jmap-mail.provider.ts @@ -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 = new Set(['inbox', 'archive']); const EMPTYABLE_ROLES: ReadonlySet = 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 | 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 { @@ -1166,12 +1086,15 @@ export class JmapMailProvider implements MailProvider { * 부재중 자동 응답 — 필터와 같은 엔진 규칙(`arasystem` 스크립트)에 둔다. 그 스크립트가 아직 없으면 엔진의 VacationResponse 값이다 * (계약 2절 "메일 필터"의 저장 자리 — jmap-filter-script.ts) */ - getVacation(user: string): Promise { + getVacation(user: string): Promise { return this.filterScripts.getVacation(user); } /** 부재중 자동 응답만 바꾼다 — 같은 스크립트의 필터 규칙은 그대로 둔다. 저장한 값을 돌려준다 */ - setVacation(user: string, vacation: MailVacation): Promise { + setVacation( + user: string, + vacation: MailVacation, + ): Promise { 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 { + return this.filterRuns.run(user, request, context); + } + async getUsage(user: string): Promise { 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 | undefined, doing: string, ): void { - const counts = new Map(); - 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); } /** diff --git a/backend/src/modules/mail/jmap/jmap-results.ts b/backend/src/modules/mail/jmap/jmap-results.ts index 72ea2f0..b9eb042 100644 --- a/backend/src/modules/mail/jmap/jmap-results.ts +++ b/backend/src/modules/mail/jmap/jmap-results.ts @@ -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 } | { 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 | 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 | undefined, + doing: string, +): void { + const counts = new Map(); + 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}통`, + ); + } +} diff --git a/backend/src/modules/mail/jmap/jmap.types.ts b/backend/src/modules/mail/jmap/jmap.types.ts index a114860..0768ed0 100644 --- a/backend/src/modules/mail/jmap/jmap.types.ts +++ b/backend/src/modules/mail/jmap/jmap.types.ts @@ -189,6 +189,8 @@ export interface JmapEmailFilterCondition { /** 이 메일함들이 **아닌** 메일함에 하나라도 들어 있는 메일 — 휴지통·스팸메일함에만 있는 메일을 뺀다 */ inMailboxOtherThan?: string[]; notKeyword?: string; + /** 받은 시각이 이보다 앞선(미만) 메일 — UTCDate. 엔진은 받은 시각을 초 단위로 둔다 (2026-10-29 확인) */ + before?: string; /** 보낸 사람·받는 사람·참조·제목·본문 전문 검색 — 큰따옴표로 감싼 값은 구절로 찾는다 */ text?: string; } diff --git a/backend/src/modules/mail/jmap/mime-text.spec.ts b/backend/src/modules/mail/jmap/mime-text.spec.ts index 61c0ddb..a56054d 100644 --- a/backend/src/modules/mail/jmap/mime-text.spec.ts +++ b/backend/src/modules/mail/jmap/mime-text.spec.ts @@ -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" ')).toBe( + '"Kim, Ara" ', + ); + expect(decodeHeaderText(' ')).toBe( + '', + ); + }); + + it('UTF-8 B 로 인코딩된 표시 이름 · 제목을 푼다 (실배달 메일의 From · Cc · Subject)', () => { + expect( + decodeHeaderText(' =?UTF-8?B?6rmA7JWE6528?= '), + ).toBe('김아라 '); + expect( + decodeHeaderText(' =?UTF-8?B?7LC47KGw7J24?= '), + ).toBe('참조인 '); + 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?= ')).toBe( + 'Jörg ', + ); + }); + + it('접힌 줄(줄바꿈 + 뒤의 공백 · 탭 전부)은 띄어쓰기 하나로 편다 — 접지 않은 탭 · 이어진 공백은 그대로 (엔진 판정 H3 · H4 · G1 ~ G3)', () => { + expect( + decodeHeaderText( + ' a1@example.com,\r\n a2@example.com,\r\n\t', + ), + ).toBe('a1@example.com, a2@example.com, '); + expect( + decodeHeaderText( + ' "가나" ,\r\n "다라" ', + ), + ).toBe('"가나" , "다라" '); + expect(decodeHeaderText(' a,\n \t b')).toBe('a, b'); + expect(decodeHeaderText(' "가나"\t')).toBe( + '"가나"\t', + ); + expect(decodeHeaderText(' "가나" ')).toBe( + '"가나" ', + ); + }); + + it('인코딩된 글은 홀로 선 것만 푼다 — 앞이 처음 · 공백 · " · , 이고 뒤가 끝 · 공백 · " 일 때, 앞뒤가 다르다 (엔진 판정 H1 · H5 · H7 · G4 ~ G6 · G8 ~ G10)', () => { + const name = '=?UTF-8?B?6rmA7JWE6528?='; // 김아라 + const title = '=?UTF-8?B?67aA7J6l?='; // 부장 + expect(decodeHeaderText(` "${name}" `)).toBe( + '"김아라" ', + ); + expect(decodeHeaderText(` "${name} 부장" `)).toBe( + '"김아라 부장" ', + ); + expect(decodeHeaderText(` ,${name} `)).toBe( + ',김아라 ', + ); + expect(decodeHeaderText(` a@example.com ( ${title} )`)).toBe( + 'a@example.com ( 부장 )', + ); + // 글자 · ( 뒤, 글자 · < · , 앞이면 풀지 않는다 — 앞의 , 는 풀고(위) 뒤의 , 는 풀지 않는다(G10) + for (const raw of [ + `abc${name} `, + `${name}abc `, + `a@example.com (${title})`, + `${name}`, + `, ${name},"가나" `, + ]) { + 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?= ', + ), + ).toBe('김아라 '); + 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?= ')).toBe( + '=?x-unknown?B?7ZWc?= ', + ); + 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); + }); +}); diff --git a/backend/src/modules/mail/jmap/mime-text.ts b/backend/src/modules/mail/jmap/mime-text.ts index 488e543..0a1b164 100644 --- a/backend/src/modules/mail/jmap/mime-text.ts +++ b/backend/src/modules/mail/jmap/mime-text.ts @@ -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 = 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(); +/** + * 한 머리글에서 기억에 없는 문자셋 이름을 찾아보는 횟수 — 넘으면 모르는 문자셋으로 본다. + * 모르는 이름을 찾아보는 일(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 = new Set([ + 0x20, 0x09, 0x0d, 0x0a, 0x22, 0x2c, +]); +const WORD_END_BOUNDARY_CODES: ReadonlySet = 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, +): 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, +): 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 · `"` 일 때 + * (앞뒤가 다르다 — `,=?…?= ` 는 풀고 `=?…?=,"가나"` 는 풀지 않는다). + * 글자 · `(` 뒤(`abc=?…?=` · `(=?…?=)`), 글자 · `<` · `,` 앞(`=?…?=abc` · `=?…?=` · `=?…?=,`)이면 풀지 않고 그대로 둔다. + * 공백만 사이에 둔 인코딩된 글끼리는 공백을 버리고 붙인다(같은 문자셋이면 바이트를 이어 푼다) + * 3. 앞뒤 공백을 뺀다. 따옴표 · 꺾쇠 · 주소는 그대로 둔다 (`"Kim, Ara" ` — 계약 2절 "이미 받은 메일에 적용"의 판정) + * 모르는 문자셋의 낱말 · 모양이 틀린 낱말도 풀지 않고 그대로 둔다 + */ +export function decodeHeaderText(raw: string): string { + const unfolded = raw.replace(FOLDED_LINE_BREAK, ' '); + const looked = new Map(); + 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(); +} diff --git a/backend/src/modules/mail/jmap/sieve-script.ts b/backend/src/modules/mail/jmap/sieve-script.ts index 684909b..890c9b9 100644 --- a/backend/src/modules/mail/jmap/sieve-script.ts +++ b/backend/src/modules/mail/jmap/sieve-script.ts @@ -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 string> = { from: (value) => `header :contains "from" ${value}`, to: (value) => `header :contains ["to", "cc"] ${value}`, diff --git a/backend/src/modules/mail/mail-filter-match.spec.ts b/backend/src/modules/mail/mail-filter-match.spec.ts new file mode 100644 index 0000000..53c50e0 --- /dev/null +++ b/backend/src/modules/mail/mail-filter-match.spec.ts @@ -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 => ({ + 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" '], + to: ['seojun.park@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', '참조인 { + 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); + }); +}); diff --git a/backend/src/modules/mail/mail-filter-match.ts b/backend/src/modules/mail/mail-filter-match.ts new file mode 100644 index 0000000..a77708e --- /dev/null +++ b/backend/src/modules/mail/mail-filter-match.ts @@ -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 { + 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(); + 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; +} diff --git a/backend/src/modules/mail/mail-filter.controller.ts b/backend/src/modules/mail/mail-filter.controller.ts index 6f72c21..10693be 100644 --- a/backend/src/modules/mail/mail-filter.controller.ts +++ b/backend/src/modules/mail/mail-filter.controller.ts @@ -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); + } +} diff --git a/backend/src/modules/mail/mail-filters.spec.ts b/backend/src/modules/mail/mail-filters.spec.ts index a65a1ea..1604c00 100644 --- a/backend/src/modules/mail/mail-filters.spec.ts +++ b/backend/src/modules/mail/mail-filters.spec.ts @@ -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'); + }); +}); diff --git a/backend/src/modules/mail/mail-filters.ts b/backend/src/modules/mail/mail-filters.ts index df7c146..c8946c7 100644 --- a/backend/src/modules/mail/mail-filters.ts +++ b/backend/src/modules/mail/mail-filters.ts @@ -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>, +): 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>, +): 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, +): string | null { + return criteriaProblem(request) ?? actionsProblem(request.actions); } /** diff --git a/backend/src/modules/mail/mail.constants.ts b/backend/src/modules/mail/mail.constants.ts index 3dc228c..eaf0a65 100644 --- a/backend/src/modules/mail/mail.constants.ts +++ b/backend/src/modules/mail/mail.constants.ts @@ -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") diff --git a/backend/src/modules/mail/mail.module.ts b/backend/src/modules/mail/mail.module.ts index bcd31ac..e4f83d7 100644 --- a/backend/src/modules/mail/mail.module.ts +++ b/backend/src/modules/mail/mail.module.ts @@ -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) diff --git a/backend/src/modules/mail/mail.provider.ts b/backend/src/modules/mail/mail.provider.ts index d16614d..c0b35a7 100644 --- a/backend/src/modules/mail/mail.provider.ts +++ b/backend/src/modules/mail/mail.provider.ts @@ -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; + getVacation(user: string): Promise; /** - * 부재중 자동 응답을 통째로 바꾸고 저장한 값을 돌려준다 — 메일 필터 규칙은 그대로 둔다. + * 부재중 자동 응답을 통째로 바꾸고 저장한 값을 돌려준다 — 메일 필터 규칙은 그대로 둔다. 저장하면 다시 동작한다(isActive). * 형식은 요청 검증이, 켤 때의 빈 값 · 길이 · 날짜 순서는 서비스가 본다. 함께 만든 엔진 규칙이 엔진 한도를 넘으면 MAIL_013 */ - setVacation(user: string, vacation: MailVacation): Promise; + setVacation(user: string, vacation: MailVacation): Promise; /** 메일 필터 (계약 2절 "메일 필터") — 규칙(차례대로) · 지금 규칙의 지문(version) · 동작하는지(isActive) */ getMailFilters(user: string): Promise; @@ -130,6 +134,20 @@ export interface MailProvider { update: MailFilterUpdate, ): Promise; + /** + * 받은편지함에 이미 있는 메일에 규칙 하나를 적용한다 (계약 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; + /** 메일함 사용량과 한도 (한도가 없으면 limitBytes = null) */ getUsage(user: string): Promise; diff --git a/backend/src/modules/mail/mail.service.spec.ts b/backend/src/modules/mail/mail.service.spec.ts index 461e37d..50c52dc 100644 --- a/backend/src/modules/mail/mail.service.spec.ts +++ b/backend/src/modules/mail/mail.service.spec.ts @@ -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(): { + promise: Promise; + finish: (value: T) => void; + fail: (error: Error) => void; + } { + let finish!: (value: T) => void; + let fail!: (error: Error) => void; + const promise = new Promise((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(); + 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()); diff --git a/backend/src/modules/mail/mail.service.ts b/backend/src/modules/mail/mail.service.ts index e85aaf8..ee75cb0 100644 --- a/backend/src/modules/mail/mail.service.ts +++ b/backend/src/modules/mail/mail.service.ts @@ -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 = new Set([ 'drafts', ]); +/** + * 필터가 옮길 메일함 — 보낸편지함 · 임시보관함이면 VAL_001. 메일함 목록에 없는 id 는 받는다 + * (지운 메일함을 가리키는 규칙이 남아 있을 수 있다 — 새 메일은 받은편지함에 들어오고, 이미 받은 메일은 옮기지 않는다). + * 규칙 저장(규칙마다의 할 일 — index 는 몇 번째 규칙)과 이미 받은 메일에 적용(할 일 하나)이 같이 쓴다 + */ +function assertMoveTargetsAllowed( + mailboxes: readonly Mailbox[], + actions: readonly Pick[], +): 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(); + /** + * 진행 중인 "이미 받은 메일에 필터 적용"의 직원 — 한 직원에 하나씩만 (계약 2절 "이미 받은 메일에 적용" — 다른 탭 · PC 에서 함께 하면 429 REQ_001). + * 두 적용이 같은 받은편지함을 함께 읽으면 서로 옮긴 메일 때문에 자리가 어긋나고 엔진 호출이 그 수만큼 곱절로 는다. + * ⚠️ 프로세스 메모리다. 인스턴스를 늘리면 Redis 잠금(SET NX PX)으로 옮겨야 인스턴스 사이에서도 하나씩이 된다. + */ + private readonly filterRuns = new Set(); + 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 { + /** 부재중 자동 응답 (계약 2절 "부재중 자동 응답") — 엔진에서 우리 규칙이 동작하는지(isActive)를 함께 (7단계) */ + getVacation(user: AuthenticatedUser): Promise { return this.provider.getVacation(user.email); } @@ -392,7 +425,7 @@ export class MailService { async updateVacation( user: AuthenticatedUser, dto: MailVacationDto, - ): Promise { + ): Promise { 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 { + 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[], ): Promise { - 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( + /** 같은 직원 · 같은 메일함의 오래 걸리는 처리를 하나씩만 한다 — 다른 메일함끼리는 동시에 된다 (exclusive) */ + private runExclusive( user: AuthenticatedUser, mailboxId: string, operation: () => Promise, ): Promise { - 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( + running: Set, + key: string, + reason: string, + user: AuthenticatedUser, + operation: () => Promise, + ): Promise { + 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); } } diff --git a/backend/src/modules/mail/mail.types.ts b/backend/src/modules/mail/mail.types.ts index 62b4ac5..8cc8caa 100644 --- a/backend/src/modules/mail/mail.types.ts +++ b/backend/src/modules/mail/mail.types.ts @@ -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; + +/** 이미 받은 메일에 할 일 (계약 2절 "이미 받은 메일에 적용") — 규칙의 할 일에서 전달을 뺀 것 (이미 받은 메일은 전달하지 않는다) */ +export type MailFilterRunActions = Omit; + +/** 받은편지함에 이미 있는 메일에 규칙 하나를 적용하는 요청 (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; diff --git a/backend/src/modules/mail/mailbox.controller.ts b/backend/src/modules/mail/mailbox.controller.ts index 8b9da32..0121a54 100644 --- a/backend/src/modules/mail/mailbox.controller.ts +++ b/backend/src/modules/mail/mailbox.controller.ts @@ -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( diff --git a/backend/test/app.e2e-spec.ts b/backend/test/app.e2e-spec.ts index 020b7b5..bd16bf1 100644 --- a/backend/test/app.e2e-spec.ts +++ b/backend/test/app.e2e-spec.ts @@ -38,9 +38,12 @@ import type { MailboxCreate, MailboxDeletion, MailFilterRule, + MailFilterRunOutcome, + MailFilterRunRequest, MailFilterSet, MailFilterUpdate, MailVacation, + MailVacationState, MailWatch, MailWatchListener, MessageDeletionResult, @@ -189,6 +192,9 @@ const E2E_MAILBOXES: Mailbox[] = ( unreadCount: 0, })); +/** 대역 메일 엔진이 "다른 규칙의 cursor"라고 답하는 cursor (400 VAL_001 경로 — 실제 해석은 jmap/filter-run-cursor.ts) */ +const OTHER_RULE_CURSOR = 'otherrulecursor'; + /** 대역 엔진의 규칙 지문 — 실제 지문은 jmap/filter-source.ts (여기서는 같은 규칙이면 같은 값이면 된다) */ const fakeFilterVersion = (rules: readonly MailFilterRule[]) => createHash('sha256').update(JSON.stringify(rules)).digest('base64url'); @@ -369,6 +375,11 @@ describe('응답 계약 (e2e)', () => { { rules: readonly MailFilterRule[]; vacation: MailVacation } >(); const filterSaves: { user: string; update: MailFilterUpdate }[] = []; + /** 7단계 — 엔진에서 우리 규칙이 꺼진 직원(부재중 · 필터가 동작하지 않음 — 저장하면 다시 켜진다) · 받은 "이미 받은 메일에 적용" */ + const inactiveScripts = new Set(); + const filterRuns: { user: string; request: MailFilterRunRequest }[] = []; + /** 대역 엔진의 적용이 테스트가 문을 열 때까지 끝나지 않는다 — 같은 직원의 진행 중 잠금 확인용. null 이면 바로 답한다 */ + let slowFilterRunGate: Promise | null = null; const singleUpdates: { user: string; id: string; @@ -669,21 +680,23 @@ describe('응답 계약 (e2e)', () => { if (id === SLOW_MAILBOX && slowMailboxGate) await slowMailboxGate; return { processed: 2, remaining: 0 }; }, - getVacation: (user: string): Promise => - Promise.resolve( - mailScripts.get(user)?.vacation ?? NEVER_CONFIGURED_VACATION, - ), + getVacation: (user: string): Promise => + Promise.resolve({ + ...(mailScripts.get(user)?.vacation ?? NEVER_CONFIGURED_VACATION), + isActive: !inactiveScripts.has(user), + }), // 부재중만 바꾼다 — 같은 엔진 규칙의 필터 규칙은 그대로 (실제 규칙은 jmap-filter-script.spec.ts) setVacation: ( user: string, vacation: MailVacation, - ): Promise => { + ): Promise => { vacationSaves.push({ user, vacation }); mailScripts.set(user, { rules: mailScripts.get(user)?.rules ?? [], vacation, }); - return Promise.resolve(vacation); + inactiveScripts.delete(user); + return Promise.resolve({ ...vacation, isActive: true }); }, listMailboxes: (): Promise => Promise.resolve(E2E_MAILBOXES), // 6단계 — 엔진 쪽 판단(지문 비교 · 한도)만 흉내 낸다. 만드는 Sieve · 조건부 쓰기는 jmap/*.spec.ts 가 본다 @@ -735,6 +748,34 @@ describe('응답 계약 (e2e)', () => { isActive: true, }); }, + // 7단계 — 차례 · 판정 · 이어 할 자리는 jmap-filter-run.spec.ts 가 본다. 여기서는 받은 값을 기록하고 cursor 로 이어 하기만 흉내 낸다 + runMailFilter: async ( + user: string, + request: MailFilterRunRequest, + ): Promise => { + filterRuns.push({ user, request }); + if (request.cursor === OTHER_RULE_CURSOR) { + throw new BusinessException('VAL_001', { + context: { reason: 'e2e-filter-run-other-rule' }, + }); + } + if (slowFilterRunGate) await slowFilterRunGate; + return request.cursor === null + ? { + scanned: 500, + matched: 3, + remaining: 120, + cursor: 'e2enextcursor', + rounds: 1, + } + : { + scanned: 120, + matched: 1, + remaining: 0, + cursor: null, + rounds: 1, + }; + }, openMessageSource: ( _user: string, id: string, @@ -1242,6 +1283,7 @@ describe('응답 계약 (e2e)', () => { ['put', '/api/mail-vacation'], ['get', '/api/mail-filters'], ['put', '/api/mail-filters'], + ['post', '/api/mail-filter-runs'], ['get', '/api/messages/eaaaaab/source'], // 템플릿 업로드 예제도 로그인한 직원만 쓴다 ['post', '/api/files'], @@ -2581,30 +2623,41 @@ describe('응답 계약 (e2e)', () => { .set('Cookie', cookieOf(ME)) .send(payload); - it('한 번도 설정하지 않았으면 끈 상태 · 빈 제목과 내용 — 받은 그대로 다시 저장해도 200', async () => { + it('한 번도 설정하지 않았으면 끈 상태 · 빈 제목과 내용 · 동작함 — isActive 를 뺀 그대로 다시 저장해도 200, isActive 를 넣으면 400', async () => { const res = await request(server) .get('/api/mail-vacation') .set('Cookie', cookieOf(ME)) .expect(200); expect(body(res)).toEqual({ success: true, - data: NEVER_CONFIGURED_VACATION, + data: { ...NEVER_CONFIGURED_VACATION, isActive: true }, }); - const restored = await put(dataOf(res)).expect(200); - expect(dataOf(restored)).toEqual(NEVER_CONFIGURED_VACATION); + const { isActive, ...stored } = dataOf(res); + expect(isActive).toBe(true); + const restored = await put(stored).expect(200); + expect(dataOf(restored)).toEqual({ + ...NEVER_CONFIGURED_VACATION, + isActive: true, + }); + + // 응답 그대로(isActive 포함) 보내면 모르는 필드 — 계약 2절 "부재중 자동 응답"의 MailVacationState + const before = vacationSaves.length; + const rejected = await put(dataOf(res)).expect(400); + expect(body(rejected).error?.code).toBe('VAL_001'); + expect(vacationSaves).toHaveLength(before); }); it('저장하면(전체 교체) 엔진이 저장한 값을 돌려주고, 다음 조회도 그 값이다', async () => { const res = await put(vacation).expect(200); - expect(dataOf(res)).toEqual(vacation); + expect(dataOf(res)).toEqual({ ...vacation, isActive: true }); expect(vacationSaves.at(-1)).toEqual({ user: ME, vacation }); const read = await request(server) .get('/api/mail-vacation') .set('Cookie', cookieOf(ME)) .expect(200); - expect(dataOf(read)).toEqual(vacation); + expect(dataOf(read)).toEqual({ ...vacation, isActive: true }); }); it('켤 때 빈(공백뿐인) 제목 · 내용, 제어 문자, 시작이 끝보다 늦음 · 같음(같은 초 포함), 날짜 형식, 빠진 키, 길이 초과(켤 때 150 · 600자, 꺼 둔 채로 200 · 4,000자), 서식 본문은 400 VAL_001 — 저장하지 않는다', async () => { @@ -2660,7 +2713,7 @@ describe('응답 계약 (e2e)', () => { const res = await put(full).expect(200); - expect(dataOf(res)).toEqual(full); + expect(dataOf(res)).toEqual({ ...full, isActive: true }); }); it('꺼 둔 채로는 4단계 한도까지 받는다 — 4단계에 저장해 둔 영문 내용 1,000자도 끌 수 있고(200), 그대로 켜면 400 VAL_001 (줄인 뒤 켠다)', async () => { @@ -2671,7 +2724,7 @@ describe('응답 계약 (e2e)', () => { }; const res = await put(longOff).expect(200); - expect(dataOf(res)).toEqual(longOff); + expect(dataOf(res)).toEqual({ ...longOff, isActive: true }); expect(vacationSaves.at(-1)).toEqual({ user: ME, vacation: longOff }); const before = vacationSaves.length; @@ -2706,7 +2759,7 @@ describe('응답 계약 (e2e)', () => { fromDate: '2026-11-02T15:00:00.000Z', toDate: '2026-11-07T15:00:00.000Z', }; - expect(dataOf(res)).toEqual(saved); + expect(dataOf(res)).toEqual({ ...saved, isActive: true }); expect(vacationSaves.at(-1)).toEqual({ user: ME, vacation: saved }); }); }); @@ -3222,7 +3275,7 @@ describe('응답 계약 (e2e)', () => { .get('/api/mail-vacation') .set('Cookie', cookieOf(ME)) .expect(200); - expect(dataOf(read)).toEqual(vacation); + expect(dataOf(read)).toEqual({ ...vacation, isActive: true }); await request(server) .put('/api/mail-vacation') @@ -3236,6 +3289,183 @@ describe('응답 계약 (e2e)', () => { }); }); + describe('7단계 — 이미 받은 메일에 필터 적용 · 부재중의 isActive', () => { + describe('이미 받은 메일에 적용 (POST /api/mail-filter-runs)', () => { + const ME = 'filter.run@e2e.arasystem.test'; + const run = { + match: 'any', + conditions: [ + { + field: 'from', + operator: 'contains', + value: '@hanbit-partners.co.kr', + }, + ], + actions: { moveToMailboxId: 'j', markAsRead: true, flag: false }, + cursor: null, + }; + const post = (payload: unknown, email = ME) => + request(server) + .post('/api/mail-filter-runs') + .set('Cookie', cookieOf(email)) + .send(payload as object); + + it('200(201 이 아니다) — 확인 · 적용 · 남은 수와 cursor, 차례 수는 응답에 없다 · 받은 cursor 로 다시 보내면 이어 한다 (찾을 글은 앞뒤 공백을 지워 넘긴다)', async () => { + const first = await post({ + ...run, + conditions: [ + { ...run.conditions[0], value: ' @hanbit-partners.co.kr ' }, + ], + }).expect(200); + expect(body(first)).toEqual({ + success: true, + data: { + scanned: 500, + matched: 3, + remaining: 120, + cursor: 'e2enextcursor', + }, + }); + expect(filterRuns.at(-1)).toEqual({ user: ME, request: run }); + + const next = await post({ ...run, cursor: 'e2enextcursor' }).expect( + 200, + ); + expect(dataOf(next)).toEqual({ + scanned: 120, + matched: 1, + remaining: 0, + cursor: null, + }); + expect(filterRuns.at(-1)?.request.cursor).toBe('e2enextcursor'); + }); + + it('형식 · 칸 사이의 규칙이 어긋나면 400 VAL_001 — 엔진에 묻지 않는다 (전달 · 보낸편지함 · 임시보관함 · 할 일 없음 · cursor 빠짐 · 모르는 필드)', async () => { + const before = filterRuns.length; + const withActions = (overrides: object) => ({ + ...run, + actions: { ...run.actions, ...overrides }, + }); + const withoutCursor = Object.fromEntries( + Object.entries(run).filter(([key]) => key !== 'cursor'), + ); + const payloads = [ + withActions({ forwardTo: 'doyun.lee@e2e.arasystem.test' }), + withActions({ moveToMailboxId: 'e' }), + withActions({ moveToMailboxId: 'd' }), + withActions({ moveToMailboxId: null, markAsRead: false }), + { ...run, match: 'always' }, + { ...run, conditions: [] }, + { + ...run, + conditions: [{ ...run.conditions[0], value: '광고${x}' }], + }, + withoutCursor, + { ...run, cursor: 'not base64url!' }, + { ...run, cursor: 'a'.repeat(1_025) }, + { ...run, version: 'v' }, + { ...run, id: 'rule-1' }, + ]; + const answers: string[] = []; + for (const payload of payloads) { + const res = await post(payload); + answers.push(`${res.status} ${body(res).error?.code ?? ''}`); + } + expect(answers).toEqual(payloads.map(() => '400 VAL_001')); + expect(filterRuns).toHaveLength(before); + }); + + it('다른 규칙의 cursor 는 어댑터가 400 VAL_001 로 막는다 · 없는 메일함(지워짐)으로 옮기는 규칙은 받는다', async () => { + const res = await post({ ...run, cursor: OTHER_RULE_CURSOR }).expect( + 400, + ); + expect(body(res).error?.code).toBe('VAL_001'); + + await post({ + ...run, + actions: { ...run.actions, moveToMailboxId: 'deletedbox' }, + }).expect(200); + }); + + it( + '같은 직원의 적용이 진행 중이면 429 REQ_001 — 엔진에 묻지 않는다. 다른 직원은 된다, 끝나면 다시 된다', + async () => { + const gate = createGate(); + slowFilterRunGate = gate.opened; + try { + const before = filterRuns.length; + const slow = post(run).then((res) => res); + await waitUntil(() => filterRuns.length > before, GATE_DEADLINE_MS); + expect(filterRuns).toHaveLength(before + 1); + + const busy = await post(run).expect(429); + expect(body(busy).error?.code).toBe('REQ_001'); + expect(filterRuns).toHaveLength(before + 1); + + gate.open(); + expect((await slow).status).toBe(200); + } finally { + gate.open(); + slowFilterRunGate = null; + } + await post(run).expect(200); + await post(run, 'filter.run.other@e2e.arasystem.test').expect(200); + }, + GATED_TEST_TIMEOUT_MS, + ); + + it('본문의 모양 한도(배열 하나 1,000)를 넘으면 DTO 검사 전에 400 VAL_001 — 로그인 전이어도 401 이 아니다', async () => { + const huge = JSON.stringify({ + ...run, + conditions: Array.from({ length: 80_000 }, () => ({})), + }); + for (const cookie of [cookieOf(ME), '']) { + const res = await request(server) + .post('/api/mail-filter-runs') + .set('Cookie', cookie) + .set('Content-Type', 'application/json') + .send(huge); + expect(res.status).toBe(400); + expect(body(res).error?.code).toBe('VAL_001'); + expect(body(res).error?.traceId).toBe(res.headers['x-request-id']); + } + }); + }); + + describe('부재중 자동 응답의 isActive (GET · PUT /api/mail-vacation)', () => { + const ME = 'vacation.inactive@e2e.arasystem.test'; + + it('엔진에서 우리 규칙이 꺼져 있으면 isActive: false — 켜 둔 값은 그대로, 저장하면 다시 켜진다', async () => { + const vacation = { + isEnabled: true, + fromDate: null, + toDate: null, + subject: '부재중입니다', + textBody: '11월 7일까지 휴가입니다.', + }; + await request(server) + .put('/api/mail-vacation') + .set('Cookie', cookieOf(ME)) + .send(vacation) + .expect(200); + inactiveScripts.add(ME); + + const read = await request(server) + .get('/api/mail-vacation') + .set('Cookie', cookieOf(ME)) + .expect(200); + expect(dataOf(read)).toEqual({ ...vacation, isActive: false }); + + const saved = await request(server) + .put('/api/mail-vacation') + .set('Cookie', cookieOf(ME)) + .send(vacation) + .expect(200); + expect(dataOf(saved)).toEqual({ ...vacation, isActive: true }); + }); + }); + }); + /** * 출처 검사(CSRF) — 상태를 바꾸는 요청은 Sec-Fetch-Site(same-origin·none) 또는 허용 목록의 Origin 이어야 한다. * 둘 다 없으면 브라우저가 아닌 클라이언트로 보고 통과시킨다 (docs/mail/api-contract.md 머리말).

링크글X13p7mark