generated from ttipo/project-base
feat(backend): 메일함 옮기기 · 하위까지 지우기 · 차단할 때 받은편지함 정리 · 예약 메일 지금 보내기
- PATCH /api/mailboxes/{id} 에 parentId — 맨 위(null) · 자기 하위로는 VAL_001 · 새 상위에 같은 이름(대소문자 무시)은 MAIL_009 · 역할 메일함 막기
- DELETE /api/mailboxes/{id}?withChildren=true — 나무를 깊은 것부터 지금 지우기와 같은 차례로(휴지통으로 → destroy)
- POST /api/mail-block-runs — 받은편지함에서 차단한 보낸 사람의 메일을 스팸메일함으로(Sieve 차단 규칙과 같은 판정,
필터 적용과 같은 cursor · 잠금, 도는 동안 스팸메일함 잠금)
- POST /api/scheduled-messages/{id}/release — 예약 제출을 취소하고 원래 envelope 으로 다시 제출(보내기 취소 설정만큼 붙잡음).
결과를 모르는 실패면 다시 내지 않고 503 — 같은 요청이 이어 낸다(두 통이 나가지 않게), 엔진이 분명히 거절했을 때만 임시보관함으로
엔진 사실은 개발 엔진(Stalwart 0.16.27)으로 확인했다: parentId 바꾸기 · 고리 거절, destroy 는 적은 차례대로,
from 검색은 비슷한 주소 · 표시 이름의 주소까지 넓게 잡힘, 취소 뒤 다시 제출하면 받는 사람에게 한 통.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
+577
-20
@@ -84,6 +84,7 @@ import {
|
||||
AccountLoginDisabledException,
|
||||
MAIL_PROVIDER,
|
||||
PasswordExpiredException,
|
||||
ScheduledSendReturnedToDraftsException,
|
||||
} from '@/modules/mail/mail.provider';
|
||||
import { MAIL_SETTINGS, type MailSettings } from '@/modules/mail/mail.settings';
|
||||
import type {
|
||||
@@ -93,10 +94,13 @@ import type {
|
||||
DraftSource,
|
||||
Mailbox,
|
||||
MailboxBulkResult,
|
||||
MailboxChange,
|
||||
MailboxCreate,
|
||||
MailboxDeleteOptions,
|
||||
MailboxDeletion,
|
||||
MailBlockAdded,
|
||||
MailBlockList,
|
||||
MailBlockRunRequest,
|
||||
MailFilterRule,
|
||||
MailFilterRunOutcome,
|
||||
MailFilterRunRequest,
|
||||
@@ -118,7 +122,9 @@ import type {
|
||||
PasswordChangeOutcome,
|
||||
PendingSend,
|
||||
RecipientRejectionReason,
|
||||
ReleasedScheduledSend,
|
||||
SavedDraft,
|
||||
SendHold,
|
||||
SentMessage,
|
||||
} from '@/modules/mail/mail.types';
|
||||
import {
|
||||
@@ -326,6 +332,9 @@ const NEVER_CONFIGURED_VACATION: MailVacation = {
|
||||
/** 9단계 — 대역 메일 엔진이 "이미 보냈다(엔진이 풀었다)"고 답하는 메일 id (409 MAIL_014 경로) */
|
||||
const ALREADY_RELEASED_MESSAGE = 'alreadyreleased';
|
||||
|
||||
/** 15단계 — 대역 메일 엔진의 지금 보내기가 받는 사람을 모두 받지 않아 임시보관함으로 되돌리는 메일 id (400 MAIL_010 경로) */
|
||||
const RELEASE_REJECTED_MESSAGE = 'releaserejected';
|
||||
|
||||
/**
|
||||
* 9단계 — 대역 메일 엔진이 붙잡고 있는(보내기 취소 · 예약을 기다리는) 메일 id — 옮기기 · 스팸 · 지우기는 막힌다 (409 MAIL_015 · failedIds 경로).
|
||||
* 붙잡힘 판정(보낼 시각이 지금 뒤인 pending 제출 · fail closed)은 jmap-mail.provider.spec.ts 가 본다
|
||||
@@ -860,6 +869,12 @@ describe('응답 계약 (e2e)', () => {
|
||||
update: MessageUpdate;
|
||||
}[] = [];
|
||||
|
||||
/** 15단계 — 대역 엔진이 받은 차단 정리 · 지금 보내기, 직원마다 더한 메일함(하위 메일함이 있는 나무) · 지금 보내기를 붙잡는 문 */
|
||||
const blockRuns: { user: string; request: MailBlockRunRequest }[] = [];
|
||||
const releases: { user: string; id: string; hold: SendHold | null }[] = [];
|
||||
const extraMailboxes = new Map<string, Mailbox[]>();
|
||||
let slowReleaseGate: Promise<void> | null = null;
|
||||
|
||||
/** 9단계 — 대역 엔진의 차단 목록(직원마다 — 최근 것부터) · 예약한 메일 · 취소한 발송 · 바깥 이미지 요청 */
|
||||
const mailBlocks = new Map<string, BlockedSender[]>();
|
||||
const scheduledMessages = new Map<string, MessageSummary[]>();
|
||||
@@ -1588,30 +1603,57 @@ describe('응답 계약 (e2e)', () => {
|
||||
unreadCount: 0,
|
||||
});
|
||||
},
|
||||
renameMailbox: (
|
||||
// 15단계 — 이름 · 상위 바꾸기. 상위의 자리 · 고리 판정은 jmap-mail.provider.spec.ts 가 본다 (여기서는 엔진의 답만 흉내 낸다)
|
||||
updateMailbox: (
|
||||
user: string,
|
||||
id: string,
|
||||
name: string,
|
||||
change: MailboxChange,
|
||||
): Promise<Mailbox> => {
|
||||
mailboxCalls.push({ user, action: 'rename', args: [id, name] });
|
||||
mailboxCalls.push({ user, action: 'update', args: [id, change] });
|
||||
if (change.parentId === MISSING_MAILBOX) {
|
||||
return Promise.reject(
|
||||
new BusinessException('RES_001', {
|
||||
context: { reason: 'e2e-parent-not-found' },
|
||||
}),
|
||||
);
|
||||
}
|
||||
if (change.parentId === id) {
|
||||
return Promise.reject(
|
||||
new BusinessException('VAL_001', {
|
||||
context: { reason: 'e2e-mailbox-move-into-itself' },
|
||||
}),
|
||||
);
|
||||
}
|
||||
if (change.name === TAKEN_MAILBOX_NAME) {
|
||||
return Promise.reject(
|
||||
new BusinessException('MAIL_009', {
|
||||
context: { reason: 'e2e-mailbox-name-taken' },
|
||||
}),
|
||||
);
|
||||
}
|
||||
return Promise.resolve({
|
||||
id,
|
||||
name,
|
||||
name: change.name ?? id,
|
||||
role: null,
|
||||
parentId: null,
|
||||
parentId: change.parentId ?? null,
|
||||
sortOrder: 0,
|
||||
totalCount: 3,
|
||||
unreadCount: 1,
|
||||
});
|
||||
},
|
||||
deleteMailbox: (user: string, id: string): Promise<MailboxDeletion> => {
|
||||
mailboxCalls.push({ user, action: 'delete', args: [id] });
|
||||
return Promise.resolve({
|
||||
deleteMailbox: async (
|
||||
user: string,
|
||||
id: string,
|
||||
options?: MailboxDeleteOptions,
|
||||
): Promise<MailboxDeletion> => {
|
||||
mailboxCalls.push({ user, action: 'delete', args: [id, options] });
|
||||
if (id === SLOW_MAILBOX && slowMailboxGate) await slowMailboxGate;
|
||||
return {
|
||||
deleted: true,
|
||||
processed: 4,
|
||||
movedToTrash: 3,
|
||||
remaining: 0,
|
||||
});
|
||||
};
|
||||
},
|
||||
markMailboxRead: (
|
||||
user: string,
|
||||
@@ -1646,7 +1688,11 @@ describe('응답 계약 (e2e)', () => {
|
||||
inactiveScripts.delete(user);
|
||||
return Promise.resolve({ ...vacation, isActive: true });
|
||||
},
|
||||
listMailboxes: (): Promise<Mailbox[]> => Promise.resolve(E2E_MAILBOXES),
|
||||
listMailboxes: (user: string): Promise<Mailbox[]> =>
|
||||
Promise.resolve([
|
||||
...E2E_MAILBOXES,
|
||||
...(extraMailboxes.get(user) ?? []),
|
||||
]),
|
||||
// 6단계 — 엔진 쪽 판단(지문 비교 · 한도)만 흉내 낸다. 만드는 Sieve · 조건부 쓰기는 jmap/*.spec.ts 가 본다
|
||||
getMailFilters: (user: string): Promise<MailFilterSet> => {
|
||||
const rules = mailScripts.get(user)?.rules ?? [];
|
||||
@@ -1724,6 +1770,34 @@ describe('응답 계약 (e2e)', () => {
|
||||
rounds: 1,
|
||||
};
|
||||
},
|
||||
// 15단계 — 차단 정리. 판정 · 차례 · 이어 할 자리는 jmap-filter-run.spec.ts 가 본다. 필터 적용과 같은 문으로 붙잡는다(같은 잠금 확인)
|
||||
runMailBlock: async (
|
||||
user: string,
|
||||
request: MailBlockRunRequest,
|
||||
): Promise<MailFilterRunOutcome> => {
|
||||
blockRuns.push({ user, request });
|
||||
if (request.cursor === OTHER_RULE_CURSOR) {
|
||||
throw new BusinessException('VAL_001', {
|
||||
context: { reason: 'e2e-mail-block-run-other-sender' },
|
||||
});
|
||||
}
|
||||
if (slowFilterRunGate) await slowFilterRunGate;
|
||||
return request.cursor === null
|
||||
? {
|
||||
scanned: 500,
|
||||
matched: 7,
|
||||
remaining: 40,
|
||||
cursor: 'e2eblockcursor',
|
||||
rounds: 1,
|
||||
}
|
||||
: {
|
||||
scanned: 40,
|
||||
matched: 1,
|
||||
remaining: 0,
|
||||
cursor: null,
|
||||
rounds: 1,
|
||||
};
|
||||
},
|
||||
openMessageSource: (
|
||||
_user: string,
|
||||
id: string,
|
||||
@@ -1905,6 +1979,46 @@ describe('응답 계약 (e2e)', () => {
|
||||
canceledSends.push({ user, id });
|
||||
return Promise.resolve();
|
||||
},
|
||||
// 15단계 — 지금 보내기 (취소 → 다시 제출 · 상태 가르기 · 되돌리기는 jmap-mail.provider.spec.ts 가 본다)
|
||||
releaseScheduledSend: async (
|
||||
user: string,
|
||||
id: string,
|
||||
hold: SendHold | null,
|
||||
): Promise<ReleasedScheduledSend> => {
|
||||
releases.push({ user, id, hold });
|
||||
if (slowReleaseGate) await slowReleaseGate;
|
||||
if (id === GONE_MESSAGE) {
|
||||
throw new BusinessException('RES_001', {
|
||||
context: { reason: 'e2e-not-a-scheduled-send' },
|
||||
});
|
||||
}
|
||||
if (id === RELEASE_REJECTED_MESSAGE) {
|
||||
throw new ScheduledSendReturnedToDraftsException('MAIL_010', {
|
||||
context: { reason: 'e2e-release-all-recipients-rejected' },
|
||||
});
|
||||
}
|
||||
if (id === ALREADY_RELEASED_MESSAGE) {
|
||||
return {
|
||||
id,
|
||||
rejectedRecipients: [],
|
||||
pendingSend: null,
|
||||
releasedAt: null,
|
||||
};
|
||||
}
|
||||
const now = Date.now();
|
||||
return {
|
||||
id,
|
||||
rejectedRecipients: [],
|
||||
pendingSend:
|
||||
hold?.kind === 'undo'
|
||||
? {
|
||||
kind: 'undo',
|
||||
sendAt: new Date(now + hold.seconds * 1_000).toISOString(),
|
||||
}
|
||||
: null,
|
||||
releasedAt: new Date(now).toISOString(),
|
||||
};
|
||||
},
|
||||
})
|
||||
.overrideProvider(MAIL_SETTINGS)
|
||||
.useValue(TEST_MAIL_SETTINGS)
|
||||
@@ -2449,6 +2563,10 @@ describe('응답 계약 (e2e)', () => {
|
||||
['get', '/api/mail-filters'],
|
||||
['put', '/api/mail-filters'],
|
||||
['post', '/api/mail-filter-runs'],
|
||||
// 15단계 — 차단 정리 · 지금 보내기 (옮기기 · 하위까지 지우기는 위의 PATCH · DELETE 와 같은 경로)
|
||||
['post', '/api/mail-block-runs'],
|
||||
['post', '/api/scheduled-messages/eaaaaab/release'],
|
||||
['delete', '/api/mailboxes/u1?withChildren=true'],
|
||||
['get', '/api/messages/eaaaaab/source'],
|
||||
// 템플릿 업로드 예제도 로그인한 직원만 쓴다
|
||||
['post', '/api/files'],
|
||||
@@ -3638,27 +3756,21 @@ describe('응답 계약 (e2e)', () => {
|
||||
expect(body(orphan).error?.code).toBe('RES_001');
|
||||
});
|
||||
|
||||
it('이름 바꾸기는 200 Mailbox — 형식이 틀린 id 는 404 RES_001, 상위는 바꾸지 않는다(모르는 필드)', async () => {
|
||||
it('이름 바꾸기는 200 Mailbox — 형식이 틀린 id 는 404 RES_001, 이름만 보내면 상위는 그대로 (15단계부터 상위도 받는다 — 아래 "15단계")', async () => {
|
||||
const res = await send('patch', '/api/mailboxes/u1', {
|
||||
name: ' 새 이름 ',
|
||||
}).expect(200);
|
||||
expect(dataOf<{ name: string }>(res).name).toBe('새 이름');
|
||||
expect(mailboxCalls.at(-1)).toEqual({
|
||||
user: ME,
|
||||
action: 'rename',
|
||||
args: ['u1', '새 이름'],
|
||||
action: 'update',
|
||||
args: ['u1', { name: '새 이름' }],
|
||||
});
|
||||
|
||||
const malformed = await send('patch', '/api/mailboxes/a%20b', {
|
||||
name: '새 이름',
|
||||
}).expect(404);
|
||||
expect(body(malformed).error?.code).toBe('RES_001');
|
||||
|
||||
const move = await send('patch', '/api/mailboxes/u1', {
|
||||
name: '새 이름',
|
||||
parentId: 'a',
|
||||
}).expect(400);
|
||||
expect(body(move).error?.code).toBe('VAL_001');
|
||||
});
|
||||
|
||||
it('지우기는 204 가 아니라 200 { deleted, processed, movedToTrash, remaining }', async () => {
|
||||
@@ -3671,7 +3783,7 @@ describe('응답 계약 (e2e)', () => {
|
||||
expect(mailboxCalls.at(-1)).toEqual({
|
||||
user: ME,
|
||||
action: 'delete',
|
||||
args: ['u1'],
|
||||
args: ['u1', { startedAt: expect.any(Number) as number }],
|
||||
});
|
||||
});
|
||||
|
||||
@@ -6201,6 +6313,31 @@ describe('응답 계약 (e2e)', () => {
|
||||
return { sentId, message, token, link };
|
||||
};
|
||||
|
||||
it('예약 메일을 지금 보내면(15단계) 그 링크의 보낸 때를 지금으로 앞당기고 끝나는 날은 그대로 둔다 — 덩이가 적은 날짜 (실제 DB 의 조건부 UPDATE)', async () => {
|
||||
const ME = 'lf.release@e2e.arasystem.test';
|
||||
const file = await uploadReady(ME, '지금 보내기 시험', '도면.zip');
|
||||
const sendAt = new Date(
|
||||
Math.floor((Date.now() + 3 * 24 * 60 * 60_000) / 1_000) * 1_000,
|
||||
).toISOString();
|
||||
const { sentId, link } = await sendAndShare(ME, [file.id], { sendAt });
|
||||
expect(link?.sentAt).toBe(sendAt);
|
||||
const expiresAt = link?.expiresAt;
|
||||
|
||||
const before = Date.now();
|
||||
await client(
|
||||
request(server).post(`/api/scheduled-messages/${sentId}/release`),
|
||||
ME,
|
||||
).expect(200);
|
||||
const after = (await shareLinksOf(ME)).items.find(
|
||||
(item) => item.sentEmailId === sentId,
|
||||
);
|
||||
expect(Date.parse(after?.sentAt ?? '')).toBeGreaterThanOrEqual(
|
||||
Math.floor(before / 1_000) * 1_000,
|
||||
);
|
||||
expect(Date.parse(after?.sentAt ?? '')).toBeLessThan(Date.parse(sendAt));
|
||||
expect(after?.expiresAt).toBe(expiresAt);
|
||||
});
|
||||
|
||||
it('로그인하지 않았으면 401 AUTH_001 — 공개 경로 밖은 모두', async () => {
|
||||
const id = randomUUID();
|
||||
for (const res of [
|
||||
@@ -11119,6 +11256,426 @@ describe('응답 계약 (e2e)', () => {
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* 15단계 — 메일함 옮기기 · 하위 메일함까지 지우기 · 차단할 때 받은편지함 정리 · 예약 메일 지금 보내기 (계약 2절).
|
||||
* 엔진 쪽 판단(상위의 자리 · 고리 · 깊은 것부터 지우기 · Sieve 와 같은 판정 · 취소 → 다시 제출)은 jmap-*.spec.ts 가 본다 — 여기서는 HTTP 형식 · 검사 · 잠금 · 응답 꼴을 본다
|
||||
*/
|
||||
describe('15단계 — 메일함 옮기기 · 하위까지 지우기 · 차단 정리 · 지금 보내기', () => {
|
||||
/** 보내기 · 읽기 설정 — 지금 보내기의 보내기 취소 초 (9단계 "보내기 · 읽기 설정") */
|
||||
const patchSettings = (email: string, payload: object) =>
|
||||
request(server)
|
||||
.patch('/api/mail-settings')
|
||||
.set('Cookie', cookieOf(email))
|
||||
.send(payload);
|
||||
|
||||
describe('메일함 옮기기 (PATCH /api/mailboxes/{id} 의 parentId)', () => {
|
||||
const ME = 'move.me@e2e.arasystem.test';
|
||||
const patch = (id: string, payload: object) =>
|
||||
request(server)
|
||||
.patch(`/api/mailboxes/${id}`)
|
||||
.set('Cookie', cookieOf(ME))
|
||||
.send(payload);
|
||||
|
||||
it('parentId 로 옮긴다 — null 은 맨 위, 이름과 함께도 된다 · 보낸 칸만 엔진에 넘긴다', async () => {
|
||||
const res = await patch('u1', { parentId: 'a' }).expect(200);
|
||||
expect(body(res)).toEqual({
|
||||
success: true,
|
||||
data: {
|
||||
id: 'u1',
|
||||
name: 'u1',
|
||||
role: null,
|
||||
parentId: 'a',
|
||||
sortOrder: 0,
|
||||
totalCount: 3,
|
||||
unreadCount: 1,
|
||||
},
|
||||
});
|
||||
expect(mailboxCalls.at(-1)).toEqual({
|
||||
user: ME,
|
||||
action: 'update',
|
||||
args: ['u1', { parentId: 'a' }],
|
||||
});
|
||||
|
||||
await patch('u1', { parentId: null }).expect(200);
|
||||
expect(mailboxCalls.at(-1)?.args).toEqual(['u1', { parentId: null }]);
|
||||
|
||||
await patch('u1', { name: ' 2025 ', parentId: 'j' }).expect(200);
|
||||
expect(mailboxCalls.at(-1)?.args).toEqual([
|
||||
'u1',
|
||||
{ name: '2025', parentId: 'j' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('빈 본문 · 상위 id 형식 · null 이름 · 모르는 필드는 400 VAL_001 — 엔진에 묻지 않는다', async () => {
|
||||
const before = mailboxCalls.length;
|
||||
for (const payload of [
|
||||
{},
|
||||
{ parentId: '../a' },
|
||||
{ parentId: 7 },
|
||||
{ name: null },
|
||||
{ parentId: 'a', role: 'inbox' },
|
||||
]) {
|
||||
const res = await patch('u1', payload).expect(400);
|
||||
expect(body(res).error?.code).toBe('VAL_001');
|
||||
}
|
||||
expect(mailboxCalls).toHaveLength(before);
|
||||
});
|
||||
|
||||
it('엔진의 답 그대로 — 없는 상위 404 RES_001 · 자기 아래 400 VAL_001 · 같은 이름 409 MAIL_009', async () => {
|
||||
const orphan = await patch('u1', { parentId: MISSING_MAILBOX }).expect(
|
||||
404,
|
||||
);
|
||||
expect(body(orphan).error?.code).toBe('RES_001');
|
||||
const self = await patch('u1', { parentId: 'u1' }).expect(400);
|
||||
expect(body(self).error?.code).toBe('VAL_001');
|
||||
const taken = await patch('u1', {
|
||||
name: TAKEN_MAILBOX_NAME,
|
||||
parentId: 'a',
|
||||
}).expect(409);
|
||||
expect(body(taken).error?.code).toBe('MAIL_009');
|
||||
});
|
||||
});
|
||||
|
||||
describe('하위 메일함까지 지우기 (DELETE /api/mailboxes/{id}?withChildren=true)', () => {
|
||||
const ME = 'tree.me@e2e.arasystem.test';
|
||||
const CHILD = 'slowchild';
|
||||
const remove = (id: string, query = '') =>
|
||||
request(server)
|
||||
.delete(`/api/mailboxes/${id}${query}`)
|
||||
.set('Cookie', cookieOf(ME));
|
||||
|
||||
beforeAll(() => {
|
||||
// 대역 엔진의 나무 — SLOW_MAILBOX › CHILD
|
||||
extraMailboxes.set(ME, [
|
||||
{
|
||||
id: SLOW_MAILBOX,
|
||||
name: '거래처',
|
||||
role: null,
|
||||
parentId: null,
|
||||
sortOrder: 0,
|
||||
totalCount: 0,
|
||||
unreadCount: 0,
|
||||
},
|
||||
{
|
||||
id: CHILD,
|
||||
name: '2025',
|
||||
role: null,
|
||||
parentId: SLOW_MAILBOX,
|
||||
sortOrder: 0,
|
||||
totalCount: 0,
|
||||
unreadCount: 0,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('withChildren=true 면 하위까지 지우기를 넘기고 200 MailboxDeletion — 다른 값 · 모르는 쿼리는 400 VAL_001 (엔진에 묻지 않는다)', async () => {
|
||||
const res = await remove('u1', '?withChildren=true').expect(200);
|
||||
expect(body(res)).toEqual({
|
||||
success: true,
|
||||
data: { deleted: true, processed: 4, movedToTrash: 3, remaining: 0 },
|
||||
});
|
||||
expect(mailboxCalls.at(-1)).toEqual({
|
||||
user: ME,
|
||||
action: 'delete',
|
||||
args: [
|
||||
'u1',
|
||||
{ withChildren: true, startedAt: expect.any(Number) as number },
|
||||
],
|
||||
});
|
||||
|
||||
const before = mailboxCalls.length;
|
||||
for (const query of [
|
||||
'?withChildren=false',
|
||||
'?withChildren=TRUE',
|
||||
'?withChildren=1',
|
||||
'?recursive=true',
|
||||
]) {
|
||||
const rejected = await remove('u1', query).expect(400);
|
||||
expect(body(rejected).error?.code).toBe('VAL_001');
|
||||
}
|
||||
expect(mailboxCalls).toHaveLength(before);
|
||||
});
|
||||
|
||||
it(
|
||||
'나무를 지우는 동안 나무의 메일함 모두가 처리 중이다 — 하위의 전체 읽음 · 나무 안으로 옮기기는 429 REQ_001, 나무 밖은 된다',
|
||||
async () => {
|
||||
const httpServer = app.getHttpServer();
|
||||
const startedServer = !httpServer.listening;
|
||||
if (startedServer) await listenOnAnyPort(httpServer);
|
||||
|
||||
const gate = createGate();
|
||||
slowMailboxGate = gate.opened;
|
||||
const before = mailboxCalls.length;
|
||||
const first = remove(SLOW_MAILBOX, '?withChildren=true').then(
|
||||
(res) => res,
|
||||
);
|
||||
|
||||
try {
|
||||
await waitUntil(
|
||||
() => mailboxCalls.length > before,
|
||||
GATE_DEADLINE_MS,
|
||||
);
|
||||
for (const [method, path, payload] of [
|
||||
['patch', `/api/mailboxes/${CHILD}/messages`, { isRead: true }],
|
||||
['delete', `/api/mailboxes/${CHILD}`, undefined],
|
||||
['patch', '/api/mailboxes/j', { parentId: CHILD }],
|
||||
] as const) {
|
||||
const req = request(server)
|
||||
[method](path)
|
||||
.set('Cookie', cookieOf(ME));
|
||||
const busy = await (
|
||||
payload === undefined ? req : req.send(payload)
|
||||
).expect(429);
|
||||
expect(body(busy).error?.code).toBe('REQ_001');
|
||||
}
|
||||
expect(mailboxCalls).toHaveLength(before + 1);
|
||||
|
||||
// 나무 밖은 된다 · 이름만 바꾸기는 잠그지 않는다(지금처럼)
|
||||
await request(server)
|
||||
.patch('/api/mailboxes/a/messages')
|
||||
.set('Cookie', cookieOf(ME))
|
||||
.send({ isRead: true })
|
||||
.expect(200);
|
||||
await request(server)
|
||||
.patch(`/api/mailboxes/${CHILD}`)
|
||||
.set('Cookie', cookieOf(ME))
|
||||
.send({ name: '새 이름' })
|
||||
.expect(200);
|
||||
} finally {
|
||||
gate.open();
|
||||
slowMailboxGate = null;
|
||||
await Promise.allSettled([first]);
|
||||
if (startedServer) await closeServer(httpServer);
|
||||
}
|
||||
expect((await first).status).toBe(200);
|
||||
},
|
||||
GATED_TEST_TIMEOUT_MS,
|
||||
);
|
||||
});
|
||||
|
||||
describe('차단할 때 받은편지함 정리 (POST /api/mail-block-runs)', () => {
|
||||
const ME = 'blockrun.me@e2e.arasystem.test';
|
||||
const SENDER = 'kim@spam.e2e.example';
|
||||
const run = (payload: object) =>
|
||||
request(server)
|
||||
.post('/api/mail-block-runs')
|
||||
.set('Cookie', cookieOf(ME))
|
||||
.send(payload);
|
||||
|
||||
beforeAll(() => {
|
||||
mailBlocks.set(ME, [
|
||||
{ address: SENDER, blockedAt: '2026-11-09T01:00:00.000Z' },
|
||||
{
|
||||
address: '@spam.e2e.example',
|
||||
blockedAt: '2026-11-09T01:00:00.000Z',
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('200 { scanned, matched, remaining, cursor } — cursor 로 이어 하고 null 이면 끝 (차례 수는 응답에 없다)', async () => {
|
||||
const first = await run({ sender: SENDER, cursor: null }).expect(200);
|
||||
expect(body(first)).toEqual({
|
||||
success: true,
|
||||
data: {
|
||||
scanned: 500,
|
||||
matched: 7,
|
||||
remaining: 40,
|
||||
cursor: 'e2eblockcursor',
|
||||
},
|
||||
});
|
||||
expect(blockRuns.at(-1)).toEqual({
|
||||
user: ME,
|
||||
request: { sender: SENDER, cursor: null },
|
||||
});
|
||||
|
||||
const second = await run({
|
||||
sender: '@SPAM.e2e.example',
|
||||
cursor: 'e2eblockcursor',
|
||||
}).expect(200);
|
||||
expect(dataOf<{ cursor: string | null }>(second).cursor).toBeNull();
|
||||
// 차단 목록의 모양(소문자)으로 넘긴다
|
||||
expect(blockRuns.at(-1)?.request.sender).toBe('@spam.e2e.example');
|
||||
});
|
||||
|
||||
it('차단 목록에 없으면 404 RES_001 · 형식 · cursor 키 없음 · 모르는 필드는 400 VAL_001 · 어댑터의 다른 보낸 사람 cursor 도 400 — 받은편지함을 읽지 않는다', async () => {
|
||||
const before = blockRuns.length;
|
||||
const missing = await run({
|
||||
sender: 'lee@spam.e2e.example',
|
||||
cursor: null,
|
||||
}).expect(404);
|
||||
expect(body(missing).error?.code).toBe('RES_001');
|
||||
|
||||
for (const payload of [
|
||||
{ sender: SENDER },
|
||||
{ sender: 'not an address', cursor: null },
|
||||
{ sender: SENDER, cursor: 7 },
|
||||
{ sender: SENDER, cursor: 'a b' },
|
||||
{ sender: SENDER, cursor: null, mailboxId: 'a' },
|
||||
]) {
|
||||
const rejected = await run(payload).expect(400);
|
||||
expect(body(rejected).error?.code).toBe('VAL_001');
|
||||
}
|
||||
expect(blockRuns).toHaveLength(before);
|
||||
|
||||
const other = await run({
|
||||
sender: SENDER,
|
||||
cursor: OTHER_RULE_CURSOR,
|
||||
}).expect(400);
|
||||
expect(body(other).error?.code).toBe('VAL_001');
|
||||
});
|
||||
|
||||
it(
|
||||
'같은 직원의 받은편지함 정리(필터 적용 · 차단 정리)는 한 번에 하나 — 진행 중이면 서로 429 REQ_001',
|
||||
async () => {
|
||||
const httpServer = app.getHttpServer();
|
||||
const startedServer = !httpServer.listening;
|
||||
if (startedServer) await listenOnAnyPort(httpServer);
|
||||
|
||||
const gate = createGate();
|
||||
slowFilterRunGate = gate.opened;
|
||||
const before = blockRuns.length;
|
||||
const first = run({ sender: SENDER, cursor: null }).then(
|
||||
(res) => res,
|
||||
);
|
||||
try {
|
||||
await waitUntil(() => blockRuns.length > before, GATE_DEADLINE_MS);
|
||||
const again = await run({ sender: SENDER, cursor: null }).expect(
|
||||
429,
|
||||
);
|
||||
expect(body(again).error?.code).toBe('REQ_001');
|
||||
const filter = await request(server)
|
||||
.post('/api/mail-filter-runs')
|
||||
.set('Cookie', cookieOf(ME))
|
||||
.send({
|
||||
match: 'always',
|
||||
conditions: [],
|
||||
actions: {
|
||||
moveToMailboxId: null,
|
||||
markAsRead: true,
|
||||
flag: false,
|
||||
},
|
||||
cursor: null,
|
||||
})
|
||||
.expect(429);
|
||||
expect(body(filter).error?.code).toBe('REQ_001');
|
||||
// 차단 정리가 도는 동안 그 직원의 스팸메일함(c)도 잡혀 있다 — 비우기는 429 (엔진에 묻지 않는다)
|
||||
const emptyCalls = mailboxCalls.length;
|
||||
const empty = await request(server)
|
||||
.delete('/api/mailboxes/c/messages')
|
||||
.set('Cookie', cookieOf(ME))
|
||||
.expect(429);
|
||||
expect(body(empty).error?.code).toBe('REQ_001');
|
||||
expect(mailboxCalls).toHaveLength(emptyCalls);
|
||||
} finally {
|
||||
gate.open();
|
||||
slowFilterRunGate = null;
|
||||
await Promise.allSettled([first]);
|
||||
if (startedServer) await closeServer(httpServer);
|
||||
}
|
||||
expect((await first).status).toBe(200);
|
||||
},
|
||||
GATED_TEST_TIMEOUT_MS,
|
||||
);
|
||||
});
|
||||
|
||||
describe('예약 메일 지금 보내기 (POST /api/scheduled-messages/{id}/release)', () => {
|
||||
const ME = 'release.me@e2e.arasystem.test';
|
||||
const release = (id: string) =>
|
||||
request(server)
|
||||
.post(`/api/scheduled-messages/${id}/release`)
|
||||
.set('Cookie', cookieOf(ME));
|
||||
|
||||
it('200 — 발송 응답과 같은 칸 { id, rejectedRecipients, pendingSend }, 보내기 취소 설정 초만큼 붙잡게 넘긴다(0초면 null)', async () => {
|
||||
const res = await release('escheduled1').expect(200);
|
||||
expect(body(res)).toEqual({
|
||||
success: true,
|
||||
data: {
|
||||
id: 'escheduled1',
|
||||
rejectedRecipients: [],
|
||||
pendingSend: {
|
||||
kind: 'undo',
|
||||
sendAt: expect.any(String) as string,
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(releases.at(-1)).toEqual({
|
||||
user: ME,
|
||||
id: 'escheduled1',
|
||||
hold: { kind: 'undo', seconds: 10 },
|
||||
});
|
||||
|
||||
await patchSettings(ME, { undoSendSeconds: 0 }).expect(200);
|
||||
const now = await release('escheduled2').expect(200);
|
||||
expect(
|
||||
dataOf<{ pendingSend: PendingSend | null }>(now).pendingSend,
|
||||
).toBeNull();
|
||||
expect(releases.at(-1)?.hold).toBeNull();
|
||||
await patchSettings(ME, { undoSendSeconds: 10 }).expect(200);
|
||||
});
|
||||
|
||||
it('이미 보낸 메일은 오류가 아니다 — 200 pendingSend: null', async () => {
|
||||
const res = await release(ALREADY_RELEASED_MESSAGE).expect(200);
|
||||
expect(body(res)).toEqual({
|
||||
success: true,
|
||||
data: {
|
||||
id: ALREADY_RELEASED_MESSAGE,
|
||||
rejectedRecipients: [],
|
||||
pendingSend: null,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('예약이 아닌 메일 · 형식이 틀린 id 는 404 RES_001 · 받는 사람이 모두 받지 않으면 400 MAIL_010 (임시보관함으로 되돌렸다)', async () => {
|
||||
const gone = await release(GONE_MESSAGE).expect(404);
|
||||
expect(body(gone).error?.code).toBe('RES_001');
|
||||
|
||||
const before = releases.length;
|
||||
const malformed = await release('a"b').expect(404);
|
||||
expect(body(malformed).error?.code).toBe('RES_001');
|
||||
expect(releases).toHaveLength(before);
|
||||
|
||||
const rejected = await release(RELEASE_REJECTED_MESSAGE).expect(400);
|
||||
expect(body(rejected).error?.code).toBe('MAIL_010');
|
||||
});
|
||||
|
||||
it(
|
||||
'같은 메일의 지금 보내기 · 예약 취소는 한 번에 하나 — 진행 중이면 429 REQ_001, 다른 메일은 된다',
|
||||
async () => {
|
||||
const httpServer = app.getHttpServer();
|
||||
const startedServer = !httpServer.listening;
|
||||
if (startedServer) await listenOnAnyPort(httpServer);
|
||||
|
||||
const gate = createGate();
|
||||
slowReleaseGate = gate.opened;
|
||||
const before = releases.length;
|
||||
const first = release('eslowrelease').then((res) => res);
|
||||
try {
|
||||
await waitUntil(() => releases.length > before, GATE_DEADLINE_MS);
|
||||
const again = await release('eslowrelease').expect(429);
|
||||
expect(body(again).error?.code).toBe('REQ_001');
|
||||
const cancel = await request(server)
|
||||
.delete('/api/scheduled-messages/eslowrelease')
|
||||
.set('Cookie', cookieOf(ME))
|
||||
.expect(429);
|
||||
expect(body(cancel).error?.code).toBe('REQ_001');
|
||||
// 다른 메일의 취소는 된다
|
||||
await request(server)
|
||||
.delete('/api/scheduled-messages/eotherheld')
|
||||
.set('Cookie', cookieOf(ME))
|
||||
.expect(200);
|
||||
} finally {
|
||||
gate.open();
|
||||
slowReleaseGate = null;
|
||||
await Promise.allSettled([first]);
|
||||
if (startedServer) await closeServer(httpServer);
|
||||
}
|
||||
expect((await first).status).toBe(200);
|
||||
},
|
||||
GATED_TEST_TIMEOUT_MS,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('출처 검사 (상태 변경 요청)', () => {
|
||||
const EVIL_ORIGIN = 'https://evil.example';
|
||||
|
||||
|
||||
Reference in New Issue
Block a user