diff --git a/backend/src/common/filters/http-exception.filter.spec.ts b/backend/src/common/filters/http-exception.filter.spec.ts index 844618a..13d7fb0 100644 --- a/backend/src/common/filters/http-exception.filter.spec.ts +++ b/backend/src/common/filters/http-exception.filter.spec.ts @@ -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자>', ]); }); diff --git a/backend/src/modules/mail/dto/bulk-messages.dto.spec.ts b/backend/src/modules/mail/dto/bulk-messages.dto.spec.ts new file mode 100644 index 0000000..898f886 --- /dev/null +++ b/backend/src/modules/mail/dto/bulk-messages.dto.spec.ts @@ -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 { + 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']); + }); +}); diff --git a/backend/src/modules/mail/dto/bulk-messages.dto.ts b/backend/src/modules/mail/dto/bulk-messages.dto.ts new file mode 100644 index 0000000..60c7b2f --- /dev/null +++ b/backend/src/modules/mail/dto/bulk-messages.dto.ts @@ -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[]; +} diff --git a/backend/src/modules/mail/dto/mail-response.dto.ts b/backend/src/modules/mail/dto/mail-response.dto.ts index c38f1cb..0d93aa2 100644 --- a/backend/src/modules/mail/dto/mail-response.dto.ts +++ b/backend/src/modules/mail/dto/mail-response.dto.ts @@ -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({ diff --git a/backend/src/modules/mail/dto/message-list-query.dto.spec.ts b/backend/src/modules/mail/dto/message-list-query.dto.spec.ts new file mode 100644 index 0000000..597bcfa --- /dev/null +++ b/backend/src/modules/mail/dto/message-list-query.dto.spec.ts @@ -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, +): 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', + ); + }); +}); diff --git a/backend/src/modules/mail/dto/message-list-query.dto.ts b/backend/src/modules/mail/dto/message-list-query.dto.ts index 7a86c7e..77edd0a 100644 --- a/backend/src/modules/mail/dto/message-list-query.dto.ts +++ b/backend/src/modules/mail/dto/message-list-query.dto.ts @@ -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; } diff --git a/backend/src/modules/mail/jmap/jmap-cursor.spec.ts b/backend/src/modules/mail/jmap/jmap-cursor.spec.ts index c5fa427..e1d4f1d 100644 --- a/backend/src/modules/mail/jmap/jmap-cursor.spec.ts +++ b/backend/src/modules/mail/jmap/jmap-cursor.spec.ts @@ -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' }), diff --git a/backend/src/modules/mail/jmap/jmap-cursor.ts b/backend/src/modules/mail/jmap/jmap-cursor.ts index 4daa7a4..cc03e69 100644 --- a/backend/src/modules/mail/jmap/jmap-cursor.ts +++ b/backend/src/modules/mail/jmap/jmap-cursor.ts @@ -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); } diff --git a/backend/src/modules/mail/jmap/jmap-mail.provider.spec.ts b/backend/src/modules/mail/jmap/jmap-mail.provider.spec.ts index a0a0f20..9213c8a 100644 --- a/backend/src/modules/mail/jmap/jmap-mail.provider.spec.ts +++ b/backend/src/modules/mail/jmap/jmap-mail.provider.spec.ts @@ -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>; + }; + 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 }; + + /** 메일 + 메일함 확인 — 그때의 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 }], diff --git a/backend/src/modules/mail/jmap/jmap-mail.provider.ts b/backend/src/modules/mail/jmap/jmap-mail.provider.ts index 41457ac..fafee56 100644 --- a/backend/src/modules/mail/jmap/jmap-mail.provider.ts +++ b/backend/src/modules/mail/jmap/jmap-mail.provider.ts @@ -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 = 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 { + const patch: Record = {}; + 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 { + 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 { + const responses = await this.jmap.call(user, (accountId) => [ + ['Mailbox/get', { accountId, ids: [] }, 'm'], + ['Email/get', { accountId, ids: [] }, 'e'], + ]); + const mailboxes = expectResult>( + responses, + 'm', + 'Mailbox/get', + ); + const emails = expectResult>( + 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 { 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>( - 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>( + 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(responses, 'q', 'Email/query'); @@ -520,17 +660,7 @@ export class JmapMailProvider implements MailProvider { id: string, update: MessageUpdate, ): Promise { - const patch: Record = {}; - 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 { + 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(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>( + 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 { 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 { + 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>( + lookup, + 'g', + 'Email/get', + ); + const { list: mailboxes = [] } = expectResult>( + 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 { + const lookup = await this.jmap.call(user, (accountId) => [ + [ + 'Mailbox/get', + { accountId, ids: null, properties: ['id', 'role'] }, + 'm', + ], + ]); + const { list: mailboxes = [] } = expectResult>( + 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 { - // 발송 중에 생긴 내부 초안은 목록에 보이지 않는다 — 사용자의 임시 저장본과 섞이지 않게 (계약 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 | undefined, + doing: string, + ): void { + const counts = new Map(); + 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 와 엔진의 메일 한 통 첨부 한도 중 작은 값이다. diff --git a/backend/src/modules/mail/jmap/jmap.types.ts b/backend/src/modules/mail/jmap/jmap.types.ts index 71eaf95..7803871 100644 --- a/backend/src/modules/mail/jmap/jmap.types.ts +++ b/backend/src/modules/mail/jmap/jmap.types.ts @@ -121,6 +121,28 @@ export interface JmapSetResult { notDestroyed?: Record; } +/** + * 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 { + operator: 'AND' | 'OR' | 'NOT'; + conditions: (T | JmapFilterOperator)[]; +} + +export type JmapEmailFilter = + | JmapEmailFilterCondition + | JmapFilterOperator; + export interface JmapQueryResult { ids?: string[]; position?: number; diff --git a/backend/src/modules/mail/jmap/search-terms.spec.ts b/backend/src/modules/mail/jmap/search-terms.spec.ts new file mode 100644 index 0000000..af40546 --- /dev/null +++ b/backend/src/modules/mail/jmap/search-terms.spec.ts @@ -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), + ); + }); + }); +}); diff --git a/backend/src/modules/mail/jmap/search-terms.ts b/backend/src/modules/mail/jmap/search-terms.ts new file mode 100644 index 0000000..258f7fa --- /dev/null +++ b/backend/src/modules/mail/jmap/search-terms.ts @@ -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> = { + '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(); + 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; +} diff --git a/backend/src/modules/mail/mail.constants.ts b/backend/src/modules/mail/mail.constants.ts index 373e565..a0c1a31 100644 --- a/backend/src/modules/mail/mail.constants.ts +++ b/backend/src/modules/mail/mail.constants.ts @@ -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; diff --git a/backend/src/modules/mail/mail.module.ts b/backend/src/modules/mail/mail.module.ts index 64570e5..1417191 100644 --- a/backend/src/modules/mail/mail.module.ts +++ b/backend/src/modules/mail/mail.module.ts @@ -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, diff --git a/backend/src/modules/mail/mail.provider.ts b/backend/src/modules/mail/mail.provider.ts index 1eb0dee..a4c1499 100644 --- a/backend/src/modules/mail/mail.provider.ts +++ b/backend/src/modules/mail/mail.provider.ts @@ -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; + /** + * 새 메일 확인용 상태 값 — 메일함이나 메일이 하나라도 바뀌면 달라진다 (메일을 읽지 않는 가벼운 조회). + * 엔진이 상태를 알려 주지 않으면 MAIL_001. + */ + getMailState(user: string): Promise; + + /** + * 메일 목록 (받은 시각 내림차순). `mailboxId` 가 없으면 휴지통·스팸메일함을 뺀 모든 메일함에서 찾는다 (검색). + * `text` 는 엔진의 낱말 검색 규칙에 맞춰 넓혀 찾는다 (한국어 조사 — 계약 2절 "검색"). 없는 메일함은 RES_001. + * `text` 에 찾을 낱말(문자·숫자)이 하나도 없으면 엔진에 묻지 않고 빈 목록을 돌려준다 (메일함 범위여도 — 메일함을 확인하지 않는다). + */ listMessages(user: string, query: MessageListQuery): Promise; /** 조회만 한다 — 읽음 처리하지 않는다 */ @@ -71,9 +85,29 @@ export interface MailProvider { update: MessageUpdate, ): Promise; + /** + * 여러 통에 같은 변경(읽음 · 중요 표시 · 이동)을 한다 — 엔진 요청 한 번. 바꾸지 못한 메일(없음 · 엔진이 거절)은 failedIds 로. + * 옮길 메일함이 없으면 아무것도 바꾸지 않고 RES_001. + */ + updateMessages( + user: string, + ids: readonly string[], + update: MessageUpdate, + ): Promise; + /** 휴지통이 아니면 휴지통으로 옮기고, 휴지통이면 영구 삭제한다 */ deleteMessage(user: string, id: string): Promise; + /** + * 휴지통에 있는 메일만 영구 삭제한다 (여러 통) — 휴지통 밖의 메일은 옮기지 않고 건너뛴다. + * 확인한 상태 그대로일 때만 한 번에 지운다(휴지통에만 있으면 영구 삭제, 다른 메일함에도 있으면 휴지통에서만 뺀다). + * 확인하는 사이 계속 바뀌거나 휴지통이 없으면 아무것도 지우지 않고 MAIL_001. + */ + deleteFromTrash( + user: string, + ids: readonly string[], + ): Promise; + /** * @returns 보낸편지함에 남은 메일 id * 첨부 합계가 한도(MAIL_ATTACHMENT_TOTAL_MAX_SIZE_MB 와 엔진 한도 중 작은 값)를 넘으면 FILE_002, diff --git a/backend/src/modules/mail/mail.service.spec.ts b/backend/src/modules/mail/mail.service.spec.ts index 78ab28d..cf84411 100644 --- a/backend/src/modules/mail/mail.service.spec.ts +++ b/backend/src/modules/mail/mail.service.spec.ts @@ -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()); diff --git a/backend/src/modules/mail/mail.service.ts b/backend/src/modules/mail/mail.service.ts index 2b21663..6478106 100644 --- a/backend/src/modules/mail/mail.service.ts +++ b/backend/src/modules/mail/mail.service.ts @@ -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 { + return this.provider.getMailState(user.email); + } + + /** + * 메일 목록 · 검색 — mailboxId 와 검색어(q) 중 하나는 있어야 한다 (둘 다 없으면 VAL_001). + * mailboxId 가 없으면 휴지통·스팸메일함을 뺀 모든 메일함에서 찾는다 (계약 2절 "검색"). 검색어는 어떤 로그에도 남기지 않는다. + */ async listMessages( user: AuthenticatedUser, query: MessageListQueryDto, ): Promise> { - 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 { - 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 { + return this.provider.updateMessages( + user.email, + dto.ids, + toMessageUpdate(dto), + ); } deleteMessage(user: AuthenticatedUser, id: string): Promise { return this.provider.deleteMessage(user.email, id); } + /** + * 휴지통에서 영구 삭제 (여러 통) — 휴지통에 있는 메일만 지운다(휴지통 밖의 메일은 건너뛴다). + * 되돌릴 수 없는 일이라 지운 것이 있으면 개수만 남긴다 (주소·id 목록 없이). + */ + async deleteFromTrash( + user: AuthenticatedUser, + dto: MessageDeletionDto, + ): Promise { + 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 를 돌려준다 diff --git a/backend/src/modules/mail/mail.types.ts b/backend/src/modules/mail/mail.types.ts index d8272ab..36768ce 100644 --- a/backend/src/modules/mail/mail.types.ts +++ b/backend/src/modules/mail/mail.types.ts @@ -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; diff --git a/backend/src/modules/mail/mailbox.controller.ts b/backend/src/modules/mail/mailbox.controller.ts index 8cc08fa..b1940b0 100644 --- a/backend/src/modules/mail/mailbox.controller.ts +++ b/backend/src/modules/mail/mailbox.controller.ts @@ -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); + } +} diff --git a/backend/src/modules/mail/message-deletion.controller.ts b/backend/src/modules/mail/message-deletion.controller.ts new file mode 100644 index 0000000..eece9f4 --- /dev/null +++ b/backend/src/modules/mail/message-deletion.controller.ts @@ -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); + } +} diff --git a/backend/src/modules/mail/message.controller.ts b/backend/src/modules/mail/message.controller.ts index 26c5fde..4ea8b05 100644 --- a/backend/src/modules/mail/message.controller.ts +++ b/backend/src/modules/mail/message.controller.ts @@ -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: '메일 상세 — 조회만으로 읽음 처리하지 않는다', diff --git a/backend/src/shared/utils/mask.spec.ts b/backend/src/shared/utils/mask.spec.ts index 293608f..0160da3 100644 --- a/backend/src/shared/utils/mask.spec.ts +++ b/backend/src/shared/utils/mask.spec.ts @@ -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자>'); // 값 안의 = 도 값이다 diff --git a/backend/src/shared/utils/mask.ts b/backend/src/shared/utils/mask.ts index 8ba20c1..1bbadd0 100644 --- a/backend/src/shared/utils/mask.ts +++ b/backend/src/shared/utils/mask.ts @@ -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('?'); diff --git a/backend/test/app.e2e-spec.ts b/backend/test/app.e2e-spec.ts index f15b46f..b41effa 100644 --- a/backend/test/app.e2e-spec.ts +++ b/backend/test/app.e2e-spec.ts @@ -17,6 +17,9 @@ import { AUTH_SETTINGS, type AuthSettings } from '@/modules/auth/auth.settings'; import { MAX_BODY_HTML_LENGTH, MAX_BODY_LENGTH, + MAX_BULK_MESSAGE_IDS, + MAX_SEARCH_LENGTH, + MAX_SEARCH_WORDS, MAX_SUBJECT_LENGTH, } from '@/modules/mail/mail.constants'; import { MAIL_PROVIDER } from '@/modules/mail/mail.provider'; @@ -25,6 +28,10 @@ import type { Correspondence, DraftContent, DraftSource, + MessageDeletionResult, + MessageListQuery, + MessagesPatchResult, + MessageUpdate, OutgoingMessage, SavedDraft, } from '@/modules/mail/mail.types'; @@ -100,6 +107,15 @@ const SLOW_ACCOUNT = 'slow.target@e2e.arasystem.test'; /** 대역 메일 엔진이 "다른 창·기기에서 먼저 바뀌었다"고 답하는 임시 저장본 id (409 MAIL_008 경로) */ const DRAFT_CHANGED_ELSEWHERE = 'draftchangedelsewhere'; +/** 대역 메일 엔진이 돌려주는 새 메일 확인 값 */ +const MAIL_STATE = 'c2Iy.c2Fq'; + +/** 대역 메일 엔진이 "없는 메일함"으로 답하는 이동 대상 (404 RES_001 경로) */ +const MISSING_MAILBOX = 'nomailbox'; + +/** 대역 메일 엔진이 "그사이 지워졌다"고 답하는 메일 id (failedIds · skippedIds 경로) */ +const GONE_MESSAGE = 'gonemessage'; + /** 대역 메일 엔진이 로그인 때 알려 주는 이름 — 없으면 EMPLOYEE.name */ const PROFILE_NAMES = new Map([ ['colleague.a@e2e.arasystem.test', '가나다'], @@ -191,6 +207,16 @@ describe('응답 계약 (e2e)', () => { /** 대역 엔진이 돌려줄 주고받은 주소 (직원별) */ const correspondents = new Map(); + /** 대역 엔진이 받은 메일 목록 요청 (검색 범위·검색어 확인용) */ + const listQueries: { user: string; query: MessageListQuery }[] = []; + /** 대역 엔진이 받은 여러 통 바꾸기 · 휴지통에서 영구 삭제 */ + const bulkUpdates: { + user: string; + ids: readonly string[]; + update: MessageUpdate; + }[] = []; + const trashDeletions: { user: string; ids: readonly string[] }[] = []; + /** 이 앱의 서명 키로 토큰을 만든다 (CI 처럼 JWT_SECRET 이 없으면 임시 키) */ const sign = (payload: object, expiresIn: number) => jwt.sign(payload, { @@ -199,6 +225,10 @@ describe('응답 계약 (e2e)', () => { expiresIn, }); + /** 그 직원의 액세스 쿠키 (세션 없이 서명만 맞춘다 — 보호 라우트는 액세스 토큰만 본다) */ + const cookieOf = (email: string) => + `access_token=${sign({ sub: email, typ: 'access', name: '테스트 직원' }, 600)}`; + /** 로그인해서 두 쿠키 값을 받는다 (서버에 세션이 생긴다) */ const login = async (email: string) => { const res = await request(server) @@ -301,6 +331,45 @@ describe('응답 계약 (e2e)', () => { }, listCorrespondents: (user: string) => Promise.resolve(correspondents.get(user) ?? []), + // 3단계 — 엔진 쪽 판단(검색 조건 · 조건부 영구 삭제)은 jmap-mail.provider.spec.ts 가 본다. 여기서는 받은 값만 기록한다 + getMailState: () => Promise.resolve({ state: MAIL_STATE }), + listMessages: (user: string, query: MessageListQuery) => { + listQueries.push({ user, query }); + return Promise.resolve({ + items: [], + hasNext: false, + nextCursor: null, + total: 0, + }); + }, + updateMessages: ( + user: string, + ids: readonly string[], + update: MessageUpdate, + ): Promise => { + bulkUpdates.push({ user, ids, update }); + if (update.mailboxId === MISSING_MAILBOX) { + return Promise.reject( + new BusinessException('RES_001', { + context: { reason: 'e2e-mailbox-not-found' }, + }), + ); + } + return Promise.resolve({ + updatedIds: ids.filter((id) => id !== GONE_MESSAGE), + failedIds: ids.filter((id) => id === GONE_MESSAGE), + }); + }, + deleteFromTrash: ( + user: string, + ids: readonly string[], + ): Promise => { + trashDeletions.push({ user, ids }); + return Promise.resolve({ + deletedIds: ids.filter((id) => id !== GONE_MESSAGE), + skippedIds: ids.filter((id) => id === GONE_MESSAGE), + }); + }, }) .overrideProvider(MAIL_SETTINGS) .useValue(TEST_MAIL_SETTINGS) @@ -442,7 +511,7 @@ describe('응답 계약 (e2e)', () => { ); }); - it('다른 API 는 그대로 1MB — 같은 2MB 본문이 로그인·메일 수정(PATCH /api/messages/:id)에서는 413 FILE_002', async () => { + it('다른 API 는 그대로 1MB — 같은 2MB 본문이 로그인·메일 수정(PATCH /api/messages/:id · 여러 통 PATCH)·영구 삭제에서는 413 FILE_002', async () => { const twoMegabytes = JSON.stringify({ email: 'x', password: 'y'.repeat(2_000_000), @@ -451,6 +520,8 @@ describe('응답 계약 (e2e)', () => { for (const [method, url] of [ ['post', '/api/auth/login'], ['patch', '/api/messages/eaaaaab'], + ['patch', '/api/messages'], + ['post', '/api/message-deletions'], ] as const) { const res = await request(server) [method](url) @@ -677,6 +748,10 @@ describe('응답 계약 (e2e)', () => { '/api/mail-signature/images/3f1c0a2e-0f1b-4f2a-9a1e-6c9f0a1b2c3d', ], ['get', '/api/recipient-suggestions'], + ['get', '/api/mail-state'], + ['get', '/api/messages?q=%ED%9A%8C%EC%9D%98'], + ['patch', '/api/messages'], + ['post', '/api/message-deletions'], // 템플릿 업로드 예제도 로그인한 직원만 쓴다 ['post', '/api/files'], ] as const)('쿠키 없이 %s %s 는 401 AUTH_001', async (method, path) => { @@ -763,9 +838,6 @@ describe('응답 계약 (e2e)', () => { * 엔진 쪽 판단(초안인지·옛것이 그사이 없어졌는지)은 대역 엔진이 흉내 낸다 — 여기서는 HTTP 형식·정화·DB·파일을 본다. */ describe('2단계 — 임시 저장 · 서명 · 받는 사람 추천', () => { - /** 그 직원의 액세스 쿠키 (세션 없이 서명만 맞춘다 — 보호 라우트는 액세스 토큰만 본다) */ - const cookieOf = (email: string) => - `access_token=${sign({ sub: email, typ: 'access', name: '테스트 직원' }, 600)}`; const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/; const DOYUN = { name: '이도윤', email: 'doyun.lee@e2e.arasystem.test' }; @@ -1395,6 +1467,208 @@ describe('응답 계약 (e2e)', () => { }); }); + /** + * 3단계 — 새 메일 확인 · 검색 · 여러 통 한꺼번에 (계약 2절). + * 엔진 쪽 판단(조사를 붙인 검색 조건 · 휴지통·스팸메일함 빼기 · 조건부 영구 삭제)은 jmap-mail.provider.spec.ts 가 본다 — + * 여기서는 라우트 · 검증(VAL_001) · 상태 코드 · 응답 형식 · 헤더를 본다. + */ + describe('3단계 — 새 메일 확인 · 검색 · 여러 통', () => { + const ME = 'bulk.me@e2e.arasystem.test'; + + describe('새 메일 확인 (GET /api/mail-state)', () => { + it('불투명 상태 값을 표준 래퍼로 — Cache-Control: private, no-store', async () => { + const res = await request(server) + .get('/api/mail-state') + .set('Cookie', cookieOf(ME)) + .expect(200); + + expect(body(res)).toEqual({ + success: true, + data: { state: MAIL_STATE }, + }); + expect(res.headers['cache-control']).toBe('private, no-store'); + }); + }); + + describe('검색 (GET /api/messages 의 q)', () => { + const list = (params: Record) => + request(server) + .get('/api/messages') + .query(params) + .set('Cookie', cookieOf(ME)); + + it('q 만 있으면 메일함 없이(모든 메일함 검색) 앞뒤 공백을 뺀 검색어로 묻는다 — 목록 응답 형식 그대로', async () => { + const before = listQueries.length; + + const res = await list({ q: ' 회의 일정 ', limit: 20 }).expect(200); + + expect(dataOf(res)).toEqual({ + items: [], + pageInfo: { hasNext: false, nextCursor: null, total: 0 }, + }); + expect(listQueries.slice(before)).toEqual([ + { + user: ME, + query: { + mailboxId: undefined, + limit: 20, + cursor: undefined, + text: '회의 일정', + }, + }, + ]); + }); + + it('메일함과 검색어를 함께 주면 그 메일함에서 찾는다 · 메일함만 주면 지금처럼 목록', async () => { + const before = listQueries.length; + + await list({ mailboxId: 'a', q: '"회의를 잡겠습니다"' }).expect(200); + await list({ mailboxId: 'a' }).expect(200); + + expect( + listQueries + .slice(before) + .map(({ query }) => [query.mailboxId, query.text]), + ).toEqual([ + ['a', '"회의를 잡겠습니다"'], + ['a', undefined], + ]); + }); + + it(`메일함도 검색어도 없음 · 공백뿐인 검색어 · ${MAX_SEARCH_LENGTH}자 초과 · 낱말 ${MAX_SEARCH_WORDS}개 초과 · 같은 이름 두 번은 400 VAL_001 — 엔진에 묻지 않는다`, async () => { + const before = listQueries.length; + const words = (count: number) => + Array.from({ length: count }, (_, index) => `w${index}`).join(' '); + + const rejected: Record[] = [ + {}, + { q: ' ' }, + { q: '가'.repeat(MAX_SEARCH_LENGTH + 1) }, + { q: words(MAX_SEARCH_WORDS + 1) }, + { mailboxId: '../a', q: '회의' }, + ]; + for (const params of rejected) { + const res = await list(params).expect(400); + expect(body(res).error?.code).toBe('VAL_001'); + } + const twice = await request(server) + .get('/api/messages?q=a&q=b') + .set('Cookie', cookieOf(ME)) + .expect(400); + expect(body(twice).error?.code).toBe('VAL_001'); + + // 한도 안은 묻는다 — 앞뒤 공백은 길이에 넣지 않는다 + await list({ + q: ` ${'가'.repeat(MAX_SEARCH_LENGTH)} `, + }).expect(200); + await list({ q: words(MAX_SEARCH_WORDS) }).expect(200); + expect(listQueries.length - before).toBe(2); + }); + }); + + describe('여러 통 바꾸기 (PATCH /api/messages)', () => { + const patch = (payload: object) => + request(server) + .patch('/api/messages') + .set('Cookie', cookieOf(ME)) + .send(payload); + + it('200 { updatedIds, failedIds } — 바꾸지 못한 메일은 failedIds, 한 통 PATCH 경로와 따로 논다', async () => { + const res = await patch({ + ids: ['eaaaaab', GONE_MESSAGE], + isRead: true, + }).expect(200); + + expect(body(res)).toEqual({ + success: true, + data: { updatedIds: ['eaaaaab'], failedIds: [GONE_MESSAGE] }, + }); + expect(bulkUpdates.at(-1)).toEqual({ + user: ME, + ids: ['eaaaaab', GONE_MESSAGE], + update: { isRead: true, isFlagged: undefined, mailboxId: undefined }, + }); + }); + + it('없는 메일함으로 옮기면 404 RES_001', async () => { + const res = await patch({ + ids: ['eaaaaab'], + mailboxId: MISSING_MAILBOX, + }).expect(404); + + expect(body(res).error?.code).toBe('RES_001'); + }); + + it(`ids 없음 · 빈 ids · ${MAX_BULK_MESSAGE_IDS}개 초과 · 같은 id 두 번 · 형식이 틀린 id · 바꿀 것 없음 · 모르는 필드는 400 VAL_001 — 엔진에 묻지 않는다`, async () => { + const before = bulkUpdates.length; + const many = Array.from( + { length: MAX_BULK_MESSAGE_IDS + 1 }, + (_, index) => `m${index}`, + ); + + for (const payload of [ + { isRead: true }, + { ids: [], isRead: true }, + { ids: many, isRead: true }, + { ids: ['eaaaaab', 'eaaaaab'], isRead: true }, + { ids: ['../eaaaaab'], isRead: true }, + { ids: ['eaaaaab'] }, + { ids: ['eaaaaab'], isRead: null }, + { ids: ['eaaaaab'], isRead: true, isDeleted: true }, + ]) { + const res = await patch(payload).expect(400); + expect(body(res).error?.code).toBe('VAL_001'); + } + expect(bulkUpdates).toHaveLength(before); + }); + }); + + describe('휴지통에서 영구 삭제 (POST /api/message-deletions)', () => { + const remove = (payload: object) => + request(server) + .post('/api/message-deletions') + .set('Cookie', cookieOf(ME)) + .send(payload); + + it('201 이 아니라 200 { deletedIds, skippedIds }', async () => { + const res = await remove({ ids: ['eaaaaab', GONE_MESSAGE] }).expect( + 200, + ); + + expect(body(res)).toEqual({ + success: true, + data: { deletedIds: ['eaaaaab'], skippedIds: [GONE_MESSAGE] }, + }); + expect(trashDeletions.at(-1)).toEqual({ + user: ME, + ids: ['eaaaaab', GONE_MESSAGE], + }); + }); + + it(`ids 없음 · 빈 ids · ${MAX_BULK_MESSAGE_IDS}개 초과 · 같은 id 두 번 · 형식이 틀린 id · 모르는 필드는 400 VAL_001 — 지우지 않는다`, async () => { + const before = trashDeletions.length; + + for (const payload of [ + {}, + { ids: [] }, + { + ids: Array.from( + { length: MAX_BULK_MESSAGE_IDS + 1 }, + (_, index) => `m${index}`, + ), + }, + { ids: ['eaaaaab', 'eaaaaab'] }, + { ids: ['eaaaaab', 'a b'] }, + { ids: ['eaaaaab'], permanent: true }, + ]) { + const res = await remove(payload).expect(400); + expect(body(res).error?.code).toBe('VAL_001'); + } + expect(trashDeletions).toHaveLength(before); + }); + }); + }); + /** * 출처 검사(CSRF) — 상태를 바꾸는 요청은 Sec-Fetch-Site(same-origin·none) 또는 허용 목록의 Origin 이어야 한다. * 둘 다 없으면 브라우저가 아닌 클라이언트로 보고 통과시킨다 (docs/mail/api-contract.md 머리말). @@ -1419,7 +1693,9 @@ describe('응답 계약 (e2e)', () => { ['post', '/api/auth/refresh'], ['post', '/api/messages'], ['patch', '/api/messages/eaaaaab'], + ['patch', '/api/messages'], ['delete', '/api/messages/eaaaaab'], + ['post', '/api/message-deletions'], ['post', '/api/mail-attachments'], ] as const)( 'Sec-Fetch-Site 없이 허용 목록에 없는 Origin 이면 %s %s 도 403 AUTH_003 (로그인 검사보다 먼저)',