feat(backend): 직원 인증(쿠키 JWT·DB 세션)과 웹메일 API(Stalwart JMAP 중계)

첫 사내 모듈인 웹메일의 백엔드다. 메일 엔진은 외부(Stalwart)에 두고 백엔드가 JMAP 으로 중계한다.

- 직원 로그인 = 메일 계정: 엔진으로 비밀번호를 확인한 뒤 HttpOnly 쿠키 JWT 를 발급한다.
  리프레시 세션은 DB 에 해시로 저장하고 1회용으로 회전한다 (이미 쓴 토큰이 오면 세션 폐기)
- 로그인 실패 한도는 엔진에 묻기 전에 원자적으로 차지하고, IPv6 는 /64 로 묶어 센다
- 상태를 바꾸는 요청은 출처(Sec-Fetch-Site·Origin)를 검사하고, 클라이언트 IP 는 TRUST_PROXY 로만 해석한다
- 메일함·목록·읽기·발송·첨부 API: 받은 HTML 은 정화해서 내보내고, 발송은 Idempotency-Key 로 중복을 막는다
- 메일 엔진 관리자 계정은 로그인·대리 접근 대상에서 막는다 (게이트웨이가 관리자까지 대리할 수 있다)
- 에러 코드 AUTH_004·MAIL_001~006 을 추가하고 클라이언트 에러 사전을 다시 생성했다

계약: docs/mail/api-contract.md

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-27 01:31:38 +09:00
parent 238d0ba838
commit 71f2e01d83
115 changed files with 15985 additions and 174 deletions
+20 -10
View File
@@ -1,13 +1,16 @@
import { MiddlewareConsumer, Module, NestModule } from '@nestjs/common';
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { ConfigModule } from '@nestjs/config';
import { ScheduleModule } from '@nestjs/schedule';
import { ThrottlerGuard, ThrottlerModule } from '@nestjs/throttler';
import { ThrottlerModule } from '@nestjs/throttler';
import { CacheModule } from '@nestjs/cache-manager';
import { APP_GUARD } from '@nestjs/core';
import { RequestIdMiddleware } from '@/common/middleware/request-id.middleware';
import { ClientIpThrottlerGuard } from '@/common/guards/client-ip-throttler.guard';
import { OriginGuard } from '@/common/guards/origin.guard';
import { AuthModule } from '@/modules/auth/auth.module';
import { HealthModule } from '@/modules/health/health.module';
import { MailModule } from '@/modules/mail/mail.module';
import { UploadModule } from '@/modules/upload/upload.module';
import { UserModule } from '@/modules/user/user.module';
import { parsePositiveInt } from '@/shared/utils/env';
@@ -58,20 +61,27 @@ const THROTTLE_DEFAULT_LIMIT = 100; // 구간당 IP별 최대 요청 수
}),
}),
HealthModule,
// 직원 로그인(쿠키 JWT) — 자격 확인은 MailModule 의 메일 엔진 포트로
AuthModule,
// 웹메일 API (Stalwart JMAP 어댑터)
MailModule,
UploadModule,
UserModule,
],
providers: [
ShutdownLogger,
// 전역 가드는 등록 순서대로 돈다 — 다른 사이트에서 온 상태 변경 요청은 요청 수를 세기 전에 막는다
{
provide: APP_GUARD,
useClass: ThrottlerGuard,
useClass: OriginGuard,
},
// 요청 수 한도 — ThrottlerGuard 그대로, 클라이언트 키만 IPv6 /64 로 묶는다 (두 번째 limiter 가 아니다)
{
provide: APP_GUARD,
useClass: ClientIpThrottlerGuard,
},
],
})
export class AppModule implements NestModule {
// 모든 요청에 추적 ID 를 부여한다 (로그·에러 응답 traceId 와 동일 값)
configure(consumer: MiddlewareConsumer): void {
consumer.apply(RequestIdMiddleware).forRoutes('*');
}
}
// 요청 추적 ID 는 app.setup.ts 의 configureApp() 이 **맨 앞** express 미들웨어로 붙인다 —
// 본문 파서 오류(413·400·415)에도 traceId 가 남도록 Nest 미들웨어(본문 파서 뒤)로 두지 않는다
export class AppModule {}
+62
View File
@@ -0,0 +1,62 @@
import { RequestMethod, ValidationPipe } from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import type { NestExpressApplication } from '@nestjs/platform-express';
import cookieParser from 'cookie-parser';
import helmet from 'helmet';
import { HttpExceptionFilter } from '@/common/filters/http-exception.filter';
import { TransformInterceptor } from '@/common/interceptors/transform.interceptor';
import { requestIdMiddleware } from '@/common/middleware/request-id.middleware';
import { resolveTrustProxy } from '@/shared/utils/trust-proxy';
/**
* JSON 본문 최대 크기.
* 메일 발송 본문은 100,000자까지 받는다 — 한글은 UTF-8 로 3바이트라 약 300KB 다 (기본값 100KB 로는 잘린다).
*/
export const JSON_BODY_LIMIT = '1mb';
/**
* HTTP 파이프라인 전역 설정 — main.ts 와 e2e 테스트가 **같은 함수**를 쓴다
* (따로 적으면 e2e 가 실제 응답과 다른 설정으로 돌게 된다).
*
* 앱은 `{ bodyParser: false }` 로 만들어야 한다 — 본문 파서를 여기서 직접 등록한다.
* CORS·Swagger·종료 훅은 배포 환경에 묶인 설정이라 main.ts 에 둔다.
*/
export function configureApp(app: NestExpressApplication): void {
// 요청 추적 ID — **가장 먼저** 붙인다. 본문 파서·helmet 보다 앞이라 그 단계의 실패(413·400·415)에도
// 응답 traceId 와 로그가 남고, 이후 모든 처리가 요청 컨텍스트(traceTag) 안에서 돈다
app.use(requestIdMiddleware);
// 믿을 프록시 뒤에서 실제 클라이언트 IP 를 읽는다 — 로그인 실패 한도·요청 수 한도의 기준 (TRUST_PROXY).
// 운영 체인: 클라이언트 → 외부 리버스 프록시 → 프론트 nginx → 백엔드. 값이 없으면(비운영) 1
app.set('trust proxy', resolveTrustProxy(process.env.TRUST_PROXY));
// 글로벌 prefix — 모든 라우트가 /api/* (클라이언트 baseURL 과 일치)
app.setGlobalPrefix('api', {
exclude: [{ path: '/', method: RequestMethod.GET }],
});
// 보안 헤더
app.use(helmet());
// 인증 쿠키(access_token·refresh_token)를 읽는다
app.use(cookieParser());
// JSON 만 받는다 — urlencoded 파서를 등록하지 않으면 다른 사이트의 <form> 으로 로그인·발송을 시도하는
// 요청(CSRF)은 본문이 비어 VAL_001 이 된다. JSON 요청은 CORS 사전 확인을 거치므로 화이트리스트가 막는다.
// inflate=false: 압축된 요청 본문을 받지 않는다 (브라우저는 보내지 않는다 — 압축 폭탄·body-parser DoS 경로 차단)
app.useBodyParser('json', { limit: JSON_BODY_LIMIT, inflate: false });
// 전역 DTO 검증
app.useGlobalPipes(
new ValidationPipe({
whitelist: true, // DTO 에 없는 값은 버린다
forbidNonWhitelisted: true, // DTO 에 없는 필드가 오면 400
transform: true, // DTO·기본 타입으로 변환
}),
);
// 표준 실패·성공 응답
app.useGlobalFilters(new HttpExceptionFilter());
app.useGlobalInterceptors(new TransformInterceptor(app.get(Reflector)));
}
@@ -0,0 +1,34 @@
import { createParamDecorator, ExecutionContext } from '@nestjs/common';
import type { Request } from 'express';
import { BusinessException } from '@/common/errors/business.exception';
/** 인증된 직원 — 액세스 토큰에서 복원한 값 (요청마다 메일 엔진에 묻지 않는다) */
export interface AuthenticatedUser {
/** 직원 메일 주소 (= 메일 계정) */
readonly email: string;
/** 표시 이름 */
readonly name: string;
}
/**
* 가드(`JwtAuthGuard`)가 검증해 `req.user` 에 넣은 직원 정보를 꺼낸다.
* 가드 없이 쓰면 사용자가 없으므로 AUTH_001 로 막는다 (가드 누락이 조용히 통과하지 않게).
*
* 사용 예:
* @UseGuards(JwtAuthGuard)
* me(@CurrentUser() user: AuthenticatedUser) { ... }
*/
export const CurrentUser = createParamDecorator(
(_data: unknown, context: ExecutionContext): AuthenticatedUser => {
const request = context.switchToHttp().getRequest<Request>();
const user = request.user as AuthenticatedUser | undefined;
if (!user) {
throw new BusinessException('AUTH_001', {
context: { reason: 'current-user-without-guard' },
});
}
return user;
},
);
@@ -46,7 +46,7 @@ describe('에러 코드 레지스트리', () => {
it('클라이언트 전용 코드(AUTH_002)는 서버가 상태 역매핑으로 선택하지 않는다', () => {
const clientOnlyCodes = Object.entries(ERROR_CODES)
.filter(([, definition]) => definition.clientOnly)
.filter(([, definition]) => 'clientOnly' in definition)
.map(([code]) => code);
expect(clientOnlyCodes).toContain('AUTH_002');
+39
View File
@@ -40,6 +40,11 @@ const COMMON_ERROR_CODES = {
status: HttpStatus.FORBIDDEN,
message: '해당 메뉴나 기능에 접근할 수 있는 권한이 없습니다.',
},
// 로그인 자격 증명 불일치 — 계정 없음/비밀번호 틀림을 구분하지 않는다 (.claude/rules/security.md)
AUTH_004: {
status: HttpStatus.UNAUTHORIZED,
message: '이메일 또는 비밀번호가 올바르지 않습니다.',
},
VAL_001: {
status: HttpStatus.BAD_REQUEST,
message: '입력하신 정보를 다시 확인해 주세요. (필수값 누락 또는 형식 오류)',
@@ -73,6 +78,40 @@ const DOMAIN_ERROR_CODES = {
status: HttpStatus.PAYLOAD_TOO_LARGE,
message: '파일 크기가 허용 범위를 초과했습니다.',
},
// 메일 — 외부 메일 엔진(Stalwart) 장애를 SYS_001(우리 버그)과 구분하기 위해 분리한다
MAIL_001: {
status: HttpStatus.SERVICE_UNAVAILABLE,
message: '메일 서버와 연결할 수 없습니다. 잠시 후 다시 시도해 주세요.',
},
MAIL_002: {
status: HttpStatus.BAD_REQUEST,
message: '받는 사람 주소를 다시 확인해 주세요.',
},
MAIL_003: {
status: HttpStatus.BAD_REQUEST,
message:
'메일함 용량이 부족합니다. 불필요한 메일을 정리한 뒤 다시 시도해 주세요.',
},
// 발송 때 엔진에서 첨부(blob)를 찾지 못함 — 올려 두고 오래 지나 만료된 업로드·그사이 지워진 원본 메일의 첨부(전달)·잘못된 첨부 id.
// 문구는 원인을 단정하지 않는다 — 올린 파일은 다시 올리고, 전달하는 원본 첨부는 빼야 한다 (계약 2절)
MAIL_004: {
status: HttpStatus.BAD_REQUEST,
message:
'첨부 파일을 찾을 수 없습니다. 해당 첨부를 빼거나 다시 올린 뒤 보내 주세요.',
},
// 같은 Idempotency-Key 로 이미 보낸 메일과 내용(받는 사람·제목·본문·첨부)이 다르다 — 보내지 않는다.
// 첫 메일이 실제로 나갔을 수 있으므로 클라이언트는 보낸편지함을 확인하게 하고 새 키로 다시 보내게 한다
MAIL_005: {
status: HttpStatus.CONFLICT,
message:
'이미 보낸 메일과 내용이 달라 보내지 않았습니다. 보낸편지함을 확인한 뒤 다시 보내 주세요.',
},
// 첨부 개수 한도 초과 — 업로드한 파일과 전달하는 원본 첨부를 함께 센다.
// 문구의 숫자는 attachment.policy.ts 의 MAX_ATTACHMENTS_PER_MESSAGE 와 같아야 한다 (attachment.policy.spec.ts 가 확인한다)
MAIL_006: {
status: HttpStatus.BAD_REQUEST,
message: '첨부는 한 메일에 최대 20개까지 보낼 수 있습니다.',
},
} as const satisfies Record<string, ErrorCodeDefinition>;
export const ERROR_CODES = {
@@ -107,6 +107,51 @@ describe('HttpExceptionFilter', () => {
expect(JSON.stringify(result.body)).not.toContain('File too large');
});
it('Express 미들웨어의 4xx(http-errors)는 상태로 역매핑한다 — 본문 크기 초과가 SYS_001 로 보이면 안 된다', () => {
// body-parser 는 한도를 넘으면 { status: 413, expose: true } 인 오류를 next(err) 로 넘긴다
const tooLarge = Object.assign(new Error('request entity too large'), {
status: 413,
statusCode: 413,
expose: true,
type: 'entity.too.large',
});
const result = runFilter(tooLarge);
expect(result.status).toBe(HttpStatus.PAYLOAD_TOO_LARGE);
expect(result.body.error.code).toBe('FILE_002');
expect(JSON.stringify(result.body)).not.toContain('entity too large');
});
it('expose 가 없거나 5xx 인 오류 객체는 SYS_001 로 수렴한다', () => {
const notExposed = Object.assign(new Error('내부'), { status: 400 });
const serverSide = Object.assign(new Error('내부'), {
status: 502,
expose: true,
});
expect(runFilter(notExposed).body.error.code).toBe('SYS_001');
expect(runFilter(serverSide).status).toBe(HttpStatus.INTERNAL_SERVER_ERROR);
});
it('5xx BusinessException 도 context 를 로그에 남긴다 (응답에는 싣지 않는다)', () => {
const errorLog = jest.spyOn(Logger.prototype, 'error');
errorLog.mockClear();
const result = runFilter(
new BusinessException('MAIL_001', {
context: { reason: 'timeout', jmapType: 'serverFail' },
}),
);
expect(result.status).toBe(HttpStatus.SERVICE_UNAVAILABLE);
expect(JSON.stringify(result.body)).not.toContain('serverFail');
expect(errorLog).toHaveBeenCalledWith(
expect.stringContaining('"jmapType":"serverFail"'),
expect.anything(),
);
});
it('미정의 예외는 내부 정보 없이 SYS_001 로 수렴한다', () => {
const result = runFilter(new Error('DB 커넥션 풀 고갈: host=10.0.0.5'));
@@ -21,6 +21,9 @@ import { REQUEST_ID_HEADER } from '@/common/middleware/request-id.middleware';
/** 이 값 이상이면 서버 책임 오류로 보고 error 레벨로 남긴다. */
const SERVER_ERROR_STATUS_THRESHOLD = 500;
/** 클라이언트 오류(4xx)의 시작 */
const CLIENT_ERROR_STATUS_THRESHOLD = 400;
/**
* 전역 Exception Filter — 모든 실패 응답을 표준 포맷으로 변환한다.
* 에러 코드 ↔ 메시지 ↔ HTTP 상태는 error-codes.ts 레지스트리가 SSOT 이며,
@@ -81,7 +84,19 @@ export class HttpExceptionFilter implements ExceptionFilter {
};
}
// 3. 미정의 예외 — 내부 정보가 새지 않도록 항상 SYS_001 로 수렴시킨다.
// 3. Express 미들웨어(body-parser 등)가 만든 http-errors 객체 — 클라이언트 책임(4xx)이면 상태로 역매핑한다.
// (본문 크기 초과 413 이 SYS_001(500)로 보이면 원인을 오해하게 된다)
const clientStatus = this.extractClientErrorStatus(exception);
if (clientStatus !== null) {
const code = resolveCodeByStatus(clientStatus);
return {
status: clientStatus,
code,
message: getErrorDefinition(code).message,
};
}
// 4. 미정의 예외 — 내부 정보가 새지 않도록 항상 SYS_001 로 수렴시킨다.
return {
status: HttpStatus.INTERNAL_SERVER_ERROR,
code: FALLBACK_ERROR_CODE,
@@ -89,6 +104,25 @@ export class HttpExceptionFilter implements ExceptionFilter {
};
}
/**
* http-errors 형식(`status` + `expose: true`)의 클라이언트 오류면 상태 코드를 꺼낸다.
* `expose` 는 "메시지를 클라이언트에 보여도 되는 4xx" 에만 true 이므로, 5xx·임의 객체는 걸러진다.
*/
private extractClientErrorStatus(exception: unknown): number | null {
if (!(exception instanceof Error)) return null;
const { status, expose } = exception as Error & {
status?: unknown;
expose?: unknown;
};
const isClientError =
typeof status === 'number' &&
status >= CLIENT_ERROR_STATUS_THRESHOLD &&
status < SERVER_ERROR_STATUS_THRESHOLD;
return isClientError && expose === true ? status : null;
}
/** 예외 본문에 error code 가 실려 있으면 꺼낸다. */
private extractCode(body: unknown): ErrorCode | null {
if (typeof body === 'object' && body !== null && 'code' in body) {
@@ -108,19 +142,20 @@ export class HttpExceptionFilter implements ExceptionFilter {
): void {
const where = `${request.method} ${request.url}`;
const prefix = `[${code}]${traceId ? `[${traceId}]` : ''} ${where}`;
// context 는 로그 전용 개발자 정보다 — 5xx(예: 외부 메일 엔진 장애 MAIL_001)도 원인을 알 수 있게 함께 남긴다
const context =
exception instanceof BusinessException && exception.context
? ` ${JSON.stringify(exception.context)}`
: '';
if (status >= SERVER_ERROR_STATUS_THRESHOLD) {
this.logger.error(
prefix,
`${prefix}${context}`,
exception instanceof Error ? exception.stack : String(exception),
);
return;
}
const context =
exception instanceof BusinessException && exception.context
? ` ${JSON.stringify(exception.context)}`
: '';
this.logger.warn(`${prefix}${context}`);
}
}
@@ -0,0 +1,44 @@
import { ThrottlerGuard } from '@nestjs/throttler';
import { ClientIpThrottlerGuard } from './client-ip-throttler.guard';
/** getTracker 만 확인한다 — 한도 계산은 throttler 의 몫이다 */
function trackerOf(req: Record<string, unknown>): Promise<string> {
const guard = Object.create(
ClientIpThrottlerGuard.prototype,
) as ClientIpThrottlerGuard;
return (
guard as unknown as {
getTracker: (r: Record<string, unknown>) => Promise<string>;
}
).getTracker(req);
}
describe('ClientIpThrottlerGuard', () => {
it('ThrottlerGuard 를 그대로 쓰고 세는 키만 바꾼다 (두 번째 limiter 가 아니다)', () => {
expect(Object.getPrototypeOf(ClientIpThrottlerGuard.prototype)).toBe(
ThrottlerGuard.prototype,
);
});
it('IPv6 는 /64 로 묶는다 — 로그인 실패 한도와 같은 키', async () => {
await expect(trackerOf({ ip: '2001:db8:1:2::5' })).resolves.toBe(
'2001:db8:1:2::/64',
);
await expect(trackerOf({ ip: '2001:db8:1:2:aaaa::9' })).resolves.toBe(
'2001:db8:1:2::/64',
);
});
it('IPv4-mapped 주소는 IPv4 로, IPv4 는 그대로', async () => {
await expect(trackerOf({ ip: '::ffff:203.0.113.7' })).resolves.toBe(
'203.0.113.7',
);
await expect(trackerOf({ ip: '203.0.113.7' })).resolves.toBe('203.0.113.7');
});
it('IP 를 알 수 없으면 한 묶음(unknown)으로 센다', async () => {
await expect(trackerOf({})).resolves.toBe('unknown');
await expect(trackerOf({ ip: '' })).resolves.toBe('unknown');
});
});
@@ -0,0 +1,19 @@
import { Injectable } from '@nestjs/common';
import { ThrottlerGuard } from '@nestjs/throttler';
import { clientIpKey } from '@/shared/utils/client-ip';
/**
* 전역 요청 수 한도(APP_GUARD) — `@nestjs/throttler` 의 ThrottlerGuard 에서 **클라이언트를 세는 키만** 바꾼다.
* 두 번째 limiter 가 아니다 (한도·저장소·`@Throttle`/`@SkipThrottle` 은 그대로 throttler 가 맡는다).
*
* 키는 로그인 실패 한도와 같은 `clientIpKey()` — IPv6 는 /64 로 묶고, IPv4-mapped 주소는 IPv4 로 본다.
* 그래서 같은 회선에서 IPv6 주소를 바꿔 가며 요청 수 한도를 피하지 못한다.
*/
@Injectable()
export class ClientIpThrottlerGuard extends ThrottlerGuard {
protected override getTracker(req: Record<string, unknown>): Promise<string> {
const ip = typeof req.ip === 'string' && req.ip !== '' ? req.ip : 'unknown';
return Promise.resolve(clientIpKey(ip));
}
}
@@ -0,0 +1,71 @@
import { BusinessException } from '@/common/errors/business.exception';
import { JwtAuthGuard } from './jwt-auth.guard';
const user = { email: 'ara.kim@arasystem.test', name: '김아라' };
/** 가드가 던진 예외의 에러 코드를 꺼낸다 */
function codeOf(run: () => unknown): string | undefined {
try {
run();
} catch (error) {
return error instanceof BusinessException ? error.code : 'NOT_BUSINESS';
}
return undefined;
}
describe('JwtAuthGuard.handleRequest', () => {
const guard = new JwtAuthGuard();
it('검증된 사용자는 그대로 통과시킨다', () => {
expect(guard.handleRequest(null, user, undefined)).toBe(user);
});
it('토큰이 없으면 AUTH_001 이다', () => {
expect(
codeOf(() =>
guard.handleRequest(null, false, new Error('No auth token')),
),
).toBe('AUTH_001');
});
it('만료·위조 토큰도 원인을 구분하지 않고 AUTH_001 이다', () => {
for (const message of [
'jwt expired',
'invalid signature',
'jwt malformed',
]) {
expect(
codeOf(() => guard.handleRequest(null, false, new Error(message))),
).toBe('AUTH_001');
}
});
it('예상치 못한 오류도 코드 없는 실패로 새지 않고 AUTH_001 이다', () => {
expect(
codeOf(() => guard.handleRequest(new Error('boom'), false, undefined)),
).toBe('AUTH_001');
});
it('실패 사유는 로그 context 에만 남긴다', () => {
try {
guard.handleRequest(null, false, new Error('jwt expired'));
} catch (error) {
expect((error as BusinessException).context).toEqual({
reason: 'jwt expired',
});
expect((error as BusinessException).getResponse()).not.toHaveProperty(
'reason',
);
}
});
it('전략이 코드와 함께 거부한 예외는 그대로 전달한다', () => {
const rejected = new BusinessException('AUTH_001', {
context: { reason: 'wrong-token-type' },
});
expect(() => guard.handleRequest(rejected, false, undefined)).toThrow(
rejected,
);
});
});
@@ -0,0 +1,42 @@
import { Injectable } from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport';
import type { AuthenticatedUser } from '@/common/decorators/current-user.decorator';
import { BusinessException } from '@/common/errors/business.exception';
/** passport 전략 이름 — `modules/auth/jwt.strategy.ts` 가 이 이름으로 등록한다 */
export const JWT_STRATEGY = 'jwt';
/**
* 액세스 토큰 쿠키로 보호하는 가드.
* 토큰이 없거나·만료됐거나·위조됐으면 원인을 구분하지 않고 AUTH_001 로 응답한다.
* (구체적인 사유는 로그 context 에만 남긴다 — 클라이언트는 AUTH_001 이면 /auth/refresh 를 한 번 시도한다)
*
* 사용 예:
* @UseGuards(JwtAuthGuard)
* @ApiCookieAuth()
*/
@Injectable()
export class JwtAuthGuard extends AuthGuard(JWT_STRATEGY) {
handleRequest<TUser = AuthenticatedUser>(
error: unknown,
user: unknown,
info: unknown,
): TUser {
// 전략의 validate() 가 코드와 함께 거부한 경우 그대로 전달한다
if (error instanceof BusinessException) throw error;
if (error || !user) {
throw new BusinessException('AUTH_001', {
context: { reason: describeFailure(error ?? info) },
});
}
return user as TUser;
}
}
/** 토큰 원문은 남기지 않고 실패 종류만 남긴다 (예: 'No auth token', 'jwt expired') */
function describeFailure(cause: unknown): string {
if (cause instanceof Error) return cause.message;
return 'unauthenticated';
}
@@ -0,0 +1,120 @@
import type { ExecutionContext } from '@nestjs/common';
import type { ConfigService } from '@nestjs/config';
import { BusinessException } from '@/common/errors/business.exception';
import { OriginGuard } from './origin.guard';
function guardWith(env: Record<string, string>): OriginGuard {
const config = {
get: (key: string) => env[key],
} as unknown as ConfigService;
return new OriginGuard(config);
}
function httpContext(
method: string,
headers: Record<string, string> = {},
): ExecutionContext {
const request = {
method,
header: (name: string) => headers[name.toLowerCase()],
};
return {
getType: () => 'http',
switchToHttp: () => ({ getRequest: () => request }),
} as unknown as ExecutionContext;
}
function codeOf(run: () => unknown): string | undefined {
try {
run();
} catch (error) {
return error instanceof BusinessException ? error.code : 'NOT_BUSINESS';
}
return undefined;
}
describe('OriginGuard', () => {
const production = guardWith({
NODE_ENV: 'production',
CORS_ORIGIN: 'https://mail.arasystem.it.kr',
});
it('조회(GET·HEAD·OPTIONS)는 검사하지 않는다', () => {
for (const method of ['GET', 'HEAD', 'OPTIONS']) {
expect(
production.canActivate(
httpContext(method, { 'sec-fetch-site': 'cross-site' }),
),
).toBe(true);
}
});
it.each(['POST', 'PUT', 'PATCH', 'DELETE'])(
'%s 는 다른 사이트에서 오면 403 AUTH_003',
(method) => {
expect(
codeOf(() =>
production.canActivate(
httpContext(method, {
'sec-fetch-site': 'cross-site',
origin: 'https://evil.test',
}),
),
),
).toBe('AUTH_003');
},
);
it('같은 주소의 화면에서 온 요청은 통과한다', () => {
expect(
production.canActivate(
httpContext('POST', {
'sec-fetch-site': 'same-origin',
origin: 'https://mail.arasystem.it.kr',
}),
),
).toBe(true);
});
it('운영에서는 localhost 출처를 허용하지 않는다 (CORS_ORIGIN 만)', () => {
expect(
codeOf(() =>
production.canActivate(
httpContext('POST', { origin: 'http://localhost:5173' }),
),
),
).toBe('AUTH_003');
});
it('개발에서는 vite dev 서버 출처를 허용한다', () => {
const development = guardWith({ NODE_ENV: 'development' });
expect(
development.canActivate(
httpContext('POST', {
'sec-fetch-site': 'same-site',
origin: 'http://localhost:5173',
}),
),
).toBe(true);
});
it('출처 정보가 없는 요청(브라우저가 아닌 클라이언트)은 통과한다', () => {
expect(production.canActivate(httpContext('POST'))).toBe(true);
});
it('거부 사유는 로그 context 에만 남긴다', () => {
try {
production.canActivate(
httpContext('DELETE', { origin: 'https://evil.test' }),
);
fail('예외가 발생해야 한다');
} catch (error) {
expect((error as BusinessException).context).toEqual({
reason: 'cross-site-request',
secFetchSite: null,
origin: 'https://evil.test',
});
}
});
});
+57
View File
@@ -0,0 +1,57 @@
import { CanActivate, ExecutionContext, Injectable } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import type { Request } from 'express';
import { BusinessException } from '@/common/errors/business.exception';
import {
buildAllowedOrigins,
checkRequestOrigin,
} from '@/shared/utils/request-origin';
/** 상태를 바꾸는 메서드 — 이 요청만 출처를 검사한다 (조회는 다른 사이트가 응답을 읽지 못한다) */
const STATE_CHANGING_METHODS = new Set(['POST', 'PUT', 'PATCH', 'DELETE']);
/**
* 출처 검사(CSRF 방어) — 전역 가드(APP_GUARD, app.module.ts).
*
* 쿠키 인증이라 다른 사이트가 사용자의 브라우저로 요청을 보내면 쿠키가 함께 간다.
* SameSite 쿠키와 JSON 전용 본문 파서가 1차로 막고, 이 가드가 브라우저가 붙이는 출처 정보로 한 번 더 막는다.
* 규칙은 `shared/utils/request-origin.ts` (docs/mail/api-contract.md 머리말) — 거부하면 403 AUTH_003.
*
* 가드는 인터셉터보다 먼저 돌므로 거부된 업로드는 파일을 읽기 전에 끝난다.
* 웹훅처럼 다른 사이트가 정당하게 부르는 엔드포인트가 생기면 이 가드에 예외 표시를 따로 만든다.
*/
@Injectable()
export class OriginGuard implements CanActivate {
private readonly allowedOrigins: ReadonlySet<string>;
constructor(config: ConfigService) {
this.allowedOrigins = new Set(
buildAllowedOrigins((key) => config.get<string>(key)),
);
}
canActivate(context: ExecutionContext): boolean {
if (context.getType() !== 'http') return true;
const request = context.switchToHttp().getRequest<Request>();
if (!STATE_CHANGING_METHODS.has(request.method.toUpperCase())) return true;
const secFetchSite = request.header('sec-fetch-site');
const origin = request.header('origin');
if (
checkRequestOrigin({ secFetchSite, origin }, this.allowedOrigins) !==
'rejected'
) {
return true;
}
throw new BusinessException('AUTH_003', {
context: {
reason: 'cross-site-request',
secFetchSite: secFetchSite ?? null,
origin: origin ?? null,
},
});
}
}
@@ -0,0 +1,63 @@
import type { CallHandler, ExecutionContext } from '@nestjs/common';
import { existsSync, mkdtempSync, rmSync, writeFileSync } from 'fs';
import { tmpdir } from 'os';
import { join } from 'path';
import { lastValueFrom, of, throwError } from 'rxjs';
import { DeleteUploadedFileInterceptor } from './delete-uploaded-file.interceptor';
function contextWith(file?: { path: string }): ExecutionContext {
const request = { file };
return {
switchToHttp: () => ({ getRequest: () => request }),
} as unknown as ExecutionContext;
}
/** finalize 의 비동기 삭제가 끝날 때까지 잠깐 기다린다 */
const settle = () => new Promise((resolve) => setTimeout(resolve, 20));
describe('DeleteUploadedFileInterceptor', () => {
let dir: string;
let file: string;
const interceptor = new DeleteUploadedFileInterceptor();
beforeEach(() => {
dir = mkdtempSync(join(tmpdir(), 'upload-cleanup-'));
file = join(dir, 'upload.bin');
writeFileSync(file, 'temp');
});
afterEach(() => rmSync(dir, { recursive: true, force: true }));
it('핸들러가 성공하면 임시 파일을 지운다', async () => {
const next: CallHandler = { handle: () => of({ id: 'blob' }) };
await expect(
lastValueFrom(interceptor.intercept(contextWith({ path: file }), next)),
).resolves.toEqual({ id: 'blob' });
await settle();
expect(existsSync(file)).toBe(false);
});
it('핸들러가 실패해도 임시 파일을 지운다', async () => {
const next: CallHandler = {
handle: () => throwError(() => new Error('engine down')),
};
await expect(
lastValueFrom(interceptor.intercept(contextWith({ path: file }), next)),
).rejects.toThrow('engine down');
await settle();
expect(existsSync(file)).toBe(false);
});
it('파일이 없는 요청은 그냥 통과한다', async () => {
const next: CallHandler = { handle: () => of(null) };
await expect(
lastValueFrom(interceptor.intercept(contextWith(), next)),
).resolves.toBeNull();
});
});
@@ -0,0 +1,46 @@
import {
CallHandler,
ExecutionContext,
Injectable,
Logger,
NestInterceptor,
} from '@nestjs/common';
import type { Request } from 'express';
import { rm } from 'fs/promises';
import { Observable, finalize } from 'rxjs';
import { traceTag } from '@/shared/logger/request-context';
/**
* 임시 업로드 파일 정리 — 핸들러가 끝나면(성공·실패 모두) multer 가 디스크에 받아 둔 파일을 지운다.
*
* 파일을 계속 보관하는 업로드(modules/upload)가 아니라, 받은 파일을 다른 곳(메일 엔진)으로 넘기고
* 버리는 엔드포인트에 쓴다. 반드시 FileInterceptor **뒤에** 둔다 (그래야 req.file 이 채워져 있다):
*
* @UseInterceptors(FileInterceptor('file'), DeleteUploadedFileInterceptor)
*
* multer 자체의 실패(크기 초과·연결 끊김)는 multer 가 받던 파일을 지운다 — 이 인터셉터까지 오지 않는다.
*/
@Injectable()
export class DeleteUploadedFileInterceptor implements NestInterceptor {
private readonly logger = new Logger(DeleteUploadedFileInterceptor.name);
intercept(context: ExecutionContext, next: CallHandler): Observable<unknown> {
const request = context.switchToHttp().getRequest<Request>();
return next.handle().pipe(finalize(() => void this.remove(request)));
}
private async remove(request: Request): Promise<void> {
const path = request.file?.path;
if (!path) return;
try {
await rm(path, { force: true });
} catch (error) {
// 지우지 못해도 응답은 이미 정해졌다 — 남은 파일은 기동 시 정리가 지운다
this.logger.warn(
`[SYS_001]${traceTag()} 임시 업로드 파일을 지우지 못했습니다: ${error instanceof Error ? error.message : String(error)}`,
);
}
}
}
@@ -0,0 +1,76 @@
import type { NextFunction, Request, Response } from 'express';
import { currentRequestId } from '@/shared/logger/request-context';
import {
REQUEST_ID_HEADER,
requestIdMiddleware,
resolveRequestId,
} from './request-id.middleware';
const UUID_PATTERN =
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
describe('resolveRequestId', () => {
it('영문·숫자·.·_·- 1~128자면 이어받는다 (nginx $request_id 등)', () => {
for (const value of [
'3f1c0a2e0f1b4f2a9a1e6c9f0a1b2c3d',
'req_2026-10-26.001',
'a'.repeat(128),
]) {
expect(resolveRequestId(value)).toBe(value);
}
});
it.each([
['없음', undefined],
['빈 값', ''],
['공백', 'has space'],
['따옴표·꺾쇠', '"><script>'],
['줄바꿈 흉내', 'a\\r\\nb'],
['너무 김', 'a'.repeat(129)],
['한글', '추적번호'],
])('그 밖의 값은 버리고 새로 만든다 — %s', (_label, value) => {
const resolved = resolveRequestId(value);
expect(resolved).toMatch(UUID_PATTERN);
expect(resolved).not.toBe(value);
});
});
describe('requestIdMiddleware', () => {
function run(incoming?: string) {
const headers: Record<string, string> = {};
if (incoming !== undefined) headers[REQUEST_ID_HEADER] = incoming;
const req = {
headers,
header: (name: string) => headers[name.toLowerCase()],
} as unknown as Request;
const setHeader = jest.fn();
const res = { setHeader } as unknown as Response;
let seenInContext: string | undefined;
const next: NextFunction = () => {
seenInContext = currentRequestId();
};
requestIdMiddleware(req, res, next);
return { headers, setHeader, seenInContext };
}
it('정한 ID 를 요청 헤더에 다시 쓰고(예외 필터가 읽는다) 응답 헤더로 돌려주며, 이후 처리는 그 컨텍스트 안에서 돈다', () => {
const { headers, setHeader, seenInContext } = run('trace-abc');
expect(headers[REQUEST_ID_HEADER]).toBe('trace-abc');
expect(setHeader).toHaveBeenCalledWith(REQUEST_ID_HEADER, 'trace-abc');
expect(seenInContext).toBe('trace-abc');
});
it('형식이 틀린 값은 요청 헤더에서도 새 ID 로 바꾼다 (원래 값이 로그에 실리지 않게)', () => {
const { headers, setHeader } = run('bad value <x>');
expect(headers[REQUEST_ID_HEADER]).toMatch(UUID_PATTERN);
expect(setHeader).toHaveBeenCalledWith(
REQUEST_ID_HEADER,
headers[REQUEST_ID_HEADER],
);
});
});
@@ -1,25 +1,41 @@
import { Injectable, NestMiddleware } from '@nestjs/common';
import { randomUUID } from 'crypto';
import { NextFunction, Request, Response } from 'express';
import type { NextFunction, Request, Response } from 'express';
import { runWithRequestContext } from '@/shared/logger/request-context';
/** 요청 추적 헤더명 — 로그·에러 응답의 traceId 와 동일한 값을 사용한다. */
export const REQUEST_ID_HEADER = 'x-request-id';
/**
* 요청 추적 ID 미들웨어.
* 프록시/클라이언트가 보낸 값이 있으면 이어받고, 없으면 새로 발급한다.
* 발급된 ID 는 응답 헤더로도 돌려주어 클라이언트가 문의 시 제시할 수 있게 한다.
* 이어받을 수 있는 요청 ID 형식 — 영문·숫자·`.`·`_`·`-` 1~128자.
* 프록시(nginx `$request_id` 등)가 붙인 값은 이어받고, 그 밖의 값(공백·따옴표·아주 긴 값 등)은 버리고 새로 만든다.
* 클라이언트가 보낸 임의 문자열이 로그·응답 헤더에 그대로 실리지 않게 하기 위해서다.
*/
@Injectable()
export class RequestIdMiddleware implements NestMiddleware {
use(req: Request, res: Response, next: NextFunction): void {
const incoming = req.header(REQUEST_ID_HEADER);
const requestId =
incoming && incoming.trim() !== '' ? incoming : randomUUID();
const REQUEST_ID_PATTERN = /^[A-Za-z0-9._-]{1,128}$/;
req.headers[REQUEST_ID_HEADER] = requestId;
res.setHeader(REQUEST_ID_HEADER, requestId);
next();
}
/** 들어온 값이 쓸 수 있는 형식이면 그것을, 아니면 새 UUID */
export function resolveRequestId(incoming: string | undefined): string {
return incoming !== undefined && REQUEST_ID_PATTERN.test(incoming)
? incoming
: randomUUID();
}
/**
* 요청 추적 ID 미들웨어 (express) — `configureApp()` 이 **가장 먼저** 등록한다.
* 본문 파서보다 앞에 있어야 본문 파서의 실패(413·400·415)에도 응답 traceId 와 로그가 붙는다.
*
* 발급한 ID 는 요청 헤더에 다시 써 두고(예외 필터가 읽는다) 응답 헤더로도 돌려준다.
* 이후의 처리는 요청 컨텍스트 안에서 돌아, 어느 계층의 로그에서도 `traceTag()` 로 같은 ID 를 붙일 수 있다.
*/
export function requestIdMiddleware(
req: Request,
res: Response,
next: NextFunction,
): void {
const requestId = resolveRequestId(req.header(REQUEST_ID_HEADER));
req.headers[REQUEST_ID_HEADER] = requestId;
res.setHeader(REQUEST_ID_HEADER, requestId);
runWithRequestContext({ requestId }, next);
}
+28 -45
View File
@@ -1,58 +1,54 @@
import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module';
import { Logger, RequestMethod, ValidationPipe } from '@nestjs/common';
import { Logger } from '@nestjs/common';
import type { NestExpressApplication } from '@nestjs/platform-express';
import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger';
import { WinstonModule } from 'nest-winston';
import helmet from 'helmet';
import type {
Express,
Request as ExpressRequest,
Response as ExpressResponse,
} from 'express';
import { Reflector } from '@nestjs/core';
import { HttpExceptionFilter } from '@/common/filters/http-exception.filter';
import { TransformInterceptor } from '@/common/interceptors/transform.interceptor';
import { configureApp } from '@/app.setup';
import { ACCESS_TOKEN_COOKIE } from '@/modules/auth/auth.constants';
import { winstonConfig } from '@/shared/logger/winston.config';
import {
buildAllowedOrigins,
normalizeOrigin,
} from '@/shared/utils/request-origin';
async function bootstrap() {
const app = await NestFactory.create(AppModule, {
const app = await NestFactory.create<NestExpressApplication>(AppModule, {
logger: WinstonModule.createLogger(winstonConfig),
// 본문 파서는 configureApp 이 직접 등록한다 (JSON 만, 크기 한도 지정)
bodyParser: false,
});
// 리버스 프록시(Nginx, Docker 배포 환경 등) 뒤에서 올바른 클라이언트 IP 식별 지원을 위해 설정합니다.
const expressApp = app.getHttpAdapter().getInstance() as Express;
expressApp.set('trust proxy', 1);
// 글로벌 prefix — 모든 라우트가 /api/* 로 정규화됨 (Flutter, Web 양쪽 baseURL과 일치)
app.setGlobalPrefix('api', {
// 헬스체크 / 루트 상태 엔드포인트는 prefix 미적용
exclude: [{ path: '/', method: RequestMethod.GET }],
});
// 기본 주소('/') 접속 시 상태 확인용 텍스트 응답 추가
// 기본 주소('/') 접속 시 상태 확인용 텍스트 응답 (컨테이너 healthcheck 가 읽는다)
const expressApp = app.getHttpAdapter().getInstance();
expressApp.get('/', (_req: ExpressRequest, res: ExpressResponse) => {
res.send('Backend API Server is running 정상 구동 중입니다.');
});
// 1. 보안 헤더 (Helmet) - XSS 방지 및 기본 웹 보안
app.use(helmet());
// 1. 공통 HTTP 파이프라인 — prefix · helmet · 쿠키 · 본문 파서 · 검증 · 표준 응답
// e2e 테스트도 같은 함수를 쓴다 (app.setup.ts)
configureApp(app);
// 2. 화이트리스트 기반 CORS 정책
const frontendUrl = process.env.FRONTEND_URL || 'http://localhost:5173';
const corsOriginEnv = process.env.CORS_ORIGIN;
// 2. 화이트리스트 기반 CORS 정책 — 운영은 CORS_ORIGIN 만, localhost 기본값은 개발에서만.
// 화면과 API 를 같은 주소로 배포하면 CORS 는 쓰이지 않는다 (docs/mail/api-contract.md 머리말).
// 같은 목록을 상태 변경 요청의 출처 검사(OriginGuard)도 쓴다.
const allowedOrigins = new Set(
buildAllowedOrigins((key) => process.env[key]),
);
app.enableCors({
origin: (
origin: string | undefined,
callback: (err: Error | null, allow?: boolean) => void,
) => {
// 서버간의 통신(origin이 undefined)이거나 명시된 리스트일 때만 허용
const whitelist = [frontendUrl, 'http://localhost:3000'];
if (corsOriginEnv) whitelist.push(corsOriginEnv);
if (!origin || whitelist.indexOf(origin) !== -1) {
// 서버 간 통신(origin 없음)이거나 목록에 있는 출처일 때만 허용
const normalized = origin === undefined ? null : normalizeOrigin(origin);
if (!origin || (normalized !== null && allowedOrigins.has(normalized))) {
callback(null, true);
} else {
// Error 를 넘기면 500(SYS_001)이 되어 원인 파악이 어려우므로,
@@ -67,30 +63,17 @@ async function bootstrap() {
// 라우트 단위 제어(@Throttle / @SkipThrottle)가 가능하고, 한도 초과는 REQ_001(429)로 응답된다.
// (전역 예외 필터가 429 → REQ_001 로 매핑한다 — .claude/rules/error-codes.md)
// 4. 전역 DTO 유효성 파이프 (강력한 검증 단계)
app.useGlobalPipes(
new ValidationPipe({
whitelist: true, // DTO에 불필요한 값이 오면 삭제
forbidNonWhitelisted: true, // DTO에 없는 필드가 주입되면 400 에러 발생
transform: true, // 네트워크 데이터를 DTO나 기본 타입으로 자동 변환
}),
);
// 5. 프론트엔드 연동을 위한 규격화된 에러 & 성공 페이로드 인터셉터 적용
app.useGlobalFilters(new HttpExceptionFilter());
app.useGlobalInterceptors(new TransformInterceptor(app.get(Reflector)));
// 6. Swagger API 문서 설정
// 4. Swagger API 문서 설정 — 인증은 HttpOnly 쿠키(access_token)뿐이다 (Authorization 헤더는 읽지 않는다)
const config = new DocumentBuilder()
.setTitle('Backend API Documentation')
.setDescription('The API documentation conforming to the global guidelines')
.setVersion('1.0')
.addBearerAuth()
.addCookieAuth(ACCESS_TOKEN_COOKIE)
.build();
const document = SwaggerModule.createDocument(app, config);
SwaggerModule.setup('api-docs', app, document);
// 7. Graceful shutdown — SIGTERM 수신 시 새 요청을 받지 않고
// 5. Graceful shutdown — SIGTERM 수신 시 새 요청을 받지 않고
// 처리 중인 요청을 마친 뒤 DB/Redis 커넥션을 정리하고 종료한다.
// (이 설정이 없으면 배포·재시작 때 처리 중이던 요청이 그대로 끊긴다)
app.enableShutdownHooks();
@@ -0,0 +1,33 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
/**
* 로그인 세션 표 (docs/mail/api-contract.md 1절 "세션 규칙") — 이 프로젝트의 첫 테이블.
* 리프레시 토큰은 해시만 저장하고, 1회용 회전·재사용 감지·로그아웃 폐기를 이 표로 판단한다.
* 인덱스: expires_at(만료 정리) · revoked_at 부분 인덱스(폐기된 행만 — 정리 작업의 두 번째 조건)
* (자동 생성: npm run migration:generate — 엔티티는 modules/auth/auth-session.entity.ts)
*/
export class CreateAuthSessions1793010690903 implements MigrationInterface {
name = 'CreateAuthSessions1793010690903';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`CREATE TABLE "auth_sessions" ("id" uuid NOT NULL, "user_email" character varying(254) NOT NULL, "token_hash" character(64) NOT NULL, "previous_token_hash" character(64), "rotated_at" TIMESTAMP WITH TIME ZONE, "created_at" TIMESTAMP WITH TIME ZONE NOT NULL, "expires_at" TIMESTAMP WITH TIME ZONE NOT NULL, "absolute_expires_at" TIMESTAMP WITH TIME ZONE NOT NULL, "revoked_at" TIMESTAMP WITH TIME ZONE, "revoked_reason" character varying(32), CONSTRAINT "PK_641507381f32580e8479efc36cd" PRIMARY KEY ("id"))`,
);
await queryRunner.query(
`CREATE INDEX "IDX_auth_sessions_expires_at" ON "auth_sessions" ("expires_at") `,
);
await queryRunner.query(
`CREATE INDEX "IDX_auth_sessions_revoked_at" ON "auth_sessions" ("revoked_at") WHERE "revoked_at" IS NOT NULL`,
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`DROP INDEX "public"."IDX_auth_sessions_revoked_at"`,
);
await queryRunner.query(
`DROP INDEX "public"."IDX_auth_sessions_expires_at"`,
);
await queryRunner.query(`DROP TABLE "auth_sessions"`);
}
}
@@ -0,0 +1,86 @@
import type { Response } from 'express';
import {
clearSessionCookies,
sessionCookieOptions,
setSessionCookies,
} from './auth-cookies';
import type { AuthSettings } from './auth.settings';
const settings: AuthSettings = {
jwtSecret: 'secret',
accessTtlSeconds: 3600,
refreshTtlSeconds: 14 * 24 * 3600,
secureCookies: false,
};
function fakeResponse() {
return {
cookie: jest.fn(),
clearCookie: jest.fn(),
};
}
describe('세션 쿠키', () => {
it('액세스 쿠키: HttpOnly · SameSite=Lax · Path=/api · Max-Age=액세스 만료', () => {
expect(sessionCookieOptions('access', settings)).toEqual({
httpOnly: true,
secure: false,
sameSite: 'lax',
path: '/api',
maxAge: 3600 * 1000,
});
});
it('리프레시 쿠키: HttpOnly · SameSite=Strict · Path=/api/auth · Max-Age=리프레시 만료', () => {
expect(sessionCookieOptions('refresh', settings)).toEqual({
httpOnly: true,
secure: false,
sameSite: 'strict',
path: '/api/auth',
maxAge: 14 * 24 * 3600 * 1000,
});
});
it('운영에서는 Secure 를 붙인다', () => {
const production = { ...settings, secureCookies: true };
expect(sessionCookieOptions('access', production).secure).toBe(true);
expect(sessionCookieOptions('refresh', production).secure).toBe(true);
});
it('두 쿠키를 함께 심고, 지울 때는 같은 Path·속성으로 지운다', () => {
const res = fakeResponse();
setSessionCookies(
res as unknown as Response,
{ accessToken: 'a.b.c', refreshToken: 'd.e.f', refreshTtlSeconds: 600 },
settings,
);
clearSessionCookies(res as unknown as Response, settings);
expect(res.cookie).toHaveBeenCalledWith(
'access_token',
'a.b.c',
expect.objectContaining({ path: '/api' }),
);
// 리프레시 쿠키 Max-Age 는 세션의 남은 수명을 따른다 (절대 만료가 가까우면 짧아진다)
expect(res.cookie).toHaveBeenCalledWith(
'refresh_token',
'd.e.f',
expect.objectContaining({ path: '/api/auth', maxAge: 600 * 1000 }),
);
expect(res.clearCookie).toHaveBeenCalledWith('access_token', {
httpOnly: true,
secure: false,
sameSite: 'lax',
path: '/api',
});
expect(res.clearCookie).toHaveBeenCalledWith('refresh_token', {
httpOnly: true,
secure: false,
sameSite: 'strict',
path: '/api/auth',
});
});
});
+73
View File
@@ -0,0 +1,73 @@
import type { CookieOptions, Response } from 'express';
import {
ACCESS_TOKEN_COOKIE,
ACCESS_TOKEN_COOKIE_PATH,
REFRESH_TOKEN_COOKIE,
REFRESH_TOKEN_COOKIE_PATH,
} from './auth.constants';
import type { AuthSettings } from './auth.settings';
/**
* 세션 쿠키 — 계약 1절. 토큰은 응답 본문에 싣지 않고 HttpOnly 쿠키로만 주고받는다.
*
* | 쿠키 | SameSite | Path | Max-Age |
* | access_token | Lax | /api | JWT_ACCESS_EXPIRES_IN |
* | refresh_token | Strict | /api/auth | 세션의 남은 미사용 기간 (JWT_REFRESH_EXPIRES_IN, 절대 만료 30일 이내) |
*/
export interface SessionTokens {
readonly accessToken: string;
readonly refreshToken: string;
/** 리프레시 토큰의 남은 수명(초) — 세션 절대 만료가 가까우면 JWT_REFRESH_EXPIRES_IN 보다 짧다 */
readonly refreshTtlSeconds: number;
}
type CookieKind = 'access' | 'refresh';
/** 지울 때도 같은 Path·속성을 써야 브라우저가 같은 쿠키로 알아본다 */
function baseOptions(kind: CookieKind, settings: AuthSettings): CookieOptions {
return {
httpOnly: true,
secure: settings.secureCookies,
sameSite: kind === 'access' ? 'lax' : 'strict',
path:
kind === 'access' ? ACCESS_TOKEN_COOKIE_PATH : REFRESH_TOKEN_COOKIE_PATH,
};
}
export function sessionCookieOptions(
kind: CookieKind,
settings: AuthSettings,
ttlSeconds: number = kind === 'access'
? settings.accessTtlSeconds
: settings.refreshTtlSeconds,
): CookieOptions {
// express 의 maxAge 는 밀리초다 (헤더에는 초 단위 Max-Age 와 Expires 로 나간다)
return { ...baseOptions(kind, settings), maxAge: ttlSeconds * 1000 };
}
export function setSessionCookies(
res: Response,
tokens: SessionTokens,
settings: AuthSettings,
): void {
res.cookie(
ACCESS_TOKEN_COOKIE,
tokens.accessToken,
sessionCookieOptions('access', settings),
);
res.cookie(
REFRESH_TOKEN_COOKIE,
tokens.refreshToken,
sessionCookieOptions('refresh', settings, tokens.refreshTtlSeconds),
);
}
export function clearSessionCookies(
res: Response,
settings: AuthSettings,
): void {
res.clearCookie(ACCESS_TOKEN_COOKIE, baseOptions('access', settings));
res.clearCookie(REFRESH_TOKEN_COOKIE, baseOptions('refresh', settings));
}
@@ -0,0 +1,37 @@
import { Logger } from '@nestjs/common';
import { ENDED_SESSION_RETENTION_MS } from './auth.constants';
import { AuthSessionCleanup } from './auth-session.cleanup';
import type { AuthSessionRepository } from './auth-session.repository';
describe('AuthSessionCleanup', () => {
afterEach(() => jest.restoreAllMocks());
it('보존 기간(7일)보다 먼저 끝난 세션을 지운다', async () => {
jest.spyOn(Logger.prototype, 'debug').mockImplementation(() => undefined);
const deleteEndedBefore = jest.fn().mockResolvedValue(3);
const cleanup = new AuthSessionCleanup({
deleteEndedBefore,
} as unknown as AuthSessionRepository);
const now = new Date('2026-10-26T09:00:00.000Z');
await expect(cleanup.purgeEndedSessions(now)).resolves.toBe(3);
expect(deleteEndedBefore).toHaveBeenCalledWith(
new Date(now.getTime() - ENDED_SESSION_RETENTION_MS),
);
});
it('DB 오류가 나도 앱을 멈추지 않고 오류 로그만 남긴다 (다음 주기에 다시 한다)', async () => {
const error = jest
.spyOn(Logger.prototype, 'error')
.mockImplementation(() => undefined);
const cleanup = new AuthSessionCleanup({
deleteEndedBefore: jest.fn().mockRejectedValue(new Error('db down')),
} as unknown as AuthSessionRepository);
await expect(cleanup.purgeEndedSessions()).resolves.toBe(0);
expect(error).toHaveBeenCalledWith(
expect.stringContaining('[SYS_001] 끝난 세션 정리에 실패했습니다'),
);
});
});
@@ -0,0 +1,37 @@
import { Injectable, Logger } from '@nestjs/common';
import { Cron, CronExpression } from '@nestjs/schedule';
import { ENDED_SESSION_RETENTION_MS } from './auth.constants';
import { AuthSessionRepository } from './auth-session.repository';
/**
* 끝난 세션 정리 — 만료·폐기되고 보존 기간(7일)이 지난 세션을 지운다.
* 보존 기간 동안은 "왜 로그아웃됐나요" 문의에 폐기 사유(revoked_reason)로 답할 수 있다.
*
* ⚠️ 여러 인스턴스로 늘리면 모든 인스턴스가 같은 시각에 같은 DELETE 를 실행한다 (결과는 같지만 낭비).
*/
@Injectable()
export class AuthSessionCleanup {
private readonly logger = new Logger(AuthSessionCleanup.name);
constructor(private readonly sessions: AuthSessionRepository) {}
@Cron(CronExpression.EVERY_HOUR, { name: 'auth-session-cleanup' })
async purgeEndedSessions(now: Date = new Date()): Promise<number> {
try {
const removed = await this.sessions.deleteEndedBefore(
new Date(now.getTime() - ENDED_SESSION_RETENTION_MS),
);
// 반복되는 정상 작업이라 info 로 남기지 않는다 (.claude/rules/logging.md)
if (removed > 0)
this.logger.debug(`끝난 세션 ${removed}건을 지웠습니다.`);
return removed;
} catch (error) {
// 정리 작업이 실패해도 앱은 계속 돈다 — 다음 주기에 다시 시도한다
this.logger.error(
`[SYS_001] 끝난 세션 정리에 실패했습니다: ${error instanceof Error ? error.message : String(error)}`,
);
return 0;
}
}
}
@@ -0,0 +1,73 @@
import { Column, Entity, Index, PrimaryColumn } from 'typeorm';
/** 메일 주소 최대 길이 (RFC 5321 경로 한도) — mail.constants 의 MAX_EMAIL_LENGTH 와 같다 */
const EMAIL_COLUMN_LENGTH = 254;
/** SHA-256 16진 문자열 길이 */
const TOKEN_HASH_LENGTH = 64;
/** 폐기 사유 길이 */
const REVOKED_REASON_LENGTH = 32;
/** 세션을 끝낸 사유 — 로그·문의 대응용 */
export type SessionRevokedReason =
| 'logout'
| 'refresh-token-reused'
| 'account-unavailable';
/**
* 로그인 세션 (계약 1절 "세션 규칙") — 리프레시 토큰의 1회용 회전과 폐기를 서버에서 판단한다.
*
* - 리프레시 토큰 원문은 저장하지 않는다. SHA-256 해시만 둔다 (DB 가 새어도 토큰을 만들 수 없다)
* - 액세스 토큰은 이 표를 보지 않는다 (서버 조회 없이 검증 — 세션을 끊어도 만료까지는 유효하다)
* - 끝난 세션은 정리 작업이 일정 기간 뒤에 지운다 (auth-session.cleanup.ts)
*/
@Entity({ name: 'auth_sessions' })
export class AuthSessionEntity {
/** 세션 id — 리프레시 토큰의 `sid` 클레임. 토큰을 서명하기 전에 정해야 하므로 앱이 만든다 */
@PrimaryColumn({ type: 'uuid' })
id!: string;
@Column({ name: 'user_email', type: 'varchar', length: EMAIL_COLUMN_LENGTH })
userEmail!: string;
/** 지금 쓸 수 있는 리프레시 토큰의 해시 */
@Column({ name: 'token_hash', type: 'char', length: TOKEN_HASH_LENGTH })
tokenHash!: string;
/** 바로 이전 토큰의 해시 — 동시 갱신 유예 판단용 (그보다 오래된 토큰이 오면 재사용) */
@Column({
name: 'previous_token_hash',
type: 'char',
length: TOKEN_HASH_LENGTH,
nullable: true,
})
previousTokenHash!: string | null;
/** 마지막 회전 시각 */
@Column({ name: 'rotated_at', type: 'timestamptz', nullable: true })
rotatedAt!: Date | null;
@Column({ name: 'created_at', type: 'timestamptz' })
createdAt!: Date;
/** 미사용 만료 — 회전할 때마다 늘어나지만 절대 만료를 넘지 않는다 */
@Index('IDX_auth_sessions_expires_at')
@Column({ name: 'expires_at', type: 'timestamptz' })
expiresAt!: Date;
/** 절대 만료 — 로그인 후 30일 */
@Column({ name: 'absolute_expires_at', type: 'timestamptz' })
absoluteExpiresAt!: Date;
/** 폐기 시각 — 정리 작업이 이 열로 찾으므로 폐기된 행만 담는 부분 인덱스를 둔다 (대부분의 행은 null) */
@Index('IDX_auth_sessions_revoked_at', { where: '"revoked_at" IS NOT NULL' })
@Column({ name: 'revoked_at', type: 'timestamptz', nullable: true })
revokedAt!: Date | null;
@Column({
name: 'revoked_reason',
type: 'varchar',
length: REVOKED_REASON_LENGTH,
nullable: true,
})
revokedReason!: SessionRevokedReason | null;
}
@@ -0,0 +1,98 @@
import {
classifyRefresh,
hashRefreshToken,
nextIdleExpiry,
type SessionSnapshot,
} from './auth-session.policy';
const ARA = 'ara.kim@arasystem.test';
const NOW = new Date('2026-10-26T09:00:00.000Z');
const GRACE_MS = 30_000;
const DAY_MS = 24 * 60 * 60 * 1000;
const CURRENT = hashRefreshToken('current.token');
const PREVIOUS = hashRefreshToken('previous.token');
const OLDER = hashRefreshToken('older.token');
function session(overrides: Partial<SessionSnapshot> = {}): SessionSnapshot {
return {
userEmail: ARA,
tokenHash: CURRENT,
previousTokenHash: PREVIOUS,
rotatedAt: new Date(NOW.getTime() - 5_000),
expiresAt: new Date(NOW.getTime() + DAY_MS),
absoluteExpiresAt: new Date(NOW.getTime() + 10 * DAY_MS),
revokedAt: null,
...overrides,
};
}
const classify = (snapshot: SessionSnapshot | null, hash: string, now = NOW) =>
classifyRefresh(snapshot, { sub: ARA }, hash, now, GRACE_MS);
describe('classifyRefresh', () => {
it('지금 토큰이면 current', () => {
expect(classify(session(), CURRENT)).toBe('current');
});
it('바로 이전 토큰이 유예 시간 안에 오면 grace (동시 갱신)', () => {
expect(classify(session(), PREVIOUS)).toBe('grace');
});
it('바로 이전 토큰이라도 유예 시간이 지나면 재사용(reused)', () => {
const rotatedLongAgo = session({
rotatedAt: new Date(NOW.getTime() - GRACE_MS),
});
expect(classify(rotatedLongAgo, PREVIOUS)).toBe('reused');
});
it('두 번 이상 지난 토큰은 시간과 무관하게 재사용(reused)', () => {
expect(classify(session(), OLDER)).toBe('reused');
});
it('회전한 적 없는 세션에 다른 토큰이 오면 재사용(reused)', () => {
expect(
classify(session({ previousTokenHash: null, rotatedAt: null }), OLDER),
).toBe('reused');
});
it('끊긴 세션은 어떤 토큰이든 revoked', () => {
const revoked = session({ revokedAt: new Date(NOW.getTime() - 1) });
expect(classify(revoked, CURRENT)).toBe('revoked');
expect(classify(revoked, OLDER)).toBe('revoked');
});
it('미사용 만료·절대 만료가 지나면 expired', () => {
expect(classify(session({ expiresAt: NOW }), CURRENT)).toBe('expired');
expect(classify(session({ absoluteExpiresAt: NOW }), CURRENT)).toBe(
'expired',
);
});
it('세션이 없거나 주인이 다르면 missing', () => {
expect(classify(null, CURRENT)).toBe('missing');
expect(
classify(session({ userEmail: 'doyun.lee@arasystem.test' }), CURRENT),
).toBe('missing');
});
});
describe('hashRefreshToken', () => {
it('SHA-256 16진 64자 — 원문을 담지 않는다', () => {
const hash = hashRefreshToken('header.payload.signature');
expect(hash).toMatch(/^[0-9a-f]{64}$/);
expect(hash).not.toContain('payload');
});
});
describe('nextIdleExpiry', () => {
it('지금 + 미사용 기간, 단 절대 만료를 넘지 않는다', () => {
const absolute = new Date(NOW.getTime() + 20 * DAY_MS);
expect(nextIdleExpiry(NOW, 14 * 24 * 3600, absolute)).toEqual(
new Date(NOW.getTime() + 14 * DAY_MS),
);
expect(nextIdleExpiry(NOW, 30 * 24 * 3600, absolute)).toEqual(absolute);
});
});
@@ -0,0 +1,72 @@
import { createHash, timingSafeEqual } from 'crypto';
/** 판단에 필요한 세션 값 — 엔티티의 일부 */
export interface SessionSnapshot {
readonly userEmail: string;
readonly tokenHash: string;
readonly previousTokenHash: string | null;
readonly rotatedAt: Date | null;
readonly expiresAt: Date;
readonly absoluteExpiresAt: Date;
readonly revokedAt: Date | null;
}
/**
* 리프레시 요청이 세션의 어느 상태에 해당하는가 (계약 1절 "세션 규칙").
*
* - current: 지금 토큰 → 회전한다
* - grace: 바로 이전 토큰이 회전 직후 유예 시간 안에 왔다 → 동시 갱신(탭 여러 개). 새 토큰 없이 성공만
* - reused: 이미 쓴 토큰 → 탈취로 보고 세션 전체를 끊는다
* - revoked / expired / missing: 끝난 세션 → AUTH_001
*/
export type RefreshState =
| 'current'
| 'grace'
| 'reused'
| 'revoked'
| 'expired'
| 'missing';
/** 리프레시 토큰을 저장할 형태 — SHA-256 16진 (토큰은 무작위 jti 를 담아 엔트로피가 충분하다) */
export function hashRefreshToken(token: string): string {
return createHash('sha256').update(token).digest('hex');
}
function sameHash(a: string, b: string): boolean {
const left = Buffer.from(a, 'hex');
const right = Buffer.from(b, 'hex');
return left.length === right.length && timingSafeEqual(left, right);
}
export function classifyRefresh(
session: SessionSnapshot | null,
claims: { readonly sub: string },
presentedHash: string,
now: Date,
graceMs: number,
): RefreshState {
// 서명이 맞는데 주인이 다르면 우리가 만든 토큰이 아니다 — 없는 세션과 같게 다룬다
if (!session || session.userEmail !== claims.sub) return 'missing';
if (session.revokedAt !== null) return 'revoked';
if (session.expiresAt <= now || session.absoluteExpiresAt <= now) {
return 'expired';
}
if (sameHash(session.tokenHash, presentedHash)) return 'current';
const withinGrace =
session.previousTokenHash !== null &&
session.rotatedAt !== null &&
sameHash(session.previousTokenHash, presentedHash) &&
now.getTime() - session.rotatedAt.getTime() < graceMs;
return withinGrace ? 'grace' : 'reused';
}
/** 다음 미사용 만료 — 지금 + 미사용 기간, 단 절대 만료를 넘지 않는다 */
export function nextIdleExpiry(
now: Date,
idleTtlSeconds: number,
absoluteExpiresAt: Date,
): Date {
const idle = now.getTime() + idleTtlSeconds * 1000;
return new Date(Math.min(idle, absoluteExpiresAt.getTime()));
}
@@ -0,0 +1,88 @@
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { IsNull, MoreThan, type Repository } from 'typeorm';
import {
AuthSessionEntity,
type SessionRevokedReason,
} from './auth-session.entity';
export interface RotateSessionCommand {
readonly id: string;
/** 요청에 실려 온 토큰의 해시 — 지금 토큰이 이것일 때만 바꾼다 */
readonly presentedHash: string;
readonly nextHash: string;
readonly nextExpiresAt: Date;
readonly now: Date;
}
/**
* 세션 표 접근 — 판단(회전·재사용·만료)은 AuthService 와 auth-session.policy.ts 가 한다.
*/
@Injectable()
export class AuthSessionRepository {
constructor(
@InjectRepository(AuthSessionEntity)
private readonly sessions: Repository<AuthSessionEntity>,
) {}
async create(session: AuthSessionEntity): Promise<void> {
await this.sessions.insert(session);
}
findById(id: string): Promise<AuthSessionEntity | null> {
return this.sessions.findOneBy({ id });
}
/**
* 1회용 회전 — **조건부 UPDATE 한 번**으로 한다.
* 지금 토큰이 요청의 토큰이고 세션이 살아 있을 때만 바뀐다. 같은 토큰으로 동시에 온 두 요청 중
* 하나만 성공한다 (PostgreSQL 은 먼저 잡은 쪽이 커밋한 뒤 나머지의 WHERE 를 다시 평가한다).
* @returns 바꿨으면 true
*/
async rotate(command: RotateSessionCommand): Promise<boolean> {
const result = await this.sessions.update(
{
id: command.id,
tokenHash: command.presentedHash,
revokedAt: IsNull(),
expiresAt: MoreThan(command.now),
absoluteExpiresAt: MoreThan(command.now),
},
{
tokenHash: command.nextHash,
previousTokenHash: command.presentedHash,
rotatedAt: command.now,
expiresAt: command.nextExpiresAt,
},
);
return result.affected === 1;
}
/** 세션을 끝낸다 — 이미 끝난 세션은 사유를 덮어쓰지 않는다 */
async revoke(
id: string,
reason: SessionRevokedReason,
now: Date,
): Promise<void> {
await this.sessions.update(
{ id, revokedAt: IsNull() },
{ revokedAt: now, revokedReason: reason },
);
}
/**
* `before` 보다 먼저 끝난(만료·폐기) 세션을 지운다 → 지운 수.
* 미사용 만료(expires_at)는 항상 절대 만료 이하로 저장하므로(nextIdleExpiry) 절대 만료를 따로 볼 필요가 없다.
* 두 조건 모두 인덱스를 탄다 — expires_at 인덱스, revoked_at 부분 인덱스(폐기된 행만).
*/
async deleteEndedBefore(before: Date): Promise<number> {
const result = await this.sessions
.createQueryBuilder()
.delete()
.from(AuthSessionEntity)
.where('expires_at < :before OR revoked_at < :before', { before })
.execute();
return result.affected ?? 0;
}
}
@@ -0,0 +1,19 @@
/**
* JWT 페이로드.
* `typ` 으로 액세스/리프레시를 구분한다 — 리프레시 토큰을 액세스 쿠키에 넣어도 통하지 않고, 그 반대도 마찬가지다.
*/
export type AuthTokenType = 'access' | 'refresh';
export interface AuthTokenPayload {
/** 직원 메일 주소 */
readonly sub: string;
readonly typ: AuthTokenType;
/** 표시 이름 — 액세스 토큰에만 넣는다 (/auth/me 가 메일 엔진에 묻지 않고 답한다) */
readonly name?: string;
/** 세션 id — 리프레시 토큰에만 넣는다 (auth_sessions.id). 회전·폐기를 이 세션 기준으로 판단한다 */
readonly sid?: string;
/** 토큰 id — 같은 순간에 발급한 토큰도 서로 다른 값(해시)이 되게 한다 */
readonly jti?: string;
readonly iat?: number;
readonly exp?: number;
}
@@ -0,0 +1,72 @@
/**
* 인증 상수 — 계약(docs/mail/api-contract.md 1절)의 쿠키 규칙과 한도를 한 곳에 모은다.
*/
/** 액세스 JWT 쿠키 — 모든 API 경로로 간다 */
export const ACCESS_TOKEN_COOKIE = 'access_token';
export const ACCESS_TOKEN_COOKIE_PATH = '/api';
/** 리프레시 JWT 쿠키 — 인증 경로로만 간다 (다른 API 요청에 실려 다니지 않게) */
export const REFRESH_TOKEN_COOKIE = 'refresh_token';
export const REFRESH_TOKEN_COOKIE_PATH = '/api/auth';
const MINUTE_SECONDS = 60;
const HOUR_SECONDS = 60 * MINUTE_SECONDS;
const DAY_SECONDS = 24 * HOUR_SECONDS;
/** env 가 없을 때의 만료 기본값 (JWT_ACCESS_EXPIRES_IN · JWT_REFRESH_EXPIRES_IN) */
export const DEFAULT_ACCESS_TTL_SECONDS = HOUR_SECONDS;
/** 리프레시 = 세션의 **미사용** 만료 — 이 기간 동안 갱신하지 않으면 세션이 끝난다 (계약 1절) */
export const DEFAULT_REFRESH_TTL_SECONDS = 14 * DAY_SECONDS;
/**
* 액세스 토큰 만료 허용 범위 (env 검증) — 액세스 토큰은 서버 조회 없이 검증되므로
* 세션을 끊어도 만료까지 유효하다. 그래서 짧게 둔다 (.claude/rules/security.md).
*/
export const ACCESS_TTL_MIN_SECONDS = 5 * MINUTE_SECONDS;
export const ACCESS_TTL_MAX_SECONDS = HOUR_SECONDS;
/** 세션 절대 만료 — 로그인 후 이 기간이 지나면 갱신해도 끝난다 (계약 1절) */
export const SESSION_ABSOLUTE_TTL_SECONDS = 30 * DAY_SECONDS;
/** 리프레시(미사용 만료)는 절대 만료를 넘을 수 없다 */
export const REFRESH_TTL_MAX_SECONDS = SESSION_ABSOLUTE_TTL_SECONDS;
/**
* 직전 리프레시 토큰의 유예 시간 (밀리초).
* 탭 두 개가 같은 리프레시 쿠키로 동시에 갱신하면 한쪽은 이미 회전된 토큰을 내게 된다.
* 회전 직후 이 시간 안에 **바로 이전** 토큰이 오면 탈취로 보지 않고, 새 토큰 없이 성공만 돌려준다
* (이긴 쪽 응답이 이미 새 쿠키를 구웠다). 유예 중에도 토큰을 새로 주지 않으므로 훔친 토큰으로 얻는 것은 없다.
* 이 시간이 지난 뒤 오거나 두 번 이상 지난 토큰이 오면 재사용으로 보고 세션 전체를 끊는다.
*/
export const REFRESH_REUSE_GRACE_MS = 30_000;
/** 끝난 세션(만료·폐기)을 지우기 전까지 남겨 두는 기간 — "왜 로그아웃됐나요" 문의 확인용 */
export const ENDED_SESSION_RETENTION_MS = 7 * DAY_SECONDS * 1000;
/** 서명 알고리즘 — 검증 때도 이것만 받는다 (alg 바꿔치기 방지) */
export const JWT_ALGORITHM = 'HS256';
/**
* 로그인 **실패** 한도 (계약 1절) — 성공한 로그인은 세지 않는다.
* 사무실처럼 여러 직원이 한 IP 를 쓰면 성공까지 세는 빡빡한 한도는 정상 로그인까지 막기 때문이다.
* 메일 엔진은 백엔드(신뢰 대역)를 자동 차단에서 빼므로 무차별 대입 방어는 백엔드 몫이다 (mail/README.md).
* 두 한도 모두 없는 주소에도 똑같이 적용해 계정 존재 여부를 드러내지 않는다.
*/
/** 같은 IP 에서 1분에 실패 20회 — 여러 계정을 돌아가며 찔러 보는 공격 */
export const LOGIN_IP_FAILURE_LIMIT = 20;
export const LOGIN_IP_FAILURE_WINDOW_MS = 60_000;
/** 같은 주소로 15분에 실패 10회 — IP 를 바꿔 가며 한 계정을 노리는 공격 */
export const LOGIN_ADDRESS_FAILURE_LIMIT = 10;
export const LOGIN_ADDRESS_FAILURE_WINDOW_MS = 15 * 60_000;
/**
* 로그인 요청 수 상한 (IP 당 1분) — 실패 한도와 별개로 요청 자체를 넉넉하게 제한한다.
* 실패 한도가 무차별 대입을 막고, 이 상한은 한 IP 가 엔진 확인을 폭주시키지 못하게 한다.
* 사무실 NAT(여러 직원이 한 IP)를 고려해 정상 사용으로는 닿지 않는 값으로 둔다.
*/
export const LOGIN_REQUEST_LIMIT = 60;
export const LOGIN_REQUEST_WINDOW_MS = 60_000;
/** 비밀번호 최대 길이 — 아주 긴 값으로 메일 엔진의 해시 계산을 오래 붙잡지 못하게 한다 */
export const MAX_PASSWORD_LENGTH = 256;
@@ -0,0 +1,182 @@
import { GUARDS_METADATA } from '@nestjs/common/constants';
import type { Request, Response } from 'express';
import { BusinessException } from '@/common/errors/business.exception';
import { JwtAuthGuard } from '@/common/guards/jwt-auth.guard';
import { AuthController } from './auth.controller';
import type { AuthService } from './auth.service';
import type { AuthSettings } from './auth.settings';
const settings: AuthSettings = {
jwtSecret: 'secret',
accessTtlSeconds: 3600,
refreshTtlSeconds: 1209600,
secureCookies: false,
};
const session = {
user: { email: 'ara.kim@arasystem.test', name: '김아라' },
tokens: {
accessToken: 'new.access',
refreshToken: 'new.refresh',
refreshTtlSeconds: 1209600,
},
};
function fakeResponse() {
return { cookie: jest.fn(), clearCookie: jest.fn() };
}
/** 핸들러 함수 객체 — Nest 는 라우트 메타데이터(가드·한도)를 이 함수에 붙인다 */
function handlerOf(name: 'login' | 'me' | 'refresh' | 'logout'): object {
const descriptor = Object.getOwnPropertyDescriptor(
AuthController.prototype,
name,
);
return descriptor?.value as object;
}
describe('AuthController', () => {
let auth: { login: jest.Mock; refresh: jest.Mock; logout: jest.Mock };
let controller: AuthController;
beforeEach(() => {
auth = {
login: jest.fn(),
refresh: jest.fn(),
logout: jest.fn().mockResolvedValue(undefined),
};
controller = new AuthController(auth as unknown as AuthService, settings);
});
it('/auth/refresh 에는 가드가 없다 — 액세스 쿠키 없이 리프레시 쿠키만으로 동작한다', () => {
const refreshGuards: unknown = Reflect.getMetadata(
GUARDS_METADATA,
handlerOf('refresh'),
);
const meGuards: unknown = Reflect.getMetadata(
GUARDS_METADATA,
handlerOf('me'),
);
expect(refreshGuards).toBeUndefined();
expect(meGuards).toEqual([JwtAuthGuard]);
expect(
Reflect.getMetadata(GUARDS_METADATA, AuthController),
).toBeUndefined();
});
it('로그인에는 넉넉한 요청 수 상한(IP 당 1분 60회)이 따로 걸린다 — 무차별 대입은 실패 횟수로 막는다', () => {
const handler = handlerOf('login');
expect(Reflect.getMetadata('THROTTLER:LIMITdefault', handler)).toBe(60);
expect(Reflect.getMetadata('THROTTLER:TTLdefault', handler)).toBe(60_000);
// 다른 인증 엔드포인트는 전역 기본값을 쓴다
expect(
Reflect.getMetadata('THROTTLER:LIMITdefault', handlerOf('refresh')),
).toBeUndefined();
});
it('로그인은 클라이언트 IP 를 서비스에 넘기고 두 쿠키를 굽는다', async () => {
auth.login.mockResolvedValue(session);
const res = fakeResponse();
const user = await controller.login(
{ email: 'ara.kim@arasystem.test', password: 'pw' },
'203.0.113.10',
res as unknown as Response,
);
expect(auth.login).toHaveBeenCalledWith(
'ara.kim@arasystem.test',
'pw',
'203.0.113.10',
);
expect(user).toEqual(session.user);
expect(res.cookie).toHaveBeenCalledTimes(2);
});
it('리프레시는 리프레시 쿠키만 읽어 서비스에 넘기고 두 쿠키를 새로 굽는다', async () => {
auth.refresh.mockResolvedValue(session);
const res = fakeResponse();
const req = { cookies: { refresh_token: 'old.refresh' } };
const user = await controller.refresh(
req as unknown as Request,
res as unknown as Response,
);
expect(auth.refresh).toHaveBeenCalledWith('old.refresh');
expect(user).toEqual(session.user);
expect(res.cookie).toHaveBeenCalledWith(
'access_token',
'new.access',
expect.anything(),
);
expect(res.cookie).toHaveBeenCalledWith(
'refresh_token',
'new.refresh',
expect.anything(),
);
});
it('동시 갱신 유예(tokens: null)면 쿠키를 굽지 않는다 — 먼저 끝난 요청의 새 쿠키를 덮어쓰지 않게', async () => {
auth.refresh.mockResolvedValue({ user: session.user, tokens: null });
const res = fakeResponse();
const user = await controller.refresh(
{ cookies: { refresh_token: 'old.refresh' } } as unknown as Request,
res as unknown as Response,
);
expect(user).toEqual(session.user);
expect(res.cookie).not.toHaveBeenCalled();
expect(res.clearCookie).not.toHaveBeenCalled();
});
it('리프레시가 AUTH_001 이면 두 쿠키를 지운다', async () => {
auth.refresh.mockRejectedValue(new BusinessException('AUTH_001'));
const res = fakeResponse();
await expect(
controller.refresh(
{ cookies: {} } as unknown as Request,
res as unknown as Response,
),
).rejects.toBeInstanceOf(BusinessException);
expect(res.clearCookie).toHaveBeenCalledWith(
'access_token',
expect.anything(),
);
expect(res.clearCookie).toHaveBeenCalledWith(
'refresh_token',
expect.anything(),
);
});
it('메일 엔진 장애(MAIL_001)면 세션을 남긴다 (쿠키를 지우지 않는다)', async () => {
auth.refresh.mockRejectedValue(new BusinessException('MAIL_001'));
const res = fakeResponse();
await expect(
controller.refresh(
{ cookies: { refresh_token: 'old.refresh' } } as unknown as Request,
res as unknown as Response,
),
).rejects.toBeInstanceOf(BusinessException);
expect(res.clearCookie).not.toHaveBeenCalled();
});
it('로그아웃은 리프레시 쿠키의 세션을 끊고, 항상 null 을 돌려주며 두 쿠키를 지운다', async () => {
const res = fakeResponse();
await expect(
controller.logout(
{ cookies: { refresh_token: 'old.refresh' } } as unknown as Request,
res as unknown as Response,
),
).resolves.toBeNull();
expect(auth.logout).toHaveBeenCalledWith('old.refresh');
expect(res.clearCookie).toHaveBeenCalledTimes(2);
});
});
+149
View File
@@ -0,0 +1,149 @@
import {
Body,
Controller,
Get,
HttpCode,
HttpStatus,
Inject,
Ip,
Post,
Req,
Res,
UseGuards,
} from '@nestjs/common';
import { ApiCookieAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
import { Throttle } from '@nestjs/throttler';
import type { Request, Response } from 'express';
import {
ApiErrorResponse,
ApiStandardResponse,
} from '@/common/decorators/api-standard-response.decorator';
import {
CurrentUser,
type AuthenticatedUser,
} from '@/common/decorators/current-user.decorator';
import { BusinessException } from '@/common/errors/business.exception';
import { JwtAuthGuard } from '@/common/guards/jwt-auth.guard';
import { readCookie } from '@/shared/utils/cookies';
import { clearSessionCookies, setSessionCookies } from './auth-cookies';
import {
LOGIN_REQUEST_LIMIT,
LOGIN_REQUEST_WINDOW_MS,
REFRESH_TOKEN_COOKIE,
} from './auth.constants';
import { AuthService } from './auth.service';
import { AUTH_SETTINGS, type AuthSettings } from './auth.settings';
import { AuthUserDto } from './dto/auth-user.dto';
import { LoginDto } from './dto/login.dto';
/**
* 직원 인증 — 쿠키 기반 (계약 1절). 토큰은 응답 본문에 없다.
* 쿠키 굽기·지우기는 HTTP 응답 처리라 여기서 하고, 판단은 모두 AuthService 가 한다.
*/
@ApiTags('Auth')
@Controller('auth')
export class AuthController {
constructor(
private readonly auth: AuthService,
@Inject(AUTH_SETTINGS) private readonly settings: AuthSettings,
) {}
// 무차별 대입은 **실패** 횟수로 막는다 (LoginAttemptService: IP 1분 20회 · 주소 15분 10회 — 엔진에 묻기 전에 차지).
// 요청 수 상한은 그와 별개로 넉넉하게 — 사무실 NAT 에서 정상 로그인으로는 닿지 않는 값 (IP 당 1분 60회)
@Post('login')
@Throttle({
default: { ttl: LOGIN_REQUEST_WINDOW_MS, limit: LOGIN_REQUEST_LIMIT },
})
@HttpCode(HttpStatus.OK)
@ApiOperation({
summary: '로그인 — 성공하면 access_token·refresh_token 쿠키를 굽는다',
})
@ApiStandardResponse(AuthUserDto, { description: '로그인 성공' })
@ApiErrorResponse(
'AUTH_004',
'계정 없음·비밀번호 틀림·사내 도메인이 아닌 주소·메일 엔진 관리자 계정 (구분하지 않는다)',
)
@ApiErrorResponse('VAL_001', '형식 오류')
@ApiErrorResponse(
'REQ_001',
'시도 과다 — 같은 IP 1분에 실패 20회 · 같은 주소 15분에 실패 10회(성공은 세지 않는다) · 같은 주소의 로그인이 처리 중 · IP 당 1분 60회 요청',
)
@ApiErrorResponse('MAIL_001', '메일 엔진 장애')
async login(
@Body() dto: LoginDto,
@Ip() ip: string,
@Res({ passthrough: true }) res: Response,
): Promise<AuthUserDto> {
const result = await this.auth.login(dto.email, dto.password, ip);
if (result.tokens) setSessionCookies(res, result.tokens, this.settings);
return result.user;
}
@Get('me')
@UseGuards(JwtAuthGuard)
@ApiCookieAuth()
@ApiOperation({ summary: '현재 로그인한 직원 (앱 시작 시 세션 복원용)' })
@ApiStandardResponse(AuthUserDto, { description: '로그인 상태' })
@ApiErrorResponse(
'AUTH_001',
'로그인 안 됨 — 앱 시작 시에는 정상 상태이므로 알림을 띄우지 않는다',
)
me(@CurrentUser() user: AuthenticatedUser): AuthUserDto {
return { email: user.email, name: user.name };
}
/**
* 가드를 걸지 않는다 — 액세스 쿠키가 없거나 만료된 상태에서 부르는 엔드포인트다 (세션 복원).
* 리프레시 쿠키만 검증한다.
*/
@Post('refresh')
@HttpCode(HttpStatus.OK)
@ApiOperation({
summary: '토큰 회전 — 리프레시 쿠키만으로 두 쿠키를 새로 굽는다 (1회용)',
description:
'액세스 쿠키가 없거나 만료돼도 동작한다. 실패(AUTH_001)하면 두 쿠키를 지운다. ' +
'이미 쓴 리프레시 토큰이 오면 세션 전체를 끊는다. 다른 탭이 방금 회전한 직후의 이전 토큰은 30초 동안 ' +
'새 쿠키 없이 200 으로 답한다 (이긴 요청이 이미 새 쿠키를 구웠다).',
})
@ApiStandardResponse(AuthUserDto, { description: '회전 성공' })
@ApiErrorResponse(
'AUTH_001',
'리프레시 쿠키 없음·만료·위조·이미 쓴 토큰(세션 폐기)·로그아웃한 세션·계정 비활성 — 쿠키도 지운다',
)
@ApiErrorResponse('MAIL_001', '메일 엔진 장애 — 쿠키는 그대로 둔다')
async refresh(
@Req() req: Request,
@Res({ passthrough: true }) res: Response,
): Promise<AuthUserDto> {
try {
const result = await this.auth.refresh(
readCookie(req, REFRESH_TOKEN_COOKIE),
);
// 동시 갱신 유예면 쿠키를 굽지 않는다 — 먼저 끝난 요청이 구운 새 쿠키를 덮어쓰지 않게
if (result.tokens) setSessionCookies(res, result.tokens, this.settings);
return result.user;
} catch (error) {
// 더는 쓸 수 없는 세션이면 쿠키를 지운다. 메일 엔진 장애처럼 일시적인 실패는 세션을 남긴다
if (error instanceof BusinessException && error.code === 'AUTH_001') {
clearSessionCookies(res, this.settings);
}
throw error;
}
}
@Post('logout')
@HttpCode(HttpStatus.OK)
@ApiOperation({
summary: '로그아웃 — 항상 200, 서버의 세션을 끊고 두 쿠키를 지운다',
})
@ApiStandardResponse(undefined, { description: '로그아웃 (data: null)' })
async logout(
@Req() req: Request,
@Res({ passthrough: true }) res: Response,
): Promise<null> {
await this.auth.logout(readCookie(req, REFRESH_TOKEN_COOKIE));
clearSessionCookies(res, this.settings);
return null;
}
}
+51
View File
@@ -0,0 +1,51 @@
import { Logger, Module } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { JwtModule } from '@nestjs/jwt';
import { PassportModule } from '@nestjs/passport';
import { TypeOrmModule } from '@nestjs/typeorm';
import { MailModule } from '@/modules/mail/mail.module';
import { AuthSessionCleanup } from './auth-session.cleanup';
import { AuthSessionEntity } from './auth-session.entity';
import { AuthSessionRepository } from './auth-session.repository';
import { AuthController } from './auth.controller';
import { AuthService } from './auth.service';
import { AUTH_SETTINGS, buildAuthSettings } from './auth.settings';
import { JwtStrategy } from './jwt.strategy';
import { LoginAttemptService } from './login-attempt.service';
/**
* 직원 인증 모듈 — 직원 계정 = 메일 계정 (자격 확인은 MailModule 의 포트로).
*
* 서명 키는 모듈 등록 값이 아니라 AUTH_SETTINGS 하나에서 읽는다 —
* 임시 키(비운영, JWT_SECRET 없음)가 서명과 검증(JwtStrategy)에서 어긋나지 않게 하기 위해서다.
* 보호 라우트는 `@UseGuards(JwtAuthGuard)` (common/guards) 로 건다.
*
* 로그인 세션은 DB(auth_sessions)에 둔다 — 스키마는 마이그레이션으로만 바꾼다 (src/migrations).
*/
@Module({
imports: [
PassportModule.register({ session: false }),
JwtModule.register({}),
TypeOrmModule.forFeature([AuthSessionEntity]),
MailModule,
],
controllers: [AuthController],
providers: [
{
provide: AUTH_SETTINGS,
inject: [ConfigService],
useFactory: (config: ConfigService) =>
buildAuthSettings(
(key) => config.get<string>(key),
(message) => new Logger('AuthModule').warn(message),
),
},
AuthService,
AuthSessionRepository,
AuthSessionCleanup,
LoginAttemptService,
JwtStrategy,
],
})
export class AuthModule {}
@@ -0,0 +1,723 @@
import { Logger } from '@nestjs/common';
import { JwtService } from '@nestjs/jwt';
import { BusinessException } from '@/common/errors/business.exception';
import type { MailProvider } from '@/modules/mail/mail.provider';
import type { MailSettings } from '@/modules/mail/mail.settings';
import type { AuthSessionEntity } from './auth-session.entity';
import { hashRefreshToken } from './auth-session.policy';
import type {
AuthSessionRepository,
RotateSessionCommand,
} from './auth-session.repository';
import type { AuthTokenPayload } from './auth-token.types';
import {
LOGIN_ADDRESS_FAILURE_LIMIT,
LOGIN_IP_FAILURE_LIMIT,
REFRESH_REUSE_GRACE_MS,
} from './auth.constants';
import { AuthService } from './auth.service';
import type { AuthSettings } from './auth.settings';
import { LoginAttemptService } from './login-attempt.service';
const ARA = 'ara.kim@arasystem.test';
const PASSWORD = 'correct-horse-battery';
const CLIENT_IP = '203.0.113.10';
const DAY_SECONDS = 24 * 3600;
const mailSettings: MailSettings = {
jmapBaseUrl: 'http://mail:8080',
domain: 'arasystem.test',
gatewayUser: 'mail-gateway@arasystem.test',
gatewayPassword: 'gateway-secret',
attachmentMaxBytes: 20 * 1024 * 1024,
attachmentTotalMaxBytes: 25 * 1024 * 1024,
blockedAccounts: new Set(['admin@arasystem.test']),
};
const settings: AuthSettings = {
jwtSecret: 'unit-test-secret-unit-test-secret-1234',
accessTtlSeconds: 3600,
refreshTtlSeconds: 14 * DAY_SECONDS,
secureCookies: false,
};
type DecodedToken = AuthTokenPayload & {
iat: number;
exp: number;
jti: string;
};
/**
* 세션 표 대역 — 실제 저장소와 같은 조건부 회전을 흉내 낸다 (조건이 맞을 때만 바꾸고 true).
* 값은 복사해서 돌려준다 (호출한 쪽이 고쳐도 저장된 값이 바뀌지 않게 — DB 와 같다).
*/
class FakeSessions {
readonly rows = new Map<string, AuthSessionEntity>();
readonly create = jest.fn((session: AuthSessionEntity) => {
this.rows.set(session.id, { ...session });
return Promise.resolve();
});
readonly findById = jest.fn((id: string) => {
const row = this.rows.get(id);
return Promise.resolve(row ? { ...row } : null);
});
readonly rotate = jest.fn((command: RotateSessionCommand) => {
const row = this.rows.get(command.id);
const alive =
row !== undefined &&
row.tokenHash === command.presentedHash &&
row.revokedAt === null &&
row.expiresAt > command.now &&
row.absoluteExpiresAt > command.now;
if (alive) {
Object.assign(row, {
tokenHash: command.nextHash,
previousTokenHash: command.presentedHash,
rotatedAt: command.now,
expiresAt: command.nextExpiresAt,
});
}
return Promise.resolve(alive);
});
readonly revoke = jest.fn(
(id: string, reason: AuthSessionEntity['revokedReason'], now: Date) => {
const row = this.rows.get(id);
if (row && row.revokedAt === null) {
Object.assign(row, { revokedAt: now, revokedReason: reason });
}
return Promise.resolve();
},
);
only(): AuthSessionEntity {
const [row] = [...this.rows.values()];
if (!row) throw new Error('세션이 없다');
return row;
}
}
async function failureOf(
promise: Promise<unknown>,
): Promise<BusinessException | undefined> {
try {
await promise;
} catch (error) {
if (error instanceof BusinessException) return error;
throw error;
}
return undefined;
}
/** 이행되지 않은 결과를 코드별로 센다 */
function countCodes(results: PromiseSettledResult<unknown>[]) {
const counts: Record<string, number> = {};
for (const result of results) {
const code =
result.status === 'fulfilled'
? 'ok'
: result.reason instanceof BusinessException
? result.reason.code
: 'UNEXPECTED';
counts[code] = (counts[code] ?? 0) + 1;
}
return counts;
}
const delay = (ms: number) =>
new Promise<void>((resolve) => setTimeout(resolve, ms));
describe('AuthService', () => {
let mail: jest.Mocked<Pick<MailProvider, 'verifyCredentials' | 'getProfile'>>;
let attempts: LoginAttemptService;
let sessions: FakeSessions;
let jwt: JwtService;
let service: AuthService;
let logs: string[];
const build = (overrides: Partial<MailSettings> = {}) =>
new AuthService(
mail as unknown as MailProvider,
{ ...mailSettings, ...overrides },
settings,
jwt,
attempts,
sessions as unknown as AuthSessionRepository,
);
const decode = (token: string) =>
jwt.verify<DecodedToken>(token, { secret: settings.jwtSecret });
const sign = (payload: object, expiresIn: number = 3600) =>
jwt.sign(payload, {
secret: settings.jwtSecret,
expiresIn,
algorithm: 'HS256',
});
/** 로그인해서 받은 리프레시 토큰 */
const loginToken = async () => {
const result = await service.login(ARA, PASSWORD, CLIENT_IP);
if (!result.tokens) throw new Error('로그인은 항상 토큰을 준다');
return result.tokens.refreshToken;
};
/** 회전해서 받은 새 리프레시 토큰 */
const rotateFrom = async (token: string) => {
const result = await service.refresh(token);
if (!result.tokens) throw new Error('회전이 아니라 유예로 끝났다');
return result.tokens.refreshToken;
};
beforeEach(() => {
mail = {
verifyCredentials: jest.fn().mockResolvedValue(ARA),
getProfile: jest
.fn()
.mockImplementation((email: string) =>
Promise.resolve({ email, name: '김아라' }),
),
};
attempts = new LoginAttemptService();
sessions = new FakeSessions();
jwt = new JwtService({});
service = build();
logs = [];
const record = (message: unknown) => {
logs.push(String(message));
};
jest.spyOn(Logger.prototype, 'log').mockImplementation(record);
jest.spyOn(Logger.prototype, 'warn').mockImplementation(record);
jest.spyOn(Logger.prototype, 'error').mockImplementation(record);
});
afterEach(() => jest.restoreAllMocks());
describe('로그인', () => {
it('메일 엔진이 확인하면 서버에 세션을 만들고 두 토큰을 발급한다 — 세션에는 토큰 해시만 남는다', async () => {
const result = await service.login(
' Ara.Kim@AraSystem.test ',
PASSWORD,
CLIENT_IP,
);
expect(mail.verifyCredentials).toHaveBeenCalledWith(ARA, PASSWORD);
expect(mail.getProfile).toHaveBeenCalledWith(ARA, { fresh: true });
expect(result.user).toEqual({ email: ARA, name: '김아라' });
const tokens = result.tokens!;
const access = decode(tokens.accessToken);
const refresh = decode(tokens.refreshToken);
expect(access).toMatchObject({ sub: ARA, typ: 'access', name: '김아라' });
expect(access.exp - access.iat).toBe(settings.accessTtlSeconds);
expect(refresh).toMatchObject({ sub: ARA, typ: 'refresh' });
expect(refresh).not.toHaveProperty('name');
expect(refresh.exp - refresh.iat).toBe(settings.refreshTtlSeconds);
expect(tokens.refreshTtlSeconds).toBe(settings.refreshTtlSeconds);
expect(access.jti).not.toBe(refresh.jti);
const row = sessions.only();
expect(refresh.sid).toBe(row.id);
expect(row.userEmail).toBe(ARA);
expect(row.tokenHash).toBe(hashRefreshToken(tokens.refreshToken));
expect(JSON.stringify(row)).not.toContain(tokens.refreshToken);
// 절대 만료 30일 · 미사용 만료 14일
expect(row.absoluteExpiresAt.getTime() - row.createdAt.getTime()).toBe(
30 * DAY_SECONDS * 1000,
);
expect(row.expiresAt.getTime() - row.createdAt.getTime()).toBe(
14 * DAY_SECONDS * 1000,
);
});
it('비밀번호가 틀리면 AUTH_004 이고 세션을 만들지 않는다', async () => {
mail.verifyCredentials.mockResolvedValue(null);
const error = await failureOf(service.login(ARA, 'wrong', CLIENT_IP));
expect(error?.code).toBe('AUTH_004');
expect(sessions.create).not.toHaveBeenCalled();
});
it('사내 도메인이 아닌 주소는 메일 엔진에 묻지 않고 같은 AUTH_004', async () => {
const error = await failureOf(
service.login('ara.kim@gmail.com', PASSWORD, CLIENT_IP),
);
expect(error?.code).toBe('AUTH_004');
expect(mail.verifyCredentials).not.toHaveBeenCalled();
});
it('게이트웨이 계정과 대리 접근 형식(%)은 로그인할 수 없다', async () => {
for (const email of [
'mail-gateway@arasystem.test',
'ara.kim%mail-gateway@arasystem.test',
]) {
expect(
(await failureOf(service.login(email, PASSWORD, CLIENT_IP)))?.code,
).toBe('AUTH_004');
}
expect(mail.verifyCredentials).not.toHaveBeenCalled();
});
it('메일 관리자 주소는 비밀번호가 맞아도 메일 엔진에 묻지 않고 같은 AUTH_004 (대소문자 무관)', async () => {
for (const email of ['admin@arasystem.test', ' Admin@ARASYSTEM.test ']) {
const error = await failureOf(
service.login(email, PASSWORD, CLIENT_IP),
);
expect(error?.code).toBe('AUTH_004');
expect(error?.context).toMatchObject({
reason: 'not-employee-address',
});
}
expect(mail.verifyCredentials).not.toHaveBeenCalled();
expect(sessions.create).not.toHaveBeenCalled();
});
it('관리자 아이디를 바꾸면(MAIL_ADMIN_LOCAL_PART) admin@ 은 일반 직원처럼 로그인한다 — 옮겨 온 실제 메일함', async () => {
service = build({
blockedAccounts: new Set(['mail-admin@arasystem.test']),
});
mail.verifyCredentials.mockResolvedValue('admin@arasystem.test');
const result = await service.login(
'admin@arasystem.test',
PASSWORD,
CLIENT_IP,
);
expect(result.user.email).toBe('admin@arasystem.test');
expect(
(
await failureOf(
service.login('mail-admin@arasystem.test', PASSWORD, CLIENT_IP),
)
)?.code,
).toBe('AUTH_004');
expect(mail.verifyCredentials).toHaveBeenCalledTimes(1);
});
it('MAIL_BLOCKED_ACCOUNTS 로 더 막은 계정도 같다', async () => {
service = build({
blockedAccounts: new Set([
'admin@arasystem.test',
'ops@arasystem.test',
]),
});
expect(
(
await failureOf(
service.login('ops@arasystem.test', PASSWORD, CLIENT_IP),
)
)?.code,
).toBe('AUTH_004');
expect(mail.verifyCredentials).not.toHaveBeenCalled();
});
it('별칭으로 들어와도 엔진이 알려 준 정식 계정이 관리자면 AUTH_004 — 메일함을 열지 않는다', async () => {
mail.verifyCredentials.mockResolvedValue('admin@arasystem.test');
const error = await failureOf(
service.login('postmaster@arasystem.test', PASSWORD, CLIENT_IP),
);
expect(error?.code).toBe('AUTH_004');
expect(error?.context).toMatchObject({ reason: 'rejected' });
expect(mail.getProfile).not.toHaveBeenCalled();
expect(sessions.create).not.toHaveBeenCalled();
});
it('비밀번호는 맞지만 메일함에 접근할 수 없는 계정도 AUTH_004 (원인을 드러내지 않는다)', async () => {
mail.getProfile.mockRejectedValue(new BusinessException('AUTH_001'));
const error = await failureOf(service.login(ARA, PASSWORD, CLIENT_IP));
expect(error?.code).toBe('AUTH_004');
expect(error?.context).toMatchObject({ reason: 'mailbox-unavailable' });
});
it('메일 설정이 없으면 MAIL_001 (비운영 기동 상태)', async () => {
service = build({ domain: null });
expect(
(await failureOf(service.login(ARA, PASSWORD, CLIENT_IP)))?.code,
).toBe('MAIL_001');
});
it('로그에는 마스킹한 주소만 남고 비밀번호·토큰은 남지 않는다', async () => {
const result = await service.login(ARA, PASSWORD, CLIENT_IP);
mail.verifyCredentials.mockResolvedValue(null);
const failure = await failureOf(service.login(ARA, PASSWORD, CLIENT_IP));
const everything = [...logs, JSON.stringify(failure?.context)].join('\n');
expect(everything).toContain('a***@arasystem.test');
expect(everything).not.toContain(ARA);
expect(everything).not.toContain(PASSWORD);
expect(everything).not.toContain(result.tokens!.accessToken);
});
});
/**
* 실패 한도는 엔진에 묻기 **전에** 차지한다 — 동시 요청이 "아직 남았다"를 함께 보고 모두 통과하지 못한다.
* 엔진이 느린(80ms) 상황에서 실제 LoginAttemptService 로 확인한다 (리뷰 재현 시나리오).
*/
describe('로그인 실패 한도 — 동시 요청', () => {
const N = 100;
beforeEach(() => {
mail.verifyCredentials.mockImplementation(async () => {
await delay(80);
return null;
});
});
it(`같은 주소로 틀린 비밀번호 ${N}건을 동시에 보내도 엔진 확인은 1번 — 나머지는 진행 중이라 REQ_001`, async () => {
const results = await Promise.allSettled(
Array.from({ length: N }, () =>
service.login(ARA, 'wrong', '203.0.113.7'),
),
);
expect(mail.verifyCredentials).toHaveBeenCalledTimes(1);
expect(countCodes(results)).toEqual({ AUTH_004: 1, REQ_001: N - 1 });
});
it('같은 주소의 동시 공격을 이어 가도 엔진 확인은 주소 한도(10)를 넘지 않고, 그 뒤엔 맞는 비밀번호도 REQ_001', async () => {
// 다른 IP 로 나눠 IP 한도에 걸리지 않게 한다 — 주소 한도만 본다
for (let round = 0; round < 15; round += 1) {
await Promise.allSettled(
Array.from({ length: 10 }, (_, i) =>
service.login(ARA, 'wrong', `198.51.100.${round * 10 + i}`),
),
);
}
expect(mail.verifyCredentials).toHaveBeenCalledTimes(
LOGIN_ADDRESS_FAILURE_LIMIT,
);
mail.verifyCredentials.mockResolvedValue(ARA);
expect(
(await failureOf(service.login(ARA, PASSWORD, '192.0.2.1')))?.code,
).toBe('REQ_001');
expect(mail.verifyCredentials).toHaveBeenCalledTimes(
LOGIN_ADDRESS_FAILURE_LIMIT,
);
});
it(`같은 IP 에서 서로 다른 주소 ${N}건을 동시에 보내면 엔진 확인은 IP 한도(20)번뿐`, async () => {
const results = await Promise.allSettled(
Array.from({ length: N }, (_, i) =>
service.login(`guess${i}@arasystem.test`, 'wrong', '203.0.113.8'),
),
);
expect(mail.verifyCredentials).toHaveBeenCalledTimes(
LOGIN_IP_FAILURE_LIMIT,
);
expect(countCodes(results)).toEqual({
AUTH_004: LOGIN_IP_FAILURE_LIMIT,
REQ_001: N - LOGIN_IP_FAILURE_LIMIT,
});
});
it(`서로 다른 IP ${N}개로 한 주소를 동시에 찔러도 엔진 확인은 1번`, async () => {
const results = await Promise.allSettled(
Array.from({ length: N }, (_, i) =>
service.login(ARA, 'wrong', `10.0.${Math.floor(i / 250)}.${i % 250}`),
),
);
expect(mail.verifyCredentials).toHaveBeenCalledTimes(1);
expect(countCodes(results)).toEqual({ AUTH_004: 1, REQ_001: N - 1 });
});
it('성공한 로그인은 세지 않는다 — 같은 IP(사무실 NAT)에서 한도보다 많이 성공해도 막히지 않는다', async () => {
mail.verifyCredentials.mockImplementation((email: string) =>
Promise.resolve(email),
);
for (let i = 0; i < LOGIN_IP_FAILURE_LIMIT * 2; i += 1) {
await service.login(`staff${i}@arasystem.test`, PASSWORD, CLIENT_IP);
}
expect(sessions.create).toHaveBeenCalledTimes(LOGIN_IP_FAILURE_LIMIT * 2);
});
it('메일 엔진 장애(MAIL_001)는 실패로 세지 않는다', async () => {
mail.verifyCredentials.mockRejectedValue(
new BusinessException('MAIL_001'),
);
for (let i = 0; i < LOGIN_ADDRESS_FAILURE_LIMIT * 2; i += 1) {
expect(
(await failureOf(service.login(ARA, PASSWORD, CLIENT_IP)))?.code,
).toBe('MAIL_001');
}
});
it('IP 를 알 수 없는 요청은 한 묶음으로 센다', async () => {
await Promise.allSettled(
Array.from({ length: LOGIN_IP_FAILURE_LIMIT }, (_, i) =>
service.login(`guess${i}@arasystem.test`, 'wrong', ''),
),
);
expect(
(await failureOf(service.login('new@arasystem.test', 'wrong', '')))
?.context,
).toMatchObject({ scope: 'ip', ip: 'unknown' });
});
it('사내 도메인이 아닌 주소는 주소 한도에 넣지 않는다 — IP 를 바꿔 가며 계속 시도해도 늘 AUTH_004', async () => {
for (let i = 0; i < LOGIN_ADDRESS_FAILURE_LIMIT + 5; i += 1) {
expect(
(
await failureOf(
service.login('ara.kim@gmail.com', 'wrong', `198.51.100.${i}`),
)
)?.code,
).toBe('AUTH_004');
}
expect(mail.verifyCredentials).not.toHaveBeenCalled();
});
it('메일 관리자 주소도 주소 한도에 넣지 않는다 — 관리자 주소로 한도를 채워 잠그는 일이 없다 (IP 한도에만 센다)', async () => {
for (let i = 0; i < LOGIN_ADDRESS_FAILURE_LIMIT + 5; i += 1) {
expect(
(
await failureOf(
service.login('admin@arasystem.test', 'wrong', `198.51.100.${i}`),
)
)?.code,
).toBe('AUTH_004');
}
expect(mail.verifyCredentials).not.toHaveBeenCalled();
});
it('사내 도메인이 아닌 주소도 IP 한도에는 센다', async () => {
for (let i = 0; i < LOGIN_IP_FAILURE_LIMIT; i += 1) {
await failureOf(
service.login(`someone${i}@gmail.com`, 'wrong', '203.0.113.9'),
);
}
expect(
(await failureOf(service.login(ARA, PASSWORD, '203.0.113.9')))?.context,
).toMatchObject({ scope: 'ip' });
});
});
describe('리프레시 (서버 세션 · 1회용 회전)', () => {
it('리프레시 토큰만으로 계정을 다시 확인하고 두 토큰을 새로 발급한다 — 이전 토큰은 폐기된다', async () => {
const first = await loginToken();
const result = await service.refresh(first);
expect(mail.getProfile).toHaveBeenLastCalledWith(ARA, { fresh: true });
expect(result.user).toEqual({ email: ARA, name: '김아라' });
const next = result.tokens!.refreshToken;
expect(next).not.toBe(first);
expect(decode(result.tokens!.accessToken)).toMatchObject({
typ: 'access',
});
const row = sessions.only();
expect(decode(next).sid).toBe(row.id);
expect(row.tokenHash).toBe(hashRefreshToken(next));
expect(row.previousTokenHash).toBe(hashRefreshToken(first));
});
it('회전 직후 바로 이전 토큰이 오면(다른 탭의 동시 갱신) 세션을 끊지 않고 새 토큰 없이 성공한다', async () => {
const first = await loginToken();
const next = await rotateFrom(first);
const result = await service.refresh(first);
expect(result.tokens).toBeNull();
expect(result.user.email).toBe(ARA);
expect(sessions.only().revokedAt).toBeNull();
// 이긴 쪽의 토큰은 그대로 쓸 수 있다
await expect(rotateFrom(next)).resolves.toEqual(expect.any(String));
});
it('같은 토큰으로 동시에 갱신하면 하나만 회전하고 나머지는 유예로 끝난다 (세션 유지)', async () => {
const first = await loginToken();
const results = await Promise.all(
Array.from({ length: 5 }, () => service.refresh(first)),
);
expect(results.filter((result) => result.tokens !== null)).toHaveLength(
1,
);
expect(sessions.only().revokedAt).toBeNull();
});
it('이미 쓴 토큰(두 번 이상 지난 것)이 오면 세션 전체를 끊는다 — 최신 토큰도 더는 못 쓴다', async () => {
const first = await loginToken();
const second = await rotateFrom(first);
const third = await rotateFrom(second);
const reuse = await failureOf(service.refresh(first));
expect(reuse?.code).toBe('AUTH_001');
expect(reuse?.context).toMatchObject({ reason: 'session-reused' });
expect(sessions.only()).toMatchObject({
revokedReason: 'refresh-token-reused',
});
expect((await failureOf(service.refresh(third)))?.context).toMatchObject({
reason: 'session-revoked',
});
expect(logs.join('\n')).toMatch(/\[AUTH_001\].*세션을 끊었습니다/);
});
it('바로 이전 토큰이라도 유예 시간이 지난 뒤 오면 재사용으로 보고 세션을 끊는다', async () => {
const first = await loginToken();
await rotateFrom(first);
// 시간이 흐른 것처럼 회전 시각을 유예 시간보다 앞으로 돌린다
const row = sessions.only();
row.rotatedAt = new Date(Date.now() - REFRESH_REUSE_GRACE_MS - 1);
expect((await failureOf(service.refresh(first)))?.code).toBe('AUTH_001');
expect(row.revokedReason).toBe('refresh-token-reused');
});
it('만료된 세션(미사용 14일·절대 30일)은 AUTH_001', async () => {
const token = await loginToken();
sessions.only().expiresAt = new Date(Date.now() - 1);
expect((await failureOf(service.refresh(token)))?.context).toMatchObject({
reason: 'session-expired',
});
});
it('절대 만료가 가까우면 새 리프레시 토큰·쿠키 수명이 그 안으로 줄고, 액세스 토큰도 그보다 오래 살지 않는다', async () => {
const token = await loginToken();
sessions.only().absoluteExpiresAt = new Date(Date.now() + 600_000);
const result = await service.refresh(token);
const { refreshTtlSeconds, refreshToken, accessToken } = result.tokens!;
expect(refreshTtlSeconds).toBeLessThanOrEqual(600);
expect(refreshTtlSeconds).toBeGreaterThan(590);
const access = decode(accessToken);
expect(access.exp - access.iat).toBeLessThanOrEqual(600);
const refresh = decode(refreshToken);
expect(refresh.exp - refresh.iat).toBe(refreshTtlSeconds);
});
it('계정이 사라졌으면 세션을 끊고 AUTH_001', async () => {
const token = await loginToken();
mail.getProfile.mockRejectedValueOnce(new BusinessException('AUTH_001'));
expect((await failureOf(service.refresh(token)))?.code).toBe('AUTH_001');
expect(sessions.only().revokedReason).toBe('account-unavailable');
});
it('메일 엔진 장애면 MAIL_001 그대로 — 회전하지 않았으므로 같은 토큰으로 다시 시도할 수 있다', async () => {
const token = await loginToken();
mail.getProfile.mockRejectedValueOnce(new BusinessException('MAIL_001'));
expect((await failureOf(service.refresh(token)))?.code).toBe('MAIL_001');
expect(sessions.rotate).not.toHaveBeenCalled();
await expect(rotateFrom(token)).resolves.toEqual(expect.any(String));
});
it('쿠키가 없거나·위조·만료·종류가 다르거나·세션 id 가 없으면 AUTH_001 이고 엔진·DB 에 묻지 않는다', async () => {
const sid = '9b2f3a64-1c1d-4f5e-8a77-2f1c0a9e5d10';
const forged = jwt.sign(
{ sub: ARA, typ: 'refresh', sid },
{ secret: 'another-secret-another-secret-12' },
);
const expired = sign({ sub: ARA, typ: 'refresh', sid }, -10);
const accessToken = sign({ sub: ARA, typ: 'access', name: '김아라' });
const withoutSession = sign({ sub: ARA, typ: 'refresh' });
const badSessionId = sign({ sub: ARA, typ: 'refresh', sid: "1' OR '1" });
const noneAlg = `${Buffer.from('{"alg":"none","typ":"JWT"}').toString('base64url')}.${Buffer.from(`{"sub":"${ARA}","typ":"refresh","sid":"${sid}"}`).toString('base64url')}.`;
for (const token of [
undefined,
'garbage',
forged,
expired,
accessToken,
withoutSession,
badSessionId,
noneAlg,
]) {
expect((await failureOf(service.refresh(token)))?.code).toBe(
'AUTH_001',
);
}
expect(mail.getProfile).not.toHaveBeenCalled();
expect(sessions.findById).not.toHaveBeenCalled();
});
it('서명은 맞지만 DB 에 없는 세션이면 AUTH_001 (정리된 세션 등)', async () => {
const token = sign({
sub: ARA,
typ: 'refresh',
sid: '9b2f3a64-1c1d-4f5e-8a77-2f1c0a9e5d10',
});
expect((await failureOf(service.refresh(token)))?.context).toMatchObject({
reason: 'session-missing',
});
});
it('회전 조건이 같은데 바뀌지 않으면(서버 쪽 불일치) SYS_001 — 세션을 끊지 않아 같은 쿠키로 다시 시도할 수 있다', async () => {
const token = await loginToken();
// 저장소가 "현재 토큰"으로 보이는데도 회전하지 않는 상황을 흉내 낸다
sessions.rotate.mockResolvedValueOnce(false);
const error = await failureOf(service.refresh(token));
expect(error?.code).toBe('SYS_001');
expect(error?.context).toMatchObject({ reason: 'rotation-conflict' });
expect(sessions.only().revokedAt).toBeNull();
// 세션은 그대로 — 같은 토큰으로 다시 회전된다
await expect(rotateFrom(token)).resolves.toEqual(expect.any(String));
});
});
describe('로그아웃', () => {
it('서버의 세션을 끊는다 — 그 뒤 리프레시는 AUTH_001', async () => {
const token = await loginToken();
await service.logout(token);
expect(sessions.only().revokedReason).toBe('logout');
expect((await failureOf(service.refresh(token)))?.code).toBe('AUTH_001');
});
it('리프레시 토큰이 만료됐어도(서명은 맞으면) 그 세션을 끊는다', async () => {
await loginToken();
const { id } = sessions.only();
const expired = sign({ sub: ARA, typ: 'refresh', sid: id }, -10);
await service.logout(expired);
expect(sessions.only().revokedReason).toBe('logout');
});
it('토큰이 없거나 위조여도 예외 없이 끝난다 (항상 200)', async () => {
await expect(service.logout(undefined)).resolves.toBeUndefined();
await expect(service.logout('garbage')).resolves.toBeUndefined();
expect(sessions.revoke).not.toHaveBeenCalled();
});
it('세션 폐기가 실패해도 예외 없이 끝나고 오류 로그를 남긴다', async () => {
const token = await loginToken();
sessions.revoke.mockRejectedValueOnce(new Error('connection refused'));
await expect(service.logout(token)).resolves.toBeUndefined();
expect(logs.join('\n')).toMatch(/\[SYS_001\].*로그아웃 세션 폐기/);
});
});
});
+430
View File
@@ -0,0 +1,430 @@
import { Inject, Injectable, Logger } from '@nestjs/common';
import { JwtService } from '@nestjs/jwt';
import { randomUUID } from 'crypto';
import type { AuthenticatedUser } from '@/common/decorators/current-user.decorator';
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 { 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 {
classifyRefresh,
hashRefreshToken,
nextIdleExpiry,
type RefreshState,
} from './auth-session.policy';
import { AuthSessionRepository } from './auth-session.repository';
import type { AuthTokenPayload } from './auth-token.types';
import {
JWT_ALGORITHM,
REFRESH_REUSE_GRACE_MS,
SESSION_ABSOLUTE_TTL_SECONDS,
} from './auth.constants';
import { AUTH_SETTINGS, type AuthSettings } from './auth.settings';
import { isEmployeeAddress, normalizeEmail } from './employee-address';
import { LoginAttemptService } from './login-attempt.service';
export interface AuthResult {
readonly user: AuthenticatedUser;
/**
* 구울 쿠키. 동시 갱신 유예(같은 세션의 다른 요청이 방금 회전함)면 null —
* 이긴 요청의 응답이 이미 새 쿠키를 구웠으므로 덮어쓰지 않는다.
*/
readonly tokens: SessionTokens | null;
}
/** 리프레시 토큰에서 꺼낸 값 */
interface RefreshClaims {
readonly sub: string;
readonly sid: string;
}
/** 세션 id 형식 — DB 의 uuid 열에 넣기 전에 확인한다 (형식이 틀리면 DB 오류가 SYS_001 로 새어 나간다) */
const SESSION_ID_PATTERN =
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
function toUser(profile: MailAccountProfile): AuthenticatedUser {
return { email: profile.email, name: profile.name };
}
function errorMessage(error: unknown): string {
return error instanceof Error ? error.message : String(error);
}
/**
* 직원 로그인 — 직원 계정 = 메일 계정.
* 비밀번호는 로그인할 때만 메일 엔진에 확인하고 저장하지 않는다.
* 로그인하면 서버에 세션이 생기고(auth_sessions), 리프레시 토큰은 1회용으로 회전한다 (계약 1절 "세션 규칙").
*
* 로그에는 비밀번호·토큰을 절대 남기지 않고, 주소는 마스킹한다 (.claude/rules/logging.md).
*/
@Injectable()
export class AuthService {
private readonly logger = new Logger(AuthService.name);
constructor(
@Inject(MAIL_PROVIDER) private readonly mail: MailProvider,
@Inject(MAIL_SETTINGS) private readonly mailSettings: MailSettings,
@Inject(AUTH_SETTINGS) private readonly settings: AuthSettings,
private readonly jwt: JwtService,
private readonly attempts: LoginAttemptService,
private readonly sessions: AuthSessionRepository,
) {}
/**
* 계정 없음·비밀번호 틀림·사내 도메인이 아닌 주소는 구분하지 않고 AUTH_004 (.claude/rules/security.md).
* 메일 엔진에 묻기 **전에** 실패 한 번을 IP·주소에 미리 차지한다 — 동시 요청으로 한도를 넘지 못하게 (LoginAttemptService).
* 성공·엔진 장애는 차지한 자리를 돌려준다 (성공은 세지 않는다).
*/
async login(
email: string,
password: string,
clientIp: string,
): Promise<AuthResult> {
const address = normalizeEmail(email);
if (!this.mailSettings.domain) {
throw new BusinessException('MAIL_001', {
context: { reason: 'mail-not-configured' },
});
}
// IP 를 알 수 없는 요청(소켓이 이미 닫힘 등)도 한 묶음으로 센다.
// 로그인할 수 없는 주소(사내 도메인 밖·게이트웨이·관리자)는 엔진에 묻지 않고 바로 거절하므로 주소 카운터에 넣지 않는다(IP 만 센다) —
// 아무 주소나 뿌려 주소 카운터를 채우지 못하게. 응답은 같은 AUTH_004 라 이 차이로 드러나는 것은 없다
const ticket = this.attempts.begin({
email: this.isLoginAddress(address) ? address : null,
ip: clientIp || 'unknown',
});
let profile: MailAccountProfile;
try {
profile = await this.authenticate(address, password);
ticket.succeed();
} catch (error) {
// AUTH_004 로 응답하는 경우만 실패로 센다 — 엔진 장애(MAIL_001)·엔진 한도(REQ_001)는 사용자 잘못이 아니다
if (error instanceof BusinessException && error.code === 'AUTH_004') {
ticket.fail();
} else {
ticket.abort();
}
throw error;
}
const tokens = await this.startSession(profile);
this.logger.log(`로그인: ${maskEmail(profile.email)}`);
return { user: toUser(profile), tokens };
}
/**
* 리프레시 쿠키만으로 두 토큰을 새로 발급한다(1회용 회전) — 액세스 쿠키가 없거나 만료돼도 된다.
*
* - 이미 쓴 토큰이 오면 탈취로 보고 그 세션 전체를 끊는다 → AUTH_001
* - 회전 직후의 바로 이전 토큰은 유예한다(탭 여러 개의 동시 갱신) → 새 토큰 없이 성공
* - 계정이 아직 살아 있는지 메일 엔진에 다시 확인한다 (사라졌으면 세션을 끊고 AUTH_001)
* - 메일 엔진 장애는 MAIL_001 그대로 — 회전하기 전에 확인하므로 같은 토큰으로 다시 시도할 수 있다
*/
async refresh(refreshToken: string | undefined): Promise<AuthResult> {
if (!refreshToken) {
throw new BusinessException('AUTH_001', {
context: { reason: 'refresh-token-missing' },
});
}
const claims = await this.verifyRefreshToken(refreshToken, false);
const presentedHash = hashRefreshToken(refreshToken);
const now = new Date();
const session = await this.sessions.findById(claims.sid);
return this.resolveRefresh(
this.classify(session, claims, presentedHash, now),
session,
claims,
presentedHash,
now,
);
}
/**
* 로그아웃 — 서버의 세션을 끊는다. 항상 성공으로 끝난다(계약: 항상 200):
* 토큰이 없거나 이미 쓸 수 없어도, 세션 폐기에 실패해도(오류 로그만) 쿠키는 컨트롤러가 지운다.
*/
async logout(refreshToken: string | undefined): Promise<void> {
if (!refreshToken) return;
let claims: RefreshClaims;
try {
// 만료된 토큰으로도 그 세션은 끊을 수 있게 한다 (서명은 확인한다)
claims = await this.verifyRefreshToken(refreshToken, true);
} catch {
return;
}
try {
await this.sessions.revoke(claims.sid, 'logout', new Date());
this.logger.log(`로그아웃: ${maskEmail(claims.sub)}`);
} catch (error) {
this.logger.error(
`[SYS_001]${traceTag()} 로그아웃 세션 폐기에 실패했습니다 (쿠키는 지운다): ${errorMessage(error)}`,
);
}
}
/**
* 로그인할 수 있는 주소인가 — 사내 도메인 주소이고 게이트웨이·관리자(막는 계정)가 아니어야 한다.
* 관리자 주소는 엔진에 묻기 전에 막는다: 공개된 로그인 창이 관리자 비밀번호를 시험해 보는 창구가 되지 않게.
*/
private isLoginAddress(address: string): boolean {
const { domain, gatewayUser, blockedAccounts } = this.mailSettings;
return (
domain !== null &&
isEmployeeAddress(address, domain, gatewayUser, blockedAccounts)
);
}
private async authenticate(
address: string,
password: string,
): Promise<MailAccountProfile> {
if (!this.isLoginAddress(address)) {
throw this.loginFailed(address, 'not-employee-address');
}
const account = await this.mail.verifyCredentials(address, password);
// 엔진이 돌려준 정식 주소로 한 번 더 — 별칭으로 들어와도 관리자·게이트웨이 계정이면 막는다
if (!account || !this.isLoginAddress(account)) {
throw this.loginFailed(address, 'rejected');
}
return this.loadProfileForLogin(account, address);
}
private classify(
session: AuthSessionEntity | null,
claims: RefreshClaims,
presentedHash: string,
now: Date,
): RefreshState {
return classifyRefresh(
session,
claims,
presentedHash,
now,
REFRESH_REUSE_GRACE_MS,
);
}
private async resolveRefresh(
state: RefreshState,
session: AuthSessionEntity | null,
claims: RefreshClaims,
presentedHash: string,
now: Date,
): Promise<AuthResult> {
if (state === 'current' && session) {
return this.rotate(session, claims, presentedHash, now);
}
if (state === 'grace') {
const profile = await this.mail.getProfile(claims.sub);
return { user: toUser(profile), tokens: null };
}
if (state === 'reused') {
await this.sessions.revoke(claims.sid, 'refresh-token-reused', now);
this.logger.warn(
`[AUTH_001]${traceTag()} 이미 쓴 리프레시 토큰이 다시 와서 세션을 끊었습니다 (탈취 의심): user=${maskEmail(claims.sub)}, sid=${claims.sid}`,
);
}
throw new BusinessException('AUTH_001', {
context: { reason: `session-${state}`, sid: claims.sid },
});
}
private async rotate(
session: AuthSessionEntity,
claims: RefreshClaims,
presentedHash: string,
now: Date,
): Promise<AuthResult> {
const profile = await this.loadProfileForRefresh(claims, now);
const expiresAt = nextIdleExpiry(
now,
this.settings.refreshTtlSeconds,
session.absoluteExpiresAt,
);
const tokens = await this.signTokens(profile, claims.sid, expiresAt, now);
const rotated = await this.sessions.rotate({
id: claims.sid,
presentedHash,
nextHash: hashRefreshToken(tokens.refreshToken),
nextExpiresAt: expiresAt,
now,
});
if (rotated) return { user: toUser(profile), tokens };
// 그 사이 다른 요청이 먼저 회전했거나 세션을 끊었다 — 지금 상태로 다시 판단한다
const latest = await this.sessions.findById(claims.sid);
const state = this.classify(latest, claims, presentedHash, now);
if (state === 'grace') return { user: toUser(profile), tokens: null };
if (state === 'current') {
// 조건부 UPDATE 와 같은 조건인데 바뀌지 않았다 — 서버 쪽 불일치(시계 어긋남 등)라 사용자 잘못이 아니다.
// 세션을 끊지 않는다: AUTH_001 이 아니라 SYS_001(500)로 답해 컨트롤러가 쿠키를 지우지 않게 하고,
// 클라이언트는 같은 리프레시 쿠키로 다시 시도할 수 있다. 5xx 라 예외 필터가 error 로 남긴다
throw new BusinessException('SYS_001', {
context: { reason: 'rotation-conflict', sid: claims.sid },
});
}
return this.resolveRefresh(state, latest, claims, presentedHash, now);
}
/** 계정이 사라졌으면 세션을 끊는다. 메일 엔진 장애는 그대로 던진다 (세션은 남는다) */
private async loadProfileForRefresh(
claims: RefreshClaims,
now: Date,
): Promise<MailAccountProfile> {
try {
return await this.mail.getProfile(claims.sub, { fresh: true });
} catch (error) {
if (error instanceof BusinessException && error.code === 'AUTH_001') {
await this.sessions.revoke(claims.sid, 'account-unavailable', now);
}
throw error;
}
}
/** 비밀번호는 맞지만 메일함에 접근할 수 없는 계정도 같은 실패로 응답한다 (원인을 드러내지 않는다) */
private async loadProfileForLogin(
account: string,
address: string,
): Promise<MailAccountProfile> {
try {
return await this.mail.getProfile(account, { fresh: true });
} catch (error) {
if (error instanceof BusinessException && error.code === 'AUTH_001') {
throw this.loginFailed(address, 'mailbox-unavailable');
}
throw error;
}
}
/** 새 세션 — 절대 만료 30일, 미사용 만료는 리프레시 기간 */
private async startSession(
profile: MailAccountProfile,
): Promise<SessionTokens> {
const now = new Date();
const id = randomUUID();
const absoluteExpiresAt = new Date(
now.getTime() + SESSION_ABSOLUTE_TTL_SECONDS * 1000,
);
const expiresAt = nextIdleExpiry(
now,
this.settings.refreshTtlSeconds,
absoluteExpiresAt,
);
const tokens = await this.signTokens(profile, id, expiresAt, now);
await this.sessions.create({
id,
userEmail: profile.email,
tokenHash: hashRefreshToken(tokens.refreshToken),
previousTokenHash: null,
rotatedAt: null,
createdAt: now,
expiresAt,
absoluteExpiresAt,
revokedAt: null,
revokedReason: null,
});
return tokens;
}
private async verifyRefreshToken(
token: string,
ignoreExpiration: boolean,
): Promise<RefreshClaims> {
let payload: AuthTokenPayload;
try {
payload = await this.jwt.verifyAsync<AuthTokenPayload>(token, {
secret: this.settings.jwtSecret,
algorithms: [JWT_ALGORITHM],
ignoreExpiration,
});
} catch (error) {
throw new BusinessException('AUTH_001', {
context: {
reason: 'invalid-refresh-token',
cause: errorMessage(error),
},
});
}
const { typ, sub, sid } = payload;
if (
typ !== 'refresh' ||
typeof sub !== 'string' ||
typeof sid !== 'string' ||
!SESSION_ID_PATTERN.test(sid)
) {
// 액세스 토큰을 리프레시 쿠키에 넣은 경우·세션 저장 전에 발급된 토큰 등
throw new BusinessException('AUTH_001', {
context: { reason: 'not-a-refresh-token' },
});
}
return { sub, sid };
}
/**
* 두 토큰을 서명한다. 리프레시 토큰은 세션의 미사용 만료에, 액세스 토큰은 그보다 늦지 않게 끝난다
* (세션보다 오래 사는 토큰을 만들지 않는다).
*/
private async signTokens(
profile: MailAccountProfile,
sessionId: string,
refreshExpiresAt: Date,
now: Date,
): Promise<SessionTokens> {
const { jwtSecret, accessTtlSeconds } = this.settings;
const refreshTtlSeconds = Math.max(
1,
Math.floor((refreshExpiresAt.getTime() - now.getTime()) / 1000),
);
const access: AuthTokenPayload = {
sub: profile.email,
typ: 'access',
name: profile.name,
};
const refresh: AuthTokenPayload = {
sub: profile.email,
typ: 'refresh',
sid: sessionId,
};
const [accessToken, refreshToken] = await Promise.all([
this.jwt.signAsync(access, {
secret: jwtSecret,
algorithm: JWT_ALGORITHM,
expiresIn: Math.min(accessTtlSeconds, refreshTtlSeconds),
jwtid: randomUUID(),
}),
this.jwt.signAsync(refresh, {
secret: jwtSecret,
algorithm: JWT_ALGORITHM,
expiresIn: refreshTtlSeconds,
jwtid: randomUUID(),
}),
]);
return { accessToken, refreshToken, refreshTtlSeconds };
}
private loginFailed(email: string, reason: string): BusinessException {
return new BusinessException('AUTH_004', {
context: { reason, email: maskEmail(email) },
});
}
}
@@ -0,0 +1,73 @@
import { buildAuthSettings } from './auth.settings';
const reader =
(values: Record<string, string>) =>
(key: string): string | undefined =>
values[key];
describe('buildAuthSettings', () => {
it('env 의 서명 키와 만료 기간을 초 단위로 쓴다', () => {
const warn = jest.fn();
const settings = buildAuthSettings(
reader({
NODE_ENV: 'development',
JWT_SECRET: 'configured-secret-value',
JWT_ACCESS_EXPIRES_IN: '15m',
JWT_REFRESH_EXPIRES_IN: '7d',
}),
warn,
);
expect(settings).toEqual({
jwtSecret: 'configured-secret-value',
accessTtlSeconds: 15 * 60,
refreshTtlSeconds: 7 * 24 * 60 * 60,
secureCookies: false,
});
expect(warn).not.toHaveBeenCalled();
});
it('만료 기간이 없으면 계약 기본값(1h · 14d)', () => {
const settings = buildAuthSettings(
reader({ JWT_SECRET: 'configured-secret-value' }),
jest.fn(),
);
expect(settings.accessTtlSeconds).toBe(60 * 60);
expect(settings.refreshTtlSeconds).toBe(14 * 24 * 60 * 60);
});
it('비운영에서 서명 키가 없으면 임시 키로 기동하고 경고한다 (CI e2e)', () => {
const warn = jest.fn<void, [string]>();
const first = buildAuthSettings(reader({ NODE_ENV: 'test' }), warn);
const second = buildAuthSettings(
reader({ NODE_ENV: 'test', JWT_SECRET: ' ' }),
warn,
);
expect(first.jwtSecret.length).toBeGreaterThanOrEqual(64);
expect(second.jwtSecret).not.toBe(first.jwtSecret);
expect(warn).toHaveBeenCalledTimes(2);
expect(warn.mock.calls[0][0]).toContain('임시 서명 키');
});
it('운영에서 서명 키가 없으면 기동하지 않는다', () => {
expect(() =>
buildAuthSettings(reader({ NODE_ENV: 'production' }), jest.fn()),
).toThrow(/JWT_SECRET/);
});
it('운영에서만 Secure 쿠키를 쓴다', () => {
const settings = buildAuthSettings(
reader({
NODE_ENV: 'production',
JWT_SECRET: 'q7Vd2mXk9LpR4tWz8nBc6yHs3gJf5aEu',
}),
jest.fn(),
);
expect(settings.secureCookies).toBe(true);
});
});
+58
View File
@@ -0,0 +1,58 @@
import { randomBytes } from 'crypto';
import { parseDurationSeconds } from '@/shared/utils/duration';
import {
DEFAULT_ACCESS_TTL_SECONDS,
DEFAULT_REFRESH_TTL_SECONDS,
} from './auth.constants';
/**
* 인증 설정 — 백엔드 고유 시크릿(`backend/.env.{APP_ENV}`)에서 읽는다.
* 쿠키 Max-Age 와 토큰 만료가 같은 값에서 나오도록 초 단위로 한 번만 해석한다.
*/
export const AUTH_SETTINGS = Symbol('AUTH_SETTINGS');
export interface AuthSettings {
readonly jwtSecret: string;
readonly accessTtlSeconds: number;
readonly refreshTtlSeconds: number;
/** 운영에서만 Secure 쿠키 (개발은 http://localhost) */
readonly secureCookies: boolean;
}
/** JWT_SECRET 이 없을 때 만드는 임시 키 길이 (바이트) */
const TEMPORARY_SECRET_BYTES = 48;
/**
* env 값을 읽어 인증 설정을 만든다.
* JWT_SECRET 이 없으면 **비운영에서만** 무작위 임시 키로 기동하고 경고를 남긴다
* (재시작하면 모든 세션이 끊긴다). 운영은 env 검증이 기동을 막지만 여기서도 한 번 더 막는다.
*/
export function buildAuthSettings(
read: (key: string) => string | undefined,
warn: (message: string) => void,
): AuthSettings {
const isProduction = read('NODE_ENV')?.trim() === 'production';
let jwtSecret = read('JWT_SECRET')?.trim();
if (!jwtSecret) {
if (isProduction) {
throw new Error('JWT_SECRET 가 설정되지 않았습니다. (운영 환경 필수)');
}
jwtSecret = randomBytes(TEMPORARY_SECRET_BYTES).toString('base64url');
warn(
'JWT_SECRET 이 없어 임시 서명 키로 기동합니다. 재시작하면 모든 로그인 세션이 끊깁니다. (개발·테스트 전용 — 운영은 기동하지 않는다)',
);
}
return {
jwtSecret,
accessTtlSeconds:
parseDurationSeconds(read('JWT_ACCESS_EXPIRES_IN')) ??
DEFAULT_ACCESS_TTL_SECONDS,
refreshTtlSeconds:
parseDurationSeconds(read('JWT_REFRESH_EXPIRES_IN')) ??
DEFAULT_REFRESH_TTL_SECONDS,
secureCookies: isProduction,
};
}
@@ -0,0 +1,15 @@
import { ApiProperty } from '@nestjs/swagger';
import type { AuthenticatedUser } from '@/common/decorators/current-user.decorator';
/** 로그인한 직원 — 토큰은 본문에 없고 HttpOnly 쿠키로만 간다 */
export class AuthUserDto implements AuthenticatedUser {
@ApiProperty({
description: '직원 메일 주소',
example: 'ara.kim@arasystem.it.kr',
})
email!: string;
@ApiProperty({ description: '표시 이름', example: '김아라' })
name!: string;
}
+26
View File
@@ -0,0 +1,26 @@
import { ApiProperty } from '@nestjs/swagger';
import { IsEmail, IsNotEmpty, IsString, MaxLength } from 'class-validator';
import { MAX_PASSWORD_LENGTH } from '@/modules/auth/auth.constants';
import { MAX_EMAIL_LENGTH } from '@/modules/mail/mail.constants';
/** POST /api/auth/login — 직원 메일 계정 */
export class LoginDto {
@ApiProperty({
description: '직원 메일 주소 (사내 도메인)',
example: 'ara.kim@arasystem.it.kr',
})
@IsEmail({}, { message: '올바른 이메일 형식을 입력해 주세요.' })
@MaxLength(MAX_EMAIL_LENGTH, { message: '이메일이 너무 깁니다.' })
email!: string;
@ApiProperty({
description: '메일 계정 비밀번호 — 메일 엔진에 확인만 하고 저장하지 않는다',
format: 'password',
example: 'your-password',
})
@IsString({ message: '비밀번호를 입력해 주세요.' })
@IsNotEmpty({ message: '비밀번호를 입력해 주세요.' })
@MaxLength(MAX_PASSWORD_LENGTH, { message: '비밀번호가 너무 깁니다.' })
password!: string;
}
@@ -0,0 +1,85 @@
import {
isEmployeeAddress,
isLocalPart,
normalizeEmail,
} from './employee-address';
const DOMAIN = 'arasystem.test';
const GATEWAY = 'mail-gateway@arasystem.test';
describe('로그인 주소 정책', () => {
it('사내 도메인의 직원 주소를 받는다', () => {
for (const email of [
'ara.kim@arasystem.test',
'doyun.lee@arasystem.test',
'a@arasystem.test',
'team_lead-01@arasystem.test',
]) {
expect(isEmployeeAddress(email, DOMAIN, GATEWAY)).toBe(true);
}
});
it('사내 도메인이 아니면 거절한다 (하위 도메인·비슷한 도메인 포함)', () => {
for (const email of [
'ara.kim@gmail.com',
'ara.kim@mail.arasystem.test',
'ara.kim@arasystem.test.evil.example',
'ara.kim@xarasystem.test',
]) {
expect(isEmployeeAddress(email, DOMAIN, GATEWAY)).toBe(false);
}
});
it('게이트웨이 계정은 직접 로그인할 수 없다', () => {
expect(isEmployeeAddress(GATEWAY, DOMAIN, GATEWAY)).toBe(false);
});
it('막는 계정(메일 관리자 등)은 사내 도메인 주소여도 로그인할 수 없다 — 다른 직원은 그대로', () => {
const blocked = new Set(['admin@arasystem.test', 'ops@arasystem.test']);
expect(
isEmployeeAddress('admin@arasystem.test', DOMAIN, GATEWAY, blocked),
).toBe(false);
expect(
isEmployeeAddress('ops@arasystem.test', DOMAIN, GATEWAY, blocked),
).toBe(false);
expect(
isEmployeeAddress('ara.kim@arasystem.test', DOMAIN, GATEWAY, blocked),
).toBe(true);
});
it('대리 접근 구분자(%)·콜론 등이 섞인 주소는 거절한다', () => {
for (const email of [
'ara.kim%mail-gateway@arasystem.test',
'ara:kim@arasystem.test',
'"ara kim"@arasystem.test',
'.ara@arasystem.test',
'ara.@arasystem.test',
'@arasystem.test',
]) {
expect(isEmployeeAddress(email, DOMAIN, GATEWAY)).toBe(false);
}
});
it('로컬 부분(아이디) 형식 — 직원 주소와 같은 규칙, 대소문자는 가리지 않는다 (관리자 아이디 검증에도 쓴다)', () => {
for (const value of ['admin', 'Mail-Admin', 'a', 'ara_ops.1']) {
expect(isLocalPart(value)).toBe(true);
}
for (const value of [
'admin@arasystem.test',
'admin%gw',
'ad min',
'.admin',
'',
'a'.repeat(65),
]) {
expect(isLocalPart(value)).toBe(false);
}
});
it('비교 전에 공백을 지우고 소문자로 맞춘다', () => {
expect(normalizeEmail(' Ara.Kim@AraSystem.TEST ')).toBe(
'ara.kim@arasystem.test',
);
});
});
@@ -0,0 +1,50 @@
/**
* 로그인 주소 정책 (순수 함수).
* 직원 계정 = 사내 도메인의 메일 계정. 그 밖의 주소는 메일 엔진에 묻지도 않고 거절한다.
*/
/**
* 사내 주소의 로컬 부분 — 영문 소문자·숫자·`.`·`_`·`-` (앞뒤는 영문·숫자, 64자 이하).
* 특히 `%`·`:` 를 막는다. Stalwart 는 `<대상>%<계정>` 을 대리 접근으로 해석하므로,
* 로그인 주소에 `%` 가 섞이면 백엔드(신뢰 대역)를 통해 대리 접근 로그인이 시도될 수 있다.
*/
const LOCAL_PART_PATTERN = /^[a-z0-9](?:[a-z0-9._-]{0,62}[a-z0-9])?$/;
/**
* 사내 주소의 로컬 부분(아이디) 형식인가 — 대소문자는 가리지 않는다 (주소는 소문자로 맞춰 비교한다).
* 메일 엔진 관리자 아이디(MAIL_ADMIN_LOCAL_PART) 검증에도 쓴다 — env.validation.ts
*/
export function isLocalPart(value: string): boolean {
return LOCAL_PART_PATTERN.test(value.toLowerCase());
}
/** 비교용 정규화 — 앞뒤 공백 제거 + 소문자 */
export function normalizeEmail(email: string): string {
return email.trim().toLowerCase();
}
/**
* 로그인할 수 있는 직원 주소인지 확인한다.
* - 사내 도메인(MAIL_DOMAIN) 주소만
* - 게이트웨이 서비스 계정은 직접 로그인할 수 없다
* - 막는 계정(메일 관리자 `<MAIL_ADMIN_LOCAL_PART>@<도메인>`·MAIL_BLOCKED_ACCOUNTS)도 직원 주소가 아니다 (mail.settings.ts 의 blockedAccounts)
*/
export function isEmployeeAddress(
email: string,
domain: string,
gatewayUser: string | null,
blockedAccounts: ReadonlySet<string> = new Set(),
): boolean {
const at = email.lastIndexOf('@');
if (at <= 0) return false;
const localPart = email.slice(0, at);
const host = email.slice(at + 1);
return (
host === domain &&
LOCAL_PART_PATTERN.test(localPart) &&
email !== gatewayUser &&
!blockedAccounts.has(email)
);
}
@@ -0,0 +1,150 @@
import {
FixedWindowCounter,
type ReserveResult,
type WindowSlot,
} from './fixed-window-counter';
const T0 = 1_000_000;
const WINDOW = 60_000;
/** 자리를 차지했다고 확인하고 그 자리를 꺼낸다 */
function slotOf(result: ReserveResult): WindowSlot {
if (!result.ok) throw new Error(`자리를 얻지 못했다: ${result.reason}`);
return result.slot;
}
describe('FixedWindowCounter', () => {
it('한도까지 자리를 내주고, 한도에 닿으면 limit 으로 거절', () => {
const counter = new FixedWindowCounter(3, WINDOW, 100);
expect(counter.reserve('a', T0).ok).toBe(true);
expect(counter.reserve('a', T0).ok).toBe(true);
expect(counter.reserve('a', T0).ok).toBe(true);
expect(counter.reserve('a', T0)).toEqual({ ok: false, reason: 'limit' });
expect(counter.isExhausted('a', T0)).toBe(true);
// 다른 키는 따로 센다
expect(counter.reserve('b', T0).ok).toBe(true);
});
it('구간은 첫 기록부터 고정이다 — 기록할 때마다 늘어나지 않는다', () => {
const counter = new FixedWindowCounter(2, WINDOW, 100);
counter.reserve('a', T0);
counter.reserve('a', T0 + WINDOW - 1);
expect(counter.reserve('a', T0 + WINDOW - 1).ok).toBe(false);
// 첫 기록 + WINDOW 가 지나면 0 부터 다시 센다
expect(counter.count('a', T0 + WINDOW)).toBe(0);
expect(counter.reserve('a', T0 + WINDOW).ok).toBe(true);
});
it('돌려준 자리는 다시 쓸 수 있다', () => {
const counter = new FixedWindowCounter(1, WINDOW, 100);
const slot = slotOf(counter.reserve('a', T0));
expect(counter.reserve('a', T0).ok).toBe(false);
counter.release(slot, T0 + 1);
expect(counter.count('a', T0 + 1)).toBe(0);
expect(counter.reserve('a', T0 + 1).ok).toBe(true);
});
it('구간이 바뀐 뒤에 돌려주면 새 구간의 수를 건드리지 않는다', () => {
const counter = new FixedWindowCounter(5, WINDOW, 100);
const oldSlot = slotOf(counter.reserve('a', T0));
counter.reserve('a', T0 + WINDOW); // 새 구간의 첫 기록
counter.release(oldSlot, T0 + WINDOW + 1);
expect(counter.count('a', T0 + WINDOW + 1)).toBe(1);
});
it('clear 는 그 키의 기록을 지운다', () => {
const counter = new FixedWindowCounter(1, WINDOW, 100);
counter.reserve('a', T0);
counter.clear('a');
expect(counter.reserve('a', T0).ok).toBe(true);
});
it('sweep 은 끝난 구간만 지운다', () => {
const counter = new FixedWindowCounter(5, WINDOW, 100);
counter.reserve('old', T0);
counter.reserve('new', T0 + 10_000);
expect(counter.sweep(T0 + WINDOW)).toEqual({
removed: 1,
evicted: 0,
rejected: 0,
});
expect(counter.size).toBe(1);
expect(counter.count('new', T0 + WINDOW)).toBe(1);
});
describe('키 개수 상한', () => {
it('상한에 닿으면 가장 먼저 끝나는 잠기지 않은 구간을 밀어내고, 밀어낸 수를 알려 준다', () => {
const counter = new FixedWindowCounter(5, WINDOW, 3);
counter.reserve('k1', T0);
counter.reserve('k2', T0 + 1);
counter.reserve('k3', T0 + 2);
expect(counter.reserve('k4', T0 + 3).ok).toBe(true);
expect(counter.size).toBe(3);
expect(counter.count('k1', T0 + 3)).toBe(0);
expect(counter.count('k4', T0 + 3)).toBe(1);
expect(counter.sweep(T0 + 3).evicted).toBe(1);
expect(counter.sweep(T0 + 3).evicted).toBe(0);
});
it('잠금 중인 구간(한도에 닿은 것)은 밀어내지 않는다 — 그 다음의 잠기지 않은 구간을 밀어낸다', () => {
const counter = new FixedWindowCounter(2, WINDOW, 3);
// locked 는 한도(2)에 닿아 잠겨 있다
counter.reserve('locked', T0);
counter.reserve('locked', T0);
counter.reserve('open', T0 + 1);
counter.reserve('other', T0 + 2);
expect(counter.reserve('new', T0 + 3).ok).toBe(true);
expect(counter.isExhausted('locked', T0 + 3)).toBe(true);
expect(counter.count('open', T0 + 3)).toBe(0);
});
it('밀어낼 것이 없으면(모두 잠금 중) 새 키를 capacity 로 거절한다 — fail-closed, 이미 아는 키는 그대로 센다', () => {
const counter = new FixedWindowCounter(1, WINDOW, 2);
counter.reserve('a', T0);
counter.reserve('b', T0);
expect(counter.reserve('new', T0 + 1)).toEqual({
ok: false,
reason: 'capacity',
});
expect(counter.isExhausted('a', T0 + 1)).toBe(true);
expect(counter.reserve('a', T0 + 1)).toEqual({
ok: false,
reason: 'limit',
});
expect(counter.sweep(T0 + 1)).toMatchObject({ rejected: 1 });
});
it('잠긴 구간도 끝나면 다시 자리가 난다', () => {
const counter = new FixedWindowCounter(1, WINDOW, 1);
counter.reserve('a', T0);
expect(counter.reserve('new', T0 + 1).ok).toBe(false);
expect(counter.reserve('new', T0 + WINDOW).ok).toBe(true);
});
it('끝난 구간은 밀어내도 경고 대상(evicted)으로 세지 않는다', () => {
const counter = new FixedWindowCounter(5, WINDOW, 1);
counter.reserve('k1', T0);
counter.reserve('k2', T0 + WINDOW);
expect(counter.sweep(T0 + WINDOW).evicted).toBe(0);
});
});
});
@@ -0,0 +1,164 @@
/** 차지한 자리 — 돌려줄 때 같은 구간인지 확인하는 데 쓴다 */
export interface WindowSlot {
readonly key: string;
/** 자리를 차지한 구간의 끝 (ms) */
readonly windowEndsAt: number;
}
/**
* 자리 차지 결과.
* - `limit`: 그 키가 이번 구간의 한도에 닿았다
* - `capacity`: 기억하는 키가 상한에 닿았고 밀어낼 수 있는 기록(끝났거나 잠기지 않은 구간)이 없다 — 새 키를 받지 않는다(fail-closed)
*/
export type ReserveResult =
| { readonly ok: true; readonly slot: WindowSlot }
| { readonly ok: false; readonly reason: 'limit' | 'capacity' };
interface WindowState {
count: number;
endsAt: number;
}
/**
* 상한에서 밀어낼 기록을 찾을 때 앞(가장 먼저 끝나는 쪽)에서부터 볼 최대 개수.
* 잠긴 기록이 앞을 채우고 있어도 요청마다 전체를 훑지 않게 한다 (그 안에 없으면 새 키를 거절한다).
*/
const MAX_EVICTION_SCAN = 256;
/**
* 고정 구간 카운터 (프로세스 메모리) — 로그인 실패 한도의 저장소.
*
* **확인과 증가가 한 동기 구간에서 끝난다**(await 없음). 그래서 동시에 들어온 요청들이
* "아직 한 자리 남았다"를 함께 보고 모두 통과하는 일이 없다 — Node 는 한 스레드라 이 구간에 끼어들 수 없다.
*
* 기억하는 키 개수에 상한이 있다(많은 IP·주소로 메모리를 채우는 공격 대비). 상한에 닿으면
* - 끝난 구간과 **잠기지 않은**(한도에 닿지 않은) 구간만 밀어낸다 — 잠금 중인 구간을 밀어내면 그 공격이 풀린다
* - 밀어낼 것이 없으면 새 키를 받지 않는다(fail-closed) — 이미 기억하는 키는 그대로 센다
*
* ⚠️ 인스턴스마다 따로 센다. 백엔드를 여러 대로 늘리면 한도가 인스턴스 수만큼 늘어나므로,
* 그때는 Redis 의 원자 연산(INCR + PEXPIRE 를 한 Lua 스크립트로)으로 옮겨야 한다 (.claude/skills/nestjs/SKILL.md).
*
* 구간은 첫 기록부터 `windowMs` 동안이다 (기록할 때마다 늘어나지 않는다).
*/
export class FixedWindowCounter {
private readonly windows = new Map<string, WindowState>();
private evictedSinceSweep = 0;
private rejectedSinceSweep = 0;
constructor(
readonly limit: number,
readonly windowMs: number,
/** 기억하는 키 개수 상한 — 많은 IP 로 무작위 주소를 뿌려 메모리를 채우는 공격 대비 */
private readonly maxKeys: number,
) {}
/** 지금 구간에 센 수 (구간이 끝났으면 0) */
count(key: string, now: number = Date.now()): number {
return this.current(key, now)?.count ?? 0;
}
isExhausted(key: string, now: number = Date.now()): boolean {
return this.count(key, now) >= this.limit;
}
/** 자리 하나를 차지한다 — 확인과 증가가 한 번에 일어난다 */
reserve(key: string, now: number = Date.now()): ReserveResult {
const state = this.current(key, now);
if (state) {
if (state.count >= this.limit) return { ok: false, reason: 'limit' };
state.count += 1;
return { ok: true, slot: { key, windowEndsAt: state.endsAt } };
}
if (!this.makeRoom(now)) {
this.rejectedSinceSweep += 1;
return { ok: false, reason: 'capacity' };
}
const endsAt = now + this.windowMs;
this.windows.set(key, { count: 1, endsAt });
return { ok: true, slot: { key, windowEndsAt: endsAt } };
}
/** 차지한 자리를 돌려준다 — 그 구간이 아직 이어질 때만 (구간이 바뀌었으면 이미 0 부터 다시 센다) */
release(slot: WindowSlot, now: number = Date.now()): void {
const state = this.current(slot.key, now);
if (!state || state.endsAt !== slot.windowEndsAt) return;
state.count -= 1;
if (state.count <= 0) this.windows.delete(slot.key);
}
clear(key: string): void {
this.windows.delete(key);
}
/**
* 끝난 구간을 지운다. 지난 정리 이후 상한 때문에 밀려난 키 수(evicted)와
* 상한이 차서 거절한 새 키 수(rejected)도 함께 돌려준다 (로그용)
*/
sweep(now: number = Date.now()): {
removed: number;
evicted: number;
rejected: number;
} {
const removed = this.removeEnded(now);
const result = {
removed,
evicted: this.evictedSinceSweep,
rejected: this.rejectedSinceSweep,
};
this.evictedSinceSweep = 0;
this.rejectedSinceSweep = 0;
return result;
}
get size(): number {
return this.windows.size;
}
/**
* 구간 길이가 모두 같으므로 Map 의 삽입 순서 = 구간이 끝나는 순서다.
* 앞에서부터 끝난 것만 지우고, 아직 이어지는 구간을 만나면 멈춘다.
*/
private removeEnded(now: number): number {
let removed = 0;
for (const [key, state] of this.windows) {
if (state.endsAt > now) break;
this.windows.delete(key);
removed += 1;
}
return removed;
}
private current(key: string, now: number): WindowState | undefined {
const state = this.windows.get(key);
if (state && state.endsAt <= now) {
this.windows.delete(key);
return undefined;
}
return state;
}
/**
* 새 키를 넣을 자리를 만든다 → 만들었으면 true.
* 가장 먼저 끝나는 쪽(삽입 순서)부터 최대 MAX_EVICTION_SCAN 개를 보며, 끝났거나 잠기지 않은 구간 하나를 밀어낸다.
* 잠금 중인 구간(한도에 닿은 것)은 건너뛴다.
*/
private makeRoom(now: number): boolean {
if (this.windows.size < this.maxKeys) return true;
let scanned = 0;
for (const [key, state] of this.windows) {
if (scanned >= MAX_EVICTION_SCAN) break;
scanned += 1;
const ended = state.endsAt <= now;
if (ended || state.count < this.limit) {
this.windows.delete(key);
if (!ended) this.evictedSinceSweep += 1;
return true;
}
}
return false;
}
}
@@ -0,0 +1,33 @@
import { BusinessException } from '@/common/errors/business.exception';
import { JwtStrategy } from './jwt.strategy';
const strategy = new JwtStrategy({
jwtSecret: 'unit-test-secret-unit-test-secret-1234',
accessTtlSeconds: 3600,
refreshTtlSeconds: 1209600,
secureCookies: false,
});
describe('JwtStrategy.validate', () => {
it('액세스 토큰이면 직원 정보를 돌려준다', () => {
expect(
strategy.validate({
sub: 'ara.kim@arasystem.test',
typ: 'access',
name: '김아라',
}),
).toEqual({ email: 'ara.kim@arasystem.test', name: '김아라' });
});
it('리프레시 토큰을 액세스 쿠키에 넣으면 AUTH_001', () => {
expect(() =>
strategy.validate({ sub: 'ara.kim@arasystem.test', typ: 'refresh' }),
).toThrow(BusinessException);
try {
strategy.validate({ sub: 'ara.kim@arasystem.test', typ: 'refresh' });
} catch (error) {
expect((error as BusinessException).code).toBe('AUTH_001');
}
});
});
+47
View File
@@ -0,0 +1,47 @@
import { Inject, Injectable } from '@nestjs/common';
import { PassportStrategy } from '@nestjs/passport';
import type { Request } from 'express';
import { ExtractJwt, Strategy } from 'passport-jwt';
import type { AuthenticatedUser } from '@/common/decorators/current-user.decorator';
import { BusinessException } from '@/common/errors/business.exception';
import { JWT_STRATEGY } from '@/common/guards/jwt-auth.guard';
import { readCookie } from '@/shared/utils/cookies';
import { ACCESS_TOKEN_COOKIE, JWT_ALGORITHM } from './auth.constants';
import { AUTH_SETTINGS, type AuthSettings } from './auth.settings';
import type { AuthTokenPayload } from './auth-token.types';
/** 액세스 토큰은 Authorization 헤더가 아니라 HttpOnly 쿠키에서만 읽는다 (계약 1절) */
function accessTokenFromCookie(request: Request): string | null {
return readCookie(request, ACCESS_TOKEN_COOKIE) ?? null;
}
/**
* 액세스 토큰 검증 전략 — `JwtAuthGuard` 가 이 전략을 쓴다.
* 서명·만료는 passport-jwt 가 확인하고, 여기서는 토큰 종류(typ)와 필드 모양을 확인한다.
*/
@Injectable()
export class JwtStrategy extends PassportStrategy(Strategy, JWT_STRATEGY) {
constructor(@Inject(AUTH_SETTINGS) settings: AuthSettings) {
super({
jwtFromRequest: ExtractJwt.fromExtractors([accessTokenFromCookie]),
secretOrKey: settings.jwtSecret,
algorithms: [JWT_ALGORITHM],
ignoreExpiration: false,
});
}
validate(payload: AuthTokenPayload): AuthenticatedUser {
if (
payload.typ !== 'access' ||
typeof payload.sub !== 'string' ||
typeof payload.name !== 'string'
) {
// 리프레시 토큰을 액세스 쿠키에 넣은 경우 등
throw new BusinessException('AUTH_001', {
context: { reason: 'not-an-access-token' },
});
}
return { email: payload.sub, name: payload.name };
}
}
@@ -0,0 +1,314 @@
import { Logger } from '@nestjs/common';
import { BusinessException } from '@/common/errors/business.exception';
import {
LOGIN_ADDRESS_FAILURE_LIMIT,
LOGIN_ADDRESS_FAILURE_WINDOW_MS,
LOGIN_IP_FAILURE_LIMIT,
LOGIN_IP_FAILURE_WINDOW_MS,
} from './auth.constants';
import { FixedWindowCounter } from './fixed-window-counter';
import {
LoginAttemptService,
type LoginAttemptSource,
} from './login-attempt.service';
const ARA = 'ara.kim@arasystem.test';
const IP = '203.0.113.10';
function reasonOf(run: () => unknown): unknown {
try {
run();
} catch (error) {
if (error instanceof BusinessException) {
return { code: error.code, ...error.context };
}
throw error;
}
return 'allowed';
}
describe('LoginAttemptService', () => {
let service: LoginAttemptService;
let now: number;
/** 시도 하나를 시작하자마자 결과를 정한다 */
const attempt = (
source: LoginAttemptSource,
outcome: 'fail' | 'succeed' | 'abort',
) => service.begin(source)[outcome]();
beforeEach(() => {
service = new LoginAttemptService();
now = 1_700_000_000_000;
jest.spyOn(Date, 'now').mockImplementation(() => now);
});
afterEach(() => jest.restoreAllMocks());
it('같은 주소로 실패 10회가 쌓이면 11번째 시도는 REQ_001 — IP 를 바꿔도 마찬가지', () => {
for (let i = 0; i < LOGIN_ADDRESS_FAILURE_LIMIT; i += 1) {
attempt({ email: ARA, ip: `198.51.100.${i}` }, 'fail');
}
expect(reasonOf(() => service.begin({ email: ARA, ip: IP }))).toEqual({
code: 'REQ_001',
reason: 'login-failures-exceeded',
scope: 'address',
email: 'a***@arasystem.test',
});
});
it('같은 IP 에서 실패 20회가 쌓이면 처음 보는 주소도 REQ_001', () => {
for (let i = 0; i < LOGIN_IP_FAILURE_LIMIT; i += 1) {
attempt({ email: `guess${i}@arasystem.test`, ip: IP }, 'fail');
}
expect(
reasonOf(() =>
service.begin({ email: 'new.person@arasystem.test', ip: IP }),
),
).toMatchObject({ code: 'REQ_001', scope: 'ip' });
// 다른 IP 는 영향이 없다
expect(
reasonOf(() =>
service.begin({ email: 'new.person@arasystem.test', ip: '192.0.2.1' }),
),
).toBe('allowed');
});
it('성공은 세지 않는다 — 같은 IP 에서 한도보다 많이 성공해도 막히지 않는다 (사무실 NAT)', () => {
for (let i = 0; i < LOGIN_IP_FAILURE_LIMIT * 3; i += 1) {
attempt({ email: `staff${i}@arasystem.test`, ip: IP }, 'succeed');
}
expect(reasonOf(() => service.begin({ email: ARA, ip: IP }))).toBe(
'allowed',
);
});
it('성공하면 그 주소의 실패 기록을 지운다 — IP 기록은 남긴다', () => {
for (let i = 0; i < LOGIN_ADDRESS_FAILURE_LIMIT - 1; i += 1) {
attempt({ email: ARA, ip: IP }, 'fail');
}
attempt({ email: ARA, ip: IP }, 'succeed');
// 주소: 다시 10번 실패할 수 있다
for (let i = 0; i < LOGIN_ADDRESS_FAILURE_LIMIT; i += 1) {
attempt({ email: ARA, ip: `198.51.100.${i}` }, 'fail');
}
expect(
reasonOf(() => service.begin({ email: ARA, ip: '192.0.2.9' })),
).toMatchObject({ scope: 'address' });
// IP: 앞의 실패 9회는 지워지지 않았다 → 11번 더 실패하면 한도(20)
for (let i = 0; i < LOGIN_IP_FAILURE_LIMIT - 9; i += 1) {
attempt({ email: `guess${i}@arasystem.test`, ip: IP }, 'fail');
}
expect(
reasonOf(() => service.begin({ email: 'x@arasystem.test', ip: IP })),
).toMatchObject({ scope: 'ip' });
});
it('판단하지 못한 시도(엔진 장애)는 두 자리를 모두 돌려준다', () => {
for (let i = 0; i < LOGIN_ADDRESS_FAILURE_LIMIT * 3; i += 1) {
attempt({ email: ARA, ip: IP }, 'abort');
}
expect(reasonOf(() => service.begin({ email: ARA, ip: IP }))).toBe(
'allowed',
);
});
it('같은 주소의 시도가 진행 중이면 다음 시도는 기다리지 않고 REQ_001 — 끝나면 다시 받는다', () => {
const first = service.begin({ email: ARA, ip: IP });
expect(
reasonOf(() => service.begin({ email: ARA, ip: '192.0.2.1' })),
).toMatchObject({ code: 'REQ_001', reason: 'login-in-progress' });
// 다른 주소는 같은 IP 라도 동시에 진행할 수 있다
expect(
reasonOf(() => service.begin({ email: 'doyun@arasystem.test', ip: IP })),
).toBe('allowed');
first.fail();
expect(reasonOf(() => service.begin({ email: ARA, ip: IP }))).toBe(
'allowed',
);
});
it('결과는 한 번만 반영된다 — 실패 뒤 abort 가 불려도 실패가 지워지지 않는다', () => {
for (let i = 0; i < LOGIN_ADDRESS_FAILURE_LIMIT; i += 1) {
const ticket = service.begin({ email: ARA, ip: IP });
ticket.fail();
ticket.abort();
ticket.succeed();
}
expect(reasonOf(() => service.begin({ email: ARA, ip: IP }))).toMatchObject(
{ scope: 'address' },
);
});
it('구간이 지나면 다시 시도할 수 있다 (IP 1분 · 주소 15분)', () => {
for (let i = 0; i < LOGIN_IP_FAILURE_LIMIT; i += 1) {
attempt({ email: `guess${i}@arasystem.test`, ip: IP }, 'fail');
}
now += LOGIN_IP_FAILURE_WINDOW_MS;
expect(
reasonOf(() => service.begin({ email: 'later@arasystem.test', ip: IP })),
).toBe('allowed');
for (let i = 0; i < LOGIN_ADDRESS_FAILURE_LIMIT; i += 1) {
attempt({ email: ARA, ip: `198.51.100.${i}` }, 'fail');
}
now += LOGIN_ADDRESS_FAILURE_WINDOW_MS - 1;
expect(
reasonOf(() => service.begin({ email: ARA, ip: '192.0.2.2' })),
).toMatchObject({ scope: 'address' });
now += 1;
expect(reasonOf(() => service.begin({ email: ARA, ip: '192.0.2.3' }))).toBe(
'allowed',
);
});
it('주소 한도로 거부되면 IP 자리도 돌려준다 — 막힌 주소를 두드려 IP 한도를 대신 소모시키지 못한다', () => {
for (let i = 0; i < LOGIN_ADDRESS_FAILURE_LIMIT; i += 1) {
attempt({ email: ARA, ip: `198.51.100.${i}` }, 'fail');
}
for (let i = 0; i < LOGIN_IP_FAILURE_LIMIT * 2; i += 1) {
expect(
reasonOf(() => service.begin({ email: ARA, ip: IP })),
).toMatchObject({ scope: 'address' });
}
expect(
reasonOf(() => service.begin({ email: 'doyun@arasystem.test', ip: IP })),
).toBe('allowed');
});
it('정리 작업은 상한 때문에 일찍 지운 기록이 있을 때만 경고한다', () => {
const warn = jest
.spyOn(Logger.prototype, 'warn')
.mockImplementation(() => undefined);
service.sweep(now);
expect(warn).not.toHaveBeenCalled();
});
describe('IP 묶기 (요청 수 한도와 같은 clientIpKey)', () => {
it('IPv6 는 /64 로 묶는다 — 같은 회선에서 주소를 바꿔 가며 IP 한도를 피하지 못한다', () => {
for (let i = 0; i < LOGIN_IP_FAILURE_LIMIT; i += 1) {
attempt(
{ email: `guess${i}@arasystem.test`, ip: `2001:db8:7:7::${i + 1}` },
'fail',
);
}
expect(
reasonOf(() =>
service.begin({
email: 'new@arasystem.test',
ip: '2001:db8:7:7:abcd::1',
}),
),
).toMatchObject({ scope: 'ip', ip: '2001:db8:7:7::/64' });
// 다른 /64 는 영향이 없다
expect(
reasonOf(() =>
service.begin({ email: 'new@arasystem.test', ip: '2001:db8:7:8::1' }),
),
).toBe('allowed');
});
it('IPv4-mapped 주소와 IPv4 주소는 같은 IP 로 센다', () => {
for (let i = 0; i < LOGIN_IP_FAILURE_LIMIT / 2; i += 1) {
attempt({ email: `a${i}@arasystem.test`, ip: '203.0.113.7' }, 'fail');
attempt(
{ email: `b${i}@arasystem.test`, ip: '::ffff:203.0.113.7' },
'fail',
);
}
expect(
reasonOf(() =>
service.begin({ email: 'c@arasystem.test', ip: '203.0.113.7' }),
),
).toMatchObject({ scope: 'ip' });
});
});
describe('사내 도메인이 아닌 주소 (email: null)', () => {
it('주소 카운터에 넣지 않고 IP 만 센다 — 진행 중 잠금도 없다', () => {
// 같은 IP 에서 여러 번 실패해도 주소 한도는 걸리지 않고, IP 한도만 쌓인다
const tickets = Array.from({ length: 3 }, () =>
service.begin({ email: null, ip: IP }),
);
tickets.forEach((ticket) => ticket.fail());
for (let i = 0; i < LOGIN_IP_FAILURE_LIMIT - 3; i += 1) {
attempt({ email: null, ip: IP }, 'fail');
}
expect(
reasonOf(() => service.begin({ email: null, ip: IP })),
).toMatchObject({ scope: 'ip' });
});
it('사내 주소의 주소 한도와 섞이지 않는다', () => {
for (let i = 0; i < LOGIN_ADDRESS_FAILURE_LIMIT; i += 1) {
attempt({ email: null, ip: `198.51.100.${i}` }, 'fail');
}
expect(
reasonOf(() => service.begin({ email: ARA, ip: '192.0.2.5' })),
).toBe('allowed');
});
});
describe('기억하는 키 상한 (fail-closed)', () => {
/** 상한을 작게 바꾼 서비스 — 내부 카운터를 같은 규칙의 작은 카운터로 바꿔 끼운다 */
function withCapacity(maxKeys: number): LoginAttemptService {
const small = new LoginAttemptService();
Reflect.set(
small,
'ipFailures',
new FixedWindowCounter(
LOGIN_IP_FAILURE_LIMIT,
LOGIN_IP_FAILURE_WINDOW_MS,
maxKeys,
),
);
return small;
}
it('상한이 차고 모두 잠금 중이면 새 IP 는 REQ_001(login-tracking-full) 이고, 처음 한 번 error 로그를 남긴다', () => {
const error = jest
.spyOn(Logger.prototype, 'error')
.mockImplementation(() => undefined);
const small = withCapacity(1);
// 한 IP 를 한도까지 채워 잠근다 (밀어낼 수 없는 기록)
for (let i = 0; i < LOGIN_IP_FAILURE_LIMIT; i += 1) {
small.begin({ email: null, ip: '198.51.100.1' }).fail();
}
for (const ip of ['198.51.100.2', '198.51.100.3']) {
try {
small.begin({ email: null, ip });
fail('거절해야 한다');
} catch (rejection) {
expect((rejection as BusinessException).code).toBe('REQ_001');
expect((rejection as BusinessException).context).toMatchObject({
reason: 'login-tracking-full',
});
}
}
expect(error).toHaveBeenCalledTimes(1);
expect(error.mock.calls[0][0]).toMatch(/\[REQ_001\].*fail-closed/);
// 정리 주기에 거절 건수를 error 로 합산하고, 다음 주기에는 다시 처음 한 번 알린다
small.sweep(now);
expect(error).toHaveBeenCalledTimes(2);
expect(error.mock.calls[1][0]).toMatch(/2건을 거절했습니다/);
});
});
});
@@ -0,0 +1,204 @@
import { Injectable, Logger } from '@nestjs/common';
import { Interval } from '@nestjs/schedule';
import { createHash } from 'crypto';
import { BusinessException } from '@/common/errors/business.exception';
import { clientIpKey } from '@/shared/utils/client-ip';
import { maskEmail } from '@/shared/utils/mask';
import {
LOGIN_ADDRESS_FAILURE_LIMIT,
LOGIN_ADDRESS_FAILURE_WINDOW_MS,
LOGIN_IP_FAILURE_LIMIT,
LOGIN_IP_FAILURE_WINDOW_MS,
} from './auth.constants';
import {
FixedWindowCounter,
type ReserveResult,
type WindowSlot,
} from './fixed-window-counter';
/** 끝난 구간을 지우는 주기 */
const SWEEP_INTERVAL_MS = 60_000;
/**
* 기억하는 IP·주소 개수 상한 (카운터마다). 키 하나는 수백 바이트라 상한에서도 수십 MB 안쪽이다.
* 넘치면 끝났거나 잠기지 않은 구간만 밀어내고, 밀어낼 것이 없으면 새 키를 거절한다 (fixed-window-counter.ts).
*/
const MAX_TRACKED_KEYS = 100_000;
type Scope = 'ip' | 'address';
type RejectReason =
| 'login-failures-exceeded'
| 'login-in-progress'
| 'login-tracking-full';
/** 로그인 시도 하나를 세는 기준 */
export interface LoginAttemptSource {
/**
* 정규화한 로그인 주소 — 사내 도메인 주소만 넣는다(없는 주소도 그대로 센다).
* 사내 도메인이 아닌 주소는 null: 엔진에 묻지 않고 바로 거절하므로 IP 만 센다.
* (도메인 규칙은 공개 정보라 이 차이로 알 수 있는 것은 없다)
*/
readonly email: string | null;
/** 클라이언트 IP — 믿을 수 있는 프록시 뒤에서 읽은 값 (app.setup.ts 의 TRUST_PROXY). IPv6 는 /64 로 묶어 센다 */
readonly ip: string;
}
/**
* 진행 중인 로그인 시도 하나. 결과가 정해지면 셋 중 하나를 **한 번** 부른다 (두 번째부터는 무시).
*/
export interface LoginAttemptTicket {
/** 실패(AUTH_004) — 미리 차지한 실패 한 번을 그대로 남긴다 */
fail(): void;
/** 성공 — 그 주소의 실패 기록을 지우고, 차지했던 IP 자리를 돌려준다 (성공은 세지 않는다) */
succeed(): void;
/** 판단하지 못함(메일 엔진 장애 등) — 차지했던 자리를 모두 돌려준다 */
abort(): void;
}
/**
* 로그인 **실패** 횟수 제한 (계약 1절).
*
* - 같은 IP 에서 1분에 실패 20회 / 같은 주소로 15분에 실패 10회를 넘으면 REQ_001.
* 한도를 넘은 동안은 비밀번호가 맞아도 막는다 (무차별 대입이 계속 정답을 시험하지 못하게)
* - IP 는 `clientIpKey()` 로 묶는다 — IPv6 는 /64, IPv4-mapped 는 IPv4 (요청 수 한도와 같은 키)
* - **메일 엔진에 묻기 전에** 실패 한 번을 IP·주소 양쪽에 미리 차지한다(`begin`).
* 확인과 차지가 한 동기 구간이라, 동시에 보낸 요청 100건이 "아직 한 번 남았다"를 함께 보고
* 모두 엔진까지 가는 일이 없다. 결과가 성공이거나 판단할 수 없으면 차지한 자리를 돌려준다.
* - 같은 주소의 시도가 진행 중이면 다음 시도는 기다리지 않고 REQ_001 — 한 주소는 한 번에 하나만 엔진에 묻는다.
* - 성공하면 그 **주소**의 기록만 지운다. IP 기록은 지우지 않는다
* (계정 하나를 아는 공격자가 성공 로그인으로 IP 카운터를 비워 가며 다른 계정을 찔러 보지 못하게)
* - 기억하는 키가 상한에 닿고 밀어낼 기록도 없으면 새 IP·주소는 REQ_001 (fail-closed) + error 로그
*
* 저장소는 프로세스 메모리다. ⚠️ 여러 인스턴스로 늘리면 인스턴스마다 따로 세므로
* Redis 원자 연산으로 옮겨야 한다 (fixed-window-counter.ts).
*/
@Injectable()
export class LoginAttemptService {
private readonly logger = new Logger(LoginAttemptService.name);
private readonly ipFailures = new FixedWindowCounter(
LOGIN_IP_FAILURE_LIMIT,
LOGIN_IP_FAILURE_WINDOW_MS,
MAX_TRACKED_KEYS,
);
private readonly addressFailures = new FixedWindowCounter(
LOGIN_ADDRESS_FAILURE_LIMIT,
LOGIN_ADDRESS_FAILURE_WINDOW_MS,
MAX_TRACKED_KEYS,
);
/** 엔진 확인이 진행 중인 주소 */
private readonly inFlight = new Set<string>();
/** 상한이 차서 거절하기 시작했다는 error 로그를 이번 정리 주기에 이미 남겼는가 (로그 폭주 방지) */
private capacityAlarmRaised = false;
/**
* 시도를 시작한다 — 한도를 넘었거나 같은 주소의 시도가 진행 중이면 REQ_001.
* 이 메서드 안에는 await 가 없다 (확인과 차지 사이에 다른 요청이 끼어들지 못한다).
*/
begin(source: LoginAttemptSource): LoginAttemptTicket {
const now = Date.now();
const ipKey = digest(clientIpKey(source.ip));
const addressKey = source.email === null ? null : digest(source.email);
if (addressKey !== null && this.inFlight.has(addressKey)) {
throw this.rejected(source, 'address', 'login-in-progress');
}
const ip = this.ipFailures.reserve(ipKey, now);
if (!ip.ok) throw this.rejected(source, 'ip', this.reasonOf(ip));
let addressSlot: WindowSlot | null = null;
if (addressKey !== null) {
const address = this.addressFailures.reserve(addressKey, now);
if (!address.ok) {
this.ipFailures.release(ip.slot, now);
throw this.rejected(source, 'address', this.reasonOf(address));
}
addressSlot = address.slot;
this.inFlight.add(addressKey);
}
let settled = false;
const settle = (finish: () => void) => {
if (settled) return;
settled = true;
if (addressKey !== null) this.inFlight.delete(addressKey);
finish();
};
return {
fail: () => settle(() => undefined),
succeed: () =>
settle(() => {
this.ipFailures.release(ip.slot);
if (addressKey !== null) this.addressFailures.clear(addressKey);
}),
abort: () =>
settle(() => {
this.ipFailures.release(ip.slot);
if (addressSlot !== null) this.addressFailures.release(addressSlot);
}),
};
}
/**
* 끝난 구간을 지운다. 상한 때문에 밀려난 기록이 있었다면 warn(대량 시도 징후),
* 상한이 차서 새 키를 거절했다면 error (정상 사용자도 막히고 있다 — 즉시 확인이 필요하다)
*/
@Interval(SWEEP_INTERVAL_MS)
sweep(now: number = Date.now()): void {
const ip = this.ipFailures.sweep(now);
const address = this.addressFailures.sweep(now);
this.capacityAlarmRaised = false;
const evicted = ip.evicted + address.evicted;
if (evicted > 0) {
this.logger.warn(
`[REQ_001] 로그인 실패 기록이 상한(${MAX_TRACKED_KEYS})에 닿아 잠기지 않은 기록 ${evicted}건을 일찍 지웠습니다 — 많은 IP·주소로 시도하는 공격일 수 있습니다.`,
);
}
const rejected = ip.rejected + address.rejected;
if (rejected > 0) {
this.logger.error(
`[REQ_001] 로그인 실패 기록이 상한(${MAX_TRACKED_KEYS})에 닿고 밀어낼 기록이 없어 지난 주기에 새 IP·주소의 로그인 ${rejected}건을 거절했습니다 (fail-closed).`,
);
}
}
private reasonOf(
result: Extract<ReserveResult, { ok: false }>,
): RejectReason {
if (result.reason === 'limit') return 'login-failures-exceeded';
// 상한이 찼다 — 정상 사용자도 막힐 수 있는 상태라 처음 한 번은 바로 error 로 알린다 (나머지는 정리 주기에 합계로)
if (!this.capacityAlarmRaised) {
this.capacityAlarmRaised = true;
this.logger.error(
`[REQ_001] 로그인 실패 기록이 상한(${MAX_TRACKED_KEYS})에 닿았고 밀어낼 수 있는 기록(끝났거나 잠기지 않은 것)이 없어 새 IP·주소의 로그인을 거절합니다 (fail-closed). 대량 시도가 의심됩니다.`,
);
}
return 'login-tracking-full';
}
private rejected(
source: LoginAttemptSource,
scope: Scope,
reason: RejectReason,
): BusinessException {
return new BusinessException('REQ_001', {
context: {
reason,
scope,
...(scope === 'ip'
? { ip: clientIpKey(source.ip) }
: { email: maskEmail(source.email ?? '') }),
},
});
}
}
/** 키에 주소·IP 원문을 남기지 않는다 (Redis 로 옮겨도 목록이 드러나지 않게) */
function digest(subject: string): string {
return createHash('sha256').update(subject).digest('base64url');
}
@@ -0,0 +1,163 @@
import { ERROR_CODES } from '@/common/errors/error-codes';
import {
MAX_ATTACHMENT_LIST_LENGTH,
MAX_ATTACHMENTS_PER_MESSAGE,
allowedAttachmentExtensions,
isAllowedAttachment,
normalizeMimeType,
resolveAttachmentType,
resolveAttachmentsTotalLimit,
sanitizeFileName,
} from './attachment.policy';
const MB = 1024 * 1024;
describe('첨부 개수 한도', () => {
it('레지스트리의 MAIL_006 문구가 말하는 개수는 한도 상수와 같다 — 문구와 실제 거절 기준이 어긋나지 않게', () => {
expect(ERROR_CODES.MAIL_006.message).toContain(
`${MAX_ATTACHMENTS_PER_MESSAGE}개`,
);
});
it('형식 검증(DTO)의 목록 상한은 개수 한도보다 넉넉하다 — 한도를 넘긴 요청이 VAL_001 이 아니라 MAIL_006 을 받게', () => {
expect(MAX_ATTACHMENT_LIST_LENGTH).toBeGreaterThan(
MAX_ATTACHMENTS_PER_MESSAGE,
);
});
});
describe('첨부 합계 한도 (resolveAttachmentsTotalLimit)', () => {
it('설정 한도와 엔진 한도 중 작은 값을 쓴다', () => {
expect(resolveAttachmentsTotalLimit(25 * MB, 50_000_000)).toBe(25 * MB);
expect(resolveAttachmentsTotalLimit(25 * MB, 10 * MB)).toBe(10 * MB);
});
it('엔진이 한도를 알려 주지 않으면 설정 한도만 쓴다', () => {
for (const unknown of [null, undefined, 0, -1, 1.5, Number.NaN]) {
expect(resolveAttachmentsTotalLimit(25 * MB, unknown)).toBe(25 * MB);
}
});
});
describe('메일 첨부 형식 정책', () => {
it('계약의 허용 형식을 모두 받는다 (확장자 + MIME)', () => {
const allowed: Array<[string, string]> = [
['보고서.pdf', 'application/pdf'],
['a.doc', 'application/msword'],
[
'a.docx',
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
],
['a.xls', 'application/vnd.ms-excel'],
[
'체크리스트.xlsx',
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
],
['a.ppt', 'application/vnd.ms-powerpoint'],
[
'a.pptx',
'application/vnd.openxmlformats-officedocument.presentationml.presentation',
],
['기안.hwp', 'application/x-hwp'],
['기안.hwpx', 'application/hwp+zip'],
['메모.txt', 'text/plain'],
['목록.csv', 'text/csv'],
['사진.png', 'image/png'],
['사진.jpg', 'image/jpeg'],
['사진.JPEG', 'image/jpeg'],
['움짤.gif', 'image/gif'],
['사진.webp', 'image/webp'],
['묶음.zip', 'application/zip'],
];
for (const [name, mime] of allowed) {
expect(isAllowedAttachment(name, mime)).toBe(true);
}
});
it('브라우저·OS 가 보내는 다른 MIME 표기도 받는다', () => {
expect(
isAllowedAttachment('묶음.zip', 'application/x-zip-compressed'),
).toBe(true);
expect(isAllowedAttachment('목록.csv', 'application/vnd.ms-excel')).toBe(
true,
);
expect(isAllowedAttachment('기안.hwp', 'application/haansofthwp')).toBe(
true,
);
// 한컴오피스가 없는 PC 는 octet-stream 으로 보낸다
expect(isAllowedAttachment('기안.hwp', 'application/octet-stream')).toBe(
true,
);
expect(isAllowedAttachment('메모.txt', 'text/plain; charset=utf-8')).toBe(
true,
);
});
it('브라우저가 그릴 수 있는 형식은 octet-stream 으로 위장하면 거부한다', () => {
for (const name of ['사진.png', '보고서.pdf', '메모.txt', '사진.jpg']) {
expect(isAllowedAttachment(name, 'application/octet-stream')).toBe(false);
}
});
it('확장자와 MIME 이 어긋나면 거부한다 (위장 방지)', () => {
expect(isAllowedAttachment('payload.png', 'text/html')).toBe(false);
expect(isAllowedAttachment('payload.html', 'image/png')).toBe(false);
expect(isAllowedAttachment('noext', 'application/pdf')).toBe(false);
});
it('실행·렌더링 가능한 형식은 허용 목록에 없다', () => {
const extensions = allowedAttachmentExtensions();
for (const dangerous of [
'.html',
'.htm',
'.svg',
'.js',
'.exe',
'.sh',
'.bat',
]) {
expect(extensions).not.toContain(dangerous);
}
expect(isAllowedAttachment('icon.svg', 'image/svg+xml')).toBe(false);
});
it('발송 형식은 확장자로 정한다 — 허용 목록 밖이면 null', () => {
expect(resolveAttachmentType('체크리스트.XLSX')).toBe(
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
);
expect(resolveAttachmentType('사진.jpeg')).toBe('image/jpeg');
expect(resolveAttachmentType('virus.exe')).toBeNull();
expect(resolveAttachmentType('README')).toBeNull();
});
it('MIME 을 소문자·매개변수 없이 맞춘다', () => {
expect(normalizeMimeType(' Text/Plain; charset=UTF-8 ')).toBe('text/plain');
});
});
describe('sanitizeFileName', () => {
it('경로를 버리고 파일명만 남긴다 (경로 탈출 방지)', () => {
expect(sanitizeFileName('../../etc/passwd')).toBe('passwd');
expect(sanitizeFileName('C:\\Users\\kim\\보고서.pdf')).toBe('보고서.pdf');
});
it('제어 문자와 방향 제어 문자를 지운다 (확장자 뒤집기 위장 방지)', () => {
expect(sanitizeFileName('a\r\nb.txt')).toBe('ab.txt');
expect(sanitizeFileName('invoice\u202Etxt.exe')).toBe('invoicetxt.exe');
});
it('너무 긴 이름은 확장자를 남기고 자른다', () => {
const long = `${'가'.repeat(300)}.xlsx`;
const result = sanitizeFileName(long);
expect(Array.from(result)).toHaveLength(255);
expect(result.endsWith('.xlsx')).toBe(true);
});
it('남는 것이 없으면 기본 이름을 쓴다', () => {
expect(sanitizeFileName('')).toBe('첨부파일');
expect(sanitizeFileName('dir/')).toBe('첨부파일');
});
});
@@ -0,0 +1,251 @@
import { extname } from 'path';
import { MAX_FILE_NAME_LENGTH } from './mail.constants';
/**
* 메일 첨부 형식·크기 정책 (순수 함수) — `modules/upload/upload.config.ts` 와 같은 원칙을 따른다.
*
* - **허용 목록**만 받는다. 확장자와 MIME 을 **둘 다** 검사한다 (.claude/rules/security.md "파일 업로드")
* - 저장·발송에 쓰는 형식은 클라이언트가 보낸 MIME 이 아니라 **확장자로 정한 정식 형식**이다
* - 브라우저가 실행할 수 있는 형식(.html·.svg·.js 등)은 넣지 않는다
*/
const OCTET_STREAM = 'application/octet-stream';
/**
* 메일 한 통에 붙일 수 있는 첨부 개수 — 넘으면 400 MAIL_006 (계약 2절. 화면은 첨부를 추가할 때 미리 막는다).
* 업로드한 파일과 전달하는 원본 첨부를 함께 센다. **개수 한도는 이 상수 하나**다 —
* 발송 검사(mail.service.ts)·API 문서가 이 값을 쓰고, 레지스트리의 MAIL_006 문구는 테스트가 이 값과 맞춰 본다.
*/
export const MAX_ATTACHMENTS_PER_MESSAGE = 20;
/**
* 발송 본문 검증(DTO)이 받는 첨부 목록 길이 상한 — 한도가 아니라 **남용 방지용**이다 (넘으면 400 VAL_001).
*
* 100 으로 둔 근거:
* - 한도(20)보다 넉넉해야 한다. 파일을 한꺼번에 많이 고른 경우(21~100개)는 검증을 통과시켜 MAIL_006 으로 답한다 —
* VAL_001 문구("입력하신 정보를 다시 확인해 주세요")로는 무엇을 고칠지 알 수 없다.
* - 끝은 있어야 한다. 화면으로는 만들 수 없는 길이는 형식 남용으로 보고 서비스·메일 엔진까지 보내지 않는다.
* (본문 해석·검증 비용 자체는 JSON 본문 한도 1MB 가 묶는다 — app.setup.ts 의 JSON_BODY_LIMIT)
* - 같은 DTO 의 다른 목록(받는 사람·참조·숨은 참조) 상한도 100 이다 (mail.constants.ts 의 MAX_RECIPIENTS).
*/
export const MAX_ATTACHMENT_LIST_LENGTH = 100;
interface AttachmentTypeRule {
/** 서버가 저장·발송에 쓰는 정식 형식 */
readonly type: string;
/** 업로드 때 받아들이는 MIME */
readonly accept: readonly string[];
}
/**
* 확장자 → 형식.
*
* 문서·압축 형식(브라우저가 직접 그리지 않는 형식)은 `application/octet-stream` 도 받는다.
* 브라우저는 OS 에 등록되지 않은 형식(한컴오피스가 없는 PC 의 .hwp 등)을 octet-stream 으로 보내기 때문이다.
* 이미지·PDF·텍스트처럼 브라우저가 그릴 수 있는 형식은 정확한 MIME 만 받는다.
* 어느 쪽이든 내려줄 때는 항상 octet-stream + attachment 이므로 내용이 실행되지 않는다.
*/
const ATTACHMENT_TYPES: ReadonlyMap<string, AttachmentTypeRule> = new Map([
['.pdf', { type: 'application/pdf', accept: ['application/pdf'] }],
[
'.doc',
{
type: 'application/msword',
accept: ['application/msword', OCTET_STREAM],
},
],
[
'.docx',
{
type: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
accept: [
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
OCTET_STREAM,
],
},
],
[
'.xls',
{
type: 'application/vnd.ms-excel',
accept: ['application/vnd.ms-excel', OCTET_STREAM],
},
],
[
'.xlsx',
{
type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
accept: [
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
OCTET_STREAM,
],
},
],
[
'.ppt',
{
type: 'application/vnd.ms-powerpoint',
accept: ['application/vnd.ms-powerpoint', OCTET_STREAM],
},
],
[
'.pptx',
{
type: 'application/vnd.openxmlformats-officedocument.presentationml.presentation',
accept: [
'application/vnd.openxmlformats-officedocument.presentationml.presentation',
OCTET_STREAM,
],
},
],
[
'.hwp',
{
type: 'application/x-hwp',
accept: [
'application/x-hwp',
'application/haansofthwp',
'application/vnd.hancom.hwp',
OCTET_STREAM,
],
},
],
[
'.hwpx',
{
type: 'application/hwp+zip',
accept: [
'application/hwp+zip',
'application/haansofthwpx',
'application/vnd.hancom.hwpx',
'application/x-hwpx',
OCTET_STREAM,
],
},
],
['.txt', { type: 'text/plain', accept: ['text/plain'] }],
[
'.csv',
{
type: 'text/csv',
// 엑셀이 설치된 Windows 는 .csv 를 application/vnd.ms-excel 로 보낸다
accept: [
'text/csv',
'application/csv',
'text/x-csv',
'application/vnd.ms-excel',
'text/plain',
],
},
],
['.png', { type: 'image/png', accept: ['image/png'] }],
['.jpg', { type: 'image/jpeg', accept: ['image/jpeg', 'image/pjpeg'] }],
['.jpeg', { type: 'image/jpeg', accept: ['image/jpeg', 'image/pjpeg'] }],
['.gif', { type: 'image/gif', accept: ['image/gif'] }],
['.webp', { type: 'image/webp', accept: ['image/webp'] }],
[
'.zip',
{
type: 'application/zip',
// Windows 의 Chrome 은 application/x-zip-compressed 로 보낸다
accept: [
'application/zip',
'application/x-zip-compressed',
'application/x-zip',
'multipart/x-zip',
OCTET_STREAM,
],
},
],
]);
/** 정리 후 이름이 비면 쓰는 이름 */
const FALLBACK_FILE_NAME = '첨부파일';
/** 'Text/Plain; charset=UTF-8' → 'text/plain' */
export function normalizeMimeType(mimeType: string): string {
return mimeType.split(';')[0].trim().toLowerCase();
}
function extensionOf(fileName: string): string {
return extname(fileName).toLowerCase();
}
/** 업로드 허용 여부 — 확장자가 허용 목록에 있고, MIME 이 그 확장자에 맞아야 통과한다 */
export function isAllowedAttachment(
fileName: string,
mimeType: string,
): boolean {
const rule = ATTACHMENT_TYPES.get(extensionOf(fileName));
return (
rule !== undefined && rule.accept.includes(normalizeMimeType(mimeType))
);
}
/** 파일명 확장자로 정식 형식을 정한다. 허용 목록에 없으면 null */
export function resolveAttachmentType(fileName: string): string | null {
return ATTACHMENT_TYPES.get(extensionOf(fileName))?.type ?? null;
}
/** 안내·문서용 허용 확장자 목록 */
export function allowedAttachmentExtensions(): string[] {
return [...ATTACHMENT_TYPES.keys()];
}
/**
* 메일 한 통의 첨부 합계 한도(바이트) — 설정 한도(MAIL_ATTACHMENT_TOTAL_MAX_SIZE_MB)와 메일 엔진 한도 중 작은 값.
*
* **이 함수 하나만** 한도를 정한다. 발송 검사(어댑터의 FILE_002)와 `GET /api/mail-quota` 의
* `attachmentTotalMaxBytes` 가 같은 값을 써야 화면이 미리 막는 값과 서버가 거절하는 값이 어긋나지 않는다.
* 엔진이 한도를 알려 주지 않으면(null·0 이하·정수가 아님) 설정 한도만 쓴다.
*/
export function resolveAttachmentsTotalLimit(
configuredBytes: number,
engineBytes: number | null | undefined,
): number {
const engineKnown =
typeof engineBytes === 'number' &&
Number.isSafeInteger(engineBytes) &&
engineBytes > 0;
return engineKnown ? Math.min(configuredBytes, engineBytes) : configuredBytes;
}
/**
* 화면 표시·헤더용으로 파일명을 정리한다.
* - 경로 구분자 앞부분을 버린다 (클라이언트가 보낸 경로를 쓰지 않는다)
* - 제어 문자와 방향 제어 문자(확장자를 거꾸로 보이게 하는 U+202E 등)를 지운다
* - 길이를 제한하되 확장자는 남긴다
*/
export function sanitizeFileName(rawName: string): string {
const lastSegment = rawName.split(/[\\/]/).pop() ?? '';
const cleaned = Array.from(lastSegment)
.filter((char) => !isControlOrBidiChar(char.codePointAt(0) ?? 0))
.join('')
.trim();
const chars = Array.from(cleaned);
if (chars.length === 0) return FALLBACK_FILE_NAME;
if (chars.length <= MAX_FILE_NAME_LENGTH) return cleaned;
const extensionChars = Array.from(extname(cleaned));
if (extensionChars.length >= MAX_FILE_NAME_LENGTH) {
return chars.slice(0, MAX_FILE_NAME_LENGTH).join('');
}
return (
chars.slice(0, MAX_FILE_NAME_LENGTH - extensionChars.length).join('') +
extensionChars.join('')
);
}
/** C0·C1 제어 문자, DEL, 방향 제어 문자 */
function isControlOrBidiChar(codePoint: number): boolean {
return (
codePoint <= 0x1f ||
(codePoint >= 0x7f && codePoint <= 0x9f) ||
codePoint === 0x200e ||
codePoint === 0x200f ||
(codePoint >= 0x202a && codePoint <= 0x202e) ||
(codePoint >= 0x2066 && codePoint <= 0x2069)
);
}
@@ -0,0 +1,273 @@
import { ApiProperty } from '@nestjs/swagger';
import { PageInfoDto } from '@/common/dto/pagination.dto';
import type {
MailAddress,
MailAttachment,
Mailbox,
MailboxRole,
MailQuota,
MessageDetail,
MessageSummary,
} from '@/modules/mail/mail.types';
/**
* 메일 응답 DTO — Swagger 문서용. 실제 값은 도메인 타입(mail.types.ts)과 같은 모양이다.
* 계약: docs/mail/api-contract.md 2절
*/
const MAILBOX_ROLES: MailboxRole[] = [
'inbox',
'drafts',
'sent',
'junk',
'trash',
'archive',
];
export class MailAddressDto implements MailAddress {
@ApiProperty({
description: '표시 이름 (없으면 null)',
example: '이도윤',
nullable: true,
type: String,
})
name!: string | null;
@ApiProperty({
description: '메일 주소',
example: 'doyun.lee@arasystem.it.kr',
})
email!: string;
}
export class MailAttachmentDto implements MailAttachment {
@ApiProperty({
description: '첨부 id — 다운로드 경로와 발송 본문(전달)에 그대로 쓴다',
example: 'cd7vmbo0jtypewx7k712frdppgb19udb9pv7shnr2y0ohthwy0afkayaae',
})
id!: string;
@ApiProperty({ description: '파일명', example: '체크리스트.xlsx' })
name!: string;
@ApiProperty({
description: '형식 (표시용 — 다운로드는 항상 application/octet-stream)',
example:
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
})
type!: string;
@ApiProperty({ description: '크기 (바이트)', example: 20480 })
size!: number;
}
export class MailboxDto implements Mailbox {
@ApiProperty({ description: '메일함 id', example: 'a' })
id!: string;
@ApiProperty({
description: '역할이 있으면 한국어 표준 이름, 없으면 서버의 이름',
example: '받은편지함',
})
name!: string;
@ApiProperty({
description: '역할',
enum: MAILBOX_ROLES,
nullable: true,
example: 'inbox',
})
role!: MailboxRole | null;
@ApiProperty({
description: '상위 메일함 id',
nullable: true,
type: String,
example: null,
})
parentId!: string | null;
@ApiProperty({ description: '서버의 정렬 값', example: 0 })
sortOrder!: number;
@ApiProperty({ description: '메일 수', example: 143 })
totalCount!: number;
@ApiProperty({ description: '읽지 않은 메일 수', example: 3 })
unreadCount!: number;
}
export class MailboxPageDto {
@ApiProperty({
type: [MailboxDto],
description:
'받은편지함 → 보낸편지함 → 임시보관함 → 스팸메일함 → 휴지통 → 보관함 → 그 외(sortOrder, 이름 순)',
})
items!: MailboxDto[];
@ApiProperty({
type: PageInfoDto,
description: '한 번에 모두 내려준다 (hasNext=false)',
})
pageInfo!: PageInfoDto;
}
export class MailQuotaDto implements MailQuota {
@ApiProperty({ description: '사용량 (바이트)', example: 966367641 })
usedBytes!: number;
@ApiProperty({
description: '한도 (바이트). 한도가 없으면 null',
nullable: true,
type: Number,
example: 10737418240,
})
limitBytes!: number | null;
@ApiProperty({
description:
'첨부 1개 최대 크기 (바이트) — 서버의 MAIL_ATTACHMENT_MAX_SIZE_MB',
example: 20971520,
})
attachmentMaxBytes!: number;
@ApiProperty({
description:
'메일 한 통의 첨부 합계 최대 크기 (바이트) — MAIL_ATTACHMENT_TOTAL_MAX_SIZE_MB 와 메일 엔진 한도 중 작은 값. ' +
'발송 때 검사하는 값과 같다 (넘으면 413 FILE_002). 전달하는 원본 첨부도 세고, 같은 첨부를 두 번 붙이면 두 번 센다',
example: 26214400,
})
attachmentTotalMaxBytes!: number;
}
export class MessageSummaryDto implements MessageSummary {
@ApiProperty({ description: '메일 id', example: 'eaaaaab' })
id!: string;
@ApiProperty({ description: '스레드 id', example: 'b' })
threadId!: string;
@ApiProperty({
description: '속한 메일함 id 목록',
type: [String],
example: ['a'],
})
mailboxIds!: string[];
@ApiProperty({
description: '보낸 사람',
type: MailAddressDto,
nullable: true,
})
from!: MailAddressDto | null;
@ApiProperty({ description: '받는 사람', type: [MailAddressDto] })
to!: MailAddressDto[];
@ApiProperty({
description: '제목 (없으면 빈 문자열)',
example: '메일 서버 이전 일정 공유',
})
subject!: string;
@ApiProperty({ description: '본문 미리보기', example: '도윤님, 다음 주…' })
preview!: string;
@ApiProperty({
description: '받은 시각 (ISO 8601 UTC)',
example: '2026-10-26T05:12:00.000Z',
})
receivedAt!: string;
@ApiProperty({ description: '읽음 여부', example: false })
isRead!: boolean;
@ApiProperty({ description: '중요 표시 여부', example: false })
isFlagged!: boolean;
@ApiProperty({ description: '첨부 여부', example: true })
hasAttachment!: boolean;
@ApiProperty({ description: '크기 (바이트)', example: 20480 })
size!: number;
}
export class MessageDetailDto
extends MessageSummaryDto
implements MessageDetail
{
@ApiProperty({ description: '참조', type: [MailAddressDto] })
cc!: MailAddressDto[];
@ApiProperty({
description: '숨은 참조 (보낸 메일에서만 보인다)',
type: [MailAddressDto],
})
bcc!: MailAddressDto[];
@ApiProperty({ description: '회신 주소', type: [MailAddressDto] })
replyTo!: MailAddressDto[];
@ApiProperty({
description: '보낸 시각 (ISO 8601 UTC)',
nullable: true,
type: String,
example: '2026-10-26T05:11:58.000Z',
})
sentAt!: string | null;
@ApiProperty({
description: 'Message-ID 헤더 값 (꺾쇠 괄호 없이)',
nullable: true,
type: String,
example: '20261026051158.abc@arasystem.it.kr',
})
messageId!: string | null;
@ApiProperty({ description: 'References 헤더', type: [String] })
references!: string[];
@ApiProperty({
description:
'서버가 정화한 HTML — 스크립트·스타일 태그·이벤트 핸들러·폼·iframe·object·embed·svg 제거, 외부 이미지 제거. 클라이언트는 격리된 iframe 에만 그린다',
nullable: true,
type: String,
})
htmlBody!: string | null;
@ApiProperty({
description: '일반 텍스트 본문',
nullable: true,
type: String,
})
textBody!: string | null;
@ApiProperty({
description: '외부 이미지를 지웠으면 true → "외부 이미지 차단" 안내',
example: false,
})
hasBlockedImages!: boolean;
@ApiProperty({
description: '첨부 (본문 내장 이미지 포함)',
type: [MailAttachmentDto],
})
attachments!: MailAttachmentDto[];
}
export class MessagePageDto {
@ApiProperty({
type: [MessageSummaryDto],
description: '받은 시각 내림차순',
})
items!: MessageSummaryDto[];
@ApiProperty({ type: PageInfoDto, description: 'total 포함' })
pageInfo!: PageInfoDto;
}
export class SentMessageDto {
@ApiProperty({ description: '보낸편지함에 남은 메일 id', example: 'maaaaad' })
id!: string;
}
@@ -0,0 +1,26 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { IsOptional, IsString, Matches, MaxLength } from 'class-validator';
import { CursorPaginationQueryDto } from '@/common/dto/pagination.dto';
import {
MAIL_ID_PATTERN,
MAX_SEARCH_LENGTH,
} from '@/modules/mail/mail.constants';
/** GET /api/messages — 커서 페이지네이션 + 메일함 + 검색어 */
export class MessageListQueryDto extends CursorPaginationQueryDto {
@ApiProperty({ description: '메일함 id (필수)', example: 'a' })
@Matches(MAIL_ID_PATTERN, { message: '메일함을 다시 선택해 주세요.' })
mailboxId!: string;
@ApiPropertyOptional({
description: `검색어 (보낸 사람·받는 사람·제목·본문, ${MAX_SEARCH_LENGTH}자 이하)`,
example: '서버 이전',
})
@IsOptional()
@IsString({ message: '검색어 형식이 올바르지 않습니다.' })
@MaxLength(MAX_SEARCH_LENGTH, {
message: `검색어는 ${MAX_SEARCH_LENGTH}자 이하로 입력해 주세요.`,
})
q?: string;
}
@@ -0,0 +1,46 @@
import { plainToInstance } from 'class-transformer';
import { validate } from 'class-validator';
import {
MAX_ATTACHMENT_LIST_LENGTH,
MAX_ATTACHMENTS_PER_MESSAGE,
} from '@/modules/mail/attachment.policy';
import { SendMessageDto } from './send-message.dto';
/** 전역 ValidationPipe 와 같은 옵션으로 검증한다 (app.setup.ts) */
async function constraintsOf(body: object): Promise<string[]> {
const dto = plainToInstance(SendMessageDto, body);
const errors = await validate(dto, {
whitelist: true,
forbidNonWhitelisted: true,
});
return errors.flatMap((error) => Object.keys(error.constraints ?? {}));
}
const withAttachments = (count: number) => ({
to: [{ name: null, email: 'doyun.lee@arasystem.test' }],
subject: '제목',
body: '본문',
attachments: Array.from({ length: count }, (_, i) => ({
id: `blob-${i}`,
name: `파일${i}.pdf`,
})),
});
describe('SendMessageDto 첨부 목록', () => {
it('개수 한도를 넘는 목록도 형식 검증은 통과한다 — 개수 한도(MAIL_006)는 서비스가 답한다', async () => {
for (const count of [
MAX_ATTACHMENTS_PER_MESSAGE,
MAX_ATTACHMENTS_PER_MESSAGE + 1,
MAX_ATTACHMENT_LIST_LENGTH,
]) {
expect(await constraintsOf(withAttachments(count))).toEqual([]);
}
});
it('남용 방지 상한(100개)을 넘는 목록은 형식 검증에서 막는다 (VAL_001)', async () => {
expect(
await constraintsOf(withAttachments(MAX_ATTACHMENT_LIST_LENGTH + 1)),
).toContain('arrayMaxSize');
});
});
@@ -0,0 +1,157 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { Type } from 'class-transformer';
import {
ArrayMaxSize,
ArrayMinSize,
IsArray,
IsEmail,
IsNotEmpty,
IsOptional,
IsString,
Matches,
MaxLength,
ValidateNested,
} from 'class-validator';
import {
MAX_ATTACHMENT_LIST_LENGTH,
MAX_ATTACHMENTS_PER_MESSAGE,
} from '@/modules/mail/attachment.policy';
import {
MAIL_ID_PATTERN,
MAX_BODY_LENGTH,
MAX_DISPLAY_NAME_LENGTH,
MAX_EMAIL_LENGTH,
MAX_FILE_NAME_LENGTH,
MAX_RECIPIENTS,
MAX_SUBJECT_LENGTH,
} from '@/modules/mail/mail.constants';
export class RecipientDto {
@ApiPropertyOptional({
description: '표시 이름',
example: '이도윤',
nullable: true,
type: String,
})
@IsOptional()
@IsString({ message: '받는 사람 이름 형식이 올바르지 않습니다.' })
@MaxLength(MAX_DISPLAY_NAME_LENGTH, {
message: `받는 사람 이름은 ${MAX_DISPLAY_NAME_LENGTH}자 이하로 입력해 주세요.`,
})
name?: string | null;
@ApiProperty({
description: '메일 주소',
example: 'doyun.lee@arasystem.it.kr',
})
@IsEmail({}, { message: '받는 사람 메일 주소 형식이 올바르지 않습니다.' })
@MaxLength(MAX_EMAIL_LENGTH, {
message: '받는 사람 메일 주소가 너무 깁니다.',
})
email!: string;
}
export class OutgoingAttachmentDto {
@ApiProperty({
description:
'첨부 id — 업로드 응답의 id, 또는 전달할 원본 메일의 attachments[].id (같은 계정의 파일)',
example: 'cd7vmbo0jtypewx7k712frdppgb19udb9pv7shnr2y0ohthwy0afkayaae',
})
@Matches(MAIL_ID_PATTERN, { message: '첨부파일을 다시 선택해 주세요.' })
id!: string;
@ApiProperty({
description:
'파일명 — 확장자로 형식을 정한다 (업로드와 같은 허용 목록, 밖이면 FILE_001)',
example: '체크리스트.xlsx',
})
@IsString({ message: '첨부파일 이름 형식이 올바르지 않습니다.' })
@IsNotEmpty({ message: '첨부파일 이름이 비어 있습니다.' })
@MaxLength(MAX_FILE_NAME_LENGTH, {
message: `첨부파일 이름은 ${MAX_FILE_NAME_LENGTH}자 이하여야 합니다.`,
})
name!: string;
}
/** POST /api/messages — 계약 2절 "발송 본문" */
export class SendMessageDto {
@ApiProperty({
description: '받는 사람 (1명 이상)',
type: [RecipientDto],
})
@IsArray({ message: '받는 사람 형식이 올바르지 않습니다.' })
@ArrayMinSize(1, { message: '받는 사람을 1명 이상 입력해 주세요.' })
@ArrayMaxSize(MAX_RECIPIENTS, {
message: `받는 사람은 ${MAX_RECIPIENTS}명 이하여야 합니다.`,
})
@ValidateNested({ each: true })
@Type(() => RecipientDto)
to!: RecipientDto[];
@ApiPropertyOptional({ description: '참조', type: [RecipientDto] })
@IsOptional()
@IsArray({ message: '참조 형식이 올바르지 않습니다.' })
@ArrayMaxSize(MAX_RECIPIENTS, {
message: `참조는 ${MAX_RECIPIENTS}명 이하여야 합니다.`,
})
@ValidateNested({ each: true })
@Type(() => RecipientDto)
cc?: RecipientDto[];
@ApiPropertyOptional({
description: `숨은 참조 (받는 사람·참조·숨은 참조 합계 ${MAX_RECIPIENTS}명 이하)`,
type: [RecipientDto],
})
@IsOptional()
@IsArray({ message: '숨은 참조 형식이 올바르지 않습니다.' })
@ArrayMaxSize(MAX_RECIPIENTS, {
message: `숨은 참조는 ${MAX_RECIPIENTS}명 이하여야 합니다.`,
})
@ValidateNested({ each: true })
@Type(() => RecipientDto)
bcc?: RecipientDto[];
@ApiProperty({
description: `제목 (${MAX_SUBJECT_LENGTH}자 이하, 빈 문자열 가능)`,
example: 'Re: 메일 서버 이전 일정 공유',
})
@IsString({ message: '제목 형식이 올바르지 않습니다.' })
@MaxLength(MAX_SUBJECT_LENGTH, {
message: `제목은 ${MAX_SUBJECT_LENGTH}자 이하로 입력해 주세요.`,
})
subject!: string;
@ApiProperty({
description: `일반 텍스트 본문 (${MAX_BODY_LENGTH.toLocaleString('ko-KR')}자 이하, 빈 문자열 가능)`,
example: '도윤님, 확인했습니다.',
})
@IsString({ message: '본문 형식이 올바르지 않습니다.' })
@MaxLength(MAX_BODY_LENGTH, {
message: `본문은 ${MAX_BODY_LENGTH.toLocaleString('ko-KR')}자 이하로 입력해 주세요.`,
})
body!: string;
@ApiPropertyOptional({
description: `첨부 (${MAX_ATTACHMENTS_PER_MESSAGE}개 이하, 넘으면 400 MAIL_006) — 업로드한 파일 또는 전달할 원본 첨부`,
type: [OutgoingAttachmentDto],
})
@IsOptional()
@IsArray({ message: '첨부 형식이 올바르지 않습니다.' })
// 남용 방지용 상한일 뿐이다 — 개수 한도(MAIL_006)는 서비스가 검사한다 (근거는 MAX_ATTACHMENT_LIST_LENGTH)
@ArrayMaxSize(MAX_ATTACHMENT_LIST_LENGTH, {
message: `첨부 목록은 ${MAX_ATTACHMENT_LIST_LENGTH}개를 넘을 수 없습니다.`,
})
@ValidateNested({ each: true })
@Type(() => OutgoingAttachmentDto)
attachments?: OutgoingAttachmentDto[];
@ApiPropertyOptional({
description:
'답장할 메일 id — 스레드 헤더(In-Reply-To·References)를 서버가 채운다. 전달에는 보내지 않는다',
example: 'eaaaaab',
})
@IsOptional()
@Matches(MAIL_ID_PATTERN, { message: '답장할 메일을 다시 선택해 주세요.' })
inReplyTo?: string;
}
@@ -0,0 +1,28 @@
import { ApiPropertyOptional } from '@nestjs/swagger';
import { IsBoolean, Matches, ValidateIf } from 'class-validator';
import { MAIL_ID_PATTERN } from '@/modules/mail/mail.constants';
/** 값이 왔을 때만 검사한다 — null 은 "보내지 않음"이 아니라 형식 오류로 막는다 */
const isPresent = (_object: object, value: unknown) => value !== undefined;
/** PATCH /api/messages/{id} — 하나 이상 지정한다 (빈 요청은 VAL_001) */
export class UpdateMessageDto {
@ApiPropertyOptional({ description: '읽음 여부', example: true })
@ValidateIf(isPresent)
@IsBoolean({ message: '읽음 여부는 true 또는 false 여야 합니다.' })
isRead?: boolean;
@ApiPropertyOptional({ description: '중요 표시 여부', example: true })
@ValidateIf(isPresent)
@IsBoolean({ message: '중요 표시 여부는 true 또는 false 여야 합니다.' })
isFlagged?: boolean;
@ApiPropertyOptional({
description: '옮길 메일함 id — 지정하면 이 메일함 하나에만 속하게 된다',
example: 'b',
})
@ValidateIf(isPresent)
@Matches(MAIL_ID_PATTERN, { message: '메일함을 다시 선택해 주세요.' })
mailboxId?: string;
}
@@ -0,0 +1,80 @@
import type { InlinePart } from '@/modules/mail/mail.types';
import {
INLINE_IMAGES_MAX_COUNT,
INLINE_IMAGE_MAX_BYTES,
findReferencedContentIds,
normalizeContentId,
selectInlineImages,
toDataUri,
} from './inline-images';
const part = (cid: string, type = 'image/png', size = 1000): InlinePart => ({
attachmentId: `blob-${cid}`,
cid,
type,
size,
});
describe('본문 내장 이미지 정책', () => {
it('Content-ID 를 꺾쇠·퍼센트 인코딩·대소문자와 무관하게 맞춘다', () => {
expect(normalizeContentId('<Logo@AraSystem.it.kr>')).toBe(
'logo@arasystem.it.kr',
);
expect(normalizeContentId('logo%40arasystem.it.kr')).toBe(
'logo@arasystem.it.kr',
);
// 잘못된 퍼센트 인코딩이어도 실패하지 않는다
expect(normalizeContentId('bad%zz')).toBe('bad%zz');
});
it('본문이 참조하는 cid 만 찾는다', () => {
const ids = findReferencedContentIds(
`<img src="cid:a@x"><img src='CID:B@X'><div style="background:url(cid:c@x)"></div>`,
);
expect([...ids].sort()).toEqual(['a@x', 'b@x', 'c@x']);
});
it('형식·크기·참조 여부를 모두 만족하는 것만 고른다', () => {
const referenced = new Set(['ok@x', 'svg@x', 'big@x', 'pdf@x']);
const selected = selectInlineImages(
[
part('ok@x'),
part('svg@x', 'image/svg+xml'),
part('big@x', 'image/png', INLINE_IMAGE_MAX_BYTES + 1),
part('pdf@x', 'application/pdf'),
part('unreferenced@x'),
],
referenced,
);
expect(selected.map((p) => p.cid)).toEqual(['ok@x']);
});
it('합계 한도와 개수 상한을 지킨다', () => {
const many = Array.from({ length: 30 }, (_, i) =>
part(`${i}@x`, 'image/gif', 100),
);
const referenced = new Set(many.map((p) => p.cid));
expect(selectInlineImages(many, referenced)).toHaveLength(
INLINE_IMAGES_MAX_COUNT,
);
const heavy = Array.from({ length: 10 }, (_, i) =>
part(`${i}@x`, 'image/jpeg', INLINE_IMAGE_MAX_BYTES),
);
// 300KB × 6 = 1.8MB 까지만 들어간다 (합계 2MB)
expect(
selectInlineImages(heavy, new Set(heavy.map((p) => p.cid))),
).toHaveLength(6);
});
it('data: URI 는 허용 목록의 정식 형식으로만 만든다', () => {
expect(toDataUri('IMAGE/JPG', Buffer.from([1, 2, 3]))).toBe(
'data:image/jpeg;base64,AQID',
);
expect(toDataUri('image/svg+xml', Buffer.from('<svg/>'))).toBeNull();
});
});
@@ -0,0 +1,96 @@
import type { InlinePart } from '@/modules/mail/mail.types';
/**
* 본문 내장 이미지(`<img src="cid:…">`) 정책 (순수 함수) — 계약 2절.
*
* 작은 이미지만 data: URI 로 바꿔 본문에 넣고, 나머지는 정화 단계에서 지운다(첨부 목록에는 남는다).
* SVG 는 스크립트를 품을 수 있으므로 넣지 않는다.
*/
/** 이미지 1개 최대 크기 */
export const INLINE_IMAGE_MAX_BYTES = 300 * 1024;
/** 메일 1통의 내장 이미지 합계 최대 크기 */
export const INLINE_IMAGES_TOTAL_MAX_BYTES = 2 * 1024 * 1024;
/** 메일 1통에서 내려받아 넣을 내장 이미지 개수 상한 */
export const INLINE_IMAGES_MAX_COUNT = 20;
/** 넣을 수 있는 형식 → data: URI 에 쓰는 정식 형식 */
const INLINE_IMAGE_TYPES: ReadonlyMap<string, string> = new Map([
['image/png', 'image/png'],
['image/jpeg', 'image/jpeg'],
['image/jpg', 'image/jpeg'],
['image/pjpeg', 'image/jpeg'],
['image/gif', 'image/gif'],
['image/webp', 'image/webp'],
]);
/** 본문 HTML 의 cid 참조 — 따옴표·공백·괄호 앞까지 */
const CID_REFERENCE = /cid:([^"'\s>)]+)/gi;
/**
* Content-ID 를 비교 가능한 형태로 맞춘다.
* HTML 의 `cid:` URL 은 퍼센트 인코딩될 수 있고(RFC 2392), 헤더 값에는 꺾쇠 괄호가 붙기도 한다.
*/
export function normalizeContentId(value: string): string {
let id = value.trim();
try {
id = decodeURIComponent(id);
} catch {
// 잘못된 퍼센트 인코딩 — 원문 그대로 비교한다
}
return id.replace(/^</, '').replace(/>$/, '').trim().toLowerCase();
}
/** 본문이 실제로 참조하는 Content-ID — 참조하지 않는 부분은 내려받지 않는다 */
export function findReferencedContentIds(html: string): Set<string> {
const ids = new Set<string>();
for (const match of html.matchAll(CID_REFERENCE)) {
ids.add(normalizeContentId(match[1]));
}
return ids;
}
/** 넣을 수 있는 이미지 형식이면 정식 형식, 아니면 null */
export function inlineImageType(type: string): string | null {
return INLINE_IMAGE_TYPES.get(type.trim().toLowerCase()) ?? null;
}
/**
* 내려받아 넣을 후보를 고른다 — 허용 형식, 개당 한도, 본문 참조 여부, 합계 한도, 개수 상한.
* (엔진이 알려 준 크기 기준이다. 실제로 읽을 때도 한도를 다시 지킨다)
*/
export function selectInlineImages(
parts: readonly InlinePart[],
referenced: ReadonlySet<string>,
): InlinePart[] {
const selected: InlinePart[] = [];
const seen = new Set<string>();
let total = 0;
for (const part of parts) {
const cid = normalizeContentId(part.cid);
const eligible =
inlineImageType(part.type) !== null &&
part.size > 0 &&
part.size <= INLINE_IMAGE_MAX_BYTES &&
referenced.has(cid) &&
!seen.has(cid) &&
total + part.size <= INLINE_IMAGES_TOTAL_MAX_BYTES;
if (!eligible) continue;
seen.add(cid);
total += part.size;
selected.push(part);
if (selected.length >= INLINE_IMAGES_MAX_COUNT) break;
}
return selected;
}
/** 이미지 바이트 → data: URI (형식은 허용 목록의 정식 형식만 쓴다) */
export function toDataUri(type: string, data: Buffer): string | null {
const canonical = inlineImageType(type);
return canonical
? `data:${canonical};base64,${data.toString('base64')}`
: null;
}
@@ -0,0 +1,273 @@
import { sanitizeMailHtml } from './sanitize-mail-html';
const clean = (html: string, inline?: ReadonlyMap<string, string>) =>
sanitizeMailHtml(html, inline).html;
const PNG_DATA_URI = 'data:image/png;base64,iVBORw0KGgo=';
describe('sanitizeMailHtml', () => {
describe('스크립트', () => {
it('script 태그는 내용까지 지운다', () => {
expect(clean('<p>본문</p><script>alert(1)</script>')).toBe('<p>본문</p>');
expect(
clean('<SCRIPT src="https://evil.example/x.js"></SCRIPT>안녕'),
).toBe('안녕');
});
it('이벤트 핸들러 속성을 모두 지운다', () => {
expect(
clean('<p onclick="steal()" onmouseover="x()" style="color:red">a</p>'),
).toBe('<p style="color:red">a</p>');
expect(clean('<body onload="x()"><div>본문</div></body>')).toBe(
'<div>본문</div>',
);
});
it('onerror 를 노리는 이미지는 태그째 사라진다', () => {
expect(clean('<img src=x onerror="alert(1)">글자')).toBe('글자');
});
});
describe('링크', () => {
it('http·https·mailto 링크는 새 창으로 연다', () => {
expect(clean('<a href="https://arasystem.it.kr/notice">공지</a>')).toBe(
'<a href="https://arasystem.it.kr/notice" target="_blank" rel="noopener noreferrer">공지</a>',
);
expect(clean('<a href="mailto:ara.kim@arasystem.it.kr">메일</a>')).toBe(
'<a href="mailto:ara.kim@arasystem.it.kr" target="_blank" rel="noopener noreferrer">메일</a>',
);
});
it('원문의 target·rel 은 버리고 서버 값으로 덮는다 (window.opener 악용 방지)', () => {
expect(
clean('<a href="https://a.example" target="_self" rel="opener">x</a>'),
).toBe(
'<a href="https://a.example" target="_blank" rel="noopener noreferrer">x</a>',
);
});
it('javascript: 등 위험한 스킴은 주소만 지우고 글자는 남긴다', () => {
for (const href of [
'javascript:alert(1)',
'JaVaScRiPt:alert(1)',
'java&#x09;script:alert(1)',
'&#106;avascript:alert(1)',
' javascript:alert(1)',
'vbscript:msgbox(1)',
'data:text/html,<script>alert(1)</script>',
'file:///etc/passwd',
]) {
expect(clean(`<a href="${href}">눌러</a>`)).toBe('<a>눌러</a>');
}
});
it('상대 주소·프로토콜 상대 주소도 링크로 남기지 않는다', () => {
expect(clean('<a href="/admin">x</a>')).toBe('<a>x</a>');
expect(clean('<a href="//evil.example">x</a>')).toBe('<a>x</a>');
});
});
describe('스타일', () => {
it('style 태그는 내용까지 지운다', () => {
expect(
clean(
'<style>body{background:url(https://t.example/p.gif)}</style><p>본문</p>',
),
).toBe('<p>본문</p>');
});
it('인라인 style 의 외부 url() 은 지우고 외부 이미지 차단으로 표시한다', () => {
const result = sanitizeMailHtml(
'<div style="background-image:url(https://t.example/p.gif);color:#333">a</div>',
);
expect(result.html).toBe('<div style="color:#333">a</div>');
expect(result.hasBlockedImages).toBe(true);
});
it('url() 은 따옴표·프로토콜 상대·data: 어떤 형태든 남기지 않는다', () => {
for (const style of [
"background:url('//t.example/p.gif')",
'background: url( "http://t.example/p.gif" )',
'background:url(data:image/png;base64,AAAA)',
'list-style:url(https://t.example/b.png)',
'background:u\\72l(https://t.example/p.gif)',
'background:image-set("https://t.example/p.gif" 1x)',
]) {
expect(clean(`<p style="${style}">a</p>`)).toBe('<p>a</p>');
}
});
it('expression()·behavior 같은 스크립트성 CSS 를 지운다', () => {
expect(clean('<p style="width:expression(alert(1))">a</p>')).toBe(
'<p>a</p>',
);
expect(clean('<p style="behavior:url(x.htc);color:red">a</p>')).toBe(
'<p style="color:red">a</p>',
);
});
it('위치를 바꿔 화면을 덮는 속성은 남기지 않는다', () => {
expect(
clean(
'<div style="position:fixed;top:0;z-index:9999;color:red">a</div>',
),
).toBe('<div style="color:red">a</div>');
});
it('일반적인 메일 서식은 남긴다 (색 함수·한글 글꼴·여백)', () => {
expect(
clean(
`<p style="color:rgb(10, 20, 30);font-family:'맑은 고딕', sans-serif;margin:0 auto;font:12px/1.5 Arial">a</p>`,
),
).toBe(
`<p style="color:rgb(10, 20, 30);font-family:'맑은 고딕', sans-serif;margin:0 auto;font:12px/1.5 Arial">a</p>`,
);
});
});
describe('이미지', () => {
it('외부 이미지는 지우고 hasBlockedImages 를 켠다 (추적 픽셀 차단)', () => {
const result = sanitizeMailHtml(
'<p>안녕</p><img src="https://tracker.example/open.gif" width="1" height="1">',
);
expect(result.html).toBe('<p>안녕</p>');
expect(result.hasBlockedImages).toBe(true);
});
it('프로토콜 상대 주소·srcset 의 외부 이미지도 차단한다', () => {
expect(
sanitizeMailHtml('<img src="//cdn.example/a.png">').hasBlockedImages,
).toBe(true);
expect(
sanitizeMailHtml('<img srcset="https://cdn.example/a.png 2x">')
.hasBlockedImages,
).toBe(true);
});
it('표 배경(background 속성)의 외부 이미지도 지우고 표시한다', () => {
const result = sanitizeMailHtml(
'<table><tr><td background="https://t.example/bg.png">a</td></tr></table>',
);
expect(result.html).toBe('<table><tr><td>a</td></tr></table>');
expect(result.hasBlockedImages).toBe(true);
});
it('cid: 이미지는 준비된 data: URI 로 바꾼다 (대소문자·꺾쇠·퍼센트 인코딩 무관)', () => {
const inline = new Map([['logo@arasystem.it.kr', PNG_DATA_URI]]);
expect(
clean('<img src="cid:logo@arasystem.it.kr" alt="로고">', inline),
).toBe(`<img alt="로고" src="${PNG_DATA_URI}" />`);
expect(clean('<img src="CID:Logo%40AraSystem.it.kr">', inline)).toBe(
`<img src="${PNG_DATA_URI}" />`,
);
});
it('준비되지 않은(큰·형식 불일치) cid: 이미지는 지우되 외부 이미지로 표시하지 않는다', () => {
const result = sanitizeMailHtml('<p>a</p><img src="cid:big@x">');
expect(result.html).toBe('<p>a</p>');
expect(result.hasBlockedImages).toBe(false);
});
it('원문의 data: 이미지는 png·jpeg·gif·webp 만 남긴다 (svg 제외)', () => {
expect(clean(`<img src="${PNG_DATA_URI}">`)).toBe(
`<img src="${PNG_DATA_URI}" />`,
);
expect(
clean('<img src="data:image/svg+xml;base64,PHN2Zz48L3N2Zz4=">'),
).toBe('');
});
it('외부 이미지가 없으면 hasBlockedImages 는 false 다', () => {
expect(sanitizeMailHtml('<p>텍스트만</p>').hasBlockedImages).toBe(false);
});
});
describe('위험한 요소', () => {
it.each([
[
'form 의 입력 요소',
'<form action="https://evil.example"><input name="pw"><button>보내기</button></form>',
],
['iframe', '<iframe src="https://evil.example">대체 글자</iframe>'],
['object', '<object data="x.swf">대체 글자</object>'],
['embed', '<embed src="x.swf">'],
['svg', '<svg><script>alert(1)</script><text>글자</text></svg>'],
['math', '<math><mtext>글자</mtext></math>'],
['video', '<video src="https://evil.example/v.mp4">대체 글자</video>'],
[
'base·meta·link',
'<base href="https://evil.example/"><meta http-equiv="refresh" content="0;url=https://evil.example"><link rel="stylesheet" href="https://evil.example/x.css">',
],
])('%s 은(는) 남지 않는다', (_name, html) => {
const output = clean(`<p>본문</p>${html}`);
expect(output).toBe('<p>본문</p>');
});
it('깊게 중첩한 iframe 도 스택 넘침 없이 통째로 버린다 — GHSA-c9rx-x947-fp3h(2.18.0) 에 닿지 않는 설정', () => {
// sanitize-html 2.18.0 기본 설정은 버린 iframe 의 대체 내용을 다시 정화하다 1만 단계쯤에서 스택이 넘친다(확인함).
// 우리는 iframe 을 내용째 버리므로 그 경로를 타지 않는다 — iframe 을 허용하거나 내용을 남기게 바꾸면 이 테스트가 막는다
const depth = 20_000;
const nested = `${'<iframe>'.repeat(depth)}<p>x</p>${'</iframe>'.repeat(depth)}`;
expect(clean(`<p>본문</p>${nested}`)).toBe('<p>본문</p>');
});
it('sanitize-html 보안 권고 4건의 재현 입력이 통하지 않는다 (svg·textarea·xmp·meta·noscript 비허용 — 버전을 올려도 유지)', () => {
for (const payload of [
'<svg><textarea><img src=x onerror=alert(1)></textarea></svg>',
'<textarea></textarea/><img src=x onerror=alert(1)>',
'<xmp></xmp/><img src=x onerror=alert(1)>',
'<meta http-equiv="refresh" content="0;url=javascript:alert(1)">',
'<svg><animate attributeName="href" values="javascript:alert(1)"/></svg>',
'<noscript><p title="</noscript><img src=x onerror=alert(1)>"></noscript>',
]) {
const output = clean(payload);
expect(output).not.toMatch(
/onerror|javascript:|<img|<svg|<meta|<textarea/i,
);
}
});
it('주석(조건부 주석 포함)을 지운다', () => {
expect(clean('<!--[if mso]><p>아웃룩</p><![endif]--><p>본문</p>')).toBe(
'<p>본문</p>',
);
});
it('허용하지 않은 태그는 태그만 지우고 글자는 남긴다', () => {
expect(clean('<marquee>흐르는 글자</marquee>')).toBe('흐르는 글자');
});
it('id·class·name 속성은 남기지 않는다 (DOM 충돌 방지)', () => {
expect(clean('<div id="app" class="x" name="y">a</div>')).toBe(
'<div>a</div>',
);
});
});
describe('HTML 메일 레이아웃', () => {
it('표·글꼴의 표시용 속성은 남긴다', () => {
expect(
clean(
'<table width="600" cellpadding="0" cellspacing="0" border="0" align="center" bgcolor="#ffffff"><tr><td colspan="2" valign="top"><font color="#333333" face="Arial" size="2">안내</font></td></tr></table>',
),
).toBe(
'<table width="600" cellpadding="0" cellspacing="0" border="0" align="center" bgcolor="#ffffff"><tr><td colspan="2" valign="top"><font color="#333333" face="Arial" size="2">안내</font></td></tr></table>',
);
});
it('html·head·body 껍데기를 벗기고 본문만 남긴다', () => {
expect(
clean(
'<html><head><title>제목</title><meta charset="utf-8"></head><body><p>본문</p></body></html>',
),
).toBe('<p>본문</p>');
});
});
});
@@ -0,0 +1,359 @@
import sanitizeHtml from 'sanitize-html';
import { normalizeContentId } from './inline-images';
/**
* 받은 메일 HTML 정화 (순수 함수) — 계약 2절 "htmlBody 정화 규칙".
*
* 메일 엔진은 HTML 을 정화하지 않는다(`<script>` 가 그대로 온다). 정화는 백엔드 책임이고,
* 클라이언트는 그래도 격리된 iframe(스크립트 비허용 sandbox + 내부 CSP)에만 그린다 — 이중 방어.
*
* - 허용 목록 방식: 아래에 없는 태그·속성은 모두 버린다
* - 스크립트·스타일 태그·폼·iframe·object·embed·svg 는 **내용까지** 버린다
* - 이벤트 핸들러(on*)·id·class 는 허용 속성이 아니므로 버린다
* - 링크는 http·https·mailto 만 남기고 새 창(`target="_blank" rel="noopener noreferrer"`)
* - 외부(http·https·//) 이미지는 지우고 `hasBlockedImages` 를 켠다 (style 의 url() 포함 — 추적 픽셀 차단)
* - `cid:` 이미지는 미리 만든 data: URI 로 바꾸고, 없으면 지운다
*
* sanitize-html 2.18 (런타임 Node 24) — 2.17.5 고정을 풀면서 보안 권고 4건(GHSA-jxwj-j7wr-gfrw·GHSA-g8qq-57p8-ggw5·
* GHSA-cv27-6wvh-8x7j·GHSA-x3q4-9hxx-gx8m)이 해결됐다. 그래도 svg·math·textarea·xmp·option·meta·noscript 는
* 허용 목록에 넣지 않는다 — 우회 기법이 반복해서 나온 태그들이다 (sanitize-mail-html.spec.ts 가 지킨다).
* ⚠️ 2.18 이 쓰는 htmlparser2 12 계열은 ESM 전용이다. Node 24 는 require 로 그대로 읽지만 Jest(CJS)는 못 읽어서,
* jest 설정(package.json·test/jest-e2e.json)의 transform 이 그 6개 패키지만 CJS 로 바꿔 읽는다.
* 패키지를 올리다 "Cannot use import statement outside a module" 가 나면 그 목록에 ESM 전용 패키지를 더한다.
*/
export interface SanitizedMailHtml {
readonly html: string;
/** 외부 이미지를 하나라도 지웠으면 true → 화면에 "외부 이미지 차단" 안내 */
readonly hasBlockedImages: boolean;
}
const ALLOWED_TAGS = [
// 구획
'address',
'article',
'aside',
'footer',
'header',
'main',
'nav',
'section',
'h1',
'h2',
'h3',
'h4',
'h5',
'h6',
// 본문
'blockquote',
'center',
'dd',
'details',
'div',
'dl',
'dt',
'figcaption',
'figure',
'hr',
'li',
'ol',
'p',
'pre',
'summary',
'ul',
// 인라인
'a',
'abbr',
'b',
'bdi',
'bdo',
'big',
'br',
'cite',
'code',
'del',
'dfn',
'em',
'font',
'i',
'img',
'ins',
'kbd',
'mark',
'q',
'rp',
'rt',
'ruby',
's',
'samp',
'small',
'span',
'strike',
'strong',
'sub',
'sup',
'time',
'tt',
'u',
'var',
'wbr',
// 표 (HTML 메일의 레이아웃은 대부분 표다)
'caption',
'col',
'colgroup',
'table',
'tbody',
'td',
'tfoot',
'th',
'thead',
'tr',
];
/**
* 태그를 버릴 때 **내용까지** 버리는 태그.
* 여기에 없는 비허용 태그는 태그만 버리고 안의 글자는 (정화해서) 남긴다.
* sanitize-html 기본값(script·style·textarea·option·xmp)을 모두 포함해야 한다.
*/
const NON_TEXT_TAGS = [
'script',
'style',
'textarea',
'option',
'xmp',
'noscript',
'head',
'title',
'template',
'svg',
'math',
'iframe',
'frame',
'frameset',
'noframes',
'object',
'embed',
'applet',
'noembed',
'canvas',
'audio',
'video',
'map',
'select',
'button',
'datalist',
];
/** 속성 허용 목록 — id·name·class 는 넣지 않는다 (DOM 충돌·스타일 우회 방지) */
const ALLOWED_ATTRIBUTES: Record<string, string[]> = {
'*': [
'style',
'title',
'dir',
'lang',
'align',
'valign',
'width',
'height',
'bgcolor',
],
a: ['href', 'target', 'rel'],
img: ['src', 'alt', 'border'],
table: ['border', 'cellpadding', 'cellspacing', 'summary'],
td: ['colspan', 'rowspan', 'nowrap'],
th: ['colspan', 'rowspan', 'nowrap', 'scope'],
col: ['span'],
colgroup: ['span'],
font: ['color', 'face', 'size'],
ol: ['start', 'type', 'reversed'],
ul: ['type'],
li: ['value'],
time: ['datetime'],
del: ['datetime'],
ins: ['datetime'],
details: ['open'],
};
const BOX_SIDES = ['', '-top', '-right', '-bottom', '-left'];
/** 인라인 style 에서 남기는 표시용 속성 — position·z-index·content 등은 넣지 않는다 */
const ALLOWED_STYLE_PROPERTIES = [
'color',
'background',
'background-color',
'font',
'font-family',
'font-size',
'font-style',
'font-variant',
'font-weight',
'letter-spacing',
'line-height',
'word-spacing',
'white-space',
'word-break',
'word-wrap',
'overflow-wrap',
'text-align',
'text-decoration',
'text-decoration-color',
'text-decoration-line',
'text-decoration-style',
'text-indent',
'text-transform',
'text-overflow',
'vertical-align',
'direction',
'width',
'min-width',
'max-width',
'height',
'min-height',
'max-height',
...BOX_SIDES.map((side) => `margin${side}`),
...BOX_SIDES.map((side) => `padding${side}`),
...BOX_SIDES.flatMap((side) => [
`border${side}`,
`border${side}-color`,
`border${side}-style`,
`border${side}-width`,
]),
'border-collapse',
'border-spacing',
'border-radius',
'display',
'float',
'clear',
'overflow',
'visibility',
'opacity',
'list-style',
'list-style-type',
'list-style-position',
'table-layout',
'caption-side',
'empty-cells',
];
/**
* 안전한 CSS 값 — 허용 문자만으로 이뤄지고, 괄호는 색·계산·그라데이션 함수에만 쓴다.
* url()·image-set()·expression() 같은 함수와 `:`(스킴)·`\`(CSS 이스케이프)·주석은 모두 걸러진다.
* 한글 글꼴 이름("맑은 고딕")을 위해 유니코드 글자를 허용한다.
*/
const SAFE_CSS_VALUE =
/^(?:[-\p{L}\p{N}_\s#%.,+!/'"]|\)|(?<=\b(?:rgba?|hsla?|calc|(?:repeating-)?(?:linear|radial)-gradient)\s*)\()*$/iu;
const ALLOWED_STYLES = {
'*': Object.fromEntries(
ALLOWED_STYLE_PROPERTIES.map((property) => [property, [SAFE_CSS_VALUE]]),
),
};
/** 링크로 남길 수 있는 주소 */
const SAFE_LINK = /^(?:https?:\/\/|mailto:)/i;
/** 외부에서 불러오는 주소 (프로토콜 상대 `//host` 포함) */
const EXTERNAL_URL = /^\s*(?:https?:)?\/\//i;
/** srcset·style 안의 외부 주소 */
const EMBEDDED_EXTERNAL_URL = /(?:https?:)?\/\//i;
/** style 의 url() 이 외부 주소를 가리키는지 */
const CSS_EXTERNAL_URL = /url\s*\(\s*['"]?\s*(?:https?:)?\/\//i;
/** 원문에 이미 들어 있던 data: 이미지 중 남겨도 되는 형식 */
const SAFE_DATA_IMAGE =
/^data:image\/(?:png|jpeg|gif|webp);base64,[a-z0-9+/]+={0,2}$/i;
function secureLink(attribs: sanitizeHtml.Attributes): sanitizeHtml.Attributes {
const next: sanitizeHtml.Attributes = { ...attribs };
delete next.href;
delete next.target;
delete next.rel;
const href = attribs.href?.trim();
if (href && SAFE_LINK.test(href)) {
next.href = href;
next.target = '_blank';
next.rel = 'noopener noreferrer';
}
return next;
}
function resolveImage(
attribs: sanitizeHtml.Attributes,
inlineImages: ReadonlyMap<string, string>,
markBlocked: () => void,
): sanitizeHtml.Attributes {
const next: sanitizeHtml.Attributes = { ...attribs };
delete next.src;
delete next.srcset;
if (attribs.srcset && EMBEDDED_EXTERNAL_URL.test(attribs.srcset)) {
markBlocked();
}
const src = attribs.src?.trim() ?? '';
if (/^cid:/i.test(src)) {
const dataUri = inlineImages.get(normalizeContentId(src.slice(4)));
if (dataUri) next.src = dataUri;
return next;
}
if (SAFE_DATA_IMAGE.test(src)) {
next.src = src;
return next;
}
if (EXTERNAL_URL.test(src)) markBlocked();
return next;
}
/** 배경 이미지로 외부 주소를 불러오려는 속성 (지워지지만 안내를 위해 표시한다) */
function loadsExternalImage(attribs: sanitizeHtml.Attributes): boolean {
return (
(attribs.background !== undefined &&
EXTERNAL_URL.test(attribs.background)) ||
(attribs.style !== undefined && CSS_EXTERNAL_URL.test(attribs.style))
);
}
/**
* 메일 HTML 을 정화한다.
* @param inlineImages 정규화한 Content-ID → data: URI (서비스 계층이 크기·형식을 확인해 만든 것)
*/
export function sanitizeMailHtml(
html: string,
inlineImages: ReadonlyMap<string, string> = new Map(),
): SanitizedMailHtml {
let hasBlockedImages = false;
const markBlocked = () => {
hasBlockedImages = true;
};
const output = sanitizeHtml(html, {
allowedTags: ALLOWED_TAGS,
allowedAttributes: ALLOWED_ATTRIBUTES,
// 스킴은 태그별 목록만 쓴다 — 그 밖의 URL 속성(background·cite 등)은 모두 거부된다
allowedSchemes: [],
allowedSchemesByTag: { a: ['http', 'https', 'mailto'], img: ['data'] },
allowProtocolRelative: false,
allowedStyles: ALLOWED_STYLES,
nonTextTags: NON_TEXT_TAGS,
disallowedTagsMode: 'discard',
transformTags: {
a: (tagName, attribs) => ({ tagName, attribs: secureLink(attribs) }),
img: (tagName, attribs) => ({
tagName,
attribs: resolveImage(attribs, inlineImages, markBlocked),
}),
'*': (tagName, attribs) => {
if (loadsExternalImage(attribs)) markBlocked();
return { tagName, attribs };
},
},
// 주소가 남지 않은 이미지는 태그째 지운다 (깨진 이미지 아이콘 방지)
exclusiveFilter: (frame) => frame.tag === 'img' && !frame.attribs.src,
});
return { html: output, hasBlockedImages };
}
@@ -0,0 +1,54 @@
import { BusinessException } from '@/common/errors/business.exception';
import { decodeCursor, encodeCursor } from './jmap-cursor';
const inbox = { mailboxId: 'a' };
function codeOf(run: () => unknown): string | undefined {
try {
run();
} catch (error) {
return error instanceof BusinessException ? error.code : 'NOT_BUSINESS';
}
return undefined;
}
describe('메일 목록 커서', () => {
it('인코딩한 위치를 그대로 되돌린다', () => {
const cursor = encodeCursor({ anchor: 'eaaaaab', position: 20 }, inbox);
expect(decodeCursor(cursor, inbox)).toEqual({
anchor: 'eaaaaab',
position: 20,
});
});
it('URL 에 그대로 넣을 수 있는 문자만 쓴다', () => {
const cursor = encodeCursor(
{ anchor: 'eaaaaab', position: 20 },
{ mailboxId: 'a', text: '메일 서버 이전' },
);
expect(cursor).toMatch(/^[A-Za-z0-9_-]+$/);
});
it('다른 메일함이나 다른 검색어의 커서는 VAL_001 이다', () => {
const cursor = encodeCursor({ anchor: 'eaaaaab', position: 20 }, inbox);
expect(codeOf(() => decodeCursor(cursor, { mailboxId: 'b' }))).toBe(
'VAL_001',
);
expect(
codeOf(() => decodeCursor(cursor, { mailboxId: 'a', text: '이전' })),
).toBe('VAL_001');
});
it('해석할 수 없는 값은 VAL_001 이다', () => {
const forged = Buffer.from(
JSON.stringify({ v: 1, a: '../x', p: -1, s: 'x' }),
).toString('base64url');
for (const cursor of ['not-a-cursor', '', forged, 'a'.repeat(600)]) {
expect(codeOf(() => decodeCursor(cursor, inbox))).toBe('VAL_001');
}
});
});
@@ -0,0 +1,96 @@
import { createHash } from 'crypto';
import { BusinessException } from '@/common/errors/business.exception';
import { MAIL_ID_PATTERN } from '@/modules/mail/mail.constants';
/**
* 메일 목록 커서 (클라이언트에게는 불투명 문자열).
*
* JMAP `Email/query` 는 마지막으로 본 메일 id(anchor) 다음부터 이어 읽는다.
* 그 메일이 그 사이에 지워지거나 옮겨지면 `anchorNotFound` 가 나므로,
* 다음 위치(position)도 함께 넣어 두었다가 그때는 위치로 다시 읽는다.
*
* 다른 목록(메일함·검색어)의 커서를 섞어 쓰지 못하도록 목록 범위의 해시를 함께 넣는다.
*/
const CURSOR_VERSION = 1;
/** 커서 문자열 최대 길이 — 이보다 길면 해석하지 않는다 */
const MAX_CURSOR_LENGTH = 512;
/** 범위 해시 길이 (충돌보다는 "다른 목록의 커서" 감지가 목적이라 짧게 둔다) */
const SCOPE_HASH_LENGTH = 16;
export interface CursorScope {
readonly mailboxId: string;
readonly text?: string;
}
export interface CursorPosition {
/** 마지막으로 내려준 메일 id */
readonly anchor: string;
/** 다음 페이지 첫 항목의 위치 (anchor 를 못 찾을 때 대신 쓴다) */
readonly position: number;
}
interface EncodedCursor {
v: number;
a: string;
p: number;
s: string;
}
function scopeHash(scope: CursorScope): string {
return createHash('sha256')
.update(`${scope.mailboxId}\n${scope.text ?? ''}`)
.digest('base64url')
.slice(0, SCOPE_HASH_LENGTH);
}
function invalidCursor(reason: string): BusinessException {
return new BusinessException('VAL_001', {
context: { reason: `invalid-cursor:${reason}` },
});
}
export function encodeCursor(
position: CursorPosition,
scope: CursorScope,
): string {
const payload: EncodedCursor = {
v: CURSOR_VERSION,
a: position.anchor,
p: position.position,
s: scopeHash(scope),
};
return Buffer.from(JSON.stringify(payload)).toString('base64url');
}
/** 커서를 해석한다. 형식이 틀리거나 다른 목록의 커서면 VAL_001 */
export function decodeCursor(
cursor: string,
scope: CursorScope,
): CursorPosition {
if (cursor.length > MAX_CURSOR_LENGTH) throw invalidCursor('too-long');
let parsed: unknown;
try {
parsed = JSON.parse(Buffer.from(cursor, 'base64url').toString('utf8'));
} catch {
throw invalidCursor('not-json');
}
const { v, a, p, s } = (parsed ?? {}) as Partial<EncodedCursor>;
const valid =
v === CURSOR_VERSION &&
typeof a === 'string' &&
MAIL_ID_PATTERN.test(a) &&
typeof p === 'number' &&
Number.isSafeInteger(p) &&
p >= 0 &&
typeof s === 'string';
if (!valid) throw invalidCursor('shape');
if (s !== scopeHash(scope)) throw invalidCursor('other-list');
return { anchor: a, position: p };
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,986 @@
import { CACHE_MANAGER } from '@nestjs/cache-manager';
import { Inject, Injectable, Logger } from '@nestjs/common';
import type { Cache } from 'cache-manager';
import { openAsBlob } from 'fs';
import { BusinessException } from '@/common/errors/business.exception';
import { resolveAttachmentsTotalLimit } from '@/modules/mail/attachment.policy';
import type { MailProvider } from '@/modules/mail/mail.provider';
import { MAIL_SETTINGS, type MailSettings } from '@/modules/mail/mail.settings';
import type {
AttachmentContent,
MailAccountProfile,
MailAddress,
MailAttachment,
Mailbox,
MailUsage,
MessageDetailSource,
MessageListQuery,
MessagePage,
MessageSummary,
MessageUpdate,
OutgoingAttachment,
OutgoingMessage,
SentMessageRecord,
UploadedFile,
} from '@/modules/mail/mail.types';
import { traceTag } from '@/shared/logger/request-context';
import { maskEmail } from '@/shared/utils/mask';
import {
JMAP_SEND_TIMEOUT_MS,
JmapClient,
type JmapSession,
} from './jmap.client';
import { decodeCursor, encodeCursor } from './jmap-cursor';
import { isMissingAttachmentBlob, jmapFailure } from './jmap.errors';
import {
BODY_PART_PROPERTIES,
DETAIL_PROPERTIES,
MAILBOX_PROPERTIES,
SUMMARY_PROPERTIES,
fallbackDisplayName,
findAttachment,
pickIdentity,
selectAccountQuota,
toDetailSource,
toMailbox,
toSummary,
toUsage,
} from './jmap.mapper';
import type {
JmapBlob,
JmapEmail,
JmapError,
JmapGetResult,
JmapIdentity,
JmapInvocation,
JmapMailbox,
JmapQueryResult,
JmapQuota,
JmapSetResult,
} from './jmap.types';
const QUOTA_CAPABILITY = 'urn:ietf:params:jmap:quota';
/** Blob/get (RFC 9404) — 첨부 합계 크기를 발송 전에 잰다 */
const BLOB_CAPABILITY = 'urn:ietf:params:jmap:blob';
/**
* 발송 때 남기는 내용 지문 헤더 — 같은 Idempotency-Key 로 내용이 바뀌었는지 비교한다.
* 메일과 함께 받는 사람에게도 가지만 Idempotency-Key(무작위)로 만든 HMAC 이라
* 키를 모르는 받는 사람은 내용(숨은 참조 등)을 맞춰 볼 수 없다 (send-idempotency.ts 머리말).
*/
const FINGERPRINT_HEADER = 'X-Ara-Payload-Hash';
const FINGERPRINT_PROPERTY = `header:${FINGERPRINT_HEADER}:asText`;
/**
* 중복 발송 확인 때 훑는 최근 메일 수 (보낸편지함 + 임시보관함, 받은 시각 내림차순).
* 재시도는 같은 쓰기 화면에서 곧 오므로 충분하다 — 그 사이 이보다 많이 보냈다면 다시 보낸다.
*/
const IDEMPOTENCY_LOOKBACK = 200;
/**
* 쿼터가 없는 계정의 사용량은 메일 크기를 더해 구한다 (대리 접근 권한으로는 엔진의 계정 사용량을 읽을 수 없다).
* 한 번에 읽는 개수는 엔진의 maxObjectsInGet(500) 이하, 비용이 커서 결과를 직원별로 잠깐 캐시한다.
*/
const USAGE_SCAN_PAGE_SIZE = 500;
/** 이보다 많으면 더 세지 않는다 (경고 로그 — 실제 사용량보다 작게 보인다) */
const USAGE_SCAN_MAX_EMAILS = 50_000;
const USAGE_CACHE_TTL_MS = 5 * 60_000;
/** 상세 조회 때 본문 값을 읽어 오는 상한 — 넘으면 엔진이 잘라서 준다 */
const MAX_BODY_VALUE_BYTES = 1_000_000;
/** References 헤더에 이어 붙이는 Message-ID 개수 상한 (긴 스레드에서 헤더가 끝없이 커지지 않게) */
const MAX_REFERENCES = 20;
/** 다운로드할 때 엔진에 요청하는 형식 — 클라이언트로는 어차피 octet-stream 으로 내려준다 */
const DOWNLOAD_TYPE = 'application/octet-stream';
type MethodResult =
| { ok: true; args: Record<string, unknown> }
| { ok: false; error: JmapError };
/** 호출 id·메서드 이름으로 응답을 찾는다 (onSuccessUpdateEmail 처럼 한 호출 id 에 응답이 둘일 수 있다) */
function findResult(
responses: JmapInvocation[],
callId: string,
method: string,
): MethodResult {
for (const [name, args, id] of responses) {
if (id !== callId) continue;
if (name === 'error') return { ok: false, error: args };
if (name === method) return { ok: true, args };
}
throw new BusinessException('MAIL_001', {
context: { reason: 'missing-response', method },
});
}
/** 성공 응답을 꺼낸다. 메서드 오류면 코드가 있는 예외로 바꾼다 */
function expectResult<T>(
responses: JmapInvocation[],
callId: string,
method: string,
): T {
const result = findResult(responses, callId, method);
if (!result.ok) throw jmapFailure(result.error, { method });
return result.args as T;
}
/** id 하나로 처리한 SetError — 엔진이 id 를 정규화해 키가 달라질 수 있어 첫 값을 본다 */
function firstSetError(
errors: Record<string, JmapError> | undefined,
): JmapError | undefined {
return errors ? Object.values(errors)[0] : undefined;
}
function toJmapAddress(address: MailAddress): {
name: string | null;
email: string;
} {
return { name: address.name, email: address.email };
}
function roleMailboxId(
mailboxes: JmapMailbox[],
role: string,
): string | undefined {
return mailboxes.find((mailbox) => mailbox.role?.toLowerCase() === role)?.id;
}
/** 답장 스레드 헤더 — 원 메일의 Message-ID 를 In-Reply-To 로, References 끝에 이어 붙인다 */
export function buildThreadHeaders(
original: Pick<JmapEmail, 'messageId' | 'references'> | undefined,
): { inReplyTo: string[]; references: string[] } | null {
const messageIds = original?.messageId ?? [];
if (messageIds.length === 0) return null;
const references = [
...new Set([...(original?.references ?? []), ...messageIds]),
].slice(-MAX_REFERENCES);
return { inReplyTo: messageIds, references };
}
/**
* Stalwart(JMAP) 어댑터 — `MailProvider` 의 구현.
* JMAP 의 메서드·속성·오류 형태는 이 파일과 `jmap/` 안에서만 다룬다.
*/
@Injectable()
export class JmapMailProvider implements MailProvider {
private readonly logger = new Logger(JmapMailProvider.name);
constructor(
private readonly jmap: JmapClient,
@Inject(CACHE_MANAGER) private readonly cache: Cache,
@Inject(MAIL_SETTINGS) private readonly settings: MailSettings,
) {}
verifyCredentials(email: string, password: string): Promise<string | null> {
return this.jmap.verifyCredentials(email, password);
}
async getProfile(
user: string,
options: { fresh?: boolean } = {},
): Promise<MailAccountProfile> {
const session = await this.jmap.getSession(user, options);
// 로그인·리프레시(fresh)에서만 — 관리 권한이 있는 계정은 웹메일로 다루지 않는다 (있으면 AUTH_001)
if (options.fresh) await this.jmap.assertNoManagementAccess(user);
const responses = await this.jmap.call(user, (accountId) => [
['Identity/get', { accountId, ids: null }, 'i'],
]);
const { list = [] } = expectResult<JmapGetResult<JmapIdentity>>(
responses,
'i',
'Identity/get',
);
const name = pickIdentity(list, session.username)?.name?.trim();
return {
email: session.username,
name: name || fallbackDisplayName(session.username),
};
}
async listMailboxes(user: string): Promise<Mailbox[]> {
const responses = await this.jmap.call(user, (accountId) => [
[
'Mailbox/get',
{ accountId, ids: null, properties: MAILBOX_PROPERTIES },
'm',
],
]);
const { list = [] } = expectResult<JmapGetResult<JmapMailbox>>(
responses,
'm',
'Mailbox/get',
);
return list.map(toMailbox);
}
async getUsage(user: string): Promise<MailUsage> {
const responses = await this.jmap.call(
user,
(accountId) => [['Quota/get', { accountId, ids: null }, 'qt']],
{ capabilities: [QUOTA_CAPABILITY] },
);
const { list = [] } = expectResult<JmapGetResult<JmapQuota>>(
responses,
'qt',
'Quota/get',
);
const quota = selectAccountQuota(list);
if (quota) return toUsage(quota);
// 계정 쿼터가 없다 = 한도 없음. 사용량은 메일 크기 합계로 대신한다 (연락처·일정 등은 빠진 근삿값)
const cacheKey = `mail:usage:${user}`;
const cached = await this.cache.get<number>(cacheKey);
if (typeof cached === 'number') {
return { usedBytes: cached, limitBytes: null };
}
const usedBytes = await this.sumEmailSizes(user);
await this.cache.set(cacheKey, usedBytes, USAGE_CACHE_TTL_MS);
return { usedBytes, limitBytes: null };
}
/** 발송 검사(assertAttachmentsFit)와 같은 세션·같은 함수로 정한다 — 세션은 발송 때처럼 캐시된 것을 쓴다 */
async getAttachmentsTotalLimit(user: string): Promise<number> {
return this.attachmentsTotalLimit(await this.jmap.getSession(user));
}
async listMessages(
user: string,
query: MessageListQuery,
): Promise<MessagePage> {
const scope = { mailboxId: query.mailboxId, text: query.text };
const resume = query.cursor ? decodeCursor(query.cursor, scope) : null;
let responses = await this.queryMessages(
user,
query,
resume ? { anchor: resume.anchor } : { position: 0 },
);
// 마지막으로 본 메일이 그 사이에 지워지거나 옮겨졌으면 위치로 이어 읽는다
const first = findResult(responses, 'q', 'Email/query');
if (resume && !first.ok && first.error.type === 'anchorNotFound') {
responses = await this.queryMessages(user, query, {
position: resume.position,
});
}
const mailbox = expectResult<JmapGetResult<JmapMailbox>>(
responses,
'mb',
'Mailbox/get',
);
if (!mailbox.list?.length) {
// 없는 메일함 — 계약 2절 "표에 없는 실패": 404 RES_001
throw new BusinessException('RES_001', {
context: { reason: 'mailbox-not-found' },
});
}
const result = expectResult<JmapQueryResult>(responses, 'q', 'Email/query');
const { list = [] } = expectResult<JmapGetResult<JmapEmail>>(
responses,
'g',
'Email/get',
);
const ids = result.ids ?? [];
const start = result.position ?? 0;
const total = typeof result.total === 'number' ? result.total : null;
// Email/get 의 결과 순서는 보장되지 않으므로 query 순서(받은 시각 내림차순)로 다시 놓는다
const byId = new Map(list.map((email) => [email.id, email]));
const items = ids.flatMap((id) => {
const email = byId.get(id);
return email ? [toSummary(email)] : [];
});
const hasNext =
total !== null ? start + ids.length < total : ids.length === query.limit;
const lastId = ids.at(-1);
return {
items,
hasNext,
nextCursor:
hasNext && lastId
? encodeCursor(
{ anchor: lastId, position: start + ids.length },
scope,
)
: null,
total,
};
}
async getMessage(user: string, id: string): Promise<MessageDetailSource> {
const responses = await this.jmap.call(
user,
(accountId) => [
[
'Email/get',
{
accountId,
ids: [id],
properties: DETAIL_PROPERTIES,
bodyProperties: BODY_PART_PROPERTIES,
fetchHTMLBodyValues: true,
fetchTextBodyValues: true,
maxBodyValueBytes: MAX_BODY_VALUE_BYTES,
},
'g',
],
],
{ invalidRequestCode: 'RES_001' },
);
return toDetailSource(this.requireEmail(responses, 'g'));
}
async updateMessage(
user: string,
id: string,
update: MessageUpdate,
): Promise<MessageSummary> {
const patch: Record<string, unknown> = {};
if (update.isRead !== undefined) {
patch['keywords/$seen'] = update.isRead ? true : null;
}
if (update.isFlagged !== undefined) {
patch['keywords/$flagged'] = update.isFlagged ? true : null;
}
if (update.mailboxId !== undefined) {
// 이동 = 이 메일함 하나에만 속하게 한다
patch.mailboxIds = { [update.mailboxId]: true };
}
const responses = await this.jmap.call(
user,
(accountId) => [
['Email/set', { accountId, update: { [id]: patch } }, 'u'],
[
'Email/get',
{ accountId, ids: [id], properties: SUMMARY_PROPERTIES },
'g',
],
],
{ invalidRequestCode: 'RES_001' },
);
const set = expectResult<JmapSetResult>(responses, 'u', 'Email/set');
const failure = firstSetError(set.notUpdated);
if (failure) throw jmapFailure(failure, { method: 'Email/set' });
return toSummary(this.requireEmail(responses, 'g'));
}
async deleteMessage(user: string, id: string): Promise<void> {
const lookup = await this.jmap.call(
user,
(accountId) => [
[
'Email/get',
{ accountId, ids: [id], properties: ['id', 'mailboxIds'] },
'g',
],
[
'Mailbox/get',
{ accountId, ids: null, properties: ['id', 'role'] },
'm',
],
],
{ invalidRequestCode: 'RES_001' },
);
const email = this.requireEmail(lookup, 'g');
const { list: mailboxes = [] } = expectResult<JmapGetResult<JmapMailbox>>(
lookup,
'm',
'Mailbox/get',
);
const trashId = roleMailboxId(mailboxes, 'trash');
if (!trashId) {
// 휴지통 없이 지우면 되돌릴 수 없다 — 영구 삭제로 대신하지 않는다
throw new BusinessException('MAIL_001', {
context: { reason: 'trash-mailbox-missing' },
});
}
const inTrash = email.mailboxIds?.[trashId] === true;
const responses = await this.jmap.call(user, (accountId) => [
[
'Email/set',
inTrash
? { accountId, destroy: [email.id] }
: {
accountId,
update: { [email.id]: { mailboxIds: { [trashId]: true } } },
},
'd',
],
]);
const set = expectResult<JmapSetResult>(responses, 'd', 'Email/set');
const failure = firstSetError(inTrash ? set.notDestroyed : set.notUpdated);
if (failure) throw jmapFailure(failure, { method: 'Email/set' });
}
async sendMessage(
user: string,
message: OutgoingMessage,
): Promise<{ id: string }> {
// 1) 준비 — 메일함 역할, 발신 ID, (답장이면) 원 메일의 스레드 헤더
const session = await this.jmap.getSession(user);
const { inReplyTo } = message;
const prepared = await this.jmap.call(
user,
(accountId) => [
[
'Mailbox/get',
{ accountId, ids: null, properties: ['id', 'role'] },
'm',
],
['Identity/get', { accountId, ids: null }, 'i'],
...(inReplyTo
? [
[
'Email/get',
{
accountId,
ids: [inReplyTo],
properties: ['messageId', 'references'],
},
'r',
] as JmapInvocation,
]
: []),
],
{ invalidRequestCode: 'VAL_001' },
);
const { list: mailboxes = [] } = expectResult<JmapGetResult<JmapMailbox>>(
prepared,
'm',
'Mailbox/get',
);
const draftsId = roleMailboxId(mailboxes, 'drafts');
const sentId = roleMailboxId(mailboxes, 'sent');
if (!draftsId || !sentId) {
throw new BusinessException('MAIL_001', {
context: { reason: 'drafts-or-sent-mailbox-missing' },
});
}
const { list: identities = [] } = expectResult<JmapGetResult<JmapIdentity>>(
prepared,
'i',
'Identity/get',
);
const identity = pickIdentity(identities, session.username);
if (!identity) {
throw new BusinessException('MAIL_001', {
context: { reason: 'identity-missing' },
});
}
const thread = inReplyTo ? this.threadHeaders(prepared) : null;
// 2) 첨부 합계 확인 — 엔진에 메일을 만들기 전에 (없는 첨부 MAIL_004, 한도 초과 FILE_002)
await this.assertAttachmentsFit(user, message.attachments, session);
// 3) 초안을 만들고 같은 요청에서 제출한다 — 성공하면 초안 → 보낸편지함으로 옮긴다
const draft = {
mailboxIds: { [draftsId]: true },
keywords: { $draft: true, $seen: true },
// 중복 발송 방지 — 같은 키의 재시도는 같은 Message-ID 로 이 메일을 다시 찾는다
...(message.messageId ? { messageId: [message.messageId] } : {}),
...(message.fingerprint
? { [FINGERPRINT_PROPERTY]: message.fingerprint }
: {}),
from: [
{
name: identity.name || null,
email: identity.email ?? session.username,
},
],
to: message.to.map(toJmapAddress),
...(message.cc.length ? { cc: message.cc.map(toJmapAddress) } : {}),
...(message.bcc.length ? { bcc: message.bcc.map(toJmapAddress) } : {}),
subject: message.subject,
...(thread ?? {}),
bodyValues: { body: { value: message.body } },
textBody: [{ partId: 'body', type: 'text/plain' }],
...(message.attachments.length
? {
attachments: message.attachments.map((attachment) => ({
blobId: attachment.id,
type: attachment.type,
name: attachment.name,
disposition: 'attachment',
})),
}
: {}),
};
const responses = await this.jmap.call(
user,
(accountId) => [
['Email/set', { accountId, create: { draft } }, 'e'],
[
'EmailSubmission/set',
{
accountId,
create: { send: { identityId: identity.id, emailId: '#draft' } },
onSuccessUpdateEmail: {
'#send': {
[`mailboxIds/${draftsId}`]: null,
[`mailboxIds/${sentId}`]: true,
'keywords/$draft': null,
},
},
},
's',
],
],
// 첨부를 붙여 메시지를 만드는 큰 요청이라 제한 시간을 늘린다
{ invalidRequestCode: 'VAL_001', timeoutMs: JMAP_SEND_TIMEOUT_MS },
);
const created = expectResult<JmapSetResult>(responses, 'e', 'Email/set');
const draftFailure = firstSetError(created.notCreated);
if (draftFailure && isMissingAttachmentBlob(draftFailure)) {
// 만료된 업로드·잘못된 첨부 id — 클라이언트는 그 첨부를 다시 올리게 한다 (계약 2절)
throw new BusinessException('MAIL_004', {
context: {
reason: 'attachment-blob-not-found',
jmapType: draftFailure.type ?? 'unknown',
},
});
}
if (draftFailure) throw jmapFailure(draftFailure, { method: 'Email/set' });
const draftId = created.created?.draft?.id;
if (!draftId) {
throw new BusinessException('MAIL_001', {
context: { reason: 'draft-not-created' },
});
}
const submission = findResult(responses, 's', 'EmailSubmission/set');
const submitted = submission.ok
? (submission.args as JmapSetResult)
: undefined;
if (!submitted?.created?.send) {
// 보내지 못한 초안이 임시보관함에 남지 않게 지운다
await this.discardDraft(user, draftId);
throw jmapFailure(
submission.ok ? firstSetError(submitted?.notCreated) : submission.error,
{ method: 'EmailSubmission/set' },
);
}
this.warnIfNotMovedToSent(responses, user);
return { id: draftId };
}
async findSentMessage(
user: string,
messageId: string,
): Promise<SentMessageRecord | null> {
const lookup = await this.jmap.call(user, (accountId) => [
[
'Mailbox/get',
{ accountId, ids: null, properties: ['id', 'role'] },
'm',
],
]);
const { list: mailboxes = [] } = expectResult<JmapGetResult<JmapMailbox>>(
lookup,
'm',
'Mailbox/get',
);
const searched = [
roleMailboxId(mailboxes, 'sent'),
roleMailboxId(mailboxes, 'drafts'),
].filter((id): id is string => id !== undefined);
if (searched.length === 0) return null;
// Stalwart 는 Message-ID 헤더 필터를 지원하지 않는다(확인함) — 최근 메일의 messageId 속성을 읽어 비교한다
const responses = await this.jmap.call(user, (accountId) => [
[
'Email/query',
{
accountId,
filter: {
operator: 'OR',
conditions: searched.map((inMailbox) => ({ inMailbox })),
},
sort: [{ property: 'receivedAt', isAscending: false }],
limit: IDEMPOTENCY_LOOKBACK,
},
'q',
],
[
'Email/get',
{
accountId,
'#ids': { resultOf: 'q', name: 'Email/query', path: '/ids' },
properties: ['id', 'messageId', 'keywords', FINGERPRINT_PROPERTY],
},
'g',
],
]);
const { list = [] } = expectResult<JmapGetResult<JmapEmail>>(
responses,
'g',
'Email/get',
);
const email = list.find((candidate) =>
(candidate.messageId ?? []).includes(messageId),
);
if (!email) return null;
// 초안으로 남아 있으면 실제로 제출됐는지 확인한다 — 제출 기록이 없으면 보내지 못한 초안이다
if (
email.keywords?.$draft === true &&
!(await this.wasSubmitted(user, email.id))
) {
await this.discardDraft(user, email.id);
return null;
}
const fingerprint = (email as unknown as Record<string, unknown>)[
FINGERPRINT_PROPERTY
];
return {
id: email.id,
fingerprint:
typeof fingerprint === 'string' && fingerprint.trim() !== ''
? fingerprint.trim()
: null,
};
}
async uploadAttachment(
user: string,
file: UploadedFile,
): Promise<MailAttachment> {
// 임시 파일을 Blob 으로 연다 — 파일을 조금씩 읽어 엔진으로 흘려보낸다 (메모리에 다 올리지 않는다)
const content = await openAsBlob(file.path, { type: file.type });
const blob = await this.jmap.upload(user, content, file.type);
return {
id: blob.blobId,
name: file.name,
type: file.type,
size: blob.size,
};
}
async openAttachment(
user: string,
messageId: string,
attachmentId: string,
): Promise<AttachmentContent> {
const responses = await this.jmap.call(
user,
(accountId) => [
[
'Email/get',
{
accountId,
ids: [messageId],
properties: ['id', 'attachments'],
bodyProperties: ['blobId', 'name', 'type', 'size'],
},
'g',
],
],
{ invalidRequestCode: 'RES_001' },
);
// 그 메일에 실제로 있는 첨부인지 확인한다 — 다른 blob id 로 엉뚱한 데이터를 받지 못하게
const attachment = findAttachment(
this.requireEmail(responses, 'g'),
attachmentId,
);
if (!attachment) {
throw new BusinessException('RES_001', {
context: { reason: 'attachment-not-in-message' },
});
}
const download = await this.jmap.download(
user,
attachmentId,
attachment.name,
DOWNLOAD_TYPE,
);
return {
stream: download.stream,
name: attachment.name,
// Content-Length 로 쓰이므로 엔진이 알려 준 실제 길이만 쓴다 (모르면 청크 전송)
size: download.length,
};
}
readAttachmentBytes(
user: string,
attachmentId: string,
maxBytes: number,
): Promise<Buffer | null> {
return this.jmap.downloadBytes(user, attachmentId, maxBytes);
}
/**
* 메일함 확인 + 목록 조회 + 요약 조회를 한 요청으로 보낸다.
* 사용자가 보낸 mailboxId 가 필터에 들어가므로, 엔진이 해석하지 못하면 없는 메일함과 같게 RES_001 로 응답한다
* (Stalwart 는 필터의 id 형식이 틀리면 요청 전체를 notRequest 로 거부한다).
*/
private queryMessages(
user: string,
query: MessageListQuery,
start: { anchor: string } | { position: number },
): Promise<JmapInvocation[]> {
const filter = query.text
? { inMailbox: query.mailboxId, text: query.text }
: { inMailbox: query.mailboxId };
return this.jmap.call(
user,
(accountId) => [
[
'Mailbox/get',
{ accountId, ids: [query.mailboxId], properties: ['id'] },
'mb',
],
[
'Email/query',
{
accountId,
filter,
sort: [{ property: 'receivedAt', isAscending: false }],
...('anchor' in start
? { anchor: start.anchor, anchorOffset: 1 }
: { position: start.position }),
limit: query.limit,
calculateTotal: true,
},
'q',
],
[
'Email/get',
{
accountId,
'#ids': { resultOf: 'q', name: 'Email/query', path: '/ids' },
properties: SUMMARY_PROPERTIES,
},
'g',
],
],
{ invalidRequestCode: 'RES_001' },
);
}
/**
* 첨부 합계 크기 확인 — 업로드한 파일과 전달하는 원본 첨부 모두 Blob/get 으로 잰다.
* 한도는 MAIL_ATTACHMENT_TOTAL_MAX_SIZE_MB 와 엔진의 메일 한 통 첨부 한도 중 작은 값이다.
* 같은 첨부를 두 번 붙이면 두 번 센다 (메일에 두 번 들어간다).
*/
private async assertAttachmentsFit(
user: string,
attachments: readonly OutgoingAttachment[],
session: JmapSession,
): Promise<void> {
if (attachments.length === 0) return;
const ids = [...new Set(attachments.map((attachment) => attachment.id))];
const responses = await this.jmap.call(
user,
(accountId) => [
['Blob/get', { accountId, ids, properties: ['size'] }, 'b'],
],
// 엔진이 해석하지 못하는 id 형식도 "찾을 수 없는 첨부"다
{ capabilities: [BLOB_CAPABILITY], invalidRequestCode: 'MAIL_004' },
);
const { list = [], notFound = [] } = expectResult<JmapGetResult<JmapBlob>>(
responses,
'b',
'Blob/get',
);
const sizes = new Map(list.map((blob) => [blob.id, blob.size]));
if (notFound.length > 0 || ids.some((id) => !sizes.has(id))) {
throw new BusinessException('MAIL_004', {
context: {
reason: 'attachment-blob-not-found',
missing: notFound.length || 1,
},
});
}
const totalBytes = attachments.reduce(
(sum, attachment) => sum + (sizes.get(attachment.id) ?? 0),
0,
);
const limitBytes = this.attachmentsTotalLimit(session);
if (totalBytes > limitBytes) {
throw new BusinessException('FILE_002', {
context: {
reason: 'attachments-total-too-large',
totalBytes,
limitBytes,
},
});
}
}
/**
* 첨부 합계 한도 — 발송 검사와 `getAttachmentsTotalLimit`(mail-quota)이 **이것 하나**를 쓴다.
* 설정 한도와 엔진 세션의 `maxSizeAttachmentsPerEmail` 중 작은 값 (attachment.policy.ts).
*/
private attachmentsTotalLimit(session: JmapSession): number {
return resolveAttachmentsTotalLimit(
this.settings.attachmentTotalMaxBytes,
session.maxAttachmentsBytes,
);
}
/** 이 메일로 만든 제출(EmailSubmission)이 있는가 */
private async wasSubmitted(user: string, emailId: string): Promise<boolean> {
const responses = await this.jmap.call(user, (accountId) => [
[
'EmailSubmission/query',
{ accountId, filter: { emailIds: [emailId] } },
's',
],
]);
const { ids = [] } = expectResult<JmapQueryResult>(
responses,
's',
'EmailSubmission/query',
);
return ids.length > 0;
}
/** 계정의 모든 메일 크기를 더한다 — 엔진이 limit 을 줄여 돌려줘도 실제로 받은 개수만큼 전진한다 */
private async sumEmailSizes(user: string): Promise<number> {
let position = 0;
let usedBytes = 0;
while (position < USAGE_SCAN_MAX_EMAILS) {
const responses = await this.jmap.call(user, (accountId) => [
[
'Email/query',
{
accountId,
position,
limit: USAGE_SCAN_PAGE_SIZE,
calculateTotal: true,
},
'q',
],
[
'Email/get',
{
accountId,
'#ids': { resultOf: 'q', name: 'Email/query', path: '/ids' },
properties: ['size'],
},
'g',
],
]);
const { ids = [], total } = expectResult<JmapQueryResult>(
responses,
'q',
'Email/query',
);
const { list = [] } = expectResult<JmapGetResult<JmapEmail>>(
responses,
'g',
'Email/get',
);
usedBytes += list.reduce((sum, email) => sum + (email.size ?? 0), 0);
position += ids.length;
const exhausted =
ids.length === 0 || (typeof total === 'number' && position >= total);
if (exhausted) return usedBytes;
}
this.logger.warn(
`[MAIL_001]${traceTag()} 메일이 ${USAGE_SCAN_MAX_EMAILS}통을 넘어 사용량을 끝까지 세지 않았습니다: user=${maskEmail(user)}`,
);
return usedBytes;
}
/** Email/get 응답에서 메일 하나를 꺼낸다. 없으면 RES_001 */
private requireEmail(responses: JmapInvocation[], callId: string): JmapEmail {
const { list = [] } = expectResult<JmapGetResult<JmapEmail>>(
responses,
callId,
'Email/get',
);
// 엔진이 id 를 정규화해 돌려줄 수 있으므로(대소문자 등) 첫 항목을 쓴다 — 계정 안의 메일만 온다
const [email] = list;
if (!email) {
throw new BusinessException('RES_001', {
context: { reason: 'email-not-found' },
});
}
return email;
}
/** 답장할 원 메일을 찾지 못하면 스레드 헤더 없이 보낸다 (지워진 메일에 답장하는 경우) */
private threadHeaders(
prepared: JmapInvocation[],
): { inReplyTo: string[]; references: string[] } | null {
const original = findResult(prepared, 'r', 'Email/get');
const email = original.ok
? (original.args as JmapGetResult<JmapEmail>).list?.[0]
: undefined;
if (!email) {
this.logger.warn(
`[RES_001]${traceTag()} 답장할 원 메일을 찾지 못해 스레드 헤더 없이 보냅니다.`,
);
}
return buildThreadHeaders(email);
}
private async discardDraft(user: string, draftId: string): Promise<void> {
try {
const responses = await this.jmap.call(user, (accountId) => [
['Email/set', { accountId, destroy: [draftId] }, 'x'],
]);
const set = expectResult<JmapSetResult>(responses, 'x', 'Email/set');
if (firstSetError(set.notDestroyed)) throw new Error('notDestroyed');
} catch (error) {
this.logger.warn(
`[MAIL_001]${traceTag()} 보내지 못한 초안 정리에 실패했습니다: user=${maskEmail(user)}, reason=${error instanceof Error ? error.message : String(error)}`,
);
}
}
/** 발송은 끝났지만 보낸편지함 이동이 실패한 경우 — 사용자 응답은 성공으로 두고 경고만 남긴다 */
private warnIfNotMovedToSent(
responses: JmapInvocation[],
user: string,
): void {
const moved = responses.find(
([name, , id]) => name === 'Email/set' && id === 's',
);
const notUpdated = (moved?.[1] as JmapSetResult | undefined)?.notUpdated;
if (notUpdated && Object.keys(notUpdated).length > 0) {
this.logger.warn(
`[MAIL_001]${traceTag()} 발송한 메일을 보낸편지함으로 옮기지 못했습니다: user=${maskEmail(user)}, jmapType=${firstSetError(notUpdated)?.type ?? 'unknown'}`,
);
}
}
}
@@ -0,0 +1,724 @@
import type { Cache } from 'cache-manager';
import { BusinessException } from '@/common/errors/business.exception';
import type { MailSettings } from '@/modules/mail/mail.settings';
import { runWithRequestContext } from '@/shared/logger/request-context';
import {
JMAP_SEND_TIMEOUT_MS,
JmapClient,
basicAuthorization,
classifyManagementProbe,
expandTemplate,
toRelativeReference,
} from './jmap.client';
const settings: MailSettings = {
jmapBaseUrl: 'http://mail:8080',
domain: 'arasystem.test',
gatewayUser: 'mail-gateway@arasystem.test',
gatewayPassword: 'gateway-secret',
attachmentMaxBytes: 20 * 1024 * 1024,
attachmentTotalMaxBytes: 25 * 1024 * 1024,
blockedAccounts: new Set(['admin@arasystem.test']),
};
const ARA = 'ara.kim@arasystem.test';
/** 검증된 Stalwart 세션 응답 형태 — URL 은 공개 주소(MAIL_PUBLIC_URL) 기준 절대 주소다 */
const sessionBody = {
username: ARA,
apiUrl: 'http://localhost:8180/jmap/',
downloadUrl:
'http://localhost:8180/jmap/download/{accountId}/{blobId}/{name}?accept={type}',
uploadUrl: 'http://localhost:8180/jmap/upload/{accountId}/',
primaryAccounts: { 'urn:ietf:params:jmap:mail': 'd' },
};
function json(body: unknown, status = 200): Response {
return new Response(JSON.stringify(body), {
status,
headers: { 'content-type': 'application/json' },
});
}
/** Map 기반 가짜 캐시 — cache-manager 의 get/set/del 만 흉내 낸다 */
function fakeCache(): Cache {
const store = new Map<string, unknown>();
return {
get: jest.fn((key: string) => Promise.resolve(store.get(key) ?? null)),
set: jest.fn((key: string, value: unknown) => {
store.set(key, value);
return Promise.resolve(value);
}),
del: jest.fn((key: string) => Promise.resolve(store.delete(key))),
} as unknown as Cache;
}
async function codeOf(promise: Promise<unknown>): Promise<string | undefined> {
try {
await promise;
} catch (error) {
return error instanceof BusinessException ? error.code : 'NOT_BUSINESS';
}
return undefined;
}
function decodeBasic(header: string): string {
return Buffer.from(header.replace(/^Basic /, ''), 'base64').toString('utf8');
}
describe('JmapClient', () => {
let fetchMock: jest.SpyInstance;
let cache: Cache;
let client: JmapClient;
/** n 번째 fetch 호출의 URL·헤더 */
const requestAt = (index: number) => {
const [url, init] = fetchMock.mock.calls[index] as [string, RequestInit];
return {
url,
init,
authorization: (init.headers as Record<string, string>).authorization,
};
};
beforeEach(() => {
fetchMock = jest.spyOn(global, 'fetch');
cache = fakeCache();
client = new JmapClient(settings, cache);
});
afterEach(() => {
fetchMock.mockRestore();
jest.useRealTimers();
});
describe('직원 자격 확인 (verifyCredentials)', () => {
it('직원 주소:비밀번호 Basic 인증으로 내부 주소의 세션을 조회한다', async () => {
fetchMock.mockResolvedValueOnce(
json({ ...sessionBody, username: 'Ara.Kim@AraSystem.test' }),
);
await expect(
client.verifyCredentials(ARA, 'pw:with:colon'),
).resolves.toBe(ARA);
const { url, authorization } = requestAt(0);
expect(url).toBe('http://mail:8080/jmap/session');
expect(decodeBasic(authorization)).toBe(`${ARA}:pw:with:colon`);
});
it('401·403 이면 null (틀린 자격 증명)', async () => {
fetchMock.mockResolvedValueOnce(json({}, 401));
await expect(client.verifyCredentials(ARA, 'wrong')).resolves.toBeNull();
fetchMock.mockResolvedValueOnce(json({}, 403));
await expect(client.verifyCredentials(ARA, 'wrong')).resolves.toBeNull();
});
it('엔진 5xx·네트워크 오류는 MAIL_001, 429 는 REQ_001', async () => {
fetchMock.mockResolvedValueOnce(json({}, 502));
expect(await codeOf(client.verifyCredentials(ARA, 'pw'))).toBe(
'MAIL_001',
);
fetchMock.mockRejectedValueOnce(
new TypeError('fetch failed', {
cause: Object.assign(new Error('connect ECONNREFUSED'), {
code: 'ECONNREFUSED',
}),
}),
);
expect(await codeOf(client.verifyCredentials(ARA, 'pw'))).toBe(
'MAIL_001',
);
fetchMock.mockResolvedValueOnce(json({}, 429));
expect(await codeOf(client.verifyCredentials(ARA, 'pw'))).toBe('REQ_001');
});
it('응답이 없으면 제한 시간 뒤 MAIL_001 (timeout) 이다', async () => {
jest.useFakeTimers();
fetchMock.mockImplementation(
(_url: string, init: RequestInit) =>
new Promise((_resolve, reject) => {
init.signal?.addEventListener('abort', () =>
reject(new DOMException('aborted', 'AbortError')),
);
}),
);
const pending = client.verifyCredentials(ARA, 'pw');
const settled = pending.catch((error: unknown) => error);
await jest.advanceTimersByTimeAsync(10_000);
const error = (await settled) as BusinessException;
expect(error.code).toBe('MAIL_001');
expect(error.context).toMatchObject({ reason: 'timeout' });
});
it('메일 설정이 없으면 엔진에 가지 않고 MAIL_001 이다', async () => {
const unconfigured = new JmapClient(
{ ...settings, jmapBaseUrl: null },
cache,
);
expect(await codeOf(unconfigured.verifyCredentials(ARA, 'pw'))).toBe(
'MAIL_001',
);
expect(fetchMock).not.toHaveBeenCalled();
});
});
describe('대리 접근 세션 (getSession)', () => {
it('<직원>%<게이트웨이>:<게이트웨이 비밀번호> 로 인증하고, 공개 주소 URL 을 경로로 바꿔 둔다', async () => {
fetchMock.mockResolvedValueOnce(json(sessionBody));
const session = await client.getSession(ARA);
expect(decodeBasic(requestAt(0).authorization)).toBe(
`${ARA}%mail-gateway@arasystem.test:gateway-secret`,
);
expect(session).toEqual({
username: ARA,
accountId: 'd',
apiPath: '/jmap/',
downloadPath:
'/jmap/download/{accountId}/{blobId}/{name}?accept={type}',
uploadPath: '/jmap/upload/{accountId}/',
// 계정 기능을 알려 주지 않는 응답 — 엔진 한도를 모르는 것으로 둔다
maxAttachmentsBytes: null,
});
});
it('계정의 메일 기능이 알려 주는 첨부 합계 한도(maxSizeAttachmentsPerEmail)를 읽어 둔다', async () => {
fetchMock.mockResolvedValueOnce(
json({
...sessionBody,
accounts: {
d: {
accountCapabilities: {
'urn:ietf:params:jmap:mail': {
maxSizeAttachmentsPerEmail: 50_000_000,
},
},
},
},
}),
);
const session = await client.getSession(ARA);
expect(session.maxAttachmentsBytes).toBe(50_000_000);
});
it('직원별로 캐시하고, fresh 면 다시 조회한다', async () => {
fetchMock.mockImplementation(() => Promise.resolve(json(sessionBody)));
await client.getSession(ARA);
await client.getSession(ARA);
expect(fetchMock).toHaveBeenCalledTimes(1);
await client.getSession(ARA, { fresh: true });
expect(fetchMock).toHaveBeenCalledTimes(2);
});
it('대리 접근 401 이고 게이트웨이 자체는 정상이면 계정이 사라진 것 → AUTH_001', async () => {
fetchMock
.mockResolvedValueOnce(json({}, 401))
// 게이트웨이 자신으로 로그인 확인
.mockResolvedValueOnce(json({}, 200));
expect(await codeOf(client.getSession(ARA))).toBe('AUTH_001');
expect(decodeBasic(requestAt(1).authorization)).toBe(
'mail-gateway@arasystem.test:gateway-secret',
);
});
it('게이트웨이 자격 증명 자체가 거부되면 설정 문제 → MAIL_001 (전 직원 로그아웃을 막는다) — 오류 로그에 코드·추적 ID', async () => {
const errorLog = jest
.spyOn(
Reflect.get(client, 'logger') as { error: (message: string) => void },
'error',
)
.mockImplementation(() => undefined);
fetchMock
.mockResolvedValueOnce(json({}, 401))
.mockResolvedValueOnce(json({}, 401));
const code = await runWithRequestContext({ requestId: 'trace-gw' }, () =>
codeOf(client.getSession(ARA)),
);
expect(code).toBe('MAIL_001');
expect(errorLog).toHaveBeenCalledWith(
expect.stringMatching(/^\[MAIL_001\]\[trace-gw\] /),
);
});
it('대리 접근 403(게이트웨이에 대리 권한 없음)은 계정 문제가 아니라 설정 문제 → MAIL_001 + error 로그 (세션을 폐기하게 두지 않는다)', async () => {
const errorLog = jest
.spyOn(
Reflect.get(client, 'logger') as { error: (message: string) => void },
'error',
)
.mockImplementation(() => undefined);
// Stalwart 가 실제로 주는 본문 (개발 엔진에서 확인)
fetchMock.mockResolvedValueOnce(
json(
{
type: 'about:blank',
title: 'Forbidden',
detail:
'You do not have enough permissions to access this resource.',
status: 403,
},
403,
),
);
const code = await runWithRequestContext({ requestId: 'trace-403' }, () =>
codeOf(client.getSession(ARA)),
);
expect(code).toBe('MAIL_001');
// 게이트웨이 확인을 하지 않는다 — 403 은 인증이 이미 된 상태다
expect(fetchMock).toHaveBeenCalledTimes(1);
expect(errorLog).toHaveBeenCalledWith(
expect.stringMatching(
/^\[MAIL_001\]\[trace-403\] .*대리 접근 권한이 없습니다/,
),
);
});
it('막는 계정(메일 관리자)은 엔진에 대리 접근을 요청하지 않고 AUTH_001 (대소문자 무관)', async () => {
for (const user of ['admin@arasystem.test', ' Admin@AraSystem.TEST']) {
expect(await codeOf(client.getSession(user, { fresh: true }))).toBe(
'AUTH_001',
);
}
expect(fetchMock).not.toHaveBeenCalled();
});
it('로컬 이름·별칭으로 들어와도 엔진이 알려 준 정식 주소가 막는 계정이면 AUTH_001 — 캐시하지 않는다', async () => {
// Stalwart 는 대리 접근 대상 "admin" 을 admin@<도메인> 으로 풀어 준다 (개발 엔진에서 확인)
fetchMock.mockResolvedValueOnce(
json({ ...sessionBody, username: 'admin@arasystem.test' }),
);
expect(await codeOf(client.getSession('admin'))).toBe('AUTH_001');
expect(cache.set).not.toHaveBeenCalled();
});
it('메일 계정이 없는 세션은 MAIL_001', async () => {
fetchMock.mockResolvedValueOnce(
json({ ...sessionBody, primaryAccounts: {} }),
);
expect(await codeOf(client.getSession(ARA))).toBe('MAIL_001');
});
});
describe('관리 권한 확인 (assertNoManagementAccess)', () => {
const OPS = 'it.ops@arasystem.test';
const probeAnswer = (invocation: unknown[]) =>
json({ methodResponses: [invocation], sessionState: 's' });
let errorLog: jest.SpyInstance;
let warnLog: jest.SpyInstance;
beforeEach(() => {
const logger = Reflect.get(client, 'logger') as {
error: (message: string) => void;
warn: (message: string) => void;
};
errorLog = jest
.spyOn(logger, 'error')
.mockImplementation(() => undefined);
warnLog = jest.spyOn(logger, 'warn').mockImplementation(() => undefined);
});
it('관리 API 를 데이터 없이(ids: []) 불러 성공하면 관리 권한이 있는 계정 → AUTH_001, 캐시한 세션을 지운다', async () => {
fetchMock
.mockResolvedValueOnce(json({ ...sessionBody, username: OPS }))
.mockResolvedValueOnce(
probeAnswer(['x:Account/get', { list: [], notFound: [] }, 'p']),
);
const failure = await runWithRequestContext(
{ requestId: 'trace-admin' },
() =>
client.assertNoManagementAccess(OPS).then(
() => undefined,
(error: unknown) => error as BusinessException,
),
);
expect(failure?.code).toBe('AUTH_001');
expect(failure?.context).toMatchObject({ reason: 'management-account' });
expect(cache.del).toHaveBeenCalledWith(`mail:jmap-session:${OPS}`);
expect(warnLog).toHaveBeenCalledWith(
expect.stringMatching(/^\[AUTH_001\]\[trace-admin\] /),
);
const { url, init, authorization } = requestAt(1);
expect(url).toBe('http://mail:8080/jmap/');
expect(decodeBasic(authorization)).toBe(
`${OPS}%mail-gateway@arasystem.test:gateway-secret`,
);
expect(JSON.parse(init.body as string)).toEqual({
using: ['urn:ietf:params:jmap:core'],
methodCalls: [
[
'x:Account/get',
{ accountId: 'd', ids: [], properties: ['name'] },
'p',
],
],
});
});
it('forbidden 이면 관리 권한이 없는 직원 — 그대로 통과한다', async () => {
fetchMock
.mockResolvedValueOnce(json(sessionBody))
.mockResolvedValueOnce(
probeAnswer(['error', { type: 'forbidden' }, 'p']),
);
await expect(client.assertNoManagementAccess(ARA)).resolves.toBe(
undefined,
);
expect(errorLog).not.toHaveBeenCalled();
expect(warnLog).not.toHaveBeenCalled();
});
it('해석할 수 없는 응답(unknownMethod·요청 형식 거부 400·빈 응답)은 막지 않는다 — error 로그는 한 번만', async () => {
fetchMock
.mockResolvedValueOnce(json(sessionBody))
.mockResolvedValueOnce(
probeAnswer(['error', { type: 'unknownMethod' }, 'p']),
)
.mockResolvedValueOnce(
json({ type: 'urn:ietf:params:jmap:error:notRequest' }, 400),
)
.mockResolvedValueOnce(json({ methodResponses: [] }));
for (let i = 0; i < 3; i += 1) {
await expect(client.assertNoManagementAccess(ARA)).resolves.toBe(
undefined,
);
}
expect(fetchMock).toHaveBeenCalledTimes(4);
expect(errorLog).toHaveBeenCalledTimes(1);
expect(errorLog).toHaveBeenCalledWith(
expect.stringMatching(/^\[MAIL_001\].*error\/unknownMethod/),
);
});
it('엔진 장애는 다른 호출과 같다 — 5xx 는 MAIL_001, 429 는 REQ_001', async () => {
fetchMock
.mockResolvedValueOnce(json(sessionBody))
.mockResolvedValueOnce(json({}, 502))
.mockResolvedValueOnce(json({}, 429));
expect(await codeOf(client.assertNoManagementAccess(ARA))).toBe(
'MAIL_001',
);
expect(await codeOf(client.assertNoManagementAccess(ARA))).toBe(
'REQ_001',
);
});
it('막는 계정은 확인 호출도 하지 않고 AUTH_001', async () => {
expect(
await codeOf(client.assertNoManagementAccess('admin@arasystem.test')),
).toBe('AUTH_001');
expect(fetchMock).not.toHaveBeenCalled();
});
});
describe('메서드 호출 (call)', () => {
beforeEach(() => {
fetchMock.mockResolvedValueOnce(json(sessionBody));
});
it('세션의 accountId 로 호출을 만들고 내부 주소의 API 경로로 보낸다', async () => {
const responses = [['Mailbox/get', { list: [] }, 'm']];
fetchMock.mockResolvedValueOnce(json({ methodResponses: responses }));
const result = await client.call(ARA, (accountId) => [
['Mailbox/get', { accountId }, 'm'],
]);
expect(result).toEqual(responses);
const { url, init } = requestAt(1);
expect(url).toBe('http://mail:8080/jmap/');
expect(JSON.parse(init.body as string)).toEqual({
using: [
'urn:ietf:params:jmap:core',
'urn:ietf:params:jmap:mail',
'urn:ietf:params:jmap:submission',
],
methodCalls: [['Mailbox/get', { accountId: 'd' }, 'm']],
});
});
it('호출별 추가 기능은 그 요청의 using 에만 붙인다', async () => {
fetchMock.mockResolvedValueOnce(json({ methodResponses: [] }));
await client.call(ARA, () => [], {
capabilities: ['urn:ietf:params:jmap:quota'],
});
const { init } = requestAt(1);
expect(
(JSON.parse(init.body as string) as { using: string[] }).using,
).toEqual([
'urn:ietf:params:jmap:core',
'urn:ietf:params:jmap:mail',
'urn:ietf:params:jmap:submission',
'urn:ietf:params:jmap:quota',
]);
});
it('제한 시간은 호출별로 늘릴 수 있다 — 발송처럼 큰 요청 (기본 10초)', async () => {
jest.useFakeTimers();
fetchMock.mockImplementation(
(_url: string, init: RequestInit) =>
new Promise((resolve, reject) => {
if (!init.body) {
resolve(json(sessionBody));
return;
}
init.signal?.addEventListener('abort', () =>
reject(new DOMException('aborted', 'AbortError')),
);
}),
);
let settled = false;
const pending = client
.call(ARA, () => [], { timeoutMs: JMAP_SEND_TIMEOUT_MS })
.catch((error: unknown) => error)
.finally(() => {
settled = true;
});
await jest.advanceTimersByTimeAsync(10_000);
expect(settled).toBe(false);
await jest.advanceTimersByTimeAsync(JMAP_SEND_TIMEOUT_MS - 10_000);
const error = (await pending) as BusinessException;
expect(error.code).toBe('MAIL_001');
expect(error.context).toMatchObject({ reason: 'timeout' });
});
it('요청 자체가 거부되면(notRequest) 지정한 코드, 지정이 없으면 MAIL_001', async () => {
const notRequest = {
type: 'urn:ietf:params:jmap:error:notRequest',
status: 400,
};
fetchMock.mockResolvedValueOnce(json(notRequest, 400));
expect(
await codeOf(
client.call(ARA, () => [], { invalidRequestCode: 'VAL_001' }),
),
).toBe('VAL_001');
fetchMock.mockResolvedValueOnce(json(notRequest, 400));
expect(await codeOf(client.call(ARA, () => []))).toBe('MAIL_001');
});
it('엔진 5xx 는 MAIL_001, 429 는 REQ_001, 형식이 깨진 응답은 MAIL_001', async () => {
fetchMock.mockResolvedValueOnce(json({}, 503));
expect(await codeOf(client.call(ARA, () => []))).toBe('MAIL_001');
fetchMock.mockResolvedValueOnce(json({}, 429));
expect(await codeOf(client.call(ARA, () => []))).toBe('REQ_001');
fetchMock.mockResolvedValueOnce(json({ unexpected: true }));
expect(await codeOf(client.call(ARA, () => []))).toBe('MAIL_001');
fetchMock.mockResolvedValueOnce(new Response('<html>', { status: 200 }));
expect(await codeOf(client.call(ARA, () => []))).toBe('MAIL_001');
});
it('호출 중 대리 접근이 거부되면 캐시한 세션을 지우고 AUTH_001', async () => {
await client.getSession(ARA);
fetchMock
.mockResolvedValueOnce(json({}, 401))
.mockResolvedValueOnce(json({}, 200));
expect(await codeOf(client.call(ARA, () => []))).toBe('AUTH_001');
expect(cache.del).toHaveBeenCalledWith(`mail:jmap-session:${ARA}`);
});
});
describe('첨부 업로드·다운로드', () => {
beforeEach(() => {
fetchMock.mockResolvedValueOnce(json(sessionBody));
});
it('업로드 경로의 accountId 를 채우고 본문(Blob)을 그대로 넘긴다 — 파일 Blob 은 fetch 가 조금씩 읽는다', async () => {
fetchMock.mockResolvedValueOnce(
json({ accountId: 'd', blobId: 'blob1', type: 'text/plain', size: 3 }),
);
const content = new Blob(['abc'], { type: 'text/plain' });
const result = await client.upload(ARA, content, 'text/plain');
expect(result).toEqual({ blobId: 'blob1', type: 'text/plain', size: 3 });
const { url, init } = requestAt(1);
expect(url).toBe('http://mail:8080/jmap/upload/d/');
expect(init.body).toBe(content);
expect((init.headers as Record<string, string>)['content-type']).toBe(
'text/plain',
);
});
it('엔진이 크기를 알려 주지 않으면 보낸 Blob 의 크기를 쓴다', async () => {
fetchMock.mockResolvedValueOnce(json({ blobId: 'blob1' }));
const result = await client.upload(
ARA,
new Blob(['abcd']),
'application/pdf',
);
expect(result).toEqual({
blobId: 'blob1',
type: 'application/pdf',
size: 4,
});
});
it('엔진의 업로드 한도 초과는 FILE_002', async () => {
fetchMock.mockResolvedValueOnce(json({}, 413));
expect(
await codeOf(client.upload(ARA, new Blob(['abc']), 'text/plain')),
).toBe('FILE_002');
});
it('다운로드 경로의 변수를 퍼센트 인코딩해 채우고 스트림으로 돌려준다', async () => {
fetchMock.mockResolvedValueOnce(
new Response('파일 내용', {
status: 200,
headers: { 'content-length': String(Buffer.byteLength('파일 내용')) },
}),
);
const download = await client.download(
ARA,
'blob1',
'체크 리스트.txt',
'application/octet-stream',
);
expect(requestAt(1).url).toBe(
`http://mail:8080/jmap/download/d/blob1/${encodeURIComponent('체크 리스트.txt')}?accept=application%2Foctet-stream`,
);
expect(download.length).toBe(Buffer.byteLength('파일 내용'));
const chunks: Buffer[] = [];
for await (const chunk of download.stream) chunks.push(chunk as Buffer);
expect(Buffer.concat(chunks).toString('utf8')).toBe('파일 내용');
});
it('압축을 요청하지 않고, 압축되어 오면 길이를 모르는 것으로 둔다 (잘린 다운로드 방지)', async () => {
fetchMock.mockResolvedValueOnce(
new Response('abc', {
status: 200,
headers: { 'content-length': '2', 'content-encoding': 'gzip' },
}),
);
const download = await client.download(ARA, 'b', 'a.txt', 'text/plain');
expect(
(requestAt(1).init.headers as Record<string, string>)[
'accept-encoding'
],
).toBe('identity');
expect(download.length).toBeNull();
download.stream.destroy();
});
it('없는 blob 은 RES_001', async () => {
fetchMock.mockResolvedValueOnce(json({}, 404));
expect(
await codeOf(
client.download(ARA, 'nope', 'a', 'application/octet-stream'),
),
).toBe('RES_001');
});
it('작은 부분 읽기는 한도를 넘으면 null 이다', async () => {
fetchMock.mockResolvedValueOnce(
new Response(Buffer.alloc(2048), {
status: 200,
headers: { 'content-length': '2048' },
}),
);
await expect(
client.downloadBytes(ARA, 'blob1', 1024),
).resolves.toBeNull();
fetchMock.mockResolvedValueOnce(
new Response(Buffer.from([1, 2, 3]), { status: 200 }),
);
await expect(client.downloadBytes(ARA, 'blob1', 1024)).resolves.toEqual(
Buffer.from([1, 2, 3]),
);
});
});
});
describe('JMAP URL 유틸', () => {
it('공개 주소의 스킴·호스트를 떼고 경로·쿼리·템플릿 변수를 그대로 둔다', () => {
expect(toRelativeReference('http://localhost:8180/jmap/')).toBe('/jmap/');
expect(
toRelativeReference(
'https://mx1.arasystem.it.kr/jmap/download/{accountId}/{blobId}/{name}?accept={type}',
),
).toBe('/jmap/download/{accountId}/{blobId}/{name}?accept={type}');
// 상대 경로로 오는 경우
expect(toRelativeReference('/jmap/')).toBe('/jmap/');
expect(toRelativeReference('jmap/')).toBe('/jmap/');
});
it('템플릿에 없는 변수는 그대로 두고, 프로토타입 속성 이름에 속지 않는다', () => {
expect(
expandTemplate('/x/{accountId}/{constructor}', { accountId: 'd' }),
).toBe('/x/d/{constructor}');
});
it('Basic 인증 값은 UTF-8 로 인코딩한다', () => {
expect(decodeBasic(basicAuthorization('a@b.c', '비밀'))).toBe('a@b.c:비밀');
});
});
describe('관리 권한 확인 응답 해석 (classifyManagementProbe)', () => {
it('성공은 관리 권한 있음, forbidden 은 없음', () => {
expect(
classifyManagementProbe(['x:Account/get', { list: [] }, 'p']),
).toEqual({ kind: 'management' });
expect(
classifyManagementProbe(['error', { type: 'forbidden' }, 'p']),
).toEqual({ kind: 'none' });
});
it('그 밖은 판단하지 않는다 (로그용 요약만)', () => {
expect(
classifyManagementProbe(['error', { type: 'unknownMethod' }, 'p']),
).toEqual({ kind: 'unclear', detail: 'error/unknownMethod' });
expect(classifyManagementProbe(['Other/get', {}, 'p'])).toEqual({
kind: 'unclear',
detail: 'Other/get',
});
expect(classifyManagementProbe(undefined)).toEqual({
kind: 'unclear',
detail: 'undefined',
});
});
});
@@ -0,0 +1,764 @@
import { CACHE_MANAGER } from '@nestjs/cache-manager';
import { Inject, Injectable, Logger } from '@nestjs/common';
import type { Cache } from 'cache-manager';
import { Readable } from 'stream';
import type { ReadableStream as NodeReadableStream } from 'stream/web';
import { BusinessException } from '@/common/errors/business.exception';
import type { ErrorCode } from '@/common/errors/error-codes';
import {
MAIL_SETTINGS,
isMailEngineConfigured,
type ConfiguredMailSettings,
type MailSettings,
} from '@/modules/mail/mail.settings';
import { traceTag } from '@/shared/logger/request-context';
import { maskEmail } from '@/shared/utils/mask';
import type {
JmapInvocation,
JmapProblem,
JmapResponseBody,
JmapSessionResource,
JmapUploadResult,
} from './jmap.types';
/** 사용하는 JMAP 기능 — 메일 읽기·쓰기와 발송 */
export const JMAP_USING = [
'urn:ietf:params:jmap:core',
'urn:ietf:params:jmap:mail',
'urn:ietf:params:jmap:submission',
] as const;
const MAIL_CAPABILITY = 'urn:ietf:params:jmap:mail';
const CORE_CAPABILITY = 'urn:ietf:params:jmap:core';
const SESSION_PATH = '/jmap/session';
/**
* 관리 권한 확인에 쓰는 관리 API 메서드 (Stalwart 의 `x:` 관리 객체) — `ids: []` 로 불러 **데이터는 읽지 않는다**.
* 관리자 세션은 성공하고, 직원 세션은 `forbidden` 이다 (Stalwart 0.16 개발 엔진에서 확인).
*/
const MANAGEMENT_PROBE_METHOD = 'x:Account/get';
/** 관리 권한 확인 결과 — 관리 권한 있음 · 없음 · 해석할 수 없음(엔진 API 변경 등, detail 은 로그용 요약) */
export type ManagementProbeResult =
| { readonly kind: 'management' }
| { readonly kind: 'none' }
| { readonly kind: 'unclear'; readonly detail: string };
/**
* 관리 권한 확인 호출의 메서드 응답을 해석한다 (순수 함수).
* 성공 = 관리 권한 있음, `forbidden` = 없음. 그 밖(unknownMethod·다른 오류·빈 응답)은 판단하지 않는다.
*/
export function classifyManagementProbe(
invocation: JmapInvocation | undefined,
): ManagementProbeResult {
const [name, args] = invocation ?? [];
if (name === MANAGEMENT_PROBE_METHOD) return { kind: 'management' };
if (name === 'error') {
const type = (args as JmapProblem | undefined)?.type;
return type === 'forbidden'
? { kind: 'none' }
: { kind: 'unclear', detail: `error/${String(type)}` };
}
return { kind: 'unclear', detail: String(name) };
}
/** 일반 API 호출 제한 시간 — 응답 본문을 다 읽을 때까지 */
const REQUEST_TIMEOUT_MS = 10_000;
/**
* 발송(초안 생성 + 제출) 제한 시간. 엔진이 첨부(최대 합계 25MB)를 붙여 메시지를 만드는 시간을 넉넉히 주되,
* 클라이언트의 발송 시간 한도(60초)보다 짧게 둔다 — 백엔드가 먼저 MAIL_001 로 답해야 사용자가 같은 키로 다시 보낼 수 있다.
*/
export const JMAP_SEND_TIMEOUT_MS = 40_000;
/** 첨부 업로드 제한 시간 — 임시 파일에서 엔진으로 흘려보낸다 (내부망 기준 20MB 에 충분한 여유) */
const UPLOAD_TIMEOUT_MS = 120_000;
/** 다운로드는 응답 헤더까지만 제한한다 — 본문은 클라이언트로 흘려보내는 중이라 시간을 정할 수 없다 */
const DOWNLOAD_HEADERS_TIMEOUT_MS = 15_000;
/** 작은 부분(본문 내장 이미지)을 메모리로 읽을 때의 전체 제한 시간 */
const SMALL_DOWNLOAD_TIMEOUT_MS = 10_000;
/** 직원별 JMAP 세션 캐시 — 계정 삭제가 늦게 반영되는 상한이기도 하다 */
const SESSION_CACHE_TTL_MS = 5 * 60_000;
const HTTP_UNAUTHORIZED = 401;
const HTTP_BAD_REQUEST = 400;
const HTTP_FORBIDDEN = 403;
const HTTP_NOT_FOUND = 404;
const HTTP_PAYLOAD_TOO_LARGE = 413;
const HTTP_TOO_MANY_REQUESTS = 429;
/** 직원 메일함에 접근하는 데 필요한 세션 정보 (캐시에 저장되므로 평범한 객체로 둔다) */
export interface JmapSession {
/** 엔진이 아는 정식 계정 주소 */
readonly username: string;
readonly accountId: string;
/** MAIL_JMAP_URL 에 붙일 경로 — 세션 응답의 공개 주소에서 경로만 떼어 낸 것 */
readonly apiPath: string;
readonly downloadPath: string;
readonly uploadPath: string;
/** 엔진의 메일 한 통 첨부 합계 한도(바이트) — 알려 주지 않으면 null (캐시된 옛 세션에는 없을 수 있다) */
readonly maxAttachmentsBytes?: number | null;
}
export interface JmapDownload {
readonly stream: Readable;
readonly length: number | null;
}
export interface JmapCallOptions {
/**
* 엔진이 요청 자체를 해석하지 못했을 때(HTTP 400 `notRequest`) 쓸 코드.
* Stalwart 는 형식이 맞지 않는 id 가 필터에 들어오면 요청 전체를 거부한다 —
* 사용자가 보낸 id 가 들어가는 호출은 MAIL_001(엔진 장애) 대신 이 코드로 응답한다.
*/
readonly invalidRequestCode?: ErrorCode;
/**
* 이 호출에만 추가로 선언할 기능 (예: 쿼터 `urn:ietf:params:jmap:quota`).
* 모든 요청에 넣지 않는 이유: 엔진이 모르는 기능이 `using` 에 있으면 요청 전체가 거부된다.
*/
readonly capabilities?: readonly string[];
/** 제한 시간(ms) — 발송처럼 큰 요청만 늘린다 (기본 10초) */
readonly timeoutMs?: number;
}
/** 'http://localhost:8180/jmap/' → '/jmap/' (스킴·호스트를 떼고 경로·쿼리만 남긴다) */
export function toRelativeReference(url: string): string {
const withoutOrigin = url.replace(/^[a-z][a-z0-9+.-]*:\/\/[^/?#]*/i, '');
return withoutOrigin.startsWith('/') ? withoutOrigin : `/${withoutOrigin}`;
}
/** URL 템플릿의 `{변수}` 를 퍼센트 인코딩한 값으로 채운다 (RFC 6570 단순 확장) */
export function expandTemplate(
template: string,
values: Record<string, string>,
): string {
return template.replace(/\{(\w+)\}/g, (placeholder, name: string) =>
Object.prototype.hasOwnProperty.call(values, name)
? encodeURIComponent(values[name])
: placeholder,
);
}
export function basicAuthorization(user: string, password: string): string {
return `Basic ${Buffer.from(`${user}:${password}`, 'utf8').toString('base64')}`;
}
function sessionCacheKey(user: string): string {
return `mail:jmap-session:${user}`;
}
/** 연결이 반환되도록 읽지 않을 본문을 버린다 */
async function discard(response: Response): Promise<void> {
try {
await response.body?.cancel();
} catch {
// 이미 닫힌 본문 — 무시한다
}
}
function describeError(error: unknown): string {
if (!(error instanceof Error)) return String(error);
const { cause } = error as { cause?: unknown };
if (cause instanceof Error) {
const code = (cause as NodeJS.ErrnoException).code ?? cause.name;
return `${error.message} (${code}: ${cause.message})`;
}
return error.message;
}
function engineFailure(context: Record<string, unknown>): BusinessException {
return new BusinessException('MAIL_001', { context });
}
async function readJson<T>(response: Response, label: string): Promise<T> {
try {
return (await response.json()) as T;
} catch {
throw engineFailure({ reason: 'malformed-response', call: label });
}
}
/** 실패 응답의 problem details 를 읽는다 (JSON 이 아니면 null) */
async function readProblem(response: Response): Promise<JmapProblem | null> {
try {
const body: unknown = await response.json();
return typeof body === 'object' && body !== null ? body : null;
} catch {
return null;
}
}
/** 계정의 메일 기능이 알려 주는 첨부 합계 한도 (`maxSizeAttachmentsPerEmail`) — 없거나 이상하면 null */
function readAttachmentsLimit(
resource: JmapSessionResource,
accountId: string,
): number | null {
const limit =
resource.accounts?.[accountId]?.accountCapabilities?.[MAIL_CAPABILITY]
?.maxSizeAttachmentsPerEmail;
return typeof limit === 'number' && Number.isSafeInteger(limit) && limit > 0
? limit
: null;
}
function parseContentLength(value: string | null): number | null {
if (value === null) return null;
const length = Number(value);
return Number.isSafeInteger(length) && length >= 0 ? length : null;
}
/**
* Stalwart 내부 JMAP 에 붙는 얇은 클라이언트 (Node 내장 fetch).
*
* - 로그인 확인: `<직원 주소>:<비밀번호>` 로 세션 조회 (200 / 401)
* - 메일함 접근: 게이트웨이 대리 접근 `<직원 주소>%<게이트웨이 주소>` : `<게이트웨이 비밀번호>`
* - 세션 응답의 URL 은 공개 주소 기준이므로 경로만 떼어 MAIL_JMAP_URL 에 붙인다 (mail/README.md)
*
* 인증 헤더·비밀번호는 어떤 경우에도 로그에 남기지 않는다.
*/
@Injectable()
export class JmapClient {
private readonly logger = new Logger(JmapClient.name);
/** 관리 권한 확인 응답을 해석하지 못했다는 error 로그를 이미 남겼는가 (엔진 API 가 바뀐 경우 — 한 번만 알린다) */
private managementProbeUnclearReported = false;
constructor(
@Inject(MAIL_SETTINGS) private readonly settings: MailSettings,
@Inject(CACHE_MANAGER) private readonly cache: Cache,
) {}
/**
* 직원 자격 증명을 확인한다.
* @returns 맞으면 엔진이 아는 정식 계정 주소, 틀리면 null. 엔진 장애는 MAIL_001.
*/
async verifyCredentials(
email: string,
password: string,
): Promise<string | null> {
return this.exchange(
SESSION_PATH,
{ headers: { authorization: basicAuthorization(email, password) } },
REQUEST_TIMEOUT_MS,
'verify',
async (response) => {
if (
response.status === HTTP_UNAUTHORIZED ||
response.status === HTTP_FORBIDDEN
) {
await discard(response);
return null;
}
await this.assertOk(response, 'verify');
const session = await readJson<JmapSessionResource>(response, 'verify');
return session.username?.trim().toLowerCase() || email;
},
);
}
/** 대리 접근 세션 — 직원별로 잠깐 캐시한다. `fresh` 면 캐시를 건너뛰고 계정 상태를 다시 확인한다 */
async getSession(
user: string,
options: { fresh?: boolean } = {},
): Promise<JmapSession> {
// 막는 계정(관리자 등)은 엔진에 대리 접근을 요청하지도 않는다
this.assertNotBlocked(user);
const key = sessionCacheKey(user);
if (!options.fresh) {
const cached = await this.cache.get<JmapSession>(key);
if (cached) return cached;
}
const session = await this.exchange(
SESSION_PATH,
{ headers: { authorization: this.impersonation(user) } },
REQUEST_TIMEOUT_MS,
'session',
async (response) => {
await this.assertImpersonationAccepted(response, user);
await this.assertOk(response, 'session');
return this.toSession(
await readJson<JmapSessionResource>(response, 'session'),
user,
);
},
);
// 엔진이 알려 준 정식 주소로 한 번 더 — 로컬 이름(admin)이나 별칭으로 들어와도 막는 계정이면 캐시하지 않는다
this.assertNotBlocked(session.username);
await this.cache.set(key, session, SESSION_CACHE_TTL_MS);
return session;
}
/**
* 대상 계정에 메일 엔진의 **관리 권한**이 있으면 AUTH_001 — 웹메일로 관리자 계정을 다루지 않는다.
* 게이트웨이는 관리자도 대리 접근할 수 있고 그 세션은 관리 권한을 가지므로(확인함), 정적 목록(blockedAccounts)에 없는
* 관리자도 로그인·리프레시 때 걸러 낸다. 데이터를 읽지 않는 관리 호출 한 번으로 판별한다 (MANAGEMENT_PROBE_METHOD).
*
* 해석할 수 없는 응답(엔진 API 변경으로 unknownMethod·요청 형식 거부(400) 등)은 막지 않는다 — 전 직원 로그인을 막지 않기 위해서다.
* 대신 error 로그를 한 번 남긴다 (관리자 `<MAIL_ADMIN_LOCAL_PART>@<도메인>` 은 정적 목록이 계속 막는다).
* 엔진 장애·한도는 다른 호출과 같다 (MAIL_001 · REQ_001).
*/
async assertNoManagementAccess(user: string): Promise<void> {
const session = await this.getSession(user);
const body = JSON.stringify({
using: [CORE_CAPABILITY],
methodCalls: [
[
MANAGEMENT_PROBE_METHOD,
{ accountId: session.accountId, ids: [], properties: ['name'] },
'p',
],
],
});
const result = await this.exchange(
session.apiPath,
{
method: 'POST',
headers: {
authorization: this.impersonation(user),
'content-type': 'application/json',
},
body,
},
REQUEST_TIMEOUT_MS,
'management-check',
async (response): Promise<ManagementProbeResult> => {
await this.assertImpersonationAccepted(response, user);
// 요청 전체 거부 — 엔진이 이 관리 호출의 형식을 바꾼 경우다 (Stalwart 는 인자 형식 오류를 400 으로 거부한다)
if (response.status === HTTP_BAD_REQUEST) {
const problem = await readProblem(response);
return {
kind: 'unclear',
detail: `http-400/${problem?.type ?? 'unknown'}`,
};
}
await this.assertOk(response, 'management-check');
const parsed = await readJson<JmapResponseBody>(
response,
'management-check',
);
return classifyManagementProbe(parsed.methodResponses?.[0]);
},
);
if (result.kind === 'none') return;
if (result.kind === 'management') {
await this.cache.del(sessionCacheKey(user));
this.logger.warn(
`[AUTH_001]${traceTag()} 메일 엔진의 관리 권한이 있는 계정이라 웹메일로 다루지 않습니다: user=${maskEmail(user)}`,
);
throw new BusinessException('AUTH_001', {
context: { reason: 'management-account', user: maskEmail(user) },
});
}
if (!this.managementProbeUnclearReported) {
this.managementProbeUnclearReported = true;
this.logger.error(
`[MAIL_001]${traceTag()} 관리 권한 확인(${MANAGEMENT_PROBE_METHOD}) 응답을 해석하지 못해 이 확인 없이 진행합니다 — 메일 엔진 API 가 바뀌었는지 확인하세요. (응답: ${result.detail})`,
);
}
}
/**
* JMAP 메서드를 호출한다. accountId 는 세션에서 정해지므로 호출 목록을 함수로 받는다.
* 메서드 단위 오류(`["error", …]`)는 그대로 돌려주고, 해석은 호출한 쪽이 한다.
*/
async call(
user: string,
build: (accountId: string) => JmapInvocation[],
options: JmapCallOptions = {},
): Promise<JmapInvocation[]> {
const session = await this.getSession(user);
const body = JSON.stringify({
using: [...JMAP_USING, ...(options.capabilities ?? [])],
methodCalls: build(session.accountId),
});
return this.exchange(
session.apiPath,
{
method: 'POST',
headers: {
authorization: this.impersonation(user),
'content-type': 'application/json',
},
body,
},
options.timeoutMs ?? REQUEST_TIMEOUT_MS,
'api',
async (response) => {
await this.assertImpersonationAccepted(response, user);
if (!response.ok && response.status !== HTTP_TOO_MANY_REQUESTS) {
const problem = await readProblem(response);
const rejectedRequest =
response.status === 400 &&
(problem?.type ?? '').endsWith(':notRequest');
if (rejectedRequest && options.invalidRequestCode) {
throw new BusinessException(options.invalidRequestCode, {
context: { reason: 'request-rejected', jmapType: 'notRequest' },
});
}
throw engineFailure({
reason: 'http-error',
call: 'api',
status: response.status,
jmapType: problem?.type ?? 'unknown',
});
}
await this.assertOk(response, 'api');
const parsed = await readJson<JmapResponseBody>(response, 'api');
if (!Array.isArray(parsed.methodResponses)) {
throw engineFailure({ reason: 'malformed-response', call: 'api' });
}
return parsed.methodResponses;
},
);
}
/**
* 첨부를 업로드한다 → blob id.
* 본문은 Blob 으로 받는다 — 임시 파일을 연 Blob(fs.openAsBlob)을 넘기면 파일을 조금씩 읽어 흘려보내므로
* 파일 전체를 메모리에 올리지 않는다. 크기를 알고 있어 Content-Length 도 정확히 나간다.
*/
async upload(
user: string,
content: Blob,
type: string,
): Promise<{ blobId: string; type: string; size: number }> {
const session = await this.getSession(user);
const path = expandTemplate(session.uploadPath, {
accountId: session.accountId,
});
return this.exchange(
path,
{
method: 'POST',
headers: {
authorization: this.impersonation(user),
'content-type': type,
},
body: content,
},
UPLOAD_TIMEOUT_MS,
'upload',
async (response) => {
await this.assertImpersonationAccepted(response, user);
if (response.status === HTTP_PAYLOAD_TOO_LARGE) {
await discard(response);
throw new BusinessException('FILE_002', {
context: { reason: 'mail-engine-upload-limit' },
});
}
if (!response.ok && response.status !== HTTP_TOO_MANY_REQUESTS) {
throw this.uploadFailure(
response.status,
await readProblem(response),
);
}
await this.assertOk(response, 'upload');
const result = await readJson<JmapUploadResult>(response, 'upload');
if (typeof result.blobId !== 'string' || result.blobId === '') {
throw engineFailure({ reason: 'malformed-response', call: 'upload' });
}
return {
blobId: result.blobId,
type: result.type ?? type,
size: typeof result.size === 'number' ? result.size : content.size,
};
},
);
}
/** blob 을 스트림으로 내려받는다 (응답 헤더까지만 시간 제한) */
async download(
user: string,
blobId: string,
name: string,
type: string,
): Promise<JmapDownload> {
const session = await this.getSession(user);
const path = expandTemplate(session.downloadPath, {
accountId: session.accountId,
blobId,
name,
type,
});
const response = await this.exchange(
path,
{
headers: {
authorization: this.impersonation(user),
// 압축을 받지 않는다 — fetch 가 자동으로 풀면 content-length(압축 크기)가 실제 길이와 달라진다
'accept-encoding': 'identity',
},
},
DOWNLOAD_HEADERS_TIMEOUT_MS,
'download',
async (response) => {
await this.assertImpersonationAccepted(response, user);
if (response.status === HTTP_NOT_FOUND) {
await discard(response);
throw new BusinessException('RES_001', {
context: { reason: 'blob-not-found' },
});
}
await this.assertOk(response, 'download');
if (!response.body) {
throw engineFailure({ reason: 'empty-body', call: 'download' });
}
return response;
},
);
// 그래도 압축되어 왔다면 길이를 모르는 것으로 둔다 (잘못된 Content-Length 는 파일을 자른다)
const encoding = response.headers.get('content-encoding');
const compressed = encoding !== null && encoding !== 'identity';
return {
// fetch 의 웹 스트림을 Node 스트림으로 — 파괴(destroy)하면 엔진 연결도 끊긴다
stream: Readable.fromWeb(response.body as NodeReadableStream<Uint8Array>),
length: compressed
? null
: parseContentLength(response.headers.get('content-length')),
};
}
/**
* 작은 blob 을 메모리로 읽는다. `maxBytes` 를 넘으면 즉시 멈추고 null.
* (본문 내장 이미지를 data: URI 로 넣을 때 쓴다)
*/
async downloadBytes(
user: string,
blobId: string,
maxBytes: number,
): Promise<Buffer | null> {
const { stream, length } = await this.download(
user,
blobId,
'inline',
'application/octet-stream',
);
if (length !== null && length > maxBytes) {
stream.destroy();
return null;
}
const timer = setTimeout(
() => stream.destroy(new Error('inline download timeout')),
SMALL_DOWNLOAD_TIMEOUT_MS,
);
try {
const chunks: Buffer[] = [];
let total = 0;
for await (const chunk of stream) {
const buffer = Buffer.isBuffer(chunk)
? chunk
: Buffer.from(chunk as Uint8Array);
total += buffer.length;
if (total > maxBytes) {
stream.destroy();
return null;
}
chunks.push(buffer);
}
return Buffer.concat(chunks);
} catch (error) {
throw engineFailure({
reason: 'download-interrupted',
call: 'download',
cause: describeError(error),
});
} finally {
clearTimeout(timer);
}
}
private requireConfigured(): ConfiguredMailSettings {
if (!isMailEngineConfigured(this.settings)) {
throw engineFailure({ reason: 'mail-not-configured' });
}
return this.settings;
}
/** 막는 계정(관리자·MAIL_BLOCKED_ACCOUNTS)이면 AUTH_001 — 계정을 쓸 수 없는 것과 같게 다룬다 */
private assertNotBlocked(address: string): void {
if (this.settings.blockedAccounts.has(address.trim().toLowerCase())) {
throw new BusinessException('AUTH_001', {
context: { reason: 'blocked-account', user: maskEmail(address) },
});
}
}
private impersonation(user: string): string {
const { gatewayUser, gatewayPassword } = this.requireConfigured();
return basicAuthorization(`${user}%${gatewayUser}`, gatewayPassword);
}
/**
* fetch 한 번을 제한 시간 안에서 수행한다. `read` 가 상태를 확인하고 본문을 읽는다.
* 네트워크 오류·시간 초과는 MAIL_001 로 바꾼다.
*/
private async exchange<T>(
path: string,
init: RequestInit,
timeoutMs: number,
label: string,
read: (response: Response) => Promise<T>,
): Promise<T> {
const { jmapBaseUrl } = this.requireConfigured();
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), timeoutMs);
try {
const response = await fetch(`${jmapBaseUrl}${path}`, {
...init,
// 엔진이 다른 곳으로 보내면 따라가지 않는다 (인증 헤더가 다른 호스트로 가지 않게)
redirect: 'manual',
signal: controller.signal,
});
return await read(response);
} catch (error) {
if (error instanceof BusinessException) throw error;
throw engineFailure({
reason: controller.signal.aborted ? 'timeout' : 'network',
call: label,
cause: describeError(error),
});
} finally {
clearTimeout(timer);
}
}
/** 2xx 가 아니면 코드로 바꿔 던진다 — 429 는 REQ_001, 나머지는 엔진 장애 */
private async assertOk(response: Response, label: string): Promise<void> {
if (response.ok) return;
await discard(response);
if (response.status === HTTP_TOO_MANY_REQUESTS) {
throw new BusinessException('REQ_001', {
context: { reason: 'mail-engine-rate-limit', call: label },
});
}
throw engineFailure({
reason: 'http-error',
call: label,
status: response.status,
});
}
/**
* 대리 접근 거부를 해석한다 (Stalwart v0.16 개발 엔진에서 확인):
* - **401**: 대상 계정이 없거나(다른 도메인 주소 포함) 게이트웨이 비밀번호가 틀렸다 — 응답이 같다.
* 그래서 게이트웨이로 직접 로그인해 구분한다: 게이트웨이도 거부되면 설정 문제(MAIL_001), 아니면 계정 없음(AUTH_001)
* - **403** "You do not have enough permissions": 인증은 됐지만 **대리 접근 권한이 없다** — 게이트웨이 설정 문제다.
* 계정 없음으로 보면 설정 실수 한 번에 전 직원의 세션이 폐기된다(리프레시가 AUTH_001 → 세션 폐기).
* 그래서 MAIL_001(503) + error 로그로 답하고 세션은 건드리지 않는다.
*/
private async assertImpersonationAccepted(
response: Response,
user: string,
): Promise<void> {
if (
response.status !== HTTP_UNAUTHORIZED &&
response.status !== HTTP_FORBIDDEN
) {
return;
}
await discard(response);
await this.cache.del(sessionCacheKey(user));
if (response.status === HTTP_FORBIDDEN) {
this.logger.error(
`[MAIL_001]${traceTag()} 메일 게이트웨이 계정에 대리 접근 권한이 없습니다(403) — 계정 문제가 아니라 설정 문제로 보고 세션을 유지합니다. 게이트웨이 계정의 대리 접근(impersonate) 권한과 메일 엔진 설정(mail/scripts/setup.mjs)을 확인하세요.`,
);
throw engineFailure({
reason: 'impersonation-forbidden',
user: maskEmail(user),
});
}
if (!(await this.gatewayAccepted())) {
this.logger.error(
`[MAIL_001]${traceTag()} 메일 게이트웨이 계정의 인증이 거부되었습니다. MAIL_GATEWAY_USER·MAIL_GATEWAY_PASSWORD 와 메일 엔진 설정(mail/scripts/setup.mjs)을 확인하세요.`,
);
throw engineFailure({ reason: 'gateway-rejected' });
}
throw new BusinessException('AUTH_001', {
context: {
reason: 'impersonation-rejected',
status: response.status,
user: maskEmail(user),
},
});
}
private async gatewayAccepted(): Promise<boolean> {
const { gatewayUser, gatewayPassword } = this.requireConfigured();
return this.exchange(
SESSION_PATH,
{
headers: {
authorization: basicAuthorization(gatewayUser, gatewayPassword),
},
},
REQUEST_TIMEOUT_MS,
'gateway-check',
async (response) => {
await discard(response);
return response.status !== HTTP_UNAUTHORIZED;
},
);
}
private uploadFailure(
status: number,
problem: JmapProblem | null,
): BusinessException {
const context = {
reason: 'http-error',
call: 'upload',
status,
jmapType: problem?.type ?? 'unknown',
};
if (problem?.limit === 'maxSizeUpload') {
return new BusinessException('FILE_002', { context });
}
if (/quota/i.test(`${problem?.type ?? ''} ${problem?.detail ?? ''}`)) {
return new BusinessException('MAIL_003', { context });
}
return engineFailure(context);
}
private toSession(resource: JmapSessionResource, user: string): JmapSession {
const accountId = resource.primaryAccounts?.[MAIL_CAPABILITY];
const { apiUrl, downloadUrl, uploadUrl } = resource;
if (!accountId || !apiUrl || !downloadUrl || !uploadUrl) {
throw engineFailure({ reason: 'session-incomplete', call: 'session' });
}
return {
username: resource.username?.trim().toLowerCase() || user,
accountId,
apiPath: toRelativeReference(apiUrl),
downloadPath: toRelativeReference(downloadUrl),
uploadPath: toRelativeReference(uploadUrl),
maxAttachmentsBytes: readAttachmentsLimit(resource, accountId),
};
}
}
@@ -0,0 +1,91 @@
import {
isMissingAttachmentBlob,
jmapFailure,
mapJmapErrorType,
} from './jmap.errors';
describe('JMAP 오류 매핑', () => {
it.each([
['notFound', 'RES_001'],
['invalidArguments', 'VAL_001'],
['invalidProperties', 'VAL_001'],
['blobNotFound', 'MAIL_004'],
['forbidden', 'AUTH_003'],
['forbiddenFrom', 'AUTH_003'],
['forbiddenToSend', 'AUTH_003'],
['overQuota', 'MAIL_003'],
['tooLarge', 'FILE_002'],
['rateLimit', 'REQ_001'],
['invalidRecipients', 'MAIL_002'],
['noRecipients', 'MAIL_002'],
['tooManyRecipients', 'MAIL_002'],
])('%s → %s', (type, code) => {
expect(mapJmapErrorType(type)).toBe(code);
});
it('예상하지 못한 type 은 MAIL_001 로 수렴한다', () => {
for (const type of [
'serverFail',
'serverUnavailable',
'unknownMethod',
'stateMismatch',
'accountNotFound',
undefined,
]) {
expect(mapJmapErrorType(type)).toBe('MAIL_001');
}
});
it('로그 context 에 type 과 속성 이름만 남기고 엔진의 설명 문장은 남기지 않는다', () => {
const exception = jmapFailure(
{
type: 'invalidProperties',
description: 'Invalid address: someone@example.net',
properties: ['to/0/email'],
},
{ method: 'Email/set' },
);
expect(exception.code).toBe('VAL_001');
expect(exception.context).toEqual({
method: 'Email/set',
jmapType: 'invalidProperties',
properties: ['to/0/email'],
});
expect(JSON.stringify(exception.context)).not.toContain('example.net');
});
it('오류 객체가 없으면 type 을 unknown 으로 기록한다', () => {
const exception = jmapFailure(undefined, { method: 'Email/get' });
expect(exception.code).toBe('MAIL_001');
expect(exception.context).toEqual({
method: 'Email/get',
jmapType: 'unknown',
});
});
it('첨부 blob 을 찾지 못한 오류를 가려낸다 (Stalwart: invalidProperties + attachments/blobId)', () => {
expect(isMissingAttachmentBlob({ type: 'blobNotFound' })).toBe(true);
expect(
isMissingAttachmentBlob({
type: 'invalidProperties',
properties: ['attachments/blobId'],
}),
).toBe(true);
expect(
isMissingAttachmentBlob({
type: 'invalidProperties',
properties: ['attachments/2/blobId'],
}),
).toBe(true);
expect(
isMissingAttachmentBlob({
type: 'invalidProperties',
properties: ['attachments/type', 'to/0/email'],
}),
).toBe(false);
expect(isMissingAttachmentBlob({ type: 'overQuota' })).toBe(false);
expect(isMissingAttachmentBlob(undefined)).toBe(false);
});
});
@@ -0,0 +1,68 @@
import { BusinessException } from '@/common/errors/business.exception';
import type { ErrorCode } from '@/common/errors/error-codes';
import type { JmapError } from './jmap.types';
/**
* JMAP 오류 type → 우리 에러 코드.
* 메서드 오류(`["error", {type}]`)와 SetError(`notCreated`·`notUpdated`·`notDestroyed` 의 `{type}`)가 같은 표를 쓴다.
* 표에 없는 type 은 예상하지 못한 엔진 오류이므로 MAIL_001 로 수렴한다 (type 은 로그 context 에 남긴다).
*/
const JMAP_ERROR_CODES: ReadonlyMap<string, ErrorCode> = new Map<
string,
ErrorCode
>([
['notFound', 'RES_001'],
['invalidArguments', 'VAL_001'],
['invalidProperties', 'VAL_001'],
// 첨부 blob 을 찾지 못함 — 만료된 업로드·잘못된 첨부 id (RFC 8621 의 표준 type)
['blobNotFound', 'MAIL_004'],
['forbidden', 'AUTH_003'],
['forbiddenFrom', 'AUTH_003'],
['forbiddenToSend', 'AUTH_003'],
['overQuota', 'MAIL_003'],
['tooLarge', 'FILE_002'],
['rateLimit', 'REQ_001'],
['invalidRecipients', 'MAIL_002'],
['noRecipients', 'MAIL_002'],
['tooManyRecipients', 'MAIL_002'],
]);
export function mapJmapErrorType(type: string | undefined): ErrorCode {
return (type && JMAP_ERROR_CODES.get(type)) || 'MAIL_001';
}
/** `attachments/blobId`, `attachments/0/blobId` — 첨부의 blob 참조 속성 */
const ATTACHMENT_BLOB_PROPERTY = /^attachments(?:\/\d+)?\/blobId$/;
/**
* 첨부 blob 을 엔진에서 찾지 못한 오류인지 — MAIL_004 로 응답한다.
* RFC 8621 은 `blobNotFound` 를 정의하지만, Stalwart(v0.16)는 없는 blob 을
* `invalidProperties` + `properties: ["attachments/blobId"]` 로 알려 준다 (확인함).
*/
export function isMissingAttachmentBlob(error: JmapError | undefined): boolean {
if (error?.type === 'blobNotFound') return true;
return (
error?.type === 'invalidProperties' &&
(error.properties ?? []).some((property) =>
ATTACHMENT_BLOB_PROPERTY.test(property),
)
);
}
/**
* JMAP 오류를 코드가 있는 예외로 바꾼다.
* description 은 엔진이 만든 문장(주소 등이 섞일 수 있다)이라 남기지 않고 type·속성 이름만 남긴다.
*/
export function jmapFailure(
error: JmapError | undefined,
context: Record<string, unknown>,
): BusinessException {
const type = error?.type;
return new BusinessException(mapJmapErrorType(type), {
context: {
...context,
jmapType: type ?? 'unknown',
...(error?.properties?.length ? { properties: error.properties } : {}),
},
});
}
@@ -0,0 +1,315 @@
import { sanitizeFileName } from '@/modules/mail/attachment.policy';
import type {
InlinePart,
MailAddress,
MailAttachment,
Mailbox,
MailboxRole,
MailUsage,
MessageDetailSource,
MessageSummary,
} from '@/modules/mail/mail.types';
import type {
JmapBodyPart,
JmapEmail,
JmapEmailAddress,
JmapIdentity,
JmapMailbox,
JmapQuota,
} from './jmap.types';
/**
* JMAP 객체 → 우리 도메인 타입 (순수 함수).
* 엔진 응답은 외부 입력이므로 모든 필드를 방어적으로 읽는다.
*/
/** 목록·요약에 필요한 Email 속성 */
export const SUMMARY_PROPERTIES = [
'id',
'threadId',
'mailboxIds',
'keywords',
'from',
'to',
'subject',
'preview',
'receivedAt',
'hasAttachment',
'size',
];
/** 상세에 필요한 Email 속성 */
export const DETAIL_PROPERTIES = [
...SUMMARY_PROPERTIES,
'cc',
'bcc',
'replyTo',
'sentAt',
'messageId',
'references',
'bodyValues',
'htmlBody',
'textBody',
'attachments',
];
/** 본문 부분(part)에서 읽는 속성 */
export const BODY_PART_PROPERTIES = [
'partId',
'blobId',
'size',
'name',
'type',
'charset',
'disposition',
'cid',
];
export const MAILBOX_PROPERTIES = [
'id',
'name',
'role',
'parentId',
'sortOrder',
'totalEmails',
'unreadEmails',
];
const KNOWN_ROLES: ReadonlySet<string> = new Set<MailboxRole>([
'inbox',
'drafts',
'sent',
'junk',
'trash',
'archive',
]);
const OCTET_STREAM = 'application/octet-stream';
/** 이름 없는 첨부의 표시 이름 접두어 */
const UNNAMED_ATTACHMENT = '첨부파일';
/** 이름 없는 첨부에 붙일 확장자 */
const EXTENSION_BY_TYPE: ReadonlyMap<string, string> = new Map([
['image/png', '.png'],
['image/jpeg', '.jpg'],
['image/gif', '.gif'],
['image/webp', '.webp'],
['application/pdf', '.pdf'],
['text/plain', '.txt'],
['text/calendar', '.ics'],
['message/rfc822', '.eml'],
]);
/** 날짜를 ISO 8601 UTC 로 맞춘다 (JMAP sentAt 은 시간대 오프셋이 붙어 온다) */
export function toUtcIso(value: string | null | undefined): string | null {
if (!value) return null;
const time = Date.parse(value);
return Number.isNaN(time) ? null : new Date(time).toISOString();
}
export function toAddress(
address: JmapEmailAddress | null | undefined,
): MailAddress | null {
const email = address?.email?.trim();
if (!email) return null;
const name = address?.name?.trim();
return { name: name ? name : null, email };
}
export function toAddresses(
addresses: JmapEmailAddress[] | null | undefined,
): MailAddress[] {
return (addresses ?? []).flatMap((address) => {
const mapped = toAddress(address);
return mapped ? [mapped] : [];
});
}
export function toMailboxRole(
role: string | null | undefined,
): MailboxRole | null {
const normalized = role?.toLowerCase();
return normalized && KNOWN_ROLES.has(normalized)
? (normalized as MailboxRole)
: null;
}
export function toMailbox(mailbox: JmapMailbox): Mailbox {
return {
id: mailbox.id,
name: mailbox.name ?? '',
role: toMailboxRole(mailbox.role),
parentId: mailbox.parentId ?? null,
sortOrder: mailbox.sortOrder ?? 0,
totalCount: mailbox.totalEmails ?? 0,
unreadCount: mailbox.unreadEmails ?? 0,
};
}
function trueKeys(record: Record<string, boolean> | undefined): string[] {
return Object.entries(record ?? {})
.filter(([, value]) => value === true)
.map(([key]) => key);
}
export function toSummary(email: JmapEmail): MessageSummary {
return {
id: email.id,
threadId: email.threadId ?? email.id,
mailboxIds: trueKeys(email.mailboxIds),
from: toAddress(email.from?.[0]),
to: toAddresses(email.to),
subject: email.subject ?? '',
preview: email.preview ?? '',
receivedAt: toUtcIso(email.receivedAt) ?? new Date(0).toISOString(),
isRead: email.keywords?.$seen === true,
isFlagged: email.keywords?.$flagged === true,
hasAttachment: email.hasAttachment === true,
size: email.size ?? 0,
};
}
function partType(part: JmapBodyPart): string {
return (part.type ?? OCTET_STREAM).trim().toLowerCase() || OCTET_STREAM;
}
/** 첨부 표시 이름 — 이름이 없으면 '첨부파일-2.png' 처럼 만든다 */
export function attachmentName(part: JmapBodyPart, index: number): string {
const name = part.name?.trim();
if (name) return sanitizeFileName(name);
return `${UNNAMED_ATTACHMENT}-${index + 1}${EXTENSION_BY_TYPE.get(partType(part)) ?? ''}`;
}
/** blob 이 있는(내려받을 수 있는) 첨부만 */
function downloadableAttachments(
email: JmapEmail,
): Array<JmapBodyPart & { blobId: string }> {
return (email.attachments ?? []).filter(
(part): part is JmapBodyPart & { blobId: string } =>
typeof part.blobId === 'string' && part.blobId !== '',
);
}
function toAttachment(
part: JmapBodyPart & { blobId: string },
index: number,
): MailAttachment {
return {
id: part.blobId,
name: attachmentName(part, index),
type: partType(part),
size: part.size ?? 0,
};
}
/**
* 지정한 형식의 본문 부분 값을 이어 붙인다. 그 형식 부분이 없으면 null.
* (JMAP 의 htmlBody 는 HTML 이 없으면 text/plain 부분으로 채워지므로 형식을 확인해야 한다)
*/
function joinBodyValues(
email: JmapEmail,
parts: JmapBodyPart[] | undefined,
type: string,
): string | null {
const values = (parts ?? [])
.filter((part) => partType(part) === type && part.partId)
.map((part) => email.bodyValues?.[part.partId as string]?.value ?? '');
return values.length > 0 ? values.join('\n') : null;
}
export function toDetailSource(email: JmapEmail): MessageDetailSource {
const attachments = downloadableAttachments(email);
const inlineParts: InlinePart[] = attachments
.filter((part) => typeof part.cid === 'string' && part.cid.trim() !== '')
.map((part) => ({
attachmentId: part.blobId,
cid: (part.cid as string).trim(),
type: partType(part),
size: part.size ?? 0,
}));
return {
...toSummary(email),
cc: toAddresses(email.cc),
bcc: toAddresses(email.bcc),
replyTo: toAddresses(email.replyTo),
sentAt: toUtcIso(email.sentAt),
messageId: email.messageId?.[0] ?? null,
references: email.references ?? [],
rawHtmlBody: joinBodyValues(email, email.htmlBody, 'text/html'),
textBody: joinBodyValues(email, email.textBody, 'text/plain'),
attachments: attachments.map(toAttachment),
inlineParts,
};
}
/** 첨부 목록에서 blob id 로 첨부를 찾는다 (그 메일에 실제로 있는지 확인용) */
export function findAttachment(
email: JmapEmail,
attachmentId: string,
): { name: string; size: number | null } | null {
const attachments = downloadableAttachments(email);
const index = attachments.findIndex((part) => part.blobId === attachmentId);
if (index < 0) return null;
return {
name: attachmentName(attachments[index], index),
size: attachments[index].size ?? null,
};
}
/**
* 직원 메일함의 바이트 쿼터를 고른다 (RFC 9425).
* 도메인·전체(global) 범위 쿼터의 `used` 는 직원 한 명의 사용량이 아니므로 계정 범위만 쓴다.
* 여러 개면 가장 빠듯한(작은) 한도를 쓴다.
*/
export function selectAccountQuota(quotas: JmapQuota[]): JmapQuota | null {
const candidates = quotas.filter(
(quota) =>
quota.resourceType === 'octets' &&
quota.scope === 'account' &&
typeof quota.used === 'number' &&
(!quota.types ||
quota.types.length === 0 ||
quota.types.includes('Mail')),
);
if (candidates.length === 0) return null;
const limitOf = (quota: JmapQuota) =>
typeof quota.hardLimit === 'number' && quota.hardLimit > 0
? quota.hardLimit
: Number.POSITIVE_INFINITY;
return [...candidates].sort((a, b) => limitOf(a) - limitOf(b))[0];
}
/** 쿼터 → 사용량 (한도 0·없음은 "한도 없음") */
export function toUsage(quota: JmapQuota): MailUsage {
return {
usedBytes: quota.used ?? 0,
limitBytes:
typeof quota.hardLimit === 'number' && quota.hardLimit > 0
? quota.hardLimit
: null,
};
}
/** 발신 ID — 직원 주소와 같은 것을 고르고, 없으면 첫 번째 */
export function pickIdentity(
identities: JmapIdentity[],
email: string,
): JmapIdentity | null {
return (
identities.find((identity) => identity.email?.toLowerCase() === email) ??
identities[0] ??
null
);
}
/** 표시 이름이 비어 있을 때 쓰는 이름 — 주소의 로컬 부분 */
export function fallbackDisplayName(email: string): string {
const at = email.indexOf('@');
return at > 0 ? email.slice(0, at) : email;
}
+144
View File
@@ -0,0 +1,144 @@
/**
* JMAP(RFC 8620 · 8621) 타입 중 이 어댑터가 쓰는 부분만 정의한다.
* 메일 엔진의 응답은 외부 입력이므로 모든 필드를 선택적으로 두고, 매핑할 때 방어적으로 읽는다.
* 이 타입은 `modules/mail/jmap/` 밖으로 내보내지 않는다.
*/
/** [메서드 이름, 인자, 호출 id] */
export type JmapInvocation = [string, Record<string, unknown>, string];
export interface JmapResponseBody {
methodResponses?: JmapInvocation[];
}
/** 요청 단위 오류 (RFC 7807 problem details) */
export interface JmapProblem {
type?: string;
status?: number;
limit?: string;
detail?: string;
}
export interface JmapSessionResource {
username?: string;
apiUrl?: string;
downloadUrl?: string;
uploadUrl?: string;
primaryAccounts?: Record<string, string>;
/** 계정별 기능·한도 — 메일 기능의 `maxSizeAttachmentsPerEmail` 를 읽는다 */
accounts?: Record<
string,
{ accountCapabilities?: Record<string, Record<string, unknown>> }
>;
}
/** 메서드 오류(`["error", {...}]`)·SetError 공통 형태 */
export interface JmapError {
type?: string;
description?: string;
properties?: string[];
}
export interface JmapEmailAddress {
name?: string | null;
email?: string | null;
}
export interface JmapBodyPart {
partId?: string | null;
blobId?: string | null;
size?: number;
name?: string | null;
type?: string | null;
charset?: string | null;
disposition?: string | null;
cid?: string | null;
}
export interface JmapBodyValue {
value?: string;
isTruncated?: boolean;
isEncodingProblem?: boolean;
}
export interface JmapEmail {
id: string;
threadId?: string;
mailboxIds?: Record<string, boolean>;
keywords?: Record<string, boolean>;
from?: JmapEmailAddress[] | null;
to?: JmapEmailAddress[] | null;
cc?: JmapEmailAddress[] | null;
bcc?: JmapEmailAddress[] | null;
replyTo?: JmapEmailAddress[] | null;
subject?: string | null;
preview?: string;
receivedAt?: string;
sentAt?: string | null;
hasAttachment?: boolean;
size?: number;
messageId?: string[] | null;
references?: string[] | null;
bodyValues?: Record<string, JmapBodyValue>;
htmlBody?: JmapBodyPart[];
textBody?: JmapBodyPart[];
attachments?: JmapBodyPart[];
}
export interface JmapMailbox {
id: string;
name?: string;
role?: string | null;
parentId?: string | null;
sortOrder?: number;
totalEmails?: number;
unreadEmails?: number;
}
export interface JmapIdentity {
id: string;
name?: string;
email?: string;
}
export interface JmapGetResult<T> {
list?: T[];
notFound?: string[];
}
export interface JmapSetResult {
created?: Record<string, { id?: string }>;
updated?: Record<string, unknown>;
destroyed?: string[];
notCreated?: Record<string, JmapError>;
notUpdated?: Record<string, JmapError>;
notDestroyed?: Record<string, JmapError>;
}
export interface JmapQueryResult {
ids?: string[];
position?: number;
total?: number;
}
/** RFC 9425 Quota */
export interface JmapQuota {
id: string;
resourceType?: string;
used?: number;
hardLimit?: number;
scope?: string;
types?: string[];
}
/** RFC 9404 Blob/get 결과 항목 */
export interface JmapBlob {
id: string;
size?: number;
}
export interface JmapUploadResult {
blobId?: string;
type?: string;
size?: number;
}
@@ -0,0 +1,78 @@
import {
Controller,
HttpStatus,
Post,
UploadedFile,
UseGuards,
UseInterceptors,
} from '@nestjs/common';
import { FileInterceptor } from '@nestjs/platform-express';
import {
ApiBody,
ApiConsumes,
ApiCookieAuth,
ApiOperation,
ApiTags,
} from '@nestjs/swagger';
import {
ApiErrorResponse,
ApiStandardResponse,
} from '@/common/decorators/api-standard-response.decorator';
import {
CurrentUser,
type AuthenticatedUser,
} from '@/common/decorators/current-user.decorator';
import { BusinessException } from '@/common/errors/business.exception';
import { JwtAuthGuard } from '@/common/guards/jwt-auth.guard';
import { DeleteUploadedFileInterceptor } from '@/common/interceptors/delete-uploaded-file.interceptor';
import { allowedAttachmentExtensions } from './attachment.policy';
import { MailAttachmentDto } from './dto/mail-response.dto';
import { MailService } from './mail.service';
// 메일 첨부 업로드 — modules/upload 패턴을 따른다.
// 형식·크기 검증은 MailModule 의 multer 설정(임시 디스크 저장)이 담당하고, 파일은 메일 엔진으로 흘려보낸 뒤 지운다.
// 가드가 인터셉터보다 먼저 돌므로 로그인하지 않은·다른 사이트에서 온 요청은 파일을 읽기 전에 막힌다.
@ApiTags('Mail')
@ApiCookieAuth()
@UseGuards(JwtAuthGuard)
@ApiErrorResponse('AUTH_001', '로그인 필요 (쿠키 없음·만료)')
@Controller('mail-attachments')
export class MailAttachmentController {
constructor(private readonly mail: MailService) {}
@Post()
// 순서 중요: FileInterceptor 가 임시 파일을 받고, 그 뒤의 정리 인터셉터가 핸들러가 끝나면 지운다
@UseInterceptors(FileInterceptor('file'), DeleteUploadedFileInterceptor)
@ApiOperation({
summary: '메일 첨부 업로드 (파일 1개)',
description: `허용 형식(확장자 + MIME): ${allowedAttachmentExtensions().join(', ')}. 크기 한도는 GET /api/mail-quota 의 attachmentMaxBytes.`,
})
@ApiConsumes('multipart/form-data')
@ApiBody({
schema: {
type: 'object',
properties: { file: { type: 'string', format: 'binary' } },
required: ['file'],
},
})
@ApiStandardResponse(MailAttachmentDto, {
status: HttpStatus.CREATED,
description: '업로드 완료 — 이 id 를 발송 본문 attachments 에 넣는다',
})
@ApiErrorResponse('FILE_001', '허용되지 않는 형식')
@ApiErrorResponse('FILE_002', '파일 크기 초과')
@ApiErrorResponse('MAIL_001', '메일 엔진 장애')
upload(
@CurrentUser() user: AuthenticatedUser,
@UploadedFile() file?: Express.Multer.File,
) {
if (!file) {
// 파일 없이 보낸 요청 — multer 는 오류를 내지 않으므로 여기서 막는다
throw new BusinessException('VAL_001', {
context: { reason: 'attachment-file-missing' },
});
}
return this.mail.uploadAttachment(user, file);
}
}
+19
View File
@@ -0,0 +1,19 @@
import { Injectable, PipeTransform } from '@nestjs/common';
import { BusinessException } from '@/common/errors/business.exception';
import { MAIL_ID_PATTERN } from './mail.constants';
/**
* 경로의 메일·첨부 id 형식 확인.
* 형식이 틀린 id 는 "그런 대상이 없다"와 같으므로 RES_001 로 응답한다 (엔진까지 보내지 않는다).
*/
@Injectable()
export class MailIdPipe implements PipeTransform<unknown, string> {
transform(value: unknown): string {
if (typeof value === 'string' && MAIL_ID_PATTERN.test(value)) return value;
throw new BusinessException('RES_001', {
context: { reason: 'malformed-mail-id' },
});
}
}
@@ -0,0 +1,99 @@
import { existsSync, mkdtempSync, rmSync, utimesSync, writeFileSync } from 'fs';
import { tmpdir } from 'os';
import { join } from 'path';
import { Logger } from '@nestjs/common';
import {
MailUploadTempCleanup,
removeStaleUploads,
} from './mail-upload-storage';
const HOUR_MS = 60 * 60_000;
describe('removeStaleUploads', () => {
let dir: string;
beforeEach(() => {
dir = mkdtempSync(join(tmpdir(), 'mail-uploads-'));
});
afterEach(() => rmSync(dir, { recursive: true, force: true }));
it('오래된 파일만 지운다 — 진행 중일 수 있는 최근 파일은 남긴다', async () => {
const old = join(dir, 'old');
const fresh = join(dir, 'fresh');
writeFileSync(old, 'x');
writeFileSync(fresh, 'y');
const twoHoursAgo = (Date.now() - 2 * HOUR_MS) / 1000;
utimesSync(old, twoHoursAgo, twoHoursAgo);
await expect(removeStaleUploads(dir, HOUR_MS)).resolves.toBe(1);
expect(existsSync(old)).toBe(false);
expect(existsSync(fresh)).toBe(true);
});
it('디렉터리가 없으면 0 (첫 기동)', async () => {
await expect(
removeStaleUploads(join(dir, 'missing'), HOUR_MS),
).resolves.toBe(0);
});
});
describe('MailUploadTempCleanup (기동 때 + 주기 정리)', () => {
let dir: string;
beforeEach(() => {
dir = mkdtempSync(join(tmpdir(), 'mail-uploads-cleanup-'));
});
afterEach(() => {
rmSync(dir, { recursive: true, force: true });
jest.restoreAllMocks();
});
function cleanupIn(directory: string): MailUploadTempCleanup {
const cleanup = new MailUploadTempCleanup();
Reflect.set(cleanup, 'directory', directory);
return cleanup;
}
it('주기 정리는 오래된 임시 첨부만 지우고, 지웠으면 경고를 남긴다', async () => {
const warn = jest
.spyOn(Logger.prototype, 'warn')
.mockImplementation(() => undefined);
const stale = join(dir, 'stale');
writeFileSync(stale, 'x');
writeFileSync(join(dir, 'in-progress'), 'y');
const twoHoursAgo = (Date.now() - 2 * HOUR_MS) / 1000;
utimesSync(stale, twoHoursAgo, twoHoursAgo);
await expect(cleanupIn(dir).removeStale()).resolves.toBe(1);
expect(existsSync(stale)).toBe(false);
expect(existsSync(join(dir, 'in-progress'))).toBe(true);
expect(warn).toHaveBeenCalledWith(
expect.stringContaining('임시 첨부 1개를 지웠습니다'),
);
});
it('주기 작업으로 등록되어 있다 (@Interval) — 기동 때 한 번만 도는 것이 아니다', () => {
const descriptor = Object.getOwnPropertyDescriptor(
MailUploadTempCleanup.prototype,
'removeStale',
);
const keys = Reflect.getMetadataKeys(descriptor?.value as object);
expect(
keys.some((key) => String(key).toUpperCase().includes('SCHEDULE')),
).toBe(true);
});
it('지울 것이 없으면 조용히 끝난다', async () => {
const warn = jest
.spyOn(Logger.prototype, 'warn')
.mockImplementation(() => undefined);
await expect(cleanupIn(dir).removeStale()).resolves.toBe(0);
expect(warn).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,94 @@
import { Injectable, Logger, OnModuleInit } from '@nestjs/common';
import { Interval } from '@nestjs/schedule';
import { randomUUID } from 'crypto';
import { readdir, rm, stat } from 'fs/promises';
import { diskStorage, type StorageEngine } from 'multer';
import { tmpdir } from 'os';
import { join } from 'path';
/**
* 메일 첨부의 임시 저장소.
*
* 첨부는 메모리가 아니라 **임시 디스크**에 받은 뒤 메일 엔진으로 흘려보내고 지운다.
* 메모리에 받으면 파일 하나(최대 20MB)가 요청이 끝날 때까지 통째로 프로세스 메모리에 머문다 —
* 운영 메모리 한도(1GiB)에서 동시 업로드 수십 건이면 컨테이너가 OOM 으로 죽고 다른 사용자의 요청까지 끊긴다.
* 디스크에 받으면 메모리는 업로드 수와 무관하게 거의 일정하다 (대신 임시 디스크 공간이 업로드 수 × 파일 크기만큼 필요하다).
*
* 이 디렉터리는 정적으로 서빙하지 않는다 — 파일은 엔진으로 넘긴 뒤 바로 지운다.
*/
export const MAIL_UPLOAD_TMP_DIR = join(tmpdir(), 'arasystem-mail-uploads');
/** 이보다 오래된 임시 파일은 끝나지 못한 업로드가 남긴 것이다 (비정상 종료·지우기 실패 등) */
const STALE_UPLOAD_AGE_MS = 60 * 60_000;
/** 남은 임시 첨부를 찾는 주기 */
const STALE_UPLOAD_SWEEP_INTERVAL_MS = 15 * 60_000;
/** multer 디스크 저장 — 파일명은 서버가 만든다 (클라이언트가 보낸 이름을 경로에 쓰지 않는다) */
export function mailUploadStorage(): StorageEngine {
return diskStorage({
destination: MAIL_UPLOAD_TMP_DIR,
filename: (_req, _file, callback) => callback(null, randomUUID()),
});
}
/** `dir` 안에서 `maxAgeMs` 보다 오래된 파일을 지운다 → 지운 수 (디렉터리가 없으면 0) */
export async function removeStaleUploads(
dir: string,
maxAgeMs: number,
now: number = Date.now(),
): Promise<number> {
let names: string[];
try {
names = await readdir(dir);
} catch {
return 0;
}
let removed = 0;
for (const name of names) {
const path = join(dir, name);
try {
const info = await stat(path);
if (info.isFile() && now - info.mtimeMs > maxAgeMs) {
await rm(path, { force: true });
removed += 1;
}
} catch {
// 그 사이 다른 쪽이 지웠다 — 무시한다
}
}
return removed;
}
/**
* 남은 임시 첨부 정리 — 기동할 때 한 번, 그 뒤 15분마다(ScheduleModule).
* 요청마다 지우지만(DeleteUploadedFileInterceptor) 프로세스가 업로드 도중 죽거나 지우기가 실패하면 파일이 남는다.
* 진행 중인 업로드를 건드리지 않도록 오래된(1시간) 파일만 지운다.
*/
@Injectable()
export class MailUploadTempCleanup implements OnModuleInit {
private readonly logger = new Logger(MailUploadTempCleanup.name);
/** 정리할 디렉터리 — 테스트에서 바꿔 끼울 수 있게 속성으로 둔다 */
protected readonly directory: string = MAIL_UPLOAD_TMP_DIR;
async onModuleInit(): Promise<void> {
await this.removeStale();
}
@Interval(STALE_UPLOAD_SWEEP_INTERVAL_MS)
async removeStale(now: number = Date.now()): Promise<number> {
const removed = await removeStaleUploads(
this.directory,
STALE_UPLOAD_AGE_MS,
now,
);
if (removed > 0) {
// 정상이면 요청이 끝날 때 지워진다 — 남아 있었다는 것은 비정상 종료·지우기 실패의 흔적이다
this.logger.warn(
`남아 있던 임시 첨부 ${removed}개를 지웠습니다 (업로드 도중 비정상 종료 또는 지우기 실패).`,
);
}
return removed;
}
}
@@ -0,0 +1,30 @@
/**
* 메일 API 한도·형식 상수 — 계약(docs/mail/api-contract.md)의 숫자를 한 곳에 모은다.
*/
/**
* 외부로 노출하는 메일 객체 id 형식 (메일함·메일·첨부).
* JMAP 의 Id 정의(RFC 8620 §1.2: URL-safe base64 문자 1~255자)를 따른다 — 엔진을 바꿔도 유지되는 형식이다.
*/
export const MAIL_ID_PATTERN = /^[A-Za-z0-9_-]{1,255}$/;
/** 받는 사람(to + cc + bcc) 합계 상한 */
export const MAX_RECIPIENTS = 100;
/** 제목 최대 길이 */
export const MAX_SUBJECT_LENGTH = 255;
/** 본문(일반 텍스트) 최대 길이 */
export const MAX_BODY_LENGTH = 100_000;
/** 검색어 최대 길이 */
export const MAX_SEARCH_LENGTH = 200;
/** 받는 사람 표시 이름 최대 길이 */
export const MAX_DISPLAY_NAME_LENGTH = 200;
/** 메일 주소 최대 길이 (RFC 5321 경로 한도) */
export const MAX_EMAIL_LENGTH = 254;
/** 첨부 파일명 최대 길이 */
export const MAX_FILE_NAME_LENGTH = 255;
+104
View File
@@ -0,0 +1,104 @@
import { Logger, Module } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import {
MulterModule,
type MulterModuleOptions,
} from '@nestjs/platform-express';
import { BusinessException } from '@/common/errors/business.exception';
import { isAllowedAttachment } from './attachment.policy';
import { JmapClient } from './jmap/jmap.client';
import { JmapMailProvider } from './jmap/jmap-mail.provider';
import { MailAttachmentController } from './mail-attachment.controller';
import {
MailUploadTempCleanup,
mailUploadStorage,
} from './mail-upload-storage';
import { MAIL_PROVIDER } from './mail.provider';
import { MailService } from './mail.service';
import {
MAIL_SETTINGS,
type MailSettings,
buildMailSettings,
isMailEngineConfigured,
} from './mail.settings';
import { MailboxController, MailQuotaController } from './mailbox.controller';
import { MessageController } from './message.controller';
function readMailSettings(config: ConfigService): MailSettings {
return buildMailSettings((key) => config.get<string>(key));
}
function createMailSettings(config: ConfigService): MailSettings {
const settings = readMailSettings(config);
if (!isMailEngineConfigured(settings)) {
// 비운영에서만 여기까지 온다 (운영은 env 검증이 기동을 막는다) — CI e2e 가 이 경로다
new Logger('MailModule').warn(
'메일 엔진 설정(MAIL_JMAP_URL·MAIL_DOMAIN·MAIL_GATEWAY_*)이 없어 로그인·메일 API 가 MAIL_001 을 돌려줍니다.',
);
}
return settings;
}
/**
* 메일 첨부 업로드 설정 — 임시 디스크에 받아 메일 엔진으로 흘려보내고 지운다 (mail-upload-storage.ts).
* 메모리에 통째로 올리지 않는다 (운영 메모리 한도 1GiB — 동시 업로드에 버티게).
* 형식 검사는 확장자 + MIME 허용 목록 (FILE_001), 크기 초과는 multer → 413 (FILE_002).
*/
function attachmentUploadOptions(config: ConfigService): MulterModuleOptions {
return {
storage: mailUploadStorage(),
limits: {
fileSize: readMailSettings(config).attachmentMaxBytes,
files: 1,
fields: 10,
},
// 브라우저는 파일명을 UTF-8 그대로 보낸다 — 기본값(latin1)이면 한글 이름이 깨진다
defParamCharset: 'utf8',
fileFilter: (_req, file, callback) => {
if (!isAllowedAttachment(file.originalname, file.mimetype)) {
callback(
new BusinessException('FILE_001', {
context: { reason: 'attachment-type-not-allowed' },
}),
false,
);
return;
}
callback(null, true);
},
};
}
/**
* 메일 모듈 — 웹메일 API 와 메일 엔진 포트.
* 엔진을 바꿀 때는 `MAIL_PROVIDER` 의 구현(useClass)만 바꾼다.
* 인증 모듈이 직원 자격 확인에 쓰도록 포트와 설정을 내보낸다.
*/
@Module({
imports: [
MulterModule.registerAsync({
inject: [ConfigService],
useFactory: attachmentUploadOptions,
}),
],
controllers: [
MailboxController,
MailQuotaController,
MessageController,
MailAttachmentController,
],
providers: [
{
provide: MAIL_SETTINGS,
inject: [ConfigService],
useFactory: createMailSettings,
},
JmapClient,
{ provide: MAIL_PROVIDER, useClass: JmapMailProvider },
MailService,
MailUploadTempCleanup,
],
exports: [MAIL_PROVIDER, MAIL_SETTINGS],
})
export class MailModule {}
+107
View File
@@ -0,0 +1,107 @@
import type {
AttachmentContent,
MailAccountProfile,
MailAttachment,
Mailbox,
MailUsage,
MessageDetailSource,
MessageListQuery,
MessagePage,
MessageSummary,
MessageUpdate,
OutgoingMessage,
SentMessageRecord,
UploadedFile,
} from './mail.types';
/**
* 메일 엔진 포트 — 서비스 계층은 이 인터페이스만 안다.
*
* 지금 구현은 Stalwart(JMAP) 하나(`JmapMailProvider`)지만, 엔진을 바꿀 때는
* 이 인터페이스의 새 구현만 만들어 `MAIL_PROVIDER` 에 연결하면 된다.
*
* 규칙:
* - 우리 도메인 타입만 받고 돌려준다 (엔진 타입·id 형식을 밖으로 흘리지 않는다)
* - 실패는 항상 BusinessException(에러 코드)으로 던진다 — 엔진 장애 MAIL_001, 없는 대상 RES_001 등
* - `user` 는 인증된 직원 주소다. 어댑터는 그 직원의 메일함에만 접근한다
*/
export const MAIL_PROVIDER = Symbol('MAIL_PROVIDER');
export interface MailProvider {
/**
* 직원 자격 증명을 메일 엔진에 확인한다. 비밀번호는 저장하지 않는다.
* @returns 맞으면 엔진이 아는 정식 계정 주소, 틀리면 null
*/
verifyCredentials(email: string, password: string): Promise<string | null>;
/**
* 직원 메일함 프로필(표시 이름). `fresh` 면 캐시를 건너뛰고 계정이 살아 있는지 다시 확인한다.
* 계정이 사라졌거나, 로그인을 막는 계정(관리자 등)이거나, 메일 엔진의 관리 권한이 있는 계정이면 AUTH_001.
* (관리 권한 확인은 `fresh` 일 때 — 로그인·리프레시마다 — 한다)
*/
getProfile(
user: string,
options?: { fresh?: boolean },
): Promise<MailAccountProfile>;
listMailboxes(user: string): Promise<Mailbox[]>;
/** 메일함 사용량과 한도 (한도가 없으면 limitBytes = null) */
getUsage(user: string): Promise<MailUsage>;
/**
* 메일 한 통의 첨부 합계 한도(바이트) — `sendMessage` 가 FILE_002 로 거절하는 기준과 **같은 값**이어야 한다
* (`attachment.policy.ts` 의 `resolveAttachmentsTotalLimit` 하나로 정한다).
* 엔진 정보를 읽지 못하면 다른 호출과 같은 실패(MAIL_001·AUTH_001)로 던진다.
*/
getAttachmentsTotalLimit(user: string): Promise<number>;
listMessages(user: string, query: MessageListQuery): Promise<MessagePage>;
/** 조회만 한다 — 읽음 처리하지 않는다 */
getMessage(user: string, id: string): Promise<MessageDetailSource>;
updateMessage(
user: string,
id: string,
update: MessageUpdate,
): Promise<MessageSummary>;
/** 휴지통이 아니면 휴지통으로 옮기고, 휴지통이면 영구 삭제한다 */
deleteMessage(user: string, id: string): Promise<void>;
/**
* @returns 보낸편지함에 남은 메일 id
* 첨부 합계가 한도(MAIL_ATTACHMENT_TOTAL_MAX_SIZE_MB 와 엔진 한도 중 작은 값)를 넘으면 FILE_002,
* 엔진에서 찾을 수 없는 첨부가 있으면 MAIL_004.
*/
sendMessage(user: string, message: OutgoingMessage): Promise<{ id: string }>;
/**
* 같은 Message-ID 로 이미 **발송한** 메일을 찾는다 (중복 발송 방지 — 계약 2절 Idempotency-Key).
* 보내지 못하고 남은 초안은 지우고 없는 것으로 본다 (다시 보내도 된다).
*/
findSentMessage(
user: string,
messageId: string,
): Promise<SentMessageRecord | null>;
uploadAttachment(user: string, file: UploadedFile): Promise<MailAttachment>;
/** 그 메일에 실제로 있는 첨부만 내려준다 (스트리밍) */
openAttachment(
user: string,
messageId: string,
attachmentId: string,
): Promise<AttachmentContent>;
/**
* 메일 안의 작은 부분(본문 내장 이미지 등)을 메모리로 읽는다.
* `maxBytes` 를 넘으면 읽기를 멈추고 null 을 돌려준다.
*/
readAttachmentBytes(
user: string,
attachmentId: string,
maxBytes: number,
): Promise<Buffer | null>;
}
@@ -0,0 +1,640 @@
import { Logger } from '@nestjs/common';
import { BusinessException } from '@/common/errors/business.exception';
import type { SendMessageDto } from './dto/send-message.dto';
import { MailService } from './mail.service';
import type { MailProvider } from './mail.provider';
import type { MailSettings } from './mail.settings';
import type { MessageDetailSource } from './mail.types';
import { idempotentMessageId, payloadFingerprint } from './send-idempotency';
const user = { email: 'ara.kim@arasystem.test', name: '김아라' };
const IDEMPOTENCY_KEY = '3f1c0a2e-0f1b-4f2a-9a1e-6c9f0a1b2c3d';
const settings: MailSettings = {
jmapBaseUrl: 'http://mail:8080',
domain: 'arasystem.test',
gatewayUser: 'mail-gateway@arasystem.test',
gatewayPassword: 'secret',
attachmentMaxBytes: 20 * 1024 * 1024,
attachmentTotalMaxBytes: 25 * 1024 * 1024,
blockedAccounts: new Set(['admin@arasystem.test']),
};
/** 포트의 각 메서드를 타입이 있는 jest.fn 속성으로 — 메서드가 아니라 속성이라 그대로 expect 에 넘길 수 있다 */
type ProviderMocks = {
[K in keyof MailProvider]: jest.Mock<
ReturnType<MailProvider[K]>,
Parameters<MailProvider[K]>
>;
};
/** 포트 메서드 하나의 타입이 있는 mock */
function mockOf<K extends keyof MailProvider>(): ProviderMocks[K] {
return jest.fn();
}
function fakeProvider(): ProviderMocks {
const sendMessage = mockOf<'sendMessage'>();
sendMessage.mockResolvedValue({ id: 'sent-1' });
return {
verifyCredentials: mockOf<'verifyCredentials'>(),
getProfile: mockOf<'getProfile'>(),
listMailboxes: mockOf<'listMailboxes'>(),
getUsage: mockOf<'getUsage'>(),
getAttachmentsTotalLimit:
mockOf<'getAttachmentsTotalLimit'>().mockResolvedValue(25 * 1024 * 1024),
listMessages: mockOf<'listMessages'>(),
getMessage: mockOf<'getMessage'>(),
updateMessage: mockOf<'updateMessage'>(),
deleteMessage: mockOf<'deleteMessage'>(),
sendMessage,
findSentMessage: mockOf<'findSentMessage'>().mockResolvedValue(null),
uploadAttachment: mockOf<'uploadAttachment'>(),
openAttachment: mockOf<'openAttachment'>(),
readAttachmentBytes: mockOf<'readAttachmentBytes'>(),
};
}
async function codeOf(
run: () => Promise<unknown>,
): Promise<string | undefined> {
try {
await run();
} catch (error) {
return error instanceof BusinessException ? error.code : 'NOT_BUSINESS';
}
return undefined;
}
const source = (
overrides: Partial<MessageDetailSource> = {},
): MessageDetailSource => ({
id: 'm1',
threadId: 't1',
mailboxIds: ['a'],
from: { name: '이도윤', email: 'doyun.lee@arasystem.test' },
to: [{ name: null, email: user.email }],
subject: '제목',
preview: '미리보기',
receivedAt: '2026-10-26T05:12:00.000Z',
isRead: false,
isFlagged: false,
hasAttachment: true,
size: 1000,
cc: [],
bcc: [],
replyTo: [],
sentAt: null,
messageId: 'abc@x',
references: [],
rawHtmlBody: null,
textBody: '본문',
attachments: [],
inlineParts: [],
...overrides,
});
const sendDto = (overrides: Partial<SendMessageDto> = {}): SendMessageDto => ({
to: [{ name: '이도윤', email: 'doyun.lee@arasystem.test' }],
subject: '안녕하세요',
body: '본문',
...overrides,
});
describe('MailService', () => {
let provider: ProviderMocks;
let service: MailService;
let logSpy: jest.SpyInstance;
beforeEach(() => {
provider = fakeProvider();
service = new MailService(provider, settings);
logSpy = jest
.spyOn(Logger.prototype, 'log')
.mockImplementation(() => undefined);
jest.spyOn(Logger.prototype, 'warn').mockImplementation(() => undefined);
});
afterEach(() => jest.restoreAllMocks());
it('메일함은 한국어 이름·확정 순서로, 한 페이지에 모두 내려준다', async () => {
provider.listMailboxes.mockResolvedValue([
{
id: 'd',
name: 'Drafts',
role: 'drafts',
parentId: null,
sortOrder: 0,
totalCount: 0,
unreadCount: 0,
},
{
id: 'e',
name: 'Sent Items',
role: 'sent',
parentId: null,
sortOrder: 0,
totalCount: 0,
unreadCount: 0,
},
{
id: 'a',
name: 'Inbox',
role: 'inbox',
parentId: null,
sortOrder: 0,
totalCount: 3,
unreadCount: 1,
},
]);
const result = await service.listMailboxes(user);
expect(result.items.map((m) => m.name)).toEqual([
'받은편지함',
'보낸편지함',
'임시보관함',
]);
expect(result.pageInfo).toEqual({
hasNext: false,
nextCursor: null,
total: 3,
});
});
it('사용량에 서버의 첨부 한도를 함께 내려준다', async () => {
provider.getUsage.mockResolvedValue({ usedBytes: 900, limitBytes: null });
await expect(service.getQuota(user)).resolves.toEqual({
usedBytes: 900,
limitBytes: null,
attachmentMaxBytes: 20 * 1024 * 1024,
attachmentTotalMaxBytes: 25 * 1024 * 1024,
});
});
it('첨부 합계 한도는 어댑터가 발송 검사와 같은 함수로 정한 값을 그대로 내려준다 (엔진 한도가 더 작은 경우 포함)', async () => {
provider.getUsage.mockResolvedValue({ usedBytes: 0, limitBytes: null });
provider.getAttachmentsTotalLimit.mockResolvedValue(10 * 1024 * 1024);
const quota = await service.getQuota(user);
expect(quota.attachmentTotalMaxBytes).toBe(10 * 1024 * 1024);
expect(provider.getAttachmentsTotalLimit).toHaveBeenCalledWith(user.email);
});
it('사용량이나 첨부 한도를 엔진에서 읽지 못하면 그 실패를 그대로 던진다 (MAIL_001)', async () => {
provider.getUsage.mockResolvedValue({ usedBytes: 0, limitBytes: null });
provider.getAttachmentsTotalLimit.mockRejectedValueOnce(
new BusinessException('MAIL_001'),
);
expect(await codeOf(() => service.getQuota(user))).toBe('MAIL_001');
provider.getUsage.mockRejectedValueOnce(new BusinessException('MAIL_001'));
expect(await codeOf(() => service.getQuota(user))).toBe('MAIL_001');
});
it('검색어 앞뒤 공백을 지우고, 비면 검색하지 않는다', async () => {
provider.listMessages.mockResolvedValue({
items: [],
hasNext: false,
nextCursor: null,
total: 0,
});
await service.listMessages(user, { mailboxId: 'a', limit: 20, q: ' ' });
await service.listMessages(user, {
mailboxId: 'a',
limit: 20,
q: ' 이전 ',
});
expect(provider.listMessages.mock.calls[0][1]).toMatchObject({
text: undefined,
});
expect(provider.listMessages.mock.calls[1][1]).toMatchObject({
text: '이전',
});
});
describe('메일 상세', () => {
it('HTML 이 없으면 정화 없이 텍스트만 내려준다', async () => {
provider.getMessage.mockResolvedValue(source());
const detail = await service.getMessage(user, 'm1');
expect(detail).toMatchObject({
htmlBody: null,
textBody: '본문',
hasBlockedImages: false,
});
expect(detail).not.toHaveProperty('rawHtmlBody');
expect(detail).not.toHaveProperty('inlineParts');
});
it('HTML 은 정화해서 내려준다 (원본을 그대로 내보내지 않는다)', async () => {
provider.getMessage.mockResolvedValue(
source({
rawHtmlBody:
'<p onclick="x()">안녕</p><script>alert(1)</script><img src="https://t.example/p.gif">',
}),
);
const detail = await service.getMessage(user, 'm1');
expect(detail.htmlBody).toBe('<p>안녕</p>');
expect(detail.hasBlockedImages).toBe(true);
});
it('본문이 참조하는 작은 내장 이미지만 읽어 data: URI 로 넣는다', async () => {
provider.getMessage.mockResolvedValue(
source({
rawHtmlBody: '<img src="cid:logo@x"><img src="cid:big@x">',
inlineParts: [
{
attachmentId: 'blob-logo',
cid: 'logo@x',
type: 'image/png',
size: 3,
},
{
attachmentId: 'blob-big',
cid: 'big@x',
type: 'image/png',
size: 5 * 1024 * 1024,
},
{
attachmentId: 'blob-unused',
cid: 'unused@x',
type: 'image/png',
size: 3,
},
],
}),
);
provider.readAttachmentBytes.mockResolvedValue(Buffer.from([1, 2, 3]));
const detail = await service.getMessage(user, 'm1');
expect(provider.readAttachmentBytes).toHaveBeenCalledTimes(1);
expect(provider.readAttachmentBytes).toHaveBeenCalledWith(
user.email,
'blob-logo',
300 * 1024,
);
expect(detail.htmlBody).toBe('<img src="data:image/png;base64,AQID" />');
});
it('내장 이미지 하나를 읽지 못해도 메일은 열린다', async () => {
provider.getMessage.mockResolvedValue(
source({
rawHtmlBody: '<p>본문</p><img src="cid:logo@x">',
inlineParts: [
{
attachmentId: 'blob-logo',
cid: 'logo@x',
type: 'image/png',
size: 3,
},
],
}),
);
provider.readAttachmentBytes.mockRejectedValue(
new BusinessException('MAIL_001'),
);
await expect(service.getMessage(user, 'm1')).resolves.toMatchObject({
htmlBody: '<p>본문</p>',
});
});
});
it('PATCH 는 하나 이상 지정해야 한다', async () => {
expect(
await codeOf(() =>
Promise.resolve(service.updateMessage(user, 'm1', {})),
),
).toBe('VAL_001');
expect(provider.updateMessage).not.toHaveBeenCalled();
});
describe('발송', () => {
it('받는 사람 합계가 100명을 넘으면 MAIL_002 (엔진에 보내지 않는다)', async () => {
const many = (prefix: string, count: number) =>
Array.from({ length: count }, (_, i) => ({
name: null,
email: `${prefix}${i}@arasystem.test`,
}));
expect(
await codeOf(() =>
service.sendMessage(
user,
sendDto({
to: many('t', 50),
cc: many('c', 30),
bcc: many('b', 21),
}),
),
),
).toBe('MAIL_002');
expect(provider.sendMessage).not.toHaveBeenCalled();
});
it('첨부가 20개를 넘으면 MAIL_006 — 업로드한 파일과 전달하는 원본 첨부를 함께 센다 (엔진에 보내지 않는다)', async () => {
const attachments = Array.from({ length: 21 }, (_, i) => ({
id: `blob-${i}`,
name: i % 2 === 0 ? `파일${i}.pdf` : `원본${i}.xlsx`,
}));
expect(
await codeOf(() => service.sendMessage(user, sendDto({ attachments }))),
).toBe('MAIL_006');
expect(provider.sendMessage).not.toHaveBeenCalled();
});
it('첨부 개수는 형식보다 먼저 본다 — 20개를 넘으면 허용 밖 형식이 섞여 있어도 MAIL_006', async () => {
const attachments = Array.from({ length: 21 }, (_, i) => ({
id: `blob-${i}`,
name: i === 0 ? 'virus.exe' : `파일${i}.pdf`,
}));
expect(
await codeOf(() => service.sendMessage(user, sendDto({ attachments }))),
).toBe('MAIL_006');
});
it('첨부 20개까지는 보낸다', async () => {
const attachments = Array.from({ length: 20 }, (_, i) => ({
id: `blob-${i}`,
name: `파일${i}.pdf`,
}));
await service.sendMessage(user, sendDto({ attachments }));
expect(provider.sendMessage.mock.calls[0][1].attachments).toHaveLength(
20,
);
});
it('첨부 형식은 클라이언트 값이 아니라 이름의 확장자로 정한다', async () => {
await service.sendMessage(
user,
sendDto({ attachments: [{ id: 'blob-1', name: '체크리스트.xlsx' }] }),
);
expect(provider.sendMessage.mock.calls[0][1].attachments).toEqual([
{
id: 'blob-1',
name: '체크리스트.xlsx',
type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
},
]);
});
it('전달: 원본 메일의 첨부 id(같은 계정 blob)를 그대로 넘긴다 — 업로드 여부를 따지지 않는다', async () => {
const originalAttachmentId =
'cd7vmbo0jtypewx7k712frdppgb19udb9pv7shnr2y0ohthwy0afkayaae';
await service.sendMessage(
user,
sendDto({
subject: 'Fwd: 메일 서버 이전 일정 공유',
attachments: [{ id: originalAttachmentId, name: '일정표.pdf' }],
}),
);
const [, outgoing] = provider.sendMessage.mock.calls[0];
expect(outgoing.attachments).toEqual([
{
id: originalAttachmentId,
name: '일정표.pdf',
type: 'application/pdf',
},
]);
expect(outgoing.inReplyTo).toBeUndefined();
});
it('허용 목록 밖의 확장자는 업로드와 같게 FILE_001 이다 (전달 포함)', async () => {
for (const name of ['원본.eml', 'virus.exe', 'page.html', '이름없음']) {
expect(
await codeOf(() =>
service.sendMessage(
user,
sendDto({ attachments: [{ id: 'blob-1', name }] }),
),
),
).toBe('FILE_001');
}
expect(provider.sendMessage).not.toHaveBeenCalled();
});
it('제목·표시 이름의 줄바꿈은 공백으로 바꾼다 (헤더 분리 방지)', async () => {
await service.sendMessage(
user,
sendDto({
subject: '제목\r\nBcc: evil@example.net',
to: [
{ name: '이도윤\nX-Evil: 1', email: 'doyun.lee@arasystem.test' },
],
}),
);
const [, outgoing] = provider.sendMessage.mock.calls[0];
expect(outgoing.subject).toBe('제목 Bcc: evil@example.net');
expect(outgoing.to[0].name).toBe('이도윤 X-Evil: 1');
});
it('발송 로그에는 마스킹한 보낸 사람과 개수만 남긴다 (제목·본문·주소 없음)', async () => {
await service.sendMessage(
user,
sendDto({
subject: '비밀 제목',
body: '비밀 본문',
cc: [{ name: null, email: 'seojun.park@arasystem.test' }],
}),
);
const logged = logSpy.mock.calls
.map(([message]) => String(message))
.join('\n');
expect(logged).toContain('from=a***@arasystem.test');
expect(logged).toContain('받는 사람 2명');
expect(logged).not.toMatch(/비밀|seojun|doyun/);
});
});
/** 계약 2절 "중복 발송 방지 — Idempotency-Key" */
describe('발송 중복 방지 (Idempotency-Key)', () => {
const send = (dto = sendDto(), key: string | undefined = IDEMPOTENCY_KEY) =>
service.sendMessage(user, dto, key);
/** 이 키·이 내용으로 첫 발송 때 남는 지문 */
const fingerprintOf = async (dto: SendMessageDto): Promise<string> => {
provider.sendMessage.mockClear();
await service.sendMessage(user, dto, IDEMPOTENCY_KEY);
const { fingerprint } = provider.sendMessage.mock.calls[0][1];
if (!fingerprint) throw new Error('지문 없이 보냈다');
return fingerprint;
};
it('키가 없으면 찾아보지 않고 그대로 보낸다', async () => {
await service.sendMessage(user, sendDto());
expect(provider.findSentMessage).not.toHaveBeenCalled();
const [, outgoing] = provider.sendMessage.mock.calls[0];
expect(outgoing.messageId).toBeUndefined();
expect(outgoing.fingerprint).toBeUndefined();
});
it('키 형식이 틀리면 400 VAL_001 — 엔진에 묻지 않는다', async () => {
expect(await codeOf(() => send(sendDto(), 'not-a-uuid'))).toBe('VAL_001');
expect(provider.findSentMessage).not.toHaveBeenCalled();
expect(provider.sendMessage).not.toHaveBeenCalled();
});
it('처음 보내는 키면 (사용자, 키)로 정한 Message-ID 와 내용 지문을 실어 보낸다', async () => {
await expect(send()).resolves.toEqual({ id: 'sent-1' });
const messageId = idempotentMessageId(user.email, IDEMPOTENCY_KEY);
expect(provider.findSentMessage).toHaveBeenCalledWith(
user.email,
messageId,
);
const [, outgoing] = provider.sendMessage.mock.calls[0];
expect(outgoing.messageId).toBe(messageId);
// 지문 키는 이 요청의 Idempotency-Key — 서버 비밀이 아니다 (v2)
expect(outgoing.fingerprint).toBe(
payloadFingerprint(outgoing, IDEMPOTENCY_KEY),
);
expect(outgoing.fingerprint).toMatch(/^v2:/);
});
it('다른 키로 같은 내용을 보내면 지문도 다르다 — 이전 메일과 엮이지 않고 새로 보낸다', async () => {
const otherKey = '00000000-0000-4000-8000-000000000000';
await send(sendDto(), IDEMPOTENCY_KEY);
await send(sendDto(), otherKey);
const [[, first], [, second]] = provider.sendMessage.mock.calls;
expect(first.fingerprint).not.toBe(second.fingerprint);
expect(first.messageId).not.toBe(second.messageId);
});
it('같은 키·같은 내용으로 이미 보냈으면 다시 보내지 않고 처음 결과(id)를 돌려준다', async () => {
const fingerprint = await fingerprintOf(sendDto());
provider.sendMessage.mockClear();
provider.findSentMessage.mockResolvedValue({ id: 'sent-1', fingerprint });
await expect(send()).resolves.toEqual({ id: 'sent-1' });
expect(provider.sendMessage).not.toHaveBeenCalled();
});
it('같은 키인데 내용(받는 사람·제목·본문·첨부)이 다르면 보내지 않고 409 MAIL_005', async () => {
const fingerprint = await fingerprintOf(sendDto());
provider.sendMessage.mockClear();
provider.findSentMessage.mockResolvedValue({ id: 'sent-1', fingerprint });
for (const changed of [
sendDto({ subject: '고친 제목' }),
sendDto({ body: '고친 본문' }),
sendDto({ cc: [{ name: null, email: 'seojun.park@arasystem.test' }] }),
sendDto({ attachments: [{ id: 'blob-1', name: '추가.pdf' }] }),
]) {
expect(await codeOf(() => send(changed))).toBe('MAIL_005');
}
expect(provider.sendMessage).not.toHaveBeenCalled();
});
it('지문 없이 남은 메일(헤더가 지워진 경우 등)도 같은 내용인지 알 수 없으므로 MAIL_005', async () => {
provider.findSentMessage.mockResolvedValue({
id: 'sent-1',
fingerprint: null,
});
expect(await codeOf(() => send())).toBe('MAIL_005');
});
it('같은 키의 발송이 처리 중이면 기다리지 않고 429 REQ_001 — 끝나면 다시 받는다', async () => {
let finish!: (value: { id: string }) => void;
provider.sendMessage.mockImplementationOnce(
() => new Promise((resolve) => (finish = resolve)),
);
const first = send();
// 첫 요청이 엔진에 보내는 중이 될 때까지 기다린다
await new Promise((resolve) => setImmediate(resolve));
expect(await codeOf(() => send())).toBe('REQ_001');
// 다른 키·다른 사용자는 막지 않는다
await expect(
send(sendDto(), '00000000-0000-4000-8000-000000000000'),
).resolves.toEqual({ id: 'sent-1' });
finish({ id: 'sent-first' });
await expect(first).resolves.toEqual({ id: 'sent-first' });
await expect(send()).resolves.toEqual({ id: 'sent-1' });
});
it('엔진 장애(MAIL_001)로 끝나면 엔진이 아직 처리 중일 수 있어 30초 동안 같은 키를 REQ_001 로 막는다', async () => {
jest.useFakeTimers({ doNotFake: ['setImmediate', 'nextTick'] });
try {
provider.sendMessage.mockRejectedValueOnce(
new BusinessException('MAIL_001', { context: { reason: 'timeout' } }),
);
expect(await codeOf(() => send())).toBe('MAIL_001');
expect(await codeOf(() => send())).toBe('REQ_001');
jest.advanceTimersByTime(30_000);
await expect(send()).resolves.toEqual({ id: 'sent-1' });
} finally {
jest.useRealTimers();
}
});
it('엔진 장애가 아닌 실패(형식 오류 등)는 곧바로 같은 키로 다시 보낼 수 있다', async () => {
provider.sendMessage.mockRejectedValueOnce(
new BusinessException('MAIL_004'),
);
expect(await codeOf(() => send())).toBe('MAIL_004');
await expect(send()).resolves.toEqual({ id: 'sent-1' });
});
it('이미 보낸 메일을 찾는 중 엔진 장애면 잠그지 않는다 (보낸 것이 없다)', async () => {
provider.findSentMessage.mockRejectedValueOnce(
new BusinessException('MAIL_001'),
);
expect(await codeOf(() => send())).toBe('MAIL_001');
await expect(send()).resolves.toEqual({ id: 'sent-1' });
});
});
it('업로드는 정리한 이름과 확장자로 정한 형식을 엔진에 넘긴다', async () => {
provider.uploadAttachment.mockResolvedValue({
id: 'blob-1',
name: '보고서.pdf',
type: 'application/pdf',
size: 3,
});
await service.uploadAttachment(user, {
originalname: 'C:\\temp\\보고서.pdf',
mimetype: 'application/pdf',
path: '/tmp/arasystem-mail-uploads/5f0c',
size: 3,
} as Express.Multer.File);
// 파일 내용은 메모리로 넘기지 않는다 — multer 가 받은 임시 파일 경로만 넘긴다
expect(provider.uploadAttachment).toHaveBeenCalledWith(user.email, {
path: '/tmp/arasystem-mail-uploads/5f0c',
size: 3,
name: '보고서.pdf',
type: 'application/pdf',
});
});
});
+412
View File
@@ -0,0 +1,412 @@
import { Inject, Injectable, Logger } from '@nestjs/common';
import type { AuthenticatedUser } from '@/common/decorators/current-user.decorator';
import {
PaginatedResult,
toPaginatedResult,
} from '@/common/dto/pagination.dto';
import { BusinessException } from '@/common/errors/business.exception';
import { traceTag } from '@/shared/logger/request-context';
import { maskEmail } from '@/shared/utils/mask';
import {
MAX_ATTACHMENTS_PER_MESSAGE,
resolveAttachmentType,
sanitizeFileName,
} from './attachment.policy';
import type { MessageListQueryDto } from './dto/message-list-query.dto';
import type { RecipientDto, SendMessageDto } from './dto/send-message.dto';
import type { UpdateMessageDto } from './dto/update-message.dto';
import {
INLINE_IMAGES_TOTAL_MAX_BYTES,
INLINE_IMAGE_MAX_BYTES,
findReferencedContentIds,
normalizeContentId,
selectInlineImages,
toDataUri,
} from './html/inline-images';
import { sanitizeMailHtml } from './html/sanitize-mail-html';
import { MAX_RECIPIENTS } from './mail.constants';
import { MAIL_PROVIDER, type MailProvider } from './mail.provider';
import { MAIL_SETTINGS, type MailSettings } from './mail.settings';
import type {
AttachmentContent,
InlinePart,
MailAddress,
MailAttachment,
Mailbox,
MailQuota,
MessageDetail,
MessageSummary,
OutgoingAttachment,
OutgoingMessage,
} from './mail.types';
import { presentMailboxes } from './mailbox.presenter';
import {
idempotentMessageId,
parseIdempotencyKey,
payloadFingerprint,
} from './send-idempotency';
/**
* 엔진 장애(MAIL_001)로 끝난 발송의 키를 잠가 두는 시간.
* 시간 초과·연결 끊김이면 엔진이 아직 그 메일을 처리하고 있을 수 있다 — 곧바로 온 재시도가
* "아직 안 보냈다"고 보고 한 번 더 보내지 않게, 이 시간 동안은 처리 중(REQ_001)으로 답한다.
*/
const UNCERTAIN_SEND_HOLD_MS = 30_000;
/** 헤더에 들어가는 값(제목·표시 이름)의 줄바꿈을 공백으로 — 헤더 분리 시도를 막는다 */
function singleLine(value: string): string {
return value.replace(/[\r\n]+/g, ' ').trim();
}
function toAddress(recipient: RecipientDto): MailAddress {
const name = recipient.name ? singleLine(recipient.name) : '';
return { name: name || null, email: recipient.email.trim() };
}
/**
* 메일 비즈니스 로직 — 메일 엔진은 `MailProvider` 포트로만 다룬다.
* 엔진과 무관한 규칙(표시 이름·정렬, HTML 정화, 받는 사람 수, 첨부 형식)은 여기서 지킨다.
*/
@Injectable()
export class MailService {
private readonly logger = new Logger(MailService.name);
/**
* 처리 중인 (사용자, Idempotency-Key) — 같은 키가 동시에 오면 기다리지 않고 REQ_001.
* ⚠️ 프로세스 메모리다. 인스턴스를 늘리면 Redis 잠금(SET NX PX)으로 옮겨야 같은 키가 두 인스턴스에서 동시에 나가지 않는다.
*/
private readonly sendsInProgress = new Set<string>();
constructor(
@Inject(MAIL_PROVIDER) private readonly provider: MailProvider,
@Inject(MAIL_SETTINGS) private readonly settings: MailSettings,
) {}
async listMailboxes(
user: AuthenticatedUser,
): Promise<PaginatedResult<Mailbox>> {
const items = presentMailboxes(
await this.provider.listMailboxes(user.email),
);
// 메일함은 한 번에 모두 내려준다 — 목록 응답 형식만 맞춘다
return toPaginatedResult(items, {
hasNext: false,
nextCursor: null,
total: items.length,
});
}
/**
* 사용량 + 첨부 한도. 합계 한도는 어댑터가 발송 검사와 같은 함수로 정한 값을 그대로 내려준다
* (화면이 미리 막는 값과 서버가 거절하는 값이 어긋나지 않게).
* 사용량을 먼저 읽어 엔진 세션이 캐시된 뒤 한도를 읽는다 — 세션을 두 번 받아 오지 않는다.
* 어느 쪽이든 엔진에서 읽지 못하면 그 실패(MAIL_001 등)를 그대로 던진다.
*/
async getQuota(user: AuthenticatedUser): Promise<MailQuota> {
const usage = await this.provider.getUsage(user.email);
const attachmentTotalMaxBytes =
await this.provider.getAttachmentsTotalLimit(user.email);
return {
...usage,
attachmentMaxBytes: this.settings.attachmentMaxBytes,
attachmentTotalMaxBytes,
};
}
async listMessages(
user: AuthenticatedUser,
query: MessageListQueryDto,
): Promise<PaginatedResult<MessageSummary>> {
const text = query.q?.trim();
const page = await this.provider.listMessages(user.email, {
mailboxId: query.mailboxId,
limit: query.limit,
cursor: query.cursor,
text: text ? text : undefined,
});
return toPaginatedResult(page.items, {
hasNext: page.hasNext,
nextCursor: page.nextCursor,
total: page.total,
});
}
/** 상세 — 조회만 한다(읽음 처리 없음). HTML 은 정화한 뒤에만 내보낸다 */
async getMessage(
user: AuthenticatedUser,
id: string,
): Promise<MessageDetail> {
const { rawHtmlBody, inlineParts, ...detail } =
await this.provider.getMessage(user.email, id);
if (rawHtmlBody === null) {
return { ...detail, htmlBody: null, hasBlockedImages: false };
}
const inlineImages = await this.loadInlineImages(
user.email,
rawHtmlBody,
inlineParts,
);
const sanitized = sanitizeMailHtml(rawHtmlBody, inlineImages);
return {
...detail,
htmlBody: sanitized.html,
hasBlockedImages: sanitized.hasBlockedImages,
};
}
async updateMessage(
user: AuthenticatedUser,
id: string,
dto: UpdateMessageDto,
): Promise<MessageSummary> {
const { isRead, isFlagged, mailboxId } = dto;
if (
isRead === undefined &&
isFlagged === undefined &&
mailboxId === undefined
) {
throw new BusinessException('VAL_001', {
context: { reason: 'empty-update' },
});
}
return this.provider.updateMessage(user.email, id, {
isRead,
isFlagged,
mailboxId,
});
}
deleteMessage(user: AuthenticatedUser, id: string): Promise<void> {
return this.provider.deleteMessage(user.email, id);
}
/**
* 메일 발송. `idempotencyKey`(Idempotency-Key 헤더)가 있으면 중복 발송을 막는다 (계약 2절):
* - 같은 사용자·같은 키로 이미 보낸 메일이 있으면 다시 보내지 않고 그 id 를 돌려준다
* - 그 메일과 내용(받는 사람·제목·본문·첨부)이 다르면 보내지 않고 409 MAIL_005
* - 같은 키의 요청이 처리 중이면 기다리지 않고 429 REQ_001
* - 키 형식이 틀리면 400 VAL_001, 키가 없으면 중복 방지 없이 보낸다
*/
async sendMessage(
user: AuthenticatedUser,
dto: SendMessageDto,
idempotencyKey?: string,
): Promise<{ id: string }> {
const parsed = parseIdempotencyKey(idempotencyKey);
if (!parsed.ok) {
throw new BusinessException('VAL_001', {
context: { reason: 'invalid-idempotency-key' },
});
}
const message = this.prepareOutgoing(dto);
if (parsed.key === null) return this.deliver(user, message);
return this.deliverOnce(user, message, parsed.key);
}
/** 같은 키로는 한 번만 보낸다 — 처리 중이면 REQ_001, 이미 보냈으면 그 결과, 내용이 다르면 MAIL_005 */
private async deliverOnce(
user: AuthenticatedUser,
message: OutgoingMessage,
key: string,
): Promise<{ id: string }> {
const lockKey = `${user.email.toLowerCase()}\n${key}`;
if (this.sendsInProgress.has(lockKey)) {
throw new BusinessException('REQ_001', {
context: {
reason: 'idempotency-key-in-progress',
from: maskEmail(user.email),
},
});
}
this.sendsInProgress.add(lockKey);
let holdLock = false;
try {
const messageId = idempotentMessageId(user.email, key);
// 지문 키는 이 요청의 Idempotency-Key 다 — 서버 비밀을 쓰지 않는다 (send-idempotency.ts 머리말)
const fingerprint = payloadFingerprint(message, key);
const previous = await this.provider.findSentMessage(
user.email,
messageId,
);
if (previous) {
if (previous.fingerprint !== fingerprint) {
throw new BusinessException('MAIL_005', {
context: {
reason: 'idempotency-key-reused-with-different-payload',
from: maskEmail(user.email),
sentId: previous.id,
},
});
}
this.logger.log(
`${traceTag()} 같은 Idempotency-Key 의 재발송 — 이미 보낸 메일을 돌려준다: from=${maskEmail(user.email)}, id=${previous.id}`,
);
return { id: previous.id };
}
try {
return await this.deliver(user, { ...message, messageId, fingerprint });
} catch (error) {
// 엔진에 닿지 못했거나 답을 못 받았다 — 엔진이 아직 처리 중일 수 있으니 잠시 같은 키를 막아 둔다
holdLock =
error instanceof BusinessException && error.code === 'MAIL_001';
throw error;
}
} finally {
if (holdLock) {
setTimeout(
() => this.sendsInProgress.delete(lockKey),
UNCERTAIN_SEND_HOLD_MS,
).unref();
} else {
this.sendsInProgress.delete(lockKey);
}
}
}
/** 엔진과 무관한 발송 규칙 — 받는 사람 수(MAIL_002), 첨부 형식(FILE_001), 헤더 값 줄바꿈 */
private prepareOutgoing(dto: SendMessageDto): OutgoingMessage {
const to = dto.to.map(toAddress);
const cc = (dto.cc ?? []).map(toAddress);
const bcc = (dto.bcc ?? []).map(toAddress);
const recipientCount = to.length + cc.length + bcc.length;
if (recipientCount > MAX_RECIPIENTS) {
throw new BusinessException('MAIL_002', {
context: { reason: 'too-many-recipients', count: recipientCount },
});
}
// 첨부 개수 — 업로드한 파일과 전달하는 원본 첨부를 함께 센다 (한도는 attachment.policy.ts)
const requested = dto.attachments ?? [];
if (requested.length > MAX_ATTACHMENTS_PER_MESSAGE) {
throw new BusinessException('MAIL_006', {
context: { reason: 'too-many-attachments', count: requested.length },
});
}
// 업로드한 파일이든 전달하는 원본 첨부든 같은 규칙 — 이름의 확장자로 허용 목록에서 형식을 정한다
const attachments = requested.map((attachment): OutgoingAttachment => {
const name = sanitizeFileName(attachment.name);
const type = resolveAttachmentType(name);
if (!type) {
throw new BusinessException('FILE_001', {
context: { reason: 'attachment-type-not-allowed' },
});
}
return { id: attachment.id, name, type };
});
return {
to,
cc,
bcc,
subject: singleLine(dto.subject),
body: dto.body,
attachments,
inReplyTo: dto.inReplyTo,
};
}
private async deliver(
user: AuthenticatedUser,
message: OutgoingMessage,
): Promise<{ id: string }> {
const result = await this.provider.sendMessage(user.email, message);
// 제목·본문·받는 사람 주소는 남기지 않는다 (.claude/rules/logging.md)
const recipientCount =
message.to.length + message.cc.length + message.bcc.length;
this.logger.log(
`메일 발송: from=${maskEmail(user.email)}, 받는 사람 ${recipientCount}명, 첨부 ${message.attachments.length}개${message.inReplyTo ? ', 답장' : ''}${message.messageId ? ', 중복 방지 키' : ''}`,
);
return result;
}
async uploadAttachment(
user: AuthenticatedUser,
file: Express.Multer.File,
): Promise<MailAttachment> {
const name = sanitizeFileName(file.originalname);
const type = resolveAttachmentType(name);
if (!type) {
// multer fileFilter 가 먼저 막지만, 이름 정리 후 확장자가 바뀌는 경우까지 한 번 더 확인한다
throw new BusinessException('FILE_001', {
context: { reason: 'attachment-type-not-allowed' },
});
}
// multer 가 임시 파일에 받아 둔 것을 엔진으로 흘려보낸다 — 임시 파일은 요청이 끝나면 인터셉터가 지운다
return this.provider.uploadAttachment(user.email, {
path: file.path,
size: file.size,
name,
type,
});
}
openAttachment(
user: AuthenticatedUser,
messageId: string,
attachmentId: string,
): Promise<AttachmentContent> {
return this.provider.openAttachment(user.email, messageId, attachmentId);
}
/**
* 본문이 참조하는 작은 내장 이미지를 data: URI 로 준비한다.
* 하나를 읽지 못해도 메일 전체를 실패시키지 않는다 — 그 이미지만 빠진다.
*/
private async loadInlineImages(
user: string,
html: string,
parts: readonly InlinePart[],
): Promise<Map<string, string>> {
const images = new Map<string, string>();
const candidates = selectInlineImages(
parts,
findReferencedContentIds(html),
);
if (candidates.length === 0) return images;
const loaded = await Promise.all(
candidates.map(async (part) => {
try {
return {
part,
data: await this.provider.readAttachmentBytes(
user,
part.attachmentId,
INLINE_IMAGE_MAX_BYTES,
),
};
} catch (error) {
this.logger.warn(
`[MAIL_001]${traceTag()} 본문 내장 이미지를 읽지 못해 건너뜁니다: ${error instanceof Error ? error.message : String(error)}`,
);
return { part, data: null };
}
}),
);
let total = 0;
for (const { part, data } of loaded) {
const fits =
data !== null && total + data.length <= INLINE_IMAGES_TOTAL_MAX_BYTES;
if (!fits) continue;
const dataUri = toDataUri(part.type, data);
if (!dataUri) continue;
total += data.length;
images.set(normalizeContentId(part.cid), dataUri);
}
return images;
}
}
@@ -0,0 +1,160 @@
import {
DEFAULT_ATTACHMENT_MAX_SIZE_MB,
DEFAULT_ATTACHMENT_TOTAL_MAX_SIZE_MB,
buildMailSettings,
isMailEngineConfigured,
parseAccountList,
} from './mail.settings';
const reader =
(values: Record<string, string>) =>
(key: string): string | undefined =>
values[key];
const MB = 1024 * 1024;
describe('buildMailSettings', () => {
it('내부 JMAP 주소는 origin 만 남긴다 (세션의 경로를 이 origin 에 붙인다)', () => {
const settings = buildMailSettings(
reader({ MAIL_JMAP_URL: 'http://mail:8080/' }),
);
expect(settings.jmapBaseUrl).toBe('http://mail:8080');
});
it('도메인·게이트웨이 주소는 소문자로 맞춘다', () => {
const settings = buildMailSettings(
reader({
MAIL_DOMAIN: 'AraSystem.Test',
MAIL_GATEWAY_USER: 'Mail-Gateway@AraSystem.Test',
}),
);
expect(settings.domain).toBe('arasystem.test');
expect(settings.gatewayUser).toBe('mail-gateway@arasystem.test');
});
it('빈 값은 미설정으로 본다 (compose 가 미정의 변수를 빈 문자열로 주입한다)', () => {
const settings = buildMailSettings(
reader({
MAIL_JMAP_URL: '',
MAIL_DOMAIN: ' ',
MAIL_GATEWAY_USER: '',
MAIL_GATEWAY_PASSWORD: '',
}),
);
expect(isMailEngineConfigured(settings)).toBe(false);
});
it('필수값이 모두 있어야 설정된 것으로 본다', () => {
const complete = {
MAIL_JMAP_URL: 'http://mail:8080',
MAIL_DOMAIN: 'arasystem.test',
MAIL_GATEWAY_USER: 'mail-gateway@arasystem.test',
MAIL_GATEWAY_PASSWORD: 'secret-value',
};
expect(isMailEngineConfigured(buildMailSettings(reader(complete)))).toBe(
true,
);
expect(
isMailEngineConfigured(
buildMailSettings(reader({ ...complete, MAIL_GATEWAY_PASSWORD: '' })),
),
).toBe(false);
});
it('첨부 한도는 MB 단위 env 를 바이트로 바꾸고, 없거나 잘못되면 기본값이다', () => {
expect(
buildMailSettings(reader({ MAIL_ATTACHMENT_MAX_SIZE_MB: '5' }))
.attachmentMaxBytes,
).toBe(5 * MB);
expect(buildMailSettings(reader({})).attachmentMaxBytes).toBe(
DEFAULT_ATTACHMENT_MAX_SIZE_MB * MB,
);
expect(
buildMailSettings(reader({ MAIL_ATTACHMENT_MAX_SIZE_MB: '' }))
.attachmentMaxBytes,
).toBe(DEFAULT_ATTACHMENT_MAX_SIZE_MB * MB);
});
it('첨부 합계 한도는 기본 25MB, env 로 바꿀 수 있다', () => {
expect(buildMailSettings(reader({})).attachmentTotalMaxBytes).toBe(
DEFAULT_ATTACHMENT_TOTAL_MAX_SIZE_MB * MB,
);
expect(
buildMailSettings(reader({ MAIL_ATTACHMENT_TOTAL_MAX_SIZE_MB: '40' }))
.attachmentTotalMaxBytes,
).toBe(40 * MB);
});
it('파일 하나 한도만 올리면 합계 한도도 그만큼은 된다 — 큰 파일 하나를 못 보내는 설정이 되지 않게', () => {
expect(
buildMailSettings(reader({ MAIL_ATTACHMENT_MAX_SIZE_MB: '30' }))
.attachmentTotalMaxBytes,
).toBe(30 * MB);
});
it('메일 관리자 아이디 기본값은 admin — admin@<도메인> 을 설정 없이도 막고, MAIL_BLOCKED_ACCOUNTS 로 더한다 (소문자)', () => {
expect([
...buildMailSettings(reader({ MAIL_DOMAIN: 'AraSystem.Test' }))
.blockedAccounts,
]).toEqual(['admin@arasystem.test']);
expect([
...buildMailSettings(
reader({
MAIL_DOMAIN: 'arasystem.test',
MAIL_BLOCKED_ACCOUNTS: ' Ops@AraSystem.test, ,audit@arasystem.test ',
}),
).blockedAccounts,
]).toEqual([
'admin@arasystem.test',
'ops@arasystem.test',
'audit@arasystem.test',
]);
});
it('관리자 아이디를 MAIL_ADMIN_LOCAL_PART 로 바꾸면 그 주소를 막고, admin@ 은 직원 메일함으로 쓸 수 있다', () => {
const blocked = buildMailSettings(
reader({
MAIL_DOMAIN: 'arasystem.test',
MAIL_ADMIN_LOCAL_PART: ' Mail-Admin ',
MAIL_BLOCKED_ACCOUNTS: 'ops@arasystem.test',
}),
).blockedAccounts;
expect([...blocked]).toEqual([
'mail-admin@arasystem.test',
'ops@arasystem.test',
]);
expect(blocked.has('admin@arasystem.test')).toBe(false);
});
it('관리자 아이디가 빈 값이면 기본값 admin (compose 가 미정의 변수를 빈 문자열로 주입한다)', () => {
expect([
...buildMailSettings(
reader({ MAIL_DOMAIN: 'arasystem.test', MAIL_ADMIN_LOCAL_PART: ' ' }),
).blockedAccounts,
]).toEqual(['admin@arasystem.test']);
});
it('도메인이 없으면 목록에 적은 계정만 막는다', () => {
expect([
...buildMailSettings(reader({ MAIL_BLOCKED_ACCOUNTS: 'ops@x.test' }))
.blockedAccounts,
]).toEqual(['ops@x.test']);
expect(buildMailSettings(reader({})).blockedAccounts.size).toBe(0);
});
});
describe('parseAccountList', () => {
it('콤마로 나누고 앞뒤 공백·빈 항목을 버리며 소문자로 맞춘다', () => {
expect(parseAccountList(' A@x.test,,b@X.test , ')).toEqual([
'a@x.test',
'b@x.test',
]);
expect(parseAccountList(undefined)).toEqual([]);
});
});
+136
View File
@@ -0,0 +1,136 @@
import { parsePositiveInt } from '@/shared/utils/env';
/**
* 메일 엔진 연결 설정 — 루트 `.env` 에서 compose 가 주입한다 (mail/README.md "백엔드 연결 정보").
*
* 비운영 환경에서는 값이 없어도 기동한다. 그때 메일 API 는 MAIL_001 을 돌려준다.
* (운영은 env.validation.ts 가 필수로 요구한다)
*/
export const MAIL_SETTINGS = Symbol('MAIL_SETTINGS');
export interface MailSettings {
/** 내부 JMAP 주소의 origin (예: http://mail:8080) — 세션 응답의 공개 주소 대신 이 주소에 경로만 붙인다 */
readonly jmapBaseUrl: string | null;
/** 사내 메일 도메인 (소문자) — 이 도메인 주소만 로그인할 수 있다 */
readonly domain: string | null;
/** 대리 접근 전용 게이트웨이 계정 주소 전체 (소문자) — 직접 로그인은 막는다 */
readonly gatewayUser: string | null;
readonly gatewayPassword: string | null;
/** 첨부 1개 최대 크기 (바이트) */
readonly attachmentMaxBytes: number;
/**
* 메일 한 통의 첨부 합계 최대 크기 (바이트) — 넘으면 FILE_002.
* 받는 쪽 메일 서버가 큰 메일을 거부하는 일이 많아(흔히 25MB) 엔진 한도(50MB)보다 낮게 둔다.
* 첨부 1개 한도보다 작지 않다 (파일 하나는 항상 보낼 수 있게).
*/
readonly attachmentTotalMaxBytes: number;
/**
* 로그인·대리 접근을 막는 계정(소문자 주소) — 메일 관리자 `<MAIL_ADMIN_LOCAL_PART>@<도메인>`(기본 admin) + MAIL_BLOCKED_ACCOUNTS.
* 게이트웨이는 관리자 계정도 대리 접근할 수 있고, 그 세션은 **관리 권한**을 가진다(Stalwart 0.16 에서 확인).
* 웹메일이 관리자 계정을 다루지 않게 하고, 공개된 로그인 창이 관리자 비밀번호를 시험해 보는 창구가 되지 않게
* 메일 엔진에 묻기 **전에** 거절한다.
*/
readonly blockedAccounts: ReadonlySet<string>;
}
/** 메일 엔진에 붙을 수 있는 설정 — 필수값이 모두 있는 상태 */
export type ConfiguredMailSettings = MailSettings & {
readonly jmapBaseUrl: string;
readonly domain: string;
readonly gatewayUser: string;
readonly gatewayPassword: string;
};
/** 첨부 1개 기본 한도 (MB) — 계약의 기본값 */
export const DEFAULT_ATTACHMENT_MAX_SIZE_MB = 20;
/** 첨부 합계 기본 한도 (MB) — MAIL_ATTACHMENT_TOTAL_MAX_SIZE_MB */
export const DEFAULT_ATTACHMENT_TOTAL_MAX_SIZE_MB = 25;
/**
* 메일 엔진 관리자 아이디의 기본값 — 루트 env `MAIL_ADMIN_LOCAL_PART` 로 바꾼다.
* 메일 엔진 설정 스크립트(`mail/scripts/setup.mjs`)가 같은 env 로 영구 관리자를 만든다 — 두 쪽이 한 값을 읽는다.
*
* 옮겨 올 메일함에 같은 이름의 주소(예: 실제 직원 메일함 admin@<도메인>)가 있으면 **bootstrap 전에** 다른 아이디로 정한다.
* 그대로 두면 엔진 관리자와 이름이 겹치고, 그 메일함은 웹메일에 로그인할 수 없다 (관리자 주소는 로그인을 막는다).
* 다른 관리자 계정은 MAIL_BLOCKED_ACCOUNTS 로 더한다.
*/
export const DEFAULT_MAIL_ADMIN_LOCAL_PART = 'admin';
const MEGABYTE = 1024 * 1024;
function readTrimmed(
read: (key: string) => string | undefined,
key: string,
): string | null {
const value = read(key)?.trim();
return value ? value : null;
}
function toOrigin(url: string | null): string | null {
if (url === null) return null;
try {
return new URL(url).origin;
} catch {
// 형식 오류는 env 검증이 기동 시점에 막는다 — 여기서는 "미설정"으로 취급한다
return null;
}
}
/** env 값을 읽어 메일 설정을 만든다 (순수 함수 — 읽기 함수를 주입받는다) */
export function buildMailSettings(
read: (key: string) => string | undefined,
): MailSettings {
const attachmentMaxBytes =
parsePositiveInt(
read('MAIL_ATTACHMENT_MAX_SIZE_MB'),
DEFAULT_ATTACHMENT_MAX_SIZE_MB,
) * MEGABYTE;
const attachmentTotalMaxBytes =
parsePositiveInt(
read('MAIL_ATTACHMENT_TOTAL_MAX_SIZE_MB'),
DEFAULT_ATTACHMENT_TOTAL_MAX_SIZE_MB,
) * MEGABYTE;
const domain = readTrimmed(read, 'MAIL_DOMAIN')?.toLowerCase() ?? null;
// 형식(주소가 아니라 아이디만)은 env 검증이 기동 시점에 막는다
const adminLocalPart = (
readTrimmed(read, 'MAIL_ADMIN_LOCAL_PART') ?? DEFAULT_MAIL_ADMIN_LOCAL_PART
).toLowerCase();
return {
jmapBaseUrl: toOrigin(readTrimmed(read, 'MAIL_JMAP_URL')),
domain,
gatewayUser: readTrimmed(read, 'MAIL_GATEWAY_USER')?.toLowerCase() ?? null,
gatewayPassword: readTrimmed(read, 'MAIL_GATEWAY_PASSWORD'),
attachmentMaxBytes,
// 파일 하나 한도만 올리고 합계를 그대로 두면 큰 파일 하나를 못 보내게 된다 — 합계는 그보다 작아지지 않는다
attachmentTotalMaxBytes: Math.max(
attachmentTotalMaxBytes,
attachmentMaxBytes,
),
blockedAccounts: new Set([
...(domain ? [`${adminLocalPart}@${domain}`] : []),
...parseAccountList(read('MAIL_BLOCKED_ACCOUNTS')),
]),
};
}
/** 콤마로 나열한 주소 목록 → 소문자 주소 (형식 검사는 env 검증이 한다) */
export function parseAccountList(raw: string | undefined): string[] {
return (raw ?? '')
.split(',')
.map((entry) => entry.trim().toLowerCase())
.filter((entry) => entry !== '');
}
export function isMailEngineConfigured(
settings: MailSettings,
): settings is ConfiguredMailSettings {
return (
settings.jmapBaseUrl !== null &&
settings.domain !== null &&
settings.gatewayUser !== null &&
settings.gatewayPassword !== null
);
}
+189
View File
@@ -0,0 +1,189 @@
import type { Readable } from 'stream';
/**
* 메일 도메인 타입 — API 계약(docs/mail/api-contract.md 2절)과 같은 모양이다.
*
* 메일 엔진(JMAP 등)의 타입은 어댑터 밖으로 나오지 않는다. 엔진을 바꿔도 이 타입은 그대로다.
*/
export type MailboxRole =
| 'inbox'
| 'drafts'
| 'sent'
| 'junk'
| 'trash'
| 'archive';
export interface MailAddress {
readonly name: string | null;
readonly email: string;
}
export interface Mailbox {
readonly id: string;
/** 역할이 있으면 한국어 표준 이름, 없으면 서버의 이름 (표시 이름은 서비스 계층이 정한다) */
readonly name: string;
readonly role: MailboxRole | null;
readonly parentId: string | null;
readonly sortOrder: number;
readonly totalCount: number;
readonly unreadCount: number;
}
export interface MessageSummary {
readonly id: string;
readonly threadId: string;
readonly mailboxIds: string[];
readonly from: MailAddress | null;
readonly to: MailAddress[];
/** 제목이 없으면 빈 문자열 */
readonly subject: string;
readonly preview: string;
/** ISO 8601 UTC */
readonly receivedAt: string;
readonly isRead: boolean;
readonly isFlagged: boolean;
readonly hasAttachment: boolean;
/** 바이트 */
readonly size: number;
}
export interface MailAttachment {
readonly id: string;
readonly name: string;
readonly type: string;
readonly size: number;
}
export interface MessageDetail extends MessageSummary {
readonly cc: MailAddress[];
readonly bcc: MailAddress[];
readonly replyTo: MailAddress[];
readonly sentAt: string | null;
/** Message-ID 헤더 값 (꺾쇠 괄호 없이) */
readonly messageId: string | null;
readonly references: string[];
/** 서버가 정화한 HTML — 정화 전 원본은 절대 내보내지 않는다 */
readonly htmlBody: string | null;
readonly textBody: string | null;
/** 외부 이미지를 지웠으면 true */
readonly hasBlockedImages: boolean;
readonly attachments: MailAttachment[];
}
/** 본문이 `cid:` 로 참조할 수 있는 내장 부분 (첨부 목록에 있는 것 중 Content-ID 가 있는 것) */
export interface InlinePart {
readonly attachmentId: string;
/** Content-ID (꺾쇠 괄호 없이) */
readonly cid: string;
readonly type: string;
readonly size: number;
}
/**
* 어댑터가 돌려주는 메일 상세 원본.
* HTML 은 **정화 전** 값이다 — 서비스 계층이 정화한 뒤에만 MessageDetail 로 내보낸다.
*/
export interface MessageDetailSource extends Omit<
MessageDetail,
'htmlBody' | 'textBody' | 'hasBlockedImages'
> {
readonly rawHtmlBody: string | null;
readonly textBody: string | null;
readonly inlineParts: InlinePart[];
}
export interface MessageListQuery {
readonly mailboxId: string;
readonly limit: number;
/** 불투명 커서 — 어댑터가 만들고 해석한다 */
readonly cursor?: string;
/** 자유 텍스트 검색어 */
readonly text?: string;
}
export interface MessagePage {
readonly items: MessageSummary[];
readonly hasNext: boolean;
readonly nextCursor: string | null;
readonly total: number | null;
}
export interface MessageUpdate {
readonly isRead?: boolean;
readonly isFlagged?: boolean;
/** 옮길 메일함 — 지정하면 이 메일함 하나에만 속하게 된다 */
readonly mailboxId?: string;
}
export interface OutgoingAttachment {
/** 업로드 응답의 id */
readonly id: string;
readonly name: string;
/** 서버가 파일명 확장자로 정한 형식 (클라이언트 값이 아니다) */
readonly type: string;
}
export interface OutgoingMessage {
readonly to: MailAddress[];
readonly cc: MailAddress[];
readonly bcc: MailAddress[];
readonly subject: string;
/** 일반 텍스트 본문 */
readonly body: string;
readonly attachments: OutgoingAttachment[];
/** 답장할 메일의 id — 어댑터가 스레드 헤더(In-Reply-To·References)를 채운다 */
readonly inReplyTo?: string;
/**
* 중복 발송 방지(Idempotency-Key)용 Message-ID (꺾쇠 괄호 없이) — 같은 사용자·같은 키면 같은 값이다.
* 어댑터는 이 값을 Message-ID 헤더로 쓰고, 나중에 `findSentMessage` 로 찾을 수 있어야 한다.
*/
readonly messageId?: string;
/** 내용 지문 — 같은 키로 내용이 바뀌었는지 비교한다. 어댑터는 메일과 함께 남겨 `findSentMessage` 로 돌려준다 */
readonly fingerprint?: string;
}
/** 같은 Message-ID 로 이미 발송한 메일 */
export interface SentMessageRecord {
/** 보낸편지함의 메일 id */
readonly id: string;
/** 발송 때 남긴 내용 지문 — 없으면 null */
readonly fingerprint: string | null;
}
/** 업로드된 첨부 — 임시 파일에 있다 (요청이 끝나면 지워진다) */
export interface UploadedFile {
readonly path: string;
readonly size: number;
readonly name: string;
/** 서버가 정한 형식 */
readonly type: string;
}
export interface AttachmentContent {
readonly stream: Readable;
readonly name: string;
/** 알 수 있으면 바이트 수 */
readonly size: number | null;
}
/** 메일함 사용량 */
export interface MailUsage {
readonly usedBytes: number;
/** 한도가 없으면 null */
readonly limitBytes: number | null;
}
/** 계약의 MailQuota — 사용량 + 서버의 첨부 한도 */
export interface MailQuota extends MailUsage {
/** 첨부 1개 최대 크기 (MAIL_ATTACHMENT_MAX_SIZE_MB) */
readonly attachmentMaxBytes: number;
/** 메일 한 통의 첨부 합계 한도 — 발송 검사(FILE_002)와 같은 값 (설정 한도와 엔진 한도 중 작은 값) */
readonly attachmentTotalMaxBytes: number;
}
/** 메일 계정 = 직원 계정 */
export interface MailAccountProfile {
readonly email: string;
readonly name: string;
}
@@ -0,0 +1,59 @@
import { Controller, Get, UseGuards } from '@nestjs/common';
import { ApiCookieAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
import {
ApiErrorResponse,
ApiStandardResponse,
} from '@/common/decorators/api-standard-response.decorator';
import {
CurrentUser,
type AuthenticatedUser,
} from '@/common/decorators/current-user.decorator';
import { JwtAuthGuard } from '@/common/guards/jwt-auth.guard';
import { MailboxPageDto, MailQuotaDto } from './dto/mail-response.dto';
import { MailService } from './mail.service';
@ApiTags('Mail')
@ApiCookieAuth()
@UseGuards(JwtAuthGuard)
@ApiErrorResponse(
'AUTH_001',
'로그인 필요 (쿠키 없음·만료) — 클라이언트는 /auth/refresh 를 한 번 시도한다',
)
@ApiErrorResponse('MAIL_001', '메일 엔진 장애')
@Controller('mailboxes')
export class MailboxController {
constructor(private readonly mail: MailService) {}
@Get()
@ApiOperation({ summary: '메일함 목록' })
@ApiStandardResponse(MailboxPageDto, {
description:
'역할 메일함(한국어 이름) → 그 외 순서. 한 번에 모두 내려준다 (hasNext=false)',
})
list(@CurrentUser() user: AuthenticatedUser) {
return this.mail.listMailboxes(user);
}
}
@ApiTags('Mail')
@ApiCookieAuth()
@UseGuards(JwtAuthGuard)
@ApiErrorResponse('AUTH_001', '로그인 필요 (쿠키 없음·만료)')
@ApiErrorResponse('MAIL_001', '메일 엔진 장애')
@Controller('mail-quota')
export class MailQuotaController {
constructor(private readonly mail: MailService) {}
@Get()
@ApiOperation({
summary: '메일함 사용량 · 한도 · 첨부 최대 크기(1개 · 한 통 합계)',
description:
'한도가 없으면 limitBytes 는 null 이고, 사용량은 메일 크기 합계(근삿값, 5분 캐시)다. ' +
'attachmentTotalMaxBytes 는 발송 때 검사하는 합계 한도와 같은 값이다.',
})
@ApiStandardResponse(MailQuotaDto, { description: '사용량 조회 성공' })
get(@CurrentUser() user: AuthenticatedUser) {
return this.mail.getQuota(user);
}
}
@@ -0,0 +1,72 @@
import type { Mailbox, MailboxRole } from './mail.types';
import { presentMailboxes } from './mailbox.presenter';
const box = (
id: string,
name: string,
role: MailboxRole | null,
sortOrder = 0,
): Mailbox => ({
id,
name,
role,
parentId: null,
sortOrder,
totalCount: 0,
unreadCount: 0,
});
describe('presentMailboxes', () => {
// 검증된 Stalwart 기본 메일함 (영어 이름, sortOrder 모두 0)
const stalwartDefaults = [
box('b', 'Deleted Items', 'trash'),
box('d', 'Drafts', 'drafts'),
box('c', 'Junk Mail', 'junk'),
box('e', 'Sent Items', 'sent'),
box('a', 'Inbox', 'inbox'),
];
it('역할이 있으면 한국어 표준 이름을 쓰고 확정 디자인 순서로 놓는다', () => {
const names = presentMailboxes(stalwartDefaults).map((m) => m.name);
expect(names).toEqual([
'받은편지함',
'보낸편지함',
'임시보관함',
'스팸메일함',
'휴지통',
]);
});
it('역할 메일함 다음에 그 외 메일함을 sortOrder·이름 순으로 놓는다', () => {
const result = presentMailboxes([
box('x', '프로젝트', null, 2),
box('y', '거래처', null, 1),
box('z', '가나다', null, 2),
box('arc', 'Archive', 'archive'),
...stalwartDefaults,
]);
expect(result.map((m) => m.id)).toEqual([
'a',
'e',
'd',
'c',
'b',
'arc',
'y',
'z',
'x',
]);
expect(result.find((m) => m.id === 'arc')?.name).toBe('보관함');
expect(result.find((m) => m.id === 'x')?.name).toBe('프로젝트');
});
it('원본 배열과 객체를 바꾸지 않는다', () => {
const input = [box('a', 'Inbox', 'inbox')];
presentMailboxes(input);
expect(input[0].name).toBe('Inbox');
});
});
@@ -0,0 +1,46 @@
import type { Mailbox, MailboxRole } from './mail.types';
/**
* 메일함 표시 규칙 (순수 함수) — 계약 2절.
* 엔진의 영어 이름("Inbox", "Sent Items" …) 대신 역할별 한국어 표준 이름을 쓰고, 정해진 순서로 놓는다.
*/
const ROLE_NAMES: Readonly<Record<MailboxRole, string>> = {
inbox: '받은편지함',
drafts: '임시보관함',
sent: '보낸편지함',
junk: '스팸메일함',
trash: '휴지통',
archive: '보관함',
};
/** 받은편지함 → 보낸편지함 → 임시보관함 → 스팸메일함 → 휴지통 → 보관함 → 그 외 (확정 디자인 기준) */
const ROLE_ORDER: readonly MailboxRole[] = [
'inbox',
'sent',
'drafts',
'junk',
'trash',
'archive',
];
function roleRank(role: MailboxRole | null): number {
return role === null ? ROLE_ORDER.length : ROLE_ORDER.indexOf(role);
}
function compareMailboxes(a: Mailbox, b: Mailbox): number {
return (
roleRank(a.role) - roleRank(b.role) ||
a.sortOrder - b.sortOrder ||
a.name.localeCompare(b.name, 'ko')
);
}
/** 표시 이름을 붙이고 정렬한다 (원본 배열은 바꾸지 않는다) */
export function presentMailboxes(mailboxes: readonly Mailbox[]): Mailbox[] {
return mailboxes
.map((mailbox) =>
mailbox.role ? { ...mailbox, name: ROLE_NAMES[mailbox.role] } : mailbox,
)
.sort(compareMailboxes);
}
@@ -0,0 +1,226 @@
import {
Body,
Controller,
Delete,
Get,
Headers,
HttpCode,
HttpStatus,
Logger,
Param,
Patch,
Post,
Query,
Res,
StreamableFile,
UseGuards,
} from '@nestjs/common';
import {
ApiCookieAuth,
ApiHeader,
ApiOperation,
ApiProduces,
ApiResponse,
ApiTags,
} from '@nestjs/swagger';
import type { Response } from 'express';
import {
ApiErrorResponse,
ApiStandardResponse,
} from '@/common/decorators/api-standard-response.decorator';
import {
CurrentUser,
type AuthenticatedUser,
} from '@/common/decorators/current-user.decorator';
import { SkipTransform } from '@/common/decorators/skip-transform.decorator';
import { JwtAuthGuard } from '@/common/guards/jwt-auth.guard';
import { currentRequestId } from '@/shared/logger/request-context';
import { buildAttachmentDisposition } from '@/shared/utils/content-disposition';
import {
MessageDetailDto,
MessagePageDto,
MessageSummaryDto,
SentMessageDto,
} from './dto/mail-response.dto';
import { MAX_ATTACHMENTS_PER_MESSAGE } from './attachment.policy';
import { MessageListQueryDto } from './dto/message-list-query.dto';
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 { IDEMPOTENCY_KEY_HEADER } from './send-idempotency';
/** 첨부는 항상 내려받게 한다 — 브라우저가 내용을 해석해 그리지 않도록 형식을 서버가 정한다 */
const ATTACHMENT_CONTENT_TYPE = 'application/octet-stream';
@ApiTags('Mail')
@ApiCookieAuth()
@UseGuards(JwtAuthGuard)
@ApiErrorResponse(
'AUTH_001',
'로그인 필요 (쿠키 없음·만료) — 클라이언트는 /auth/refresh 를 한 번 시도한다',
)
@Controller('messages')
export class MessageController {
private readonly logger = new Logger(MessageController.name);
constructor(private readonly mail: MailService) {}
@Get()
@ApiOperation({
summary: '메일 목록 (커서 페이지네이션, 받은 시각 내림차순)',
})
@ApiStandardResponse(MessagePageDto, {
description: '메일 목록 (total 포함)',
})
@ApiErrorResponse('VAL_001', 'mailboxId 누락·잘못된 커서·limit 범위 초과')
@ApiErrorResponse(
'RES_001',
'없는 메일함 (엔진이 해석하지 못하는 mailboxId 포함)',
)
@ApiErrorResponse('MAIL_001', '메일 엔진 장애')
list(
@CurrentUser() user: AuthenticatedUser,
@Query() query: MessageListQueryDto,
) {
return this.mail.listMessages(user, query);
}
@Get(':id')
@ApiOperation({
summary: '메일 상세 — 조회만으로 읽음 처리하지 않는다',
description:
'htmlBody 는 서버가 정화한 HTML 이다. 클라이언트는 격리된 iframe 에만 그린다.',
})
@ApiStandardResponse(MessageDetailDto, { description: '메일 상세' })
@ApiErrorResponse('RES_001', '없는 메일')
@ApiErrorResponse('MAIL_001', '메일 엔진 장애')
get(
@CurrentUser() user: AuthenticatedUser,
@Param('id', MailIdPipe) id: string,
) {
return this.mail.getMessage(user, id);
}
@Patch(':id')
@ApiOperation({ summary: '읽음 · 중요 표시 · 메일함 이동 (하나 이상 지정)' })
@ApiStandardResponse(MessageSummaryDto, { description: '바뀐 메일 요약' })
@ApiErrorResponse('RES_001', '없는 메일')
@ApiErrorResponse('VAL_001', '빈 요청·잘못된 값·없는 메일함으로 이동')
update(
@CurrentUser() user: AuthenticatedUser,
@Param('id', MailIdPipe) id: string,
@Body() dto: UpdateMessageDto,
) {
return this.mail.updateMessage(user, id, dto);
}
@Delete(':id')
@HttpCode(HttpStatus.NO_CONTENT)
@SkipTransform()
@ApiOperation({
summary: '삭제 — 휴지통이 아니면 휴지통으로 이동, 휴지통이면 영구 삭제',
})
@ApiResponse({
status: HttpStatus.NO_CONTENT,
description: '삭제(이동) 완료 — 본문 없음',
})
@ApiErrorResponse('RES_001', '없는 메일')
async remove(
@CurrentUser() user: AuthenticatedUser,
@Param('id', MailIdPipe) id: string,
): Promise<void> {
await this.mail.deleteMessage(user, id);
}
@Post()
@ApiOperation({
summary:
'메일 발송 (답장: inReplyTo · 전달: 원본 첨부 id 를 attachments 에)',
description:
'Idempotency-Key 가 같으면 한 번만 보낸다 — 이미 보냈으면 그 결과(201 { id })를 그대로 돌려준다. ' +
'같은 키인데 내용이 다르면 409 MAIL_005, 같은 키가 처리 중이면 429 REQ_001.',
})
@ApiHeader({
name: 'Idempotency-Key',
required: false,
description:
'중복 발송 방지 키 (UUID) — 쓰기 화면마다 하나 만들고, 다시 보낼 때(타임아웃 뒤 재시도 등)도 같은 키를 보낸다. 형식이 틀리면 400 VAL_001',
})
@ApiStandardResponse(SentMessageDto, {
status: HttpStatus.CREATED,
description:
'발송 완료(또는 같은 키로 이미 보낸 메일) — 보낸편지함의 메일 id',
})
@ApiErrorResponse(
'MAIL_002',
`MAIL_002 받는 사람 주소 · MAIL_003 메일함 용량 · MAIL_004 첨부를 찾을 수 없음(만료된 업로드는 다시 올리고, 지워진 원본 첨부는 뺀다) · MAIL_006 첨부 ${MAX_ATTACHMENTS_PER_MESSAGE}개 초과 · VAL_001 형식·Idempotency-Key 형식 · FILE_001 첨부 형식 (모두 400)`,
)
@ApiErrorResponse(
'MAIL_005',
'같은 Idempotency-Key 로 이미 보낸 메일과 내용이 다르다 — 보내지 않았다 (보낸편지함 확인 후 새 키로)',
)
@ApiErrorResponse('FILE_002', '첨부 합계 크기 초과')
@ApiErrorResponse(
'REQ_001',
'같은 Idempotency-Key 의 발송이 처리 중(또는 엔진 장애 직후 30초) · 메일 엔진 발송 한도',
)
@ApiErrorResponse(
'MAIL_001',
'메일 엔진 장애 — 실제로는 나갔을 수 있으니 같은 키로 다시 보낸다',
)
send(
@CurrentUser() user: AuthenticatedUser,
@Body() dto: SendMessageDto,
@Headers(IDEMPOTENCY_KEY_HEADER) idempotencyKey?: string,
) {
return this.mail.sendMessage(user, dto, idempotencyKey);
}
@Get(':id/attachments/:attachmentId')
@SkipTransform()
@ApiOperation({
summary: '첨부 다운로드 (표준 래퍼 없음)',
description:
'그 메일에 실제로 있는 첨부만 내려준다. 항상 application/octet-stream + Content-Disposition: attachment.',
})
@ApiProduces(ATTACHMENT_CONTENT_TYPE)
@ApiResponse({
status: HttpStatus.OK,
description: '파일',
schema: { type: 'string', format: 'binary' },
})
@ApiErrorResponse('RES_001', '없는 메일 또는 그 메일에 없는 첨부')
async download(
@CurrentUser() user: AuthenticatedUser,
@Param('id', MailIdPipe) id: string,
@Param('attachmentId', MailIdPipe) attachmentId: string,
@Res({ passthrough: true }) res: Response,
): Promise<StreamableFile> {
const content = await this.mail.openAttachment(user, id, attachmentId);
// 스트림 오류는 요청 처리가 끝난 뒤의 이벤트에서 올 수 있다 — 추적 ID 를 지금 잡아 둔다
const traceId = currentRequestId() ?? 'unknown';
// 메일 첨부는 공유 캐시에 남기지 않는다
res.setHeader('Cache-Control', 'private, no-store');
// 클라이언트가 중간에 끊으면 메일 엔진에서 받던 스트림도 닫는다 (pipe 는 원천을 닫지 않는다)
res.once('close', () => {
if (!content.stream.destroyed) content.stream.destroy();
});
const file = new StreamableFile(content.stream, {
type: ATTACHMENT_CONTENT_TYPE,
disposition: buildAttachmentDisposition(content.name),
...(content.size !== null ? { length: content.size } : {}),
});
// 기본 처리기는 오류 메시지를 본문으로 보낸다 — 내부 정보가 새지 않게 연결만 끊는다
file.setErrorHandler((error) => {
this.logger.error(
`[MAIL_001][${traceId}] 첨부 전송 중단: ${error.message}`,
);
res.destroy();
});
return file;
}
}
@@ -0,0 +1,151 @@
import { createHash, createHmac } from 'crypto';
import {
idempotentMessageId,
parseIdempotencyKey,
payloadFingerprint,
type FingerprintSource,
} from './send-idempotency';
const KEY = '3f1c0a2e-0f1b-4f2a-9a1e-6c9f0a1b2c3d';
const OTHER_KEY = '9b2f3a64-1c1d-4f5e-8a77-2f1c0a9e5d10';
const ARA = 'ara.kim@arasystem.test';
const message: FingerprintSource = {
to: [
{ name: '이도윤', email: 'doyun.lee@arasystem.test' },
{ name: null, email: 'seojun.park@arasystem.test' },
],
cc: [],
bcc: [{ name: null, email: 'secret.boss@arasystem.test' }],
subject: '메일 서버 이전 일정',
body: '확인했습니다.\n감사합니다.',
attachments: [
{ id: 'blob-b', name: '일정.xlsx', type: 'application/vnd.ms-excel' },
{ id: 'blob-a', name: '목록.pdf', type: 'application/pdf' },
],
inReplyTo: undefined,
};
describe('parseIdempotencyKey', () => {
it('없거나 비면 중복 방지 없이 보낸다 (key: null)', () => {
expect(parseIdempotencyKey(undefined)).toEqual({ ok: true, key: null });
expect(parseIdempotencyKey(' ')).toEqual({ ok: true, key: null });
});
it('UUID 는 소문자로 정규화한다', () => {
expect(parseIdempotencyKey(` ${KEY.toUpperCase()} `)).toEqual({
ok: true,
key: KEY,
});
});
it.each([
['UUID 가 아님', 'my-key'],
['헤더를 두 번 보냄(콤마로 합쳐짐)', `${KEY}, ${KEY}`],
['너무 김', `${KEY}0`],
])('형식이 틀리면 거부한다 — %s', (_label, value) => {
expect(parseIdempotencyKey(value)).toEqual({ ok: false });
});
});
describe('idempotentMessageId', () => {
it('같은 사용자·같은 키면 같은 값, 사용자나 키가 다르면 다른 값', () => {
const id = idempotentMessageId(ARA, KEY);
expect(idempotentMessageId(' Ara.Kim@AraSystem.test ', KEY)).toBe(id);
expect(idempotentMessageId('doyun.lee@arasystem.test', KEY)).not.toBe(id);
expect(idempotentMessageId(ARA, OTHER_KEY)).not.toBe(id);
});
it('보내는 사람의 도메인을 쓰고, 주소·키 원문은 담지 않는다', () => {
const id = idempotentMessageId(ARA, KEY);
expect(id).toMatch(/^ara\.[0-9a-f]{32}@arasystem\.test$/);
expect(id).not.toContain('ara.kim');
expect(id).not.toContain(KEY);
});
it('전제: 토큰은 SHA-256(주소 + 줄바꿈 + 키)의 앞 128비트뿐이다 — 키(122비트 무작위)를 거꾸로 알아낼 수 없다', () => {
// 받는 사람이 보는 값이 키의 단방향 해시뿐이라는 것을 고정한다.
// 이 전제가 깨지면(키·키의 일부가 그대로 실리면) 지문 HMAC 키까지 드러난다 (send-idempotency.ts 머리말)
const expected = createHash('sha256')
.update(`${ARA}\n${KEY}`)
.digest('hex')
.slice(0, 32);
expect(idempotentMessageId(ARA, KEY)).toBe(
`ara.${expected}@arasystem.test`,
);
for (const part of KEY.split('-')) {
expect(idempotentMessageId(ARA, KEY)).not.toContain(part);
}
});
});
describe('payloadFingerprint (v2 — Idempotency-Key 로 만든 HMAC)', () => {
it('같은 키·같은 내용이면 같은 지문 — 주소 순서·대소문자·표시 이름·첨부 순서·줄바꿈 형식은 무시한다', () => {
const reordered: FingerprintSource = {
...message,
to: [
{ name: '박서준', email: 'SEOJUN.PARK@arasystem.test' },
{ name: null, email: 'doyun.lee@arasystem.test' },
],
body: '확인했습니다.\r\n감사합니다.',
attachments: [...message.attachments].reverse(),
};
expect(payloadFingerprint(reordered, KEY)).toBe(
payloadFingerprint(message, KEY),
);
});
const changes: [string, Partial<FingerprintSource>][] = [
['받는 사람', { to: [{ name: null, email: 'other@arasystem.test' }] }],
['참조', { cc: [{ name: null, email: 'cc@arasystem.test' }] }],
['숨은 참조', { bcc: [] }],
['제목', { subject: '바뀐 제목' }],
['본문', { body: '고친 본문' }],
['첨부', { attachments: [message.attachments[0]] }],
['답장 대상', { inReplyTo: 'eaaaaab' }],
];
it.each(changes)('%s 이 바뀌면 지문이 달라진다', (_label, change) => {
expect(payloadFingerprint({ ...message, ...change }, KEY)).not.toBe(
payloadFingerprint(message, KEY),
);
});
it('형식은 v2:<HMAC-SHA256 16진 64자>', () => {
expect(payloadFingerprint(message, KEY)).toMatch(/^v2:[0-9a-f]{64}$/);
});
it('키가 다르면 같은 내용이라도 지문이 다르다 — 메일끼리 지문을 비교할 수 없다', () => {
expect(payloadFingerprint(message, OTHER_KEY)).not.toBe(
payloadFingerprint(message, KEY),
);
});
it('키를 모르면 맞춰 볼 수 없다 — 내용만으로 만든 해시·서버 비밀 HMAC 과 일치하지 않는다', () => {
const fingerprint = payloadFingerprint(message, KEY);
const canonicalGuess = JSON.stringify([
'v2',
['doyun.lee@arasystem.test', 'seojun.park@arasystem.test'],
[],
['secret.boss@arasystem.test'],
message.subject,
message.body,
['blob-a', 'blob-b'],
null,
]);
// 받은 사람이 숨은 참조 후보를 넣어 맞춰 보는 시도 — 키 없이는 같은 값이 나오지 않는다
expect(fingerprint).not.toBe(
`v2:${createHash('sha256').update(canonicalGuess).digest('hex')}`,
);
// 서버 비밀(예: JWT_SECRET)로 만든 HMAC 도 아니다 — 공개 값이 서버 비밀을 대입해 볼 수단이 되지 않는다
expect(fingerprint).not.toBe(
`v2:${createHmac('sha256', 'unit-test-secret-unit-test-secret-1234').update(canonicalGuess).digest('hex')}`,
);
});
});
@@ -0,0 +1,113 @@
import { createHash, createHmac, hkdfSync } from 'crypto';
import type { MailAddress, OutgoingMessage } from './mail.types';
/**
* 발송 중복 방지 (계약 2절 "중복 발송 방지 — Idempotency-Key") — 순수 함수 모음.
*
* - 같은 사용자·같은 키 → 같은 Message-ID. 재시도는 이 Message-ID 로 이미 보낸 메일을 찾는다
* - 첫 발송 때 내용 지문을 메일 헤더로 남기고, 같은 키의 재시도와 비교한다 (다르면 MAIL_005)
*
* ── 받는 사람에게 보이는 값과 그 안전성 ─────────────────────────────────────────
* Message-ID 와 지문 헤더는 메일과 함께 (외부) 받는 사람에게 간다. 둘 다 **Idempotency-Key 를 모르면
* 쓸모가 없게** 만든다 — 키는 클라이언트가 쓰기 화면마다 만드는 무작위 UUID v4(122비트)다.
* - Message-ID 토큰 = SHA-256(주소 + "\n" + 키) 의 앞 128비트. 키를 거꾸로 알아내려면 122비트 무작위 값을
* SHA-256 으로 대입해야 하므로 할 수 없다 (전제: 키가 무작위다 — 형식만 검사하므로 클라이언트 책임)
* - 지문 = HMAC-SHA256(HKDF(키, 용도 라벨), 정규화한 내용). 받은 사람은 내용 대부분(받는 사람·제목·본문)을 알지만
* 키를 모르므로 숨은 참조 후보를 넣어 맞춰 볼 수 없고, 다른 메일의 지문과 비교할 수도 없다
* - 서버 비밀(JWT_SECRET 등)은 쓰지 않는다 — 공개되는 값이 서버 비밀을 대입해 볼 수단이 되지 않게
*/
/** 요청 헤더 이름 (Express 는 소문자로 읽는다) */
export const IDEMPOTENCY_KEY_HEADER = 'idempotency-key';
/** 키 형식 — 클라이언트는 쓰기 화면마다 무작위 UUID 를 만든다 (계약) */
const IDEMPOTENCY_KEY_PATTERN =
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
/** Message-ID 에 넣는 토큰 길이 (16진 32자 = 128비트) */
const MESSAGE_ID_TOKEN_LENGTH = 32;
/** 지문 형식 버전 — 키·정규화 규칙을 바꾸면 올린다 (옛 지문과 섞여도 오판하지 않게) */
const FINGERPRINT_VERSION = 'v2';
/** 지문 HMAC 키를 Idempotency-Key 에서 유도할 때의 용도 라벨 (HKDF info) — 키를 다른 용도의 값과 섞지 않는다 */
const FINGERPRINT_KEY_INFO = 'arasystem/mail-payload-fingerprint/v2';
const FINGERPRINT_KEY_BYTES = 32;
export type IdempotencyKeyParseResult =
| { readonly ok: true; readonly key: string | null }
| { readonly ok: false };
/** 헤더 값을 읽는다 — 없으면 key: null(중복 방지 없이 보낸다), 형식이 틀리면 ok: false(VAL_001) */
export function parseIdempotencyKey(
raw: string | undefined,
): IdempotencyKeyParseResult {
if (raw === undefined || raw.trim() === '') return { ok: true, key: null };
const key = raw.trim().toLowerCase();
return IDEMPOTENCY_KEY_PATTERN.test(key) ? { ok: true, key } : { ok: false };
}
/**
* (사용자, 키) → Message-ID (꺾쇠 괄호 없이). 받는 사람에게도 보이지만
* 무작위 키를 모르면 거꾸로 알아낼 수 없다 (파일 머리말) — 해시라 사용자 주소도 드러나지 않는다.
*/
export function idempotentMessageId(user: string, key: string): string {
const address = user.trim().toLowerCase();
const domain = address.slice(address.lastIndexOf('@') + 1);
const token = createHash('sha256')
.update(`${address}\n${key}`)
.digest('hex')
.slice(0, MESSAGE_ID_TOKEN_LENGTH);
return `ara.${token}@${domain}`;
}
/** 지문에 넣는 내용 — 받는 사람·참조·숨은 참조·제목·본문·첨부 id·답장 대상 */
export type FingerprintSource = Pick<
OutgoingMessage,
'to' | 'cc' | 'bcc' | 'subject' | 'body' | 'attachments' | 'inReplyTo'
>;
function addresses(list: readonly MailAddress[]): string[] {
return list.map((address) => address.email.trim().toLowerCase()).sort();
}
/** 지문 HMAC 키 — Idempotency-Key 에서 용도를 나눠 유도한다 */
function fingerprintKey(idempotencyKey: string): Buffer {
return Buffer.from(
hkdfSync(
'sha256',
idempotencyKey,
Buffer.alloc(0),
FINGERPRINT_KEY_INFO,
FINGERPRINT_KEY_BYTES,
),
);
}
/**
* 내용 지문 — `v2:` + HMAC-SHA256(HKDF(Idempotency-Key), 정규화한 내용).
* 그냥 해시를 쓰면 받은 사람이 숨은 참조 후보를 넣어 맞춰 볼 수 있다 — 키를 모르면 맞춰 볼 수 없다 (파일 머리말).
*
* 정규화: 주소는 소문자·정렬(순서만 바꾼 것은 같은 메일), 표시 이름은 넣지 않는다, 본문 줄바꿈은 LF 로, 첨부는 id 만 정렬.
*/
export function payloadFingerprint(
message: FingerprintSource,
idempotencyKey: string,
): string {
const canonical = JSON.stringify([
FINGERPRINT_VERSION,
addresses(message.to),
addresses(message.cc),
addresses(message.bcc),
message.subject,
message.body.replace(/\r\n?/g, '\n'),
message.attachments.map((attachment) => attachment.id).sort(),
message.inReplyTo ?? null,
]);
const digest = createHmac('sha256', fingerprintKey(idempotencyKey))
.update(canonical)
.digest('hex');
return `${FINGERPRINT_VERSION}:${digest}`;
}
@@ -2,22 +2,34 @@ import {
Controller,
Post,
UploadedFile,
UseGuards,
UseInterceptors,
} from '@nestjs/common';
import { FileInterceptor } from '@nestjs/platform-express';
import { ApiBody, ApiConsumes, ApiOperation, ApiTags } from '@nestjs/swagger';
import {
ApiBody,
ApiConsumes,
ApiCookieAuth,
ApiOperation,
ApiTags,
} from '@nestjs/swagger';
import {
ApiErrorResponse,
ApiStandardResponse,
} from '@/common/decorators/api-standard-response.decorator';
import { BusinessException } from '@/common/errors/business.exception';
import { JwtAuthGuard } from '@/common/guards/jwt-auth.guard';
import { UploadResponseDto } from './dto/upload-response.dto';
import { UploadService } from './upload.service';
// 파일 업로드 샘플 — 새 업로드 엔드포인트는 이 패턴을 복제한다.
// 검증(형식·크기)과 안전한 파일명 생성은 UploadModule 의 multer 설정이 담당한다.
// 로그인한 직원만 올린다 — 가드가 인터셉터(multer)보다 먼저 돌아 미인증 요청은 디스크에 닿기 전에 막힌다.
@ApiTags('Upload')
@ApiCookieAuth()
@UseGuards(JwtAuthGuard)
@ApiErrorResponse('AUTH_001', '로그인 필요 (쿠키 없음·만료)')
@Controller('files')
export class UploadController {
constructor(private readonly uploadService: UploadService) {}
@@ -36,6 +36,8 @@ mkdirSync(UPLOAD_DIR, { recursive: true });
files: 1,
fields: 10,
},
// 브라우저는 파일명을 UTF-8 그대로 보낸다 — 기본값(latin1)이면 한글 이름(originalName)이 깨진다
defParamCharset: 'utf8',
fileFilter: (_req, file, callback) => {
if (!isAllowedFile(file.originalname, file.mimetype)) {
// 에러 코드와 함께 거부한다 (전역 필터가 표준 응답으로 변환)
+2 -2
View File
@@ -7,7 +7,7 @@ import {
Post,
Query,
} from '@nestjs/common';
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
import { ApiOperation, ApiTags } from '@nestjs/swagger';
import {
ApiErrorResponse,
@@ -19,8 +19,8 @@ import { CreateUserDto } from './dto/create-user.dto';
import { UserResponseDto } from './dto/user-response.dto';
// 사용자 도메인 컨트롤러 샘플 — 라우팅과 입출력만 담당, 비즈니스 로직은 모두 service 위임
// (인증 없는 예제다. 보호가 필요하면 @UseGuards(JwtAuthGuard) + @ApiCookieAuth() — 인증은 쿠키뿐이다)
@ApiTags('User')
@ApiBearerAuth()
@Controller('users')
export class UserController {
constructor(private readonly userService: UserService) {}
+2 -1
View File
@@ -71,7 +71,8 @@ export function buildDatabaseOptions(): DataSourceOptions {
synchronize: shouldSynchronize(),
// 운영 배포 시 미적용 마이그레이션을 자동 실행한다
migrationsRun: isProduction,
logging: !isProduction,
// SQL 로그는 개발에서만 — 테스트(e2e·CI)는 수천 줄의 쿼리·파라미터(주소 등)가 출력을 덮는다
logging: !isProduction && process.env.NODE_ENV !== 'test',
extra: { ...POOL_OPTIONS },
entities: paths.entities,
migrations: paths.migrations,
@@ -12,6 +12,13 @@ const prod = (extra: Record<string, string> = {}) => ({
DB_USER: 'app_user',
DB_PASSWORD: 'a-real-secret',
DB_NAME: 'project_db',
JWT_SECRET: 'q7Vd2mXk9LpR4tWz8nBc6yHs3gJf5aEu',
MAIL_JMAP_URL: 'http://mail:8080',
MAIL_DOMAIN: 'arasystem.it.kr',
MAIL_GATEWAY_USER: 'mail-gateway@arasystem.it.kr',
MAIL_GATEWAY_PASSWORD: 'Gw7xQ2pLm9Rt4Vz8Kc3N',
CORS_ORIGIN: 'https://mail.arasystem.it.kr',
TRUST_PROXY: '2',
...extra,
});
@@ -32,6 +39,247 @@ describe('validateEnv', () => {
validateEnv({ NODE_ENV: 'development', THROTTLE_LIMIT: '' }),
).not.toThrow();
});
it('메일·JWT 설정이 없어도 기동한다 (CI e2e 는 메일 엔진 없이 돈다)', () => {
expect(() =>
validateEnv({
NODE_ENV: 'test',
MAIL_JMAP_URL: '',
MAIL_DOMAIN: '',
MAIL_GATEWAY_USER: '',
MAIL_GATEWAY_PASSWORD: '',
JWT_SECRET: '',
}),
).not.toThrow();
});
it('첨부 한도는 양의 정수여야 한다', () => {
expect(() =>
validateEnv({
NODE_ENV: 'development',
MAIL_ATTACHMENT_MAX_SIZE_MB: '20MB',
}),
).toThrow(/MAIL_ATTACHMENT_MAX_SIZE_MB 는 양의 정수/);
});
it('토큰 만료 기간 형식이 틀리면 환경과 무관하게 실패한다', () => {
expect(() =>
validateEnv({ NODE_ENV: 'development', JWT_ACCESS_EXPIRES_IN: '1.5h' }),
).toThrow(/JWT_ACCESS_EXPIRES_IN 는 '숫자 \+ s·m·h·d' 형식/);
expect(() =>
validateEnv({
NODE_ENV: 'development',
JWT_ACCESS_EXPIRES_IN: '1h',
JWT_REFRESH_EXPIRES_IN: '14d',
}),
).not.toThrow();
});
it('액세스 토큰 만료는 5분~1시간만 허용한다', () => {
for (const value of ['4m', '61m', '2h', '1d']) {
expect(() =>
validateEnv({
NODE_ENV: 'development',
JWT_ACCESS_EXPIRES_IN: value,
}),
).toThrow(/JWT_ACCESS_EXPIRES_IN 은 5분~1시간 사이/);
}
for (const value of ['5m', '15m', '3600']) {
expect(() =>
validateEnv({
NODE_ENV: 'development',
JWT_ACCESS_EXPIRES_IN: value,
}),
).not.toThrow();
}
});
it('리프레시 토큰 만료는 30일(세션 절대 만료) 이하만 허용한다', () => {
expect(() =>
validateEnv({ NODE_ENV: 'development', JWT_REFRESH_EXPIRES_IN: '31d' }),
).toThrow(/JWT_REFRESH_EXPIRES_IN 은 30일 이하/);
expect(() =>
validateEnv({ NODE_ENV: 'development', JWT_REFRESH_EXPIRES_IN: '30d' }),
).not.toThrow();
});
it('리프레시는 액세스보다 길어야 한다 — 기본값과 섞여도 비교한다', () => {
expect(() =>
validateEnv({
NODE_ENV: 'development',
JWT_ACCESS_EXPIRES_IN: '30m',
JWT_REFRESH_EXPIRES_IN: '30m',
}),
).toThrow(
/JWT_REFRESH_EXPIRES_IN\(30분\)은 JWT_ACCESS_EXPIRES_IN\(30분\)보다 길어야/,
);
// 액세스 기본값(1시간)보다 짧은 리프레시
expect(() =>
validateEnv({ NODE_ENV: 'development', JWT_REFRESH_EXPIRES_IN: '20m' }),
).toThrow(/보다 길어야/);
});
it('TRUST_PROXY 형식이 틀리면 환경과 무관하게 실패한다', () => {
expect(() =>
validateEnv({ NODE_ENV: 'development', TRUST_PROXY: 'nginx' }),
).toThrow(/TRUST_PROXY 홉 수, 또는 IP·CIDR·키워드/);
expect(() =>
validateEnv({ NODE_ENV: 'development', TRUST_PROXY: '9' }),
).toThrow(/TRUST_PROXY 홉 수는 0~5/);
expect(() =>
validateEnv({
NODE_ENV: 'development',
TRUST_PROXY: 'loopback, 172.16.0.0/12',
}),
).not.toThrow();
});
it('개발에서는 TRUST_PROXY 가 없어도·true 여도 기동한다', () => {
expect(() => validateEnv({ NODE_ENV: 'development' })).not.toThrow();
expect(() =>
validateEnv({ NODE_ENV: 'development', TRUST_PROXY: 'true' }),
).not.toThrow();
});
it('CORS_ORIGIN 은 출처 목록이어야 한다', () => {
expect(() =>
validateEnv({
NODE_ENV: 'development',
CORS_ORIGIN: 'localhost:5173',
}),
).toThrow(/CORS_ORIGIN 은 http\(s\) 출처/);
expect(() =>
validateEnv({
NODE_ENV: 'development',
CORS_ORIGIN: 'http://localhost:5173, http://10.0.0.5:5173',
}),
).not.toThrow();
});
it('첨부 합계 한도는 파일 하나 한도보다 작을 수 없다', () => {
expect(() =>
validateEnv({
NODE_ENV: 'development',
MAIL_ATTACHMENT_MAX_SIZE_MB: '20',
MAIL_ATTACHMENT_TOTAL_MAX_SIZE_MB: '10',
}),
).toThrow(
/MAIL_ATTACHMENT_TOTAL_MAX_SIZE_MB\(10\)는 MAIL_ATTACHMENT_MAX_SIZE_MB\(20\)보다 작을 수 없습니다/,
);
expect(() =>
validateEnv({
NODE_ENV: 'development',
MAIL_ATTACHMENT_TOTAL_MAX_SIZE_MB: '0',
}),
).toThrow(/MAIL_ATTACHMENT_TOTAL_MAX_SIZE_MB 는 양의 정수/);
});
it('메일 설정 형식이 틀리면 환경과 무관하게 실패한다', () => {
expect(() =>
validateEnv({ NODE_ENV: 'development', MAIL_JMAP_URL: 'mail:8080' }),
).toThrow(/MAIL_JMAP_URL 은 http\(s\) 주소/);
expect(() =>
validateEnv({
NODE_ENV: 'development',
MAIL_DOMAIN: '@arasystem.test',
}),
).toThrow(/MAIL_DOMAIN 은 도메인 이름/);
});
it('메일 관리자 아이디(MAIL_ADMIN_LOCAL_PART)에 @ 가 있으면 기동을 실패시킨다 — 주소가 아니라 아이디만 쓴다', () => {
for (const value of ['admin@arasystem.test', '@admin', 'admin@']) {
expect(() =>
validateEnv({
NODE_ENV: 'development',
MAIL_ADMIN_LOCAL_PART: value,
}),
).toThrow(/MAIL_ADMIN_LOCAL_PART 는 주소가 아니라 앞부분\(아이디\)만/);
}
});
it('메일 관리자 아이디는 주소의 로컬 부분 형식만 받는다 (대리 접근 구분자 % 등 거부)', () => {
for (const value of [
'admin%mail-gateway',
'ad min',
'admin:x',
'.admin',
'admin.',
'관리자',
'a'.repeat(65),
]) {
expect(() =>
validateEnv({
NODE_ENV: 'development',
MAIL_ADMIN_LOCAL_PART: value,
}),
).toThrow(/MAIL_ADMIN_LOCAL_PART 는 영문·숫자/);
}
for (const value of [
'',
'admin',
'mail-admin',
'Postmaster',
'ara_ops.1',
]) {
expect(() =>
validateEnv({
NODE_ENV: 'development',
MAIL_ADMIN_LOCAL_PART: value,
}),
).not.toThrow();
}
});
it('로그인을 막는 계정 목록(MAIL_BLOCKED_ACCOUNTS)은 주소 전체여야 한다 — 아이디만 쓰면 아무것도 막지 못한다', () => {
for (const value of [
'admin',
'ops@arasystem.test, root',
'a@b@arasystem.test',
]) {
expect(() =>
validateEnv({
NODE_ENV: 'development',
MAIL_BLOCKED_ACCOUNTS: value,
}),
).toThrow(/MAIL_BLOCKED_ACCOUNTS 는 로그인을 막을 계정의 주소 전체/);
}
for (const value of ['', ' ops@arasystem.test , Audit@AraSystem.test,']) {
expect(() =>
validateEnv({
NODE_ENV: 'development',
MAIL_BLOCKED_ACCOUNTS: value,
}),
).not.toThrow();
}
});
it('게이트웨이 계정은 주소 전체여야 한다 (대리 접근 형식 <직원>%<게이트웨이>)', () => {
expect(() =>
validateEnv({
NODE_ENV: 'development',
MAIL_GATEWAY_USER: 'mail-gateway',
}),
).toThrow(/MAIL_GATEWAY_USER 는 주소 전체/);
expect(() =>
validateEnv({
NODE_ENV: 'development',
MAIL_GATEWAY_USER: 'mail-gateway@arasystem.test',
MAIL_DOMAIN: 'arasystem.test',
MAIL_JMAP_URL: 'http://mail:8080',
}),
).not.toThrow();
});
});
describe('운영 환경', () => {
@@ -55,6 +303,68 @@ describe('validateEnv', () => {
).toThrow(/JWT_SECRET 가 예시 값 그대로입니다/);
});
it('메일 엔진·JWT 설정이 없으면 실패한다', () => {
for (const key of [
'JWT_SECRET',
'MAIL_JMAP_URL',
'MAIL_DOMAIN',
'MAIL_GATEWAY_USER',
'MAIL_GATEWAY_PASSWORD',
]) {
expect(() => validateEnv(omit(prod(), key))).toThrow(
new RegExp(`${key} 가 설정되지 않았습니다`),
);
}
});
it('CORS_ORIGIN·TRUST_PROXY 가 없으면 실패한다 (출처 검사 허용 목록·클라이언트 IP 기준)', () => {
expect(() => validateEnv(omit(prod(), 'CORS_ORIGIN'))).toThrow(
/CORS_ORIGIN 가 설정되지 않았습니다/,
);
expect(() => validateEnv(omit(prod(), 'TRUST_PROXY'))).toThrow(
/TRUST_PROXY 가 설정되지 않았습니다/,
);
});
it('TRUST_PROXY=true 는 X-Forwarded-For 위조를 허용하므로 운영에서 실패한다', () => {
expect(() => validateEnv(prod({ TRUST_PROXY: 'true' }))).toThrow(
/TRUST_PROXY=true 는 운영에서 쓸 수 없습니다/,
);
});
it('TRUST_PROXY=0·false 는 모든 사용자를 nginx IP 하나로 묶으므로 운영에서 실패한다', () => {
for (const value of ['0', 'false']) {
expect(() => validateEnv(prod({ TRUST_PROXY: value }))).toThrow(
/운영에서 쓸 수 없습니다 — 앞단 프록시/,
);
}
});
it('TRUST_PROXY 는 홉 수 또는 주소 목록이면 통과한다', () => {
for (const value of ['1', '2', 'loopback,172.18.0.0/16,198.51.100.10']) {
expect(() => validateEnv(prod({ TRUST_PROXY: value }))).not.toThrow();
}
});
it('게이트웨이 비밀번호가 예시 값 그대로면 실패한다', () => {
expect(() =>
validateEnv(prod({ MAIL_GATEWAY_PASSWORD: 'change-me-in-local-env' })),
).toThrow(/MAIL_GATEWAY_PASSWORD 가 예시 값 그대로입니다/);
});
it('JWT 서명 키가 짧으면 실패하고, 키 값은 메시지에 싣지 않는다', () => {
const shortSecret = 'too-short-secret-1234';
try {
validateEnv(prod({ JWT_SECRET: shortSecret }));
fail('예외가 발생해야 한다');
} catch (error) {
const message = (error as Error).message;
expect(message).toMatch(/JWT_SECRET 이 너무 짧습니다/);
expect(message).not.toContain(shortSecret);
}
});
it('약한 기본값(password)도 placeholder 로 간주한다', () => {
expect(() => validateEnv(prod({ DB_PASSWORD: 'password' }))).toThrow(
/예시 값 그대로/,
+261 -1
View File
@@ -6,16 +6,44 @@
*
* `ConfigModule.forRoot({ validate })` 에 연결되어 있다 (app.module.ts).
*/
import {
ACCESS_TTL_MAX_SECONDS,
ACCESS_TTL_MIN_SECONDS,
DEFAULT_ACCESS_TTL_SECONDS,
DEFAULT_REFRESH_TTL_SECONDS,
REFRESH_TTL_MAX_SECONDS,
} from '@/modules/auth/auth.constants';
import { isLocalPart } from '@/modules/auth/employee-address';
import { parseAccountList } from '@/modules/mail/mail.settings';
import { parseDurationSeconds } from '@/shared/utils/duration';
import { parseOriginList } from '@/shared/utils/request-origin';
import {
parseTrustProxy,
unsafeTrustProxyReason,
} from '@/shared/utils/trust-proxy';
/** 운영 배포 전에 반드시 교체해야 하는 예시 값들 — `*.example` 에서 복사한 흔적 */
const PLACEHOLDER_PATTERNS = [/change[-_]?me/i, /^password$/i, /^secret$/i];
/** 운영 환경에서 반드시 채워져야 하는 키 */
/**
* 운영 환경에서 반드시 채워져야 하는 키.
* 메일·JWT 는 비운영에서는 없어도 기동한다 — 메일 API 는 MAIL_001 을 돌려주고,
* JWT 서명 키는 임시 키로 대체된다 (CI e2e 는 메일 엔진 없이 돈다).
*/
const REQUIRED_IN_PRODUCTION = [
'DB_HOST',
'DB_USER',
'DB_PASSWORD',
'DB_NAME',
'JWT_SECRET',
'MAIL_JMAP_URL',
'MAIL_DOMAIN',
'MAIL_GATEWAY_USER',
'MAIL_GATEWAY_PASSWORD',
// 상태 변경 요청의 출처 검사·CORS 허용 목록 — 같은 주소 배포면 그 화면 주소 (Sec-Fetch-Site 를 보내지 않는 브라우저용)
'CORS_ORIGIN',
// 백엔드 앞의 프록시 — 클라이언트 IP(로그인 실패 한도·요청 수 한도의 기준)를 정한다
'TRUST_PROXY',
] as const;
/** 값이 있다면 양의 정수여야 하는 키 */
@@ -24,6 +52,14 @@ const POSITIVE_INT_KEYS = [
'DB_PORT',
'THROTTLE_TTL_MS',
'THROTTLE_LIMIT',
'MAIL_ATTACHMENT_MAX_SIZE_MB',
'MAIL_ATTACHMENT_TOTAL_MAX_SIZE_MB',
] as const;
/** 값이 있다면 기간 형식(숫자 + s·m·h·d)이어야 하는 키 — 쿠키 Max-Age 와 토큰 만료가 같은 값을 쓴다 */
const DURATION_KEYS = [
'JWT_ACCESS_EXPIRES_IN',
'JWT_REFRESH_EXPIRES_IN',
] as const;
/** 운영 환경에서 placeholder 로 남아 있으면 안 되는 키 */
@@ -31,8 +67,17 @@ const NO_PLACEHOLDER_IN_PRODUCTION = [
'DB_PASSWORD',
'REDIS_PASSWORD',
'JWT_SECRET',
// 전 직원 메일함의 열쇠다 (mail/README.md "보안")
'MAIL_GATEWAY_PASSWORD',
] as const;
/**
* 운영 JWT 서명 키 최소 길이.
* HS256 은 토큰 하나만 있으면 오프라인으로 키를 대입해 볼 수 있다 — 짧은 키는 위조로 이어지고,
* 위조된 토큰은 백엔드가 게이트웨이로 대리 접근하는 **직원 메일함 전체**의 열쇠가 된다.
*/
const JWT_SECRET_MIN_LENGTH_IN_PRODUCTION = 32;
/**
* 운영에서 **공개 주소**여야 하는 키.
* 로컬 주소가 남은 채 배포되면 서버는 정상 기동하지만
@@ -74,6 +119,189 @@ function looksNonPublic(value: string): boolean {
return NON_PUBLIC_URL_PATTERNS.some((pattern) => pattern.test(value));
}
function isHttpUrl(value: string): boolean {
try {
const { protocol } = new URL(value);
return protocol === 'http:' || protocol === 'https:';
} catch {
return false;
}
}
/** 초 → 사람이 읽는 기간 (오류 메시지용) */
function describeSeconds(seconds: number): string {
const units: [number, string][] = [
[24 * 60 * 60, '일'],
[60 * 60, '시간'],
[60, '분'],
];
for (const [size, label] of units) {
if (seconds % size === 0) return `${seconds / size}${label}`;
}
return `${seconds}초`;
}
/**
* 토큰 만료 범위 (모든 환경, 값이 있을 때) — .claude/rules/security.md "인증 · 토큰".
* 액세스는 서버 조회 없이 검증되므로(세션을 끊어도 만료까지 유효) 짧게, 리프레시는 세션 절대 만료(30일)를 넘지 않게.
*/
function validateTokenLifetimes(
config: Record<string, unknown>,
errors: string[],
): void {
const accessRaw = readString(config, 'JWT_ACCESS_EXPIRES_IN');
const refreshRaw = readString(config, 'JWT_REFRESH_EXPIRES_IN');
const access = parseDurationSeconds(accessRaw);
const refresh = parseDurationSeconds(refreshRaw);
// 형식 오류는 위에서 이미 알렸다 — 범위는 해석된 값만 본다
if (
access !== null &&
(access < ACCESS_TTL_MIN_SECONDS || access > ACCESS_TTL_MAX_SECONDS)
) {
errors.push(
`JWT_ACCESS_EXPIRES_IN 은 ${describeSeconds(ACCESS_TTL_MIN_SECONDS)}~${describeSeconds(ACCESS_TTL_MAX_SECONDS)} 사이여야 합니다. (현재 값: "${accessRaw}")`,
);
}
if (refresh !== null && refresh > REFRESH_TTL_MAX_SECONDS) {
errors.push(
`JWT_REFRESH_EXPIRES_IN 은 ${describeSeconds(REFRESH_TTL_MAX_SECONDS)} 이하여야 합니다 — 세션 절대 만료보다 길 수 없습니다. (현재 값: "${refreshRaw}")`,
);
}
const effectiveAccess = access ?? DEFAULT_ACCESS_TTL_SECONDS;
const effectiveRefresh = refresh ?? DEFAULT_REFRESH_TTL_SECONDS;
if (effectiveRefresh <= effectiveAccess) {
errors.push(
`JWT_REFRESH_EXPIRES_IN(${describeSeconds(effectiveRefresh)})은 JWT_ACCESS_EXPIRES_IN(${describeSeconds(effectiveAccess)})보다 길어야 합니다.`,
);
}
}
/** 프록시 신뢰 설정 — 형식은 모든 환경, 위험한 값은 운영에서만 막는다 */
function validateTrustProxy(
config: Record<string, unknown>,
isProduction: boolean,
errors: string[],
): void {
const raw = readString(config, 'TRUST_PROXY');
const parsed = parseTrustProxy(raw);
if (!parsed.ok) {
errors.push(
`TRUST_PROXY ${parsed.reason}. (예: 2 — 외부 리버스 프록시 → 프론트 nginx → 백엔드, 현재 값: "${raw}")`,
);
return;
}
const unsafe =
isProduction && parsed.value !== undefined
? unsafeTrustProxyReason(parsed.value)
: null;
if (unsafe) {
errors.push(`TRUST_PROXY=${raw} 는 운영에서 쓸 수 없습니다 — ${unsafe}.`);
}
}
/** 허용 출처 목록 형식 (모든 환경, 값이 있을 때) — 스킴+호스트[+포트] 를 콤마로 */
function validateOrigins(
config: Record<string, unknown>,
errors: string[],
): void {
const { invalid } = parseOriginList(readString(config, 'CORS_ORIGIN'));
if (invalid.length > 0) {
errors.push(
`CORS_ORIGIN 은 http(s) 출처(스킴+호스트[+포트])를 콤마로 나열해야 합니다. (해석하지 못한 값: ${invalid.join(', ')})`,
);
}
}
/** 첨부 합계 한도는 파일 하나 한도보다 작을 수 없다 (파일 하나도 보내지 못하게 된다) */
function validateAttachmentLimits(
config: Record<string, unknown>,
errors: string[],
): void {
const perFile = Number(readString(config, 'MAIL_ATTACHMENT_MAX_SIZE_MB'));
const total = Number(readString(config, 'MAIL_ATTACHMENT_TOTAL_MAX_SIZE_MB'));
if (
Number.isInteger(perFile) &&
Number.isInteger(total) &&
perFile > 0 &&
total > 0 &&
total < perFile
) {
errors.push(
`MAIL_ATTACHMENT_TOTAL_MAX_SIZE_MB(${total})는 MAIL_ATTACHMENT_MAX_SIZE_MB(${perFile})보다 작을 수 없습니다.`,
);
}
}
/** 도메인 이름 형식 (예: arasystem.it.kr, arasystem.test) — '@' 나 스킴이 섞이면 거부한다 */
const DOMAIN_PATTERN =
/^(?=.{1,253}$)(?:[a-z0-9](?:[a-z0-9-]*[a-z0-9])?\.)+[a-z]{2,}$/i;
/** 주소 전체 형식 — 로컬 부분 + '@' 하나 + 도메인 이름 */
function isAccountAddress(value: string): boolean {
const at = value.indexOf('@');
return (
at > 0 &&
value.indexOf('@', at + 1) === -1 &&
DOMAIN_PATTERN.test(value.slice(at + 1))
);
}
/**
* 메일 설정 형식 검사 (값이 있을 때만, 모든 환경).
* 형식이 틀리면 기동은 되는데 로그인이 전부 실패하는 조용한 장애가 되므로 먼저 막는다.
*/
function validateMailSettings(
config: Record<string, unknown>,
errors: string[],
): void {
const jmapUrl = readString(config, 'MAIL_JMAP_URL');
if (jmapUrl !== undefined && !isHttpUrl(jmapUrl)) {
errors.push(
`MAIL_JMAP_URL 은 http(s) 주소여야 합니다. (현재 값: "${jmapUrl}")`,
);
}
const domain = readString(config, 'MAIL_DOMAIN');
if (domain !== undefined && !DOMAIN_PATTERN.test(domain)) {
errors.push(
`MAIL_DOMAIN 은 도메인 이름이어야 합니다. (예: arasystem.it.kr, 현재 값: "${domain}")`,
);
}
// 대리 접근 형식이 <직원 주소>%<게이트웨이 주소> 라 아이디만 쓰면 모든 메일함 접근이 실패한다
const gatewayUser = readString(config, 'MAIL_GATEWAY_USER');
if (gatewayUser !== undefined && !gatewayUser.includes('@')) {
errors.push(
`MAIL_GATEWAY_USER 는 주소 전체여야 합니다. (예: mail-gateway@${domain ?? 'arasystem.it.kr'}, 현재 값: "${gatewayUser}")`,
);
}
// 메일 엔진 관리자 아이디 — 백엔드는 <아이디>@<MAIL_DOMAIN> 의 로그인을 막는다 (mail.settings.ts).
// 주소 전체를 쓰면 막는 주소가 admin@도메인@도메인 이 되어 관리자를 막지 못한다 (메일 엔진 설정 스크립트도 같은 값을 읽는다)
const adminLocalPart = readString(config, 'MAIL_ADMIN_LOCAL_PART');
if (adminLocalPart !== undefined && !isLocalPart(adminLocalPart)) {
errors.push(
adminLocalPart.includes('@')
? `MAIL_ADMIN_LOCAL_PART 는 주소가 아니라 앞부분(아이디)만 씁니다 — '@' 와 도메인을 빼 주세요. (예: admin, 현재 값: "${adminLocalPart}")`
: `MAIL_ADMIN_LOCAL_PART 는 영문·숫자·'.'·'_'·'-' 로 된 아이디여야 합니다 (앞뒤는 영문·숫자, 64자 이하). (예: admin, 현재 값: "${adminLocalPart}")`,
);
}
// 막는 계정은 주소 전체로 비교한다 — 아이디(admin)만 쓰면 아무것도 막지 못하는 조용한 설정 실수가 된다
const invalidBlocked = parseAccountList(
readString(config, 'MAIL_BLOCKED_ACCOUNTS'),
).filter((entry) => !isAccountAddress(entry));
if (invalidBlocked.length > 0) {
errors.push(
`MAIL_BLOCKED_ACCOUNTS 는 로그인을 막을 계정의 주소 전체를 콤마로 나열해야 합니다. (예: ops@${domain ?? 'arasystem.it.kr'}, 해석하지 못한 값: ${invalidBlocked.join(', ')})`,
);
}
}
/**
* 환경변수를 검증한다. 문제가 있으면 **모든 문제를 모아** 예외를 던진다.
* (하나씩 고쳐가며 재기동하는 낭비를 막기 위해 한 번에 보여준다)
@@ -95,6 +323,27 @@ export function validateEnv(
}
}
// 1-1. 기간 값 — 비어 있으면 코드의 기본값을 쓴다
for (const key of DURATION_KEYS) {
const value = readString(config, key);
if (value !== undefined && parseDurationSeconds(value) === null) {
errors.push(
`${key} 는 '숫자 + s·m·h·d' 형식이어야 합니다. (예: 15m, 1h, 14d, 현재 값: "${value}")`,
);
}
}
// 1-2. 토큰 만료 범위
validateTokenLifetimes(config, errors);
// 1-3. 메일 설정 형식
validateMailSettings(config, errors);
validateAttachmentLimits(config, errors);
// 1-4. 프록시 신뢰·허용 출처 형식
validateTrustProxy(config, isProduction, errors);
validateOrigins(config, errors);
if (isProduction) {
// 2. 운영 필수값 — 없으면 기동 실패
for (const key of REQUIRED_IN_PRODUCTION) {
@@ -113,6 +362,17 @@ export function validateEnv(
}
}
// 3-1. JWT 서명 키 강도 — 값 자체는 오류 메시지에 싣지 않는다
const jwtSecret = readString(config, 'JWT_SECRET');
if (
jwtSecret !== undefined &&
jwtSecret.length < JWT_SECRET_MIN_LENGTH_IN_PRODUCTION
) {
errors.push(
`JWT_SECRET 이 너무 짧습니다. ${JWT_SECRET_MIN_LENGTH_IN_PRODUCTION}자 이상의 무작위 값을 쓰세요. (현재 ${jwtSecret.length}자)`,
);
}
// 4. 공개 주소가 로컬 주소·예시 도메인으로 남은 채 배포되는 것을 막는다
const allowLocalhostUrl =
readString(config, ALLOW_LOCALHOST_URL_KEY) === 'true';
@@ -0,0 +1,37 @@
import {
currentRequestId,
runWithRequestContext,
traceTag,
} from './request-context';
describe('요청 컨텍스트', () => {
it('요청 밖에서는 추적 ID 가 없다', () => {
expect(currentRequestId()).toBeUndefined();
expect(traceTag()).toBe('');
});
it('비동기 흐름을 따라 추적 ID 가 이어진다', async () => {
const seen = await runWithRequestContext(
{ requestId: 'trace-1' },
async () => {
await new Promise((resolve) => setTimeout(resolve, 5));
return traceTag();
},
);
expect(seen).toBe('[trace-1]');
});
it('동시에 처리되는 요청끼리 섞이지 않는다', async () => {
const run = (id: string, delay: number) =>
runWithRequestContext({ requestId: id }, async () => {
await new Promise((resolve) => setTimeout(resolve, delay));
return currentRequestId();
});
await expect(Promise.all([run('a', 10), run('b', 1)])).resolves.toEqual([
'a',
'b',
]);
});
});
@@ -0,0 +1,33 @@
import { AsyncLocalStorage } from 'async_hooks';
/**
* 요청 단위 컨텍스트 — 지금은 추적 ID(traceId)만 담는다.
*
* `requestIdMiddleware`(configureApp 의 첫 미들웨어)가 요청마다 시작하고, 그 요청에서 이어지는 비동기 흐름 어디서든
* `traceTag()` 로 꺼내 로그에 붙인다. 예외 필터를 거치지 않는 로그(서비스·클라이언트의 경고 등)도
* 사용자 문의의 traceId 로 찾을 수 있게 하기 위해서다 (.claude/rules/logging.md "필수 포함 정보").
*/
interface RequestContext {
readonly requestId: string;
}
const storage = new AsyncLocalStorage<RequestContext>();
/** 이 컨텍스트 안에서 `callback` 과 그 뒤의 비동기 흐름을 실행한다 */
export function runWithRequestContext<T>(
context: RequestContext,
callback: () => T,
): T {
return storage.run(context, callback);
}
/** 지금 처리 중인 요청의 추적 ID (요청 밖이면 undefined) */
export function currentRequestId(): string | undefined {
return storage.getStore()?.requestId;
}
/** 로그 앞에 붙이는 `[traceId]` — 요청 밖이면 빈 문자열 */
export function traceTag(): string {
const requestId = currentRequestId();
return requestId ? `[${requestId}]` : '';
}
@@ -0,0 +1,43 @@
import { clientIpKey } from './client-ip';
describe('clientIpKey', () => {
it('IPv4 는 그대로', () => {
expect(clientIpKey('203.0.113.7')).toBe('203.0.113.7');
expect(clientIpKey(' 198.51.100.1 ')).toBe('198.51.100.1');
});
it('IPv4-mapped IPv6 는 IPv4 로 본다 — 같은 클라이언트가 두 키로 따로 세지지 않는다', () => {
expect(clientIpKey('::ffff:203.0.113.7')).toBe('203.0.113.7');
expect(clientIpKey('::FFFF:203.0.113.7')).toBe('203.0.113.7');
// 16진 표기로 와도 같다
expect(clientIpKey('::ffff:cb00:7107')).toBe('203.0.113.7');
expect(clientIpKey('0:0:0:0:0:ffff:203.0.113.7')).toBe('203.0.113.7');
});
it('IPv6 는 /64 로 묶는다 — 같은 회선에서 주소를 바꿔도 같은 키', () => {
const key = clientIpKey('2001:db8:1:2::1');
expect(key).toBe('2001:db8:1:2::/64');
expect(clientIpKey('2001:db8:1:2:ffff:eeee:dddd:cccc')).toBe(key);
expect(clientIpKey('2001:0DB8:0001:0002:0:0:0:abcd')).toBe(key);
});
it('다른 /64 는 다른 키', () => {
expect(clientIpKey('2001:db8:1:3::1')).not.toBe(
clientIpKey('2001:db8:1:2::1'),
);
});
it('생략(::)·끝의 IPv4 표기·영역 표시를 풀어서 묶는다', () => {
expect(clientIpKey('::1')).toBe('0:0:0:0::/64');
expect(clientIpKey('2001:db8::')).toBe('2001:db8:0:0::/64');
expect(clientIpKey('64:ff9b::192.0.2.33')).toBe('64:ff9b:0:0::/64');
expect(clientIpKey('fe80::1%eth0')).toBe('fe80:0:0:0::/64');
});
it('해석할 수 없는 값은 그대로 — 한 묶음으로 센다', () => {
expect(clientIpKey('unknown')).toBe('unknown');
expect(clientIpKey('')).toBe('');
expect(clientIpKey('300.1.1.1')).toBe('300.1.1.1');
});
});
+84
View File
@@ -0,0 +1,84 @@
import { isIPv4, isIPv6 } from 'net';
/** IPv6 를 묶는 접두 길이 — /64 = 앞 16비트 묶음 4개 */
const IPV6_GROUP_PREFIX = 64;
const IPV6_GROUP_HEXTETS = 4;
const IPV6_HEXTETS = 8;
/** `::ffff:0:0/96` 의 표시 — 앞 80비트 0, 다음 16비트 ffff */
const IPV4_MAPPED_ZERO_HEXTETS = 5;
const IPV4_MAPPED_MARKER = 0xffff;
const BYTE_BITS = 8;
const BYTE_MASK = 0xff;
/**
* 한도를 세는 클라이언트 키 (순수 함수) — 로그인 실패 한도(LoginAttemptService)와
* 요청 수 한도(ClientIpThrottlerGuard)가 **같은 함수**로 클라이언트를 묶는다.
*
* - IPv4 → 그대로
* - IPv4-mapped IPv6(`::ffff:a.b.c.d`) → IPv4 로 (같은 클라이언트가 두 키로 따로 세지지 않게)
* - IPv6 → 앞 64비트(/64) 로 묶는다. 가정·사무실 회선 하나가 보통 /64 전체를 받으므로,
* 주소 하나씩 세면 같은 회선에서 주소를 바꿔 가며 한도를 무한히 피할 수 있다
* - 해석할 수 없는 값('unknown' 등) → 그대로 (한 묶음으로 센다)
*/
export function clientIpKey(ip: string): string {
const trimmed = ip.trim();
if (isIPv4(trimmed)) return trimmed;
// 링크 로컬 주소의 영역 표시(fe80::1%eth0)는 떼고 본다
const address = trimmed.split('%')[0].toLowerCase();
if (!isIPv6(address)) return trimmed;
const hextets = expandIPv6(address);
if (isIPv4Mapped(hextets)) {
const [high, low] = [hextets[6], hextets[7]];
return [
high >> BYTE_BITS,
high & BYTE_MASK,
low >> BYTE_BITS,
low & BYTE_MASK,
].join('.');
}
const prefix = hextets
.slice(0, IPV6_GROUP_HEXTETS)
.map((hextet) => hextet.toString(16))
.join(':');
return `${prefix}::/${IPV6_GROUP_PREFIX}`;
}
function isIPv4Mapped(hextets: readonly number[]): boolean {
return (
hextets
.slice(0, IPV4_MAPPED_ZERO_HEXTETS)
.every((hextet) => hextet === 0) &&
hextets[IPV4_MAPPED_ZERO_HEXTETS] === IPV4_MAPPED_MARKER
);
}
/**
* IPv6 문자열(isIPv6 로 검증된 것) → 16비트 묶음 8개.
* `::` 생략과 끝의 IPv4 표기(`::ffff:1.2.3.4`, `64:ff9b::1.2.3.4`)를 푼다.
*/
function expandIPv6(address: string): number[] {
let text = address;
const lastColon = text.lastIndexOf(':');
const tail = text.slice(lastColon + 1);
if (tail.includes('.')) {
const [a, b, c, d] = tail.split('.').map(Number);
const high = ((a << BYTE_BITS) | b).toString(16);
const low = ((c << BYTE_BITS) | d).toString(16);
text = `${text.slice(0, lastColon + 1)}${high}:${low}`;
}
const [head, rest] = text.split('::') as [string, string | undefined];
const parse = (part: string) =>
part === '' ? [] : part.split(':').map((hextet) => parseInt(hextet, 16));
const left = parse(head);
if (rest === undefined) return left;
const right = parse(rest);
const zeros = new Array<number>(
IPV6_HEXTETS - left.length - right.length,
).fill(0);
return [...left, ...zeros, ...right];
}
@@ -0,0 +1,43 @@
import { buildAttachmentDisposition } from './content-disposition';
describe('buildAttachmentDisposition', () => {
it('ASCII 이름은 그대로 두 형식 모두에 넣는다', () => {
expect(buildAttachmentDisposition('report.pdf')).toBe(
`attachment; filename="report.pdf"; filename*=UTF-8''report.pdf`,
);
});
it('한글 이름은 filename* 로 퍼센트 인코딩하고 ASCII 대체 이름을 함께 준다', () => {
const header = buildAttachmentDisposition('체크리스트.xlsx');
expect(header).toContain(`filename="_____.xlsx"`);
expect(header).toContain(
`filename*=UTF-8''${encodeURIComponent('체크리스트.xlsx')}`,
);
});
it('따옴표·역슬래시·퍼센트는 대체 이름에서 치환한다 (헤더 깨짐 방지)', () => {
const header = buildAttachmentDisposition('a"b\\c%d.txt');
expect(header).toContain(`filename="a_b_c_d.txt"`);
});
it('줄바꿈 같은 제어 문자로 헤더를 주입할 수 없다', () => {
const header = buildAttachmentDisposition('evil\r\nSet-Cookie: x=1.txt');
expect(header).not.toMatch(/[\r\n]/);
expect(header).toContain('%0D%0A');
});
it("RFC 5987 에서 허용되지 않는 '()* 도 인코딩한다", () => {
expect(buildAttachmentDisposition("it's (1)*.txt")).toContain(
`filename*=UTF-8''it%27s%20%281%29%2A.txt`,
);
});
it('빈 이름이면 기본 이름을 쓴다', () => {
expect(buildAttachmentDisposition('')).toBe(
`attachment; filename="download"; filename*=UTF-8''download`,
);
});
});
@@ -0,0 +1,42 @@
/**
* Content-Disposition 헤더 생성 유틸 (순수 함수).
*
* 한글 파일명은 RFC 5987 의 `filename*=UTF-8''…` 로 보내고,
* 이를 모르는 오래된 클라이언트를 위해 ASCII 로만 된 `filename="…"` 을 함께 준다 (RFC 6266).
*/
/** ASCII 대체 이름을 만들 수 없을 때 쓰는 이름 */
const FALLBACK_FILE_NAME = 'download';
/** 출력 가능한 ASCII(0x20~0x7E) 밖의 문자 — 대체 이름에서 '_' 로 바꾼다 */
const NON_PRINTABLE_ASCII = /[^\x20-\x7e]/g;
/** 따옴표 문자열 안에서 의미가 있는 문자 — 일부 브라우저는 '%' 도 해석하므로 함께 바꾼다 */
const QUOTED_STRING_SPECIALS = /["\\%]/g;
/** encodeURIComponent 가 남기지만 RFC 5987 attr-char 가 아닌 문자 */
const RFC5987_EXTRA = /['()*]/g;
function toAsciiFallback(fileName: string): string {
const replaced = fileName
.replace(NON_PRINTABLE_ASCII, '_')
.replace(QUOTED_STRING_SPECIALS, '_')
.trim();
return replaced === '' ? FALLBACK_FILE_NAME : replaced;
}
function encodeRfc5987(value: string): string {
return encodeURIComponent(value).replace(
RFC5987_EXTRA,
(char) => `%${char.charCodeAt(0).toString(16).toUpperCase()}`,
);
}
/**
* 다운로드용 `attachment` 헤더 값을 만든다.
* 예) '체크리스트.xlsx' → `attachment; filename="_____.xlsx"; filename*=UTF-8''%EC%B2%B4...`
*/
export function buildAttachmentDisposition(fileName: string): string {
const name = fileName === '' ? FALLBACK_FILE_NAME : fileName;
return `attachment; filename="${toAsciiFallback(name)}"; filename*=UTF-8''${encodeRfc5987(name)}`;
}
+24
View File
@@ -0,0 +1,24 @@
import { readCookie } from './cookies';
describe('readCookie', () => {
it('문자열 쿠키를 꺼낸다', () => {
expect(
readCookie({ cookies: { access_token: 'abc' } }, 'access_token'),
).toBe('abc');
});
it('쿠키가 없거나 cookie-parser 가 등록되지 않았으면 undefined', () => {
expect(readCookie({ cookies: {} }, 'access_token')).toBeUndefined();
expect(readCookie({}, 'access_token')).toBeUndefined();
expect(readCookie({ cookies: null }, 'access_token')).toBeUndefined();
});
it('문자열이 아니거나 빈 값이면 undefined (JSON 쿠키 등)', () => {
expect(
readCookie({ cookies: { access_token: { x: 1 } } }, 'access_token'),
).toBeUndefined();
expect(readCookie({ cookies: { access_token: '' } }, 'access_token')).toBe(
undefined,
);
});
});
+16
View File
@@ -0,0 +1,16 @@
/**
* 쿠키 읽기 유틸 (순수 함수).
*
* `req.cookies` 는 cookie-parser 가 채우며 타입이 `Record<string, any>` 라,
* 값이 문자열인지 확인한 뒤에만 꺼낸다 (JSON 쿠키·미등록 미들웨어 대비).
*/
export function readCookie(
request: { cookies?: unknown },
name: string,
): string | undefined {
const { cookies } = request;
if (typeof cookies !== 'object' || cookies === null) return undefined;
const value: unknown = (cookies as Record<string, unknown>)[name];
return typeof value === 'string' && value !== '' ? value : undefined;
}
+33
View File
@@ -0,0 +1,33 @@
import { parseDurationSeconds } from './duration';
describe('parseDurationSeconds', () => {
it('단위가 없으면 초로 해석한다', () => {
expect(parseDurationSeconds('3600')).toBe(3600);
});
it('s·m·h·d 단위를 초로 바꾼다', () => {
expect(parseDurationSeconds('30s')).toBe(30);
expect(parseDurationSeconds('15m')).toBe(15 * 60);
expect(parseDurationSeconds('1h')).toBe(60 * 60);
expect(parseDurationSeconds('14d')).toBe(14 * 24 * 60 * 60);
});
it('대소문자와 앞뒤 공백을 허용한다', () => {
expect(parseDurationSeconds(' 2H ')).toBe(2 * 60 * 60);
});
it('값이 없으면 null 이다 (호출부가 기본값을 정한다)', () => {
expect(parseDurationSeconds(undefined)).toBeNull();
expect(parseDurationSeconds('')).toBeNull();
expect(parseDurationSeconds(' ')).toBeNull();
});
it('지원하지 않는 형식이나 0 이하는 null 이다', () => {
expect(parseDurationSeconds('1.5h')).toBeNull();
expect(parseDurationSeconds('2 days')).toBeNull();
expect(parseDurationSeconds('1w')).toBeNull();
expect(parseDurationSeconds('-1h')).toBeNull();
expect(parseDurationSeconds('0')).toBeNull();
expect(parseDurationSeconds('abc')).toBeNull();
});
});

Some files were not shown because too many files have changed in this diff Show More