generated from ttipo/project-base
feat(backend): 새 메일 확인·전체 메일함 검색(한국어 조사)·여러 통 수정·휴지통 영구 삭제 API
- GET /api/mail-state: 메일함·메일 상태를 합친 불투명 값 — 화면이 1분마다 바뀐 것이 있는지만 묻는다 - GET /api/messages: mailboxId 를 선택으로(없으면 휴지통·스팸메일함을 뺀 전체). 엔진은 낱말이 통째로 같을 때만 맞으므로 q 를 낱말·구절로 나눠 낱말마다 조사를 붙인 형태를 OR 로 찾는다 (jmap/search-terms.ts) - PATCH /api/messages: 읽음·중요·이동을 100통까지 엔진 요청 한 번에. 없는 메일함이면 아무것도 바꾸지 않고 404 - POST /api/message-deletions: 휴지통에 있는 메일만, 확인한 상태 그대로일 때만(ifInState) 지운다 — 그사이 되돌린 메일을 지우지 않게 - 로그의 cursor 도 길이만 남긴다 — 커서에 검색어의 지문이 들어 있다 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -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' }),
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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 를 돌려준다
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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: '메일 상세 — 조회만으로 읽음 처리하지 않는다',
|
||||
|
||||
@@ -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자>');
|
||||
// 값 안의 = 도 값이다
|
||||
|
||||
@@ -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('?');
|
||||
|
||||
Reference in New Issue
Block a user