generated from ttipo/project-base
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:
@@ -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 이 & 를 & 로 쓰지 않는 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) => {
|
||||
|
||||
Reference in New Issue
Block a user