chore(docker): 메일 엔진·네트워크 분리·같은 주소 배포와 Node 24 고정 #1

Merged
ttipo merged 52 commits from feat/mail-module into main 2026-10-30 12:50:48 +09:00
25 changed files with 2870 additions and 107 deletions
Showing only changes of commit 090ee361a3 - Show all commits
@@ -156,19 +156,19 @@ describe('HttpExceptionFilter', () => {
);
});
it('로그의 요청 주소에서 검색어(q)는 길이만 남긴다 — 나머지 쿼리는 그대로', () => {
it('로그의 요청 주소에서 검색어(q)와 커서(cursor — 검색어 해시를 담는다)는 길이만 남긴다 — 나머지 쿼리는 그대로', () => {
const warnLog = jest.spyOn(Logger.prototype, 'warn');
warnLog.mockClear();
runFilter(
new BusinessException('VAL_001'),
'trace-q',
'/api/recipient-suggestions?q=%EB%8F%84%EC%9C%A4&cursor=bad',
'/api/recipient-suggestions?q=%EB%8F%84%EC%9C%A4&limit=20&cursor=bad',
);
const logged = warnLog.mock.calls.map(([message]) => String(message));
expect(logged).toEqual([
'[VAL_001][trace-q] GET /api/recipient-suggestions?q=<2자>&cursor=bad',
'[VAL_001][trace-q] GET /api/recipient-suggestions?q=<2자>&limit=20&cursor=<3자>',
]);
});
@@ -0,0 +1,85 @@
import { plainToInstance } from 'class-transformer';
import { validate } from 'class-validator';
import { MAX_BULK_MESSAGE_IDS } from '@/modules/mail/mail.constants';
import { MessageDeletionDto, UpdateMessagesDto } from './bulk-messages.dto';
/** 전역 ValidationPipe 와 같은 옵션으로 검증한다 (app.setup.ts) */
async function constraintsOf(
type: typeof UpdateMessagesDto | typeof MessageDeletionDto,
body: object,
): Promise<string[]> {
const errors = await validate(plainToInstance(type, body), {
whitelist: true,
forbidNonWhitelisted: true,
});
return errors.flatMap((error) => Object.keys(error.constraints ?? {}));
}
const ids = (count: number) =>
Array.from({ length: count }, (_, index) => `m${index}`);
describe('여러 통 요청 본문 (PATCH /api/messages · POST /api/message-deletions)', () => {
it.each([
['PATCH', UpdateMessagesDto, { isRead: true }],
['영구 삭제', MessageDeletionDto, {}],
] as const)(
`%s — ids 는 1~${MAX_BULK_MESSAGE_IDS}개, 같은 id 두 번 · 형식이 틀린 id 는 형식 오류`,
async (_name, type, changes) => {
expect(await constraintsOf(type, { ids: ['m1'], ...changes })).toEqual(
[],
);
expect(
await constraintsOf(type, {
ids: ids(MAX_BULK_MESSAGE_IDS),
...changes,
}),
).toEqual([]);
expect(await constraintsOf(type, { ids: [], ...changes })).toEqual([
'arrayMinSize',
]);
expect(
await constraintsOf(type, {
ids: ids(MAX_BULK_MESSAGE_IDS + 1),
...changes,
}),
).toEqual(['arrayMaxSize']);
expect(
await constraintsOf(type, { ids: ['m1', 'm1'], ...changes }),
).toEqual(['arrayUnique']);
for (const bad of ['../m1', '', 'm 1', 1, null]) {
expect(
await constraintsOf(type, { ids: ['m1', bad], ...changes }),
).toEqual(['matches']);
}
expect(await constraintsOf(type, { ...changes })).toContain('isArray');
expect(await constraintsOf(type, { ids: 'm1', ...changes })).toContain(
'isArray',
);
},
);
it('PATCH 의 바꿀 것은 한 통 PATCH 와 같은 규칙이다 — null 은 형식 오류, 모르는 필드는 거부', async () => {
expect(
await constraintsOf(UpdateMessagesDto, {
ids: ['m1'],
isRead: false,
isFlagged: true,
mailboxId: 'b',
}),
).toEqual([]);
expect(
await constraintsOf(UpdateMessagesDto, { ids: ['m1'], isRead: null }),
).toEqual(['isBoolean']);
expect(
await constraintsOf(UpdateMessagesDto, {
ids: ['m1'],
mailboxId: '../b',
}),
).toEqual(['matches']);
expect(
await constraintsOf(MessageDeletionDto, { ids: ['m1'], isRead: true }),
).toEqual(['whitelistValidation']);
});
});
@@ -0,0 +1,58 @@
import { applyDecorators } from '@nestjs/common';
import { ApiProperty } from '@nestjs/swagger';
import {
ArrayMaxSize,
ArrayMinSize,
ArrayUnique,
IsArray,
Matches,
} from 'class-validator';
import {
MAIL_ID_PATTERN,
MAX_BULK_MESSAGE_IDS,
} from '@/modules/mail/mail.constants';
import { UpdateMessageDto } from './update-message.dto';
/**
* 여러 통을 고르는 id 목록 — 1~MAX_BULK_MESSAGE_IDS 개, 같은 id 두 번 금지, 형식이 틀리면 400 VAL_001
* (계약 2절 "여러 통 한꺼번에"). 두 요청 본문이 같은 규칙을 쓴다.
*/
function MessageIdList(description: string) {
return applyDecorators(
ApiProperty({
description: `${description} (1~${MAX_BULK_MESSAGE_IDS}개, 같은 id 를 두 번 넣지 않는다)`,
type: [String],
minItems: 1,
maxItems: MAX_BULK_MESSAGE_IDS,
uniqueItems: true,
example: ['eaaaaab', 'eaaaaac'],
}),
IsArray({ message: '메일 목록 형식이 올바르지 않습니다.' }),
ArrayMinSize(1, { message: '메일을 1통 이상 선택해 주세요.' }),
ArrayMaxSize(MAX_BULK_MESSAGE_IDS, {
message: `메일은 한 번에 ${MAX_BULK_MESSAGE_IDS}통까지 선택할 수 있습니다.`,
}),
ArrayUnique({ message: '같은 메일을 두 번 선택했습니다.' }),
Matches(MAIL_ID_PATTERN, {
each: true,
message: '메일을 다시 선택해 주세요.',
}),
);
}
/**
* PATCH /api/messages — 여러 통의 읽음 · 중요 표시 · 이동 (바꿀 것은 한 통 PATCH 와 같고, 하나 이상 — 없으면 서비스가 VAL_001)
*/
export class UpdateMessagesDto extends UpdateMessageDto {
@MessageIdList('바꿀 메일 id')
ids!: string[];
}
/** POST /api/message-deletions — 휴지통에서 영구 삭제할 메일 */
export class MessageDeletionDto {
@MessageIdList(
'휴지통에서 영구 삭제할 메일 id — 휴지통에 없는 메일은 건너뛴다',
)
ids!: string[];
}
@@ -9,8 +9,11 @@ import type {
Mailbox,
MailboxRole,
MailQuota,
MailState,
MessageAttachment,
MessageDeletionResult,
MessageDetail,
MessagesPatchResult,
MessageSummary,
RecipientSuggestion,
SavedDraft,
@@ -314,6 +317,52 @@ export class SentMessageDto {
id!: string;
}
/** 계약 2절 "새 메일 확인"의 MailState */
export class MailStateDto implements MailState {
@ApiProperty({
description:
'불투명 값 — 메일함이나 메일이 하나라도 바뀌면 달라진다. 해석하지 말고 지난 값과 같은지만 본다',
example: 'c2Iy.c2Fj',
})
state!: string;
}
/** 계약 2절 "여러 통 한꺼번에"의 MessagesPatched */
export class MessagesPatchedDto implements MessagesPatchResult {
@ApiProperty({
description: '바꾼 메일 id (요청 순서)',
type: [String],
example: ['eaaaaab'],
})
updatedIds!: string[];
@ApiProperty({
description:
'바꾸지 못한 메일 id — 없음 · 그사이 지워짐 · 엔진이 거절함. 나머지는 그대로 처리했다',
type: [String],
example: ['eaaaaac'],
})
failedIds!: string[];
}
/** 계약 2절 "여러 통 한꺼번에"의 MessageDeletionResult */
export class MessageDeletionResultDto implements MessageDeletionResult {
@ApiProperty({
description:
'지운 메일 id (요청 순서) — 영구 삭제했다(휴지통에만 있었다) · 휴지통에서만 뺐다(다른 메일함에도 있었다)',
type: [String],
example: ['eaaaaab'],
})
deletedIds!: string[];
@ApiProperty({
description: '지우지 않은 메일 id — 휴지통에 없음 · 없음 · 엔진이 거절',
type: [String],
example: ['eaaaaac'],
})
skippedIds!: string[];
}
/** 계약 2절 "임시 저장"의 Draft — 쓰기 화면이 이어 쓴다 */
export class DraftDto implements Draft {
@ApiProperty({
@@ -0,0 +1,77 @@
import { plainToInstance } from 'class-transformer';
import { validate } from 'class-validator';
import {
MAX_SEARCH_LENGTH,
MAX_SEARCH_WORDS,
} from '@/modules/mail/mail.constants';
import { MessageListQueryDto } from './message-list-query.dto';
/** 전역 ValidationPipe 와 같은 옵션으로 변환·검증한다 (app.setup.ts — transform · whitelist · forbidNonWhitelisted) */
async function check(
query: Record<string, unknown>,
): Promise<{ dto: MessageListQueryDto; constraints: string[] }> {
const dto = plainToInstance(MessageListQueryDto, query);
const errors = await validate(dto, {
whitelist: true,
forbidNonWhitelisted: true,
});
return {
dto,
constraints: errors.flatMap((error) =>
Object.keys(error.constraints ?? {}),
),
};
}
describe('MessageListQueryDto (GET /api/messages)', () => {
it('mailboxId 는 선택이다 — 있으면 메일함 id 형식이어야 한다', async () => {
expect((await check({})).constraints).toEqual([]);
expect((await check({ mailboxId: 'a' })).constraints).toEqual([]);
for (const mailboxId of ['', '../a', 'a b']) {
expect((await check({ mailboxId })).constraints).toEqual(['matches']);
}
});
it('검색어는 앞뒤 공백을 지우고, 공백뿐이면 없는 것으로 본다', async () => {
expect((await check({ q: ' 서버 이전 ' })).dto.q).toBe('서버 이전');
// 전각 공백(U+3000)도 공백이다
const blank = await check({ q: ` ${String.fromCharCode(0x3000)} ` });
expect(blank.dto.q).toBeUndefined();
expect(blank.constraints).toEqual([]);
});
it(`앞뒤 공백을 뺀 ${MAX_SEARCH_LENGTH}자까지 — 길이는 JS 문자열 길이(UTF-16)로 센다`, async () => {
const longest = '가'.repeat(MAX_SEARCH_LENGTH);
expect((await check({ q: ` ${longest} ` })).constraints).toEqual([]);
expect((await check({ q: `${longest}가` })).constraints).toEqual([
'maxCodeUnits',
]);
// 이모지 하나는 2자다 — 웹 클라이언트가 세는 수와 같다
expect(
(await check({ q: '😀'.repeat(MAX_SEARCH_LENGTH / 2 + 1) })).constraints,
).toEqual(['maxCodeUnits']);
});
it(`낱말(띄어쓰기로 나눈 것)은 ${MAX_SEARCH_WORDS}개까지 — 따옴표 안의 낱말도 센다`, async () => {
const words = (count: number) =>
Array.from({ length: count }, (_, index) => `낱말${index}`).join(' ');
expect((await check({ q: words(MAX_SEARCH_WORDS) })).constraints).toEqual(
[],
);
expect(
(await check({ q: words(MAX_SEARCH_WORDS + 1) })).constraints,
).toEqual(['maxSearchWords']);
expect(
(await check({ q: `"${words(MAX_SEARCH_WORDS + 1)}"` })).constraints,
).toEqual(['maxSearchWords']);
});
it('검색어가 문자열이 아니면(같은 이름을 두 번 보냄) 형식 오류', async () => {
expect((await check({ q: ['회의', '일정'] })).constraints).toContain(
'isString',
);
});
});
@@ -1,26 +1,101 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { IsOptional, IsString, Matches, MaxLength } from 'class-validator';
import { ApiPropertyOptional } from '@nestjs/swagger';
import { Transform, type TransformFnParams } from 'class-transformer';
import {
IsOptional,
IsString,
Matches,
ValidateBy,
type ValidationOptions,
} from 'class-validator';
import { CursorPaginationQueryDto } from '@/common/dto/pagination.dto';
import {
MAIL_ID_PATTERN,
MAX_SEARCH_LENGTH,
MAX_SEARCH_WORDS,
SEARCH_WORD_SEPARATOR,
} from '@/modules/mail/mail.constants';
/** GET /api/messages — 커서 페이지네이션 + 메일함 + 검색어 */
export class MessageListQueryDto extends CursorPaginationQueryDto {
@ApiProperty({ description: '메일함 id (필수)', example: 'a' })
@Matches(MAIL_ID_PATTERN, { message: '메일함을 다시 선택해 주세요.' })
mailboxId!: string;
/**
* 검색어 — 앞뒤 공백을 지우고, 공백뿐이면 없는 것으로 본다 (계약 2절 "검색").
* 문자열이 아니면(같은 이름을 두 번 보낸 배열 등) 그대로 두어 IsString 이 막는다.
*/
function normalizeSearchQuery({ value }: TransformFnParams): unknown {
const raw: unknown = value;
if (typeof raw !== 'string') return raw;
const trimmed = raw.trim();
return trimmed === '' ? undefined : trimmed;
}
/** 검색어의 낱말 수 — 띄어쓰기(공백 문자)로 나눈 조각 (따옴표와 상관없이 센다) */
function countSearchWords(text: string): number {
return text.split(SEARCH_WORD_SEPARATOR).filter((word) => word !== '').length;
}
/**
* 최대 길이 — JS 문자열 길이(UTF-16 코드 단위)로 센다. 웹 클라이언트가 세는 방법과 같다
* (MaxLength(validator.js)는 서로게이트 쌍·이모지 표시 선택자를 덜 센다 — dto/send-message.dto.ts 와 같은 이유).
*/
function MaxCodeUnits(max: number, options: ValidationOptions) {
return ValidateBy(
{
name: 'maxCodeUnits',
constraints: [max],
validator: {
validate: (value: unknown) =>
typeof value === 'string' && value.length <= max,
},
},
options,
);
}
/** 최대 낱말 수 */
function MaxSearchWords(max: number, options: ValidationOptions) {
return ValidateBy(
{
name: 'maxSearchWords',
constraints: [max],
validator: {
validate: (value: unknown) =>
typeof value === 'string' && countSearchWords(value) <= max,
},
},
options,
);
}
/**
* GET /api/messages — 커서 페이지네이션 + 메일함 + 검색어.
* mailboxId 와 q 중 하나는 있어야 한다 — 둘 다 없으면 서비스가 400 VAL_001 (mail.service.ts 의 listMessages)
*/
export class MessageListQueryDto extends CursorPaginationQueryDto {
@ApiPropertyOptional({
description: `검색어 (보낸 사람·받는 사람·제목·본문, ${MAX_SEARCH_LENGTH}자 이하)`,
example: '서버 이전',
description:
'메일함 id — 없으면 q 가 있어야 하고, 휴지통·스팸메일함을 뺀 모든 메일함에서 찾는다 (mailboxId 와 q 가 둘 다 없으면 400 VAL_001)',
example: 'a',
})
@IsOptional()
@Matches(MAIL_ID_PATTERN, { message: '메일함을 다시 선택해 주세요.' })
mailboxId?: string;
@ApiPropertyOptional({
description:
`검색어 — 앞뒤 공백을 뺀 1~${MAX_SEARCH_LENGTH}자(JS 문자열 길이), 낱말(띄어쓰기로 나눈 것) ${MAX_SEARCH_WORDS}개까지. 공백뿐이면 없는 것으로 본다. ` +
'보낸 사람·받는 사람·참조·제목·본문에서 찾고, 여러 낱말이면 모두 들어간 메일만. 큰따옴표로 감싼 부분은 그 구절 그대로 찾는다. ' +
'한글로 끝나는 낱말은 흔한 조사를 붙인 형태도 함께 찾는다(회의 → 회의를 · 회의에서 …). ' +
'낱말 앞뒤의 문장부호는 떼고(회의, → 회의), 문자·숫자가 없는 낱말(- · … 등)은 빼고, 찾을 낱말이 하나도 없으면 빈 목록. 커서는 같은 검색어·범위로만 쓴다',
example: '서버 이전',
maxLength: MAX_SEARCH_LENGTH,
})
@Transform(normalizeSearchQuery)
@IsOptional()
@IsString({ message: '검색어 형식이 올바르지 않습니다.' })
@MaxLength(MAX_SEARCH_LENGTH, {
@MaxCodeUnits(MAX_SEARCH_LENGTH, {
message: `검색어는 ${MAX_SEARCH_LENGTH}자 이하로 입력해 주세요.`,
})
@MaxSearchWords(MAX_SEARCH_WORDS, {
message: `검색어는 낱말 ${MAX_SEARCH_WORDS}개까지 입력해 주세요.`,
})
q?: string;
}
@@ -42,6 +42,40 @@ describe('메일 목록 커서', () => {
).toBe('VAL_001');
});
it('모든 메일함 검색(메일함 없음)과 메일함 검색의 커서는 서로 쓸 수 없다 — 검색어가 다르면 다른 목록이다', () => {
const all = { text: '회의' };
const cursor = encodeCursor({ anchor: 'eaaaaab', position: 20 }, all);
expect(decodeCursor(cursor, all)).toEqual({
anchor: 'eaaaaab',
position: 20,
});
expect(
codeOf(() => decodeCursor(cursor, { mailboxId: 'a', text: '회의' })),
).toBe('VAL_001');
expect(codeOf(() => decodeCursor(cursor, { text: '일정' }))).toBe(
'VAL_001',
);
// 메일함 커서도 모든 메일함 검색에는 쓸 수 없다
const inboxSearch = encodeCursor(
{ anchor: 'eaaaaab', position: 20 },
{ mailboxId: 'a', text: '회의' },
);
expect(codeOf(() => decodeCursor(inboxSearch, all))).toBe('VAL_001');
});
it('메일함 범위의 커서 값은 예전과 같다 — 배포 전에 받은 커서도 그대로 쓴다', () => {
// 3단계 전의 범위 해시: sha256("<메일함 id>\n<검색어>") 의 앞 16자 (받은편지함 'a', 검색어 없음)
const before = Buffer.from(
JSON.stringify({ v: 1, a: 'eaaaaab', p: 20, s: 'h0KPxSKAPTEGXnvO' }),
).toString('base64url');
expect(decodeCursor(before, inbox)).toEqual({
anchor: 'eaaaaab',
position: 20,
});
});
it('해석할 수 없는 값은 VAL_001 이다', () => {
const forged = Buffer.from(
JSON.stringify({ v: 1, a: '../x', p: -1, s: 'x' }),
+9 -2
View File
@@ -20,8 +20,15 @@ const MAX_CURSOR_LENGTH = 512;
/** 범위 해시 길이 (충돌보다는 "다른 목록의 커서" 감지가 목적이라 짧게 둔다) */
const SCOPE_HASH_LENGTH = 16;
/**
* "모든 메일함"(휴지통·스팸메일함 제외 — 검색) 범위의 표시 — 메일함 id 형식(MAIL_ID_PATTERN)에 없는 글자라
* 실제 메일함 id 와 겹치지 않는다. 메일함 범위의 해시는 예전과 같다 (배포 전에 받은 커서도 그대로 쓴다).
*/
const ALL_MAILBOXES_SCOPE = '*';
export interface CursorScope {
readonly mailboxId: string;
/** 메일함 — 없으면 휴지통·스팸메일함을 뺀 모든 메일함 */
readonly mailboxId?: string;
readonly text?: string;
}
@@ -41,7 +48,7 @@ interface EncodedCursor {
function scopeHash(scope: CursorScope): string {
return createHash('sha256')
.update(`${scope.mailboxId}\n${scope.text ?? ''}`)
.update(`${scope.mailboxId ?? ALL_MAILBOXES_SCOPE}\n${scope.text ?? ''}`)
.digest('base64url')
.slice(0, SCOPE_HASH_LENGTH);
}
@@ -1,3 +1,4 @@
import { Logger } from '@nestjs/common';
import type { Cache } from 'cache-manager';
import { mkdtempSync, rmSync, writeFileSync } from 'fs';
import { tmpdir } from 'os';
@@ -560,7 +561,7 @@ describe('JmapMailProvider', () => {
expect(page.items.map((m) => m.id)).toEqual(['m3']);
});
it('검색어는 text 필터로 보낸다 (subject 필터는 단어 단위라 쓰지 않는다)', async () => {
it('검색어는 text 조건으로 보낸다 — 메일함 조건 뒤에 낱말마다 조사를 붙인 형태의 OR 를 AND 로 (subject 필터는 쓰지 않는다)', async () => {
const { provider, recorded } = fakeClient([queryResponses([], 0, 0)]);
await provider.listMessages(ARA, {
@@ -569,8 +570,221 @@ describe('JmapMailProvider', () => {
text: '서버 이전',
});
const { filter } = argsOf(recorded[0], 1) as {
filter: {
operator: string;
conditions: {
operator?: string;
conditions?: { text: string }[];
}[];
};
};
expect(filter.operator).toBe('AND');
expect(filter.conditions[0]).toEqual({
inMailbox: 'a',
notKeyword: '$ara-sending',
});
expect(filter.conditions.slice(1).map((group) => group.operator)).toEqual(
['OR', 'OR'],
);
const forms = filter.conditions[1].conditions?.map((form) => form.text);
expect(forms).toEqual(
expect.arrayContaining(['서버', '서버가', '서버를', '서버에서']),
);
// 받침 있는 '이전' 에는 받침 있을 때의 조사
expect(filter.conditions[2].conditions?.map((form) => form.text)).toEqual(
expect.arrayContaining(['이전', '이전이', '이전을']),
);
// 메일함 범위는 그 메일함을 함께 확인한다 (없으면 RES_001)
expect(recorded[0].calls[0][0]).toBe('Mailbox/get');
expect(recorded[0].options).toEqual({ invalidRequestCode: 'RES_001' });
});
it('큰따옴표 구절은 조사 없이 구절 그대로 찾는다', async () => {
const { provider, recorded } = fakeClient([queryResponses([], 0, 0)]);
await provider.listMessages(ARA, {
mailboxId: 'a',
limit: 20,
text: '"회의를 잡겠습니다"',
});
expect(argsOf(recorded[0], 1)).toMatchObject({
filter: { inMailbox: 'a', text: '서버 이전' },
filter: {
operator: 'AND',
conditions: [
{ inMailbox: 'a', notKeyword: '$ara-sending' },
{ text: '"회의를 잡겠습니다"' },
],
},
});
});
it('문자·숫자가 없는 낱말은 빼고 찾는다 — 남은 낱말만 조건이 된다', async () => {
const { provider, recorded } = fakeClient([queryResponses([], 0, 0)]);
await provider.listMessages(ARA, {
mailboxId: 'a',
limit: 20,
text: '회의 - …',
});
const { filter } = argsOf(recorded[0], 1) as {
filter: { conditions: unknown[] };
};
expect(filter.conditions).toHaveLength(2);
});
it('찾을 낱말이 하나도 없는 검색어는 엔진에 묻지 않고 빈 결과 — 메일함 범위 · 모든 메일함 모두 (범위 전체를 돌려주지 않는다)', async () => {
for (const query of [
{ mailboxId: 'a', limit: 20, text: '-' },
{ mailboxId: 'zzzz', limit: 20, text: '"…"' },
{ limit: 20, text: '""' },
{ limit: 20, text: '- … !!' },
]) {
const { provider, client } = fakeClient([]);
await expect(provider.listMessages(ARA, query)).resolves.toEqual({
items: [],
hasNext: false,
nextCursor: null,
total: 0,
});
expect(client.call).not.toHaveBeenCalled();
}
});
it('찾을 낱말이 없어도 커서는 먼저 본다 — 다른 목록의 커서면 VAL_001', async () => {
const cursor = encodeCursor(
{ anchor: 'm2', position: 2 },
{ mailboxId: 'a' },
);
const { provider, client } = fakeClient([]);
expect(
await codeOf(
provider.listMessages(ARA, {
mailboxId: 'a',
limit: 20,
text: '-',
cursor,
}),
),
).toBe('VAL_001');
expect(client.call).not.toHaveBeenCalled();
});
describe('메일함을 정하지 않은 검색 (휴지통 · 스팸메일함을 뺀 모든 메일함)', () => {
/** 메일함 확인 없는 목록 응답 */
const searchResponses = (ids: string[], total: number) =>
queryResponses(ids, 0, total).filter(([, , id]) => id !== 'mb');
const mailboxRoles: JmapInvocation[] = [
[
'Mailbox/get',
{
list: stalwartMailboxes.map(({ id, role }) => ({ id, role })),
},
'm',
],
];
it('휴지통 · 스팸메일함 id 를 먼저 읽어 inMailboxOtherThan 으로 뺀다 — 메일함 확인 없이, 커서는 "모든 메일함" 범위', async () => {
const { provider, recorded } = fakeClient([
mailboxRoles,
searchResponses(['m1', 'm2'], 3),
]);
const page = await provider.listMessages(ARA, {
limit: 2,
text: '회의',
});
expect(argsOf(recorded[0])).toEqual({
accountId: 'd',
ids: null,
properties: ['id', 'role'],
});
expect(recorded[1].calls.map(([method]) => method)).toEqual([
'Email/query',
'Email/get',
]);
expect(argsOf(recorded[1])).toMatchObject({
filter: {
operator: 'AND',
conditions: [
{ inMailboxOtherThan: ['b', 'c'], notKeyword: '$ara-sending' },
{
operator: 'OR',
conditions: expect.arrayContaining([
{ text: '회의' },
{ text: '회의를' },
]) as unknown,
},
],
},
sort: [{ property: 'receivedAt', isAscending: false }],
calculateTotal: true,
});
// 사용자가 보낸 값은 검색어·커서뿐이다 — 엔진이 요청을 거부하면 VAL_001
expect(recorded[1].options).toEqual({ invalidRequestCode: 'VAL_001' });
expect(page.items.map((item) => item.id)).toEqual(['m1', 'm2']);
expect(page.total).toBe(3);
expect(
decodeCursor(page.nextCursor as string, { text: '회의' }),
).toEqual({ anchor: 'm2', position: 2 });
});
it('다음 쪽도 같은 범위로 — 메일함 검색이나 다른 검색어의 커서는 엔진에 묻기 전에 VAL_001', async () => {
const cursor = encodeCursor(
{ anchor: 'm2', position: 2 },
{ text: '회의' },
);
const next = fakeClient([mailboxRoles, searchResponses(['m3'], 3)]);
await next.provider.listMessages(ARA, {
limit: 2,
text: '회의',
cursor,
});
expect(argsOf(next.recorded[1])).toMatchObject({
anchor: 'm2',
anchorOffset: 1,
});
for (const query of [
{ mailboxId: 'a', limit: 2, text: '회의', cursor },
{ limit: 2, text: '일정', cursor },
]) {
const other = fakeClient([]);
expect(await codeOf(other.provider.listMessages(ARA, query))).toBe(
'VAL_001',
);
expect(other.client.call).not.toHaveBeenCalled();
}
});
it('휴지통 · 스팸메일함이 없으면 모든 메일함에서 찾는다 (빼는 조건 없음)', async () => {
const { provider, recorded } = fakeClient([
[
[
'Mailbox/get',
{
list: [
{ id: 'a', role: 'inbox' },
{ id: 'x', role: null },
],
},
'm',
],
],
searchResponses([], 0),
]);
await provider.listMessages(ARA, { limit: 20, text: 'ERP' });
const { filter } = argsOf(recorded[1]) as {
filter: { conditions: unknown[] };
};
expect(filter.conditions[0]).toEqual({ notKeyword: '$ara-sending' });
});
});
@@ -1063,6 +1277,488 @@ describe('JmapMailProvider', () => {
});
});
describe('새 메일 확인 (getMailState)', () => {
const states = (
mailboxState: unknown,
emailState: unknown,
): JmapInvocation[] => [
['Mailbox/get', { state: mailboxState, list: [], notFound: [] }, 'm'],
['Email/get', { state: emailState, list: [], notFound: [] }, 'e'],
];
it('메일을 읽지 않는 조회(ids: []) 두 개를 한 요청으로 — 메일함 · 메일 상태를 이어 붙인 불투명 값', async () => {
const { provider, recorded } = fakeClient([states('sb2', 'saj')]);
const { state } = await provider.getMailState(ARA);
expect(recorded).toHaveLength(1);
expect(recorded[0].calls).toEqual([
['Mailbox/get', { accountId: 'd', ids: [] }, 'm'],
['Email/get', { accountId: 'd', ids: [] }, 'e'],
]);
expect(state).toMatch(/^[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+$/);
});
it('둘 중 하나만 바뀌어도 값이 바뀌고, 다른 두 상태가 같은 값이 되지 않는다', async () => {
const read = async (mailboxState: string, emailState: string) =>
(
await fakeClient([
states(mailboxState, emailState),
]).provider.getMailState(ARA)
).state;
const base = await read('sb2', 'saj');
expect(await read('sb2', 'saj')).toBe(base);
expect(await read('sb3', 'saj')).not.toBe(base);
expect(await read('sb2', 'sak')).not.toBe(base);
// 경계가 다른 두 쌍 ('ab' + 'c' 와 'a' + 'bc')
expect(await read('ab', 'c')).not.toBe(await read('a', 'bc'));
});
it('엔진이 상태를 알려 주지 않으면 MAIL_001 · 메서드 오류도 그 코드', async () => {
for (const [mailboxState, emailState] of [
[undefined, 's1'],
['s1', null],
['', 's1'],
]) {
const { provider } = fakeClient([states(mailboxState, emailState)]);
expect(await codeOf(provider.getMailState(ARA))).toBe('MAIL_001');
}
const failed = fakeClient([
[
['error', { type: 'serverFail' }, 'm'],
['Email/get', { state: 's1', list: [] }, 'e'],
],
]);
expect(await codeOf(failed.provider.getMailState(ARA))).toBe('MAIL_001');
});
});
describe('여러 통 바꾸기 (updateMessages)', () => {
let warnSpy: jest.SpyInstance;
beforeEach(() => {
warnSpy = jest
.spyOn(Logger.prototype, 'warn')
.mockImplementation(() => undefined);
});
afterEach(() => warnSpy.mockRestore());
it('한 요청의 Email/set 하나로 — 메일마다 한 통 PATCH 와 같은 패치, 바꾸지 못한 것은 failedIds (요청 순서)', async () => {
const { provider, recorded } = fakeClient([
[
[
'Email/set',
{
updated: { m1: null, m3: null },
notUpdated: { m2: { type: 'notFound' } },
},
'u',
],
],
]);
const result = await provider.updateMessages(ARA, ['m1', 'm2', 'm3'], {
isRead: true,
isFlagged: false,
});
expect(recorded).toHaveLength(1);
expect(recorded[0].calls).toEqual([
[
'Email/set',
{
accountId: 'd',
update: {
m1: { 'keywords/$seen': true, 'keywords/$flagged': null },
m2: { 'keywords/$seen': true, 'keywords/$flagged': null },
m3: { 'keywords/$seen': true, 'keywords/$flagged': null },
},
},
'u',
],
]);
// id 는 요청 본문에서 왔다 — 엔진이 요청을 거부하면 VAL_001
expect(recorded[0].options).toEqual({ invalidRequestCode: 'VAL_001' });
expect(result).toEqual({ updatedIds: ['m1', 'm3'], failedIds: ['m2'] });
// 그사이 지워진 것(notFound)은 흔한 경합이라 경고하지 않는다
expect(warnSpy).not.toHaveBeenCalled();
});
it('이동은 옮길 메일함 확인과 Email/set 을 같은 요청에 — 그 메일함 하나에만 속하게', async () => {
const { provider, recorded } = fakeClient([
[
['Mailbox/get', { list: [{ id: 'b' }], notFound: [] }, 'mb'],
['Email/set', { updated: { m1: null, m2: null } }, 'u'],
],
]);
const result = await provider.updateMessages(ARA, ['m1', 'm2'], {
mailboxId: 'b',
});
expect(recorded[0].calls).toEqual([
[
'Mailbox/get',
{ accountId: 'd', ids: ['b'], properties: ['id'] },
'mb',
],
[
'Email/set',
{
accountId: 'd',
update: {
m1: { mailboxIds: { b: true } },
m2: { mailboxIds: { b: true } },
},
},
'u',
],
]);
expect(result).toEqual({ updatedIds: ['m1', 'm2'], failedIds: [] });
});
it('옮길 메일함이 없으면 404 RES_001 — 엔진이 메일마다 이동을 거절해 아무것도 바뀌지 않았다', async () => {
const { provider, recorded } = fakeClient([
[
['Mailbox/get', { list: [], notFound: ['zzzz'] }, 'mb'],
[
'Email/set',
{
notUpdated: {
m1: { type: 'invalidProperties', properties: ['mailboxIds'] },
m2: { type: 'invalidProperties', properties: ['mailboxIds'] },
},
},
'u',
],
],
]);
expect(
await codeOf(
provider.updateMessages(ARA, ['m1', 'm2'], {
isRead: true,
mailboxId: 'zzzz',
}),
),
).toBe('RES_001');
// 읽음 표시도 같은 패치라 메일마다 통째로 거절된다 — 이동만 빠지고 읽음만 바뀌는 일은 없다
const set = argsOf(recorded[0], 1) as {
update: Record<string, Record<string, unknown>>;
};
expect(set.update.m1).toEqual({
'keywords/$seen': true,
mailboxIds: { zzzz: true },
});
expect(recorded).toHaveLength(1);
});
it('없는 메일함인데 엔진이 바꿨다고 답하면(있을 수 없는 경우) 사실대로 돌려주고 경고한다', async () => {
const { provider } = fakeClient([
[
['Mailbox/get', { list: [], notFound: ['k'] }, 'mb'],
['Email/set', { updated: { m1: null } }, 'u'],
],
]);
await expect(
provider.updateMessages(ARA, ['m1'], { mailboxId: 'k' }),
).resolves.toEqual({ updatedIds: ['m1'], failedIds: [] });
expect(warnSpy).toHaveBeenCalledTimes(1);
});
it('메일마다의 거절(notFound 밖)은 failedIds 로 — 종류와 개수만 경고한다 (id 는 남기지 않는다)', async () => {
const { provider } = fakeClient([
[
[
'Email/set',
{
updated: { m1: null },
notUpdated: {
m2: { type: 'forbidden' },
m3: { type: 'forbidden' },
m4: { type: 'notFound' },
},
},
'u',
],
],
]);
const result = await provider.updateMessages(
ARA,
['m1', 'm2', 'm3', 'm4'],
{ isFlagged: true },
);
expect(result).toEqual({
updatedIds: ['m1'],
failedIds: ['m2', 'm3', 'm4'],
});
expect(warnSpy).toHaveBeenCalledTimes(1);
const [line] = warnSpy.mock.calls[0] as [string];
expect(line).toContain('jmapType=forbidden');
expect(line).toContain('2통');
expect(line).not.toMatch(/m2|m3/);
expect(line).not.toContain(ARA);
});
it('엔진이 바꿨다고 알려 준 것만 updatedIds — 응답에 없는 것은 failedIds', async () => {
const { provider } = fakeClient([
[['Email/set', { updated: { m1: null } }, 'u']],
]);
await expect(
provider.updateMessages(ARA, ['m1', 'm2'], { isRead: false }),
).resolves.toEqual({ updatedIds: ['m1'], failedIds: ['m2'] });
});
it('메서드 단위 실패는 그 코드 — 엔진 장애 MAIL_001', async () => {
const { provider } = fakeClient([
[['error', { type: 'serverFail' }, 'u']],
]);
expect(
await codeOf(provider.updateMessages(ARA, ['m1'], { isRead: true })),
).toBe('MAIL_001');
});
});
describe('휴지통에서 영구 삭제 (deleteFromTrash)', () => {
let warnSpy: jest.SpyInstance;
beforeEach(() => {
warnSpy = jest
.spyOn(Logger.prototype, 'warn')
.mockImplementation(() => undefined);
});
afterEach(() => warnSpy.mockRestore());
/** 확인 요청이 돌려주는 메일 (id · 들어 있는 메일함) */
type TrashCandidate = { id: string; mailboxIds: Record<string, boolean> };
/** 메일 + 메일함 확인 — 그때의 Email 상태 (null 이면 엔진이 상태를 알려 주지 않는다) */
const lookup = (
emails: TrashCandidate[],
state: string | null = 's1',
mailboxes: unknown[] = stalwartMailboxes,
): JmapInvocation[] => [
[
'Email/get',
{ ...(state === null ? {} : { state }), list: emails, notFound: [] },
'g',
],
['Mailbox/get', { list: mailboxes }, 'm'],
];
const mismatch: JmapInvocation[] = [
['error', { type: 'stateMismatch' }, 'd'],
];
/** 휴지통에만 (t1) · 휴지통과 받은편지함에 (t2) · 받은편지함에만 (i1) — 'gone' 은 없다 */
const mixed: TrashCandidate[] = [
{ id: 't1', mailboxIds: { b: true } },
{ id: 't2', mailboxIds: { b: true, a: true } },
{ id: 'i1', mailboxIds: { a: true } },
];
it('휴지통에만 있으면 영구 삭제, 다른 메일함에도 있으면 휴지통에서만 뺀다 — 확인한 상태 그대로일 때 한 번에 (ifInState)', async () => {
const { provider, recorded } = fakeClient([
lookup(mixed),
[['Email/set', { destroyed: ['t1'], updated: { t2: null } }, 'd']],
]);
const result = await provider.deleteFromTrash(ARA, [
't1',
'gone',
't2',
'i1',
]);
expect(recorded[0].calls).toEqual([
[
'Email/get',
{
accountId: 'd',
ids: ['t1', 'gone', 't2', 'i1'],
properties: ['id', 'mailboxIds'],
},
'g',
],
[
'Mailbox/get',
{ accountId: 'd', ids: null, properties: ['id', 'role'] },
'm',
],
]);
expect(recorded[0].options).toEqual({ invalidRequestCode: 'VAL_001' });
expect(argsOf(recorded[1])).toEqual({
accountId: 'd',
ifInState: 's1',
destroy: ['t1'],
update: { t2: { 'mailboxIds/b': null } },
});
expect(result).toEqual({
deletedIds: ['t1', 't2'],
skippedIds: ['gone', 'i1'],
});
});
it('휴지통 밖의 메일은 옮기지 않는다 — 지울 것이 없으면 지우는 요청 없이 모두 건너뛴다', async () => {
const { provider, recorded } = fakeClient([
lookup([{ id: 'i1', mailboxIds: { a: true } }]),
]);
await expect(
provider.deleteFromTrash(ARA, ['i1', 'gone']),
).resolves.toEqual({ deletedIds: [], skippedIds: ['i1', 'gone'] });
expect(recorded).toHaveLength(1);
});
it('휴지통이 없으면 아무것도 지우지 않고 MAIL_001', async () => {
const { provider, recorded } = fakeClient([
lookup(
[{ id: 't1', mailboxIds: { a: true } }],
's1',
stalwartMailboxes.filter((mailbox) => mailbox.role !== 'trash'),
),
]);
expect(await codeOf(provider.deleteFromTrash(ARA, ['t1']))).toBe(
'MAIL_001',
);
expect(recorded).toHaveLength(1);
});
it('상태가 어긋나면(그사이 다른 창이 바꿈) 다시 확인해 그때의 대상 · 상태로 지운다', async () => {
const { provider, recorded } = fakeClient([
lookup(mixed),
mismatch,
// 다시 보니 t2 는 휴지통 밖으로 되돌려졌다 — 지우지 않는다
lookup(
[
{ id: 't1', mailboxIds: { b: true } },
{ id: 't2', mailboxIds: { a: true } },
],
's2',
),
[['Email/set', { destroyed: ['t1'] }, 'd']],
]);
const result = await provider.deleteFromTrash(ARA, ['t1', 't2']);
expect(argsOf(recorded[3])).toEqual({
accountId: 'd',
ifInState: 's2',
destroy: ['t1'],
});
expect(result).toEqual({ deletedIds: ['t1'], skippedIds: ['t2'] });
});
it('세 번 모두 어긋나면 아무것도 지우지 않고 MAIL_001 — 다시 시도하면 된다', async () => {
const { provider, recorded } = fakeClient([
lookup(mixed),
mismatch,
lookup(mixed, 's2'),
mismatch,
lookup(mixed, 's3'),
mismatch,
]);
expect(await codeOf(provider.deleteFromTrash(ARA, ['t1', 't2']))).toBe(
'MAIL_001',
);
expect(recorded).toHaveLength(6);
// 지우는 요청은 모두 조건부였다 — 엔진이 거절해 아무것도 지워지지 않았다
for (const index of [1, 3, 5]) {
expect(argsOf(recorded[index])).toHaveProperty('ifInState');
}
});
it('지우는 순간 없어졌으면(notFound) 건너뛴 것으로 — 그 밖의 거절도 건너뛰고 종류 · 개수만 경고한다', async () => {
const { provider } = fakeClient([
lookup([
{ id: 't1', mailboxIds: { b: true } },
{ id: 't2', mailboxIds: { b: true } },
{ id: 't3', mailboxIds: { b: true, a: true } },
{ id: 't4', mailboxIds: { b: true } },
]),
[
[
'Email/set',
{
destroyed: ['t1'],
notDestroyed: {
t2: { type: 'notFound' },
t4: { type: 'forbidden' },
},
notUpdated: { t3: { type: 'forbidden' } },
},
'd',
],
],
]);
const result = await provider.deleteFromTrash(ARA, [
't1',
't2',
't3',
't4',
]);
expect(result).toEqual({
deletedIds: ['t1'],
skippedIds: ['t2', 't3', 't4'],
});
expect(warnSpy).toHaveBeenCalledTimes(1);
const [line] = warnSpy.mock.calls[0] as [string];
expect(line).toContain('[AUTH_003]');
expect(line).toContain('jmapType=forbidden');
expect(line).toContain('2통');
expect(line).not.toMatch(/t3|t4/);
});
it('엔진이 돌려준 id 가 요청한 id 와 다르면(정규화) 지우지 않는다', async () => {
const { provider, recorded } = fakeClient([
lookup([{ id: 'eaaaaab', mailboxIds: { b: true } }]),
]);
await expect(provider.deleteFromTrash(ARA, ['EAAAAAB'])).resolves.toEqual(
{ deletedIds: [], skippedIds: ['EAAAAAB'] },
);
expect(recorded).toHaveLength(1);
});
it('상태를 모르면 조건 없이 지우지 않는다 (MAIL_001) — 지울 것이 없으면 묻지 않는다', async () => {
const unknown = fakeClient([lookup(mixed, null)]);
expect(await codeOf(unknown.provider.deleteFromTrash(ARA, ['t1']))).toBe(
'MAIL_001',
);
expect(unknown.recorded).toHaveLength(1);
const nothing = fakeClient([
lookup([{ id: 'i1', mailboxIds: { a: true } }], null),
]);
await expect(
nothing.provider.deleteFromTrash(ARA, ['i1']),
).resolves.toEqual({ deletedIds: [], skippedIds: ['i1'] });
});
it('지우기의 그 밖의 메서드 오류는 그 코드 — 조건 없이 다시 지우지 않는다', async () => {
const { provider, recorded } = fakeClient([
lookup(mixed),
[['error', { type: 'serverFail' }, 'd']],
]);
expect(await codeOf(provider.deleteFromTrash(ARA, ['t1']))).toBe(
'MAIL_001',
);
expect(recorded).toHaveLength(2);
});
});
describe('발송 (sendMessage)', () => {
const outgoing: OutgoingMessage = {
to: [{ name: '이도윤', email: DOYUN }],
@@ -20,10 +20,13 @@ import type {
MailAddress,
MailAttachment,
Mailbox,
MailState,
MailUsage,
MessageDeletionResult,
MessageDetailSource,
MessageListQuery,
MessagePage,
MessagesPatchResult,
MessageSummary,
MessageUpdate,
OutgoingAttachment,
@@ -42,7 +45,11 @@ import {
type JmapSession,
} from './jmap.client';
import { decodeCursor, encodeCursor } from './jmap-cursor';
import { isMissingAttachmentBlob, jmapFailure } from './jmap.errors';
import {
isMissingAttachmentBlob,
jmapFailure,
mapJmapErrorType,
} from './jmap.errors';
import {
BODY_PART_PROPERTIES,
DETAIL_PROPERTIES,
@@ -63,6 +70,8 @@ import {
import type {
JmapBlob,
JmapEmail,
JmapEmailFilter,
JmapEmailFilterCondition,
JmapError,
JmapGetResult,
JmapIdentity,
@@ -72,6 +81,7 @@ import type {
JmapQuota,
JmapSetResult,
} from './jmap.types';
import { searchTextConditions, withSearchConditions } from './search-terms';
const QUOTA_CAPABILITY = 'urn:ietf:params:jmap:quota';
/** Blob/get (RFC 9404) — 첨부 합계 크기를 발송 전에 잰다 */
@@ -123,7 +133,7 @@ const RECEIVED_ADDRESS_PROPERTIES = ['from', 'receivedAt'];
/**
* 조건부 영구 삭제(ifInState)에서 상태가 어긋나면 다시 확인하고 지우는 횟수 — 지우기 요청을 모두 합쳐.
* 사용자 초안(교체 저장·보낸 뒤)과 휴지통 비우기(DELETE /api/messages/{id})가 함께 쓴다.
* 사용자 초안(교체 저장·보낸 뒤)과 휴지통에서 영구 삭제(DELETE /api/messages/{id} · POST /api/message-deletions)가 함께 쓴다.
* 계정의 메일이 하나라도 바뀌면(새 메일 도착·다른 메일의 읽음 표시 포함) 상태가 바뀌므로 바쁜 메일함에서는 어긋날 수 있다 —
* 그래도 이 횟수 안에 맞지 않으면 지우지 않는다.
*/
@@ -132,6 +142,12 @@ const CONDITIONAL_DESTROY_ATTEMPTS = 3;
/** JMAP 메서드 오류 — ifInState 가 지금 상태와 다르다 (RFC 8620 §5.3) */
const STATE_MISMATCH = 'stateMismatch';
/** SetError — 그 대상이 없다 (그사이 지워짐) */
const NOT_FOUND = 'notFound';
/** 메일함을 정하지 않은 검색에서 빼는 메일함 역할 — 휴지통 · 스팸메일함 (계약 2절 "검색") */
const SEARCH_EXCLUDED_ROLES: ReadonlySet<string> = new Set(['trash', 'junk']);
/**
* 발송 요청 하나가 엔진을 기다리는 전체 시간 — 클라이언트의 발송 시간 한도(60초)보다 짧게 둔다
* (백엔드가 먼저 답해야 사용자가 같은 키로 다시 보낼 수 있다 — jmap.client.ts 의 JMAP_SEND_TIMEOUT_MS 주석).
@@ -251,6 +267,80 @@ function isUserDraft(email: JmapEmail, draftsId: string): boolean {
);
}
/** 읽음 · 중요 표시 · 이동을 JMAP 패치로 — 한 통(updateMessage)과 여러 통(updateMessages)이 같은 패치를 쓴다 */
function toEmailPatch(update: MessageUpdate): Record<string, unknown> {
const patch: Record<string, unknown> = {};
if (update.isRead !== undefined) {
patch['keywords/$seen'] = update.isRead ? true : null;
}
if (update.isFlagged !== undefined) {
patch['keywords/$flagged'] = update.isFlagged ? true : null;
}
if (update.mailboxId !== undefined) {
// 이동 = 이 메일함 하나에만 속하게 한다
patch.mailboxIds = { [update.mailboxId]: true };
}
return patch;
}
/** 휴지통에서 지우는 방법 — 영구 삭제 · 휴지통에서만 빼기 */
type TrashRemoval = 'destroy' | 'untrash';
/**
* 휴지통의 메일을 어떻게 지울지 — 휴지통에만 있으면 영구 삭제, 휴지통과 다른 메일함에 함께 있으면 휴지통에서만 뺀다
* (영구 삭제하면 다른 메일함의 사본까지 사라진다). 휴지통에 없으면 null — 지우지 않는다.
* 한 통 삭제(purgeFromTrash)와 여러 통 영구 삭제(deleteFromTrash)가 같은 규칙을 쓴다.
* @param mailboxIds 메일이 들어 있는 메일함 (inMailboxes)
*/
function trashRemoval(
mailboxIds: readonly string[],
trashId: string,
): TrashRemoval | null {
if (!mailboxIds.includes(trashId)) return null;
return mailboxIds.every((mailboxId) => mailboxId === trashId)
? 'destroy'
: 'untrash';
}
/**
* 확인한 상태 그대로일 때만(ifInState) 휴지통에서 지우는 Email/set 인자 — 영구 삭제와 휴지통에서 빼기를 한 번에.
* 상태가 어긋나면 엔진이 `stateMismatch` 로 거절하고 아무것도 하지 않는다 (2026-10-27 개발 엔진 확인).
*/
function trashRemovalSet(
accountId: string,
ifInState: string,
trashId: string,
destroy: readonly string[],
untrash: readonly string[],
): Record<string, unknown> {
return {
accountId,
ifInState,
...(destroy.length > 0 ? { destroy: [...destroy] } : {}),
...(untrash.length > 0
? {
update: Object.fromEntries(
untrash.map((id) => [id, { [`mailboxIds/${trashId}`]: null }]),
),
}
: {}),
};
}
/** 엔진이 알려 준 상태 문자열인가 (비었거나 없으면 아니다) */
function isState(value: unknown): value is string {
return typeof value === 'string' && value !== '';
}
/**
* 새 메일 확인 값 — 메일함 상태와 메일 상태를 이어 붙인다 (클라이언트에게는 불투명).
* 각각 base64url 로 바꿔 '.' 으로 잇는다 — base64url 에는 '.' 이 없어 서로 다른 두 상태 쌍이 같은 값이 되지 않는다.
*/
function combineMailStates(mailboxState: string, emailState: string): string {
const encode = (state: string) => Buffer.from(state).toString('base64url');
return `${encode(mailboxState)}.${encode(emailState)}`;
}
/** 메일에 함께 싣는 그림 — 엔진에 올린 blob */
interface UploadedInlineImage {
readonly blobId: string;
@@ -420,37 +510,87 @@ export class JmapMailProvider implements MailProvider {
return this.attachmentsTotalLimit(await this.jmap.getSession(user));
}
/**
* 새 메일 확인 — 메일함·메일의 상태 문자열만 읽는다 (`ids: []` — 메일을 읽지 않는 가벼운 조회, 요청 한 번).
* 새 메일이 오거나 다른 창·메일 프로그램이 읽음·이동·삭제를 하면 둘 중 하나가 바뀐다 (2026-10-27 개발 엔진 확인).
*/
async getMailState(user: string): Promise<MailState> {
const responses = await this.jmap.call(user, (accountId) => [
['Mailbox/get', { accountId, ids: [] }, 'm'],
['Email/get', { accountId, ids: [] }, 'e'],
]);
const mailboxes = expectResult<JmapGetResult<JmapMailbox>>(
responses,
'm',
'Mailbox/get',
);
const emails = expectResult<JmapGetResult<JmapEmail>>(
responses,
'e',
'Email/get',
);
if (!isState(mailboxes.state) || !isState(emails.state)) {
throw new BusinessException('MAIL_001', {
context: { reason: 'mail-state-missing' },
});
}
return { state: combineMailStates(mailboxes.state, emails.state) };
}
async listMessages(
user: string,
query: MessageListQuery,
): Promise<MessagePage> {
const scope = { mailboxId: query.mailboxId, text: query.text };
// 커서는 엔진에 묻기 전에 본다 — 다른 메일함·검색어의 커서면 VAL_001
const resume = query.cursor ? decodeCursor(query.cursor, scope) : null;
// 검색어에 찾을 낱말(문자·숫자)이 하나도 없으면(`-` · `…` 등) 엔진에 묻지 않고 빈 결과 — 범위 전체를 돌려주지 않는다.
// 메일함 범위여도 그 메일함을 확인하지 않는다 (계약 2절 "검색")
const textConditions =
query.text !== undefined ? searchTextConditions(query.text) : [];
if (query.text !== undefined && textConditions.length === 0) {
return { items: [], hasNext: false, nextCursor: null, total: 0 };
}
// 범위 — 메일함 하나, 또는 휴지통·스팸메일함을 뺀 모든 메일함(검색). 발송 중에 생긴 내부 초안은 목록에 보이지 않는다 —
// 사용자의 임시 저장본과 섞이지 않게 (계약 2절 "임시 저장"). 보낸 사본은 제출할 때 키워드를 떼므로 보낸편지함에서는 그대로 보인다
const range: JmapEmailFilterCondition =
query.mailboxId !== undefined
? { inMailbox: query.mailboxId }
: await this.searchableMailboxes(user);
const filter = withSearchConditions(
{ ...range, notKeyword: INTERNAL_SEND_KEYWORD },
textConditions,
);
let responses = await this.queryMessages(
user,
query,
filter,
resume ? { anchor: resume.anchor } : { position: 0 },
);
// 마지막으로 본 메일이 그 사이에 지워지거나 옮겨졌으면 위치로 이어 읽는다
const first = findResult(responses, 'q', 'Email/query');
if (resume && !first.ok && first.error.type === 'anchorNotFound') {
responses = await this.queryMessages(user, query, {
responses = await this.queryMessages(user, query, filter, {
position: resume.position,
});
}
const mailbox = expectResult<JmapGetResult<JmapMailbox>>(
responses,
'mb',
'Mailbox/get',
);
if (!mailbox.list?.length) {
// 없는 메일함 — 계약 2절 "표에 없는 실패": 404 RES_001
throw new BusinessException('RES_001', {
context: { reason: 'mailbox-not-found' },
});
if (query.mailboxId !== undefined) {
const mailbox = expectResult<JmapGetResult<JmapMailbox>>(
responses,
'mb',
'Mailbox/get',
);
if (!mailbox.list?.length) {
// 없는 메일함 — 계약 2절 "표에 없는 실패": 404 RES_001
throw new BusinessException('RES_001', {
context: { reason: 'mailbox-not-found' },
});
}
}
const result = expectResult<JmapQueryResult>(responses, 'q', 'Email/query');
@@ -520,17 +660,7 @@ export class JmapMailProvider implements MailProvider {
id: string,
update: MessageUpdate,
): Promise<MessageSummary> {
const patch: Record<string, unknown> = {};
if (update.isRead !== undefined) {
patch['keywords/$seen'] = update.isRead ? true : null;
}
if (update.isFlagged !== undefined) {
patch['keywords/$flagged'] = update.isFlagged ? true : null;
}
if (update.mailboxId !== undefined) {
// 이동 = 이 메일함 하나에만 속하게 한다
patch.mailboxIds = { [update.mailboxId]: true };
}
const patch = toEmailPatch(update);
const responses = await this.jmap.call(
user,
@@ -557,6 +687,73 @@ export class JmapMailProvider implements MailProvider {
return toSummary(this.requireEmail(responses, 'g'));
}
/**
* 여러 통에 같은 변경을 한다 — 엔진 요청 한 번(옮길 메일함 확인 + Email/set). 메일마다 패치는 한 통 PATCH 와 같다.
* - 바꾸지 못한 메일(없음 · 그사이 지워짐 · 엔진이 거절)은 failedIds — 나머지는 그대로 처리한다
* - 옮길 메일함이 없으면 404 RES_001 — 엔진은 없는 메일함으로의 이동을 메일마다 거절하므로(invalidProperties)
* 같은 요청의 Email/set 도 아무것도 바꾸지 않는다. 메일 하나가 바뀐 것처럼 오면(있을 수 없는 경우) 사실대로 돌려준다
* - 메서드 단위 실패는 그 코드(엔진 장애 MAIL_001) — 일부만 바뀌었을 수 있다 (같은 요청을 다시 보내도 결과가 같다)
* id 는 요청 본문에서 왔다 — 엔진이 그 형식을 해석하지 못해 요청 전체를 거부하면 VAL_001 (아무것도 바꾸지 않았다)
*/
async updateMessages(
user: string,
ids: readonly string[],
update: MessageUpdate,
): Promise<MessagesPatchResult> {
const patch = toEmailPatch(update);
const { mailboxId } = update;
const responses = await this.jmap.call(
user,
(accountId) => [
...(mailboxId !== undefined
? [
[
'Mailbox/get',
{ accountId, ids: [mailboxId], properties: ['id'] },
'mb',
] as JmapInvocation,
]
: []),
[
'Email/set',
{
accountId,
update: Object.fromEntries(ids.map((id) => [id, patch])),
},
'u',
],
],
{ invalidRequestCode: 'VAL_001' },
);
const set = expectResult<JmapSetResult>(responses, 'u', 'Email/set');
const updated = new Set(Object.keys(set.updated ?? {}));
const updatedIds = ids.filter((id) => updated.has(id));
const failedIds = ids.filter((id) => !updated.has(id));
if (mailboxId !== undefined) {
const mailbox = expectResult<JmapGetResult<JmapMailbox>>(
responses,
'mb',
'Mailbox/get',
);
if (!mailbox.list?.length) {
if (updatedIds.length === 0) {
throw new BusinessException('RES_001', {
context: { reason: 'mailbox-not-found', requested: ids.length },
});
}
this.logger.warn(
`[MAIL_001]${traceTag()} 없는 메일함으로 옮긴 메일이 있다고 엔진이 답했습니다 — 결과를 그대로 돌려줍니다: user=${maskEmail(user)}, 바뀜=${updatedIds.length}통`,
);
}
}
this.warnRefusals(user, set.notUpdated, '바꾸지');
return { updatedIds, failedIds };
}
async deleteMessage(user: string, id: string): Promise<void> {
const lookup = await this.jmap.call(
user,
@@ -666,20 +863,20 @@ export class JmapMailProvider implements MailProvider {
snapshot = { state: found.state, mailboxIds: inMailboxes(email) };
}
const onlyInTrash = snapshot.mailboxIds.every(
(mailboxId) => mailboxId === trashId,
);
// 확인한 대로면 늘 휴지통에 있다 — 아니면 지우지 않는다
const removal = trashRemoval(snapshot.mailboxIds, trashId);
if (removal === null) return;
const ifInState = snapshot.state;
const responses = await this.jmap.call(user, (accountId) => [
[
'Email/set',
onlyInTrash
? { accountId, ifInState, destroy: [id] }
: {
accountId,
ifInState,
update: { [id]: { [`mailboxIds/${trashId}`]: null } },
},
trashRemovalSet(
accountId,
ifInState,
trashId,
removal === 'destroy' ? [id] : [],
removal === 'untrash' ? [id] : [],
),
'd',
],
]);
@@ -693,10 +890,10 @@ export class JmapMailProvider implements MailProvider {
}
const set = result.args as JmapSetResult;
const failure = firstSetError(
onlyInTrash ? set.notDestroyed : set.notUpdated,
removal === 'destroy' ? set.notDestroyed : set.notUpdated,
);
// 지웠다 · 휴지통에서 뺐다 · 그사이 없어졌다
if (!failure || failure.type === 'notFound') return;
if (!failure || failure.type === NOT_FOUND) return;
throw jmapFailure(failure, { method: 'Email/set' });
}
throw new BusinessException('MAIL_001', {
@@ -704,6 +901,120 @@ export class JmapMailProvider implements MailProvider {
});
}
/**
* 휴지통에서 영구 삭제 (여러 통) — 휴지통에 있는 메일만 다룬다. 휴지통 밖의 메일을 휴지통으로 옮기지 않는다 (DELETE 한 통과 다르다).
* 한 번의 시도: 메일·메일함 확인(요청 1) → 확인한 상태 그대로일 때만 한 번에 지우기(요청 2, ifInState).
* - 휴지통에만 있으면 영구 삭제, 다른 메일함에도 있으면 휴지통에서만 뺀다 (trashRemoval — 한 통 삭제와 같은 규칙)
* - 없음 · 휴지통에 없음 → skippedIds (확인한 뒤 다른 창이 휴지통 밖으로 되돌린 메일은 상태가 어긋나 지우지 않는다)
* - 지우는 순간 메일마다의 거절: 없어졌으면(notFound) 건너뛴 것으로, 그 밖의 거절도 건너뛴 것으로(종류·개수만 경고)
* - 상태가 어긋나면 다시 확인한다 — 합계 CONDITIONAL_DESTROY_ATTEMPTS 번이 모두 어긋나면 아무것도 지우지 않고 MAIL_001
* - 휴지통이 없으면 MAIL_001 (지우지 않는다)
* id 는 요청 본문에서 왔다 — 엔진이 그 형식을 해석하지 못해 확인 요청 전체를 거부하면 VAL_001 (지우지 않았다)
*/
async deleteFromTrash(
user: string,
ids: readonly string[],
): Promise<MessageDeletionResult> {
for (
let attempt = 0;
attempt < CONDITIONAL_DESTROY_ATTEMPTS;
attempt += 1
) {
const lookup = await this.jmap.call(
user,
(accountId) => [
[
'Email/get',
{ accountId, ids: [...ids], properties: ['id', 'mailboxIds'] },
'g',
],
[
'Mailbox/get',
{ accountId, ids: null, properties: ['id', 'role'] },
'm',
],
],
{ invalidRequestCode: 'VAL_001' },
);
const found = expectResult<JmapGetResult<JmapEmail>>(
lookup,
'g',
'Email/get',
);
const { list: mailboxes = [] } = expectResult<JmapGetResult<JmapMailbox>>(
lookup,
'm',
'Mailbox/get',
);
const trashId = roleMailboxId(mailboxes, 'trash');
if (!trashId) {
throw new BusinessException('MAIL_001', {
context: { reason: 'trash-mailbox-missing' },
});
}
// 요청한 id 그대로 찾는다 — 엔진이 돌려준 id 가 다르면(정규화) 지우지 않고 건너뛴다
const byId = new Map(
(found.list ?? []).map((email) => [email.id, email]),
);
const destroy: string[] = [];
const untrash: string[] = [];
for (const id of ids) {
const email = byId.get(id);
const removal = email
? trashRemoval(inMailboxes(email), trashId)
: null;
if (removal === 'destroy') destroy.push(id);
else if (removal === 'untrash') untrash.push(id);
}
if (destroy.length === 0 && untrash.length === 0) {
return { deletedIds: [], skippedIds: [...ids] };
}
// 상태를 모르면 조건 없이 지우지 않는다
if (!isState(found.state)) {
throw new BusinessException('MAIL_001', {
context: { reason: 'trash-state-unknown' },
});
}
const ifInState = found.state;
const responses = await this.jmap.call(user, (accountId) => [
[
'Email/set',
trashRemovalSet(accountId, ifInState, trashId, destroy, untrash),
'd',
],
]);
const result = findResult(responses, 'd', 'Email/set');
if (!result.ok) {
// 확인한 뒤 계정의 메일이 바뀌었다 — 아무것도 하지 않았으니 다시 확인한다
if (result.error.type === STATE_MISMATCH) continue;
throw jmapFailure(result.error, { method: 'Email/set' });
}
const set = result.args as JmapSetResult;
// 엔진이 지웠다 · 휴지통에서 뺐다고 답한 것 중 이 요청이 지우려던 것만
const destroyed = new Set(set.destroyed ?? []);
const untrashed = new Set(Object.keys(set.updated ?? {}));
const deleted = new Set([
...destroy.filter((id) => destroyed.has(id)),
...untrash.filter((id) => untrashed.has(id)),
]);
this.warnRefusals(
user,
{ ...set.notDestroyed, ...set.notUpdated },
'휴지통에서 지우지',
);
return {
deletedIds: ids.filter((id) => deleted.has(id)),
skippedIds: ids.filter((id) => !deleted.has(id)),
};
}
throw new BusinessException('MAIL_001', {
context: { reason: 'trash-delete-contended', requested: ids.length },
});
}
async sendMessage(
user: string,
message: OutgoingMessage,
@@ -1354,31 +1665,58 @@ export class JmapMailProvider implements MailProvider {
}
/**
* 메일함 확인 + 목록 조회 + 요약 조회를 한 요청으로 보낸다.
* 사용자가 보낸 mailboxId 가 필터에 들어가므로, 엔진이 해석하지 못하면 없는 메일함과 같게 RES_001 로 응답한다
* 메일함을 정하지 않은 검색의 범위 — 휴지통·스팸메일함을 뺀 모든 메일함 (계약 2절 "검색").
* 그 메일함 id 를 먼저 읽는다 (필터 안에서는 다른 메서드의 결과를 참조할 수 없다). 그런 역할의 메일함이 없으면 모든 메일함이다.
* `inMailboxOtherThan` 은 그 밖의 메일함에 하나라도 들어 있는 메일이다 — 휴지통에만 있는 메일이 빠진다 (RFC 8621 §4.4.1).
*/
private async searchableMailboxes(
user: string,
): Promise<JmapEmailFilterCondition> {
const lookup = await this.jmap.call(user, (accountId) => [
[
'Mailbox/get',
{ accountId, ids: null, properties: ['id', 'role'] },
'm',
],
]);
const { list: mailboxes = [] } = expectResult<JmapGetResult<JmapMailbox>>(
lookup,
'm',
'Mailbox/get',
);
const excluded = mailboxes
.filter((mailbox) =>
SEARCH_EXCLUDED_ROLES.has(mailbox.role?.toLowerCase() ?? ''),
)
.map((mailbox) => mailbox.id);
return excluded.length > 0 ? { inMailboxOtherThan: excluded } : {};
}
/**
* (메일함 확인 +) 목록 조회 + 요약 조회를 한 요청으로 보낸다.
* 메일함 범위면 사용자가 보낸 mailboxId 가 필터에 들어가므로, 엔진이 해석하지 못하면 없는 메일함과 같게 RES_001 로 응답한다
* (Stalwart 는 필터의 id 형식이 틀리면 요청 전체를 notRequest 로 거부한다).
* 모든 메일함 검색에서 사용자가 보낸 값은 검색어와 커서뿐이라 그때는 VAL_001 이다.
*/
private queryMessages(
user: string,
query: MessageListQuery,
filter: JmapEmailFilter,
start: { anchor: string } | { position: number },
): Promise<JmapInvocation[]> {
// 발송 중에 생긴 내부 초안은 목록에 보이지 않는다 — 사용자의 임시 저장본과 섞이지 않게 (계약 2절 "임시 저장").
// 보낸 사본은 제출할 때 키워드를 떼므로 보낸편지함에서는 그대로 보인다
const filter = {
inMailbox: query.mailboxId,
notKeyword: INTERNAL_SEND_KEYWORD,
...(query.text ? { text: query.text } : {}),
};
const { mailboxId } = query;
return this.jmap.call(
user,
(accountId) => [
[
'Mailbox/get',
{ accountId, ids: [query.mailboxId], properties: ['id'] },
'mb',
],
...(mailboxId !== undefined
? [
[
'Mailbox/get',
{ accountId, ids: [mailboxId], properties: ['id'] },
'mb',
] as JmapInvocation,
]
: []),
[
'Email/query',
{
@@ -1405,10 +1743,33 @@ export class JmapMailProvider implements MailProvider {
'g',
],
],
{ invalidRequestCode: 'RES_001' },
{ invalidRequestCode: mailboxId !== undefined ? 'RES_001' : 'VAL_001' },
);
}
/**
* 여러 통을 처리하다 엔진이 메일마다 거절한 것 — 종류별 개수만 경고로 남긴다 (id 목록은 남기지 않는다).
* 그사이 없어진 것(notFound)은 흔한 경합이라 남기지 않는다.
* @param doing 로그 문장 — '바꾸지' · '휴지통에서 지우지'
*/
private warnRefusals(
user: string,
errors: Record<string, JmapError> | undefined,
doing: string,
): void {
const counts = new Map<string, number>();
for (const error of Object.values(errors ?? {})) {
const type = error?.type ?? 'unknown';
if (type === NOT_FOUND) continue;
counts.set(type, (counts.get(type) ?? 0) + 1);
}
for (const [type, count] of counts) {
this.logger.warn(
`[${mapJmapErrorType(type)}]${traceTag()} 엔진이 거절해 ${doing} 못한 메일이 있습니다: user=${maskEmail(user)}, jmapType=${type}, ${count}통`,
);
}
}
/**
* 첨부 합계 크기 확인 — 업로드한 파일과 전달하는 원본 첨부 모두 Blob/get 으로 잰다.
* 한도는 MAIL_ATTACHMENT_TOTAL_MAX_SIZE_MB 와 엔진의 메일 한 통 첨부 한도 중 작은 값이다.
@@ -121,6 +121,28 @@ export interface JmapSetResult {
notDestroyed?: Record<string, JmapError>;
}
/**
* Email/query 의 FilterCondition 중 이 어댑터가 쓰는 것 (RFC 8621 §4.4.1) — 한 객체 안의 조건은 모두 맞아야 한다(AND).
*/
export interface JmapEmailFilterCondition {
inMailbox?: string;
/** 이 메일함들이 **아닌** 메일함에 하나라도 들어 있는 메일 — 휴지통·스팸메일함에만 있는 메일을 뺀다 */
inMailboxOtherThan?: string[];
notKeyword?: string;
/** 보낸 사람·받는 사람·참조·제목·본문 전문 검색 — 큰따옴표로 감싼 값은 구절로 찾는다 */
text?: string;
}
/** FilterOperator (RFC 8620 §5.5) — 조건들을 AND · OR · NOT 으로 묶는다 */
export interface JmapFilterOperator<T> {
operator: 'AND' | 'OR' | 'NOT';
conditions: (T | JmapFilterOperator<T>)[];
}
export type JmapEmailFilter =
| JmapEmailFilterCondition
| JmapFilterOperator<JmapEmailFilterCondition>;
export interface JmapQueryResult {
ids?: string[];
position?: number;
@@ -0,0 +1,368 @@
import {
SEARCH_PARTICLES,
parseSearchTerms,
particleForms,
searchTextConditions,
withSearchConditions,
} from './search-terms';
import type { JmapEmailFilter } from './jmap.types';
const { any, withoutFinal, withFinal } = SEARCH_PARTICLES;
/** 낱말 하나의 OR 조건이 찾는 형태들 */
function formsIn(condition: JmapEmailFilter): string[] {
if ('operator' in condition) {
return condition.conditions.map((child) =>
'text' in child ? String(child.text) : '',
);
}
return condition.text === undefined ? [] : [condition.text];
}
describe('검색어 → 엔진 조건 (search-terms)', () => {
describe('조사 표 (SEARCH_PARTICLES)', () => {
it('모두 비어 있지 않은 한글 조사이고, 한 목록 안에 같은 조사가 없다', () => {
for (const list of [any, withoutFinal, withFinal]) {
expect(new Set(list).size).toBe(list.length);
for (const particle of list) {
expect(particle).toMatch(/^[가-힣]+$/);
}
}
});
it("받침 있는 목록의 '으로' 꼴은 넷이다 — ㄹ 받침이면 '로' 꼴로 바꾼다", () => {
expect(
withFinal.filter((particle) => particle.startsWith('으로')),
).toEqual(['으로', '으로는', '으로도', '으로서']);
});
});
describe('낱말 하나의 형태 (particleForms)', () => {
it('받침 없는 한글 — 낱말 그대로 + 공통 + 받침 없을 때의 조사', () => {
const forms = particleForms('회의');
expect(forms[0]).toBe('회의');
expect(forms).toEqual(
expect.arrayContaining([
'회의가',
'회의를',
'회의는',
'회의에서',
'회의입니다',
'회의로',
'회의와',
'회의예요',
'회의들을',
]),
);
for (const wrong of [
'회의이',
'회의을',
'회의은',
'회의으로',
'회의과',
]) {
expect(forms).not.toContain(wrong);
}
expect(forms).toHaveLength(1 + any.length + withoutFinal.length);
});
it('받침 있는 한글 — 받침 있을 때의 조사 (으로 꼴 그대로)', () => {
const forms = particleForms('일정');
expect(forms).toEqual(
expect.arrayContaining([
'일정',
'일정이',
'일정을',
'일정은',
'일정과',
'일정으로',
'일정이에요',
'일정입니다',
]),
);
for (const wrong of [
'일정가',
'일정를',
'일정는',
'일정로',
'일정예요',
]) {
expect(forms).not.toContain(wrong);
}
expect(forms).toHaveLength(1 + any.length + withFinal.length);
});
it("ㄹ 받침 — '으로' 대신 '로' (메일로 · 서울로), 나머지는 받침 있을 때와 같다", () => {
for (const word of ['메일', '서울']) {
const forms = particleForms(word);
expect(forms).toEqual(
expect.arrayContaining([
`${word}로`,
`${word}로는`,
`${word}로도`,
`${word}로서`,
`${word}이`,
`${word}을`,
`${word}은`,
`${word}이나`,
]),
);
expect(forms.some((form) => form.includes('으로'))).toBe(false);
expect(forms).not.toContain(`${word}를`);
expect(forms).toHaveLength(1 + any.length + withFinal.length);
}
});
it("겹받침(ㄺ 등)은 ㄹ 받침이 아니다 — '으로' 를 그대로 쓴다 (닭으로)", () => {
const forms = particleForms('닭');
expect(forms).toContain('닭으로');
expect(forms).not.toContain('닭로');
});
it('영문·숫자로 끝나면 받침을 알 수 없어 두 가지를 다 붙인다', () => {
for (const word of ['ERP', '2026', 'v2']) {
const forms = particleForms(word);
expect(forms).toEqual(
expect.arrayContaining([
word,
`${word}가`,
`${word}이`,
`${word}를`,
`${word}을`,
`${word}로`,
`${word}으로`,
`${word}에서`,
]),
);
expect(forms).toHaveLength(
1 + any.length + withoutFinal.length + withFinal.length,
);
}
});
it('끝 글자만 본다 — 영문이 섞여도 한글로 끝나면 한글 규칙 (ERP시스템 → 받침 ㅁ)', () => {
const forms = particleForms('ERP시스템');
expect(forms).toContain('ERP시스템이');
expect(forms).not.toContain('ERP시스템가');
});
it('끝 글자가 한글 음절도 영문·숫자도 아니면 낱말 그대로 하나다 — 한글 자모 · 한자 · 악센트 있는 라틴 문자 · 전각 숫자', () => {
// 검색어에서는 앞뒤 문장부호를 먼저 뗀다 (parseSearchTerms) — 여기까지 오는 낱말은 문자·숫자로 끝난다
for (const word of ['ㅎㅇ', '東京', 'café', '2026']) {
expect(particleForms(word)).toEqual([word]);
}
});
it('같은 형태는 한 번만 나온다', () => {
for (const word of ['회의', '일정', '메일', 'ERP']) {
const forms = particleForms(word);
expect(new Set(forms).size).toBe(forms.length);
}
});
});
describe('검색어 나누기 (parseSearchTerms)', () => {
it('띄어쓰기로 낱말을 나눈다 — 여러 칸 · 전각 공백 · 줄바꿈도 한 번의 띄어쓰기', () => {
const ideographicSpace = String.fromCharCode(0x3000);
expect(
parseSearchTerms(` 서버 이전${ideographicSpace}일정\n공유 `),
).toEqual([
{ kind: 'word', text: '서버' },
{ kind: 'word', text: '이전' },
{ kind: 'word', text: '일정' },
{ kind: 'word', text: '공유' },
]);
});
it('큰따옴표 안은 구절 하나다 — 나온 순서를 지키고, 구절 안의 여러 칸 공백은 한 칸으로', () => {
expect(parseSearchTerms('회의 "일정 공유" 서버')).toEqual([
{ kind: 'word', text: '회의' },
{ kind: 'phrase', text: '일정 공유' },
{ kind: 'word', text: '서버' },
]);
});
it('따옴표가 낱말에 붙어 있어도 따옴표에서 나눈다', () => {
expect(parseSearchTerms('견적"2차 수정"본')).toEqual([
{ kind: 'word', text: '견적' },
{ kind: 'phrase', text: '2차 수정' },
{ kind: 'word', text: '본' },
]);
});
it('짝이 없는 마지막 따옴표는 없는 것으로 보고 뒤를 낱말로 나눈다', () => {
expect(parseSearchTerms('회의 "일정 공유')).toEqual([
{ kind: 'word', text: '회의' },
{ kind: 'word', text: '일정' },
{ kind: 'word', text: '공유' },
]);
expect(parseSearchTerms('"a b" "c')).toEqual([
{ kind: 'phrase', text: 'a b' },
{ kind: 'word', text: 'c' },
]);
});
it('빈 따옴표는 버린다 — 찾을 것이 없으면 빈 목록', () => {
expect(parseSearchTerms('""')).toEqual([]);
expect(parseSearchTerms('" "')).toEqual([]);
expect(parseSearchTerms('"')).toEqual([]);
expect(parseSearchTerms('회의 ""')).toEqual([
{ kind: 'word', text: '회의' },
]);
});
it('문자·숫자가 하나도 없는 낱말 · 구절은 버린다 — 문장부호 · 말줄임표 · 그림 문자뿐인 것', () => {
for (const query of ['-', '…', '- … !!', '"- …"', '😀', '" "', '"-" "']) {
expect(parseSearchTerms(query)).toEqual([]);
}
expect(parseSearchTerms('회의 - "…" 2026 "- 일정"')).toEqual([
{ kind: 'word', text: '회의' },
{ kind: 'word', text: '2026' },
// 문자가 하나라도 있으면 구절은 그대로 (안의 문장부호 포함)
{ kind: 'phrase', text: '- 일정' },
]);
});
it('문자·숫자가 하나라도 있으면 남긴다 — 한글 자모 · 다른 나라 글자 · 전각 숫자', () => {
expect(
parseSearchTerms('ㅎㅇ café 東京 2026').map((term) => term.text),
).toEqual(['ㅎㅇ', 'café', '東京', '2026']);
});
it('낱말 앞뒤의 문장부호 · 기호는 떼고 가운데 낱말로 찾는다 — 같은 가운데 낱말은 한 번만', () => {
expect(
parseSearchTerms(
'회의, (회의) 회의. “회의” ...회의... [공지] 2026. 회의!',
),
).toEqual([
{ kind: 'word', text: '회의' },
{ kind: 'word', text: '공지' },
{ kind: 'word', text: '2026' },
]);
});
it('문장부호를 뗀 가운데 낱말에 받침에 맞는 조사를 붙인다 — "회의," 로도 "회의를" 을 찾는다', () => {
const [condition] = searchTextConditions('회의,');
expect(formsIn(condition)).toEqual(particleForms('회의'));
expect(formsIn(condition)).toContain('회의를');
});
it('낱말 가운데의 문장부호 · 글자에 붙은 결합 문자는 그대로 둔다 — 끝의 그림 문자는 이체 선택자와 함께 뗀다', () => {
// 태국어 'ที่' — 끝의 두 글자는 앞 글자에 붙는 결합 문자(성조·모음 기호)다
const thai = String.fromCodePoint(0x0e17, 0x0e35, 0x0e48);
// ❤️ = U+2764 + 이체 선택자 U+FE0F (결합 문자 범주)
const heart = String.fromCodePoint(0x2764, 0xfe0f);
expect(
parseSearchTerms(
`서버-이전 don't ${thai} 확인${heart} ${heart}${heart}`,
),
).toEqual([
{ kind: 'word', text: '서버-이전' },
{ kind: 'word', text: "don't" },
{ kind: 'word', text: thai },
{ kind: 'word', text: '확인' },
]);
expect(particleForms('서버-이전')).toContain('서버-이전이');
});
it('구절은 앞뒤 문장부호를 떼지 않는다', () => {
expect(parseSearchTerms('"회의, 일정."')).toEqual([
{ kind: 'phrase', text: '회의, 일정.' },
]);
});
it('같은 낱말 · 같은 구절은 한 번만 — 낱말과 구절은 서로 다른 것이다', () => {
expect(parseSearchTerms('회의 회의 "일정" "일정" 일정')).toEqual([
{ kind: 'word', text: '회의' },
{ kind: 'phrase', text: '일정' },
{ kind: 'word', text: '일정' },
]);
});
it('자모를 풀어 쓴(NFD) 한글도 완성형으로 읽어 받침에 맞는 조사를 붙인다', () => {
const decomposed = '회의'.normalize('NFD');
expect(decomposed).not.toBe('회의');
expect(parseSearchTerms(decomposed)).toEqual([
{ kind: 'word', text: '회의' },
]);
expect(formsIn(searchTextConditions(decomposed)[0])).toContain('회의를');
});
});
describe('엔진 조건 (searchTextConditions · withSearchConditions)', () => {
it('낱말은 형태들의 OR, 구절은 따옴표를 붙인 text 하나 (조사를 붙이지 않는다)', () => {
const [word, phrase] = searchTextConditions('회의 "회의를 잡겠습니다"');
expect(word).toEqual({
operator: 'OR',
conditions: particleForms('회의').map((text) => ({ text })),
});
expect(phrase).toEqual({ text: '"회의를 잡겠습니다"' });
});
it('형태가 하나뿐인 낱말은 OR 로 감싸지 않는다', () => {
expect(searchTextConditions('東京')).toEqual([{ text: '東京' }]);
expect(searchTextConditions('(東京)')).toEqual([{ text: '東京' }]);
});
it('찾을 낱말이 없으면 조건도 없다 (빈 따옴표 · 문장부호뿐)', () => {
for (const query of ['""', '"', '-', '… —', '"" -']) {
expect(searchTextConditions(query)).toEqual([]);
}
});
it('범위 조건이 맨 앞에 오는 AND — 낱말끼리도 AND 다', () => {
const scope = { inMailbox: 'a', notKeyword: '$ara-sending' };
const filter = withSearchConditions(
scope,
searchTextConditions('서버 이전'),
);
expect(filter).toEqual({
operator: 'AND',
conditions: [
scope,
{
operator: 'OR',
conditions: particleForms('서버').map((text) => ({ text })),
},
{
operator: 'OR',
conditions: particleForms('이전').map((text) => ({ text })),
},
],
});
});
it('검색어 조건이 없으면(검색이 아닌 목록) 범위 조건 그대로', () => {
const scope = { inMailboxOtherThan: ['b', 'c'], notKeyword: '$x' };
expect(withSearchConditions(scope, [])).toBe(scope);
});
it('낱말 8개(영문 — 가장 많은 형태)도 조건 수가 낱말 수에 비례한다', () => {
const words = Array.from({ length: 8 }, (_, index) => `word${index}`);
const conditions = searchTextConditions(words.join(' '));
expect(conditions).toHaveLength(8);
const total = conditions.reduce(
(sum, condition) => sum + formsIn(condition).length,
0,
);
expect(total).toBe(
8 * (1 + any.length + withoutFinal.length + withFinal.length),
);
});
});
});
@@ -0,0 +1,258 @@
import { SEARCH_WORD_SEPARATOR } from '@/modules/mail/mail.constants';
import type { JmapEmailFilter, JmapEmailFilterCondition } from './jmap.types';
/**
* 검색어(q) → JMAP `Email/query` 의 text 조건 (순수 함수, 계약 2절 "검색").
*
* 엔진(Stalwart 0.16.27)의 전문 검색은 띄어쓰기·문장부호로 나눈 낱말이 **통째로 같을 때만** 맞는다 —
* '회의'로는 '회의를'을 찾지 못하고 앞부분 검색('회의*')도 없다. 한 text 값에 낱말이 여럿이면 모두 맞아야 하고(AND),
* 큰따옴표로 감싼 값은 구절로 찾는다 (2026-10-27 개발 엔진에서 확인).
* 그래서 한글로 끝나는 낱말마다 흔한 조사를 받침에 맞게 붙인 형태를 함께 찾는다 — 낱말 안은 OR, 낱말끼리는 AND.
* 큰따옴표로 감싼 부분은 구절 그대로 찾고 조사를 붙이지 않는다.
*
* 알려진 한계(엔진 색인 때문): 붙여 쓴 말의 일부('회의실'의 '회의'), 앞부분만 친 말, 끝이 바뀐 말('보냈습니다'의 '보내'),
* 제목 맨 앞의 말머리('[공지]' · 'Re:' — 엔진이 제목을 정규화하며 색인에서 뺀다)는 찾지 못한다.
*/
/**
* 낱말 뒤에 붙여 함께 찾는 조사 — 서버 상수는 이 표 하나다 (계약 2절).
* 한글로 끝나는 낱말 하나가 형태 44개(낱말 그대로 + 28 + 15), 영문·숫자로 끝나면 59개가 된다
* (개발 엔진에서 낱말 하나에 형태 40개 안팎으로 한두 낱말 검색이 0.04~0.12초였다).
*/
export const SEARCH_PARTICLES = {
/** 받침과 상관없이 붙는 것 */
any: [
'의',
'에',
'에서',
'에게',
'께',
'한테',
'도',
'만',
'까지',
'부터',
'마다',
'보다',
'처럼',
'하고',
'에는',
'에서는',
'에도',
'에서도',
'에게는',
'까지는',
'부터는',
'들',
'들이',
'들을',
'들은',
'들의',
'입니다',
'입니까',
],
/** 받침이 없을 때 (회의 → 회의가 · 회의를) */
withoutFinal: [
'가',
'를',
'는',
'와',
'로',
'로는',
'로도',
'로서',
'랑',
'나',
'라고',
'라는',
'라도',
'예요',
'였습니다',
],
/** 받침이 있을 때 (일정 → 일정이 · 일정을) — ㄹ 받침이면 '으로…' 대신 '로…' (메일로 · 서울로) */
withFinal: [
'이',
'을',
'은',
'과',
'으로',
'으로는',
'으로도',
'으로서',
'이랑',
'이나',
'이라고',
'이라는',
'이라도',
'이에요',
'이었습니다',
],
} as const;
/** 한글 음절(가~힣) — 받침은 (코드 − 첫 음절) % 28 (0 = 받침 없음) */
const HANGUL_SYLLABLE_FIRST = 0xac00;
const HANGUL_SYLLABLE_LAST = 0xd7a3;
const FINAL_CONSONANT_COUNT = 28;
/** 받침 번호 — ㄹ 하나 (ㄺ·ㄻ 같은 겹받침은 '으로'를 그대로 쓴다: 닭으로) */
const RIEUL_FINAL = 8;
/** ㄹ 받침 뒤에서는 '으로' 의 '으' 가 빠진다 */
const EURO = '으로';
/** 영문·숫자로 끝나는 낱말 — 읽는 법(받침)을 알 수 없어 두 가지 조사를 다 붙인다 (ERP가 · ERP이) */
const ASCII_ALNUM_END = /[A-Za-z0-9]$/;
/** 큰따옴표 — 감싼 부분은 구절로 찾는다 */
const QUOTE = '"';
/**
* 찾을 수 있는 글자 — 문자(\p{L}: 한글·자모·영문 등)나 숫자(\p{N}).
* 이것이 하나도 없는 낱말·구절(`-` · `…` · 그림 문자뿐)은 버린다 — 엔진은 문장부호를 낱말로 색인하지 않는다
*/
const SEARCHABLE_CHARACTER = /[\p{L}\p{N}]/u;
/**
* 낱말 앞뒤에 붙은, 문자·숫자가 아닌 글자 — 엔진은 문장부호에서 낱말을 나누므로 떼고 가운데 낱말로 찾는다
* ('회의,' · '(회의)' · '회의.' · '“회의”' → '회의' — 그래야 받침을 읽어 '회의를' 도 찾는다). 낱말 가운데의 문장부호('서버-이전')는 그대로 둔다.
* 뒤쪽은 "문자·숫자·결합 문자가 아닌 글자 + 그 뒤의 결합 문자" 를 뗀다 — 글자에 붙은 결합 문자(태국어 성조 등)는 남기고,
* 그림 문자와 그 이체 선택자(❤️ = U+2764 U+FE0F)는 함께 뗀다.
*/
const WORD_EDGE_PUNCTUATION =
/^[^\p{L}\p{N}]+|(?:[^\p{L}\p{N}\p{M}]\p{M}*)+$/gu;
/** 낱말 앞뒤의 문장부호·기호를 뗀 가운데 낱말 — 문자·숫자가 없으면 빈 문자열 */
function coreWord(word: string): string {
return word.replace(WORD_EDGE_PUNCTUATION, '');
}
/** 낱말 끝의 종류 — 어떤 조사를 붙일지 정한다 */
type WordEnding =
/** 받침 없는 한글 */
| 'no-final'
/** 받침 있는 한글 (ㄹ 하나는 따로) */
| 'final'
/** ㄹ 받침 한글 */
| 'rieul'
/** 영문·숫자 — 받침을 알 수 없다 */
| 'unknown'
/** 그 밖 (문장부호·한글 자모·그림 문자 등) — 조사를 붙이지 않는다 */
| 'other';
const toRieul = (particle: string): string =>
particle.startsWith(EURO) ? particle.slice(1) : particle;
/** 끝의 종류마다 붙일 조사 — 같은 조사는 한 번만 */
const PARTICLES_BY_ENDING: Readonly<Record<WordEnding, readonly string[]>> = {
'no-final': unique([
...SEARCH_PARTICLES.any,
...SEARCH_PARTICLES.withoutFinal,
]),
final: unique([...SEARCH_PARTICLES.any, ...SEARCH_PARTICLES.withFinal]),
rieul: unique([
...SEARCH_PARTICLES.any,
...SEARCH_PARTICLES.withFinal.map(toRieul),
]),
unknown: unique([
...SEARCH_PARTICLES.any,
...SEARCH_PARTICLES.withoutFinal,
...SEARCH_PARTICLES.withFinal,
]),
other: [],
};
function unique(values: readonly string[]): string[] {
return [...new Set(values)];
}
function endingOf(word: string): WordEnding {
const code = word.charCodeAt(word.length - 1);
if (code >= HANGUL_SYLLABLE_FIRST && code <= HANGUL_SYLLABLE_LAST) {
const final = (code - HANGUL_SYLLABLE_FIRST) % FINAL_CONSONANT_COUNT;
if (final === 0) return 'no-final';
return final === RIEUL_FINAL ? 'rieul' : 'final';
}
return ASCII_ALNUM_END.test(word) ? 'unknown' : 'other';
}
/**
* 낱말 하나로 찾을 형태 — 낱말 그대로 + 끝에 맞는 조사를 붙인 것 (같은 형태는 한 번만).
* 한글로 끝나지도 영문·숫자로 끝나지도 않으면 낱말 그대로 하나다.
*/
export function particleForms(word: string): string[] {
if (word === '') return [];
return unique([
word,
...PARTICLES_BY_ENDING[endingOf(word)].map((particle) => word + particle),
]);
}
/** 검색어를 나눈 조각 — 낱말(조사를 붙여 찾는다) · 구절(큰따옴표 안, 그대로 찾는다) */
export interface SearchTerm {
readonly kind: 'word' | 'phrase';
readonly text: string;
}
function splitWords(text: string): string[] {
return text.split(SEARCH_WORD_SEPARATOR).filter((word) => word !== '');
}
/**
* 검색어를 낱말·구절로 나눈다 — 나온 순서대로, 같은 낱말·같은 구절은 한 번만.
* - 큰따옴표 한 쌍 안은 구절이다 (안의 여러 칸 공백은 한 칸으로)
* - 짝이 없는 마지막 큰따옴표는 없는 것으로 본다 — 그 뒤는 따옴표 밖처럼 낱말로 나눈다
* - 낱말은 띄어쓰기(공백 문자)로 나눈다 — DTO 가 세는 "낱말 수"와 같은 기준 (SEARCH_WORD_SEPARATOR)
* - 낱말 앞뒤의 문장부호·기호는 떼고 가운데 낱말로 찾는다 ('회의,' → '회의', WORD_EDGE_PUNCTUATION). 구절은 그대로 둔다
* - 문자·숫자가 하나도 없는 낱말·구절(비어 있음 · `-` · `…` 등)은 버린다 (SEARCHABLE_CHARACTER) —
* 남는 것이 없으면 빈 목록이고, 목록 조회는 엔진에 묻지 않고 빈 결과를 돌려준다 (범위 전체를 돌려주지 않는다)
* - 유니코드 NFC(완성형)로 정규화한다 — 자모를 풀어 쓴(NFD) 한글을 붙여 넣어도 받침을 읽는다
*/
export function parseSearchTerms(query: string): SearchTerm[] {
const segments = query.normalize('NFC').split(QUOTE);
const terms: SearchTerm[] = [];
const seen = new Set<string>();
const add = (kind: SearchTerm['kind'], text: string) => {
const key = `${kind}\n${text}`;
if (!SEARCHABLE_CHARACTER.test(text) || seen.has(key)) return;
seen.add(key);
terms.push({ kind, text });
};
segments.forEach((segment, index) => {
// 홀수 번째 조각은 따옴표 안이다 — 뒤에 닫는 따옴표가 있을 때만 (마지막 조각이면 짝이 없다)
const isPhrase = index % 2 === 1 && index < segments.length - 1;
if (isPhrase) {
add('phrase', splitWords(segment).join(' '));
return;
}
for (const word of splitWords(segment)) add('word', coreWord(word));
});
return terms;
}
/**
* 검색어의 text 조건들 — 모두 맞아야 한다(AND 로 묶어 쓴다).
* 구절은 `{ text: '"…"' }` 하나, 낱말은 형태들의 OR (형태가 하나뿐이면 그 조건 하나).
* 찾을 낱말이 하나도 없으면(문장부호뿐 · 빈 따옴표 등) 빈 목록 — 호출한 쪽은 아무것도 찾지 않는다.
*/
export function searchTextConditions(query: string): JmapEmailFilter[] {
return parseSearchTerms(query).map((term): JmapEmailFilter => {
if (term.kind === 'phrase') return { text: `${QUOTE}${term.text}${QUOTE}` };
const forms = particleForms(term.text);
return forms.length === 1
? { text: forms[0] }
: { operator: 'OR', conditions: forms.map((text) => ({ text })) };
});
}
/**
* 범위 조건(메일함·내부 초안 제외)에 검색어 조건(searchTextConditions)을 AND 로 더한다.
* 검색어 조건이 없으면(검색이 아닌 목록) 범위 조건 그대로다.
*/
export function withSearchConditions(
scope: JmapEmailFilterCondition,
conditions: readonly JmapEmailFilter[],
): JmapEmailFilter {
return conditions.length > 0
? { operator: 'AND', conditions: [scope, ...conditions] }
: scope;
}
+16 -1
View File
@@ -41,9 +41,24 @@ export const MESSAGES_ROUTE = 'messages';
*/
export const DRAFTS_ROUTE = 'drafts';
/** 검색어 최대 길이 */
/**
* 검색어 최대 길이 — 앞뒤 공백을 뺀 길이를 JS 문자열 길이(UTF-16 코드 단위)로 센다 (계약 2절 "검색").
* 넘으면 400 VAL_001 (dto/message-list-query.dto.ts)
*/
export const MAX_SEARCH_LENGTH = 200;
/** 검색어의 낱말(띄어쓰기로 나눈 것) 최대 개수 — 낱말마다 조사를 붙인 형태 수십 개로 찾으므로 엔진 비용을 묶어 둔다 */
export const MAX_SEARCH_WORDS = 8;
/** 검색어의 낱말을 나누는 기준 — 공백 문자(전각 공백·줄바꿈 포함). DTO 의 낱말 수와 엔진 조건(jmap/search-terms.ts)이 같이 쓴다 */
export const SEARCH_WORD_SEPARATOR = /\s+/u;
/**
* 여러 통을 한꺼번에 다루는 요청(PATCH /api/messages · POST /api/message-deletions)의 id 최대 개수 —
* 엔진의 한 번 처리 한도(maxObjectsInGet · maxObjectsInSet 500)보다 작게 둔다 (계약 2절 "여러 통 한꺼번에")
*/
export const MAX_BULK_MESSAGE_IDS = 100;
/** 받는 사람 추천의 거르는 글자(q) 최대 길이 — 계약 2절 "받는 사람 추천" */
export const MAX_SUGGESTION_QUERY_LENGTH = 100;
+8 -1
View File
@@ -25,7 +25,12 @@ import {
buildMailSettings,
isMailEngineConfigured,
} from './mail.settings';
import { MailboxController, MailQuotaController } from './mailbox.controller';
import {
MailboxController,
MailQuotaController,
MailStateController,
} from './mailbox.controller';
import { MessageDeletionController } from './message-deletion.controller';
import { MessageController } from './message.controller';
import { RecipientSuggestionController } from './recipient-suggestion.controller';
import { RecipientSuggestionService } from './recipient-suggestions.service';
@@ -97,7 +102,9 @@ function attachmentUploadOptions(config: ConfigService): MulterModuleOptions {
controllers: [
MailboxController,
MailQuotaController,
MailStateController,
MessageController,
MessageDeletionController,
MailAttachmentController,
DraftController,
RecipientSuggestionController,
+34
View File
@@ -6,10 +6,13 @@ import type {
MailAccountProfile,
MailAttachment,
Mailbox,
MailState,
MailUsage,
MessageDeletionResult,
MessageDetailSource,
MessageListQuery,
MessagePage,
MessagesPatchResult,
MessageSummary,
MessageUpdate,
OutgoingMessage,
@@ -60,6 +63,17 @@ export interface MailProvider {
*/
getAttachmentsTotalLimit(user: string): Promise<number>;
/**
* 새 메일 확인용 상태 값 — 메일함이나 메일이 하나라도 바뀌면 달라진다 (메일을 읽지 않는 가벼운 조회).
* 엔진이 상태를 알려 주지 않으면 MAIL_001.
*/
getMailState(user: string): Promise<MailState>;
/**
* 메일 목록 (받은 시각 내림차순). `mailboxId` 가 없으면 휴지통·스팸메일함을 뺀 모든 메일함에서 찾는다 (검색).
* `text` 는 엔진의 낱말 검색 규칙에 맞춰 넓혀 찾는다 (한국어 조사 — 계약 2절 "검색"). 없는 메일함은 RES_001.
* `text` 에 찾을 낱말(문자·숫자)이 하나도 없으면 엔진에 묻지 않고 빈 목록을 돌려준다 (메일함 범위여도 — 메일함을 확인하지 않는다).
*/
listMessages(user: string, query: MessageListQuery): Promise<MessagePage>;
/** 조회만 한다 — 읽음 처리하지 않는다 */
@@ -71,9 +85,29 @@ export interface MailProvider {
update: MessageUpdate,
): Promise<MessageSummary>;
/**
* 여러 통에 같은 변경(읽음 · 중요 표시 · 이동)을 한다 — 엔진 요청 한 번. 바꾸지 못한 메일(없음 · 엔진이 거절)은 failedIds 로.
* 옮길 메일함이 없으면 아무것도 바꾸지 않고 RES_001.
*/
updateMessages(
user: string,
ids: readonly string[],
update: MessageUpdate,
): Promise<MessagesPatchResult>;
/** 휴지통이 아니면 휴지통으로 옮기고, 휴지통이면 영구 삭제한다 */
deleteMessage(user: string, id: string): Promise<void>;
/**
* 휴지통에 있는 메일만 영구 삭제한다 (여러 통) — 휴지통 밖의 메일은 옮기지 않고 건너뛴다.
* 확인한 상태 그대로일 때만 한 번에 지운다(휴지통에만 있으면 영구 삭제, 다른 메일함에도 있으면 휴지통에서만 뺀다).
* 확인하는 사이 계속 바뀌거나 휴지통이 없으면 아무것도 지우지 않고 MAIL_001.
*/
deleteFromTrash(
user: string,
ids: readonly string[],
): Promise<MessageDeletionResult>;
/**
* @returns 보낸편지함에 남은 메일 id
* 첨부 합계가 한도(MAIL_ATTACHMENT_TOTAL_MAX_SIZE_MB 와 엔진 한도 중 작은 값)를 넘으면 FILE_002,
@@ -56,10 +56,13 @@ function fakeProvider(): ProviderMocks {
getUsage: mockOf<'getUsage'>(),
getAttachmentsTotalLimit:
mockOf<'getAttachmentsTotalLimit'>().mockResolvedValue(25 * 1024 * 1024),
getMailState: mockOf<'getMailState'>(),
listMessages: mockOf<'listMessages'>(),
getMessage: mockOf<'getMessage'>(),
updateMessage: mockOf<'updateMessage'>(),
updateMessages: mockOf<'updateMessages'>(),
deleteMessage: mockOf<'deleteMessage'>(),
deleteFromTrash: mockOf<'deleteFromTrash'>(),
sendMessage,
findSentMessage: mockOf<'findSentMessage'>().mockResolvedValue(null),
uploadAttachment: mockOf<'uploadAttachment'>(),
@@ -324,6 +327,105 @@ describe('MailService', () => {
});
});
describe('검색 · 새 메일 확인 · 여러 통 (3단계)', () => {
const emptyPage = {
items: [],
hasNext: false,
nextCursor: null,
total: 0,
};
it('메일함 없이 검색어만 있으면 모든 메일함 검색으로 넘긴다 (휴지통·스팸메일함 제외는 어댑터)', async () => {
provider.listMessages.mockResolvedValue(emptyPage);
await service.listMessages(user, { limit: 20, q: ' 회의 ' });
expect(provider.listMessages).toHaveBeenCalledWith(user.email, {
mailboxId: undefined,
limit: 20,
cursor: undefined,
text: '회의',
});
});
it('메일함도 검색어도 없으면(공백뿐인 검색어 포함) 엔진에 묻지 않고 VAL_001', async () => {
for (const query of [{ limit: 20 }, { limit: 20, q: ' ' }]) {
expect(await codeOf(() => service.listMessages(user, query))).toBe(
'VAL_001',
);
}
expect(provider.listMessages).not.toHaveBeenCalled();
});
it('새 메일 확인은 어댑터의 상태 값을 그대로 — 요청마다 로그를 남기지 않는다', async () => {
provider.getMailState.mockResolvedValue({ state: 'c2Iy.c2Fq' });
await expect(service.getMailState(user)).resolves.toEqual({
state: 'c2Iy.c2Fq',
});
expect(provider.getMailState).toHaveBeenCalledWith(user.email);
expect(logSpy).not.toHaveBeenCalled();
expect(warnSpy).not.toHaveBeenCalled();
});
it('여러 통 PATCH — 바꿀 것이 하나 이상이어야 한다 (없으면 VAL_001, 엔진에 묻지 않는다)', async () => {
expect(
await codeOf(() => service.updateMessages(user, { ids: ['m1', 'm2'] })),
).toBe('VAL_001');
expect(provider.updateMessages).not.toHaveBeenCalled();
});
it('여러 통 PATCH — id 와 변경을 그대로 넘기고 결과를 돌려준다', async () => {
provider.updateMessages.mockResolvedValue({
updatedIds: ['m1'],
failedIds: ['m2'],
});
await expect(
service.updateMessages(user, {
ids: ['m1', 'm2'],
isRead: true,
mailboxId: 'b',
}),
).resolves.toEqual({ updatedIds: ['m1'], failedIds: ['m2'] });
expect(provider.updateMessages).toHaveBeenCalledWith(
user.email,
['m1', 'm2'],
{ isRead: true, isFlagged: undefined, mailboxId: 'b' },
);
});
it('휴지통에서 영구 삭제 — 지운 것이 있으면 개수만 남긴다 (주소·id 없이), 없으면 남기지 않는다', async () => {
provider.deleteFromTrash.mockResolvedValueOnce({
deletedIds: ['t1', 't2'],
skippedIds: ['i1'],
});
await expect(
service.deleteFromTrash(user, { ids: ['t1', 't2', 'i1'] }),
).resolves.toEqual({ deletedIds: ['t1', 't2'], skippedIds: ['i1'] });
expect(provider.deleteFromTrash).toHaveBeenCalledWith(user.email, [
't1',
't2',
'i1',
]);
expect(logSpy).toHaveBeenCalledTimes(1);
const [line] = logSpy.mock.calls[0] as [string];
expect(line).toContain('지움=2통');
expect(line).toContain('건너뜀=1통');
expect(line).not.toContain(user.email);
expect(line).not.toMatch(/t1|t2|i1/);
logSpy.mockClear();
provider.deleteFromTrash.mockResolvedValueOnce({
deletedIds: [],
skippedIds: ['i1'],
});
await service.deleteFromTrash(user, { ids: ['i1'] });
expect(logSpy).not.toHaveBeenCalled();
});
});
describe('메일 상세', () => {
it('HTML 이 없으면 정화 없이 텍스트만 내려준다', async () => {
provider.getMessage.mockResolvedValue(source());
+72 -17
View File
@@ -17,6 +17,10 @@ import {
resolveAttachmentType,
sanitizeFileName,
} from './attachment.policy';
import type {
MessageDeletionDto,
UpdateMessagesDto,
} from './dto/bulk-messages.dto';
import type { MessageListQueryDto } from './dto/message-list-query.dto';
import type {
ComposeContentDto,
@@ -59,9 +63,13 @@ import type {
MailAttachment,
Mailbox,
MailQuota,
MailState,
MessageAttachment,
MessageDeletionResult,
MessageDetail,
MessagesPatchResult,
MessageSummary,
MessageUpdate,
OutgoingAttachment,
OutgoingInlineImage,
OutgoingMessage,
@@ -162,6 +170,21 @@ function assertHtmlBytes(html: string): void {
}
}
/** 읽음 · 중요 표시 · 이동 — 하나 이상이어야 한다 (빈 요청은 VAL_001). 한 통·여러 통 PATCH 가 같이 쓴다 */
function toMessageUpdate(dto: UpdateMessageDto): MessageUpdate {
const { isRead, isFlagged, mailboxId } = dto;
if (
isRead === undefined &&
isFlagged === undefined &&
mailboxId === undefined
) {
throw new BusinessException('VAL_001', {
context: { reason: 'empty-update' },
});
}
return { isRead, isFlagged, mailboxId };
}
/** 서명 그림의 Content-ID — 같은 그림이면 늘 같은 값이다 (발송 지문이 cid 가 든 서식 본문을 본다) */
function signatureImageCid(imageId: string, sender: string): string {
const domain = sender.slice(sender.lastIndexOf('@') + 1).toLowerCase();
@@ -221,16 +244,30 @@ export class MailService {
};
}
/** 새 메일 확인 (계약 2절 "새 메일 확인") — 클라이언트가 1분마다 묻는다. 요청마다 로그를 남기지 않는다 */
getMailState(user: AuthenticatedUser): Promise<MailState> {
return this.provider.getMailState(user.email);
}
/**
* 메일 목록 · 검색 — mailboxId 와 검색어(q) 중 하나는 있어야 한다 (둘 다 없으면 VAL_001).
* mailboxId 가 없으면 휴지통·스팸메일함을 뺀 모든 메일함에서 찾는다 (계약 2절 "검색"). 검색어는 어떤 로그에도 남기지 않는다.
*/
async listMessages(
user: AuthenticatedUser,
query: MessageListQueryDto,
): Promise<PaginatedResult<MessageSummary>> {
const text = query.q?.trim();
const text = query.q?.trim() || undefined;
if (query.mailboxId === undefined && text === undefined) {
throw new BusinessException('VAL_001', {
context: { reason: 'mailbox-or-search-required' },
});
}
const page = await this.provider.listMessages(user.email, {
mailboxId: query.mailboxId,
limit: query.limit,
cursor: query.cursor,
text: text ? text : undefined,
text,
});
return toPaginatedResult(page.items, {
@@ -318,27 +355,45 @@ export class MailService {
id: string,
dto: UpdateMessageDto,
): Promise<MessageSummary> {
const { isRead, isFlagged, mailboxId } = dto;
if (
isRead === undefined &&
isFlagged === undefined &&
mailboxId === undefined
) {
throw new BusinessException('VAL_001', {
context: { reason: 'empty-update' },
});
}
return this.provider.updateMessage(user.email, id, {
isRead,
isFlagged,
mailboxId,
});
return this.provider.updateMessage(user.email, id, toMessageUpdate(dto));
}
/**
* 여러 통의 읽음 · 중요 표시 · 이동 (계약 2절 "여러 통 한꺼번에") — 바꿀 것이 하나 이상이어야 한다(VAL_001).
* 바꾸지 못한 메일은 failedIds 로 돌려준다 — 클라이언트는 그것만 되돌린다.
*/
async updateMessages(
user: AuthenticatedUser,
dto: UpdateMessagesDto,
): Promise<MessagesPatchResult> {
return this.provider.updateMessages(
user.email,
dto.ids,
toMessageUpdate(dto),
);
}
deleteMessage(user: AuthenticatedUser, id: string): Promise<void> {
return this.provider.deleteMessage(user.email, id);
}
/**
* 휴지통에서 영구 삭제 (여러 통) — 휴지통에 있는 메일만 지운다(휴지통 밖의 메일은 건너뛴다).
* 되돌릴 수 없는 일이라 지운 것이 있으면 개수만 남긴다 (주소·id 목록 없이).
*/
async deleteFromTrash(
user: AuthenticatedUser,
dto: MessageDeletionDto,
): Promise<MessageDeletionResult> {
const result = await this.provider.deleteFromTrash(user.email, dto.ids);
if (result.deletedIds.length > 0) {
this.logger.log(
`${traceTag()} 휴지통에서 영구 삭제했습니다: user=${maskEmail(user.email)}, 지움=${result.deletedIds.length}통, 건너뜀=${result.skippedIds.length}통`,
);
}
return result;
}
/**
* 메일 발송. `idempotencyKey`(Idempotency-Key 헤더)가 있으면 중복 발송을 막는다 (계약 2절):
* - 같은 사용자·같은 키로 이미 보낸 메일이 있으면 다시 보내지 않고 그 id 를 돌려준다
+23 -2
View File
@@ -125,11 +125,12 @@ export interface MessageDetailSource extends Omit<
}
export interface MessageListQuery {
readonly mailboxId: string;
/** 메일함 — 없으면 휴지통·스팸메일함을 뺀 모든 메일함에서 찾는다 (검색어가 있을 때만 — 계약 2절 "검색") */
readonly mailboxId?: string;
readonly limit: number;
/** 불투명 커서 — 어댑터가 만들고 해석한다 */
readonly cursor?: string;
/** 자유 텍스트 검색어 */
/** 자유 텍스트 검색어 (앞뒤 공백을 뺀 값) */
readonly text?: string;
}
@@ -147,6 +148,26 @@ export interface MessageUpdate {
readonly mailboxId?: string;
}
/** 새 메일 확인 (계약 2절 "새 메일 확인") — 메일함이나 메일이 하나라도 바뀌면 달라지는 불투명 값 */
export interface MailState {
readonly state: string;
}
/** 여러 통을 바꾼 결과 (PATCH /api/messages) — 요청한 id 는 둘 중 한 곳에만 있다 */
export interface MessagesPatchResult {
readonly updatedIds: string[];
/** 없음 · 그사이 지워짐 · 엔진이 거절함 */
readonly failedIds: string[];
}
/** 휴지통에서 영구 삭제한 결과 (POST /api/message-deletions) — 요청한 id 는 둘 중 한 곳에만 있다 */
export interface MessageDeletionResult {
/** 영구 삭제했다(휴지통에만 있었다) · 휴지통에서만 뺐다(다른 메일함에도 있었다) */
readonly deletedIds: string[];
/** 휴지통에 없음 · 없음 · 엔진이 거절 — 지우지 않았다 */
readonly skippedIds: string[];
}
export interface OutgoingAttachment {
/** 업로드 응답의 id */
readonly id: string;
+35 -2
View File
@@ -1,4 +1,4 @@
import { Controller, Get, UseGuards } from '@nestjs/common';
import { Controller, Get, Header, UseGuards } from '@nestjs/common';
import { ApiCookieAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
import {
@@ -10,7 +10,11 @@ import {
type AuthenticatedUser,
} from '@/common/decorators/current-user.decorator';
import { JwtAuthGuard } from '@/common/guards/jwt-auth.guard';
import { MailboxPageDto, MailQuotaDto } from './dto/mail-response.dto';
import {
MailboxPageDto,
MailQuotaDto,
MailStateDto,
} from './dto/mail-response.dto';
import { MailService } from './mail.service';
@ApiTags('Mail')
@@ -57,3 +61,32 @@ export class MailQuotaController {
return this.mail.getQuota(user);
}
}
// 새 메일 확인 (계약 2절 "새 메일 확인") — 클라이언트가 화면이 보이는 동안 1분마다 묻는다.
// 요청 수 한도는 다른 API 와 같다(SkipThrottle 하지 않는다). 요청마다 로그를 남기지 않는다.
@ApiTags('Mail')
@ApiCookieAuth()
@UseGuards(JwtAuthGuard)
@ApiErrorResponse('AUTH_001', '로그인 필요 (쿠키 없음·만료)')
@ApiErrorResponse(
'MAIL_001',
'메일 엔진 장애 (엔진이 상태를 알려 주지 않음 포함)',
)
@Controller('mail-state')
export class MailStateController {
constructor(private readonly mail: MailService) {}
@Get()
// 지난 값과 비교하는 값이라 브라우저·공유 캐시 어디에도 남기지 않는다
@Header('Cache-Control', 'private, no-store')
@ApiOperation({
summary: '새 메일 확인 — 메일함·메일의 상태 값',
description:
'불투명 값이다 — 메일함이나 메일이 하나라도 바뀌면(새 메일 · 다른 창이나 메일 프로그램의 읽음 · 이동 · 삭제) 달라진다. ' +
'해석하지 말고 지난 값과 같은지만 본다. 메일을 읽지 않는 가벼운 조회다. Cache-Control: private, no-store.',
})
@ApiStandardResponse(MailStateDto, { description: '지금 상태 값' })
get(@CurrentUser() user: AuthenticatedUser) {
return this.mail.getMailState(user);
}
}
@@ -0,0 +1,65 @@
import {
Body,
Controller,
HttpCode,
HttpStatus,
Post,
UseGuards,
} from '@nestjs/common';
import { ApiCookieAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
import {
ApiErrorResponse,
ApiStandardResponse,
} from '@/common/decorators/api-standard-response.decorator';
import {
CurrentUser,
type AuthenticatedUser,
} from '@/common/decorators/current-user.decorator';
import { JwtAuthGuard } from '@/common/guards/jwt-auth.guard';
import { MessageDeletionDto } from './dto/bulk-messages.dto';
import { MessageDeletionResultDto } from './dto/mail-response.dto';
import { MAX_BULK_MESSAGE_IDS } from './mail.constants';
import { MailService } from './mail.service';
// 휴지통에서 영구 삭제 — 여러 통 (계약 2절 "여러 통 한꺼번에"). 휴지통 밖의 메일을 휴지통으로 옮기는 DELETE /api/messages/{id} 와 다르다 —
// 휴지통에 있는 메일만 다루므로, 확인하는 사이 다른 창이 휴지통으로 옮긴 메일을 영구 삭제하지 않는다.
@ApiTags('Mail')
@ApiCookieAuth()
@UseGuards(JwtAuthGuard)
@ApiErrorResponse(
'AUTH_001',
'로그인 필요 (쿠키 없음·만료) — 클라이언트는 /auth/refresh 를 한 번 시도한다',
)
@Controller('message-deletions')
export class MessageDeletionController {
constructor(private readonly mail: MailService) {}
@Post()
@HttpCode(HttpStatus.OK)
@ApiOperation({
summary: '휴지통에서 영구 삭제 (여러 통)',
description:
`ids 는 1~${MAX_BULK_MESSAGE_IDS}개. 휴지통에 있는 메일만 다룬다 — 휴지통에만 있으면 영구 삭제, 다른 메일함에도 있으면 휴지통에서만 뺀다. ` +
'휴지통에 없는 메일 · 없는 메일 · 엔진이 거절한 메일은 지우지 않고 skippedIds 로 돌려준다. ' +
'대상을 확인한 뒤 그 상태 그대로일 때만 한 번에 지운다(그사이 다른 창이 휴지통 밖으로 되돌린 메일을 지우지 않는다) — ' +
'계속 어긋나면 아무것도 지우지 않고 503 MAIL_001 (다시 시도하면 된다).',
})
@ApiStandardResponse(MessageDeletionResultDto, {
description: '지운 메일 · 지우지 않은 메일',
})
@ApiErrorResponse(
'VAL_001',
`ids 가 비었거나 ${MAX_BULK_MESSAGE_IDS}개 초과 · 같은 id 두 번 · 형식이 틀린 id(엔진이 해석하지 못하는 id 포함) — 지우지 않았다`,
)
@ApiErrorResponse(
'MAIL_001',
'확인하는 사이 계속 바뀜 · 휴지통 없음 — 지우지 않았다. 지우는 요청 중의 엔진 장애(시간 초과 등)면 일부만 지웠을 수 있다 — 다시 보내면 남은 것만 지운다',
)
remove(
@CurrentUser() user: AuthenticatedUser,
@Body() dto: MessageDeletionDto,
) {
return this.mail.deleteFromTrash(user, dto);
}
}
+43 -2
View File
@@ -40,6 +40,7 @@ import { buildAttachmentDisposition } from '@/shared/utils/content-disposition';
import {
MessageDetailDto,
MessagePageDto,
MessagesPatchedDto,
MessageSummaryDto,
SentMessageDto,
} from './dto/mail-response.dto';
@@ -48,9 +49,13 @@ import {
MAX_BODY_HTML_LENGTH,
MAX_BODY_LENGTH,
MAX_BODY_VALUE_BYTES,
MAX_BULK_MESSAGE_IDS,
MAX_SEARCH_LENGTH,
MAX_SEARCH_WORDS,
MAX_SUBJECT_LENGTH,
MESSAGES_ROUTE,
} from './mail.constants';
import { UpdateMessagesDto } from './dto/bulk-messages.dto';
import { MessageListQueryDto } from './dto/message-list-query.dto';
import { SendMessageDto } from './dto/send-message.dto';
import { UpdateMessageDto } from './dto/update-message.dto';
@@ -76,12 +81,21 @@ export class MessageController {
@Get()
@ApiOperation({
summary: '메일 목록 (커서 페이지네이션, 받은 시각 내림차순)',
summary: '메일 목록 · 검색 (커서 페이지네이션, 받은 시각 내림차순)',
description:
'mailboxId 와 q 중 하나는 있어야 한다. q 만 있으면 휴지통·스팸메일함을 뺀 모든 메일함에서 찾는다. ' +
'엔진의 전문 검색은 낱말이 통째로 같을 때만 맞으므로, 한글로 끝나는 낱말은 흔한 조사를 받침에 맞게 붙인 형태도 함께 찾는다 ' +
'(낱말 안은 OR · 낱말끼리는 AND, 영문·숫자로 끝나면 두 가지를 다, 큰따옴표 구절에는 붙이지 않는다). ' +
'낱말 앞뒤의 문장부호는 떼고 찾는다(회의, → 회의). 문자·숫자가 없는 낱말·구절(- · … 등)은 빼고 찾으며, 찾을 낱말이 하나도 없으면 빈 목록이다(범위 전체를 돌려주지 않는다). ' +
'붙여 쓴 말의 일부 · 앞부분만 친 말 · 제목 맨 앞의 말머리([공지] · Re:)는 찾지 못한다. 발송 중인 내부 초안은 빠진다.',
})
@ApiStandardResponse(MessagePageDto, {
description: '메일 목록 (total 포함)',
})
@ApiErrorResponse('VAL_001', 'mailboxId 누락·잘못된 커서·limit 범위 초과')
@ApiErrorResponse(
'VAL_001',
`mailboxId 와 q 가 둘 다 없음 · q 가 ${MAX_SEARCH_LENGTH}자 초과 또는 낱말 ${MAX_SEARCH_WORDS}개 초과 · 다른 메일함·검색어의 커서 · limit 범위 초과`,
)
@ApiErrorResponse(
'RES_001',
'없는 메일함 (엔진이 해석하지 못하는 mailboxId 포함)',
@@ -94,6 +108,33 @@ export class MessageController {
return this.mail.listMessages(user, query);
}
@Patch()
@ApiOperation({
summary: '여러 통의 읽음 · 중요 표시 · 메일함 이동 (하나 이상 지정)',
description:
`ids 는 1~${MAX_BULK_MESSAGE_IDS}개. 메일마다의 변경은 한 통 PATCH 와 같다(이동 = 그 메일함 하나에만). 엔진 요청 한 번으로 처리한다. ` +
'바꾸지 못한 메일(없음 · 그사이 지워짐 · 엔진이 거절)은 failedIds 로 돌려주고 나머지는 그대로 처리한다. ' +
'없는 mailboxId 면 아무것도 바꾸지 않고 404 RES_001. 503 MAIL_001 이면 일부만 바뀌었을 수 있다 — 같은 요청을 다시 보내도 결과가 같다(멱등).',
})
@ApiStandardResponse(MessagesPatchedDto, {
description: '바꾼 메일 · 바꾸지 못한 메일',
})
@ApiErrorResponse(
'VAL_001',
`빈 변경 · ids 가 비었거나 ${MAX_BULK_MESSAGE_IDS}개 초과 · 같은 id 두 번 · 형식이 틀린 id(엔진이 해석하지 못하는 id 포함 — 아무것도 바꾸지 않았다)`,
)
@ApiErrorResponse('RES_001', '없는 메일함으로 이동 — 아무것도 바꾸지 않았다')
@ApiErrorResponse(
'MAIL_001',
'메일 엔진 장애 — 일부만 바뀌었을 수 있다 (같은 요청을 다시 보내면 된다)',
)
updateMany(
@CurrentUser() user: AuthenticatedUser,
@Body() dto: UpdateMessagesDto,
) {
return this.mail.updateMessages(user, dto);
}
@Get(':id')
@ApiOperation({
summary: '메일 상세 — 조회만으로 읽음 처리하지 않는다',
+26 -5
View File
@@ -1,12 +1,12 @@
import { maskEmail, maskUrlForLog } from './mask';
describe('maskUrlForLog (로그에 남길 요청 주소)', () => {
it('검색어(q)는 값 대신 디코딩한 글자 수만 남기고, 경로와 나머지 쿼리는 그대로 둔다', () => {
it('검색어(q)와 커서(cursor)는 값 대신 디코딩한 글자 수만 남기고, 경로와 나머지 쿼리는 그대로 둔다', () => {
expect(
maskUrlForLog(
'/api/recipient-suggestions?q=%EB%8F%84%EC%9C%A4&limit=20&cursor=eyJ2Ijox',
),
).toBe('/api/recipient-suggestions?q=<2자>&limit=20&cursor=eyJ2Ijox');
).toBe('/api/recipient-suggestions?q=<2자>&limit=20&cursor=<8자>');
// + 는 공백 · 주소 형태의 검색어
expect(
maskUrlForLog(
@@ -15,22 +15,43 @@ describe('maskUrlForLog (로그에 남길 요청 주소)', () => {
).toBe('/api/messages?mailboxId=a&q=<26자>');
});
it('q 가 여럿이어도, 이름이 인코딩·대문자·배열 표기여도 모두 가린다', () => {
it('커서는 검색어의 해시(범위 해시)를 담으므로 검색 없는 목록의 커서도 가린다 — 메일함 id 는 그대로', () => {
expect(
maskUrlForLog(
'/api/messages?mailboxId=a&limit=20&cursor=eyJ2IjoxLCJhIjoibTIiLCJwIjoyLCJzIjoiaDBLUHhTS0FQVEVHWG52TyJ9',
),
).toBe('/api/messages?mailboxId=a&limit=20&cursor=<60자>');
});
it('q · cursor 가 여럿이어도, 이름이 인코딩·대문자·배열 표기여도 모두 가린다', () => {
expect(
maskUrlForLog('/api/x?q=a&limit=1&q=%EA%B9%80%EC%95%84%EB%9D%BC'),
).toBe('/api/x?q=<1자>&limit=1&q=<3자>');
expect(
maskUrlForLog('/api/x?%71=secret&Q=secret&q[]=secret&q%5B0%5D=secret'),
).toBe('/api/x?%71=<6자>&Q=<6자>&q[]=<6자>&q%5B0%5D=<6자>');
expect(
maskUrlForLog(
'/api/x?CURSOR=abc&cursor[]=abcd&%63ursor=ab&cursor%5B0%5D=a&cursor=x&cursor=yz',
),
).toBe(
'/api/x?CURSOR=<3자>&cursor[]=<4자>&%63ursor=<2자>&cursor%5B0%5D=<1자>&cursor=<1자>&cursor=<2자>',
);
});
it('q 가 없거나 쿼리가 없으면 그대로 · 값이 비었거나 = 가 없으면 0자 · 잘못된 인코딩은 원문 길이', () => {
it('q · cursor 가 없거나 쿼리가 없으면 그대로 · 값이 비었거나 = 가 없으면 0자 · 잘못된 인코딩은 원문 길이', () => {
expect(maskUrlForLog('/api/users/1')).toBe('/api/users/1');
expect(maskUrlForLog('/api/messages?mailboxId=a&limit=20')).toBe(
'/api/messages?mailboxId=a&limit=20',
);
// q 로 시작하는 다른 이름은 가리지 않는다
// q · cursor 로 시작하거나 끝나는 다른 이름은 가리지 않는다
expect(maskUrlForLog('/api/x?query=1&qq=2')).toBe('/api/x?query=1&qq=2');
expect(maskUrlForLog('/api/x?cursors=1&nextCursor=2&mycursor=3')).toBe(
'/api/x?cursors=1&nextCursor=2&mycursor=3',
);
expect(maskUrlForLog('/api/x?cursor=&cursor')).toBe(
'/api/x?cursor=<0자>&cursor=<0자>',
);
expect(maskUrlForLog('/api/x?q=&q')).toBe('/api/x?q=<0자>&q=<0자>');
expect(maskUrlForLog('/api/x?q=%E0%A4%A')).toBe('/api/x?q=<8자>');
// 값 안의 = 도 값이다
+7 -4
View File
@@ -9,10 +9,13 @@
const MASK = '***';
/**
* 값을 남기지 않는 쿼리 이름 — 검색어. 받는 사람 추천의 q 에는 사람 이름·주소가 들어간다 (계약 2절 "로그에는 개수만").
* 값을 남기지 않는 쿼리 이름 — 검색어(q)와 목록 커서(cursor).
* - q: 메일 검색어 · 받는 사람 추천의 거르는 글자 — 사람 이름·주소가 들어간다 (계약 2절 "검색" · "로그에는 개수만")
* - cursor: 다른 목록의 커서를 막으려고 검색어의 해시(범위 해시)를 담는다 — 로그를 읽는 사람이 후보 검색어를 대입해 맞춰 볼 수 있다
* (modules/mail/jmap/jmap-cursor.ts · recipient-suggestion-cursor.ts)
* 대소문자와 배열 표기(`q[]`·`q[0]`)도 같은 이름으로 본다 — 형식 오류(VAL_001)로 거절된 요청도 로그에는 남는다.
*/
const REDACTED_QUERY_NAME = /^q(?:\[[^\]]*\])*$/i;
const REDACTED_QUERY_NAME = /^(?:q|cursor)(?:\[[^\]]*\])*$/i;
/** 쿼리 조각을 디코딩한다 (`+` 는 공백) — 잘못된 퍼센트 인코딩이면 원문 그대로 */
function decodeQueryPart(part: string): string {
@@ -39,10 +42,10 @@ export function maskEmail(email: string): string {
}
/**
* 로그에 남길 요청 주소 — 검색어(q)는 값 대신 길이만 남기고, 경로와 나머지 쿼리는 그대로 둔다.
* 로그에 남길 요청 주소 — 검색어(q)와 커서(cursor)는 값 대신 길이만 남기고, 경로와 나머지 쿼리는 그대로 둔다.
* 길이는 디코딩한 값의 JS 문자열 길이다 (클라이언트가 세는 방법과 같다).
*
* 예) '/api/recipient-suggestions?q=%EB%8F%84%EC%9C%A4&limit=20' → '/api/recipient-suggestions?q=<2자>&limit=20'
* 예) '/api/recipient-suggestions?q=%EB%8F%84%EC%9C%A4&limit=20&cursor=eyJ2Ijox' → '/api/recipient-suggestions?q=<2자>&limit=20&cursor=<8자>'
*/
export function maskUrlForLog(url: string): string {
const queryStart = url.indexOf('?');
+280 -4
View File
@@ -17,6 +17,9 @@ import { AUTH_SETTINGS, type AuthSettings } from '@/modules/auth/auth.settings';
import {
MAX_BODY_HTML_LENGTH,
MAX_BODY_LENGTH,
MAX_BULK_MESSAGE_IDS,
MAX_SEARCH_LENGTH,
MAX_SEARCH_WORDS,
MAX_SUBJECT_LENGTH,
} from '@/modules/mail/mail.constants';
import { MAIL_PROVIDER } from '@/modules/mail/mail.provider';
@@ -25,6 +28,10 @@ import type {
Correspondence,
DraftContent,
DraftSource,
MessageDeletionResult,
MessageListQuery,
MessagesPatchResult,
MessageUpdate,
OutgoingMessage,
SavedDraft,
} from '@/modules/mail/mail.types';
@@ -100,6 +107,15 @@ const SLOW_ACCOUNT = 'slow.target@e2e.arasystem.test';
/** 대역 메일 엔진이 "다른 창·기기에서 먼저 바뀌었다"고 답하는 임시 저장본 id (409 MAIL_008 경로) */
const DRAFT_CHANGED_ELSEWHERE = 'draftchangedelsewhere';
/** 대역 메일 엔진이 돌려주는 새 메일 확인 값 */
const MAIL_STATE = 'c2Iy.c2Fq';
/** 대역 메일 엔진이 "없는 메일함"으로 답하는 이동 대상 (404 RES_001 경로) */
const MISSING_MAILBOX = 'nomailbox';
/** 대역 메일 엔진이 "그사이 지워졌다"고 답하는 메일 id (failedIds · skippedIds 경로) */
const GONE_MESSAGE = 'gonemessage';
/** 대역 메일 엔진이 로그인 때 알려 주는 이름 — 없으면 EMPLOYEE.name */
const PROFILE_NAMES = new Map([
['colleague.a@e2e.arasystem.test', '가나다'],
@@ -191,6 +207,16 @@ describe('응답 계약 (e2e)', () => {
/** 대역 엔진이 돌려줄 주고받은 주소 (직원별) */
const correspondents = new Map<string, Correspondence[]>();
/** 대역 엔진이 받은 메일 목록 요청 (검색 범위·검색어 확인용) */
const listQueries: { user: string; query: MessageListQuery }[] = [];
/** 대역 엔진이 받은 여러 통 바꾸기 · 휴지통에서 영구 삭제 */
const bulkUpdates: {
user: string;
ids: readonly string[];
update: MessageUpdate;
}[] = [];
const trashDeletions: { user: string; ids: readonly string[] }[] = [];
/** 이 앱의 서명 키로 토큰을 만든다 (CI 처럼 JWT_SECRET 이 없으면 임시 키) */
const sign = (payload: object, expiresIn: number) =>
jwt.sign(payload, {
@@ -199,6 +225,10 @@ describe('응답 계약 (e2e)', () => {
expiresIn,
});
/** 그 직원의 액세스 쿠키 (세션 없이 서명만 맞춘다 — 보호 라우트는 액세스 토큰만 본다) */
const cookieOf = (email: string) =>
`access_token=${sign({ sub: email, typ: 'access', name: '테스트 직원' }, 600)}`;
/** 로그인해서 두 쿠키 값을 받는다 (서버에 세션이 생긴다) */
const login = async (email: string) => {
const res = await request(server)
@@ -301,6 +331,45 @@ describe('응답 계약 (e2e)', () => {
},
listCorrespondents: (user: string) =>
Promise.resolve(correspondents.get(user) ?? []),
// 3단계 — 엔진 쪽 판단(검색 조건 · 조건부 영구 삭제)은 jmap-mail.provider.spec.ts 가 본다. 여기서는 받은 값만 기록한다
getMailState: () => Promise.resolve({ state: MAIL_STATE }),
listMessages: (user: string, query: MessageListQuery) => {
listQueries.push({ user, query });
return Promise.resolve({
items: [],
hasNext: false,
nextCursor: null,
total: 0,
});
},
updateMessages: (
user: string,
ids: readonly string[],
update: MessageUpdate,
): Promise<MessagesPatchResult> => {
bulkUpdates.push({ user, ids, update });
if (update.mailboxId === MISSING_MAILBOX) {
return Promise.reject(
new BusinessException('RES_001', {
context: { reason: 'e2e-mailbox-not-found' },
}),
);
}
return Promise.resolve({
updatedIds: ids.filter((id) => id !== GONE_MESSAGE),
failedIds: ids.filter((id) => id === GONE_MESSAGE),
});
},
deleteFromTrash: (
user: string,
ids: readonly string[],
): Promise<MessageDeletionResult> => {
trashDeletions.push({ user, ids });
return Promise.resolve({
deletedIds: ids.filter((id) => id !== GONE_MESSAGE),
skippedIds: ids.filter((id) => id === GONE_MESSAGE),
});
},
})
.overrideProvider(MAIL_SETTINGS)
.useValue(TEST_MAIL_SETTINGS)
@@ -442,7 +511,7 @@ describe('응답 계약 (e2e)', () => {
);
});
it('다른 API 는 그대로 1MB — 같은 2MB 본문이 로그인·메일 수정(PATCH /api/messages/:id)에서는 413 FILE_002', async () => {
it('다른 API 는 그대로 1MB — 같은 2MB 본문이 로그인·메일 수정(PATCH /api/messages/:id · 여러 통 PATCH)·영구 삭제에서는 413 FILE_002', async () => {
const twoMegabytes = JSON.stringify({
email: 'x',
password: 'y'.repeat(2_000_000),
@@ -451,6 +520,8 @@ describe('응답 계약 (e2e)', () => {
for (const [method, url] of [
['post', '/api/auth/login'],
['patch', '/api/messages/eaaaaab'],
['patch', '/api/messages'],
['post', '/api/message-deletions'],
] as const) {
const res = await request(server)
[method](url)
@@ -677,6 +748,10 @@ describe('응답 계약 (e2e)', () => {
'/api/mail-signature/images/3f1c0a2e-0f1b-4f2a-9a1e-6c9f0a1b2c3d',
],
['get', '/api/recipient-suggestions'],
['get', '/api/mail-state'],
['get', '/api/messages?q=%ED%9A%8C%EC%9D%98'],
['patch', '/api/messages'],
['post', '/api/message-deletions'],
// 템플릿 업로드 예제도 로그인한 직원만 쓴다
['post', '/api/files'],
] as const)('쿠키 없이 %s %s 는 401 AUTH_001', async (method, path) => {
@@ -763,9 +838,6 @@ describe('응답 계약 (e2e)', () => {
* 엔진 쪽 판단(초안인지·옛것이 그사이 없어졌는지)은 대역 엔진이 흉내 낸다 — 여기서는 HTTP 형식·정화·DB·파일을 본다.
*/
describe('2단계 — 임시 저장 · 서명 · 받는 사람 추천', () => {
/** 그 직원의 액세스 쿠키 (세션 없이 서명만 맞춘다 — 보호 라우트는 액세스 토큰만 본다) */
const cookieOf = (email: string) =>
`access_token=${sign({ sub: email, typ: 'access', name: '테스트 직원' }, 600)}`;
const UUID =
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
const DOYUN = { name: '이도윤', email: 'doyun.lee@e2e.arasystem.test' };
@@ -1395,6 +1467,208 @@ describe('응답 계약 (e2e)', () => {
});
});
/**
* 3단계 — 새 메일 확인 · 검색 · 여러 통 한꺼번에 (계약 2절).
* 엔진 쪽 판단(조사를 붙인 검색 조건 · 휴지통·스팸메일함 빼기 · 조건부 영구 삭제)은 jmap-mail.provider.spec.ts 가 본다 —
* 여기서는 라우트 · 검증(VAL_001) · 상태 코드 · 응답 형식 · 헤더를 본다.
*/
describe('3단계 — 새 메일 확인 · 검색 · 여러 통', () => {
const ME = 'bulk.me@e2e.arasystem.test';
describe('새 메일 확인 (GET /api/mail-state)', () => {
it('불투명 상태 값을 표준 래퍼로 — Cache-Control: private, no-store', async () => {
const res = await request(server)
.get('/api/mail-state')
.set('Cookie', cookieOf(ME))
.expect(200);
expect(body(res)).toEqual({
success: true,
data: { state: MAIL_STATE },
});
expect(res.headers['cache-control']).toBe('private, no-store');
});
});
describe('검색 (GET /api/messages 의 q)', () => {
const list = (params: Record<string, string | number>) =>
request(server)
.get('/api/messages')
.query(params)
.set('Cookie', cookieOf(ME));
it('q 만 있으면 메일함 없이(모든 메일함 검색) 앞뒤 공백을 뺀 검색어로 묻는다 — 목록 응답 형식 그대로', async () => {
const before = listQueries.length;
const res = await list({ q: ' 회의 일정 ', limit: 20 }).expect(200);
expect(dataOf<unknown>(res)).toEqual({
items: [],
pageInfo: { hasNext: false, nextCursor: null, total: 0 },
});
expect(listQueries.slice(before)).toEqual([
{
user: ME,
query: {
mailboxId: undefined,
limit: 20,
cursor: undefined,
text: '회의 일정',
},
},
]);
});
it('메일함과 검색어를 함께 주면 그 메일함에서 찾는다 · 메일함만 주면 지금처럼 목록', async () => {
const before = listQueries.length;
await list({ mailboxId: 'a', q: '"회의를 잡겠습니다"' }).expect(200);
await list({ mailboxId: 'a' }).expect(200);
expect(
listQueries
.slice(before)
.map(({ query }) => [query.mailboxId, query.text]),
).toEqual([
['a', '"회의를 잡겠습니다"'],
['a', undefined],
]);
});
it(`메일함도 검색어도 없음 · 공백뿐인 검색어 · ${MAX_SEARCH_LENGTH}자 초과 · 낱말 ${MAX_SEARCH_WORDS}개 초과 · 같은 이름 두 번은 400 VAL_001 — 엔진에 묻지 않는다`, async () => {
const before = listQueries.length;
const words = (count: number) =>
Array.from({ length: count }, (_, index) => `w${index}`).join(' ');
const rejected: Record<string, string>[] = [
{},
{ q: ' ' },
{ q: '가'.repeat(MAX_SEARCH_LENGTH + 1) },
{ q: words(MAX_SEARCH_WORDS + 1) },
{ mailboxId: '../a', q: '회의' },
];
for (const params of rejected) {
const res = await list(params).expect(400);
expect(body(res).error?.code).toBe('VAL_001');
}
const twice = await request(server)
.get('/api/messages?q=a&q=b')
.set('Cookie', cookieOf(ME))
.expect(400);
expect(body(twice).error?.code).toBe('VAL_001');
// 한도 안은 묻는다 — 앞뒤 공백은 길이에 넣지 않는다
await list({
q: ` ${'가'.repeat(MAX_SEARCH_LENGTH)} `,
}).expect(200);
await list({ q: words(MAX_SEARCH_WORDS) }).expect(200);
expect(listQueries.length - before).toBe(2);
});
});
describe('여러 통 바꾸기 (PATCH /api/messages)', () => {
const patch = (payload: object) =>
request(server)
.patch('/api/messages')
.set('Cookie', cookieOf(ME))
.send(payload);
it('200 { updatedIds, failedIds } — 바꾸지 못한 메일은 failedIds, 한 통 PATCH 경로와 따로 논다', async () => {
const res = await patch({
ids: ['eaaaaab', GONE_MESSAGE],
isRead: true,
}).expect(200);
expect(body(res)).toEqual({
success: true,
data: { updatedIds: ['eaaaaab'], failedIds: [GONE_MESSAGE] },
});
expect(bulkUpdates.at(-1)).toEqual({
user: ME,
ids: ['eaaaaab', GONE_MESSAGE],
update: { isRead: true, isFlagged: undefined, mailboxId: undefined },
});
});
it('없는 메일함으로 옮기면 404 RES_001', async () => {
const res = await patch({
ids: ['eaaaaab'],
mailboxId: MISSING_MAILBOX,
}).expect(404);
expect(body(res).error?.code).toBe('RES_001');
});
it(`ids 없음 · 빈 ids · ${MAX_BULK_MESSAGE_IDS}개 초과 · 같은 id 두 번 · 형식이 틀린 id · 바꿀 것 없음 · 모르는 필드는 400 VAL_001 — 엔진에 묻지 않는다`, async () => {
const before = bulkUpdates.length;
const many = Array.from(
{ length: MAX_BULK_MESSAGE_IDS + 1 },
(_, index) => `m${index}`,
);
for (const payload of [
{ isRead: true },
{ ids: [], isRead: true },
{ ids: many, isRead: true },
{ ids: ['eaaaaab', 'eaaaaab'], isRead: true },
{ ids: ['../eaaaaab'], isRead: true },
{ ids: ['eaaaaab'] },
{ ids: ['eaaaaab'], isRead: null },
{ ids: ['eaaaaab'], isRead: true, isDeleted: true },
]) {
const res = await patch(payload).expect(400);
expect(body(res).error?.code).toBe('VAL_001');
}
expect(bulkUpdates).toHaveLength(before);
});
});
describe('휴지통에서 영구 삭제 (POST /api/message-deletions)', () => {
const remove = (payload: object) =>
request(server)
.post('/api/message-deletions')
.set('Cookie', cookieOf(ME))
.send(payload);
it('201 이 아니라 200 { deletedIds, skippedIds }', async () => {
const res = await remove({ ids: ['eaaaaab', GONE_MESSAGE] }).expect(
200,
);
expect(body(res)).toEqual({
success: true,
data: { deletedIds: ['eaaaaab'], skippedIds: [GONE_MESSAGE] },
});
expect(trashDeletions.at(-1)).toEqual({
user: ME,
ids: ['eaaaaab', GONE_MESSAGE],
});
});
it(`ids 없음 · 빈 ids · ${MAX_BULK_MESSAGE_IDS}개 초과 · 같은 id 두 번 · 형식이 틀린 id · 모르는 필드는 400 VAL_001 — 지우지 않는다`, async () => {
const before = trashDeletions.length;
for (const payload of [
{},
{ ids: [] },
{
ids: Array.from(
{ length: MAX_BULK_MESSAGE_IDS + 1 },
(_, index) => `m${index}`,
),
},
{ ids: ['eaaaaab', 'eaaaaab'] },
{ ids: ['eaaaaab', 'a b'] },
{ ids: ['eaaaaab'], permanent: true },
]) {
const res = await remove(payload).expect(400);
expect(body(res).error?.code).toBe('VAL_001');
}
expect(trashDeletions).toHaveLength(before);
});
});
});
/**
* 출처 검사(CSRF) — 상태를 바꾸는 요청은 Sec-Fetch-Site(same-origin·none) 또는 허용 목록의 Origin 이어야 한다.
* 둘 다 없으면 브라우저가 아닌 클라이언트로 보고 통과시킨다 (docs/mail/api-contract.md 머리말).
@@ -1419,7 +1693,9 @@ describe('응답 계약 (e2e)', () => {
['post', '/api/auth/refresh'],
['post', '/api/messages'],
['patch', '/api/messages/eaaaaab'],
['patch', '/api/messages'],
['delete', '/api/messages/eaaaaab'],
['post', '/api/message-deletions'],
['post', '/api/mail-attachments'],
] as const)(
'Sec-Fetch-Site 없이 허용 목록에 없는 Origin 이면 %s %s 도 403 AUTH_003 (로그인 검사보다 먼저)',