feat(backend): 이미 받은 메일에 필터 적용 POST /api/mail-filter-runs · 부재중 isActive

본문의 규칙 하나로 받은편지함을 최근 메일부터 판정하고 표시 · 옮기기만 한다(전달 · 지우기 없음).
한 요청은 25초 안에서 하고 cursor 로 이어 하며, 같은 직원은 한 번에 하나다(진행 중이면 429 REQ_001).

- 판정(mail-filter-match.ts)은 엔진 Sieve(sieve-script.ts 의 FIELD_TESTS)를 그대로 옮겼다. 견줄 글은
  원래 머리글(mime-text.ts 의 decodeHeaderText)과 텍스트 · HTML · 텍스트 첨부(html/html-text.ts)에서 뽑는다.
  실엔진 배달 94통(HTML 75 · 머리글 19)을 표 테스트로 고정했다 — HTML5 에만 있는 이름 참조는 알려진 한계
- 이어 할 자리(cursor 2판)는 위 경계 · 남긴 마지막 메일과 그 받은 시각이다. 시작한 뒤 온 메일은 보지 않고,
  그 메일이 빠지면 그 초부터 다시 읽는다 — 리뷰가 가짜 엔진으로 재현한 건너뜀을 고쳤다
- 옮기기는 읽은 상태 그대로일 때만(ifInState), 본문 값은 부분마다 256KiB · 한 통 1MiB 까지
- 오래 걸리는 처리의 시간 규칙(BulkRun)을 jmap/bulk-run.ts 로 옮겨 메일함 처리와 함께 쓴다
- 부재중 GET · PUT 응답에 isActive — 엔진에서 우리 Sieve 스크립트가 꺼져 있으면 false(본문으로는 받지 않는다)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-29 08:37:51 +09:00
parent ab4124ba94
commit 6fd0911b86
34 changed files with 6861 additions and 240 deletions
+246 -16
View File
@@ -38,9 +38,12 @@ import type {
MailboxCreate,
MailboxDeletion,
MailFilterRule,
MailFilterRunOutcome,
MailFilterRunRequest,
MailFilterSet,
MailFilterUpdate,
MailVacation,
MailVacationState,
MailWatch,
MailWatchListener,
MessageDeletionResult,
@@ -189,6 +192,9 @@ const E2E_MAILBOXES: Mailbox[] = (
unreadCount: 0,
}));
/** 대역 메일 엔진이 "다른 규칙의 cursor"라고 답하는 cursor (400 VAL_001 경로 — 실제 해석은 jmap/filter-run-cursor.ts) */
const OTHER_RULE_CURSOR = 'otherrulecursor';
/** 대역 엔진의 규칙 지문 — 실제 지문은 jmap/filter-source.ts (여기서는 같은 규칙이면 같은 값이면 된다) */
const fakeFilterVersion = (rules: readonly MailFilterRule[]) =>
createHash('sha256').update(JSON.stringify(rules)).digest('base64url');
@@ -369,6 +375,11 @@ describe('응답 계약 (e2e)', () => {
{ rules: readonly MailFilterRule[]; vacation: MailVacation }
>();
const filterSaves: { user: string; update: MailFilterUpdate }[] = [];
/** 7단계 — 엔진에서 우리 규칙이 꺼진 직원(부재중 · 필터가 동작하지 않음 — 저장하면 다시 켜진다) · 받은 "이미 받은 메일에 적용" */
const inactiveScripts = new Set<string>();
const filterRuns: { user: string; request: MailFilterRunRequest }[] = [];
/** 대역 엔진의 적용이 테스트가 문을 열 때까지 끝나지 않는다 — 같은 직원의 진행 중 잠금 확인용. null 이면 바로 답한다 */
let slowFilterRunGate: Promise<void> | null = null;
const singleUpdates: {
user: string;
id: string;
@@ -669,21 +680,23 @@ describe('응답 계약 (e2e)', () => {
if (id === SLOW_MAILBOX && slowMailboxGate) await slowMailboxGate;
return { processed: 2, remaining: 0 };
},
getVacation: (user: string): Promise<MailVacation> =>
Promise.resolve(
mailScripts.get(user)?.vacation ?? NEVER_CONFIGURED_VACATION,
),
getVacation: (user: string): Promise<MailVacationState> =>
Promise.resolve({
...(mailScripts.get(user)?.vacation ?? NEVER_CONFIGURED_VACATION),
isActive: !inactiveScripts.has(user),
}),
// 부재중만 바꾼다 — 같은 엔진 규칙의 필터 규칙은 그대로 (실제 규칙은 jmap-filter-script.spec.ts)
setVacation: (
user: string,
vacation: MailVacation,
): Promise<MailVacation> => {
): Promise<MailVacationState> => {
vacationSaves.push({ user, vacation });
mailScripts.set(user, {
rules: mailScripts.get(user)?.rules ?? [],
vacation,
});
return Promise.resolve(vacation);
inactiveScripts.delete(user);
return Promise.resolve({ ...vacation, isActive: true });
},
listMailboxes: (): Promise<Mailbox[]> => Promise.resolve(E2E_MAILBOXES),
// 6단계 — 엔진 쪽 판단(지문 비교 · 한도)만 흉내 낸다. 만드는 Sieve · 조건부 쓰기는 jmap/*.spec.ts 가 본다
@@ -735,6 +748,34 @@ describe('응답 계약 (e2e)', () => {
isActive: true,
});
},
// 7단계 — 차례 · 판정 · 이어 할 자리는 jmap-filter-run.spec.ts 가 본다. 여기서는 받은 값을 기록하고 cursor 로 이어 하기만 흉내 낸다
runMailFilter: async (
user: string,
request: MailFilterRunRequest,
): Promise<MailFilterRunOutcome> => {
filterRuns.push({ user, request });
if (request.cursor === OTHER_RULE_CURSOR) {
throw new BusinessException('VAL_001', {
context: { reason: 'e2e-filter-run-other-rule' },
});
}
if (slowFilterRunGate) await slowFilterRunGate;
return request.cursor === null
? {
scanned: 500,
matched: 3,
remaining: 120,
cursor: 'e2enextcursor',
rounds: 1,
}
: {
scanned: 120,
matched: 1,
remaining: 0,
cursor: null,
rounds: 1,
};
},
openMessageSource: (
_user: string,
id: string,
@@ -1242,6 +1283,7 @@ describe('응답 계약 (e2e)', () => {
['put', '/api/mail-vacation'],
['get', '/api/mail-filters'],
['put', '/api/mail-filters'],
['post', '/api/mail-filter-runs'],
['get', '/api/messages/eaaaaab/source'],
// 템플릿 업로드 예제도 로그인한 직원만 쓴다
['post', '/api/files'],
@@ -2581,30 +2623,41 @@ describe('응답 계약 (e2e)', () => {
.set('Cookie', cookieOf(ME))
.send(payload);
it('한 번도 설정하지 않았으면 끈 상태 · 빈 제목과 내용 — 받은 그대로 다시 저장해도 200', async () => {
it('한 번도 설정하지 않았으면 끈 상태 · 빈 제목과 내용 · 동작함 — isActive 를 뺀 그대로 다시 저장해도 200, isActive 를 넣으면 400', async () => {
const res = await request(server)
.get('/api/mail-vacation')
.set('Cookie', cookieOf(ME))
.expect(200);
expect(body(res)).toEqual({
success: true,
data: NEVER_CONFIGURED_VACATION,
data: { ...NEVER_CONFIGURED_VACATION, isActive: true },
});
const restored = await put(dataOf<object>(res)).expect(200);
expect(dataOf<unknown>(restored)).toEqual(NEVER_CONFIGURED_VACATION);
const { isActive, ...stored } = dataOf<MailVacationState>(res);
expect(isActive).toBe(true);
const restored = await put(stored).expect(200);
expect(dataOf<unknown>(restored)).toEqual({
...NEVER_CONFIGURED_VACATION,
isActive: true,
});
// 응답 그대로(isActive 포함) 보내면 모르는 필드 — 계약 2절 "부재중 자동 응답"의 MailVacationState
const before = vacationSaves.length;
const rejected = await put(dataOf<object>(res)).expect(400);
expect(body(rejected).error?.code).toBe('VAL_001');
expect(vacationSaves).toHaveLength(before);
});
it('저장하면(전체 교체) 엔진이 저장한 값을 돌려주고, 다음 조회도 그 값이다', async () => {
const res = await put(vacation).expect(200);
expect(dataOf<unknown>(res)).toEqual(vacation);
expect(dataOf<unknown>(res)).toEqual({ ...vacation, isActive: true });
expect(vacationSaves.at(-1)).toEqual({ user: ME, vacation });
const read = await request(server)
.get('/api/mail-vacation')
.set('Cookie', cookieOf(ME))
.expect(200);
expect(dataOf<unknown>(read)).toEqual(vacation);
expect(dataOf<unknown>(read)).toEqual({ ...vacation, isActive: true });
});
it('켤 때 빈(공백뿐인) 제목 · 내용, 제어 문자, 시작이 끝보다 늦음 · 같음(같은 초 포함), 날짜 형식, 빠진 키, 길이 초과(켤 때 150 · 600자, 꺼 둔 채로 200 · 4,000자), 서식 본문은 400 VAL_001 — 저장하지 않는다', async () => {
@@ -2660,7 +2713,7 @@ describe('응답 계약 (e2e)', () => {
const res = await put(full).expect(200);
expect(dataOf<unknown>(res)).toEqual(full);
expect(dataOf<unknown>(res)).toEqual({ ...full, isActive: true });
});
it('꺼 둔 채로는 4단계 한도까지 받는다 — 4단계에 저장해 둔 영문 내용 1,000자도 끌 수 있고(200), 그대로 켜면 400 VAL_001 (줄인 뒤 켠다)', async () => {
@@ -2671,7 +2724,7 @@ describe('응답 계약 (e2e)', () => {
};
const res = await put(longOff).expect(200);
expect(dataOf<unknown>(res)).toEqual(longOff);
expect(dataOf<unknown>(res)).toEqual({ ...longOff, isActive: true });
expect(vacationSaves.at(-1)).toEqual({ user: ME, vacation: longOff });
const before = vacationSaves.length;
@@ -2706,7 +2759,7 @@ describe('응답 계약 (e2e)', () => {
fromDate: '2026-11-02T15:00:00.000Z',
toDate: '2026-11-07T15:00:00.000Z',
};
expect(dataOf<unknown>(res)).toEqual(saved);
expect(dataOf<unknown>(res)).toEqual({ ...saved, isActive: true });
expect(vacationSaves.at(-1)).toEqual({ user: ME, vacation: saved });
});
});
@@ -3222,7 +3275,7 @@ describe('응답 계약 (e2e)', () => {
.get('/api/mail-vacation')
.set('Cookie', cookieOf(ME))
.expect(200);
expect(dataOf<unknown>(read)).toEqual(vacation);
expect(dataOf<unknown>(read)).toEqual({ ...vacation, isActive: true });
await request(server)
.put('/api/mail-vacation')
@@ -3236,6 +3289,183 @@ describe('응답 계약 (e2e)', () => {
});
});
describe('7단계 — 이미 받은 메일에 필터 적용 · 부재중의 isActive', () => {
describe('이미 받은 메일에 적용 (POST /api/mail-filter-runs)', () => {
const ME = 'filter.run@e2e.arasystem.test';
const run = {
match: 'any',
conditions: [
{
field: 'from',
operator: 'contains',
value: '@hanbit-partners.co.kr',
},
],
actions: { moveToMailboxId: 'j', markAsRead: true, flag: false },
cursor: null,
};
const post = (payload: unknown, email = ME) =>
request(server)
.post('/api/mail-filter-runs')
.set('Cookie', cookieOf(email))
.send(payload as object);
it('200(201 이 아니다) — 확인 · 적용 · 남은 수와 cursor, 차례 수는 응답에 없다 · 받은 cursor 로 다시 보내면 이어 한다 (찾을 글은 앞뒤 공백을 지워 넘긴다)', async () => {
const first = await post({
...run,
conditions: [
{ ...run.conditions[0], value: ' @hanbit-partners.co.kr ' },
],
}).expect(200);
expect(body(first)).toEqual({
success: true,
data: {
scanned: 500,
matched: 3,
remaining: 120,
cursor: 'e2enextcursor',
},
});
expect(filterRuns.at(-1)).toEqual({ user: ME, request: run });
const next = await post({ ...run, cursor: 'e2enextcursor' }).expect(
200,
);
expect(dataOf<unknown>(next)).toEqual({
scanned: 120,
matched: 1,
remaining: 0,
cursor: null,
});
expect(filterRuns.at(-1)?.request.cursor).toBe('e2enextcursor');
});
it('형식 · 칸 사이의 규칙이 어긋나면 400 VAL_001 — 엔진에 묻지 않는다 (전달 · 보낸편지함 · 임시보관함 · 할 일 없음 · cursor 빠짐 · 모르는 필드)', async () => {
const before = filterRuns.length;
const withActions = (overrides: object) => ({
...run,
actions: { ...run.actions, ...overrides },
});
const withoutCursor = Object.fromEntries(
Object.entries(run).filter(([key]) => key !== 'cursor'),
);
const payloads = [
withActions({ forwardTo: 'doyun.lee@e2e.arasystem.test' }),
withActions({ moveToMailboxId: 'e' }),
withActions({ moveToMailboxId: 'd' }),
withActions({ moveToMailboxId: null, markAsRead: false }),
{ ...run, match: 'always' },
{ ...run, conditions: [] },
{
...run,
conditions: [{ ...run.conditions[0], value: '광고${x}' }],
},
withoutCursor,
{ ...run, cursor: 'not base64url!' },
{ ...run, cursor: 'a'.repeat(1_025) },
{ ...run, version: 'v' },
{ ...run, id: 'rule-1' },
];
const answers: string[] = [];
for (const payload of payloads) {
const res = await post(payload);
answers.push(`${res.status} ${body(res).error?.code ?? ''}`);
}
expect(answers).toEqual(payloads.map(() => '400 VAL_001'));
expect(filterRuns).toHaveLength(before);
});
it('다른 규칙의 cursor 는 어댑터가 400 VAL_001 로 막는다 · 없는 메일함(지워짐)으로 옮기는 규칙은 받는다', async () => {
const res = await post({ ...run, cursor: OTHER_RULE_CURSOR }).expect(
400,
);
expect(body(res).error?.code).toBe('VAL_001');
await post({
...run,
actions: { ...run.actions, moveToMailboxId: 'deletedbox' },
}).expect(200);
});
it(
'같은 직원의 적용이 진행 중이면 429 REQ_001 — 엔진에 묻지 않는다. 다른 직원은 된다, 끝나면 다시 된다',
async () => {
const gate = createGate();
slowFilterRunGate = gate.opened;
try {
const before = filterRuns.length;
const slow = post(run).then((res) => res);
await waitUntil(() => filterRuns.length > before, GATE_DEADLINE_MS);
expect(filterRuns).toHaveLength(before + 1);
const busy = await post(run).expect(429);
expect(body(busy).error?.code).toBe('REQ_001');
expect(filterRuns).toHaveLength(before + 1);
gate.open();
expect((await slow).status).toBe(200);
} finally {
gate.open();
slowFilterRunGate = null;
}
await post(run).expect(200);
await post(run, 'filter.run.other@e2e.arasystem.test').expect(200);
},
GATED_TEST_TIMEOUT_MS,
);
it('본문의 모양 한도(배열 하나 1,000)를 넘으면 DTO 검사 전에 400 VAL_001 — 로그인 전이어도 401 이 아니다', async () => {
const huge = JSON.stringify({
...run,
conditions: Array.from({ length: 80_000 }, () => ({})),
});
for (const cookie of [cookieOf(ME), '']) {
const res = await request(server)
.post('/api/mail-filter-runs')
.set('Cookie', cookie)
.set('Content-Type', 'application/json')
.send(huge);
expect(res.status).toBe(400);
expect(body(res).error?.code).toBe('VAL_001');
expect(body(res).error?.traceId).toBe(res.headers['x-request-id']);
}
});
});
describe('부재중 자동 응답의 isActive (GET · PUT /api/mail-vacation)', () => {
const ME = 'vacation.inactive@e2e.arasystem.test';
it('엔진에서 우리 규칙이 꺼져 있으면 isActive: false — 켜 둔 값은 그대로, 저장하면 다시 켜진다', async () => {
const vacation = {
isEnabled: true,
fromDate: null,
toDate: null,
subject: '부재중입니다',
textBody: '11월 7일까지 휴가입니다.',
};
await request(server)
.put('/api/mail-vacation')
.set('Cookie', cookieOf(ME))
.send(vacation)
.expect(200);
inactiveScripts.add(ME);
const read = await request(server)
.get('/api/mail-vacation')
.set('Cookie', cookieOf(ME))
.expect(200);
expect(dataOf<unknown>(read)).toEqual({ ...vacation, isActive: false });
const saved = await request(server)
.put('/api/mail-vacation')
.set('Cookie', cookieOf(ME))
.send(vacation)
.expect(200);
expect(dataOf<unknown>(saved)).toEqual({ ...vacation, isActive: true });
});
});
});
/**
* 출처 검사(CSRF) — 상태를 바꾸는 요청은 Sec-Fetch-Site(same-origin·none) 또는 허용 목록의 Origin 이어야 한다.
* 둘 다 없으면 브라우저가 아닌 클라이언트로 보고 통과시킨다 (docs/mail/api-contract.md 머리말).