feat(backend): 보내기 취소 · 예약 발송 · 외부 이미지 중계 · 보낸 사람 차단 · 보내기 · 읽기 설정 API

계약 2절 9단계. 보내기 취소 · 예약은 엔진 제출의 HOLDFOR · HOLDUNTIL 로 붙잡고(7일 안), 취소는 undoStatus: canceled 뒤 그 메일을 임시보관함의 초안으로 되돌린다.
외부 이미지는 서버가 대신 받아 온다 — 모든 주소가 공인일 때만 푼 주소로 고정 연결 · 80/443 · 다시 가기 3번 · 10초 · 5MB · 앞 바이트로 그림 형식, 중계 주소의 표는 AES-GCM 으로 암호화.
차단은 필터 · 부재중과 같은 Sieve 스크립트의 맨 앞, 설정은 DB 표 mail_settings(마이그레이션 AddMailSettings).

리뷰로 고친 것:
- 붙잡힌 메일을 옮기거나 지워도 엔진은 시간이 되면 보낸다 — 한 통 · 여러 통 PATCH · DELETE · 영구 삭제 · 메일함 지우기 · 비우기 · 필터 적용이 엔진에 먼저 묻고 막는다(MAIL_015 · failedIds · skippedIds).
  읽지 못하거나 다 받았는지 응답의 total 로 확인하지 못하면 바꾸지 않는다(MAIL_001), 보낼 시각을 읽지 못한 기다리는 제출도 붙잡힌 것으로 본다.
- 발송은 늘 envelope 을 직접 준다 — 엔진이 오타 도메인 같은 주소를 말없이 빼고 보냈다. 거절한 주소를 not-found 로 빼고 다시 제출한다
  (한 차례에 하나 — 거절 글의 따옴표 토큰과 정확히 같은 주소부터, 20차례까지), 같은 키 재시도는 제출의 envelope 으로 같은 답을 다시 만든다.
- 이미지 중계: 서버 16 · 직원 6 동시 수(받는 수가 적은 직원부터), 자리는 응답이 끝날 때까지, 버퍼는 한 번에 — 메모리가 동시 수 × 5MB 를 넘지 않게.
- 다시 취소해도 200(취소 표시가 있으면 옮기지 않는다), 엔진이 확인한 취소만 취소로 본다, 모두 거절된 붙잡은 발송은 취소한 뒤 사본을 지운다.
- 차단 주소는 dot-atom 만, 이미 있는 주소를 POST 하면 꺼진 규칙을 켠다, 로그의 중계 주소 가리기는 대소문자 무시.
- 연결 · 이름 풀기를 실제 소켓으로 시험한다(테스트 전용 포트 · DNS 서버 — 앱은 80/443 고정).

