feat(backend): 비밀번호 변경 · 메일함 관리 · 부재중 자동 응답 · 스팸 표시 · 원문 API

- 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 <noreply@anthropic.com>
This commit is contained in:
2026-10-28 06:13:36 +09:00
parent cbfccaa499
commit 05fc3909dd
44 changed files with 6476 additions and 88 deletions
@@ -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,
);
}
@@ -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)
+11
View File
@@ -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<string, ErrorCodeDefinition>;
export const ERROR_CODES = {
@@ -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회용 회전과 폐기를 서버에서 판단한다.
@@ -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<number> {
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) 절대 만료를 따로 볼 필요가 없다.
@@ -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;
@@ -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();
@@ -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<null> {
await this.auth.changePassword(
user,
dto.currentPassword,
dto.newPassword,
ip,
readCookie(req, REFRESH_TOKEN_COOKIE),
);
return null;
}
@Post('logout')
@HttpCode(HttpStatus.OK)
@ApiOperation({
+408 -1
View File
@@ -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<unknown>[]) {
const delay = (ms: number) =>
new Promise<void>((resolve) => setTimeout(resolve, ms));
/** 조건이 될 때까지 짧게 다시 본다 — 고정 대기 대신, 기한은 넉넉하게 */
async function until(
condition: () => boolean,
deadlineMs = 5_000,
): Promise<void> {
const deadline = Date.now() + deadlineMs;
while (!condition()) {
if (Date.now() > deadline) throw new Error('기한 안에 조건이 되지 않았다');
await delay(1);
}
}
describe('AuthService', () => {
let mail: jest.Mocked<Pick<MailProvider, 'verifyCredentials' | 'getProfile'>>;
let mail: jest.Mocked<
Pick<MailProvider, 'verifyCredentials' | 'getProfile' | 'changePassword'>
>;
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<number>((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();
+192 -3
View File
@@ -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<void> {
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<number> {
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<number> {
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<string | null> {
if (!refreshToken) return null;
try {
const claims = await this.verifyRefreshToken(refreshToken, true);
return normalizeEmail(claims.sub) === address ? claims.sid : null;
} catch {
return null;
}
}
/**
* 로그아웃 — 서버의 세션을 끊는다. 항상 성공으로 끝난다(계약: 항상 200):
* 토큰이 없거나 이미 쓸 수 없어도, 세션 폐기에 실패해도(오류 로그만) 쿠키는 컨트롤러가 지운다.
@@ -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<string[]> {
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']);
});
});
@@ -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;
}
@@ -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');
});
});
@@ -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<string> = new Set([
'\\',
'/',
':',
'*',
'?',
'"',
'<',
'>',
'|',
]);
/** 이름 앞뒤에서 떼는 문자 — 공백과 점(앞의 점은 숨김 파일, Windows 는 끝의 점을 지운다) */
const NAME_EDGE_CHARS: ReadonlySet<string> = 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 (
@@ -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']);
}
});
});
@@ -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')
@@ -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;
@@ -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<string[]> {
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<typeof enabled> = { ...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: '<p>휴가</p>' }),
).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);
});
});
@@ -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;
}
@@ -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<T extends object>(
type: ClassConstructor<T>,
body: object,
): Promise<{ dto: Record<string, unknown>; constraints: string[] }> {
const dto = plainToInstance(type, body);
const errors = await validate(dto, {
whitelist: true,
forbidNonWhitelisted: true,
});
return {
dto: dto as Record<string, unknown>,
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']);
});
});
@@ -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;
}
@@ -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(
@@ -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;
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -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<Response>, 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(
+255 -7
View File
@@ -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<PasswordChangeOutcome> {
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<JmapSessionResource>(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<PasswordChangeReply> {
// 응답 머리를 받았는가 — 받기 전에 끝난 실패(시간 초과 · 연결 끊김)는 엔진에 닿았을 수 있다
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<PasswordChangeReply> => {
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<JmapSession> {
// 막는 계정(관리자 등)은 엔진에 대리 접근을 요청하지도 않는다
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<JmapInvocation[]> {
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);
@@ -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<string, unknown> {
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[],
@@ -63,6 +63,8 @@ export interface JmapBodyValue {
export interface JmapEmail {
id: string;
/** 받은 그대로의 원문(message/rfc822) blob — 원문 내려받기에 쓴다 */
blobId?: string;
threadId?: string;
mailboxIds?: Record<string, boolean>;
keywords?: Record<string, boolean>;
@@ -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;
@@ -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;
+2
View File
@@ -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,
+53
View File
@@ -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<string | null>;
/**
* 직원 **본인의 자격으로**(지금 비밀번호) 비밀번호를 바꾼다 — 게이트웨이 대리 접근·관리자 권한을 쓰지 않는다 (계약 1절).
* 엔진의 판단(틀림 · 거절 · 규칙 위반)과 결과를 알 수 없음(바꿨을 수 있다)은 결과로 돌려주고,
* 결과가 확실한 엔진 장애(바뀌지 않았다)는 MAIL_001(한도는 REQ_001)로 던진다 (PasswordChangeOutcome).
* 비밀번호는 저장하지 않고 어떤 로그에도 남기지 않는다.
*/
changePassword(
email: string,
currentPassword: string,
newPassword: string,
): Promise<PasswordChangeOutcome>;
/**
* 직원 메일함 프로필(표시 이름). `fresh` 면 캐시를 건너뛰고 계정이 살아 있는지 다시 확인한다.
* 계정이 사라졌거나, 로그인을 막는 계정(관리자 등)이거나, 메일 엔진의 관리 권한이 있는 계정이면 AUTH_001.
@@ -53,6 +71,38 @@ export interface MailProvider {
listMailboxes(user: string): Promise<Mailbox[]>;
/**
* 메일함을 만든다 (계약 2절 "메일함 관리") — 이름 형식은 요청 검증(dto/mailbox.dto.ts)이 본다.
* 상위가 없으면 RES_001, 만들 수 없는 자리(보낸편지함 · 임시보관함 · 스팸메일함 · 휴지통 · 그 밖의 역할 메일함 아래)면 VAL_001,
* 같은 상위 안에 같은 이름이 있으면 MAIL_009.
*/
createMailbox(user: string, input: MailboxCreate): Promise<Mailbox>;
/** 메일함 이름을 바꾼다 — 없으면 RES_001, 역할이 있는 메일함(기본 메일함)이면 VAL_001, 같은 이름이 있으면 MAIL_009 */
renameMailbox(user: string, id: string, name: string): Promise<Mailbox>;
/**
* 메일함을 지운다 — 안의 메일을 휴지통으로 옮긴 뒤(다른 메일함에도 있는 메일은 이 메일함에서만 뺀다) 빈 메일함을 지운다.
* 메일을 영구 삭제하지 않는다. 한 요청에 다 옮기지 못하면 deleted: false (클라이언트가 다시 보낸다).
* 없으면 RES_001, 역할이 있는 메일함 · 하위 메일함이 있으면 VAL_001.
*/
deleteMailbox(user: string, id: string): Promise<MailboxDeletion>;
/** 메일함의 안 읽은 메일을 읽음으로 바꾼다 (한 요청의 시간 안에서 차례로) — 없으면 RES_001 */
markMailboxRead(user: string, id: string): Promise<MailboxBulkResult>;
/**
* 휴지통 · 스팸메일함을 비운다 (영구 삭제) — 그 메일함에만 있는 메일은 지우고, 다른 메일함에도 있는 메일은 그 메일함에서만 뺀다.
* 확인한 상태 그대로일 때만 지운다(deleteFromTrash 와 같은 규칙). 없으면 RES_001, 휴지통 · 스팸메일함이 아니면 VAL_001.
*/
emptyMailbox(user: string, id: string): Promise<MailboxBulkResult>;
/** 부재중 자동 응답 — 엔진 값이 없으면(null) 제목 · 내용은 빈 문자열 */
getVacation(user: string): Promise<MailVacation>;
/** 부재중 자동 응답을 통째로 바꾸고(서식 본문은 비운다) 다시 읽은 값을 돌려준다 — 형식은 요청 검증이, 켤 때의 빈 값 · 날짜 순서는 서비스가 본다 */
setVacation(user: string, vacation: MailVacation): Promise<MailVacation>;
/** 메일함 사용량과 한도 (한도가 없으면 limitBytes = null) */
getUsage(user: string): Promise<MailUsage>;
@@ -126,6 +176,9 @@ export interface MailProvider {
uploadAttachment(user: string, file: UploadedFile): Promise<MailAttachment>;
/** 받은 그대로의 메일 원문(엔진의 원본)을 흘려보낸다 — 없는 메일은 RES_001 */
openMessageSource(user: string, id: string): Promise<MessageSource>;
/** 그 메일에 실제로 있는 첨부만 내려준다 (스트리밍) */
openAttachment(
user: string,
@@ -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<T>(): {
promise: Promise<T>;
finish: (value: T) => void;
fail: (error: Error) => void;
} {
let finish!: (value: T) => void;
let fail!: (error: Error) => void;
const promise = new Promise<T>((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());
+197 -5
View File
@@ -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<string>();
/**
* 진행 중인 오래 걸리는 처리(전체 읽음 · 비우기 · 지우기)의 (사용자, 메일함) — 같은 메일함에는 하나씩만, 종류가 달라도 막는다.
* 두 탭이 같은 휴지통을 비우면 서로 상태가 어긋나 둘 다 멈추고, 동시 요청이 많으면 엔진 호출이 그 수만큼 곱절로 는다.
* ⚠️ 프로세스 메모리다. 인스턴스를 늘리면 Redis 잠금(SET NX PX)으로 옮겨야 인스턴스 사이에서도 하나씩이 된다.
*/
private readonly mailboxOperations = new Set<string>();
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<Mailbox> {
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<Mailbox> {
return presentMailbox(
await this.provider.renameMailbox(user.email, id, dto.name),
);
}
/**
* 메일함 지우기 — 안의 메일을 휴지통으로 옮긴 뒤 지운다(영구 삭제하지 않는다). 한 요청에 끝나지 않으면 deleted: false.
* 같은 메일함의 오래 걸리는 처리는 하나씩만 (진행 중이면 REQ_001 — runExclusive).
* 지웠으면 info 로그에 개수만 남긴다 (메일함 이름 없이 — 사용자가 정한 글이다)
*/
async deleteMailbox(
user: AuthenticatedUser,
id: string,
): Promise<MailboxDeletion> {
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<MailboxBulkResult> {
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<MailboxBulkResult> {
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<MailVacation> {
return this.provider.getVacation(user.email);
}
/**
* 부재중 자동 응답 저장(전체 교체) — 엔진이 저장한 값을 다시 읽어 돌려준다.
* - 켤 때는 제목 · 내용이 앞뒤 공백을 빼고 1자 이상 (저장은 쓴 그대로 — 지우지 않는다).
* 끈 채로는 빈 값도 저장한다 — 한 번도 설정하지 않은 값을 그대로 되돌려 놓을 수 있게
* - 날짜는 초 단위로 맞춰 엔진에 보낸다(소수 초를 버린다 — 엔진의 UTCDate). 시작 · 끝이 둘 다 있으면 **맞춘 값으로** 시작 < 끝
* 어긋나면 엔진에 묻지 않고 VAL_001. 형식(길이 · 제어 문자 · 날짜 모양)은 요청 검증이 본다. 로그에는 켬 · 끔만 (제목 · 내용 없이)
*/
async updateVacation(
user: AuthenticatedUser,
dto: MailVacationDto,
): Promise<MailVacation> {
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<AttachmentContent> {
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<T>(
user: AuthenticatedUser,
mailboxId: string,
operation: () => Promise<T>,
): Promise<T> {
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,
+74
View File
@@ -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;
+183 -1
View File
@@ -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')
@@ -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);
});
});
@@ -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);
}
+58 -6
View File
@@ -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<StreamableFile> {
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<StreamableFile> {
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();
});
+28
View File
@@ -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('내일');
});
});
+14
View File
@@ -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();
}