generated from ttipo/project-base
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:
@@ -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)
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 !== '';
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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
|
||||
* (로그인의 진행 중 시도와 같은 모양). 끝나면 성공 · 실패와 상관없이 푼다. 다른 메일함끼리는 동시에 된다
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user