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:
2026-11-09 18:02:40 +09:00
parent 099faeaafc
commit 1ca210dec4
36 changed files with 5055 additions and 315 deletions
+577 -20
View File
@@ -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';