feat(backend): 메일 필터 API · 부재중을 Sieve 스크립트로

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 <noreply@anthropic.com>
This commit is contained in:
2026-10-28 20:59:31 +09:00
parent 41b5fdbe22
commit b4d30482fe
33 changed files with 4809 additions and 301 deletions
@@ -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 ']);
});
});
@@ -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;
}
@@ -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)
+13
View File
@@ -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<string, ErrorCodeDefinition>;
export const ERROR_CODES = {
@@ -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<string, unknown> = { ...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<string, unknown> = { ...actions };
delete withoutForward.forwardTo;
expect(await namesOf(withRule({ actions: withoutForward }))).toContain(
'isEmail',
);
});
});
@@ -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;
}
+2 -8
View File
@@ -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, {
@@ -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> = {},
): 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<string, unknown> = {}) => ({
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));
});
});
@@ -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<string, unknown>;
function isRecord(value: unknown): value is JsonRecord {
return typeof value === 'object' && value !== null && !Array.isArray(value);
}
function isOneOf<T extends string>(
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;
}
@@ -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> = {},
): 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<string, string>();
vacation: JmapVacationResponse | null = null;
readonly recorded: RecordedCall[] = [];
/** 다음 SieveScript/set 을 처리하기 전에 한 번씩 부른다 — 그사이 다른 곳의 변경을 흉내 낸다 */
readonly beforeSet: (() => void)[] = [];
/** 다음 SieveScript/set 의 응답을 대신한다 (null 이면 평소대로) */
readonly setReplies: ((
args: Record<string, unknown>,
) => 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<string, unknown>[] {
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<string, unknown>, 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<string, { id: string }> = {};
const updated: Record<string, null> = {};
const notCreated: Record<string, JmapError> = {};
const notUpdated: Record<string, JmapError> = {};
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<string, { blobId: string }>;
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<unknown>,
): Promise<{ code?: string; context?: Record<string, unknown> }> {
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,
});
});
});
});
@@ -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<string> = 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<string, unknown> {
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<MailFilterSet> {
return toFilterSet(await this.snapshot(user));
}
/**
* 규칙을 통째로 바꾼다 — 부재중은 다시 읽은 값 그대로 둔다.
* 다시 읽은 규칙의 지문이 받은 version 과 다르면 MAIL_012 (저장하지 않는다) — 다만 저장해 둔 규칙이 이미 바꾸려는 규칙과 같으면
* 그대로 저장한다(같은 요청을 다시 보낸 경우 — 결과를 모르는 실패 뒤의 재시도가 409 로 끝나지 않게, 꺼져 있으면 다시 켜진다)
*/
async setFilters(
user: string,
update: MailFilterUpdate,
): Promise<MailFilterSet> {
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<MailVacation> {
return (await this.snapshot(user)).source.vacation;
}
/** 부재중 자동 응답만 바꾼다 — 규칙은 다시 읽은 그대로 둔다 (지문을 보지 않는다). 저장한 값을 돌려준다 */
async setVacation(
user: string,
vacation: MailVacation,
): Promise<MailVacation> {
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<ScriptSnapshot> {
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<JmapGetResult<JmapSieveScript>>(
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<JmapGetResult<JmapVacationResponse>>(
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<string | null | undefined> {
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<ScriptView> {
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<string> {
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<JmapSieveScript>).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' });
}
}
@@ -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,
});
});
});
@@ -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<string, unknown> }
| { 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<T>(
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<string, JmapError> | 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<string | null> {
@@ -1202,66 +1162,30 @@ export class JmapMailProvider implements MailProvider {
return { processed: run.processed, remaining };
}
/** 부재중 자동 응답 (RFC 8621 §8 — 계정마다 하나) */
async getVacation(user: string): Promise<MailVacation> {
const responses = await this.jmap.call(
user,
(accountId) => [
['VacationResponse/get', { accountId, ids: [VACATION_ID] }, 'v'],
],
{ capabilities: [VACATION_CAPABILITY] },
);
const { list = [] } = expectResult<JmapGetResult<JmapVacationResponse>>(
responses,
'v',
'VacationResponse/get',
);
return toVacation(list[0]);
/**
* 부재중 자동 응답 — 필터와 같은 엔진 규칙(`arasystem` 스크립트)에 둔다. 그 스크립트가 아직 없으면 엔진의 VacationResponse 값이다
* (계약 2절 "메일 필터"의 저장 자리 — jmap-filter-script.ts)
*/
getVacation(user: string): Promise<MailVacation> {
return this.filterScripts.getVacation(user);
}
/**
* 부재중 자동 응답을 통째로 바꾼다 — 같은 요청에서 다시 읽은 값을 돌려준다(엔진이 저장한 그대로).
* 서식 본문은 늘 비운다 (toJmapVacation). 엔진이 거절하면 그 코드 (형식 거절은 VAL_001)
*/
async setVacation(
/** 부재중 자동 응답만 바꾼다 — 같은 스크립트의 필터 규칙은 그대로 둔다. 저장한 값을 돌려준다 */
setVacation(user: string, vacation: MailVacation): Promise<MailVacation> {
return this.filterScripts.setVacation(user, vacation);
}
/** 메일 필터 — 규칙 · 지문 · 동작하는지 (계약 2절 "메일 필터") */
getMailFilters(user: string): Promise<MailFilterSet> {
return this.filterScripts.getFilters(user);
}
/** 메일 필터를 통째로 바꾼다 — 그사이 규칙이 바뀌었으면 MAIL_012, 만든 스크립트가 한도를 넘으면 MAIL_013 */
setMailFilters(
user: string,
vacation: MailVacation,
): Promise<MailVacation> {
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<JmapSetResult>(
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<JmapGetResult<JmapVacationResponse>>(
responses,
'v',
'VacationResponse/get',
);
return toVacation(list[0]);
update: MailFilterUpdate,
): Promise<MailFilterSet> {
return this.filterScripts.setFilters(user, update);
}
async getUsage(user: string): Promise<MailUsage> {
@@ -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<string, unknown> }
| { 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<T>(
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<string, JmapError> | undefined,
): JmapError | undefined {
return errors ? Object.values(errors)[0] : undefined;
}
/** 엔진이 알려 준 상태 문자열인가 (비었거나 없으면 아니다) */
export function isState(value: unknown): value is string {
return typeof value === 'string' && value !== '';
}
+5 -21
View File
@@ -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<string, unknown> {
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[],
@@ -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;
@@ -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',
);
});
});
@@ -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);
}
@@ -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> = {},
): 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);
});
});
@@ -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<MailFilterField, (value: string) => 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<SieveExtension>();
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,
};
}
@@ -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);
}
}
@@ -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> = {},
): 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<MailFilterRule>, 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);
});
});
+142
View File
@@ -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<string>();
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;
}
@@ -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;
+5
View File
@@ -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)
+24 -2
View File
@@ -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<MailboxBulkResult>;
/** 부재중 자동 응답 — 엔진 값이 없으면(null) 제목 · 내용은 빈 문자열 */
/**
* 부재중 자동 응답 — 한 번도 설정하지 않았으면 끈 상태 · 빈 제목과 내용.
* 저장은 메일 필터와 같은 엔진 규칙에 둔다 (계약 2절 "메일 필터"의 저장 자리)
*/
getVacation(user: string): Promise<MailVacation>;
/** 부재중 자동 응답을 통째로 바꾸고(서식 본문은 비운다) 다시 읽은 값을 돌려준다 — 형식은 요청 검증이, 켤 때의 빈 값 · 날짜 순서는 서비스가 본다 */
/**
* 부재중 자동 응답을 통째로 바꾸고 저장한 값을 돌려준다 — 메일 필터 규칙은 그대로 둔다.
* 형식은 요청 검증이, 켤 때의 빈 값 · 길이 · 날짜 순서는 서비스가 본다. 함께 만든 엔진 규칙이 엔진 한도를 넘으면 MAIL_013
*/
setVacation(user: string, vacation: MailVacation): Promise<MailVacation>;
/** 메일 필터 (계약 2절 "메일 필터") — 규칙(차례대로) · 지금 규칙의 지문(version) · 동작하는지(isActive) */
getMailFilters(user: string): Promise<MailFilterSet>;
/**
* 메일 필터 규칙을 통째로 바꾸고 저장한 것을 돌려준다 — 부재중 자동 응답은 그대로 둔다. 저장하면 다시 동작한다(isActive).
* 그사이 다른 곳에서 규칙이 바뀌었으면(지금 규칙의 지문이 version 과 다름) 저장하지 않고 MAIL_012 —
* 지금 규칙이 바꾸려는 규칙과 이미 같으면 그대로 저장한다(같은 요청을 다시 보낸 경우).
* 만든 엔진 규칙이 엔진 한도를 넘으면 MAIL_013. 규칙의 형식 · 내용은 서비스가 검사한 값이다
*/
setMailFilters(
user: string,
update: MailFilterUpdate,
): Promise<MailFilterSet>;
/** 메일함 사용량과 한도 (한도가 없으면 limitBytes = null) */
getUsage(user: string): Promise<MailUsage>;
@@ -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> = {},
): 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<MailFilterRule>, 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());
+113 -2
View File
@@ -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<MailboxRole> = 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<MailFilterSet> {
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<MailFilterSet> {
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<void> {
const targets = rules.flatMap((rule, index) =>
rule.actions.moveToMailboxId === null
? []
: [{ index, mailboxId: rule.actions.moveToMailboxId }],
);
if (targets.length === 0) return;
const roles = new Map(
(await this.provider.listMailboxes(user.email)).map((mailbox) => [
mailbox.id,
mailbox.role,
]),
);
for (const { index, mailboxId } of targets) {
const role = roles.get(mailboxId);
if (role && FILTER_MOVE_FORBIDDEN_ROLES.has(role)) {
throw new BusinessException('VAL_001', {
context: { reason: 'filter-move-target-not-allowed', role, index },
});
}
}
}
/**
* 같은 직원 · 같은 메일함의 오래 걸리는 처리를 하나씩만 한다 — 진행 중이면 엔진에 묻지 않고 바로 REQ_001
* (로그인의 진행 중 시도와 같은 모양). 끝나면 성공 · 실패와 상관없이 푼다. 다른 메일함끼리는 동시에 된다
+58 -1
View File
@@ -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;
@@ -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);