generated from ttipo/project-base
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:
@@ -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)
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
@@ -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('내일');
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user