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

GET·PUT /api/mail-filters 를 더한다. 4단계 부재중 자동 응답의 저장 자리는 엔진의
VacationResponse 에서 우리 Sieve 스크립트(arasystem)로 옮긴다 — API 는 그대로다.

- 엔진은 계정마다 켜진 스크립트가 하나라 필터와 부재중을 한 스크립트에 둔다.
  읽을 때는 머리 주석의 원본(JSON)만 믿고, 쓸 때는 읽은 상태 그대로일 때만 쓴다(ifInState)
- 다른 곳에서 먼저 바꿨으면 409 MAIL_012, 만든 글이 90,000바이트를 넘으면 400 MAIL_013
- 스팸은 엔진의 분류 표시(X-Spam-Score)를 보고 건너뛴다. 처음에는 spamtest(정수로 내린
  점수)로 막았는데, 실엔진에서 9.7점 스팸에 규칙과 부재중 응답이 나가는 것을 찾았다
- 엔진의 오류 설명은 로그에 줄 · 칸만 남긴다. 새 판 원본은 덮어쓰지 않는다(배포를 되돌릴 때)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-28 20:59:31 +09:00
parent 41b5fdbe22
commit b4d30482fe
33 changed files with 4809 additions and 301 deletions
+315 -3
View File
@@ -37,6 +37,9 @@ import type {
MailboxBulkResult,
MailboxCreate,
MailboxDeletion,
MailFilterRule,
MailFilterSet,
MailFilterUpdate,
MailVacation,
MailWatch,
MailWatchListener,
@@ -163,6 +166,33 @@ const ENGINE_REJECTIONS = new Map<string, RecipientRejectionReason>([
[BUSY, 'temporary'],
]);
/** 대역 메일 엔진이 "만든 엔진 규칙이 엔진 한도를 넘는다"고 답하는 필터의 찾을 글 (400 MAIL_013 경로) */
const ENGINE_TOO_LARGE_FILTER_VALUE = 'engine-says-too-large';
/** 대역 메일 엔진의 메일함 — 필터가 옮길 수 없는 보낸편지함(e) · 임시보관함(d) 확인용 */
const E2E_MAILBOXES: Mailbox[] = (
[
['a', 'inbox'],
['b', 'trash'],
['c', 'junk'],
['d', 'drafts'],
['e', 'sent'],
['j', null],
] as const
).map(([id, role]) => ({
id,
name: id,
role,
parentId: null,
sortOrder: 0,
totalCount: 0,
unreadCount: 0,
}));
/** 대역 엔진의 규칙 지문 — 실제 지문은 jmap/filter-source.ts (여기서는 같은 규칙이면 같은 값이면 된다) */
const fakeFilterVersion = (rules: readonly MailFilterRule[]) =>
createHash('sha256').update(JSON.stringify(rules)).digest('base64url');
/** 한 번도 설정하지 않은 계정의 부재중 자동 응답 (계약 2절) */
const NEVER_CONFIGURED_VACATION: MailVacation = {
isEnabled: false,
@@ -332,8 +362,13 @@ describe('응답 계약 (e2e)', () => {
/** 4단계 — 대역 엔진이 받은 비밀번호 바꾸기 · 메일함 관리 · 부재중 자동 응답 · 한 통 바꾸기 */
const passwordChanges: { email: string; newPassword: string }[] = [];
const mailboxCalls: { user: string; action: string; args: unknown[] }[] = [];
const vacations = new Map<string, MailVacation>();
const vacationSaves: { user: string; vacation: MailVacation }[] = [];
/** 6단계 — 대역 엔진의 엔진 규칙(직원마다 하나): 필터 규칙과 부재중 자동 응답을 함께 둔다 (계약 2절 "메일 필터"의 저장 자리) */
const mailScripts = new Map<
string,
{ rules: readonly MailFilterRule[]; vacation: MailVacation }
>();
const filterSaves: { user: string; update: MailFilterUpdate }[] = [];
const singleUpdates: {
user: string;
id: string;
@@ -635,15 +670,71 @@ describe('응답 계약 (e2e)', () => {
return { processed: 2, remaining: 0 };
},
getVacation: (user: string): Promise<MailVacation> =>
Promise.resolve(vacations.get(user) ?? NEVER_CONFIGURED_VACATION),
Promise.resolve(
mailScripts.get(user)?.vacation ?? NEVER_CONFIGURED_VACATION,
),
// 부재중만 바꾼다 — 같은 엔진 규칙의 필터 규칙은 그대로 (실제 규칙은 jmap-filter-script.spec.ts)
setVacation: (
user: string,
vacation: MailVacation,
): Promise<MailVacation> => {
vacationSaves.push({ user, vacation });
vacations.set(user, vacation);
mailScripts.set(user, {
rules: mailScripts.get(user)?.rules ?? [],
vacation,
});
return Promise.resolve(vacation);
},
listMailboxes: (): Promise<Mailbox[]> => Promise.resolve(E2E_MAILBOXES),
// 6단계 — 엔진 쪽 판단(지문 비교 · 한도)만 흉내 낸다. 만드는 Sieve · 조건부 쓰기는 jmap/*.spec.ts 가 본다
getMailFilters: (user: string): Promise<MailFilterSet> => {
const rules = mailScripts.get(user)?.rules ?? [];
return Promise.resolve({
rules,
version: fakeFilterVersion(rules),
isActive: true,
});
},
setMailFilters: (
user: string,
update: MailFilterUpdate,
): Promise<MailFilterSet> => {
filterSaves.push({ user, update });
const current = mailScripts.get(user);
const version = fakeFilterVersion(current?.rules ?? []);
if (
version !== update.version &&
version !== fakeFilterVersion(update.rules)
) {
return Promise.reject(
new BusinessException('MAIL_012', {
context: { reason: 'e2e-filter-rules-changed' },
}),
);
}
if (
update.rules.some((rule) =>
rule.conditions.some(
({ value }) => value === ENGINE_TOO_LARGE_FILTER_VALUE,
),
)
) {
return Promise.reject(
new BusinessException('MAIL_013', {
context: { reason: 'e2e-filter-script-too-large' },
}),
);
}
mailScripts.set(user, {
rules: update.rules,
vacation: current?.vacation ?? NEVER_CONFIGURED_VACATION,
});
return Promise.resolve({
rules: update.rules,
version: fakeFilterVersion(update.rules),
isActive: true,
});
},
openMessageSource: (
_user: string,
id: string,
@@ -1073,6 +1164,8 @@ describe('응답 계약 (e2e)', () => {
['delete', '/api/mailboxes/u1/messages'],
['get', '/api/mail-vacation'],
['put', '/api/mail-vacation'],
['get', '/api/mail-filters'],
['put', '/api/mail-filters'],
['get', '/api/messages/eaaaaab/source'],
// 템플릿 업로드 예제도 로그인한 직원만 쓴다
['post', '/api/files'],
@@ -2848,6 +2941,225 @@ describe('응답 계약 (e2e)', () => {
});
});
/**
* 6단계 — 메일 필터 (계약 2절 "메일 필터").
* 엔진 쪽(만드는 Sieve · 머리의 원본 · 지문 · 조건부 쓰기 · 다시 시도)은 jmap/*.spec.ts 가 본다 —
* 여기서는 HTTP 형식 · 요청 검사 · 부재중 자동 응답과 같은 저장 자리를 쓰는지를 본다 (대역 엔진이 둘을 한 곳에 둔다)
*/
describe('6단계 — 메일 필터', () => {
describe('메일 필터 (/api/mail-filters)', () => {
const ME = 'filter.me@e2e.arasystem.test';
const rule = {
id: '0b9a4f9e-7c2d-4d3a-9f4e-1a2b3c4d5e6f',
name: '거래처 메일',
isEnabled: true,
match: 'any',
conditions: [
{
field: 'from',
operator: 'contains',
value: '@hanbit-partners.co.kr',
},
],
actions: {
moveToMailboxId: 'j',
markAsRead: true,
flag: false,
forwardTo: 'doyun.lee@e2e.arasystem.test',
},
};
const get = () =>
request(server).get('/api/mail-filters').set('Cookie', cookieOf(ME));
const put = (payload: object) =>
request(server)
.put('/api/mail-filters')
.set('Cookie', cookieOf(ME))
.send(payload);
const versionNow = async () =>
dataOf<{ version: string }>(await get().expect(200)).version;
it('처음에는 규칙 없음 · 동작함 — 받은 version 으로 저장하면 200(글은 앞뒤 공백을 지워서), 다음 조회도 그 규칙', async () => {
const first = await get().expect(200);
expect(body(first).success).toBe(true);
const initial = dataOf<{
rules: unknown[];
version: string;
isActive: boolean;
}>(first);
expect(initial).toEqual({
rules: [],
version: expect.any(String) as string,
isActive: true,
});
const res = await put({
rules: [
{
...rule,
name: ' 거래처 메일 ',
conditions: [
{ ...rule.conditions[0], value: ' @hanbit-partners.co.kr ' },
],
},
],
version: initial.version,
}).expect(200);
const saved = dataOf<{ rules: unknown[]; version: string }>(res);
expect(saved.rules).toEqual([rule]);
expect(saved.version).not.toBe(initial.version);
expect(filterSaves.at(-1)?.user).toBe(ME);
const read = dataOf<{ rules: unknown[]; version: string }>(
await get().expect(200),
);
expect(read).toEqual({
rules: [rule],
version: saved.version,
isActive: true,
});
});
it('옛 version 으로 다른 규칙을 저장하면 409 MAIL_012 — 저장하지 않는다. 같은 규칙을 다시 보내면 그대로 200', async () => {
const before = await versionNow();
await put({
rules: [{ ...rule, id: 'other-tab-rule' }],
version: before,
}).expect(200);
const conflict = await put({
rules: [{ ...rule, id: 'my-rule' }],
version: before,
}).expect(409);
expect(body(conflict).error?.code).toBe('MAIL_012');
expect(
dataOf<{ rules: { id: string }[] }>(await get().expect(200)).rules,
).toEqual([expect.objectContaining({ id: 'other-tab-rule' })]);
// 결과를 모르는 실패 뒤의 재시도 — 저장해 둔 규칙과 같으면 옛 version 이어도 저장한다
await put({
rules: [{ ...rule, id: 'other-tab-rule' }],
version: before,
}).expect(200);
});
it('형식 · 칸 사이의 규칙이 어긋나면 400 VAL_001 — 엔진에 묻지 않는다', async () => {
const version = await versionNow();
const before = filterSaves.length;
const withRule = (overrides: object) => ({
rules: [{ ...rule, ...overrides }],
version,
});
const withActions = (overrides: object) =>
withRule({ actions: { ...rule.actions, ...overrides } });
const withValue = (value: unknown) =>
withRule({ conditions: [{ ...rule.conditions[0], value }] });
const payloads = [
{ rules: [rule] },
{ rules: [rule], version, isActive: true },
{
rules: Array.from({ length: 51 }, (_, i) => ({
...rule,
id: `r${i}`,
})),
version,
},
{ rules: [rule, rule], version },
withRule({ id: '규칙 하나' }),
withRule({ name: 'x'.repeat(51) }),
withRule({ match: 'always' }),
withRule({ match: 'all', conditions: [] }),
withRule({
conditions: Array.from({ length: 6 }, () => rule.conditions[0]),
}),
withRule({ priority: 1 }),
withValue(''),
withValue(' '),
withValue('x'.repeat(201)),
withValue('광고${x}'),
withValue('광고\n줄바꿈'),
withValue('탭\t가운데'),
withActions({
moveToMailboxId: null,
markAsRead: false,
flag: false,
forwardTo: null,
}),
withActions({ forwardTo: 'someone@gmail.com' }),
withActions({ forwardTo: 'filter.me@e2e.arasystem.test' }),
withActions({ forwardTo: '${x}@e2e.arasystem.test' }),
withActions({ forwardTo: 'not-an-address' }),
withActions({ moveToMailboxId: 'e' }),
withActions({ moveToMailboxId: 'd' }),
];
// 몇 번째 본문이 어긋났는지 보이게 모두 보낸 뒤 견준다
const answers: string[] = [];
for (const payload of payloads) {
const res = await put(payload);
answers.push(`${res.status} ${body(res).error?.code ?? ''}`);
}
expect(answers).toEqual(payloads.map(() => '400 VAL_001'));
expect(filterSaves).toHaveLength(before);
// 지운 메일함을 가리키는 규칙은 받는다 — 그 메일함이 없으면 받은편지함에 들어온다
await put(withActions({ moveToMailboxId: 'deletedbox' })).expect(200);
});
it('만든 엔진 규칙이 엔진 한도를 넘으면 400 MAIL_013', async () => {
const res = await put({
rules: [
{
...rule,
conditions: [
{
...rule.conditions[0],
value: ENGINE_TOO_LARGE_FILTER_VALUE,
},
],
},
],
version: await versionNow(),
}).expect(400);
expect(body(res).error?.code).toBe('MAIL_013');
});
it('필터를 저장해도 부재중 자동 응답은 그대로다 — 같은 엔진 규칙에 함께 두고, 부재중 저장도 필터를 지킨다', async () => {
const vacation = {
isEnabled: true,
fromDate: '2026-11-02T15:00:00.000Z',
toDate: '2026-11-07T15:00:00.000Z',
subject: '부재중입니다',
textBody: '11월 7일까지 휴가입니다.',
};
await request(server)
.put('/api/mail-vacation')
.set('Cookie', cookieOf(ME))
.send(vacation)
.expect(200);
await put({
rules: [{ ...rule, isEnabled: false }],
version: await versionNow(),
}).expect(200);
const read = await request(server)
.get('/api/mail-vacation')
.set('Cookie', cookieOf(ME))
.expect(200);
expect(dataOf<unknown>(read)).toEqual(vacation);
await request(server)
.put('/api/mail-vacation')
.set('Cookie', cookieOf(ME))
.send({ ...vacation, isEnabled: false })
.expect(200);
expect(
dataOf<{ rules: unknown[] }>(await get().expect(200)).rules,
).toEqual([{ ...rule, isEnabled: false }]);
});
});
});
/**
* 출처 검사(CSRF) — 상태를 바꾸는 요청은 Sec-Fetch-Site(same-origin·none) 또는 허용 목록의 Origin 이어야 한다.
* 둘 다 없으면 브라우저가 아닌 클라이언트로 보고 통과시킨다 (docs/mail/api-contract.md 머리말).