에러 코드 MAIL_014(이미 보냄) · MAIL_015(붙잡힌 메일)를 더하고 MAIL_013 문구에 차단 목록을 넣었다(뜻은 그대로). 클라이언트 사전을 생성했다.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-29 22:07:56 +09:00
parent fa6799d9f7
commit b0412ac453
89 changed files with 12851 additions and 351 deletions
+781 -2
View File
@@ -40,9 +40,17 @@ import {
MAX_VACATION_TEXT_LENGTH,
MAX_VACATION_TEXT_LENGTH_WHEN_OFF,
} from '@/modules/mail/mail.constants';
import {
MAIL_IMAGE_RESOLVER,
MAIL_IMAGE_TRANSPORT,
type MailImageRequest,
type MailImageResponse,
type ResolvedAddress,
} from '@/modules/mail/images/mail-image-fetcher';
import { MAIL_PROVIDER } from '@/modules/mail/mail.provider';
import { MAIL_SETTINGS, type MailSettings } from '@/modules/mail/mail.settings';
import type {
BlockedSender,
Correspondence,
DraftContent,
DraftSource,
@@ -50,6 +58,8 @@ import type {
MailboxBulkResult,
MailboxCreate,
MailboxDeletion,
MailBlockAdded,
MailBlockList,
MailFilterRule,
MailFilterRunOutcome,
MailFilterRunRequest,
@@ -60,6 +70,7 @@ import type {
MailWatch,
MailWatchListener,
MessageDeletionResult,
MessageDetailSource,
MessageListQuery,
MessageSource,
MessagesPatchResult,
@@ -67,6 +78,7 @@ import type {
MessageUpdate,
OutgoingMessage,
PasswordChangeOutcome,
PendingSend,
RecipientRejectionReason,
SavedDraft,
SentMessage,
@@ -224,6 +236,25 @@ const NEVER_CONFIGURED_VACATION: MailVacation = {
textBody: '',
};
/** 9단계 — 대역 메일 엔진이 "이미 보냈다(엔진이 풀었다)"고 답하는 메일 id (409 MAIL_014 경로) */
const ALREADY_RELEASED_MESSAGE = 'alreadyreleased';
/**
* 9단계 — 대역 메일 엔진이 붙잡고 있는(보내기 취소 · 예약을 기다리는) 메일 id — 옮기기 · 스팸 · 지우기는 막힌다 (409 MAIL_015 · failedIds 경로).
* 붙잡힘 판정(보낼 시각이 지금 뒤인 pending 제출 · fail closed)은 jmap-mail.provider.spec.ts 가 본다
*/
const HELD_MESSAGE = 'eheldbyengine';
/** 9단계 — 외부 이미지가 든 메일 (대역 엔진의 GET /api/messages/{id}) · 그 바깥 주소 */
const IMAGE_MESSAGE = 'imagemessage';
const PUBLIC_IMAGE_URL = 'https://images.e2e.example/logo.png';
const INTERNAL_IMAGE_URL = 'https://intranet.e2e.example/secret.png';
/** 대역 이름 풀기 — 공인 주소 · 사설 주소 (그 밖의 이름은 풀지 못한다) */
const IMAGE_HOSTS = new Map<string, ResolvedAddress[]>([
['images.e2e.example', [{ address: '93.184.216.34', family: 4 }]],
['intranet.e2e.example', [{ address: '10.0.0.7', family: 4 }]],
]);
/** 대역 메일 엔진이 돌려주는 메일 원문과 그 제목 */
const RAW_MESSAGE_SUBJECT = 'Re: 견적서/10월';
const RAW_MESSAGE =
@@ -413,6 +444,37 @@ describe('응답 계약 (e2e)', () => {
update: MessageUpdate;
}[] = [];
/** 9단계 — 대역 엔진의 차단 목록(직원마다 — 최근 것부터) · 예약한 메일 · 취소한 발송 · 바깥 이미지 요청 */
const mailBlocks = new Map<string, BlockedSender[]>();
const scheduledMessages = new Map<string, MessageSummary[]>();
const canceledSends: { user: string; id: string }[] = [];
/** 대역 엔진이 받은 한 통 삭제 (DELETE /api/messages/{id}) */
const deletedMessages: { user: string; id: string }[] = [];
const imageRequests: MailImageRequest[] = [];
const fakeImageResolver = {
resolve: (hostname: string): Promise<ResolvedAddress[]> => {
const addresses = IMAGE_HOSTS.get(hostname);
return addresses
? Promise.resolve(addresses)
: Promise.reject(new Error('e2e-unknown-host'));
},
};
const fakeImageTransport = {
get: (imageRequest: MailImageRequest): Promise<MailImageResponse> => {
imageRequests.push(imageRequest);
const data = pngBytes(48, 16);
return Promise.resolve({
status: 200,
location: null,
contentLength: data.length,
body: (async function* () {
yield await Promise.resolve(new Uint8Array(data));
})(),
close: () => undefined,
});
},
};
/** 5단계 — 대역 엔진에 열린 실시간 알림 연결 (직원마다 하나여야 한다) */
const liveWatches: {
user: string;
@@ -636,9 +698,21 @@ describe('응답 계약 (e2e)', () => {
);
}
sentMessages.push({ user, message });
// 9단계 — 엔진이 붙잡았으면 보낼 시각과 종류 (실제 계산은 jmap/pending-send.ts)
const hold = message.hold;
const pendingSend: PendingSend | null = hold
? {
kind: hold.kind,
sendAt:
hold.kind === 'scheduled'
? hold.until
: new Date(Date.now() + hold.seconds * 1_000).toISOString(),
}
: null;
return Promise.resolve({
id: `sent${sentMessages.length}`,
rejectedRecipients,
pendingSend,
});
},
// 임시 저장 — 엔진 쪽 판단(초안이 아님 RES_001 · 이미 바뀜 MAIL_008)만 흉내 낸다 (실제 규칙은 jmap-mail.provider.spec.ts)
@@ -689,6 +763,7 @@ describe('응답 계약 (e2e)', () => {
attachments: [],
inReplyTo: null,
updatedAt,
isCanceledSend: false,
});
},
deleteDraft: (user: string, id: string) => {
@@ -744,9 +819,14 @@ describe('응답 계약 (e2e)', () => {
}),
);
}
// 붙잡힌 메일은 옮기기 · 스팸 표시를 하지 않고 failedIds 로 (9단계)
const moving =
update.mailboxId !== undefined || update.isJunk !== undefined;
const fails = (id: string) =>
id === GONE_MESSAGE || (moving && id === HELD_MESSAGE);
return Promise.resolve({
updatedIds: ids.filter((id) => id !== GONE_MESSAGE),
failedIds: ids.filter((id) => id === GONE_MESSAGE),
updatedIds: ids.filter((id) => !fails(id)),
failedIds: ids.filter(fails),
});
},
deleteFromTrash: (
@@ -964,6 +1044,16 @@ describe('응답 계약 (e2e)', () => {
update: MessageUpdate,
): Promise<MessageSummary> => {
singleUpdates.push({ user, id, update });
if (
id === HELD_MESSAGE &&
(update.mailboxId !== undefined || update.isJunk !== undefined)
) {
return Promise.reject(
new BusinessException('MAIL_015', {
context: { reason: 'e2e-held-send-locked' },
}),
);
}
return Promise.resolve({
id,
threadId: id,
@@ -977,8 +1067,125 @@ describe('응답 계약 (e2e)', () => {
isFlagged: false,
hasAttachment: false,
size: 1,
pendingSend: null,
});
},
// 9단계 — 메일 읽기 (외부 이미지 확인용 한 통만). 정화 · 그림 판정은 html/*.spec.ts · mail.service.spec.ts 가 본다
getMessage: (
user: string,
id: string,
): Promise<MessageDetailSource> => {
if (id !== IMAGE_MESSAGE) {
return Promise.reject(
new BusinessException('RES_001', {
context: { reason: 'e2e-message-not-found' },
}),
);
}
return Promise.resolve({
id,
threadId: id,
mailboxIds: ['a'],
from: { name: '바깥 보낸 사람', email: 'news@letters.e2e.example' },
to: [{ name: null, email: user }],
subject: '그림이 든 메일',
preview: '',
receivedAt: '2026-10-29T01:00:00.000Z',
isRead: true,
isFlagged: false,
hasAttachment: false,
size: 2_000,
pendingSend: null,
cc: [],
bcc: [],
replyTo: [],
sentAt: null,
messageId: 'image@e2e',
references: [],
rawHtmlBody: `<p>안녕하세요</p><img src="${PUBLIC_IMAGE_URL}" alt="로고"><img src="${INTERNAL_IMAGE_URL}">`,
isRawHtmlBodyTruncated: false,
textBody: '안녕하세요',
isTextBodyTruncated: false,
attachments: [],
inlineParts: [],
isInJunkMailbox: false,
});
},
// 9단계 — 차단 목록 (저장 자리 · Sieve · 판은 jmap-filter-script.spec.ts 가 본다)
getMailBlocks: (user: string): Promise<MailBlockList> =>
Promise.resolve({
senders: [...(mailBlocks.get(user) ?? [])],
isActive: true,
}),
addMailBlock: (
user: string,
sender: BlockedSender,
): Promise<MailBlockAdded> => {
const current = mailBlocks.get(user) ?? [];
const existing = current.find(
({ address }) => address === sender.address,
);
if (existing) {
return Promise.resolve({ sender: existing, created: false });
}
mailBlocks.set(user, [sender, ...current]);
return Promise.resolve({ sender, created: true });
},
removeMailBlock: (user: string, address: string): Promise<void> => {
const current = mailBlocks.get(user) ?? [];
if (!current.some((entry) => entry.address === address)) {
return Promise.reject(
new BusinessException('RES_001', {
context: { reason: 'e2e-mail-block-not-found' },
}),
);
}
mailBlocks.set(
user,
current.filter((entry) => entry.address !== address),
);
return Promise.resolve();
},
// 한 통 삭제 — 붙잡힌 메일은 409 MAIL_015 (9단계), 없으면 RES_001 (조건부 영구 삭제는 jmap-mail.provider.spec.ts)
deleteMessage: (user: string, id: string): Promise<void> => {
if (id === HELD_MESSAGE) {
return Promise.reject(
new BusinessException('MAIL_015', {
context: { reason: 'e2e-held-send-locked' },
}),
);
}
if (id === GONE_MESSAGE) {
return Promise.reject(
new BusinessException('RES_001', {
context: { reason: 'e2e-message-not-found' },
}),
);
}
deletedMessages.push({ user, id });
return Promise.resolve();
},
// 9단계 — 붙잡은 발송 (엔진의 기다리는 제출 · 되돌리기는 jmap-mail.provider.spec.ts 가 본다)
listScheduledMessages: (user: string): Promise<MessageSummary[]> =>
Promise.resolve(scheduledMessages.get(user) ?? []),
cancelPendingSend: (user: string, id: string): Promise<void> => {
if (id === ALREADY_RELEASED_MESSAGE) {
return Promise.reject(
new BusinessException('MAIL_014', {
context: { reason: 'e2e-not-a-pending-send' },
}),
);
}
if (id === GONE_MESSAGE) {
return Promise.reject(
new BusinessException('RES_001', {
context: { reason: 'e2e-message-not-found' },
}),
);
}
canceledSends.push({ user, id });
return Promise.resolve();
},
})
.overrideProvider(MAIL_SETTINGS)
.useValue(TEST_MAIL_SETTINGS)
@@ -986,6 +1193,11 @@ describe('응답 계약 (e2e)', () => {
.useValue(fakeContactsProvider)
.overrideProvider(DIRECTORY_PROVIDER)
.useValue(fakeDirectoryProvider)
// 9단계 — 바깥 이미지 받기는 실제 네트워크에 나가지 않는다 (SSRF 검사 · 다시 가기 · 크기는 images/*.spec.ts)
.overrideProvider(MAIL_IMAGE_RESOLVER)
.useValue(fakeImageResolver)
.overrideProvider(MAIL_IMAGE_TRANSPORT)
.useValue(fakeImageTransport)
.compile();
// CI 의 빈 DB 에도 표가 있어야 한다 — 운영처럼 마이그레이션으로 만든다 (synchronize 를 켜지 않는다)
@@ -1023,6 +1235,7 @@ describe('응답 계약 (e2e)', () => {
'DELETE FROM mail_signature_images WHERE user_email LIKE $1',
'DELETE FROM mail_signatures WHERE user_email LIKE $1',
'DELETE FROM mail_directory WHERE email LIKE $1',
'DELETE FROM mail_settings WHERE user_email LIKE $1',
]) {
await dataSource.query(statement, [TEST_DOMAIN_PATTERN]);
}
@@ -3189,6 +3402,8 @@ describe('응답 계약 (e2e)', () => {
data: {
id: expect.stringMatching(/^sent\d+$/) as string,
rejectedRecipients: [],
// 9단계 — 처음 설정은 보내기 취소 10초: 엔진이 붙잡았다가 보낸다
pendingSend: { kind: 'undo', sendAt: expect.any(String) as string },
},
});
});
@@ -3209,6 +3424,7 @@ describe('응답 계약 (e2e)', () => {
{ email: NOBODY, reason: 'not-found' },
{ email: BUSY, reason: 'temporary' },
],
pendingSend: { kind: 'undo', sendAt: expect.any(String) as string },
});
expect(deletedDrafts).toContain(draftId);
});
@@ -4376,6 +4592,565 @@ describe('응답 계약 (e2e)', () => {
});
});
/**
* 9단계 — 보내기 취소 · 예약 발송 · 외부 이미지 · 보낸 사람 차단 · 보내기와 읽기 설정 (계약 2절).
* 엔진 쪽(붙잡기 · 취소 · Sieve · 바깥 받기의 SSRF 검사)은 jmap/*.spec.ts · images/*.spec.ts 가 본다 —
* 여기서는 HTTP 형식 · 요청 검사 · 설정(DB 의 mail_settings)이 발송과 읽기에 닿는지를 본다
*/
describe('9단계 — 보내기 취소 · 예약 · 외부 이미지 · 차단 · 설정', () => {
const patchSettings = (email: string, payload: object) =>
request(server)
.patch('/api/mail-settings')
.set('Cookie', cookieOf(email))
.send(payload);
describe('보내기 · 읽기 설정 (/api/mail-settings)', () => {
const ME = 'settings.me@e2e.arasystem.test';
it('로그인하지 않았으면 401 AUTH_001', async () => {
for (const res of [
await request(server).get('/api/mail-settings'),
await request(server)
.patch('/api/mail-settings')
.send({ undoSendSeconds: 5 }),
]) {
expect(res.status).toBe(401);
expect(body(res).error?.code).toBe('AUTH_001');
}
});
it('처음에는 처음 값 — 보내기 취소 10초 · 외부 이미지 늘 묻기 (DB 에 줄이 없어도)', async () => {
const res = await request(server)
.get('/api/mail-settings')
.set('Cookie', cookieOf(ME))
.expect(200);
expect(body(res)).toEqual({
success: true,
data: { undoSendSeconds: 10, externalImages: 'ask' },
});
});
it('PATCH 는 보낸 칸만 바꾸고 전체를 돌려준다 — 다시 읽어도 같고 DB 에 직원마다 한 줄', async () => {
const first = await patchSettings(ME, {
externalImages: 'always',
}).expect(200);
expect(dataOf<unknown>(first)).toEqual({
undoSendSeconds: 10,
externalImages: 'always',
});
const second = await patchSettings(ME, { undoSendSeconds: 30 }).expect(
200,
);
expect(dataOf<unknown>(second)).toEqual({
undoSendSeconds: 30,
externalImages: 'always',
});
const read = await request(server)
.get('/api/mail-settings')
.set('Cookie', cookieOf(ME))
.expect(200);
expect(dataOf<unknown>(read)).toEqual({
undoSendSeconds: 30,
externalImages: 'always',
});
const rows: {
undo_send_seconds: number;
external_images: string;
}[] = await dataSource.query(
'SELECT undo_send_seconds, external_images FROM mail_settings WHERE user_email = $1',
[ME],
);
expect(rows).toEqual([
{ undo_send_seconds: 30, external_images: 'always' },
]);
});
it.each<[string, object]>([
['빈 본문(하나 이상)', {}],
['고를 수 없는 초', { undoSendSeconds: 7 }],
['글로 쓴 초', { undoSendSeconds: '10' }],
['null 초', { undoSendSeconds: null }],
['모르는 외부 이미지 값', { externalImages: 'never' }],
['null 외부 이미지', { externalImages: null }],
['모르는 칸', { theme: 'dark' }],
])('잘못된 본문은 400 VAL_001 — %s', async (_label, payload) => {
const res = await patchSettings(ME, payload).expect(400);
expect(body(res).error?.code).toBe('VAL_001');
});
});
describe('보내기 취소 · 예약 발송 (POST /api/messages · /api/scheduled-messages)', () => {
const ME = 'schedule.me@e2e.arasystem.test';
const DOYUN = { name: '이도윤', email: 'doyun.lee@e2e.arasystem.test' };
const MINUTE_MS = 60_000;
const DAY_MS = 24 * 60 * MINUTE_MS;
const send = (payload: object = {}) =>
request(server)
.post('/api/messages')
.set('Cookie', cookieOf(ME))
.send({ to: [DOYUN], subject: '제목', body: '본문', ...payload });
const lastHold = () =>
sentMessages[sentMessages.length - 1]?.message.hold;
const pendingOf = (res: Response) =>
dataOf<{ pendingSend: PendingSend | null }>(res).pendingSend;
const summary = (id: string, sendAt: string): MessageSummary => ({
id,
threadId: id,
mailboxIds: ['e'],
from: { name: '나', email: ME },
to: [DOYUN],
subject: `예약 ${id}`,
preview: '',
receivedAt: '2026-10-29T01:00:00.000Z',
isRead: true,
isFlagged: false,
hasAttachment: false,
size: 100,
pendingSend: { kind: 'scheduled', sendAt },
});
it('sendAt 이 있으면 그 시각(소수 초는 버린다)까지 붙잡는다 — 201 의 pendingSend 는 예약, 보내기 취소 시간을 더하지 않는다', async () => {
const second =
Math.floor((Date.now() + 60 * MINUTE_MS) / 1_000) * 1_000;
const until = new Date(second).toISOString();
const res = await send({
sendAt: new Date(second + 750).toISOString(),
}).expect(201);
expect(pendingOf(res)).toEqual({ kind: 'scheduled', sendAt: until });
expect(lastHold()).toEqual({ kind: 'scheduled', until });
});
it.each<[string, () => string]>([
[
'4분 뒤 — 5분보다 이르다',
() => new Date(Date.now() + 4 * MINUTE_MS).toISOString(),
],
[
'7일하고 1분 뒤',
() => new Date(Date.now() + 7 * DAY_MS + MINUTE_MS).toISOString(),
],
['지난 시각', () => new Date(Date.now() - MINUTE_MS).toISOString()],
['UTC 가 아님(+09:00)', () => '2030-12-01T09:00:00+09:00'],
['날짜가 아님', () => '내일 아침'],
])(
'예약 시각이 %s 이면 400 VAL_001 — 엔진에 보내지 않는다',
async (_label, sendAt) => {
const before = sentMessages.length;
const res = await send({ sendAt: sendAt() }).expect(400);
expect(body(res).error?.code).toBe('VAL_001');
expect(sentMessages).toHaveLength(before);
},
);
it('sendAt 이 없으면 설정의 보내기 취소 초만큼 붙잡는다 — 0초면 붙잡지 않고 pendingSend 는 null', async () => {
const undo = await send().expect(201);
expect(pendingOf(undo)).toEqual({
kind: 'undo',
sendAt: expect.any(String) as string,
});
expect(lastHold()).toEqual({ kind: 'undo', seconds: 10 });
await patchSettings(ME, { undoSendSeconds: 0 }).expect(200);
const now = await send().expect(201);
expect(pendingOf(now)).toBeNull();
expect(lastHold()).toBeUndefined();
await patchSettings(ME, { undoSendSeconds: 20 }).expect(200);
await send().expect(201);
expect(lastHold()).toEqual({ kind: 'undo', seconds: 20 });
});
it('예약한 메일 — { items, pageInfo }(total 포함), 커서로 이어 받는다 · 깨진 커서는 400 VAL_001', async () => {
const at = (minutes: number) =>
new Date(Date.UTC(2030, 0, 1, 9, minutes)).toISOString();
scheduledMessages.set(ME, [
summary('s1', at(0)),
summary('s2', at(10)),
summary('s3', at(20)),
]);
const first = await request(server)
.get('/api/scheduled-messages?limit=2')
.set('Cookie', cookieOf(ME))
.expect(200);
const page = dataOf<Page<MessageSummary>>(first);
expect(page.items.map(({ id }) => id)).toEqual(['s1', 's2']);
expect(page.items[0].pendingSend).toEqual({
kind: 'scheduled',
sendAt: at(0),
});
expect(page.pageInfo).toEqual({
hasNext: true,
nextCursor: expect.any(String) as string,
total: 3,
});
const second = await request(server)
.get(
`/api/scheduled-messages?limit=2&cursor=${encodeURIComponent(page.pageInfo.nextCursor ?? '')}`,
)
.set('Cookie', cookieOf(ME))
.expect(200);
expect(dataOf<Page<MessageSummary>>(second)).toEqual({
items: [expect.objectContaining({ id: 's3' }) as MessageSummary],
pageInfo: { hasNext: false, nextCursor: null, total: 3 },
});
const broken = await request(server)
.get('/api/scheduled-messages?cursor=bm90LWEtY3Vyc29y')
.set('Cookie', cookieOf(ME))
.expect(400);
expect(body(broken).error?.code).toBe('VAL_001');
await request(server).get('/api/scheduled-messages').expect(401);
});
it('취소 — 200 { draftId }(같은 메일 id), 이미 보냈으면 409 MAIL_014, 없거나 id 형식이 틀리면 404 RES_001 (엔진에 묻지 않는다)', async () => {
const cancel = (id: string) =>
request(server)
.delete(`/api/scheduled-messages/${id}`)
.set('Cookie', cookieOf(ME));
const ok = await cancel('eheldmsg1').expect(200);
expect(body(ok)).toEqual({
success: true,
data: { draftId: 'eheldmsg1' },
});
expect(canceledSends).toContainEqual({ user: ME, id: 'eheldmsg1' });
const released = await cancel(ALREADY_RELEASED_MESSAGE).expect(409);
expect(body(released).error?.code).toBe('MAIL_014');
const gone = await cancel(GONE_MESSAGE).expect(404);
expect(body(gone).error?.code).toBe('RES_001');
const before = canceledSends.length;
const malformed = await cancel('a"b').expect(404);
expect(body(malformed).error?.code).toBe('RES_001');
expect(canceledSends).toHaveLength(before);
});
it('같은 발송을 두 번 취소해도 둘 다 200 { draftId } — 응답을 받지 못한 재시도 (멱등은 jmap-mail.provider.spec.ts)', async () => {
const cancel = () =>
request(server)
.delete('/api/scheduled-messages/etwicecanceled')
.set('Cookie', cookieOf(ME));
for (const res of [await cancel(), await cancel()]) {
expect(res.status).toBe(200);
expect(body(res)).toEqual({
success: true,
data: { draftId: 'etwicecanceled' },
});
}
});
describe('붙잡힌 메일은 취소로만 보낸편지함을 떠난다 (409 MAIL_015)', () => {
const patchOne = (payload: object) =>
request(server)
.patch(`/api/messages/${HELD_MESSAGE}`)
.set('Cookie', cookieOf(ME))
.send(payload);
it.each<[string, object]>([
['옮기기', { mailboxId: 'b' }],
['스팸 신고', { isJunk: true }],
['옮기기와 읽음 함께', { mailboxId: 'b', isRead: true }],
])(
'한 통 %s 는 409 MAIL_015 — 표준 실패 형식',
async (_label, payload) => {
const res = await patchOne(payload).expect(409);
expect(body(res)).toEqual({
success: false,
error: {
code: 'MAIL_015',
message:
'보내기를 기다리는 메일은 옮기거나 지울 수 없습니다. 먼저 보내기를 취소해 주세요.',
traceId: expect.any(String) as string,
},
});
},
);
it('한 통의 읽음 · 중요 표시는 된다 — 200', async () => {
const res = await patchOne({ isFlagged: true, isRead: true }).expect(
200,
);
expect(dataOf<MessageSummary>(res).id).toBe(HELD_MESSAGE);
});
it('여러 통 옮기기는 200 — 붙잡힌 메일만 failedIds, 나머지는 옮긴다', async () => {
const res = await request(server)
.patch('/api/messages')
.set('Cookie', cookieOf(ME))
.send({ ids: ['eaaaaab', HELD_MESSAGE], mailboxId: 'b' })
.expect(200);
expect(dataOf<MessagesPatchResult>(res)).toEqual({
updatedIds: ['eaaaaab'],
failedIds: [HELD_MESSAGE],
});
});
it('한 통 삭제(DELETE)는 409 MAIL_015 — 지우지 않는다', async () => {
const before = deletedMessages.length;
const res = await request(server)
.delete(`/api/messages/${HELD_MESSAGE}`)
.set('Cookie', cookieOf(ME))
.expect(409);
expect(body(res).error?.code).toBe('MAIL_015');
expect(deletedMessages).toHaveLength(before);
// 붙잡히지 않은 메일은 지금처럼 204
await request(server)
.delete('/api/messages/eaaaaab')
.set('Cookie', cookieOf(ME))
.expect(204);
expect(deletedMessages).toContainEqual({ user: ME, id: 'eaaaaab' });
});
});
});
describe('외부 이미지 (GET /api/messages/{id}?images=show · GET /api/mail-images/{token})', () => {
const ME = 'images.me@e2e.arasystem.test';
const OTHER = 'images.other@e2e.arasystem.test';
interface ReadDetail {
htmlBody: string | null;
hasBlockedImages: boolean;
imagesShown: boolean;
isInJunkMailbox?: unknown;
}
const read = (query = '', who = ME) =>
request(server)
.get(`/api/messages/${IMAGE_MESSAGE}${query}`)
.set('Cookie', cookieOf(who));
/** 본문의 중계 주소들 (sanitize-html 이 & 를 &amp; 로 쓰지 않는 base64url 표) */
const proxiedSources = (html: string | null) =>
[...(html ?? '').matchAll(/src="(\/api\/mail-images\/[^"]+)"/g)].map(
(match) => match[1],
);
it('images 의 모르는 값 · 모르는 쿼리는 400 VAL_001', async () => {
for (const query of ['?images=1', '?images=SHOW', '?show=1']) {
const res = await read(query).expect(400);
expect(body(res).error?.code).toBe('VAL_001');
}
});
it('묻기(처음 설정)면 바깥 그림을 지우고 hasBlockedImages · imagesShown false — 스팸메일함 여부는 응답에 싣지 않는다', async () => {
const res = await read().expect(200);
const detail = dataOf<ReadDetail>(res);
expect(detail.hasBlockedImages).toBe(true);
expect(detail.imagesShown).toBe(false);
expect(detail.htmlBody).not.toContain('e2e.example');
expect(proxiedSources(detail.htmlBody)).toEqual([]);
expect(detail).not.toHaveProperty('isInJunkMailbox');
});
it('images=show 면 그림 주소를 이 서버의 중계 주소로 바꾼다 — 받은 그림은 표준 래퍼 없이 그대로, 머리글은 계약대로', async () => {
const res = await read('?images=show').expect(200);
const detail = dataOf<ReadDetail>(res);
expect(detail.imagesShown).toBe(true);
expect(detail.hasBlockedImages).toBe(false);
expect(detail.htmlBody).not.toContain('e2e.example');
const [publicImage, internalImage] = proxiedSources(detail.htmlBody);
expect(internalImage).toBeDefined();
const before = imageRequests.length;
// superagent 는 image/* 를 Buffer 로 받는다
const image = await request(server)
.get(publicImage)
.set('Cookie', cookieOf(ME))
.expect(200);
expect(Buffer.isBuffer(image.body)).toBe(true);
expect(image.body as Buffer).toEqual(pngBytes(48, 16));
expect(image.headers['content-type']).toBe('image/png');
expect(image.headers['content-length']).toBe(
String(pngBytes(48, 16).length),
);
expect(image.headers['x-content-type-options']).toBe('nosniff');
expect(image.headers['cache-control']).toBe('private, max-age=3600');
expect(image.headers['content-disposition']).toBe('inline');
expect(image.headers['content-security-policy']).toBe(
"default-src 'none'; sandbox",
);
// 바깥으로는 확인한 주소로만 · 쿠키 · Referer 없이
expect(imageRequests).toHaveLength(before + 1);
const sent = imageRequests[imageRequests.length - 1];
expect(sent.url.href).toBe(PUBLIC_IMAGE_URL);
expect(sent.addresses).toEqual([
{ address: '93.184.216.34', family: 4 },
]);
expect(Object.keys(sent.headers).sort()).toEqual([
'accept',
'accept-encoding',
'user-agent',
]);
// 사설 주소로 풀리는 이름은 묻지 않고 404 RES_001 (까닭을 가리지 않는다)
const blocked = await request(server)
.get(internalImage)
.set('Cookie', cookieOf(ME))
.expect(404);
expect(body(blocked)).toEqual({
success: false,
error: {
code: 'RES_001',
message: expect.any(String) as string,
traceId: blocked.headers['x-request-id'],
},
});
expect(imageRequests).toHaveLength(before + 1);
});
it('표는 그 직원 것만 — 다른 직원 · 고친 표 · 없는 표는 404 RES_001, 로그인하지 않았으면 401 AUTH_001', async () => {
const [source] = proxiedSources(
dataOf<ReadDetail>(await read('?images=show').expect(200)).htmlBody,
);
const token = source.slice('/api/mail-images/'.length);
const tampered = `${token.slice(0, -2)}${token.endsWith('AA') ? 'BB' : 'AA'}`;
for (const [path, who] of [
[source, OTHER],
[`/api/mail-images/${tampered}`, ME],
['/api/mail-images/not-a-token', ME],
] as const) {
const res = await request(server)
.get(path)
.set('Cookie', cookieOf(who))
.expect(404);
expect(body(res).error?.code).toBe('RES_001');
}
const anonymous = await request(server).get(source).expect(401);
expect(body(anonymous).error?.code).toBe('AUTH_001');
});
it('설정이 늘 보기(always)면 images 없이도 보인다', async () => {
await patchSettings(ME, { externalImages: 'always' }).expect(200);
const detail = dataOf<ReadDetail>(await read().expect(200));
expect(detail.imagesShown).toBe(true);
expect(proxiedSources(detail.htmlBody)).toHaveLength(2);
await patchSettings(ME, { externalImages: 'ask' }).expect(200);
});
});
describe('보낸 사람 차단 (/api/mail-blocks)', () => {
const ME = 'blocks.me@e2e.arasystem.test';
const add = (address: unknown, extra: object = {}) =>
request(server)
.post('/api/mail-blocks')
.set('Cookie', cookieOf(ME))
.send({ address, ...extra });
const list = async () =>
dataOf<{ senders: BlockedSender[]; isActive: boolean }>(
await request(server)
.get('/api/mail-blocks')
.set('Cookie', cookieOf(ME))
.expect(200),
);
it('처음에는 빈 목록 · 동작함, 로그인하지 않았으면 401 AUTH_001', async () => {
await expect(list()).resolves.toEqual({ senders: [], isActive: true });
const res = await request(server).get('/api/mail-blocks').expect(401);
expect(body(res).error?.code).toBe('AUTH_001');
});
it('차단 — 앞뒤 공백을 빼고 소문자로, 새로 차단하면 201 · 이미 있으면 200(처음 차단한 시각) · 목록은 최근 것부터', async () => {
const created = await add(' Kim@Spam.Example ').expect(201);
const kim = dataOf<BlockedSender>(created);
expect(kim).toEqual({
address: 'kim@spam.example',
blockedAt: expect.stringMatching(/^\d{4}-\d{2}-\d{2}T.+Z$/) as string,
});
const again = await add('kim@spam.example').expect(200);
expect(dataOf<BlockedSender>(again)).toEqual(kim);
await add('@Ads.Example.org').expect(201);
// 여러 사람이 쓰는 메일 서비스도 주소로는 막는다
await add('friend@gmail.com').expect(201);
expect((await list()).senders.map(({ address }) => address)).toEqual([
'friend@gmail.com',
'@ads.example.org',
'kim@spam.example',
]);
});
it.each<[string, unknown]>([
['내 주소(대소문자 무시)', ME.toUpperCase()],
['사내 도메인', '@e2e.arasystem.test'],
['사내 하위 도메인', '@mail.e2e.arasystem.test'],
['사내 직원 주소', 'doyun.lee@e2e.arasystem.test'],
['여러 사람이 쓰는 메일 서비스의 도메인', '@Naver.com'],
['주소 모양이 아님', 'not an address'],
['${ 가 든 주소', '${x}@spam.example'],
['IP 주소 도메인', '@127.0.0.1'],
['점 없는 도메인', '@localhost'],
['255자', `${'a'.repeat(240)}@spam.example`],
['빈 글', ' '],
['글이 아님', 42],
])(
'막을 수 없거나 모양이 틀리면 400 VAL_001 — %s',
async (_label, address) => {
const res = await add(address).expect(400);
expect(body(res).error?.code).toBe('VAL_001');
},
);
it('모르는 칸이 있으면 400 VAL_001', async () => {
const res = await add('lee@spam.example', { note: '메모' }).expect(400);
expect(body(res).error?.code).toBe('VAL_001');
});
it('차단 풀기 — URL 인코딩한 주소(대소문자 무시)는 200 { address }, 없거나 모양이 틀리면 404 RES_001', async () => {
await add('lee@spam.example').expect(201);
const removed = await request(server)
.delete(`/api/mail-blocks/${encodeURIComponent('LEE@spam.example')}`)
.set('Cookie', cookieOf(ME))
.expect(200);
expect(body(removed)).toEqual({
success: true,
data: { address: 'lee@spam.example' },
});
expect(
(await list()).senders.map(({ address }) => address),
).not.toContain('lee@spam.example');
// 도메인 차단도 같은 길 — `@` 를 인코딩한 경로(%40…)를 풀어 도메인으로 맞춘다 (대소문자 무시)
await add('@Domain-Spam.example').expect(201);
const domainRemoved = await request(server)
.delete('/api/mail-blocks/%40DOMAIN-spam.example')
.set('Cookie', cookieOf(ME))
.expect(200);
expect(body(domainRemoved)).toEqual({
success: true,
data: { address: '@domain-spam.example' },
});
expect(
(await list()).senders.map(({ address }) => address),
).not.toContain('@domain-spam.example');
for (const path of [
encodeURIComponent('lee@spam.example'),
encodeURIComponent('not an address'),
]) {
const res = await request(server)
.delete(`/api/mail-blocks/${path}`)
.set('Cookie', cookieOf(ME))
.expect(404);
expect(body(res).error?.code).toBe('RES_001');
}
});
});
});
/**
* 출처 검사(CSRF) — 상태를 바꾸는 요청은 Sec-Fetch-Site(same-origin·none) 또는 허용 목록의 Origin 이어야 한다.
* 둘 다 없으면 브라우저가 아닌 클라이언트로 보고 통과시킨다 (docs/mail/api-contract.md 머리말).
@@ -4406,6 +5181,10 @@ describe('응답 계약 (e2e)', () => {
['post', '/api/mail-attachments'],
['patch', '/api/contacts/abc'],
['post', '/api/contact-restorations'],
['patch', '/api/mail-settings'],
['post', '/api/mail-blocks'],
['delete', '/api/mail-blocks/kim%40spam.example'],
['delete', '/api/scheduled-messages/eaaaaab'],
] as const)(
'Sec-Fetch-Site 없이 허용 목록에 없는 Origin 이면 %s %s 도 403 AUTH_003 (로그인 검사보다 먼저)',
async (method, path) => {