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:
@@ -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 머리말).
|
||||
|
||||
Reference in New Issue
Block a user