generated from ttipo/project-base
feat(backend): 새 메일 확인·전체 메일함 검색(한국어 조사)·여러 통 수정·휴지통 영구 삭제 API
- GET /api/mail-state: 메일함·메일 상태를 합친 불투명 값 — 화면이 1분마다 바뀐 것이 있는지만 묻는다 - GET /api/messages: mailboxId 를 선택으로(없으면 휴지통·스팸메일함을 뺀 전체). 엔진은 낱말이 통째로 같을 때만 맞으므로 q 를 낱말·구절로 나눠 낱말마다 조사를 붙인 형태를 OR 로 찾는다 (jmap/search-terms.ts) - PATCH /api/messages: 읽음·중요·이동을 100통까지 엔진 요청 한 번에. 없는 메일함이면 아무것도 바꾸지 않고 404 - POST /api/message-deletions: 휴지통에 있는 메일만, 확인한 상태 그대로일 때만(ifInState) 지운다 — 그사이 되돌린 메일을 지우지 않게 - 로그의 cursor 도 길이만 남긴다 — 커서에 검색어의 지문이 들어 있다 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -17,6 +17,9 @@ import { AUTH_SETTINGS, type AuthSettings } from '@/modules/auth/auth.settings';
|
||||
import {
|
||||
MAX_BODY_HTML_LENGTH,
|
||||
MAX_BODY_LENGTH,
|
||||
MAX_BULK_MESSAGE_IDS,
|
||||
MAX_SEARCH_LENGTH,
|
||||
MAX_SEARCH_WORDS,
|
||||
MAX_SUBJECT_LENGTH,
|
||||
} from '@/modules/mail/mail.constants';
|
||||
import { MAIL_PROVIDER } from '@/modules/mail/mail.provider';
|
||||
@@ -25,6 +28,10 @@ import type {
|
||||
Correspondence,
|
||||
DraftContent,
|
||||
DraftSource,
|
||||
MessageDeletionResult,
|
||||
MessageListQuery,
|
||||
MessagesPatchResult,
|
||||
MessageUpdate,
|
||||
OutgoingMessage,
|
||||
SavedDraft,
|
||||
} from '@/modules/mail/mail.types';
|
||||
@@ -100,6 +107,15 @@ const SLOW_ACCOUNT = 'slow.target@e2e.arasystem.test';
|
||||
/** 대역 메일 엔진이 "다른 창·기기에서 먼저 바뀌었다"고 답하는 임시 저장본 id (409 MAIL_008 경로) */
|
||||
const DRAFT_CHANGED_ELSEWHERE = 'draftchangedelsewhere';
|
||||
|
||||
/** 대역 메일 엔진이 돌려주는 새 메일 확인 값 */
|
||||
const MAIL_STATE = 'c2Iy.c2Fq';
|
||||
|
||||
/** 대역 메일 엔진이 "없는 메일함"으로 답하는 이동 대상 (404 RES_001 경로) */
|
||||
const MISSING_MAILBOX = 'nomailbox';
|
||||
|
||||
/** 대역 메일 엔진이 "그사이 지워졌다"고 답하는 메일 id (failedIds · skippedIds 경로) */
|
||||
const GONE_MESSAGE = 'gonemessage';
|
||||
|
||||
/** 대역 메일 엔진이 로그인 때 알려 주는 이름 — 없으면 EMPLOYEE.name */
|
||||
const PROFILE_NAMES = new Map([
|
||||
['colleague.a@e2e.arasystem.test', '가나다'],
|
||||
@@ -191,6 +207,16 @@ describe('응답 계약 (e2e)', () => {
|
||||
/** 대역 엔진이 돌려줄 주고받은 주소 (직원별) */
|
||||
const correspondents = new Map<string, Correspondence[]>();
|
||||
|
||||
/** 대역 엔진이 받은 메일 목록 요청 (검색 범위·검색어 확인용) */
|
||||
const listQueries: { user: string; query: MessageListQuery }[] = [];
|
||||
/** 대역 엔진이 받은 여러 통 바꾸기 · 휴지통에서 영구 삭제 */
|
||||
const bulkUpdates: {
|
||||
user: string;
|
||||
ids: readonly string[];
|
||||
update: MessageUpdate;
|
||||
}[] = [];
|
||||
const trashDeletions: { user: string; ids: readonly string[] }[] = [];
|
||||
|
||||
/** 이 앱의 서명 키로 토큰을 만든다 (CI 처럼 JWT_SECRET 이 없으면 임시 키) */
|
||||
const sign = (payload: object, expiresIn: number) =>
|
||||
jwt.sign(payload, {
|
||||
@@ -199,6 +225,10 @@ describe('응답 계약 (e2e)', () => {
|
||||
expiresIn,
|
||||
});
|
||||
|
||||
/** 그 직원의 액세스 쿠키 (세션 없이 서명만 맞춘다 — 보호 라우트는 액세스 토큰만 본다) */
|
||||
const cookieOf = (email: string) =>
|
||||
`access_token=${sign({ sub: email, typ: 'access', name: '테스트 직원' }, 600)}`;
|
||||
|
||||
/** 로그인해서 두 쿠키 값을 받는다 (서버에 세션이 생긴다) */
|
||||
const login = async (email: string) => {
|
||||
const res = await request(server)
|
||||
@@ -301,6 +331,45 @@ describe('응답 계약 (e2e)', () => {
|
||||
},
|
||||
listCorrespondents: (user: string) =>
|
||||
Promise.resolve(correspondents.get(user) ?? []),
|
||||
// 3단계 — 엔진 쪽 판단(검색 조건 · 조건부 영구 삭제)은 jmap-mail.provider.spec.ts 가 본다. 여기서는 받은 값만 기록한다
|
||||
getMailState: () => Promise.resolve({ state: MAIL_STATE }),
|
||||
listMessages: (user: string, query: MessageListQuery) => {
|
||||
listQueries.push({ user, query });
|
||||
return Promise.resolve({
|
||||
items: [],
|
||||
hasNext: false,
|
||||
nextCursor: null,
|
||||
total: 0,
|
||||
});
|
||||
},
|
||||
updateMessages: (
|
||||
user: string,
|
||||
ids: readonly string[],
|
||||
update: MessageUpdate,
|
||||
): Promise<MessagesPatchResult> => {
|
||||
bulkUpdates.push({ user, ids, update });
|
||||
if (update.mailboxId === MISSING_MAILBOX) {
|
||||
return Promise.reject(
|
||||
new BusinessException('RES_001', {
|
||||
context: { reason: 'e2e-mailbox-not-found' },
|
||||
}),
|
||||
);
|
||||
}
|
||||
return Promise.resolve({
|
||||
updatedIds: ids.filter((id) => id !== GONE_MESSAGE),
|
||||
failedIds: ids.filter((id) => id === GONE_MESSAGE),
|
||||
});
|
||||
},
|
||||
deleteFromTrash: (
|
||||
user: string,
|
||||
ids: readonly string[],
|
||||
): Promise<MessageDeletionResult> => {
|
||||
trashDeletions.push({ user, ids });
|
||||
return Promise.resolve({
|
||||
deletedIds: ids.filter((id) => id !== GONE_MESSAGE),
|
||||
skippedIds: ids.filter((id) => id === GONE_MESSAGE),
|
||||
});
|
||||
},
|
||||
})
|
||||
.overrideProvider(MAIL_SETTINGS)
|
||||
.useValue(TEST_MAIL_SETTINGS)
|
||||
@@ -442,7 +511,7 @@ describe('응답 계약 (e2e)', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('다른 API 는 그대로 1MB — 같은 2MB 본문이 로그인·메일 수정(PATCH /api/messages/:id)에서는 413 FILE_002', async () => {
|
||||
it('다른 API 는 그대로 1MB — 같은 2MB 본문이 로그인·메일 수정(PATCH /api/messages/:id · 여러 통 PATCH)·영구 삭제에서는 413 FILE_002', async () => {
|
||||
const twoMegabytes = JSON.stringify({
|
||||
email: 'x',
|
||||
password: 'y'.repeat(2_000_000),
|
||||
@@ -451,6 +520,8 @@ describe('응답 계약 (e2e)', () => {
|
||||
for (const [method, url] of [
|
||||
['post', '/api/auth/login'],
|
||||
['patch', '/api/messages/eaaaaab'],
|
||||
['patch', '/api/messages'],
|
||||
['post', '/api/message-deletions'],
|
||||
] as const) {
|
||||
const res = await request(server)
|
||||
[method](url)
|
||||
@@ -677,6 +748,10 @@ describe('응답 계약 (e2e)', () => {
|
||||
'/api/mail-signature/images/3f1c0a2e-0f1b-4f2a-9a1e-6c9f0a1b2c3d',
|
||||
],
|
||||
['get', '/api/recipient-suggestions'],
|
||||
['get', '/api/mail-state'],
|
||||
['get', '/api/messages?q=%ED%9A%8C%EC%9D%98'],
|
||||
['patch', '/api/messages'],
|
||||
['post', '/api/message-deletions'],
|
||||
// 템플릿 업로드 예제도 로그인한 직원만 쓴다
|
||||
['post', '/api/files'],
|
||||
] as const)('쿠키 없이 %s %s 는 401 AUTH_001', async (method, path) => {
|
||||
@@ -763,9 +838,6 @@ describe('응답 계약 (e2e)', () => {
|
||||
* 엔진 쪽 판단(초안인지·옛것이 그사이 없어졌는지)은 대역 엔진이 흉내 낸다 — 여기서는 HTTP 형식·정화·DB·파일을 본다.
|
||||
*/
|
||||
describe('2단계 — 임시 저장 · 서명 · 받는 사람 추천', () => {
|
||||
/** 그 직원의 액세스 쿠키 (세션 없이 서명만 맞춘다 — 보호 라우트는 액세스 토큰만 본다) */
|
||||
const cookieOf = (email: string) =>
|
||||
`access_token=${sign({ sub: email, typ: 'access', name: '테스트 직원' }, 600)}`;
|
||||
const UUID =
|
||||
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
|
||||
const DOYUN = { name: '이도윤', email: 'doyun.lee@e2e.arasystem.test' };
|
||||
@@ -1395,6 +1467,208 @@ describe('응답 계약 (e2e)', () => {
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* 3단계 — 새 메일 확인 · 검색 · 여러 통 한꺼번에 (계약 2절).
|
||||
* 엔진 쪽 판단(조사를 붙인 검색 조건 · 휴지통·스팸메일함 빼기 · 조건부 영구 삭제)은 jmap-mail.provider.spec.ts 가 본다 —
|
||||
* 여기서는 라우트 · 검증(VAL_001) · 상태 코드 · 응답 형식 · 헤더를 본다.
|
||||
*/
|
||||
describe('3단계 — 새 메일 확인 · 검색 · 여러 통', () => {
|
||||
const ME = 'bulk.me@e2e.arasystem.test';
|
||||
|
||||
describe('새 메일 확인 (GET /api/mail-state)', () => {
|
||||
it('불투명 상태 값을 표준 래퍼로 — Cache-Control: private, no-store', async () => {
|
||||
const res = await request(server)
|
||||
.get('/api/mail-state')
|
||||
.set('Cookie', cookieOf(ME))
|
||||
.expect(200);
|
||||
|
||||
expect(body(res)).toEqual({
|
||||
success: true,
|
||||
data: { state: MAIL_STATE },
|
||||
});
|
||||
expect(res.headers['cache-control']).toBe('private, no-store');
|
||||
});
|
||||
});
|
||||
|
||||
describe('검색 (GET /api/messages 의 q)', () => {
|
||||
const list = (params: Record<string, string | number>) =>
|
||||
request(server)
|
||||
.get('/api/messages')
|
||||
.query(params)
|
||||
.set('Cookie', cookieOf(ME));
|
||||
|
||||
it('q 만 있으면 메일함 없이(모든 메일함 검색) 앞뒤 공백을 뺀 검색어로 묻는다 — 목록 응답 형식 그대로', async () => {
|
||||
const before = listQueries.length;
|
||||
|
||||
const res = await list({ q: ' 회의 일정 ', limit: 20 }).expect(200);
|
||||
|
||||
expect(dataOf<unknown>(res)).toEqual({
|
||||
items: [],
|
||||
pageInfo: { hasNext: false, nextCursor: null, total: 0 },
|
||||
});
|
||||
expect(listQueries.slice(before)).toEqual([
|
||||
{
|
||||
user: ME,
|
||||
query: {
|
||||
mailboxId: undefined,
|
||||
limit: 20,
|
||||
cursor: undefined,
|
||||
text: '회의 일정',
|
||||
},
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('메일함과 검색어를 함께 주면 그 메일함에서 찾는다 · 메일함만 주면 지금처럼 목록', async () => {
|
||||
const before = listQueries.length;
|
||||
|
||||
await list({ mailboxId: 'a', q: '"회의를 잡겠습니다"' }).expect(200);
|
||||
await list({ mailboxId: 'a' }).expect(200);
|
||||
|
||||
expect(
|
||||
listQueries
|
||||
.slice(before)
|
||||
.map(({ query }) => [query.mailboxId, query.text]),
|
||||
).toEqual([
|
||||
['a', '"회의를 잡겠습니다"'],
|
||||
['a', undefined],
|
||||
]);
|
||||
});
|
||||
|
||||
it(`메일함도 검색어도 없음 · 공백뿐인 검색어 · ${MAX_SEARCH_LENGTH}자 초과 · 낱말 ${MAX_SEARCH_WORDS}개 초과 · 같은 이름 두 번은 400 VAL_001 — 엔진에 묻지 않는다`, async () => {
|
||||
const before = listQueries.length;
|
||||
const words = (count: number) =>
|
||||
Array.from({ length: count }, (_, index) => `w${index}`).join(' ');
|
||||
|
||||
const rejected: Record<string, string>[] = [
|
||||
{},
|
||||
{ q: ' ' },
|
||||
{ q: '가'.repeat(MAX_SEARCH_LENGTH + 1) },
|
||||
{ q: words(MAX_SEARCH_WORDS + 1) },
|
||||
{ mailboxId: '../a', q: '회의' },
|
||||
];
|
||||
for (const params of rejected) {
|
||||
const res = await list(params).expect(400);
|
||||
expect(body(res).error?.code).toBe('VAL_001');
|
||||
}
|
||||
const twice = await request(server)
|
||||
.get('/api/messages?q=a&q=b')
|
||||
.set('Cookie', cookieOf(ME))
|
||||
.expect(400);
|
||||
expect(body(twice).error?.code).toBe('VAL_001');
|
||||
|
||||
// 한도 안은 묻는다 — 앞뒤 공백은 길이에 넣지 않는다
|
||||
await list({
|
||||
q: ` ${'가'.repeat(MAX_SEARCH_LENGTH)} `,
|
||||
}).expect(200);
|
||||
await list({ q: words(MAX_SEARCH_WORDS) }).expect(200);
|
||||
expect(listQueries.length - before).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('여러 통 바꾸기 (PATCH /api/messages)', () => {
|
||||
const patch = (payload: object) =>
|
||||
request(server)
|
||||
.patch('/api/messages')
|
||||
.set('Cookie', cookieOf(ME))
|
||||
.send(payload);
|
||||
|
||||
it('200 { updatedIds, failedIds } — 바꾸지 못한 메일은 failedIds, 한 통 PATCH 경로와 따로 논다', async () => {
|
||||
const res = await patch({
|
||||
ids: ['eaaaaab', GONE_MESSAGE],
|
||||
isRead: true,
|
||||
}).expect(200);
|
||||
|
||||
expect(body(res)).toEqual({
|
||||
success: true,
|
||||
data: { updatedIds: ['eaaaaab'], failedIds: [GONE_MESSAGE] },
|
||||
});
|
||||
expect(bulkUpdates.at(-1)).toEqual({
|
||||
user: ME,
|
||||
ids: ['eaaaaab', GONE_MESSAGE],
|
||||
update: { isRead: true, isFlagged: undefined, mailboxId: undefined },
|
||||
});
|
||||
});
|
||||
|
||||
it('없는 메일함으로 옮기면 404 RES_001', async () => {
|
||||
const res = await patch({
|
||||
ids: ['eaaaaab'],
|
||||
mailboxId: MISSING_MAILBOX,
|
||||
}).expect(404);
|
||||
|
||||
expect(body(res).error?.code).toBe('RES_001');
|
||||
});
|
||||
|
||||
it(`ids 없음 · 빈 ids · ${MAX_BULK_MESSAGE_IDS}개 초과 · 같은 id 두 번 · 형식이 틀린 id · 바꿀 것 없음 · 모르는 필드는 400 VAL_001 — 엔진에 묻지 않는다`, async () => {
|
||||
const before = bulkUpdates.length;
|
||||
const many = Array.from(
|
||||
{ length: MAX_BULK_MESSAGE_IDS + 1 },
|
||||
(_, index) => `m${index}`,
|
||||
);
|
||||
|
||||
for (const payload of [
|
||||
{ isRead: true },
|
||||
{ ids: [], isRead: true },
|
||||
{ ids: many, isRead: true },
|
||||
{ ids: ['eaaaaab', 'eaaaaab'], isRead: true },
|
||||
{ ids: ['../eaaaaab'], isRead: true },
|
||||
{ ids: ['eaaaaab'] },
|
||||
{ ids: ['eaaaaab'], isRead: null },
|
||||
{ ids: ['eaaaaab'], isRead: true, isDeleted: true },
|
||||
]) {
|
||||
const res = await patch(payload).expect(400);
|
||||
expect(body(res).error?.code).toBe('VAL_001');
|
||||
}
|
||||
expect(bulkUpdates).toHaveLength(before);
|
||||
});
|
||||
});
|
||||
|
||||
describe('휴지통에서 영구 삭제 (POST /api/message-deletions)', () => {
|
||||
const remove = (payload: object) =>
|
||||
request(server)
|
||||
.post('/api/message-deletions')
|
||||
.set('Cookie', cookieOf(ME))
|
||||
.send(payload);
|
||||
|
||||
it('201 이 아니라 200 { deletedIds, skippedIds }', async () => {
|
||||
const res = await remove({ ids: ['eaaaaab', GONE_MESSAGE] }).expect(
|
||||
200,
|
||||
);
|
||||
|
||||
expect(body(res)).toEqual({
|
||||
success: true,
|
||||
data: { deletedIds: ['eaaaaab'], skippedIds: [GONE_MESSAGE] },
|
||||
});
|
||||
expect(trashDeletions.at(-1)).toEqual({
|
||||
user: ME,
|
||||
ids: ['eaaaaab', GONE_MESSAGE],
|
||||
});
|
||||
});
|
||||
|
||||
it(`ids 없음 · 빈 ids · ${MAX_BULK_MESSAGE_IDS}개 초과 · 같은 id 두 번 · 형식이 틀린 id · 모르는 필드는 400 VAL_001 — 지우지 않는다`, async () => {
|
||||
const before = trashDeletions.length;
|
||||
|
||||
for (const payload of [
|
||||
{},
|
||||
{ ids: [] },
|
||||
{
|
||||
ids: Array.from(
|
||||
{ length: MAX_BULK_MESSAGE_IDS + 1 },
|
||||
(_, index) => `m${index}`,
|
||||
),
|
||||
},
|
||||
{ ids: ['eaaaaab', 'eaaaaab'] },
|
||||
{ ids: ['eaaaaab', 'a b'] },
|
||||
{ ids: ['eaaaaab'], permanent: true },
|
||||
]) {
|
||||
const res = await remove(payload).expect(400);
|
||||
expect(body(res).error?.code).toBe('VAL_001');
|
||||
}
|
||||
expect(trashDeletions).toHaveLength(before);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* 출처 검사(CSRF) — 상태를 바꾸는 요청은 Sec-Fetch-Site(same-origin·none) 또는 허용 목록의 Origin 이어야 한다.
|
||||
* 둘 다 없으면 브라우저가 아닌 클라이언트로 보고 통과시킨다 (docs/mail/api-contract.md 머리말).
|
||||
@@ -1419,7 +1693,9 @@ describe('응답 계약 (e2e)', () => {
|
||||
['post', '/api/auth/refresh'],
|
||||
['post', '/api/messages'],
|
||||
['patch', '/api/messages/eaaaaab'],
|
||||
['patch', '/api/messages'],
|
||||
['delete', '/api/messages/eaaaaab'],
|
||||
['post', '/api/message-deletions'],
|
||||
['post', '/api/mail-attachments'],
|
||||
] as const)(
|
||||
'Sec-Fetch-Site 없이 허용 목록에 없는 Origin 이면 %s %s 도 403 AUTH_003 (로그인 검사보다 먼저)',
|
||||
|
||||
Reference in New Issue
Block a user