From 05fc3909dd5480e9731870c919d662ba5baa6bb3 Mon Sep 17 00:00:00 2001 From: ttipo Date: Wed, 28 Oct 2026 06:13:36 +0900 Subject: [PATCH] =?UTF-8?q?feat(backend):=20=EB=B9=84=EB=B0=80=EB=B2=88?= =?UTF-8?q?=ED=98=B8=20=EB=B3=80=EA=B2=BD=20=C2=B7=20=EB=A9=94=EC=9D=BC?= =?UTF-8?q?=ED=95=A8=20=EA=B4=80=EB=A6=AC=20=C2=B7=20=EB=B6=80=EC=9E=AC?= =?UTF-8?q?=EC=A4=91=20=EC=9E=90=EB=8F=99=20=EC=9D=91=EB=8B=B5=20=C2=B7=20?= =?UTF-8?q?=EC=8A=A4=ED=8C=B8=20=ED=91=9C=EC=8B=9C=20=C2=B7=20=EC=9B=90?= =?UTF-8?q?=EB=AC=B8=20API?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - PUT /api/auth/password: 직원 본인 자격으로 엔진의 x:AccountPassword/set(게이트웨이 · 관리자 권한 없이), 로그인과 같은 실패 카운터 · 진행 중 잠금. 바뀌면 리프레시 쿠키의 세션만 남기고 끊는다 — 결과를 모르는 실패에도 끊는다. 엔진이 바꾸기만 거절하면 세지 않는다 - 메일함 만들기 · 이름 바꾸기 · 지우기(안의 메일은 휴지통으로, onDestroyRemoveEmails false), 모두 읽음 · 휴지통 / 스팸메일함 비우기(조건부 지우기) — 500통씩 · 한 요청 25초 상한(엔진 호출마다 남은 시간), 같은 직원 · 같은 메일함은 하나씩(REQ_001), 진행 없는 차례가 이어지면 멈춘다 - GET · PUT /api/mail-vacation(켤 때만 제목 · 내용 필수, 제어 문자 검사, 초 단위), PATCH /api/messages(/{id}) 의 isJunk, GET /api/messages/{id}/source(message/rfc822) - 에러 코드 AUTH_005 · MAIL_009 추가와 클라이언트 사전 다시 생성, 공용 길이 검사 데코레이터(JS 문자열 길이) Co-Authored-By: Claude Opus 5.5 --- .../common/decorators/code-units.decorator.ts | 44 + backend/src/common/errors/error-codes.spec.ts | 10 + backend/src/common/errors/error-codes.ts | 11 + .../src/modules/auth/auth-session.entity.ts | 6 +- .../modules/auth/auth-session.repository.ts | 26 +- backend/src/modules/auth/auth.constants.ts | 7 + .../src/modules/auth/auth.controller.spec.ts | 44 +- backend/src/modules/auth/auth.controller.ts | 57 + backend/src/modules/auth/auth.service.spec.ts | 409 ++++- backend/src/modules/auth/auth.service.ts | 195 ++- .../auth/dto/change-password.dto.spec.ts | 86 + .../modules/auth/dto/change-password.dto.ts | 47 + .../modules/mail/attachment.policy.spec.ts | 45 + backend/src/modules/mail/attachment.policy.ts | 56 + .../mail/dto/bulk-messages.dto.spec.ts | 17 + .../src/modules/mail/dto/bulk-messages.dto.ts | 2 +- .../src/modules/mail/dto/mail-response.dto.ts | 45 + .../mail/dto/mail-vacation.dto.spec.ts | 178 ++ .../src/modules/mail/dto/mail-vacation.dto.ts | 106 ++ .../src/modules/mail/dto/mailbox.dto.spec.ts | 132 ++ backend/src/modules/mail/dto/mailbox.dto.ts | 83 + .../mail/dto/message-list-query.dto.ts | 19 +- .../modules/mail/dto/update-message.dto.ts | 12 +- .../mail/jmap/jmap-mail.provider.spec.ts | 1463 +++++++++++++++++ .../modules/mail/jmap/jmap-mail.provider.ts | 922 ++++++++++- .../src/modules/mail/jmap/jmap.client.spec.ts | 385 +++++ backend/src/modules/mail/jmap/jmap.client.ts | 262 ++- backend/src/modules/mail/jmap/jmap.mapper.ts | 45 + backend/src/modules/mail/jmap/jmap.types.ts | 14 + backend/src/modules/mail/mail.constants.ts | 22 + backend/src/modules/mail/mail.module.ts | 2 + backend/src/modules/mail/mail.provider.ts | 53 + backend/src/modules/mail/mail.service.spec.ts | 390 +++++ backend/src/modules/mail/mail.service.ts | 202 ++- backend/src/modules/mail/mail.types.ts | 74 + .../src/modules/mail/mailbox.controller.ts | 184 ++- .../modules/mail/mailbox.presenter.spec.ts | 12 +- backend/src/modules/mail/mailbox.presenter.ts | 13 +- .../src/modules/mail/message.controller.ts | 64 +- backend/src/shared/utils/format.spec.ts | 28 + backend/src/shared/utils/format.ts | 14 + backend/test/app.e2e-spec.ts | 774 +++++++++ flutter/lib/core/api/error_lexicon.g.dart | 2 + .../shared/constants/error-codes.generated.ts | 2 + 44 files changed, 6476 insertions(+), 88 deletions(-) create mode 100644 backend/src/common/decorators/code-units.decorator.ts create mode 100644 backend/src/modules/auth/dto/change-password.dto.spec.ts create mode 100644 backend/src/modules/auth/dto/change-password.dto.ts create mode 100644 backend/src/modules/mail/dto/mail-vacation.dto.spec.ts create mode 100644 backend/src/modules/mail/dto/mail-vacation.dto.ts create mode 100644 backend/src/modules/mail/dto/mailbox.dto.spec.ts create mode 100644 backend/src/modules/mail/dto/mailbox.dto.ts create mode 100644 backend/src/shared/utils/format.spec.ts diff --git a/backend/src/common/decorators/code-units.decorator.ts b/backend/src/common/decorators/code-units.decorator.ts new file mode 100644 index 0000000..82163a6 --- /dev/null +++ b/backend/src/common/decorators/code-units.decorator.ts @@ -0,0 +1,44 @@ +import { ValidateBy, type ValidationOptions } from 'class-validator'; + +/** + * 문자열 길이 검사 — JS 문자열 길이(UTF-16 코드 단위)로 센다. 웹 클라이언트가 세는 방법(`value.length`)과 같다. + * class-validator 의 MinLength·MaxLength(validator.js)는 서로게이트 쌍·이모지 표시 선택자를 덜 세어 + * 클라이언트가 센 길이와 어긋난다 (modules/mail/dto/send-message.dto.ts 의 본문 한도와 같은 이유). + * 문자열이 아니면 실패한다 — 형식 오류는 IsString 이 함께 알린다. + */ + +/** 최대 길이 (JS 문자열 길이) */ +export function MaxCodeUnits( + max: number, + options: ValidationOptions, +): PropertyDecorator { + return ValidateBy( + { + name: 'maxCodeUnits', + constraints: [max], + validator: { + validate: (value: unknown) => + typeof value === 'string' && value.length <= max, + }, + }, + options, + ); +} + +/** 최소 길이 (JS 문자열 길이) */ +export function MinCodeUnits( + min: number, + options: ValidationOptions, +): PropertyDecorator { + return ValidateBy( + { + name: 'minCodeUnits', + constraints: [min], + validator: { + validate: (value: unknown) => + typeof value === 'string' && value.length >= min, + }, + }, + options, + ); +} diff --git a/backend/src/common/errors/error-codes.spec.ts b/backend/src/common/errors/error-codes.spec.ts index 89ea48b..dd40df6 100644 --- a/backend/src/common/errors/error-codes.spec.ts +++ b/backend/src/common/errors/error-codes.spec.ts @@ -44,6 +44,16 @@ describe('에러 코드 레지스트리', () => { expect(resolveCodeByStatus(HttpStatus.BAD_GATEWAY)).toBe('SYS_001'); }); + it('지금 비밀번호 틀림(AUTH_005)은 401 이 아니라 400 이다 — 웹 클라이언트는 401 이면 세션을 갱신하고 같은 요청을 다시 보낸다', () => { + expect(ERROR_CODES.AUTH_005.status).toBe(HttpStatus.BAD_REQUEST); + // 400 의 역매핑은 그대로 VAL_001 이다 (AUTH_005 는 서버가 명시적으로만 던진다) + expect(resolveCodeByStatus(HttpStatus.BAD_REQUEST)).toBe('VAL_001'); + }); + + it('같은 이름의 메일함(MAIL_009)은 409 다', () => { + expect(ERROR_CODES.MAIL_009.status).toBe(HttpStatus.CONFLICT); + }); + it('클라이언트 전용 코드(AUTH_002)는 서버가 상태 역매핑으로 선택하지 않는다', () => { const clientOnlyCodes = Object.entries(ERROR_CODES) .filter(([, definition]) => 'clientOnly' in definition) diff --git a/backend/src/common/errors/error-codes.ts b/backend/src/common/errors/error-codes.ts index 481aa7b..d372698 100644 --- a/backend/src/common/errors/error-codes.ts +++ b/backend/src/common/errors/error-codes.ts @@ -45,6 +45,12 @@ const COMMON_ERROR_CODES = { status: HttpStatus.UNAUTHORIZED, message: '이메일 또는 비밀번호가 올바르지 않습니다.', }, + // 비밀번호 바꾸기(PUT /api/auth/password)의 지금 비밀번호가 틀림 — 세션은 그대로다. + // 401 이 아니라 400 인 까닭: 웹 클라이언트는 401 이면 세션을 갱신하고 같은 요청을 다시 보낸다 (계약 1절) + AUTH_005: { + status: HttpStatus.BAD_REQUEST, + message: '지금 비밀번호가 올바르지 않습니다.', + }, VAL_001: { status: HttpStatus.BAD_REQUEST, message: '입력하신 정보를 다시 확인해 주세요. (필수값 누락 또는 형식 오류)', @@ -126,6 +132,11 @@ const DOMAIN_ERROR_CODES = { message: '임시 저장한 메일이 다른 곳에서 바뀌었거나 지워졌습니다. 지금 내용을 새 임시 저장본으로 남겨 주세요.', }, + // 메일함 만들기·이름 바꾸기 때 같은 상위 안에 같은 이름이 이미 있다 — 엔진이 alreadyExists 로 거절한다 (계약 2절 "메일함 관리") + MAIL_009: { + status: HttpStatus.CONFLICT, + message: '같은 이름의 메일함이 이미 있습니다.', + }, } as const satisfies Record; export const ERROR_CODES = { diff --git a/backend/src/modules/auth/auth-session.entity.ts b/backend/src/modules/auth/auth-session.entity.ts index 135b698..837615c 100644 --- a/backend/src/modules/auth/auth-session.entity.ts +++ b/backend/src/modules/auth/auth-session.entity.ts @@ -11,7 +11,11 @@ const REVOKED_REASON_LENGTH = 32; export type SessionRevokedReason = | 'logout' | 'refresh-token-reused' - | 'account-unavailable'; + | 'account-unavailable' + /** 비밀번호를 바꿨다 — 바꾼 세션 밖의 그 직원 세션 (계약 1절 "PUT /api/auth/password") */ + | 'password-changed' + /** 비밀번호 바꾸기의 결과를 알 수 없다(엔진이 바꿨을 수 있다) — 바꾼 세션 밖의 그 직원 세션 */ + | 'password-change-uncertain'; /** * 로그인 세션 (계약 1절 "세션 규칙") — 리프레시 토큰의 1회용 회전과 폐기를 서버에서 판단한다. diff --git a/backend/src/modules/auth/auth-session.repository.ts b/backend/src/modules/auth/auth-session.repository.ts index 6d6087b..835a7b6 100644 --- a/backend/src/modules/auth/auth-session.repository.ts +++ b/backend/src/modules/auth/auth-session.repository.ts @@ -1,6 +1,6 @@ import { Injectable } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; -import { IsNull, MoreThan, type Repository } from 'typeorm'; +import { IsNull, MoreThan, Not, type Repository } from 'typeorm'; import { AuthSessionEntity, @@ -71,6 +71,30 @@ export class AuthSessionRepository { ); } + /** + * 그 직원의 살아 있는 세션을 모두 끝낸다 — `keepId` 세션만 남긴다 (없으면 모두) → 끝낸 수. + * 이미 끝난(만료·폐기) 세션은 건드리지 않는다 (끝난 사유를 덮어쓰지 않게). 비밀번호를 바꿨을 때 쓴다. + * user_email 에는 인덱스가 없다 — 표가 작고(끝난 세션은 7일 뒤 정리) 드문 일이라 순차 조회로 충분하다. + */ + async revokeAllOf( + userEmail: string, + reason: SessionRevokedReason, + now: Date, + keepId: string | null, + ): Promise { + const result = await this.sessions.update( + { + userEmail, + revokedAt: IsNull(), + expiresAt: MoreThan(now), + absoluteExpiresAt: MoreThan(now), + ...(keepId !== null ? { id: Not(keepId) } : {}), + }, + { revokedAt: now, revokedReason: reason }, + ); + return result.affected ?? 0; + } + /** * `before` 보다 먼저 끝난(만료·폐기) 세션을 지운다 → 지운 수. * 미사용 만료(expires_at)는 항상 절대 만료 이하로 저장하므로(nextIdleExpiry) 절대 만료를 따로 볼 필요가 없다. diff --git a/backend/src/modules/auth/auth.constants.ts b/backend/src/modules/auth/auth.constants.ts index df8b941..ac74abe 100644 --- a/backend/src/modules/auth/auth.constants.ts +++ b/backend/src/modules/auth/auth.constants.ts @@ -70,3 +70,10 @@ export const LOGIN_REQUEST_WINDOW_MS = 60_000; /** 비밀번호 최대 길이 — 아주 긴 값으로 메일 엔진의 해시 계산을 오래 붙잡지 못하게 한다 */ export const MAX_PASSWORD_LENGTH = 256; + +/** + * 비밀번호 바꾸기의 새 비밀번호 (계약 1절 "PUT /api/auth/password") — 8~128자, 길이는 JS 문자열 길이(UTF-16 코드 단위)로 센다(웹 클라이언트와 같다). + * 엔진도 8자 미만을 거절한다(2026-10-28 확인). 지금 비밀번호는 로그인과 같은 검사(MAX_PASSWORD_LENGTH) — 로그인보다 긴 비밀번호로는 들어올 수 없다 + */ +export const NEW_PASSWORD_MIN_LENGTH = 8; +export const NEW_PASSWORD_MAX_LENGTH = 128; diff --git a/backend/src/modules/auth/auth.controller.spec.ts b/backend/src/modules/auth/auth.controller.spec.ts index 6f03dbe..5eb845e 100644 --- a/backend/src/modules/auth/auth.controller.spec.ts +++ b/backend/src/modules/auth/auth.controller.spec.ts @@ -28,7 +28,9 @@ function fakeResponse() { } /** 핸들러 함수 객체 — Nest 는 라우트 메타데이터(가드·한도)를 이 함수에 붙인다 */ -function handlerOf(name: 'login' | 'me' | 'refresh' | 'logout'): object { +function handlerOf( + name: 'login' | 'me' | 'refresh' | 'logout' | 'changePassword', +): object { const descriptor = Object.getOwnPropertyDescriptor( AuthController.prototype, name, @@ -37,7 +39,12 @@ function handlerOf(name: 'login' | 'me' | 'refresh' | 'logout'): object { } describe('AuthController', () => { - let auth: { login: jest.Mock; refresh: jest.Mock; logout: jest.Mock }; + let auth: { + login: jest.Mock; + refresh: jest.Mock; + logout: jest.Mock; + changePassword: jest.Mock; + }; let controller: AuthController; beforeEach(() => { @@ -45,6 +52,7 @@ describe('AuthController', () => { login: jest.fn(), refresh: jest.fn(), logout: jest.fn().mockResolvedValue(undefined), + changePassword: jest.fn().mockResolvedValue(undefined), }; controller = new AuthController(auth as unknown as AuthService, settings); }); @@ -167,6 +175,38 @@ describe('AuthController', () => { expect(res.clearCookie).not.toHaveBeenCalled(); }); + it('비밀번호 바꾸기는 로그인이 필요하고(JwtAuthGuard), 직원 · 두 비밀번호 · IP · 리프레시 쿠키를 서비스에 넘겨 null 을 돌려준다 — 쿠키는 굽지도 지우지도 않는다', async () => { + expect( + Reflect.getMetadata(GUARDS_METADATA, handlerOf('changePassword')), + ).toEqual([JwtAuthGuard]); + // 요청 수 상한은 전역 기본값 — 무차별 대입은 로그인과 같은 실패 한도가 막는다 + expect( + Reflect.getMetadata( + 'THROTTLER:LIMITdefault', + handlerOf('changePassword'), + ), + ).toBeUndefined(); + + const user = { email: 'ara.kim@arasystem.test', name: '김아라' }; + const req = { cookies: { refresh_token: 'this.refresh' } }; + + await expect( + controller.changePassword( + user, + { currentPassword: 'old-password', newPassword: 'new-password-1' }, + '203.0.113.10', + req as unknown as Request, + ), + ).resolves.toBeNull(); + expect(auth.changePassword).toHaveBeenCalledWith( + user, + 'old-password', + 'new-password-1', + '203.0.113.10', + 'this.refresh', + ); + }); + it('로그아웃은 리프레시 쿠키의 세션을 끊고, 항상 null 을 돌려주며 두 쿠키를 지운다', async () => { const res = fakeResponse(); diff --git a/backend/src/modules/auth/auth.controller.ts b/backend/src/modules/auth/auth.controller.ts index 4ca86aa..cca9e93 100644 --- a/backend/src/modules/auth/auth.controller.ts +++ b/backend/src/modules/auth/auth.controller.ts @@ -7,6 +7,7 @@ import { Inject, Ip, Post, + Put, Req, Res, UseGuards, @@ -35,6 +36,7 @@ import { import { AuthService } from './auth.service'; import { AUTH_SETTINGS, type AuthSettings } from './auth.settings'; import { AuthUserDto } from './dto/auth-user.dto'; +import { ChangePasswordDto } from './dto/change-password.dto'; import { LoginDto } from './dto/login.dto'; /** @@ -132,6 +134,61 @@ export class AuthController { } } + /** + * 비밀번호 바꾸기 — 판단은 모두 AuthService 가 한다. 리프레시 쿠키(Path=/api/auth 라 이 경로에도 온다)로 이 세션을 알아 + * 그 세션만 남기고 그 직원의 다른 세션을 끊는다. 쿠키는 굽지도 지우지도 않는다 (이 세션은 그대로 이어 쓴다). + * 요청 수 상한은 전역 기본값 — 무차별 대입은 로그인과 같은 실패 횟수 한도가 막는다. + */ + @Put('password') + @UseGuards(JwtAuthGuard) + @ApiCookieAuth() + @HttpCode(HttpStatus.OK) + @ApiOperation({ + summary: '비밀번호 바꾸기 — 지금 비밀번호로 메일 엔진에 본인 변경을 보낸다', + description: + '메일 엔진에 그 직원 본인의 자격(지금 비밀번호)으로 보낸다 — 게이트웨이 대리 접근·관리자 권한을 쓰지 않는다. ' + + '바뀌면 이 요청의 세션만 남기고 그 직원의 다른 로그인 세션을 모두 끊는다 (휴대폰·PC 메일 프로그램은 새 비밀번호를 다시 넣어야 한다). ' + + '지금 비밀번호 틀림은 로그인 실패와 같은 한도(같은 IP 1분 20회 · 같은 주소 15분 10회)로 함께 센다. ' + + '지금 비밀번호의 길이 한도는 로그인 비밀번호와 같다.', + }) + @ApiStandardResponse(undefined, { + description: '바꿨다 (data: null)', + }) + // 같은 400 이라 한 항목에 함께 적는다 (Swagger 는 상태 코드마다 하나만 남긴다) + @ApiErrorResponse( + 'AUTH_005', + 'AUTH_005 지금 비밀번호가 틀림 · 지금 비밀번호로 들어갔지만 메일 엔진이 바꾸기를 거절함(변경 권한 없음 등 — 이것은 실패로 세지 않는다) — 세션은 그대로다. ' + + '401 이 아니다(클라이언트는 401 이면 세션을 갱신하고 같은 요청을 다시 보낸다) · ' + + 'VAL_001 새 비밀번호가 8자 미만 · 128자 초과(JS 문자열 길이) · 지금 비밀번호와 같음 · 메일 엔진 규칙에 어긋남, 형식 오류 (모두 400)', + ) + @ApiErrorResponse( + 'REQ_001', + '지금 비밀번호 틀림이 잦음(로그인 실패와 같은 한도) · 같은 주소의 로그인·변경이 처리 중', + ) + @ApiErrorResponse( + 'AUTH_001', + '로그인 필요 (쿠키 없음·만료) · 웹메일로 다루지 않는 계정(메일 엔진 관리자 등)', + ) + @ApiErrorResponse( + 'MAIL_001', + '메일 엔진 장애 (바뀌지 않았다) · 결과를 알 수 없게 끝남(시간 초과 · 연결 끊김 · 엔진 5xx — 엔진이 바꿨을 수 있어 이 세션만 남기고 다른 세션은 끊어 두었다)', + ) + async changePassword( + @CurrentUser() user: AuthenticatedUser, + @Body() dto: ChangePasswordDto, + @Ip() ip: string, + @Req() req: Request, + ): Promise { + await this.auth.changePassword( + user, + dto.currentPassword, + dto.newPassword, + ip, + readCookie(req, REFRESH_TOKEN_COOKIE), + ); + return null; + } + @Post('logout') @HttpCode(HttpStatus.OK) @ApiOperation({ diff --git a/backend/src/modules/auth/auth.service.spec.ts b/backend/src/modules/auth/auth.service.spec.ts index 07d274e..4022f55 100644 --- a/backend/src/modules/auth/auth.service.spec.ts +++ b/backend/src/modules/auth/auth.service.spec.ts @@ -95,6 +95,31 @@ class FakeSessions { }, ); + /** 실제 저장소와 같은 조건 — 그 직원의 살아 있는 세션 가운데 keepId 를 뺀 것만 끝낸다 */ + readonly revokeAllOf = jest.fn( + ( + userEmail: string, + reason: AuthSessionEntity['revokedReason'], + now: Date, + keepId: string | null, + ) => { + let revoked = 0; + for (const row of this.rows.values()) { + const alive = + row.userEmail === userEmail && + row.revokedAt === null && + row.expiresAt > now && + row.absoluteExpiresAt > now && + row.id !== keepId; + if (alive) { + Object.assign(row, { revokedAt: now, revokedReason: reason }); + revoked += 1; + } + } + return Promise.resolve(revoked); + }, + ); + only(): AuthSessionEntity { const [row] = [...this.rows.values()]; if (!row) throw new Error('세션이 없다'); @@ -132,8 +157,22 @@ function countCodes(results: PromiseSettledResult[]) { const delay = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); +/** 조건이 될 때까지 짧게 다시 본다 — 고정 대기 대신, 기한은 넉넉하게 */ +async function until( + condition: () => boolean, + deadlineMs = 5_000, +): Promise { + const deadline = Date.now() + deadlineMs; + while (!condition()) { + if (Date.now() > deadline) throw new Error('기한 안에 조건이 되지 않았다'); + await delay(1); + } +} + describe('AuthService', () => { - let mail: jest.Mocked>; + let mail: jest.Mocked< + Pick + >; let attempts: LoginAttemptService; let sessions: FakeSessions; let directory: { recordLogin: jest.Mock }; @@ -184,6 +223,7 @@ describe('AuthService', () => { .mockImplementation((email: string) => Promise.resolve({ email, name: '김아라' }), ), + changePassword: jest.fn().mockResolvedValue('changed'), }; attempts = new LoginAttemptService(); sessions = new FakeSessions(); @@ -715,6 +755,373 @@ describe('AuthService', () => { }); }); + /** + * 비밀번호 바꾸기 (계약 1절 "PUT /api/auth/password") — 엔진 쪽 판단은 대역으로 두고(jmap.client.spec.ts 가 본다), + * 한도 · 세션 끊기 · 로그를 실제 LoginAttemptService 와 세션 표 대역으로 확인한다. + */ + describe('비밀번호 바꾸기', () => { + const NEW_PASSWORD = 'new-horse-battery-staple'; + const DOYUN = 'doyun.lee@arasystem.test'; + const me = { email: ARA, name: '김아라' }; + + const change = ( + currentPassword = PASSWORD, + refreshToken?: string, + ip = CLIENT_IP, + ) => + service.changePassword( + me, + currentPassword, + NEW_PASSWORD, + ip, + refreshToken, + ); + + beforeEach(() => { + // 로그인한 주소 그대로 엔진이 확인해 준다 (다른 직원의 세션도 만들 수 있게) + mail.verifyCredentials.mockImplementation((email: string) => + Promise.resolve(email), + ); + mail.changePassword.mockResolvedValue('changed'); + }); + + it('본인 자격으로 엔진에 바꾸기를 보내고, 이 요청의 세션만 남기고 그 직원의 다른 세션을 모두 끊는다 — 다른 직원의 세션은 그대로', async () => { + const current = await loginToken(); + const other = await loginToken(); + const colleague = (await service.login(DOYUN, PASSWORD, CLIENT_IP)) + .tokens!.refreshToken; + + await expect(change(PASSWORD, current)).resolves.toBeUndefined(); + + expect(mail.changePassword).toHaveBeenCalledWith( + ARA, + PASSWORD, + NEW_PASSWORD, + ); + expect(sessions.revokeAllOf).toHaveBeenCalledWith( + ARA, + 'password-changed', + expect.any(Date), + decode(current).sid, + ); + expect(sessions.rows.get(decode(other).sid!)?.revokedReason).toBe( + 'password-changed', + ); + // 이 세션은 그대로 이어 쓴다 — 다른 세션은 갱신할 수 없다 + await expect(rotateFrom(current)).resolves.toEqual(expect.any(String)); + expect((await failureOf(service.refresh(other)))?.code).toBe('AUTH_001'); + await expect(rotateFrom(colleague)).resolves.toEqual(expect.any(String)); + }); + + it('리프레시 쿠키가 없거나 · 위조거나 · 다른 직원의 것이면 이 세션을 알 수 없어 그 직원의 세션을 모두 끊는다', async () => { + const colleague = (await service.login(DOYUN, PASSWORD, CLIENT_IP)) + .tokens!.refreshToken; + + for (const refreshToken of [undefined, 'not-a-jwt', colleague]) { + const mine = await loginToken(); + sessions.revokeAllOf.mockClear(); + + await change(PASSWORD, refreshToken); + + expect(sessions.revokeAllOf).toHaveBeenCalledWith( + ARA, + 'password-changed', + expect.any(Date), + null, + ); + expect(sessions.rows.get(decode(mine).sid!)?.revokedAt).not.toBeNull(); + } + // 다른 직원의 세션은 건드리지 않는다 + await expect(rotateFrom(colleague)).resolves.toEqual(expect.any(String)); + }); + + it('지금 비밀번호가 틀리면 400 AUTH_005 — 세션은 그대로, 로그인 실패와 같은 카운터로 센다 (주소 15분 10회를 넘으면 맞는 비밀번호 · 로그인도 REQ_001)', async () => { + const current = await loginToken(); + mail.changePassword.mockResolvedValue('current-password-rejected'); + + for (let i = 0; i < LOGIN_ADDRESS_FAILURE_LIMIT; i += 1) { + // IP 를 나눠 IP 한도(1분 20회)와 섞이지 않게 한다 — 주소 한도만 본다 + const failure = await failureOf( + change('wrong', current, `198.51.100.${i}`), + ); + expect(failure?.code).toBe('AUTH_005'); + expect(failure?.getStatus()).toBe(400); + } + expect(sessions.revokeAllOf).not.toHaveBeenCalled(); + + mail.changePassword.mockResolvedValue('changed'); + expect( + (await failureOf(change(PASSWORD, current, '192.0.2.10')))?.code, + ).toBe('REQ_001'); + expect( + (await failureOf(service.login(ARA, PASSWORD, '192.0.2.11')))?.code, + ).toBe('REQ_001'); + expect(mail.changePassword).toHaveBeenCalledTimes( + LOGIN_ADDRESS_FAILURE_LIMIT, + ); + }); + + it('로그인 실패와 한 카운터다 — 로그인 실패가 쌓인 주소는 비밀번호 바꾸기도 그만큼 덜 남는다 · IP 한도도 같다', async () => { + mail.verifyCredentials.mockResolvedValue(null); + for (let i = 0; i < LOGIN_ADDRESS_FAILURE_LIMIT - 1; i += 1) { + await failureOf(service.login(ARA, 'wrong', `198.51.100.${i}`)); + } + mail.changePassword.mockResolvedValue('current-password-rejected'); + expect( + (await failureOf(change('wrong', undefined, '192.0.2.20')))?.code, + ).toBe('AUTH_005'); + expect( + (await failureOf(change('wrong', undefined, '192.0.2.21')))?.code, + ).toBe('REQ_001'); + + // 같은 IP 의 로그인 실패 20회 — 다른 직원이라도 그 IP 의 비밀번호 바꾸기는 REQ_001 + for (let i = 0; i < LOGIN_IP_FAILURE_LIMIT; i += 1) { + await failureOf( + service.login(`guess${i}@arasystem.test`, 'wrong', '203.0.113.30'), + ); + } + const failure = await failureOf( + service.changePassword( + { email: DOYUN, name: '이도윤' }, + PASSWORD, + NEW_PASSWORD, + '203.0.113.30', + undefined, + ), + ); + expect(failure?.code).toBe('REQ_001'); + expect(failure?.context).toMatchObject({ scope: 'ip' }); + }); + + it('바뀌면 그 주소의 실패 기록을 지운다 (로그인 성공과 같다)', async () => { + mail.changePassword.mockResolvedValue('current-password-rejected'); + for (let i = 0; i < LOGIN_ADDRESS_FAILURE_LIMIT - 1; i += 1) { + await failureOf(change('wrong', undefined, `198.51.100.${i}`)); + } + mail.changePassword.mockResolvedValue('changed'); + await change(PASSWORD, undefined, '192.0.2.30'); + + mail.changePassword.mockResolvedValue('current-password-rejected'); + for (let i = 0; i < LOGIN_ADDRESS_FAILURE_LIMIT; i += 1) { + expect( + (await failureOf(change('wrong', undefined, `198.51.101.${i}`))) + ?.code, + ).toBe('AUTH_005'); + } + }); + + it('새 비밀번호가 지금과 같으면 엔진에 묻지 않고 VAL_001 — 실패로 세지 않는다', async () => { + for (let i = 0; i < LOGIN_ADDRESS_FAILURE_LIMIT + 2; i += 1) { + const failure = await failureOf( + service.changePassword(me, PASSWORD, PASSWORD, CLIENT_IP, undefined), + ); + expect(failure?.code).toBe('VAL_001'); + } + expect(mail.changePassword).not.toHaveBeenCalled(); + await expect(change()).resolves.toBeUndefined(); + }); + + it('엔진 규칙에 어긋난 새 비밀번호(VAL_001) · 엔진 장애(MAIL_001)는 실패로 세지 않는다 — 세션도 그대로', async () => { + const current = await loginToken(); + const other = await loginToken(); + + mail.changePassword.mockResolvedValue('new-password-rejected'); + for (let i = 0; i < LOGIN_ADDRESS_FAILURE_LIMIT + 2; i += 1) { + expect((await failureOf(change(PASSWORD, current)))?.code).toBe( + 'VAL_001', + ); + } + mail.changePassword.mockRejectedValue(new BusinessException('MAIL_001')); + for (let i = 0; i < LOGIN_ADDRESS_FAILURE_LIMIT + 2; i += 1) { + expect((await failureOf(change(PASSWORD, current)))?.code).toBe( + 'MAIL_001', + ); + } + + expect(sessions.revokeAllOf).not.toHaveBeenCalled(); + await expect(rotateFrom(other)).resolves.toEqual(expect.any(String)); + mail.changePassword.mockResolvedValue('changed'); + await expect(change(PASSWORD, current)).resolves.toBeUndefined(); + }); + + it('지금 비밀번호로 들어갔는데 엔진이 바꾸기를 거절하면(변경 권한 없음 등) 400 AUTH_005 — 실패로 세지 않고 세션도 그대로', async () => { + const current = await loginToken(); + const other = await loginToken(); + mail.changePassword.mockResolvedValue('current-password-refused'); + + for (let i = 0; i < LOGIN_ADDRESS_FAILURE_LIMIT + 2; i += 1) { + const failure = await failureOf(change(PASSWORD, current)); + expect(failure?.code).toBe('AUTH_005'); + expect(failure?.getStatus()).toBe(400); + expect(failure?.context).toMatchObject({ + reason: 'current-password-refused-by-mail-engine', + }); + } + + expect(sessions.revokeAllOf).not.toHaveBeenCalled(); + await expect(rotateFrom(other)).resolves.toEqual(expect.any(String)); + // 세지 않았다 — 맞는 비밀번호의 로그인은 그대로 된다 + await expect( + service.login(ARA, PASSWORD, '192.0.2.60'), + ).resolves.toMatchObject({ user: { email: ARA } }); + }); + + it('결과를 알 수 없게 끝나면(엔진이 바꿨을 수 있다) 다른 세션을 끊어 두고 503 MAIL_001 — 이 세션은 남기고, 실패로 세지 않는다', async () => { + const current = await loginToken(); + const other = await loginToken(); + mail.changePassword.mockResolvedValue('outcome-unknown'); + + const failure = await failureOf(change(PASSWORD, current)); + + expect(failure?.code).toBe('MAIL_001'); + expect(failure?.getStatus()).toBe(503); + expect(failure?.context).toMatchObject({ + reason: 'password-change-outcome-unknown', + revokedOtherSessions: 1, + }); + expect(sessions.revokeAllOf).toHaveBeenCalledWith( + ARA, + 'password-change-uncertain', + expect.any(Date), + decode(current).sid, + ); + expect(sessions.rows.get(decode(other).sid!)?.revokedReason).toBe( + 'password-change-uncertain', + ); + await expect(rotateFrom(current)).resolves.toEqual(expect.any(String)); + expect((await failureOf(service.refresh(other)))?.code).toBe('AUTH_001'); + + for (let i = 0; i < LOGIN_ADDRESS_FAILURE_LIMIT + 2; i += 1) { + expect((await failureOf(change(PASSWORD, current)))?.code).toBe( + 'MAIL_001', + ); + } + mail.changePassword.mockResolvedValue('changed'); + await expect(change(PASSWORD, current)).resolves.toBeUndefined(); + }); + + it('결과를 알 수 없고 세션도 끊지 못하면 error 로그를 남기고 그대로 MAIL_001 — 시도는 끝낸다 (그 주소가 막히지 않는다)', async () => { + mail.changePassword.mockResolvedValue('outcome-unknown'); + sessions.revokeAllOf.mockRejectedValueOnce(new Error('db down')); + + const failure = await failureOf(change(PASSWORD, await loginToken())); + + expect(failure?.code).toBe('MAIL_001'); + expect(failure?.context).toMatchObject({ revokedOtherSessions: 0 }); + expect(logs.join('\n')).toMatch(/\[SYS_001\].*세션을 끊지 못했습니다/); + await expect( + service.login(ARA, PASSWORD, '192.0.2.61'), + ).resolves.toMatchObject({ user: { email: ARA } }); + }); + + it.each(['changed', 'outcome-unknown'] as const)( + '다른 세션을 끊는 동안은 같은 주소의 로그인이 REQ_001 (%s) — 끊기가 끝난 뒤에 시도를 끝내, 그 뒤에 만든 세션은 끊기지 않는다', + async (outcome) => { + const current = await loginToken(); + mail.changePassword.mockResolvedValue(outcome); + const revokeAll = sessions.revokeAllOf.getMockImplementation(); + if (!revokeAll) throw new Error('revokeAllOf 구현이 없다'); + let release!: () => void; + sessions.revokeAllOf.mockImplementationOnce( + (...args) => + new Promise((resolve) => { + release = () => resolve(revokeAll(...args)); + }), + ); + + const running = failureOf(change(PASSWORD, current)); + await until(() => sessions.revokeAllOf.mock.calls.length === 1); + expect( + (await failureOf(service.login(ARA, PASSWORD, '192.0.2.70')))?.code, + ).toBe('REQ_001'); + + release(); + const expected = { changed: undefined, 'outcome-unknown': 'MAIL_001' }; + expect((await running)?.code).toBe(expected[outcome]); + const fresh = (await service.login(ARA, PASSWORD, '192.0.2.71')).tokens! + .refreshToken; + await expect(rotateFrom(fresh)).resolves.toEqual(expect.any(String)); + }, + ); + + it('같은 주소의 바꾸기 · 로그인이 엔진에 가 있는 동안 다음 시도는 기다리지 않고 REQ_001', async () => { + let release!: () => void; + mail.changePassword.mockImplementationOnce( + () => + new Promise((resolve) => { + release = () => resolve('changed'); + }), + ); + + const first = change(); + expect((await failureOf(change()))?.code).toBe('REQ_001'); + expect( + (await failureOf(service.login(ARA, PASSWORD, '192.0.2.40')))?.code, + ).toBe('REQ_001'); + + release(); + await expect(first).resolves.toBeUndefined(); + expect(mail.changePassword).toHaveBeenCalledTimes(1); + }); + + it('웹메일로 다루지 않는 계정(메일 엔진 관리자 · 막은 계정)은 엔진에 묻지 않고 AUTH_001 · 메일 설정이 없으면 MAIL_001', async () => { + const admin = await failureOf( + service.changePassword( + { email: 'Admin@AraSystem.test', name: '관리자' }, + PASSWORD, + NEW_PASSWORD, + CLIENT_IP, + undefined, + ), + ); + expect(admin?.code).toBe('AUTH_001'); + expect(mail.changePassword).not.toHaveBeenCalled(); + + service = build({ domain: null }); + expect((await failureOf(change()))?.code).toBe('MAIL_001'); + expect(mail.changePassword).not.toHaveBeenCalled(); + }); + + it('비밀번호는 바뀌었는데 세션을 끊지 못하면 성공으로 답하고 error 로그를 남긴다 (실패로 답하면 옛 비밀번호로 다시 시도해 AUTH_005 가 된다)', async () => { + sessions.revokeAllOf.mockRejectedValueOnce(new Error('db down')); + + await expect( + change(PASSWORD, await loginToken()), + ).resolves.toBeUndefined(); + expect(logs.join('\n')).toMatch(/\[SYS_001\].*세션을 끊지 못했습니다/); + }); + + it('로그에는 가린 주소와 끊은 세션 수만 — 비밀번호는 어디에도 남지 않는다 (실패의 context 포함)', async () => { + const current = await loginToken(); + await loginToken(); + await change(PASSWORD, current); + + mail.changePassword.mockResolvedValue('current-password-rejected'); + const wrong = await failureOf(change('wrong-current-pw', current)); + mail.changePassword.mockResolvedValue('new-password-rejected'); + const rejected = await failureOf(change(PASSWORD, current)); + mail.changePassword.mockResolvedValue('current-password-refused'); + const refused = await failureOf(change(PASSWORD, current)); + mail.changePassword.mockResolvedValue('outcome-unknown'); + const unknown = await failureOf(change(PASSWORD, current)); + + const everything = [ + ...logs, + JSON.stringify(wrong?.context), + JSON.stringify(rejected?.context), + JSON.stringify(refused?.context), + JSON.stringify(unknown?.context), + ].join('\n'); + expect(everything).toContain( + '비밀번호 변경: a***@arasystem.test, 끊은 다른 세션 1개', + ); + expect(everything).not.toContain(ARA); + expect(everything).not.toContain(PASSWORD); + expect(everything).not.toContain(NEW_PASSWORD); + expect(everything).not.toContain('wrong-current-pw'); + }); + }); + describe('로그아웃', () => { it('서버의 세션을 끊는다 — 그 뒤 리프레시는 AUTH_001', async () => { const token = await loginToken(); diff --git a/backend/src/modules/auth/auth.service.ts b/backend/src/modules/auth/auth.service.ts index 26c7ebc..fee31d3 100644 --- a/backend/src/modules/auth/auth.service.ts +++ b/backend/src/modules/auth/auth.service.ts @@ -6,12 +6,18 @@ import type { AuthenticatedUser } from '@/common/decorators/current-user.decorat import { BusinessException } from '@/common/errors/business.exception'; import { MAIL_PROVIDER, type MailProvider } from '@/modules/mail/mail.provider'; import { MAIL_SETTINGS, type MailSettings } from '@/modules/mail/mail.settings'; -import type { MailAccountProfile } from '@/modules/mail/mail.types'; +import type { + MailAccountProfile, + PasswordChangeOutcome, +} from '@/modules/mail/mail.types'; import { MailDirectoryService } from '@/modules/mail-directory/mail-directory.service'; import { traceTag } from '@/shared/logger/request-context'; import { maskEmail } from '@/shared/utils/mask'; import type { SessionTokens } from './auth-cookies'; -import type { AuthSessionEntity } from './auth-session.entity'; +import type { + AuthSessionEntity, + SessionRevokedReason, +} from './auth-session.entity'; import { classifyRefresh, hashRefreshToken, @@ -27,7 +33,10 @@ import { } from './auth.constants'; import { AUTH_SETTINGS, type AuthSettings } from './auth.settings'; import { isEmployeeAddress, normalizeEmail } from './employee-address'; -import { LoginAttemptService } from './login-attempt.service'; +import { + LoginAttemptService, + type LoginAttemptTicket, +} from './login-attempt.service'; export interface AuthResult { readonly user: AuthenticatedUser; @@ -169,6 +178,186 @@ export class AuthService { ); } + /** + * 비밀번호 바꾸기 (계약 1절 "PUT /api/auth/password") — 메일 엔진에 그 직원 **본인의 자격**(지금 비밀번호)으로 보낸다. + * + * - 새 비밀번호가 지금과 같으면 엔진에 묻지 않고 VAL_001 + * - 막는 계정(관리자 등 로그인할 수 없는 주소)은 엔진에 묻지 않고 AUTH_001 — 웹메일로 다루지 않는 계정이다 + * - 지금 비밀번호 틀림은 로그인 실패와 **같은 한도 · 같은 카운터**로 센다 — 엔진에 묻기 전에 실패 한 번을 IP·주소에 차지하고 + * (LoginAttemptService), 틀리면 남기고(400 AUTH_005 — 401 이 아니다: 클라이언트는 401 이면 갱신하고 다시 보낸다), + * 바뀌면 로그인 성공처럼 돌려준다. 같은 주소의 로그인·변경이 처리 중이면 REQ_001 + * - 세지 않는 실패: 지금 비밀번호로 들어갔는데 엔진이 바꾸기를 거절함(400 AUTH_005 — 맞혀 보기가 아니다) · 엔진 규칙 거절(VAL_001) · 엔진 장애 + * - 바뀌면 그 직원의 **다른** 로그인 세션을 모두 끊는다 — 이 요청의 리프레시 쿠키(Path=/api/auth 라 함께 온다)가 가리키는 세션만 남긴다. + * 쿠키로 이 세션을 알 수 없으면 모두 끊는다 (이 탭도 액세스 토큰이 끝나면 다시 로그인한다). + * 결과를 알 수 없게 끝났어도(엔진이 바꿨을 수 있다) 다른 세션은 끊어 두고 503 MAIL_001 로 답한다. + * 시도는 끊기가 끝난 뒤에 끝낸다 — 그동안 같은 주소의 로그인은 처리 중(REQ_001)이라 새 비밀번호로 막 만든 세션이 함께 끊기지 않는다 + * 비밀번호는 로그 · 오류 context 에 남기지 않는다. 바꾼 일은 info 로그에 가린 주소와 끊은 세션 수만. + */ + async changePassword( + user: AuthenticatedUser, + currentPassword: string, + newPassword: string, + clientIp: string, + refreshToken: string | undefined, + ): Promise { + const address = normalizeEmail(user.email); + if (newPassword === currentPassword) { + throw new BusinessException('VAL_001', { + context: { reason: 'new-password-same-as-current' }, + }); + } + if (!this.mailSettings.domain) { + throw new BusinessException('MAIL_001', { + context: { reason: 'mail-not-configured' }, + }); + } + if (!this.isLoginAddress(address)) { + throw new BusinessException('AUTH_001', { + context: { + reason: 'password-change-not-allowed', + email: maskEmail(address), + }, + }); + } + + // 로그인과 같은 카운터 — 지금 비밀번호를 맞혀 보는 창구가 로그인 한도를 비켜 가지 못하게 + const ticket = this.attempts.begin({ + email: address, + ip: clientIp || 'unknown', + }); + + let outcome: PasswordChangeOutcome; + try { + outcome = await this.mail.changePassword( + address, + currentPassword, + newPassword, + ); + } catch (error) { + // 결과가 확실한 엔진 장애(MAIL_001) · 엔진 한도(REQ_001) — 바뀌지 않았고 사용자 잘못도 아니다. 세지 않는다 + ticket.abort(); + throw error; + } + + switch (outcome) { + case 'current-password-rejected': + ticket.fail(); + throw new BusinessException('AUTH_005', { + context: { + reason: 'current-password-rejected', + email: maskEmail(address), + }, + }); + case 'current-password-refused': + // 같은 값으로 엔진에 들어가기는 했다 — 맞혀 보기가 아니라 세지 않는다 (변경 권한을 빼면 맞는 비밀번호로 로그인까지 막히지 않게). + // 엔진의 거절 종류는 어댑터가 warn 으로 남겼다 + ticket.abort(); + throw new BusinessException('AUTH_005', { + context: { + reason: 'current-password-refused-by-mail-engine', + email: maskEmail(address), + }, + }); + case 'new-password-rejected': + // 지금 비밀번호는 맞았다 — 실패로 세지 않는다 + ticket.abort(); + throw new BusinessException('VAL_001', { + context: { + reason: 'new-password-rejected-by-mail-engine', + email: maskEmail(address), + }, + }); + case 'outcome-unknown': { + // 엔진이 바꿨을 수 있다 — 탈취가 의심돼 바꾸는 중일 수 있으니 다른 세션은 끊어 두고 실패로 답한다 (이 세션은 남긴다) + const revoked = await this.revokeBeforeSettling( + ticket, + 'abort', + address, + refreshToken, + 'password-change-uncertain', + ); + throw new BusinessException('MAIL_001', { + context: { + reason: 'password-change-outcome-unknown', + email: maskEmail(address), + revokedOtherSessions: revoked, + }, + }); + } + case 'changed': { + const revoked = await this.revokeBeforeSettling( + ticket, + 'succeed', + address, + refreshToken, + 'password-changed', + ); + this.logger.log( + `비밀번호 변경: ${maskEmail(address)}, 끊은 다른 세션 ${revoked}개`, + ); + } + } + } + + /** + * 다른 세션을 끊은 **뒤에** 시도를 끝낸다 → 끊은 수. 끊는 동안은 같은 주소의 로그인이 진행 중(REQ_001)으로 막혀, + * 새 비밀번호로 막 로그인한 세션이 함께 끊기지 않는다. 끊다 실패해도 시도는 반드시 끝낸다 (그 주소가 계속 막히지 않게) + */ + private async revokeBeforeSettling( + ticket: LoginAttemptTicket, + settle: 'succeed' | 'abort', + address: string, + refreshToken: string | undefined, + reason: SessionRevokedReason, + ): Promise { + try { + return await this.revokeOtherSessions(address, refreshToken, reason); + } finally { + if (settle === 'succeed') ticket.succeed(); + else ticket.abort(); + } + } + + /** + * 비밀번호를 바꾼(또는 바꿨을 수 있는) 뒤 그 직원의 다른 세션을 끊는다 → 끊은 수. + * 여기서 실패해도 던지지 않는다 — 비밀번호는 이미 바뀌었을 수 있어, 실패로 답하면 사용자가 옛 비밀번호로 다시 시도해 + * AUTH_005 를 받는다. 대신 error 로그를 남긴다 (다른 세션이 남아 있다 — 운영자가 끊을 수 있게 가린 주소와 함께) + */ + private async revokeOtherSessions( + address: string, + refreshToken: string | undefined, + reason: SessionRevokedReason, + ): Promise { + const keepId = await this.sessionIdOf(refreshToken, address); + try { + return await this.sessions.revokeAllOf( + address, + reason, + new Date(), + keepId, + ); + } catch (error) { + this.logger.error( + `[SYS_001]${traceTag()} 비밀번호 바꾸기 뒤 다른 로그인 세션을 끊지 못했습니다 — 남은 세션은 만료까지 쓸 수 있습니다: user=${maskEmail(address)}, reason=${errorMessage(error)}`, + ); + return 0; + } + } + + /** 리프레시 쿠키가 가리키는 그 직원의 세션 id — 없거나 · 위조거나 · 다른 직원의 토큰이면 null (만료는 따지지 않는다 — 고르기만 한다) */ + private async sessionIdOf( + refreshToken: string | undefined, + address: string, + ): Promise { + if (!refreshToken) return null; + try { + const claims = await this.verifyRefreshToken(refreshToken, true); + return normalizeEmail(claims.sub) === address ? claims.sid : null; + } catch { + return null; + } + } + /** * 로그아웃 — 서버의 세션을 끊는다. 항상 성공으로 끝난다(계약: 항상 200): * 토큰이 없거나 이미 쓸 수 없어도, 세션 폐기에 실패해도(오류 로그만) 쿠키는 컨트롤러가 지운다. diff --git a/backend/src/modules/auth/dto/change-password.dto.spec.ts b/backend/src/modules/auth/dto/change-password.dto.spec.ts new file mode 100644 index 0000000..06d4170 --- /dev/null +++ b/backend/src/modules/auth/dto/change-password.dto.spec.ts @@ -0,0 +1,86 @@ +import { plainToInstance } from 'class-transformer'; +import { validate } from 'class-validator'; + +import { + MAX_PASSWORD_LENGTH, + NEW_PASSWORD_MAX_LENGTH, + NEW_PASSWORD_MIN_LENGTH, +} from '@/modules/auth/auth.constants'; +import { ChangePasswordDto } from './change-password.dto'; + +/** 전역 ValidationPipe 와 같은 옵션으로 검증한다 (app.setup.ts) — 걸린 규칙 이름만 */ +async function constraintsOf(body: object): Promise { + const errors = await validate(plainToInstance(ChangePasswordDto, body), { + whitelist: true, + forbidNonWhitelisted: true, + }); + return errors.flatMap((error) => Object.keys(error.constraints ?? {})); +} + +describe('비밀번호 바꾸기 요청 본문 (PUT /api/auth/password)', () => { + const valid = { currentPassword: 'old-password', newPassword: 'new-pass' }; + + it(`새 비밀번호는 ${NEW_PASSWORD_MIN_LENGTH}~${NEW_PASSWORD_MAX_LENGTH}자 — 밖이면 형식 오류`, async () => { + expect(await constraintsOf(valid)).toEqual([]); + expect( + await constraintsOf({ + ...valid, + newPassword: 'x'.repeat(NEW_PASSWORD_MAX_LENGTH), + }), + ).toEqual([]); + + expect( + await constraintsOf({ + ...valid, + newPassword: 'x'.repeat(NEW_PASSWORD_MIN_LENGTH - 1), + }), + ).toEqual(['minCodeUnits']); + expect( + await constraintsOf({ + ...valid, + newPassword: 'x'.repeat(NEW_PASSWORD_MAX_LENGTH + 1), + }), + ).toEqual(['maxCodeUnits']); + }); + + it('길이는 JS 문자열 길이(UTF-16 코드 단위)로 센다 — 웹 클라이언트와 같은 수 (이모지는 2자)', async () => { + // 이모지 4개 = 8 코드 단위 — validator.js 의 MinLength 는 4자로 세어 막았을 것이다 + expect(await constraintsOf({ ...valid, newPassword: '🔒🔒🔒🔒' })).toEqual( + [], + ); + // 이모지 64개 + 1자 = 129 코드 단위 — 넘는다 + expect( + await constraintsOf({ ...valid, newPassword: `${'🔒'.repeat(64)}x` }), + ).toEqual(['maxCodeUnits']); + }); + + it(`지금 비밀번호는 로그인과 같은 한도(${MAX_PASSWORD_LENGTH}자) — 비었거나 길면 형식 오류, 문자열이 아니면 형식 오류`, async () => { + expect( + await constraintsOf({ + ...valid, + currentPassword: 'x'.repeat(MAX_PASSWORD_LENGTH), + }), + ).toEqual([]); + expect(await constraintsOf({ ...valid, currentPassword: '' })).toEqual([ + 'isNotEmpty', + ]); + expect( + await constraintsOf({ + ...valid, + currentPassword: 'x'.repeat(MAX_PASSWORD_LENGTH + 1), + }), + ).toEqual(['maxLength']); + expect( + await constraintsOf({ ...valid, currentPassword: 12345678 }), + ).toEqual(expect.arrayContaining(['isString'])); + expect(await constraintsOf({ newPassword: 'new-pass' })).toEqual( + expect.arrayContaining(['isString']), + ); + }); + + it('모르는 필드는 거부한다 (확인 칸은 화면만 본다)', async () => { + expect( + await constraintsOf({ ...valid, newPasswordConfirm: 'new-pass' }), + ).toEqual(['whitelistValidation']); + }); +}); diff --git a/backend/src/modules/auth/dto/change-password.dto.ts b/backend/src/modules/auth/dto/change-password.dto.ts new file mode 100644 index 0000000..0f21145 --- /dev/null +++ b/backend/src/modules/auth/dto/change-password.dto.ts @@ -0,0 +1,47 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { IsNotEmpty, IsString, MaxLength } from 'class-validator'; + +import { + MaxCodeUnits, + MinCodeUnits, +} from '@/common/decorators/code-units.decorator'; +import { + MAX_PASSWORD_LENGTH, + NEW_PASSWORD_MAX_LENGTH, + NEW_PASSWORD_MIN_LENGTH, +} from '@/modules/auth/auth.constants'; + +/** + * PUT /api/auth/password — 계약 1절. + * 지금 비밀번호는 로그인(dto/login.dto.ts)과 같은 검사 — 로그인할 수 있었던 비밀번호는 여기서도 통과하고, 그보다 긴 값은 들어올 수 없다. + * 새 비밀번호의 길이는 JS 문자열 길이로 센다(웹 클라이언트와 같다). 지금 비밀번호와 같으면 서비스가 400 VAL_001 (엔진에 묻지 않는다). + * 비밀번호는 저장하지 않고 로그에 남기지 않는다. + */ +export class ChangePasswordDto { + @ApiProperty({ + description: `지금 비밀번호 — 메일 엔진이 확인한다 (로그인과 같은 한도, ${MAX_PASSWORD_LENGTH}자 이하)`, + format: 'password', + maxLength: MAX_PASSWORD_LENGTH, + example: 'current-password', + }) + @IsString({ message: '지금 비밀번호를 입력해 주세요.' }) + @IsNotEmpty({ message: '지금 비밀번호를 입력해 주세요.' }) + @MaxLength(MAX_PASSWORD_LENGTH, { message: '지금 비밀번호가 너무 깁니다.' }) + currentPassword!: string; + + @ApiProperty({ + description: `새 비밀번호 — ${NEW_PASSWORD_MIN_LENGTH}~${NEW_PASSWORD_MAX_LENGTH}자, 지금 비밀번호와 달라야 한다 (메일 엔진의 규칙에 어긋나면 400 VAL_001)`, + format: 'password', + minLength: NEW_PASSWORD_MIN_LENGTH, + maxLength: NEW_PASSWORD_MAX_LENGTH, + example: 'new-password-1234', + }) + @IsString({ message: '새 비밀번호를 입력해 주세요.' }) + @MinCodeUnits(NEW_PASSWORD_MIN_LENGTH, { + message: `새 비밀번호는 ${NEW_PASSWORD_MIN_LENGTH}자 이상이어야 합니다.`, + }) + @MaxCodeUnits(NEW_PASSWORD_MAX_LENGTH, { + message: `새 비밀번호는 ${NEW_PASSWORD_MAX_LENGTH}자 이하여야 합니다.`, + }) + newPassword!: string; +} diff --git a/backend/src/modules/mail/attachment.policy.spec.ts b/backend/src/modules/mail/attachment.policy.spec.ts index 3203210..1083eb2 100644 --- a/backend/src/modules/mail/attachment.policy.spec.ts +++ b/backend/src/modules/mail/attachment.policy.spec.ts @@ -4,6 +4,7 @@ import { MAX_ATTACHMENTS_PER_MESSAGE, allowedAttachmentExtensions, isAllowedAttachment, + messageSourceFileName, normalizeMimeType, resolveAttachmentType, resolveAttachmentsTotalLimit, @@ -161,3 +162,47 @@ describe('sanitizeFileName', () => { expect(sanitizeFileName('dir/')).toBe('첨부파일'); }); }); + +describe('원문 파일 이름 (messageSourceFileName)', () => { + it('제목 + .eml — 제목이 없거나 정리하고 비면 "메일"', () => { + expect(messageSourceFileName('메일 서버 이전 일정 공유')).toBe( + '메일 서버 이전 일정 공유.eml', + ); + for (const subject of ['', ' ', '...', '\u0000\u202E']) { + expect(messageSourceFileName(subject)).toBe('메일.eml'); + } + }); + + it('경로 구분자와 Windows 에서 못 쓰는 문자는 _ 로, 제어 · 방향 제어 문자는 지우고, 공백(탭 · 줄바꿈)은 하나로', () => { + expect(messageSourceFileName('Re: 견적서/수정본 <최종>?')).toBe( + 'Re_ 견적서_수정본 _최종__.eml', + ); + expect(messageSourceFileName('..\\..\\secret|"x"*')).toBe( + '_.._secret__x__.eml', + ); + expect(messageSourceFileName('주간\t보고\r\n (10월)')).toBe( + '주간 보고 (10월).eml', + ); + expect(messageSourceFileName('계약서\u202Egpj.exe')).toBe( + '계약서gpj.exe.eml', + ); + }); + + it('앞뒤의 공백 · 점을 떼고 80글자(코드 포인트)까지 — 자른 끝의 공백 · 점도 뗀다', () => { + expect(messageSourceFileName(' .숨김 파일. ')).toBe('숨김 파일.eml'); + expect(messageSourceFileName('가'.repeat(100))).toBe( + `${'가'.repeat(80)}.eml`, + ); + expect(messageSourceFileName('😀'.repeat(81))).toBe( + `${'😀'.repeat(80)}.eml`, + ); + expect(messageSourceFileName(`${'가'.repeat(79)} 끝`)).toBe( + `${'가'.repeat(79)}.eml`, + ); + }); + + it('아주 긴 제목도 입력 길이에 비례해 처리한다 (반복 정규식 없이)', () => { + const long = `${' .'.repeat(200_000)}x${' .'.repeat(200_000)}`; + expect(messageSourceFileName(long)).toBe('x.eml'); + }); +}); diff --git a/backend/src/modules/mail/attachment.policy.ts b/backend/src/modules/mail/attachment.policy.ts index 1dca1ff..0fde152 100644 --- a/backend/src/modules/mail/attachment.policy.ts +++ b/backend/src/modules/mail/attachment.policy.ts @@ -238,6 +238,62 @@ export function sanitizeFileName(rawName: string): string { ); } +/** + * 원문 파일 이름의 제목 부분 최대 글자 수(코드 포인트). 한글(UTF-8 3바이트)뿐이면 `.eml` 까지 240+4 바이트라 + * 파일 시스템의 이름 한도(255바이트) 안에 든다. 4바이트 문자(이모지 등)가 많으면 넘을 수 있는데, + * 그때는 브라우저가 저장하면서 이름을 줄인다 (서버는 더 자르지 않는다) + */ +const SOURCE_NAME_MAX_CHARS = 80; +/** 제목이 없거나 정리하고 비면 쓰는 이름 */ +const SOURCE_FALLBACK_NAME = '메일'; +/** 원문 파일의 확장자 */ +const SOURCE_EXTENSION = '.eml'; +/** 파일 이름에 쓸 수 없는 문자(경로 구분자와 Windows 예약 문자) — '_' 로 바꾼다 */ +const FILE_NAME_RESERVED_CHARS: ReadonlySet = new Set([ + '\\', + '/', + ':', + '*', + '?', + '"', + '<', + '>', + '|', +]); +/** 이름 앞뒤에서 떼는 문자 — 공백과 점(앞의 점은 숨김 파일, Windows 는 끝의 점을 지운다) */ +const NAME_EDGE_CHARS: ReadonlySet = new Set([' ', '.']); +const WHITESPACE = /\s/u; + +/** 앞뒤의 공백·점을 뗀다 — 반복 정규식 없이 한 번 훑는다 (긴 제목에서도 입력 길이에 비례) */ +function trimNameEdges(chars: readonly string[]): string[] { + let start = 0; + let end = chars.length; + while (start < end && NAME_EDGE_CHARS.has(chars[start])) start += 1; + while (end > start && NAME_EDGE_CHARS.has(chars[end - 1])) end -= 1; + return chars.slice(start, end); +} + +/** + * 원문 내려받기 파일 이름 — 제목(없으면 "메일") + `.eml` (계약 2절 "스팸 · 원문"). + * 공백(탭 · 줄바꿈 포함)은 하나로 줄이고, 제어 · 방향 제어 문자는 지우고, 경로 구분자와 Windows 에서 못 쓰는 문자는 '_' 로 바꾼다. + * 앞뒤의 공백 · 점을 떼고 SOURCE_NAME_MAX_CHARS 글자까지 자른다. 헤더 인코딩은 content-disposition.ts 가 한다 + */ +export function messageSourceFileName(subject: string): string { + const chars: string[] = []; + for (const char of subject) { + if (WHITESPACE.test(char)) { + if (chars.at(-1) !== ' ') chars.push(' '); + continue; + } + if (isControlOrBidiChar(char.codePointAt(0) ?? 0)) continue; + chars.push(FILE_NAME_RESERVED_CHARS.has(char) ? '_' : char); + } + const base = trimNameEdges( + trimNameEdges(chars).slice(0, SOURCE_NAME_MAX_CHARS), + ).join(''); + return `${base || SOURCE_FALLBACK_NAME}${SOURCE_EXTENSION}`; +} + /** C0·C1 제어 문자, DEL, 방향 제어 문자 */ function isControlOrBidiChar(codePoint: number): boolean { return ( diff --git a/backend/src/modules/mail/dto/bulk-messages.dto.spec.ts b/backend/src/modules/mail/dto/bulk-messages.dto.spec.ts index 898f886..9302542 100644 --- a/backend/src/modules/mail/dto/bulk-messages.dto.spec.ts +++ b/backend/src/modules/mail/dto/bulk-messages.dto.spec.ts @@ -82,4 +82,21 @@ describe('여러 통 요청 본문 (PATCH /api/messages · POST /api/message-del await constraintsOf(MessageDeletionDto, { ids: ['m1'], isRead: true }), ).toEqual(['whitelistValidation']); }); + + it('스팸 표시(isJunk)도 한 통 PATCH 와 같다 — true · false, null · 문자열은 형식 오류', async () => { + for (const isJunk of [true, false]) { + expect( + await constraintsOf(UpdateMessagesDto, { + ids: ['m1'], + mailboxId: 'c', + isJunk, + }), + ).toEqual([]); + } + for (const isJunk of [null, 'true']) { + expect( + await constraintsOf(UpdateMessagesDto, { ids: ['m1'], isJunk }), + ).toEqual(['isBoolean']); + } + }); }); diff --git a/backend/src/modules/mail/dto/bulk-messages.dto.ts b/backend/src/modules/mail/dto/bulk-messages.dto.ts index 60c7b2f..cf9db0d 100644 --- a/backend/src/modules/mail/dto/bulk-messages.dto.ts +++ b/backend/src/modules/mail/dto/bulk-messages.dto.ts @@ -42,7 +42,7 @@ function MessageIdList(description: string) { } /** - * PATCH /api/messages — 여러 통의 읽음 · 중요 표시 · 이동 (바꿀 것은 한 통 PATCH 와 같고, 하나 이상 — 없으면 서비스가 VAL_001) + * PATCH /api/messages — 여러 통의 읽음 · 중요 표시 · 이동 · 스팸 표시 (바꿀 것은 한 통 PATCH 와 같고, 하나 이상 — 없으면 서비스가 VAL_001) */ export class UpdateMessagesDto extends UpdateMessageDto { @MessageIdList('바꿀 메일 id') diff --git a/backend/src/modules/mail/dto/mail-response.dto.ts b/backend/src/modules/mail/dto/mail-response.dto.ts index 0d93aa2..f1d2a9f 100644 --- a/backend/src/modules/mail/dto/mail-response.dto.ts +++ b/backend/src/modules/mail/dto/mail-response.dto.ts @@ -7,6 +7,8 @@ import type { MailAddress, MailAttachment, Mailbox, + MailboxBulkResult, + MailboxDeletion, MailboxRole, MailQuota, MailState, @@ -135,6 +137,49 @@ export class MailboxPageDto { pageInfo!: PageInfoDto; } +/** 계약 2절 "메일함 관리"의 MailboxDeletion — DELETE /api/mailboxes/{id} */ +export class MailboxDeletionDto implements MailboxDeletion { + @ApiProperty({ + description: + 'true = 지웠다. false = 한 요청(25초)에 다 옮기지 못했다 — 같은 요청을 다시 보낸다', + example: true, + }) + deleted!: boolean; + + @ApiProperty({ + description: + '이번 요청에서 이 메일함에서 빠진 메일 수 — 휴지통으로 옮긴 것 + 다른 메일함에도 있어 이 메일함에서만 뺀 것. 진행 수 · 멈춤 판정에 쓴다', + example: 40, + }) + processed!: number; + + @ApiProperty({ + description: + '이번 요청에서 휴지통으로 옮긴 메일 수 (다른 메일함에도 있어 이 메일함에서만 뺀 메일은 세지 않는다)', + example: 37, + }) + movedToTrash!: number; + + @ApiProperty({ description: '아직 이 메일함에 남은 메일 수', example: 0 }) + remaining!: number; +} + +/** 계약 2절 "메일함 관리"의 MailboxBulkResult — 전체 읽음 · 비우기 */ +export class MailboxBulkResultDto implements MailboxBulkResult { + @ApiProperty({ + description: + '이번 요청에서 읽음으로 바꾼 · 지운(다른 메일함에도 있어 그 메일함에서만 뺀 것 포함) 메일 수', + example: 500, + }) + processed!: number; + + @ApiProperty({ + description: '남은 수 — 0 이 될 때까지 같은 요청을 다시 보낸다', + example: 734, + }) + remaining!: number; +} + export class MailQuotaDto implements MailQuota { @ApiProperty({ description: '사용량 (바이트)', example: 966367641 }) usedBytes!: number; diff --git a/backend/src/modules/mail/dto/mail-vacation.dto.spec.ts b/backend/src/modules/mail/dto/mail-vacation.dto.spec.ts new file mode 100644 index 0000000..baf773f --- /dev/null +++ b/backend/src/modules/mail/dto/mail-vacation.dto.spec.ts @@ -0,0 +1,178 @@ +import { plainToInstance } from 'class-transformer'; +import { validate } from 'class-validator'; + +import { + MAX_VACATION_SUBJECT_LENGTH, + MAX_VACATION_TEXT_LENGTH, +} from '@/modules/mail/mail.constants'; +import { MailVacationDto, isUtcDateTime } from './mail-vacation.dto'; + +/** 전역 ValidationPipe 와 같은 옵션으로 검증한다 (app.setup.ts) — 걸린 규칙 이름만 */ +async function constraintsOf(body: object): Promise { + const errors = await validate(plainToInstance(MailVacationDto, body), { + whitelist: true, + forbidNonWhitelisted: true, + }); + return errors.flatMap((error) => Object.keys(error.constraints ?? {})); +} + +describe('부재중 자동 응답 본문 (PUT /api/mail-vacation)', () => { + const enabled = { + isEnabled: true, + fromDate: '2026-11-02T15:00:00.000Z', + toDate: '2026-11-07T15:00:00.000Z', + subject: '부재중입니다', + textBody: '11월 7일까지 휴가입니다.', + }; + /** 한 번도 설정하지 않은 계정의 GET 응답 — 그대로 PUT 해도 통과해야 한다 (계약 2절) */ + const neverConfigured = { + isEnabled: false, + fromDate: null, + toDate: null, + subject: '', + textBody: '', + }; + + it('다섯 필드 — 날짜는 null 이어도 된다. 한 번도 설정하지 않은 값(빈 제목 · 내용)도 형식은 맞다', async () => { + expect(await constraintsOf(enabled)).toEqual([]); + expect(await constraintsOf(neverConfigured)).toEqual([]); + expect( + await constraintsOf({ ...enabled, fromDate: null, toDate: null }), + ).toEqual([]); + }); + + it('전체 교체라 날짜 키가 없으면 형식 오류 — null 로 보낸다', async () => { + const withoutToDate: Partial = { ...enabled }; + delete withoutToDate.toDate; + expect(await constraintsOf(withoutToDate)).toEqual(['isUtcDateTime']); + expect(await constraintsOf({ ...enabled, isEnabled: undefined })).toEqual([ + 'isBoolean', + ]); + }); + + it('날짜는 ISO 8601 UTC 시각만 — 날짜만 · 시간대 오프셋 · 없는 날짜 · 소문자 z 는 형식 오류', async () => { + for (const fromDate of [ + '2026-11-02', + '2026-11-02T15:00:00+09:00', + '2026-11-02T15:00:00.000z', + '2026-02-30T00:00:00.000Z', + '2026-11-02T24:00:00Z', + '2026-11-02 15:00:00Z', + '내일', + 1_793_000_000_000, + ]) { + expect(await constraintsOf({ ...enabled, fromDate })).toEqual([ + 'isUtcDateTime', + ]); + } + expect( + await constraintsOf({ ...enabled, fromDate: '2026-11-02T15:00:00Z' }), + ).toEqual([]); + }); + + it(`제목 ${MAX_VACATION_SUBJECT_LENGTH}자 · 내용 ${MAX_VACATION_TEXT_LENGTH}자까지 (JS 문자열 길이) — 서식 본문은 받지 않는다`, async () => { + expect( + await constraintsOf({ + ...enabled, + subject: 'x'.repeat(MAX_VACATION_SUBJECT_LENGTH), + textBody: 'x'.repeat(MAX_VACATION_TEXT_LENGTH), + }), + ).toEqual([]); + expect( + await constraintsOf({ + ...enabled, + subject: 'x'.repeat(MAX_VACATION_SUBJECT_LENGTH + 1), + }), + ).toEqual(['maxCodeUnits']); + expect( + await constraintsOf({ + ...enabled, + textBody: `${'🌴'.repeat(MAX_VACATION_TEXT_LENGTH / 2)}x`, + }), + ).toEqual(['maxCodeUnits']); + expect(await constraintsOf({ ...enabled, subject: null })).toEqual( + expect.arrayContaining(['isString']), + ); + expect( + await constraintsOf({ ...enabled, htmlBody: '

휴가

' }), + ).toEqual(['whitelistValidation']); + }); + + it('제목에는 제어 문자(C0 — 탭 · 줄바꿈 포함, DEL, C1)를 쓸 수 없다 — 범위 바로 바깥 문자는 받는다', async () => { + for (const subject of [ + '부재\n중', + '부재\r중', + '부재\t중', + 'a\u0000', + 'a\u001f', + 'a\u007f', + 'a\u0080', + 'a\u0085', + 'a\u009f', + ]) { + expect(await constraintsOf({ ...enabled, subject })).toEqual(['matches']); + } + for (const subject of [ + 'a b', + 'a~b', + 'a\u00a0b', + '부재중 🌴 (11/3~11/7) · 휴가', + ]) { + expect(await constraintsOf({ ...enabled, subject })).toEqual([]); + } + }); + + it('내용은 제어 문자 가운데 탭 · 줄바꿈(U+0009 · U+000A · U+000D)만 받는다', async () => { + expect( + await constraintsOf({ + ...enabled, + textBody: + '안녕하세요.\n\n11월 7일까지 휴가입니다.\r\n급한 일은\t이도윤에게.', + }), + ).toEqual([]); + for (const textBody of [ + 'a\u0000', + 'a\u0008', + 'a\u000b', + 'a\u000c', + 'a\u000e', + 'a\u001b', + 'a\u001f', + 'a\u007f', + 'a\u0080', + 'a\u0085', + 'a\u009f', + ]) { + expect(await constraintsOf({ ...enabled, textBody })).toEqual([ + 'matches', + ]); + } + }); + + it('꺼 둔 채로도 형식 검사는 같다 — 공백뿐인 제목 · 내용은 형식으로는 맞다 (켤 때 비는지는 서비스가 본다)', async () => { + expect( + await constraintsOf({ ...neverConfigured, subject: 'a\nb' }), + ).toEqual(['matches']); + expect( + await constraintsOf({ ...enabled, subject: ' ', textBody: ' \n ' }), + ).toEqual([]); + }); +}); + +describe('isUtcDateTime', () => { + it('JS toISOString 모양(소수 초 3자리까지)과 소수 초 없는 모양을 받는다', () => { + expect(isUtcDateTime('2026-11-02T15:00:00.000Z')).toBe(true); + expect(isUtcDateTime('2026-11-02T15:00:00Z')).toBe(true); + expect(isUtcDateTime('2026-11-02T15:00:00.5Z')).toBe(true); + expect(isUtcDateTime('2028-02-29T00:00:00Z')).toBe(true); + }); + + it('없는 날짜 · 시각, 너무 긴 소수 초, 문자열이 아닌 값은 받지 않는다', () => { + expect(isUtcDateTime('2027-02-29T00:00:00Z')).toBe(false); + expect(isUtcDateTime('2026-13-01T00:00:00Z')).toBe(false); + expect(isUtcDateTime('2026-11-02T15:60:00Z')).toBe(false); + expect(isUtcDateTime('2026-11-02T15:00:00.0000Z')).toBe(false); + expect(isUtcDateTime(null)).toBe(false); + expect(isUtcDateTime(undefined)).toBe(false); + }); +}); diff --git a/backend/src/modules/mail/dto/mail-vacation.dto.ts b/backend/src/modules/mail/dto/mail-vacation.dto.ts new file mode 100644 index 0000000..418335d --- /dev/null +++ b/backend/src/modules/mail/dto/mail-vacation.dto.ts @@ -0,0 +1,106 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { + IsBoolean, + IsString, + Matches, + ValidateBy, + ValidateIf, + type ValidationOptions, +} from 'class-validator'; + +import { MaxCodeUnits } from '@/common/decorators/code-units.decorator'; +import { + MAX_VACATION_SUBJECT_LENGTH, + MAX_VACATION_TEXT_LENGTH, + MULTI_LINE_TEXT_PATTERN, + SINGLE_LINE_TEXT_PATTERN, +} from '@/modules/mail/mail.constants'; +import type { MailVacation } from '@/modules/mail/mail.types'; + +/** ISO 8601 UTC 시각 — 'Z' 로 끝나고 소수 초는 3자리까지 (JS toISOString 의 모양, 계약 머리말 "날짜·시각") */ +const UTC_DATE_TIME = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d{1,3})?Z$/; + +/** + * 실제로 있는 UTC 시각인가 — 모양과 함께, 읽은 값을 다시 적었을 때 같은지 본다 + * (2026-02-30 처럼 없는 날짜를 Date 가 다음 달로 넘겨 읽는 것을 막는다) + */ +export function isUtcDateTime(value: unknown): boolean { + if (typeof value !== 'string' || !UTC_DATE_TIME.test(value)) return false; + const time = Date.parse(value); + return ( + !Number.isNaN(time) && + new Date(time).toISOString().slice(0, 19) === value.slice(0, 19) + ); +} + +function IsUtcDateTime(options: ValidationOptions) { + return ValidateBy( + { name: 'isUtcDateTime', validator: { validate: isUtcDateTime } }, + options, + ); +} + +/** null 은 "날짜 없음"으로 받는다 — 키가 없으면(undefined) 형식 오류다 (전체 교체라 다섯 필드를 모두 보낸다) */ +const unlessNull = (_object: object, value: unknown) => value !== null; + +/** + * 부재중 자동 응답 — GET 응답과 PUT 본문(전체 교체)이 같은 모양이다 (계약 2절 "부재중 자동 응답"). + * 켤 때 제목 · 내용이 비었는지, 시작이 끝보다 앞인지는 서비스가 본다 (400 VAL_001). 서식 본문은 받지 않는다. + */ +export class MailVacationDto implements MailVacation { + @ApiProperty({ + description: '켜짐 — 켜면 제목 · 내용이 1자 이상이어야 한다', + example: true, + }) + @IsBoolean({ message: '부재중 자동 응답을 켤지 다시 선택해 주세요.' }) + isEnabled!: boolean; + + @ApiProperty({ + description: + '시작 시각 (ISO 8601 UTC) — null 이면 켠 때부터. 끝 시각도 있으면 그보다 앞이어야 한다 (아니면 400 VAL_001)', + nullable: true, + type: String, + example: '2026-11-02T15:00:00.000Z', + }) + @ValidateIf(unlessNull) + @IsUtcDateTime({ message: '시작 날짜 형식이 올바르지 않습니다.' }) + fromDate!: string | null; + + @ApiProperty({ + description: '끝 시각 (ISO 8601 UTC) — null 이면 끌 때까지', + nullable: true, + type: String, + example: '2026-11-07T15:00:00.000Z', + }) + @ValidateIf(unlessNull) + @IsUtcDateTime({ message: '끝 날짜 형식이 올바르지 않습니다.' }) + toDate!: string | null; + + @ApiProperty({ + description: `자동 응답 제목 — ${MAX_VACATION_SUBJECT_LENGTH}자까지(JS 문자열 길이), 켤 때는 1자 이상 (끈 채로는 빈 문자열도 저장된다)`, + maxLength: MAX_VACATION_SUBJECT_LENGTH, + example: '부재중입니다', + }) + @IsString({ message: '자동 응답 제목 형식이 올바르지 않습니다.' }) + @MaxCodeUnits(MAX_VACATION_SUBJECT_LENGTH, { + message: `자동 응답 제목은 ${MAX_VACATION_SUBJECT_LENGTH}자 이하로 입력해 주세요.`, + }) + @Matches(SINGLE_LINE_TEXT_PATTERN, { + message: '자동 응답 제목에는 줄바꿈 같은 제어 문자를 쓸 수 없습니다.', + }) + subject!: string; + + @ApiProperty({ + description: `자동 응답 내용 — 글자만(서식 없음), ${MAX_VACATION_TEXT_LENGTH.toLocaleString('ko-KR')}자까지(JS 문자열 길이), 켤 때는 1자 이상. 서명은 붙지 않는다`, + maxLength: MAX_VACATION_TEXT_LENGTH, + example: '11월 7일까지 휴가입니다. 급한 일은 이도윤 님께 연락해 주세요.', + }) + @IsString({ message: '자동 응답 내용 형식이 올바르지 않습니다.' }) + @MaxCodeUnits(MAX_VACATION_TEXT_LENGTH, { + message: `자동 응답 내용은 ${MAX_VACATION_TEXT_LENGTH.toLocaleString('ko-KR')}자 이하로 입력해 주세요.`, + }) + @Matches(MULTI_LINE_TEXT_PATTERN, { + message: '자동 응답 내용에는 탭 · 줄바꿈 밖의 제어 문자를 쓸 수 없습니다.', + }) + textBody!: string; +} diff --git a/backend/src/modules/mail/dto/mailbox.dto.spec.ts b/backend/src/modules/mail/dto/mailbox.dto.spec.ts new file mode 100644 index 0000000..971fb12 --- /dev/null +++ b/backend/src/modules/mail/dto/mailbox.dto.spec.ts @@ -0,0 +1,132 @@ +import { plainToInstance, type ClassConstructor } from 'class-transformer'; +import { validate } from 'class-validator'; + +import { MAX_MAILBOX_NAME_LENGTH } from '@/modules/mail/mail.constants'; +import { + CreateMailboxDto, + MailboxMessagesPatchDto, + RenameMailboxDto, +} from './mailbox.dto'; + +/** 전역 ValidationPipe 와 같은 옵션으로 검증한다 (app.setup.ts) — 변환한 값과 걸린 규칙 이름 */ +async function check( + type: ClassConstructor, + body: object, +): Promise<{ dto: Record; constraints: string[] }> { + const dto = plainToInstance(type, body); + const errors = await validate(dto, { + whitelist: true, + forbidNonWhitelisted: true, + }); + return { + dto: dto as Record, + constraints: errors.flatMap((error) => + Object.keys(error.constraints ?? {}), + ), + }; +} + +describe('메일함 요청 본문 (계약 2절 "메일함 관리")', () => { + it.each([ + ['만들기', CreateMailboxDto], + ['이름 바꾸기', RenameMailboxDto], + ] as const)( + `%s — 이름은 앞뒤 공백을 뺀 1~${MAX_MAILBOX_NAME_LENGTH}자 (JS 문자열 길이)`, + async (_name, type) => { + const trimmed = await check(type, { name: ' 거래처 ' }); + expect(trimmed.constraints).toEqual([]); + expect(trimmed.dto.name).toBe('거래처'); + + expect( + (await check(type, { name: 'x'.repeat(MAX_MAILBOX_NAME_LENGTH) })) + .constraints, + ).toEqual([]); + expect( + ( + await check(type, { + name: ` ${'가'.repeat(MAX_MAILBOX_NAME_LENGTH)} `, + }) + ).constraints, + ).toEqual([]); + + expect((await check(type, { name: ' ' })).constraints).toEqual([ + 'minCodeUnits', + ]); + expect( + (await check(type, { name: 'x'.repeat(MAX_MAILBOX_NAME_LENGTH + 1) })) + .constraints, + ).toEqual(['maxCodeUnits']); + // 이모지 32개 + 1자 = 65 코드 단위 + expect( + (await check(type, { name: `${'📁'.repeat(32)}x` })).constraints, + ).toEqual(['maxCodeUnits']); + }, + ); + + it.each([ + ['만들기', CreateMailboxDto], + ['이름 바꾸기', RenameMailboxDto], + ] as const)( + '%s — "/" 와 제어 문자(줄바꿈 · 탭 등)는 쓸 수 없다, 문자열이 아니면 형식 오류', + async (_name, type) => { + for (const name of [ + 'a/b', + '거래\n처', + '거래\t처', + 'a\u0000', + 'a\u001f', + 'a\u007f', + 'a\u0085b', + 'a\u009f', + ]) { + expect((await check(type, { name })).constraints).toEqual(['matches']); + } + for (const name of ['2026-보고 (최종) · 1차', 'a~b', 'a\u00a0b']) { + expect((await check(type, { name })).constraints).toEqual([]); + } + expect((await check(type, { name: 7 })).constraints).toEqual( + expect.arrayContaining(['isString']), + ); + expect((await check(type, {})).constraints).toEqual( + expect.arrayContaining(['isString']), + ); + }, + ); + + it('만들기의 상위는 없거나 null(맨 위) · 메일함 id 형식 — 이름 바꾸기에는 상위가 없다', async () => { + expect( + (await check(CreateMailboxDto, { name: '거래처' })).constraints, + ).toEqual([]); + expect( + (await check(CreateMailboxDto, { name: '거래처', parentId: null })) + .constraints, + ).toEqual([]); + expect( + (await check(CreateMailboxDto, { name: '거래처', parentId: 'a' })) + .constraints, + ).toEqual([]); + expect( + (await check(CreateMailboxDto, { name: '거래처', parentId: '../a' })) + .constraints, + ).toEqual(['matches']); + expect( + (await check(RenameMailboxDto, { name: '거래처', parentId: 'a' })) + .constraints, + ).toEqual(['whitelistValidation']); + }); + + it('메일함 메일 바꾸기는 { isRead: true } 만 받는다', async () => { + expect( + (await check(MailboxMessagesPatchDto, { isRead: true })).constraints, + ).toEqual([]); + for (const body of [{ isRead: false }, { isRead: 'true' }, {}]) { + expect((await check(MailboxMessagesPatchDto, body)).constraints).toEqual([ + 'equals', + ]); + } + expect( + (await check(MailboxMessagesPatchDto, { isRead: true, isFlagged: true })) + .constraints, + ).toEqual(['whitelistValidation']); + }); +}); diff --git a/backend/src/modules/mail/dto/mailbox.dto.ts b/backend/src/modules/mail/dto/mailbox.dto.ts new file mode 100644 index 0000000..1068766 --- /dev/null +++ b/backend/src/modules/mail/dto/mailbox.dto.ts @@ -0,0 +1,83 @@ +import { applyDecorators } from '@nestjs/common'; +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { Transform, type TransformFnParams } from 'class-transformer'; +import { Equals, IsOptional, IsString, Matches } from 'class-validator'; + +import { + MaxCodeUnits, + MinCodeUnits, +} from '@/common/decorators/code-units.decorator'; +import { + MAIL_ID_PATTERN, + MAX_MAILBOX_NAME_LENGTH, + SINGLE_LINE_TEXT_PATTERN, +} from '@/modules/mail/mail.constants'; + +/** 메일함 이름에 쓸 수 없는 '/' — 경로 구분자로 읽는 메일 프로그램이 있다 (계약 2절 "메일함 관리") */ +const NO_SLASH_PATTERN = /^[^/]*$/; + +/** 앞뒤 공백을 지운다 — 문자열이 아니면 그대로 두어 IsString 이 막는다 */ +function trimString({ value }: TransformFnParams): unknown { + const raw: unknown = value; + return typeof raw === 'string' ? raw.trim() : raw; +} + +/** 메일함 이름 — 앞뒤 공백을 뺀 1~MAX_MAILBOX_NAME_LENGTH 자(JS 문자열 길이), `/` · 제어 문자 없음. 만들기 · 이름 바꾸기가 같이 쓴다 */ +function MailboxName() { + return applyDecorators( + ApiProperty({ + description: `메일함 이름 — 앞뒤 공백을 뺀 1~${MAX_MAILBOX_NAME_LENGTH}자(JS 문자열 길이). "/" 와 제어 문자(줄바꿈 · 탭 등)는 쓸 수 없다. 같은 상위 안에 같은 이름이 있으면 409 MAIL_009`, + example: '프로젝트', + minLength: 1, + maxLength: MAX_MAILBOX_NAME_LENGTH, + }), + Transform(trimString), + IsString({ message: '메일함 이름 형식이 올바르지 않습니다.' }), + MinCodeUnits(1, { message: '메일함 이름을 입력해 주세요.' }), + MaxCodeUnits(MAX_MAILBOX_NAME_LENGTH, { + message: `메일함 이름은 ${MAX_MAILBOX_NAME_LENGTH}자 이하로 입력해 주세요.`, + }), + Matches(NO_SLASH_PATTERN, { + message: '메일함 이름에는 / 를 쓸 수 없습니다.', + }), + // 제어 문자(C0 · DEL · C1 — 줄바꿈 · 탭 포함) — IMAP 폴더 이름이 깨지고 로그 줄이 나뉜다 + Matches(SINGLE_LINE_TEXT_PATTERN, { + message: '메일함 이름에는 줄바꿈 같은 제어 문자를 쓸 수 없습니다.', + }), + ); +} + +/** POST /api/mailboxes — 계약 2절 "메일함 관리" */ +export class CreateMailboxDto { + @MailboxName() + name!: string; + + @ApiPropertyOptional({ + description: + '상위 메일함 id — 없거나 null 이면 맨 위. 받은편지함 · 보관함 · 내 메일함(역할 없음) 아래에만 만들 수 있다 ' + + '(보낸편지함 · 임시보관함 · 스팸메일함 · 휴지통 아래는 400 VAL_001, 없는 id 는 404 RES_001)', + nullable: true, + type: String, + example: 'a', + }) + @IsOptional() + @Matches(MAIL_ID_PATTERN, { message: '상위 메일함을 다시 선택해 주세요.' }) + parentId?: string | null; +} + +/** PATCH /api/mailboxes/{id} — 이름만 바꾼다 (다른 상위로 옮기기는 이 단계에서 하지 않는다) */ +export class RenameMailboxDto { + @MailboxName() + name!: string; +} + +/** PATCH /api/mailboxes/{id}/messages — 그 메일함 전체를 읽음으로 (true 만 받는다) */ +export class MailboxMessagesPatchDto { + @ApiProperty({ + description: '읽음으로 — true 만 받는다', + enum: [true], + example: true, + }) + @Equals(true, { message: '읽음 표시는 true 만 보낼 수 있습니다.' }) + isRead!: true; +} 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 77edd0a..8fa807a 100644 --- a/backend/src/modules/mail/dto/message-list-query.dto.ts +++ b/backend/src/modules/mail/dto/message-list-query.dto.ts @@ -8,6 +8,7 @@ import { type ValidationOptions, } from 'class-validator'; +import { MaxCodeUnits } from '@/common/decorators/code-units.decorator'; import { CursorPaginationQueryDto } from '@/common/dto/pagination.dto'; import { MAIL_ID_PATTERN, @@ -32,24 +33,6 @@ 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( diff --git a/backend/src/modules/mail/dto/update-message.dto.ts b/backend/src/modules/mail/dto/update-message.dto.ts index 050c993..0b802e9 100644 --- a/backend/src/modules/mail/dto/update-message.dto.ts +++ b/backend/src/modules/mail/dto/update-message.dto.ts @@ -6,7 +6,7 @@ import { MAIL_ID_PATTERN } from '@/modules/mail/mail.constants'; /** 값이 왔을 때만 검사한다 — null 은 "보내지 않음"이 아니라 형식 오류로 막는다 */ const isPresent = (_object: object, value: unknown) => value !== undefined; -/** PATCH /api/messages/{id} — 하나 이상 지정한다 (빈 요청은 VAL_001) */ +/** PATCH /api/messages/{id} — 읽음 · 중요 표시 · 이동 · 스팸 표시 중 하나 이상 지정한다 (빈 요청은 VAL_001) */ export class UpdateMessageDto { @ApiPropertyOptional({ description: '읽음 여부', example: true }) @ValidateIf(isPresent) @@ -25,4 +25,14 @@ export class UpdateMessageDto { @ValidateIf(isPresent) @Matches(MAIL_ID_PATTERN, { message: '메일함을 다시 선택해 주세요.' }) mailboxId?: string; + + @ApiPropertyOptional({ + description: + '스팸 신고(true — 키워드 $junk 를 붙이고 $notjunk 를 뗀다) · 스팸 아님(false — 반대). 메일함 이동과는 따로다 — ' + + '화면은 스팸 신고 { mailboxId: <스팸메일함>, isJunk: true } · 스팸 아님 { mailboxId: <받은편지함>, isJunk: false } 로 함께 보낸다', + example: true, + }) + @ValidateIf(isPresent) + @IsBoolean({ message: '스팸 표시는 true 또는 false 여야 합니다.' }) + isJunk?: boolean; } 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 9213c8a..bd03406 100644 --- a/backend/src/modules/mail/jmap/jmap-mail.provider.spec.ts +++ b/backend/src/modules/mail/jmap/jmap-mail.provider.spec.ts @@ -16,6 +16,7 @@ import { import type { JmapClient, JmapSession } from './jmap.client'; import { decodeCursor, encodeCursor } from './jmap-cursor'; import { JmapMailProvider, buildThreadHeaders } from './jmap-mail.provider'; +import { MAILBOX_PROPERTIES } from './jmap.mapper'; import type { JmapInvocation } from './jmap.types'; const ARA = 'ara.kim@arasystem.test'; @@ -124,6 +125,7 @@ function fakeClient( const queue = [...responses]; const client = { verifyCredentials: jest.fn(), + changePassword: jest.fn(), getSession: jest.fn().mockResolvedValue(overrides.session ?? session), assertNoManagementAccess: jest.fn().mockResolvedValue(undefined), call: jest.fn( @@ -3394,6 +3396,1467 @@ describe('JmapMailProvider', () => { }); }); + describe('스팸 신고 · 스팸 아님 (isJunk)', () => { + it('true 면 $junk 를 붙이고 $notjunk 를 뗀다 — 이동과 같은 패치 하나로 (한 통)', async () => { + const { provider, recorded } = fakeClient([ + [ + ['Email/set', { updated: { m1: null } }, 'u'], + [ + 'Email/get', + { + list: [ + summaryEmail('m1', '2026-10-28T01:00:00Z', { + mailboxIds: { c: true }, + }), + ], + }, + 'g', + ], + ], + ]); + + await provider.updateMessage(ARA, 'm1', { mailboxId: 'c', isJunk: true }); + + expect(argsOf(recorded[0])).toEqual({ + accountId: 'd', + update: { + m1: { + 'keywords/$junk': true, + 'keywords/$notjunk': null, + mailboxIds: { c: true }, + }, + }, + }); + }); + + it('false 면 반대 — 여러 통도 메일마다 같은 패치', async () => { + const { provider, recorded } = fakeClient([ + [ + ['Mailbox/get', { list: [{ id: 'a' }] }, 'mb'], + ['Email/set', { updated: { m1: null, m2: null } }, 'u'], + ], + ]); + + await provider.updateMessages(ARA, ['m1', 'm2'], { + mailboxId: 'a', + isJunk: false, + }); + + const patch = { + 'keywords/$junk': null, + 'keywords/$notjunk': true, + mailboxIds: { a: true }, + }; + expect(argsOf(recorded[0], 1)).toEqual({ + accountId: 'd', + update: { m1: patch, m2: patch }, + }); + }); + }); + + describe('메일함 관리 (계약 2절 "메일함 관리")', () => { + let warnSpy: jest.SpyInstance; + + beforeEach(() => { + warnSpy = jest + .spyOn(Logger.prototype, 'warn') + .mockImplementation(() => undefined); + }); + + // 시각(Date.now)을 바꾸는 테스트가 있다 — 다음 테스트로 넘기지 않는다 + afterEach(() => jest.restoreAllMocks()); + + const userMailbox = ( + id: string, + name: string, + extra: Record = {}, + ) => ({ + id, + name, + role: null, + sortOrder: 0, + parentId: null, + totalEmails: 0, + unreadEmails: 0, + ...extra, + }); + /** 기본 메일함 + 보관함(f) + 내 메일함 — u1 아래에 u2 가 있다, u4 에 메일이 있다 */ + const mailboxes = [ + ...stalwartMailboxes, + userMailbox('f', 'Archive', { role: 'archive' }), + userMailbox('u1', '프로젝트', { totalEmails: 2, unreadEmails: 1 }), + userMailbox('u2', '하위', { parentId: 'u1' }), + userMailbox('u3', '빈 메일함'), + userMailbox('u4', '보관 자료', { totalEmails: 2, unreadEmails: 2 }), + ]; + const mailboxList = (list: unknown[] = mailboxes): JmapInvocation[] => [ + ['Mailbox/get', { list }, 'm'], + ]; + + /** 메일함 한 차례 — 조회(전체 수) + 메일(들어 있는 메일함)과 그때의 Email 상태 */ + type Candidate = { id: string; mailboxIds: Record }; + const batch = ( + emails: Candidate[], + state: string | null = 's1', + total: number = emails.length, + ): JmapInvocation[] => [ + ['Email/query', { ids: emails.map((email) => email.id), total }, 'q'], + [ + 'Email/get', + { ...(state === null ? {} : { state }), list: emails, notFound: [] }, + 'g', + ], + ]; + const only = (id: string, mailboxId: string): Candidate => ({ + id, + mailboxIds: { [mailboxId]: true }, + }); + const mismatch: JmapInvocation[] = [ + ['error', { type: 'stateMismatch' }, 's'], + ]; + const ids = (prefix: string, count: number) => + Array.from({ length: count }, (_, index) => `${prefix}${index}`); + + /** n 번째 엔진 호출이 끝난 뒤 시계를 넘긴다 — 한 요청의 시간(25초) 확인용 */ + function advanceClockAfterCall( + fake: ReturnType, + callNumber: number, + ms: number, + ): void { + let now = 1_000_000; + jest.spyOn(Date, 'now').mockImplementation(() => now); + const call = fake.client.call.getMockImplementation(); + if (!call) throw new Error('call 구현이 없다'); + fake.client.call.mockImplementation((...args) => { + const answer = call(...args); + if (fake.recorded.length === callNumber) now += ms; + return answer; + }); + } + + /** n 번째 엔진 호출이 시계를 ms 만큼 넘긴 뒤 error 로 끝난다 — 남은 시간 때문에 끊긴 호출 확인용 (그 호출은 기록되지 않는다) */ + function failCallWith( + fake: ReturnType, + callNumber: number, + ms: number, + error: Error, + ): void { + let now = 1_000_000; + jest.spyOn(Date, 'now').mockImplementation(() => now); + const call = fake.client.call.getMockImplementation(); + if (!call) throw new Error('call 구현이 없다'); + let count = 0; + fake.client.call.mockImplementation((...args) => { + count += 1; + if (count === callNumber) { + now += ms; + return Promise.reject(error); + } + return call(...args); + }); + } + + describe('만들기 (createMailbox)', () => { + const created = userMailbox('n1', '거래처'); + + it('메일함 목록으로 상위를 확인하고, 구독한 메일함으로 만든 뒤 같은 요청에서 다시 읽어 돌려준다', async () => { + const { provider, recorded } = fakeClient([ + mailboxList(), + [ + ['Mailbox/set', { created: { mailbox: { id: 'n1' } } }, 'c'], + ['Mailbox/get', { list: [...mailboxes, created] }, 'm'], + ], + ]); + + await expect( + provider.createMailbox(ARA, { name: '거래처', parentId: null }), + ).resolves.toEqual({ + id: 'n1', + name: '거래처', + role: null, + parentId: null, + sortOrder: 0, + totalCount: 0, + unreadCount: 0, + }); + expect(recorded[0].calls).toEqual([ + [ + 'Mailbox/get', + { accountId: 'd', ids: null, properties: MAILBOX_PROPERTIES }, + 'm', + ], + ]); + expect(recorded[1].calls).toEqual([ + [ + 'Mailbox/set', + { + accountId: 'd', + create: { + mailbox: { name: '거래처', parentId: null, isSubscribed: true }, + }, + }, + 'c', + ], + [ + 'Mailbox/get', + { accountId: 'd', ids: null, properties: MAILBOX_PROPERTIES }, + 'm', + ], + ]); + }); + + it('받은편지함 · 보관함 · 내 메일함(역할 없음) 아래에 만들 수 있다', async () => { + for (const parentId of ['a', 'f', 'u1']) { + const { provider, recorded } = fakeClient([ + mailboxList(), + [ + ['Mailbox/set', { created: { mailbox: { id: 'n1' } } }, 'c'], + [ + 'Mailbox/get', + { list: [...mailboxes, { ...created, parentId }] }, + 'm', + ], + ], + ]); + + const mailbox = await provider.createMailbox(ARA, { + name: '거래처', + parentId, + }); + + expect(mailbox.parentId).toBe(parentId); + expect(argsOf(recorded[1])).toMatchObject({ + create: { mailbox: { parentId } }, + }); + } + }); + + it('보낸편지함 · 임시보관함 · 스팸메일함 · 휴지통 아래는 만들지 않고 VAL_001, 없는 상위는 RES_001', async () => { + for (const parentId of ['e', 'd', 'c', 'b']) { + const { provider, recorded } = fakeClient([mailboxList()]); + expect( + await codeOf( + provider.createMailbox(ARA, { name: '거래처', parentId }), + ), + ).toBe('VAL_001'); + expect(recorded).toHaveLength(1); + } + + const missing = fakeClient([mailboxList()]); + expect( + await codeOf( + missing.provider.createMailbox(ARA, { + name: '거래처', + parentId: 'nope', + }), + ), + ).toBe('RES_001'); + expect(missing.recorded).toHaveLength(1); + }); + + it('같은 상위 안에 같은 이름(alreadyExists)은 MAIL_009, 그사이 상위가 없어짐(parentId 거절)은 RES_001, 이름 거절은 VAL_001', async () => { + const cases = [ + [{ type: 'alreadyExists' }, 'MAIL_009'], + [{ type: 'invalidProperties', properties: ['parentId'] }, 'RES_001'], + [{ type: 'invalidProperties', properties: ['name'] }, 'VAL_001'], + [{ type: 'serverFail' }, 'MAIL_001'], + ] as const; + + for (const [setError, code] of cases) { + const { provider } = fakeClient([ + mailboxList(), + [ + ['Mailbox/set', { notCreated: { mailbox: setError } }, 'c'], + ['Mailbox/get', { list: mailboxes }, 'm'], + ], + ]); + expect( + await codeOf( + provider.createMailbox(ARA, { name: '프로젝트', parentId: null }), + ), + ).toBe(code); + } + }); + + it('다시 읽은 목록에 없으면(있을 수 없는 경우) 보낸 값으로 답한다 — 만들기는 성공했다', async () => { + const { provider } = fakeClient([ + mailboxList(), + [ + ['Mailbox/set', { created: { mailbox: { id: 'n1' } } }, 'c'], + ['Mailbox/get', { list: mailboxes }, 'm'], + ], + ]); + + await expect( + provider.createMailbox(ARA, { name: '거래처', parentId: 'a' }), + ).resolves.toMatchObject({ id: 'n1', name: '거래처', parentId: 'a' }); + }); + }); + + describe('이름 바꾸기 (renameMailbox)', () => { + it('그 메일함만 이름을 바꾸고 같은 요청에서 다시 읽어 돌려준다', async () => { + const { provider, recorded } = fakeClient([ + mailboxList(), + [ + ['Mailbox/set', { updated: { u1: null } }, 'u'], + [ + 'Mailbox/get', + { + list: [ + userMailbox('u1', '새 이름', { + totalEmails: 2, + unreadEmails: 1, + }), + ], + }, + 'm', + ], + ], + ]); + + await expect( + provider.renameMailbox(ARA, 'u1', '새 이름'), + ).resolves.toMatchObject({ id: 'u1', name: '새 이름', totalCount: 2 }); + expect(recorded[1].calls).toEqual([ + [ + 'Mailbox/set', + { accountId: 'd', update: { u1: { name: '새 이름' } } }, + 'u', + ], + [ + 'Mailbox/get', + { accountId: 'd', ids: ['u1'], properties: MAILBOX_PROPERTIES }, + 'm', + ], + ]); + }); + + it('기본 메일함(역할 있음 — 우리가 모르는 역할 포함)은 VAL_001, 없는 메일함은 RES_001 — 엔진에 바꾸기를 보내지 않는다', async () => { + const withUnknownRole = [ + ...mailboxes, + userMailbox('i1', 'Important', { role: 'important' }), + ]; + for (const [id, code] of [ + ['a', 'VAL_001'], + ['f', 'VAL_001'], + ['i1', 'VAL_001'], + ['nope', 'RES_001'], + ] as const) { + const { provider, recorded } = fakeClient([ + mailboxList(withUnknownRole), + ]); + expect(await codeOf(provider.renameMailbox(ARA, id, '새 이름'))).toBe( + code, + ); + expect(recorded).toHaveLength(1); + } + }); + + it('지금 이름과 같으면 엔진에 묻지 않고 그대로 돌려준다', async () => { + const { provider, recorded } = fakeClient([mailboxList()]); + + await expect( + provider.renameMailbox(ARA, 'u1', '프로젝트'), + ).resolves.toMatchObject({ id: 'u1', name: '프로젝트' }); + expect(recorded).toHaveLength(1); + }); + + it('같은 이름이 있으면(alreadyExists) MAIL_009, 그사이 없어졌으면 RES_001', async () => { + for (const [setError, code] of [ + [{ type: 'alreadyExists' }, 'MAIL_009'], + [{ type: 'notFound' }, 'RES_001'], + ] as const) { + const { provider } = fakeClient([ + mailboxList(), + [ + ['Mailbox/set', { notUpdated: { u1: setError } }, 'u'], + ['Mailbox/get', { list: [] }, 'm'], + ], + ]); + expect( + await codeOf(provider.renameMailbox(ARA, 'u1', '보관 자료')), + ).toBe(code); + } + }); + }); + + describe('지우기 (deleteMailbox)', () => { + const destroyed = (id: string): JmapInvocation[] => [ + ['Mailbox/set', { destroyed: [id] }, 'x'], + ]; + const hasEmail: JmapInvocation[] = [ + [ + 'Mailbox/set', + { notDestroyed: { u4: { type: 'mailboxHasEmail' } } }, + 'x', + ], + ]; + + it('빈 메일함은 메일을 지우지 않는 옵션(onDestroyRemoveEmails: false)으로 지운다', async () => { + const { provider, recorded } = fakeClient([ + mailboxList(), + batch([]), + destroyed('u3'), + ]); + + await expect(provider.deleteMailbox(ARA, 'u3')).resolves.toEqual({ + deleted: true, + processed: 0, + movedToTrash: 0, + remaining: 0, + }); + expect(recorded[1].calls).toEqual([ + [ + 'Email/query', + { + accountId: 'd', + filter: { inMailbox: 'u3' }, + limit: 500, + calculateTotal: true, + }, + 'q', + ], + [ + 'Email/get', + { + accountId: 'd', + '#ids': { resultOf: 'q', name: 'Email/query', path: '/ids' }, + properties: ['id', 'mailboxIds'], + }, + 'g', + ], + ]); + expect(recorded[2].calls).toEqual([ + [ + 'Mailbox/set', + { accountId: 'd', destroy: ['u3'], onDestroyRemoveEmails: false }, + 'x', + ], + ]); + }); + + it('안의 메일은 확인한 상태 그대로일 때만 옮긴다 — 이 메일함에만 있으면 휴지통으로, 다른 메일함에도 있으면 이 메일함에서만 뺀다', async () => { + const { provider, recorded } = fakeClient([ + mailboxList(), + batch([ + only('m1', 'u4'), + { id: 'm2', mailboxIds: { u4: true, a: true } }, + ]), + [['Email/set', { updated: { m1: null, m2: null } }, 's']], + batch([]), + destroyed('u4'), + ]); + + await expect(provider.deleteMailbox(ARA, 'u4')).resolves.toEqual({ + deleted: true, + processed: 2, + movedToTrash: 1, + remaining: 0, + }); + expect(argsOf(recorded[2])).toEqual({ + accountId: 'd', + ifInState: 's1', + update: { + m1: { 'mailboxIds/u4': null, 'mailboxIds/b': true }, + m2: { 'mailboxIds/u4': null }, + }, + }); + // 메일을 영구 삭제하는 요청은 하나도 없다 + for (const record of recorded) { + for (const [name, args] of record.calls) { + if (name === 'Email/set') + expect(args).not.toHaveProperty('destroy'); + } + } + }); + + it('기본 메일함 · 하위 메일함이 있는 메일함은 VAL_001, 없는 메일함은 RES_001 — 아무것도 옮기지 않는다', async () => { + for (const [id, code] of [ + ['a', 'VAL_001'], + ['b', 'VAL_001'], + ['u1', 'VAL_001'], + ['nope', 'RES_001'], + ] as const) { + const { provider, recorded } = fakeClient([mailboxList()]); + expect(await codeOf(provider.deleteMailbox(ARA, id))).toBe(code); + expect(recorded).toHaveLength(1); + } + }); + + it('어긋나면(그사이 다른 창이 바꿈) 다시 읽어 그때의 상태로 옮긴다', async () => { + const { provider, recorded } = fakeClient([ + mailboxList(), + batch([only('m1', 'u4')], 's1'), + mismatch, + batch([only('m1', 'u4')], 's2'), + [['Email/set', { updated: { m1: null } }, 's']], + batch([]), + destroyed('u4'), + ]); + + await expect(provider.deleteMailbox(ARA, 'u4')).resolves.toEqual({ + deleted: true, + processed: 1, + movedToTrash: 1, + remaining: 0, + }); + expect(argsOf(recorded[4])).toMatchObject({ ifInState: 's2' }); + }); + + it('이어서 세 번 어긋나 하나도 옮기지 못하면 MAIL_001 — 옮기는 요청은 모두 조건부였다', async () => { + const { provider, recorded } = fakeClient([ + mailboxList(), + batch([only('m1', 'u4')], 's1'), + mismatch, + batch([only('m1', 'u4')], 's2'), + mismatch, + batch([only('m1', 'u4')], 's3'), + mismatch, + ]); + + expect(await codeOf(provider.deleteMailbox(ARA, 'u4'))).toBe( + 'MAIL_001', + ); + expect(recorded).toHaveLength(7); + for (const index of [2, 4, 6]) { + expect(argsOf(recorded[index])).toHaveProperty('ifInState'); + } + }); + + it('지우는 사이 메일이 들어오면(mailboxHasEmail) 그 메일도 옮긴 뒤 다시 지운다', async () => { + const { provider } = fakeClient([ + mailboxList(), + batch([]), + hasEmail, + batch([only('m9', 'u4')]), + [['Email/set', { updated: { m9: null } }, 's']], + batch([]), + destroyed('u4'), + ]); + + await expect(provider.deleteMailbox(ARA, 'u4')).resolves.toEqual({ + deleted: true, + processed: 1, + movedToTrash: 1, + remaining: 0, + }); + }); + + it('읽은 목록이 비어 있는데도 이어서 세 번 거절되면(엔진 이상) MAIL_001', async () => { + const { provider } = fakeClient([ + mailboxList(), + batch([]), + hasEmail, + batch([]), + hasEmail, + batch([]), + hasEmail, + ]); + + expect(await codeOf(provider.deleteMailbox(ARA, 'u4'))).toBe( + 'MAIL_001', + ); + }); + + it('그사이 하위 메일함이 생겼으면(mailboxHasChild) VAL_001, 이미 없으면 지운 것으로 본다', async () => { + const child = fakeClient([ + mailboxList(), + batch([]), + [ + [ + 'Mailbox/set', + { notDestroyed: { u3: { type: 'mailboxHasChild' } } }, + 'x', + ], + ], + ]); + expect(await codeOf(child.provider.deleteMailbox(ARA, 'u3'))).toBe( + 'VAL_001', + ); + + const gone = fakeClient([ + mailboxList(), + batch([]), + [ + [ + 'Mailbox/set', + { notDestroyed: { u3: { type: 'notFound' } } }, + 'x', + ], + ], + ]); + await expect(gone.provider.deleteMailbox(ARA, 'u3')).resolves.toEqual({ + deleted: true, + processed: 0, + movedToTrash: 0, + remaining: 0, + }); + }); + + it('옮길 메일이 있는데 휴지통이 없으면 옮기지 않고 MAIL_001 · 상태를 모르면 조건 없이 옮기지 않는다', async () => { + const noTrash = fakeClient([ + mailboxList(mailboxes.filter((mailbox) => mailbox.role !== 'trash')), + batch([only('m1', 'u4')]), + ]); + expect(await codeOf(noTrash.provider.deleteMailbox(ARA, 'u4'))).toBe( + 'MAIL_001', + ); + expect(noTrash.recorded).toHaveLength(2); + + const unknownState = fakeClient([ + mailboxList(), + batch([only('m1', 'u4')], null), + ]); + expect( + await codeOf(unknownState.provider.deleteMailbox(ARA, 'u4')), + ).toBe('MAIL_001'); + expect(unknownState.recorded).toHaveLength(2); + }); + + it('한 요청의 시간(25초)이 모자라면 옮긴 만큼만 하고 deleted: false 와 남은 수 — 클라이언트가 다시 보낸다', async () => { + const first = ids('m', 500).map((id) => only(id, 'u4')); + const fake = fakeClient([ + mailboxList(), + batch(first, 's1', 1_200), + [ + [ + 'Email/set', + { + updated: Object.fromEntries(first.map(({ id }) => [id, null])), + }, + 's', + ], + ], + ]); + // 첫 차례를 옮긴 뒤 21초가 지났다 — 남은 시간이 5초보다 적어 다음 차례를 시작하지 않는다 + advanceClockAfterCall(fake, 3, 21_000); + + await expect(fake.provider.deleteMailbox(ARA, 'u4')).resolves.toEqual({ + deleted: false, + processed: 500, + movedToTrash: 500, + remaining: 700, + }); + expect(fake.recorded).toHaveLength(3); + }); + + it('엔진이 옮기기를 모두 거절하면 멈춘다 — 이 요청에서 옮긴 것이 없으면 그 거절의 코드', async () => { + const { provider } = fakeClient([ + mailboxList(), + batch([only('m1', 'u4')]), + [['Email/set', { notUpdated: { m1: { type: 'forbidden' } } }, 's']], + ]); + + expect(await codeOf(provider.deleteMailbox(ARA, 'u4'))).toBe( + 'AUTH_003', + ); + expect(warnSpy).toHaveBeenCalledWith( + expect.stringContaining('jmapType=forbidden'), + ); + }); + + it('다른 메일함에도 든 메일만 남았으면 휴지통으로 옮긴 수는 0 이어도 진행 수(processed)는 빠진 수다', async () => { + const both = (id: string): Candidate => ({ + id, + mailboxIds: { u4: true, a: true }, + }); + const { provider, recorded } = fakeClient([ + mailboxList(), + batch([both('m1'), both('m2')]), + [['Email/set', { updated: { m1: null, m2: null } }, 's']], + batch([]), + destroyed('u4'), + ]); + + await expect(provider.deleteMailbox(ARA, 'u4')).resolves.toEqual({ + deleted: true, + processed: 2, + movedToTrash: 0, + remaining: 0, + }); + expect(argsOf(recorded[2])).toEqual({ + accountId: 'd', + ifInState: 's1', + update: { + m1: { 'mailboxIds/u4': null }, + m2: { 'mailboxIds/u4': null }, + }, + }); + }); + + it('바뀐 것도 거절도 없는 차례가 이어서 세 번이면 멈춘다 — 옮긴 것이 없으면 MAIL_001, 있으면 거기까지', async () => { + const vanished: JmapInvocation[] = [ + ['Email/set', { notUpdated: { m2: { type: 'notFound' } } }, 's'], + ]; + const idle = fakeClient([ + mailboxList(), + batch([only('m2', 'u4')]), + vanished, + batch([only('m2', 'u4')]), + vanished, + batch([only('m2', 'u4')]), + vanished, + ]); + expect(await codeOf(idle.provider.deleteMailbox(ARA, 'u4'))).toBe( + 'MAIL_001', + ); + expect(idle.recorded).toHaveLength(7); + + const some = fakeClient([ + mailboxList(), + batch([only('m1', 'u4'), only('m2', 'u4')]), + [ + [ + 'Email/set', + { + updated: { m1: null }, + notUpdated: { m2: { type: 'notFound' } }, + }, + 's', + ], + ], + batch([only('m2', 'u4')]), + vanished, + batch([only('m2', 'u4')]), + vanished, + batch([only('m2', 'u4')]), + vanished, + ]); + await expect(some.provider.deleteMailbox(ARA, 'u4')).resolves.toEqual({ + deleted: false, + processed: 1, + movedToTrash: 1, + remaining: 1, + }); + }); + + it('시계는 메일함 목록을 읽기 전부터 간다 — 목록을 읽는 데 21초가 걸리면 차례를 시작하지 않고 MAIL_001 (옮긴 것이 없다)', async () => { + const fake = fakeClient([mailboxList()]); + advanceClockAfterCall(fake, 1, 21_000); + + expect(await codeOf(fake.provider.deleteMailbox(ARA, 'u4'))).toBe( + 'MAIL_001', + ); + expect(fake.recorded).toHaveLength(1); + }); + + it('남은 시간 때문에 끊긴 엔진 호출 — 옮긴 것이 있으면 거기까지로 답하고, 없으면 MAIL_001 · 시간이 남았는데 끊긴 것은 엔진 장애 그대로', async () => { + const timeout = new BusinessException('MAIL_001', { + context: { reason: 'timeout' }, + }); + const firstRound = (): JmapInvocation[][] => [ + mailboxList(), + batch([only('m1', 'u4')], 's1', 2), + [['Email/set', { updated: { m1: null } }, 's']], + ]; + + // 넷째 호출(다음 차례의 읽기)이 시간을 다 쓰고 끊겼다 — 첫 차례의 결과로 답한다 + const cut = fakeClient(firstRound()); + failCallWith(cut, 4, 30_000, timeout); + await expect(cut.provider.deleteMailbox(ARA, 'u4')).resolves.toEqual({ + deleted: false, + processed: 1, + movedToTrash: 1, + remaining: 1, + }); + + // 첫 차례의 읽기가 끊겼다 — 옮긴 것이 없다 + const early = fakeClient([mailboxList()]); + failCallWith(early, 2, 30_000, timeout); + expect(await codeOf(early.provider.deleteMailbox(ARA, 'u4'))).toBe( + 'MAIL_001', + ); + + // 시간이 남았는데 끊겼다(엔진이 늦다) — 옮겼어도 엔진 장애로 답한다 + const slow = fakeClient(firstRound()); + failCallWith(slow, 4, 0, timeout); + expect(await codeOf(slow.provider.deleteMailbox(ARA, 'u4'))).toBe( + 'MAIL_001', + ); + }); + }); + + describe('전체 읽음 (markMailboxRead)', () => { + const unread = (emailIds: string[], total = emailIds.length) => [ + ['Email/query', { ids: emailIds, total }, 'q'] as JmapInvocation, + ]; + const marked = (emailIds: string[]): JmapInvocation[] => [ + [ + 'Email/set', + { updated: Object.fromEntries(emailIds.map((id) => [id, null])) }, + 'u', + ], + ]; + + it('안 읽은 메일을 500통씩 찾아 읽음으로 바꾼다 — 남은 것이 없을 때까지', async () => { + const firstIds = ids('m', 500); + const secondIds = ids('n', 200); + const { provider, recorded } = fakeClient([ + mailboxList(), + unread(firstIds, 700), + marked(firstIds), + unread(secondIds, 200), + marked(secondIds), + unread([]), + ]); + + await expect(provider.markMailboxRead(ARA, 'a')).resolves.toEqual({ + processed: 700, + remaining: 0, + }); + expect(recorded[1].calls).toEqual([ + [ + 'Email/query', + { + accountId: 'd', + filter: { inMailbox: 'a', notKeyword: '$seen' }, + limit: 500, + calculateTotal: true, + }, + 'q', + ], + ]); + expect(argsOf(recorded[2])).toEqual({ + accountId: 'd', + update: Object.fromEntries( + firstIds.map((id) => [id, { 'keywords/$seen': true }]), + ), + }); + // 읽음 표시는 되돌릴 수 있는 일이라 조건 없이 바꾼다 + expect(argsOf(recorded[2])).not.toHaveProperty('ifInState'); + }); + + it('한 요청의 시간이 모자라면 바꾼 만큼과 남은 수를 돌려준다', async () => { + const firstIds = ids('m', 500); + const fake = fakeClient([ + mailboxList(), + unread(firstIds, 1_234), + marked(firstIds), + ]); + advanceClockAfterCall(fake, 3, 21_000); + + await expect(fake.provider.markMailboxRead(ARA, 'a')).resolves.toEqual({ + processed: 500, + remaining: 734, + }); + }); + + it('엔진이 모두 거절하면 멈춘다 — 바꾼 것이 없으면 그 거절의 코드, 바꾼 것이 있으면 거기까지와 남은 수 (끝없이 다시 보내지 않게)', async () => { + const refused = (emailIds: string[]): JmapInvocation[] => [ + [ + 'Email/set', + { + notUpdated: Object.fromEntries( + emailIds.map((id) => [id, { type: 'forbidden' }]), + ), + }, + 'u', + ], + ]; + + const nothing = fakeClient([ + mailboxList(), + unread(['m1', 'm2']), + refused(['m1', 'm2']), + ]); + expect(await codeOf(nothing.provider.markMailboxRead(ARA, 'a'))).toBe( + 'AUTH_003', + ); + + const some = fakeClient([ + mailboxList(), + unread(['m1', 'm2']), + marked(['m1']), + unread(['m2']), + refused(['m2']), + ]); + await expect(some.provider.markMailboxRead(ARA, 'a')).resolves.toEqual({ + processed: 1, + remaining: 1, + }); + }); + + it('그사이 지워진 메일(notFound)만 남았으면 다시 찾는다 — 없는 메일함은 RES_001', async () => { + const { provider } = fakeClient([ + mailboxList(), + unread(['m1']), + [['Email/set', { notUpdated: { m1: { type: 'notFound' } } }, 'u']], + unread([]), + ]); + await expect(provider.markMailboxRead(ARA, 'a')).resolves.toEqual({ + processed: 0, + remaining: 0, + }); + + const missing = fakeClient([mailboxList()]); + expect( + await codeOf(missing.provider.markMailboxRead(ARA, 'nope')), + ).toBe('RES_001'); + expect(missing.recorded).toHaveLength(1); + }); + + it('엔진 호출마다 제한 시간은 기본 10초와 남은 시간 중 짧은 쪽이다 — 메일함 목록부터', async () => { + const firstIds = ids('m', 3); + const fake = fakeClient([ + mailboxList(), + unread(firstIds, 5), + marked(firstIds), + unread([]), + ]); + // 둘째 호출(첫 찾기) 뒤 17초가 지났다 — 남은 시간 8초 + advanceClockAfterCall(fake, 2, 17_000); + + await expect(fake.provider.markMailboxRead(ARA, 'a')).resolves.toEqual({ + processed: 3, + remaining: 0, + }); + expect(fake.recorded.map((record) => record.options)).toEqual([ + { timeoutMs: 10_000 }, + { timeoutMs: 10_000 }, + { timeoutMs: 8_000 }, + { timeoutMs: 8_000 }, + ]); + }); + + it('바뀐 것도 거절도 없는 차례가 이어서 세 번이면 멈춘다 — 바꾼 것이 없으면 MAIL_001, 있으면 거기까지', async () => { + const vanished: JmapInvocation[] = [ + ['Email/set', { notUpdated: { m2: { type: 'notFound' } } }, 'u'], + ]; + const idle = fakeClient([ + mailboxList(), + unread(['m2']), + vanished, + unread(['m2']), + vanished, + unread(['m2']), + vanished, + ]); + expect(await codeOf(idle.provider.markMailboxRead(ARA, 'a'))).toBe( + 'MAIL_001', + ); + expect(idle.recorded).toHaveLength(7); + + const some = fakeClient([ + mailboxList(), + unread(['m1', 'm2']), + marked(['m1']), + unread(['m2']), + vanished, + unread(['m2']), + vanished, + unread(['m2']), + vanished, + ]); + await expect(some.provider.markMailboxRead(ARA, 'a')).resolves.toEqual({ + processed: 1, + remaining: 1, + }); + }); + + it('남은 시간 때문에 끊긴 읽음 표시 — 바꾼 것이 있으면 거기까지와 그때의 남은 수 (끊긴 요청은 엔진이 적용했을 수 있다 — 다음 요청이 새로 찾는다)', async () => { + const firstIds = ids('m', 500); + const fake = fakeClient([ + mailboxList(), + unread(firstIds, 900), + marked(firstIds), + unread(ids('n', 400), 400), + ]); + failCallWith( + fake, + 5, + 30_000, + new BusinessException('MAIL_001', { context: { reason: 'timeout' } }), + ); + + await expect(fake.provider.markMailboxRead(ARA, 'a')).resolves.toEqual({ + processed: 500, + remaining: 400, + }); + }); + }); + + describe('비우기 (emptyMailbox)', () => { + it('휴지통 — 확인한 상태 그대로일 때만, 휴지통에만 있으면 영구 삭제 · 다른 메일함에도 있으면 휴지통에서만 뺀다', async () => { + const { provider, recorded } = fakeClient([ + mailboxList(), + batch([ + only('t1', 'b'), + { id: 't2', mailboxIds: { b: true, a: true } }, + ]), + [['Email/set', { destroyed: ['t1'], updated: { t2: null } }, 's']], + batch([]), + ]); + + await expect(provider.emptyMailbox(ARA, 'b')).resolves.toEqual({ + processed: 2, + remaining: 0, + }); + expect(recorded[1].calls[0][1]).toMatchObject({ + filter: { inMailbox: 'b' }, + limit: 500, + }); + expect(argsOf(recorded[2])).toEqual({ + accountId: 'd', + ifInState: 's1', + destroy: ['t1'], + update: { t2: { 'mailboxIds/b': null } }, + }); + }); + + it('스팸메일함도 같은 규칙으로 비운다', async () => { + const { provider, recorded } = fakeClient([ + mailboxList(), + batch([only('j1', 'c')]), + [['Email/set', { destroyed: ['j1'] }, 's']], + batch([]), + ]); + + await expect(provider.emptyMailbox(ARA, 'c')).resolves.toEqual({ + processed: 1, + remaining: 0, + }); + expect(argsOf(recorded[2])).toEqual({ + accountId: 'd', + ifInState: 's1', + destroy: ['j1'], + }); + }); + + it('휴지통 · 스팸메일함이 아니면 VAL_001, 없는 메일함은 RES_001 — 메일을 읽지도 않는다', async () => { + for (const [id, code] of [ + ['a', 'VAL_001'], + ['d', 'VAL_001'], + ['u4', 'VAL_001'], + ['nope', 'RES_001'], + ] as const) { + const { provider, recorded } = fakeClient([mailboxList()]); + expect(await codeOf(provider.emptyMailbox(ARA, id))).toBe(code); + expect(recorded).toHaveLength(1); + } + }); + + it('어긋나면 다시 읽어 그때의 상태로 — 이어서 세 번 어긋나 하나도 못 지우면 MAIL_001 (지우지 않았다)', async () => { + const retried = fakeClient([ + mailboxList(), + batch([only('t1', 'b')], 's1'), + mismatch, + batch([only('t1', 'b')], 's2'), + [['Email/set', { destroyed: ['t1'] }, 's']], + batch([]), + ]); + await expect(retried.provider.emptyMailbox(ARA, 'b')).resolves.toEqual({ + processed: 1, + remaining: 0, + }); + expect(argsOf(retried.recorded[4])).toMatchObject({ ifInState: 's2' }); + + const contended = fakeClient([ + mailboxList(), + batch([only('t1', 'b')], 's1'), + mismatch, + batch([only('t1', 'b')], 's2'), + mismatch, + batch([only('t1', 'b')], 's3'), + mismatch, + ]); + expect(await codeOf(contended.provider.emptyMailbox(ARA, 'b'))).toBe( + 'MAIL_001', + ); + }); + + it('지운 뒤에 계속 어긋나면 거기까지 지운 수와 남은 수를 돌려준다', async () => { + const { provider } = fakeClient([ + mailboxList(), + batch([only('t1', 'b'), only('t2', 'b')], 's1', 3), + [['Email/set', { destroyed: ['t1', 't2'] }, 's']], + batch([only('t3', 'b')], 's2'), + mismatch, + batch([only('t3', 'b')], 's3'), + mismatch, + batch([only('t3', 'b')], 's4'), + mismatch, + ]); + + await expect(provider.emptyMailbox(ARA, 'b')).resolves.toEqual({ + processed: 2, + remaining: 1, + }); + }); + + it('상태를 모르면 조건 없이 지우지 않는다 (MAIL_001) · 시간이 모자라면 지운 만큼과 남은 수', async () => { + const unknownState = fakeClient([ + mailboxList(), + batch([only('t1', 'b')], null), + ]); + expect(await codeOf(unknownState.provider.emptyMailbox(ARA, 'b'))).toBe( + 'MAIL_001', + ); + expect(unknownState.recorded).toHaveLength(2); + + const first = ids('t', 500).map((id) => only(id, 'b')); + const slow = fakeClient([ + mailboxList(), + batch(first, 's1', 501), + [['Email/set', { destroyed: first.map(({ id }) => id) }, 's']], + ]); + advanceClockAfterCall(slow, 3, 21_000); + await expect(slow.provider.emptyMailbox(ARA, 'b')).resolves.toEqual({ + processed: 500, + remaining: 1, + }); + }); + + it('엔진이 모두 거절하면 멈춘다 — 지운 것이 없으면 그 거절의 코드, 지운 것이 있으면 거기까지와 남은 수', async () => { + const refused = (emailId: string): JmapInvocation[] => [ + [ + 'Email/set', + { notDestroyed: { [emailId]: { type: 'forbidden' } } }, + 's', + ], + ]; + + const nothing = fakeClient([ + mailboxList(), + batch([only('t1', 'b')]), + refused('t1'), + ]); + expect(await codeOf(nothing.provider.emptyMailbox(ARA, 'b'))).toBe( + 'AUTH_003', + ); + expect(warnSpy).toHaveBeenCalledWith( + expect.stringContaining('jmapType=forbidden'), + ); + + const some = fakeClient([ + mailboxList(), + batch([only('t1', 'b'), only('t2', 'b')], 's1', 2), + [ + [ + 'Email/set', + { + destroyed: ['t1'], + notDestroyed: { t2: { type: 'forbidden' } }, + }, + 's', + ], + ], + batch([only('t2', 'b')], 's2', 1), + refused('t2'), + ]); + await expect(some.provider.emptyMailbox(ARA, 'b')).resolves.toEqual({ + processed: 1, + remaining: 1, + }); + expect(some.recorded).toHaveLength(5); + }); + + it('바뀐 것도 거절도 없는 차례가 이어서 세 번이면 멈춘다 — 지운 것이 없으면 MAIL_001', async () => { + const vanished: JmapInvocation[] = [ + ['Email/set', { notDestroyed: { t1: { type: 'notFound' } } }, 's'], + ]; + const { provider, recorded } = fakeClient([ + mailboxList(), + batch([only('t1', 'b')], 's1'), + vanished, + batch([only('t1', 'b')], 's2'), + vanished, + batch([only('t1', 'b')], 's3'), + vanished, + ]); + + expect(await codeOf(provider.emptyMailbox(ARA, 'b'))).toBe('MAIL_001'); + expect(recorded).toHaveLength(7); + }); + + it('남은 시간 때문에 끊긴 지우기 — 지운 것이 있으면 거기까지로 답한다 (끊긴 지우기는 엔진이 적용했을 수 있다 — 다음 요청이 새로 읽는다)', async () => { + const fake = fakeClient([ + mailboxList(), + batch([only('t1', 'b')], 's1', 3), + [['Email/set', { destroyed: ['t1'] }, 's']], + batch([only('t2', 'b'), only('t3', 'b')], 's2', 2), + ]); + failCallWith( + fake, + 5, + 30_000, + new BusinessException('MAIL_001', { context: { reason: 'timeout' } }), + ); + + await expect(fake.provider.emptyMailbox(ARA, 'b')).resolves.toEqual({ + processed: 1, + remaining: 2, + }); + }); + }); + }); + + describe('부재중 자동 응답 (getVacation · setVacation)', () => { + const stored = { + id: 'singleton', + isEnabled: true, + fromDate: '2026-11-02T15:00:00Z', + toDate: '2026-11-07T15:00:00Z', + subject: '부재중입니다', + textBody: '11월 7일까지 휴가입니다.', + htmlBody: null, + }; + + it('VacationResponse 를 그 기능을 선언한 요청으로 읽어 우리 모양으로 — 날짜는 ISO 8601 UTC', async () => { + const { provider, recorded } = fakeClient([ + [['VacationResponse/get', { list: [stored] }, 'v']], + ]); + + await expect(provider.getVacation(ARA)).resolves.toEqual({ + isEnabled: true, + fromDate: '2026-11-02T15:00:00.000Z', + toDate: '2026-11-07T15:00:00.000Z', + subject: '부재중입니다', + textBody: '11월 7일까지 휴가입니다.', + }); + expect(recorded[0].calls).toEqual([ + ['VacationResponse/get', { accountId: 'd', ids: ['singleton'] }, 'v'], + ]); + expect(recorded[0].options).toEqual({ + capabilities: ['urn:ietf:params:jmap:vacationresponse'], + }); + }); + + it('한 번도 설정하지 않은 계정(엔진 값이 null)은 끈 상태 · 빈 제목과 내용', async () => { + const blank = { + id: 'singleton', + isEnabled: false, + fromDate: null, + toDate: null, + subject: null, + textBody: null, + htmlBody: null, + }; + for (const list of [[blank], []]) { + const { provider } = fakeClient([ + [['VacationResponse/get', { list }, 'v']], + ]); + await expect(provider.getVacation(ARA)).resolves.toEqual({ + isEnabled: false, + fromDate: null, + toDate: null, + subject: '', + textBody: '', + }); + } + }); + + it('저장은 통째로 바꾼다 — 서식 본문은 비우고, 빈 제목·내용은 null, 날짜는 소수 초 없는 UTCDate. 같은 요청에서 다시 읽은 값을 돌려준다', async () => { + const { provider, recorded } = fakeClient([ + [ + ['VacationResponse/set', { updated: { singleton: null } }, 's'], + [ + 'VacationResponse/get', + { + list: [ + { + ...stored, + isEnabled: false, + subject: null, + textBody: null, + }, + ], + }, + 'v', + ], + ], + ]); + + await expect( + provider.setVacation(ARA, { + isEnabled: false, + fromDate: '2026-11-02T15:00:00.000Z', + toDate: '2026-11-07T15:00:00.000Z', + subject: '', + textBody: '', + }), + ).resolves.toEqual({ + isEnabled: false, + fromDate: '2026-11-02T15:00:00.000Z', + toDate: '2026-11-07T15:00:00.000Z', + subject: '', + textBody: '', + }); + expect(recorded[0].calls).toEqual([ + [ + 'VacationResponse/set', + { + accountId: 'd', + update: { + singleton: { + isEnabled: false, + fromDate: '2026-11-02T15:00:00Z', + toDate: '2026-11-07T15:00:00Z', + subject: null, + textBody: null, + htmlBody: null, + }, + }, + }, + 's', + ], + ['VacationResponse/get', { accountId: 'd', ids: ['singleton'] }, 'v'], + ]); + expect(recorded[0].options).toEqual({ + capabilities: ['urn:ietf:params:jmap:vacationresponse'], + }); + }); + + it('날짜 없이 켜면 날짜는 null 그대로 — 엔진이 거절하면 그 코드(형식 거절 VAL_001), 바꿨다는 답이 없으면 MAIL_001', async () => { + const { provider, recorded } = fakeClient([ + [ + ['VacationResponse/set', { updated: { singleton: null } }, 's'], + ['VacationResponse/get', { list: [stored] }, 'v'], + ], + ]); + await provider.setVacation(ARA, { + isEnabled: true, + fromDate: null, + toDate: null, + subject: '부재중입니다', + textBody: '휴가입니다.', + }); + expect(argsOf(recorded[0])).toMatchObject({ + update: { + singleton: { + isEnabled: true, + fromDate: null, + toDate: null, + subject: '부재중입니다', + textBody: '휴가입니다.', + }, + }, + }); + + const vacation = { + isEnabled: true, + fromDate: null, + toDate: null, + subject: '부재중', + textBody: '휴가', + }; + const rejected = fakeClient([ + [ + [ + 'VacationResponse/set', + { + notUpdated: { + singleton: { + type: 'invalidProperties', + properties: ['fromDate'], + }, + }, + }, + 's', + ], + ['VacationResponse/get', { list: [stored] }, 'v'], + ], + ]); + expect(await codeOf(rejected.provider.setVacation(ARA, vacation))).toBe( + 'VAL_001', + ); + + const silent = fakeClient([ + [ + ['VacationResponse/set', {}, 's'], + ['VacationResponse/get', { list: [stored] }, 'v'], + ], + ]); + expect(await codeOf(silent.provider.setVacation(ARA, vacation))).toBe( + 'MAIL_001', + ); + }); + }); + + describe('원문 (openMessageSource)', () => { + it('메일의 원본 blob 을 message/rfc822 로 내려받아 흘려보낸다 — 제목을 함께 돌려준다', async () => { + const { provider, recorded, client } = fakeClient([ + [ + [ + 'Email/get', + { list: [{ id: 'm1', blobId: 'raw-m1', subject: '견적서' }] }, + 'g', + ], + ], + ]); + const stream = Readable.from([Buffer.from('From: a\r\n\r\nbody')]); + client.download.mockResolvedValue({ stream, length: 15 }); + + await expect(provider.openMessageSource(ARA, 'm1')).resolves.toEqual({ + stream, + subject: '견적서', + size: 15, + }); + expect(recorded[0].calls).toEqual([ + [ + 'Email/get', + { + accountId: 'd', + ids: ['m1'], + properties: ['id', 'blobId', 'subject'], + }, + 'g', + ], + ]); + expect(recorded[0].options).toEqual({ invalidRequestCode: 'RES_001' }); + expect(client.download).toHaveBeenCalledWith( + ARA, + 'raw-m1', + 'message.eml', + 'message/rfc822', + ); + }); + + it('제목이 없으면 빈 문자열 · 없는 메일은 RES_001 · 엔진이 원본 blob 을 알려 주지 않으면 MAIL_001 (내려받지 않는다)', async () => { + const untitled = fakeClient([ + [['Email/get', { list: [{ id: 'm1', blobId: 'raw-m1' }] }, 'g']], + ]); + untitled.client.download.mockResolvedValue({ + stream: Readable.from([]), + length: null, + }); + await expect( + untitled.provider.openMessageSource(ARA, 'm1'), + ).resolves.toMatchObject({ subject: '', size: null }); + + const missing = fakeClient([ + [['Email/get', { list: [], notFound: ['m1'] }, 'g']], + ]); + expect(await codeOf(missing.provider.openMessageSource(ARA, 'm1'))).toBe( + 'RES_001', + ); + expect(missing.client.download).not.toHaveBeenCalled(); + + const noBlob = fakeClient([ + [['Email/get', { list: [{ id: 'm1' }] }, 'g']], + ]); + expect(await codeOf(noBlob.provider.openMessageSource(ARA, 'm1'))).toBe( + 'MAIL_001', + ); + expect(noBlob.client.download).not.toHaveBeenCalled(); + }); + }); + + it('비밀번호 바꾸기는 클라이언트의 본인 자격 변경에 그대로 맡긴다 (대리 접근 세션을 쓰지 않는다)', async () => { + const { provider, client } = fakeClient([]); + client.changePassword.mockResolvedValue('changed'); + + await expect( + provider.changePassword(ARA, 'current-pw', 'new-password-1'), + ).resolves.toBe('changed'); + expect(client.changePassword).toHaveBeenCalledWith( + ARA, + 'current-pw', + 'new-password-1', + ); + expect(client.getSession).not.toHaveBeenCalled(); + expect(client.call).not.toHaveBeenCalled(); + }); + describe('첨부 업로드 (uploadAttachment)', () => { let dir: string; diff --git a/backend/src/modules/mail/jmap/jmap-mail.provider.ts b/backend/src/modules/mail/jmap/jmap-mail.provider.ts index fafee56..c077773 100644 --- a/backend/src/modules/mail/jmap/jmap-mail.provider.ts +++ b/backend/src/modules/mail/jmap/jmap-mail.provider.ts @@ -20,18 +20,24 @@ import type { MailAddress, MailAttachment, Mailbox, + MailboxBulkResult, + MailboxCreate, + MailboxDeletion, MailState, MailUsage, + MailVacation, MessageDeletionResult, MessageDetailSource, MessageListQuery, MessagePage, + MessageSource, MessagesPatchResult, MessageSummary, MessageUpdate, OutgoingAttachment, OutgoingInlineImage, OutgoingMessage, + PasswordChangeOutcome, SavedDraft, SentMessageRecord, UploadedFile, @@ -40,8 +46,10 @@ import { traceTag } from '@/shared/logger/request-context'; import { maskEmail } from '@/shared/utils/mask'; import { JMAP_INLINE_UPLOAD_TIMEOUT_MS, + JMAP_REQUEST_TIMEOUT_MS, JMAP_SEND_TIMEOUT_MS, JmapClient, + type JmapCallOptions, type JmapSession, } from './jmap.client'; import { decodeCursor, encodeCursor } from './jmap-cursor'; @@ -62,10 +70,12 @@ import { selectAccountQuota, toAddresses, toDetailSource, + toJmapVacation, toMailbox, toSummary, toUsage, toUtcIso, + toVacation, } from './jmap.mapper'; import type { JmapBlob, @@ -80,6 +90,7 @@ import type { JmapQueryResult, JmapQuota, JmapSetResult, + JmapVacationResponse, } from './jmap.types'; import { searchTextConditions, withSearchConditions } from './search-terms'; @@ -145,6 +156,36 @@ const STATE_MISMATCH = 'stateMismatch'; /** SetError — 그 대상이 없다 (그사이 지워짐) */ const NOT_FOUND = 'notFound'; +/** SetError — 같은 상위 안에 같은 이름의 메일함이 있다 (2026-10-28 개발 엔진 확인) */ +const ALREADY_EXISTS = 'alreadyExists'; +/** Mailbox/set destroy 의 SetError (RFC 8621 §2.5) — 메일이 남았다 · 하위 메일함이 있다 */ +const MAILBOX_HAS_EMAIL = 'mailboxHasEmail'; +const MAILBOX_HAS_CHILD = 'mailboxHasChild'; + +/** 메일함을 만들 수 있는 상위의 역할 — 그 밖에 역할이 없는 메일함(내 메일함) 아래와 맨 위에 만들 수 있다 (계약 2절 "메일함 관리") */ +const PARENT_ALLOWED_ROLES: ReadonlySet = new Set(['inbox', 'archive']); +/** 비울 수 있는 메일함의 역할 — 휴지통 · 스팸메일함 */ +const EMPTYABLE_ROLES: ReadonlySet = new Set(['trash', 'junk']); + +/** + * 오래 걸리는 처리(메일함 전체 읽음 · 비우기 · 지우기) 한 요청의 시간 (계약 2절 "메일함 관리") — 실제 상한이다 (BulkRun). + * 이 안에서 끝내고 남은 수(remaining)를 돌려준다. 클라이언트가 남은 것이 없을 때까지 같은 요청을 다시 보낸다. + */ +const BULK_REQUEST_BUDGET_MS = 25_000; +/** 남은 시간이 이보다 적으면 다음 차례를 시작하지 않는다 — 한 차례(엔진 호출 두 번)가 끝까지 시간 안에 들게 */ +const BULK_ROUND_RESERVE_MS = 5_000; +/** 한 차례에 다루는 메일 수 — 엔진의 한 번 처리 한도(maxObjectsInGet · maxObjectsInSet 500) */ +const BULK_BATCH_SIZE = 500; + +/** 부재중 자동 응답 기능 (RFC 8621 §8) — 이 호출에만 선언한다 (모르는 기능이 using 에 있으면 요청 전체가 거부된다) */ +const VACATION_CAPABILITY = 'urn:ietf:params:jmap:vacationresponse'; +/** 계정마다 하나인 부재중 자동 응답의 id (RFC 8621 §8) */ +const VACATION_ID = 'singleton'; + +/** 원문 내려받기 때 엔진에 요청하는 형식·이름 — 클라이언트에게 주는 이름·헤더는 서비스·컨트롤러가 정한다 */ +const SOURCE_TYPE = 'message/rfc822'; +const SOURCE_DOWNLOAD_NAME = 'message.eml'; + /** 메일함을 정하지 않은 검색에서 빼는 메일함 역할 — 휴지통 · 스팸메일함 (계약 2절 "검색") */ const SEARCH_EXCLUDED_ROLES: ReadonlySet = new Set(['trash', 'junk']); @@ -267,7 +308,10 @@ function isUserDraft(email: JmapEmail, draftsId: string): boolean { ); } -/** 읽음 · 중요 표시 · 이동을 JMAP 패치로 — 한 통(updateMessage)과 여러 통(updateMessages)이 같은 패치를 쓴다 */ +/** + * 읽음 · 중요 표시 · 이동 · 스팸 표시를 JMAP 패치로 — 한 통(updateMessage)과 여러 통(updateMessages)이 같은 패치를 쓴다. + * 스팸 표시는 `$junk` · `$notjunk` 를 서로 반대로 둔다 (RFC 8621 4.1.1) — 메일함 이동과 같은 패치에 함께 든다 + */ function toEmailPatch(update: MessageUpdate): Record { const patch: Record = {}; if (update.isRead !== undefined) { @@ -276,6 +320,10 @@ function toEmailPatch(update: MessageUpdate): Record { if (update.isFlagged !== undefined) { patch['keywords/$flagged'] = update.isFlagged ? true : null; } + if (update.isJunk !== undefined) { + patch['keywords/$junk'] = update.isJunk ? true : null; + patch['keywords/$notjunk'] = update.isJunk ? null : true; + } if (update.mailboxId !== undefined) { // 이동 = 이 메일함 하나에만 속하게 한다 patch.mailboxIds = { [update.mailboxId]: true }; @@ -283,50 +331,219 @@ function toEmailPatch(update: MessageUpdate): Record { return patch; } -/** 휴지통에서 지우는 방법 — 영구 삭제 · 휴지통에서만 빼기 */ -type TrashRemoval = 'destroy' | 'untrash'; +/** 메일함에서 메일을 지우는 방법 — 영구 삭제 · 그 메일함에서만 빼기 */ +type MailboxRemoval = 'destroy' | 'detach'; /** - * 휴지통의 메일을 어떻게 지울지 — 휴지통에만 있으면 영구 삭제, 휴지통과 다른 메일함에 함께 있으면 휴지통에서만 뺀다 - * (영구 삭제하면 다른 메일함의 사본까지 사라진다). 휴지통에 없으면 null — 지우지 않는다. - * 한 통 삭제(purgeFromTrash)와 여러 통 영구 삭제(deleteFromTrash)가 같은 규칙을 쓴다. + * 메일함(휴지통 · 스팸메일함)의 메일을 어떻게 지울지 — 그 메일함에만 있으면 영구 삭제, 다른 메일함에도 함께 있으면 그 메일함에서만 뺀다 + * (영구 삭제하면 다른 메일함의 사본까지 사라진다). 그 메일함에 없으면 null — 지우지 않는다. + * 한 통 삭제(purgeFromTrash) · 여러 통 영구 삭제(deleteFromTrash) · 비우기(emptyMailbox)가 같은 규칙을 쓴다. * @param mailboxIds 메일이 들어 있는 메일함 (inMailboxes) */ -function trashRemoval( +function removalFrom( mailboxIds: readonly string[], - trashId: string, -): TrashRemoval | null { - if (!mailboxIds.includes(trashId)) return null; - return mailboxIds.every((mailboxId) => mailboxId === trashId) + mailboxId: string, +): MailboxRemoval | null { + if (!mailboxIds.includes(mailboxId)) return null; + return mailboxIds.every((candidate) => candidate === mailboxId) ? 'destroy' - : 'untrash'; + : 'detach'; } /** - * 확인한 상태 그대로일 때만(ifInState) 휴지통에서 지우는 Email/set 인자 — 영구 삭제와 휴지통에서 빼기를 한 번에. - * 상태가 어긋나면 엔진이 `stateMismatch` 로 거절하고 아무것도 하지 않는다 (2026-10-27 개발 엔진 확인). + * 확인한 상태 그대로일 때만(ifInState) 하는 Email/set 의 바꿀 것 — 영구 삭제 · 고치기만 (만들기는 넣을 수 없다). + * ifInState 는 conditionalEmailSet 의 필수 인자라, 이 경로로는 조건 없는 지우기가 나갈 수 없다 + * (조건 없는 지우기는 방금 만든 메일의 되돌림 경로 — discardQuietly · discardDraft — 에만 있다) */ -function trashRemovalSet( - accountId: string, - ifInState: string, - trashId: string, +interface EmailSetChanges { + readonly destroy?: readonly string[]; + readonly update?: Readonly>>; +} + +/** 메일함에서 지우는 바꿀 것 — 영구 삭제와 그 메일함에서 빼기 (removalFrom 의 두 갈래) */ +function removalChanges( + mailboxId: string, destroy: readonly string[], - untrash: readonly string[], -): Record { + detach: readonly string[], +): EmailSetChanges { return { - accountId, - ifInState, - ...(destroy.length > 0 ? { destroy: [...destroy] } : {}), - ...(untrash.length > 0 + ...(destroy.length > 0 ? { destroy } : {}), + ...(detach.length > 0 ? { update: Object.fromEntries( - untrash.map((id) => [id, { [`mailboxIds/${trashId}`]: null }]), + detach.map((id) => [id, { [`mailboxIds/${mailboxId}`]: null }]), ), } : {}), }; } +/** + * 확인한 상태 그대로일 때만(ifInState) 메일함에서 지우는 Email/set 인자 — 영구 삭제와 그 메일함에서 빼기를 한 번에. + * 상태가 어긋나면 엔진이 `stateMismatch` 로 거절하고 아무것도 하지 않는다 (2026-10-27 개발 엔진 확인). + */ +function removalSet( + accountId: string, + ifInState: string, + mailboxId: string, + destroy: readonly string[], + detach: readonly string[], +): Record { + const { destroy: destroyIds, update } = removalChanges( + mailboxId, + destroy, + detach, + ); + return { + accountId, + ifInState, + ...(destroyIds ? { destroy: [...destroyIds] } : {}), + ...(update ? { update } : {}), + }; +} + +/** 엔진이 정한 메일함 역할(소문자) — 없으면 null. 우리가 모르는 역할도 역할이다 (기본 메일함으로 다룬다) */ +function engineRole(mailbox: JmapMailbox): string | null { + const role = mailbox.role?.trim().toLowerCase(); + return role ? role : null; +} + +/** + * 경로의 메일함 id 로 메일함을 찾는다 — 엔진에 그 id 를 보내지 않고 목록에서 찾는다 + * (엔진이 해석하지 못하는 형식의 id 도 없는 메일함과 같은 RES_001 이 된다) + */ +function findMailbox( + mailboxes: readonly JmapMailbox[], + id: string, +): JmapMailbox { + const mailbox = mailboxes.find((candidate) => candidate.id === id); + if (!mailbox) { + throw new BusinessException('RES_001', { + context: { reason: 'mailbox-not-found' }, + }); + } + return mailbox; +} + +/** 역할이 있는 메일함(기본 메일함)은 이름을 바꾸거나 지울 수 없다 — 400 VAL_001 (계약 2절 "메일함 관리") */ +function assertUserMailbox(mailbox: JmapMailbox, action: string): void { + const role = engineRole(mailbox); + if (role !== null) { + throw new BusinessException('VAL_001', { + context: { reason: 'role-mailbox', role, action }, + }); + } +} + +/** + * 메일함 만들기 · 이름 바꾸기를 엔진이 거절한 것 — 같은 상위 안에 같은 이름(alreadyExists, 확인함)은 MAIL_009, + * 상위가 그사이 없어졌으면(parentId 속성 거절) RES_001, 그 밖은 오류 표대로 (jmap.errors.ts) + */ +function mailboxSetFailure( + error: JmapError, + method: string, +): BusinessException { + if (error.type === ALREADY_EXISTS) { + return new BusinessException('MAIL_009', { + context: { reason: 'mailbox-name-taken', method }, + }); + } + if ( + error.type === 'invalidProperties' && + (error.properties ?? []).includes('parentId') + ) { + return new BusinessException('RES_001', { + context: { reason: 'mailbox-parent-gone', method }, + }); + } + return jmapFailure(error, { method }); +} + +/** + * 오래 걸리는 처리(메일함 전체 읽음 · 비우기 · 지우기)의 한 요청 — 시간 · 진행 · 멈춘 차례 (계약 2절 "메일함 관리"). + * - 시간: 메일함 목록을 읽기 **전부터** BULK_REQUEST_BUDGET_MS 가 실제 상한이다. 다음 차례는 남은 시간이 BULK_ROUND_RESERVE_MS 보다 + * 많을 때만 시작하고, 엔진 호출마다 제한 시간을 기본 제한 시간(JMAP_REQUEST_TIMEOUT_MS)과 남은 시간 중 짧은 쪽으로 준다 — + * 엔진이 느려도 한 요청이 앞단 nginx 의 시간 제한에 걸리지 않게 + * - 멈춘 차례: 아무것도 바뀌지 않은 차례(어긋남 · 바뀐 것도 거절도 없음 · 비운 메일함을 지우는 사이 메일이 들어옴)가 + * 이어서 CONDITIONAL_DESTROY_ATTEMPTS 번이면 멈춘다 — 엔진에 끝없이 묻지 않게. 진행한 차례가 있으면 처음부터 다시 센다 + * - 일찍 멈췄을 때(멈춘 차례 · 남은 시간 · 남은 시간 때문에 끊긴 호출) 이 요청에서 처리한 것이 있으면 거기까지로 답하고, 없으면 MAIL_001 + */ +class BulkRun { + private readonly deadline = Date.now() + BULK_REQUEST_BUDGET_MS; + private stalls = 0; + private lastStall: string | null = null; + /** 이번 요청에서 처리한 메일 수 */ + processed = 0; + + /** 다음 차례를 시작해도 되는가 */ + canStartRound(): boolean { + return this.left() > BULK_ROUND_RESERVE_MS; + } + + /** 이번 엔진 호출의 제한 시간 — 기본 제한 시간과 남은 시간 중 짧은 쪽 */ + callOptions(): JmapCallOptions { + return { + timeoutMs: Math.max(1, Math.min(JMAP_REQUEST_TIMEOUT_MS, this.left())), + }; + } + + /** 처리했다 — 멈춘 차례를 처음부터 다시 센다 */ + progress(count: number): void { + if (count <= 0) return; + this.processed += count; + this.stalls = 0; + } + + /** 아무것도 바뀌지 않은 차례 — 이어서 CONDITIONAL_DESTROY_ATTEMPTS 번째면 true (멈춘다) */ + stalled(why: string): boolean { + this.stalls += 1; + this.lastStall = why; + return this.stalls >= CONDITIONAL_DESTROY_ATTEMPTS; + } + + /** + * 한 요청의 시간을 다 써서 엔진 호출이 끊겼고, 이 요청에서 처리한 것이 있는가 — 그러면 거기까지로 답한다. + * 끊긴 Email/set 은 엔진이 적용했을 수 있다 — 남은 수는 다음 요청이 새로 읽어 맞춘다 (조건부 규칙 그대로) + */ + cutOffAfterProgress(error: unknown): boolean { + return ( + this.processed > 0 && + this.left() <= 0 && + error instanceof BusinessException && + error.code === 'MAIL_001' + ); + } + + /** 끝까지 하지 못하고 멈췄는데 이 요청에서 처리한 것이 없으면 MAIL_001 — 클라이언트가 같은 요청을 끝없이 다시 보내지 않게 */ + assertProgress(operation: string): void { + if (this.processed > 0) return; + throw new BusinessException('MAIL_001', { + context: { reason: `${operation}-no-progress`, stalled: this.lastStall }, + }); + } + + private left(): number { + return this.deadline - Date.now(); + } +} + +/** 조회의 전체 수 — 엔진이 세어 주지 않으면 받은 수로 (가득 찼으면 더 있는 것으로 하나 더) */ +function queryTotal(result: JmapQueryResult, received: number): number { + if (typeof result.total === 'number' && result.total >= received) { + return result.total; + } + return received === BULK_BATCH_SIZE ? received + 1 : received; +} + +/** 메일마다의 거절 가운데 "그사이 없어짐(notFound)" 이 아닌 첫 오류 — 없으면 undefined */ +function firstRefusal( + ...errors: (Record | undefined)[] +): JmapError | undefined { + return errors + .flatMap((record) => Object.values(record ?? {})) + .find((error) => error?.type !== NOT_FOUND); +} + /** 엔진이 알려 준 상태 문자열인가 (비었거나 없으면 아니다) */ function isState(value: unknown): value is string { return typeof value === 'string' && value !== ''; @@ -439,6 +656,15 @@ export class JmapMailProvider implements MailProvider { return this.jmap.verifyCredentials(email, password); } + /** 본인 자격으로만 바꾼다 — 게이트웨이 대리 접근을 쓰지 않는다 (jmap.client.ts 의 changePassword) */ + changePassword( + email: string, + currentPassword: string, + newPassword: string, + ): Promise { + return this.jmap.changePassword(email, currentPassword, newPassword); + } + async getProfile( user: string, options: { fresh?: boolean } = {}, @@ -463,19 +689,462 @@ export class JmapMailProvider implements MailProvider { } async listMailboxes(user: string): Promise { + return (await this.loadMailboxes(user)).map(toMailbox); + } + + /** + * 메일함 만들기 — 메일함 목록으로 상위를 확인하고(요청 1), 만들고 다시 읽는다(요청 2 — Mailbox/set 다음의 Mailbox/get 이 만든 것을 본다). + * - 상위가 없으면 RES_001, 받은편지함 · 보관함 · 역할 없는 메일함이 아니면 VAL_001 + * - 같은 상위 안에 같은 이름이면 엔진이 alreadyExists 로 거절한다 → MAIL_009 + * - 구독(isSubscribed)으로 만든다 — 구독한 폴더만 보이는 PC 메일 프로그램(IMAP)에도 보이게 + */ + async createMailbox(user: string, input: MailboxCreate): Promise { + const mailboxes = await this.loadMailboxes(user); + if (input.parentId !== null) { + const role = engineRole(findMailbox(mailboxes, input.parentId)); + if (role !== null && !PARENT_ALLOWED_ROLES.has(role)) { + throw new BusinessException('VAL_001', { + context: { reason: 'mailbox-parent-not-allowed', parentRole: role }, + }); + } + } + const responses = await this.jmap.call(user, (accountId) => [ + [ + 'Mailbox/set', + { + accountId, + create: { + mailbox: { + name: input.name, + parentId: input.parentId, + isSubscribed: true, + }, + }, + }, + 'c', + ], [ 'Mailbox/get', { accountId, ids: null, properties: MAILBOX_PROPERTIES }, 'm', ], ]); + const set = expectResult(responses, 'c', 'Mailbox/set'); + const failure = firstSetError(set.notCreated); + if (failure) throw mailboxSetFailure(failure, 'Mailbox/set'); + + const id = set.created?.mailbox?.id; + if (!id) { + throw new BusinessException('MAIL_001', { + context: { reason: 'mailbox-not-created' }, + }); + } const { list = [] } = expectResult>( responses, 'm', 'Mailbox/get', ); - return list.map(toMailbox); + // 다시 읽은 목록에 없으면(있을 수 없는 경우) 보낸 값으로 답한다 — 만들기는 성공했다 + return toMailbox( + list.find((mailbox) => mailbox.id === id) ?? { + id, + name: input.name, + parentId: input.parentId, + }, + ); + } + + /** + * 메일함 이름 바꾸기 — 역할이 있는 메일함(기본 메일함)은 VAL_001, 없으면 RES_001, 같은 이름이 있으면 MAIL_009. + * 지금 이름과 같으면 엔진에 묻지 않고 그대로 돌려준다 (자기 이름과 겹친다고 거절되지 않게) + */ + async renameMailbox( + user: string, + id: string, + name: string, + ): Promise { + const mailbox = findMailbox(await this.loadMailboxes(user), id); + assertUserMailbox(mailbox, 'rename'); + if (mailbox.name === name) return toMailbox(mailbox); + + const responses = await this.jmap.call(user, (accountId) => [ + ['Mailbox/set', { accountId, update: { [mailbox.id]: { name } } }, 'u'], + [ + 'Mailbox/get', + { accountId, ids: [mailbox.id], properties: MAILBOX_PROPERTIES }, + 'm', + ], + ]); + const set = expectResult(responses, 'u', 'Mailbox/set'); + const failure = firstSetError(set.notUpdated); + if (failure) throw mailboxSetFailure(failure, 'Mailbox/set'); + + const [renamed] = + expectResult>(responses, 'm', 'Mailbox/get') + .list ?? []; + if (!renamed) { + throw new BusinessException('RES_001', { + context: { reason: 'mailbox-gone' }, + }); + } + return toMailbox(renamed); + } + + /** + * 메일함 지우기 (계약 2절 "메일함 관리") — 메일을 영구 삭제하지 않는다. 한 요청의 시간 · 멈춘 차례는 BulkRun. + * 1) 메일함 목록으로 확인 — 없으면 RES_001, 역할이 있으면 · 하위 메일함이 있으면 VAL_001 + * 2) 차례마다 메일을 BULK_BATCH_SIZE 통씩 읽어(id · 들어 있는 메일함, 그때의 상태) 확인한 상태 그대로일 때만(ifInState) 옮긴다 — + * 이 메일함에만 있으면 휴지통으로(이 메일함에서 빼고 휴지통에 넣는다), 다른 메일함에도 있으면 이 메일함에서만 뺀다 + * 3) 비었으면 빈 메일함을 지운다(`onDestroyRemoveEmails: false` — 메일을 지우는 옵션은 쓰지 않는다). + * 그사이 메일이 들어와 엔진이 mailboxHasEmail 로 거절하면 다시 옮긴다 + * 다 옮기지 못하면 deleted: false 와 진행 수(processed — 이 메일함에서 빠진 수)·휴지통으로 옮긴 수·남은 수 (클라이언트가 다시 보낸다). + * 옮길 메일이 있는데 휴지통이 없으면 MAIL_001 (옮길 곳 없이 지우지 않는다) + */ + async deleteMailbox(user: string, id: string): Promise { + const run = new BulkRun(); + const mailboxes = await this.loadMailboxes(user, run.callOptions()); + const mailbox = findMailbox(mailboxes, id); + assertUserMailbox(mailbox, 'delete'); + if (mailboxes.some((candidate) => candidate.parentId === mailbox.id)) { + throw new BusinessException('VAL_001', { + context: { reason: 'mailbox-has-children' }, + }); + } + const trashId = roleMailboxId(mailboxes, 'trash'); + const target = `mailboxIds/${mailbox.id}`; + let movedToTrash = 0; + let remaining = mailbox.totalEmails ?? 0; + const result = (deleted: boolean): MailboxDeletion => ({ + deleted, + processed: run.processed, + movedToTrash, + remaining: deleted ? 0 : remaining, + }); + + try { + while (run.canStartRound()) { + const batch = await this.readMailboxBatch( + user, + mailbox.id, + run.callOptions(), + ); + remaining = batch.total; + if (batch.emails.length === 0) { + if ( + await this.destroyEmptyMailbox(user, mailbox.id, run.callOptions()) + ) { + return result(true); + } + // 비운 사이 메일이 들어왔다 — 다시 옮긴다 (읽은 목록이 비어 있는데도 이어서 거절되면 엔진 이상 — 멈춘다) + if (run.stalled('mailbox-has-email')) break; + continue; + } + if (!trashId) { + throw new BusinessException('MAIL_001', { + context: { reason: 'trash-mailbox-missing' }, + }); + } + // 상태를 모르면 조건 없이 옮기지 않는다 + if (batch.state === null) { + throw new BusinessException('MAIL_001', { + context: { reason: 'mailbox-state-unknown' }, + }); + } + + const toTrash: string[] = []; + const detach: string[] = []; + for (const email of batch.emails) { + if (removalFrom(inMailboxes(email), mailbox.id) === 'destroy') { + toTrash.push(email.id); + } else { + detach.push(email.id); + } + } + const set = await this.conditionalEmailSet( + user, + batch.state, + { + update: { + ...Object.fromEntries( + toTrash.map((emailId) => [ + emailId, + { [target]: null, [`mailboxIds/${trashId}`]: true }, + ]), + ), + ...Object.fromEntries( + detach.map((emailId) => [emailId, { [target]: null }]), + ), + }, + }, + run.callOptions(), + ); + if (set === null) { + if (run.stalled('state-mismatch')) break; + continue; + } + + const updated = new Set(Object.keys(set.updated ?? {})); + const moved = toTrash.filter((emailId) => updated.has(emailId)).length; + const removed = + moved + detach.filter((emailId) => updated.has(emailId)).length; + this.warnRefusals(user, set.notUpdated, '지우는 메일함에서 옮기지'); + movedToTrash += moved; + remaining = Math.max(0, batch.total - removed); + if (removed > 0) { + run.progress(removed); + continue; + } + + const refusal = firstRefusal(set.notUpdated); + if (refusal) { + if (run.processed > 0) break; + throw jmapFailure(refusal, { method: 'Email/set' }); + } + // 바뀐 것도 거절도 없다 (모두 그사이 없어짐 등) — 다시 읽는다 + if (run.stalled('no-change')) break; + } + } catch (error) { + if (!run.cutOffAfterProgress(error)) throw error; + } + run.assertProgress('mailbox-delete'); + return result(false); + } + + /** + * 메일함 전체 읽음 — 안 읽은 메일(`$seen` 없음)을 BULK_BATCH_SIZE 통씩 찾아 읽음으로 바꾼다 (조건 없이 — 되돌릴 수 있는 표시라서). + * 한 요청의 시간 · 멈춘 차례는 BulkRun — 남은 수를 돌려준다. 없는 메일함은 RES_001. + * 엔진이 모두 거절한 차례가 있으면 멈춘다 — 이 요청에서 바꾼 것이 없으면 그 거절의 코드로 실패한다 (클라이언트가 끝없이 다시 보내지 않게) + */ + async markMailboxRead(user: string, id: string): Promise { + const run = new BulkRun(); + const mailbox = findMailbox( + await this.loadMailboxes(user, run.callOptions()), + id, + ); + let remaining = mailbox.unreadEmails ?? 0; + + try { + while (run.canStartRound()) { + const responses = await this.jmap.call( + user, + (accountId) => [ + [ + 'Email/query', + { + accountId, + filter: { inMailbox: mailbox.id, notKeyword: '$seen' }, + limit: BULK_BATCH_SIZE, + calculateTotal: true, + }, + 'q', + ], + ], + run.callOptions(), + ); + const query = expectResult( + responses, + 'q', + 'Email/query', + ); + const ids = query.ids ?? []; + if (ids.length === 0) { + return { processed: run.processed, remaining: 0 }; + } + const total = queryTotal(query, ids.length); + remaining = total; + + const result = await this.jmap.call( + user, + (accountId) => [ + [ + 'Email/set', + { + accountId, + update: Object.fromEntries( + ids.map((emailId) => [emailId, { 'keywords/$seen': true }]), + ), + }, + 'u', + ], + ], + run.callOptions(), + ); + const set = expectResult(result, 'u', 'Email/set'); + const updated = new Set(Object.keys(set.updated ?? {})); + const marked = ids.filter((emailId) => updated.has(emailId)).length; + this.warnRefusals(user, set.notUpdated, '읽음으로 바꾸지'); + remaining = Math.max(0, total - marked); + if (marked > 0) { + run.progress(marked); + continue; + } + + const refusal = firstRefusal(set.notUpdated); + if (refusal) { + if (run.processed > 0) break; + throw jmapFailure(refusal, { method: 'Email/set' }); + } + // 바뀐 것도 거절도 없다 (모두 그사이 없어짐 등) — 다시 찾는다 + if (run.stalled('no-change')) break; + } + } catch (error) { + if (!run.cutOffAfterProgress(error)) throw error; + } + run.assertProgress('mailbox-mark-read'); + return { processed: run.processed, remaining }; + } + + /** + * 휴지통 · 스팸메일함 비우기 (영구 삭제) — 휴지통에서 영구 삭제(deleteFromTrash)와 같은 규칙이다: + * 차례마다 BULK_BATCH_SIZE 통을 읽어 확인한 상태 그대로일 때만(ifInState) 지운다 — 그 메일함에만 있으면 영구 삭제, + * 다른 메일함에도 있으면 그 메일함에서만 뺀다. 어긋나면 다시 읽는다. 한 요청의 시간 · 멈춘 차례는 BulkRun — + * 이 요청에서 지운 것이 없이 멈추면 MAIL_001 (지우지 않았다). 남은 수를 돌려준다. + * 없는 메일함은 RES_001, 휴지통 · 스팸메일함이 아니면 VAL_001. + */ + async emptyMailbox(user: string, id: string): Promise { + const run = new BulkRun(); + const mailbox = findMailbox( + await this.loadMailboxes(user, run.callOptions()), + id, + ); + const role = engineRole(mailbox); + if (role === null || !EMPTYABLE_ROLES.has(role)) { + throw new BusinessException('VAL_001', { + context: { reason: 'mailbox-not-emptyable', role }, + }); + } + let remaining = mailbox.totalEmails ?? 0; + + try { + while (run.canStartRound()) { + const batch = await this.readMailboxBatch( + user, + mailbox.id, + run.callOptions(), + ); + if (batch.emails.length === 0) { + return { processed: run.processed, remaining: 0 }; + } + remaining = batch.total; + // 상태를 모르면 조건 없이 지우지 않는다 + if (batch.state === null) { + throw new BusinessException('MAIL_001', { + context: { reason: 'mailbox-state-unknown' }, + }); + } + + const destroy: string[] = []; + const detach: string[] = []; + for (const email of batch.emails) { + if (removalFrom(inMailboxes(email), mailbox.id) === 'destroy') { + destroy.push(email.id); + } else { + detach.push(email.id); + } + } + const set = await this.conditionalEmailSet( + user, + batch.state, + removalChanges(mailbox.id, destroy, detach), + run.callOptions(), + ); + if (set === null) { + if (run.stalled('state-mismatch')) break; + continue; + } + + const destroyed = new Set(set.destroyed ?? []); + const detached = new Set(Object.keys(set.updated ?? {})); + const removed = + destroy.filter((emailId) => destroyed.has(emailId)).length + + detach.filter((emailId) => detached.has(emailId)).length; + this.warnRefusals( + user, + { ...set.notDestroyed, ...set.notUpdated }, + '메일함을 비우며 지우지', + ); + remaining = Math.max(0, batch.total - removed); + if (removed > 0) { + run.progress(removed); + continue; + } + + const refusal = firstRefusal(set.notDestroyed, set.notUpdated); + if (refusal) { + if (run.processed > 0) break; + throw jmapFailure(refusal, { method: 'Email/set' }); + } + // 바뀐 것도 거절도 없다 (모두 그사이 없어짐 등) — 다시 읽는다 + if (run.stalled('no-change')) break; + } + } catch (error) { + if (!run.cutOffAfterProgress(error)) throw error; + } + run.assertProgress('mailbox-empty'); + return { processed: run.processed, remaining }; + } + + /** 부재중 자동 응답 (RFC 8621 §8 — 계정마다 하나) */ + async getVacation(user: string): Promise { + const responses = await this.jmap.call( + user, + (accountId) => [ + ['VacationResponse/get', { accountId, ids: [VACATION_ID] }, 'v'], + ], + { capabilities: [VACATION_CAPABILITY] }, + ); + const { list = [] } = expectResult>( + responses, + 'v', + 'VacationResponse/get', + ); + return toVacation(list[0]); + } + + /** + * 부재중 자동 응답을 통째로 바꾼다 — 같은 요청에서 다시 읽은 값을 돌려준다(엔진이 저장한 그대로). + * 서식 본문은 늘 비운다 (toJmapVacation). 엔진이 거절하면 그 코드 (형식 거절은 VAL_001) + */ + async setVacation( + user: string, + vacation: MailVacation, + ): Promise { + const responses = await this.jmap.call( + user, + (accountId) => [ + [ + 'VacationResponse/set', + { accountId, update: { [VACATION_ID]: toJmapVacation(vacation) } }, + 's', + ], + ['VacationResponse/get', { accountId, ids: [VACATION_ID] }, 'v'], + ], + { capabilities: [VACATION_CAPABILITY] }, + ); + const set = expectResult( + responses, + 's', + 'VacationResponse/set', + ); + const failure = firstSetError(set.notUpdated); + if (failure) { + throw jmapFailure(failure, { method: 'VacationResponse/set' }); + } + if ( + !set.updated || + !Object.prototype.hasOwnProperty.call(set.updated, VACATION_ID) + ) { + throw new BusinessException('MAIL_001', { + context: { reason: 'vacation-not-updated' }, + }); + } + const { list = [] } = expectResult>( + responses, + 'v', + 'VacationResponse/get', + ); + return toVacation(list[0]); } async getUsage(user: string): Promise { @@ -864,18 +1533,18 @@ export class JmapMailProvider implements MailProvider { } // 확인한 대로면 늘 휴지통에 있다 — 아니면 지우지 않는다 - const removal = trashRemoval(snapshot.mailboxIds, trashId); + const removal = removalFrom(snapshot.mailboxIds, trashId); if (removal === null) return; const ifInState = snapshot.state; const responses = await this.jmap.call(user, (accountId) => [ [ 'Email/set', - trashRemovalSet( + removalSet( accountId, ifInState, trashId, removal === 'destroy' ? [id] : [], - removal === 'untrash' ? [id] : [], + removal === 'detach' ? [id] : [], ), 'd', ], @@ -904,7 +1573,7 @@ export class JmapMailProvider implements MailProvider { /** * 휴지통에서 영구 삭제 (여러 통) — 휴지통에 있는 메일만 다룬다. 휴지통 밖의 메일을 휴지통으로 옮기지 않는다 (DELETE 한 통과 다르다). * 한 번의 시도: 메일·메일함 확인(요청 1) → 확인한 상태 그대로일 때만 한 번에 지우기(요청 2, ifInState). - * - 휴지통에만 있으면 영구 삭제, 다른 메일함에도 있으면 휴지통에서만 뺀다 (trashRemoval — 한 통 삭제와 같은 규칙) + * - 휴지통에만 있으면 영구 삭제, 다른 메일함에도 있으면 휴지통에서만 뺀다 (removalFrom — 한 통 삭제와 같은 규칙) * - 없음 · 휴지통에 없음 → skippedIds (확인한 뒤 다른 창이 휴지통 밖으로 되돌린 메일은 상태가 어긋나 지우지 않는다) * - 지우는 순간 메일마다의 거절: 없어졌으면(notFound) 건너뛴 것으로, 그 밖의 거절도 건너뛴 것으로(종류·개수만 경고) * - 상태가 어긋나면 다시 확인한다 — 합계 CONDITIONAL_DESTROY_ATTEMPTS 번이 모두 어긋나면 아무것도 지우지 않고 MAIL_001 @@ -961,11 +1630,9 @@ export class JmapMailProvider implements MailProvider { const untrash: string[] = []; for (const id of ids) { const email = byId.get(id); - const removal = email - ? trashRemoval(inMailboxes(email), trashId) - : null; + const removal = email ? removalFrom(inMailboxes(email), trashId) : null; if (removal === 'destroy') destroy.push(id); - else if (removal === 'untrash') untrash.push(id); + else if (removal === 'detach') untrash.push(id); } if (destroy.length === 0 && untrash.length === 0) { return { deletedIds: [], skippedIds: [...ids] }; @@ -981,7 +1648,7 @@ export class JmapMailProvider implements MailProvider { const responses = await this.jmap.call(user, (accountId) => [ [ 'Email/set', - trashRemovalSet(accountId, ifInState, trashId, destroy, untrash), + removalSet(accountId, ifInState, trashId, destroy, untrash), 'd', ], ]); @@ -1276,6 +1943,43 @@ export class JmapMailProvider implements MailProvider { }; } + /** + * 받은 그대로의 메일 원문 — 메일의 원본 blob 을 흘려보낸다 (계약 2절 "스팸 · 원문"). + * 없는 메일 · 엔진이 해석하지 못하는 id 는 RES_001. 엔진이 원본 blob 을 알려 주지 않으면 MAIL_001 + */ + async openMessageSource(user: string, id: string): Promise { + const responses = await this.jmap.call( + user, + (accountId) => [ + [ + 'Email/get', + { accountId, ids: [id], properties: ['id', 'blobId', 'subject'] }, + 'g', + ], + ], + { invalidRequestCode: 'RES_001' }, + ); + const email = this.requireEmail(responses, 'g'); + if (typeof email.blobId !== 'string' || email.blobId === '') { + throw new BusinessException('MAIL_001', { + context: { reason: 'message-blob-missing' }, + }); + } + + const download = await this.jmap.download( + user, + email.blobId, + SOURCE_DOWNLOAD_NAME, + SOURCE_TYPE, + ); + return { + stream: download.stream, + subject: email.subject ?? '', + // Content-Length 로 쓰이므로 엔진이 알려 준 실제 길이만 쓴다 (모르면 청크 전송) + size: download.length, + }; + } + async openAttachment( user: string, messageId: string, @@ -1664,6 +2368,154 @@ export class JmapMailProvider implements MailProvider { }); } + /** 메일함 모두 — 표시에 쓰는 속성 (메일함은 많지 않아 한 번에 읽는다). 오래 걸리는 처리는 남은 시간을 제한 시간으로 준다 */ + private async loadMailboxes( + user: string, + options: JmapCallOptions = {}, + ): Promise { + const responses = await this.jmap.call( + user, + (accountId) => [ + [ + 'Mailbox/get', + { accountId, ids: null, properties: MAILBOX_PROPERTIES }, + 'm', + ], + ], + options, + ); + const { list = [] } = expectResult>( + responses, + 'm', + 'Mailbox/get', + ); + return list; + } + + /** + * 메일함의 메일 한 차례(최대 BULK_BATCH_SIZE 통) — id · 들어 있는 메일함과 그때의 Email 상태(ifInState 에 쓴다), + * 이 메일함의 전체 수. 조회와 읽기를 한 요청으로 한다 (같은 순간의 상태). 그 메일함에 든 것만 돌려준다 + */ + private async readMailboxBatch( + user: string, + mailboxId: string, + options: JmapCallOptions, + ): Promise<{ emails: JmapEmail[]; state: string | null; total: number }> { + const responses = await this.jmap.call( + user, + (accountId) => [ + [ + 'Email/query', + { + accountId, + filter: { inMailbox: mailboxId }, + limit: BULK_BATCH_SIZE, + calculateTotal: true, + }, + 'q', + ], + [ + 'Email/get', + { + accountId, + '#ids': { resultOf: 'q', name: 'Email/query', path: '/ids' }, + properties: ['id', 'mailboxIds'], + }, + 'g', + ], + ], + options, + ); + const query = expectResult(responses, 'q', 'Email/query'); + const found = expectResult>( + responses, + 'g', + 'Email/get', + ); + const ids = query.ids ?? []; + const byId = new Map((found.list ?? []).map((email) => [email.id, email])); + const emails = ids.flatMap((emailId) => { + const email = byId.get(emailId); + return email && email.mailboxIds?.[mailboxId] === true ? [email] : []; + }); + return { + emails, + state: isState(found.state) ? found.state : null, + total: queryTotal(query, ids.length), + }; + } + + /** + * 확인한 상태 그대로일 때만 하는 Email/set — 어긋났으면(stateMismatch) null. + * `ifInState` 는 필수 인자이고 바꿀 것(EmailSetChanges)에는 지우기 · 고치기만 들어간다 — 이 경로로 조건 없는 지우기가 나갈 수 없다. + * 엔진은 어긋난 요청을 통째로 거절하고 아무것도 하지 않는다 (2026-10-27 개발 엔진 확인). 그 밖의 메서드 오류는 그 코드로 던진다 + */ + private async conditionalEmailSet( + user: string, + ifInState: string, + changes: EmailSetChanges, + options: JmapCallOptions, + ): Promise { + const responses = await this.jmap.call( + user, + (accountId) => [ + [ + 'Email/set', + { + accountId, + ifInState, + ...(changes.destroy?.length + ? { destroy: [...changes.destroy] } + : {}), + ...(changes.update ? { update: changes.update } : {}), + }, + 's', + ], + ], + options, + ); + const result = findResult(responses, 's', 'Email/set'); + if (!result.ok) { + if (result.error.type === STATE_MISMATCH) return null; + throw jmapFailure(result.error, { method: 'Email/set' }); + } + const set = result.args as JmapSetResult; + return set; + } + + /** + * 빈 메일함을 지운다 — 메일이 든 메일함은 엔진이 mailboxHasEmail 로 거절한다(메일을 지우는 옵션은 쓰지 않는다, 확인함). + * @returns 지웠거나 이미 없으면 true, 그사이 메일이 들어와 거절됐으면 false + * @throws 하위 메일함이 생겼으면 VAL_001, 그 밖의 거절은 그 코드 + */ + private async destroyEmptyMailbox( + user: string, + mailboxId: string, + options: JmapCallOptions, + ): Promise { + const responses = await this.jmap.call( + user, + (accountId) => [ + [ + 'Mailbox/set', + { accountId, destroy: [mailboxId], onDestroyRemoveEmails: false }, + 'x', + ], + ], + options, + ); + const set = expectResult(responses, 'x', 'Mailbox/set'); + const failure = firstSetError(set.notDestroyed); + if (!failure || failure.type === NOT_FOUND) return true; + if (failure.type === MAILBOX_HAS_EMAIL) return false; + if (failure.type === MAILBOX_HAS_CHILD) { + throw new BusinessException('VAL_001', { + context: { reason: 'mailbox-has-children' }, + }); + } + throw jmapFailure(failure, { method: 'Mailbox/set' }); + } + /** * 메일함을 정하지 않은 검색의 범위 — 휴지통·스팸메일함을 뺀 모든 메일함 (계약 2절 "검색"). * 그 메일함 id 를 먼저 읽는다 (필터 안에서는 다른 메서드의 결과를 참조할 수 없다). 그런 역할의 메일함이 없으면 모든 메일함이다. diff --git a/backend/src/modules/mail/jmap/jmap.client.spec.ts b/backend/src/modules/mail/jmap/jmap.client.spec.ts index bcfc7d5..a0996dc 100644 --- a/backend/src/modules/mail/jmap/jmap.client.spec.ts +++ b/backend/src/modules/mail/jmap/jmap.client.spec.ts @@ -8,6 +8,7 @@ import { JmapClient, basicAuthorization, classifyManagementProbe, + classifyPasswordChange, expandTemplate, toRelativeReference, } from './jmap.client'; @@ -170,6 +171,265 @@ describe('JmapClient', () => { }); }); + describe('본인 비밀번호 바꾸기 (changePassword)', () => { + const CURRENT = 'old:secret-값'; + const NEXT = 'new-secret-1234'; + /** 본인 자격 세션 — 계정 관리 기능(urn:stalwart:jmap)의 계정 id 는 메일 계정 id 와 다를 수 있다 */ + const ownSession = { + ...sessionBody, + primaryAccounts: { + 'urn:ietf:params:jmap:mail': 'd', + 'urn:stalwart:jmap': 'x1', + }, + }; + const passwordAnswer = (invocation: unknown[]) => + json({ methodResponses: [invocation], sessionState: 's' }); + const notUpdated = (setError: object) => + passwordAnswer([ + 'x:AccountPassword/set', + { notUpdated: { singleton: setError } }, + 'p', + ]); + let warnLog: jest.SpyInstance; + + beforeEach(() => { + const logger = Reflect.get(client, 'logger') as { + warn: (message: string) => void; + }; + warnLog = jest.spyOn(logger, 'warn').mockImplementation(() => undefined); + }); + + /** warn 로그 전부 — 비밀번호가 없는지 볼 때 */ + const warned = () => + (warnLog.mock.calls as unknown[][]).map((call) => String(call[0])); + + it('본인 자격(직원 주소:지금 비밀번호)으로 세션을 열고, 같은 자격으로 x:AccountPassword/set 에 지금·새 비밀번호를 보낸다 — 게이트웨이를 쓰지 않는다', async () => { + fetchMock + .mockResolvedValueOnce(json(ownSession)) + .mockResolvedValueOnce( + passwordAnswer([ + 'x:AccountPassword/set', + { updated: { singleton: null } }, + 'p', + ]), + ); + + await expect(client.changePassword(ARA, CURRENT, NEXT)).resolves.toBe( + 'changed', + ); + + expect(fetchMock).toHaveBeenCalledTimes(2); + const opened = requestAt(0); + expect(opened.url).toBe('http://mail:8080/jmap/session'); + expect(decodeBasic(opened.authorization)).toBe(`${ARA}:${CURRENT}`); + + const { url, init, authorization } = requestAt(1); + expect(url).toBe('http://mail:8080/jmap/'); + expect(init.method).toBe('POST'); + expect(decodeBasic(authorization)).toBe(`${ARA}:${CURRENT}`); + expect(JSON.parse(init.body as string)).toEqual({ + using: ['urn:ietf:params:jmap:core', 'urn:stalwart:jmap'], + methodCalls: [ + [ + 'x:AccountPassword/set', + { + accountId: 'x1', + update: { + singleton: { currentSecret: CURRENT, secret: NEXT }, + }, + }, + 'p', + ], + ], + }); + for (const index of [0, 1]) { + expect(decodeBasic(requestAt(index).authorization)).not.toContain( + 'mail-gateway', + ); + } + // 대리 접근 세션 캐시를 건드리지 않는다 + expect(cache.set).not.toHaveBeenCalled(); + expect(warnLog).not.toHaveBeenCalled(); + }); + + it('지금 비밀번호가 틀리면 세션 요청이 401 — 바꾸기를 보내지 않고 current-password-rejected (403 도 로그인 확인처럼 같다)', async () => { + for (const status of [401, 403]) { + fetchMock.mockReset(); + fetchMock.mockResolvedValueOnce(json({}, status)); + + await expect(client.changePassword(ARA, 'wrong', NEXT)).resolves.toBe( + 'current-password-rejected', + ); + expect(fetchMock).toHaveBeenCalledTimes(1); + } + }); + + it('들어간 뒤 엔진이 바꾸기를 거절하면(forbidden · 지금 비밀번호 속성 거절 · 그사이 401) current-password-refused — warn 로그에는 가린 주소와 종류만', async () => { + const answers: [Response, string][] = [ + [ + notUpdated({ + type: 'forbidden', + description: 'Current secret must be provided', + }), + 'forbidden', + ], + [ + notUpdated({ + type: 'invalidProperties', + properties: ['currentSecret'], + }), + 'invalidProperties', + ], + [json({}, 401), 'http-401'], + ]; + + for (const [answer, jmapType] of answers) { + warnLog.mockClear(); + fetchMock + .mockResolvedValueOnce(json(ownSession)) + .mockResolvedValueOnce(answer); + + await expect(client.changePassword(ARA, CURRENT, NEXT)).resolves.toBe( + 'current-password-refused', + ); + expect(warnLog).toHaveBeenCalledTimes(1); + const [line] = warned(); + expect(line).toMatch(/^\[AUTH_005\]/); + expect(line).toContain('a***@arasystem.test'); + expect(line).toContain(`jmapType=${jmapType}`); + expect(line).not.toContain(ARA); + expect(line).not.toContain(CURRENT); + expect(line).not.toContain(NEXT); + expect(line).not.toContain('Current secret must be provided'); + } + }); + + it('새 비밀번호가 엔진 규칙에 어긋나면(invalidProperties secret) new-password-rejected', async () => { + fetchMock + .mockResolvedValueOnce(json(ownSession)) + .mockResolvedValueOnce( + notUpdated({ type: 'invalidProperties', properties: ['secret'] }), + ); + + await expect(client.changePassword(ARA, CURRENT, NEXT)).resolves.toBe( + 'new-password-rejected', + ); + expect(warnLog).not.toHaveBeenCalled(); + }); + + it('바뀌지 않은 것이 확실한 실패는 던진다 — 요청을 받지 않음(403 · 400) · 메서드 오류 · 그 밖의 SetError 는 MAIL_001, 429 는 REQ_001 (context 에 비밀번호 없음)', async () => { + const answers: [Response, string][] = [ + [json({ type: 'forbidden' }, 403), 'MAIL_001'], + [ + json({ type: 'urn:ietf:params:jmap:error:unknownCapability' }, 400), + 'MAIL_001', + ], + [passwordAnswer(['error', { type: 'unknownMethod' }, 'p']), 'MAIL_001'], + [notUpdated({ type: 'serverFail' }), 'MAIL_001'], + [json({}, 429), 'REQ_001'], + ]; + + for (const [answer, code] of answers) { + fetchMock + .mockResolvedValueOnce(json(ownSession)) + .mockResolvedValueOnce(answer); + const failure = await client.changePassword(ARA, CURRENT, NEXT).then( + () => undefined, + (error: unknown) => error as BusinessException, + ); + + expect(failure?.code).toBe(code); + const logged = JSON.stringify(failure?.context); + expect(logged).not.toContain(CURRENT); + expect(logged).not.toContain(NEXT); + } + expect(warnLog).not.toHaveBeenCalled(); + }); + + it('바꾸기 요청의 결과를 알 수 없으면(5xx · 연결 끊김 · 해석할 수 없는 응답 · 결과 없는 응답) 던지지 않고 outcome-unknown — warn 로그에 원인', async () => { + const answers: [() => Promise, string][] = [ + [() => Promise.resolve(json({}, 502)), 'http-502'], + [() => Promise.reject(new TypeError('fetch failed')), 'network'], + [ + () => + Promise.resolve( + new Response('not json', { + status: 200, + headers: { 'content-type': 'application/json' }, + }), + ), + 'malformed-response', + ], + [ + () => + Promise.resolve(passwordAnswer(['x:AccountPassword/set', {}, 'p'])), + 'no-result', + ], + [() => Promise.resolve(json({ methodResponses: [] })), 'undefined'], + ]; + + for (const [answer, cause] of answers) { + warnLog.mockClear(); + fetchMock + .mockResolvedValueOnce(json(ownSession)) + .mockImplementationOnce(answer); + + await expect(client.changePassword(ARA, CURRENT, NEXT)).resolves.toBe( + 'outcome-unknown', + ); + const [line] = warned(); + expect(line).toMatch(/^\[MAIL_001\]/); + expect(line).toContain(`원인=${cause}`); + expect(line).toContain('a***@arasystem.test'); + expect(line).not.toContain(CURRENT); + expect(line).not.toContain(NEXT); + } + }); + + it('바꾸기 요청이 시간 안에 답하지 않으면 outcome-unknown (엔진이 바꿨을 수 있다)', async () => { + jest.useFakeTimers(); + fetchMock.mockResolvedValueOnce(json(ownSession)).mockImplementationOnce( + (_url: string, init: RequestInit) => + new Promise((_resolve, reject) => { + init.signal?.addEventListener('abort', () => + reject(new DOMException('aborted', 'AbortError')), + ); + }), + ); + + const pending = client.changePassword(ARA, CURRENT, NEXT); + await jest.advanceTimersByTimeAsync(10_000); + + await expect(pending).resolves.toBe('outcome-unknown'); + expect(warned()[0]).toContain('원인=timeout'); + }); + + it('세션을 열지 못한 실패는 바뀌지 않은 것이 확실하다 — 5xx · 연결 끊김은 MAIL_001, 429 는 REQ_001 · 계정 관리 기능이 없으면 바꾸기를 보내지 않고 MAIL_001', async () => { + fetchMock.mockResolvedValueOnce(json({}, 502)); + expect(await codeOf(client.changePassword(ARA, CURRENT, NEXT))).toBe( + 'MAIL_001', + ); + + fetchMock.mockRejectedValueOnce(new TypeError('fetch failed')); + expect(await codeOf(client.changePassword(ARA, CURRENT, NEXT))).toBe( + 'MAIL_001', + ); + + fetchMock.mockResolvedValueOnce(json({}, 429)); + expect(await codeOf(client.changePassword(ARA, CURRENT, NEXT))).toBe( + 'REQ_001', + ); + + fetchMock.mockReset(); + fetchMock.mockResolvedValueOnce(json(sessionBody)); + expect(await codeOf(client.changePassword(ARA, CURRENT, NEXT))).toBe( + 'MAIL_001', + ); + expect(fetchMock).toHaveBeenCalledTimes(1); + expect(warnLog).not.toHaveBeenCalled(); + }); + }); + describe('대리 접근 세션 (getSession)', () => { it('<직원>%<게이트웨이>:<게이트웨이 비밀번호> 로 인증하고, 공개 주소 URL 을 경로로 바꿔 둔다', async () => { fetchMock.mockResolvedValueOnce(json(sessionBody)); @@ -512,6 +772,68 @@ describe('JmapClient', () => { expect(error.context).toMatchObject({ reason: 'timeout' }); }); + it('제한 시간을 받으면 세션 조회(캐시에 없을 때)까지 그 안에서 한다 — 세션 조회에 쓴 시간만큼 호출이 짧아진다', async () => { + jest.useFakeTimers(); + // 이 describe 의 beforeEach 가 넣어 둔 세션 응답을 지운다 — 세션 조회도 아래 구현을 따르게 + fetchMock.mockReset(); + fetchMock.mockImplementation( + (_url: string, init: RequestInit) => + new Promise((resolve, reject) => { + if (!init.body) { + // 세션 조회는 3초 뒤에 답한다 + setTimeout(() => resolve(json(sessionBody)), 3_000); + return; + } + init.signal?.addEventListener('abort', () => + reject(new DOMException('aborted', 'AbortError')), + ); + }), + ); + + let settled = false; + const pending = client + .call(ARA, () => [], { timeoutMs: 5_000 }) + .catch((error: unknown) => error) + .finally(() => { + settled = true; + }); + + await jest.advanceTimersByTimeAsync(4_999); + expect(settled).toBe(false); + + await jest.advanceTimersByTimeAsync(1); + const error = (await pending) as BusinessException; + expect(error.code).toBe('MAIL_001'); + expect(error.context).toMatchObject({ reason: 'timeout', call: 'api' }); + }); + + it('세션 조회가 받은 제한 시간을 넘기면 거기서 MAIL_001 (timeout) — 호출은 보내지 않는다', async () => { + jest.useFakeTimers(); + // 이 describe 의 beforeEach 가 넣어 둔 세션 응답을 지운다 — 세션 조회도 아래 구현을 따르게 + fetchMock.mockReset(); + fetchMock.mockImplementation( + (_url: string, init: RequestInit) => + new Promise((_resolve, reject) => { + init.signal?.addEventListener('abort', () => + reject(new DOMException('aborted', 'AbortError')), + ); + }), + ); + + const pending = client + .call(ARA, () => [], { timeoutMs: 2_000 }) + .catch((error: unknown) => error); + await jest.advanceTimersByTimeAsync(2_000); + + const error = (await pending) as BusinessException; + expect(error.code).toBe('MAIL_001'); + expect(error.context).toMatchObject({ + reason: 'timeout', + call: 'session', + }); + expect(fetchMock).toHaveBeenCalledTimes(1); + }); + it('요청 자체가 거부되면(notRequest) 지정한 코드, 지정이 없으면 MAIL_001', async () => { const notRequest = { type: 'urn:ietf:params:jmap:error:notRequest', @@ -698,6 +1020,69 @@ describe('JMAP URL 유틸', () => { }); }); +describe('비밀번호 바꾸기 응답 해석 (classifyPasswordChange)', () => { + const notUpdated = (error: object) => + classifyPasswordChange([ + 'x:AccountPassword/set', + { notUpdated: { singleton: error } }, + 'p', + ]); + + it('바꿨다고 답하면 changed', () => { + expect( + classifyPasswordChange([ + 'x:AccountPassword/set', + { updated: { singleton: null } }, + 'p', + ]), + ).toEqual({ kind: 'changed' }); + }); + + it('invalidProperties 는 새 비밀번호 쪽(지금 비밀번호 속성이면 들어간 뒤의 거절), forbidden 은 들어간 뒤의 거절', () => { + expect( + notUpdated({ type: 'invalidProperties', properties: ['secret'] }), + ).toEqual({ kind: 'new-password-rejected' }); + expect(notUpdated({ type: 'invalidProperties' })).toEqual({ + kind: 'new-password-rejected', + }); + expect( + notUpdated({ type: 'invalidProperties', properties: ['currentSecret'] }), + ).toEqual({ + kind: 'current-password-refused', + detail: 'invalidProperties', + }); + expect(notUpdated({ type: 'forbidden' })).toEqual({ + kind: 'current-password-refused', + detail: 'forbidden', + }); + }); + + it('그 밖의 SetError · 메서드 오류는 바뀌지 않은 것이 확실한 실패(failed)', () => { + expect(notUpdated({ type: 'serverFail' })).toEqual({ + kind: 'failed', + detail: 'notUpdated/serverFail', + }); + expect( + classifyPasswordChange(['error', { type: 'unknownMethod' }, 'p']), + ).toEqual({ kind: 'failed', detail: 'error/unknownMethod' }); + }); + + it('응답이 바꾸기 결과를 말하지 않으면 알 수 없음(unknown) — 엔진이 바꿨을 수 있다', () => { + expect(classifyPasswordChange(['x:AccountPassword/set', {}, 'p'])).toEqual({ + kind: 'unknown', + detail: 'no-result', + }); + expect(classifyPasswordChange(['Other/set', {}, 'p'])).toEqual({ + kind: 'unknown', + detail: 'Other/set', + }); + expect(classifyPasswordChange(undefined)).toEqual({ + kind: 'unknown', + detail: 'undefined', + }); + }); +}); + describe('관리 권한 확인 응답 해석 (classifyManagementProbe)', () => { it('성공은 관리 권한 있음, forbidden 은 없음', () => { expect( diff --git a/backend/src/modules/mail/jmap/jmap.client.ts b/backend/src/modules/mail/jmap/jmap.client.ts index 66867e2..115ede8 100644 --- a/backend/src/modules/mail/jmap/jmap.client.ts +++ b/backend/src/modules/mail/jmap/jmap.client.ts @@ -12,6 +12,7 @@ import { type ConfiguredMailSettings, type MailSettings, } from '@/modules/mail/mail.settings'; +import type { PasswordChangeOutcome } from '@/modules/mail/mail.types'; import { traceTag } from '@/shared/logger/request-context'; import { maskEmail } from '@/shared/utils/mask'; import type { @@ -19,6 +20,7 @@ import type { JmapProblem, JmapResponseBody, JmapSessionResource, + JmapSetResult, JmapUploadResult, } from './jmap.types'; @@ -64,8 +66,76 @@ export function classifyManagementProbe( return { kind: 'unclear', detail: String(name) }; } -/** 일반 API 호출 제한 시간 — 응답 본문을 다 읽을 때까지 */ -const REQUEST_TIMEOUT_MS = 10_000; +/** + * Stalwart 의 계정 관리 기능 — 본인 비밀번호 바꾸기(x:AccountPassword)가 여기 있다. + * 이 기능의 계정 id 는 세션의 `primaryAccounts` 에서 이 이름으로 찾는다 (2026-10-28 개발 엔진(0.16.27)에서 확인) + */ +const ACCOUNT_MANAGEMENT_CAPABILITY = 'urn:stalwart:jmap'; +/** 본인 비밀번호 바꾸기 — 지금 비밀번호(currentSecret)가 함께 와야 바꾼다 (없으면 forbidden, 확인함) */ +const PASSWORD_SET_METHOD = 'x:AccountPassword/set'; +/** 계정마다 하나인 비밀번호 객체의 id */ +const PASSWORD_OBJECT_ID = 'singleton'; + +/** + * 비밀번호 바꾸기 응답을 해석한 것 (detail 은 로그용 요약 — 엔진 오류 종류 · 원인. 엔진의 설명 문구 · 비밀번호는 넣지 않는다) + * - changed · new-password-rejected: 엔진의 판단 그대로 + * - current-password-refused: 지금 비밀번호로 들어가기는 했는데 엔진이 바꾸기를 거절했다 + * - failed: 바뀌지 않은 것이 확실한 엔진 오류 (메서드 오류 · 그 밖의 SetError) + * - unknown: 응답으로는 바뀌었는지 알 수 없다 — 엔진이 바꿨을 수 있다 + */ +export type PasswordChangeReply = + | { readonly kind: 'changed' | 'new-password-rejected' } + | { + readonly kind: 'current-password-refused' | 'failed' | 'unknown'; + readonly detail: string; + }; + +/** + * 비밀번호 바꾸기 호출(x:AccountPassword/set)의 메서드 응답을 해석한다 (순수 함수). + * - 바꿨다고 답하면(updated 에 singleton) changed + * - invalidProperties — 새 비밀번호가 엔진 규칙에 어긋난다(8자 미만이면 properties ['secret'], 확인함) → new-password-rejected. + * 지금 비밀번호 쪽(properties 에 currentSecret)이면 current-password-refused + * - forbidden — 엔진이 바꾸기를 받지 않았다(지금 비밀번호가 빠지면 이 오류다, 확인함) → current-password-refused. + * 세션은 그 비밀번호로 열렸으므로 맞혀 보기가 아니다 — 계정 비밀번호가 아닌 값(앱 비밀번호 등)으로 들어왔거나 변경 권한이 없다 + * - 그 밖의 SetError · 메서드 오류는 failed (바뀌지 않았다), 응답이 바꾸기 결과를 말하지 않으면 unknown + */ +export function classifyPasswordChange( + invocation: JmapInvocation | undefined, +): PasswordChangeReply { + const [name, args] = invocation ?? []; + if (name === PASSWORD_SET_METHOD) { + const result = args as JmapSetResult; + if ( + result.updated && + Object.prototype.hasOwnProperty.call(result.updated, PASSWORD_OBJECT_ID) + ) { + return { kind: 'changed' }; + } + const failure = + result.notUpdated?.[PASSWORD_OBJECT_ID] ?? + Object.values(result.notUpdated ?? {})[0]; + if (!failure) return { kind: 'unknown', detail: 'no-result' }; + if (failure.type === 'invalidProperties') { + return (failure.properties ?? []).includes('currentSecret') + ? { kind: 'current-password-refused', detail: 'invalidProperties' } + : { kind: 'new-password-rejected' }; + } + if (failure.type === 'forbidden') { + return { kind: 'current-password-refused', detail: 'forbidden' }; + } + return { kind: 'failed', detail: `notUpdated/${String(failure.type)}` }; + } + + if (name === 'error') { + const type = (args as JmapProblem | undefined)?.type; + return { kind: 'failed', detail: `error/${String(type)}` }; + } + return { kind: 'unknown', detail: String(name) }; +} + +/** 일반 API 호출 제한 시간 — 응답 본문을 다 읽을 때까지. 오래 걸리는 처리는 남은 시간이 이보다 적으면 그만큼만 준다 (jmap-mail.provider.ts) */ +export const JMAP_REQUEST_TIMEOUT_MS = 10_000; +const REQUEST_TIMEOUT_MS = JMAP_REQUEST_TIMEOUT_MS; /** * 발송(초안 생성 + 제출) 제한 시간. 엔진이 첨부(최대 합계 25MB)를 붙여 메시지를 만드는 시간을 넉넉히 주되, * 클라이언트의 발송 시간 한도(60초)보다 짧게 둔다 — 백엔드가 먼저 MAIL_001 로 답해야 사용자가 같은 키로 다시 보낼 수 있다. @@ -92,6 +162,7 @@ const HTTP_FORBIDDEN = 403; const HTTP_NOT_FOUND = 404; const HTTP_PAYLOAD_TOO_LARGE = 413; const HTTP_TOO_MANY_REQUESTS = 429; +const HTTP_SERVER_ERROR = 500; /** 직원 메일함에 접근하는 데 필요한 세션 정보 (캐시에 저장되므로 평범한 객체로 둔다) */ export interface JmapSession { @@ -218,6 +289,7 @@ function parseContentLength(value: string | null): number | null { * Stalwart 내부 JMAP 에 붙는 얇은 클라이언트 (Node 내장 fetch). * * - 로그인 확인: `<직원 주소>:<비밀번호>` 로 세션 조회 (200 / 401) + * - 본인 비밀번호 바꾸기: 같은 본인 자격으로 `x:AccountPassword/set` (게이트웨이 대리 접근으로는 바꾸지 않는다) * - 메일함 접근: 게이트웨이 대리 접근 `<직원 주소>%<게이트웨이 주소>` : `<게이트웨이 비밀번호>` * - 세션 응답의 URL 은 공개 주소 기준이므로 경로만 떼어 MAIL_JMAP_URL 에 붙인다 (mail/README.md) * @@ -263,10 +335,176 @@ export class JmapClient { ); } - /** 대리 접근 세션 — 직원별로 잠깐 캐시한다. `fresh` 면 캐시를 건너뛰고 계정 상태를 다시 확인한다 */ + /** + * 직원 **본인의 자격으로** 비밀번호를 바꾼다 (계약 1절 "PUT /api/auth/password"). + * 게이트웨이 대리 접근·관리자 권한으로는 바꾸지 않는다 — 엔진은 본인 자격의 요청만 받는다 (2026-10-28 개발 엔진에서 확인). + * + * 1) `<직원 주소>:<지금 비밀번호>` 로 세션을 연다 — 지금 비밀번호가 틀리면 401(403 도 로그인 확인처럼 틀린 것으로) + * → current-password-rejected. 그 밖의 실패는 바뀌지 않은 것이 확실하다 (MAIL_001 · REQ_001) + * 2) 같은 자격으로 `x:AccountPassword/set` 에 지금 비밀번호(currentSecret)와 새 비밀번호(secret)를 보낸다 + * (계정 id 는 세션의 `primaryAccounts['urn:stalwart:jmap']`, `using` 에 그 기능을 넣는다) + * - 엔진이 바꾸기를 거절함(forbidden · 지금 비밀번호 속성 거절 · 그사이 401) → current-password-refused (warn 로그: 가린 주소 · 종류) + * - 요청을 받지 않음(그 밖의 4xx · 메서드 오류 · 그 밖의 SetError) → MAIL_001, 엔진 한도(429) → REQ_001 — 바뀌지 않았다 + * - 응답을 받기 전에 끝남(시간 초과 · 연결 끊김) · 5xx · 해석할 수 없는 응답 → outcome-unknown (warn 로그) — 엔진이 바꿨을 수 있다 + * 비밀번호·인증 헤더·엔진의 설명 문구는 어떤 경우에도 로그·오류 context 에 남기지 않는다. + */ + async changePassword( + email: string, + currentPassword: string, + newPassword: string, + ): Promise { + const authorization = basicAuthorization(email, currentPassword); + + const session = await this.exchange( + SESSION_PATH, + { headers: { authorization } }, + REQUEST_TIMEOUT_MS, + 'password-session', + async (response) => { + if ( + response.status === HTTP_UNAUTHORIZED || + response.status === HTTP_FORBIDDEN + ) { + await discard(response); + return null; + } + await this.assertOk(response, 'password-session'); + return readJson(response, 'password-session'); + }, + ); + if (session === null) return 'current-password-rejected'; + + const accountId = session.primaryAccounts?.[ACCOUNT_MANAGEMENT_CAPABILITY]; + if (!accountId || !session.apiUrl) { + // 엔진이 그 기능을 알려 주지 않는다 — 엔진 판이 바뀌었거나 계정에 그 권한이 없다 + throw engineFailure({ + reason: 'password-capability-missing', + call: 'password-session', + }); + } + + const reply = await this.sendPasswordChange( + toRelativeReference(session.apiUrl), + authorization, + JSON.stringify({ + using: [CORE_CAPABILITY, ACCOUNT_MANAGEMENT_CAPABILITY], + methodCalls: [ + [ + PASSWORD_SET_METHOD, + { + accountId, + update: { + [PASSWORD_OBJECT_ID]: { + currentSecret: currentPassword, + secret: newPassword, + }, + }, + }, + 'p', + ], + ], + }), + ); + + switch (reply.kind) { + case 'changed': + case 'new-password-rejected': + return reply.kind; + case 'current-password-refused': + this.logger.warn( + `[AUTH_005]${traceTag()} 지금 비밀번호로 들어갔는데 메일 엔진이 비밀번호 바꾸기를 거절했습니다 — 계정의 비밀번호 변경 권한을 확인하세요: user=${maskEmail(email)}, jmapType=${reply.detail}`, + ); + return 'current-password-refused'; + case 'unknown': + this.logger.warn( + `[MAIL_001]${traceTag()} 비밀번호 바꾸기 요청의 결과를 알 수 없습니다 — 메일 엔진이 바꿨을 수 있습니다: user=${maskEmail(email)}, 원인=${reply.detail}`, + ); + return 'outcome-unknown'; + case 'failed': + throw engineFailure({ + reason: 'password-change-failed', + call: 'password-change', + detail: reply.detail, + }); + } + } + + /** + * 바꾸기 요청(2단계)을 보내고 응답을 해석한다 — 바뀌었는지 알 수 없게 끝난 것(응답 전에 끊김 · 5xx · 해석할 수 없는 본문)은 + * 던지지 않고 unknown 으로 돌려준다. 바뀌지 않은 것이 확실한 실패만 던진다 (4xx → MAIL_001, 429 → REQ_001) + */ + private async sendPasswordChange( + path: string, + authorization: string, + body: string, + ): Promise { + // 응답 머리를 받았는가 — 받기 전에 끝난 실패(시간 초과 · 연결 끊김)는 엔진에 닿았을 수 있다 + const progress = { answered: false }; + try { + return await this.exchange( + path, + { + method: 'POST', + headers: { authorization, 'content-type': 'application/json' }, + body, + }, + REQUEST_TIMEOUT_MS, + 'password-change', + async (response): Promise => { + progress.answered = true; + // 세션을 연 뒤 지금 비밀번호가 다른 곳에서 바뀌었다 — 맞혀 보기가 아니다 + if (response.status === HTTP_UNAUTHORIZED) { + await discard(response); + return { kind: 'current-password-refused', detail: 'http-401' }; + } + if (response.status >= HTTP_SERVER_ERROR) { + await discard(response); + return { kind: 'unknown', detail: `http-${response.status}` }; + } + // 그 밖의 4xx 는 요청을 받지 않았다 — 403 은 방금 그 자격으로 세션이 열렸으므로 비밀번호가 아니라 권한·설정 문제다 + if (!response.ok && response.status !== HTTP_TOO_MANY_REQUESTS) { + const problem = await readProblem(response); + throw engineFailure({ + reason: 'http-error', + call: 'password-change', + status: response.status, + jmapType: problem?.type ?? 'unknown', + }); + } + await this.assertOk(response, 'password-change'); + let parsed: JmapResponseBody; + try { + parsed = (await response.json()) as JmapResponseBody; + } catch { + return { kind: 'unknown', detail: 'malformed-response' }; + } + return classifyPasswordChange(parsed.methodResponses?.[0]); + }, + ); + } catch (error) { + if ( + !progress.answered && + error instanceof BusinessException && + error.code === 'MAIL_001' + ) { + // exchange 가 붙인 사유 — timeout · network + const reason: unknown = error.context?.reason; + return { + kind: 'unknown', + detail: typeof reason === 'string' ? reason : 'no-response', + }; + } + throw error; + } + } + + /** + * 대리 접근 세션 — 직원별로 잠깐 캐시한다. `fresh` 면 캐시를 건너뛰고 계정 상태를 다시 확인한다. + * `timeoutMs` 는 캐시에 없어 엔진에 물을 때의 제한 시간 (기본 10초) + */ async getSession( user: string, - options: { fresh?: boolean } = {}, + options: { fresh?: boolean; timeoutMs?: number } = {}, ): Promise { // 막는 계정(관리자 등)은 엔진에 대리 접근을 요청하지도 않는다 this.assertNotBlocked(user); @@ -279,7 +517,7 @@ export class JmapClient { const session = await this.exchange( SESSION_PATH, { headers: { authorization: this.impersonation(user) } }, - REQUEST_TIMEOUT_MS, + options.timeoutMs ?? REQUEST_TIMEOUT_MS, 'session', async (response) => { await this.assertImpersonationAccepted(response, user); @@ -372,13 +610,23 @@ export class JmapClient { /** * JMAP 메서드를 호출한다. accountId 는 세션에서 정해지므로 호출 목록을 함수로 받는다. * 메서드 단위 오류(`["error", …]`)는 그대로 돌려주고, 해석은 호출한 쪽이 한다. + * 제한 시간(`timeoutMs`)을 받으면 세션 조회(캐시에 없을 때)까지 그 시간 안에서 한다 — 한 요청의 시간 상한 + * (발송 · 메일함의 오래 걸리는 처리)을 세션 조회가 넘기지 않게. 받지 않으면 세션 조회 · 호출이 각각 기본 10초다 */ async call( user: string, build: (accountId: string) => JmapInvocation[], options: JmapCallOptions = {}, ): Promise { - const session = await this.getSession(user); + const deadline = + options.timeoutMs === undefined ? null : Date.now() + options.timeoutMs; + const session = await this.getSession(user, { + timeoutMs: options.timeoutMs, + }); + const timeoutMs = + deadline === null + ? REQUEST_TIMEOUT_MS + : Math.max(1, deadline - Date.now()); const body = JSON.stringify({ using: [...JMAP_USING, ...(options.capabilities ?? [])], methodCalls: build(session.accountId), @@ -394,7 +642,7 @@ export class JmapClient { }, body, }, - options.timeoutMs ?? REQUEST_TIMEOUT_MS, + timeoutMs, 'api', async (response) => { await this.assertImpersonationAccepted(response, user); diff --git a/backend/src/modules/mail/jmap/jmap.mapper.ts b/backend/src/modules/mail/jmap/jmap.mapper.ts index 2476c7e..6d75af2 100644 --- a/backend/src/modules/mail/jmap/jmap.mapper.ts +++ b/backend/src/modules/mail/jmap/jmap.mapper.ts @@ -7,6 +7,7 @@ import type { Mailbox, MailboxRole, MailUsage, + MailVacation, MessageDetailSource, MessageSummary, } from '@/modules/mail/mail.types'; @@ -17,6 +18,7 @@ import type { JmapIdentity, JmapMailbox, JmapQuota, + JmapVacationResponse, } from './jmap.types'; /** @@ -331,6 +333,49 @@ export function toUsage(quota: JmapQuota): MailUsage { }; } +/** + * 부재중 자동 응답 → 우리 모양 (계약 2절 "부재중 자동 응답"). + * 엔진에 없는 값(null)의 제목·내용은 빈 문자열, 날짜는 ISO 8601 UTC 로 — 한 번도 설정하지 않은 계정은 + * `{ isEnabled: false, fromDate: null, toDate: null, subject: '', textBody: '' }` 가 된다. 서식 본문(htmlBody)은 쓰지 않는다. + */ +export function toVacation( + response: JmapVacationResponse | undefined, +): MailVacation { + return { + isEnabled: response?.isEnabled === true, + fromDate: toUtcIso(response?.fromDate), + toDate: toUtcIso(response?.toDate), + subject: response?.subject ?? '', + textBody: response?.textBody ?? '', + }; +} + +/** + * 날짜를 JMAP UTCDate 로 — 초 단위까지, 소수 초 없이 'Z' 로 끝낸다 + * (RFC 8620 §1.4: 소수 초가 0 이면 쓰지 않는다. 화면이 보내는 값은 0시라 잃는 것이 없다) + */ +export function toJmapUtcDate(iso: string): string { + return new Date(Date.parse(iso)).toISOString().replace(/\.\d+Z$/, 'Z'); +} + +/** + * 우리 모양 → VacationResponse 전체 교체 값. 빈 제목·내용은 null(엔진의 "정하지 않음" — 끈 채로 저장할 때만 비어 있다). + * 서식 본문은 늘 비운다 — 다른 메일 프로그램이 넣어 둔 것도 지운다 (글자만 보낸다, 계약 2절) + */ +export function toJmapVacation( + vacation: MailVacation, +): Record { + return { + isEnabled: vacation.isEnabled, + fromDate: + vacation.fromDate === null ? null : toJmapUtcDate(vacation.fromDate), + toDate: vacation.toDate === null ? null : toJmapUtcDate(vacation.toDate), + subject: vacation.subject === '' ? null : vacation.subject, + textBody: vacation.textBody === '' ? null : vacation.textBody, + htmlBody: null, + }; +} + /** 발신 ID — 직원 주소와 같은 것을 고르고, 없으면 첫 번째 */ export function pickIdentity( identities: JmapIdentity[], diff --git a/backend/src/modules/mail/jmap/jmap.types.ts b/backend/src/modules/mail/jmap/jmap.types.ts index 7803871..bc41b54 100644 --- a/backend/src/modules/mail/jmap/jmap.types.ts +++ b/backend/src/modules/mail/jmap/jmap.types.ts @@ -63,6 +63,8 @@ export interface JmapBodyValue { export interface JmapEmail { id: string; + /** 받은 그대로의 원문(message/rfc822) blob — 원문 내려받기에 쓴다 */ + blobId?: string; threadId?: string; mailboxIds?: Record; keywords?: Record; @@ -95,6 +97,18 @@ export interface JmapMailbox { unreadEmails?: number; } +/** RFC 8621 §8 VacationResponse — 계정마다 하나(id `singleton`) */ +export interface JmapVacationResponse { + id?: string; + isEnabled?: boolean; + /** UTCDate 또는 null */ + fromDate?: string | null; + toDate?: string | null; + subject?: string | null; + textBody?: string | null; + htmlBody?: string | null; +} + export interface JmapIdentity { id: string; name?: string; diff --git a/backend/src/modules/mail/mail.constants.ts b/backend/src/modules/mail/mail.constants.ts index a0c1a31..2006c7a 100644 --- a/backend/src/modules/mail/mail.constants.ts +++ b/backend/src/modules/mail/mail.constants.ts @@ -59,6 +59,28 @@ export const SEARCH_WORD_SEPARATOR = /\s+/u; */ export const MAX_BULK_MESSAGE_IDS = 100; +/** + * 메일함 이름 최대 길이 — 앞뒤 공백을 뺀 JS 문자열 길이 (계약 2절 "메일함 관리"). 넘으면 400 VAL_001 (dto/mailbox.dto.ts) + */ +export const MAX_MAILBOX_NAME_LENGTH = 64; + +/** 부재중 자동 응답의 제목 · 내용 최대 길이 — JS 문자열 길이 (계약 2절 "부재중 자동 응답"). 켤 때는 둘 다 1자 이상 */ +export const MAX_VACATION_SUBJECT_LENGTH = 200; +export const MAX_VACATION_TEXT_LENGTH = 4_000; + +/** + * 한 줄 글자 — 제어 문자(C0 · DEL · C1 — 줄바꿈 · 탭 포함)가 없다. 메일함 이름 · 부재중 자동 응답 제목이 쓴다 (계약 2절) + */ +// eslint-disable-next-line no-control-regex +export const SINGLE_LINE_TEXT_PATTERN = /^[^\u0000-\u001f\u007f-\u009f]*$/; + +/** + * 여러 줄 글자 — 제어 문자 가운데 탭 · 줄바꿈(\t \n \r)만 있어도 된다 (C0 의 나머지 · DEL · C1 은 없다). 부재중 자동 응답 내용이 쓴다 + */ +export const MULTI_LINE_TEXT_PATTERN = + // eslint-disable-next-line no-control-regex + /^[^\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f-\u009f]*$/; + /** 받는 사람 추천의 거르는 글자(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 1417191..19dbeae 100644 --- a/backend/src/modules/mail/mail.module.ts +++ b/backend/src/modules/mail/mail.module.ts @@ -29,6 +29,7 @@ import { MailboxController, MailQuotaController, MailStateController, + MailVacationController, } from './mailbox.controller'; import { MessageDeletionController } from './message-deletion.controller'; import { MessageController } from './message.controller'; @@ -103,6 +104,7 @@ function attachmentUploadOptions(config: ConfigService): MulterModuleOptions { MailboxController, MailQuotaController, MailStateController, + MailVacationController, MessageController, MessageDeletionController, MailAttachmentController, diff --git a/backend/src/modules/mail/mail.provider.ts b/backend/src/modules/mail/mail.provider.ts index a4c1499..8702424 100644 --- a/backend/src/modules/mail/mail.provider.ts +++ b/backend/src/modules/mail/mail.provider.ts @@ -6,16 +6,22 @@ import type { MailAccountProfile, MailAttachment, Mailbox, + MailboxBulkResult, + MailboxCreate, + MailboxDeletion, MailState, MailUsage, + MailVacation, MessageDeletionResult, MessageDetailSource, MessageListQuery, MessagePage, + MessageSource, MessagesPatchResult, MessageSummary, MessageUpdate, OutgoingMessage, + PasswordChangeOutcome, SavedDraft, SentMessageRecord, UploadedFile, @@ -41,6 +47,18 @@ export interface MailProvider { */ verifyCredentials(email: string, password: string): Promise; + /** + * 직원 **본인의 자격으로**(지금 비밀번호) 비밀번호를 바꾼다 — 게이트웨이 대리 접근·관리자 권한을 쓰지 않는다 (계약 1절). + * 엔진의 판단(틀림 · 거절 · 규칙 위반)과 결과를 알 수 없음(바꿨을 수 있다)은 결과로 돌려주고, + * 결과가 확실한 엔진 장애(바뀌지 않았다)는 MAIL_001(한도는 REQ_001)로 던진다 (PasswordChangeOutcome). + * 비밀번호는 저장하지 않고 어떤 로그에도 남기지 않는다. + */ + changePassword( + email: string, + currentPassword: string, + newPassword: string, + ): Promise; + /** * 직원 메일함 프로필(표시 이름). `fresh` 면 캐시를 건너뛰고 계정이 살아 있는지 다시 확인한다. * 계정이 사라졌거나, 로그인을 막는 계정(관리자 등)이거나, 메일 엔진의 관리 권한이 있는 계정이면 AUTH_001. @@ -53,6 +71,38 @@ export interface MailProvider { listMailboxes(user: string): Promise; + /** + * 메일함을 만든다 (계약 2절 "메일함 관리") — 이름 형식은 요청 검증(dto/mailbox.dto.ts)이 본다. + * 상위가 없으면 RES_001, 만들 수 없는 자리(보낸편지함 · 임시보관함 · 스팸메일함 · 휴지통 · 그 밖의 역할 메일함 아래)면 VAL_001, + * 같은 상위 안에 같은 이름이 있으면 MAIL_009. + */ + createMailbox(user: string, input: MailboxCreate): Promise; + + /** 메일함 이름을 바꾼다 — 없으면 RES_001, 역할이 있는 메일함(기본 메일함)이면 VAL_001, 같은 이름이 있으면 MAIL_009 */ + renameMailbox(user: string, id: string, name: string): Promise; + + /** + * 메일함을 지운다 — 안의 메일을 휴지통으로 옮긴 뒤(다른 메일함에도 있는 메일은 이 메일함에서만 뺀다) 빈 메일함을 지운다. + * 메일을 영구 삭제하지 않는다. 한 요청에 다 옮기지 못하면 deleted: false (클라이언트가 다시 보낸다). + * 없으면 RES_001, 역할이 있는 메일함 · 하위 메일함이 있으면 VAL_001. + */ + deleteMailbox(user: string, id: string): Promise; + + /** 메일함의 안 읽은 메일을 읽음으로 바꾼다 (한 요청의 시간 안에서 차례로) — 없으면 RES_001 */ + markMailboxRead(user: string, id: string): Promise; + + /** + * 휴지통 · 스팸메일함을 비운다 (영구 삭제) — 그 메일함에만 있는 메일은 지우고, 다른 메일함에도 있는 메일은 그 메일함에서만 뺀다. + * 확인한 상태 그대로일 때만 지운다(deleteFromTrash 와 같은 규칙). 없으면 RES_001, 휴지통 · 스팸메일함이 아니면 VAL_001. + */ + emptyMailbox(user: string, id: string): Promise; + + /** 부재중 자동 응답 — 엔진 값이 없으면(null) 제목 · 내용은 빈 문자열 */ + getVacation(user: string): Promise; + + /** 부재중 자동 응답을 통째로 바꾸고(서식 본문은 비운다) 다시 읽은 값을 돌려준다 — 형식은 요청 검증이, 켤 때의 빈 값 · 날짜 순서는 서비스가 본다 */ + setVacation(user: string, vacation: MailVacation): Promise; + /** 메일함 사용량과 한도 (한도가 없으면 limitBytes = null) */ getUsage(user: string): Promise; @@ -126,6 +176,9 @@ export interface MailProvider { uploadAttachment(user: string, file: UploadedFile): Promise; + /** 받은 그대로의 메일 원문(엔진의 원본)을 흘려보낸다 — 없는 메일은 RES_001 */ + openMessageSource(user: string, id: string): Promise; + /** 그 메일에 실제로 있는 첨부만 내려준다 (스트리밍) */ openAttachment( user: string, diff --git a/backend/src/modules/mail/mail.service.spec.ts b/backend/src/modules/mail/mail.service.spec.ts index cf84411..e9d834d 100644 --- a/backend/src/modules/mail/mail.service.spec.ts +++ b/backend/src/modules/mail/mail.service.spec.ts @@ -1,4 +1,5 @@ import { Logger } from '@nestjs/common'; +import { Readable } from 'stream'; import { startCpuTimer } from '@/../test/cpu-time'; import { BusinessException } from '@/common/errors/business.exception'; @@ -51,8 +52,17 @@ function fakeProvider(): ProviderMocks { return { verifyCredentials: mockOf<'verifyCredentials'>(), + changePassword: mockOf<'changePassword'>(), getProfile: mockOf<'getProfile'>(), listMailboxes: mockOf<'listMailboxes'>(), + createMailbox: mockOf<'createMailbox'>(), + renameMailbox: mockOf<'renameMailbox'>(), + deleteMailbox: mockOf<'deleteMailbox'>(), + markMailboxRead: mockOf<'markMailboxRead'>(), + emptyMailbox: mockOf<'emptyMailbox'>(), + getVacation: mockOf<'getVacation'>(), + setVacation: mockOf<'setVacation'>(), + openMessageSource: mockOf<'openMessageSource'>(), getUsage: mockOf<'getUsage'>(), getAttachmentsTotalLimit: mockOf<'getAttachmentsTotalLimit'>().mockResolvedValue(25 * 1024 * 1024), @@ -426,6 +436,386 @@ describe('MailService', () => { }); }); + describe('메일함 관리 · 부재중 자동 응답 · 스팸 · 원문 (4단계)', () => { + const mailbox = { + id: 'n1', + name: '거래처', + role: null, + parentId: null, + sortOrder: 0, + totalCount: 0, + unreadCount: 0, + }; + + it('메일함 만들기 — 상위가 없으면 맨 위(null)로 넘기고, 이름 바꾸기와 함께 표시 규칙을 거친 메일함을 돌려준다', async () => { + provider.createMailbox.mockResolvedValue(mailbox); + provider.renameMailbox.mockResolvedValue({ + ...mailbox, + id: 'a', + name: 'Inbox', + role: 'inbox', + }); + + await expect( + service.createMailbox(user, { name: '거래처' }), + ).resolves.toEqual(mailbox); + await service.createMailbox(user, { name: '거래처', parentId: 'a' }); + expect(provider.createMailbox.mock.calls).toEqual([ + [user.email, { name: '거래처', parentId: null }], + [user.email, { name: '거래처', parentId: 'a' }], + ]); + + // (어댑터가 막지만) 역할이 있는 메일함은 한국어 표준 이름으로 보인다 + await expect( + service.renameMailbox(user, 'a', { name: '새 이름' }), + ).resolves.toMatchObject({ name: '받은편지함' }); + expect(provider.renameMailbox).toHaveBeenCalledWith( + user.email, + 'a', + '새 이름', + ); + }); + + it('메일함 지우기 — 지웠을 때만 개수를 남긴다 (메일함 이름 · 주소 없이)', async () => { + provider.deleteMailbox.mockResolvedValueOnce({ + deleted: false, + processed: 500, + movedToTrash: 500, + remaining: 700, + }); + await service.deleteMailbox(user, 'u4'); + expect(logSpy).not.toHaveBeenCalled(); + + provider.deleteMailbox.mockResolvedValueOnce({ + deleted: true, + processed: 9, + movedToTrash: 7, + remaining: 0, + }); + await expect(service.deleteMailbox(user, 'u4')).resolves.toEqual({ + deleted: true, + processed: 9, + movedToTrash: 7, + remaining: 0, + }); + expect(logSpy).toHaveBeenCalledTimes(1); + const [line] = logSpy.mock.calls[0] as [string]; + expect(line).toContain('휴지통으로 옮김=7통'); + expect(line).toContain('이 메일함에서만 뺌=2통'); + expect(line).not.toContain(user.email); + }); + + it('전체 읽음은 { isRead: true } 를 어댑터의 전체 읽음으로 — 로그를 남기지 않는다', async () => { + provider.markMailboxRead.mockResolvedValue({ + processed: 3, + remaining: 0, + }); + + await expect( + service.updateMailboxMessages(user, 'a', { isRead: true }), + ).resolves.toEqual({ processed: 3, remaining: 0 }); + expect(provider.markMailboxRead).toHaveBeenCalledWith(user.email, 'a'); + expect(logSpy).not.toHaveBeenCalled(); + }); + + it('비우기 — 지운 것이 있으면 개수만 남긴다 (되돌릴 수 없는 일)', async () => { + provider.emptyMailbox.mockResolvedValueOnce({ + processed: 0, + remaining: 0, + }); + await service.emptyMailbox(user, 'b'); + expect(logSpy).not.toHaveBeenCalled(); + + provider.emptyMailbox.mockResolvedValueOnce({ + processed: 500, + remaining: 12, + }); + await service.emptyMailbox(user, 'b'); + const [line] = logSpy.mock.calls[0] as [string]; + expect(line).toContain('지움=500통'); + expect(line).toContain('남음=12통'); + expect(line).not.toContain(user.email); + }); + + describe('같은 메일함의 오래 걸리는 처리는 하나씩만', () => { + /** 손으로 끝내는 처리 — 끝낼 때까지 진행 중이다 */ + function pending(): { + promise: Promise; + finish: (value: T) => void; + fail: (error: Error) => void; + } { + let finish!: (value: T) => void; + let fail!: (error: Error) => void; + const promise = new Promise((resolve, reject) => { + finish = resolve; + fail = reject; + }); + return { promise, finish, fail }; + } + const emptied = { processed: 2, remaining: 0 }; + + it('진행 중이면 종류가 달라도(비우기 · 전체 읽음 · 지우기) 엔진에 묻지 않고 바로 REQ_001 — 끝나면 다시 받는다', async () => { + const first = pending<{ processed: number; remaining: number }>(); + provider.emptyMailbox.mockReturnValueOnce(first.promise); + + const running = service.emptyMailbox(user, 'b'); + for (const attempt of [ + () => service.emptyMailbox(user, 'b'), + () => service.updateMailboxMessages(user, 'b', { isRead: true }), + () => service.deleteMailbox(user, 'b'), + ]) { + expect(await failureOf(attempt)).toEqual({ + code: 'REQ_001', + reason: 'mailbox-operation-in-progress', + }); + } + expect(provider.emptyMailbox).toHaveBeenCalledTimes(1); + expect(provider.markMailboxRead).not.toHaveBeenCalled(); + expect(provider.deleteMailbox).not.toHaveBeenCalled(); + + first.finish(emptied); + await expect(running).resolves.toEqual(emptied); + + provider.emptyMailbox.mockResolvedValueOnce(emptied); + await expect(service.emptyMailbox(user, 'b')).resolves.toEqual(emptied); + }); + + it('다른 메일함 · 다른 직원은 동시에 된다', async () => { + const first = pending<{ processed: number; remaining: number }>(); + provider.emptyMailbox.mockReturnValueOnce(first.promise); + provider.markMailboxRead.mockResolvedValue({ + processed: 1, + remaining: 0, + }); + + const running = service.emptyMailbox(user, 'b'); + await expect( + service.updateMailboxMessages(user, 'a', { isRead: true }), + ).resolves.toEqual({ processed: 1, remaining: 0 }); + await expect( + service.updateMailboxMessages( + { email: 'doyun.lee@arasystem.test', name: '이도윤' }, + 'b', + { isRead: true }, + ), + ).resolves.toEqual({ processed: 1, remaining: 0 }); + + first.finish(emptied); + await running; + }); + + it('실패로 끝나도 푼다', async () => { + const first = pending<{ processed: number; remaining: number }>(); + provider.emptyMailbox.mockReturnValueOnce(first.promise); + + const running = service.emptyMailbox(user, 'b'); + first.fail(new BusinessException('MAIL_001')); + expect(await failureOf(() => running)).toMatchObject({ + code: 'MAIL_001', + }); + + provider.emptyMailbox.mockResolvedValueOnce(emptied); + await expect(service.emptyMailbox(user, 'b')).resolves.toEqual(emptied); + }); + }); + + describe('부재중 자동 응답 저장', () => { + const vacation = { + isEnabled: true, + fromDate: '2026-11-02T15:00:00.000Z', + toDate: '2026-11-07T15:00:00.000Z', + subject: '부재중입니다', + textBody: '11월 7일까지 휴가입니다.', + }; + + it('형식 규칙을 지나면 그대로 넘기고 엔진이 저장한 값을 돌려준다 — 로그에는 켬 · 끔만', async () => { + provider.setVacation.mockImplementation((_user, value) => + Promise.resolve(value), + ); + + await expect(service.updateVacation(user, vacation)).resolves.toEqual( + vacation, + ); + expect(provider.setVacation).toHaveBeenCalledWith(user.email, vacation); + const [line] = logSpy.mock.calls[0] as [string]; + expect(line).toContain('켬'); + expect(line).not.toContain(vacation.subject); + expect(line).not.toContain(vacation.textBody); + expect(line).not.toContain(user.email); + }); + + it('켤 때 제목 · 내용이 비면 VAL_001 — 끈 채로는 빈 값도 저장한다 (한 번도 설정하지 않은 값을 그대로 되돌려 놓을 수 있게)', async () => { + for (const blank of [{ subject: '' }, { textBody: '' }]) { + expect( + await failureOf(() => + service.updateVacation(user, { ...vacation, ...blank }), + ), + ).toEqual({ + code: 'VAL_001', + reason: 'vacation-enabled-without-text', + }); + } + expect(provider.setVacation).not.toHaveBeenCalled(); + + const neverConfigured = { + isEnabled: false, + fromDate: null, + toDate: null, + subject: '', + textBody: '', + }; + provider.setVacation.mockResolvedValue(neverConfigured); + await expect( + service.updateVacation(user, neverConfigured), + ).resolves.toEqual(neverConfigured); + }); + + it('켤 때 앞뒤 공백을 빼고 빈 제목 · 내용도 VAL_001 — 끈 채로는 받고, 저장은 쓴 그대로(공백을 지우지 않는다)', async () => { + for (const blank of [{ subject: ' ' }, { textBody: ' \n\t ' }]) { + expect( + await failureOf(() => + service.updateVacation(user, { ...vacation, ...blank }), + ), + ).toEqual({ + code: 'VAL_001', + reason: 'vacation-enabled-without-text', + }); + } + expect(provider.setVacation).not.toHaveBeenCalled(); + + provider.setVacation.mockImplementation((_user, value) => + Promise.resolve(value), + ); + await service.updateVacation(user, { + ...vacation, + isEnabled: false, + subject: ' ', + }); + await service.updateVacation(user, { + ...vacation, + subject: ' 부재중입니다 ', + textBody: '\n휴가입니다.\n', + }); + expect( + provider.setVacation.mock.calls.map(([, value]) => value), + ).toEqual([ + { ...vacation, isEnabled: false, subject: ' ' }, + { + ...vacation, + subject: ' 부재중입니다 ', + textBody: '\n휴가입니다.\n', + }, + ]); + }); + + it('날짜는 초 단위로 맞춰 넘기고, 순서도 맞춘 값으로 본다 — 같은 초 안의 두 시각은 VAL_001', async () => { + expect( + await failureOf(() => + service.updateVacation(user, { + ...vacation, + fromDate: '2026-11-02T15:00:00.100Z', + toDate: '2026-11-02T15:00:00.900Z', + }), + ), + ).toEqual({ code: 'VAL_001', reason: 'vacation-dates-reversed' }); + expect(provider.setVacation).not.toHaveBeenCalled(); + + provider.setVacation.mockImplementation((_user, value) => + Promise.resolve(value), + ); + await service.updateVacation(user, { + ...vacation, + fromDate: '2026-11-02T15:00:00.999Z', + toDate: '2026-11-02T15:00:01.000Z', + }); + expect(provider.setVacation).toHaveBeenCalledWith(user.email, { + ...vacation, + fromDate: '2026-11-02T15:00:00.000Z', + toDate: '2026-11-02T15:00:01.000Z', + }); + }); + + it('시작 · 끝이 둘 다 있으면 시작이 끝보다 앞이어야 한다 (같아도 VAL_001) — 한쪽만 있으면 따지지 않는다', async () => { + for (const toDate of [vacation.fromDate, '2026-11-01T15:00:00.000Z']) { + expect( + await failureOf(() => + service.updateVacation(user, { ...vacation, toDate }), + ), + ).toEqual({ code: 'VAL_001', reason: 'vacation-dates-reversed' }); + } + expect(provider.setVacation).not.toHaveBeenCalled(); + + provider.setVacation.mockImplementation((_user, value) => + Promise.resolve(value), + ); + await service.updateVacation(user, { ...vacation, toDate: null }); + await service.updateVacation(user, { ...vacation, fromDate: null }); + expect(provider.setVacation).toHaveBeenCalledTimes(2); + }); + }); + + it('스팸 표시만 바꾸는 PATCH 도 받는다 — 한 통 · 여러 통 모두 isJunk 를 그대로 넘긴다', async () => { + provider.updateMessage.mockResolvedValue({ + id: 'm1', + threadId: 't1', + mailboxIds: ['c'], + from: null, + to: [], + subject: '', + preview: '', + receivedAt: '2026-10-28T01:00:00.000Z', + isRead: true, + isFlagged: false, + hasAttachment: false, + size: 1, + }); + provider.updateMessages.mockResolvedValue({ + updatedIds: ['m1'], + failedIds: [], + }); + + await service.updateMessage(user, 'm1', { isJunk: true }); + await service.updateMessages(user, { + ids: ['m1'], + mailboxId: 'a', + isJunk: false, + }); + + expect(provider.updateMessage).toHaveBeenCalledWith(user.email, 'm1', { + isJunk: true, + }); + expect(provider.updateMessages).toHaveBeenCalledWith(user.email, ['m1'], { + mailboxId: 'a', + isJunk: false, + }); + }); + + it('원문 내려받기 — 파일 이름은 제목(없으면 "메일") + .eml, 흐름과 크기는 그대로', async () => { + const stream = Readable.from([Buffer.from('From: a\r\n\r\nbody')]); + provider.openMessageSource.mockResolvedValueOnce({ + stream, + subject: 'Re: 견적서/수정본', + size: 15, + }); + + await expect(service.openMessageSource(user, 'm1')).resolves.toEqual({ + stream, + name: 'Re_ 견적서_수정본.eml', + size: 15, + }); + expect(provider.openMessageSource).toHaveBeenCalledWith(user.email, 'm1'); + + provider.openMessageSource.mockResolvedValueOnce({ + stream, + subject: '', + size: null, + }); + await expect( + service.openMessageSource(user, 'm2'), + ).resolves.toMatchObject({ name: '메일.eml', size: null }); + }); + }); + 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 6478106..a2b5a13 100644 --- a/backend/src/modules/mail/mail.service.ts +++ b/backend/src/modules/mail/mail.service.ts @@ -10,10 +10,12 @@ import { SIGNATURE_IMAGES_PER_SIGNATURE } from '@/modules/mail-signature/mail-si import { MailSignatureService } from '@/modules/mail-signature/mail-signature.service'; import { SIGNATURE_IMAGE_EXTENSIONS } from '@/modules/mail-signature/signature-image-format'; import { traceTag } from '@/shared/logger/request-context'; +import { toWholeSecondIso } from '@/shared/utils/format'; import { maskEmail } from '@/shared/utils/mask'; import { settledWithin } from '@/shared/utils/settle'; import { MAX_ATTACHMENTS_PER_MESSAGE, + messageSourceFileName, resolveAttachmentType, sanitizeFileName, } from './attachment.policy'; @@ -21,6 +23,12 @@ import type { MessageDeletionDto, UpdateMessagesDto, } from './dto/bulk-messages.dto'; +import type { MailVacationDto } from './dto/mail-vacation.dto'; +import type { + CreateMailboxDto, + MailboxMessagesPatchDto, + RenameMailboxDto, +} from './dto/mailbox.dto'; import type { MessageListQueryDto } from './dto/message-list-query.dto'; import type { ComposeContentDto, @@ -62,8 +70,11 @@ import type { MailAddress, MailAttachment, Mailbox, + MailboxBulkResult, + MailboxDeletion, MailQuota, MailState, + MailVacation, MessageAttachment, MessageDeletionResult, MessageDetail, @@ -75,7 +86,7 @@ import type { OutgoingMessage, SavedDraft, } from './mail.types'; -import { presentMailboxes } from './mailbox.presenter'; +import { presentMailbox, presentMailboxes } from './mailbox.presenter'; import { RecipientSuggestionService } from './recipient-suggestions.service'; import { idempotentMessageId, @@ -170,19 +181,20 @@ function assertHtmlBytes(html: string): void { } } -/** 읽음 · 중요 표시 · 이동 — 하나 이상이어야 한다 (빈 요청은 VAL_001). 한 통·여러 통 PATCH 가 같이 쓴다 */ +/** 읽음 · 중요 표시 · 이동 · 스팸 표시 — 하나 이상이어야 한다 (빈 요청은 VAL_001). 한 통·여러 통 PATCH 가 같이 쓴다 */ function toMessageUpdate(dto: UpdateMessageDto): MessageUpdate { - const { isRead, isFlagged, mailboxId } = dto; + const { isRead, isFlagged, mailboxId, isJunk } = dto; if ( isRead === undefined && isFlagged === undefined && - mailboxId === undefined + mailboxId === undefined && + isJunk === undefined ) { throw new BusinessException('VAL_001', { context: { reason: 'empty-update' }, }); } - return { isRead, isFlagged, mailboxId }; + return { isRead, isFlagged, mailboxId, isJunk }; } /** 서명 그림의 Content-ID — 같은 그림이면 늘 같은 값이다 (발송 지문이 cid 가 든 서식 본문을 본다) */ @@ -206,6 +218,13 @@ export class MailService { */ private readonly sendsInProgress = new Set(); + /** + * 진행 중인 오래 걸리는 처리(전체 읽음 · 비우기 · 지우기)의 (사용자, 메일함) — 같은 메일함에는 하나씩만, 종류가 달라도 막는다. + * 두 탭이 같은 휴지통을 비우면 서로 상태가 어긋나 둘 다 멈추고, 동시 요청이 많으면 엔진 호출이 그 수만큼 곱절로 는다. + * ⚠️ 프로세스 메모리다. 인스턴스를 늘리면 Redis 잠금(SET NX PX)으로 옮겨야 인스턴스 사이에서도 하나씩이 된다. + */ + private readonly mailboxOperations = new Set(); + constructor( @Inject(MAIL_PROVIDER) private readonly provider: MailProvider, @Inject(MAIL_SETTINGS) private readonly settings: MailSettings, @@ -249,6 +268,153 @@ export class MailService { return this.provider.getMailState(user.email); } + /** + * 메일함 만들기 (계약 2절 "메일함 관리") — 이름은 요청 검증이 앞뒤 공백을 빼고 형식을 본 값이다. + * 상위가 없으면 RES_001, 만들 수 없는 자리면 VAL_001, 같은 이름이면 MAIL_009 (어댑터) + */ + async createMailbox( + user: AuthenticatedUser, + dto: CreateMailboxDto, + ): Promise { + return presentMailbox( + await this.provider.createMailbox(user.email, { + name: dto.name, + parentId: dto.parentId ?? null, + }), + ); + } + + /** 메일함 이름 바꾸기 — 기본 메일함(역할 있음)은 VAL_001 */ + async renameMailbox( + user: AuthenticatedUser, + id: string, + dto: RenameMailboxDto, + ): Promise { + return presentMailbox( + await this.provider.renameMailbox(user.email, id, dto.name), + ); + } + + /** + * 메일함 지우기 — 안의 메일을 휴지통으로 옮긴 뒤 지운다(영구 삭제하지 않는다). 한 요청에 끝나지 않으면 deleted: false. + * 같은 메일함의 오래 걸리는 처리는 하나씩만 (진행 중이면 REQ_001 — runExclusive). + * 지웠으면 info 로그에 개수만 남긴다 (메일함 이름 없이 — 사용자가 정한 글이다) + */ + async deleteMailbox( + user: AuthenticatedUser, + id: string, + ): Promise { + const result = await this.runExclusive(user, id, () => + this.provider.deleteMailbox(user.email, id), + ); + if (result.deleted) { + this.logger.log( + `${traceTag()} 메일함을 지웠습니다: user=${maskEmail(user.email)}, 이번 요청에서 휴지통으로 옮김=${result.movedToTrash}통 · 이 메일함에서만 뺌=${result.processed - result.movedToTrash}통`, + ); + } + return result; + } + + /** + * 메일함의 메일을 한꺼번에 바꾼다 — 지금은 전체 읽음({ isRead: true })뿐이다 (요청 검증이 그 밖을 막는다). + * 안 읽은 메일을 차례로 읽음으로 바꾸고 남은 수를 돌려준다 (요청마다 로그를 남기지 않는다 — 되돌릴 수 있는 표시다). + * 같은 메일함의 오래 걸리는 처리는 하나씩만 (runExclusive) + */ + async updateMailboxMessages( + user: AuthenticatedUser, + id: string, + dto: MailboxMessagesPatchDto, + ): Promise { + if (dto.isRead !== true) { + throw new BusinessException('VAL_001', { + context: { reason: 'unsupported-mailbox-messages-change' }, + }); + } + return this.runExclusive(user, id, () => + this.provider.markMailboxRead(user.email, id), + ); + } + + /** + * 휴지통 · 스팸메일함 비우기(영구 삭제) — 되돌릴 수 없는 일이라 지운 것이 있으면 개수만 남긴다. + * 같은 메일함의 오래 걸리는 처리는 하나씩만 (runExclusive) + */ + async emptyMailbox( + user: AuthenticatedUser, + id: string, + ): Promise { + const result = await this.runExclusive(user, id, () => + this.provider.emptyMailbox(user.email, id), + ); + if (result.processed > 0) { + this.logger.log( + `${traceTag()} 메일함을 비웠습니다(영구 삭제): user=${maskEmail(user.email)}, 지움=${result.processed}통, 남음=${result.remaining}통`, + ); + } + return result; + } + + /** 부재중 자동 응답 (계약 2절 "부재중 자동 응답") */ + getVacation(user: AuthenticatedUser): Promise { + return this.provider.getVacation(user.email); + } + + /** + * 부재중 자동 응답 저장(전체 교체) — 엔진이 저장한 값을 다시 읽어 돌려준다. + * - 켤 때는 제목 · 내용이 앞뒤 공백을 빼고 1자 이상 (저장은 쓴 그대로 — 지우지 않는다). + * 끈 채로는 빈 값도 저장한다 — 한 번도 설정하지 않은 값을 그대로 되돌려 놓을 수 있게 + * - 날짜는 초 단위로 맞춰 엔진에 보낸다(소수 초를 버린다 — 엔진의 UTCDate). 시작 · 끝이 둘 다 있으면 **맞춘 값으로** 시작 < 끝 + * 어긋나면 엔진에 묻지 않고 VAL_001. 형식(길이 · 제어 문자 · 날짜 모양)은 요청 검증이 본다. 로그에는 켬 · 끔만 (제목 · 내용 없이) + */ + async updateVacation( + user: AuthenticatedUser, + dto: MailVacationDto, + ): Promise { + const vacation: MailVacation = { + isEnabled: dto.isEnabled, + fromDate: dto.fromDate === null ? null : toWholeSecondIso(dto.fromDate), + toDate: dto.toDate === null ? null : toWholeSecondIso(dto.toDate), + subject: dto.subject, + textBody: dto.textBody, + }; + if ( + vacation.isEnabled && + (vacation.subject.trim() === '' || vacation.textBody.trim() === '') + ) { + throw new BusinessException('VAL_001', { + context: { reason: 'vacation-enabled-without-text' }, + }); + } + if ( + vacation.fromDate !== null && + vacation.toDate !== null && + Date.parse(vacation.fromDate) >= Date.parse(vacation.toDate) + ) { + throw new BusinessException('VAL_001', { + context: { reason: 'vacation-dates-reversed' }, + }); + } + + const saved = await this.provider.setVacation(user.email, vacation); + this.logger.log( + `${traceTag()} 부재중 자동 응답을 저장했습니다: user=${maskEmail(user.email)}, ${saved.isEnabled ? '켬' : '끔'}`, + ); + return saved; + } + + /** 받은 그대로의 메일 원문 — 파일 이름은 제목(없으면 "메일") + .eml (attachment.policy.ts 의 messageSourceFileName) */ + async openMessageSource( + user: AuthenticatedUser, + id: string, + ): Promise { + const source = await this.provider.openMessageSource(user.email, id); + return { + stream: source.stream, + name: messageSourceFileName(source.subject), + size: source.size, + }; + } + /** * 메일 목록 · 검색 — mailboxId 와 검색어(q) 중 하나는 있어야 한다 (둘 다 없으면 VAL_001). * mailboxId 가 없으면 휴지통·스팸메일함을 뺀 모든 메일함에서 찾는다 (계약 2절 "검색"). 검색어는 어떤 로그에도 남기지 않는다. @@ -464,6 +630,32 @@ export class MailService { }; } + /** + * 같은 직원 · 같은 메일함의 오래 걸리는 처리를 하나씩만 한다 — 진행 중이면 엔진에 묻지 않고 바로 REQ_001 + * (로그인의 진행 중 시도와 같은 모양). 끝나면 성공 · 실패와 상관없이 푼다. 다른 메일함끼리는 동시에 된다 + */ + private async runExclusive( + user: AuthenticatedUser, + mailboxId: string, + operation: () => Promise, + ): Promise { + const key = `${user.email.toLowerCase()}\n${mailboxId}`; + if (this.mailboxOperations.has(key)) { + throw new BusinessException('REQ_001', { + context: { + reason: 'mailbox-operation-in-progress', + user: maskEmail(user.email), + }, + }); + } + this.mailboxOperations.add(key); + try { + return await operation(); + } finally { + this.mailboxOperations.delete(key); + } + } + /** 같은 키로는 한 번만 보낸다 — 처리 중이면 REQ_001, 이미 보냈으면 그 결과, 내용이 다르면 MAIL_005 */ private async deliverOnce( user: AuthenticatedUser, diff --git a/backend/src/modules/mail/mail.types.ts b/backend/src/modules/mail/mail.types.ts index 36768ce..add15fc 100644 --- a/backend/src/modules/mail/mail.types.ts +++ b/backend/src/modules/mail/mail.types.ts @@ -146,8 +146,82 @@ export interface MessageUpdate { readonly isFlagged?: boolean; /** 옮길 메일함 — 지정하면 이 메일함 하나에만 속하게 된다 */ readonly mailboxId?: string; + /** + * 스팸 신고(true) · 스팸 아님(false) — 키워드 `$junk` · `$notjunk` 를 서로 반대로 둔다 (RFC 8621 4.1.1, 계약 2절 "스팸 · 원문"). + * 메일함 이동과는 따로다 — 화면이 mailboxId 와 함께 보낸다 + */ + readonly isJunk?: boolean; } +/** 메일함 만들기 (계약 2절 "메일함 관리") — 이름은 서비스가 검사한 값이다 */ +export interface MailboxCreate { + /** 앞뒤 공백을 뺀 1~64자, `/` 없음 */ + readonly name: string; + /** 상위 메일함 — null 이면 맨 위 */ + readonly parentId: string | null; +} + +/** + * 메일함 지우기 결과 (DELETE /api/mailboxes/{id}) — 안의 메일을 휴지통으로 옮긴 뒤 빈 메일함을 지운다. + * 한 요청에 다 옮기지 못하면 deleted: false — 클라이언트가 같은 요청을 다시 보낸다. + */ +export interface MailboxDeletion { + /** true = 지웠다 */ + readonly deleted: boolean; + /** 이번 요청에서 이 메일함에서 빠진 메일 수 — 휴지통으로 옮긴 것 + 다른 메일함에도 있어 이 메일함에서만 뺀 것 (진행 수) */ + readonly processed: number; + /** 이번 요청에서 휴지통으로 옮긴 메일 수 (다른 메일함에도 있어 이 메일함에서만 뺀 메일은 세지 않는다) */ + readonly movedToTrash: number; + /** 아직 이 메일함에 남은 메일 수 */ + readonly remaining: number; +} + +/** 메일함 전체 읽음 · 비우기 결과 — remaining 이 0 이 될 때까지 클라이언트가 같은 요청을 다시 보낸다 */ +export interface MailboxBulkResult { + /** 이번 요청에서 읽음으로 바꾼 · 지운(그 메일함에서 뺀 것 포함) 메일 수 */ + readonly processed: number; + readonly remaining: number; +} + +/** 부재중 자동 응답 (계약 2절 "부재중 자동 응답") — 엔진의 VacationResponse 하나 */ +export interface MailVacation { + readonly isEnabled: boolean; + /** ISO 8601 UTC — null 이면 켠 때부터 */ + readonly fromDate: string | null; + /** ISO 8601 UTC — null 이면 끌 때까지 */ + readonly toDate: string | null; + /** 엔진에 없으면(null) 빈 문자열 */ + readonly subject: string; + /** 글자만(서식 없음) — 엔진에 없으면(null) 빈 문자열 */ + readonly textBody: string; +} + +/** 받은 그대로의 메일 원문 (GET /api/messages/{id}/source) — 파일 이름은 서비스가 제목으로 정한다 */ +export interface MessageSource { + readonly stream: Readable; + /** 제목 — 없으면 빈 문자열 */ + readonly subject: string; + /** 알 수 있으면 바이트 수 */ + readonly size: number | null; +} + +/** + * 비밀번호 바꾸기의 엔진 판단 (계약 1절 "PUT /api/auth/password"). + * 결과가 확실한 엔진 장애(바뀌지 않았다)는 결과가 아니라 실패(MAIL_001 · REQ_001)로 던진다. + * - changed: 바뀌었다 + * - current-password-rejected: 지금 비밀번호로 엔진에 들어가지 못했다 (틀림) — 맞혀 보기라 로그인 실패처럼 센다 + * - current-password-refused: 지금 비밀번호로 들어가기는 했는데 엔진이 바꾸기를 거절했다 + * (지금 비밀번호 확인 거절 · 변경 권한 없음 · 그사이 다른 곳에서 바뀜) — 맞혀 보기가 아니다 + * - new-password-rejected: 새 비밀번호가 엔진 규칙에 어긋난다 (8자 미만 등) — 지금 비밀번호는 맞았다 + * - outcome-unknown: 바꾸기 요청을 보낸 뒤 결과를 알 수 없게 끝났다 (시간 초과 · 연결 끊김 · 5xx · 해석할 수 없는 응답) — 엔진이 바꿨을 수 있다 + */ +export type PasswordChangeOutcome = + | 'changed' + | 'current-password-rejected' + | 'current-password-refused' + | 'new-password-rejected' + | 'outcome-unknown'; + /** 새 메일 확인 (계약 2절 "새 메일 확인") — 메일함이나 메일이 하나라도 바뀌면 달라지는 불투명 값 */ export interface MailState { readonly state: string; diff --git a/backend/src/modules/mail/mailbox.controller.ts b/backend/src/modules/mail/mailbox.controller.ts index b1940b0..4ed6089 100644 --- a/backend/src/modules/mail/mailbox.controller.ts +++ b/backend/src/modules/mail/mailbox.controller.ts @@ -1,4 +1,16 @@ -import { Controller, Get, Header, UseGuards } from '@nestjs/common'; +import { + Body, + Controller, + Delete, + Get, + Header, + HttpStatus, + Param, + Patch, + Post, + Put, + UseGuards, +} from '@nestjs/common'; import { ApiCookieAuth, ApiOperation, ApiTags } from '@nestjs/swagger'; import { @@ -11,12 +23,37 @@ import { } from '@/common/decorators/current-user.decorator'; import { JwtAuthGuard } from '@/common/guards/jwt-auth.guard'; import { + MailboxBulkResultDto, + MailboxDeletionDto, + MailboxDto, MailboxPageDto, MailQuotaDto, MailStateDto, } from './dto/mail-response.dto'; +import { MailVacationDto } from './dto/mail-vacation.dto'; +import { + CreateMailboxDto, + MailboxMessagesPatchDto, + RenameMailboxDto, +} from './dto/mailbox.dto'; +import { MailIdPipe } from './mail-id.pipe'; +import { MAX_MAILBOX_NAME_LENGTH } from './mail.constants'; import { MailService } from './mail.service'; +/** 오래 걸리는 처리(전체 읽음 · 비우기 · 지우기)의 안내 — 계약 2절 "메일함 관리" */ +const BULK_NOTE = + '한 요청을 25초 안에 끝내고 남은 수를 돌려준다 — 클라이언트는 남은 것이 없을 때까지 같은 요청을 다시 보낸다. ' + + '도중에 실패하면 거기까지 처리했다 — 다시 보내면 남은 것만 처리한다. ' + + '같은 직원 · 같은 메일함에는 하나씩만 — 처리 중이면(종류가 달라도) 엔진에 묻지 않고 429 REQ_001. 다른 메일함은 동시에 된다.'; + +/** 오래 걸리는 처리가 진행 중일 때의 안내 */ +const BULK_IN_PROGRESS = + '같은 메일함의 오래 걸리는 처리(전체 읽음 · 비우기 · 지우기)가 처리 중 — 엔진에 묻지 않았다'; + +/** 오래 걸리는 처리가 하나도 처리하지 못하고 멈췄을 때의 안내 */ +const BULK_NO_PROGRESS = + '메일 엔진 장애 · 하나도 처리하지 못하고 멈춤 (확인하는 사이 계속 바뀜 · 엔진이 거절 없이 바꾸지 않음 · 25초 안에 끝나지 않음)'; + @ApiTags('Mail') @ApiCookieAuth() @UseGuards(JwtAuthGuard) @@ -38,6 +75,151 @@ export class MailboxController { list(@CurrentUser() user: AuthenticatedUser) { return this.mail.listMailboxes(user); } + + @Post() + @ApiOperation({ + summary: '메일함 만들기', + description: + `이름은 앞뒤 공백을 뺀 1~${MAX_MAILBOX_NAME_LENGTH}자, "/" · 제어 문자는 쓸 수 없다. ` + + '맨 위, 받은편지함 · 보관함 · 내 메일함(역할 없음) 아래에 만들 수 있다. 다른 메일 프로그램(IMAP)에도 보이게 구독한 메일함으로 만든다.', + }) + @ApiStandardResponse(MailboxDto, { + status: HttpStatus.CREATED, + description: '만든 메일함', + }) + @ApiErrorResponse( + 'VAL_001', + '이름 형식 · 만들 수 없는 자리(보낸편지함 · 임시보관함 · 스팸메일함 · 휴지통 아래) · 모르는 필드', + ) + @ApiErrorResponse('RES_001', '없는 상위 메일함 (parentId)') + @ApiErrorResponse('MAIL_009', '같은 상위 안에 같은 이름의 메일함이 있다') + create( + @CurrentUser() user: AuthenticatedUser, + @Body() dto: CreateMailboxDto, + ) { + return this.mail.createMailbox(user, dto); + } + + @Patch(':id') + @ApiOperation({ + summary: '메일함 이름 바꾸기', + description: + '기본 메일함(역할이 있는 메일함 — 받은편지함 · 보낸편지함 등)은 바꿀 수 없다. 다른 상위로 옮기기는 하지 않는다.', + }) + @ApiStandardResponse(MailboxDto, { description: '바뀐 메일함' }) + @ApiErrorResponse('VAL_001', '기본 메일함 · 이름 형식 · 모르는 필드') + @ApiErrorResponse('RES_001', '없는 메일함 (형식이 틀린 id 포함)') + @ApiErrorResponse('MAIL_009', '같은 상위 안에 같은 이름의 메일함이 있다') + rename( + @CurrentUser() user: AuthenticatedUser, + @Param('id', MailIdPipe) id: string, + @Body() dto: RenameMailboxDto, + ) { + return this.mail.renameMailbox(user, id, dto); + } + + @Delete(':id') + @ApiOperation({ + summary: '메일함 지우기 — 안의 메일을 휴지통으로 옮긴 뒤 지운다', + description: + '메일을 영구 삭제하지 않는다 — 이 메일함에만 있는 메일은 휴지통으로 옮기고, 다른 메일함에도 있는 메일은 이 메일함에서만 뺀 뒤 빈 메일함을 지운다. ' + + `옮기는 동안 들어온 메일도 옮긴다. ${BULK_NOTE} (deleted: false 면 다시 보낸다)`, + }) + @ApiStandardResponse(MailboxDeletionDto, { + description: + '지웠다(deleted: true) · 아직 옮길 메일이 남았다(deleted: false)', + }) + @ApiErrorResponse('VAL_001', '기본 메일함 · 하위 메일함이 있음') + @ApiErrorResponse('RES_001', '없는 메일함 (형식이 틀린 id 포함)') + @ApiErrorResponse('REQ_001', BULK_IN_PROGRESS) + @ApiErrorResponse('MAIL_001', BULK_NO_PROGRESS) + remove( + @CurrentUser() user: AuthenticatedUser, + @Param('id', MailIdPipe) id: string, + ) { + return this.mail.deleteMailbox(user, id); + } + + @Patch(':id/messages') + @ApiOperation({ + summary: '메일함 전체를 읽음으로', + description: `안 읽은 메일을 500통씩 읽음으로 바꾼다. ${BULK_NOTE}`, + }) + @ApiStandardResponse(MailboxBulkResultDto, { + description: '이번 요청에서 읽음으로 바꾼 수 · 남은 수', + }) + @ApiErrorResponse('VAL_001', '본문이 { isRead: true } 가 아님') + @ApiErrorResponse('RES_001', '없는 메일함 (형식이 틀린 id 포함)') + @ApiErrorResponse('REQ_001', BULK_IN_PROGRESS) + @ApiErrorResponse('MAIL_001', BULK_NO_PROGRESS) + updateMessages( + @CurrentUser() user: AuthenticatedUser, + @Param('id', MailIdPipe) id: string, + @Body() dto: MailboxMessagesPatchDto, + ) { + return this.mail.updateMailboxMessages(user, id, dto); + } + + @Delete(':id/messages') + @ApiOperation({ + summary: '휴지통 · 스팸메일함 비우기 (영구 삭제)', + description: + '500통씩 확인한 상태 그대로일 때만 영구 삭제한다 — 그 메일함에만 있는 메일은 지우고, 다른 메일함에도 있는 메일은 그 메일함에서만 뺀다 ' + + `(POST /api/message-deletions 와 같은 규칙). 확인하는 사이 계속 바뀌어 하나도 지우지 못하면 503 MAIL_001 (지우지 않았다). ${BULK_NOTE}`, + }) + @ApiStandardResponse(MailboxBulkResultDto, { + description: '이번 요청에서 지운 수 · 남은 수', + }) + @ApiErrorResponse('VAL_001', '휴지통 · 스팸메일함이 아님') + @ApiErrorResponse('RES_001', '없는 메일함 (형식이 틀린 id 포함)') + @ApiErrorResponse('REQ_001', BULK_IN_PROGRESS) + @ApiErrorResponse('MAIL_001', BULK_NO_PROGRESS) + empty( + @CurrentUser() user: AuthenticatedUser, + @Param('id', MailIdPipe) id: string, + ) { + return this.mail.emptyMailbox(user, id); + } +} + +// 부재중 자동 응답 (계약 2절 "부재중 자동 응답") — 엔진의 VacationResponse 하나를 그대로 쓴다 +@ApiTags('Mail') +@ApiCookieAuth() +@UseGuards(JwtAuthGuard) +@ApiErrorResponse('AUTH_001', '로그인 필요 (쿠키 없음·만료)') +@ApiErrorResponse('MAIL_001', '메일 엔진 장애') +@Controller('mail-vacation') +export class MailVacationController { + constructor(private readonly mail: MailService) {} + + @Get() + @ApiOperation({ + summary: '부재중 자동 응답', + description: + '한 번도 설정하지 않았으면 { isEnabled: false, fromDate: null, toDate: null, subject: "", textBody: "" } 다.', + }) + @ApiStandardResponse(MailVacationDto, { description: '지금 설정' }) + get(@CurrentUser() user: AuthenticatedUser) { + return this.mail.getVacation(user); + } + + @Put() + @ApiOperation({ + summary: '부재중 자동 응답 저장 (전체 교체)', + description: + '다섯 필드를 모두 보낸다(날짜는 null 가능). 켤 때는 제목 · 내용이 앞뒤 공백을 빼고 1자 이상(저장은 쓴 그대로), ' + + '시작 · 끝이 둘 다 있으면 시작이 끝보다 앞이어야 한다 — 날짜는 소수 초를 버린 값으로 저장하고, 순서도 그 값으로 본다. ' + + '제목에는 제어 문자(탭 · 줄바꿈 포함)를, 내용에는 탭 · 줄바꿈 밖의 제어 문자를 쓸 수 없다. ' + + '꺼도 쓴 제목 · 내용은 남겨 둔다. 서식 본문은 쓰지 않는다. 엔진이 저장한 값을 다시 읽어 돌려준다.', + }) + @ApiStandardResponse(MailVacationDto, { description: '저장한 설정' }) + @ApiErrorResponse( + 'VAL_001', + '형식 · 길이(제목 200자 · 내용 4,000자) · 제어 문자 · 켤 때 공백뿐인 제목 · 내용 · 시작이 끝보다 늦거나 같음(소수 초를 버린 값으로) · 모르는 필드', + ) + update(@CurrentUser() user: AuthenticatedUser, @Body() dto: MailVacationDto) { + return this.mail.updateVacation(user, dto); + } } @ApiTags('Mail') diff --git a/backend/src/modules/mail/mailbox.presenter.spec.ts b/backend/src/modules/mail/mailbox.presenter.spec.ts index c6d5d4c..2338b9f 100644 --- a/backend/src/modules/mail/mailbox.presenter.spec.ts +++ b/backend/src/modules/mail/mailbox.presenter.spec.ts @@ -1,5 +1,5 @@ import type { Mailbox, MailboxRole } from './mail.types'; -import { presentMailboxes } from './mailbox.presenter'; +import { presentMailbox, presentMailboxes } from './mailbox.presenter'; const box = ( id: string, @@ -70,3 +70,13 @@ describe('presentMailboxes', () => { expect(input[0].name).toBe('Inbox'); }); }); + +describe('presentMailbox', () => { + it('메일함 하나 — 역할이 있으면 한국어 표준 이름, 없으면 서버의 이름 그대로 (만들기 · 이름 바꾸기 응답)', () => { + expect(presentMailbox(box('f', 'Archive', 'archive'))).toMatchObject({ + name: '보관함', + }); + const mine = box('u1', '거래처', null); + expect(presentMailbox(mine)).toBe(mine); + }); +}); diff --git a/backend/src/modules/mail/mailbox.presenter.ts b/backend/src/modules/mail/mailbox.presenter.ts index 630eaaf..a6df722 100644 --- a/backend/src/modules/mail/mailbox.presenter.ts +++ b/backend/src/modules/mail/mailbox.presenter.ts @@ -36,11 +36,14 @@ function compareMailboxes(a: Mailbox, b: Mailbox): number { ); } +/** 메일함 하나의 표시 이름 — 역할이 있으면 한국어 표준 이름 (만들기 · 이름 바꾸기 응답) */ +export function presentMailbox(mailbox: Mailbox): Mailbox { + return mailbox.role + ? { ...mailbox, name: ROLE_NAMES[mailbox.role] } + : mailbox; +} + /** 표시 이름을 붙이고 정렬한다 (원본 배열은 바꾸지 않는다) */ export function presentMailboxes(mailboxes: readonly Mailbox[]): Mailbox[] { - return mailboxes - .map((mailbox) => - mailbox.role ? { ...mailbox, name: ROLE_NAMES[mailbox.role] } : mailbox, - ) - .sort(compareMailboxes); + return mailboxes.map(presentMailbox).sort(compareMailboxes); } diff --git a/backend/src/modules/mail/message.controller.ts b/backend/src/modules/mail/message.controller.ts index 4ea8b05..b8337b3 100644 --- a/backend/src/modules/mail/message.controller.ts +++ b/backend/src/modules/mail/message.controller.ts @@ -61,11 +61,15 @@ import { SendMessageDto } from './dto/send-message.dto'; import { UpdateMessageDto } from './dto/update-message.dto'; import { MailIdPipe } from './mail-id.pipe'; import { MailService } from './mail.service'; +import type { AttachmentContent } from './mail.types'; import { IDEMPOTENCY_KEY_HEADER } from './send-idempotency'; /** 첨부는 항상 내려받게 한다 — 브라우저가 내용을 해석해 그리지 않도록 형식을 서버가 정한다 */ const ATTACHMENT_CONTENT_TYPE = 'application/octet-stream'; +/** 메일 원문의 형식 (계약 2절 "스팸 · 원문") — attachment 로 내려주므로 브라우저가 열어 그리지 않는다 */ +const MESSAGE_SOURCE_CONTENT_TYPE = 'message/rfc822'; + @ApiTags('Mail') @ApiCookieAuth() @UseGuards(JwtAuthGuard) @@ -110,9 +114,10 @@ export class MessageController { @Patch() @ApiOperation({ - summary: '여러 통의 읽음 · 중요 표시 · 메일함 이동 (하나 이상 지정)', + summary: + '여러 통의 읽음 · 중요 표시 · 메일함 이동 · 스팸 표시 (하나 이상 지정)', description: - `ids 는 1~${MAX_BULK_MESSAGE_IDS}개. 메일마다의 변경은 한 통 PATCH 와 같다(이동 = 그 메일함 하나에만). 엔진 요청 한 번으로 처리한다. ` + + `ids 는 1~${MAX_BULK_MESSAGE_IDS}개. 메일마다의 변경은 한 통 PATCH 와 같다(이동 = 그 메일함 하나에만, isJunk = $junk · $notjunk). 엔진 요청 한 번으로 처리한다. ` + '바꾸지 못한 메일(없음 · 그사이 지워짐 · 엔진이 거절)은 failedIds 로 돌려주고 나머지는 그대로 처리한다. ' + '없는 mailboxId 면 아무것도 바꾸지 않고 404 RES_001. 503 MAIL_001 이면 일부만 바뀌었을 수 있다 — 같은 요청을 다시 보내도 결과가 같다(멱등).', }) @@ -154,7 +159,11 @@ export class MessageController { } @Patch(':id') - @ApiOperation({ summary: '읽음 · 중요 표시 · 메일함 이동 (하나 이상 지정)' }) + @ApiOperation({ + summary: '읽음 · 중요 표시 · 메일함 이동 · 스팸 표시 (하나 이상 지정)', + description: + 'isJunk 는 키워드 $junk · $notjunk 를 서로 반대로 둔다(스팸 신고 true · 스팸 아님 false). 이동과 함께 보내면 같은 변경 하나로 처리한다.', + }) @ApiStandardResponse(MessageSummaryDto, { description: '바뀐 메일 요약' }) @ApiErrorResponse('RES_001', '없는 메일') @ApiErrorResponse('VAL_001', '빈 요청·잘못된 값·없는 메일함으로 이동') @@ -263,10 +272,53 @@ export class MessageController { @Res({ passthrough: true }) res: Response, ): Promise { const content = await this.mail.openAttachment(user, id, attachmentId); + return this.streamDownload(res, content, ATTACHMENT_CONTENT_TYPE, '첨부'); + } + + @Get(':id/source') + @SkipTransform() + @ApiOperation({ + summary: '받은 그대로의 메일 원문 내려받기 (표준 래퍼 없음)', + description: + '메일 엔진의 원본을 그대로 흘려보낸다 — message/rfc822 + Content-Disposition: attachment (파일 이름은 제목, 없으면 "메일" + .eml). ' + + 'Cache-Control: private, no-store. 화면은 첨부 내려받기처럼 이동 전에 세션을 확인한다.', + }) + @ApiProduces(MESSAGE_SOURCE_CONTENT_TYPE) + @ApiResponse({ + status: HttpStatus.OK, + description: '메일 원문 (.eml)', + schema: { type: 'string', format: 'binary' }, + }) + @ApiErrorResponse('RES_001', '없는 메일 (형식이 틀린 id 포함)') + @ApiErrorResponse('MAIL_001', '메일 엔진 장애') + async source( + @CurrentUser() user: AuthenticatedUser, + @Param('id', MailIdPipe) id: string, + @Res({ passthrough: true }) res: Response, + ): Promise { + const content = await this.mail.openMessageSource(user, id); + return this.streamDownload( + res, + content, + MESSAGE_SOURCE_CONTENT_TYPE, + '원문', + ); + } + + /** + * 메일 엔진에서 받은 파일을 내려받기로 흘려보낸다 (첨부 · 원문 공용) — 형식은 서버가 정하고 늘 attachment 로 내려준다. + * @param what 로그 문장의 대상 — '첨부' · '원문' + */ + private streamDownload( + res: Response, + content: AttachmentContent, + type: string, + what: string, + ): StreamableFile { // 스트림 오류는 요청 처리가 끝난 뒤의 이벤트에서 올 수 있다 — 추적 ID 를 지금 잡아 둔다 const traceId = currentRequestId() ?? 'unknown'; - // 메일 첨부는 공유 캐시에 남기지 않는다 + // 메일 내용은 공유 캐시에 남기지 않는다 res.setHeader('Cache-Control', 'private, no-store'); // 클라이언트가 중간에 끊으면 메일 엔진에서 받던 스트림도 닫는다 (pipe 는 원천을 닫지 않는다) res.once('close', () => { @@ -274,14 +326,14 @@ export class MessageController { }); const file = new StreamableFile(content.stream, { - type: ATTACHMENT_CONTENT_TYPE, + type, disposition: buildAttachmentDisposition(content.name), ...(content.size !== null ? { length: content.size } : {}), }); // 기본 처리기는 오류 메시지를 본문으로 보낸다 — 내부 정보가 새지 않게 연결만 끊는다 file.setErrorHandler((error) => { this.logger.error( - `[MAIL_001][${traceId}] 첨부 전송 중단: ${error.message}`, + `[MAIL_001][${traceId}] ${what} 전송 중단: ${error.message}`, ); res.destroy(); }); diff --git a/backend/src/shared/utils/format.spec.ts b/backend/src/shared/utils/format.spec.ts new file mode 100644 index 0000000..fb69fae --- /dev/null +++ b/backend/src/shared/utils/format.spec.ts @@ -0,0 +1,28 @@ +import { toWholeSecondIso } from './format'; + +describe('toWholeSecondIso', () => { + it('소수 초를 버리고 UTC ISO 문자열로 돌려준다 — 올리지 않는다', () => { + expect(toWholeSecondIso('2026-11-02T15:00:00.999Z')).toBe( + '2026-11-02T15:00:00.000Z', + ); + expect(toWholeSecondIso('2026-11-02T15:00:00.5Z')).toBe( + '2026-11-02T15:00:00.000Z', + ); + expect(toWholeSecondIso('2026-11-02T15:00:00Z')).toBe( + '2026-11-02T15:00:00.000Z', + ); + expect(toWholeSecondIso('2026-11-02T15:00:01.000Z')).toBe( + '2026-11-02T15:00:01.000Z', + ); + }); + + it('1970년 이전 시각도 앞쪽으로 버린다 (음수 시각에서 다음 초로 올라가지 않게)', () => { + expect(toWholeSecondIso('1969-12-31T23:59:59.500Z')).toBe( + '1969-12-31T23:59:59.000Z', + ); + }); + + it('시각으로 읽을 수 없는 값은 그대로 돌려준다 (형식 검사는 부르는 쪽 몫)', () => { + expect(toWholeSecondIso('내일')).toBe('내일'); + }); +}); diff --git a/backend/src/shared/utils/format.ts b/backend/src/shared/utils/format.ts index 72b69be..d7e2d07 100644 --- a/backend/src/shared/utils/format.ts +++ b/backend/src/shared/utils/format.ts @@ -11,3 +11,17 @@ export function formatDate(input: Date | string): string { const dd = String(date.getDate()).padStart(2, '0'); return `${yyyy}-${mm}-${dd}`; } + +/** 1초 (밀리초) */ +const SECOND_MS = 1_000; + +/** + * ISO 8601 시각을 초 단위로 맞춘다 — 소수 초를 버리고 UTC ISO 문자열('…:00.000Z')로 돌려준다. + * 초 단위로만 받는 곳(메일 엔진의 UTCDate 등)에 보낼 값과, 그 값으로 하는 비교를 같게 하려고 쓴다. + * 시각으로 읽을 수 없는 값은 그대로 돌려준다 (형식 검사는 부르는 쪽이 먼저 한다) + */ +export function toWholeSecondIso(iso: string): string { + const time = Date.parse(iso); + if (Number.isNaN(time)) return iso; + return new Date(Math.floor(time / SECOND_MS) * SECOND_MS).toISOString(); +} diff --git a/backend/test/app.e2e-spec.ts b/backend/test/app.e2e-spec.ts index b41effa..81afe30 100644 --- a/backend/test/app.e2e-spec.ts +++ b/backend/test/app.e2e-spec.ts @@ -5,6 +5,7 @@ import { createHash } from 'crypto'; import { existsSync, unlinkSync } from 'fs'; import type { Server } from 'http'; import { basename, join } from 'path'; +import { Readable } from 'stream'; import request from 'supertest'; import type { Response } from 'supertest'; import { App } from 'supertest/types'; @@ -28,11 +29,19 @@ import type { Correspondence, DraftContent, DraftSource, + Mailbox, + MailboxBulkResult, + MailboxCreate, + MailboxDeletion, + MailVacation, MessageDeletionResult, MessageListQuery, + MessageSource, MessagesPatchResult, + MessageSummary, MessageUpdate, OutgoingMessage, + PasswordChangeOutcome, SavedDraft, } from '@/modules/mail/mail.types'; import { @@ -43,6 +52,7 @@ import { } from '@/modules/mail-signature/mail-signature.constants'; import { MailSignatureRepository } from '@/modules/mail-signature/mail-signature.repository'; import { UPLOAD_DIR } from '@/modules/upload/upload.config'; +import { buildAttachmentDisposition } from '@/shared/utils/content-disposition'; import { pngBytes } from './image-fixtures'; /** @@ -116,6 +126,35 @@ const MISSING_MAILBOX = 'nomailbox'; /** 대역 메일 엔진이 "그사이 지워졌다"고 답하는 메일 id (failedIds · skippedIds 경로) */ const GONE_MESSAGE = 'gonemessage'; +/** 대역 메일 엔진이 "엔진 규칙에 어긋난다"고 답하는 새 비밀번호 (400 VAL_001 경로) */ +const ENGINE_REJECTED_PASSWORD = 'engine-rejects-this-one'; + +/** 대역 메일 엔진이 "지금 비밀번호로 들어갔지만 바꾸기는 거절했다"고 답하는 새 비밀번호 (400 AUTH_005 — 실패로 세지 않는다) */ +const ENGINE_REFUSED_PASSWORD = 'engine-refuses-this-change'; + +/** 대역 메일 엔진의 비밀번호 바꾸기가 결과를 알 수 없게 끝나는 새 비밀번호 (503 MAIL_001 — 다른 세션은 끊어 둔다) */ +const ENGINE_UNKNOWN_PASSWORD = 'engine-outcome-unknown'; + +/** 대역 메일 엔진이 "같은 상위 안에 같은 이름이 있다"고 답하는 메일함 이름 (409 MAIL_009 경로) */ +const TAKEN_MAILBOX_NAME = '이미 있는 이름'; + +/** 대역 메일 엔진의 비우기가 테스트가 문을 열 때까지 끝나지 않는 메일함 — 같은 메일함의 처리 중 잠금 확인용 */ +const SLOW_MAILBOX = 'slowbox'; + +/** 한 번도 설정하지 않은 계정의 부재중 자동 응답 (계약 2절) */ +const NEVER_CONFIGURED_VACATION: MailVacation = { + isEnabled: false, + fromDate: null, + toDate: null, + subject: '', + textBody: '', +}; + +/** 대역 메일 엔진이 돌려주는 메일 원문과 그 제목 */ +const RAW_MESSAGE_SUBJECT = 'Re: 견적서/10월'; +const RAW_MESSAGE = + 'From: doyun.lee@e2e.arasystem.test\r\nSubject: Re: 견적서/10월\r\n\r\n견적서를 보냅니다.\r\n'; + /** 대역 메일 엔진이 로그인 때 알려 주는 이름 — 없으면 EMPLOYEE.name */ const PROFILE_NAMES = new Map([ ['colleague.a@e2e.arasystem.test', '가나다'], @@ -194,6 +233,8 @@ describe('응답 계약 (e2e)', () => { * 열기 전에 온 확인은 모두 같은 문을 기다린다 (회귀로 여러 번 와도 한 번 열면 모두 풀린다). */ let slowEngineGate: Promise | null = null; + /** 대역 엔진의 SLOW_MAILBOX 비우기가 기다리는 문 — 테스트가 만들고 연다. null 이면 바로 답한다 */ + let slowMailboxGate: Promise | null = null; /** 대역 엔진의 임시 저장본 (id → 주인·내용) — 계약 2절 "임시 저장" */ const drafts = new Map< string, @@ -217,6 +258,17 @@ describe('응답 계약 (e2e)', () => { }[] = []; const trashDeletions: { user: string; ids: readonly string[] }[] = []; + /** 4단계 — 대역 엔진이 받은 비밀번호 바꾸기 · 메일함 관리 · 부재중 자동 응답 · 한 통 바꾸기 */ + const passwordChanges: { email: string; newPassword: string }[] = []; + const mailboxCalls: { user: string; action: string; args: unknown[] }[] = []; + const vacations = new Map(); + const vacationSaves: { user: string; vacation: MailVacation }[] = []; + const singleUpdates: { + user: string; + id: string; + update: MessageUpdate; + }[] = []; + /** 이 앱의 서명 키로 토큰을 만든다 (CI 처럼 JWT_SECRET 이 없으면 임시 키) */ const sign = (payload: object, expiresIn: number) => jwt.sign(payload, { @@ -370,6 +422,140 @@ describe('응답 계약 (e2e)', () => { skippedIds: ids.filter((id) => id === GONE_MESSAGE), }); }, + // 4단계 — 엔진 쪽 판단(본인 자격의 비밀번호 바꾸기 · 조건부로 옮기기 · 비우기)은 jmap-*.spec.ts 가 본다. 여기서는 받은 값만 흉내 낸다 + changePassword: ( + email: string, + currentPassword: string, + newPassword: string, + ): Promise => { + passwordChanges.push({ email, newPassword }); + if (currentPassword !== CORRECT_PASSWORD) { + return Promise.resolve('current-password-rejected'); + } + const outcomes = new Map([ + [ENGINE_REJECTED_PASSWORD, 'new-password-rejected'], + [ENGINE_REFUSED_PASSWORD, 'current-password-refused'], + [ENGINE_UNKNOWN_PASSWORD, 'outcome-unknown'], + ]); + return Promise.resolve(outcomes.get(newPassword) ?? 'changed'); + }, + createMailbox: ( + user: string, + input: MailboxCreate, + ): Promise => { + mailboxCalls.push({ user, action: 'create', args: [input] }); + if (input.name === TAKEN_MAILBOX_NAME) { + return Promise.reject( + new BusinessException('MAIL_009', { + context: { reason: 'e2e-mailbox-name-taken' }, + }), + ); + } + if (input.parentId === MISSING_MAILBOX) { + return Promise.reject( + new BusinessException('RES_001', { + context: { reason: 'e2e-parent-not-found' }, + }), + ); + } + return Promise.resolve({ + id: 'newbox', + name: input.name, + role: null, + parentId: input.parentId, + sortOrder: 0, + totalCount: 0, + unreadCount: 0, + }); + }, + renameMailbox: ( + user: string, + id: string, + name: string, + ): Promise => { + mailboxCalls.push({ user, action: 'rename', args: [id, name] }); + return Promise.resolve({ + id, + name, + role: null, + parentId: null, + sortOrder: 0, + totalCount: 3, + unreadCount: 1, + }); + }, + deleteMailbox: (user: string, id: string): Promise => { + mailboxCalls.push({ user, action: 'delete', args: [id] }); + return Promise.resolve({ + deleted: true, + processed: 4, + movedToTrash: 3, + remaining: 0, + }); + }, + markMailboxRead: ( + user: string, + id: string, + ): Promise => { + mailboxCalls.push({ user, action: 'mark-read', args: [id] }); + return Promise.resolve({ processed: 5, remaining: 0 }); + }, + emptyMailbox: async ( + user: string, + id: string, + ): Promise => { + mailboxCalls.push({ user, action: 'empty', args: [id] }); + if (id === SLOW_MAILBOX && slowMailboxGate) await slowMailboxGate; + return { processed: 2, remaining: 0 }; + }, + getVacation: (user: string): Promise => + Promise.resolve(vacations.get(user) ?? NEVER_CONFIGURED_VACATION), + setVacation: ( + user: string, + vacation: MailVacation, + ): Promise => { + vacationSaves.push({ user, vacation }); + vacations.set(user, vacation); + return Promise.resolve(vacation); + }, + openMessageSource: ( + _user: string, + id: string, + ): Promise => { + if (id === GONE_MESSAGE) { + return Promise.reject( + new BusinessException('RES_001', { + context: { reason: 'e2e-message-not-found' }, + }), + ); + } + return Promise.resolve({ + stream: Readable.from([Buffer.from(RAW_MESSAGE, 'utf8')]), + subject: RAW_MESSAGE_SUBJECT, + size: Buffer.byteLength(RAW_MESSAGE, 'utf8'), + }); + }, + updateMessage: ( + user: string, + id: string, + update: MessageUpdate, + ): Promise => { + singleUpdates.push({ user, id, update }); + return Promise.resolve({ + id, + threadId: id, + mailboxIds: [update.mailboxId ?? 'a'], + from: null, + to: [], + subject: '', + preview: '', + receivedAt: '2026-10-28T01:00:00.000Z', + isRead: false, + isFlagged: false, + hasAttachment: false, + size: 1, + }); + }, }) .overrideProvider(MAIL_SETTINGS) .useValue(TEST_MAIL_SETTINGS) @@ -752,6 +938,15 @@ describe('응답 계약 (e2e)', () => { ['get', '/api/messages?q=%ED%9A%8C%EC%9D%98'], ['patch', '/api/messages'], ['post', '/api/message-deletions'], + ['put', '/api/auth/password'], + ['post', '/api/mailboxes'], + ['patch', '/api/mailboxes/u1'], + ['delete', '/api/mailboxes/u1'], + ['patch', '/api/mailboxes/u1/messages'], + ['delete', '/api/mailboxes/u1/messages'], + ['get', '/api/mail-vacation'], + ['put', '/api/mail-vacation'], + ['get', '/api/messages/eaaaaab/source'], // 템플릿 업로드 예제도 로그인한 직원만 쓴다 ['post', '/api/files'], ] as const)('쿠키 없이 %s %s 는 401 AUTH_001', async (method, path) => { @@ -1669,6 +1864,585 @@ describe('응답 계약 (e2e)', () => { }); }); + describe('4단계 — 비밀번호 · 메일함 관리 · 부재중 자동 응답 · 스팸 · 원문', () => { + describe('비밀번호 바꾸기 (PUT /api/auth/password)', () => { + const NEW_PASSWORD = 'new-password-for-e2e'; + + /** 로그인한 두 쿠키로 보낸다 — 리프레시 쿠키(Path=/api/auth)가 이 세션을 알려 준다 */ + const changeWith = ( + session: { access: string; refresh: string }, + payload: object, + ip: string, + ) => + request(server) + .put('/api/auth/password') + .set('X-Forwarded-For', ip) + .set( + 'Cookie', + `access_token=${session.access}; refresh_token=${session.refresh}`, + ) + .send(payload); + + /** 액세스 쿠키만으로 보낸다 (형식 검사 · 한도 확인용) */ + const changeAs = (email: string, payload: object, ip: string) => + request(server) + .put('/api/auth/password') + .set('X-Forwarded-For', ip) + .set('Cookie', cookieOf(email)) + .send(payload); + + /** 리프레시 토큰이 가리키는 세션의 끝난 사유 */ + const revokedReasonOf = async (refreshToken: string) => { + const { sid } = jwt.decode<{ sid: string }>(refreshToken); + const rows: { revoked_reason: string | null }[] = + await dataSource.query( + 'SELECT revoked_reason FROM auth_sessions WHERE id = $1', + [sid], + ); + return rows[0]?.revoked_reason; + }; + + it('바뀌면 200 data null — 이 세션만 남기고 그 직원의 다른 세션을 끊는다 (쿠키는 그대로)', async () => { + const email = 'pw.change@e2e.arasystem.test'; + const mine = await login(email); + const other = await login(email); + + const res = await changeWith( + mine, + { currentPassword: CORRECT_PASSWORD, newPassword: NEW_PASSWORD }, + '198.51.100.200', + ).expect(200); + + expect(body(res)).toEqual({ success: true, data: null }); + expect(res.headers['set-cookie']).toBeUndefined(); + expect(passwordChanges.at(-1)).toEqual({ + email, + newPassword: NEW_PASSWORD, + }); + expect(await revokedReasonOf(other.refresh)).toBe('password-changed'); + expect(await revokedReasonOf(mine.refresh)).toBeNull(); + + await refreshWith(mine.refresh).expect(200); + const cut = await refreshWith(other.refresh).expect(401); + expect(body(cut).error?.code).toBe('AUTH_001'); + }); + + it('지금 비밀번호가 틀리면 401 이 아니라 400 AUTH_005 — 세션은 그대로 · 엔진 규칙에 어긋난 새 비밀번호는 400 VAL_001', async () => { + const mine = await login('pw.wrong@e2e.arasystem.test'); + + const wrong = await changeWith( + mine, + { currentPassword: 'wrong-password', newPassword: NEW_PASSWORD }, + '198.51.100.201', + ).expect(400); + expect(body(wrong).error?.code).toBe('AUTH_005'); + + const rejected = await changeWith( + mine, + { + currentPassword: CORRECT_PASSWORD, + newPassword: ENGINE_REJECTED_PASSWORD, + }, + '198.51.100.201', + ).expect(400); + expect(body(rejected).error?.code).toBe('VAL_001'); + + expect(await revokedReasonOf(mine.refresh)).toBeNull(); + await refreshWith(mine.refresh).expect(200); + }); + + it('지금 비밀번호로 들어갔는데 엔진이 바꾸기를 거절하면 400 AUTH_005 — 실패로 세지 않아 10번을 넘겨도 429 가 아니고, 세션은 그대로', async () => { + const email = 'pw.refused@e2e.arasystem.test'; + const mine = await login(email); + const other = await login(email); + + for (let i = 0; i < 12; i += 1) { + const res = await changeWith( + mine, + { + currentPassword: CORRECT_PASSWORD, + newPassword: ENGINE_REFUSED_PASSWORD, + }, + '198.51.100.240', + ).expect(400); + expect(body(res).error?.code).toBe('AUTH_005'); + } + + expect(await revokedReasonOf(other.refresh)).toBeNull(); + await refreshWith(other.refresh).expect(200); + }); + + it('결과를 알 수 없게 끝나면 503 MAIL_001 — 엔진이 바꿨을 수 있어 이 세션만 남기고 다른 세션은 끊어 둔다', async () => { + const email = 'pw.unknown@e2e.arasystem.test'; + const mine = await login(email); + const other = await login(email); + + const res = await changeWith( + mine, + { + currentPassword: CORRECT_PASSWORD, + newPassword: ENGINE_UNKNOWN_PASSWORD, + }, + '198.51.100.241', + ).expect(503); + + expect(body(res).error?.code).toBe('MAIL_001'); + expect(await revokedReasonOf(other.refresh)).toBe( + 'password-change-uncertain', + ); + expect(await revokedReasonOf(mine.refresh)).toBeNull(); + await refreshWith(mine.refresh).expect(200); + const cut = await refreshWith(other.refresh).expect(401); + expect(body(cut).error?.code).toBe('AUTH_001'); + }); + + it('형식 오류는 400 VAL_001 — 7자 · 129자 · 지금과 같음 · 빠진 필드 · 모르는 필드, 엔진에 묻지 않는다', async () => { + const before = passwordChanges.length; + + for (const payload of [ + { currentPassword: CORRECT_PASSWORD, newPassword: 'x'.repeat(7) }, + { currentPassword: CORRECT_PASSWORD, newPassword: 'x'.repeat(129) }, + { currentPassword: CORRECT_PASSWORD, newPassword: CORRECT_PASSWORD }, + { newPassword: NEW_PASSWORD }, + { currentPassword: '', newPassword: NEW_PASSWORD }, + { + currentPassword: CORRECT_PASSWORD, + newPassword: NEW_PASSWORD, + confirmPassword: NEW_PASSWORD, + }, + ]) { + const res = await changeAs( + 'pw.format@e2e.arasystem.test', + payload, + '198.51.100.202', + ).expect(400); + expect(body(res).error?.code).toBe('VAL_001'); + } + expect(passwordChanges).toHaveLength(before); + }); + + it('틀린 지금 비밀번호는 로그인 실패와 같은 한도 — 같은 주소로 10번이 넘으면 맞는 비밀번호도 429 REQ_001, 그 주소의 로그인도 막힌다', async () => { + const email = 'pw.locked@e2e.arasystem.test'; + for (let i = 0; i < 10; i += 1) { + const res = await changeAs( + email, + { currentPassword: 'wrong-password', newPassword: NEW_PASSWORD }, + `198.51.100.${210 + i}`, + ).expect(400); + expect(body(res).error?.code).toBe('AUTH_005'); + } + + const locked = await changeAs( + email, + { currentPassword: CORRECT_PASSWORD, newPassword: NEW_PASSWORD }, + '198.51.100.230', + ).expect(429); + expect(body(locked).error?.code).toBe('REQ_001'); + + const login = await request(server) + .post('/api/auth/login') + .set('X-Forwarded-For', '198.51.100.231') + .send({ email, password: CORRECT_PASSWORD }) + .expect(429); + expect(body(login).error?.code).toBe('REQ_001'); + }); + }); + + describe('메일함 관리 (/api/mailboxes)', () => { + const ME = 'box.me@e2e.arasystem.test'; + const send = ( + method: 'post' | 'patch' | 'delete', + path: string, + payload?: object, + ) => { + const req = request(server)[method](path).set('Cookie', cookieOf(ME)); + return payload === undefined ? req : req.send(payload); + }; + + it('만들기는 201 Mailbox — 이름은 앞뒤 공백을 빼고, 상위가 없으면 맨 위(null)로 엔진에 넘긴다', async () => { + const res = await send('post', '/api/mailboxes', { + name: ' 거래처 ', + }).expect(201); + + expect(body(res)).toEqual({ + success: true, + data: { + id: 'newbox', + name: '거래처', + role: null, + parentId: null, + sortOrder: 0, + totalCount: 0, + unreadCount: 0, + }, + }); + expect(mailboxCalls.at(-1)).toEqual({ + user: ME, + action: 'create', + args: [{ name: '거래처', parentId: null }], + }); + + await send('post', '/api/mailboxes', { + name: '하위', + parentId: 'a', + }).expect(201); + expect(mailboxCalls.at(-1)?.args).toEqual([ + { name: '하위', parentId: 'a' }, + ]); + }); + + it('이름 형식 · 상위 id 형식 · 모르는 필드는 400 VAL_001 — 엔진에 묻지 않는다', async () => { + const before = mailboxCalls.length; + + for (const payload of [ + {}, + { name: '' }, + { name: ' ' }, + { name: 'x'.repeat(65) }, + { name: '거래처/2026' }, + { name: '거래\t처' }, + { name: '거래처', parentId: '../a' }, + { name: '거래처', role: 'inbox' }, + ]) { + const res = await send('post', '/api/mailboxes', payload).expect(400); + expect(body(res).error?.code).toBe('VAL_001'); + } + expect(mailboxCalls).toHaveLength(before); + }); + + it('같은 상위 안에 같은 이름은 409 MAIL_009, 없는 상위는 404 RES_001', async () => { + const taken = await send('post', '/api/mailboxes', { + name: TAKEN_MAILBOX_NAME, + }).expect(409); + expect(body(taken).error?.code).toBe('MAIL_009'); + + const orphan = await send('post', '/api/mailboxes', { + name: '거래처', + parentId: MISSING_MAILBOX, + }).expect(404); + expect(body(orphan).error?.code).toBe('RES_001'); + }); + + it('이름 바꾸기는 200 Mailbox — 형식이 틀린 id 는 404 RES_001, 상위는 바꾸지 않는다(모르는 필드)', async () => { + const res = await send('patch', '/api/mailboxes/u1', { + name: ' 새 이름 ', + }).expect(200); + expect(dataOf<{ name: string }>(res).name).toBe('새 이름'); + expect(mailboxCalls.at(-1)).toEqual({ + user: ME, + action: 'rename', + args: ['u1', '새 이름'], + }); + + const malformed = await send('patch', '/api/mailboxes/a%20b', { + name: '새 이름', + }).expect(404); + expect(body(malformed).error?.code).toBe('RES_001'); + + const move = await send('patch', '/api/mailboxes/u1', { + name: '새 이름', + parentId: 'a', + }).expect(400); + expect(body(move).error?.code).toBe('VAL_001'); + }); + + it('지우기는 204 가 아니라 200 { deleted, processed, movedToTrash, remaining }', async () => { + const res = await send('delete', '/api/mailboxes/u1').expect(200); + + expect(body(res)).toEqual({ + success: true, + data: { deleted: true, processed: 4, movedToTrash: 3, remaining: 0 }, + }); + expect(mailboxCalls.at(-1)).toEqual({ + user: ME, + action: 'delete', + args: ['u1'], + }); + }); + + it('전체 읽음은 { isRead: true } 만 받는다 — 200 { processed, remaining }', async () => { + const res = await send('patch', '/api/mailboxes/a/messages', { + isRead: true, + }).expect(200); + expect(body(res)).toEqual({ + success: true, + data: { processed: 5, remaining: 0 }, + }); + expect(mailboxCalls.at(-1)).toEqual({ + user: ME, + action: 'mark-read', + args: ['a'], + }); + + const before = mailboxCalls.length; + for (const payload of [ + { isRead: false }, + {}, + { isRead: true, isFlagged: true }, + ]) { + const rejected = await send( + 'patch', + '/api/mailboxes/a/messages', + payload, + ).expect(400); + expect(body(rejected).error?.code).toBe('VAL_001'); + } + expect(mailboxCalls).toHaveLength(before); + }); + + it('비우기는 200 { processed, remaining }', async () => { + const res = await send('delete', '/api/mailboxes/b/messages').expect( + 200, + ); + + expect(body(res)).toEqual({ + success: true, + data: { processed: 2, remaining: 0 }, + }); + expect(mailboxCalls.at(-1)).toEqual({ + user: ME, + action: 'empty', + args: ['b'], + }); + }); + + /** + * 같은 메일함의 처리 중 잠금 — 대역 엔진의 SLOW_MAILBOX 비우기를 문으로 붙잡아 둔 채 다음 요청을 보낸다. + * 붙잡힌 요청이 있는 동안 supertest 가 서버를 닫지 않게 서버를 미리 열어 둔다 (로그인 동시 시도 테스트와 같은 이유) + */ + it( + '같은 직원 · 같은 메일함의 오래 걸리는 처리가 처리 중이면 종류가 달라도 엔진에 묻지 않고 429 REQ_001 — 다른 메일함은 그대로, 끝나면 다시 받는다', + async () => { + const httpServer = app.getHttpServer(); + const startedServer = !httpServer.listening; + if (startedServer) await listenOnAnyPort(httpServer); + + const gate = createGate(); + slowMailboxGate = gate.opened; + const before = mailboxCalls.length; + const first = send( + 'delete', + `/api/mailboxes/${SLOW_MAILBOX}/messages`, + ).then((res) => res); + + try { + await waitUntil( + () => mailboxCalls.length > before, + GATE_DEADLINE_MS, + ); + expect(mailboxCalls.slice(before)).toEqual([ + { user: ME, action: 'empty', args: [SLOW_MAILBOX] }, + ]); + + for (const [method, path, payload] of [ + ['delete', `/api/mailboxes/${SLOW_MAILBOX}/messages`, undefined], + [ + 'patch', + `/api/mailboxes/${SLOW_MAILBOX}/messages`, + { isRead: true }, + ], + ['delete', `/api/mailboxes/${SLOW_MAILBOX}`, undefined], + ] as const) { + const busy = await send(method, path, payload).expect(429); + expect(body(busy).error?.code).toBe('REQ_001'); + } + expect(mailboxCalls).toHaveLength(before + 1); + + // 같은 때 같은 IP 에서 다른 메일함은 된다 — 위의 429 는 요청 수 한도가 아니다 + await send('patch', '/api/mailboxes/a/messages', { + isRead: true, + }).expect(200); + } finally { + // 실패해도 문을 열고 붙잡힌 요청이 끝나기를 기다린다 — 다음 테스트 · 앱 종료가 멈추지 않게 + gate.open(); + slowMailboxGate = null; + await Promise.allSettled([first]); + if (startedServer) await closeServer(httpServer); + } + + expect((await first).status).toBe(200); + await send( + 'delete', + `/api/mailboxes/${SLOW_MAILBOX}/messages`, + ).expect(200); + }, + GATED_TEST_TIMEOUT_MS, + ); + }); + + describe('부재중 자동 응답 (/api/mail-vacation)', () => { + const ME = 'vacation.me@e2e.arasystem.test'; + const vacation = { + isEnabled: true, + fromDate: '2026-11-02T15:00:00.000Z', + toDate: '2026-11-07T15:00:00.000Z', + subject: '부재중입니다', + textBody: '11월 7일까지 휴가입니다.', + }; + const put = (payload: object) => + request(server) + .put('/api/mail-vacation') + .set('Cookie', cookieOf(ME)) + .send(payload); + + it('한 번도 설정하지 않았으면 끈 상태 · 빈 제목과 내용 — 받은 그대로 다시 저장해도 200', async () => { + const res = await request(server) + .get('/api/mail-vacation') + .set('Cookie', cookieOf(ME)) + .expect(200); + expect(body(res)).toEqual({ + success: true, + data: NEVER_CONFIGURED_VACATION, + }); + + const restored = await put(dataOf(res)).expect(200); + expect(dataOf(restored)).toEqual(NEVER_CONFIGURED_VACATION); + }); + + it('저장하면(전체 교체) 엔진이 저장한 값을 돌려주고, 다음 조회도 그 값이다', async () => { + const res = await put(vacation).expect(200); + expect(dataOf(res)).toEqual(vacation); + expect(vacationSaves.at(-1)).toEqual({ user: ME, vacation }); + + const read = await request(server) + .get('/api/mail-vacation') + .set('Cookie', cookieOf(ME)) + .expect(200); + expect(dataOf(read)).toEqual(vacation); + }); + + it('켤 때 빈(공백뿐인) 제목 · 내용, 제어 문자, 시작이 끝보다 늦음 · 같음(같은 초 포함), 날짜 형식, 빠진 키, 길이 초과, 서식 본문은 400 VAL_001 — 저장하지 않는다', async () => { + const before = vacationSaves.length; + const withoutToDate = Object.fromEntries( + Object.entries(vacation).filter(([key]) => key !== 'toDate'), + ); + + for (const payload of [ + { ...vacation, subject: '' }, + { ...vacation, textBody: '' }, + { ...vacation, toDate: vacation.fromDate }, + { ...vacation, toDate: '2026-11-01T15:00:00.000Z' }, + { ...vacation, fromDate: '2026-11-02' }, + { ...vacation, fromDate: '2026-11-02T15:00:00+09:00' }, + withoutToDate, + { ...vacation, subject: 'x'.repeat(201) }, + { ...vacation, textBody: 'x'.repeat(4_001) }, + { ...vacation, htmlBody: '

휴가

' }, + { ...vacation, subject: ' ' }, + { ...vacation, textBody: ' \n\t ' }, + { ...vacation, subject: '부재\r\n중' }, + { ...vacation, textBody: '휴가\u0000' }, + { ...vacation, textBody: '휴가\u001b[2J' }, + { + ...vacation, + fromDate: '2026-11-02T15:00:00.100Z', + toDate: '2026-11-02T15:00:00.900Z', + }, + ]) { + const res = await put(payload).expect(400); + expect(body(res).error?.code).toBe('VAL_001'); + } + expect(vacationSaves).toHaveLength(before); + }); + + it('꺼 둔 채로는 공백뿐인 제목도 저장한다 — 쓴 그대로(내용의 탭 · 줄바꿈 포함), 날짜는 소수 초를 버려 저장한다', async () => { + const off = { + ...vacation, + isEnabled: false, + fromDate: '2026-11-02T15:00:00.750Z', + toDate: '2026-11-07T15:00:00.250Z', + subject: ' ', + textBody: '안녕하세요.\r\n\t11월 7일까지 휴가입니다.\n', + }; + + const res = await put(off).expect(200); + + const saved = { + ...off, + fromDate: '2026-11-02T15:00:00.000Z', + toDate: '2026-11-07T15:00:00.000Z', + }; + expect(dataOf(res)).toEqual(saved); + expect(vacationSaves.at(-1)).toEqual({ user: ME, vacation: saved }); + }); + }); + + describe('스팸 신고 · 스팸 아님 (isJunk)', () => { + const ME = 'junk.me@e2e.arasystem.test'; + + it('한 통 PATCH 는 이동과 함께 isJunk 를 엔진에 넘긴다 — true · false 가 아니면 400 VAL_001', async () => { + await request(server) + .patch('/api/messages/eaaaaab') + .set('Cookie', cookieOf(ME)) + .send({ mailboxId: 'c', isJunk: true }) + .expect(200); + expect(singleUpdates.at(-1)).toEqual({ + user: ME, + id: 'eaaaaab', + update: { mailboxId: 'c', isJunk: true }, + }); + + const before = singleUpdates.length; + for (const isJunk of ['yes', null, 1]) { + const res = await request(server) + .patch('/api/messages/eaaaaab') + .set('Cookie', cookieOf(ME)) + .send({ isJunk }) + .expect(400); + expect(body(res).error?.code).toBe('VAL_001'); + } + expect(singleUpdates).toHaveLength(before); + }); + + it('여러 통 PATCH 도 isJunk 만으로 바꿀 수 있다 (스팸 아님)', async () => { + await request(server) + .patch('/api/messages') + .set('Cookie', cookieOf(ME)) + .send({ ids: ['eaaaaab'], mailboxId: 'a', isJunk: false }) + .expect(200); + + expect(bulkUpdates.at(-1)).toEqual({ + user: ME, + ids: ['eaaaaab'], + update: { mailboxId: 'a', isJunk: false }, + }); + }); + }); + + describe('원문 내려받기 (GET /api/messages/{id}/source)', () => { + const ME = 'source.me@e2e.arasystem.test'; + + it('표준 래퍼 없이 message/rfc822 원문 — attachment(제목으로 만든 .eml) · private, no-store · nosniff', async () => { + const res = await request(server) + .get('/api/messages/eaaaaab/source') + .set('Cookie', cookieOf(ME)) + .responseType('blob') + .expect(200); + + expect(res.headers['content-type']).toBe('message/rfc822'); + expect(res.headers['content-disposition']).toBe( + buildAttachmentDisposition('Re_ 견적서_10월.eml'), + ); + expect(res.headers['cache-control']).toBe('private, no-store'); + expect(res.headers['x-content-type-options']).toBe('nosniff'); + expect(res.headers['content-length']).toBe( + String(Buffer.byteLength(RAW_MESSAGE, 'utf8')), + ); + expect((res.body as Buffer).toString('utf8')).toBe(RAW_MESSAGE); + }); + + it('없는 메일 · 형식이 틀린 id 는 표준 실패 형식의 404 RES_001', async () => { + for (const id of [GONE_MESSAGE, 'a%20b']) { + const res = await request(server) + .get(`/api/messages/${id}/source`) + .set('Cookie', cookieOf(ME)) + .expect(404); + expect(body(res).error?.code).toBe('RES_001'); + } + }); + }); + }); + /** * 출처 검사(CSRF) — 상태를 바꾸는 요청은 Sec-Fetch-Site(same-origin·none) 또는 허용 목록의 Origin 이어야 한다. * 둘 다 없으면 브라우저가 아닌 클라이언트로 보고 통과시킨다 (docs/mail/api-contract.md 머리말). diff --git a/flutter/lib/core/api/error_lexicon.g.dart b/flutter/lib/core/api/error_lexicon.g.dart index 1b94ef6..e47c441 100644 --- a/flutter/lib/core/api/error_lexicon.g.dart +++ b/flutter/lib/core/api/error_lexicon.g.dart @@ -7,6 +7,7 @@ const Map kErrorCodeMessages = { 'AUTH_002': '안전을 위해 로그아웃 되었습니다. 다시 로그인해 주세요.', 'AUTH_003': '해당 메뉴나 기능에 접근할 수 있는 권한이 없습니다.', 'AUTH_004': '이메일 또는 비밀번호가 올바르지 않습니다.', + 'AUTH_005': '지금 비밀번호가 올바르지 않습니다.', 'VAL_001': '입력하신 정보를 다시 확인해 주세요. (필수값 누락 또는 형식 오류)', 'RES_001': '요청하신 정보나 페이지를 찾을 수 없습니다.', 'BIZ_001': '요청하신 작업을 완료하지 못했습니다. 다시 시도해 주세요.', @@ -20,6 +21,7 @@ const Map kErrorCodeMessages = { 'MAIL_006': '첨부는 한 메일에 최대 20개까지 보낼 수 있습니다.', 'MAIL_007': '본문이 너무 길어 보낼 수 없습니다. 내용을 줄인 뒤 다시 보내 주세요.', 'MAIL_008': '임시 저장한 메일이 다른 곳에서 바뀌었거나 지워졌습니다. 지금 내용을 새 임시 저장본으로 남겨 주세요.', + 'MAIL_009': '같은 이름의 메일함이 이미 있습니다.', }; /// 매핑 실패 시 사용하는 폴백 코드 diff --git a/frontend/src/shared/constants/error-codes.generated.ts b/frontend/src/shared/constants/error-codes.generated.ts index 22b0eae..5e30e5c 100644 --- a/frontend/src/shared/constants/error-codes.generated.ts +++ b/frontend/src/shared/constants/error-codes.generated.ts @@ -7,6 +7,7 @@ export const ERROR_CODE_MESSAGES = { AUTH_002: '안전을 위해 로그아웃 되었습니다. 다시 로그인해 주세요.', AUTH_003: '해당 메뉴나 기능에 접근할 수 있는 권한이 없습니다.', AUTH_004: '이메일 또는 비밀번호가 올바르지 않습니다.', + AUTH_005: '지금 비밀번호가 올바르지 않습니다.', VAL_001: '입력하신 정보를 다시 확인해 주세요. (필수값 누락 또는 형식 오류)', RES_001: '요청하신 정보나 페이지를 찾을 수 없습니다.', BIZ_001: '요청하신 작업을 완료하지 못했습니다. 다시 시도해 주세요.', @@ -20,6 +21,7 @@ export const ERROR_CODE_MESSAGES = { MAIL_006: '첨부는 한 메일에 최대 20개까지 보낼 수 있습니다.', MAIL_007: '본문이 너무 길어 보낼 수 없습니다. 내용을 줄인 뒤 다시 보내 주세요.', MAIL_008: '임시 저장한 메일이 다른 곳에서 바뀌었거나 지워졌습니다. 지금 내용을 새 임시 저장본으로 남겨 주세요.', + MAIL_009: '같은 이름의 메일함이 이미 있습니다.', } as const export type ErrorCode = keyof typeof ERROR_CODE_MESSAGES