generated from ttipo/project-base
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:
+246
-16
@@ -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 머리말).
|
||||
|
||||
Reference in New Issue
Block a user