From b4d30482fe7b1cc3fd3bc41769228270a51ddd6d Mon Sep 17 00:00:00 2001 From: ttipo Date: Wed, 28 Oct 2026 20:59:31 +0900 Subject: [PATCH] =?UTF-8?q?feat(backend):=20=EB=A9=94=EC=9D=BC=20=ED=95=84?= =?UTF-8?q?=ED=84=B0=20API=20=C2=B7=20=EB=B6=80=EC=9E=AC=EC=A4=91=EC=9D=84?= =?UTF-8?q?=20Sieve=20=EC=8A=A4=ED=81=AC=EB=A6=BD=ED=8A=B8=EB=A1=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit GET·PUT /api/mail-filters 를 더한다. 4단계 부재중 자동 응답의 저장 자리는 엔진의 VacationResponse 에서 우리 Sieve 스크립트(arasystem)로 옮긴다 — API 는 그대로다. - 엔진은 계정마다 켜진 스크립트가 하나라 필터와 부재중을 한 스크립트에 둔다. 읽을 때는 머리 주석의 원본(JSON)만 믿고, 쓸 때는 읽은 상태 그대로일 때만 쓴다(ifInState) - 다른 곳에서 먼저 바꿨으면 409 MAIL_012, 만든 글이 90,000바이트를 넘으면 400 MAIL_013 - 스팸은 엔진의 분류 표시(X-Spam-Score)를 보고 건너뛴다. 처음에는 spamtest(정수로 내린 점수)로 막았는데, 실엔진에서 9.7점 스팸에 규칙과 부재중 응답이 나가는 것을 찾았다 - 엔진의 오류 설명은 로그에 줄 · 칸만 남긴다. 새 판 원본은 덮어쓰지 않는다(배포를 되돌릴 때) Co-Authored-By: Claude Opus 5.5 --- .../decorators/trim-string.decorator.spec.ts | 24 + .../decorators/trim-string.decorator.ts | 16 + backend/src/common/errors/error-codes.spec.ts | 5 + backend/src/common/errors/error-codes.ts | 13 + .../modules/mail/dto/mail-filter.dto.spec.ts | 249 +++++ .../src/modules/mail/dto/mail-filter.dto.ts | 269 +++++ backend/src/modules/mail/dto/mailbox.dto.ts | 10 +- .../modules/mail/jmap/filter-source.spec.ts | 341 ++++++ .../src/modules/mail/jmap/filter-source.ts | 294 ++++++ .../mail/jmap/jmap-filter-script.spec.ts | 983 ++++++++++++++++++ .../modules/mail/jmap/jmap-filter-script.ts | 472 +++++++++ .../mail/jmap/jmap-mail.provider.spec.ts | 216 ++-- .../modules/mail/jmap/jmap-mail.provider.ts | 140 +-- backend/src/modules/mail/jmap/jmap-results.ts | 51 + backend/src/modules/mail/jmap/jmap.mapper.ts | 26 +- backend/src/modules/mail/jmap/jmap.types.ts | 13 + .../src/modules/mail/jmap/mime-text.spec.ts | 96 ++ backend/src/modules/mail/jmap/mime-text.ts | 83 ++ .../modules/mail/jmap/sieve-script.spec.ts | 313 ++++++ backend/src/modules/mail/jmap/sieve-script.ts | 264 +++++ .../modules/mail/mail-filter.controller.ts | 88 ++ backend/src/modules/mail/mail-filters.spec.ts | 185 ++++ backend/src/modules/mail/mail-filters.ts | 142 +++ backend/src/modules/mail/mail.constants.ts | 27 + backend/src/modules/mail/mail.module.ts | 5 + backend/src/modules/mail/mail.provider.ts | 26 +- backend/src/modules/mail/mail.service.spec.ts | 252 +++++ backend/src/modules/mail/mail.service.ts | 115 +- backend/src/modules/mail/mail.types.ts | 59 +- .../src/modules/mail/mailbox.controller.ts | 11 +- backend/test/app.e2e-spec.ts | 318 +++++- flutter/lib/core/api/error_lexicon.g.dart | 2 + .../shared/constants/error-codes.generated.ts | 2 + 33 files changed, 4809 insertions(+), 301 deletions(-) create mode 100644 backend/src/common/decorators/trim-string.decorator.spec.ts create mode 100644 backend/src/common/decorators/trim-string.decorator.ts create mode 100644 backend/src/modules/mail/dto/mail-filter.dto.spec.ts create mode 100644 backend/src/modules/mail/dto/mail-filter.dto.ts create mode 100644 backend/src/modules/mail/jmap/filter-source.spec.ts create mode 100644 backend/src/modules/mail/jmap/filter-source.ts create mode 100644 backend/src/modules/mail/jmap/jmap-filter-script.spec.ts create mode 100644 backend/src/modules/mail/jmap/jmap-filter-script.ts create mode 100644 backend/src/modules/mail/jmap/jmap-results.ts create mode 100644 backend/src/modules/mail/jmap/mime-text.spec.ts create mode 100644 backend/src/modules/mail/jmap/mime-text.ts create mode 100644 backend/src/modules/mail/jmap/sieve-script.spec.ts create mode 100644 backend/src/modules/mail/jmap/sieve-script.ts create mode 100644 backend/src/modules/mail/mail-filter.controller.ts create mode 100644 backend/src/modules/mail/mail-filters.spec.ts create mode 100644 backend/src/modules/mail/mail-filters.ts diff --git a/backend/src/common/decorators/trim-string.decorator.spec.ts b/backend/src/common/decorators/trim-string.decorator.spec.ts new file mode 100644 index 0000000..e6147e4 --- /dev/null +++ b/backend/src/common/decorators/trim-string.decorator.spec.ts @@ -0,0 +1,24 @@ +import { plainToInstance } from 'class-transformer'; + +import { TrimString } from './trim-string.decorator'; + +class Named { + @TrimString() + name!: unknown; +} + +const nameOf = (name: unknown) => plainToInstance(Named, { name }).name; + +describe('TrimString — DTO 변환에서 앞뒤 공백을 지운다', () => { + it('문자열은 JS trim() 과 같게 — 탭 · 줄바꿈 · NBSP · U+3000 도 지운다, 가운데는 그대로', () => { + expect(nameOf(' 거래처 메일 ')).toBe('거래처 메일'); + expect(nameOf('\t\n\u00a0\u3000가 나\u3000')).toBe('가 나'); + expect(nameOf(' ')).toBe(''); + }); + + it('문자열이 아니면 그대로 둔다 — 형식 검사(IsString 등)가 막는다', () => { + expect(nameOf(null)).toBeNull(); + expect(nameOf(42)).toBe(42); + expect(nameOf([' a '])).toEqual([' a ']); + }); +}); diff --git a/backend/src/common/decorators/trim-string.decorator.ts b/backend/src/common/decorators/trim-string.decorator.ts new file mode 100644 index 0000000..62f0057 --- /dev/null +++ b/backend/src/common/decorators/trim-string.decorator.ts @@ -0,0 +1,16 @@ +import { Transform, type TransformFnParams } from 'class-transformer'; + +/** + * 앞뒤 공백을 지운다 — DTO 변환(class-transformer) 단계라 검사(class-validator)는 지운 값으로 한다. + * 문자열이 아니면 그대로 두어 IsString · IsEmail 같은 검사가 막는다. 지우는 공백은 JS `trim()` 과 같다(NBSP · U+3000 포함 — 웹의 `.trim()` 과 같게 센다). + * 메일함 이름(dto/mailbox.dto.ts) · 메일 필터의 이름 · 찾을 글 · 전달 주소(dto/mail-filter.dto.ts)가 쓴다 + */ +export function TrimString(): PropertyDecorator { + return Transform(trimStringValue); +} + +/** TrimString 의 변환 — 문자열만 앞뒤 공백을 지운다 */ +export function trimStringValue({ value }: TransformFnParams): unknown { + const raw: unknown = value; + return typeof raw === 'string' ? raw.trim() : raw; +} diff --git a/backend/src/common/errors/error-codes.spec.ts b/backend/src/common/errors/error-codes.spec.ts index a419aa1..6d90089 100644 --- a/backend/src/common/errors/error-codes.spec.ts +++ b/backend/src/common/errors/error-codes.spec.ts @@ -59,6 +59,11 @@ describe('에러 코드 레지스트리', () => { expect(ERROR_CODES.MAIL_011.status).toBe(HttpStatus.SERVICE_UNAVAILABLE); }); + it('메일 필터 — 그사이 규칙이 바뀜은 409 MAIL_012, 만든 엔진 규칙이 엔진 한도를 넘음은 400 MAIL_013', () => { + expect(ERROR_CODES.MAIL_012.status).toBe(HttpStatus.CONFLICT); + expect(ERROR_CODES.MAIL_013.status).toBe(HttpStatus.BAD_REQUEST); + }); + it('클라이언트 전용 코드(AUTH_002)는 서버가 상태 역매핑으로 선택하지 않는다', () => { const clientOnlyCodes = Object.entries(ERROR_CODES) .filter(([, definition]) => 'clientOnly' in definition) diff --git a/backend/src/common/errors/error-codes.ts b/backend/src/common/errors/error-codes.ts index 3e04738..62df90a 100644 --- a/backend/src/common/errors/error-codes.ts +++ b/backend/src/common/errors/error-codes.ts @@ -148,6 +148,19 @@ const DOMAIN_ERROR_CODES = { message: '받는 쪽이 지금은 메일을 받을 수 없습니다. 잠시 뒤 다시 보내 주세요.', }, + // 필터 저장(PUT /api/mail-filters) 때 그사이 다른 곳(다른 탭 · PC)에서 규칙이 바뀌었다 — 저장하지 않았다. + // 화면은 목록 위 줄 + [다시 불러오기] 로 알린다 (계약 2절 "메일 필터"). 부재중 응답만 바뀐 것은 걸리지 않는다 + MAIL_012: { + status: HttpStatus.CONFLICT, + message: + '다른 곳에서 필터가 바뀌어 저장하지 않았습니다. 다시 불러온 뒤 고쳐 주세요.', + }, + // 필터 · 부재중 응답으로 만든 엔진 규칙(Sieve 스크립트)이 엔진 한도를 넘는다 — 저장하지 않았다 (계약 2절 "메일 필터"의 크기) + MAIL_013: { + status: HttpStatus.BAD_REQUEST, + message: + '필터가 너무 길어 저장할 수 없습니다. 조건을 줄이거나 쓰지 않는 필터를 지워 주세요.', + }, } as const satisfies Record; export const ERROR_CODES = { diff --git a/backend/src/modules/mail/dto/mail-filter.dto.spec.ts b/backend/src/modules/mail/dto/mail-filter.dto.spec.ts new file mode 100644 index 0000000..002c944 --- /dev/null +++ b/backend/src/modules/mail/dto/mail-filter.dto.spec.ts @@ -0,0 +1,249 @@ +import { plainToInstance } from 'class-transformer'; +import { validate, type ValidationError } from 'class-validator'; + +import { + MAX_MAIL_FILTER_CONDITIONS, + MAX_MAIL_FILTER_NAME_LENGTH, + MAX_MAIL_FILTER_RULES, + MAX_MAIL_FILTER_VALUE_LENGTH, +} from '@/modules/mail/mail.constants'; +import { MailFilterUpdateDto } from './mail-filter.dto'; + +/** 걸린 규칙 이름 — 중첩된 칸(rules.0.conditions.1.value 등)까지 */ +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: MailFilterUpdateDto }> { + const dto = plainToInstance(MailFilterUpdateDto, 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, + forwardTo: null, +}; +const rule = { + id: '0b9a4f9e-7c2d-4d3a-9f4e-1a2b3c4d5e6f', + name: '거래처 메일', + isEnabled: true, + match: 'any', + conditions: [condition], + actions, +}; +const VERSION = 'Cbd1Lkq0H3S7mJ5yV0w2qg8qkQvJ8sB7bW2nZ1y3u9E'; +const body = (overrides: object = {}) => ({ + rules: [rule], + version: VERSION, + ...overrides, +}); +/** 규칙 하나를 바꾼 본문 */ +const withRule = (overrides: object) => + body({ rules: [{ ...rule, ...overrides }] }); +const withCondition = (overrides: object) => + withRule({ conditions: [{ ...condition, ...overrides }] }); +const withActions = (overrides: object) => + withRule({ actions: { ...actions, ...overrides } }); + +describe('메일 필터 저장 본문 (PUT /api/mail-filters)', () => { + it('맞는 본문 — 빈 목록 · 모든 메일(조건 없음) · 전달만 하는 규칙도 형식은 맞다', async () => { + expect(await namesOf(body())).toEqual([]); + expect(await namesOf(body({ rules: [] }))).toEqual([]); + expect( + await namesOf( + withRule({ + match: 'always', + conditions: [], + actions: { + moveToMailboxId: null, + markAsRead: false, + flag: true, + forwardTo: 'doyun.lee@arasystem.it.kr', + }, + }), + ), + ).toEqual([]); + }); + + it('이름 · 찾을 글 · 전달 주소는 앞뒤 공백을 지운 값으로 검사하고 그 값을 쓴다', async () => { + const { names, dto } = await check( + withRule({ + name: ` ${'가'.repeat(MAX_MAIL_FILTER_NAME_LENGTH)} `, + conditions: [ + { + ...condition, + value: `\u3000 ${'x'.repeat(MAX_MAIL_FILTER_VALUE_LENGTH)} `, + }, + ], + actions: { ...actions, forwardTo: ' doyun.lee@arasystem.it.kr ' }, + }), + ); + expect(names).toEqual([]); + expect(dto.rules[0].name).toBe('가'.repeat(MAX_MAIL_FILTER_NAME_LENGTH)); + expect(dto.rules[0].conditions[0].value).toBe( + 'x'.repeat(MAX_MAIL_FILTER_VALUE_LENGTH), + ); + expect(dto.rules[0].actions.forwardTo).toBe('doyun.lee@arasystem.it.kr'); + }); + + it('지문(version)은 서버가 준 모양만 — 빠짐 · 빈칸 · 너무 김은 형식 오류', async () => { + expect(await namesOf({ rules: [] })).toContain('isString'); + expect(await namesOf(body({ version: 'a b' }))).toEqual(['matches']); + expect(await namesOf(body({ version: 'x'.repeat(129) }))).toEqual([ + 'matches', + ]); + }); + + it(`규칙은 배열 · ${MAX_MAIL_FILTER_RULES}개까지 · 모르는 필드는 받지 않는다(isActive 포함) — 겹친 id 는 서비스가 본다`, async () => { + expect(await namesOf(body({ rules: rule }))).toContain('isArray'); + const many = Array.from({ length: MAX_MAIL_FILTER_RULES + 1 }, (_, i) => ({ + ...rule, + id: `r${i}`, + })); + expect(await namesOf(body({ rules: many }))).toEqual(['arrayMaxSize']); + expect(await namesOf(body({ rules: many.slice(1) }))).toEqual([]); + // 겹친 id 는 형식이 아니라 목록의 규칙이다 — 서비스가 findFilterRulesProblem(Set, 항목 수에 비례)으로 VAL_001. + // 요청 검증의 ArrayUnique 는 항목 수의 제곱이라 쓰지 않는다 (mail-filters.spec.ts · mail.service.spec.ts) + expect(await namesOf(body({ rules: [rule, rule] }))).toEqual([]); + expect(await namesOf(body({ isActive: true }))).toEqual([ + 'whitelistValidation', + ]); + expect(await namesOf(withRule({ priority: 1 }))).toEqual([ + 'whitelistValidation', + ]); + expect(await namesOf(body({ rules: [null] }))).not.toEqual([]); + }); + + it('규칙의 칸 — id 형식 · 이름 길이와 제어 문자 · 켬은 불리언 · 묶는 방법', async () => { + expect(await namesOf(withRule({ id: '규칙 1' }))).toEqual(['matches']); + expect(await namesOf(withRule({ id: 'x'.repeat(65) }))).toEqual([ + 'matches', + ]); + expect( + await namesOf( + withRule({ name: 'x'.repeat(MAX_MAIL_FILTER_NAME_LENGTH + 1) }), + ), + ).toEqual(['maxCodeUnits']); + expect(await namesOf(withRule({ name: '거래처\t메일' }))).toEqual([ + 'matches', + ]); + expect(await namesOf(withRule({ name: null }))).toContain('isString'); + expect(await namesOf(withRule({ isEnabled: 'true' }))).toEqual([ + 'isBoolean', + ]); + expect(await namesOf(withRule({ match: 'none' }))).toEqual(['isIn']); + }); + + it(`조건 — 배열 · ${MAX_MAIL_FILTER_CONDITIONS}개까지, 견줄 곳 · 포함 여부는 정해진 값만`, async () => { + expect(await namesOf(withRule({ conditions: condition }))).toContain( + 'isArray', + ); + expect( + await namesOf( + withRule({ + conditions: Array.from( + { length: MAX_MAIL_FILTER_CONDITIONS + 1 }, + () => condition, + ), + }), + ), + ).toEqual(['arrayMaxSize']); + expect(await namesOf(withCondition({ field: 'cc' }))).toEqual(['isIn']); + expect(await namesOf(withCondition({ operator: 'is' }))).toEqual(['isIn']); + expect(await namesOf(withCondition({ regex: '.*' }))).toEqual([ + 'whitelistValidation', + ]); + }); + + it(`찾을 글 — 앞뒤 공백을 빼고 1~${MAX_MAIL_FILTER_VALUE_LENGTH}자(JS 문자열 길이) · 제어 문자 없음 · \${ 없음`, async () => { + for (const value of ['', ' ', ' \u3000\u00a0 ']) { + expect(await namesOf(withCondition({ value }))).toEqual(['minCodeUnits']); + } + expect( + await namesOf( + withCondition({ value: 'x'.repeat(MAX_MAIL_FILTER_VALUE_LENGTH + 1) }), + ), + ).toEqual(['maxCodeUnits']); + // 이모지는 두 자로 센다 (웹의 .length 와 같다) + expect( + await namesOf( + withCondition({ + value: `${'🌴'.repeat(MAX_MAIL_FILTER_VALUE_LENGTH / 2)}x`, + }), + ), + ).toEqual(['maxCodeUnits']); + for (const value of ['a\nb', 'a\u0000', 'a\u007f', 'a\u0085']) { + expect(await namesOf(withCondition({ value }))).toEqual(['matches']); + } + for (const value of ['${x}', '광고${', '${unicode:41}']) { + expect(await namesOf(withCondition({ value }))).toEqual([ + 'noSieveVariable', + ]); + } + // $ · { 하나씩 · 따옴표 · 역슬래시는 받는다 (엔진 규칙에서 이스케이프한다) + expect(await namesOf(withCondition({ value: '"$5{0}" \\ 견적' }))).toEqual( + [], + ); + expect(await namesOf(withCondition({ value: 42 }))).toContain('isString'); + }); + + it('할 일 — 객체여야 한다. 옮길 메일함은 id 형식이거나 null, 읽음 · 중요는 불리언 (키가 빠지면 형식 오류)', async () => { + expect(await namesOf(withRule({ actions: undefined }))).toContain( + 'isObject', + ); + expect(await namesOf(withRule({ actions: 'move' }))).toContain('isObject'); + expect(await namesOf(withActions({ moveToMailboxId: 'a/b' }))).toEqual([ + 'matches', + ]); + const withoutMove: Record = { ...actions }; + delete withoutMove.moveToMailboxId; + expect(await namesOf(withRule({ actions: withoutMove }))).toEqual([ + 'matches', + ]); + expect(await namesOf(withActions({ markAsRead: 1 }))).toEqual([ + 'isBoolean', + ]); + expect(await namesOf(withActions({ flag: null }))).toEqual(['isBoolean']); + expect(await namesOf(withActions({ copy: true }))).toEqual([ + 'whitelistValidation', + ]); + }); + + it('전달 주소 — 메일 주소 형식 · 254자까지 · ${ 없음 · null 은 없음 (키가 빠지면 형식 오류)', async () => { + expect(await namesOf(withActions({ forwardTo: 'doyun.lee' }))).toContain( + 'isEmail', + ); + expect( + await namesOf( + withActions({ forwardTo: `${'a'.repeat(250)}@arasystem.it.kr` }), + ), + ).not.toEqual([]); + expect( + await namesOf(withActions({ forwardTo: '${x}@arasystem.it.kr' })), + ).toContain('noSieveVariable'); + const withoutForward: Record = { ...actions }; + delete withoutForward.forwardTo; + expect(await namesOf(withRule({ actions: withoutForward }))).toContain( + 'isEmail', + ); + }); +}); diff --git a/backend/src/modules/mail/dto/mail-filter.dto.ts b/backend/src/modules/mail/dto/mail-filter.dto.ts new file mode 100644 index 0000000..96b201b --- /dev/null +++ b/backend/src/modules/mail/dto/mail-filter.dto.ts @@ -0,0 +1,269 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { Type } from 'class-transformer'; +import { + ArrayMaxSize, + IsArray, + IsBoolean, + IsEmail, + IsIn, + IsObject, + IsString, + Matches, + MaxLength, + ValidateBy, + ValidateIf, + ValidateNested, + type ValidationOptions, +} from 'class-validator'; + +import { + MaxCodeUnits, + MinCodeUnits, +} from '@/common/decorators/code-units.decorator'; +import { TrimString } from '@/common/decorators/trim-string.decorator'; +import { + MAIL_FILTER_FIELDS, + MAIL_FILTER_MATCHES, + MAIL_FILTER_OPERATORS, + hasSieveVariable, +} from '@/modules/mail/mail-filters'; +import { + MAIL_FILTER_RULE_ID_PATTERN, + MAIL_FILTER_VERSION_PATTERN, + MAIL_ID_PATTERN, + MAX_EMAIL_LENGTH, + MAX_MAIL_FILTER_CONDITIONS, + MAX_MAIL_FILTER_NAME_LENGTH, + MAX_MAIL_FILTER_RULES, + MAX_MAIL_FILTER_VALUE_LENGTH, + SINGLE_LINE_TEXT_PATTERN, +} from '@/modules/mail/mail.constants'; +import type { + MailFilterActions, + MailFilterCondition, + MailFilterField, + MailFilterMatch, + MailFilterOperator, + MailFilterRule, + MailFilterSet, +} from '@/modules/mail/mail.types'; + +/** + * 메일 필터 — PUT 본문(`{ rules, version }` 전체 교체)과 GET · PUT 응답(MailFilterSet) (계약 2절 "메일 필터"). + * 여기서는 칸마다 형식을 본다. 칸 사이의 규칙(묶는 방법 ↔ 조건 수 · 할 일 하나 이상)과 전달 주소(사내 · 자기 주소 아님) · + * 옮길 메일함(보낸편지함 · 임시보관함 아님)은 서비스가 본다 (모두 400 VAL_001). + * 글은 앞뒤 공백을 지운 값으로 검사하고 그 값으로 저장한다. + */ + +/** null 은 "없음"으로 받는다 — 키가 없으면(undefined) 형식 오류다 (전체 교체라 모든 필드를 보낸다) */ +const unlessNull = (_object: object, value: unknown) => value !== null; + +/** 엔진이 변수로 펼칠 `${` 가 없다 — 펼쳐져 빈 값이 되면 `:contains` 가 모든 메일에 맞는다 (계약 2절 "만드는 Sieve") */ +function NoSieveVariable(options: ValidationOptions) { + return ValidateBy( + { + name: 'noSieveVariable', + validator: { + validate: (value: unknown) => + typeof value === 'string' && !hasSieveVariable(value), + }, + }, + options, + ); +} + +export class MailFilterConditionDto implements MailFilterCondition { + @ApiProperty({ + description: + '견줄 곳 — from 보낸 사람(이름 · 주소) · to 받는 사람(받는 사람 · 참조의 이름 · 주소) · subject 제목 · body 본문(글자 부분)', + enum: [...MAIL_FILTER_FIELDS], + example: 'from', + }) + @IsIn(MAIL_FILTER_FIELDS, { message: '조건의 견줄 곳을 다시 골라 주세요.' }) + field!: MailFilterField; + + @ApiProperty({ + description: '포함 · 포함하지 않음 — 대소문자는 영문만 가리지 않는다', + enum: [...MAIL_FILTER_OPERATORS], + example: 'contains', + }) + @IsIn(MAIL_FILTER_OPERATORS, { + message: '조건의 포함 · 포함하지 않음을 다시 골라 주세요.', + }) + operator!: MailFilterOperator; + + @ApiProperty({ + description: + `찾을 글 — 앞뒤 공백을 뺀 1~${MAX_MAIL_FILTER_VALUE_LENGTH}자(JS 문자열 길이, 앞뒤 공백은 지워 저장한다). ` + + '제어 문자(줄바꿈 · 탭 등)와 "${" 는 쓸 수 없다', + example: '@hanbit-partners.co.kr', + minLength: 1, + maxLength: MAX_MAIL_FILTER_VALUE_LENGTH, + }) + @TrimString() + @IsString({ message: '찾을 글 형식이 올바르지 않습니다.' }) + @MinCodeUnits(1, { message: '찾을 글을 입력해 주세요.' }) + @MaxCodeUnits(MAX_MAIL_FILTER_VALUE_LENGTH, { + message: `찾을 글은 ${MAX_MAIL_FILTER_VALUE_LENGTH}자 이하로 입력해 주세요.`, + }) + @Matches(SINGLE_LINE_TEXT_PATTERN, { + message: '찾을 글에는 줄바꿈 같은 제어 문자를 쓸 수 없습니다.', + }) + @NoSieveVariable({ message: '찾을 글에는 ${ 를 쓸 수 없습니다.' }) + value!: string; +} + +export class MailFilterActionsDto implements MailFilterActions { + @ApiProperty({ + description: + '옮길 메일함 id (없으면 null) — 받은편지함 · 스팸메일함 · 휴지통 · 내 메일함 (보낸편지함 · 임시보관함은 400 VAL_001). ' + + '없는 id 도 받는다 — 그 메일함이 없으면 받은편지함에 들어온다. 옮기는 규칙에 맞으면 아래 규칙은 보지 않는다', + nullable: true, + type: String, + example: 'j', + }) + @ValidateIf(unlessNull) + @Matches(MAIL_ID_PATTERN, { message: '옮길 메일함을 다시 골라 주세요.' }) + moveToMailboxId!: string | null; + + @ApiProperty({ description: '읽음으로 표시', example: false }) + @IsBoolean({ message: '읽음으로 표시할지 다시 골라 주세요.' }) + markAsRead!: boolean; + + @ApiProperty({ description: '중요 표시', example: true }) + @IsBoolean({ message: '중요 표시할지 다시 골라 주세요.' }) + flag!: boolean; + + @ApiProperty({ + description: + '전달할 사내 주소(MAIL_DOMAIN — 없으면 null). 자기 주소는 안 된다(400 VAL_001). 사본은 늘 남긴다. ' + + '메일 엔진은 한 통에 한 곳만 전달한다 — 맞는 규칙이 여럿이면 두 번째 전달은 엔진이 버린다', + nullable: true, + type: String, + example: 'doyun.lee@arasystem.it.kr', + }) + @ValidateIf(unlessNull) + @TrimString() + @IsEmail({}, { message: '전달할 주소 형식이 올바르지 않습니다.' }) + @MaxLength(MAX_EMAIL_LENGTH, { message: '전달할 주소가 너무 깁니다.' }) + @NoSieveVariable({ message: '전달할 주소에는 ${ 를 쓸 수 없습니다.' }) + forwardTo!: string | null; +} + +export class MailFilterRuleDto implements MailFilterRule { + @ApiProperty({ + description: + '규칙 id — 클라이언트가 만든다(UUID, 영문 · 숫자 · - · _ 64자까지). 목록 안에서 겹치지 않는다', + example: '0b9a4f9e-7c2d-4d3a-9f4e-1a2b3c4d5e6f', + }) + @Matches(MAIL_FILTER_RULE_ID_PATTERN, { + message: '필터를 다시 만들어 주세요.', + }) + id!: string; + + @ApiProperty({ + description: `이름 — 앞뒤 공백을 뺀 0~${MAX_MAIL_FILTER_NAME_LENGTH}자(비면 화면이 조건으로 이름을 보인다). 제어 문자는 쓸 수 없다`, + example: '거래처 메일', + maxLength: MAX_MAIL_FILTER_NAME_LENGTH, + }) + @TrimString() + @IsString({ message: '필터 이름 형식이 올바르지 않습니다.' }) + @MaxCodeUnits(MAX_MAIL_FILTER_NAME_LENGTH, { + message: `필터 이름은 ${MAX_MAIL_FILTER_NAME_LENGTH}자 이하로 입력해 주세요.`, + }) + @Matches(SINGLE_LINE_TEXT_PATTERN, { + message: '필터 이름에는 줄바꿈 같은 제어 문자를 쓸 수 없습니다.', + }) + name!: string; + + @ApiProperty({ + description: '켜짐 — 꺼 둔 규칙은 목록에만 있고 적용하지 않는다', + example: true, + }) + @IsBoolean({ message: '필터를 켤지 다시 골라 주세요.' }) + isEnabled!: boolean; + + @ApiProperty({ + description: + '조건이 모두 맞음(all) · 하나라도 맞음(any) · 모든 메일(always — 조건 없음)', + enum: [...MAIL_FILTER_MATCHES], + example: 'any', + }) + @IsIn(MAIL_FILTER_MATCHES, { + message: '조건을 묶는 방법을 다시 골라 주세요.', + }) + match!: MailFilterMatch; + + @ApiProperty({ + description: `조건 — all · any 는 1~${MAX_MAIL_FILTER_CONDITIONS}개, always 는 빈 배열 (아니면 400 VAL_001)`, + type: [MailFilterConditionDto], + maxItems: MAX_MAIL_FILTER_CONDITIONS, + }) + @IsArray({ message: '조건 형식이 올바르지 않습니다.' }) + @ArrayMaxSize(MAX_MAIL_FILTER_CONDITIONS, { + message: `조건은 ${MAX_MAIL_FILTER_CONDITIONS}개까지 넣을 수 있습니다.`, + }) + @ValidateNested({ each: true }) + @Type(() => MailFilterConditionDto) + conditions!: MailFilterConditionDto[]; + + @ApiProperty({ + description: + '할 일 — 하나 이상 (아니면 400 VAL_001). 맞으면 표시(읽음 · 중요) → 전달 → 옮기기 차례로 한다', + type: MailFilterActionsDto, + }) + @IsObject({ message: '할 일 형식이 올바르지 않습니다.' }) + @ValidateNested() + @Type(() => MailFilterActionsDto) + actions!: MailFilterActionsDto; +} + +/** PUT /api/mail-filters — 규칙 전체 교체 + 고치기 시작할 때 받은 지문 */ +export class MailFilterUpdateDto { + @ApiProperty({ + description: `규칙 — 위에서부터 차례로 적용한다. ${MAX_MAIL_FILTER_RULES}개까지, id 가 겹치지 않는다`, + type: [MailFilterRuleDto], + maxItems: MAX_MAIL_FILTER_RULES, + }) + @IsArray({ message: '필터 목록 형식이 올바르지 않습니다.' }) + @ArrayMaxSize(MAX_MAIL_FILTER_RULES, { + message: `필터는 ${MAX_MAIL_FILTER_RULES}개까지 만들 수 있습니다.`, + }) + @ValidateNested({ each: true }) + @Type(() => MailFilterRuleDto) + rules!: MailFilterRuleDto[]; + + @ApiProperty({ + description: + '고치기 시작할 때 받은 version 그대로 — 그사이 다른 곳에서 규칙이 바뀌었으면 409 MAIL_012 (저장하지 않는다)', + example: 'Cbd1Lkq0H3S7mJ5yV0w2qg8qkQvJ8sB7bW2nZ1y3u9E', + }) + @IsString({ message: '필터를 다시 불러온 뒤 저장해 주세요.' }) + @Matches(MAIL_FILTER_VERSION_PATTERN, { + message: '필터를 다시 불러온 뒤 저장해 주세요.', + }) + version!: string; +} + +/** GET · PUT /api/mail-filters 의 응답 */ +export class MailFilterSetDto implements MailFilterSet { + @ApiProperty({ + description: `규칙 — 위에서부터 차례로 적용한다 (${MAX_MAIL_FILTER_RULES}개까지)`, + type: [MailFilterRuleDto], + }) + rules!: MailFilterRuleDto[]; + + @ApiProperty({ + description: + '지금 규칙의 지문 — 해석하지 말고 PUT 에 그대로 돌려보낸다. 규칙만 본다(부재중 자동 응답이 바뀌어도 그대로다)', + example: 'Cbd1Lkq0H3S7mJ5yV0w2qg8qkQvJ8sB7bW2nZ1y3u9E', + }) + version!: string; + + @ApiProperty({ + description: + 'false = 메일 엔진에서 다른 규칙이 켜져 지금은 필터 · 부재중 자동 응답이 동작하지 않는다(드묾) — 지금 목록 그대로 저장하면 다시 켜진다', + example: true, + }) + isActive!: boolean; +} diff --git a/backend/src/modules/mail/dto/mailbox.dto.ts b/backend/src/modules/mail/dto/mailbox.dto.ts index 1068766..a4731b9 100644 --- a/backend/src/modules/mail/dto/mailbox.dto.ts +++ b/backend/src/modules/mail/dto/mailbox.dto.ts @@ -1,12 +1,12 @@ import { applyDecorators } from '@nestjs/common'; import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; -import { Transform, type TransformFnParams } from 'class-transformer'; import { Equals, IsOptional, IsString, Matches } from 'class-validator'; import { MaxCodeUnits, MinCodeUnits, } from '@/common/decorators/code-units.decorator'; +import { TrimString } from '@/common/decorators/trim-string.decorator'; import { MAIL_ID_PATTERN, MAX_MAILBOX_NAME_LENGTH, @@ -16,12 +16,6 @@ import { /** 메일함 이름에 쓸 수 없는 '/' — 경로 구분자로 읽는 메일 프로그램이 있다 (계약 2절 "메일함 관리") */ const NO_SLASH_PATTERN = /^[^/]*$/; -/** 앞뒤 공백을 지운다 — 문자열이 아니면 그대로 두어 IsString 이 막는다 */ -function trimString({ value }: TransformFnParams): unknown { - const raw: unknown = value; - return typeof raw === 'string' ? raw.trim() : raw; -} - /** 메일함 이름 — 앞뒤 공백을 뺀 1~MAX_MAILBOX_NAME_LENGTH 자(JS 문자열 길이), `/` · 제어 문자 없음. 만들기 · 이름 바꾸기가 같이 쓴다 */ function MailboxName() { return applyDecorators( @@ -31,7 +25,7 @@ function MailboxName() { minLength: 1, maxLength: MAX_MAILBOX_NAME_LENGTH, }), - Transform(trimString), + TrimString(), IsString({ message: '메일함 이름 형식이 올바르지 않습니다.' }), MinCodeUnits(1, { message: '메일함 이름을 입력해 주세요.' }), MaxCodeUnits(MAX_MAILBOX_NAME_LENGTH, { diff --git a/backend/src/modules/mail/jmap/filter-source.spec.ts b/backend/src/modules/mail/jmap/filter-source.spec.ts new file mode 100644 index 0000000..1338e27 --- /dev/null +++ b/backend/src/modules/mail/jmap/filter-source.spec.ts @@ -0,0 +1,341 @@ +import { + MAIL_FILTER_VERSION_PATTERN, + MAX_MAIL_FILTER_RULES, +} from '@/modules/mail/mail.constants'; +import type { MailFilterRule, MailVacation } from '@/modules/mail/mail.types'; +import { + FILTER_SOURCE_FORMAT, + FILTER_SOURCE_MARKER, + filterRulesVersion, + filterSourceLine, + readFilterSource, + type FilterScriptSource, +} from './filter-source'; + +const rule = ( + id: string, + extra: Partial = {}, +): MailFilterRule => ({ + id, + name: '거래처', + isEnabled: true, + match: 'any', + conditions: [ + { field: 'from', operator: 'contains', value: '@hanbit-partners.co.kr' }, + { field: 'subject', operator: 'notContains', value: '"광고" \\ {중괄호}' }, + ], + actions: { + moveToMailboxId: 'j', + markAsRead: true, + flag: false, + forwardTo: 'doyun.lee@arasystem.test', + }, + ...extra, +}); + +const vacation: MailVacation = { + isEnabled: true, + fromDate: '2026-11-02T15:00:00.000Z', + toDate: null, + subject: '부재중 ${x} "따옴표"', + textBody: '첫 줄\r\n둘째 줄\n\t탭', +}; + +const source: FilterScriptSource = { + rules: [rule('r1'), rule('r2', { isEnabled: false, match: 'all' })], + vacation, +}; + +/** 원본 줄을 스크립트 머리에 둔 글 — 생성기가 쓰는 모양 (설명 주석 → 원본 → Sieve) */ +const scriptWith = (line: string, eol = '\r\n') => + ['# 설명', line, 'require ["imap4flags"];', 'keep;', ''].join(eol); + +/** 원본 JSON 을 직접 써서 넣는다 — 깨진 원본 · 다른 메일 프로그램이 고친 원본 */ +const scriptWithJson = (json: unknown) => + scriptWith(`${FILTER_SOURCE_MARKER}${JSON.stringify(json)}`); + +/** 이 판의 원본이면 그것, 아니면 null */ +const sourceOf = (script: string): FilterScriptSource | null => { + const reading = readFilterSource(script); + return reading.status === 'ok' ? reading.source : null; +}; + +const BROKEN = { status: 'broken' }; + +const stored = (overrides: Record = {}) => ({ + v: FILTER_SOURCE_FORMAT, + rules: source.rules, + vacation, + ...overrides, +}); + +describe('filterSourceLine · readFilterSource — 스크립트 머리의 원본(JSON)', () => { + it('써 둔 원본을 그대로 읽는다 — 규칙(꺼 둔 것 포함) · 부재중, 줄바꿈은 CRLF · LF 모두', () => { + for (const eol of ['\r\n', '\n']) { + expect( + readFilterSource(scriptWith(filterSourceLine(source), eol)), + ).toEqual({ status: 'ok', source }); + } + }); + + it('원본은 한 줄이다 — 글 안의 줄바꿈 · 탭은 JSON 이스케이프로, 판 번호와 함께', () => { + const line = filterSourceLine(source); + + expect(line.startsWith(FILTER_SOURCE_MARKER)).toBe(true); + expect(line).not.toMatch(/[\r\n\t]/); + expect(JSON.parse(line.slice(FILTER_SOURCE_MARKER.length))).toMatchObject({ + v: FILTER_SOURCE_FORMAT, + }); + }); + + it('정해진 키 순서로 쓴다 — 들어온 객체의 키 순서 · 다른 속성과 상관없다', () => { + const shuffled = { + actions: { + forwardTo: 'doyun.lee@arasystem.test', + flag: false, + markAsRead: true, + moveToMailboxId: 'j', + extra: 1, + }, + conditions: source.rules[0].conditions.map( + ({ value, operator, field }) => ({ value, operator, field }), + ), + match: 'any', + isEnabled: true, + name: '거래처', + id: 'r1', + unknown: '버린다', + } as unknown as MailFilterRule; + + expect( + filterSourceLine({ rules: [shuffled, source.rules[1]], vacation }), + ).toBe(filterSourceLine(source)); + }); + + it('원본 줄은 스크립트 맨 앞의 주석들에서만 찾는다 — Sieve 글 뒤에 있으면 없는 것', () => { + const line = filterSourceLine(source); + expect(sourceOf(['# 설명', line, 'keep;'].join('\r\n'))).toEqual(source); + expect(readFilterSource(['keep;', line].join('\r\n'))).toEqual(BROKEN); + expect(readFilterSource(['# 설명', 'keep;', line].join('\r\n'))).toEqual( + BROKEN, + ); + expect(readFilterSource('')).toEqual(BROKEN); + expect(readFilterSource('# 설명만')).toEqual(BROKEN); + }); + + it('부재중 날짜는 읽을 때 ISO 8601 UTC(소수 초 3자리)로 맞춘다', () => { + const read = sourceOf( + scriptWithJson( + stored({ + vacation: { + ...vacation, + fromDate: '2026-11-02T15:00:00Z', + toDate: '2026-11-07T15:00:00Z', + }, + }), + ), + ); + expect(read?.vacation).toMatchObject({ + fromDate: '2026-11-02T15:00:00.000Z', + toDate: '2026-11-07T15:00:00.000Z', + }); + }); + + it('부재중은 모양만 본다 — 엔진의 VacationResponse 에서 가져와 우리 한도보다 긴 글도 깨진 것이 아니다', () => { + const long = { + ...vacation, + subject: 'x'.repeat(511), + textBody: 'y'.repeat(2_047), + }; + expect( + sourceOf(scriptWithJson(stored({ vacation: long })))?.vacation, + ).toEqual(long); + }); + + it('짝 없는 서로게이트도 쓴 그대로 돌아온다 — 원본 JSON 은 \\ud800 꼴(ASCII), 엔진에 올리는 UTF-8 에서는 Sieve 쪽만 U+FFFD', () => { + const lonely = { + rules: [ + rule('s1', { + name: '반쪽\ud800글자', + conditions: [ + { field: 'subject', operator: 'contains', value: 'a\udc00b' }, + ], + }), + ], + vacation: { ...vacation, subject: '부재\ud83d중' }, + }; + const line = filterSourceLine(lonely); + expect(line).toContain('반쪽\\ud800글자'); + expect(line).toContain('a\\udc00b'); + // 엔진은 UTF-8 로 받는다 — 원본 줄은 그 왕복에도 그대로다 + const uploaded = Buffer.from(scriptWith(line), 'utf8').toString('utf8'); + expect(sourceOf(uploaded)).toEqual(lonely); + }); + + it('새 판(이 서버의 판보다 큰 정수)은 깨짐과 나눈다 — 나머지 모양은 보지 않는다', () => { + expect( + readFilterSource(scriptWithJson(stored({ v: FILTER_SOURCE_FORMAT + 1 }))), + ).toEqual({ status: 'newer', format: FILTER_SOURCE_FORMAT + 1 }); + expect( + readFilterSource( + scriptWithJson({ v: FILTER_SOURCE_FORMAT + 5, entries: '새 모양' }), + ), + ).toEqual({ status: 'newer', format: FILTER_SOURCE_FORMAT + 5 }); + // 정수가 아니거나 · 판이 아닌 값 · 옛 판(0 이하)은 깨진 원본이다 — 저장하면 덮어쓴다 + for (const v of [ + FILTER_SOURCE_FORMAT + 0.5, + `${FILTER_SOURCE_FORMAT + 1}`, + 0, + -1, + null, + Number.MAX_VALUE, + ]) { + expect(readFilterSource(scriptWithJson(stored({ v })))).toEqual(BROKEN); + } + }); + + describe('깨진 원본은 broken — 부르는 쪽이 빈 규칙 + 동작하지 않음으로 다루고, 저장하면 덮어쓴다', () => { + it.each([ + ['표지 없음', '# 다른 프로그램이 만든 스크립트\r\nkeep;\r\n'], + ['JSON 이 아님', scriptWith(`${FILTER_SOURCE_MARKER}{"v":1,`)], + ['판 없음', scriptWithJson(stored({ v: undefined }))], + ['규칙이 배열이 아님', scriptWithJson(stored({ rules: {} }))], + ['부재중 없음', scriptWithJson(stored({ vacation: undefined }))], + [ + '부재중 날짜를 읽을 수 없음', + scriptWithJson(stored({ vacation: { ...vacation, toDate: '내일' } })), + ], + [ + '부재중 켬이 불리언이 아님', + scriptWithJson(stored({ vacation: { ...vacation, isEnabled: 'yes' } })), + ], + ])('%s', (_label, script) => { + expect(readFilterSource(script)).toEqual(BROKEN); + }); + + it.each<[string, (r: MailFilterRule) => unknown]>([ + [ + '모르는 견줄 곳', + (r) => ({ ...r, conditions: [{ ...r.conditions[0], field: 'cc' }] }), + ], + ['모르는 묶는 방법', (r) => ({ ...r, match: 'none' })], + ['할 일 없음', (r) => ({ ...r, actions: undefined })], + [ + '할 일이 하나도 없음', + (r) => ({ + ...r, + actions: { + moveToMailboxId: null, + markAsRead: false, + flag: false, + forwardTo: null, + }, + }), + ], + [ + '찾을 글에 ${', + (r) => ({ ...r, conditions: [{ ...r.conditions[0], value: '${x}' }] }), + ], + [ + '찾을 글이 200자 초과', + (r) => ({ + ...r, + conditions: [{ ...r.conditions[0], value: 'x'.repeat(201) }], + }), + ], + [ + '찾을 글에 줄바꿈', + (r) => ({ ...r, conditions: [{ ...r.conditions[0], value: 'a\nb' }] }), + ], + [ + '조건 6개', + (r) => ({ + ...r, + conditions: Array.from({ length: 6 }, () => r.conditions[0]), + }), + ], + ['모든 메일인데 조건이 있음', (r) => ({ ...r, match: 'always' })], + ['모두인데 조건이 없음', (r) => ({ ...r, match: 'all', conditions: [] })], + [ + '전달 주소에 ${', + (r) => ({ + ...r, + actions: { ...r.actions, forwardTo: '${x}@arasystem.test' }, + }), + ], + [ + '전달 주소 모양이 아님', + (r) => ({ ...r, actions: { ...r.actions, forwardTo: 'a b@c' } }), + ], + [ + '메일함 id 형식', + (r) => ({ ...r, actions: { ...r.actions, moveToMailboxId: 'a"b' } }), + ], + ['id 형식', (r) => ({ ...r, id: '규칙 하나' })], + ['이름 51자', (r) => ({ ...r, name: 'x'.repeat(51) })], + ])('규칙이 계약에 맞지 않음 — %s', (_label, tamper) => { + const rules = [tamper(source.rules[0]), source.rules[1]]; + expect(readFilterSource(scriptWithJson(stored({ rules })))).toEqual( + BROKEN, + ); + }); + + it('규칙이 50개를 넘거나 id 가 겹침', () => { + const many = Array.from({ length: MAX_MAIL_FILTER_RULES + 1 }, (_, i) => + rule(`r${i}`), + ); + expect(readFilterSource(scriptWithJson(stored({ rules: many })))).toEqual( + BROKEN, + ); + expect( + readFilterSource( + scriptWithJson(stored({ rules: [rule('same'), rule('same')] })), + ), + ).toEqual(BROKEN); + expect( + sourceOf(scriptWithJson(stored({ rules: many.slice(1) }))), + ).not.toBeNull(); + }); + }); +}); + +describe('filterRulesVersion — 규칙의 지문(version)', () => { + const rules = source.rules; + + it('같은 규칙이면 같은 값 — base64url 이라 요청 검증의 형식에 맞는다', () => { + const version = filterRulesVersion(rules); + expect(filterRulesVersion(rules.map((r) => ({ ...r })))).toBe(version); + expect(version).toMatch(MAIL_FILTER_VERSION_PATTERN); + expect(filterRulesVersion([])).toMatch(MAIL_FILTER_VERSION_PATTERN); + }); + + it('규칙 하나라도 바뀌면 달라진다 — 순서 · 켜기 · 찾을 글 · 할 일 · 이름', () => { + const version = filterRulesVersion(rules); + const changed: MailFilterRule[][] = [ + [rules[1], rules[0]], + [{ ...rules[0], isEnabled: false }, rules[1]], + [ + { + ...rules[0], + conditions: [{ ...rules[0].conditions[0], value: '@other.co.kr' }], + }, + rules[1], + ], + [{ ...rules[0], actions: { ...rules[0].actions, flag: true } }, rules[1]], + [{ ...rules[0], name: '거래처 메일' }, rules[1]], + [rules[0]], + [], + ]; + const versions = changed.map(filterRulesVersion); + expect(versions).not.toContain(version); + expect(new Set(versions).size).toBe(versions.length); + }); + + it('키 순서 · 다른 속성과 상관없다 — 원본 줄과 같은 정해진 모양으로 센다', () => { + const withExtra = rules.map((r) => ({ + ...r, + extra: '무시', + })) as unknown as MailFilterRule[]; + expect(filterRulesVersion(withExtra)).toBe(filterRulesVersion(rules)); + }); +}); diff --git a/backend/src/modules/mail/jmap/filter-source.ts b/backend/src/modules/mail/jmap/filter-source.ts new file mode 100644 index 0000000..57660e6 --- /dev/null +++ b/backend/src/modules/mail/jmap/filter-source.ts @@ -0,0 +1,294 @@ +import { createHash } from 'crypto'; + +import { + MAIL_FILTER_FIELDS, + MAIL_FILTER_MATCHES, + MAIL_FILTER_OPERATORS, + findFilterRulesProblem, +} from '@/modules/mail/mail-filters'; +import { MAX_MAIL_FILTER_RULES } from '@/modules/mail/mail.constants'; +import type { + MailFilterActions, + MailFilterCondition, + MailFilterRule, + MailVacation, +} from '@/modules/mail/mail.types'; +import { toUtcIso } from './jmap.mapper'; + +/** + * 엔진 규칙(Sieve 스크립트) 머리의 원본 — 규칙과 부재중 자동 응답을 JSON 한 줄로 주석에 둔다 (계약 2절 "메일 필터"의 저장 자리). + * + * - 읽을 때는 이것만 믿는다 — 엔진의 Sieve 를 다시 해석하지 않는다 (읽은 결과는 FilterSourceReading) + * - 주석(`#`)은 줄 끝(CRLF)까지라 JSON 이 한 줄이어야 한다 — JSON.stringify 는 글 안의 줄바꿈 · 제어 문자를 `\n` · `\u0000` 꼴로 쓴다. + * 짝 없는 서로게이트도 `\ud800` 꼴로 쓴다 — 원본은 쓴 그대로 돌아온다(Sieve 글 쪽은 UTF-8 로 올릴 때 U+FFFD 가 된다). + * 주석 안의 `${` · 따옴표 · 역슬래시 · 한국어는 엔진이 건드리지 않는다 (97,216바이트짜리 주석 한 줄도 validate 통과 — 2026-10-28 확인) + * - 원본 줄은 스크립트 맨 앞의 주석들 가운데에만 둔다 — 그 아래의 Sieve 글은 보지 않는다 + */ + +/** 원본 줄의 표지 — 이 뒤가 JSON 이다. 판을 올려도 이 표지와 JSON 의 `v` 는 지킨다 (옛 서버가 새 판인 줄 알아야 한다) */ +export const FILTER_SOURCE_MARKER = '# arasystem-source: '; + +/** + * 원본 모양의 판 — 모양을 바꾸면 올린다. + * 이보다 큰 판(새 판)은 깨진 원본과 나눠 다룬다(FilterSourceReading 의 `newer`): 새 판을 쓰는 서버를 배포했다 되돌리면 옛 서버가 그 원본을 읽는다 — + * 읽기는 빈 규칙 · 동작하지 않음으로 보이고, 쓰기(필터 · 부재중 모두)는 거절한다. 옛 서버가 모르는 규칙을 덮어써 알림 없이 잃지 않게 + */ +export const FILTER_SOURCE_FORMAT = 1; + +/** + * 스크립트 글에서 원본을 읽은 결과 + * - ok: 이 판의 원본 + * - broken: 원본이 없거나 깨졌다 — 표지 없음 · JSON 이 아님 · 판이 없음 · 모양이나 규칙이 계약에 어긋남(다른 메일 프로그램이 고친 경우 등). + * 부르는 쪽이 빈 규칙 + 동작하지 않음으로 다루고, 저장하면 덮어쓴다 + * - newer: 이 서버가 모르는 새 판 (FILTER_SOURCE_FORMAT 설명) — 읽기는 깨진 것처럼 보이되 쓰지 않는다 + */ +export type FilterSourceReading = + | { readonly status: 'ok'; readonly source: FilterScriptSource } + | { readonly status: 'broken' } + | { readonly status: 'newer'; readonly format: number }; + +/** 엔진 규칙 하나에 함께 두는 것 — 필터 규칙(차례대로)과 부재중 자동 응답 */ +export interface FilterScriptSource { + readonly rules: readonly MailFilterRule[]; + readonly vacation: MailVacation; +} + +/** 한 번도 설정하지 않은 부재중 자동 응답 (계약 2절 "부재중 자동 응답") */ +export const NEVER_CONFIGURED_VACATION: MailVacation = { + isEnabled: false, + fromDate: null, + toDate: null, + subject: '', + textBody: '', +}; + +/** 원본을 읽지 못할 때(깨짐 · 새 판) 보이는 것 — 규칙 없음 · 부재중 꺼짐 (깨진 원본은 저장하면 덮어쓴다 · 새 판은 쓰지 않는다) */ +export const EMPTY_FILTER_SOURCE: FilterScriptSource = { + rules: [], + vacation: NEVER_CONFIGURED_VACATION, +}; + +/** 정해진 키 순서의 사본 — 들어온 객체(DTO 등)의 키 순서 · 다른 속성과 상관없이 같은 규칙이면 같은 JSON 이 된다 */ +function canonicalRule(rule: MailFilterRule): MailFilterRule { + const { moveToMailboxId, markAsRead, flag, forwardTo } = rule.actions; + return { + id: rule.id, + name: rule.name, + isEnabled: rule.isEnabled, + match: rule.match, + conditions: rule.conditions.map(({ field, operator, value }) => ({ + field, + operator, + value, + })), + actions: { moveToMailboxId, markAsRead, flag, forwardTo }, + }; +} + +function canonicalVacation(vacation: MailVacation): MailVacation { + return { + isEnabled: vacation.isEnabled, + fromDate: vacation.fromDate, + toDate: vacation.toDate, + subject: vacation.subject, + textBody: vacation.textBody, + }; +} + +/** 같은 규칙이면 같은 사본 목록 — 저장 · 지문 · 응답이 이것을 쓴다 */ +export function canonicalRules( + rules: readonly MailFilterRule[], +): MailFilterRule[] { + return rules.map(canonicalRule); +} + +/** + * 규칙의 지문(계약의 version) — 정해진 키 순서의 JSON 을 SHA-256 으로, base64url. + * 규칙 부분만 본다 — 부재중 응답만 바뀐 것은 지문을 바꾸지 않는다. 직원의 글에서 나온 값이라 로그에 남기지 않는다 + */ +export function filterRulesVersion(rules: readonly MailFilterRule[]): string { + return createHash('sha256') + .update(JSON.stringify(canonicalRules(rules)), 'utf8') + .digest('base64url'); +} + +/** 원본 줄 — 표지 + JSON 한 줄 (판 · 규칙 · 부재중) */ +export function filterSourceLine(source: FilterScriptSource): string { + return `${FILTER_SOURCE_MARKER}${JSON.stringify({ + v: FILTER_SOURCE_FORMAT, + rules: canonicalRules(source.rules), + vacation: canonicalVacation(source.vacation), + })}`; +} + +/** 스크립트 맨 앞의 주석들에서 원본 줄을 찾는다 — 주석이 아닌 줄이 나오면 멈춘다 */ +function findSourceLine(script: string): string | null { + let start = 0; + while (start < script.length) { + const end = script.indexOf('\n', start); + const line = script + .slice(start, end === -1 ? script.length : end) + .replace(/\r$/, ''); + if (!line.startsWith('#')) return null; + if (line.startsWith(FILTER_SOURCE_MARKER)) return line; + if (end === -1) return null; + start = end + 1; + } + return null; +} + +type JsonRecord = Record; + +function isRecord(value: unknown): value is JsonRecord { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +function isOneOf( + value: unknown, + allowed: readonly T[], +): value is T { + return ( + typeof value === 'string' && (allowed as readonly string[]).includes(value) + ); +} + +const isNullableString = (value: unknown): value is string | null => + value === null || typeof value === 'string'; + +function toCondition(raw: unknown): MailFilterCondition | null { + if ( + !isRecord(raw) || + !isOneOf(raw.field, MAIL_FILTER_FIELDS) || + !isOneOf(raw.operator, MAIL_FILTER_OPERATORS) || + typeof raw.value !== 'string' + ) { + return null; + } + return { field: raw.field, operator: raw.operator, value: raw.value }; +} + +function toActions(raw: unknown): MailFilterActions | null { + if ( + !isRecord(raw) || + !isNullableString(raw.moveToMailboxId) || + typeof raw.markAsRead !== 'boolean' || + typeof raw.flag !== 'boolean' || + !isNullableString(raw.forwardTo) + ) { + return null; + } + return { + moveToMailboxId: raw.moveToMailboxId, + markAsRead: raw.markAsRead, + flag: raw.flag, + forwardTo: raw.forwardTo, + }; +} + +function toRule(raw: unknown): MailFilterRule | null { + if ( + !isRecord(raw) || + typeof raw.id !== 'string' || + typeof raw.name !== 'string' || + typeof raw.isEnabled !== 'boolean' || + !isOneOf(raw.match, MAIL_FILTER_MATCHES) || + !Array.isArray(raw.conditions) + ) { + return null; + } + const conditions: MailFilterCondition[] = []; + for (const entry of raw.conditions) { + const condition = toCondition(entry); + if (!condition) return null; + conditions.push(condition); + } + const actions = toActions(raw.actions); + if (!actions) return null; + return { + id: raw.id, + name: raw.name, + isEnabled: raw.isEnabled, + match: raw.match, + conditions, + actions, + }; +} + +/** 날짜 — null 이면 null, 읽을 수 있는 시각이면 ISO 8601 UTC(소수 초 3자리), 아니면 undefined(깨짐) */ +function readDate(raw: unknown): string | null | undefined { + if (raw === null) return null; + if (typeof raw !== 'string') return undefined; + return toUtcIso(raw) ?? undefined; +} + +/** + * 부재중 — 모양만 본다. 길이 한도는 보지 않는다: 저장해 둔 값은 4단계 요청 검증 · 엔진의 VacationResponse 한도 안에서 온 것이고 + * (VacationResponse 에서 가져온 값은 우리 한도보다 길 수 있다), Sieve 에는 base64 로 들어가 글이 문법을 깨지 않는다 + */ +function toVacation(raw: unknown): MailVacation | null { + if ( + !isRecord(raw) || + typeof raw.isEnabled !== 'boolean' || + typeof raw.subject !== 'string' || + typeof raw.textBody !== 'string' + ) { + return null; + } + const fromDate = readDate(raw.fromDate); + const toDate = readDate(raw.toDate); + if (fromDate === undefined || toDate === undefined) return null; + return { + isEnabled: raw.isEnabled, + fromDate, + toDate, + subject: raw.subject, + textBody: raw.textBody, + }; +} + +const BROKEN: FilterSourceReading = { status: 'broken' }; + +/** + * 스크립트 글에서 원본을 읽는다 (FilterSourceReading). + * 판(`v`)이 이 서버의 판보다 큰 정수면 나머지를 보지 않고 newer 다 — 모양이 달라졌을 수 있다. + * 같은 판이면 모양과 규칙을 계약대로 다시 본다(mail-filters.ts — 50개 · 겹친 id · 조건 수 · `${` 등) — 어긋나면 broken. + * 다시 보는 까닭: 다른 메일 프로그램(ManageSieve 등)이 고친 원본으로 Sieve 를 다시 만들면 엔진 한도 · 변수 펼침에 걸릴 수 있다 + */ +export function readFilterSource(script: string): FilterSourceReading { + const line = findSourceLine(script); + if (line === null) return BROKEN; + + let parsed: unknown; + try { + parsed = JSON.parse(line.slice(FILTER_SOURCE_MARKER.length)); + } catch { + return BROKEN; + } + if (!isRecord(parsed)) return BROKEN; + const format = parsed.v; + if ( + typeof format === 'number' && + Number.isSafeInteger(format) && + format > FILTER_SOURCE_FORMAT + ) { + return { status: 'newer', format }; + } + if ( + format !== FILTER_SOURCE_FORMAT || + !Array.isArray(parsed.rules) || + parsed.rules.length > MAX_MAIL_FILTER_RULES + ) { + return BROKEN; + } + + const rules: MailFilterRule[] = []; + for (const entry of parsed.rules) { + const rule = toRule(entry); + if (!rule) return BROKEN; + rules.push(rule); + } + if (findFilterRulesProblem(rules)) return BROKEN; + + const vacation = toVacation(parsed.vacation); + return vacation ? { status: 'ok', source: { rules, vacation } } : BROKEN; +} diff --git a/backend/src/modules/mail/jmap/jmap-filter-script.spec.ts b/backend/src/modules/mail/jmap/jmap-filter-script.spec.ts new file mode 100644 index 0000000..661f035 --- /dev/null +++ b/backend/src/modules/mail/jmap/jmap-filter-script.spec.ts @@ -0,0 +1,983 @@ +import { Logger } from '@nestjs/common'; + +import { BusinessException } from '@/common/errors/business.exception'; +import type { MailFilterRule, MailVacation } from '@/modules/mail/mail.types'; +import { + NEVER_CONFIGURED_VACATION, + filterRulesVersion, + readFilterSource, + type FilterScriptSource, +} from './filter-source'; +import type { JmapClient } from './jmap.client'; +import { + FILTER_SCRIPT_NAME, + JmapFilterScriptStore, + scriptErrorPosition, +} from './jmap-filter-script'; +import type { + JmapError, + JmapInvocation, + JmapVacationResponse, +} from './jmap.types'; +import { buildFilterScript } from './sieve-script'; + +const ARA = 'ara.kim@arasystem.test'; +const SIEVE = 'urn:ietf:params:jmap:sieve'; +const VACATION = 'urn:ietf:params:jmap:vacationresponse'; + +const rule = ( + id: string, + extra: Partial = {}, +): MailFilterRule => ({ + id, + name: '거래처', + isEnabled: true, + match: 'any', + conditions: [ + { field: 'from', operator: 'contains', value: '@hanbit-partners.co.kr' }, + ], + actions: { + moveToMailboxId: 'j', + markAsRead: true, + flag: false, + forwardTo: null, + }, + ...extra, +}); + +const vacationOn: MailVacation = { + isEnabled: true, + fromDate: '2026-11-02T15:00:00.000Z', + toDate: '2026-11-07T15:00:00.000Z', + subject: '부재중입니다', + textBody: '11월 7일까지 휴가입니다.', +}; + +interface FakeScript { + id: string; + name: string; + isActive: boolean; + blobId: string; +} + +/** 엔진이 받은 JMAP 요청 하나 */ +interface RecordedCall { + readonly calls: JmapInvocation[]; + readonly options: unknown; +} + +/** + * 대역 엔진 — Sieve 스크립트 · blob · 상태(ifInState)만 흉내 낸다 (RFC 9661 · Stalwart 0.16.27 에서 확인한 모양). + * 계정마다 켜진 스크립트는 하나다 — 켜면 다른 것은 꺼진다. + */ +class FakeSieveEngine { + state = 1; + scripts: FakeScript[] = []; + readonly blobs = new Map(); + vacation: JmapVacationResponse | null = null; + readonly recorded: RecordedCall[] = []; + /** 다음 SieveScript/set 을 처리하기 전에 한 번씩 부른다 — 그사이 다른 곳의 변경을 흉내 낸다 */ + readonly beforeSet: (() => void)[] = []; + /** 다음 SieveScript/set 의 응답을 대신한다 (null 이면 평소대로) */ + readonly setReplies: (( + args: Record, + ) => JmapInvocation | null)[] = []; + /** 다음 SieveScript/get 이 상태를 알려 주지 않는다 */ + hideState = false; + /** 다음 내려받기를 이만큼 404 로 */ + missingDownloads = 0; + /** 저장 뒤 같은 요청의 SieveScript/get 을 메서드 오류로 */ + failRereadAfterSet = false; + private sequence = 0; + + readonly client = { + call: jest.fn( + ( + _user: string, + build: (accountId: string) => JmapInvocation[], + options?: unknown, + ) => { + const calls = build('d'); + this.recorded.push({ calls, options }); + const afterSet = calls.some(([method]) => method === 'SieveScript/set'); + return Promise.resolve( + calls.map((call) => this.handle(call, afterSet)), + ); + }, + ), + upload: jest.fn(async (_user: string, content: Blob, type: string) => { + const text = await content.text(); + const blobId = this.nextId('blob'); + this.blobs.set(blobId, text); + return { blobId, type, size: content.size }; + }), + downloadBytes: jest.fn( + (_user: string, blobId: string, maxBytes: number) => { + const text = this.blobs.get(blobId); + if (this.missingDownloads > 0 || text === undefined) { + this.missingDownloads = Math.max(0, this.missingDownloads - 1); + return Promise.reject( + new BusinessException('RES_001', { + context: { reason: 'blob-not-found' }, + }), + ); + } + const bytes = Buffer.from(text, 'utf8'); + return Promise.resolve(bytes.length > maxBytes ? null : bytes); + }, + ), + }; + + readonly store = new JmapFilterScriptStore( + this.client as unknown as JmapClient, + ); + + /** 스크립트를 하나 둔다 (다른 메일 프로그램 · 앞선 저장이 만든 것) */ + addScript(name: string, text: string, isActive: boolean): FakeScript { + const blobId = this.nextId('blob'); + this.blobs.set(blobId, text); + if (isActive) for (const script of this.scripts) script.isActive = false; + const script = { id: this.nextId('sc'), name, isActive, blobId }; + this.scripts.push(script); + this.state += 1; + return script; + } + + /** 우리 스크립트를 이 원본으로 둔다 */ + addOurs(source: FilterScriptSource, isActive = true): FakeScript { + return this.addScript( + FILTER_SCRIPT_NAME, + buildFilterScript(source).text, + isActive, + ); + } + + /** 우리 스크립트의 지금 원본 */ + ourSource(): FilterScriptSource | null { + const ours = this.scripts.find( + (script) => script.name === FILTER_SCRIPT_NAME, + ); + if (!ours) return null; + const reading = readFilterSource(this.blobs.get(ours.blobId) ?? ''); + return reading.status === 'ok' ? reading.source : null; + } + + ours(): FakeScript | undefined { + return this.scripts.find((script) => script.name === FILTER_SCRIPT_NAME); + } + + /** 엔진이 받은 SieveScript/set 인자 */ + setCalls(): Record[] { + return this.recorded.flatMap(({ calls }) => + calls + .filter(([method]) => method === 'SieveScript/set') + .map(([, args]) => args), + ); + } + + private nextId(prefix: string): string { + this.sequence += 1; + return `${prefix}${this.sequence}`; + } + + private handle( + [method, args, callId]: JmapInvocation, + afterSet: boolean, + ): JmapInvocation { + switch (method) { + case 'SieveScript/get': { + if (afterSet && this.failRereadAfterSet) { + return ['error', { type: 'serverFail' }, callId]; + } + const state = this.hideState ? undefined : `s${this.state}`; + this.hideState = false; + return [ + method, + { state, list: this.scripts.map((script) => ({ ...script })) }, + callId, + ]; + } + case 'VacationResponse/get': + return [ + method, + { list: this.vacation ? [{ ...this.vacation }] : [] }, + callId, + ]; + case 'SieveScript/set': + return this.set(args, callId); + default: + return ['error', { type: 'unknownMethod' }, callId]; + } + } + + private set(args: Record, callId: string): JmapInvocation { + this.beforeSet.shift()?.(); + const replaced = this.setReplies.shift()?.(args); + if (replaced) return [replaced[0], replaced[1], callId]; + if (args.ifInState !== `s${this.state}`) { + return ['error', { type: 'stateMismatch' }, callId]; + } + + const created: Record = {}; + const updated: Record = {}; + const notCreated: Record = {}; + const notUpdated: Record = {}; + const create = (args.create ?? {}) as Record< + string, + { name: string; blobId: string } + >; + for (const [createId, { name, blobId }] of Object.entries(create)) { + const existing = this.scripts.find((script) => script.name === name); + if (existing) { + notCreated[createId] = { type: 'alreadyExists' }; + continue; + } + const id = this.nextId('sc'); + this.scripts.push({ id, name, isActive: false, blobId }); + created[createId] = { id }; + } + const update = (args.update ?? {}) as Record; + for (const [id, { blobId }] of Object.entries(update)) { + const script = this.scripts.find((candidate) => candidate.id === id); + if (!script) { + notUpdated[id] = { type: 'notFound' }; + continue; + } + script.blobId = blobId; + updated[id] = null; + } + const failed = + Object.keys(notCreated).length > 0 || Object.keys(notUpdated).length > 0; + const activate = args.onSuccessActivateScript; + if (!failed && typeof activate === 'string') { + const target = activate.startsWith('#') + ? created[activate.slice(1)]?.id + : activate; + for (const script of this.scripts) script.isActive = script.id === target; + } + const oldState = `s${this.state}`; + if (!failed) this.state += 1; + return [ + 'SieveScript/set', + { + oldState, + newState: `s${this.state}`, + ...(Object.keys(created).length ? { created } : {}), + ...(Object.keys(updated).length ? { updated } : {}), + ...(Object.keys(notCreated).length ? { notCreated } : {}), + ...(Object.keys(notUpdated).length ? { notUpdated } : {}), + }, + callId, + ]; + } +} + +async function failureOf( + promise: Promise, +): Promise<{ code?: string; context?: Record }> { + try { + await promise; + } catch (error) { + return error instanceof BusinessException + ? { code: error.code, context: error.context } + : { code: 'NOT_BUSINESS' }; + } + return {}; +} + +describe('JmapFilterScriptStore — 필터 · 부재중 자동 응답의 저장 자리', () => { + // 저장할 때마다 남기는 진단 로그(debug)는 이 테스트의 관심이 아니다 + beforeEach(() => { + jest.spyOn(Logger.prototype, 'debug').mockImplementation(() => undefined); + }); + afterEach(() => jest.restoreAllMocks()); + + describe('읽기 (getFilters · getVacation)', () => { + it('우리 스크립트가 없으면 규칙 없음 · 동작함 — 부재중은 엔진의 VacationResponse (한 요청으로 함께 묻는다)', async () => { + const engine = new FakeSieveEngine(); + engine.addScript('vacation', 'require ["vacation"];\r\n', true); + engine.vacation = { + id: 'singleton', + isEnabled: true, + fromDate: '2026-11-02T15:00:00Z', + toDate: null, + subject: '4단계 부재중', + textBody: '휴가입니다.', + htmlBody: null, + }; + + await expect(engine.store.getFilters(ARA)).resolves.toEqual({ + rules: [], + version: filterRulesVersion([]), + isActive: true, + }); + await expect(engine.store.getVacation(ARA)).resolves.toEqual({ + isEnabled: true, + fromDate: '2026-11-02T15:00:00.000Z', + toDate: null, + subject: '4단계 부재중', + textBody: '휴가입니다.', + }); + expect(engine.recorded[0]).toEqual({ + calls: [ + [ + 'SieveScript/get', + { + accountId: 'd', + ids: null, + properties: ['name', 'isActive', 'blobId'], + }, + 'g', + ], + ['VacationResponse/get', { accountId: 'd', ids: ['singleton'] }, 'v'], + ], + options: { capabilities: [SIEVE, VACATION] }, + }); + expect(engine.client.downloadBytes).not.toHaveBeenCalled(); + }); + + it('한 번도 설정하지 않은 계정은 끈 부재중 · 빈 제목과 내용', async () => { + const engine = new FakeSieveEngine(); + await expect(engine.store.getVacation(ARA)).resolves.toEqual( + NEVER_CONFIGURED_VACATION, + ); + }); + + it('VacationResponse 의 null 제목 · 내용은 빈 문자열로 이어받는다 — 처음 저장하면 그 값이 우리 스크립트에 들어간다', async () => { + const engine = new FakeSieveEngine(); + engine.vacation = { + id: 'singleton', + isEnabled: false, + fromDate: null, + toDate: '2026-11-07T15:00:00Z', + subject: null, + textBody: null, + htmlBody: null, + }; + const imported = { + isEnabled: false, + fromDate: null, + toDate: '2026-11-07T15:00:00.000Z', + subject: '', + textBody: '', + }; + + await expect(engine.store.getVacation(ARA)).resolves.toEqual(imported); + await engine.store.setFilters(ARA, { + rules: [rule('r1')], + version: filterRulesVersion([]), + }); + expect(engine.ourSource()?.vacation).toEqual(imported); + }); + + it('우리 스크립트가 켜져 있으면 원본의 규칙 · 부재중 — 글은 엔진 한도(102,400바이트)까지만 내려받는다', async () => { + const engine = new FakeSieveEngine(); + const rules = [rule('r1'), rule('r2', { isEnabled: false })]; + const ours = engine.addOurs({ rules, vacation: vacationOn }); + + await expect(engine.store.getFilters(ARA)).resolves.toEqual({ + rules, + version: filterRulesVersion(rules), + isActive: true, + }); + await expect(engine.store.getVacation(ARA)).resolves.toEqual(vacationOn); + expect(engine.client.downloadBytes).toHaveBeenCalledWith( + ARA, + ours.blobId, + 102_400, + ); + }); + + it('엔진에서 다른 스크립트가 켜져 있으면 isActive false — 규칙 · 부재중은 원본 그대로', async () => { + const engine = new FakeSieveEngine(); + const rules = [rule('r1')]; + engine.addOurs({ rules, vacation: vacationOn }); + engine.addScript('vacation', 'require ["vacation"];\r\n', true); + + await expect(engine.store.getFilters(ARA)).resolves.toEqual({ + rules, + version: filterRulesVersion(rules), + isActive: false, + }); + await expect(engine.store.getVacation(ARA)).resolves.toEqual(vacationOn); + }); + + it('원본이 깨졌으면 규칙 없음 · 부재중 꺼짐 · isActive false — 켜져 있어도, 글이 너무 커도', async () => { + for (const text of [ + '# 다른 메일 프로그램이 고친 스크립트\r\nkeep;\r\n', + `# arasystem-source: {"v":1,"rules":[${'x'.repeat(103_000)}]}\r\n`, + ]) { + const engine = new FakeSieveEngine(); + engine.addScript(FILTER_SCRIPT_NAME, text, true); + engine.vacation = { + id: 'singleton', + isEnabled: true, + subject: '안 가져온다', + textBody: '스크립트가 있으면 VacationResponse 는 보지 않는다', + }; + + await expect(engine.store.getFilters(ARA)).resolves.toEqual({ + rules: [], + version: filterRulesVersion([]), + isActive: false, + }); + await expect(engine.store.getVacation(ARA)).resolves.toEqual( + NEVER_CONFIGURED_VACATION, + ); + } + }); + + it('내려받는 사이 글이 없어졌으면(404 — 그사이 바뀜) 다시 묻는다 — 세 번 모두면 MAIL_001', async () => { + const engine = new FakeSieveEngine(); + engine.addOurs({ rules: [rule('r1')], vacation: vacationOn }); + engine.missingDownloads = 2; + await expect(engine.store.getFilters(ARA)).resolves.toMatchObject({ + isActive: true, + }); + expect(engine.recorded).toHaveLength(3); + + engine.missingDownloads = 3; + expect(await failureOf(engine.store.getFilters(ARA))).toMatchObject({ + code: 'MAIL_001', + context: { reason: 'filter-script-unreadable' }, + }); + }); + }); + + describe('새 판의 원본 (이 서버가 모르는 판 — 새 판을 쓰는 서버를 배포했다 되돌린 경우)', () => { + const newer = `# 설명\r\n# arasystem-source: ${JSON.stringify({ v: 99, entries: [{ id: 'r1', name: '새 판의 규칙' }] })}\r\nkeep;\r\n`; + + it('읽기는 빈 규칙 · 부재중 꺼짐 · 동작하지 않음으로 보인다', async () => { + const engine = new FakeSieveEngine(); + engine.addScript(FILTER_SCRIPT_NAME, newer, true); + + await expect(engine.store.getFilters(ARA)).resolves.toEqual({ + rules: [], + version: filterRulesVersion([]), + isActive: false, + }); + await expect(engine.store.getVacation(ARA)).resolves.toEqual( + NEVER_CONFIGURED_VACATION, + ); + }); + + it('필터 저장 · 부재중 저장 모두 쓰지 않는다 — 503 MAIL_001(filter-source-newer), 글은 그대로 남는다', async () => { + const engine = new FakeSieveEngine(); + const ours = engine.addScript(FILTER_SCRIPT_NAME, newer, true); + + for (const save of [ + () => + engine.store.setFilters(ARA, { + rules: [rule('r1')], + version: filterRulesVersion([]), + }), + () => engine.store.setVacation(ARA, vacationOn), + ]) { + expect(await failureOf(save())).toEqual({ + code: 'MAIL_001', + context: { + reason: 'filter-source-newer', + format: 99, + supportedFormat: 1, + }, + }); + } + expect(engine.client.upload).not.toHaveBeenCalled(); + expect(engine.setCalls()).toEqual([]); + expect(engine.blobs.get(ours.blobId)).toBe(newer); + }); + + it('같은 판의 깨진 원본은 지금처럼 저장하면 덮어쓴다', async () => { + const engine = new FakeSieveEngine(); + engine.addScript( + FILTER_SCRIPT_NAME, + '# arasystem-source: {"v":1,"rules":"깨짐"}\r\nkeep;\r\n', + true, + ); + + await engine.store.setVacation(ARA, vacationOn); + expect(engine.ourSource()).toEqual({ rules: [], vacation: vacationOn }); + }); + }); + + describe('필터 저장 (setFilters)', () => { + it('처음 저장 — 읽은 상태 그대로일 때만 만들고 켠다. 엔진의 VacationResponse 를 이어받아 함께 둔다', async () => { + const engine = new FakeSieveEngine(); + engine.addScript('vacation', 'require ["vacation"];\r\n', true); + engine.vacation = { + id: 'singleton', + isEnabled: true, + fromDate: '2026-11-02T15:00:00Z', + toDate: '2026-11-07T15:00:00Z', + subject: '부재중입니다', + textBody: '11월 7일까지 휴가입니다.', + }; + const rules = [rule('r1')]; + const state = `s${engine.state}`; + + await expect( + engine.store.setFilters(ARA, { + rules, + version: filterRulesVersion([]), + }), + ).resolves.toEqual({ + rules, + version: filterRulesVersion(rules), + isActive: true, + }); + + const expectedSource = { rules, vacation: vacationOn }; + expect(engine.client.upload).toHaveBeenCalledTimes(1); + const [, blob, type] = engine.client.upload.mock.calls[0]; + expect(type).toBe('application/sieve'); + expect(await blob.text()).toBe(buildFilterScript(expectedSource).text); + expect(engine.setCalls()).toEqual([ + { + accountId: 'd', + ifInState: state, + create: { + script: { + name: FILTER_SCRIPT_NAME, + blobId: engine.ours()?.blobId, + }, + }, + onSuccessActivateScript: '#script', + }, + ]); + expect(engine.recorded.at(-1)?.options).toEqual({ + capabilities: [SIEVE], + }); + expect(engine.ourSource()).toEqual(expectedSource); + // 켜진 스크립트는 하나 — 엔진의 vacation 스크립트는 꺼졌다(지우지 않는다) + expect( + engine.scripts.map(({ name, isActive }) => ({ name, isActive })), + ).toEqual([ + { name: 'vacation', isActive: false }, + { name: FILTER_SCRIPT_NAME, isActive: true }, + ]); + }); + + it('있으면 고친다 — 부재중은 다시 읽은 원본 그대로, 꺼져 있었으면 다시 켠다', async () => { + const engine = new FakeSieveEngine(); + const before = [rule('r1')]; + const ours = engine.addOurs( + { rules: before, vacation: vacationOn }, + false, + ); + engine.addScript('other', 'keep;\r\n', true); + const rules = [rule('r2', { name: '새 규칙' }), rule('r1')]; + + await expect( + engine.store.setFilters(ARA, { + rules, + version: filterRulesVersion(before), + }), + ).resolves.toEqual({ + rules, + version: filterRulesVersion(rules), + isActive: true, + }); + expect(engine.setCalls()).toEqual([ + { + accountId: 'd', + ifInState: expect.any(String) as string, + update: { [ours.id]: { blobId: engine.ours()?.blobId } }, + onSuccessActivateScript: ours.id, + }, + ]); + expect(engine.ourSource()).toEqual({ rules, vacation: vacationOn }); + expect(engine.ours()?.isActive).toBe(true); + }); + + it('그사이 다른 곳에서 규칙이 바뀌었으면(지문이 다름) MAIL_012 — 올리지도 쓰지도 않는다', async () => { + const engine = new FakeSieveEngine(); + engine.addOurs({ rules: [rule('r1')], vacation: vacationOn }); + + expect( + await failureOf( + engine.store.setFilters(ARA, { + rules: [rule('mine')], + version: filterRulesVersion([]), + }), + ), + ).toEqual({ + code: 'MAIL_012', + context: { reason: 'filter-rules-changed', rules: 1, storedRules: 1 }, + }); + expect(engine.client.upload).not.toHaveBeenCalled(); + expect(engine.setCalls()).toEqual([]); + }); + + it('부재중 응답만 바뀐 것은 걸리지 않는다 — 다른 곳에서 바꾼 부재중을 그대로 둔다', async () => { + const engine = new FakeSieveEngine(); + const before = [rule('r1')]; + engine.addOurs({ rules: before, vacation: NEVER_CONFIGURED_VACATION }); + const version = (await engine.store.getFilters(ARA)).version; + await engine.store.setVacation(ARA, vacationOn); + + const rules = [rule('r1', { isEnabled: false })]; + await expect( + engine.store.setFilters(ARA, { rules, version }), + ).resolves.toMatchObject({ rules }); + expect(engine.ourSource()).toEqual({ rules, vacation: vacationOn }); + }); + + it('저장해 둔 규칙이 이미 바꾸려는 규칙과 같으면 옛 지문이어도 그대로 저장한다 — 같은 요청을 다시 보낸 경우', async () => { + const engine = new FakeSieveEngine(); + const rules = [rule('r1')]; + engine.addOurs({ rules, vacation: vacationOn }, false); + engine.addScript('other', 'keep;\r\n', true); + + await expect( + engine.store.setFilters(ARA, { + rules, + version: filterRulesVersion([]), + }), + ).resolves.toEqual({ + rules, + version: filterRulesVersion(rules), + isActive: true, + }); + }); + + it('쓰는 사이 상태가 바뀌면(stateMismatch) 다시 읽어 확인하고 쓴다 — 그사이 바뀐 부재중을 지킨다', async () => { + const engine = new FakeSieveEngine(); + const before = [rule('r1')]; + engine.addOurs({ rules: before, vacation: NEVER_CONFIGURED_VACATION }); + // 다른 탭이 부재중을 켰다 + engine.beforeSet.push(() => { + const ours = engine.ours()!; + const blobId = 'other-tab'; + engine.blobs.set( + blobId, + buildFilterScript({ rules: before, vacation: vacationOn }).text, + ); + ours.blobId = blobId; + engine.state += 1; + }); + const rules = [rule('r1', { name: '이름 바꿈' })]; + + await expect( + engine.store.setFilters(ARA, { + rules, + version: filterRulesVersion(before), + }), + ).resolves.toMatchObject({ rules, isActive: true }); + expect(engine.setCalls()).toHaveLength(2); + expect(engine.client.upload).toHaveBeenCalledTimes(2); + expect(engine.ourSource()).toEqual({ rules, vacation: vacationOn }); + }); + + it('다시 읽은 글이 같으면 올린 것을 다시 쓴다 — 엔진에 같은 글을 두 번 올리지 않는다', async () => { + const engine = new FakeSieveEngine(); + engine.addOurs({ rules: [], vacation: vacationOn }); + engine.beforeSet.push(() => { + engine.state += 1; + }); + + await engine.store.setFilters(ARA, { + rules: [rule('r1')], + version: filterRulesVersion([]), + }); + expect(engine.setCalls()).toHaveLength(2); + expect(engine.client.upload).toHaveBeenCalledTimes(1); + }); + + it('다시 읽었더니 다른 곳에서 규칙을 저장했으면 MAIL_012', async () => { + const engine = new FakeSieveEngine(); + engine.addOurs({ rules: [], vacation: vacationOn }); + engine.beforeSet.push(() => { + const ours = engine.ours()!; + engine.blobs.set( + 'other-tab', + buildFilterScript({ rules: [rule('theirs')], vacation: vacationOn }) + .text, + ); + ours.blobId = 'other-tab'; + engine.state += 1; + }); + + expect( + ( + await failureOf( + engine.store.setFilters(ARA, { + rules: [rule('mine')], + version: filterRulesVersion([]), + }), + ) + ).code, + ).toBe('MAIL_012'); + expect(engine.ourSource()?.rules.map(({ id }) => id)).toEqual(['theirs']); + }); + + it('처음 쓰기가 겹치면(alreadyExists) 다시 읽어 그 스크립트를 고친다', async () => { + const engine = new FakeSieveEngine(); + // 상태 확인을 지나 만들기에서 겹쳤다 — 다른 요청이 같은 이름으로 먼저 만들었다 + engine.setReplies.push(() => { + engine.addOurs({ rules: [], vacation: vacationOn }, true); + return [ + 'SieveScript/set', + { notCreated: { script: { type: 'alreadyExists' } } }, + 's', + ]; + }); + const rules = [rule('r1')]; + + await expect( + engine.store.setFilters(ARA, { + rules, + version: filterRulesVersion([]), + }), + ).resolves.toMatchObject({ rules, isActive: true }); + expect(engine.setCalls()[1]).toMatchObject({ + update: { [engine.ours()!.id]: expect.any(Object) as object }, + }); + expect(engine.ourSource()).toEqual({ rules, vacation: vacationOn }); + }); + + it('세 번 모두 어긋나면 MAIL_001 — 쓰지 않았다', async () => { + const engine = new FakeSieveEngine(); + for (let i = 0; i < 3; i += 1) { + engine.beforeSet.push(() => { + engine.state += 1; + }); + } + + expect( + await failureOf( + engine.store.setFilters(ARA, { + rules: [rule('r1')], + version: filterRulesVersion([]), + }), + ), + ).toMatchObject({ + code: 'MAIL_001', + context: { reason: 'filter-script-contended', attempts: 3 }, + }); + expect(engine.setCalls()).toHaveLength(3); + expect(engine.ours()).toBeUndefined(); + }); + + it('엔진이 상태를 알려 주지 않으면 조건 없이 쓰지 않는다 — MAIL_001', async () => { + const engine = new FakeSieveEngine(); + engine.hideState = true; + + expect( + await failureOf( + engine.store.setFilters(ARA, { + rules: [rule('r1')], + version: filterRulesVersion([]), + }), + ), + ).toMatchObject({ + code: 'MAIL_001', + context: { reason: 'filter-script-state-unknown' }, + }); + expect(engine.client.upload).not.toHaveBeenCalled(); + }); + + it('만든 스크립트가 90,000바이트를 넘으면 MAIL_013 — 올리지 않는다. 로그에는 크기 · 규칙 수만 (글 없이)', async () => { + const engine = new FakeSieveEngine(); + const value = '가'.repeat(200); + const rules = Array.from({ length: 50 }, (_, index) => + rule(`r${index}`, { + match: 'all', + conditions: Array.from({ length: 5 }, () => ({ + field: 'subject' as const, + operator: 'contains' as const, + value, + })), + }), + ); + + const failure = await failureOf( + engine.store.setFilters(ARA, { + rules, + version: filterRulesVersion([]), + }), + ); + expect(failure).toMatchObject({ + code: 'MAIL_013', + context: { + reason: 'filter-script-too-large', + rules: 50, + enabledRules: 50, + }, + }); + expect(failure.context?.bytes).toBeGreaterThan(90_000); + expect(JSON.stringify(failure.context)).not.toContain(value); + expect(engine.client.upload).not.toHaveBeenCalled(); + }); + + it('엔진이 한도로 거절하면(tooLarge) MAIL_013', async () => { + const engine = new FakeSieveEngine(); + engine.setReplies.push(() => [ + 'SieveScript/set', + { notCreated: { script: { type: 'tooLarge' } } }, + 's', + ]); + + expect( + await failureOf( + engine.store.setFilters(ARA, { + rules: [rule('r1')], + version: filterRulesVersion([]), + }), + ), + ).toMatchObject({ code: 'MAIL_013', context: { jmapType: 'tooLarge' } }); + }); + + it('엔진이 글을 받지 않으면(invalidScript) 우리 생성기의 결함 — SYS_001, 엔진이 알려 준 자리(줄 · 칸)와 규칙 수만 남긴다 (설명 문장의 규칙 글은 남기지 않는다)', async () => { + const engine = new FakeSieveEngine(); + const secret = '거래처-비밀-낱말'; + engine.setReplies.push(() => [ + 'SieveScript/set', + { + notCreated: { + script: { + type: 'invalidScript', + // Sieve 컴파일 오류는 만난 문자열 토큰(규칙의 글)을 그대로 적는다 + description: `Expected token "string" but found "${secret}" at line 7, column 46.`, + }, + }, + }, + 's', + ]); + + const failure = await failureOf( + engine.store.setFilters(ARA, { + rules: [ + rule('r1', { + conditions: [ + { field: 'subject', operator: 'contains', value: secret }, + ], + }), + ], + version: filterRulesVersion([]), + }), + ); + expect(failure).toMatchObject({ + code: 'SYS_001', + context: { + reason: 'filter-script-rejected', + jmapType: 'invalidScript', + rules: 1, + enabledRules: 1, + }, + }); + expect(failure.context?.position).toEqual({ line: 7, column: 46 }); + expect(failure.context).not.toHaveProperty('description'); + expect(JSON.stringify(failure.context)).not.toContain(secret); + }); + + it('엔진의 설명에 자리가 없으면 자리는 null — 설명은 여전히 남기지 않는다', () => { + expect(scriptErrorPosition('Sieve script is too large')).toBeNull(); + expect(scriptErrorPosition(undefined)).toBeNull(); + expect( + scriptErrorPosition( + "Undeclared capability 'mailbox' at line 3, column 59.", + ), + ).toEqual({ line: 3, column: 59 }); + }); + + it('그 밖의 거절은 오류 표대로 — 권한 없음 AUTH_003, 메서드 오류 MAIL_001', async () => { + const forbidden = new FakeSieveEngine(); + forbidden.setReplies.push(() => [ + 'SieveScript/set', + { notCreated: { script: { type: 'forbidden' } } }, + 's', + ]); + const serverFail = new FakeSieveEngine(); + serverFail.setReplies.push(() => ['error', { type: 'serverFail' }, 's']); + + for (const [engine, code] of [ + [forbidden, 'AUTH_003'], + [serverFail, 'MAIL_001'], + ] as const) { + expect( + ( + await failureOf( + engine.store.setFilters(ARA, { + rules: [rule('r1')], + version: filterRulesVersion([]), + }), + ) + ).code, + ).toBe(code); + } + }); + + it('저장한 뒤 같은 요청의 다시 읽기가 실패해도 저장한 것으로 — 켜기는 저장의 일부다', async () => { + const engine = new FakeSieveEngine(); + engine.failRereadAfterSet = true; + const rules = [rule('r1')]; + + await expect( + engine.store.setFilters(ARA, { + rules, + version: filterRulesVersion([]), + }), + ).resolves.toEqual({ + rules, + version: filterRulesVersion(rules), + isActive: true, + }); + }); + }); + + describe('부재중 저장 (setVacation)', () => { + it('규칙을 그대로 두고 부재중만 바꾼다 — 지문을 보지 않고, 규칙의 지문은 그대로다', async () => { + const engine = new FakeSieveEngine(); + const rules = [rule('r1'), rule('r2', { isEnabled: false })]; + engine.addOurs({ rules, vacation: NEVER_CONFIGURED_VACATION }); + const before = await engine.store.getFilters(ARA); + + await expect(engine.store.setVacation(ARA, vacationOn)).resolves.toEqual( + vacationOn, + ); + expect(engine.ourSource()).toEqual({ rules, vacation: vacationOn }); + await expect(engine.store.getFilters(ARA)).resolves.toEqual(before); + }); + + it('우리 스크립트가 없으면 만든다 — 규칙 없음 + 부재중, 켠다', async () => { + const engine = new FakeSieveEngine(); + engine.addScript('vacation', 'require ["vacation"];\r\n', true); + + await engine.store.setVacation(ARA, vacationOn); + expect(engine.ourSource()).toEqual({ rules: [], vacation: vacationOn }); + expect(engine.ours()?.isActive).toBe(true); + }); + + it('원본이 깨졌으면 규칙 없음으로 덮어쓴다 (계약 2절 — 저장하면 덮어쓴다)', async () => { + const engine = new FakeSieveEngine(); + engine.addScript(FILTER_SCRIPT_NAME, 'keep;\r\n', true); + + await engine.store.setVacation(ARA, vacationOn); + expect(engine.ourSource()).toEqual({ rules: [], vacation: vacationOn }); + }); + + it('쓰는 사이 다른 곳에서 필터를 저장했으면 다시 읽어 그 규칙을 지킨다', async () => { + const engine = new FakeSieveEngine(); + engine.addOurs({ rules: [], vacation: NEVER_CONFIGURED_VACATION }); + const theirs = [rule('theirs')]; + engine.beforeSet.push(() => { + const ours = engine.ours()!; + engine.blobs.set( + 'other-tab', + buildFilterScript({ + rules: theirs, + vacation: NEVER_CONFIGURED_VACATION, + }).text, + ); + ours.blobId = 'other-tab'; + engine.state += 1; + }); + + await engine.store.setVacation(ARA, vacationOn); + expect(engine.ourSource()).toEqual({ + rules: theirs, + vacation: vacationOn, + }); + }); + }); +}); diff --git a/backend/src/modules/mail/jmap/jmap-filter-script.ts b/backend/src/modules/mail/jmap/jmap-filter-script.ts new file mode 100644 index 0000000..e17c151 --- /dev/null +++ b/backend/src/modules/mail/jmap/jmap-filter-script.ts @@ -0,0 +1,472 @@ +import { Injectable, Logger } from '@nestjs/common'; + +import { BusinessException } from '@/common/errors/business.exception'; +import type { + MailFilterSet, + MailFilterUpdate, + MailVacation, +} from '@/modules/mail/mail.types'; +import { traceTag } from '@/shared/logger/request-context'; +import { maskEmail } from '@/shared/utils/mask'; +import { + EMPTY_FILTER_SOURCE, + FILTER_SOURCE_FORMAT, + canonicalRules, + filterRulesVersion, + readFilterSource, + type FilterScriptSource, +} from './filter-source'; +import { JMAP_REQUEST_TIMEOUT_MS, JmapClient } from './jmap.client'; +import { jmapFailure } from './jmap.errors'; +import { toVacation } from './jmap.mapper'; +import { + expectResult, + findResult, + firstSetError, + isState, +} from './jmap-results'; +import type { + JmapError, + JmapGetResult, + JmapSetResult, + JmapSieveScript, + JmapVacationResponse, +} from './jmap.types'; +import { + FILTER_SCRIPT_MAX_BYTES, + SIEVE_STRING_MAX_BYTES, + buildFilterScript, + type BuiltFilterScript, +} from './sieve-script'; + +/** JMAP for Sieve (RFC 9661) — 이 호출에만 선언한다 (계정 id 는 메일 계정과 같다 — 2026-10-28 확인) */ +const SIEVE_CAPABILITY = 'urn:ietf:params:jmap:sieve'; +/** 부재중 자동 응답 기능 (RFC 8621 §8) — 우리 스크립트가 아직 없을 때 엔진의 값을 가져온다 */ +const VACATION_CAPABILITY = 'urn:ietf:params:jmap:vacationresponse'; +/** 계정마다 하나인 부재중 자동 응답의 id (RFC 8621 §8) */ +const VACATION_ID = 'singleton'; + +/** 필터와 부재중 자동 응답을 함께 두는 스크립트의 이름 — 이름은 계정 안에서 하나다 (계약 2절 "저장 자리") */ +export const FILTER_SCRIPT_NAME = 'arasystem'; + +/** 올리는 스크립트 글의 형식 */ +const SIEVE_TYPE = 'application/sieve'; + +/** 처음 만들 때의 생성 id — 같은 요청의 onSuccessActivateScript 가 `#` 를 붙여 가리킨다 */ +const CREATE_ID = 'script'; + +/** + * 읽은 상태 그대로일 때만(ifInState) 쓰는 횟수 — 그사이 바뀌면 다시 읽어 확인한다. 합계이고, 넘으면 MAIL_001 (계약 2절 "저장 자리"). + * 읽기에서 스크립트 글이 그사이 없어졌을 때(내려받기 404) 다시 읽는 횟수도 같다 + */ +const SCRIPT_ATTEMPTS = 3; + +/** 스크립트 글을 내려받는 상한 — 엔진의 사용자 스크립트 한도. 넘는 글은 우리가 쓴 것이 아니다(깨진 원본) */ +const SCRIPT_READ_MAX_BYTES = 102_400; + +/** SieveScript/get 에서 읽는 속성 (id 는 늘 온다) */ +const SCRIPT_PROPERTIES = ['name', 'isActive', 'blobId']; + +/** JMAP 메서드 오류 — ifInState 가 지금 상태와 다르다 (RFC 8620 §5.3, SetError 가 아니다 — 같은 날 확인) */ +const STATE_MISMATCH = 'stateMismatch'; +/** SetError — 같은 이름의 스크립트가 있다 (그사이 다른 곳에서 처음 쓰기가 겹쳤다) · 그 스크립트가 없다 (그사이 지워졌다) */ +const ALREADY_EXISTS = 'alreadyExists'; +const NOT_FOUND = 'notFound'; +/** SetError — 엔진의 스크립트 한도(102,400바이트)를 넘는다 */ +const TOO_LARGE = 'tooLarge'; +/** SetError — 엔진이 스크립트 글을 받지 않는다. Stalwart 의 이름(invalidScript)과 RFC 9661 의 이름(invalidSieve) */ +const INVALID_SCRIPT_TYPES: ReadonlySet = new Set([ + 'invalidScript', + 'invalidSieve', +]); +/** + * 엔진이 글을 받지 않은 자리 — 설명 문장에서 `line N, column M` 만 꺼낸다. + * 설명 문장은 남기지 않는다: Sieve 컴파일 오류는 만난 문자열 토큰(규칙의 글)을 그대로 적을 수 있다 + * (예: `Expected token "string" but found ";" at line 2, column 46.` — 계약 "규칙의 글은 남기지 않는다", jmap.errors.ts 의 jmapFailure 와 같은 규칙) + */ +const SCRIPT_POSITION_PATTERN = /\bline (\d+), column (\d+)\b/; + +/** 엔진의 설명에서 자리(줄 · 칸)만 — 없으면 null */ +export function scriptErrorPosition( + description: string | undefined, +): { readonly line: number; readonly column: number } | null { + const match = SCRIPT_POSITION_PATTERN.exec(description ?? ''); + return match ? { line: Number(match[1]), column: Number(match[2]) } : null; +} + +/** 엔진 규칙의 내용 — 원본과 동작하는지 */ +interface ScriptView { + readonly source: FilterScriptSource; + /** + * 필터 · 부재중이 동작하는가 (계약의 isActive) — 우리 스크립트가 없으면 true(막힌 것이 없다), + * 있으면 엔진에서 켜져 있고 원본을 읽을 수 있을 때만 true + */ + readonly isActive: boolean; +} + +/** 지금 엔진 규칙 — 내용과 쓸 때의 조건 */ +interface ScriptSnapshot extends ScriptView { + /** SieveScript 상태 — 엔진이 알려 주지 않으면 null (조건 없이는 쓰지 않는다) */ + readonly state: string | null; + /** 우리 스크립트 id — 없으면 null (처음 쓸 때 만든다) */ + readonly scriptId: string | null; + /** 원본이 이 서버가 모르는 새 판이면 그 판 — 그때는 쓰지 않는다 (filter-source.ts 의 FILTER_SOURCE_FORMAT) */ + readonly newerFormat: number | null; +} + +/** 로그 · 오류 context 에 남기는 스크립트 요약 — 크기와 규칙 수만 (규칙의 글은 남기지 않는다) */ +function scriptSummary( + script: BuiltFilterScript, + source: FilterScriptSource, +): Record { + return { + bytes: script.bytes, + rules: source.rules.length, + enabledRules: source.rules.filter((rule) => rule.isEnabled).length, + vacation: source.vacation.isEnabled, + }; +} + +function toFilterSet(view: ScriptView): MailFilterSet { + return { + rules: canonicalRules(view.source.rules), + version: filterRulesVersion(view.source.rules), + isActive: view.isActive, + }; +} + +/** + * 메일 필터와 부재중 자동 응답의 저장 자리 — 엔진의 Sieve 스크립트 하나(`arasystem`) (계약 2절 "메일 필터"의 저장 자리). + * Stalwart 0.16.27 은 계정마다 켜진 스크립트가 하나뿐이고 엔진의 VacationResponse 도 스크립트(`vacation`)라서, 둘을 따로 두면 + * 부재중을 켜면 필터가 꺼지고 필터를 켜면 부재중이 꺼진다 — 그래서 한 스크립트에 함께 둔다. `vacation` 스크립트는 지우지도 고치지도 않는다(엔진이 forbidden). + * + * - 읽기: SieveScript/get → 우리 스크립트의 글을 내려받아 머리의 원본(JSON)만 믿는다(filter-source.ts). 없으면 규칙 없음 + + * 부재중은 엔진의 VacationResponse 에서 가져온다(4단계 데이터 이어받기 — 처음 저장할 때 우리 스크립트로 옮겨진다). + * 원본이 깨졌거나 새 판이면 빈 규칙 · 부재중 꺼짐 · 동작하지 않음으로 보인다 — 깨진 원본은 저장하면 덮어쓰고, 새 판은 쓰지 않는다(MAIL_001) + * - 쓰기: 읽은 상태로 다음 원본을 정해 글을 만들고(sieve-script.ts) 크기를 본 뒤 올리고, 읽은 상태 그대로일 때만(ifInState) + * 만들거나 고치고 켠다(onSuccessActivateScript — 다른 스크립트는 엔진이 끈다). 그사이 바뀌면 다시 읽어 확인한다 (합계 SCRIPT_ATTEMPTS 번) + */ +@Injectable() +export class JmapFilterScriptStore { + private readonly logger = new Logger(JmapFilterScriptStore.name); + + constructor(private readonly jmap: JmapClient) {} + + /** 지금 규칙 · 지문 · 동작하는지 */ + async getFilters(user: string): Promise { + return toFilterSet(await this.snapshot(user)); + } + + /** + * 규칙을 통째로 바꾼다 — 부재중은 다시 읽은 값 그대로 둔다. + * 다시 읽은 규칙의 지문이 받은 version 과 다르면 MAIL_012 (저장하지 않는다) — 다만 저장해 둔 규칙이 이미 바꾸려는 규칙과 같으면 + * 그대로 저장한다(같은 요청을 다시 보낸 경우 — 결과를 모르는 실패 뒤의 재시도가 409 로 끝나지 않게, 꺼져 있으면 다시 켜진다) + */ + async setFilters( + user: string, + update: MailFilterUpdate, + ): Promise { + const wanted = filterRulesVersion(update.rules); + const saved = await this.write(user, (current) => { + const version = filterRulesVersion(current.rules); + if (version !== update.version && version !== wanted) { + throw new BusinessException('MAIL_012', { + context: { + reason: 'filter-rules-changed', + rules: update.rules.length, + storedRules: current.rules.length, + }, + }); + } + return { rules: update.rules, vacation: current.vacation }; + }); + return toFilterSet(saved); + } + + /** 부재중 자동 응답 — 우리 스크립트가 없으면 엔진의 VacationResponse, 원본이 깨졌으면 끈 상태 */ + async getVacation(user: string): Promise { + return (await this.snapshot(user)).source.vacation; + } + + /** 부재중 자동 응답만 바꾼다 — 규칙은 다시 읽은 그대로 둔다 (지문을 보지 않는다). 저장한 값을 돌려준다 */ + async setVacation( + user: string, + vacation: MailVacation, + ): Promise { + const saved = await this.write(user, (current) => ({ + rules: current.rules, + vacation, + })); + return saved.source.vacation; + } + + /** + * 지금 엔진 규칙을 읽는다 — 스크립트 목록과 엔진의 부재중 응답을 한 요청으로 묻고, 우리 스크립트가 있으면 글을 내려받는다. + * 내려받는 사이 글이 없어졌으면(그사이 바뀜) 다시 묻는다 — SCRIPT_ATTEMPTS 번을 넘으면 MAIL_001 + */ + private async snapshot(user: string): Promise { + for (let attempt = 1; attempt <= SCRIPT_ATTEMPTS; attempt += 1) { + const responses = await this.jmap.call( + user, + (accountId) => [ + [ + 'SieveScript/get', + { accountId, ids: null, properties: SCRIPT_PROPERTIES }, + 'g', + ], + ['VacationResponse/get', { accountId, ids: [VACATION_ID] }, 'v'], + ], + { capabilities: [SIEVE_CAPABILITY, VACATION_CAPABILITY] }, + ); + const scripts = expectResult>( + responses, + 'g', + 'SieveScript/get', + ); + const state = isState(scripts.state) ? scripts.state : null; + const ours = (scripts.list ?? []).find( + (script) => script.name === FILTER_SCRIPT_NAME, + ); + + // 아직 저장한 적이 없다 — 막힌 것이 없고(isActive), 부재중은 엔진의 VacationResponse 를 이어받는다 + if (!ours) { + const { list = [] } = expectResult>( + responses, + 'v', + 'VacationResponse/get', + ); + return { + state, + scriptId: null, + isActive: true, + source: { rules: [], vacation: toVacation(list[0]) }, + newerFormat: null, + }; + } + + const text = await this.downloadScript(user, ours); + // 목록을 읽은 뒤 스크립트가 바뀌어 그 글이 없어졌다 — 다시 묻는다 + if (text === undefined) continue; + // 읽을 수 없는 글(없음 · 너무 큼)은 깨진 원본과 같다 — 깨짐 · 새 판은 빈 규칙 · 동작하지 않음으로 보인다 + const reading = + text === null ? { status: 'broken' as const } : readFilterSource(text); + return { + state, + scriptId: ours.id, + isActive: reading.status === 'ok' && ours.isActive === true, + source: reading.status === 'ok' ? reading.source : EMPTY_FILTER_SOURCE, + newerFormat: reading.status === 'newer' ? reading.format : null, + }; + } + throw new BusinessException('MAIL_001', { + context: { + reason: 'filter-script-unreadable', + attempts: SCRIPT_ATTEMPTS, + }, + }); + } + + /** 스크립트 글 — 읽을 수 없으면(글이 없음 · 너무 큼) null, 그사이 바뀌어 글이 없어졌으면(404) undefined */ + private async downloadScript( + user: string, + script: JmapSieveScript, + ): Promise { + if (!script.blobId) return null; + try { + const bytes = await this.jmap.downloadBytes( + user, + script.blobId, + SCRIPT_READ_MAX_BYTES, + ); + return bytes === null ? null : bytes.toString('utf8'); + } catch (error) { + if (error instanceof BusinessException && error.code === 'RES_001') { + return undefined; + } + throw error; + } + } + + /** + * 다음 원본을 정해(change) 쓴다 — 읽은 상태 그대로일 때만 쓰고 켠다. change 가 던지면(MAIL_012) 그대로 던진다. + * 만든 글이 한도를 넘으면 MAIL_013 (올리지 않는다). 같은 글을 다시 쓸 때는 올린 것을 다시 쓴다. + * 원본이 새 판이면 쓰지 않고 MAIL_001 — 이 서버가 모르는 규칙 · 부재중을 덮어써 알림 없이 잃지 않게 (부재중만 저장해도 규칙을 다시 쓴다) + */ + private async write( + user: string, + change: (current: FilterScriptSource) => FilterScriptSource, + ): Promise { + let uploaded: { readonly text: string; readonly blobId: string } | null = + null; + for (let attempt = 1; attempt <= SCRIPT_ATTEMPTS; attempt += 1) { + const current = await this.snapshot(user); + if (current.newerFormat !== null) { + throw new BusinessException('MAIL_001', { + context: { + reason: 'filter-source-newer', + format: current.newerFormat, + supportedFormat: FILTER_SOURCE_FORMAT, + }, + }); + } + // 상태를 모르면 조건 없이 쓰지 않는다 — 그사이 다른 곳에서 바꾼 것을 덮을 수 있다 + if (current.state === null) { + throw new BusinessException('MAIL_001', { + context: { reason: 'filter-script-state-unknown' }, + }); + } + const next = change(current.source); + const script = buildFilterScript(next); + if ( + script.bytes > FILTER_SCRIPT_MAX_BYTES || + script.longestStringBytes > SIEVE_STRING_MAX_BYTES + ) { + throw new BusinessException('MAIL_013', { + context: { + reason: 'filter-script-too-large', + ...scriptSummary(script, next), + longestStringBytes: script.longestStringBytes, + }, + }); + } + const blobId: string = + uploaded?.text === script.text + ? uploaded.blobId + : await this.upload(user, script.text); + uploaded = { text: script.text, blobId }; + + const saved = await this.save(user, current, blobId, script, next); + if (saved !== null) { + this.logger.debug( + `${traceTag()} 엔진 규칙(Sieve)을 저장했습니다: user=${maskEmail(user)}, ${script.bytes}바이트, 규칙 ${next.rules.length}개, 부재중 ${next.vacation.isEnabled ? '켬' : '끔'}, ${attempt}번째`, + ); + return { source: next, isActive: saved.isActive }; + } + } + throw new BusinessException('MAIL_001', { + context: { + reason: 'filter-script-contended', + attempts: SCRIPT_ATTEMPTS, + }, + }); + } + + private async upload(user: string, text: string): Promise { + const { blobId } = await this.jmap.upload( + user, + new Blob([text], { type: SIEVE_TYPE }), + SIEVE_TYPE, + { timeoutMs: JMAP_REQUEST_TIMEOUT_MS }, + ); + return blobId; + } + + /** + * 읽은 상태 그대로일 때만 만들거나(없으면) 고치고(있으면) 켠다 — 같은 요청에서 다시 읽어 켜졌는지 본다. + * @returns 저장했으면 켜졌는지, 그사이 바뀌었으면(stateMismatch · alreadyExists · notFound) null — 다시 읽는다 + */ + private async save( + user: string, + current: ScriptSnapshot, + blobId: string, + script: BuiltFilterScript, + next: FilterScriptSource, + ): Promise<{ readonly isActive: boolean } | null> { + const { scriptId } = current; + const responses = await this.jmap.call( + user, + (accountId) => [ + [ + 'SieveScript/set', + { + accountId, + ifInState: current.state, + ...(scriptId === null + ? { + create: { + [CREATE_ID]: { name: FILTER_SCRIPT_NAME, blobId }, + }, + } + : { update: { [scriptId]: { blobId } } }), + onSuccessActivateScript: scriptId ?? `#${CREATE_ID}`, + }, + 's', + ], + [ + 'SieveScript/get', + { accountId, ids: null, properties: SCRIPT_PROPERTIES }, + 'g', + ], + ], + { capabilities: [SIEVE_CAPABILITY] }, + ); + + const result = findResult(responses, 's', 'SieveScript/set'); + if (!result.ok) { + if (result.error.type === STATE_MISMATCH) return null; + throw jmapFailure(result.error, { method: 'SieveScript/set' }); + } + const set = result.args as JmapSetResult; + const failure = + firstSetError(set.notCreated) ?? firstSetError(set.notUpdated); + if (failure) { + if (failure.type === ALREADY_EXISTS || failure.type === NOT_FOUND) { + return null; + } + throw this.rejection(failure, script, next); + } + + const savedId = scriptId ?? set.created?.[CREATE_ID]?.id; + const updated = + scriptId === null || + (set.updated !== undefined && + Object.prototype.hasOwnProperty.call(set.updated, scriptId)); + if (!savedId || !updated) { + throw new BusinessException('MAIL_001', { + context: { reason: 'filter-script-not-saved' }, + }); + } + + // 저장은 끝났다 — 다시 읽지 못해도 실패로 답하지 않는다 (켜기는 같은 저장의 일부다) + const reread = findResult(responses, 'g', 'SieveScript/get'); + const isActive = reread.ok + ? ((reread.args as JmapGetResult).list ?? []).some( + (candidate) => + candidate.id === savedId && candidate.isActive === true, + ) + : true; + return { isActive }; + } + + /** + * 엔진이 저장을 거절한 것 — 한도 초과(tooLarge)는 MAIL_013. + * 글을 받지 않음(invalidScript)은 우리가 만든 글의 결함이라 내부 오류(SYS_001)다 — 엔진이 알려 준 자리(줄 · 칸)와 크기 · 규칙 수만 남긴다 + * (설명 문장은 규칙의 글을 담을 수 있어 남기지 않는다 — scriptErrorPosition). 그 밖은 오류 표대로 (jmap.errors.ts) + */ + private rejection( + failure: JmapError, + script: BuiltFilterScript, + next: FilterScriptSource, + ): BusinessException { + const context = { + method: 'SieveScript/set', + jmapType: failure.type ?? 'unknown', + ...scriptSummary(script, next), + }; + if (failure.type === TOO_LARGE) { + return new BusinessException('MAIL_013', { + context: { reason: 'filter-script-too-large', ...context }, + }); + } + if (failure.type !== undefined && INVALID_SCRIPT_TYPES.has(failure.type)) { + return new BusinessException('SYS_001', { + context: { + reason: 'filter-script-rejected', + position: scriptErrorPosition(failure.description), + ...context, + }, + }); + } + return jmapFailure(failure, { method: 'SieveScript/set' }); + } +} 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 6a13447..9d523ba 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 { JmapFilterScriptStore } from './jmap-filter-script'; import { WATCH_DEBOUNCE_MS, WATCH_START_TIMEOUT_MS } from './jmap-mail-watch'; import { JmapMailProvider, buildThreadHeaders } from './jmap-mail.provider'; import { MAILBOX_PROPERTIES } from './jmap.mapper'; @@ -174,6 +175,7 @@ function fakeClient( client as unknown as JmapClient, cache, overrides.settings ?? settings, + new JmapFilterScriptStore(client as unknown as JmapClient), ), }; } @@ -5535,7 +5537,8 @@ describe('JmapMailProvider', () => { }); }); - describe('부재중 자동 응답 (getVacation · setVacation)', () => { + describe('부재중 자동 응답 · 메일 필터 — 엔진의 Sieve 스크립트 하나(arasystem)에 함께 둔다', () => { + // 읽기 · 쓰기 · 다시 시도의 규칙은 jmap-filter-script.spec.ts, 만드는 Sieve 는 sieve-script.spec.ts 가 본다 — 여기서는 어댑터가 그 저장 자리를 쓰는지만 const stored = { id: 'singleton', isEnabled: true, @@ -5545,175 +5548,82 @@ describe('JmapMailProvider', () => { textBody: '11월 7일까지 휴가입니다.', htmlBody: null, }; + const vacation = { + isEnabled: true, + fromDate: '2026-11-02T15:00:00.000Z', + toDate: '2026-11-07T15:00:00.000Z', + subject: '부재중입니다', + textBody: '11월 7일까지 휴가입니다.', + }; + beforeEach(() => { + jest.spyOn(Logger.prototype, 'debug').mockImplementation(() => undefined); + }); + afterEach(() => jest.restoreAllMocks()); - it('VacationResponse 를 그 기능을 선언한 요청으로 읽어 우리 모양으로 — 날짜는 ISO 8601 UTC', async () => { - const { provider, recorded } = fakeClient([ - [['VacationResponse/get', { list: [stored] }, 'v']], - ]); + /** 우리 스크립트가 아직 없는 계정 — 엔진의 VacationResponse 만 있다 */ + const noScript: JmapInvocation[] = [ + ['SieveScript/get', { state: 's1', list: [] }, 'g'], + ['VacationResponse/get', { list: [stored] }, 'v'], + ]; - await expect(provider.getVacation(ARA)).resolves.toEqual({ - isEnabled: true, - fromDate: '2026-11-02T15:00:00.000Z', - toDate: '2026-11-07T15:00:00.000Z', - subject: '부재중입니다', - textBody: '11월 7일까지 휴가입니다.', - }); - expect(recorded[0].calls).toEqual([ - ['VacationResponse/get', { accountId: 'd', ids: ['singleton'] }, 'v'], + it('부재중은 우리 스크립트가 없으면 엔진의 VacationResponse 를 이어받는다 — 스크립트 목록과 한 요청으로 묻는다', async () => { + const { provider, recorded } = fakeClient([noScript]); + + await expect(provider.getVacation(ARA)).resolves.toEqual(vacation); + expect(recorded[0].calls.map(([method]) => method)).toEqual([ + 'SieveScript/get', + 'VacationResponse/get', ]); expect(recorded[0].options).toEqual({ - capabilities: ['urn:ietf:params:jmap:vacationresponse'], + capabilities: [ + 'urn:ietf:params:jmap:sieve', + 'urn:ietf:params:jmap:vacationresponse', + ], }); }); - it('한 번도 설정하지 않은 계정(엔진 값이 null)은 끈 상태 · 빈 제목과 내용', async () => { - const blank = { - id: 'singleton', - isEnabled: false, - fromDate: null, - toDate: null, - subject: null, - textBody: null, - htmlBody: null, - }; - for (const list of [[blank], []]) { - const { provider } = fakeClient([ - [['VacationResponse/get', { list }, 'v']], - ]); - await expect(provider.getVacation(ARA)).resolves.toEqual({ - isEnabled: false, - fromDate: null, - toDate: null, - subject: '', - textBody: '', - }); - } - }); - - it('저장은 통째로 바꾼다 — 서식 본문은 비우고, 빈 제목·내용은 null, 날짜는 소수 초 없는 UTCDate. 같은 요청에서 다시 읽은 값을 돌려준다', async () => { - const { provider, recorded } = fakeClient([ + it('부재중 저장은 VacationResponse 가 아니라 우리 스크립트를 만들고 켠다 — 저장한 값을 돌려준다', async () => { + const { provider, recorded, client } = fakeClient([ + noScript, [ - ['VacationResponse/set', { updated: { singleton: null } }, 's'], + ['SieveScript/set', { created: { script: { id: 'sc1' } } }, 's'], [ - 'VacationResponse/get', - { - list: [ - { - ...stored, - isEnabled: false, - subject: null, - textBody: null, - }, - ], - }, - 'v', + 'SieveScript/get', + { list: [{ id: 'sc1', name: 'arasystem', isActive: true }] }, + 'g', ], ], ]); + client.upload.mockResolvedValue({ + blobId: 'blob1', + type: 'application/sieve', + size: 100, + }); + const off = { ...vacation, isEnabled: false }; - await expect( - provider.setVacation(ARA, { - isEnabled: false, - fromDate: '2026-11-02T15:00:00.000Z', - toDate: '2026-11-07T15:00:00.000Z', - subject: '', - textBody: '', - }), - ).resolves.toEqual({ - isEnabled: false, - fromDate: '2026-11-02T15:00:00.000Z', - toDate: '2026-11-07T15:00:00.000Z', - subject: '', - textBody: '', - }); - expect(recorded[0].calls).toEqual([ - [ - 'VacationResponse/set', - { - accountId: 'd', - update: { - singleton: { - isEnabled: false, - fromDate: '2026-11-02T15:00:00Z', - toDate: '2026-11-07T15:00:00Z', - subject: null, - textBody: null, - htmlBody: null, - }, - }, - }, - 's', - ], - ['VacationResponse/get', { accountId: 'd', ids: ['singleton'] }, 'v'], - ]); - expect(recorded[0].options).toEqual({ - capabilities: ['urn:ietf:params:jmap:vacationresponse'], + await expect(provider.setVacation(ARA, off)).resolves.toEqual(off); + expect((client.upload.mock.calls[0] as unknown[])[2]).toBe( + 'application/sieve', + ); + expect(argsOf(recorded[1])).toEqual({ + accountId: 'd', + ifInState: 's1', + create: { script: { name: 'arasystem', blobId: 'blob1' } }, + onSuccessActivateScript: '#script', }); + expect( + recorded.flatMap(({ calls }) => calls.map(([method]) => method)), + ).not.toContain('VacationResponse/set'); }); - it('날짜 없이 켜면 날짜는 null 그대로 — 엔진이 거절하면 그 코드(형식 거절 VAL_001), 바꿨다는 답이 없으면 MAIL_001', async () => { - const { provider, recorded } = fakeClient([ - [ - ['VacationResponse/set', { updated: { singleton: null } }, 's'], - ['VacationResponse/get', { list: [stored] }, 'v'], - ], - ]); - await provider.setVacation(ARA, { - isEnabled: true, - fromDate: null, - toDate: null, - subject: '부재중입니다', - textBody: '휴가입니다.', - }); - expect(argsOf(recorded[0])).toMatchObject({ - update: { - singleton: { - isEnabled: true, - fromDate: null, - toDate: null, - subject: '부재중입니다', - textBody: '휴가입니다.', - }, - }, - }); + it('메일 필터도 같은 저장 자리에서 읽는다 — 스크립트가 없으면 규칙 없음 · 동작함', async () => { + const { provider } = fakeClient([noScript]); - const vacation = { - isEnabled: true, - fromDate: null, - toDate: null, - subject: '부재중', - textBody: '휴가', - }; - const rejected = fakeClient([ - [ - [ - 'VacationResponse/set', - { - notUpdated: { - singleton: { - type: 'invalidProperties', - properties: ['fromDate'], - }, - }, - }, - 's', - ], - ['VacationResponse/get', { list: [stored] }, 'v'], - ], - ]); - expect(await codeOf(rejected.provider.setVacation(ARA, vacation))).toBe( - 'VAL_001', - ); - - const silent = fakeClient([ - [ - ['VacationResponse/set', {}, 's'], - ['VacationResponse/get', { list: [stored] }, 'v'], - ], - ]); - expect(await codeOf(silent.provider.setVacation(ARA, vacation))).toBe( - 'MAIL_001', - ); + await expect(provider.getMailFilters(ARA)).resolves.toEqual({ + rules: [], + version: expect.any(String) as string, + isActive: true, + }); }); }); diff --git a/backend/src/modules/mail/jmap/jmap-mail.provider.ts b/backend/src/modules/mail/jmap/jmap-mail.provider.ts index 52ea7a1..31ad39c 100644 --- a/backend/src/modules/mail/jmap/jmap-mail.provider.ts +++ b/backend/src/modules/mail/jmap/jmap-mail.provider.ts @@ -23,6 +23,8 @@ import type { MailboxBulkResult, MailboxCreate, MailboxDeletion, + MailFilterSet, + MailFilterUpdate, MailLiveState, MailState, MailUsage, @@ -67,12 +69,19 @@ import { type DeliveryResult, } from './delivery-status'; import { decodeCursor, encodeCursor } from './jmap-cursor'; +import { JmapFilterScriptStore } from './jmap-filter-script'; import { JmapMailWatch, type WatchPosition, type WatchReading, } from './jmap-mail-watch'; import { PUSH_PING_SECONDS, PUSH_TYPES } from './jmap-push'; +import { + expectResult, + findResult, + firstSetError, + isState, +} from './jmap-results'; import { describeFailure, isMissingAttachmentBlob, @@ -91,12 +100,10 @@ import { selectAccountQuota, toAddresses, toDetailSource, - toJmapVacation, toMailbox, toSummary, toUsage, toUtcIso, - toVacation, } from './jmap.mapper'; import type { JmapBlob, @@ -113,7 +120,6 @@ import type { JmapQueryResult, JmapQuota, JmapSetResult, - JmapVacationResponse, } from './jmap.types'; import { NEW_MAIL_PROPERTIES, selectNewMail } from './new-mail'; import { searchTextConditions, withSearchConditions } from './search-terms'; @@ -201,11 +207,6 @@ const BULK_ROUND_RESERVE_MS = 5_000; /** 한 차례에 다루는 메일 수 — 엔진의 한 번 처리 한도(maxObjectsInGet · maxObjectsInSet 500) */ const BULK_BATCH_SIZE = 500; -/** 부재중 자동 응답 기능 (RFC 8621 §8) — 이 호출에만 선언한다 (모르는 기능이 using 에 있으면 요청 전체가 거부된다) */ -const VACATION_CAPABILITY = 'urn:ietf:params:jmap:vacationresponse'; -/** 계정마다 하나인 부재중 자동 응답의 id (RFC 8621 §8) */ -const VACATION_ID = 'singleton'; - /** 원문 내려받기 때 엔진에 요청하는 형식·이름 — 클라이언트에게 주는 이름·헤더는 서비스·컨트롤러가 정한다 */ const SOURCE_TYPE = 'message/rfc822'; const SOURCE_DOWNLOAD_NAME = 'message.eml'; @@ -263,44 +264,6 @@ const MAX_REFERENCES = 20; /** 다운로드할 때 엔진에 요청하는 형식 — 클라이언트로는 어차피 octet-stream 으로 내려준다 */ const DOWNLOAD_TYPE = 'application/octet-stream'; -type MethodResult = - | { ok: true; args: Record } - | { ok: false; error: JmapError }; - -/** 호출 id·메서드 이름으로 응답을 찾는다 (onSuccessUpdateEmail 처럼 한 호출 id 에 응답이 둘일 수 있다) */ -function findResult( - responses: JmapInvocation[], - callId: string, - method: string, -): MethodResult { - for (const [name, args, id] of responses) { - if (id !== callId) continue; - if (name === 'error') return { ok: false, error: args }; - if (name === method) return { ok: true, args }; - } - throw new BusinessException('MAIL_001', { - context: { reason: 'missing-response', method }, - }); -} - -/** 성공 응답을 꺼낸다. 메서드 오류면 코드가 있는 예외로 바꾼다 */ -function expectResult( - responses: JmapInvocation[], - callId: string, - method: string, -): T { - const result = findResult(responses, callId, method); - if (!result.ok) throw jmapFailure(result.error, { method }); - return result.args as T; -} - -/** id 하나로 처리한 SetError — 엔진이 id 를 정규화해 키가 달라질 수 있어 첫 값을 본다 */ -function firstSetError( - errors: Record | undefined, -): JmapError | undefined { - return errors ? Object.values(errors)[0] : undefined; -} - function toJmapAddress(address: MailAddress): { name: string | null; email: string; @@ -577,11 +540,6 @@ function firstRefusal( .find((error) => error?.type !== NOT_FOUND); } -/** 엔진이 알려 준 상태 문자열인가 (비었거나 없으면 아니다) */ -function isState(value: unknown): value is string { - return typeof value === 'string' && value !== ''; -} - /** * 새 메일 확인 값 — 메일함 상태와 메일 상태를 이어 붙인다 (클라이언트에게는 불투명). * 각각 base64url 로 바꿔 '.' 으로 잇는다 — base64url 에는 '.' 이 없어 서로 다른 두 상태 쌍이 같은 값이 되지 않는다. @@ -767,6 +725,8 @@ export class JmapMailProvider implements MailProvider { private readonly jmap: JmapClient, @Inject(CACHE_MANAGER) private readonly cache: Cache, @Inject(MAIL_SETTINGS) private readonly settings: MailSettings, + /** 필터 · 부재중 자동 응답의 저장 자리 — 엔진의 Sieve 스크립트 하나 (jmap-filter-script.ts) */ + private readonly filterScripts: JmapFilterScriptStore, ) {} verifyCredentials(email: string, password: string): Promise { @@ -1202,66 +1162,30 @@ export class JmapMailProvider implements MailProvider { return { processed: run.processed, remaining }; } - /** 부재중 자동 응답 (RFC 8621 §8 — 계정마다 하나) */ - async getVacation(user: string): Promise { - const responses = await this.jmap.call( - user, - (accountId) => [ - ['VacationResponse/get', { accountId, ids: [VACATION_ID] }, 'v'], - ], - { capabilities: [VACATION_CAPABILITY] }, - ); - const { list = [] } = expectResult>( - responses, - 'v', - 'VacationResponse/get', - ); - return toVacation(list[0]); + /** + * 부재중 자동 응답 — 필터와 같은 엔진 규칙(`arasystem` 스크립트)에 둔다. 그 스크립트가 아직 없으면 엔진의 VacationResponse 값이다 + * (계약 2절 "메일 필터"의 저장 자리 — jmap-filter-script.ts) + */ + getVacation(user: string): Promise { + return this.filterScripts.getVacation(user); } - /** - * 부재중 자동 응답을 통째로 바꾼다 — 같은 요청에서 다시 읽은 값을 돌려준다(엔진이 저장한 그대로). - * 서식 본문은 늘 비운다 (toJmapVacation). 엔진이 거절하면 그 코드 (형식 거절은 VAL_001) - */ - async setVacation( + /** 부재중 자동 응답만 바꾼다 — 같은 스크립트의 필터 규칙은 그대로 둔다. 저장한 값을 돌려준다 */ + setVacation(user: string, vacation: MailVacation): Promise { + return this.filterScripts.setVacation(user, vacation); + } + + /** 메일 필터 — 규칙 · 지문 · 동작하는지 (계약 2절 "메일 필터") */ + getMailFilters(user: string): Promise { + return this.filterScripts.getFilters(user); + } + + /** 메일 필터를 통째로 바꾼다 — 그사이 규칙이 바뀌었으면 MAIL_012, 만든 스크립트가 한도를 넘으면 MAIL_013 */ + setMailFilters( user: string, - vacation: MailVacation, - ): Promise { - const responses = await this.jmap.call( - user, - (accountId) => [ - [ - 'VacationResponse/set', - { accountId, update: { [VACATION_ID]: toJmapVacation(vacation) } }, - 's', - ], - ['VacationResponse/get', { accountId, ids: [VACATION_ID] }, 'v'], - ], - { capabilities: [VACATION_CAPABILITY] }, - ); - const set = expectResult( - responses, - 's', - 'VacationResponse/set', - ); - const failure = firstSetError(set.notUpdated); - if (failure) { - throw jmapFailure(failure, { method: 'VacationResponse/set' }); - } - if ( - !set.updated || - !Object.prototype.hasOwnProperty.call(set.updated, VACATION_ID) - ) { - throw new BusinessException('MAIL_001', { - context: { reason: 'vacation-not-updated' }, - }); - } - const { list = [] } = expectResult>( - responses, - 'v', - 'VacationResponse/get', - ); - return toVacation(list[0]); + update: MailFilterUpdate, + ): Promise { + return this.filterScripts.setFilters(user, update); } async getUsage(user: string): Promise { diff --git a/backend/src/modules/mail/jmap/jmap-results.ts b/backend/src/modules/mail/jmap/jmap-results.ts new file mode 100644 index 0000000..72ea2f0 --- /dev/null +++ b/backend/src/modules/mail/jmap/jmap-results.ts @@ -0,0 +1,51 @@ +import { BusinessException } from '@/common/errors/business.exception'; +import { jmapFailure } from './jmap.errors'; +import type { JmapError, JmapInvocation } from './jmap.types'; + +/** + * JMAP 응답 읽기 — 어댑터(jmap-mail.provider.ts)와 엔진 규칙 저장(jmap-filter-script.ts)이 같이 쓴다. + * 이 모듈의 타입 · 함수는 `modules/mail/jmap/` 밖으로 내보내지 않는다. + */ + +export type MethodResult = + | { ok: true; args: Record } + | { ok: false; error: JmapError }; + +/** 호출 id·메서드 이름으로 응답을 찾는다 (onSuccessUpdateEmail 처럼 한 호출 id 에 응답이 둘일 수 있다) */ +export function findResult( + responses: JmapInvocation[], + callId: string, + method: string, +): MethodResult { + for (const [name, args, id] of responses) { + if (id !== callId) continue; + if (name === 'error') return { ok: false, error: args }; + if (name === method) return { ok: true, args }; + } + throw new BusinessException('MAIL_001', { + context: { reason: 'missing-response', method }, + }); +} + +/** 성공 응답을 꺼낸다. 메서드 오류면 코드가 있는 예외로 바꾼다 */ +export function expectResult( + responses: JmapInvocation[], + callId: string, + method: string, +): T { + const result = findResult(responses, callId, method); + if (!result.ok) throw jmapFailure(result.error, { method }); + return result.args as T; +} + +/** id 하나로 처리한 SetError — 엔진이 id 를 정규화해 키가 달라질 수 있어 첫 값을 본다 */ +export function firstSetError( + errors: Record | undefined, +): JmapError | undefined { + return errors ? Object.values(errors)[0] : undefined; +} + +/** 엔진이 알려 준 상태 문자열인가 (비었거나 없으면 아니다) */ +export function isState(value: unknown): value is string { + return typeof value === 'string' && value !== ''; +} diff --git a/backend/src/modules/mail/jmap/jmap.mapper.ts b/backend/src/modules/mail/jmap/jmap.mapper.ts index 6d75af2..b9b2ba3 100644 --- a/backend/src/modules/mail/jmap/jmap.mapper.ts +++ b/backend/src/modules/mail/jmap/jmap.mapper.ts @@ -334,7 +334,8 @@ export function toUsage(quota: JmapQuota): MailUsage { } /** - * 부재중 자동 응답 → 우리 모양 (계약 2절 "부재중 자동 응답"). + * 엔진의 VacationResponse → 우리 모양 (계약 2절 "부재중 자동 응답") — 필터 · 부재중을 두는 우리 스크립트가 아직 없을 때 가져온다 + * (4단계에 저장한 값 이어받기 — jmap-filter-script.ts). * 엔진에 없는 값(null)의 제목·내용은 빈 문자열, 날짜는 ISO 8601 UTC 로 — 한 번도 설정하지 않은 계정은 * `{ isEnabled: false, fromDate: null, toDate: null, subject: '', textBody: '' }` 가 된다. 서식 본문(htmlBody)은 쓰지 않는다. */ @@ -351,31 +352,14 @@ export function toVacation( } /** - * 날짜를 JMAP UTCDate 로 — 초 단위까지, 소수 초 없이 'Z' 로 끝낸다 - * (RFC 8620 §1.4: 소수 초가 0 이면 쓰지 않는다. 화면이 보내는 값은 0시라 잃는 것이 없다) + * 날짜를 JMAP UTCDate 꼴로 — 초 단위까지, 소수 초 없이 'Z' 로 끝낸다 (RFC 8620 §1.4: 소수 초가 0 이면 쓰지 않는다). + * 부재중 자동 응답의 기간을 엔진 규칙(Sieve 의 currentdate)에 넣을 때 쓴다 — 엔진이 VacationResponse 로 만들던 모양과 같다. + * 화면이 보내는 값은 0시라 잃는 것이 없다 */ export function toJmapUtcDate(iso: string): string { return new Date(Date.parse(iso)).toISOString().replace(/\.\d+Z$/, 'Z'); } -/** - * 우리 모양 → VacationResponse 전체 교체 값. 빈 제목·내용은 null(엔진의 "정하지 않음" — 끈 채로 저장할 때만 비어 있다). - * 서식 본문은 늘 비운다 — 다른 메일 프로그램이 넣어 둔 것도 지운다 (글자만 보낸다, 계약 2절) - */ -export function toJmapVacation( - vacation: MailVacation, -): Record { - return { - isEnabled: vacation.isEnabled, - fromDate: - vacation.fromDate === null ? null : toJmapUtcDate(vacation.fromDate), - toDate: vacation.toDate === null ? null : toJmapUtcDate(vacation.toDate), - subject: vacation.subject === '' ? null : vacation.subject, - textBody: vacation.textBody === '' ? null : vacation.textBody, - htmlBody: null, - }; -} - /** 발신 ID — 직원 주소와 같은 것을 고르고, 없으면 첫 번째 */ export function pickIdentity( identities: JmapIdentity[], diff --git a/backend/src/modules/mail/jmap/jmap.types.ts b/backend/src/modules/mail/jmap/jmap.types.ts index 216ec52..a114860 100644 --- a/backend/src/modules/mail/jmap/jmap.types.ts +++ b/backend/src/modules/mail/jmap/jmap.types.ts @@ -111,6 +111,19 @@ export interface JmapVacationResponse { htmlBody?: string | null; } +/** + * RFC 9661 SieveScript — 계정마다 켜진 스크립트는 하나다. 엔진의 VacationResponse 도 `vacation` 이라는 스크립트다 + * (Stalwart 0.16.27, 2026-10-28 확인 — 그 스크립트는 고칠 수 없다) + */ +export interface JmapSieveScript { + id: string; + /** 계정 안에서 하나 — 같은 이름으로 만들면 alreadyExists */ + name?: string | null; + isActive?: boolean; + /** 스크립트 글(application/sieve) — 내려받기 주소로 읽는다 */ + blobId?: string; +} + export interface JmapIdentity { id: string; name?: string; diff --git a/backend/src/modules/mail/jmap/mime-text.spec.ts b/backend/src/modules/mail/jmap/mime-text.spec.ts new file mode 100644 index 0000000..61c0ddb --- /dev/null +++ b/backend/src/modules/mail/jmap/mime-text.spec.ts @@ -0,0 +1,96 @@ +import { + base64TextBody, + encodeHeaderWords, + plainTextMimeEntity, +} from './mime-text'; + +/** encoded-word 를 읽는 쪽처럼 되돌린다 — 낱말 사이의 빈칸은 버린다 (RFC 2047 §6.2) */ +function decodeWords(encoded: string): string { + const bytes = encoded.split(' ').map((word) => { + const match = /^=\?UTF-8\?B\?([A-Za-z0-9+/=]*)\?=$/.exec(word); + if (!match) throw new Error(`encoded-word 가 아니다: ${word}`); + return Buffer.from(match[1], 'base64'); + }); + return Buffer.concat(bytes).toString('utf8'); +} + +describe('encodeHeaderWords — 부재중 제목 (RFC 2047)', () => { + it('짧은 제목은 낱말 하나 — UTF-8 의 base64', () => { + expect(encodeHeaderWords('부재중입니다')).toBe( + `=?UTF-8?B?${Buffer.from('부재중입니다').toString('base64')}?=`, + ); + }); + + it('ASCII 만 남는다 — 따옴표 · 역슬래시 · ${ 가 글자에 있어도 결과에는 없다', () => { + const encoded = encodeHeaderWords('휴가 "중" \\ ${x}'); + expect(encoded).toMatch(/^[\x21-\x7e ]+$/); + expect(encoded).not.toMatch(/["\\]|\$\{/); + expect(decodeWords(encoded)).toBe('휴가 "중" \\ ${x}'); + }); + + it('긴 제목은 75자 이하의 낱말들로 나눠 빈칸으로 잇는다 — 한글 150자는 한 낱말에 15자씩 10낱말', () => { + const subject = '가'.repeat(150); + const words = encodeHeaderWords(subject).split(' '); + + expect(words).toHaveLength(10); + for (const word of words) expect(word.length).toBeLessThanOrEqual(75); + expect(decodeWords(words.join(' '))).toBe(subject); + }); + + it('글자 경계에서 나눈다 — 한 글자의 바이트나 이모지(서로게이트 쌍)를 두 낱말에 나누지 않는다', () => { + // 'a' 하나 뒤에 3바이트 글자들 · 4바이트 이모지 — 45바이트 경계가 글자 중간에 걸리는 모양 + for (const subject of [ + `a${'가'.repeat(40)}`, + `ab${'🌴'.repeat(30)}`, + `${'😀가a'.repeat(20)}`, + ]) { + const words = encodeHeaderWords(subject).split(' '); + for (const word of words) { + expect(word.length).toBeLessThanOrEqual(75); + // 낱말 하나만 되돌려도 깨진 글자(U+FFFD)가 없다 = 글자 경계에서 나뉘었다 + expect(decodeWords(word)).not.toContain('\ufffd'); + } + expect(decodeWords(words.join(' '))).toBe(subject); + } + }); + + it('빈 제목은 빈 문자열', () => { + expect(encodeHeaderWords('')).toBe(''); + }); +}); + +describe('base64TextBody · plainTextMimeEntity — 부재중 내용', () => { + it('줄바꿈은 모두 CRLF 로 맞춘 뒤 base64 — LF · CR 홀로 · CRLF', () => { + const encoded = base64TextBody('첫 줄\n둘째\r셋째\r\n넷째'); + expect(Buffer.from(encoded, 'base64').toString('utf8')).toBe( + '첫 줄\r\n둘째\r\n셋째\r\n넷째', + ); + }); + + it('base64 는 76자 줄로 나눈다 (RFC 2045) — 한글 600자', () => { + const text = '나'.repeat(600); + const lines = base64TextBody(text).split('\r\n'); + + expect(lines.length).toBeGreaterThan(1); + for (const line of lines.slice(0, -1)) expect(line).toHaveLength(76); + expect(lines[lines.length - 1].length).toBeLessThanOrEqual(76); + expect(Buffer.from(lines.join(''), 'base64').toString('utf8')).toBe(text); + }); + + it('MIME 개체 — 형식 · 인코딩 머리, 빈 줄, 본문, 마지막 줄바꿈 (엔진이 만들던 모양)', () => { + expect(plainTextMimeEntity('휴가입니다.')).toBe( + 'Content-Type: text/plain; charset="utf-8"\r\n' + + 'Content-Transfer-Encoding: base64\r\n' + + '\r\n' + + `${Buffer.from('휴가입니다.').toString('base64')}\r\n`, + ); + }); + + it('본문에 ${ · 역슬래시가 있어도 base64 라 결과에는 없다 — 빈 내용은 빈 본문', () => { + const entity = plainTextMimeEntity('${x} \\ "따옴표"'); + expect(entity.split('\r\n\r\n')[1]).not.toMatch(/["\\]|\$\{/); + expect(plainTextMimeEntity('')).toBe( + 'Content-Type: text/plain; charset="utf-8"\r\nContent-Transfer-Encoding: base64\r\n\r\n\r\n', + ); + }); +}); diff --git a/backend/src/modules/mail/jmap/mime-text.ts b/backend/src/modules/mail/jmap/mime-text.ts new file mode 100644 index 0000000..488e543 --- /dev/null +++ b/backend/src/modules/mail/jmap/mime-text.ts @@ -0,0 +1,83 @@ +/** + * 부재중 자동 응답을 엔진 규칙(Sieve)에 넣을 때의 글 모양 (순수 함수) — 제목은 RFC 2047 encoded-word, 내용은 base64 MIME 본문. + * 둘 다 ASCII 로만 나오므로 Sieve 문자열 안에 따옴표 · 역슬래시 · `${` 가 생기지 않는다 + * (엔진은 require 와 상관없이 문자열의 `${…}` 를 늘 변수로 펼친다 — 계약 2절 "만드는 Sieve", 2026-10-28 확인). + */ + +const CRLF = '\r\n'; + +const ENCODED_WORD_PREFIX = '=?UTF-8?B?'; +const ENCODED_WORD_SUFFIX = '?='; +/** encoded-word 하나의 최대 길이 (RFC 2047 §2) */ +const ENCODED_WORD_MAX_LENGTH = 75; +/** + * encoded-word 하나에 넣는 UTF-8 바이트 — base64 는 3바이트를 4자로 쓰므로 앞뒤 표시(12자)를 빼고 75자에 드는 만큼. + * (75 - 12) / 4 를 내림한 15묶음 × 3 = 45바이트 → base64 60자 → 낱말 72자 + */ +const ENCODED_WORD_MAX_BYTES = + Math.floor( + (ENCODED_WORD_MAX_LENGTH - + ENCODED_WORD_PREFIX.length - + ENCODED_WORD_SUFFIX.length) / + 4, + ) * 3; + +/** MIME 본문의 base64 한 줄 길이 (RFC 2045 §6.8 — 76자 이하) */ +const BASE64_LINE_LENGTH = 76; + +/** + * 머리글 값(제목)을 RFC 2047 encoded-word(B, UTF-8)로 — 낱말 하나가 75자를 넘지 않게 **글자 경계에서** 나눠 빈칸으로 잇는다 + * (한 글자의 UTF-8 바이트를 두 낱말에 나누지 않는다 — RFC 2047 §5. 낱말 사이의 빈칸은 읽는 쪽이 버린다 — §6.2). + * 코드 포인트 단위로 세어 이모지(서로게이트 쌍)도 나누지 않는다. 빈 문자열이면 빈 문자열 + */ +export function encodeHeaderWords(text: string): string { + const words: string[] = []; + let pending: Buffer[] = []; + let pendingBytes = 0; + const flush = () => { + words.push( + `${ENCODED_WORD_PREFIX}${Buffer.concat(pending).toString('base64')}${ENCODED_WORD_SUFFIX}`, + ); + pending = []; + pendingBytes = 0; + }; + + for (const character of text) { + const bytes = Buffer.from(character, 'utf8'); + if ( + pendingBytes > 0 && + pendingBytes + bytes.length > ENCODED_WORD_MAX_BYTES + ) { + flush(); + } + pending.push(bytes); + pendingBytes += bytes.length; + } + if (pendingBytes > 0) flush(); + return words.join(' '); +} + +/** 글자 본문을 base64 로 — 줄바꿈은 모두 CRLF 로 맞춘 뒤(LF · CR 홀로 · CRLF), 76자 줄로 나눈다 */ +export function base64TextBody(text: string): string { + const normalized = text.replace(/\r\n|\r|\n/g, CRLF); + const encoded = Buffer.from(normalized, 'utf8').toString('base64'); + const lines: string[] = []; + for (let start = 0; start < encoded.length; start += BASE64_LINE_LENGTH) { + lines.push(encoded.slice(start, start + BASE64_LINE_LENGTH)); + } + return lines.join(CRLF); +} + +/** + * 글자만 있는 MIME 개체 — 머리(형식 · 인코딩), 빈 줄, base64 본문, 마지막 줄바꿈. + * 엔진이 VacationResponse 로 만들던 `vacation :mime` 의 모양 그대로다 (본문만 base64 — 같은 날 확인) + */ +export function plainTextMimeEntity(text: string): string { + return [ + 'Content-Type: text/plain; charset="utf-8"', + 'Content-Transfer-Encoding: base64', + '', + base64TextBody(text), + '', + ].join(CRLF); +} diff --git a/backend/src/modules/mail/jmap/sieve-script.spec.ts b/backend/src/modules/mail/jmap/sieve-script.spec.ts new file mode 100644 index 0000000..876f5cd --- /dev/null +++ b/backend/src/modules/mail/jmap/sieve-script.spec.ts @@ -0,0 +1,313 @@ +import type { MailFilterRule, MailVacation } from '@/modules/mail/mail.types'; +import { + FILTER_SOURCE_MARKER, + NEVER_CONFIGURED_VACATION, + filterSourceLine, + readFilterSource, + type FilterScriptSource, +} from './filter-source'; +import { buildFilterScript } from './sieve-script'; + +const b64 = (text: string) => Buffer.from(text, 'utf8').toString('base64'); + +const rule = ( + id: string, + extra: Partial = {}, +): MailFilterRule => ({ + id, + name: '', + isEnabled: true, + match: 'all', + conditions: [{ field: 'subject', operator: 'contains', value: '견적' }], + actions: { + moveToMailboxId: null, + markAsRead: true, + flag: false, + forwardTo: null, + }, + ...extra, +}); + +const vacationOn: MailVacation = { + isEnabled: true, + fromDate: '2026-11-02T15:00:00.000Z', + toDate: '2026-11-07T15:00:00.000Z', + subject: '부재중입니다', + textBody: '휴가입니다.\n급한 일은 이도윤 님께.', +}; + +/** 대표 원본 — 하나라도(옮기기 · 표시 둘) · 꺼 둔 규칙 · 모든 메일(표시 · 전달) · 모두(받는 사람 · 본문 포함하지 않음, 없는 메일함) */ +const representative: FilterScriptSource = { + rules: [ + rule('r1', { + name: '거래처', + match: 'any', + conditions: [ + { + field: 'from', + operator: 'contains', + value: '@hanbit-partners.co.kr', + }, + { field: 'subject', operator: 'notContains', value: '"광고" \\ 끝' }, + ], + actions: { + moveToMailboxId: 'j', + markAsRead: true, + flag: true, + forwardTo: null, + }, + }), + rule('r2', { isEnabled: false, name: '꺼 둔 규칙' }), + rule('r3', { + match: 'always', + conditions: [], + actions: { + moveToMailboxId: null, + markAsRead: false, + flag: true, + forwardTo: 'doyun.lee@arasystem.test', + }, + }), + rule('r4', { + conditions: [ + { field: 'to', operator: 'contains', value: 'team@arasystem.test' }, + { field: 'body', operator: 'notContains', value: '수신 거부' }, + ], + actions: { + moveToMailboxId: 'nosuchid0', + markAsRead: false, + flag: false, + forwardTo: null, + }, + }), + ], + vacation: vacationOn, +}; + +/** 스크립트의 줄 — 머리 주석 두 줄(설명 · 원본)은 따로 본다 */ +const linesOf = (text: string) => text.split('\r\n'); + +/** 스크립트 머리의 원본 — 이 판의 원본이 아니면 null */ +const sourceOf = (text: string): FilterScriptSource | null => { + const reading = readFilterSource(text); + return reading.status === 'ok' ? reading.source : null; +}; + +describe('buildFilterScript — 규칙 · 부재중 → 엔진 규칙(Sieve)', () => { + it('대표 원본의 스크립트 그대로 — 머리 주석(설명 · 원본), 쓰는 확장, 스팸 막기(엔진의 분류 표시) 안에 부재중 → 켠 규칙 차례로', () => { + const { text } = buildFilterScript(representative); + const lines = linesOf(text); + + expect(lines[0]).toMatch(/^# /); + expect(lines[1]).toBe(filterSourceLine(representative)); + expect(lines.slice(2)).toEqual([ + 'require ["body", "copy", "date", "fileinto", "imap4flags", "mailbox", "mailboxid", "relational", "vacation"];', + 'if not header :matches "X-Spam-Score" "spam*" {', + ' if currentdate :zone "+0000" :value "ge" "iso8601" "2026-11-02T15:00:00Z" {', + ' if currentdate :zone "+0000" :value "le" "iso8601" "2026-11-07T15:00:00Z" {', + ` vacation :mime :subject "=?UTF-8?B?${b64('부재중입니다')}?=" "Content-Type: text/plain; charset=\\"utf-8\\"`, + 'Content-Transfer-Encoding: base64', + '', + b64('휴가입니다.\r\n급한 일은 이도윤 님께.'), + '";', + ' }', + ' }', + ' if anyof (header :contains "from" "@hanbit-partners.co.kr", not header :contains "subject" "\\"광고\\" \\\\ 끝") {', + ' addflag ["\\\\Seen", "\\\\Flagged"];', + ' fileinto :mailboxid "j" "INBOX";', + ' stop;', + ' }', + ' if true {', + ' addflag ["\\\\Flagged"];', + ' redirect :copy "doyun.lee@arasystem.test";', + ' }', + ' if allof (header :contains ["to", "cc"] "team@arasystem.test", not body :text :contains "수신 거부") {', + ' fileinto :mailboxid "nosuchid0" "INBOX";', + ' stop;', + ' }', + '}', + '', + ]); + }); + + it('스팸 막기는 엔진의 분류 표시(X-Spam-Score 의 spam)를 본다 — spamtest 점수 · 그 확장은 쓰지 않는다', () => { + const { text } = buildFilterScript(representative); + const lines = linesOf(text); + + expect(lines[3]).toBe('if not header :matches "X-Spam-Score" "spam*" {'); + expect(text).not.toContain('spamtest'); + expect(text).not.toContain('comparator-i;ascii-numeric'); + expect(text).not.toContain('X-Spam-Status'); + }); + + it('줄바꿈은 모두 CRLF — 홀로 있는 LF 가 없고 마지막 줄도 CRLF 로 끝난다', () => { + const { text } = buildFilterScript(representative); + expect(text.replace(/\r\n/g, '')).not.toMatch(/[\r\n]/); + expect(text.endsWith('\r\n')).toBe(true); + }); + + it('꺼 둔 규칙은 원본에만 있다 — Sieve 에는 없다', () => { + const { text } = buildFilterScript(representative); + const sieve = linesOf(text).slice(2).join('\n'); + + expect(sourceOf(text)?.rules.map((r) => r.id)).toEqual([ + 'r1', + 'r2', + 'r3', + 'r4', + ]); + // r2 의 조건(제목에 '견적')은 Sieve 에 없다 + expect(sieve).not.toContain('견적'); + }); + + it('찾을 글의 따옴표 · 역슬래시만 이스케이프한다 — 한국어 · 그 밖의 글자는 그대로', () => { + const { text } = buildFilterScript({ + rules: [ + rule('q', { + conditions: [ + { field: 'subject', operator: 'contains', value: 'a"b\\c 가$나{}' }, + ], + }), + ], + vacation: NEVER_CONFIGURED_VACATION, + }); + expect(text).toContain( + 'if allof (header :contains "subject" "a\\"b\\\\c 가$나{}") {', + ); + }); + + it('짝 없는 서로게이트 — 원본 JSON 은 \\udc00 꼴로 남고, 엔진에 올리는 UTF-8 에서 Sieve 의 글만 U+FFFD 가 된다 (바이트 수도 그 값)', () => { + const built = buildFilterScript({ + rules: [ + rule('s1', { + conditions: [ + { field: 'subject', operator: 'contains', value: 'a\udc00b' }, + ], + }), + ], + vacation: NEVER_CONFIGURED_VACATION, + }); + const uploaded = Buffer.from(built.text, 'utf8').toString('utf8'); + + expect(linesOf(uploaded)[1]).toContain('"value":"a\\udc00b"'); + expect(uploaded).toContain('header :contains "subject" "a\ufffdb"'); + expect(sourceOf(uploaded)?.rules[0].conditions[0].value).toBe('a\udc00b'); + expect(built.bytes).toBe(Buffer.byteLength(uploaded, 'utf8')); + }); + + it('하나뿐인 조건도 allof · anyof 로 묶는다 — 쓰는 확장만 require 에 (표시만 하는 규칙)', () => { + const lines = linesOf( + buildFilterScript({ + rules: [rule('one')], + vacation: NEVER_CONFIGURED_VACATION, + }).text, + ); + expect(lines.slice(2)).toEqual([ + 'require ["imap4flags"];', + 'if not header :matches "X-Spam-Score" "spam*" {', + ' if allof (header :contains "subject" "견적") {', + ' addflag ["\\\\Seen"];', + ' }', + '}', + '', + ]); + }); + + it('켠 규칙도 부재중도 없으면 머리 주석만 — require · 스팸 막기도 없다 (엔진이 받는 것을 확인함)', () => { + for (const rules of [[], [rule('off', { isEnabled: false })]]) { + const { text } = buildFilterScript({ + rules, + vacation: NEVER_CONFIGURED_VACATION, + }); + const lines = linesOf(text); + expect(lines).toHaveLength(3); + expect(lines[1].startsWith(FILTER_SOURCE_MARKER)).toBe(true); + expect(lines[2]).toBe(''); + } + }); + + describe('부재중', () => { + const vacationLines = (vacation: MailVacation) => + linesOf(buildFilterScript({ rules: [], vacation }).text).slice(2); + + it('날짜가 없으면 기간 검사 없이 — date 확장도 쓰지 않는다', () => { + const lines = vacationLines({ + ...vacationOn, + fromDate: null, + toDate: null, + }); + expect(lines[0]).toBe('require ["vacation"];'); + expect(lines[2]).toMatch(/^ {2}vacation :mime :subject /); + }); + + it('시작만 · 끝만 있으면 그 검사 하나 — 시작은 ge, 끝은 le (엔진이 만들던 모양), 견주는 시각은 UTC(:zone "+0000")', () => { + const fromOnly = vacationLines({ ...vacationOn, toDate: null }); + expect(fromOnly[2]).toBe( + ' if currentdate :zone "+0000" :value "ge" "iso8601" "2026-11-02T15:00:00Z" {', + ); + expect(fromOnly[3]).toMatch(/^ {4}vacation :mime /); + + const toOnly = vacationLines({ ...vacationOn, fromDate: null }); + expect(toOnly[2]).toBe( + ' if currentdate :zone "+0000" :value "le" "iso8601" "2026-11-07T15:00:00Z" {', + ); + }); + + it('날짜는 소수 초 없는 UTC 로 쓴다', () => { + const lines = vacationLines({ + ...vacationOn, + fromDate: '2026-11-02T15:00:00.750Z', + }); + expect(lines[2]).toContain('"2026-11-02T15:00:00Z"'); + }); + + it('제목이 비었으면 :subject 를 빼 엔진이 정하게 둔다', () => { + const lines = vacationLines({ + ...vacationOn, + fromDate: null, + toDate: null, + subject: '', + }); + expect(lines[2]).toMatch( + /^ {2}vacation :mime "Content-Type: text\/plain; charset=\\"utf-8\\"$/, + ); + }); + + it('꺼져 있으면 부재중이 없다 — 원본에는 남는다', () => { + const off = { ...vacationOn, isEnabled: false }; + const { text } = buildFilterScript({ rules: [rule('a')], vacation: off }); + expect(text).not.toContain('vacation :mime'); + expect(linesOf(text)[2]).toBe('require ["imap4flags"];'); + expect(sourceOf(text)?.vacation).toEqual(off); + }); + + it('제목 · 내용의 ${ · 따옴표 · 역슬래시는 Sieve 글에 그대로 나오지 않는다 — encoded-word · base64 (원본 주석에만)', () => { + const { text } = buildFilterScript({ + rules: [], + vacation: { + ...vacationOn, + subject: '부재 ${x} "중" \\', + textBody: '${unicode:41} \\ "따옴표"', + }, + }); + const sieve = linesOf(text).slice(2).join('\r\n'); + expect(sieve).not.toContain('${'); + expect(sieve).not.toContain('\\\\'); + }); + }); + + it('바이트 수와 가장 긴 문자열의 바이트를 함께 준다 — 한도는 부르는 쪽이 본다', () => { + const built = buildFilterScript({ + rules: [], + vacation: { + ...vacationOn, + subject: '가'.repeat(150), + textBody: '나'.repeat(600), + }, + }); + expect(built.bytes).toBe(Buffer.byteLength(built.text, 'utf8')); + // 내용 1,800바이트 → base64 2,400자 + 줄바꿈 · MIME 머리 · 따옴표 + expect(built.longestStringBytes).toBeGreaterThan(2_400); + expect(built.longestStringBytes).toBeLessThan(4_096); + }); +}); diff --git a/backend/src/modules/mail/jmap/sieve-script.ts b/backend/src/modules/mail/jmap/sieve-script.ts new file mode 100644 index 0000000..684909b --- /dev/null +++ b/backend/src/modules/mail/jmap/sieve-script.ts @@ -0,0 +1,264 @@ +import type { + MailFilterCondition, + MailFilterField, + MailFilterRule, + MailVacation, +} from '@/modules/mail/mail.types'; +import { filterSourceLine, type FilterScriptSource } from './filter-source'; +import { toJmapUtcDate } from './jmap.mapper'; +import { encodeHeaderWords, plainTextMimeEntity } from './mime-text'; + +/** + * 메일 필터 · 부재중 자동 응답 → 엔진 규칙(Sieve 스크립트, RFC 5228) — 순수 함수. 계약 2절 "만드는 Sieve" (Stalwart 0.16.27, 2026-10-28 확인한 모양): + * + * ``` + * # 설명 한 줄 + * # arasystem-source: {원본 JSON — filter-source.ts} + * require [쓰는 확장만]; + * if not header :matches "X-Spam-Score" "spam*" { ← 엔진이 스팸으로 분류한 메일에는 아무것도 하지 않는다 + * 부재중 — 켜져 있으면 (기간은 currentdate, UTC) + * 규칙 — 켠 것만 위에서부터: if <조건> { addflag → redirect :copy → fileinto :mailboxid + stop } + * } + * ``` + * + * - 스팸: 엔진은 규칙의 옮기기가 스팸메일함 분류를 이기므로 막는다 — 엔진의 **분류 표시**를 본다 (SPAM_VERDICT_HEADER). + * `spamtest` 의 점수는 쓰지 않는다: 점수를 정수로 내린 값(10에서 멈춤)이라 기준(엔진 설정 `scoreSpam` — 지금 5)과 상관없는 문턱을 + * 우리가 따로 정하게 된다 — 9.70점 스팸이 "10 이상"을 지나 규칙대로 옮겨지고 위조한 보낸 사람에게 부재중 응답까지 나갔다 + * (2026-10-28 실엔진). 분류 표시를 보면 엔진의 기준을 바꿔도 엔진의 분류를 그대로 따른다 + * - 부재중을 규칙보다 먼저 둔다 — 옮기는 규칙의 `stop` 이 부재중을 건너뛰지 않게 + * - 표시(`addflag`)는 옮기기보다 먼저 — 엔진은 fileinto 하는 순간의 표시만 붙인다. 옮기지 않은 메일은 마지막 표시로 받은편지함에 남는다 + * - 옮길 메일함이 없으면 대체 이름 "INBOX" 로 받은편지함에 들어온다 (`:mailboxid` 는 "fileinto" · "mailbox" · "mailboxid" 가 다 있어야 한다) + * - 전달은 사본을 남긴다(`redirect :copy`). 엔진은 한 통에 리다이렉트 하나만 한다 — 두 번째는 조용히 버린다 + * - 대소문자는 ASCII 만 무시한다 (엔진 기본 비교) + * - 글은 모두 따옴표 문자열 — `\` 와 `"` 만 이스케이프한다. `${` 는 받지 않는다(요청 검증) — 엔진이 늘 변수로 펼친다 + * - 줄바꿈은 CRLF. 규칙 글이 없으면(켠 규칙도 부재중도 없음) 머리 주석만 남는다 + */ + +/** + * 만든 스크립트의 상한 (UTF-8 바이트) — 엔진 한도(102,400)보다 낮게 둔다. 넘으면 저장하지 않고 400 MAIL_013 (계약 2절 "크기") + */ +export const FILTER_SCRIPT_MAX_BYTES = 90_000; + +/** 엔진의 문자열 하나 한도 (UTF-8 바이트) — 넘는 문자열이 있으면 엔진이 받지 않는다(invalidScript). 만든 문자열이 넘으면 MAIL_013 */ +export const SIEVE_STRING_MAX_BYTES = 4_096; + +/** + * 엔진의 스팸 분류 표시 — 우리 스크립트가 돌 때 이미 붙어 있다: `spam, score=9.70` · `ham, score=2.20` (Stalwart 0.16.27, 2026-10-28 확인). + * - 머리글 검사는 같은 이름이 여럿이면 하나라도 맞을 때 참이다 — 바깥에서 넣어 온 같은 이름의 머리글은 지워지지 않고 엔진 것과 함께 남지만, + * 위조한 `ham` 으로 진짜 스팸을 숨길 수는 없다. 위조한 `spam` 은 그 메일에 우리 규칙이 적용되지 않게 할 뿐이다(엔진 판정대로 들어간다) + * - `X-Spam-Status: Yes|No` 는 쓰지 않는다 — 스크립트가 돌 때는 없고, 끝난 뒤 실제로 들어간 곳에 따라 붙는다(우리 규칙이 옮긴 스팸은 No) + * - 엔진의 스팸 필터는 인증 없는 세션(바깥에서 오는 메일)에만 돈다 — 사내 메일에는 이 머리글이 없어 규칙이 그대로 적용된다 + */ +const SPAM_VERDICT_HEADER = 'X-Spam-Score'; +/** 분류 표시가 스팸인지 (`:matches` — 대소문자는 ASCII 만 무시한다) */ +const SPAM_VERDICT_PATTERN = 'spam*'; + +/** + * 부재중 기간을 견주는 시간대 — 기간 값(UTC, 'Z')과 같게 둔다. 엔진 서버의 시간대에 기대지 않는다 + * (개발 엔진에서 `currentdate :zone "+0000" :value "ge" "iso8601" "2020-01-01T00:00:00Z"` 가 참 · "lt" 가 거짓 — 2026-10-28 확인) + */ +const VACATION_PERIOD_ZONE = '+0000'; + +/** 옮길 메일함이 없을 때 쓰는 대체 이름 — 받은편지함 */ +const FALLBACK_MAILBOX = 'INBOX'; + +const CRLF = '\r\n'; +const INDENT = ' '; + +/** 첫 줄 — 엔진의 관리 화면 등에서 이 스크립트를 보는 사람에게 */ +const DESCRIPTION_LINE = + '# ARA SYSTEM 웹메일의 필터 · 부재중 자동 응답 — 웹메일이 아래 원본(JSON)으로 만든다. 여기서 고친 것은 웹메일에서 저장할 때 덮어쓴다.'; + +/** 견줄 곳 → Sieve 검사 (찾을 글을 받는다) — 표시 이름 · 주소 · 인코딩된 한국어 제목 · 본문 모두 맞는다 (같은 날 확인) */ +const FIELD_TESTS: Record string> = { + from: (value) => `header :contains "from" ${value}`, + to: (value) => `header :contains ["to", "cc"] ${value}`, + subject: (value) => `header :contains "subject" ${value}`, + body: (value) => `body :text :contains ${value}`, +}; + +/** require 에 넣는 확장 — 이 차례로 쓴다 (쓰는 것만 고른다). 스팸 막기의 `header :matches` 는 기본 명령이라 확장이 없다 */ +type SieveExtension = + | 'body' + | 'copy' + | 'date' + | 'fileinto' + | 'imap4flags' + | 'mailbox' + | 'mailboxid' + | 'relational' + | 'vacation'; + +const EXTENSION_ORDER: readonly SieveExtension[] = [ + 'body', + 'copy', + 'date', + 'fileinto', + 'imap4flags', + 'mailbox', + 'mailboxid', + 'relational', + 'vacation', +]; + +/** 만든 스크립트 */ +export interface BuiltFilterScript { + readonly text: string; + /** 글 전체의 UTF-8 바이트 */ + readonly bytes: number; + /** 가장 긴 따옴표 문자열의 UTF-8 바이트 (이스케이프 포함) — SIEVE_STRING_MAX_BYTES 와 견준다 */ + readonly longestStringBytes: number; +} + +/** 스크립트를 만드는 동안 쓴 확장 · 가장 긴 문자열을 모은다 */ +class ScriptWriter { + readonly extensions = new Set(); + longestStringBytes = 0; + + /** Sieve 따옴표 문자열 — `\` 와 `"` 만 이스케이프한다 (RFC 5228 §2.4.2) */ + quote(value: string): string { + const quoted = `"${value.replace(/[\\"]/g, '\\$&')}"`; + this.longestStringBytes = Math.max( + this.longestStringBytes, + Buffer.byteLength(quoted, 'utf8'), + ); + return quoted; + } + + use(...extensions: SieveExtension[]): void { + for (const extension of extensions) this.extensions.add(extension); + } +} + +function indent(depth: number, line: string): string { + return `${INDENT.repeat(depth)}${line}`; +} + +function conditionTest( + writer: ScriptWriter, + condition: MailFilterCondition, +): string { + if (condition.field === 'body') writer.use('body'); + const test = FIELD_TESTS[condition.field](writer.quote(condition.value)); + return condition.operator === 'notContains' ? `not ${test}` : test; +} + +/** 규칙의 조건 — 모두 allof, 하나라도 anyof, 모든 메일 true */ +function ruleTest(writer: ScriptWriter, rule: MailFilterRule): string { + if (rule.match === 'always') return 'true'; + const tests = rule.conditions.map((condition) => + conditionTest(writer, condition), + ); + return `${rule.match === 'all' ? 'allof' : 'anyof'} (${tests.join(', ')})`; +} + +/** 규칙의 할 일 — 표시 → 전달 → 옮기기(+ 멈춤). 있는 것만 */ +function ruleCommands(writer: ScriptWriter, rule: MailFilterRule): string[] { + const { moveToMailboxId, markAsRead, flag, forwardTo } = rule.actions; + const commands: string[] = []; + + const flags = [ + ...(markAsRead ? ['\\Seen'] : []), + ...(flag ? ['\\Flagged'] : []), + ]; + if (flags.length > 0) { + writer.use('imap4flags'); + commands.push( + `addflag [${flags.map((name) => writer.quote(name)).join(', ')}];`, + ); + } + if (forwardTo !== null) { + writer.use('copy'); + commands.push(`redirect :copy ${writer.quote(forwardTo)};`); + } + if (moveToMailboxId !== null) { + writer.use('fileinto', 'mailbox', 'mailboxid'); + commands.push( + `fileinto :mailboxid ${writer.quote(moveToMailboxId)} ${writer.quote(FALLBACK_MAILBOX)};`, + 'stop;', + ); + } + return commands; +} + +function ruleBlock( + writer: ScriptWriter, + rule: MailFilterRule, + depth: number, +): string[] { + return [ + indent(depth, `if ${ruleTest(writer, rule)} {`), + ...ruleCommands(writer, rule).map((command) => indent(depth + 1, command)), + indent(depth, '}'), + ]; +} + +/** + * 부재중 — 엔진이 VacationResponse 로 만들던 모양에 시간대만 더했다: 기간은 currentdate(시작 ge · 끝 le — 끝 시각 그 1초에도 답한다, 지금과 같다), + * 견주는 시각은 UTC(`:zone "+0000"` — 기간 값도 UTC 다), `vacation :mime` 에 base64 본문, 제목은 encoded-word. + * `:days` · `:handle` 은 쓰지 않는다(엔진 기본값 — 같은 사람에게 다시 답하는 간격). + * 제목이 비었으면(VacationResponse 에서 가져온 값 등) `:subject` 를 빼 엔진이 정하게 둔다 + */ +function vacationBlock( + writer: ScriptWriter, + vacation: MailVacation, + depth: number, +): string[] { + writer.use('vacation'); + const subject = encodeHeaderWords(vacation.subject); + const command = `vacation :mime ${subject ? `:subject ${writer.quote(subject)} ` : ''}${writer.quote(plainTextMimeEntity(vacation.textBody))};`; + /** 기간 검사 하나 — 지금 시각(UTC)을 그 시각과 견준다 */ + const period = (relation: 'ge' | 'le', iso: string) => + `currentdate :zone ${writer.quote(VACATION_PERIOD_ZONE)} :value "${relation}" "iso8601" ${writer.quote(toJmapUtcDate(iso))}`; + + const periods = [ + ...(vacation.fromDate === null ? [] : [period('ge', vacation.fromDate)]), + ...(vacation.toDate === null ? [] : [period('le', vacation.toDate)]), + ]; + if (periods.length > 0) writer.use('date', 'relational'); + + const lines = periods.map((test, level) => + indent(depth + level, `if ${test} {`), + ); + lines.push(indent(depth + periods.length, command)); + for (let level = periods.length - 1; level >= 0; level -= 1) { + lines.push(indent(depth + level, '}')); + } + return lines; +} + +/** 규칙 · 부재중 원본으로 엔진 규칙 글을 만든다 — 크기 한도는 부르는 쪽이 본다(FILTER_SCRIPT_MAX_BYTES · SIEVE_STRING_MAX_BYTES) */ +export function buildFilterScript( + source: FilterScriptSource, +): BuiltFilterScript { + const writer = new ScriptWriter(); + const body: string[] = []; + if (source.vacation.isEnabled) { + body.push(...vacationBlock(writer, source.vacation, 1)); + } + for (const rule of source.rules) { + if (rule.isEnabled) body.push(...ruleBlock(writer, rule, 1)); + } + + const lines = [DESCRIPTION_LINE, filterSourceLine(source)]; + if (body.length > 0) { + const spamCheck = `if not header :matches ${writer.quote(SPAM_VERDICT_HEADER)} ${writer.quote(SPAM_VERDICT_PATTERN)} {`; + const extensions = EXTENSION_ORDER.filter((extension) => + writer.extensions.has(extension), + ); + // 빈 목록(`require [];`)은 문법 오류다 — 할 일마다 확장을 쓰므로 늘 하나 이상이지만, 없으면 줄을 뺀다 + if (extensions.length > 0) { + lines.push( + `require [${extensions.map((extension) => writer.quote(extension)).join(', ')}];`, + ); + } + lines.push(spamCheck, ...body, '}'); + } + + const text = `${lines.join(CRLF)}${CRLF}`; + return { + text, + bytes: Buffer.byteLength(text, 'utf8'), + longestStringBytes: writer.longestStringBytes, + }; +} diff --git a/backend/src/modules/mail/mail-filter.controller.ts b/backend/src/modules/mail/mail-filter.controller.ts new file mode 100644 index 0000000..6f72c21 --- /dev/null +++ b/backend/src/modules/mail/mail-filter.controller.ts @@ -0,0 +1,88 @@ +import { Body, Controller, Get, Put, UseGuards } from '@nestjs/common'; +import { ApiCookieAuth, ApiOperation, ApiTags } from '@nestjs/swagger'; + +import { + ApiErrorResponse, + ApiStandardResponse, +} from '@/common/decorators/api-standard-response.decorator'; +import { + CurrentUser, + type AuthenticatedUser, +} from '@/common/decorators/current-user.decorator'; +import { JwtAuthGuard } from '@/common/guards/jwt-auth.guard'; +import { MailFilterSetDto, MailFilterUpdateDto } from './dto/mail-filter.dto'; +import { + MAIL_FILTERS_ROUTE, + MAX_MAIL_FILTER_CONDITIONS, + MAX_MAIL_FILTER_NAME_LENGTH, + MAX_MAIL_FILTER_RULES, + MAX_MAIL_FILTER_VALUE_LENGTH, +} from './mail.constants'; +import { MailService } from './mail.service'; + +/** 적용 규칙 — 두 엔드포인트의 설명에 함께 쓴다 (계약 2절 "메일 필터"의 적용 순서) */ +const APPLY_NOTE = + '새로 온 메일을 위의 규칙부터 차례로 견주고, 맞는 규칙의 할 일을 모두 한다(읽음 · 중요는 겹쳐 붙는다). ' + + '메일함으로 옮기는 규칙에 맞으면 거기서 멈춘다. 전달은 한 통에 한 곳(엔진 한도 — 두 번째는 엔진이 버린다)이고 사본을 남긴다. ' + + '스팸으로 판정된 메일에는 아무 규칙도 적용하지 않는다(부재중 응답도 하지 않는다). 꺼 둔 규칙 · 이미 받은 메일에는 적용하지 않는다. ' + + '필터와 부재중 자동 응답은 메일 엔진의 규칙 하나에 함께 저장된다.'; + +// 메일 필터 (계약 2절 "메일 필터", 6단계) +@ApiTags('Mail') +@ApiCookieAuth() +@UseGuards(JwtAuthGuard) +@ApiErrorResponse('AUTH_001', '로그인 필요 (쿠키 없음·만료)') +@ApiErrorResponse('MAIL_001', '메일 엔진 장애') +@Controller(MAIL_FILTERS_ROUTE) +export class MailFilterController { + constructor(private readonly mail: MailService) {} + + @Get() + @ApiOperation({ + summary: '메일 필터', + description: + `${APPLY_NOTE} 한 번도 저장하지 않았으면 규칙 없이 isActive: true 다. ` + + '메일 엔진에서 다른 규칙이 켜졌거나 저장해 둔 규칙을 읽을 수 없으면 isActive: false 다(읽을 수 없으면 규칙 없음) — 지금 목록 그대로 저장하면 다시 켜진다.', + }) + @ApiStandardResponse(MailFilterSetDto, { + description: '지금 규칙 · 지문(version) · 동작하는지', + }) + get(@CurrentUser() user: AuthenticatedUser) { + return this.mail.getMailFilters(user); + } + + @Put() + @ApiOperation({ + summary: '메일 필터 저장 (전체 교체)', + description: + '켜기 · 끄기 · 순서 바꾸기 · 지우기 · 고치기 모두 규칙 전체를 받은 version 과 함께 보낸다. 부재중 자동 응답은 그대로 둔다. ' + + `규칙 ${MAX_MAIL_FILTER_RULES}개까지(id 가 겹치지 않음) · 이름 앞뒤 공백을 뺀 0~${MAX_MAIL_FILTER_NAME_LENGTH}자 · ` + + `조건은 all · any 면 1~${MAX_MAIL_FILTER_CONDITIONS}개, always 면 없음 · 찾을 글은 앞뒤 공백을 뺀 1~${MAX_MAIL_FILTER_VALUE_LENGTH}자(제어 문자 · "\${" 없음) · ` + + '할 일 하나 이상 · 옮길 메일함은 보낸편지함 · 임시보관함이 아님(없는 id 는 받는다) · 전달은 사내 주소이고 자기 주소가 아님. ' + + '글은 앞뒤 공백을 지워 저장한다. 그사이 다른 곳에서 규칙이 바뀌었으면 저장하지 않고 409 MAIL_012 — 지금 규칙이 보낸 규칙과 이미 같으면 그대로 저장한다. ' + + `저장하면 isActive 는 true 가 된다. ${APPLY_NOTE}`, + }) + @ApiStandardResponse(MailFilterSetDto, { + description: '저장한 규칙(앞뒤 공백을 지운 값) · 새 지문 · 동작하는지', + }) + @ApiErrorResponse( + 'VAL_001', + `VAL_001 형식 · 한도(규칙 ${MAX_MAIL_FILTER_RULES}개 · 조건 ${MAX_MAIL_FILTER_CONDITIONS}개 · 이름 ${MAX_MAIL_FILTER_NAME_LENGTH}자 · 찾을 글 ${MAX_MAIL_FILTER_VALUE_LENGTH}자) · ` + + '겹친 id · 묶는 방법과 조건 수가 맞지 않음 · 할 일 없음 · 제어 문자 · "${" · 바깥 주소나 자기 주소로 전달 · 보낸편지함 · 임시보관함으로 옮김 · 모르는 필드 ' + + '/ MAIL_013 만든 엔진 규칙이 엔진 한도를 넘음(조건을 줄이거나 쓰지 않는 필터를 지운다) — 모두 400, 저장하지 않았다', + ) + @ApiErrorResponse( + 'MAIL_012', + '그사이 다른 곳(다른 탭 · 기기)에서 규칙이 바뀌었다 — 저장하지 않았다 (다시 불러온 뒤 고친다). 부재중 응답만 바뀐 것은 걸리지 않는다', + ) + @ApiErrorResponse( + 'MAIL_001', + '메일 엔진 장애 · 저장하는 사이 엔진의 규칙이 계속 바뀜(세 번). 엔진이 답하기 전에 끊겼으면 저장됐을 수도 있다 — 같은 요청을 다시 보내면 된다(이미 같으면 그대로 저장한다)', + ) + update( + @CurrentUser() user: AuthenticatedUser, + @Body() dto: MailFilterUpdateDto, + ) { + return this.mail.updateMailFilters(user, dto); + } +} diff --git a/backend/src/modules/mail/mail-filters.spec.ts b/backend/src/modules/mail/mail-filters.spec.ts new file mode 100644 index 0000000..a65a1ea --- /dev/null +++ b/backend/src/modules/mail/mail-filters.spec.ts @@ -0,0 +1,185 @@ +import { + MAX_MAIL_FILTER_CONDITIONS, + MAX_MAIL_FILTER_RULES, +} from './mail.constants'; +import { + findFilterRulesProblem, + hasFilterAction, + hasSieveVariable, +} from './mail-filters'; +import type { MailFilterRule } from './mail.types'; + +const rule = ( + id: string, + extra: Partial = {}, +): MailFilterRule => ({ + id, + name: '거래처 메일', + isEnabled: true, + match: 'all', + conditions: [ + { field: 'from', operator: 'contains', value: '@hanbit-partners.co.kr' }, + ], + actions: { + moveToMailboxId: 'j', + markAsRead: false, + flag: false, + forwardTo: null, + }, + ...extra, +}); + +const noActions = { + moveToMailboxId: null, + markAsRead: false, + flag: false, + forwardTo: null, +}; + +describe('findFilterRulesProblem — 계약 2절 "메일 필터"의 타입', () => { + it('맞는 목록이면 null — 모든 메일(조건 없음) · 꺼 둔 규칙 · 이름 없음 · 조건 5개 · 빈 목록', () => { + expect( + findFilterRulesProblem([ + rule('a'), + rule('b', { match: 'always', conditions: [] }), + rule('c', { isEnabled: false, name: '' }), + rule('d', { + match: 'any', + conditions: Array.from( + { length: MAX_MAIL_FILTER_CONDITIONS }, + (_, index) => ({ + field: 'subject' as const, + operator: 'notContains' as const, + value: `광고 ${index} "따옴표" \\ {중괄호} $달러`, + }), + ), + }), + rule('e', { + actions: { ...noActions, forwardTo: 'doyun.lee@arasystem.test' }, + }), + ]), + ).toBeNull(); + expect(findFilterRulesProblem([])).toBeNull(); + }); + + it(`규칙은 ${MAX_MAIL_FILTER_RULES}개까지 · id 는 겹치지 않는다`, () => { + const many = Array.from({ length: MAX_MAIL_FILTER_RULES + 1 }, (_, i) => + rule(`r${i}`), + ); + expect(findFilterRulesProblem(many)).toEqual({ + index: null, + reason: 'too-many-rules', + }); + expect(findFilterRulesProblem(many.slice(1))).toBeNull(); + expect( + findFilterRulesProblem([rule('same'), rule('other'), rule('same')]), + ).toEqual({ index: 2, reason: 'duplicate-rule-id' }); + }); + + it.each<[string, Partial, string]>([ + ['모든 메일인데 조건이 있음', { match: 'always' }, 'conditions-for-match'], + [ + '모두인데 조건이 없음', + { match: 'all', conditions: [] }, + 'conditions-for-match', + ], + [ + '하나라도인데 조건이 없음', + { match: 'any', conditions: [] }, + 'conditions-for-match', + ], + [ + `조건 ${MAX_MAIL_FILTER_CONDITIONS + 1}개`, + { + conditions: Array.from( + { length: MAX_MAIL_FILTER_CONDITIONS + 1 }, + () => ({ + field: 'subject' as const, + operator: 'contains' as const, + value: '견적', + }), + ), + }, + 'too-many-conditions', + ], + ['할 일이 하나도 없음', { actions: noActions }, 'no-action'], + ['id 형식', { id: '규칙 1' }, 'rule-id'], + ['이름 51자', { name: 'x'.repeat(51) }, 'rule-name'], + ['이름에 줄바꿈', { name: '거래처\n메일' }, 'rule-name'], + [ + '찾을 글이 공백뿐', + { + conditions: [ + { field: 'subject', operator: 'contains', value: ' \u3000 ' }, + ], + }, + 'condition-value-length', + ], + [ + '찾을 글 201자', + { + conditions: [ + { field: 'subject', operator: 'contains', value: 'x'.repeat(201) }, + ], + }, + 'condition-value-length', + ], + [ + '찾을 글에 탭', + { conditions: [{ field: 'body', operator: 'contains', value: 'a\tb' }] }, + 'condition-value-control', + ], + [ + '찾을 글에 ${', + { conditions: [{ field: 'to', operator: 'contains', value: 'a${b}' }] }, + 'condition-value-variable', + ], + [ + '옮길 메일함 id 형식', + { actions: { ...noActions, moveToMailboxId: 'a b' } }, + 'move-mailbox-id', + ], + [ + '전달 주소 모양이 아님', + { actions: { ...noActions, forwardTo: 'doyun.lee' } }, + 'forward-address', + ], + [ + '전달 주소에 ${', + { actions: { ...noActions, forwardTo: '${x}@arasystem.test' } }, + 'forward-address-variable', + ], + ])('%s — 그 규칙의 위치와 까닭', (_label, extra, reason) => { + expect(findFilterRulesProblem([rule('ok'), rule('bad', extra)])).toEqual({ + index: 1, + reason, + }); + }); + + it('꺼 둔 규칙도 같게 본다 — 켜면 그대로 적용된다', () => { + expect( + findFilterRulesProblem([ + rule('off', { isEnabled: false, actions: noActions }), + ]), + ).toEqual({ index: 0, reason: 'no-action' }); + }); +}); + +describe('hasSieveVariable · hasFilterAction', () => { + it('${ 가 있으면 true — $ 나 { 하나씩은 괜찮다', () => { + expect(hasSieveVariable('${x}')).toBe(true); + expect(hasSieveVariable('a${')).toBe(true); + expect(hasSieveVariable('$ {x}')).toBe(false); + expect(hasSieveVariable('{$}')).toBe(false); + }); + + it('할 일 — 옮기기 · 읽음 · 중요 · 전달 가운데 하나라도', () => { + expect(hasFilterAction(noActions)).toBe(false); + expect(hasFilterAction({ ...noActions, markAsRead: true })).toBe(true); + expect(hasFilterAction({ ...noActions, flag: true })).toBe(true); + expect(hasFilterAction({ ...noActions, moveToMailboxId: 'a' })).toBe(true); + expect( + hasFilterAction({ ...noActions, forwardTo: 'a@arasystem.test' }), + ).toBe(true); + }); +}); diff --git a/backend/src/modules/mail/mail-filters.ts b/backend/src/modules/mail/mail-filters.ts new file mode 100644 index 0000000..df7c146 --- /dev/null +++ b/backend/src/modules/mail/mail-filters.ts @@ -0,0 +1,142 @@ +import { + MAIL_FILTER_RULE_ID_PATTERN, + MAIL_ID_PATTERN, + MAX_EMAIL_LENGTH, + MAX_MAIL_FILTER_CONDITIONS, + MAX_MAIL_FILTER_NAME_LENGTH, + MAX_MAIL_FILTER_RULES, + MAX_MAIL_FILTER_VALUE_LENGTH, + SIEVE_VARIABLE_MARKER, + SINGLE_LINE_TEXT_PATTERN, +} from './mail.constants'; +import type { + MailFilterActions, + MailFilterCondition, + MailFilterField, + MailFilterMatch, + MailFilterOperator, + MailFilterRule, +} from './mail.types'; + +/** + * 메일 필터 규칙 (계약 2절 "메일 필터"의 타입) — 메일 엔진과 무관한 순수 규칙. + * 요청 검증(dto/mail-filter.dto.ts)이 칸마다 형식을 보고, 서비스가 칸 사이의 규칙을 이것으로 한 번 더 본다. + * 어댑터가 엔진에 저장해 둔 원본을 읽을 때(jmap/filter-source.ts)도 같은 규칙으로 본다 — 어긋나면 깨진 원본이다. + */ + +/** 견줄 곳 · 견주는 방법 · 묶는 방법 — 요청 검증과 원본 읽기가 같이 쓴다 */ +export const MAIL_FILTER_FIELDS = [ + 'from', + 'to', + 'subject', + 'body', +] as const satisfies readonly MailFilterField[]; +export const MAIL_FILTER_OPERATORS = [ + 'contains', + 'notContains', +] as const satisfies readonly MailFilterOperator[]; +export const MAIL_FILTER_MATCHES = [ + 'all', + 'any', + 'always', +] as const satisfies readonly MailFilterMatch[]; + +/** + * 전달 주소의 대략적인 모양 — 자세한 형식은 요청 검증(IsEmail)이 본다. + * 여기서는 저장한 원본을 다시 쓸 때 엔진 규칙 글을 지킬 만큼만 본다 (빈칸 · `@` 하나) + */ +const FORWARD_ADDRESS_SHAPE = /^[^\s@]+@[^\s@]+$/; + +/** 규칙 목록이 어긋난 까닭 — 로그용 (규칙의 글은 넣지 않는다). index 는 몇 번째 규칙인지 (목록 전체의 문제면 null) */ +export interface FilterRulesProblem { + readonly index: number | null; + readonly reason: string; +} + +/** 엔진이 변수로 펼칠 표시(`${`)가 있는가 — 찾을 글 · 전달 주소에 쓰지 않는다 */ +export function hasSieveVariable(value: string): boolean { + return value.includes(SIEVE_VARIABLE_MARKER); +} + +/** 할 일이 하나라도 있는가 (옮기기 · 읽음 · 중요 · 전달) */ +export function hasFilterAction(actions: MailFilterActions): boolean { + return ( + actions.moveToMailboxId !== null || + actions.markAsRead || + actions.flag || + actions.forwardTo !== null + ); +} + +/** 조건 하나 — 찾을 글은 공백이 아닌 글자 1자 이상 · 200자 이하, 제어 문자 · `${` 없음 */ +function conditionProblem(condition: MailFilterCondition): string | null { + const { value } = condition; + if (value.trim() === '' || value.length > MAX_MAIL_FILTER_VALUE_LENGTH) { + return 'condition-value-length'; + } + if (!SINGLE_LINE_TEXT_PATTERN.test(value)) return 'condition-value-control'; + if (hasSieveVariable(value)) return 'condition-value-variable'; + return null; +} + +/** 할 일 — 하나 이상, 옮길 메일함 id 형식, 전달 주소 모양 (사내 주소인지 · 자기 주소가 아닌지는 서비스가 본다) */ +function actionsProblem(actions: MailFilterActions): string | null { + if (!hasFilterAction(actions)) return 'no-action'; + const { moveToMailboxId, forwardTo } = actions; + if (moveToMailboxId !== null && !MAIL_ID_PATTERN.test(moveToMailboxId)) { + return 'move-mailbox-id'; + } + if (forwardTo !== null) { + if ( + forwardTo.length > MAX_EMAIL_LENGTH || + !FORWARD_ADDRESS_SHAPE.test(forwardTo) || + !SINGLE_LINE_TEXT_PATTERN.test(forwardTo) + ) { + return 'forward-address'; + } + if (hasSieveVariable(forwardTo)) return 'forward-address-variable'; + } + return null; +} + +/** 규칙 하나 — id · 이름 · 묶는 방법에 맞는 조건 수 · 조건 · 할 일 */ +function ruleProblem(rule: MailFilterRule): string | null { + if (!MAIL_FILTER_RULE_ID_PATTERN.test(rule.id)) return 'rule-id'; + if ( + rule.name.length > MAX_MAIL_FILTER_NAME_LENGTH || + !SINGLE_LINE_TEXT_PATTERN.test(rule.name) + ) { + 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); +} + +/** + * 규칙 목록이 계약에 맞는지 — 어긋난 첫 까닭, 맞으면 null. + * 50개 이하 · id 가 겹치지 않음 · 규칙마다(id 형식 · 이름 50자 · 모든 메일은 조건 0개, 나머지는 1~5개 · 찾을 글 · 할 일 하나 이상). + * 꺼 둔 규칙도 같게 본다 (켜면 그대로 적용된다) + */ +export function findFilterRulesProblem( + rules: readonly MailFilterRule[], +): FilterRulesProblem | null { + if (rules.length > MAX_MAIL_FILTER_RULES) { + return { index: null, reason: 'too-many-rules' }; + } + const ids = new Set(); + for (const [index, rule] of rules.entries()) { + if (ids.has(rule.id)) return { index, reason: 'duplicate-rule-id' }; + ids.add(rule.id); + const reason = ruleProblem(rule); + if (reason) return { index, reason }; + } + return null; +} diff --git a/backend/src/modules/mail/mail.constants.ts b/backend/src/modules/mail/mail.constants.ts index 57034b3..3dc228c 100644 --- a/backend/src/modules/mail/mail.constants.ts +++ b/backend/src/modules/mail/mail.constants.ts @@ -90,6 +90,33 @@ export const MULTI_LINE_TEXT_PATTERN = // eslint-disable-next-line no-control-regex /^[^\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f-\u009f]*$/; +/** + * 메일 필터 라우트 경로 (`/api/mail-filters`) — 계약 2절 "메일 필터" + */ +export const MAIL_FILTERS_ROUTE = 'mail-filters'; + +/** + * 메일 필터의 한도 (계약 2절 "메일 필터"의 타입) — 길이는 앞뒤 공백을 뺀 값의 JS 문자열 길이. 넘으면 400 VAL_001 + * - 규칙 50개 · 조건(모두 · 하나라도) 1~5개 — 모든 메일(always)은 조건 0개 + * - 이름 0~50자 · 찾을 글 1~200자 + */ +export const MAX_MAIL_FILTER_RULES = 50; +export const MAX_MAIL_FILTER_CONDITIONS = 5; +export const MAX_MAIL_FILTER_NAME_LENGTH = 50; +export const MAX_MAIL_FILTER_VALUE_LENGTH = 200; + +/** 필터 규칙 id — 클라이언트가 만든다(UUID). 목록 안에서 겹치지 않는다 */ +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}$/; + +/** + * 메일 엔진(Stalwart)이 규칙 글 안에서 변수로 펼치는 표시 — require 와 상관없이 늘 펼친다(2026-10-28 확인). + * 펼쳐져 빈 값이 되면 `:contains` 가 모든 메일에 맞으므로 찾을 글 · 전달 주소에 쓰지 않는다 (계약 2절 "만드는 Sieve") + */ +export const SIEVE_VARIABLE_MARKER = '${'; + /** 받는 사람 추천의 거르는 글자(q) 최대 길이 — 계약 2절 "받는 사람 추천" */ export const MAX_SUGGESTION_QUERY_LENGTH = 100; diff --git a/backend/src/modules/mail/mail.module.ts b/backend/src/modules/mail/mail.module.ts index 315a82d..bcd31ac 100644 --- a/backend/src/modules/mail/mail.module.ts +++ b/backend/src/modules/mail/mail.module.ts @@ -11,9 +11,11 @@ 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 { 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 { MailEventsService } from './mail-events.service'; import { MailUploadTempCleanup, @@ -108,6 +110,7 @@ function attachmentUploadOptions(config: ConfigService): MulterModuleOptions { MailStateController, MailEventsController, MailVacationController, + MailFilterController, MessageController, MessageDeletionController, MailAttachmentController, @@ -121,6 +124,8 @@ function attachmentUploadOptions(config: ConfigService): MulterModuleOptions { useFactory: createMailSettings, }, JmapClient, + // 필터 · 부재중 자동 응답의 저장 자리(엔진의 Sieve 스크립트 하나) — 어댑터 안에서만 쓴다 + JmapFilterScriptStore, { 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 1a5bf46..d16614d 100644 --- a/backend/src/modules/mail/mail.provider.ts +++ b/backend/src/modules/mail/mail.provider.ts @@ -9,6 +9,8 @@ import type { MailboxBulkResult, MailboxCreate, MailboxDeletion, + MailFilterSet, + MailFilterUpdate, MailState, MailUsage, MailVacation, @@ -102,12 +104,32 @@ export interface MailProvider { */ emptyMailbox(user: string, id: string): Promise; - /** 부재중 자동 응답 — 엔진 값이 없으면(null) 제목 · 내용은 빈 문자열 */ + /** + * 부재중 자동 응답 — 한 번도 설정하지 않았으면 끈 상태 · 빈 제목과 내용. + * 저장은 메일 필터와 같은 엔진 규칙에 둔다 (계약 2절 "메일 필터"의 저장 자리) + */ getVacation(user: string): Promise; - /** 부재중 자동 응답을 통째로 바꾸고(서식 본문은 비운다) 다시 읽은 값을 돌려준다 — 형식은 요청 검증이, 켤 때의 빈 값 · 날짜 순서는 서비스가 본다 */ + /** + * 부재중 자동 응답을 통째로 바꾸고 저장한 값을 돌려준다 — 메일 필터 규칙은 그대로 둔다. + * 형식은 요청 검증이, 켤 때의 빈 값 · 길이 · 날짜 순서는 서비스가 본다. 함께 만든 엔진 규칙이 엔진 한도를 넘으면 MAIL_013 + */ setVacation(user: string, vacation: MailVacation): Promise; + /** 메일 필터 (계약 2절 "메일 필터") — 규칙(차례대로) · 지금 규칙의 지문(version) · 동작하는지(isActive) */ + getMailFilters(user: string): Promise; + + /** + * 메일 필터 규칙을 통째로 바꾸고 저장한 것을 돌려준다 — 부재중 자동 응답은 그대로 둔다. 저장하면 다시 동작한다(isActive). + * 그사이 다른 곳에서 규칙이 바뀌었으면(지금 규칙의 지문이 version 과 다름) 저장하지 않고 MAIL_012 — + * 지금 규칙이 바꾸려는 규칙과 이미 같으면 그대로 저장한다(같은 요청을 다시 보낸 경우). + * 만든 엔진 규칙이 엔진 한도를 넘으면 MAIL_013. 규칙의 형식 · 내용은 서비스가 검사한 값이다 + */ + setMailFilters( + user: string, + update: MailFilterUpdate, + ): 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 53ab81f..461e37d 100644 --- a/backend/src/modules/mail/mail.service.spec.ts +++ b/backend/src/modules/mail/mail.service.spec.ts @@ -5,6 +5,7 @@ import { startCpuTimer } from '@/../test/cpu-time'; 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 { DraftBodyDto, SendMessageDto } from './dto/send-message.dto'; import { MAX_HTML_PARSE_LENGTH } from './html/html-parse-budget'; import { @@ -19,6 +20,8 @@ import type { MailProvider } from './mail.provider'; import type { MailSettings } from './mail.settings'; import type { DraftSource, + Mailbox, + MailFilterRule, MessageDetailSource, SentMessage, } from './mail.types'; @@ -71,6 +74,8 @@ function fakeProvider(): ProviderMocks { emptyMailbox: mockOf<'emptyMailbox'>(), getVacation: mockOf<'getVacation'>(), setVacation: mockOf<'setVacation'>(), + getMailFilters: mockOf<'getMailFilters'>(), + setMailFilters: mockOf<'setMailFilters'>(), openMessageSource: mockOf<'openMessageSource'>(), getUsage: mockOf<'getUsage'>(), getAttachmentsTotalLimit: @@ -881,6 +886,253 @@ describe('MailService', () => { }); }); + describe('메일 필터 (6단계)', () => { + const condition = { + field: 'from' as const, + operator: 'contains' as const, + value: '@hanbit-partners.co.kr', + }; + const noActions = { + moveToMailboxId: null, + markAsRead: false, + flag: false, + forwardTo: null, + }; + const rule = ( + id: string, + extra: Partial = {}, + ): MailFilterRule => ({ + id, + name: '거래처 메일', + isEnabled: true, + match: 'all', + conditions: [condition], + actions: { ...noActions, markAsRead: true }, + ...extra, + }); + const VERSION = 'v-before'; + const update = (rules: MailFilterRule[]) => + ({ rules, version: VERSION }) as unknown as MailFilterUpdateDto; + const mailbox = (id: string, role: Mailbox['role']): Mailbox => ({ + id, + name: id, + role, + parentId: null, + sortOrder: 0, + totalCount: 0, + unreadCount: 0, + }); + + beforeEach(() => { + provider.setMailFilters.mockImplementation((_user, { rules }) => + Promise.resolve({ rules, version: 'v-after', isActive: true }), + ); + provider.listMailboxes.mockResolvedValue([ + mailbox('a', 'inbox'), + mailbox('c', 'junk'), + mailbox('b', 'trash'), + mailbox('e', 'sent'), + mailbox('d', 'drafts'), + mailbox('j', null), + ]); + }); + + it('읽기는 어댑터 그대로 (규칙 · 지문 · 동작하는지)', async () => { + const filters = { rules: [rule('r1')], version: 'v1', isActive: false }; + provider.getMailFilters.mockResolvedValue(filters); + + await expect(service.getMailFilters(user)).resolves.toBe(filters); + expect(provider.getMailFilters).toHaveBeenCalledWith(user.email); + }); + + it('검사를 지나면 규칙과 받은 지문을 넘기고 저장한 것을 돌려준다 — 로그에는 규칙 수만 (조건의 글 · 전달 주소 없이)', async () => { + const rules = [ + rule('r1', { + conditions: [{ ...condition, value: '비밀 거래처' }], + actions: { + ...noActions, + forwardTo: 'doyun.lee@arasystem.test', + moveToMailboxId: 'j', + }, + }), + rule('r2', { isEnabled: false, match: 'always', conditions: [] }), + ]; + + await expect( + service.updateMailFilters(user, update(rules)), + ).resolves.toEqual({ rules, version: 'v-after', isActive: true }); + expect(provider.setMailFilters).toHaveBeenCalledWith(user.email, { + rules, + version: VERSION, + }); + const [line] = logSpy.mock.calls.at(-1) as [string]; + expect(line).toContain('규칙 2개(켬 1개)'); + expect(line).not.toContain('비밀 거래처'); + expect(line).not.toContain('doyun.lee'); + expect(line).not.toContain(user.email); + }); + + it.each<[string, Partial, string]>([ + [ + '모든 메일인데 조건이 있음', + { match: 'always' }, + 'conditions-for-match', + ], + ['모두인데 조건이 없음', { conditions: [] }, 'conditions-for-match'], + [ + '하나라도인데 조건이 없음', + { match: 'any', conditions: [] }, + 'conditions-for-match', + ], + ['할 일이 하나도 없음', { actions: noActions }, 'no-action'], + ])( + '칸 사이의 규칙이 어긋나면 엔진에 묻지 않고 VAL_001 — %s', + async (_label, extra, problem) => { + let failure: unknown; + try { + await service.updateMailFilters( + user, + update([rule('ok'), rule('bad', extra)]), + ); + } catch (error) { + failure = error; + } + expect(failure).toBeInstanceOf(BusinessException); + expect((failure as BusinessException).code).toBe('VAL_001'); + expect((failure as BusinessException).context).toEqual({ + reason: 'invalid-filter-rule', + problem, + index: 1, + }); + expect(provider.listMailboxes).not.toHaveBeenCalled(); + expect(provider.setMailFilters).not.toHaveBeenCalled(); + }, + ); + + it('같은 id 의 규칙이 둘이면 VAL_001 — 요청 검증이 아니라 여기서 본다(Set — 항목 수에 비례)', async () => { + expect( + await failureOf(() => + service.updateMailFilters( + user, + update([rule('same'), rule('other'), rule('same')]), + ), + ), + ).toEqual({ code: 'VAL_001', reason: 'invalid-filter-rule' }); + expect(provider.setMailFilters).not.toHaveBeenCalled(); + }); + + it('전달은 사내 주소만 — 바깥 도메인 · 하위 도메인은 VAL_001, 대소문자는 가리지 않는다', async () => { + const forwardTo = (address: string) => + update([rule('r1', { actions: { ...noActions, forwardTo: address } })]); + + for (const address of [ + 'someone@gmail.com', + 'doyun.lee@mail.arasystem.test', + 'doyun.lee@arasystem.test.evil.com', + ]) { + expect( + await failureOf(() => + service.updateMailFilters(user, forwardTo(address)), + ), + ).toEqual({ code: 'VAL_001', reason: 'filter-forward-not-internal' }); + } + expect(provider.setMailFilters).not.toHaveBeenCalled(); + + await service.updateMailFilters( + user, + forwardTo('Doyun.Lee@ARASYSTEM.test'), + ); + expect(provider.setMailFilters).toHaveBeenCalledTimes(1); + }); + + it('자기 주소로는 전달하지 않는다 (대소문자 무시) — VAL_001', async () => { + expect( + await failureOf(() => + service.updateMailFilters( + user, + update([ + rule('r1', { + actions: { ...noActions, forwardTo: 'ARA.KIM@arasystem.test' }, + }), + ]), + ), + ), + ).toEqual({ code: 'VAL_001', reason: 'filter-forward-to-self' }); + }); + + it('메일 도메인 설정이 없으면 사내 주소인지 알 수 없어 전달을 받지 않는다', async () => { + const unconfigured = new MailService( + provider, + { ...settings, domain: null }, + signatures as unknown as MailSignatureService, + suggestions as unknown as RecipientSuggestionService, + ); + expect( + await failureOf(() => + unconfigured.updateMailFilters( + user, + update([ + rule('r1', { + actions: { + ...noActions, + forwardTo: 'doyun.lee@arasystem.test', + }, + }), + ]), + ), + ), + ).toEqual({ code: 'VAL_001', reason: 'filter-forward-not-internal' }); + }); + + it('보낸편지함 · 임시보관함으로는 옮기지 않는다 — VAL_001. 받은편지함 · 스팸메일함 · 휴지통 · 내 메일함 · 없는 id 는 받는다', async () => { + const moveTo = (mailboxId: string) => + update([ + rule('r1', { + actions: { ...noActions, moveToMailboxId: mailboxId }, + }), + ]); + + for (const mailboxId of ['e', 'd']) { + expect( + await failureOf(() => + service.updateMailFilters(user, moveTo(mailboxId)), + ), + ).toEqual({ + code: 'VAL_001', + reason: 'filter-move-target-not-allowed', + }); + } + expect(provider.setMailFilters).not.toHaveBeenCalled(); + + for (const mailboxId of ['a', 'c', 'b', 'j', 'gone']) { + await service.updateMailFilters(user, moveTo(mailboxId)); + } + expect(provider.setMailFilters).toHaveBeenCalledTimes(5); + expect(provider.listMailboxes).toHaveBeenCalledWith(user.email); + }); + + it('옮기는 규칙이 없으면 메일함 목록을 읽지 않는다', async () => { + await service.updateMailFilters(user, update([rule('r1')])); + await service.updateMailFilters(user, update([])); + expect(provider.listMailboxes).not.toHaveBeenCalled(); + expect(provider.setMailFilters).toHaveBeenCalledTimes(2); + }); + + it('어댑터의 실패는 그대로 — 그사이 바뀜 MAIL_012 · 한도 MAIL_013 · 엔진 장애 MAIL_001 (저장 로그 없음)', async () => { + for (const code of ['MAIL_012', 'MAIL_013', 'MAIL_001'] as const) { + provider.setMailFilters.mockRejectedValueOnce( + new BusinessException(code), + ); + expect( + await codeOf(() => + service.updateMailFilters(user, update([rule('r1')])), + ), + ).toBe(code); + } + expect(logSpy).not.toHaveBeenCalled(); + }); + }); + 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 0f2f70b..e85aaf8 100644 --- a/backend/src/modules/mail/mail.service.ts +++ b/backend/src/modules/mail/mail.service.ts @@ -23,6 +23,7 @@ import type { MessageDeletionDto, UpdateMessagesDto, } from './dto/bulk-messages.dto'; +import type { MailFilterUpdateDto } from './dto/mail-filter.dto'; import type { MailVacationDto } from './dto/mail-vacation.dto'; import type { CreateMailboxDto, @@ -61,6 +62,7 @@ import { MAX_VACATION_SUBJECT_LENGTH, MAX_VACATION_TEXT_LENGTH, } from './mail.constants'; +import { findFilterRulesProblem } from './mail-filters'; import { MAIL_PROVIDER, type MailProvider } from './mail.provider'; import { MAIL_SETTINGS, type MailSettings } from './mail.settings'; import type { @@ -74,6 +76,9 @@ import type { Mailbox, MailboxBulkResult, MailboxDeletion, + MailboxRole, + MailFilterRule, + MailFilterSet, MailQuota, MailState, MailVacation, @@ -206,6 +211,12 @@ function toMessageUpdate(dto: UpdateMessageDto): MessageUpdate { return { isRead, isFlagged, mailboxId, isJunk }; } +/** 필터가 메일을 옮길 수 없는 메일함 — 보낸편지함 · 임시보관함 (계약 2절 "메일 필터"의 타입) */ +const FILTER_MOVE_FORBIDDEN_ROLES: ReadonlySet = new Set([ + 'sent', + 'drafts', +]); + /** 서명 그림의 Content-ID — 같은 그림이면 늘 같은 값이다 (발송 지문이 cid 가 든 서식 본문을 본다) */ function signatureImageCid(imageId: string, sender: string): string { const domain = sender.slice(sender.lastIndexOf('@') + 1).toLowerCase(); @@ -369,12 +380,13 @@ export class MailService { } /** - * 부재중 자동 응답 저장(전체 교체) — 엔진이 저장한 값을 다시 읽어 돌려준다. + * 부재중 자동 응답 저장(전체 교체) — 저장한 값을 돌려준다. 메일 필터와 같은 엔진 규칙에 두며 필터 규칙은 그대로 둔다 + * (계약 2절 "메일 필터"의 저장 자리 — 함께 만든 엔진 규칙이 한도를 넘으면 MAIL_013). * - 켤 때는 제목 · 내용이 앞뒤 공백을 빼고 1자 이상 (저장은 쓴 그대로 — 지우지 않는다). * 끈 채로는 빈 값도 저장한다 — 한 번도 설정하지 않은 값을 그대로 되돌려 놓을 수 있게 * - 켤 때는 제목 150자 · 내용 600자까지 (엔진의 바이트 한도 안 — mail.constants.ts). 끈 채로는 요청 검증의 200자 · 4,000자까지 받는다 — * 4단계에 저장해 둔 긴 글이 켜져 있어도 끌 수 있게 (계약 2절 "길이 한도의 근거") - * - 날짜는 초 단위로 맞춰 엔진에 보낸다(소수 초를 버린다 — 엔진의 UTCDate). 시작 · 끝이 둘 다 있으면 **맞춘 값으로** 시작 < 끝 + * - 날짜는 초 단위로 맞춰 저장한다(소수 초를 버린다 — 엔진 규칙의 기간은 초 단위다). 시작 · 끝이 둘 다 있으면 **맞춘 값으로** 시작 < 끝 * 어긋나면 엔진에 묻지 않고 VAL_001. 형식(늘 보는 길이 · 제어 문자 · 날짜 모양)은 요청 검증이 본다. 로그에는 켬 · 끔만 (제목 · 내용 없이) */ async updateVacation( @@ -427,6 +439,48 @@ export class MailService { return saved; } + /** 메일 필터 (계약 2절 "메일 필터") — 규칙 · 지문(version) · 동작하는지(isActive) */ + getMailFilters(user: AuthenticatedUser): Promise { + return this.provider.getMailFilters(user.email); + } + + /** + * 메일 필터 저장(전체 교체) — 그사이 다른 곳에서 규칙이 바뀌었으면 MAIL_012, 만든 엔진 규칙이 한도를 넘으면 MAIL_013 (어댑터). + * 요청 검증(칸마다의 형식 — 글은 앞뒤 공백을 지운 값) 뒤에 칸 사이의 규칙을 본다 — 어긋나면 엔진에 묻지 않고 VAL_001: + * - 모든 메일(always)은 조건 0개 · 모두 · 하나라도는 1~5개 · 할 일 하나 이상 (mail-filters.ts — 저장해 둔 원본을 읽을 때와 같은 규칙) + * - 전달은 사내 주소(MAIL_DOMAIN)이고 자기 주소가 아님 (대소문자 무시) + * - 옮길 메일함이 보낸편지함 · 임시보관함이 아님 — 옮기는 규칙이 있을 때만 메일함 목록을 읽는다. 없는 id 는 받는다 + * 로그에는 규칙 수만 남긴다 — 조건의 글 · 전달 주소는 남기지 않는다 (검색어처럼 직원의 내용이다) + */ + async updateMailFilters( + user: AuthenticatedUser, + dto: MailFilterUpdateDto, + ): Promise { + const { rules, version } = dto; + const problem = findFilterRulesProblem(rules); + if (problem) { + throw new BusinessException('VAL_001', { + context: { + reason: 'invalid-filter-rule', + problem: problem.reason, + index: problem.index, + }, + }); + } + this.assertFilterForwardTargets(user, rules); + await this.assertFilterMoveTargets(user, rules); + + const saved = await this.provider.setMailFilters(user.email, { + rules, + version, + }); + const enabled = saved.rules.filter((rule) => rule.isEnabled).length; + this.logger.log( + `${traceTag()} 메일 필터를 저장했습니다: user=${maskEmail(user.email)}, 규칙 ${saved.rules.length}개(켬 ${enabled}개)`, + ); + return saved; + } + /** 받은 그대로의 메일 원문 — 파일 이름은 제목(없으면 "메일") + .eml (attachment.policy.ts 의 messageSourceFileName) */ async openMessageSource( user: AuthenticatedUser, @@ -658,6 +712,63 @@ export class MailService { }; } + /** + * 필터의 전달 주소 — 사내 도메인(MAIL_DOMAIN)의 주소이고 자기 주소가 아니어야 한다 (계약 2절 "메일 필터" — 바깥 주소 전달은 하지 않는다). + * 메일 도메인 설정이 없으면(비운영의 엔진 미설정) 사내 주소인지 알 수 없어 받지 않는다 + */ + private assertFilterForwardTargets( + user: AuthenticatedUser, + rules: readonly MailFilterRule[], + ): void { + const self = user.email.toLowerCase(); + for (const [index, rule] of rules.entries()) { + const target = rule.actions.forwardTo?.toLowerCase(); + if (target === undefined) continue; + const domain = target.slice(target.lastIndexOf('@') + 1); + if (this.settings.domain === null || domain !== this.settings.domain) { + throw new BusinessException('VAL_001', { + context: { reason: 'filter-forward-not-internal', index }, + }); + } + if (target === self) { + throw new BusinessException('VAL_001', { + context: { reason: 'filter-forward-to-self', index }, + }); + } + } + } + + /** + * 필터가 옮길 메일함 — 보낸편지함 · 임시보관함이면 VAL_001. 메일함 목록에 없는 id 는 받는다 + * (지운 메일함을 가리키는 규칙이 남아 있을 수 있다 — 그때는 받은편지함에 들어온다). 옮기는 규칙이 없으면 엔진에 묻지 않는다 + */ + private async assertFilterMoveTargets( + user: AuthenticatedUser, + rules: readonly MailFilterRule[], + ): 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 }, + }); + } + } + } + /** * 같은 직원 · 같은 메일함의 오래 걸리는 처리를 하나씩만 한다 — 진행 중이면 엔진에 묻지 않고 바로 REQ_001 * (로그인의 진행 중 시도와 같은 모양). 끝나면 성공 · 실패와 상관없이 푼다. 다른 메일함끼리는 동시에 된다 diff --git a/backend/src/modules/mail/mail.types.ts b/backend/src/modules/mail/mail.types.ts index 2716bc9..62b4ac5 100644 --- a/backend/src/modules/mail/mail.types.ts +++ b/backend/src/modules/mail/mail.types.ts @@ -183,7 +183,9 @@ export interface MailboxBulkResult { readonly remaining: number; } -/** 부재중 자동 응답 (계약 2절 "부재중 자동 응답") — 엔진의 VacationResponse 하나 */ +/** + * 부재중 자동 응답 (계약 2절 "부재중 자동 응답") — 저장은 메일 필터와 같은 엔진 규칙에 함께 둔다 (계약 2절 "메일 필터"의 저장 자리) + */ export interface MailVacation { readonly isEnabled: boolean; /** ISO 8601 UTC — null 이면 켠 때부터 */ @@ -196,6 +198,61 @@ export interface MailVacation { readonly textBody: string; } +/** 필터 조건을 견줄 곳 — 보낸 사람(이름 · 주소) · 받는 사람(받는 사람 · 참조의 이름 · 주소) · 제목 · 본문(글자 부분) */ +export type MailFilterField = 'from' | 'to' | 'subject' | 'body'; + +/** 포함 · 포함하지 않음 (대소문자는 ASCII 만 무시한다 — 엔진 기본 비교) */ +export type MailFilterOperator = 'contains' | 'notContains'; + +/** 조건이 모두 맞음 · 하나라도 맞음 · 모든 메일(조건 없음) */ +export type MailFilterMatch = 'all' | 'any' | 'always'; + +export interface MailFilterCondition { + readonly field: MailFilterField; + readonly operator: MailFilterOperator; + /** 찾을 글 — 앞뒤 공백을 뺀 1~200자, 제어 문자 · `${` 없음 */ + readonly value: string; +} + +/** 맞는 메일에 할 일 — 하나 이상 (계약 2절 "메일 필터") */ +export interface MailFilterActions { + /** 옮길 메일함 — 없는 id 도 받는다(지운 메일함을 가리키는 규칙 — 그때는 받은편지함에 들어온다) */ + readonly moveToMailboxId: string | null; + readonly markAsRead: boolean; + readonly flag: boolean; + /** 전달할 사내 주소 — 사본은 늘 남긴다 */ + readonly forwardTo: string | null; +} + +export interface MailFilterRule { + /** 클라이언트가 만든다(UUID) — 목록 안에서 겹치지 않는다 */ + readonly id: string; + /** 0~50자 — 비면 화면이 조건으로 이름을 보인다 */ + readonly name: string; + /** 꺼 둔 규칙은 목록에만 있고 적용하지 않는다 */ + readonly isEnabled: boolean; + readonly match: MailFilterMatch; + /** 모두 · 하나라도는 1~5개, 모든 메일은 빈 배열 */ + readonly conditions: readonly MailFilterCondition[]; + readonly actions: MailFilterActions; +} + +/** 메일 필터 (GET · PUT /api/mail-filters 의 응답) — 위에서부터 차례로 적용한다 */ +export interface MailFilterSet { + readonly rules: readonly MailFilterRule[]; + /** 지금 규칙의 지문 — PUT 에 그대로 돌려받는다 (그사이 규칙이 바뀌었으면 MAIL_012) */ + readonly version: string; + /** false = 엔진에서 다른 규칙이 켜져 지금은 필터 · 부재중 응답이 동작하지 않는다 (저장하면 다시 켜진다) */ + readonly isActive: boolean; +} + +/** 필터 저장 (PUT /api/mail-filters — 전체 교체) — 규칙은 서비스가 검사한 값이다 */ +export interface MailFilterUpdate { + readonly rules: readonly MailFilterRule[]; + /** 고치기 시작할 때 받은 지문 */ + readonly version: string; +} + /** 받은 그대로의 메일 원문 (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 600d14c..8b9da32 100644 --- a/backend/src/modules/mail/mailbox.controller.ts +++ b/backend/src/modules/mail/mailbox.controller.ts @@ -188,7 +188,7 @@ export class MailboxController { } } -// 부재중 자동 응답 (계약 2절 "부재중 자동 응답") — 엔진의 VacationResponse 하나를 그대로 쓴다 +// 부재중 자동 응답 (계약 2절 "부재중 자동 응답") — 메일 필터와 같은 엔진 규칙(Sieve 스크립트)에 함께 둔다 (계약 2절 "메일 필터"의 저장 자리) @ApiTags('Mail') @ApiCookieAuth() @UseGuards(JwtAuthGuard) @@ -218,12 +218,17 @@ export class MailVacationController { `꺼 둔 채로는 제목 ${MAX_VACATION_SUBJECT_LENGTH_WHEN_OFF}자 · 내용 ${MAX_VACATION_TEXT_LENGTH_WHEN_OFF.toLocaleString('ko-KR')}자까지 받는다 — 전에 저장한 긴 글이 켜져 있어도 끌 수 있게. ` + '시작 · 끝이 둘 다 있으면 시작이 끝보다 앞이어야 한다 — 날짜는 소수 초를 버린 값으로 저장하고, 순서도 그 값으로 본다. ' + '제목에는 제어 문자(탭 · 줄바꿈 포함)를, 내용에는 탭 · 줄바꿈 밖의 제어 문자를 쓸 수 없다. ' + - '꺼도 쓴 제목 · 내용은 남겨 둔다. 서식 본문은 쓰지 않는다. 엔진이 저장한 값을 다시 읽어 돌려준다.', + '꺼도 쓴 제목 · 내용은 남겨 둔다. 서식 본문은 쓰지 않는다. 저장한 값을 돌려준다. 메일 필터 규칙은 그대로 둔다.', }) @ApiStandardResponse(MailVacationDto, { description: '저장한 설정' }) @ApiErrorResponse( '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}자) · 제어 문자 · 켤 때 공백뿐인 제목 · 내용 · 시작이 끝보다 늦거나 같음(소수 초를 버린 값으로) · 모르는 필드 ` + + '/ MAIL_013 메일 필터와 함께 만든 엔진 규칙이 엔진 한도를 넘음 — 모두 400, 저장하지 않았다', + ) + @ApiErrorResponse( + 'MAIL_001', + '메일 엔진 장애 · 저장하는 사이 엔진의 규칙이 계속 바뀜(세 번) — 같은 요청을 다시 보내면 된다', ) update(@CurrentUser() user: AuthenticatedUser, @Body() dto: MailVacationDto) { return this.mail.updateVacation(user, dto); diff --git a/backend/test/app.e2e-spec.ts b/backend/test/app.e2e-spec.ts index 5aaacda..31e7d12 100644 --- a/backend/test/app.e2e-spec.ts +++ b/backend/test/app.e2e-spec.ts @@ -37,6 +37,9 @@ import type { MailboxBulkResult, MailboxCreate, MailboxDeletion, + MailFilterRule, + MailFilterSet, + MailFilterUpdate, MailVacation, MailWatch, MailWatchListener, @@ -163,6 +166,33 @@ const ENGINE_REJECTIONS = new Map([ [BUSY, 'temporary'], ]); +/** 대역 메일 엔진이 "만든 엔진 규칙이 엔진 한도를 넘는다"고 답하는 필터의 찾을 글 (400 MAIL_013 경로) */ +const ENGINE_TOO_LARGE_FILTER_VALUE = 'engine-says-too-large'; + +/** 대역 메일 엔진의 메일함 — 필터가 옮길 수 없는 보낸편지함(e) · 임시보관함(d) 확인용 */ +const E2E_MAILBOXES: Mailbox[] = ( + [ + ['a', 'inbox'], + ['b', 'trash'], + ['c', 'junk'], + ['d', 'drafts'], + ['e', 'sent'], + ['j', null], + ] as const +).map(([id, role]) => ({ + id, + name: id, + role, + parentId: null, + sortOrder: 0, + totalCount: 0, + unreadCount: 0, +})); + +/** 대역 엔진의 규칙 지문 — 실제 지문은 jmap/filter-source.ts (여기서는 같은 규칙이면 같은 값이면 된다) */ +const fakeFilterVersion = (rules: readonly MailFilterRule[]) => + createHash('sha256').update(JSON.stringify(rules)).digest('base64url'); + /** 한 번도 설정하지 않은 계정의 부재중 자동 응답 (계약 2절) */ const NEVER_CONFIGURED_VACATION: MailVacation = { isEnabled: false, @@ -332,8 +362,13 @@ describe('응답 계약 (e2e)', () => { /** 4단계 — 대역 엔진이 받은 비밀번호 바꾸기 · 메일함 관리 · 부재중 자동 응답 · 한 통 바꾸기 */ const passwordChanges: { email: string; newPassword: string }[] = []; const mailboxCalls: { user: string; action: string; args: unknown[] }[] = []; - const vacations = new Map(); const vacationSaves: { user: string; vacation: MailVacation }[] = []; + /** 6단계 — 대역 엔진의 엔진 규칙(직원마다 하나): 필터 규칙과 부재중 자동 응답을 함께 둔다 (계약 2절 "메일 필터"의 저장 자리) */ + const mailScripts = new Map< + string, + { rules: readonly MailFilterRule[]; vacation: MailVacation } + >(); + const filterSaves: { user: string; update: MailFilterUpdate }[] = []; const singleUpdates: { user: string; id: string; @@ -635,15 +670,71 @@ describe('응답 계약 (e2e)', () => { return { processed: 2, remaining: 0 }; }, getVacation: (user: string): Promise => - Promise.resolve(vacations.get(user) ?? NEVER_CONFIGURED_VACATION), + Promise.resolve( + mailScripts.get(user)?.vacation ?? NEVER_CONFIGURED_VACATION, + ), + // 부재중만 바꾼다 — 같은 엔진 규칙의 필터 규칙은 그대로 (실제 규칙은 jmap-filter-script.spec.ts) setVacation: ( user: string, vacation: MailVacation, ): Promise => { vacationSaves.push({ user, vacation }); - vacations.set(user, vacation); + mailScripts.set(user, { + rules: mailScripts.get(user)?.rules ?? [], + vacation, + }); return Promise.resolve(vacation); }, + listMailboxes: (): Promise => Promise.resolve(E2E_MAILBOXES), + // 6단계 — 엔진 쪽 판단(지문 비교 · 한도)만 흉내 낸다. 만드는 Sieve · 조건부 쓰기는 jmap/*.spec.ts 가 본다 + getMailFilters: (user: string): Promise => { + const rules = mailScripts.get(user)?.rules ?? []; + return Promise.resolve({ + rules, + version: fakeFilterVersion(rules), + isActive: true, + }); + }, + setMailFilters: ( + user: string, + update: MailFilterUpdate, + ): Promise => { + filterSaves.push({ user, update }); + const current = mailScripts.get(user); + const version = fakeFilterVersion(current?.rules ?? []); + if ( + version !== update.version && + version !== fakeFilterVersion(update.rules) + ) { + return Promise.reject( + new BusinessException('MAIL_012', { + context: { reason: 'e2e-filter-rules-changed' }, + }), + ); + } + if ( + update.rules.some((rule) => + rule.conditions.some( + ({ value }) => value === ENGINE_TOO_LARGE_FILTER_VALUE, + ), + ) + ) { + return Promise.reject( + new BusinessException('MAIL_013', { + context: { reason: 'e2e-filter-script-too-large' }, + }), + ); + } + mailScripts.set(user, { + rules: update.rules, + vacation: current?.vacation ?? NEVER_CONFIGURED_VACATION, + }); + return Promise.resolve({ + rules: update.rules, + version: fakeFilterVersion(update.rules), + isActive: true, + }); + }, openMessageSource: ( _user: string, id: string, @@ -1073,6 +1164,8 @@ describe('응답 계약 (e2e)', () => { ['delete', '/api/mailboxes/u1/messages'], ['get', '/api/mail-vacation'], ['put', '/api/mail-vacation'], + ['get', '/api/mail-filters'], + ['put', '/api/mail-filters'], ['get', '/api/messages/eaaaaab/source'], // 템플릿 업로드 예제도 로그인한 직원만 쓴다 ['post', '/api/files'], @@ -2848,6 +2941,225 @@ describe('응답 계약 (e2e)', () => { }); }); + /** + * 6단계 — 메일 필터 (계약 2절 "메일 필터"). + * 엔진 쪽(만드는 Sieve · 머리의 원본 · 지문 · 조건부 쓰기 · 다시 시도)은 jmap/*.spec.ts 가 본다 — + * 여기서는 HTTP 형식 · 요청 검사 · 부재중 자동 응답과 같은 저장 자리를 쓰는지를 본다 (대역 엔진이 둘을 한 곳에 둔다) + */ + describe('6단계 — 메일 필터', () => { + describe('메일 필터 (/api/mail-filters)', () => { + const ME = 'filter.me@e2e.arasystem.test'; + const rule = { + id: '0b9a4f9e-7c2d-4d3a-9f4e-1a2b3c4d5e6f', + name: '거래처 메일', + isEnabled: true, + match: 'any', + conditions: [ + { + field: 'from', + operator: 'contains', + value: '@hanbit-partners.co.kr', + }, + ], + actions: { + moveToMailboxId: 'j', + markAsRead: true, + flag: false, + forwardTo: 'doyun.lee@e2e.arasystem.test', + }, + }; + const get = () => + request(server).get('/api/mail-filters').set('Cookie', cookieOf(ME)); + const put = (payload: object) => + request(server) + .put('/api/mail-filters') + .set('Cookie', cookieOf(ME)) + .send(payload); + const versionNow = async () => + dataOf<{ version: string }>(await get().expect(200)).version; + + it('처음에는 규칙 없음 · 동작함 — 받은 version 으로 저장하면 200(글은 앞뒤 공백을 지워서), 다음 조회도 그 규칙', async () => { + const first = await get().expect(200); + expect(body(first).success).toBe(true); + const initial = dataOf<{ + rules: unknown[]; + version: string; + isActive: boolean; + }>(first); + expect(initial).toEqual({ + rules: [], + version: expect.any(String) as string, + isActive: true, + }); + + const res = await put({ + rules: [ + { + ...rule, + name: ' 거래처 메일 ', + conditions: [ + { ...rule.conditions[0], value: ' @hanbit-partners.co.kr ' }, + ], + }, + ], + version: initial.version, + }).expect(200); + const saved = dataOf<{ rules: unknown[]; version: string }>(res); + expect(saved.rules).toEqual([rule]); + expect(saved.version).not.toBe(initial.version); + expect(filterSaves.at(-1)?.user).toBe(ME); + + const read = dataOf<{ rules: unknown[]; version: string }>( + await get().expect(200), + ); + expect(read).toEqual({ + rules: [rule], + version: saved.version, + isActive: true, + }); + }); + + it('옛 version 으로 다른 규칙을 저장하면 409 MAIL_012 — 저장하지 않는다. 같은 규칙을 다시 보내면 그대로 200', async () => { + const before = await versionNow(); + await put({ + rules: [{ ...rule, id: 'other-tab-rule' }], + version: before, + }).expect(200); + + const conflict = await put({ + rules: [{ ...rule, id: 'my-rule' }], + version: before, + }).expect(409); + expect(body(conflict).error?.code).toBe('MAIL_012'); + expect( + dataOf<{ rules: { id: string }[] }>(await get().expect(200)).rules, + ).toEqual([expect.objectContaining({ id: 'other-tab-rule' })]); + + // 결과를 모르는 실패 뒤의 재시도 — 저장해 둔 규칙과 같으면 옛 version 이어도 저장한다 + await put({ + rules: [{ ...rule, id: 'other-tab-rule' }], + version: before, + }).expect(200); + }); + + it('형식 · 칸 사이의 규칙이 어긋나면 400 VAL_001 — 엔진에 묻지 않는다', async () => { + const version = await versionNow(); + const before = filterSaves.length; + const withRule = (overrides: object) => ({ + rules: [{ ...rule, ...overrides }], + version, + }); + const withActions = (overrides: object) => + withRule({ actions: { ...rule.actions, ...overrides } }); + const withValue = (value: unknown) => + withRule({ conditions: [{ ...rule.conditions[0], value }] }); + + const payloads = [ + { rules: [rule] }, + { rules: [rule], version, isActive: true }, + { + rules: Array.from({ length: 51 }, (_, i) => ({ + ...rule, + id: `r${i}`, + })), + version, + }, + { rules: [rule, rule], version }, + withRule({ id: '규칙 하나' }), + withRule({ name: 'x'.repeat(51) }), + withRule({ match: 'always' }), + withRule({ match: 'all', conditions: [] }), + withRule({ + conditions: Array.from({ length: 6 }, () => rule.conditions[0]), + }), + withRule({ priority: 1 }), + withValue(''), + withValue(' '), + withValue('x'.repeat(201)), + withValue('광고${x}'), + withValue('광고\n줄바꿈'), + withValue('탭\t가운데'), + withActions({ + moveToMailboxId: null, + markAsRead: false, + flag: false, + forwardTo: null, + }), + withActions({ forwardTo: 'someone@gmail.com' }), + withActions({ forwardTo: 'filter.me@e2e.arasystem.test' }), + withActions({ forwardTo: '${x}@e2e.arasystem.test' }), + withActions({ forwardTo: 'not-an-address' }), + withActions({ moveToMailboxId: 'e' }), + withActions({ moveToMailboxId: 'd' }), + ]; + // 몇 번째 본문이 어긋났는지 보이게 모두 보낸 뒤 견준다 + const answers: string[] = []; + for (const payload of payloads) { + const res = await put(payload); + answers.push(`${res.status} ${body(res).error?.code ?? ''}`); + } + expect(answers).toEqual(payloads.map(() => '400 VAL_001')); + expect(filterSaves).toHaveLength(before); + + // 지운 메일함을 가리키는 규칙은 받는다 — 그 메일함이 없으면 받은편지함에 들어온다 + await put(withActions({ moveToMailboxId: 'deletedbox' })).expect(200); + }); + + it('만든 엔진 규칙이 엔진 한도를 넘으면 400 MAIL_013', async () => { + const res = await put({ + rules: [ + { + ...rule, + conditions: [ + { + ...rule.conditions[0], + value: ENGINE_TOO_LARGE_FILTER_VALUE, + }, + ], + }, + ], + version: await versionNow(), + }).expect(400); + expect(body(res).error?.code).toBe('MAIL_013'); + }); + + it('필터를 저장해도 부재중 자동 응답은 그대로다 — 같은 엔진 규칙에 함께 두고, 부재중 저장도 필터를 지킨다', async () => { + const vacation = { + isEnabled: true, + fromDate: '2026-11-02T15:00:00.000Z', + toDate: '2026-11-07T15:00:00.000Z', + subject: '부재중입니다', + textBody: '11월 7일까지 휴가입니다.', + }; + await request(server) + .put('/api/mail-vacation') + .set('Cookie', cookieOf(ME)) + .send(vacation) + .expect(200); + + await put({ + rules: [{ ...rule, isEnabled: false }], + version: await versionNow(), + }).expect(200); + + const read = await request(server) + .get('/api/mail-vacation') + .set('Cookie', cookieOf(ME)) + .expect(200); + expect(dataOf(read)).toEqual(vacation); + + await request(server) + .put('/api/mail-vacation') + .set('Cookie', cookieOf(ME)) + .send({ ...vacation, isEnabled: false }) + .expect(200); + expect( + dataOf<{ rules: unknown[] }>(await get().expect(200)).rules, + ).toEqual([{ ...rule, isEnabled: false }]); + }); + }); + }); + /** * 출처 검사(CSRF) — 상태를 바꾸는 요청은 Sec-Fetch-Site(same-origin·none) 또는 허용 목록의 Origin 이어야 한다. * 둘 다 없으면 브라우저가 아닌 클라이언트로 보고 통과시킨다 (docs/mail/api-contract.md 머리말). diff --git a/flutter/lib/core/api/error_lexicon.g.dart b/flutter/lib/core/api/error_lexicon.g.dart index 0b656b6..a9e5f76 100644 --- a/flutter/lib/core/api/error_lexicon.g.dart +++ b/flutter/lib/core/api/error_lexicon.g.dart @@ -24,6 +24,8 @@ const Map kErrorCodeMessages = { 'MAIL_009': '같은 이름의 메일함이 이미 있습니다.', 'MAIL_010': '받는 사람 주소로 메일을 보낼 수 없습니다. 주소를 확인해 주세요.', 'MAIL_011': '받는 쪽이 지금은 메일을 받을 수 없습니다. 잠시 뒤 다시 보내 주세요.', + 'MAIL_012': '다른 곳에서 필터가 바뀌어 저장하지 않았습니다. 다시 불러온 뒤 고쳐 주세요.', + 'MAIL_013': '필터가 너무 길어 저장할 수 없습니다. 조건을 줄이거나 쓰지 않는 필터를 지워 주세요.', }; /// 매핑 실패 시 사용하는 폴백 코드 diff --git a/frontend/src/shared/constants/error-codes.generated.ts b/frontend/src/shared/constants/error-codes.generated.ts index 6f4d425..1470a18 100644 --- a/frontend/src/shared/constants/error-codes.generated.ts +++ b/frontend/src/shared/constants/error-codes.generated.ts @@ -24,6 +24,8 @@ export const ERROR_CODE_MESSAGES = { MAIL_009: '같은 이름의 메일함이 이미 있습니다.', MAIL_010: '받는 사람 주소로 메일을 보낼 수 없습니다. 주소를 확인해 주세요.', MAIL_011: '받는 쪽이 지금은 메일을 받을 수 없습니다. 잠시 뒤 다시 보내 주세요.', + MAIL_012: '다른 곳에서 필터가 바뀌어 저장하지 않았습니다. 다시 불러온 뒤 고쳐 주세요.', + MAIL_013: '필터가 너무 길어 저장할 수 없습니다. 조건을 줄이거나 쓰지 않는 필터를 지워 주세요.', } as const export type ErrorCode = keyof typeof ERROR_CODE_MESSAGES